5 Commits

Author SHA1 Message Date
lenn
2c6fee9aad Update raw_to_g1 calibration points 2026-06-09 18:35:31 +08:00
lenn
2828eaafb5 Refine sample delivery force UI and mapping 2026-06-09 18:26:14 +08:00
lenn
821800beb1 refactor: add debug feature flag and cross-platform serial port filtering 2026-06-09 14:27:30 +08:00
lenn
8533747670 Add sample delivery changelog 2026-06-09 11:17:24 +08:00
lenn
160ff54368 Prepare sample delivery UI 2026-06-09 11:05:18 +08:00
16 changed files with 973 additions and 970 deletions

32
docs/CHANGELOG.md Normal file
View File

@@ -0,0 +1,32 @@
# 更新日志
## 2026-06-09 - 送样分支
分支:`sample-delivery`
提交:`160ff54 Prepare sample delivery UI`
### 显示与交互
- 合力曲线改为 Canvas 渲染,保留原有 HUD 风格。
- 合力曲线 panel 宽度调整为与三维力 panel 一致。
- 卸力后合力曲线继续接收并渲染后端 0 值数据panel 延迟 5 秒消失。
- 5 秒内重新施力会取消合力曲线的消失计时。
- 三维力 panel 改为卸力后延迟 5 秒消失。
- 三维力箭头仍然实时跟随后端数据,卸力后箭头立即消失,不冻结最后方向。
### 后端数据
- 合力低于显示阈值时,后端不再清空 summary 数组,而是持续写入 `0.0`
- 压力矩阵在低于显示阈值时仍然清零。
- 前端可以通过 summary 数组是否存在和最新值大小来判断释放状态。
### 显示范围
- 合力与压力数值显示增加最大显示值处理:超过 `25.6` 时显示为 `25.6+`
### 验证
- `npm.cmd run check` 通过0 errors保留 13 个既有 warnings。
- `cargo check` 通过。

104
package-lock.json generated
View File

