<template>
  <view class="three-map-container">
    <view ref="containerRef" class="three-canvas-wrapper"></view>

    <view v-if="isLoading && !loadError" class="loading-overlay">
      <view class="loading-content">
        <view class="loading-spinner"></view>
        <text class="loading-text">{{ loadingDisplayMessage }}</text>
        <view class="loading-progress">
          <view class="loading-bar" :style="{ width: `${loadingProgress}%` }"></view>
        </view>
      </view>
    </view>

    <view v-else-if="loadError" class="loading-overlay error-overlay">
      <view class="loading-content error-content">
        <text class="error-title">{{ modelLoadErrorTitle }}</text>
        <text class="error-text">{{ modelLoadErrorMessage }}</text>
        <view class="retry-btn" @tap="retryLoad">
          <text class="retry-text">重新加载</text>
        </view>
      </view>
    </view>

    <view v-if="showControls && floors.length" class="map-toolbar">
      <view class="overview-btn" :class="{ active: activeView === 'multi' }" @tap="showMultiFloor">
        <text class="overview-text">多层</text>
      </view>
    </view>

    <FloorSwitcher
      v-if="showControls && floors.length"
      :floors="floors"
      :current-floor="currentFloor"
      @floor-change="handleFloorChange"
    />

    <view v-if="showControls && selectedPOI" class="poi-detail-popup">
      <view class="poi-content">
        <text class="poi-name">{{ selectedPOI.name }}</text>
        <text class="poi-floor">{{ formatFloorLabel(selectedPOI.floorId) }} · {{ selectedPOI.primaryCategoryZh }}</text>
      </view>
    </view>
  </view>
</template>

<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import * as THREE from 'three'
import { GLTFLoader, type GLTF } from 'three/addons/loaders/GLTFLoader.js'
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import FloorSwitcher from './FloorSwitcher.vue'
import type {
  GuideModelFloorAsset,
  GuideModelRenderPackage,
  GuideModelSource,
  GuideRenderPoi
} from '@/domain/guideModel'
import type {
  GuideRouteEndpoint,
  GuideRouteFloorSegment,
  GuideRouteResult
} from '@/domain/museum'
import {
  compareFloorsTopToBottom
} from '@/domain/guideFloor'
import {
  guideModelLoadManager,
  type GuideModelResourceSet
} from '@/services/model/GuideModelLoadManager'
import {
  GuideAutoSwitchStateMachine,
  type GuideAutoSwitchDirection,
  type GuideAutoSwitchInputSource,
  type GuideAutoSwitchRequest
} from './guideAutoSwitchStateMachine'

type ViewMode = 'overview' | 'floor' | 'multi'
type CameraPreset = 'top' | 'oblique'
type TouchGestureMode = 'orbit' | 'pan'
type ZoomCameraSource = 'button' | 'gesture'
type InteractionPolicyMode = 'display' | 'explore'

interface InitialModelProgressEvent {
  progress: number
  message: string
  view: ViewMode
  floorId?: string
  elapsedMs?: number
}

const CANVAS_FONT_FAMILY = '"鸿蒙黑体", "HarmonyOS Sans SC", "HarmonyOS Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'
const FLOOR_CAMERA_DISTANCE_FACTOR = 0.78
const SGS_VISUAL_RENDER_CONFIG = {
  sceneBackground: 0xeceff1,
  camera: {
    fov: 42,
    near: 0.1,
    far: 20000,
    initialPosition: new THREE.Vector3(20, 20, 20)
  },
  renderer: {
    dprCap: 1.5,
    powerPreference: 'high-performance' as WebGLPowerPreference,
    antialias: true,
    alpha: false,
    toneMapping: THREE.NoToneMapping,
    toneMappingExposure: 1.0,
    shadows: false
  },
  lights: {
    hemisphere: {
      skyColor: 0xffffff,
      groundColor: 0xb0bec5,
      intensity: 1.7
    },
    key: {
      color: 0xffffff,
      intensity: 2.2,
      position: new THREE.Vector3(80, 120, 80)
    },
    fill: {
      color: 0xffffff,
      intensity: 0.55,
      position: new THREE.Vector3(-60, 70, -50)
    }
  },
  controls: {
    minDistance: 10,
    maxDistance: 600,
    minPolarAngle: 0,
    maxPolarAngle: Math.PI / 2 - 0.05,
    dampingFactor: 0.06
  },
  framing: {
    overviewScreenOffsetRatio: new THREE.Vector2(-0.045, -0.095),
    floorScreenOffsetRatio: new THREE.Vector2(0, -0.055)
  }
} as const

// 使用调试面板确认的建筑外观固定相机参数。
const OVERVIEW_INITIAL_CAMERA_PARAMS = {
  position: new THREE.Vector3(338.6842, 410.8487, 225.5939),
  target: new THREE.Vector3(29.0598, -61.8768, 23.9424),
  zoom: 1
} as const

const getIndoorInitialCameraDirection = () => (
  OVERVIEW_INITIAL_CAMERA_PARAMS.position
    .clone()
    .sub(OVERVIEW_INITIAL_CAMERA_PARAMS.target)
    .normalize()
)

const INDOOR_INITIAL_MODEL_PARAMS = {
  position: new THREE.Vector3(0, 0, 0),
  rotation: new THREE.Euler(
    THREE.MathUtils.degToRad(0),
    THREE.MathUtils.degToRad(0),
    THREE.MathUtils.degToRad(0),
    'XYZ'
  ),
  scale: new THREE.Vector3(1, 1, 1)
} as const

const MODEL_ADJUST_IDLE_REPORT_DELAY_MS = 800

const degToRad = (degrees: number) => THREE.MathUtils.degToRad(degrees)

const INTERACTION_POLICY = {
  display: {
    minPolarAngle: degToRad(35),
    maxPolarAngle: degToRad(76),
    minAzimuthAngle: degToRad(-75),
    maxAzimuthAngle: degToRad(75),
    allowPan: true,
    allowZoom: true
  },
  explore: {
    minPolarAngle: degToRad(30),
    maxPolarAngle: degToRad(78),
    minAzimuthAngle: degToRad(-90),
    maxAzimuthAngle: degToRad(90),
    allowPan: true,
    allowZoom: true
  }
} as const satisfies Record<InteractionPolicyMode, {
  minPolarAngle: number
  maxPolarAngle: number
  minAzimuthAngle: number
  maxAzimuthAngle: number
  allowPan: boolean
  allowZoom: boolean
}>

interface CameraFitOptions {
  distanceFactor?: number
  targetOffsetRatio?: THREE.Vector3
  screenOffsetRatio?: THREE.Vector2
  up?: THREE.Vector3
  onComplete?: () => void
}

interface LoadFloorOptions {
  preserveCurrentSceneUntilReady?: boolean
  suppressProgress?: boolean
  detachPoiBeforeLoad?: boolean
  allowSameFloorReload?: boolean
}

interface LoadModelOptions {
  suppressProgress?: boolean
}

interface CameraTweenState {
  fromPosition: THREE.Vector3
  toPosition: THREE.Vector3
  fromTarget: THREE.Vector3
  toTarget: THREE.Vector3
  startedAt: number
  durationMs: number
  onComplete?: () => void
}

type FloorIndexItem = GuideModelFloorAsset

interface FloorOption {
  id: string
  label: string
}

type RenderPoi = GuideRenderPoi
type PoiDisplayMode = 'core' | 'balanced' | 'detail'
type PoiVisibilityTier = 'tight' | 'balanced' | 'full'

interface PoiSpriteUserData {
  poi?: RenderPoi
  baseScale?: number
  hitTargetBaseScale?: number
  visibleMarker?: THREE.Sprite
  hitTarget?: THREE.Sprite
  labelSprite?: THREE.Sprite
  feedbackUntil?: number
  labelBaseScaleX?: number
  labelBaseScaleY?: number
  isPoiLabel?: boolean
  isPoiPulse?: boolean
  isPoiBase?: boolean
  isPoiHitTarget?: boolean
  isCorePoi?: boolean
}

interface FocusHallMaterialState {
  material: THREE.Material
  color?: THREE.Color
  emissive?: THREE.Color
  emissiveIntensity?: number
  opacity: number
  transparent: boolean
}

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
}

interface MultiFloorModelItem {
  floor: FloorIndexItem
  label: string
  model: THREE.Object3D
  size: THREE.Vector3
}

interface ReusableModelResources {
  geometries: Set<THREE.BufferGeometry>
  materials: Set<THREE.Material>
  textures: Set<THREE.Texture>
}

interface PoiMarkerCacheEntry {
  floorId: string
  displayMode: PoiDisplayMode
  pois: RenderPoi[]
  group: THREE.Group
  markerSize: number
  displayOffsetY: number
  rawPoiCount?: number
  rawPositionedPoiCount?: number
  filteredCategoryCounts?: Record<string, number>
}

interface PoiYDistribution {
  count: number
  min: number
  max: number
  median: number
}

interface ModelYBounds {
  min: number
  max: number
  center: number
  height: number
}

interface PreparedFloorScene {
  floor: FloorIndexItem
  model: THREE.Object3D
  poiEntry: PoiMarkerCacheEntry | null
  ownsModel: boolean
  cacheAsSharedModel: boolean
  protectedResources?: ReusableModelResources
}

type ContainerRef = HTMLElement | { $el?: HTMLElement } | null

const props = withDefaults(defineProps<{
  assetBaseUrl?: string
  modelSource: GuideModelSource
  initialFloorId?: string
  initialView?: ViewMode
  showControls?: boolean
  showPoi?: boolean
  visiblePoiIds?: string[] | null
  targetFocus?: TargetPoiFocusRequest | null
  touchGestureMode?: TouchGestureMode
  targetFocusDistanceFactor?: number
  routePreview?: GuideRouteResult | null
  showRoute?: boolean
  autoSwitch?: boolean
  disableAutoExit?: boolean
  autoSwitchCooldown?: number
}>(), {
  assetBaseUrl: '',
  initialFloorId: 'L1',
  initialView: 'overview',
  showControls: true,
  showPoi: false,
  visiblePoiIds: null,
  targetFocus: null,
  touchGestureMode: 'pan',
  targetFocusDistanceFactor: 0.36,
  routePreview: null,
  showRoute: false,
  autoSwitch: true,
  disableAutoExit: false,
  autoSwitchCooldown: 1500
})

const emit = defineEmits<{
  floorChange: [floorId: string]
  poiClick: [poi: RenderPoi]
  selectionClear: []
  targetFocus: [result: TargetPoiFocusResult]
  autoSwitch: [event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: 'zoom-in' | 'zoom-out'; distance: number }]
  autoSwitchBlocked: [reason: { reason: 'loading-locked' | 'cooldown' | 'disabled' }]
  initialModelProgress: [event: InitialModelProgressEvent]
  initialModelReady: [event: { view: ViewMode; floorId?: string; elapsedMs?: number }]
  initialModelFailed: [event: { view: ViewMode; floorId?: string; message: string; elapsedMs?: number }]
}>()

const containerRef = ref<ContainerRef>(null)
const isLoading = ref(true)
const loadError = ref(false)
const loadingProgress = ref(0)
const loadingMessage = ref('正在读取馆内导览资源...')
const loadingDisplayMessage = '正在加载馆内三维场景'
const modelLoadErrorTitle = '馆内三维场景加载失败'
const modelLoadErrorMessage = '可能是网络原因，请重新加载。'
const setFriendlyModelLoadError = () => {
  setProgress(0, modelLoadErrorMessage)
}
const activeView = ref<ViewMode>(props.initialView)
const currentFloor = ref(props.initialFloorId)
const selectedPOI = ref<RenderPoi | null>(null)
const activeFocusPoiId = ref('')
const floorIndex = ref<FloorIndexItem[]>([])
const renderPackage = ref<GuideModelRenderPackage | null>(null)

const floors = computed<FloorOption[]>(() => (
  [...floorIndex.value]
    .sort(compareFloorsTopToBottom)
    .map((floor) => ({
      id: floor.floorId,
      label: formatFloorLabel(floor.floorId)
    }))
))
const shouldRenderPoiMarkers = computed(() => (
  props.showPoi
  || props.showControls
  || props.visiblePoiIds !== null
  || Boolean(props.targetFocus)
))
const visiblePoiIdFilter = computed(() => (
  props.visiblePoiIds === null
    ? null
    : new Set(props.visiblePoiIds)
))

const isPoiIncludedByVisibleFilter = (poi: RenderPoi) => (
  visiblePoiIdFilter.value === null || visiblePoiIdFilter.value.has(poi.id)
)

let scene: THREE.Scene | null = null
let camera: THREE.PerspectiveCamera | null = null
let renderer: THREE.WebGLRenderer | null = null
let controls: OrbitControls | null = null
let loader: GLTFLoader | null = null
let dracoLoader: DRACOLoader | null = null
let activeModel: THREE.Object3D | null = null
let poiGroup: THREE.Group | null = null
let routeGroup: THREE.Group | null = null
let activeRoutePreviewSignature = ''
const poiDataCache = new Map<string, RenderPoi[]>()
const poiMarkerGroupCache = new Map<string, PoiMarkerCacheEntry>()
const poiDisplayOffsetYCache = new Map<string, number>()
const poiDisplayDiagnosticsKeys = new Set<string>()
let activeFocusLabelSprite: THREE.Sprite | null = null
let activeFocusPulseSprite: THREE.Sprite | null = null
let activeFocusBaseSprite: THREE.Sprite | null = null
let activeFocusHallGlowMesh: THREE.Mesh | null = null
let activeFocusHallMaterialStates: FocusHallMaterialState[] = []
let animationId = 0
let resizeObserver: ResizeObserver | null = null
let isDisposed = false
let pendingTargetFocus: TargetPoiFocusRequest | null = null
let targetFocusQueue: Promise<unknown> = Promise.resolve()
let modelLoadVersion = 0
let pendingRequestedFloorId = ''
let floorSwitchLoadToken = 0
let floorSwitchRequestedFloorId = ''
let isFloorSwitching = false
let hasLoadedFloorViewOnce = false
let pointerDownState: { x: number; y: number } | null = null
const activePointers = new Set<number>()
let activePointerTypes = new Map<number, string>()
let hadMultiPointerGesture = false
let isDesktopRotateModifierActive = false
let desktopRotateModifiers = {
  shift: false,
  ctrl: false,
  meta: false,
  alt: false
}
let cachedOverviewModel: THREE.Object3D | null = null
let cachedSharedModelUrl = ''

// 自动切换状态：所有缩放输入统一使用相对距离状态机。
let isAutoSwitchLocked = false
let autoSwitchTemporarilyDisabled = false
let autoSwitchDisableTimer: ReturnType<typeof setTimeout> | null = null
let modelAdjustReportTimer: ReturnType<typeof setTimeout> | null = null
let hasPendingManualModelAdjustment = false
let activeAutoSwitchInputSource: GuideAutoSwitchInputSource = 'gesture'
let isProgrammaticCameraChange = false
let programmaticCameraTimer: ReturnType<typeof setTimeout> | null = null
let cameraTween: CameraTweenState | null = null
let adjacentPreloadSeq = 0
let firstModelLoadStartedAt = 0
let firstModelVisibleReported = false
let initialModelSettled = false

const cameraTweenEnabled = true
const cameraTweenDurationMs = 480
const poiTapFeedbackDurationMs = 180
const poiHitTargetScaleMultiplier = 4.8
const poiHitTargetCoreScaleMultiplier = 5.4
const poiScreenHitRadiusPx = 64
const poiScreenHitRadiusCorePx = 76
const poiAmbientLabelHallSpacingPx = 92
const poiAmbientLabelServiceSpacingPx = 72
const modelLoadRetryDelaysMs = [300, 900]
const adjacentPreloadDelayMs = 900
const manualAutoSwitchPauseMs = 1500
const poiMarkerVisualLiftFactor = 0.92
const poiAmbientLabelLiftFactor = 2.1
const poiFocusLabelLiftFactor = 2.72
const poiFocusBaseLiftFactor = 0.18
const poiFocusPulseLiftFactor = 0.34

type ThreeMapDiagnosticEvent =
  | 'init-start'
  | 'package-ready'
  | 'model-load-start'
  | 'model-load-complete'
  | 'model-load-failed'
  | 'first-model-visible'
  | 'poi-background-start'
  | 'poi-background-ready'
  | 'poi-filter-diagnostics'
  | 'poi-marker-render-diagnostics'
  | 'poi-hit-diagnostics'
  | 'adjacent-preload-skip'

const getNow = () => (
  typeof performance !== 'undefined' && typeof performance.now === 'function'
    ? performance.now()
    : Date.now()
)

const isThreeMapDiagnosticsEnabled = () => {
  if (!import.meta.env.DEV) return false
  if (typeof window === 'undefined') return true

  const diagnosticsWindow = window as unknown as {
    __GUIDE_3D_MODEL_DIAGNOSTICS__?: boolean
  }
  return diagnosticsWindow.__GUIDE_3D_MODEL_DIAGNOSTICS__ !== false
}

const recordThreeMapDiagnostic = (
  event: ThreeMapDiagnosticEvent,
  payload: Record<string, unknown>
) => {
  if (typeof window === 'undefined') return

  const diagnosticsWindow = window as unknown as {
    __GUIDE_3D_DIAGNOSTIC_EVENTS__?: Array<{
      source: string
      event: string
      payload: Record<string, unknown>
    }>
  }
  diagnosticsWindow.__GUIDE_3D_DIAGNOSTIC_EVENTS__ ||= []
  diagnosticsWindow.__GUIDE_3D_DIAGNOSTIC_EVENTS__.push({
    source: 'ThreeMap',
    event,
    payload
  })
}

const stringifyDiagnosticPayload = (payload: Record<string, unknown>) => {
  try {
    return JSON.stringify(payload)
  } catch {
    return '[unserializable]'
  }
}

const logThreeMapDiagnostic = (
  event: ThreeMapDiagnosticEvent,
  payload: Record<string, unknown> = {}
) => {
  if (!isThreeMapDiagnosticsEnabled()) return
  recordThreeMapDiagnostic(event, payload)
  console.debug(`[ThreeMap] ${event} ${stringifyDiagnosticPayload(payload)}`)
}

const roundModelAdjustValue = (value: number) => Number(value.toFixed(4))

const serializeVector3 = (value: THREE.Vector3) => ({
  x: roundModelAdjustValue(value.x),
  y: roundModelAdjustValue(value.y),
  z: roundModelAdjustValue(value.z)
})

const serializeEulerDegrees = (value: THREE.Euler) => ({
  x: roundModelAdjustValue(THREE.MathUtils.radToDeg(value.x)),
  y: roundModelAdjustValue(THREE.MathUtils.radToDeg(value.y)),
  z: roundModelAdjustValue(THREE.MathUtils.radToDeg(value.z))
})

const clearModelAdjustReportTimer = () => {
  if (!modelAdjustReportTimer) return
  clearTimeout(modelAdjustReportTimer)
  modelAdjustReportTimer = null
}

const emitModelAdjustIdleReport = () => {
  modelAdjustReportTimer = null
  if (!hasPendingManualModelAdjustment || !camera || !controls || !activeModel) return

  hasPendingManualModelAdjustment = false
  const payload = {
    view: activeView.value,
    floorId: currentFloor.value,
    modelName: activeModel.name || 'unnamed-model',
    model: {
      position: serializeVector3(activeModel.position),
      rotationDeg: serializeEulerDegrees(activeModel.rotation),
      scale: serializeVector3(activeModel.scale)
    },
    camera: {
      position: serializeVector3(camera.position),
      target: serializeVector3(controls.target),
      distance: roundModelAdjustValue(controls.getDistance()),
      zoom: roundModelAdjustValue(camera.zoom)
    }
  }

  console.info('[ThreeMap] 模型停止移动，当前调参信息:', payload)
}

const scheduleModelAdjustIdleReport = () => {
  if (!hasPendingManualModelAdjustment || isProgrammaticCameraChange || !camera || !controls || !activeModel) return
  clearModelAdjustReportTimer()
  modelAdjustReportTimer = window.setTimeout(emitModelAdjustIdleReport, MODEL_ADJUST_IDLE_REPORT_DELAY_MS)
}

const getInteractionPolicyMode = (): InteractionPolicyMode => (
  activeView.value === 'overview' ? 'display' : 'explore'
)

const getActiveTouchPointerCount = () => (
  [...activePointerTypes.values()].filter((pointerType) => pointerType === 'touch').length
)

const updateDesktopRotateModifierState = (event?: KeyboardEvent | PointerEvent) => {
  if (event) {
    desktopRotateModifiers = {
      shift: event.shiftKey,
      ctrl: event.ctrlKey,
      meta: event.metaKey,
      alt: event.altKey
    }
  }

  isDesktopRotateModifierActive = (
    desktopRotateModifiers.shift
    || desktopRotateModifiers.ctrl
    || desktopRotateModifiers.meta
    || desktopRotateModifiers.alt
  )
}

const shouldEnableRotation = () => (
  getActiveTouchPointerCount() >= 2
  || isDesktopRotateModifierActive
)

const shouldUseDirectMouseRotate = (event?: PointerEvent) => {
  const hasAlt = event?.altKey ?? desktopRotateModifiers.alt
  const hasCtrlOrMeta = event
    ? event.ctrlKey || event.metaKey
    : desktopRotateModifiers.ctrl || desktopRotateModifiers.meta
  const hasShift = event?.shiftKey ?? desktopRotateModifiers.shift

  return (hasCtrlOrMeta || hasAlt) && !hasShift
}

const resetInteractionGateState = () => {
  activePointers.clear()
  activePointerTypes.clear()
  hadMultiPointerGesture = false
  pointerDownState = null
  desktopRotateModifiers = {
    shift: false,
    ctrl: false,
    meta: false,
    alt: false
  }
  isDesktopRotateModifierActive = false
  syncControlInteractionOptions()
}

