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

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

@@ -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
}
}