MapCanvas.vue 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626
  1. <!--
  2. 中央一张图:常驻地图宿主(票 02)。
  3. · 实例生命周期归地图服务层(services/mapService.js)——本组件只负责宿主要素与尺寸上报。
  4. · 常驻语义:一级模式切换、面板收放都不得销毁或重建本组件与地图实例。
  5. · 当前底图为临时影像(public/cockpit/aerial.webp);正式底图/瓦片见票 21/22,
  6. 引擎接入 Cesium 时只改地图服务内部(见票 06 调研),本组件与外壳不动。
  7. -->
  8. <template>
  9. <div ref="hostRef" class="map-canvas" :data-map-instance="instanceId">
  10. <!-- 临时影像底图 -->
  11. <div class="map-canvas__photo" :style="{ backgroundImage: `url(${photoUrl})` }"></div>
  12. <div class="map-holo" aria-hidden="true">
  13. <div class="map-holo__grid"></div>
  14. <div class="map-holo__scan"></div>
  15. <div class="map-holo__vignette"></div>
  16. </div>
  17. <!--
  18. 一张图**最上方**:状态栏(票 14;决策 02 的 Q9/Q12/Q18——原只读图例改为
  19. **全局组合筛选入口**)。八桶多选 OR、计数=全局未筛全集(后端全量、不分页,
  20. 与监测分布饼图共用同一份,来自 `map-snapshot` 的八桶全局计数);
  21. 筛选只改选中高亮、不改计数;行**常驻可点**(含计数 0 的行——死胡同根治)。
  22. 票 46:引导态仍不显示(规格 18:引导态没有图例/计数/详情)。
  23. -->
  24. <div class="map-holo__top">
  25. <div v-if="entered" class="map-holo__legend" data-map-legend data-status-filter>
  26. <button
  27. v-for="row in statusRows"
  28. :key="row.key"
  29. type="button"
  30. class="map-holo__legend-row"
  31. :class="{ 'is-selected': row.selected }"
  32. :style="{ '--legend-color': `var(--${row.tone})` }"
  33. :data-legend-key="row.key"
  34. :data-status-selected="row.selected ? 'yes' : 'no'"
  35. :data-status-count="row.count === null ? '' : String(row.count)"
  36. :title="row.title"
  37. :aria-pressed="row.selected"
  38. @click="toggleStatusFilter(row.key)"
  39. >
  40. <!-- 批次 1 第三轮:状态行前置区分图标(语义色染色)。纯装饰,形状+颜色仍是第一线索。 -->
  41. <CockpitIcon v-if="row.icon" monochrome :name="row.icon" size="calc(14 * var(--u, 1px))" class="map-holo__legend-icon" />
  42. <StatusTag :tone="row.tone" :shape="row.shape" :label="row.label" />
  43. <span class="map-holo__legend-count" data-status-count>:{{ row.count === null ? '—' : row.count }}</span>
  44. </button>
  45. </div>
  46. <!--
  47. 票 25:中央「一张图」标题与图层占位已并入右下角的地图信息集合面板。
  48. 此处**只保留状态筛选图例**——它是决策 02 冻结的全局组合筛选入口,必须常驻可点。
  49. -->
  50. </div>
  51. <!--
  52. 票 25:原先挂在地图四周的标题、图层占位、底图提示、加载状态、降级说明、标记计数、
  53. 坐标准入浮块与引擎读数,已并入**外壳的状态面板**(`CockpitShell.vue` 的
  54. `cockpit-shell__status-details`)。它们不再出现在地图上,中央只保留状态筛选图例
  55. 与 3D 场景;维护者反馈过另起面板会和图例、外壳面板互相占用容器。
  56. -->
  57. </div>
  58. </template>
  59. <script setup>
  60. import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
  61. import {
  62. clearPointMarkers,
  63. getPointMarkerState,
  64. locateMonitoringPoint,
  65. mountMap,
  66. onDeviceHover,
  67. onPipelinePick,
  68. onPointPick,
  69. publishSummaryHandle,
  70. resizeMap,
  71. syncPointMarkersForPlan,
  72. // 票 24:设施点位与管线图层。与摘要标记分属不同数据源与不同业务对象,
  73. // 各自同步,互不写对方的状态。
  74. syncPipeLayerForPlan,
  75. unmountMap,
  76. } from '../services/mapService.js'
  77. import { MAP_SUMMARY_PHASE } from '../domain/mapSummary.js'
  78. // 票 46:模式图层计划由外壳算好传入(本组件只消费,不 import 计划函数 —— 避免二次过滤诱惑)。
  79. import { selectionFromMapObject } from '../domain/detailFlow.js'
  80. // 批次 3 第五轮:未关联设备点选的镜头联动复用定位链路(坐标准入与 findMapObject 同源)。
  81. import { isRenderable } from '../domain/coordinate.js'
  82. import { findMapObject } from '../domain/mapLocate.js'
  83. // 票 41:监测点选择的统一入口(地图点选与台账卡片共用同一条,见 pointSelection.js)。
  84. import { selectMonitoringPoint } from '../pointSelection.js'
  85. // 票 14:八桶词表(唯一一张表)与全局状态筛选(顶部状态栏的计数与选中态来源)。
  86. import { legendVisuals } from '../domain/pointStatus.js'
  87. import { storeToRefs } from 'pinia'
  88. import { useDeviceLayerStore } from '../stores/deviceLayerStore.js'
  89. import { useFilterStore } from '../stores/filterStore.js'
  90. import { useMapStore } from '../stores/mapStore.js'
  91. import { useDetailStore } from '../stores/detailStore.js'
  92. import StatusTag from './StatusTag.vue'
  93. import CockpitIcon from './CockpitIcon.vue'
  94. const emit = defineEmits(['pipeline-pick'])
  95. const props = defineProps({
  96. modeLabel: { type: String, default: '' },
  97. /**
  98. * 票 32:调试形态。`false`(生产构建默认)时收起「中央一张图」标题、图层占位、实例提示等
  99. * 开发期文本,只保留**颜色语义**(点位状态图例 + 状态灯)与一行贴底小字。
  100. * 外壳按配置 `map.ui.debug` 下发,缺省跟随构建模式。
  101. */
  102. uiDebug: { type: Boolean, default: true },
  103. /**
  104. * 票 46:模式图层计划的输出(`domain/modeLayerPlan.js` 的 `planModeLayers()` 结果)。
  105. * 本组件**只消费它**:不自己判模式、不自己过滤 objects、不自己算图例。
  106. * 传入 null(理论不发生)时按空计划处理,保证组件不崩。
  107. */
  108. plan: { type: Object, default: null },
  109. })
  110. // 走 Vite 的 BASE_URL,保证 qiankun / 相对 base 部署下也能取到 public 资源。
  111. const base = import.meta.env.BASE_URL || '/'
  112. const photoUrl = `${base}cockpit/aerial.webp`
  113. const hostRef = ref(null)
  114. const instanceId = ref('')
  115. // ---- 票 08:地图摘要的渐进加载状态(装载器归 mapStore,本组件只画出来)----
  116. // 「地图先显示框架」在代码里的落点:本组件与地图实例先挂好,装载器初始态是 idle
  117. // (文案「地图框架已就绪 · 等待分页数据」),随后每取一页才多出点位。
  118. const mapStore = useMapStore()
  119. const detailStore = useDetailStore()
  120. // 票 43:三维 hover / 点选设备都经这个 store(面板高亮与选中态的唯一持有者)。
  121. const deviceLayerStore = useDeviceLayerStore()
  122. const summaryText = computed(() => mapStore.view.label)
  123. const summarySettled = computed(() => mapStore.view.phase === MAP_SUMMARY_PHASE.COMPLETE)
  124. const summaryBad = computed(() => ['failed', 'expired'].includes(mapStore.view.phase))
  125. // 票 07:坐标准入总览(后端全量口径 + 已加载口径 + 不上图清单)。判定归 domain/coordinate.js。
  126. const coordinateOverview = computed(() => mapStore.coordinate)
  127. // ---- 票 09/14:点位标记与状态栏(标记画在地图上;状态栏是全局筛选入口)----
  128. // 标记与状态栏的形状/色键同源于 `domain/pointStatus.js` 的唯一一张表——
  129. // 不是"两边各自小心保持一致",而是结构上只有一份状态→视觉的映射。
  130. // 票 14:状态栏**计数改为全局未筛口径**(`mapStore.statusDistribution`,与饼图共用),
  131. // 不再是"当前已上图标记"的计数——上图数由底部「点位标记 N」单独说明。
  132. const markerState = ref(getPointMarkerState())
  133. const markerStatusText = computed(() => {
  134. const byStatus = markerState.value.byStatus
  135. const keys = Object.keys(byStatus).sort()
  136. if (!keys.length) return '等待分页数据'
  137. return keys.map((key) => `${key} ${byStatus[key]}`).join(' · ')
  138. })
  139. // ---- 票 14:状态栏 = 全局组合筛选入口(八桶多选 OR、全局未筛计数、选中高亮、行常驻可点)----
  140. const filterStore = useFilterStore()
  141. const { pointStatus: selectedStatuses } = storeToRefs(filterStore)
  142. const statusRows = computed(() => {
  143. const distribution = mapStore.statusDistribution
  144. return legendVisuals().map((visual) => {
  145. const selected = selectedStatuses.value.includes(visual.key)
  146. // 分布未取到(后端还没发布八桶计数)→ 计数 null,界面显示「—」,**不补 0**
  147. // 「后端没给」与「确实是 0」是两件事(票 05 的纪律)。
  148. const count = distribution.available ? distribution.counts[visual.key] || 0 : null
  149. return {
  150. key: visual.key,
  151. tone: visual.tone,
  152. shape: visual.shape,
  153. label: visual.label,
  154. // 批次 1 第三轮:状态行区分图标(legendVisuals 补的纯视觉字段)。
  155. icon: visual.icon || '',
  156. count,
  157. selected,
  158. title:
  159. count === null
  160. ? `按「${visual.label}」筛选(可多选);全局状态分布未取到,计数暂不可见`
  161. : selected
  162. ? `取消「${visual.label}」筛选(计数 ${count} 为全局未筛口径,不随筛选变化)`
  163. : `按「${visual.label}」筛选(可多选;计数 ${count} 为全局未筛口径)`,
  164. }
  165. })
  166. })
  167. /** 点状态桶 → 写全局筛选(地图与面板的跟随由外壳的 watcher 收口,本组件不自己发请求)。 */
  168. function toggleStatusFilter(key) {
  169. filterStore.togglePointStatus(key)
  170. }
  171. // ---- 票 46:模式图层计划(唯一裁决点)----
  172. // 计划由**外壳**算好传入(外壳握有设备行/异常设备/选择态全部输入),
  173. // 本组件只消费:markers 喂服务层、图例/降级说明直接渲染。不二次过滤。
  174. const EMPTY_PLAN = {
  175. mode: 'overview',
  176. entered: false,
  177. markers: [],
  178. blocked: [],
  179. legend: [],
  180. byStatus: {},
  181. locateTargets: [],
  182. degradedNotes: [],
  183. associatedDevices: [],
  184. selection: { visible: false, action: 'preserve' },
  185. }
  186. const modePlan = computed(() => props.plan || EMPTY_PLAN)
  187. /** 引导态(计划层以 `entered` 表达):图例/计数浮标/降级面板都门控掉。 */
  188. const entered = computed(() => modePlan.value.entered === true)
  189. let resizeObserver = null
  190. let stopSummaryWatch = null
  191. let stopMarkerWatch = null
  192. let stopPointPick = null
  193. // 票 43:三维 hover 订阅(与点选一起在卸载时退订)。
  194. let stopDeviceHover = null
  195. let stopPipelinePick = null
  196. // 票 23:管线/构件命中由地图服务按设备/监测点之后的优先级派发。
  197. /**
  198. * 把装载状态写进宿主 `dataset`(走查据此断言,不靠读中文字符串):
  199. * `data-map-summary` = idle / loading / complete / empty / failed / expired
  200. * `data-map-summary-loaded` = 已上图对象数
  201. * `data-map-summary-snapshot` = 本次加载锁定的快照标记
  202. * `data-map-summary-total` = 后端给的总数
  203. *
  204. * 票 07 追加坐标准入的四个数(同样给走查读,不读中文):
  205. * `data-map-renderable` = 已加载范围内可上图数
  206. * `data-map-blocked` = 已加载范围内不上图数
  207. * `data-map-blocked-backend`= 后端全量口径的不上图数(= 非法 + 未校验)
  208. * `data-map-coordinate-crs` = 后端汇总里的坐标系
  209. */
  210. function paintSummary(host, state) {
  211. if (!host || !host.dataset) return
  212. host.dataset.mapSummary = state.phase
  213. host.dataset.mapSummaryLoaded = String(state.loaded)
  214. host.dataset.mapSummaryTotal = String(state.total)
  215. host.dataset.mapSummarySnapshot = state.snapshot || ''
  216. host.dataset.mapSummaryPage = String(state.page)
  217. const overview = coordinateOverview.value
  218. host.dataset.mapRenderable = String(overview.loaded.renderable)
  219. host.dataset.mapBlocked = String(overview.loaded.blocked)
  220. const backend = overview.backend
  221. host.dataset.mapBlockedBackend = backend.available
  222. ? String((backend.invalid === null ? 0 : backend.invalid) + (backend.unverified === null ? 0 : backend.unverified))
  223. : ''
  224. host.dataset.mapCoordinateCrs = backend.crs || ''
  225. }
  226. /**
  227. * 票 09:把当前已加载的对象同步成地图标记,并把结果(含图例)取回来给组件渲染。
  228. *
  229. * 为什么要等:装载器几乎总是**比引擎先就绪**(引擎要动态拉 Cesium 分包、再拉地形与影像,
  230. * 实测首屏 dev 冷启动可以到十几秒),这时 `syncPointMarkersForSummary()` 只更新计划、返回 false。
  231. * 若不补画,第一批点位就永远上不了图 —— 而票 08 的渐进加载恰恰是从第一批开始的。
  232. *
  233. * 退出的判据是**引擎的终态**(`data-map-engine` 落到 ready/failed/disabled/unavailable),
  234. * 不是"试够多少次就算了":第一版写的是 40 × 250ms = 10 秒封顶,实测引擎冷启动超过 10 秒时
  235. * 重试已经放弃,地图上一个标记都没有(图例却有计数)—— 只有用户去点一下「加载下一页」
  236. * 才会因为对象变化重新触发。那个 bug 是视觉探针(截图里「点位标记 0」)抓到的,
  237. * 走查当时反而没抓到,因为它一路在点分页按钮。
  238. *
  239. * `markerTimer` 保证**同时只有一个**重试在排队:翻页会连触发 watch,若不收口,
  240. * 几页就会叠出几个定时器(各自重画一遍,白烧 CPU 且最后谁赢不确定)。
  241. */
  242. let markerTimer = null
  243. /** 引擎是否已落到终态;没落到就还得等它(booting/idle 都要等)。 */
  244. function engineSettled() {
  245. const host = hostRef.value
  246. const state = host && host.dataset ? host.dataset.mapEngine : ''
  247. return ['ready', 'failed', 'disabled', 'unavailable'].includes(state)
  248. }
  249. function applyMarkers() {
  250. // 票 46:只把**计划**交给服务层 —— 模式过滤已在 `domain/modeLayerPlan.js` 收口,
  251. // 服务层与引擎都不做第二次过滤。图例与标记读同一份计划的 `byStatus`。
  252. const painted = syncPointMarkersForPlan({
  253. markers: modePlan.value.markers,
  254. byStatus: modePlan.value.byStatus,
  255. })
  256. markerState.value = getPointMarkerState()
  257. // 票 24:设施点位与管线跟着**同一份模式计划**走 —— 图层显隐因此与监测点标记
  258. // 必然一致(计划里 hidden 就是空数组 → 自动撤下),不会出现两套判定打架。
  259. syncPipeLayerForPlan(modePlan.value)
  260. // 票 46:降级说明写进宿主 dataset(走查断言「数据未就绪 ≠ 空数据」的断言面)。
  261. const host = hostRef.value
  262. if (host && host.dataset) {
  263. host.dataset.mapDegraded = modePlan.value.degradedNotes.map((note) => note.key).join(',') || ''
  264. }
  265. // 画上了就收工;引擎已定局(失败/关闭/没有宿主)也不用再等 —— 图例仍要显示。
  266. if (painted || engineSettled()) return
  267. if (markerTimer !== null) return
  268. markerTimer = window.setTimeout(() => {
  269. markerTimer = null
  270. applyMarkers()
  271. }, 250)
  272. }
  273. /**
  274. * 票 33:applyMarkers 的**批间合并**(票 26 诊断的另一半:8 批分页每批都撞一次 watcher)。
  275. *
  276. * 30s freshness tick 的 `map-snapshot` 分批到达(批间 120ms),批批新 objects 引用都会
  277. * 触发一次 marker watch。这里做**领先一次 + 落尾一次**的合并:批 1 立即画(保持首响),
  278. * 合并窗口内的后续批次不再各自重同步,只把一次落尾补画排到最后一批之后——
  279. * 一轮 refresh 从 8 次全管线重同步降到 1–2 次(窗口内 diff 已让重复同步变便宜,这里让它更少)。
  280. * 120ms 与 `mapStore` 的 auto-batch 间隔同值:窗口恰好盖住一整轮分页。
  281. */
  282. const APPLY_MERGE_MS = 120
  283. let lastApplyAt = 0
  284. let pendingApplyTimer = null
  285. function applyMarkersMerged() {
  286. const elapsed = Date.now() - lastApplyAt
  287. if (elapsed < APPLY_MERGE_MS) {
  288. // 窗口内:只排一次落尾(拿到的是最后一批的状态,中间批次画了也会被覆盖)。
  289. if (pendingApplyTimer === null) {
  290. pendingApplyTimer = window.setTimeout(() => {
  291. pendingApplyTimer = null
  292. applyMarkersMerged()
  293. }, APPLY_MERGE_MS - elapsed)
  294. }
  295. return
  296. }
  297. lastApplyAt = Date.now()
  298. applyMarkers()
  299. }
  300. onMounted(() => {
  301. // 幂等挂载:模式切换或面板收放都不会再次走到这里(本组件常驻)。
  302. const instance = mountMap(hostRef.value)
  303. instanceId.value = instance.id
  304. // 宿主尺寸变化上报给地图服务(接入 Cesium 后即 viewer.resize())。
  305. if (typeof ResizeObserver !== 'undefined' && hostRef.value) {
  306. resizeObserver = new ResizeObserver(() => resizeMap())
  307. resizeObserver.observe(hostRef.value)
  308. }
  309. // 装载器状态 → 宿主 dataset(常驻组件,停止监听只在离开 /cockpit 时发生)。
  310. mapStore.setup({})
  311. paintSummary(hostRef.value, mapStore.summary)
  312. stopSummaryWatch = watch(() => mapStore.summary, (state) => paintSummary(hostRef.value, state), { deep: false })
  313. // 票 09/46:摘要翻页、计划变化(模式/引导态/异常设备都在计划里体现)→ 重画一次
  314. // (服务层只消费计划,重画是唯一路径;一个 watch 一份退订,不叠两份)。
  315. // 票 33:重画走**批间合并**入口,不再批批全量同步。
  316. stopMarkerWatch = watch(
  317. () => [mapStore.summary.objects, modePlan.value.markers, modePlan.value.byStatus, modePlan.value.pipelines, modePlan.value.facilities],
  318. () => applyMarkersMerged(),
  319. { deep: false },
  320. )
  321. // DEV 把手:人工演示与走查用 `window.__cockpitMap.mapSummary` 一页页推进(票 08)。
  322. publishSummaryHandle({
  323. advance: () => mapStore.advance(),
  324. refresh: () => mapStore.refresh(),
  325. getState: () => mapStore.summary,
  326. })
  327. // 票 10:点选地图上的点位标记 → 打开两级详情。
  328. // 分工:地图服务只回答「拾到了谁」(拾不到、或拾到空白一律 null),本组件决定「打开什么」。
  329. // 「关联设备不重复上图」在这条路径上是结构性的:它只把 kind/code 变成选择,
  330. // 从不往地图里加对象(标记计划的唯一来源仍是 mapStore.summary.objects)。
  331. stopPointPick = onPointPick((picked) => {
  332. // 票 43:三维点选**设备点** → 选中该设备并进设备级(复用同一入口,含镜头联动)。
  333. if (picked.kind === 'monitoring-device') {
  334. deviceLayerStore.selectDevice(picked.code)
  335. return
  336. }
  337. // 票 41:监测点走**统一选择入口**(与台账卡片同一条)——它负责点位级定位 + 打开两级详情。
  338. if (picked.kind === 'monitoring-point') selectMonitoringPoint(picked.code)
  339. else {
  340. const selection = selectionFromMapObject(picked)
  341. if (selection) {
  342. detailStore.openSelection(selection)
  343. // 批次 3 第五轮修复:点选**未关联设备**也要像监测点一样飞镜头——
  344. // 走查发现「只出面板不移动」。复用 `locateMonitoringPoint` 同一条定位链路
  345. // (它只要求对象坐标准入通过,不关心 kind);坐标来自 map-snapshot(未关联
  346. // 设备自带经纬度),与标记上图同一份数据,不借用、不伪造。
  347. const mapStore = useMapStore()
  348. const object = findMapObject(mapStore.summary.objects, picked.code)
  349. if (object && isRenderable(object)) void locateMonitoringPoint(object)
  350. }
  351. }
  352. })
  353. // 票 43:三维 hover 设备点 → 面板对应卡片高亮(服务层已做节流,这里只转发)。
  354. stopDeviceHover = onDeviceHover((hit) => {
  355. deviceLayerStore.hoverDevice(hit ? hit.code : '')
  356. })
  357. stopPipelinePick = onPipelinePick((hit) => emit('pipeline-pick', hit))
  358. })
  359. onBeforeUnmount(() => {
  360. publishSummaryHandle(null)
  361. // 票 10:先退订点选,再拆地图(否则服务层还会往已卸载的 store 上写选择)。
  362. if (stopPointPick) {
  363. stopPointPick()
  364. stopPointPick = null
  365. }
  366. // 票 43:hover 订阅同样先退订(否则服务层还会往已卸载的 store 上写 hover)。
  367. if (stopDeviceHover) {
  368. stopDeviceHover()
  369. stopDeviceHover = null
  370. }
  371. if (stopPipelinePick) {
  372. stopPipelinePick()
  373. stopPipelinePick = null
  374. }
  375. // 票 09:离开 /cockpit 时要连点位标记一起收(标记挂在 viewer 上,viewer 随后销毁)。
  376. if (markerTimer !== null) {
  377. window.clearTimeout(markerTimer)
  378. markerTimer = null
  379. }
  380. // 票 33:批间合并的落尾定时器一并收掉。
  381. if (pendingApplyTimer !== null) {
  382. window.clearTimeout(pendingApplyTimer)
  383. pendingApplyTimer = null
  384. }
  385. clearPointMarkers()
  386. markerState.value = getPointMarkerState()
  387. if (stopSummaryWatch) {
  388. stopSummaryWatch()
  389. stopSummaryWatch = null
  390. }
  391. if (stopMarkerWatch) {
  392. stopMarkerWatch()
  393. stopMarkerWatch = null
  394. }
  395. if (resizeObserver) {
  396. resizeObserver.disconnect()
  397. resizeObserver = null
  398. }
  399. // 只有离开 /cockpit 才会走到这里。
  400. unmountMap()
  401. })
  402. </script>
  403. <style scoped>
  404. /* 组件内自带的盒模型重置(不依赖全局 `*` 规则)。 */
  405. .map-canvas,
  406. .map-canvas *,
  407. .map-canvas *::before,
  408. .map-canvas *::after {
  409. box-sizing: border-box;
  410. }
  411. .map-canvas {
  412. position: relative;
  413. width: 100%;
  414. height: 100%;
  415. min-height: 0;
  416. overflow: hidden;
  417. border: 1px solid var(--panel-line, rgba(1, 171, 234, 0.35));
  418. background: var(--bg-1, #02091b);
  419. box-shadow: inset 0 1px 0 rgba(1, 171, 234, 0.12), var(--shadow, 0 18px 44px rgba(0, 0, 0, 0.55));
  420. }
  421. .map-canvas:focus { outline: none; }
  422. .map-canvas[data-scene-focused] { border-color: var(--cyan, #01abea); }
  423. /* ---------- 临时影像底图:铺满 ---------- */
  424. .map-canvas__photo {
  425. position: absolute;
  426. inset: 0;
  427. background-position: center center;
  428. background-size: cover;
  429. background-repeat: no-repeat;
  430. filter: saturate(0.85) brightness(0.78);
  431. }
  432. /* ---------- 全息浮层:网格 + 扫描线 + 暗角 ---------- */
  433. .map-holo,
  434. .map-holo > div {
  435. position: absolute;
  436. inset: 0;
  437. pointer-events: none;
  438. }
  439. .map-holo__grid {
  440. opacity: 0.4;
  441. background-image:
  442. linear-gradient(rgba(1, 171, 234, 0.09) 1px, transparent 1px),
  443. linear-gradient(90deg, rgba(1, 171, 234, 0.09) 1px, transparent 1px);
  444. background-size: 58px 58px, 58px 58px;
  445. }
  446. .map-holo__scan {
  447. height: 22%;
  448. top: -22%;
  449. inset: auto 0 auto 0;
  450. background: linear-gradient(180deg, transparent 0%, rgba(1, 171, 234, 0.14) 50%, transparent 100%);
  451. animation: map-scan 7s linear infinite;
  452. }
  453. @keyframes map-scan {
  454. 0% { transform: translateY(0); opacity: 0; }
  455. 12% { opacity: 1; }
  456. 88% { opacity: 1; }
  457. 100% { transform: translateY(560%); opacity: 0; }
  458. }
  459. .map-holo__vignette {
  460. background: linear-gradient(180deg, rgba(1, 5, 20, 0.55) 0%, transparent 26%, transparent 68%, rgba(1, 5, 20, 0.6) 100%);
  461. }
  462. /* ---------- 顶部带:图例在最上,调试形态的标题/图层占位在其下 ---------- */
  463. .map-holo__top {
  464. position: absolute;
  465. z-index: 2;
  466. top: calc(10 * var(--u, 1px));
  467. left: 50%;
  468. transform: translateX(-50%);
  469. display: grid;
  470. gap: calc(6 * var(--u, 1px));
  471. justify-items: center;
  472. max-width: 92%;
  473. text-align: center;
  474. pointer-events: none;
  475. }
  476. /* 图例容器:标签外观由 StatusTag 提供(颜色取自 token,语义与颜色不再硬编码在这里) */
  477. .map-holo__legend {
  478. display: flex;
  479. gap: calc(8 * var(--u, 1px));
  480. justify-content: center;
  481. flex-wrap: wrap;
  482. }
  483. /* 票 14:状态栏行是**按钮**(全局筛选入口)。抹掉按钮默认外观,保留可聚焦/hover/选中反馈。 */
  484. .map-holo__legend-row {
  485. pointer-events: auto;
  486. display: inline-flex;
  487. align-items: center;
  488. gap: calc(3 * var(--u, 1px));
  489. padding: 3px 8px;
  490. border: 1px solid var(--legend-color, var(--neutral));
  491. border-radius: 999px;
  492. background: rgba(2, 9, 27, .86);
  493. color: var(--legend-color, var(--neutral));
  494. font: inherit;
  495. cursor: pointer;
  496. opacity: 0.9;
  497. transition: opacity 0.16s, filter 0.16s, box-shadow 0.16s, background-color 0.16s;
  498. }
  499. /* 批次 1 第三轮:状态行前置图标。弱辉光避免在地图亮部糊掉。 */
  500. .map-holo__legend-icon {
  501. flex: none;
  502. filter: drop-shadow(0 0 4px rgba(2, 9, 27, 0.9));
  503. }
  504. .map-holo__legend-row:hover,
  505. .map-holo__legend-row:focus-visible {
  506. opacity: 1;
  507. filter: brightness(1.2);
  508. outline: 1px solid var(--cyan, #01abea);
  509. outline-offset: 2px;
  510. }
  511. /*
  512. * 选中态(维护者反馈:选中效果差不少)——三层反馈叠出「这一桶在筛选中」的实感:
  513. * ① 本行语义色的低透明底板(与未选中的深底对比,多选时行与行也分得开);
  514. * ② 同色内描边 + 弱外辉光(强调但不刺眼,hover 的 outline 蓝只留给聚焦);
  515. * ③ 计数转本行语义色并加粗(「哪个桶被选中」一眼可辨,计数数值仍不动——只改高亮不改口径)。
  516. * 底色用 color-mix 基于既有语义色派生,不新增 token(tokens.css 仍是唯一色源)。
  517. */
  518. .map-holo__legend-row.is-selected {
  519. opacity: 1;
  520. background: color-mix(in srgb, var(--legend-color, var(--cyan)) 22%, rgba(2, 9, 27, 0.86));
  521. box-shadow:
  522. inset 0 0 0 1px var(--legend-color, var(--cyan)),
  523. 0 0 8px color-mix(in srgb, var(--legend-color, var(--cyan)) 55%, transparent);
  524. filter: drop-shadow(0 0 6px rgba(1, 171, 234, 0.45));
  525. }
  526. /* 选中行的标签文字与形状都转本行语义色:StatusTag 是 scoped 子组件,
  527. * 文字色在 .status-tag__label 上是显式值(非继承),必须 :deep() 命中;
  528. * 形状/边框走 --tag-color(currentcolor),覆盖该变量即可同色。 */
  529. .map-holo__legend-row.is-selected :deep(.status-tag) {
  530. --tag-color: var(--legend-color, var(--cyan));
  531. }
  532. .map-holo__legend-row.is-selected :deep(.status-tag__label) {
  533. color: var(--legend-color, var(--cyan));
  534. }
  535. .map-holo__legend-row.is-selected .map-holo__legend-count {
  536. color: var(--legend-color, var(--cyan));
  537. font-weight: 600;
  538. }
  539. .map-holo__legend-count {
  540. color: var(--text-2, #88b7e8);
  541. font-family: var(--num, monospace);
  542. font-size: var(--fs-caption, 11.5px);
  543. }
  544. /* 票 35 续:生产形态把底部摘要行也收进**右下角状态日志**那一摞,
  545. 中央视野完全空出来(与引擎状态同一条右边线、在其上方)。 */
  546. /* 票 32:生产形态的状态行 —— 再小一点、再淡一点,让位于 3D 场景(状态灯的颜色语义保留)。 */
  547. /* 票 09:标记数与状态灯同一行;左侧加一条分隔线,免得跟摘要文案连成一串。 */
  548. /* 状态灯:默认(加载中/待取数)呼吸的橙点;加载完成转绿、失败/快照失效转红。 */
  549. /* 票 46:模式图层计划的降级说明(「设施数据未就绪」等)。与状态灯同一行、小字弱化。 */
  550. @keyframes map-breathe {
  551. 0%, 100% { opacity: 1; transform: scale(1); }
  552. 50% { opacity: 0.35; transform: scale(0.72); }
  553. }
  554. .map-holo__legend-row :deep(.status-tag) {
  555. padding: 0;
  556. border: 0;
  557. background: transparent;
  558. }
  559. .map-holo__legend-row :deep(.status-tag__shape) { display: none; }
  560. .map-holo__legend-icon { color: var(--legend-color); }
  561. </style>