Files
planet/frontend/public/earth/js/interactable.js
linkong f3f1ceb833
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
release: bump version to 0.68.0
2026-05-28 17:10:05 +08:00

2010 lines
65 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 COMPACT_DOT_ZOOM_THRESHOLD = 1.7;
const COMPACT_DOT_POINT_SIZE = 12;
const COMPACT_DOT_RADIUS_RATIO = 0.26;
const CLUSTER_OVERLAP_FACTOR = 0.9;
const CLUSTER_MIN_COUNT = 2;
const CLUSTER_MAX_MARKERS_PER_DOT = 14;
const CLUSTER_MAX_POINT_SIZE = 32;
const CLUSTER_MAX_SCREEN_DIAMETER_PX = 96;
const CLUSTER_SEED_DISTANCE_FACTOR = 1.8;
const CLUSTER_TRANSITION_MS = 220;
const CLUSTER_BAND_HYSTERESIS = 0.08;
const CLUSTER_DISABLE_ABOVE_ZOOM = 2.5;
const CLUSTER_STRATEGY_DYNAMIC_SCREEN = "dynamic-screen";
const CLUSTER_STRATEGY_STABLE_SPHERICAL = "stable-spherical";
const CLUSTER_STRATEGY_NONE = "none";
const CLUSTER_ZOOM_BANDS = Object.freeze([
Object.freeze({
maxZoom: 2.0,
referenceZoom: 1.0,
overlapFactor: 0.9,
maxDiameterPx: 96,
}),
Object.freeze({
maxZoom: 3.0,
referenceZoom: 2.0,
overlapFactor: 0.45,
maxDiameterPx: 72,
}),
Object.freeze({
maxZoom: Number.POSITIVE_INFINITY,
referenceZoom: 3.0,
overlapFactor: 0.2,
maxDiameterPx: 48,
}),
]);
const SPHERICAL_CLUSTER_BANDS = Object.freeze([
Object.freeze({ key: "far", maxZoom: 1.7, distance: 15 }),
Object.freeze({ key: "mid", maxZoom: 2.6, distance: 8 }),
Object.freeze({ key: "near", maxZoom: 3.5, distance: 4 }),
Object.freeze({ key: "detail", maxZoom: Number.POSITIVE_INFINITY, distance: 0 }),
]);
let compactDotsEnabled = true;
let screenAvoidanceRevision = 0;
// Named overlap profiles. Layers that should be recognized as sharing a place
// must reference the SAME profile. The profile records overlap metadata only;
// it never moves the marker away from its real geographic anchor.
//
// 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.
// Overlap tracking is opt-in: dynamic high-density layers such as vessels should
// leave it disabled so their per-frame metadata work stays minimal.
export const SURFACE_AVOIDANCE_PROFILES = Object.freeze({
city: Object.freeze({ precision: 2 }),
precise: Object.freeze({
precision: DEFAULT_AVOIDANCE_PRECISION,
}),
});
function colorToRgbArray(colorValue, fallback = "#ffffff") {
const color = new THREE.Color(colorValue || fallback);
return [color.r, color.g, color.b];
}
function normalizeAvoidanceConfig(avoidance) {
if (avoidance === false || avoidance === null || avoidance === undefined) {
return {
enabled: false,
screen: false,
precision: DEFAULT_AVOIDANCE_PRECISION,
};
}
const customConfig =
avoidance === true || typeof avoidance !== "object" ? {} : avoidance;
const config = {
enabled: true,
precision: DEFAULT_AVOIDANCE_PRECISION,
screen: false,
...customConfig,
};
config.enabled = config.enabled !== false;
config.screen = false;
return config;
}
function normalizeClusterConfig(cluster, avoidanceConfig) {
if (cluster === false || cluster === null) {
return {
enabled: false,
strategy: CLUSTER_STRATEGY_NONE,
overlapFactor: CLUSTER_OVERLAP_FACTOR,
minCount: CLUSTER_MIN_COUNT,
maxMarkersPerDot: CLUSTER_MAX_MARKERS_PER_DOT,
bands: SPHERICAL_CLUSTER_BANDS,
transitionMs: CLUSTER_TRANSITION_MS,
bandHysteresis: CLUSTER_BAND_HYSTERESIS,
disableAboveZoom: CLUSTER_DISABLE_ABOVE_ZOOM,
};
}
const customConfig = typeof cluster === "object" ? cluster : {};
const requestedStrategy = String(customConfig.strategy || "").trim();
const enabled = customConfig.enabled ?? avoidanceConfig.enabled;
const strategy =
enabled === false
? CLUSTER_STRATEGY_NONE
: [
CLUSTER_STRATEGY_DYNAMIC_SCREEN,
CLUSTER_STRATEGY_STABLE_SPHERICAL,
CLUSTER_STRATEGY_NONE,
].includes(requestedStrategy)
? requestedStrategy
: CLUSTER_STRATEGY_DYNAMIC_SCREEN;
return {
enabled: enabled !== false && strategy !== CLUSTER_STRATEGY_NONE,
strategy,
overlapFactor: Number.isFinite(Number(customConfig.overlapFactor))
? Number(customConfig.overlapFactor)
: CLUSTER_OVERLAP_FACTOR,
minCount: Math.max(2, Math.round(Number(customConfig.minCount ?? CLUSTER_MIN_COUNT))),
maxMarkersPerDot: Math.max(
2,
Math.round(Number(customConfig.maxMarkersPerDot ?? CLUSTER_MAX_MARKERS_PER_DOT)),
),
bands: normalizeSphericalClusterBands(customConfig.bands),
transitionMs: Math.max(
0,
Math.round(Number(customConfig.transitionMs ?? CLUSTER_TRANSITION_MS)),
),
bandHysteresis: Math.max(
0,
Number(customConfig.bandHysteresis ?? CLUSTER_BAND_HYSTERESIS),
),
disableAboveZoom:
customConfig.disableAboveZoom === false || customConfig.disableAboveZoom === null
? Number.POSITIVE_INFINITY
: Number.isFinite(Number(customConfig.disableAboveZoom))
? Number(customConfig.disableAboveZoom)
: CLUSTER_DISABLE_ABOVE_ZOOM,
};
}
function normalizeSphericalClusterBands(bands) {
if (!Array.isArray(bands) || bands.length === 0) return SPHERICAL_CLUSTER_BANDS;
const normalizedBands = bands
.map((band, index) => {
const maxZoom = Number(band?.maxZoom);
const distance = Number(band?.distance);
return {
key: String(band?.key || `band-${index}`),
maxZoom: Number.isFinite(maxZoom) ? maxZoom : Number.POSITIVE_INFINITY,
distance: Number.isFinite(distance) ? Math.max(0, distance) : 0,
};
})
.sort((a, b) => a.maxZoom - b.maxZoom);
return normalizedBands.length > 0 ? normalizedBands : SPHERICAL_CLUSTER_BANDS;
}
function getClusterPointSize(count) {
const safeCount = Math.max(2, Number(count) || 2);
return Math.min(
CLUSTER_MAX_POINT_SIZE,
COMPACT_DOT_POINT_SIZE + Math.log2(safeCount) * 5.5,
);
}
function getClusterZoomBand(zoom) {
return (
CLUSTER_ZOOM_BANDS.find((band) => zoom <= band.maxZoom) ||
CLUSTER_ZOOM_BANDS[CLUSTER_ZOOM_BANDS.length - 1]
);
}
function getSphericalClusterBand(config, zoom, previousBandKey = null) {
const bands = config?.bands || SPHERICAL_CLUSTER_BANDS;
const previousIndex = previousBandKey
? bands.findIndex((band) => band.key === previousBandKey)
: -1;
const hysteresis = Number(config?.bandHysteresis) || 0;
if (previousIndex >= 0 && hysteresis > 0) {
const previousBand = bands[previousIndex];
const lowerBoundary =
previousIndex > 0 ? bands[previousIndex - 1].maxZoom : Number.NEGATIVE_INFINITY;
const upperBoundary = previousBand.maxZoom;
if (zoom > lowerBoundary - hysteresis && zoom <= upperBoundary + hysteresis) {
return previousBand;
}
}
return bands.find((band) => zoom <= band.maxZoom) || bands[bands.length - 1];
}
function getNowMs() {
return typeof performance !== "undefined" && typeof performance.now === "function"
? performance.now()
: Date.now();
}
function easeOutCubic(value) {
const t = Math.max(0, Math.min(1, value));
return 1 - Math.pow(1 - t, 3);
}
function getTransitionProgress(startedAt, durationMs) {
if (!startedAt || !durationMs || durationMs <= 0) return 1;
return easeOutCubic((getNowMs() - startedAt) / durationMs);
}
function getNominalGlobePixelsPerWorldUnit(camera, referenceZoom) {
if (!camera) return 1;
const viewportHeight = window.innerHeight || 1;
const fovRad = ((camera.fov || 75) * Math.PI) / 180;
const distance = Math.max(
1,
Number.isFinite(referenceZoom) && referenceZoom > 0
? CONFIG.defaultCameraZ / referenceZoom
: camera.position?.length?.() || CONFIG.defaultCameraZ,
);
return viewportHeight / (2 * Math.tan(fovRad / 2) * distance);
}
function getAngularRadiusFromPixels(pixelRadius, worldRadius, camera, referenceZoom) {
const globeRadiusPx = Math.max(
1,
worldRadius * getNominalGlobePixelsPerWorldUnit(camera, referenceZoom),
);
return Math.max(0, pixelRadius) / globeRadiusPx;
}
function getClusterOverlapFactorForBand(band, baseFactor = CLUSTER_OVERLAP_FACTOR) {
return Math.min(baseFactor, band.overlapFactor);
}
function getSphericalBucketKey(latIndex, lonIndex) {
return `${latIndex}:${lonIndex}`;
}
function normalizeSphericalLongitudeIndex(lonIndex, lonBucketCount) {
if (!Number.isFinite(lonBucketCount) || lonBucketCount <= 0) return lonIndex;
return ((lonIndex % lonBucketCount) + lonBucketCount) % lonBucketCount;
}
function getNeighboringSphericalBucketKeys(latIndex, lonIndex, lonBucketCount) {
const keys = [];
for (let latOffset = -1; latOffset <= 1; latOffset += 1) {
for (let lonOffset = -1; lonOffset <= 1; lonOffset += 1) {
keys.push(
getSphericalBucketKey(
latIndex + latOffset,
normalizeSphericalLongitudeIndex(lonIndex + lonOffset, lonBucketCount),
),
);
}
}
return keys;
}
function getEntrySphericalCoordinates(entry) {
const direction = entry.direction;
const lat = Math.asin(Math.max(-1, Math.min(1, direction.y)));
const lon = Math.atan2(direction.z, direction.x);
return { lat, lon };
}
function hashStableIds(entries) {
let hash = 2166136261;
entries.forEach((entry) => {
const value = entry.stableId;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
});
return (hash >>> 0).toString(36);
}
function getClusterRecordId(entries) {
const sortedEntries = [...entries].sort((a, b) => a.stableId.localeCompare(b.stableId));
const anchorId = sortedEntries[0]?.stableId || "unknown";
return `cluster:${anchorId}:${sortedEntries.length}:${hashStableIds(sortedEntries)}`;
}
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;
}
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 = null;
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) => {
entry.marker.position.copy(entry.marker.userData.icon_base_position);
entry.marker.userData.icon_static_avoidance_position = null;
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,
});
affectedKeys.add(key);
});
affectedKeys.forEach((key) => recomputeAvoidanceBucket(key));
}
function getMarkerStaticAvoidancePosition(marker) {
return (
marker?.userData?.icon_base_position ||
marker?.position
);
}
function collectClusterUpdates(camera) {
if (!camera) return [];
const updates = [];
interactableLayerControllers.forEach((controller) => {
const update = controller.collectClusterEntries?.(camera);
if (!update) return;
if (Array.isArray(update)) {
updates.push({
strategy: CLUSTER_STRATEGY_DYNAMIC_SCREEN,
entries: update,
records: [],
});
return;
}
updates.push(update);
});
return updates;
}
function findScreenClusterGroups(entries) {
const unvisited = new Set(entries);
const groups = [];
entries.forEach((seed) => {
if (!unvisited.has(seed)) return;
const groupEntries = [seed];
unvisited.delete(seed);
let changed = true;
while (changed) {
changed = false;
const candidates = [...unvisited].sort((a, b) => {
const distanceA = getAngularDistance(seed, a);
const distanceB = getAngularDistance(seed, b);
return distanceA - distanceB || a.stableId.localeCompare(b.stableId);
});
for (const candidate of candidates) {
const overlapFactor = Math.min(
seed.overlapFactor || CLUSTER_OVERLAP_FACTOR,
candidate.overlapFactor || CLUSTER_OVERLAP_FACTOR,
);
const seedDistance = getAngularDistance(seed, candidate);
const seedLimit =
(seed.angularRadius + candidate.angularRadius) *
CLUSTER_SEED_DISTANCE_FACTOR;
if (seedDistance > seedLimit) continue;
const overlapsGroup = groupEntries.some((entry) => {
const distance = getAngularDistance(candidate, entry);
return distance <= (candidate.angularRadius + entry.angularRadius) * overlapFactor;
});
if (!overlapsGroup) continue;
const maxAngularDiameter = Math.max(
seed.maxAngularDiameter,
candidate.maxAngularDiameter,
);
const staysLocal = groupEntries.every(
(entry) => getAngularDistance(candidate, entry) <= maxAngularDiameter,
);
if (!staysLocal) {
continue;
}
groupEntries.push(candidate);
unvisited.delete(candidate);
changed = true;
}
}
if (groupEntries.length >= CLUSTER_MIN_COUNT) {
groups.push(groupEntries.sort((a, b) => a.stableId.localeCompare(b.stableId)));
return;
}
groupEntries.forEach((entry) => unvisited.add(entry));
});
return groups;
}
function getScreenDistance(entryA, entryB) {
return Math.hypot(entryA.x - entryB.x, entryA.y - entryB.y);
}
function getAngularDistance(entryA, entryB) {
const dot = entryA.direction.dot(entryB.direction);
return Math.acos(Math.min(1, Math.max(-1, dot)));
}
function getScreenBounds(entries) {
return entries.reduce(
(bounds, entry) => ({
left: Math.min(bounds.left, entry.x - entry.radiusPx),
right: Math.max(bounds.right, entry.x + entry.radiusPx),
top: Math.min(bounds.top, entry.y - entry.radiusPx),
bottom: Math.max(bounds.bottom, entry.y + entry.radiusPx),
}),
{
left: Number.POSITIVE_INFINITY,
right: Number.NEGATIVE_INFINITY,
top: Number.POSITIVE_INFINITY,
bottom: Number.NEGATIVE_INFINITY,
},
);
}
function splitLargeScreenClusterGroup(groupEntries) {
const maxMarkersPerDot = groupEntries.reduce(
(maxCount, entry) => Math.min(maxCount, entry.maxMarkersPerDot || CLUSTER_MAX_MARKERS_PER_DOT),
Number.POSITIVE_INFINITY,
);
const safeMaxMarkersPerDot = Number.isFinite(maxMarkersPerDot)
? maxMarkersPerDot
: CLUSTER_MAX_MARKERS_PER_DOT;
if (groupEntries.length <= safeMaxMarkersPerDot) return [groupEntries];
const sortedEntries = [...groupEntries].sort(compareEntriesByStableGeography);
const chunks = [];
for (let index = 0; index < sortedEntries.length; index += safeMaxMarkersPerDot) {
chunks.push(sortedEntries.slice(index, index + safeMaxMarkersPerDot));
}
return chunks;
}
function computeSphericalClusterRecords(entries, config, band) {
if (!entries.length || !band || band.distance <= 0) return [];
const radius =
entries.find((entry) => Number.isFinite(entry.radiusWorld))?.radiusWorld ||
CONFIG.earthRadius;
const angularThreshold = Math.max(0.00001, band.distance / Math.max(1, radius));
const lonBucketCount = Math.max(1, Math.ceil((Math.PI * 2) / angularThreshold));
const buckets = new Map();
const entryState = entries.map((entry) => {
const { lat, lon } = getEntrySphericalCoordinates(entry);
const latIndex = Math.floor(lat / angularThreshold);
const lonIndex = normalizeSphericalLongitudeIndex(
Math.floor(lon / angularThreshold),
lonBucketCount,
);
const state = {
...entry,
latIndex,
lonIndex,
};
const key = getSphericalBucketKey(latIndex, lonIndex);
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key).push(state);
return state;
});
const unvisited = new Set(entryState);
const groups = [];
entryState
.slice()
.sort((a, b) => a.stableId.localeCompare(b.stableId))
.forEach((seed) => {
if (!unvisited.has(seed)) return;
const groupEntries = [seed];
unvisited.delete(seed);
const neighborKeys = getNeighboringSphericalBucketKeys(
seed.latIndex,
seed.lonIndex,
lonBucketCount,
);
const candidates = neighborKeys
.flatMap((key) => buckets.get(key) || [])
.filter((candidate) => unvisited.has(candidate))
.sort((a, b) => getAngularDistance(seed, a) - getAngularDistance(seed, b) || a.stableId.localeCompare(b.stableId));
candidates.forEach((candidate) => {
if (!unvisited.has(candidate)) return;
if (getAngularDistance(seed, candidate) > angularThreshold) return;
const overlapsGroup = groupEntries.some(
(entry) => getAngularDistance(candidate, entry) <= angularThreshold,
);
if (!overlapsGroup) return;
groupEntries.push(candidate);
unvisited.delete(candidate);
});
if (groupEntries.length >= config.minCount) {
groups.push(groupEntries.sort((a, b) => a.stableId.localeCompare(b.stableId)));
return;
}
groupEntries.forEach((entry) => unvisited.add(entry));
});
return groups
.flatMap(splitLargeScreenClusterGroup)
.map(createScreenClusterRecord)
.filter(Boolean);
}
function compareEntriesByStableGeography(a, b) {
const lonA = Math.atan2(a.direction.z, a.direction.x);
const lonB = Math.atan2(b.direction.z, b.direction.x);
if (Math.abs(lonA - lonB) > 1e-6) return lonA - lonB;
const latA = Math.asin(Math.max(-1, Math.min(1, a.direction.y)));
const latB = Math.asin(Math.max(-1, Math.min(1, b.direction.y)));
if (Math.abs(latA - latB) > 1e-6) return latA - latB;
return a.stableId.localeCompare(b.stableId);
}
function createScreenClusterRecord(groupEntries) {
const sortedEntries = [...groupEntries].sort((a, b) => a.stableId.localeCompare(b.stableId));
const anchorEntry = sortedEntries[0];
const clusterPosition = new THREE.Vector3();
let positionCount = 0;
sortedEntries.forEach((entry) => {
const position = getMarkerStaticAvoidancePosition(entry.marker);
if (!(position instanceof THREE.Vector3)) return;
clusterPosition.add(position);
positionCount += 1;
});
if (positionCount === 0) return null;
const fallbackPosition = getMarkerStaticAvoidancePosition(anchorEntry?.marker);
const altitudeRadius =
fallbackPosition instanceof THREE.Vector3
? fallbackPosition.length()
: CONFIG.earthRadius;
clusterPosition.normalize().multiplyScalar(altitudeRadius);
const center = { x: 0, y: 0 };
sortedEntries.forEach((entry) => {
center.x += entry.x;
center.y += entry.y;
});
center.x /= sortedEntries.length;
center.y /= sortedEntries.length;
const clusterId = getClusterRecordId(sortedEntries);
return {
clusterId,
markers: sortedEntries.map((entry) => entry.marker),
owner: anchorEntry.controller,
position: clusterPosition,
pointSize: getClusterPointSize(sortedEntries.length),
anchorStableId: anchorEntry.stableId,
clusterZoomBand: anchorEntry.clusterBandKey || anchorEntry.clusterZoomBand,
screenX: center.x,
screenY: center.y,
transitionStartedAt: getNowMs(),
};
}
function recomputeScreenAvoidance(camera) {
if (!camera) return;
const controllersToRebuild = new Set();
interactableLayerControllers.forEach((controller) => {
controller.beginClusterUpdate?.();
});
const updates = collectClusterUpdates(camera);
const dynamicEntries = updates
.filter((update) => update.strategy === CLUSTER_STRATEGY_DYNAMIC_SCREEN)
.flatMap((update) => update.entries || [])
.sort((a, b) => a.stableId.localeCompare(b.stableId));
const stableRecords = updates
.filter((update) => update.strategy === CLUSTER_STRATEGY_STABLE_SPHERICAL)
.flatMap((update) => update.records || []);
const entries = dynamicEntries;
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;
});
findScreenClusterGroups(entries)
.flatMap(splitLargeScreenClusterGroup)
.map(createScreenClusterRecord)
.filter(Boolean)
.concat(stableRecords)
.forEach((record) => {
record.owner.addOwnedCluster?.(record);
record.markers.forEach((marker) => {
const owner = interactableLayerControllers.get(marker.userData?.icon_layer_id);
owner?.markMarkerClustered?.(marker, record);
});
});
interactableLayerControllers.forEach((controller) => {
if (controller.commitClusterUpdate?.()) {
controllersToRebuild.add(controller);
}
});
controllersToRebuild.forEach((controller) => controller.rebuildPointLayers?.(camera));
}
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,
cluster = undefined,
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 clusterPointObjects = [];
const textureCache = new Map();
let pointsGroup = null;
let clusterGroup = null;
let hoverOverlay = null;
let lockedOverlay = null;
let visible = false;
let lastVisualStateKey = "";
let lastClusterSignature = "";
let pendingClusterSignature = "";
let clusterUpdateActive = false;
let visualStateVersion = 0;
let clusterTopologyRevision = 0;
let lastStableClusterKey = "";
let lastStableClusterBandKey = "";
let stableClusterRecords = [];
const ownedClusterRecords = [];
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);
const clusterConfig = normalizeClusterConfig(cluster, avoidanceConfig);
const clusterWorldScratch = new THREE.Vector3();
const clusterProjectedScratch = new THREE.Vector3();
const clusterCameraLocalScratch = new THREE.Vector3();
const clusterDirectionScratch = new THREE.Vector3();
function invalidateVisualState() {
visualStateVersion += 1;
lastVisualStateKey = "";
}
function invalidateClusterTopology() {
clusterTopologyRevision += 1;
lastStableClusterKey = "";
lastStableClusterBandKey = "";
}
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 getClusterMajorityColor(clusterMarkers) {
const colorCounts = new Map();
clusterMarkers.forEach((marker) => {
const color = getMarkerColor(marker);
colorCounts.set(color, (colorCounts.get(color) || 0) + 1);
});
return [...colorCounts.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0] ||
colors.normal ||
"#ffffff";
}
function createClusterTexture(clusterMarkers) {
const color = getClusterMajorityColor(clusterMarkers);
const textureKey = `cluster-dot:${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 getCameraZoom(camera) <= COMPACT_DOT_ZOOM_THRESHOLD;
}
function getMarkerStableId(marker, fallbackIndex = 0) {
const itemId = getItemId(marker?.userData || {});
if (itemId !== undefined && itemId !== null && String(itemId).trim() !== "") {
return String(itemId);
}
return `${id}:${fallbackIndex}`;
}
function beginClusterUpdate() {
clusterUpdateActive = true;
ownedClusterRecords.length = 0;
markers.forEach((marker) => {
marker.userData.icon_cluster_hidden = false;
marker.userData.icon_cluster_count = 1;
marker.userData.icon_cluster_id = null;
});
}
function collectClusterEntries(camera) {
if (!visible || !clusterConfig.enabled || !camera || markers.length === 0) {
return null;
}
const cameraScale = getCameraScale(camera);
const zoom = getCameraZoom(camera);
if (zoom > clusterConfig.disableAboveZoom) {
lastStableClusterBandKey = "";
return {
strategy: CLUSTER_STRATEGY_DYNAMIC_SCREEN,
entries: [],
records: [],
};
}
const clusterZoomBand = getClusterZoomBand(zoom);
const sphericalBand = getSphericalClusterBand(
clusterConfig,
zoom,
lastStableClusterBandKey,
);
if (clusterConfig.strategy === CLUSTER_STRATEGY_STABLE_SPHERICAL && sphericalBand?.key) {
lastStableClusterBandKey = sphericalBand.key;
}
const stableSpherical = clusterConfig.strategy === CLUSTER_STRATEGY_STABLE_SPHERICAL;
const visualPointSize = shouldUseCompactDots(camera)
? COMPACT_DOT_POINT_SIZE
: pointSize;
const width = window.innerWidth || 1;
const height = window.innerHeight || 1;
clusterCameraLocalScratch.copy(camera.position);
group.parent?.worldToLocal?.(clusterCameraLocalScratch);
clusterCameraLocalScratch.normalize();
group.updateMatrixWorld?.(true);
const entries = markers
.map((marker, index) => {
const basePosition = marker.userData?.icon_base_position || marker.position;
if (!(basePosition instanceof THREE.Vector3)) return null;
const direction = clusterDirectionScratch.copy(basePosition).normalize().clone();
if (!stableSpherical) {
if (clusterCameraLocalScratch.dot(clusterDirectionScratch) <= 0) return null;
clusterWorldScratch.copy(basePosition);
group.localToWorld(clusterWorldScratch);
clusterProjectedScratch.copy(clusterWorldScratch).project(camera);
if (clusterProjectedScratch.z < -1 || clusterProjectedScratch.z > 1) {
return null;
}
}
const pointSizeMultiplier = getPointSizeMultiplier(marker) * cameraScale;
const sizePx = visualPointSize * pointSizeMultiplier;
const screenX =
(stableSpherical ? 0 : (clusterProjectedScratch.x * 0.5 + 0.5) * width) +
(0.5 - iconAnchor.x) * sizePx;
const screenY =
(stableSpherical ? 0 : (-clusterProjectedScratch.y * 0.5 + 0.5) * height) +
(0.5 - iconAnchor.y) * sizePx;
const nominalRadiusPx = Math.max(8, visualPointSize * 0.5);
const angularRadius = getAngularRadiusFromPixels(
nominalRadiusPx,
basePosition.length(),
camera,
clusterZoomBand.referenceZoom,
);
const maxAngularDiameter = getAngularRadiusFromPixels(
Math.max(clusterZoomBand.maxDiameterPx, nominalRadiusPx * 6),
basePosition.length(),
camera,
clusterZoomBand.referenceZoom,
);
return {
marker,
stableId: `${id}:${getMarkerStableId(marker, index)}`,
controller,
x: screenX,
y: screenY,
radiusPx: Math.max(8, sizePx * 0.5),
direction,
angularRadius,
maxAngularDiameter,
overlapFactor: getClusterOverlapFactorForBand(
clusterZoomBand,
clusterConfig.overlapFactor,
),
maxMarkersPerDot: clusterConfig.maxMarkersPerDot,
clusterZoomBand: clusterZoomBand.referenceZoom,
clusterBandKey: sphericalBand?.key || String(clusterZoomBand.referenceZoom),
radiusWorld: basePosition.length(),
};
})
.filter(Boolean)
.sort((a, b) => a.stableId.localeCompare(b.stableId));
if (clusterConfig.strategy !== CLUSTER_STRATEGY_STABLE_SPHERICAL) {
return {
strategy: CLUSTER_STRATEGY_DYNAMIC_SCREEN,
entries,
records: [],
};
}
const stableKey = [
id,
sphericalBand?.key || "unknown",
sphericalBand?.distance ?? 0,
clusterTopologyRevision,
entries.length,
].join(":");
if (stableKey !== lastStableClusterKey) {
stableClusterRecords = computeSphericalClusterRecords(
entries,
clusterConfig,
sphericalBand,
);
lastStableClusterKey = stableKey;
}
return {
strategy: CLUSTER_STRATEGY_STABLE_SPHERICAL,
entries: [],
records: stableClusterRecords,
};
}
function addOwnedCluster(record) {
ownedClusterRecords.push(record);
}
function markMarkerClustered(marker, record) {
if (!marker || marker.userData?.icon_layer_id !== id) return;
marker.userData.icon_cluster_hidden = true;
marker.userData.icon_cluster_count = record.markers.length;
marker.userData.icon_cluster_id = record.clusterId;
}
function getClusterSignature() {
return ownedClusterRecords
.map((record) =>
`${record.clusterId}:${record.anchorStableId}:${record.clusterZoomBand}:${record.pointSize.toFixed(1)}`,
)
.join("|");
}
function commitClusterUpdate() {
if (!clusterUpdateActive) return false;
clusterUpdateActive = false;
pendingClusterSignature = getClusterSignature();
if (pendingClusterSignature === lastClusterSignature) return false;
lastClusterSignature = pendingClusterSignature;
return true;
}
function hasActiveRenderTransitions() {
if (clusterConfig.transitionMs <= 0) return false;
const now = getNowMs();
return pointObjects
.concat(clusterPointObjects)
.some((points) => {
const startedAt = points.userData?.transitionStartedAt;
return startedAt && now - startedAt < clusterConfig.transitionMs;
});
}
function disposePointsGroup() {
if (pointsGroup?.parent) {
pointsGroup.parent.remove(pointsGroup);
}
pointObjects.forEach((points) => {
points.geometry?.dispose?.();
points.material?.dispose?.();
});
pointsGroup = null;
pointObjects.length = 0;
}
function disposeClusterGroup() {
if (clusterGroup?.parent) {
clusterGroup.parent.remove(clusterGroup);
}
clusterPointObjects.forEach((points) => {
points.geometry?.dispose?.();
points.material?.dispose?.();
});
clusterGroup = null;
clusterPointObjects.length = 0;
}
function rebuildPointLayers(camera = null) {
disposePointsGroup();
disposeClusterGroup();
buildPoints(camera);
buildClusters();
if (pointsGroup) pointsGroup.visible = visible;
if (clusterGroup) clusterGroup.visible = visible;
invalidateVisualState();
}
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(camera = null) {
refreshViewportSize();
const compactDotMode = camera ? shouldUseCompactDots(camera) : false;
pointsGroup = new THREE.Group();
pointsGroup.visible = visible;
pointsGroup.renderOrder = renderOrder;
pointsGroup.userData = { type: `${id}_points`, id };
pointObjects.length = 0;
const buckets = new Map();
markers
.filter((marker) => !marker.userData?.icon_cluster_hidden)
.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: compactDotMode
? createCompactDotTexture(bucketMarkers[0])
: createPointTexture(bucketKey, bucketMarkers),
size:
(compactDotMode ? COMPACT_DOT_POINT_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]),
transitionStartedAt: getNowMs(),
};
pointObjects.push(points);
pointsGroup.add(points);
});
group.add(pointsGroup);
}
function buildClusters() {
if (ownedClusterRecords.length === 0) return;
clusterGroup = new THREE.Group();
clusterGroup.visible = visible;
clusterGroup.renderOrder = renderOrder + 0.05;
clusterGroup.userData = { type: `${id}_clusters`, id };
ownedClusterRecords.forEach((record) => {
if (!(record.position instanceof THREE.Vector3) || record.markers.length < 2) return;
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.BufferAttribute(
new Float32Array([
record.position.x,
record.position.y,
record.position.z,
]),
3,
),
);
geometry.computeBoundingSphere();
const material = applyIconAnchor(
new THREE.PointsMaterial({
map: createClusterTexture(record.markers),
size: record.pointSize,
sizeAttenuation: false,
transparent: true,
opacity: baseOpacity,
depthWrite,
depthTest,
alphaTest,
}),
);
const points = new THREE.Points(geometry, material);
points.renderOrder = renderOrder + 0.05;
points.frustumCulled = false;
points.userData = {
type: `${id}_cluster`,
id,
clusterId: record.clusterId,
markers: record.markers,
clusterPointSize: record.pointSize,
transitionStartedAt: record.transitionStartedAt || getNowMs(),
};
clusterPointObjects.push(points);
clusterGroup.add(points);
});
if (clusterGroup.children.length > 0) {
group.add(clusterGroup);
} else {
clusterGroup = null;
}
}
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() {
disposePointsGroup();
disposeClusterGroup();
hoverOverlay?.geometry?.dispose?.();
hoverOverlay?.material?.dispose?.();
hoverOverlay?.parent?.remove?.(hoverOverlay);
lockedOverlay?.geometry?.dispose?.();
lockedOverlay?.material?.dispose?.();
lockedOverlay?.parent?.remove?.(lockedOverlay);
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();
invalidateClusterTopology();
if (!pointsGroup && !clusterGroup) return;
rebuildPointLayers();
group.visible = visible;
}
function setData(items = []) {
invalidateScreenAvoidance();
invalidateVisualState();
invalidateClusterTopology();
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();
invalidateClusterTopology();
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();
invalidateClusterTopology();
group.visible = visible;
if (pointsGroup) {
pointsGroup.visible = visible;
}
if (clusterGroup) {
clusterGroup.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);
const hasFocus = focusType === objectType && focusObject;
if (!visible || markers.length === 0 || !pointsGroup) {
if (lastVisualStateKey !== "hidden") {
if (pointsGroup) pointsGroup.visible = false;
if (clusterGroup) clusterGroup.visible = false;
if (hoverOverlay) hoverOverlay.visible = false;
if (lockedOverlay) lockedOverlay.visible = false;
lastVisualStateKey = "hidden";
}
return;
}
pointsGroup.visible = true;
if (clusterGroup) clusterGroup.visible = true;
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 &&
!hasActiveRenderTransitions()
) 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;
const transitionProgress = getTransitionProgress(
points.userData?.transitionStartedAt,
clusterConfig.transitionMs,
);
const transitionScale = 0.82 + transitionProgress * 0.18;
points.material.opacity =
getPointOpacity?.(sampleMarker) ??
(hasFocus ? dimmedOpacity : baseOpacity) * transitionProgress;
points.material.size =
(compactDotMode ? COMPACT_DOT_POINT_SIZE : pointSize) *
getPointSizeMultiplier(sampleMarker) *
cameraScale *
(hasFocus ? dimmedScale : 1) *
transitionScale;
});
clusterPointObjects.forEach((points) => {
points.visible = visible;
const transitionProgress = getTransitionProgress(
points.userData?.transitionStartedAt,
clusterConfig.transitionMs,
);
const transitionScale = 0.68 + transitionProgress * 0.32;
const pulseScale = 1 + Math.sin(Date.now() / 260) * 0.018;
points.material.opacity = (hasFocus ? dimmedOpacity : baseOpacity) * transitionProgress;
points.material.size =
(points.userData?.clusterPointSize || COMPACT_DOT_POINT_SIZE) *
(hasFocus ? dimmedScale : 1) *
transitionScale *
pulseScale;
});
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 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);
const compactDotMode = shouldUseCompactDots(camera);
const visualPointSize = compactDotMode ? COMPACT_DOT_POINT_SIZE : pointSize;
clusterPointObjects.forEach((clusterPoints) => {
const clusterMarkers = clusterPoints.userData?.markers || [];
const positionAttribute = clusterPoints.geometry?.getAttribute("position");
if (!positionAttribute || clusterMarkers.length === 0) return;
scratchDirection
.set(
positionAttribute.getX(0),
positionAttribute.getY(0),
positionAttribute.getZ(0),
)
.normalize();
if (scratchCameraLocal.dot(scratchDirection) <= frontFacingDotThreshold) {
return;
}
scratchWorldPosition.set(
positionAttribute.getX(0),
positionAttribute.getY(0),
positionAttribute.getZ(0),
);
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 deltaX = screenX - pointerX;
const deltaY = screenY - pointerY;
const clusterPointSize =
clusterPoints.userData?.clusterPointSize || COMPACT_DOT_POINT_SIZE;
const clusterRadius = Math.max(radiusPx, clusterPointSize * 0.9);
const distancePxSq = deltaX * deltaX + deltaY * deltaY;
if (distancePxSq > clusterRadius * clusterRadius) return;
intersections.push({
cluster: true,
clusterCount: clusterMarkers.length,
clusterMarkers,
object: null,
point: scratchWorldPosition.clone(),
distance: camera.position.distanceTo(scratchWorldPosition),
distancePxSq,
});
});
markers.forEach((marker) => {
if (marker.userData?.icon_cluster_hidden) return;
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) * visualPointSize * pointSizeMultiplier;
const visualCenterY =
screenY + (0.5 - iconAnchor.y) * visualPointSize * pointSizeMultiplier;
const deltaX = visualCenterX - pointerX;
const deltaY = visualCenterY - pointerY;
const distancePxSq = deltaX * deltaX + deltaY * deltaY;
const markerRadiusPx = Math.max(
radiusPx,
visualPointSize * pointSizeMultiplier * 0.5,
);
if (distancePxSq > Math.max(radiusSq, markerRadiusPx * markerRadiusPx)) 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,
beginClusterUpdate,
collectClusterEntries,
addOwnedCluster,
markMarkerClustered,
commitClusterUpdate,
rebuildPointLayers,
};
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,
};
}