release: bump version to 0.51.0
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
187
frontend/public/earth/js/controls.js
vendored
187
frontend/public/earth/js/controls.js
vendored
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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, '"');
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user