feat: expand SGS hall POI adaptation
This commit is contained in:
27
.superpowers/sdd/task-2-report.md
Normal file
27
.superpowers/sdd/task-2-report.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Task 2 Report
|
||||
|
||||
Status: DONE_WITH_CONCERNS
|
||||
|
||||
Files changed:
|
||||
- `src/data/adapters/sgsSdkGuideAdapter.ts`
|
||||
- `src/domain/museum.ts`
|
||||
|
||||
Commands run:
|
||||
- `node scripts/check-sgs-hall-poi-adapter.mjs`
|
||||
- Output: `SGS hall POI adapter smoke check passed`
|
||||
- `pnpm type-check`
|
||||
- Output summary: `vue-tsc --noEmit` completed successfully.
|
||||
|
||||
Commits created:
|
||||
- `08a6bcb` in implementer worktree: `feat: expand SGS hall POI adaptation`
|
||||
- Main workspace integrated manually because cherry-pick was blocked by existing local changes to the same files.
|
||||
|
||||
Self-review notes:
|
||||
- Adapter now includes public exhibition-space whitelist and keyword checks.
|
||||
- `toMuseumHallPoisFromSgs` now supports entrance anchors, space-center fallback, standalone space fallback, and diagnostics helper.
|
||||
- Main integration preserves stronger Task 1 semantics approved by user: `backend-sgs-sdk-hall-entrance` requires both entrance coordinates and route node, and later real entrance anchors can upgrade a space-center POI.
|
||||
- `src/domain/museum.ts` already had/keeps typed hall fields (`MuseumPoiKind`, `MuseumPoiEntrance`, hall/source fields, preview fields) required by adapter output.
|
||||
|
||||
Concerns:
|
||||
- Manual integration was required because the main workspace already had overlapping uncommitted changes in adapter/domain files.
|
||||
- Task 3 will add repository diagnostics consumption of `createSgsHallPoiDiagnostics`.
|
||||
902
docs/superpowers/plans/2026-06-29-sgs-hall-poi-display.md
Normal file
902
docs/superpowers/plans/2026-06-29-sgs-hall-poi-display.md
Normal file
@@ -0,0 +1,902 @@
|
||||
# SGS Hall POI Display Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make SGS SDK mode generate and display exhibition hall/public exhibition-space POIs reliably in the single-floor 3D model, with diagnostics for silent data failures.
|
||||
|
||||
**Architecture:** Keep the existing provider/adapter/repository/renderer boundary. Expand SGS `spaces + navigablePlaces` adaptation inside `sgsSdkGuideAdapter.ts`, keep `GuideModelRepository` as the model-POI source, and keep `ThreeMap` consuming `GuideRenderPoi` without reading SGS payloads directly.
|
||||
|
||||
**Tech Stack:** Vue 3, uni-app H5, TypeScript 5.3, Three.js 0.163, pnpm, `vue-tsc`, ESLint.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Default runtime target is H5; do not add mini-program/mp-weixin work.
|
||||
- Pages and presentation components must not import or parse `/app-api/gis/sdk/*` response shapes.
|
||||
- Keep guide capability wording as “位置预览” / “查看位置”; do not claim certified navigation.
|
||||
- `spaces` represent museum spaces; `navigablePlaces` represent entrances/reachable destinations; route planning can only use entrance route nodes.
|
||||
- Do not add dependencies.
|
||||
- Avoid unrelated refactors and broad data cleanup.
|
||||
- Validation baseline after code changes: `pnpm type-check`, `pnpm lint`, `pnpm build:h5`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify: `src/data/adapters/sgsSdkGuideAdapter.ts`
|
||||
- Owns SGS payload-to-domain conversion.
|
||||
- Add public helpers for eligible exhibition-space classification, hall-place matching, space center fallback, and hall POI diagnostics.
|
||||
- Keep raw SGS fields contained in this adapter.
|
||||
|
||||
- Modify: `src/repositories/GuideModelRepository.ts`
|
||||
- Owns model render package and floor render POI loading.
|
||||
- Add development diagnostics around failed SGS floor data requests and hall POI generation counts.
|
||||
- Continue returning only `GuideRenderPoi[]` to `ThreeMap`.
|
||||
|
||||
- Modify: `src/components/map/ThreeMap.vue`
|
||||
- Owns 3D presentation and POI visibility.
|
||||
- Confirm floor/detail mode does not hide valid hall POIs through distance/limit pruning. If necessary, add a focused guard so hall POIs remain visible in floor mode only.
|
||||
|
||||
- Create: `scripts/check-sgs-hall-poi-adapter.mjs`
|
||||
- Node-based smoke script for the adapter algorithm using representative SGS payload fixtures.
|
||||
- It does not import TypeScript project files, so no new test runner is required.
|
||||
- It verifies expected hall/public-space POI outputs, classification behavior, entrance-vs-space-center source semantics, entrance-anchor upgrade behavior, and ID-based assertions.
|
||||
|
||||
- Reference: `docs/superpowers/specs/2026-06-29-sgs-hall-poi-display-design.md`
|
||||
- Implementation must match this spec.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Adapter classification and fallback smoke coverage
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/check-sgs-hall-poi-adapter.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: No project imports. Uses local JS functions mirroring the intended adapter behavior.
|
||||
- Produces: A runnable smoke command: `node scripts/check-sgs-hall-poi-adapter.mjs`.
|
||||
|
||||
- [ ] **Step 1: Create the failing smoke script**
|
||||
|
||||
Create `scripts/check-sgs-hall-poi-adapter.mjs` with this exact content:
|
||||
|
||||
```js
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const exhibitionSpaceTypeWhitelist = new Set([
|
||||
'exhibition_hall',
|
||||
'theater',
|
||||
'education_activity',
|
||||
'ramp'
|
||||
])
|
||||
|
||||
const exhibitionSpaceKeywords = [
|
||||
'展厅',
|
||||
'临展',
|
||||
'展览',
|
||||
'影院',
|
||||
'球幕',
|
||||
'巨幕',
|
||||
'报告厅',
|
||||
'活动室',
|
||||
'科普',
|
||||
'实验室',
|
||||
'展览坡道',
|
||||
'宇宙',
|
||||
'地球',
|
||||
'演化',
|
||||
'恐龙',
|
||||
'人类',
|
||||
'生物',
|
||||
'生态',
|
||||
'家园'
|
||||
]
|
||||
|
||||
const normalizedText = (value) => (value || '').trim()
|
||||
const normalizedHallName = (value) => normalizedText(value).replace(/[\s()()【】\[\]_-]/g, '')
|
||||
const hasExhibitionKeyword = (value) => exhibitionSpaceKeywords.some((keyword) => normalizedText(value).includes(keyword))
|
||||
|
||||
const isEligiblePublicExhibitionSpace = (space) => {
|
||||
const type = normalizedText(space.type).toLowerCase()
|
||||
const name = normalizedText(space.name)
|
||||
|
||||
if (type === 'exhibition_hall') return true
|
||||
if (type === 'theater') return hasExhibitionKeyword(name)
|
||||
if (type === 'education_activity') return hasExhibitionKeyword(name)
|
||||
if (type === 'ramp') return name.includes('展览坡道')
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const placeHallName = (place) => (
|
||||
normalizedText(place.ownerName)
|
||||
|| normalizedText(place.name)
|
||||
.replace(/(?:主)?(?:出入口|入口|出口|门)\s*[A-Za-z0-9一二三四五六七八九十号-]*$/u, '')
|
||||
.trim()
|
||||
|| normalizedText(place.name)
|
||||
)
|
||||
|
||||
const isExhibitionHallNavigablePlace = (place) => {
|
||||
const searchableText = [
|
||||
place.name,
|
||||
place.ownerName,
|
||||
place.category,
|
||||
place.type,
|
||||
place.typeCode,
|
||||
place.typeName
|
||||
]
|
||||
.map(normalizedText)
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
return hasExhibitionKeyword(searchableText)
|
||||
}
|
||||
|
||||
const findMatchedHallSpace = (place, spaces) => {
|
||||
const placeName = normalizedHallName(placeHallName(place))
|
||||
if (!placeName) return undefined
|
||||
|
||||
return spaces.find((space) => {
|
||||
const spaceName = normalizedHallName(space.name)
|
||||
return spaceName && (placeName.includes(spaceName) || spaceName.includes(placeName))
|
||||
})
|
||||
}
|
||||
|
||||
const normalizePositionSource = (source) => {
|
||||
const x = Number(source?.x)
|
||||
const y = Number(source?.y)
|
||||
const z = Number(source?.z)
|
||||
if (![x, y, z].every(Number.isFinite)) return undefined
|
||||
return [x, y, z]
|
||||
}
|
||||
|
||||
const normalizePlacePosition = (place) => normalizePositionSource(
|
||||
place.position
|
||||
? { x: place.position.x, y: place.position.y ?? 0, z: place.position.z }
|
||||
: { x: place.x, y: place.y ?? 0, z: place.z }
|
||||
)
|
||||
|
||||
const normalizeSpaceCenter = (space) => normalizePositionSource(space.center || {})
|
||||
|
||||
const createHallPoiId = (space, place, fallbackIndex) => {
|
||||
if (space?.id) return `hall-${space.id}`
|
||||
const ownerName = normalizedHallName(placeHallName(place))
|
||||
if (ownerName) return `hall-place-${ownerName}`
|
||||
return `hall-place-${place.id || fallbackIndex + 1}`
|
||||
}
|
||||
|
||||
const toHallPois = (spaces, navigablePlaces, fallbackFloorId) => {
|
||||
const hallSpaces = spaces.filter(isEligiblePublicExhibitionSpace)
|
||||
const hallPlaces = navigablePlaces.filter(isExhibitionHallNavigablePlace)
|
||||
const halls = new Map()
|
||||
|
||||
hallPlaces.forEach((place, index) => {
|
||||
const matchedSpace = findMatchedHallSpace(place, hallSpaces)
|
||||
const entrancePosition = normalizePlacePosition(place)
|
||||
const fallbackPosition = matchedSpace ? normalizeSpaceCenter(matchedSpace) : undefined
|
||||
const position = entrancePosition || fallbackPosition
|
||||
const poiId = createHallPoiId(matchedSpace, place, index)
|
||||
|
||||
if (!position) return
|
||||
|
||||
const existing = halls.get(poiId)
|
||||
if (existing) {
|
||||
existing.entrances.push({ name: place.name, positionGltf: entrancePosition })
|
||||
return
|
||||
}
|
||||
|
||||
halls.set(poiId, {
|
||||
id: poiId,
|
||||
name: matchedSpace?.name || placeHallName(place),
|
||||
floorId: String(place.floorId || matchedSpace?.floorId || fallbackFloorId),
|
||||
primaryCategory: 'exhibition_hall',
|
||||
positionGltf: position,
|
||||
sourceConfidence: entrancePosition ? 'backend-sgs-sdk-hall-entrance' : 'backend-sgs-sdk-space-center',
|
||||
entrances: [{ name: place.name, positionGltf: entrancePosition }]
|
||||
})
|
||||
})
|
||||
|
||||
hallSpaces.forEach((space, index) => {
|
||||
const poiId = `hall-${space.id}`
|
||||
if (halls.has(poiId)) return
|
||||
const position = normalizeSpaceCenter(space)
|
||||
if (!position) return
|
||||
halls.set(poiId, {
|
||||
id: poiId,
|
||||
name: space.name,
|
||||
floorId: String(space.floorId || fallbackFloorId),
|
||||
primaryCategory: 'exhibition_hall',
|
||||
positionGltf: position,
|
||||
sourceConfidence: 'backend-sgs-sdk-space-center',
|
||||
entrances: []
|
||||
})
|
||||
})
|
||||
|
||||
return Array.from(halls.values())
|
||||
}
|
||||
|
||||
const spaces = [
|
||||
{ id: 1, name: '展厅1宇宙厅', type: 'exhibition_hall', floorId: 'B2', center: { x: 10, y: 0, z: 20 } },
|
||||
{ id: 2, name: '巨幕影院', type: 'theater', floorId: 'L1', center: { x: 30, y: 0, z: 40 } },
|
||||
{ id: 3, name: '博物馆之友活动室', type: 'education_activity', floorId: 'L4', center: { x: 50, y: 0, z: 60 } },
|
||||
{ id: 4, name: '展览坡道', type: 'ramp', floorId: 'L1.5', center: { x: 70, y: 0, z: 80 } },
|
||||
{ id: 5, name: '茶水间', type: 'service', floorId: 'L1', center: { x: 90, y: 0, z: 100 } }
|
||||
]
|
||||
|
||||
const places = [
|
||||
{ id: 101, name: '展厅1宇宙厅 出入口 1', ownerName: '展厅1宇宙厅', floorId: 'B2', position: { x: 11, y: 0, z: 21 }, nodeId: 'n101' },
|
||||
{ id: 102, name: '巨幕影院 出入口 1', ownerName: '巨幕影院', floorId: 'L1', position: { x: 31, y: 0, z: 41 }, nodeId: 'n102' }
|
||||
]
|
||||
|
||||
const result = toHallPois(spaces, places, 'B2')
|
||||
const byName = new Map(result.map((poi) => [poi.name, poi]))
|
||||
|
||||
assert.equal(result.length, 4)
|
||||
assert.deepEqual(byName.get('展厅1宇宙厅').positionGltf, [11, 0, 21])
|
||||
assert.deepEqual(byName.get('巨幕影院').positionGltf, [31, 0, 41])
|
||||
assert.deepEqual(byName.get('博物馆之友活动室').positionGltf, [50, 0, 60])
|
||||
assert.deepEqual(byName.get('展览坡道').positionGltf, [70, 0, 80])
|
||||
assert.equal(byName.has('茶水间'), false)
|
||||
assert.equal(byName.get('博物馆之友活动室').sourceConfidence, 'backend-sgs-sdk-space-center')
|
||||
|
||||
console.log('SGS hall POI adapter smoke check passed')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the smoke script and verify current intended behavior is captured**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
node scripts/check-sgs-hall-poi-adapter.mjs
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```text
|
||||
SGS hall POI adapter smoke check passed
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit the smoke coverage**
|
||||
|
||||
```powershell
|
||||
git add scripts/check-sgs-hall-poi-adapter.mjs docs/superpowers/specs/2026-06-29-sgs-hall-poi-display-design.md
|
||||
git commit -m "test: cover SGS hall POI display rules"
|
||||
```
|
||||
|
||||
If the working tree already contains unrelated user changes, commit only the two listed files.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Extend SGS hall-space adapter behavior
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/data/adapters/sgsSdkGuideAdapter.ts:122-428`
|
||||
- Test: `scripts/check-sgs-hall-poi-adapter.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `SgsSpacePayload.center?: SgsPositionPayload | null`, `SgsNavigablePlacePayload`, and existing `toMuseumHallPoisFromSgs(spaces, navigablePlaces, floors, fallbackFloorId): MuseumPoi[]`.
|
||||
- Produces:
|
||||
- `isExhibitionHallSpace(space: SgsSpacePayload): boolean` now returns true for public exhibition spaces.
|
||||
- `toMuseumHallPoisFromSgs(...)` generates hall POIs from entrance coordinates and space-center fallback.
|
||||
- `createSgsHallPoiDiagnostics(spaces, navigablePlaces, hallPois): SgsHallPoiDiagnostics` for repository diagnostics.
|
||||
|
||||
- [ ] **Step 1: Add classification constants and diagnostics types**
|
||||
|
||||
In `src/data/adapters/sgsSdkGuideAdapter.ts`, replace the current `exhibitionHallKeywords` block at lines around `263-274` with:
|
||||
|
||||
```ts
|
||||
const exhibitionSpaceTypeWhitelist = new Set([
|
||||
'exhibition_hall',
|
||||
'theater',
|
||||
'education_activity',
|
||||
'ramp'
|
||||
])
|
||||
|
||||
const exhibitionHallKeywords = [
|
||||
'展厅',
|
||||
'临展',
|
||||
'展览',
|
||||
'影院',
|
||||
'球幕',
|
||||
'巨幕',
|
||||
'报告厅',
|
||||
'活动室',
|
||||
'科普',
|
||||
'实验室',
|
||||
'展览坡道',
|
||||
'宇宙',
|
||||
'地球',
|
||||
'演化',
|
||||
'恐龙',
|
||||
'人类',
|
||||
'生物',
|
||||
'生态',
|
||||
'家园'
|
||||
]
|
||||
|
||||
export interface SgsHallPoiDiagnostics {
|
||||
spaceCount: number
|
||||
eligibleSpaceCount: number
|
||||
navigablePlaceCount: number
|
||||
hallPlaceCount: number
|
||||
hallPoiCount: number
|
||||
hallPoiWithPositionCount: number
|
||||
skippedSpaceCount: number
|
||||
skippedPlaceCount: number
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add keyword and center helpers**
|
||||
|
||||
Immediately after the constants from Step 1, add:
|
||||
|
||||
```ts
|
||||
const hasExhibitionKeyword = (value?: string | null) => (
|
||||
exhibitionHallKeywords.some((keyword) => normalizedText(value).includes(keyword))
|
||||
)
|
||||
|
||||
const searchableSpaceText = (space: SgsSpacePayload) => [
|
||||
space.name,
|
||||
space.type,
|
||||
space.sourceNodeName
|
||||
]
|
||||
.map((value) => normalizedText(value))
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
const normalizeSpaceCenter = (space: SgsSpacePayload): [number, number, number] | undefined => normalizePositionSource(
|
||||
space.center || {
|
||||
x: undefined,
|
||||
y: undefined,
|
||||
z: undefined
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace `isExhibitionHallSpace` implementation**
|
||||
|
||||
Replace the existing function:
|
||||
|
||||
```ts
|
||||
export const isExhibitionHallSpace = (space: SgsSpacePayload) => (
|
||||
normalizedText(space.type).toLowerCase() === 'exhibition_hall'
|
||||
)
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
export const isExhibitionHallSpace = (space: SgsSpacePayload) => {
|
||||
const type = normalizedText(space.type).toLowerCase()
|
||||
const name = normalizedText(space.name)
|
||||
const searchableText = searchableSpaceText(space)
|
||||
|
||||
if (!exhibitionSpaceTypeWhitelist.has(type)) return false
|
||||
if (type === 'exhibition_hall') return true
|
||||
if (type === 'ramp') return name.includes('展览坡道')
|
||||
|
||||
return hasExhibitionKeyword(searchableText)
|
||||
}
|
||||
```
|
||||
|
||||
This keeps non-public service spaces such as 茶水间 out of hall POIs.
|
||||
|
||||
- [ ] **Step 4: Update `isExhibitionHallNavigablePlace` to use the shared keyword helper**
|
||||
|
||||
Inside `isExhibitionHallNavigablePlace`, replace:
|
||||
|
||||
```ts
|
||||
return exhibitionHallKeywords.some((keyword) => searchableText.includes(keyword))
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
return hasExhibitionKeyword(searchableText)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add space fallback hall POI helper**
|
||||
|
||||
After `createHallPoiId(...)`, add:
|
||||
|
||||
```ts
|
||||
const createSpaceFallbackHallPoi = (
|
||||
space: SgsSpacePayload,
|
||||
floors: SgsSdkFloorSummaryPayload[],
|
||||
fallbackFloorId: string
|
||||
): MuseumPoi | null => {
|
||||
const floorId = stringifyId(space.floorId) || fallbackFloorId
|
||||
const position = normalizeSpaceCenter(space)
|
||||
const hallId = stringifyId(space.id)
|
||||
|
||||
if (!hallId || !normalizedText(space.name) || !position) return null
|
||||
|
||||
return {
|
||||
id: `hall-${hallId}`,
|
||||
name: normalizedText(space.name),
|
||||
floorId,
|
||||
floorLabel: floorLabelForSgs(floorId, floors),
|
||||
primaryCategory: hallCategory,
|
||||
categories: [
|
||||
hallCategory
|
||||
],
|
||||
positionGltf: position,
|
||||
sourceObjectName: space.sourceNodeName || undefined,
|
||||
sourceConfidence: 'backend-sgs-sdk-space-center',
|
||||
navigationReadiness: '位置预览',
|
||||
accessible: false,
|
||||
kind: 'hall',
|
||||
hallId,
|
||||
hallName: normalizedText(space.name),
|
||||
spaceId: hallId,
|
||||
sourceSpaceId: hallId,
|
||||
entrances: []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Update `toMuseumHallPoisFromSgs` to use fallback positions and space-only POIs**
|
||||
|
||||
Inside `toMuseumHallPoisFromSgs`, keep the existing function signature. Replace the body with:
|
||||
|
||||
```ts
|
||||
const hallSpaces = spaces.filter(isExhibitionHallSpace)
|
||||
const hallPlaces = navigablePlaces.filter(isExhibitionHallNavigablePlace)
|
||||
const halls = new Map<string, MuseumPoi>()
|
||||
|
||||
hallPlaces.forEach((place, index) => {
|
||||
const matchedSpace = findMatchedHallSpace(place, hallSpaces)
|
||||
const entrance = createHallEntrance(place, floors, fallbackFloorId)
|
||||
const fallbackPosition = matchedSpace ? normalizeSpaceCenter(matchedSpace) : undefined
|
||||
const floorId = entrance.floorId || stringifyId(matchedSpace?.floorId) || fallbackFloorId
|
||||
const hallId = stringifyId(matchedSpace?.id) || undefined
|
||||
const poiId = createHallPoiId(matchedSpace, place, index)
|
||||
const hallName = normalizedText(matchedSpace?.name) || placeHallName(place) || entrance.name
|
||||
const existing = halls.get(poiId)
|
||||
|
||||
if (existing) {
|
||||
existing.entrances = [
|
||||
...(existing.entrances || []),
|
||||
entrance
|
||||
]
|
||||
if (!existing.positionGltf && (entrance.positionGltf || fallbackPosition)) {
|
||||
existing.positionGltf = entrance.positionGltf || fallbackPosition
|
||||
existing.floorId = floorId
|
||||
existing.floorLabel = entrance.floorLabel
|
||||
existing.sourceObjectName = entrance.sourceObjectName || matchedSpace?.sourceNodeName || undefined
|
||||
existing.sourcePlaceId = entrance.sourcePlaceId
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const positionGltf = entrance.positionGltf || fallbackPosition
|
||||
if (!positionGltf) return
|
||||
|
||||
halls.set(poiId, {
|
||||
id: poiId,
|
||||
name: hallName,
|
||||
floorId,
|
||||
floorLabel: entrance.floorLabel,
|
||||
primaryCategory: hallCategory,
|
||||
categories: [
|
||||
hallCategory,
|
||||
hallEntranceCategory
|
||||
],
|
||||
positionGltf,
|
||||
sourceObjectName: entrance.sourceObjectName || matchedSpace?.sourceNodeName || undefined,
|
||||
sourceConfidence: entrance.positionGltf
|
||||
? 'backend-sgs-sdk-hall-entrance'
|
||||
: 'backend-sgs-sdk-space-center',
|
||||
navigationReadiness: '位置预览',
|
||||
accessible: false,
|
||||
kind: 'hall',
|
||||
hallId,
|
||||
hallName,
|
||||
spaceId: hallId,
|
||||
sourcePlaceId: entrance.sourcePlaceId,
|
||||
sourceSpaceId: hallId,
|
||||
entrances: [entrance]
|
||||
})
|
||||
})
|
||||
|
||||
hallSpaces.forEach((space) => {
|
||||
const hallId = stringifyId(space.id)
|
||||
if (!hallId || halls.has(`hall-${hallId}`)) return
|
||||
|
||||
const fallbackPoi = createSpaceFallbackHallPoi(space, floors, fallbackFloorId)
|
||||
if (fallbackPoi) {
|
||||
halls.set(fallbackPoi.id, fallbackPoi)
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(halls.values())
|
||||
.filter((poi) => poi.id && poi.name && poi.positionGltf)
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Add diagnostics helper**
|
||||
|
||||
After `toMuseumHallPoisFromSgs`, add:
|
||||
|
||||
```ts
|
||||
export const createSgsHallPoiDiagnostics = (
|
||||
spaces: SgsSpacePayload[],
|
||||
navigablePlaces: SgsNavigablePlacePayload[],
|
||||
hallPois: MuseumPoi[]
|
||||
): SgsHallPoiDiagnostics => {
|
||||
const eligibleSpaceCount = spaces.filter(isExhibitionHallSpace).length
|
||||
const hallPlaceCount = navigablePlaces.filter(isExhibitionHallNavigablePlace).length
|
||||
const hallPoiWithPositionCount = hallPois.filter((poi) => Boolean(poi.positionGltf)).length
|
||||
|
||||
return {
|
||||
spaceCount: spaces.length,
|
||||
eligibleSpaceCount,
|
||||
navigablePlaceCount: navigablePlaces.length,
|
||||
hallPlaceCount,
|
||||
hallPoiCount: hallPois.length,
|
||||
hallPoiWithPositionCount,
|
||||
skippedSpaceCount: Math.max(eligibleSpaceCount - hallPois.length, 0),
|
||||
skippedPlaceCount: Math.max(hallPlaceCount - hallPois.reduce((count, poi) => count + (poi.entrances?.length || 0), 0), 0)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Run the adapter smoke script**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
node scripts/check-sgs-hall-poi-adapter.mjs
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```text
|
||||
SGS hall POI adapter smoke check passed
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Run type-check for adapter changes**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
pnpm type-check
|
||||
```
|
||||
|
||||
Expected: exits 0. If it fails, fix TypeScript errors in `sgsSdkGuideAdapter.ts` only before continuing.
|
||||
|
||||
- [ ] **Step 10: Commit adapter behavior**
|
||||
|
||||
```powershell
|
||||
git add src/data/adapters/sgsSdkGuideAdapter.ts scripts/check-sgs-hall-poi-adapter.mjs
|
||||
git commit -m "feat: expand SGS hall POI adaptation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add SGS model POI diagnostics in repository
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/repositories/GuideModelRepository.ts:14-244`
|
||||
- Test: `scripts/check-sgs-hall-poi-adapter.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `createSgsHallPoiDiagnostics(spaces, navigablePlaces, hallPois)` from `sgsSdkGuideAdapter.ts`.
|
||||
- Produces: Development-only console warnings for failed SGS floor data endpoints and suspicious empty hall POI generation.
|
||||
|
||||
- [ ] **Step 1: Update imports**
|
||||
|
||||
In `src/repositories/GuideModelRepository.ts`, replace:
|
||||
|
||||
```ts
|
||||
import {
|
||||
formatSgsFloorLabel,
|
||||
toMuseumHallPoisFromSgs,
|
||||
toMuseumPoiFromSgs
|
||||
} from '@/data/adapters/sgsSdkGuideAdapter'
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
import {
|
||||
createSgsHallPoiDiagnostics,
|
||||
formatSgsFloorLabel,
|
||||
toMuseumHallPoisFromSgs,
|
||||
toMuseumPoiFromSgs
|
||||
} from '@/data/adapters/sgsSdkGuideAdapter'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add development warning helper**
|
||||
|
||||
After `isSharedSgsModelAsset(...)`, add:
|
||||
|
||||
```ts
|
||||
const warnSgsGuideModelDiagnostics = (
|
||||
message: string,
|
||||
payload: Record<string, unknown>
|
||||
) => {
|
||||
if (!import.meta.env.DEV) return
|
||||
console.warn(`[SGS guide model] ${message}`, payload)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add safe request helper inside `loadFloorPois`**
|
||||
|
||||
Inside `SgsSdkGuideModelRepository.loadFloorPois`, immediately after:
|
||||
|
||||
```ts
|
||||
const floorId = String(matchedFloor.floorId)
|
||||
```
|
||||
|
||||
add this local helper:
|
||||
|
||||
```ts
|
||||
const loadFloorData = async <T>(
|
||||
endpoint: 'pois' | 'spaces' | 'navigablePlaces',
|
||||
loader: () => Promise<T[]>
|
||||
) => {
|
||||
try {
|
||||
return await loader()
|
||||
} catch (error) {
|
||||
warnSgsGuideModelDiagnostics('floor data request failed', {
|
||||
floorId,
|
||||
endpoint,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Replace silent catch requests**
|
||||
|
||||
Replace:
|
||||
|
||||
```ts
|
||||
const [pois, spaces, navigablePlaces] = await Promise.all([
|
||||
this.provider.getFloorPois(floorId).catch(() => []),
|
||||
this.provider.getFloorSpaces(floorId).catch(() => []),
|
||||
this.provider.getNavigablePlaces(floorId).catch(() => [])
|
||||
])
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
const [pois, spaces, navigablePlaces] = await Promise.all([
|
||||
loadFloorData('pois', () => this.provider.getFloorPois(floorId)),
|
||||
loadFloorData('spaces', () => this.provider.getFloorSpaces(floorId)),
|
||||
loadFloorData('navigablePlaces', () => this.provider.getNavigablePlaces(floorId))
|
||||
])
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add hall generation diagnostics**
|
||||
|
||||
Replace:
|
||||
|
||||
```ts
|
||||
const ordinaryPois = pois.map((poi) => toSgsRenderPoi(toMuseumPoiFromSgs(poi, manifest.floors)))
|
||||
const hallPois = toMuseumHallPoisFromSgs(spaces, navigablePlaces, manifest.floors, floorId)
|
||||
.map(toSgsRenderPoi)
|
||||
|
||||
return dedupeRenderPoisById([
|
||||
...hallPois,
|
||||
...ordinaryPois
|
||||
])
|
||||
.filter((poi) => Array.isArray(poi.positionGltf) && poi.positionGltf.length === 3)
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
const ordinaryPois = pois.map((poi) => toSgsRenderPoi(toMuseumPoiFromSgs(poi, manifest.floors)))
|
||||
const museumHallPois = toMuseumHallPoisFromSgs(spaces, navigablePlaces, manifest.floors, floorId)
|
||||
const hallDiagnostics = createSgsHallPoiDiagnostics(spaces, navigablePlaces, museumHallPois)
|
||||
|
||||
if (hallDiagnostics.eligibleSpaceCount > 0 && hallDiagnostics.hallPoiWithPositionCount === 0) {
|
||||
warnSgsGuideModelDiagnostics('eligible hall spaces produced no renderable hall POIs', {
|
||||
floorId,
|
||||
...hallDiagnostics
|
||||
})
|
||||
}
|
||||
|
||||
const hallPois = museumHallPois.map(toSgsRenderPoi)
|
||||
const renderPois = dedupeRenderPoisById([
|
||||
...hallPois,
|
||||
...ordinaryPois
|
||||
])
|
||||
.filter((poi) => Array.isArray(poi.positionGltf) && poi.positionGltf.length === 3)
|
||||
|
||||
if (hallDiagnostics.hallPoiWithPositionCount > 0 && !renderPois.some((poi) => poi.primaryCategory === 'exhibition_hall')) {
|
||||
warnSgsGuideModelDiagnostics('render POI filter removed all hall POIs', {
|
||||
floorId,
|
||||
...hallDiagnostics,
|
||||
renderPoiCount: renderPois.length
|
||||
})
|
||||
}
|
||||
|
||||
return renderPois
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run smoke and type-check**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
node scripts/check-sgs-hall-poi-adapter.mjs
|
||||
pnpm type-check
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
SGS hall POI adapter smoke check passed
|
||||
```
|
||||
|
||||
and `pnpm type-check` exits 0.
|
||||
|
||||
- [ ] **Step 7: Commit repository diagnostics**
|
||||
|
||||
```powershell
|
||||
git add src/repositories/GuideModelRepository.ts src/data/adapters/sgsSdkGuideAdapter.ts scripts/check-sgs-hall-poi-adapter.mjs
|
||||
git commit -m "chore: expose SGS hall POI diagnostics"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Confirm floor-mode hall POI visibility
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/map/ThreeMap.vue:650-749` only if source inspection shows floor mode can still hide hall POIs.
|
||||
- Test: `scripts/check-sgs-hall-poi-adapter.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `RenderPoi.primaryCategory === 'exhibition_hall'` / `'exhibition_hall_entrance'`.
|
||||
- Produces: In floor/detail mode, valid hall POIs are not hidden by distance/limit pruning.
|
||||
|
||||
- [ ] **Step 1: Inspect current floor visibility behavior**
|
||||
|
||||
Open `src/components/map/ThreeMap.vue` around `getPoiVisibilityTier` and confirm this current behavior exists:
|
||||
|
||||
```ts
|
||||
const getPoiVisibilityTier = (): PoiVisibilityTier => {
|
||||
if (activeView.value === 'floor') return 'full'
|
||||
if (!controls || !activeModel) return 'full'
|
||||
```
|
||||
|
||||
If this is still present, no code change is required for floor-mode pruning because `full` makes `getPoiVisibilityLimit()` return infinity and `getPoiScreenSpacing()` return 0.
|
||||
|
||||
- [ ] **Step 2: Only if floor mode no longer returns `full`, add hall visibility guard**
|
||||
|
||||
If Step 1 shows floor mode can be `tight` or `balanced`, update `shouldShowPoiAtDistance` to begin with:
|
||||
|
||||
```ts
|
||||
if (activeView.value === 'floor' && (
|
||||
poi.primaryCategory === 'exhibition_hall'
|
||||
|| poi.primaryCategory === 'exhibition_hall_entrance'
|
||||
)) {
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
Do not add this guard if `activeView.value === 'floor'` already returns `full`.
|
||||
|
||||
- [ ] **Step 3: Run type-check**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
pnpm type-check
|
||||
```
|
||||
|
||||
Expected: exits 0.
|
||||
|
||||
- [ ] **Step 4: Commit visibility confirmation or change**
|
||||
|
||||
If no code change was needed, do not create an empty commit. Record the finding in the final implementation summary.
|
||||
|
||||
If code changed, run:
|
||||
|
||||
```powershell
|
||||
git add src/components/map/ThreeMap.vue
|
||||
git commit -m "fix: keep hall POIs visible in floor mode"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Final validation and handoff
|
||||
|
||||
**Files:**
|
||||
- Modify: none unless validation reveals a failure in files changed by Tasks 2-4.
|
||||
- Test: whole changed scope.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: all previous task outputs.
|
||||
- Produces: Verified working tree state and final summary.
|
||||
|
||||
- [ ] **Step 1: Run adapter smoke check**
|
||||
|
||||
```powershell
|
||||
node scripts/check-sgs-hall-poi-adapter.mjs
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
SGS hall POI adapter smoke check passed
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run type-check**
|
||||
|
||||
```powershell
|
||||
pnpm type-check
|
||||
```
|
||||
|
||||
Expected: exits 0.
|
||||
|
||||
- [ ] **Step 3: Run lint**
|
||||
|
||||
```powershell
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
Expected: exits 0. If warnings appear with exit 0, record them in the final summary.
|
||||
|
||||
- [ ] **Step 4: Run H5 build**
|
||||
|
||||
This writes `dist`, which is expected for build validation.
|
||||
|
||||
```powershell
|
||||
pnpm build:h5
|
||||
```
|
||||
|
||||
Expected: exits 0.
|
||||
|
||||
- [ ] **Step 5: Inspect changed files**
|
||||
|
||||
```powershell
|
||||
git diff -- src/data/adapters/sgsSdkGuideAdapter.ts src/repositories/GuideModelRepository.ts src/components/map/ThreeMap.vue scripts/check-sgs-hall-poi-adapter.mjs docs/superpowers/specs/2026-06-29-sgs-hall-poi-display-design.md
|
||||
```
|
||||
|
||||
Expected: diff is limited to the planned files and contains no unrelated refactor.
|
||||
|
||||
- [ ] **Step 6: Commit final validation notes if a docs update was needed**
|
||||
|
||||
If no docs changed after validation, do not commit. If validation required updating this plan or spec, commit only those docs:
|
||||
|
||||
```powershell
|
||||
git add docs/superpowers/specs/2026-06-29-sgs-hall-poi-display-design.md docs/superpowers/plans/2026-06-29-sgs-hall-poi-display.md
|
||||
git commit -m "docs: record SGS hall POI validation"
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Final response**
|
||||
|
||||
Report:
|
||||
|
||||
- Files changed.
|
||||
- How hall/public exhibition spaces are now classified.
|
||||
- Whether `ThreeMap` required a change.
|
||||
- Exact validation commands and outcomes.
|
||||
- Any skipped validation and why.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
### Spec coverage
|
||||
|
||||
- Expanded `exhibition_hall`-only classification: Task 2.
|
||||
- Added theater / education_activity / ramp handling: Task 2.
|
||||
- Added entrance-first and space-center fallback: Task 2.
|
||||
- Added diagnostics for silent request/adaptation failure: Task 3.
|
||||
- Preserved ThreeMap boundary and floor-mode visibility: Task 4.
|
||||
- Validation commands and H5 build: Task 5.
|
||||
|
||||
### Placeholder scan
|
||||
|
||||
This plan contains no TBD/TODO placeholders and every code-changing step includes exact code or exact conditional instructions.
|
||||
|
||||
### Type consistency
|
||||
|
||||
- `createSgsHallPoiDiagnostics` is defined in Task 2 and imported in Task 3 with the same name.
|
||||
- `SgsHallPoiDiagnostics` fields match Task 3 diagnostic spreading.
|
||||
- `toMuseumHallPoisFromSgs` signature is unchanged for existing callers.
|
||||
@@ -19,10 +19,12 @@ import {
|
||||
import type {
|
||||
SgsFloorDiagnosticsPayload,
|
||||
SgsMapDiagnosticsPayload,
|
||||
SgsNavigablePlacePayload,
|
||||
SgsPoiPayload,
|
||||
SgsPositionPayload,
|
||||
SgsSdkFloorSummaryPayload,
|
||||
SgsSdkManifestPayload
|
||||
SgsSdkManifestPayload,
|
||||
SgsSpacePayload
|
||||
} from '@/data/providers/sgsSdkApiProvider'
|
||||
|
||||
const defaultCategory: MuseumCategory = {
|
||||
@@ -31,6 +33,18 @@ const defaultCategory: MuseumCategory = {
|
||||
iconType: 'poi'
|
||||
}
|
||||
|
||||
const hallCategory: MuseumCategory = {
|
||||
id: 'exhibition_hall',
|
||||
label: '展厅',
|
||||
iconType: 'exhibition_hall'
|
||||
}
|
||||
|
||||
const hallEntranceCategory: MuseumCategory = {
|
||||
id: 'exhibition_hall_entrance',
|
||||
label: '展厅出入口',
|
||||
iconType: 'hall_entrance'
|
||||
}
|
||||
|
||||
const categoryBySgsType: Record<string, MuseumCategory & { accessible?: boolean }> = {
|
||||
toilet: {
|
||||
id: 'basic_service_facility',
|
||||
@@ -105,13 +119,24 @@ const optionalNumber = (value: number | null | undefined) => (
|
||||
Number.isFinite(Number(value)) ? Number(value) : undefined
|
||||
)
|
||||
|
||||
const normalizedText = (value?: string | null) => (value || '').trim()
|
||||
|
||||
const normalizePositionSource = (source: SgsPositionPayload): [number, number, number] | undefined => {
|
||||
const x = Number(source.x)
|
||||
const y = Number(source.y)
|
||||
const z = Number(source.z)
|
||||
|
||||
if (![x, y, z].every(Number.isFinite)) return undefined
|
||||
return [x, y, z]
|
||||
}
|
||||
|
||||
const normalizeStatus = (status?: string): GuideDiagnosticsStatus => {
|
||||
if (status === 'OK' || status === 'WARN' || status === 'ERROR') return status
|
||||
return 'WARN'
|
||||
}
|
||||
|
||||
export const formatSgsFloorLabel = (floorCode?: string | null, floorName?: string | null) => {
|
||||
if (floorCode === 'EXTERIOR') return '室外'
|
||||
if (floorCode === 'EXTERIOR') return '馆外'
|
||||
|
||||
const basementMatch = floorCode?.match(/^L-(\d+(?:\.\d+)?)$/)
|
||||
if (basementMatch) return `B${basementMatch[1]}`
|
||||
@@ -148,19 +173,119 @@ export const buildSgsFloorAliases = (floors: SgsSdkFloorSummaryPayload[]) => {
|
||||
return aliases
|
||||
}
|
||||
|
||||
const normalizePosition = (poi: SgsPoiPayload): [number, number, number] | undefined => {
|
||||
const source: SgsPositionPayload = poi.position || {
|
||||
const exhibitionSpaceTypeWhitelist = new Set([
|
||||
'exhibition_hall',
|
||||
'theater',
|
||||
'education_activity',
|
||||
'ramp'
|
||||
])
|
||||
|
||||
const exhibitionHallKeywords = [
|
||||
'展厅',
|
||||
'临展',
|
||||
'展览',
|
||||
'影院',
|
||||
'球幕',
|
||||
'巨幕',
|
||||
'报告厅',
|
||||
'活动室',
|
||||
'科普',
|
||||
'实验室',
|
||||
'展览坡道',
|
||||
'宇宙',
|
||||
'地球',
|
||||
'演化',
|
||||
'恐龙',
|
||||
'人类',
|
||||
'生物',
|
||||
'生态',
|
||||
'家园'
|
||||
]
|
||||
|
||||
export interface SgsHallPoiDiagnostics {
|
||||
spaceCount: number
|
||||
eligibleSpaceCount: number
|
||||
navigablePlaceCount: number
|
||||
hallPlaceCount: number
|
||||
hallPoiCount: number
|
||||
hallPoiWithPositionCount: number
|
||||
skippedSpaceCount: number
|
||||
skippedPlaceCount: number
|
||||
}
|
||||
|
||||
const hasExhibitionKeyword = (value?: string | null) => (
|
||||
exhibitionHallKeywords.some((keyword) => normalizedText(value).includes(keyword))
|
||||
)
|
||||
|
||||
const searchableSpaceText = (space: SgsSpacePayload) => [
|
||||
space.name,
|
||||
space.type,
|
||||
space.sourceNodeName
|
||||
]
|
||||
.map((value) => normalizedText(value))
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
const normalizeSpaceCenter = (space: SgsSpacePayload): [number, number, number] | undefined => normalizePositionSource(
|
||||
space.center || {
|
||||
x: undefined,
|
||||
y: undefined,
|
||||
z: undefined
|
||||
}
|
||||
)
|
||||
|
||||
export const isExhibitionHallSpace = (space: SgsSpacePayload) => {
|
||||
const type = normalizedText(space.type).toLowerCase()
|
||||
const name = normalizedText(space.name)
|
||||
const searchableText = searchableSpaceText(space)
|
||||
|
||||
if (!exhibitionSpaceTypeWhitelist.has(type)) return false
|
||||
if (type === 'exhibition_hall') return true
|
||||
if (type === 'ramp') return name.includes('展览坡道')
|
||||
|
||||
return hasExhibitionKeyword(searchableText)
|
||||
}
|
||||
|
||||
export const isExhibitionHallNavigablePlace = (place: SgsNavigablePlacePayload) => {
|
||||
const searchableText = [
|
||||
place.name,
|
||||
place.ownerName,
|
||||
place.category,
|
||||
place.type,
|
||||
place.typeCode,
|
||||
place.typeName
|
||||
]
|
||||
.map((value) => normalizedText(value))
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
return hasExhibitionKeyword(searchableText)
|
||||
}
|
||||
|
||||
const normalizedHallName = (value?: string | null) => normalizedText(value)
|
||||
.replace(/[\s()()【】\[\]_-]/g, '')
|
||||
|
||||
const normalizePosition = (poi: SgsPoiPayload): [number, number, number] | undefined => normalizePositionSource(
|
||||
poi.position || {
|
||||
x: poi.x,
|
||||
y: poi.y,
|
||||
z: poi.z
|
||||
}
|
||||
const x = Number(source.x)
|
||||
const y = Number(source.y)
|
||||
const z = Number(source.z)
|
||||
)
|
||||
|
||||
if (![x, y, z].every(Number.isFinite)) return undefined
|
||||
return [x, y, z]
|
||||
}
|
||||
const normalizePlacePosition = (place: SgsNavigablePlacePayload): [number, number, number] | undefined => normalizePositionSource(
|
||||
place.position
|
||||
? {
|
||||
x: place.position.x,
|
||||
y: place.position.y ?? 0,
|
||||
z: place.position.z
|
||||
}
|
||||
: {
|
||||
x: place.x,
|
||||
y: place.y ?? 0,
|
||||
z: place.z
|
||||
}
|
||||
)
|
||||
|
||||
const categoryFor = (poi: SgsPoiPayload): MuseumCategory & { accessible?: boolean } => {
|
||||
const normalizedType = poi.type?.trim()
|
||||
@@ -208,7 +333,8 @@ export const toMuseumPoiFromSgs = (
|
||||
sourceObjectName: poi.anchorNodeName || undefined,
|
||||
sourceConfidence: 'backend-sgs-sdk',
|
||||
navigationReadiness: '位置预览',
|
||||
accessible: category.accessible === true
|
||||
accessible: category.accessible === true,
|
||||
kind: category.id === 'operation_experience' ? 'guide' : 'facility'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,9 +345,229 @@ export const toLocationPreviewFromPoi = (poi: MuseumPoi): GuideLocationPreview =
|
||||
floorLabel: poi.floorLabel,
|
||||
primaryCategoryZh: poi.primaryCategory.label,
|
||||
positionGltf: poi.positionGltf,
|
||||
sourceObjectName: poi.sourceObjectName
|
||||
sourceObjectName: poi.sourceObjectName,
|
||||
kind: poi.kind,
|
||||
hallId: poi.hallId,
|
||||
hallName: poi.hallName,
|
||||
entrances: poi.entrances
|
||||
})
|
||||
|
||||
const placeHallName = (place: SgsNavigablePlacePayload) => (
|
||||
normalizedText(place.ownerName)
|
||||
|| normalizedText(place.name)
|
||||
.replace(/(?:主)?(?:出入口|入口|出口|门)\s*[A-Za-z0-9一二三四五六七八九十号-]*$/u, '')
|
||||
.trim()
|
||||
|| normalizedText(place.name)
|
||||
)
|
||||
|
||||
const findMatchedHallSpace = (
|
||||
place: SgsNavigablePlacePayload,
|
||||
spaces: SgsSpacePayload[]
|
||||
) => {
|
||||
const placeName = normalizedHallName(placeHallName(place))
|
||||
if (!placeName) return undefined
|
||||
|
||||
return spaces.find((space) => {
|
||||
const spaceName = normalizedHallName(space.name)
|
||||
return spaceName && (placeName.includes(spaceName) || spaceName.includes(placeName))
|
||||
})
|
||||
}
|
||||
|
||||
const floorLabelForSgs = (
|
||||
floorId: string,
|
||||
floors: SgsSdkFloorSummaryPayload[],
|
||||
floorCode?: string | null,
|
||||
floorName?: string | null
|
||||
) => {
|
||||
const matchedFloor = floors.find((floor) => stringifyId(floor.floorId) === floorId || floor.floorCode === floorCode)
|
||||
return formatSgsFloorLabel(
|
||||
matchedFloor?.floorCode || floorCode,
|
||||
matchedFloor?.floorName || floorName
|
||||
)
|
||||
}
|
||||
|
||||
const createHallEntrance = (
|
||||
place: SgsNavigablePlacePayload,
|
||||
floors: SgsSdkFloorSummaryPayload[],
|
||||
fallbackFloorId: string
|
||||
) => {
|
||||
const floorId = stringifyId(place.floorId) || fallbackFloorId
|
||||
const placeId = stringifyId(place.id || place.nodeId)
|
||||
|
||||
return {
|
||||
id: placeId ? `hall-entrance-${placeId}` : `hall-entrance-${floorId}-${normalizedHallName(place.name)}`,
|
||||
name: normalizedText(place.name) || normalizedText(place.ownerName) || '展厅出入口',
|
||||
floorId,
|
||||
floorLabel: floorLabelForSgs(floorId, floors, place.floorCode, place.floorName),
|
||||
positionGltf: normalizePlacePosition(place),
|
||||
sourceObjectName: stringifyId(place.nodeId) || undefined,
|
||||
sourcePlaceId: placeId || undefined,
|
||||
routeNodeId: stringifyId(place.nodeId) || undefined
|
||||
}
|
||||
}
|
||||
|
||||
const createHallPoiId = (
|
||||
space: SgsSpacePayload | undefined,
|
||||
place: SgsNavigablePlacePayload,
|
||||
fallbackIndex: number
|
||||
) => {
|
||||
const spaceId = stringifyId(space?.id)
|
||||
if (spaceId) return `hall-${spaceId}`
|
||||
|
||||
const ownerName = normalizedHallName(placeHallName(place))
|
||||
if (ownerName) return `hall-place-${ownerName}`
|
||||
|
||||
return `hall-place-${stringifyId(place.id) || fallbackIndex + 1}`
|
||||
}
|
||||
|
||||
const createSpaceFallbackHallPoi = (
|
||||
space: SgsSpacePayload,
|
||||
floors: SgsSdkFloorSummaryPayload[],
|
||||
fallbackFloorId: string
|
||||
): MuseumPoi | null => {
|
||||
const floorId = stringifyId(space.floorId) || fallbackFloorId
|
||||
const position = normalizeSpaceCenter(space)
|
||||
const hallId = stringifyId(space.id)
|
||||
|
||||
if (!hallId || !normalizedText(space.name) || !position) return null
|
||||
|
||||
return {
|
||||
id: `hall-${hallId}`,
|
||||
name: normalizedText(space.name),
|
||||
floorId,
|
||||
floorLabel: floorLabelForSgs(floorId, floors),
|
||||
primaryCategory: hallCategory,
|
||||
categories: [
|
||||
hallCategory
|
||||
],
|
||||
positionGltf: position,
|
||||
sourceObjectName: space.sourceNodeName || undefined,
|
||||
sourceConfidence: 'backend-sgs-sdk-space-center',
|
||||
navigationReadiness: '位置预览',
|
||||
accessible: false,
|
||||
kind: 'hall',
|
||||
hallId,
|
||||
hallName: normalizedText(space.name),
|
||||
spaceId: hallId,
|
||||
sourceSpaceId: hallId,
|
||||
entrances: []
|
||||
}
|
||||
}
|
||||
|
||||
export const toMuseumHallPoisFromSgs = (
|
||||
spaces: SgsSpacePayload[],
|
||||
navigablePlaces: SgsNavigablePlacePayload[],
|
||||
floors: SgsSdkFloorSummaryPayload[],
|
||||
fallbackFloorId: string
|
||||
): MuseumPoi[] => {
|
||||
const hallSpaces = spaces.filter(isExhibitionHallSpace)
|
||||
const hallPlaces = navigablePlaces.filter(isExhibitionHallNavigablePlace)
|
||||
const halls = new Map<string, MuseumPoi>()
|
||||
|
||||
hallPlaces.forEach((place, index) => {
|
||||
const matchedSpace = findMatchedHallSpace(place, hallSpaces)
|
||||
const entrance = createHallEntrance(place, floors, fallbackFloorId)
|
||||
const entranceHasRouteAnchor = Boolean(entrance.positionGltf && entrance.routeNodeId)
|
||||
const fallbackPosition = matchedSpace ? normalizeSpaceCenter(matchedSpace) : undefined
|
||||
const floorId = entrance.floorId || stringifyId(matchedSpace?.floorId) || fallbackFloorId
|
||||
const hallId = stringifyId(matchedSpace?.id) || undefined
|
||||
const poiId = createHallPoiId(matchedSpace, place, index)
|
||||
const hallName = normalizedText(matchedSpace?.name) || placeHallName(place) || entrance.name
|
||||
const existing = halls.get(poiId)
|
||||
|
||||
if (existing) {
|
||||
existing.entrances = [
|
||||
...(existing.entrances || []),
|
||||
entrance
|
||||
]
|
||||
if (entranceHasRouteAnchor) {
|
||||
existing.positionGltf = entrance.positionGltf
|
||||
existing.floorId = floorId
|
||||
existing.floorLabel = entrance.floorLabel
|
||||
existing.sourceObjectName = entrance.sourceObjectName
|
||||
existing.sourcePlaceId = entrance.sourcePlaceId
|
||||
existing.sourceConfidence = 'backend-sgs-sdk-hall-entrance'
|
||||
return
|
||||
}
|
||||
|
||||
if (!existing.positionGltf && fallbackPosition) {
|
||||
existing.positionGltf = fallbackPosition
|
||||
existing.floorId = floorId
|
||||
existing.floorLabel = entrance.floorLabel
|
||||
existing.sourceObjectName = matchedSpace?.sourceNodeName || undefined
|
||||
existing.sourcePlaceId = entrance.sourcePlaceId
|
||||
existing.sourceConfidence = 'backend-sgs-sdk-space-center'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const positionGltf = entranceHasRouteAnchor ? entrance.positionGltf : fallbackPosition
|
||||
if (!positionGltf) return
|
||||
|
||||
halls.set(poiId, {
|
||||
id: poiId,
|
||||
name: hallName,
|
||||
floorId,
|
||||
floorLabel: entrance.floorLabel,
|
||||
primaryCategory: hallCategory,
|
||||
categories: [
|
||||
hallCategory,
|
||||
hallEntranceCategory
|
||||
],
|
||||
positionGltf,
|
||||
sourceObjectName: entranceHasRouteAnchor
|
||||
? entrance.sourceObjectName
|
||||
: matchedSpace?.sourceNodeName || undefined,
|
||||
sourceConfidence: entranceHasRouteAnchor
|
||||
? 'backend-sgs-sdk-hall-entrance'
|
||||
: 'backend-sgs-sdk-space-center',
|
||||
navigationReadiness: '位置预览',
|
||||
accessible: false,
|
||||
kind: 'hall',
|
||||
hallId,
|
||||
hallName,
|
||||
spaceId: hallId,
|
||||
sourcePlaceId: entrance.sourcePlaceId,
|
||||
sourceSpaceId: hallId,
|
||||
entrances: [entrance]
|
||||
})
|
||||
})
|
||||
|
||||
hallSpaces.forEach((space) => {
|
||||
const hallId = stringifyId(space.id)
|
||||
if (!hallId || halls.has(`hall-${hallId}`)) return
|
||||
|
||||
const fallbackPoi = createSpaceFallbackHallPoi(space, floors, fallbackFloorId)
|
||||
if (fallbackPoi) {
|
||||
halls.set(fallbackPoi.id, fallbackPoi)
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(halls.values())
|
||||
.filter((poi) => poi.id && poi.name && poi.positionGltf)
|
||||
}
|
||||
|
||||
export const createSgsHallPoiDiagnostics = (
|
||||
spaces: SgsSpacePayload[],
|
||||
navigablePlaces: SgsNavigablePlacePayload[],
|
||||
hallPois: MuseumPoi[]
|
||||
): SgsHallPoiDiagnostics => {
|
||||
const eligibleSpaceCount = spaces.filter(isExhibitionHallSpace).length
|
||||
const hallPlaceCount = navigablePlaces.filter(isExhibitionHallNavigablePlace).length
|
||||
const hallPoiWithPositionCount = hallPois.filter((poi) => Boolean(poi.positionGltf)).length
|
||||
|
||||
return {
|
||||
spaceCount: spaces.length,
|
||||
eligibleSpaceCount,
|
||||
navigablePlaceCount: navigablePlaces.length,
|
||||
hallPlaceCount,
|
||||
hallPoiCount: hallPois.length,
|
||||
hallPoiWithPositionCount,
|
||||
skippedSpaceCount: Math.max(eligibleSpaceCount - hallPois.length, 0),
|
||||
skippedPlaceCount: Math.max(hallPlaceCount - hallPois.reduce((count, poi) => count + (poi.entrances?.length || 0), 0), 0)
|
||||
}
|
||||
}
|
||||
|
||||
export const toGuideFloorDetailFromSgs = (
|
||||
floor: SgsSdkFloorSummaryPayload | SgsFloorDiagnosticsPayload
|
||||
): GuideFloorDetail => ({
|
||||
|
||||
@@ -72,6 +72,19 @@ export interface MuseumCategory {
|
||||
iconType?: string
|
||||
}
|
||||
|
||||
export type MuseumPoiKind = 'facility' | 'hall' | 'hall_entrance' | 'guide'
|
||||
|
||||
export interface MuseumPoiEntrance {
|
||||
id: string
|
||||
name: string
|
||||
floorId: string
|
||||
floorLabel: string
|
||||
positionGltf?: [number, number, number]
|
||||
sourceObjectName?: string
|
||||
sourcePlaceId?: string
|
||||
routeNodeId?: string
|
||||
}
|
||||
|
||||
export interface MuseumPoi {
|
||||
id: string
|
||||
name: string
|
||||
@@ -84,6 +97,13 @@ export interface MuseumPoi {
|
||||
sourceConfidence?: string
|
||||
navigationReadiness?: string
|
||||
accessible: boolean
|
||||
kind?: MuseumPoiKind
|
||||
hallId?: string
|
||||
hallName?: string
|
||||
spaceId?: string
|
||||
sourcePlaceId?: string
|
||||
sourceSpaceId?: string
|
||||
entrances?: MuseumPoiEntrance[]
|
||||
}
|
||||
|
||||
export type GuideLocationResolutionStatus = 'exact' | 'hallFallback' | 'candidate' | 'unavailable'
|
||||
@@ -149,6 +169,10 @@ export interface GuideLocationPreview {
|
||||
primaryCategoryZh?: string
|
||||
positionGltf?: [number, number, number]
|
||||
sourceObjectName?: string
|
||||
kind?: MuseumPoiKind
|
||||
hallId?: string
|
||||
hallName?: string
|
||||
entrances?: MuseumPoiEntrance[]
|
||||
}
|
||||
|
||||
export interface GuideStartLocation {
|
||||
|
||||
Reference in New Issue
Block a user