release: bump version to 0.46.0
This commit is contained in:
@@ -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
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
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user