|
|
|
|
@@ -17,6 +17,12 @@ 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,
|
|
|
|
|
@@ -37,6 +43,12 @@ const CLUSTER_ZOOM_BANDS = Object.freeze([
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
@@ -90,15 +102,33 @@ 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: customConfig.enabled ?? avoidanceConfig.enabled,
|
|
|
|
|
enabled: enabled !== false && strategy !== CLUSTER_STRATEGY_NONE,
|
|
|
|
|
strategy,
|
|
|
|
|
overlapFactor: Number.isFinite(Number(customConfig.overlapFactor))
|
|
|
|
|
? Number(customConfig.overlapFactor)
|
|
|
|
|
: CLUSTER_OVERLAP_FACTOR,
|
|
|
|
|
@@ -107,9 +137,40 @@ function normalizeClusterConfig(cluster, avoidanceConfig) {
|
|
|
|
|
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(
|
|
|
|
|
@@ -125,6 +186,40 @@ function getClusterZoomBand(zoom) {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
@@ -150,6 +245,55 @@ function getClusterOverlapFactorForBand(band, baseFactor = CLUSTER_OVERLAP_FACTO
|
|
|
|
|
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];
|
|
|
|
|
@@ -302,13 +446,23 @@ function getMarkerStaticAvoidancePosition(marker) {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function collectScreenAvoidanceEntries(camera) {
|
|
|
|
|
function collectClusterUpdates(camera) {
|
|
|
|
|
if (!camera) return [];
|
|
|
|
|
const entries = [];
|
|
|
|
|
const updates = [];
|
|
|
|
|
interactableLayerControllers.forEach((controller) => {
|
|
|
|
|
entries.push(...(controller.collectClusterEntries?.(camera) || []));
|
|
|
|
|
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 entries.sort((a, b) => a.stableId.localeCompare(b.stableId));
|
|
|
|
|
return updates;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function findScreenClusterGroups(entries) {
|
|
|
|
|
@@ -419,6 +573,77 @@ function splitLargeScreenClusterGroup(groupEntries) {
|
|
|
|
|
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);
|
|
|
|
|
@@ -455,7 +680,7 @@ function createScreenClusterRecord(groupEntries) {
|
|
|
|
|
});
|
|
|
|
|
center.x /= sortedEntries.length;
|
|
|
|
|
center.y /= sortedEntries.length;
|
|
|
|
|
const clusterId = `cluster:${sortedEntries.map((entry) => entry.stableId).join("+")}`;
|
|
|
|
|
const clusterId = getClusterRecordId(sortedEntries);
|
|
|
|
|
return {
|
|
|
|
|
clusterId,
|
|
|
|
|
markers: sortedEntries.map((entry) => entry.marker),
|
|
|
|
|
@@ -463,9 +688,10 @@ function createScreenClusterRecord(groupEntries) {
|
|
|
|
|
position: clusterPosition,
|
|
|
|
|
pointSize: getClusterPointSize(sortedEntries.length),
|
|
|
|
|
anchorStableId: anchorEntry.stableId,
|
|
|
|
|
clusterZoomBand: anchorEntry.clusterZoomBand,
|
|
|
|
|
clusterZoomBand: anchorEntry.clusterBandKey || anchorEntry.clusterZoomBand,
|
|
|
|
|
screenX: center.x,
|
|
|
|
|
screenY: center.y,
|
|
|
|
|
transitionStartedAt: getNowMs(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -475,7 +701,15 @@ function recomputeScreenAvoidance(camera) {
|
|
|
|
|
interactableLayerControllers.forEach((controller) => {
|
|
|
|
|
controller.beginClusterUpdate?.();
|
|
|
|
|
});
|
|
|
|
|
const entries = collectScreenAvoidanceEntries(camera);
|
|
|
|
|
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);
|
|
|
|
|
@@ -491,6 +725,7 @@ function recomputeScreenAvoidance(camera) {
|
|
|
|
|
.flatMap(splitLargeScreenClusterGroup)
|
|
|
|
|
.map(createScreenClusterRecord)
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.concat(stableRecords)
|
|
|
|
|
.forEach((record) => {
|
|
|
|
|
record.owner.addOwnedCluster?.(record);
|
|
|
|
|
record.markers.forEach((marker) => {
|
|
|
|
|
@@ -589,6 +824,10 @@ export function createInteractableLayer(options = {}) {
|
|
|
|
|
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();
|
|
|
|
|
@@ -626,6 +865,12 @@ export function createInteractableLayer(options = {}) {
|
|
|
|
|
lastVisualStateKey = "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function invalidateClusterTopology() {
|
|
|
|
|
clusterTopologyRevision += 1;
|
|
|
|
|
lastStableClusterKey = "";
|
|
|
|
|
lastStableClusterBandKey = "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function refreshViewportSize() {
|
|
|
|
|
const pixelRatio = window.devicePixelRatio || 1;
|
|
|
|
|
viewportSize.set(
|
|
|
|
|
@@ -910,10 +1155,29 @@ export function createInteractableLayer(options = {}) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function collectClusterEntries(camera) {
|
|
|
|
|
if (!visible || !clusterConfig.enabled || !camera || markers.length === 0) return [];
|
|
|
|
|
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;
|
|
|
|
|
@@ -925,27 +1189,29 @@ export function createInteractableLayer(options = {}) {
|
|
|
|
|
clusterCameraLocalScratch.normalize();
|
|
|
|
|
group.updateMatrixWorld?.(true);
|
|
|
|
|
|
|
|
|
|
return markers
|
|
|
|
|
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 (clusterCameraLocalScratch.dot(clusterDirectionScratch) <= 0) return null;
|
|
|
|
|
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;
|
|
|
|
|
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 +
|
|
|
|
|
(stableSpherical ? 0 : (clusterProjectedScratch.x * 0.5 + 0.5) * width) +
|
|
|
|
|
(0.5 - iconAnchor.x) * sizePx;
|
|
|
|
|
const screenY =
|
|
|
|
|
(-clusterProjectedScratch.y * 0.5 + 0.5) * height +
|
|
|
|
|
(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(
|
|
|
|
|
@@ -976,10 +1242,41 @@ export function createInteractableLayer(options = {}) {
|
|
|
|
|
),
|
|
|
|
|
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) {
|
|
|
|
|
@@ -1010,6 +1307,17 @@ export function createInteractableLayer(options = {}) {
|
|
|
|
|
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);
|
|
|
|
|
@@ -1131,6 +1439,7 @@ export function createInteractableLayer(options = {}) {
|
|
|
|
|
bucketKey,
|
|
|
|
|
markers: bucketMarkers,
|
|
|
|
|
pointSizeMultiplier: getPointSizeMultiplier(bucketMarkers[0]),
|
|
|
|
|
transitionStartedAt: getNowMs(),
|
|
|
|
|
};
|
|
|
|
|
pointObjects.push(points);
|
|
|
|
|
pointsGroup.add(points);
|
|
|
|
|
@@ -1184,6 +1493,7 @@ export function createInteractableLayer(options = {}) {
|
|
|
|
|
clusterId: record.clusterId,
|
|
|
|
|
markers: record.markers,
|
|
|
|
|
clusterPointSize: record.pointSize,
|
|
|
|
|
transitionStartedAt: record.transitionStartedAt || getNowMs(),
|
|
|
|
|
};
|
|
|
|
|
clusterPointObjects.push(points);
|
|
|
|
|
clusterGroup.add(points);
|
|
|
|
|
@@ -1290,6 +1600,7 @@ export function createInteractableLayer(options = {}) {
|
|
|
|
|
function refreshVisuals() {
|
|
|
|
|
invalidateScreenAvoidance();
|
|
|
|
|
invalidateVisualState();
|
|
|
|
|
invalidateClusterTopology();
|
|
|
|
|
if (!pointsGroup && !clusterGroup) return;
|
|
|
|
|
rebuildPointLayers();
|
|
|
|
|
group.visible = visible;
|
|
|
|
|
@@ -1298,6 +1609,7 @@ export function createInteractableLayer(options = {}) {
|
|
|
|
|
function setData(items = []) {
|
|
|
|
|
invalidateScreenAvoidance();
|
|
|
|
|
invalidateVisualState();
|
|
|
|
|
invalidateClusterTopology();
|
|
|
|
|
unregisterLayerAvoidance(id);
|
|
|
|
|
markers.length = 0;
|
|
|
|
|
clearRenderObjects();
|
|
|
|
|
@@ -1407,6 +1719,7 @@ export function createInteractableLayer(options = {}) {
|
|
|
|
|
function clearData(parent) {
|
|
|
|
|
invalidateScreenAvoidance();
|
|
|
|
|
invalidateVisualState();
|
|
|
|
|
invalidateClusterTopology();
|
|
|
|
|
unregisterLayerAvoidance(id);
|
|
|
|
|
markers.length = 0;
|
|
|
|
|
clearRenderObjects();
|
|
|
|
|
@@ -1427,6 +1740,7 @@ export function createInteractableLayer(options = {}) {
|
|
|
|
|
visible = Boolean(nextVisible);
|
|
|
|
|
invalidateScreenAvoidance();
|
|
|
|
|
invalidateVisualState();
|
|
|
|
|
invalidateClusterTopology();
|
|
|
|
|
group.visible = visible;
|
|
|
|
|
if (pointsGroup) {
|
|
|
|
|
pointsGroup.visible = visible;
|
|
|
|
|
@@ -1478,7 +1792,8 @@ export function createInteractableLayer(options = {}) {
|
|
|
|
|
if (
|
|
|
|
|
nextStateKey === lastVisualStateKey &&
|
|
|
|
|
!(pulse.enabled && hasFocus) &&
|
|
|
|
|
!dynamicVisuals
|
|
|
|
|
!dynamicVisuals &&
|
|
|
|
|
!hasActiveRenderTransitions()
|
|
|
|
|
) return;
|
|
|
|
|
lastVisualStateKey = nextStateKey;
|
|
|
|
|
|
|
|
|
|
@@ -1493,21 +1808,35 @@ export function createInteractableLayer(options = {}) {
|
|
|
|
|
}
|
|
|
|
|
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);
|
|
|
|
|
(hasFocus ? dimmedOpacity : baseOpacity) * transitionProgress;
|
|
|
|
|
points.material.size =
|
|
|
|
|
(compactDotMode ? COMPACT_DOT_POINT_SIZE : pointSize) *
|
|
|
|
|
getPointSizeMultiplier(sampleMarker) *
|
|
|
|
|
cameraScale *
|
|
|
|
|
(hasFocus ? dimmedScale : 1);
|
|
|
|
|
(hasFocus ? dimmedScale : 1) *
|
|
|
|
|
transitionScale;
|
|
|
|
|
});
|
|
|
|
|
clusterPointObjects.forEach((points) => {
|
|
|
|
|
points.visible = visible;
|
|
|
|
|
points.material.opacity = hasFocus ? dimmedOpacity : baseOpacity;
|
|
|
|
|
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);
|
|
|
|
|
(hasFocus ? dimmedScale : 1) *
|
|
|
|
|
transitionScale *
|
|
|
|
|
pulseScale;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const hoverMarker = markers.find(
|
|
|
|
|
|