chore: freeze guide and explain update

This commit is contained in:
lyf
2026-06-24 18:00:25 +08:00
parent feb7310a46
commit 67c6609ae6
104 changed files with 3203572 additions and 40713 deletions

View File

@@ -0,0 +1,593 @@
import type {
ExplainTrack,
GuideLocationResolution,
GuideLocationResolutionStatus,
MediaAsset,
MuseumExhibit,
MuseumHall,
AudioPlayTargetType
} from '@/domain/museum'
import {
formatNavFloorLabel
} from '@/data/adapters/navAssetsAdapter'
import {
normalizeSameOriginPublicUrl
} from '@/utils/publicUrl'
import type {
GuideStaticContentPayload,
GuideStaticDataset,
GuideStaticExplainDataset,
GuideStaticExhibitPayload,
GuideStaticHallPayload,
GuideStaticOutlinePayload,
GuideStaticPoiBridgeEntryPayload,
GuideStaticPoiPayload,
GuideStaticStopPayload,
} from '@/data/providers/staticGuideDataProvider'
export interface GuideContentDataAdapterResult {
halls: MuseumHall[]
exhibits: MuseumExhibit[]
tracks: ExplainTrack[]
mediaAssets: MediaAsset[]
}
export interface GuideExplainDataAdapterResult {
halls: MuseumHall[]
exhibits: MuseumExhibit[]
tracks: ExplainTrack[]
mediaAssets: MediaAsset[]
}
const stringifyId = (value: string | number | null | undefined) => (
value === null || typeof value === 'undefined' ? undefined : String(value)
)
const isTruthyDeleted = (value: unknown) => value === true || value === 1 || value === '1'
const isEnabledStatus = (value: unknown) => value === undefined || value === null || value === true || value === 1 || value === '1'
const isVisibleRecord = (record: { deleted?: unknown; status?: unknown }) => (
!isTruthyDeleted(record.deleted) && isEnabledStatus(record.status)
)
const compact = (values: Array<string | null | undefined>) => values
.map((value) => value?.trim())
.filter(Boolean) as string[]
const firstText = (...values: Array<string | null | undefined>) => (
compact(values).find(Boolean) || ''
)
const parseJsonArray = (value: string | null | undefined): string[] => {
if (!value) return []
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
: []
} catch {
return value.split(',').map((item) => item.trim()).filter(Boolean)
}
}
const resolveImage = (...values: Array<string | null | undefined>) => normalizeSameOriginPublicUrl(firstText(
...values,
...values.flatMap(parseJsonArray)
))
const formatFloorId = (hall: GuideStaticHallPayload | undefined, poi?: GuideStaticPoiPayload) => (
firstText(
hall?.floorCode,
stringifyId(hall?.floorId),
stringifyId(poi?.floorId)
) || undefined
)
const formatFloorLabel = (floorId: string | undefined) => (
floorId
? floorId.startsWith('L') ? formatNavFloorLabel(floorId) : floorId
: undefined
)
const formatArea = (area?: number | null) => (
typeof area === 'number' && Number.isFinite(area) ? `${area}` : undefined
)
const locationActionText = (status: GuideLocationResolutionStatus) => {
if (status === 'hallFallback') return '查看所属展厅'
if (status === 'candidate') return '候选位置'
if (status === 'exact') return '查看三维位置'
return '暂无位置'
}
const locationNote = (status: GuideLocationResolutionStatus) => {
if (status === 'hallFallback') return '当前展示所属展厅位置,展品精确点位待补充。'
if (status === 'candidate') return '当前仅有候选点位,需人工确认后再用于正式位置预览。'
if (status === 'exact') return '当前支持三维位置预览,馆内路线规划待开放。'
return '暂无可用三维位置数据。'
}
const toLocationResolution = (
entry: GuideStaticPoiBridgeEntryPayload | undefined,
status: Exclude<GuideLocationResolutionStatus, 'unavailable'>,
sourcePoiId?: string
): GuideLocationResolution | undefined => {
const poiId = stringifyId(entry?.navPoiId)
if (!poiId) return undefined
const floorId = stringifyId(entry?.navFloorId)
return {
status,
poiId,
sourcePoiId: stringifyId(entry?.sourcePoiId) || sourcePoiId,
actionText: locationActionText(status),
previewOnly: true,
confidence: entry?.confidence || undefined,
method: entry?.method || undefined,
floorId,
floorLabel: formatFloorLabel(floorId),
note: locationNote(status)
}
}
const guideTargetKey = (guide: GuideStaticContentPayload) => {
const targetType = guide.targetType?.toUpperCase()
const targetId = stringifyId(guide.targetId)
return targetType && targetId ? `${targetType}:${targetId}` : ''
}
const normalizeAudioTargetType = (targetType: string | null | undefined): AudioPlayTargetType | undefined => {
const normalizedType = targetType?.toUpperCase()
if (normalizedType === 'STOP' || normalizedType === 'ITEM') {
return normalizedType
}
return undefined
}
const resolveAudioPlayTarget = (
guide: GuideStaticContentPayload | undefined,
exhibitId?: string
) => {
const guideTargetType = normalizeAudioTargetType(guide?.targetType)
const guideTargetId = stringifyId(guide?.targetId)
if (guideTargetType && guideTargetId) {
return {
playTargetType: guideTargetType,
playTargetId: guideTargetId
}
}
return exhibitId
? {
playTargetType: 'ITEM' as AudioPlayTargetType,
playTargetId: exhibitId
}
: {}
}
const primaryAudioUrl = (guide: GuideStaticContentPayload) => normalizeSameOriginPublicUrl(firstText(
guide.standardAudioUrl,
guide.extendedAudioUrl,
guide.audioUrl,
guide.standardAudioUrlEn,
guide.extendedAudioUrlEn
))
const primaryAudioDuration = (guide: GuideStaticContentPayload) => (
guide.standardAudioDuration
|| guide.extendedAudioDuration
|| guide.audioDuration
|| guide.standardAudioDurationEn
|| guide.extendedAudioDurationEn
|| undefined
)
const primaryGuideText = (guide: GuideStaticContentPayload | undefined, exhibit: GuideStaticExhibitPayload) => firstText(
guide?.standardText,
guide?.extendedText,
exhibit.description,
exhibit.narrationText,
guide?.standardTextEn,
guide?.extendedTextEn,
exhibit.descriptionEn
)
const toGuideLevelTags = (guide: GuideStaticContentPayload | undefined, exhibit: GuideStaticExhibitPayload) => {
const parsedTags = parseJsonArray(guide?.aiTags)
return compact([
exhibit.category,
exhibit.exhibitLevel,
...parsedTags,
guide ? '真实讲解词' : '展品档案'
])
}
const visibleHallsFrom = (halls: GuideStaticHallPayload[]) => halls.filter(isVisibleRecord)
const visibleOutlinesFrom = (outlines: GuideStaticOutlinePayload[]) => outlines.filter((outline) => !isTruthyDeleted(outline.deleted))
const visibleStopsFrom = (stops: GuideStaticStopPayload[]) => stops.filter((stop) => !isTruthyDeleted(stop.deleted))
const visibleGuidesFrom = (guides: GuideStaticContentPayload[]) => guides.filter(isVisibleRecord)
const toHallModels = (
sourceHalls: GuideStaticHallPayload[],
hallExhibitCounts: Map<string, number>,
hallBridgeById: Record<string, GuideStaticPoiBridgeEntryPayload>,
sgsPoiBridgeById: Record<string, GuideStaticPoiBridgeEntryPayload>
) => sourceHalls.map((hall) => {
const id = stringifyId(hall.id) || ''
const sourcePoiId = stringifyId(hall.poiId)
const bridgedHall = id ? toLocationResolution(hallBridgeById[id], 'exact', sourcePoiId) : undefined
const location = bridgedHall || (() => {
if (!sourcePoiId) return undefined
return toLocationResolution(sgsPoiBridgeById[sourcePoiId], 'exact', sourcePoiId)
})()
const floorId = location?.floorId || formatFloorId(hall)
const description = firstText(hall.description, hall.narrationText, hall.subtitle)
return {
id,
name: hall.name || hall.hallCode || `展厅 ${id}`,
floorId,
floorLabel: formatFloorLabel(floorId),
description,
image: resolveImage(hall.coverImageUrl, hall.galleryUrls),
exhibitCount: hallExhibitCounts.get(id) || hall.exhibitCount || 0,
area: formatArea(hall.areaSqm),
poiId: location?.poiId,
location
}
})
export const createGuideExplainDataAdapter = (dataset: GuideStaticExplainDataset): GuideExplainDataAdapterResult => {
const visibleHalls = visibleHallsFrom(dataset.halls)
const visibleOutlines = visibleOutlinesFrom(dataset.outlines)
const visibleStops = visibleStopsFrom(dataset.guideStops)
const visibleGuides = visibleGuidesFrom(dataset.guideContents)
const hallById = new Map(visibleHalls.map((hall) => [stringifyId(hall.id), hall]))
const outlineById = new Map(visibleOutlines.map((outline) => [stringifyId(outline.id), outline]))
const stopById = new Map(visibleStops.map((stop) => [stringifyId(stop.id), stop]))
const hallBridgeById = dataset.poiBridge?.hallToNavPoiId || {}
const sgsPoiBridgeById = dataset.poiBridge?.sgsPoiToNavPoiId || {}
const findHallForOutline = (outline: GuideStaticOutlinePayload | undefined) => {
let current = outline
let guard = 0
while (current && guard < 16) {
const parentId = stringifyId(current.parentId)
if (parentId && hallById.has(parentId)) {
return hallById.get(parentId)
}
current = parentId ? outlineById.get(parentId) : undefined
guard += 1
}
return undefined
}
const findHallForStop = (stop: GuideStaticStopPayload | undefined) => (
findHallForOutline(outlineById.get(stringifyId(stop?.outlineId)))
)
const resolveHallLocation = (
hall: GuideStaticHallPayload | undefined,
status: Exclude<GuideLocationResolutionStatus, 'unavailable'>
) => {
const hallId = stringifyId(hall?.id)
const sourcePoiId = stringifyId(hall?.poiId)
const bridgedHall = hallId ? toLocationResolution(hallBridgeById[hallId], status, sourcePoiId) : undefined
if (bridgedHall) return bridgedHall
return sourcePoiId ? toLocationResolution(sgsPoiBridgeById[sourcePoiId], status, sourcePoiId) : undefined
}
const resolveSgsPoiLocation = (
sourcePoiIds: Array<string | number | null | undefined>,
status: Exclude<GuideLocationResolutionStatus, 'unavailable'>
) => {
for (const rawId of sourcePoiIds) {
const sourcePoiId = stringifyId(rawId)
if (!sourcePoiId) continue
const location = toLocationResolution(sgsPoiBridgeById[sourcePoiId], status, sourcePoiId)
if (location) return location
}
return undefined
}
const hallGuideCounts = new Map<string, number>()
visibleGuides.forEach((guide) => {
const stop = guide.targetType?.toUpperCase() === 'STOP'
? stopById.get(stringifyId(guide.targetId))
: undefined
const hall = findHallForStop(stop)
const hallId = stringifyId(hall?.id)
if (hallId) {
hallGuideCounts.set(hallId, (hallGuideCounts.get(hallId) || 0) + 1)
}
})
const halls = toHallModels(visibleHalls, hallGuideCounts, hallBridgeById, sgsPoiBridgeById)
const mediaAssets: MediaAsset[] = visibleGuides.map((guide) => {
const id = `media-${stringifyId(guide.id) || 'unknown'}`
const url = primaryAudioUrl(guide)
const title = guide.title || '该讲解'
return {
id,
type: 'audio',
url: url || undefined,
duration: primaryAudioDuration(guide),
language: 'zh-CN',
available: Boolean(url),
unavailableReason: url ? undefined : `${title}暂无已发布音频,当前提供图文讲解。`
}
})
const mediaByGuideId = new Map(mediaAssets.map((media) => [media.id.replace(/^media-/, ''), media]))
const exhibits: MuseumExhibit[] = visibleGuides.map((guide) => {
const id = stringifyId(guide.exhibitId) || `guide-${stringifyId(guide.id) || ''}`
const guideId = stringifyId(guide.id) || ''
const stop = guide.targetType?.toUpperCase() === 'STOP'
? stopById.get(stringifyId(guide.targetId))
: undefined
const outline = outlineById.get(stringifyId(stop?.outlineId))
const hall = findHallForStop(stop)
const sourcePoiId = stringifyId(guide.poiId || stop?.poiId || hall?.poiId)
const preciseLocation = resolveSgsPoiLocation([guide.poiId, stop?.poiId], 'exact')
const location = preciseLocation || resolveHallLocation(hall, 'hallFallback')
const floorId = location?.floorId || formatFloorId(hall, undefined) || stop?.floorCode || stringifyId(stop?.floorId)
const audioUrl = primaryAudioUrl(guide)
const audioPlayTarget = resolveAudioPlayTarget(guide, id)
const title = firstText(guide.title, stop?.name)?.replace(/\s*讲解\s*$/, '') || `讲解 ${guideId || id}`
return {
id,
name: title,
hallId: stringifyId(hall?.id),
hallName: hall?.name || undefined,
floorId,
floorLabel: formatFloorLabel(floorId),
image: resolveImage(stop?.coverImageUrl, guide.mediaGallery, hall?.coverImageUrl, hall?.galleryUrls),
description: firstText(guide.standardText, guide.extendedText, stop?.name, guide.standardTextEn, guide.extendedTextEn),
poiId: location?.poiId,
sourcePoiId,
location,
tags: compact([
hall?.name,
outline?.name,
guide.contentType || undefined,
guide ? '真实讲解词' : undefined
]),
guideContentId: guideId,
guideTitle: guide.title || undefined,
guideText: firstText(guide.standardText, guide.extendedText, guide.standardTextEn, guide.extendedTextEn) || undefined,
audioUrl: audioUrl || undefined,
audioDuration: primaryAudioDuration(guide),
audioAvailable: Boolean(audioUrl),
...audioPlayTarget
}
})
const tracks: ExplainTrack[] = visibleGuides.map((guide) => {
const id = stringifyId(guide.id) || ''
const exhibitId = stringifyId(guide.exhibitId) || `guide-${id}`
const stop = guide.targetType?.toUpperCase() === 'STOP'
? stopById.get(stringifyId(guide.targetId))
: undefined
const hall = findHallForStop(stop)
const sourcePoiId = stringifyId(guide.poiId || stop?.poiId || hall?.poiId)
const preciseLocation = resolveSgsPoiLocation([guide.poiId, stop?.poiId], 'exact')
const location = preciseLocation || resolveHallLocation(hall, 'hallFallback')
const floorId = location?.floorId || formatFloorId(hall, undefined) || stop?.floorCode || stringifyId(stop?.floorId)
const media = mediaByGuideId.get(id)
const audioPlayTarget = resolveAudioPlayTarget(guide, exhibitId)
return {
id: `track-${id}`,
exhibitId,
hallId: stringifyId(hall?.id),
title: guide.title || stop?.name || `讲解 ${id}`,
summary: firstText(guide.standardText, guide.extendedText, stop?.name),
mediaId: media?.id,
coverImage: resolveImage(stop?.coverImageUrl, guide.mediaGallery, hall?.coverImageUrl, hall?.galleryUrls),
poiId: location?.poiId,
sourcePoiId,
location,
floorId,
available: media?.available === true,
...audioPlayTarget
}
})
return {
halls,
exhibits,
tracks,
mediaAssets
}
}
export const createGuideContentDataAdapter = (dataset: GuideStaticDataset): GuideContentDataAdapterResult => {
const visibleHalls = visibleHallsFrom(dataset.halls)
const visibleOutlines = visibleOutlinesFrom(dataset.outlines)
const visibleStops = visibleStopsFrom(dataset.guideStops)
const visibleExhibits = dataset.exhibits.filter(isVisibleRecord)
const visibleGuides = visibleGuidesFrom(dataset.guideContents)
const hallById = new Map(visibleHalls.map((hall) => [stringifyId(hall.id), hall]))
const outlineById = new Map(visibleOutlines.map((outline) => [stringifyId(outline.id), outline]))
const stopById = new Map(visibleStops.map((stop) => [stringifyId(stop.id), stop]))
const poiById = new Map(dataset.pois.map((poi) => [stringifyId(poi.id), poi]))
const exhibitById = new Map(visibleExhibits.map((exhibit) => [stringifyId(exhibit.id), exhibit]))
const hallBridgeById = dataset.poiBridge?.hallToNavPoiId || {}
const sgsPoiBridgeById = dataset.poiBridge?.sgsPoiToNavPoiId || {}
const resolveSgsPoiLocation = (
sourcePoiIds: Array<string | number | null | undefined>,
status: Exclude<GuideLocationResolutionStatus, 'unavailable'>
) => {
for (const rawId of sourcePoiIds) {
const sourcePoiId = stringifyId(rawId)
if (!sourcePoiId) continue
const location = toLocationResolution(sgsPoiBridgeById[sourcePoiId], status, sourcePoiId)
if (location) return location
}
return undefined
}
const resolveHallLocation = (
hall: GuideStaticHallPayload | undefined,
status: Exclude<GuideLocationResolutionStatus, 'unavailable'>
) => {
const hallId = stringifyId(hall?.id)
const sourcePoiId = stringifyId(hall?.poiId)
const bridgedHall = hallId ? toLocationResolution(hallBridgeById[hallId], status, sourcePoiId) : undefined
return bridgedHall || resolveSgsPoiLocation([sourcePoiId], status)
}
const guideByExhibitId = new Map<string, GuideStaticContentPayload>()
const guideByTarget = new Map<string, GuideStaticContentPayload>()
for (const guide of visibleGuides) {
const key = guideTargetKey(guide)
if (key && !guideByTarget.has(key)) {
guideByTarget.set(key, guide)
}
const exhibitId = stringifyId(guide.exhibitId)
if (exhibitId && !guideByExhibitId.has(exhibitId)) {
guideByExhibitId.set(exhibitId, guide)
}
if (guide.targetType?.toUpperCase() === 'ITEM') {
const targetId = stringifyId(guide.targetId)
if (targetId && !guideByExhibitId.has(targetId)) {
guideByExhibitId.set(targetId, guide)
}
}
}
const hallExhibitCounts = new Map<string, number>()
for (const exhibit of visibleExhibits) {
const hallId = stringifyId(exhibit.hallId)
if (hallId) {
hallExhibitCounts.set(hallId, (hallExhibitCounts.get(hallId) || 0) + 1)
}
}
const halls = toHallModels(visibleHalls, hallExhibitCounts, hallBridgeById, sgsPoiBridgeById)
const exhibits: MuseumExhibit[] = visibleExhibits.map((exhibit) => {
const id = stringifyId(exhibit.id) || ''
const hall = hallById.get(stringifyId(exhibit.hallId))
const outline = outlineById.get(stringifyId(exhibit.outlineId))
const stop = stopById.get(stringifyId(exhibit.stopId))
const itemGuide = guideByExhibitId.get(id)
const stopGuide = stop ? guideByTarget.get(`STOP:${stringifyId(stop.id)}`) : undefined
const guide = itemGuide || stopGuide
const audioUrl = guide ? primaryAudioUrl(guide) : ''
const audioPlayTarget = resolveAudioPlayTarget(guide, id)
const sourcePoiId = stringifyId(exhibit.poiId || stop?.poiId || guide?.poiId || hall?.poiId)
const sourcePoi = sourcePoiId ? poiById.get(sourcePoiId) : undefined
const preciseLocation = resolveSgsPoiLocation([exhibit.poiId, stop?.poiId, guide?.poiId], 'exact')
const location = preciseLocation || resolveHallLocation(hall, 'hallFallback')
const floorId = location?.floorId || formatFloorId(hall, sourcePoi)
const description = primaryGuideText(guide, exhibit)
return {
id,
name: exhibit.name || exhibit.exhibitCode || guide?.title || `展品 ${id}`,
hallId: stringifyId(exhibit.hallId),
hallName: hall?.name || undefined,
floorId,
floorLabel: formatFloorLabel(floorId),
image: resolveImage(exhibit.coverImageUrl, exhibit.galleryUrls, guide?.mediaGallery),
description,
poiId: location?.poiId,
sourcePoiId,
location,
year: exhibit.era || undefined,
material: exhibit.origin || undefined,
size: exhibit.exhibitCode || exhibit.dimensions || undefined,
tags: compact([
hall?.name,
outline?.name,
stop?.name,
...toGuideLevelTags(guide, exhibit)
]),
guideContentId: stringifyId(guide?.id),
guideTitle: guide?.title || undefined,
guideText: description || undefined,
audioUrl: audioUrl || undefined,
audioDuration: guide ? primaryAudioDuration(guide) : undefined,
audioAvailable: Boolean(audioUrl),
...audioPlayTarget
}
})
const mediaAssets: MediaAsset[] = visibleGuides.map((guide) => {
const id = `media-${stringifyId(guide.id) || 'unknown'}`
const url = primaryAudioUrl(guide)
const title = guide.title || '该讲解'
return {
id,
type: 'audio',
url: url || undefined,
duration: primaryAudioDuration(guide),
language: 'zh-CN',
available: Boolean(url),
unavailableReason: url ? undefined : `${title}暂无已发布音频,当前提供图文讲解。`
}
})
const mediaByGuideId = new Map(mediaAssets.map((media) => [media.id.replace(/^media-/, ''), media]))
const tracks: ExplainTrack[] = visibleGuides.map((guide) => {
const id = stringifyId(guide.id) || ''
const targetType = guide.targetType?.toUpperCase()
const targetId = stringifyId(guide.targetId)
const exhibit = stringifyId(guide.exhibitId)
? exhibitById.get(stringifyId(guide.exhibitId))
: targetType === 'ITEM' && targetId ? exhibitById.get(targetId) : undefined
const stop = targetType === 'STOP' && targetId ? stopById.get(targetId) : undefined
const hall = exhibit ? hallById.get(stringifyId(exhibit.hallId)) : undefined
const sourcePoiId = stringifyId(exhibit?.poiId || stop?.poiId || guide.poiId || hall?.poiId)
const sourcePoi = sourcePoiId ? poiById.get(sourcePoiId) : undefined
const preciseLocation = resolveSgsPoiLocation([exhibit?.poiId, stop?.poiId, guide.poiId], 'exact')
const location = preciseLocation || resolveHallLocation(hall, 'hallFallback')
const floorId = location?.floorId || formatFloorId(hall, sourcePoi)
const media = mediaByGuideId.get(id)
const audioPlayTarget = resolveAudioPlayTarget(guide, stringifyId(exhibit?.id))
return {
id: `track-${id}`,
exhibitId: stringifyId(exhibit?.id),
hallId: stringifyId(hall?.id),
title: guide.title || exhibit?.name || stop?.name || `讲解 ${id}`,
summary: firstText(guide.standardText, guide.extendedText, exhibit?.description, stop?.name),
mediaId: media?.id,
coverImage: resolveImage(exhibit?.coverImageUrl, exhibit?.galleryUrls, stop?.coverImageUrl, guide.mediaGallery),
poiId: location?.poiId,
sourcePoiId,
location,
floorId,
available: media?.available === true,
...audioPlayTarget
}
})
return {
halls,
exhibits,
tracks,
mediaAssets
}
}

