2257 lines
69 KiB
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>
|