const syncControlInteractionOptions = (event?: PointerEvent) => {
  if (!controls) return

  const policy = INTERACTION_POLICY[getInteractionPolicyMode()]
  const enableRotate = shouldEnableRotation()
  const enablePan = policy.allowPan && !enableRotate
  const directMouseRotate = enableRotate && shouldUseDirectMouseRotate(event)

  controls.enableRotate = enableRotate
  controls.enablePan = enablePan
  controls.enableZoom = policy.allowZoom
  controls.screenSpacePanning = true
  controls.panSpeed = 0.85
  controls.minPolarAngle = policy.minPolarAngle
  controls.maxPolarAngle = policy.maxPolarAngle
  controls.minAzimuthAngle = policy.minAzimuthAngle
  controls.maxAzimuthAngle = policy.maxAzimuthAngle

  controls.mouseButtons.LEFT = enableRotate
    ? directMouseRotate
      ? THREE.MOUSE.ROTATE
      : THREE.MOUSE.PAN
    : enablePan
      ? THREE.MOUSE.PAN
      : null
  controls.mouseButtons.MIDDLE = THREE.MOUSE.DOLLY
  controls.mouseButtons.RIGHT = directMouseRotate
    ? THREE.MOUSE.ROTATE
    : enablePan
      ? THREE.MOUSE.PAN
      : null
  controls.touches.ONE = enablePan ? THREE.TOUCH.PAN : null
  controls.touches.TWO = THREE.TOUCH.DOLLY_ROTATE
}

const corePoiCategories = new Set([
  'poi',
  'touring_poi',
  'exhibition_hall',
  'exhibition_hall_entrance',
  'basic_service_facility',
  'transport_circulation',
  'accessibility_special_service'
])

const poiCategoryPriority: Record<string, number> = {
  target_preview: 100,
  exhibition_hall: 96,
  exhibition_hall_entrance: 92,
  touring_poi: 90,
  poi: 86,
  accessibility_special_service: 88,
  basic_service_facility: 82,
  business_poi: 80,
  transport_circulation: 76,
  operation_experience: 46
}

const isHallPoi = (poi: RenderPoi) => (
  poi.kind === 'hall'
  || poi.primaryCategory === 'exhibition_hall'
  || poi.primaryCategory === 'touring_poi'
)

const isServiceFacilityPoi = (poi: RenderPoi) => (
  poi.primaryCategory === 'basic_service_facility'
  || poi.primaryCategory === 'accessibility_special_service'
  || poi.primaryCategory === 'business_poi'
)

const isTransportPoi = (poi: RenderPoi) => poi.primaryCategory === 'transport_circulation'

const poiGlyphMap: Record<string, string> = {
  poi: '点',
  accessible_restroom: '无',
  accessibility: '无',
  restroom: '卫',
  water: '水',
  rest_area: '休',
  elevator: '梯',
  stair: '梯',
  experience: '展',
  theater: '演',
  restaurant: '餐',
  cafe: '咖',
  shop: '购',
  bookstore: '书',
  cultural_shop: '文',
  business: '商',
  exhibition_hall: '展',
  hall_entrance: '门',
  entrance_exit: '门',
  target_preview: '位'
}

const getContainerElement = () => {
  const container = containerRef.value
  if (!container) return null

  if (typeof HTMLElement === 'undefined') return null

  if (container instanceof HTMLElement) {
    return container
  }

  const element = '$el' in container ? container.$el : null
  return element instanceof HTMLElement ? element : null
}

const formatFloorLabel = (floorId: string) => (
  floorIndex.value.find((floor) => floor.floorId === floorId)?.label || floorId
)

const floorExteriorNameKeywords = [
  '外墙',
  '玻璃幕墙',
  '楼顶',
  '屋顶',
  '馆外'
]

const overviewBuildingSubjectNameKeywords = [
  '外墙',
  '幕墙',
  '玻璃幕墙',
  '楼顶',
  '屋顶',
  '外墙装饰'
]

const isRenderableObject = (object: THREE.Object3D) => (
  object instanceof THREE.Mesh
  || object instanceof THREE.Line
  || object instanceof THREE.Points
)

const getModelNodeNames = (object: THREE.Object3D) => {
  const names: string[] = []
  let current: THREE.Object3D | null = object

  while (current) {
    if (current.name) {
      names.push(current.name)
    }

    current = current.parent
  }

  return names
}

const normalizeModelMatchKey = (value: string) => (
  value
    .trim()
    .toLowerCase()
    .replace(/[\s_\-./\\]/g, '')
)

const getPoiModelMatchKeys = (poi: RenderPoi) => (
  [
    poi.sourceObjectName,
    `${poi.floorId}_${poi.name}`,
    poi.name
  ]
    .filter((value): value is string => Boolean(value))
    .map(normalizeModelMatchKey)
    .filter(Boolean)
)

const isPoiModelNodeMatch = (object: THREE.Object3D, poi: RenderPoi) => {
  const keys = getPoiModelMatchKeys(poi)
  if (!keys.length) return false

  return getModelNodeNames(object)
    .map(normalizeModelMatchKey)
    .some((name) => keys.some((key) => name === key || name.includes(key)))
}

const findPoiModelRoot = (poi: RenderPoi): THREE.Object3D | null => {
  if (!activeModel) return null

  let matchedRoot: THREE.Object3D | null = null

  activeModel.traverse((child) => {
    if (matchedRoot || child === activeModel) return

    const childName = child.name ? normalizeModelMatchKey(child.name) : ''
    const keys = getPoiModelMatchKeys(poi)
    if (childName && keys.some((key) => childName === key || childName.includes(key))) {
      matchedRoot = child
    }
  })

  if (matchedRoot) return matchedRoot

  activeModel.traverse((child) => {
    if (matchedRoot || !(child instanceof THREE.Mesh) || !child.visible) return

    if (isPoiModelNodeMatch(child, poi)) {
      matchedRoot = child.parent && child.parent !== activeModel ? child.parent : child
    }
  })

  return matchedRoot
}

const isExteriorModelNode = (object: THREE.Object3D) => (
  getModelNodeNames(object).some((name) => (
    floorExteriorNameKeywords.some((keyword) => name.includes(keyword))
  ))
)

const isOverviewBuildingSubjectNode = (object: THREE.Object3D) => (
  getModelNodeNames(object).some((name) => (
    overviewBuildingSubjectNameKeywords.some((keyword) => name.includes(keyword))
  ))
)

const getOverviewBuildingSubjectBox = (object: THREE.Object3D) => {
  const box = new THREE.Box3()

  object.updateWorldMatrix(true, true)
  object.traverse((child) => {
    if (!child.visible || !isRenderableObject(child) || !isOverviewBuildingSubjectNode(child)) return

    const childBox = new THREE.Box3().setFromObject(child)
    if (!childBox.isEmpty()) {
      box.union(childBox)
    }
  })

  return box.isEmpty() ? getObjectBox(object) : box
}

const getKnownFloorMatchEntriesBySpecificity = () => (
  floorIndex.value
    .flatMap((floor) => [floor.floorId, floor.label, ...(floor.modelMatchKeys || [])]
      .filter((value): value is string => Boolean(value))
      .map((key) => ({ key, floorId: floor.floorId })))
    .sort((a, b) => b.key.length - a.key.length)
)

const getModelNodeFloorIdByName = (name: string) => {
  if (!name) return ''

  const normalizedName = normalizeModelMatchKey(name)
  return getKnownFloorMatchEntriesBySpecificity().find(({ key }) => (
    name === key
    || name.startsWith(`${key}_`)
    || name.startsWith(`${key}中心`)
    || normalizedName === normalizeModelMatchKey(key)
    || normalizedName.startsWith(normalizeModelMatchKey(key))
  ))?.floorId || ''
}

const getModelNodeFloorId = (object: THREE.Object3D) => (
  getModelNodeNames(object)
    .map(getModelNodeFloorIdByName)
    .find(Boolean) || ''
)

const setModelNodeVisibility = (model: THREE.Object3D, isVisible: (object: THREE.Object3D) => boolean) => {
  model.traverse((child) => {
    child.visible = child === model || !isRenderableObject(child) || isVisible(child)
  })
}

const showAllModelNodes = (model: THREE.Object3D) => {
  model.traverse((child) => {
    child.visible = true
  })
}

const showOnlyFloorModelNodes = (model: THREE.Object3D, floorId: string) => {
  let currentFloorRenderableCount = 0

  model.traverse((child) => {
    if (!isRenderableObject(child)) return

    const nodeFloorId = getModelNodeFloorId(child)
    if (nodeFloorId === floorId && !isExteriorModelNode(child)) {
      currentFloorRenderableCount += 1
    }
  })

  if (!currentFloorRenderableCount) {
    setModelNodeVisibility(model, (child) => !isExteriorModelNode(child))
    return
  }

  setModelNodeVisibility(model, (child) => (
    getModelNodeFloorId(child) === floorId
    && !isExteriorModelNode(child)
  ))
}

const applyModelVisibilityForView = (
  model: THREE.Object3D,
  view: ViewMode,
  floorId?: string
) => {
  if (view === 'floor' && floorId) {
    showOnlyFloorModelNodes(model, floorId)
    return
  }

  showAllModelNodes(model)
}

const getObjectBox = (object: THREE.Object3D) => {
  const box = new THREE.Box3()

  object.updateWorldMatrix(true, true)
  object.traverse((child) => {
    if (!child.visible || !isRenderableObject(child)) return

    const childBox = new THREE.Box3().setFromObject(child)
    if (!childBox.isEmpty()) {
      box.union(childBox)
    }
  })

  if (box.isEmpty()) {
    return new THREE.Box3().setFromObject(object)
  }

  return box
}

const getPoiDisplayMode = (): PoiDisplayMode => {
  if (activeView.value === 'overview') return 'core'
  if (activeView.value === 'multi') return 'balanced'
  return 'detail'
}

const getPoiMarkerCacheKey = (floorId: string, displayMode = getPoiDisplayMode()) => `${floorId}:${displayMode}`

const countPoisByCategory = (pois: RenderPoi[]) => pois.reduce<Record<string, number>>((counts, poi) => {
  const key = poi.primaryCategory || '<missing>'
  counts[key] = (counts[key] || 0) + 1
  return counts
}, {})

const summarizeSelectedPoiForDiagnostics = (poi: RenderPoi | null) => (
  poi
    ? {
        id: poi.id,
        name: poi.name,
        floorId: poi.floorId,
        primaryCategory: poi.primaryCategory
      }
    : null
)

const countPoiMarkerObjects = (group: THREE.Group) => {
  const counts = {
    markerCount: 0,
    hitTargetCount: 0,
    labelCount: 0
  }

  group.traverse((child) => {
    if (!(child instanceof THREE.Sprite)) return

    const userData = child.userData as PoiSpriteUserData
    if (userData.isPoiHitTarget) {
      counts.hitTargetCount += 1
      return
    }

    if (userData.isPoiLabel) {
      counts.labelCount += 1
      return
    }

    if (userData.poi) {
      counts.markerCount += 1
    }
  })

  return counts
}

const summarizePoiYDistribution = (pois: RenderPoi[]): PoiYDistribution | null => {
  const values = pois
    .map((poi) => poi.positionGltf?.[1])
    .filter((value): value is number => Number.isFinite(value))
    .sort((left, right) => left - right)

  if (!values.length) return null

  const middle = Math.floor(values.length / 2)
  const median = values.length % 2
    ? values[middle]
    : (values[middle - 1] + values[middle]) / 2

  return {
    count: values.length,
    min: values[0],
    max: values[values.length - 1],
    median
  }
}

const getModelYBounds = (model: THREE.Object3D): ModelYBounds => {
  const box = getObjectBox(model)
  const height = Math.max(box.max.y - box.min.y, 0)

  return {
    min: box.min.y,
    max: box.max.y,
    center: (box.min.y + box.max.y) / 2,
    height
  }
}

const roundDiagnosticNumber = (value: number) => Math.round(value * 1000) / 1000

const getPoiDisplayOffsetCacheKey = (floorId: string) => floorId

const getCachedPoiDisplayOffsetY = (floorId: string) => (
  poiDisplayOffsetYCache.get(getPoiDisplayOffsetCacheKey(floorId)) || 0
)

const getPoiDisplayPosition = (poi: RenderPoi, floorId = poi.floorId) => {
  if (!poi.positionGltf) return null

  const [x, y, z] = poi.positionGltf
  return new THREE.Vector3(x, y + getCachedPoiDisplayOffsetY(floorId), z)
}

const getPoiLiftedPosition = (
  displayPosition: THREE.Vector3,
  markerSize: number,
  liftFactor: number
) => new THREE.Vector3(
  displayPosition.x,
  displayPosition.y + markerSize * liftFactor,
  displayPosition.z
)

const getPoiMarkerPosition = (displayPosition: THREE.Vector3, markerSize: number) => (
  getPoiLiftedPosition(displayPosition, markerSize, poiMarkerVisualLiftFactor)
)

const getPoiAmbientLabelPosition = (displayPosition: THREE.Vector3, markerSize: number) => (
  getPoiLiftedPosition(displayPosition, markerSize, poiAmbientLabelLiftFactor)
)

const resolvePoiDisplayOffsetY = (
  floorId: string,
  model: THREE.Object3D,
  pois: RenderPoi[]
) => {
  const modelY = getModelYBounds(model)
  const poiY = summarizePoiYDistribution(pois)
  let offsetY = 0
  let reason = 'no-positioned-poi'

  if (poiY) {
    const modelTolerance = Math.max(2, modelY.height * 0.35)
    const overlapsModelRange = poiY.max >= modelY.min - modelTolerance && poiY.min <= modelY.max + modelTolerance

    if (overlapsModelRange) {
      reason = 'poi-y-overlaps-model-y'
    } else {
      offsetY = modelY.center - poiY.median
      reason = 'align-poi-median-to-model-center'
    }
  }

  poiDisplayOffsetYCache.set(getPoiDisplayOffsetCacheKey(floorId), offsetY)

  if (import.meta.env.DEV) {
    const diagnosticsKey = [
      floorId,
      roundDiagnosticNumber(modelY.min),
      roundDiagnosticNumber(modelY.max),
      poiY ? roundDiagnosticNumber(poiY.min) : 'none',
      poiY ? roundDiagnosticNumber(poiY.max) : 'none',
      roundDiagnosticNumber(offsetY)
    ].join(':')

    if (!poiDisplayDiagnosticsKeys.has(diagnosticsKey)) {
      poiDisplayDiagnosticsKeys.add(diagnosticsKey)
      console.info('[ThreeMap] floor POI display coordinate diagnostics', {
        floorId,
        modelY: {
          min: roundDiagnosticNumber(modelY.min),
          max: roundDiagnosticNumber(modelY.max),
          center: roundDiagnosticNumber(modelY.center),
          height: roundDiagnosticNumber(modelY.height)
        },
        poiY: poiY
          ? {
            count: poiY.count,
            min: roundDiagnosticNumber(poiY.min),
            max: roundDiagnosticNumber(poiY.max),
            median: roundDiagnosticNumber(poiY.median)
          }
          : null,
        displayOffsetY: roundDiagnosticNumber(offsetY),
        reason
      })
    }
  }

  return offsetY
}

const getPoiGlyph = (poi: RenderPoi) => (
  poiGlyphMap[poi.iconType] || poi.name.charAt(0) || '点'
)

const shouldShowPoiInCurrentMode = (poi: RenderPoi) => {
  const mode = getPoiDisplayMode()

  if (mode === 'core') {
    return corePoiCategories.has(poi.primaryCategory)
  }

  if (mode === 'balanced') {
    return corePoiCategories.has(poi.primaryCategory) || poi.primaryCategory === 'touring_poi'
  }

  return true
}

const getPoiSpriteUserData = (sprite: THREE.Sprite) => sprite.userData as PoiSpriteUserData

const isPoiAuxiliarySprite = (sprite: THREE.Sprite) => (
  Boolean(
    sprite.userData.isPoiLabel
    || sprite.userData.isPoiPulse
    || sprite.userData.isPoiBase
    || sprite.userData.isPoiHitTarget
  )
)

const isPoiHitTargetSprite = (child: THREE.Object3D): child is THREE.Sprite => (
  child instanceof THREE.Sprite
  && Boolean(child.userData.isPoiHitTarget)
  && Boolean(child.userData.poi)
)

const getActiveModelSpan = () => {
  if (!activeModel) return 1

  const size = getObjectSize(activeModel)
  return Math.max(size.x, size.y, size.z, 1)
}

const getPoiVisibilityTier = (): PoiVisibilityTier => {
  if (!controls || !activeModel) return 'full'

  const ratio = controls.getDistance() / getActiveModelSpan()

  if (ratio >= 0.32) return 'tight'
  if (ratio >= 0.2) return 'balanced'
  return 'full'
}

const shouldShowPoiAtDistance = (poi: RenderPoi, tier: PoiVisibilityTier) => {
  if (poi.id === activeFocusPoiId.value || poi.primaryCategory === 'target_preview') {
    return true
  }

  if (activeView.value === 'floor' && isHallPoi(poi)) {
    return true
  }

  if (tier === 'tight') {
    return corePoiCategories.has(poi.primaryCategory) && !isTransportPoi(poi)
  }

  if (tier === 'balanced') {
    return !isTransportPoi(poi) && (
      corePoiCategories.has(poi.primaryCategory)
      || poi.primaryCategory === 'touring_poi'
    )
  }

  return true
}

const getPoiVisibilityLimit = (tier: PoiVisibilityTier) => {
  if (tier === 'tight') return 5
  if (tier === 'balanced') return 9
  return Number.POSITIVE_INFINITY
}

const getPoiScreenSpacing = (tier: PoiVisibilityTier) => {
  if (tier === 'tight') return 116
  if (tier === 'balanced') return 78
  return 0
}

const getPoiPriority = (poi: RenderPoi) => {
  const categoryPriority = poiCategoryPriority[poi.primaryCategory] || 20
  const selectedBoost = poi.id === activeFocusPoiId.value ? 1000 : 0
  const hallBoost = activeView.value === 'floor' && isHallPoi(poi) ? 40 : 0
  const transportPenalty = isTransportPoi(poi) ? 18 : 0

  return selectedBoost + hallBoost + categoryPriority - transportPenalty
}

const getPoiLabelDensityTier = (): PoiVisibilityTier => {
  if (!controls || !activeModel) return 'full'

  const ratio = controls.getDistance() / getActiveModelSpan()

  if (ratio >= 0.42) return 'tight'
  if (ratio >= 0.26) return 'balanced'
  return 'full'
}

const shouldCreateAmbientPoiLabel = (poi: RenderPoi) => (
  isHallPoi(poi)
  || isServiceFacilityPoi(poi)
)

const shouldShowAmbientPoiLabel = (
  poi: RenderPoi,
  markerVisible: boolean,
  densityTier: PoiVisibilityTier
) => {
  if (!markerVisible || activeView.value !== 'floor') return false
  if (poi.id === activeFocusPoiId.value || poi.primaryCategory === 'target_preview') return false
  if (isHallPoi(poi)) return true
  if (isTransportPoi(poi)) return false
  if (isServiceFacilityPoi(poi)) return densityTier === 'full'

  return false
}

const getPoiAmbientLabelPriority = (poi: RenderPoi) => {
  if (isHallPoi(poi)) return getPoiPriority(poi) + 120
  if (isServiceFacilityPoi(poi)) return getPoiPriority(poi) + 20

  return getPoiPriority(poi)
}

const getAmbientLabelScaleBoost = (poi: RenderPoi) => {
  if (!controls || !activeModel) return 1

  const distanceRatio = controls.getDistance() / getActiveModelSpan()
  const baseBoost = Math.min(1.55, Math.max(0.92, distanceRatio / 0.28))
  return isHallPoi(poi) ? Math.max(1, baseBoost) : baseBoost
}

const updateAmbientLabelScale = (labelSprite: THREE.Sprite, poi: RenderPoi) => {
  const userData = getPoiSpriteUserData(labelSprite)
  const baseScaleX = typeof userData.labelBaseScaleX === 'number'
    ? userData.labelBaseScaleX
    : labelSprite.scale.x
  const baseScaleY = typeof userData.labelBaseScaleY === 'number'
    ? userData.labelBaseScaleY
    : labelSprite.scale.y
  const boost = getAmbientLabelScaleBoost(poi)

  labelSprite.scale.set(baseScaleX * boost, baseScaleY * boost, 1)
}

const getProjectedScreenPosition = (object: THREE.Object3D) => {
  if (!camera || !renderer) return null

  const projected = object.position.clone().project(camera)
  if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y) || projected.z < -1 || projected.z > 1) {
    return null
  }

  return new THREE.Vector2(
    (projected.x + 1) * renderer.domElement.clientWidth * 0.5,
    (1 - projected.y) * renderer.domElement.clientHeight * 0.5
  )
}

const getAmbientLabelBounds = (center: THREE.Vector2, poi: RenderPoi) => {
  const width = isHallPoi(poi) ? 138 : 112
  const height = isHallPoi(poi) ? 44 : 34

  return {
    left: center.x - width * 0.5,
    right: center.x + width * 0.5,
    top: center.y - height * 0.5,
    bottom: center.y + height * 0.5
  }
}

const labelBoundsOverlap = (
  left: ReturnType<typeof getAmbientLabelBounds>,
  right: ReturnType<typeof getAmbientLabelBounds>,
  spacing: number
) => (
  left.left - spacing < right.right
  && left.right + spacing > right.left
  && left.top - spacing < right.bottom
  && left.bottom + spacing > right.top
)

