568 lines
17 KiB
TypeScript
568 lines
17 KiB
TypeScript
import type {
|
|
MuseumCategory,
|
|
MuseumPoi
|
|
} from '@/domain/museum'
|
|
|
|
export type PoiCategoryId =
|
|
| 'exhibition-hall'
|
|
| 'cinema'
|
|
| 'ticket-office'
|
|
| 'dining'
|
|
| 'shopping'
|
|
| 'service-center'
|
|
| 'restroom'
|
|
| 'nursing-room'
|
|
| 'elevator'
|
|
| 'escalator'
|
|
|
|
export interface PoiCategoryDefinition {
|
|
id: PoiCategoryId
|
|
label: string
|
|
icon: string
|
|
order: number
|
|
homeVisible: boolean
|
|
keywords: readonly string[]
|
|
categoryIds: readonly string[]
|
|
iconTypes: readonly string[]
|
|
excludedCategoryIds?: readonly string[]
|
|
}
|
|
|
|
const definitions: PoiCategoryDefinition[] = [
|
|
{
|
|
id: 'exhibition-hall',
|
|
label: '展厅',
|
|
icon: 'exhibition',
|
|
order: 1,
|
|
homeVisible: true,
|
|
keywords: ['展厅', '展览', '展馆', 'exhibition_hall', 'hall'],
|
|
categoryIds: ['exhibition_hall'],
|
|
iconTypes: ['exhibition_hall', 'exhibition']
|
|
},
|
|
{
|
|
id: 'cinema',
|
|
label: '影院',
|
|
icon: 'cinema',
|
|
order: 2,
|
|
homeVisible: true,
|
|
keywords: ['影院', '影厅', '剧场', '报告厅', 'cinema', 'theater', '影院空间'],
|
|
categoryIds: ['space_theater'],
|
|
iconTypes: ['cinema', 'theater']
|
|
},
|
|
{
|
|
id: 'ticket-office',
|
|
label: '售票处',
|
|
icon: 'ticket',
|
|
order: 3,
|
|
homeVisible: true,
|
|
keywords: ['售票处', '售票', '票务', 'ticket_office'],
|
|
categoryIds: [],
|
|
iconTypes: ['ticket_office']
|
|
},
|
|
{
|
|
id: 'dining',
|
|
label: '餐饮',
|
|
icon: 'restaurant',
|
|
order: 4,
|
|
homeVisible: true,
|
|
keywords: ['餐饮', '餐厅', '快餐', '咖啡', 'restaurant', 'dining', 'food', 'cafe'],
|
|
categoryIds: [],
|
|
iconTypes: ['restaurant', 'dining', 'food', 'cafe']
|
|
},
|
|
{
|
|
id: 'shopping',
|
|
label: '购物',
|
|
icon: 'bag',
|
|
order: 5,
|
|
homeVisible: true,
|
|
keywords: ['购物', '商店', '文创', '纪念品', '零售', 'souvenir', 'gift', 'shop', 'shopping', 'store'],
|
|
categoryIds: [],
|
|
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: '母婴',
|
|
icon: 'nursing',
|
|
order: 8,
|
|
homeVisible: true,
|
|
keywords: ['母婴室', '母婴间', 'mother_baby_room', 'nursing_room'],
|
|
categoryIds: [],
|
|
iconTypes: ['mother_baby_room', 'nursing_room']
|
|
},
|
|
{
|
|
id: 'restroom',
|
|
label: '洗手间',
|
|
icon: 'restroom',
|
|
order: 7,
|
|
homeVisible: true,
|
|
keywords: ['卫生间', '洗手间', '厕所', 'toilet', 'accessible_toilet', 'restroom'],
|
|
categoryIds: [],
|
|
iconTypes: ['toilet', 'accessible_toilet', 'restroom', 'restroom_accessible']
|
|
},
|
|
{
|
|
id: 'elevator',
|
|
label: '电梯',
|
|
icon: 'elevator',
|
|
order: 9,
|
|
homeVisible: true,
|
|
keywords: ['电梯', 'elevator'],
|
|
categoryIds: [],
|
|
iconTypes: ['elevator']
|
|
},
|
|
{
|
|
id: 'escalator',
|
|
label: '扶梯',
|
|
icon: 'escalator',
|
|
order: 10,
|
|
homeVisible: true,
|
|
keywords: ['扶梯', 'escalator'],
|
|
categoryIds: [],
|
|
iconTypes: ['escalator']
|
|
}
|
|
]
|
|
|
|
export const POI_CATEGORIES = Object.freeze(
|
|
[...definitions].sort((left, right) => left.order - right.order)
|
|
)
|
|
|
|
const homeCategoryOrder: readonly PoiCategoryId[] = [
|
|
'exhibition-hall',
|
|
'cinema',
|
|
'ticket-office',
|
|
'dining',
|
|
'shopping',
|
|
'service-center',
|
|
'restroom',
|
|
'nursing-room',
|
|
'elevator',
|
|
'escalator'
|
|
]
|
|
|
|
export const HOME_POI_CATEGORIES = Object.freeze(
|
|
homeCategoryOrder.map((categoryId) => (
|
|
POI_CATEGORIES.find((category) => category.id === categoryId)!
|
|
))
|
|
)
|
|
|
|
const POI_CATEGORY_ICON_SYMBOLS: Readonly<Record<PoiCategoryId, string>> = {
|
|
'exhibition-hall': 'poi-exhibition-hall',
|
|
cinema: 'poi-cinema',
|
|
'ticket-office': 'poi-ticket-office',
|
|
dining: 'poi-dining',
|
|
shopping: 'poi-souvenir',
|
|
'service-center': 'poi-service-center',
|
|
restroom: 'poi-restroom',
|
|
'nursing-room': 'poi-nursing-room',
|
|
elevator: 'poi-elevator',
|
|
escalator: 'poi-escalator'
|
|
}
|
|
|
|
export const getPoiCategoryIconHref = (categoryId: PoiCategoryId) => (
|
|
`/static/icons/poi/shortcut-icons.svg#${POI_CATEGORY_ICON_SYMBOLS[categoryId]}`
|
|
)
|
|
|
|
export type PoiCategorySource = Pick<
|
|
MuseumPoi,
|
|
| 'id'
|
|
| 'name'
|
|
| 'floorId'
|
|
| 'primaryCategory'
|
|
| 'categories'
|
|
| 'kind'
|
|
| 'hallId'
|
|
| 'spaceId'
|
|
| 'sourcePlaceId'
|
|
| 'sourceSpaceId'
|
|
| 'sourceObjectName'
|
|
| 'visitorVisible'
|
|
>
|
|
|
|
const normalizeValue = (value?: string | null) => (value || '')
|
|
.trim()
|
|
.normalize('NFKC')
|
|
.toLowerCase()
|
|
.replace(/[\s-]+/g, '_')
|
|
.replace(/^_+|_+$/g, '')
|
|
|
|
const visitorRestrictedPlaceNamePattern = /(?:贵宾|vip|员工|职工|后勤|办公|行政|库房|仓库|机房|设备间|配电|弱电|强电|保洁|值班|消防控制|监控室|staff|employee|back[_ -]?of[_ -]?house|maintenance)/i
|
|
|
|
/** Legacy sources without an explicit flag must not expose staff-only places. */
|
|
export const isVisitorRestrictedPlaceName = (value?: string | null) => (
|
|
visitorRestrictedPlaceNamePattern.test(normalizeValue(value))
|
|
)
|
|
|
|
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) => normalizePoiSemanticValue(category.id)))
|
|
const normalizedIconTypes = new Set(categories.map((category) => normalizePoiSemanticValue(category.iconType)))
|
|
|
|
if (definition.excludedCategoryIds?.some((categoryId) => (
|
|
normalizedCategoryIds.has(normalizePoiSemanticValue(categoryId))
|
|
))) {
|
|
return false
|
|
}
|
|
|
|
if (definition.categoryIds.some((categoryId) => normalizedCategoryIds.has(normalizePoiSemanticValue(categoryId)))) {
|
|
return true
|
|
}
|
|
|
|
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,
|
|
...categorySearchValues(categories)
|
|
]
|
|
.map(normalizeValue)
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
|
|
return definition.keywords.some((keyword) => {
|
|
const normalizedKeyword = normalizeValue(keyword)
|
|
const semanticKeyword = normalizePoiSemanticValue(keyword)
|
|
return searchableText.includes(normalizedKeyword)
|
|
|| searchableText.includes(semanticKeyword)
|
|
})
|
|
}
|
|
|
|
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)
|
|
if (!normalizedKeyword) return null
|
|
|
|
return POI_CATEGORIES.find((category) => (
|
|
normalizeValue(category.label) === normalizedKeyword
|
|
|| category.keywords.some((item) => normalizeValue(item) === normalizedKeyword)
|
|
)) || null
|
|
}
|
|
|
|
export const resolvePoiCategory = (poi: PoiCategorySource) => (
|
|
POI_CATEGORIES.find((category) => matchesPoiCategory(poi, category)) || null
|
|
)
|
|
|
|
const hiddenVisitorTypes = new Set([
|
|
'entrance_exit',
|
|
'hall_entrance',
|
|
'entrance_anchor',
|
|
'route_node',
|
|
'navigable_place',
|
|
'operation_experience'
|
|
])
|
|
|
|
const visitorTypeValues = (poi: PoiCategorySource) => [
|
|
poi.primaryCategory.id,
|
|
poi.primaryCategory.label,
|
|
poi.primaryCategory.iconType || ''
|
|
]
|
|
.map(normalizePoiSemanticValue)
|
|
.filter(Boolean)
|
|
|
|
const isHiddenVisitorType = (value: string) => {
|
|
const normalizedValue = normalizePoiSemanticValue(value)
|
|
const sourceWrappedValue = normalizedValue.replace(/^(?:space|poi|facility|business)_/, '')
|
|
return hiddenVisitorTypes.has(normalizedValue)
|
|
|| hiddenVisitorTypes.has(sourceWrappedValue)
|
|
}
|
|
|
|
/** A visitor result must never be a guide point, door, entrance anchor, or route node. */
|
|
export const isVisitorSearchPoi = (poi: PoiCategorySource) => {
|
|
if (poi.visitorVisible === false) return false
|
|
if (poi.kind === 'guide' || poi.kind === 'hall_entrance') return false
|
|
return !visitorTypeValues(poi).some(isHiddenVisitorType)
|
|
}
|
|
|
|
/** 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'
|
|
| 'missing-name'
|
|
| 'missing-floor'
|
|
| 'missing-position'
|
|
| 'invalid-position'
|
|
| 'unsupported-category'
|
|
|
|
export interface PoiDataIssue {
|
|
code: PoiDataIssueCode
|
|
poiId: string
|
|
poiName: string
|
|
}
|
|
|
|
const hasFinitePosition = (position?: [number, number, number]) => (
|
|
Array.isArray(position)
|
|
&& position.length === 3
|
|
&& position.every((value) => Number.isFinite(value))
|
|
)
|
|
|
|
export const getPoiDataIssues = (poi: MuseumPoi): PoiDataIssue[] => {
|
|
const issues: PoiDataIssue[] = []
|
|
const issueBase = {
|
|
poiId: poi.id || '',
|
|
poiName: poi.name || ''
|
|
}
|
|
|
|
if (!poi.id?.trim()) issues.push({ code: 'missing-id', ...issueBase })
|
|
if (!poi.name?.trim()) issues.push({ code: 'missing-name', ...issueBase })
|
|
if (!poi.floorId?.trim() || !poi.floorLabel?.trim()) issues.push({ code: 'missing-floor', ...issueBase })
|
|
if (!poi.positionGltf) {
|
|
issues.push({ code: 'missing-position', ...issueBase })
|
|
} else if (!hasFinitePosition(poi.positionGltf)) {
|
|
issues.push({ code: 'invalid-position', ...issueBase })
|
|
}
|
|
return issues
|
|
}
|
|
|
|
export interface PoiCollectionInspection {
|
|
issues: PoiDataIssue[]
|
|
duplicateIds: string[]
|
|
}
|
|
|
|
export const inspectPoiCollection = (pois: MuseumPoi[]): PoiCollectionInspection => {
|
|
const seenIds = new Set<string>()
|
|
const duplicateIds = new Set<string>()
|
|
|
|
pois.forEach((poi) => {
|
|
if (!poi.id) return
|
|
if (seenIds.has(poi.id)) duplicateIds.add(poi.id)
|
|
seenIds.add(poi.id)
|
|
})
|
|
|
|
return {
|
|
issues: pois.flatMap(getPoiDataIssues),
|
|
duplicateIds: Array.from(duplicateIds)
|
|
}
|
|
}
|
|
|
|
export const warnPoiCollectionIssues = (source: string, pois: MuseumPoi[]) => {
|
|
if (!import.meta.env.DEV) return
|
|
|
|
const inspection = inspectPoiCollection(pois)
|
|
if (!inspection.issues.length && !inspection.duplicateIds.length) return
|
|
|
|
// This is an aggregate development diagnostic, not a visitor-facing failure.
|
|
console.debug(`[POI 数据诊断] ${source}`, inspection)
|
|
}
|