This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<view class="guide-stop-catalog" :class="{ 'host-navigation': shouldUseHostNavigation, 'has-embedded-back': showEmbeddedBack }">
|
||||
<view v-if="showInternalHeader || showEmbeddedBack" class="catalog-header">
|
||||
<view v-if="showInternalBack" class="header-back" @tap="emit('back')">
|
||||
<view class="guide-stop-catalog" :class="{ 'host-navigation': shouldUseHostNavigation }">
|
||||
<view v-if="showInternalHeader" class="catalog-header">
|
||||
<view class="header-back" @tap="emit('back')">
|
||||
<text class="header-back-icon" />
|
||||
<text class="header-back-text">返回</text>
|
||||
</view>
|
||||
<text v-if="showInternalHeader" class="header-title">{{ selectedHallName || '讲解对象' }}</text>
|
||||
<text class="header-title">{{ selectedHallName || '讲解对象' }}</text>
|
||||
</view>
|
||||
|
||||
<scroll-view class="explain-scroll" scroll-y :show-scrollbar="false" @scroll="handleScroll" @scrolltolower="requestMore">
|
||||
@@ -71,18 +71,16 @@ export interface ExplainGuideStopCatalogItem {
|
||||
const props = withDefaults(defineProps<{
|
||||
guideStops?: ExplainGuideStopCatalogItem[]
|
||||
selectedHallName?: string
|
||||
showEmbeddedBack?: boolean
|
||||
loading?: boolean
|
||||
loadingMore?: boolean
|
||||
hasMore?: boolean
|
||||
error?: string
|
||||
loadMoreError?: string
|
||||
}>(), { guideStops: () => [], selectedHallName: '', showEmbeddedBack: false, loading: false, loadingMore: false, hasMore: false, error: '', loadMoreError: '' })
|
||||
}>(), { guideStops: () => [], selectedHallName: '', loading: false, loadingMore: false, hasMore: false, error: '', loadMoreError: '' })
|
||||
|
||||
const emit = defineEmits<{ guideStopClick: [stop: ExplainGuideStopCatalogItem], back: [], retry: [], retryMore: [] }>()
|
||||
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
|
||||
const showInternalHeader = computed(() => !shouldUseHostNavigation.value)
|
||||
const showInternalBack = computed(() => !shouldUseHostNavigation.value || props.showEmbeddedBack)
|
||||
const failedImageIds = ref(new Set<string>())
|
||||
const visibleCoverIds = ref(new Set<string>())
|
||||
const coverSlots = new Map<string, Element>()
|
||||
@@ -157,7 +155,7 @@ const handleScroll = (event: Event) => { const target = event.target as HTMLElem
|
||||
.catalog-header { position: absolute; z-index: 2; top: 0; left: 0; right: 0; height: 64px; display: flex; align-items: center; justify-content: center; background: #f7f9f2; }
|
||||
.header-back { position: absolute; left: 12px; top: 10px; width: 44px; height: 44px; display: flex; align-items: center; }.header-back-icon { width: 20px; height: 20px; position: relative; } .header-back-icon::before { content: ''; position: absolute; left: 3px; top: 4px; width: 10px; height: 10px; border-left: 1.5px solid #262421; border-bottom: 1.5px solid #262421; transform: rotate(45deg); }
|
||||
.header-back-text { display: none; }.header-title { max-width: calc(100% - 152px); padding: 0 8px; font-size: 18px; line-height: 26px; font-weight: 700; color: #262421; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.explain-scroll { height: 100%; padding: 80px 16px calc(16px + env(safe-area-inset-bottom)); box-sizing: border-box; background: #f7f9f2; }.host-navigation .explain-scroll { padding-top: 16px; }.host-navigation.has-embedded-back .explain-scroll { padding-top: 80px; }
|
||||
.explain-scroll { height: 100%; padding: 80px 16px calc(16px + env(safe-area-inset-bottom)); box-sizing: border-box; background: #f7f9f2; }.host-navigation .explain-scroll { padding-top: 16px; }
|
||||
.stop-list { display: flex; width: 100%; min-width: 0; flex-direction: column; gap: 4px; }.stop-card { width: 100%; min-width: 0; height: 80px; padding: 8px 0; display: flex; align-items: center; gap: 12px; box-sizing: border-box; overflow: hidden; border: 0; border-bottom: 1px solid #dbded4; border-radius: 0; background: transparent; }.stop-card:active { background: rgba(227, 235, 201, 0.4); }.stop-cover { flex: 0 0 64px; width: 64px; height: 64px; overflow: hidden; border-radius: 6px; background: #f5f5ed; }.stop-cover-fallback { font-size: 22px; line-height: 28px; color: #a9b29b; }.stop-copy { min-width: 0; display: flex; flex: 1; overflow: hidden; }.stop-name { display: -webkit-box; max-height: 40px; font-size: 14px; line-height: 20px; font-weight: 700; color: #262421; overflow: hidden; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }.stop-status { flex: 0 0 44px; width: 44px; height: 24px; display: flex; align-items: center; justify-content: center; border-radius: 12px; background: #e3ebc9; }.stop-status-text { font-size: 11px; line-height: 16px; color: #262421; white-space: nowrap; }
|
||||
.state-block { margin: 34px 0; padding: 22px 16px; box-sizing: border-box; text-align: center; border: 1px solid #e4e6df; border-radius: 8px; background: #fff; }.state-block.error { border-color: #e5c2c2; background: #fff7f7; }.state-title,.state-desc { display: block; }.state-title { font-size: 15px; line-height: 21px; font-weight: 700; color: #141412; }.state-desc { margin-top: 6px; font-size: 12px; line-height: 18px; color: #68725d; }.state-retry { margin-top: 12px; padding: 0 14px; font-size: 13px; line-height: 32px; color: #141412; background: #e0df00; border: 0; border-radius: 6px; }.load-more-state { min-height: 52px; padding: 12px 16px calc(12px + env(safe-area-inset-bottom)); text-align: center; }
|
||||
</style>
|
||||
|
||||
@@ -53,13 +53,19 @@
|
||||
v-if="!isHallPreviewError(hall.id)"
|
||||
class="hall-full-card-image"
|
||||
:src="hallPreviewUrl(hall)"
|
||||
mode="aspectFill"
|
||||
mode="aspectFit"
|
||||
:lazy-load="isHallPreviewLazy(index)"
|
||||
@load="handleHallPreviewLoad(hall.id)"
|
||||
@error="handleHallPreviewError(hall.id)"
|
||||
/>
|
||||
<view v-else class="hall-full-card-fallback">
|
||||
<text class="hall-name">{{ hall.name }}</text>
|
||||
<view v-if="isHallPreviewError(hall.id)" class="hall-full-card-fallback">
|
||||
<text class="hall-thumb-text">{{ hallIconText(hall.name) }}</text>
|
||||
</view>
|
||||
<view class="hall-full-card-copy">
|
||||
<text class="hall-full-card-name">{{ hall.name }}</text>
|
||||
<text v-if="hallCardTheme(hall).englishName" class="hall-full-card-english">
|
||||
{{ hallCardTheme(hall).englishName }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<template v-else>
|
||||
@@ -253,7 +259,7 @@ const emit = defineEmits<{
|
||||
retryMore: []
|
||||
}>()
|
||||
|
||||
const HALL_PREVIEW_BASE = '/static/icons/halls/custom-cards'
|
||||
const HALL_PREVIEW_BASE = '/static/icons/halls/portal-icons'
|
||||
const HALL_PREVIEW_EAGER_COUNT = 4
|
||||
const loadedHallPreviewIds = ref(new Set<string>())
|
||||
const failedHallPreviewIds = ref(new Set<string>())
|
||||
@@ -271,15 +277,15 @@ const hallPreviewMap: Record<string, string> = {
|
||||
}
|
||||
|
||||
const hallCardThemeMap: Record<string, { color: string; englishName: string }> = {
|
||||
宇宙厅: { color: '#65d2e4', englishName: '' },
|
||||
地球厅: { color: '#b8e51b', englishName: '' },
|
||||
演化厅: { color: '#cba7ff', englishName: '' },
|
||||
恐龙厅: { color: '#d9b06c', englishName: '' },
|
||||
人类厅: { color: '#e9ea16', englishName: '' },
|
||||
动物厅: { color: '#20c9c0', englishName: '' },
|
||||
生物厅: { color: '#20c9c0', englishName: '' },
|
||||
生态厅: { color: '#94e7be', englishName: '' },
|
||||
家园厅: { color: '#ffc6a5', englishName: '' }
|
||||
宇宙厅: { color: '#5ed0e4', englishName: 'Cosmos Hall' },
|
||||
地球厅: { color: '#94d40b', englishName: 'Earth Hall' },
|
||||
演化厅: { color: '#bc9afb', englishName: 'Evolution Hall' },
|
||||
恐龙厅: { color: '#bb965a', englishName: 'Dinosaur Hall' },
|
||||
人类厅: { color: '#e0e100', englishName: 'Human Hall' },
|
||||
动物厅: { color: '#00c3b3', englishName: 'Biology Hall' },
|
||||
生物厅: { color: '#00c3b3', englishName: 'Biology Hall' },
|
||||
生态厅: { color: '#87e0b0', englishName: 'Ecology Hall' },
|
||||
家园厅: { color: '#ffbe9e', englishName: 'Homeland Hall' }
|
||||
}
|
||||
|
||||
const filteredHalls = computed(() => props.halls)
|
||||
@@ -935,9 +941,13 @@ const handleBack = () => {
|
||||
}
|
||||
|
||||
.hall-full-card-image {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 12px;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: 78%;
|
||||
height: calc(100% - 66px);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.hall-full-card-fallback {
|
||||
@@ -945,10 +955,47 @@ const handleBack = () => {
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
justify-content: center;
|
||||
padding-bottom: 46px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.hall-full-card-copy {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: 9px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hall-full-card-name,
|
||||
.hall-full-card-english {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hall-full-card-name {
|
||||
font-size: 19px;
|
||||
line-height: 25px;
|
||||
font-weight: 500;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.hall-full-card-english {
|
||||
margin-top: 0;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: 300;
|
||||
color: rgba(21, 23, 19, 0.72);
|
||||
}
|
||||
|
||||
.hall-overview-copy {
|
||||
top: 24px;
|
||||
}
|
||||
|
||||
608
src/components/map/TencentJsMap.vue
Normal file
608
src/components/map/TencentJsMap.vue
Normal file
@@ -0,0 +1,608 @@
|
||||
<template>
|
||||
<view class="tencent-js-map">
|
||||
<view ref="mapElementRef" class="tencent-js-map-canvas"></view>
|
||||
<view v-if="mapLoadState !== 'ready'" class="tencent-js-map-status">
|
||||
<view v-if="mapLoadState === 'loading'" class="map-loading-indicator"></view>
|
||||
<text class="map-status-text">{{ mapLoadState === 'error' ? '来馆地图加载失败' : '正在加载来馆地图' }}</text>
|
||||
<view v-if="mapLoadState === 'error'" class="map-retry-button" @tap="retryMapLoad">
|
||||
<text class="map-retry-text">重新加载</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
canEnterBuildingSemanticMap,
|
||||
OUTDOOR_BUILDING_EXIT_ZOOM
|
||||
} from '@/domain/guideSemanticZoom'
|
||||
import { MUSEUM_LOCATION } from '@/config/museum'
|
||||
import { getOutdoorMapProfile } from '@/services/outdoor/OutdoorMapAppService'
|
||||
import {
|
||||
loadTencentJsMapScript,
|
||||
type TencentLatLng,
|
||||
type TencentMapApi,
|
||||
type TencentMapInstance
|
||||
} from '@/services/outdoor/TencentJsMapLoader'
|
||||
|
||||
interface Coordinate {
|
||||
latitude: number
|
||||
longitude: number
|
||||
}
|
||||
|
||||
interface OutdoorMapMarker extends Coordinate {
|
||||
id: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
markerKind?: 'bus' | 'metro' | 'parking' | 'museum' | 'user'
|
||||
}
|
||||
|
||||
interface OutdoorMapPolyline {
|
||||
points: Coordinate[]
|
||||
color: string
|
||||
width: number
|
||||
}
|
||||
|
||||
interface TencentMapEvent {
|
||||
latLng?: TencentLatLng
|
||||
geometry?: { id?: string | number }
|
||||
}
|
||||
|
||||
interface TencentLayer {
|
||||
setGeometries: (geometries: unknown[]) => void
|
||||
setMap?: (map: TencentMapInstance | null) => void
|
||||
on?: (event: string, listener: (event: TencentMapEvent) => void) => void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
polylines?: OutdoorMapPolyline[]
|
||||
outdoorMarkers?: OutdoorMapMarker[]
|
||||
activeOutdoorMarkerId?: string
|
||||
outdoorFocusOffsetY?: number
|
||||
semanticZoomEnabled?: boolean
|
||||
}>(), {
|
||||
polylines: () => [],
|
||||
outdoorMarkers: () => [],
|
||||
activeOutdoorMarkerId: '',
|
||||
outdoorFocusOffsetY: 0,
|
||||
semanticZoomEnabled: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
outdoorMarkerClick: [markerId: string]
|
||||
mapTap: [location: Coordinate]
|
||||
semanticEnterBuilding: [viewport: Coordinate & { zoom: number }]
|
||||
unavailable: [message: string]
|
||||
}>()
|
||||
|
||||
const MUSEUM_CENTER: Coordinate = MUSEUM_LOCATION
|
||||
const USER_MARKER_ID = 'outdoor-user-location'
|
||||
const mapElementRef = ref<unknown>(null)
|
||||
const userLocation = ref<Coordinate | null>(null)
|
||||
const mapLoadState = ref<'loading' | 'ready' | 'error'>('loading')
|
||||
let map: TencentMapInstance | null = null
|
||||
let mapApi: TencentMapApi | null = null
|
||||
let markerLayer: TencentLayer | null = null
|
||||
let polylineLayer: TencentLayer | null = null
|
||||
let mapReady = false
|
||||
let activeInitializationId = 0
|
||||
let destroyed = false
|
||||
let semanticViewportTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let museumCenteredZoomTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let routeViewportTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let museumCenteredZoomRequestId = 0
|
||||
let semanticBuildingRequested = false
|
||||
|
||||
const activeMarker = computed(() => props.outdoorMarkers.find((marker) => marker.id === props.activeOutdoorMarkerId) || null)
|
||||
|
||||
const resolveMapContainer = () => {
|
||||
const refValue = mapElementRef.value
|
||||
if (refValue instanceof HTMLElement) return refValue
|
||||
|
||||
// uni-app H5 may return the built-in <view> component proxy instead of its
|
||||
// native element. Tencent JS Map requires a real HTMLElement container.
|
||||
if (refValue && typeof refValue === 'object' && '$el' in refValue) {
|
||||
const element = (refValue as { $el?: unknown }).$el
|
||||
if (element instanceof HTMLElement) return element
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const waitForMapContainerLayout = async (container: HTMLElement) => {
|
||||
await nextTick()
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))
|
||||
if (container.clientWidth <= 0 || container.clientHeight <= 0) {
|
||||
throw new Error('腾讯地图容器尚未完成布局')
|
||||
}
|
||||
}
|
||||
|
||||
const isValidCoordinate = (coordinate: Coordinate) => {
|
||||
const latitude = Number(coordinate.latitude)
|
||||
const longitude = Number(coordinate.longitude)
|
||||
return Number.isFinite(latitude)
|
||||
&& Number.isFinite(longitude)
|
||||
&& latitude >= -90
|
||||
&& latitude <= 90
|
||||
&& longitude >= -180
|
||||
&& longitude <= 180
|
||||
}
|
||||
|
||||
const getCoordinateValue = (location: TencentLatLng, key: 'latitude' | 'longitude') => {
|
||||
if (key === 'latitude') return location.getLat?.() ?? location.lat
|
||||
return location.getLng?.() ?? location.lng
|
||||
}
|
||||
|
||||
const clearSemanticViewportCheck = () => {
|
||||
if (semanticViewportTimer) {
|
||||
clearTimeout(semanticViewportTimer)
|
||||
semanticViewportTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const clearMuseumCenteredZoom = () => {
|
||||
museumCenteredZoomRequestId += 1
|
||||
if (museumCenteredZoomTimer) {
|
||||
clearTimeout(museumCenteredZoomTimer)
|
||||
museumCenteredZoomTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const clearRouteViewportFit = () => {
|
||||
if (routeViewportTimer) {
|
||||
clearTimeout(routeViewportTimer)
|
||||
routeViewportTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const centerMapOnGuideFocus = () => {
|
||||
if (!map || !mapReady) return
|
||||
// Browsing starts at the museum. Once a visitor chooses a transport or
|
||||
// parking destination, the same controls must keep that destination stable.
|
||||
map.setCenter(toLatLng(activeMarker.value || MUSEUM_CENTER))
|
||||
}
|
||||
|
||||
const requestSemanticBuildingEntry = (viewportOverride?: Coordinate & { zoom: number }) => {
|
||||
if (!props.semanticZoomEnabled || semanticBuildingRequested || !map || !mapReady) return
|
||||
|
||||
const center = map.getCenter?.()
|
||||
const zoom = map.getZoom?.()
|
||||
if (!viewportOverride && (!center || zoom === undefined)) return
|
||||
|
||||
const latitude = viewportOverride?.latitude ?? getCoordinateValue(center!, 'latitude')
|
||||
const longitude = viewportOverride?.longitude ?? getCoordinateValue(center!, 'longitude')
|
||||
const viewportZoom = viewportOverride?.zoom ?? zoom
|
||||
if (latitude === undefined || longitude === undefined || viewportZoom === undefined) return
|
||||
|
||||
const viewport = { latitude, longitude, zoom: viewportZoom }
|
||||
if (!canEnterBuildingSemanticMap(viewport, MUSEUM_LOCATION)) return
|
||||
|
||||
semanticBuildingRequested = true
|
||||
emit('semanticEnterBuilding', viewport)
|
||||
}
|
||||
|
||||
const scheduleSemanticBuildingEntry = () => {
|
||||
clearSemanticViewportCheck()
|
||||
if (!props.semanticZoomEnabled || semanticBuildingRequested) return
|
||||
|
||||
semanticViewportTimer = setTimeout(() => {
|
||||
semanticViewportTimer = null
|
||||
requestSemanticBuildingEntry()
|
||||
}, 140)
|
||||
}
|
||||
|
||||
const toLatLng = (coordinate: Coordinate) => {
|
||||
if (!mapApi) throw new Error('腾讯 JS 地图尚未就绪')
|
||||
if (!isValidCoordinate(coordinate)) {
|
||||
throw new Error('腾讯地图坐标无效')
|
||||
}
|
||||
return new mapApi.LatLng(Number(coordinate.latitude), Number(coordinate.longitude))
|
||||
}
|
||||
|
||||
const markerStyle = (src: string, width: number, height: number) => {
|
||||
if (!mapApi) return null
|
||||
return new mapApi.MarkerStyle({
|
||||
width,
|
||||
height,
|
||||
anchor: { x: Math.round(width / 2), y: height },
|
||||
src
|
||||
})
|
||||
}
|
||||
|
||||
const renderMarkers = () => {
|
||||
if (!mapApi || !map || !mapReady) return
|
||||
|
||||
const candidates = [
|
||||
...props.outdoorMarkers.map((marker) => ({
|
||||
id: marker.id,
|
||||
styleId: marker.markerKind || (marker.id === props.activeOutdoorMarkerId ? 'active' : 'poi'),
|
||||
coordinate: marker,
|
||||
properties: { title: marker.title, subtitle: marker.subtitle || '' }
|
||||
})),
|
||||
...(userLocation.value ? [{
|
||||
id: USER_MARKER_ID,
|
||||
styleId: 'user',
|
||||
coordinate: userLocation.value,
|
||||
properties: { title: '我的位置' }
|
||||
}] : [])
|
||||
]
|
||||
const markers = candidates
|
||||
.filter((marker) => isValidCoordinate(marker.coordinate))
|
||||
.map((marker) => ({ ...marker, position: toLatLng(marker.coordinate) }))
|
||||
|
||||
if (!markerLayer) {
|
||||
const layer = new mapApi.MultiMarker({
|
||||
map,
|
||||
styles: {
|
||||
poi: markerStyle('/static/icons/marker-location.svg', 30, 36),
|
||||
active: markerStyle('/static/icons/marker-selected.svg', 32, 32),
|
||||
museum: markerStyle('/static/icons/marker-selected.svg', 32, 32),
|
||||
bus: markerStyle('/static/icons/marker-bus.svg', 36, 44),
|
||||
metro: markerStyle('/static/icons/marker-metro.svg', 36, 44),
|
||||
parking: markerStyle('/static/icons/marker-parking.svg', 36, 44),
|
||||
user: markerStyle('/static/icons/marker-user-location.svg', 36, 42)
|
||||
},
|
||||
geometries: markers
|
||||
}) as TencentLayer
|
||||
markerLayer = layer
|
||||
layer.on?.('click', (event) => {
|
||||
const markerId = String(event.geometry?.id || '')
|
||||
if (props.outdoorMarkers.some((marker) => marker.id === markerId)) {
|
||||
emit('outdoorMarkerClick', markerId)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
markerLayer.setGeometries(markers)
|
||||
}
|
||||
|
||||
const renderPolylines = () => {
|
||||
if (!mapApi || !map || !mapReady) return
|
||||
|
||||
const routes = props.polylines
|
||||
.filter((polyline) => polyline.points.length > 1)
|
||||
.map((polyline, index) => ({
|
||||
id: `route-${index}`,
|
||||
styleId: 'route',
|
||||
paths: polyline.points.map(toLatLng)
|
||||
}))
|
||||
|
||||
if (!polylineLayer) {
|
||||
polylineLayer = new mapApi.MultiPolyline({
|
||||
map,
|
||||
styles: {
|
||||
route: new mapApi.PolylineStyle({
|
||||
color: '#1565C0',
|
||||
width: 6,
|
||||
borderWidth: 2,
|
||||
borderColor: '#FFFFFF',
|
||||
lineCap: 'round'
|
||||
})
|
||||
},
|
||||
geometries: routes
|
||||
}) as TencentLayer
|
||||
return
|
||||
}
|
||||
|
||||
polylineLayer.setGeometries(routes)
|
||||
}
|
||||
|
||||
const longitudeToWorldX = (longitude: number) => (longitude + 180) / 360
|
||||
|
||||
const latitudeToWorldY = (latitude: number) => {
|
||||
const radians = latitude * Math.PI / 180
|
||||
const sine = Math.min(Math.max(Math.sin(radians), -0.9999), 0.9999)
|
||||
return 0.5 - Math.log((1 + sine) / (1 - sine)) / (4 * Math.PI)
|
||||
}
|
||||
|
||||
const worldYToLatitude = (worldY: number) => (
|
||||
Math.atan(Math.sinh(Math.PI * (1 - 2 * worldY))) * 180 / Math.PI
|
||||
)
|
||||
|
||||
const fitRouteInVisibleMapArea = () => {
|
||||
if (!map || !mapReady || !map.setZoom) return
|
||||
const points = props.polylines.flatMap((polyline) => polyline.points)
|
||||
.filter(isValidCoordinate)
|
||||
if (points.length < 2) return
|
||||
|
||||
const mapContainer = resolveMapContainer()
|
||||
if (!mapContainer || mapContainer.clientWidth <= 0 || mapContainer.clientHeight <= 0) return
|
||||
|
||||
const longitudes = points.map((point) => point.longitude)
|
||||
const worldYs = points.map((point) => latitudeToWorldY(point.latitude))
|
||||
const minLongitude = Math.min(...longitudes)
|
||||
const maxLongitude = Math.max(...longitudes)
|
||||
const minWorldY = Math.min(...worldYs)
|
||||
const maxWorldY = Math.max(...worldYs)
|
||||
const longitudeSpan = Math.max(longitudeToWorldX(maxLongitude) - longitudeToWorldX(minLongitude), 1e-6)
|
||||
const latitudeSpan = Math.max(maxWorldY - minWorldY, 1e-6)
|
||||
const usableWidth = Math.max(mapContainer.clientWidth * 0.82, 1)
|
||||
// The route panel covers the lower part of the map. Fit the path into the
|
||||
// remaining upper canvas so both start and destination stay visible.
|
||||
const usableHeight = Math.max(
|
||||
mapContainer.clientHeight - Math.max(props.outdoorFocusOffsetY, 0) * 1.65,
|
||||
mapContainer.clientHeight * 0.42
|
||||
)
|
||||
const zoom = Math.max(4, Math.min(18, Math.floor(Math.min(
|
||||
Math.log2(usableWidth / (256 * longitudeSpan)),
|
||||
Math.log2(usableHeight * 0.84 / (256 * latitudeSpan))
|
||||
))))
|
||||
const center: Coordinate = {
|
||||
latitude: worldYToLatitude((minWorldY + maxWorldY) / 2),
|
||||
longitude: (minLongitude + maxLongitude) / 2
|
||||
}
|
||||
|
||||
clearMuseumCenteredZoom()
|
||||
map.setCenter(toLatLng(center))
|
||||
map.setZoom(zoom)
|
||||
clearRouteViewportFit()
|
||||
routeViewportTimer = setTimeout(() => {
|
||||
routeViewportTimer = null
|
||||
if (!map || !mapReady) return
|
||||
map.setCenter(toLatLng(center))
|
||||
if (props.outdoorFocusOffsetY > 0) {
|
||||
map.panBy?.({ x: 0, y: Math.min(props.outdoorFocusOffsetY, mapContainer.clientHeight * 0.22) })
|
||||
}
|
||||
}, 260)
|
||||
}
|
||||
|
||||
const centerInVisibleMapArea = (coordinate: Coordinate, offsetY = props.outdoorFocusOffsetY) => {
|
||||
if (!map || !mapReady || !isValidCoordinate(coordinate)) return
|
||||
map.setCenter(toLatLng(coordinate))
|
||||
if (!offsetY || !map.panBy) return
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!map || !mapReady) return
|
||||
// Keep the focused point above the arrival panel instead of under it.
|
||||
map.panBy?.({ x: 0, y: Math.abs(offsetY) })
|
||||
})
|
||||
}
|
||||
|
||||
const syncActiveFocus = () => {
|
||||
if (!map || !mapReady || !activeMarker.value || !isValidCoordinate(activeMarker.value)) return
|
||||
centerInVisibleMapArea(activeMarker.value)
|
||||
}
|
||||
|
||||
const updateUserLocation = (location: Coordinate) => {
|
||||
if (!isValidCoordinate(location)) return
|
||||
userLocation.value = location
|
||||
renderMarkers()
|
||||
centerInVisibleMapArea(location)
|
||||
}
|
||||
|
||||
const clearUserLocation = () => {
|
||||
userLocation.value = null
|
||||
renderMarkers()
|
||||
}
|
||||
|
||||
const moveTo = (location: Coordinate) => {
|
||||
if (!isValidCoordinate(location)) return
|
||||
centerInVisibleMapArea(location)
|
||||
}
|
||||
|
||||
const zoom = (direction: 'in' | 'out') => {
|
||||
if (!map || !mapReady || !map.getZoom || !map.setZoom) return
|
||||
const nextZoom = Math.max(4, Math.min(20, map.getZoom() + (direction === 'in' ? 1 : -1)))
|
||||
clearMuseumCenteredZoom()
|
||||
const requestId = museumCenteredZoomRequestId
|
||||
|
||||
// The guide controls are museum-centric. Tencent GL completes a zoom after
|
||||
// setZoom returns, so reapply the center after that animation rather than
|
||||
// trusting its transient event viewport. Pan and pinch remain unrestricted.
|
||||
centerMapOnGuideFocus()
|
||||
map.setZoom(nextZoom)
|
||||
requestAnimationFrame(() => {
|
||||
if (!map || !mapReady || requestId !== museumCenteredZoomRequestId) return
|
||||
centerMapOnGuideFocus()
|
||||
museumCenteredZoomTimer = setTimeout(() => {
|
||||
museumCenteredZoomTimer = null
|
||||
if (!map || !mapReady || requestId !== museumCenteredZoomRequestId) return
|
||||
centerMapOnGuideFocus()
|
||||
// Route to the building only when the actual post-zoom viewport is
|
||||
// around the museum. A selected bus stop or parking lot must stay on
|
||||
// the outdoor map even if the visitor uses the shared zoom controls.
|
||||
requestSemanticBuildingEntry()
|
||||
}, 260)
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({ updateUserLocation, clearUserLocation, moveTo, zoom })
|
||||
|
||||
watch(
|
||||
() => [props.outdoorMarkers, props.activeOutdoorMarkerId, userLocation.value] as const,
|
||||
() => {
|
||||
renderMarkers()
|
||||
syncActiveFocus()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.polylines,
|
||||
() => {
|
||||
renderPolylines()
|
||||
fitRouteInVisibleMapArea()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.outdoorFocusOffsetY,
|
||||
syncActiveFocus
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.semanticZoomEnabled,
|
||||
(enabled) => {
|
||||
if (!enabled) {
|
||||
semanticBuildingRequested = false
|
||||
clearSemanticViewportCheck()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const clearMapInstance = () => {
|
||||
clearSemanticViewportCheck()
|
||||
clearMuseumCenteredZoom()
|
||||
clearRouteViewportFit()
|
||||
semanticBuildingRequested = false
|
||||
mapReady = false
|
||||
markerLayer?.setMap?.(null)
|
||||
polylineLayer?.setMap?.(null)
|
||||
markerLayer = null
|
||||
polylineLayer = null
|
||||
map?.destroy()
|
||||
map = null
|
||||
mapApi = null
|
||||
}
|
||||
|
||||
const initializeMap = async () => {
|
||||
const initializationId = ++activeInitializationId
|
||||
mapLoadState.value = 'loading'
|
||||
clearMapInstance()
|
||||
|
||||
try {
|
||||
const profile = await getOutdoorMapProfile()
|
||||
const mapContainer = resolveMapContainer()
|
||||
if (!profile.available || !profile.jsMapEnabled || !profile.jsMapKey || !mapContainer) {
|
||||
throw new Error(profile.message || '来馆地图服务暂不可用')
|
||||
}
|
||||
|
||||
const loadedMapApi = await loadTencentJsMapScript(profile.jsMapKey)
|
||||
if (destroyed || initializationId !== activeInitializationId) return
|
||||
mapApi = loadedMapApi
|
||||
await waitForMapContainerLayout(mapContainer)
|
||||
if (destroyed || initializationId !== activeInitializationId) return
|
||||
map = new mapApi.Map(mapContainer, {
|
||||
center: toLatLng(MUSEUM_CENTER),
|
||||
zoom: OUTDOOR_BUILDING_EXIT_ZOOM,
|
||||
zoomControl: false,
|
||||
rotationControl: false,
|
||||
scaleControl: false,
|
||||
mapTypeControl: false
|
||||
})
|
||||
map.on('click', (event) => {
|
||||
if (!event.latLng) return
|
||||
const latitude = getCoordinateValue(event.latLng, 'latitude')
|
||||
const longitude = getCoordinateValue(event.latLng, 'longitude')
|
||||
if (latitude === undefined || longitude === undefined) return
|
||||
emit('mapTap', { latitude, longitude })
|
||||
})
|
||||
map.on('zoom_changed', scheduleSemanticBuildingEntry)
|
||||
map.on('center_changed', scheduleSemanticBuildingEntry)
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))
|
||||
if (destroyed || initializationId !== activeInitializationId) return
|
||||
mapReady = true
|
||||
centerMapOnGuideFocus()
|
||||
renderMarkers()
|
||||
renderPolylines()
|
||||
syncActiveFocus()
|
||||
fitRouteInVisibleMapArea()
|
||||
mapLoadState.value = 'ready'
|
||||
} catch (error) {
|
||||
if (destroyed || initializationId !== activeInitializationId) return
|
||||
clearMapInstance()
|
||||
mapLoadState.value = 'error'
|
||||
const message = error instanceof Error ? error.message : '腾讯 JS 地图初始化失败'
|
||||
console.error('腾讯 JS 地图初始化失败:', error)
|
||||
emit('unavailable', message)
|
||||
}
|
||||
}
|
||||
|
||||
const retryMapLoad = () => {
|
||||
void initializeMap()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
destroyed = false
|
||||
void initializeMap()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
destroyed = true
|
||||
activeInitializationId += 1
|
||||
clearMapInstance()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tencent-js-map,
|
||||
.tencent-js-map-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--museum-bg-map);
|
||||
}
|
||||
|
||||
.tencent-js-map {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Keep the guide's 46px control stack as the only zoom affordance.
|
||||
.tencent-js-map :deep(.tmap-scale-control),
|
||||
.tencent-js-map :deep(.tmap-zoom-control) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
// Tencent JS Map renders its rotation compass independently of the map option.
|
||||
// The guide keeps north-up, so the control has no user-facing purpose here.
|
||||
.tencent-js-map :deep(.rotate-circle) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.tencent-js-map-status {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
background: rgba(236, 239, 241, 0.94);
|
||||
}
|
||||
|
||||
.map-loading-indicator {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
box-sizing: border-box;
|
||||
border: 3px solid rgba(21, 101, 192, 0.2);
|
||||
border-top-color: var(--museum-accent);
|
||||
border-radius: 50%;
|
||||
animation: tencent-map-loading 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.map-status-text {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
color: var(--museum-text-secondary);
|
||||
}
|
||||
|
||||
.map-retry-button {
|
||||
min-width: 88px;
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--museum-accent);
|
||||
border-radius: var(--radius-button);
|
||||
background: var(--museum-bg-surface);
|
||||
}
|
||||
|
||||
.map-retry-text {
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--museum-accent);
|
||||
}
|
||||
|
||||
@keyframes tencent-map-loading {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -164,6 +164,10 @@ import {
|
||||
createGuideObliqueReturnPose,
|
||||
createGuideTopViewPose
|
||||
} from '@/domain/guideTopView'
|
||||
import {
|
||||
hasReachedOutdoorExitBoundary,
|
||||
OUTDOOR_OUTWARD_INTENT_GRACE_MS
|
||||
} from '@/domain/guideSemanticZoom'
|
||||
import {
|
||||
getOverviewMapLabelDefinition,
|
||||
isOverviewMapLabelMatch,
|
||||
@@ -193,6 +197,7 @@ import {
|
||||
type PoiVisibilityTier
|
||||
} from '@/domain/poiDisplay'
|
||||
import {
|
||||
ensurePoiIconSprite,
|
||||
getPoiIconHref,
|
||||
resolvePoiIconKey,
|
||||
type PoiIconKey
|
||||
@@ -306,7 +311,9 @@ const SGS_VISUAL_RENDER_CONFIG = {
|
||||
framing: {
|
||||
overviewScreenOffsetRatio: new THREE.Vector2(-0.045, -0.095),
|
||||
overviewReferenceVerticalOffset: 44,
|
||||
overviewMaxDistanceFactor: 1.35,
|
||||
// Keep two to three zoom-out levels of the 3D building overview before
|
||||
// the semantic boundary hands the visitor to the Tencent outdoor map.
|
||||
overviewMaxDistanceFactor: 1.85,
|
||||
// The left floor rail and right zoom tools make the geometric center look
|
||||
// left-biased. Shift the floor framing right into the visible map area.
|
||||
floorScreenOffsetRatio: new THREE.Vector2(-0.05, -0.055)
|
||||
@@ -712,6 +719,7 @@ const props = withDefaults(defineProps<{
|
||||
autoSwitch?: boolean
|
||||
disableAutoExit?: boolean
|
||||
autoSwitchCooldown?: number
|
||||
semanticZoomEnabled?: boolean
|
||||
}>(), {
|
||||
assetBaseUrl: '',
|
||||
activeFloor: '',
|
||||
@@ -735,7 +743,8 @@ const props = withDefaults(defineProps<{
|
||||
sceneViewport: null,
|
||||
autoSwitch: true,
|
||||
disableAutoExit: false,
|
||||
autoSwitchCooldown: 1500
|
||||
autoSwitchCooldown: 1500,
|
||||
semanticZoomEnabled: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -760,6 +769,7 @@ const emit = defineEmits<{
|
||||
sceneRevision: number
|
||||
}]
|
||||
autoSwitchBlocked: [reason: { reason: 'loading-locked' | 'cooldown' | 'disabled' }]
|
||||
semanticExteriorExit: []
|
||||
initialModelProgress: [event: InitialModelProgressEvent]
|
||||
initialModelReady: [event: { view: ViewMode; floorId?: string; elapsedMs?: number }]
|
||||
initialModelFailed: [event: {
|
||||
@@ -983,6 +993,9 @@ let hasPendingManualModelAdjustment = false
|
||||
let activeAutoSwitchInputSource: GuideAutoSwitchInputSource = 'gesture'
|
||||
let isProgrammaticCameraChange = false
|
||||
let hasActiveUserCameraGesture = false
|
||||
let overviewGestureStartDistance = 0
|
||||
let semanticOutwardZoomIntentAt = 0
|
||||
let semanticExteriorExitRequested = false
|
||||
let programmaticCameraTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let cameraTween: CameraTweenState | null = null
|
||||
let buttonAutoSwitchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -2572,6 +2585,7 @@ const handleControlStart = () => {
|
||||
const distance = controls?.getDistance()
|
||||
if (!isProgrammaticCameraChange && typeof distance === 'number' && Number.isFinite(distance)) {
|
||||
hasActiveUserCameraGesture = true
|
||||
overviewGestureStartDistance = activeView.value === 'overview' ? distance : 0
|
||||
autoSwitchStateMachine.beginInput(distance, activeAutoSwitchInputSource)
|
||||
}
|
||||
if (!isProgrammaticCameraChange) {
|
||||
@@ -2582,7 +2596,9 @@ const handleControlStart = () => {
|
||||
}
|
||||
|
||||
const handleControlEnd = () => {
|
||||
requestSemanticExteriorExitFromGesture()
|
||||
hasActiveUserCameraGesture = false
|
||||
overviewGestureStartDistance = 0
|
||||
activeAutoSwitchInputSource = 'gesture'
|
||||
scheduleModelAdjustIdleReport()
|
||||
}
|
||||
@@ -2797,6 +2813,7 @@ const waitForContainer = async () => {
|
||||
}
|
||||
|
||||
const initThree = async () => {
|
||||
ensurePoiIconSprite()
|
||||
const container = await waitForContainer()
|
||||
if (!container) {
|
||||
throw new Error('3D 容器未就绪')
|
||||
@@ -5144,9 +5161,46 @@ const handleWheelIntent = (event: WheelEvent) => {
|
||||
if (!Number.isFinite(distance)) return
|
||||
|
||||
activeAutoSwitchInputSource = 'wheel'
|
||||
if (event.deltaY > 0) semanticOutwardZoomIntentAt = Date.now()
|
||||
autoSwitchStateMachine.beginInput(distance, 'wheel')
|
||||
}
|
||||
|
||||
const canRequestSemanticExteriorExit = () => (
|
||||
props.semanticZoomEnabled
|
||||
&& !semanticExteriorExitRequested
|
||||
&& !weakNetworkFallbackActive.value
|
||||
&& !isLoading.value
|
||||
&& !isProgrammaticCameraChange
|
||||
&& Boolean(controls)
|
||||
&& activeView.value === 'overview'
|
||||
&& !props.showRoute
|
||||
&& !props.routeNavigationActive
|
||||
&& !props.routeStartSelectionActive
|
||||
&& !props.targetFocus
|
||||
&& !selectedPOI.value
|
||||
)
|
||||
|
||||
const requestSemanticExteriorExit = () => {
|
||||
if (!canRequestSemanticExteriorExit()) return false
|
||||
semanticExteriorExitRequested = true
|
||||
emit('semanticExteriorExit')
|
||||
return true
|
||||
}
|
||||
|
||||
const requestSemanticExteriorExitFromGesture = () => {
|
||||
if (!canRequestSemanticExteriorExit() || !controls || !overviewGestureStartDistance) return
|
||||
|
||||
const currentDistance = controls.getDistance()
|
||||
const maxDistance = controls.maxDistance
|
||||
const outwardGesture = currentDistance > overviewGestureStartDistance * 1.025
|
||||
const atExteriorBoundary = hasReachedOutdoorExitBoundary(currentDistance, maxDistance)
|
||||
const recentWheelIntent = Date.now() - semanticOutwardZoomIntentAt < OUTDOOR_OUTWARD_INTENT_GRACE_MS
|
||||
|
||||
if (atExteriorBoundary && (outwardGesture || recentWheelIntent)) {
|
||||
requestSemanticExteriorExit()
|
||||
}
|
||||
}
|
||||
|
||||
const canRunAutoSwitch = (direction: GuideAutoSwitchDirection) => {
|
||||
if (
|
||||
!props.autoSwitch
|
||||
@@ -6262,15 +6316,24 @@ const handleWeakNetworkZoomIntent = async (event: {
|
||||
boundaryAttempt: boolean
|
||||
referenceVisibleWorldSpan: number | null
|
||||
thresholdReached: boolean
|
||||
outdoorExitReached: boolean
|
||||
viewport: GuideViewportState
|
||||
}) => {
|
||||
if (
|
||||
!weakNetworkFallbackActive.value
|
||||
|| isLoading.value
|
||||
|| !props.autoSwitch
|
||||
|| weakNetworkSceneTransitionInFlight
|
||||
) return
|
||||
const from = activeView.value
|
||||
|
||||
if (
|
||||
from === 'overview'
|
||||
&& event.direction === 'out'
|
||||
&& event.outdoorExitReached
|
||||
&& requestWeakNetworkSemanticExteriorExit()
|
||||
) return
|
||||
|
||||
if (!props.autoSwitch) return
|
||||
const shouldEnterFloor = from === 'overview' && event.direction === 'in'
|
||||
const shouldExitOverview = from === 'floor' && event.direction === 'out'
|
||||
if (!shouldEnterFloor && !shouldExitOverview) return
|
||||
@@ -6340,6 +6403,26 @@ const handleWeakNetworkZoomIntent = async (event: {
|
||||
}
|
||||
}
|
||||
|
||||
const canRequestWeakNetworkSemanticExteriorExit = () => (
|
||||
props.semanticZoomEnabled
|
||||
&& !semanticExteriorExitRequested
|
||||
&& weakNetworkFallbackActive.value
|
||||
&& !isLoading.value
|
||||
&& activeView.value === 'overview'
|
||||
&& !props.showRoute
|
||||
&& !props.routeNavigationActive
|
||||
&& !props.routeStartSelectionActive
|
||||
&& !props.targetFocus
|
||||
&& !selectedPOI.value
|
||||
)
|
||||
|
||||
const requestWeakNetworkSemanticExteriorExit = () => {
|
||||
if (!canRequestWeakNetworkSemanticExteriorExit()) return false
|
||||
semanticExteriorExitRequested = true
|
||||
emit('semanticExteriorExit')
|
||||
return true
|
||||
}
|
||||
|
||||
const zoomCamera = (direction: 'in' | 'out', options: { source?: ZoomCameraSource } = {}) => {
|
||||
if (weakNetworkFallbackActive.value) {
|
||||
const source = options.source || 'button'
|
||||
@@ -6369,6 +6452,13 @@ const zoomCamera = (direction: 'in' | 'out', options: { source?: ZoomCameraSourc
|
||||
|
||||
if (!Number.isFinite(nextDistance) || nextDistance <= 0) return
|
||||
|
||||
if (
|
||||
direction === 'out'
|
||||
&& activeView.value === 'overview'
|
||||
&& hasReachedOutdoorExitBoundary(nextDistance, maxDistance)
|
||||
&& requestSemanticExteriorExit()
|
||||
) return
|
||||
|
||||
const shouldTrackUserZoom = options.source === 'button'
|
||||
if (shouldTrackUserZoom) {
|
||||
trackButtonZoomForAutoSwitch(currentDistance, nextDistance)
|
||||
@@ -7115,7 +7205,9 @@ const createPoiDomLabelIcon = (iconKey: PoiIconKey) => {
|
||||
svg.setAttribute('data-poi-icon', iconKey)
|
||||
|
||||
const use = document.createElementNS('http://www.w3.org/2000/svg', 'use')
|
||||
use.setAttribute('href', getPoiIconHref(iconKey))
|
||||
const iconHref = getPoiIconHref(iconKey)
|
||||
use.setAttribute('href', iconHref)
|
||||
use.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', iconHref)
|
||||
svg.appendChild(use)
|
||||
return svg
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
>
|
||||
<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"></use>
|
||||
<use :href="marker.iconHref" :xlink:href="marker.iconHref"></use>
|
||||
</svg>
|
||||
</view>
|
||||
<view
|
||||
@@ -119,7 +119,7 @@
|
||||
: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"></use>
|
||||
<use :href="marker.iconHref" :xlink:href="marker.iconHref"></use>
|
||||
</svg>
|
||||
<text class="weak-network-marker-label-title">{{ marker.label }}</text>
|
||||
</view>
|
||||
@@ -153,7 +153,12 @@ import {
|
||||
type AmbientFacilityKind,
|
||||
type PoiDisplayPolicy
|
||||
} from '@/domain/poiDisplay'
|
||||
import { getPoiIconHref, resolvePoiIconKey, type PoiIconKey } from '@/domain/poiCategories'
|
||||
import {
|
||||
ensurePoiIconSprite,
|
||||
getPoiIconHref,
|
||||
resolvePoiIconKey,
|
||||
type PoiIconKey
|
||||
} from '@/domain/poiCategories'
|
||||
import {
|
||||
getOverviewMapLabelDefinition,
|
||||
OVERVIEW_MAP_LABEL_DEFINITIONS
|
||||
@@ -239,6 +244,10 @@ 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
|
||||
@@ -307,6 +316,7 @@ const emit = defineEmits<{
|
||||
boundaryAttempt: boolean
|
||||
referenceVisibleWorldSpan: number | null
|
||||
thresholdReached: boolean
|
||||
outdoorExitReached: boolean
|
||||
viewport: GuideViewportState
|
||||
}]
|
||||
viewportChange: [event: {
|
||||
@@ -359,6 +369,7 @@ const updateSurfaceSize = () => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
ensurePoiIconSprite()
|
||||
updateSurfaceSize()
|
||||
const surfaceElement = getSurfaceElement()
|
||||
if (typeof ResizeObserver !== 'undefined' && surfaceElement) {
|
||||
@@ -861,6 +872,20 @@ const referenceVisibleWorldSpan = computed<number | null>(() => {
|
||||
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)
|
||||
@@ -1053,6 +1078,7 @@ const emitZoomIntent = (
|
||||
currentVisibleWorldSpan: viewport.visibleWorldSpan,
|
||||
referenceVisibleWorldSpan: referenceSpan
|
||||
}),
|
||||
outdoorExitReached: direction === 'out' && hasReachedOutdoorMapExitBoundary(viewport),
|
||||
viewport
|
||||
})
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,7 @@
|
||||
:scene-revision="sceneRevision"
|
||||
:scene-viewport="sceneViewport"
|
||||
:disable-auto-exit="disableAutoExit"
|
||||
:semantic-zoom-enabled="semanticZoomEnabled"
|
||||
@floor-change="handleThreeFloorChange"
|
||||
@poi-click="handlePoiClick"
|
||||
@route-start-candidate="handleRouteStartCandidate"
|
||||
@@ -38,6 +39,7 @@
|
||||
@selection-clear="handleSelectionClear"
|
||||
@target-focus="handleTargetFocus"
|
||||
@auto-switch="handleAutoSwitch"
|
||||
@semantic-exterior-exit="handleSemanticExteriorExit"
|
||||
@initial-model-progress="handleInitialModelProgress"
|
||||
@initial-model-ready="handleInitialModelReady"
|
||||
@initial-model-failed="handleInitialModelFailed"
|
||||
@@ -53,6 +55,22 @@
|
||||
<!-- #endif -->
|
||||
</template>
|
||||
<view v-else class="outdoor-map-bg">
|
||||
<!-- #ifdef H5 -->
|
||||
<TencentJsMap
|
||||
ref="outdoorRendererRef"
|
||||
class="outdoor-map"
|
||||
:polylines="outdoorNavPolylines"
|
||||
:outdoor-markers="outdoorMarkers"
|
||||
:active-outdoor-marker-id="activeOutdoorMarkerId"
|
||||
:outdoor-focus-offset-y="outdoorFocusOffsetY"
|
||||
:semantic-zoom-enabled="semanticZoomEnabled"
|
||||
@outdoor-marker-click="handleOutdoorMarkerClick"
|
||||
@map-tap="handleMapTap"
|
||||
@semantic-enter-building="handleSemanticEnterBuilding"
|
||||
@unavailable="emit('outdoorUnavailable', $event)"
|
||||
/>
|
||||
<!-- #endif -->
|
||||
<!-- #ifndef H5 -->
|
||||
<TencentMap
|
||||
class="outdoor-map"
|
||||
:active-floor="activeFloor"
|
||||
@@ -64,6 +82,7 @@
|
||||
@outdoor-marker-click="handleOutdoorMarkerClick"
|
||||
@map-tap="handleMapTap"
|
||||
/>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
<view v-if="dimmed" class="dim-layer"></view>
|
||||
</view>
|
||||
@@ -275,6 +294,7 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import TencentMap from '@/components/map/TencentMap.vue'
|
||||
// #ifdef H5
|
||||
import TencentJsMap from '@/components/map/TencentJsMap.vue'
|
||||
import ThreeMap from '@/components/map/ThreeMap.vue'
|
||||
import type { ResetViewBaselineResult } from '@/components/map/ThreeMap.vue'
|
||||
// #endif
|
||||
@@ -317,6 +337,7 @@ interface OutdoorMapMarker {
|
||||
subtitle?: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
markerKind?: 'bus' | 'metro' | 'parking' | 'museum' | 'user'
|
||||
}
|
||||
|
||||
type CameraViewMode = 'reset' | 'top' | 'oblique'
|
||||
@@ -412,6 +433,7 @@ const props = withDefaults(defineProps<{
|
||||
outdoorMarkers?: OutdoorMapMarker[]
|
||||
activeOutdoorMarkerId?: string
|
||||
outdoorFocusOffsetY?: number
|
||||
semanticZoomEnabled?: boolean
|
||||
}>(), {
|
||||
searchText: '请输入地点进行搜索',
|
||||
activeMode: '3d',
|
||||
@@ -470,7 +492,8 @@ const props = withDefaults(defineProps<{
|
||||
outdoorNavPolylines: () => [] as OutdoorNavPolyline[],
|
||||
outdoorMarkers: () => [] as OutdoorMapMarker[],
|
||||
activeOutdoorMarkerId: '',
|
||||
outdoorFocusOffsetY: 0
|
||||
outdoorFocusOffsetY: 0,
|
||||
semanticZoomEnabled: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -523,6 +546,9 @@ const emit = defineEmits<{
|
||||
}]
|
||||
mapTap: [location: { latitude: number; longitude: number }]
|
||||
outdoorMarkerClick: [markerId: string]
|
||||
semanticEnterBuilding: [viewport: { latitude: number; longitude: number; zoom: number }]
|
||||
semanticExitOutdoor: []
|
||||
outdoorUnavailable: [message: string]
|
||||
}>()
|
||||
|
||||
// 手动切换与自动切换共用 1500ms 保护,避免操作完成后立即反向跳转。
|
||||
@@ -560,6 +586,9 @@ const indoorRendererRef = ref<{
|
||||
disableAutoSwitchTemporarily?: (durationMs: number) => void
|
||||
getGuideViewportState?: () => GuideViewportState | null
|
||||
} | null>(null)
|
||||
const outdoorRendererRef = ref<{
|
||||
zoom?: (direction: 'in' | 'out') => void
|
||||
} | null>(null)
|
||||
|
||||
const indoorFloors = computed(() => props.floors.filter((floor) => isIndoorNavigableFloor(floor)))
|
||||
const floorItems = computed(() => indoorFloors.value)
|
||||
@@ -706,6 +735,16 @@ const handleModeChange = (mode: '2d' | '3d') => {
|
||||
emit('modeChange', mode)
|
||||
}
|
||||
|
||||
const handleSemanticEnterBuilding = (viewport: { latitude: number; longitude: number; zoom: number }) => {
|
||||
if (!props.semanticZoomEnabled || props.mapType !== 'outdoor') return
|
||||
emit('semanticEnterBuilding', viewport)
|
||||
}
|
||||
|
||||
const handleSemanticExteriorExit = () => {
|
||||
if (!props.semanticZoomEnabled || props.mapType !== 'indoor') return
|
||||
emit('semanticExitOutdoor')
|
||||
}
|
||||
|
||||
const isStaleFloorSwitchError = (error: unknown) => (
|
||||
error instanceof Error && error.message === 'STALE_MODEL_LOAD'
|
||||
)
|
||||
@@ -934,7 +973,11 @@ const handleZoomClick = (direction: 'in' | 'out') => {
|
||||
const now = Date.now()
|
||||
if (now - lastZoomActionAt < 100) return
|
||||
lastZoomActionAt = now
|
||||
indoorRendererRef.value?.zoomCamera?.(direction, { source: 'button' })
|
||||
if (props.mapType === 'indoor') {
|
||||
indoorRendererRef.value?.zoomCamera?.(direction, { source: 'button' })
|
||||
} else {
|
||||
outdoorRendererRef.value?.zoom?.(direction)
|
||||
}
|
||||
emit('toolClick', direction === 'in' ? '放大' : '缩小')
|
||||
}
|
||||
|
||||
|
||||
716
src/components/navigation/OutdoorArrivalRoutePanel.vue
Normal file
716
src/components/navigation/OutdoorArrivalRoutePanel.vue
Normal file
@@ -0,0 +1,716 @@
|
||||
<template>
|
||||
<view v-if="visible" class="outdoor-route-panel">
|
||||
<view class="outdoor-route-handle"></view>
|
||||
<view class="outdoor-route-header">
|
||||
<view class="outdoor-route-heading">
|
||||
<text class="outdoor-route-title">{{ panelTitle }}</text>
|
||||
<text v-if="destination" class="outdoor-route-destination">前往 {{ destination.title }}</text>
|
||||
</view>
|
||||
<view class="outdoor-route-back" role="button" aria-label="返回目的地详情" @tap="emit('back')">
|
||||
<view class="outdoor-route-back-icon"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<template v-if="phase === 'locating'">
|
||||
<view class="outdoor-route-state">
|
||||
<view class="outdoor-route-spinner"></view>
|
||||
<text class="outdoor-route-state-title">正在确认当前位置</text>
|
||||
<text class="outdoor-route-state-copy">将以实时位置作为来馆起点</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<template v-else-if="phase === 'permission-needed'">
|
||||
<view v-if="locationHelpVisible" class="outdoor-route-permission-help">
|
||||
<text class="outdoor-route-state-title">请开启位置权限</text>
|
||||
<text class="outdoor-route-state-copy">请在微信小程序的设置中开启位置权限,然后返回此页面重新定位。</text>
|
||||
</view>
|
||||
<view v-else class="outdoor-route-state location-needed">
|
||||
<view class="outdoor-route-location-icon"></view>
|
||||
<text class="outdoor-route-state-title">暂未获取实时位置</text>
|
||||
<text class="outdoor-route-state-copy">{{ locationMessage || '请开启位置权限,或手动选择起点' }}</text>
|
||||
</view>
|
||||
<view class="outdoor-route-actions two-columns">
|
||||
<view class="outdoor-route-action secondary" @tap="emit('retryLocation')">
|
||||
<text class="outdoor-route-action-text">重新检测</text>
|
||||
</view>
|
||||
<view class="outdoor-route-action primary" @tap="emit('manualStartRequest')">
|
||||
<text class="outdoor-route-action-text">手动选择起点</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<template v-else-if="phase === 'confirm-start'">
|
||||
<view class="outdoor-route-point-card">
|
||||
<view class="outdoor-route-point-icon start"></view>
|
||||
<view class="outdoor-route-point-copy">
|
||||
<text class="outdoor-route-point-label">起点</text>
|
||||
<text class="outdoor-route-point-name">{{ startPoint?.name || '当前位置' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="outdoor-route-actions">
|
||||
<view class="outdoor-route-action primary full" @tap="emit('confirmStart')">
|
||||
<text class="outdoor-route-action-text">从这里出发</text>
|
||||
</view>
|
||||
<view class="outdoor-route-text-action" @tap="emit('manualStartRequest')">
|
||||
<text class="outdoor-route-text-action-label">更换起点</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<template v-else-if="phase === 'manual-search'">
|
||||
<view class="outdoor-route-switches">
|
||||
<view class="outdoor-route-switch active">
|
||||
<text class="outdoor-route-switch-text">输入地点</text>
|
||||
</view>
|
||||
<view class="outdoor-route-switch" @tap="emit('manualMapRequest')">
|
||||
<text class="outdoor-route-switch-text">地图选点</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="outdoor-route-search-field">
|
||||
<input
|
||||
class="outdoor-route-search-input"
|
||||
type="text"
|
||||
:value="searchKeyword"
|
||||
placeholder="输入起点地址或地名"
|
||||
@input="handleSearchInput"
|
||||
/>
|
||||
<view v-if="manualSearchLoading" class="outdoor-route-search-loading"></view>
|
||||
</view>
|
||||
<scroll-view v-if="manualSearchResults.length" class="outdoor-route-search-results" scroll-y :show-scrollbar="false">
|
||||
<view
|
||||
v-for="result in manualSearchResults"
|
||||
:key="result.id"
|
||||
class="outdoor-route-search-result"
|
||||
@tap="emit('manualStartConfirm', result)"
|
||||
>
|
||||
<text class="outdoor-route-search-result-title">{{ result.title }}</text>
|
||||
<text class="outdoor-route-search-result-address">{{ result.address }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<template v-else-if="phase === 'manual-map'">
|
||||
<view class="outdoor-route-switches">
|
||||
<view class="outdoor-route-switch" @tap="emit('manualStartRequest')">
|
||||
<text class="outdoor-route-switch-text">输入地点</text>
|
||||
</view>
|
||||
<view class="outdoor-route-switch active">
|
||||
<text class="outdoor-route-switch-text">地图选点</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="outdoor-route-map-prompt">
|
||||
<view class="outdoor-route-map-prompt-icon"></view>
|
||||
<view class="outdoor-route-map-prompt-copy">
|
||||
<text class="outdoor-route-map-prompt-title">请在地图上点选起点</text>
|
||||
<text class="outdoor-route-map-prompt-desc">确认位置后即可规划来馆路线</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<view class="outdoor-route-journey">
|
||||
<view class="outdoor-route-journey-item">
|
||||
<view class="outdoor-route-point-icon start"></view>
|
||||
<view class="outdoor-route-point-copy">
|
||||
<text class="outdoor-route-point-label">起点</text>
|
||||
<text class="outdoor-route-point-name">{{ startPoint?.name || '当前位置' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="outdoor-route-journey-line"></view>
|
||||
<view class="outdoor-route-journey-item">
|
||||
<view class="outdoor-route-point-icon end"></view>
|
||||
<view class="outdoor-route-point-copy">
|
||||
<text class="outdoor-route-point-label">终点</text>
|
||||
<text class="outdoor-route-point-name">{{ destination?.title || '深圳自然博物馆' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="outdoor-route-result loading">
|
||||
<view class="outdoor-route-spinner small"></view>
|
||||
<text class="outdoor-route-result-text">正在规划路线</text>
|
||||
</view>
|
||||
<view v-else-if="routeError" class="outdoor-route-result error">
|
||||
<text class="outdoor-route-result-text">{{ routeError }}</text>
|
||||
<view class="outdoor-route-retry" @tap="emit('retryRoute')">
|
||||
<text class="outdoor-route-retry-text">重试</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="routeSummary" class="outdoor-route-result">
|
||||
<text class="outdoor-route-result-text">{{ routeSummary }}</text>
|
||||
<view class="outdoor-route-status-dot"></view>
|
||||
<text class="outdoor-route-result-note">路线已显示在地图上</text>
|
||||
</view>
|
||||
|
||||
<view class="outdoor-route-text-action route-change-start" @tap="emit('manualStartRequest')">
|
||||
<text class="outdoor-route-text-action-label">更换起点</text>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { OutdoorPlace } from '@/services/outdoor/OutdoorMapAppService'
|
||||
|
||||
export type OutdoorRouteStartPhase =
|
||||
| 'locating'
|
||||
| 'permission-needed'
|
||||
| 'confirm-start'
|
||||
| 'manual-search'
|
||||
| 'manual-map'
|
||||
| 'route'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
visible?: boolean
|
||||
phase?: OutdoorRouteStartPhase
|
||||
locationMessage?: string
|
||||
locationHelpVisible?: boolean
|
||||
destination?: { title: string; address?: string; latitude: number; longitude: number } | null
|
||||
startPoint?: { latitude: number; longitude: number; name?: string } | null
|
||||
loading?: boolean
|
||||
routeError?: string
|
||||
routeDistance?: number
|
||||
routeDuration?: number
|
||||
manualSearchResults?: OutdoorPlace[]
|
||||
manualSearchLoading?: boolean
|
||||
}>(), {
|
||||
visible: false,
|
||||
phase: 'locating',
|
||||
locationMessage: '',
|
||||
locationHelpVisible: false,
|
||||
destination: null,
|
||||
startPoint: null,
|
||||
loading: false,
|
||||
routeError: '',
|
||||
routeDistance: 0,
|
||||
routeDuration: 0,
|
||||
manualSearchResults: () => [],
|
||||
manualSearchLoading: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
back: []
|
||||
retryLocation: []
|
||||
manualStartRequest: []
|
||||
manualMapRequest: []
|
||||
manualStartSearch: [keyword: string]
|
||||
manualStartConfirm: [location: OutdoorPlace]
|
||||
confirmStart: []
|
||||
retryRoute: []
|
||||
}>()
|
||||
|
||||
const searchKeyword = ref('')
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const panelTitle = computed(() => {
|
||||
if (props.phase === 'route') return '来馆路线'
|
||||
if (props.phase === 'manual-search' || props.phase === 'manual-map') return '选择起点'
|
||||
if (props.phase === 'locating' || props.phase === 'permission-needed') return '获取位置'
|
||||
return '确认起点'
|
||||
})
|
||||
|
||||
const routeSummary = computed(() => {
|
||||
if (props.routeDistance <= 0) return ''
|
||||
const distance = props.routeDistance >= 1000
|
||||
? `${(props.routeDistance / 1000).toFixed(1)} 公里`
|
||||
: `${Math.round(props.routeDistance)} 米`
|
||||
const duration = props.routeDuration > 0
|
||||
? `约 ${Math.max(1, Math.round(props.routeDuration))} 分钟`
|
||||
: ''
|
||||
return [distance, duration].filter(Boolean).join(' · ')
|
||||
})
|
||||
|
||||
const handleSearchInput = (event: { detail?: { value?: string } } | Event) => {
|
||||
const target = event as { detail?: { value?: string }; target?: { value?: string } }
|
||||
searchKeyword.value = target.detail?.value ?? target.target?.value ?? ''
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
const keyword = searchKeyword.value.trim()
|
||||
if (keyword.length < 2) {
|
||||
emit('manualStartSearch', '')
|
||||
return
|
||||
}
|
||||
searchTimer = setTimeout(() => emit('manualStartSearch', keyword), 220)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.outdoor-route-panel {
|
||||
position: absolute;
|
||||
z-index: 1102;
|
||||
right: 12px;
|
||||
bottom: calc(env(safe-area-inset-bottom) + 12px);
|
||||
left: 12px;
|
||||
max-width: 406px;
|
||||
margin: 0 auto;
|
||||
padding: 10px 14px 14px;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid rgba(26, 35, 126, 0.11);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
box-shadow: 0 12px 30px rgba(35, 46, 66, 0.18);
|
||||
}
|
||||
|
||||
.outdoor-route-handle {
|
||||
width: 34px;
|
||||
height: 4px;
|
||||
margin: 0 auto 11px;
|
||||
border-radius: 99px;
|
||||
background: #d7dce5;
|
||||
}
|
||||
|
||||
.outdoor-route-header,
|
||||
.outdoor-route-heading,
|
||||
.outdoor-route-actions,
|
||||
.outdoor-route-switches,
|
||||
.outdoor-route-point-card,
|
||||
.outdoor-route-journey-item,
|
||||
.outdoor-route-result,
|
||||
.outdoor-route-map-prompt {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.outdoor-route-header {
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.outdoor-route-heading {
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.outdoor-route-title {
|
||||
color: #263447;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
line-height: 25px;
|
||||
}
|
||||
|
||||
.outdoor-route-destination {
|
||||
overflow: hidden;
|
||||
color: #69778c;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.outdoor-route-back {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: 0 0 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: #f4f7fb;
|
||||
}
|
||||
|
||||
.outdoor-route-back-icon {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
margin-left: 3px;
|
||||
border-bottom: 1.5px solid #647286;
|
||||
border-left: 1.5px solid #647286;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.outdoor-route-state {
|
||||
min-height: 116px;
|
||||
padding: 22px 10px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.outdoor-route-state.location-needed {
|
||||
min-height: 104px;
|
||||
}
|
||||
|
||||
.outdoor-route-permission-help {
|
||||
min-height: 104px;
|
||||
padding: 17px 10px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.outdoor-route-permission-help .outdoor-route-state-copy {
|
||||
max-width: 304px;
|
||||
}
|
||||
|
||||
.outdoor-route-spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-bottom: 12px;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid rgba(21, 101, 192, 0.16);
|
||||
border-top-color: #1565c0;
|
||||
border-radius: 50%;
|
||||
animation: outdoor-route-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.outdoor-route-spinner.small {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.outdoor-route-location-icon,
|
||||
.outdoor-route-map-prompt-icon {
|
||||
position: relative;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-bottom: 10px;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid #4778bd;
|
||||
border-radius: 50% 50% 50% 0;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.outdoor-route-location-icon::after,
|
||||
.outdoor-route-map-prompt-icon::after {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #4778bd;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.outdoor-route-state-title,
|
||||
.outdoor-route-map-prompt-title {
|
||||
color: #34435a;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 21px;
|
||||
}
|
||||
|
||||
.outdoor-route-state-copy,
|
||||
.outdoor-route-map-prompt-desc {
|
||||
margin-top: 4px;
|
||||
color: #7c8898;
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
.outdoor-route-actions {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.outdoor-route-actions.two-columns {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.outdoor-route-action {
|
||||
height: 44px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #c7d8ec;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.outdoor-route-action.primary {
|
||||
border-color: #1565c0;
|
||||
background: #1565c0;
|
||||
}
|
||||
|
||||
.outdoor-route-action.secondary {
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.outdoor-route-action.full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.outdoor-route-action-text {
|
||||
color: #1565c0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.outdoor-route-action.primary .outdoor-route-action-text {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.outdoor-route-text-action {
|
||||
min-height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.outdoor-route-text-action-label {
|
||||
color: #5172a0;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.outdoor-route-point-card,
|
||||
.outdoor-route-journey {
|
||||
margin: 18px 0 14px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e1e8f0;
|
||||
border-radius: 7px;
|
||||
background: #f9fbfd;
|
||||
}
|
||||
|
||||
.outdoor-route-point-card,
|
||||
.outdoor-route-journey-item {
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.outdoor-route-point-icon {
|
||||
position: relative;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex: 0 0 20px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.outdoor-route-point-icon.start {
|
||||
border: 5px solid #2f7d65;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.outdoor-route-point-icon.end {
|
||||
border: 5px solid #c8904f;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.outdoor-route-point-copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.outdoor-route-point-label {
|
||||
color: #8290a1;
|
||||
font-size: 11px;
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
.outdoor-route-point-name {
|
||||
overflow: hidden;
|
||||
color: #34435a;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.outdoor-route-switches {
|
||||
height: 38px;
|
||||
margin-top: 16px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #dbe5f0;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.outdoor-route-switch {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f9fbfd;
|
||||
}
|
||||
|
||||
.outdoor-route-switch.active {
|
||||
background: #eaf3ff;
|
||||
}
|
||||
|
||||
.outdoor-route-switch-text {
|
||||
color: #5d6d80;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.outdoor-route-switch.active .outdoor-route-switch-text {
|
||||
color: #1565c0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.outdoor-route-search-field {
|
||||
position: relative;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.outdoor-route-search-input {
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
padding: 0 38px 0 12px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #cfd9e5;
|
||||
border-radius: 7px;
|
||||
background: #ffffff;
|
||||
color: #34435a;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.outdoor-route-search-loading {
|
||||
position: absolute;
|
||||
top: 13px;
|
||||
right: 12px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(21, 101, 192, 0.16);
|
||||
border-top-color: #1565c0;
|
||||
border-radius: 50%;
|
||||
animation: outdoor-route-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.outdoor-route-search-results {
|
||||
max-height: 154px;
|
||||
margin-top: 8px;
|
||||
overflow: auto;
|
||||
border-top: 1px solid #edf1f5;
|
||||
}
|
||||
|
||||
.outdoor-route-search-result {
|
||||
padding: 10px 4px;
|
||||
border-bottom: 1px solid #edf1f5;
|
||||
}
|
||||
|
||||
.outdoor-route-search-result-title,
|
||||
.outdoor-route-search-result-address {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.outdoor-route-search-result-title {
|
||||
color: #34435a;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.outdoor-route-search-result-address {
|
||||
margin-top: 2px;
|
||||
color: #8490a0;
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
.outdoor-route-map-prompt {
|
||||
min-height: 72px;
|
||||
margin-top: 12px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #dbe7f5;
|
||||
border-radius: 7px;
|
||||
background: #f7fbff;
|
||||
}
|
||||
|
||||
.outdoor-route-map-prompt-icon {
|
||||
flex: 0 0 24px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.outdoor-route-map-prompt-copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.outdoor-route-journey {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.outdoor-route-journey-line {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
margin: 3px 0 3px 9px;
|
||||
border-left: 1px dashed #b9c8da;
|
||||
}
|
||||
|
||||
.outdoor-route-result {
|
||||
min-height: 42px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 11px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 7px;
|
||||
background: #f6f9fc;
|
||||
}
|
||||
|
||||
.outdoor-route-result.loading {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.outdoor-route-result.error {
|
||||
justify-content: space-between;
|
||||
background: #fff8f2;
|
||||
}
|
||||
|
||||
.outdoor-route-result-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #53647b;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.outdoor-route-status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
flex: 0 0 6px;
|
||||
border-radius: 50%;
|
||||
background: #2f7d65;
|
||||
}
|
||||
|
||||
.outdoor-route-result-note {
|
||||
color: #8190a1;
|
||||
font-size: 11px;
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
.outdoor-route-retry {
|
||||
height: 26px;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #e2bd99;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.outdoor-route-retry-text {
|
||||
color: #b66a2b;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.route-change-start {
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
@keyframes outdoor-route-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
@@ -61,7 +61,7 @@
|
||||
>
|
||||
<view class="home-category-icon">
|
||||
<svg class="poi-category-icon" viewBox="0 0 32 32" aria-hidden="true">
|
||||
<use :href="getPoiCategoryIconHref(item.id)" />
|
||||
<use :href="getPoiCategoryIconHref(item.id)" :xlink:href="getPoiCategoryIconHref(item.id)" />
|
||||
</svg>
|
||||
</view>
|
||||
<text class="home-category-label">{{ item.label }}</text>
|
||||
@@ -149,7 +149,7 @@
|
||||
>
|
||||
<view class="category-icon-shell">
|
||||
<svg class="poi-category-icon" viewBox="0 0 32 32" aria-hidden="true">
|
||||
<use :href="getPoiCategoryIconHref(item.id)" />
|
||||
<use :href="getPoiCategoryIconHref(item.id)" :xlink:href="getPoiCategoryIconHref(item.id)" />
|
||||
</svg>
|
||||
</view>
|
||||
<text class="category-label">{{ item.label }}</text>
|
||||
@@ -236,6 +236,7 @@ import {
|
||||
import {
|
||||
HOME_POI_CATEGORIES,
|
||||
POI_CATEGORIES,
|
||||
ensurePoiIconSprite,
|
||||
getPoiCategoryIconHref,
|
||||
getPoiCategoryById,
|
||||
resolvePoiCategory,
|
||||
@@ -1062,6 +1063,7 @@ watch(showSearchContent, (expanded) => {
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(async () => {
|
||||
ensurePoiIconSprite()
|
||||
await loadFloors()
|
||||
// 首页默认是折叠态。先只同步楼层,避免首屏为了填充未展开的搜索结果而读取全馆点位。
|
||||
// 用户展开搜索、点击分类或提交关键词时再按需建立搜索状态。
|
||||
|
||||
Reference in New Issue
Block a user