Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36be9e3da6 | ||
|
|
7d8822d34d | ||
|
|
ae1a4b3942 | ||
|
|
35e478642b | ||
|
|
33734b08b8 | ||
|
|
bbe47c5759 | ||
|
|
361bf258e4 | ||
|
|
83c552f9b2 | ||
|
|
15fed1e121 | ||
|
|
f4514b588e | ||
|
|
9523f2bb83 | ||
|
|
9dc3b61fa5 | ||
|
|
698863da68 | ||
|
|
4c0b49962c | ||
|
|
ffb3134d93 | ||
|
|
e8742b5d3c | ||
|
|
28a7132eec | ||
|
|
dd41588527 | ||
|
|
45604a1ebb | ||
|
|
06c122bade | ||
|
|
96e9b15fda | ||
|
|
a8713e58fd |
@@ -8,6 +8,248 @@ This project follows the repository versioning rule:
|
||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||
- `bugfix` -> `+0.0.1`
|
||||
|
||||
## [0.76.18] — 2026-10-02
|
||||
|
||||
Released: 2026-10-02
|
||||
|
||||
### Highlights
|
||||
- 收敛 Earth 新闻 breaking 交互、卡片 hover、面板稳定性与 ticker 玻璃扫光,并修复船只地表锚点显隐。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- breaking 数据、ticker 当前项、面板生命周期和选中态分离;展开面板时冻结卡片 DOM,后台更新在关闭时应用。
|
||||
- ticker 保留原有容器与滚动,breaking 扫光独立于静态上下边线,连续 breaking 切换不重启;卡片 hover/扫光统一复用等级色相。
|
||||
- 新闻卡片内容高度、首卡间距和 cruise 选中态统一;船只 marker 复用地表锚点遮挡,保持前后显隐与 BGP/算力对象一致。
|
||||
- 同步 Earth 前端状态机与渲染层中英文开发文档。
|
||||
|
||||
---
|
||||
|
||||
## [0.76.17] — 2026-10-02
|
||||
|
||||
Released: 2026-10-02
|
||||
|
||||
### Highlights
|
||||
- 修复 ticker 连续 breaking 新闻切换时扫光动画重启导致的闪烁、回跳和断帧。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- breaking 等级 class 只负责颜色,稳定的 active 状态负责动画生命周期。
|
||||
- ticker 与卡片扫光方向统一为左到右,连续 breaking 切换不重置时间轴。
|
||||
|
||||
---
|
||||
|
||||
## [0.76.16] — 2026-10-02
|
||||
|
||||
Released: 2026-10-02
|
||||
|
||||
### Highlights
|
||||
- 修复 ticker breaking class 每 120ms 重置导致的边框闪烁、扫光停滞和方向反转。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- watcher 仅在当前 breaking 等级变化时更新 class,动画可以完整播放。
|
||||
- ticker 与卡片扫光方向统一为左到右,ticker 扫光覆盖完整 ticker 区域。
|
||||
|
||||
---
|
||||
|
||||
## [0.76.15] — 2026-10-02
|
||||
|
||||
Released: 2026-10-02
|
||||
|
||||
### Highlights
|
||||
- 将 ticker 扫光限制在上下边线以内,消除 breaking 边框被扫光伪元素带动闪烁。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- ticker 扫光与卡片扫光保持独立横向移动,边线保持稳定,只响应 hover/等级颜色。
|
||||
|
||||
---
|
||||
|
||||
## [0.76.14] — 2026-10-02
|
||||
|
||||
Released: 2026-10-02
|
||||
|
||||
### Highlights
|
||||
- 修复卡片扫光从中途出现的问题,让反射从卡片左边界完整扫到右边界。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- 卡片反射改用内部 `background-position` 扫描,不再使用卡片外部 transform 起始。
|
||||
- ticker 上下边线保持静态,横向扫光独立运行。
|
||||
|
||||
---
|
||||
|
||||
## [0.76.13] — 2026-10-02
|
||||
|
||||
Released: 2026-10-02
|
||||
|
||||
### Highlights
|
||||
- 修正 breaking hover 强度基准,hover 会在自身 breaking 高亮上继续增强。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- ticker hover 从 breaking 原始色相和强度继续增强,不再回落到普通新闻强度。
|
||||
- breaking 卡片边框 hover 同样相对自身等级增强,保持红/橙/黄颜色连续。
|
||||
|
||||
---
|
||||
|
||||
## [0.76.12] — 2026-10-02
|
||||
|
||||
Released: 2026-10-02
|
||||
|
||||
### Highlights
|
||||
- 修复 breaking ticker 和黄卡 hover 被白色混合漂淡的问题。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- hover 使用共享强度参数与等级色相,玻璃底复用深色 HUD surface。
|
||||
- 红色 ticker 保持红色边线,黄色卡片保持黄色 tint,不再被白色边框稀释。
|
||||
|
||||
---
|
||||
|
||||
## [0.76.11] — 2026-10-02
|
||||
|
||||
Released: 2026-10-02
|
||||
|
||||
### Highlights
|
||||
- 修正 breaking 卡片整卡扫光层级,并将 ticker hover 亮度对齐共享 HUD 面板。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- 卡片扫光覆层位于内容之上,整张卡片 hover/focus 时才扫过。
|
||||
- ticker 上下边线 hover 使用共享 HUD 边框 hover 强度,breaking 只替换色相。
|
||||
|
||||
---
|
||||
|
||||
## [0.76.10] — 2026-10-02
|
||||
|
||||
Released: 2026-10-02
|
||||
|
||||
### Highlights
|
||||
- 将 breaking 卡片扫光限制为当前 hover/focus 卡片,避免所有卡片同时动态闪烁。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- 卡片反射层默认关闭,只在当前交互卡片上启动对应等级颜色扫光。
|
||||
- ticker 的全宽 breaking 扫光保持独立,不影响卡片浏览。
|
||||
|
||||
---
|
||||
|
||||
## [0.76.9] — 2026-10-02
|
||||
|
||||
Released: 2026-10-02
|
||||
|
||||
### Highlights
|
||||
- 修复 breaking 卡片黄色 hover 变淡,并补齐 ticker 上下边线的 hover 高亮反馈。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- 卡片 hover 使用不透明等级色相和统一混合强度,红/橙/黄表现一致。
|
||||
- ticker hover 只强化上下两条边,普通新闻使用蓝色,breaking 使用对应等级颜色。
|
||||
|
||||
---
|
||||
|
||||
## [0.76.8] — 2026-10-02
|
||||
|
||||
Released: 2026-10-02
|
||||
|
||||
### Highlights
|
||||
- 修复展开新闻面板时后台 ticker / realtime 更新导致卡片重建和 hover 抖动的问题。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- 面板浏览期间冻结卡片 DOM,后台更新只写入最新 payload。
|
||||
- 关闭面板时一次性应用待处理新闻,保持滚动位置和 hover 稳定。
|
||||
- 展开面板时暂停隐藏 ticker,手动刷新仍可立即更新面板内容。
|
||||
|
||||
---
|
||||
|
||||
## [0.76.7] — 2026-10-02
|
||||
|
||||
Released: 2026-10-02
|
||||
|
||||
### Highlights
|
||||
- 统一 breaking 新闻卡片的 hover 交互,让通用 hover 行为叠加卡片自身等级颜色。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- 红、橙、黄 breaking 卡片共用同一套 hover 提亮、边框增强和浮起逻辑。
|
||||
- 等级颜色通过卡片变量注入,避免为每种颜色复制 hover 样式。
|
||||
|
||||
---
|
||||
|
||||
## [0.76.6] — 2026-10-02
|
||||
|
||||
Released: 2026-10-02
|
||||
|
||||
### Highlights
|
||||
- 扩展 breaking ticker 的全域扫光效果,并拉开新闻卡片与上方状态内容的间距。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- ticker 反射从局部光斑改为覆盖整条横向区域的扫光/闪烁带。
|
||||
- 桌面与移动新闻列表首卡预留顶部呼吸空间,保持内容区层次。
|
||||
|
||||
---
|
||||
|
||||
## [0.76.5] — 2026-10-02
|
||||
|
||||
Released: 2026-10-02
|
||||
|
||||
### Highlights
|
||||
- 增加 breaking 新闻的玻璃反射式警灯脉冲,并修复新闻卡片内容被 flex 压缩的问题。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- ticker、卡片边框和卡片反射统一使用同一 breaking 等级颜色。
|
||||
- breaking ticker 使用局部红/橙高光脉冲,卡片使用反射带扫过;面板底色保持蓝色。
|
||||
- 新闻卡片固定非收缩布局并预留最小内容高度,避免只剩媒体名和时间。
|
||||
- 同步中英文 Earth 前端开发文档,记录视觉状态与 reduced-motion 行为。
|
||||
|
||||
---
|
||||
|
||||
## [0.76.4] — 2026-10-01
|
||||
|
||||
Released: 2026-10-01
|
||||
|
||||
### Highlights
|
||||
- 修正 breaking 新闻状态显示:ticker 仅在当前滚动到 breaking 新闻时变红,展开面板始终保持蓝色。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- 选中、焦点、关闭动画与 breaking 数据状态彻底分离,关闭新闻栏不再留下橙色边线。
|
||||
- breaking 新闻按过期时间重新计算,单条新闻卡片继续保留自身 breaking 红色样式。
|
||||
- 增加 ticker breaking 状态单测和浏览器关闭/重开验证。
|
||||
|
||||
---
|
||||
|
||||
## [0.76.3] — 2026-10-01
|
||||
|
||||
Released: 2026-10-01
|
||||
|
||||
### Highlights
|
||||
- 修复新闻栏关闭后残留橙色边线的问题,明确 breaking、展开面板和新闻选择的状态边界。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- ticker 保持中性,breaking 等级只作用于展开新闻面板整体背景和阴影。
|
||||
- 关闭新闻面板时清理选中故事和过期焦点状态,breaking 按新闻过期时间重新计算。
|
||||
- 增加 breaking 状态单测,保持新闻滚动、选择和面板动画行为不变。
|
||||
|
||||
---
|
||||
|
||||
## [0.76.2] — 2026-10-01
|
||||
|
||||
Released: 2026-10-01
|
||||
|
||||
### Highlights
|
||||
- 优化光缆密集区的卫星点拾取,并将点状交互物件优先级泛化到其他密集图层。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- 屏幕命中先统一比较卫星、BGP、算力和其他点状对象的距离,海缆线作为兜底候选,保护穿插在密集线缆中的小面积对象。
|
||||
- hover 与 click 共用同一候选排序,避免鼠标提示和最终选中对象不一致。
|
||||
- 补充密集区域拾取回归测试,清理中间态代码并同步渲染层说明。
|
||||
|
||||
---
|
||||
|
||||
## [0.76.1] — 2026-10-01
|
||||
|
||||
Released: 2026-10-01
|
||||
|
||||
### Highlights
|
||||
- 修复选中 BGP 事件静态光晕在地表边缘被切断或陷入地表的问题。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- 抽取通用整体锚点遮挡能力,BGP 事件保持原有图标样式并复用圆角图标的前后显隐规则。
|
||||
- 选中事件光晕与事件图标共享地表锚点、前景层级和前后显隐路径,清理中间态代码与资源。
|
||||
- 补充中英文渲染层说明、回归测试和浏览器验证记录。
|
||||
|
||||
---
|
||||
|
||||
## [0.76.0] — 2026-09-30
|
||||
|
||||
Released: 2026-09-30
|
||||
|
||||
@@ -253,7 +253,7 @@ All material, layer, satellite, BGP, cable, terrain, celestial, and other style
|
||||
|
||||
## Render hot paths
|
||||
|
||||
`cable-batches.js` batches the complete cable and landing-point sets. `cables.js` still owns original business objects, picking, occlusion, and selection state. Do not make these interaction proxies draw individually again or return a render batch as the detail-card business object.
|
||||
`cable-batches.js` batches the complete cable and landing-point sets. `cables.js` still owns original business objects, picking, occlusion, and selection state. Do not make these interaction proxies draw individually again or return a render batch as the detail-card business object. In dense cable areas, screen-distance picking compares satellites, BGP, compute and other point objects before using cable lines as the fallback; hover and click share the same ordering so small objects threaded through a line bundle remain selectable.
|
||||
|
||||
Satellite breathing runs in vertex shaders. `satellite-position-worker.js` moves full SGP4 position and initial trail calculations off the main thread; the main thread applies snapshots to interaction coordinates and render buffers. `satellite-propagation.js` supplies the same mathematics to the Worker, predicted orbits, and synchronous fallback. Reload, clear, and altitude changes must terminate prior work so stale snapshots cannot resurrect cleared layers.
|
||||
|
||||
@@ -434,9 +434,21 @@ On mobile, compute uses a compact regional overview in the existing drawer. Rows
|
||||
|
||||
Normal borders, coastlines, claim lines, highlights and landing points use `earth-surface.js` to share the HD sphere UV-triangle surface. Borders draw after the texture but before landing points. Highlight lines are always transparent-queue objects with depth testing, preventing through-globe display or overpainting markers. Geometry is computed and cached at construction, not reprojected per frame. See the [render-layer specification](earth-render-layer-order.md).
|
||||
|
||||
### News ticker edge fading
|
||||
### News ticker edge fading and breaking state machine
|
||||
|
||||
A separate pseudo-element paints the ticker background and horizontal rules, fading to full transparency outside the text bounds. The shell has no rectangular backdrop filter, box shadow, or text mask. The region label sizes to its content and the scrolling text follows directly without fading. Alert states change only the gradient rule color so they cannot reintroduce rectangular shadow edges.
|
||||
A separate pseudo-element paints the ticker background and horizontal rules, fading to full transparency outside the text bounds. The shell has no rectangular backdrop filter, box shadow, or text mask. The region label sizes to its content and the scrolling text follows directly without fading.
|
||||
|
||||
The previous breaking rule was global shell promotion: if the payload contained any unexpired breaking story, the highest level (`critical > breaking > watch`) was applied to the ticker or expanded panel. That made a closed panel, or a ticker currently showing a normal story, retain an orange/red state. `breaking_expires_at` eventually cleared the state, while selection, opening and closing did not change the underlying breaking data.
|
||||
|
||||
The current state machine keeps data, ticker visibility, panel lifecycle and selection separate:
|
||||
|
||||
- `getNewsBreakingState()` derives the data state from `items` and `cruise_items`, ignores expired stories, and ranks `critical > breaking > watch`. The expiry timestamp only schedules the next recomputation.
|
||||
- A ticker viewport watcher runs every 120 ms and chooses the item with the greatest viewport overlap, breaking ties by distance to the viewport center. Only when that current item has a non-`none` level does the ticker switch its horizontal rules to `has-breaking-*`; a brief no-overlap gap at the duplicated-track seam retains the active state so the sweep timeline and borders do not restart.
|
||||
- The panel lifecycle is `closed → opening → open → closing → closed`. Opening and closing control morphing, visibility and ticker hiding; they do not mutate the breaking data. During `opening/open`, the current card DOM stays stable while incoming payloads are deferred and applied once on close, so realtime updates cannot rebuild a hovered card.
|
||||
- `selectedCruiseStoryId` is independent of breaking severity. Selection, focus, hover and close only change the selected story and reading position; close also clears the selection and filter popover.
|
||||
- The expanded panel always keeps its blue glass surface. Breaking colors remain on the matching story cards, so opening the panel never turns the entire glass panel red; the ticker can still show a red signal when the currently scrolling story is breaking.
|
||||
|
||||
The glass-reflection effect is implemented: when the current ticker story enters a breaking state, its `::after` carries a short, feathered light cloud across the ticker glass area; the matching breaking card gets a full-card reflection sweep through its own `::after`. The panel base remains blue and the static top/bottom rules do not participate in the sweep. `prefers-reduced-motion` disables the animation and keeps a low-intensity static cue. This visual sub-state stays attached to the current breaking story and matching card, independent of whether the panel is open.
|
||||
|
||||
Compute cards directly reuse the shared `.hud-panel` background, border, shadow, and glass treatment. Summary, essentials, and detail areas add no separate black surface. Their headers share inset spacing with the layer panel through `.hud-panel__header--inset` / `.layer-panel-header`; primary numbers remain prominent and essential information stays visible.
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ Note: the layer control panel order and the registration / startup load order ar
|
||||
| 3 | Satellite footprint fill / Iridium coverage ring | `satellites.js`, `iridium-footprint-adapter.js` | `GROUND_FOOTPRINT_RENDER_ORDER` | depth-tested; Iridium adapter fill / ring use the same renderOrder | Footprint above land / texture / terrain and country borders, below compute centers and satellites. |
|
||||
| 3-4.5 | BGP collectors, event rings, and event markers | `bgp.js`, `interactable.js` | Collector markers use `BGP_COLLECTOR_RENDER_ORDER = 4.4`; event markers use `BGP_EVENT_RENDER_ORDER = 4.5`; overlays remain in BGP-owned groups | Screen-space Interactable picking; BGP collectors/events may use collision avoidance where appropriate | BGP collectors share the surface-facility band with vessels; BGP events share the compute-center band. |
|
||||
| 4.3 | AIS vessel track lines | `vessels.js` | `VESSEL_RENDER_ORDER - 0.1`; `CONFIG.earthRadius + VESSEL_CONFIG.track.altitudeOffset` | Follows vessel selection, not independently picked | Recent track for the selected vessel, below vessel markers. |
|
||||
| 4.4 | AIS vessel markers | `vessels.js`, `interactable.js` | `VESSEL_RENDER_ORDER`; business radius `CONFIG.earthRadius + VESSEL_CONFIG.altitudeOffset`; normal markers are bucketed `THREE.Points`, hover / locked states are single-point overlays | `depthTest: true`; `main.js` uses screen-space picking for front-facing markers; `cluster: false` and `avoidance: false` | Moving vessels use triangular point textures, anchored / slow vessels use dots. Dense waterways may overlap and do not participate in dynamic screen clustering, avoiding Points rebuilds during rotation. |
|
||||
| 4.4 | AIS vessel markers | `vessels.js`, `interactable.js` | `VESSEL_RENDER_ORDER`; business radius `CONFIG.earthRadius + VESSEL_CONFIG.altitudeOffset`; normal markers are bucketed `THREE.Points`, hover / locked states are single-point overlays; whole-anchor occlusion matches BGP and compute markers at the globe limb | `depthTest: false`, with visibility decided by anchor occlusion; `main.js` uses screen-space picking for front-facing markers; `cluster: false` and `avoidance: false` | Moving vessels use triangular point textures, anchored / slow vessels use dots. Dense waterways may overlap and do not participate in dynamic screen clustering, avoiding Points rebuilds during rotation. |
|
||||
| 4.5 | Compute centers | `compute-centers.js`, `interactable.js` | `COMPUTE_CENTER_RENDER_ORDER` | Screen-space Interactable picking, with surface-icon collision handling | Surface facilities, below satellites. |
|
||||
| 6 | Satellite dots | `satellites.js` | SGP4 altitude compressed to R+4..25; fallback R+8 | Screen-space satellite picking | Satellite dots above footprints and compute centers. |
|
||||
| 12+ | Satellite locked ring, halo, predicted orbit | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` and offsets; predicted orbit follows the same real-altitude toggle and fixes the lock-time globe pose to draw an open full-period inertial curve with forward-flow highlights; returns to same-sphere mode when real altitude is disabled | Satellite overlay path | Used for selected/locked satellite emphasis. |
|
||||
@@ -98,7 +98,7 @@ Base, tint, land fill and HD surface now use `createEarthSurfaceGeometry()` and
|
||||
|
||||
### Surface marker alignment and limb visibility
|
||||
|
||||
Cables and landing points share R+0.48, the `earthSurfacePoint` triangle projection and renderOrder=1. Related landing halos use an emphasis attribute in the existing batch, adding no Sprite or draw call. Rounded badges use GPU anchor ray/sphere occlusion: draw the entire billboard when its anchor is visible, otherwise clip it entirely. CPU picking uses the same test to reject rear anchors. Only `icon.badge` layers (including compute and collectors; vessels independently use heading arrows) use this path; flat BGP change-event depth testing stays unchanged.
|
||||
Cables and landing points share R+0.48, the `earthSurfacePoint` triangle projection and renderOrder=1. Related landing halos use an emphasis attribute in the existing batch, adding no Sprite or draw call. Rounded badges and vessel heading arrows use GPU anchor ray/sphere occlusion: draw the entire billboard when its anchor is visible, otherwise clip it entirely. CPU picking uses the same test to reject rear anchors. The selected BGP event's static overlay halo uses the event icon's R+0.48 anchor, foreground order and whole-anchor front/rear visibility; the existing animated BGP event ring remains a separate effect with its original behavior.
|
||||
|
||||
|
||||
The `.map-clusters` projection layer uses z-index 8 below HUD and whole-marker occlusion from real surface anchors. Ordinary points retain their Three.js layers; transitioning members render only once in the projection layer. Spider legs use widened transparent hit areas; hover highlights the matching line and child, and both clicks resolve the same business object. Wheel input over projected markers and legs continues to zoom the globe.
|
||||
|
||||
@@ -233,7 +233,7 @@ TV 预览需要尽量复用 Earth 运行时的直播卡片结构和状态标签
|
||||
|
||||
## 渲染热路径
|
||||
|
||||
`cable-batches.js` 将全部海缆线段与全部登陆点分别合批绘制;`cables.js` 继续拥有原始业务对象、拾取、遮挡和选择状态。避免在后续功能中把这些代理对象重新加入逐对象绘制,或把绘制批次当作业务对象返回给详情卡。
|
||||
`cable-batches.js` 将全部海缆线段与全部登陆点分别合批绘制;`cables.js` 继续拥有原始业务对象、拾取、遮挡和选择状态。避免在后续功能中把这些代理对象重新加入逐对象绘制,或把绘制批次当作业务对象返回给详情卡。密集线缆区的拾取先在屏幕距离上比较卫星、BGP、算力和其他点状对象,海缆线作为没有更近点状对象时的兜底;hover 与 click 共用该候选顺序,保护穿插在线缆中的小面积物件。
|
||||
|
||||
卫星的逐点呼吸在 GPU 中计算。全量 SGP4 位置和初始轨迹计算通过 `satellite-position-worker.js` 离开主线程;主线程只接收位置快照、更新共享交互坐标及绘制缓冲。轨道数学由 `satellite-propagation.js` 同时供 Worker、预测轨道和同步降级路径使用,避免多套公式漂移。数据重载、清空和高度切换必须同步终止旧计算,不能让旧快照恢复已清空的图层。
|
||||
|
||||
@@ -567,9 +567,21 @@ Earth 原有 Roboto Condensed 字体已随 `assets/fonts/` 本地分发,许可
|
||||
|
||||
普通国界、海岸、主张线、高亮及登陆点使用 `earth-surface.js` 对齐高清球面的 UV 三角形。国界与高亮在贴图之后、登陆点之前绘制,高亮开启深度测试且始终位于透明渲染队列;不再透过球体显示或盖住登陆点。几何构建时计算并缓存,不增加每帧重投影。具体顺序见[渲染图层规范](earth-render-layer-order.md)。
|
||||
|
||||
### 新闻栏端部渐变
|
||||
### 新闻栏端部渐变与 breaking 状态机
|
||||
|
||||
新闻栏的底色与上下细线由独立伪元素绘制,两端在正文范围之外渐变到完全透明;外框不使用矩形 backdrop-filter、box-shadow 或文字遮罩。地区标签按内容占宽,正文紧接标签,滚动文字不做渐隐。警报状态只改变渐变细线的颜色,避免重新引入矩形阴影边界。
|
||||
新闻栏的底色与上下细线由独立伪元素绘制,两端在正文范围之外渐变到完全透明;外框不使用矩形 backdrop-filter、box-shadow 或文字遮罩。地区标签按内容占宽,正文紧接标签,滚动文字不做渐隐。
|
||||
|
||||
此前的 breaking 规则是:只要本次 payload 中存在未过期的 breaking 新闻,就按 `critical > breaking > watch` 取最高等级,把等级直接映射到新闻壳层。这样会让整条 ticker 或展开面板在当前显示的新闻并非 breaking、甚至面板已经关闭时仍保留橙/红状态,造成“关闭后残留边缘”和“整个面板变色”。`breaking_expires_at` 到期后才清除该状态;选中、打开和关闭不会改变 breaking 数据本身。
|
||||
|
||||
当前状态机将数据态、ticker 显示态、面板生命周期和选中态分开:
|
||||
|
||||
- 数据态由 `getNewsBreakingState()` 从 `items` 与 `cruise_items` 合并计算,只接受未过期项目,并按 `critical > breaking > watch` 排序;到期时间只负责安排下一次重新计算。
|
||||
- ticker 的显示态由滚动视口 watcher 每 120ms 取视口内重叠最多、距离中心最近的当前项目。只有当前项目的 breaking 等级非 `none` 时,ticker 的上下细线才切换为对应的 `has-breaking-*` 颜色;循环换项产生的短暂无重叠空档会保留 active 状态,避免扫光时间轴重启或边线闪烁。
|
||||
- 面板生命周期 `panelPhase` 只有 `closed → opening → open → closing → closed`。打开/关闭只控制形变、可见性和 ticker 的隐藏,不改变 breaking 数据态;进入 `opening/open` 后保留当前卡片 DOM,后台 payload 先暂存,关闭时再一次性应用,避免 hover 卡片被实时更新重建。
|
||||
- `selectedCruiseStoryId` 独立于 breaking 等级。选择、聚焦、悬停和关闭只更新选中项与阅读位置;关闭时清空选中项及筛选弹层。
|
||||
- 展开面板始终使用蓝色玻璃底。breaking 只在对应新闻卡片上使用等级颜色,因此点开面板不会把整块玻璃染成红色,之后滚动到 breaking 新闻时仍可让 ticker 显示红色提示。
|
||||
|
||||
breaking 的玻璃反射效果已实现:ticker 当前项目进入 breaking 状态时,`::after` 用短而四周羽化的光团横穿 ticker 玻璃区域;展开面板中只在对应 breaking 新闻卡片上用 `::after` 扫过整卡反射带。面板底色保持蓝色,静态上下边线不参与扫光;`prefers-reduced-motion` 下停用动画并保留低强度静态提示。该视觉子状态挂在当前 breaking 项目和对应卡片上,不与面板是否打开耦合。
|
||||
|
||||
算力卡片外观直接复用共享 `.hud-panel` 的背景、边框、阴影和玻璃效果,摘要、基础信息及详情区不再叠加单独黑底。标题栏与图层面板共用 `.hud-panel__header--inset` / `.layer-panel-header` 的内边距;主数字继续突出,基础信息保持常显。
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
| 3 | 卫星 footprint 填充 / Iridium coverage ring | `satellites.js`, `iridium-footprint-adapter.js` | `GROUND_FOOTPRINT_RENDER_ORDER` | depth-tested;Iridium adapter 的 fill / ring 也使用同一 renderOrder | Footprint 在 land / texture / terrain 和国界线之上,但在算力中心和卫星之下。 |
|
||||
| 3-4.5 | BGP 观测站、事件扩散圈和事件 marker | `bgp.js`, `interactable.js` | BGP 观测站和事件 marker 均使用 `Interactable` 批量 `THREE.Points`;事件 marker 使用 `BGP_EVENT_RENDER_ORDER = 4.5`;观测站主图标使用 `BGP_COLLECTOR_RENDER_ORDER = 4.4` 和 `BGP_CONFIG.collectorAltitudeOffset = 0.2`;事件 overlay 进入 `bgp-event-overlay-layer`;观测站 halo 和覆盖扇形进入 `bgp-collector-radar-layer` | BGP 事件和观测站都通过 `Interactable` 屏幕空间 picking,并参与同坐标避让 | BGP 观测站主图标与船只同层;BGP 事件与算力中心同层;向外扩散圈、观测站雷达/覆盖动画继续由 BGP 业务逻辑驱动。 |
|
||||
| 4.3 | AIS 船只轨迹线 | `vessels.js` | `VESSEL_RENDER_ORDER - 0.1`;`CONFIG.earthRadius + VESSEL_CONFIG.track.altitudeOffset` | 跟随船只显隐,不单独参与拾取 | 选中船只后显示最近轨迹,低于船只 marker。 |
|
||||
| 4.4 | AIS 船只 marker | `vessels.js`, `interactable.js` | `VESSEL_RENDER_ORDER`;业务高度为 `CONFIG.earthRadius + VESSEL_CONFIG.altitudeOffset`;普通 marker 为分桶 `THREE.Points`,hover / locked 为单点 `THREE.Points` overlay | `depthTest: true`;`main.js` 使用屏幕空间 picking,只取正面 marker;`cluster: false` 且 `avoidance: false` | 航行船只用三角点纹理,停泊/低速用圆点;密集海域允许重叠,不参与动态屏幕聚类,避免旋转时重建 Points;低于算力中心 `4.5`。 |
|
||||
| 4.4 | AIS 船只 marker | `vessels.js`, `interactable.js` | `VESSEL_RENDER_ORDER`;业务高度为 `CONFIG.earthRadius + VESSEL_CONFIG.altitudeOffset`;普通 marker 为分桶 `THREE.Points`,hover / locked 为单点 `THREE.Points` overlay;使用锚点整块遮挡,与 BGP/算力图标在地球边缘保持同一显隐策略 | `depthTest: false`,由锚点前后显隐控制;`main.js` 使用屏幕空间 picking,只取正面 marker;`cluster: false` 且 `avoidance: false` | 航行船只用三角点纹理,停泊/低速用圆点;密集海域允许重叠,不参与动态屏幕聚类,避免旋转时重建 Points;低于算力中心 `4.5`。 |
|
||||
| 4.5 | 算力中心 | `compute-centers.js`, `interactable.js` | 使用 `COMPUTE_CENTER_RENDER_ORDER` 并由 `Interactable` 绘制 | 通过 `Interactable` 屏幕空间 picking,参与同坐标避让 | 地表设施层,保持在卫星下方。登陆点已下沉到海缆层。 |
|
||||
| 6 | 卫星点 | `satellites.js` | SGP4 高度压缩为 R+4..25;回退高度 R+8 | 屏幕空间卫星拾取 | 卫星点压过 footprint 和算力中心。 |
|
||||
| 12+ | 卫星锁定 ring、halo、预测轨道 | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` 及偏移;预测轨道使用同一真实高度开关,并固定锁定时刻的地球姿态来绘制 完整周期开放惯性轨道与方向流光;关闭真实高度时回到同层球面 | 卫星覆盖层路径 | 用于选中 / 锁定卫星强调。 |
|
||||
@@ -100,7 +100,7 @@ Earth 的地表不是单一 mesh,而是多层近似同心球:基座球、海
|
||||
|
||||
### 地表标记对齐与侧面显隐
|
||||
|
||||
海缆和登陆点现在统一使用 R+0.48 及 `earthSurfacePoint` 三角表面投影,并保持 renderOrder=1。登陆点关联光晕进入原实例批次的 emphasis 属性,不增加独立 Sprite 或 draw call。侧面的完整圆角徽标采用 GPU 锚点射线/球体遮挡:锚点可见则整个 billboard 绘制,锚点被地球挡住则整块裁除。CPU 拾取使用同样的射线判断,避免点到背面图标。该路径只作用于 `icon.badge` 层(含算力、观测站;船舶独立使用航向箭头),不改变 BGP 变化事件的原深度测试。
|
||||
海缆和登陆点现在统一使用 R+0.48 及 `earthSurfacePoint` 三角表面投影,并保持 renderOrder=1。登陆点关联光晕进入原实例批次的 emphasis 属性,不增加独立 Sprite 或 draw call。侧面的完整圆角徽标和船舶航向箭头采用 GPU 锚点射线/球体遮挡:锚点可见则整个 billboard 绘制,锚点被地球挡住则整块裁除。CPU 拾取使用同样的射线判断,避免点到背面图标。选中 BGP 事件的静态覆盖光晕使用事件图标相同的 R+0.48 锚点、前景顺序和整块前后显隐;BGP 发散圆环仍保留原有独立动画语义。
|
||||
|
||||
|
||||
聚合投影层 `.map-clusters` 的 z-index 为 8,低于 HUD;以真实地表锚点做整标遮挡。普通点仍使用原 Three.js 图层,过渡中的成员只在投影层显示一次,避免双图标。Spiderfy 引线有透明加宽命中区域,悬停联动高亮引线与子点,点击返回同一业务对象;滚轮在图标与引线上继续缩放地球。
|
||||
|
||||
@@ -16,12 +16,30 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.76.0`
|
||||
- `dev` 当前开发分支历史推导到:`0.76.18`
|
||||
|
||||
## Timeline
|
||||
|
||||
| Version | Type | Branch | Commit | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `0.76.18` | bugfix | `dev` | `v0.76.18` | 收敛 breaking 新闻交互、卡片 hover/扫光、面板稳定性、船只锚点显隐并同步开发文档 |
|
||||
| `0.76.17` | bugfix | `dev` | `v0.76.17` | 稳定 breaking active 动画状态,修复连续新闻切换时扫光重启闪烁 |
|
||||
| `0.76.16` | bugfix | `dev` | `v0.76.16` | 修复 breaking class 重置导致的边框闪烁、扫光停滞与方向反转 |
|
||||
| `0.76.15` | bugfix | `dev` | `v0.76.15` | ticker 扫光限制在上下边线以内,消除 breaking 边框闪烁并与卡片特效分离 |
|
||||
| `0.76.14` | bugfix | `dev` | `v0.76.14` | 修复卡片扫光起点,改为从卡片左边界到右边界的内部背景扫描 |
|
||||
| `0.76.13` | bugfix | `dev` | `v0.76.13` | breaking hover 以自身高亮为基准继续增强,修复红 ticker/卡片变暗 |
|
||||
| `0.76.12` | bugfix | `dev` | `v0.76.12` | 修复 breaking ticker 与黄卡 hover 被白色混合漂淡,保留等级色相 |
|
||||
| `0.76.11` | bugfix | `dev` | `v0.76.11` | breaking 卡片整卡扫光及 ticker hover 强度对齐共享 HUD |
|
||||
| `0.76.10` | bugfix | `dev` | `v0.76.10` | breaking 卡片扫光仅在 hover/focus 时启动,避免全列表同时闪烁 |
|
||||
| `0.76.9` | bugfix | `dev` | `v0.76.9` | 统一 breaking 卡片 hover 强度并增加 ticker 上下边线 hover 高亮 |
|
||||
| `0.76.8` | bugfix | `dev` | `v0.76.8` | 展开新闻面板时冻结卡片 DOM,隔离 ticker/realtime 更新并修复 hover 抖动 |
|
||||
| `0.76.7` | bugfix | `dev` | `v0.76.7` | 统一 breaking 卡片 hover 行为并通过卡片等级变量注入颜色 tint |
|
||||
| `0.76.6` | bugfix | `dev` | `v0.76.6` | breaking ticker 全域扫光闪烁,新闻列表首卡增加顶部呼吸空间 |
|
||||
| `0.76.5` | bugfix | `dev` | `v0.76.5` | breaking 玻璃反射式警灯脉冲、等级颜色统一及新闻卡片内容高度修复 |
|
||||
| `0.76.4` | bugfix | `dev` | `v0.76.4` | 修正 breaking 新闻 ticker 与面板状态边界,当前滚动到 breaking 项时才显示红色 |
|
||||
| `0.76.3` | bugfix | `dev` | `v0.76.3` | 修复新闻栏关闭后残留 breaking 橙色边线,分离 ticker、面板、选择和过期状态 |
|
||||
| `0.76.2` | bugfix | `dev` | `v0.76.2` | 优化光缆密集区卫星点拾取,统一点状对象与线缆候选排序并补齐回归验证 |
|
||||
| `0.76.1` | bugfix | `dev` | `v0.76.1` | 修复选中 BGP 事件静态光晕地表遮挡,统一事件锚点显隐与前景偏移,清理中间态逻辑并补齐验证 |
|
||||
| `0.76.0` | feature | `dev` | `v0.76.0` | 详情卡默认配图与后台配置、五类算力中心、统一地表拾取与选中置顶、卡片交互修复及 Earth 性能优化 |
|
||||
| `0.75.4` | improvement | `dev` | `v0.75.4` | 图例统一筛选、卫星 Points 点径与性能优化、FPS、菜单 DPI/hover、星空 sRGB、地表对齐及新闻编码修复,清理废弃代码文档 |
|
||||
| `0.75.3` | improvement | `dev` | `v0.75.3` | 地球海陆材质与可读夜侧、连续拖拽、现代徽标、卫星平滑开放轨道及流光,修复 CelesTrak 缓存与任务历史并清理废弃实现 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.76.0",
|
||||
"version": "0.76.18",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
--news-ticker-width: min(calc(820px * var(--hud-scale)), 58vw);
|
||||
--news-ticker-edge-fade: calc(96px * var(--hud-scale));
|
||||
--news-ticker-rule-color: var(--hud-border);
|
||||
--news-ticker-line-color: var(--news-ticker-rule-color);
|
||||
--news-ticker-hover-rule-color: var(--hud-border-hover);
|
||||
--news-breaking-color: rgba(255, 166, 102, 0.46);
|
||||
--news-breaking-hue: rgb(255 166 102);
|
||||
position: absolute;
|
||||
top: calc(var(--hud-offset) + calc(2px * var(--hud-scale)));
|
||||
left: 50%;
|
||||
@@ -41,18 +45,72 @@
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(90deg, transparent, var(--news-ticker-rule-color) var(--news-ticker-edge-fade), var(--news-ticker-rule-color) calc(100% - var(--news-ticker-edge-fade)), transparent) top / 100% 1px no-repeat,
|
||||
linear-gradient(90deg, transparent, var(--news-ticker-rule-color) var(--news-ticker-edge-fade), var(--news-ticker-rule-color) calc(100% - var(--news-ticker-edge-fade)), transparent) bottom / 100% 1px no-repeat,
|
||||
linear-gradient(90deg, transparent, var(--news-ticker-line-color) var(--news-ticker-edge-fade), var(--news-ticker-line-color) calc(100% - var(--news-ticker-edge-fade)), transparent) top / 100% 1px no-repeat,
|
||||
linear-gradient(90deg, transparent, var(--news-ticker-line-color) var(--news-ticker-edge-fade), var(--news-ticker-line-color) calc(100% - var(--news-ticker-edge-fade)), transparent) bottom / 100% 1px no-repeat,
|
||||
linear-gradient(90deg, rgba(17, 31, 53, 0), rgba(17, 31, 53, 0.84) var(--news-ticker-edge-fade), rgba(7, 17, 31, 0.84) calc(100% - var(--news-ticker-edge-fade)), rgba(7, 17, 31, 0));
|
||||
}
|
||||
|
||||
.earth-news-ticker::after {
|
||||
content: none;
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: calc(4px * var(--hud-scale)) calc(-1 * var(--news-ticker-edge-fade));
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
background: linear-gradient(
|
||||
102deg,
|
||||
transparent 0%,
|
||||
transparent 42%,
|
||||
color-mix(in srgb, var(--news-breaking-color) 18%, transparent) 47%,
|
||||
var(--news-breaking-color) 50%,
|
||||
color-mix(in srgb, var(--news-breaking-color) 18%, transparent) 53%,
|
||||
transparent 58%,
|
||||
transparent 100%
|
||||
) -100% 0 / 200% 100% no-repeat;
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
.earth-news-ticker.has-breaking-watch { --news-ticker-rule-color: rgba(255, 213, 128, 0.42); }
|
||||
.earth-news-ticker.has-breaking-breaking { --news-ticker-rule-color: rgba(255, 166, 102, 0.48); }
|
||||
.earth-news-ticker.has-breaking-critical { --news-ticker-rule-color: rgba(255, 118, 118, 0.52); }
|
||||
.earth-news-ticker__viewport {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.earth-news-ticker.is-breaking-active::after {
|
||||
animation: earthNewsBreakingGlassSweep 1.8s linear infinite;
|
||||
}
|
||||
|
||||
.earth-news-ticker.has-breaking-watch {
|
||||
--news-breaking-color: rgba(255, 213, 128, 0.42);
|
||||
--news-breaking-hue: rgb(255 213 128);
|
||||
--news-ticker-rule-color: var(--news-breaking-color);
|
||||
}
|
||||
|
||||
.earth-news-ticker.has-breaking-breaking {
|
||||
--news-breaking-color: rgba(255, 151, 78, 0.5);
|
||||
--news-breaking-hue: rgb(255 151 78);
|
||||
--news-ticker-rule-color: var(--news-breaking-color);
|
||||
}
|
||||
|
||||
.earth-news-ticker.has-breaking-critical {
|
||||
--news-breaking-color: rgba(255, 95, 95, 0.56);
|
||||
--news-breaking-hue: rgb(255 95 95);
|
||||
--news-ticker-rule-color: var(--news-breaking-color);
|
||||
}
|
||||
|
||||
.earth-news-ticker.has-breaking-watch,
|
||||
.earth-news-ticker.has-breaking-breaking,
|
||||
.earth-news-ticker.has-breaking-critical {
|
||||
--news-ticker-hover-rule-color: color-mix(
|
||||
in srgb,
|
||||
var(--news-breaking-hue) 68%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.earth-news-ticker:hover,
|
||||
.earth-news-ticker:focus-visible {
|
||||
--news-ticker-line-color: var(--news-ticker-hover-rule-color);
|
||||
}
|
||||
|
||||
.earth-news-ticker__region {
|
||||
position: relative;
|
||||
@@ -225,6 +283,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes earthNewsBreakingGlassSweep {
|
||||
from {
|
||||
opacity: 0.68;
|
||||
background-position: 100% 0;
|
||||
}
|
||||
to {
|
||||
opacity: 0.68;
|
||||
background-position: -100% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes earthNewsBreakingCardReflection {
|
||||
0% {
|
||||
opacity: 0;
|
||||
background-position: 100% 0;
|
||||
}
|
||||
10%,
|
||||
88% {
|
||||
opacity: 0.58;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
background-position: -100% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.news-panel-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -446,6 +530,7 @@
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
overflow-y: auto;
|
||||
padding-top: calc(8px * var(--hud-scale));
|
||||
padding-right: calc(4px * var(--hud-scale));
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(160, 220, 255, 0.36) transparent;
|
||||
@@ -465,6 +550,13 @@
|
||||
}
|
||||
|
||||
.news-story-card {
|
||||
--news-breaking-hue: rgb(214 235 255);
|
||||
--news-hover-color: var(--news-breaking-hue);
|
||||
--news-hover-border-strength: 24%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
flex: 0 0 auto;
|
||||
min-height: calc(108px * var(--hud-scale));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(8px * var(--hud-scale));
|
||||
@@ -480,10 +572,23 @@
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.news-story-card > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.earth-mobile-news-board-list {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.news-story-card:hover {
|
||||
border-color: rgba(214, 235, 255, 0.16);
|
||||
border-color: color-mix(in srgb, var(--news-hover-color) var(--news-hover-border-strength), transparent);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(92, 151, 218, 0.06));
|
||||
linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--news-hover-color) 16%, var(--hud-surface-top)),
|
||||
color-mix(in srgb, var(--news-hover-color) 12%, var(--hud-surface-bottom))
|
||||
);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@@ -493,51 +598,70 @@
|
||||
}
|
||||
|
||||
.news-story-card--cruise {
|
||||
border-color: rgba(255, 213, 128, 0.42);
|
||||
--news-hover-color: rgb(127 219 255);
|
||||
--news-hover-border-strength: 52%;
|
||||
border-color: rgba(127, 219, 255, 0.28);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 248, 220, 0.1), rgba(255, 184, 77, 0.08));
|
||||
linear-gradient(180deg, rgba(127, 219, 255, 0.08), rgba(92, 151, 218, 0.04));
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 213, 128, 0.18) inset,
|
||||
0 0 18px rgba(255, 184, 77, 0.12);
|
||||
0 0 0 1px rgba(127, 219, 255, 0.12) inset,
|
||||
0 0 18px rgba(92, 151, 218, 0.1);
|
||||
}
|
||||
|
||||
.earth-news-hud.has-breaking-watch,
|
||||
.earth-mobile-news-board.has-breaking-watch {
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 213, 128, 0.08) inset,
|
||||
0 18px 46px rgba(156, 106, 20, 0.12);
|
||||
}
|
||||
|
||||
.earth-news-hud.has-breaking-breaking,
|
||||
.earth-mobile-news-board.has-breaking-breaking {
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 166, 102, 0.14) inset,
|
||||
0 18px 48px rgba(185, 89, 32, 0.16);
|
||||
}
|
||||
|
||||
.earth-news-hud.has-breaking-critical,
|
||||
.earth-mobile-news-board.has-breaking-critical {
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 118, 118, 0.18) inset,
|
||||
0 18px 52px rgba(172, 43, 43, 0.18);
|
||||
.news-story-card--breaking-watch,
|
||||
.news-story-card--breaking-breaking,
|
||||
.news-story-card--breaking-critical {
|
||||
--news-hover-border-strength: 68%;
|
||||
border-color: var(--news-breaking-color);
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--news-breaking-hue) 11%, var(--hud-surface-top)),
|
||||
color-mix(in srgb, var(--news-breaking-hue) 7%, var(--hud-surface-bottom))
|
||||
);
|
||||
}
|
||||
|
||||
.news-story-card--breaking-watch {
|
||||
border-color: rgba(255, 213, 128, 0.28);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 248, 220, 0.08), rgba(255, 184, 77, 0.05));
|
||||
--news-breaking-color: rgba(255, 213, 128, 0.42);
|
||||
--news-breaking-hue: rgb(255 213 128);
|
||||
}
|
||||
|
||||
.news-story-card--breaking-breaking {
|
||||
border-color: rgba(255, 166, 102, 0.36);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 205, 150, 0.1), rgba(255, 135, 80, 0.07));
|
||||
--news-breaking-color: rgba(255, 151, 78, 0.5);
|
||||
--news-breaking-hue: rgb(255 151 78);
|
||||
}
|
||||
|
||||
.news-story-card--breaking-critical {
|
||||
border-color: rgba(255, 118, 118, 0.42);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 155, 155, 0.11), rgba(205, 58, 58, 0.08));
|
||||
--news-breaking-color: rgba(255, 95, 95, 0.56);
|
||||
--news-breaking-hue: rgb(255 95 95);
|
||||
}
|
||||
|
||||
.news-story-card--breaking-watch::after,
|
||||
.news-story-card--breaking-breaking::after,
|
||||
.news-story-card--breaking-critical::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
105deg,
|
||||
transparent 32%,
|
||||
var(--news-breaking-color) 49%,
|
||||
transparent 66%
|
||||
) -100% 0 / 200% 100% no-repeat;
|
||||
mix-blend-mode: screen;
|
||||
opacity: 0;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.news-story-card--breaking-watch:hover::after,
|
||||
.news-story-card--breaking-watch:focus-visible::after,
|
||||
.news-story-card--breaking-breaking:hover::after,
|
||||
.news-story-card--breaking-breaking:focus-visible::after,
|
||||
.news-story-card--breaking-critical:hover::after,
|
||||
.news-story-card--breaking-critical:focus-visible::after {
|
||||
animation: earthNewsBreakingCardReflection 3.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.news-story-card--breaking-global {
|
||||
@@ -624,6 +748,19 @@
|
||||
padding: calc(16px * var(--hud-scale)) calc(4px * var(--hud-scale));
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.earth-news-ticker.is-breaking-active::after,
|
||||
.news-story-card--breaking-watch:hover::after,
|
||||
.news-story-card--breaking-watch:focus-visible::after,
|
||||
.news-story-card--breaking-breaking:hover::after,
|
||||
.news-story-card--breaking-breaking:focus-visible::after,
|
||||
.news-story-card--breaking-critical:hover::after,
|
||||
.news-story-card--breaking-critical:focus-visible::after {
|
||||
animation: none;
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.earth-news-ticker {
|
||||
--news-ticker-width: min(calc(760px * var(--hud-scale)), 66vw);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
SURFACE_AVOIDANCE_PROFILES,
|
||||
} from "./interactable.js";
|
||||
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
|
||||
import { isSurfaceAnchorVisible } from "./surface-marker-occlusion.js";
|
||||
|
||||
const bgpGroup = new THREE.Group();
|
||||
const bgpOverlayGroup = new THREE.Group();
|
||||
@@ -22,7 +23,6 @@ let totalAnomalyCount = 0;
|
||||
let totalIncidentCount = 0;
|
||||
let textureCache = null;
|
||||
let eventRingTextureCache = null;
|
||||
let activeEventOverlay = null;
|
||||
let activeCollectorOverlayContext = null;
|
||||
const relativeTimeFormatter = new Intl.RelativeTimeFormat("zh-CN", {
|
||||
numeric: "auto",
|
||||
@@ -42,6 +42,7 @@ const BGP_COLLECTOR_ICON_SOURCE = new URL("../assets/icons/bgp-broadcast-pin.svg
|
||||
const BGP_COLLECTOR_HOVER_SCALE = 1.08;
|
||||
const BGP_COLLECTOR_LOCKED_SCALE = 1.12;
|
||||
const BGP_COLLECTOR_PULSE_AMPLITUDE = 0.14;
|
||||
const bgpCameraLocalScratch = new THREE.Vector3();
|
||||
|
||||
bgpOverlayGroup.name = "bgp-overlay-root";
|
||||
bgpEventOverlayGroup.name = "bgp-event-overlay-layer";
|
||||
@@ -279,6 +280,7 @@ const bgpEventIconLayer = createInteractableLayer({
|
||||
coordinates: "canvas",
|
||||
draw: drawBGPEventIcon,
|
||||
},
|
||||
surfaceAnchorOcclusion: true,
|
||||
getPosition: (item) => ({
|
||||
latitude: item.latitude,
|
||||
longitude: item.longitude,
|
||||
@@ -905,6 +907,16 @@ function createOverlaySprite({ color, opacity, scale }) {
|
||||
return sprite;
|
||||
}
|
||||
|
||||
function updateEventHaloVisibility(camera) {
|
||||
const earth = bgpEventOverlayGroup.parent?.parent;
|
||||
if (!earth || !camera || bgpEventOverlayGroup.children.length === 0) return;
|
||||
bgpCameraLocalScratch.copy(camera.position);
|
||||
earth.worldToLocal(bgpCameraLocalScratch);
|
||||
bgpEventOverlayGroup.children.forEach((halo) => {
|
||||
halo.visible = isSurfaceAnchorVisible(halo.position, bgpCameraLocalScratch, CONFIG.earthRadius);
|
||||
});
|
||||
}
|
||||
|
||||
function projectLatLon(lat, lon, bearingDeg, distanceDeg) {
|
||||
const latRad = (lat * Math.PI) / 180;
|
||||
const lonRad = (lon * Math.PI) / 180;
|
||||
@@ -1361,6 +1373,7 @@ export async function loadBGPAnomalies(scene, earth) {
|
||||
|
||||
export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cruiseMarker = null) {
|
||||
const now = performance.now();
|
||||
updateEventHaloVisibility(camera);
|
||||
updateCollectorOverlayScan(lockedObjectType, lockedObject);
|
||||
syncBGPLegendEffects();
|
||||
const hasLockedLayer = Boolean(
|
||||
@@ -1686,8 +1699,6 @@ export function showBGPEventOverlay(marker, earth) {
|
||||
typeof region?.longitude === "number",
|
||||
);
|
||||
if (validRegions.length === 0) return;
|
||||
const overlayItems = [];
|
||||
|
||||
validRegions.forEach((region) => {
|
||||
const eventBaseColor = marker.userData.baseColor || getSeverityColor(marker.userData.severity);
|
||||
const halo = createOverlaySprite({
|
||||
@@ -1695,19 +1706,19 @@ export function showBGPEventOverlay(marker, earth) {
|
||||
opacity: 0.24,
|
||||
scale: BGP_CONFIG.regionScale,
|
||||
});
|
||||
halo.position.copy(
|
||||
latLonToVector3(
|
||||
region.latitude,
|
||||
region.longitude,
|
||||
CONFIG.earthRadius + BGP_COLLECTOR_ALTITUDE_OFFSET - 0.1,
|
||||
),
|
||||
const anchor = latLonToVector3(
|
||||
region.latitude,
|
||||
region.longitude,
|
||||
CONFIG.earthRadius + BGP_CONFIG.altitudeOffset,
|
||||
);
|
||||
halo.renderOrder = 2;
|
||||
halo.position.copy(anchor);
|
||||
// Like marker badges, a visible anchor owns the entire billboard. Per-pixel
|
||||
// Earth depth would cut this camera-facing gradient through the surface.
|
||||
halo.material.depthTest = false;
|
||||
halo.renderOrder = BGP_EVENT_RENDER_ORDER;
|
||||
bgpEventOverlayGroup.add(halo);
|
||||
overlayItems.push(halo);
|
||||
});
|
||||
|
||||
activeEventOverlay = overlayItems;
|
||||
activeCollectorOverlayContext = null;
|
||||
bgpOverlayGroup.visible = showBGP;
|
||||
}
|
||||
@@ -1767,7 +1778,6 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
|
||||
innerRing.renderOrder = 3;
|
||||
bgpCollectorRadarGroup.add(innerRing);
|
||||
|
||||
const overlayItems = [halo, pulseHalo, innerRing];
|
||||
const anchorLatitude = marker.userData.displayLatitude ?? marker.userData.latitude;
|
||||
const anchorLongitude = marker.userData.displayLongitude ?? marker.userData.longitude;
|
||||
const activityMagnitude = Math.log2(prefixCount + observationCount + 1);
|
||||
@@ -1823,7 +1833,6 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
|
||||
);
|
||||
sectorFill.renderOrder = 2;
|
||||
bgpCollectorRadarGroup.add(sectorFill);
|
||||
overlayItems.push(sectorFill);
|
||||
|
||||
const outerArc = createCoverageBoundaryLine(
|
||||
outerArcPoints,
|
||||
@@ -1832,7 +1841,6 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
|
||||
);
|
||||
outerArc.renderOrder = 3;
|
||||
bgpCollectorRadarGroup.add(outerArc);
|
||||
overlayItems.push(outerArc);
|
||||
|
||||
const leftBoundary = createCoverageBoundaryLine(
|
||||
leftBoundaryPoints,
|
||||
@@ -1841,7 +1849,6 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
|
||||
);
|
||||
leftBoundary.renderOrder = 3;
|
||||
bgpCollectorRadarGroup.add(leftBoundary);
|
||||
overlayItems.push(leftBoundary);
|
||||
|
||||
const rightBoundary = createCoverageBoundaryLine(
|
||||
rightBoundaryPoints,
|
||||
@@ -1850,9 +1857,7 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
|
||||
);
|
||||
rightBoundary.renderOrder = 3;
|
||||
bgpCollectorRadarGroup.add(rightBoundary);
|
||||
overlayItems.push(rightBoundary);
|
||||
|
||||
activeEventOverlay = overlayItems;
|
||||
activeCollectorOverlayContext = {
|
||||
marker,
|
||||
earth,
|
||||
@@ -1863,7 +1868,6 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
|
||||
}
|
||||
|
||||
export function clearBGPEventOverlay() {
|
||||
activeEventOverlay = null;
|
||||
activeCollectorOverlayContext = null;
|
||||
clearGroup(bgpEventOverlayGroup);
|
||||
clearGroup(bgpCollectorRadarGroup);
|
||||
|
||||
@@ -490,6 +490,7 @@ export function createInteractableLayer(options = {}) {
|
||||
avoidance = false,
|
||||
cluster = undefined,
|
||||
icon,
|
||||
surfaceAnchorOcclusion = false,
|
||||
getPosition,
|
||||
getKind = (item) => item?.type || "default",
|
||||
getRotationBin = () => 0,
|
||||
@@ -548,7 +549,9 @@ export function createInteractableLayer(options = {}) {
|
||||
const hoverScale = stateScale.hover ?? 1;
|
||||
const lockedScale = stateScale.locked ?? 1;
|
||||
const dimmedScale = stateScale.dimmed ?? 1;
|
||||
const usesSurfaceAnchorOcclusion = Boolean(icon.badge);
|
||||
// Rounded badges enable this implicitly, while other icon styles can opt
|
||||
// into the same whole-billboard surface behavior without changing artwork.
|
||||
const usesSurfaceAnchorOcclusion = Boolean(surfaceAnchorOcclusion || icon.badge);
|
||||
const depthTest = usesSurfaceAnchorOcclusion ? false : material.depthTest ?? true;
|
||||
const depthWrite = material.depthWrite ?? false;
|
||||
const alphaTest = material.alphaTest ?? 0.01;
|
||||
|
||||
@@ -27,3 +27,9 @@ export function pickNearestInteractiveHit(groups = []) {
|
||||
);
|
||||
return candidates[0] || null;
|
||||
}
|
||||
|
||||
export function pickNearestPointCandidate(candidates = []) {
|
||||
return candidates
|
||||
.filter(candidate => candidate?.object && Number.isFinite(candidate.distancePxSq))
|
||||
.sort((left, right) => left.distancePxSq - right.distancePxSq)[0] || null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { pickNearestInteractiveHit } from "./interactive-picking.js";
|
||||
import { pickNearestInteractiveHit, pickNearestPointCandidate } from "./interactive-picking.js";
|
||||
|
||||
const hit = (object, distancePxSq, cluster = false) => ({ object, distancePxSq, cluster });
|
||||
|
||||
@@ -23,3 +23,10 @@ test("cluster hits are ignored while pointer eligibility remains true for real m
|
||||
{ type: "bgp_collector", hits: [hit("rrc22", 9)] },
|
||||
]).object).toBe("rrc22");
|
||||
});
|
||||
|
||||
test("nearest point candidate is resolved before the line fallback", () => {
|
||||
expect(pickNearestPointCandidate([
|
||||
{ type: "compute_center", object: "compute", distancePxSq: 16 },
|
||||
{ type: "satellite", object: "satellite", distancePxSq: 0 },
|
||||
]).type).toBe("satellite");
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import { applyBGPLegendFilter } from "./bgp.js";
|
||||
import { applyCountryBoundaryLegendFilter } from "./country-boundaries.js";
|
||||
import { isSatelliteCategoryVisible, updateSatelliteLabels } from "./satellites.js";
|
||||
import { beginFrameStats, finishFrameStats } from "./frame-stats.js";
|
||||
import { pickNearestInteractiveHit } from "./interactive-picking.js";
|
||||
import { pickNearestInteractiveHit, pickNearestPointCandidate } from "./interactive-picking.js";
|
||||
import { createScreenSpaceDrag, globeDragRotationFactor } from "./globe-navigation.js";
|
||||
import * as THREE from "three";
|
||||
|
||||
@@ -5816,23 +5816,33 @@ function onMouseMove(event) {
|
||||
vesselIntersects,
|
||||
earthInteractableIntersects,
|
||||
});
|
||||
const satelliteCandidate = satIntersects.length > 0
|
||||
? {
|
||||
type: "satellite",
|
||||
index: satIntersects[0].index,
|
||||
object: getSatelliteAt(satIntersects[0].index),
|
||||
screen: { x: event.clientX, y: event.clientY },
|
||||
distancePxSq: satIntersects[0].distanceSq,
|
||||
}
|
||||
: null;
|
||||
const pointPick = pickNearestPointCandidate([interactivePick, satelliteCandidate]);
|
||||
if (renderer?.domElement) {
|
||||
renderer.domElement.style.cursor =
|
||||
interactivePick || satIntersects?.length || cableIntersects?.length
|
||||
pointPick || cableIntersects?.length
|
||||
? "pointer"
|
||||
: "default";
|
||||
}
|
||||
|
||||
let hoveredSat = null;
|
||||
let hoveredSatIndexFromIntersect = null;
|
||||
if (satIntersects.length > 0) {
|
||||
const satIndex = satIntersects[0].index;
|
||||
if (pointPick?.type === "satellite") {
|
||||
const satIndex = pointPick.index;
|
||||
hoveredSatIndexFromIntersect = satIndex;
|
||||
hoveredSat = selectSatellite(satIndex);
|
||||
}
|
||||
|
||||
const hoveredBGPMarker =
|
||||
interactivePick?.type === "bgp" || interactivePick?.type === "bgp_collector"
|
||||
pointPick?.type === "bgp" || pointPick?.type === "bgp_collector"
|
||||
? getPrimaryBGPHoverTarget(bgpAnomalyIntersects, bgpCollectorIntersects)
|
||||
: null;
|
||||
|
||||
@@ -5842,11 +5852,11 @@ function onMouseMove(event) {
|
||||
}
|
||||
|
||||
const hoveredComputeCenterMarker =
|
||||
interactivePick?.type === "compute_center" ? interactivePick.object : null;
|
||||
pointPick?.type === "compute_center" ? pointPick.object : null;
|
||||
const hoveredVesselMarker =
|
||||
interactivePick?.type === "vessel" && vesselPick.checked ? interactivePick.object : null;
|
||||
pointPick?.type === "vessel" && vesselPick.checked ? pointPick.object : null;
|
||||
const hoveredEarthInteractableMarker =
|
||||
interactivePick?.type === "earth_interactable" ? interactivePick.object : null;
|
||||
pointPick?.type === "earth_interactable" ? pointPick.object : null;
|
||||
const earthPoint = screenToEarthCoords(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
@@ -5962,15 +5972,7 @@ function onMouseMove(event) {
|
||||
getEarthInteractableBriefHtml(hoveredEarthInteractableMarker),
|
||||
);
|
||||
objectTooltipShown = true;
|
||||
} else if (cableIntersects.length > 0 && getShowCables()) {
|
||||
const cable = cableIntersects[0].object;
|
||||
hoveredCable = cable;
|
||||
if (!isSameCable(cable, lockedObject)) {
|
||||
setCableState(cable.userData.cableId, CABLE_STATE.HOVERED);
|
||||
}
|
||||
showTooltip(event.clientX + TOOLTIP_CURSOR_OFFSET, event.clientY + TOOLTIP_CURSOR_OFFSET, getCableBriefHtml(cable));
|
||||
objectTooltipShown = true;
|
||||
} else if (hoveredSat?.properties) {
|
||||
} else if (hoveredSat?.properties && pointPick?.type === "satellite") {
|
||||
hoveredSatellite = hoveredSat;
|
||||
hoveredSatelliteIndex = hoveredSatIndexFromIntersect;
|
||||
if (hoveredSatelliteIndex !== lockedSatelliteIndex) {
|
||||
@@ -5986,6 +5988,14 @@ function onMouseMove(event) {
|
||||
setHoveredSatelliteIndex(hoveredSatelliteIndex);
|
||||
showTooltip(event.clientX + TOOLTIP_CURSOR_OFFSET, event.clientY + TOOLTIP_CURSOR_OFFSET, getSatelliteBriefHtml(hoveredSat.properties));
|
||||
objectTooltipShown = true;
|
||||
} else if (cableIntersects.length > 0 && getShowCables()) {
|
||||
const cable = cableIntersects[0].object;
|
||||
hoveredCable = cable;
|
||||
if (!isSameCable(cable, lockedObject)) {
|
||||
setCableState(cable.userData.cableId, CABLE_STATE.HOVERED);
|
||||
}
|
||||
showTooltip(event.clientX + TOOLTIP_CURSOR_OFFSET, event.clientY + TOOLTIP_CURSOR_OFFSET, getCableBriefHtml(cable));
|
||||
objectTooltipShown = true;
|
||||
} else if (lockedObjectType === "bgp" && lockedObject) {
|
||||
applyBGPHoverState(lockedObject);
|
||||
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
|
||||
@@ -6239,6 +6249,23 @@ function onClick(event) {
|
||||
vesselIntersects,
|
||||
earthInteractableIntersects,
|
||||
});
|
||||
const satelliteCandidate = satIntersects.length > 0
|
||||
? {
|
||||
type: "satellite",
|
||||
index: satIntersects[0].index,
|
||||
object: getSatelliteAt(satIntersects[0].index),
|
||||
screen: { x: event.clientX, y: event.clientY },
|
||||
distancePxSq: satIntersects[0].distanceSq,
|
||||
}
|
||||
: null;
|
||||
const pointPick = pickNearestPointCandidate([interactivePick, satelliteCandidate]);
|
||||
|
||||
if (pointPick?.type === "satellite") {
|
||||
if (confirmInteractionCandidate(pointPick, { showConfirmationFeedback: false })) {
|
||||
showSelectionStatus(getInteractionCandidateLabel(pointPick));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (interactivePick) {
|
||||
let marker = interactivePick.object;
|
||||
@@ -6272,21 +6299,6 @@ function onClick(event) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (satIntersects.length > 0) {
|
||||
const hit = satIntersects[0];
|
||||
const candidate = {
|
||||
type: "satellite",
|
||||
index: hit.index,
|
||||
object: getSatelliteAt(hit.index),
|
||||
screen: { x: event.clientX, y: event.clientY },
|
||||
distancePxSq: hit.distancePxSq,
|
||||
};
|
||||
if (confirmInteractionCandidate(candidate, { showConfirmationFeedback: false })) {
|
||||
showSelectionStatus(getInteractionCandidateLabel(candidate));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCruiseModeActive()) interruptCruisePresentation({ resetLoop: true });
|
||||
clearLockedObject();
|
||||
hideInfoCard();
|
||||
|
||||
@@ -82,6 +82,25 @@ test('projected BGP symbols bake the same category color as GPU vertex colors',
|
||||
} finally {layer?.clearData();globalThis.document=oldDocument;globalThis.window=oldWindow;}
|
||||
});
|
||||
|
||||
test('surface anchor occlusion can preserve a custom icon drawing style', () => {
|
||||
const oldDocument = globalThis.document, oldWindow = globalThis.window;
|
||||
globalThis.window = { innerWidth: 1280, innerHeight: 720, devicePixelRatio: 1 };
|
||||
globalThis.document = { createElement: () => ({ width: 128, height: 128, getContext: () => new Proxy({}, { get: () => () => {} }) }) };
|
||||
let layer;
|
||||
try {
|
||||
layer = createInteractableLayer({ id: 'surface-anchor-style-test', cluster: false,
|
||||
surfaceAnchorOcclusion: true, getPosition: item => item,
|
||||
icon: { draw() {} } });
|
||||
const earth = new THREE.Object3D(), camera = new THREE.PerspectiveCamera(75, 1, .1, 5000);
|
||||
camera.position.z = 350; camera.updateMatrixWorld();
|
||||
layer.attach(earth); layer.setVisible(true); layer.setData([{ id: 'custom', latitude: 0, longitude: -90 }]);
|
||||
layer.updateVisualState(null, null, camera);
|
||||
const points = layer.group.children.find(group => group.children.some(child => child.isPoints)).children.find(child => child.isPoints);
|
||||
expect(points.material.depthTest).toBe(false);
|
||||
expect(points.material.defines.USE_SURFACE_ANCHOR_OCCLUSION).toBe('');
|
||||
} finally { layer?.clearData(); globalThis.document = oldDocument; globalThis.window = oldWindow; }
|
||||
});
|
||||
|
||||
|
||||
test('selected pulses do not refresh unchanged batches; state changes still do', () => {
|
||||
const oldDocument=globalThis.document,oldWindow=globalThis.window;
|
||||
|
||||
31
frontend/public/earth/js/news-breaking.js
Normal file
31
frontend/public/earth/js/news-breaking.js
Normal file
@@ -0,0 +1,31 @@
|
||||
const BREAKING_RANKS = { none: 0, watch: 1, breaking: 2, critical: 3 };
|
||||
|
||||
export function normalizeBreakingLevel(level) {
|
||||
const normalized = String(level ?? "").trim().toLowerCase();
|
||||
return Object.hasOwn(BREAKING_RANKS, normalized) ? normalized : "none";
|
||||
}
|
||||
|
||||
export function isBreakingActive(item, now = Date.now()) {
|
||||
if (normalizeBreakingLevel(item?.breaking_level) === "none") return false;
|
||||
const expiry = Date.parse(item?.breaking_expires_at);
|
||||
return !Number.isFinite(expiry) || expiry > now;
|
||||
}
|
||||
|
||||
export function getNewsBreakingState(payload, now = Date.now()) {
|
||||
const items = [
|
||||
...(Array.isArray(payload?.items) ? payload.items : []),
|
||||
...(Array.isArray(payload?.cruise_items) ? payload.cruise_items : []),
|
||||
];
|
||||
let level = "none";
|
||||
let expiresAt = null;
|
||||
for (const item of items) {
|
||||
if (!isBreakingActive(item, now)) continue;
|
||||
const itemLevel = normalizeBreakingLevel(item.breaking_level);
|
||||
if (BREAKING_RANKS[itemLevel] > BREAKING_RANKS[level]) level = itemLevel;
|
||||
const expiry = Date.parse(item.breaking_expires_at);
|
||||
if (Number.isFinite(expiry) && (expiresAt === null || expiry < expiresAt)) expiresAt = expiry;
|
||||
}
|
||||
// The server summary is derived from these same items, but can outlive their
|
||||
// expiry in a cached payload. Recompute from the timestamped source records.
|
||||
return { level, expiresAt };
|
||||
}
|
||||
17
frontend/public/earth/js/news-breaking.test.js
Normal file
17
frontend/public/earth/js/news-breaking.test.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { getNewsBreakingState, isBreakingActive, normalizeBreakingLevel } from "./news-breaking.js";
|
||||
|
||||
test("breaking state ranks active items and ignores expired levels", () => {
|
||||
const now = Date.parse("2026-10-01T00:00:00Z");
|
||||
expect(normalizeBreakingLevel("CRITICAL")).toBe("critical");
|
||||
expect(normalizeBreakingLevel("unknown")).toBe("none");
|
||||
expect(isBreakingActive({ breaking_level: "breaking", breaking_expires_at: "2026-09-30T23:59:59Z" }, now)).toBe(false);
|
||||
expect(getNewsBreakingState({ items: [
|
||||
{ breaking_level: "watch", breaking_expires_at: "2026-10-01T01:00:00Z" },
|
||||
{ breaking_level: "critical", breaking_expires_at: "2026-09-30T23:59:59Z" },
|
||||
] }, now)).toEqual({ level: "watch", expiresAt: Date.parse("2026-10-01T01:00:00Z") });
|
||||
});
|
||||
|
||||
test("breaking state stays neutral when no item is active", () => {
|
||||
expect(getNewsBreakingState({ items: [{ breaking_level: "none" }] }, Date.now())).toEqual({ level: "none", expiresAt: null });
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { showStatusMessage } from "./ui.js";
|
||||
import { getNewsBreakingState, isBreakingActive, normalizeBreakingLevel } from "./news-breaking.js";
|
||||
import {
|
||||
applyEarthI18n,
|
||||
earthMessage,
|
||||
@@ -58,6 +59,11 @@ let lastFetchAt = 0;
|
||||
let lastRegionSwitchAt = 0;
|
||||
let selectedCruiseStoryId = null;
|
||||
let morphTimer = null;
|
||||
let morphFrame = null;
|
||||
let panelPhase = "closed";
|
||||
let deferredPanelPayload = null;
|
||||
let breakingExpiryTimer = null;
|
||||
let tickerBreakingTimer = null;
|
||||
let newsRealtimeSocket = null;
|
||||
let newsRealtimeReconnectTimer = null;
|
||||
let activeNewsCategoryFilters = null;
|
||||
@@ -166,9 +172,10 @@ function getHudScale() {
|
||||
}
|
||||
|
||||
function clearMorphTimer() {
|
||||
if (!morphTimer) return;
|
||||
window.clearTimeout(morphTimer);
|
||||
if (morphTimer !== null) window.clearTimeout(morphTimer);
|
||||
if (morphFrame !== null) cancelAnimationFrame(morphFrame);
|
||||
morphTimer = null;
|
||||
morphFrame = null;
|
||||
}
|
||||
|
||||
function setHudRect(hud, rect, opacity = 1) {
|
||||
@@ -202,12 +209,16 @@ function getNewsHudTargetRect(hud) {
|
||||
}
|
||||
|
||||
function finishHudOpen(hud) {
|
||||
panelPhase = "open";
|
||||
hud.classList.remove("is-morphing");
|
||||
hud.style.opacity = "";
|
||||
revealSelectedCruiseStory();
|
||||
}
|
||||
|
||||
function finishHudClose(hud, ticker, restoreRect = null) {
|
||||
const pendingPayload = deferredPanelPayload;
|
||||
deferredPanelPayload = null;
|
||||
panelPhase = "closed";
|
||||
hud.classList.add("hud-panel-hidden");
|
||||
hud.classList.remove("is-morphing");
|
||||
hud.style.opacity = "";
|
||||
@@ -222,12 +233,25 @@ function finishHudClose(hud, ticker, restoreRect = null) {
|
||||
hud.style.transform = "";
|
||||
}
|
||||
ticker?.classList.remove("is-hidden");
|
||||
setTickerPlayback(true);
|
||||
if (pendingPayload) renderPayload(pendingPayload, { forcePanelRender: true });
|
||||
}
|
||||
|
||||
function setTickerPlayback(running) {
|
||||
const { tickerTrack } = getElements();
|
||||
if (!(tickerTrack instanceof HTMLElement)) return;
|
||||
tickerTrack.style.animationPlayState = running ? "" : "paused";
|
||||
}
|
||||
|
||||
function setNewsHudOpen(open, { highlightId = null } = {}) {
|
||||
const { hud, ticker } = getElements();
|
||||
if (!(hud instanceof HTMLElement)) return;
|
||||
|
||||
if (!open && (panelPhase === "closed" || panelPhase === "closing")) return;
|
||||
if (open && (panelPhase === "open" || panelPhase === "opening")) {
|
||||
if (highlightId) selectNewsItem(highlightId, { scrollIntoView: true });
|
||||
return;
|
||||
}
|
||||
clearMorphTimer();
|
||||
if (highlightId) {
|
||||
selectedCruiseStoryId = highlightId;
|
||||
@@ -239,12 +263,15 @@ function setNewsHudOpen(open, { highlightId = null } = {}) {
|
||||
: { left: window.innerWidth / 2 - 240, top: 20, width: 480, height: 38 };
|
||||
|
||||
if (open) {
|
||||
panelPhase = "opening";
|
||||
const targetRect = getNewsHudTargetRect(hud);
|
||||
hud.classList.remove("hud-panel-hidden");
|
||||
hud.classList.add("is-morphing");
|
||||
setHudRectWithoutTransition(hud, tickerRect, 1);
|
||||
ticker?.classList.add("is-hidden");
|
||||
requestAnimationFrame(() => {
|
||||
setTickerPlayback(false);
|
||||
morphFrame = requestAnimationFrame(() => {
|
||||
morphFrame = null;
|
||||
setHudRect(hud, targetRect, 1);
|
||||
});
|
||||
morphTimer = window.setTimeout(() => {
|
||||
@@ -252,10 +279,14 @@ function setNewsHudOpen(open, { highlightId = null } = {}) {
|
||||
finishHudOpen(hud);
|
||||
}, NEWS_HUD_MORPH_MS);
|
||||
} else {
|
||||
panelPhase = "closing";
|
||||
clearSelectedNewsItem();
|
||||
closeNewsFilterPopover();
|
||||
const currentRect = hud.getBoundingClientRect();
|
||||
hud.classList.add("is-morphing");
|
||||
setHudRectWithoutTransition(hud, currentRect, 1);
|
||||
requestAnimationFrame(() => {
|
||||
morphFrame = requestAnimationFrame(() => {
|
||||
morphFrame = null;
|
||||
setHudRect(hud, tickerRect, 1);
|
||||
});
|
||||
morphTimer = window.setTimeout(() => {
|
||||
@@ -387,52 +418,22 @@ function getDisplayableNewsItems(items) {
|
||||
: [];
|
||||
}
|
||||
|
||||
function normalizeBreakingLevel(level) {
|
||||
const normalized = String(level ?? "").trim().toLowerCase();
|
||||
return ["watch", "breaking", "critical"].includes(normalized) ? normalized : "none";
|
||||
}
|
||||
|
||||
function normalizeBreakingScope(scope) {
|
||||
const normalized = String(scope ?? "").trim().toLowerCase();
|
||||
return normalized === "global" ? "global" : "regional";
|
||||
}
|
||||
|
||||
function isBreakingActive(item) {
|
||||
const level = normalizeBreakingLevel(item?.breaking_level);
|
||||
if (level === "none") return false;
|
||||
const expiresAt = item?.breaking_expires_at ? new Date(item.breaking_expires_at) : null;
|
||||
return !expiresAt || Number.isNaN(expiresAt.getTime()) || expiresAt.getTime() > Date.now();
|
||||
}
|
||||
|
||||
function getHighestBreakingLevel(items) {
|
||||
const ranks = { none: 0, watch: 1, breaking: 2, critical: 3 };
|
||||
return (Array.isArray(items) ? items : []).reduce((highest, item) => {
|
||||
if (!isBreakingActive(item)) return highest;
|
||||
const level = normalizeBreakingLevel(item?.breaking_level);
|
||||
return ranks[level] > ranks[highest] ? level : highest;
|
||||
}, "none");
|
||||
}
|
||||
|
||||
function applyNewsBreakingShellState(nextPayload) {
|
||||
const filtersLevel = normalizeBreakingLevel(nextPayload?.filters?.highest_breaking_level);
|
||||
const computedLevel = getHighestBreakingLevel([
|
||||
...(Array.isArray(nextPayload?.items) ? nextPayload.items : []),
|
||||
...(Array.isArray(nextPayload?.cruise_items) ? nextPayload.cruise_items : []),
|
||||
]);
|
||||
const level = filtersLevel !== "none" ? filtersLevel : computedLevel;
|
||||
document
|
||||
.querySelectorAll(".earth-news-hud, .earth-news-ticker, .earth-mobile-news-board")
|
||||
.forEach((element) => {
|
||||
if (!(element instanceof HTMLElement)) return;
|
||||
element.classList.remove(
|
||||
"has-breaking-watch",
|
||||
"has-breaking-breaking",
|
||||
"has-breaking-critical",
|
||||
);
|
||||
if (level !== "none") {
|
||||
element.classList.add(`has-breaking-${level}`);
|
||||
}
|
||||
});
|
||||
if (breakingExpiryTimer !== null) window.clearTimeout(breakingExpiryTimer);
|
||||
breakingExpiryTimer = null;
|
||||
const { expiresAt } = getNewsBreakingState(nextPayload);
|
||||
scheduleTickerBreakingWatcher();
|
||||
if (expiresAt !== null) {
|
||||
breakingExpiryTimer = window.setTimeout(() => {
|
||||
breakingExpiryTimer = null;
|
||||
if (payload === nextPayload) renderPayload(payload);
|
||||
}, Math.max(0, Math.min(expiresAt - Date.now(), 2147483647)));
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeNewsSourceType(value) {
|
||||
@@ -738,6 +739,7 @@ function renderTicker(nextPayload) {
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
stopTickerBreakingWatcher();
|
||||
tickerTrack.textContent = translateText("正在准备全球态势新闻...");
|
||||
tickerTrack.style.removeProperty("--news-ticker-duration");
|
||||
return;
|
||||
@@ -745,23 +747,81 @@ function renderTicker(nextPayload) {
|
||||
|
||||
const visibleItems = getDisplayableNewsItems(items).slice(0, 6);
|
||||
if (visibleItems.length === 0) {
|
||||
stopTickerBreakingWatcher();
|
||||
tickerTrack.textContent = translateText("当前新闻类型没有可显示新闻...");
|
||||
tickerTrack.style.removeProperty("--news-ticker-duration");
|
||||
return;
|
||||
}
|
||||
const tickerItems = [...visibleItems, ...visibleItems];
|
||||
tickerTrack.innerHTML = tickerItems
|
||||
.map((item) => `
|
||||
<span class="earth-news-ticker__item" data-news-id="${escapeTickerText(item.id || "")}">
|
||||
.map((item) => {
|
||||
const breakingLevel = isBreakingActive(item) ? normalizeBreakingLevel(item.breaking_level) : "none";
|
||||
const breakingClass = breakingLevel === "none" ? "" : ` earth-news-ticker__item--breaking-${breakingLevel}`;
|
||||
return `
|
||||
<span class="earth-news-ticker__item${breakingClass}" data-news-id="${escapeTickerText(item.id || "")}" data-breaking-level="${breakingLevel}">
|
||||
<span class="earth-news-ticker__source">${escapeTickerText(getNewsSourceNameLabel({
|
||||
id: item.source_id,
|
||||
name: item.source || item.feed_name || "NEWS",
|
||||
}))}</span>
|
||||
<span>${escapeTickerText(getNewsDisplaySummary(item))}</span>
|
||||
</span>
|
||||
`)
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
tickerTrack.style.setProperty("--news-ticker-duration", `${Math.max(22, visibleItems.length * 7)}s`);
|
||||
scheduleTickerBreakingWatcher();
|
||||
}
|
||||
|
||||
function stopTickerBreakingWatcher() {
|
||||
if (tickerBreakingTimer !== null) window.clearTimeout(tickerBreakingTimer);
|
||||
tickerBreakingTimer = null;
|
||||
document.getElementById("desktop-news-ticker")?.classList.remove(
|
||||
"is-breaking-active",
|
||||
"has-breaking-watch",
|
||||
"has-breaking-breaking",
|
||||
"has-breaking-critical",
|
||||
);
|
||||
document.getElementById("desktop-news-ticker")?.removeAttribute("data-breaking-level");
|
||||
}
|
||||
|
||||
function updateTickerBreakingState() {
|
||||
const ticker = document.getElementById("desktop-news-ticker");
|
||||
const viewport = ticker?.querySelector(".earth-news-ticker__viewport");
|
||||
const items = viewport ? [...viewport.querySelectorAll(".earth-news-ticker__item")] : [];
|
||||
if (!(ticker instanceof HTMLElement) || !(viewport instanceof HTMLElement) || items.length === 0) {
|
||||
stopTickerBreakingWatcher();
|
||||
return;
|
||||
}
|
||||
const viewportRect = viewport.getBoundingClientRect();
|
||||
const viewportCenter = viewportRect.left + viewportRect.width / 2;
|
||||
let best = null;
|
||||
for (const item of items) {
|
||||
const rect = item.getBoundingClientRect();
|
||||
const overlap = Math.max(0, Math.min(rect.right, viewportRect.right) - Math.max(rect.left, viewportRect.left));
|
||||
if (overlap <= 0) continue;
|
||||
const distance = Math.abs((rect.left + rect.right) / 2 - viewportCenter);
|
||||
if (!best || overlap > best.overlap || (overlap === best.overlap && distance < best.distance)) {
|
||||
best = { overlap, distance, level: normalizeBreakingLevel(item.dataset.breakingLevel) };
|
||||
}
|
||||
}
|
||||
const currentLevel = ticker.dataset.breakingLevel || "none";
|
||||
const nextLevel = best
|
||||
? normalizeBreakingLevel(best.level)
|
||||
: currentLevel !== "none"
|
||||
? currentLevel
|
||||
: "none";
|
||||
ticker.classList.toggle("is-breaking-active", nextLevel !== "none");
|
||||
if (ticker.dataset.breakingLevel !== nextLevel) {
|
||||
ticker.classList.remove("has-breaking-watch", "has-breaking-breaking", "has-breaking-critical");
|
||||
if (nextLevel !== "none") ticker.classList.add(`has-breaking-${nextLevel}`);
|
||||
ticker.dataset.breakingLevel = nextLevel;
|
||||
}
|
||||
tickerBreakingTimer = window.setTimeout(updateTickerBreakingState, 120);
|
||||
}
|
||||
|
||||
function scheduleTickerBreakingWatcher() {
|
||||
if (tickerBreakingTimer !== null) return;
|
||||
tickerBreakingTimer = window.setTimeout(updateTickerBreakingState, 0);
|
||||
}
|
||||
|
||||
function renderEmptyState(message) {
|
||||
@@ -778,7 +838,7 @@ function renderEmptyState(message) {
|
||||
renderTicker({ items: [], focus: payload?.focus || { region: "global" } });
|
||||
}
|
||||
|
||||
function renderPayload(nextPayload) {
|
||||
function renderPayload(nextPayload, { forcePanelRender = false } = {}) {
|
||||
payload = nextPayload;
|
||||
reconcileNewsSourceFilters(nextPayload);
|
||||
const {
|
||||
@@ -811,6 +871,12 @@ function renderPayload(nextPayload) {
|
||||
syncFilterSummaries(nextPayload);
|
||||
applyNewsBreakingShellState(nextPayload);
|
||||
|
||||
if (!forcePanelRender && (panelPhase === "opening" || panelPhase === "open")) {
|
||||
deferredPanelPayload = nextPayload;
|
||||
return;
|
||||
}
|
||||
if (forcePanelRender) deferredPanelPayload = null;
|
||||
|
||||
if (!board || !status || !focusLabel || !focusCoords || !sourceCount || !regionChip) {
|
||||
if (document.body.classList.contains("layout-mode-mobile")) {
|
||||
// Mobile page omits the region chip shell, but the rest of the page is still renderable.
|
||||
@@ -1067,7 +1133,7 @@ async function fetchNews(lat, lon, context = {}) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function refreshNews(lat, lon, { silent = false } = {}) {
|
||||
async function refreshNews(lat, lon, { silent = false, forcePanelRender = false } = {}) {
|
||||
const targetRegion = inferRegion(lat, lon);
|
||||
const categorySignature = getNewsCategorySignature();
|
||||
const sourceSignature = getNewsSourceSignatureForFetch(lat, lon);
|
||||
@@ -1093,7 +1159,7 @@ async function refreshNews(lat, lon, { silent = false } = {}) {
|
||||
if (requestToken !== activeRefreshToken) {
|
||||
return nextPayload;
|
||||
}
|
||||
renderPayload(nextPayload);
|
||||
renderPayload(nextPayload, { forcePanelRender });
|
||||
lastFetchAt = Date.now();
|
||||
lastCategorySignature = getNewsCategorySignature();
|
||||
lastSourceSignature = getNewsSourceSignature(nextPayload);
|
||||
@@ -1333,7 +1399,7 @@ export function initNewsPanel() {
|
||||
const refreshBtn = document.getElementById(id);
|
||||
refreshBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
await refreshNews(lastFocus?.lat, lastFocus?.lon);
|
||||
await refreshNews(lastFocus?.lat, lastFocus?.lon, { forcePanelRender: true });
|
||||
showStatusMessage(earthMessage("status.newsRefresh", { ok: true }), "info");
|
||||
} catch {
|
||||
showStatusMessage(earthMessage("status.newsRefresh", { ok: false }), "error");
|
||||
|
||||
@@ -221,6 +221,9 @@ const vesselIconLayer = createInteractableLayer({
|
||||
const courseBin = getCourseBin(marker);
|
||||
return `${anchored ? "anchored" : "moving"}:${courseBin}`;
|
||||
},
|
||||
// Keep the complete heading arrow/dot visible when its surface anchor is
|
||||
// still on the front-facing limb, matching BGP and compute markers.
|
||||
surfaceAnchorOcclusion: true,
|
||||
getUserData: (item) => ({
|
||||
...item,
|
||||
vessel_kind: item.type,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.76.0"
|
||||
version = "0.76.18"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
Reference in New Issue
Block a user