| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626 |
- <!--
- 中央一张图:常驻地图宿主(票 02)。
- · 实例生命周期归地图服务层(services/mapService.js)——本组件只负责宿主要素与尺寸上报。
- · 常驻语义:一级模式切换、面板收放都不得销毁或重建本组件与地图实例。
- · 当前底图为临时影像(public/cockpit/aerial.webp);正式底图/瓦片见票 21/22,
- 引擎接入 Cesium 时只改地图服务内部(见票 06 调研),本组件与外壳不动。
- -->
- <template>
- <div ref="hostRef" class="map-canvas" :data-map-instance="instanceId">
- <!-- 临时影像底图 -->
- <div class="map-canvas__photo" :style="{ backgroundImage: `url(${photoUrl})` }"></div>
- <div class="map-holo" aria-hidden="true">
- <div class="map-holo__grid"></div>
- <div class="map-holo__scan"></div>
- <div class="map-holo__vignette"></div>
- </div>
- <!--
- 一张图**最上方**:状态栏(票 14;决策 02 的 Q9/Q12/Q18——原只读图例改为
- **全局组合筛选入口**)。八桶多选 OR、计数=全局未筛全集(后端全量、不分页,
- 与监测分布饼图共用同一份,来自 `map-snapshot` 的八桶全局计数);
- 筛选只改选中高亮、不改计数;行**常驻可点**(含计数 0 的行——死胡同根治)。
- 票 46:引导态仍不显示(规格 18:引导态没有图例/计数/详情)。
- -->
- <div class="map-holo__top">
- <div v-if="entered" class="map-holo__legend" data-map-legend data-status-filter>
- <button
- v-for="row in statusRows"
- :key="row.key"
- type="button"
- class="map-holo__legend-row"
- :class="{ 'is-selected': row.selected }"
- :style="{ '--legend-color': `var(--${row.tone})` }"
- :data-legend-key="row.key"
- :data-status-selected="row.selected ? 'yes' : 'no'"
- :data-status-count="row.count === null ? '' : String(row.count)"
- :title="row.title"
- :aria-pressed="row.selected"
- @click="toggleStatusFilter(row.key)"
- >
- <!-- 批次 1 第三轮:状态行前置区分图标(语义色染色)。纯装饰,形状+颜色仍是第一线索。 -->
- <CockpitIcon v-if="row.icon" monochrome :name="row.icon" size="calc(14 * var(--u, 1px))" class="map-holo__legend-icon" />
- <StatusTag :tone="row.tone" :shape="row.shape" :label="row.label" />
- <span class="map-holo__legend-count" data-status-count>:{{ row.count === null ? '—' : row.count }}</span>
- </button>
- </div>
- <!--
- 票 25:中央「一张图」标题与图层占位已并入右下角的地图信息集合面板。
- 此处**只保留状态筛选图例**——它是决策 02 冻结的全局组合筛选入口,必须常驻可点。
- -->
- </div>
- <!--
- 票 25:原先挂在地图四周的标题、图层占位、底图提示、加载状态、降级说明、标记计数、
- 坐标准入浮块与引擎读数,已并入**外壳的状态面板**(`CockpitShell.vue` 的
- `cockpit-shell__status-details`)。它们不再出现在地图上,中央只保留状态筛选图例
- 与 3D 场景;维护者反馈过另起面板会和图例、外壳面板互相占用容器。
- -->
- </div>
- </template>
- <script setup>
- import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
- import {
- clearPointMarkers,
- getPointMarkerState,
- locateMonitoringPoint,
- mountMap,
- onDeviceHover,
- onPipelinePick,
- onPointPick,
- publishSummaryHandle,
- resizeMap,
- syncPointMarkersForPlan,
- // 票 24:设施点位与管线图层。与摘要标记分属不同数据源与不同业务对象,
- // 各自同步,互不写对方的状态。
- syncPipeLayerForPlan,
- unmountMap,
- } from '../services/mapService.js'
- import { MAP_SUMMARY_PHASE } from '../domain/mapSummary.js'
- // 票 46:模式图层计划由外壳算好传入(本组件只消费,不 import 计划函数 —— 避免二次过滤诱惑)。
- import { selectionFromMapObject } from '../domain/detailFlow.js'
- // 批次 3 第五轮:未关联设备点选的镜头联动复用定位链路(坐标准入与 findMapObject 同源)。
- import { isRenderable } from '../domain/coordinate.js'
- import { findMapObject } from '../domain/mapLocate.js'
- // 票 41:监测点选择的统一入口(地图点选与台账卡片共用同一条,见 pointSelection.js)。
- import { selectMonitoringPoint } from '../pointSelection.js'
- // 票 14:八桶词表(唯一一张表)与全局状态筛选(顶部状态栏的计数与选中态来源)。
- import { legendVisuals } from '../domain/pointStatus.js'
- import { storeToRefs } from 'pinia'
- import { useDeviceLayerStore } from '../stores/deviceLayerStore.js'
- import { useFilterStore } from '../stores/filterStore.js'
- import { useMapStore } from '../stores/mapStore.js'
- import { useDetailStore } from '../stores/detailStore.js'
- import StatusTag from './StatusTag.vue'
- import CockpitIcon from './CockpitIcon.vue'
- const emit = defineEmits(['pipeline-pick'])
- const props = defineProps({
- modeLabel: { type: String, default: '' },
- /**
- * 票 32:调试形态。`false`(生产构建默认)时收起「中央一张图」标题、图层占位、实例提示等
- * 开发期文本,只保留**颜色语义**(点位状态图例 + 状态灯)与一行贴底小字。
- * 外壳按配置 `map.ui.debug` 下发,缺省跟随构建模式。
- */
- uiDebug: { type: Boolean, default: true },
- /**
- * 票 46:模式图层计划的输出(`domain/modeLayerPlan.js` 的 `planModeLayers()` 结果)。
- * 本组件**只消费它**:不自己判模式、不自己过滤 objects、不自己算图例。
- * 传入 null(理论不发生)时按空计划处理,保证组件不崩。
- */
- plan: { type: Object, default: null },
- })
- // 走 Vite 的 BASE_URL,保证 qiankun / 相对 base 部署下也能取到 public 资源。
- const base = import.meta.env.BASE_URL || '/'
- const photoUrl = `${base}cockpit/aerial.webp`
- const hostRef = ref(null)
- const instanceId = ref('')
- // ---- 票 08:地图摘要的渐进加载状态(装载器归 mapStore,本组件只画出来)----
- // 「地图先显示框架」在代码里的落点:本组件与地图实例先挂好,装载器初始态是 idle
- // (文案「地图框架已就绪 · 等待分页数据」),随后每取一页才多出点位。
- const mapStore = useMapStore()
- const detailStore = useDetailStore()
- // 票 43:三维 hover / 点选设备都经这个 store(面板高亮与选中态的唯一持有者)。
- const deviceLayerStore = useDeviceLayerStore()
- const summaryText = computed(() => mapStore.view.label)
- const summarySettled = computed(() => mapStore.view.phase === MAP_SUMMARY_PHASE.COMPLETE)
- const summaryBad = computed(() => ['failed', 'expired'].includes(mapStore.view.phase))
- // 票 07:坐标准入总览(后端全量口径 + 已加载口径 + 不上图清单)。判定归 domain/coordinate.js。
- const coordinateOverview = computed(() => mapStore.coordinate)
- // ---- 票 09/14:点位标记与状态栏(标记画在地图上;状态栏是全局筛选入口)----
- // 标记与状态栏的形状/色键同源于 `domain/pointStatus.js` 的唯一一张表——
- // 不是"两边各自小心保持一致",而是结构上只有一份状态→视觉的映射。
- // 票 14:状态栏**计数改为全局未筛口径**(`mapStore.statusDistribution`,与饼图共用),
- // 不再是"当前已上图标记"的计数——上图数由底部「点位标记 N」单独说明。
- const markerState = ref(getPointMarkerState())
- const markerStatusText = computed(() => {
- const byStatus = markerState.value.byStatus
- const keys = Object.keys(byStatus).sort()
- if (!keys.length) return '等待分页数据'
- return keys.map((key) => `${key} ${byStatus[key]}`).join(' · ')
- })
- // ---- 票 14:状态栏 = 全局组合筛选入口(八桶多选 OR、全局未筛计数、选中高亮、行常驻可点)----
- const filterStore = useFilterStore()
- const { pointStatus: selectedStatuses } = storeToRefs(filterStore)
- const statusRows = computed(() => {
- const distribution = mapStore.statusDistribution
- return legendVisuals().map((visual) => {
- const selected = selectedStatuses.value.includes(visual.key)
- // 分布未取到(后端还没发布八桶计数)→ 计数 null,界面显示「—」,**不补 0**
- // 「后端没给」与「确实是 0」是两件事(票 05 的纪律)。
- const count = distribution.available ? distribution.counts[visual.key] || 0 : null
- return {
- key: visual.key,
- tone: visual.tone,
- shape: visual.shape,
- label: visual.label,
- // 批次 1 第三轮:状态行区分图标(legendVisuals 补的纯视觉字段)。
- icon: visual.icon || '',
- count,
- selected,
- title:
- count === null
- ? `按「${visual.label}」筛选(可多选);全局状态分布未取到,计数暂不可见`
- : selected
- ? `取消「${visual.label}」筛选(计数 ${count} 为全局未筛口径,不随筛选变化)`
- : `按「${visual.label}」筛选(可多选;计数 ${count} 为全局未筛口径)`,
- }
- })
- })
- /** 点状态桶 → 写全局筛选(地图与面板的跟随由外壳的 watcher 收口,本组件不自己发请求)。 */
- function toggleStatusFilter(key) {
- filterStore.togglePointStatus(key)
- }
- // ---- 票 46:模式图层计划(唯一裁决点)----
- // 计划由**外壳**算好传入(外壳握有设备行/异常设备/选择态全部输入),
- // 本组件只消费:markers 喂服务层、图例/降级说明直接渲染。不二次过滤。
- const EMPTY_PLAN = {
- mode: 'overview',
- entered: false,
- markers: [],
- blocked: [],
- legend: [],
- byStatus: {},
- locateTargets: [],
- degradedNotes: [],
- associatedDevices: [],
- selection: { visible: false, action: 'preserve' },
- }
- const modePlan = computed(() => props.plan || EMPTY_PLAN)
- /** 引导态(计划层以 `entered` 表达):图例/计数浮标/降级面板都门控掉。 */
- const entered = computed(() => modePlan.value.entered === true)
- let resizeObserver = null
- let stopSummaryWatch = null
- let stopMarkerWatch = null
- let stopPointPick = null
- // 票 43:三维 hover 订阅(与点选一起在卸载时退订)。
- let stopDeviceHover = null
- let stopPipelinePick = null
- // 票 23:管线/构件命中由地图服务按设备/监测点之后的优先级派发。
- /**
- * 把装载状态写进宿主 `dataset`(走查据此断言,不靠读中文字符串):
- * `data-map-summary` = idle / loading / complete / empty / failed / expired
- * `data-map-summary-loaded` = 已上图对象数
- * `data-map-summary-snapshot` = 本次加载锁定的快照标记
- * `data-map-summary-total` = 后端给的总数
- *
- * 票 07 追加坐标准入的四个数(同样给走查读,不读中文):
- * `data-map-renderable` = 已加载范围内可上图数
- * `data-map-blocked` = 已加载范围内不上图数
- * `data-map-blocked-backend`= 后端全量口径的不上图数(= 非法 + 未校验)
- * `data-map-coordinate-crs` = 后端汇总里的坐标系
- */
- function paintSummary(host, state) {
- if (!host || !host.dataset) return
- host.dataset.mapSummary = state.phase
- host.dataset.mapSummaryLoaded = String(state.loaded)
- host.dataset.mapSummaryTotal = String(state.total)
- host.dataset.mapSummarySnapshot = state.snapshot || ''
- host.dataset.mapSummaryPage = String(state.page)
- const overview = coordinateOverview.value
- host.dataset.mapRenderable = String(overview.loaded.renderable)
- host.dataset.mapBlocked = String(overview.loaded.blocked)
- const backend = overview.backend
- host.dataset.mapBlockedBackend = backend.available
- ? String((backend.invalid === null ? 0 : backend.invalid) + (backend.unverified === null ? 0 : backend.unverified))
- : ''
- host.dataset.mapCoordinateCrs = backend.crs || ''
- }
- /**
- * 票 09:把当前已加载的对象同步成地图标记,并把结果(含图例)取回来给组件渲染。
- *
- * 为什么要等:装载器几乎总是**比引擎先就绪**(引擎要动态拉 Cesium 分包、再拉地形与影像,
- * 实测首屏 dev 冷启动可以到十几秒),这时 `syncPointMarkersForSummary()` 只更新计划、返回 false。
- * 若不补画,第一批点位就永远上不了图 —— 而票 08 的渐进加载恰恰是从第一批开始的。
- *
- * 退出的判据是**引擎的终态**(`data-map-engine` 落到 ready/failed/disabled/unavailable),
- * 不是"试够多少次就算了":第一版写的是 40 × 250ms = 10 秒封顶,实测引擎冷启动超过 10 秒时
- * 重试已经放弃,地图上一个标记都没有(图例却有计数)—— 只有用户去点一下「加载下一页」
- * 才会因为对象变化重新触发。那个 bug 是视觉探针(截图里「点位标记 0」)抓到的,
- * 走查当时反而没抓到,因为它一路在点分页按钮。
- *
- * `markerTimer` 保证**同时只有一个**重试在排队:翻页会连触发 watch,若不收口,
- * 几页就会叠出几个定时器(各自重画一遍,白烧 CPU 且最后谁赢不确定)。
- */
- let markerTimer = null
- /** 引擎是否已落到终态;没落到就还得等它(booting/idle 都要等)。 */
- function engineSettled() {
- const host = hostRef.value
- const state = host && host.dataset ? host.dataset.mapEngine : ''
- return ['ready', 'failed', 'disabled', 'unavailable'].includes(state)
- }
- function applyMarkers() {
- // 票 46:只把**计划**交给服务层 —— 模式过滤已在 `domain/modeLayerPlan.js` 收口,
- // 服务层与引擎都不做第二次过滤。图例与标记读同一份计划的 `byStatus`。
- const painted = syncPointMarkersForPlan({
- markers: modePlan.value.markers,
- byStatus: modePlan.value.byStatus,
- })
- markerState.value = getPointMarkerState()
- // 票 24:设施点位与管线跟着**同一份模式计划**走 —— 图层显隐因此与监测点标记
- // 必然一致(计划里 hidden 就是空数组 → 自动撤下),不会出现两套判定打架。
- syncPipeLayerForPlan(modePlan.value)
- // 票 46:降级说明写进宿主 dataset(走查断言「数据未就绪 ≠ 空数据」的断言面)。
- const host = hostRef.value
- if (host && host.dataset) {
- host.dataset.mapDegraded = modePlan.value.degradedNotes.map((note) => note.key).join(',') || ''
- }
- // 画上了就收工;引擎已定局(失败/关闭/没有宿主)也不用再等 —— 图例仍要显示。
- if (painted || engineSettled()) return
- if (markerTimer !== null) return
- markerTimer = window.setTimeout(() => {
- markerTimer = null
- applyMarkers()
- }, 250)
- }
- /**
- * 票 33:applyMarkers 的**批间合并**(票 26 诊断的另一半:8 批分页每批都撞一次 watcher)。
- *
- * 30s freshness tick 的 `map-snapshot` 分批到达(批间 120ms),批批新 objects 引用都会
- * 触发一次 marker watch。这里做**领先一次 + 落尾一次**的合并:批 1 立即画(保持首响),
- * 合并窗口内的后续批次不再各自重同步,只把一次落尾补画排到最后一批之后——
- * 一轮 refresh 从 8 次全管线重同步降到 1–2 次(窗口内 diff 已让重复同步变便宜,这里让它更少)。
- * 120ms 与 `mapStore` 的 auto-batch 间隔同值:窗口恰好盖住一整轮分页。
- */
- const APPLY_MERGE_MS = 120
- let lastApplyAt = 0
- let pendingApplyTimer = null
- function applyMarkersMerged() {
- const elapsed = Date.now() - lastApplyAt
- if (elapsed < APPLY_MERGE_MS) {
- // 窗口内:只排一次落尾(拿到的是最后一批的状态,中间批次画了也会被覆盖)。
- if (pendingApplyTimer === null) {
- pendingApplyTimer = window.setTimeout(() => {
- pendingApplyTimer = null
- applyMarkersMerged()
- }, APPLY_MERGE_MS - elapsed)
- }
- return
- }
- lastApplyAt = Date.now()
- applyMarkers()
- }
- onMounted(() => {
- // 幂等挂载:模式切换或面板收放都不会再次走到这里(本组件常驻)。
- const instance = mountMap(hostRef.value)
- instanceId.value = instance.id
- // 宿主尺寸变化上报给地图服务(接入 Cesium 后即 viewer.resize())。
- if (typeof ResizeObserver !== 'undefined' && hostRef.value) {
- resizeObserver = new ResizeObserver(() => resizeMap())
- resizeObserver.observe(hostRef.value)
- }
- // 装载器状态 → 宿主 dataset(常驻组件,停止监听只在离开 /cockpit 时发生)。
- mapStore.setup({})
- paintSummary(hostRef.value, mapStore.summary)
- stopSummaryWatch = watch(() => mapStore.summary, (state) => paintSummary(hostRef.value, state), { deep: false })
- // 票 09/46:摘要翻页、计划变化(模式/引导态/异常设备都在计划里体现)→ 重画一次
- // (服务层只消费计划,重画是唯一路径;一个 watch 一份退订,不叠两份)。
- // 票 33:重画走**批间合并**入口,不再批批全量同步。
- stopMarkerWatch = watch(
- () => [mapStore.summary.objects, modePlan.value.markers, modePlan.value.byStatus, modePlan.value.pipelines, modePlan.value.facilities],
- () => applyMarkersMerged(),
- { deep: false },
- )
- // DEV 把手:人工演示与走查用 `window.__cockpitMap.mapSummary` 一页页推进(票 08)。
- publishSummaryHandle({
- advance: () => mapStore.advance(),
- refresh: () => mapStore.refresh(),
- getState: () => mapStore.summary,
- })
- // 票 10:点选地图上的点位标记 → 打开两级详情。
- // 分工:地图服务只回答「拾到了谁」(拾不到、或拾到空白一律 null),本组件决定「打开什么」。
- // 「关联设备不重复上图」在这条路径上是结构性的:它只把 kind/code 变成选择,
- // 从不往地图里加对象(标记计划的唯一来源仍是 mapStore.summary.objects)。
- stopPointPick = onPointPick((picked) => {
- // 票 43:三维点选**设备点** → 选中该设备并进设备级(复用同一入口,含镜头联动)。
- if (picked.kind === 'monitoring-device') {
- deviceLayerStore.selectDevice(picked.code)
- return
- }
- // 票 41:监测点走**统一选择入口**(与台账卡片同一条)——它负责点位级定位 + 打开两级详情。
- if (picked.kind === 'monitoring-point') selectMonitoringPoint(picked.code)
- else {
- const selection = selectionFromMapObject(picked)
- if (selection) {
- detailStore.openSelection(selection)
- // 批次 3 第五轮修复:点选**未关联设备**也要像监测点一样飞镜头——
- // 走查发现「只出面板不移动」。复用 `locateMonitoringPoint` 同一条定位链路
- // (它只要求对象坐标准入通过,不关心 kind);坐标来自 map-snapshot(未关联
- // 设备自带经纬度),与标记上图同一份数据,不借用、不伪造。
- const mapStore = useMapStore()
- const object = findMapObject(mapStore.summary.objects, picked.code)
- if (object && isRenderable(object)) void locateMonitoringPoint(object)
- }
- }
- })
- // 票 43:三维 hover 设备点 → 面板对应卡片高亮(服务层已做节流,这里只转发)。
- stopDeviceHover = onDeviceHover((hit) => {
- deviceLayerStore.hoverDevice(hit ? hit.code : '')
- })
- stopPipelinePick = onPipelinePick((hit) => emit('pipeline-pick', hit))
- })
- onBeforeUnmount(() => {
- publishSummaryHandle(null)
- // 票 10:先退订点选,再拆地图(否则服务层还会往已卸载的 store 上写选择)。
- if (stopPointPick) {
- stopPointPick()
- stopPointPick = null
- }
- // 票 43:hover 订阅同样先退订(否则服务层还会往已卸载的 store 上写 hover)。
- if (stopDeviceHover) {
- stopDeviceHover()
- stopDeviceHover = null
- }
- if (stopPipelinePick) {
- stopPipelinePick()
- stopPipelinePick = null
- }
- // 票 09:离开 /cockpit 时要连点位标记一起收(标记挂在 viewer 上,viewer 随后销毁)。
- if (markerTimer !== null) {
- window.clearTimeout(markerTimer)
- markerTimer = null
- }
- // 票 33:批间合并的落尾定时器一并收掉。
- if (pendingApplyTimer !== null) {
- window.clearTimeout(pendingApplyTimer)
- pendingApplyTimer = null
- }
- clearPointMarkers()
- markerState.value = getPointMarkerState()
- if (stopSummaryWatch) {
- stopSummaryWatch()
- stopSummaryWatch = null
- }
- if (stopMarkerWatch) {
- stopMarkerWatch()
- stopMarkerWatch = null
- }
- if (resizeObserver) {
- resizeObserver.disconnect()
- resizeObserver = null
- }
- // 只有离开 /cockpit 才会走到这里。
- unmountMap()
- })
- </script>
- <style scoped>
- /* 组件内自带的盒模型重置(不依赖全局 `*` 规则)。 */
- .map-canvas,
- .map-canvas *,
- .map-canvas *::before,
- .map-canvas *::after {
- box-sizing: border-box;
- }
- .map-canvas {
- position: relative;
- width: 100%;
- height: 100%;
- min-height: 0;
- overflow: hidden;
- border: 1px solid var(--panel-line, rgba(1, 171, 234, 0.35));
- background: var(--bg-1, #02091b);
- box-shadow: inset 0 1px 0 rgba(1, 171, 234, 0.12), var(--shadow, 0 18px 44px rgba(0, 0, 0, 0.55));
- }
- .map-canvas:focus { outline: none; }
- .map-canvas[data-scene-focused] { border-color: var(--cyan, #01abea); }
- /* ---------- 临时影像底图:铺满 ---------- */
- .map-canvas__photo {
- position: absolute;
- inset: 0;
- background-position: center center;
- background-size: cover;
- background-repeat: no-repeat;
- filter: saturate(0.85) brightness(0.78);
- }
- /* ---------- 全息浮层:网格 + 扫描线 + 暗角 ---------- */
- .map-holo,
- .map-holo > div {
- position: absolute;
- inset: 0;
- pointer-events: none;
- }
- .map-holo__grid {
- opacity: 0.4;
- background-image:
- linear-gradient(rgba(1, 171, 234, 0.09) 1px, transparent 1px),
- linear-gradient(90deg, rgba(1, 171, 234, 0.09) 1px, transparent 1px);
- background-size: 58px 58px, 58px 58px;
- }
- .map-holo__scan {
- height: 22%;
- top: -22%;
- inset: auto 0 auto 0;
- background: linear-gradient(180deg, transparent 0%, rgba(1, 171, 234, 0.14) 50%, transparent 100%);
- animation: map-scan 7s linear infinite;
- }
- @keyframes map-scan {
- 0% { transform: translateY(0); opacity: 0; }
- 12% { opacity: 1; }
- 88% { opacity: 1; }
- 100% { transform: translateY(560%); opacity: 0; }
- }
- .map-holo__vignette {
- background: linear-gradient(180deg, rgba(1, 5, 20, 0.55) 0%, transparent 26%, transparent 68%, rgba(1, 5, 20, 0.6) 100%);
- }
- /* ---------- 顶部带:图例在最上,调试形态的标题/图层占位在其下 ---------- */
- .map-holo__top {
- position: absolute;
- z-index: 2;
- top: calc(10 * var(--u, 1px));
- left: 50%;
- transform: translateX(-50%);
- display: grid;
- gap: calc(6 * var(--u, 1px));
- justify-items: center;
- max-width: 92%;
- text-align: center;
- pointer-events: none;
- }
- /* 图例容器:标签外观由 StatusTag 提供(颜色取自 token,语义与颜色不再硬编码在这里) */
- .map-holo__legend {
- display: flex;
- gap: calc(8 * var(--u, 1px));
- justify-content: center;
- flex-wrap: wrap;
- }
- /* 票 14:状态栏行是**按钮**(全局筛选入口)。抹掉按钮默认外观,保留可聚焦/hover/选中反馈。 */
- .map-holo__legend-row {
- pointer-events: auto;
- display: inline-flex;
- align-items: center;
- gap: calc(3 * var(--u, 1px));
- padding: 3px 8px;
- border: 1px solid var(--legend-color, var(--neutral));
- border-radius: 999px;
- background: rgba(2, 9, 27, .86);
- color: var(--legend-color, var(--neutral));
- font: inherit;
- cursor: pointer;
- opacity: 0.9;
- transition: opacity 0.16s, filter 0.16s, box-shadow 0.16s, background-color 0.16s;
- }
- /* 批次 1 第三轮:状态行前置图标。弱辉光避免在地图亮部糊掉。 */
- .map-holo__legend-icon {
- flex: none;
- filter: drop-shadow(0 0 4px rgba(2, 9, 27, 0.9));
- }
- .map-holo__legend-row:hover,
- .map-holo__legend-row:focus-visible {
- opacity: 1;
- filter: brightness(1.2);
- outline: 1px solid var(--cyan, #01abea);
- outline-offset: 2px;
- }
- /*
- * 选中态(维护者反馈:选中效果差不少)——三层反馈叠出「这一桶在筛选中」的实感:
- * ① 本行语义色的低透明底板(与未选中的深底对比,多选时行与行也分得开);
- * ② 同色内描边 + 弱外辉光(强调但不刺眼,hover 的 outline 蓝只留给聚焦);
- * ③ 计数转本行语义色并加粗(「哪个桶被选中」一眼可辨,计数数值仍不动——只改高亮不改口径)。
- * 底色用 color-mix 基于既有语义色派生,不新增 token(tokens.css 仍是唯一色源)。
- */
- .map-holo__legend-row.is-selected {
- opacity: 1;
- background: color-mix(in srgb, var(--legend-color, var(--cyan)) 22%, rgba(2, 9, 27, 0.86));
- box-shadow:
- inset 0 0 0 1px var(--legend-color, var(--cyan)),
- 0 0 8px color-mix(in srgb, var(--legend-color, var(--cyan)) 55%, transparent);
- filter: drop-shadow(0 0 6px rgba(1, 171, 234, 0.45));
- }
- /* 选中行的标签文字与形状都转本行语义色:StatusTag 是 scoped 子组件,
- * 文字色在 .status-tag__label 上是显式值(非继承),必须 :deep() 命中;
- * 形状/边框走 --tag-color(currentcolor),覆盖该变量即可同色。 */
- .map-holo__legend-row.is-selected :deep(.status-tag) {
- --tag-color: var(--legend-color, var(--cyan));
- }
- .map-holo__legend-row.is-selected :deep(.status-tag__label) {
- color: var(--legend-color, var(--cyan));
- }
- .map-holo__legend-row.is-selected .map-holo__legend-count {
- color: var(--legend-color, var(--cyan));
- font-weight: 600;
- }
- .map-holo__legend-count {
- color: var(--text-2, #88b7e8);
- font-family: var(--num, monospace);
- font-size: var(--fs-caption, 11.5px);
- }
- /* 票 35 续:生产形态把底部摘要行也收进**右下角状态日志**那一摞,
- 中央视野完全空出来(与引擎状态同一条右边线、在其上方)。 */
- /* 票 32:生产形态的状态行 —— 再小一点、再淡一点,让位于 3D 场景(状态灯的颜色语义保留)。 */
- /* 票 09:标记数与状态灯同一行;左侧加一条分隔线,免得跟摘要文案连成一串。 */
- /* 状态灯:默认(加载中/待取数)呼吸的橙点;加载完成转绿、失败/快照失效转红。 */
- /* 票 46:模式图层计划的降级说明(「设施数据未就绪」等)。与状态灯同一行、小字弱化。 */
- @keyframes map-breathe {
- 0%, 100% { opacity: 1; transform: scale(1); }
- 50% { opacity: 0.35; transform: scale(0.72); }
- }
- .map-holo__legend-row :deep(.status-tag) {
- padding: 0;
- border: 0;
- background: transparent;
- }
- .map-holo__legend-row :deep(.status-tag__shape) { display: none; }
- .map-holo__legend-icon { color: var(--legend-color); }
- </style>
|