const updateAmbientPoiLabels = () => {
  if (!poiGroup || !camera || !renderer) return

  const densityTier = getPoiLabelDensityTier()
  const acceptedBounds: Array<ReturnType<typeof getAmbientLabelBounds>> = []
  const markerSize = getPoiMarkerSize()
  const candidates = getPoiSprites()
    .map((sprite) => {
      const poi = sprite.userData.poi as RenderPoi | undefined
      const labelSprite = getPoiSpriteUserData(sprite).labelSprite

      return poi && labelSprite
        ? {
            marker: sprite,
            labelSprite,
            poi,
            priority: getPoiAmbientLabelPriority(poi)
          }
        : null
    })
    .filter((candidate): candidate is {
      marker: THREE.Sprite
      labelSprite: THREE.Sprite
      poi: RenderPoi
      priority: number
    } => Boolean(candidate))
    .sort((left, right) => right.priority - left.priority)

  candidates.forEach(({ marker, labelSprite, poi }) => {
    const displayPosition = getPoiDisplayPosition(poi)
    if (!displayPosition || !shouldShowAmbientPoiLabel(poi, marker.visible, densityTier)) {
      labelSprite.visible = false
      return
    }

    updateAmbientLabelScale(labelSprite, poi)

    const baseOffset = markerSize * (isHallPoi(poi) ? poiAmbientLabelLiftFactor : 1.82)
    const offsetAttempts = [
      0,
      markerSize * 0.82,
      -markerSize * 0.62,
      markerSize * 1.48,
      -markerSize * 1.08
    ]
    const spacing = isHallPoi(poi) ? poiAmbientLabelHallSpacingPx : poiAmbientLabelServiceSpacingPx
    let accepted = false

    for (const offset of offsetAttempts) {
      labelSprite.position.set(displayPosition.x, displayPosition.y + baseOffset + offset, displayPosition.z)
      const screenPosition = getProjectedScreenPosition(labelSprite)
      if (!screenPosition) continue

      const bounds = getAmbientLabelBounds(screenPosition, poi)
      const overlaps = acceptedBounds.some((acceptedBound) => labelBoundsOverlap(bounds, acceptedBound, spacing))
      if (overlaps) continue

      labelSprite.visible = true
      acceptedBounds.push(bounds)
      accepted = true
      break
    }

    if (!accepted) {
      if (isHallPoi(poi)) {
        const fallbackOffset = offsetAttempts[offsetAttempts.length - 1]
        labelSprite.position.set(
          displayPosition.x,
          displayPosition.y + baseOffset + fallbackOffset,
          displayPosition.z
        )
        labelSprite.visible = true
      } else {
        labelSprite.visible = false
      }
    }
  })
}

const updatePoiVisibilityByDistance = () => {
  if (!poiGroup || !controls || !activeModel || !camera || !renderer) return

  const tier = getPoiVisibilityTier()
  const limit = getPoiVisibilityLimit(tier)
  const spacing = getPoiScreenSpacing(tier)
  const acceptedPositions: THREE.Vector2[] = []
  let visibleCount = 0

  const candidates = getPoiSprites()
    .map((sprite) => {
      const poi = sprite.userData.poi as RenderPoi | undefined
      const screenPosition = sprite.position.clone().project(camera!)

      return {
        sprite,
        poi,
        screenPosition: new THREE.Vector2(
          (screenPosition.x + 1) * renderer!.domElement.clientWidth * 0.5,
          (1 - screenPosition.y) * renderer!.domElement.clientHeight * 0.5
        )
      }
    })
    .sort((a, b) => (b.poi ? getPoiPriority(b.poi) : 0) - (a.poi ? getPoiPriority(a.poi) : 0))

  candidates.forEach(({ sprite, poi, screenPosition }) => {
    if (!poi || !isPoiIncludedByVisibleFilter(poi)) {
      sprite.visible = false
      const hitTarget = getPoiSpriteUserData(sprite).hitTarget
      const labelSprite = getPoiSpriteUserData(sprite).labelSprite
      if (hitTarget) {
        hitTarget.visible = false
      }
      if (labelSprite) {
        labelSprite.visible = false
      }
      return
    }

    // 分类结果模式直接展示列表对应点位，避免密度策略再次裁剪结果。
    if (visiblePoiIdFilter.value !== null) {
      sprite.visible = true
      const hitTarget = getPoiSpriteUserData(sprite).hitTarget
      if (hitTarget) {
        hitTarget.visible = true
      }
      acceptedPositions.push(screenPosition)
      visibleCount += 1
      return
    }

    const isSelected = poi.id === activeFocusPoiId.value || poi.primaryCategory === 'target_preview'
    const isPinnedHall = activeView.value === 'floor' && isHallPoi(poi)
    const categoryVisible = shouldShowPoiAtDistance(poi, tier)
    const isTooClose = spacing > 0 && acceptedPositions.some((position) => position.distanceTo(screenPosition) < spacing)
    const exceedsLimit = Number.isFinite(limit) && visibleCount >= limit

    if (!categoryVisible || (!isSelected && !isPinnedHall && (isTooClose || exceedsLimit))) {
      sprite.visible = false
      const hitTarget = getPoiSpriteUserData(sprite).hitTarget
      const labelSprite = getPoiSpriteUserData(sprite).labelSprite
      if (hitTarget) {
        hitTarget.visible = false
      }
      if (labelSprite) {
        labelSprite.visible = false
      }
      return
    }

    sprite.visible = true
    const hitTarget = getPoiSpriteUserData(sprite).hitTarget
    if (hitTarget) {
      hitTarget.visible = true
    }
    acceptedPositions.push(screenPosition)
    visibleCount += 1
  })

  updateAmbientPoiLabels()
}

const refreshPoiVisibilityByDistance = () => {
  updatePoiVisibilityByDistance()
}

const handleControlChange = () => {
  updatePoiVisibilityByDistance()
  checkAutoSwitch()
  scheduleModelAdjustIdleReport()
}

const clearProgrammaticCameraTimer = () => {
  if (programmaticCameraTimer) {
    clearTimeout(programmaticCameraTimer)
    programmaticCameraTimer = null
  }
}

const endProgrammaticCameraChangeSoon = (onComplete?: () => void) => {
  clearProgrammaticCameraTimer()
  programmaticCameraTimer = window.setTimeout(() => {
    isProgrammaticCameraChange = false
    programmaticCameraTimer = null
    onComplete?.()
  }, 120)
}

const easeInOutCubic = (t: number) => (
  t < 0.5
    ? 4 * t * t * t
    : 1 - ((-2 * t + 2) ** 3) / 2
)

const cancelCameraTween = (options: { manual?: boolean } = {}) => {
  if (!cameraTween) return

  cameraTween = null
  if (options.manual) {
    isProgrammaticCameraChange = false
    clearProgrammaticCameraTimer()
    const distance = controls?.getDistance()
    if (typeof distance === 'number' && Number.isFinite(distance)) {
      if (activeView.value === 'floor') {
        // 用户中断楼层初始拟合时，使用中断后的稳定距离重新建立退出基准。
        autoSwitchStateMachine.setFloorInitialDistance(distance)
      }
    }
  }
}

const moveCameraTo = (
  position: THREE.Vector3,
  target: THREE.Vector3,
  options: { durationMs?: number; immediate?: boolean; onComplete?: () => void } = {}
) => {
  if (!camera || !controls) return

  const toPosition = position.clone()
  const toTarget = target.clone()
  isProgrammaticCameraChange = true
  clearProgrammaticCameraTimer()

  if (!cameraTweenEnabled || options.immediate) {
    cameraTween = null
    camera.position.copy(toPosition)
    controls.target.copy(toTarget)
    controls.update()
    updatePoiVisibilityByDistance()
    endProgrammaticCameraChangeSoon(options.onComplete)
    return
  }

  cameraTween = {
    fromPosition: camera.position.clone(),
    toPosition,
    fromTarget: controls.target.clone(),
    toTarget,
    startedAt: performance.now(),
    durationMs: options.durationMs ?? cameraTweenDurationMs,
    onComplete: options.onComplete
  }
}

const updateCameraTween = (now: number) => {
  if (!cameraTween || !camera || !controls) return

  const elapsed = now - cameraTween.startedAt
  const progress = Math.min(1, Math.max(0, elapsed / cameraTween.durationMs))
  const eased = easeInOutCubic(progress)

  camera.position.lerpVectors(cameraTween.fromPosition, cameraTween.toPosition, eased)
  controls.target.lerpVectors(cameraTween.fromTarget, cameraTween.toTarget, eased)
  controls.update()
  updatePoiVisibilityByDistance()

  if (progress >= 1) {
    camera.position.copy(cameraTween.toPosition)
    controls.target.copy(cameraTween.toTarget)
    controls.update()
    const { onComplete } = cameraTween
    cameraTween = null
    endProgrammaticCameraChangeSoon(onComplete)
  }
}

const updatePoiTapFeedback = (now: number) => {
  getPoiSprites().forEach((sprite) => {
    const userData = getPoiSpriteUserData(sprite)
    const feedbackUntil = userData.feedbackUntil || 0
    const poi = userData.poi
    const focused = Boolean(poi && poi.id === activeFocusPoiId.value)

    if (feedbackUntil <= now) {
      if (feedbackUntil) {
        userData.feedbackUntil = 0
        setPoiSpriteFocusStyle(sprite, focused)
      }
      return
    }

    const remaining = Math.max(0, feedbackUntil - now)
    const progress = remaining / poiTapFeedbackDurationMs
    const baseScale = typeof userData.baseScale === 'number'
      ? userData.baseScale
      : sprite.scale.x
    const scaleBoost = userData.isCorePoi ? 1.18 : 1
    const selectedBoost = focused ? 1.62 : 1
    const tapBoost = 1 + Math.sin(progress * Math.PI) * 0.22
    const scale = baseScale * scaleBoost * selectedBoost * tapBoost

    sprite.scale.set(scale, scale, scale)
    sprite.material.opacity = Math.min(1, focused ? 1 : 0.92 + progress * 0.08)
  })
}

const handleControlStart = () => {
  const distance = controls?.getDistance()
  if (typeof distance === 'number' && Number.isFinite(distance)) {
    autoSwitchStateMachine.beginInput(distance, activeAutoSwitchInputSource)
  }
  if (!isProgrammaticCameraChange) {
    hasPendingManualModelAdjustment = true
    clearModelAdjustReportTimer()
  }
  cancelCameraTween({ manual: true })
}

const handleControlEnd = () => {
  activeAutoSwitchInputSource = 'gesture'
  scheduleModelAdjustIdleReport()
}

const setProgress = (progress: number, message: string) => {
  const normalizedProgress = Math.min(100, Math.max(0, progress))
  loadingProgress.value = normalizedProgress
  loadingMessage.value = message

  if (firstModelLoadStartedAt && !initialModelSettled) {
    emit('initialModelProgress', {
      progress: normalizedProgress,
      message,
      view: activeView.value,
      floorId: currentFloor.value,
      elapsedMs: Math.round(getNow() - firstModelLoadStartedAt)
    })
  }
}

const markFirstModelVisible = (
  view: ViewMode,
  payload: Record<string, unknown> = {}
) => {
  if (firstModelVisibleReported) return
  firstModelVisibleReported = true

  window.requestAnimationFrame(() => {
    logThreeMapDiagnostic('first-model-visible', {
      view,
      elapsedMs: firstModelLoadStartedAt ? Math.round(getNow() - firstModelLoadStartedAt) : undefined,
      diagnostics: guideModelLoadManager.getDiagnostics(),
      ...payload
    })
  })
}

const staleModelLoadMessage = 'STALE_MODEL_LOAD'

const startModelLoad = () => {
  modelLoadVersion += 1
  return modelLoadVersion
}

const invalidateModelLoads = () => {
  modelLoadVersion += 1
  pendingRequestedFloorId = ''
  floorSwitchLoadToken = 0
  floorSwitchRequestedFloorId = ''
  isFloorSwitching = false
}

const isCurrentModelLoad = (loadToken: number) => (
  loadToken === modelLoadVersion && !isDisposed && Boolean(scene)
)

const createStaleModelLoadError = () => new Error(staleModelLoadMessage)

const isStaleModelLoadError = (error: unknown) => (
  error instanceof Error && error.message === staleModelLoadMessage
)

const startFloorContextTransaction = (requestedFloorId: string) => {
  const loadToken = startModelLoad()
  pendingRequestedFloorId = requestedFloorId
  floorSwitchLoadToken = loadToken
  floorSwitchRequestedFloorId = requestedFloorId
  isFloorSwitching = true
  return loadToken
}

const isCurrentFloorContextTransaction = (loadToken: number, requestedFloorId: string) => (
  isFloorSwitching
  && floorSwitchLoadToken === loadToken
  && floorSwitchRequestedFloorId === requestedFloorId
  && pendingRequestedFloorId === requestedFloorId
  && isCurrentModelLoad(loadToken)
)

const assertCurrentFloorContextTransaction = (
  loadToken: number,
  requestedFloorId: string,
  staleObject?: THREE.Object3D
) => {
  if (isCurrentFloorContextTransaction(loadToken, requestedFloorId)) return

  if (staleObject) {
    disposeObject(staleObject)
  }

  throw createStaleModelLoadError()
}

const completeFloorContextTransaction = (loadToken: number, requestedFloorId: string) => {
  if (!isCurrentFloorContextTransaction(loadToken, requestedFloorId)) return

  isFloorSwitching = false
  floorSwitchLoadToken = 0
  floorSwitchRequestedFloorId = ''
  pendingRequestedFloorId = ''
}

const assertCurrentModelLoad = (loadToken: number, staleObject?: THREE.Object3D) => {
  if (isCurrentModelLoad(loadToken)) return

  if (staleObject) {
    disposeObject(staleObject)
  }

  throw createStaleModelLoadError()
}

const assertActiveFloorModelState = (loadToken: number, requestedFloorId: string) => {
  assertCurrentModelLoad(loadToken)

  const activeModelFloorId = typeof activeModel?.userData.floorId === 'string'
    ? activeModel.userData.floorId
    : ''
  if (
    activeView.value !== 'floor'
    || currentFloor.value !== requestedFloorId
    || !activeModel
    || activeModelFloorId !== requestedFloorId
  ) {
    throw new Error(`楼层模型状态不一致：请求 ${requestedFloorId}，当前 ${currentFloor.value || '未知'}，模型 ${activeModelFloorId || '未知'}`)
  }
}

const assertPreparedFloorScene = (
  prepared: PreparedFloorScene,
  loadToken: number,
  expectedFloorId: string
) => {
  assertCurrentModelLoad(loadToken, prepared.ownsModel ? prepared.model : undefined)

  if (pendingRequestedFloorId !== expectedFloorId) {
    if (prepared.ownsModel) {
      disposeObject(prepared.model, prepared.protectedResources)
    }
    throw createStaleModelLoadError()
  }

  const modelFloorId = typeof prepared.model.userData.floorId === 'string'
    ? prepared.model.userData.floorId
    : ''
  if (modelFloorId !== expectedFloorId) {
    if (prepared.ownsModel) {
      disposeObject(prepared.model, prepared.protectedResources)
    }
    throw new Error(`楼层模型校验失败：请求 ${expectedFloorId}，模型 ${modelFloorId || '未知'}`)
  }

  const poiFloorId = typeof prepared.poiEntry?.group.userData.floorId === 'string'
    ? prepared.poiEntry.group.userData.floorId
    : ''
  if (prepared.poiEntry && (prepared.poiEntry.floorId !== expectedFloorId || poiFloorId !== expectedFloorId)) {
    if (prepared.ownsModel) {
      disposeObject(prepared.model, prepared.protectedResources)
    }
    throw new Error(`楼层点位校验失败：请求 ${expectedFloorId}，点位 ${poiFloorId || prepared.poiEntry.floorId || '未知'}`)
  }
}

const assertCommittedFloorScene = (loadToken: number, expectedFloorId: string) => {
  assertActiveFloorModelState(loadToken, expectedFloorId)

  const poiFloorId = typeof poiGroup?.userData.floorId === 'string'
    ? poiGroup.userData.floorId
    : ''
  const poiCurrentFloor = typeof poiGroup?.userData.currentFloor === 'string'
    ? poiGroup.userData.currentFloor
    : ''

  if (
    shouldRenderPoiMarkers.value
    && poiGroup
    && (poiFloorId !== expectedFloorId || poiCurrentFloor !== expectedFloorId)
  ) {
    throw new Error(`楼层点位状态不一致：请求 ${expectedFloorId}，点位 ${poiFloorId || '未知'}，当前 ${poiCurrentFloor || '未知'}`)
  }
}

const waitForContainer = async () => {
  await nextTick()

  for (let index = 0; index < 20; index += 1) {
    const container = getContainerElement()
    if (container && container.clientWidth > 0 && container.clientHeight > 0) {
      return container
    }

    await new Promise((resolve) => setTimeout(resolve, 50))
  }

  return getContainerElement()
}

const initThree = async () => {
  const container = await waitForContainer()
  if (!container) {
    throw new Error('3D 容器未就绪')
  }

  scene = new THREE.Scene()
  scene.background = new THREE.Color(SGS_VISUAL_RENDER_CONFIG.sceneBackground)

  const width = Math.max(1, container.clientWidth)
  const height = Math.max(1, container.clientHeight)
  camera = new THREE.PerspectiveCamera(
    SGS_VISUAL_RENDER_CONFIG.camera.fov,
    width / height,
    SGS_VISUAL_RENDER_CONFIG.camera.near,
    SGS_VISUAL_RENDER_CONFIG.camera.far
  )
  camera.position.copy(SGS_VISUAL_RENDER_CONFIG.camera.initialPosition)

  renderer = new THREE.WebGLRenderer({
    antialias: SGS_VISUAL_RENDER_CONFIG.renderer.antialias,
    alpha: SGS_VISUAL_RENDER_CONFIG.renderer.alpha,
    powerPreference: SGS_VISUAL_RENDER_CONFIG.renderer.powerPreference
  })
  renderer.setSize(width, height)
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, SGS_VISUAL_RENDER_CONFIG.renderer.dprCap))
  renderer.outputColorSpace = THREE.SRGBColorSpace
  renderer.toneMapping = SGS_VISUAL_RENDER_CONFIG.renderer.toneMapping
  renderer.toneMappingExposure = SGS_VISUAL_RENDER_CONFIG.renderer.toneMappingExposure
  renderer.shadowMap.enabled = SGS_VISUAL_RENDER_CONFIG.renderer.shadows
  container.appendChild(renderer.domElement)

  controls = new OrbitControls(camera, renderer.domElement)
  controls.enableDamping = true
  controls.dampingFactor = SGS_VISUAL_RENDER_CONFIG.controls.dampingFactor
  controls.enableZoom = true
  controls.minDistance = SGS_VISUAL_RENDER_CONFIG.controls.minDistance
  controls.maxDistance = SGS_VISUAL_RENDER_CONFIG.controls.maxDistance
  controls.minPolarAngle = SGS_VISUAL_RENDER_CONFIG.controls.minPolarAngle
  controls.maxPolarAngle = SGS_VISUAL_RENDER_CONFIG.controls.maxPolarAngle
  syncControlInteractionOptions()

  loader = new GLTFLoader()
  dracoLoader = new DRACOLoader()
  dracoLoader.setDecoderPath('/static/three/draco/')
  loader.setDRACOLoader(dracoLoader)
  poiGroup = new THREE.Group()
  poiGroup.name = 'GuideModelPOI'
  scene.add(poiGroup)

  routeGroup = new THREE.Group()
  routeGroup.name = 'GuideModelRoute'
  scene.add(routeGroup)

  const hemisphereLight = new THREE.HemisphereLight(
    SGS_VISUAL_RENDER_CONFIG.lights.hemisphere.skyColor,
    SGS_VISUAL_RENDER_CONFIG.lights.hemisphere.groundColor,
    SGS_VISUAL_RENDER_CONFIG.lights.hemisphere.intensity
  )
  scene.add(hemisphereLight)

  const keyLight = new THREE.DirectionalLight(
    SGS_VISUAL_RENDER_CONFIG.lights.key.color,
    SGS_VISUAL_RENDER_CONFIG.lights.key.intensity
  )
  keyLight.position.copy(SGS_VISUAL_RENDER_CONFIG.lights.key.position)
  scene.add(keyLight)

  const fillLight = new THREE.DirectionalLight(
    SGS_VISUAL_RENDER_CONFIG.lights.fill.color,
    SGS_VISUAL_RENDER_CONFIG.lights.fill.intensity
  )
  fillLight.position.copy(SGS_VISUAL_RENDER_CONFIG.lights.fill.position)
  scene.add(fillLight)

  resizeObserver = new ResizeObserver(handleResize)
  resizeObserver.observe(container)
  container.addEventListener('pointerdown', handlePointerDown, true)
  container.addEventListener('pointerup', handlePointerUp, true)
  container.addEventListener('pointercancel', handlePointerCancel, true)
  container.addEventListener('pointerleave', handlePointerLeave, true)
  window.addEventListener('keydown', handleKeyDown)
  window.addEventListener('keyup', handleKeyUp)
  window.addEventListener('blur', handleWindowBlur)
  document.addEventListener('visibilitychange', handleVisibilityChange)
  renderer.domElement.addEventListener('wheel', handleWheelIntent, { passive: true, capture: true })

  // 监听控制器变化，用于自动切换和点位可见层级更新
  if (controls) {
    controls.addEventListener('start', handleControlStart)
    controls.addEventListener('end', handleControlEnd)
    controls.addEventListener('change', handleControlChange)
  }

  startRenderLoop()
}

