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 ", [ "#include ", "uniform vec2 interactableIconAnchor;", "uniform vec2 interactableViewportSize;", ].join("\n"), ) .replace( "#include ", [ "#include ", "#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, }; }