feat: wire guide data source and POI focus UI
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
120
src/repositories/ExplainRepository.ts
Normal file
120
src/repositories/ExplainRepository.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type {
|
||||
ExplainTrack,
|
||||
MuseumExhibit,
|
||||
MuseumHall,
|
||||
SearchIndexItem
|
||||
} from '@/domain/museum'
|
||||
import {
|
||||
mediaRepository,
|
||||
type MediaRepository
|
||||
} from '@/repositories/MediaRepository'
|
||||
import {
|
||||
museumContentRepository,
|
||||
type MuseumContentRepository
|
||||
} from '@/repositories/MuseumContentRepository'
|
||||
|
||||
export interface ExplainRepository {
|
||||
listExhibits(): Promise<MuseumExhibit[]>
|
||||
getExhibitById(id: string): Promise<MuseumExhibit | null>
|
||||
listHalls(): Promise<MuseumHall[]>
|
||||
getHallById(id: string): Promise<MuseumHall | null>
|
||||
listTracks(): Promise<ExplainTrack[]>
|
||||
getTrackByExhibitId(exhibitId: string): Promise<ExplainTrack | null>
|
||||
searchExplain(keyword?: string): Promise<SearchIndexItem[]>
|
||||
}
|
||||
|
||||
const trackTitleFor = (exhibit: MuseumExhibit) => `${exhibit.name}讲解`
|
||||
|
||||
export class DefaultExplainRepository implements ExplainRepository {
|
||||
constructor(
|
||||
private readonly content: MuseumContentRepository = museumContentRepository,
|
||||
private readonly media: MediaRepository = mediaRepository
|
||||
) {}
|
||||
|
||||
listExhibits() {
|
||||
return this.content.listExhibits()
|
||||
}
|
||||
|
||||
getExhibitById(id: string) {
|
||||
return this.content.getExhibitById(id)
|
||||
}
|
||||
|
||||
listHalls() {
|
||||
return this.content.listHalls()
|
||||
}
|
||||
|
||||
getHallById(id: string) {
|
||||
return this.content.getHallById(id)
|
||||
}
|
||||
|
||||
async listTracks() {
|
||||
const exhibits = await this.content.listExhibits()
|
||||
|
||||
return Promise.all(exhibits.map(async (exhibit): Promise<ExplainTrack> => {
|
||||
const media = await this.media.getMediaForExplainTrack(exhibit.id)
|
||||
return {
|
||||
id: `track-${exhibit.id}`,
|
||||
exhibitId: exhibit.id,
|
||||
hallId: exhibit.hallId,
|
||||
title: trackTitleFor(exhibit),
|
||||
summary: exhibit.description,
|
||||
mediaId: media?.id,
|
||||
coverImage: exhibit.image,
|
||||
poiId: exhibit.poiId,
|
||||
floorId: exhibit.floorId,
|
||||
available: media?.available === true
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
async getTrackByExhibitId(exhibitId: string) {
|
||||
const tracks = await this.listTracks()
|
||||
return tracks.find((track) => track.exhibitId === exhibitId) || null
|
||||
}
|
||||
|
||||
async searchExplain(keyword = '') {
|
||||
const normalizedKeyword = keyword.trim().toLowerCase()
|
||||
const [exhibits, halls] = await Promise.all([
|
||||
this.content.listExhibits(),
|
||||
this.content.listHalls()
|
||||
])
|
||||
|
||||
const exhibitItems = exhibits
|
||||
.filter((exhibit) => !normalizedKeyword || [
|
||||
exhibit.name,
|
||||
exhibit.hallName,
|
||||
exhibit.floorLabel,
|
||||
...(exhibit.tags || [])
|
||||
].filter(Boolean).join(' ').toLowerCase().includes(normalizedKeyword))
|
||||
.map<SearchIndexItem>((exhibit) => ({
|
||||
id: exhibit.id,
|
||||
name: exhibit.name,
|
||||
desc: `${exhibit.hallName || '展项'} · ${exhibit.floorLabel || ''}`.trim(),
|
||||
type: 'exhibit',
|
||||
exhibitId: exhibit.id,
|
||||
hallId: exhibit.hallId,
|
||||
poiId: exhibit.poiId,
|
||||
floorId: exhibit.floorId
|
||||
}))
|
||||
|
||||
const hallItems = halls
|
||||
.filter((hall) => !normalizedKeyword || [
|
||||
hall.name,
|
||||
hall.floorLabel,
|
||||
hall.description
|
||||
].filter(Boolean).join(' ').toLowerCase().includes(normalizedKeyword))
|
||||
.map<SearchIndexItem>((hall) => ({
|
||||
id: hall.id,
|
||||
name: hall.name,
|
||||
desc: `${hall.floorLabel || ''} · ${hall.exhibitCount || 0}件展项`.trim(),
|
||||
type: 'hall',
|
||||
hallId: hall.id,
|
||||
poiId: hall.poiId,
|
||||
floorId: hall.floorId
|
||||
}))
|
||||
|
||||
return [...exhibitItems, ...hallItems]
|
||||
}
|
||||
}
|
||||
|
||||
export const explainRepository = new DefaultExplainRepository()
|
||||
64
src/repositories/GuideModelRepository.ts
Normal file
64
src/repositories/GuideModelRepository.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type {
|
||||
GuideModelFloorAsset,
|
||||
GuideModelRenderPackage,
|
||||
GuideModelSource,
|
||||
GuideRenderPoi
|
||||
} from '@/domain/guideModel'
|
||||
import {
|
||||
formatNavFloorLabel
|
||||
} from '@/data/adapters/navAssetsAdapter'
|
||||
import {
|
||||
defaultStaticNavAssetsProvider,
|
||||
type StaticNavAssetsProvider,
|
||||
type StaticNavPoiPayload
|
||||
} from '@/data/providers/staticNavAssetsProvider'
|
||||
|
||||
const toRenderPoi = (poi: StaticNavPoiPayload): GuideRenderPoi => ({
|
||||
id: poi.id,
|
||||
name: poi.name,
|
||||
floorId: poi.floorId,
|
||||
primaryCategory: poi.primaryCategory,
|
||||
primaryCategoryZh: poi.primaryCategoryZh,
|
||||
iconType: poi.iconType || poi.primaryCategory,
|
||||
positionGltf: poi.positionGltf
|
||||
})
|
||||
|
||||
export interface GuideModelRepository extends GuideModelSource {}
|
||||
|
||||
export class StaticGuideModelRepository implements GuideModelRepository {
|
||||
constructor(private readonly provider: StaticNavAssetsProvider = defaultStaticNavAssetsProvider) {}
|
||||
|
||||
async loadPackage(): Promise<GuideModelRenderPackage> {
|
||||
const [manifest, floorIndex] = await Promise.all([
|
||||
this.provider.loadManifest(),
|
||||
this.provider.loadFloorIndex()
|
||||
])
|
||||
|
||||
const floors: GuideModelFloorAsset[] = [...floorIndex.floors]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((floor) => ({
|
||||
floorId: floor.floorId,
|
||||
label: formatNavFloorLabel(floor.floorId),
|
||||
order: floor.order,
|
||||
modelUrl: this.provider.assetUrl(floor.modelAsset)
|
||||
}))
|
||||
|
||||
return {
|
||||
overviewModelUrl: this.provider.assetUrl(manifest.assets.overviewModel.asset),
|
||||
floors
|
||||
}
|
||||
}
|
||||
|
||||
async loadFloorPois(floorId: string) {
|
||||
const floorIndex = await this.provider.loadFloorIndex()
|
||||
const floor = floorIndex.floors.find((item) => item.floorId === floorId)
|
||||
if (!floor) return []
|
||||
|
||||
const pois = await this.provider.loadFloorPois(floor.poiDataAsset)
|
||||
return pois
|
||||
.map(toRenderPoi)
|
||||
.filter((poi) => Array.isArray(poi.positionGltf) && poi.positionGltf.length === 3)
|
||||
}
|
||||
}
|
||||
|
||||
export const guideModelRepository = new StaticGuideModelRepository()
|
||||
122
src/repositories/GuideRepository.ts
Normal file
122
src/repositories/GuideRepository.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type {
|
||||
GuideLocationPreview,
|
||||
MuseumFloor,
|
||||
MuseumPoi
|
||||
} from '@/domain/museum'
|
||||
import {
|
||||
NAV_ROUTE_READINESS
|
||||
} from '@/domain/guideReadiness'
|
||||
import {
|
||||
formatNavFloorLabel,
|
||||
navFloorIdFromLabel,
|
||||
toMuseumPoi
|
||||
} from '@/data/adapters/navAssetsAdapter'
|
||||
import {
|
||||
defaultStaticNavAssetsProvider,
|
||||
type StaticNavAssetsProvider
|
||||
} from '@/data/providers/staticNavAssetsProvider'
|
||||
|
||||
const searchableCategories = new Set([
|
||||
'touring_poi',
|
||||
'basic_service_facility',
|
||||
'transport_circulation',
|
||||
'accessibility_special_service',
|
||||
'operation_experience'
|
||||
])
|
||||
|
||||
const toSearchText = (poi: MuseumPoi) => [
|
||||
poi.name,
|
||||
poi.floorId,
|
||||
poi.floorLabel,
|
||||
poi.primaryCategory.label,
|
||||
poi.primaryCategory.iconType,
|
||||
...poi.categories.flatMap((category) => [
|
||||
category.label,
|
||||
category.id,
|
||||
category.iconType
|
||||
])
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
const toLocationPreview = (poi: MuseumPoi): GuideLocationPreview => ({
|
||||
poiId: poi.id,
|
||||
name: poi.name,
|
||||
floorId: poi.floorId,
|
||||
floorLabel: poi.floorLabel,
|
||||
primaryCategoryZh: poi.primaryCategory.label,
|
||||
positionGltf: poi.positionGltf
|
||||
})
|
||||
|
||||
export interface GuideRepository {
|
||||
getAssetBaseUrl(): string
|
||||
getFloors(): Promise<MuseumFloor[]>
|
||||
normalizeFloorId(labelOrId: string): string
|
||||
listPois(): Promise<MuseumPoi[]>
|
||||
getPoiById(id: string): Promise<MuseumPoi | null>
|
||||
searchPois(keyword?: string): Promise<MuseumPoi[]>
|
||||
getLocationPreview(poiId: string): Promise<GuideLocationPreview | null>
|
||||
getRouteReadiness(): typeof NAV_ROUTE_READINESS
|
||||
}
|
||||
|
||||
export class StaticGuideRepository implements GuideRepository {
|
||||
private poiCache: MuseumPoi[] | null = null
|
||||
|
||||
constructor(private readonly provider: StaticNavAssetsProvider = defaultStaticNavAssetsProvider) {}
|
||||
|
||||
getAssetBaseUrl() {
|
||||
return this.provider.baseUrl
|
||||
}
|
||||
|
||||
async getFloors() {
|
||||
const floorIndex = await this.provider.loadFloorIndex()
|
||||
return [...floorIndex.floors]
|
||||
.sort((a, b) => b.order - a.order)
|
||||
.map((floor) => ({
|
||||
id: floor.floorId,
|
||||
label: formatNavFloorLabel(floor.floorId),
|
||||
order: floor.order
|
||||
}))
|
||||
}
|
||||
|
||||
normalizeFloorId(labelOrId: string) {
|
||||
return navFloorIdFromLabel(labelOrId)
|
||||
}
|
||||
|
||||
async listPois() {
|
||||
if (this.poiCache) return this.poiCache
|
||||
|
||||
const pois = await this.provider.loadPoiIndex()
|
||||
this.poiCache = pois
|
||||
.filter((poi) => searchableCategories.has(poi.primaryCategory))
|
||||
.map(toMuseumPoi)
|
||||
|
||||
return this.poiCache
|
||||
}
|
||||
|
||||
async getPoiById(id: string) {
|
||||
const pois = await this.listPois()
|
||||
return pois.find((poi) => poi.id === id) || null
|
||||
}
|
||||
|
||||
async searchPois(keyword = '') {
|
||||
const pois = await this.listPois()
|
||||
const normalizedKeyword = keyword.trim().toLowerCase()
|
||||
|
||||
if (!normalizedKeyword) return pois
|
||||
|
||||
return pois.filter((poi) => toSearchText(poi).includes(normalizedKeyword))
|
||||
}
|
||||
|
||||
async getLocationPreview(poiId: string) {
|
||||
const poi = await this.getPoiById(poiId)
|
||||
return poi ? toLocationPreview(poi) : null
|
||||
}
|
||||
|
||||
getRouteReadiness() {
|
||||
return NAV_ROUTE_READINESS
|
||||
}
|
||||
}
|
||||
|
||||
export const guideRepository = new StaticGuideRepository()
|
||||
27
src/repositories/MediaRepository.ts
Normal file
27
src/repositories/MediaRepository.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type {
|
||||
MediaAsset
|
||||
} from '@/domain/museum'
|
||||
|
||||
export interface MediaRepository {
|
||||
getMediaById(id: string): Promise<MediaAsset | null>
|
||||
getMediaForExplainTrack(trackId: string): Promise<MediaAsset | null>
|
||||
}
|
||||
|
||||
const unavailableAudio = (id: string): MediaAsset => ({
|
||||
id,
|
||||
type: 'audio',
|
||||
available: false,
|
||||
unavailableReason: '正式讲解音频尚未接入媒体仓储'
|
||||
})
|
||||
|
||||
export class DefaultMediaRepository implements MediaRepository {
|
||||
async getMediaById(id: string) {
|
||||
return unavailableAudio(id)
|
||||
}
|
||||
|
||||
async getMediaForExplainTrack(trackId: string) {
|
||||
return unavailableAudio(`media-${trackId}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const mediaRepository = new DefaultMediaRepository()
|
||||
124
src/repositories/MuseumContentRepository.ts
Normal file
124
src/repositories/MuseumContentRepository.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type {
|
||||
MuseumExhibit,
|
||||
MuseumHall,
|
||||
MuseumPoi,
|
||||
SearchIndexItem
|
||||
} from '@/domain/museum'
|
||||
import {
|
||||
guideRepository,
|
||||
type GuideRepository
|
||||
} from '@/repositories/GuideRepository'
|
||||
import {
|
||||
staticMuseumContentProvider,
|
||||
type MuseumContentProvider
|
||||
} from '@/data/providers/staticMuseumContentProvider'
|
||||
|
||||
export interface MuseumContentRepository {
|
||||
listFacilities(): Promise<MuseumPoi[]>
|
||||
getFacilityById(id: string): Promise<MuseumPoi | null>
|
||||
searchFacilities(keyword?: string): Promise<MuseumPoi[]>
|
||||
listExhibits(): Promise<MuseumExhibit[]>
|
||||
getExhibitById(id: string): Promise<MuseumExhibit | null>
|
||||
listHalls(): Promise<MuseumHall[]>
|
||||
getHallById(id: string): Promise<MuseumHall | null>
|
||||
searchContent(keyword?: string): Promise<SearchIndexItem[]>
|
||||
}
|
||||
|
||||
const normalize = (value: string) => value.trim().toLowerCase()
|
||||
const includesKeyword = (values: Array<string | undefined>, keyword: string) => (
|
||||
values.filter(Boolean).join(' ').toLowerCase().includes(keyword)
|
||||
)
|
||||
|
||||
export class DefaultMuseumContentRepository implements MuseumContentRepository {
|
||||
constructor(
|
||||
private readonly contentProvider: MuseumContentProvider = staticMuseumContentProvider,
|
||||
private readonly guide: GuideRepository = guideRepository
|
||||
) {}
|
||||
|
||||
listFacilities() {
|
||||
return this.guide.listPois()
|
||||
}
|
||||
|
||||
getFacilityById(id: string) {
|
||||
return this.guide.getPoiById(id)
|
||||
}
|
||||
|
||||
searchFacilities(keyword = '') {
|
||||
return this.guide.searchPois(keyword)
|
||||
}
|
||||
|
||||
listExhibits() {
|
||||
return this.contentProvider.listExhibits()
|
||||
}
|
||||
|
||||
async getExhibitById(id: string) {
|
||||
const exhibits = await this.contentProvider.listExhibits()
|
||||
return exhibits.find((exhibit) => exhibit.id === id) || null
|
||||
}
|
||||
|
||||
listHalls() {
|
||||
return this.contentProvider.listHalls()
|
||||
}
|
||||
|
||||
async getHallById(id: string) {
|
||||
const halls = await this.contentProvider.listHalls()
|
||||
return halls.find((hall) => hall.id === id) || null
|
||||
}
|
||||
|
||||
async searchContent(keyword = '') {
|
||||
const normalizedKeyword = normalize(keyword)
|
||||
const facilities = await this.searchFacilities(keyword)
|
||||
const exhibits = await this.listExhibits()
|
||||
const halls = await this.listHalls()
|
||||
|
||||
const exhibitItems = exhibits
|
||||
.filter((exhibit) => !normalizedKeyword || includesKeyword([
|
||||
exhibit.name,
|
||||
exhibit.hallName,
|
||||
exhibit.floorLabel,
|
||||
...(exhibit.tags || [])
|
||||
], normalizedKeyword))
|
||||
.map<SearchIndexItem>((exhibit) => ({
|
||||
id: exhibit.id,
|
||||
name: exhibit.name,
|
||||
desc: `${exhibit.hallName || '展项'} · ${exhibit.floorLabel || ''}`.trim(),
|
||||
type: 'exhibit',
|
||||
exhibitId: exhibit.id,
|
||||
poiId: exhibit.poiId,
|
||||
floorId: exhibit.floorId
|
||||
}))
|
||||
|
||||
const hallItems = halls
|
||||
.filter((hall) => !normalizedKeyword || includesKeyword([
|
||||
hall.name,
|
||||
hall.floorLabel,
|
||||
hall.description
|
||||
], normalizedKeyword))
|
||||
.map<SearchIndexItem>((hall) => ({
|
||||
id: hall.id,
|
||||
name: hall.name,
|
||||
desc: `${hall.floorLabel || ''} · ${hall.exhibitCount || 0}件展项`.trim(),
|
||||
type: 'hall',
|
||||
hallId: hall.id,
|
||||
poiId: hall.poiId,
|
||||
floorId: hall.floorId
|
||||
}))
|
||||
|
||||
const facilityItems = facilities.map<SearchIndexItem>((poi) => ({
|
||||
id: poi.id,
|
||||
name: poi.name,
|
||||
desc: `${poi.floorLabel} · ${poi.primaryCategory.label}`,
|
||||
type: 'facility',
|
||||
poiId: poi.id,
|
||||
floorId: poi.floorId
|
||||
}))
|
||||
|
||||
return [
|
||||
...exhibitItems,
|
||||
...hallItems,
|
||||
...facilityItems
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export const museumContentRepository = new DefaultMuseumContentRepository()
|
||||
Reference in New Issue
Block a user