const startRenderLoop = () => {
  const render = () => {
    if (isDisposed || !renderer || !scene || !camera) return

    if (cameraTween) {
      updateCameraTween(performance.now())
    } else {
      controls?.update()
    }
    updatePoiTapFeedback(performance.now())
    updateFocusLabelScale()
    renderer.render(scene, camera)
    animationId = window.requestAnimationFrame(render)
  }

  render()
}

const handleResize = () => {
  const container = getContainerElement()
  if (!container || !camera || !renderer) return

  const width = Math.max(1, container.clientWidth)
  const height = Math.max(1, container.clientHeight)
  camera.aspect = width / height
  camera.updateProjectionMatrix()
  renderer.setSize(width, height)
}

const materialTextureKeys = [
  'map',
  'alphaMap',
  'aoMap',
  'bumpMap',
  'normalMap',
  'displacementMap',
  'roughnessMap',
  'metalnessMap',
  'emissiveMap',
  'specularMap',
  'envMap',
  'lightMap',
  'gradientMap',
  'matcap',
  'clearcoatMap',
  'clearcoatNormalMap',
  'clearcoatRoughnessMap',
  'sheenColorMap',
  'sheenRoughnessMap',
  'transmissionMap',
  'thicknessMap',
  'iridescenceMap',
  'iridescenceThicknessMap'
] as const

const collectMaterialTextures = (material: THREE.Material | null | undefined) => {
  const textures = new Set<THREE.Texture>()
  if (!material) return textures

  const materialRecord = material as unknown as Record<string, unknown>
  materialTextureKeys.forEach((key) => {
    const texture = materialRecord[key]
    if (texture instanceof THREE.Texture) {
      textures.add(texture)
    }
  })

  return textures
}

const disposeMaterialTextures = (
  material: THREE.Material,
  protectedResources: ReusableModelResources,
  disposedTextures: Set<THREE.Texture>
) => {
  collectMaterialTextures(material).forEach((texture) => {
    if (!protectedResources.textures.has(texture) && !disposedTextures.has(texture)) {
      texture.dispose()
      disposedTextures.add(texture)
    }
  })
}

const collectReusableModelResources = (object: THREE.Object3D | null): ReusableModelResources => {
  const geometries = new Set<THREE.BufferGeometry>()
  const materials = new Set<THREE.Material>()
  const textures = new Set<THREE.Texture>()

  object?.traverse((child) => {
    if (child instanceof THREE.Mesh || child instanceof THREE.Line) {
      if (child.geometry) {
        geometries.add(child.geometry)
      }

      const childMaterials = Array.isArray(child.material) ? child.material : [child.material]
      childMaterials.forEach((material) => {
        if (!material) return

        materials.add(material)
        collectMaterialTextures(material).forEach((texture) => textures.add(texture))
      })
    }

    if (child instanceof THREE.Sprite && child.material) {
      materials.add(child.material)
      collectMaterialTextures(child.material).forEach((texture) => textures.add(texture))
    }
  })

  return { geometries, materials, textures }
}

const mergeReusableResources = (...resources: ReusableModelResources[]) => {
  const merged: ReusableModelResources = {
    geometries: new Set(),
    materials: new Set(),
    textures: new Set()
  }

  resources.forEach((resource) => {
    resource.geometries.forEach((geometry) => merged.geometries.add(geometry))
    resource.materials.forEach((material) => merged.materials.add(material))
    resource.textures.forEach((texture) => merged.textures.add(texture))
  })

  return merged
}

const toReusableModelResources = (resources: GuideModelResourceSet): ReusableModelResources => ({
  geometries: resources.geometries,
  materials: resources.materials,
  textures: resources.textures
})

const collectCachedModelResources = () => {
  return toReusableModelResources(guideModelLoadManager.collectProtectedResources())
}

const disposeObject = (
  object: THREE.Object3D,
  protectedResources = collectReusableModelResources(null),
  protectCachedModelResources = true
) => {
  const effectiveProtectedResources = protectCachedModelResources
    ? mergeReusableResources(protectedResources, collectCachedModelResources())
    : protectedResources
  const disposedGeometries = new Set<THREE.BufferGeometry>()
  const disposedMaterials = new Set<THREE.Material>()
  const disposedTextures = new Set<THREE.Texture>()

  object.traverse((child) => {
    if (child instanceof THREE.Mesh) {
      if (
        child.geometry
        && !effectiveProtectedResources.geometries.has(child.geometry)
        && !disposedGeometries.has(child.geometry)
      ) {
        child.geometry.dispose()
        disposedGeometries.add(child.geometry)
      }
      const materials = Array.isArray(child.material) ? child.material : [child.material]
      materials.forEach((material) => {
        if (material && !effectiveProtectedResources.materials.has(material) && !disposedMaterials.has(material)) {
          disposeMaterialTextures(material, effectiveProtectedResources, disposedTextures)
          material.dispose()
          disposedMaterials.add(material)
        }
      })
    }

    if (child instanceof THREE.Sprite) {
      if (!effectiveProtectedResources.materials.has(child.material) && !disposedMaterials.has(child.material)) {
        disposeMaterialTextures(child.material, effectiveProtectedResources, disposedTextures)
        child.material.dispose()
        disposedMaterials.add(child.material)
      }
    }

    if (child instanceof THREE.Line) {
      if (
        child.geometry
        && !effectiveProtectedResources.geometries.has(child.geometry)
        && !disposedGeometries.has(child.geometry)
      ) {
        child.geometry.dispose()
        disposedGeometries.add(child.geometry)
      }
      const materials = Array.isArray(child.material) ? child.material : [child.material]
      materials.forEach((material) => {
        if (material && !effectiveProtectedResources.materials.has(material) && !disposedMaterials.has(material)) {
          disposeMaterialTextures(material, effectiveProtectedResources, disposedTextures)
          material.dispose()
          disposedMaterials.add(material)
        }
      })
    }
  })
}

const isPoiMarkerSprite = (child: THREE.Object3D): child is THREE.Sprite => (
  child instanceof THREE.Sprite
  && Boolean(child.userData.poi)
  && !isPoiAuxiliarySprite(child)
)

const getPoiSprites = () => {
  const sprites: THREE.Sprite[] = []

  poiGroup?.traverse((child) => {
    if (isPoiMarkerSprite(child)) {
      sprites.push(child)
    }
  })

  return sprites
}

const getPoiHitTargets = () => {
  const sprites: THREE.Sprite[] = []

  poiGroup?.traverse((child) => {
    const poi = child.userData.poi as RenderPoi | undefined
    if (
      isPoiHitTargetSprite(child)
      && child.visible
      && Boolean(poi && isPoiIncludedByVisibleFilter(poi))
    ) {
      sprites.push(child)
    }
  })

  return sprites
}

const getPoiScreenPoint = (sprite: THREE.Sprite, rect: DOMRect) => {
  if (!camera) return null

  const projected = sprite.position.clone().project(camera)
  if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y) || projected.z < -1 || projected.z > 1) {
    return null
  }

  return new THREE.Vector2(
    rect.left + ((projected.x + 1) * rect.width * 0.5),
    rect.top + ((1 - projected.y) * rect.height * 0.5)
  )
}

const findNearestPoiMarkerByScreenPoint = (event: PointerEvent, rect: DOMRect) => {
  const pointer = new THREE.Vector2(event.clientX, event.clientY)

  return getPoiSprites()
    .filter((sprite) => sprite.visible && sprite.userData.poi)
    .map((sprite) => {
      const screenPoint = getPoiScreenPoint(sprite, rect)
      const poi = sprite.userData.poi as RenderPoi | undefined
      if (!screenPoint || !poi) return null

      const isCorePoi = Boolean(getPoiSpriteUserData(sprite).isCorePoi)
      const hitRadius = isCorePoi ? poiScreenHitRadiusCorePx : poiScreenHitRadiusPx
      const distance = screenPoint.distanceTo(pointer)

      return {
        sprite,
        distance,
        hitRadius,
        priority: getPoiPriority(poi)
      }
    })
    .filter((candidate): candidate is {
      sprite: THREE.Sprite
      distance: number
      hitRadius: number
      priority: number
    } => Boolean(candidate && candidate.distance <= candidate.hitRadius))
    .sort((a, b) => (a.distance - b.distance) || (b.priority - a.priority))[0]?.sprite || null
}

const detachPoiMarkerGroups = () => {
  poiMarkerGroupCache.forEach((entry) => {
    entry.group.parent?.remove(entry.group)
  })

  if (poiGroup) {
    poiGroup.userData.floorId = ''
    poiGroup.userData.currentFloor = ''
  }
}

const detachActivePoiLayer = () => {
  disposeFocusLabel()
  disposeFocusPulse()
  disposeFocusBase()
  clearFocusHallHighlight()
  clearRoutePreview()
  detachPoiMarkerGroups()
}

const disposePoiMarkerCache = () => {
  detachPoiMarkerGroups()
  poiMarkerGroupCache.forEach((entry) => {
    disposeObject(entry.group)
  })
  poiMarkerGroupCache.clear()
  poiDataCache.clear()
  poiDisplayOffsetYCache.clear()
  poiDisplayDiagnosticsKeys.clear()
}

const clearPoiGroupChildren = () => {
  if (!poiGroup) return

  while (poiGroup.children.length) {
    const child = poiGroup.children[0]
    poiGroup.remove(child)

    if (child instanceof THREE.Group) {
      const cachedEntry = [...poiMarkerGroupCache.values()].find((entry) => entry.group === child)
      if (cachedEntry) continue
    }

    disposeObject(child)
  }
}

const getMaterialColor = (material: THREE.Material) => {
  const candidate = material as THREE.Material & { color?: unknown }
  return candidate.color instanceof THREE.Color ? candidate.color : undefined
}

const getMaterialEmissive = (material: THREE.Material) => {
  const candidate = material as THREE.Material & { emissive?: unknown }
  return candidate.emissive instanceof THREE.Color ? candidate.emissive : undefined
}

const getMaterialEmissiveIntensity = (material: THREE.Material) => {
  const candidate = material as THREE.Material & { emissiveIntensity?: unknown }
  return typeof candidate.emissiveIntensity === 'number'
    ? candidate.emissiveIntensity
    : undefined
}

const setMaterialEmissiveIntensity = (material: THREE.Material, value: number) => {
  const candidate = material as THREE.Material & { emissiveIntensity?: number }
  if (typeof candidate.emissiveIntensity === 'number') {
    candidate.emissiveIntensity = value
  }
}

const clearFocusHallHighlight = () => {
  activeFocusHallMaterialStates.forEach((state) => {
    const color = getMaterialColor(state.material)
    const emissive = getMaterialEmissive(state.material)

    if (color && state.color) {
      color.copy(state.color)
    }

    if (emissive && state.emissive) {
      emissive.copy(state.emissive)
    }

    if (typeof state.emissiveIntensity === 'number') {
      setMaterialEmissiveIntensity(state.material, state.emissiveIntensity)
    }

    state.material.opacity = state.opacity
    state.material.transparent = state.transparent
    state.material.needsUpdate = true
  })

  activeFocusHallMaterialStates = []

  if (activeFocusHallGlowMesh) {
    activeFocusHallGlowMesh.parent?.remove(activeFocusHallGlowMesh)
    const material = activeFocusHallGlowMesh.material
    if (!Array.isArray(material)) {
      const glowMaterial = material as THREE.MeshBasicMaterial
      glowMaterial.map?.dispose()
    }
    disposeObject(activeFocusHallGlowMesh)
    activeFocusHallGlowMesh = null
  }
}

const applyFocusHallMaterial = (material: THREE.Material, seenMaterials: Set<THREE.Material>) => {
  if (seenMaterials.has(material)) return
  seenMaterials.add(material)

  const color = getMaterialColor(material)
  const emissive = getMaterialEmissive(material)
  const emissiveIntensity = getMaterialEmissiveIntensity(material)

  activeFocusHallMaterialStates.push({
    material,
    color: color?.clone(),
    emissive: emissive?.clone(),
    emissiveIntensity,
    opacity: material.opacity,
    transparent: material.transparent
  })

  const glowColor = new THREE.Color('#e0df00')

  if (color) {
    color.lerp(glowColor, 0.34)
  }

  if (emissive) {
    emissive.copy(glowColor)
    setMaterialEmissiveIntensity(material, Math.max(emissiveIntensity || 0, 0.76))
  }

  material.opacity = Math.max(material.opacity, 0.96)
  material.transparent = true
  material.needsUpdate = true
}

const createFocusHallGlowMesh = (poi: RenderPoi) => {
  const displayPosition = getPoiDisplayPosition(poi)
  if (!displayPosition) return null

  const canvas = document.createElement('canvas')
  canvas.width = 256
  canvas.height = 256
  const context = canvas.getContext('2d')

  if (context) {
    const gradient = context.createRadialGradient(128, 128, 16, 128, 128, 118)
    gradient.addColorStop(0, 'rgba(224, 223, 0, 0.52)')
    gradient.addColorStop(0.45, 'rgba(224, 223, 0, 0.34)')
    gradient.addColorStop(0.78, 'rgba(224, 223, 0, 0.16)')
    gradient.addColorStop(1, 'rgba(224, 223, 0, 0)')

    context.clearRect(0, 0, canvas.width, canvas.height)
    context.fillStyle = gradient
    context.fillRect(0, 0, canvas.width, canvas.height)
  }

  const texture = new THREE.CanvasTexture(canvas)
  texture.colorSpace = THREE.SRGBColorSpace
  const material = new THREE.MeshBasicMaterial({
    map: texture,
    transparent: true,
    opacity: 0.78,
    depthTest: false,
    depthWrite: false,
    side: THREE.DoubleSide
  })
  const hallGlowSize = Math.max(getPoiMarkerSize() * 14, getActiveModelSpan() * 0.14)
  const geometry = new THREE.PlaneGeometry(hallGlowSize, hallGlowSize * 0.72)
  const mesh = new THREE.Mesh(geometry, material)

  mesh.name = 'GuideFocusHallGlow'
  mesh.position.set(displayPosition.x, displayPosition.y + 0.08, displayPosition.z)
  mesh.rotation.x = -Math.PI / 2
  mesh.renderOrder = 7

  return mesh
}

const showFocusHallHighlight = (poi: RenderPoi) => {
  clearFocusHallHighlight()
  if (!activeModel) return

  activeFocusHallGlowMesh = createFocusHallGlowMesh(poi)
  if (activeFocusHallGlowMesh) {
    poiGroup?.add(activeFocusHallGlowMesh)
  }

  const modelRoot = findPoiModelRoot(poi)
  if (!modelRoot) return

  const seenMaterials = new Set<THREE.Material>()
  let matchedMeshCount = 0

  modelRoot.traverse((child) => {
    if (!(child instanceof THREE.Mesh) || !child.visible) return

    matchedMeshCount += 1
    const materials = Array.isArray(child.material) ? child.material : [child.material]
    materials.forEach((material) => {
      if (material) {
        applyFocusHallMaterial(material, seenMaterials)
      }
    })
  })

  if (!matchedMeshCount) {
    clearFocusHallHighlight()
  }
}

const disposeCachedOverviewModel = () => {
  if (!cachedOverviewModel) return

  disposeObject(cachedOverviewModel)
  cachedOverviewModel = null
  cachedSharedModelUrl = ''
}

const clearRoutePreview = () => {
  activeRoutePreviewSignature = ''
  if (!routeGroup) return

  while (routeGroup.children.length) {
    const child = routeGroup.children[0]
    routeGroup.remove(child)
    disposeObject(child)
  }
}

const getRouteFloorOffsetY = (floorId: string) => {
  if (activeView.value !== 'multi' || !activeModel) return 0

  let offsetY = 0
  activeModel.traverse((child) => {
    if (offsetY) return
    if (child.userData.floorId === floorId && typeof child.userData.multiFloorOffsetY === 'number') {
      offsetY = child.userData.multiFloorOffsetY
    }
  })

  return offsetY
}

const routePointToVector = (position: [number, number, number], floorId: string, lift = 0.45) => (
  new THREE.Vector3(position[0], position[1] + getRouteFloorOffsetY(floorId) + lift, position[2])
)

const getVisibleRouteSegments = (route: GuideRouteResult): GuideRouteFloorSegment[] => {
  if (activeView.value === 'floor') {
    return route.floorSegments
      .filter((segment) => segment.floorId === currentFloor.value)
  }

  return route.floorSegments
}

const createRouteLine = (points: THREE.Vector3[], opacity: number) => {
  const geometry = new THREE.BufferGeometry().setFromPoints(points)
  const material = new THREE.LineBasicMaterial({
    color: 0x1f8f5f,
    transparent: true,
    opacity,
    depthTest: false,
    depthWrite: false
  })
  const line = new THREE.Line(geometry, material)
  line.renderOrder = 18
  return line
}

const createRouteMarkerSprite = (label: string, color: string, markerSize: number) => {
  const canvas = document.createElement('canvas')
  canvas.width = 128
  canvas.height = 128
  const context = canvas.getContext('2d')

  if (context) {
    context.clearRect(0, 0, canvas.width, canvas.height)
    context.shadowColor = 'rgba(31, 35, 41, 0.24)'
    context.shadowBlur = 16
    context.shadowOffsetY = 8
    context.beginPath()
    context.arc(64, 56, 32, 0, Math.PI * 2)
    context.fillStyle = color
    context.fill()
    context.shadowColor = 'transparent'
    context.lineWidth = 6
    context.strokeStyle = '#ffffff'
    context.stroke()

    context.beginPath()
    context.moveTo(64, 116)
    context.lineTo(43, 78)
    context.lineTo(85, 78)
    context.closePath()
    context.fillStyle = color
    context.fill()
    context.lineWidth = 6
    context.strokeStyle = '#ffffff'
    context.stroke()

    context.font = `700 30px ${CANVAS_FONT_FAMILY}`
    context.textAlign = 'center'
    context.textBaseline = 'middle'
    context.fillStyle = '#ffffff'
    context.fillText(label, 64, 56, 52)
  }

  const texture = new THREE.CanvasTexture(canvas)
  texture.colorSpace = THREE.SRGBColorSpace
  const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
    map: texture,
    transparent: true,
    depthTest: false,
    depthWrite: false
  }))
  sprite.scale.set(markerSize, markerSize, markerSize)
  sprite.renderOrder = 28
  return sprite
}

const shouldShowRouteEndpoint = (endpoint: GuideRouteEndpoint) => (
  activeView.value !== 'floor' || endpoint.floorId === currentFloor.value
)

const renderRouteEndpoint = (
  endpoint: GuideRouteEndpoint,
  label: string,
  color: string,
  markerSize: number
) => {
  if (!routeGroup || !shouldShowRouteEndpoint(endpoint)) return

  const marker = createRouteMarkerSprite(label, color, markerSize)
  marker.position.copy(routePointToVector(endpoint.position, endpoint.floorId, markerSize * 0.45))
  routeGroup.add(marker)
}

const renderRouteConnectorMarkers = (route: GuideRouteResult, markerSize: number) => {
  if (!routeGroup) return

  route.connectorPoints
    .filter((point) => activeView.value !== 'floor' || point.floorId === currentFloor.value)
    .forEach((point) => {
      const marker = createRouteMarkerSprite('换', '#8a5cf6', markerSize * 0.72)
      marker.position.copy(routePointToVector(point.position, point.floorId, markerSize * 0.25))
      routeGroup!.add(marker)
    })
}

const routePositionSignature = (position: [number, number, number]) => (
  position.map((value) => Number(value.toFixed(3))).join(',')
)

const getRoutePreviewSignature = (
  route: GuideRouteResult,
  visibleSegments: GuideRouteFloorSegment[],
  markerSize: number
) => JSON.stringify({
  routeId: route.id,
  view: activeView.value,
  floor: activeView.value === 'floor' ? currentFloor.value : '',
  markerSize: Number(markerSize.toFixed(3)),
  start: {
    poiId: route.start.poiId,
    floorId: route.start.floorId,
    position: routePositionSignature(route.start.position)
  },
  end: {
    poiId: route.end.poiId,
    floorId: route.end.floorId,
    position: routePositionSignature(route.end.position)
  },
  segments: visibleSegments.map((segment) => ({
    floorId: segment.floorId,
    points: segment.points.map((point) => `${point.nodeId}:${routePositionSignature(point.position)}`)
  })),
  connectors: route.connectorPoints
    .filter((point) => activeView.value !== 'floor' || point.floorId === currentFloor.value)
    .map((point) => `${point.nodeId}:${point.floorId}:${routePositionSignature(point.position)}`)
})

const renderRoutePreview = () => {
  if (!props.showRoute || !props.routePreview || !routeGroup || !scene) {
    clearRoutePreview()
    return
  }


  const route = props.routePreview
  const targetRouteGroup = routeGroup
  const visibleSegments = getVisibleRouteSegments(route)
  const markerSize = Math.max(getPoiMarkerSize() * 0.82, 2.8)
  const signature = getRoutePreviewSignature(route, visibleSegments, markerSize)
  if (signature === activeRoutePreviewSignature) return

  clearRoutePreview()
  activeRoutePreviewSignature = signature

  visibleSegments.forEach((segment) => {
    if (segment.points.length < 2) return

    targetRouteGroup.add(createRouteLine(
      segment.points.map((point) => routePointToVector(point.position, point.floorId)),
      activeView.value === 'floor' ? 1 : 0.88
    ))
  })

  renderRouteEndpoint(route.start, '起', '#1f8f5f', markerSize)
  renderRouteEndpoint(route.end, '终', '#cf3f59', markerSize)
  renderRouteConnectorMarkers(route, markerSize)
}

const clearSceneData = () => {
  cancelCameraTween()

  if (activeModel && scene) {
    clearFocusHallHighlight()
    scene.remove(activeModel)
    if (activeModel === cachedOverviewModel) {
      cachedOverviewModel.visible = false
    } else {
      disposeObject(activeModel, collectReusableModelResources(cachedOverviewModel))
    }
  }

  activeModel = null
  disposeFocusLabel()
  disposeFocusPulse()
  disposeFocusBase()
  selectedPOI.value = null
  clearRoutePreview()
  clearPoiGroupChildren()
}

