整改点位搜索数据加载与分类逻辑

This commit is contained in:
cxk
2026-07-20 21:30:55 +08:00
parent 65ba9720f8
commit 7ae539accc
14 changed files with 2352 additions and 812 deletions

View File

@@ -44,13 +44,18 @@
</view>
</view>
<view v-if="variant === 'home' && !showSearchContent && !isHomeCategoryMode" class="home-category-strip">
<view
v-if="variant === 'home' && !showSearchContent && !isHomeCategoryMode"
class="home-category-strip"
aria-label="快捷查找"
>
<view
v-for="item in homeCategories"
:key="item.id"
class="home-category-chip"
:class="{ active: activeCategoryId === item.id }"
:class="{ active: activeCategoryId === item.id, disabled: isCategoryDisabled(item) }"
:data-testid="`poi-category-${item.id}`"
:aria-disabled="isCategoryDisabled(item)"
@tap="handleFacilityShortcut(item)"
>
<view class="home-category-icon">
@@ -129,14 +134,15 @@
<view class="home-collapse-bar"></view>
</view>
<view class="category-scroll">
<view class="category-scroll" aria-label="快捷查找">
<view class="category-grid" :class="{ 'home-shortcut-grid': variant === 'home' }">
<view
v-for="item in searchCategoryColumns"
:key="item.id"
class="category-item"
:class="{ active: activeCategoryId === item.id }"
:class="{ active: activeCategoryId === item.id, disabled: isCategoryDisabled(item) }"
:data-testid="`poi-category-${item.id}`"
:aria-disabled="isCategoryDisabled(item)"
@tap="handleFacilityShortcut(item)"
>
<view class="category-icon-shell">
@@ -220,8 +226,7 @@ import type {
} from '@/domain/museum'
import {
compareFloorsTopToBottom,
isIndoorNavigableFloor,
isPoiOnIndoorNavigableFloor
isIndoorNavigableFloor
} from '@/domain/guideFloor'
import {
guideUseCase
@@ -232,18 +237,14 @@ import {
import {
HOME_POI_CATEGORIES,
POI_CATEGORIES,
findPoiCategoryByKeyword,
getPoiCategoryIconHref,
getPoiCategoryById,
getPoiDataIssues,
inspectPoiCollection,
isPoiSearchCategorySupported,
matchesPoiCategory,
resolvePoiCategory,
type PoiCategoryDefinition,
type PoiCategoryId
} from '@/domain/poiCategories'
import type {
GuidePoiSearchViewState,
PoiCategoryResultState,
PoiSearchContext
} from '@/domain/poiSearch'
@@ -273,12 +274,6 @@ const emit = defineEmits<{
cancel: []
}>()
type PreparedPoiResults = {
visiblePois: MuseumPoi[]
duplicateCount: number
excludedCount: number
}
const homeCategories = HOME_POI_CATEGORIES
const searchCategories = POI_CATEGORIES
const searchCategoryColumns = computed(() => (
@@ -299,6 +294,7 @@ const homeContentTapBlockedUntil = ref(0)
const floors = ref<MuseumFloor[]>([])
const activeFloor = ref('')
const pois = ref<MuseumPoi[]>([])
const searchViewState = ref<GuidePoiSearchViewState | null>(null)
const isLoading = ref(false)
const searchError = ref('')
const floorLoadError = ref('')
@@ -306,7 +302,6 @@ const excludedPoiCount = ref(0)
const duplicatePoiCount = ref(0)
const resultListScrollTop = ref(0)
const resultListRef = ref<HTMLElement | { $el?: HTMLElement } | null>(null)
let cachedPoiPool: PreparedPoiResults | null = null
let searchRequestSeq = 0
const collapseDragThreshold = 48
@@ -324,6 +319,9 @@ const isHomeCategoryMode = computed(() => (
const isHomeCollapsed = computed(() => props.variant === 'home' && !showSearchContent.value)
const activeCategory = computed(() => getPoiCategoryById(activeCategoryId.value))
const loadError = computed(() => searchError.value || floorLoadError.value)
const categoryStatesById = computed(() => new Map(
(searchViewState.value?.categories || []).map((item) => [item.definition.id, item] as const)
))
const displayFloors = computed(() => [...floors.value]
.filter(isIndoorNavigableFloor)
@@ -349,24 +347,13 @@ const emptyDesc = computed(() => {
: '可切换楼层或更换关键词'
})
const isVisiblePoi = (poi: MuseumPoi) => isPoiSearchCategorySupported(poi)
&& isPoiOnIndoorNavigableFloor(poi)
const isPoiLocatable = (poi: MuseumPoi) => !getPoiDataIssues(poi).some((issue) => (
issue.code === 'missing-id'
|| issue.code === 'missing-floor'
|| issue.code === 'missing-position'
|| issue.code === 'invalid-position'
))
const unavailablePoiCount = computed(() => pois.value.filter((poi) => !isPoiLocatable(poi)).length)
const isPoiLocatable = (poi: MuseumPoi) => Boolean(poi.positionGltf)
// 数据质量统计保留给诊断日志,普通游客只看到可恢复的加载状态。
const dataWarning = computed(() => {
const diagnostic = {
excluded: excludedPoiCount.value,
duplicates: duplicatePoiCount.value,
unavailable: unavailablePoiCount.value,
floorLoadError: floorLoadError.value
}
if (import.meta.env.DEV && Object.values(diagnostic).some(Boolean)) {
@@ -375,26 +362,11 @@ const dataWarning = computed(() => {
return ''
})
const dedupePois = (items: MuseumPoi[]) => {
const seenIds = new Set<string>()
return items.filter((poi) => {
if (!poi.id?.trim() || seenIds.has(poi.id)) return false
seenIds.add(poi.id)
return true
})
}
const preparePoiResults = (items: MuseumPoi[]): PreparedPoiResults => {
const inspection = inspectPoiCollection(items)
const missingIdCount = items.filter((poi) => !poi.id?.trim()).length
const uniquePois = dedupePois(items)
const visiblePois = uniquePois.filter(isVisiblePoi)
return {
visiblePois,
duplicateCount: inspection.duplicateIds.length,
excludedCount: missingIdCount + uniquePois.length - visiblePois.length
}
const isCategoryDisabled = (category: PoiCategoryDefinition) => {
const state = categoryStatesById.value.get(category.id)
return isLoading.value
|| !state
|| state.disabled
}
const activeFloorOption = computed(() => (
@@ -402,19 +374,15 @@ const activeFloorOption = computed(() => (
))
const createSearchContext = (): PoiSearchContext => {
const visiblePoiIds = floorResults.value
.filter(isPoiLocatable)
.map((poi) => poi.id)
const resultCount = isHomeCategoryMode.value
? floorResults.value.length
: pois.value.length
const visiblePoiIds = searchViewState.value?.visiblePoiIds || []
const resultCount = pois.value.length
return {
origin: props.variant,
keyword: searchKeyword.value,
categoryId: activeCategoryId.value,
floorId: activeFloorOption.value?.id || props.currentFloorId || '',
floorLabel: activeFloor.value || props.currentFloorLabel || '',
floorId: searchViewState.value?.floorId || activeFloorOption.value?.id || props.currentFloorId || '',
floorLabel: searchViewState.value?.floorLabel || activeFloor.value || props.currentFloorLabel || '',
resultCount,
floorResultCount: floorResults.value.length,
visiblePoiIds,
@@ -424,7 +392,8 @@ const createSearchContext = (): PoiSearchContext => {
const emitResultsState = () => {
const context = createSearchContext()
const active = isHomeCategoryMode.value
const active = props.variant === 'home'
&& (searchViewState.value?.mode === 'category' || searchViewState.value?.mode === 'keyword')
emit('results-change', {
...context,
visiblePoiIds: active ? context.visiblePoiIds : [],
@@ -432,6 +401,20 @@ const emitResultsState = () => {
})
}
const emitPendingHomeSearchResults = (floor?: Pick<MuseumFloor, 'id' | 'label'>) => {
if (props.variant !== 'home') return
const context = createSearchContext()
emit('results-change', {
...context,
floorId: floor?.id || context.floorId,
floorLabel: floor?.label || context.floorLabel,
visiblePoiIds: [],
active: true,
pending: true
})
}
const setHomeCategoryMode = (active: boolean) => {
const nextValue = props.variant === 'home' && active
if (homeCategoryMode.value === nextValue) return
@@ -459,29 +442,6 @@ const syncActiveFloor = () => {
|| displayFloors.value[0].label
}
const syncActiveFloorToResults = (items: MuseumPoi[]) => {
if (!displayFloors.value.length) {
activeFloor.value = ''
return
}
if (props.variant === 'home' && (props.currentFloorId || props.currentFloorLabel)) {
syncActiveFloor()
return
}
const resultFloorLabels = new Set(items.map((poi) => poi.floorLabel).filter(Boolean))
if (activeFloor.value && resultFloorLabels.has(activeFloor.value)) return
const firstFloorWithResult = displayFloors.value.find((floor) => resultFloorLabels.has(floor.label))
if (firstFloorWithResult) {
activeFloor.value = firstFloorWithResult.label
return
}
syncActiveFloor()
}
const loadFloors = async () => {
floorLoadError.value = ''
try {
@@ -494,26 +454,66 @@ const loadFloors = async () => {
}
}
const loadPoiPool = async () => {
const result = await guideUseCase.getInitialSearchSpacePoints()
return preparePoiResults(result)
const activeFloorId = () => (
activeFloorOption.value?.id
|| searchViewState.value?.floorId
|| props.currentFloorId
|| ''
)
const commitPoiSearchViewState = async (
state: GuidePoiSearchViewState,
requestSeq: number
) => {
if (requestSeq !== searchRequestSeq) return
searchViewState.value = state
pois.value = state.results
activeFloor.value = state.floorLabel
duplicatePoiCount.value = 0
excludedPoiCount.value = 0
await nextTick()
if (requestSeq !== searchRequestSeq) return
emitResultsState()
}
const setPreparedResults = async (prepared: PreparedPoiResults) => {
pois.value = prepared.visiblePois
duplicatePoiCount.value = prepared.duplicateCount
excludedPoiCount.value = prepared.excludedCount
syncActiveFloorToResults(prepared.visiblePois)
await nextTick()
emitResultsState()
const runPoiSearchRequest = async (
loader: () => Promise<GuidePoiSearchViewState>
) => {
const requestSeq = ++searchRequestSeq
isLoading.value = true
searchError.value = ''
try {
const state = await loader()
await commitPoiSearchViewState(state, requestSeq)
return state
} catch (error) {
await handleLoadFailure(requestSeq, error, '加载点位搜索结果失败:')
return null
} finally {
if (requestSeq === searchRequestSeq) isLoading.value = false
}
}
const handleLoadFailure = async (requestSeq: number, error: unknown, message: string) => {
if (requestSeq !== searchRequestSeq) return
console.error(message, error)
pois.value = []
if (searchViewState.value) {
searchViewState.value = {
...searchViewState.value,
results: [],
visiblePoiIds: []
}
}
searchError.value = '点位加载失败'
await nextTick()
if (requestSeq !== searchRequestSeq) return
if (props.variant === 'home' && (homeCategoryMode.value || Boolean(searchKeyword.value))) {
emitPendingHomeSearchResults()
return
}
emitResultsState()
}
@@ -523,10 +523,9 @@ const loadInitialSpacePoints = async () => {
searchError.value = ''
try {
const prepared = await loadPoiPool()
const state = await guideUseCase.createInitialPoiSearchState(activeFloorId())
if (requestSeq !== searchRequestSeq) return
cachedPoiPool = prepared
await setPreparedResults(prepared)
await commitPoiSearchViewState(state, requestSeq)
} catch (error) {
await handleLoadFailure(requestSeq, error, '加载点位基础数据失败:')
} finally {
@@ -543,14 +542,19 @@ const loadPois = async (keyword = '') => {
const requestSeq = ++searchRequestSeq
isLoading.value = true
searchError.value = ''
if (props.variant === 'home') {
// Keep a pending query from briefly showing stale list or marker IDs.
pois.value = []
emitPendingHomeSearchResults()
}
try {
const [result, pool] = await Promise.all([
guideUseCase.searchPois(keyword),
loadPoiPool()
])
const state = await guideUseCase.searchPoiKeyword(
searchViewState.value,
keyword,
activeFloorId()
)
if (requestSeq !== searchRequestSeq) return
cachedPoiPool = pool
await setPreparedResults(preparePoiResults(result))
await commitPoiSearchViewState(state, requestSeq)
} catch (error) {
await handleLoadFailure(requestSeq, error, '加载点位搜索结果失败:')
} finally {
@@ -558,6 +562,24 @@ const loadPois = async (keyword = '') => {
}
}
const refreshCurrentPoiSearchFloor = async (floorLabel: string) => {
const floor = displayFloors.value.find((item) => item.label === floorLabel)
if (!floor) return
activeFloor.value = floor.label
resultListScrollTop.value = 0
const hasExplicitQuery = searchViewState.value?.mode === 'category'
|| searchViewState.value?.mode === 'keyword'
if (props.variant === 'home' && hasExplicitQuery) {
emitPendingHomeSearchResults(floor)
}
await runPoiSearchRequest(() => guideUseCase.changePoiSearchFloor(
searchViewState.value,
floor.id
))
}
const focusSearchInput = async () => {
searchInputFocused.value = false
await nextTick()
@@ -571,7 +593,6 @@ const expandHomePanel = () => {
}
setHomeCategoryMode(false)
homeExpanded.value = true
emitResultsState()
}
const shouldBlockHomeContentTap = () => (
@@ -619,7 +640,6 @@ const collapseHomePanel = () => {
}
const resetSearchState = async () => {
searchRequestSeq += 1
searchKeyword.value = ''
searchDraftKeyword.value = ''
activeCategoryId.value = ''
@@ -628,18 +648,14 @@ const resetSearchState = async () => {
setHomeCategoryMode(false)
homeExpanded.value = false
searchInputFocused.value = false
if (cachedPoiPool) {
pois.value = cachedPoiPool.visiblePois
duplicatePoiCount.value = cachedPoiPool.duplicateCount
excludedPoiCount.value = cachedPoiPool.excludedCount
} else {
if (searchViewState.value?.mode !== 'default') {
pois.value = []
duplicatePoiCount.value = 0
excludedPoiCount.value = 0
emitPendingHomeSearchResults()
}
syncActiveFloor()
await nextTick()
emitResultsState()
await runPoiSearchRequest(() => guideUseCase.clearPoiSearch(
searchViewState.value,
activeFloorId()
))
}
// 从详情页返回时只清理搜索 UI 与分类 marker不参与地图目标焦点的复位。
@@ -739,7 +755,9 @@ const enterFullSearch = () => {
activeCategoryId.value = ''
searchKeyword.value = ''
searchDraftKeyword.value = ''
if (cachedPoiPool) void setPreparedResults(cachedPoiPool)
pois.value = []
emitPendingHomeSearchResults()
void loadInitialSpacePoints()
}
}
@@ -761,7 +779,14 @@ const handleSearchClear = async () => {
searchKeyword.value = ''
activeCategoryId.value = ''
setHomeCategoryMode(false)
await loadInitialSpacePoints()
if (searchViewState.value?.mode !== 'default') {
pois.value = []
emitPendingHomeSearchResults()
}
await runPoiSearchRequest(() => guideUseCase.clearPoiSearch(
searchViewState.value,
activeFloorId()
))
await focusSearchInput()
}
@@ -776,7 +801,7 @@ const searchShortcut = async (
const category = typeof categoryInput === 'string'
? getPoiCategoryById(categoryInput)
: categoryInput
if (!category) return
if (!category || isCategoryDisabled(category)) return
const requestSeq = ++searchRequestSeq
const useHomeResultList = props.variant === 'home'
@@ -793,17 +818,21 @@ const searchShortcut = async (
searchError.value = ''
setHomeCategoryMode(useHomeResultList)
isLoading.value = true
if (useHomeResultList) {
// The category result replaces the current home list, so clear both
// surfaces until its canonical IDs have resolved.
pois.value = []
emitPendingHomeSearchResults()
}
try {
const pool = await loadPoiPool()
const state = await guideUseCase.selectPoiSearchCategory(
searchViewState.value,
category.id,
activeFloorId()
)
if (requestSeq !== searchRequestSeq) return
cachedPoiPool = pool
const prepared: PreparedPoiResults = {
visiblePois: pool.visiblePois.filter((poi) => matchesPoiCategory(poi, category)),
duplicateCount: pool.duplicateCount,
excludedCount: pool.excludedCount
}
await setPreparedResults(prepared)
await commitPoiSearchViewState(state, requestSeq)
} catch (error) {
await handleLoadFailure(requestSeq, error, '加载点位分类搜索结果失败:')
} finally {
@@ -827,12 +856,6 @@ const handleSearchConfirm = async (event?: any) => {
setHomeCategoryMode(false)
if (props.variant === 'home' && !homeExpanded.value) expandHomePanel()
const matchedCategory = findPoiCategoryByKeyword(searchKeyword.value)
if (matchedCategory) {
await searchShortcut(matchedCategory)
return
}
activeCategoryId.value = ''
resultListScrollTop.value = 0
await loadPois(searchKeyword.value)
@@ -840,8 +863,7 @@ const handleSearchConfirm = async (event?: any) => {
const handleFloorChange = (floor: string) => {
if (activeFloor.value === floor) return
activeFloor.value = floor
resultListScrollTop.value = 0
void refreshCurrentPoiSearchFloor(floor)
}
const handleResultListScroll = (event: any) => {
@@ -946,11 +968,6 @@ const applyInitialKeyword = async (keyword: string) => {
if (props.variant === 'home' && normalizedKeyword) expandHomePanel()
if (props.variant === 'home' && !normalizedKeyword) homeExpanded.value = false
const matchedCategory = findPoiCategoryByKeyword(normalizedKeyword)
if (matchedCategory) {
await searchShortcut(matchedCategory)
return
}
activeCategoryId.value = ''
if (normalizedKeyword) {
await loadPois(normalizedKeyword)
@@ -963,16 +980,14 @@ watch(() => props.initialKeyword, (keyword) => {
void applyInitialKeyword(keyword || '')
})
watch([() => props.currentFloorId, () => props.currentFloorLabel], async () => {
watch([() => props.currentFloorId, () => props.currentFloorLabel], () => {
if (props.variant !== 'home') return
syncActiveFloor()
await nextTick()
emitResultsState()
})
watch(activeFloor, async () => {
await nextTick()
emitResultsState()
const requestedFloor = displayFloors.value.find((floor) => (
floor.id === props.currentFloorId
|| floor.label === props.currentFloorLabel
))
if (!requestedFloor || requestedFloor.id === searchViewState.value?.floorId) return
void refreshCurrentPoiSearchFloor(requestedFloor.label)
})
watch(showSearchContent, (expanded) => {

View File

@@ -9,6 +9,9 @@ import type {
import {
isIndoorNavigableFloor
} from '@/domain/guideFloor'
import {
normalizePoiSemanticValue
} from '@/domain/poiCategories'
const floorIdPattern = /^L(-?\d+(?:\.\d+)?)$/
@@ -43,33 +46,176 @@ export const isStaticIndoorNavigableFloorId = (floorId: string) => (
})
)
const toCategory = (category: StaticNavPoiCategoryPayload): MuseumCategory => ({
id: category.topCategory,
label: category.topCategoryZh,
iconType: category.iconType
})
const normalizedText = (value?: string | null) => (value || '').trim().toLowerCase()
const staticDestinationCategoryBySemanticType: Readonly<Record<string, string>> = {
exhibition_hall: 'exhibition_hall',
theater: 'space_theater',
restaurant: 'space_restaurant',
cafe: 'space_restaurant',
shop: 'space_shop',
cultural_shop: 'space_shop',
bookstore: 'space_shop',
vending: 'space_shop'
}
const staticFallbackDestinationTypes = new Set(
Object.keys(staticDestinationCategoryBySemanticType)
)
const staticFacilityCategoryBySemanticType: Readonly<Record<string, string>> = {
ticket_office: 'basic_service_facility',
service_desk: 'basic_service_facility',
locker: 'basic_service_facility',
rental_service: 'basic_service_facility',
toilet: 'basic_service_facility',
accessible_toilet: 'accessibility_special_service',
mother_baby_room: 'basic_service_facility',
elevator: 'transport_circulation',
escalator: 'transport_circulation',
stairs: 'transport_circulation'
}
const staticCategoryLabelBySemanticType: Readonly<Record<string, string>> = {
exhibition_hall: '展厅',
theater: '影院',
restaurant: '餐饮',
cafe: '餐饮',
shop: '购物',
cultural_shop: '购物',
bookstore: '购物',
vending: '购物',
ticket_office: '售票处',
service_desk: '服务',
locker: '服务',
rental_service: '服务',
toilet: '洗手间',
accessible_toilet: '洗手间',
mother_baby_room: '母婴',
elevator: '电梯',
escalator: '扶梯',
stairs: '楼梯'
}
const staticNameSemanticType = (name: string) => {
if (/(?:售票|票务|ticket)/.test(name)) return 'ticket_office'
if (/(?:服务台|咨询台|接待|服务中心|service[_ ]?(?:desk|center)|information)/.test(name)) return 'service_desk'
if (/(?:存包|寄存|储物|locker)/.test(name)) return 'locker'
if (/(?:租赁|租借|rental)/.test(name)) return 'rental_service'
if (/(?:茶水间|饮水间|water)/.test(name)) return 'service_space'
if (/(?:影院|影厅|剧场|cinema|theater)/.test(name)) return 'theater'
if (/(?:餐饮|餐厅|餐馆|咖啡|restaurant|dining|food|cafe)/.test(name)) return 'restaurant'
if (/(?:购物|商店|商铺|文创|零售|shop|store|retail)/.test(name)) return 'shop'
return ''
}
/**
* The static navigation package has legacy facility labels where stairways,
* escalators, and nursing rooms inherit a generic elevator/restroom icon.
* Keep the provider source-faithful and repair that visitor-facing semantic at
* the adapter boundary.
*/
export const normalizeStaticPoiIconType = (
poi: Pick<StaticNavPoiPayload, 'name'>,
rawIconType?: string | null
) => {
const name = normalizedText(poi.name)
const iconType = normalizedText(rawIconType)
if (name.includes('扶梯')) return 'escalator'
if (name.includes('楼梯')) return 'stairs'
if (name.includes('母婴')) return 'mother_baby_room'
if (/(?:无障碍|残疾人|残障).*(?:卫生间|洗手间|厕所)/.test(name)) {
return 'accessible_toilet'
}
if (/(?:卫生间|洗手间|厕所)/.test(name)) return 'toilet'
return iconType
}
const getStaticPoiSemanticType = (
poi: StaticNavPoiPayload,
rawIconType?: string | null
) => (
staticNameSemanticType(normalizedText(poi.name))
|| normalizePoiSemanticValue(normalizeStaticPoiIconType(poi, rawIconType))
)
const getStaticPoiCategoryId = (
rawCategoryId: string,
semanticType: string
) => {
const normalizedCategoryId = normalizePoiSemanticValue(rawCategoryId)
const isTouringFallback = normalizedCategoryId === 'touring_poi'
|| staticFallbackDestinationTypes.has(normalizedCategoryId)
if (isTouringFallback) {
return staticDestinationCategoryBySemanticType[semanticType]
|| staticFacilityCategoryBySemanticType[semanticType]
|| rawCategoryId
}
return rawCategoryId
}
const getStaticPoiCategoryLabel = (
rawCategoryId: string,
rawCategoryLabel: string,
semanticType: string
) => {
const normalizedCategoryId = normalizePoiSemanticValue(rawCategoryId)
const isGenericTouringCategory = normalizedCategoryId === 'touring_poi'
return isGenericTouringCategory
? staticCategoryLabelBySemanticType[semanticType] || rawCategoryLabel
: rawCategoryLabel
}
const getStaticPoiKind = (categoryId: string): MuseumPoi['kind'] => {
if (categoryId === 'exhibition_hall') return 'hall'
if (categoryId.startsWith('space_')) return 'space'
return 'facility'
}
const toCategory = (
poi: StaticNavPoiPayload,
category: StaticNavPoiCategoryPayload
): MuseumCategory => {
const semanticType = getStaticPoiSemanticType(poi, category.iconType)
return {
id: getStaticPoiCategoryId(category.topCategory, semanticType),
label: getStaticPoiCategoryLabel(category.topCategory, category.topCategoryZh, semanticType),
iconType: semanticType
}
}
const isPoiAccessible = (poi: StaticNavPoiPayload) => (
poi.primaryCategory === 'accessibility_special_service'
|| poi.categories?.some((category) => category.topCategory === 'accessibility_special_service') === true
)
export const toMuseumPoi = (poi: StaticNavPoiPayload): MuseumPoi => ({
id: poi.id,
name: poi.name,
floorId: poi.floorId,
floorLabel: formatNavFloorLabel(poi.floorId),
primaryCategory: {
id: poi.primaryCategory,
label: poi.primaryCategoryZh,
iconType: poi.iconType
},
categories: (poi.categories || []).map(toCategory),
positionGltf: poi.positionGltf,
sourceObjectName: poi.sourceObjectName,
sourceConfidence: poi.sourceConfidence,
navigationReadiness: poi.navigationReadiness,
accessible: isPoiAccessible(poi),
kind: poi.primaryCategory === 'touring_poi' ? 'hall' : 'facility',
hallName: poi.primaryCategory === 'touring_poi' ? poi.name : undefined
})
export const toMuseumPoi = (poi: StaticNavPoiPayload): MuseumPoi => {
const categoryFallbackIconType = poi.categories?.[0]?.iconType
const iconType = getStaticPoiSemanticType(poi, poi.iconType || categoryFallbackIconType)
const primaryCategoryId = getStaticPoiCategoryId(poi.primaryCategory, iconType)
const kind = getStaticPoiKind(primaryCategoryId)
return {
id: poi.id,
name: poi.name,
floorId: poi.floorId,
floorLabel: formatNavFloorLabel(poi.floorId),
primaryCategory: {
id: primaryCategoryId,
label: getStaticPoiCategoryLabel(poi.primaryCategory, poi.primaryCategoryZh, iconType),
iconType
},
categories: (poi.categories || []).map((category) => toCategory(poi, category)),
positionGltf: poi.positionGltf,
sourceObjectName: poi.sourceObjectName,
sourceConfidence: poi.sourceConfidence,
navigationReadiness: poi.navigationReadiness,
accessible: isPoiAccessible(poi),
kind,
hallName: kind === 'hall' ? poi.name : undefined
}
}

View File

@@ -19,7 +19,8 @@ import {
isIndoorNavigableFloor
} from '@/domain/guideFloor'
import {
isPoiSearchCategorySupported
isPoiSearchCategorySupported,
normalizePoiSemanticValue
} from '@/domain/poiCategories'
import type {
SgsFloorDiagnosticsPayload,
@@ -91,9 +92,39 @@ const spaceCategoryBySgsType: Record<string, MuseumCategory> = {
iconType: 'service_space'
},
commercial: {
id: 'space_commercial',
label: '商业空间',
iconType: 'commercial'
id: 'space_shop',
label: '购物空间',
iconType: 'shop'
},
restaurant: {
id: 'space_restaurant',
label: '餐饮空间',
iconType: 'restaurant'
},
cafe: {
id: 'space_restaurant',
label: '餐饮空间',
iconType: 'cafe'
},
shop: {
id: 'space_shop',
label: '购物空间',
iconType: 'shop'
},
cultural_shop: {
id: 'space_shop',
label: '购物空间',
iconType: 'cultural_shop'
},
bookstore: {
id: 'space_shop',
label: '购物空间',
iconType: 'bookstore'
},
vending: {
id: 'space_shop',
label: '购物空间',
iconType: 'vending'
},
parking: {
id: 'space_parking',
@@ -113,6 +144,15 @@ const spaceCategoryBySgsType: Record<string, MuseumCategory> = {
}
const categoryBySgsType: Record<string, MuseumCategory & { accessible?: boolean }> = {
exhibition_hall: hallCategory,
theater: spaceCategoryBySgsType.theater,
commercial: spaceCategoryBySgsType.commercial,
restaurant: spaceCategoryBySgsType.restaurant,
cafe: spaceCategoryBySgsType.cafe,
shop: spaceCategoryBySgsType.shop,
cultural_shop: spaceCategoryBySgsType.cultural_shop,
bookstore: spaceCategoryBySgsType.bookstore,
vending: spaceCategoryBySgsType.vending,
poi: {
id: 'poi',
label: '点位',
@@ -126,7 +166,7 @@ const categoryBySgsType: Record<string, MuseumCategory & { accessible?: boolean
operation_experience: {
id: 'operation_experience',
label: '导览点位',
iconType: 'poi'
iconType: 'guide'
},
toilet: {
id: 'basic_service_facility',
@@ -155,11 +195,27 @@ const categoryBySgsType: Record<string, MuseumCategory & { accessible?: boolean
label: '扶梯',
iconType: 'escalator'
},
entrance_exit: {
id: 'transport_circulation',
label: '出入口',
iconType: 'entrance_exit'
},
entrance_exit: {
id: 'navigation_anchor',
label: '出入口',
iconType: 'entrance_exit'
},
hall_entrance: hallEntranceCategory,
entrance_anchor: {
id: 'navigation_anchor',
label: '入口锚点',
iconType: 'entrance_anchor'
},
route_node: {
id: 'navigation_anchor',
label: '路线节点',
iconType: 'route_node'
},
navigable_place: {
id: 'navigation_anchor',
label: '可导航点',
iconType: 'navigable_place'
},
service_desk: {
id: 'basic_service_facility',
label: '服务台',
@@ -203,7 +259,9 @@ const optionalNumber = (value: number | null | undefined) => (
: undefined
)
const normalizedText = (value?: string | null) => (value || '').trim()
const normalizedText = (value?: string | null) => (value || '').trim()
export const normalizeSgsPoiSemanticType = (value?: string | null) => normalizePoiSemanticValue(value)
const normalizePositionSource = (source: SgsPositionPayload): [number, number, number] | undefined => {
const x = optionalNumber(source.x)
@@ -302,6 +360,8 @@ const nonHallNavigablePlaceTypes = new Set([
'toilet',
'accessible_toilet',
'restroom',
'route_node',
'navigable_place',
'卫生间',
'洗手间',
'无障碍卫生间',
@@ -322,15 +382,38 @@ export interface SgsHallPoiDiagnostics {
}
const businessTypeLabels: Record<string, string> = {
shop: '商店',
shop: '购物',
commercial: '购物',
restaurant: '餐饮',
cafe: '咖啡',
vending: '售卖机',
bookstore: '书店',
cultural_shop: '文创商店',
cafe: '餐饮',
vending: '购物',
bookstore: '购物',
cultural_shop: '购物',
photo_spot: '打卡点'
}
const businessTypeIconTypes: Record<string, string> = {
shop: 'shop',
commercial: 'shop',
restaurant: 'restaurant',
cafe: 'cafe',
vending: 'vending',
bookstore: 'bookstore',
cultural_shop: 'cultural_shop',
photo_spot: 'photo_spot'
}
const categoryForBusinessType = (businessType?: string | null): MuseumCategory => {
const normalizedBusinessType = normalizeSgsPoiSemanticType(businessType)
if (!normalizedBusinessType) return businessCategory
return {
...businessCategory,
label: businessTypeLabels[normalizedBusinessType] || businessCategory.label,
iconType: businessTypeIconTypes[normalizedBusinessType] || normalizedBusinessType
}
}
interface SgsHallPoiBuildOptions {
fallbackY?: number
}
@@ -339,10 +422,28 @@ const sgsSpaceCenterConfidence = 'backend-sgs-sdk-space-center'
const sgsSpaceBoundaryCenterConfidence = 'backend-sgs-sdk-space-boundary-center'
const sgsHallEntranceConfidence = 'backend-sgs-sdk-hall-entrance'
const normalizeTypeId = (value?: string | null) => normalizedText(value)
.toLowerCase()
const normalizeTypeId = (value?: string | null) => normalizeSgsPoiSemanticType(value)
.replace(/[^a-z0-9_-]+/g, '_')
.replace(/^_+|_+$/g, '')
const hiddenSgsPoiTypes = new Set([
'entrance_exit',
'hall_entrance',
'entrance_anchor',
'route_node',
'navigable_place',
'operation_experience'
])
/** Raw navigable-place records may enrich a hall, but are never visitor results themselves. */
export const isSgsNavigablePlaceVisitorResult = (_place: SgsNavigablePlacePayload) => false
export const isSgsPoiHiddenFromVisitorSearch = (poi: Pick<
SgsPoiPayload,
'type' | 'typeName'
>) => [poi.type, poi.typeName]
.map(normalizeSgsPoiSemanticType)
.some((type) => hiddenSgsPoiTypes.has(type))
const hasExhibitionKeyword = (value?: string | null) => (
exhibitionHallKeywords.some((keyword) => normalizedText(value).includes(keyword))
@@ -412,7 +513,7 @@ const normalizeSpacePosition = (
}
export const isExhibitionHallSpace = (space: SgsSpacePayload) => {
const type = normalizedText(space.type).toLowerCase()
const type = normalizeSgsPoiSemanticType(space.type)
const name = normalizedText(space.name)
const searchableText = searchableSpaceText(space)
@@ -430,7 +531,7 @@ export const isExhibitionHallNavigablePlace = (place: SgsNavigablePlacePayload)
place.typeCode,
place.typeName
]
.map((value) => normalizedText(value).toLowerCase())
.map((value) => normalizeSgsPoiSemanticType(value))
.filter(Boolean)
if (placeTypes.some((type) => nonHallNavigablePlaceTypes.has(type))) return false
@@ -480,23 +581,26 @@ const normalizePlacePosition = (
)
const categoryFor = (poi: SgsPoiPayload): MuseumCategory & { accessible?: boolean } => {
const normalizedType = poi.type?.trim().toLowerCase()
const normalizedBusinessType = poi.businessType?.trim().toLowerCase()
const normalizedType = normalizeSgsPoiSemanticType(poi.type)
const normalizedTypeName = normalizeSgsPoiSemanticType(poi.typeName)
const resolvedType = categoryBySgsType[normalizedType]
? normalizedType
: normalizedTypeName || normalizedType
const normalizedBusinessType = normalizeSgsPoiSemanticType(poi.businessType)
if (String(poi.poiGroup || '').toUpperCase() === 'BUSINESS') {
return {
...businessCategory,
label: normalizedBusinessType ? businessTypeLabels[normalizedBusinessType] || poi.typeName || businessCategory.label : poi.typeName || businessCategory.label,
iconType: normalizedBusinessType || businessCategory.iconType
}
const category = categoryForBusinessType(normalizedBusinessType || resolvedType)
return category.iconType === businessCategory.iconType && poi.typeName
? { ...category, label: poi.typeName }
: category
}
if (String(poi.poiGroup || '').toUpperCase() === 'OTHER') {
return categoryBySgsType[normalizedType || ''] || defaultCategory
return categoryBySgsType[resolvedType] || defaultCategory
}
if (normalizedType && categoryBySgsType[normalizedType]) {
return categoryBySgsType[normalizedType]
if (resolvedType && categoryBySgsType[resolvedType]) {
return categoryBySgsType[resolvedType]
}
if (poi.typeName) {
@@ -511,9 +615,13 @@ const categoryFor = (poi: SgsPoiPayload): MuseumCategory & { accessible?: boolea
}
const kindForSgsPoi = (poi: SgsPoiPayload, category: MuseumCategory) => {
if (category.id === 'business_poi') return 'facility'
if (category.id === 'operation_experience') return 'guide'
if (isSgsPoiHiddenFromVisitorSearch(poi)) return 'guide'
if (category.id === 'operation_experience' || category.id === 'navigation_anchor') return 'guide'
if (category.id === hallCategory.id) return 'hall'
// Raw POI endpoints can use a space-like semantic type (restaurant, shop,
// theater) without being a source space. Space records are adapted through
// toMuseumSpacePointFromSgs; raw records remain facility POIs.
return 'facility'
}
@@ -527,10 +635,14 @@ export const toMuseumPoiFromSgs = (
|| sourceFloorId
|| stringifyId(poi.floorCode)
const category = categoryFor(poi)
return {
id: stringifyId(poi.id),
name: poi.name?.trim() || `未命名点位 ${stringifyId(poi.id)}`,
const kind = kindForSgsPoi(poi, category)
const spatialAreaId = stringifyId(poi.spatialAreaId)
const spatialAreaName = normalizedText(poi.spatialAreaName)
const name = poi.name?.trim() || `未命名点位 ${stringifyId(poi.id)}`
return {
id: stringifyId(poi.id),
name,
floorId,
floorLabel: formatSgsFloorLabel(matchedFloor?.floorCode || poi.floorCode, matchedFloor?.floorName),
primaryCategory: {
@@ -550,7 +662,11 @@ export const toMuseumPoiFromSgs = (
sourceConfidence: 'backend-sgs-sdk',
navigationReadiness: '位置预览',
accessible: category.accessible === true,
kind: kindForSgsPoi(poi, category)
kind,
hallId: kind === 'hall' ? spatialAreaId || stringifyId(poi.id) : undefined,
hallName: spatialAreaName || (kind === 'hall' ? name : undefined),
spaceId: spatialAreaId || undefined,
sourceSpaceId: spatialAreaId || undefined
}
}

View File

@@ -4,17 +4,15 @@ import type {
} from '@/domain/museum'
export type PoiCategoryId =
| 'itinerary'
| 'exhibition-hall'
| 'cinema'
| 'ticket-office'
| 'service-center'
| 'dining'
| 'souvenir'
| 'nursing-room'
| 'shopping'
| 'service-center'
| 'restroom'
| 'nursing-room'
| 'elevator'
| 'stairs'
| 'escalator'
export interface PoiCategoryDefinition {
@@ -31,33 +29,22 @@ export interface PoiCategoryDefinition {
const definitions: PoiCategoryDefinition[] = [
{
id: 'itinerary',
label: '行程',
icon: 'itinerary',
id: 'exhibition-hall',
label: '展厅',
icon: 'exhibition',
order: 1,
homeVisible: true,
keywords: ['行程', '导览', '参观', 'touring_poi'],
categoryIds: ['touring_poi'],
iconTypes: ['touring', 'guide']
},
{
id: 'exhibition-hall',
label: '展览',
icon: 'exhibition',
order: 2,
homeVisible: true,
keywords: ['展厅', '展览', '展馆', 'exhibition_hall', 'hall'],
categoryIds: ['exhibition_hall', 'exhibition_hall_entrance'],
iconTypes: ['exhibition_hall', 'hall_entrance', 'exhibition'],
excludedCategoryIds: ['touring_poi']
categoryIds: ['exhibition_hall'],
iconTypes: ['exhibition_hall', 'exhibition']
},
{
id: 'cinema',
label: '影院',
icon: 'cinema',
order: 3,
order: 2,
homeVisible: true,
keywords: ['影院', '影厅', '剧场', '报告厅', 'cinema', 'theater'],
keywords: ['影院', '影厅', '剧场', '报告厅', 'cinema', 'theater', '影院空间'],
categoryIds: ['space_theater'],
iconTypes: ['cinema', 'theater']
},
@@ -65,47 +52,47 @@ const definitions: PoiCategoryDefinition[] = [
id: 'ticket-office',
label: '售票处',
icon: 'ticket',
order: 6,
order: 3,
homeVisible: true,
keywords: ['售票处', '售票', '票务', 'ticket_office'],
categoryIds: [],
iconTypes: ['ticket_office']
},
{
id: 'service-center',
label: '服务中心',
icon: 'service',
order: 8,
homeVisible: true,
keywords: ['服务中心', '服务台', '咨询台', 'service_desk'],
categoryIds: [],
iconTypes: ['service_desk']
},
{
id: 'dining',
label: '餐',
label: '餐',
icon: 'restaurant',
order: 5,
order: 4,
homeVisible: true,
keywords: ['餐饮', '餐厅', '快餐', '咖啡', 'restaurant', 'dining', 'food', 'cafe'],
categoryIds: [],
iconTypes: ['restaurant', 'dining', 'food', 'cafe']
},
{
id: 'souvenir',
label: '文创',
id: 'shopping',
label: '购物',
icon: 'bag',
order: 11,
homeVisible: false,
keywords: ['文创', '纪念品', '商店', 'souvenir', 'gift', 'shop'],
order: 5,
homeVisible: true,
keywords: ['购物', '商店', '文创', '纪念品', '零售', 'souvenir', 'gift', 'shop', 'shopping', 'store'],
categoryIds: [],
iconTypes: ['souvenir', 'gift', 'shop', 'cultural_creative']
iconTypes: ['souvenir', 'gift', 'shop', 'store', 'retail', 'cultural_shop', 'bookstore', 'vending']
},
{
id: 'service-center',
label: '服务',
icon: 'service',
order: 6,
homeVisible: true,
keywords: ['服务', '服务中心', '服务台', '咨询台', '租赁', '寄存', 'service_desk', 'rental_service', 'locker'],
categoryIds: [],
iconTypes: ['service_desk', 'service_center', 'rental_service', 'locker']
},
{
id: 'nursing-room',
label: '母婴',
label: '母婴',
icon: 'nursing',
order: 7,
order: 8,
homeVisible: true,
keywords: ['母婴室', '母婴间', 'mother_baby_room', 'nursing_room'],
categoryIds: [],
@@ -115,7 +102,7 @@ const definitions: PoiCategoryDefinition[] = [
id: 'restroom',
label: '洗手间',
icon: 'restroom',
order: 4,
order: 7,
homeVisible: true,
keywords: ['卫生间', '洗手间', '厕所', 'toilet', 'accessible_toilet', 'restroom'],
categoryIds: [],
@@ -131,16 +118,6 @@ const definitions: PoiCategoryDefinition[] = [
categoryIds: [],
iconTypes: ['elevator']
},
{
id: 'stairs',
label: '楼梯',
icon: 'stairs',
order: 12,
homeVisible: false,
keywords: ['楼梯', 'stairs', 'stair'],
categoryIds: [],
iconTypes: ['stairs', 'stair']
},
{
id: 'escalator',
label: '扶梯',
@@ -160,11 +137,12 @@ export const POI_CATEGORIES = Object.freeze(
const homeCategoryOrder: readonly PoiCategoryId[] = [
'exhibition-hall',
'cinema',
'restroom',
'dining',
'ticket-office',
'nursing-room',
'dining',
'shopping',
'service-center',
'restroom',
'nursing-room',
'elevator',
'escalator'
]
@@ -176,17 +154,15 @@ export const HOME_POI_CATEGORIES = Object.freeze(
)
const POI_CATEGORY_ICON_SYMBOLS: Readonly<Record<PoiCategoryId, string>> = {
itinerary: 'poi-itinerary',
'exhibition-hall': 'poi-exhibition-hall',
cinema: 'poi-cinema',
'ticket-office': 'poi-ticket-office',
'service-center': 'poi-service-center',
dining: 'poi-dining',
souvenir: 'poi-souvenir',
'nursing-room': 'poi-nursing-room',
shopping: 'poi-souvenir',
'service-center': 'poi-service-center',
restroom: 'poi-restroom',
'nursing-room': 'poi-nursing-room',
elevator: 'poi-elevator',
stairs: 'poi-stairs',
escalator: 'poi-escalator'
}
@@ -194,52 +170,218 @@ export const getPoiCategoryIconHref = (categoryId: PoiCategoryId) => (
`/static/icons/poi/shortcut-icons.svg#${POI_CATEGORY_ICON_SYMBOLS[categoryId]}`
)
type PoiCategorySource = Pick<MuseumPoi, 'name' | 'primaryCategory' | 'categories'>
export type PoiCategorySource = Pick<
MuseumPoi,
| 'id'
| 'name'
| 'floorId'
| 'primaryCategory'
| 'categories'
| 'kind'
| 'hallId'
| 'spaceId'
| 'sourcePlaceId'
| 'sourceSpaceId'
| 'sourceObjectName'
>
const normalizeValue = (value?: string | null) => (value || '').trim().toLowerCase()
const normalizeValue = (value?: string | null) => (value || '')
.trim()
.normalize('NFKC')
.toLowerCase()
.replace(/[\s-]+/g, '_')
.replace(/^_+|_+$/g, '')
const categoryValues = (categories: MuseumCategory[]) => categories.flatMap((category) => [
category.id,
category.label,
category.iconType || ''
])
const poiSemanticAliases: Readonly<Record<string, string>> = {
exhibition: 'exhibition_hall',
exhibition_hall: 'exhibition_hall',
hall: 'exhibition_hall',
: 'exhibition_hall',
: 'exhibition_hall',
: 'exhibition_hall',
cinema: 'theater',
theater: 'theater',
: 'theater',
: 'theater',
: 'theater',
ticket_office: 'ticket_office',
ticketing: 'ticket_office',
: 'ticket_office',
: 'ticket_office',
: 'ticket_office',
service_desk: 'service_desk',
service_center: 'service_desk',
information_desk: 'service_desk',
: 'service_desk',
: 'service_desk',
: 'service_desk',
restaurant: 'restaurant',
dining: 'restaurant',
food: 'restaurant',
: 'restaurant',
: 'restaurant',
: 'restaurant',
: 'restaurant',
cafe: 'cafe',
coffee: 'cafe',
: 'cafe',
commercial: 'shop',
shop: 'shop',
store: 'shop',
retail: 'shop',
: 'shop',
: 'shop',
: 'shop',
: 'shop',
cultural_shop: 'cultural_shop',
cultural_creative: 'cultural_shop',
: 'cultural_shop',
: 'cultural_shop',
bookstore: 'bookstore',
: 'bookstore',
vending: 'vending',
: 'vending',
toilet: 'toilet',
restroom: 'toilet',
: 'toilet',
: 'toilet',
: 'toilet',
accessible_toilet: 'accessible_toilet',
: 'accessible_toilet',
elevator: 'elevator',
lift: 'elevator',
: 'elevator',
escalator: 'escalator',
: 'escalator',
: 'escalator',
stairs: 'stairs',
stair: 'stairs',
: 'stairs',
mother_baby_room: 'mother_baby_room',
nursing_room: 'mother_baby_room',
: 'mother_baby_room',
: 'mother_baby_room',
: 'mother_baby_room',
entrance_exit: 'entrance_exit',
entrance: 'entrance_exit',
exit: 'entrance_exit',
door: 'entrance_exit',
: 'entrance_exit',
: 'entrance_exit',
: 'entrance_exit',
hall_entrance: 'hall_entrance',
entrance_anchor: 'entrance_anchor',
: 'entrance_anchor',
route_node: 'route_node',
navigation_node: 'route_node',
线: 'route_node',
navigable_place: 'navigable_place',
operation_experience: 'operation_experience',
guide_point: 'operation_experience',
: 'operation_experience'
}
/** Normalize source-specific English/Chinese type labels before domain matching. */
export const normalizePoiSemanticValue = (value?: string | null) => {
const normalized = normalizeValue(value)
return poiSemanticAliases[normalized] || normalized
}
const genericSourceCategoryValues = new Set([
'poi',
'basic_service_facility',
'transport_circulation',
'accessibility_special_service',
'business_poi',
'operation_experience',
'navigation_anchor',
'服务设施',
'交通流线',
'无障碍服务',
'运营服务',
'导览点位'
].map(normalizePoiSemanticValue))
/** Source grouping is not a visitor-facing search term. */
export const isGenericPoiSourceCategory = (category: Pick<MuseumCategory, 'id'>) => (
genericSourceCategoryValues.has(normalizePoiSemanticValue(category.id))
)
const categorySearchValues = (categories: MuseumCategory[]) => categories.flatMap((category) => {
// A generic source group such as "基础服务设施" describes the data source,
// not a visitor-facing service destination. Keep all of its labels out of
// free-text category matching so a restroom cannot become a service result.
if (isGenericPoiSourceCategory(category)) return []
return [category.id, category.label, category.iconType || '']
.map(normalizePoiSemanticValue)
.filter(Boolean)
})
export const matchesPoiCategory = (
poi: PoiCategorySource,
definition: PoiCategoryDefinition
) => {
const categories = [poi.primaryCategory, ...(poi.categories || [])]
const normalizedCategoryIds = new Set(categories.map((category) => normalizeValue(category.id)))
const normalizedIconTypes = new Set(categories.map((category) => normalizeValue(category.iconType)))
const normalizedCategoryIds = new Set(categories.map((category) => normalizePoiSemanticValue(category.id)))
const normalizedIconTypes = new Set(categories.map((category) => normalizePoiSemanticValue(category.iconType)))
if (definition.excludedCategoryIds?.some((categoryId) => (
normalizedCategoryIds.has(normalizeValue(categoryId))
normalizedCategoryIds.has(normalizePoiSemanticValue(categoryId))
))) {
return false
}
if (definition.categoryIds.some((categoryId) => normalizedCategoryIds.has(normalizeValue(categoryId)))) {
if (definition.categoryIds.some((categoryId) => normalizedCategoryIds.has(normalizePoiSemanticValue(categoryId)))) {
return true
}
if (definition.iconTypes.some((iconType) => normalizedIconTypes.has(normalizeValue(iconType)))) {
if (definition.iconTypes.some((iconType) => normalizedIconTypes.has(normalizePoiSemanticValue(iconType)))) {
return true
}
// A recognized icon/category is a stronger signal than a word embedded in
// the place name. For example, "展厅售票点" is a ticket office, not an
// exhibition hall merely because its name contains "展厅".
const belongsToAnotherExplicitCategory = POI_CATEGORIES.some((candidate) => {
if (candidate.id === definition.id) return false
return candidate.categoryIds.some((categoryId) => (
normalizedCategoryIds.has(normalizePoiSemanticValue(categoryId))
)) || candidate.iconTypes.some((iconType) => (
normalizedIconTypes.has(normalizePoiSemanticValue(iconType))
))
})
if (belongsToAnotherExplicitCategory) return false
const searchableText = [
poi.name,
...categoryValues(categories)
...categorySearchValues(categories)
]
.map(normalizeValue)
.filter(Boolean)
.join(' ')
return definition.keywords.some((keyword) => searchableText.includes(normalizeValue(keyword)))
return definition.keywords.some((keyword) => {
const normalizedKeyword = normalizeValue(keyword)
const semanticKeyword = normalizePoiSemanticValue(keyword)
return searchableText.includes(normalizedKeyword)
|| searchableText.includes(semanticKeyword)
})
}
export const getPoiCategoryById = (categoryId?: string | null) => (
POI_CATEGORIES.find((category) => category.id === categoryId) || null
)
const legacyCategoryAliases: Readonly<Record<string, PoiCategoryId>> = {
souvenir: 'shopping',
exhibition_hall: 'exhibition-hall',
ticket_office: 'ticket-office',
service_center: 'service-center',
nursing_room: 'nursing-room'
}
export const getPoiCategoryById = (categoryId?: string | null) => {
const categoryIdValue = (categoryId || '').trim().toLowerCase()
const resolvedCategoryId = legacyCategoryAliases[normalizeValue(categoryIdValue)] || categoryIdValue
return POI_CATEGORIES.find((category) => category.id === resolvedCategoryId) || null
}
export const findPoiCategoryByKeyword = (keyword: string) => {
const normalizedKeyword = normalizeValue(keyword)
@@ -255,7 +397,89 @@ export const resolvePoiCategory = (poi: PoiCategorySource) => (
POI_CATEGORIES.find((category) => matchesPoiCategory(poi, category)) || null
)
export const isPoiSearchCategorySupported = (poi: PoiCategorySource) => Boolean(resolvePoiCategory(poi))
const hiddenVisitorPrimaryTypes = new Set([
'entrance_exit',
'hall_entrance',
'entrance_anchor',
'route_node',
'navigable_place',
'operation_experience'
])
const primaryTypeValues = (poi: PoiCategorySource) => [
poi.primaryCategory.id,
poi.primaryCategory.label,
poi.primaryCategory.iconType || ''
]
.map(normalizePoiSemanticValue)
.filter(Boolean)
/** A visitor result must never be a guide point, door, entrance anchor, or route node. */
export const isVisitorSearchPoi = (poi: PoiCategorySource) => {
if (poi.kind === 'guide' || poi.kind === 'hall_entrance') return false
return !primaryTypeValues(poi).some((value) => hiddenVisitorPrimaryTypes.has(value))
}
/** Default floor browse only contains canonical halls and valid destination spaces. */
export const isDefaultSearchDestinationPoi = (poi: PoiCategorySource) => {
if (!isVisitorSearchPoi(poi)) return false
if (poi.kind) return poi.kind === 'hall' || poi.kind === 'space'
const primaryCategoryId = normalizePoiSemanticValue(poi.primaryCategory.id)
return primaryCategoryId === 'exhibition_hall'
|| primaryCategoryId === 'touring_poi'
|| primaryCategoryId.startsWith('space_')
}
export const isQuickFindPoi = (
poi: PoiCategorySource,
definition: PoiCategoryDefinition
) => isVisitorSearchPoi(poi) && matchesPoiCategory(poi, definition)
export const isFacilitySearchPoi = (poi: PoiCategorySource) => (
isVisitorSearchPoi(poi) && !isDefaultSearchDestinationPoi(poi)
)
/**
* A strong, source-stable identity for source-level deduplication. Linked space
* IDs are deliberately exposed separately because a facility inside a hall is
* not automatically a duplicate of that hall.
*/
export const getPoiStablePlaceIdentity = (poi: PoiCategorySource) => {
const primaryCategoryId = normalizePoiSemanticValue(poi.primaryCategory.id)
const canUseLinkedSpace = poi.kind === 'hall'
|| poi.kind === 'space'
|| primaryCategoryId === 'business_poi'
|| primaryCategoryId.startsWith('space_')
const sourceSpaceId = canUseLinkedSpace
? poi.sourceSpaceId?.trim() || poi.spaceId?.trim() || poi.hallId?.trim()
: ''
if (sourceSpaceId) return `space:${sourceSpaceId}`
const sourcePlaceId = poi.sourcePlaceId?.trim()
if (sourcePlaceId) return `place:${sourcePlaceId}`
const poiId = poi.id?.trim()
if (poiId) return `poi:${poiId}`
const normalizedName = normalizeValue(poi.name)
return normalizedName ? `name:${poi.floorId || ''}:${normalizedName}` : ''
}
export const getPoiLinkedSpaceIdentity = (poi: PoiCategorySource) => {
const primaryCategoryId = normalizePoiSemanticValue(poi.primaryCategory.id)
const canUseLinkedSpace = poi.kind === 'hall'
|| poi.kind === 'space'
|| primaryCategoryId === 'business_poi'
|| primaryCategoryId.startsWith('space_')
const spaceId = canUseLinkedSpace
? poi.sourceSpaceId?.trim() || poi.spaceId?.trim() || poi.hallId?.trim()
: ''
return spaceId ? `space:${spaceId}` : ''
}
/** Existing consumers use this for generic search eligibility, not quick-find eligibility. */
export const isPoiSearchCategorySupported = (poi: PoiCategorySource) => isVisitorSearchPoi(poi)
export type PoiDataIssueCode =
| 'missing-id'

View File

@@ -2,6 +2,7 @@ import type {
MuseumPoi
} from '@/domain/museum'
import type {
PoiCategoryDefinition,
PoiCategoryId
} from '@/domain/poiCategories'
@@ -26,4 +27,29 @@ export interface PoiSearchSelection {
export interface PoiCategoryResultState extends PoiSearchContext {
active: boolean
pending?: boolean
}
export type GuidePoiSearchMode = 'default' | 'category' | 'keyword'
export interface GuidePoiSearchCategoryState {
definition: PoiCategoryDefinition
count: number
disabled: boolean
}
/**
* Ready-to-render search state for exactly one active floor. The results and
* visible IDs deliberately share the same canonical source so map/list parity
* cannot be reimplemented by a component.
*/
export interface GuidePoiSearchViewState {
mode: GuidePoiSearchMode
floorId: string
floorLabel: string
keyword: string
categoryId: PoiCategoryId | ''
results: MuseumPoi[]
visiblePoiIds: string[]
categories: GuidePoiSearchCategoryState[]
}

View File

@@ -1640,7 +1640,7 @@ const handleHomeSearchResultsChange = (state: PoiCategoryResultState) => {
}
searchVisiblePoiIds.value = [...state.visiblePoiIds]
if (state.floorId) {
if (state.floorId && !state.pending) {
activeGuideFloor.value = state.floorId
}
}

View File

@@ -7,6 +7,9 @@ import type {
import type {
MuseumPoi
} from '@/domain/museum'
import {
isVisitorSearchPoi
} from '@/domain/poiCategories'
import {
getPoiDisplayPolicy
} from '@/domain/poiDisplay'
@@ -31,12 +34,12 @@ import {
} from '@/data/providers/sgsSdkApiProvider'
import {
formatNavFloorLabel,
isStaticIndoorNavigableFloorId
isStaticIndoorNavigableFloorId,
toMuseumPoi
} from '@/data/adapters/navAssetsAdapter'
import {
defaultStaticNavAssetsProvider,
type StaticNavAssetsProvider,
type StaticNavPoiPayload
type StaticNavAssetsProvider
} from '@/data/providers/staticNavAssetsProvider'
import {
mapWithConcurrency
@@ -59,26 +62,6 @@ const getNow = () => (
: Date.now()
)
const toRenderPoi = (poi: StaticNavPoiPayload): GuideRenderPoi => {
const kind = poi.primaryCategory === 'touring_poi' ? 'hall' : 'facility'
return {
id: poi.id,
name: poi.name,
floorId: poi.floorId,
primaryCategory: poi.primaryCategory,
primaryCategoryZh: poi.primaryCategoryZh,
iconType: poi.iconType || poi.primaryCategory,
positionGltf: poi.positionGltf,
sourceObjectName: poi.sourceObjectName,
kind,
hallName: poi.primaryCategory === 'touring_poi' ? poi.name : undefined,
displayPolicy: getPoiDisplayPolicy({
primaryCategory: poi.primaryCategory,
kind
})
}
}
const toGuideRenderPoi = (poi: MuseumPoi): GuideRenderPoi => ({
id: poi.id,
name: poi.name,
@@ -104,10 +87,17 @@ const toGuideRenderPoi = (poi: MuseumPoi): GuideRenderPoi => ({
const toSgsRenderPoi = (poi: ReturnType<typeof toMuseumPoiFromSgs>) => toGuideRenderPoi(poi)
const getRenderPoiDedupeKeys = (poi: GuideRenderPoi) => {
// A facility can be located inside the same spatial area as other facilities.
// That relationship must not collapse distinct visitor markers (for example,
// a restroom and an elevator in one service zone). Space identity is only a
// canonical-place key for halls, spaces, and business-place representations.
const canDedupeBySpace = poi.kind === 'hall'
|| poi.kind === 'space'
|| poi.primaryCategory === 'business_poi'
const stableKeys = [
poi.id ? `id:${poi.id}` : '',
poi.sourceSpaceId ? `space:${poi.floorId}:${poi.sourceSpaceId}` : '',
poi.spaceId ? `space:${poi.floorId}:${poi.spaceId}` : '',
canDedupeBySpace && poi.sourceSpaceId ? `space:${poi.floorId}:${poi.sourceSpaceId}` : '',
canDedupeBySpace && poi.spaceId ? `space:${poi.floorId}:${poi.spaceId}` : '',
poi.sourcePlaceId ? `place:${poi.floorId}:${poi.sourcePlaceId}` : ''
].filter(Boolean)
@@ -303,7 +293,9 @@ export class StaticGuideModelRepository implements GuideModelRepository {
const pois = await this.provider.loadFloorPois(floor.poiDataAsset)
return pois
.map(toRenderPoi)
.map(toMuseumPoi)
.filter(isVisitorSearchPoi)
.map(toGuideRenderPoi)
.filter((poi) => Array.isArray(poi.positionGltf) && poi.positionGltf.length === 3)
}
}
@@ -472,12 +464,19 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository {
loadFloorData('guidePois', () => this.guide.listPois()),
loadFloorData('guideSpacePoints', () => this.guide.listSpacePoints())
])
const ordinaryPois = pois.map((poi) => toSgsRenderPoi(toMuseumPoiFromSgs(poi, manifest.floors)))
const ordinaryPois = pois
.map((poi) => toMuseumPoiFromSgs(poi, manifest.floors))
.filter(isVisitorSearchPoi)
.map(toSgsRenderPoi)
const repositoryBusinessPois = guidePois
.filter((poi) => poi.floorId === resolvedFloorId && poi.primaryCategory.id === 'business_poi')
.filter((poi) => (
poi.floorId === resolvedFloorId
&& poi.primaryCategory.id === 'business_poi'
&& isVisitorSearchPoi(poi)
))
.map(toGuideRenderPoi)
const repositorySpacePois = guideSpacePoints
.filter((poi) => poi.floorId === resolvedFloorId)
.filter((poi) => poi.floorId === resolvedFloorId && isVisitorSearchPoi(poi))
.map(toGuideRenderPoi)
const floorPoiMedianY = getMedianPoiY(ordinaryPois)
const museumHallPois = toMuseumHallPoisFromSgs(spaces, navigablePlaces, manifest.floors, resolvedFloorId, {
@@ -492,7 +491,9 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository {
})
}
const hallPois = museumHallPois.map(toSgsRenderPoi)
const hallPois = museumHallPois
.filter(isVisitorSearchPoi)
.map(toSgsRenderPoi)
const adaptedPois = dedupeRenderPoisById([
...hallPois,
...ordinaryPois,

View File

@@ -17,6 +17,17 @@ import {
isPoiOnIndoorNavigableFloor
} from '@/domain/guideFloor'
import {
POI_CATEGORIES,
getPoiCategoryById,
getPoiLinkedSpaceIdentity,
getPoiStablePlaceIdentity,
isDefaultSearchDestinationPoi,
isFacilitySearchPoi,
isGenericPoiSourceCategory,
isQuickFindPoi,
isVisitorSearchPoi,
resolvePoiCategory,
type PoiCategoryId,
warnPoiCollectionIssues
} from '@/domain/poiCategories'
import {
@@ -46,33 +57,29 @@ import {
toRouteReadinessFromSgsDiagnostics
} from '@/data/adapters/sgsSdkGuideAdapter'
const searchableCategories = new Set([
'touring_poi',
'exhibition_hall',
'exhibition_hall_entrance',
'basic_service_facility',
'transport_circulation',
'accessibility_special_service',
'business_poi',
'operation_experience'
])
const toSearchText = (poi: MuseumPoi) => [
poi.name,
poi.hallName,
poi.floorId,
poi.floorLabel,
poi.primaryCategory.label,
poi.primaryCategory.iconType,
...poi.categories.flatMap((category) => [
category.label,
category.id,
const toSearchText = (poi: MuseumPoi) => {
const resolvedCategory = resolvePoiCategory(poi)
const rawCategoryTerms = [poi.primaryCategory, ...poi.categories].flatMap((category) => [
...(isGenericPoiSourceCategory(category) ? [] : [category.label, category.id]),
category.iconType
])
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return [
poi.name,
poi.hallName,
poi.floorId,
poi.floorLabel,
...rawCategoryTerms,
// Search terms must use the same normalized visitor category as quick find.
// SDK labels such as "卫生间" otherwise miss the visitor-facing "洗手间" alias.
resolvedCategory?.id,
resolvedCategory?.label,
...(resolvedCategory?.keywords || [])
]
.filter(Boolean)
.join(' ')
.toLowerCase()
}
const normalizePoiLookupText = (value: string) => value
.trim()
@@ -117,11 +124,6 @@ const toLocationPreview = (poi: MuseumPoi): GuideLocationPreview => ({
entrances: poi.entrances
})
const isSearchableIndoorPoi = (poi: MuseumPoi) => (
searchableCategories.has(poi.primaryCategory.id)
&& isPoiOnIndoorNavigableFloor(poi)
)
const dedupePoisById = (pois: MuseumPoi[]) => {
const seen = new Set<string>()
return pois.filter((poi) => {
@@ -131,8 +133,6 @@ const dedupePoisById = (pois: MuseumPoi[]) => {
})
}
const getPoiSpaceIdentity = (poi: MuseumPoi) => poi.sourceSpaceId || poi.spaceId || ''
const getPoiSourceLookupIdentity = (id: string) => (
id.trim().replace(/^(?:hall|space)-/, '')
)
@@ -147,44 +147,159 @@ const mergePoiCategories = (...categoryGroups: MuseumCategory[][]) => {
})
}
const mergeSearchablePois = (pois: MuseumPoi[], spacePoints: MuseumPoi[]) => {
const hallPoisBySpaceId = new Map(
pois
.filter((poi) => poi.kind === 'hall' || Boolean(poi.hallId))
.map((poi) => [getPoiSpaceIdentity(poi), poi] as const)
.filter(([spaceId]) => Boolean(spaceId))
)
const spacePointsBySpaceId = new Map(
spacePoints
.map((poi) => [getPoiSpaceIdentity(poi), poi] as const)
.filter(([spaceId]) => Boolean(spaceId))
)
const hasRenderablePosition = (poi: MuseumPoi) => (
Array.isArray(poi.positionGltf)
&& poi.positionGltf.length === 3
&& poi.positionGltf.every((value) => Number.isFinite(value))
)
const mergedPois = pois.map((poi) => {
const spaceId = getPoiSpaceIdentity(poi)
const matchingSpacePoint = spaceId ? spacePointsBySpaceId.get(spaceId) : null
const matchingHallPoi = spaceId ? hallPoisBySpaceId.get(spaceId) : null
if (!matchingSpacePoint || matchingHallPoi?.id !== poi.id) return poi
const isVisitorSearchResult = (poi: MuseumPoi) => (
isVisitorSearchPoi(poi)
&& isPoiOnIndoorNavigableFloor(poi)
&& hasRenderablePosition(poi)
)
return {
...poi,
primaryCategory: matchingSpacePoint.primaryCategory,
categories: mergePoiCategories(
[matchingSpacePoint.primaryCategory],
matchingSpacePoint.categories
)
const normalizePlaceName = (value: string) => value
.trim()
.normalize('NFKC')
.toLowerCase()
.replace(/[\s\-_/()[\]{}]/g, '')
const positionKey = (poi: MuseumPoi) => {
if (!hasRenderablePosition(poi)) return ''
const [x, y, z] = poi.positionGltf!
return [x, y, z].map((value) => Math.round(value * 2) / 2).join(',')
}
const destinationQuickCategoryIds = new Set<PoiCategoryId>([
'exhibition-hall',
'cinema',
'dining',
'shopping'
])
const canonicalCategoryId = (poi: MuseumPoi) => resolvePoiCategory(poi)?.id || ''
const canonicalMergeKeys = (poi: MuseumPoi) => {
const categoryId = canonicalCategoryId(poi)
const keys: string[] = []
const linkedSpace = getPoiLinkedSpaceIdentity(poi)
if (linkedSpace) {
keys.push(`space:${poi.floorId}:${linkedSpace}:${categoryId || 'other'}`)
}
const normalizedName = normalizePlaceName(poi.name)
const location = positionKey(poi)
// A space, regular POI, and business POI can describe the same visitor
// place without sharing a spatial-area ID. Exact normalized name and
// render position are a stronger cross-source identity than source IDs.
if (normalizedName && location) {
keys.push(`fallback:${poi.floorId}:${categoryId || 'other'}:${normalizedName}:${location}`)
}
if (keys.length) return keys
const stableIdentity = getPoiStablePlaceIdentity(poi)
return stableIdentity ? [stableIdentity] : []
}
const canonicalPoiPriority = (poi: MuseumPoi) => {
const categoryId = canonicalCategoryId(poi)
const isDestinationCategory = categoryId
? destinationQuickCategoryIds.has(categoryId)
: isDefaultSearchDestinationPoi(poi)
let priority = hasRenderablePosition(poi) ? 8 : 0
if (isDestinationCategory) {
if (categoryId === 'dining' || categoryId === 'shopping') {
// Keep a renderable spatial representation ahead of a business record;
// source ordering then matches the renderer's canonical marker ID.
if (poi.kind === 'hall' || poi.kind === 'space') priority += 56
if (poi.kind === 'facility') priority += 40
} else if (poi.kind === 'hall' || poi.kind === 'space') {
priority += 56
} else {
priority += 16
}
})
const unmatchedSpacePoints = spacePoints.filter((poi) => {
const spaceId = getPoiSpaceIdentity(poi)
const matchingHallPoi = spaceId ? hallPoisBySpaceId.get(spaceId) : null
return !matchingHallPoi
})
} else if (poi.kind === 'facility') {
priority += 52
} else if (poi.kind === 'hall' || poi.kind === 'space') {
priority += 30
}
return dedupePoisById([
...mergedPois,
...unmatchedSpacePoints
return priority
}
const mergeCanonicalPoi = (left: MuseumPoi, right: MuseumPoi) => {
const primary = canonicalPoiPriority(right) > canonicalPoiPriority(left) ? right : left
const secondary = primary === left ? right : left
return {
...primary,
categories: mergePoiCategories(
[primary.primaryCategory],
primary.categories,
[secondary.primaryCategory],
secondary.categories
),
hallId: primary.hallId || secondary.hallId,
hallName: primary.hallName || secondary.hallName,
spaceId: primary.spaceId || secondary.spaceId,
sourceSpaceId: primary.sourceSpaceId || secondary.sourceSpaceId,
sourcePlaceId: primary.sourcePlaceId || secondary.sourcePlaceId,
entrances: primary.entrances || secondary.entrances
}
}
/**
* Search queries return a single map-renderable representation of each real
* visitor place. Source collections remain intact for the renderer itself.
*/
const mergeCanonicalSearchPois = (pois: MuseumPoi[]) => {
const canonicalByKey = new Map<string, MuseumPoi>()
pois
.filter(isVisitorSearchResult)
.forEach((poi) => {
const keys = canonicalMergeKeys(poi)
if (!keys.length) keys.push(`poi:${poi.id}`)
const existingPois = Array.from(new Set(
keys
.map((key) => canonicalByKey.get(key))
.filter((candidate): candidate is MuseumPoi => Boolean(candidate))
))
const mergedPoi = existingPois.reduce(
(merged, existing) => mergeCanonicalPoi(existing, merged),
poi
)
if (existingPois.length) {
for (const [key, candidate] of canonicalByKey) {
if (existingPois.includes(candidate)) canonicalByKey.set(key, mergedPoi)
}
}
keys.forEach((key) => canonicalByKey.set(key, mergedPoi))
})
return dedupePoisById(Array.from(canonicalByKey.values()))
}
const mergeSearchablePois = (pois: MuseumPoi[], spacePoints: MuseumPoi[]) => (
mergeCanonicalSearchPois([
...pois,
...spacePoints
])
)
const restrictToFloor = (pois: MuseumPoi[], floorId?: string) => (
floorId ? pois.filter((poi) => poi.floorId === floorId) : pois
)
const filterByKeyword = (pois: MuseumPoi[], keyword = '') => {
const normalizedKeyword = keyword.trim().toLowerCase()
return normalizedKeyword
? pois.filter((poi) => toSearchText(poi).includes(normalizedKeyword))
: pois
}
const getMedianPoiY = (pois: MuseumPoi[]) => {
@@ -243,8 +358,11 @@ export interface GuideRepository {
normalizeFloorId(labelOrId: string): string
listPois(): Promise<MuseumPoi[]>
listSpacePoints(): Promise<MuseumPoi[]>
listDestinationPois(floorId?: string): Promise<MuseumPoi[]>
listQuickFindPois(categoryId: PoiCategoryId, floorId?: string): Promise<MuseumPoi[]>
getQuickFindCategoryAvailability(floorId?: string): Promise<GuideQuickFindCategoryAvailability[]>
getPoiById(id: string): Promise<MuseumPoi | null>
searchPois(keyword?: string): Promise<MuseumPoi[]>
searchPois(keyword?: string, floorId?: string): Promise<MuseumPoi[]>
getLocationPreview(poiId: string): Promise<GuideLocationPreview | null>
getRouteReadiness(): Promise<GuideRouteReadiness>
getFloorDetail?(floorId: string): Promise<GuideFloorDetail | null>
@@ -252,8 +370,15 @@ export interface GuideRepository {
checkDataIntegrity?(): Promise<GuideDataIntegrityReport>
}
export interface GuideQuickFindCategoryAvailability {
id: PoiCategoryId
count: number
disabled: boolean
}
export class StaticGuideRepository implements GuideRepository {
private poiCache: MuseumPoi[] | null = null
private visitorSearchPoolCache: MuseumPoi[] | null = null
constructor(private readonly provider: StaticNavAssetsProvider = defaultStaticNavAssetsProvider) {}
@@ -284,7 +409,7 @@ export class StaticGuideRepository implements GuideRepository {
const adaptedPois = pois.map(toMuseumPoi)
warnPoiCollectionIssues('static-nav-assets', adaptedPois)
this.poiCache = dedupePoisById(adaptedPois
.filter(isSearchableIndoorPoi)
.filter(isPoiOnIndoorNavigableFloor)
)
return this.poiCache
@@ -294,18 +419,64 @@ export class StaticGuideRepository implements GuideRepository {
return []
}
private async getVisitorSearchPool() {
if (this.visitorSearchPoolCache) return this.visitorSearchPoolCache
const [pois, spacePoints] = await Promise.all([
this.listPois(),
this.listSpacePoints()
])
this.visitorSearchPoolCache = mergeCanonicalSearchPois([
...pois,
...spacePoints
])
return this.visitorSearchPoolCache
}
async listDestinationPois(floorId?: string) {
const searchPool = await this.getVisitorSearchPool()
// Static nav assets do not expose a standalone space collection. Their
// hall/destination POIs are the source-faithful fallback for default browse.
return restrictToFloor(searchPool.filter(isDefaultSearchDestinationPoi), floorId)
}
async listQuickFindPois(categoryId: PoiCategoryId, floorId?: string) {
const category = getPoiCategoryById(categoryId)
if (!category) return []
const searchPool = await this.getVisitorSearchPool()
return restrictToFloor(searchPool.filter((poi) => {
if (!isQuickFindPoi(poi, category)) return false
return destinationQuickCategoryIds.has(category.id)
|| isFacilitySearchPoi(poi)
}), floorId)
}
async getQuickFindCategoryAvailability(floorId?: string) {
const searchPool = await this.getVisitorSearchPool()
const floorPois = restrictToFloor(searchPool, floorId)
return POI_CATEGORIES.map((category) => {
const count = floorPois.filter((poi) => (
isQuickFindPoi(poi, category)
&& (destinationQuickCategoryIds.has(category.id) || isFacilitySearchPoi(poi))
)).length
return {
id: category.id,
count,
disabled: count === 0
}
})
}
async getPoiById(id: string) {
const pois = await this.listPois()
return pois.find((poi) => poi.id === id) || findPoiByNavIdNameFallback(pois, id)
}
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 searchPois(keyword = '', floorId?: string) {
const searchPool = await this.getVisitorSearchPool()
return filterByKeyword(restrictToFloor(searchPool, floorId), keyword)
}
async getLocationPreview(poiId: string) {
@@ -547,6 +718,71 @@ export class SgsSdkGuideRepository implements GuideRepository {
return result
}
private async getVisitorSearchPool() {
const sourceFailures: unknown[] = []
let pois: MuseumPoi[] = []
let spacePoints: MuseumPoi[] = []
try {
pois = await this.listPois()
} catch (error) {
sourceFailures.push(error)
}
try {
spacePoints = await this.listSpacePoints()
} catch (error) {
sourceFailures.push(error)
}
const searchPool = mergeCanonicalSearchPois([
...pois,
...spacePoints
])
if (sourceFailures.length && !searchPool.length) {
throw new Error(poiDataLoadErrorMessage)
}
if (import.meta.env.DEV && sourceFailures.length) {
console.warn('[SGS 点位数据] 搜索使用部分可用数据', sourceFailures.map(toErrorMessage))
}
return searchPool
}
async listDestinationPois(floorId?: string) {
const searchPool = await this.getVisitorSearchPool()
return restrictToFloor(searchPool.filter(isDefaultSearchDestinationPoi), floorId)
}
async listQuickFindPois(categoryId: PoiCategoryId, floorId?: string) {
const category = getPoiCategoryById(categoryId)
if (!category) return []
const searchPool = await this.getVisitorSearchPool()
return restrictToFloor(searchPool.filter((poi) => {
if (!isQuickFindPoi(poi, category)) return false
return destinationQuickCategoryIds.has(category.id)
|| isFacilitySearchPoi(poi)
}), floorId)
}
async getQuickFindCategoryAvailability(floorId?: string) {
const searchPool = await this.getVisitorSearchPool()
const floorPois = restrictToFloor(searchPool, floorId)
return POI_CATEGORIES.map((category) => {
const count = floorPois.filter((poi) => (
isQuickFindPoi(poi, category)
&& (destinationQuickCategoryIds.has(category.id) || isFacilitySearchPoi(poi))
)).length
return {
id: category.id,
count,
disabled: count === 0
}
})
}
async getPoiById(id: string) {
const normalizedId = id.trim()
if (!normalizedId) return null
@@ -581,7 +817,7 @@ export class SgsSdkGuideRepository implements GuideRepository {
|| findPoiByNavIdNameFallback(pois, id)
}
async searchPois(keyword = '') {
async searchPois(keyword = '', floorId?: string) {
const sourceFailures: unknown[] = []
let pois: MuseumPoi[] = []
let spacePoints: MuseumPoi[] = []
@@ -607,9 +843,7 @@ export class SgsSdkGuideRepository implements GuideRepository {
}
const normalizedKeyword = keyword.trim().toLowerCase()
if (!normalizedKeyword) return searchablePois
return searchablePois.filter((poi) => toSearchText(poi).includes(normalizedKeyword))
return filterByKeyword(restrictToFloor(searchablePois, floorId), normalizedKeyword)
}
async getLocationPreview(poiId: string) {

View File

@@ -23,6 +23,13 @@ import {
applyRouteReadinessGate,
NAV_ROUTE_READINESS
} from '@/domain/guideReadiness'
import {
getPoiCategoryById
} from '@/domain/poiCategories'
import type {
GuidePoiSearchMode,
GuidePoiSearchViewState
} from '@/domain/poiSearch'
const startSourceLabels: Record<string, string> = {
'facility-detail': '设施详情选择',
@@ -98,6 +105,12 @@ export interface GuideContentLocationRequest {
targetName?: string
}
type PoiSearchModeInput = {
mode: GuidePoiSearchMode
keyword?: string
categoryId?: GuidePoiSearchViewState['categoryId']
}
export class GuideUseCase {
private routeReadinessCache: GuideRouteReadiness | null = null
@@ -139,8 +152,115 @@ export class GuideUseCase {
return this.guide.searchPois(keyword)
}
getInitialSearchSpacePoints() {
return this.guide.searchPois()
getInitialSearchSpacePoints(floorId?: string) {
return this.guide.listDestinationPois(floorId)
}
private async resolvePoiSearchFloor(floorId?: string) {
const floors = await this.guide.getFloors()
const requestedFloorId = floorId?.trim() ? this.guide.normalizeFloorId(floorId) : ''
const floor = floors.find((item) => item.id === requestedFloorId)
|| floors.find((item) => item.label === floorId)
|| floors.find((item) => item.label === '1F')
|| floors[0]
return floor || {
id: requestedFloorId || floorId?.trim() || '',
label: floorId?.trim() || ''
}
}
private async loadPoiSearchView(
floorId: string | undefined,
input: PoiSearchModeInput
): Promise<GuidePoiSearchViewState> {
const floor = await this.resolvePoiSearchFloor(floorId)
const keyword = input.keyword?.trim() || ''
const categoryId = input.categoryId || ''
const category = categoryId ? getPoiCategoryById(categoryId) : null
const mode = input.mode === 'category' && !category
? 'default'
: input.mode
const [availability, results] = await Promise.all([
this.guide.getQuickFindCategoryAvailability(floor.id),
mode === 'category' && category
? this.guide.listQuickFindPois(category.id, floor.id)
: mode === 'keyword'
? this.guide.searchPois(keyword, floor.id)
: this.guide.listDestinationPois(floor.id)
])
return {
mode,
floorId: floor.id,
floorLabel: floor.label,
keyword: mode === 'keyword' ? keyword : category?.label || '',
categoryId: mode === 'category' && category ? category.id : '',
results,
visiblePoiIds: results.map((poi) => poi.id),
categories: availability.map((item) => {
const definition = getPoiCategoryById(item.id)
if (!definition) {
throw new Error(`Unknown quick-find category: ${item.id}`)
}
return {
definition,
count: item.count,
disabled: item.disabled
}
})
}
}
createInitialPoiSearchState(floorId?: string) {
return this.loadPoiSearchView(floorId, { mode: 'default' })
}
selectPoiSearchCategory(
state: GuidePoiSearchViewState | null,
categoryId: GuidePoiSearchViewState['categoryId'],
floorId = state?.floorId
) {
return this.loadPoiSearchView(floorId, {
mode: 'category',
categoryId
})
}
searchPoiKeyword(
state: GuidePoiSearchViewState | null,
keyword: string,
floorId = state?.floorId
) {
const normalizedKeyword = keyword.trim()
return this.loadPoiSearchView(floorId, normalizedKeyword
? {
mode: 'keyword',
keyword: normalizedKeyword
}
: {
mode: 'default'
})
}
clearPoiSearch(
state: GuidePoiSearchViewState | null,
floorId = state?.floorId
) {
return this.loadPoiSearchView(floorId, { mode: 'default' })
}
changePoiSearchFloor(
state: GuidePoiSearchViewState | null,
floorId: string
) {
const mode = state?.mode || 'default'
return this.loadPoiSearchView(floorId, {
mode,
keyword: state?.keyword,
categoryId: state?.categoryId
})
}
getPoiById(id: string) {