29 KiB
Earth 前端结构
本文件描述当前 Earth 大屏前端的真实结构,重点是帮助后续继续改 HUD、图层、媒体面板、真实地形、BGP 可视化时,不再重复踩结构和状态同步上的坑。
相关规则建议一起参考:
当前目标
Earth 前端不是普通管理页,它是独立的大屏展示前端。当前产品目标是:
- 维持地球视图的空间感和可读性
- 让 HUD、图层、媒体面板、BGP、卫星、海缆等保持统一交互
- 把加载中、已启用、已隐藏、锁定中这类状态做清楚
当前入口
React 路由入口:
当前做法很简单:
- React 页面只负责提供一个全屏
iframe - 真正的 Earth 应用运行在:
所以 Earth 前端本质上是 public/earth 下的一套独立静态应用。
当前文件分层
1. 页面入口与结构
职责:
- HUD 基础 DOM
- 图层面板
- 媒体面板
- 工具栏
- 设置弹窗
- 兼容旧元素 id
2. 主运行时
职责:
- 地球初始化
- Three.js 场景组装
- 数据加载与刷新
- 各图层集成
- Earth 级别状态同步
3. 地球控制层
职责:
- 工具栏交互
- 图层面板交互
- 旋转/缩放/布局
- HUD 面板拖拽
- 图层开关状态机
- Earth 设置读取、持久化与重置
这份文件是 Earth 前端当前最核心的 UI 控制入口。
4. UI 与状态消息
职责:
- loading 面板
- status message
- tooltip / error / 清理逻辑
当前 status message 有两类短提示:
- 普通业务提示:通过
showStatusMessage()入队显示。 - 手势提示:通过
showGestureStatusMessage()直接短暂显示,用于缩放视角时的缩放 N%。
手势提示不会抢占 loading 状态。对应样式是 hud.css 中的 .earth-status-message.gesture。
5. 动作捕捉控制适配层
职责:
- 作为 Motion Provider manager,统一接入
browser_camera与motion_agent。 - 默认使用浏览器
getUserMedia+ 本地 MediaPipe 识别;高级模式可连接本地 Motion Capture Agent WebSocket。 - 处理浏览器摄像头权限/安全上下文错误,以及 Agent 断线重连和
status/heartbeat。 - 过滤低置信度和过快重复的手势事件。
- 将
rotate_left、rotate_right、rotate_up、rotate_down、zoom_in、zoom_out、focus_prev、focus_next、layer_prev、layer_next、confirm映射到main.js暴露的动作入口。 - 解析
skeleton调试事件并派发earth:motion-debug-frame。
动作捕捉识别可以在浏览器本地执行,也可以在本地 Agent 中执行,但两者都不会把实时视频帧发给 SaaS 云端。main.js 暴露旋转、缩放、目标切换、图层切换和确认入口,并通过 window.__planetEarth.motion 提供调试入口。默认只有 URL 参数 ?motion=1、本地存储 planet-earth-motion-control-enabled=true,或 Earth 设置中的“动捕调试模式”打开时才启动当前 provider。
动捕调试面板由 motion-debug-panel.js 负责。它监听 earth:motion-debug-frame,用 canvas 绘制归一化骨架点和连线;Browser Camera provider 会额外通过 earth:motion-debug-video-source 提供本机 <video> 作为调试预览底图,shared.motionDebugSkeletonOnly 可切换为只显示骨骼。停止匹配动作 通过 earth:motion-recognition-pause 暂停 gesture 执行,但继续显示视频和骨架。未匹配动作为红色,匹配后变绿并显示动作名。设置项持久化在 planet.earth.settings.v2 的 shared.motionDebugEnabled、shared.motionProvider 与 shared.motionDebugSkeletonOnly,switch 和输入源控件都预留 data-gatekeeper-permission="earth.motion_debug"。
Browser Camera provider 的手势识别管线在 motion-browser-provider.js 的 recognizeGesture(),按以下顺序匹配,前者命中即返回:
getZoomTrend(趋势 zoom):基于上一帧到当前帧两腕 x 间距的变化量(Math.abs(rightWrist.x - leftWrist.x)的 delta)。两腕都越过噪声地板(ZOOM_TREND_MIN_WRIST_DELTA = 0.010)且高度差不超过ZOOM_TREND_HEIGHT_TOLERANCE,spread 增加 →zoom_in,spread 减少 →zoom_out。趋势优先级最高,避免中间帧被 layer/focus/rotate 抢先误判。getZoomHoldPose(姿态 zoom):动作停止后,只要两腕仍保持在胸口及以上、且 span >ZOOM_HOLD_SPREAD_FACTOR × shoulderWidth(默认 1.30)就持续派发zoom_in;两肘明显外展且 span <ZOOM_HOLD_CLOSE_FACTOR × shoulderWidth(默认 0.85)就持续派发zoom_out。- layer / focus:左手抬起 + 上下移动派发
layer_prev/next;头部左右倾斜派发focus_prev/next。 getRightArmPattern(单臂 rotate):仅当!isZoomCandidatePose(...) && isLeftArmAtRest(...)同时成立时才考虑。isLeftArmAtRest要求左腕明显垂在肩下 13% 以下且左肘/左腕都不外伸,把「张臂中间帧」「单手举起」「左手扶在胸前」等所有模糊状态都判为非静止 —— 单臂 rotate 严格要求另一只手处于静止。
两个关键设计:
- mirror-safe:所有 zoom 检测都基于
Math.abs(rightWrist.x - leftWrist.x),不依赖单侧 x 方向。无论摄像头是否做镜像翻转(浏览器默认不翻转,getUserMedia返回的就是原始帧),张臂始终 → zoom_in,合手始终 → zoom_out。早期基于「左腕往左 + 右腕往右」的判定会在非镜像视图里把方向判反。 - 连续 vs 离散:rotate / layer / focus / confirm 都过
applyPoseLatch,同一手势只发一次,必须先回到中性位才能再发(挥一下转一格)。zoom 故意 bypass latch,每帧匹配都返回 → 下游GESTURE_POLICIES.zoom_in/out.cooldownMs = 120节流到 ~8 次/秒,张臂保持就一直放大直到姿势变化。这是 zoom 跟其它手势在交互语义上的本质区别,不要复用 latch 给 zoom。
presentation-controller.js 是新的 Presentation 层。第一阶段只接入 Motion:motion-cruise-adapter.js 通过 persistent presentation 复用巡航固定卡片位置和 connector,但不会让鼠标移动触发自动隐藏;connector 每帧重算 source/target anchor,让卡片拖动、地球旋转和目标移动时端点继续跟随。BGP/News 仍保持原有 CruiseSequencer 自动轮播路径,避免改变既有巡航体验。
6. 地球与地形
职责:
- 地球球体、云层、大气
- 真实地形 mesh
- terrain tile 拉取、解码、位移、着色
7. 图层模块
- satellites.js
- cables.js
- bgp.js
- bgp-cruise-adapter.js
- vessels.js
- news.js
- tv.js
- layer-startup-tasks.js
- cruise-sequencer.js
- callout-connector.js
职责:
- 各自的数据层
- 开关行为
- 面板内容
- hover/lock/selection 语义
tv.js 管理 media-panel 里的直播 / 态势新闻 tab。toolbar 打开或切换 TV/新闻时,会通过 earth:tv-visibility-change 和 earth:tv-tab-change 回写 Earth 设置:面板可见性仍按 desktop/mobile viewport 存在 views.<scope>.panelVisibility.media-panel,当前 tab 存在 shared.mediaPanelActiveTab,因此刷新页面后能恢复用户上次打开的直播或新闻状态。closeTransientMobileOverlays() 这类临时收起会带 persist:false,不会覆盖用户偏好。
其中 Earth 启动加载链现在也拆成了两层:
controls.js- 提供图层注册表与启动元信息
layer-startup-tasks.js- 提供图层启动任务注册表
- 通过
registerLayerStartupTask(id, taskFactory)扩展启动任务
main.js- 只负责读取排序后的启动图层,再按映射执行队列
其中巡航模式现在已经拆成两层:
cruise-sequencer.js- 负责目标队列顺序、停留时长、切换节奏、打断与恢复
callout-connector.js- 负责卡片连线 SVG、路径计算与绘制动画
bgp-cruise-adapter.js- 负责 BGP 巡航展示适配:目标排序、卡片落点、连线路径、focus/overlay/info-card 时序
当前 BGP 巡航只是这套能力的一个调用方,不应再把“按队列巡航”和“BGP 事件展示”混写在同一个状态机里。
新闻巡航摘要计划见:
当前样式分层
Earth 的 CSS 不是一份大样式表,而是分层管理:
- base.css
- hud.css
- toolbar.css
- layer-panel.css
- info-panel.css
- legend.css
- earth-stats.css
- coordinates-display.css
- tv-panel.css
当前建议:
- 通用 HUD 壳层写进
hud.css - 单一面板特性写进各自子文件
- 不要把业务状态样式再散回
index.html
当前图层开关状态语义
Earth 图层按钮现在不应再只有“开/关”两态,而应支持:
inactiveactiveloading
当前入口在:
关键函数:
updateLayerButtonState(button, isActive)setLayerButtonState(button, options)
setLayerButtonState 负责:
loading样式aria-busy- 按钮禁用
- tooltip 更新
- 绑定状态文本更新
- 可选同步
active
因此后续如果别的图层也需要异步启用,应该直接走这套状态机,而不是再手写一套临时 loading class。
另外,Earth 图层控制现在已经收成“注册表驱动”:
- 图层元数据
idiconlabelmetabuttonIdpersiststartupPrioritystartupModestartupLabelstartupMessage
- 图层行为
getVisible()setVisible(next, options)
当前入口仍在 controls.js。
这意味着后续新增图层时,优先应补一条图层注册定义,而不是同时去改:
- 图层面板 HTML
- 持久化快照
- 初始化恢复
- click 绑定
这四处现在都应该由注册表派生。
其中:
startupPriority- 描述图层参与启动加载时的顺序
startupModevisible- 仅当前图层处于启用/可见状态时,才加入启动加载队列
preload- 即使当前图层未显示,也会参与启动预加载
当前 main.js 会通过注册表读取排序后的启动图层列表,再动态拼装启动加载队列,而不是手写一串固定步骤。像 BGP 这类需要尽早准备数据、但不一定默认显示的图层,应该优先走 startupMode: "preload",而不是在启动流程里写隐式特判。
此外,启动阶段给用户看的提示文案也应尽量从注册表派生:
startupLabel- 用于描述当前启动任务的业务名称
startupMessage- 用于描述启动中的提示文案
- 可以是字符串
- 也可以是对象,用于像海缆这种“准备阶段 / 主加载阶段”两段式文案
这样后续新增会参与启动加载的图层时,顺序、模式和提示文案都在同一处定义,不需要再去 main.js 里补第二套常量。
船只图层与图例
AIS 船只图层入口:
船只图层当前负责:
- 请求
/api/v1/vessels/snapshot获取当前视口初始快照;请求必须携带bbox、zoom,并传入受控limit - 通过
/ws的vesselschannel 订阅后续增量;订阅 payload 同样必须携带当前视口bbox、zoom、limit - 将聚合后的 AIS GeoJSON 转为地球局部坐标 marker 数据;后端默认
limit=1000,最大limit=5000 - 通过
createInteractableLayer()注册 Interactable 图标层 - 用按航向分桶的
THREE.Points批量渲染普通船只 marker - 按船型映射颜色;
vessels.js会用vessel_type_name和 AISvessel_type数字共同归一化船型 - 根据航行/停泊状态绘制三角形或圆点纹理
- 用单点
THREE.Pointsoverlay 承载 hover / locked glow - 支持 hover、lock、轨迹加载和视觉聚焦
船只图层不再是“每艘船一个 THREE.Sprite”。原始 Sprite 方案在拖动地球时会把透明对象排序、draw call 和对象级 raycast 成本全部放到主交互路径上;即使 BarentsWatch 免费 AIS 当前只覆盖挪威周边,也会让地球拖动明显不跟手。
当前设计把普通船只拆成少量批次:
- moving / anchored 分开。
- moving 船只按
VESSEL_COURSE_BINS做航向分桶。 - 每个批次是一组
THREE.PointsMaterial,位置和颜色写入BufferGeometryattribute。 - 普通态不带 glow;hover / locked 时才在相同点位叠加带 glow 的单点 overlay。
方向标准以 AIS course / cog 为准:从正北开始顺时针。普通态和交互态都通过同一套 canvas 旋转规则生成纹理,避免 hover 后箭头方向和原 marker 不一致。
船型展示也必须复用同一套归一化结果。buildVesselMarkerData() 会把后端的 vessel_type_name 和 AIS 数字类型码归一化为 type,用于 marker 颜色;同时生成 vessel_type_display,供详情卡、hover 简述和搜索结果显示。不要让详情卡直接只读原始 vessel_type_name,否则会出现 marker 已按 Cargo/Tanker 等颜色显示、卡片仍写 Other 的不一致。
AISStream 的 PositionReport 常带实时位置和 MetaData.ShipName,但船型通常来自低频 ShipStaticData.Type。后端会把 MetaData.ShipName 补进船名,并将类型码映射为 Cargo / Tanker / Passenger / Fishing / Military;仍缺失的船型需要等待静态 AIS 消息或后续船舶资料 enrichment,不能在前端凭颜色之外的信息臆造细分类。
旧 /api/v1/visualization/geo/vessels 已下线并返回 410 Gone。前端打开船只图层时应先按当前视口拉一次 /api/v1/vessels/snapshot,再用 WebSocket 接收同一视口内的 upsert 增量;地图拖动或缩放后应重新拉取 snapshot 并重发 vessels 订阅。后端不再把 legacy vessel_position / vessel_static 合并进船只快照,前端也不应依赖旧表里的 BarentsWatch-only 兜底数据。
新的图层接口族是 /api/v1/layers/*,用于把地图渲染数据和聚合面板统计分开。地图层请求必须带 bbox、zoom 和受控 limit,响应会返回 visible_count、returned_count 和 diagnostics,其中 degraded/truncated/limit_clamped 用于前端提示降级。右侧聚合统计不要从图层响应累加,应读取 /api/v1/data-products 或 /api/v1/data-products/{product_id}/status,因为这些统计保持全量/全局口径,不随当前视口变化。
船只 hover / click 也不再对渲染对象做 raycaster.intersectObjects()。main.js 只负责传入当前 Earth、camera、pointer 和命中半径,实际命中计算由 interactable.js 的图标层接口完成:
- 拖动地球或惯性旋转时跳过 hover picking。
- 对 hover picking 做轻量节流。
- 只保留正面船只作为候选。
- 将候选船只投影到屏幕坐标。
- 用
VESSEL_POINTER_RADIUS_PX做像素距离命中,并取最近船只。
这样 picking 位置和用户看到的屏幕 marker 对齐,也避免 Points 自带 raycaster 在固定屏幕尺寸图标上的命中半径错位。
图例系统已经注册 vessels 模式:
getVesselLegendItems() 返回带 shape 的图例项:
shape: "vessel":三角形,表示航行船只。shape: "dot":圆点,表示停泊或低速状态。
图例项颜色来自 VESSEL_CONFIG.colors,不要在 legend.css 里重新定义业务颜色。新增船型时,应优先改 vessels.js 和 constants.js 的船型映射,再同步图例项。
interactable.js 是后续地表图标类图层的共用入口。它当前已经承载船只、BGP 事件、BGP 观测站和算力中心图层的批量 Points、texture cache、hover / locked overlay、默认 glow、状态增量更新和屏幕空间 picking;BGP 事件的向外扩散圈、BGP 观测站的 halo / 覆盖扇形仍由 bgp.js 保留业务动画,但图标本体和 pointer 命中已经接入通用层。新增小型、中心对齐、可以参与深度测试的图标类元素时,应优先复用这个接口,而不是再次复制船只渲染逻辑。
登陆点是当前明确保留的例外:它曾接入 Interactable,但 pin 类 SVG 在地球边缘会被 THREE.Points 的深度测试裁切成碎片;关闭 depthTest 又会破坏背面遮挡语义。因此登陆点退回 cables.js 内的专用 THREE.Sprite 路径,并改为 canvas 生成的黄色扁平球纹理。它的 altitudeOffset 和 renderOrder 与海缆线一致,避免漂在海缆之上;Sprite 本体关闭 depthTest 保持球完整,背面可见性由 isFacingCamera() 的球体遮挡判断控制。
图标资源可以继续用 canvas draw,也可以放到 frontend/public/earth/assets/icons/ 后由 Interactable 预加载。asset 路径不会在每帧读取;图层加载阶段通过 preloadAssets() 只加载一次 SVG / 图片,之后按 icon source + state + bucket + color 生成 CanvasTexture 并复用。当前算力中心已经从 assets/icons/compute-supercomputer.svg、assets/icons/compute-gpu-cluster.svg 和备用 assets/icons/compute-hdd-network.svg 读取图标,再在 canvas 上叠加未确认位置的 ? badge。算力中心后端在启动链路只渲染源数据自带坐标或 compute_center_locations 维表坐标;手动候选采集会调用 ROR 和 Nominatim/OpenStreetMap,并在 GeoJSON 或候选响应中返回位置精度、置信度、来源说明和核验时间;前端详情卡展示这些字段。
算力中心图层行左上角的通知气泡显示 GeoJSON unresolved 数量。这个数字表示“完全没有可信坐标、不能渲染到地球上”的记录,不等同于地图上带 ? 的已定位待确认点。点击气泡会在图层面板右侧打开固定信息卡,信息卡内容区内部滚动,不随鼠标 hover 消失。列表中的单条 采集 只展示候选;顶部 一键采用 会按当前列表顺序逐条采集、保存最高置信候选,成功一条就移除一条、重新编号,并通过 earth:compute-center-unresolved-count-change 同步气泡数量。批量结束后再触发 earth:compute-center-location-saved 刷新真实图层。
详情卡里的坐标候选状态由 info-card.js 按 entityType:entityId 缓存在模块内存中。用户关闭详情卡或待定位列表后再次打开同一个算力中心 / BGP 观测站,已经采集到的候选和状态文案会恢复;一键采用 会优先使用缓存候选,避免重复调用在线地理编码或 LLM factcheck。保存成功后该实体的候选列表会清空为“正在刷新图层”状态,避免旧候选在刷新后继续误导用户。
候选行的 预览 / 保存 按钮采用单一的事件委托模型:每个候选根(详情卡里的 [data-collect-cache-key] 块,或待定位列表里的 [data-unresolved-item])只挂一个 click 监听,由 data-candidate-actions-bound 幂等标记,不再混用 pointerup / click 直绑或重复委托。候选对象不再以 JSON 字符串塞进 HTML 属性后再 JSON.parse,按钮只携带 data-candidate-index,handler 通过 cache-key 在模块内存的 Map 里取出原对象,避开 HTML 实体转义对 & / < / " 的破坏。点击 预览 会派发 earth:preview-location-candidate,由 main.js 的 previewLocationCandidate() 调用 showComputeCenterLocationPreview():在候选经纬度上挂双层空心呼吸 sprite(视觉参考 BGP 事件 ring),并把视角聚焦到候选坐标;切换到另一个候选会替换为新呼吸圈,保存时立即清除并由 spawnSavedComputeCenterLocation() 即时生成正式算力中心交互图标。注意 main.js 没有模块级 earth 变量,所有 location-save / preview 处理函数必须先 const earth = getEarth();,否则会在事件 handler 里抛 ReferenceError 被 .catch 静默掉,外观上等同于按钮“没有反应”。
earth:compute-center-location-saved 之后的图层校准链路对后台刷新失败保持沉默:spawnComputeCenterAfterLocationSave() 已经把 toast 和 locked 状态都给了用户,refreshComputeCentersAfterLocationSave() 只在场景就绪时重新拉取后端数据,本身不再吐 已保存 toast;handleComputeCenterLocationSaved() 在 spawn 成功路径让 refresh 静默后台运行,只在 spawn 返回 null(场景未就绪)或抛错时才让 refresh 接管成功 toast,refresh 自身报错只走 console.warn,绝不冒泡成 保存失败 文案——保存请求本身已经成功,刷新失败属于后续同步问题。
asset 图标大小由 Interactable 的 icon.fitSize 控制。SVG / 图片文件应尽量保持原始 viewBox 和路径,不要为了在地球上显示成 60x60 而手写 transform;drawAssetIcon() 会把资源等比 contain 到指定尺寸并居中绘制到 atlas canvas。
Interactable 默认使用固定屏幕像素尺寸,适合船只、BGP 事件、BGP 观测站、算力中心这类需要稳定识别的图标。如果某类图标需要跟随相机距离缩放,可以把 sizeMode 设为非 "fixed",并用 sizeScale.min / max / referenceFov 控制缩放范围;单个 marker 的业务尺寸差异可以通过 getPointSizeMultiplier() 表达,例如 BGP 事件按严重级别调整点大小,BGP 观测站按活跃度调整点大小。
Interactable 不再把图标本体额外抬离业务高度。altitudeOffset 就是 marker、hover glow、locked glow 和 picking 共同使用的地表高度;这样船只图标会继续贴着船只轨迹线,不会因为单独抬高显示位置而显得漂浮。后续如果要解决边缘 glow 裁切,应优先考虑 glow 纹理、overlay 尺寸或图层专属特效,而不是把通用图标层整体抬高。
跨 Interactable 的同坐标避让也在公共层处理。每个 marker 会保留 icon_base_position 作为业务原始位置;当多个 Interactable marker 归入同一个经纬度 key 时,公共层会把它们沿地表切平面排成小圈,并刷新已创建的 THREE.Points geometry。这样视觉位置和屏幕空间 picking 位置一致,不需要业务层再单独判断“算力中心和 BGP 事件重叠”这类场景。
接口细节、生命周期和接入示例见:
视角控制反馈
controls.js 统一维护 Earth 缩放状态。滚轮缩放、缩放按钮和触屏双指捏合最终都会更新 zoomLevel,并通过 showZoomStatusCapsule() 显示当前缩放比例:
showGestureStatusMessage(`缩放 ${Math.round(zoomLevel * 100)}%`, "info");
该提示每 90ms 最多更新一次,显示 760ms 后淡出。它是视角反馈,不是数据加载进度,也不应该写进图层 loading 状态。
main.js 只负责在双指捏合缩放时调用 setZoomLevel() 和 showZoomStatusCapsule()。鼠标滚轮与缩放按钮的胶囊提示应继续放在 controls.js,避免同一种缩放反馈散落在多个模块。
拖拽地球的旋转灵敏度会根据当前缩放连续衰减,而不是按某个缩放阈值分段:
const scale = THREE.MathUtils.clamp(
Math.pow(zoom, -CONFIG.dragRotationZoomExponent),
CONFIG.dragRotationScaleMin,
CONFIG.dragRotationScaleMax,
);
调参入口在 constants.js:dragRotationFactorBase 控制基础速度,dragRotationZoomExponent 控制放大后的衰减曲线,dragRotationScaleMin / dragRotationScaleMax 控制上下限。
data-status-target
图层按钮可以通过:
data-status-target
指向一个状态文本节点。当前 terrain 已接入:
- 按钮:
#toggle-terrain - 状态节点:
#terrain-status
地形不是默认可见图层时,启动期不会立即阻塞加载地形瓦片。controls.js 会在图层可见性恢复完成后才调度 scheduleTerrainPrefetch(),并且只在高清材质可用、地形尚未 ready、预取未开始时执行。预取使用 setTimeout + requestIdleCallback,避免和首屏云图、高清材质、图层启动队列抢主线程。
地形瓦片请求也不再逐个散发大量单 tile 请求。terrain.js 会把需要的 Terrarium tile 去重后按 TERRAIN_CONFIG.batchRequestSize 分批请求 /api/v1/visualization/terrain/terrarium/batch;后端用 LRU 内存缓存、批次去重和并发限制代理 S3 Terrarium tile。单 tile endpoint 仍保留给回退路径和浏览器缓存语义。
以后别的异步图层也可以沿用这套约定。
当前设置持久化
Earth 设置面板当前由 controls.js 统一负责:
- 捕获默认值
- 从
localStorage读取上次设置 - 初始化应用当前设置
- 用户变更后即时持久化
- 一键重置回默认值
当前持久化的范围是:
- 旋转模式
- 地球默认大小(作为重置视角、缩放重置和巡航视图的默认 zoom 真源)
- HUD 面板显示/隐藏
- 图层控制开关:
地形 / 卫星 / 轨迹 / 海缆 / BGP - 地形透明度
也就是说,Earth 设置不是一次性 UI 状态了,而是本地设备级偏好。后续如果再加入新的设置项,应优先接入同一条持久化链,而不是各自散着写 localStorage。
当前地形链路
真实地形首次启用会慢,原因不只是一个:
- 需要拉取 Terrarium 瓦片
- 需要解码图片
- 需要按顶点采样高程
- 需要重新写入 geometry 和 color
- 需要重新计算法线与包围体
当前入口在:
当前已经做了两层体验优化:
- 图层开关 loading 状态持续可见
- 页面空闲时会预热
ensureTerrainReady()
当前巡航链路
当前巡航边界:
- 通用巡航层包含:
- 当前目标
- 队列顺序
- 相机 focus
- 停留 / 隐藏 / 切换
- 业务模块提供:
- 提供目标队列
- 提供 focus 坐标
- 提供卡片内容
- 提供高亮/图层副作用
巡航模块的结构文件: