981 lines
29 KiB
TypeScript
981 lines
29 KiB
TypeScript
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
|
||
}
|
||
}
|