chore: freeze guide and explain update
This commit is contained in:
593
src/data/adapters/guideDataAdapter.ts
Normal file
593
src/data/adapters/guideDataAdapter.ts
Normal 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
|
||||
}
|
||||
}
|
||||
254
src/data/adapters/navRouteAdapter.ts
Normal file
254
src/data/adapters/navRouteAdapter.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user