1681 lines
54 KiB
JavaScript
1681 lines
54 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_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,
|
|
}),
|
|
]);
|
|
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,
|
|
overlapFactor: CLUSTER_OVERLAP_FACTOR,
|
|
minCount: CLUSTER_MIN_COUNT,
|
|
maxMarkersPerDot: CLUSTER_MAX_MARKERS_PER_DOT,
|
|
};
|
|
}
|
|
|
|
const customConfig = typeof cluster === "object" ? cluster : {};
|
|
return {
|
|
enabled: customConfig.enabled ?? avoidanceConfig.enabled,
|
|
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)),
|
|
),
|
|
};
|
|
}
|
|
|
|
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 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 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 collectScreenAvoidanceEntries(camera) {
|
|
if (!camera) return [];
|
|
const entries = [];
|
|
interactableLayerControllers.forEach((controller) => {
|
|
entries.push(...(controller.collectClusterEntries?.(camera) || []));
|
|
});
|
|
return entries.sort((a, b) => a.stableId.localeCompare(b.stableId));
|
|
}
|
|
|
|
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 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 = `cluster:${sortedEntries.map((entry) => entry.stableId).join("+")}`;
|
|
return {
|
|
clusterId,
|
|
markers: sortedEntries.map((entry) => entry.marker),
|
|
owner: anchorEntry.controller,
|
|
position: clusterPosition,
|
|
pointSize: getClusterPointSize(sortedEntries.length),
|
|
anchorStableId: anchorEntry.stableId,
|
|
clusterZoomBand: anchorEntry.clusterZoomBand,
|
|
screenX: center.x,
|
|
screenY: center.y,
|
|
};
|
|
}
|
|
|
|
function recomputeScreenAvoidance(camera) {
|
|
if (!camera) return;
|
|
const controllersToRebuild = new Set();
|
|
interactableLayerControllers.forEach((controller) => {
|
|
controller.beginClusterUpdate?.();
|
|
});
|
|
const entries = collectScreenAvoidanceEntries(camera);
|
|
|
|
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)
|
|
.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;
|
|
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 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 [];
|
|
const cameraScale = getCameraScale(camera);
|
|
const zoom = getCameraZoom(camera);
|
|
const clusterZoomBand = getClusterZoomBand(zoom);
|
|
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);
|
|
|
|
return 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 (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 =
|
|
(clusterProjectedScratch.x * 0.5 + 0.5) * width +
|
|
(0.5 - iconAnchor.x) * sizePx;
|
|
const screenY =
|
|
(-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,
|
|
};
|
|
})
|
|
.filter(Boolean)
|
|
.sort((a, b) => a.stableId.localeCompare(b.stableId));
|
|
}
|
|
|
|
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 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]),
|
|
};
|
|
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,
|
|
};
|
|
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();
|
|
if (!pointsGroup && !clusterGroup) return;
|
|
rebuildPointLayers();
|
|
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;
|
|
}
|
|
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
|
|
) 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);
|
|
});
|
|
clusterPointObjects.forEach((points) => {
|
|
points.visible = visible;
|
|
points.material.opacity = hasFocus ? dimmedOpacity : baseOpacity;
|
|
points.material.size =
|
|
(points.userData?.clusterPointSize || COMPACT_DOT_POINT_SIZE) *
|
|
(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 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,
|
|
};
|
|
}
|