import type { GuideDataIntegrityReport, GuideFloorDetail, GuideLocationPreview, GuideMapDiagnostics, GuideRouteReadiness, MuseumFloor, MuseumPoi } from '@/domain/museum' import { NAV_ROUTE_READINESS } from '@/domain/guideReadiness' import { compareFloorsTopToBottom, isIndoorNavigableFloor, isPoiOnIndoorNavigableFloor } from '@/domain/guideFloor' import { formatNavFloorLabel, isStaticIndoorNavigableFloorId, navFloorIdFromLabel, toMuseumPoi } from '@/data/adapters/navAssetsAdapter' import { defaultStaticNavAssetsProvider, type StaticNavAssetsProvider } from '@/data/providers/staticNavAssetsProvider' import { defaultSgsSdkApiProvider, type SgsSdkApiProvider } from '@/data/providers/sgsSdkApiProvider' import { buildSgsFloorAliases, createGuideDataIntegrityReport, toGuideFloorDetailFromSgs, toGuideMapDiagnostics, toLocationPreviewFromPoi, toMuseumFloorFromSgs, toMuseumHallPoisFromSgs, toMuseumPoiFromSgs, toMuseumSpacePointFromSgs, toRouteReadinessFromSgsDiagnostics } from '@/data/adapters/sgsSdkGuideAdapter' const searchableCategories = new Set([ 'touring_poi', 'exhibition_hall', 'exhibition_hall_entrance', 'basic_service_facility', 'transport_circulation', 'accessibility_special_service', 'operation_experience' ]) const toSearchText = (poi: MuseumPoi) => [ poi.name, poi.hallName, poi.floorId, poi.floorLabel, poi.primaryCategory.label, poi.primaryCategory.iconType, ...poi.categories.flatMap((category) => [ category.label, category.id, category.iconType ]) ] .filter(Boolean) .join(' ') .toLowerCase() const normalizePoiLookupText = (value: string) => value .trim() .replace(/[\s()()【】[\]_-]/g, '') .toLowerCase() const extractNameFromNavPoiId = (id: string) => { const normalized = id.trim() if (!normalized.startsWith('poi_')) return '' const segments = normalized.split('_') if (segments.length < 3) return '' return segments.slice(2).join('_') } const findPoiByNavIdNameFallback = (pois: MuseumPoi[], id: string) => { const targetName = normalizePoiLookupText(extractNameFromNavPoiId(id)) if (!targetName) return null return pois.find((poi) => { const name = normalizePoiLookupText(poi.name) const hallName = normalizePoiLookupText(poi.hallName || '') return name === targetName || hallName === targetName || (name && (name.includes(targetName) || targetName.includes(name))) || (hallName && (hallName.includes(targetName) || targetName.includes(hallName))) }) || null } const toLocationPreview = (poi: MuseumPoi): GuideLocationPreview => ({ poiId: poi.id, name: poi.name, floorId: poi.floorId, floorLabel: poi.floorLabel, primaryCategoryZh: poi.primaryCategory.label, positionGltf: poi.positionGltf, sourceObjectName: poi.sourceObjectName, kind: poi.kind, hallId: poi.hallId, hallName: poi.hallName, entrances: poi.entrances }) const isSearchableIndoorPoi = (poi: MuseumPoi) => ( searchableCategories.has(poi.primaryCategory.id) && isPoiOnIndoorNavigableFloor(poi) ) const dedupePoisById = (pois: MuseumPoi[]) => { const seen = new Set() return pois.filter((poi) => { if (!poi.id || seen.has(poi.id)) return false seen.add(poi.id) return true }) } export interface GuideRepository { getAssetBaseUrl(): string getFloors(): Promise normalizeFloorId(labelOrId: string): string listPois(): Promise listSpacePoints(): Promise getPoiById(id: string): Promise searchPois(keyword?: string): Promise getLocationPreview(poiId: string): Promise getRouteReadiness(): Promise getFloorDetail?(floorId: string): Promise getMapDiagnostics?(): Promise checkDataIntegrity?(): Promise } export class StaticGuideRepository implements GuideRepository { private poiCache: MuseumPoi[] | null = null constructor(private readonly provider: StaticNavAssetsProvider = defaultStaticNavAssetsProvider) {} getAssetBaseUrl() { return this.provider.baseUrl } async getFloors() { const floorIndex = await this.provider.loadFloorIndex() return [...floorIndex.floors] .filter((floor) => isStaticIndoorNavigableFloorId(floor.floorId)) .sort(compareFloorsTopToBottom) .map((floor) => ({ id: floor.floorId, label: formatNavFloorLabel(floor.floorId), order: floor.order })) } normalizeFloorId(labelOrId: string) { return navFloorIdFromLabel(labelOrId) } async listPois() { if (this.poiCache) return this.poiCache const pois = await this.provider.loadPoiIndex() this.poiCache = pois .map(toMuseumPoi) .filter(isSearchableIndoorPoi) return this.poiCache } async listSpacePoints() { return [] } async getPoiById(id: string) { const pois = await this.listPois() return pois.find((poi) => poi.id === id) || findPoiByNavIdNameFallback(pois, id) } async searchPois(keyword = '') { const pois = await this.listPois() const normalizedKeyword = keyword.trim().toLowerCase() if (!normalizedKeyword) return pois return pois.filter((poi) => toSearchText(poi).includes(normalizedKeyword)) } async getLocationPreview(poiId: string) { const poi = await this.getPoiById(poiId) return poi ? toLocationPreview(poi) : null } async getRouteReadiness() { return this.provider.loadRouteReadiness() .catch(() => NAV_ROUTE_READINESS) } async getFloorDetail(floorId: string) { const floors = await this.getFloors() const normalizedFloorId = this.normalizeFloorId(floorId) const floor = floors.find((item) => item.id === normalizedFloorId) return floor ? { ...floor, warnings: [] } : null } async getMapDiagnostics(): Promise { const [floors, pois] = await Promise.all([ this.getFloors(), this.listPois() ]) return { mapId: 'static', mapName: '本地静态导览数据', status: 'WARN', floors: floors.map((floor) => ({ ...floor, poiCount: pois.filter((poi) => poi.floorId === floor.id).length, warnings: [] })), summary: { floorCount: floors.length, modelReadyFloorCount: floors.length, poiCount: pois.length, spaceCount: 0, guideStopCount: 0, navigablePlaceCount: 0, routeNodeCount: 0, routeEdgeCount: 0 }, warnings: ['static 模式仅提供本地导览资源基础检查'] } } async checkDataIntegrity() { const [floors, pois, diagnostics] = await Promise.all([ this.getFloors(), this.listPois(), this.getMapDiagnostics() ]) return createGuideDataIntegrityReport(diagnostics, floors, pois) } } export class SgsSdkGuideRepository implements GuideRepository { private floorsCache: MuseumFloor[] | null = null private poiCache: MuseumPoi[] | null = null private spacePointCache: MuseumPoi[] | null = null private floorAliases: Map | null = null constructor(private readonly provider: SgsSdkApiProvider = defaultSgsSdkApiProvider) {} getAssetBaseUrl() { return '' } async getFloors() { if (this.floorsCache) return this.floorsCache const manifest = await this.provider.getManifest() const indoorFloors = manifest.floors.filter(isIndoorNavigableFloor) this.floorAliases = buildSgsFloorAliases(indoorFloors) this.floorsCache = [...manifest.floors] .filter(isIndoorNavigableFloor) .sort(compareFloorsTopToBottom) .map(toMuseumFloorFromSgs) return this.floorsCache } normalizeFloorId(labelOrId: string) { const normalized = labelOrId.trim() const aliases = this.floorAliases if (!aliases) return normalized return aliases.get(normalized) || aliases.get(normalized.toLowerCase()) || normalized } async listPois() { if (this.poiCache) return this.poiCache const manifest = await this.provider.getManifest() const indoorFloors = manifest.floors.filter(isIndoorNavigableFloor) const indoorFloorIds = new Set(indoorFloors.map((floor) => String(floor.floorId))) this.floorAliases = buildSgsFloorAliases(indoorFloors) const floorDataGroups = await Promise.all( indoorFloors.map(async (floor) => { const floorId = String(floor.floorId) const [pois, spaces, navigablePlaces] = await Promise.all([ this.provider.getFloorPois(floorId).catch(() => []), this.provider.getFloorSpaces(floorId).catch(() => []), this.provider.getNavigablePlaces(floorId).catch(() => []) ]) return { floorId, pois, hallPois: toMuseumHallPoisFromSgs(spaces, navigablePlaces, manifest.floors, floorId) } }) ) const ordinaryPois = floorDataGroups .flatMap((group) => group.pois) .map((poi) => toMuseumPoiFromSgs(poi, manifest.floors)) .filter((poi) => poi.id && poi.name) .filter((poi) => indoorFloorIds.has(String(poi.floorId)) || isPoiOnIndoorNavigableFloor(poi)) const hallPois = floorDataGroups .flatMap((group) => group.hallPois) .filter((poi) => indoorFloorIds.has(String(poi.floorId)) || isPoiOnIndoorNavigableFloor(poi)) this.poiCache = dedupePoisById([ ...hallPois, ...ordinaryPois ]) return this.poiCache } async listSpacePoints() { if (this.spacePointCache) return this.spacePointCache const manifest = await this.provider.getManifest() const indoorFloors = manifest.floors.filter(isIndoorNavigableFloor) const indoorFloorIds = new Set(indoorFloors.map((floor) => String(floor.floorId))) this.floorAliases = buildSgsFloorAliases(indoorFloors) const floorDataGroups = await Promise.all( indoorFloors.map(async (floor) => { const floorId = String(floor.floorId) const spaces = await this.provider.getFloorSpaces(floorId).catch(() => []) return { floorId, spaces } }) ) const spacePoints = floorDataGroups .flatMap((group) => group.spaces.map((space) => toMuseumSpacePointFromSgs(space, manifest.floors, group.floorId))) .filter((poi): poi is MuseumPoi => Boolean(poi)) .filter((poi) => indoorFloorIds.has(String(poi.floorId)) || isPoiOnIndoorNavigableFloor(poi)) this.spacePointCache = dedupePoisById(spacePoints) return this.spacePointCache } async getPoiById(id: string) { const pois = await this.listPois() const matchedPoi = pois.find((poi) => poi.id === id) || findPoiByNavIdNameFallback(pois, id) if (matchedPoi) return matchedPoi const spacePoints = await this.listSpacePoints() return spacePoints.find((poi) => poi.id === id || poi.spaceId === id || poi.sourceSpaceId === id) || null } async searchPois(keyword = '') { const pois = await this.listPois() const normalizedKeyword = keyword.trim().toLowerCase() if (!normalizedKeyword) return pois return pois.filter((poi) => toSearchText(poi).includes(normalizedKeyword)) } async getLocationPreview(poiId: string) { const poi = await this.getPoiById(poiId) return poi ? toLocationPreviewFromPoi(poi) : null } async getRouteReadiness() { return this.provider.getMapDiagnostics() .then(toRouteReadinessFromSgsDiagnostics) .catch(() => NAV_ROUTE_READINESS) } async getFloorDetail(floorId: string) { await this.getFloors() const normalizedFloorId = this.normalizeFloorId(floorId) try { const diagnostics = await this.provider.getFloorDiagnostics(normalizedFloorId) return isIndoorNavigableFloor(diagnostics) ? toGuideFloorDetailFromSgs(diagnostics) : null } catch { const manifest = await this.provider.getManifest() const floor = manifest.floors.find((item) => String(item.floorId) === normalizedFloorId) return floor && isIndoorNavigableFloor(floor) ? toGuideFloorDetailFromSgs(floor) : null } } async getMapDiagnostics() { const [manifest, diagnostics] = await Promise.all([ this.provider.getManifest(), this.provider.getMapDiagnostics() ]) return toGuideMapDiagnostics(diagnostics, manifest) } async checkDataIntegrity() { const [floors, pois, diagnostics] = await Promise.all([ this.getFloors(), this.listPois(), this.getMapDiagnostics() ]) return createGuideDataIntegrityReport(diagnostics, floors, pois) } }