release: bump version to 0.50.0

This commit is contained in:
rayd1o
2026-05-10 22:06:01 +08:00
parent e1984c7a35
commit 455b8360d0
80 changed files with 10936 additions and 298 deletions

View File

@@ -200,6 +200,7 @@ const COMPUTE_CENTER_LOCATION_SOURCE_LABELS = {
source_coordinates: "源数据自带坐标",
ror_organization_registry: "ROR 组织注册 API",
nominatim_online_geocode: "Nominatim 在线搜索",
llm_location_factcheck: "LLM factcheck 兜底",
};
export function formatComputeCenterLocationSource(markerData) {
@@ -369,9 +370,6 @@ export async function loadComputeCenters(_scene, earth) {
const features = Array.isArray(payload?.features) ? payload.features : [];
const unresolved = Array.isArray(payload?.unresolved) ? payload.unresolved : [];
clearComputeCenterData(earth);
unresolvedComputeCenters = unresolved;
const markerData = spreadComputeCenterPositions(
features
.map((feature) => buildComputeCenterMarkerData(feature))
@@ -379,14 +377,21 @@ export async function loadComputeCenters(_scene, earth) {
)
.slice(0, COMPUTE_CENTER_CONFIG.maxRenderedMarkers);
let nextSupercomputerCount = 0;
let nextGpuClusterCount = 0;
markerData.forEach((item) => {
if (item.site_type === "supercomputer") {
supercomputerCount += 1;
nextSupercomputerCount += 1;
} else {
gpuClusterCount += 1;
nextGpuClusterCount += 1;
}
});
await computeCenterIconLayer.preloadAssets(markerData);
clearComputeCenterData(earth);
unresolvedComputeCenters = unresolved;
supercomputerCount = nextSupercomputerCount;
gpuClusterCount = nextGpuClusterCount;
computeCenterIconLayer.setData(markerData);
computeCenterIconLayer.attach(earth);
computeCenterIconLayer.setVisible(showComputeCenters);

View File

@@ -17,11 +17,16 @@ export const CONFIG = {
export const ROTATION_MODE = {
ROTATE: "rotate",
CRUISE: "cruise",
MOTION: "motion",
};
export const CRUISE_MODULES = {
BGP: "bgp",
NEWS: "news",
COMPUTE_CENTERS: "computeCenters",
VESSELS: "vessels",
CABLES: "cables",
SATELLITES: "satellites",
};
export const DEFAULT_CRUISE_MODULES = [CRUISE_MODULES.BGP];

View File

@@ -64,7 +64,13 @@ import {
getShowVessels,
getVesselCount,
} from "./vessels.js";
import { ensureTVPanelReady, isTVPanelVisible, setTVPanelVisible } from "./tv.js";
import {
ensureTVPanelReady,
getActiveTVTab,
isTVPanelVisible,
setActiveTVTab,
setTVPanelVisible,
} from "./tv.js";
import { createHUDPanel } from "./hud-panels.js";
import {
ensureNewsPanelReady,
@@ -82,6 +88,10 @@ import {
setLayerButtonState,
updateLayerButtonState,
} from "./layer-button-state.js";
import {
DEFAULT_MOTION_PROVIDER,
normalizeMotionProvider,
} from "./motion-protocol.js";
export let autoRotate = true;
export let zoomLevel = 1.0;
@@ -90,6 +100,9 @@ export let layoutExpanded = false;
export let rotationMode = ROTATION_MODE.ROTATE;
let dayNightEnabled = true;
let defaultEarthZoom = CONFIG.defaultViewZoom;
let motionDebugEnabled = false;
let motionProvider = DEFAULT_MOTION_PROVIDER;
let motionDebugSkeletonOnly = false;
let activeCamera = null;
let settingsApplyPromise = Promise.resolve();
@@ -122,10 +135,14 @@ 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 = 5;
const EARTH_SETTINGS_VERSION = 9;
const GRID_LINES_DEFAULT_VERSION = 3;
const SATELLITE_DISPLAY_DEFAULT_VERSION = 4;
const MEDIA_PANEL_DEFAULT_VERSION = 5;
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 DEFAULT_EARTH_ZOOM_STEP = 0.01;
const ZOOM_STATUS_UPDATE_INTERVAL_MS = 90;
let settingsModalTimer = null;
@@ -151,6 +168,18 @@ const ALLOWED_CRUISE_MODULES = new Set(Object.values(CRUISE_MODULES));
const ALLOWED_SATELLITE_DISPLAY_STYLES = new Set(
Object.values(SATELLITE_DISPLAY_STYLES),
);
const CRUISE_MODULE_LABELS = {
[CRUISE_MODULES.BGP]: "BGP",
[CRUISE_MODULES.NEWS]: "新闻",
[CRUISE_MODULES.COMPUTE_CENTERS]: "算力中心",
[CRUISE_MODULES.VESSELS]: "船只",
[CRUISE_MODULES.CABLES]: "海缆",
[CRUISE_MODULES.SATELLITES]: "卫星",
};
function normalizeMediaPanelActiveTab(tab) {
return tab === "news" ? "news" : "live";
}
function detectLayoutMode() {
const width = window.innerWidth;
@@ -278,7 +307,7 @@ function closeTransientMobileOverlays({ except = null } = {}) {
&& except !== "settings"
&& isTVPanelVisible()
) {
setTVPanelVisible(false);
setTVPanelVisible(false, { persist: false });
}
}
@@ -750,6 +779,10 @@ function getCurrentSharedSettingsSnapshot() {
terrainOpacity: getTerrainOpacity(),
dayNightEnabled,
defaultEarthZoom,
motionDebugEnabled,
motionProvider,
motionDebugSkeletonOnly,
mediaPanelActiveTab: normalizeMediaPanelActiveTab(getActiveTVTab()),
};
}
@@ -793,6 +826,13 @@ function cloneEarthSettings(settings) {
terrainOpacity: settings.shared.terrainOpacity,
dayNightEnabled: settings.shared.dayNightEnabled,
defaultEarthZoom: settings.shared.defaultEarthZoom,
motionDebugEnabled: settings.shared.motionDebugEnabled,
motionProvider: normalizeMotionProvider(
settings.shared.motionProvider,
DEFAULT_MOTION_PROVIDER,
),
motionDebugSkeletonOnly: Boolean(settings.shared.motionDebugSkeletonOnly),
mediaPanelActiveTab: normalizeMediaPanelActiveTab(settings.shared.mediaPanelActiveTab),
layerVisibility: { ...(settings.shared.layerVisibility || {}) },
},
views: {
@@ -865,8 +905,9 @@ function normalizeEarthSettings(rawSettings, defaults) {
}
const nextRotationMode =
sharedSettings?.rotationMode === ROTATION_MODE.CRUISE
? ROTATION_MODE.CRUISE
sharedSettings?.rotationMode === ROTATION_MODE.CRUISE ||
sharedSettings?.rotationMode === ROTATION_MODE.MOTION
? sharedSettings.rotationMode
: defaults.shared.rotationMode;
const requestedCruiseModules = Array.isArray(sharedSettings?.cruiseModules)
? sharedSettings.cruiseModules
@@ -894,6 +935,24 @@ function normalizeEarthSettings(rawSettings, defaults) {
const nextDefaultEarthZoom = clampEarthZoomLevel(
sharedSettings?.defaultEarthZoom ?? defaults.shared.defaultEarthZoom,
);
const nextMotionDebugEnabled =
(rawSettings?.version || 0) >= MOTION_DEBUG_DEFAULT_VERSION &&
typeof sharedSettings?.motionDebugEnabled === "boolean"
? sharedSettings.motionDebugEnabled
: defaults.shared.motionDebugEnabled;
const nextMotionProvider =
(rawSettings?.version || 0) >= MOTION_PROVIDER_DEFAULT_VERSION
? normalizeMotionProvider(sharedSettings?.motionProvider, defaults.shared.motionProvider)
: defaults.shared.motionProvider;
const nextMotionDebugSkeletonOnly =
(rawSettings?.version || 0) >= MOTION_DEBUG_SKELETON_ONLY_DEFAULT_VERSION &&
typeof sharedSettings?.motionDebugSkeletonOnly === "boolean"
? sharedSettings.motionDebugSkeletonOnly
: defaults.shared.motionDebugSkeletonOnly;
const nextMediaPanelActiveTab =
(rawSettings?.version || 0) >= MEDIA_PANEL_ACTIVE_TAB_DEFAULT_VERSION
? normalizeMediaPanelActiveTab(sharedSettings?.mediaPanelActiveTab)
: normalizeMediaPanelActiveTab(defaults.shared.mediaPanelActiveTab);
return {
version: EARTH_SETTINGS_VERSION,
@@ -909,6 +968,10 @@ function normalizeEarthSettings(rawSettings, defaults) {
: defaults.shared.terrainOpacity,
dayNightEnabled: nextDayNightEnabled,
defaultEarthZoom: nextDefaultEarthZoom,
motionDebugEnabled: nextMotionDebugEnabled,
motionProvider: nextMotionProvider,
motionDebugSkeletonOnly: nextMotionDebugSkeletonOnly,
mediaPanelActiveTab: nextMediaPanelActiveTab,
},
views: {
desktop: {
@@ -921,6 +984,43 @@ function normalizeEarthSettings(rawSettings, defaults) {
};
}
function syncMotionDebugToggle(nextEnabled = motionDebugEnabled) {
document.querySelectorAll("[data-motion-debug-toggle]").forEach((input) => {
if (input instanceof HTMLInputElement) {
input.checked = Boolean(nextEnabled);
}
});
}
function syncMotionProviderControls(nextProvider = motionProvider) {
document.querySelectorAll("[data-motion-provider]").forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return;
const active = normalizeMotionProvider(button.dataset.motionProvider) === nextProvider;
button.classList.toggle("is-active", active);
button.setAttribute("aria-pressed", active ? "true" : "false");
});
}
function syncMotionDebugSkeletonOnlyToggle(nextEnabled = motionDebugSkeletonOnly) {
document.querySelectorAll("[data-motion-skeleton-only-toggle]").forEach((input) => {
if (input instanceof HTMLInputElement) {
input.checked = Boolean(nextEnabled);
}
});
}
function dispatchMotionSettingsChange() {
window.dispatchEvent(
new CustomEvent("earth:motion-debug-mode-change", {
detail: {
enabled: motionDebugEnabled,
provider: motionProvider,
skeletonOnly: motionDebugSkeletonOnly,
},
}),
);
}
function getPersistedLayers() {
return getDisplayLayerDefinitions().filter((layer) => layer.persist !== false);
}
@@ -974,6 +1074,13 @@ function syncEarthSettingsStateFromRuntime() {
return nextSettings;
}
function ensureMutableEarthSettingsState() {
earthSettingsState = cloneEarthSettings(
earthSettingsState || cloneEarthSettings(captureEarthSettingsDefaults()),
);
return earthSettingsState;
}
function persistEarthSettings() {
if (!canUseLocalStorage()) return;
try {
@@ -1051,9 +1158,7 @@ export function setCruiseModules(nextModules, { persist = true, suppressStatus =
return normalizedModules;
}
earthSettingsState = cloneEarthSettings(
earthSettingsState || cloneEarthSettings(captureEarthSettingsDefaults()),
);
ensureMutableEarthSettingsState();
earthSettingsState.shared.cruiseModules = [...normalizedModules];
syncCruiseModuleControls();
dispatchCruiseModulesChange();
@@ -1063,9 +1168,7 @@ export function setCruiseModules(nextModules, { persist = true, suppressStatus =
}
if (!suppressStatus) {
const labels = normalizedModules.map((moduleId) =>
moduleId === CRUISE_MODULES.NEWS ? "新闻" : "BGP",
);
const labels = normalizedModules.map((moduleId) => CRUISE_MODULE_LABELS[moduleId] || moduleId);
showStatusMessage(`巡航模块已切换为:${labels.join(" + ")}`, "info");
}
@@ -1086,9 +1189,7 @@ export function setSatelliteDisplayStyle(
return normalizedStyle;
}
earthSettingsState = cloneEarthSettings(
earthSettingsState || cloneEarthSettings(captureEarthSettingsDefaults()),
);
ensureMutableEarthSettingsState();
earthSettingsState.shared.satelliteDisplayStyle = normalizedStyle;
applySatelliteDisplayStyle(normalizedStyle);
syncSatelliteDisplayStyleControls();
@@ -1184,6 +1285,19 @@ async function applyEarthSettings(settings, { applyLayers = true } = {}) {
persist: false,
applyToCurrentView: true,
});
setMotionDebugEnabled(settings.shared.motionDebugEnabled, {
persist: false,
suppressStatus: true,
});
setMotionProvider(settings.shared.motionProvider, {
persist: false,
suppressStatus: true,
});
setMotionDebugSkeletonOnly(settings.shared.motionDebugSkeletonOnly, {
persist: false,
suppressStatus: true,
});
setActiveTVTab(settings.shared.mediaPanelActiveTab);
if (!applyLayers) {
const layerVisibility = { ...(settings.shared.layerVisibility || {}) };
@@ -1199,6 +1313,104 @@ async function applyEarthSettings(settings, { applyLayers = true } = {}) {
});
}
export function getMotionDebugEnabled() {
return motionDebugEnabled;
}
export function getMotionProvider() {
return motionProvider;
}
export function getMotionDebugSkeletonOnly() {
return motionDebugSkeletonOnly;
}
export function setMotionDebugEnabled(
nextEnabled,
{ persist = true, suppressStatus = false } = {},
) {
const requested = Boolean(nextEnabled);
const normalized = requested && rotationMode === ROTATION_MODE.MOTION;
const changed = motionDebugEnabled !== normalized;
motionDebugEnabled = normalized;
syncMotionDebugToggle(motionDebugEnabled);
ensureMutableEarthSettingsState();
earthSettingsState.shared.motionDebugEnabled = motionDebugEnabled;
if (changed) {
dispatchMotionSettingsChange();
}
if (persist) {
persistEarthSettings();
}
if (!suppressStatus && changed) {
showStatusMessage(
motionDebugEnabled ? "动捕调试模式已开启" : "动捕调试模式已关闭",
"info",
);
} else if (!suppressStatus && requested && rotationMode !== ROTATION_MODE.MOTION) {
showStatusMessage("请先切换到动捕模式再打开调试面板", "info");
}
return motionDebugEnabled;
}
export function setMotionProvider(
nextProvider,
{ persist = true, suppressStatus = false } = {},
) {
const normalized = normalizeMotionProvider(nextProvider, DEFAULT_MOTION_PROVIDER);
const changed = motionProvider !== normalized;
motionProvider = normalized;
syncMotionProviderControls(motionProvider);
ensureMutableEarthSettingsState();
earthSettingsState.shared.motionProvider = motionProvider;
if (changed) {
dispatchMotionSettingsChange();
}
if (persist) {
persistEarthSettings();
}
if (!suppressStatus && changed) {
showStatusMessage(
motionProvider === "motion_agent"
? "动捕输入源已切换为 Motion Agent"
: "动捕输入源已切换为浏览器摄像头",
"info",
);
}
return motionProvider;
}
export function setMotionDebugSkeletonOnly(
nextEnabled,
{ persist = true, suppressStatus = false } = {},
) {
const normalized = Boolean(nextEnabled);
const changed = motionDebugSkeletonOnly !== normalized;
motionDebugSkeletonOnly = normalized;
syncMotionDebugSkeletonOnlyToggle(motionDebugSkeletonOnly);
ensureMutableEarthSettingsState();
earthSettingsState.shared.motionDebugSkeletonOnly = motionDebugSkeletonOnly;
if (changed) {
dispatchMotionSettingsChange();
}
if (persist) {
persistEarthSettings();
}
if (!suppressStatus && changed) {
showStatusMessage(
motionDebugSkeletonOnly ? "动捕调试已切换为只显示骨骼" : "动捕调试已显示实时画面",
"info",
);
}
return motionDebugSkeletonOnly;
}
export async function applyDeferredLayerVisibilitySettings(options = {}) {
const layerVisibility = deferredLayerVisibilitySettings;
deferredLayerVisibilitySettings = null;
@@ -2380,6 +2592,27 @@ function setupSettingsControls() {
});
});
document.querySelectorAll("[data-motion-debug-toggle]").forEach((motionDebugToggle) => {
if (!(motionDebugToggle instanceof HTMLInputElement)) return;
bindListener(motionDebugToggle, "change", () => {
setMotionDebugEnabled(motionDebugToggle.checked);
});
});
document.querySelectorAll("[data-motion-provider]").forEach((motionProviderButton) => {
if (!(motionProviderButton instanceof HTMLButtonElement)) return;
bindListener(motionProviderButton, "click", () => {
setMotionProvider(motionProviderButton.dataset.motionProvider);
});
});
document.querySelectorAll("[data-motion-skeleton-only-toggle]").forEach((motionSkeletonOnlyToggle) => {
if (!(motionSkeletonOnlyToggle instanceof HTMLInputElement)) return;
bindListener(motionSkeletonOnlyToggle, "change", () => {
setMotionDebugSkeletonOnly(motionSkeletonOnlyToggle.checked);
});
});
const mobileSettingsReset = document.getElementById("mobile-settings-reset");
bindListener(mobileSettingsReset, "click", () => {
resetEarthSettings();
@@ -2392,6 +2625,9 @@ function setupSettingsControls() {
syncCruiseModuleControls();
syncSatelliteDisplayStyleControls();
syncDayNightToggle(dayNightEnabled);
syncMotionDebugToggle(motionDebugEnabled);
syncMotionProviderControls(motionProvider);
syncMotionDebugSkeletonOnlyToggle(motionDebugSkeletonOnly);
}
function setupHudPanelControls() {
@@ -2732,10 +2968,28 @@ export async function setupControls(camera, renderer, scene, earth) {
}
});
bindListener(window, "earth:tv-visibility-change", (event) => {
if (
event instanceof CustomEvent &&
typeof event.detail?.visible === "boolean" &&
event.detail.persist !== false
) {
const scope = getSettingsViewportScope();
ensureMutableEarthSettingsState();
if (earthSettingsState.views?.[scope]?.panelVisibility) {
earthSettingsState.views[scope].panelVisibility["media-panel"] = event.detail.visible;
}
persistEarthSettings();
}
if (event instanceof CustomEvent && event.detail?.visible) {
closeTransientMobileOverlays({ except: "media" });
}
});
bindListener(window, "earth:tv-tab-change", (event) => {
if (!(event instanceof CustomEvent)) return;
ensureMutableEarthSettingsState();
earthSettingsState.shared.mediaPanelActiveTab = normalizeMediaPanelActiveTab(event.detail?.tab);
persistEarthSettings();
});
// No longer auto-navigates to details tab on mobile — popup handles the display.
// Drawer details tab is opened explicitly via earth:open-details-tab when user taps popup.
}
@@ -2956,7 +3210,12 @@ function setupRotateControls(camera) {
bindListener(rotateBtn, "click", () => {
const isRotating = toggleAutoRotate();
const label = rotationMode === ROTATION_MODE.CRUISE ? "巡航" : "自动旋转";
const label =
rotationMode === ROTATION_MODE.CRUISE
? "巡航"
: rotationMode === ROTATION_MODE.MOTION
? "动捕"
: "自动旋转";
showStatusMessage(isRotating ? `${label}已开启` : `${label}已暂停`, "info");
});
@@ -3129,6 +3388,22 @@ export function getStartupLoadLayers() {
.map((definition) => ({ ...definition }));
}
export function getVisibleMotionLayerDefinitions() {
if (layerRegistry.size === 0) {
initializeLayerRegistry();
}
const motionLayerIds = new Set(["cables", "computeCenters", "bgp", "vessels", "satellites"]);
return getDisplayLayerDefinitions()
.filter((definition) => motionLayerIds.has(definition.id))
.filter((definition) => Boolean(definition.getVisible?.()))
.map((definition) => ({
id: definition.id,
label: definition.label,
meta: definition.meta,
}));
}
function setupTerrainControls() {
initializeLayerRegistry();
const container = document.getElementById("container");
@@ -3645,7 +3920,9 @@ export function getAutoRotate() {
}
function getRotationModeLabel(mode = rotationMode) {
return mode === ROTATION_MODE.CRUISE ? "巡航模式" : "旋转模式";
if (mode === ROTATION_MODE.CRUISE) return "巡航模式";
if (mode === ROTATION_MODE.MOTION) return "动捕模式";
return "旋转模式";
}
function syncRotationModeButtons() {
@@ -3665,7 +3942,11 @@ function updateRotateUI() {
btn.classList.toggle("is-stopped", !autoRotate);
const tooltip = btn.querySelector(".earth-toolbar-tooltip");
const activeLabel =
rotationMode === ROTATION_MODE.CRUISE ? "巡航" : "自动旋转";
rotationMode === ROTATION_MODE.CRUISE
? "巡航"
: rotationMode === ROTATION_MODE.MOTION
? "动捕"
: "自动旋转";
if (tooltip) {
tooltip.textContent = autoRotate ? `暂停${activeLabel}` : `开始${activeLabel}`;
}
@@ -3678,7 +3959,7 @@ function updateRotateUI() {
export function setAutoRotate(value) {
autoRotate = value;
updateRotateUI();
if (rotationMode === ROTATION_MODE.CRUISE) {
if (rotationMode === ROTATION_MODE.CRUISE || rotationMode === ROTATION_MODE.MOTION) {
dispatchRotationModeChange();
}
}
@@ -3687,7 +3968,7 @@ export function toggleAutoRotate() {
autoRotate = !autoRotate;
updateRotateUI();
clearLockedObject();
if (rotationMode === ROTATION_MODE.CRUISE) {
if (rotationMode === ROTATION_MODE.CRUISE || rotationMode === ROTATION_MODE.MOTION) {
dispatchRotationModeChange();
}
return autoRotate;
@@ -3699,22 +3980,26 @@ export function getRotationMode() {
export function setRotationMode(nextMode, { persist = true, suppressStatus = false } = {}) {
const normalizedMode =
nextMode === ROTATION_MODE.CRUISE ? ROTATION_MODE.CRUISE : ROTATION_MODE.ROTATE;
nextMode === ROTATION_MODE.CRUISE
? ROTATION_MODE.CRUISE
: nextMode === ROTATION_MODE.MOTION
? ROTATION_MODE.MOTION
: ROTATION_MODE.ROTATE;
const changed = normalizedMode !== rotationMode;
if (changed && normalizedMode === ROTATION_MODE.CRUISE) {
if (changed && (normalizedMode === ROTATION_MODE.CRUISE || normalizedMode === ROTATION_MODE.MOTION)) {
autoRotate = true;
}
rotationMode = normalizedMode;
if (normalizedMode !== ROTATION_MODE.MOTION && motionDebugEnabled) {
setMotionDebugEnabled(false, { persist, suppressStatus: true });
}
updateRotateUI();
dispatchRotationModeChange();
if (persist) {
persistEarthSettings();
}
if (changed && !suppressStatus) {
showStatusMessage(
normalizedMode === ROTATION_MODE.CRUISE ? "已切换到巡航模式" : "已切换到旋转模式",
"info",
);
showStatusMessage(`已切换到${getRotationModeLabel(normalizedMode)}`, "info");
}
}

View File

@@ -12,6 +12,7 @@ let _hoverGlowLines = null;
let _hoverLines = null;
let _hoveredFeature = null;
let _hoveredGroupKey = null;
let _hoverGeometryCache = new Map();
let _visible = false;
let _landFillEnabled = true;
let _landFillSuppressed = false;
@@ -235,15 +236,62 @@ function setBoundaryLinesDimmed(dimmed) {
_boundaryLines.material.needsUpdate = true;
}
function clearHoverLineGeometries() {
if (_hoverGlowLines) _hoverGlowLines.geometry.setFromPoints([]);
if (_hoverLines) _hoverLines.geometry.setFromPoints([]);
function setHoverLinesVisible(visible) {
const nextVisible = _visible && Boolean(visible);
if (_hoverGlowLines) _hoverGlowLines.visible = nextVisible;
if (_hoverLines) _hoverLines.visible = nextVisible;
}
function featureListToSegments(features, radius) {
return features.flatMap(f => featureToSegments(f.geometry, radius));
}
function makeLineGeometry(points) {
return points.length > 0
? new THREE.BufferGeometry().setFromPoints(points)
: new THREE.BufferGeometry();
}
function markCachedHoverGeometry(geometry) {
if (geometry) geometry.userData.countryBoundaryHoverCached = true;
return geometry;
}
function setLineGeometry(line, geometry) {
if (!line || !geometry || line.geometry === geometry) return;
if (!line.geometry?.userData?.countryBoundaryHoverCached) {
line.geometry?.dispose?.();
}
line.geometry = geometry;
}
function getHoverGeometries(groupKey, features) {
const cacheKey = groupKey || features[0] || "__empty__";
const cached = _hoverGeometryCache.get(cacheKey);
if (cached) return cached;
const coreRadius = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset;
const glowRadius = coreRadius + COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset;
const geometries = {
core: markCachedHoverGeometry(
makeLineGeometry(featureListToSegments(features, coreRadius)),
),
glow: markCachedHoverGeometry(
makeLineGeometry(featureListToSegments(features, glowRadius)),
),
};
_hoverGeometryCache.set(cacheKey, geometries);
return geometries;
}
function disposeHoverGeometryCache() {
_hoverGeometryCache.forEach(({ core, glow }) => {
core?.dispose?.();
glow?.dispose?.();
});
_hoverGeometryCache.clear();
}
// ─── Point-in-polygon (lat/lon space) ─────────────────────────────────────────
function pointInRing(lat, lon, ring) {
@@ -373,14 +421,13 @@ export function toggleCountryBoundaries(
_landMesh.visible = _landFillEnabled && !_landFillSuppressed;
}
if (_boundaryLines) _boundaryLines.visible = _visible;
if (_hoverGlowLines) _hoverGlowLines.visible = _visible;
if (_hoverLines) _hoverLines.visible = _visible;
setHoverLinesVisible(_hoveredFeature);
if (!_visible) {
_hoveredFeature = null;
_hoveredGroupKey = null;
setBoundaryLinesDimmed(false);
clearHoverLineGeometries();
setHoverLinesVisible(false);
}
if (_tintMesh) _tintMesh.visible = _visible && showTint && _tintEnabled;
@@ -417,7 +464,7 @@ export function clearCountryBoundaryHover() {
_hoveredFeature = null;
_hoveredGroupKey = null;
setBoundaryLinesDimmed(false);
clearHoverLineGeometries();
setHoverLinesVisible(false);
}
/**
@@ -437,18 +484,14 @@ export function updateCountryBoundaryHover(coords) {
if (_hoverLines) {
if (!found) {
setBoundaryLinesDimmed(false);
clearHoverLineGeometries();
setHoverLinesVisible(false);
} else {
setBoundaryLinesDimmed(true);
const highlightFeatures = getHighlightFeatures(found);
const coreRadius = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset;
const glowRadius = coreRadius + COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset;
if (_hoverGlowLines) {
const glowPts = featureListToSegments(highlightFeatures, glowRadius);
_hoverGlowLines.geometry.setFromPoints(glowPts);
}
const corePts = featureListToSegments(highlightFeatures, coreRadius);
_hoverLines.geometry.setFromPoints(corePts);
const geometries = getHoverGeometries(groupKey, highlightFeatures);
setLineGeometry(_hoverGlowLines, geometries.glow);
setLineGeometry(_hoverLines, geometries.core);
setHoverLinesVisible(true);
}
}
}
@@ -464,7 +507,9 @@ export function clearCountryBoundaryData() {
function disposeObj(obj) {
if (!obj) return;
if (_earthObj) _earthObj.remove(obj);
obj.geometry?.dispose();
if (!obj.geometry?.userData?.countryBoundaryHoverCached) {
obj.geometry?.dispose();
}
if (obj.material) {
if (obj.material.map) obj.material.map.dispose();
obj.material.dispose();
@@ -483,6 +528,7 @@ export function clearCountryBoundaryData() {
_landMesh = null;
_tintMesh = null;
_features = [];
disposeHoverGeometryCache();
_loaded = false;
_loadPromise = null;
_visible = false;

View File

@@ -14,6 +14,7 @@ export class CruiseSequencer {
hideItem,
clearCurrent,
onStop,
presentationMode = "auto_advance",
dwellMs = 2400,
transitionGapMs = 24,
}) {
@@ -25,6 +26,7 @@ export class CruiseSequencer {
this.hideItem = hideItem;
this.clearCurrent = clearCurrent;
this.onStop = onStop;
this.presentationMode = presentationMode === "pinned" ? "pinned" : "auto_advance";
this.dwellMs = dwellMs;
this.transitionGapMs = transitionGapMs;
@@ -39,11 +41,12 @@ export class CruiseSequencer {
this.primaryTimerId = null;
this.secondaryTimerId = null;
this.presentationVisible = false;
this.currentItem = null;
}
getCurrentItem() {
if (!this.currentItemId) return null;
return this.getItems().find((item) => this.getItemId(item) === this.currentItemId) || null;
return this.getItems().find((item) => this.getItemId(item) === this.currentItemId) || this.currentItem || null;
}
getCurrentItemId() {
@@ -58,6 +61,10 @@ export class CruiseSequencer {
return this.advanceInFlight || this.presentationVisible;
}
isPinnedMode() {
return this.presentationMode === "pinned";
}
enqueue(itemIds = []) {
if (!Array.isArray(itemIds) || itemIds.length === 0) return;
this.queuedItemIds = Array.from(
@@ -91,12 +98,14 @@ export class CruiseSequencer {
}
if (!preservePresentation) {
this.presentationVisible = false;
this.currentItem = null;
this.clearCurrent?.();
}
}
stop({ preservePresentation = false } = {}) {
this.interruptPresentation({ preservePresentation });
this.currentItem = preservePresentation ? this.currentItem : null;
this.currentItemId = preservePresentation ? this.currentItemId : null;
this.currentIndex = preservePresentation ? this.currentIndex : -1;
this.queuedItemIds = [];
@@ -145,15 +154,11 @@ export class CruiseSequencer {
return items[nextIndex] || items[0] || null;
}
async performAdvance({ interrupt = false } = {}) {
async presentResolvedItem(targetItem, { interrupt = false } = {}) {
if (!this.isActive()) return;
const items = this.getItems();
if (!Array.isArray(items) || items.length === 0) return;
const targetItem = this.resolveNextItem(items);
if (!targetItem) return;
const items = this.getItems();
const token = ++this.sequenceToken;
const context = this.createContext(token);
@@ -161,10 +166,11 @@ export class CruiseSequencer {
this.presentationVisible = false;
this.clearCurrent?.();
this.currentItem = targetItem;
this.currentItemId = this.getItemId(targetItem);
this.currentIndex = items.findIndex(
(item) => this.getItemId(item) === this.currentItemId,
);
this.currentIndex = Array.isArray(items)
? items.findIndex((item) => this.getItemId(item) === this.currentItemId)
: -1;
await this.focusItem?.(targetItem, { interrupt, context });
if (!context.isCurrent()) {
@@ -179,6 +185,10 @@ export class CruiseSequencer {
}
this.presentationVisible = true;
if (this.isPinnedMode()) {
return true;
}
const dwellCompleted = await context.wait(this.dwellMs);
if (!dwellCompleted || !context.isCurrent()) {
this.presentationVisible = false;
@@ -198,6 +208,26 @@ export class CruiseSequencer {
}
void this.advance();
return true;
}
async performAdvance({ interrupt = false } = {}) {
if (!this.isActive()) return;
const items = this.getItems();
if (!Array.isArray(items) || items.length === 0) return;
const targetItem = this.resolveNextItem(items);
return this.presentResolvedItem(targetItem, { interrupt });
}
async presentSpecificItem(item, { interrupt = false } = {}) {
if (!this.isActive() || !item) return false;
this.advanceLoopToken += 1;
this.advanceQueued = false;
this.advanceInterrupt = false;
this.advanceInFlight = false;
return Boolean(await this.presentResolvedItem(item, { interrupt }));
}
async advance({ interrupt = false } = {}) {

View File

@@ -0,0 +1,101 @@
import { describe, expect, test } from "bun:test";
import { CruiseSequencer } from "./cruise-sequencer.js";
function installWindow() {
globalThis.window = {
requestAnimationFrame: (callback) => setTimeout(callback, 0),
setTimeout,
clearTimeout,
};
}
function wait(ms = 8) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function createSequencer(options = {}) {
installWindow();
const calls = [];
let active = true;
let items = options.items || [{ id: "one" }, { id: "two" }];
const sequencer = new CruiseSequencer({
isActive: () => active,
getItems: () => items,
getItemId: (item) => item.id,
dwellMs: options.dwellMs ?? 1,
transitionGapMs: options.transitionGapMs ?? 1,
presentationMode: options.presentationMode,
clearCurrent: () => calls.push("clear"),
focusItem: async (item) => calls.push(`focus:${item.id}`),
presentItem: async (item) => {
calls.push(`present:${item.id}`);
return true;
},
hideItem: async (item) => {
calls.push(`hide:${item.id}`);
if (options.stopAfterHide) active = false;
},
});
return {
calls,
items,
sequencer,
setItems: (nextItems) => {
items = nextItems;
},
};
}
describe("CruiseSequencer presentation modes", () => {
test("auto_advance keeps existing dwell, hide, and advance behavior", async () => {
const { calls, sequencer } = createSequencer({ stopAfterHide: true });
await sequencer.advance();
await wait();
expect(calls).toContain("focus:one");
expect(calls).toContain("present:one");
expect(calls).toContain("hide:one");
});
test("pinned mode presents without auto hiding or advancing", async () => {
const { calls, sequencer } = createSequencer({ presentationMode: "pinned" });
await sequencer.advance();
await wait();
expect(calls).toEqual(["clear", "focus:one", "present:one"]);
expect(sequencer.isPresentationPinned()).toBe(true);
});
test("presentSpecificItem directly presents the requested item", async () => {
const { calls, items, sequencer } = createSequencer({ presentationMode: "pinned" });
const presented = await sequencer.presentSpecificItem(items[1], { interrupt: true });
expect(presented).toBe(true);
expect(calls).toEqual(["clear", "focus:two", "present:two"]);
expect(sequencer.getCurrentItemId()).toBe("two");
});
test("pinned mode keeps the presented item even when the live queue no longer contains it", async () => {
const { items, sequencer, setItems } = createSequencer({ presentationMode: "pinned" });
await sequencer.presentSpecificItem(items[1], { interrupt: true });
setItems([]);
expect(sequencer.getCurrentItem()).toEqual({ id: "two" });
});
test("stop can preserve or clear a pinned presentation", async () => {
const { sequencer } = createSequencer({ presentationMode: "pinned" });
await sequencer.advance();
sequencer.stop({ preservePresentation: true });
expect(sequencer.isPresentationPinned()).toBe(true);
sequencer.stop();
expect(sequencer.isPresentationPinned()).toBe(false);
});
});

View File

@@ -8,6 +8,7 @@ let typewriterToken = 0;
let pendingMobileDetailState = null;
let mobileDetailsListenerBound = false;
let renderedMobileDetailKey = null;
const locationCollectStateCache = new Map();
const IDENTIFIER_FIELD_KEYS = new Set([
'mmsi',
'mmsi_display',
@@ -17,6 +18,53 @@ const IDENTIFIER_FIELD_KEYS = new Set([
]);
const MAX_VESSEL_MEDIA_TILES = 4;
function getLocationCollectCacheKey(context) {
const entityType = context?.entityType || 'unknown';
const entityId = context?.entityId || context?.sourceId || '';
if (!entityId) return '';
return `${entityType}:${entityId}`;
}
function getLocationCollectState(contextOrKey) {
const key = typeof contextOrKey === 'string'
? contextOrKey
: getLocationCollectCacheKey(contextOrKey);
return key ? locationCollectStateCache.get(key) || null : null;
}
function setLocationCollectState(contextOrKey, patch = {}) {
const key = typeof contextOrKey === 'string'
? contextOrKey
: getLocationCollectCacheKey(contextOrKey);
if (!key) return null;
const previous = locationCollectStateCache.get(key) || {};
const next = {
...previous,
...patch,
updatedAt: Date.now(),
};
locationCollectStateCache.set(key, next);
updateLocationCollectDomFromState(key);
return next;
}
function clearLocationCollectState(contextOrKey) {
const key = typeof contextOrKey === 'string'
? contextOrKey
: getLocationCollectCacheKey(contextOrKey);
if (!key) return;
locationCollectStateCache.delete(key);
updateLocationCollectDomFromState(key);
}
function updateLocationCollectDomFromState(key) {
if (!key) return;
const state = getLocationCollectState(key);
document.querySelectorAll(`[data-collect-cache-key="${escapeCssIdentifier(key)}"]`).forEach((root) => {
hydrateLocationCollectRoot(root, state);
});
}
function formatInfoCardValue(field, rawValue) {
if (rawValue === undefined || rawValue === null || rawValue === '') {
return '-';
@@ -43,6 +91,13 @@ function escapeInfoCardHtml(value) {
}[char]));
}
function escapeCssIdentifier(value) {
if (window.CSS && typeof window.CSS.escape === 'function') {
return window.CSS.escape(String(value));
}
return String(value).replace(/["\\]/g, '\\$&');
}
function getNewsSummaryText(data) {
return (data?.summary || data?.title || '').trim() || '暂无摘要';
}
@@ -300,18 +355,55 @@ function renderLocationCollectSection(context) {
const buttonLabel = context.needsConfirmation
? '重新自动采集坐标'
: '自动采集坐标候选';
const cacheKey = getLocationCollectCacheKey(context);
const cached = getLocationCollectState(cacheKey);
return `
<div class="info-card-compute-collect" data-collect-entity-id="${context.entityId}" data-collect-entity-type="${context.entityType}">
<div class="info-card-compute-collect" data-collect-entity-id="${context.entityId}" data-collect-entity-type="${context.entityType}" data-collect-cache-key="${escapeInfoCardHtml(cacheKey)}">
<button type="button" class="info-card-compute-collect-button" data-collect-action="run">
<span class="material-symbols-rounded" aria-hidden="true">explore</span>
<span>${buttonLabel}</span>
</button>
<div class="info-card-compute-collect-status" data-collect-status></div>
<div class="info-card-compute-collect-candidates" data-collect-candidates></div>
<div class="info-card-compute-collect-status" data-collect-status>${escapeInfoCardHtml(cached?.statusText || '')}</div>
<div class="info-card-compute-collect-candidates" data-collect-candidates>
${renderCachedCollectCandidates(cached)}
</div>
</div>
`;
}
function renderCachedCollectCandidates(state) {
const candidates = Array.isArray(state?.candidates) ? state.candidates : [];
if (!candidates.length) return '';
return candidates
.slice(0, 5)
.map((candidate, index) => renderCollectCandidateRow(candidate, index === 0))
.join('');
}
function hydrateLocationCollectRoot(root, state) {
if (!(root instanceof HTMLElement)) return;
const statusEl = root.querySelector('[data-collect-status], [data-unresolved-status]');
const candidatesEl = root.querySelector('[data-collect-candidates], [data-unresolved-candidates]');
const button = root.querySelector('[data-collect-action="run"], [data-unresolved-collect]');
if (statusEl) statusEl.textContent = state?.statusText || '';
if (candidatesEl) candidatesEl.innerHTML = renderCachedCollectCandidates(state);
if (button instanceof HTMLButtonElement) button.disabled = state?.loading === true;
}
function formatLocationCollectFailure(result) {
const regularReason = result?.failure_reason || '常规来源没有可用坐标候选';
const llmReason = result?.llm_failure_reason;
if (llmReason) {
return `常规来源无结果LLM 兜底未生成可用候选:${llmReason}`;
}
const attempted = Array.isArray(result?.attempted_queries) ? result.attempted_queries : [];
const attemptedLlm = attempted.some((query) => String(query || '').startsWith('llm_factcheck:'));
if (attemptedLlm) {
return `常规来源无结果LLM 兜底已尝试但没有返回可用候选。${regularReason}`;
}
return regularReason;
}
function bindLocationCollectControls(content, context) {
const collectRoot = content.querySelector('[data-collect-entity-id]');
if (!collectRoot) return;
@@ -319,30 +411,52 @@ function bindLocationCollectControls(content, context) {
const statusEl = collectRoot.querySelector('[data-collect-status]');
const candidatesEl = collectRoot.querySelector('[data-collect-candidates]');
if (!button) return;
const cachedState = getLocationCollectState(context);
if (cachedState) {
hydrateLocationCollectRoot(collectRoot, cachedState);
bindCandidatePreviewButtons(candidatesEl, context);
bindCandidateSaveButtons(candidatesEl, context, statusEl);
}
button.addEventListener('click', async (event) => {
event.stopPropagation();
button.disabled = true;
statusEl.textContent = '正在采集坐标候选...';
candidatesEl.innerHTML = '';
setLocationCollectState(context, {
loading: true,
statusText: '正在采集坐标候选...',
candidates: [],
});
try {
const result = await context.collect();
if (!result?.success) {
statusEl.textContent = `未能采集到坐标:${result?.failure_reason || '未知原因'}`;
setLocationCollectState(context, {
loading: false,
statusText: `未能采集到坐标:${formatLocationCollectFailure(result)}`,
candidates: [],
result,
});
return;
}
const candidates = Array.isArray(result.candidates) ? result.candidates : [];
statusEl.textContent = `共找到 ${candidates.length} 个候选位置`;
candidatesEl.innerHTML = candidates
.slice(0, 5)
.map((candidate, index) => renderCollectCandidateRow(candidate, index === 0))
.join('');
setLocationCollectState(context, {
loading: false,
statusText: `共找到 ${candidates.length} 个候选位置`,
candidates,
result,
});
bindCandidatePreviewButtons(candidatesEl, context);
bindCandidateSaveButtons(candidatesEl, context, statusEl);
} catch (error) {
console.error('collect-location failed', error);
statusEl.textContent = `采集失败:${error?.message || error}`;
setLocationCollectState(context, {
loading: false,
statusText: `采集失败:${error?.message || error}`,
candidates: [],
});
} finally {
button.disabled = false;
updateLocationCollectDomFromState(getLocationCollectCacheKey(context));
bindCandidatePreviewButtons(candidatesEl, context);
bindCandidateSaveButtons(candidatesEl, context, statusEl);
}
}, { once: false });
}
@@ -384,6 +498,8 @@ function getUnresolvedComputeCenterContext(item) {
? item.metadata
: {};
return {
entityType: 'compute_center',
entityId: item?.source_id || item?.id || '',
sourceId: item?.source_id || item?.id || '',
recordId: item?.id || item?.record_id || '',
name: item?.name || item?.title || '未命名算力中心',
@@ -410,11 +526,13 @@ function renderComputeCenterUnresolvedContent(content, data) {
.map((item, index) => {
const context = getUnresolvedComputeCenterContext(item);
const contextJson = JSON.stringify(context).replace(/"/g, '&quot;');
const cacheKey = getLocationCollectCacheKey(context);
const cached = getLocationCollectState(cacheKey);
const meta = [context.site || context.operator, context.city, context.country]
.filter(Boolean)
.join(' · ') || '缺少可用地址字段';
return `
<div class="info-card-unresolved-item" data-unresolved-item>
<div class="info-card-unresolved-item" data-unresolved-item data-collect-cache-key="${escapeInfoCardHtml(cacheKey)}">
<div class="info-card-unresolved-main">
<div class="info-card-unresolved-index">${index + 1}</div>
<div class="info-card-unresolved-copy">
@@ -424,8 +542,10 @@ function renderComputeCenterUnresolvedContent(content, data) {
<button type="button" class="info-card-compute-candidate-preview" data-unresolved-collect
data-context-json="${contextJson}">采集</button>
</div>
<div class="info-card-compute-collect-status" data-unresolved-status></div>
<div class="info-card-compute-collect-candidates" data-unresolved-candidates></div>
<div class="info-card-compute-collect-status" data-unresolved-status>${escapeInfoCardHtml(cached?.statusText || '')}</div>
<div class="info-card-compute-collect-candidates" data-unresolved-candidates>
${renderCachedCollectCandidates(cached)}
</div>
</div>
`;
})
@@ -479,7 +599,17 @@ function removeResolvedUnresolvedItem(content, itemRoot) {
return updateUnresolvedSummary(content);
}
async function collectUnresolvedComputeCenterCandidates(context) {
async function collectUnresolvedComputeCenterCandidates(context, options = {}) {
const cached = getLocationCollectState(context);
if (options.useCached === true && Array.isArray(cached?.candidates) && cached.candidates.length) {
const mod = await import('./compute-centers.js');
return {
mod,
result: cached.result || { success: true, candidates: cached.candidates },
candidates: cached.candidates,
fromCache: true,
};
}
const mod = await import('./compute-centers.js');
const result = await mod.collectComputeCenterLocation(context.sourceId, {
name: context.name,
@@ -494,6 +624,7 @@ async function collectUnresolvedComputeCenterCandidates(context) {
mod,
result,
candidates: Array.isArray(result?.candidates) ? result.candidates : [],
fromCache: false,
};
}
@@ -547,7 +678,12 @@ function bindCandidateSaveButtons(container, context, statusEl) {
if (statusEl) statusEl.textContent = '正在保存所选坐标...';
try {
await context.save(candidate);
if (statusEl) statusEl.textContent = '坐标已保存,正在刷新图层...';
setLocationCollectState(context, {
loading: false,
statusText: '坐标已保存,正在后台刷新图层...',
candidates: [],
});
if (statusEl) statusEl.textContent = '坐标已保存,正在后台刷新图层...';
window.dispatchEvent(
new CustomEvent('earth:compute-center-location-saved', {
detail: {
@@ -557,6 +693,12 @@ function bindCandidateSaveButtons(container, context, statusEl) {
},
}),
);
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}`;
@@ -568,6 +710,25 @@ function bindCandidateSaveButtons(container, context, statusEl) {
}
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 = {
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);
});
content.querySelectorAll('[data-unresolved-collect]').forEach((button) => {
button.addEventListener('click', async (event) => {
event.stopPropagation();
@@ -578,31 +739,57 @@ function bindComputeCenterUnresolvedControls(content) {
if (!context.sourceId || !statusEl || !candidatesEl) return;
button.disabled = true;
statusEl.textContent = '正在采集坐标候选...';
candidatesEl.innerHTML = '';
setLocationCollectState(context, {
loading: true,
statusText: '正在采集坐标候选...',
candidates: [],
});
try {
const { mod, result, candidates } = await collectUnresolvedComputeCenterCandidates(context);
if (!result?.success) {
statusEl.textContent = `未能采集到坐标:${result?.failure_reason || '未知原因'}`;
setLocationCollectState(context, {
loading: false,
statusText: `未能采集到坐标:${formatLocationCollectFailure(result)}`,
candidates: [],
result,
});
return;
}
statusEl.textContent = `共找到 ${candidates.length} 个候选位置`;
candidatesEl.innerHTML = candidates
.slice(0, 5)
.map((candidate, index) => renderCollectCandidateRow(candidate, index === 0))
.join('');
setLocationCollectState(context, {
loading: false,
statusText: `共找到 ${candidates.length} 个候选位置`,
candidates,
result,
});
const actionContext = {
entityType: 'compute_center',
entityId: context.sourceId,
isUnresolved: true,
save: (candidate) => mod.saveComputeCenterLocation(context.sourceId, candidate, context),
};
bindCandidatePreviewButtons(candidatesEl, actionContext);
bindCandidateSaveButtons(candidatesEl, actionContext, statusEl);
} catch (error) {
console.error('collect unresolved compute-center location failed', error);
statusEl.textContent = `采集失败:${error?.message || error}`;
setLocationCollectState(context, {
loading: false,
statusText: `采集失败:${error?.message || error}`,
candidates: [],
});
} 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);
}
});
});
@@ -633,10 +820,13 @@ function bindComputeCenterUnresolvedControls(content) {
statusEl.textContent = `正在采用最高置信候选 ${index + 1}/${pendingItems.length}...`;
}
try {
const { mod, result, candidates } = await collectUnresolvedComputeCenterCandidates(context);
const { mod, result, candidates, fromCache } = await collectUnresolvedComputeCenterCandidates(
context,
{ useCached: true },
);
if (!result?.success) {
if (itemStatusEl) {
itemStatusEl.textContent = `未找到可采用候选:${result?.failure_reason || '未知原因'}`;
itemStatusEl.textContent = `未找到可采用候选:${formatLocationCollectFailure(result)}`;
}
missedCount += 1;
continue;
@@ -650,6 +840,9 @@ function bindComputeCenterUnresolvedControls(content) {
continue;
}
await mod.saveComputeCenterLocation(context.sourceId, bestCandidate, context);
if (fromCache) {
clearLocationCollectState(context);
}
savedCount += 1;
removeResolvedUnresolvedItem(content, itemRoot);
} catch (error) {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,108 @@
const DEFAULT_RECONNECT_MS = 1800;
export const DEFAULT_AGENT_URL = "ws://127.0.0.1:8765/ws/gestures";
export function createMotionAgentProvider(options = {}) {
const {
url = DEFAULT_AGENT_URL,
reconnectMs = DEFAULT_RECONNECT_MS,
WebSocketCtor = typeof WebSocket !== "undefined" ? WebSocket : null,
onMessage = () => {},
onState = () => {},
onStatus = () => {},
} = options;
let socket = null;
let reconnectTimer = null;
let disposed = false;
let connected = false;
function emitState(detail = {}) {
onState({
provider: "motion_agent",
connected,
url,
...detail,
});
}
function clearReconnect() {
if (!reconnectTimer) return;
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
function scheduleReconnect() {
if (disposed || reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, reconnectMs);
}
function closeSocket() {
if (!socket) return;
const current = socket;
socket = null;
current.onopen = null;
current.onmessage = null;
current.onerror = null;
current.onclose = null;
try {
current.close();
} catch (_error) {
// Browser WebSocket close can throw during teardown in older engines.
}
}
function connect() {
if (disposed || socket) return;
if (!WebSocketCtor) {
emitState({ error: "websocket_unavailable", message: "当前浏览器不支持 WebSocket" });
return;
}
try {
socket = new WebSocketCtor(url);
} catch (_error) {
emitState({ error: "socket_create_failed", message: "无法创建 Motion Agent 连接" });
scheduleReconnect();
return;
}
socket.onopen = () => {
connected = true;
emitState({ connected: true });
onStatus("动捕 Agent 已连接", "info");
};
socket.onmessage = (rawMessage) => onMessage(rawMessage?.data ?? rawMessage);
socket.onerror = () => {
emitState({ connected: false, error: "socket_error", message: "Motion Agent 连接异常" });
};
socket.onclose = () => {
connected = false;
socket = null;
emitState({ connected: false, message: "Motion Agent 未连接" });
scheduleReconnect();
};
}
return {
provider: "motion_agent",
start() {
if (disposed) return false;
connect();
return true;
},
stop() {
disposed = true;
clearReconnect();
closeSocket();
connected = false;
emitState({ connected: false });
},
isConnected() {
return connected;
},
};
}

View File

@@ -0,0 +1,548 @@
const MEDIAPIPE_TASKS_VERSION = "0.10.35";
const MEDIAPIPE_TASKS_URLS = [
`https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${MEDIAPIPE_TASKS_VERSION}/vision_bundle.mjs`,
`https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${MEDIAPIPE_TASKS_VERSION}`,
`https://unpkg.com/@mediapipe/tasks-vision@${MEDIAPIPE_TASKS_VERSION}/vision_bundle.mjs`,
];
const MEDIAPIPE_WASM_URL = `https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${MEDIAPIPE_TASKS_VERSION}/wasm`;
const POSE_MODEL_URL =
"https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_lite/float16/latest/pose_landmarker_lite.task";
const FRAME_INTERVAL_MS = 66;
const GESTURE_COOLDOWN_MS = 420;
const VIDEO_METADATA_TIMEOUT_MS = 900;
const LEFT_WRIST_LAYER_DELTA_Y = 0.05;
const HEAD_TILT_DELTA_Y = 0.035;
const ARM_PATTERN_TERMINAL_TOLERANCE_DEG = 32;
const ARM_PATTERN_UPPER_TOLERANCE_DEG = 34;
const ARM_PATTERN_MIN_SEGMENT = 0.045;
const ARM_PATTERN_MIN_SIDE_REACH = 0.06;
const ARM_PATTERN_MIN_VERTICAL_REACH = 0.055;
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;
const CAMERA_CONSTRAINTS = {
video: {
facingMode: "user",
width: { ideal: 1280 },
height: { ideal: 720 },
},
audio: false,
};
const POSE_JOINTS = [
[0, "nose"],
[7, "left_ear"],
[8, "right_ear"],
[11, "left_shoulder"],
[12, "right_shoulder"],
[13, "left_elbow"],
[14, "right_elbow"],
[15, "left_wrist"],
[16, "right_wrist"],
];
const POSE_BONES = [
["left_shoulder", "left_elbow"],
["left_elbow", "left_wrist"],
["right_shoulder", "right_elbow"],
["right_elbow", "right_wrist"],
["left_shoulder", "right_shoulder"],
];
function nowMs() {
return Math.round(performance?.now?.() || Date.now());
}
function wallClockMs() {
return Date.now();
}
function waitForVideoMetadata(video) {
if (video.videoWidth > 0 && video.videoHeight > 0) {
return Promise.resolve();
}
return new Promise((resolve) => {
const done = () => resolve();
video.addEventListener?.("loadedmetadata", done, { once: true });
video.addEventListener?.("canplay", done, { once: true });
setTimeout(done, VIDEO_METADATA_TIMEOUT_MS);
});
}
function getUserMediaErrorMessage(error) {
if (error?.name === "NotAllowedError" || error?.name === "PermissionDeniedError") {
return "浏览器摄像头权限被拒绝";
}
if (error?.name === "NotFoundError" || error?.name === "DevicesNotFoundError") {
return "没有找到可用摄像头";
}
if (error?.name === "NotReadableError") {
return "摄像头正被其他程序占用";
}
return `浏览器摄像头启动失败: ${error?.message || String(error)}`;
}
function canUseBrowserCamera(mediaDevices) {
return Boolean(
mediaDevices &&
typeof mediaDevices.getUserMedia === "function",
);
}
function isSecureCameraContext() {
if (typeof window === "undefined") return false;
const hostname = window.location?.hostname || "";
return Boolean(window.isSecureContext || hostname === "localhost" || hostname === "127.0.0.1");
}
function normalizePoseLandmarks(landmarks = []) {
return POSE_JOINTS.map(([index, id]) => {
const point = landmarks[index];
if (!point) return null;
const x = Number(point.x);
const y = Number(point.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
return {
id,
x: Math.max(0, Math.min(1, x)),
y: Math.max(0, Math.min(1, y)),
confidence: Math.max(0, Math.min(1, Number(point.visibility ?? point.presence ?? 1))),
};
}).filter(Boolean);
}
function getJoint(joints, id) {
return joints.find((joint) => joint.id === id) || null;
}
function vectorBetween(start, end) {
if (!start || !end) return null;
const dx = end.x - start.x;
const dy = end.y - start.y;
return {
dx,
dy,
length: Math.hypot(dx, dy),
};
}
function vectorAngleDeg(vector) {
return Math.atan2(vector.dy, vector.dx) * 180 / Math.PI;
}
function normalizeAngleDelta(angle, target) {
let delta = angle - target;
while (delta > 180) delta -= 360;
while (delta < -180) delta += 360;
return Math.abs(delta);
}
function isAngleNear(angle, target, toleranceDeg) {
return normalizeAngleDelta(angle, target) <= toleranceDeg;
}
function isHorizontalArm(upperVector) {
if (!upperVector || upperVector.length < ARM_PATTERN_MIN_SEGMENT) return false;
const angle = vectorAngleDeg(upperVector);
return (
isAngleNear(angle, 0, ARM_PATTERN_UPPER_TOLERANCE_DEG) ||
isAngleNear(angle, 180, ARM_PATTERN_UPPER_TOLERANCE_DEG)
);
}
function isTerminalToward(vector, targetAngle) {
if (!vector || vector.length < ARM_PATTERN_MIN_SEGMENT) return false;
return isAngleNear(vectorAngleDeg(vector), targetAngle, ARM_PATTERN_TERMINAL_TOLERANCE_DEG);
}
function getRightArmPattern(rightShoulder, rightElbow, rightWrist) {
const upper = vectorBetween(rightShoulder, rightElbow);
const terminal = vectorBetween(rightElbow, rightWrist);
if (!upper || !terminal) return null;
const intensity = Math.min(1, Math.max(MIN_GESTURE_INTENSITY, terminal.length * ARM_PATTERN_INTENSITY_SCALE));
if (isTerminalToward(terminal, 180) && rightWrist.x < rightShoulder.x - ARM_PATTERN_MIN_SIDE_REACH) {
return { gesture: "rotate_right", confidence: 0.82, intensity };
}
if (isTerminalToward(terminal, 0) && rightWrist.x > rightShoulder.x + ARM_PATTERN_MIN_SIDE_REACH) {
return { gesture: "rotate_left", confidence: 0.82, intensity };
}
if (isHorizontalArm(upper) && isTerminalToward(terminal, -90) && rightWrist.y < rightElbow.y - ARM_PATTERN_MIN_VERTICAL_REACH) {
return { gesture: "rotate_up", confidence: 0.8, intensity };
}
if (isHorizontalArm(upper) && isTerminalToward(terminal, 90) && rightWrist.y > rightElbow.y + ARM_PATTERN_MIN_VERTICAL_REACH) {
return { gesture: "rotate_down", confidence: 0.8, intensity };
}
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 =
leftWrist.x < leftElbow.x - ARM_PATTERN_MIN_SIDE_REACH * 0.25 &&
leftWrist.x < leftShoulder.x - ARM_PATTERN_MIN_SIDE_REACH * 0.55 &&
rightWrist.x > rightElbow.x + ARM_PATTERN_MIN_SIDE_REACH * 0.25 &&
rightWrist.x > rightShoulder.x + ARM_PATTERN_MIN_SIDE_REACH * 0.55;
const bothElbowsParticipating =
leftElbow.x <= leftShoulder.x + ARM_PATTERN_MIN_SIDE_REACH &&
rightElbow.x >= rightShoulder.x - ARM_PATTERN_MIN_SIDE_REACH;
const handsNearCenter =
leftElbow.x < leftShoulder.x - ARM_PATTERN_MIN_SIDE_REACH * 0.5 &&
rightElbow.x > rightShoulder.x + ARM_PATTERN_MIN_SIDE_REACH * 0.5 &&
leftWrist.x > leftElbow.x &&
rightWrist.x < rightElbow.x &&
wristsApart < shoulderWidth * ZOOM_CLOSE_WRIST_SPREAD_FACTOR;
return (
(bothHandsOutside && bothElbowsParticipating && wristsApart > shoulderWidth * ZOOM_SUPPRESS_WRIST_SPREAD_FACTOR) ||
handsNearCenter
);
}
function applyPoseLatch(observation, state) {
if (!state || !observation) return observation;
if (state.activePatternGesture === observation.gesture) return null;
state.activePatternGesture = observation.gesture;
return observation;
}
function recognizeGesture(joints, previousJoints, options = {}) {
const state = options.state || null;
const leftEar = getJoint(joints, "left_ear");
const rightEar = getJoint(joints, "right_ear");
const leftWrist = getJoint(joints, "left_wrist");
const rightWrist = getJoint(joints, "right_wrist");
const leftElbow = getJoint(joints, "left_elbow");
const rightElbow = getJoint(joints, "right_elbow");
const leftShoulder = getJoint(joints, "left_shoulder");
const rightShoulder = getJoint(joints, "right_shoulder");
const previousLeftWrist = getJoint(previousJoints, "left_wrist");
if (!leftWrist || !rightWrist || !leftElbow || !rightElbow || !leftShoulder || !rightShoulder) return null;
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;
const leftDeltaX = previousLeftWrist ? leftWrist.x - previousLeftWrist.x : 0;
const leftDeltaY = previousLeftWrist ? leftWrist.y - previousLeftWrist.y : 0;
const headTiltY = leftEar && rightEar ? rightEar.y - leftEar.y : 0;
if (!rightRaised && leftRaised && leftDeltaY < -LEFT_WRIST_LAYER_DELTA_Y) {
return { gesture: "layer_prev", confidence: 0.78, intensity: Math.min(1, Math.abs(leftDeltaY) * WRIST_LAYER_INTENSITY_SCALE) };
}
if (!rightRaised && leftRaised && leftDeltaY > LEFT_WRIST_LAYER_DELTA_Y) {
return { gesture: "layer_next", confidence: 0.78, intensity: Math.min(1, Math.abs(leftDeltaY) * WRIST_LAYER_INTENSITY_SCALE) };
}
if (headTiltY < -HEAD_TILT_DELTA_Y) {
return { gesture: "focus_prev", confidence: 0.78, intensity: Math.min(1, Math.abs(headTiltY) * HEAD_TILT_INTENSITY_SCALE) };
}
if (headTiltY > HEAD_TILT_DELTA_Y) {
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);
if (state) state.activePatternGesture = null;
return null;
}
async function createDefaultRecognizer() {
const { FilesetResolver, PoseLandmarker } = await importMediaPipeTasksVision();
const vision = await FilesetResolver.forVisionTasks(MEDIAPIPE_WASM_URL);
const pose = await PoseLandmarker.createFromOptions(vision, {
baseOptions: {
modelAssetPath: POSE_MODEL_URL,
delegate: "GPU",
},
runningMode: "VIDEO",
numPoses: 1,
});
return {
recognize(video, timestampMs) {
const result = pose.detectForVideo(video, timestampMs);
const landmarks = result?.landmarks?.[0] || [];
return normalizePoseLandmarks(landmarks);
},
close() {
pose.close?.();
},
};
}
async function importMediaPipeTasksVision() {
const failures = [];
for (const moduleUrl of MEDIAPIPE_TASKS_URLS) {
try {
const module = await import(moduleUrl);
if (module?.FilesetResolver && module?.PoseLandmarker) {
return module;
}
failures.push(`${moduleUrl}: missing MediaPipe exports`);
} catch (error) {
failures.push(`${moduleUrl}: ${error?.message || String(error)}`);
}
}
const error = new Error("无法加载 MediaPipe Tasks Vision 模块,请检查网络或切换 Motion Agent");
error.details = failures;
throw error;
}
export function createBrowserCameraProvider(options = {}) {
const {
mediaDevices = typeof navigator !== "undefined" ? navigator.mediaDevices : null,
recognizerFactory = createDefaultRecognizer,
requestAnimationFrameFn =
typeof requestAnimationFrame !== "undefined"
? requestAnimationFrame.bind(globalThis)
: (callback) => setTimeout(() => callback(nowMs()), 16),
cancelAnimationFrameFn =
typeof cancelAnimationFrame !== "undefined"
? cancelAnimationFrame.bind(globalThis)
: clearTimeout,
onMessage = () => {},
onState = () => {},
onStatus = () => {},
onVideoSource = () => {},
} = options;
let disposed = false;
let connected = false;
let stream = null;
let video = null;
let recognizer = null;
let rafId = null;
let lastFrameAt = 0;
let lastGestureAt = 0;
let seq = 0;
let previousJoints = [];
const gestureState = {};
function emitState(detail = {}) {
onState({
provider: "browser_camera",
connected,
...detail,
});
}
function emitStatus(message, type = "info", extra = {}) {
onStatus(message, type);
emitState({ message, ...extra });
}
function stopStream() {
onVideoSource({
provider: "browser_camera",
source: null,
active: false,
});
if (stream) {
stream.getTracks?.().forEach((track) => track.stop?.());
stream = null;
}
if (video) {
video.pause?.();
video.srcObject = null;
video.remove?.();
video = null;
}
}
function emitSkeleton(joints, matchedGesture = null, confidence = 0) {
onMessage({
type: "skeleton",
timestamp_ms: wallClockMs(),
source: "browser-camera",
mode: "single",
camera_id: "browser:getUserMedia",
matched_gesture: matchedGesture,
confidence,
joints,
bones: POSE_BONES,
});
}
function emitGesture(observation) {
seq += 1;
onMessage({
type: "gesture",
gesture: observation.gesture,
phase: "discrete",
confidence: observation.confidence,
intensity: observation.intensity,
timestamp_ms: wallClockMs(),
seq,
source: "browser-camera",
mode: "single",
payload: {},
});
}
function scheduleFrame() {
if (disposed) return;
rafId = requestAnimationFrameFn(processFrame);
}
function processFrame(timestamp) {
if (disposed || !video || !recognizer) return;
if (timestamp - lastFrameAt < FRAME_INTERVAL_MS) {
scheduleFrame();
return;
}
lastFrameAt = timestamp;
try {
const joints = recognizer.recognize(video, timestamp) || [];
const currentWallMs = wallClockMs();
const observation = recognizeGesture(joints, previousJoints, {
state: gestureState,
timestampMs: currentWallMs,
});
const canEmitGesture = observation && currentWallMs - lastGestureAt >= GESTURE_COOLDOWN_MS;
if (canEmitGesture) {
lastGestureAt = currentWallMs;
emitGesture(observation);
}
emitSkeleton(
joints,
observation?.gesture || null,
observation?.confidence || 0,
);
previousJoints = joints;
} catch (error) {
emitStatus(`浏览器动捕识别失败: ${error?.message || String(error)}`, "error", {
error: "recognition_failed",
});
}
scheduleFrame();
}
async function startCamera() {
if (!canUseBrowserCamera(mediaDevices)) {
emitStatus("当前浏览器不支持 getUserMedia 摄像头接口", "error", {
error: "get_user_media_unavailable",
});
return false;
}
if (!isSecureCameraContext()) {
emitStatus("浏览器摄像头需要 HTTPS 或 localhost 环境", "error", {
error: "insecure_context",
});
return false;
}
try {
recognizer = await recognizerFactory();
} catch (error) {
connected = false;
emitStatus(`浏览器动捕模型加载失败: ${error?.message || String(error)}`, "error", {
error: "model_load_failed",
});
return false;
}
try {
stream = await mediaDevices.getUserMedia(CAMERA_CONSTRAINTS);
video = document.createElement("video");
video.muted = true;
video.playsInline = true;
video.autoplay = true;
video.style.display = "none";
video.srcObject = stream;
document.body.appendChild(video);
await video.play();
await waitForVideoMetadata(video);
onVideoSource({
provider: "browser_camera",
source: video,
active: true,
});
} catch (error) {
connected = false;
recognizer?.close?.();
recognizer = null;
stopStream();
emitStatus(getUserMediaErrorMessage(error), "error", {
error: "browser_camera_failed",
});
return false;
}
connected = true;
emitStatus("浏览器摄像头动捕已连接", "info");
scheduleFrame();
return true;
}
return {
provider: "browser_camera",
async start() {
if (disposed) return false;
return startCamera();
},
stop() {
disposed = true;
if (rafId) {
cancelAnimationFrameFn(rafId);
rafId = null;
}
recognizer?.close?.();
recognizer = null;
stopStream();
connected = false;
emitState({ connected: false });
},
isConnected() {
return connected;
},
};
}
export {
CAMERA_CONSTRAINTS,
importMediaPipeTasksVision,
POSE_BONES,
recognizeGesture,
};

View File

@@ -0,0 +1,257 @@
import {
createMotionAgentProvider,
DEFAULT_AGENT_URL,
} from "./motion-agent-provider.js";
import { createBrowserCameraProvider } from "./motion-browser-provider.js";
import {
DEFAULT_MOTION_PROVIDER,
MOTION_PROVIDER_AGENT,
normalizeGestureMessage,
normalizeMotionProvider,
normalizeSkeletonMessage,
} from "./motion-protocol.js";
import {
MOTION_CONTROL_STATE_EVENT,
MOTION_DEBUG_FRAME_EVENT,
MOTION_DEBUG_VIDEO_SOURCE_EVENT,
MOTION_RECOGNITION_PAUSE_EVENT,
} from "./motion-events.js";
const DEFAULT_MIN_CONFIDENCE = 0.72;
const DEFAULT_COOLDOWN_MS = 120;
const DEFAULT_FOCUS_COOLDOWN_MS = 900;
const DEFAULT_LAYER_COOLDOWN_MS = 1400;
const DEFAULT_CONFIRM_COOLDOWN_MS = 1200;
const ENABLED_STORAGE_KEY = "planet-earth-motion-control-enabled";
const URL_STORAGE_KEY = "planet-earth-motion-control-url";
const GESTURE_POLICIES = {
rotate_left: { group: "rotate_left", cooldownMs: DEFAULT_COOLDOWN_MS },
rotate_right: { group: "rotate_right", cooldownMs: DEFAULT_COOLDOWN_MS },
rotate_up: { group: "rotate_up", cooldownMs: DEFAULT_COOLDOWN_MS },
rotate_down: { group: "rotate_down", cooldownMs: DEFAULT_COOLDOWN_MS },
zoom_in: { group: "zoom_in", cooldownMs: DEFAULT_COOLDOWN_MS },
zoom_out: { group: "zoom_out", cooldownMs: DEFAULT_COOLDOWN_MS },
focus_prev: { group: "focus", cooldownMs: DEFAULT_FOCUS_COOLDOWN_MS },
focus_next: { group: "focus", cooldownMs: DEFAULT_FOCUS_COOLDOWN_MS },
layer_prev: { group: "layer", cooldownMs: DEFAULT_LAYER_COOLDOWN_MS },
layer_next: { group: "layer", cooldownMs: DEFAULT_LAYER_COOLDOWN_MS },
confirm: { group: "confirm", cooldownMs: DEFAULT_CONFIRM_COOLDOWN_MS },
};
function getSearchParams() {
if (typeof window === "undefined") return new URLSearchParams();
return new URLSearchParams(window.location.search || "");
}
function dispatchWindowEvent(name, detail) {
if (typeof window === "undefined") return;
if (name === MOTION_DEBUG_VIDEO_SOURCE_EVENT) {
window.__earthMotionDebugVideoSource = detail;
}
window.dispatchEvent(new CustomEvent(name, { detail }));
}
function getConfiguredAgentUrl() {
if (typeof window === "undefined") return DEFAULT_AGENT_URL;
const search = getSearchParams();
const queryUrl = search.get("motionAgent");
if (queryUrl) return queryUrl;
const storedUrl = window.localStorage?.getItem(URL_STORAGE_KEY);
return storedUrl || DEFAULT_AGENT_URL;
}
export function getConfiguredMotionProvider(fallback = DEFAULT_MOTION_PROVIDER) {
const search = getSearchParams();
if (search.get("motionAgent")) return MOTION_PROVIDER_AGENT;
const queryProvider = search.get("motionProvider");
if (queryProvider) return normalizeMotionProvider(queryProvider, fallback);
return normalizeMotionProvider(fallback, DEFAULT_MOTION_PROVIDER);
}
export function shouldEnableMotionControl() {
if (typeof window === "undefined") return false;
const search = getSearchParams();
if (search.get("motion") === "1") return true;
if (search.get("motion") === "0") return false;
return window.localStorage?.getItem(ENABLED_STORAGE_KEY) === "true";
}
export function createMotionControlAdapter(options = {}) {
const {
enabled = false,
provider = DEFAULT_MOTION_PROVIDER,
url = getConfiguredAgentUrl(),
minConfidence = DEFAULT_MIN_CONFIDENCE,
cooldownMs = DEFAULT_COOLDOWN_MS,
providerFactories = {},
WebSocketCtor = typeof WebSocket !== "undefined" ? WebSocket : null,
onRotate = () => false,
onZoom = () => false,
onConfirm = () => false,
onFocus = () => false,
onLayer = () => false,
onSkeleton = () => {},
onStatus = () => {},
onVideoSource = (detail) => dispatchWindowEvent(MOTION_DEBUG_VIDEO_SOURCE_EVENT, detail),
nowFn = () => Date.now(),
} = options;
const selectedProvider = getConfiguredMotionProvider(provider);
let disposed = false;
let activeProvider = null;
let connected = false;
let recognitionPaused = false;
const lastHandledByGestureGroup = new Map();
function emitState(detail) {
connected = Boolean(detail?.connected);
dispatchWindowEvent(MOTION_CONTROL_STATE_EVENT, {
provider: selectedProvider,
connected,
recognitionPaused,
...detail,
});
}
function setRecognitionPaused(nextPaused) {
recognitionPaused = Boolean(nextPaused);
emitState({ provider: selectedProvider, connected });
}
function shouldHandleGesture(event) {
if (!event || event.confidence < minConfidence) return false;
const policy = GESTURE_POLICIES[event.gesture] || {
group: event.gesture,
cooldownMs,
};
const effectiveCooldownMs =
policy.cooldownMs === DEFAULT_COOLDOWN_MS ? cooldownMs : policy.cooldownMs;
const now = nowFn();
const last = lastHandledByGestureGroup.get(policy.group);
if (last !== undefined && now - last < effectiveCooldownMs) return false;
lastHandledByGestureGroup.set(policy.group, now);
return true;
}
function handleGesture(event) {
if (recognitionPaused) return;
if (!shouldHandleGesture(event)) return;
if (event.gesture === "rotate_left" || event.gesture === "rotate_right") {
onRotate("horizontal", event.gesture === "rotate_left" ? "left" : "right", event.intensity, event);
} else if (event.gesture === "rotate_up" || event.gesture === "rotate_down") {
onRotate("vertical", event.gesture === "rotate_up" ? "up" : "down", event.intensity, event);
} else if (event.gesture === "zoom_in") {
onZoom("in", event.intensity, event);
} else if (event.gesture === "zoom_out") {
onZoom("out", event.intensity, event);
} else if (event.gesture === "focus_prev") {
onFocus("prev", event);
} else if (event.gesture === "focus_next") {
onFocus("next", event);
} else if (event.gesture === "layer_prev") {
onLayer("prev", event);
} else if (event.gesture === "layer_next") {
onLayer("next", event);
} else if (event.gesture === "confirm") {
onConfirm(event);
}
}
function handleProviderMessage(rawMessage) {
let data = rawMessage;
if (typeof rawMessage === "string") {
try {
data = JSON.parse(rawMessage);
} catch (_error) {
return;
}
}
const gesture = normalizeGestureMessage(data, selectedProvider);
if (gesture) {
handleGesture(gesture);
return;
}
const skeleton = normalizeSkeletonMessage(data, selectedProvider);
if (skeleton) {
const nextSkeleton = recognitionPaused
? { ...skeleton, matchedGesture: null, confidence: 0 }
: skeleton;
onSkeleton(nextSkeleton);
dispatchWindowEvent(MOTION_DEBUG_FRAME_EVENT, nextSkeleton);
return;
}
if (data?.type === "status" || data?.type === "heartbeat") {
emitState({ provider: selectedProvider, connected, message: data });
}
}
function createProvider() {
const sharedOptions = {
onMessage: handleProviderMessage,
onState: emitState,
onStatus,
onVideoSource,
};
if (providerFactories[selectedProvider]) {
return providerFactories[selectedProvider]({
...sharedOptions,
url,
WebSocketCtor,
});
}
if (selectedProvider === MOTION_PROVIDER_AGENT) {
return createMotionAgentProvider({
...sharedOptions,
url,
WebSocketCtor,
});
}
return createBrowserCameraProvider(sharedOptions);
}
return {
start() {
if (!enabled || disposed) return false;
if (typeof window !== "undefined") {
window.addEventListener(MOTION_RECOGNITION_PAUSE_EVENT, handleRecognitionPause);
}
activeProvider = createProvider();
const result = activeProvider.start();
emitState({ provider: selectedProvider, connected: activeProvider.isConnected?.() || false });
return result;
},
stop() {
disposed = true;
if (typeof window !== "undefined") {
window.removeEventListener(MOTION_RECOGNITION_PAUSE_EVENT, handleRecognitionPause);
}
activeProvider?.stop?.();
activeProvider = null;
connected = false;
emitState({ provider: selectedProvider, connected: false });
},
isConnected() {
return Boolean(activeProvider?.isConnected?.());
},
getProvider() {
return selectedProvider;
},
handleMessage: handleProviderMessage,
};
function handleRecognitionPause(event) {
setRecognitionPaused(event?.detail?.paused === true);
}
}
export {
DEFAULT_AGENT_URL,
DEFAULT_MOTION_PROVIDER,
normalizeGestureMessage,
normalizeSkeletonMessage,
normalizeMotionProvider,
};

View File

@@ -0,0 +1,624 @@
import { describe, expect, test } from "bun:test";
import {
createMotionControlAdapter,
normalizeMotionProvider,
} from "./motion-control.js";
import {
createBrowserCameraProvider,
recognizeGesture,
} from "./motion-browser-provider.js";
class TestEventTarget {
constructor() {
this.events = [];
this.listeners = new Map();
}
dispatchEvent(event) {
this.events.push(event);
(this.listeners.get(event.type) || []).forEach((listener) => listener(event));
return true;
}
addEventListener(type, listener) {
const listeners = this.listeners.get(type) || [];
listeners.push(listener);
this.listeners.set(type, listeners);
}
removeEventListener(type, listener) {
const listeners = this.listeners.get(type) || [];
this.listeners.set(type, listeners.filter((item) => item !== listener));
}
}
function installWindow(search = "") {
const target = new TestEventTarget();
globalThis.CustomEvent = class CustomEvent {
constructor(type, options = {}) {
this.type = type;
this.detail = options.detail;
}
};
globalThis.window = {
location: {
search,
hostname: "localhost",
},
isSecureContext: true,
localStorage: {
getItem() {
return null;
},
},
dispatchEvent: target.dispatchEvent.bind(target),
addEventListener: target.addEventListener.bind(target),
removeEventListener: target.removeEventListener.bind(target),
};
return target;
}
function installDocument() {
const tracks = [];
globalThis.document = {
body: {
appendChild() {},
},
createElement(tagName) {
expect(tagName).toBe("video");
return {
muted: false,
playsInline: false,
autoplay: false,
readyState: 4,
style: {},
srcObject: null,
videoWidth: 640,
videoHeight: 360,
addEventListener() {},
play: () => Promise.resolve(),
pause() {},
remove() {},
};
},
};
return tracks;
}
describe("motion-control provider manager", () => {
test("normalizes browser and agent provider aliases", () => {
expect(normalizeMotionProvider("browser")).toBe("browser_camera");
expect(normalizeMotionProvider("agent")).toBe("motion_agent");
expect(normalizeMotionProvider("motion_agent")).toBe("motion_agent");
expect(normalizeMotionProvider("unknown")).toBe("browser_camera");
});
test("mock skeleton message dispatches debug frame event", () => {
const events = installWindow();
const adapter = createMotionControlAdapter({
enabled: true,
providerFactories: {
browser_camera: ({ onMessage, onState }) => ({
start() {
onState({ connected: true });
onMessage({
type: "skeleton",
timestamp_ms: 1000,
source: "test",
joints: [{ id: "left_wrist", x: 0.2, y: 0.3, confidence: 1 }],
bones: [],
});
return true;
},
stop() {},
isConnected: () => true,
}),
},
});
adapter.start();
const debugEvent = events.events.find((event) => event.type === "earth:motion-debug-frame");
expect(debugEvent?.detail.joints[0]).toEqual({
id: "left_wrist",
x: 0.2,
y: 0.3,
confidence: 1,
});
});
test("mock gesture message maps to Earth control callback", () => {
installWindow();
const rotations = [];
const adapter = createMotionControlAdapter({
enabled: true,
onRotate: (...args) => rotations.push(args),
providerFactories: {
browser_camera: ({ onMessage }) => ({
start() {
onMessage({
type: "gesture",
gesture: "rotate_left",
confidence: 0.91,
intensity: 0.5,
});
return true;
},
stop() {},
isConnected: () => true,
}),
},
});
adapter.start();
expect(rotations[0][0]).toBe("horizontal");
expect(rotations[0][1]).toBe("left");
expect(rotations[0][2]).toBe(0.5);
});
test("recognition pause suppresses gestures and matched skeleton state", () => {
const events = installWindow();
const rotations = [];
const adapter = createMotionControlAdapter({
enabled: true,
cooldownMs: 0,
onRotate: (...args) => rotations.push(args),
providerFactories: {
browser_camera: () => ({
start() {
return true;
},
stop() {},
isConnected: () => true,
}),
},
});
adapter.start();
window.dispatchEvent(new CustomEvent("earth:motion-recognition-pause", {
detail: { paused: true },
}));
adapter.handleMessage({
type: "gesture",
gesture: "rotate_left",
confidence: 0.91,
intensity: 0.5,
});
adapter.handleMessage({
type: "skeleton",
matched_gesture: "rotate_left",
confidence: 0.91,
joints: [],
bones: [],
});
const debugEvent = events.events.findLast?.((event) => event.type === "earth:motion-debug-frame") ||
events.events.filter((event) => event.type === "earth:motion-debug-frame").at(-1);
expect(rotations).toHaveLength(0);
expect(debugEvent?.detail.matchedGesture).toBeNull();
expect(debugEvent?.detail.confidence).toBe(0);
});
test("mock vertical gesture and focus gesture use dedicated callbacks", () => {
installWindow();
const rotations = [];
const focuses = [];
const layers = [];
const adapter = createMotionControlAdapter({
enabled: true,
cooldownMs: 0,
onRotate: (...args) => rotations.push(args),
onFocus: (...args) => focuses.push(args),
onLayer: (...args) => layers.push(args),
providerFactories: {
browser_camera: ({ onMessage }) => ({
start() {
onMessage({
type: "gesture",
gesture: "rotate_up",
confidence: 0.91,
intensity: 0.6,
});
onMessage({
type: "gesture",
gesture: "focus_next",
confidence: 0.91,
intensity: 0.8,
});
onMessage({
type: "gesture",
gesture: "layer_next",
confidence: 0.91,
intensity: 0.8,
});
return true;
},
stop() {},
isConnected: () => true,
}),
},
});
adapter.start();
expect(rotations[0][0]).toBe("vertical");
expect(rotations[0][1]).toBe("up");
expect(rotations[0][2]).toBe(0.6);
expect(focuses[0][0]).toBe("next");
expect(layers[0][0]).toBe("next");
});
test("continuous rotate can retrigger after the short cooldown", () => {
installWindow();
let now = 1000;
const rotations = [];
const adapter = createMotionControlAdapter({
enabled: true,
nowFn: () => now,
onRotate: (...args) => rotations.push(args),
providerFactories: {
browser_camera: () => ({
start() {
return true;
},
stop() {},
isConnected: () => true,
}),
},
});
adapter.start();
adapter.handleMessage({ type: "gesture", gesture: "rotate_right", confidence: 0.91 });
now += 60;
adapter.handleMessage({ type: "gesture", gesture: "rotate_right", confidence: 0.91 });
now += 70;
adapter.handleMessage({ type: "gesture", gesture: "rotate_right", confidence: 0.91 });
expect(rotations).toHaveLength(2);
});
test("focus gestures share one 900ms cooldown group", () => {
installWindow();
let now = 1000;
const focuses = [];
const adapter = createMotionControlAdapter({
enabled: true,
nowFn: () => now,
onFocus: (...args) => focuses.push(args),
providerFactories: {
browser_camera: () => ({
start() {
return true;
},
stop() {},
isConnected: () => true,
}),
},
});
adapter.start();
adapter.handleMessage({ type: "gesture", gesture: "focus_next", confidence: 0.91 });
now += 100;
adapter.handleMessage({ type: "gesture", gesture: "focus_prev", confidence: 0.91 });
now += 900;
adapter.handleMessage({ type: "gesture", gesture: "focus_prev", confidence: 0.91 });
expect(focuses.map((args) => args[0])).toEqual(["next", "prev"]);
});
test("layer gestures share one 1400ms cooldown group", () => {
installWindow();
let now = 1000;
const layers = [];
const adapter = createMotionControlAdapter({
enabled: true,
nowFn: () => now,
onLayer: (...args) => layers.push(args),
providerFactories: {
browser_camera: () => ({
start() {
return true;
},
stop() {},
isConnected: () => true,
}),
},
});
adapter.start();
adapter.handleMessage({ type: "gesture", gesture: "layer_next", confidence: 0.91 });
now += 1200;
adapter.handleMessage({ type: "gesture", gesture: "layer_next", confidence: 0.91 });
now += 200;
adapter.handleMessage({ type: "gesture", gesture: "layer_prev", confidence: 0.91 });
expect(layers.map((args) => args[0])).toEqual(["next", "prev"]);
});
test("confirm uses a 1200ms cooldown", () => {
installWindow();
let now = 1000;
const confirms = [];
const adapter = createMotionControlAdapter({
enabled: true,
nowFn: () => now,
onConfirm: (...args) => confirms.push(args),
providerFactories: {
browser_camera: () => ({
start() {
return true;
},
stop() {},
isConnected: () => true,
}),
},
});
adapter.start();
adapter.handleMessage({ type: "gesture", gesture: "confirm", confidence: 0.91 });
now += 1000;
adapter.handleMessage({ type: "gesture", gesture: "confirm", confidence: 0.91 });
now += 200;
adapter.handleMessage({ type: "gesture", gesture: "confirm", confidence: 0.91 });
expect(confirms).toHaveLength(2);
});
});
function createPoseJoints(overrides = {}) {
const base = {
left_ear: { id: "left_ear", x: 0.45, y: 0.2, confidence: 1 },
right_ear: { id: "right_ear", x: 0.55, y: 0.2, confidence: 1 },
left_shoulder: { id: "left_shoulder", x: 0.42, y: 0.5, confidence: 1 },
right_shoulder: { id: "right_shoulder", x: 0.58, y: 0.5, confidence: 1 },
left_elbow: { id: "left_elbow", x: 0.4, y: 0.62, confidence: 1 },
right_elbow: { id: "right_elbow", x: 0.6, y: 0.62, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.42, y: 0.65, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.58, y: 0.65, confidence: 1 },
};
return Object.values({
...base,
...overrides,
});
}
describe("browser camera gesture semantics", () => {
test("right-arm left-facing pattern maps to rotate_right", () => {
const current = createPoseJoints({
right_elbow: { id: "right_elbow", x: 0.5, y: 0.5, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.4, y: 0.53, confidence: 1 },
});
expect(recognizeGesture(current, current)?.gesture).toBe("rotate_right");
});
test("right-arm upward terminal pattern maps to rotate_up", () => {
const current = createPoseJoints({
right_elbow: { id: "right_elbow", x: 0.68, y: 0.5, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.71, y: 0.39, confidence: 1 },
});
expect(recognizeGesture(current, current)?.gesture).toBe("rotate_up");
});
test("right-arm terminal can float about 30 degrees while matching horizontal wave", () => {
const current = createPoseJoints({
right_elbow: { id: "right_elbow", x: 0.5, y: 0.5, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.39, y: 0.56, confidence: 1 },
});
expect(recognizeGesture(current, current)?.gesture).toBe("rotate_right");
});
test("left-hand vertical movement switches motion layer", () => {
const previous = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.42, y: 0.48, confidence: 1 },
});
const current = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.42, y: 0.38, confidence: 1 },
});
expect(recognizeGesture(current, previous)?.gesture).toBe("layer_prev");
});
test("centered close hands no longer trigger confirm", () => {
const current = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.48, y: 0.6, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.52, y: 0.6, confidence: 1 },
});
expect(recognizeGesture(current, current)).toBeNull();
});
test("head tilt emits focus navigation gesture", () => {
const current = createPoseJoints({
left_ear: { id: "left_ear", x: 0.45, y: 0.24, confidence: 1 },
right_ear: { id: "right_ear", x: 0.55, y: 0.18, confidence: 1 },
});
expect(recognizeGesture(current, current)?.gesture).toBe("focus_prev");
});
test("holding right hand high does not continuously rotate", () => {
const current = createPoseJoints({
right_wrist: { id: "right_wrist", x: 0.58, y: 0.34, confidence: 1 },
});
expect(recognizeGesture(current, current)).toBeNull();
});
test("holding the same pattern only emits once until neutral", () => {
const state = {};
const pattern = createPoseJoints({
right_elbow: { id: "right_elbow", x: 0.5, y: 0.5, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.4, y: 0.5, confidence: 1 },
});
const neutral = createPoseJoints();
expect(recognizeGesture(pattern, neutral, { state })?.gesture).toBe("rotate_right");
expect(recognizeGesture(pattern, pattern, { state })).toBeNull();
expect(recognizeGesture(neutral, pattern, { state })).toBeNull();
expect(recognizeGesture(pattern, neutral, { state })?.gesture).toBe("rotate_right");
});
test("hands resting below the shoulders do not rotate down", () => {
const current = createPoseJoints({
right_wrist: { id: "right_wrist", x: 0.58, y: 0.82, confidence: 1 },
});
expect(recognizeGesture(current, current)).toBeNull();
});
test("moving left hand near the chest does not trigger zoom out", () => {
const previous = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.38, y: 0.62, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.55, y: 0.62, confidence: 1 },
});
const current = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.49, y: 0.62, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.55, y: 0.62, confidence: 1 },
});
expect(recognizeGesture(current, previous)).toBeNull();
});
test("two hands opening from center trigger zoom in", () => {
const current = 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(current, current)?.gesture).toBe("zoom_in");
});
test("two hands opening with tilted wrists still trigger zoom in", () => {
const current = createPoseJoints({
left_elbow: { id: "left_elbow", x: 0.37, y: 0.51, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.31, y: 0.46, confidence: 1 },
right_elbow: { id: "right_elbow", x: 0.63, y: 0.51, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.69, y: 0.58, confidence: 1 },
});
expect(recognizeGesture(current, current)?.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 },
left_wrist: { id: "left_wrist", x: 0.35, y: 0.58, confidence: 1 },
right_elbow: { id: "right_elbow", x: 0.64, y: 0.5, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.7, y: 0.39, confidence: 1 },
});
expect(recognizeGesture(current, current)).toBeNull();
});
test("two hands closing toward center trigger zoom out", () => {
const current = 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(current, current)?.gesture).toBe("zoom_out");
});
});
describe("browser camera provider", () => {
test("starts with mocked getUserMedia and emits active state", async () => {
installWindow();
installDocument();
const stopped = [];
const states = [];
const videoSources = [];
const provider = createBrowserCameraProvider({
mediaDevices: {
getUserMedia: () =>
Promise.resolve({
getTracks: () => [{ stop: () => stopped.push("camera") }],
}),
},
recognizerFactory: () =>
Promise.resolve({
recognize: () => [],
close() {},
}),
requestAnimationFrameFn: () => 0,
onState: (state) => states.push(state),
onVideoSource: (source) => videoSources.push(source),
});
await provider.start();
provider.stop();
expect(states.some((state) => state.connected === true)).toBe(true);
expect(stopped).toEqual(["camera"]);
expect(videoSources.some((source) => source.active === true)).toBe(true);
expect(videoSources.at(-1)).toEqual({
provider: "browser_camera",
source: null,
active: false,
});
});
test("reports permission errors without falling back to dry-run", async () => {
installWindow();
installDocument();
const statuses = [];
const closed = [];
const provider = createBrowserCameraProvider({
mediaDevices: {
getUserMedia: () =>
Promise.reject(Object.assign(new Error("denied"), { name: "NotAllowedError" })),
},
recognizerFactory: () =>
Promise.resolve({
recognize: () => [],
close: () => closed.push("recognizer"),
}),
onStatus: (message, type) => statuses.push({ message, type }),
});
const started = await provider.start();
expect(started).toBe(false);
expect(statuses[0]).toEqual({
message: "浏览器摄像头权限被拒绝",
type: "error",
});
expect(closed).toEqual(["recognizer"]);
});
test("reports model load errors separately from camera permission", async () => {
installWindow();
installDocument();
const statuses = [];
const requested = [];
const provider = createBrowserCameraProvider({
mediaDevices: {
getUserMedia: () => {
requested.push("camera");
return Promise.resolve({
getTracks: () => [],
});
},
},
recognizerFactory: () => Promise.reject(new Error("model unavailable")),
onStatus: (message, type) => statuses.push({ message, type }),
});
const started = await provider.start();
expect(started).toBe(false);
expect(requested).toEqual([]);
expect(statuses[0]).toEqual({
message: "浏览器动捕模型加载失败: model unavailable",
type: "error",
});
});
});

View File

@@ -0,0 +1,186 @@
import { CONNECTOR_CONFIG, CRUISE_CONFIG } from "./constants.js";
const MOTION_CONNECTOR_READY_TIMEOUT_MS = 1200;
const MOTION_CONNECTOR_DRAW_MS = 420;
const MOTION_PRESENTATION_HIDE_MS = 220;
const MOTION_CARD_ESTIMATED_WIDTH_PX = 300;
const MOTION_CARD_ESTIMATED_HEIGHT_PX = 420;
const MOTION_CARD_SCREEN_MARGIN_PX = 12;
const MOTION_MOBILE_POPUP_ESTIMATED_WIDTH_PX = 220;
const MOTION_MOBILE_POPUP_ESTIMATED_HEIGHT_PX = 68;
const MOTION_MOBILE_POPUP_TOP_RATIO = 0.17;
const MOTION_MOBILE_POPUP_MARGIN_PX = 14;
function getMotionCardScreenCoords() {
if (document.body.classList.contains("layout-mode-mobile")) {
const estimatedWidth = Math.min(
MOTION_MOBILE_POPUP_ESTIMATED_WIDTH_PX,
window.innerWidth - MOTION_MOBILE_POPUP_MARGIN_PX * 2,
);
const safeBottom =
Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--safe-bottom")) || 0;
const y = Math.max(
MOTION_MOBILE_POPUP_MARGIN_PX,
Math.min(
window.innerHeight * MOTION_MOBILE_POPUP_TOP_RATIO,
window.innerHeight -
safeBottom -
MOTION_MOBILE_POPUP_ESTIMATED_HEIGHT_PX -
MOTION_MOBILE_POPUP_MARGIN_PX,
),
);
return {
x: Math.max(MOTION_MOBILE_POPUP_MARGIN_PX, window.innerWidth - estimatedWidth - MOTION_MOBILE_POPUP_MARGIN_PX),
y,
width: estimatedWidth,
height: MOTION_MOBILE_POPUP_ESTIMATED_HEIGHT_PX,
dockSide: "left",
};
}
const hudScale =
Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--hud-scale")) || 1;
const width = Math.min(MOTION_CARD_ESTIMATED_WIDTH_PX * hudScale, window.innerWidth - 32);
const height = Math.min(MOTION_CARD_ESTIMATED_HEIGHT_PX * hudScale, window.innerHeight * 0.7);
const x = window.innerWidth * CRUISE_CONFIG.cardAnchorXRatio - width * 0.5;
const y = window.innerHeight * CRUISE_CONFIG.cardAnchorYRatio - height * 0.5;
return {
x: Math.min(Math.max(MOTION_CARD_SCREEN_MARGIN_PX, x), window.innerWidth - width - MOTION_CARD_SCREEN_MARGIN_PX),
y: Math.min(Math.max(MOTION_CARD_SCREEN_MARGIN_PX, y), window.innerHeight - height - MOTION_CARD_SCREEN_MARGIN_PX),
width,
height,
};
}
function getMotionInfoOptions({ reveal = true } = {}) {
const placement = getMotionCardScreenCoords();
return {
x: placement.x,
y: placement.y,
absolute: true,
reveal,
anchorStable: true,
dockSide: placement.dockSide,
};
}
export function createMotionCruiseAdapter({
presentationController,
focusView,
getItems = () => [],
getItemId = (item) => item?.id || null,
resolveLatestItem = (item) => item,
getCandidateAnchor,
getCandidateFocusCoords,
showCandidateInfo,
hideInfo,
clearCandidateVisual,
}) {
let currentItemId = null;
function getCurrentItem() {
if (!currentItemId) return null;
return getItems().find((item) => getItemId(item) === currentItemId) || null;
}
function getCandidate(item) {
return item?.payload || item || null;
}
function getCandidateId(candidate) {
return (
candidate?.id ||
candidate?.object?.userData?.id ||
candidate?.object?.userData?.collector ||
candidate?.object?.userData?.mmsi ||
candidate?.object?.userData?.name ||
(Number.isInteger(candidate?.index) ? `satellite:${candidate.index}` : null)
);
}
function getVisibleCardTarget() {
const mobileTarget = document.getElementById("earth-mobile-popup");
const visibleMobileTarget =
mobileTarget instanceof HTMLElement && !mobileTarget.hasAttribute("hidden")
? mobileTarget
: null;
if (visibleMobileTarget) return visibleMobileTarget;
const infoPanel = document.getElementById("info-panel");
return infoPanel instanceof HTMLElement && !infoPanel.hasAttribute("hidden")
? infoPanel
: null;
}
return {
getSortedItems() {
return getItems();
},
getCurrentItem,
clearCurrentHighlight() {
const candidate = getCandidate(getCurrentItem());
currentItemId = null;
clearCandidateVisual?.(candidate);
},
async focusItem(item, { interrupt = false } = {}) {
const candidate = getCandidate(item);
currentItemId = getItemId(item) || getCandidateId(candidate);
const coords = getCandidateFocusCoords?.(candidate);
if (!coords) return;
await focusView({
lat: coords.lat,
lon: coords.lon,
rotLon: coords.lon - 270,
zoom: Math.max(coords.zoom || 1.08, 1.08),
duration: interrupt
? Math.round(CRUISE_CONFIG.focusDurationMs * 0.58)
: CRUISE_CONFIG.focusDurationMs,
suppressStatus: true,
});
},
async presentItem(item, { context }) {
const latestItem = resolveLatestItem(item) || item;
const candidate = getCandidate(latestItem);
if (!candidate) return false;
return presentationController.present(
{
id: getItemId(latestItem) || getCandidateId(candidate),
owner: "motion",
card: {
render: ({ reveal = true } = {}) =>
showCandidateInfo(candidate, getMotionInfoOptions({ reveal })),
hide: () => hideInfo?.(),
},
connector: {
enabled: true,
sourceProvider: () => getCandidateAnchor?.(getCandidate(resolveLatestItem(latestItem) || latestItem)),
targetProvider: getVisibleCardTarget,
options: {
routingMode: "adaptive",
sourceGapPx: 0,
targetGapPx: CONNECTOR_CONFIG.panelGapPx,
obstacleClearancePx: CONNECTOR_CONFIG.obstacleClearancePx,
},
readyTimeoutMs: MOTION_CONNECTOR_READY_TIMEOUT_MS,
drawMs: MOTION_CONNECTOR_DRAW_MS,
},
lifetime: { mode: "persistent" },
},
{ context },
);
},
async hidePresentation({ context }) {
presentationController.dismiss("sequenced_hide");
const hidden = context?.wait
? await context.wait(MOTION_PRESENTATION_HIDE_MS, { secondary: true })
: true;
return hidden;
},
repositionConnector() {
presentationController.update();
},
resetPresentation() {
presentationController.dismiss("owner_stop");
},
};
}

View File

@@ -0,0 +1,355 @@
import {
MOTION_CONTROL_STATE_EVENT,
MOTION_DEBUG_CLOSE_EVENT,
MOTION_DEBUG_FRAME_EVENT,
MOTION_DEBUG_VIDEO_SOURCE_EVENT,
MOTION_RECOGNITION_PAUSE_EVENT,
} from "./motion-events.js";
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 MOBILE_MOUNT_ID = "mobile-motion-debug-mount";
const FALLBACK_CANVAS_WIDTH = 320;
const FALLBACK_CANVAS_HEIGHT = 220;
const MIN_MEASURED_CANVAS_SIZE = 20;
const CANVAS_ASPECT_HEIGHT = 11;
const CANVAS_ASPECT_WIDTH = 16;
const CANVAS_CSS_HEIGHT = "calc(194px * var(--hud-scale))";
const CANVAS_CSS_MIN_HEIGHT = "calc(170px * var(--hud-scale))";
let panel = null;
let canvas = null;
let ctx = null;
let statusEl = null;
let matchEl = null;
let desktopParent = null;
let desktopNextSibling = null;
let visible = false;
let lastFrame = null;
let connected = false;
let provider = "browser_camera";
let videoSource = null;
let previewRafId = null;
let skeletonOnly = false;
let recognitionPaused = false;
let controlsBound = false;
function getProviderLabel(value) {
return value === "motion_agent" ? "Motion Agent" : "浏览器摄像头";
}
function createCanvasElement() {
const nextCanvas = document.createElement("canvas");
nextCanvas.id = DEBUG_CANVAS_ID;
nextCanvas.className = "motion-debug-canvas";
nextCanvas.width = FALLBACK_CANVAS_WIDTH;
nextCanvas.height = FALLBACK_CANVAS_HEIGHT;
return nextCanvas;
}
function getPanelElements() {
panel = panel || document.getElementById(DEBUG_PANEL_ID);
if (panel && !desktopParent) {
desktopParent = panel.parentElement;
desktopNextSibling = panel.nextSibling;
}
if (panel && !document.getElementById(DEBUG_CANVAS_ID)) {
const body = panel.querySelector(".motion-debug-body");
const footer = panel.querySelector(".motion-debug-footer");
if (body instanceof HTMLElement) {
body.insertBefore(createCanvasElement(), footer || null);
}
}
canvas = canvas || document.getElementById(DEBUG_CANVAS_ID);
ctx = ctx || canvas?.getContext?.("2d") || null;
statusEl = statusEl || document.getElementById(DEBUG_STATUS_ID);
matchEl = matchEl || document.getElementById(DEBUG_MATCH_ID);
}
function dispatchWindowEvent(name, detail = {}) {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(name, { detail }));
}
function bindPanelControls() {
if (controlsBound || !(panel instanceof HTMLElement)) return;
controlsBound = true;
const closeButton = panel.querySelector(DEBUG_CLOSE_SELECTOR);
closeButton?.addEventListener?.("click", (event) => {
event.preventDefault();
event.stopPropagation();
dispatchWindowEvent(MOTION_DEBUG_CLOSE_EVENT);
});
const pauseInput = document.getElementById(DEBUG_PAUSE_ID);
pauseInput?.addEventListener?.("change", () => {
recognitionPaused = pauseInput.checked === true;
dispatchWindowEvent(MOTION_RECOGNITION_PAUSE_EVENT, { paused: recognitionPaused });
render();
});
}
function setText(element, value) {
if (element instanceof HTMLElement) {
element.textContent = value;
}
}
function ensurePanelLayout() {
if (canvas instanceof HTMLCanvasElement) {
canvas.style.display = "block";
canvas.style.width = "100%";
canvas.style.height = CANVAS_CSS_HEIGHT;
canvas.style.minHeight = CANVAS_CSS_MIN_HEIGHT;
}
const body = canvas?.closest?.(".motion-debug-body");
if (body instanceof HTMLElement) {
body.style.display = "flex";
body.style.flexDirection = "column";
}
}
function shouldUseMobileMount() {
return Boolean(document.querySelector(".layout-mode-mobile"));
}
function syncPanelMount() {
if (!(panel instanceof HTMLElement)) return;
const mobileMount = document.getElementById(MOBILE_MOUNT_ID);
if (shouldUseMobileMount() && mobileMount instanceof HTMLElement) {
if (panel.parentElement !== mobileMount) {
mobileMount.appendChild(panel);
}
return;
}
if (desktopParent && panel.parentElement !== desktopParent) {
desktopParent.insertBefore(panel, desktopNextSibling);
}
}
function resizeCanvasToDisplaySize() {
if (!(canvas instanceof HTMLCanvasElement)) return;
ensurePanelLayout();
const rect = canvas.getBoundingClientRect();
const scale = window.devicePixelRatio || 1;
const fallbackWidth = canvas.parentElement?.clientWidth || FALLBACK_CANVAS_WIDTH;
const cssWidth = rect.width >= MIN_MEASURED_CANVAS_SIZE ? rect.width : fallbackWidth;
const cssHeight = rect.height >= MIN_MEASURED_CANVAS_SIZE
? rect.height
: Math.max(
FALLBACK_CANVAS_HEIGHT,
Math.round(cssWidth * CANVAS_ASPECT_HEIGHT / CANVAS_ASPECT_WIDTH),
);
const width = Math.max(1, Math.round(cssWidth * scale));
const height = Math.max(1, Math.round(cssHeight * scale));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
}
function clearCanvas(message = "等待动捕数据") {
if (!ctx || !(canvas instanceof HTMLCanvasElement)) return;
resizeCanvasToDisplaySize();
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "rgba(8, 12, 20, 0.82)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "rgba(226, 232, 240, 0.74)";
ctx.font = `${Math.max(13, Math.round(canvas.width * 0.038))}px sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(message, canvas.width / 2, canvas.height / 2);
}
function hasDrawableVideoSource() {
return Boolean(
videoSource &&
typeof videoSource.videoWidth === "number" &&
typeof videoSource.videoHeight === "number" &&
videoSource.videoWidth > 0 &&
videoSource.videoHeight > 0 &&
videoSource.readyState >= 2,
);
}
function hasVideoSource() {
return Boolean(videoSource);
}
function drawVideoSource() {
if (!ctx || !(canvas instanceof HTMLCanvasElement) || !hasDrawableVideoSource()) return false;
const sourceRatio = videoSource.videoWidth / videoSource.videoHeight;
const canvasRatio = canvas.width / canvas.height;
let sourceWidth = videoSource.videoWidth;
let sourceHeight = videoSource.videoHeight;
let sourceX = 0;
let sourceY = 0;
if (sourceRatio > canvasRatio) {
sourceWidth = videoSource.videoHeight * canvasRatio;
sourceX = (videoSource.videoWidth - sourceWidth) / 2;
} else {
sourceHeight = videoSource.videoWidth / canvasRatio;
sourceY = (videoSource.videoHeight - sourceHeight) / 2;
}
ctx.drawImage(
videoSource,
sourceX,
sourceY,
sourceWidth,
sourceHeight,
0,
0,
canvas.width,
canvas.height,
);
ctx.fillStyle = "rgba(4, 8, 14, 0.24)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
return true;
}
function drawFrame(frame = {}) {
if (!ctx || !(canvas instanceof HTMLCanvasElement)) return;
resizeCanvasToDisplaySize();
const matched = Boolean(frame?.matchedGesture);
const color = matched ? "#39e58c" : "#ff4d5f";
const jointMap = new Map((frame?.joints || []).map((joint) => [joint.id, joint]));
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (skeletonOnly || !drawVideoSource()) {
ctx.fillStyle = "rgba(8, 12, 20, 0.82)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
ctx.lineWidth = Math.max(2, canvas.width * 0.008);
ctx.lineCap = "round";
ctx.strokeStyle = color;
ctx.shadowColor = color;
ctx.shadowBlur = 10;
(frame?.bones || []).forEach(([fromId, toId]) => {
const from = jointMap.get(fromId);
const to = jointMap.get(toId);
if (!from || !to) return;
ctx.beginPath();
ctx.moveTo(from.x * canvas.width, from.y * canvas.height);
ctx.lineTo(to.x * canvas.width, to.y * canvas.height);
ctx.stroke();
});
ctx.shadowBlur = 6;
ctx.fillStyle = color;
(frame?.joints || []).forEach((joint) => {
ctx.beginPath();
ctx.arc(joint.x * canvas.width, joint.y * canvas.height, Math.max(4, canvas.width * 0.012), 0, Math.PI * 2);
ctx.fill();
});
ctx.shadowBlur = 0;
}
function stopPreviewLoop() {
if (previewRafId !== null) {
cancelAnimationFrame(previewRafId);
previewRafId = null;
}
}
function shouldAnimatePreview() {
return visible && connected && hasVideoSource();
}
function startPreviewLoop() {
if (previewRafId !== null || !shouldAnimatePreview()) return;
const tick = () => {
previewRafId = null;
if (!shouldAnimatePreview()) return;
drawFrame(lastFrame || {});
previewRafId = requestAnimationFrame(tick);
};
previewRafId = requestAnimationFrame(tick);
}
function render() {
getPanelElements();
if (!panel) return;
syncPanelMount();
ensurePanelLayout();
panel.classList.toggle("hud-panel-hidden", !visible);
panel.classList.toggle("is-motion-matched", Boolean(lastFrame?.matchedGesture));
panel.classList.toggle("is-motion-recognition-paused", recognitionPaused);
const providerLabel = getProviderLabel(provider);
const pauseSuffix = recognitionPaused ? " · 匹配已暂停" : "";
setText(statusEl, connected ? `${providerLabel}已连接${pauseSuffix}` : `${providerLabel}未连接${pauseSuffix}`);
if (!visible) {
stopPreviewLoop();
return;
}
if (lastFrame) {
const action = recognitionPaused ? "匹配已暂停" : (lastFrame.matchedGesture || "未匹配动作");
const confidence = !recognitionPaused && lastFrame.matchedGesture
? ` · ${Math.round((lastFrame.confidence || 0) * 100)}%`
: "";
setText(matchEl, `${action}${confidence}`);
drawFrame(lastFrame);
} else if (hasVideoSource()) {
setText(matchEl, "等待骨架数据");
drawFrame({});
} else {
setText(matchEl, "等待骨架数据");
clearCanvas(connected ? "等待动捕数据" : "动捕未连接");
}
startPreviewLoop();
}
export function initMotionDebugPanel() {
getPanelElements();
bindPanelControls();
const cachedVideoSource = window.__earthMotionDebugVideoSource;
if (cachedVideoSource?.active !== false && cachedVideoSource?.source) {
videoSource = cachedVideoSource.source;
provider = cachedVideoSource.provider || provider;
}
render();
window.addEventListener("resize", render);
window.addEventListener(MOTION_DEBUG_FRAME_EVENT, (event) => {
lastFrame = event.detail || null;
render();
});
window.addEventListener(MOTION_CONTROL_STATE_EVENT, (event) => {
connected = Boolean(event?.detail?.connected);
provider = event?.detail?.provider || provider;
if (!connected) {
videoSource = null;
stopPreviewLoop();
}
render();
});
window.addEventListener(MOTION_DEBUG_VIDEO_SOURCE_EVENT, (event) => {
if (event?.detail?.active === false) {
videoSource = null;
stopPreviewLoop();
} else {
videoSource = event?.detail?.source || null;
provider = event?.detail?.provider || provider;
videoSource?.addEventListener?.("loadedmetadata", render, { once: true });
videoSource?.addEventListener?.("canplay", render, { once: true });
}
render();
});
}
export function setMotionDebugPanelVisible(nextVisible) {
visible = Boolean(nextVisible);
render();
}
export function setMotionDebugPanelSkeletonOnly(nextSkeletonOnly) {
skeletonOnly = Boolean(nextSkeletonOnly);
render();
}

View File

@@ -0,0 +1,5 @@
export const MOTION_CONTROL_STATE_EVENT = "earth:motion-control-state";
export const MOTION_DEBUG_FRAME_EVENT = "earth:motion-debug-frame";
export const MOTION_DEBUG_VIDEO_SOURCE_EVENT = "earth:motion-debug-video-source";
export const MOTION_DEBUG_CLOSE_EVENT = "earth:motion-debug-close";
export const MOTION_RECOGNITION_PAUSE_EVENT = "earth:motion-recognition-pause";

View File

@@ -0,0 +1,93 @@
export const MOTION_PROVIDER_BROWSER = "browser_camera";
export const MOTION_PROVIDER_AGENT = "motion_agent";
export const DEFAULT_MOTION_PROVIDER = MOTION_PROVIDER_BROWSER;
export const MOTION_PROVIDERS = new Set([
MOTION_PROVIDER_BROWSER,
MOTION_PROVIDER_AGENT,
]);
export const MOTION_GESTURES = new Set([
"rotate_left",
"rotate_right",
"rotate_up",
"rotate_down",
"zoom_in",
"zoom_out",
"focus_prev",
"focus_next",
"layer_prev",
"layer_next",
"confirm",
]);
export function clamp01(value, fallback = 1) {
const number = Number(value);
if (!Number.isFinite(number)) return fallback;
return Math.max(0, Math.min(1, number));
}
export function normalizeMotionProvider(value, fallback = DEFAULT_MOTION_PROVIDER) {
const normalized = String(value || "").trim();
if (normalized === "browser") return MOTION_PROVIDER_BROWSER;
if (normalized === "agent") return MOTION_PROVIDER_AGENT;
return MOTION_PROVIDERS.has(normalized) ? normalized : fallback;
}
export function normalizeGestureMessage(raw, fallbackSource = "motion-provider") {
if (!raw || typeof raw !== "object") return null;
if (raw.type !== "gesture") return null;
const gesture = String(raw.gesture || "").trim();
if (!MOTION_GESTURES.has(gesture)) return null;
return {
type: "gesture",
gesture,
phase: raw.phase || "discrete",
confidence: clamp01(raw.confidence, 0),
intensity: clamp01(raw.intensity, 1),
timestampMs: Number(raw.timestamp_ms || raw.timestampMs || Date.now()),
seq: Number(raw.seq || 0),
source: raw.source || fallbackSource,
mode: raw.mode || "single",
payload: raw.payload && typeof raw.payload === "object" ? raw.payload : {},
};
}
export function normalizeSkeletonMessage(raw, fallbackSource = "motion-provider") {
if (!raw || typeof raw !== "object" || raw.type !== "skeleton") return null;
const joints = Array.isArray(raw.joints)
? raw.joints
.map((joint) => {
const id = String(joint?.id || "").trim();
const x = Number(joint?.x);
const y = Number(joint?.y);
if (!id || !Number.isFinite(x) || !Number.isFinite(y)) return null;
return {
id,
x: clamp01(x, 0),
y: clamp01(y, 0),
confidence: clamp01(joint?.confidence, 1),
};
})
.filter(Boolean)
: [];
const bones = Array.isArray(raw.bones)
? raw.bones
.map((bone) => {
if (!Array.isArray(bone) || bone.length < 2) return null;
const from = String(bone[0] || "").trim();
const to = String(bone[1] || "").trim();
return from && to ? [from, to] : null;
})
.filter(Boolean)
: [];
return {
type: "skeleton",
timestampMs: Number(raw.timestamp_ms || raw.timestampMs || Date.now()),
source: raw.source || fallbackSource,
mode: raw.mode || "single",
cameraId: raw.camera_id || raw.cameraId || "unknown",
matchedGesture: raw.matched_gesture || raw.matchedGesture || null,
confidence: clamp01(raw.confidence, 0),
joints,
bones,
};
}

View File

@@ -0,0 +1,183 @@
import { createConnectorPath } from "./callout-connector.js";
const DEFAULT_CONNECTOR_READY_TIMEOUT_MS = 1200;
const DEFAULT_CONNECTOR_DRAW_MS = 420;
function nextAnimationFrame() {
return new Promise((resolve) => {
window.requestAnimationFrame(() => resolve());
});
}
function isCurrentContext(context) {
return !context || typeof context.isCurrent !== "function" || context.isCurrent();
}
function waitForContext(context, durationMs) {
if (context && typeof context.wait === "function") {
return context.wait(durationMs);
}
return new Promise((resolve) => {
window.setTimeout(() => resolve(isCurrentContext(context)), durationMs);
});
}
function getDefaultCardTarget() {
// TODO: replace the singleton card fallback with a presentation/card token check
// before BGP/News migrate here, so connectors only attach to their owning card.
const mobilePopup = document.getElementById("earth-mobile-popup");
if (mobilePopup instanceof HTMLElement && !mobilePopup.hasAttribute("hidden")) {
return mobilePopup;
}
const infoPanel = document.getElementById("info-panel");
if (infoPanel instanceof HTMLElement && !infoPanel.hasAttribute("hidden")) {
return infoPanel;
}
return null;
}
export class PresentationController {
constructor({
connector,
pathFactory = createConnectorPath,
requestAnimationFrame = nextAnimationFrame,
} = {}) {
this.connector = connector;
this.pathFactory = pathFactory;
this.requestAnimationFrame = requestAnimationFrame;
this.active = null;
this.timeoutId = null;
}
isActive(owner = null) {
if (!this.active) return false;
return owner ? this.active.request.owner === owner : true;
}
clearTimeout() {
if (!this.timeoutId) return;
window.clearTimeout(this.timeoutId);
this.timeoutId = null;
}
dismiss(reason = "dismiss") {
if (!this.active) return false;
const active = this.active;
this.active = null;
this.clearTimeout();
active.request.connector?.instance?.hide?.();
this.connector?.hide?.();
active.request.card?.hide?.();
active.request.onDismiss?.(reason);
return true;
}
scheduleLifetime(request) {
this.clearTimeout();
if (request.lifetime?.mode !== "timeout") return;
const timeoutMs = Number(request.lifetime.timeoutMs);
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return;
this.timeoutId = window.setTimeout(() => {
this.dismiss("timeout");
}, timeoutMs);
}
getConnectorInstance(request) {
return request.connector?.instance || this.connector || null;
}
getConnectorPath(request) {
const connectorRequest = request.connector;
if (!connectorRequest?.enabled) return null;
const source = connectorRequest.sourceProvider?.();
if (!source) return null;
const target = connectorRequest.targetProvider?.() || getDefaultCardTarget();
if (!target) return null;
const sourceIsRect =
Number.isFinite(source.x) &&
Number.isFinite(source.y) &&
Number.isFinite(source.width) &&
Number.isFinite(source.height);
const sourceAnchor = sourceIsRect
? { x: source.x + source.width * 0.5, y: source.y + source.height * 0.5 }
: source;
const options = {
...(connectorRequest.options || {}),
...(sourceIsRect && !connectorRequest.options?.sourceRect ? { sourceRect: source } : {}),
};
return this.pathFactory(sourceAnchor, target, options);
}
update({ animate = false } = {}) {
if (!this.active) return false;
const { request } = this.active;
const connector = this.getConnectorInstance(request);
if (!connector || request.connector?.enabled !== true) return false;
const path = this.getConnectorPath(request);
if (!path) {
connector.hide?.();
return false;
}
return connector.render(path, { animate }) === true;
}
async waitForConnector(request, context) {
const timeoutMs =
Number(request.connector?.readyTimeoutMs) || DEFAULT_CONNECTOR_READY_TIMEOUT_MS;
const startedAt = performance.now();
let ready = false;
while (isCurrentContext(context) && this.active?.request === request) {
ready = this.update({ animate: !ready });
if (ready) break;
if (performance.now() - startedAt >= timeoutMs) break;
await this.requestAnimationFrame();
}
return ready;
}
async present(request, { context } = {}) {
if (!request?.id || !request.card || !isCurrentContext(context)) return false;
this.dismiss("replace");
this.active = { id: request.id, request };
request.card.render?.({ reveal: false });
await this.requestAnimationFrame();
if (!isCurrentContext(context) || this.active?.request !== request) {
this.dismiss("interrupted");
return false;
}
const connectorReady =
request.connector?.enabled === true
? await this.waitForConnector(request, context)
: false;
if (!isCurrentContext(context) || this.active?.request !== request) {
this.dismiss("interrupted");
return false;
}
if (connectorReady) {
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;
}
}
request.card.render?.({ reveal: true });
await this.requestAnimationFrame();
if (!isCurrentContext(context) || this.active?.request !== request) {
this.dismiss("interrupted");
return false;
}
this.update({ animate: !connectorReady });
this.scheduleLifetime(request);
return true;
}
}

View File

@@ -0,0 +1,137 @@
import { describe, expect, test } from "bun:test";
import { PresentationController } from "./presentation-controller.js";
function installWindow() {
globalThis.HTMLElement = class HTMLElement {};
globalThis.document = {
getElementById: () => null,
};
globalThis.performance = {
now: () => Date.now(),
};
globalThis.window = {
requestAnimationFrame: (callback) => setTimeout(callback, 0),
setTimeout,
clearTimeout,
};
}
function wait(ms = 8) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function createController(options = {}) {
installWindow();
const calls = [];
const pathCalls = [];
const connector = {
render: () => {
calls.push("connector:render");
return options.connectorReady ?? true;
},
hide: () => calls.push("connector:hide"),
};
const controller = new PresentationController({
connector,
pathFactory: (source, target, pathOptions) => {
pathCalls.push({ source, target, options: pathOptions });
return { points: [{ x: 0, y: 0 }, { x: 10, y: 10 }], start: { x: 0, y: 0 }, end: { x: 10, y: 10 } };
},
});
return { calls, controller, pathCalls };
}
function createRequest(overrides = {}) {
return {
id: overrides.id || "presentation:one",
owner: overrides.owner || "motion",
card: {
render: ({ reveal }) => overrides.calls?.push(`card:${reveal ? "show" : "stage"}`),
hide: () => overrides.calls?.push("card:hide"),
},
connector: {
enabled: true,
sourceProvider: () => ({ x: 1, y: 2, width: 12, height: 12 }),
targetProvider: () => ({ x: 50, y: 60, width: 120, height: 80 }),
readyTimeoutMs: 1,
drawMs: 1,
},
lifetime: { mode: "persistent" },
onDismiss: (reason) => overrides.calls?.push(`dismiss:${reason}`),
...overrides,
};
}
describe("PresentationController", () => {
test("persistent presentation remains active without timeout", async () => {
const { calls, controller } = createController();
const request = createRequest({ calls });
const presented = await controller.present(request);
await wait();
expect(presented).toBe(true);
expect(controller.isActive("motion")).toBe(true);
expect(calls).toContain("card:stage");
expect(calls).toContain("card:show");
});
test("timeout lifetime dismisses the active presentation", async () => {
const { calls, controller } = createController();
const request = createRequest({
calls,
lifetime: { mode: "timeout", timeoutMs: 2 },
});
await controller.present(request);
await wait(12);
expect(controller.isActive()).toBe(false);
expect(calls).toContain("dismiss:timeout");
expect(calls).toContain("card:hide");
});
test("present replaces the previous presentation", async () => {
const { calls, controller } = createController();
await controller.present(createRequest({ calls, id: "one" }));
await controller.present(createRequest({ calls, id: "two" }));
expect(controller.isActive("motion")).toBe(true);
expect(calls).toContain("dismiss:replace");
});
test("dismiss clears card and connector", async () => {
const { calls, controller } = createController();
await controller.present(createRequest({ calls }));
const dismissed = controller.dismiss("owner_stop");
expect(dismissed).toBe(true);
expect(controller.isActive()).toBe(false);
expect(calls).toContain("connector:hide");
expect(calls).toContain("card:hide");
expect(calls).toContain("dismiss:owner_stop");
});
test("update refreshes connector anchors", async () => {
const { calls, controller } = createController();
await controller.present(createRequest({ calls }));
calls.length = 0;
const updated = controller.update();
expect(updated).toBe(true);
expect(calls).toEqual(["connector:render"]);
});
test("rect source anchors are passed as center point plus sourceRect", async () => {
const { controller, pathCalls } = createController();
await controller.present(createRequest());
expect(pathCalls[0].source).toEqual({ x: 7, y: 8 });
expect(pathCalls[0].options.sourceRect).toEqual({ x: 1, y: 2, width: 12, height: 12 });
});
});

View File

@@ -388,7 +388,7 @@ function syncSettingsToggle(visible) {
}
}
function setPanelVisible(visible) {
function setPanelVisible(visible, { persist = true } = {}) {
const { panel } = getElements();
if (!panel) return;
mediaPanel?.setVisible(visible);
@@ -396,12 +396,12 @@ function setPanelVisible(visible) {
updateToggleButton(visible);
syncSettingsToggle(visible);
window.dispatchEvent(new CustomEvent("earth:tv-visibility-change", {
detail: { visible },
detail: { visible, persist },
}));
}
export function setTVPanelVisible(visible) {
setPanelVisible(visible);
export function setTVPanelVisible(visible, options = {}) {
setPanelVisible(visible, options);
}
function clearReformState() {
@@ -582,6 +582,10 @@ export function openTVPanelTab(tab = "live") {
setActiveTab(tab);
}
export function setActiveTVTab(tab = "live") {
setActiveTab(tab);
}
export function isTVPanelVisible() {
return mediaPanel?.isVisible() ?? !getElements().panel?.classList.contains("hud-panel-hidden");
}