release: bump version to 0.46.0
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.45.0",
|
||||
"version": "0.46.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
3
frontend/public/earth/assets/icons/bgp-broadcast-pin.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#ffffff" viewBox="0 0 16 16">
|
||||
<path d="M3.05 3.05a7 7 0 0 0 0 9.9.5.5 0 0 1-.707.707 8 8 0 0 1 0-11.314.5.5 0 0 1 .707.707zm2.122 2.122a4 4 0 0 0 0 5.656.5.5 0 1 1-.708.708 5 5 0 0 1 0-7.072.5.5 0 0 1 .708.708zm5.656-.708a.5.5 0 0 1 .708 0 5 5 0 0 1 0 7.072.5.5 0 1 1-.708-.708 4 4 0 0 0 0-5.656.5.5 0 0 1 0-.708zm2.122-2.12a.5.5 0 0 1 .707 0 8 8 0 0 1 0 11.313.5.5 0 0 1-.707-.707 7 7 0 0 0 0-9.9.5.5 0 0 1 0-.707zM6 8a2 2 0 1 1 2.5 1.937V15.5a.5.5 0 0 1-1 0V9.937A2 2 0 0 1 6 8z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 562 B |
@@ -1,16 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<!-- GPU cluster marker: database/cylinder stack icon. -->
|
||||
<!-- Color: #2dd4bf (teal) per COMPUTE_CENTER_CONFIG.colors.gpu_cluster -->
|
||||
<!-- States: normal, estimated (adds a "?" badge drawn separately at canvas level) -->
|
||||
<!-- Outer cylinder: top ellipse cap + side rect + bottom half-ellipse -->
|
||||
<!-- Inner groove ring: smaller cylinder shape overlaid at same color (subtle shape layering) -->
|
||||
<g fill="#2dd4bf">
|
||||
<rect x="46" y="46" width="36" height="28"/>
|
||||
<ellipse cx="64" cy="46" rx="18" ry="8"/>
|
||||
<path d="M 82,74 A 18,8 0 0,1 46,74 Z"/>
|
||||
|
||||
<rect x="52" y="58" width="24" height="6"/>
|
||||
<ellipse cx="64" cy="58" rx="12" ry="4.5"/>
|
||||
<path d="M 76,64 A 12,4.5 0 0,1 52,64 Z"/>
|
||||
</g>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#2dd4bf" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M3.904 1.777C4.978 1.289 6.427 1 8 1s3.022.289 4.096.777C13.125 2.245 14 2.993 14 4s-.875 1.755-1.904 2.223C11.022 6.711 9.573 7 8 7s-3.022-.289-4.096-.777C2.875 5.755 2 5.007 2 4s.875-1.755 1.904-2.223Z"/>
|
||||
<path d="M2 6.161V7c0 1.007.875 1.755 1.904 2.223C4.978 9.71 6.427 10 8 10s3.022-.289 4.096-.777C13.125 8.755 14 8.007 14 7v-.839c-.457.432-1.004.751-1.49.972C11.278 7.693 9.682 8 8 8s-3.278-.307-4.51-.867c-.486-.22-1.033-.54-1.49-.972Z"/>
|
||||
<path d="M2 9.161V10c0 1.007.875 1.755 1.904 2.223C4.978 12.711 6.427 13 8 13s3.022-.289 4.096-.777C13.125 11.755 14 11.007 14 10v-.839c-.457.432-1.004.751-1.49.972-1.232.56-2.828.867-4.51.867s-3.278-.307-4.51-.867c-.486-.22-1.033-.54-1.49-.972Z"/>
|
||||
<path d="M2 12.161V13c0 1.007.875 1.755 1.904 2.223C4.978 15.711 6.427 16 8 16s3.022-.289 4.096-.777C13.125 14.755 14 14.007 14 13v-.839c-.457.432-1.004.751-1.49.972-1.232.56-2.828.867-4.51.867s-3.278-.307-4.51-.867c-.486-.22-1.033-.54-1.49-.972Z"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 787 B After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#f8fafc" viewBox="0 0 16 16">
|
||||
<path d="M2 2a2 2 0 0 0-2 2v1a2 2 0 0 0 2 2h5.5v3A1.5 1.5 0 0 0 6 11.5H.5a.5.5 0 0 0 0 1H6A1.5 1.5 0 0 0 7.5 14h1a1.5 1.5 0 0 0 1.5-1.5h5.5a.5.5 0 0 0 0-1H10A1.5 1.5 0 0 0 8.5 10V7H14a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H2zm.5 3a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm2 0a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 399 B |
@@ -1,10 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<!-- Supercomputer marker: flat-screen monitor with neck and base stand. -->
|
||||
<!-- Color: #38bdf8 (sky-blue) per COMPUTE_CENTER_CONFIG.colors.supercomputer -->
|
||||
<!-- States: normal, estimated (adds a "?" badge drawn separately at canvas level) -->
|
||||
<g fill="#38bdf8">
|
||||
<rect x="40" y="42" width="48" height="30" rx="7"/>
|
||||
<rect x="58" y="74" width="12" height="8" rx="3"/>
|
||||
<rect x="50" y="84" width="28" height="5" rx="2.5"/>
|
||||
</g>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#38bdf8" viewBox="0 0 16 16">
|
||||
<path d="M1.5 0A1.5 1.5 0 0 0 0 1.5v7A1.5 1.5 0 0 0 1.5 10H6v1H1a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1h-5v-1h4.5A1.5 1.5 0 0 0 16 8.5v-7A1.5 1.5 0 0 0 14.5 0h-13Zm0 1h13a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5h-13a.5.5 0 0 1-.5-.5v-7a.5.5 0 0 1 .5-.5ZM12 12.5a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm2 0a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0ZM1.5 12h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1 0-1ZM1 14.25a.25.25 0 0 1 .25-.25h5.5a.25.25 0 1 1 0 .5h-5.5a.25.25 0 0 1-.25-.25Z"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 520 B After Width: | Height: | Size: 578 B |
3
frontend/public/earth/assets/icons/landing-point-geo-alt-fill.svg
Executable file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-geo-fill" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M4 4a4 4 0 1 1 4.5 3.969V13.5a.5.5 0 0 1-1 0V7.97A4 4 0 0 1 4 3.999zm2.493 8.574a.5.5 0 0 1-.411.575c-.712.118-1.28.295-1.655.493a1.319 1.319 0 0 0-.37.265.301.301 0 0 0-.057.09V14l.002.008a.147.147 0 0 0 .016.033.617.617 0 0 0 .145.15c.165.13.435.27.813.395.751.25 1.82.414 3.024.414s2.273-.163 3.024-.414c.378-.126.648-.265.813-.395a.619.619 0 0 0 .146-.15.148.148 0 0 0 .015-.033L12 14v-.004a.301.301 0 0 0-.057-.09 1.318 1.318 0 0 0-.37-.264c-.376-.198-.943-.375-1.655-.493a.5.5 0 1 1 .164-.986c.77.127 1.452.328 1.957.594C12.5 13 13 13.4 13 14c0 .426-.26.752-.544.977-.29.228-.68.413-1.116.558-.878.293-2.059.465-3.34.465-1.281 0-2.462-.172-3.34-.465-.436-.145-.826-.33-1.116-.558C3.26 14.752 3 14.426 3 14c0-.599.5-1 .961-1.243.505-.266 1.187-.467 1.957-.594a.5.5 0 0 1 .575.411z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 953 B |
@@ -24,16 +24,16 @@
|
||||
}
|
||||
|
||||
.earth-toolbar {
|
||||
--toolbar-scale: 1;
|
||||
--toolbar-orb-size: calc(46px * var(--toolbar-scale));
|
||||
--toolbar-hub-size: calc(58px * var(--toolbar-scale));
|
||||
--toolbar-arc-width: calc(420px * var(--toolbar-scale));
|
||||
--toolbar-arc-height: calc(160px * var(--toolbar-scale));
|
||||
--toolbar-inner-arc-width: calc(260px * var(--toolbar-scale));
|
||||
--toolbar-inner-arc-height: calc(56px * var(--toolbar-scale));
|
||||
--toolbar-scale: var(--initial-toolbar-scale, 1);
|
||||
--toolbar-orb-size: var(--initial-toolbar-orb-size, calc(46px * var(--toolbar-scale)));
|
||||
--toolbar-hub-size: var(--initial-toolbar-hub-size, calc(58px * var(--toolbar-scale)));
|
||||
--toolbar-arc-width: var(--initial-toolbar-arc-width, calc(420px * var(--toolbar-scale)));
|
||||
--toolbar-arc-height: var(--initial-toolbar-arc-height, calc(160px * var(--toolbar-scale)));
|
||||
--toolbar-inner-arc-width: var(--initial-toolbar-inner-arc-width, calc(260px * var(--toolbar-scale)));
|
||||
--toolbar-inner-arc-height: var(--initial-toolbar-inner-arc-height, calc(56px * var(--toolbar-scale)));
|
||||
position: relative;
|
||||
width: min(620px, calc(100vw - 40px));
|
||||
height: calc(200px * var(--toolbar-scale));
|
||||
height: var(--initial-toolbar-height, calc(200px * var(--toolbar-scale)));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -155,7 +155,8 @@
|
||||
height: var(--toolbar-orb-size);
|
||||
min-width: var(--toolbar-orb-size);
|
||||
min-height: var(--toolbar-orb-size);
|
||||
border-radius: 50%;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -164,7 +165,8 @@
|
||||
height: var(--toolbar-hub-size);
|
||||
min-width: var(--toolbar-hub-size);
|
||||
min-height: var(--toolbar-hub-size);
|
||||
border-radius: 50%;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
color: var(--hud-title);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,87 @@
|
||||
|
||||
document.documentElement.style.setProperty("--hud-scale", clampedScale.toFixed(3));
|
||||
})();
|
||||
|
||||
(function applyInitialToolbarLayout() {
|
||||
var width = window.innerWidth;
|
||||
var height = window.innerHeight;
|
||||
var toolbarBaseWidth = 620;
|
||||
var toolbarMinScale = 0.68;
|
||||
var orbSizeBase = 46;
|
||||
var hubSizeBase = 58;
|
||||
var orbGapBase = 12;
|
||||
var archSpanBase = 232;
|
||||
var archRiseBase = 40;
|
||||
var sidePaddingBase = 12;
|
||||
var bottomClearanceBase = 34;
|
||||
var extraHeightBase = 34;
|
||||
var visibleOrbCount = 8;
|
||||
var toolbarWidth = Math.min(toolbarBaseWidth, Math.max(0, width - 40));
|
||||
var viewportScale = Math.min(width / 1920, height / 1080);
|
||||
var toolbarScale = Math.max(
|
||||
toolbarMinScale,
|
||||
Math.min(1, Math.min(toolbarWidth / toolbarBaseWidth, viewportScale)),
|
||||
);
|
||||
var orbSize = orbSizeBase * toolbarScale;
|
||||
var desiredGap = orbGapBase * toolbarScale;
|
||||
var span = archSpanBase * toolbarScale;
|
||||
var rise = archRiseBase * toolbarScale;
|
||||
var minSpanForSpacing =
|
||||
visibleOrbCount > 1
|
||||
? (visibleOrbCount - 1) * (orbSize + desiredGap)
|
||||
: orbSize;
|
||||
var maxSpanByWidth =
|
||||
toolbarWidth - orbSize - sidePaddingBase * 2 * toolbarScale;
|
||||
|
||||
if (minSpanForSpacing > maxSpanByWidth) {
|
||||
toolbarScale = Math.max(
|
||||
toolbarMinScale,
|
||||
Math.min(toolbarScale, maxSpanByWidth / minSpanForSpacing),
|
||||
);
|
||||
orbSize = orbSizeBase * toolbarScale;
|
||||
desiredGap = orbGapBase * toolbarScale;
|
||||
span = archSpanBase * toolbarScale;
|
||||
rise = archRiseBase * toolbarScale;
|
||||
minSpanForSpacing =
|
||||
visibleOrbCount > 1
|
||||
? (visibleOrbCount - 1) * (orbSize + desiredGap)
|
||||
: orbSize;
|
||||
maxSpanByWidth =
|
||||
toolbarWidth - orbSize - sidePaddingBase * 2 * toolbarScale;
|
||||
}
|
||||
|
||||
span = Math.max(minSpanForSpacing, Math.min(maxSpanByWidth, span));
|
||||
rise = Math.min(rise, span * 0.32);
|
||||
|
||||
var hubSize = hubSizeBase * toolbarScale;
|
||||
var maxVerticalReach = rise + orbSize * 0.5;
|
||||
var toolbarHeight =
|
||||
maxVerticalReach +
|
||||
hubSize +
|
||||
bottomClearanceBase * toolbarScale +
|
||||
extraHeightBase * toolbarScale;
|
||||
var rootStyle = document.documentElement.style;
|
||||
var roundedOrbSize = Math.round(orbSize);
|
||||
var roundedHubSize = Math.round(hubSize);
|
||||
|
||||
rootStyle.setProperty("--initial-toolbar-scale", toolbarScale.toFixed(3));
|
||||
rootStyle.setProperty("--initial-toolbar-orb-size", roundedOrbSize + "px");
|
||||
rootStyle.setProperty("--initial-toolbar-hub-size", roundedHubSize + "px");
|
||||
rootStyle.setProperty("--initial-toolbar-height", Math.ceil(toolbarHeight) + "px");
|
||||
rootStyle.setProperty(
|
||||
"--initial-toolbar-arc-width",
|
||||
Math.ceil(span + orbSize + sidePaddingBase * 2 * toolbarScale) + "px",
|
||||
);
|
||||
rootStyle.setProperty(
|
||||
"--initial-toolbar-arc-height",
|
||||
Math.ceil(rise + orbSize * 0.95) + "px",
|
||||
);
|
||||
rootStyle.setProperty("--initial-toolbar-inner-arc-width", Math.ceil(span * 0.72) + "px");
|
||||
rootStyle.setProperty(
|
||||
"--initial-toolbar-inner-arc-height",
|
||||
Math.ceil(hubSize * 0.8 + desiredGap * 0.5) + "px",
|
||||
);
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="css/base.css">
|
||||
<link rel="stylesheet" href="css/hud.css">
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import { BGP_CONFIG, CONFIG, PATHS } from "./constants.js";
|
||||
import { createInteractableLayer } from "./interactable.js";
|
||||
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
|
||||
|
||||
const bgpGroup = new THREE.Group();
|
||||
const bgpOverlayGroup = new THREE.Group();
|
||||
const bgpEventOverlayGroup = new THREE.Group();
|
||||
const bgpCollectorRadarGroup = new THREE.Group();
|
||||
const collectorMarkers = [];
|
||||
const anomalyMarkers = [];
|
||||
const activeEventCountByCollector = new Map();
|
||||
@@ -14,7 +17,6 @@ let totalAnomalyCount = 0;
|
||||
let totalIncidentCount = 0;
|
||||
let textureCache = null;
|
||||
let eventRingTextureCache = null;
|
||||
let collectorTextureCache = null;
|
||||
const eventTextureCache = new Map();
|
||||
let activeEventOverlay = null;
|
||||
let activeCollectorOverlayContext = null;
|
||||
@@ -22,17 +24,27 @@ const relativeTimeFormatter = new Intl.RelativeTimeFormat("zh-CN", {
|
||||
numeric: "auto",
|
||||
});
|
||||
const collectorWorldPosition = new THREE.Vector3();
|
||||
const collectorSurfaceNormal = new THREE.Vector3();
|
||||
const collectorNorthPole = new THREE.Vector3(0, 1, 0);
|
||||
const collectorFallbackForward = new THREE.Vector3(0, 0, 1);
|
||||
const collectorNorthTangent = new THREE.Vector3();
|
||||
const collectorEastTangent = new THREE.Vector3();
|
||||
const collectorOrientationMatrix = new THREE.Matrix4();
|
||||
const colorScratchA = new THREE.Color();
|
||||
const colorScratchB = new THREE.Color();
|
||||
const COLLECTOR_SCAN_SPEED_RAD = 0.00018;
|
||||
const COLLECTOR_SCAN_REBUILD_MS = 80;
|
||||
const MATERIAL_ACCESS_POINT_PATH = "M4.93 4.93A9.97 9.97 0 0 0 2 12c0 2.76 1.12 5.26 2.93 7.07l1.41-1.41A7.94 7.94 0 0 1 4 12c0-2.21.89-4.22 2.34-5.66zm14.14 0l-1.41 1.41A7.96 7.96 0 0 1 20 12c0 2.22-.89 4.22-2.34 5.66l1.41 1.41A9.97 9.97 0 0 0 22 12c0-2.76-1.12-5.26-2.93-7.07M7.76 7.76A5.98 5.98 0 0 0 6 12c0 1.65.67 3.15 1.76 4.24l1.41-1.41A4 4 0 0 1 8 12c0-1.11.45-2.11 1.17-2.83zm8.48 0l-1.41 1.41A4 4 0 0 1 16 12c0 1.11-.45 2.11-1.17 2.83l1.41 1.41A5.98 5.98 0 0 0 18 12c0-1.65-.67-3.15-1.76-4.24M12 10a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2";
|
||||
const BGP_EVENT_RENDER_ORDER = 4.5;
|
||||
const BGP_EVENT_POINT_SIZE = 34;
|
||||
const BGP_EVENT_SYMBOL_SIZE = 60;
|
||||
const BGP_COLLECTOR_RENDER_ORDER = 4.4;
|
||||
const BGP_COLLECTOR_ALTITUDE_OFFSET = BGP_CONFIG.collectorAltitudeOffset;
|
||||
const BGP_COLLECTOR_POINT_SIZE = 36;
|
||||
const BGP_COLLECTOR_ICON_FIT_SIZE = 60;
|
||||
const BGP_COLLECTOR_ICON_SOURCE = "/earth/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;
|
||||
|
||||
bgpOverlayGroup.name = "bgp-overlay-root";
|
||||
bgpEventOverlayGroup.name = "bgp-event-overlay-layer";
|
||||
bgpCollectorRadarGroup.name = "bgp-collector-radar-layer";
|
||||
bgpOverlayGroup.add(bgpEventOverlayGroup);
|
||||
bgpOverlayGroup.add(bgpCollectorRadarGroup);
|
||||
|
||||
function getMarkerTexture() {
|
||||
if (textureCache) return textureCache;
|
||||
@@ -85,48 +97,6 @@ function getEventRingTexture() {
|
||||
return eventRingTextureCache;
|
||||
}
|
||||
|
||||
function getCollectorTexture() {
|
||||
if (collectorTextureCache) return collectorTextureCache;
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 128;
|
||||
canvas.height = 128;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
collectorTextureCache = new THREE.Texture(canvas);
|
||||
return collectorTextureCache;
|
||||
}
|
||||
|
||||
context.clearRect(0, 0, 128, 128);
|
||||
|
||||
context.strokeStyle = BGP_CONFIG.collectorIcon.ringStroke;
|
||||
context.lineWidth = BGP_CONFIG.collectorIcon.ringLineWidth;
|
||||
context.beginPath();
|
||||
context.arc(64, 64, BGP_CONFIG.collectorIcon.ringRadius, 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
|
||||
context.save();
|
||||
context.translate(16, 16);
|
||||
context.scale(4, 4);
|
||||
const path = new Path2D(MATERIAL_ACCESS_POINT_PATH);
|
||||
context.lineJoin = "round";
|
||||
context.lineCap = "round";
|
||||
context.lineWidth = BGP_CONFIG.collectorIcon.pathLineWidth;
|
||||
context.strokeStyle = BGP_CONFIG.collectorIcon.pathStroke;
|
||||
context.stroke(path);
|
||||
context.fillStyle = BGP_CONFIG.collectorIcon.pathFill;
|
||||
context.shadowBlur = 0;
|
||||
context.fill(path);
|
||||
context.fillStyle = BGP_CONFIG.collectorIcon.centerFill;
|
||||
context.beginPath();
|
||||
context.arc(12, 12, BGP_CONFIG.collectorIcon.centerRadius, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.restore();
|
||||
|
||||
collectorTextureCache = new THREE.CanvasTexture(canvas);
|
||||
return collectorTextureCache;
|
||||
}
|
||||
|
||||
function getEventSymbolKind(anomalyType) {
|
||||
const value = String(anomalyType || "").toLowerCase();
|
||||
if (value.includes("origin")) return "triangle";
|
||||
@@ -266,6 +236,169 @@ function getSeverityScale(severity) {
|
||||
return BGP_CONFIG.severityScales[normalizeSeverity(severity)];
|
||||
}
|
||||
|
||||
function severityColorHex(severity) {
|
||||
return `#${getSeverityColor(severity).toString(16).padStart(6, "0")}`;
|
||||
}
|
||||
|
||||
function colorNumberHex(colorNumber) {
|
||||
return `#${Number(colorNumber || 0xffffff).toString(16).padStart(6, "0")}`;
|
||||
}
|
||||
|
||||
function drawBGPEventIcon(context, { marker, color = "#ffffff", glow = false }) {
|
||||
const kind = getEventSymbolKind(
|
||||
marker?.userData?.incident_type || marker?.userData?.anomaly_type,
|
||||
);
|
||||
|
||||
context.save();
|
||||
context.fillStyle = color;
|
||||
context.strokeStyle = color;
|
||||
context.lineJoin = "round";
|
||||
context.lineCap = "round";
|
||||
context.shadowColor = color;
|
||||
context.shadowBlur = glow ? 14 : 0;
|
||||
|
||||
const inset = (128 - BGP_EVENT_SYMBOL_SIZE) / 2;
|
||||
context.translate(inset, inset);
|
||||
context.scale(BGP_EVENT_SYMBOL_SIZE / 128, BGP_EVENT_SYMBOL_SIZE / 128);
|
||||
|
||||
if (kind === "triangle") {
|
||||
drawTriangleSymbol(context);
|
||||
} else if (kind === "exclamation") {
|
||||
drawExclamationSymbol(context);
|
||||
} else if (kind === "wave") {
|
||||
drawWaveSymbol(context);
|
||||
} else if (kind === "burst") {
|
||||
drawBurstSymbol(context);
|
||||
} else if (kind === "leak") {
|
||||
drawLeakSymbol(context);
|
||||
} else {
|
||||
drawDotSymbol(context);
|
||||
}
|
||||
|
||||
context.restore();
|
||||
}
|
||||
|
||||
const bgpEventIconLayer = createInteractableLayer({
|
||||
id: "bgp-events",
|
||||
objectType: "bgp",
|
||||
renderOrder: BGP_EVENT_RENDER_ORDER,
|
||||
altitudeOffset: BGP_CONFIG.altitudeOffset,
|
||||
pointSize: BGP_EVENT_POINT_SIZE,
|
||||
colors: {
|
||||
byKind: Object.fromEntries(
|
||||
Object.keys(BGP_CONFIG.severityColors).map((severity) => [
|
||||
severity,
|
||||
severityColorHex(severity),
|
||||
]),
|
||||
),
|
||||
normal: severityColorHex("medium"),
|
||||
},
|
||||
opacity: {
|
||||
normal: BGP_CONFIG.opacity.normal,
|
||||
hover: BGP_CONFIG.opacity.hover,
|
||||
locked: BGP_CONFIG.opacity.lockedMax,
|
||||
dimmed: BGP_CONFIG.opacity.dimmed,
|
||||
},
|
||||
stateScale: {
|
||||
hover: BGP_CONFIG.marker.hoverScale,
|
||||
locked: BGP_CONFIG.marker.hoverScale,
|
||||
dimmed: BGP_CONFIG.marker.dimmedScale,
|
||||
},
|
||||
pulse: {
|
||||
enabled: true,
|
||||
speed: BGP_CONFIG.pulse.eventSpeed,
|
||||
amplitude: BGP_CONFIG.pulse.lockedAmplitude,
|
||||
},
|
||||
icon: {
|
||||
coordinates: "canvas",
|
||||
draw: drawBGPEventIcon,
|
||||
},
|
||||
getPosition: (item) => ({
|
||||
latitude: item.latitude,
|
||||
longitude: item.longitude,
|
||||
}),
|
||||
getKind: (item) => normalizeSeverity(item.severity),
|
||||
getBucketKey: (marker) => [
|
||||
getEventSymbolKind(marker.userData?.incident_type || marker.userData?.anomaly_type),
|
||||
normalizeSeverity(marker.userData?.severity),
|
||||
].join(":"),
|
||||
getPointSizeMultiplier: (marker) => getSeverityScale(marker.userData?.severity),
|
||||
getUserData: (item) => ({
|
||||
...item,
|
||||
baseScale: BGP_CONFIG.marker.eventBaseScale * getSeverityScale(item.severity),
|
||||
baseColor: getSeverityColor(item.severity),
|
||||
pulseOffset: Math.random() * Math.PI * 2,
|
||||
}),
|
||||
});
|
||||
|
||||
const bgpCollectorIconLayer = createInteractableLayer({
|
||||
id: "bgp-collectors",
|
||||
objectType: "bgp_collector",
|
||||
renderOrder: BGP_COLLECTOR_RENDER_ORDER,
|
||||
altitudeOffset: BGP_COLLECTOR_ALTITUDE_OFFSET,
|
||||
pointSize: BGP_COLLECTOR_POINT_SIZE,
|
||||
colors: {
|
||||
byKind: Object.fromEntries(
|
||||
Object.entries(BGP_CONFIG.collectorHeatColors).map(([tier, color]) => [
|
||||
tier,
|
||||
colorNumberHex(color),
|
||||
]),
|
||||
),
|
||||
normal: colorNumberHex(BGP_CONFIG.collectorColor),
|
||||
},
|
||||
opacity: {
|
||||
normal: BGP_CONFIG.collectorIcon.idleOpacity,
|
||||
hover: 0.98,
|
||||
locked: 1,
|
||||
dimmed: BGP_CONFIG.opacity.dimmed,
|
||||
},
|
||||
stateScale: {
|
||||
hover: BGP_COLLECTOR_HOVER_SCALE,
|
||||
locked: BGP_COLLECTOR_LOCKED_SCALE,
|
||||
dimmed: BGP_CONFIG.marker.dimmedScale,
|
||||
},
|
||||
pulse: {
|
||||
enabled: true,
|
||||
speed: BGP_CONFIG.pulse.collectorSpeed,
|
||||
amplitude: BGP_COLLECTOR_PULSE_AMPLITUDE,
|
||||
},
|
||||
icon: {
|
||||
coordinates: "canvas",
|
||||
fitSize: BGP_COLLECTOR_ICON_FIT_SIZE,
|
||||
glowBlur: 16,
|
||||
source: BGP_COLLECTOR_ICON_SOURCE,
|
||||
},
|
||||
getPosition: (item) => ({
|
||||
latitude: item.displayLatitude,
|
||||
longitude: item.displayLongitude,
|
||||
}),
|
||||
getKind: (item) => getCollectorActivityProfile(item).tier,
|
||||
getBucketKey: (marker) => {
|
||||
const scaleBoost = marker.userData?.activity?.scaleBoost ?? 1;
|
||||
return `${marker.userData?.activity?.tier || "idle"}:${scaleBoost.toFixed(2)}`;
|
||||
},
|
||||
getPointSizeMultiplier: (marker) => marker.userData?.activity?.scaleBoost ?? 1,
|
||||
getUserData: (item) => {
|
||||
const activity = getCollectorActivityProfile(item);
|
||||
const baseColor = activity.color;
|
||||
const idleColor = blendHexColors(
|
||||
BGP_CONFIG.collectorIcon.idleBaseColor,
|
||||
baseColor,
|
||||
BGP_CONFIG.collectorIcon.idleBlend,
|
||||
);
|
||||
|
||||
return {
|
||||
...item,
|
||||
baseScale: BGP_CONFIG.marker.collectorBaseScale * activity.scaleBoost,
|
||||
baseColor,
|
||||
idleColor,
|
||||
pulseOffset: Math.random() * Math.PI * 2,
|
||||
anomaly_count: 0,
|
||||
activity,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
@@ -281,7 +414,7 @@ function getCollectorDistanceScale(marker, camera) {
|
||||
if (!marker || !camera || BGP_CONFIG.sizeStabilization?.enabled === false) return 1;
|
||||
|
||||
return getSurfaceMarkerCameraScale(camera, {
|
||||
altitudeOffset: BGP_CONFIG.collectorAltitudeOffset,
|
||||
altitudeOffset: BGP_COLLECTOR_ALTITUDE_OFFSET,
|
||||
referenceFov: 75,
|
||||
min: Number(BGP_CONFIG.sizeStabilization?.collectorMin ?? 0.6),
|
||||
max: Number(BGP_CONFIG.sizeStabilization?.collectorMax ?? 1.9),
|
||||
@@ -299,32 +432,6 @@ function getEventDistanceScale(marker, camera) {
|
||||
});
|
||||
}
|
||||
|
||||
function orientCollectorMarkerToSurface(marker, position) {
|
||||
collectorSurfaceNormal.copy(position).normalize();
|
||||
collectorNorthTangent
|
||||
.copy(collectorNorthPole)
|
||||
.projectOnPlane(collectorSurfaceNormal);
|
||||
|
||||
if (collectorNorthTangent.lengthSq() < 1e-6) {
|
||||
collectorNorthTangent
|
||||
.copy(collectorFallbackForward)
|
||||
.projectOnPlane(collectorSurfaceNormal);
|
||||
}
|
||||
|
||||
collectorNorthTangent.normalize();
|
||||
collectorEastTangent
|
||||
.copy(collectorNorthTangent)
|
||||
.cross(collectorSurfaceNormal)
|
||||
.normalize();
|
||||
|
||||
collectorOrientationMatrix.makeBasis(
|
||||
collectorEastTangent,
|
||||
collectorNorthTangent,
|
||||
collectorSurfaceNormal,
|
||||
);
|
||||
marker.quaternion.setFromRotationMatrix(collectorOrientationMatrix);
|
||||
}
|
||||
|
||||
function getCollectorActivityProfile(markerData) {
|
||||
const recent24h = Number(markerData?.recent_24h_observation_count || 0);
|
||||
const recent7d = Number(markerData?.recent_7d_observation_count || 0);
|
||||
@@ -784,13 +891,26 @@ function clearMarkerArray(markers) {
|
||||
const marker = markers.pop();
|
||||
while (marker.children.length > 0) {
|
||||
const child = marker.children.pop();
|
||||
child.geometry?.dispose?.();
|
||||
child.material?.dispose();
|
||||
}
|
||||
disposeCollectorEffectSprites(marker);
|
||||
disposeEventRingSprite(marker.userData?.ringA);
|
||||
disposeEventRingSprite(marker.userData?.ringB);
|
||||
marker.material?.dispose();
|
||||
bgpGroup.remove(marker);
|
||||
marker.parent?.remove?.(marker);
|
||||
}
|
||||
}
|
||||
|
||||
function disposeAnomalyRingSprites() {
|
||||
anomalyMarkers.forEach((marker) => {
|
||||
disposeEventRingSprite(marker.userData?.ringA);
|
||||
disposeEventRingSprite(marker.userData?.ringB);
|
||||
delete marker.userData.ringA;
|
||||
delete marker.userData.ringB;
|
||||
});
|
||||
}
|
||||
|
||||
function clearGroup(group) {
|
||||
while (group.children.length > 0) {
|
||||
const child = group.children[group.children.length - 1];
|
||||
@@ -979,46 +1099,14 @@ function createRadialBoundaryPoints(
|
||||
return points;
|
||||
}
|
||||
|
||||
function createCollectorMarker(markerData) {
|
||||
const activity = getCollectorActivityProfile(markerData);
|
||||
const baseColor = activity.color;
|
||||
const idleColor = blendHexColors(
|
||||
BGP_CONFIG.collectorIcon.idleBaseColor,
|
||||
baseColor,
|
||||
BGP_CONFIG.collectorIcon.idleBlend,
|
||||
);
|
||||
const marker = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(1, 1),
|
||||
new THREE.MeshBasicMaterial({
|
||||
map: getCollectorTexture(),
|
||||
color: idleColor,
|
||||
transparent: true,
|
||||
opacity: BGP_CONFIG.collectorIcon.idleOpacity,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
side: THREE.DoubleSide,
|
||||
}),
|
||||
);
|
||||
|
||||
const position = latLonToVector3(
|
||||
markerData.displayLatitude,
|
||||
markerData.displayLongitude,
|
||||
CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset,
|
||||
);
|
||||
|
||||
marker.position.copy(position);
|
||||
marker.scale.set(BGP_CONFIG.marker.collectorBaseScale * 0.88 * activity.scaleBoost, BGP_CONFIG.marker.collectorBaseScale * 1.08 * activity.scaleBoost, 1);
|
||||
marker.renderOrder = 3;
|
||||
marker.visible = showBGP;
|
||||
orientCollectorMarkerToSurface(marker, position);
|
||||
|
||||
function attachCollectorEffectSprites(marker) {
|
||||
const activity = marker.userData.activity;
|
||||
const heatHalo = createOverlaySprite({
|
||||
color: activity.color,
|
||||
opacity: 0.0,
|
||||
scale: activity.haloScale * 0.58,
|
||||
});
|
||||
heatHalo.renderOrder = 1;
|
||||
marker.add(heatHalo);
|
||||
|
||||
const pulseHalo = createOverlaySprite({
|
||||
color: activity.color,
|
||||
@@ -1026,7 +1114,6 @@ function createCollectorMarker(markerData) {
|
||||
scale: activity.pulseHaloScale * 0.48,
|
||||
});
|
||||
pulseHalo.renderOrder = 0;
|
||||
marker.add(pulseHalo);
|
||||
|
||||
const statusCore = createOverlaySprite({
|
||||
color: activity.color,
|
||||
@@ -1037,9 +1124,7 @@ function createCollectorMarker(markerData) {
|
||||
BGP_CONFIG.marker.collectorStatusCoreMinScale,
|
||||
),
|
||||
});
|
||||
statusCore.position.set(0, 0, 0.02);
|
||||
statusCore.renderOrder = 4;
|
||||
marker.add(statusCore);
|
||||
|
||||
const coverageHalo = createOverlaySprite({
|
||||
color: BGP_CONFIG.regionColor,
|
||||
@@ -1048,65 +1133,52 @@ function createCollectorMarker(markerData) {
|
||||
});
|
||||
coverageHalo.renderOrder = 0;
|
||||
coverageHalo.scale.set(activity.coverageHaloScale * 0.82, activity.coverageHaloScale * 0.56, 1);
|
||||
marker.add(coverageHalo);
|
||||
|
||||
marker.userData = {
|
||||
type: "bgp_collector",
|
||||
state: "normal",
|
||||
baseScale: BGP_CONFIG.marker.collectorBaseScale * activity.scaleBoost,
|
||||
baseColor,
|
||||
idleColor,
|
||||
pulseOffset: Math.random() * Math.PI * 2,
|
||||
anomaly_count: 0,
|
||||
activity,
|
||||
heatHalo,
|
||||
pulseHalo,
|
||||
statusCore,
|
||||
coverageHalo,
|
||||
...markerData,
|
||||
};
|
||||
marker.userData.heatHalo = heatHalo;
|
||||
marker.userData.pulseHalo = pulseHalo;
|
||||
marker.userData.statusCore = statusCore;
|
||||
marker.userData.coverageHalo = coverageHalo;
|
||||
|
||||
collectorMarkers.push(marker);
|
||||
bgpGroup.add(marker);
|
||||
[heatHalo, pulseHalo, statusCore, coverageHalo].forEach((sprite) => {
|
||||
sprite.position.copy(marker.position);
|
||||
sprite.visible = showBGP;
|
||||
bgpGroup.add(sprite);
|
||||
});
|
||||
}
|
||||
|
||||
function createAnomalyMarker(markerData) {
|
||||
const sprite = new THREE.Sprite(
|
||||
new THREE.SpriteMaterial({
|
||||
map: getEventTexture(markerData.incident_type || markerData.anomaly_type),
|
||||
color: getSeverityColor(markerData.severity),
|
||||
transparent: true,
|
||||
opacity: BGP_CONFIG.opacity.normal,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.NormalBlending,
|
||||
}),
|
||||
);
|
||||
function disposeCollectorEffectSprites(marker) {
|
||||
[
|
||||
marker?.userData?.heatHalo,
|
||||
marker?.userData?.pulseHalo,
|
||||
marker?.userData?.statusCore,
|
||||
marker?.userData?.coverageHalo,
|
||||
].forEach((sprite) => {
|
||||
if (!sprite) return;
|
||||
sprite.parent?.remove?.(sprite);
|
||||
sprite.material?.dispose?.();
|
||||
sprite.geometry?.dispose?.();
|
||||
});
|
||||
}
|
||||
|
||||
const position = latLonToVector3(
|
||||
markerData.latitude,
|
||||
markerData.longitude,
|
||||
CONFIG.earthRadius + BGP_CONFIG.altitudeOffset,
|
||||
);
|
||||
async function setCollectorMarkers(markerData, earth) {
|
||||
collectorMarkers.forEach(disposeCollectorEffectSprites);
|
||||
collectorMarkers.length = 0;
|
||||
await bgpCollectorIconLayer.preloadAssets(markerData);
|
||||
bgpCollectorIconLayer.setData(markerData);
|
||||
bgpCollectorIconLayer.attach(earth);
|
||||
bgpCollectorIconLayer.setVisible(showBGP);
|
||||
|
||||
const baseScale = BGP_CONFIG.marker.eventBaseScale * getSeverityScale(markerData.severity);
|
||||
sprite.position.copy(position);
|
||||
sprite.scale.setScalar(baseScale);
|
||||
sprite.renderOrder = 5;
|
||||
sprite.visible = showBGP;
|
||||
sprite.userData = {
|
||||
type: "bgp",
|
||||
state: "normal",
|
||||
baseScale,
|
||||
baseColor: getSeverityColor(markerData.severity),
|
||||
pulseOffset: Math.random() * Math.PI * 2,
|
||||
...markerData,
|
||||
};
|
||||
bgpCollectorIconLayer.getMarkers().forEach((marker) => {
|
||||
attachCollectorEffectSprites(marker);
|
||||
collectorMarkers.push(marker);
|
||||
});
|
||||
}
|
||||
|
||||
const ringA = new THREE.Sprite(
|
||||
function createEventRingSprite(marker) {
|
||||
const ring = new THREE.Sprite(
|
||||
new THREE.SpriteMaterial({
|
||||
map: getEventRingTexture(),
|
||||
color: getSeverityColor(markerData.severity),
|
||||
color: marker.userData.baseColor || getSeverityColor(marker.userData.severity),
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
depthWrite: false,
|
||||
@@ -1114,30 +1186,40 @@ function createAnomalyMarker(markerData) {
|
||||
blending: THREE.AdditiveBlending,
|
||||
}),
|
||||
);
|
||||
ringA.scale.setScalar(baseScale * BGP_CONFIG.ring.scaleA);
|
||||
ringA.position.set(0, 0, -0.01);
|
||||
sprite.add(ringA);
|
||||
ring.position.copy(marker.position);
|
||||
ring.renderOrder = BGP_EVENT_RENDER_ORDER - 0.05;
|
||||
ring.visible = showBGP;
|
||||
return ring;
|
||||
}
|
||||
|
||||
const ringB = new THREE.Sprite(
|
||||
new THREE.SpriteMaterial({
|
||||
map: getEventRingTexture(),
|
||||
color: getSeverityColor(markerData.severity),
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
}),
|
||||
);
|
||||
ringB.scale.setScalar(baseScale * BGP_CONFIG.ring.scaleB);
|
||||
ringB.position.set(0, 0, -0.02);
|
||||
sprite.add(ringB);
|
||||
function disposeEventRingSprite(ring) {
|
||||
if (!ring) return;
|
||||
ring.parent?.remove?.(ring);
|
||||
ring.material?.dispose?.();
|
||||
ring.geometry?.dispose?.();
|
||||
}
|
||||
|
||||
sprite.userData.ringA = ringA;
|
||||
sprite.userData.ringB = ringB;
|
||||
function attachAnomalyRingSprites(marker) {
|
||||
const ringA = createEventRingSprite(marker);
|
||||
const ringB = createEventRingSprite(marker);
|
||||
ringB.visible = false;
|
||||
marker.userData.ringA = ringA;
|
||||
marker.userData.ringB = ringB;
|
||||
bgpGroup.add(ringA);
|
||||
bgpGroup.add(ringB);
|
||||
}
|
||||
|
||||
anomalyMarkers.push(sprite);
|
||||
bgpGroup.add(sprite);
|
||||
function setAnomalyMarkers(markerData, earth) {
|
||||
disposeAnomalyRingSprites();
|
||||
anomalyMarkers.length = 0;
|
||||
bgpEventIconLayer.setData(markerData);
|
||||
bgpEventIconLayer.attach(earth);
|
||||
bgpEventIconLayer.setVisible(showBGP);
|
||||
|
||||
bgpEventIconLayer.getMarkers().forEach((marker) => {
|
||||
attachAnomalyRingSprites(marker);
|
||||
anomalyMarkers.push(marker);
|
||||
});
|
||||
}
|
||||
|
||||
function dedupeAnomalies(features) {
|
||||
@@ -1304,17 +1386,18 @@ export async function loadBGPAnomalies(scene, earth) {
|
||||
totalIncidentCount = selectedEventData.totalIncidentCount;
|
||||
activeEventCountByCollector.clear();
|
||||
|
||||
spreadCollectorPositions(
|
||||
const collectorMarkersData = spreadCollectorPositions(
|
||||
collectorFeatures
|
||||
.map(buildCollectorFeatureData)
|
||||
.filter(Boolean),
|
||||
).forEach(createCollectorMarker);
|
||||
);
|
||||
await setCollectorMarkers(collectorMarkersData, earth);
|
||||
|
||||
if (selectedEventData.mode === "incident") {
|
||||
dedupeIncidents(selectedEventData.features).forEach(createAnomalyMarker);
|
||||
} else {
|
||||
dedupeAnomalies(selectedEventData.features).forEach(createAnomalyMarker);
|
||||
}
|
||||
const eventMarkers =
|
||||
selectedEventData.mode === "incident"
|
||||
? dedupeIncidents(selectedEventData.features)
|
||||
: dedupeAnomalies(selectedEventData.features);
|
||||
setAnomalyMarkers(eventMarkers, earth);
|
||||
applyCollectorCounts();
|
||||
|
||||
if (!bgpGroup.parent) {
|
||||
@@ -1360,7 +1443,6 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
|
||||
);
|
||||
|
||||
let scale = marker.userData.baseScale * getCollectorDistanceScale(marker, camera);
|
||||
let opacity = BGP_CONFIG.collectorIcon.idleOpacity;
|
||||
let haloOpacity = 0.0;
|
||||
let pulseOpacity = 0.0;
|
||||
let coverageOpacity = 0.0;
|
||||
@@ -1374,14 +1456,12 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
|
||||
|
||||
if (isLocked) {
|
||||
scale *= 1.1 + 0.14 * pulse;
|
||||
opacity = 0.96;
|
||||
haloOpacity = 0.05;
|
||||
pulseOpacity = 0.024;
|
||||
coverageOpacity = 0.036;
|
||||
markerColor = 0xcff2ff;
|
||||
} else if (isHovered) {
|
||||
scale *= 1.08;
|
||||
opacity = 0.88;
|
||||
haloOpacity = 0.03;
|
||||
pulseOpacity = 0.014;
|
||||
coverageOpacity = 0.02;
|
||||
@@ -1392,7 +1472,6 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
|
||||
);
|
||||
} else if (hasLockedLayer) {
|
||||
scale *= 0.98;
|
||||
opacity = BGP_CONFIG.collectorIcon.idleOpacity;
|
||||
haloOpacity = 0.0;
|
||||
pulseOpacity = 0.0;
|
||||
coverageOpacity = 0.0;
|
||||
@@ -1401,12 +1480,8 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
|
||||
scale *= 1 + 0.05 * pulse;
|
||||
}
|
||||
|
||||
marker.scale.setScalar(scale);
|
||||
marker.material.color.setHex(markerColor);
|
||||
marker.material.opacity = opacity;
|
||||
marker.visible = showBGP;
|
||||
|
||||
if (marker.userData.heatHalo) {
|
||||
marker.userData.heatHalo.position.copy(marker.position);
|
||||
marker.userData.heatHalo.material.opacity = haloOpacity;
|
||||
marker.userData.heatHalo.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor);
|
||||
marker.userData.heatHalo.scale.setScalar(
|
||||
@@ -1414,6 +1489,7 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
|
||||
);
|
||||
}
|
||||
if (marker.userData.pulseHalo) {
|
||||
marker.userData.pulseHalo.position.copy(marker.position);
|
||||
marker.userData.pulseHalo.material.opacity = pulseOpacity;
|
||||
marker.userData.pulseHalo.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor);
|
||||
marker.userData.pulseHalo.scale.setScalar(
|
||||
@@ -1421,6 +1497,7 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
|
||||
);
|
||||
}
|
||||
if (marker.userData.statusCore) {
|
||||
marker.userData.statusCore.position.copy(marker.position);
|
||||
marker.userData.statusCore.material.opacity =
|
||||
isLocked ? 0.58 : isHovered ? 0.4 : hasLockedLayer ? 0.0 : 0.18;
|
||||
marker.userData.statusCore.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor);
|
||||
@@ -1433,6 +1510,7 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
|
||||
);
|
||||
}
|
||||
if (marker.userData.coverageHalo) {
|
||||
marker.userData.coverageHalo.position.copy(marker.position);
|
||||
marker.userData.coverageHalo.material.opacity = coverageOpacity;
|
||||
marker.userData.coverageHalo.scale.set(
|
||||
marker.userData.activity?.coverageHaloScale * 0.82 * (1 + pulse * 0.012),
|
||||
@@ -1442,6 +1520,20 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
|
||||
}
|
||||
});
|
||||
|
||||
const focusedCollector =
|
||||
lockedObjectType === "bgp"
|
||||
? collectorMarkers.find(
|
||||
(marker) => marker.userData.collector === lockedObject?.userData?.collector,
|
||||
)
|
||||
: lockedObjectType === "bgp_collector"
|
||||
? lockedObject
|
||||
: null;
|
||||
bgpCollectorIconLayer.updateVisualState(
|
||||
focusedCollector ? "bgp_collector" : lockedObjectType,
|
||||
focusedCollector || lockedObject,
|
||||
camera,
|
||||
);
|
||||
|
||||
anomalyMarkers.forEach((marker) => {
|
||||
const isLocked = lockedObjectType === "bgp" && lockedObject === marker;
|
||||
const isLinkedCollectorLocked =
|
||||
@@ -1459,7 +1551,6 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
|
||||
const iconAnchorScale =
|
||||
marker.userData.baseScale * getEventDistanceScale(marker, camera);
|
||||
let scale = iconAnchorScale;
|
||||
let opacity = BGP_CONFIG.opacity.normal;
|
||||
let markerColor = marker.userData.baseColor || getSeverityColor(marker.userData.severity);
|
||||
const isIncidentMarker = marker.userData.source === "bgp_incident";
|
||||
let ringBaseOpacity = isIncidentMarker
|
||||
@@ -1468,49 +1559,38 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
|
||||
|
||||
if (isLocked || isLinkedCollectorLocked) {
|
||||
scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse;
|
||||
opacity = 0.9 + 0.1 * pulse;
|
||||
markerColor = 0xfff1a8;
|
||||
ringBaseOpacity *= 1.2;
|
||||
} else if (isCruise) {
|
||||
scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse;
|
||||
opacity = 0.9 + 0.1 * pulse;
|
||||
ringBaseOpacity *= 1.2;
|
||||
} else if (isHovered) {
|
||||
scale *= BGP_CONFIG.marker.hoverScale;
|
||||
opacity = 0.9;
|
||||
ringBaseOpacity *= 1.05;
|
||||
} else if (isOtherLocked) {
|
||||
scale *= BGP_CONFIG.marker.dimmedScale;
|
||||
opacity = 0.22;
|
||||
markerColor = 0x7d8ca3;
|
||||
ringBaseOpacity = 0.02;
|
||||
} else {
|
||||
scale *= 1 + BGP_CONFIG.pulse.normalAmplitude * pulse;
|
||||
opacity = isIncidentMarker ? 0.7 : 0.62;
|
||||
}
|
||||
|
||||
marker.scale.setScalar(scale);
|
||||
marker.material.color.setHex(markerColor);
|
||||
marker.material.opacity = opacity;
|
||||
marker.visible = showBGP;
|
||||
marker.renderOrder = isActive ? 7 : 3;
|
||||
|
||||
const ringPhaseA = (now * BGP_CONFIG.ring.speed + marker.userData.pulseOffset) % 1;
|
||||
const ringPhaseA = (now * BGP_CONFIG.ring.speed + marker.userData.pulseOffset) % 1;
|
||||
const applyRingState = (ring, phase, maxScale) => {
|
||||
if (!ring) return;
|
||||
const progress = Math.max(0, Math.min(1, phase));
|
||||
const minScale = 1.28;
|
||||
const desiredWorldScale =
|
||||
iconAnchorScale * (minScale + progress * (maxScale - minScale));
|
||||
const parentScale = Math.max(scale, 0.0001);
|
||||
const localRingScale = desiredWorldScale / parentScale;
|
||||
scale * (minScale + progress * (maxScale - minScale));
|
||||
const fadeIn = Math.max(0, Math.min(1, (progress - 0.08) / 0.14));
|
||||
const fadeOut = 1 - progress;
|
||||
const visibility = fadeIn * fadeOut;
|
||||
ring.scale.setScalar(localRingScale);
|
||||
ring.position.copy(marker.position);
|
||||
ring.scale.setScalar(desiredWorldScale);
|
||||
ring.material.color.setHex(markerColor);
|
||||
ring.material.opacity = showBGP ? ringBaseOpacity * visibility : 0;
|
||||
ring.visible = showBGP;
|
||||
ring.renderOrder = isActive ? BGP_EVENT_RENDER_ORDER + 0.15 : BGP_EVENT_RENDER_ORDER - 0.05;
|
||||
};
|
||||
|
||||
applyRingState(marker.userData.ringA, ringPhaseA, BGP_CONFIG.ring.scaleA);
|
||||
@@ -1519,6 +1599,8 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
|
||||
marker.userData.ringB.visible = false;
|
||||
}
|
||||
});
|
||||
|
||||
bgpEventIconLayer.updateVisualState(lockedObjectType, lockedObject, camera);
|
||||
}
|
||||
|
||||
export function setBGPMarkerState(marker, state = "normal") {
|
||||
@@ -1526,15 +1608,19 @@ export function setBGPMarkerState(marker, state = "normal") {
|
||||
if (marker.userData.type !== "bgp" && marker.userData.type !== "bgp_collector") {
|
||||
return;
|
||||
}
|
||||
marker.userData.state = state;
|
||||
if (marker.userData.type === "bgp") {
|
||||
bgpEventIconLayer.setMarkerState(marker, state);
|
||||
return;
|
||||
}
|
||||
bgpCollectorIconLayer.setMarkerState(marker, state);
|
||||
}
|
||||
|
||||
export function clearBGPSelection() {
|
||||
collectorMarkers.forEach((marker) => {
|
||||
marker.userData.state = "normal";
|
||||
bgpCollectorIconLayer.setMarkerState(marker, "normal");
|
||||
});
|
||||
anomalyMarkers.forEach((marker) => {
|
||||
marker.userData.state = "normal";
|
||||
bgpEventIconLayer.setMarkerState(marker, "normal");
|
||||
});
|
||||
clearBGPEventOverlay();
|
||||
}
|
||||
@@ -1542,6 +1628,8 @@ export function clearBGPSelection() {
|
||||
export function clearBGPData(earth) {
|
||||
clearMarkerArray(collectorMarkers);
|
||||
clearMarkerArray(anomalyMarkers);
|
||||
bgpCollectorIconLayer.clearData(earth);
|
||||
bgpEventIconLayer.clearData(earth);
|
||||
clearBGPEventOverlay();
|
||||
activeEventCountByCollector.clear();
|
||||
totalAnomalyCount = 0;
|
||||
@@ -1559,11 +1647,21 @@ export function toggleBGP(show) {
|
||||
showBGP = Boolean(show);
|
||||
bgpGroup.visible = showBGP;
|
||||
bgpOverlayGroup.visible = showBGP;
|
||||
bgpCollectorIconLayer.setVisible(showBGP);
|
||||
bgpEventIconLayer.setVisible(showBGP);
|
||||
collectorMarkers.forEach((marker) => {
|
||||
marker.visible = showBGP;
|
||||
[
|
||||
marker.userData.heatHalo,
|
||||
marker.userData.pulseHalo,
|
||||
marker.userData.statusCore,
|
||||
marker.userData.coverageHalo,
|
||||
].forEach((sprite) => {
|
||||
if (sprite) sprite.visible = showBGP;
|
||||
});
|
||||
});
|
||||
anomalyMarkers.forEach((marker) => {
|
||||
marker.visible = showBGP;
|
||||
marker.userData.ringA && (marker.userData.ringA.visible = showBGP);
|
||||
marker.userData.ringB && (marker.userData.ringB.visible = false);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1579,6 +1677,14 @@ export function getBGPAnomalyMarkers() {
|
||||
return anomalyMarkers;
|
||||
}
|
||||
|
||||
export function getBGPAnomalyPointerIntersections(options = {}) {
|
||||
return bgpEventIconLayer.getPointerIntersections(options);
|
||||
}
|
||||
|
||||
export function getBGPCollectorPointerIntersections(options = {}) {
|
||||
return bgpCollectorIconLayer.getPointerIntersections(options);
|
||||
}
|
||||
|
||||
export function getBGPCollectorMarkers() {
|
||||
return collectorMarkers;
|
||||
}
|
||||
@@ -1644,11 +1750,11 @@ export function showBGPEventOverlay(marker, earth) {
|
||||
latLonToVector3(
|
||||
region.latitude,
|
||||
region.longitude,
|
||||
CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset - 0.1,
|
||||
CONFIG.earthRadius + BGP_COLLECTOR_ALTITUDE_OFFSET - 0.1,
|
||||
),
|
||||
);
|
||||
halo.renderOrder = 2;
|
||||
bgpOverlayGroup.add(halo);
|
||||
bgpEventOverlayGroup.add(halo);
|
||||
overlayItems.push(halo);
|
||||
});
|
||||
|
||||
@@ -1687,11 +1793,11 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
|
||||
latLonToVector3(
|
||||
marker.userData.displayLatitude ?? marker.userData.latitude,
|
||||
marker.userData.displayLongitude ?? marker.userData.longitude,
|
||||
CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset - 0.15,
|
||||
CONFIG.earthRadius + BGP_COLLECTOR_ALTITUDE_OFFSET - 0.15,
|
||||
),
|
||||
);
|
||||
halo.renderOrder = 2;
|
||||
bgpOverlayGroup.add(halo);
|
||||
bgpCollectorRadarGroup.add(halo);
|
||||
|
||||
const pulseHalo = createOverlaySprite({
|
||||
color: BGP_CONFIG.collectorColor,
|
||||
@@ -1700,7 +1806,7 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
|
||||
});
|
||||
pulseHalo.position.copy(halo.position);
|
||||
pulseHalo.renderOrder = 1;
|
||||
bgpOverlayGroup.add(pulseHalo);
|
||||
bgpCollectorRadarGroup.add(pulseHalo);
|
||||
const innerRing = createOverlaySprite({
|
||||
color: BGP_CONFIG.collectorColor,
|
||||
opacity: 0.12,
|
||||
@@ -1708,7 +1814,7 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
|
||||
});
|
||||
innerRing.position.copy(halo.position);
|
||||
innerRing.renderOrder = 3;
|
||||
bgpOverlayGroup.add(innerRing);
|
||||
bgpCollectorRadarGroup.add(innerRing);
|
||||
|
||||
const overlayItems = [halo, pulseHalo, innerRing];
|
||||
const anchorLatitude = marker.userData.displayLatitude ?? marker.userData.latitude;
|
||||
@@ -1723,8 +1829,8 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
|
||||
const startBearing = (sectorRotation - sectorHalfWidth) * (180 / Math.PI);
|
||||
const endBearing = (sectorRotation + sectorHalfWidth) * (180 / Math.PI);
|
||||
const coverageColor = marker.userData.baseColor || BGP_CONFIG.collectorColor;
|
||||
const boundaryAltitude = CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset + 0.44;
|
||||
const fillAltitude = CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset + 0.4;
|
||||
const boundaryAltitude = CONFIG.earthRadius + BGP_COLLECTOR_ALTITUDE_OFFSET + 0.44;
|
||||
const fillAltitude = CONFIG.earthRadius + BGP_COLLECTOR_ALTITUDE_OFFSET + 0.4;
|
||||
const leftBoundaryPoints = createRadialBoundaryPoints(
|
||||
anchorLatitude,
|
||||
anchorLongitude,
|
||||
@@ -1765,7 +1871,7 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
|
||||
0.12,
|
||||
);
|
||||
sectorFill.renderOrder = 2;
|
||||
bgpOverlayGroup.add(sectorFill);
|
||||
bgpCollectorRadarGroup.add(sectorFill);
|
||||
overlayItems.push(sectorFill);
|
||||
|
||||
const outerArc = createCoverageBoundaryLine(
|
||||
@@ -1774,7 +1880,7 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
|
||||
0.9,
|
||||
);
|
||||
outerArc.renderOrder = 3;
|
||||
bgpOverlayGroup.add(outerArc);
|
||||
bgpCollectorRadarGroup.add(outerArc);
|
||||
overlayItems.push(outerArc);
|
||||
|
||||
const leftBoundary = createCoverageBoundaryLine(
|
||||
@@ -1783,7 +1889,7 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
|
||||
0.76,
|
||||
);
|
||||
leftBoundary.renderOrder = 3;
|
||||
bgpOverlayGroup.add(leftBoundary);
|
||||
bgpCollectorRadarGroup.add(leftBoundary);
|
||||
overlayItems.push(leftBoundary);
|
||||
|
||||
const rightBoundary = createCoverageBoundaryLine(
|
||||
@@ -1792,7 +1898,7 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
|
||||
0.76,
|
||||
);
|
||||
rightBoundary.renderOrder = 3;
|
||||
bgpOverlayGroup.add(rightBoundary);
|
||||
bgpCollectorRadarGroup.add(rightBoundary);
|
||||
overlayItems.push(rightBoundary);
|
||||
|
||||
activeEventOverlay = overlayItems;
|
||||
@@ -1808,7 +1914,8 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
|
||||
export function clearBGPEventOverlay() {
|
||||
activeEventOverlay = null;
|
||||
activeCollectorOverlayContext = null;
|
||||
clearGroup(bgpOverlayGroup);
|
||||
clearGroup(bgpEventOverlayGroup);
|
||||
clearGroup(bgpCollectorRadarGroup);
|
||||
}
|
||||
|
||||
function updateCollectorOverlayScan(lockedObjectType, lockedObject) {
|
||||
|
||||
@@ -20,80 +20,110 @@ export let lockedCable = null;
|
||||
let cableIdMap = new Map();
|
||||
let cableStates = new Map();
|
||||
let cablesVisible = true;
|
||||
let landingPointTexture = null;
|
||||
const _lpEarthWorldPos = new THREE.Vector3();
|
||||
const _lpWorldPos = new THREE.Vector3();
|
||||
const _lpCameraRel = new THREE.Vector3();
|
||||
const _lpPointRel = new THREE.Vector3();
|
||||
const _lpCameraToPoint = new THREE.Vector3();
|
||||
const LANDING_POINT_SPRITE_HEIGHT = 3;
|
||||
const LANDING_POINT_SPRITE_ASPECT = 1;
|
||||
const LANDING_POINT_SIZE_REFERENCE_FOV = 75;
|
||||
const LANDING_POINT_SIZE_SCALE_MIN = 0.36;
|
||||
const LANDING_POINT_SIZE_SCALE_MAX = 3;
|
||||
const LANDING_POINT_ATLAS_CELL_SIZE = 128;
|
||||
let landingPointTexture = null;
|
||||
|
||||
function createLandingPointTexture() {
|
||||
const size = CABLE_CONFIG.landingPoint.textureSize;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const iconPath = new Path2D(
|
||||
[
|
||||
"M400 704",
|
||||
"C386 704 375 697 367 684",
|
||||
"L173 378",
|
||||
"C117 290 144 173 229 111",
|
||||
"C278 75 337 57 400 57",
|
||||
"C463 57 522 75 571 111",
|
||||
"C656 173 683 290 627 378",
|
||||
"L433 684",
|
||||
"C425 697 414 704 400 704",
|
||||
"Z",
|
||||
].join(" "),
|
||||
function getLandingPointPulse() {
|
||||
return (
|
||||
Math.sin(Date.now() * CABLE_CONFIG.landingPointVisual.pulseSpeed) + 1
|
||||
) * 0.5;
|
||||
}
|
||||
|
||||
function getLandingPointDimColor() {
|
||||
const dimColor = CABLE_CONFIG.landingPointVisual.dimmed.colorRGB;
|
||||
const brightness = CABLE_CONFIG.landingPointVisual.dimBrightness;
|
||||
const color = new THREE.Color(
|
||||
(dimColor.r * brightness) / 255,
|
||||
(dimColor.g * brightness) / 255,
|
||||
(dimColor.b * brightness) / 255,
|
||||
);
|
||||
return `#${color.getHexString()}`;
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
ctx.save();
|
||||
ctx.translate(size * 0.12, size * 0.02);
|
||||
ctx.scale(size / 1000, size / 1000);
|
||||
function createLandingPointBallTexture() {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = LANDING_POINT_ATLAS_CELL_SIZE;
|
||||
canvas.height = LANDING_POINT_ATLAS_CELL_SIZE;
|
||||
const context = canvas.getContext("2d");
|
||||
const center = LANDING_POINT_ATLAS_CELL_SIZE / 2;
|
||||
const radius = 46;
|
||||
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fill(iconPath);
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.globalCompositeOperation = "destination-out";
|
||||
ctx.beginPath();
|
||||
ctx.arc(400, 320, 86, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
const shadow = context.createRadialGradient(
|
||||
center - 16,
|
||||
center - 18,
|
||||
8,
|
||||
center,
|
||||
center,
|
||||
radius,
|
||||
);
|
||||
shadow.addColorStop(0, "rgba(255, 255, 255, 1)");
|
||||
shadow.addColorStop(0.48, "rgba(238, 238, 238, 0.98)");
|
||||
shadow.addColorStop(0.82, "rgba(178, 178, 178, 0.94)");
|
||||
shadow.addColorStop(1, "rgba(92, 92, 92, 0.88)");
|
||||
|
||||
context.beginPath();
|
||||
context.arc(center, center, radius, 0, Math.PI * 2);
|
||||
context.fillStyle = shadow;
|
||||
context.fill();
|
||||
|
||||
context.beginPath();
|
||||
context.ellipse(center - 14, center - 18, 14, 9, -0.45, 0, Math.PI * 2);
|
||||
context.fillStyle = "rgba(255, 255, 255, 0.38)";
|
||||
context.fill();
|
||||
|
||||
context.beginPath();
|
||||
context.arc(center, center, radius - 1, 0, Math.PI * 2);
|
||||
context.strokeStyle = "rgba(255, 255, 255, 0.24)";
|
||||
context.lineWidth = 2;
|
||||
context.stroke();
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.generateMipmaps = false;
|
||||
texture.minFilter = THREE.LinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
|
||||
function getLandingPointTexture() {
|
||||
if (!landingPointTexture) {
|
||||
landingPointTexture = createLandingPointTexture();
|
||||
}
|
||||
async function getLandingPointTexture() {
|
||||
if (landingPointTexture) return landingPointTexture;
|
||||
landingPointTexture = createLandingPointBallTexture();
|
||||
return landingPointTexture;
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function getLandingPointDistanceScale(point, camera) {
|
||||
if (
|
||||
!point ||
|
||||
!camera ||
|
||||
CABLE_CONFIG.landingPointSizeStabilization?.enabled === false
|
||||
) return 1;
|
||||
|
||||
function getLandingPointDistanceScale(camera) {
|
||||
if (!camera) return 1;
|
||||
return getSurfaceMarkerCameraScale(camera, {
|
||||
altitudeOffset: CABLE_CONFIG.landingPoint.altitudeOffset,
|
||||
referenceFov: CABLE_CONFIG.landingPointSizeStabilization?.referenceFov || 75,
|
||||
min: CABLE_CONFIG.landingPointSizeStabilization?.min ?? 0.12,
|
||||
max: CABLE_CONFIG.landingPointSizeStabilization?.max ?? 3.0,
|
||||
referenceFov: LANDING_POINT_SIZE_REFERENCE_FOV,
|
||||
min: LANDING_POINT_SIZE_SCALE_MIN,
|
||||
max: LANDING_POINT_SIZE_SCALE_MAX,
|
||||
});
|
||||
}
|
||||
|
||||
function setLandingPointScale(point, camera = null) {
|
||||
const height = LANDING_POINT_SPRITE_HEIGHT * getLandingPointDistanceScale(camera);
|
||||
point.scale.set(height * LANDING_POINT_SPRITE_ASPECT, height, 1);
|
||||
}
|
||||
|
||||
function setLandingPointMaterialState(point, { color, opacity }) {
|
||||
point.material.color.set(color);
|
||||
point.material.opacity = opacity;
|
||||
}
|
||||
|
||||
function disposeMaterial(material) {
|
||||
if (!material) return;
|
||||
|
||||
@@ -122,22 +152,6 @@ function disposeObject(object, parent) {
|
||||
}
|
||||
}
|
||||
|
||||
function setLandingPointMaterialState(point, { color, opacity, emissive, emissiveIntensity }) {
|
||||
point.material.color.set(color);
|
||||
point.material.opacity = opacity;
|
||||
if (point.material.emissive && emissive !== undefined) {
|
||||
point.material.emissive.setHex(emissive);
|
||||
}
|
||||
if ("emissiveIntensity" in point.material && emissiveIntensity !== undefined) {
|
||||
point.material.emissiveIntensity = emissiveIntensity;
|
||||
}
|
||||
}
|
||||
|
||||
function setLandingPointScale(point, heightScale) {
|
||||
const aspect = CABLE_CONFIG.landingPoint.iconAspectRatio;
|
||||
point.scale.set(heightScale * aspect, heightScale, 1);
|
||||
}
|
||||
|
||||
function getCableColor(properties) {
|
||||
if (properties.color) {
|
||||
if (
|
||||
@@ -426,7 +440,7 @@ export async function loadLandingPoints(scene, earthObj, options = {}) {
|
||||
|
||||
clearLandingPoints(earthObj);
|
||||
|
||||
let validCount = 0;
|
||||
const markerTexture = await getLandingPointTexture();
|
||||
|
||||
for (const feature of data.features) {
|
||||
if (!feature.geometry || !feature.geometry.coordinates) continue;
|
||||
@@ -460,20 +474,18 @@ export async function loadLandingPoints(scene, earthObj, options = {}) {
|
||||
|
||||
const marker = new THREE.Sprite(
|
||||
new THREE.SpriteMaterial({
|
||||
map: getLandingPointTexture(),
|
||||
map: markerTexture,
|
||||
color: CABLE_CONFIG.landingPoint.color,
|
||||
transparent: true,
|
||||
opacity: CABLE_CONFIG.landingPoint.opacity,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
alphaTest: 0.01,
|
||||
}),
|
||||
);
|
||||
marker.material.userData.sharedMap = true;
|
||||
marker.renderOrder = CABLE_CONFIG.landingPoint.renderOrder;
|
||||
marker.center.set(
|
||||
CABLE_CONFIG.landingPoint.anchorX,
|
||||
CABLE_CONFIG.landingPoint.anchorY,
|
||||
);
|
||||
marker.center.set(0.5, 0.5);
|
||||
marker.position.copy(position);
|
||||
marker.userData = {
|
||||
type: "landingPoint",
|
||||
@@ -481,15 +493,18 @@ export async function loadLandingPoints(scene, earthObj, options = {}) {
|
||||
cableNames: properties.cable_names || [],
|
||||
country: properties.country || "未知国家",
|
||||
status: properties.status || "Unknown",
|
||||
baseScale: CABLE_CONFIG.landingPoint.baseScale,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
landing_visual_state: "normal",
|
||||
};
|
||||
setLandingPointScale(marker, CABLE_CONFIG.landingPoint.baseScale);
|
||||
setLandingPointScale(marker);
|
||||
|
||||
earthObj.add(marker);
|
||||
landingPoints.push(marker);
|
||||
validCount++;
|
||||
}
|
||||
|
||||
const validCount = landingPoints.length;
|
||||
|
||||
setEarthStatValue("landing-point-count", `${validCount}个`);
|
||||
|
||||
if (!silent) {
|
||||
@@ -619,10 +634,8 @@ function isFacingCamera(lp, camera) {
|
||||
const distance = Math.sqrt(distanceSq);
|
||||
_lpCameraToPoint.multiplyScalar(1 / distance);
|
||||
|
||||
// The pin sprite is rendered without depth testing so its full shape does
|
||||
// not get sliced by the globe. Instead, hide it when the camera-to-anchor
|
||||
// segment is occluded by a slightly inflated globe, matching the behavior of
|
||||
// the BGP and compute-center markers near the limb.
|
||||
// Sprite rendering keeps the full pin visible. Hide it when the anchor point
|
||||
// is occluded by the globe so back-side landing points do not bleed through.
|
||||
const occlusionRadius =
|
||||
CONFIG.earthRadius + CABLE_CONFIG.landingPoint.altitudeOffset * 0.45;
|
||||
const cameraProjection = _lpCameraRel.dot(_lpCameraToPoint);
|
||||
@@ -638,75 +651,52 @@ function isFacingCamera(lp, camera) {
|
||||
}
|
||||
|
||||
export function applyLandingPointVisualState(lockedCableName, dimAll = false, camera = null) {
|
||||
const pulse =
|
||||
(Math.sin(Date.now() * CABLE_CONFIG.landingPointVisual.pulseSpeed) + 1) * 0.5;
|
||||
const brightness = CABLE_CONFIG.landingPointVisual.dimBrightness;
|
||||
const relatedNames = Array.isArray(lockedCableName)
|
||||
? lockedCableName.filter(Boolean)
|
||||
: lockedCableName
|
||||
? [lockedCableName]
|
||||
: [];
|
||||
|
||||
landingPoints.forEach((lp) => {
|
||||
lp.visible = cablesVisible && (camera ? isFacingCamera(lp, camera) : true);
|
||||
const isVisible = cablesVisible && (camera ? isFacingCamera(lp, camera) : true);
|
||||
const isRelated =
|
||||
!dimAll &&
|
||||
Array.isArray(lp.userData.cableNames) &&
|
||||
lp.userData.cableNames.some((name) => relatedNames.includes(name));
|
||||
|
||||
lp.visible = isVisible;
|
||||
setLandingPointScale(lp, camera);
|
||||
|
||||
if (isRelated) {
|
||||
const pulse = getLandingPointPulse();
|
||||
setLandingPointMaterialState(lp, {
|
||||
color: 0xffd27a,
|
||||
emissive: 0x7a4a00,
|
||||
emissiveIntensity:
|
||||
CABLE_CONFIG.landingPointVisual.related.emissiveIntensityBase +
|
||||
0.2 +
|
||||
pulse * (CABLE_CONFIG.landingPointVisual.related.emissiveIntensityPulse + 0.2),
|
||||
opacity: Math.max(
|
||||
0.92,
|
||||
CABLE_CONFIG.landingPointVisual.related.opacityBase +
|
||||
pulse * CABLE_CONFIG.landingPointVisual.related.opacityPulse,
|
||||
),
|
||||
});
|
||||
const distanceScale = getLandingPointDistanceScale(lp, camera);
|
||||
const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale;
|
||||
setLandingPointScale(
|
||||
lp,
|
||||
(CABLE_CONFIG.landingPointVisual.related.scaleBase +
|
||||
pulse * CABLE_CONFIG.landingPointVisual.related.scalePulse) *
|
||||
baseScale *
|
||||
distanceScale,
|
||||
);
|
||||
lp.userData.landing_visual_state = "related";
|
||||
} else {
|
||||
const dimColor = CABLE_CONFIG.landingPointVisual.dimmed.colorRGB;
|
||||
const r = dimColor.r * brightness;
|
||||
const g = dimColor.g * brightness;
|
||||
const b = dimColor.b * brightness;
|
||||
setLandingPointMaterialState(lp, {
|
||||
color: new THREE.Color(r / 255, g / 255, b / 255),
|
||||
emissive: CABLE_CONFIG.landingPointVisual.dimmed.emissive,
|
||||
emissiveIntensity: CABLE_CONFIG.landingPointVisual.dimmed.emissiveIntensity,
|
||||
color: getLandingPointDimColor(),
|
||||
opacity: CABLE_CONFIG.landingPointVisual.dimmed.opacity,
|
||||
});
|
||||
const distanceScale = getLandingPointDistanceScale(lp, camera);
|
||||
const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale;
|
||||
setLandingPointScale(lp, baseScale * distanceScale);
|
||||
lp.userData.landing_visual_state = isVisible ? "dimmed" : "hidden";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function resetLandingPointVisualState(camera = null) {
|
||||
landingPoints.forEach((lp) => {
|
||||
lp.visible = cablesVisible && (camera ? isFacingCamera(lp, camera) : true);
|
||||
const isVisible = cablesVisible && (camera ? isFacingCamera(lp, camera) : true);
|
||||
lp.visible = isVisible;
|
||||
setLandingPointScale(lp, camera);
|
||||
setLandingPointMaterialState(lp, {
|
||||
color: CABLE_CONFIG.landingPoint.color,
|
||||
emissive: CABLE_CONFIG.landingPoint.emissive,
|
||||
emissiveIntensity: CABLE_CONFIG.landingPoint.emissiveIntensity,
|
||||
opacity: CABLE_CONFIG.landingPoint.opacity,
|
||||
});
|
||||
const distanceScale = getLandingPointDistanceScale(lp, camera);
|
||||
const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale;
|
||||
setLandingPointScale(lp, baseScale * distanceScale);
|
||||
lp.userData.landing_visual_state = isVisible ? "normal" : "hidden";
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import * as THREE from "three";
|
||||
import { COMPUTE_CENTER_CONFIG, PATHS } from "./constants.js";
|
||||
import { createInteractableLayer } from "./interactable.js";
|
||||
|
||||
import { COMPUTE_CENTER_CONFIG, CONFIG, PATHS } from "./constants.js";
|
||||
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
|
||||
|
||||
const computeCenterGroup = new THREE.Group();
|
||||
const computeCenterMarkers = [];
|
||||
const COMPUTE_CENTER_RENDER_ORDER = 4.5;
|
||||
const textureCache = new Map();
|
||||
const COMPUTE_CENTER_POINT_SIZE = 36;
|
||||
const COMPUTE_CENTER_ICON_FIT_SIZE = 60;
|
||||
const COMPUTE_CENTER_ATLAS_CELL_SIZE = 128;
|
||||
const COMPUTE_CENTER_ICON_SOURCES = {
|
||||
supercomputer: "/earth/assets/icons/compute-supercomputer.svg",
|
||||
gpu_cluster: "/earth/assets/icons/compute-gpu-cluster.svg",
|
||||
infrastructure: "/earth/assets/icons/compute-hdd-network.svg",
|
||||
};
|
||||
let showComputeCenters = true;
|
||||
let supercomputerCount = 0;
|
||||
let gpuClusterCount = 0;
|
||||
@@ -69,71 +72,13 @@ function spreadComputeCenterPositions(markers) {
|
||||
return markers;
|
||||
}
|
||||
|
||||
function createMarkerTexture(siteType, isEstimated = false) {
|
||||
const textureKey = `${siteType}:${isEstimated ? "estimated" : "precise"}`;
|
||||
if (textureCache.has(textureKey)) {
|
||||
return textureCache.get(textureKey);
|
||||
}
|
||||
|
||||
const color =
|
||||
COMPUTE_CENTER_CONFIG.colors[siteType] ||
|
||||
COMPUTE_CENTER_CONFIG.colors.gpu_cluster;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 128;
|
||||
canvas.height = 128;
|
||||
const context = canvas.getContext("2d");
|
||||
const centerX = 64;
|
||||
const centerY = 64;
|
||||
const baseFill = color;
|
||||
|
||||
function fillPath(draw, options = {}) {
|
||||
const { fillStyle = color } = options;
|
||||
context.save();
|
||||
context.fillStyle = fillStyle;
|
||||
context.beginPath();
|
||||
draw();
|
||||
context.fill();
|
||||
context.restore();
|
||||
}
|
||||
|
||||
context.clearRect(0, 0, 128, 128);
|
||||
|
||||
if (siteType === "supercomputer") {
|
||||
fillPath(() => {
|
||||
context.roundRect(40, 42, 48, 30, 7);
|
||||
}, {
|
||||
fillStyle: baseFill,
|
||||
});
|
||||
fillPath(() => {
|
||||
context.roundRect(58, 74, 12, 8, 3);
|
||||
context.roundRect(50, 84, 28, 5, 2.5);
|
||||
}, {
|
||||
fillStyle: baseFill,
|
||||
});
|
||||
} else {
|
||||
fillPath(() => {
|
||||
context.ellipse(centerX, 46, 18, 8, 0, 0, Math.PI * 2);
|
||||
context.rect(46, 46, 36, 28);
|
||||
context.ellipse(centerX, 74, 18, 8, 0, 0, Math.PI);
|
||||
}, {
|
||||
fillStyle: baseFill,
|
||||
});
|
||||
fillPath(() => {
|
||||
context.ellipse(centerX, 58, 12, 4.5, 0, 0, Math.PI * 2);
|
||||
context.rect(52, 58, 24, 6);
|
||||
context.ellipse(centerX, 64, 12, 4.5, 0, 0, Math.PI);
|
||||
}, {
|
||||
fillStyle: baseFill,
|
||||
});
|
||||
}
|
||||
|
||||
function drawComputeCenterEstimatedBadge(context, isEstimated = false) {
|
||||
if (isEstimated) {
|
||||
fillPath(() => {
|
||||
context.arc(94, 36, 12, 0, Math.PI * 2);
|
||||
}, {
|
||||
fillStyle: "rgba(15,23,42,0.92)",
|
||||
});
|
||||
context.save();
|
||||
context.fillStyle = "rgba(15,23,42,0.92)";
|
||||
context.beginPath();
|
||||
context.arc(94, 36, 12, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.fillStyle = "rgba(255,255,255,0.98)";
|
||||
context.font = "bold 18px sans-serif";
|
||||
context.textAlign = "center";
|
||||
@@ -141,76 +86,74 @@ function createMarkerTexture(siteType, isEstimated = false) {
|
||||
context.fillText("?", 94, 36);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.needsUpdate = true;
|
||||
textureCache.set(textureKey, texture);
|
||||
return texture;
|
||||
}
|
||||
|
||||
function normalizeSiteType(siteType) {
|
||||
return siteType === "supercomputer" ? "supercomputer" : "gpu_cluster";
|
||||
}
|
||||
|
||||
function getBaseScale(siteType) {
|
||||
return siteType === "supercomputer"
|
||||
? COMPUTE_CENTER_CONFIG.marker.supercomputerScale
|
||||
: COMPUTE_CENTER_CONFIG.marker.gpuClusterScale;
|
||||
}
|
||||
|
||||
function getDistanceScale(marker, camera) {
|
||||
if (!marker || !camera || COMPUTE_CENTER_CONFIG.sizeStabilization.enabled === false) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return getSurfaceMarkerCameraScale(camera, {
|
||||
altitudeOffset: COMPUTE_CENTER_CONFIG.altitudeOffset,
|
||||
referenceFov: 75,
|
||||
min: COMPUTE_CENTER_CONFIG.sizeStabilization.min,
|
||||
max: COMPUTE_CENTER_CONFIG.sizeStabilization.max,
|
||||
});
|
||||
}
|
||||
|
||||
function clearGroup(group) {
|
||||
for (let index = group.children.length - 1; index >= 0; index -= 1) {
|
||||
const child = group.children[index];
|
||||
child.material?.dispose?.();
|
||||
group.remove(child);
|
||||
}
|
||||
}
|
||||
|
||||
function createComputeCenterMarker(markerData) {
|
||||
const siteType = markerData.site_type;
|
||||
const material = new THREE.SpriteMaterial({
|
||||
map: createMarkerTexture(siteType, Boolean(markerData.is_estimated)),
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
opacity: COMPUTE_CENTER_CONFIG.marker.baseOpacity,
|
||||
});
|
||||
const marker = new THREE.Sprite(material);
|
||||
const baseScale = getBaseScale(siteType);
|
||||
marker.position.copy(
|
||||
latLonToVector3(
|
||||
markerData.displayLatitude,
|
||||
markerData.displayLongitude,
|
||||
CONFIG.earthRadius + COMPUTE_CENTER_CONFIG.altitudeOffset,
|
||||
),
|
||||
);
|
||||
marker.scale.setScalar(baseScale);
|
||||
marker.renderOrder = COMPUTE_CENTER_RENDER_ORDER;
|
||||
marker.visible = showComputeCenters;
|
||||
marker.userData = {
|
||||
...markerData,
|
||||
site_type: siteType,
|
||||
type: "compute_center",
|
||||
baseScale,
|
||||
state: "normal",
|
||||
const computeCenterIconLayer = createInteractableLayer({
|
||||
id: "computeCenters",
|
||||
objectType: "compute_center",
|
||||
renderOrder: COMPUTE_CENTER_RENDER_ORDER,
|
||||
altitudeOffset: COMPUTE_CENTER_CONFIG.altitudeOffset,
|
||||
pointSize: COMPUTE_CENTER_POINT_SIZE,
|
||||
atlasCellSize: COMPUTE_CENTER_ATLAS_CELL_SIZE,
|
||||
colors: {
|
||||
byKind: COMPUTE_CENTER_CONFIG.colors,
|
||||
normal: COMPUTE_CENTER_CONFIG.colors.gpu_cluster,
|
||||
},
|
||||
opacity: {
|
||||
normal: COMPUTE_CENTER_CONFIG.marker.baseOpacity,
|
||||
dimmed: COMPUTE_CENTER_CONFIG.marker.dimmedOpacity,
|
||||
hover: 0.98,
|
||||
locked: 1,
|
||||
},
|
||||
stateScale: {
|
||||
hover: COMPUTE_CENTER_CONFIG.marker.hoverScale,
|
||||
locked: COMPUTE_CENTER_CONFIG.marker.lockedScale,
|
||||
dimmed: COMPUTE_CENTER_CONFIG.marker.dimmedScale,
|
||||
},
|
||||
pulse: {
|
||||
enabled: true,
|
||||
speed: COMPUTE_CENTER_CONFIG.marker.pulseSpeed,
|
||||
amplitude: COMPUTE_CENTER_CONFIG.marker.pulseAmplitude,
|
||||
},
|
||||
icon: {
|
||||
coordinates: "canvas",
|
||||
colorable: false,
|
||||
fitSize: COMPUTE_CENTER_ICON_FIT_SIZE,
|
||||
glowBlur: 16,
|
||||
getSource({ marker, item }) {
|
||||
const siteType =
|
||||
marker?.userData?.site_type || item?.site_type || "gpu_cluster";
|
||||
return (
|
||||
COMPUTE_CENTER_ICON_SOURCES[siteType] ||
|
||||
COMPUTE_CENTER_ICON_SOURCES.infrastructure
|
||||
);
|
||||
},
|
||||
afterDraw(context, { marker, item }) {
|
||||
drawComputeCenterEstimatedBadge(
|
||||
context,
|
||||
Boolean(marker?.userData?.is_estimated ?? item?.is_estimated),
|
||||
);
|
||||
},
|
||||
},
|
||||
getPosition: (item) => ({
|
||||
latitude: item.displayLatitude,
|
||||
longitude: item.displayLongitude,
|
||||
}),
|
||||
getKind: (item) => item.site_type || "gpu_cluster",
|
||||
getBucketKey: (marker) =>
|
||||
[
|
||||
marker.userData?.site_type || "gpu_cluster",
|
||||
marker.userData?.is_estimated ? "estimated" : "precise",
|
||||
].join(":"),
|
||||
getUserData: (item) => ({
|
||||
...item,
|
||||
pulseOffset: Math.random() * Math.PI * 2,
|
||||
};
|
||||
computeCenterGroup.add(marker);
|
||||
computeCenterMarkers.push(marker);
|
||||
return marker;
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
export function formatComputeCenterTypeLabel(siteType) {
|
||||
return siteType === "supercomputer" ? "超算中心" : "GPU 集群";
|
||||
@@ -252,11 +195,11 @@ export function getComputeCenterLegendItems() {
|
||||
}
|
||||
|
||||
export function getComputeCenterMarkers() {
|
||||
return computeCenterMarkers;
|
||||
return computeCenterIconLayer.getMarkers();
|
||||
}
|
||||
|
||||
export function getComputeCenterCount() {
|
||||
return computeCenterMarkers.length;
|
||||
return computeCenterIconLayer.getCount();
|
||||
}
|
||||
|
||||
export function getComputeCenterSupercomputerCount() {
|
||||
@@ -268,35 +211,27 @@ export function getComputeCenterGPUClusterCount() {
|
||||
}
|
||||
|
||||
export function getComputeCenterStatusSummary() {
|
||||
if (computeCenterMarkers.length === 0) return "暂无算力中心数据";
|
||||
if (getComputeCenterCount() === 0) return "暂无算力中心数据";
|
||||
return `${supercomputerCount} 台超算 / ${gpuClusterCount} 个 GPU 集群`;
|
||||
}
|
||||
|
||||
export function setComputeCenterMarkerState(marker, state = "normal") {
|
||||
if (!marker || marker.userData?.type !== "compute_center") return;
|
||||
marker.userData.state = state;
|
||||
computeCenterIconLayer.setMarkerState(marker, state);
|
||||
}
|
||||
|
||||
export function clearComputeCenterSelection() {
|
||||
computeCenterMarkers.forEach((marker) => setComputeCenterMarkerState(marker, "normal"));
|
||||
getComputeCenterMarkers().forEach((marker) => setComputeCenterMarkerState(marker, "normal"));
|
||||
}
|
||||
|
||||
export function clearComputeCenterData(earth) {
|
||||
computeCenterMarkers.length = 0;
|
||||
supercomputerCount = 0;
|
||||
gpuClusterCount = 0;
|
||||
clearGroup(computeCenterGroup);
|
||||
if (earth && computeCenterGroup.parent === earth) {
|
||||
earth.remove(computeCenterGroup);
|
||||
}
|
||||
computeCenterIconLayer.clearData(earth);
|
||||
}
|
||||
|
||||
export function toggleComputeCenters(show) {
|
||||
showComputeCenters = Boolean(show);
|
||||
computeCenterGroup.visible = showComputeCenters;
|
||||
computeCenterMarkers.forEach((marker) => {
|
||||
marker.visible = showComputeCenters;
|
||||
});
|
||||
computeCenterIconLayer.setVisible(showComputeCenters);
|
||||
}
|
||||
|
||||
export function getShowComputeCenters() {
|
||||
@@ -313,64 +248,37 @@ export async function loadComputeCenters(_scene, earth) {
|
||||
|
||||
clearComputeCenterData(earth);
|
||||
|
||||
spreadComputeCenterPositions(
|
||||
const markerData = spreadComputeCenterPositions(
|
||||
features
|
||||
.map((feature) => buildComputeCenterMarkerData(feature))
|
||||
.filter(Boolean),
|
||||
)
|
||||
.slice(0, COMPUTE_CENTER_CONFIG.maxRenderedMarkers)
|
||||
.forEach((markerData) => {
|
||||
const marker = createComputeCenterMarker(markerData);
|
||||
if (!marker) return;
|
||||
if (marker.userData.site_type === "supercomputer") {
|
||||
supercomputerCount += 1;
|
||||
} else {
|
||||
gpuClusterCount += 1;
|
||||
}
|
||||
});
|
||||
.slice(0, COMPUTE_CENTER_CONFIG.maxRenderedMarkers);
|
||||
|
||||
if (earth && !computeCenterGroup.parent) {
|
||||
earth.add(computeCenterGroup);
|
||||
}
|
||||
computeCenterGroup.visible = showComputeCenters;
|
||||
markerData.forEach((item) => {
|
||||
if (item.site_type === "supercomputer") {
|
||||
supercomputerCount += 1;
|
||||
} else {
|
||||
gpuClusterCount += 1;
|
||||
}
|
||||
});
|
||||
await computeCenterIconLayer.preloadAssets(markerData);
|
||||
computeCenterIconLayer.setData(markerData);
|
||||
computeCenterIconLayer.attach(earth);
|
||||
computeCenterIconLayer.setVisible(showComputeCenters);
|
||||
|
||||
return {
|
||||
totalCount: computeCenterMarkers.length,
|
||||
totalCount: getComputeCenterCount(),
|
||||
supercomputerCount,
|
||||
gpuClusterCount,
|
||||
summary: getComputeCenterStatusSummary(),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateComputeCenterVisualState(lockedObjectType, lockedObject, camera) {
|
||||
const hasFocus = lockedObjectType === "compute_center" && lockedObject;
|
||||
const now = Date.now();
|
||||
|
||||
computeCenterMarkers.forEach((marker) => {
|
||||
const isLocked = lockedObjectType === "compute_center" && lockedObject === marker;
|
||||
const state = marker.userData?.state || "normal";
|
||||
const pulse =
|
||||
1 +
|
||||
COMPUTE_CENTER_CONFIG.marker.pulseAmplitude *
|
||||
Math.sin(now * COMPUTE_CENTER_CONFIG.marker.pulseSpeed + marker.userData.pulseOffset);
|
||||
|
||||
let opacity = COMPUTE_CENTER_CONFIG.marker.baseOpacity;
|
||||
let scaleMultiplier = 1;
|
||||
|
||||
if (isLocked) {
|
||||
opacity = 1;
|
||||
scaleMultiplier = COMPUTE_CENTER_CONFIG.marker.lockedScale * pulse;
|
||||
} else if (state === "hover") {
|
||||
opacity = 0.98;
|
||||
scaleMultiplier = COMPUTE_CENTER_CONFIG.marker.hoverScale;
|
||||
} else if (hasFocus) {
|
||||
opacity = COMPUTE_CENTER_CONFIG.marker.dimmedOpacity;
|
||||
scaleMultiplier = COMPUTE_CENTER_CONFIG.marker.dimmedScale;
|
||||
}
|
||||
|
||||
const distanceScale = getDistanceScale(marker, camera);
|
||||
marker.material.opacity = showComputeCenters ? opacity : 0;
|
||||
marker.scale.setScalar(marker.userData.baseScale * scaleMultiplier * distanceScale);
|
||||
marker.visible = showComputeCenters;
|
||||
});
|
||||
export function getComputeCenterPointerIntersections(options) {
|
||||
return computeCenterIconLayer.getPointerIntersections(options);
|
||||
}
|
||||
|
||||
export function updateComputeCenterVisualState(lockedObjectType, lockedObject, camera) {
|
||||
computeCenterIconLayer.updateVisualState(lockedObjectType, lockedObject, camera);
|
||||
}
|
||||
|
||||
@@ -293,39 +293,20 @@ export const CABLE_CONFIG = {
|
||||
renderOrder: 1,
|
||||
},
|
||||
landingPoint: {
|
||||
altitudeOffset: 0.48,
|
||||
textureSize: 256,
|
||||
iconAspectRatio: 0.82,
|
||||
anchorX: 0.52,
|
||||
anchorY: 0.276,
|
||||
baseScale: 12,
|
||||
altitudeOffset: 0.2,
|
||||
color: 0xffaa00,
|
||||
emissive: 0x442200,
|
||||
emissiveIntensity: 0.5,
|
||||
opacity: 1.0,
|
||||
renderOrder: 4.5,
|
||||
},
|
||||
landingPointSizeStabilization: {
|
||||
enabled: true,
|
||||
referenceFov: 75,
|
||||
min: 0.12,
|
||||
max: 3.0,
|
||||
renderOrder: 1,
|
||||
},
|
||||
landingPointVisual: {
|
||||
pulseSpeed: 0.003,
|
||||
dimBrightness: 0.62,
|
||||
related: {
|
||||
emissiveIntensityBase: 0.5,
|
||||
emissiveIntensityPulse: 0.5,
|
||||
opacityBase: 0.8,
|
||||
opacityPulse: 0.2,
|
||||
scaleBase: 1.2,
|
||||
scalePulse: 0.3,
|
||||
},
|
||||
dimmed: {
|
||||
colorRGB: { r: 180, g: 116, b: 28 },
|
||||
emissive: 0x3a2200,
|
||||
emissiveIntensity: 0.18,
|
||||
opacity: 0.78,
|
||||
},
|
||||
},
|
||||
@@ -364,8 +345,8 @@ export const SATELLITE_CONFIG = {
|
||||
export const BGP_CONFIG = {
|
||||
defaultFetchLimit: 200,
|
||||
maxRenderedMarkers: 200,
|
||||
altitudeOffset: 2.1,
|
||||
collectorAltitudeOffset: 1.6,
|
||||
altitudeOffset: 0.48,
|
||||
collectorAltitudeOffset: 0.2,
|
||||
marker: {
|
||||
eventBaseScale: 6.2,
|
||||
collectorBaseScale: 7.4,
|
||||
|
||||
2
frontend/public/earth/js/controls.js
vendored
@@ -3301,6 +3301,8 @@ function setupToolbarHubCluster() {
|
||||
const toolbarHeight = maxVerticalReach + hubSize + TOOLBAR_BOTTOM_CLEARANCE_PX * toolbarScale + TOOLBAR_EXTRA_HEIGHT_PX * toolbarScale;
|
||||
|
||||
toolbar.style.setProperty("--toolbar-scale", toolbarScale.toFixed(3));
|
||||
toolbar.style.setProperty("--toolbar-orb-size", `${Math.round(orbSize)}px`);
|
||||
toolbar.style.setProperty("--toolbar-hub-size", `${Math.round(hubSize)}px`);
|
||||
toolbar.style.height = `${Math.ceil(toolbarHeight)}px`;
|
||||
toolbar.style.setProperty("--toolbar-arc-width", `${Math.ceil(span + orbSize + (TOOLBAR_SIDE_PADDING_PX * 2 * toolbarScale))}px`);
|
||||
toolbar.style.setProperty("--toolbar-arc-height", `${Math.ceil(rise + orbSize * 0.95)}px`);
|
||||
|
||||
899
frontend/public/earth/js/interactable.js
Normal file
@@ -0,0 +1,899 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import { CONFIG } from "./constants.js";
|
||||
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
|
||||
|
||||
const assetImageCache = new Map();
|
||||
const assetImageLoadPromises = new Map();
|
||||
const surfaceAvoidanceBuckets = new Map();
|
||||
const interactableLayerControllers = new Map();
|
||||
const DEFAULT_AVOIDANCE_PRECISION = 4;
|
||||
const DEFAULT_AVOIDANCE_RADIUS = 1.1;
|
||||
const DEFAULT_AVOIDANCE_STEP = 0.35;
|
||||
const AVOIDANCE_RING_SLOT_COUNT = 8;
|
||||
const TANGENT_EPSILON_SQ = 1e-6;
|
||||
const avoidanceNorthPole = new THREE.Vector3(0, 1, 0);
|
||||
const avoidanceFallbackEast = new THREE.Vector3(1, 0, 0);
|
||||
const avoidanceCenterScratch = new THREE.Vector3();
|
||||
const avoidanceEastScratch = new THREE.Vector3();
|
||||
const avoidanceNorthScratch = new THREE.Vector3();
|
||||
const avoidancePositionScratch = new THREE.Vector3();
|
||||
|
||||
function colorToRgbArray(colorValue, fallback = "#ffffff") {
|
||||
const color = new THREE.Color(colorValue || fallback);
|
||||
return [color.r, color.g, color.b];
|
||||
}
|
||||
|
||||
function toFiniteNumber(value, fallback) {
|
||||
const numericValue = Number(value);
|
||||
return Number.isFinite(numericValue) ? numericValue : fallback;
|
||||
}
|
||||
|
||||
function disposeGroupChildren(group) {
|
||||
for (let index = group.children.length - 1; index >= 0; index -= 1) {
|
||||
const child = group.children[index];
|
||||
child.material?.dispose?.();
|
||||
child.geometry?.dispose?.();
|
||||
group.remove(child);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePosition(position, radius) {
|
||||
if (position instanceof THREE.Vector3) {
|
||||
return position.clone();
|
||||
}
|
||||
const lat = Number(position?.latitude ?? position?.lat);
|
||||
const lon = Number(position?.longitude ?? position?.lon);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
return latLonToVector3(lat, lon, radius);
|
||||
}
|
||||
|
||||
function createCanvas(width, height) {
|
||||
if (typeof OffscreenCanvas !== "undefined") {
|
||||
return new OffscreenCanvas(width, height);
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function getAvoidanceKey(position, basePosition, precision = 4) {
|
||||
if (position instanceof THREE.Vector3) {
|
||||
return [
|
||||
"vec",
|
||||
basePosition.x.toFixed(precision),
|
||||
basePosition.y.toFixed(precision),
|
||||
basePosition.z.toFixed(precision),
|
||||
].join(":");
|
||||
}
|
||||
|
||||
const lat = Number(position?.latitude ?? position?.lat);
|
||||
const lon = Number(position?.longitude ?? position?.lon);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
return ["geo", lat.toFixed(precision), lon.toFixed(precision)].join(":");
|
||||
}
|
||||
|
||||
function notifyAvoidancePositionChanged(layerIds) {
|
||||
layerIds.forEach((layerId) => {
|
||||
interactableLayerControllers.get(layerId)?.refreshPositions?.();
|
||||
});
|
||||
}
|
||||
|
||||
function recomputeAvoidanceBucket(key) {
|
||||
const entries = surfaceAvoidanceBuckets.get(key);
|
||||
if (!entries || entries.length === 0) return;
|
||||
|
||||
const affectedLayerIds = new Set(entries.map((entry) => entry.layerId));
|
||||
if (entries.length === 1) {
|
||||
const entry = entries[0];
|
||||
entry.marker.position.copy(entry.marker.userData.icon_base_position);
|
||||
entry.marker.userData.icon_avoidance_index = 0;
|
||||
entry.marker.userData.icon_avoidance_count = 1;
|
||||
notifyAvoidancePositionChanged(affectedLayerIds);
|
||||
return;
|
||||
}
|
||||
|
||||
const count = entries.length;
|
||||
entries.forEach((entry, index) => {
|
||||
const basePosition =
|
||||
entry.marker.userData.icon_base_position || entry.marker.position;
|
||||
const altitudeRadius = basePosition.length();
|
||||
const ringIndex = Math.floor(index / AVOIDANCE_RING_SLOT_COUNT);
|
||||
const radius =
|
||||
Math.max(0, entry.radius) + ringIndex * Math.max(0, entry.step);
|
||||
const angle = -Math.PI / 2 + (Math.PI * 2 * index) / count;
|
||||
|
||||
avoidanceCenterScratch.copy(basePosition).normalize();
|
||||
avoidanceEastScratch
|
||||
.copy(avoidanceNorthPole)
|
||||
.cross(avoidanceCenterScratch);
|
||||
if (avoidanceEastScratch.lengthSq() < TANGENT_EPSILON_SQ) {
|
||||
avoidanceEastScratch.copy(avoidanceFallbackEast);
|
||||
}
|
||||
avoidanceEastScratch.normalize();
|
||||
avoidanceNorthScratch
|
||||
.copy(avoidanceCenterScratch)
|
||||
.cross(avoidanceEastScratch)
|
||||
.normalize();
|
||||
|
||||
avoidancePositionScratch
|
||||
.copy(basePosition)
|
||||
.addScaledVector(avoidanceEastScratch, Math.cos(angle) * radius)
|
||||
.addScaledVector(avoidanceNorthScratch, Math.sin(angle) * radius)
|
||||
.normalize()
|
||||
.multiplyScalar(altitudeRadius);
|
||||
|
||||
entry.marker.position.copy(avoidancePositionScratch);
|
||||
entry.marker.userData.icon_avoidance_index = index;
|
||||
entry.marker.userData.icon_avoidance_count = count;
|
||||
});
|
||||
|
||||
notifyAvoidancePositionChanged(affectedLayerIds);
|
||||
}
|
||||
|
||||
function unregisterLayerAvoidance(layerId) {
|
||||
const affectedKeys = new Set();
|
||||
surfaceAvoidanceBuckets.forEach((entries, key) => {
|
||||
const nextEntries = entries.filter((entry) => entry.layerId !== layerId);
|
||||
if (nextEntries.length !== entries.length) {
|
||||
affectedKeys.add(key);
|
||||
}
|
||||
if (nextEntries.length === 0) {
|
||||
surfaceAvoidanceBuckets.delete(key);
|
||||
} else {
|
||||
surfaceAvoidanceBuckets.set(key, nextEntries);
|
||||
}
|
||||
});
|
||||
affectedKeys.forEach((key) => recomputeAvoidanceBucket(key));
|
||||
}
|
||||
|
||||
function registerLayerAvoidance(layerId, markers, avoidanceConfig) {
|
||||
if (avoidanceConfig.enabled === false) return;
|
||||
|
||||
const affectedKeys = new Set();
|
||||
markers.forEach((marker) => {
|
||||
const key = marker.userData?.icon_avoidance_key;
|
||||
if (!key) return;
|
||||
if (!surfaceAvoidanceBuckets.has(key)) {
|
||||
surfaceAvoidanceBuckets.set(key, []);
|
||||
}
|
||||
surfaceAvoidanceBuckets.get(key).push({
|
||||
layerId,
|
||||
marker,
|
||||
radius: toFiniteNumber(
|
||||
avoidanceConfig.radius,
|
||||
DEFAULT_AVOIDANCE_RADIUS,
|
||||
),
|
||||
step: toFiniteNumber(avoidanceConfig.step, DEFAULT_AVOIDANCE_STEP),
|
||||
});
|
||||
affectedKeys.add(key);
|
||||
});
|
||||
|
||||
affectedKeys.forEach((key) => recomputeAvoidanceBucket(key));
|
||||
}
|
||||
|
||||
function loadAssetImage(source) {
|
||||
if (!source) return Promise.resolve(null);
|
||||
if (assetImageCache.has(source)) {
|
||||
return Promise.resolve(assetImageCache.get(source));
|
||||
}
|
||||
if (assetImageLoadPromises.has(source)) {
|
||||
return assetImageLoadPromises.get(source);
|
||||
}
|
||||
|
||||
const loadPromise = new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
assetImageCache.set(source, image);
|
||||
assetImageLoadPromises.delete(source);
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = () => {
|
||||
assetImageLoadPromises.delete(source);
|
||||
reject(new Error(`Failed to load interactable icon asset: ${source}`));
|
||||
};
|
||||
image.src = source;
|
||||
});
|
||||
|
||||
assetImageLoadPromises.set(source, loadPromise);
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
export function createInteractableLayer(options = {}) {
|
||||
const {
|
||||
id,
|
||||
objectType = id,
|
||||
renderOrder = 4,
|
||||
altitudeOffset = 0.2,
|
||||
pointSize = 32,
|
||||
sizeMode = "fixed",
|
||||
sizeScale = {},
|
||||
atlasCellSize = 128,
|
||||
material = {},
|
||||
colors = {},
|
||||
opacity = {},
|
||||
stateScale = {},
|
||||
pulse = {},
|
||||
avoidance = {},
|
||||
icon,
|
||||
getPosition,
|
||||
getKind = (item) => item?.type || "default",
|
||||
getRotationBin = () => 0,
|
||||
getBucketKey = (marker) => String(getRotationBin(marker)),
|
||||
getPointSizeMultiplier = () => 1,
|
||||
getPointOpacity = null,
|
||||
getUserData = (item) => item,
|
||||
dynamicVisuals = false,
|
||||
} = options;
|
||||
|
||||
if (!id) {
|
||||
throw new Error("createInteractableLayer requires an id");
|
||||
}
|
||||
if (!icon?.draw && !icon?.source && !icon?.getSource) {
|
||||
throw new Error(`Interactable layer ${id} requires icon.draw, icon.source, or icon.getSource`);
|
||||
}
|
||||
|
||||
const group = new THREE.Group();
|
||||
group.name = `interactable-layer:${id}`;
|
||||
group.renderOrder = renderOrder;
|
||||
group.userData = { type: "interactable_layer", id };
|
||||
|
||||
const markers = [];
|
||||
const pointObjects = [];
|
||||
const textureCache = new Map();
|
||||
let pointsGroup = null;
|
||||
let hoverOverlay = null;
|
||||
let lockedOverlay = null;
|
||||
let visible = false;
|
||||
let lastVisualStateKey = "";
|
||||
let visualStateVersion = 0;
|
||||
const scratchDirection = new THREE.Vector3();
|
||||
const scratchCameraLocal = new THREE.Vector3();
|
||||
const scratchWorldPosition = new THREE.Vector3();
|
||||
const scratchScreenPosition = new THREE.Vector3();
|
||||
const viewportSize = new THREE.Vector2(1, 1);
|
||||
|
||||
const baseOpacity = opacity.normal ?? 0.88;
|
||||
const dimmedOpacity = opacity.dimmed ?? 0.26;
|
||||
const hoverOpacity = opacity.hover ?? 0.98;
|
||||
const lockedOpacity = opacity.locked ?? 1;
|
||||
const hoverScale = stateScale.hover ?? 1;
|
||||
const lockedScale = stateScale.locked ?? 1;
|
||||
const dimmedScale = stateScale.dimmed ?? 1;
|
||||
const depthTest = material.depthTest ?? true;
|
||||
const depthWrite = material.depthWrite ?? false;
|
||||
const alphaTest = material.alphaTest ?? 0.01;
|
||||
const usesDistanceScaling = sizeMode !== "fixed";
|
||||
const iconAnchor = new THREE.Vector2(
|
||||
Number(icon.anchor?.x ?? icon.anchor?.[0] ?? 0.5),
|
||||
Number(icon.anchor?.y ?? icon.anchor?.[1] ?? 0.5),
|
||||
);
|
||||
const usesIconAnchor =
|
||||
Math.abs(iconAnchor.x - 0.5) > 0.001 ||
|
||||
Math.abs(iconAnchor.y - 0.5) > 0.001;
|
||||
const avoidanceConfig = {
|
||||
enabled: true,
|
||||
precision: DEFAULT_AVOIDANCE_PRECISION,
|
||||
radius: DEFAULT_AVOIDANCE_RADIUS,
|
||||
step: DEFAULT_AVOIDANCE_STEP,
|
||||
...avoidance,
|
||||
};
|
||||
|
||||
function invalidateVisualState() {
|
||||
visualStateVersion += 1;
|
||||
lastVisualStateKey = "";
|
||||
}
|
||||
|
||||
function refreshViewportSize() {
|
||||
const pixelRatio = window.devicePixelRatio || 1;
|
||||
viewportSize.set(
|
||||
(window.innerWidth || 1) * pixelRatio,
|
||||
(window.innerHeight || 1) * pixelRatio,
|
||||
);
|
||||
}
|
||||
|
||||
function applyIconAnchor(material) {
|
||||
if (!usesIconAnchor) return material;
|
||||
|
||||
material.defines = {
|
||||
...(material.defines || {}),
|
||||
USE_INTERACTABLE_ICON_ANCHOR: "",
|
||||
};
|
||||
material.onBeforeCompile = (shader) => {
|
||||
shader.uniforms.interactableIconAnchor = { value: iconAnchor };
|
||||
shader.uniforms.interactableViewportSize = { value: viewportSize };
|
||||
shader.vertexShader = shader.vertexShader
|
||||
.replace(
|
||||
"#include <common>",
|
||||
[
|
||||
"#include <common>",
|
||||
"uniform vec2 interactableIconAnchor;",
|
||||
"uniform vec2 interactableViewportSize;",
|
||||
].join("\n"),
|
||||
)
|
||||
.replace(
|
||||
"#include <project_vertex>",
|
||||
[
|
||||
"#include <project_vertex>",
|
||||
"#ifdef USE_INTERACTABLE_ICON_ANCHOR",
|
||||
" vec2 interactableAnchorOffset = vec2((0.5 - interactableIconAnchor.x) * size, (interactableIconAnchor.y - 0.5) * size);",
|
||||
" gl_Position.xy += (interactableAnchorOffset / interactableViewportSize) * 2.0 * gl_Position.w;",
|
||||
"#endif",
|
||||
].join("\n"),
|
||||
);
|
||||
};
|
||||
material.customProgramCacheKey = () =>
|
||||
`interactable-icon-anchor:${iconAnchor.x.toFixed(3)}:${iconAnchor.y.toFixed(3)}`;
|
||||
return material;
|
||||
}
|
||||
|
||||
function getMarkerColor(marker) {
|
||||
const kind = marker?.userData?.icon_kind || getKind(marker?.userData);
|
||||
return colors.byKind?.[kind] || colors[kind] || colors.normal || "#ffffff";
|
||||
}
|
||||
|
||||
function getIconSource(drawOptions) {
|
||||
const stateSource = icon.stateSources?.[drawOptions.state];
|
||||
if (stateSource) return stateSource;
|
||||
if (typeof icon.getSource === "function") {
|
||||
return icon.getSource(drawOptions);
|
||||
}
|
||||
return icon.source;
|
||||
}
|
||||
|
||||
function drawAssetIcon(context, source, drawOptions) {
|
||||
const image = assetImageCache.get(source);
|
||||
if (!image) {
|
||||
icon.fallbackDraw?.(context, drawOptions);
|
||||
return;
|
||||
}
|
||||
|
||||
if (drawOptions.glow) {
|
||||
context.shadowColor = drawOptions.color || "#ffffff";
|
||||
context.shadowBlur = icon.glowBlur ?? 14;
|
||||
}
|
||||
|
||||
const fitSize =
|
||||
typeof icon.fitSize === "function"
|
||||
? icon.fitSize(drawOptions)
|
||||
: icon.fitSize;
|
||||
const fitWidth =
|
||||
typeof fitSize === "number"
|
||||
? fitSize
|
||||
: Number(fitSize?.width ?? atlasCellSize);
|
||||
const fitHeight =
|
||||
typeof fitSize === "number"
|
||||
? fitSize
|
||||
: Number(fitSize?.height ?? atlasCellSize);
|
||||
const maxWidth = Number.isFinite(fitWidth) ? fitWidth : atlasCellSize;
|
||||
const maxHeight = Number.isFinite(fitHeight) ? fitHeight : atlasCellSize;
|
||||
const sourceWidth = image.naturalWidth || image.width || atlasCellSize;
|
||||
const sourceHeight = image.naturalHeight || image.height || atlasCellSize;
|
||||
const scale = Math.min(maxWidth / sourceWidth, maxHeight / sourceHeight);
|
||||
const drawWidth = sourceWidth * scale;
|
||||
const drawHeight = sourceHeight * scale;
|
||||
const drawX = (atlasCellSize - drawWidth) / 2;
|
||||
const drawY = (atlasCellSize - drawHeight) / 2;
|
||||
|
||||
if (icon.colorable !== false && drawOptions.color) {
|
||||
const tintCanvas = createCanvas(atlasCellSize, atlasCellSize);
|
||||
const tintContext = tintCanvas.getContext("2d");
|
||||
tintContext.clearRect(0, 0, atlasCellSize, atlasCellSize);
|
||||
tintContext.drawImage(image, drawX, drawY, drawWidth, drawHeight);
|
||||
tintContext.globalCompositeOperation = "source-in";
|
||||
tintContext.fillStyle = drawOptions.color;
|
||||
tintContext.fillRect(0, 0, atlasCellSize, atlasCellSize);
|
||||
context.drawImage(tintCanvas, 0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
context.drawImage(image, drawX, drawY, drawWidth, drawHeight);
|
||||
}
|
||||
|
||||
function drawIconTexture(textureKey, drawOptions) {
|
||||
if (textureCache.has(textureKey)) return textureCache.get(textureKey);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = atlasCellSize;
|
||||
canvas.height = atlasCellSize;
|
||||
const context = canvas.getContext("2d");
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.save();
|
||||
const resolvedDrawOptions = {
|
||||
atlasCellSize,
|
||||
...drawOptions,
|
||||
};
|
||||
if (icon.coordinates !== "canvas") {
|
||||
context.translate(canvas.width / 2, canvas.height / 2);
|
||||
}
|
||||
if (icon.draw) {
|
||||
icon.draw(context, resolvedDrawOptions);
|
||||
} else {
|
||||
drawAssetIcon(context, getIconSource(resolvedDrawOptions), resolvedDrawOptions);
|
||||
}
|
||||
icon.afterDraw?.(context, resolvedDrawOptions);
|
||||
context.restore();
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.generateMipmaps = false;
|
||||
texture.minFilter = THREE.LinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
texture.needsUpdate = true;
|
||||
textureCache.set(textureKey, texture);
|
||||
return texture;
|
||||
}
|
||||
|
||||
function createPointTexture(bucketKey, bucketMarkers) {
|
||||
const sampleMarker = bucketMarkers[0];
|
||||
const rotationBin = getRotationBin(sampleMarker);
|
||||
return drawIconTexture(`point:${bucketKey}`, {
|
||||
marker: sampleMarker,
|
||||
bucketKey,
|
||||
rotationBin,
|
||||
glow: false,
|
||||
color: "#ffffff",
|
||||
state: "normal",
|
||||
});
|
||||
}
|
||||
|
||||
function createOverlayTexture(marker, state) {
|
||||
const kind = marker?.userData?.icon_kind || "default";
|
||||
const rotationBin = getRotationBin(marker);
|
||||
const color = getMarkerColor(marker);
|
||||
const textureKey = [
|
||||
"overlay",
|
||||
state,
|
||||
kind,
|
||||
getBucketKey(marker),
|
||||
rotationBin,
|
||||
color,
|
||||
].join(":");
|
||||
return drawIconTexture(textureKey, {
|
||||
marker,
|
||||
bucketKey: getBucketKey(marker),
|
||||
rotationBin,
|
||||
glow: true,
|
||||
color,
|
||||
state,
|
||||
});
|
||||
}
|
||||
|
||||
function getCameraScale(camera) {
|
||||
if (!usesDistanceScaling || !camera) return 1;
|
||||
return getSurfaceMarkerCameraScale(camera, {
|
||||
altitudeOffset,
|
||||
referenceFov: sizeScale.referenceFov ?? 75,
|
||||
min: sizeScale.min ?? 0.12,
|
||||
max: sizeScale.max ?? 3,
|
||||
});
|
||||
}
|
||||
|
||||
function buildPoints() {
|
||||
refreshViewportSize();
|
||||
pointsGroup = new THREE.Group();
|
||||
pointsGroup.visible = visible;
|
||||
pointsGroup.renderOrder = renderOrder;
|
||||
pointsGroup.userData = { type: `${id}_points`, id };
|
||||
pointObjects.length = 0;
|
||||
|
||||
const buckets = new Map();
|
||||
markers.forEach((marker) => {
|
||||
const key = getBucketKey(marker);
|
||||
if (!buckets.has(key)) {
|
||||
buckets.set(key, []);
|
||||
}
|
||||
buckets.get(key).push(marker);
|
||||
});
|
||||
|
||||
buckets.forEach((bucketMarkers, bucketKey) => {
|
||||
const count = bucketMarkers.length;
|
||||
const positions = new Float32Array(count * 3);
|
||||
const colorValues = new Float32Array(count * 3);
|
||||
|
||||
bucketMarkers.forEach((marker, index) => {
|
||||
positions[index * 3] = marker.position.x;
|
||||
positions[index * 3 + 1] = marker.position.y;
|
||||
positions[index * 3 + 2] = marker.position.z;
|
||||
const pointColor =
|
||||
icon.colorable === false ? "#ffffff" : getMarkerColor(marker);
|
||||
const [r, g, b] = colorToRgbArray(pointColor);
|
||||
colorValues[index * 3] = r;
|
||||
colorValues[index * 3 + 1] = g;
|
||||
colorValues[index * 3 + 2] = b;
|
||||
});
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute("color", new THREE.BufferAttribute(colorValues, 3));
|
||||
geometry.computeBoundingSphere();
|
||||
|
||||
const material = applyIconAnchor(
|
||||
new THREE.PointsMaterial({
|
||||
map: createPointTexture(bucketKey, bucketMarkers),
|
||||
size: pointSize * getPointSizeMultiplier(bucketMarkers[0]),
|
||||
sizeAttenuation: false,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: getPointOpacity?.(bucketMarkers[0]) ?? baseOpacity,
|
||||
depthWrite,
|
||||
depthTest,
|
||||
alphaTest,
|
||||
}),
|
||||
);
|
||||
|
||||
const points = new THREE.Points(geometry, material);
|
||||
points.renderOrder = renderOrder;
|
||||
points.frustumCulled = false;
|
||||
points.userData = {
|
||||
type: `${id}_points`,
|
||||
id,
|
||||
bucketKey,
|
||||
markers: bucketMarkers,
|
||||
pointSizeMultiplier: getPointSizeMultiplier(bucketMarkers[0]),
|
||||
};
|
||||
pointObjects.push(points);
|
||||
pointsGroup.add(points);
|
||||
});
|
||||
|
||||
group.add(pointsGroup);
|
||||
}
|
||||
|
||||
function ensureOverlay(kind) {
|
||||
const existing = kind === "locked" ? lockedOverlay : hoverOverlay;
|
||||
if (existing) return existing;
|
||||
refreshViewportSize();
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(new Float32Array(3), 3),
|
||||
);
|
||||
const material = applyIconAnchor(
|
||||
new THREE.PointsMaterial({
|
||||
size: pointSize,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
depthWrite,
|
||||
depthTest,
|
||||
opacity: 1,
|
||||
alphaTest,
|
||||
}),
|
||||
);
|
||||
const overlay = new THREE.Points(geometry, material);
|
||||
overlay.renderOrder = renderOrder + (kind === "locked" ? 0.2 : 0.1);
|
||||
overlay.frustumCulled = false;
|
||||
overlay.visible = false;
|
||||
overlay.userData = { type: `${id}_${kind}_overlay`, id };
|
||||
group.add(overlay);
|
||||
|
||||
if (kind === "locked") {
|
||||
lockedOverlay = overlay;
|
||||
} else {
|
||||
hoverOverlay = overlay;
|
||||
}
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function updateOverlay(overlay, marker, state, nextOpacity, sizeMultiplier = 1) {
|
||||
if (!overlay) return;
|
||||
if (!marker) {
|
||||
overlay.visible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const texture = createOverlayTexture(marker, state);
|
||||
if (overlay.material.map !== texture) {
|
||||
overlay.material.map = texture;
|
||||
overlay.material.needsUpdate = true;
|
||||
}
|
||||
overlay.material.opacity = nextOpacity;
|
||||
overlay.material.size =
|
||||
pointSize * getPointSizeMultiplier(marker) * sizeMultiplier;
|
||||
const positionAttribute = overlay.geometry.getAttribute("position");
|
||||
positionAttribute.setXYZ(0, marker.position.x, marker.position.y, marker.position.z);
|
||||
positionAttribute.needsUpdate = true;
|
||||
overlay.visible = visible;
|
||||
}
|
||||
|
||||
function clearRenderObjects() {
|
||||
if (pointsGroup?.parent) {
|
||||
pointsGroup.parent.remove(pointsGroup);
|
||||
}
|
||||
pointObjects.forEach((points) => {
|
||||
points.geometry?.dispose?.();
|
||||
points.material?.dispose?.();
|
||||
});
|
||||
hoverOverlay?.geometry?.dispose?.();
|
||||
hoverOverlay?.material?.dispose?.();
|
||||
hoverOverlay?.parent?.remove?.(hoverOverlay);
|
||||
lockedOverlay?.geometry?.dispose?.();
|
||||
lockedOverlay?.material?.dispose?.();
|
||||
lockedOverlay?.parent?.remove?.(lockedOverlay);
|
||||
pointsGroup = null;
|
||||
pointObjects.length = 0;
|
||||
hoverOverlay = null;
|
||||
lockedOverlay = null;
|
||||
}
|
||||
|
||||
function refreshPositions() {
|
||||
pointObjects.forEach((points) => {
|
||||
const bucketMarkers = points.userData?.markers || [];
|
||||
const positionAttribute = points.geometry?.getAttribute("position");
|
||||
if (!positionAttribute) return;
|
||||
bucketMarkers.forEach((marker, index) => {
|
||||
positionAttribute.setXYZ(
|
||||
index,
|
||||
marker.position.x,
|
||||
marker.position.y,
|
||||
marker.position.z,
|
||||
);
|
||||
});
|
||||
positionAttribute.needsUpdate = true;
|
||||
points.geometry.computeBoundingSphere();
|
||||
});
|
||||
invalidateVisualState();
|
||||
}
|
||||
|
||||
function refreshVisuals() {
|
||||
invalidateVisualState();
|
||||
if (!pointsGroup) return;
|
||||
clearRenderObjects();
|
||||
buildPoints();
|
||||
group.visible = visible;
|
||||
}
|
||||
|
||||
function setData(items = []) {
|
||||
invalidateVisualState();
|
||||
unregisterLayerAvoidance(id);
|
||||
markers.length = 0;
|
||||
clearRenderObjects();
|
||||
disposeGroupChildren(group);
|
||||
|
||||
const radius = CONFIG.earthRadius + altitudeOffset;
|
||||
items.forEach((item) => {
|
||||
const rawPosition = getPosition(item);
|
||||
const position = normalizePosition(rawPosition, radius);
|
||||
if (!position) return;
|
||||
const kind = getKind(item);
|
||||
const avoidanceKey = getAvoidanceKey(
|
||||
rawPosition,
|
||||
position,
|
||||
avoidanceConfig.precision,
|
||||
);
|
||||
const marker = new THREE.Object3D();
|
||||
marker.position.copy(position);
|
||||
marker.userData = {
|
||||
...getUserData(item),
|
||||
type: objectType,
|
||||
icon_layer_id: id,
|
||||
icon_kind: kind,
|
||||
icon_base_position: position.clone(),
|
||||
icon_avoidance_key: avoidanceKey,
|
||||
state: "normal",
|
||||
};
|
||||
markers.push(marker);
|
||||
});
|
||||
|
||||
registerLayerAvoidance(id, markers, avoidanceConfig);
|
||||
buildPoints();
|
||||
group.visible = visible;
|
||||
}
|
||||
|
||||
async function preloadAssets(items = []) {
|
||||
if (icon.draw && !icon.source && !icon.getSource && !icon.stateSources) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sources = new Set();
|
||||
const states = ["normal", "hover", "locked"];
|
||||
items.forEach((item) => {
|
||||
const kind = getKind(item);
|
||||
const marker = {
|
||||
userData: {
|
||||
...getUserData(item),
|
||||
type: objectType,
|
||||
icon_layer_id: id,
|
||||
icon_kind: kind,
|
||||
state: "normal",
|
||||
},
|
||||
};
|
||||
states.forEach((state) => {
|
||||
const source = getIconSource({
|
||||
marker,
|
||||
item,
|
||||
state,
|
||||
color:
|
||||
colors.byKind?.[kind] ||
|
||||
colors[kind] ||
|
||||
colors.normal ||
|
||||
"#ffffff",
|
||||
});
|
||||
if (source) sources.add(source);
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(Array.from(sources).map((source) => loadAssetImage(source)));
|
||||
}
|
||||
|
||||
function clearData(parent) {
|
||||
invalidateVisualState();
|
||||
unregisterLayerAvoidance(id);
|
||||
markers.length = 0;
|
||||
clearRenderObjects();
|
||||
disposeGroupChildren(group);
|
||||
if (parent && group.parent === parent) {
|
||||
parent.remove(group);
|
||||
}
|
||||
}
|
||||
|
||||
function attach(parent) {
|
||||
if (parent && !group.parent) {
|
||||
parent.add(group);
|
||||
}
|
||||
group.visible = visible;
|
||||
}
|
||||
|
||||
function setVisible(nextVisible) {
|
||||
visible = Boolean(nextVisible);
|
||||
invalidateVisualState();
|
||||
group.visible = visible;
|
||||
if (pointsGroup) {
|
||||
pointsGroup.visible = visible;
|
||||
}
|
||||
}
|
||||
|
||||
function setMarkerState(marker, state = "normal") {
|
||||
if (!marker || marker.userData?.type !== objectType) return;
|
||||
if (marker.userData.state === state) return;
|
||||
marker.userData.state = state;
|
||||
invalidateVisualState();
|
||||
}
|
||||
|
||||
function updateVisualState(focusType, focusObject, camera) {
|
||||
refreshViewportSize();
|
||||
if (!visible || markers.length === 0 || !pointsGroup) {
|
||||
if (lastVisualStateKey !== "hidden") {
|
||||
if (pointsGroup) pointsGroup.visible = false;
|
||||
if (hoverOverlay) hoverOverlay.visible = false;
|
||||
if (lockedOverlay) lockedOverlay.visible = false;
|
||||
lastVisualStateKey = "hidden";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
pointsGroup.visible = true;
|
||||
const hasFocus = focusType === objectType && focusObject;
|
||||
const lockedKey = hasFocus
|
||||
? focusObject?.userData?.mmsi || focusObject?.uuid || "locked"
|
||||
: "none";
|
||||
const stateKey = [
|
||||
"visible",
|
||||
focusType || "none",
|
||||
lockedKey,
|
||||
visualStateVersion,
|
||||
].join(":");
|
||||
|
||||
const cameraScale = getCameraScale(camera);
|
||||
const scaleKey = usesDistanceScaling ? cameraScale.toFixed(3) : "fixed";
|
||||
const nextStateKey = `${stateKey}:${scaleKey}`;
|
||||
|
||||
if (
|
||||
nextStateKey === lastVisualStateKey &&
|
||||
!(pulse.enabled && hasFocus) &&
|
||||
!dynamicVisuals
|
||||
) return;
|
||||
lastVisualStateKey = nextStateKey;
|
||||
|
||||
pointObjects.forEach((points) => {
|
||||
const sampleMarker = points.userData?.markers?.[0];
|
||||
points.visible = visible;
|
||||
points.material.opacity =
|
||||
getPointOpacity?.(sampleMarker) ??
|
||||
(hasFocus ? dimmedOpacity : baseOpacity);
|
||||
points.material.size =
|
||||
pointSize *
|
||||
getPointSizeMultiplier(sampleMarker) *
|
||||
cameraScale *
|
||||
(hasFocus ? dimmedScale : 1);
|
||||
});
|
||||
|
||||
const hoverMarker = markers.find(
|
||||
(marker) => marker.userData?.state === "hover" && marker !== focusObject,
|
||||
);
|
||||
updateOverlay(
|
||||
ensureOverlay("hover"),
|
||||
hoverMarker,
|
||||
"hover",
|
||||
hoverOpacity,
|
||||
hoverScale * cameraScale,
|
||||
);
|
||||
const lockedPulse =
|
||||
pulse.enabled && hasFocus
|
||||
? 1 + (pulse.amplitude ?? 0) * Math.sin(Date.now() * (pulse.speed ?? 0) + (focusObject?.userData?.pulseOffset ?? 0))
|
||||
: 1;
|
||||
updateOverlay(
|
||||
ensureOverlay("locked"),
|
||||
hasFocus ? focusObject : null,
|
||||
"locked",
|
||||
lockedOpacity,
|
||||
lockedScale * lockedPulse * cameraScale,
|
||||
);
|
||||
}
|
||||
|
||||
function getPointerIntersections({
|
||||
earth,
|
||||
camera,
|
||||
pointer,
|
||||
radiusPx = 20,
|
||||
width = window.innerWidth,
|
||||
height = window.innerHeight,
|
||||
frontFacingDotThreshold = 0,
|
||||
} = {}) {
|
||||
if (!earth || !camera || !pointer) return [];
|
||||
|
||||
scratchCameraLocal.copy(camera.position);
|
||||
earth.worldToLocal(scratchCameraLocal);
|
||||
scratchCameraLocal.normalize();
|
||||
|
||||
const pointerX = ((pointer.x + 1) / 2) * width;
|
||||
const pointerY = ((1 - pointer.y) / 2) * height;
|
||||
const radiusSq = radiusPx * radiusPx;
|
||||
const intersections = [];
|
||||
const cameraScale = getCameraScale(camera);
|
||||
|
||||
markers.forEach((marker) => {
|
||||
scratchDirection.copy(marker.position).normalize();
|
||||
if (scratchCameraLocal.dot(scratchDirection) <= frontFacingDotThreshold) {
|
||||
return;
|
||||
}
|
||||
|
||||
scratchWorldPosition.copy(marker.position);
|
||||
earth.localToWorld(scratchWorldPosition);
|
||||
scratchScreenPosition.copy(scratchWorldPosition).project(camera);
|
||||
if (scratchScreenPosition.z < -1 || scratchScreenPosition.z > 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const screenX = (scratchScreenPosition.x * 0.5 + 0.5) * width;
|
||||
const screenY = (-scratchScreenPosition.y * 0.5 + 0.5) * height;
|
||||
const pointSizeMultiplier = getPointSizeMultiplier(marker) * cameraScale;
|
||||
const visualCenterX =
|
||||
screenX + (0.5 - iconAnchor.x) * pointSize * pointSizeMultiplier;
|
||||
const visualCenterY =
|
||||
screenY + (0.5 - iconAnchor.y) * pointSize * pointSizeMultiplier;
|
||||
const deltaX = visualCenterX - pointerX;
|
||||
const deltaY = visualCenterY - pointerY;
|
||||
const distancePxSq = deltaX * deltaX + deltaY * deltaY;
|
||||
if (distancePxSq > radiusSq) return;
|
||||
|
||||
intersections.push({
|
||||
object: marker,
|
||||
point: scratchWorldPosition.clone(),
|
||||
distance: camera.position.distanceTo(scratchWorldPosition),
|
||||
distancePxSq,
|
||||
});
|
||||
});
|
||||
|
||||
return intersections.sort((a, b) => a.distancePxSq - b.distancePxSq);
|
||||
}
|
||||
|
||||
interactableLayerControllers.set(id, { refreshPositions, refreshVisuals });
|
||||
|
||||
return {
|
||||
group,
|
||||
markers,
|
||||
getMarkers: () => markers,
|
||||
getCount: () => markers.length,
|
||||
isVisible: () => visible,
|
||||
setData,
|
||||
preloadAssets,
|
||||
clearData,
|
||||
attach,
|
||||
setVisible,
|
||||
setMarkerState,
|
||||
updateVisualState,
|
||||
getPointerIntersections,
|
||||
refreshVisuals,
|
||||
};
|
||||
}
|
||||
@@ -123,6 +123,8 @@ import {
|
||||
import {
|
||||
loadBGPAnomalies,
|
||||
getBGPAnomalyMarkers,
|
||||
getBGPAnomalyPointerIntersections as getBGPEventIconPointerIntersections,
|
||||
getBGPCollectorPointerIntersections as getBGPCollectorIconPointerIntersections,
|
||||
getBGPCollectorMarkers,
|
||||
getBGPLegendItems,
|
||||
getBGPCount,
|
||||
@@ -162,6 +164,7 @@ import {
|
||||
getComputeCenterCount,
|
||||
getComputeCenterLegendItems,
|
||||
getComputeCenterMarkers,
|
||||
getComputeCenterPointerIntersections as getComputeCenterIconPointerIntersections,
|
||||
getShowComputeCenters,
|
||||
loadComputeCenters,
|
||||
setComputeCenterMarkerState,
|
||||
@@ -175,6 +178,7 @@ import {
|
||||
getVesselCount,
|
||||
getVesselLegendItems,
|
||||
getVesselMarkers,
|
||||
getVesselPointerIntersections as getVesselIconPointerIntersections,
|
||||
loadVessels,
|
||||
setVesselMarkerState,
|
||||
showVesselTrack,
|
||||
@@ -291,14 +295,6 @@ const interactionMouse = new THREE.Vector2();
|
||||
const scratchCameraToEarth = new THREE.Vector3();
|
||||
const scratchCableCenter = new THREE.Vector3();
|
||||
const scratchCableDirection = new THREE.Vector3();
|
||||
const scratchBGPDirection = new THREE.Vector3();
|
||||
const scratchBGPWorldPosition = new THREE.Vector3();
|
||||
const scratchComputeCenterDirection = new THREE.Vector3();
|
||||
const scratchComputeCenterWorldPosition = new THREE.Vector3();
|
||||
const scratchVesselDirection = new THREE.Vector3();
|
||||
const scratchVesselCameraLocal = new THREE.Vector3();
|
||||
const scratchVesselWorldPosition = new THREE.Vector3();
|
||||
const scratchVesselScreenPosition = new THREE.Vector3();
|
||||
const scratchSatelliteWorldPosition = new THREE.Vector3();
|
||||
const scratchSatelliteScreenPosition = new THREE.Vector3();
|
||||
const scratchViewCenterWorld = new THREE.Vector3();
|
||||
@@ -315,6 +311,7 @@ const RELATED_SATELLITE_HIGHLIGHT_COLOR = "#7dd3fc";
|
||||
const DRAG_POINTER_THRESHOLD_PX = 8;
|
||||
const VESSEL_HOVER_PICK_INTERVAL_MS = 100;
|
||||
const VESSEL_POINTER_RADIUS_PX = 22;
|
||||
const INTERACTABLE_POINTER_RADIUS_PX = 24;
|
||||
const GLOBE_DRAGGING_CLASS = "is-globe-dragging";
|
||||
const HUD_INTERACTIVE_SELECTORS = [
|
||||
".earth-left-column",
|
||||
@@ -548,70 +545,37 @@ function clearTransientHoverState() {
|
||||
setHoveredSatelliteIndex(null);
|
||||
}
|
||||
|
||||
function getFrontFacingVesselMarkers(markers) {
|
||||
function getVesselPointerIntersections() {
|
||||
const earth = getEarth();
|
||||
if (!earth) return markers;
|
||||
|
||||
scratchVesselCameraLocal.copy(camera.position);
|
||||
earth.worldToLocal(scratchVesselCameraLocal);
|
||||
scratchVesselCameraLocal.normalize();
|
||||
|
||||
return markers.filter((marker) => {
|
||||
scratchVesselDirection.copy(marker.position).normalize();
|
||||
return (
|
||||
scratchVesselCameraLocal.dot(scratchVesselDirection) >
|
||||
SATELLITE_CONFIG.frontFacingDotThreshold
|
||||
);
|
||||
return getVesselIconPointerIntersections({
|
||||
earth,
|
||||
camera,
|
||||
pointer: interactionMouse,
|
||||
radiusPx: VESSEL_POINTER_RADIUS_PX,
|
||||
frontFacingDotThreshold: SATELLITE_CONFIG.frontFacingDotThreshold,
|
||||
});
|
||||
}
|
||||
|
||||
function getVesselPointerIntersections() {
|
||||
function getBGPEventPointerIntersections() {
|
||||
const earth = getEarth();
|
||||
if (!earth) return [];
|
||||
scratchVesselCameraLocal.copy(camera.position);
|
||||
earth.worldToLocal(scratchVesselCameraLocal);
|
||||
scratchVesselCameraLocal.normalize();
|
||||
|
||||
const pointerX = ((interactionMouse.x + 1) / 2) * window.innerWidth;
|
||||
const pointerY = ((1 - interactionMouse.y) / 2) * window.innerHeight;
|
||||
const radiusSq = VESSEL_POINTER_RADIUS_PX * VESSEL_POINTER_RADIUS_PX;
|
||||
const intersections = [];
|
||||
|
||||
getVesselMarkers().forEach((marker) => {
|
||||
scratchVesselDirection.copy(marker.position).normalize();
|
||||
if (
|
||||
scratchVesselCameraLocal.dot(scratchVesselDirection) <=
|
||||
SATELLITE_CONFIG.frontFacingDotThreshold
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
scratchVesselWorldPosition.copy(marker.position);
|
||||
earth.localToWorld(scratchVesselWorldPosition);
|
||||
scratchVesselScreenPosition.copy(scratchVesselWorldPosition).project(camera);
|
||||
if (
|
||||
scratchVesselScreenPosition.z < -1 ||
|
||||
scratchVesselScreenPosition.z > 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const screenX = (scratchVesselScreenPosition.x * 0.5 + 0.5) * window.innerWidth;
|
||||
const screenY = (-scratchVesselScreenPosition.y * 0.5 + 0.5) * window.innerHeight;
|
||||
const deltaX = screenX - pointerX;
|
||||
const deltaY = screenY - pointerY;
|
||||
const distancePxSq = deltaX * deltaX + deltaY * deltaY;
|
||||
if (distancePxSq > radiusSq) return;
|
||||
|
||||
intersections.push({
|
||||
object: marker,
|
||||
point: scratchVesselWorldPosition.clone(),
|
||||
distance: camera.position.distanceTo(scratchVesselWorldPosition),
|
||||
distancePxSq,
|
||||
});
|
||||
return getBGPEventIconPointerIntersections({
|
||||
earth,
|
||||
camera,
|
||||
pointer: interactionMouse,
|
||||
radiusPx: INTERACTABLE_POINTER_RADIUS_PX,
|
||||
frontFacingDotThreshold: SATELLITE_CONFIG.frontFacingDotThreshold,
|
||||
});
|
||||
}
|
||||
|
||||
return intersections.sort((a, b) => a.distancePxSq - b.distancePxSq);
|
||||
function getBGPCollectorPointerIntersections() {
|
||||
const earth = getEarth();
|
||||
return getBGPCollectorIconPointerIntersections({
|
||||
earth,
|
||||
camera,
|
||||
pointer: interactionMouse,
|
||||
radiusPx: INTERACTABLE_POINTER_RADIUS_PX,
|
||||
frontFacingDotThreshold: SATELLITE_CONFIG.frontFacingDotThreshold,
|
||||
});
|
||||
}
|
||||
|
||||
function shouldSkipVesselHoverPicking() {
|
||||
@@ -3115,41 +3079,14 @@ function getFrontFacingCables(cableLines) {
|
||||
});
|
||||
}
|
||||
|
||||
function getFrontFacingBGPMarkers(markers) {
|
||||
function getComputeCenterPointerIntersections() {
|
||||
const earth = getEarth();
|
||||
if (!earth) return markers;
|
||||
|
||||
scratchCameraToEarth.subVectors(camera.position, earth.position).normalize();
|
||||
|
||||
return markers.filter((marker) => {
|
||||
scratchBGPWorldPosition.copy(marker.position);
|
||||
marker.parent?.localToWorld(scratchBGPWorldPosition);
|
||||
scratchBGPDirection
|
||||
.subVectors(scratchBGPWorldPosition, earth.position)
|
||||
.normalize();
|
||||
return (
|
||||
scratchCameraToEarth.dot(scratchBGPDirection) >
|
||||
SATELLITE_CONFIG.frontFacingDotThreshold
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getFrontFacingComputeCenterMarkers(markers) {
|
||||
const earth = getEarth();
|
||||
if (!earth) return markers;
|
||||
|
||||
scratchCameraToEarth.subVectors(camera.position, earth.position).normalize();
|
||||
|
||||
return markers.filter((marker) => {
|
||||
scratchComputeCenterWorldPosition.copy(marker.position);
|
||||
marker.parent?.localToWorld(scratchComputeCenterWorldPosition);
|
||||
scratchComputeCenterDirection
|
||||
.subVectors(scratchComputeCenterWorldPosition, earth.position)
|
||||
.normalize();
|
||||
return (
|
||||
scratchCameraToEarth.dot(scratchComputeCenterDirection) >
|
||||
SATELLITE_CONFIG.frontFacingDotThreshold
|
||||
);
|
||||
return getComputeCenterIconPointerIntersections({
|
||||
earth,
|
||||
camera,
|
||||
pointer: interactionMouse,
|
||||
radiusPx: INTERACTABLE_POINTER_RADIUS_PX,
|
||||
frontFacingDotThreshold: SATELLITE_CONFIG.frontFacingDotThreshold,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3199,23 +3136,14 @@ function onMouseMove(event) {
|
||||
|
||||
const frontCables = getFrontFacingCables(getCableLines());
|
||||
const cableIntersects = interactionRaycaster.intersectObjects(frontCables);
|
||||
const frontFacingBGPAnomalyMarkers = getFrontFacingBGPMarkers(
|
||||
getBGPAnomalyMarkers(),
|
||||
);
|
||||
const frontFacingBGPCollectorMarkers = getFrontFacingBGPMarkers(
|
||||
getBGPCollectorMarkers(),
|
||||
);
|
||||
const bgpAnomalyIntersects = getShowBGP()
|
||||
? interactionRaycaster.intersectObjects(frontFacingBGPAnomalyMarkers)
|
||||
? getBGPEventPointerIntersections()
|
||||
: [];
|
||||
const bgpCollectorIntersects = getShowBGP()
|
||||
? interactionRaycaster.intersectObjects(frontFacingBGPCollectorMarkers)
|
||||
? getBGPCollectorPointerIntersections()
|
||||
: [];
|
||||
const frontFacingComputeCenterMarkers = getFrontFacingComputeCenterMarkers(
|
||||
getComputeCenterMarkers(),
|
||||
);
|
||||
const computeCenterIntersects = getShowComputeCenters()
|
||||
? interactionRaycaster.intersectObjects(frontFacingComputeCenterMarkers)
|
||||
? getComputeCenterPointerIntersections()
|
||||
: [];
|
||||
const vesselPick = getVesselHoverIntersections();
|
||||
const vesselIntersects = vesselPick.intersects;
|
||||
@@ -3552,22 +3480,14 @@ function onClick(event) {
|
||||
const cableIntersects = interactionRaycaster.intersectObjects(
|
||||
getFrontFacingCables(getCableLines()),
|
||||
);
|
||||
const frontFacingBGPAnomalyMarkers = getFrontFacingBGPMarkers(
|
||||
getBGPAnomalyMarkers(),
|
||||
);
|
||||
const frontFacingBGPCollectorMarkers = getFrontFacingBGPMarkers(
|
||||
getBGPCollectorMarkers(),
|
||||
);
|
||||
const bgpAnomalyIntersects = getShowBGP()
|
||||
? interactionRaycaster.intersectObjects(frontFacingBGPAnomalyMarkers)
|
||||
? getBGPEventPointerIntersections()
|
||||
: [];
|
||||
const bgpCollectorIntersects = getShowBGP()
|
||||
? interactionRaycaster.intersectObjects(frontFacingBGPCollectorMarkers)
|
||||
? getBGPCollectorPointerIntersections()
|
||||
: [];
|
||||
const computeCenterIntersects = getShowComputeCenters()
|
||||
? interactionRaycaster.intersectObjects(
|
||||
getFrontFacingComputeCenterMarkers(getComputeCenterMarkers()),
|
||||
)
|
||||
? getComputeCenterPointerIntersections()
|
||||
: [];
|
||||
const vesselIntersects = getShowVessels()
|
||||
? getVesselPointerIntersections()
|
||||
|
||||
@@ -1,30 +1,17 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import { CONFIG, PATHS, VESSEL_CONFIG } from "./constants.js";
|
||||
import { createInteractableLayer } from "./interactable.js";
|
||||
import { latLonToVector3 } from "./utils.js";
|
||||
|
||||
const vesselGroup = new THREE.Group();
|
||||
const vesselMarkers = [];
|
||||
const textureCache = new Map();
|
||||
let vesselPoints = null;
|
||||
let vesselPointObjects = [];
|
||||
let hoverOverlaySprite = null;
|
||||
let lockedOverlaySprite = null;
|
||||
let showVessels = false;
|
||||
let activeTrackLine = null;
|
||||
let lastVisualStateKey = "";
|
||||
let visualStateVersion = 0;
|
||||
|
||||
const VESSEL_RENDER_ORDER = 4.4;
|
||||
const VESSEL_POINT_SIZE = 34;
|
||||
const VESSEL_ATLAS_CELL_SIZE = 128;
|
||||
const VESSEL_COURSE_BINS = 32;
|
||||
|
||||
function invalidateVesselVisualState() {
|
||||
visualStateVersion += 1;
|
||||
lastVisualStateKey = "";
|
||||
}
|
||||
|
||||
function normalizeVesselType(value, code) {
|
||||
const type = String(value || "").trim().toLowerCase();
|
||||
const numericCode = Number(code);
|
||||
@@ -56,72 +43,6 @@ function drawVesselShape(context, anchored, glow, color = "#ffffff") {
|
||||
context.fill();
|
||||
}
|
||||
|
||||
function createVesselPointTexture(anchored, courseBin = 0) {
|
||||
const textureKey = `vessel-point:${anchored ? "anchored" : "moving"}:${courseBin}`;
|
||||
if (textureCache.has(textureKey)) return textureCache.get(textureKey);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = VESSEL_ATLAS_CELL_SIZE;
|
||||
canvas.height = VESSEL_ATLAS_CELL_SIZE;
|
||||
const context = canvas.getContext("2d");
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.save();
|
||||
context.translate(canvas.width / 2, canvas.height / 2);
|
||||
if (!anchored) {
|
||||
context.rotate((courseBin / VESSEL_COURSE_BINS) * Math.PI * 2);
|
||||
}
|
||||
drawVesselShape(context, anchored, false);
|
||||
context.restore();
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.generateMipmaps = false;
|
||||
texture.minFilter = THREE.LinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
texture.needsUpdate = true;
|
||||
textureCache.set(textureKey, texture);
|
||||
return texture;
|
||||
}
|
||||
|
||||
function createVesselOverlayTexture(marker, glow = true) {
|
||||
const kind = marker?.userData?.vessel_kind || "other";
|
||||
const anchored = Boolean(marker?.userData?.anchored);
|
||||
const courseBin = marker ? getCourseBin(marker) : 0;
|
||||
const textureKey = [
|
||||
"vessel-overlay",
|
||||
kind,
|
||||
anchored ? "anchored" : "moving",
|
||||
courseBin,
|
||||
glow ? "glow" : "plain",
|
||||
].join(":");
|
||||
if (textureCache.has(textureKey)) return textureCache.get(textureKey);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = VESSEL_ATLAS_CELL_SIZE;
|
||||
canvas.height = VESSEL_ATLAS_CELL_SIZE;
|
||||
const context = canvas.getContext("2d");
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.save();
|
||||
context.translate(canvas.width / 2, canvas.height / 2);
|
||||
if (!anchored) {
|
||||
context.rotate((courseBin / VESSEL_COURSE_BINS) * Math.PI * 2);
|
||||
}
|
||||
drawVesselShape(
|
||||
context,
|
||||
anchored,
|
||||
glow,
|
||||
VESSEL_CONFIG.colors[kind] || VESSEL_CONFIG.colors.other,
|
||||
);
|
||||
context.restore();
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.generateMipmaps = false;
|
||||
texture.minFilter = THREE.LinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
texture.needsUpdate = true;
|
||||
textureCache.set(textureKey, texture);
|
||||
return texture;
|
||||
}
|
||||
|
||||
function buildVesselMarkerData(feature) {
|
||||
const props = feature?.properties || {};
|
||||
const coordinates = feature?.geometry?.coordinates || [];
|
||||
@@ -144,30 +65,6 @@ function buildVesselMarkerData(feature) {
|
||||
};
|
||||
}
|
||||
|
||||
function createVesselMarker(markerData) {
|
||||
const marker = new THREE.Object3D();
|
||||
marker.position.copy(
|
||||
latLonToVector3(
|
||||
markerData.latitude,
|
||||
markerData.longitude,
|
||||
CONFIG.earthRadius + VESSEL_CONFIG.altitudeOffset,
|
||||
),
|
||||
);
|
||||
marker.userData = {
|
||||
...markerData,
|
||||
type: "vessel",
|
||||
vessel_kind: markerData.type,
|
||||
baseScale: VESSEL_CONFIG.marker.baseScale,
|
||||
state: "normal",
|
||||
};
|
||||
vesselMarkers.push(marker);
|
||||
}
|
||||
|
||||
function colorToRgbArray(colorValue) {
|
||||
const color = new THREE.Color(colorValue || VESSEL_CONFIG.colors.other);
|
||||
return [color.r, color.g, color.b];
|
||||
}
|
||||
|
||||
function getCourseBin(marker) {
|
||||
if (marker.userData.anchored) return 0;
|
||||
const course = Number(marker.userData.course || 0);
|
||||
@@ -175,108 +72,61 @@ function getCourseBin(marker) {
|
||||
return Math.round((normalized / 360) * VESSEL_COURSE_BINS) % VESSEL_COURSE_BINS;
|
||||
}
|
||||
|
||||
function buildVesselPoints() {
|
||||
vesselPoints = new THREE.Group();
|
||||
vesselPoints.visible = showVessels;
|
||||
vesselPoints.renderOrder = VESSEL_RENDER_ORDER;
|
||||
vesselPoints.userData = { type: "vessel_points" };
|
||||
vesselPointObjects = [];
|
||||
|
||||
const groups = new Map();
|
||||
vesselMarkers.forEach((marker, index) => {
|
||||
const vesselIconLayer = createInteractableLayer({
|
||||
id: "vessels",
|
||||
objectType: "vessel",
|
||||
renderOrder: VESSEL_RENDER_ORDER,
|
||||
altitudeOffset: VESSEL_CONFIG.altitudeOffset,
|
||||
pointSize: VESSEL_POINT_SIZE,
|
||||
atlasCellSize: VESSEL_ATLAS_CELL_SIZE,
|
||||
colors: {
|
||||
byKind: VESSEL_CONFIG.colors,
|
||||
normal: VESSEL_CONFIG.colors.other,
|
||||
},
|
||||
opacity: {
|
||||
normal: VESSEL_CONFIG.marker.baseOpacity,
|
||||
dimmed: VESSEL_CONFIG.marker.dimmedOpacity,
|
||||
hover: 0.98,
|
||||
locked: 1,
|
||||
},
|
||||
stateScale: {
|
||||
hover: VESSEL_CONFIG.marker.hoverScale,
|
||||
locked: VESSEL_CONFIG.marker.lockedScale,
|
||||
dimmed: VESSEL_CONFIG.marker.dimmedScale,
|
||||
},
|
||||
icon: {
|
||||
draw(context, { marker, rotationBin = 0, glow = false, color = "#ffffff" }) {
|
||||
const anchored = Boolean(marker?.userData?.anchored);
|
||||
if (!anchored) {
|
||||
context.rotate((rotationBin / VESSEL_COURSE_BINS) * Math.PI * 2);
|
||||
}
|
||||
drawVesselShape(context, anchored, glow, color);
|
||||
},
|
||||
},
|
||||
getPosition: (item) => ({
|
||||
latitude: item.latitude,
|
||||
longitude: item.longitude,
|
||||
}),
|
||||
getKind: (item) => item.type || "other",
|
||||
getRotationBin: getCourseBin,
|
||||
getBucketKey: (marker) => {
|
||||
const anchored = Boolean(marker.userData.anchored);
|
||||
const courseBin = getCourseBin(marker);
|
||||
const key = `${anchored ? "anchored" : "moving"}:${courseBin}`;
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, {
|
||||
anchored,
|
||||
courseBin,
|
||||
markers: [],
|
||||
markerIndexes: [],
|
||||
});
|
||||
}
|
||||
const group = groups.get(key);
|
||||
group.markers.push(marker);
|
||||
group.markerIndexes.push(index);
|
||||
});
|
||||
|
||||
groups.forEach((group) => {
|
||||
const count = group.markers.length;
|
||||
const positions = new Float32Array(count * 3);
|
||||
const colors = new Float32Array(count * 3);
|
||||
group.markers.forEach((marker, index) => {
|
||||
positions[index * 3] = marker.position.x;
|
||||
positions[index * 3 + 1] = marker.position.y;
|
||||
positions[index * 3 + 2] = marker.position.z;
|
||||
const [r, g, b] = colorToRgbArray(VESSEL_CONFIG.colors[marker.userData.vessel_kind]);
|
||||
colors[index * 3] = r;
|
||||
colors[index * 3 + 1] = g;
|
||||
colors[index * 3 + 2] = b;
|
||||
});
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
|
||||
geometry.computeBoundingSphere();
|
||||
const material = new THREE.PointsMaterial({
|
||||
map: createVesselPointTexture(group.anchored, group.courseBin),
|
||||
size: VESSEL_POINT_SIZE,
|
||||
sizeAttenuation: false,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: VESSEL_CONFIG.marker.baseOpacity,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
alphaTest: 0.01,
|
||||
});
|
||||
const points = new THREE.Points(geometry, material);
|
||||
points.renderOrder = VESSEL_RENDER_ORDER;
|
||||
points.frustumCulled = false;
|
||||
points.userData = {
|
||||
type: "vessel_points",
|
||||
};
|
||||
vesselPointObjects.push(points);
|
||||
vesselPoints.add(points);
|
||||
});
|
||||
|
||||
vesselGroup.add(vesselPoints);
|
||||
}
|
||||
|
||||
function clearGroup(group) {
|
||||
for (let index = group.children.length - 1; index >= 0; index -= 1) {
|
||||
const child = group.children[index];
|
||||
child.material?.dispose?.();
|
||||
child.geometry?.dispose?.();
|
||||
group.remove(child);
|
||||
}
|
||||
}
|
||||
|
||||
function disposeVesselRenderObjects() {
|
||||
if (vesselPoints?.parent) {
|
||||
vesselPoints.parent.remove(vesselPoints);
|
||||
}
|
||||
vesselPointObjects.forEach((points) => {
|
||||
points.geometry?.dispose?.();
|
||||
points.material?.dispose?.();
|
||||
});
|
||||
hoverOverlaySprite?.geometry?.dispose?.();
|
||||
hoverOverlaySprite?.material?.dispose?.();
|
||||
hoverOverlaySprite?.parent?.remove?.(hoverOverlaySprite);
|
||||
lockedOverlaySprite?.geometry?.dispose?.();
|
||||
lockedOverlaySprite?.material?.dispose?.();
|
||||
lockedOverlaySprite?.parent?.remove?.(lockedOverlaySprite);
|
||||
vesselPoints = null;
|
||||
vesselPointObjects = [];
|
||||
hoverOverlaySprite = null;
|
||||
lockedOverlaySprite = null;
|
||||
}
|
||||
return `${anchored ? "anchored" : "moving"}:${courseBin}`;
|
||||
},
|
||||
getUserData: (item) => ({
|
||||
...item,
|
||||
vessel_kind: item.type,
|
||||
baseScale: VESSEL_CONFIG.marker.baseScale,
|
||||
}),
|
||||
});
|
||||
|
||||
export function getVesselMarkers() {
|
||||
return vesselMarkers;
|
||||
return vesselIconLayer.getMarkers();
|
||||
}
|
||||
|
||||
export function getVesselCount() {
|
||||
return vesselMarkers.length;
|
||||
return vesselIconLayer.getCount();
|
||||
}
|
||||
|
||||
export function getShowVessels() {
|
||||
@@ -285,18 +135,14 @@ export function getShowVessels() {
|
||||
|
||||
export function toggleVessels(show) {
|
||||
showVessels = Boolean(show);
|
||||
invalidateVesselVisualState();
|
||||
vesselGroup.visible = showVessels;
|
||||
if (vesselPoints) {
|
||||
vesselPoints.visible = showVessels;
|
||||
}
|
||||
vesselIconLayer.setVisible(showVessels);
|
||||
if (activeTrackLine) {
|
||||
activeTrackLine.visible = showVessels;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearVesselSelection() {
|
||||
vesselMarkers.forEach((marker) => setVesselMarkerState(marker, "normal"));
|
||||
getVesselMarkers().forEach((marker) => setVesselMarkerState(marker, "normal"));
|
||||
clearVesselTrack();
|
||||
}
|
||||
|
||||
@@ -310,21 +156,16 @@ function clearVesselTrack() {
|
||||
}
|
||||
|
||||
export function setVesselMarkerState(marker, state = "normal") {
|
||||
if (!marker || marker.userData?.type !== "vessel") return;
|
||||
if (marker.userData.state === state) return;
|
||||
marker.userData.state = state;
|
||||
invalidateVesselVisualState();
|
||||
vesselIconLayer.setMarkerState(marker, state);
|
||||
}
|
||||
|
||||
export function getVesselPointerIntersections(options) {
|
||||
return vesselIconLayer.getPointerIntersections(options);
|
||||
}
|
||||
|
||||
export function clearVesselData(earth) {
|
||||
invalidateVesselVisualState();
|
||||
clearVesselSelection();
|
||||
vesselMarkers.length = 0;
|
||||
disposeVesselRenderObjects();
|
||||
clearGroup(vesselGroup);
|
||||
if (earth && vesselGroup.parent === earth) {
|
||||
earth.remove(vesselGroup);
|
||||
}
|
||||
vesselIconLayer.clearData(earth);
|
||||
}
|
||||
|
||||
export async function loadVessels(_scene, earth, options = {}) {
|
||||
@@ -338,20 +179,17 @@ export async function loadVessels(_scene, earth, options = {}) {
|
||||
const features = Array.isArray(payload?.features) ? payload.features : [];
|
||||
|
||||
clearVesselData(earth);
|
||||
features
|
||||
const markerData = features
|
||||
.map((feature) => buildVesselMarkerData(feature))
|
||||
.filter(Boolean)
|
||||
.slice(0, VESSEL_CONFIG.maxRenderedMarkers)
|
||||
.forEach((markerData) => createVesselMarker(markerData));
|
||||
buildVesselPoints();
|
||||
.slice(0, VESSEL_CONFIG.maxRenderedMarkers);
|
||||
vesselIconLayer.setData(markerData);
|
||||
|
||||
if (earth && !vesselGroup.parent) {
|
||||
earth.add(vesselGroup);
|
||||
}
|
||||
vesselGroup.visible = showVessels;
|
||||
vesselIconLayer.attach(earth);
|
||||
vesselIconLayer.setVisible(showVessels);
|
||||
|
||||
return {
|
||||
totalCount: vesselMarkers.length,
|
||||
totalCount: getVesselCount(),
|
||||
stats: payload?.stats || {},
|
||||
};
|
||||
}
|
||||
@@ -404,102 +242,6 @@ export function getVesselLegendItems() {
|
||||
];
|
||||
}
|
||||
|
||||
function ensureOverlaySprite(kind) {
|
||||
const existing = kind === "locked" ? lockedOverlaySprite : hoverOverlaySprite;
|
||||
if (existing) return existing;
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(new Float32Array(3), 3),
|
||||
);
|
||||
const material = new THREE.PointsMaterial({
|
||||
size: VESSEL_POINT_SIZE,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
opacity: 1,
|
||||
alphaTest: 0.01,
|
||||
});
|
||||
const overlay = new THREE.Points(geometry, material);
|
||||
overlay.renderOrder = VESSEL_RENDER_ORDER + (kind === "locked" ? 0.2 : 0.1);
|
||||
overlay.frustumCulled = false;
|
||||
overlay.visible = false;
|
||||
vesselGroup.add(overlay);
|
||||
if (kind === "locked") {
|
||||
lockedOverlaySprite = overlay;
|
||||
} else {
|
||||
hoverOverlaySprite = overlay;
|
||||
}
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function updateOverlaySprite(overlay, marker, opacity) {
|
||||
if (!overlay) return;
|
||||
if (!marker) {
|
||||
overlay.visible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const texture = createVesselOverlayTexture(marker, true);
|
||||
if (overlay.material.map !== texture) {
|
||||
overlay.material.map = texture;
|
||||
overlay.material.needsUpdate = true;
|
||||
}
|
||||
overlay.material.opacity = opacity;
|
||||
overlay.material.size = VESSEL_POINT_SIZE;
|
||||
const positionAttribute = overlay.geometry.getAttribute("position");
|
||||
positionAttribute.setXYZ(0, marker.position.x, marker.position.y, marker.position.z);
|
||||
positionAttribute.needsUpdate = true;
|
||||
overlay.visible = showVessels;
|
||||
}
|
||||
|
||||
export function updateVesselVisualState(lockedObjectType, lockedObject, camera) {
|
||||
if (!showVessels || vesselMarkers.length === 0 || !vesselPoints) {
|
||||
if (lastVisualStateKey !== "hidden") {
|
||||
if (vesselPoints) vesselPoints.visible = false;
|
||||
if (hoverOverlaySprite) hoverOverlaySprite.visible = false;
|
||||
if (lockedOverlaySprite) lockedOverlaySprite.visible = false;
|
||||
lastVisualStateKey = "hidden";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
vesselPoints.visible = true;
|
||||
const hasFocus = lockedObjectType === "vessel" && lockedObject;
|
||||
const lockedKey = hasFocus
|
||||
? lockedObject?.userData?.mmsi || lockedObject?.uuid || "locked"
|
||||
: "none";
|
||||
const stateKey = [
|
||||
"visible",
|
||||
lockedObjectType || "none",
|
||||
lockedKey,
|
||||
visualStateVersion,
|
||||
].join(":");
|
||||
|
||||
if (stateKey === lastVisualStateKey) return;
|
||||
lastVisualStateKey = stateKey;
|
||||
|
||||
vesselPointObjects.forEach((points) => {
|
||||
points.visible = showVessels;
|
||||
points.material.opacity = hasFocus
|
||||
? VESSEL_CONFIG.marker.dimmedOpacity
|
||||
: VESSEL_CONFIG.marker.baseOpacity;
|
||||
points.material.size = VESSEL_POINT_SIZE;
|
||||
});
|
||||
|
||||
const hoverMarker = vesselMarkers.find(
|
||||
(marker) => marker.userData?.state === "hover" && marker !== lockedObject,
|
||||
);
|
||||
updateOverlaySprite(
|
||||
ensureOverlaySprite("hover"),
|
||||
hoverMarker,
|
||||
0.98,
|
||||
);
|
||||
updateOverlaySprite(
|
||||
ensureOverlaySprite("locked"),
|
||||
hasFocus ? lockedObject : null,
|
||||
1,
|
||||
);
|
||||
vesselIconLayer.updateVisualState(lockedObjectType, lockedObject, camera);
|
||||
}
|
||||
|
||||