Files
frontend-miniapp/src/repositories/GuideModelRepository.ts

555 lines
18 KiB
TypeScript

import type {
GuideModelFloorAsset,
GuideModelRenderPackage,
GuideModelSource,
GuideRenderPoi
} from '@/domain/guideModel'
import type {
MuseumPoi
} from '@/domain/museum'
import {
isVisitorSearchPoi
} from '@/domain/poiCategories'
import {
getPoiDisplayPolicy
} from '@/domain/poiDisplay'
import {
dataSourceConfig
} from '@/config/dataSource'
import {
compareFloorsTopToBottom,
isIndoorNavigableFloor
} from '@/domain/guideFloor'
import {
buildSgsFloorAliases,
createSgsHallPoiDiagnostics,
formatSgsFloorLabel,
toMuseumHallPoisFromSgs,
toMuseumPoiFromSgs
} from '@/data/adapters/sgsSdkGuideAdapter'
import {
defaultSgsSdkApiProvider,
type SgsSdkApiProvider,
type SgsSdkFloorSummaryPayload
} from '@/data/providers/sgsSdkApiProvider'
import {
formatNavFloorLabel,
isStaticIndoorNavigableFloorId,
toMuseumPoi
} from '@/data/adapters/navAssetsAdapter'
import {
defaultStaticNavAssetsProvider,
type StaticNavAssetsProvider
} from '@/data/providers/staticNavAssetsProvider'
import {
mapWithConcurrency
} from '@/utils/concurrency'
import type {
GuideRepository
} from '@/repositories/GuideRepository'
import {
guideRepository
} from '@/repositories/createGuideRepository'
import {
normalizeSameOriginPublicUrl
} from '@/utils/publicUrl'
const SDK_FLOOR_BUNDLE_CONCURRENCY = 2
const getNow = () => (
typeof performance !== 'undefined' && typeof performance.now === 'function'
? performance.now()
: Date.now()
)
const toGuideRenderPoi = (poi: MuseumPoi): GuideRenderPoi => ({
id: poi.id,
name: poi.name,
floorId: poi.floorId,
primaryCategory: poi.primaryCategory.id,
primaryCategoryZh: poi.primaryCategory.label,
iconType: poi.primaryCategory.iconType || poi.primaryCategory.id,
positionGltf: poi.positionGltf,
sourceObjectName: poi.sourceObjectName,
kind: poi.kind,
hallId: poi.hallId,
hallName: poi.hallName,
spaceId: poi.spaceId,
sourcePlaceId: poi.sourcePlaceId,
sourceSpaceId: poi.sourceSpaceId,
entrances: poi.entrances,
displayPolicy: poi.displayPolicy || getPoiDisplayPolicy({
primaryCategory: poi.primaryCategory.id,
kind: poi.kind
})
})
const toSgsRenderPoi = (poi: ReturnType<typeof toMuseumPoiFromSgs>) => toGuideRenderPoi(poi)
const getRenderPoiDedupeKeys = (poi: GuideRenderPoi) => {
// A facility can be located inside the same spatial area as other facilities.
// That relationship must not collapse distinct visitor markers (for example,
// a restroom and an elevator in one service zone). Space identity is only a
// canonical-place key for halls, spaces, and business-place representations.
const canDedupeBySpace = poi.kind === 'hall'
|| poi.kind === 'space'
|| poi.primaryCategory === 'business_poi'
const stableKeys = [
poi.id ? `id:${poi.id}` : '',
canDedupeBySpace && poi.sourceSpaceId ? `space:${poi.floorId}:${poi.sourceSpaceId}` : '',
canDedupeBySpace && poi.spaceId ? `space:${poi.floorId}:${poi.spaceId}` : '',
poi.sourcePlaceId ? `place:${poi.floorId}:${poi.sourcePlaceId}` : ''
].filter(Boolean)
if (stableKeys.length) return stableKeys
return poi.sourceObjectName ? [`object:${poi.floorId}:${poi.sourceObjectName}`] : []
}
const dedupeRenderPoisById = (pois: GuideRenderPoi[]) => {
const seen = new Set<string>()
return pois.filter((poi) => {
const keys = getRenderPoiDedupeKeys(poi)
if (!keys.length || keys.some((key) => seen.has(key))) return false
keys.forEach((key) => seen.add(key))
return true
})
}
const countByValue = <T>(
items: T[],
selector: (item: T) => string | number | null | undefined
) => items.reduce<Record<string, number>>((counts, item) => {
const key = String(selector(item) || '<missing>')
counts[key] = (counts[key] || 0) + 1
return counts
}, {})
const countDroppedRenderPoiCategories = (
sourcePois: GuideRenderPoi[],
keptPois: GuideRenderPoi[]
) => {
const keptIds = new Set(keptPois.map((poi) => poi.id))
return countByValue(
sourcePois.filter((poi) => !keptIds.has(poi.id)),
(poi) => poi.primaryCategory
)
}
const trimTrailingSlash = (value: string) => value.replace(/\/+$/, '')
const resolveSgsAssetUrl = (url?: string | null) => {
const normalizedUrl = url?.trim()
if (!normalizedUrl) return ''
if (/^https?:\/\//i.test(normalizedUrl)) {
return normalizeSameOriginPublicUrl(normalizedUrl)
}
if (!normalizedUrl.startsWith('/')) return normalizedUrl
const baseUrl = trimTrailingSlash(dataSourceConfig.sgsApiBaseUrl || dataSourceConfig.apiBaseUrl || '')
const origin = baseUrl.endsWith('/app-api')
? baseUrl.slice(0, -'/app-api'.length)
: baseUrl
return origin ? `${origin}${normalizedUrl}` : normalizedUrl
}
const uniqueModelUrls = (urls: Array<string | undefined | null>) => Array.from(new Set(
urls.map(resolveSgsAssetUrl).filter(Boolean)
))
const sortSgsFloors = (floors: SgsSdkFloorSummaryPayload[]) => [...floors]
.sort(compareFloorsTopToBottom)
const isSharedSgsModelAsset = (
modelUrl: string,
overviewModelUrl: string,
allModelUrls: string[]
) => {
if (!modelUrl) return false
return modelUrl === overviewModelUrl || allModelUrls.filter((url) => url === modelUrl).length > 1
}
const warnSgsGuideModelDiagnostics = (
message: string,
payload: Record<string, unknown>
) => {
if (!import.meta.env.DEV) return
console.warn(`[SGS guide model] ${message}`, payload)
}
const stringifyDiagnosticPayload = (payload: Record<string, unknown>) => {
try {
return JSON.stringify(payload)
} catch {
return '[unserializable]'
}
}
const logSgsGuideModelDiagnostics = (
message: string,
payload: Record<string, unknown>
) => {
if (!import.meta.env.DEV) return
if (typeof window !== 'undefined') {
const diagnosticsWindow = window as unknown as {
__GUIDE_3D_DIAGNOSTIC_EVENTS__?: Array<{
source: string
event: string
payload: Record<string, unknown>
}>
}
diagnosticsWindow.__GUIDE_3D_DIAGNOSTIC_EVENTS__ ||= []
diagnosticsWindow.__GUIDE_3D_DIAGNOSTIC_EVENTS__.push({
source: 'SGS guide model',
event: message,
payload
})
}
console.debug(`[SGS guide model] ${message} ${stringifyDiagnosticPayload(payload)}`)
}
const summarizeYValues = (pois: GuideRenderPoi[]) => {
const values = pois
.map((poi) => poi.positionGltf?.[1])
.filter((value): value is number => Number.isFinite(value))
.sort((left, right) => left - right)
if (!values.length) {
return {
count: 0,
min: null,
median: null,
max: null
}
}
return {
count: values.length,
min: values[0],
median: values[Math.floor(values.length / 2)],
max: values[values.length - 1]
}
}
const getMedianPoiY = (pois: GuideRenderPoi[]) => {
const values = pois
.map((poi) => poi.positionGltf?.[1])
.filter((value): value is number => Number.isFinite(value))
.sort((left, right) => left - right)
if (!values.length) return undefined
const middle = Math.floor(values.length / 2)
return values.length % 2
? values[middle]
: (values[middle - 1] + values[middle]) / 2
}
const getSgsFloorModelMatchKeys = (floor: SgsSdkFloorSummaryPayload, label: string) => (
[
String(floor.floorId),
floor.floorCode,
floor.floorName,
label
]
.filter((value): value is string => Boolean(value))
)
export interface GuideModelRepository extends GuideModelSource {}
export class StaticGuideModelRepository implements GuideModelRepository {
constructor(private readonly provider: StaticNavAssetsProvider = defaultStaticNavAssetsProvider) {}
async loadPackage(): Promise<GuideModelRenderPackage> {
const [manifest, floorIndex] = await Promise.all([
this.provider.loadManifest(),
this.provider.loadFloorIndex()
])
const floors: GuideModelFloorAsset[] = [...floorIndex.floors]
.filter((floor) => isStaticIndoorNavigableFloorId(floor.floorId))
.sort(compareFloorsTopToBottom)
.map((floor) => ({
floorId: floor.floorId,
label: formatNavFloorLabel(floor.floorId),
order: floor.order,
modelUrl: this.provider.assetUrl(floor.modelAsset),
sharedModelAsset: floor.sharedModelAsset
}))
return {
overviewModelUrl: this.provider.assetUrl(manifest.assets.overviewModel.asset),
floors
}
}
async loadFloorPois(floorId: string) {
const floorIndex = await this.provider.loadFloorIndex()
const floor = floorIndex.floors.find((item) => item.floorId === floorId)
if (!floor || !isStaticIndoorNavigableFloorId(floor.floorId)) return []
const pois = await this.provider.loadFloorPois(floor.poiDataAsset)
return pois
.map(toMuseumPoi)
.filter(isVisitorSearchPoi)
.map(toGuideRenderPoi)
.filter((poi) => Array.isArray(poi.positionGltf) && poi.positionGltf.length === 3)
}
}
export class SgsSdkGuideModelRepository implements GuideModelRepository {
private floorsCache: SgsSdkFloorSummaryPayload[] | null = null
constructor(
private readonly provider: SgsSdkApiProvider = defaultSgsSdkApiProvider,
private readonly guide: GuideRepository = guideRepository
) {}
async loadPackage(): Promise<GuideModelRenderPackage> {
const startedAt = getNow()
const manifest = await this.provider.getManifest()
const allFloors = sortSgsFloors(manifest.floors)
const floors = allFloors.filter(isIndoorNavigableFloor)
const overviewFloor = allFloors.find((floor) => !isIndoorNavigableFloor(floor))
const overviewBundleRequest = overviewFloor
? this.provider.getFloorBundle(String(overviewFloor.floorId)).catch((error) => {
warnSgsGuideModelDiagnostics('overview bundle request failed', {
floorId: String(overviewFloor.floorId),
floorCode: overviewFloor.floorCode,
error: error instanceof Error ? error.message : String(error)
})
return null
})
: Promise.resolve(null)
this.floorsCache = floors
const bundleResults = await mapWithConcurrency(
floors,
SDK_FLOOR_BUNDLE_CONCURRENCY,
async (floor) => {
try {
return {
floor,
bundle: await this.provider.getFloorBundle(String(floor.floorId)),
error: null
}
} catch (error) {
warnSgsGuideModelDiagnostics('floor bundle request failed', {
floorId: String(floor.floorId),
floorCode: floor.floorCode,
error: error instanceof Error ? error.message : String(error)
})
return {
floor,
bundle: null,
error
}
}
}
)
const floorModelItems: GuideModelFloorAsset[] = bundleResults
.map((result): GuideModelFloorAsset | null => {
const { floor, bundle } = result
if (!bundle) return null
const bundleFloor = bundle.floor
if (bundleFloor && String(bundleFloor.floorId) !== String(floor.floorId)) {
warnSgsGuideModelDiagnostics('floor bundle metadata does not match requested floor', {
requestedFloorId: String(floor.floorId),
requestedFloorCode: floor.floorCode,
bundleFloorId: String(bundleFloor.floorId),
bundleFloorCode: bundleFloor.floorCode
})
}
const modelUrls = uniqueModelUrls([
bundle.model?.modelUrl,
bundle.model?.fallbackModelUrl
])
const modelUrl = modelUrls[0] || ''
const label = formatSgsFloorLabel(floor.floorCode, floor.floorName)
return {
floorId: String(floor.floorId),
label,
order: Number(floor.sortOrder || 0),
modelUrl,
modelUrls,
modelMatchKeys: getSgsFloorModelMatchKeys(floor, label)
}
})
.filter((asset): asset is GuideModelFloorAsset => Boolean(asset?.modelUrl))
const overviewBundle = await overviewBundleRequest
const overviewModelUrls = uniqueModelUrls([
overviewBundle?.model?.modelUrl,
overviewBundle?.model?.fallbackModelUrl,
floorModelItems[0]?.modelUrl
])
const overviewModelUrl = overviewModelUrls[0] || ''
const floorModelUrls = floorModelItems.map((asset) => asset.modelUrl)
const floorAssets: GuideModelFloorAsset[] = floorModelItems.map((asset) => ({
...asset,
sharedModelAsset: isSharedSgsModelAsset(asset.modelUrl, overviewModelUrl, floorModelUrls)
}))
logSgsGuideModelDiagnostics('render package ready', {
elapsedMs: Math.round(getNow() - startedAt),
mapId: manifest.mapId,
mode: dataSourceConfig.guideDataMode,
overviewFloorId: overviewFloor ? String(overviewFloor.floorId) : '',
overviewModelUrl,
floorCount: floorAssets.length,
floorBundleCount: bundleResults.filter((result) => Boolean(result.bundle)).length
})
return {
overviewModelUrl,
overviewModelUrls,
floors: floorAssets
}
}
async loadFloorPois(floorId: string) {
const manifest = await this.provider.getManifest()
this.floorsCache = sortSgsFloors(manifest.floors).filter(isIndoorNavigableFloor)
const aliases = buildSgsFloorAliases(this.floorsCache)
const requestedFloorId = floorId.trim()
const normalizedFloorId = aliases.get(requestedFloorId)
|| aliases.get(requestedFloorId.toLowerCase())
|| requestedFloorId
const matchedFloor = this.floorsCache.find((floor) => (
String(floor.floorId) === normalizedFloorId
|| floor.floorCode === normalizedFloorId
|| formatSgsFloorLabel(floor.floorCode, floor.floorName) === normalizedFloorId
))
if (!matchedFloor) {
warnSgsGuideModelDiagnostics('unable to resolve floor for render POI request', {
requestedFloorId: floorId,
normalizedFloorId,
knownFloors: this.floorsCache.map((floor) => ({
floorId: String(floor.floorId),
floorCode: floor.floorCode,
label: formatSgsFloorLabel(floor.floorCode, floor.floorName)
}))
})
return []
}
const resolvedFloorId = String(matchedFloor.floorId)
const loadFloorData = async <T>(
endpoint: 'pois' | 'spaces' | 'navigablePlaces' | 'guidePois' | 'guideSpacePoints',
loader: () => Promise<T[]>
) => {
try {
return await loader()
} catch (error) {
warnSgsGuideModelDiagnostics('floor data request failed', {
floorId: resolvedFloorId,
endpoint,
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
const [pois, spaces, navigablePlaces, guidePois, guideSpacePoints] = await Promise.all([
loadFloorData('pois', () => this.provider.getFloorPois(resolvedFloorId)),
loadFloorData('spaces', () => this.provider.getFloorSpaces(resolvedFloorId)),
loadFloorData('navigablePlaces', () => this.provider.getNavigablePlaces(resolvedFloorId)),
loadFloorData('guidePois', () => this.guide.listPois()),
loadFloorData('guideSpacePoints', () => this.guide.listSpacePoints())
])
const ordinaryPois = pois
.map((poi) => toMuseumPoiFromSgs(poi, manifest.floors))
.filter(isVisitorSearchPoi)
.map(toSgsRenderPoi)
const repositoryBusinessPois = guidePois
.filter((poi) => (
poi.floorId === resolvedFloorId
&& poi.primaryCategory.id === 'business_poi'
&& isVisitorSearchPoi(poi)
))
.map(toGuideRenderPoi)
const repositorySpacePois = guideSpacePoints
.filter((poi) => poi.floorId === resolvedFloorId && isVisitorSearchPoi(poi))
.map(toGuideRenderPoi)
const floorPoiMedianY = getMedianPoiY(ordinaryPois)
const museumHallPois = toMuseumHallPoisFromSgs(spaces, navigablePlaces, manifest.floors, resolvedFloorId, {
fallbackY: floorPoiMedianY
})
const hallDiagnostics = createSgsHallPoiDiagnostics(spaces, navigablePlaces, museumHallPois)
if (hallDiagnostics.eligibleSpaceCount > 0 && hallDiagnostics.hallPoiWithPositionCount === 0) {
warnSgsGuideModelDiagnostics('eligible hall spaces produced no renderable hall POIs', {
floorId: resolvedFloorId,
...hallDiagnostics
})
}
const hallPois = museumHallPois
.filter(isVisitorSearchPoi)
.map(toSgsRenderPoi)
const adaptedPois = dedupeRenderPoisById([
...hallPois,
...ordinaryPois,
...repositorySpacePois,
...repositoryBusinessPois
])
const floorMatchedPois = adaptedPois.filter((poi) => poi.floorId === resolvedFloorId)
const renderPois = floorMatchedPois
.filter((poi) => Array.isArray(poi.positionGltf) && poi.positionGltf.length === 3)
const renderHallPois = renderPois.filter((poi) => poi.kind === 'hall' || poi.primaryCategory === 'exhibition_hall')
logSgsGuideModelDiagnostics('floor POI category diagnostics', {
floorId: resolvedFloorId,
floorCode: matchedFloor.floorCode,
rawPoiCount: pois.length,
rawRepositoryBusinessPoiCount: repositoryBusinessPois.length,
rawRepositorySpacePoiCount: repositorySpacePois.length,
rawPoiTypeCounts: countByValue(pois, (poi) => poi.type),
rawPoiGroupCounts: countByValue(pois, (poi) => poi.poiGroup),
adaptedPoiCount: adaptedPois.length,
adaptedCategoryCounts: countByValue(adaptedPois, (poi) => poi.primaryCategory),
adaptedPoiCategoryCount: adaptedPois.filter((poi) => poi.primaryCategory === 'poi').length,
renderPoiCount: renderPois.length,
renderPoiCategoryCount: renderPois.filter((poi) => poi.primaryCategory === 'poi').length,
repositoryDroppedCategoryCounts: countDroppedRenderPoiCategories(adaptedPois, renderPois)
})
warnSgsGuideModelDiagnostics('floor render POI diagnostics', {
floorId: resolvedFloorId,
floorCode: matchedFloor.floorCode,
poiCount: renderPois.length,
hallPoiCount: renderHallPois.length,
hallPoiWithPositionCount: renderHallPois.filter((poi) => Boolean(poi.positionGltf)).length,
poiY: summarizeYValues(renderPois),
hallPoiY: summarizeYValues(renderHallPois),
fallbackHallY: floorPoiMedianY ?? null
})
if (hallDiagnostics.hallPoiWithPositionCount > 0 && !renderHallPois.length) {
warnSgsGuideModelDiagnostics('render POI filter removed all hall POIs', {
floorId: resolvedFloorId,
...hallDiagnostics,
renderPoiCount: renderPois.length
})
}
return renderPois
}
}
export const createGuideModelRepository = (): GuideModelRepository => {
if (dataSourceConfig.guideDataMode === 'api' || dataSourceConfig.guideDataMode === 'sdk') {
return new SgsSdkGuideModelRepository()
}
return new StaticGuideModelRepository()
}
export const guideModelRepository = createGuideModelRepository()