Files
JE-Skin/src/routes/+page.svelte

2257 lines
69 KiB
Svelte

<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { LogicalSize, currentMonitor, getCurrentWindow } from "@tauri-apps/api/window";
import { relaunch } from "@tauri-apps/plugin-process";
import { check } from "@tauri-apps/plugin-updater";
import HudPanel from "$lib/components/HudPanel.svelte";
import CenterStage from "$lib/components/CenterStage.svelte";
import FileExplorerModal from "$lib/components/FileExplorerModal.svelte";
import DevKitConfigPanel from "$lib/components/DevKitConfigPanel.svelte";
import { DEFAULT_PRESSURE_RANGE_MAX, DEFAULT_PRESSURE_RANGE_MIN } from "$lib/config/pressure-range";
import { pressureColorPalettes } from "$lib/config/color-map";
import "$lib/styles/theme.css";
import type {
ConnectionState,
FileExplorerEntry,
FileExplorerListResult,
FileExplorerRoot,
HudColorMapOption,
HudCopy,
HudConfigLink,
HudNoticeTone,
HudPacket,
HudSpatialForce,
PressureColorMapPreset,
HudSignalPanel,
HudSignalSeries,
HudSummary,
LocaleCode,
MatrixDisplayMode,
SerialConnectResult,
SerialExportResult,
SerialRecordStateResult,
SerialImportResult,
SignalTone,
StageViewMode,
WindowControlAction
} from "$lib/types/hud";
type SignalPanelTemplate = Pick<HudSignalPanel, "id" | "code" | "title" | "side">;
type FileExplorerMode = "open" | "save";
interface ReplayFrame {
values: number[];
dtsMs: number;
}
interface DevKitPztAngleEvent {
seq: number;
timestampMs: number;
dtsMs: number;
angle: number;
}
const copyByLocale: Record<LocaleCode, HudCopy> = {
"zh-CN": {
appName: "JE-Skin",
suiteName: "v0.4.0",
stageTitle: "WebGL2 主渲染区",
stageHint: "底图与三维操作将在此区域加载",
configPanelTitle: "参数配置",
configPanelHint: "矩阵规模与颜色映射范围会实时作用到主舞台。",
matrixSizeLabel: "点阵数量",
matrixRowsLabel: "行数",
matrixColsLabel: "列数",
rangeLabel: "映射范围",
rangeMinLabel: "最小值",
rangeMaxLabel: "最大值",
colorMapLabel: "映射颜色",
matrixViewLabel: "矩阵模式",
matrixViewNumericLabel: "数字矩阵",
matrixViewDotsLabel: "点矩阵",
stageModeLabel: "渲染模式",
stageModeWebglLabel: "WebGL",
stageModeModelLabel: "3D 模型",
resetConfigLabel: "恢复默认",
applyLiveHint: "实时生效 / 矩阵尺寸变更将重建 viewer",
runtimeReady: "WEBGL2 READY",
runtimeFallback: "WEBGL2 N/A",
controlArea: "控制区",
serialPortLabel: "串口",
connectionLabel: "连接状态",
deviceLabel: "设备",
sampleRateLabel: "采样率",
channelsLabel: "通道",
configLinksLabel: "配置链接",
refreshPortsLabel: "刷新",
connectActionLabel: "连接",
disconnectActionLabel: "断开",
exportActionLabel: "导出 CSV",
exportingActionLabel: "导出中",
importActionLabel: "导入 CSV",
fileExplorerImportTitle: "导入 CSV 文件",
fileExplorerExportTitle: "导出 CSV 文件",
fileExplorerPathLabel: "路径",
fileExplorerNameLabel: "文件名",
fileExplorerCancelLabel: "取消",
fileExplorerOpenLabel: "打开",
fileExplorerSaveLabel: "保存",
fileExplorerEmptyHint: "当前目录下没有可用条目",
fileExplorerCsvHint: "仅显示 *.csv 文件",
fileExplorerLoadingLabel: "处理中...",
fileExplorerUpLabel: "↑ 上一级",
fileExplorerNameColumnLabel: "名称",
fileExplorerSizeColumnLabel: "大小",
fileExplorerModifiedColumnLabel: "修改时间",
replaySectionLabel: "回放",
replayPlayLabel: "播放",
replayPauseLabel: "暂停",
replayStopLabel: "停止",
replaySpeedLabel: "速度",
replayProgressLabel: "进度",
replayEmptyHint: "未加载回放文件",
connectedLabel: "已连接",
connectingLabel: "连接中",
disconnectedLabel: "未连接"
},
"en-US": {
appName: "JE-Skin",
suiteName: "v0.4.0",
stageTitle: "WebGL2 Main Surface",
stageHint: "Map texture and 3D interactions will render here",
configPanelTitle: "Config Panel",
configPanelHint: "Matrix dimensions and color-mapping range update the stage live.",
matrixSizeLabel: "Matrix Density",
matrixRowsLabel: "Rows",
matrixColsLabel: "Cols",
rangeLabel: "Color Range",
rangeMinLabel: "Min",
rangeMaxLabel: "Max",
colorMapLabel: "Color Map",
matrixViewLabel: "Matrix Mode",
matrixViewNumericLabel: "Numeric",
matrixViewDotsLabel: "Dots",
stageModeLabel: "Render Mode",
stageModeWebglLabel: "WebGL",
stageModeModelLabel: "3D Model",
resetConfigLabel: "Reset",
applyLiveHint: "Live apply / size changes recreate the viewer",
runtimeReady: "WEBGL2 READY",
runtimeFallback: "WEBGL2 N/A",
controlArea: "Control Area",
serialPortLabel: "Port",
connectionLabel: "Connection",
deviceLabel: "Device",
sampleRateLabel: "Sample Rate",
channelsLabel: "Channels",
configLinksLabel: "Config Links",
refreshPortsLabel: "Refresh",
connectActionLabel: "Connect",
disconnectActionLabel: "Disconnect",
exportActionLabel: "Export CSV",
exportingActionLabel: "Exporting",
importActionLabel: "Import CSV",
fileExplorerImportTitle: "Import CSV File",
fileExplorerExportTitle: "Export CSV File",
fileExplorerPathLabel: "Path",
fileExplorerNameLabel: "File Name",
fileExplorerCancelLabel: "Cancel",
fileExplorerOpenLabel: "Open",
fileExplorerSaveLabel: "Save",
fileExplorerEmptyHint: "No entries in this directory",
fileExplorerCsvHint: "Only *.csv files are listed",
fileExplorerLoadingLabel: "Processing...",
fileExplorerUpLabel: "↑ Up",
fileExplorerNameColumnLabel: "Name",
fileExplorerSizeColumnLabel: "Size",
fileExplorerModifiedColumnLabel: "Modified",
replaySectionLabel: "Replay",
replayPlayLabel: "Play",
replayPauseLabel: "Pause",
replayStopLabel: "Stop",
replaySpeedLabel: "Speed",
replayProgressLabel: "Progress",
replayEmptyHint: "No replay file loaded",
connectedLabel: "Connected",
connectingLabel: "Connecting",
disconnectedLabel: "Offline"
}
};
const pointsPerSeries = 28;
const summaryPointsPerSeries = 42;
const signalRenderTickMs = 1200;
const replayDefaultFrameMs = 40;
const showSignalPanels = false;
const mockToneCycle: SignalTone[] = ["cyan", "lime", "orange", "violet", "gold", "rose"];
const signalPanelTemplates: SignalPanelTemplate[] = [
{
id: "m2258",
code: "M2258",
title: "Pressure / Ch-A",
side: "left"
},
{
id: "m2036",
code: "M2036",
title: "Pressure / Ch-B",
side: "left"
},
{
id: "s16104",
code: "S16104",
title: "Signal / Right-A",
side: "right"
},
{
id: "s2716",
code: "S2716",
title: "Signal / Right-B",
side: "right"
}
];
let locale: LocaleCode = "zh-CN";
let connectionState: ConnectionState = "offline";
let serialPortValue = "COM14";
let serialPortOptions = ["COM4", "COM9", "COM14", "COM16"];
let isRefreshingPorts = false;
let connectionNotice = "";
let connectionNoticeTone: HudNoticeTone = "info";
let updateNoticeVisible = false;
let updateInstallBusy = false;
let pendingUpdate: Awaited<ReturnType<typeof check>> | null = null;
let isExporting = false;
let deviceValue = "JE-Skin-F";
let sampleRateValue = "100Hz";
let channelsValue = "84";
let isWindowMaximized = false;
let activeConfigLinkId = "stream-on";
let isConfigPanelOpen = false;
let isPrecisionTestOpen = false;
let hasSignalData = false;
let signalPanels: HudSignalPanel[] = buildInactivePanels();
let summary: HudSummary = buildEmptySummary();
let pressureMatrix: number[] | null = null;
let spatialForce: HudSpatialForce | null = null;
let devkitSpatialForce: HudSpatialForce | null = null;
let matrixRows = 12;
let matrixCols = 7;
let rangeMin = DEFAULT_PRESSURE_RANGE_MIN;
let rangeMax = DEFAULT_PRESSURE_RANGE_MAX;
let colorMapPreset: PressureColorMapPreset = "emerald";
let matrixDisplayMode: MatrixDisplayMode = "dots";
let stageViewMode: StageViewMode = "webgl";
let replayFrames: ReplayFrame[] = [];
let replayCurrentIndex = 0;
let replayHasDisplayedFrame = false;
let replayIsPlaying = false;
let replaySpeed = 1;
let replayProgress = 0;
let replayFileName = "";
let replayTimerId: number | null = null;
let fileExplorerOpen = false;
let fileExplorerMode: FileExplorerMode = "open";
let fileExplorerBusy = false;
let fileExplorerCurrentPath = "";
let fileExplorerParentPath: string | null = null;
let fileExplorerEntries: FileExplorerEntry[] = [];
let fileExplorerRoots: FileExplorerRoot[] = [];
let fileExplorerSelectedPath = "";
let fileExplorerFileName = "";
let isDevKitConfigOpen = false;
let devkitEnabled = false;
let devkitRunning = false;
let devkitPort = 50051;
let devkitFramesSent = 0;
let devkitFilterLift = true;
let devkitSaveXlsx = false;
let devkitLastResult: {
outputPath: string;
groupsUsed: number;
meanValue: number;
threshold: number;
rowsTotal: number;
rowsKept: number;
} | null = null;
let devkitStatusTimer: number | null = null;
let devkitSpatialForceClearTimer: number | null = null;
let sessionStartedAt: number = Date.now();
$: uiCopy = copyByLocale[locale];
$: configLinks = buildConfigLinks(
locale,
activeConfigLinkId,
isConfigPanelOpen,
isPrecisionTestOpen,
devkitEnabled,
isDevKitConfigOpen
);
$: leftSignalPanels = signalPanels.filter((panel) => panel.side === "left");
$: rightSignalPanels = signalPanels.filter((panel) => panel.side === "right");
$: rangeTicks = buildRangeTicks(rangeMin, rangeMax);
$: colorMapOptions = buildColorMapOptions(locale);
$: rangeScaleStyle = buildRangeScaleStyle(colorMapPreset);
$: replayHasData = replayFrames.length > 0;
$: replayFrameInfo = replayHasData ? `${replayHasDisplayedFrame ? replayCurrentIndex + 1 : 0}/${replayFrames.length}` : "";
$: fileExplorerTitle =
fileExplorerMode === "open" ? uiCopy.fileExplorerImportTitle : uiCopy.fileExplorerExportTitle;
$: fileExplorerConfirmLabel =
fileExplorerMode === "open" ? uiCopy.fileExplorerOpenLabel : uiCopy.fileExplorerSaveLabel;
function isTauriRuntime(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
function clearDevkitSpatialForce(): void {
devkitSpatialForce = null;
if (devkitSpatialForceClearTimer != null && typeof window !== "undefined") {
window.clearTimeout(devkitSpatialForceClearTimer);
devkitSpatialForceClearTimer = null;
}
hasSignalData = signalPanels.length > 0 || summary.points.length > 0 || spatialForce !== null;
}
function scheduleDevkitSpatialForceClear(): void {
if (typeof window === "undefined") {
return;
}
if (devkitSpatialForceClearTimer != null) {
window.clearTimeout(devkitSpatialForceClearTimer);
}
devkitSpatialForceClearTimer = window.setTimeout(() => {
devkitSpatialForce = null;
devkitSpatialForceClearTimer = null;
hasSignalData = signalPanels.length > 0 || summary.points.length > 0 || spatialForce !== null;
}, 420);
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function randomBetween(min: number, max: number): number {
return Math.random() * (max - min) + min;
}
function formatRangeTick(value: number): string {
if (Math.abs(value) >= 1000) {
return Number.isInteger(value / 1000) ? `${value / 1000}k` : `${(value / 1000).toFixed(1)}k`;
}
return Number.isInteger(value) ? String(value) : value.toFixed(1);
}
function buildRangeTicks(min: number, max: number): string[] {
const safeMax = Math.max(max, min + 1);
const steps = 10;
const ticks: string[] = [];
for (let index = 0; index <= steps; index += 1) {
const value = min + ((safeMax - min) * index) / steps;
ticks.push(formatRangeTick(Math.round(value * 10) / 10));
}
return ticks;
}
function buildColorMapOptions(currentLocale: LocaleCode): HudColorMapOption[] {
const localizedLabels: Record<PressureColorMapPreset, string> =
currentLocale === "zh-CN"
? {
emerald: "翡翠",
arctic: "极光",
ember: "热焰"
}
: {
emerald: "Emerald",
arctic: "Arctic",
ember: "Ember"
};
return (Object.keys(localizedLabels) as PressureColorMapPreset[]).map((id) => {
const palette = pressureColorPalettes[id];
return {
id,
label: localizedLabels[id],
previewStops: [palette.rangeStops[1], palette.rangeStops[3], palette.rangeStops[5]]
};
});
}
function buildRangeScaleStyle(preset: PressureColorMapPreset): string {
const palette = pressureColorPalettes[preset] ?? pressureColorPalettes.emerald;
const [range0, range1, range2, range3, range4, range5] = palette.rangeStops;
const [glow0, glow1, glow2] = palette.rangeGlow;
const {
bg00,
bg10,
bg20,
bg30,
textMainRgb,
textDimRgb,
borderRgb,
borderStrongRgb,
surfaceRgb,
surfaceAltRgb,
surfaceDeepRgb,
glowRgb,
glowAltRgb,
cyanRgb,
limeRgb,
orangeRgb,
infoRgb
} = palette.uiTheme;
return [
`--hud-bg-00: ${bg00}`,
`--hud-bg-10: ${bg10}`,
`--hud-bg-20: ${bg20}`,
`--hud-bg-30: ${bg30}`,
`--hud-text-main-rgb: ${textMainRgb}`,
`--hud-text-dim-rgb: ${textDimRgb}`,
`--hud-text-main: rgb(${textMainRgb})`,
`--hud-text-dim: rgb(${textDimRgb})`,
`--hud-border-rgb: ${borderRgb}`,
`--hud-border-strong-rgb: ${borderStrongRgb}`,
`--hud-surface-rgb: ${surfaceRgb}`,
`--hud-surface-alt-rgb: ${surfaceAltRgb}`,
`--hud-surface-deep-rgb: ${surfaceDeepRgb}`,
`--hud-glow-rgb: ${glowRgb}`,
`--hud-glow-alt-rgb: ${glowAltRgb}`,
`--hud-cyan-rgb: ${cyanRgb}`,
`--hud-lime-rgb: ${limeRgb}`,
`--hud-orange-rgb: ${orangeRgb}`,
`--hud-info-rgb: ${infoRgb}`,
`--hud-cyan: rgb(${cyanRgb})`,
`--hud-lime: rgb(${limeRgb})`,
`--hud-orange: rgb(${orangeRgb})`,
`--hud-range-0: ${range0}`,
`--hud-range-1: ${range1}`,
`--hud-range-2: ${range2}`,
`--hud-range-3: ${range3}`,
`--hud-range-4: ${range4}`,
`--hud-range-5: ${range5}`,
`--hud-range-glow-0: ${glow0}`,
`--hud-range-glow-1: ${glow1}`,
`--hud-range-glow-2: ${glow2}`
].join("; ");
}
function unquoteCsvCell(cell: string): string {
const trimmed = cell.trim();
if (trimmed.startsWith("\"") && trimmed.endsWith("\"")) {
return trimmed.slice(1, -1).replaceAll("\"\"", "\"").trim();
}
return trimmed;
}
function splitCsvLine(line: string): string[] {
const cells: string[] = [];
let current = "";
let inQuotes = false;
for (let index = 0; index < line.length; index += 1) {
const char = line[index];
if (char === "\"") {
if (inQuotes && line[index + 1] === "\"") {
current += "\"";
index += 1;
} else {
inQuotes = !inQuotes;
}
continue;
}
if (char === "," && !inQuotes) {
cells.push(unquoteCsvCell(current));
current = "";
continue;
}
current += char;
}
cells.push(unquoteCsvCell(current));
return cells;
}
function parseReplayCsv(text: string): ReplayFrame[] {
const lines = text
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
if (lines.length < 2) {
return [];
}
const headerCells = splitCsvLine(lines[0]).map((item) => item.toLowerCase());
let dtsIndex = headerCells.findIndex((item) => item === "dts" || item === "dts_ms" || item === "timestamp");
if (dtsIndex < 0) {
dtsIndex = Math.max(headerCells.length - 1, 0);
}
const frames: ReplayFrame[] = [];
for (let rowIndex = 1; rowIndex < lines.length; rowIndex += 1) {
const cells = splitCsvLine(lines[rowIndex]);
if (cells.length === 0) {
continue;
}
const values: number[] = [];
for (let columnIndex = 0; columnIndex < cells.length; columnIndex += 1) {
if (columnIndex === dtsIndex) {
continue;
}
const parsed = Number(cells[columnIndex]);
values.push(Number.isFinite(parsed) ? parsed : 0);
}
const fallbackDts = frames.length ? frames[frames.length - 1].dtsMs + replayDefaultFrameMs : 0;
const parsedDts = Number(cells[dtsIndex]);
const resolvedDts = Number.isFinite(parsedDts) ? Math.max(Math.round(parsedDts), fallbackDts) : fallbackDts;
frames.push({ values, dtsMs: resolvedDts });
}
return frames;
}
function buildDefaultExportName(): string {
const now = new Date();
const pad = (value: number) => String(value).padStart(2, "0");
return `joyson_export_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}.csv`;
}
function ensureCsvSuffix(fileName: string): string {
const trimmed = fileName.trim();
if (!trimmed) {
return "";
}
return trimmed.toLowerCase().endsWith(".csv") ? trimmed : `${trimmed}.csv`;
}
function inferPathSeparator(path: string): string {
return path.includes("\\") ? "\\" : "/";
}
function joinPath(parent: string, fileName: string): string {
const safeParent = parent.trim();
if (!safeParent) {
return fileName;
}
const separator = inferPathSeparator(safeParent);
if (safeParent.endsWith(separator)) {
return `${safeParent}${fileName}`;
}
return `${safeParent}${separator}${fileName}`;
}
function isCsvPath(path: string): boolean {
return path.toLowerCase().endsWith(".csv");
}
function applyImportedFrames(fileName: string, frames: ReplayFrame[], frameCount: number, channelCount: number): void {
if (!frames.length) {
throw new Error("EmptyReplayData");
}
replayFrames = frames;
replayFileName = fileName;
replayCurrentIndex = 0;
replayHasDisplayedFrame = false;
replayProgress = 0;
resetReplayVisualState();
connectionNotice =
locale === "zh-CN"
? `已导入 ${fileName},共 ${frameCount} 帧 / ${channelCount} 通道,可开始回放。`
: `${fileName} loaded (${frameCount} frames / ${channelCount} channels). Ready to replay.`;
connectionNoticeTone = "ok";
}
async function loadFileExplorerDirectory(path?: string): Promise<void> {
if (!isTauriRuntime()) {
return;
}
fileExplorerBusy = true;
try {
const result = await invoke<FileExplorerListResult>("file_explorer_list", {
path,
extensions: fileExplorerMode === "open" ? ["csv"] : undefined
});
fileExplorerCurrentPath = result.currentPath;
fileExplorerParentPath = result.parentPath;
fileExplorerRoots = result.roots;
fileExplorerEntries = result.entries;
const selectedExists = fileExplorerEntries.some((entry) => entry.path === fileExplorerSelectedPath);
if (!selectedExists) {
fileExplorerSelectedPath = "";
}
} catch (error) {
connectionNotice =
locale === "zh-CN" ? "文件浏览器加载失败,请检查目录权限。" : "File explorer failed to load. Check directory permissions.";
connectionNoticeTone = "warn";
console.error("File explorer load failed:", error);
} finally {
fileExplorerBusy = false;
}
}
async function openFileExplorer(mode: FileExplorerMode): Promise<void> {
if (!isTauriRuntime()) {
if (mode === "open") {
await importViaBrowserInput();
return;
}
await runSerialExport();
return;
}
fileExplorerMode = mode;
fileExplorerOpen = true;
fileExplorerBusy = false;
fileExplorerSelectedPath = "";
if (mode === "save") {
fileExplorerFileName = buildDefaultExportName();
} else {
fileExplorerFileName = "";
}
await loadFileExplorerDirectory(fileExplorerCurrentPath || undefined);
}
function closeFileExplorer(): void {
if (fileExplorerBusy) {
return;
}
fileExplorerOpen = false;
}
async function importViaBrowserInput(): Promise<void> {
if (typeof document === "undefined") {
return;
}
const input = document.createElement("input");
input.type = "file";
input.accept = ".csv,text/csv";
const selectedFile = await new Promise<File | null>((resolve) => {
input.onchange = () => resolve(input.files?.[0] ?? null);
input.click();
});
if (!selectedFile) {
return;
}
await importReplayFromFile(selectedFile);
}
async function importReplayFromFile(file: File): Promise<boolean> {
if (!file) {
return false;
}
pauseReplayPlayback();
try {
const text = await file.text();
let frames: ReplayFrame[] = [];
let importedFrameCount = 0;
let importedChannelCount = 0;
if (isTauriRuntime()) {
const result = await invoke<SerialImportResult>("serial_import_csv", {
fileName: file.name,
csvContent: text
});
frames = result.frames.map((frame) => ({
values: frame.data,
dtsMs: frame.dtsMs
}));
importedFrameCount = result.frameCount;
importedChannelCount = result.channelCount;
} else {
frames = parseReplayCsv(text);
importedFrameCount = frames.length;
importedChannelCount = frames[0]?.values.length ?? 0;
}
applyImportedFrames(file.name, frames, importedFrameCount, importedChannelCount);
return true;
} catch (error) {
connectionNotice = resolveImportNotice(error);
connectionNoticeTone = "warn";
console.error("Replay import failed:", error);
return false;
}
}
async function importReplayFromPath(path: string): Promise<boolean> {
pauseReplayPlayback();
try {
const result = await invoke<SerialImportResult>("serial_import_csv_from_path", {
filePath: path
});
const frames = result.frames.map((frame) => ({
values: frame.data,
dtsMs: frame.dtsMs
}));
applyImportedFrames(result.fileName, frames, result.frameCount, result.channelCount);
return true;
} catch (error) {
connectionNotice = resolveImportNotice(error);
connectionNoticeTone = "warn";
console.error("Replay import failed:", error);
return false;
}
}
function stopReplayTimer(): void {
if (replayTimerId == null || typeof window === "undefined") {
return;
}
window.clearTimeout(replayTimerId);
replayTimerId = null;
}
function frameValuesToMatrix(values: number[]): number[] {
const totalCells = Math.max(matrixRows * matrixCols, 1);
const matrix = new Array<number>(totalCells).fill(0);
for (let index = 0; index < totalCells; index += 1) {
const value = Number(values[index] ?? 0);
matrix[index] = Number.isFinite(value) ? value : 0;
}
return matrix;
}
function buildZeroMatrix(): number[] {
const totalCells = Math.max(matrixRows * matrixCols, 1);
return new Array<number>(totalCells).fill(0);
}
function resetReplayVisualState(): void {
pressureMatrix = buildZeroMatrix();
spatialForce = null;
clearDevkitSpatialForce();
signalPanels = buildInactivePanels();
summary = buildEmptySummary();
hasSignalData = false;
}
function replayFrameTotal(frame: ReplayFrame): number {
return frame.values.reduce((sum, value) => sum + (Number.isFinite(value) ? value : 0), 0);
}
function buildReplaySummaryAt(index: number): HudSummary {
if (!replayFrames.length) {
return buildEmptySummary();
}
const safeIndex = clamp(index, 0, replayFrames.length - 1);
const startIndex = Math.max(0, safeIndex - summaryPointsPerSeries + 1);
const points: number[] = [];
const xSeconds: number[] = [];
for (let cursor = startIndex; cursor <= safeIndex; cursor += 1) {
points.push(replayFrameTotal(replayFrames[cursor]));
xSeconds.push(replayFrames[cursor].dtsMs / 1000);
}
return buildSummary(points, xSeconds);
}
function applyReplayFrame(index: number): void {
if (!replayFrames.length) {
return;
}
const safeIndex = clamp(Math.round(index), 0, replayFrames.length - 1);
replayCurrentIndex = safeIndex;
replayHasDisplayedFrame = true;
replayProgress = replayFrames.length > 1 ? safeIndex / (replayFrames.length - 1) : 1;
pressureMatrix = frameValuesToMatrix(replayFrames[safeIndex].values);
spatialForce = null;
clearDevkitSpatialForce();
signalPanels = buildInactivePanels();
summary = buildReplaySummaryAt(safeIndex);
hasSignalData = true;
}
function getReplayFrameDelay(frameIndex: number): number {
if (frameIndex >= replayFrames.length - 1) {
return replayDefaultFrameMs;
}
const currentDts = replayFrames[frameIndex].dtsMs;
const nextDts = replayFrames[frameIndex + 1].dtsMs;
const rawGap = Math.max(nextDts - currentDts, replayDefaultFrameMs);
return clamp(Math.round(rawGap / Math.max(replaySpeed, 0.25)), 16, 2500);
}
function stepReplayPlayback(): void {
if (!replayIsPlaying) {
return;
}
if (!replayFrames.length || replayCurrentIndex >= replayFrames.length - 1) {
replayIsPlaying = false;
stopReplayTimer();
return;
}
const delay = getReplayFrameDelay(replayCurrentIndex);
applyReplayFrame(replayCurrentIndex + 1);
if (typeof window !== "undefined") {
replayTimerId = window.setTimeout(stepReplayPlayback, delay);
}
}
function pauseReplayPlayback(): void {
replayIsPlaying = false;
stopReplayTimer();
}
function startReplayPlayback(): void {
if (!replayFrames.length) {
return;
}
if (!replayHasDisplayedFrame) {
applyReplayFrame(replayCurrentIndex);
}
if (replayCurrentIndex >= replayFrames.length - 1) {
applyReplayFrame(0);
}
replayIsPlaying = true;
stopReplayTimer();
if (typeof window !== "undefined") {
replayTimerId = window.setTimeout(stepReplayPlayback, getReplayFrameDelay(replayCurrentIndex));
}
}
function createSeriesPoints(seedValue: number): number[] {
let current = seedValue;
const points: number[] = [];
for (let index = 0; index < pointsPerSeries; index += 1) {
current = clamp(current + randomBetween(-5.5, 5.5), 5, 95);
points.push(Math.round(current * 10) / 10);
}
return points;
}
function buildSeriesForPanel(panelId: string): HudSignalSeries[] {
return [
{
id: `${panelId}-series-1`,
tone: "cyan",
points: createSeriesPoints(randomBetween(80, 220))
}
];
}
function evolveSeries(points: number[], tone: SignalTone): number[] {
const drift =
tone === "cyan"
? 12
: tone === "lime"
? 4.9
: tone === "orange"
? 4.4
: tone === "violet"
? 5.1
: tone === "gold"
? 4.6
: 5.3;
const previous = points.length ? points[points.length - 1] : randomBetween(80, 220);
const next = Math.round(clamp(previous + randomBetween(-drift, drift), 0, 500) * 10) / 10;
const nextPoints = points.length >= pointsPerSeries ? points.slice(1) : points.slice();
nextPoints.push(next);
return nextPoints;
}
function buildIcons(panelCode: string, count: number) {
return Array.from({ length: count }, (_, index) => ({
id: `${panelCode}-icon-${index + 1}`,
label: count === 1 ? "TOTAL" : `${panelCode}-${index + 1}`,
tone: mockToneCycle[index % mockToneCycle.length]
}));
}
function buildPanelStats(series: HudSignalSeries[]) {
const latestValues = series
.map((entry) => entry.points[entry.points.length - 1])
.filter((value): value is number => value != null);
const allPoints = series.flatMap((entry) => entry.points);
return {
latest: latestValues.length ? latestValues.reduce((sum, value) => sum + value, 0) / latestValues.length : null,
min: allPoints.length ? Math.min(...allPoints) : null,
max: allPoints.length ? Math.max(...allPoints) : null
};
}
function buildMockPanels(activeIds: string[]): HudSignalPanel[] {
return signalPanelTemplates
.filter((panel) => activeIds.includes(panel.id))
.map((panel) => {
const series = buildSeriesForPanel(panel.id);
return {
...panel,
active: true,
series,
icons: buildIcons(panel.code, series.length),
...buildPanelStats(series)
};
});
}
function evolvePanels(panels: HudSignalPanel[]): HudSignalPanel[] {
return panels.map((panel) => {
const series = panel.series.map((series) => ({
...series,
points: evolveSeries(series.points, series.tone)
}));
return {
...panel,
active: true,
series,
icons: buildIcons(panel.code, series.length),
...buildPanelStats(series)
};
});
}
function buildEmptySummary(): HudSummary {
return {
label: "Resultant Force",
xValues: [],
points: [],
latest: null,
min: null,
max: null
};
}
function isZeroLikeValue(value: number): boolean {
return !Number.isFinite(value) || Math.abs(value) < 0.0001;
}
function shouldHideSummary(points: number[]): boolean {
return points.length === 0 || points.every((value) => isZeroLikeValue(value));
}
function normalizeSummary(summaryValue: HudSummary): HudSummary {
return shouldHideSummary(summaryValue.points) ? buildEmptySummary() : summaryValue;
}
function buildSummary(points: number[], xValues: number[] = []): HudSummary {
if (points.length === 0) {
return buildEmptySummary();
}
const resolvedXValues = points.map((_, index) => {
const x = xValues[index];
return Number.isFinite(x) ? Number(x) : index + 1;
});
return {
label: "Resultant Force",
xValues: resolvedXValues,
points,
latest: points[points.length - 1],
min: Math.min(...points),
max: Math.max(...points)
};
}
function createSummaryPoints(seedValue: number): number[] {
let current = seedValue;
const points: number[] = [];
for (let index = 0; index < summaryPointsPerSeries; index += 1) {
current = clamp(current + randomBetween(-140, 140), 180, 2200);
points.push(Math.round(current * 10) / 10);
}
return points;
}
function evolveSummary(summaryValue: HudSummary): HudSummary {
const previous = summaryValue.points.length
? summaryValue.points[summaryValue.points.length - 1]
: randomBetween(280, 1600);
const next = Math.round(clamp(previous + randomBetween(-160, 160), 120, 2400) * 10) / 10;
const nowSeconds = Math.round((Date.now() - sessionStartedAt) / 100) / 10;
const previousXValues =
summaryValue.xValues && summaryValue.xValues.length === summaryValue.points.length
? summaryValue.xValues
: summaryValue.points.map((_, index) => nowSeconds);
const points =
summaryValue.points.length >= summaryPointsPerSeries
? summaryValue.points.slice(1)
: summaryValue.points.slice();
const xValues =
previousXValues.length >= summaryPointsPerSeries ? previousXValues.slice(1) : previousXValues.slice();
points.push(next);
xValues.push(nowSeconds);
return buildSummary(points, xValues);
}
function buildInactivePanels(): HudSignalPanel[] {
return [];
}
function applyPacket(packet: HudPacket): void {
if (replayHasData) {
return;
}
signalPanels = showSignalPanels ? packet.panels : buildInactivePanels();
if (packet.summary.points.length > 0) {
const nowSeconds = Math.round((Date.now() - sessionStartedAt) / 100) / 10;
const pointCount = packet.summary.points.length;
const spacing =
pointCount > 1 ? Math.min(1.2, nowSeconds / Math.max(pointCount - 1, 1)) : 0;
const startX = Math.max(0, nowSeconds - spacing * Math.max(pointCount - 1, 0));
const xValues = packet.summary.points.map((_, index) => Math.round((startX + index * spacing) * 10) / 10);
summary = { ...packet.summary, xValues };
} else {
summary = packet.summary;
}
pressureMatrix = packet.pressureMatrix;
spatialForce = packet.spatialForce ?? null;
hasSignalData =
signalPanels.length > 0 ||
packet.summary.points.length > 0 ||
spatialForce !== null ||
devkitSpatialForce !== null;
}
function clearHudPanels(): void {
hasSignalData = false;
signalPanels = buildInactivePanels();
summary = buildEmptySummary();
pressureMatrix = null;
spatialForce = null;
clearDevkitSpatialForce();
}
function startMockFeed(push: (packet: HudPacket) => void): () => void {
let panels = buildInactivePanels();
let summaryValue = buildSummary(createSummaryPoints(randomBetween(480, 1440)));
push({ ts: Date.now(), panels, summary: summaryValue, pressureMatrix: null, spatialForce: null });
const timerId = window.setInterval(() => {
summaryValue = evolveSummary(summaryValue);
push({ ts: Date.now(), panels, summary: summaryValue, pressureMatrix: null, spatialForce: null });
}, signalRenderTickMs);
return () => {
window.clearInterval(timerId);
};
}
async function startTauriHudStream(push: (packet: HudPacket) => void): Promise<UnlistenFn> {
return listen<HudPacket>("hud_stream", (event) => {
push(event.payload);
});
}
function buildConfigLinks(
currentLocale: LocaleCode,
activeId: string,
isSettingsOpen: boolean,
isPrecisionOpen: boolean,
isDevKitEnabled: boolean,
isDevKitOpen: boolean
): HudConfigLink[] {
const labels =
currentLocale === "zh-CN"
? {
streamOn: "打开",
streamOff: "关闭",
precisionTest: "游戏",
settings: "参数"
}
: {
streamOn: "Open",
streamOff: "Close",
precisionTest: "Game",
settings: "Setup"
};
const devkitLabel = currentLocale === "zh-CN" ? "开发工具" : "DevKit";
const links: HudConfigLink[] = [
{
id: "stream-on",
label: labels.streamOn,
tone: "lime",
active: activeId === "stream-on"
},
{
id: "stream-off",
label: labels.streamOff,
tone: "orange",
active: activeId === "stream-off"
},
{
id: "precision-test",
label: labels.precisionTest,
tone: "lime",
active: isPrecisionOpen
},
{
id: "settings",
label: labels.settings,
tone: "neutral",
active: isSettingsOpen
}
];
if (isDevKitEnabled) {
links.push({
id: "devkit",
label: devkitLabel,
tone: "cyan",
active: isDevKitOpen
});
}
return links;
}
async function ensureDefaultWindowSize(): Promise<void> {
if (!isTauriRuntime()) {
return;
}
try {
const monitor = await currentMonitor();
if (!monitor) {
return;
}
const scaleFactor = monitor.scaleFactor || 1;
const targetWidth = Math.floor((monitor.size.width * 0.75) / scaleFactor);
const targetHeight = Math.floor((monitor.size.height * 0.75) / scaleFactor);
const currentWindow = getCurrentWindow();
const currentSize = await currentWindow.innerSize();
const currentWidth = Math.floor(currentSize.width / scaleFactor);
const currentHeight = Math.floor(currentSize.height / scaleFactor);
const shouldResize = currentWidth < targetWidth * 0.9 || currentHeight < targetHeight * 0.9;
if (shouldResize) {
await currentWindow.setSize(new LogicalSize(targetWidth, targetHeight));
await currentWindow.center();
}
} catch (error) {
console.error("Default window sizing failed:", error);
}
}
async function syncWindowState(): Promise<void> {
if (!isTauriRuntime()) {
return;
}
try {
isWindowMaximized = await getCurrentWindow().isMaximized();
} catch {
isWindowMaximized = false;
}
}
async function probeWebgl2(): Promise<void> {
if (typeof window === "undefined") {
return;
}
const canvas = document.createElement("canvas");
const context = canvas.getContext("webgl2");
}
async function checkForAppUpdate(): Promise<void> {
if (!isTauriRuntime()) {
return;
}
const updateDismissKey = "je-skin-update-dismissed-version";
try {
const update = await check();
if (!update) {
return;
}
if (window.sessionStorage.getItem(updateDismissKey) === update.version) {
return;
}
const message =
locale === "zh-CN"
? `发现新版本 ${update.version},是否现在下载并安装?`
: `Version ${update.version} is available. Download and install now?`;
pendingUpdate = update;
updateNoticeVisible = true;
updateInstallBusy = false;
connectionNotice = message;
connectionNoticeTone = "info";
} catch (error) {
console.error("App update check failed:", error);
}
}
async function handleUpdateConfirm(): Promise<void> {
if (!pendingUpdate || updateInstallBusy) {
return;
}
updateInstallBusy = true;
connectionNotice = locale === "zh-CN" ? "正在下载并安装更新..." : "Downloading and installing update...";
connectionNoticeTone = "info";
try {
await pendingUpdate.downloadAndInstall();
await relaunch();
} catch (error) {
updateInstallBusy = false;
updateNoticeVisible = false;
pendingUpdate = null;
connectionNotice = locale === "zh-CN" ? "更新安装失败,请稍后重试。" : "Update failed. Please try again later.";
connectionNoticeTone = "warn";
console.error("App update install failed:", error);
}
}
function handleUpdateCancel(): void {
if (pendingUpdate) {
window.sessionStorage.setItem("je-skin-update-dismissed-version", pendingUpdate.version);
}
pendingUpdate = null;
updateNoticeVisible = false;
updateInstallBusy = false;
connectionNotice = "";
}
$: if (updateNoticeVisible && pendingUpdate && !updateInstallBusy) {
connectionNotice = locale === "zh-CN"
? `发现新版本 ${pendingUpdate.version},是否现在下载并安装?`
: `Version ${pendingUpdate.version} is available. Download and install now?`;
}
function handleLocaleChange(event: CustomEvent<LocaleCode>): void {
locale = event.detail;
}
function handlePortChange(event: CustomEvent<string>): void {
serialPortValue = event.detail;
connectionState = "offline";
connectionNotice = "";
clearHudPanels();
console.info("[hud] port changed:", event.detail);
}
function normalizeInvokeError(error: unknown): string {
if (typeof error === "string") {
return error;
}
if (error && typeof error === "object") {
if ("message" in error && typeof error.message === "string") {
return error.message;
}
return JSON.stringify(error);
}
return "UnknownError";
}
function resolveSerialNotice(error: unknown, action: "connect" | "disconnect"): string {
const errorCode = normalizeInvokeError(error);
if (locale === "zh-CN") {
if (action === "connect") {
switch (errorCode) {
case "OpenError":
return "串口连接失败,请确认端口存在且未被占用。";
case "AlreadyConnected":
return "当前已存在活动连接,请先断开。";
case "InvalidConfig":
return "当前串口配置无效,请重新选择端口。";
default:
return "串口连接失败,请稍后重试。";
}
}
switch (errorCode) {
case "CloseError":
return "串口断开失败,请稍后重试。";
default:
return "串口断开失败,请稍后重试。";
}
}
if (action === "connect") {
switch (errorCode) {
case "OpenError":
return "Connection failed. Check whether the port exists or is already in use.";
case "AlreadyConnected":
return "A serial connection is already active. Disconnect it first.";
case "InvalidConfig":
return "The selected serial port is invalid. Choose another port.";
default:
return "Connection failed. Please try again.";
}
}
return "Disconnect failed. Please try again.";
}
function resolveRefreshNotice(error: unknown): string {
const errorCode = normalizeInvokeError(error);
if (locale === "zh-CN") {
return errorCode === "ScanError"
? "串口列表刷新失败,请确认系统串口服务正常。"
: "刷新串口列表失败,请稍后重试。";
}
return errorCode === "ScanError"
? "Refreshing serial ports failed. Check whether the OS serial service is available."
: "Refreshing serial ports failed. Please try again.";
}
function resolveExportNotice(error: unknown): string {
const errorCode = normalizeInvokeError(error);
if (locale === "zh-CN") {
if (errorCode === "NoRecordedData") {
return "暂无可导出的采样数据,请先连接并采集。";
}
return errorCode === "ExportError"
? "CSV 导出失败,请确认目标目录可写。"
: "CSV 导出失败,请稍后重试。";
}
if (errorCode === "NoRecordedData") {
return "No recorded data is available. Connect and collect samples first.";
}
return errorCode === "ExportError"
? "CSV export failed. Verify that the output directory is writable."
: "CSV export failed. Please try again.";
}
function resolveImportNotice(error: unknown): string {
const errorCode = normalizeInvokeError(error);
if (locale === "zh-CN") {
if (errorCode === "NoRecordedData") {
return "CSV 文件没有可回放的数据帧。";
}
return errorCode === "ImportError"
? "CSV 导入失败,请确认数据格式正确。"
: "CSV 导入失败,请稍后重试。";
}
if (errorCode === "NoRecordedData") {
return "The CSV file does not contain replayable frames.";
}
return errorCode === "ImportError"
? "CSV import failed. Please verify the data format."
: "CSV import failed. Please try again.";
}
async function refreshSerialPorts(): Promise<void> {
if (!isTauriRuntime()) {
return;
}
isRefreshingPorts = true;
try {
const ports = await invoke<string[]>("serial_enum");
serialPortOptions = ports;
if (ports.includes(serialPortValue)) {
return;
}
serialPortValue = ports[0] ?? "";
if (!serialPortValue) {
connectionState = "offline";
clearHudPanels();
}
} catch (error) {
connectionNotice = resolveRefreshNotice(error);
connectionNoticeTone = "warn";
console.error("Serial port refresh failed:", error);
} finally {
isRefreshingPorts = false;
}
}
async function handleSerialRefresh(): Promise<void> {
await refreshSerialPorts();
}
async function handleSerialConnect(event: CustomEvent<string>): Promise<void> {
if (!isTauriRuntime()) {
console.warn("[serial] Connect is only available inside Tauri.");
return;
}
if (connectionState === "online") {
await handleSerialDisconnect();
return;
}
connectionState = "connecting";
connectionNotice = "";
try {
const result = await invoke<SerialConnectResult>("serial_connect", { port: event.detail });
connectionState = result.connected ? "online" : "offline";
serialPortValue = result.port;
connectionNotice = "";
connectionNoticeTone = "info";
clearHudPanels();
console.info("[serial] connect result:", result.message);
} catch (error) {
connectionState = "offline";
connectionNotice = resolveSerialNotice(error, "connect");
connectionNoticeTone = "warn";
clearHudPanels();
console.error("Serial connect failed:", error);
}
}
async function handleSerialDisconnect(): Promise<void> {
try {
const result = await invoke<SerialConnectResult>("serial_disconnect");
connectionState = result.connected ? "online" : "offline";
connectionNotice = "";
connectionNoticeTone = "info";
clearHudPanels();
} catch (error) {
connectionNotice = resolveSerialNotice(error, "disconnect");
connectionNoticeTone = "warn";
console.error("Serial disconnect failed:", error);
}
}
async function runSerialExport(filePath?: string): Promise<boolean> {
if (!isTauriRuntime()) {
connectionNotice =
locale === "zh-CN" ? "当前环境不支持导出到本地路径。" : "Current runtime cannot export to local paths.";
connectionNoticeTone = "warn";
return false;
}
isExporting = true;
fileExplorerBusy = true;
try {
const result = filePath
? await invoke<SerialExportResult>("serial_export_csv_to_path", { filePath })
: await invoke<SerialExportResult>("serial_export_csv");
if (devkitEnabled && devkitRunning && devkitFilterLift) {
try {
const processResult = await invoke<{
ok: boolean;
outputPath: string;
groupsUsed: number;
meanValue: number;
threshold: number;
rowsTotal: number;
rowsKept: number;
message: string;
}>("devkit_process_export", {
csvPath: result.path,
saveAsXlsx: devkitSaveXlsx
});
if (processResult.ok) {
devkitLastResult = {
outputPath: processResult.outputPath,
groupsUsed: processResult.groupsUsed,
meanValue: processResult.meanValue,
threshold: processResult.threshold,
rowsTotal: processResult.rowsTotal,
rowsKept: processResult.rowsKept
};
connectionNotice =
locale === "zh-CN"
? `CSV 已导出并完成 DevKit 处理(${result.frameCount} 帧):${processResult.outputPath}`
: `CSV exported and processed by DevKit (${result.frameCount} frames): ${processResult.outputPath}`;
connectionNoticeTone = "ok";
return true;
}
connectionNotice =
locale === "zh-CN"
? `CSV 已导出,但 DevKit 处理失败:${processResult.message}`
: `CSV exported, but DevKit processing failed: ${processResult.message}`;
connectionNoticeTone = "warn";
return true;
} catch (error) {
connectionNotice =
locale === "zh-CN"
? "CSV 已导出,但 DevKit 后处理调用失败。"
: "CSV exported, but DevKit post-processing failed.";
connectionNoticeTone = "warn";
console.error("DevKit export post-process failed:", error);
return true;
}
}
connectionNotice =
locale === "zh-CN"
? `CSV 导出成功(${result.frameCount} 帧):${result.path}`
: `CSV exported (${result.frameCount} frames): ${result.path}`;
connectionNoticeTone = "ok";
return true;
} catch (error) {
connectionNotice = resolveExportNotice(error);
connectionNoticeTone = "warn";
console.error("Serial export failed:", error);
return false;
} finally {
isExporting = false;
fileExplorerBusy = false;
}
}
async function precheckExportRecordData(): Promise<boolean> {
if (!isTauriRuntime()) {
return true;
}
try {
const result = await invoke<SerialRecordStateResult>("serial_has_record_data");
if (result.hasData) {
return true;
}
connectionNotice = resolveExportNotice("NoRecordedData");
connectionNoticeTone = "warn";
return false;
} catch (error) {
connectionNotice = resolveExportNotice(error);
connectionNoticeTone = "warn";
console.error("Export precheck failed:", error);
return false;
}
}
async function handleSerialExportRequest(): Promise<void> {
const hasData = await precheckExportRecordData();
if (!hasData) {
return;
}
await openFileExplorer("save");
}
async function handleReplayImportRequest(): Promise<void> {
await openFileExplorer("open");
}
async function handleFileExplorerNavigate(event: CustomEvent<string>): Promise<void> {
await loadFileExplorerDirectory(event.detail);
}
async function handleFileExplorerConfirm(): Promise<void> {
if (fileExplorerBusy) {
return;
}
if (fileExplorerMode === "open") {
const selected = fileExplorerEntries.find((entry) => entry.path === fileExplorerSelectedPath);
if (!selected) {
return;
}
if (selected.isDir) {
await loadFileExplorerDirectory(selected.path);
return;
}
if (!isCsvPath(selected.path)) {
connectionNotice = locale === "zh-CN" ? "请先选择 CSV 文件。" : "Please choose a CSV file.";
connectionNoticeTone = "warn";
return;
}
fileExplorerBusy = true;
const ok = await importReplayFromPath(selected.path);
fileExplorerBusy = false;
if (ok) {
fileExplorerOpen = false;
}
return;
}
const selected = fileExplorerEntries.find((entry) => entry.path === fileExplorerSelectedPath);
const targetDir = selected?.isDir ? selected.path : fileExplorerCurrentPath;
const csvName = ensureCsvSuffix(fileExplorerFileName || buildDefaultExportName());
if (!csvName) {
return;
}
const targetPath = joinPath(targetDir, csvName);
const ok = await runSerialExport(targetPath);
if (ok) {
fileExplorerOpen = false;
}
}
function handleReplayToggle(): void {
if (!replayHasData) {
return;
}
if (replayIsPlaying) {
pauseReplayPlayback();
return;
}
startReplayPlayback();
}
function handleReplayStop(): void {
pauseReplayPlayback();
if (replayHasData) {
applyReplayFrame(0);
}
}
function handleReplaySeek(event: CustomEvent<number>): void {
if (!replayHasData) {
return;
}
const ratio = clamp(Number.isFinite(event.detail) ? event.detail : 0, 0, 1);
const targetIndex = Math.round(ratio * Math.max(replayFrames.length - 1, 0));
applyReplayFrame(targetIndex);
if (replayIsPlaying && typeof window !== "undefined") {
stopReplayTimer();
replayTimerId = window.setTimeout(stepReplayPlayback, 0);
}
}
function handleReplaySpeed(event: CustomEvent<number>): void {
const nextSpeed = clamp(Number.isFinite(event.detail) ? event.detail : 1, 0.5, 2);
replaySpeed = Math.round(nextSpeed * 100) / 100;
if (replayIsPlaying && typeof window !== "undefined") {
stopReplayTimer();
replayTimerId = window.setTimeout(stepReplayPlayback, 0);
}
}
function handleReplayClose(): void {
pauseReplayPlayback();
replayFrames = [];
replayCurrentIndex = 0;
replayHasDisplayedFrame = false;
replaySpeed = 1;
replayProgress = 0;
replayFileName = "";
resetReplayVisualState();
}
function handleConfigLink(event: CustomEvent<string>): void {
if (event.detail === "precision-test") {
stageViewMode = "webgl";
isPrecisionTestOpen = !isPrecisionTestOpen;
isConfigPanelOpen = false;
isDevKitConfigOpen = false;
return;
}
if (event.detail === "settings") {
stageViewMode = "webgl";
isPrecisionTestOpen = false;
isConfigPanelOpen = !isConfigPanelOpen;
isDevKitConfigOpen = false;
return;
}
if (event.detail === "devkit") {
isPrecisionTestOpen = false;
isConfigPanelOpen = false;
isDevKitConfigOpen = !isDevKitConfigOpen;
return;
}
isPrecisionTestOpen = false;
isConfigPanelOpen = false;
isDevKitConfigOpen = false;
activeConfigLinkId = event.detail;
console.info("[hud] config link clicked:", event.detail);
}
async function handleWindowControl(event: CustomEvent<WindowControlAction>): Promise<void> {
if (!isTauriRuntime()) {
return;
}
try {
if (event.detail === "minimize") {
await invoke("win_minimize");
} else if (event.detail === "toggle-maximize") {
await invoke("win_toggle_maximize");
await syncWindowState();
} else {
await invoke("win_close");
}
} catch (error) {
console.error("Window control failed:", error);
}
}
// ── DevKit Functions ────────────────────────────────────────────
async function pollDevKitStatus(): Promise<void> {
if (!isTauriRuntime()) return;
try {
const status = await invoke<{
enabled: boolean;
running: boolean;
port: number;
framesSent: number;
config: { filterLiftEnabled: boolean; saveAsXlsx: boolean };
}>("devkit_status");
devkitEnabled = status.enabled;
devkitRunning = status.running;
devkitPort = status.port;
devkitFramesSent = status.framesSent;
devkitFilterLift = status.config.filterLiftEnabled;
devkitSaveXlsx = status.config.saveAsXlsx;
} catch {
devkitEnabled = false;
devkitRunning = false;
isDevKitConfigOpen = false;
}
}
async function handleDevKitToggleFilterLift(): Promise<void> {
if (!isTauriRuntime()) return;
try {
const newConfig = { filterLiftEnabled: !devkitFilterLift, saveAsXlsx: devkitSaveXlsx };
const result = await invoke<{ filterLiftEnabled: boolean; saveAsXlsx: boolean }>("devkit_set_config", { config: newConfig });
devkitFilterLift = result.filterLiftEnabled;
devkitSaveXlsx = result.saveAsXlsx;
} catch (error) {
console.error("DevKit config update failed:", error);
}
}
async function handleDevKitToggleXlsx(): Promise<void> {
if (!isTauriRuntime()) return;
try {
const newConfig = { filterLiftEnabled: devkitFilterLift, saveAsXlsx: !devkitSaveXlsx };
const result = await invoke<{ filterLiftEnabled: boolean; saveAsXlsx: boolean }>("devkit_set_config", { config: newConfig });
devkitFilterLift = result.filterLiftEnabled;
devkitSaveXlsx = result.saveAsXlsx;
} catch (error) {
console.error("DevKit config update failed:", error);
}
}
function handleMatrixDisplayToggle(event: CustomEvent<boolean>): void {
matrixDisplayMode = event.detail ? "dots" : "numeric";
}
function handleStageModeChange(event: CustomEvent<StageViewMode>): void {
stageViewMode = event.detail;
if (stageViewMode === "model3d") {
isPrecisionTestOpen = false;
isConfigPanelOpen = false;
}
}
onMount(() => {
let disposed = false;
let unlistenHudStream: UnlistenFn | null = null;
let unlistenDevkitPztAngle: UnlistenFn | null = null;
let stopMockFeed: (() => void) | null = null;
void ensureDefaultWindowSize();
void syncWindowState();
void probeWebgl2();
if (isTauriRuntime()) {
void refreshSerialPorts();
void checkForAppUpdate();
void pollDevKitStatus();
devkitStatusTimer = window.setInterval(() => void pollDevKitStatus(), 3000);
void startTauriHudStream(applyPacket)
.then((unlisten) => {
if (disposed) {
unlisten();
return;
}
unlistenHudStream = unlisten;
})
.catch((error) => {
console.error("Failed to listen for hud_stream:", error);
});
void listen<DevKitPztAngleEvent>("devkit_pzt_angle", (event) => {
const angleDeg = Number(event.payload.angle);
if (!Number.isFinite(angleDeg)) {
clearDevkitSpatialForce();
return;
}
devkitSpatialForce = {
angleDeg,
magnitude: 0,
confidence: 0
};
scheduleDevkitSpatialForceClear();
hasSignalData =
signalPanels.length > 0 ||
summary.points.length > 0 ||
spatialForce !== null ||
devkitSpatialForce !== null;
})
.then((unlisten) => {
if (disposed) {
unlisten();
return;
}
unlistenDevkitPztAngle = unlisten;
})
.catch((error) => {
console.error("Failed to listen for devkit_pzt_angle:", error);
});
} else {
stopMockFeed = startMockFeed(applyPacket);
}
return () => {
disposed = true;
pauseReplayPlayback();
clearDevkitSpatialForce();
stopMockFeed?.();
unlistenHudStream?.();
unlistenDevkitPztAngle?.();
if (devkitStatusTimer != null) {
window.clearInterval(devkitStatusTimer);
devkitStatusTimer = null;
}
};
});
</script>
<main class="hud-screen" style={rangeScaleStyle}>
<div class="hud-background" aria-hidden="true">
<div class="hud-gradient"></div>
<div class="hud-vignette"></div>
<div class="hud-noise"></div>
</div>
<div class="hud-layout">
<HudPanel
appName={uiCopy.appName}
suiteName={uiCopy.suiteName}
controlAreaLabel={uiCopy.controlArea}
locale={locale}
connectionState={connectionState}
connectionLabel={uiCopy.connectionLabel}
connectedLabel={uiCopy.connectedLabel}
connectingLabel={uiCopy.connectingLabel}
disconnectedLabel={uiCopy.disconnectedLabel}
serialPortLabel={uiCopy.serialPortLabel}
{serialPortValue}
{serialPortOptions}
deviceLabel={uiCopy.deviceLabel}
deviceValue={deviceValue}
sampleRateLabel={uiCopy.sampleRateLabel}
sampleRateValue={sampleRateValue}
channelsLabel={uiCopy.channelsLabel}
channelsValue={channelsValue}
configLinksLabel={uiCopy.configLinksLabel}
refreshPortsLabel={uiCopy.refreshPortsLabel}
matrixViewLabel={uiCopy.matrixViewLabel}
matrixViewNumericLabel={uiCopy.matrixViewNumericLabel}
matrixViewDotsLabel={uiCopy.matrixViewDotsLabel}
{matrixDisplayMode}
stageModeLabel={uiCopy.stageModeLabel}
stageModeWebglLabel={uiCopy.stageModeWebglLabel}
stageModeModelLabel={uiCopy.stageModeModelLabel}
{stageViewMode}
connectActionLabel={uiCopy.connectActionLabel}
disconnectActionLabel={uiCopy.disconnectActionLabel}
exportActionLabel={uiCopy.exportActionLabel}
exportingActionLabel={uiCopy.exportingActionLabel}
importActionLabel={uiCopy.importActionLabel}
{connectionNotice}
{connectionNoticeTone}
noticeConfirmLabel={locale === "zh-CN" ? "确定" : "Confirm"}
noticeCancelLabel={locale === "zh-CN" ? "取消" : "Cancel"}
noticeShowActions={updateNoticeVisible}
noticeActionBusy={updateInstallBusy}
{configLinks}
{isRefreshingPorts}
{isExporting}
isConnectDisabled={!serialPortValue || connectionState === "connecting"}
isExportDisabled={isExporting || connectionState === "connecting"}
isWindowMaximized={isWindowMaximized}
on:windowcontrol={handleWindowControl}
on:localechange={handleLocaleChange}
on:portchange={handlePortChange}
on:configlink={handleConfigLink}
on:matrixdisplaytoggle={handleMatrixDisplayToggle}
on:stagemodechange={handleStageModeChange}
on:serialrefresh={handleSerialRefresh}
on:serialconnect={handleSerialConnect}
on:serialexport={handleSerialExportRequest}
on:csvimport={handleReplayImportRequest}
on:noticeclear={() => {
connectionNotice = "";
updateNoticeVisible = false;
}}
on:noticeconfirm={handleUpdateConfirm}
on:noticecancel={handleUpdateCancel}
/>
<CenterStage
{locale}
bind:matrixRows
bind:matrixCols
bind:rangeMin
bind:rangeMax
bind:colorMapPreset
bind:matrixDisplayMode
{stageViewMode}
configPanelTitle={uiCopy.configPanelTitle}
configPanelHint={uiCopy.configPanelHint}
matrixSizeLabel={uiCopy.matrixSizeLabel}
matrixRowsLabel={uiCopy.matrixRowsLabel}
matrixColsLabel={uiCopy.matrixColsLabel}
rangeLabel={uiCopy.rangeLabel}
rangeMinLabel={uiCopy.rangeMinLabel}
rangeMaxLabel={uiCopy.rangeMaxLabel}
replaySectionLabel={uiCopy.replaySectionLabel}
replayPlayLabel={uiCopy.replayPlayLabel}
replayPauseLabel={uiCopy.replayPauseLabel}
replayStopLabel={uiCopy.replayStopLabel}
replaySpeedLabel={uiCopy.replaySpeedLabel}
replayProgressLabel={uiCopy.replayProgressLabel}
{replayHasData}
{replayIsPlaying}
{replaySpeed}
{replayProgress}
{replayFileName}
{replayFrameInfo}
{sessionStartedAt}
resetConfigLabel={uiCopy.resetConfigLabel}
applyLiveHint={uiCopy.applyLiveHint}
leftPanels={leftSignalPanels}
rightPanels={rightSignalPanels}
{pressureMatrix}
{spatialForce}
{devkitSpatialForce}
showConfigPanel={isConfigPanelOpen}
showPrecisionTestPanel={isPrecisionTestOpen}
{summary}
on:replaytoggle={handleReplayToggle}
on:replaystop={handleReplayStop}
on:replayseek={handleReplaySeek}
on:replayspeed={handleReplaySpeed}
on:replayclose={handleReplayClose}
on:configclose={() => (isConfigPanelOpen = false)}
>
{#if !isPrecisionTestOpen && stageViewMode === "webgl"}
<section class="range-scale" aria-label="Signal Range">
<p class="range-label">{locale === "zh-CN" ? "范围" : "Range"}</p>
<div class="range-track">
{#each rangeTicks as tick}
<span class="range-tick">{tick}</span>
{/each}
</div>
</section>
{/if}
</CenterStage>
</div>
<FileExplorerModal
open={fileExplorerOpen}
mode={fileExplorerMode}
title={fileExplorerTitle}
currentPath={fileExplorerCurrentPath}
parentPath={fileExplorerParentPath}
roots={fileExplorerRoots}
entries={fileExplorerEntries}
bind:selectedPath={fileExplorerSelectedPath}
bind:fileName={fileExplorerFileName}
pathLabel={uiCopy.fileExplorerPathLabel}
fileNameLabel={uiCopy.fileExplorerNameLabel}
cancelLabel={uiCopy.fileExplorerCancelLabel}
confirmLabel={fileExplorerConfirmLabel}
emptyHint={uiCopy.fileExplorerEmptyHint}
csvHint={uiCopy.fileExplorerCsvHint}
busyLabel={uiCopy.fileExplorerLoadingLabel}
upLabel={uiCopy.fileExplorerUpLabel}
nameColumnLabel={uiCopy.fileExplorerNameColumnLabel}
sizeColumnLabel={uiCopy.fileExplorerSizeColumnLabel}
modifiedColumnLabel={uiCopy.fileExplorerModifiedColumnLabel}
isBusy={fileExplorerBusy}
on:close={closeFileExplorer}
on:navigate={handleFileExplorerNavigate}
on:confirm={handleFileExplorerConfirm}
/>
{#if isDevKitConfigOpen && devkitEnabled}
<div class="devkit-overlay" role="dialog" aria-label={locale === "zh-CN" ? "开发工具配置" : "DevKit Config"}>
<div class="devkit-float">
<DevKitConfigPanel
running={devkitRunning}
filterLiftEnabled={devkitFilterLift}
saveAsXlsx={devkitSaveXlsx}
locale={locale}
lastProcessResult={devkitLastResult}
on:close={() => (isDevKitConfigOpen = false)}
on:togglefilterlift={handleDevKitToggleFilterLift}
on:togglexlsx={handleDevKitToggleXlsx}
/>
</div>
</div>
{/if}
</main>
<style>
.hud-screen {
position: relative;
isolation: isolate;
height: 100dvh;
min-height: 100dvh;
overflow: clip;
background: var(--hud-bg-00);
}
.hud-background {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
}
.hud-gradient {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 14% 6%, rgb(var(--hud-glow-rgb) / 0.07), transparent 36%),
radial-gradient(circle at 86% 14%, rgb(var(--hud-glow-alt-rgb) / 0.05), transparent 32%),
linear-gradient(170deg, var(--hud-bg-20) 0%, var(--hud-bg-10) 56%, var(--hud-bg-30) 100%);
}
.hud-vignette {
position: absolute;
inset: 0;
background: radial-gradient(circle at center, transparent 41%, rgb(0 0 0 / 0.66) 100%);
}
.hud-noise {
position: absolute;
inset: -12%;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 140 140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E");
opacity: 0.025;
mix-blend-mode: soft-light;
}
.hud-layout {
position: relative;
z-index: 1;
display: grid;
height: 100%;
min-height: 0;
grid-template-rows: auto minmax(0, 1fr);
gap: clamp(0.5rem, 1.2vw, 0.95rem);
padding: clamp(0.65rem, 1.75vw, 1.3rem);
border: 1px solid rgb(var(--hud-border-rgb) / 0.2);
border-radius: 0.9rem;
background:
linear-gradient(
176deg,
rgb(var(--hud-surface-alt-rgb) / 0.9) 0%,
rgb(var(--hud-surface-deep-rgb) / 0.94) 56%,
rgb(var(--hud-surface-rgb) / 0.9) 100%
),
radial-gradient(circle at 18% 0%, rgb(var(--hud-glow-rgb) / 0.05), transparent 40%),
radial-gradient(circle at 84% 8%, rgb(var(--hud-glow-alt-rgb) / 0.04), transparent 36%);
box-shadow:
inset 0 1px 0 rgb(var(--hud-border-strong-rgb) / 0.08),
inset 0 -28px 60px rgb(0 0 0 / 0.34);
overflow: hidden;
}
.range-scale {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 0.75rem;
border: 1px solid rgb(var(--hud-border-rgb) / 0.24);
border-radius: 0.48rem;
padding: 0.34rem 0.52rem;
background:
linear-gradient(180deg, rgb(var(--hud-surface-rgb) / 0.72), rgb(var(--hud-surface-deep-rgb) / 0.56)),
radial-gradient(circle at 50% 0, rgb(var(--hud-glow-rgb) / 0.06), transparent 52%);
box-shadow:
inset 0 1px 0 rgb(var(--hud-border-strong-rgb) / 0.06),
0 0 12px rgb(var(--hud-glow-rgb) / 0.08);
pointer-events: none;
}
.range-label {
margin: 0;
color: rgb(var(--hud-text-dim-rgb) / 0.82);
font-size: 0.56rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.range-track {
position: relative;
isolation: isolate;
display: grid;
grid-template-columns: repeat(11, minmax(0, 1fr));
gap: 0.26rem;
padding: 0.28rem 0.36rem 0.16rem;
border: 1px solid rgb(var(--hud-border-strong-rgb) / 0.14);
border-radius: 999px;
background: rgb(var(--hud-surface-rgb) / 0.34);
overflow: hidden;
}
.range-track::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
background:
linear-gradient(
90deg,
color-mix(in srgb, var(--hud-range-0) 92%, black) 0%,
color-mix(in srgb, var(--hud-range-1) 96%, black) 12.5%,
color-mix(in srgb, var(--hud-range-1) 92%, black) 25%,
color-mix(in srgb, var(--hud-range-2) 96%, black) 37.5%,
color-mix(in srgb, var(--hud-range-2) 92%, black) 50%,
color-mix(in srgb, var(--hud-range-3) 96%, black) 62.5%,
color-mix(in srgb, var(--hud-range-3) 92%, black) 75%,
color-mix(in srgb, var(--hud-range-4) 96%, black) 87.5%,
color-mix(in srgb, var(--hud-range-5) 94%, black) 100%
),
linear-gradient(180deg, rgb(var(--hud-text-main-rgb) / 0.06), transparent 42%);
box-shadow:
inset 0 1px 0 rgb(var(--hud-text-main-rgb) / 0.1),
inset 0 -10px 18px rgb(0 0 0 / 0.18);
opacity: 0.94;
}
.range-track::after {
content: "";
position: absolute;
inset-inline: 0.32rem;
inset-block-start: 0.22rem;
block-size: 0.18rem;
z-index: 0;
border-radius: 999px;
background: linear-gradient(
90deg,
var(--hud-range-glow-0) 0%,
var(--hud-range-glow-1) 52%,
var(--hud-range-glow-2) 100%
);
filter: blur(0.18rem);
}
.range-tick {
position: relative;
z-index: 1;
padding-block-start: 0.36rem;
color: rgb(var(--hud-text-main-rgb) / 0.96);
font-size: 0.56rem;
text-align: center;
text-shadow:
0 1px 0 rgb(0 0 0 / 0.46),
0 0 12px rgb(var(--hud-surface-alt-rgb) / 0.4);
}
.range-tick::before {
content: "";
position: absolute;
inset-block-start: 0;
inset-inline: 50%;
inline-size: 1px;
block-size: 0.24rem;
transform: translateX(-50%);
background: rgb(var(--hud-text-main-rgb) / 0.74);
box-shadow: 0 0 8px rgb(var(--hud-glow-rgb) / 0.22);
}
@media (max-width: 760px) {
.range-scale {
grid-template-columns: 1fr;
}
}
.devkit-overlay {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background: rgb(0 0 0 / 0.5);
backdrop-filter: blur(6px);
}
.devkit-float {
position: relative;
z-index: 1;
}
</style>