Files
frontend-miniapp/src/repositories/GuideRepository.ts
lyf b99ae572da
Some checks failed
CI / verify (push) Has been cancelled
调整点位搜索初始空间点位来源
2026-07-05 23:55:20 +08:00

430 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string>()
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<MuseumFloor[]>
normalizeFloorId(labelOrId: string): string
listPois(): Promise<MuseumPoi[]>
listSpacePoints(): Promise<MuseumPoi[]>
getPoiById(id: string): Promise<MuseumPoi | null>
searchPois(keyword?: string): Promise<MuseumPoi[]>
getLocationPreview(poiId: string): Promise<GuideLocationPreview | null>
getRouteReadiness(): Promise<GuideRouteReadiness>
getFloorDetail?(floorId: string): Promise<GuideFloorDetail | null>
getMapDiagnostics?(): Promise<GuideMapDiagnostics>
checkDataIntegrity?(): Promise<GuideDataIntegrityReport>
}
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<GuideMapDiagnostics> {
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<string, string> | 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)
}
}