diff --git a/devkit/build_server.bat b/devkit/build_server.bat index 10de7db..f272d71 100644 --- a/devkit/build_server.bat +++ b/devkit/build_server.bat @@ -1,6 +1,6 @@ @echo off REM ── JE-Skin DevKit: 打包 Python gRPC server 为 exe ── -REM 前提: pip install pyinstaller grpcio grpcio-tools openpyxl +REM 前提: pip install pyinstaller grpcio grpcio-tools numpy openpyxl echo [1/3] Generating gRPC stubs... python -m grpc_tools.protoc ^ @@ -21,4 +21,4 @@ pyinstaller ^ echo [3/3] Done! echo Output: dist/je-skin-devkit-server.exe -pause \ No newline at end of file +pause diff --git a/devkit/je-skin-devkit-server.spec b/devkit/je-skin-devkit-server.spec index e12a2fa..c8b1cd4 100644 --- a/devkit/je-skin-devkit-server.spec +++ b/devkit/je-skin-devkit-server.spec @@ -5,8 +5,8 @@ a = Analysis( ['sensor_server.py'], pathex=[], binaries=[], - datas=[], - hiddenimports=['grpc', 'openpyxl'], + datas=[('sensor_stream_pb2.py', '.'), ('sensor_stream_pb2_grpc.py', '.')], + hiddenimports=['grpc', 'openpyxl', 'numpy'], hookspath=[], hooksconfig={}, runtime_hooks=[], diff --git a/devkit/sensor_server.py b/devkit/sensor_server.py index 7a031fe..d85e81d 100644 --- a/devkit/sensor_server.py +++ b/devkit/sensor_server.py @@ -234,30 +234,63 @@ class SensorPushServicer(sensor_stream_pb2_grpc.SensorPushServicer): def __init__(self): self.frame_count = 0 self.last_report_time = time.time() + self.last_angle = None def Upload(self, request_iterator, context): print("[SensorPush] Client connected, waiting for frames...") + reset_baseline() + self.last_angle = None for frame in request_iterator: self.frame_count += 1 + angle = 0.0 + ok = True + message = "OK" + if len(frame.matrix) == SENSOR_ROWS * SENSOR_COLS: + try: + angle = get_pzt_angle(frame.matrix) + self.last_angle = angle + if self.frame_count <= 10 or self.frame_count % 30 == 0: + print( + f"[SensorPush] PZT angle frame #{frame.seq} " + f"dts={frame.dts_ms} angle={angle:.2f}" + ) + except Exception as e: + ok = False + message = str(e) + print(f"[SensorPush] PZT compute error on frame #{frame.seq}: {e}") + else: + ok = False + message = f"Invalid matrix length: {len(frame.matrix)}" + + yield sensor_stream_pb2.PztAngleResponse( + seq=frame.seq, + timestamp_ms=frame.timestamp_ms, + angle=angle, + dts_ms=frame.dts_ms, + ok=ok, + message=message, + ) + if self.frame_count % 100 == 0: now = time.time() elapsed = now - self.last_report_time fps = 100 / elapsed if elapsed > 0 else 0 self.last_report_time = now + angle_text = ( + f"{self.last_angle:.2f}" + if self.last_angle is not None + else "n/a" + ) print( f"[SensorPush] Frame #{frame.seq} | " f"{frame.rows}x{frame.cols} | " + f"angle={angle_text} | " f"force={frame.resultant_force:.1f} | " f"total={self.frame_count} | ~{fps:.1f} fps" ) print(f"[SensorPush] Stream ended. Total: {self.frame_count}") - return sensor_stream_pb2.UploadResponse( - ok=True, - frames_received=self.frame_count, - message=f"Processed {self.frame_count} frames", - ) class ExportProcessorServicer(sensor_stream_pb2_grpc.ExportProcessorServicer): @@ -316,8 +349,119 @@ def serve(port: int): server.wait_for_termination() +import numpy as np +import threading + +# ===================== 算法参数===================== +TOTAL_PRESSURE_LOW_THRESHOLD = 500 +COP_STABILITY_FRAMES_REQUIRED = 5 +SENSOR_ROWS = 12 +SENSOR_COLS = 7 + +# ===================== 线程安全全局状态 ===================== +first_frame = None +first_frame_lock = threading.Lock() + +first_contact_CoP_x = None +first_contact_CoP_y = None +contact_initialized = False + +total_pressure_low_counter = 0 + +# ===================== 基线减除 ===================== +def subtract_baseline(current_frame): + global first_frame + current_frame = np.array(current_frame, dtype=np.float32).flatten() + + with first_frame_lock: + if first_frame is None: + first_frame = current_frame.copy() + + diff = current_frame - first_frame + return np.clip(diff, 0, None) + +# ===================== 重置CoP状态 ===================== +def reset_cop_state(): + global first_contact_CoP_x, first_contact_CoP_y, contact_initialized + global total_pressure_low_counter + + first_contact_CoP_x = None + first_contact_CoP_y = None + contact_initialized = False + total_pressure_low_counter = 0 + +# ===================== CoP压力中心计算 ===================== +def compute_pressure_direction(baseline_subtracted_frame): + global first_contact_CoP_x, first_contact_CoP_y, contact_initialized + global total_pressure_low_counter + + rows, cols = SENSOR_ROWS, SENSOR_COLS + frame_flat = np.asarray(baseline_subtracted_frame, dtype=np.float32).flatten() + frame2d = frame_flat.reshape(rows, cols) + + total_pressure = np.sum(frame2d) + if total_pressure < TOTAL_PRESSURE_LOW_THRESHOLD: + total_pressure_low_counter += 1 + else: + total_pressure_low_counter = 0 + + if total_pressure_low_counter >= COP_STABILITY_FRAMES_REQUIRED: + reset_cop_state() + return 0.0, 0.0 + + if total_pressure == 0: + return 0.0, 0.0 + + x_grid = np.tile(np.arange(cols), (rows, 1)) + y_grid = np.repeat(np.arange(rows), cols).reshape(rows, cols) + cop_x = np.sum(frame2d * x_grid) / total_pressure + cop_y = np.sum(frame2d * y_grid) / total_pressure + + delta_CoP_x = 0.0 + delta_CoP_y = 0.0 + + if not contact_initialized: + first_contact_CoP_x = cop_x + first_contact_CoP_y = cop_y + contact_initialized = True + else: + delta_CoP_x = cop_x - first_contact_CoP_x + delta_CoP_y = cop_y - first_contact_CoP_y + + return delta_CoP_x, delta_CoP_y + +# ===================== 角度计算核心 ===================== +def compute_vector_angle(x: float, y: float) -> tuple[float, float]: + epsilon = 1e-8 + mag = np.hypot(x, y) + angle = np.degrees(np.arctan2(y, x + epsilon)) + if angle < 0: + angle += 360 + return angle, mag + +def compute_PZT_angle(Px: float, Py: float) -> tuple[float, float]: + return compute_vector_angle(Px, -Py) + +# ===================== 核心入口函数 ===================== +def get_pzt_angle(adc_data): + if len(adc_data) != 84: + raise ValueError("ADC数据长度必须为84") + baseline_subtracted = subtract_baseline(adc_data) + dx, dy = compute_pressure_direction(baseline_subtracted) + pzt_angle, _ = compute_PZT_angle(dx, dy) + + return pzt_angle + +# ===================== 重置基线(校准用) ===================== +def reset_baseline(): + global first_frame + with first_frame_lock: + first_frame = None + reset_cop_state() + + if __name__ == "__main__": parser = argparse.ArgumentParser(description="JE-Skin DevKit gRPC Server") parser.add_argument("--port", type=int, default=50051, help="gRPC listen port (default: 50051)") args = parser.parse_args() - serve(args.port) \ No newline at end of file + serve(args.port) diff --git a/devkit/sensor_stream_pb2.py b/devkit/sensor_stream_pb2.py index 92a4d8d..42e2496 100644 --- a/devkit/sensor_stream_pb2.py +++ b/devkit/sensor_stream_pb2.py @@ -24,7 +24,7 @@ _sym_db = _symbol_database.Default() -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x13sensor_stream.proto\x12\rsensor_stream\"\x85\x01\n\x0bSensorFrame\x12\x0b\n\x03seq\x18\x01 \x01(\x04\x12\x14\n\x0ctimestamp_ms\x18\x02 \x01(\x04\x12\x0c\n\x04rows\x18\x03 \x01(\r\x12\x0c\n\x04\x63ols\x18\x04 \x01(\r\x12\x0e\n\x06matrix\x18\x05 \x03(\r\x12\x17\n\x0fresultant_force\x18\x06 \x01(\x01\x12\x0e\n\x06\x64ts_ms\x18\x07 \x01(\r\"F\n\x0eUploadResponse\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x17\n\x0f\x66rames_received\x18\x02 \x01(\x04\x12\x0f\n\x07message\x18\x03 \x01(\t\"8\n\x0eProcessRequest\x12\x10\n\x08\x63sv_path\x18\x01 \x01(\t\x12\x14\n\x0csave_as_xlsx\x18\x02 \x01(\x08\"\xa6\x01\n\x0fProcessResponse\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x13\n\x0boutput_path\x18\x02 \x01(\t\x12\x13\n\x0bgroups_used\x18\x03 \x01(\r\x12\x12\n\nmean_value\x18\x04 \x01(\x01\x12\x11\n\tthreshold\x18\x05 \x01(\x01\x12\x12\n\nrows_total\x18\x06 \x01(\r\x12\x11\n\trows_kept\x18\x07 \x01(\r\x12\x0f\n\x07message\x18\x08 \x01(\t2S\n\nSensorPush\x12\x45\n\x06Upload\x12\x1a.sensor_stream.SensorFrame\x1a\x1d.sensor_stream.UploadResponse(\x01\x32_\n\x0f\x45xportProcessor\x12L\n\x0bProcessFile\x12\x1d.sensor_stream.ProcessRequest\x1a\x1e.sensor_stream.ProcessResponseb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x13sensor_stream.proto\x12\rsensor_stream\"\x85\x01\n\x0bSensorFrame\x12\x0b\n\x03seq\x18\x01 \x01(\x04\x12\x14\n\x0ctimestamp_ms\x18\x02 \x01(\x04\x12\x0c\n\x04rows\x18\x03 \x01(\r\x12\x0c\n\x04\x63ols\x18\x04 \x01(\r\x12\x0e\n\x06matrix\x18\x05 \x03(\r\x12\x17\n\x0fresultant_force\x18\x06 \x01(\x01\x12\x0e\n\x06\x64ts_ms\x18\x07 \x01(\r\"q\n\x10PztAngleResponse\x12\x0b\n\x03seq\x18\x01 \x01(\x04\x12\x14\n\x0ctimestamp_ms\x18\x02 \x01(\x04\x12\r\n\x05\x61ngle\x18\x03 \x01(\x02\x12\x0e\n\x06\x64ts_ms\x18\x04 \x01(\r\x12\n\n\x02ok\x18\x05 \x01(\x08\x12\x0f\n\x07message\x18\x06 \x01(\t\"8\n\x0eProcessRequest\x12\x10\n\x08\x63sv_path\x18\x01 \x01(\t\x12\x14\n\x0csave_as_xlsx\x18\x02 \x01(\x08\"\xa6\x01\n\x0fProcessResponse\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x13\n\x0boutput_path\x18\x02 \x01(\t\x12\x13\n\x0bgroups_used\x18\x03 \x01(\r\x12\x12\n\nmean_value\x18\x04 \x01(\x01\x12\x11\n\tthreshold\x18\x05 \x01(\x01\x12\x12\n\nrows_total\x18\x06 \x01(\r\x12\x11\n\trows_kept\x18\x07 \x01(\r\x12\x0f\n\x07message\x18\x08 \x01(\t2W\n\nSensorPush\x12I\n\x06Upload\x12\x1a.sensor_stream.SensorFrame\x1a\x1f.sensor_stream.PztAngleResponse(\x01\x30\x01\x32_\n\x0f\x45xportProcessor\x12L\n\x0bProcessFile\x12\x1d.sensor_stream.ProcessRequest\x1a\x1e.sensor_stream.ProcessResponseb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -33,14 +33,14 @@ if not _descriptor._USE_C_DESCRIPTORS: DESCRIPTOR._loaded_options = None _globals['_SENSORFRAME']._serialized_start=39 _globals['_SENSORFRAME']._serialized_end=172 - _globals['_UPLOADRESPONSE']._serialized_start=174 - _globals['_UPLOADRESPONSE']._serialized_end=244 - _globals['_PROCESSREQUEST']._serialized_start=246 - _globals['_PROCESSREQUEST']._serialized_end=302 - _globals['_PROCESSRESPONSE']._serialized_start=305 - _globals['_PROCESSRESPONSE']._serialized_end=471 - _globals['_SENSORPUSH']._serialized_start=473 - _globals['_SENSORPUSH']._serialized_end=556 - _globals['_EXPORTPROCESSOR']._serialized_start=558 - _globals['_EXPORTPROCESSOR']._serialized_end=653 + _globals['_PZTANGLERESPONSE']._serialized_start=174 + _globals['_PZTANGLERESPONSE']._serialized_end=287 + _globals['_PROCESSREQUEST']._serialized_start=289 + _globals['_PROCESSREQUEST']._serialized_end=345 + _globals['_PROCESSRESPONSE']._serialized_start=348 + _globals['_PROCESSRESPONSE']._serialized_end=514 + _globals['_SENSORPUSH']._serialized_start=516 + _globals['_SENSORPUSH']._serialized_end=603 + _globals['_EXPORTPROCESSOR']._serialized_start=605 + _globals['_EXPORTPROCESSOR']._serialized_end=700 # @@protoc_insertion_point(module_scope) diff --git a/devkit/sensor_stream_pb2_grpc.py b/devkit/sensor_stream_pb2_grpc.py index fb4ef6a..7aa33be 100644 --- a/devkit/sensor_stream_pb2_grpc.py +++ b/devkit/sensor_stream_pb2_grpc.py @@ -26,8 +26,7 @@ if _version_not_supported: class SensorPushStub(object): - """传感器数据推送服务 —— Rust 端作为 gRPC client 推送实时帧 - """ + """Missing associated documentation comment in .proto file.""" def __init__(self, channel): """Constructor. @@ -35,16 +34,15 @@ class SensorPushStub(object): Args: channel: A grpc.Channel. """ - self.Upload = channel.stream_unary( + self.Upload = channel.stream_stream( '/sensor_stream.SensorPush/Upload', request_serializer=sensor__stream__pb2.SensorFrame.SerializeToString, - response_deserializer=sensor__stream__pb2.UploadResponse.FromString, + response_deserializer=sensor__stream__pb2.PztAngleResponse.FromString, _registered_method=True) class SensorPushServicer(object): - """传感器数据推送服务 —— Rust 端作为 gRPC client 推送实时帧 - """ + """Missing associated documentation comment in .proto file.""" def Upload(self, request_iterator, context): """Missing associated documentation comment in .proto file.""" @@ -55,10 +53,10 @@ class SensorPushServicer(object): def add_SensorPushServicer_to_server(servicer, server): rpc_method_handlers = { - 'Upload': grpc.stream_unary_rpc_method_handler( + 'Upload': grpc.stream_stream_rpc_method_handler( servicer.Upload, request_deserializer=sensor__stream__pb2.SensorFrame.FromString, - response_serializer=sensor__stream__pb2.UploadResponse.SerializeToString, + response_serializer=sensor__stream__pb2.PztAngleResponse.SerializeToString, ), } generic_handler = grpc.method_handlers_generic_handler( @@ -69,8 +67,7 @@ def add_SensorPushServicer_to_server(servicer, server): # This class is part of an EXPERIMENTAL API. class SensorPush(object): - """传感器数据推送服务 —— Rust 端作为 gRPC client 推送实时帧 - """ + """Missing associated documentation comment in .proto file.""" @staticmethod def Upload(request_iterator, @@ -83,12 +80,12 @@ class SensorPush(object): wait_for_ready=None, timeout=None, metadata=None): - return grpc.experimental.stream_unary( + return grpc.experimental.stream_stream( request_iterator, target, '/sensor_stream.SensorPush/Upload', sensor__stream__pb2.SensorFrame.SerializeToString, - sensor__stream__pb2.UploadResponse.FromString, + sensor__stream__pb2.PztAngleResponse.FromString, options, channel_credentials, insecure, @@ -101,8 +98,7 @@ class SensorPush(object): class ExportProcessorStub(object): - """导出后处理服务 —— Rust 导出 CSV 后将路径发给 Python 做梯度过滤 - """ + """Missing associated documentation comment in .proto file.""" def __init__(self, channel): """Constructor. @@ -118,8 +114,7 @@ class ExportProcessorStub(object): class ExportProcessorServicer(object): - """导出后处理服务 —— Rust 导出 CSV 后将路径发给 Python 做梯度过滤 - """ + """Missing associated documentation comment in .proto file.""" def ProcessFile(self, request, context): """Missing associated documentation comment in .proto file.""" @@ -144,8 +139,7 @@ def add_ExportProcessorServicer_to_server(servicer, server): # This class is part of an EXPERIMENTAL API. class ExportProcessor(object): - """导出后处理服务 —— Rust 导出 CSV 后将路径发给 Python 做梯度过滤 - """ + """Missing associated documentation comment in .proto file.""" @staticmethod def ProcessFile(request, diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 257a588..71f15c8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -18,6 +18,7 @@ dependencies = [ "futures-util", "humantime", "log", + "ndarray", "prost", "prost-types", "protoc-bin-vendored", @@ -2441,6 +2442,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "memchr" version = "2.8.0" @@ -2530,6 +2541,19 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "ndarray" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb12d4e967ec485a5f71c6311fe28158e9d6f4bc4a447b474184d0f91a8fa32" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "rawpointer", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2595,12 +2619,30 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3605,6 +3647,12 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + [[package]] name = "redox_syscall" version = "0.5.18" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4b74496..8df8fea 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -17,6 +17,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] [features] default = [] devkit = ["dep:tonic", "dep:prost", "dep:prost-types", "dep:async-stream", "dep:dirs"] +multi-dim = ["dep:ndarray"] [build-dependencies] tauri-build = { version = "2", features = [] } @@ -51,6 +52,7 @@ futures-util = "0.3" uuid = { version = "1", features = ["v4", "serde"] } rand = "0.8" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +ndarray = { version = "0.15", optional = true } [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = "2" diff --git a/src-tauri/proto/sensor_stream.proto b/src-tauri/proto/sensor_stream.proto index f1a6855..f8cbd37 100644 --- a/src-tauri/proto/sensor_stream.proto +++ b/src-tauri/proto/sensor_stream.proto @@ -2,17 +2,14 @@ syntax = "proto3"; package sensor_stream; -// 传感器数据推送服务 —— Rust 端作为 gRPC client 推送实时帧 service SensorPush { - rpc Upload (stream SensorFrame) returns (UploadResponse); + rpc Upload(stream SensorFrame) returns (stream PztAngleResponse); } -// 导出后处理服务 —— Rust 导出 CSV 后将路径发给 Python 做梯度过滤 service ExportProcessor { - rpc ProcessFile (ProcessRequest) returns (ProcessResponse); + rpc ProcessFile(ProcessRequest) returns (ProcessResponse); } -// 一帧传感器数据 message SensorFrame { uint64 seq = 1; uint64 timestamp_ms = 2; @@ -23,27 +20,27 @@ message SensorFrame { uint32 dts_ms = 7; } -// 上传确认响应 -message UploadResponse { - bool ok = 1; - uint64 frames_received = 2; - string message = 3; +message PztAngleResponse { + uint64 seq = 1; + uint64 timestamp_ms = 2; + float angle = 3; + uint32 dts_ms = 4; + bool ok = 5; + string message = 6; } -// 导出处理请求 message ProcessRequest { - string csv_path = 1; // 导出的 CSV 文件路径 - bool save_as_xlsx = 2; // 是否以 xlsx 保存(删除源 CSV) + string csv_path = 1; + bool save_as_xlsx = 2; } -// 导出处理响应 message ProcessResponse { bool ok = 1; - string output_path = 2; // 输出文件路径 - uint32 groups_used = 3; // 分组数 - double mean_value = 4; // 均值 - double threshold = 5; // 梯度阈值 - uint32 rows_total = 6; // 原始行数 - uint32 rows_kept = 7; // 保留行数 + string output_path = 2; + uint32 groups_used = 3; + double mean_value = 4; + double threshold = 5; + uint32 rows_total = 6; + uint32 rows_kept = 7; string message = 8; -} \ No newline at end of file +} diff --git a/src-tauri/resources/je-skin-devkit-server.exe b/src-tauri/resources/je-skin-devkit-server.exe index 05ab567..76eaa39 100644 Binary files a/src-tauri/resources/je-skin-devkit-server.exe and b/src-tauri/resources/je-skin-devkit-server.exe differ diff --git a/src-tauri/src/commands/devkit.rs b/src-tauri/src/commands/devkit.rs index 45b4e4e..11c418c 100644 --- a/src-tauri/src/commands/devkit.rs +++ b/src-tauri/src/commands/devkit.rs @@ -3,6 +3,8 @@ //! 仅在 `devkit` feature 启用时编译。 use tauri::State; +#[cfg(feature = "devkit")] +use tauri::AppHandle; use crate::devkit::{DevKitConfig, DevKitState, DevKitStatusSnapshot, ExportProcessResult}; @@ -12,9 +14,13 @@ pub fn devkit_status(state: State<'_, DevKitState>) -> DevKitStatusSnapshot { } #[tauri::command] -pub async fn devkit_start(state: State<'_, DevKitState>, port: Option) -> Result { +pub async fn devkit_start( + app: AppHandle, + state: State<'_, DevKitState>, + port: Option, +) -> Result { let target_port = port.unwrap_or(50051); - state.start(target_port).await?; + state.start(app, target_port).await?; Ok(state.status()) } @@ -44,4 +50,4 @@ pub async fn devkit_process_export( let config = state.get_config(); let use_xlsx = save_as_xlsx.unwrap_or(config.save_as_xlsx); state.process_export(&csv_path, use_xlsx).await -} \ No newline at end of file +} diff --git a/src-tauri/src/devkit/client.rs b/src-tauri/src/devkit/client.rs index f2cee57..03406f1 100644 --- a/src-tauri/src/devkit/client.rs +++ b/src-tauri/src/devkit/client.rs @@ -8,14 +8,24 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::Arc; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter}; use tokio::sync::mpsc; use tokio::task::JoinHandle; -use serde::{Deserialize, Serialize}; use super::proto::sensor_push_client::SensorPushClient; use super::proto::export_processor_client::ExportProcessorClient; use super::proto::{ProcessRequest, SensorFrame}; +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct DevKitPztAngleEvent { + seq: u64, + timestamp_ms: u64, + dts_ms: u32, + angle: f32, +} + // ── DevKit 配置 ──────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] @@ -145,7 +155,7 @@ impl DevKitState { } /// 启动 gRPC client,连接到 Python server 并开始推送数据 - pub async fn start(&self, port: u16) -> Result<(), String> { + pub async fn start(&self, app: AppHandle, port: u16) -> Result<(), String> { if self.running.load(Ordering::SeqCst) { return Err("AlreadyRunning".into()); } @@ -161,9 +171,10 @@ impl DevKitState { let running = Arc::clone(&self.running); let frame_count = Arc::clone(&self.frame_count); + let app_handle = app.clone(); let handle = tokio::spawn(async move { - if let Err(e) = run_grpc_upload(addr, rx, frame_count).await { + if let Err(e) = run_grpc_upload(app_handle, addr, rx, frame_count).await { ::log::error!("DevKit gRPC upload error: {e:?}"); } running.store(false, Ordering::SeqCst); @@ -241,6 +252,7 @@ impl DevKitState { // ── gRPC Upload Client ───────────────────────────────────────────── async fn run_grpc_upload( + app: AppHandle, addr: String, mut rx: mpsc::Receiver, frame_count: Arc, @@ -255,14 +267,29 @@ async fn run_grpc_upload( }; let response = client.upload(stream).await?; - let resp = response.into_inner(); + let mut inbound = response.into_inner(); - ::log::info!( - "DevKit upload complete: ok={}, frames={}, msg={}", - resp.ok, - resp.frames_received, - resp.message - ); + while let Some(message) = inbound.message().await? { + if message.ok { + let payload = DevKitPztAngleEvent { + seq: message.seq, + timestamp_ms: message.timestamp_ms, + dts_ms: message.dts_ms, + angle: message.angle, + }; + ::log::debug!( + "python pzt angle: seq={} dts_ms={} angle={:.2}", + message.seq, + message.dts_ms, + message.angle + ); + app.emit("devkit_pzt_angle", payload)?; + } else { + ::log::warn!("DevKit PZT response error: {}", message.message); + } + } + + ::log::info!("DevKit upload stream closed"); Ok(()) -} \ No newline at end of file +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7bd96ff..848645a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -22,11 +22,43 @@ fn start_server_exe(exe_path: &std::path::Path) { } match command.spawn() { - Ok(_) => ::log::info!("DevKit Python server started: {}", exe_path.display()), + Ok(_) => ::log::info!("DevKit Python server launched: {}", exe_path.display()), Err(error) => ::log::warn!("Failed to start DevKit Python server: {error}"), } } +#[cfg(feature = "devkit")] +fn is_local_port_open(port: u16) -> bool { + use std::net::{SocketAddr, TcpStream}; + use std::time::Duration; + + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + TcpStream::connect_timeout(&addr, Duration::from_millis(250)).is_ok() +} + +#[cfg(feature = "devkit")] +fn find_server_exe( + resource_dir: &std::path::Path, + exe_name: &str, +) -> Option { + let mut candidates = Vec::new(); + candidates.push(resource_dir.join(exe_name)); + + if let Ok(current_exe) = std::env::current_exe() { + if let Some(parent) = current_exe.parent() { + candidates.push(parent.join(exe_name)); + } + } + + if let Ok(current_dir) = std::env::current_dir() { + candidates.push(current_dir.join("src-tauri").join("resources").join(exe_name)); + candidates.push(current_dir.join("devkit").join("dist").join(exe_name)); + candidates.push(current_dir.join("resources").join(exe_name)); + } + + candidates.into_iter().find(|path| path.exists()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let builder = tauri::Builder::default() @@ -56,35 +88,35 @@ pub fn run() { .path() .resource_dir() .unwrap_or_else(|_| std::path::PathBuf::from("./resources")); + let app_handle = app.handle().clone(); tauri::async_runtime::spawn(async move { + let devkit_port = 50051u16; #[cfg(target_os = "windows")] let exe_name = "je-skin-devkit-server.exe"; #[cfg(not(target_os = "windows"))] let exe_name = "je-skin-devkit-server"; - let bundled_exe = resource_dir.join(exe_name); - let fallback_exe = std::env::current_exe() - .ok() - .and_then(|path| path.parent().map(|parent| parent.join(exe_name))); - - let server_exe = if bundled_exe.exists() { - Some(bundled_exe) + if is_local_port_open(devkit_port) { + ::log::info!( + "DevKit port {} already in use, skipping Python server auto-start", + devkit_port + ); } else { - fallback_exe.filter(|path| path.exists()) - }; + let server_exe = find_server_exe(&resource_dir, exe_name); - if let Some(exe_path) = server_exe { - start_server_exe(&exe_path); - tokio::time::sleep(std::time::Duration::from_millis(1200)).await; - } else { - ::log::info!("DevKit Python server not found, skipping auto-start"); + if let Some(exe_path) = server_exe { + start_server_exe(&exe_path); + tokio::time::sleep(std::time::Duration::from_millis(1200)).await; + } else { + ::log::info!("DevKit Python server not found, skipping auto-start"); + } } - if let Err(error) = devkit_state_clone.start(50051).await { + if let Err(error) = devkit_state_clone.start(app_handle, devkit_port).await { ::log::warn!("DevKit auto-start failed: {error}"); } else { - ::log::info!("DevKit auto-started on 127.0.0.1:50051"); + ::log::info!("DevKit gRPC client initialized for 127.0.0.1:{devkit_port}"); } }); diff --git a/src-tauri/src/serial_core/mod.rs b/src-tauri/src/serial_core/mod.rs index a3adf2c..3a3e73c 100644 --- a/src-tauri/src/serial_core/mod.rs +++ b/src-tauri/src/serial_core/mod.rs @@ -11,6 +11,8 @@ pub mod model; pub mod serial; pub mod record; pub mod utils; +#[cfg(feature = "multi-dim")] +pub mod multi_dim_force; pub type TestRecording = Recording; pub type TactileARecording = Recording; diff --git a/src-tauri/src/serial_core/multi_dim_force.rs b/src-tauri/src/serial_core/multi_dim_force.rs new file mode 100644 index 0000000..379af89 --- /dev/null +++ b/src-tauri/src/serial_core/multi_dim_force.rs @@ -0,0 +1,122 @@ +use ndarray::Array2; + +const TOTAL_PRESSURE_LOW_THRESHOLD: usize = 500; +const COP_STABILITY_FRAMES_REQUIRED: usize = 5; +const SENSOR_ROWS: usize = 12; +const SENSOR_COLS: usize = 7; + +pub struct PztProcessor { + first_frame: Option>, + first_contact_cop_x: Option, + first_contact_cop_y: Option, + contact_initialized: bool, + total_pressure_low_counter: usize, +} + +impl PztProcessor { + pub fn new() -> Self { + Self { + first_frame: None, + first_contact_cop_x: None, + first_contact_cop_y: None, + contact_initialized: false, + total_pressure_low_counter: 0, + } + } + + fn subtract_baseline(&mut self, current_frame: &[f32]) -> Vec { + if self.first_frame.is_none() { + self.first_frame = Some(current_frame.to_vec()); + } + + let baseline = self.first_frame.as_ref().unwrap(); + current_frame + .iter() + .zip(baseline.iter()) + .map(|(c, b)| (c - b).max(0.0)) + .collect() + } + + fn reset_cop_state(&mut self) { + self.first_contact_cop_x = None; + self.first_contact_cop_y = None; + self.contact_initialized = false; + self.total_pressure_low_counter = 0; + } + + fn compute_pressure_direction(&mut self, frame: &[f32]) -> (f32, f32) { + let frame2d = Array2::from_shape_vec((SENSOR_ROWS, SENSOR_COLS), frame.to_vec()).unwrap(); + let total_pressure: f32 = frame2d.sum(); + if total_pressure < TOTAL_PRESSURE_LOW_THRESHOLD as f32 { + self.total_pressure_low_counter += 1; + } else { + self.total_pressure_low_counter = 0; + } + + if self.total_pressure_low_counter >= COP_STABILITY_FRAMES_REQUIRED { + self.reset_cop_state(); + return (0.0, 0.0); + } + + if total_pressure == 0.0 { + return (0.0, 0.0); + } + + let mut sum_x = 0.0; + let mut sum_y = 0.0; + + for r in 0..SENSOR_ROWS { + for c in 0..SENSOR_COLS { + let val = frame2d[(r, c)]; + sum_x += val * c as f32; + sum_y += val * r as f32; + } + } + + let cop_x = sum_x / total_pressure; + let cop_y = sum_y / total_pressure; + + if !self.contact_initialized { + self.first_contact_cop_x = Some(cop_x); + self.first_contact_cop_y = Some(cop_y); + self.contact_initialized = true; + return (0.0, 0.0); + } + + let dx = cop_x - self.first_contact_cop_x.unwrap(); + let dy = cop_y - self.first_contact_cop_y.unwrap(); + + (dx, dy) + } + + fn compute_vector_angle(x: f32, y: f32) -> (f32, f32) { + let epsilon = 1e-8; + let mag = (x * x + y * y).sqrt(); + let mut angle = (y).atan2(x + epsilon).to_degrees(); + if angle < 0.0 { + angle += 360.0; + } + (angle, mag) + } + + fn compute_pzt_angle(px: f32, py: f32) -> (f32, f32) { + Self::compute_vector_angle(px, -py) + } + + pub fn get_pzt_angle(&mut self, adc_data: &[f32]) -> Result { + if adc_data.len() != 84 { + return Err("ADC data length must be 84"); + } + + let baseline = self.subtract_baseline(adc_data); + let (dx, dy) = self.compute_pressure_direction(&baseline); + let (angle, _) = Self::compute_pzt_angle(dx, dy); + + Ok(angle) + } + + pub fn reset_baseline(&mut self) { + self.first_frame = None; + self.reset_cop_state(); + } +} diff --git a/src-tauri/src/serial_core/serial.rs b/src-tauri/src/serial_core/serial.rs index 7b161d2..fca733d 100644 --- a/src-tauri/src/serial_core/serial.rs +++ b/src-tauri/src/serial_core/serial.rs @@ -2,12 +2,17 @@ use crate::serial_core::codec::Codec; use crate::serial_core::codecs::tactile_a::TactileACodec; use crate::serial_core::frame::{FrameHandler, TactileAFrame, TestFrame}; use crate::serial_core::model::{HudChartState, HudPacket}; +#[cfg(feature = "multi-dim")] +use crate::serial_core::multi_dim_force::PztProcessor; use crate::serial_core::record::Recording; use crate::serial_core::record::{FrameTiming, RecordedFrame}; #[cfg(feature = "devkit")] use crate::devkit::{proto::SensorFrame, DevKitState}; use anyhow::Result; +use log::debug; use std::future::pending; +#[cfg(feature = "devkit")] +use std::sync::atomic::Ordering; use std::sync::{Arc, Mutex}; use std::time::Instant; use tauri::{AppHandle, Emitter}; @@ -17,14 +22,19 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::time::{self, Duration, MissedTickBehavior}; use tokio_serial::SerialStream; use tokio_util::sync::CancellationToken; -#[cfg(feature = "devkit")] -use std::sync::atomic::Ordering; + +const AUTO_SUB_INTERVAL: Duration = Duration::from_nanos(16_666_667); pub enum PollMode { Disable, Enabled(Box>), } +struct PendingSubFrame { + frame: F, + values: Vec, +} + pub trait SerialFrame: Clone + Send + 'static { fn dts_ms(&self) -> u64; @@ -215,10 +225,15 @@ where it.set_missed_tick_behavior(MissedTickBehavior::Skip); it }); + let mut poll_sub_interval = time::interval(AUTO_SUB_INTERVAL); + poll_sub_interval.set_missed_tick_behavior(MissedTickBehavior::Skip); let mut chart_state = HudChartState::new(); let mut buffer = [0u8; 1024]; let mut prune_interval = time::interval(Duration::from_millis(450)); + #[cfg(feature = "multi-dim")] + let mut pzt_processor = PztProcessor::new(); + let mut pending_sub_frame: Option> = None; prune_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); loop { @@ -246,6 +261,21 @@ where app.emit("hud_stream", packet)?; } } + _ = poll_sub_interval.tick() => { + if let Some(pending) = pending_sub_frame.take() { + let display_values = build_display_values( + &mut chart_state, + pending.values.as_slice(), + ); + + if let Some(packet) = pending + .frame + .to_hud_packet(&mut chart_state, display_values.as_deref()) + { + app.emit("hud_stream", packet)?; + } + } + } read_result = port.read(&mut buffer) => { let n = read_result?; if n == 0 { @@ -266,25 +296,38 @@ where .await? .map(|vals| vals.into_iter().map(Into::into).collect::>()); - let mut record = recording.lock().map_err(|_| anyhow::anyhow!("recording state poisoned"))?; - record.push(RecordedFrame{ - timing: FrameTiming { pts_ms: None, dts_ms: frame.dts_ms() }, + let mut record = recording + .lock() + .map_err(|_| anyhow::anyhow!("recording state poisoned"))?; + record.push(RecordedFrame { + timing: FrameTiming { + pts_ms: None, + dts_ms: frame.dts_ms(), + }, frame: frame.clone(), }); + drop(record); - let display_values = if let Some(vals) = decode_res.as_ref() { - let summary = vals.iter().copied().sum::(); - let force = raw_to_g1(summary as u32); - chart_state.record_summary(force as f32); - chart_state.record_pressure_matrix(vals.as_slice()); + if let Some(vals) = decode_res { + #[cfg(feature = "multi-dim")] + { + let pzt_values = vals.iter().map(|value| *value as f32).collect::>(); + if let Ok(angle) = pzt_processor.get_pzt_angle(&pzt_values) { + // debug!("pzt angle: {:.2}", angle); + } + } #[cfg(feature = "devkit")] - push_devkit_frame(&app, vals.as_slice(), frame.dts_ms(), force); - Some(vec![summary]) - } else { - None - }; + { + let summary = vals.iter().copied().sum::(); + let force = raw_to_g1(summary as u32); + push_devkit_frame(&app, vals.as_slice(), frame.dts_ms(), force); + } - if let Some(packet) = frame.to_hud_packet(&mut chart_state, display_values.as_deref()) { + pending_sub_frame = Some(PendingSubFrame { + frame: frame.clone(), + values: vals, + }); + } else if let Some(packet) = frame.to_hud_packet(&mut chart_state, None) { app.emit("hud_stream", packet)?; } } @@ -294,6 +337,14 @@ where Ok(()) } +fn build_display_values(chart_state: &mut HudChartState, values: &[i32]) -> Option> { + let summary = values.iter().copied().sum::(); + let force = raw_to_g1(summary as u32); + chart_state.record_summary(force as f32); + chart_state.record_pressure_matrix(values); + Some(vec![summary]) +} + #[cfg(feature = "devkit")] fn push_devkit_frame(app: &AppHandle, values: &[i32], dts_ms: u64, resultant_force: f64) { let devkit_state = app.state::(); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 00ed87a..1d96cfc 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -37,7 +37,8 @@ "nsis": { "installMode": "both", "displayLanguageSelector": false, - "installerIcon": "icons/icon.ico" + "installerIcon": "icons/icon.ico", + "template": "nsis/installer.nsi" } }, "resources": [ diff --git a/src/lib/components/CenterStage.svelte b/src/lib/components/CenterStage.svelte index 10ba1e9..de63228 100644 --- a/src/lib/components/CenterStage.svelte +++ b/src/lib/components/CenterStage.svelte @@ -33,7 +33,6 @@ export let rangeLabel = ""; export let rangeMinLabel = ""; export let rangeMaxLabel = ""; - export let colorMapLabel = ""; export let resetConfigLabel = ""; export let applyLiveHint = ""; export let matrixRows = 12; @@ -42,7 +41,6 @@ export let rangeMax = DEFAULT_PRESSURE_RANGE_MAX; export let colorMapPreset: PressureColorMapPreset = "emerald"; export let matrixDisplayMode: MatrixDisplayMode = "dots"; - export let colorMapOptions: HudColorMapOption[] = []; export let replaySectionLabel = ""; export let replayPlayLabel = ""; export let replayPauseLabel = ""; @@ -56,6 +54,7 @@ export let replayFileName = ""; export let replayFrameInfo = ""; export let showPrecisionTestPanel = false; + export let sessionStartedAt: number = Date.now(); let stagePlaneEl: HTMLDivElement | undefined; let panelZoneEl: HTMLDivElement | undefined; @@ -195,6 +194,7 @@ {rangeMax} {colorMapPreset} {matrixDisplayMode} + {locale} showStatsPanel={true} /> {/key} @@ -225,6 +225,7 @@ {rangeMax} {colorMapPreset} {matrixDisplayMode} + {locale} showStatsPanel={true} /> {/key} @@ -246,9 +247,6 @@ {rangeLabel} {rangeMinLabel} {rangeMaxLabel} - {colorMapLabel} - bind:colorMapPreset - {colorMapOptions} resetLabel={resetConfigLabel} {applyLiveHint} on:close={() => dispatch("configclose")} @@ -267,7 +265,7 @@ in:fly={{ x: -180, duration: 340, opacity: 0.08, easing: cubicOut }} out:fly={{ x: -180, duration: 280, opacity: 0.06, easing: cubicIn }} > - + {/each} @@ -281,6 +279,9 @@ {summary} xValues={summary.xValues ?? null} yValues={summary.points} + {locale} + {sessionStartedAt} + isRealtime={!replayHasData} side="left" panelIndex={leftPanels.length} /> @@ -298,7 +299,7 @@ in:fly={{ x: 180, duration: 340, opacity: 0.08, easing: cubicOut }} out:fly={{ x: 180, duration: 280, opacity: 0.06, easing: cubicIn }} > - + {/each} @@ -312,6 +313,9 @@ {summary} xValues={summary.xValues ?? null} yValues={summary.points} + {locale} + {sessionStartedAt} + isRealtime={!replayHasData} side="right" panelIndex={rightPanels.length} /> @@ -396,7 +400,7 @@ } .stage-canvas-plane { - --rail-width: clamp(17.5rem, 23vw, 21.5rem); + --rail-width: clamp(20rem, 27vw, 26rem); --rail-edge-inset: clamp(0.35rem, 1vw, 0.9rem); --safe-gap: clamp(0.35rem, 0.9vw, 0.85rem); --panel-zone-top: clamp(6.4rem, 11.8vh, 8rem); @@ -754,7 +758,7 @@ @media (max-width: 1180px) { .stage-canvas-plane { - --rail-width: clamp(14.2rem, 28vw, 16.4rem); + --rail-width: clamp(17rem, 32vw, 22rem); --rail-edge-inset: clamp(0.25rem, 0.7vw, 0.55rem); --safe-gap: clamp(0.2rem, 0.75vw, 0.45rem); --panel-zone-top: clamp(6rem, 11.2vh, 7.2rem); diff --git a/src/lib/components/ConfigPanel.svelte b/src/lib/components/ConfigPanel.svelte index df1bbaf..5cf8780 100644 --- a/src/lib/components/ConfigPanel.svelte +++ b/src/lib/components/ConfigPanel.svelte @@ -1,7 +1,6 @@

{labels.title}

-
@@ -77,18 +76,6 @@ {labels.status} {running ? labels.connected : labels.disconnected}
- {#if running} -
-
- {labels.port} - :{port} -
-
- {labels.framesSent} - {framesSent} -
-
- {/if}
@@ -132,8 +119,8 @@ {lastProcessResult.threshold.toFixed(3)}
- {labels.rows} - {lastProcessResult.rowsTotal} → {lastProcessResult.rowsKept} + {labels.rowsFlow} + {lastProcessResult.rowsTotal} -> {lastProcessResult.rowsKept}
@@ -236,30 +223,6 @@ margin-inline-start: auto; } - .dk-info-grid { - display: flex; - gap: 1rem; - } - - .dk-info { - display: flex; - flex-direction: column; - gap: 0.2rem; - } - - .dk-info-label { - color: rgb(var(--hud-text-dim-rgb) / 0.7); - font-size: 0.56rem; - letter-spacing: 0.08em; - text-transform: uppercase; - } - - .dk-info-value { - color: rgb(var(--hud-text-main-rgb) / 0.94); - font-size: 0.82rem; - font-weight: 500; - } - .dk-toggle { display: flex; align-items: flex-start; diff --git a/src/lib/components/PressureMatrixViewer.svelte b/src/lib/components/PressureMatrixViewer.svelte index 0da8ce9..ad52a11 100644 --- a/src/lib/components/PressureMatrixViewer.svelte +++ b/src/lib/components/PressureMatrixViewer.svelte @@ -32,6 +32,7 @@ export let matrixDisplayMode: MatrixDisplayMode = "dots"; export let summary: HudSummary | null = null; export let showStatsPanel = true; + export let locale: "zh-CN" | "en-US" = "zh-CN"; let viewerEl: HTMLDivElement | undefined; let canvasEl: HTMLCanvasElement | undefined; @@ -131,8 +132,13 @@ $: resolvedRangeMin = resolvedRange.min; $: resolvedRangeMax = resolvedRange.max; $: matrixLayout = buildMatrixLayout(resolvedMatrixRows, resolvedMatrixCols); - $: statsModeLabel = matrixDisplayMode === "dots" ? "dot pulse" : "numeric pulse"; - $: statsNote = `${resolvedMatrixRows}x${resolvedMatrixCols} / force range ${resolvedRangeMin}-${resolvedRangeMax} / ${statsModeLabel}`; + $: statsModeLabel = matrixDisplayMode === "dots" + ? (locale === "zh-CN" ? "点阵脉冲" : "dot pulse") + : (locale === "zh-CN" ? "数字脉冲" : "numeric pulse"); + $: statsNote = `${resolvedMatrixRows}x${resolvedMatrixCols} / ${locale === "zh-CN" ? "力量范围" : "force range"} ${resolvedRangeMin}-${resolvedRangeMax} / ${statsModeLabel}`; + $: viewerI18n = locale === "zh-CN" + ? { title: "合力", current: "当前合力", max: "最大合力", min: "最小合力" } + : { title: "Resultant Force", current: "Current RF", max: "Max RF", min: "Min RF" }; function formatForceStat(value: number | null): string { if (value == null || !Number.isFinite(value)) { @@ -660,18 +666,18 @@ {#if showStatsPanel}
-

Resultant Force

+

{viewerI18n.title}

- Current RF + {viewerI18n.current} {formatForceStat(stats.current)}
- Max RF + {viewerI18n.max} {formatForceStat(stats.max)}
- Min RF + {viewerI18n.min} {formatForceStat(stats.min)}
diff --git a/src/lib/components/SignalChart.svelte b/src/lib/components/SignalChart.svelte index 8e2f12a..484090a 100644 --- a/src/lib/components/SignalChart.svelte +++ b/src/lib/components/SignalChart.svelte @@ -3,6 +3,11 @@ export let panel: HudSignalPanel; export let panelIndex = 0; + export let locale: "zh-CN" | "en-US" = "zh-CN"; + + $: signalI18n = locale === "zh-CN" + ? { now: "当前", max: "最大", min: "最小", total: "合计" } + : { now: "Now", max: "Max", min: "Min", total: "TOTAL" }; const viewportWidth = 100; const viewportHeight = 36; @@ -110,7 +115,7 @@ @@ -136,17 +141,17 @@

- Now + {signalI18n.now} {latestValue}

- Max + {signalI18n.max} {maxValue}

- Min + {signalI18n.min} {minValue}

@@ -158,7 +163,7 @@ --enter-ms: 1800ms; --fade-ms: 1000ms; overflow: hidden; - inline-size: min(100%, clamp(16.8rem, 23vw, 22rem)); + inline-size: min(100%, clamp(19rem, 27vw, 26rem)); aspect-ratio: 1.44 / 1; min-block-size: 11.8rem; justify-self: start; @@ -388,7 +393,7 @@ @media (max-width: 1180px) { .signal-panel { - inline-size: min(100%, clamp(14rem, 30vw, 17rem)); + inline-size: min(100%, clamp(16rem, 32vw, 21rem)); aspect-ratio: 1.5 / 1; min-block-size: 10.1rem; } diff --git a/src/lib/components/SummaryCurve.svelte b/src/lib/components/SummaryCurve.svelte index f9e0fd3..77a7d58 100644 --- a/src/lib/components/SummaryCurve.svelte +++ b/src/lib/components/SummaryCurve.svelte @@ -1,4 +1,5 @@