release: bump version to 0.50.0
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
|
||||
335
frontend/public/earth/js/controls.js
vendored
335
frontend/public/earth/js/controls.js
vendored
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } = {}) {
|
||||
|
||||
101
frontend/public/earth/js/cruise-sequencer.test.js
Normal file
101
frontend/public/earth/js/cruise-sequencer.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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, '"');
|
||||
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
108
frontend/public/earth/js/motion-agent-provider.js
Normal file
108
frontend/public/earth/js/motion-agent-provider.js
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
548
frontend/public/earth/js/motion-browser-provider.js
Normal file
548
frontend/public/earth/js/motion-browser-provider.js
Normal 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,
|
||||
};
|
||||
257
frontend/public/earth/js/motion-control.js
Normal file
257
frontend/public/earth/js/motion-control.js
Normal 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,
|
||||
};
|
||||
624
frontend/public/earth/js/motion-control.test.js
Normal file
624
frontend/public/earth/js/motion-control.test.js
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
186
frontend/public/earth/js/motion-cruise-adapter.js
Normal file
186
frontend/public/earth/js/motion-cruise-adapter.js
Normal 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");
|
||||
},
|
||||
};
|
||||
}
|
||||
355
frontend/public/earth/js/motion-debug-panel.js
Normal file
355
frontend/public/earth/js/motion-debug-panel.js
Normal 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();
|
||||
}
|
||||
5
frontend/public/earth/js/motion-events.js
Normal file
5
frontend/public/earth/js/motion-events.js
Normal 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";
|
||||
93
frontend/public/earth/js/motion-protocol.js
Normal file
93
frontend/public/earth/js/motion-protocol.js
Normal 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,
|
||||
};
|
||||
}
|
||||
183
frontend/public/earth/js/presentation-controller.js
Normal file
183
frontend/public/earth/js/presentation-controller.js
Normal 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;
|
||||
}
|
||||
}
|
||||
137
frontend/public/earth/js/presentation-controller.test.js
Normal file
137
frontend/public/earth/js/presentation-controller.test.js
Normal 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 });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user