release: bump version to 0.70.0
This commit is contained in:
@@ -14,12 +14,12 @@
|
||||
border-radius: 0;
|
||||
padding: 0 calc(18px * var(--hud-scale));
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
grid-template-columns: minmax(calc(112px * var(--hud-scale)), calc(152px * var(--hud-scale))) minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: calc(12px * var(--hud-scale));
|
||||
color: var(--hud-text);
|
||||
background:
|
||||
linear-gradient(90deg, transparent 0%, rgba(17, 31, 53, 0.82) 11%, rgba(7, 17, 31, 0.78) 89%, transparent 100%),
|
||||
linear-gradient(90deg, transparent 0%, rgba(17, 31, 53, 0.28) 4%, rgba(17, 31, 53, 0.82) 12%, rgba(7, 17, 31, 0.78) 88%, rgba(7, 17, 31, 0.26) 96%, transparent 100%),
|
||||
radial-gradient(circle at 50% -80%, rgba(145, 186, 255, 0.18), transparent 58%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08),
|
||||
@@ -30,6 +30,8 @@
|
||||
cursor: pointer;
|
||||
z-index: 24;
|
||||
overflow: hidden;
|
||||
-webkit-mask-image: linear-gradient(90deg, rgba(0, 0, 0, 0.84) 0%, #000 3%, #000 97%, rgba(0, 0, 0, 0.84) 100%);
|
||||
mask-image: linear-gradient(90deg, rgba(0, 0, 0, 0.84) 0%, #000 3%, #000 97%, rgba(0, 0, 0, 0.84) 100%);
|
||||
transition: opacity 0.18s ease, transform 0.24s ease;
|
||||
}
|
||||
|
||||
@@ -40,41 +42,30 @@
|
||||
|
||||
.earth-news-ticker::before,
|
||||
.earth-news-ticker::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
bottom: -1px;
|
||||
width: calc(96px * var(--hud-scale));
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.earth-news-ticker::before {
|
||||
left: 0;
|
||||
background: linear-gradient(90deg, rgba(0, 0, 0, 0), rgba(8, 18, 32, 0.08));
|
||||
}
|
||||
|
||||
.earth-news-ticker::after {
|
||||
right: 0;
|
||||
background: linear-gradient(270deg, rgba(0, 0, 0, 0), rgba(8, 18, 32, 0.08));
|
||||
content: none;
|
||||
}
|
||||
|
||||
.earth-news-ticker__region {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--hud-accent-strong);
|
||||
font-size: calc(0.66rem * var(--hud-scale));
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.earth-news-ticker__viewport {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 9%, #000 91%, transparent 100%);
|
||||
mask-image: linear-gradient(90deg, transparent 0%, #000 9%, #000 91%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent 0%, rgba(0, 0, 0, 0.22) 4%, rgba(0, 0, 0, 0.72) 11%, #000 18%, #000 82%, rgba(0, 0, 0, 0.72) 89%, rgba(0, 0, 0, 0.22) 96%, transparent 100%);
|
||||
mask-image: linear-gradient(90deg, transparent 0%, rgba(0, 0, 0, 0.22) 4%, rgba(0, 0, 0, 0.72) 11%, #000 18%, #000 82%, rgba(0, 0, 0, 0.72) 89%, rgba(0, 0, 0, 0.22) 96%, transparent 100%);
|
||||
}
|
||||
|
||||
.earth-news-ticker__track {
|
||||
@@ -490,6 +481,54 @@
|
||||
0 0 18px rgba(255, 184, 77, 0.12);
|
||||
}
|
||||
|
||||
.earth-news-hud.has-breaking-watch,
|
||||
.earth-news-ticker.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-news-ticker.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-news-ticker.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 {
|
||||
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-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-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-story-card--breaking-global {
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05) inset,
|
||||
0 0 18px rgba(255, 168, 96, 0.1);
|
||||
}
|
||||
|
||||
.news-story-meta,
|
||||
.news-story-tags {
|
||||
display: flex;
|
||||
@@ -548,6 +587,12 @@
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.news-story-tag--breaking {
|
||||
color: rgba(255, 232, 205, 0.96);
|
||||
border: 1px solid rgba(255, 184, 96, 0.24);
|
||||
background: rgba(255, 151, 78, 0.11);
|
||||
}
|
||||
|
||||
.news-board-empty {
|
||||
color: var(--hud-text-muted);
|
||||
font-size: calc(0.82rem * var(--hud-scale));
|
||||
@@ -555,6 +600,14 @@
|
||||
padding: calc(16px * var(--hud-scale)) calc(4px * var(--hud-scale));
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.earth-news-ticker {
|
||||
--news-ticker-width: min(calc(760px * var(--hud-scale)), 66vw);
|
||||
grid-template-columns: minmax(calc(86px * var(--hud-scale)), calc(118px * var(--hud-scale))) minmax(0, 1fr);
|
||||
padding-inline: calc(14px * var(--hud-scale));
|
||||
}
|
||||
}
|
||||
|
||||
.layout-mode-mobile .earth-news-ticker,
|
||||
.layout-mode-mobile .earth-news-hud {
|
||||
display: none !important;
|
||||
|
||||
@@ -280,7 +280,7 @@ export const PATHS = {
|
||||
|
||||
export const VESSEL_CONFIG = {
|
||||
altitudeOffset: 0.2,
|
||||
maxRenderedMarkers: 0,
|
||||
maxRenderedMarkers: 3000,
|
||||
marker: {
|
||||
baseScale: 7.5,
|
||||
baseOpacity: 0.88,
|
||||
|
||||
@@ -3896,7 +3896,8 @@ async function ensureVesselsEnabled() {
|
||||
if (!earth) return 0;
|
||||
|
||||
vesselsEnabled = true;
|
||||
const result = await loadVessels(scene, earth);
|
||||
const zoom = getZoomLevel();
|
||||
const result = await loadVessels(scene, earth, { zoom });
|
||||
toggleVessels(true);
|
||||
startVesselRealtime(earth, {
|
||||
onUpdate: ({ totalCount }) => {
|
||||
|
||||
@@ -31,6 +31,17 @@ const CATEGORY_LABELS = {
|
||||
other: "其他",
|
||||
};
|
||||
|
||||
const BREAKING_LEVEL_LABELS = {
|
||||
watch: "关注",
|
||||
breaking: "突发",
|
||||
critical: "严重突发",
|
||||
};
|
||||
|
||||
const BREAKING_SCOPE_LABELS = {
|
||||
regional: "区域",
|
||||
global: "全球",
|
||||
};
|
||||
|
||||
const SOURCE_TYPE_LABELS = {
|
||||
rss: "RSS",
|
||||
atom: "Atom",
|
||||
@@ -131,6 +142,14 @@ export function getNewsCategoryLabel(category) {
|
||||
return CATEGORY_LABELS[category] || category || CATEGORY_LABELS.other;
|
||||
}
|
||||
|
||||
export function getNewsBreakingLabel(level, scope = "regional") {
|
||||
const normalizedLevel = normalizeText(level).toLowerCase();
|
||||
if (!normalizedLevel || normalizedLevel === "none") return "";
|
||||
const levelLabel = BREAKING_LEVEL_LABELS[normalizedLevel] || level;
|
||||
const scopeLabel = BREAKING_SCOPE_LABELS[normalizeText(scope).toLowerCase()] || BREAKING_SCOPE_LABELS.regional;
|
||||
return `${scopeLabel}${levelLabel}`;
|
||||
}
|
||||
|
||||
export function getNewsSourceTypeLabel(sourceType) {
|
||||
const normalized = normalizeText(sourceType).toLowerCase();
|
||||
return SOURCE_TYPE_LABELS[normalized] || sourceType || "RSS";
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
getNewsDisplaySummary,
|
||||
getNewsDisplayTitle,
|
||||
getNewsCategoryLabel,
|
||||
getNewsBreakingLabel,
|
||||
getNewsEnrichmentStatusLabel,
|
||||
getNewsFetchChannelLabel,
|
||||
getNewsRegionLabel,
|
||||
@@ -371,6 +372,54 @@ 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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeNewsSourceType(value) {
|
||||
const normalized = String(value ?? "").trim().toLowerCase();
|
||||
return normalized || "";
|
||||
@@ -662,6 +711,7 @@ function renderPayload(nextPayload) {
|
||||
|
||||
renderTicker(nextPayload);
|
||||
syncFilterSummaries(nextPayload);
|
||||
applyNewsBreakingShellState(nextPayload);
|
||||
|
||||
if (!board || !status || !focusLabel || !focusCoords || !sourceCount || !regionChip) {
|
||||
if (document.body.classList.contains("layout-mode-mobile")) {
|
||||
@@ -729,19 +779,36 @@ function renderPayload(nextPayload) {
|
||||
|
||||
board.innerHTML = displayItems
|
||||
.map((item) => {
|
||||
const cardClass = item.is_focus_match
|
||||
? "news-story-card news-story-card--focus"
|
||||
: "news-story-card";
|
||||
const breakingLevel = isBreakingActive(item) ? normalizeBreakingLevel(item.breaking_level) : "none";
|
||||
const breakingScope = normalizeBreakingScope(item.breaking_scope);
|
||||
const cardClass = [
|
||||
"news-story-card",
|
||||
item.is_focus_match ? "news-story-card--focus" : "",
|
||||
breakingLevel !== "none" ? `news-story-card--breaking-${breakingLevel}` : "",
|
||||
breakingLevel !== "none" && breakingScope === "global" ? "news-story-card--breaking-global" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
const title = getNewsDisplayTitle(item);
|
||||
const summaryText = getNewsDisplaySummary(item);
|
||||
const leadText = summaryText || title;
|
||||
const regionLabel = item.display_region || getNewsRegionLabel(item.region);
|
||||
const categoryLabel = getNewsCategoryLabel(item.category);
|
||||
const statusLabel = getNewsEnrichmentStatusLabel(item);
|
||||
const breakingLabel = getNewsBreakingLabel(breakingLevel, breakingScope);
|
||||
const sourceDescriptor = getNewsSourceDescriptor(item, sourcesByName, sourcesById);
|
||||
const summary = title && title !== leadText
|
||||
? `<div class="news-story-summary">${escapeNewsHtml(title)}</div>`
|
||||
: "";
|
||||
const tagHtml = breakingLevel !== "none"
|
||||
? `
|
||||
<span class="news-story-tag news-story-tag--breaking">${escapeNewsHtml(breakingLabel)}</span>
|
||||
<span class="news-story-tag">${escapeNewsHtml(categoryLabel)}</span>
|
||||
<span class="news-story-tag">${escapeNewsHtml(regionLabel)}</span>
|
||||
`
|
||||
: `
|
||||
<span class="news-story-tag">${escapeNewsHtml(categoryLabel)}</span>
|
||||
<span class="news-story-tag">${escapeNewsHtml(regionLabel)}</span>
|
||||
<span class="news-story-tag">${escapeNewsHtml(statusLabel)}</span>
|
||||
`;
|
||||
return `
|
||||
<a class="${cardClass}" data-news-id="${item.id}" href="${item.url}" target="_blank" rel="noreferrer noopener" title="${escapeNewsHtml(sourceDescriptor.tooltip)}">
|
||||
<div class="news-story-meta">
|
||||
@@ -752,9 +819,7 @@ function renderPayload(nextPayload) {
|
||||
<div class="news-story-title">${escapeNewsHtml(leadText)}</div>
|
||||
${summary}
|
||||
<div class="news-story-tags">
|
||||
<span class="news-story-tag">${escapeNewsHtml(categoryLabel)}</span>
|
||||
<span class="news-story-tag">${escapeNewsHtml(regionLabel)}</span>
|
||||
<span class="news-story-tag">${escapeNewsHtml(statusLabel)}</span>
|
||||
${tagHtml}
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
|
||||
@@ -2,20 +2,12 @@ import * as THREE from "three";
|
||||
|
||||
import { CONFIG, PATHS, VESSEL_CONFIG } from "./constants.js";
|
||||
import { createInteractableLayer } from "./interactable.js";
|
||||
import {
|
||||
canAttemptEarthRealtime,
|
||||
getEarthRealtimeCooldownMs,
|
||||
getEarthRealtimeUrl,
|
||||
recordEarthRealtimeFailure,
|
||||
recordEarthRealtimeOpen,
|
||||
} from "./realtime.js";
|
||||
import { latLonToVector3 } from "./utils.js";
|
||||
|
||||
let showVessels = false;
|
||||
let activeTrackLine = null;
|
||||
let vesselStreamSocket = null;
|
||||
let vesselStreamReconnectTimer = null;
|
||||
let vesselDataByKey = new Map();
|
||||
let vesselSnapshotGeneration = 0;
|
||||
let vesselRealtimeStats = {
|
||||
connected: false,
|
||||
updates: 0,
|
||||
@@ -70,48 +62,6 @@ function markerDataToDedupeKey(item) {
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function buildVesselFeatureFromDelta(item) {
|
||||
const lat = Number(item?.lat ?? item?.latitude);
|
||||
const lon = Number(item?.lon ?? item?.lng ?? item?.longitude);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
return {
|
||||
type: "Feature",
|
||||
id: item.mmsi,
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: [lon, lat],
|
||||
},
|
||||
properties: {
|
||||
...item,
|
||||
mmsi: item.mmsi,
|
||||
mmsi_display: item.mmsi_display || (item.mmsi !== undefined && item.mmsi !== null ? String(item.mmsi) : undefined),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rebuildVesselLayerFromCache(earth) {
|
||||
if (!earth) return;
|
||||
vesselIconLayer.setData(Array.from(vesselDataByKey.values()));
|
||||
vesselIconLayer.attach(earth);
|
||||
vesselIconLayer.setVisible(showVessels);
|
||||
}
|
||||
|
||||
function applyVesselDeltas(earth, vessels = []) {
|
||||
let changed = false;
|
||||
vessels.forEach((item) => {
|
||||
const feature = buildVesselFeatureFromDelta(item);
|
||||
if (!feature) return;
|
||||
const marker = buildVesselMarkerData(feature);
|
||||
if (!marker) return;
|
||||
vesselDataByKey.set(markerDataToDedupeKey(marker), marker);
|
||||
changed = true;
|
||||
});
|
||||
if (changed) {
|
||||
rebuildVesselLayerFromCache(earth);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function normalizeVesselType(value, code) {
|
||||
const type = String(value || "").trim().toLowerCase();
|
||||
const numericCode = Number(code);
|
||||
@@ -258,18 +208,13 @@ const vesselIconLayer = createInteractableLayer({
|
||||
vessel_kind: item.type,
|
||||
baseScale: VESSEL_CONFIG.marker.baseScale,
|
||||
}),
|
||||
cluster: {
|
||||
strategy: "dynamic-screen",
|
||||
enabled: true,
|
||||
maxMarkersPerDot: 10,
|
||||
},
|
||||
cluster: false,
|
||||
});
|
||||
|
||||
const DEFAULT_VESSEL_VIEWPORT = {
|
||||
bbox: [-10, 50, 35, 75],
|
||||
const GLOBAL_VESSEL_VIEWPORT = {
|
||||
bbox: [-180, -85.05112878, 180, 85.05112878],
|
||||
zoom: 4,
|
||||
};
|
||||
const MAX_VESSEL_SUBSCRIPTION_BBOX_AREA = 2500;
|
||||
|
||||
export function getVesselMarkers() {
|
||||
return vesselIconLayer.getMarkers();
|
||||
@@ -324,25 +269,25 @@ export function clearVesselData(earth) {
|
||||
}
|
||||
|
||||
export async function loadVessels(_scene, earth, options = {}) {
|
||||
const params = new URLSearchParams();
|
||||
const requestGeneration = ++vesselSnapshotGeneration;
|
||||
const requestedLimit = Number(options.limit ?? VESSEL_CONFIG.maxRenderedMarkers);
|
||||
const bbox = Array.isArray(options.bbox) && options.bbox.length === 4
|
||||
? options.bbox
|
||||
: DEFAULT_VESSEL_VIEWPORT.bbox;
|
||||
: GLOBAL_VESSEL_VIEWPORT.bbox;
|
||||
const zoom = Number.isFinite(Number(options.zoom))
|
||||
? Number(options.zoom)
|
||||
: DEFAULT_VESSEL_VIEWPORT.zoom;
|
||||
params.set("bbox", bbox.join(","));
|
||||
params.set("zoom", String(zoom));
|
||||
: GLOBAL_VESSEL_VIEWPORT.zoom;
|
||||
const params = new URLSearchParams({ bbox: bbox.join(","), zoom: String(zoom) });
|
||||
if (Number.isFinite(requestedLimit) && requestedLimit > 0) {
|
||||
params.set("limit", String(requestedLimit));
|
||||
}
|
||||
const response = await fetch(`${PATHS.vesselsApi}?${params.toString()}`, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Vessels HTTP ${response.status}`);
|
||||
}
|
||||
if (!response.ok) throw new Error(`Vessels HTTP ${response.status}`);
|
||||
const payload = await response.json();
|
||||
const features = Array.isArray(payload?.features) ? payload.features : [];
|
||||
if (requestGeneration !== vesselSnapshotGeneration) {
|
||||
return { totalCount: getVesselCount(), stats: {} };
|
||||
}
|
||||
|
||||
clearVesselData(earth);
|
||||
let markerData = dedupeVesselFeatures(features);
|
||||
@@ -361,132 +306,12 @@ export async function loadVessels(_scene, earth, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeVesselViewportOptions(options = {}) {
|
||||
const bbox = Array.isArray(options.bbox) && options.bbox.length === 4
|
||||
? options.bbox.map(Number)
|
||||
: DEFAULT_VESSEL_VIEWPORT.bbox;
|
||||
const [lonA, latA, lonB, latB] = bbox;
|
||||
const normalizedBbox = [
|
||||
Math.max(-180, Math.min(lonA, lonB)),
|
||||
Math.max(-90, Math.min(latA, latB)),
|
||||
Math.min(180, Math.max(lonA, lonB)),
|
||||
Math.min(90, Math.max(latA, latB)),
|
||||
];
|
||||
const area = (normalizedBbox[2] - normalizedBbox[0]) * (normalizedBbox[3] - normalizedBbox[1]);
|
||||
const safeBbox = Number.isFinite(area) && area <= MAX_VESSEL_SUBSCRIPTION_BBOX_AREA
|
||||
? normalizedBbox
|
||||
: DEFAULT_VESSEL_VIEWPORT.bbox;
|
||||
const zoom = Number.isFinite(Number(options.zoom))
|
||||
? Number(options.zoom)
|
||||
: DEFAULT_VESSEL_VIEWPORT.zoom;
|
||||
return {
|
||||
bbox: safeBbox,
|
||||
zoom: Math.max(1, Math.min(20, Math.round(zoom))),
|
||||
limit: options.limit ?? VESSEL_CONFIG.maxRenderedMarkers,
|
||||
};
|
||||
}
|
||||
|
||||
export function startVesselRealtime(earth, { onUpdate, bbox, zoom, limit } = {}) {
|
||||
if (vesselStreamSocket || vesselStreamReconnectTimer || typeof WebSocket === "undefined") return;
|
||||
const subscriptionOptions = normalizeVesselViewportOptions({ bbox, zoom, limit });
|
||||
const connect = () => {
|
||||
if (!showVessels || vesselStreamSocket) return;
|
||||
if (!canAttemptEarthRealtime()) {
|
||||
vesselStreamReconnectTimer = window.setTimeout(connect, Math.max(3000, getEarthRealtimeCooldownMs()));
|
||||
return;
|
||||
}
|
||||
const socket = new WebSocket(getEarthRealtimeUrl());
|
||||
vesselStreamSocket = socket;
|
||||
socket.onopen = () => {
|
||||
socket.__planetOpened = true;
|
||||
recordEarthRealtimeOpen();
|
||||
vesselRealtimeStats = {
|
||||
...vesselRealtimeStats,
|
||||
connected: true,
|
||||
};
|
||||
onUpdate?.({ totalCount: getVesselCount(), stream: getVesselRealtimeStats() });
|
||||
socket.send(JSON.stringify({
|
||||
type: "subscribe",
|
||||
data: {
|
||||
channel: "vessels",
|
||||
bbox: subscriptionOptions.bbox,
|
||||
zoom: subscriptionOptions.zoom,
|
||||
limit: subscriptionOptions.limit,
|
||||
},
|
||||
}));
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (message.type === "heartbeat" && message.data?.action === "ping") {
|
||||
socket.send(JSON.stringify({ type: "heartbeat" }));
|
||||
return;
|
||||
}
|
||||
if (message.type !== "data_frame" || message.channel !== "vessels") return;
|
||||
const payload = message.payload || {};
|
||||
if (payload.action === "reload") {
|
||||
loadVessels(null, earth)
|
||||
.then((result) => {
|
||||
vesselRealtimeStats = {
|
||||
...vesselRealtimeStats,
|
||||
connected: true,
|
||||
updates: vesselRealtimeStats.updates + 1,
|
||||
lastUpdateAt: new Date(),
|
||||
lastBatchSize: 0,
|
||||
};
|
||||
onUpdate?.({ totalCount: result?.totalCount ?? getVesselCount(), payload, stream: getVesselRealtimeStats() });
|
||||
})
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (payload.action !== "upsert" || !Array.isArray(payload.vessels)) return;
|
||||
if (applyVesselDeltas(earth, payload.vessels)) {
|
||||
vesselRealtimeStats = {
|
||||
connected: true,
|
||||
updates: vesselRealtimeStats.updates + 1,
|
||||
lastUpdateAt: new Date(),
|
||||
lastBatchSize: payload.vessels.length,
|
||||
};
|
||||
onUpdate?.({ totalCount: getVesselCount(), payload, stream: getVesselRealtimeStats() });
|
||||
}
|
||||
};
|
||||
socket.onclose = () => {
|
||||
if (vesselStreamSocket === socket) {
|
||||
vesselStreamSocket = null;
|
||||
}
|
||||
vesselRealtimeStats = {
|
||||
...vesselRealtimeStats,
|
||||
connected: false,
|
||||
};
|
||||
onUpdate?.({ totalCount: getVesselCount(), stream: getVesselRealtimeStats() });
|
||||
if (showVessels) {
|
||||
if (!socket.__planetOpened) {
|
||||
recordEarthRealtimeFailure();
|
||||
}
|
||||
vesselStreamReconnectTimer = window.setTimeout(connect, Math.max(3000, getEarthRealtimeCooldownMs()));
|
||||
}
|
||||
};
|
||||
socket.onerror = () => {
|
||||
socket.close();
|
||||
};
|
||||
};
|
||||
connect();
|
||||
export function startVesselRealtime(_earth, { onUpdate } = {}) {
|
||||
onUpdate?.({ totalCount: getVesselCount(), stream: getVesselRealtimeStats() });
|
||||
}
|
||||
|
||||
export function stopVesselRealtime() {
|
||||
if (vesselStreamReconnectTimer) {
|
||||
window.clearTimeout(vesselStreamReconnectTimer);
|
||||
vesselStreamReconnectTimer = null;
|
||||
}
|
||||
if (vesselStreamSocket) {
|
||||
const socket = vesselStreamSocket;
|
||||
vesselStreamSocket = null;
|
||||
socket.close();
|
||||
}
|
||||
vesselSnapshotGeneration += 1;
|
||||
vesselRealtimeStats = {
|
||||
connected: false,
|
||||
updates: 0,
|
||||
|
||||
Reference in New Issue
Block a user