1353 lines
43 KiB
JavaScript
1353 lines
43 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 SCREEN_AVOIDANCE_ZOOM_BUCKET_SIZE = 0.03;
|
|
const SCREEN_AVOIDANCE_OVERLAP_FACTOR = 0.82;
|
|
const SCREEN_AVOIDANCE_COLLAPSE_FACTOR = 0.32;
|
|
const COMPACT_DOT_ZOOM_THRESHOLD = 1.5;
|
|
const COMPACT_DOT_POINT_SIZE = 12;
|
|
const COMPACT_DOT_RADIUS_RATIO = 0.26;
|
|
let compactDotsEnabled = true;
|
|
let screenAvoidanceRevision = 0;
|
|
let screenAvoidanceSignature = "";
|
|
|
|
// Named avoidance profiles. Layers that should mutex with each other (e.g. fan
|
|
// out when sharing the same city center) must reference the SAME profile —
|
|
// markers are bucketed by the resulting key, and only equal keys collide.
|
|
//
|
|
// city — ~1.1km grid (precision 2). Use for site/observatory/POI markers
|
|
// that often share a city-center coordinate from geocoding.
|
|
// precise — ~11m grid (precision 4). Use for markers with building-level
|
|
// coordinates (default; preserves prior behavior).
|
|
//
|
|
// Layers that need a fully custom cluster identity (e.g. a city ID string)
|
|
// can pass `getKey: (item, position) => "..."` instead of using a profile.
|
|
// Avoidance is opt-in: dynamic high-density layers such as vessels should leave
|
|
// it disabled so their markers stay at their real-time coordinates.
|
|
export const SURFACE_AVOIDANCE_PROFILES = Object.freeze({
|
|
city: Object.freeze({ precision: 2, radius: 1.4, step: 0.5 }),
|
|
precise: Object.freeze({
|
|
precision: DEFAULT_AVOIDANCE_PRECISION,
|
|
radius: DEFAULT_AVOIDANCE_RADIUS,
|
|
step: DEFAULT_AVOIDANCE_STEP,
|
|
}),
|
|
});
|
|
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();
|
|
const screenAvoidanceStaticPositionScratch = new THREE.Vector3();
|
|
const screenAvoidanceWorldPositionScratch = new THREE.Vector3();
|
|
const screenAvoidanceProjectedScratch = new THREE.Vector3();
|
|
const screenAvoidanceClusterCenterScratch = 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 normalizeAvoidanceConfig(avoidance) {
|
|
if (avoidance === false || avoidance === null || avoidance === undefined) {
|
|
return {
|
|
enabled: false,
|
|
screen: false,
|
|
precision: DEFAULT_AVOIDANCE_PRECISION,
|
|
radius: DEFAULT_AVOIDANCE_RADIUS,
|
|
step: DEFAULT_AVOIDANCE_STEP,
|
|
};
|
|
}
|
|
|
|
const customConfig =
|
|
avoidance === true || typeof avoidance !== "object" ? {} : avoidance;
|
|
const config = {
|
|
enabled: true,
|
|
screen: true,
|
|
precision: DEFAULT_AVOIDANCE_PRECISION,
|
|
radius: DEFAULT_AVOIDANCE_RADIUS,
|
|
step: DEFAULT_AVOIDANCE_STEP,
|
|
...customConfig,
|
|
};
|
|
config.enabled = config.enabled !== false;
|
|
config.screen = config.enabled && config.screen !== false;
|
|
return config;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export function getInteractableCompactDotsEnabled() {
|
|
return compactDotsEnabled;
|
|
}
|
|
|
|
export function setInteractableCompactDotsEnabled(enabled) {
|
|
compactDotsEnabled = Boolean(enabled);
|
|
invalidateScreenAvoidance();
|
|
interactableLayerControllers.forEach((controller) => {
|
|
controller.refreshVisuals?.();
|
|
});
|
|
return compactDotsEnabled;
|
|
}
|
|
|
|
function invalidateScreenAvoidance() {
|
|
screenAvoidanceRevision += 1;
|
|
screenAvoidanceSignature = "";
|
|
}
|
|
|
|
function getAvoidanceKey(item, position, basePosition, config) {
|
|
if (typeof config?.getKey === "function") {
|
|
const key = config.getKey(item, position, basePosition);
|
|
if (key) return String(key);
|
|
}
|
|
|
|
const precision = Number.isFinite(config?.precision)
|
|
? config.precision
|
|
: DEFAULT_AVOIDANCE_PRECISION;
|
|
|
|
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) {
|
|
invalidateScreenAvoidance();
|
|
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));
|
|
const crossLayer = affectedLayerIds.size > 1;
|
|
if (entries.length === 1) {
|
|
const entry = entries[0];
|
|
entry.marker.position.copy(entry.marker.userData.icon_base_position);
|
|
entry.marker.userData.icon_static_avoidance_position =
|
|
entry.marker.userData.icon_base_position.clone();
|
|
entry.marker.userData.icon_avoidance_index = 0;
|
|
entry.marker.userData.icon_avoidance_count = 1;
|
|
entry.marker.userData.icon_avoidance_layer_count = 1;
|
|
entry.marker.userData.icon_avoidance_cross_layer = false;
|
|
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_static_avoidance_position =
|
|
avoidancePositionScratch.clone();
|
|
entry.marker.userData.icon_avoidance_index = index;
|
|
entry.marker.userData.icon_avoidance_count = count;
|
|
entry.marker.userData.icon_avoidance_layer_count = affectedLayerIds.size;
|
|
entry.marker.userData.icon_avoidance_cross_layer = crossLayer;
|
|
});
|
|
|
|
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 getMarkerStaticAvoidancePosition(marker) {
|
|
return (
|
|
marker?.userData?.icon_static_avoidance_position ||
|
|
marker?.userData?.icon_base_position ||
|
|
marker?.position
|
|
);
|
|
}
|
|
|
|
function collectScreenAvoidanceEntries(camera) {
|
|
if (!camera) return [];
|
|
const entries = [];
|
|
interactableLayerControllers.forEach((controller) => {
|
|
entries.push(...(controller.collectScreenAvoidanceEntries?.(camera) || []));
|
|
});
|
|
return entries;
|
|
}
|
|
|
|
function findScreenAvoidanceGroups(entries) {
|
|
const visited = new Set();
|
|
const groups = [];
|
|
|
|
entries.forEach((entry) => {
|
|
if (visited.has(entry)) return;
|
|
const group = [entry];
|
|
const queue = [entry];
|
|
visited.add(entry);
|
|
|
|
while (queue.length > 0) {
|
|
const current = queue.shift();
|
|
entries.forEach((candidate) => {
|
|
if (visited.has(candidate)) return;
|
|
const dx = current.x - candidate.x;
|
|
const dy = current.y - candidate.y;
|
|
const minDistance =
|
|
(current.radiusPx + candidate.radiusPx) *
|
|
SCREEN_AVOIDANCE_OVERLAP_FACTOR;
|
|
if (dx * dx + dy * dy > minDistance * minDistance) return;
|
|
visited.add(candidate);
|
|
queue.push(candidate);
|
|
group.push(candidate);
|
|
});
|
|
}
|
|
|
|
groups.push(group);
|
|
});
|
|
|
|
return groups;
|
|
}
|
|
|
|
function shouldCollapseScreenAvoidanceGroup(group) {
|
|
if (group.length <= 1) return false;
|
|
|
|
for (let index = 0; index < group.length; index += 1) {
|
|
for (let nextIndex = index + 1; nextIndex < group.length; nextIndex += 1) {
|
|
const current = group[index];
|
|
const candidate = group[nextIndex];
|
|
const dx = current.x - candidate.x;
|
|
const dy = current.y - candidate.y;
|
|
const collapseDistance =
|
|
(current.radiusPx + candidate.radiusPx) *
|
|
SCREEN_AVOIDANCE_COLLAPSE_FACTOR;
|
|
if (dx * dx + dy * dy > collapseDistance * collapseDistance) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function applyCollapsedScreenAvoidanceGroup(group) {
|
|
screenAvoidanceClusterCenterScratch.set(0, 0, 0);
|
|
let count = 0;
|
|
|
|
group.forEach((entry) => {
|
|
const basePosition = getMarkerStaticAvoidancePosition(entry.marker);
|
|
if (!(basePosition instanceof THREE.Vector3)) return;
|
|
screenAvoidanceClusterCenterScratch.add(basePosition);
|
|
count += 1;
|
|
});
|
|
|
|
if (count === 0) return;
|
|
screenAvoidanceClusterCenterScratch.divideScalar(count);
|
|
if (screenAvoidanceClusterCenterScratch.lengthSq() < TANGENT_EPSILON_SQ) {
|
|
const fallbackPosition = getMarkerStaticAvoidancePosition(group[0]?.marker);
|
|
if (!(fallbackPosition instanceof THREE.Vector3)) return;
|
|
screenAvoidanceClusterCenterScratch.copy(fallbackPosition);
|
|
}
|
|
screenAvoidanceClusterCenterScratch.normalize();
|
|
|
|
group.forEach((entry, index) => {
|
|
const basePosition = getMarkerStaticAvoidancePosition(entry.marker);
|
|
if (!(basePosition instanceof THREE.Vector3)) return;
|
|
entry.marker.position
|
|
.copy(screenAvoidanceClusterCenterScratch)
|
|
.multiplyScalar(basePosition.length());
|
|
entry.marker.userData.icon_screen_avoidance_count = group.length;
|
|
entry.marker.userData.icon_screen_avoidance_index = index;
|
|
entry.marker.userData.icon_screen_clustered = true;
|
|
});
|
|
}
|
|
|
|
function applyScreenAvoidanceGroup(group) {
|
|
if (group.length <= 1) return;
|
|
if (shouldCollapseScreenAvoidanceGroup(group)) {
|
|
applyCollapsedScreenAvoidanceGroup(group);
|
|
return;
|
|
}
|
|
|
|
const count = group.length;
|
|
group.forEach((entry, index) => {
|
|
const basePosition = getMarkerStaticAvoidancePosition(entry.marker);
|
|
if (!(basePosition instanceof THREE.Vector3)) return;
|
|
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_screen_avoidance_count = count;
|
|
entry.marker.userData.icon_screen_avoidance_index = index;
|
|
entry.marker.userData.icon_screen_clustered = false;
|
|
});
|
|
}
|
|
|
|
function getScreenAvoidanceSignature(camera) {
|
|
if (!camera?.position) return `none:${screenAvoidanceRevision}`;
|
|
const zoom = CONFIG.defaultCameraZ / Math.max(1, camera.position.length());
|
|
const zoomBucket =
|
|
Math.round(zoom / SCREEN_AVOIDANCE_ZOOM_BUCKET_SIZE) *
|
|
SCREEN_AVOIDANCE_ZOOM_BUCKET_SIZE;
|
|
const viewportBucket = [
|
|
Math.round(window.innerWidth || 1),
|
|
Math.round(window.innerHeight || 1),
|
|
Math.round(window.devicePixelRatio || 1),
|
|
].join("x");
|
|
return [
|
|
zoomBucket.toFixed(2),
|
|
compactDotsEnabled ? "dots-on" : "dots-off",
|
|
viewportBucket,
|
|
screenAvoidanceRevision,
|
|
].join(":");
|
|
}
|
|
|
|
function recomputeScreenAvoidance(camera) {
|
|
const nextSignature = getScreenAvoidanceSignature(camera);
|
|
if (nextSignature === screenAvoidanceSignature) return;
|
|
screenAvoidanceSignature = nextSignature;
|
|
|
|
const entries = collectScreenAvoidanceEntries(camera);
|
|
const affectedControllers = new Set();
|
|
|
|
entries.forEach((entry) => {
|
|
const basePosition = getMarkerStaticAvoidancePosition(entry.marker);
|
|
if (basePosition instanceof THREE.Vector3) {
|
|
entry.marker.position.copy(basePosition);
|
|
}
|
|
entry.marker.userData.icon_screen_avoidance_count = 1;
|
|
entry.marker.userData.icon_screen_avoidance_index = 0;
|
|
entry.marker.userData.icon_screen_clustered = false;
|
|
affectedControllers.add(entry.controller);
|
|
});
|
|
|
|
findScreenAvoidanceGroups(entries).forEach(applyScreenAvoidanceGroup);
|
|
affectedControllers.forEach((controller) => controller.refreshPositions?.());
|
|
}
|
|
|
|
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 = false,
|
|
icon,
|
|
getPosition,
|
|
getKind = (item) => item?.type || "default",
|
|
getRotationBin = () => 0,
|
|
getBucketKey = (marker) => String(getRotationBin(marker)),
|
|
getItemId = (item) => item?.id ?? item?.source_id ?? item?.entity_key,
|
|
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 = normalizeAvoidanceConfig(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 createCompactDotTexture(marker, state = "normal") {
|
|
const kind = marker?.userData?.icon_kind || "default";
|
|
const color = state === "normal" ? "#ffffff" : getMarkerColor(marker);
|
|
const textureKey = `compact-dot:${state}:${kind}:${color}`;
|
|
if (textureCache.has(textureKey)) return textureCache.get(textureKey);
|
|
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = atlasCellSize;
|
|
canvas.height = atlasCellSize;
|
|
const context = canvas.getContext("2d");
|
|
const center = atlasCellSize / 2;
|
|
const radius = atlasCellSize * COMPACT_DOT_RADIUS_RATIO;
|
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
|
context.fillStyle = color;
|
|
context.shadowColor = color;
|
|
context.shadowBlur = atlasCellSize * 0.08;
|
|
context.beginPath();
|
|
context.arc(center, center, radius, 0, Math.PI * 2);
|
|
context.fill();
|
|
|
|
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 createOverlayTexture(marker, state, compactDotMode = false) {
|
|
if (compactDotMode) {
|
|
return createCompactDotTexture(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 getCameraZoom(camera) {
|
|
if (!camera?.position?.z) return CONFIG.defaultViewZoom;
|
|
return CONFIG.defaultCameraZ / camera.position.z;
|
|
}
|
|
|
|
function shouldUseCompactDots(camera) {
|
|
return compactDotsEnabled && getCameraZoom(camera) < COMPACT_DOT_ZOOM_THRESHOLD;
|
|
}
|
|
|
|
function updatePointColors(points, compactDotMode) {
|
|
const bucketMarkers = points.userData?.markers || [];
|
|
const colorAttribute = points.geometry?.getAttribute("color");
|
|
if (!colorAttribute?.array) return;
|
|
|
|
bucketMarkers.forEach((marker, index) => {
|
|
const pointColor =
|
|
compactDotMode || icon.colorable !== false
|
|
? getMarkerColor(marker)
|
|
: "#ffffff";
|
|
const [r, g, b] = colorToRgbArray(pointColor);
|
|
colorAttribute.array[index * 3] = r;
|
|
colorAttribute.array[index * 3 + 1] = g;
|
|
colorAttribute.array[index * 3 + 2] = b;
|
|
});
|
|
colorAttribute.needsUpdate = true;
|
|
}
|
|
|
|
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, compactDotMode = false) {
|
|
if (!overlay) return;
|
|
if (!marker) {
|
|
overlay.visible = false;
|
|
return;
|
|
}
|
|
|
|
const texture = createOverlayTexture(marker, state, compactDotMode);
|
|
if (overlay.material.map !== texture) {
|
|
overlay.material.map = texture;
|
|
overlay.material.needsUpdate = true;
|
|
}
|
|
overlay.material.opacity = nextOpacity;
|
|
overlay.material.size =
|
|
(compactDotMode ? COMPACT_DOT_POINT_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() {
|
|
invalidateScreenAvoidance();
|
|
invalidateVisualState();
|
|
if (!pointsGroup) return;
|
|
clearRenderObjects();
|
|
buildPoints();
|
|
group.visible = visible;
|
|
}
|
|
|
|
function setData(items = []) {
|
|
invalidateScreenAvoidance();
|
|
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 = avoidanceConfig.enabled
|
|
? getAvoidanceKey(item, rawPosition, position, avoidanceConfig)
|
|
: null;
|
|
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;
|
|
}
|
|
|
|
function rebuildFromMarkerData(items) {
|
|
const wasVisible = visible;
|
|
setData(items);
|
|
group.visible = wasVisible;
|
|
}
|
|
|
|
function upsertItem(item) {
|
|
const itemId = getItemId(item);
|
|
if (itemId === undefined || itemId === null || String(itemId).trim() === "") {
|
|
return false;
|
|
}
|
|
let replaced = false;
|
|
const nextItems = markers.map((marker) => {
|
|
const markerItem = marker.userData || {};
|
|
if (String(getItemId(markerItem)) !== String(itemId)) return { ...markerItem };
|
|
replaced = true;
|
|
return { ...markerItem, ...item };
|
|
});
|
|
if (!replaced) {
|
|
nextItems.push(item);
|
|
}
|
|
rebuildFromMarkerData(nextItems);
|
|
return true;
|
|
}
|
|
|
|
function removeItem(itemId) {
|
|
if (itemId === undefined || itemId === null || String(itemId).trim() === "") {
|
|
return false;
|
|
}
|
|
const nextItems = markers
|
|
.map((marker) => ({ ...(marker.userData || {}) }))
|
|
.filter((item) => String(getItemId(item)) !== String(itemId));
|
|
if (nextItems.length === markers.length) return false;
|
|
rebuildFromMarkerData(nextItems);
|
|
return true;
|
|
}
|
|
|
|
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) {
|
|
invalidateScreenAvoidance();
|
|
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);
|
|
invalidateScreenAvoidance();
|
|
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();
|
|
recomputeScreenAvoidance(camera);
|
|
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 compactDotMode = shouldUseCompactDots(camera);
|
|
const scaleKey = usesDistanceScaling ? cameraScale.toFixed(3) : "fixed";
|
|
const nextStateKey = `${stateKey}:${scaleKey}:${compactDotMode ? "dots" : "icons"}`;
|
|
|
|
if (
|
|
nextStateKey === lastVisualStateKey &&
|
|
!(pulse.enabled && hasFocus) &&
|
|
!dynamicVisuals
|
|
) return;
|
|
lastVisualStateKey = nextStateKey;
|
|
|
|
pointObjects.forEach((points) => {
|
|
const sampleMarker = points.userData?.markers?.[0];
|
|
const nextTexture = compactDotMode
|
|
? createCompactDotTexture(sampleMarker)
|
|
: createPointTexture(points.userData?.bucketKey, points.userData?.markers || []);
|
|
if (points.material.map !== nextTexture) {
|
|
points.material.map = nextTexture;
|
|
points.material.needsUpdate = true;
|
|
}
|
|
updatePointColors(points, compactDotMode);
|
|
points.visible = visible;
|
|
points.material.opacity =
|
|
getPointOpacity?.(sampleMarker) ??
|
|
(hasFocus ? dimmedOpacity : baseOpacity);
|
|
points.material.size =
|
|
(compactDotMode ? COMPACT_DOT_POINT_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,
|
|
compactDotMode,
|
|
);
|
|
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,
|
|
compactDotMode,
|
|
);
|
|
}
|
|
|
|
function collectScreenAvoidanceEntries(camera) {
|
|
if (
|
|
!visible ||
|
|
!camera ||
|
|
markers.length === 0 ||
|
|
!avoidanceConfig.enabled ||
|
|
!avoidanceConfig.screen
|
|
) {
|
|
return [];
|
|
}
|
|
|
|
const compactDotMode = shouldUseCompactDots(camera);
|
|
const cameraScale = getCameraScale(camera);
|
|
const visualPointSize = compactDotMode ? COMPACT_DOT_POINT_SIZE : pointSize;
|
|
group.updateMatrixWorld?.(true);
|
|
|
|
return markers
|
|
.map((marker) => {
|
|
const staticPosition = getMarkerStaticAvoidancePosition(marker);
|
|
if (!(staticPosition instanceof THREE.Vector3)) return null;
|
|
screenAvoidanceStaticPositionScratch.copy(staticPosition);
|
|
screenAvoidanceWorldPositionScratch.copy(screenAvoidanceStaticPositionScratch);
|
|
group.localToWorld(screenAvoidanceWorldPositionScratch);
|
|
screenAvoidanceProjectedScratch
|
|
.copy(screenAvoidanceWorldPositionScratch)
|
|
.project(camera);
|
|
if (
|
|
screenAvoidanceProjectedScratch.z < -1 ||
|
|
screenAvoidanceProjectedScratch.z > 1
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const pointSizeMultiplier = getPointSizeMultiplier(marker) * cameraScale;
|
|
const sizePx = visualPointSize * pointSizeMultiplier;
|
|
return {
|
|
controller,
|
|
marker,
|
|
x:
|
|
(screenAvoidanceProjectedScratch.x * 0.5 + 0.5) *
|
|
(window.innerWidth || 1) +
|
|
(0.5 - iconAnchor.x) * sizePx,
|
|
y:
|
|
(-screenAvoidanceProjectedScratch.y * 0.5 + 0.5) *
|
|
(window.innerHeight || 1) +
|
|
(0.5 - iconAnchor.y) * sizePx,
|
|
radiusPx: Math.max(8, sizePx * 0.48),
|
|
radius: toFiniteNumber(avoidanceConfig.radius, DEFAULT_AVOIDANCE_RADIUS),
|
|
step: toFiniteNumber(avoidanceConfig.step, DEFAULT_AVOIDANCE_STEP),
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
const controller = {
|
|
refreshPositions,
|
|
refreshVisuals,
|
|
collectScreenAvoidanceEntries,
|
|
};
|
|
interactableLayerControllers.set(id, controller);
|
|
|
|
return {
|
|
group,
|
|
markers,
|
|
getMarkers: () => markers,
|
|
getCount: () => markers.length,
|
|
isVisible: () => visible,
|
|
setData,
|
|
upsertItem,
|
|
removeItem,
|
|
preloadAssets,
|
|
clearData,
|
|
attach,
|
|
setVisible,
|
|
setMarkerState,
|
|
updateVisualState,
|
|
getPointerIntersections,
|
|
refreshVisuals,
|
|
};
|
|
}
|