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