View File

@@ -0,0 +1,254 @@
import type {
GuideRouteConnectorPoint,
GuideRouteEndpoint,
GuideRouteFloorSegment,
GuideRoutePoint,
GuideRouteResult,
GuideRouteTarget
} from '@/domain/museum'
import {
formatNavFloorLabel
} from '@/data/adapters/navAssetsAdapter'
import type {
StaticNavAnchorPayload,
StaticNavDataPayload,
StaticNavPoiPayload,
StaticNavRouteEdgePayload,
StaticNavRouteGraphPayload,
StaticNavRouteNodePayload
} from '@/data/providers/staticNavAssetsProvider'
export interface NavRouteNode {
id: string
floorId: string
position: [number, number, number]
kind?: string
connectorType?: string
}
export interface NavRouteEdge {
id: string
fromNodeId: string
toNodeId: string
weight: number
edgeType: string
floorIds: string[]
}
export interface NavRouteAnchor {
poiId: string
name: string
floorId: string
floorLabel: string
position: [number, number, number]
routeNodeId: string
baseRouteNodeId?: string
connectorType?: string
}
export interface NavRouteDataset {
nodes: Map<string, NavRouteNode>
edges: NavRouteEdge[]
anchorsByPoiId: Map<string, NavRouteAnchor>
targets: GuideRouteTarget[]
}
const isCoordinate = (value: unknown): value is [number, number, number] => (
Array.isArray(value)
&& value.length === 3
&& value.every((item) => Number.isFinite(item))
)
const toGltfCoordinate = (coordinate: [number, number, number]): [number, number, number] => [
coordinate[0],
coordinate[2],
-coordinate[1]
]
const getNodeFloorId = (node: StaticNavRouteNodePayload) => (
node.floorId || node.floor || ''
)
const getNodePosition = (node: StaticNavRouteNodePayload) => {
if (isCoordinate(node.routePosition)) return node.routePosition
if (isCoordinate(node.position)) return node.position
if (isCoordinate(node.displayPosition)) return node.displayPosition
return null
}
const toRouteNode = (node: StaticNavRouteNodePayload): NavRouteNode | null => {
const floorId = getNodeFloorId(node)
const position = getNodePosition(node)
if (!node.id || !floorId || !position) return null
return {
id: node.id,
floorId,
position: toGltfCoordinate(position),
kind: node.kind,
connectorType: node.connectorType || undefined
}
}
const toRouteEdge = (edge: StaticNavRouteEdgePayload, nodes: Map<string, NavRouteNode>): NavRouteEdge | null => {
if (!edge.id || !edge.fromNodeId || !edge.toNodeId) return null
if (!nodes.has(edge.fromNodeId) || !nodes.has(edge.toNodeId)) return null
const weight = Number(edge.weight ?? edge.distance3D)
if (!Number.isFinite(weight) || weight <= 0) return null
return {
id: edge.id,
fromNodeId: edge.fromNodeId,
toNodeId: edge.toNodeId,
weight,
edgeType: edge.edgeType || 'route_edge',
floorIds: edge.floorIds || []
}
}
const getAnchorPosition = (anchor: StaticNavAnchorPayload) => {
if (isCoordinate(anchor.routePosition)) return anchor.routePosition
if (isCoordinate(anchor.navPosition)) return anchor.navPosition
if (isCoordinate(anchor.displayPosition)) return anchor.displayPosition
if (isCoordinate(anchor.position)) return anchor.position
return null
}
const toRouteAnchor = (
anchor: StaticNavAnchorPayload,
nodes: Map<string, NavRouteNode>
): NavRouteAnchor | null => {
const floorId = anchor.floorId || anchor.floor || ''
const routeNodeId = anchor.routeNodeId || anchor.baseRouteNodeId || ''
const position = getAnchorPosition(anchor)
if (!anchor.poiId || !floorId || !routeNodeId || !position) return null
if (!nodes.has(routeNodeId)) return null
return {
poiId: anchor.poiId,
name: anchor.displayName || anchor.name || anchor.poiId,
floorId,
floorLabel: formatNavFloorLabel(floorId),
position: toGltfCoordinate(position),
routeNodeId,
baseRouteNodeId: anchor.baseRouteNodeId,
connectorType: anchor.connectorType || undefined
}
}
const toTarget = (
anchor: NavRouteAnchor,
poi?: StaticNavPoiPayload
): GuideRouteTarget => ({
poiId: anchor.poiId,
name: poi?.name || anchor.name,
floorId: anchor.floorId,
floorLabel: anchor.floorLabel,
categoryLabel: poi?.primaryCategoryZh,
positionGltf: poi?.positionGltf || anchor.position,
routeNodeId: anchor.routeNodeId
})
export const createNavRouteDataset = (
navData: StaticNavDataPayload,
routeGraph: StaticNavRouteGraphPayload,
pois: StaticNavPoiPayload[]
): NavRouteDataset => {
const nodes = new Map<string, NavRouteNode>()
routeGraph.routeNodes
.map(toRouteNode)
.filter((node): node is NavRouteNode => Boolean(node))
.forEach((node) => {
nodes.set(node.id, node)
})
const edges = routeGraph.routeEdges
.map((edge) => toRouteEdge(edge, nodes))
.filter((edge): edge is NavRouteEdge => Boolean(edge))
const poiById = new Map(pois.map((poi) => [poi.id, poi]))
const anchorsByPoiId = new Map<string, NavRouteAnchor>()
navData.navAnchors
.map((anchor) => toRouteAnchor(anchor, nodes))
.filter((anchor): anchor is NavRouteAnchor => Boolean(anchor))
.forEach((anchor) => {
anchorsByPoiId.set(anchor.poiId, anchor)
})
const targets = [...anchorsByPoiId.values()]
.map((anchor) => toTarget(anchor, poiById.get(anchor.poiId)))
.sort((a, b) => (
a.floorId.localeCompare(b.floorId, undefined, { numeric: true })
|| a.name.localeCompare(b.name, 'zh-Hans-CN')
))
return {
nodes,
edges,
anchorsByPoiId,
targets
}
}
export const toRoutePoint = (node: NavRouteNode): GuideRoutePoint => ({
nodeId: node.id,
floorId: node.floorId,
position: node.position
})
export const createRouteResult = (
start: NavRouteAnchor,
end: NavRouteAnchor,
nodes: NavRouteNode[],
totalWeight: number
): GuideRouteResult => {
const points = nodes.map(toRoutePoint)
const floorSegments: GuideRouteFloorSegment[] = []
points.forEach((point) => {
const currentSegment = floorSegments[floorSegments.length - 1]
if (currentSegment?.floorId === point.floorId) {
currentSegment.points.push(point)
return
}
floorSegments.push({
floorId: point.floorId,
floorLabel: formatNavFloorLabel(point.floorId),
points: [point]
})
})
const connectorPoints: GuideRouteConnectorPoint[] = nodes
.filter((node) => node.kind?.includes('connector') || Boolean(node.connectorType))
.map((node) => ({
nodeId: node.id,
floorId: node.floorId,
floorLabel: formatNavFloorLabel(node.floorId),
position: node.position,
connectorType: node.connectorType
}))
const toEndpoint = (anchor: NavRouteAnchor): GuideRouteEndpoint => ({
poiId: anchor.poiId,
name: anchor.name,
floorId: anchor.floorId,
floorLabel: anchor.floorLabel,
routeNodeId: anchor.routeNodeId,
position: anchor.position
})
return {
id: `route-${start.poiId}-${end.poiId}`,
start: toEndpoint(start),
end: toEndpoint(end),
distanceMeters: Number(totalWeight.toFixed(1)),
nodeIds: nodes.map((node) => node.id),
points,
floorSegments,
connectorPoints
}
}