Files
frontend-miniapp/src/data/adapters/sgsSdkGuideAdapter.ts
lyf 39f1fc1198
Some checks failed
CI / verify (push) Has been cancelled
修复馆内三维点位显示异常
2026-07-04 23:42:05 +08:00

981 lines
29 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 {
GuideDataIntegrityIssue,
GuideDataIntegrityReport,
GuideDiagnosticsStatus,
GuideFloorDetail,
GuideLocationPreview,
GuideMapDiagnostics,
GuideRouteReadiness,
MuseumCategory,
MuseumFloor,
MuseumPoi,
ExplainGuideStop,
AudioPlayTargetType
} from '@/domain/museum'
import {
NAV_ROUTE_UNAVAILABLE_MESSAGE
} from '@/domain/guideReadiness'
import {
isIndoorNavigableFloor
} from '@/domain/guideFloor'
import type {
SgsFloorDiagnosticsPayload,
SgsGuideStopPayload,
SgsMapDiagnosticsPayload,
SgsNavigablePlacePayload,
SgsPoiPayload,
SgsPositionPayload,
SgsSdkFloorSummaryPayload,
SgsSdkManifestPayload,
SgsSpacePayload
} from '@/data/providers/sgsSdkApiProvider'
const defaultCategory: MuseumCategory = {
id: 'poi',
label: '点位',
iconType: 'poi'
}
const businessCategory: MuseumCategory = {
id: 'business_poi',
label: '运营服务',
iconType: 'business'
}
const hallCategory: MuseumCategory = {
id: 'exhibition_hall',
label: '展厅',
iconType: 'exhibition_hall'
}
const hallEntranceCategory: MuseumCategory = {
id: 'exhibition_hall_entrance',
label: '展厅出入口',
iconType: 'hall_entrance'
}
const categoryBySgsType: Record<string, MuseumCategory & { accessible?: boolean }> = {
poi: {
id: 'poi',
label: '点位',
iconType: 'poi'
},
device_terminal: {
id: 'poi',
label: '点位',
iconType: 'poi'
},
operation_experience: {
id: 'operation_experience',
label: '导览点位',
iconType: 'poi'
},
toilet: {
id: 'basic_service_facility',
label: '卫生间',
iconType: 'toilet'
},
accessible_toilet: {
id: 'accessibility_special_service',
label: '无障碍卫生间',
iconType: 'accessible_toilet',
accessible: true
},
elevator: {
id: 'transport_circulation',
label: '电梯',
iconType: 'elevator',
accessible: true
},
stairs: {
id: 'transport_circulation',
label: '楼梯',
iconType: 'stairs'
},
escalator: {
id: 'transport_circulation',
label: '扶梯',
iconType: 'escalator'
},
entrance_exit: {
id: 'transport_circulation',
label: '出入口',
iconType: 'entrance_exit'
},
service_desk: {
id: 'basic_service_facility',
label: '服务台',
iconType: 'service_desk'
},
mother_baby_room: {
id: 'basic_service_facility',
label: '母婴室',
iconType: 'mother_baby_room',
accessible: true
},
locker: {
id: 'basic_service_facility',
label: '存包处',
iconType: 'locker'
},
rental_service: {
id: 'basic_service_facility',
label: '租赁服务',
iconType: 'rental_service',
accessible: true
},
ticket_office: {
id: 'basic_service_facility',
label: '售票处',
iconType: 'ticket_office'
}
}
export const stringifyId = (value: string | number | null | undefined) => (
value === null || typeof value === 'undefined' ? '' : String(value)
)
const toNumber = (value: number | null | undefined, fallback = 0) => (
Number.isFinite(Number(value)) ? Number(value) : fallback
)
const optionalNumber = (value: number | null | undefined) => (
Number.isFinite(Number(value)) ? Number(value) : undefined
)
const normalizedText = (value?: string | null) => (value || '').trim()
const normalizePositionSource = (source: SgsPositionPayload): [number, number, number] | undefined => {
const x = Number(source.x)
const y = Number(source.y)
const z = Number(source.z)
if (![x, y, z].every(Number.isFinite)) return undefined
return [x, y, z]
}
const normalizeStatus = (status?: string): GuideDiagnosticsStatus => {
if (status === 'OK' || status === 'WARN' || status === 'ERROR') return status
return 'WARN'
}
export const formatSgsFloorLabel = (floorCode?: string | null, floorName?: string | null) => {
if (floorCode === 'EXTERIOR') return '馆外'
const basementMatch = floorCode?.match(/^L-(\d+(?:\.\d+)?)$/)
if (basementMatch) return `B${basementMatch[1]}`
const floorMatch = floorCode?.match(/^L(\d+(?:\.\d+)?)$/)
if (floorMatch) return `${floorMatch[1]}F`
return floorName || floorCode || '未知楼层'
}
export const toMuseumFloorFromSgs = (floor: SgsSdkFloorSummaryPayload): MuseumFloor => ({
id: stringifyId(floor.floorId),
label: formatSgsFloorLabel(floor.floorCode, floor.floorName),
order: toNumber(floor.sortOrder)
})
export const buildSgsFloorAliases = (floors: SgsSdkFloorSummaryPayload[]) => {
const aliases = new Map<string, string>()
floors.forEach((floor) => {
if (!isIndoorNavigableFloor(floor)) return
const floorId = stringifyId(floor.floorId)
if (!floorId) return
const label = formatSgsFloorLabel(floor.floorCode, floor.floorName)
aliases.set(floorId, floorId)
aliases.set(floorId.toLowerCase(), floorId)
if (floor.floorCode) {
aliases.set(String(floor.floorCode), floorId)
aliases.set(String(floor.floorCode).toLowerCase(), floorId)
}
if (floor.floorName) {
aliases.set(String(floor.floorName), floorId)
aliases.set(String(floor.floorName).toLowerCase(), floorId)
}
aliases.set(label, floorId)
aliases.set(label.toLowerCase(), floorId)
})
return aliases
}
const exhibitionSpaceTypeWhitelist = new Set([
'exhibition_hall',
'theater',
'education_activity',
'ramp'
])
const exhibitionHallKeywords = [
'展厅',
'临展',
'展览',
'影院',
'球幕',
'巨幕',
'报告厅',
'活动室',
'科普',
'实验室',
'展览坡道',
'宇宙',
'地球',
'演化',
'恐龙',
'人类',
'生物',
'生态',
'家园'
]
const nonHallNavigablePlaceTypes = new Set([
'elevator',
'stairs',
'stair',
'escalator',
'lift',
'toilet',
'accessible_toilet',
'restroom',
'卫生间',
'洗手间',
'无障碍卫生间',
'电梯',
'楼梯',
'扶梯'
])
export interface SgsHallPoiDiagnostics {
spaceCount: number
eligibleSpaceCount: number
navigablePlaceCount: number
hallPlaceCount: number
hallPoiCount: number
hallPoiWithPositionCount: number
skippedSpaceCount: number
skippedPlaceCount: number
}
const businessTypeLabels: Record<string, string> = {
shop: '商店',
restaurant: '餐饮',
cafe: '咖啡',
vending: '售卖机',
bookstore: '书店',
cultural_shop: '文创商店',
photo_spot: '打卡点'
}
interface SgsHallPoiBuildOptions {
fallbackY?: number
}
const sgsSpaceCenterConfidence = 'backend-sgs-sdk-space-center'
const sgsSpaceBoundaryCenterConfidence = 'backend-sgs-sdk-space-boundary-center'
const sgsHallEntranceConfidence = 'backend-sgs-sdk-hall-entrance'
const hasExhibitionKeyword = (value?: string | null) => (
exhibitionHallKeywords.some((keyword) => normalizedText(value).includes(keyword))
)
const searchableSpaceText = (space: SgsSpacePayload) => [
space.name,
space.type,
space.sourceNodeName
]
.map((value) => normalizedText(value))
.filter(Boolean)
.join(' ')
const parseBoundaryWktCenter = (
boundaryWkt?: string | null,
fallbackY?: number
): [number, number, number] | undefined => {
const matches = [...(boundaryWkt || '').matchAll(/(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)/g)]
const points = matches
.map((match) => [Number(match[1]), Number(match[2])] as const)
.filter(([x, z]) => Number.isFinite(x) && Number.isFinite(z))
if (!points.length || !Number.isFinite(fallbackY)) return undefined
const xs = points.map(([x]) => x)
const zs = points.map(([, z]) => z)
const x = (Math.min(...xs) + Math.max(...xs)) / 2
const z = (Math.min(...zs) + Math.max(...zs)) / 2
return [x, fallbackY as number, z]
}
const normalizePositionSourceWithFallbackY = (
source: SgsPositionPayload | null | undefined,
fallbackY?: number
): [number, number, number] | undefined => {
if (!source) return undefined
const x = Number(source.x)
const rawY = Number(source.y)
const y = Number.isFinite(rawY) ? rawY : Number(fallbackY)
const z = Number(source.z)
if (![x, y, z].every(Number.isFinite)) return undefined
return [x, y, z]
}
const normalizeSpacePosition = (
space: SgsSpacePayload,
fallbackY?: number
) => {
const centerPosition = normalizePositionSourceWithFallbackY(space.center, fallbackY)
if (centerPosition) {
return {
position: centerPosition,
sourceConfidence: sgsSpaceCenterConfidence
}
}
const boundaryCenter = parseBoundaryWktCenter(space.boundaryWkt, fallbackY)
return boundaryCenter
? {
position: boundaryCenter,
sourceConfidence: sgsSpaceBoundaryCenterConfidence
}
: undefined
}
export const isExhibitionHallSpace = (space: SgsSpacePayload) => {
const type = normalizedText(space.type).toLowerCase()
const name = normalizedText(space.name)
const searchableText = searchableSpaceText(space)
if (!exhibitionSpaceTypeWhitelist.has(type)) return false
if (type === 'exhibition_hall') return true
if (type === 'ramp') return name.includes('展览坡道')
return hasExhibitionKeyword(searchableText)
}
export const isExhibitionHallNavigablePlace = (place: SgsNavigablePlacePayload) => {
const placeTypes = [
place.category,
place.type,
place.typeCode,
place.typeName
]
.map((value) => normalizedText(value).toLowerCase())
.filter(Boolean)
if (placeTypes.some((type) => nonHallNavigablePlaceTypes.has(type))) return false
const searchableText = [
place.name,
place.ownerName,
place.category,
place.type,
place.typeCode,
place.typeName
]
.map((value) => normalizedText(value))
.filter(Boolean)
.join(' ')
return hasExhibitionKeyword(searchableText)
}
const normalizedHallName = (value?: string | null) => normalizedText(value)
.replace(/[\s()【】[\]_-]/g, '')
const normalizePosition = (poi: SgsPoiPayload): [number, number, number] | undefined => normalizePositionSource(
poi.position || {
x: poi.x,
y: poi.y,
z: poi.z
}
)
const normalizePlacePosition = (
place: SgsNavigablePlacePayload,
fallbackY?: number
): [number, number, number] | undefined => normalizePositionSourceWithFallbackY(
place.position
? {
x: place.position.x,
y: place.position.y,
z: place.position.z
}
: {
x: place.x,
y: place.y,
z: place.z
},
fallbackY
)
const categoryFor = (poi: SgsPoiPayload): MuseumCategory & { accessible?: boolean } => {
const normalizedType = poi.type?.trim().toLowerCase()
const normalizedBusinessType = poi.businessType?.trim().toLowerCase()
if (String(poi.poiGroup || '').toUpperCase() === 'BUSINESS') {
return {
...businessCategory,
label: normalizedBusinessType ? businessTypeLabels[normalizedBusinessType] || poi.typeName || businessCategory.label : poi.typeName || businessCategory.label,
iconType: normalizedBusinessType || businessCategory.iconType
}
}
if (String(poi.poiGroup || '').toUpperCase() === 'OTHER') {
return categoryBySgsType[normalizedType || ''] || defaultCategory
}
if (normalizedType && categoryBySgsType[normalizedType]) {
return categoryBySgsType[normalizedType]
}
if (poi.typeName) {
return {
...defaultCategory,
label: poi.typeName,
iconType: normalizedType || defaultCategory.iconType
}
}
return defaultCategory
}
const kindForSgsPoi = (poi: SgsPoiPayload, category: MuseumCategory) => {
if (category.id === 'business_poi') return 'facility'
if (category.id === 'operation_experience') return 'guide'
return 'facility'
}
export const toMuseumPoiFromSgs = (
poi: SgsPoiPayload,
floors: SgsSdkFloorSummaryPayload[]
): MuseumPoi => {
const floorId = stringifyId(poi.floorId)
const matchedFloor = floors.find((floor) => stringifyId(floor.floorId) === floorId || floor.floorCode === poi.floorCode)
const category = categoryFor(poi)
return {
id: stringifyId(poi.id),
name: poi.name?.trim() || `未命名点位 ${stringifyId(poi.id)}`,
floorId,
floorLabel: formatSgsFloorLabel(matchedFloor?.floorCode || poi.floorCode, matchedFloor?.floorName),
primaryCategory: {
id: category.id,
label: category.label,
iconType: category.iconType
},
categories: [
{
id: category.id,
label: category.label,
iconType: category.iconType
}
],
positionGltf: normalizePosition(poi),
sourceObjectName: poi.anchorNodeName || undefined,
sourceConfidence: 'backend-sgs-sdk',
navigationReadiness: '位置预览',
accessible: category.accessible === true,
kind: kindForSgsPoi(poi, category)
}
}
const normalizeAudioTargetType = (targetType?: string | null): AudioPlayTargetType | undefined => {
const normalized = targetType?.toUpperCase()
if (normalized === 'ITEM' || normalized === 'STOP') return normalized
return undefined
}
export const toExplainGuideStopFromSgs = (stop: SgsGuideStopPayload): ExplainGuideStop | null => {
const id = stringifyId(stop.id)
if (!id) return null
return {
id,
name: normalizedText(stop.name) || `讲解点${id}`,
hallId: stringifyId(stop.hallId) || undefined,
hallName: normalizedText(stop.hallName) || undefined,
floorId: stringifyId(stop.floorId) || undefined,
targetType: normalizeAudioTargetType(stop.targetType) || 'STOP',
targetId: stringifyId(stop.targetId) || id,
coverImageUrl: normalizedText(stop.coverImageUrl) || undefined,
description: normalizedText(stop.description) || undefined,
hasAudio: stop.hasAudio === true || Boolean(normalizedText(stop.audioUrl)),
poiId: stringifyId(stop.poiId) || undefined,
outlineId: stringifyId(stop.outlineId || stop.routeId) || undefined,
outlineName: normalizedText(stop.outlineName || stop.routeName) || undefined,
sort: optionalNumber(stop.sort ?? stop.seqOrder)
}
}
export const toLocationPreviewFromPoi = (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 placeHallName = (place: SgsNavigablePlacePayload) => (
normalizedText(place.ownerName)
|| normalizedText(place.name)
.replace(/(?:)?(?:|||)\s*[A-Za-z0-9-]*$/u, '')
.trim()
|| normalizedText(place.name)
)
const findMatchedHallSpace = (
place: SgsNavigablePlacePayload,
spaces: SgsSpacePayload[]
) => {
const placeName = normalizedHallName(placeHallName(place))
if (!placeName) return undefined
const rankedMatches = spaces
.map((space) => {
const spaceName = normalizedHallName(space.name)
if (!spaceName) return null
const exact = placeName === spaceName
const overlaps = exact || placeName.includes(spaceName) || spaceName.includes(placeName)
if (!overlaps) return null
return {
space,
exact,
specificity: spaceName.length
}
})
.filter((match): match is {
space: SgsSpacePayload
exact: boolean
specificity: number
} => Boolean(match))
.sort((left, right) => {
if (left.exact !== right.exact) return left.exact ? -1 : 1
return right.specificity - left.specificity
})
return rankedMatches[0]?.space
}
const floorLabelForSgs = (
floorId: string,
floors: SgsSdkFloorSummaryPayload[],
floorCode?: string | null,
floorName?: string | null
) => {
const matchedFloor = floors.find((floor) => stringifyId(floor.floorId) === floorId || floor.floorCode === floorCode)
return formatSgsFloorLabel(
matchedFloor?.floorCode || floorCode,
matchedFloor?.floorName || floorName
)
}
const createHallEntrance = (
place: SgsNavigablePlacePayload,
floors: SgsSdkFloorSummaryPayload[],
fallbackFloorId: string,
fallbackY?: number
) => {
const floorId = stringifyId(place.floorId) || fallbackFloorId
const placeId = stringifyId(place.id || place.nodeId)
const routeNodeId = stringifyId(place.nodeId) || undefined
return {
id: placeId ? `hall-entrance-${placeId}` : `hall-entrance-${floorId}-${normalizedHallName(place.name)}`,
name: normalizedText(place.name) || normalizedText(place.ownerName) || '展厅出入口',
floorId,
floorLabel: floorLabelForSgs(floorId, floors, place.floorCode, place.floorName),
positionGltf: normalizePlacePosition(place, fallbackY),
sourceObjectName: routeNodeId || undefined,
sourcePlaceId: routeNodeId ? placeId || undefined : undefined,
routeNodeId
}
}
const createHallPoiId = (
space: SgsSpacePayload
) => {
const spaceId = stringifyId(space.id)
if (spaceId) return `hall-${spaceId}`
const ownerName = normalizedHallName(space.name)
const floorIdentity = normalizedHallName(stringifyId(space.floorId) || 'unknown-floor')
if (ownerName) return `hall-space-${floorIdentity}-${ownerName}`
return `hall-space-${floorIdentity}-unknown`
}
const createSpaceFallbackHallPoi = (
space: SgsSpacePayload,
floors: SgsSdkFloorSummaryPayload[],
fallbackFloorId: string,
fallbackY?: number
): MuseumPoi | null => {
const floorId = stringifyId(space.floorId) || fallbackFloorId
const spacePosition = normalizeSpacePosition(space, fallbackY)
const position = spacePosition?.position
const hallId = stringifyId(space.id)
if (!hallId || !normalizedText(space.name) || !position) return null
return {
id: `hall-${hallId}`,
name: normalizedText(space.name),
floorId,
floorLabel: floorLabelForSgs(floorId, floors),
primaryCategory: hallCategory,
categories: [
hallCategory
],
positionGltf: position,
sourceObjectName: space.sourceNodeName || undefined,
sourceConfidence: spacePosition.sourceConfidence,
navigationReadiness: '位置预览',
accessible: false,
kind: 'hall',
hallId,
hallName: normalizedText(space.name),
spaceId: hallId,
sourceSpaceId: hallId,
entrances: []
}
}
export const toMuseumHallPoisFromSgs = (
spaces: SgsSpacePayload[],
navigablePlaces: SgsNavigablePlacePayload[],
floors: SgsSdkFloorSummaryPayload[],
fallbackFloorId: string,
options: SgsHallPoiBuildOptions = {}
): MuseumPoi[] => {
const hallSpaces = spaces.filter(isExhibitionHallSpace)
const hallPlaces = navigablePlaces.filter(isExhibitionHallNavigablePlace)
const halls = new Map<string, MuseumPoi>()
hallPlaces.forEach((place) => {
const matchedSpace = findMatchedHallSpace(place, hallSpaces)
if (!matchedSpace) return
const entrance = createHallEntrance(place, floors, fallbackFloorId, options.fallbackY)
const spacePosition = matchedSpace ? normalizeSpacePosition(matchedSpace, options.fallbackY) : undefined
const spaceCenter = spacePosition?.position
const entrancePosition = entrance.positionGltf
const fallbackFloorIdForPoi = stringifyId(matchedSpace?.floorId) || entrance.floorId || fallbackFloorId
const fallbackFloorLabel = floorLabelForSgs(
fallbackFloorIdForPoi,
floors
)
const hallId = stringifyId(matchedSpace?.id) || undefined
const poiId = createHallPoiId(matchedSpace)
const hallName = normalizedText(matchedSpace?.name) || placeHallName(place) || entrance.name
const existing = halls.get(poiId)
if (existing) {
existing.entrances = [
...(existing.entrances || []),
entrance
]
if (spaceCenter) {
existing.positionGltf = spaceCenter
existing.floorId = fallbackFloorIdForPoi
existing.floorLabel = fallbackFloorLabel
existing.sourceObjectName = matchedSpace?.sourceNodeName || undefined
existing.sourceConfidence = spacePosition.sourceConfidence
return
}
if (!existing.positionGltf && entrancePosition) {
existing.positionGltf = entrancePosition
existing.floorId = entrance.floorId
existing.floorLabel = entrance.floorLabel
existing.sourceObjectName = entrance.sourceObjectName
existing.sourcePlaceId = entrance.sourcePlaceId
existing.sourceConfidence = sgsHallEntranceConfidence
}
return
}
const usesSpaceCenter = Boolean(spaceCenter)
const positionGltf = spaceCenter || entrancePosition
if (!positionGltf) return
halls.set(poiId, {
id: poiId,
name: hallName,
floorId: usesSpaceCenter ? fallbackFloorIdForPoi : entrance.floorId,
floorLabel: usesSpaceCenter ? fallbackFloorLabel : entrance.floorLabel,
primaryCategory: hallCategory,
categories: [
hallCategory,
hallEntranceCategory
],
positionGltf,
sourceObjectName: usesSpaceCenter
? matchedSpace?.sourceNodeName || undefined
: entrance.sourceObjectName,
sourceConfidence: usesSpaceCenter
? spacePosition?.sourceConfidence || sgsSpaceCenterConfidence
: sgsHallEntranceConfidence,
navigationReadiness: '位置预览',
accessible: false,
kind: 'hall',
hallId,
hallName,
spaceId: hallId,
sourcePlaceId: usesSpaceCenter ? undefined : entrance.sourcePlaceId,
sourceSpaceId: hallId,
entrances: [entrance]
})
})
hallSpaces.forEach((space) => {
const hallId = stringifyId(space.id)
if (!hallId || halls.has(`hall-${hallId}`)) return
const fallbackPoi = createSpaceFallbackHallPoi(space, floors, fallbackFloorId, options.fallbackY)
if (fallbackPoi) {
halls.set(fallbackPoi.id, fallbackPoi)
}
})
return Array.from(halls.values())
.filter((poi) => poi.id && poi.name && poi.positionGltf)
}
export const createSgsHallPoiDiagnostics = (
spaces: SgsSpacePayload[],
navigablePlaces: SgsNavigablePlacePayload[],
hallPois: MuseumPoi[]
): SgsHallPoiDiagnostics => {
const eligibleHallSpaces = spaces.filter(isExhibitionHallSpace)
const eligibleSpaceCount = eligibleHallSpaces.length
const hallPlaceCount = navigablePlaces.filter(isExhibitionHallNavigablePlace).length
const hallPoiIds = new Set(hallPois.map((poi) => poi.id))
const hallPoiWithPositionCount = hallPois.filter((poi) => Boolean(poi.positionGltf)).length
return {
spaceCount: spaces.length,
eligibleSpaceCount,
navigablePlaceCount: navigablePlaces.length,
hallPlaceCount,
hallPoiCount: hallPois.length,
hallPoiWithPositionCount,
skippedSpaceCount: eligibleHallSpaces.filter((space) => !hallPoiIds.has(`hall-${stringifyId(space.id)}`)).length,
skippedPlaceCount: Math.max(hallPlaceCount - hallPois.reduce((count, poi) => count + (poi.entrances?.length || 0), 0), 0)
}
}
export const toGuideFloorDetailFromSgs = (
floor: SgsSdkFloorSummaryPayload | SgsFloorDiagnosticsPayload
): GuideFloorDetail => ({
...toMuseumFloorFromSgs(floor),
sourceCode: floor.floorCode || undefined,
sourceName: floor.floorName || undefined,
modelReady: 'modelReady' in floor ? floor.modelReady === true : undefined,
poiCount: optionalNumber(floor.poiCount),
spaceCount: optionalNumber(floor.spaceCount),
guideStopCount: 'guideStopCount' in floor ? toNumber(floor.guideStopCount, 0) : undefined,
navigablePlaceCount: 'navigablePlaceCount' in floor ? toNumber(floor.navigablePlaceCount, 0) : undefined,
routeNodeCount: 'routeNodeCount' in floor ? toNumber(floor.routeNodeCount, 0) : undefined,
routeEdgeCount: 'routeEdgeCount' in floor ? toNumber(floor.routeEdgeCount, 0) : undefined,
routePlanningReady: 'routePlanningReady' in floor ? floor.routePlanningReady === true : undefined,
warnings: 'warnings' in floor ? floor.warnings || [] : []
})
export const toGuideMapDiagnostics = (
diagnostics: SgsMapDiagnosticsPayload,
manifest?: SgsSdkManifestPayload
): GuideMapDiagnostics => {
const summary = diagnostics.summary || {}
const floors = diagnostics.floors?.length
? diagnostics.floors.map(toGuideFloorDetailFromSgs)
: (manifest?.floors || []).map(toGuideFloorDetailFromSgs)
const indoorFloors = floors.filter(isIndoorNavigableFloor)
return {
mapId: stringifyId(diagnostics.mapId || manifest?.mapId),
mapName: diagnostics.mapName || manifest?.mapName || 'SGS 地图',
sdkVersion: diagnostics.sdkVersion || manifest?.sdkVersion || undefined,
status: normalizeStatus(diagnostics.status),
floors: indoorFloors,
summary: {
floorCount: indoorFloors.length,
modelReadyFloorCount: indoorFloors.filter((floor) => floor.modelReady === true).length || toNumber(summary.modelReadyFloorCount),
poiCount: toNumber(summary.poiCount),
spaceCount: toNumber(summary.spaceCount),
guideStopCount: toNumber(summary.guideStopCount),
navigablePlaceCount: toNumber(summary.navigablePlaceCount),
routeNodeCount: toNumber(summary.routeNodeCount),
routeEdgeCount: toNumber(summary.routeEdgeCount)
},
warnings: diagnostics.warnings || []
}
}
export const toRouteReadinessFromSgsDiagnostics = (
diagnostics: SgsMapDiagnosticsPayload
): GuideRouteReadiness => {
const warnings = diagnostics.warnings || []
const floors = (diagnostics.floors || []).filter(isIndoorNavigableFloor)
const notReadyFloors = floors.filter((floor) => (
floor.modelReady !== true
|| floor.routePlanningReady !== true
|| toNumber(floor.routeNodeCount) <= 0
|| toNumber(floor.routeEdgeCount) <= 0
|| toNumber(floor.navigablePlaceCount) <= 0
))
const ready = diagnostics.status === 'OK' && notReadyFloors.length === 0 && warnings.length === 0
return {
ready,
message: ready
? 'SGS 后端路线数据已就绪'
: NAV_ROUTE_UNAVAILABLE_MESSAGE,
requiredData: ready
? []
: [
...warnings,
...notReadyFloors.map((floor) => `${formatSgsFloorLabel(floor.floorCode, floor.floorName)} 数据未完全就绪`)
]
}
}
const addIssue = (
issues: GuideDataIntegrityIssue[],
issue: Omit<GuideDataIntegrityIssue, 'id'>
) => {
issues.push({
id: `issue-${issues.length + 1}`,
...issue
})
}
const missingFieldsForPoi = (poi: MuseumPoi) => {
const fields: string[] = []
if (!poi.id) fields.push('id')
if (!poi.name) fields.push('name')
if (!poi.floorId) fields.push('floorId')
if (!poi.positionGltf) fields.push('position')
return fields
}
export const createGuideDataIntegrityReport = (
diagnostics: GuideMapDiagnostics,
floors: MuseumFloor[],
pois: MuseumPoi[]
): GuideDataIntegrityReport => {
const issues: GuideDataIntegrityIssue[] = []
const floorIds = new Set(floors.map((floor) => floor.id))
const poiIds = new Set<string>()
floors.forEach((floor) => {
if (!floor.id || !floor.label) {
addIssue(issues, {
scope: 'floor',
severity: 'error',
message: '楼层缺少稳定 ID 或显示名称',
floorId: floor.id,
floorLabel: floor.label,
fields: ['id', 'label'].filter((field) => !floor[field as keyof MuseumFloor])
})
}
})
diagnostics.floors.forEach((floor) => {
if (floor.modelReady === false) {
addIssue(issues, {
scope: 'floor',
severity: 'warn',
message: `${floor.label} 模型未就绪`,
floorId: floor.id,
floorLabel: floor.label,
fields: ['modelReady']
})
}
if (floor.navigablePlaceCount === 0) {
addIssue(issues, {
scope: 'floor',
severity: 'warn',
message: `${floor.label} 缺少可导航目的地`,
floorId: floor.id,
floorLabel: floor.label,
fields: ['navigablePlaceCount']
})
}
})
pois.forEach((poi) => {
const missingFields = missingFieldsForPoi(poi)
if (missingFields.length) {
addIssue(issues, {
scope: 'poi',
severity: 'error',
message: `${poi.name || poi.id || '未知点位'} 缺少必要字段`,
floorId: poi.floorId,
floorLabel: poi.floorLabel,
poiId: poi.id,
fields: missingFields
})
}
if (poi.id && poiIds.has(poi.id)) {
addIssue(issues, {
scope: 'poi',
severity: 'error',
message: `${poi.name || poi.id} 点位 ID 重复`,
floorId: poi.floorId,
floorLabel: poi.floorLabel,
poiId: poi.id,
fields: ['id']
})
}
if (poi.id) poiIds.add(poi.id)
if (poi.floorId && !floorIds.has(poi.floorId)) {
addIssue(issues, {
scope: 'poi',
severity: 'error',
message: `${poi.name || poi.id} 关联的楼层不存在`,
floorId: poi.floorId,
floorLabel: poi.floorLabel,
poiId: poi.id,
fields: ['floorId']
})
}
})
const errorCount = issues.filter((issue) => issue.severity === 'error').length
const warningCount = issues.filter((issue) => issue.severity === 'warn').length
return {
status: errorCount > 0 ? 'ERROR' : warningCount > 0 || diagnostics.status === 'WARN' ? 'WARN' : 'OK',
summary: {
floorCount: floors.length,
poiCount: pois.length,
issueCount: issues.length,
errorCount,
warningCount
},
issues,
warnings: diagnostics.warnings
}
}