const wait = (delayMs: number) => new Promise<void>((resolve) => {
  window.setTimeout(resolve, delayMs)
})

const getErrorStatus = (error: unknown) => {
  const candidate = error as {
    status?: unknown
    target?: { status?: unknown }
    currentTarget?: { status?: unknown }
  } | null
  const status = candidate?.status ?? candidate?.target?.status ?? candidate?.currentTarget?.status
  return typeof status === 'number' && Number.isFinite(status) ? status : undefined
}

const formatModelLoadError = (error: unknown, url: string) => {
  if (!url.trim()) return '模型 URL 缺失'

  const status = getErrorStatus(error)
  if (status === 500) return '模型代理返回 500，请检查 /gis/sdk/minio 或 MinIO 代理层'
  if (status === 404) return '模型资源不存在或代理返回 404'
  if (status && status >= 400) return `模型资源请求失败（HTTP ${status}）`

  const rawMessage = error instanceof Error ? error.message : String(error || '')
  const message = rawMessage.toLowerCase()
  if (message.includes('timeout')) return '模型资源请求超时'
  if (message.includes('parse') || message.includes('json') || message.includes('gltf')) {
    return '模型资源解析失败'
  }

  return rawMessage || '模型资源加载失败'
}

const loadModelOnce = (
  url: string,
  label: string,
  loadToken?: number,
  options: LoadModelOptions = {}
) => new Promise<GLTF>((resolve, reject) => {
  if (!loader) {
    reject(new Error('GLTF 加载器未初始化'))
    return
  }

  const startedAt = getNow()
  logThreeMapDiagnostic('model-load-start', {
    label,
    url
  })

  loader.load(
    url,
    (gltf) => {
      logThreeMapDiagnostic('model-load-complete', {
        label,
        url,
        elapsedMs: Math.round(getNow() - startedAt)
      })
      resolve(gltf)
    },
    (event) => {
      if (options.suppressProgress || (loadToken !== undefined && !isCurrentModelLoad(loadToken))) return

      if (event.total > 0) {
        const loadedRatio = event.loaded / event.total
        const percent = Math.round(loadedRatio * 100)
        const modelProgress = Math.round(Math.min(loadedRatio, 0.98) * 68)
        const progressMessage = percent >= 98
          ? `${label}: 下载完成，正在解析模型...`
          : `${label}: ${percent}%`
        setProgress(20 + modelProgress, progressMessage)
      } else {
        setProgress(45, `${label}: 正在下载模型...`)
      }
    },
    (error) => {
      logThreeMapDiagnostic('model-load-failed', {
        label,
        url,
        elapsedMs: Math.round(getNow() - startedAt),
        error: error instanceof Error ? error.message : String(error)
      })
      reject(error)
    }
  )
})

const loadModelFromNetwork = async (
  url: string,
  label: string,
  loadToken?: number,
  options: LoadModelOptions = {}
) => {
  let lastError: unknown = null
  const normalizedUrl = url.trim()

  if (!normalizedUrl) {
    throw new Error('模型 URL 缺失')
  }

  for (let attempt = 0; attempt <= modelLoadRetryDelaysMs.length; attempt += 1) {
    if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) {
      throw createStaleModelLoadError()
    }

    try {
      return await loadModelOnce(normalizedUrl, label, loadToken, options)
    } catch (error) {
      if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) {
        throw createStaleModelLoadError()
      }

      lastError = error
      const retryDelay = modelLoadRetryDelaysMs[attempt]
      if (retryDelay === undefined) break

      if (!options.suppressProgress) {
        setProgress(18, `${label} 加载失败，正在重试 ${attempt + 1}/${modelLoadRetryDelaysMs.length}`)
      }

      await wait(retryDelay)
    }
  }

  throw new Error(formatModelLoadError(lastError, normalizedUrl))
}

const loadModelWithFallback = async (
  urls: string[],
  label: string,
  loadToken?: number,
  options: LoadModelOptions = {},
  semanticKey?: string
) => {
  const candidates = urls.map((url) => url.trim()).filter(Boolean)
  if (!candidates.length) throw new Error('模型 URL 缺失')

  const gltf = await guideModelLoadManager.load({
    urls: candidates,
    label,
    semanticKey,
    loadSource: (url) => loadModelFromNetwork(url, label, loadToken, options),
    shouldStopOnError: isStaleModelLoadError
  })
  if (!options.suppressProgress) {
    setProgress(92, `${label}: 正在准备三维场景...`)
  }

  if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) {
    disposeObject(gltf.scene)
    throw createStaleModelLoadError()
  }

  return gltf
}

const prepareModel = (model: THREE.Object3D) => {
  model.traverse((child) => {
    if (child instanceof THREE.Mesh) {
      child.castShadow = false
      child.receiveShadow = true
      child.frustumCulled = false
    }
  })
}

const getObjectSize = (object: THREE.Object3D) => (
  getObjectBox(object).getSize(new THREE.Vector3())
)

const getMultiFloorVerticalGap = (items: MultiFloorModelItem[]) => {
  const maxFloorHeight = Math.max(...items.map((item) => item.size.y), 1)
  const maxFootprint = Math.max(...items.map((item) => Math.max(item.size.x, item.size.z)), 1)

  return Math.max(maxFloorHeight * 5.2, maxFootprint * 0.32, 68)
}

const applyMultiFloorLayout = (items: MultiFloorModelItem[]) => {
  if (!items.length) return

  const verticalGap = getMultiFloorVerticalGap(items)
  const centerIndex = (items.length - 1) / 2

  items.forEach((item, index) => {
    const offsetY = (centerIndex - index) * verticalGap
    item.model.position.y += offsetY
    item.model.userData.multiFloorOffsetY = offsetY
    item.model.userData.multiFloorVerticalGap = verticalGap
  })
}

const getSharedMultiFloorModelUrl = (floorsToLoad: FloorIndexItem[]) => {
  if (!floorsToLoad.length || !floorsToLoad.every((floor) => floor.sharedModelAsset)) return ''

  const [firstFloor] = floorsToLoad
  return floorsToLoad.every((floor) => floor.modelUrl === firstFloor.modelUrl)
    ? firstFloor.modelUrl
    : ''
}

const canAttachCachedSharedModel = (modelUrl: string) => (
  Boolean(cachedOverviewModel && cachedSharedModelUrl === modelUrl && scene)
)

const canLoadFloorSilently = (floorId: string) => {
  const floor = floorIndex.value.find((item) => item.floorId === floorId)
  return Boolean(floor && canAttachCachedSharedModel(floor.modelUrl))
}

const getFloorModelUrls = (floor: FloorIndexItem) => (
  floor.modelUrls?.length ? floor.modelUrls : [floor.modelUrl]
)

const getOverviewModelUrls = (packageData: GuideModelRenderPackage) => (
  packageData.overviewModelUrls?.length ? packageData.overviewModelUrls : [packageData.overviewModelUrl]
)

const preloadModelUrls = async (urls: string[], label: string, preloadSeq: number) => {
  const candidates = urls.map((url) => url.trim()).filter(Boolean)
  if (!candidates.length || preloadSeq !== adjacentPreloadSeq || isDisposed) return

  const availableCandidates = candidates.filter((url) => {
    const isCached = guideModelLoadManager.has(url)
    const isPending = guideModelLoadManager.isInFlight(url)
    if (isCached || isPending) {
      logThreeMapDiagnostic('adjacent-preload-skip', {
        label,
        url,
        reason: isCached ? 'cache-hit' : 'in-flight-hit',
        diagnostics: guideModelLoadManager.getDiagnostics()
      })
      return false
    }

    return true
  })
  if (!availableCandidates.length) return

  try {
    await guideModelLoadManager.preload({
      urls: availableCandidates,
      label,
      loadSource: (url) => loadModelFromNetwork(url, label, undefined, { suppressProgress: true })
    })
  } catch (error) {
    console.warn('[ThreeMap] 相邻楼层模型预加载失败:', {
      urls: availableCandidates,
      label,
      error: error instanceof Error ? error.message : String(error)
    })
  }
}

const getAdjacentFloors = (floorId: string) => {
  const orderedFloors = [...floorIndex.value].sort(compareFloorsTopToBottom)
  const currentIndex = orderedFloors.findIndex((floor) => floor.floorId === floorId)
  if (currentIndex < 0) return []

  return [
    orderedFloors[currentIndex - 1],
    orderedFloors[currentIndex + 1]
  ].filter((floor): floor is FloorIndexItem => Boolean(floor?.modelUrl))
}

const scheduleAdjacentFloorPreload = (floorId: string) => {
  const adjacentFloors = getAdjacentFloors(floorId)
  if (!adjacentFloors.length) return

  const preloadSeq = adjacentPreloadSeq + 1
  adjacentPreloadSeq = preloadSeq

  window.setTimeout(() => {
    if (preloadSeq !== adjacentPreloadSeq || isDisposed) return

    void (async () => {
      const seenModelKeys = new Set<string>()
      for (const floor of adjacentFloors) {
        if (preloadSeq !== adjacentPreloadSeq || isDisposed) return
        const uniqueUrls = getFloorModelUrls(floor).filter((url) => {
          const key = guideModelLoadManager.createCacheKey(url)
          if (!key) return false
          if (seenModelKeys.has(key)) {
            logThreeMapDiagnostic('adjacent-preload-skip', {
              floorId: floor.floorId,
              url,
              reason: 'duplicate-shared-url'
            })
            return false
          }
          seenModelKeys.add(key)
          return true
        })
        await preloadModelUrls(
          uniqueUrls,
          `预加载 ${formatFloorLabel(floor.floorId)} 模型`,
          preloadSeq
        )
      }
    })()
  }, adjacentPreloadDelayMs)
}

const getFloorMatchKeys = (floor: FloorIndexItem) => (
  [floor.floorId, floor.label, ...(floor.modelMatchKeys || [])]
    .filter((value): value is string => Boolean(value))
)

const resolveFloorIdFromRequest = (requestedFloorId?: string | null) => {
  const requested = requestedFloorId?.trim()
  if (!requested) return ''

  const normalizedRequest = normalizeModelMatchKey(requested)
  return floorIndex.value.find((floor) => getFloorMatchKeys(floor).some((key) => (
    key === requested
    || key.toLowerCase() === requested.toLowerCase()
    || normalizeModelMatchKey(key) === normalizedRequest
  )))?.floorId || ''
}

const isGroundEntryFloor = (floor: FloorIndexItem) => (
  getFloorMatchKeys(floor).some((key) => {
    const normalizedKey = normalizeModelMatchKey(key)
    return normalizedKey === 'l1' || normalizedKey === '1f'
  })
)

const getPreferredInitialFloorId = () => (
  floorIndex.value.find(isGroundEntryFloor)?.floorId
  || floorIndex.value[0]?.floorId
  || ''
)

const getDefaultFloorId = () => (
  resolveFloorIdFromRequest(props.initialFloorId)
  || getPreferredInitialFloorId()
  || props.initialFloorId
  || 'L1'
)

const getAutoEntryTargetFloorId = () => (
  hasLoadedFloorViewOnce
    ? currentFloor.value || getDefaultFloorId()
    : getDefaultFloorId()
)

const hasRenderableSceneForFloorTransition = () => Boolean(scene && activeModel)

const createMultiFloorItemFromSharedModel = (sourceModel: THREE.Object3D, floor: FloorIndexItem) => {
  const floorModel = sourceModel.clone(true)
  floorModel.name = `GuideMultiFloorModel_${floor.floorId}`
  floorModel.userData.floorId = floor.floorId
  prepareModel(floorModel)
  applyModelVisibilityForView(floorModel, 'floor', floor.floorId)

  return {
    floor,
    label: formatFloorLabel(floor.floorId),
    model: floorModel,
    size: getObjectSize(floorModel)
  }
}

const disposeDetachedMultiFloorModels = (
  items: MultiFloorModelItem[],
  group: THREE.Group,
  protectedResources = collectReusableModelResources(null)
) => {
  items
    .filter((item) => !group.children.includes(item.model))
    .forEach((item) => disposeObject(item.model, protectedResources))
}

const resetAutoSwitchDistanceTracking = () => {
  activeAutoSwitchInputSource = 'gesture'
  autoSwitchStateMachine.reset(activeView.value)
}

const handleWheelIntent = (event: WheelEvent) => {
  if (!controls || activeView.value !== 'overview' || event.deltaY === 0) return

  const distance = controls.getDistance()
  if (!Number.isFinite(distance)) return

  activeAutoSwitchInputSource = 'wheel'
  autoSwitchStateMachine.beginInput(distance, 'wheel')
}

const canRunAutoSwitch = (direction: GuideAutoSwitchDirection) => {
  if (
    !props.autoSwitch
    || autoSwitchTemporarilyDisabled
    || isLoading.value
    || isAutoSwitchLocked
    || isProgrammaticCameraChange
    || !controls
    || !activeModel
    || activeView.value === 'multi'
  ) {
    return false
  }

  if (direction === 'exit-overview') {
    return !props.disableAutoExit && !props.targetFocus && activeView.value === 'floor'
  }

  return activeView.value === 'overview'
}

const requestAutoSwitch = (request: GuideAutoSwitchRequest) => {
  if (!canRunAutoSwitch(request.direction)) {
    autoSwitchStateMachine.markTransitionFailed()
    return
  }

  isAutoSwitchLocked = true
  if (request.direction === 'enter-floor') {
    const targetFloorId = getAutoEntryTargetFloorId()
    const hasSceneToKeep = hasRenderableSceneForFloorTransition()
    void runAutoSwitchLoad(
      {
        from: 'overview',
        to: 'floor',
        trigger: 'zoom-in',
        distance: request.distance
      },
      async () => {
        await loadFloor(targetFloorId, {
          preserveCurrentSceneUntilReady: hasSceneToKeep,
          suppressProgress: hasSceneToKeep,
          detachPoiBeforeLoad: true
        })
      },
      { showLoading: !hasSceneToKeep && !canLoadFloorSilently(targetFloorId) }
    )
    return
  }

  void runAutoSwitchLoad(
    {
      from: 'floor',
      to: 'overview',
      trigger: 'zoom-out',
      distance: request.distance
    },
    loadOverview
  )
}

const autoSwitchStateMachine = new GuideAutoSwitchStateMachine({
  cooldownMs: props.autoSwitchCooldown,
  canSwitch: canRunAutoSwitch,
  onSwitchRequested: requestAutoSwitch
})

const checkAutoSwitch = (source: GuideAutoSwitchInputSource = activeAutoSwitchInputSource) => {
  if (!controls || isProgrammaticCameraChange) return

  const distance = controls.getDistance()
  if (!Number.isFinite(distance)) return

  autoSwitchStateMachine.setView(activeView.value)
  autoSwitchStateMachine.updateDistance(distance, { source })
}

const runAutoSwitchLoad = async (
  event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: 'zoom-in' | 'zoom-out'; distance: number },
  loadTask: () => Promise<unknown>,
  options: { showLoading?: boolean } = {}
) => {
  const showLoading = options.showLoading ?? true

  try {
    if (showLoading) {
      isLoading.value = true
    }
    loadError.value = false
    await loadTask()
    if (showLoading) {
      isLoading.value = false
    }
    autoSwitchStateMachine.markTransitionSucceeded()
    emit('autoSwitch', event)
  } catch (error) {
    autoSwitchStateMachine.markTransitionFailed()
    if (isStaleModelLoadError(error)) return

    console.error('馆内 3D 自动视角切换失败:', error)
    loadError.value = true
    isLoading.value = false
    setFriendlyModelLoadError()
  } finally {
    isAutoSwitchLocked = false
  }
}

const disableAutoSwitchTemporarily = (durationMs: number) => {
  autoSwitchTemporarilyDisabled = true

  if (autoSwitchDisableTimer) {
    clearTimeout(autoSwitchDisableTimer)
  }

  autoSwitchDisableTimer = setTimeout(() => {
    autoSwitchTemporarilyDisabled = false
    autoSwitchDisableTimer = null
  }, durationMs)
}

const setCameraView = (
  center: THREE.Vector3,
  maxDim: number,
  direction: THREE.Vector3,
  options: CameraFitOptions = {}
) => {
  if (!camera || !controls) return

  const fov = camera.fov * (Math.PI / 180)
  const distanceFactor = options.distanceFactor ?? 0.58
  const targetOffset = options.targetOffsetRatio
    ? new THREE.Vector3(
      options.targetOffsetRatio.x * maxDim,
      options.targetOffsetRatio.y * maxDim,
      options.targetOffsetRatio.z * maxDim
    )
    : new THREE.Vector3()
  const targetCenter = center.clone().add(targetOffset)
  const distance = Math.abs(maxDim / Math.sin(fov / 2)) * distanceFactor

  camera.near = Math.max(SGS_VISUAL_RENDER_CONFIG.camera.near, maxDim / 1000)
  camera.far = Math.max(SGS_VISUAL_RENDER_CONFIG.camera.far, maxDim * 20)
  camera.up.copy(options.up || new THREE.Vector3(0, 1, 0))
  camera.updateProjectionMatrix()

  const nextTarget = targetCenter.clone()
  const nextPosition = targetCenter.clone().add(direction.clone().normalize().multiplyScalar(distance))

  if (options.screenOffsetRatio) {
    const viewDirection = nextTarget.clone().sub(nextPosition).normalize()
    const cameraRight = new THREE.Vector3().crossVectors(viewDirection, camera.up).normalize()
    const viewHeight = 2 * distance * Math.tan(fov / 2)
    const viewWidth = viewHeight * camera.aspect
    const screenOffset = cameraRight
      .multiplyScalar(options.screenOffsetRatio.x * viewWidth)
      .add(camera.up.clone().multiplyScalar(options.screenOffsetRatio.y * viewHeight))

    nextPosition.add(screenOffset)
    nextTarget.add(screenOffset)
  }
  controls.minDistance = Math.max(SGS_VISUAL_RENDER_CONFIG.controls.minDistance, maxDim * 0.08)
  controls.maxDistance = Math.max(SGS_VISUAL_RENDER_CONFIG.controls.maxDistance, maxDim * 8)
  moveCameraTo(nextPosition, nextTarget, { onComplete: options.onComplete })
}

const fitCameraToObject = (object: THREE.Object3D, preset: CameraPreset = 'oblique') => {
  if (!camera || !controls) return

  const useOverviewBuildingFit = activeView.value === 'overview' && preset !== 'top'
  const box = useOverviewBuildingFit ? getOverviewBuildingSubjectBox(object) : getObjectBox(object)
  const center = box.getCenter(new THREE.Vector3())
  const size = box.getSize(new THREE.Vector3())
  const maxDim = Math.max(size.x, size.y, size.z, 1)
  const direction = preset === 'top'
    ? new THREE.Vector3(0.02, 1, 0.02)
    : activeView.value === 'overview'
      ? new THREE.Vector3(0.76, 0.48, 1)
      : getIndoorInitialCameraDirection()
  const overviewFitOptions: CameraFitOptions = activeView.value === 'overview'
    ? {
      distanceFactor: 0.9,
      screenOffsetRatio: SGS_VISUAL_RENDER_CONFIG.framing.overviewScreenOffsetRatio,
      up: new THREE.Vector3(0, 1, 0)
    }
    : activeView.value === 'floor'
      ? {
        distanceFactor: FLOOR_CAMERA_DISTANCE_FACTOR,
        screenOffsetRatio: SGS_VISUAL_RENDER_CONFIG.framing.floorScreenOffsetRatio,
        up: new THREE.Vector3(0, 1, 0)
      }
      : {}

  overviewFitOptions.onComplete = () => {
    if (activeView.value !== 'floor' || !controls) return

    const distance = controls.getDistance()
    if (Number.isFinite(distance)) {
      // 楼层切换或相机复位完成后，以最终拟合距离作为退出基准。
      autoSwitchStateMachine.setFloorInitialDistance(distance)
    }
  }
  setCameraView(center, maxDim, direction, overviewFitOptions)
}

const applyIndoorInitialModelTransform = (model: THREE.Object3D) => {
  model.position.copy(INDOOR_INITIAL_MODEL_PARAMS.position)
  model.rotation.copy(INDOOR_INITIAL_MODEL_PARAMS.rotation)
  model.scale.copy(INDOOR_INITIAL_MODEL_PARAMS.scale)
  model.updateMatrixWorld(true)
}

const applyOverviewInitialCamera = () => {
  if (!camera || !controls) return

  camera.position.copy(OVERVIEW_INITIAL_CAMERA_PARAMS.position)
  camera.zoom = OVERVIEW_INITIAL_CAMERA_PARAMS.zoom
  camera.updateProjectionMatrix()
  controls.target.copy(OVERVIEW_INITIAL_CAMERA_PARAMS.target)
  controls.update()
}

const resetCamera = () => {
  if (activeModel) {
    fitCameraToObject(activeModel)
  }
}

const setCameraPreset = (preset: CameraPreset) => {
  if (activeModel) {
    fitCameraToObject(activeModel, preset)
  }
}

