release: bump version to 0.51.0

This commit is contained in:
rayd1o
2026-05-11 09:49:08 +08:00
parent 455b8360d0
commit 1cb51b1172
52 changed files with 4440 additions and 279 deletions

View File

@@ -77,6 +77,7 @@ function getMarkerTimestamp(marker) {
export function createBGPCruiseAdapter({
camera,
earth,
getMarkers,
connector,
focusView,
@@ -108,7 +109,13 @@ export function createBGPCruiseAdapter({
function getMarkerScreenCoords(marker) {
if (!marker || !camera) return null;
scratchBGPWorldPosition.copy(marker.position);
marker.parent?.localToWorld(scratchBGPWorldPosition);
if (marker.parent) {
marker.parent.localToWorld(scratchBGPWorldPosition);
} else {
const earthObject = typeof earth === "function" ? earth() : earth;
earthObject?.updateMatrixWorld(true);
earthObject?.localToWorld(scratchBGPWorldPosition);
}
return projectWorldToScreen(scratchBGPWorldPosition, camera);
}

View File

@@ -1,8 +1,11 @@
import { COMPUTE_CENTER_CONFIG, PATHS } from "./constants.js";
import * as THREE from "three";
import { COMPUTE_CENTER_CONFIG, CONFIG, PATHS } from "./constants.js";
import {
createInteractableLayer,
SURFACE_AVOIDANCE_PROFILES,
} from "./interactable.js";
import { latLonToVector3 } from "./utils.js";
const COMPUTE_CENTER_RENDER_ORDER = 4.5;
const COMPUTE_CENTER_POINT_SIZE = 36;
@@ -17,9 +20,84 @@ let showComputeCenters = true;
let supercomputerCount = 0;
let gpuClusterCount = 0;
let unresolvedComputeCenters = [];
let previewRingTexture = null;
let previewRingGroup = null;
let previewRingA = null;
let previewRingB = null;
let previewRingPulseOffset = 0;
const COLLECT_LOCATION_API_BASE = "/api/v1/visualization/compute-centers";
function getPreviewRingTexture() {
if (previewRingTexture) return previewRingTexture;
const canvas = document.createElement("canvas");
canvas.width = 128;
canvas.height = 128;
const context = canvas.getContext("2d");
if (!context) {
previewRingTexture = new THREE.Texture(canvas);
return previewRingTexture;
}
context.clearRect(0, 0, 128, 128);
context.strokeStyle = "rgba(255,255,255,0.96)";
context.lineWidth = 5;
context.beginPath();
context.arc(64, 64, 43, 0, Math.PI * 2);
context.stroke();
previewRingTexture = new THREE.CanvasTexture(canvas);
return previewRingTexture;
}
function ensurePreviewRingGroup(earth) {
if (!earth) return null;
if (!previewRingGroup) {
previewRingGroup = new THREE.Group();
previewRingGroup.name = "compute-center-location-preview";
}
if (previewRingGroup.parent !== earth) {
previewRingGroup.parent?.remove?.(previewRingGroup);
earth.add(previewRingGroup);
}
return previewRingGroup;
}
function createPreviewRingSprite({ color = 0x2dd4bf } = {}) {
const sprite = new THREE.Sprite(
new THREE.SpriteMaterial({
map: getPreviewRingTexture(),
color,
transparent: true,
opacity: 0,
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending,
}),
);
sprite.renderOrder = COMPUTE_CENTER_RENDER_ORDER + 0.18;
return sprite;
}
function disposePreviewRingSprite(sprite) {
if (!sprite) return;
sprite.parent?.remove?.(sprite);
sprite.material?.dispose?.();
}
function attachPreviewRings(group, position, color) {
disposePreviewRingSprite(previewRingA);
disposePreviewRingSprite(previewRingB);
previewRingA = createPreviewRingSprite({ color });
previewRingB = createPreviewRingSprite({ color });
previewRingA.position.copy(position);
previewRingB.position.copy(position);
group.add(previewRingA);
group.add(previewRingB);
previewRingPulseOffset = Math.random() * Math.PI * 2;
}
function buildComputeCenterMarkerData(feature) {
const props = feature?.properties || {};
const coordinates = feature?.geometry?.coordinates || [];
@@ -267,9 +345,164 @@ export function clearComputeCenterData(earth) {
supercomputerCount = 0;
gpuClusterCount = 0;
unresolvedComputeCenters = [];
clearComputeCenterLocationPreview();
computeCenterIconLayer.clearData(earth);
}
export function clearComputeCenterLocationPreview() {
disposePreviewRingSprite(previewRingA);
disposePreviewRingSprite(previewRingB);
previewRingA = null;
previewRingB = null;
}
export function showComputeCenterLocationPreview(earth, { latitude, longitude, color = 0x2dd4bf } = {}) {
const lat = Number(latitude);
const lon = Number(longitude);
if (!earth || !Number.isFinite(lat) || !Number.isFinite(lon)) return false;
const group = ensurePreviewRingGroup(earth);
if (!group) return false;
const position = latLonToVector3(
lat,
lon,
CONFIG.earthRadius + COMPUTE_CENTER_CONFIG.altitudeOffset + 0.2,
);
attachPreviewRings(group, position, color);
updateComputeCenterLocationPreview();
return true;
}
function updateComputeCenterLocationPreview() {
if (!previewRingA && !previewRingB) return;
const now = performance.now();
const baseScale = 8.5;
const pulseSpeed = 0.00105;
const applyRing = (ring, phaseOffset, maxScale) => {
if (!ring) return;
const phase = (now * pulseSpeed + previewRingPulseOffset + phaseOffset) % 1;
const progress = Math.max(0, Math.min(1, phase));
const fadeIn = Math.max(0, Math.min(1, (progress - 0.04) / 0.16));
const fadeOut = 1 - progress;
const visibility = fadeIn * fadeOut;
ring.scale.setScalar(baseScale * (1.0 + progress * (maxScale - 1.0)));
ring.material.opacity = 0.5 * visibility;
ring.visible = true;
};
applyRing(previewRingA, 0, 1.85);
applyRing(previewRingB, 0.45, 2.35);
}
function recomputeComputeCenterCounts(markerData) {
let nextSupercomputerCount = 0;
let nextGpuClusterCount = 0;
markerData.forEach((item) => {
if (item.site_type === "supercomputer") {
nextSupercomputerCount += 1;
} else {
nextGpuClusterCount += 1;
}
});
supercomputerCount = nextSupercomputerCount;
gpuClusterCount = nextGpuClusterCount;
}
function buildOptimisticComputeCenterMarkerData({ sourceId, candidate, context, saveResult }) {
const location = saveResult?.location || {};
const existingData = context?.data || {};
const latitude = Number(location.latitude ?? candidate?.latitude);
const longitude = Number(location.longitude ?? candidate?.longitude);
if (!sourceId || !Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
const siteType = normalizeSiteType(
context?.site_type || context?.siteType || existingData.site_type || location.site_type,
);
return {
id: context?.recordId || context?.id || existingData.id || saveResult?.record_id || sourceId,
source_id: sourceId,
name:
context?.name ||
existingData.name ||
location.name ||
candidate?.matched_location_name ||
candidate?.display_name ||
"算力中心",
source: saveResult?.source || context?.source || existingData.source || location.source || "",
site_type: siteType,
country: location.country || candidate?.country || context?.country || existingData.country || "",
city: location.city || candidate?.city || context?.city || existingData.city || "",
region: location.region || candidate?.region || "",
latitude,
longitude,
displayLatitude: latitude,
displayLongitude: longitude,
operator: context?.operator || existingData.operator || location.operator || "",
vendor: context?.vendor || existingData.vendor || "",
capacity_value: context?.capacity_value ?? existingData.capacity_value,
capacity_unit: context?.capacity_unit ?? existingData.capacity_unit,
rank: context?.rank ?? existingData.rank,
gpu_count: context?.gpu_count ?? existingData.gpu_count,
gpu_type: context?.gpu_type ?? existingData.gpu_type,
cores: context?.cores ?? existingData.cores,
power: context?.power ?? existingData.power,
updated_at: new Date().toISOString(),
status: "observed",
location_precision: location.precision || candidate?.precision || "city",
location_confidence: location.confidence ?? candidate?.confidence ?? null,
location_source: location.location_source || candidate?.source || "manual_selection",
location_source_note: location.location_source_note || candidate?.source_note || "",
location_verified_at: location.verified_at || new Date().toISOString(),
matched_location_name:
location.matched_location_name ||
candidate?.matched_location_name ||
candidate?.display_name ||
"",
needs_confirmation: location.needs_confirmation === true,
is_estimated: false,
estimated_reason: location.estimated_reason || "",
data_type: "compute_center",
metadata: context?.metadata || existingData.metadata || {},
optimistic_spawn: true,
};
}
export async function spawnSavedComputeCenterLocation(earth, { sourceId, candidate, context, saveResult } = {}) {
clearComputeCenterLocationPreview();
const spawned = buildOptimisticComputeCenterMarkerData({
sourceId,
candidate,
context,
saveResult,
});
if (!spawned) return null;
const currentItems = getComputeCenterMarkers()
.map((marker) => ({ ...(marker.userData || {}) }))
.filter((item) => item.source_id !== sourceId);
currentItems.push(spawned);
const nextItems = spreadComputeCenterPositions(currentItems);
await computeCenterIconLayer.preloadAssets(nextItems);
computeCenterIconLayer.setData(nextItems);
computeCenterIconLayer.attach(earth);
computeCenterIconLayer.setVisible(showComputeCenters);
recomputeComputeCenterCounts(nextItems);
unresolvedComputeCenters = unresolvedComputeCenters.filter((item) => {
const itemSourceId = item?.source_id || item?.id;
return itemSourceId !== sourceId;
});
return {
marker: getComputeCenterMarkers().find((marker) => marker.userData?.source_id === sourceId) || null,
totalCount: getComputeCenterCount(),
supercomputerCount,
gpuClusterCount,
unresolvedCount: unresolvedComputeCenters.length,
unresolved: unresolvedComputeCenters.slice(),
summary: getComputeCenterStatusSummary(),
optimistic: true,
};
}
export function getUnresolvedComputeCenters() {
return unresolvedComputeCenters.slice();
}
@@ -362,7 +595,11 @@ export function getShowComputeCenters() {
}
export async function loadComputeCenters(_scene, earth) {
const response = await fetch(PATHS.computeCentersApi);
const separator = PATHS.computeCentersApi.includes("?") ? "&" : "?";
const response = await fetch(
`${PATHS.computeCentersApi}${separator}_=${Date.now()}`,
{ cache: "no-store" },
);
if (!response.ok) {
throw new Error(`Compute centers HTTP ${response.status}`);
}
@@ -411,5 +648,6 @@ export function getComputeCenterPointerIntersections(options) {
}
export function updateComputeCenterVisualState(lockedObjectType, lockedObject, camera) {
updateComputeCenterLocationPreview();
computeCenterIconLayer.updateVisualState(lockedObjectType, lockedObject, camera);
}

View File

@@ -50,8 +50,14 @@ import {
getShowTrails,
getSatelliteCount,
getSatelliteDisplayStyle,
getSatelliteIdleBreathingEnabled,
setSatelliteIdleBreathingEnabled as applySatelliteIdleBreathingEnabled,
setSatelliteDisplayStyle as applySatelliteDisplayStyle,
} from "./satellites.js";
import {
getInteractableCompactDotsEnabled,
setInteractableCompactDotsEnabled as applyInteractableCompactDotsEnabled,
} from "./interactable.js";
import { getShowCables } from "./cables.js";
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
import { getShowCountryBoundaries, toggleCountryBoundaries } from "./country-boundaries.js";
@@ -135,7 +141,7 @@ const SETTINGS_SHEET_MAX_SCALE_X = 0.22;
const SETTINGS_SHEET_MAX_SCALE_Y = 0.18;
const EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v2";
const LEGACY_EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v1";
const EARTH_SETTINGS_VERSION = 9;
const EARTH_SETTINGS_VERSION = 10;
const GRID_LINES_DEFAULT_VERSION = 3;
const SATELLITE_DISPLAY_DEFAULT_VERSION = 4;
const MEDIA_PANEL_DEFAULT_VERSION = 5;
@@ -143,8 +149,11 @@ const MOTION_DEBUG_DEFAULT_VERSION = 6;
const MOTION_PROVIDER_DEFAULT_VERSION = 7;
const MOTION_DEBUG_SKELETON_ONLY_DEFAULT_VERSION = 8;
const MEDIA_PANEL_ACTIVE_TAB_DEFAULT_VERSION = 9;
const VISUAL_PREFERENCES_DEFAULT_VERSION = 10;
const DEFAULT_EARTH_ZOOM_STEP = 0.01;
const ZOOM_STATUS_UPDATE_INTERVAL_MS = 90;
const TARGET_SWITCH_ZOOM_IN_PHASE = 0.28;
const TARGET_SWITCH_ROTATE_PHASE = 0.5;
let settingsModalTimer = null;
let settingsSheetAnimation = null;
let terrainToggleToken = 0;
@@ -783,6 +792,8 @@ function getCurrentSharedSettingsSnapshot() {
motionProvider,
motionDebugSkeletonOnly,
mediaPanelActiveTab: normalizeMediaPanelActiveTab(getActiveTVTab()),
satelliteIdleBreathingEnabled: getSatelliteIdleBreathingEnabled(),
interactableCompactDotsEnabled: getInteractableCompactDotsEnabled(),
};
}
@@ -833,6 +844,10 @@ function cloneEarthSettings(settings) {
),
motionDebugSkeletonOnly: Boolean(settings.shared.motionDebugSkeletonOnly),
mediaPanelActiveTab: normalizeMediaPanelActiveTab(settings.shared.mediaPanelActiveTab),
satelliteIdleBreathingEnabled:
settings.shared.satelliteIdleBreathingEnabled !== false,
interactableCompactDotsEnabled:
settings.shared.interactableCompactDotsEnabled !== false,
layerVisibility: { ...(settings.shared.layerVisibility || {}) },
},
views: {
@@ -953,6 +968,16 @@ function normalizeEarthSettings(rawSettings, defaults) {
(rawSettings?.version || 0) >= MEDIA_PANEL_ACTIVE_TAB_DEFAULT_VERSION
? normalizeMediaPanelActiveTab(sharedSettings?.mediaPanelActiveTab)
: normalizeMediaPanelActiveTab(defaults.shared.mediaPanelActiveTab);
const nextSatelliteIdleBreathingEnabled =
(rawSettings?.version || 0) >= VISUAL_PREFERENCES_DEFAULT_VERSION &&
typeof sharedSettings?.satelliteIdleBreathingEnabled === "boolean"
? sharedSettings.satelliteIdleBreathingEnabled
: defaults.shared.satelliteIdleBreathingEnabled;
const nextInteractableCompactDotsEnabled =
(rawSettings?.version || 0) >= VISUAL_PREFERENCES_DEFAULT_VERSION &&
typeof sharedSettings?.interactableCompactDotsEnabled === "boolean"
? sharedSettings.interactableCompactDotsEnabled
: defaults.shared.interactableCompactDotsEnabled;
return {
version: EARTH_SETTINGS_VERSION,
@@ -972,6 +997,8 @@ function normalizeEarthSettings(rawSettings, defaults) {
motionProvider: nextMotionProvider,
motionDebugSkeletonOnly: nextMotionDebugSkeletonOnly,
mediaPanelActiveTab: nextMediaPanelActiveTab,
satelliteIdleBreathingEnabled: nextSatelliteIdleBreathingEnabled,
interactableCompactDotsEnabled: nextInteractableCompactDotsEnabled,
},
views: {
desktop: {
@@ -985,9 +1012,20 @@ function normalizeEarthSettings(rawSettings, defaults) {
}
function syncMotionDebugToggle(nextEnabled = motionDebugEnabled) {
const interactable = rotationMode === ROTATION_MODE.MOTION;
document.querySelectorAll("[data-motion-debug-toggle]").forEach((input) => {
if (input instanceof HTMLInputElement) {
input.checked = Boolean(nextEnabled);
input.disabled = !interactable;
const label = input.closest("label");
label?.classList.toggle("is-disabled", !interactable);
if (label instanceof HTMLElement) {
if (interactable) {
label.removeAttribute("title");
} else {
label.title = "切换到动捕模式后可开启调试面板";
}
}
}
});
}
@@ -1010,10 +1048,13 @@ function syncMotionDebugSkeletonOnlyToggle(nextEnabled = motionDebugSkeletonOnly
}
function dispatchMotionSettingsChange() {
const effectiveDebugEnabled =
rotationMode === ROTATION_MODE.MOTION && autoRotate && motionDebugEnabled;
window.dispatchEvent(
new CustomEvent("earth:motion-debug-mode-change", {
detail: {
enabled: motionDebugEnabled,
enabled: effectiveDebugEnabled,
preferredEnabled: motionDebugEnabled,
provider: motionProvider,
skeletonOnly: motionDebugSkeletonOnly,
},
@@ -1137,6 +1178,24 @@ function syncSatelliteDisplayStyleControls() {
});
}
function syncSatelliteIdleBreathingToggle() {
const enabled = getSatelliteIdleBreathingEnabled();
document.querySelectorAll("[data-satellite-idle-breathing-toggle]").forEach((input) => {
if (input instanceof HTMLInputElement) {
input.checked = enabled;
}
});
}
function syncInteractableCompactDotsToggle() {
const enabled = getInteractableCompactDotsEnabled();
document.querySelectorAll("[data-interactable-compact-dots-toggle]").forEach((input) => {
if (input instanceof HTMLInputElement) {
input.checked = enabled;
}
});
}
export function getCruiseModules() {
const configuredModules = earthSettingsState?.shared?.cruiseModules;
return normalizeCruiseModules(configuredModules);
@@ -1209,6 +1268,42 @@ export function setSatelliteDisplayStyle(
return normalizedStyle;
}
export function setSatelliteIdleBreathingEnabled(
nextEnabled,
{ persist = true, suppressStatus = false } = {},
) {
const enabled = applySatelliteIdleBreathingEnabled(nextEnabled);
ensureMutableEarthSettingsState();
earthSettingsState.shared.satelliteIdleBreathingEnabled = enabled;
syncSatelliteIdleBreathingToggle();
if (persist) {
persistEarthSettings();
}
if (!suppressStatus) {
showStatusMessage(enabled ? "卫星呼吸闪烁已开启" : "卫星呼吸闪烁已关闭", "info");
}
return enabled;
}
export function setInteractableCompactDotsEnabled(
nextEnabled,
{ persist = true, suppressStatus = false } = {},
) {
const enabled = applyInteractableCompactDotsEnabled(nextEnabled);
ensureMutableEarthSettingsState();
earthSettingsState.shared.interactableCompactDotsEnabled = enabled;
syncInteractableCompactDotsToggle();
if (persist) {
persistEarthSettings();
}
if (!suppressStatus) {
showStatusMessage(enabled ? "低缩放彩色圆点已开启" : "低缩放彩色圆点已关闭", "info");
}
return enabled;
}
function syncDefaultEarthZoomUi(nextZoom) {
const sliders = document.querySelectorAll("#default-earth-size-slider, [data-default-earth-size-slider]");
const values = document.querySelectorAll("#default-earth-size-value, [data-default-earth-size-value]");
@@ -1276,6 +1371,14 @@ async function applyEarthSettings(settings, { applyLayers = true } = {}) {
persist: false,
suppressStatus: true,
});
setSatelliteIdleBreathingEnabled(settings.shared.satelliteIdleBreathingEnabled, {
persist: false,
suppressStatus: true,
});
setInteractableCompactDotsEnabled(settings.shared.interactableCompactDotsEnabled, {
persist: false,
suppressStatus: true,
});
if (typeof settings.shared.dayNightEnabled === "boolean") {
applyDayNightEnabled(settings.shared.dayNightEnabled, { persist: false });
@@ -1329,8 +1432,9 @@ export function setMotionDebugEnabled(
nextEnabled,
{ persist = true, suppressStatus = false } = {},
) {
const requested = Boolean(nextEnabled);
const normalized = requested && rotationMode === ROTATION_MODE.MOTION;
const normalized = Boolean(nextEnabled);
const previousEffective =
rotationMode === ROTATION_MODE.MOTION && autoRotate && motionDebugEnabled;
const changed = motionDebugEnabled !== normalized;
motionDebugEnabled = normalized;
syncMotionDebugToggle(motionDebugEnabled);
@@ -1338,19 +1442,21 @@ export function setMotionDebugEnabled(
ensureMutableEarthSettingsState();
earthSettingsState.shared.motionDebugEnabled = motionDebugEnabled;
if (changed) {
const nextEffective =
rotationMode === ROTATION_MODE.MOTION && autoRotate && motionDebugEnabled;
if (changed || previousEffective !== nextEffective) {
dispatchMotionSettingsChange();
}
if (persist) {
persistEarthSettings();
}
if (!suppressStatus && changed) {
showStatusMessage(
motionDebugEnabled ? "动捕调试模式已开启" : "动捕调试模式已关闭",
"info",
);
} else if (!suppressStatus && requested && rotationMode !== ROTATION_MODE.MOTION) {
showStatusMessage("请先切换到动捕模式再打开调试面板", "info");
const message = motionDebugEnabled
? rotationMode === ROTATION_MODE.MOTION
? "动捕调试模式已开启"
: "动捕调试模式将在下次进入动捕时开启"
: "动捕调试模式已关闭";
showStatusMessage(message, "info");
}
return motionDebugEnabled;
}
@@ -2585,6 +2691,20 @@ function setupSettingsControls() {
});
});
document.querySelectorAll("[data-satellite-idle-breathing-toggle]").forEach((toggle) => {
if (!(toggle instanceof HTMLInputElement)) return;
bindListener(toggle, "change", () => {
setSatelliteIdleBreathingEnabled(toggle.checked);
});
});
document.querySelectorAll("[data-interactable-compact-dots-toggle]").forEach((toggle) => {
if (!(toggle instanceof HTMLInputElement)) return;
bindListener(toggle, "change", () => {
setInteractableCompactDotsEnabled(toggle.checked);
});
});
document.querySelectorAll("#toggle-daynight, [data-daynight-toggle]").forEach((dayNightToggle) => {
if (!(dayNightToggle instanceof HTMLInputElement)) return;
bindListener(dayNightToggle, "change", () => {
@@ -3954,6 +4074,7 @@ function updateRotateUI() {
}
syncRotationModeButtons();
syncMotionDebugToggle(motionDebugEnabled);
}
export function setAutoRotate(value) {
@@ -3990,9 +4111,6 @@ export function setRotationMode(nextMode, { persist = true, suppressStatus = fal
autoRotate = true;
}
rotationMode = normalizedMode;
if (normalizedMode !== ROTATION_MODE.MOTION && motionDebugEnabled) {
setMotionDebugEnabled(false, { persist, suppressStatus: true });
}
updateRotateUI();
dispatchRotationModeChange();
if (persist) {
@@ -4013,6 +4131,7 @@ export function focusEarthView(camera, options = {}) {
zoom = getDefaultEarthZoomLevel(),
duration = 800,
suppressStatus = true,
zoomTransitionMode = "direct",
} = options;
return new Promise((resolve) => {
@@ -4020,6 +4139,13 @@ export function focusEarthView(camera, options = {}) {
const startRotX = earthObj.rotation.x;
const startRotY = earthObj.rotation.y;
const startZoom = zoomLevel;
const defaultZoom = getDefaultEarthZoomLevel();
const shouldRestoreZoomViaDefault =
zoomTransitionMode === "restore-current-via-default" &&
Math.abs(startZoom - defaultZoom) > 0.005;
const rotateStartProgress = TARGET_SWITCH_ZOOM_IN_PHASE;
const rotateEndProgress =
TARGET_SWITCH_ZOOM_IN_PHASE + TARGET_SWITCH_ROTATE_PHASE;
animateValue(
0,
@@ -4027,14 +4153,39 @@ export function focusEarthView(camera, options = {}) {
duration,
(progress) => {
const ease = 1 - Math.pow(1 - progress, 3);
earthObj.rotation.x = startRotX + (nextRotation.x - startRotX) * ease;
earthObj.rotation.y = startRotY + (nextRotation.y - startRotY) * ease;
zoomLevel = startZoom + (zoom - startZoom) * ease;
if (shouldRestoreZoomViaDefault) {
const rotateProgress = THREE.MathUtils.clamp(
(progress - rotateStartProgress) / TARGET_SWITCH_ROTATE_PHASE,
0,
1,
);
const rotateEase = 1 - Math.pow(1 - rotateProgress, 3);
earthObj.rotation.x = startRotX + (nextRotation.x - startRotX) * rotateEase;
earthObj.rotation.y = startRotY + (nextRotation.y - startRotY) * rotateEase;
if (progress < rotateStartProgress) {
const zoomProgress = progress / rotateStartProgress;
const zoomEase = 1 - Math.pow(1 - zoomProgress, 3);
zoomLevel = startZoom + (defaultZoom - startZoom) * zoomEase;
} else if (progress <= rotateEndProgress) {
zoomLevel = defaultZoom;
} else {
const zoomProgress = (progress - rotateEndProgress) / (1 - rotateEndProgress);
const zoomEase = 1 - Math.pow(1 - zoomProgress, 3);
zoomLevel = defaultZoom + (startZoom - defaultZoom) * zoomEase;
}
} else {
earthObj.rotation.x = startRotX + (nextRotation.x - startRotX) * ease;
earthObj.rotation.y = startRotY + (nextRotation.y - startRotY) * ease;
zoomLevel = startZoom + (zoom - startZoom) * ease;
}
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
},
() => {
zoomLevel = zoom;
zoomLevel = shouldRestoreZoomViaDefault ? startZoom : zoom;
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
if (!suppressStatus) {
showStatusMessage("视角已重置", "info");
}

View File

@@ -9,6 +9,11 @@ let pendingMobileDetailState = null;
let mobileDetailsListenerBound = false;
let renderedMobileDetailKey = null;
const locationCollectStateCache = new Map();
const locationCollectContextCache = new Map();
// Latest candidate list per cache-key. Populated whenever state.candidates is
// updated, and read by the click handler via `data-candidate-index` so we
// never have to round-trip a candidate object through an HTML attribute.
const locationCollectCandidatesByKey = new Map();
const IDENTIFIER_FIELD_KEYS = new Set([
'mmsi',
'mmsi_display',
@@ -37,6 +42,12 @@ function setLocationCollectState(contextOrKey, patch = {}) {
? contextOrKey
: getLocationCollectCacheKey(contextOrKey);
if (!key) return null;
if (typeof contextOrKey !== 'string') {
locationCollectContextCache.set(key, contextOrKey);
}
if (Array.isArray(patch.candidates)) {
locationCollectCandidatesByKey.set(key, patch.candidates);
}
const previous = locationCollectStateCache.get(key) || {};
const next = {
...previous,
@@ -54,14 +65,28 @@ function clearLocationCollectState(contextOrKey) {
: getLocationCollectCacheKey(contextOrKey);
if (!key) return;
locationCollectStateCache.delete(key);
locationCollectContextCache.delete(key);
locationCollectCandidatesByKey.delete(key);
updateLocationCollectDomFromState(key);
}
function getCandidateForButton(button) {
if (!(button instanceof HTMLElement)) return null;
const root = button.closest('[data-collect-cache-key]');
if (!(root instanceof HTMLElement)) return null;
const key = root.dataset.collectCacheKey || '';
const list = locationCollectCandidatesByKey.get(key) || [];
const index = Number(button.dataset.candidateIndex);
if (!Number.isFinite(index) || index < 0 || index >= list.length) return null;
return list[index];
}
function updateLocationCollectDomFromState(key) {
if (!key) return;
const state = getLocationCollectState(key);
document.querySelectorAll(`[data-collect-cache-key="${escapeCssIdentifier(key)}"]`).forEach((root) => {
hydrateLocationCollectRoot(root, state);
ensureCandidateActionBindings(root, locationCollectContextCache.get(key));
});
}
@@ -376,7 +401,7 @@ function renderCachedCollectCandidates(state) {
if (!candidates.length) return '';
return candidates
.slice(0, 5)
.map((candidate, index) => renderCollectCandidateRow(candidate, index === 0))
.map((candidate, index) => renderCollectCandidateRow(candidate, index === 0, index))
.join('');
}
@@ -390,6 +415,128 @@ function hydrateLocationCollectRoot(root, state) {
if (button instanceof HTMLButtonElement) button.disabled = state?.loading === true;
}
function rememberLocationCollectContext(context) {
const key = getLocationCollectCacheKey(context);
if (!key) return '';
locationCollectContextCache.set(key, context);
return key;
}
function getLocationCollectContextForRoot(root, fallbackContext) {
const key = root?.dataset?.collectCacheKey || getLocationCollectCacheKey(fallbackContext);
if (key && locationCollectContextCache.has(key)) {
return locationCollectContextCache.get(key);
}
if (fallbackContext) {
rememberLocationCollectContext(fallbackContext);
return fallbackContext;
}
const collectButton = root?.querySelector?.('[data-unresolved-collect]');
try {
const parsed = JSON.parse(collectButton?.dataset?.contextJson || '{}');
if (!parsed?.sourceId) return null;
return {
...parsed,
entityType: 'compute_center',
entityId: parsed.sourceId,
isUnresolved: true,
save: async (candidate) => {
const mod = await import('./compute-centers.js');
return mod.saveComputeCenterLocation(parsed.sourceId, candidate, parsed);
},
};
} catch {
return null;
}
}
// Single delegated click handler attached once per cache-key root.
// Lookup model: button -> closest('[data-collect-cache-key]') -> map by key.
// Candidates live in `locationCollectCandidatesByKey`, indexed by
// `data-candidate-index` on the button -- no JSON round-tripped through HTML.
function ensureCandidateActionBindings(rootOrChild, context) {
const root = rootOrChild instanceof Element
? (rootOrChild.closest?.('[data-collect-cache-key]') || rootOrChild)
: null;
if (!(root instanceof HTMLElement)) return;
const key = rememberLocationCollectContext(context) || root.dataset.collectCacheKey || '';
if (key) root.dataset.collectCacheKey = key;
if (root.dataset.candidateActionsBound === 'true') return;
root.dataset.candidateActionsBound = 'true';
root.addEventListener('click', async (event) => {
const target = event.target instanceof Element ? event.target : null;
if (!target) return;
const previewButton = target.closest('[data-preview-candidate]');
const saveButton = target.closest('[data-save-candidate]');
const button = previewButton || saveButton;
if (!(button instanceof HTMLElement) || !root.contains(button)) return;
event.stopPropagation();
const actionContext = getLocationCollectContextForRoot(root, context);
if (!actionContext) return;
const candidate = getCandidateForButton(button);
if (!candidate) return;
if (previewButton) {
const lat = Number(candidate.latitude);
const lon = Number(candidate.longitude);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
window.dispatchEvent(
new CustomEvent('earth:preview-location-candidate', {
detail: {
latitude: lat,
longitude: lon,
entityType: actionContext.entityType,
entityId: actionContext.entityId,
candidate,
},
}),
);
return;
}
if (typeof actionContext.save !== 'function') return;
const statusEl = root.querySelector('[data-collect-status], [data-unresolved-status]');
button.disabled = true;
if (statusEl) statusEl.textContent = '正在保存所选坐标...';
try {
const saveResult = await actionContext.save(candidate);
setLocationCollectState(actionContext, {
loading: false,
statusText: '坐标已保存',
candidates: [],
});
if (statusEl) statusEl.textContent = '坐标已保存';
window.dispatchEvent(
new CustomEvent('earth:compute-center-location-saved', {
detail: {
entityType: actionContext.entityType,
entityId: actionContext.entityId,
candidate,
context: actionContext,
result: saveResult,
},
}),
);
if (actionContext.entityType === 'compute_center' && actionContext.isUnresolved === true) {
const itemRoot = root.closest('[data-unresolved-item]');
if (itemRoot) {
removeResolvedUnresolvedItem(
itemRoot.closest('#info-card-content') || document,
itemRoot,
);
}
}
} catch (error) {
console.error('save compute-center location failed', error);
if (statusEl) statusEl.textContent = `保存失败:${error?.message || error}`;
button.disabled = false;
}
});
}
function formatLocationCollectFailure(result) {
const regularReason = result?.failure_reason || '常规来源没有可用坐标候选';
const llmReason = result?.llm_failure_reason;
@@ -408,14 +555,14 @@ function bindLocationCollectControls(content, context) {
const collectRoot = content.querySelector('[data-collect-entity-id]');
if (!collectRoot) return;
const button = collectRoot.querySelector('[data-collect-action="run"]');
const statusEl = collectRoot.querySelector('[data-collect-status]');
const candidatesEl = collectRoot.querySelector('[data-collect-candidates]');
if (!button) return;
// Bind the delegated preview/save handler once -- works both before and
// after the user has run "采集", because innerHTML replacement of the
// candidates container does not detach handlers higher up the tree.
ensureCandidateActionBindings(collectRoot, context);
const cachedState = getLocationCollectState(context);
if (cachedState) {
hydrateLocationCollectRoot(collectRoot, cachedState);
bindCandidatePreviewButtons(candidatesEl, context);
bindCandidateSaveButtons(candidatesEl, context, statusEl);
}
button.addEventListener('click', async (event) => {
event.stopPropagation();
@@ -443,8 +590,6 @@ function bindLocationCollectControls(content, context) {
candidates,
result,
});
bindCandidatePreviewButtons(candidatesEl, context);
bindCandidateSaveButtons(candidatesEl, context, statusEl);
} catch (error) {
console.error('collect-location failed', error);
setLocationCollectState(context, {
@@ -455,13 +600,11 @@ function bindLocationCollectControls(content, context) {
} finally {
button.disabled = false;
updateLocationCollectDomFromState(getLocationCollectCacheKey(context));
bindCandidatePreviewButtons(candidatesEl, context);
bindCandidateSaveButtons(candidatesEl, context, statusEl);
}
}, { once: false });
}
function renderCollectCandidateRow(candidate, isBest) {
function renderCollectCandidateRow(candidate, isBest, index) {
const precisionLabel = {
precise: '精确',
site: '站点',
@@ -470,24 +613,23 @@ function renderCollectCandidateRow(candidate, isBest) {
const confidence = Number.isFinite(Number(candidate.confidence))
? `${Math.round(Number(candidate.confidence) * 100)}%`
: '-';
const candidateJson = JSON.stringify(candidate).replace(/"/g, '&quot;');
const safeIndex = Number.isFinite(Number(index)) ? Number(index) : 0;
const name = escapeInfoCardHtml(candidate.matched_location_name || candidate.display_name || '候选');
const sourceLabel = escapeInfoCardHtml(candidate.source || '');
return `
<div class="info-card-compute-candidate ${isBest ? 'is-best' : ''}">
<div class="info-card-compute-candidate-line">
<span class="info-card-compute-candidate-name">${candidate.matched_location_name || candidate.display_name || '候选'}</span>
<span class="info-card-compute-candidate-precision">${precisionLabel}</span>
<span class="info-card-compute-candidate-name">${name}</span>
<span class="info-card-compute-candidate-precision">${escapeInfoCardHtml(precisionLabel)}</span>
</div>
<div class="info-card-compute-candidate-line">
<span class="info-card-compute-candidate-source">${candidate.source}</span>
<span class="info-card-compute-candidate-confidence">置信 ${confidence}</span>
<span class="info-card-compute-candidate-source">${sourceLabel}</span>
<span class="info-card-compute-candidate-confidence">置信 ${escapeInfoCardHtml(confidence)}</span>
</div>
<div class="info-card-compute-candidate-line">
<span class="info-card-compute-candidate-coords">${Number(candidate.latitude).toFixed(4)}, ${Number(candidate.longitude).toFixed(4)}</span>
<button type="button" class="info-card-compute-candidate-preview" data-preview-candidate
data-lat="${candidate.latitude}" data-lon="${candidate.longitude}"
data-candidate-json="${candidateJson}">预览</button>
<button type="button" class="info-card-compute-candidate-preview" data-save-candidate
data-candidate-json="${candidateJson}">保存</button>
<button type="button" class="info-card-compute-candidate-preview" data-preview-candidate data-candidate-index="${safeIndex}">预览</button>
<button type="button" class="info-card-compute-candidate-preview" data-save-candidate data-candidate-index="${safeIndex}">保存</button>
</div>
</div>
`;
@@ -503,6 +645,7 @@ function getUnresolvedComputeCenterContext(item) {
sourceId: item?.source_id || item?.id || '',
recordId: item?.id || item?.record_id || '',
name: item?.name || item?.title || '未命名算力中心',
site_type: item?.site_type || metadata.site_type || '',
operator: item?.operator || item?.vendor || metadata.operator || '',
site: item?.site || metadata.site || metadata.organization || '',
city: item?.city || metadata.city || '',
@@ -646,77 +789,14 @@ function getBestLocationCandidate(candidates) {
})[0] || null;
}
function bindCandidatePreviewButtons(container, context) {
container.querySelectorAll('[data-preview-candidate]').forEach((el) => {
el.addEventListener('click', (clickEvt) => {
clickEvt.stopPropagation();
const lat = Number(el.dataset.lat);
const lon = Number(el.dataset.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
window.dispatchEvent(
new CustomEvent('earth:preview-location-candidate', {
detail: {
latitude: lat,
longitude: lon,
entityType: context.entityType,
entityId: context.entityId,
candidate: JSON.parse(el.dataset.candidateJson || '{}'),
},
}),
);
});
});
}
function bindCandidateSaveButtons(container, context, statusEl) {
container.querySelectorAll('[data-save-candidate]').forEach((el) => {
el.addEventListener('click', async (clickEvt) => {
clickEvt.stopPropagation();
if (typeof context.save !== 'function') return;
const candidate = JSON.parse(el.dataset.candidateJson || '{}');
el.disabled = true;
if (statusEl) statusEl.textContent = '正在保存所选坐标...';
try {
await context.save(candidate);
setLocationCollectState(context, {
loading: false,
statusText: '坐标已保存,正在后台刷新图层...',
candidates: [],
});
if (statusEl) statusEl.textContent = '坐标已保存,正在后台刷新图层...';
window.dispatchEvent(
new CustomEvent('earth:compute-center-location-saved', {
detail: {
entityType: context.entityType,
entityId: context.entityId,
candidate,
},
}),
);
if (context.entityType === 'compute_center' && context.isUnresolved === true) {
const itemRoot = container.closest('[data-unresolved-item]');
if (itemRoot) {
removeResolvedUnresolvedItem(document, itemRoot);
}
}
} catch (error) {
console.error('save compute-center location failed', error);
if (statusEl) statusEl.textContent = `保存失败:${error?.message || error}`;
} finally {
el.disabled = false;
}
});
});
}
function bindComputeCenterUnresolvedControls(content) {
content.querySelectorAll('[data-unresolved-item]').forEach((itemRoot) => {
const collectButton = itemRoot.querySelector('[data-unresolved-collect]');
const candidatesEl = itemRoot.querySelector('[data-unresolved-candidates]');
const statusEl = itemRoot.querySelector('[data-unresolved-status]');
const context = JSON.parse(collectButton?.dataset.contextJson || '{}');
if (!context.sourceId || !candidatesEl) return;
const actionContext = {
...context,
entityType: 'compute_center',
entityId: context.sourceId,
isUnresolved: true,
@@ -725,8 +805,7 @@ function bindComputeCenterUnresolvedControls(content) {
return mod.saveComputeCenterLocation(context.sourceId, candidate, context);
},
};
bindCandidatePreviewButtons(candidatesEl, actionContext);
bindCandidateSaveButtons(candidatesEl, actionContext, statusEl);
ensureCandidateActionBindings(itemRoot, actionContext);
});
content.querySelectorAll('[data-unresolved-collect]').forEach((button) => {
@@ -762,13 +841,13 @@ function bindComputeCenterUnresolvedControls(content) {
result,
});
const actionContext = {
...context,
entityType: 'compute_center',
entityId: context.sourceId,
isUnresolved: true,
save: (candidate) => mod.saveComputeCenterLocation(context.sourceId, candidate, context),
};
bindCandidatePreviewButtons(candidatesEl, actionContext);
bindCandidateSaveButtons(candidatesEl, actionContext, statusEl);
ensureCandidateActionBindings(itemRoot, actionContext);
} catch (error) {
console.error('collect unresolved compute-center location failed', error);
setLocationCollectState(context, {
@@ -779,17 +858,6 @@ function bindComputeCenterUnresolvedControls(content) {
} finally {
button.disabled = false;
updateLocationCollectDomFromState(getLocationCollectCacheKey(context));
const actionContext = {
entityType: 'compute_center',
entityId: context.sourceId,
isUnresolved: true,
save: async (candidate) => {
const mod = await import('./compute-centers.js');
return mod.saveComputeCenterLocation(context.sourceId, candidate, context);
},
};
bindCandidatePreviewButtons(candidatesEl, actionContext);
bindCandidateSaveButtons(candidatesEl, actionContext, statusEl);
}
});
});

View File

@@ -11,6 +11,10 @@ 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 —
@@ -78,6 +82,18 @@ function createCanvas(width, 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);
@@ -471,7 +487,39 @@ export function createInteractableLayer(options = {}) {
});
}
function createOverlayTexture(marker, state) {
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);
@@ -503,6 +551,33 @@ export function createInteractableLayer(options = {}) {
});
}
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();
@@ -609,21 +684,23 @@ export function createInteractableLayer(options = {}) {
return overlay;
}
function updateOverlay(overlay, marker, state, nextOpacity, sizeMultiplier = 1) {
function updateOverlay(overlay, marker, state, nextOpacity, sizeMultiplier = 1, compactDotMode = false) {
if (!overlay) return;
if (!marker) {
overlay.visible = false;
return;
}
const texture = createOverlayTexture(marker, state);
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 =
pointSize * getPointSizeMultiplier(marker) * sizeMultiplier;
(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;
@@ -810,8 +887,9 @@ export function createInteractableLayer(options = {}) {
].join(":");
const cameraScale = getCameraScale(camera);
const compactDotMode = shouldUseCompactDots(camera);
const scaleKey = usesDistanceScaling ? cameraScale.toFixed(3) : "fixed";
const nextStateKey = `${stateKey}:${scaleKey}`;
const nextStateKey = `${stateKey}:${scaleKey}:${compactDotMode ? "dots" : "icons"}`;
if (
nextStateKey === lastVisualStateKey &&
@@ -822,12 +900,20 @@ export function createInteractableLayer(options = {}) {
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 =
pointSize *
(compactDotMode ? COMPACT_DOT_POINT_SIZE : pointSize) *
getPointSizeMultiplier(sampleMarker) *
cameraScale *
(hasFocus ? dimmedScale : 1);
@@ -842,6 +928,7 @@ export function createInteractableLayer(options = {}) {
"hover",
hoverOpacity,
hoverScale * cameraScale,
compactDotMode,
);
const lockedPulse =
pulse.enabled && hasFocus
@@ -853,6 +940,7 @@ export function createInteractableLayer(options = {}) {
"locked",
lockedOpacity,
lockedScale * lockedPulse * cameraScale,
compactDotMode,
);
}

View File

@@ -113,6 +113,7 @@ import {
getRelatedSatelliteIndicesForRegions,
updateRelatedSatelliteHighlights,
updateBreathingPhase,
updateSatelliteIdleBreathingVisual,
updateSatellitePointSize,
isSatelliteFrontFacing,
setSatelliteCamera,
@@ -159,6 +160,7 @@ import {
import {
clearComputeCenterData,
clearComputeCenterSelection,
clearComputeCenterLocationPreview,
formatComputeCenterCapacity,
formatComputeCenterLocationPrecision,
formatComputeCenterLocationConfidence,
@@ -174,6 +176,8 @@ import {
getUnresolvedComputeCenters,
loadComputeCenters,
setComputeCenterMarkerState,
showComputeCenterLocationPreview,
spawnSavedComputeCenterLocation,
toggleComputeCenters,
updateComputeCenterVisualState,
} from "./compute-centers.js";
@@ -205,6 +209,7 @@ import {
applyImmediateView,
focusEarthView,
getZoomLevel,
getDefaultEarthZoomLevel,
setZoomLevel,
showZoomStatusCapsule,
teardownControls,
@@ -369,6 +374,7 @@ const INTERACTABLE_CRUISE_CARD_ESTIMATED_WIDTH_PX = 300;
const INTERACTABLE_CRUISE_CARD_ESTIMATED_HEIGHT_PX = 420;
const INTERACTABLE_CRUISE_CARD_SCREEN_MARGIN_PX = 12;
const INTERACTABLE_CRUISE_PRESENTATION_HIDE_MS = 220;
const TARGET_SWITCH_DURATION_SCALE = 1.12;
const MOTION_ROTATION_DELTA = 0.095;
const MOTION_INERTIA_FACTOR = 0.65;
const GLOBE_DRAGGING_CLASS = "is-globe-dragging";
@@ -385,6 +391,8 @@ const HUD_INTERACTIVE_SELECTORS = [
"#earth-stats *",
"#media-panel",
"#media-panel *",
"#motion-debug-panel",
"#motion-debug-panel *",
"#mobile-drawer-shell",
"#mobile-drawer-shell *",
];
@@ -442,6 +450,31 @@ function getDragRotationFactor() {
return CONFIG.dragRotationFactorBase * scale;
}
function getTargetSwitchZoomOptions(requestedZoom) {
const currentZoom = getZoomLevel();
const defaultZoom = getDefaultEarthZoomLevel();
if (Math.abs(currentZoom - defaultZoom) <= 0.005) {
return { zoom: requestedZoom };
}
return {
zoom: currentZoom,
zoomTransitionMode: "restore-current-via-default",
};
}
function focusTargetSwitchView(options = {}) {
const requestedZoom = options.zoom ?? getDefaultEarthZoomLevel();
const zoomOptions = getTargetSwitchZoomOptions(requestedZoom);
const duration = zoomOptions.zoomTransitionMode === "restore-current-via-default"
? Math.round((options.duration ?? CRUISE_CONFIG.focusDurationMs) * TARGET_SWITCH_DURATION_SCALE)
: options.duration;
return focusEarthView(camera, {
...options,
...zoomOptions,
duration,
});
}
function getTouchDistance(firstPoint, secondPoint) {
return Math.hypot(
secondPoint.clientX - firstPoint.clientX,
@@ -801,7 +834,13 @@ function getMotionAnchorRectFromCenter(center, sizePx) {
function getMarkerMotionScreenPoint(marker) {
if (!marker || !camera) return null;
scratchSatelliteWorldPosition.copy(marker.position);
marker.parent?.localToWorld(scratchSatelliteWorldPosition);
if (marker.parent) {
marker.parent.localToWorld(scratchSatelliteWorldPosition);
} else {
const earth = getEarth();
earth?.updateMatrixWorld(true);
earth?.localToWorld(scratchSatelliteWorldPosition);
}
return getMotionScreenPointFromWorld(scratchSatelliteWorldPosition);
}
@@ -825,6 +864,7 @@ function getMotionCandidateScreenCoords(candidate) {
const satPositions = getSatellitePositions();
const position = satPositions?.[candidate.index]?.current;
if (satPoints?.visible && position) {
satPoints.updateMatrixWorld(true);
scratchSatelliteWorldPosition.copy(position).applyMatrix4(satPoints.matrixWorld);
return getMotionScreenPointFromWorld(scratchSatelliteWorldPosition);
}
@@ -1021,7 +1061,7 @@ function ensureMotionCruiseAdapter() {
motionCruiseAdapter = createMotionCruiseAdapter({
presentationController: ensurePresentationController(),
focusView: (options) => focusEarthView(camera, options),
focusView: focusTargetSwitchView,
getItems: getMotionCruiseItems,
getItemId: (item) => item?.id || null,
resolveLatestItem: resolveLatestMotionCruiseItem,
@@ -1851,12 +1891,20 @@ async function previewLocationCandidate(detail) {
const lat = Number(detail?.latitude);
const lon = Number(detail?.longitude);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
const earth = getEarth();
if (earth && detail?.entityType === "compute_center") {
showComputeCenterLocationPreview(earth, {
latitude: lat,
longitude: lon,
});
}
interruptCruisePresentation({ resetLoop: true });
setAutoRotate(false);
await focusSearchTarget({ lat, lon }, Math.max(getZoomLevel(), 1.16));
}
async function refreshComputeCentersAfterLocationSave() {
const earth = getEarth();
if (!scene || !earth) {
console.warn("算力中心坐标已保存,但场景尚未就绪,跳过自动刷新");
return { skipped: true };
@@ -1867,7 +1915,38 @@ async function refreshComputeCentersAfterLocationSave() {
setLegendItems("computeCenters", getComputeCenterLegendItems());
refreshLegend();
updateStatsSummary();
return result;
}
async function spawnComputeCenterAfterLocationSave(detail = {}) {
clearComputeCenterLocationPreview();
const earth = getEarth();
if (!earth) {
console.warn("算力中心坐标已保存,但场景尚未就绪,跳过即时生成");
return null;
}
const sourceId = detail.entityId || detail.sourceId || detail.result?.source_id;
const result = await spawnSavedComputeCenterLocation(earth, {
sourceId,
candidate: detail.candidate,
context: detail.context,
saveResult: detail.result,
});
if (!result) return null;
toggleComputeCenters(getShowComputeCenters());
updateComputeCenterHud(result);
setLegendItems("computeCenters", getComputeCenterLegendItems());
refreshLegend();
updateStatsSummary();
syncComputeCenterUnresolvedCount(result.unresolvedCount);
if (result.marker) {
clearLockedObject();
setComputeCenterMarkerState(result.marker, "locked");
lockedObject = result.marker;
lockedObjectType = "compute_center";
}
showStatusMessage("算力中心坐标已保存", "success");
return result;
}
async function focusSearchVessel(marker) {
@@ -2380,9 +2459,10 @@ function ensureBGPCruiseAdapter() {
cruiseBGPAdapter = createBGPCruiseAdapter({
camera,
earth: () => getEarth(),
getMarkers: () => getBGPAnomalyMarkers(),
connector: ensureCalloutConnector(),
focusView: (options) => focusEarthView(camera, options),
focusView: focusTargetSwitchView,
setMarkerLocked: (marker) => {
setLegendMode("bgp");
setBGPMarkerState(marker, "locked");
@@ -2421,13 +2501,24 @@ function ensureNewsCruiseAdapter() {
camera,
earth: () => getEarth(),
connector: ensureCalloutConnector(),
focusView: (options) => focusEarthView(camera, options),
focusView: focusTargetSwitchView,
});
return cruiseNewsAdapter;
}
function getInteractableCruiseInfoOptions({ reveal = true } = {}) {
const placement = getInteractableCruiseCardPlacement();
return {
x: placement.x,
y: placement.y,
absolute: true,
reveal,
anchorStable: true,
};
}
function getInteractableCruiseCardPlacement() {
const hudScale =
Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--hud-scale")) || 1;
const width = Math.min(INTERACTABLE_CRUISE_CARD_ESTIMATED_WIDTH_PX * hudScale, window.innerWidth - 32);
@@ -2443,9 +2534,8 @@ function getInteractableCruiseInfoOptions({ reveal = true } = {}) {
Math.max(INTERACTABLE_CRUISE_CARD_SCREEN_MARGIN_PX, y),
window.innerHeight - height - INTERACTABLE_CRUISE_CARD_SCREEN_MARGIN_PX,
),
absolute: true,
reveal,
anchorStable: true,
width,
height,
};
}
@@ -2512,7 +2602,7 @@ function getSatelliteCruiseItems() {
async function focusInteractableCruiseItem(item, options = {}) {
const coords = getMotionCandidateFocusCoords(item?.payload);
if (!coords) return;
await focusEarthView(camera, {
await focusTargetSwitchView({
lat: coords.lat,
lon: coords.lon,
rotLon: coords.lon - 270,
@@ -2527,6 +2617,7 @@ async function focusInteractableCruiseItem(item, options = {}) {
async function presentInteractableCruiseItem(item, { context } = {}) {
const candidate = item?.payload;
if (!candidate) return false;
const cardPlacement = getInteractableCruiseCardPlacement();
return ensurePresentationController().present(
{
id: `cruise:${item.id}`,
@@ -2538,8 +2629,9 @@ async function presentInteractableCruiseItem(item, { context } = {}) {
},
connector: {
enabled: true,
animateOnReveal: true,
sourceProvider: () => getMotionCandidateAnchor(candidate),
targetProvider: getVisiblePresentationCardTarget,
targetProvider: () => getVisiblePresentationCardTarget() || cardPlacement,
options: {
routingMode: "adaptive",
sourceGapPx: 0,
@@ -4182,11 +4274,41 @@ function setupEventListeners() {
console.warn("预览候选位置失败:", error);
});
};
const handleComputeCenterLocationSaved = () => {
refreshComputeCentersAfterLocationSave().catch((error) => {
console.warn("刷新算力中心图层失败:", error);
showStatusMessage("坐标已保存,图层自动刷新失败,可手动刷新页面或重新开关图层", "warning");
});
const handleComputeCenterLocationSaved = (event) => {
const detail = event?.detail || {};
spawnComputeCenterAfterLocationSave(detail)
.then((spawnResult) => {
if (!spawnResult) {
// No optimistic marker (e.g. scene not ready yet); rely on refresh
// for the user-visible confirmation.
return refreshComputeCentersAfterLocationSave()
.then(() => {
showStatusMessage("算力中心坐标已保存", "success");
})
.catch((error) => {
// Swallow refresh failure: the save itself succeeded, so we
// must not surface this as a save failure.
console.warn("后台校准算力中心图层失败:", error);
showStatusMessage("坐标已保存,地图稍后同步", "info");
});
}
// Optimistic marker is on screen; reconcile in the background.
refreshComputeCentersAfterLocationSave().catch((error) => {
console.warn("后台校准算力中心图层失败:", error);
});
return null;
})
.catch((error) => {
console.warn("即时生成算力中心交互物件失败,改用后台刷新:", error);
showStatusMessage("坐标已保存,正在同步地图...", "info");
refreshComputeCentersAfterLocationSave()
.then(() => {
showStatusMessage("算力中心坐标已保存", "success");
})
.catch((refreshError) => {
console.warn("后台校准算力中心图层失败:", refreshError);
});
});
};
const handleComputeCenterUnresolvedCountChange = (event) => {
syncComputeCenterUnresolvedCount(event?.detail?.unresolvedCount ?? event?.detail);
@@ -4998,6 +5120,12 @@ function animate() {
updateSatellitePositions(deltaTime);
updateBreathingPhase(deltaTime);
updateSatelliteIdleBreathingVisual(
!isDragging &&
!hasActiveGlobeInertia() &&
activeTouchPoints.size === 0 &&
!pinchGesture,
);
updateSatellitePointSize();
updateRelatedSatelliteHighlights();
updateCelestialLayer(new Date(), camera);

View File

@@ -21,10 +21,31 @@ const MIN_GESTURE_INTENSITY = 0.45;
const ARM_PATTERN_INTENSITY_SCALE = 5;
const WRIST_LAYER_INTENSITY_SCALE = 9;
const HEAD_TILT_INTENSITY_SCALE = 12;
const ZOOM_OPEN_WRIST_SPREAD_FACTOR = 1.42;
const ZOOM_OPEN_WRIST_HEIGHT_TOLERANCE = 0.16;
const ZOOM_CLOSE_WRIST_SPREAD_FACTOR = 1.28;
const ZOOM_SUPPRESS_WRIST_SPREAD_FACTOR = 1.18;
// Trend-based zoom detection. Pose matching is brittle because MediaPipe
// keypoints jitter and the absolute "T-pose" pattern only matches in a
// narrow window. Track frame-to-frame motion instead: if both wrists are
// moving anti-symmetrically along the x axis (one moving outward, the other
// moving outward in the opposite direction), the user's intent is a zoom,
// regardless of where exactly the wrists end up.
const ZOOM_TREND_MIN_WRIST_DELTA = 0.010;
const ZOOM_TREND_HEIGHT_TOLERANCE = 0.18;
const ZOOM_TREND_INTENSITY_SCALE = 14;
const ZOOM_TREND_MIN_INTENSITY = 0.6;
// Left wrist must hang at least this far below the shoulder line for the
// arm to count as "at rest" -- distinguishes a deliberate single right-arm
// rotate from any two-arm or chest-height gesture in flight.
const LEFT_ARM_REST_HANGING_BELOW_SHOULDER = 0.13;
// Sustained pose-hold thresholds for continuous zoom emission while the
// user keeps their arms in a spread / closed pose. Mirror-safe (all checks
// are span-based, not direction-based) so they work regardless of whether
// the camera feed is mirrored.
const ZOOM_HOLD_HEIGHT_TOLERANCE = 0.18;
const ZOOM_HOLD_WRIST_BELOW_SHOULDER_LIMIT = 0.10;
const ZOOM_HOLD_SPREAD_FACTOR = 1.30;
const ZOOM_HOLD_CLOSE_FACTOR = 0.85;
const ZOOM_HOLD_ELBOW_OUT_FACTOR = 0.25;
const CAMERA_CONSTRAINTS = {
video: {
facingMode: "user",
@@ -179,39 +200,6 @@ function getRightArmPattern(rightShoulder, rightElbow, rightWrist) {
return null;
}
function getZoomPattern(leftShoulder, leftElbow, leftWrist, rightShoulder, rightElbow, rightWrist, shoulderWidth) {
const leftUpper = vectorBetween(leftShoulder, leftElbow);
const leftTerminal = vectorBetween(leftElbow, leftWrist);
const rightUpper = vectorBetween(rightShoulder, rightElbow);
const rightTerminal = vectorBetween(rightElbow, rightWrist);
if (!leftUpper || !leftTerminal || !rightUpper || !rightTerminal) return null;
const leftWristOutside = leftWrist.x < leftShoulder.x - ARM_PATTERN_MIN_SIDE_REACH;
const rightWristOutside = rightWrist.x > rightShoulder.x + ARM_PATTERN_MIN_SIDE_REACH;
const leftArmOut =
leftWristOutside &&
leftElbow.x <= leftShoulder.x + ARM_PATTERN_MIN_SIDE_REACH * 0.25;
const rightArmOut =
rightWristOutside &&
rightElbow.x >= rightShoulder.x - ARM_PATTERN_MIN_SIDE_REACH * 0.25;
const leftForearmIn = leftWrist.x > leftElbow.x + ARM_PATTERN_MIN_SIDE_REACH * 0.5;
const rightForearmIn = rightWrist.x < rightElbow.x - ARM_PATTERN_MIN_SIDE_REACH * 0.5;
const wristsApart = Math.abs(rightWrist.x - leftWrist.x);
const wristsCloseToCenter = wristsApart < shoulderWidth * ZOOM_CLOSE_WRIST_SPREAD_FACTOR;
const wristsHeightAligned = Math.abs(rightWrist.y - leftWrist.y) <= ZOOM_OPEN_WRIST_HEIGHT_TOLERANCE;
const elbowsOut =
leftElbow.x < leftShoulder.x - ARM_PATTERN_MIN_SIDE_REACH * 0.5 &&
rightElbow.x > rightShoulder.x + ARM_PATTERN_MIN_SIDE_REACH * 0.5;
if (leftArmOut && rightArmOut && wristsHeightAligned && wristsApart > shoulderWidth * ZOOM_OPEN_WRIST_SPREAD_FACTOR) {
return { gesture: "zoom_in", confidence: 0.82, intensity: 0.82 };
}
if (elbowsOut && leftForearmIn && rightForearmIn && wristsCloseToCenter) {
return { gesture: "zoom_out", confidence: 0.78, intensity: 0.72 };
}
return null;
}
function isZoomCandidatePose(leftShoulder, leftElbow, leftWrist, rightShoulder, rightElbow, rightWrist, shoulderWidth) {
const wristsApart = Math.abs(rightWrist.x - leftWrist.x);
const bothHandsOutside =
@@ -234,6 +222,101 @@ function isZoomCandidatePose(leftShoulder, leftElbow, leftWrist, rightShoulder,
);
}
// The single-arm rotate detector only looks at the right arm and cannot tell
// whether the user is mid-way through a two-arm gesture. Because the right
// wrist crosses its rotate trigger one or two frames before the left wrist
// catches up to the zoom threshold, rotate routinely fires as the user starts
// to spread their arms. Flip the predicate: a deliberate single right-arm
// wave keeps the left wrist clearly hanging at the side, so refuse to emit
// any right-arm rotate unless we can verify the left arm is at rest (wrist
// hanging well below the shoulder AND elbow + wrist sitting near the body).
// Any ambiguous left-arm state -- raised, extending outward, or held at
// chest level -- yields no gesture, letting getZoomHoldPose handle the next
// frame instead.
function isLeftArmAtRest(leftShoulder, leftElbow, leftWrist) {
const hangingBelowShoulder = leftWrist.y >= leftShoulder.y + LEFT_ARM_REST_HANGING_BELOW_SHOULDER;
const wristNearBody = leftWrist.x >= leftShoulder.x - ARM_PATTERN_MIN_SIDE_REACH;
const elbowNearBody = leftElbow.x >= leftShoulder.x - ARM_PATTERN_MIN_SIDE_REACH;
return hangingBelowShoulder && wristNearBody && elbowNearBody;
}
// Detect a two-arm zoom intent purely from frame-to-frame motion.
// Mirror-safe: measures the change in span between the wrists, not the
// per-wrist x direction. Spreading widens the span and triggers zoom_in
// regardless of whether the camera feed is mirrored; closing shrinks the
// span and triggers zoom_out. Both wrists must be actively moving (each
// crosses the noise floor) to rule out single-arm drift.
function getZoomTrend(leftWrist, rightWrist, previousLeftWrist, previousRightWrist) {
if (!previousLeftWrist || !previousRightWrist) return null;
const heightDelta = Math.abs(rightWrist.y - leftWrist.y);
if (heightDelta > ZOOM_TREND_HEIGHT_TOLERANCE) return null;
const leftMoved = Math.abs(leftWrist.x - previousLeftWrist.x);
const rightMoved = Math.abs(rightWrist.x - previousRightWrist.x);
if (leftMoved < ZOOM_TREND_MIN_WRIST_DELTA || rightMoved < ZOOM_TREND_MIN_WRIST_DELTA) return null;
const currentSpread = Math.abs(rightWrist.x - leftWrist.x);
const previousSpread = Math.abs(previousRightWrist.x - previousLeftWrist.x);
const spreadDelta = currentSpread - previousSpread;
const minSpreadDelta = ZOOM_TREND_MIN_WRIST_DELTA * 2;
const combinedSpeed = leftMoved + rightMoved;
const intensity = Math.min(1, Math.max(ZOOM_TREND_MIN_INTENSITY, combinedSpeed * ZOOM_TREND_INTENSITY_SCALE));
if (spreadDelta > minSpreadDelta) {
return { gesture: "zoom_in", confidence: 0.88, intensity };
}
if (spreadDelta < -minSpreadDelta) {
return { gesture: "zoom_out", confidence: 0.86, intensity };
}
return null;
}
// Mirror-safe sustained-pose detector. Decides whether the user is currently
// holding a "spread" or "closed" pose so zoom_in / zoom_out can keep firing
// while no motion is happening (trend would otherwise stop emitting).
// - heightDelta gate rules out one-arm-up-one-arm-down gestures.
// - wristsRaised gate ensures the wrists are at chest level or above
// (excludes "hands hanging at the hips" which would coincidentally have
// a small span).
// - span > shoulderWidth * 1.30 -> zoom_in (mirror-safe via Math.abs).
// - closed pose additionally requires both elbows clearly outside the
// shoulder line (forming a "hug"), distinguishing it from arms relaxed
// at the body's centerline.
function getZoomHoldPose(
leftShoulder,
leftElbow,
leftWrist,
rightShoulder,
rightElbow,
rightWrist,
shoulderWidth,
) {
const heightDelta = Math.abs(rightWrist.y - leftWrist.y);
if (heightDelta > ZOOM_HOLD_HEIGHT_TOLERANCE) return null;
const avgShoulderY = (leftShoulder.y + rightShoulder.y) / 2;
const wristsRaised =
leftWrist.y <= avgShoulderY + ZOOM_HOLD_WRIST_BELOW_SHOULDER_LIMIT &&
rightWrist.y <= avgShoulderY + ZOOM_HOLD_WRIST_BELOW_SHOULDER_LIMIT;
if (!wristsRaised) return null;
const span = Math.abs(rightWrist.x - leftWrist.x);
if (span > shoulderWidth * ZOOM_HOLD_SPREAD_FACTOR) {
return { gesture: "zoom_in", confidence: 0.82, intensity: 0.8 };
}
const leftElbowSpread = Math.abs(leftElbow.x - leftShoulder.x);
const rightElbowSpread = Math.abs(rightElbow.x - rightShoulder.x);
const elbowsOutward =
leftElbowSpread > shoulderWidth * ZOOM_HOLD_ELBOW_OUT_FACTOR &&
rightElbowSpread > shoulderWidth * ZOOM_HOLD_ELBOW_OUT_FACTOR;
if (elbowsOutward && span < shoulderWidth * ZOOM_HOLD_CLOSE_FACTOR) {
return { gesture: "zoom_out", confidence: 0.80, intensity: 0.7 };
}
return null;
}
function applyPoseLatch(observation, state) {
if (!state || !observation) return observation;
if (state.activePatternGesture === observation.gesture) return null;
@@ -252,8 +335,19 @@ function recognizeGesture(joints, previousJoints, options = {}) {
const leftShoulder = getJoint(joints, "left_shoulder");
const rightShoulder = getJoint(joints, "right_shoulder");
const previousLeftWrist = getJoint(previousJoints, "left_wrist");
const previousRightWrist = getJoint(previousJoints, "right_wrist");
if (!leftWrist || !rightWrist || !leftElbow || !rightElbow || !leftShoulder || !rightShoulder) return null;
// Trend-based zoom runs first: anti-symmetric wrist motion expresses
// the user's intent directly and is far more reliable than waiting for
// an absolute pose to match. It also bypasses the layer / focus / rotate
// detectors, which would otherwise intercept mid-spread frames.
const trend = getZoomTrend(leftWrist, rightWrist, previousLeftWrist, previousRightWrist);
if (trend) {
if (state) state.activePatternGesture = trend.gesture;
return trend;
}
const shoulderWidth = Math.max(0.08, Math.abs(rightShoulder.x - leftShoulder.x));
const leftRaised = leftWrist.y < leftShoulder.y - 0.05;
const rightRaised = rightWrist.y < rightShoulder.y - 0.05;
@@ -275,14 +369,49 @@ function recognizeGesture(joints, previousJoints, options = {}) {
return { gesture: "focus_next", confidence: 0.78, intensity: Math.min(1, Math.abs(headTiltY) * HEAD_TILT_INTENSITY_SCALE) };
}
const pattern =
getZoomPattern(leftShoulder, leftElbow, leftWrist, rightShoulder, rightElbow, rightWrist, shoulderWidth) ||
(
isZoomCandidatePose(leftShoulder, leftElbow, leftWrist, rightShoulder, rightElbow, rightWrist, shoulderWidth)
? null
: getRightArmPattern(rightShoulder, rightElbow, rightWrist)
);
if (pattern) return applyPoseLatch(pattern, state);
// Pose-based zoom hold: while the user sustains a spread (zoom_in) or
// closed (zoom_out) pose without further motion, keep emitting the same
// zoom direction every frame. Bypasses the pattern latch so the gesture
// can fire repeatedly; downstream cooldownMs (120ms) rate-limits to
// ~8 emissions/sec, which produces smooth continuous zooming on the globe
// until the user changes their pose. Uses the mirror-safe span detector
// so it works on non-mirrored camera feeds where the absolute left/right
// pose checks would otherwise fail.
const zoomPattern = getZoomHoldPose(
leftShoulder,
leftElbow,
leftWrist,
rightShoulder,
rightElbow,
rightWrist,
shoulderWidth,
);
if (zoomPattern) {
if (state) state.activePatternGesture = zoomPattern.gesture;
return zoomPattern;
}
// Two safeguards must both hold before a single right-arm rotate fires:
// (1) zoom is not currently a likely interpretation of the pose,
// (2) the left arm is verifiably at rest. This kills the right-leads-left
// race that previously emitted a stray rotate at the start of a spread.
const rotateAllowed =
!isZoomCandidatePose(
leftShoulder,
leftElbow,
leftWrist,
rightShoulder,
rightElbow,
rightWrist,
shoulderWidth,
) && isLeftArmAtRest(leftShoulder, leftElbow, leftWrist);
const rotatePattern = rotateAllowed
? getRightArmPattern(rightShoulder, rightElbow, rightWrist)
: null;
// Rotate stays latched: one deliberate wave = one rotation step. Without
// the latch, holding the arm out would spin the globe continuously, which
// is the opposite of what the user wants for navigation.
if (rotatePattern) return applyPoseLatch(rotatePattern, state);
if (state) state.activePatternGesture = null;
return null;
}

View File

@@ -232,6 +232,11 @@ export function createMotionControlAdapter(options = {}) {
activeProvider?.stop?.();
activeProvider = null;
connected = false;
dispatchWindowEvent(MOTION_DEBUG_VIDEO_SOURCE_EVENT, {
provider: selectedProvider,
source: null,
active: false,
});
emitState({ provider: selectedProvider, connected: false });
},
isConnected() {

View File

@@ -506,6 +506,24 @@ describe("browser camera gesture semantics", () => {
expect(recognizeGesture(current, current)?.gesture).toBe("zoom_in");
});
test("open arms within a 30 degree vertical fan trigger zoom in", () => {
const upwardFan = createPoseJoints({
left_elbow: { id: "left_elbow", x: 0.35, y: 0.48, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.29, y: 0.43, confidence: 1 },
right_elbow: { id: "right_elbow", x: 0.65, y: 0.52, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.72, y: 0.57, confidence: 1 },
});
const downwardFan = createPoseJoints({
left_elbow: { id: "left_elbow", x: 0.35, y: 0.52, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.29, y: 0.57, confidence: 1 },
right_elbow: { id: "right_elbow", x: 0.65, y: 0.48, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.72, y: 0.43, confidence: 1 },
});
expect(recognizeGesture(upwardFan, upwardFan)?.gesture).toBe("zoom_in");
expect(recognizeGesture(downwardFan, downwardFan)?.gesture).toBe("zoom_in");
});
test("near zoom-in pose suppresses right-arm rotate while the second hand catches up", () => {
const current = createPoseJoints({
left_elbow: { id: "left_elbow", x: 0.4, y: 0.5, confidence: 1 },
@@ -527,6 +545,188 @@ describe("browser camera gesture semantics", () => {
expect(recognizeGesture(current, current)?.gesture).toBe("zoom_out");
});
// Two-arm spread starts asymmetrically: the right wrist crosses the rotate
// trigger threshold a frame or two before the left wrist catches up. The
// mirror-safe span-based pose detector recognises this frame as a spread
// and emits zoom_in instead of letting the stale single-arm rotate fire.
test("mid-spread emits zoom_in (not a stray right-arm rotate) while the left arm is still extending", () => {
const midSpread = createPoseJoints({
right_elbow: { id: "right_elbow", x: 0.66, y: 0.5, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.72, y: 0.5, confidence: 1 },
left_elbow: { id: "left_elbow", x: 0.36, y: 0.5, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.38, y: 0.55, confidence: 1 },
});
const result = recognizeGesture(midSpread, midSpread);
expect(result?.gesture).toBe("zoom_in");
});
// Earliest-spread case: the left wrist has just started to lift toward
// shoulder height while still sitting at the body line. The right wrist has
// already crossed the rotate threshold. The mirror-safe pose detector
// recognises the wide span and fires zoom_in; the at-rest gate ensures
// rotate cannot fire in this configuration either.
test("early-spread frame fires zoom_in instead of a stray right-arm rotate", () => {
const earlySpread = createPoseJoints({
right_elbow: { id: "right_elbow", x: 0.65, y: 0.5, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.72, y: 0.5, confidence: 1 },
left_elbow: { id: "left_elbow", x: 0.41, y: 0.55, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.42, y: 0.58, confidence: 1 },
});
const result = recognizeGesture(earlySpread, earlySpread);
expect(result?.gesture).toBe("zoom_in");
});
// The suppressor must not over-fire: a deliberate single right-arm wave
// with the left arm at rest still needs to map to a rotate gesture.
test("right-arm wave with the left arm at rest still triggers rotate_left", () => {
const wave = createPoseJoints({
right_elbow: { id: "right_elbow", x: 0.65, y: 0.5, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.74, y: 0.5, confidence: 1 },
});
expect(recognizeGesture(wave, wave)?.gesture).toBe("rotate_left");
});
// Trend-based zoom: anti-symmetric wrist motion is the strongest signal of
// intent. Pose matching alone is brittle because MediaPipe keypoints
// jitter; tracking direction-of-motion catches the gesture as soon as it
// starts.
test("wrists drifting apart trigger zoom_in via trend detection", () => {
const previous = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.42, y: 0.55, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.58, y: 0.55, confidence: 1 },
});
const current = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.38, y: 0.55, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.62, y: 0.55, confidence: 1 },
});
expect(recognizeGesture(current, previous)?.gesture).toBe("zoom_in");
});
test("wrists drifting together trigger zoom_out via trend detection", () => {
const previous = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.30, y: 0.55, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.70, y: 0.55, confidence: 1 },
});
const current = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.34, y: 0.55, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.66, y: 0.55, confidence: 1 },
});
expect(recognizeGesture(current, previous)?.gesture).toBe("zoom_out");
});
// Single-arm motion (right wrist moving while left wrist is stationary)
// must NOT trip the trend zoom — only anti-symmetric motion of both
// wrists qualifies.
test("single-arm motion does not trigger trend-based zoom", () => {
const previous = createPoseJoints({
right_wrist: { id: "right_wrist", x: 0.58, y: 0.55, confidence: 1 },
});
const current = createPoseJoints({
right_wrist: { id: "right_wrist", x: 0.70, y: 0.55, confidence: 1 },
});
const result = recognizeGesture(current, previous);
expect(result?.gesture).not.toBe("zoom_in");
expect(result?.gesture).not.toBe("zoom_out");
});
// If the two wrists are at very different heights (one resting, one
// raised), trend detection must NOT fire — that's a one-arm gesture.
test("trend zoom requires the wrists to be at roughly the same height", () => {
const previous = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.42, y: 0.80, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.58, y: 0.30, confidence: 1 },
});
const current = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.38, y: 0.80, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.62, y: 0.30, confidence: 1 },
});
const result = recognizeGesture(current, previous);
expect(result?.gesture).not.toBe("zoom_in");
expect(result?.gesture).not.toBe("zoom_out");
});
// Zoom is a sustained gesture: while the user holds a spread T-pose the
// recognizer must keep emitting zoom_in every frame so the globe keeps
// zooming. This is unlike rotate, which should emit once per wave.
test("holding a spread pose emits zoom_in on every frame", () => {
const state = {};
const spread = createPoseJoints({
left_elbow: { id: "left_elbow", x: 0.36, y: 0.5, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.34, y: 0.4, confidence: 1 },
right_elbow: { id: "right_elbow", x: 0.64, y: 0.5, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.66, y: 0.4, confidence: 1 },
});
expect(recognizeGesture(spread, spread, { state })?.gesture).toBe("zoom_in");
expect(recognizeGesture(spread, spread, { state })?.gesture).toBe("zoom_in");
expect(recognizeGesture(spread, spread, { state })?.gesture).toBe("zoom_in");
});
// Mirror-safe trend: on a non-mirrored camera feed the subject's anatomical
// left arm appears on the image right (left_shoulder.x > right_shoulder.x).
// Spreading the arms must still fire zoom_in (not zoom_out) because the
// span between the wrists grows regardless of camera orientation.
test("non-mirrored camera: spreading wrists still triggers zoom_in", () => {
const previous = createPoseJoints({
// Swapped layout: anatomical left on image right
left_shoulder: { id: "left_shoulder", x: 0.58, y: 0.5, confidence: 1 },
right_shoulder: { id: "right_shoulder", x: 0.42, y: 0.5, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.58, y: 0.55, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.42, y: 0.55, confidence: 1 },
});
const current = createPoseJoints({
left_shoulder: { id: "left_shoulder", x: 0.58, y: 0.5, confidence: 1 },
right_shoulder: { id: "right_shoulder", x: 0.42, y: 0.5, confidence: 1 },
// Subject's left arm extending right in the image; subject's right
// arm extending left in the image. Span widens either way.
left_wrist: { id: "left_wrist", x: 0.62, y: 0.55, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.38, y: 0.55, confidence: 1 },
});
expect(recognizeGesture(current, previous)?.gesture).toBe("zoom_in");
});
// Mirror-safe trend: on the same non-mirrored layout, hands coming together
// must still fire zoom_out.
test("non-mirrored camera: closing wrists still triggers zoom_out", () => {
const previous = createPoseJoints({
left_shoulder: { id: "left_shoulder", x: 0.58, y: 0.5, confidence: 1 },
right_shoulder: { id: "right_shoulder", x: 0.42, y: 0.5, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.70, y: 0.55, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.30, y: 0.55, confidence: 1 },
});
const current = createPoseJoints({
left_shoulder: { id: "left_shoulder", x: 0.58, y: 0.5, confidence: 1 },
right_shoulder: { id: "right_shoulder", x: 0.42, y: 0.5, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.66, y: 0.55, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.34, y: 0.55, confidence: 1 },
});
expect(recognizeGesture(current, previous)?.gesture).toBe("zoom_out");
});
// Zoom_out must also be sustained — closing the hands and holding
// continues to zoom out.
test("holding a closed pose emits zoom_out on every frame", () => {
const state = {};
const closed = createPoseJoints({
left_elbow: { id: "left_elbow", x: 0.34, y: 0.55, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.46, y: 0.58, confidence: 1 },
right_elbow: { id: "right_elbow", x: 0.66, y: 0.55, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.54, y: 0.58, confidence: 1 },
});
expect(recognizeGesture(closed, closed, { state })?.gesture).toBe("zoom_out");
expect(recognizeGesture(closed, closed, { state })?.gesture).toBe("zoom_out");
});
});
describe("browser camera provider", () => {

View File

@@ -10,8 +10,8 @@ const DEBUG_PANEL_ID = "motion-debug-panel";
const DEBUG_CANVAS_ID = "motion-debug-canvas";
const DEBUG_STATUS_ID = "motion-debug-status";
const DEBUG_MATCH_ID = "motion-debug-match";
const DEBUG_PAUSE_ID = "motion-debug-pause";
const DEBUG_CLOSE_SELECTOR = "[data-motion-debug-close]";
const DEBUG_PAUSE_SELECTOR = "[data-motion-recognition-pause-toggle]";
const MOBILE_MOUNT_ID = "mobile-motion-debug-mount";
const FALLBACK_CANVAS_WIDTH = 320;
const FALLBACK_CANVAS_HEIGHT = 220;
@@ -79,16 +79,21 @@ function bindPanelControls() {
if (controlsBound || !(panel instanceof HTMLElement)) return;
controlsBound = true;
const closeButton = panel.querySelector(DEBUG_CLOSE_SELECTOR);
closeButton?.addEventListener?.("click", (event) => {
panel.addEventListener("click", (event) => {
const target = event.target instanceof Element ? event.target : null;
if (!target?.closest(DEBUG_CLOSE_SELECTOR)) return;
event.preventDefault();
event.stopPropagation();
dispatchWindowEvent(MOTION_DEBUG_CLOSE_EVENT);
});
const pauseInput = document.getElementById(DEBUG_PAUSE_ID);
pauseInput?.addEventListener?.("change", () => {
recognitionPaused = pauseInput.checked === true;
panel.addEventListener("change", (event) => {
const target = event.target;
if (!(target instanceof HTMLInputElement) || !target.matches(DEBUG_PAUSE_SELECTOR)) {
return;
}
event.stopPropagation();
recognitionPaused = target.checked === true;
dispatchWindowEvent(MOTION_RECOGNITION_PAUSE_EVENT, { paused: recognitionPaused });
render();
});
@@ -282,6 +287,11 @@ function render() {
panel.classList.toggle("hud-panel-hidden", !visible);
panel.classList.toggle("is-motion-matched", Boolean(lastFrame?.matchedGesture));
panel.classList.toggle("is-motion-recognition-paused", recognitionPaused);
panel.querySelectorAll(DEBUG_PAUSE_SELECTOR).forEach((input) => {
if (input instanceof HTMLInputElement) {
input.checked = recognitionPaused;
}
});
const providerLabel = getProviderLabel(provider);
const pauseSuffix = recognitionPaused ? " · 匹配已暂停" : "";
setText(statusEl, connected ? `${providerLabel}已连接${pauseSuffix}` : `${providerLabel}未连接${pauseSuffix}`);

View File

@@ -115,6 +115,7 @@ export class PresentationController {
const { request } = this.active;
const connector = this.getConnectorInstance(request);
if (!connector || request.connector?.enabled !== true) return false;
if (!animate && connector.isAnimating?.()) return true;
const path = this.getConnectorPath(request);
if (!path) {
connector.hide?.();
@@ -150,17 +151,18 @@ export class PresentationController {
return false;
}
const animateOnReveal = request.connector?.animateOnReveal === true;
const connectorReady =
request.connector?.enabled === true
request.connector?.enabled === true && !animateOnReveal
? await this.waitForConnector(request, context)
: false;
: request.connector?.enabled === true;
if (!isCurrentContext(context) || this.active?.request !== request) {
this.dismiss("interrupted");
return false;
}
if (connectorReady) {
if (connectorReady && !animateOnReveal) {
const drawMs = Number(request.connector?.drawMs) || DEFAULT_CONNECTOR_DRAW_MS;
const drawCompleted = await waitForContext(context, drawMs);
if (!drawCompleted || this.active?.request !== request) {
@@ -176,7 +178,17 @@ export class PresentationController {
return false;
}
this.update({ animate: !connectorReady });
const finalConnectorAnimated = this.update({
animate: animateOnReveal || !connectorReady,
});
if (animateOnReveal && finalConnectorAnimated) {
const drawMs = Number(request.connector?.drawMs) || DEFAULT_CONNECTOR_DRAW_MS;
const drawCompleted = await waitForContext(context, drawMs);
if (!drawCompleted || this.active?.request !== request) {
this.dismiss("interrupted");
return false;
}
}
this.scheduleLifetime(request);
return true;
}

View File

@@ -30,6 +30,7 @@ function createController(options = {}) {
calls.push("connector:render");
return options.connectorReady ?? true;
},
isAnimating: () => options.connectorAnimating === true,
hide: () => calls.push("connector:hide"),
};
const controller = new PresentationController({
@@ -126,6 +127,17 @@ describe("PresentationController", () => {
expect(calls).toEqual(["connector:render"]);
});
test("update does not interrupt an active connector draw animation", async () => {
const { calls, controller } = createController({ connectorAnimating: true });
await controller.present(createRequest({ calls }));
calls.length = 0;
const updated = controller.update();
expect(updated).toBe(true);
expect(calls).toEqual([]);
});
test("rect source anchors are passed as center point plus sourceRect", async () => {
const { controller, pathCalls } = createController();

View File

@@ -42,6 +42,7 @@ let positionUpdateAccumulator = 0;
let satelliteCapacity = 0;
let satelliteSatrecCache = new Map();
let satelliteDisplayStyle = DEFAULT_SATELLITE_DISPLAY_STYLE;
let satelliteIdleBreathingEnabled = true;
const GROUND_FOOTPRINT_RENDER_ORDER = 3;
@@ -127,6 +128,8 @@ const FALLBACK_TRAIL_ALPHA_END = 0.8;
const DOT_TEXTURE_SIZE = 32;
const POSITION_UPDATE_INTERVAL_MS = 250;
const BACKGROUND_TRAIL_RESET_DELTA_MS = 2000;
const SATELLITE_TWINKLE_SECONDARY_SPEED = 1.73;
const SATELLITE_TWINKLE_SECONDARY_WEIGHT = 0.28;
const DIMMED_SATELLITE_BRIGHTNESS = 0.42;
const DIMMED_SATELLITE_TRAIL_BRIGHTNESS = 0.24;
const DIMMED_SATELLITE_POINT_OPACITY = 0.62;
@@ -325,6 +328,48 @@ export function updateBreathingPhase(deltaTime = 16) {
breathingPhase += SATELLITE_CONFIG.breathingSpeed * (deltaTime / 16);
}
export function getSatelliteIdleBreathingEnabled() {
return satelliteIdleBreathingEnabled;
}
export function setSatelliteIdleBreathingEnabled(enabled) {
satelliteIdleBreathingEnabled = Boolean(enabled);
updateSatelliteIdleBreathingVisual(false);
return satelliteIdleBreathingEnabled;
}
export function updateSatelliteIdleBreathingVisual(isIdle = true) {
if (!satellitePoints || !satelliteBackdropPoints) return;
if (satellitePoints.material.uniforms?.opacity) {
satellitePoints.material.uniforms.opacity.value = 0.9;
} else {
satellitePoints.material.opacity = 0.9;
}
if (satelliteBackdropPoints.material.uniforms?.opacity) {
satelliteBackdropPoints.material.uniforms.opacity.value = 0.42;
} else {
satelliteBackdropPoints.material.opacity = 0.42;
}
const pointAlphaAttr = satellitePoints.geometry.attributes.alpha;
const backdropAlphaAttr = satelliteBackdropPoints.geometry.attributes.alpha;
if (!pointAlphaAttr?.array || !backdropAlphaAttr?.array) return;
const drawCount = satellitePoints.geometry.drawRange?.count ?? satelliteCapacity;
const count = Math.min(drawCount, satelliteCapacity, satellitePositions.length);
for (let i = 0; i < count; i += 1) {
const alpha = shouldHideSatellitePoint(i)
? 0
: getSatelliteTwinkleAlpha(i, isIdle);
pointAlphaAttr.array[i] = alpha;
backdropAlphaAttr.array[i] = alpha;
}
pointAlphaAttr.needsUpdate = true;
backdropAlphaAttr.needsUpdate = true;
}
export function updateSatellitePointSize() {
if (!satellitePoints || !cameraRef) return;
const camDist = cameraRef.position.length();
@@ -627,15 +672,57 @@ function getRequestedSatelliteLimit(limitOverride) {
return SATELLITE_CONFIG.maxCount < 0 ? null : SATELLITE_CONFIG.maxCount;
}
function createSatellitePositionState() {
function hashSatelliteUnit(index, salt = 0) {
const value = Math.sin((index + 1) * 12.9898 + salt * 78.233) * 43758.5453;
return value - Math.floor(value);
}
function createSatelliteTwinkleState(index) {
return {
phase: hashSatelliteUnit(index, 1) * Math.PI * 2,
secondaryPhase: hashSatelliteUnit(index, 2) * Math.PI * 2,
speed: 0.62 + hashSatelliteUnit(index, 3) * 1.45,
floor: hashSatelliteUnit(index, 4) * 0.22,
intensity: 0.48 + hashSatelliteUnit(index, 5) * 0.52,
};
}
function createSatellitePositionState(index = 0) {
return {
current: new THREE.Vector3(),
trail: [],
trailIndex: 0,
trailCount: 0,
twinkle: createSatelliteTwinkleState(index),
};
}
function getSatelliteTwinkleAlpha(index, isIdle = true) {
if (!satelliteIdleBreathingEnabled || !isIdle) return 1;
const twinkle = satellitePositions[index]?.twinkle || createSatelliteTwinkleState(index);
const primaryPulse = getBreathingPulse(
breathingPhase * twinkle.speed + twinkle.phase,
);
const secondaryPulse = getBreathingPulse(
breathingPhase * twinkle.speed * SATELLITE_TWINKLE_SECONDARY_SPEED +
twinkle.secondaryPhase,
);
const mixedPulse = THREE.MathUtils.clamp(
primaryPulse * (1 - SATELLITE_TWINKLE_SECONDARY_WEIGHT) +
secondaryPulse * SATELLITE_TWINKLE_SECONDARY_WEIGHT,
0,
1,
);
const shapedPulse = twinkle.floor + Math.pow(mixedPulse, 1.8) * twinkle.intensity;
return THREE.MathUtils.clamp(
SATELLITE_CONFIG.dotOpacityMin +
shapedPulse * (SATELLITE_CONFIG.dotOpacityMax - SATELLITE_CONFIG.dotOpacityMin),
SATELLITE_CONFIG.dotOpacityMin,
SATELLITE_CONFIG.dotOpacityMax,
);
}
function resetSatelliteTrailState() {
satellitePositions.forEach((position) => {
position.trail = [];
@@ -794,7 +881,7 @@ function ensureSatelliteCapacity(count) {
satellitePositions = Array.from({ length: nextCapacity }, (_, index) => {
const previousState = previousSatellitePositions[index];
if (!previousState) {
return createSatellitePositionState();
return createSatellitePositionState(index);
}
return {
@@ -802,6 +889,7 @@ function ensureSatelliteCapacity(count) {
trail: previousState.trail.slice(),
trailIndex: previousState.trailIndex,
trailCount: previousState.trailCount,
twinkle: previousState.twinkle || createSatelliteTwinkleState(index),
};
});
satelliteCapacity = nextCapacity;