@@ -9,7 +9,7 @@
"version": "0.4.0", "version": "0.4.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1", "@tauri-apps/plugin-updater": "^2.10.1",
@@ -19,7 +19,7 @@
"@sveltejs/adapter-static": "^3.0.6", "@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.9.0", "@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2.11.2",
"@types/three": "^0.183.1", "@types/three": "^0.183.1",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
@@ -1032,9 +1032,9 @@
} }
}, },
"node_modules/@tauri-apps/api": { "node_modules/@tauri-apps/api": {
"version": "2.10.1", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==", "integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==",
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -1042,9 +1042,9 @@
} }
}, },
"node_modules/@tauri-apps/cli": { "node_modules/@tauri-apps/cli": {
"version": "2.10.1", "version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.2.tgz",
"integrity": "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==", "integrity": "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==",
"dev": true, "dev": true,
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"bin": { "bin": {
@@ -1058,23 +1058,23 @@
"url": "https://opencollective.com/tauri" "url": "https://opencollective.com/tauri"
}, },
"optionalDependencies": { "optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-arm64": "2.11.2",
"@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.11.2",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2",
"@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.11.2",
"@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.11.2",
"@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.2",
"@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.11.2",
"@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.11.2",
"@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.11.2",
"@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.11.2",
"@tauri-apps/cli-win32-x64-msvc": "2.10.1" "@tauri-apps/cli-win32-x64-msvc": "2.11.2"
} }
}, },
"node_modules/@tauri-apps/cli-darwin-arm64": { "node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.10.1", "version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.2.tgz",
"integrity": "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==", "integrity": "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1089,9 +1089,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-darwin-x64": { "node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.10.1", "version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.2.tgz",
"integrity": "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==", "integrity": "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1106,9 +1106,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.10.1", "version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.2.tgz",
"integrity": "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==", "integrity": "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1123,9 +1123,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-arm64-gnu": { "node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.10.1", "version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.2.tgz",
"integrity": "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==", "integrity": "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1143,9 +1143,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-arm64-musl": { "node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.10.1", "version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.2.tgz",
"integrity": "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==", "integrity": "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1163,9 +1163,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": { "node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.10.1", "version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.2.tgz",
"integrity": "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==", "integrity": "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -1183,9 +1183,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-x64-gnu": { "node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.10.1", "version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.2.tgz",
"integrity": "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==", "integrity": "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1203,9 +1203,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-x64-musl": { "node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.10.1", "version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.2.tgz",
"integrity": "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==", "integrity": "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1223,9 +1223,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-win32-arm64-msvc": { "node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.10.1", "version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.2.tgz",
"integrity": "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==", "integrity": "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1240,9 +1240,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-win32-ia32-msvc": { "node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.10.1", "version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.2.tgz",
"integrity": "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==", "integrity": "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -1257,9 +1257,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-win32-x64-msvc": { "node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.10.1", "version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz",
"integrity": "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==", "integrity": "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],

View File

@@ -15,7 +15,7 @@
}, },
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1", "@tauri-apps/plugin-updater": "^2.10.1",
@@ -25,7 +25,7 @@
"@sveltejs/adapter-static": "^3.0.6", "@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.9.0", "@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2.11.2",
"@types/three": "^0.183.1", "@types/three": "^0.183.1",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",

View File

@@ -16,6 +16,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
[features] [features]
default = ["multi-dim"] default = ["multi-dim"]
debug = []
devkit = ["dep:tonic", "dep:prost", "dep:prost-types", "dep:async-stream", "dep:dirs"] devkit = ["dep:tonic", "dep:prost", "dep:prost-types", "dep:async-stream", "dep:dirs"]
multi-dim = ["dep:ndarray"] multi-dim = ["dep:ndarray"]

View File

@@ -122,8 +122,7 @@ pub fn serial_enum() -> Result<Vec<String>, SerialError> {
.into_iter() .into_iter()
.filter_map(|p| { .filter_map(|p| {
let name = p.port_name; let name = p.port_name;
#[cfg(unix)] if !should_include_serial_port(&name) {
if !name.contains("USB") {
return None; return None;
} }
Some(name) Some(name)
@@ -133,6 +132,21 @@ pub fn serial_enum() -> Result<Vec<String>, SerialError> {
Ok(ports) Ok(ports)
} }
#[cfg(target_os = "macos")]
fn should_include_serial_port(name: &str) -> bool {
name.to_ascii_lowercase().contains("usb")
}
#[cfg(all(unix, not(target_os = "macos")))]
fn should_include_serial_port(name: &str) -> bool {
name.contains("USB")
}
#[cfg(not(unix))]
fn should_include_serial_port(_name: &str) -> bool {
true
}
#[tauri::command] #[tauri::command]
pub async fn serial_connect( pub async fn serial_connect(
app: AppHandle, app: AppHandle,
@@ -185,7 +199,7 @@ pub async fn serial_connect(
) )
.await .await
{ {
eprintln!("serial task exited with error: {error}"); log::error!("serial task exited with error: {error}");
} }
let manager = task_app.state::<SerialConnectionState>(); let manager = task_app.state::<SerialConnectionState>();

View File

@@ -62,7 +62,6 @@ impl Codec<TestFrame> for TestCodec {
continue; continue;
} }
let dts = elapsed_millis(session_started_at); let dts = elapsed_millis(session_started_at);
println!("dts_ms: {dts}");
frames.push(TestFrame { frames.push(TestFrame {
header: [0xAA, 0x55], header: [0xAA, 0x55],
cmd: cmd, cmd: cmd,
@@ -244,11 +243,10 @@ mod tests {
fn test_read_csv_basic() -> anyhow::Result<()> { fn test_read_csv_basic() -> anyhow::Result<()> {
let mut rdr = Reader::from_path("recording_20260329_125238.csv")?; let mut rdr = Reader::from_path("recording_20260329_125238.csv")?;
let headers = rdr.headers()?; let headers = rdr.headers()?;
println!("headers: {:?}", headers);
for result in rdr.records() { for result in rdr.records() {
let record = result?; let record = result?;
println!("record: {:?}", record); let _ = record;
} }
Ok(()) Ok(())

View File

@@ -118,6 +118,10 @@ impl HudChartState {
push_summary_point(&mut self.summary_points, value); push_summary_point(&mut self.summary_points, value);
} }
pub fn clear_summary(&mut self) {
self.summary_points.clear();
}
pub fn record_pressure_matrix(&mut self, values: &[i32]) { pub fn record_pressure_matrix(&mut self, values: &[i32]) {
if values.is_empty() { if values.is_empty() {
return; return;

File diff suppressed because it is too large Load Diff

View File

@@ -317,7 +317,7 @@ where
#[cfg(feature = "multi-dim")] #[cfg(feature = "multi-dim")]
{ {
let pzt_values = vals.iter().map(|value| *value as f32).collect::<Vec<f32>>(); let pzt_values = vals.iter().map(|value| *value as f32).collect::<Vec<f32>>();
if let Ok(analysis) = pzt_processor.get_pzt_analysis(&pzt_values) { if let Ok(analysis) = pzt_processor.get_pzt_analysis_at(&pzt_values, frame.dts_ms() as f32) {
if PztProcessor::should_report(&analysis) { if PztProcessor::should_report(&analysis) {
spatial_force = Some(HudSpatialForce { spatial_force = Some(HudSpatialForce {
angle_deg: analysis.angle_deg, angle_deg: analysis.angle_deg,
@@ -327,12 +327,15 @@ where
} }
} }
} }
#[cfg(feature = "devkit")] // #[cfg(feature = "devkit")]
{ // {
let summary = vals.iter().copied().sum::<i32>(); // let summary = vals.iter().copied().sum::<i32>();
let force = raw_to_g1(summary as u32); // #[cfg(feature = "debug")]
push_devkit_frame(&app, vals.as_slice(), frame.dts_ms(), force); // let force = raw_to_g1(summary as u32);
} // #[cfg(not(feature = "debug"))]
// let force = summary as f64;
// push_devkit_frame(&app, vals.as_slice(), frame.dts_ms(), force);
// }
pending_sub_frame = Some(PendingSubFrame { pending_sub_frame = Some(PendingSubFrame {
frame: frame.clone(), frame: frame.clone(),
@@ -355,13 +358,24 @@ fn build_display_values(
spatial_force: Option<HudSpatialForce>, spatial_force: Option<HudSpatialForce>,
) -> Option<Vec<i32>> { ) -> Option<Vec<i32>> {
let summary = values.iter().copied().sum::<i32>(); let summary = values.iter().copied().sum::<i32>();
let force = raw_to_g1(summary as u32);
chart_state.record_summary(force as f32);
chart_state.record_pressure_matrix(values);
chart_state.record_spatial_force(spatial_force); chart_state.record_spatial_force(spatial_force);
Some(vec![summary])
let x = raw_to_g1(summary as u32).min(MAX_DISPLAY_FORCE);
if x <= MIN_DISPLAY_FORCE {
let zero_values = vec![0; values.len()];
chart_state.record_summary(0.0);
chart_state.record_pressure_matrix(&zero_values);
return Some(vec![0]);
} }
chart_state.record_pressure_matrix(values);
chart_state.record_summary(x as f32);
Some(vec![x.round() as i32])
}
const MIN_DISPLAY_FORCE: f64 = 0.1;
const MAX_DISPLAY_FORCE: f64 = 25.6;
#[cfg(feature = "devkit")] #[cfg(feature = "devkit")]
fn push_devkit_frame(app: &AppHandle, values: &[i32], dts_ms: u64, resultant_force: f64) { fn push_devkit_frame(app: &AppHandle, values: &[i32], dts_ms: u64, resultant_force: f64) {
let devkit_state = app.state::<DevKitState>(); let devkit_state = app.state::<DevKitState>();
@@ -416,11 +430,11 @@ fn infer_matrix_shape(len: usize) -> (u32, u32) {
fn raw_to_g1(raw: u32) -> f64 { fn raw_to_g1(raw: u32) -> f64 {
const X: [u32; 12] = [ const X: [u32; 12] = [
0, 84402, 117218, 140176, 159126, 175812, 191484, 208758, 224703, 252448, 302361, 352703, 0, 25077, 104682, 133468, 160843, 181418, 207589, 243402, 285163, 341185, 401743, 450145
]; ];
const Y: [f64; 12] = [ const Y: [f64; 12] = [
0.0, 160.0, 260.0, 360.0, 460.0, 560.0, 660.0, 760.0, 860.0, 1060.0, 1560.0, 2060.0, 0.0, 57.0, 257.0, 357.0, 457.0, 557.0, 657.0, 857.0, 1057.0, 1557.0, 2057.0, 2557.0
]; ];
let n = X.len(); let n = X.len();

View File

@@ -41,9 +41,7 @@
"template": "nsis/installer.nsi" "template": "nsis/installer.nsi"
} }
}, },
"resources": [ "resources": []
"resources/je-skin-devkit-server.exe"
]
}, },
"plugins": { "plugins": {
"updater": { "updater": {

View File

@@ -61,6 +61,8 @@
export let replayFileName = ""; export let replayFileName = "";
export let replayFrameInfo = ""; export let replayFrameInfo = "";
export let sessionStartedAt: number = Date.now(); export let sessionStartedAt: number = Date.now();
export let summaryReleasePending = false;
export let spatialForcePanelVisible = false;
let stagePlaneEl: HTMLDivElement | undefined; let stagePlaneEl: HTMLDivElement | undefined;
let panelZoneEl: HTMLDivElement | undefined; let panelZoneEl: HTMLDivElement | undefined;
@@ -87,7 +89,9 @@
$: replaySide = summarySide === "left" ? "right" : "left"; $: replaySide = summarySide === "left" ? "right" : "left";
$: replayToggleButtonText = replayIsPlaying ? replayPauseLabel : replayPlayLabel; $: replayToggleButtonText = replayIsPlaying ? replayPauseLabel : replayPlayLabel;
$: replayProgressPercent = Math.round(Math.min(1, Math.max(0, replayProgress)) * 100); $: replayProgressPercent = Math.round(Math.min(1, Math.max(0, replayProgress)) * 100);
$: summaryCurveVisible = summary.points.length > 0 && summary.points.some((value) => Number.isFinite(value) && Math.abs(value) >= 0.0001); $: summaryCurveVisible =
summary.points.length > 0 &&
(summaryReleasePending || summary.points.some((value) => Number.isFinite(value) && Math.abs(value) >= 0.0001));
$: isModelStage = stageViewMode === "model3d"; $: isModelStage = stageViewMode === "model3d";
function toPxNumber(rawValue: string): number { function toPxNumber(rawValue: string): number {
@@ -276,9 +280,7 @@
<SignalChart {panel} panelIndex={index} {locale} /> <SignalChart {panel} panelIndex={index} {locale} />
</div> </div>
{/each} {/each}
{#if spatialForcePanelVisible}
{#if spatialForce}
<div <div
class="panel-motion-shell" class="panel-motion-shell"
in:fly={{ x: 180, duration: 340, opacity: 0.08, easing: cubicOut }} in:fly={{ x: 180, duration: 340, opacity: 0.08, easing: cubicOut }}
@@ -311,7 +313,7 @@
{sessionStartedAt} {sessionStartedAt}
isRealtime={!replayHasData} isRealtime={!replayHasData}
side="right" side="right"
panelIndex={rightPanels.length} panelIndex={rightPanels.length + (spatialForcePanelVisible ? 1 : 0)}
/> />
</div> </div>
{/if} {/if}

View File

@@ -67,6 +67,7 @@
const CAMERA_TARGET_Z = MATRIX_OFFSET_Z - 0.4; const CAMERA_TARGET_Z = MATRIX_OFFSET_Z - 0.4;
const MATRIX_ROTATION_Y = 0; const MATRIX_ROTATION_Y = 0;
const rangeStopPositions = [0, 0.25, 0.5, 0.75, 0.875, 1] as const; const rangeStopPositions = [0, 0.25, 0.5, 0.75, 0.875, 1] as const;
const maxDisplayForce = 25.6;
const labelVector = new THREE.Vector3(); const labelVector = new THREE.Vector3();
$: resolvedColorPalette = pressureColorPalettes[colorMapPreset] ?? pressureColorPalettes.emerald; $: resolvedColorPalette = pressureColorPalettes[colorMapPreset] ?? pressureColorPalettes.emerald;
@@ -145,6 +146,10 @@
return "--"; return "--";
} }
if (value >= maxDisplayForce) {
return `${maxDisplayForce.toFixed(1)}+`;
}
return value.toFixed(1); return value.toFixed(1);
} }
@@ -672,14 +677,6 @@
<span class="stats-key">{viewerI18n.current}</span> <span class="stats-key">{viewerI18n.current}</span>
<strong class="stats-value">{formatForceStat(stats.current)}</strong> <strong class="stats-value">{formatForceStat(stats.current)}</strong>
</article> </article>
<article class="stats-card">
<span class="stats-key">{viewerI18n.max}</span>
<strong class="stats-value">{formatForceStat(stats.max)}</strong>
</article>
<article class="stats-card">
<span class="stats-key">{viewerI18n.min}</span>
<strong class="stats-value">{formatForceStat(stats.min)}</strong>
</article>
</div> </div>
<p class="stats-note">{statsNote}</p> <p class="stats-note">{statsNote}</p>
</section> </section>
@@ -768,7 +765,7 @@
.stats-grid { .stats-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: minmax(0, 1fr);
gap: 0.46rem; gap: 0.46rem;
} }

View File

@@ -11,6 +11,7 @@
const viewportWidth = 100; const viewportWidth = 100;
const viewportHeight = 36; const viewportHeight = 36;
const maxDisplayForce = 25.6;
const toneColorMap: Record<SignalTone, string> = { const toneColorMap: Record<SignalTone, string> = {
cyan: "62 232 255", cyan: "62 232 255",
@@ -32,7 +33,15 @@
} }
function formatMetric(value: number | null): string { function formatMetric(value: number | null): string {
return value === null ? "--" : value.toFixed(1); if (value === null) {
return "--";
}
if (value >= maxDisplayForce) {
return `${maxDisplayForce.toFixed(1)}+`;
}
return value.toFixed(1);
} }
function resolveBounds(seriesCollection: HudSignalPanel["series"]): { min: number; max: number } { function resolveBounds(seriesCollection: HudSignalPanel["series"]): { min: number; max: number } {

View File

@@ -20,6 +20,7 @@
return delta === -180 ? 180 : delta; return delta === -180 ? 180 : delta;
} }
const displayAngleOffsetDeg = 90;
const jumpAngleThresholdDeg = 72; const jumpAngleThresholdDeg = 72;
let visualAngleDeg = 0; let visualAngleDeg = 0;
@@ -94,14 +95,14 @@
$: hasData = $: hasData =
spatialForce !== null && spatialForce !== null &&
Number.isFinite(spatialForce.angleDeg) && Number.isFinite(spatialForce.angleDeg) &&
(!requireMagnitude || Number.isFinite(spatialForce.magnitude)); (!requireMagnitude || (Number.isFinite(spatialForce.magnitude) && Math.abs(spatialForce.magnitude) >= 0.0001));
$: angleDeg = hasData ? normalizeAngle(spatialForce?.angleDeg ?? 0) : 0; $: angleDeg = hasData ? normalizeAngle((spatialForce?.angleDeg ?? 0) + displayAngleOffsetDeg) : 0;
$: updateVisualAngle(angleDeg, hasData); $: updateVisualAngle(angleDeg, hasData);
</script> </script>
{#if hasData}
<article <article
class="signal-panel spatial-panel side-{side}" class="signal-panel spatial-panel side-{side}"
class:is-empty={!hasData}
aria-label={resolvedTitle} aria-label={resolvedTitle}
style="--panel-index: {panelIndex};" style="--panel-index: {panelIndex};"
> >
@@ -125,6 +126,7 @@
<div class="compass-ring compass-ring-inner"></div> <div class="compass-ring compass-ring-inner"></div>
<div class="compass-axis axis-horizontal"></div> <div class="compass-axis axis-horizontal"></div>
<div class="compass-axis axis-vertical"></div> <div class="compass-axis axis-vertical"></div>
{#if hasData}
<div <div
class="compass-vector" class="compass-vector"
class:is-snap={snapVector} class:is-snap={snapVector}
@@ -133,16 +135,17 @@
<span class="vector-shaft"></span> <span class="vector-shaft"></span>
<span class="vector-head"></span> <span class="vector-head"></span>
</div> </div>
{/if}
<div class="compass-center"></div> <div class="compass-center"></div>
<span class="compass-label label-top">90</span> <span class="compass-label label-top">0</span>
<span class="compass-label label-right">0</span> <span class="compass-label label-right">270</span>
<span class="compass-label label-bottom">270</span> <span class="compass-label label-bottom">180</span>
<span class="compass-label label-left">180</span> <span class="compass-label label-left">90</span>
</div> </div>
</div> </div>
</div> </div>
</article> </article>
{/if}
<style> <style>
.signal-panel { .signal-panel {
--offset-x: 12%; --offset-x: 12%;
@@ -261,14 +264,16 @@
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
gap: 0.72rem; gap: 0.72rem;
block-size: clamp(12rem, 15.5vw, 15rem); block-size: clamp(14rem, 17vw, 16.5rem);
min-block-size: 5rem; min-block-size: 5rem;
} }
.compass-stage { .compass-stage {
position: relative; position: relative;
min-block-size: 0; min-block-size: 0;
box-sizing: border-box;
overflow: hidden; overflow: hidden;
padding: 1.35rem 1.7rem;
border: 1px solid rgb(var(--hud-border-strong-rgb) / 0.32); border: 1px solid rgb(var(--hud-border-strong-rgb) / 0.32);
border-radius: 0.62rem; border-radius: 0.62rem;
background: background:
@@ -280,7 +285,8 @@
.compass-core { .compass-core {
position: relative; position: relative;
inline-size: min(72%, 13rem); inline-size: min(72%, 12.2rem, calc(100% - 3.4rem));
min-inline-size: 6.2rem;
aspect-ratio: 1; aspect-ratio: 1;
} }
@@ -379,26 +385,26 @@
} }
.label-top { .label-top {
top: -0.9rem; top: 0.35rem;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
} }
.label-right { .label-right {
top: 50%; top: 50%;
right: -1rem; right: 0.42rem;
transform: translateY(-50%); transform: translateY(-50%);
} }
.label-bottom { .label-bottom {
bottom: -0.9rem; bottom: 0.35rem;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
} }
.label-left { .label-left {
top: 50%; top: 50%;
left: -1.35rem; left: 0.42rem;
transform: translateY(-50%); transform: translateY(-50%);
} }
@@ -421,7 +427,11 @@
} }
.panel-body { .panel-body {
block-size: clamp(10rem, 13vw, 12rem); block-size: clamp(12rem, 14.5vw, 14rem);
}
.compass-core {
inline-size: min(64%, 10.4rem, calc(100% - 3.4rem));
} }
} }
@@ -432,7 +442,15 @@
} }
.panel-body { .panel-body {
block-size: clamp(9.8rem, 12vw, 11.8rem); block-size: clamp(11.2rem, 13.5vw, 13.2rem);
}
.compass-stage {
padding-block: 1.2rem;
}
.compass-core {
inline-size: min(62%, 10rem, calc(100% - 3.4rem));
} }
} }
@@ -444,7 +462,16 @@
} }
.panel-body { .panel-body {
block-size: clamp(8rem, 9.5vw, 9.8rem); block-size: clamp(9.4rem, 11vw, 10.8rem);
}
.compass-stage {
padding: 1rem 1.45rem;
}
.compass-core {
inline-size: min(58%, 8.2rem, calc(100% - 2.9rem));
min-inline-size: 5.6rem;
} }
} }
@@ -456,7 +483,16 @@
} }
.panel-body { .panel-body {
block-size: clamp(6.5rem, 8vw, 7.5rem); block-size: clamp(7.6rem, 9vw, 8.8rem);
}
.compass-stage {
padding: 0.82rem 1.25rem;
}
.compass-core {
inline-size: min(54%, 6.4rem, calc(100% - 2.5rem));
min-inline-size: 4.8rem;
} }
} }

View File

@@ -11,15 +11,26 @@
export let sessionStartedAt: number = Date.now(); export let sessionStartedAt: number = Date.now();
export let isRealtime = false; export let isRealtime = false;
let canvasEl: HTMLCanvasElement | undefined;
let currentTimeSeconds = 0; let currentTimeSeconds = 0;
let timerId: ReturnType<typeof setInterval> | null = null; let timerId: ReturnType<typeof setInterval> | null = null;
let resizeObserver: ResizeObserver | null = null;
onMount(() => { onMount(() => {
timerId = setInterval(() => { timerId = setInterval(() => {
currentTimeSeconds = Math.round((Date.now() - sessionStartedAt) / 100) / 10; currentTimeSeconds = Math.round((Date.now() - sessionStartedAt) / 100) / 10;
}, 200); }, 200);
if (canvasEl) {
resizeObserver = new ResizeObserver(() => {
drawCanvas(canvasEl, plotPoints, yAxisTicks, xAxisTicks, sampleCount);
});
resizeObserver.observe(canvasEl);
}
return () => { return () => {
if (timerId != null) clearInterval(timerId); if (timerId != null) clearInterval(timerId);
resizeObserver?.disconnect();
}; };
}); });
@@ -33,7 +44,8 @@
const plotInsetRight = 4; const plotInsetRight = 4;
const plotInsetTop = 4; const plotInsetTop = 4;
const plotInsetBottom = 9; const plotInsetBottom = 9;
const fixedYBounds = { min: 0, max: 25 }; const maxDisplayForce = 25.6;
const fixedYBounds = { min: 0, max: maxDisplayForce };
interface CurveSample { interface CurveSample {
x: number; x: number;
@@ -56,14 +68,6 @@
return Math.min(max, Math.max(min, value)); return Math.min(max, Math.max(min, value));
} }
function formatValue(value: number | null): string {
if (value === null) {
return "--";
}
return value.toFixed(1);
}
function formatAxisValue(value: number, axis: "x" | "y"): string { function formatAxisValue(value: number, axis: "x" | "y"): string {
if (!Number.isFinite(value)) { if (!Number.isFinite(value)) {
return "--"; return "--";
@@ -225,24 +229,117 @@
})); }));
} }
function createLinePath(points: PlotPoint[]): string { function drawCanvas(
if (points.length === 0) { canvas: HTMLCanvasElement | undefined,
return ""; points: PlotPoint[],
yTicks: AxisTick[],
xTicks: AxisTick[],
count: number
): void {
if (!canvas) {
return;
} }
return points.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`).join(" "); const context = canvas.getContext("2d");
if (!context) {
return;
} }
function createAreaPath(points: PlotPoint[]): string { const cssWidth = Math.max(1, canvas.clientWidth);
if (points.length < 2) { const cssHeight = Math.max(1, canvas.clientHeight);
return ""; const dpr = typeof window === "undefined" ? 1 : window.devicePixelRatio || 1;
const targetWidth = Math.round(cssWidth * dpr);
const targetHeight = Math.round(cssHeight * dpr);
if (canvas.width !== targetWidth || canvas.height !== targetHeight) {
canvas.width = targetWidth;
canvas.height = targetHeight;
} }
const linePath = createLinePath(points); context.setTransform(targetWidth / viewportWidth, 0, 0, targetHeight / viewportHeight, 0, 0);
const firstPoint = points[0]; context.clearRect(0, 0, viewportWidth, viewportHeight);
context.save();
context.lineWidth = 0.45;
context.strokeStyle = "rgb(120 180 150 / 0.16)";
for (const tick of yTicks) {
context.beginPath();
context.moveTo(plotInsetLeft, tick.plotY);
context.lineTo(viewportWidth - plotInsetRight, tick.plotY);
context.stroke();
}
context.restore();
if (points.length >= 2) {
const baselineY = viewportHeight - plotInsetBottom;
const fill = context.createLinearGradient(0, plotInsetTop, 0, baselineY);
fill.addColorStop(0, "rgb(62 232 255 / 0.28)");
fill.addColorStop(1, "rgb(62 232 255 / 0.02)");
context.save();
context.beginPath();
context.moveTo(points[0].x, points[0].y);
for (let index = 1; index < points.length; index += 1) {
context.lineTo(points[index].x, points[index].y);
}
context.lineTo(points[points.length - 1].x, baselineY);
context.lineTo(points[0].x, baselineY);
context.closePath();
context.fillStyle = fill;
context.fill();
context.restore();
}
if (points.length > 0) {
context.save();
context.beginPath();
context.moveTo(points[0].x, points[0].y);
for (let index = 1; index < points.length; index += 1) {
context.lineTo(points[index].x, points[index].y);
}
context.lineWidth = 1.35;
context.lineCap = "round";
context.lineJoin = "round";
context.strokeStyle = "rgb(130 232 255 / 0.96)";
context.shadowColor = "rgb(62 232 255 / 0.22)";
context.shadowBlur = 4;
context.stroke();
context.restore();
const lastPoint = points[points.length - 1]; const lastPoint = points[points.length - 1];
context.save();
context.beginPath();
context.arc(lastPoint.x, lastPoint.y, 1.7, 0, Math.PI * 2);
context.fillStyle = "rgb(73 222 128 / 0.98)";
context.shadowColor = "rgb(73 222 128 / 0.3)";
context.shadowBlur = 6;
context.fill();
context.restore();
}
return `${linePath} L ${lastPoint.x} ${viewportHeight - plotInsetBottom} L ${firstPoint.x} ${viewportHeight - plotInsetBottom} Z`; context.save();
context.font = "600 3.2px sans-serif";
context.textBaseline = "middle";
context.shadowColor = "rgb(0 0 0 / 0.3)";
context.shadowBlur = 4;
context.fillStyle = "rgb(145 185 165 / 0.84)";
for (const tick of yTicks) {
context.textAlign = "right";
context.fillText(tick.label, tick.plotX, tick.plotY + 0.5);
}
context.fillStyle = "rgb(180 220 200 / 0.9)";
context.textBaseline = "alphabetic";
for (let index = 0; index < xTicks.length; index += 1) {
const tick = xTicks[index];
context.textAlign = index === 0 ? "left" : index === xTicks.length - 1 ? "right" : "center";
context.fillText(tick.label, tick.plotX, tick.plotY);
}
context.restore();
if (count === 0) {
context.clearRect(0, 0, viewportWidth, viewportHeight);
}
} }
$: sourceYValues = yValues && yValues.length ? yValues : summary.points; $: sourceYValues = yValues && yValues.length ? yValues : summary.points;
@@ -261,14 +358,9 @@
$: xDataBounds = resolveDataBounds(samples.map((sample) => sample.x)); $: xDataBounds = resolveDataBounds(samples.map((sample) => sample.x));
$: yDataBounds = resolveDataBounds(samples.map((sample) => sample.y)); $: yDataBounds = resolveDataBounds(samples.map((sample) => sample.y));
$: plotPoints = convertPoints(samples, xScaleBounds, yScaleBounds); $: plotPoints = convertPoints(samples, xScaleBounds, yScaleBounds);
$: linePath = createLinePath(plotPoints);
$: areaPath = createAreaPath(plotPoints);
$: lastPoint = plotPoints.length > 0 ? plotPoints[plotPoints.length - 1] : null;
$: yAxisTicks = sampleCount > 0 ? buildYAxisTicks(yScaleBounds, yDataBounds) : []; $: yAxisTicks = sampleCount > 0 ? buildYAxisTicks(yScaleBounds, yDataBounds) : [];
$: xAxisTicks = sampleCount > 0 ? buildXAxisTicks(xScaleBounds) : []; $: xAxisTicks = sampleCount > 0 ? buildXAxisTicks(xScaleBounds) : [];
$: latestValue = formatValue(summary.latest); $: drawCanvas(canvasEl, plotPoints, yAxisTicks, xAxisTicks, sampleCount);
$: minValue = formatValue(summary.min);
$: maxValue = formatValue(summary.max);
</script> </script>
<article <article
@@ -280,62 +372,12 @@
<header class="panel-head"> <header class="panel-head">
<div class="head-text"> <div class="head-text">
<p class="panel-code">RF</p> <p class="panel-code">RF</p>
<p class="panel-title">{summary.label}</p> <p class="panel-title">{locale === "zh-CN" ? "合力" : "Resultant Force"}</p>
</div>
<div class="icon-layer" aria-hidden="true">
<span class="icon-chip tone-cyan">NOW</span>
<span class="icon-chip tone-lime">MIN</span>
<span class="icon-chip tone-orange">MAX</span>
</div> </div>
</header> </header>
<div class="chart-stage"> <div class="chart-stage">
<svg viewBox="0 0 {viewportWidth} {viewportHeight}" preserveAspectRatio="none" role="img" aria-label={summary.label}> <canvas bind:this={canvasEl} aria-label={summary.label}></canvas>
<defs>
<linearGradient id="summary-fill" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="rgb(62 232 255 / 0.28)" />
<stop offset="100%" stop-color="rgb(62 232 255 / 0.02)" />
</linearGradient>
</defs>
<g class="grid-lines" aria-hidden="true">
{#each yAxisTicks as tick (`grid-${tick.value}`)}
<line x1={plotInsetLeft} y1={tick.plotY} x2={viewportWidth - plotInsetRight} y2={tick.plotY}></line>
{/each}
</g>
{#if areaPath}
<path d={areaPath} class="summary-area"></path>
{/if}
{#if linePath}
<path d={linePath} class="summary-line"></path>
{/if}
{#if lastPoint}
<circle cx={lastPoint.x} cy={lastPoint.y} r="1.7" class="summary-dot"></circle>
{/if}
<g class="axis-labels" aria-hidden="true">
{#each yAxisTicks as tick, index (`y-${index}`)}
<text class="axis-label y-axis-label" x={tick.plotX} y={tick.plotY + 1.1} text-anchor="end">
{tick.label}
</text>
{/each}
{#each xAxisTicks as tick, index (`x-${index}`)}
<text
class="axis-label x-axis-label"
x={tick.plotX}
y={tick.plotY}
text-anchor={index === 0 ? "start" : index === xAxisTicks.length - 1 ? "end" : "middle"}
>
{tick.label}
</text>
{/each}
</g>
</svg>
{#if sampleCount === 0} {#if sampleCount === 0}
<div class="empty-state"> <div class="empty-state">
@@ -343,24 +385,6 @@
</div> </div>
{/if} {/if}
</div> </div>
<footer class="panel-foot">
<p class="foot-item">
<span class="dot tone-cyan"></span>
<span class="metric-text">{i18n.now}</span>
<span class="value">{latestValue}</span>
</p>
<p class="foot-item">
<span class="dot tone-lime"></span>
<span class="metric-text">{i18n.min}</span>
<span class="value">{minValue}</span>
</p>
<p class="foot-item">
<span class="dot tone-orange"></span>
<span class="metric-text">{i18n.max}</span>
<span class="value">{maxValue}</span>
</p>
</footer>
</article> </article>
<style> <style>
@@ -369,10 +393,13 @@
--enter-ms: 1800ms; --enter-ms: 1800ms;
--fade-ms: 1000ms; --fade-ms: 1000ms;
overflow: hidden; overflow: hidden;
inline-size: min(100%, clamp(34rem, 44vw, 44rem)); inline-size: var(--rail-width, min(100%, clamp(34rem, 44vw, 44rem)));
max-inline-size: 100%;
box-sizing: border-box;
flex: 0 0 var(--rail-width, auto);
justify-self: start; justify-self: start;
display: grid; display: grid;
grid-template-rows: auto 1fr auto; grid-template-rows: auto 1fr;
gap: 0.68rem; gap: 0.68rem;
padding: 0.88rem 0.96rem 1rem; padding: 0.88rem 0.96rem 1rem;
border: 1px solid rgb(var(--hud-border-strong-rgb) / 0.42); border: 1px solid rgb(var(--hud-border-strong-rgb) / 0.42);
@@ -438,36 +465,6 @@
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.icon-layer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.26rem;
}
.icon-chip {
border: 1px solid rgb(var(--hud-border-strong-rgb) / 0.44);
border-radius: 999px;
padding: 0.08rem 0.36rem;
font-size: 0.58rem;
letter-spacing: 0.08em;
color: rgb(var(--hud-text-main-rgb) / 0.94);
background: rgb(var(--hud-surface-rgb) / 0.66);
}
.icon-chip.tone-cyan {
border-color: rgb(var(--hud-cyan-rgb) / 0.54);
}
.icon-chip.tone-lime {
border-color: rgb(var(--hud-lime-rgb) / 0.56);
}
.icon-chip.tone-orange {
border-color: rgb(var(--hud-orange-rgb) / 0.58);
}
.chart-stage { .chart-stage {
position: relative; position: relative;
block-size: clamp(12rem, 15.5vw, 15rem); block-size: clamp(12rem, 15.5vw, 15rem);
@@ -480,53 +477,12 @@
radial-gradient(circle at 50% 0, rgb(var(--hud-glow-rgb) / 0.09), transparent 45%); radial-gradient(circle at 50% 0, rgb(var(--hud-glow-rgb) / 0.09), transparent 45%);
} }
svg { canvas {
display: block; display: block;
inline-size: 100%; inline-size: 100%;
block-size: 100%; block-size: 100%;
} }
.grid-lines line {
stroke: rgb(var(--hud-border-strong-rgb) / 0.16);
stroke-width: 0.45;
}
.summary-area {
fill: url(#summary-fill);
}
.summary-line {
fill: none;
stroke: rgb(var(--hud-cyan-rgb) / 0.96);
stroke-width: 1.35;
stroke-linecap: round;
stroke-linejoin: round;
filter: drop-shadow(0 0 4px rgb(var(--hud-cyan-rgb) / 0.22));
}
.summary-dot {
fill: rgb(var(--hud-lime-rgb) / 0.98);
filter: drop-shadow(0 0 6px rgb(var(--hud-lime-rgb) / 0.3));
}
.axis-label {
fill: rgb(var(--hud-text-main-rgb) / 0.88);
font-size: 3.2px;
font-weight: 600;
letter-spacing: 0.02em;
text-shadow:
0 1px 0 rgb(0 0 0 / 0.46),
0 0 4px rgb(0 0 0 / 0.3);
}
.y-axis-label {
fill: rgb(var(--hud-text-dim-rgb) / 0.84);
}
.x-axis-label {
fill: rgb(var(--hud-text-dim-rgb) / 0.9);
}
.empty-state { .empty-state {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -540,58 +496,9 @@
background: linear-gradient(180deg, rgb(var(--hud-surface-deep-rgb) / 0.06), rgb(var(--hud-surface-deep-rgb) / 0.18)); background: linear-gradient(180deg, rgb(var(--hud-surface-deep-rgb) / 0.06), rgb(var(--hud-surface-deep-rgb) / 0.18));
} }
.panel-foot {
display: flex;
flex-wrap: nowrap;
gap: 0.8rem;
margin: 0;
padding: 0;
flex-shrink: 0;
}
.foot-item {
margin: 0;
display: inline-flex;
align-items: center;
gap: 0.22rem;
color: rgb(var(--hud-text-main-rgb) / 0.9);
font-size: 0.68rem;
letter-spacing: 0.03em;
white-space: nowrap;
flex-shrink: 0;
}
.metric-text {
color: rgb(var(--hud-text-dim-rgb) / 0.82);
text-transform: uppercase;
flex-shrink: 0;
}
.dot {
inline-size: 0.34rem;
block-size: 0.34rem;
border-radius: 50%;
}
.dot.tone-cyan {
background: rgb(var(--hud-cyan-rgb));
}
.dot.tone-lime {
background: rgb(var(--hud-lime-rgb));
}
.dot.tone-orange {
background: rgb(var(--hud-orange-rgb));
}
.value {
min-inline-size: 2.6rem;
}
@media (max-width: 1180px) { @media (max-width: 1180px) {
.signal-panel { .signal-panel {
inline-size: min(100%, clamp(28rem, 40vw, 38rem)); inline-size: var(--rail-width, min(100%, clamp(28rem, 40vw, 38rem)));
} }
.chart-stage { .chart-stage {
@@ -601,7 +508,7 @@
@media (max-height: 900px) { @media (max-height: 900px) {
.signal-panel { .signal-panel {
inline-size: min(100%, clamp(28rem, 38vw, 36rem)); inline-size: var(--rail-width, min(100%, clamp(28rem, 38vw, 36rem)));
padding: 0.7rem 0.76rem 0.8rem; padding: 0.7rem 0.76rem 0.8rem;
} }
@@ -612,7 +519,7 @@
@media (max-height: 760px) { @media (max-height: 760px) {
.signal-panel { .signal-panel {
inline-size: min(100%, clamp(24rem, 34vw, 30rem)); inline-size: var(--rail-width, min(100%, clamp(24rem, 34vw, 30rem)));
padding: 0.62rem 0.68rem 0.72rem; padding: 0.62rem 0.68rem 0.72rem;
gap: 0.48rem; gap: 0.48rem;
} }
@@ -624,7 +531,7 @@
@media (max-height: 680px) { @media (max-height: 680px) {
.signal-panel { .signal-panel {
inline-size: min(100%, clamp(20rem, 28vw, 26rem)); inline-size: var(--rail-width, min(100%, clamp(20rem, 28vw, 26rem)));
padding: 0.52rem 0.58rem 0.6rem; padding: 0.52rem 0.58rem 0.6rem;
gap: 0.36rem; gap: 0.36rem;
} }

View File

@@ -194,6 +194,9 @@
const pointsPerSeries = 28; const pointsPerSeries = 28;
const summaryPointsPerSeries = 42; const summaryPointsPerSeries = 42;
const signalRenderTickMs = 1200; const signalRenderTickMs = 1200;
const summaryReleaseForceEpsilon = 0.1;
const releaseClearDelayMs = 5000;
const spatialForceMagnitudeEpsilon = 0.0001;
const replayDefaultFrameMs = 40; const replayDefaultFrameMs = 40;
const showSignalPanels = false; const showSignalPanels = false;
const mockToneCycle: SignalTone[] = ["cyan", "lime", "orange", "violet", "gold", "rose"]; const mockToneCycle: SignalTone[] = ["cyan", "lime", "orange", "violet", "gold", "rose"];
@@ -288,6 +291,11 @@
} | null = null; } | null = null;
let devkitStatusTimer: number | null = null; let devkitStatusTimer: number | null = null;
let devkitSpatialForceClearTimer: number | null = null; let devkitSpatialForceClearTimer: number | null = null;
let summaryClearTimer: number | null = null;
let spatialForceClearTimer: number | null = null;
let summaryReleaseHidden = true;
let spatialForceReleaseHidden = false;
let spatialForcePanelVisible = false;
let sessionStartedAt: number = Date.now(); let sessionStartedAt: number = Date.now();
$: uiCopy = copyByLocale[locale]; $: uiCopy = copyByLocale[locale];
@@ -298,6 +306,7 @@
$: rangeScaleStyle = buildRangeScaleStyle(colorMapPreset); $: rangeScaleStyle = buildRangeScaleStyle(colorMapPreset);
$: replayHasData = replayFrames.length > 0; $: replayHasData = replayFrames.length > 0;
$: replayFrameInfo = replayHasData ? `${replayHasDisplayedFrame ? replayCurrentIndex + 1 : 0}/${replayFrames.length}` : ""; $: replayFrameInfo = replayHasData ? `${replayHasDisplayedFrame ? replayCurrentIndex + 1 : 0}/${replayFrames.length}` : "";
$: summaryReleasePending = summaryClearTimer != null;
$: fileExplorerTitle = $: fileExplorerTitle =
fileExplorerMode === "open" ? uiCopy.fileExplorerImportTitle : uiCopy.fileExplorerExportTitle; fileExplorerMode === "open" ? uiCopy.fileExplorerImportTitle : uiCopy.fileExplorerExportTitle;
$: fileExplorerConfirmLabel = $: fileExplorerConfirmLabel =
@@ -313,7 +322,11 @@
window.clearTimeout(devkitSpatialForceClearTimer); window.clearTimeout(devkitSpatialForceClearTimer);
devkitSpatialForceClearTimer = null; devkitSpatialForceClearTimer = null;
} }
hasSignalData = signalPanels.length > 0 || summary.points.length > 0 || spatialForce !== null; hasSignalData =
signalPanels.length > 0 ||
summary.points.length > 0 ||
spatialForce !== null ||
spatialForcePanelVisible;
} }
function scheduleDevkitSpatialForceClear(): void { function scheduleDevkitSpatialForceClear(): void {
@@ -332,6 +345,54 @@
}, 420); }, 420);
} }
function cancelSummaryClear(): void {
if (summaryClearTimer != null && typeof window !== "undefined") {
window.clearTimeout(summaryClearTimer);
}
summaryClearTimer = null;
}
function scheduleSummaryClear(): void {
if (typeof window === "undefined" || summary.points.length === 0) {
return;
}
if (summaryClearTimer != null) {
return;
}
summaryClearTimer = window.setTimeout(() => {
summary = buildEmptySummary();
summaryClearTimer = null;
summaryReleaseHidden = true;
hasSignalData = signalPanels.length > 0 || spatialForce !== null || spatialForcePanelVisible;
}, releaseClearDelayMs);
}
function cancelSpatialForceClear(): void {
if (spatialForceClearTimer != null && typeof window !== "undefined") {
window.clearTimeout(spatialForceClearTimer);
}
spatialForceClearTimer = null;
}
function scheduleSpatialForceClear(): void {
if (typeof window === "undefined" || !spatialForcePanelVisible) {
return;
}
if (spatialForceClearTimer != null) {
return;
}
spatialForceClearTimer = window.setTimeout(() => {
spatialForceClearTimer = null;
spatialForceReleaseHidden = true;
spatialForcePanelVisible = false;
hasSignalData = signalPanels.length > 0 || summary.points.length > 0 || spatialForce !== null;
}, releaseClearDelayMs);
}
function clamp(value: number, min: number, max: number): number { function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value)); return Math.min(max, Math.max(min, value));
} }
@@ -792,6 +853,10 @@
} }
function resetReplayVisualState(): void { function resetReplayVisualState(): void {
cancelSummaryClear();
cancelSpatialForceClear();
spatialForceReleaseHidden = false;
spatialForcePanelVisible = false;
pressureMatrix = buildZeroMatrix(); pressureMatrix = buildZeroMatrix();
spatialForce = null; spatialForce = null;
replayPendingDevkitSeq = null; replayPendingDevkitSeq = null;
@@ -831,8 +896,12 @@
replayHasDisplayedFrame = true; replayHasDisplayedFrame = true;
replayProgress = replayFrames.length > 1 ? safeIndex / (replayFrames.length - 1) : 1; replayProgress = replayFrames.length > 1 ? safeIndex / (replayFrames.length - 1) : 1;
const frame = replayFrames[safeIndex]; const frame = replayFrames[safeIndex];
cancelSummaryClear();
cancelSpatialForceClear();
spatialForceReleaseHidden = false;
pressureMatrix = frameValuesToMatrix(frame.values); pressureMatrix = frameValuesToMatrix(frame.values);
spatialForce = frame.spatialForce ?? null; spatialForce = frame.spatialForce ?? null;
spatialForcePanelVisible = spatialForce !== null;
pushReplayFrameToDevkit(frame, safeIndex); pushReplayFrameToDevkit(frame, safeIndex);
signalPanels = buildInactivePanels(); signalPanels = buildInactivePanels();
summary = buildReplaySummaryAt(safeIndex); summary = buildReplaySummaryAt(safeIndex);
@@ -1002,13 +1071,99 @@
} }
function isZeroLikeValue(value: number): boolean { function isZeroLikeValue(value: number): boolean {
return !Number.isFinite(value) || Math.abs(value) < 0.0001; return !Number.isFinite(value) || Math.abs(value) <= summaryReleaseForceEpsilon;
} }
function shouldHideSummary(points: number[]): boolean { function shouldHideSummary(points: number[]): boolean {
return points.length === 0 || points.every((value) => isZeroLikeValue(value)); return points.length === 0 || points.every((value) => isZeroLikeValue(value));
} }
function latestSummaryValue(summaryValue: HudSummary): number | null {
if (Number.isFinite(summaryValue.latest)) {
return summaryValue.latest;
}
const latestPoint = summaryValue.points[summaryValue.points.length - 1];
return Number.isFinite(latestPoint) ? latestPoint : null;
}
function hasActiveSummary(summaryValue: HudSummary): boolean {
const latest = latestSummaryValue(summaryValue);
return latest !== null && !isZeroLikeValue(latest);
}
function hasActiveSpatialForce(spatialForceValue: HudSpatialForce | null): boolean {
return (
spatialForceValue !== null &&
Number.isFinite(spatialForceValue.angleDeg) &&
Number.isFinite(spatialForceValue.magnitude) &&
Math.abs(spatialForceValue.magnitude) > spatialForceMagnitudeEpsilon
);
}
function buildPacketSummary(summaryValue: HudSummary): HudSummary {
const nowSeconds = Math.round((Date.now() - sessionStartedAt) / 100) / 10;
const pointCount = summaryValue.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 = summaryValue.points.map((_, index) => Math.round((startX + index * spacing) * 10) / 10);
return { ...summaryValue, xValues };
}
function estimateSummarySpacing(xValues: number[]): number {
const diffs = xValues
.slice(1)
.map((value, index) => value - xValues[index])
.filter((value) => Number.isFinite(value) && value > 0);
if (diffs.length === 0) {
return 1.2;
}
const average = diffs.reduce((sum, value) => sum + value, 0) / diffs.length;
return Math.max(0.1, Math.min(1.2, Math.round(average * 10) / 10));
}
function buildContinuousSummary(summaryValue: HudSummary): HudSummary {
if (
summary.points.length === 0 ||
!summary.xValues ||
summary.xValues.length !== summary.points.length
) {
return buildPacketSummary(summaryValue);
}
const pointCount = summaryValue.points.length;
const previousXValues = summary.xValues;
const previousCount = previousXValues.length;
if (pointCount === 0) {
return buildEmptySummary();
}
if (pointCount < previousCount) {
return buildPacketSummary(summaryValue);
}
const spacing = estimateSummarySpacing(previousXValues);
let xValues: number[];
if (pointCount > previousCount) {
xValues = previousXValues.slice();
while (xValues.length < pointCount) {
const previousX = xValues[xValues.length - 1] ?? 0;
xValues.push(Math.round((previousX + spacing) * 10) / 10);
}
} else {
xValues = previousXValues.slice(1);
const previousX = previousXValues[previousXValues.length - 1] ?? 0;
xValues.push(Math.round((previousX + spacing) * 10) / 10);
}
return { ...summaryValue, xValues };
}
function normalizeSummary(summaryValue: HudSummary): HudSummary { function normalizeSummary(summaryValue: HudSummary): HudSummary {
return shouldHideSummary(summaryValue.points) ? buildEmptySummary() : summaryValue; return shouldHideSummary(summaryValue.points) ? buildEmptySummary() : summaryValue;
} }
@@ -1076,26 +1231,48 @@
return; return;
} }
signalPanels = showSignalPanels ? packet.panels : buildInactivePanels(); signalPanels = showSignalPanels ? packet.panels : buildInactivePanels();
const packetHasActiveSummary = hasActiveSummary(packet.summary);
if (packet.summary.points.length > 0) { if (packet.summary.points.length > 0) {
const nowSeconds = Math.round((Date.now() - sessionStartedAt) / 100) / 10; if (packetHasActiveSummary) {
const pointCount = packet.summary.points.length; summaryReleaseHidden = false;
const spacing = summary = buildContinuousSummary(packet.summary);
pointCount > 1 ? Math.min(1.2, nowSeconds / Math.max(pointCount - 1, 1)) : 0; cancelSummaryClear();
const startX = Math.max(0, nowSeconds - spacing * Math.max(pointCount - 1, 0)); } else if (summaryReleaseHidden) {
const xValues = packet.summary.points.map((_, index) => Math.round((startX + index * spacing) * 10) / 10); summary = buildEmptySummary();
summary = { ...packet.summary, xValues };
} else { } else {
summary = packet.summary; summary = buildContinuousSummary(packet.summary);
scheduleSummaryClear();
}
} else if (!summaryReleaseHidden) {
scheduleSummaryClear();
} }
pressureMatrix = packet.pressureMatrix; pressureMatrix = packet.pressureMatrix;
spatialForce = packet.spatialForce ?? null; const nextSpatialForce = packet.spatialForce ?? null;
if (packetHasActiveSummary && hasActiveSpatialForce(nextSpatialForce)) {
spatialForceReleaseHidden = false;
spatialForcePanelVisible = true;
spatialForce = nextSpatialForce;
cancelSpatialForceClear();
} else {
spatialForce = null;
if (spatialForceReleaseHidden || !spatialForcePanelVisible) {
spatialForcePanelVisible = false;
}
scheduleSpatialForceClear();
}
hasSignalData = hasSignalData =
signalPanels.length > 0 || signalPanels.length > 0 ||
packet.summary.points.length > 0 || summary.points.length > 0 ||
spatialForce !== null; spatialForce !== null ||
spatialForcePanelVisible;
} }
function clearHudPanels(): void { function clearHudPanels(): void {
cancelSummaryClear();
cancelSpatialForceClear();
summaryReleaseHidden = true;
spatialForceReleaseHidden = false;
spatialForcePanelVisible = false;
hasSignalData = false; hasSignalData = false;
signalPanels = buildInactivePanels(); signalPanels = buildInactivePanels();
summary = buildEmptySummary(); summary = buildEmptySummary();
@@ -1815,6 +1992,8 @@
return () => { return () => {
disposed = true; disposed = true;
pauseReplayPlayback(); pauseReplayPlayback();
cancelSummaryClear();
cancelSpatialForceClear();
clearDevkitSpatialForce(); clearDevkitSpatialForce();
stopMockFeed?.(); stopMockFeed?.();
unlistenHudStream?.(); unlistenHudStream?.();
@@ -1919,6 +2098,8 @@
{replayFileName} {replayFileName}
{replayFrameInfo} {replayFrameInfo}
{sessionStartedAt} {sessionStartedAt}
{summaryReleasePending}
{spatialForcePanelVisible}
resetConfigLabel={uiCopy.resetConfigLabel} resetConfigLabel={uiCopy.resetConfigLabel}
applyLiveHint={uiCopy.applyLiveHint} applyLiveHint={uiCopy.applyLiveHint}
leftPanels={leftSignalPanels} leftPanels={leftSignalPanels}