Files
JE-Skin/src/routes/+page.svelte
lennlouisgeek eec9927ae6 first commit
2026-03-30 02:59:56 +08:00

1446 lines
43 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 HudPanel from "$lib/components/HudPanel.svelte";
import CenterStage from "$lib/components/CenterStage.svelte";
import { pressureColorPalettes } from "$lib/config/color-map";
import "$lib/styles/theme.css";
import type {
ConnectionState,
HudColorMapOption,
HudCopy,
HudConfigLink,
HudNoticeTone,
HudPacket,
PressureColorMapPreset,
HudSignalPanel,
HudSignalSeries,
HudSummary,
LocaleCode,
SerialConnectResult,
SerialExportResult,
SerialImportResult,
SignalTone,
StageStatusTone,
WindowControlAction
} from "$lib/types/hud";
type SignalPanelTemplate = Pick<HudSignalPanel, "id" | "code" | "title" | "side">;
interface ReplayFrame {
values: number[];
dtsMs: number;
}
const copyByLocale: Record<LocaleCode, HudCopy> = {
"zh-CN": {
appName: "PAXINI HUD",
suiteName: "PX-6AX GEN3",
stageTitle: "WebGL2 主渲染区",
stageHint: "底图与三维操作将在此区域加载",
configPanelTitle: "参数配置",
configPanelHint: "矩阵规模与颜色映射范围会实时作用到主舞台。",
matrixSizeLabel: "点阵数量",
matrixRowsLabel: "行数",
matrixColsLabel: "列数",
rangeLabel: "映射范围",
rangeMinLabel: "最小值",
rangeMaxLabel: "最大值",
colorMapLabel: "映射颜色",
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",
replaySectionLabel: "回放",
replayPlayLabel: "播放",
replayPauseLabel: "暂停",
replayStopLabel: "停止",
replaySpeedLabel: "速度",
replayProgressLabel: "进度",
replayEmptyHint: "未加载回放文件",
connectedLabel: "已连接",
connectingLabel: "连接中",
disconnectedLabel: "未连接"
},
"en-US": {
appName: "PAXINI HUD",
suiteName: "PX-6AX GEN3",
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",
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",
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 isExporting = false;
let deviceValue = "PX-Sense Unit";
let sampleRateValue = "120Hz";
let channelsValue = "8";
let webglStatusTone: StageStatusTone = "warn";
let isWindowMaximized = false;
let activeConfigLinkId = "stream-on";
let isConfigPanelOpen = false;
let hasSignalData = false;
let signalPanels: HudSignalPanel[] = buildInactivePanels();
let summary: HudSummary = buildEmptySummary();
let pressureMatrix: number[] | null = null;
let matrixRows = 12;
let matrixCols = 7;
let rangeMin = 0;
let rangeMax = 5000;
let colorMapPreset: PressureColorMapPreset = "emerald";
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;
$: uiCopy = copyByLocale[locale];
$: configLinks = buildConfigLinks(locale, activeConfigLinkId, isConfigPanelOpen);
$: stageStatusText = webglStatusTone === "ok" ? uiCopy.runtimeReady : uiCopy.runtimeFallback;
$: 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}` : "";
function isTauriRuntime(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
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;
return [
`--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 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);
const maxRawValue = Math.max(rangeMax, 5000);
for (let index = 0; index < totalCells; index += 1) {
const value = Number(values[index] ?? 0);
matrix[index] = clamp(Number.isFinite(value) ? value : 0, 0, maxRawValue);
}
return matrix;
}
function buildZeroMatrix(): number[] {
const totalCells = Math.max(matrixRows * matrixCols, 1);
return new Array<number>(totalCells).fill(0);
}
function resetReplayVisualState(): void {
pressureMatrix = buildZeroMatrix();
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[] = [];
for (let cursor = startIndex; cursor <= safeIndex; cursor += 1) {
points.push(replayFrameTotal(replayFrames[cursor]));
}
return buildSummary(points);
}
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);
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: "TOTAL",
points: [],
latest: null,
min: null,
max: null
};
}
function buildSummary(points: number[]): HudSummary {
if (points.length === 0) {
return buildEmptySummary();
}
return {
label: "TOTAL",
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 points =
summaryValue.points.length >= summaryPointsPerSeries
? summaryValue.points.slice(1)
: summaryValue.points.slice();
points.push(next);
return buildSummary(points);
}
function buildInactivePanels(): HudSignalPanel[] {
return [];
}
function applyPacket(packet: HudPacket): void {
if (replayHasData) {
return;
}
signalPanels = showSignalPanels ? packet.panels : buildInactivePanels();
summary = packet.summary;
pressureMatrix = packet.pressureMatrix;
hasSignalData = signalPanels.length > 0 || packet.summary.points.length > 0;
}
function clearHudPanels(): void {
hasSignalData = false;
signalPanels = buildInactivePanels();
summary = buildEmptySummary();
pressureMatrix = null;
}
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 });
const timerId = window.setInterval(() => {
summaryValue = evolveSummary(summaryValue);
push({ ts: Date.now(), panels, summary: summaryValue, pressureMatrix: 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): HudConfigLink[] {
const labels =
currentLocale === "zh-CN"
? {
streamOn: "打开",
streamOff: "关闭",
calibrate: "校准",
settings: "参数"
}
: {
streamOn: "Open",
streamOff: "Close",
calibrate: "Calib",
settings: "Setup"
};
return [
{
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: "calibrate",
label: labels.calibrate,
tone: "cyan",
active: activeId === "calibrate"
},
{
id: "settings",
label: labels.settings,
tone: "neutral",
active: isSettingsOpen
}
];
}
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");
webglStatusTone = context ? "ok" : "warn";
}
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 handleSerialExport(): Promise<void> {
if (!isTauriRuntime()) {
console.warn("[serial] Export is only available inside Tauri.");
return;
}
isExporting = true;
try {
const result = await invoke<SerialExportResult>("serial_export_csv");
connectionNotice =
locale === "zh-CN"
? `CSV 导出成功(${result.frameCount} 帧):${result.path}`
: `CSV exported (${result.frameCount} frames): ${result.path}`;
connectionNoticeTone = "ok";
} catch (error) {
connectionNotice = resolveExportNotice(error);
connectionNoticeTone = "warn";
console.error("Serial export failed:", error);
} finally {
isExporting = false;
}
}
async function handleReplayImport(event: CustomEvent<File>): Promise<void> {
const file = event.detail;
if (!file) {
return;
}
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;
}
if (!frames.length) {
throw new Error("EmptyReplayData");
}
replayFrames = frames;
replayFileName = file.name;
replayCurrentIndex = 0;
replayHasDisplayedFrame = false;
replayProgress = 0;
resetReplayVisualState();
connectionNotice =
locale === "zh-CN"
? `已导入 ${file.name},共 ${importedFrameCount} 帧 / ${importedChannelCount} 通道,可开始回放。`
: `${file.name} loaded (${importedFrameCount} frames / ${importedChannelCount} channels). Ready to replay.`;
connectionNoticeTone = "ok";
} catch (error) {
connectionNotice = resolveImportNotice(error);
connectionNoticeTone = "warn";
console.error("Replay import failed:", error);
}
}
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 === "settings") {
isConfigPanelOpen = !isConfigPanelOpen;
return;
}
isConfigPanelOpen = 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);
}
}
onMount(() => {
let disposed = false;
let unlistenHudStream: UnlistenFn | null = null;
let stopMockFeed: (() => void) | null = null;
void ensureDefaultWindowSize();
void syncWindowState();
void probeWebgl2();
if (isTauriRuntime()) {
void refreshSerialPorts();
void startTauriHudStream(applyPacket)
.then((unlisten) => {
if (disposed) {
unlisten();
return;
}
unlistenHudStream = unlisten;
})
.catch((error) => {
console.error("Failed to listen for hud_stream:", error);
});
} else {
stopMockFeed = startMockFeed(applyPacket);
}
return () => {
disposed = true;
pauseReplayPlayback();
stopMockFeed?.();
unlistenHudStream?.();
};
});
</script>
<main class="hud-screen">
<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}
connectActionLabel={uiCopy.connectActionLabel}
disconnectActionLabel={uiCopy.disconnectActionLabel}
exportActionLabel={uiCopy.exportActionLabel}
exportingActionLabel={uiCopy.exportingActionLabel}
importActionLabel={uiCopy.importActionLabel}
{connectionNotice}
{connectionNoticeTone}
{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:serialrefresh={handleSerialRefresh}
on:serialconnect={handleSerialConnect}
on:serialexport={handleSerialExport}
on:csvimport={handleReplayImport}
/>
<CenterStage
bind:matrixRows
bind:matrixCols
bind:rangeMin
bind:rangeMax
bind:colorMapPreset
title={uiCopy.stageTitle}
hint={uiCopy.stageHint}
configPanelTitle={uiCopy.configPanelTitle}
configPanelHint={uiCopy.configPanelHint}
matrixSizeLabel={uiCopy.matrixSizeLabel}
matrixRowsLabel={uiCopy.matrixRowsLabel}
matrixColsLabel={uiCopy.matrixColsLabel}
rangeLabel={uiCopy.rangeLabel}
rangeMinLabel={uiCopy.rangeMinLabel}
rangeMaxLabel={uiCopy.rangeMaxLabel}
colorMapLabel={uiCopy.colorMapLabel}
{colorMapOptions}
replaySectionLabel={uiCopy.replaySectionLabel}
replayPlayLabel={uiCopy.replayPlayLabel}
replayPauseLabel={uiCopy.replayPauseLabel}
replayStopLabel={uiCopy.replayStopLabel}
replaySpeedLabel={uiCopy.replaySpeedLabel}
replayProgressLabel={uiCopy.replayProgressLabel}
{replayHasData}
{replayIsPlaying}
{replaySpeed}
{replayProgress}
{replayFileName}
{replayFrameInfo}
resetConfigLabel={uiCopy.resetConfigLabel}
applyLiveHint={uiCopy.applyLiveHint}
statusText={stageStatusText}
statusTone={webglStatusTone}
leftPanels={leftSignalPanels}
rightPanels={rightSignalPanels}
{pressureMatrix}
showConfigPanel={isConfigPanelOpen}
{summary}
on:replaytoggle={handleReplayToggle}
on:replaystop={handleReplayStop}
on:replayseek={handleReplaySeek}
on:replayspeed={handleReplaySpeed}
on:replayclose={handleReplayClose}
on:configclose={() => (isConfigPanelOpen = false)}
>
<section class="range-scale" aria-label="Signal Range" style={rangeScaleStyle}>
<p class="range-label">Range</p>
<div class="range-track">
{#each rangeTicks as tick}
<span class="range-tick">{tick}</span>
{/each}
</div>
</section>
</CenterStage>
</div>
</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(62 232 255 / 0.07), transparent 36%),
radial-gradient(circle at 86% 14%, rgb(133 255 68 / 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(111 150 173 / 0.2);
border-radius: 0.9rem;
background:
linear-gradient(176deg, rgb(8 10 12 / 0.9) 0%, rgb(0 0 0 / 0.94) 56%, rgb(6 8 10 / 0.9) 100%),
radial-gradient(circle at 18% 0%, rgb(62 232 255 / 0.05), transparent 40%),
radial-gradient(circle at 84% 8%, rgb(133 255 68 / 0.04), transparent 36%);
box-shadow:
inset 0 1px 0 rgb(197 228 245 / 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(103 135 154 / 0.24);
border-radius: 0.48rem;
padding: 0.34rem 0.52rem;
background:
linear-gradient(180deg, rgb(4 10 14 / 0.72), rgb(2 6 10 / 0.56)),
radial-gradient(circle at 50% 0, rgb(62 232 255 / 0.06), transparent 52%);
box-shadow:
inset 0 1px 0 rgb(176 218 240 / 0.06),
0 0 12px rgb(62 232 255 / 0.08);
pointer-events: none;
}
.range-label {
margin: 0;
color: rgb(146 170 187 / 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(131 181 200 / 0.14);
border-radius: 999px;
background: rgb(6 13 16 / 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) 94%, black) 18%,
color-mix(in srgb, var(--hud-range-2) 96%, black) 40%,
color-mix(in srgb, var(--hud-range-3) 98%, black) 66%,
color-mix(in srgb, var(--hud-range-4) 96%, black) 84%,
color-mix(in srgb, var(--hud-range-5) 94%, black) 100%
),
linear-gradient(180deg, rgb(255 255 255 / 0.06), transparent 42%);
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 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(230 243 252 / 0.96);
font-size: 0.56rem;
text-align: center;
text-shadow:
0 1px 0 rgb(0 0 0 / 0.46),
0 0 12px rgb(10 18 24 / 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(234 247 255 / 0.74);
box-shadow: 0 0 8px rgb(62 232 255 / 0.22);
}
@media (max-width: 760px) {
.range-scale {
grid-template-columns: 1fr;
}
}
</style>