Files
frontend-miniapp/src/components/navigation/GuideMapShell.vue

1394 lines
32 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="guide-map-shell">
<view class="map-layer" :class="`map-${mapType}`">
<template v-if="mapType === 'indoor'">
<!-- #ifdef H5 -->
<ThreeMap
v-if="effectiveIndoorModelSource"
ref="indoorRendererRef"
class="indoor-three-map"
:asset-base-url="indoorAssetBaseUrl"
:model-source="effectiveIndoorModelSource"
:initial-floor-id="activeFloorId"
:initial-view="indoorView"
:show-controls="false"
:show-poi="shouldShowIndoorPois"
:target-focus="targetFocusRequest"
:touch-gesture-mode="touchGestureMode"
:target-focus-distance-factor="targetFocusDistanceFactor"
:route-preview="routePreview"
:show-route="showRoute"
:disable-auto-exit="disableAutoExit"
:auto-switch-threshold-low="autoSwitchThresholdLow"
:auto-switch-threshold-high="autoSwitchThresholdHigh"
@floor-change="handleThreeFloorChange"
@poi-click="handlePoiClick"
@selection-clear="handleSelectionClear"
@target-focus="handleTargetFocus"
@auto-switch="handleAutoSwitch"
@initial-model-progress="handleInitialModelProgress"
@initial-model-ready="handleInitialModelReady"
@initial-model-failed="handleInitialModelFailed"
/>
<!-- #endif -->
<!-- #ifndef H5 -->
<view class="indoor-platform-fallback">
<text class="indoor-platform-title">馆内三维导览</text>
<text class="indoor-platform-desc">当前端暂不支持 WebGL 三维展示请在 H5 端查看</text>
</view>
<!-- #endif -->
</template>
<view v-else class="outdoor-map-bg">
<TencentMap
class="outdoor-map"
:active-floor="activeFloor"
:floors="props.floors"
:polylines="outdoorNavPolylines"
@map-tap="handleMapTap"
/>
</view>
<view v-if="dimmed" class="dim-layer"></view>
</view>
<view
v-if="showSearch"
class="guide-search-field"
:class="`variant-${searchVariant}`"
:style="searchFieldStyle"
@tap="handleSearchTap"
>
<view v-if="searchVariant === 'home'" class="search-icon leading">
<view class="search-icon-circle"></view>
<view class="search-icon-handle"></view>
</view>
<text class="search-field-text">{{ searchText }}</text>
<view v-if="showVoiceIcon" class="voice-icon">
<view class="voice-head"></view>
<view class="voice-stem"></view>
<view class="voice-base"></view>
</view>
<view v-else-if="searchVariant !== 'home'" class="search-icon">
<view class="search-icon-circle"></view>
<view class="search-icon-handle"></view>
</view>
</view>
<view
v-if="showModeRow"
class="guide-mode-row"
:class="`layout-${modeLayout}`"
:style="modeRowStyle"
>
<template v-if="modeLayout === 'full'">
<view
class="mode-option"
:class="{ active: activeMode === '2d' }"
@tap="handleModeChange('2d')"
>
<text class="mode-label">馆外2D</text>
</view>
<view
class="mode-option"
:class="{ active: activeMode === '3d' }"
@tap="handleModeChange('3d')"
>
<text class="mode-label">馆内3D</text>
</view>
</template>
<template v-else>
<view class="compact-mode-switch">
<view
class="mode-option"
:class="{ active: activeMode === '2d' }"
@tap="handleModeChange('2d')"
>
<text class="mode-label">馆外2D</text>
</view>
<view
class="mode-option"
:class="{ active: activeMode === '3d' }"
@tap="handleModeChange('3d')"
>
<text class="mode-label">馆内3D</text>
</view>
</view>
<view class="mode-status-chip" :class="`tone-${modeStatusTone}`">
<text class="mode-status-text">{{ modeStatus }}</text>
</view>
</template>
</view>
<slot name="overlay"></slot>
<view
v-if="showIndoorRightControls && showLayerModeToggle"
class="layer-mode-toggle"
:style="layerModeToggleStyle"
@tap="handleLayerModeToggle"
>
<text class="layer-mode-label">{{ layerModeActionLabel }}</text>
</view>
<view
v-if="showIndoorRightControls && showFloor"
class="floor-switcher"
:class="`side-${floorSide}`"
:style="floorSwitcherStyle"
>
<view
v-if="showFloorHeader"
class="floor-header"
:class="{ active: layerMode === 'multi' }"
@tap.stop="handleFloorHeaderTap"
>
<text class="floor-header-icon"></text>
<text class="floor-header-label">{{ layerModeActionLabel }}</text>
</view>
<view
class="floor-list"
>
<view
v-for="floor in floorItems"
:key="floor.id"
class="floor-item"
:class="{
active: activeFloorId === floor.id,
pending: loadingFloorId === floor.id,
failed: failedFloorId === floor.id,
disabled: isFloorSwitchDisabled(floor.id)
}"
@tap.stop="handleFloorChange(floor)"
>
<text class="floor-label">{{ floor.label }}</text>
</view>
</view>
</view>
<view v-if="showZoomControls" class="zoom-controls" :style="zoomControlsStyle">
<view class="zoom-btn" @tap="handleZoomClick('in')">
<text class="zoom-text">+</text>
</view>
<view class="zoom-divider"></view>
<view class="zoom-btn" @tap="handleZoomClick('out')">
<text class="zoom-text"></text>
</view>
</view>
<view v-if="showMoreControl" class="more-control" :style="moreControlStyle" @tap="handleMoreTap">
<text class="more-control-text">更多</text>
</view>
<view v-if="tools.length" class="tool-stack" :style="toolStackStyle">
<view
v-for="tool in tools"
:key="tool"
class="tool-btn"
:aria-label="tool"
:title="tool"
@tap="handleToolClick(tool)"
>
<svg
v-if="toolIconType(tool) === 'reset'"
class="tool-icon-svg"
viewBox="0 0 24 24"
fill="none"
>
<path d="M5 7v5h5" />
<path d="M6.2 13.4A6.2 6.2 0 1 0 7.4 6.7L5 9.1" />
</svg>
<svg
v-else-if="toolIconType(tool) === 'top'"
class="tool-icon-svg"
viewBox="0 0 24 24"
fill="none"
>
<rect x="6" y="6" width="12" height="12" rx="2" />
<path d="M9 9h6v6H9z" />
</svg>
<svg
v-else-if="toolIconType(tool) === 'oblique'"
class="tool-icon-svg"
viewBox="0 0 24 24"
fill="none"
>
<path d="M5 9.5 12 5l7 4.5-7 4.5-7-4.5Z" />
<path d="M5 9.5v5L12 19l7-4.5v-5" />
<path d="M12 14v5" />
</svg>
<svg
v-else-if="toolIconType(tool) === 'clear'"
class="tool-icon-svg"
viewBox="0 0 24 24"
fill="none"
>
<path d="M7 7l10 10" />
<path d="M17 7 7 17" />
</svg>
<text v-else class="tool-label">{{ tool }}</text>
</view>
</view>
<slot></slot>
</view>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import TencentMap from '@/components/map/TencentMap.vue'
// #ifdef H5
import ThreeMap from '@/components/map/ThreeMap.vue'
// #endif
import type {
GuideModelSource,
GuideRenderPoi
} from '@/domain/guideModel'
import type {
GuideRouteResult
} from '@/domain/museum'
import {
isIndoorNavigableFloor
} from '@/domain/guideFloor'
interface GuideFloorOption {
id: string
label: string
}
interface FloorSwitchEvent {
floorId: string
floorLabel: string
}
interface OutdoorNavPolyline {
points: Array<{ latitude: number; longitude: number }>
color: string
width: number
}
type CameraViewMode = 'reset' | 'top' | 'oblique'
interface TargetPoiFocusRequest {
requestId: number | string
poiId: string
name?: string
floorId: string
floorLabel?: string
primaryCategoryZh?: string
positionGltf?: [number, number, number]
sourceObjectName?: string
}
interface TargetPoiFocusResult {
requestId: number | string
poiId: string
floorId: string
status: 'focused' | 'missing' | 'error'
message?: string
}
type IndoorViewMode = 'overview' | 'floor' | 'multi'
type LayerDisplayMode = 'single' | 'multi'
type TouchGestureMode = 'orbit' | 'pan'
interface InitialModelProgressEvent {
progress: number
message: string
view: IndoorViewMode
floorId?: string
elapsedMs?: number
}
const props = withDefaults(defineProps<{
searchText?: string
activeMode?: '2d' | '3d'
activeFloor?: string
indoorView?: IndoorViewMode
indoorInitialView?: IndoorViewMode
layerMode?: LayerDisplayMode
searchTop?: string
floorTop?: string
floorBottom?: string
floorMaxHeight?: string
layerModeToggleTop?: string
toolsTop?: string
tools?: string[]
cameraView?: CameraViewMode
searchVariant?: 'default' | 'home'
showVoiceIcon?: boolean
showModeRow?: boolean
showFloorHeader?: boolean
showZoomControls?: boolean
showLocationControl?: boolean
showMoreControl?: boolean
toolsBottom?: string
floorSide?: 'left' | 'right'
zoomControlsTop?: string
moreControlBottom?: string
showSearch?: boolean
showFloor?: boolean
showLayerModeToggle?: boolean
modeTop?: string
modeLayout?: 'full' | 'status'
modeStatus?: string
modeStatusTone?: 'solid' | 'glass'
mapType?: 'indoor' | 'outdoor'
outdoorVariant?: 'home' | 'entrance'
indoorAssetBaseUrl?: string
indoorModelSource?: GuideModelSource
floors?: GuideFloorOption[]
normalizeFloorId?: (labelOrId: string) => string
dimmed?: boolean
targetFocusRequest?: TargetPoiFocusRequest | null
touchGestureMode?: TouchGestureMode
targetFocusDistanceFactor?: number
routePreview?: GuideRouteResult | null
showRoute?: boolean
disableAutoExit?: boolean
autoSwitchThresholdLow?: number
autoSwitchThresholdHigh?: number
outdoorNavPolylines?: OutdoorNavPolyline[]
}>(), {
searchText: '请输入地点进行搜索',
activeMode: '3d',
activeFloor: '1F',
indoorView: 'floor',
indoorInitialView: 'floor',
layerMode: 'single',
searchTop: '16px',
floorTop: '164px',
floorBottom: '',
floorMaxHeight: '',
layerModeToggleTop: '154px',
toolsTop: '406px',
toolsBottom: '',
tools: () => [] as string[],
cameraView: 'reset',
searchVariant: 'default',
showVoiceIcon: false,
showModeRow: true,
showFloorHeader: false,
showZoomControls: false,
showLocationControl: false,
showMoreControl: false,
floorSide: 'right',
zoomControlsTop: '520px',
moreControlBottom: '34px',
showSearch: true,
showFloor: true,
showLayerModeToggle: false,
modeTop: '60px',
modeLayout: 'full',
modeStatus: '',
modeStatusTone: 'solid',
mapType: 'indoor',
outdoorVariant: 'home',
indoorAssetBaseUrl: '',
indoorModelSource: undefined,
floors: () => [] as GuideFloorOption[],
normalizeFloorId: (labelOrId: string) => labelOrId,
dimmed: false,
targetFocusRequest: null,
touchGestureMode: 'orbit',
targetFocusDistanceFactor: 0.36,
routePreview: null,
showRoute: false,
disableAutoExit: false,
autoSwitchThresholdLow: 1.0,
autoSwitchThresholdHigh: 1.3,
outdoorNavPolylines: () => [] as OutdoorNavPolyline[]
})
const emit = defineEmits<{
searchTap: []
modeChange: [mode: '2d' | '3d']
floorRequest: [event: FloorSwitchEvent]
floorChange: [floor: string]
floorSwitchFailed: [event: FloorSwitchEvent]
toolClick: [tool: string]
moreClick: []
indoorViewChange: [view: IndoorViewMode]
layerModeChange: [mode: LayerDisplayMode]
poiClick: [poi: GuideRenderPoi]
selectionClear: []
targetFocus: [result: TargetPoiFocusResult]
autoSwitch: [event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: string; distance: number }]
initialModelProgress: [event: InitialModelProgressEvent]
initialModelReady: [event: { view: IndoorViewMode; floorId?: string; elapsedMs?: number }]
initialModelFailed: [event: { view: IndoorViewMode; floorId?: string; message: string; elapsedMs?: number }]
mapTap: [location: { latitude: number; longitude: number }]
}>()
// 监听视角切换循环切换reset -> top -> oblique -> reset
watch(() => props.cameraView, (view) => {
if (props.mapType !== 'indoor') return
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000)
if (view === 'reset') {
indoorRendererRef.value?.resetCamera?.()
} else if (view === 'top') {
indoorRendererRef.value?.setCameraPreset?.('top')
} else if (view === 'oblique') {
indoorRendererRef.value?.setCameraPreset?.('oblique')
}
})
const indoorRendererRef = ref<{
switchFloor?: (floorId: string) => Promise<void> | void
showOverview?: () => Promise<void> | void
showMultiFloor?: () => Promise<void> | void
resetCamera?: () => void
setCameraPreset?: (preset: 'top' | 'oblique') => void
zoomCamera?: (direction: 'in' | 'out', options?: { source?: 'button' | 'gesture' }) => void
clearSelection?: (shouldEmit?: boolean) => void
clearRoute?: () => Promise<void> | void
disableAutoSwitchTemporarily?: (durationMs: number) => void
} | null>(null)
const indoorFloors = computed(() => props.floors.filter((floor) => isIndoorNavigableFloor(floor)))
const floorItems = computed(() => indoorFloors.value)
// #ifdef H5
const effectiveIndoorModelSource = computed(() => props.indoorModelSource)
// #endif
const showIndoorRightControls = computed(() => (
props.mapType === 'indoor' && props.indoorView !== 'overview'
))
const activeFloorId = computed(() => {
const matchedFloor = indoorFloors.value.find((floor) => (
floor.id === props.activeFloor || floor.label === props.activeFloor
))
if (matchedFloor) return matchedFloor.id
const normalizedFloorId = props.normalizeFloorId(props.activeFloor)
const normalizedFloor = indoorFloors.value.find((floor) => (
floor.id === normalizedFloorId || floor.label === normalizedFloorId
))
return normalizedFloor?.id || normalizedFloorId
})
const requestedFloorId = ref('')
const requestedFloorLabel = ref('')
const loadingFloorId = ref('')
const renderedFloorId = ref(activeFloorId.value)
const activeFloorSwitchRequestSeq = ref(0)
const renderedFloorSwitchRequestSeq = ref(0)
const failedFloorId = ref('')
let floorSwitchRequestSeq = 0
const searchFieldStyle = computed(() => ({
top: props.searchTop
}))
const floorSwitcherStyle = computed(() => ({
...(props.floorBottom ? { bottom: props.floorBottom } : { top: props.floorTop })
}))
const layerModeToggleStyle = computed(() => ({
top: props.layerModeToggleTop
}))
const modeRowStyle = computed(() => ({
top: props.modeTop
}))
const toolStackStyle = computed(() => ({
...(props.toolsTop ? { top: props.toolsTop } : {}),
...(props.toolsBottom ? { bottom: props.toolsBottom } : {}),
left: '18px'
}))
const zoomControlsStyle = computed(() => ({
top: props.zoomControlsTop
}))
const moreControlStyle = computed(() => ({
bottom: props.moreControlBottom
}))
const shouldShowIndoorPois = computed(() => (
props.mapType === 'indoor'
) || Boolean(props.targetFocusRequest))
const nextLayerMode = computed<LayerDisplayMode>(() => (
props.layerMode === 'multi' ? 'single' : 'multi'
))
const layerModeActionLabel = computed(() => (
nextLayerMode.value === 'multi' ? '多层' : '单层'
))
const handleSearchTap = () => {
emit('searchTap')
}
const handleModeChange = (mode: '2d' | '3d') => {
emit('modeChange', mode)
}
const isStaleFloorSwitchError = (error: unknown) => (
error instanceof Error && error.message === 'STALE_MODEL_LOAD'
)
const findFloorItemById = (floorId: string) => (
floorItems.value.find((floor) => floor.id === floorId)
)
const isFloorSwitchDisabled = (floorId: string) => (
Boolean(loadingFloorId.value) && loadingFloorId.value !== floorId
)
const clearFloorLoadingIfCurrent = (floorId: string) => {
if (loadingFloorId.value === floorId) {
loadingFloorId.value = ''
}
}
const markFloorSwitchFailedIfUnrendered = (floorId: string, requestSeq: number) => {
if (
loadingFloorId.value !== floorId
|| activeFloorSwitchRequestSeq.value !== requestSeq
|| renderedFloorSwitchRequestSeq.value === requestSeq
) return
failedFloorId.value = floorId
clearFloorLoadingIfCurrent(floorId)
emit('floorSwitchFailed', {
floorId,
floorLabel: requestedFloorId.value === floorId
? requestedFloorLabel.value || findFloorItemById(floorId)?.label || floorId
: findFloorItemById(floorId)?.label || floorId
})
}
const handleFloorChange = (floor: { id: string; label: string }) => {
const floorId = floor.id
if (!floorId || loadingFloorId.value) return
if (floorId === renderedFloorId.value && activeFloorId.value === floorId && props.layerMode !== 'multi') {
emit('floorChange', floorId)
emit('indoorViewChange', 'floor')
emit('layerModeChange', 'single')
return
}
requestedFloorId.value = floorId
requestedFloorLabel.value = floor.label
loadingFloorId.value = floorId
const requestSeq = ++floorSwitchRequestSeq
activeFloorSwitchRequestSeq.value = requestSeq
failedFloorId.value = ''
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000)
emit('floorRequest', {
floorId,
floorLabel: floor.label
})
emit('indoorViewChange', 'floor')
emit('layerModeChange', 'single')
Promise.resolve(indoorRendererRef.value?.switchFloor?.(floorId))
.then(() => {
markFloorSwitchFailedIfUnrendered(floorId, requestSeq)
})
.catch((error) => {
if (isStaleFloorSwitchError(error)) return
failedFloorId.value = floorId
clearFloorLoadingIfCurrent(floorId)
emit('floorSwitchFailed', {
floorId,
floorLabel: floor.label
})
console.error('楼层切换失败:', error)
})
}
const handleLayerModeChange = (mode: LayerDisplayMode) => {
// 手动切换展示层数时临时禁用缩放自动切换 10 秒
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000)
if (mode === 'multi') {
loadingFloorId.value = ''
requestedFloorId.value = ''
requestedFloorLabel.value = ''
void indoorRendererRef.value?.showMultiFloor?.()
emit('indoorViewChange', 'multi')
} else {
const floorId = activeFloorId.value
const floorLabel = findFloorItemById(floorId)?.label || floorId
if (!floorId) return
if (floorId === renderedFloorId.value && props.layerMode !== 'multi') {
emit('indoorViewChange', 'floor')
emit('layerModeChange', mode)
return
}
requestedFloorId.value = floorId
requestedFloorLabel.value = floorLabel
loadingFloorId.value = floorId
const requestSeq = ++floorSwitchRequestSeq
activeFloorSwitchRequestSeq.value = requestSeq
failedFloorId.value = ''
emit('floorRequest', {
floorId,
floorLabel
})
Promise.resolve(indoorRendererRef.value?.switchFloor?.(floorId))
.then(() => {
markFloorSwitchFailedIfUnrendered(floorId, requestSeq)
})
.catch((error) => {
if (isStaleFloorSwitchError(error)) return
failedFloorId.value = floorId
clearFloorLoadingIfCurrent(floorId)
emit('floorSwitchFailed', {
floorId,
floorLabel
})
console.error('楼层切换失败:', error)
})
emit('indoorViewChange', 'floor')
}
emit('layerModeChange', mode)
}
const handleLayerModeToggle = () => {
handleLayerModeChange(nextLayerMode.value)
}
const handleFloorHeaderTap = () => {
handleLayerModeChange(props.layerMode === 'multi' ? 'single' : 'multi')
}
const handleToolClick = (tool: string) => {
if (props.mapType === 'indoor') {
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000)
if (tool === '复位') {
indoorRendererRef.value?.resetCamera?.()
} else if (tool === '俯视') {
indoorRendererRef.value?.setCameraPreset?.('top')
} else if (tool === '斜视') {
indoorRendererRef.value?.setCameraPreset?.('oblique')
} else if (tool === '清除') {
indoorRendererRef.value?.clearSelection?.()
}
}
emit('toolClick', tool)
}
const toolIconType = (tool: string) => {
if (tool === '复位') return 'reset'
if (tool === '俯视') return 'top'
if (tool === '斜视') return 'oblique'
if (tool === '清除') return 'clear'
return 'text'
}
const handleZoomClick = (direction: 'in' | 'out') => {
indoorRendererRef.value?.zoomCamera?.(direction, { source: 'button' })
emit('toolClick', direction === 'in' ? '放大' : '缩小')
}
const handleMoreTap = () => {
emit('moreClick')
}
const handleThreeFloorChange = (floorId: string) => {
renderedFloorId.value = floorId
if (loadingFloorId.value === floorId) {
renderedFloorSwitchRequestSeq.value = activeFloorSwitchRequestSeq.value
}
if (failedFloorId.value === floorId) {
failedFloorId.value = ''
}
clearFloorLoadingIfCurrent(floorId)
emit('floorChange', floorId)
emit('indoorViewChange', 'floor')
emit('layerModeChange', 'single')
}
const handlePoiClick = (poi: GuideRenderPoi) => {
emit('poiClick', poi)
}
const handleSelectionClear = () => {
emit('selectionClear')
}
const handleTargetFocus = (result: TargetPoiFocusResult) => {
emit('targetFocus', result)
}
const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: string; distance: number }) => {
emit('autoSwitch', event)
}
const handleInitialModelProgress = (event: InitialModelProgressEvent) => {
emit('initialModelProgress', event)
}
const handleInitialModelReady = (event: { view: IndoorViewMode; floorId?: string; elapsedMs?: number }) => {
emit('initialModelReady', event)
}
const handleInitialModelFailed = (event: { view: IndoorViewMode; floorId?: string; message: string; elapsedMs?: number }) => {
emit('initialModelFailed', event)
}
const handleMapTap = (location: { latitude?: number; longitude?: number }) => {
const { latitude, longitude } = location
if (latitude !== undefined && longitude !== undefined) {
emit('mapTap', { latitude, longitude })
}
}
// 暴露 clearRoute 方法供父组件显式调用路线清除
defineExpose({
clearRoute: () => {
indoorRendererRef.value?.clearRoute?.()
}
})
</script>
<style scoped lang="scss">
.guide-map-shell {
position: relative;
z-index: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #ffffff;
color: #000000;
}
.map-layer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
background: var(--museum-bg-map);
border: 0;
box-sizing: border-box;
z-index: 0;
}
.indoor-three-map,
.outdoor-map {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.dim-layer {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.28);
z-index: 2;
}
.outdoor-map-bg {
position: absolute;
inset: 0;
overflow: hidden;
background: #f6f8f5;
}
.indoor-platform-fallback {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 24px;
box-sizing: border-box;
background: #f1f3ee;
text-align: center;
}
.indoor-platform-title {
font-size: 18px;
line-height: 24px;
font-weight: 700;
color: #1f2329;
}
.indoor-platform-desc {
max-width: 260px;
font-size: 13px;
line-height: 20px;
color: #626a73;
}
.guide-search-field {
position: absolute;
left: 16px;
right: 16px;
height: 36px;
display: flex;
align-items: center;
padding: 0 10px;
box-sizing: border-box;
background: var(--museum-bg-cream);
border: 1px solid #ffffff;
border-radius: 6px;
z-index: 40;
}
.guide-search-field.variant-home {
left: 20px;
right: 20px;
height: 46px;
padding: 0 14px;
background: rgba(255, 255, 255, 0.96);
border: 1px solid #d7dad3;
border-radius: 8px;
box-shadow: 0 8px 18px rgba(36, 49, 42, 0.08);
}
.search-field-text {
flex: 1;
min-width: 0;
font-size: 14px;
line-height: 19px;
color: #8b8b84;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.variant-home .search-field-text {
padding: 0 10px;
font-size: 15px;
line-height: 22px;
color: #8b9097;
}
.search-icon {
position: relative;
width: 16px;
height: 16px;
flex-shrink: 0;
}
.search-icon.leading {
width: 22px;
height: 22px;
}
.search-icon.leading .search-icon-circle {
top: 2px;
left: 1px;
width: 14px;
height: 14px;
border-width: 2px;
}
.search-icon.leading .search-icon-handle {
left: 14px;
top: 15px;
width: 10px;
height: 2px;
}
.voice-icon {
position: relative;
width: 22px;
height: 24px;
flex: 0 0 22px;
}
.voice-head {
position: absolute;
left: 6px;
top: 1px;
width: 10px;
height: 15px;
box-sizing: border-box;
border: 2px solid #151713;
border-radius: 7px;
}
.voice-stem {
position: absolute;
left: 10px;
top: 15px;
width: 2px;
height: 6px;
background: #151713;
border-radius: 2px;
}
.voice-base {
position: absolute;
left: 5px;
bottom: 0;
width: 12px;
height: 2px;
background: #151713;
border-radius: 2px;
}
.search-icon-circle {
position: absolute;
top: 2px;
left: 1px;
width: 9px;
height: 9px;
border: 1.4px solid #1f2329;
border-radius: 50%;
box-sizing: border-box;
}
.search-icon-handle {
position: absolute;
left: 10px;
top: 10px;
width: 8px;
height: 1.4px;
background: #1f2329;
border-radius: 2px;
transform: rotate(45deg);
transform-origin: left center;
}
.guide-mode-row {
position: absolute;
left: 16px;
right: 16px;
height: 34px;
z-index: 40;
}
.guide-mode-row.layout-full {
display: flex;
align-items: center;
padding: 3px;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #e6e6dd;
border-radius: 17px;
}
.guide-mode-row.layout-status {
display: flex;
align-items: center;
justify-content: space-between;
}
.compact-mode-switch {
width: 220px;
height: 34px;
display: flex;
align-items: center;
padding: 4px;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(230, 230, 230, 0.5);
border-radius: 17px;
}
.mode-option {
flex: 1;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 13px;
}
.layout-status .mode-option {
width: 106px;
flex: 0 0 106px;
}
.mode-option.active {
background: #000000;
}
.mode-label {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: #1f2329;
}
.mode-option.active .mode-label {
color: var(--museum-accent);
}
.mode-status-chip {
width: 111px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #e6e6e6;
border-radius: 14px;
}
.mode-status-chip.tone-glass {
background: rgba(255, 255, 255, 0.42);
border-color: transparent;
}
.mode-status-text {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: #696962;
}
.floor-switcher {
position: absolute;
width: 44px;
height: auto;
padding: 1px;
box-sizing: border-box;
overflow: hidden;
background: rgba(255, 255, 255, 0.84);
border: 1px solid #dde5df;
border-radius: 8px;
box-shadow: 0 8px 18px rgba(110, 127, 115, 0.12);
z-index: 35;
}
.floor-list {
display: flex;
flex-direction: column;
overflow: visible;
}
.floor-switcher.side-right {
right: 16px;
}
.floor-switcher.side-left {
left: 18px;
}
.floor-header {
position: relative;
min-height: 38px;
padding: 5px 4px 4px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
box-sizing: border-box;
background: #ffffff;
border-radius: 8px 8px 0 0;
}
.floor-header.active {
background: #000000;
}
.floor-header::after {
content: '';
position: absolute;
left: 7px;
right: 7px;
bottom: 0;
height: 1px;
background: #ecede7;
}
.floor-header-icon {
width: 16px;
height: 16px;
position: relative;
display: block;
color: #151713;
font-size: 0;
line-height: 0;
}
.floor-header-icon::before,
.floor-header-icon::after {
content: '';
position: absolute;
box-sizing: border-box;
border: 1.8px solid currentColor;
border-radius: 2px;
}
.floor-header-icon::before {
left: 2px;
top: 4px;
width: 11px;
height: 9px;
}
.floor-header-icon::after {
left: 5px;
top: 1px;
width: 11px;
height: 9px;
background: #ffffff;
}
.floor-header.active .floor-header-icon {
color: var(--museum-accent);
}
.floor-header-label {
font-size: 10px;
line-height: 12px;
color: #545861;
}
.floor-header.active .floor-header-label {
color: var(--museum-accent);
}
.layer-mode-toggle {
position: absolute;
right: 16px;
width: 46px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #dde5df;
border-radius: 8px;
box-shadow: 0 8px 18px rgba(110, 127, 115, 0.12);
z-index: 36;
}
.layer-mode-toggle:active {
background: #f5f7f2;
}
.layer-mode-label {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: #545861;
}
.floor-item {
position: relative;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
background: #ffffff;
}
.floor-item:first-child {
border-radius: 7px 7px 0 0;
}
.floor-item:last-child {
border-radius: 0 0 7px 7px;
}
.floor-item:not(:last-child)::after {
content: '';
position: absolute;
left: 7px;
right: 7px;
bottom: 0;
height: 1px;
background: #ecede7;
}
.floor-item.active {
background: #000000;
}
.floor-item.pending {
pointer-events: none;
opacity: 0.72;
}
.floor-item.pending::before {
content: '';
position: absolute;
inset: 5px;
border: 1px solid rgba(224, 225, 0, 0.9);
border-radius: 6px;
}
.floor-item.disabled {
pointer-events: none;
opacity: 0.46;
}
.floor-item.failed:not(.pending) .floor-label {
color: #b84a4a;
}
.floor-label {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: #545861;
}
.floor-item.active .floor-label {
color: var(--museum-accent);
}
.tool-stack {
position: absolute;
left: 18px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 35;
}
.tool-btn {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.92);
border: 1px solid #e4e5df;
border-radius: 8px;
box-sizing: border-box;
}
.tool-btn:active {
background: #f6f7f4;
transform: translateY(1px);
}
.tool-icon-svg {
width: 22px;
height: 22px;
stroke: #151713;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.tool-label {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: #1f2329;
}
.zoom-controls {
position: absolute;
right: 18px;
width: 48px;
overflow: hidden;
background: rgba(255, 255, 255, 0.94);
border: 1px solid #dde5df;
border-radius: 8px;
box-shadow: 0 8px 18px rgba(110, 127, 115, 0.12);
z-index: 35;
}
.zoom-btn {
height: 48px;
display: flex;
align-items: center;
justify-content: center;
}
.zoom-divider {
height: 1px;
margin: 0 9px;
background: #ecede7;
}
.zoom-text {
font-size: 28px;
line-height: 32px;
font-weight: 300;
color: #151713;
}
.location-control,
.more-control {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.95);
border: 1px solid #dde5df;
border-radius: 8px;
box-shadow: 0 8px 18px rgba(110, 127, 115, 0.12);
z-index: 35;
}
.location-control {
left: 18px;
width: 48px;
height: 48px;
}
.location-ring {
width: 20px;
height: 20px;
box-sizing: border-box;
border: 2px solid #151713;
border-radius: 50%;
}
.location-ring::before,
.location-ring::after {
content: '';
position: absolute;
background: #151713;
border-radius: 2px;
}
.location-ring::before {
left: 23px;
top: 10px;
width: 2px;
height: 28px;
}
.location-ring::after {
left: 10px;
top: 23px;
width: 28px;
height: 2px;
}
.location-dot {
position: absolute;
width: 6px;
height: 6px;
background: #151713;
border-radius: 50%;
}
.more-control {
right: 18px;
min-width: 54px;
height: 36px;
padding: 0 12px;
}
.more-control-text {
font-size: 13px;
line-height: 18px;
font-weight: 500;
color: #151713;
}
@media (min-width: 768px) {
.guide-map-shell {
max-width: 430px;
margin: 0 auto;
box-shadow: 0 12px 36px rgba(36, 49, 42, 0.16);
}
}
</style>