Files
frontend-miniapp/src/components/map/WeakNetworkGuideFallback.vue
lyf 540bb8627f
Some checks failed
CI / verify (push) Has been cancelled
同步移动端源码并修复小程序嵌入标题
2026-07-28 18:37:40 +08:00

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>