900 lines
28 KiB
JavaScript
900 lines
28 KiB
JavaScript
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,
|
|
};
|
|
}
|