Compare commits

...

2 Commits

Author SHA1 Message Date
rayd1o
28a7132eec release: bump version to 0.76.3
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
2026-10-01 17:48:45 +08:00
rayd1o
dd41588527 release: bump version to 0.76.2
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
2026-10-01 02:29:49 +08:00
15 changed files with 180 additions and 78 deletions

View File

@@ -1 +1 @@
0.76.1
0.76.3

View File

@@ -8,6 +8,34 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
- `bugfix` -> `+0.0.1`
## [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

View File

@@ -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.
@@ -436,7 +436,7 @@ Normal borders, coastlines, claim lines, highlights and landing points use `eart
### News ticker edge fading
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 ticker stays neutral; breaking levels style the expanded news panel as a whole. Selection, focus and close animation do not mutate the breaking data state, which is recomputed from item expiry timestamps.
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.

View File

@@ -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、预测轨道和同步降级路径使用,避免多套公式漂移。数据重载、清空和高度切换必须同步终止旧计算,不能让旧快照恢复已清空的图层。
@@ -569,7 +569,7 @@ Earth 原有 Roboto Condensed 字体已随 `assets/fonts/` 本地分发,许可
### 新闻栏端部渐变
新闻栏的底色与上下细线由独立伪元素绘制,两端在正文范围之外渐变到完全透明;外框不使用矩形 backdrop-filter、box-shadow 或文字遮罩。地区标签按内容占宽,正文紧接标签,滚动文字不做渐隐。警报状态只改变渐变细线的颜色,避免重新引入矩形阴影边界。
新闻栏的底色与上下细线由独立伪元素绘制,两端在正文范围之外渐变到完全透明;外框不使用矩形 backdrop-filter、box-shadow 或文字遮罩。地区标签按内容占宽,正文紧接标签,滚动文字不做渐隐。ticker 始终保持中性,breaking 等级只作用于展开的新闻面板整体背景/阴影;选中、焦点和关闭动画不改变 breaking 数据状态,过期后由时间戳重新计算并清除面板状态。
算力卡片外观直接复用共享 `.hud-panel` 的背景、边框、阴影和玻璃效果,摘要、基础信息及详情区不再叠加单独黑底。标题栏与图层面板共用 `.hud-panel__header--inset` / `.layer-panel-header` 的内边距;主数字继续突出,基础信息保持常显。

View File

@@ -16,12 +16,14 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.76.1`
- `dev` 当前开发分支历史推导到:`0.76.3`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
| `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、地表对齐及新闻编码修复,清理废弃代码文档 |

View File

@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
"version": "0.76.1",
"version": "0.76.3",
"private": true,
"packageManager": "bun@1",
"dependencies": {

View File

@@ -50,10 +50,6 @@
content: none;
}
.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__region {
position: relative;
z-index: 3;
@@ -503,6 +499,7 @@
.earth-news-hud.has-breaking-watch,
.earth-mobile-news-board.has-breaking-watch {
background: linear-gradient(180deg, rgba(72, 53, 26, 0.94), rgba(28, 24, 18, 0.96));
box-shadow:
0 0 0 1px rgba(255, 213, 128, 0.08) inset,
0 18px 46px rgba(156, 106, 20, 0.12);
@@ -510,6 +507,7 @@
.earth-news-hud.has-breaking-breaking,
.earth-mobile-news-board.has-breaking-breaking {
background: linear-gradient(180deg, rgba(82, 44, 28, 0.94), rgba(32, 22, 18, 0.96));
box-shadow:
0 0 0 1px rgba(255, 166, 102, 0.14) inset,
0 18px 48px rgba(185, 89, 32, 0.16);
@@ -517,6 +515,7 @@
.earth-news-hud.has-breaking-critical,
.earth-mobile-news-board.has-breaking-critical {
background: linear-gradient(180deg, rgba(76, 32, 38, 0.94), rgba(30, 18, 24, 0.96));
box-shadow:
0 0 0 1px rgba(255, 118, 118, 0.18) inset,
0 18px 52px rgba(172, 43, 43, 0.18);

View File

@@ -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;
}

View File

@@ -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");
});

View File

@@ -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();

View 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 };
}

View 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 });
});

View File

@@ -1,4 +1,5 @@
import { showStatusMessage } from "./ui.js";
import { getNewsBreakingState, isBreakingActive, normalizeBreakingLevel } from "./news-breaking.js";
import {
applyEarthI18n,
earthMessage,
@@ -58,6 +59,9 @@ let lastFetchAt = 0;
let lastRegionSwitchAt = 0;
let selectedCruiseStoryId = null;
let morphTimer = null;
let morphFrame = null;
let panelPhase = "closed";
let breakingExpiryTimer = null;
let newsRealtimeSocket = null;
let newsRealtimeReconnectTimer = null;
let activeNewsCategoryFilters = null;
@@ -166,9 +170,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 +207,14 @@ function getNewsHudTargetRect(hud) {
}
function finishHudOpen(hud) {
panelPhase = "open";
hud.classList.remove("is-morphing");
hud.style.opacity = "";
revealSelectedCruiseStory();
}
function finishHudClose(hud, ticker, restoreRect = null) {
panelPhase = "closed";
hud.classList.add("hud-panel-hidden");
hud.classList.remove("is-morphing");
hud.style.opacity = "";
@@ -228,6 +235,11 @@ 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 +251,14 @@ 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(() => {
morphFrame = requestAnimationFrame(() => {
morphFrame = null;
setHudRect(hud, targetRect, 1);
});
morphTimer = window.setTimeout(() => {
@@ -252,10 +266,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,41 +405,17 @@ 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;
if (breakingExpiryTimer !== null) window.clearTimeout(breakingExpiryTimer);
breakingExpiryTimer = null;
const { level, expiresAt } = getNewsBreakingState(nextPayload);
document
.querySelectorAll(".earth-news-hud, .earth-news-ticker, .earth-mobile-news-board")
.querySelectorAll(".earth-news-hud, .earth-mobile-news-board")
.forEach((element) => {
if (!(element instanceof HTMLElement)) return;
element.classList.remove(
@@ -433,6 +427,12 @@ function applyNewsBreakingShellState(nextPayload) {
element.classList.add(`has-breaking-${level}`);
}
});
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) {

View File

@@ -1,6 +1,6 @@
[project]
name = "planet"
version = "0.76.1"
version = "0.76.3"
description = "智能星球计划 - 态势感知系统"
requires-python = ">=3.14"
dependencies = [

2
uv.lock generated
View File

@@ -757,7 +757,7 @@ wheels = [
[[package]]
name = "planet"
version = "0.76.1"
version = "0.76.3"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },