430 lines
12 KiB
TypeScript
430 lines
12 KiB
TypeScript
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)
|
||
}
|
||
}
|