diff --git a/src/components/map/ThreeMap.vue b/src/components/map/ThreeMap.vue index 47b4c55..fdc0dc8 100644 --- a/src/components/map/ThreeMap.vue +++ b/src/components/map/ThreeMap.vue @@ -65,6 +65,11 @@ import type { GuideRouteFloorSegment, GuideRouteResult } from '@/domain/museum' +import { + getPoiDisplayPolicy, + isPoiVisibilityTierAtLeast, + type PoiVisibilityTier +} from '@/domain/poiDisplay' import { compareFloorsTopToBottom, getFloorSortLevel @@ -341,7 +346,6 @@ interface FloorOption { type RenderPoi = GuideRenderPoi type PoiDisplayMode = 'core' | 'balanced' | 'detail' -type PoiVisibilityTier = 'tight' | 'balanced' | 'full' interface PoiSpriteUserData { poi?: RenderPoi @@ -607,8 +611,6 @@ const poiHitTargetScaleMultiplier = 4.8 const poiHitTargetCoreScaleMultiplier = 5.4 const poiScreenHitRadiusPx = 64 const poiScreenHitRadiusCorePx = 76 -const poiAmbientLabelHallSpacingPx = 92 -const poiAmbientLabelServiceSpacingPx = 72 const modelLoadRetryDelaysMs = [300, 900] const adjacentPreloadDelayMs = 900 const manualAutoSwitchPauseMs = 1500 @@ -844,29 +846,6 @@ const syncControlInteractionOptions = (event?: PointerEvent) => { controls.touches.TWO = THREE.TOUCH.DOLLY_ROTATE } -const corePoiCategories = new Set([ - 'poi', - 'touring_poi', - 'exhibition_hall', - 'exhibition_hall_entrance', - 'basic_service_facility', - 'transport_circulation', - 'accessibility_special_service' -]) - -const poiCategoryPriority: Record = { - target_preview: 100, - exhibition_hall: 96, - exhibition_hall_entrance: 92, - touring_poi: 90, - poi: 86, - accessibility_special_service: 88, - basic_service_facility: 82, - business_poi: 80, - transport_circulation: 76, - operation_experience: 46 -} - const isHallPoi = (poi: RenderPoi) => ( poi.kind === 'hall' || poi.primaryCategory === 'exhibition_hall' @@ -881,6 +860,13 @@ const isServiceFacilityPoi = (poi: RenderPoi) => ( const isTransportPoi = (poi: RenderPoi) => poi.primaryCategory === 'transport_circulation' +const getPoiPolicy = (poi: RenderPoi) => ( + poi.displayPolicy || getPoiDisplayPolicy({ + primaryCategory: poi.primaryCategory, + kind: poi.kind + }) +) + const poiGlyphMap: Record = { poi: '点', accessible_restroom: '无', @@ -1263,15 +1249,11 @@ const getPoiGlyph = (poi: RenderPoi) => ( const shouldShowPoiInCurrentMode = (poi: RenderPoi) => { const mode = getPoiDisplayMode() + const policy = getPoiPolicy(poi) - if (mode === 'core') { - return corePoiCategories.has(poi.primaryCategory) - } - - if (mode === 'balanced') { - return corePoiCategories.has(poi.primaryCategory) || poi.primaryCategory === 'touring_poi' - } - + if (!policy.markerVisible) return false + if (mode === 'core') return policy.allowOverview + if (mode === 'balanced') return policy.allowMulti return true } @@ -1314,22 +1296,9 @@ const shouldShowPoiAtDistance = (poi: RenderPoi, tier: PoiVisibilityTier) => { return true } - if (activeView.value === 'floor' && isHallPoi(poi)) { - return true - } - - if (tier === 'tight') { - return corePoiCategories.has(poi.primaryCategory) && !isTransportPoi(poi) - } - - if (tier === 'balanced') { - return !isTransportPoi(poi) && ( - corePoiCategories.has(poi.primaryCategory) - || poi.primaryCategory === 'touring_poi' - ) - } - - return true + const policy = getPoiPolicy(poi) + return policy.markerVisible + && isPoiVisibilityTierAtLeast(tier, policy.minMarkerTier) } const getPoiVisibilityLimit = (tier: PoiVisibilityTier) => { @@ -1345,7 +1314,7 @@ const getPoiScreenSpacing = (tier: PoiVisibilityTier) => { } const getPoiPriority = (poi: RenderPoi) => { - const categoryPriority = poiCategoryPriority[poi.primaryCategory] || 20 + const categoryPriority = getPoiPolicy(poi).priority const selectedBoost = poi.id === activeFocusPoiId.value ? 1000 : 0 const hallBoost = activeView.value === 'floor' && isHallPoi(poi) ? 40 : 0 const transportPenalty = isTransportPoi(poi) ? 18 : 0 @@ -1364,8 +1333,7 @@ const getPoiLabelDensityTier = (): PoiVisibilityTier => { } const shouldCreateAmbientPoiLabel = (poi: RenderPoi) => ( - isHallPoi(poi) - || isServiceFacilityPoi(poi) + getPoiPolicy(poi).labelVisible ) const shouldShowAmbientPoiLabel = ( @@ -1375,11 +1343,9 @@ const shouldShowAmbientPoiLabel = ( ) => { if (!markerVisible || activeView.value !== 'floor') return false if (poi.id === activeFocusPoiId.value || poi.primaryCategory === 'target_preview') return false - if (isHallPoi(poi)) return true - if (isTransportPoi(poi)) return false - if (isServiceFacilityPoi(poi)) return densityTier === 'full' - - return false + const policy = getPoiPolicy(poi) + return policy.labelVisible + && isPoiVisibilityTierAtLeast(densityTier, policy.minLabelTier) } const getPoiAmbientLabelPriority = (poi: RenderPoi) => { @@ -1490,17 +1456,15 @@ const updateAmbientPoiLabels = () => { return } - const spacing = isHallPoi(poi) ? poiAmbientLabelHallSpacingPx : poiAmbientLabelServiceSpacingPx + const spacing = getPoiPolicy(poi).labelCollisionSpacing const bounds = updatePoiDomLabelPosition(labelHandle) - if (!bounds) { - return - } + if (!bounds) return - const overlaps = acceptedBounds.some((acceptedBound) => domLabelBoundsOverlap(bounds, acceptedBound, spacing)) + const overlaps = acceptedBounds.some((acceptedBound) => ( + domLabelBoundsOverlap(bounds, acceptedBound, spacing) + )) setPoiDomLabelVisible(labelHandle, !overlaps) - if (!overlaps) { - acceptedBounds.push(bounds) - } + if (!overlaps) acceptedBounds.push(bounds) }) } @@ -4802,7 +4766,7 @@ const findLoadedPoi = (poiId: string) => ( const createPoiMarkerSprites = (poi: RenderPoi, markerSize: number) => { const sprite = new THREE.Sprite(createPoiMaterial(poi)) const hitTarget = new THREE.Sprite(createPoiHitTargetMaterial()) - const isCorePoi = corePoiCategories.has(poi.primaryCategory) || poi.primaryCategory === 'target_preview' + const isCorePoi = getPoiPolicy(poi).allowOverview || poi.primaryCategory === 'target_preview' const hitTargetScale = markerSize * (isCorePoi ? poiHitTargetCoreScaleMultiplier : poiHitTargetScaleMultiplier) sprite.userData.baseScale = markerSize diff --git a/src/domain/guideModel.ts b/src/domain/guideModel.ts index ee31840..30f3d9a 100644 --- a/src/domain/guideModel.ts +++ b/src/domain/guideModel.ts @@ -2,6 +2,7 @@ import type { MuseumPoiEntrance, MuseumPoiKind } from '@/domain/museum' +import type { PoiDisplayPolicy } from '@/domain/poiDisplay' export interface GuideRenderPoi { id: string @@ -19,6 +20,7 @@ export interface GuideRenderPoi { sourcePlaceId?: string sourceSpaceId?: string entrances?: MuseumPoiEntrance[] + displayPolicy?: PoiDisplayPolicy } export interface GuideModelFloorAsset { diff --git a/src/domain/museum.ts b/src/domain/museum.ts index 6faf651..2dc74c4 100644 --- a/src/domain/museum.ts +++ b/src/domain/museum.ts @@ -104,6 +104,7 @@ export interface MuseumPoi { sourcePlaceId?: string sourceSpaceId?: string entrances?: MuseumPoiEntrance[] + displayPolicy?: import('@/domain/poiDisplay').PoiDisplayPolicy } export type GuideLocationResolutionStatus = 'exact' | 'hallFallback' | 'candidate' | 'unavailable' diff --git a/src/domain/poiDisplay.ts b/src/domain/poiDisplay.ts new file mode 100644 index 0000000..f138667 --- /dev/null +++ b/src/domain/poiDisplay.ts @@ -0,0 +1,125 @@ +import type { MuseumPoiKind } from '@/domain/museum' + +export type PoiVisibilityTier = 'tight' | 'balanced' | 'full' + +export interface PoiDisplayPolicy { + markerVisible: boolean + labelVisible: boolean + minMarkerTier: PoiVisibilityTier + minLabelTier: PoiVisibilityTier + allowOverview: boolean + allowMulti: boolean + priority: number + labelCollisionSpacing: number +} + +export interface PoiDisplayPolicyInput { + primaryCategory?: string + kind?: MuseumPoiKind +} + +const tierRank: Record = { + tight: 0, + balanced: 1, + full: 2 +} + +export const isPoiVisibilityTierAtLeast = ( + tier: PoiVisibilityTier, + minimum: PoiVisibilityTier +) => tierRank[tier] >= tierRank[minimum] + +export const getPoiDisplayPolicy = ({ + primaryCategory = '', + kind +}: PoiDisplayPolicyInput): PoiDisplayPolicy => { + const category = primaryCategory.trim().toLowerCase() + const isTarget = category === 'target_preview' + const isHall = kind === 'hall' + || category === 'exhibition_hall' + || category === 'exhibition_hall_entrance' + || category === 'touring_poi' + const isSpace = kind === 'space' || category.startsWith('space_') + const isService = category === 'basic_service_facility' + || category === 'accessibility_special_service' + || category === 'business_poi' + const isTransport = category === 'transport_circulation' + + if (isTarget) { + return { + markerVisible: true, + labelVisible: true, + minMarkerTier: 'tight', + minLabelTier: 'tight', + allowOverview: true, + allowMulti: true, + priority: 1000, + labelCollisionSpacing: 16 + } + } + + if (isHall) { + return { + markerVisible: true, + labelVisible: true, + minMarkerTier: 'tight', + // 原始 floor 视图中展厅 marker 可见即显示标签,不受更近一档缩放限制。 + minLabelTier: 'tight', + allowOverview: true, + allowMulti: true, + priority: 120, + labelCollisionSpacing: 64 + } + } + + if (isSpace) { + return { + markerVisible: true, + labelVisible: false, + minMarkerTier: 'tight', + // 普通空间保留 marker,但不参与展厅/设施环境标签层。 + minLabelTier: 'full', + allowOverview: true, + allowMulti: true, + priority: 50, + labelCollisionSpacing: 20 + } + } + + if (isService) { + return { + markerVisible: true, + labelVisible: true, + minMarkerTier: 'tight', + minLabelTier: 'full', + allowOverview: true, + allowMulti: true, + priority: 80, + labelCollisionSpacing: 48 + } + } + + if (isTransport) { + return { + markerVisible: true, + labelVisible: false, + minMarkerTier: 'balanced', + minLabelTier: 'full', + allowOverview: true, + allowMulti: true, + priority: 40, + labelCollisionSpacing: 40 + } + } + + return { + markerVisible: true, + labelVisible: false, + minMarkerTier: 'tight', + minLabelTier: 'full', + allowOverview: true, + allowMulti: true, + priority: 20, + labelCollisionSpacing: 32 + } +} diff --git a/src/repositories/GuideModelRepository.ts b/src/repositories/GuideModelRepository.ts index 1050468..ea53d8b 100644 --- a/src/repositories/GuideModelRepository.ts +++ b/src/repositories/GuideModelRepository.ts @@ -7,6 +7,9 @@ import type { import type { MuseumPoi } from '@/domain/museum' +import { + getPoiDisplayPolicy +} from '@/domain/poiDisplay' import { dataSourceConfig } from '@/config/dataSource' @@ -56,18 +59,25 @@ const getNow = () => ( : Date.now() ) -const toRenderPoi = (poi: StaticNavPoiPayload): GuideRenderPoi => ({ - id: poi.id, - name: poi.name, - floorId: poi.floorId, - primaryCategory: poi.primaryCategory, - primaryCategoryZh: poi.primaryCategoryZh, - iconType: poi.iconType || poi.primaryCategory, - positionGltf: poi.positionGltf, - sourceObjectName: poi.sourceObjectName, - kind: poi.primaryCategory === 'touring_poi' ? 'hall' : 'facility', - hallName: poi.primaryCategory === 'touring_poi' ? poi.name : undefined -}) +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, @@ -84,7 +94,11 @@ const toGuideRenderPoi = (poi: MuseumPoi): GuideRenderPoi => ({ spaceId: poi.spaceId, sourcePlaceId: poi.sourcePlaceId, sourceSpaceId: poi.sourceSpaceId, - entrances: poi.entrances + entrances: poi.entrances, + displayPolicy: poi.displayPolicy || getPoiDisplayPolicy({ + primaryCategory: poi.primaryCategory.id, + kind: poi.kind + }) }) const toSgsRenderPoi = (poi: ReturnType) => toGuideRenderPoi(poi) diff --git a/tests/unit/PoiDisplayPolicy.spec.ts b/tests/unit/PoiDisplayPolicy.spec.ts new file mode 100644 index 0000000..3a367e8 --- /dev/null +++ b/tests/unit/PoiDisplayPolicy.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { + getPoiDisplayPolicy, + isPoiVisibilityTierAtLeast +} from '@/domain/poiDisplay' + +describe('POI 显示策略', () => { + it('空间点位初始总览保留 marker,但不进入环境标签层', () => { + const policy = getPoiDisplayPolicy({ + primaryCategory: 'space_public_area', + kind: 'space' + }) + + expect(policy.labelVisible).toBe(false) + expect(policy.minMarkerTier).toBe('tight') + expect(policy.allowOverview).toBe(true) + expect(policy.allowMulti).toBe(true) + expect(policy.minLabelTier).toBe('full') + }) + + it('展厅和服务设施拥有不同优先级与标签密度策略', () => { + const hall = getPoiDisplayPolicy({ primaryCategory: 'exhibition_hall', kind: 'hall' }) + const service = getPoiDisplayPolicy({ primaryCategory: 'business_poi', kind: 'facility' }) + + expect(hall.labelVisible).toBe(true) + expect(hall.minLabelTier).toBe('tight') + expect(hall.priority).toBeGreaterThan(service.priority) + expect(service.minLabelTier).toBe('full') + }) + + it('LOD tier 按 tight 到 full 单调放宽', () => { + expect(isPoiVisibilityTierAtLeast('tight', 'tight')).toBe(true) + expect(isPoiVisibilityTierAtLeast('balanced', 'tight')).toBe(true) + expect(isPoiVisibilityTierAtLeast('balanced', 'full')).toBe(false) + expect(isPoiVisibilityTierAtLeast('full', 'balanced')).toBe(true) + }) +})