const zoomCamera = (direction: 'in' | 'out', options: { source?: ZoomCameraSource } = {}) => {
  if (!camera || !controls) return

  const offset = camera.position.clone().sub(controls.target)
  const currentDistance = offset.length()
  const zoomFactor = direction === 'in' ? 0.72 : 1.28
  const minDistance = controls.minDistance || 2
  const maxDistance = controls.maxDistance || SGS_VISUAL_RENDER_CONFIG.controls.maxDistance
  const nextDistance = Math.min(maxDistance, Math.max(minDistance, currentDistance * zoomFactor))

  if (!Number.isFinite(nextDistance) || nextDistance <= 0) return

  const shouldTrackUserZoom = options.source === 'button'
  if (shouldTrackUserZoom) {
    activeAutoSwitchInputSource = 'button'
    autoSwitchStateMachine.beginInput(currentDistance, 'button')
  }

  offset.setLength(nextDistance)
  const nextPosition = controls.target.clone().add(offset)
  moveCameraTo(nextPosition, controls.target, {
    onComplete: () => handleControlChange()
  })
}

const loadCurrentFloorPoiMarkers = async (loadToken: number) => {
  if (!shouldRenderPoiMarkers.value) return

  const floor = floorIndex.value.find((item) => item.floorId === currentFloor.value) || floorIndex.value[0]
  if (!floor) return

  if (activeView.value === 'floor') {
    await loadFloorPOIs(floor, loadToken)
    assertCurrentModelLoad(loadToken)
    return
  }

  const entry = await prepareFloorPOIs(floor, loadToken)
  if (entry) {
    attachPoiMarkerGroup(entry)
  }
  assertCurrentModelLoad(loadToken)
}

const loadCurrentFloorPoiMarkersInBackground = (loadToken: number) => {
  if (!shouldRenderPoiMarkers.value) return

  const startedAt = getNow()
  logThreeMapDiagnostic('poi-background-start', {
    floorId: currentFloor.value,
    view: activeView.value
  })

  void loadCurrentFloorPoiMarkers(loadToken)
    .then(() => {
      if (!isCurrentModelLoad(loadToken)) return
      logThreeMapDiagnostic('poi-background-ready', {
        floorId: currentFloor.value,
        view: activeView.value,
        elapsedMs: Math.round(getNow() - startedAt)
      })
      refreshPoiVisibilityByDistance()
      renderRoutePreview()
    })
    .catch((error) => {
      if (isStaleModelLoadError(error)) return
      console.warn('馆内外观点位标记后台加载失败:', error)
    })
}

const loadOverview = async () => {
  const packageData = renderPackage.value
  if (!packageData || !scene) return

  const loadToken = startModelLoad()
  activeView.value = 'overview'
  autoSwitchStateMachine.setView('overview')
  syncControlInteractionOptions()
  clearSceneData()

  if (cachedOverviewModel) {
    const targetScene = scene
    if (!targetScene) throw createStaleModelLoadError()

    activeModel = cachedOverviewModel
    activeModel.name = 'GuideOverviewModel'
    activeModel.visible = true
    applyIndoorInitialModelTransform(activeModel)
    applyModelVisibilityForView(activeModel, 'overview')
    targetScene.add(activeModel)
    applyOverviewInitialCamera()
    loadCurrentFloorPoiMarkersInBackground(loadToken)
    resetAutoSwitchDistanceTracking()
    refreshPoiVisibilityByDistance()
    renderRoutePreview()
    markFirstModelVisible('overview', {
      source: 'cached-overview',
      modelUrl: cachedSharedModelUrl
    })
    return
  }

  setProgress(18, '正在加载建筑外观模型...')

  const gltf = await loadModelWithFallback(getOverviewModelUrls(packageData), '正在加载建筑外观模型', loadToken)
  assertCurrentModelLoad(loadToken, gltf.scene)

  const targetScene = scene
  if (!targetScene) {
    disposeObject(gltf.scene)
    throw createStaleModelLoadError()
  }

  activeModel = gltf.scene
  activeModel.name = 'GuideOverviewModel'
  prepareModel(activeModel)
  applyIndoorInitialModelTransform(activeModel)
  applyModelVisibilityForView(activeModel, 'overview')
  cachedOverviewModel = activeModel
  cachedSharedModelUrl = packageData.overviewModelUrl
  targetScene.add(activeModel)
  applyOverviewInitialCamera()
  loadCurrentFloorPoiMarkersInBackground(loadToken)
  resetAutoSwitchDistanceTracking()
  refreshPoiVisibilityByDistance()
  renderRoutePreview()
  markFirstModelVisible('overview', {
    source: 'network',
    modelUrl: packageData.overviewModelUrl
  })
}

const prepareFloorScene = async (
  floor: FloorIndexItem,
  loadToken: number,
  options: LoadFloorOptions = {}
): Promise<PreparedFloorScene> => {
  const requestedFloorId = floor.floorId
  let model: THREE.Object3D | null = null
  let ownsModel = true
  let cacheAsSharedModel = false
  let protectedResources: ReusableModelResources | undefined

  const cachedExactSharedModel = canAttachCachedSharedModel(floor.modelUrl)
    ? cachedOverviewModel
    : null

  if (cachedExactSharedModel) {
    model = cachedExactSharedModel
    ownsModel = false
    protectedResources = collectReusableModelResources(cachedExactSharedModel)
    model.name = `GuideFloorModel_${requestedFloorId}`
    model.userData.floorId = requestedFloorId
    model.visible = true
    applyIndoorInitialModelTransform(model)
    applyModelVisibilityForView(model, 'floor', requestedFloorId)
  } else {
    if (!options.suppressProgress) {
      setProgress(18, `正在加载 ${formatFloorLabel(requestedFloorId)} 模型...`)
    }

    const gltf = await loadModelWithFallback(
      getFloorModelUrls(floor),
      `正在加载 ${formatFloorLabel(requestedFloorId)} 模型`,
      loadToken,
      { suppressProgress: options.suppressProgress }
    )

    try {
      assertCurrentModelLoad(loadToken, gltf.scene)
    } catch (error) {
      disposeObject(gltf.scene)
      throw error
    }

    model = gltf.scene
    model.name = `GuideFloorModel_${requestedFloorId}`
    model.userData.floorId = requestedFloorId
    prepareModel(model)
    applyIndoorInitialModelTransform(model)
    applyModelVisibilityForView(model, 'floor', requestedFloorId)
    cacheAsSharedModel = Boolean(floor.sharedModelAsset)
  }

  if (!model) {
    throw new Error(`楼层模型加载失败：${requestedFloorId}`)
  }

  const poiEntry = shouldRenderPoiMarkers.value
    ? poiMarkerGroupCache.get(getPoiMarkerCacheKey(floor.floorId)) || null
    : null

  const prepared: PreparedFloorScene = {
    floor,
    model,
    poiEntry: poiEntry || null,
    ownsModel,
    cacheAsSharedModel,
    protectedResources
  }
  assertPreparedFloorScene(prepared, loadToken, requestedFloorId)

  return prepared
}

const commitPreparedFloorScene = (
  prepared: PreparedFloorScene,
  loadToken: number,
  expectedFloorId: string
) => {
  assertCurrentFloorContextTransaction(loadToken, expectedFloorId, prepared.ownsModel ? prepared.model : undefined)
  assertPreparedFloorScene(prepared, loadToken, expectedFloorId)

  const targetScene = scene
  if (!targetScene) {
    if (prepared.ownsModel) {
      disposeObject(prepared.model, prepared.protectedResources)
    }
    throw createStaleModelLoadError()
  }

  activeView.value = 'floor'
  autoSwitchStateMachine.setView('floor')
  autoSwitchStateMachine.clearFloorInitialDistance()
  syncControlInteractionOptions()
  currentFloor.value = expectedFloorId
  clearSceneData()
  activeModel = prepared.model
  activeModel.userData.floorId = expectedFloorId

  if (prepared.cacheAsSharedModel) {
    cachedOverviewModel = activeModel
    cachedSharedModelUrl = prepared.floor.modelUrl
  }

  targetScene.add(activeModel)
  if (prepared.poiEntry) {
    attachPoiMarkerGroup(prepared.poiEntry)
  } else if (poiGroup) {
    poiGroup.userData.floorId = expectedFloorId
    poiGroup.userData.currentFloor = expectedFloorId
  }

  assertCommittedFloorScene(loadToken, expectedFloorId)

  activeFocusPoiId.value = ''
  selectedPOI.value = null
  updatePoiMarkerFocus()
  hasLoadedFloorViewOnce = true
  fitCameraToObject(activeModel)
  refreshPoiVisibilityByDistance()
  renderRoutePreview()
  markFirstModelVisible('floor', {
    floorId: expectedFloorId,
    modelUrl: prepared.floor.modelUrl,
    poiFromCache: Boolean(prepared.poiEntry)
  })
  loadCurrentFloorPoiMarkersInBackground(loadToken)
  scheduleAdjacentFloorPreload(expectedFloorId)
}

const loadFloor = async (floorId: string, options: LoadFloorOptions = {}) => {
  const floor = floorIndex.value.find((item) => item.floorId === floorId)
  if (!floor) {
    throw new Error(`未找到楼层模型：${floorId}`)
  }
  if (!scene) {
    throw createStaleModelLoadError()
  }

  const requestedFloorId = floor.floorId
  if (
    !options.allowSameFloorReload
    && activeView.value === 'floor'
    && currentFloor.value === requestedFloorId
    && activeModel?.userData.floorId === requestedFloorId
    && !isFloorSwitching
  ) {
    return false
  }

  const loadToken = startFloorContextTransaction(requestedFloorId)
  if (options.detachPoiBeforeLoad) {
    detachActivePoiLayer()
  }

  try {
    const prepared = await prepareFloorScene(floor, loadToken, options)
    assertCurrentFloorContextTransaction(loadToken, requestedFloorId, prepared.ownsModel ? prepared.model : undefined)
    commitPreparedFloorScene(prepared, loadToken, requestedFloorId)
    return true
  } catch (error) {
    if (!isStaleModelLoadError(error)) {
      restoreCommittedFloorPoiLayer()
    }
    throw error
  } finally {
    completeFloorContextTransaction(loadToken, requestedFloorId)
  }
}

const loadMultiFloor = async () => {
  if (!scene || !floorIndex.value.length) return

  const loadToken = startModelLoad()
  activeView.value = 'multi'
  autoSwitchStateMachine.setView('multi')
  syncControlInteractionOptions()
  clearSceneData()
  setProgress(18, '正在加载多层展示模型...')

  const group = new THREE.Group()
  group.name = 'GuideMultiFloorModel'
  const orderedFloors = [...floorIndex.value].sort(compareFloorsTopToBottom)
  const loadedFloors: MultiFloorModelItem[] = []
  const sharedModelUrl = getSharedMultiFloorModelUrl(orderedFloors)

  if (sharedModelUrl) {
    let sourceModel = cachedSharedModelUrl === sharedModelUrl ? cachedOverviewModel : null

    if (!sourceModel) {
      const gltf = await loadModelWithFallback([sharedModelUrl], '正在加载多层共享模型', loadToken)
      assertCurrentModelLoad(loadToken, gltf.scene)

      const targetScene = scene
      if (!targetScene) {
        disposeObject(gltf.scene)
        disposeObject(group)
        throw createStaleModelLoadError()
      }

      sourceModel = gltf.scene
      sourceModel.name = 'GuideOverviewModel'
      prepareModel(sourceModel)
      applyModelVisibilityForView(sourceModel, 'overview')
      cachedOverviewModel = sourceModel
      cachedSharedModelUrl = sharedModelUrl
    }

    const sharedSourceModel = sourceModel
    const protectedResources = collectReusableModelResources(sharedSourceModel)

    try {
      orderedFloors.forEach((floor, index) => {
        assertCurrentModelLoad(loadToken, group)
        const item = createMultiFloorItemFromSharedModel(sharedSourceModel, floor)
        loadedFloors.push(item)
        setProgress(
          18 + Math.round(((index + 1) / orderedFloors.length) * 72),
          `已准备 ${item.label}`
        )
      })

      assertCurrentModelLoad(loadToken, group)
      applyMultiFloorLayout(loadedFloors)
      loadedFloors.forEach((item) => group.add(item.model))

      const targetScene = scene
      if (!targetScene) {
        disposeObject(group, protectedResources)
        throw createStaleModelLoadError()
      }

      activeModel = group
      targetScene.add(activeModel)
      fitCameraToObject(activeModel)
      resetAutoSwitchDistanceTracking()
      refreshPoiVisibilityByDistance()
      renderRoutePreview()
      markFirstModelVisible('multi', {
        source: 'shared-model',
        modelUrl: sharedModelUrl,
        floorCount: orderedFloors.length
      })
      return
    } catch (error) {
      disposeObject(group, protectedResources)
      disposeDetachedMultiFloorModels(loadedFloors, group, protectedResources)

      if (isStaleModelLoadError(error)) {
        throw error
      }

      throw error
    }
  }

  for (let index = 0; index < orderedFloors.length; index += 1) {
    try {
      assertCurrentModelLoad(loadToken, group)
    } catch (error) {
      disposeDetachedMultiFloorModels(loadedFloors, group)
      throw error
    }

    const floor = orderedFloors[index]
    const label = formatFloorLabel(floor.floorId)
    const gltf = await loadModelWithFallback(getFloorModelUrls(floor), `正在加载 ${label} 多层模型`, loadToken)
    if (!isCurrentModelLoad(loadToken)) {
      disposeObject(gltf.scene)
      disposeObject(group)
      disposeDetachedMultiFloorModels(loadedFloors, group)
      throw createStaleModelLoadError()
    }

    const floorModel = gltf.scene
    floorModel.name = `GuideMultiFloorModel_${floor.floorId}`
    floorModel.userData.floorId = floor.floorId
    prepareModel(floorModel)
    applyModelVisibilityForView(floorModel, 'floor', floor.floorId)
    loadedFloors.push({
      floor,
      label,
      model: floorModel,
      size: getObjectSize(floorModel)
    })
    setProgress(
      18 + Math.round(((index + 1) / orderedFloors.length) * 72),
      `已加载 ${label}`
    )
  }

  try {
    assertCurrentModelLoad(loadToken, group)
  } catch (error) {
    disposeDetachedMultiFloorModels(loadedFloors, group)
    throw error
  }

  applyMultiFloorLayout(loadedFloors)
  loadedFloors.forEach((item) => group.add(item.model))

  const targetScene = scene
  if (!targetScene) {
    disposeObject(group)
    disposeDetachedMultiFloorModels(loadedFloors, group)
    throw createStaleModelLoadError()
  }

  activeModel = group
  targetScene.add(activeModel)
  fitCameraToObject(activeModel)
  resetAutoSwitchDistanceTracking()
  refreshPoiVisibilityByDistance()
  renderRoutePreview()
  markFirstModelVisible('multi', {
    source: 'per-floor-models',
    floorCount: orderedFloors.length
  })
}

const createPoiMaterial = (poi: RenderPoi) => {
  const canvas = document.createElement('canvas')
  canvas.width = 128
  canvas.height = 156
  const context = canvas.getContext('2d')

  if (context) {
    const color = getPoiColor(poi.primaryCategory)
    const glyph = getPoiGlyph(poi)
    context.clearRect(0, 0, canvas.width, canvas.height)

    context.shadowColor = 'rgba(31, 35, 41, 0.24)'
    context.shadowBlur = 14
    context.shadowOffsetY = 8
    context.beginPath()
    context.arc(64, 56, 31, 0, Math.PI * 2)
    context.fillStyle = color
    context.fill()
    context.shadowColor = 'transparent'
    context.lineWidth = 6
    context.strokeStyle = '#ffffff'
    context.stroke()

    context.beginPath()
    context.moveTo(64, 116)
    context.lineTo(42, 78)
    context.lineTo(86, 78)
    context.closePath()
    context.fillStyle = color
    context.fill()
    context.lineWidth = 6
    context.strokeStyle = '#ffffff'
    context.stroke()

    context.beginPath()
    context.arc(64, 56, 21, 0, Math.PI * 2)
    context.fillStyle = 'rgba(255, 255, 255, 0.94)'
    context.fill()

    context.font = `700 28px ${CANVAS_FONT_FAMILY}`
    context.textAlign = 'center'
    context.textBaseline = 'middle'
    context.fillStyle = color
    context.fillText(glyph, 64, 57, 46)
  }

  const texture = new THREE.CanvasTexture(canvas)
  texture.colorSpace = THREE.SRGBColorSpace
  return new THREE.SpriteMaterial({
    map: texture,
    transparent: true,
    depthTest: false,
    depthWrite: false
  })
}

const createPoiHitTargetMaterial = () => {
  const canvas = document.createElement('canvas')
  canvas.width = 32
  canvas.height = 32
  const context = canvas.getContext('2d')

  if (context) {
    context.clearRect(0, 0, canvas.width, canvas.height)
    context.beginPath()
    context.arc(16, 16, 15, 0, Math.PI * 2)
    context.fillStyle = 'rgba(255, 255, 255, 0.02)'
    context.fill()
  }

  const texture = new THREE.CanvasTexture(canvas)
  texture.colorSpace = THREE.SRGBColorSpace
  return new THREE.SpriteMaterial({
    map: texture,
    transparent: true,
    depthTest: false,
    depthWrite: false,
    opacity: 0.01,
    colorWrite: false
  })
}

const createPoiLabelSprite = (poi: RenderPoi, markerSize: number) => {
  const canvas = document.createElement('canvas')
  canvas.width = 560
  canvas.height = 168
  const context = canvas.getContext('2d')
  const label = poi.name.length > 14 ? `${poi.name.slice(0, 14)}…` : poi.name

  if (context) {
    const color = getPoiColor(poi.primaryCategory)
    context.clearRect(0, 0, canvas.width, canvas.height)
    context.shadowColor = 'rgba(31, 35, 41, 0.2)'
    context.shadowBlur = 18
    context.shadowOffsetY = 8
    context.fillStyle = 'rgba(255, 255, 255, 0.96)'
    context.beginPath()
    context.roundRect(40, 24, 480, 92, 28)
    context.fill()
    context.shadowColor = 'transparent'
    context.lineWidth = 3
    context.strokeStyle = 'rgba(31, 35, 41, 0.08)'
    context.stroke()

    context.beginPath()
    context.moveTo(258, 116)
    context.lineTo(302, 116)
    context.lineTo(280, 144)
    context.closePath()
    context.fill()

    context.beginPath()
    context.arc(86, 70, 20, 0, Math.PI * 2)
    context.fillStyle = color
    context.fill()

    context.font = `700 24px ${CANVAS_FONT_FAMILY}`
    context.textAlign = 'left'
    context.textBaseline = 'middle'
    context.fillStyle = '#ffffff'
    context.fillText(getPoiGlyph(poi), 74, 70, 28)

    context.font = `700 34px ${CANVAS_FONT_FAMILY}`
    context.fillStyle = '#1f2329'
    context.fillText(label, 120, 61, 360)

    context.font = `500 22px ${CANVAS_FONT_FAMILY}`
    context.fillStyle = '#6b7178'
    context.fillText(`${formatFloorLabel(poi.floorId)} · ${poi.primaryCategoryZh}`, 120, 94, 360)
  }

  const texture = new THREE.CanvasTexture(canvas)
  texture.colorSpace = THREE.SRGBColorSpace
  const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
    map: texture,
    transparent: true,
    depthTest: false,
    depthWrite: false
  }))
  sprite.userData.isPoiLabel = true
  sprite.userData.labelBaseScaleX = markerSize * 5.6
  sprite.userData.labelBaseScaleY = markerSize * 1.68
  sprite.renderOrder = 30
  sprite.frustumCulled = false
  sprite.scale.set(sprite.userData.labelBaseScaleX, sprite.userData.labelBaseScaleY, 1)

  return sprite
}

const createAmbientPoiLabelSprite = (poi: RenderPoi, markerSize: number) => {
  const canvas = document.createElement('canvas')
  canvas.width = 360
  canvas.height = 112
  const context = canvas.getContext('2d')
  const labelLimit = isHallPoi(poi) ? 10 : 7
  const label = poi.name.length > labelLimit ? `${poi.name.slice(0, labelLimit)}…` : poi.name

  if (context) {
    const color = getPoiColor(poi.primaryCategory)
    const hallLabel = isHallPoi(poi)
    const boxX = hallLabel ? 28 : 42
    const boxY = hallLabel ? 18 : 26
    const boxWidth = hallLabel ? 304 : 276
    const boxHeight = hallLabel ? 60 : 48
    const radius = hallLabel ? 22 : 18

    context.clearRect(0, 0, canvas.width, canvas.height)
    context.shadowColor = 'rgba(31, 35, 41, 0.18)'
    context.shadowBlur = hallLabel ? 14 : 10
    context.shadowOffsetY = hallLabel ? 7 : 5
    context.fillStyle = hallLabel ? 'rgba(255, 255, 255, 0.98)' : 'rgba(255, 255, 255, 0.92)'
    context.beginPath()
    context.roundRect(boxX, boxY, boxWidth, boxHeight, radius)
    context.fill()
    context.shadowColor = 'transparent'
    context.lineWidth = hallLabel ? 3 : 2
    context.strokeStyle = hallLabel ? `${color}66` : 'rgba(31, 35, 41, 0.1)'
    context.stroke()

    context.beginPath()
    context.moveTo(166, boxY + boxHeight)
    context.lineTo(194, boxY + boxHeight)
    context.lineTo(180, boxY + boxHeight + 18)
    context.closePath()
    context.fillStyle = hallLabel ? 'rgba(255, 255, 255, 0.98)' : 'rgba(255, 255, 255, 0.92)'
    context.fill()
    context.strokeStyle = hallLabel ? `${color}50` : 'rgba(31, 35, 41, 0.08)'
    context.stroke()

    context.beginPath()
    context.arc(boxX + 30, boxY + boxHeight * 0.5, hallLabel ? 15 : 12, 0, Math.PI * 2)
    context.fillStyle = color
    context.fill()

    context.font = `700 ${hallLabel ? 18 : 15}px ${CANVAS_FONT_FAMILY}`
    context.textAlign = 'center'
    context.textBaseline = 'middle'
    context.fillStyle = '#ffffff'
    context.fillText(getPoiGlyph(poi), boxX + 30, boxY + boxHeight * 0.5, 24)

    context.font = `700 ${hallLabel ? 24 : 20}px ${CANVAS_FONT_FAMILY}`
    context.textAlign = 'left'
    context.fillStyle = '#1f2329'
    context.fillText(label, boxX + 56, boxY + boxHeight * 0.5, boxWidth - 76)
  }

  const texture = new THREE.CanvasTexture(canvas)
  texture.colorSpace = THREE.SRGBColorSpace
  const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
    map: texture,
    transparent: true,
    depthTest: false,
    depthWrite: false
  }))
  sprite.userData.isPoiLabel = true
  sprite.userData.poi = poi
  sprite.userData.labelBaseScaleX = markerSize * (isHallPoi(poi) ? 3.9 : 3.05)
  sprite.userData.labelBaseScaleY = markerSize * (isHallPoi(poi) ? 1.22 : 0.95)
  sprite.renderOrder = isHallPoi(poi) ? 18 : 15
  sprite.frustumCulled = false
  sprite.scale.set(sprite.userData.labelBaseScaleX, sprite.userData.labelBaseScaleY, 1)
  sprite.visible = false

  return sprite
}

