import * as THREE from "three"; import { CONFIG } from "./constants.js"; import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js"; const assetImageCache = new Map(); const assetImageLoadPromises = new Map(); const surfaceAvoidanceBuckets = new Map(); const interactableLayerControllers = new Map(); const DEFAULT_AVOIDANCE_PRECISION = 4; const DEFAULT_AVOIDANCE_RADIUS = 1.1; const DEFAULT_AVOIDANCE_STEP = 0.35; const AVOIDANCE_RING_SLOT_COUNT = 8; const COMPACT_DOT_ZOOM_THRESHOLD = 1.5; const COMPACT_DOT_POINT_SIZE = 12; const COMPACT_DOT_RADIUS_RATIO = 0.26; let compactDotsEnabled = true; // Named avoidance profiles. Layers that should mutex with each other (e.g. fan // out when sharing the same city center) must reference the SAME profile — // markers are bucketed by the resulting key, and only equal keys collide. // // city — ~1.1km grid (precision 2). Use for site/observatory/POI markers // that often share a city-center coordinate from geocoding. // precise — ~11m grid (precision 4). Use for markers with building-level // coordinates (default; preserves prior behavior). // // Layers that need a fully custom cluster identity (e.g. a city ID string) // can pass `getKey: (item, position) => "..."` instead of using a profile. export const SURFACE_AVOIDANCE_PROFILES = Object.freeze({ city: Object.freeze({ precision: 2, radius: 1.4, step: 0.5 }), precise: Object.freeze({ precision: DEFAULT_AVOIDANCE_PRECISION, radius: DEFAULT_AVOIDANCE_RADIUS, step: DEFAULT_AVOIDANCE_STEP, }), }); const TANGENT_EPSILON_SQ = 1e-6; const avoidanceNorthPole = new THREE.Vector3(0, 1, 0); const avoidanceFallbackEast = new THREE.Vector3(1, 0, 0); const avoidanceCenterScratch = new THREE.Vector3(); const avoidanceEastScratch = new THREE.Vector3(); const avoidanceNorthScratch = new THREE.Vector3(); const avoidancePositionScratch = new THREE.Vector3(); function colorToRgbArray(colorValue, fallback = "#ffffff") { const color = new THREE.Color(colorValue || fallback); return [color.r, color.g, color.b]; } function toFiniteNumber(value, fallback) { const numericValue = Number(value); return Number.isFinite(numericValue) ? numericValue : fallback; } function disposeGroupChildren(group) { for (let index = group.children.length - 1; index >= 0; index -= 1) { const child = group.children[index]; child.material?.dispose?.(); child.geometry?.dispose?.(); group.remove(child); } } function normalizePosition(position, radius) { if (position instanceof THREE.Vector3) { return position.clone(); } const lat = Number(position?.latitude ?? position?.lat); const lon = Number(position?.longitude ?? position?.lon); if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; return latLonToVector3(lat, lon, radius); } function createCanvas(width, height) { if (typeof OffscreenCanvas !== "undefined") { return new OffscreenCanvas(width, height); } const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; return canvas; } export function getInteractableCompactDotsEnabled() { return compactDotsEnabled; } export function setInteractableCompactDotsEnabled(enabled) { compactDotsEnabled = Boolean(enabled); interactableLayerControllers.forEach((controller) => { controller.refreshVisuals?.(); }); return compactDotsEnabled; } 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) { 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_avoidance_index = 0; entry.marker.userData.icon_avoidance_count = 1; entry.marker.userData.icon_avoidance_layer_count = 1; entry.marker.userData.icon_avoidance_cross_layer = false; notifyAvoidancePositionChanged(affectedLayerIds); return; } const count = entries.length; entries.forEach((entry, index) => { const basePosition = entry.marker.userData.icon_base_position || entry.marker.position; const altitudeRadius = basePosition.length(); const ringIndex = Math.floor(index / AVOIDANCE_RING_SLOT_COUNT); const radius = Math.max(0, entry.radius) + ringIndex * Math.max(0, entry.step); const angle = -Math.PI / 2 + (Math.PI * 2 * index) / count; avoidanceCenterScratch.copy(basePosition).normalize(); avoidanceEastScratch .copy(avoidanceNorthPole) .cross(avoidanceCenterScratch); if (avoidanceEastScratch.lengthSq() < TANGENT_EPSILON_SQ) { avoidanceEastScratch.copy(avoidanceFallbackEast); } avoidanceEastScratch.normalize(); avoidanceNorthScratch .copy(avoidanceCenterScratch) .cross(avoidanceEastScratch) .normalize(); avoidancePositionScratch .copy(basePosition) .addScaledVector(avoidanceEastScratch, Math.cos(angle) * radius) .addScaledVector(avoidanceNorthScratch, Math.sin(angle) * radius) .normalize() .multiplyScalar(altitudeRadius); entry.marker.position.copy(avoidancePositionScratch); entry.marker.userData.icon_avoidance_index = index; entry.marker.userData.icon_avoidance_count = count; entry.marker.userData.icon_avoidance_layer_count = affectedLayerIds.size; entry.marker.userData.icon_avoidance_cross_layer = crossLayer; }); notifyAvoidancePositionChanged(affectedLayerIds); } function unregisterLayerAvoidance(layerId) { const affectedKeys = new Set(); surfaceAvoidanceBuckets.forEach((entries, key) => { const nextEntries = entries.filter((entry) => entry.layerId !== layerId); if (nextEntries.length !== entries.length) { affectedKeys.add(key); } if (nextEntries.length === 0) { surfaceAvoidanceBuckets.delete(key); } else { surfaceAvoidanceBuckets.set(key, nextEntries); } }); affectedKeys.forEach((key) => recomputeAvoidanceBucket(key)); } function registerLayerAvoidance(layerId, markers, avoidanceConfig) { if (avoidanceConfig.enabled === false) return; const affectedKeys = new Set(); markers.forEach((marker) => { const key = marker.userData?.icon_avoidance_key; if (!key) return; if (!surfaceAvoidanceBuckets.has(key)) { surfaceAvoidanceBuckets.set(key, []); } surfaceAvoidanceBuckets.get(key).push({ layerId, marker, radius: toFiniteNumber( avoidanceConfig.radius, DEFAULT_AVOIDANCE_RADIUS, ), step: toFiniteNumber(avoidanceConfig.step, DEFAULT_AVOIDANCE_STEP), }); affectedKeys.add(key); }); affectedKeys.forEach((key) => recomputeAvoidanceBucket(key)); } function loadAssetImage(source) { if (!source) return Promise.resolve(null); if (assetImageCache.has(source)) { return Promise.resolve(assetImageCache.get(source)); } if (assetImageLoadPromises.has(source)) { return assetImageLoadPromises.get(source); } const loadPromise = new Promise((resolve, reject) => { const image = new Image(); image.onload = () => { assetImageCache.set(source, image); assetImageLoadPromises.delete(source); resolve(image); }; image.onerror = () => { assetImageLoadPromises.delete(source); reject(new Error(`Failed to load interactable icon asset: ${source}`)); }; image.src = source; }); assetImageLoadPromises.set(source, loadPromise); return loadPromise; } export function createInteractableLayer(options = {}) { const { id, objectType = id, renderOrder = 4, altitudeOffset = 0.2, pointSize = 32, sizeMode = "fixed", sizeScale = {}, atlasCellSize = 128, material = {}, colors = {}, opacity = {}, stateScale = {}, pulse = {}, avoidance = {}, icon, getPosition, getKind = (item) => item?.type || "default", getRotationBin = () => 0, getBucketKey = (marker) => String(getRotationBin(marker)), getPointSizeMultiplier = () => 1, getPointOpacity = null, getUserData = (item) => item, dynamicVisuals = false, } = options; if (!id) { throw new Error("createInteractableLayer requires an id"); } if (!icon?.draw && !icon?.source && !icon?.getSource) { throw new Error(`Interactable layer ${id} requires icon.draw, icon.source, or icon.getSource`); } const group = new THREE.Group(); group.name = `interactable-layer:${id}`; group.renderOrder = renderOrder; group.userData = { type: "interactable_layer", id }; const markers = []; const pointObjects = []; const textureCache = new Map(); let pointsGroup = null; let hoverOverlay = null; let lockedOverlay = null; let visible = false; let lastVisualStateKey = ""; let visualStateVersion = 0; const scratchDirection = new THREE.Vector3(); const scratchCameraLocal = new THREE.Vector3(); const scratchWorldPosition = new THREE.Vector3(); const scratchScreenPosition = new THREE.Vector3(); const viewportSize = new THREE.Vector2(1, 1); const baseOpacity = opacity.normal ?? 0.88; const dimmedOpacity = opacity.dimmed ?? 0.26; const hoverOpacity = opacity.hover ?? 0.98; const lockedOpacity = opacity.locked ?? 1; const hoverScale = stateScale.hover ?? 1; const lockedScale = stateScale.locked ?? 1; const dimmedScale = stateScale.dimmed ?? 1; const depthTest = material.depthTest ?? true; const depthWrite = material.depthWrite ?? false; const alphaTest = material.alphaTest ?? 0.01; const usesDistanceScaling = sizeMode !== "fixed"; const iconAnchor = new THREE.Vector2( Number(icon.anchor?.x ?? icon.anchor?.[0] ?? 0.5), Number(icon.anchor?.y ?? icon.anchor?.[1] ?? 0.5), ); const usesIconAnchor = Math.abs(iconAnchor.x - 0.5) > 0.001 || Math.abs(iconAnchor.y - 0.5) > 0.001; const avoidanceConfig = { enabled: true, precision: DEFAULT_AVOIDANCE_PRECISION, radius: DEFAULT_AVOIDANCE_RADIUS, step: DEFAULT_AVOIDANCE_STEP, ...avoidance, }; function invalidateVisualState() { visualStateVersion += 1; lastVisualStateKey = ""; } function refreshViewportSize() { const pixelRatio = window.devicePixelRatio || 1; viewportSize.set( (window.innerWidth || 1) * pixelRatio, (window.innerHeight || 1) * pixelRatio, ); } function applyIconAnchor(material) { if (!usesIconAnchor) return material; material.defines = { ...(material.defines || {}), USE_INTERACTABLE_ICON_ANCHOR: "", }; material.onBeforeCompile = (shader) => { shader.uniforms.interactableIconAnchor = { value: iconAnchor }; shader.uniforms.interactableViewportSize = { value: viewportSize }; shader.vertexShader = shader.vertexShader .replace( "#include ", [ "#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 createOverlayTexture(marker, state, compactDotMode = false) { if (compactDotMode) { return createCompactDotTexture(marker, state); } const kind = marker?.userData?.icon_kind || "default"; const rotationBin = getRotationBin(marker); const color = getMarkerColor(marker); const textureKey = [ "overlay", state, kind, getBucketKey(marker), rotationBin, color, ].join(":"); return drawIconTexture(textureKey, { marker, bucketKey: getBucketKey(marker), rotationBin, glow: true, color, state, }); } function getCameraScale(camera) { if (!usesDistanceScaling || !camera) return 1; return getSurfaceMarkerCameraScale(camera, { altitudeOffset, referenceFov: sizeScale.referenceFov ?? 75, min: sizeScale.min ?? 0.12, max: sizeScale.max ?? 3, }); } function getCameraZoom(camera) { if (!camera?.position?.z) return CONFIG.defaultViewZoom; return CONFIG.defaultCameraZ / camera.position.z; } function shouldUseCompactDots(camera) { return compactDotsEnabled && getCameraZoom(camera) < COMPACT_DOT_ZOOM_THRESHOLD; } function updatePointColors(points, compactDotMode) { const bucketMarkers = points.userData?.markers || []; const colorAttribute = points.geometry?.getAttribute("color"); if (!colorAttribute?.array) return; bucketMarkers.forEach((marker, index) => { const pointColor = compactDotMode || icon.colorable !== false ? getMarkerColor(marker) : "#ffffff"; const [r, g, b] = colorToRgbArray(pointColor); colorAttribute.array[index * 3] = r; colorAttribute.array[index * 3 + 1] = g; colorAttribute.array[index * 3 + 2] = b; }); colorAttribute.needsUpdate = true; } function buildPoints() { refreshViewportSize(); pointsGroup = new THREE.Group(); pointsGroup.visible = visible; pointsGroup.renderOrder = renderOrder; pointsGroup.userData = { type: `${id}_points`, id }; pointObjects.length = 0; const buckets = new Map(); markers.forEach((marker) => { const key = getBucketKey(marker); if (!buckets.has(key)) { buckets.set(key, []); } buckets.get(key).push(marker); }); buckets.forEach((bucketMarkers, bucketKey) => { const count = bucketMarkers.length; const positions = new Float32Array(count * 3); const colorValues = new Float32Array(count * 3); bucketMarkers.forEach((marker, index) => { positions[index * 3] = marker.position.x; positions[index * 3 + 1] = marker.position.y; positions[index * 3 + 2] = marker.position.z; const pointColor = icon.colorable === false ? "#ffffff" : getMarkerColor(marker); const [r, g, b] = colorToRgbArray(pointColor); colorValues[index * 3] = r; colorValues[index * 3 + 1] = g; colorValues[index * 3 + 2] = b; }); const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); geometry.setAttribute("color", new THREE.BufferAttribute(colorValues, 3)); geometry.computeBoundingSphere(); const material = applyIconAnchor( new THREE.PointsMaterial({ map: createPointTexture(bucketKey, bucketMarkers), size: pointSize * getPointSizeMultiplier(bucketMarkers[0]), sizeAttenuation: false, vertexColors: true, transparent: true, opacity: getPointOpacity?.(bucketMarkers[0]) ?? baseOpacity, depthWrite, depthTest, alphaTest, }), ); const points = new THREE.Points(geometry, material); points.renderOrder = renderOrder; points.frustumCulled = false; points.userData = { type: `${id}_points`, id, bucketKey, markers: bucketMarkers, pointSizeMultiplier: getPointSizeMultiplier(bucketMarkers[0]), }; pointObjects.push(points); pointsGroup.add(points); }); group.add(pointsGroup); } function ensureOverlay(kind) { const existing = kind === "locked" ? lockedOverlay : hoverOverlay; if (existing) return existing; refreshViewportSize(); const geometry = new THREE.BufferGeometry(); geometry.setAttribute( "position", new THREE.BufferAttribute(new Float32Array(3), 3), ); const material = applyIconAnchor( new THREE.PointsMaterial({ size: pointSize, sizeAttenuation: false, transparent: true, depthWrite, depthTest, opacity: 1, alphaTest, }), ); const overlay = new THREE.Points(geometry, material); overlay.renderOrder = renderOrder + (kind === "locked" ? 0.2 : 0.1); overlay.frustumCulled = false; overlay.visible = false; overlay.userData = { type: `${id}_${kind}_overlay`, id }; group.add(overlay); if (kind === "locked") { lockedOverlay = overlay; } else { hoverOverlay = overlay; } return overlay; } function updateOverlay(overlay, marker, state, nextOpacity, sizeMultiplier = 1, compactDotMode = false) { if (!overlay) return; if (!marker) { overlay.visible = false; return; } const texture = createOverlayTexture(marker, state, compactDotMode); if (overlay.material.map !== texture) { overlay.material.map = texture; overlay.material.needsUpdate = true; } overlay.material.opacity = nextOpacity; overlay.material.size = (compactDotMode ? COMPACT_DOT_POINT_SIZE : pointSize) * getPointSizeMultiplier(marker) * sizeMultiplier; const positionAttribute = overlay.geometry.getAttribute("position"); positionAttribute.setXYZ(0, marker.position.x, marker.position.y, marker.position.z); positionAttribute.needsUpdate = true; overlay.visible = visible; } function clearRenderObjects() { if (pointsGroup?.parent) { pointsGroup.parent.remove(pointsGroup); } pointObjects.forEach((points) => { points.geometry?.dispose?.(); points.material?.dispose?.(); }); hoverOverlay?.geometry?.dispose?.(); hoverOverlay?.material?.dispose?.(); hoverOverlay?.parent?.remove?.(hoverOverlay); lockedOverlay?.geometry?.dispose?.(); lockedOverlay?.material?.dispose?.(); lockedOverlay?.parent?.remove?.(lockedOverlay); pointsGroup = null; pointObjects.length = 0; hoverOverlay = null; lockedOverlay = null; } function refreshPositions() { pointObjects.forEach((points) => { const bucketMarkers = points.userData?.markers || []; const positionAttribute = points.geometry?.getAttribute("position"); if (!positionAttribute) return; bucketMarkers.forEach((marker, index) => { positionAttribute.setXYZ( index, marker.position.x, marker.position.y, marker.position.z, ); }); positionAttribute.needsUpdate = true; points.geometry.computeBoundingSphere(); }); invalidateVisualState(); } function refreshVisuals() { invalidateVisualState(); if (!pointsGroup) return; clearRenderObjects(); buildPoints(); group.visible = visible; } function setData(items = []) { invalidateVisualState(); unregisterLayerAvoidance(id); markers.length = 0; clearRenderObjects(); disposeGroupChildren(group); const radius = CONFIG.earthRadius + altitudeOffset; items.forEach((item) => { const rawPosition = getPosition(item); const position = normalizePosition(rawPosition, radius); if (!position) return; const kind = getKind(item); const avoidanceKey = getAvoidanceKey( item, rawPosition, position, avoidanceConfig, ); const marker = new THREE.Object3D(); marker.position.copy(position); marker.userData = { ...getUserData(item), type: objectType, icon_layer_id: id, icon_kind: kind, icon_base_position: position.clone(), icon_avoidance_key: avoidanceKey, state: "normal", }; markers.push(marker); }); registerLayerAvoidance(id, markers, avoidanceConfig); buildPoints(); group.visible = visible; } async function preloadAssets(items = []) { if (icon.draw && !icon.source && !icon.getSource && !icon.stateSources) { return; } const sources = new Set(); const states = ["normal", "hover", "locked"]; items.forEach((item) => { const kind = getKind(item); const marker = { userData: { ...getUserData(item), type: objectType, icon_layer_id: id, icon_kind: kind, state: "normal", }, }; states.forEach((state) => { const source = getIconSource({ marker, item, state, color: colors.byKind?.[kind] || colors[kind] || colors.normal || "#ffffff", }); if (source) sources.add(source); }); }); await Promise.all(Array.from(sources).map((source) => loadAssetImage(source))); } function clearData(parent) { invalidateVisualState(); unregisterLayerAvoidance(id); markers.length = 0; clearRenderObjects(); disposeGroupChildren(group); if (parent && group.parent === parent) { parent.remove(group); } } function attach(parent) { if (parent && !group.parent) { parent.add(group); } group.visible = visible; } function setVisible(nextVisible) { visible = Boolean(nextVisible); invalidateVisualState(); group.visible = visible; if (pointsGroup) { pointsGroup.visible = visible; } } function setMarkerState(marker, state = "normal") { if (!marker || marker.userData?.type !== objectType) return; if (marker.userData.state === state) return; marker.userData.state = state; invalidateVisualState(); } function updateVisualState(focusType, focusObject, camera) { refreshViewportSize(); if (!visible || markers.length === 0 || !pointsGroup) { if (lastVisualStateKey !== "hidden") { if (pointsGroup) pointsGroup.visible = false; if (hoverOverlay) hoverOverlay.visible = false; if (lockedOverlay) lockedOverlay.visible = false; lastVisualStateKey = "hidden"; } return; } pointsGroup.visible = true; const hasFocus = focusType === objectType && focusObject; const lockedKey = hasFocus ? focusObject?.userData?.mmsi || focusObject?.uuid || "locked" : "none"; const stateKey = [ "visible", focusType || "none", lockedKey, visualStateVersion, ].join(":"); const cameraScale = getCameraScale(camera); const compactDotMode = shouldUseCompactDots(camera); const scaleKey = usesDistanceScaling ? cameraScale.toFixed(3) : "fixed"; const nextStateKey = `${stateKey}:${scaleKey}:${compactDotMode ? "dots" : "icons"}`; if ( nextStateKey === lastVisualStateKey && !(pulse.enabled && hasFocus) && !dynamicVisuals ) return; lastVisualStateKey = nextStateKey; pointObjects.forEach((points) => { const sampleMarker = points.userData?.markers?.[0]; const nextTexture = compactDotMode ? createCompactDotTexture(sampleMarker) : createPointTexture(points.userData?.bucketKey, points.userData?.markers || []); if (points.material.map !== nextTexture) { points.material.map = nextTexture; points.material.needsUpdate = true; } updatePointColors(points, compactDotMode); points.visible = visible; points.material.opacity = getPointOpacity?.(sampleMarker) ?? (hasFocus ? dimmedOpacity : baseOpacity); points.material.size = (compactDotMode ? COMPACT_DOT_POINT_SIZE : pointSize) * getPointSizeMultiplier(sampleMarker) * cameraScale * (hasFocus ? dimmedScale : 1); }); const hoverMarker = markers.find( (marker) => marker.userData?.state === "hover" && marker !== focusObject, ); updateOverlay( ensureOverlay("hover"), hoverMarker, "hover", hoverOpacity, hoverScale * cameraScale, compactDotMode, ); const lockedPulse = pulse.enabled && hasFocus ? 1 + (pulse.amplitude ?? 0) * Math.sin(Date.now() * (pulse.speed ?? 0) + (focusObject?.userData?.pulseOffset ?? 0)) : 1; updateOverlay( ensureOverlay("locked"), hasFocus ? focusObject : null, "locked", lockedOpacity, lockedScale * lockedPulse * cameraScale, compactDotMode, ); } function getPointerIntersections({ earth, camera, pointer, radiusPx = 20, width = window.innerWidth, height = window.innerHeight, frontFacingDotThreshold = 0, } = {}) { if (!earth || !camera || !pointer) return []; scratchCameraLocal.copy(camera.position); earth.worldToLocal(scratchCameraLocal); scratchCameraLocal.normalize(); const pointerX = ((pointer.x + 1) / 2) * width; const pointerY = ((1 - pointer.y) / 2) * height; const radiusSq = radiusPx * radiusPx; const intersections = []; const cameraScale = getCameraScale(camera); markers.forEach((marker) => { scratchDirection.copy(marker.position).normalize(); if (scratchCameraLocal.dot(scratchDirection) <= frontFacingDotThreshold) { return; } scratchWorldPosition.copy(marker.position); earth.localToWorld(scratchWorldPosition); scratchScreenPosition.copy(scratchWorldPosition).project(camera); if (scratchScreenPosition.z < -1 || scratchScreenPosition.z > 1) { return; } const screenX = (scratchScreenPosition.x * 0.5 + 0.5) * width; const screenY = (-scratchScreenPosition.y * 0.5 + 0.5) * height; const pointSizeMultiplier = getPointSizeMultiplier(marker) * cameraScale; const visualCenterX = screenX + (0.5 - iconAnchor.x) * pointSize * pointSizeMultiplier; const visualCenterY = screenY + (0.5 - iconAnchor.y) * pointSize * pointSizeMultiplier; const deltaX = visualCenterX - pointerX; const deltaY = visualCenterY - pointerY; const distancePxSq = deltaX * deltaX + deltaY * deltaY; if (distancePxSq > radiusSq) return; intersections.push({ object: marker, point: scratchWorldPosition.clone(), distance: camera.position.distanceTo(scratchWorldPosition), distancePxSq, }); }); return intersections.sort((a, b) => a.distancePxSq - b.distancePxSq); } interactableLayerControllers.set(id, { refreshPositions, refreshVisuals }); return { group, markers, getMarkers: () => markers, getCount: () => markers.length, isVisible: () => visible, setData, preloadAssets, clearData, attach, setVisible, setMarkerState, updateVisualState, getPointerIntersections, refreshVisuals, }; }