1540 lines
56 KiB
Vue
1540 lines
56 KiB
Vue
<template>
|
|
<view
|
|
class="weak-network-map"
|
|
data-testid="weak-network-map"
|
|
:data-viewport-scene="localViewport?.scene || ''"
|
|
:data-viewport-floor-id="localViewport?.floorId || ''"
|
|
:data-viewport-center-x="localViewport?.centerX ?? ''"
|
|
:data-viewport-center-z="localViewport?.centerZ ?? ''"
|
|
:data-viewport-visible-world-span="localViewport?.visibleWorldSpan ?? ''"
|
|
:data-viewport-revision="localViewport?.revision ?? ''"
|
|
:data-visibility-tier="poiVisibilityTier"
|
|
:data-reference-visible-world-span="referenceVisibleWorldSpan ?? ''"
|
|
:data-active-basemap-floor-code="activeBasemap?.floorCode || ''"
|
|
:data-active-basemap-role="activeCompositeBasemap?.role || 'FLOOR'"
|
|
:data-active-basemap-coverage="activeCompositeBasemap?.coverageFloorIds.join(',') || floorId"
|
|
>
|
|
<view v-if="showChrome" class="weak-network-status">
|
|
<text>{{ presentation === 'two-dimensional' ? '已切换为二维导览' : '网络较弱,已切换为简图导览' }}</text>
|
|
<view class="weak-network-retry" data-testid="weak-network-retry" @tap="emit('retry')">
|
|
<text>{{ presentation === 'two-dimensional' ? '切换三维' : '重试三维' }}</text>
|
|
</view>
|
|
</view>
|
|
|
|
<view v-if="showChrome && sceneView === 'floor' && !activeCompositeBasemap" class="weak-network-floor-name" data-testid="weak-network-floor">
|
|
<text>{{ floorLabel || '馆内简图' }}</text>
|
|
</view>
|
|
|
|
<scroll-view v-if="showChrome && sceneView === 'floor' && !activeCompositeBasemap" class="weak-network-floor-rail" scroll-x>
|
|
<view class="weak-network-floor-rail-inner">
|
|
<view
|
|
v-for="floor in floors"
|
|
:key="floor.floorId"
|
|
class="weak-network-floor-option"
|
|
:class="{ active: String(floor.floorId) === String(floorId) }"
|
|
data-testid="weak-network-floor-option"
|
|
@tap.stop="emit('floorChange', floor.floorId)"
|
|
>
|
|
<text>{{ floor.label }}</text>
|
|
</view>
|
|
</view>
|
|
</scroll-view>
|
|
|
|
<view
|
|
ref="surfaceRef"
|
|
class="weak-network-surface"
|
|
:class="{ 'has-basemap': Boolean(activeBasemap) }"
|
|
data-testid="weak-network-surface"
|
|
@pointerdown="handleSurfacePointerDown"
|
|
@pointermove="handleSurfacePointerMove"
|
|
@pointerup="handleSurfacePointerEnd"
|
|
@pointercancel="handleSurfacePointerEnd"
|
|
@mousedown="handleSurfaceMouseDown"
|
|
@mousemove="handleSurfaceMouseMove"
|
|
@mouseup="handleSurfaceMouseUp"
|
|
@mouseleave="handleSurfaceMouseUp"
|
|
@touchstart="handleSurfaceTouchStart"
|
|
@touchmove="handleSurfaceTouchMove"
|
|
@touchend="handleSurfaceTouchEnd"
|
|
@touchcancel="handleSurfaceTouchEnd"
|
|
@tap="handleSurfaceTap"
|
|
>
|
|
<view class="weak-network-content">
|
|
<image
|
|
v-if="activeBasemap"
|
|
class="weak-network-basemap"
|
|
:src="activeBasemap.image.path"
|
|
:style="basemapStyle"
|
|
mode="scaleToFill"
|
|
data-testid="weak-network-basemap"
|
|
/>
|
|
<view
|
|
v-else
|
|
class="weak-network-neutral-grid"
|
|
:style="basemapStyle"
|
|
data-testid="weak-network-neutral-grid"
|
|
></view>
|
|
<view
|
|
v-for="line in routeLines"
|
|
:key="line.key"
|
|
class="weak-network-route-line"
|
|
:style="line.style"
|
|
:data-route-floor-ids="line.floorIds"
|
|
:data-route-start-x="line.start.x"
|
|
:data-route-start-y="line.start.y"
|
|
:data-route-end-x="line.end.x"
|
|
:data-route-end-y="line.end.y"
|
|
></view>
|
|
<view
|
|
class="weak-network-route-progress"
|
|
:class="{ 'is-hidden': !routeNavigationActive || !routeProgressPoint }"
|
|
:style="getRouteProgressStyle(routeProgressPoint)"
|
|
data-testid="weak-network-route-progress"
|
|
>
|
|
<view class="weak-network-route-progress-arrow"></view>
|
|
</view>
|
|
<view
|
|
v-for="marker in markers"
|
|
:key="marker.poi.id"
|
|
class="weak-network-marker"
|
|
:class="{ 'weak-network-marker--selected': marker.poi.id === selectedPoiId }"
|
|
:style="marker.style"
|
|
:data-poi-id="marker.poi.id"
|
|
:data-floor-id="marker.poi.floorId"
|
|
:data-label="marker.label"
|
|
:data-poi-icon="marker.iconKey"
|
|
@pointerdown.stop
|
|
@mousedown.stop
|
|
@touchstart.stop
|
|
@tap.stop="emit('poiClick', marker.poi)"
|
|
>
|
|
<view class="weak-network-marker-anchor">
|
|
<svg class="weak-network-marker-anchor-icon" viewBox="0 0 32 32" aria-hidden="true">
|
|
<use :href="marker.iconHref" :xlink:href="marker.iconHref"></use>
|
|
</svg>
|
|
</view>
|
|
<view
|
|
v-if="marker.showLabel"
|
|
class="weak-network-marker-label"
|
|
:data-testid="`weak-network-marker-label-${marker.poi.id}`"
|
|
>
|
|
<svg class="weak-network-marker-label-icon" viewBox="0 0 32 32" aria-hidden="true">
|
|
<use :href="marker.iconHref" :xlink:href="marker.iconHref"></use>
|
|
</svg>
|
|
<text class="weak-network-marker-label-title">{{ marker.label }}</text>
|
|
</view>
|
|
</view>
|
|
<view v-if="sceneView === 'floor' && !markers.length" class="weak-network-empty">
|
|
<text>该楼层暂无可显示地点</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
import type { GuideRenderPoi } from '@/domain/guideModel'
|
|
import type { GuideRouteResult } from '@/domain/museum'
|
|
import type {
|
|
RouteStartCandidatePayload,
|
|
RouteStartSelectablePoint
|
|
} from './routeStartCandidateResolver'
|
|
import {
|
|
getAmbientFacilityKind,
|
|
getAmbientFacilityLimit,
|
|
getAmbientFacilityTier,
|
|
getPoiDisplayPolicy,
|
|
getPoiMapLabelText,
|
|
getPoiMarkerLimit,
|
|
getPoiScreenSpacing,
|
|
getPoiVisibilityTier,
|
|
isPoiVisibilityTierAtLeast,
|
|
type AmbientFacilityKind,
|
|
type PoiDisplayPolicy
|
|
} from '@/domain/poiDisplay'
|
|
import {
|
|
ensurePoiIconSprite,
|
|
getPoiIconHref,
|
|
resolvePoiIconKey,
|
|
type PoiIconKey
|
|
} from '@/domain/poiCategories'
|
|
import {
|
|
getOverviewMapLabelDefinition,
|
|
OVERVIEW_MAP_LABEL_DEFINITIONS
|
|
} from '@/domain/overviewMapLabels'
|
|
import { presentVisitorPoi } from '@/view-models/visitorPoiPresentation'
|
|
import {
|
|
findGuideCompositeBasemap,
|
|
findGuideFloorBasemap,
|
|
getGuideFloorBasemapWorldBounds,
|
|
isValidGuideFloorBasemapInitialViewport,
|
|
loadGuideFloorBasemapManifest,
|
|
type GuideFloorBasemapManifest
|
|
} from '@/services/model/GuideFloorBasemap'
|
|
import {
|
|
clampGuideViewport,
|
|
createGuideViewport,
|
|
getGuideViewportKey,
|
|
isGuideAutoSwitchVisibleSpanThresholdReached,
|
|
panGuideViewport,
|
|
projectGuideWorldPoint,
|
|
setGuideViewportSpanAt,
|
|
zoomGuideViewport,
|
|
type GuideViewportScreenPoint,
|
|
type GuideViewportState,
|
|
type GuideWorldBounds
|
|
} from '@/domain/guideViewport'
|
|
|
|
interface FlatPoint {
|
|
x: number
|
|
y?: number
|
|
z: number
|
|
}
|
|
|
|
interface ProjectedPoint {
|
|
x: number
|
|
y: number
|
|
}
|
|
|
|
interface WeakMapLabelBounds {
|
|
left: number
|
|
right: number
|
|
top: number
|
|
bottom: number
|
|
}
|
|
|
|
interface WeakMapPoiCandidate {
|
|
poi: GuideRenderPoi
|
|
sourceIndex: number
|
|
required: boolean
|
|
isSearchResult: boolean
|
|
isOverviewLabel: boolean
|
|
policy: PoiDisplayPolicy
|
|
priority: number
|
|
label: string
|
|
}
|
|
|
|
interface WeakMapMarker {
|
|
poi: GuideRenderPoi
|
|
label: string
|
|
showLabel: boolean
|
|
iconKey: PoiIconKey
|
|
iconHref: string
|
|
style: Record<string, string>
|
|
}
|
|
|
|
interface WeakMapRouteSegment {
|
|
floorId: string
|
|
sourceFloorIds: string[]
|
|
points: FlatPoint[]
|
|
distance: number
|
|
}
|
|
|
|
interface WeakMapAmbientFacilityCounts {
|
|
total: number
|
|
balanced: number
|
|
nearOnly: number
|
|
byKind: Map<AmbientFacilityKind, number>
|
|
}
|
|
|
|
type FallbackPresentation = 'weak-network' | 'two-dimensional'
|
|
type SceneView = 'overview' | 'floor'
|
|
|
|
const MAX_WEAK_MAP_MARKERS = 72
|
|
const DEFAULT_VIEWPORT_WIDTH = 390
|
|
const DEFAULT_VIEWPORT_HEIGHT = 844
|
|
// Exterior WebP maps have a deliberately compact zoom-out range. Reserve the
|
|
// final step for the semantic handoff so one accidental zoom-out does not
|
|
// immediately replace the indoor exterior with Tencent Map.
|
|
const OUTDOOR_MAP_EXIT_VISIBLE_SPAN_RATIO = 0.975
|
|
const ROUTE_START_SNAP_RADIUS_PX = 44
|
|
const ROUTE_NAVIGATION_ENTRY_MS = 460
|
|
const MIN_ROUTE_SEGMENT_DURATION_MS = 6000
|
|
const MAX_ROUTE_SEGMENT_DURATION_MS = 18000
|
|
const ROUTE_DURATION_PER_METER_MS = 115
|
|
|
|
const createWeakMapAmbientFacilityCounts = (): WeakMapAmbientFacilityCounts => ({
|
|
total: 0,
|
|
balanced: 0,
|
|
nearOnly: 0,
|
|
byKind: new Map()
|
|
})
|
|
|
|
const props = withDefaults(defineProps<{
|
|
floorId: string
|
|
floorLabel?: string
|
|
floors?: Array<{ floorId: string; label: string }>
|
|
pois: GuideRenderPoi[]
|
|
routePreview?: GuideRouteResult | null
|
|
visiblePoiIds?: string[] | null
|
|
selectedPoiId?: string
|
|
selectedPoi?: GuideRenderPoi | null
|
|
floorModelVersion?: string
|
|
basemapManifest?: GuideFloorBasemapManifest | null
|
|
presentation?: FallbackPresentation
|
|
showChrome?: boolean
|
|
routeNavigationActive?: boolean
|
|
routeStartSelectionActive?: boolean
|
|
routeSelectablePoints?: RouteStartSelectablePoint[]
|
|
sceneView?: SceneView
|
|
sceneViewport?: GuideViewportState | null
|
|
}>(), {
|
|
floorLabel: '',
|
|
floors: () => [],
|
|
routePreview: null,
|
|
visiblePoiIds: null,
|
|
selectedPoiId: '',
|
|
selectedPoi: null,
|
|
floorModelVersion: '',
|
|
basemapManifest: null,
|
|
presentation: 'weak-network',
|
|
showChrome: true,
|
|
routeNavigationActive: false,
|
|
routeStartSelectionActive: false,
|
|
routeSelectablePoints: () => [],
|
|
sceneView: 'floor',
|
|
sceneViewport: null
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
retry: []
|
|
poiClick: [poi: GuideRenderPoi]
|
|
floorChange: [floorId: string]
|
|
routeStartCandidate: [candidate: RouteStartCandidatePayload]
|
|
routeStartCandidateRejected: []
|
|
routeRoamingProgress: [event: { remainingMeters: number; progress: number }]
|
|
routeRoamingTransfer: [event: {
|
|
fromFloorId: string
|
|
toFloorId: string
|
|
transferType: string
|
|
connectorName?: string
|
|
}]
|
|
zoomIntent: [event: {
|
|
direction: 'in' | 'out'
|
|
source: 'button' | 'gesture'
|
|
boundaryAttempt: boolean
|
|
referenceVisibleWorldSpan: number | null
|
|
thresholdReached: boolean
|
|
outdoorExitReached: boolean
|
|
viewport: GuideViewportState
|
|
}]
|
|
viewportChange: [event: {
|
|
viewport: GuideViewportState
|
|
source: 'initialize' | 'interaction'
|
|
}]
|
|
}>()
|
|
|
|
const surfaceRef = ref<unknown>(null)
|
|
const surfaceSize = ref({ width: DEFAULT_VIEWPORT_WIDTH, height: DEFAULT_VIEWPORT_HEIGHT })
|
|
const loadedBasemapManifest = ref<GuideFloorBasemapManifest | null>(null)
|
|
const localViewport = ref<GuideViewportState | null>(null)
|
|
const pointerStart = ref<{
|
|
id: string
|
|
x: number
|
|
y: number
|
|
viewport: GuideViewportState
|
|
} | null>(null)
|
|
const pinchStart = ref<{
|
|
distance: number
|
|
viewport: GuideViewportState
|
|
anchor: GuideViewportScreenPoint
|
|
} | null>(null)
|
|
let surfaceResizeObserver: ResizeObserver | null = null
|
|
let routeNavigationFrame: number | null = null
|
|
let routeNavigationRouteId = ''
|
|
let routeNavigationLastRemainingMeters: number | null = null
|
|
const routeNavigationSegmentIndex = ref(0)
|
|
const routeNavigationCompletedDistance = ref(0)
|
|
const routeNavigationStartedAt = ref(0)
|
|
const routeProgressPoint = ref<FlatPoint | null>(null)
|
|
|
|
const getSurfaceElement = () => {
|
|
const surface = surfaceRef.value
|
|
if (!surface || typeof HTMLElement === 'undefined') return null
|
|
if (surface instanceof HTMLElement) return surface
|
|
if (typeof surface === 'object' && '$el' in surface) {
|
|
return surface.$el instanceof HTMLElement ? surface.$el : null
|
|
}
|
|
return null
|
|
}
|
|
|
|
const updateSurfaceSize = () => {
|
|
const element = getSurfaceElement()
|
|
if (!element) return
|
|
surfaceSize.value = {
|
|
width: Math.max(element.clientWidth || DEFAULT_VIEWPORT_WIDTH, 1),
|
|
height: Math.max(element.clientHeight || DEFAULT_VIEWPORT_HEIGHT, 1)
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
ensurePoiIconSprite()
|
|
updateSurfaceSize()
|
|
const surfaceElement = getSurfaceElement()
|
|
if (typeof ResizeObserver !== 'undefined' && surfaceElement) {
|
|
surfaceResizeObserver = new ResizeObserver(updateSurfaceSize)
|
|
surfaceResizeObserver.observe(surfaceElement)
|
|
}
|
|
surfaceElement?.addEventListener('wheel', handleSurfaceWheel, { passive: false })
|
|
void loadGuideFloorBasemapManifest().then((manifest) => {
|
|
loadedBasemapManifest.value = manifest
|
|
})
|
|
startRouteNavigationForCurrentFloor()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
surfaceResizeObserver?.disconnect()
|
|
surfaceResizeObserver = null
|
|
getSurfaceElement()?.removeEventListener('wheel', handleSurfaceWheel)
|
|
stopRouteNavigation()
|
|
})
|
|
|
|
const basemapManifest = computed(() => props.basemapManifest || loadedBasemapManifest.value)
|
|
const routeCoverageFloorReferences = computed(() => Array.from(new Set(
|
|
(props.routePreview?.floorSegments || [])
|
|
.map((segment) => String(segment.floorId || '').trim())
|
|
.filter(Boolean)
|
|
)))
|
|
const activeCompositeBasemap = computed(() => findGuideCompositeBasemap(
|
|
basemapManifest.value,
|
|
routeCoverageFloorReferences.value
|
|
))
|
|
const activeBasemap = computed(() => activeCompositeBasemap.value || findGuideFloorBasemap(
|
|
basemapManifest.value,
|
|
props.floorId,
|
|
props.floorModelVersion
|
|
))
|
|
const activeCoverageFloorIds = computed(() => new Set(
|
|
activeCompositeBasemap.value
|
|
? [
|
|
...activeCompositeBasemap.value.coverageFloorIds,
|
|
...activeCompositeBasemap.value.coverageFloorCodes
|
|
].map((floorId) => String(floorId).trim().toUpperCase())
|
|
: [String(props.floorId).trim().toUpperCase()]
|
|
))
|
|
const isFloorCoveredByActiveBasemap = (floorId: string) => (
|
|
activeCoverageFloorIds.value.has(String(floorId || '').trim().toUpperCase())
|
|
)
|
|
|
|
const getTouchDistance = (touches: TouchList) => {
|
|
if (touches.length < 2) return 0
|
|
const [first, second] = [touches[0], touches[1]]
|
|
return Math.hypot(first.clientX - second.clientX, first.clientY - second.clientY)
|
|
}
|
|
|
|
const getTrackedTouch = (touches: TouchList) => {
|
|
const trackedId = pointerStart.value?.id
|
|
if (!trackedId?.startsWith('touch:')) return touches[0] || null
|
|
const touchId = Number(trackedId.slice('touch:'.length))
|
|
return Array.from(touches).find((touch) => touch.identifier === touchId) || null
|
|
}
|
|
|
|
const routeEndpointIds = computed(() => new Set([
|
|
props.routePreview?.start.poiId,
|
|
props.routePreview?.end.poiId
|
|
].filter((poiId): poiId is string => Boolean(poiId))))
|
|
|
|
const forcedPois = computed(() => {
|
|
const allPois = new Map(props.pois.map((poi) => [poi.id, poi]))
|
|
const appendPoi = (poi: GuideRenderPoi | null | undefined) => {
|
|
if (!poi?.positionGltf || !isFloorCoveredByActiveBasemap(poi.floorId)) return
|
|
if (!allPois.has(poi.id)) allPois.set(poi.id, poi)
|
|
}
|
|
|
|
appendPoi(props.selectedPoi)
|
|
for (const endpoint of [props.routePreview?.start, props.routePreview?.end]) {
|
|
if (!endpoint?.position) continue
|
|
appendPoi({
|
|
id: endpoint.poiId,
|
|
name: endpoint.name,
|
|
floorId: endpoint.floorId,
|
|
primaryCategory: 'target_preview',
|
|
primaryCategoryZh: '路线地点',
|
|
iconType: 'target_preview',
|
|
positionGltf: endpoint.position
|
|
})
|
|
}
|
|
return [...allPois.values()]
|
|
})
|
|
|
|
const eligiblePois = computed(() => {
|
|
const allowedIds = new Set(props.visiblePoiIds || [])
|
|
const activeFloorId = String(props.floorId)
|
|
const isExteriorScene = props.sceneView === 'overview'
|
|
const sourcePois = [...forcedPois.value]
|
|
|
|
if (isExteriorScene) {
|
|
const representedDefinitions = new Set(sourcePois
|
|
.map((poi) => getOverviewMapLabelDefinition(poi)?.id)
|
|
.filter((id): id is string => Boolean(id)))
|
|
|
|
for (const definition of OVERVIEW_MAP_LABEL_DEFINITIONS) {
|
|
if (!definition.fallbackPositionGltf || representedDefinitions.has(definition.id)) continue
|
|
sourcePois.push({
|
|
id: definition.id,
|
|
name: definition.label,
|
|
floorId: props.floorId,
|
|
primaryCategory: 'overview_label',
|
|
primaryCategoryZh: '外观标签',
|
|
iconType: definition.category,
|
|
kind: 'space',
|
|
positionGltf: [...definition.fallbackPositionGltf]
|
|
})
|
|
}
|
|
}
|
|
|
|
return sourcePois
|
|
.filter((poi) => {
|
|
if (!poi.positionGltf) return false
|
|
if (isExteriorScene) {
|
|
return Boolean(
|
|
getOverviewMapLabelDefinition(poi)
|
|
|| allowedIds.has(poi.id)
|
|
|| poi.id === props.selectedPoiId
|
|
|| routeEndpointIds.value.has(poi.id)
|
|
)
|
|
}
|
|
return String(poi.floorId) === activeFloorId || Boolean(
|
|
activeCompositeBasemap.value
|
|
&& isFloorCoveredByActiveBasemap(poi.floorId)
|
|
&& (poi.id === props.selectedPoiId || routeEndpointIds.value.has(poi.id))
|
|
)
|
|
})
|
|
.filter((poi) => (
|
|
props.visiblePoiIds === null
|
|
|| allowedIds.has(poi.id)
|
|
|| poi.id === props.selectedPoiId
|
|
|| routeEndpointIds.value.has(poi.id)
|
|
))
|
|
})
|
|
|
|
const getWeakMapLabel = (poi: GuideRenderPoi) => {
|
|
const overviewDefinition = props.sceneView === 'overview'
|
|
? getOverviewMapLabelDefinition(poi)
|
|
: null
|
|
if (overviewDefinition) return overviewDefinition.label
|
|
|
|
const policy = poi.displayPolicy || getPoiDisplayPolicy({
|
|
primaryCategory: poi.primaryCategory,
|
|
iconType: poi.iconType,
|
|
kind: poi.kind
|
|
})
|
|
const isDeviceTerminal = poi.iconType === 'device_terminal'
|
|
const isAmbientFacility = Boolean(
|
|
['restroom', 'toilet', 'accessible', 'accessible_restroom', 'accessible_toilet', 'restroom_accessible', 'mother_baby_room', 'nursing_room', 'nursery', 'elevator', 'escalator', 'stairs', 'stair']
|
|
.includes((poi.iconType || '').trim().toLowerCase())
|
|
)
|
|
const presentation = presentVisitorPoi({
|
|
...poi,
|
|
floorLabel: props.floorLabel || poi.floorId,
|
|
primaryCategory: { label: poi.primaryCategoryZh }
|
|
})
|
|
const label = isAmbientFacility || isDeviceTerminal
|
|
? getPoiMapLabelText({ name: poi.name, iconType: poi.iconType })
|
|
: presentation.displayName
|
|
const limit = poi.id === props.selectedPoiId
|
|
? 14
|
|
: (policy.pinInOverview || isDeviceTerminal ? 24 : isAmbientFacility ? 12 : 7)
|
|
const displayLabel = label || poi.name || '地点'
|
|
return displayLabel.length > limit
|
|
? `${displayLabel.slice(0, limit)}…`
|
|
: displayLabel
|
|
}
|
|
|
|
const poiCandidates = computed<WeakMapPoiCandidate[]>(() => {
|
|
const visiblePoiIds = new Set(props.visiblePoiIds || [])
|
|
return eligiblePois.value
|
|
.map((poi, sourceIndex) => {
|
|
const policy = poi.displayPolicy || getPoiDisplayPolicy({
|
|
primaryCategory: poi.primaryCategory,
|
|
iconType: poi.iconType,
|
|
kind: poi.kind
|
|
})
|
|
const required = poi.id === props.selectedPoiId || routeEndpointIds.value.has(poi.id)
|
|
const isSearchResult = visiblePoiIds.size > 0 && visiblePoiIds.has(poi.id)
|
|
const isOverviewLabel = props.sceneView === 'overview'
|
|
&& Boolean(getOverviewMapLabelDefinition(poi))
|
|
const labelPriority = required
|
|
? 10_000
|
|
: isSearchResult
|
|
? 5_000
|
|
: isOverviewLabel
|
|
? 3_000 + policy.priority
|
|
: (policy.pinInOverview ? 2_000 : 0) + policy.priority
|
|
|
|
return {
|
|
poi,
|
|
sourceIndex,
|
|
required,
|
|
isSearchResult,
|
|
isOverviewLabel,
|
|
policy,
|
|
priority: labelPriority,
|
|
label: getWeakMapLabel(poi)
|
|
}
|
|
})
|
|
.sort((left, right) => right.priority - left.priority || left.sourceIndex - right.sourceIndex)
|
|
})
|
|
|
|
const routePointSegments = computed(() => (props.routePreview?.floorSegments || [])
|
|
.filter((segment) => (
|
|
activeCompositeBasemap.value
|
|
? isFloorCoveredByActiveBasemap(segment.floorId)
|
|
: String(segment.floorId) === String(props.floorId)
|
|
))
|
|
.map((segment) => ({
|
|
floorId: String(segment.floorId),
|
|
points: segment.points.map((point) => ({
|
|
x: point.position[0],
|
|
y: point.position[1],
|
|
z: point.position[2]
|
|
}))
|
|
})))
|
|
|
|
const mergeContinuousRoutePoints = (segments: Array<{ points: FlatPoint[] }>) => {
|
|
const merged: FlatPoint[] = []
|
|
for (const segment of segments) {
|
|
for (const point of segment.points) {
|
|
const previous = merged[merged.length - 1]
|
|
if (previous && Math.hypot(
|
|
point.x - previous.x,
|
|
(point.y || 0) - (previous.y || 0),
|
|
point.z - previous.z
|
|
) < 1e-6) continue
|
|
merged.push(point)
|
|
}
|
|
}
|
|
return merged
|
|
}
|
|
|
|
const routePolylines = computed(() => {
|
|
if (!activeCompositeBasemap.value) return routePointSegments.value
|
|
return [{
|
|
floorId: activeCompositeBasemap.value.floorCode,
|
|
points: mergeContinuousRoutePoints(routePointSegments.value)
|
|
}]
|
|
})
|
|
|
|
const routePoints = computed(() => routePolylines.value.flatMap((segment) => segment.points))
|
|
|
|
const getRouteSegmentDistance = (points: FlatPoint[]) => points.reduce((distance, point, index) => (
|
|
index === 0
|
|
? 0
|
|
: distance + Math.hypot(
|
|
point.x - points[index - 1].x,
|
|
(point.y || 0) - (points[index - 1].y || 0),
|
|
point.z - points[index - 1].z
|
|
)
|
|
), 0)
|
|
|
|
const routeNavigationSegments = computed<WeakMapRouteSegment[]>(() => {
|
|
const sourceSegments = (props.routePreview?.floorSegments || []).filter((segment) => segment.points.length > 0)
|
|
if (activeCompositeBasemap.value) {
|
|
const points = mergeContinuousRoutePoints(sourceSegments.map((segment) => ({
|
|
points: segment.points.map((point) => ({
|
|
x: point.position[0],
|
|
y: point.position[1],
|
|
z: point.position[2]
|
|
}))
|
|
})))
|
|
return points.length > 0 ? [{
|
|
floorId: activeCompositeBasemap.value.floorCode,
|
|
sourceFloorIds: routeCoverageFloorReferences.value,
|
|
points: points.length === 1 ? [points[0], points[0]] : points,
|
|
distance: getRouteSegmentDistance(points)
|
|
}] : []
|
|
}
|
|
|
|
return sourceSegments.map((segment) => {
|
|
const points = segment.points.map((point) => ({
|
|
x: point.position[0],
|
|
y: point.position[1],
|
|
z: point.position[2]
|
|
}))
|
|
return {
|
|
floorId: segment.floorId,
|
|
sourceFloorIds: [segment.floorId],
|
|
points: points.length === 1 ? [points[0], points[0]] : points,
|
|
distance: getRouteSegmentDistance(points)
|
|
}
|
|
})
|
|
})
|
|
|
|
const routeNavigationTotalDistance = computed(() => Math.max(
|
|
routeNavigationSegments.value.reduce((total, segment) => total + segment.distance, 0),
|
|
0.001
|
|
))
|
|
|
|
const getRoutePosition = (points: FlatPoint[], progress: number) => {
|
|
if (points.length < 2) return points[0] || null
|
|
const targetDistance = getRouteSegmentDistance(points) * Math.max(0, Math.min(progress, 1))
|
|
let walked = 0
|
|
for (let index = 1; index < points.length; index += 1) {
|
|
const start = points[index - 1]
|
|
const end = points[index]
|
|
const segmentDistance = Math.hypot(
|
|
end.x - start.x,
|
|
(end.y || 0) - (start.y || 0),
|
|
end.z - start.z
|
|
)
|
|
if (walked + segmentDistance >= targetDistance) {
|
|
const segmentProgress = segmentDistance > 0
|
|
? (targetDistance - walked) / segmentDistance
|
|
: 1
|
|
return {
|
|
x: start.x + (end.x - start.x) * segmentProgress,
|
|
y: (start.y || 0) + ((end.y || 0) - (start.y || 0)) * segmentProgress,
|
|
z: start.z + (end.z - start.z) * segmentProgress
|
|
}
|
|
}
|
|
walked += segmentDistance
|
|
}
|
|
return points[points.length - 1]
|
|
}
|
|
|
|
const cancelRouteNavigationFrame = () => {
|
|
if (routeNavigationFrame !== null && typeof cancelAnimationFrame === 'function') {
|
|
cancelAnimationFrame(routeNavigationFrame)
|
|
}
|
|
routeNavigationFrame = null
|
|
}
|
|
|
|
const stopRouteNavigation = (reset = true) => {
|
|
cancelRouteNavigationFrame()
|
|
routeNavigationStartedAt.value = 0
|
|
if (!reset) return
|
|
routeNavigationRouteId = ''
|
|
routeNavigationLastRemainingMeters = null
|
|
routeNavigationSegmentIndex.value = 0
|
|
routeNavigationCompletedDistance.value = 0
|
|
routeProgressPoint.value = null
|
|
}
|
|
|
|
const emitRouteRoamingProgress = (progress: number) => {
|
|
const routeDistance = Number(props.routePreview?.distanceMeters)
|
|
const totalDistance = Number.isFinite(routeDistance) && routeDistance > 0
|
|
? routeDistance
|
|
: routeNavigationTotalDistance.value
|
|
const remainingMeters = Math.max(0, Math.round(totalDistance * (1 - progress)))
|
|
if (remainingMeters === routeNavigationLastRemainingMeters) return
|
|
routeNavigationLastRemainingMeters = remainingMeters
|
|
emit('routeRoamingProgress', { remainingMeters, progress })
|
|
}
|
|
|
|
const resolveRouteTransfer = (fromFloorId: string, toFloorId: string) => props.routePreview?.transitions?.find((transition) => (
|
|
transition.fromFloorId === fromFloorId && transition.toFloorId === toFloorId
|
|
))
|
|
|
|
const startRouteNavigationForCurrentFloor = () => {
|
|
cancelRouteNavigationFrame()
|
|
const route = props.routePreview
|
|
const segment = routeNavigationSegments.value[routeNavigationSegmentIndex.value]
|
|
if (
|
|
!props.routeNavigationActive
|
|
|| !route
|
|
|| !segment
|
|
|| (!activeCompositeBasemap.value && String(segment.floorId) !== String(props.floorId))
|
|
) return
|
|
|
|
routeNavigationStartedAt.value = performance.now() + ROUTE_NAVIGATION_ENTRY_MS
|
|
const duration = Math.max(
|
|
MIN_ROUTE_SEGMENT_DURATION_MS,
|
|
Math.min(MAX_ROUTE_SEGMENT_DURATION_MS, segment.distance * ROUTE_DURATION_PER_METER_MS)
|
|
)
|
|
routeProgressPoint.value = segment.points[0] || null
|
|
|
|
const tick = (now: number) => {
|
|
if (!props.routeNavigationActive || props.routePreview?.id !== route.id) return
|
|
const activeSegment = routeNavigationSegments.value[routeNavigationSegmentIndex.value]
|
|
if (
|
|
!activeSegment
|
|
|| (!activeCompositeBasemap.value && String(activeSegment.floorId) !== String(props.floorId))
|
|
) return
|
|
|
|
const progress = Math.max(0, Math.min(1, (now - routeNavigationStartedAt.value) / duration))
|
|
routeProgressPoint.value = getRoutePosition(activeSegment.points, progress)
|
|
const totalProgress = Math.max(0, Math.min(
|
|
1,
|
|
(routeNavigationCompletedDistance.value + activeSegment.distance * progress)
|
|
/ routeNavigationTotalDistance.value
|
|
))
|
|
emitRouteRoamingProgress(totalProgress)
|
|
|
|
if (progress < 1) {
|
|
routeNavigationFrame = requestAnimationFrame(tick)
|
|
return
|
|
}
|
|
|
|
routeNavigationCompletedDistance.value += activeSegment.distance
|
|
const nextSegment = routeNavigationSegments.value[routeNavigationSegmentIndex.value + 1]
|
|
if (!nextSegment) {
|
|
routeNavigationFrame = null
|
|
emit('routeRoamingProgress', { remainingMeters: 0, progress: 1 })
|
|
return
|
|
}
|
|
|
|
routeNavigationSegmentIndex.value += 1
|
|
routeNavigationStartedAt.value = 0
|
|
routeNavigationFrame = null
|
|
routeProgressPoint.value = null
|
|
const transition = resolveRouteTransfer(activeSegment.floorId, nextSegment.floorId)
|
|
emit('routeRoamingTransfer', {
|
|
fromFloorId: activeSegment.floorId,
|
|
toFloorId: nextSegment.floorId,
|
|
transferType: transition?.transferType || 'TRANSFER',
|
|
connectorName: transition?.connectorName
|
|
})
|
|
emit('floorChange', nextSegment.floorId)
|
|
}
|
|
|
|
routeNavigationFrame = requestAnimationFrame(tick)
|
|
}
|
|
|
|
const fallbackBounds = computed(() => {
|
|
const points: FlatPoint[] = [
|
|
...eligiblePois.value.map((poi) => ({ x: poi.positionGltf![0], y: poi.positionGltf![1], z: poi.positionGltf![2] })),
|
|
...props.routeSelectablePoints
|
|
.filter((point) => isFloorCoveredByActiveBasemap(point.floorId) && Boolean(point.positionGltf))
|
|
.map((point) => ({
|
|
x: point.positionGltf![0],
|
|
y: point.positionGltf![1],
|
|
z: point.positionGltf![2]
|
|
})),
|
|
...routePoints.value
|
|
]
|
|
if (!points.length) return { minX: -1, maxX: 1, minZ: -1, maxZ: 1 }
|
|
|
|
const minX = Math.min(...points.map((point) => point.x))
|
|
const maxX = Math.max(...points.map((point) => point.x))
|
|
const minZ = Math.min(...points.map((point) => point.z))
|
|
const maxZ = Math.max(...points.map((point) => point.z))
|
|
const paddingX = Math.max((maxX - minX) * 0.14, 4)
|
|
const paddingZ = Math.max((maxZ - minZ) * 0.14, 4)
|
|
return {
|
|
minX: minX - paddingX,
|
|
maxX: maxX + paddingX,
|
|
minZ: minZ - paddingZ,
|
|
maxZ: maxZ + paddingZ
|
|
}
|
|
})
|
|
|
|
const activeWorldBounds = computed<GuideWorldBounds>(() => {
|
|
const manifestBounds = getGuideFloorBasemapWorldBounds(activeBasemap.value)
|
|
if (manifestBounds) return manifestBounds
|
|
return {
|
|
...fallbackBounds.value,
|
|
screenXAxis: '+X',
|
|
screenYAxis: '-Z'
|
|
}
|
|
})
|
|
|
|
const viewportScene = computed<SceneView>(() => (
|
|
activeCompositeBasemap.value
|
|
? 'floor'
|
|
: props.sceneView === 'overview' ? 'overview' : 'floor'
|
|
))
|
|
|
|
const viewportFloorId = computed(() => (
|
|
viewportScene.value === 'floor'
|
|
? String(activeCompositeBasemap.value?.floorCode || props.floorId || '')
|
|
: ''
|
|
))
|
|
|
|
const viewportContextKey = computed(() => getGuideViewportKey(
|
|
viewportScene.value,
|
|
viewportFloorId.value
|
|
))
|
|
|
|
const createDefaultViewport = () => {
|
|
const initialViewport = activeBasemap.value?.initialViewport
|
|
if (isValidGuideFloorBasemapInitialViewport(initialViewport)) {
|
|
return clampGuideViewport({
|
|
scene: viewportScene.value,
|
|
floorId: viewportFloorId.value,
|
|
centerX: initialViewport!.centerX,
|
|
centerZ: initialViewport!.centerZ,
|
|
visibleWorldSpan: initialViewport!.visibleWorldSpan,
|
|
revision: 0
|
|
}, activeWorldBounds.value, surfaceSize.value)
|
|
}
|
|
|
|
return createGuideViewport({
|
|
scene: viewportScene.value,
|
|
floorId: viewportFloorId.value,
|
|
bounds: activeWorldBounds.value,
|
|
size: surfaceSize.value
|
|
})
|
|
}
|
|
|
|
const referenceVisibleWorldSpan = computed<number | null>(() => {
|
|
const span = createDefaultViewport().visibleWorldSpan
|
|
return Number.isFinite(span) && span > 0 ? span : null
|
|
})
|
|
|
|
const hasReachedOutdoorMapExitBoundary = (viewport: GuideViewportState) => {
|
|
if (viewport.scene !== 'overview') return false
|
|
|
|
const maximumViewport = clampGuideViewport({
|
|
...viewport,
|
|
visibleWorldSpan: Number.MAX_SAFE_INTEGER
|
|
}, activeWorldBounds.value, surfaceSize.value)
|
|
const maximumVisibleWorldSpan = maximumViewport.visibleWorldSpan
|
|
|
|
return Number.isFinite(maximumVisibleWorldSpan)
|
|
&& maximumVisibleWorldSpan > 0
|
|
&& viewport.visibleWorldSpan >= maximumVisibleWorldSpan * OUTDOOR_MAP_EXIT_VISIBLE_SPAN_RATIO
|
|
}
|
|
|
|
const poiVisibilityTier = computed(() => getPoiVisibilityTier(
|
|
referenceVisibleWorldSpan.value
|
|
? (localViewport.value?.visibleWorldSpan || referenceVisibleWorldSpan.value)
|
|
/ referenceVisibleWorldSpan.value
|
|
: Number.NaN
|
|
))
|
|
|
|
const isViewportForCurrentContext = (viewport: GuideViewportState | null | undefined) => (
|
|
Boolean(viewport)
|
|
&& getGuideViewportKey(viewport!.scene, viewport!.floorId) === viewportContextKey.value
|
|
&& Number.isFinite(viewport!.centerX)
|
|
&& Number.isFinite(viewport!.centerZ)
|
|
&& Number.isFinite(viewport!.visibleWorldSpan)
|
|
&& viewport!.visibleWorldSpan > 0
|
|
)
|
|
|
|
const setLocalViewport = (
|
|
viewport: GuideViewportState,
|
|
source?: 'initialize' | 'interaction'
|
|
) => {
|
|
const next = clampGuideViewport({
|
|
...viewport,
|
|
scene: viewportScene.value,
|
|
floorId: viewportFloorId.value
|
|
}, activeWorldBounds.value, surfaceSize.value)
|
|
localViewport.value = next
|
|
if (source) emit('viewportChange', { viewport: next, source })
|
|
return next
|
|
}
|
|
|
|
const getGuideViewportState = () => localViewport.value
|
|
|
|
const resetCamera = () => {
|
|
setLocalViewport(createDefaultViewport(), 'interaction')
|
|
}
|
|
|
|
const project = (point: FlatPoint) => {
|
|
const viewport = localViewport.value || createDefaultViewport()
|
|
return projectGuideWorldPoint(point, viewport, surfaceSize.value, activeWorldBounds.value)
|
|
}
|
|
|
|
const isWeakMapAmbientFacility = (candidate: WeakMapPoiCandidate) => (
|
|
Boolean(getAmbientFacilityTier(candidate.poi.iconType))
|
|
&& candidate.policy.labelVisible
|
|
&& !candidate.policy.pinInOverview
|
|
)
|
|
|
|
const exceedsWeakMapAmbientFacilityQuota = (
|
|
candidate: WeakMapPoiCandidate,
|
|
counts: WeakMapAmbientFacilityCounts
|
|
) => {
|
|
const facilityTier = getAmbientFacilityTier(candidate.poi.iconType)
|
|
const facilityKind = getAmbientFacilityKind(candidate.poi.iconType)
|
|
if (!facilityTier || !facilityKind) return false
|
|
|
|
if (counts.total >= getAmbientFacilityLimit(poiVisibilityTier.value)) return true
|
|
if ((counts.byKind.get(facilityKind) || 0) >= 2) return true
|
|
if (facilityTier === 'near-only') {
|
|
return poiVisibilityTier.value !== 'full' || counts.nearOnly >= 4
|
|
}
|
|
return counts.balanced >= 4
|
|
}
|
|
|
|
const recordWeakMapAmbientFacility = (
|
|
candidate: WeakMapPoiCandidate,
|
|
counts: WeakMapAmbientFacilityCounts
|
|
) => {
|
|
const facilityTier = getAmbientFacilityTier(candidate.poi.iconType)
|
|
const facilityKind = getAmbientFacilityKind(candidate.poi.iconType)
|
|
if (!facilityTier || !facilityKind) return
|
|
|
|
counts.total += 1
|
|
if (facilityTier === 'near-only') counts.nearOnly += 1
|
|
else counts.balanced += 1
|
|
counts.byKind.set(facilityKind, (counts.byKind.get(facilityKind) || 0) + 1)
|
|
}
|
|
|
|
const visiblePoiCandidates = computed(() => {
|
|
const tier = poiVisibilityTier.value
|
|
const markerLimit = getPoiMarkerLimit(tier)
|
|
const screenSpacing = getPoiScreenSpacing(tier)
|
|
const acceptedPositions: ProjectedPoint[] = []
|
|
const facilityCounts = createWeakMapAmbientFacilityCounts()
|
|
const accepted: WeakMapPoiCandidate[] = []
|
|
let ordinaryMarkerCount = 0
|
|
let safetyLimitedMarkerCount = 0
|
|
|
|
for (const candidate of poiCandidates.value) {
|
|
const isPinnedLandmark = viewportScene.value === 'floor' && candidate.policy.pinInOverview
|
|
const isProtected = candidate.required
|
|
|| candidate.isSearchResult
|
|
|| candidate.isOverviewLabel
|
|
|| isPinnedLandmark
|
|
const categoryVisible = candidate.policy.markerVisible
|
|
&& isPoiVisibilityTierAtLeast(tier, candidate.policy.minMarkerTier)
|
|
if (!isProtected && !categoryVisible) continue
|
|
|
|
const point = project({
|
|
x: candidate.poi.positionGltf![0],
|
|
y: candidate.poi.positionGltf![1],
|
|
z: candidate.poi.positionGltf![2]
|
|
})
|
|
const isFacility = isWeakMapAmbientFacility(candidate)
|
|
const isTooClose = screenSpacing > 0 && acceptedPositions.some((position) => (
|
|
Math.hypot(position.x - point.x, position.y - point.y) < screenSpacing
|
|
))
|
|
const exceedsTierLimit = isFacility
|
|
? exceedsWeakMapAmbientFacilityQuota(candidate, facilityCounts)
|
|
: Number.isFinite(markerLimit) && ordinaryMarkerCount >= markerLimit
|
|
|
|
if (!isProtected && (
|
|
safetyLimitedMarkerCount >= MAX_WEAK_MAP_MARKERS
|
|
|| isTooClose
|
|
|| exceedsTierLimit
|
|
)) continue
|
|
|
|
accepted.push(candidate)
|
|
acceptedPositions.push(point)
|
|
if (isProtected) continue
|
|
|
|
safetyLimitedMarkerCount += 1
|
|
if (isFacility) recordWeakMapAmbientFacility(candidate, facilityCounts)
|
|
else ordinaryMarkerCount += 1
|
|
}
|
|
|
|
return accepted
|
|
})
|
|
|
|
const basemapStyle = computed(() => {
|
|
const bounds = activeWorldBounds.value
|
|
const centerX = bounds.projectionCenterX ?? (bounds.minX + bounds.maxX) / 2
|
|
const centerZ = bounds.projectionCenterZ ?? (bounds.minZ + bounds.maxZ) / 2
|
|
const projectionWidth = bounds.projectionWidth ?? bounds.maxX - bounds.minX
|
|
const projectionHeight = bounds.projectionHeight ?? bounds.maxZ - bounds.minZ
|
|
const screenRight = bounds.screenRight || {
|
|
x: bounds.screenXAxis === '-X' ? -1 : 1,
|
|
z: 0
|
|
}
|
|
const screenDown = bounds.screenDown || {
|
|
x: 0,
|
|
z: bounds.screenYAxis === '+Z' ? -1 : 1
|
|
}
|
|
const toWorldCorner = (right: number, down: number) => ({
|
|
x: centerX + right * screenRight.x + down * screenDown.x,
|
|
z: centerZ + right * screenRight.z + down * screenDown.z
|
|
})
|
|
const corners = [
|
|
project(toWorldCorner(-projectionWidth / 2, -projectionHeight / 2)),
|
|
project(toWorldCorner(-projectionWidth / 2, projectionHeight / 2)),
|
|
project(toWorldCorner(projectionWidth / 2, -projectionHeight / 2)),
|
|
project(toWorldCorner(projectionWidth / 2, projectionHeight / 2))
|
|
]
|
|
const left = Math.min(...corners.map((point) => point.x))
|
|
const right = Math.max(...corners.map((point) => point.x))
|
|
const top = Math.min(...corners.map((point) => point.y))
|
|
const bottom = Math.max(...corners.map((point) => point.y))
|
|
return {
|
|
left: `${left}px`,
|
|
top: `${top}px`,
|
|
width: `${Math.max(right - left, 1)}px`,
|
|
height: `${Math.max(bottom - top, 1)}px`
|
|
}
|
|
})
|
|
|
|
const getSurfaceLocalPoint = (clientX: number, clientY: number) => {
|
|
const element = getSurfaceElement()
|
|
if (!element) return null
|
|
const bounds = element.getBoundingClientRect()
|
|
return {
|
|
x: clientX - bounds.left,
|
|
y: clientY - bounds.top
|
|
}
|
|
}
|
|
|
|
const emitZoomIntent = (
|
|
direction: 'in' | 'out',
|
|
source: 'button' | 'gesture',
|
|
boundaryAttempt: boolean,
|
|
viewport: GuideViewportState
|
|
) => {
|
|
const referenceSpan = referenceVisibleWorldSpan.value
|
|
emit('zoomIntent', {
|
|
direction,
|
|
source,
|
|
boundaryAttempt,
|
|
referenceVisibleWorldSpan: referenceSpan,
|
|
thresholdReached: referenceSpan !== null && isGuideAutoSwitchVisibleSpanThresholdReached({
|
|
scene: viewport.scene,
|
|
direction,
|
|
currentVisibleWorldSpan: viewport.visibleWorldSpan,
|
|
referenceVisibleWorldSpan: referenceSpan
|
|
}),
|
|
outdoorExitReached: direction === 'out' && hasReachedOutdoorMapExitBoundary(viewport),
|
|
viewport
|
|
})
|
|
}
|
|
|
|
const zoomCamera = (
|
|
direction: 'in' | 'out',
|
|
source: 'button' | 'gesture' = 'button',
|
|
anchor?: GuideViewportScreenPoint
|
|
) => {
|
|
const viewport = localViewport.value || setLocalViewport(createDefaultViewport())
|
|
const result = zoomGuideViewport(
|
|
viewport,
|
|
direction,
|
|
activeWorldBounds.value,
|
|
surfaceSize.value,
|
|
anchor
|
|
)
|
|
if (result.changed) setLocalViewport(result.viewport, 'interaction')
|
|
emitZoomIntent(direction, source, result.boundaryAttempt, result.viewport)
|
|
return result.viewport
|
|
}
|
|
|
|
const beginSurfacePan = (id: string, x: number, y: number) => {
|
|
const viewport = localViewport.value
|
|
if (!viewport) return
|
|
pointerStart.value = { id, x, y, viewport }
|
|
}
|
|
|
|
const moveSurfacePan = (id: string, x: number, y: number) => {
|
|
const start = pointerStart.value
|
|
if (!start || start.id !== id) return
|
|
localViewport.value = panGuideViewport(start.viewport, {
|
|
x: x - start.x,
|
|
y: y - start.y
|
|
}, activeWorldBounds.value, surfaceSize.value)
|
|
}
|
|
|
|
const endSurfacePan = (id: string) => {
|
|
const start = pointerStart.value
|
|
if (!start || start.id !== id) return
|
|
pointerStart.value = null
|
|
if (localViewport.value?.revision !== start.viewport.revision) {
|
|
emit('viewportChange', { viewport: localViewport.value!, source: 'interaction' })
|
|
}
|
|
}
|
|
|
|
const handleSurfacePointerDown = (event: PointerEvent) => {
|
|
const point = getSurfaceLocalPoint(event.clientX, event.clientY)
|
|
if (!point) return
|
|
beginSurfacePan(`pointer:${event.pointerId}`, point.x, point.y)
|
|
;(event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId)
|
|
}
|
|
|
|
const handleSurfacePointerMove = (event: PointerEvent) => {
|
|
const point = getSurfaceLocalPoint(event.clientX, event.clientY)
|
|
if (point) moveSurfacePan(`pointer:${event.pointerId}`, point.x, point.y)
|
|
}
|
|
|
|
const handleSurfacePointerEnd = (event: PointerEvent) => {
|
|
endSurfacePan(`pointer:${event.pointerId}`)
|
|
}
|
|
|
|
const handleSurfaceMouseDown = (event: MouseEvent) => {
|
|
const point = getSurfaceLocalPoint(event.clientX, event.clientY)
|
|
if (point) beginSurfacePan('mouse', point.x, point.y)
|
|
}
|
|
|
|
const handleSurfaceMouseMove = (event: MouseEvent) => {
|
|
const point = getSurfaceLocalPoint(event.clientX, event.clientY)
|
|
if (point) moveSurfacePan('mouse', point.x, point.y)
|
|
}
|
|
|
|
const handleSurfaceMouseUp = () => endSurfacePan('mouse')
|
|
|
|
const getTouchCenter = (touches: TouchList) => {
|
|
if (!touches.length) return null
|
|
const x = Array.from(touches).reduce((total, touch) => total + touch.clientX, 0) / touches.length
|
|
const y = Array.from(touches).reduce((total, touch) => total + touch.clientY, 0) / touches.length
|
|
return getSurfaceLocalPoint(x, y)
|
|
}
|
|
|
|
const handleSurfaceTouchStart = (event: TouchEvent) => {
|
|
if (event.touches.length >= 2 && localViewport.value) {
|
|
const distance = getTouchDistance(event.touches)
|
|
const anchor = getTouchCenter(event.touches)
|
|
if (distance > 0 && anchor) {
|
|
pinchStart.value = {
|
|
distance,
|
|
viewport: localViewport.value,
|
|
anchor
|
|
}
|
|
}
|
|
pointerStart.value = null
|
|
return
|
|
}
|
|
const touch = event.touches[0]
|
|
if (!touch) return
|
|
const point = getSurfaceLocalPoint(touch.clientX, touch.clientY)
|
|
if (point) beginSurfacePan(`touch:${touch.identifier}`, point.x, point.y)
|
|
}
|
|
|
|
const handleSurfaceTouchMove = (event: TouchEvent) => {
|
|
const pinch = pinchStart.value
|
|
if (pinch && event.touches.length >= 2) {
|
|
const distance = getTouchDistance(event.touches)
|
|
const anchorAfter = getTouchCenter(event.touches)
|
|
if (distance > 0 && anchorAfter) {
|
|
localViewport.value = setGuideViewportSpanAt(
|
|
pinch.viewport,
|
|
pinch.viewport.visibleWorldSpan * pinch.distance / distance,
|
|
pinch.anchor,
|
|
surfaceSize.value,
|
|
activeWorldBounds.value,
|
|
anchorAfter
|
|
)
|
|
}
|
|
return
|
|
}
|
|
const touch = getTrackedTouch(event.touches)
|
|
if (!touch) return
|
|
const point = getSurfaceLocalPoint(touch.clientX, touch.clientY)
|
|
if (point) moveSurfacePan(`touch:${touch.identifier}`, point.x, point.y)
|
|
}
|
|
|
|
const handleSurfaceTouchEnd = (event: TouchEvent) => {
|
|
const pinch = pinchStart.value
|
|
if (pinch && event.touches.length < 2) {
|
|
pinchStart.value = null
|
|
const viewport = localViewport.value || pinch.viewport
|
|
const direction = viewport.visibleWorldSpan < pinch.viewport.visibleWorldSpan ? 'in' : 'out'
|
|
const changed = viewport.revision !== pinch.viewport.revision
|
|
if (changed) emit('viewportChange', { viewport, source: 'interaction' })
|
|
emitZoomIntent(direction, 'gesture', !changed, viewport)
|
|
}
|
|
const touch = getTrackedTouch(event.changedTouches)
|
|
if (touch) endSurfacePan(`touch:${touch.identifier}`)
|
|
}
|
|
|
|
const handleSurfaceWheel = (event: WheelEvent) => {
|
|
if (!event.deltaY) return
|
|
event.preventDefault()
|
|
const anchor = getSurfaceLocalPoint(event.clientX, event.clientY) || undefined
|
|
zoomCamera(event.deltaY < 0 ? 'in' : 'out', 'gesture', anchor)
|
|
}
|
|
|
|
watch(() => ({
|
|
context: viewportContextKey.value,
|
|
basemap: activeBasemap.value?.image.path || '',
|
|
viewport: props.sceneViewport,
|
|
width: surfaceSize.value.width,
|
|
height: surfaceSize.value.height
|
|
}), ({ basemap, viewport }, previous) => {
|
|
if (isViewportForCurrentContext(viewport)) {
|
|
setLocalViewport(viewport!)
|
|
return
|
|
}
|
|
if (!basemap) {
|
|
// The POI-derived bounds are only a temporary frame while the orthographic
|
|
// manifest loads. Publishing it would make the real basemap start at max zoom.
|
|
setLocalViewport(createDefaultViewport())
|
|
return
|
|
}
|
|
if (!previous?.basemap || previous.basemap !== basemap) {
|
|
setLocalViewport(createDefaultViewport(), 'initialize')
|
|
return
|
|
}
|
|
if (isViewportForCurrentContext(localViewport.value)) {
|
|
setLocalViewport(localViewport.value!)
|
|
return
|
|
}
|
|
setLocalViewport(createDefaultViewport(), 'initialize')
|
|
}, {
|
|
immediate: true,
|
|
deep: true
|
|
})
|
|
|
|
const getRouteProgressStyle = (point: FlatPoint | null) => {
|
|
if (!point) return {}
|
|
const projected = project(point)
|
|
return {
|
|
left: `${projected.x}px`,
|
|
top: `${projected.y}px`
|
|
}
|
|
}
|
|
|
|
const getSurfaceTapPoint = (event: Event) => {
|
|
const element = getSurfaceElement()
|
|
if (!element) return null
|
|
|
|
const pointerEvent = event as MouseEvent & {
|
|
changedTouches?: TouchList
|
|
touches?: TouchList
|
|
detail?: { x?: number; y?: number }
|
|
}
|
|
const touch = pointerEvent.changedTouches?.[0] || pointerEvent.touches?.[0]
|
|
const clientX = touch?.clientX ?? pointerEvent.detail?.x ?? pointerEvent.clientX
|
|
const clientY = touch?.clientY ?? pointerEvent.detail?.y ?? pointerEvent.clientY
|
|
if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) return null
|
|
|
|
const bounds = element.getBoundingClientRect()
|
|
return {
|
|
x: Number(clientX) - bounds.left,
|
|
y: Number(clientY) - bounds.top
|
|
}
|
|
}
|
|
|
|
const handleSurfaceTap = (event: Event) => {
|
|
if (!props.routeStartSelectionActive) return
|
|
const tapPoint = getSurfaceTapPoint(event)
|
|
if (!tapPoint) {
|
|
emit('routeStartCandidateRejected')
|
|
return
|
|
}
|
|
|
|
const candidate = props.routeSelectablePoints
|
|
.filter((point) => String(point.floorId) === String(props.floorId) && Boolean(point.positionGltf))
|
|
.map((point) => ({
|
|
point,
|
|
distance: Math.hypot(
|
|
project({ x: point.positionGltf![0], y: point.positionGltf![1], z: point.positionGltf![2] }).x - tapPoint.x,
|
|
project({ x: point.positionGltf![0], y: point.positionGltf![1], z: point.positionGltf![2] }).y - tapPoint.y
|
|
)
|
|
}))
|
|
.sort((left, right) => left.distance - right.distance)[0]
|
|
|
|
if (!candidate || candidate.distance > ROUTE_START_SNAP_RADIUS_PX) {
|
|
emit('routeStartCandidateRejected')
|
|
return
|
|
}
|
|
|
|
emit('routeStartCandidate', {
|
|
routeTargetId: candidate.point.routeTargetId,
|
|
poiId: candidate.point.poiId,
|
|
sourceId: candidate.point.sourceId,
|
|
sourceName: candidate.point.name,
|
|
floorId: candidate.point.floorId,
|
|
positionGltf: candidate.point.positionGltf,
|
|
routeNodeId: candidate.point.routeNodeId,
|
|
coordinateFallback: !candidate.point.routeNodeId
|
|
})
|
|
}
|
|
|
|
const getLabelBounds = (
|
|
point: ProjectedPoint,
|
|
label: string,
|
|
collisionSpacing = 0
|
|
): WeakMapLabelBounds => {
|
|
const textWidth = Math.min(Math.max(label.length * 11 + 8, 36), 126)
|
|
const markerWidth = textWidth + 18
|
|
const markerHeight = 22
|
|
const halfWidth = markerWidth / 2 + collisionSpacing / 2
|
|
const halfHeight = markerHeight / 2 + collisionSpacing / 2
|
|
return {
|
|
left: point.x - halfWidth,
|
|
right: point.x + halfWidth,
|
|
top: point.y - halfHeight,
|
|
bottom: point.y + halfHeight
|
|
}
|
|
}
|
|
|
|
const labelBoundsOverlap = (left: WeakMapLabelBounds, right: WeakMapLabelBounds) => (
|
|
left.left < right.right
|
|
&& left.right > right.left
|
|
&& left.top < right.bottom
|
|
&& left.bottom > right.top
|
|
)
|
|
|
|
const shouldAttemptWeakMapLabel = (candidate: WeakMapPoiCandidate) => {
|
|
if (candidate.required || candidate.isSearchResult || candidate.isOverviewLabel) return true
|
|
return candidate.policy.labelVisible
|
|
&& isPoiVisibilityTierAtLeast(poiVisibilityTier.value, candidate.policy.minLabelTier)
|
|
}
|
|
|
|
const markers = computed<WeakMapMarker[]>(() => {
|
|
const acceptedLabelBounds: WeakMapLabelBounds[] = []
|
|
const labelVisibility = new Map<string, boolean>()
|
|
|
|
for (const candidate of visiblePoiCandidates.value) {
|
|
if (!shouldAttemptWeakMapLabel(candidate)) continue
|
|
const point = project({
|
|
x: candidate.poi.positionGltf![0],
|
|
y: candidate.poi.positionGltf![1],
|
|
z: candidate.poi.positionGltf![2]
|
|
})
|
|
const labelBounds = getLabelBounds(
|
|
point,
|
|
candidate.label,
|
|
candidate.policy.labelCollisionSpacing
|
|
)
|
|
const overlaps = acceptedLabelBounds.some((existing) => labelBoundsOverlap(existing, labelBounds))
|
|
const collisionProtected = candidate.required
|
|
|| candidate.isSearchResult
|
|
|| candidate.isOverviewLabel
|
|
if (!overlaps || collisionProtected) {
|
|
labelVisibility.set(candidate.poi.id, true)
|
|
acceptedLabelBounds.push(labelBounds)
|
|
}
|
|
}
|
|
|
|
return visiblePoiCandidates.value.map((candidate) => {
|
|
const poi = candidate.poi
|
|
const point = project({
|
|
x: poi.positionGltf![0],
|
|
y: poi.positionGltf![1],
|
|
z: poi.positionGltf![2]
|
|
})
|
|
const overviewDefinition = props.sceneView === 'overview'
|
|
? getOverviewMapLabelDefinition(poi)
|
|
: null
|
|
const iconKey = resolvePoiIconKey({
|
|
primaryCategory: poi.primaryCategory,
|
|
iconType: overviewDefinition?.category || poi.iconType,
|
|
name: overviewDefinition?.label || poi.name
|
|
})
|
|
return {
|
|
poi,
|
|
label: candidate.label,
|
|
showLabel: labelVisibility.get(poi.id) || false,
|
|
iconKey,
|
|
iconHref: getPoiIconHref(iconKey),
|
|
style: {
|
|
left: `${point.x}px`,
|
|
top: `${point.y}px`
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
const routeLines = computed(() => {
|
|
const lines: Array<{
|
|
key: string
|
|
floorIds: string
|
|
start: ProjectedPoint
|
|
end: ProjectedPoint
|
|
style: Record<string, string>
|
|
}> = []
|
|
for (const [segmentIndex, segment] of routePolylines.value.entries()) {
|
|
const points = segment.points.map(project)
|
|
for (let index = 1; index < points.length; index += 1) {
|
|
const previous = points[index - 1]
|
|
const current = points[index]
|
|
const dx = current.x - previous.x
|
|
const dy = current.y - previous.y
|
|
const length = Math.hypot(dx, dy)
|
|
if (length < 0.1) continue
|
|
lines.push({
|
|
key: `${segmentIndex}-${index}-${previous.x}-${previous.y}`,
|
|
floorIds: activeCompositeBasemap.value
|
|
? routeCoverageFloorReferences.value.join(',')
|
|
: segment.floorId,
|
|
start: previous,
|
|
end: current,
|
|
style: {
|
|
left: `${(previous.x + current.x) / 2}px`,
|
|
top: `${(previous.y + current.y) / 2}px`,
|
|
width: `${length}px`,
|
|
transform: `translate(-50%, -50%) rotate(${Math.atan2(dy, dx)}rad)`
|
|
}
|
|
})
|
|
}
|
|
}
|
|
return lines
|
|
})
|
|
|
|
watch(() => ({
|
|
routeId: props.routePreview?.id || '',
|
|
active: props.routeNavigationActive,
|
|
floorId: props.floorId,
|
|
basemap: activeBasemap.value?.image.path || ''
|
|
}), ({ routeId, active }) => {
|
|
if (!active || !routeId) {
|
|
stopRouteNavigation()
|
|
return
|
|
}
|
|
|
|
if (routeNavigationRouteId !== routeId) {
|
|
stopRouteNavigation()
|
|
routeNavigationRouteId = routeId
|
|
routeNavigationSegmentIndex.value = 0
|
|
routeNavigationCompletedDistance.value = 0
|
|
}
|
|
|
|
startRouteNavigationForCurrentFloor()
|
|
}, {
|
|
immediate: true,
|
|
flush: 'sync'
|
|
})
|
|
|
|
defineExpose({
|
|
resetCamera,
|
|
zoomCamera,
|
|
getGuideViewportState,
|
|
getCompositeNavigationDiagnostics: () => ({
|
|
activeBasemapFloorCode: activeBasemap.value?.floorCode || '',
|
|
activeBasemapRole: activeCompositeBasemap.value?.role || 'FLOOR',
|
|
coverageFloorIds: activeCompositeBasemap.value?.coverageFloorIds || [props.floorId],
|
|
routeSegmentFloorIds: routePointSegments.value.map((segment) => segment.floorId),
|
|
routeLineCount: routeLines.value.length,
|
|
routeLines: routeLines.value.map((line) => ({
|
|
start: line.start,
|
|
end: line.end,
|
|
floorIds: line.floorIds
|
|
})),
|
|
routeProgressPoint: routeProgressPoint.value ? project(routeProgressPoint.value) : null,
|
|
markers: markers.value.map((marker) => ({
|
|
id: marker.poi.id,
|
|
floorId: marker.poi.floorId,
|
|
point: project({
|
|
x: marker.poi.positionGltf![0],
|
|
y: marker.poi.positionGltf![1],
|
|
z: marker.poi.positionGltf![2]
|
|
})
|
|
}))
|
|
})
|
|
})
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.weak-network-map { position: absolute; inset: 0; z-index: 12; overflow: hidden; background: #eef1ee; }
|
|
.weak-network-status { position: absolute; z-index: 3; top: 12px; left: 12px; right: 12px; min-height: 38px; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 10px; box-sizing: border-box; color: #35443c; font-size: 12px; line-height: 18px; background: rgba(255, 255, 255, 0.94); border: 1px solid #d7dfd7; border-radius: 6px; box-shadow: 0 4px 12px rgba(42, 57, 48, 0.08); }
|
|
.weak-network-retry { flex: 0 0 auto; padding: 3px 7px; color: #155f3b; font-size: 12px; line-height: 18px; border: 1px solid #8db99e; border-radius: 4px; }
|
|
.weak-network-floor-name { position: absolute; z-index: 2; top: 64px; left: 18px; color: #4b6256; font-size: 13px; font-weight: 600; line-height: 20px; }
|
|
.weak-network-floor-rail { position: absolute; z-index: 3; top: 86px; left: 12px; right: 12px; width: auto; white-space: nowrap; }
|
|
.weak-network-floor-rail-inner { display: inline-flex; gap: 6px; padding: 1px; }
|
|
.weak-network-floor-option { display: flex; align-items: center; justify-content: center; min-width: 34px; height: 28px; padding: 0 7px; box-sizing: border-box; color: #4d6255; font-size: 12px; line-height: 18px; background: rgba(255, 255, 255, 0.88); border: 1px solid #d1ddd3; border-radius: 4px; }
|
|
.weak-network-floor-option.active { color: #ffffff; background: #1565c0; border-color: #1565c0; }
|
|
.weak-network-surface { position: absolute; inset: 0; overflow: hidden; background: #e9eee9; touch-action: none; }
|
|
.weak-network-content { position: absolute; inset: 0; }
|
|
.weak-network-basemap { position: absolute; pointer-events: none; }
|
|
.weak-network-neutral-grid { position: absolute; background-color: #e9eee9; background-image: linear-gradient(#d9e2d9 1px, transparent 1px), linear-gradient(90deg, #d9e2d9 1px, transparent 1px); background-size: 24px 24px; }
|
|
.weak-network-route-line { position: absolute; z-index: 1; height: 3px; transform-origin: center; background: #1565c0; border-radius: 2px; box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.72); }
|
|
.weak-network-route-progress { position: absolute; z-index: 4; display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; box-sizing: border-box; transform: translate(-50%, -50%); background: #1565c0; border: 3px solid #ffffff; border-radius: 50%; box-shadow: 0 2px 8px rgba(21, 101, 192, 0.3); }
|
|
.weak-network-route-progress.is-hidden { visibility: hidden; }
|
|
.weak-network-route-progress-arrow { width: 0; height: 0; margin-top: -1px; border-top: 7px solid transparent; border-bottom: 7px solid transparent; border-left: 12px solid #ffffff; transform: translateX(1px); }
|
|
.weak-network-marker { --poi-icon-color: #1565c0; position: absolute; z-index: 2; width: 14px; height: 14px; transform: translate(-50%, -50%); }
|
|
.weak-network-marker-anchor { position: absolute; left: 50%; top: 50%; display: flex; align-items: center; justify-content: center; width: 14px; height: 14px; box-sizing: border-box; color: var(--poi-icon-color); background: rgba(255, 255, 255, 0.84); border: 1px solid currentColor; border-radius: 50%; box-shadow: 0 1px 4px rgba(26, 35, 126, 0.16); transform: translate(-50%, -50%); }
|
|
.weak-network-marker-anchor-icon { display: block; width: 10px; height: 10px; fill: currentColor; }
|
|
.weak-network-marker-label { position: absolute; left: 50%; bottom: 7px; display: flex; align-items: center; min-height: 22px; gap: 3px; padding: 2px 5px; box-sizing: border-box; max-width: min(190px, calc(100vw - 20px)); color: #14205f; background: rgba(255, 255, 255, 0.56); border: 1px solid rgba(26, 35, 126, 0.12); border-radius: 4px; box-shadow: 0 2px 7px rgba(26, 35, 126, 0.1); text-shadow: 0 1px 0 rgba(255, 255, 255, 0.86); font-size: 12px; line-height: 15px; font-weight: 400; transform: translateX(-50%); }
|
|
.weak-network-marker-label::after { position: absolute; left: 50%; bottom: -5px; width: 8px; height: 8px; box-sizing: border-box; content: ''; background: rgba(255, 255, 255, 0.56); border-right: 1px solid rgba(26, 35, 126, 0.12); border-bottom: 1px solid rgba(26, 35, 126, 0.12); transform: translateX(-50%) rotate(45deg); }
|
|
.weak-network-marker-label-icon { position: relative; z-index: 1; display: block; flex: 0 0 16px; width: 16px; height: 16px; box-sizing: border-box; padding: 2px; color: var(--poi-icon-color); background: rgba(255, 255, 255, 0.74); border: 1px solid currentColor; border-radius: 50%; fill: currentColor; }
|
|
.weak-network-marker-label-title { position: relative; z-index: 1; min-width: 0; overflow: hidden; max-width: 100%; text-overflow: ellipsis; white-space: nowrap; }
|
|
.weak-network-marker--selected { z-index: 3; }
|
|
.weak-network-marker--selected .weak-network-marker-anchor { width: 16px; height: 16px; color: #1565c0; box-shadow: 0 0 0 4px rgba(21, 101, 192, 0.16); }
|
|
.weak-network-marker--selected .weak-network-marker-label { color: #0f4f9d; background: rgba(238, 246, 255, 0.72); border-color: rgba(21, 101, 192, 0.48); box-shadow: 0 2px 8px rgba(21, 101, 192, 0.16); }
|
|
.weak-network-marker--selected .weak-network-marker-label::after { background: rgba(238, 246, 255, 0.72); border-color: rgba(21, 101, 192, 0.48); }
|
|
.weak-network-marker[data-poi-icon='exhibition-hall'], .weak-network-marker[data-poi-icon='entrance'] { --poi-icon-color: #356f9c; }
|
|
.weak-network-marker[data-poi-icon='cinema'] { --poi-icon-color: #78649f; }
|
|
.weak-network-marker[data-poi-icon='ticket-office'] { --poi-icon-color: #a7752f; }
|
|
.weak-network-marker[data-poi-icon='dining'] { --poi-icon-color: #b76545; }
|
|
.weak-network-marker[data-poi-icon='shopping'] { --poi-icon-color: #4c8c7d; }
|
|
.weak-network-marker[data-poi-icon='service-center'], .weak-network-marker[data-poi-icon='elevator'], .weak-network-marker[data-poi-icon='escalator'], .weak-network-marker[data-poi-icon='stairs'] { --poi-icon-color: #39739b; }
|
|
.weak-network-marker[data-poi-icon='restroom'], .weak-network-marker[data-poi-icon='nursing-room'] { --poi-icon-color: #6b77a8; }
|
|
.weak-network-marker[data-poi-icon='parking'] { --poi-icon-color: #7b6a9d; }
|
|
.weak-network-marker[data-poi-icon='place'] { --poi-icon-color: #4d8a66; }
|
|
.weak-network-marker[data-poi-icon='road'] { --poi-icon-color: #71808b; }
|
|
.weak-network-marker[data-poi-icon='transport'] { --poi-icon-color: #b16c32; }
|
|
.weak-network-empty { position: absolute; top: 52%; left: 50%; color: #718077; font-size: 13px; transform: translate(-50%, -50%); }
|
|
</style>
|