const createPoiPulseSprite = (poi: RenderPoi, markerSize: number) => {
  const canvas = document.createElement('canvas')
  canvas.width = 160
  canvas.height = 160
  const context = canvas.getContext('2d')

  if (context) {
    const color = getPoiColor(poi.primaryCategory)
    context.clearRect(0, 0, canvas.width, canvas.height)
    context.beginPath()
    context.arc(80, 80, 56, 0, Math.PI * 2)
    context.fillStyle = `${color}30`
    context.fill()
    context.lineWidth = 6
    context.strokeStyle = `${color}66`
    context.stroke()
  }

  const texture = new THREE.CanvasTexture(canvas)
  texture.colorSpace = THREE.SRGBColorSpace
  const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
    map: texture,
    transparent: true,
    depthTest: false,
    depthWrite: false,
    opacity: 0.8
  }))
  sprite.userData.isPoiPulse = true
  sprite.userData.baseScale = markerSize * 1.9
  sprite.renderOrder = 9
  sprite.frustumCulled = false
  sprite.scale.set(markerSize * 1.9, markerSize * 1.9, markerSize * 1.9)

  return sprite
}

const createPoiBaseSprite = (poi: RenderPoi, markerSize: number) => {
  const canvas = document.createElement('canvas')
  canvas.width = 180
  canvas.height = 180
  const context = canvas.getContext('2d')

  if (context) {
    const color = getPoiColor(poi.primaryCategory)
    const gradient = context.createRadialGradient(90, 90, 8, 90, 90, 76)
    gradient.addColorStop(0, `${color}50`)
    gradient.addColorStop(0.45, `${color}28`)
    gradient.addColorStop(1, `${color}00`)

    context.clearRect(0, 0, canvas.width, canvas.height)
    context.beginPath()
    context.arc(90, 90, 76, 0, Math.PI * 2)
    context.fillStyle = gradient
    context.fill()
  }

  const texture = new THREE.CanvasTexture(canvas)
  texture.colorSpace = THREE.SRGBColorSpace
  const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
    map: texture,
    transparent: true,
    depthTest: false,
    depthWrite: false,
    opacity: 0.7
  }))
  sprite.userData.isPoiBase = true
  sprite.userData.baseScale = markerSize * 2.15
  sprite.renderOrder = 8
  sprite.frustumCulled = false
  sprite.scale.set(markerSize * 2.15, markerSize * 2.15, markerSize * 2.15)

  return sprite
}

const disposeFocusLabel = () => {
  if (!activeFocusLabelSprite) return

  activeFocusLabelSprite.parent?.remove(activeFocusLabelSprite)
  disposeObject(activeFocusLabelSprite)
  activeFocusLabelSprite = null
}

const disposeFocusPulse = () => {
  if (!activeFocusPulseSprite) return

  activeFocusPulseSprite.parent?.remove(activeFocusPulseSprite)
  disposeObject(activeFocusPulseSprite)
  activeFocusPulseSprite = null
}

const disposeFocusBase = () => {
  if (!activeFocusBaseSprite) return

  activeFocusBaseSprite.parent?.remove(activeFocusBaseSprite)
  disposeObject(activeFocusBaseSprite)
  activeFocusBaseSprite = null
}

const showFocusPoiLabel = (poi: RenderPoi) => {
  const displayPosition = getPoiDisplayPosition(poi)
  if (!poiGroup || !displayPosition) return

  disposeFocusLabel()

  const markerSize = getPoiMarkerSize()
  activeFocusLabelSprite = createPoiLabelSprite(poi, markerSize)
  activeFocusLabelSprite.frustumCulled = false
  activeFocusLabelSprite.position.copy(getPoiLiftedPosition(displayPosition, markerSize, poiFocusLabelLiftFactor))
  updateFocusLabelScale()
  poiGroup.add(activeFocusLabelSprite)
}

const showFocusPoiBase = (poi: RenderPoi) => {
  const displayPosition = getPoiDisplayPosition(poi)
  if (!poiGroup || !displayPosition) return

  disposeFocusBase()

  const markerSize = getPoiMarkerSize()
  activeFocusBaseSprite = createPoiBaseSprite(poi, markerSize)
  activeFocusBaseSprite.frustumCulled = false
  activeFocusBaseSprite.position.copy(getPoiLiftedPosition(displayPosition, markerSize, poiFocusBaseLiftFactor))
  poiGroup.add(activeFocusBaseSprite)
}

const showFocusPoiPulse = (poi: RenderPoi) => {
  const displayPosition = getPoiDisplayPosition(poi)
  if (!poiGroup || !displayPosition) return

  disposeFocusPulse()

  const markerSize = getPoiMarkerSize()
  activeFocusPulseSprite = createPoiPulseSprite(poi, markerSize)
  activeFocusPulseSprite.frustumCulled = false
  activeFocusPulseSprite.position.copy(getPoiLiftedPosition(displayPosition, markerSize, poiFocusPulseLiftFactor))
  poiGroup.add(activeFocusPulseSprite)
}

const showFocusPoiAffordances = (poi: RenderPoi) => {
  showFocusHallHighlight(poi)
  showFocusPoiBase(poi)
  showFocusPoiPulse(poi)
  showFocusPoiLabel(poi)
}

const getPoiColor = (category: string) => {
  const colorMap: Record<string, string> = {
    poi: '#56606b',
    touring_poi: '#2f6fed',
    exhibition_hall: '#2f6fed',
    exhibition_hall_entrance: '#4659d8',
    basic_service_facility: '#1f8f5f',
    business_poi: '#0f9aa5',
    transport_circulation: '#d77b20',
    accessibility_special_service: '#8a5cf6',
    operation_experience: '#cf3f59'
  }

  return colorMap[category] || '#1f2329'
}

const getPoiMarkerSize = () => (
  activeModel
    ? Math.max(new THREE.Box3().setFromObject(activeModel).getSize(new THREE.Vector3()).length() * 0.012, 2.4)
    : 3
)

const getFocusLabelScaleBoost = () => {
  if (!controls || !activeModel) return 1

  const distanceRatio = controls.getDistance() / getActiveModelSpan()
  return Math.min(2.25, Math.max(1, distanceRatio / 0.36))
}

const updateFocusLabelScale = () => {
  if (!activeFocusLabelSprite) return

  const userData = getPoiSpriteUserData(activeFocusLabelSprite)
  const baseScaleX = typeof userData.labelBaseScaleX === 'number'
    ? userData.labelBaseScaleX
    : activeFocusLabelSprite.scale.x
  const baseScaleY = typeof userData.labelBaseScaleY === 'number'
    ? userData.labelBaseScaleY
    : activeFocusLabelSprite.scale.y
  const scaleBoost = getFocusLabelScaleBoost()

  activeFocusLabelSprite.scale.set(baseScaleX * scaleBoost, baseScaleY * scaleBoost, 1)
}

const setPoiSpriteFocusStyle = (sprite: THREE.Sprite, focused: boolean) => {
  const userData = getPoiSpriteUserData(sprite)
  const baseScale = typeof userData.baseScale === 'number'
    ? userData.baseScale
    : sprite.scale.x
  const scaleBoost = userData.isCorePoi ? 1.18 : 1
  const scale = focused ? baseScale * scaleBoost * 1.62 : baseScale * scaleBoost

  sprite.scale.set(scale, scale, scale)
  sprite.renderOrder = focused ? 22 : (userData.isCorePoi ? 12 : 10)
  sprite.material.opacity = focused ? 1 : 0.86

  if (userData.hitTarget) {
    const hitBaseScale = userData.hitTargetBaseScale || baseScale * poiHitTargetScaleMultiplier
    userData.hitTarget.scale.set(hitBaseScale, hitBaseScale, hitBaseScale)
    userData.hitTarget.renderOrder = focused ? 21 : 11
  }
}

const updatePoiMarkerFocus = () => {
  if (!poiGroup) return

  getPoiSprites().forEach((sprite) => {
    const poi = sprite.userData.poi as RenderPoi | undefined
    setPoiSpriteFocusStyle(sprite, Boolean(poi && poi.id === activeFocusPoiId.value))
  })

  refreshPoiVisibilityByDistance()
}

const findPoiSprite = (poiId: string) => (
  getPoiSprites().find((sprite) => (sprite.userData.poi as RenderPoi | undefined)?.id === poiId)
)

const findLoadedPoi = (poiId: string) => (
  findPoiSprite(poiId)?.userData.poi as RenderPoi | undefined
)

const createPoiMarkerSprites = (poi: RenderPoi, markerSize: number) => {
  const sprite = new THREE.Sprite(createPoiMaterial(poi))
  const hitTarget = new THREE.Sprite(createPoiHitTargetMaterial())
  const labelSprite = shouldCreateAmbientPoiLabel(poi)
    ? createAmbientPoiLabelSprite(poi, markerSize)
    : null
  const isCorePoi = corePoiCategories.has(poi.primaryCategory) || poi.primaryCategory === 'target_preview'
  const hitTargetScale = markerSize * (isCorePoi ? poiHitTargetCoreScaleMultiplier : poiHitTargetScaleMultiplier)

  sprite.userData.baseScale = markerSize
  sprite.userData.poi = poi
  sprite.userData.isCorePoi = isCorePoi
  sprite.userData.hitTarget = hitTarget
  sprite.frustumCulled = false
  if (labelSprite) {
    sprite.userData.labelSprite = labelSprite
  }
  sprite.userData.hitTargetBaseScale = hitTargetScale

  hitTarget.userData.baseScale = hitTargetScale
  hitTarget.userData.poi = poi
  hitTarget.userData.visibleMarker = sprite
  hitTarget.userData.isPoiHitTarget = true
  hitTarget.renderOrder = 11
  hitTarget.frustumCulled = false
  hitTarget.scale.set(hitTargetScale, hitTargetScale, hitTargetScale)

  setPoiSpriteFocusStyle(sprite, poi.id === activeFocusPoiId.value)

  return { sprite, hitTarget, labelSprite }
}

const createPoiFromFocusRequest = (request: TargetPoiFocusRequest): RenderPoi | null => {
  if (!request.positionGltf) return null

  return {
    id: request.poiId,
    name: request.name || '目标位置',
    floorId: request.floorId,
    primaryCategory: 'target_preview',
    primaryCategoryZh: request.primaryCategoryZh || '位置预览',
    iconType: 'target_preview',
    positionGltf: request.positionGltf,
    sourceObjectName: request.sourceObjectName
  }
}

const addFocusMarkerFromRequest = (request: TargetPoiFocusRequest) => {
  if (!poiGroup || findPoiSprite(request.poiId)) return findLoadedPoi(request.poiId) || null

  const poi = createPoiFromFocusRequest(request)
  const displayPosition = poi ? getPoiDisplayPosition(poi, request.floorId) : null
  if (!poi || !displayPosition) return null

  const markerSize = getPoiMarkerSize()
  const { sprite, hitTarget } = createPoiMarkerSprites(poi, markerSize)
  sprite.position.copy(getPoiMarkerPosition(displayPosition, markerSize))
  hitTarget.position.copy(sprite.position)
  poiGroup.add(sprite)
  poiGroup.add(hitTarget)

  return poi
}

const createPoiMarkerGroup = (
  floorId: string,
  displayMode: PoiDisplayMode,
  pois: RenderPoi[],
  markerSize: number
) => {
  const group = new THREE.Group()
  group.name = `GuideModelPOI_${floorId}_${displayMode}`
  group.userData.floorId = floorId
  group.userData.currentFloor = floorId
  group.userData.displayMode = displayMode

  pois.forEach((poi) => {
    const displayPosition = getPoiDisplayPosition(poi, floorId)
    if (!displayPosition) return

    const { sprite, hitTarget, labelSprite } = createPoiMarkerSprites(poi, markerSize)
    sprite.position.copy(getPoiMarkerPosition(displayPosition, markerSize))
    hitTarget.position.copy(sprite.position)
    if (labelSprite) {
      labelSprite.position.copy(getPoiAmbientLabelPosition(displayPosition, markerSize))
      labelSprite.visible = false
    }
    sprite.visible = isPoiIncludedByVisibleFilter(poi)
      && (
        visiblePoiIdFilter.value !== null
        || shouldShowPoiAtDistance(poi, getPoiVisibilityTier())
      )
    hitTarget.visible = sprite.visible
    group.add(sprite)
    group.add(hitTarget)
    if (labelSprite) {
      group.add(labelSprite)
    }
  })

  return group
}

const attachPoiMarkerGroup = (entry: PoiMarkerCacheEntry) => {
  if (!poiGroup) return

  detachPoiMarkerGroups()
  if (entry.group.parent !== poiGroup) {
    poiGroup.add(entry.group)
  }
  poiGroup.userData.floorId = entry.floorId
  poiGroup.userData.currentFloor = entry.floorId
  updatePoiMarkerFocus()
  logThreeMapDiagnostic('poi-marker-render-diagnostics', {
    floorId: entry.floorId,
    currentFloor: currentFloor.value,
    displayMode: entry.displayMode,
    rawPoiCount: entry.rawPoiCount ?? entry.pois.length,
    rawPositionedPoiCount: entry.rawPositionedPoiCount ?? entry.pois.filter((poi) => Boolean(poi.positionGltf)).length,
    renderPoiCount: entry.pois.length,
    renderPositionedPoiCount: entry.pois.filter((poi) => Boolean(poi.positionGltf)).length,
    renderCategoryCounts: countPoisByCategory(entry.pois),
    filteredCategoryCounts: entry.filteredCategoryCounts || {},
    markerObjects: countPoiMarkerObjects(entry.group),
    poiGroupFloorId: poiGroup.userData.floorId,
    poiGroupCurrentFloor: poiGroup.userData.currentFloor,
    selectedPoi: summarizeSelectedPoiForDiagnostics(selectedPOI.value),
    markerSize: roundDiagnosticNumber(entry.markerSize),
    displayOffsetY: roundDiagnosticNumber(entry.displayOffsetY),
    visualLiftY: roundDiagnosticNumber(entry.markerSize * poiMarkerVisualLiftFactor)
  })
}

const restoreCommittedFloorPoiLayer = () => {
  if (!poiGroup || activeView.value !== 'floor' || !activeModel) return

  const floorId = currentFloor.value
  const modelFloorId = typeof activeModel.userData.floorId === 'string'
    ? activeModel.userData.floorId
    : ''
  if (!floorId || modelFloorId !== floorId) return

  const entry = shouldRenderPoiMarkers.value
    ? poiMarkerGroupCache.get(getPoiMarkerCacheKey(floorId))
    : null
  if (entry?.floorId === floorId) {
    attachPoiMarkerGroup(entry)
    refreshPoiVisibilityByDistance()
    return
  }

  poiGroup.userData.floorId = floorId
  poiGroup.userData.currentFloor = floorId
}

const getPoiMarkerSizeForModel = (model: THREE.Object3D | null) => (
  model
    ? Math.max(new THREE.Box3().setFromObject(model).getSize(new THREE.Vector3()).length() * 0.012, 2.4)
    : getPoiMarkerSize()
)

const prepareFloorPOIs = async (
  floor: FloorIndexItem,
  loadToken?: number,
  markerModel: THREE.Object3D | null = activeModel
) => {
  if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) return

  const displayMode = getPoiDisplayMode()
  const markerCacheKey = getPoiMarkerCacheKey(floor.floorId, displayMode)
  const cachedEntry = poiMarkerGroupCache.get(markerCacheKey)
  if (cachedEntry) {
    return cachedEntry
  }

  const cachedPois = poiDataCache.get(floor.floorId)
  const floorPois = cachedPois || await props.modelSource.loadFloorPois(floor.floorId)
  if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) return

  poiDataCache.set(floor.floorId, floorPois)
  const validPois = floorPois.filter((poi) => shouldShowPoiInCurrentMode(poi))
  const filteredPois = floorPois.filter((poi) => !validPois.some((validPoi) => validPoi.id === poi.id))
  logThreeMapDiagnostic('poi-filter-diagnostics', {
    floorId: floor.floorId,
    currentFloor: currentFloor.value,
    displayMode,
    rawPoiCount: floorPois.length,
    rawPositionedPoiCount: floorPois.filter((poi) => Boolean(poi.positionGltf)).length,
    rawCategoryCounts: countPoisByCategory(floorPois),
    poiCategoryCount: floorPois.filter((poi) => poi.primaryCategory === 'poi').length,
    renderPoiCount: validPois.length,
    renderPositionedPoiCount: validPois.filter((poi) => Boolean(poi.positionGltf)).length,
    renderCategoryCounts: countPoisByCategory(validPois),
    renderPoiCategoryCount: validPois.filter((poi) => poi.primaryCategory === 'poi').length,
    filteredCategoryCounts: countPoisByCategory(filteredPois)
  })
  const displayOffsetY = markerModel
    ? resolvePoiDisplayOffsetY(floor.floorId, markerModel, validPois)
    : getCachedPoiDisplayOffsetY(floor.floorId)
  const markerSize = getPoiMarkerSizeForModel(markerModel)
  const markerGroup = createPoiMarkerGroup(floor.floorId, displayMode, validPois, markerSize)
  const entry: PoiMarkerCacheEntry = {
    floorId: floor.floorId,
    displayMode,
    pois: validPois,
    group: markerGroup,
    markerSize,
    displayOffsetY,
    rawPoiCount: floorPois.length,
    rawPositionedPoiCount: floorPois.filter((poi) => Boolean(poi.positionGltf)).length,
    filteredCategoryCounts: countPoisByCategory(filteredPois)
  }

  if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) {
    disposeObject(markerGroup)
    return
  }

  poiMarkerGroupCache.set(markerCacheKey, entry)
  return entry
}

const loadFloorPOIs = async (floor: FloorIndexItem, loadToken?: number) => {
  if (!poiGroup) return

  if (loadToken !== undefined) {
    assertActiveFloorModelState(loadToken, floor.floorId)
  }

  const entry = await prepareFloorPOIs(floor, loadToken)
  if (!entry) return

  if (loadToken !== undefined) {
    assertActiveFloorModelState(loadToken, floor.floorId)
  }

  attachPoiMarkerGroup(entry)
}

const clearMapSelection = (shouldEmit = true) => {
  activeFocusPoiId.value = ''
  selectedPOI.value = null
  clearFocusHallHighlight()
  disposeFocusLabel()
  disposeFocusPulse()
  disposeFocusBase()
  updatePoiMarkerFocus()

  if (shouldEmit) {
    emit('selectionClear')
  }
}

const findFloorObject = (object: THREE.Object3D) => {
  let current: THREE.Object3D | null = object

  while (current) {
    if (typeof current.userData.floorId === 'string') {
      return current
    }

    current = current.parent
  }

  return null
}

const getPoiMarkerFromHit = (object: THREE.Object3D) => {
  if (object instanceof THREE.Sprite && isPoiHitTargetSprite(object)) {
    return getPoiSpriteUserData(object).visibleMarker || null
  }

  if (object instanceof THREE.Sprite && isPoiMarkerSprite(object)) {
    return object
  }

  return null
}

const triggerPoiTapFeedback = (sprite: THREE.Sprite) => {
  getPoiSpriteUserData(sprite).feedbackUntil = performance.now() + poiTapFeedbackDurationMs
}

const handleSceneTap = async (event: PointerEvent) => {
  if (!camera || !renderer || !getContainerElement()) return

  const rect = renderer.domElement.getBoundingClientRect()
  const pointer = new THREE.Vector2(
    ((event.clientX - rect.left) / rect.width) * 2 - 1,
    -((event.clientY - rect.top) / rect.height) * 2 + 1
  )
  const raycaster = new THREE.Raycaster()
  raycaster.setFromCamera(pointer, camera)

  if ((activeView.value === 'floor' || activeView.value === 'overview') && poiGroup) {
    const hitTargets = getPoiHitTargets()
    const poiSprites = getPoiSprites()
    const poiHits = raycaster.intersectObjects([...hitTargets, ...poiSprites], false)
    const hit = poiHits.find((item) => (
      item.object.visible
      && item.object instanceof THREE.Sprite
      && getPoiMarkerFromHit(item.object)
    ))
    const fallbackMarker = hit ? null : findNearestPoiMarkerByScreenPoint(event, rect)
    const hitMarker = hit ? getPoiMarkerFromHit(hit.object) : fallbackMarker

    logThreeMapDiagnostic('poi-hit-diagnostics', {
      view: activeView.value,
      currentFloor: currentFloor.value,
      poiGroupFloorId: poiGroup.userData.floorId,
      pointer: {
        x: Math.round(event.clientX - rect.left),
        y: Math.round(event.clientY - rect.top)
      },
      visibleMarkerCount: poiSprites.filter((sprite) => sprite.visible).length,
      hitTargetCount: hitTargets.length,
      rayHitCount: poiHits.length,
      raySelectedPoiId: hit ? (getPoiMarkerFromHit(hit.object)?.userData.poi as RenderPoi | undefined)?.id : null,
      fallbackSelectedPoiId: (fallbackMarker?.userData.poi as RenderPoi | undefined)?.id || null
    })

    if (hitMarker?.userData.poi) {
      const selectedPoi = hitMarker.userData.poi as RenderPoi
      if (activeView.value === 'overview') {
        disableAutoSwitchTemporarily(manualAutoSwitchPauseMs)
        try {
          await loadFloor(selectedPoi.floorId, {
            preserveCurrentSceneUntilReady: hasRenderableSceneForFloorTransition(),
            suppressProgress: true,
            detachPoiBeforeLoad: true
          })
          emit('floorChange', selectedPoi.floorId)
        } catch (error) {
          if (isStaleModelLoadError(error)) return
          console.error('展厅点位楼层切换失败:', error)
        }
      }

      const poi = findLoadedPoi(selectedPoi.id) || selectedPoi
      const focusMarker = findPoiSprite(poi.id) || hitMarker
      selectedPOI.value = poi
      activeFocusPoiId.value = poi.id
      triggerPoiTapFeedback(focusMarker)
      updatePoiMarkerFocus()
      showFocusPoiAffordances(poi)
      focusCameraOnPoi(poi)
      refreshPoiVisibilityByDistance()
      emit('poiClick', poi)
      return
    }
  }

  if (activeView.value === 'multi' && activeModel) {
    const floorHits = raycaster.intersectObjects(activeModel.children, true)
    const floorObject = floorHits
      .map((hit) => findFloorObject(hit.object))
      .find((object): object is THREE.Object3D => Boolean(object))

    if (floorObject?.userData.floorId) {
      disableAutoSwitchTemporarily(manualAutoSwitchPauseMs)
      void handleFloorChange(floorObject.userData.floorId as string)
      return
    }
  }

  clearMapSelection()
}

const handlePointerDown = (event: PointerEvent) => {
  activePointers.add(event.pointerId)
  activePointerTypes.set(event.pointerId, event.pointerType)
  if (event.pointerType === 'touch' && getActiveTouchPointerCount() >= 2) {
    hadMultiPointerGesture = true
    pointerDownState = null
    activeAutoSwitchInputSource = 'touch'
    const distance = controls?.getDistance()
    if (typeof distance === 'number' && Number.isFinite(distance)) {
      // 双指手势从第二个触点按下时重新记录连续缩放基准。
      autoSwitchStateMachine.beginInput(distance, 'touch', { resetBaseline: true })
    }
  }
  if (event.pointerType !== 'touch') {
    updateDesktopRotateModifierState(event)
  }
  syncControlInteractionOptions(event)

  if (event.pointerType === 'touch' && hadMultiPointerGesture) {
    pointerDownState = null
    return
  }

  pointerDownState = {
    x: event.clientX,
    y: event.clientY
  }
}

const handlePointerUp = (event: PointerEvent) => {
  activePointers.delete(event.pointerId)
  activePointerTypes.delete(event.pointerId)
  syncControlInteractionOptions(event)

  if (hadMultiPointerGesture) {
    if (getActiveTouchPointerCount() === 0) {
      hadMultiPointerGesture = false
    }
    pointerDownState = null
    return
  }

  if (!pointerDownState) return

  const moveDistance = Math.hypot(event.clientX - pointerDownState.x, event.clientY - pointerDownState.y)
  pointerDownState = null

  if (moveDistance > 8) return

  void handleSceneTap(event)
}

const handlePointerCancel = (event: PointerEvent) => {
  activePointers.delete(event.pointerId)
  activePointerTypes.delete(event.pointerId)
  pointerDownState = null
  if (getActiveTouchPointerCount() === 0) {
    hadMultiPointerGesture = false
  }
  syncControlInteractionOptions(event)
}

const handlePointerLeave = (event: PointerEvent) => {
  if (event.pointerType === 'mouse') {
    pointerDownState = null
  }
}

const handleKeyDown = (event: KeyboardEvent) => {
  if (!event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) return
  updateDesktopRotateModifierState(event)
  syncControlInteractionOptions()
}

const handleKeyUp = (event: KeyboardEvent) => {
  updateDesktopRotateModifierState(event)
  syncControlInteractionOptions()
}

const handleWindowBlur = () => {
  resetInteractionGateState()
}

const handleVisibilityChange = () => {
  if (document.visibilityState !== 'visible') {
    resetInteractionGateState()
  }
}

const emitTargetFocus = (
  request: TargetPoiFocusRequest,
  status: TargetPoiFocusResult['status'],
  message?: string
) => {
  emit('targetFocus', {
    requestId: request.requestId,
    poiId: request.poiId,
    floorId: request.floorId,
    status,
    message
  })
}

const clearTargetFocus = () => {
  pendingTargetFocus = null
  clearMapSelection(false)
}

const isSceneReadyForTargetFocus = () => (
  Boolean(scene && camera && controls && loader && floorIndex.value.length)
  && !isLoading.value
  && !loadError.value
)

const focusCameraOnPoi = (poi: RenderPoi) => {
  const displayPosition = getPoiDisplayPosition(poi)
  if (!camera || !controls || !displayPosition) return

  const target = displayPosition.clone()
  const modelSize = activeModel
    ? new THREE.Box3().setFromObject(activeModel).getSize(new THREE.Vector3())
    : new THREE.Vector3(80, 80, 80)
  const maxDim = Math.max(modelSize.x, modelSize.y, modelSize.z, 1)
  const focusDistanceFactor = Math.max(props.targetFocusDistanceFactor, 0.1)
  const focusMaxDistanceFactor = Math.max(0.55, focusDistanceFactor * 1.4)
  const distance = Math.min(
    Math.max(maxDim * focusDistanceFactor, 28),
    Math.max(maxDim * focusMaxDistanceFactor, 80)
  )
  const direction = new THREE.Vector3(0.72, 0.58, 1).normalize()
  const nextPosition = target.clone().add(direction.multiplyScalar(distance))

  camera.near = Math.max(SGS_VISUAL_RENDER_CONFIG.camera.near, maxDim / 1200)
  camera.far = Math.max(SGS_VISUAL_RENDER_CONFIG.camera.far, maxDim * 20)
  camera.updateProjectionMatrix()

  controls.minDistance = Math.max(SGS_VISUAL_RENDER_CONFIG.controls.minDistance, maxDim * 0.025)
  controls.maxDistance = Math.max(SGS_VISUAL_RENDER_CONFIG.controls.maxDistance, maxDim * 6)
  moveCameraTo(nextPosition, target)
}

const focusTargetPoi = async (request: TargetPoiFocusRequest) => {
  if (!request.poiId || !request.floorId) {
    emitTargetFocus(request, 'missing', '目标位置数据不完整')
    return false
  }

  const floor = floorIndex.value.find((item) => item.floorId === request.floorId)
  if (!floor) {
    emitTargetFocus(request, 'missing', '目标楼层不在当前三维资源中')
    return false
  }

  activeFocusPoiId.value = request.poiId

  try {
    if (activeView.value !== 'floor' || currentFloor.value !== request.floorId) {
      loadError.value = false
      await loadFloor(request.floorId, {
        preserveCurrentSceneUntilReady: hasRenderableSceneForFloorTransition(),
        suppressProgress: true,
        detachPoiBeforeLoad: true
      })
      isLoading.value = false
      emit('floorChange', request.floorId)
    } else if (shouldRenderPoiMarkers.value && !getPoiSprites().length) {
      await loadFloorPOIs(floor, modelLoadVersion)
    }

    const poi = findLoadedPoi(request.poiId) || addFocusMarkerFromRequest(request) || createPoiFromFocusRequest(request)

    if (!poi?.positionGltf) {
      selectedPOI.value = null
      disposeFocusLabel()
      updatePoiMarkerFocus()
      emitTargetFocus(request, 'missing', '目标暂无三维坐标')
      return false
    }

    selectedPOI.value = poi
    updatePoiMarkerFocus()
    showFocusPoiAffordances(poi)
    focusCameraOnPoi(poi)
    refreshPoiVisibilityByDistance()
    emitTargetFocus(request, 'focused')
    return true
  } catch (error) {
    if (isStaleModelLoadError(error)) return false

    console.error('目标 POI 聚焦失败:', error)
    loadError.value = true
    isLoading.value = false
    setFriendlyModelLoadError()
    emitTargetFocus(request, 'error', error instanceof Error ? error.message : '目标位置聚焦失败')
    return false
  }
}

const queueTargetFocus = (request: TargetPoiFocusRequest | null) => {
  if (!request) {
    clearTargetFocus()
    return
  }

  pendingTargetFocus = request

  if (!isSceneReadyForTargetFocus()) return

  const nextRequest = pendingTargetFocus
  if (!nextRequest) return

  pendingTargetFocus = null
  targetFocusQueue = targetFocusQueue
    .then(() => focusTargetPoi(nextRequest))
    .finally(() => {
      if (pendingTargetFocus) {
        queueTargetFocus(pendingTargetFocus)
      }
    })
}

const loadModelPackage = async () => {
  setProgress(8, '正在读取馆内导览资源...')
  const packageData = await props.modelSource.loadPackage()
  renderPackage.value = packageData

  setProgress(14, '正在读取楼层索引...')
  floorIndex.value = [...packageData.floors].sort(compareFloorsTopToBottom)
  logThreeMapDiagnostic('package-ready', {
    initialView: props.initialView,
    requestedInitialFloorId: props.initialFloorId,
    overviewModelUrl: packageData.overviewModelUrl,
    floorCount: floorIndex.value.length,
    floorModels: floorIndex.value.map((floor) => ({
      floorId: floor.floorId,
      modelUrl: floor.modelUrl,
      sharedModelAsset: floor.sharedModelAsset
    }))
  })

  const requestedInitialFloorId = resolveFloorIdFromRequest(props.initialFloorId)
  if (requestedInitialFloorId) {
    currentFloor.value = requestedInitialFloorId
  } else if (!floorIndex.value.some((floor) => floor.floorId === currentFloor.value)) {
    currentFloor.value = getDefaultFloorId()
  }

  if (props.initialView === 'floor') {
    const didCommit = await loadFloor(currentFloor.value)
    if (didCommit) {
      emit('floorChange', currentFloor.value)
    }
    return
  }

  if (props.initialView === 'multi') {
    await loadMultiFloor()
    return
  }

  await loadOverview()
}

const init3DScene = async () => {
  try {
    disposeScene()
    isDisposed = false
    firstModelLoadStartedAt = getNow()
    firstModelVisibleReported = false
    initialModelSettled = false
    isLoading.value = true
    loadError.value = false
    selectedPOI.value = null
    hasLoadedFloorViewOnce = false
    setProgress(0, '正在初始化三维场景...')
    logThreeMapDiagnostic('init-start', {
      initialView: props.initialView,
      initialFloorId: props.initialFloorId
    })

    await initThree()
    await loadModelPackage()

    setProgress(100, '馆内三维模型加载完成')
    initialModelSettled = true
    isLoading.value = false
    emit('initialModelReady', {
      view: activeView.value,
      floorId: currentFloor.value,
      elapsedMs: firstModelLoadStartedAt ? Math.round(getNow() - firstModelLoadStartedAt) : undefined
    })
    await syncRequestedIndoorView()
    queueTargetFocus(pendingTargetFocus || props.targetFocus || null)
  } catch (error) {
    if (isStaleModelLoadError(error)) return

    console.error('馆内 3D 模型资源加载失败:', error)
    loadError.value = true
    isLoading.value = false
    const message = error instanceof Error ? error.message : '请检查模型资源或网络状态后重试'
    setFriendlyModelLoadError()
    initialModelSettled = true
    emit('initialModelFailed', {
      view: activeView.value,
      floorId: currentFloor.value,
      message,
      elapsedMs: firstModelLoadStartedAt ? Math.round(getNow() - firstModelLoadStartedAt) : undefined
    })
  }
}

const handleFloorChange = async (floorId: string) => {
  const requestedFloorId = resolveFloorIdFromRequest(floorId) || floorId
  const isCommittedSameFloor = (
    activeView.value === 'floor'
    && currentFloor.value === requestedFloorId
    && activeModel?.userData.floorId === requestedFloorId
    && !isFloorSwitching
  )
  if (isCommittedSameFloor) {
    emit('floorChange', requestedFloorId)
    return
  }

  if (isFloorSwitching && floorSwitchRequestedFloorId === requestedFloorId) {
    return
  }

  const previousLoadToken = modelLoadVersion
  try {
    isLoading.value = true
    loadError.value = false
    setProgress(12, `正在切换到 ${formatFloorLabel(requestedFloorId)}...`)
    const didCommit = await loadFloor(requestedFloorId, {
      preserveCurrentSceneUntilReady: true,
      suppressProgress: false,
      detachPoiBeforeLoad: true
    })
    updatePoiVisibilityByDistance()
    if (didCommit) {
      emit('floorChange', requestedFloorId)
    }
  } catch (error) {
    if (isStaleModelLoadError(error)) return

    console.error('楼层模型加载失败:', error)
    loadError.value = true
    restoreCommittedFloorPoiLayer()
    setFriendlyModelLoadError()
  } finally {
    const requestWasSuperseded = modelLoadVersion > previousLoadToken + 1
    if (!requestWasSuperseded || floorSwitchRequestedFloorId === requestedFloorId) {
      isLoading.value = false
    }
  }
}

const showOverview = async () => {
  if (activeView.value === 'overview') {
    resetCamera()
    return
  }

  const previousLoadToken = modelLoadVersion
  try {
    isLoading.value = true
    loadError.value = false
    clearMapSelection(false)
    clearRoutePreview()
    await loadOverview()
  } catch (error) {
    if (!isStaleModelLoadError(error)) {
      console.error('建筑外观模型加载失败:', error)
      loadError.value = true
      setFriendlyModelLoadError()
    }
    throw error
  } finally {
    const requestWasSuperseded = modelLoadVersion > previousLoadToken + 1
    if (!requestWasSuperseded) {
      isLoading.value = false
    }
  }
}

const showMultiFloor = async () => {
  try {
    isLoading.value = true
    loadError.value = false
    activeFocusPoiId.value = ''
    await loadMultiFloor()
    isLoading.value = false
  } catch (error) {
    if (isStaleModelLoadError(error)) return

    console.error('多层展示模型加载失败:', error)
    loadError.value = true
    isLoading.value = false
    setFriendlyModelLoadError()
  }
}

const retryLoad = () => {
  init3DScene()
}

const disposeScene = () => {
  isDisposed = true
  adjacentPreloadSeq += 1
  invalidateModelLoads()

  if (animationId) {
    window.cancelAnimationFrame(animationId)
    animationId = 0
  }

  const container = getContainerElement()
  container?.removeEventListener('pointerdown', handlePointerDown, true)
  container?.removeEventListener('pointerup', handlePointerUp, true)
  container?.removeEventListener('pointercancel', handlePointerCancel, true)
  container?.removeEventListener('pointerleave', handlePointerLeave, true)
  window.removeEventListener('keydown', handleKeyDown)
  window.removeEventListener('keyup', handleKeyUp)
  window.removeEventListener('blur', handleWindowBlur)
  document.removeEventListener('visibilitychange', handleVisibilityChange)
  renderer?.domElement.removeEventListener('wheel', handleWheelIntent, true)

  resizeObserver?.disconnect()
  resizeObserver = null

  // 清理自动切换相关资源
  if (controls) {
    controls.removeEventListener('start', handleControlStart)
    controls.removeEventListener('end', handleControlEnd)
    controls.removeEventListener('change', handleControlChange)
    controls.dispose()
  }
  controls = null

  if (autoSwitchDisableTimer) {
    clearTimeout(autoSwitchDisableTimer)
    autoSwitchDisableTimer = null
  }

  autoSwitchStateMachine.dispose()
  clearProgrammaticCameraTimer()
  clearModelAdjustReportTimer()
  hasPendingManualModelAdjustment = false
  cameraTween = null
  isProgrammaticCameraChange = false
  activeAutoSwitchInputSource = 'gesture'
  resetInteractionGateState()

  clearSceneData()
  disposePoiMarkerCache()
  disposeCachedOverviewModel()
  scene?.clear()
  scene = null
  camera = null
  loader = null
  dracoLoader?.dispose()
  dracoLoader = null
  poiGroup = null
  routeGroup = null

  if (renderer) {
    renderer.dispose()
    renderer.forceContextLoss()
    renderer.domElement.remove()
  }

  renderer = null
}

defineExpose({
  switchFloor: handleFloorChange,
  showOverview,
  showMultiFloor,
  resetCamera,
  setCameraPreset,
  zoomCamera,
  focusTargetPoi: (request: TargetPoiFocusRequest) => {
    queueTargetFocus(request)
  },
  clearSelection: clearMapSelection,
  clearRoute: clearRoutePreview,
  clearNavigation: () => clearMapSelection(false),
  disableAutoSwitchTemporarily
})

watch(() => props.modelSource, () => {
  init3DScene()
})

watch(() => [props.showControls, props.touchGestureMode], () => {
  syncControlInteractionOptions()
})

watch(() => props.targetFocus, (request) => {
  queueTargetFocus(request || null)
}, {
  deep: true,
  immediate: true
})

watch(() => props.visiblePoiIds, () => {
  refreshPoiVisibilityByDistance()
}, {
  deep: true
})

watch(() => [props.routePreview, props.showRoute], () => {
  renderRoutePreview()
}, {
  deep: true
})

const syncRequestedIndoorView = async () => {
  if (!scene || !floorIndex.value.length || isLoading.value) return

  if (props.initialView === 'multi') {
    if (activeView.value !== 'multi') {
      await showMultiFloor()
    }
    return
  }

  if (props.initialView === 'floor') {
    const requestedFloorId = resolveFloorIdFromRequest(props.initialFloorId)
      || currentFloor.value
    if (requestedFloorId && (activeView.value !== 'floor' || currentFloor.value !== requestedFloorId)) {
      await handleFloorChange(requestedFloorId)
    }
  }
}

watch(() => [props.initialView, props.initialFloorId], () => {
  void syncRequestedIndoorView()
})

onMounted(() => {
  init3DScene()
})

onUnmounted(() => {
  disposeScene()
})
</script>

<style scoped lang="scss">
.three-map-container {
  position: relative;
  width: 100%;
  height: 100%;
  overflow: hidden;
  background: #f7f8f4;
}

.three-canvas-wrapper {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;

  :deep(canvas) {
    width: 100% !important;
    height: 100% !important;
    display: block;
    touch-action: none;
  }
}

.loading-overlay {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(247, 248, 244, 0.9);
  z-index: 20;
}

.loading-content {
  width: min(260px, calc(100vw - 80px));
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
}

.loading-spinner {
  width: 34px;
  height: 34px;
  border: 3px solid rgba(31, 35, 41, 0.16);
  border-top-color: #1f2329;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

.loading-text {
  font-size: 13px;
  line-height: 18px;
  color: #1f2329;
  text-align: center;
}

.loading-progress {
  width: 100%;
  height: 4px;
  overflow: hidden;
  background: rgba(31, 35, 41, 0.12);
  border-radius: 2px;
}

.loading-bar {
  height: 100%;
  background: #1f8f5f;
  transition: width 0.2s ease;
}

.error-overlay {
  background: rgba(31, 35, 41, 0.28);
}

.error-content {
  padding: 18px;
  box-sizing: border-box;
  background: rgba(255, 255, 255, 0.94);
  border: 1px solid rgba(31, 35, 41, 0.1);
  border-radius: 8px;
}

.error-title {
  font-size: 16px;
  line-height: 22px;
  font-weight: 700;
  color: #1f2329;
}

.error-text {
  font-size: 13px;
  line-height: 18px;
  color: #5f666d;
  text-align: center;
}

.retry-btn {
  min-width: 96px;
  height: 34px;
  padding: 0 16px;
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  background: #1f2329;
  border-radius: 6px;
}

.retry-text {
  font-size: 13px;
  line-height: 18px;
  font-weight: 600;
  color: #ffffff;
}

.map-toolbar {
  position: absolute;
  left: 12px;
  top: 50%;
  transform: translateY(-50%);
  z-index: 12;
}

.overview-btn {
  height: 34px;
  padding: 0 12px;
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  background: rgba(255, 255, 255, 0.86);
  border: 1px solid rgba(31, 35, 41, 0.12);
  border-radius: 6px;
  box-shadow: 0 4px 14px rgba(31, 35, 41, 0.12);
}

.overview-btn.active {
  background: #1f2329;
}

.overview-text {
  font-size: 12px;
  line-height: 16px;
  font-weight: 700;
  color: #1f2329;
}

.overview-btn.active .overview-text {
  color: #ffffff;
}

.poi-detail-popup {
  position: absolute;
  left: 50%;
  bottom: 118px;
  width: min(280px, calc(100vw - 64px));
  padding: 12px 14px;
  transform: translateX(-50%);
  box-sizing: border-box;
  background: rgba(255, 255, 255, 0.94);
  border: 1px solid rgba(31, 35, 41, 0.1);
  border-radius: 8px;
  box-shadow: 0 10px 28px rgba(31, 35, 41, 0.16);
  z-index: 60;
  pointer-events: auto;
}

.poi-content {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.poi-name {
  font-size: 15px;
  line-height: 20px;
  font-weight: 700;
  color: #1f2329;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.poi-floor {
  font-size: 12px;
  line-height: 16px;
  color: #5f666d;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
</style>
