fix: 修复打砖块游戏碰撞穿透bug,添加渐进提速机制

This commit is contained in:
lenn
2026-04-29 15:43:56 +08:00
parent 26533f6916
commit 326f07ed4f
23 changed files with 786 additions and 376 deletions

View File

@@ -1,6 +1,6 @@
@echo off @echo off
REM ── JE-Skin DevKit: 打包 Python gRPC server 为 exe ── 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... echo [1/3] Generating gRPC stubs...
python -m grpc_tools.protoc ^ python -m grpc_tools.protoc ^

View File

@@ -5,8 +5,8 @@ a = Analysis(
['sensor_server.py'], ['sensor_server.py'],
pathex=[], pathex=[],
binaries=[], binaries=[],
datas=[], datas=[('sensor_stream_pb2.py', '.'), ('sensor_stream_pb2_grpc.py', '.')],
hiddenimports=['grpc', 'openpyxl'], hiddenimports=['grpc', 'openpyxl', 'numpy'],
hookspath=[], hookspath=[],
hooksconfig={}, hooksconfig={},
runtime_hooks=[], runtime_hooks=[],

View File

@@ -234,30 +234,63 @@ class SensorPushServicer(sensor_stream_pb2_grpc.SensorPushServicer):
def __init__(self): def __init__(self):
self.frame_count = 0 self.frame_count = 0
self.last_report_time = time.time() self.last_report_time = time.time()
self.last_angle = None
def Upload(self, request_iterator, context): def Upload(self, request_iterator, context):
print("[SensorPush] Client connected, waiting for frames...") print("[SensorPush] Client connected, waiting for frames...")
reset_baseline()
self.last_angle = None
for frame in request_iterator: for frame in request_iterator:
self.frame_count += 1 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: if self.frame_count % 100 == 0:
now = time.time() now = time.time()
elapsed = now - self.last_report_time elapsed = now - self.last_report_time
fps = 100 / elapsed if elapsed > 0 else 0 fps = 100 / elapsed if elapsed > 0 else 0
self.last_report_time = now self.last_report_time = now
angle_text = (
f"{self.last_angle:.2f}"
if self.last_angle is not None
else "n/a"
)
print( print(
f"[SensorPush] Frame #{frame.seq} | " f"[SensorPush] Frame #{frame.seq} | "
f"{frame.rows}x{frame.cols} | " f"{frame.rows}x{frame.cols} | "
f"angle={angle_text} | "
f"force={frame.resultant_force:.1f} | " f"force={frame.resultant_force:.1f} | "
f"total={self.frame_count} | ~{fps:.1f} fps" f"total={self.frame_count} | ~{fps:.1f} fps"
) )
print(f"[SensorPush] Stream ended. Total: {self.frame_count}") 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): class ExportProcessorServicer(sensor_stream_pb2_grpc.ExportProcessorServicer):
@@ -316,6 +349,117 @@ def serve(port: int):
server.wait_for_termination() 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__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description="JE-Skin DevKit gRPC Server") parser = argparse.ArgumentParser(description="JE-Skin DevKit gRPC Server")
parser.add_argument("--port", type=int, default=50051, help="gRPC listen port (default: 50051)") parser.add_argument("--port", type=int, default=50051, help="gRPC listen port (default: 50051)")

View File

@@ -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() _globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
@@ -33,14 +33,14 @@ if not _descriptor._USE_C_DESCRIPTORS:
DESCRIPTOR._loaded_options = None DESCRIPTOR._loaded_options = None
_globals['_SENSORFRAME']._serialized_start=39 _globals['_SENSORFRAME']._serialized_start=39
_globals['_SENSORFRAME']._serialized_end=172 _globals['_SENSORFRAME']._serialized_end=172
_globals['_UPLOADRESPONSE']._serialized_start=174 _globals['_PZTANGLERESPONSE']._serialized_start=174
_globals['_UPLOADRESPONSE']._serialized_end=244 _globals['_PZTANGLERESPONSE']._serialized_end=287
_globals['_PROCESSREQUEST']._serialized_start=246 _globals['_PROCESSREQUEST']._serialized_start=289
_globals['_PROCESSREQUEST']._serialized_end=302 _globals['_PROCESSREQUEST']._serialized_end=345
_globals['_PROCESSRESPONSE']._serialized_start=305 _globals['_PROCESSRESPONSE']._serialized_start=348
_globals['_PROCESSRESPONSE']._serialized_end=471 _globals['_PROCESSRESPONSE']._serialized_end=514
_globals['_SENSORPUSH']._serialized_start=473 _globals['_SENSORPUSH']._serialized_start=516
_globals['_SENSORPUSH']._serialized_end=556 _globals['_SENSORPUSH']._serialized_end=603
_globals['_EXPORTPROCESSOR']._serialized_start=558 _globals['_EXPORTPROCESSOR']._serialized_start=605
_globals['_EXPORTPROCESSOR']._serialized_end=653 _globals['_EXPORTPROCESSOR']._serialized_end=700
# @@protoc_insertion_point(module_scope) # @@protoc_insertion_point(module_scope)

View File

@@ -26,8 +26,7 @@ if _version_not_supported:
class SensorPushStub(object): class SensorPushStub(object):
"""传感器数据推送服务 —— Rust 端作为 gRPC client 推送实时帧 """Missing associated documentation comment in .proto file."""
"""
def __init__(self, channel): def __init__(self, channel):
"""Constructor. """Constructor.
@@ -35,16 +34,15 @@ class SensorPushStub(object):
Args: Args:
channel: A grpc.Channel. channel: A grpc.Channel.
""" """
self.Upload = channel.stream_unary( self.Upload = channel.stream_stream(
'/sensor_stream.SensorPush/Upload', '/sensor_stream.SensorPush/Upload',
request_serializer=sensor__stream__pb2.SensorFrame.SerializeToString, request_serializer=sensor__stream__pb2.SensorFrame.SerializeToString,
response_deserializer=sensor__stream__pb2.UploadResponse.FromString, response_deserializer=sensor__stream__pb2.PztAngleResponse.FromString,
_registered_method=True) _registered_method=True)
class SensorPushServicer(object): class SensorPushServicer(object):
"""传感器数据推送服务 —— Rust 端作为 gRPC client 推送实时帧 """Missing associated documentation comment in .proto file."""
"""
def Upload(self, request_iterator, context): def Upload(self, request_iterator, context):
"""Missing associated documentation comment in .proto file.""" """Missing associated documentation comment in .proto file."""
@@ -55,10 +53,10 @@ class SensorPushServicer(object):
def add_SensorPushServicer_to_server(servicer, server): def add_SensorPushServicer_to_server(servicer, server):
rpc_method_handlers = { rpc_method_handlers = {
'Upload': grpc.stream_unary_rpc_method_handler( 'Upload': grpc.stream_stream_rpc_method_handler(
servicer.Upload, servicer.Upload,
request_deserializer=sensor__stream__pb2.SensorFrame.FromString, 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( 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. # This class is part of an EXPERIMENTAL API.
class SensorPush(object): class SensorPush(object):
"""传感器数据推送服务 —— Rust 端作为 gRPC client 推送实时帧 """Missing associated documentation comment in .proto file."""
"""
@staticmethod @staticmethod
def Upload(request_iterator, def Upload(request_iterator,
@@ -83,12 +80,12 @@ class SensorPush(object):
wait_for_ready=None, wait_for_ready=None,
timeout=None, timeout=None,
metadata=None): metadata=None):
return grpc.experimental.stream_unary( return grpc.experimental.stream_stream(
request_iterator, request_iterator,
target, target,
'/sensor_stream.SensorPush/Upload', '/sensor_stream.SensorPush/Upload',
sensor__stream__pb2.SensorFrame.SerializeToString, sensor__stream__pb2.SensorFrame.SerializeToString,
sensor__stream__pb2.UploadResponse.FromString, sensor__stream__pb2.PztAngleResponse.FromString,
options, options,
channel_credentials, channel_credentials,
insecure, insecure,
@@ -101,8 +98,7 @@ class SensorPush(object):
class ExportProcessorStub(object): class ExportProcessorStub(object):
"""导出后处理服务 —— Rust 导出 CSV 后将路径发给 Python 做梯度过滤 """Missing associated documentation comment in .proto file."""
"""
def __init__(self, channel): def __init__(self, channel):
"""Constructor. """Constructor.
@@ -118,8 +114,7 @@ class ExportProcessorStub(object):
class ExportProcessorServicer(object): class ExportProcessorServicer(object):
"""导出后处理服务 —— Rust 导出 CSV 后将路径发给 Python 做梯度过滤 """Missing associated documentation comment in .proto file."""
"""
def ProcessFile(self, request, context): def ProcessFile(self, request, context):
"""Missing associated documentation comment in .proto file.""" """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. # This class is part of an EXPERIMENTAL API.
class ExportProcessor(object): class ExportProcessor(object):
"""导出后处理服务 —— Rust 导出 CSV 后将路径发给 Python 做梯度过滤 """Missing associated documentation comment in .proto file."""
"""
@staticmethod @staticmethod
def ProcessFile(request, def ProcessFile(request,

48
src-tauri/Cargo.lock generated
View File

@@ -18,6 +18,7 @@ dependencies = [
"futures-util", "futures-util",
"humantime", "humantime",
"log", "log",
"ndarray",
"prost", "prost",
"prost-types", "prost-types",
"protoc-bin-vendored", "protoc-bin-vendored",
@@ -2441,6 +2442,16 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" 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]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.0" version = "2.8.0"
@@ -2530,6 +2541,19 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" 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]] [[package]]
name = "ndk" name = "ndk"
version = "0.9.0" version = "0.9.0"
@@ -2595,12 +2619,30 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" 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]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.0" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" 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]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -3605,6 +3647,12 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rawpointer"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"

View File

@@ -17,6 +17,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
[features] [features]
default = [] default = []
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"]
[build-dependencies] [build-dependencies]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
@@ -51,6 +52,7 @@ futures-util = "0.3"
uuid = { version = "1", features = ["v4", "serde"] } uuid = { version = "1", features = ["v4", "serde"] }
rand = "0.8" rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } 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] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-updater = "2" tauri-plugin-updater = "2"

View File

@@ -2,17 +2,14 @@ syntax = "proto3";
package sensor_stream; package sensor_stream;
// 传感器数据推送服务 —— Rust 端作为 gRPC client 推送实时帧
service SensorPush { service SensorPush {
rpc Upload (stream SensorFrame) returns (UploadResponse); rpc Upload(stream SensorFrame) returns (stream PztAngleResponse);
} }
// 导出后处理服务 —— Rust 导出 CSV 后将路径发给 Python 做梯度过滤
service ExportProcessor { service ExportProcessor {
rpc ProcessFile (ProcessRequest) returns (ProcessResponse); rpc ProcessFile(ProcessRequest) returns (ProcessResponse);
} }
// 一帧传感器数据
message SensorFrame { message SensorFrame {
uint64 seq = 1; uint64 seq = 1;
uint64 timestamp_ms = 2; uint64 timestamp_ms = 2;
@@ -23,27 +20,27 @@ message SensorFrame {
uint32 dts_ms = 7; uint32 dts_ms = 7;
} }
// 上传确认响应 message PztAngleResponse {
message UploadResponse { uint64 seq = 1;
bool ok = 1; uint64 timestamp_ms = 2;
uint64 frames_received = 2; float angle = 3;
string message = 3; uint32 dts_ms = 4;
bool ok = 5;
string message = 6;
} }
// 导出处理请求
message ProcessRequest { message ProcessRequest {
string csv_path = 1; // 导出的 CSV 文件路径 string csv_path = 1;
bool save_as_xlsx = 2; // 是否以 xlsx 保存(删除源 CSV bool save_as_xlsx = 2;
} }
// 导出处理响应
message ProcessResponse { message ProcessResponse {
bool ok = 1; bool ok = 1;
string output_path = 2; // 输出文件路径 string output_path = 2;
uint32 groups_used = 3; // 分组数 uint32 groups_used = 3;
double mean_value = 4; // 均值 double mean_value = 4;
double threshold = 5; // 梯度阈值 double threshold = 5;
uint32 rows_total = 6; // 原始行数 uint32 rows_total = 6;
uint32 rows_kept = 7; // 保留行数 uint32 rows_kept = 7;
string message = 8; string message = 8;
} }

View File

@@ -3,6 +3,8 @@
//! 仅在 `devkit` feature 启用时编译。 //! 仅在 `devkit` feature 启用时编译。
use tauri::State; use tauri::State;
#[cfg(feature = "devkit")]
use tauri::AppHandle;
use crate::devkit::{DevKitConfig, DevKitState, DevKitStatusSnapshot, ExportProcessResult}; use crate::devkit::{DevKitConfig, DevKitState, DevKitStatusSnapshot, ExportProcessResult};
@@ -12,9 +14,13 @@ pub fn devkit_status(state: State<'_, DevKitState>) -> DevKitStatusSnapshot {
} }
#[tauri::command] #[tauri::command]
pub async fn devkit_start(state: State<'_, DevKitState>, port: Option<u16>) -> Result<DevKitStatusSnapshot, String> { pub async fn devkit_start(
app: AppHandle,
state: State<'_, DevKitState>,
port: Option<u16>,
) -> Result<DevKitStatusSnapshot, String> {
let target_port = port.unwrap_or(50051); let target_port = port.unwrap_or(50051);
state.start(target_port).await?; state.start(app, target_port).await?;
Ok(state.status()) Ok(state.status())
} }

View File

@@ -8,14 +8,24 @@ use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc; use std::sync::Arc;
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use serde::{Deserialize, Serialize};
use super::proto::sensor_push_client::SensorPushClient; use super::proto::sensor_push_client::SensorPushClient;
use super::proto::export_processor_client::ExportProcessorClient; use super::proto::export_processor_client::ExportProcessorClient;
use super::proto::{ProcessRequest, SensorFrame}; 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 配置 ──────────────────────────────────────────────────── // ── DevKit 配置 ────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -145,7 +155,7 @@ impl DevKitState {
} }
/// 启动 gRPC client连接到 Python server 并开始推送数据 /// 启动 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) { if self.running.load(Ordering::SeqCst) {
return Err("AlreadyRunning".into()); return Err("AlreadyRunning".into());
} }
@@ -161,9 +171,10 @@ impl DevKitState {
let running = Arc::clone(&self.running); let running = Arc::clone(&self.running);
let frame_count = Arc::clone(&self.frame_count); let frame_count = Arc::clone(&self.frame_count);
let app_handle = app.clone();
let handle = tokio::spawn(async move { 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:?}"); ::log::error!("DevKit gRPC upload error: {e:?}");
} }
running.store(false, Ordering::SeqCst); running.store(false, Ordering::SeqCst);
@@ -241,6 +252,7 @@ impl DevKitState {
// ── gRPC Upload Client ───────────────────────────────────────────── // ── gRPC Upload Client ─────────────────────────────────────────────
async fn run_grpc_upload( async fn run_grpc_upload(
app: AppHandle,
addr: String, addr: String,
mut rx: mpsc::Receiver<SensorFrame>, mut rx: mpsc::Receiver<SensorFrame>,
frame_count: Arc<AtomicU32>, frame_count: Arc<AtomicU32>,
@@ -255,14 +267,29 @@ async fn run_grpc_upload(
}; };
let response = client.upload(stream).await?; let response = client.upload(stream).await?;
let resp = response.into_inner(); let mut inbound = response.into_inner();
::log::info!( while let Some(message) = inbound.message().await? {
"DevKit upload complete: ok={}, frames={}, msg={}", if message.ok {
resp.ok, let payload = DevKitPztAngleEvent {
resp.frames_received, seq: message.seq,
resp.message 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(()) Ok(())
} }

View File

@@ -22,11 +22,43 @@ fn start_server_exe(exe_path: &std::path::Path) {
} }
match command.spawn() { 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}"), 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<std::path::PathBuf> {
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)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let builder = tauri::Builder::default() let builder = tauri::Builder::default()
@@ -56,23 +88,22 @@ pub fn run() {
.path() .path()
.resource_dir() .resource_dir()
.unwrap_or_else(|_| std::path::PathBuf::from("./resources")); .unwrap_or_else(|_| std::path::PathBuf::from("./resources"));
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let devkit_port = 50051u16;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
let exe_name = "je-skin-devkit-server.exe"; let exe_name = "je-skin-devkit-server.exe";
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
let exe_name = "je-skin-devkit-server"; let exe_name = "je-skin-devkit-server";
let bundled_exe = resource_dir.join(exe_name); if is_local_port_open(devkit_port) {
let fallback_exe = std::env::current_exe() ::log::info!(
.ok() "DevKit port {} already in use, skipping Python server auto-start",
.and_then(|path| path.parent().map(|parent| parent.join(exe_name))); devkit_port
);
let server_exe = if bundled_exe.exists() {
Some(bundled_exe)
} else { } else {
fallback_exe.filter(|path| path.exists()) let server_exe = find_server_exe(&resource_dir, exe_name);
};
if let Some(exe_path) = server_exe { if let Some(exe_path) = server_exe {
start_server_exe(&exe_path); start_server_exe(&exe_path);
@@ -80,11 +111,12 @@ pub fn run() {
} else { } else {
::log::info!("DevKit Python server not found, skipping auto-start"); ::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}"); ::log::warn!("DevKit auto-start failed: {error}");
} else { } 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}");
} }
}); });

View File

@@ -11,6 +11,8 @@ pub mod model;
pub mod serial; pub mod serial;
pub mod record; pub mod record;
pub mod utils; pub mod utils;
#[cfg(feature = "multi-dim")]
pub mod multi_dim_force;
pub type TestRecording = Recording<TestFrame>; pub type TestRecording = Recording<TestFrame>;
pub type TactileARecording = Recording<TactileAFrame>; pub type TactileARecording = Recording<TactileAFrame>;

View File

@@ -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<Vec<f32>>,
first_contact_cop_x: Option<f32>,
first_contact_cop_y: Option<f32>,
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<f32> {
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<f32, &'static str> {
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();
}
}

View File

@@ -2,12 +2,17 @@ use crate::serial_core::codec::Codec;
use crate::serial_core::codecs::tactile_a::TactileACodec; use crate::serial_core::codecs::tactile_a::TactileACodec;
use crate::serial_core::frame::{FrameHandler, TactileAFrame, TestFrame}; use crate::serial_core::frame::{FrameHandler, TactileAFrame, TestFrame};
use crate::serial_core::model::{HudChartState, HudPacket}; 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::Recording;
use crate::serial_core::record::{FrameTiming, RecordedFrame}; use crate::serial_core::record::{FrameTiming, RecordedFrame};
#[cfg(feature = "devkit")] #[cfg(feature = "devkit")]
use crate::devkit::{proto::SensorFrame, DevKitState}; use crate::devkit::{proto::SensorFrame, DevKitState};
use anyhow::Result; use anyhow::Result;
use log::debug;
use std::future::pending; use std::future::pending;
#[cfg(feature = "devkit")]
use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Instant; use std::time::Instant;
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
@@ -17,14 +22,19 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::time::{self, Duration, MissedTickBehavior}; use tokio::time::{self, Duration, MissedTickBehavior};
use tokio_serial::SerialStream; use tokio_serial::SerialStream;
use tokio_util::sync::CancellationToken; 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<F> { pub enum PollMode<F> {
Disable, Disable,
Enabled(Box<dyn PollRequester<F>>), Enabled(Box<dyn PollRequester<F>>),
} }
struct PendingSubFrame<F> {
frame: F,
values: Vec<i32>,
}
pub trait SerialFrame: Clone + Send + 'static { pub trait SerialFrame: Clone + Send + 'static {
fn dts_ms(&self) -> u64; fn dts_ms(&self) -> u64;
@@ -215,10 +225,15 @@ where
it.set_missed_tick_behavior(MissedTickBehavior::Skip); it.set_missed_tick_behavior(MissedTickBehavior::Skip);
it 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 chart_state = HudChartState::new();
let mut buffer = [0u8; 1024]; let mut buffer = [0u8; 1024];
let mut prune_interval = time::interval(Duration::from_millis(450)); 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<PendingSubFrame<F>> = None;
prune_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); prune_interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
loop { loop {
@@ -246,6 +261,21 @@ where
app.emit("hud_stream", packet)?; 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) => { read_result = port.read(&mut buffer) => {
let n = read_result?; let n = read_result?;
if n == 0 { if n == 0 {
@@ -266,25 +296,38 @@ where
.await? .await?
.map(|vals| vals.into_iter().map(Into::into).collect::<Vec<i32>>()); .map(|vals| vals.into_iter().map(Into::into).collect::<Vec<i32>>());
let mut record = recording.lock().map_err(|_| anyhow::anyhow!("recording state poisoned"))?; let mut record = recording
record.push(RecordedFrame{ .lock()
timing: FrameTiming { pts_ms: None, dts_ms: frame.dts_ms() }, .map_err(|_| anyhow::anyhow!("recording state poisoned"))?;
record.push(RecordedFrame {
timing: FrameTiming {
pts_ms: None,
dts_ms: frame.dts_ms(),
},
frame: frame.clone(), frame: frame.clone(),
}); });
drop(record);
let display_values = if let Some(vals) = decode_res.as_ref() { if let Some(vals) = decode_res {
#[cfg(feature = "multi-dim")]
{
let pzt_values = vals.iter().map(|value| *value as f32).collect::<Vec<f32>>();
if let Ok(angle) = pzt_processor.get_pzt_angle(&pzt_values) {
// debug!("pzt angle: {:.2}", angle);
}
}
#[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); let force = raw_to_g1(summary as u32);
chart_state.record_summary(force as f32);
chart_state.record_pressure_matrix(vals.as_slice());
#[cfg(feature = "devkit")]
push_devkit_frame(&app, vals.as_slice(), frame.dts_ms(), force); push_devkit_frame(&app, vals.as_slice(), frame.dts_ms(), force);
Some(vec![summary]) }
} else {
None
};
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)?; app.emit("hud_stream", packet)?;
} }
} }
@@ -294,6 +337,14 @@ where
Ok(()) Ok(())
} }
fn build_display_values(chart_state: &mut HudChartState, values: &[i32]) -> Option<Vec<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);
Some(vec![summary])
}
#[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>();

View File

@@ -37,7 +37,8 @@
"nsis": { "nsis": {
"installMode": "both", "installMode": "both",
"displayLanguageSelector": false, "displayLanguageSelector": false,
"installerIcon": "icons/icon.ico" "installerIcon": "icons/icon.ico",
"template": "nsis/installer.nsi"
} }
}, },
"resources": [ "resources": [

View File

@@ -33,7 +33,6 @@
export let rangeLabel = ""; export let rangeLabel = "";
export let rangeMinLabel = ""; export let rangeMinLabel = "";
export let rangeMaxLabel = ""; export let rangeMaxLabel = "";
export let colorMapLabel = "";
export let resetConfigLabel = ""; export let resetConfigLabel = "";
export let applyLiveHint = ""; export let applyLiveHint = "";
export let matrixRows = 12; export let matrixRows = 12;
@@ -42,7 +41,6 @@
export let rangeMax = DEFAULT_PRESSURE_RANGE_MAX; export let rangeMax = DEFAULT_PRESSURE_RANGE_MAX;
export let colorMapPreset: PressureColorMapPreset = "emerald"; export let colorMapPreset: PressureColorMapPreset = "emerald";
export let matrixDisplayMode: MatrixDisplayMode = "dots"; export let matrixDisplayMode: MatrixDisplayMode = "dots";
export let colorMapOptions: HudColorMapOption[] = [];
export let replaySectionLabel = ""; export let replaySectionLabel = "";
export let replayPlayLabel = ""; export let replayPlayLabel = "";
export let replayPauseLabel = ""; export let replayPauseLabel = "";
@@ -56,6 +54,7 @@
export let replayFileName = ""; export let replayFileName = "";
export let replayFrameInfo = ""; export let replayFrameInfo = "";
export let showPrecisionTestPanel = false; export let showPrecisionTestPanel = false;
export let sessionStartedAt: number = Date.now();
let stagePlaneEl: HTMLDivElement | undefined; let stagePlaneEl: HTMLDivElement | undefined;
let panelZoneEl: HTMLDivElement | undefined; let panelZoneEl: HTMLDivElement | undefined;
@@ -195,6 +194,7 @@
{rangeMax} {rangeMax}
{colorMapPreset} {colorMapPreset}
{matrixDisplayMode} {matrixDisplayMode}
{locale}
showStatsPanel={true} showStatsPanel={true}
/> />
{/key} {/key}
@@ -225,6 +225,7 @@
{rangeMax} {rangeMax}
{colorMapPreset} {colorMapPreset}
{matrixDisplayMode} {matrixDisplayMode}
{locale}
showStatsPanel={true} showStatsPanel={true}
/> />
{/key} {/key}
@@ -246,9 +247,6 @@
{rangeLabel} {rangeLabel}
{rangeMinLabel} {rangeMinLabel}
{rangeMaxLabel} {rangeMaxLabel}
{colorMapLabel}
bind:colorMapPreset
{colorMapOptions}
resetLabel={resetConfigLabel} resetLabel={resetConfigLabel}
{applyLiveHint} {applyLiveHint}
on:close={() => dispatch("configclose")} on:close={() => dispatch("configclose")}
@@ -267,7 +265,7 @@
in:fly={{ x: -180, duration: 340, opacity: 0.08, easing: cubicOut }} in:fly={{ x: -180, duration: 340, opacity: 0.08, easing: cubicOut }}
out:fly={{ x: -180, duration: 280, opacity: 0.06, easing: cubicIn }} out:fly={{ x: -180, duration: 280, opacity: 0.06, easing: cubicIn }}
> >
<SignalChart {panel} panelIndex={index} /> <SignalChart {panel} panelIndex={index} {locale} />
</div> </div>
{/each} {/each}
@@ -281,6 +279,9 @@
{summary} {summary}
xValues={summary.xValues ?? null} xValues={summary.xValues ?? null}
yValues={summary.points} yValues={summary.points}
{locale}
{sessionStartedAt}
isRealtime={!replayHasData}
side="left" side="left"
panelIndex={leftPanels.length} panelIndex={leftPanels.length}
/> />
@@ -298,7 +299,7 @@
in:fly={{ x: 180, duration: 340, opacity: 0.08, easing: cubicOut }} in:fly={{ x: 180, duration: 340, opacity: 0.08, easing: cubicOut }}
out:fly={{ x: 180, duration: 280, opacity: 0.06, easing: cubicIn }} out:fly={{ x: 180, duration: 280, opacity: 0.06, easing: cubicIn }}
> >
<SignalChart {panel} panelIndex={index} /> <SignalChart {panel} panelIndex={index} {locale} />
</div> </div>
{/each} {/each}
@@ -312,6 +313,9 @@
{summary} {summary}
xValues={summary.xValues ?? null} xValues={summary.xValues ?? null}
yValues={summary.points} yValues={summary.points}
{locale}
{sessionStartedAt}
isRealtime={!replayHasData}
side="right" side="right"
panelIndex={rightPanels.length} panelIndex={rightPanels.length}
/> />
@@ -396,7 +400,7 @@
} }
.stage-canvas-plane { .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); --rail-edge-inset: clamp(0.35rem, 1vw, 0.9rem);
--safe-gap: clamp(0.35rem, 0.9vw, 0.85rem); --safe-gap: clamp(0.35rem, 0.9vw, 0.85rem);
--panel-zone-top: clamp(6.4rem, 11.8vh, 8rem); --panel-zone-top: clamp(6.4rem, 11.8vh, 8rem);
@@ -754,7 +758,7 @@
@media (max-width: 1180px) { @media (max-width: 1180px) {
.stage-canvas-plane { .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); --rail-edge-inset: clamp(0.25rem, 0.7vw, 0.55rem);
--safe-gap: clamp(0.2rem, 0.75vw, 0.45rem); --safe-gap: clamp(0.2rem, 0.75vw, 0.45rem);
--panel-zone-top: clamp(6rem, 11.2vh, 7.2rem); --panel-zone-top: clamp(6rem, 11.2vh, 7.2rem);

View File

@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from "svelte"; import { createEventDispatcher } from "svelte";
import { DEFAULT_PRESSURE_RANGE_MAX, DEFAULT_PRESSURE_RANGE_MIN } from "$lib/config/pressure-range"; import { DEFAULT_PRESSURE_RANGE_MAX, DEFAULT_PRESSURE_RANGE_MIN } from "$lib/config/pressure-range";
import type { HudColorMapOption, PressureColorMapPreset } from "$lib/types/hud";
export let title = ""; export let title = "";
export let hint = ""; export let hint = "";
@@ -11,15 +10,12 @@
export let rangeLabel = ""; export let rangeLabel = "";
export let rangeMinLabel = ""; export let rangeMinLabel = "";
export let rangeMaxLabel = ""; export let rangeMaxLabel = "";
export let colorMapLabel = "";
export let resetLabel = ""; export let resetLabel = "";
export let applyLiveHint = ""; export let applyLiveHint = "";
export let matrixRows = 12; export let matrixRows = 12;
export let matrixCols = 7; export let matrixCols = 7;
export let rangeMin = DEFAULT_PRESSURE_RANGE_MIN; export let rangeMin = DEFAULT_PRESSURE_RANGE_MIN;
export let rangeMax = DEFAULT_PRESSURE_RANGE_MAX; export let rangeMax = DEFAULT_PRESSURE_RANGE_MAX;
export let colorMapPreset: PressureColorMapPreset = "emerald";
export let colorMapOptions: HudColorMapOption[] = [];
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
close: void; close: void;
@@ -78,24 +74,17 @@
matrixCols = size; matrixCols = size;
} }
function applyColorMapPreset(id: PressureColorMapPreset): void {
colorMapPreset = id;
}
function resetDefaults(): void { function resetDefaults(): void {
matrixRows = 12; matrixRows = 12;
matrixCols = 7; matrixCols = 7;
rangeMin = DEFAULT_PRESSURE_RANGE_MIN; rangeMin = DEFAULT_PRESSURE_RANGE_MIN;
rangeMax = DEFAULT_PRESSURE_RANGE_MAX; rangeMax = DEFAULT_PRESSURE_RANGE_MAX;
colorMapPreset = "emerald";
} }
function handleSubmit(): void { function handleSubmit(): void {
dispatch("close"); dispatch("close");
} }
$: selectedColorMap = colorMapOptions.find((option) => option.id === colorMapPreset) ?? colorMapOptions[0];
$: { $: {
const nextRows = normalizeGridValue(matrixRows); const nextRows = normalizeGridValue(matrixRows);
if (nextRows !== matrixRows) { if (nextRows !== matrixRows) {
@@ -190,31 +179,6 @@
</div> </div>
</div> </div>
<div class="config-section">
<div class="section-head">
<p class="section-title">{colorMapLabel}</p>
<p class="section-note">{selectedColorMap?.label ?? colorMapPreset}</p>
</div>
<div class="palette-row" role="group" aria-label={colorMapLabel}>
{#each colorMapOptions as option}
<button
type="button"
class="palette-btn"
class:is-active={colorMapPreset === option.id}
on:click={() => applyColorMapPreset(option.id)}
>
<span
class="palette-preview"
style={`--palette-stop-0: ${option.previewStops[0]}; --palette-stop-1: ${option.previewStops[1]}; --palette-stop-2: ${option.previewStops[2]};`}
aria-hidden="true"
></span>
<span class="palette-name">{option.label}</span>
</button>
{/each}
</div>
</div>
<footer class="config-foot"> <footer class="config-foot">
<p class="live-note">{applyLiveHint}</p> <p class="live-note">{applyLiveHint}</p>
<button type="button" class="reset-btn" on:click={resetDefaults}>{resetLabel}</button> <button type="button" class="reset-btn" on:click={resetDefaults}>{resetLabel}</button>
@@ -327,15 +291,8 @@
gap: 0.42rem; gap: 0.42rem;
} }
.palette-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.48rem;
}
.preset-btn, .preset-btn,
.reset-btn, .reset-btn {
.palette-btn {
border: 1px solid rgb(var(--hud-border-rgb) / 0.28); border: 1px solid rgb(var(--hud-border-rgb) / 0.28);
border-radius: 999px; border-radius: 999px;
padding: 0.38rem 0.72rem; padding: 0.38rem 0.72rem;
@@ -358,48 +315,6 @@
box-shadow: inset 0 1px 0 rgb(var(--hud-border-strong-rgb) / 0.14), 0 0 16px rgb(var(--hud-glow-alt-rgb) / 0.14); box-shadow: inset 0 1px 0 rgb(var(--hud-border-strong-rgb) / 0.14), 0 0 16px rgb(var(--hud-glow-alt-rgb) / 0.14);
} }
.palette-btn {
display: grid;
gap: 0.34rem;
min-height: 4rem;
padding: 0.52rem 0.56rem 0.58rem;
border-radius: 0.74rem;
text-align: left;
}
.palette-btn.is-active {
border-color: rgb(var(--hud-lime-rgb) / 0.48);
background: linear-gradient(180deg, rgb(var(--hud-surface-alt-rgb) / 0.96), rgb(var(--hud-surface-rgb) / 0.92));
color: rgb(var(--hud-text-main-rgb) / 0.98);
box-shadow: inset 0 1px 0 rgb(var(--hud-border-strong-rgb) / 0.14), 0 0 16px rgb(var(--hud-glow-alt-rgb) / 0.14);
}
.palette-preview {
display: block;
inline-size: 100%;
block-size: 0.74rem;
border-radius: 999px;
border: 1px solid rgb(255 255 255 / 0.08);
background:
linear-gradient(
90deg,
var(--palette-stop-0) 0%,
var(--palette-stop-1) 52%,
var(--palette-stop-2) 100%
),
linear-gradient(180deg, rgb(255 255 255 / 0.08), transparent 55%);
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.1),
0 0 12px rgb(0 0 0 / 0.14);
}
.palette-name {
color: inherit;
font-size: 0.74rem;
line-height: 1.1;
letter-spacing: 0.04em;
}
.field-grid { .field-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -457,9 +372,5 @@
.field-grid { .field-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.palette-row {
grid-template-columns: 1fr;
}
} }
</style> </style>

View File

@@ -2,8 +2,6 @@
import { createEventDispatcher } from "svelte"; import { createEventDispatcher } from "svelte";
export let running = false; export let running = false;
export let port = 50051;
export let framesSent = 0;
export let filterLiftEnabled = true; export let filterLiftEnabled = true;
export let saveAsXlsx = false; export let saveAsXlsx = false;
export let locale: "zh-CN" | "en-US" = "zh-CN"; export let locale: "zh-CN" | "en-US" = "zh-CN";
@@ -22,37 +20,37 @@
togglexlsx: void; togglexlsx: void;
}>(); }>();
$: labels = locale === "zh-CN" $: labels =
locale === "zh-CN"
? { ? {
title: "DevKit 配置", title: "开发工具配置",
close: "关闭",
status: "状态", status: "状态",
connected: "已连接", connected: "已连接",
disconnected: "未连接", disconnected: "未连接",
port: "端口", filterLift: "导出后过滤抬起",
framesSent: "已发送帧", filterLiftHint: "导出 CSV 时自动过滤掉抬起阶段的小值数据。",
filterLift: "导出过滤抬起", saveXlsx: "保存为 xlsx",
filterLiftHint: "导出 CSV 后自动调用 Python 做梯度过滤,过滤掉抬起的小值数据", saveXlsxHint: "导出文件转换为 xlsx 格式。",
saveXlsx: "以 xlsx 保存",
saveXlsxHint: "Python 处理后输出 xlsx 格式并删除源 CSV 文件",
lastResult: "最近一次处理", lastResult: "最近一次处理",
output: "输出文件", output: "输出文件",
groups: "分组数", groups: "分组数",
mean: "均值", mean: "均值",
threshold: "阈值", threshold: "阈值",
rows: "行数", rows: "行数",
kept: "保留行数", kept: "保留行数",
rowsFlow: "行数变化"
} }
: { : {
title: "DevKit Config", title: "DevKit Config",
close: "Close",
status: "Status", status: "Status",
connected: "Connected", connected: "Connected",
disconnected: "Disconnected", disconnected: "Disconnected",
port: "Port",
framesSent: "Frames sent",
filterLift: "Filter lift on export", filterLift: "Filter lift on export",
filterLiftHint: "After CSV export, automatically call Python to filter out small values", filterLiftHint: "Automatically filter out small values from lift-off phases during CSV export.",
saveXlsx: "Save as xlsx", saveXlsx: "Save as xlsx",
saveXlsxHint: "Python outputs xlsx format and deletes the source CSV file", saveXlsxHint: "Convert exported file to xlsx format.",
lastResult: "Last process", lastResult: "Last process",
output: "Output", output: "Output",
groups: "Groups", groups: "Groups",
@@ -60,13 +58,14 @@
threshold: "Threshold", threshold: "Threshold",
rows: "Rows", rows: "Rows",
kept: "Kept rows", kept: "Kept rows",
rowsFlow: "Rows flow"
}; };
</script> </script>
<div class="dk-panel"> <div class="dk-panel">
<header class="dk-head"> <header class="dk-head">
<h3 class="dk-title">{labels.title}</h3> <h3 class="dk-title">{labels.title}</h3>
<button type="button" class="dk-close" on:click={() => dispatch("close")} aria-label="Close"> <button type="button" class="dk-close" on:click={() => dispatch("close")} aria-label={labels.close}>
<span></span><span></span> <span></span><span></span>
</button> </button>
</header> </header>
@@ -77,18 +76,6 @@
<span class="dk-label">{labels.status}</span> <span class="dk-label">{labels.status}</span>
<span class="dk-value">{running ? labels.connected : labels.disconnected}</span> <span class="dk-value">{running ? labels.connected : labels.disconnected}</span>
</div> </div>
{#if running}
<div class="dk-info-grid">
<div class="dk-info">
<span class="dk-info-label">{labels.port}</span>
<span class="dk-info-value">:{port}</span>
</div>
<div class="dk-info">
<span class="dk-info-label">{labels.framesSent}</span>
<span class="dk-info-value">{framesSent}</span>
</div>
</div>
{/if}
</section> </section>
<section class="dk-section"> <section class="dk-section">
@@ -132,8 +119,8 @@
<span class="dk-result-value">{lastProcessResult.threshold.toFixed(3)}</span> <span class="dk-result-value">{lastProcessResult.threshold.toFixed(3)}</span>
</div> </div>
<div class="dk-result-item"> <div class="dk-result-item">
<span class="dk-result-label">{labels.rows}</span> <span class="dk-result-label">{labels.rowsFlow}</span>
<span class="dk-result-value">{lastProcessResult.rowsTotal} {lastProcessResult.rowsKept}</span> <span class="dk-result-value">{lastProcessResult.rowsTotal} -> {lastProcessResult.rowsKept}</span>
</div> </div>
</div> </div>
</section> </section>
@@ -236,30 +223,6 @@
margin-inline-start: auto; 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 { .dk-toggle {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;

View File

@@ -32,6 +32,7 @@
export let matrixDisplayMode: MatrixDisplayMode = "dots"; export let matrixDisplayMode: MatrixDisplayMode = "dots";
export let summary: HudSummary | null = null; export let summary: HudSummary | null = null;
export let showStatsPanel = true; export let showStatsPanel = true;
export let locale: "zh-CN" | "en-US" = "zh-CN";
let viewerEl: HTMLDivElement | undefined; let viewerEl: HTMLDivElement | undefined;
let canvasEl: HTMLCanvasElement | undefined; let canvasEl: HTMLCanvasElement | undefined;
@@ -131,8 +132,13 @@
$: resolvedRangeMin = resolvedRange.min; $: resolvedRangeMin = resolvedRange.min;
$: resolvedRangeMax = resolvedRange.max; $: resolvedRangeMax = resolvedRange.max;
$: matrixLayout = buildMatrixLayout(resolvedMatrixRows, resolvedMatrixCols); $: matrixLayout = buildMatrixLayout(resolvedMatrixRows, resolvedMatrixCols);
$: statsModeLabel = matrixDisplayMode === "dots" ? "dot pulse" : "numeric pulse"; $: statsModeLabel = matrixDisplayMode === "dots"
$: statsNote = `${resolvedMatrixRows}x${resolvedMatrixCols} / force range ${resolvedRangeMin}-${resolvedRangeMax} / ${statsModeLabel}`; ? (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 { function formatForceStat(value: number | null): string {
if (value == null || !Number.isFinite(value)) { if (value == null || !Number.isFinite(value)) {
@@ -660,18 +666,18 @@
{#if showStatsPanel} {#if showStatsPanel}
<div class="viewer-controls"> <div class="viewer-controls">
<section class="stats-panel" aria-label="Pressure Summary"> <section class="stats-panel" aria-label="Pressure Summary">
<p class="stats-label">Resultant Force</p> <p class="stats-label">{viewerI18n.title}</p>
<div class="stats-grid"> <div class="stats-grid">
<article class="stats-card stats-card-wide"> <article class="stats-card stats-card-wide">
<span class="stats-key">Current RF</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"> <article class="stats-card">
<span class="stats-key">Max RF</span> <span class="stats-key">{viewerI18n.max}</span>
<strong class="stats-value">{formatForceStat(stats.max)}</strong> <strong class="stats-value">{formatForceStat(stats.max)}</strong>
</article> </article>
<article class="stats-card"> <article class="stats-card">
<span class="stats-key">Min RF</span> <span class="stats-key">{viewerI18n.min}</span>
<strong class="stats-value">{formatForceStat(stats.min)}</strong> <strong class="stats-value">{formatForceStat(stats.min)}</strong>
</article> </article>
</div> </div>

View File

@@ -3,6 +3,11 @@
export let panel: HudSignalPanel; export let panel: HudSignalPanel;
export let panelIndex = 0; 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 viewportWidth = 100;
const viewportHeight = 36; const viewportHeight = 36;
@@ -110,7 +115,7 @@
<div class="icon-layer" aria-hidden="true"> <div class="icon-layer" aria-hidden="true">
{#each panel.icons as icon (icon.id)} {#each panel.icons as icon (icon.id)}
<span class="icon-chip tone-{icon.tone}">{icon.label}</span> <span class="icon-chip tone-{icon.tone}">{icon.label === "TOTAL" ? signalI18n.total : icon.label}</span>
{/each} {/each}
</div> </div>
</header> </header>
@@ -136,17 +141,17 @@
<footer class="panel-foot"> <footer class="panel-foot">
<p class="foot-item"> <p class="foot-item">
<span class="dot tone-cyan"></span> <span class="dot tone-cyan"></span>
<span class="metric-label">Now</span> <span class="metric-label">{signalI18n.now}</span>
<span class="value">{latestValue}</span> <span class="value">{latestValue}</span>
</p> </p>
<p class="foot-item"> <p class="foot-item">
<span class="dot tone-lime"></span> <span class="dot tone-lime"></span>
<span class="metric-label">Max</span> <span class="metric-label">{signalI18n.max}</span>
<span class="value">{maxValue}</span> <span class="value">{maxValue}</span>
</p> </p>
<p class="foot-item"> <p class="foot-item">
<span class="dot tone-orange"></span> <span class="dot tone-orange"></span>
<span class="metric-label">Min</span> <span class="metric-label">{signalI18n.min}</span>
<span class="value">{minValue}</span> <span class="value">{minValue}</span>
</p> </p>
</footer> </footer>
@@ -158,7 +163,7 @@
--enter-ms: 1800ms; --enter-ms: 1800ms;
--fade-ms: 1000ms; --fade-ms: 1000ms;
overflow: hidden; overflow: hidden;
inline-size: min(100%, clamp(16.8rem, 23vw, 22rem)); inline-size: min(100%, clamp(19rem, 27vw, 26rem));
aspect-ratio: 1.44 / 1; aspect-ratio: 1.44 / 1;
min-block-size: 11.8rem; min-block-size: 11.8rem;
justify-self: start; justify-self: start;
@@ -388,7 +393,7 @@
@media (max-width: 1180px) { @media (max-width: 1180px) {
.signal-panel { .signal-panel {
inline-size: min(100%, clamp(14rem, 30vw, 17rem)); inline-size: min(100%, clamp(16rem, 32vw, 21rem));
aspect-ratio: 1.5 / 1; aspect-ratio: 1.5 / 1;
min-block-size: 10.1rem; min-block-size: 10.1rem;
} }

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte";
import type { HudSummary } from "$lib/types/hud"; import type { HudSummary } from "$lib/types/hud";
export let summary: HudSummary; export let summary: HudSummary;
@@ -6,6 +7,25 @@
export let panelIndex = 0; export let panelIndex = 0;
export let xValues: number[] | null = null; export let xValues: number[] | null = null;
export let yValues: number[] | null = null; export let yValues: number[] | null = null;
export let locale: "zh-CN" | "en-US" = "zh-CN";
export let sessionStartedAt: number = Date.now();
export let isRealtime = false;
let currentTimeSeconds = 0;
let timerId: ReturnType<typeof setInterval> | null = null;
onMount(() => {
timerId = setInterval(() => {
currentTimeSeconds = Math.round((Date.now() - sessionStartedAt) / 100) / 10;
}, 200);
return () => {
if (timerId != null) clearInterval(timerId);
};
});
$: i18n = locale === "zh-CN"
? { now: "当前", min: "最小", max: "最大", waiting: "等待数据" }
: { now: "Now", min: "Min", max: "Max", waiting: "Waiting" };
const viewportWidth = 120; const viewportWidth = 120;
const viewportHeight = 48; const viewportHeight = 48;
@@ -50,7 +70,12 @@
} }
if (axis === "x") { if (axis === "x") {
return String(Math.round(value)); if (value < 60) {
return `${value.toFixed(1)}s`;
}
const mins = Math.floor(value / 60);
const secs = value - mins * 60;
return `${mins}:${secs.toFixed(0).padStart(2, "0")}`;
} }
return `${Math.round(value)} N`; return `${Math.round(value)} N`;
@@ -104,14 +129,51 @@
return []; return [];
} }
let previousX = 0;
return rawYValues.map((rawY, index) => { return rawYValues.map((rawY, index) => {
const x = rawXValues[index]; const x = rawXValues[index];
const y = Number.isFinite(rawY) ? Number(rawY) : 0; const y = Number.isFinite(rawY) ? Number(rawY) : 0;
const resolvedX = Number.isFinite(x) ? Number(x) : index + 1; const fallbackX = index === 0 ? 0 : previousX + 1;
return { x: resolvedX, y }; const resolvedX = Number.isFinite(x) ? Number(x) : fallbackX;
const normalizedX = index === 0 ? resolvedX : Math.max(resolvedX, previousX);
previousX = normalizedX;
return { x: normalizedX, y };
}); });
} }
function resolveXScaleBounds(
samples: CurveSample[],
currentSeconds: number,
realtime: boolean
): { min: number; max: number } {
if (samples.length === 0) {
return { min: 0, max: 1 };
}
const values = samples.map((sample) => sample.x);
const dataBounds = resolveBounds(values);
if (!realtime) {
return dataBounds;
}
const firstX = samples[0].x;
const lastX = samples[samples.length - 1].x;
const axisMax = Math.max(lastX, currentSeconds);
const positiveDiffs = samples
.slice(1)
.map((sample, index) => sample.x - samples[index].x)
.filter((diff) => diff > 0);
const averageSpacing =
positiveDiffs.length > 0 ? positiveDiffs.reduce((sum, diff) => sum + diff, 0) / positiveDiffs.length : 1;
const dataSpan = Math.max(lastX - firstX, 0);
const windowSpan = Math.max(dataSpan, averageSpacing * Math.max(samples.length - 1, 1), 1);
const axisMin = Math.max(0, axisMax - windowSpan);
return resolveBounds([axisMin, axisMax]);
}
function convertPoints( function convertPoints(
samples: CurveSample[], samples: CurveSample[],
xBounds: { min: number; max: number }, xBounds: { min: number; max: number },
@@ -146,14 +208,14 @@
})); }));
} }
function buildXAxisTicks(samples: CurveSample[], xScaleBounds: { min: number; max: number }): AxisTick[] { function buildXAxisTicks(xScaleBounds: { min: number; max: number }): AxisTick[] {
if (!samples.length) { if (!Number.isFinite(xScaleBounds.min) || !Number.isFinite(xScaleBounds.max)) {
return []; return [];
} }
const first = samples[0].x; const first = xScaleBounds.min;
const middle = samples[Math.floor((samples.length - 1) / 2)].x; const middle = xScaleBounds.min + (xScaleBounds.max - xScaleBounds.min) / 2;
const last = samples[samples.length - 1].x; const last = xScaleBounds.max;
const tickValues = [first, middle, last]; const tickValues = [first, middle, last];
return tickValues.map((value) => ({ return tickValues.map((value) => ({
value, value,
@@ -185,9 +247,16 @@
$: sourceYValues = yValues && yValues.length ? yValues : summary.points; $: sourceYValues = yValues && yValues.length ? yValues : summary.points;
$: sourceXValues = xValues && xValues.length ? xValues : summary.xValues ?? []; $: sourceXValues = xValues && xValues.length ? xValues : summary.xValues ?? [];
$: samples = buildSamples(sourceYValues, sourceXValues); $: samples = (() => {
const base = buildSamples(sourceYValues, sourceXValues);
if (isRealtime && base.length > 0 && currentTimeSeconds > 0) {
const lastSample = base[base.length - 1];
base[base.length - 1] = { ...lastSample, x: Math.max(lastSample.x, currentTimeSeconds) };
}
return base;
})();
$: sampleCount = samples.length; $: sampleCount = samples.length;
$: xScaleBounds = resolveBounds(samples.map((sample) => sample.x)); $: xScaleBounds = resolveXScaleBounds(samples, currentTimeSeconds, isRealtime);
$: yScaleBounds = fixedYBounds; $: yScaleBounds = fixedYBounds;
$: 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));
@@ -196,7 +265,7 @@
$: areaPath = createAreaPath(plotPoints); $: areaPath = createAreaPath(plotPoints);
$: lastPoint = plotPoints.length > 0 ? plotPoints[plotPoints.length - 1] : null; $: 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(samples, xScaleBounds) : []; $: xAxisTicks = sampleCount > 0 ? buildXAxisTicks(xScaleBounds) : [];
$: latestValue = formatValue(summary.latest); $: latestValue = formatValue(summary.latest);
$: minValue = formatValue(summary.min); $: minValue = formatValue(summary.min);
$: maxValue = formatValue(summary.max); $: maxValue = formatValue(summary.max);
@@ -270,7 +339,7 @@
{#if sampleCount === 0} {#if sampleCount === 0}
<div class="empty-state"> <div class="empty-state">
<span>Waiting</span> <span>{i18n.waiting}</span>
</div> </div>
{/if} {/if}
</div> </div>
@@ -278,17 +347,17 @@
<footer class="panel-foot"> <footer class="panel-foot">
<p class="foot-item"> <p class="foot-item">
<span class="dot tone-cyan"></span> <span class="dot tone-cyan"></span>
<span class="metric-text">Now</span> <span class="metric-text">{i18n.now}</span>
<span class="value">{latestValue}</span> <span class="value">{latestValue}</span>
</p> </p>
<p class="foot-item"> <p class="foot-item">
<span class="dot tone-lime"></span> <span class="dot tone-lime"></span>
<span class="metric-text">Min</span> <span class="metric-text">{i18n.min}</span>
<span class="value">{minValue}</span> <span class="value">{minValue}</span>
</p> </p>
<p class="foot-item"> <p class="foot-item">
<span class="dot tone-orange"></span> <span class="dot tone-orange"></span>
<span class="metric-text">Max</span> <span class="metric-text">{i18n.max}</span>
<span class="value">{maxValue}</span> <span class="value">{maxValue}</span>
</p> </p>
</footer> </footer>
@@ -300,12 +369,10 @@
--enter-ms: 1800ms; --enter-ms: 1800ms;
--fade-ms: 1000ms; --fade-ms: 1000ms;
overflow: hidden; overflow: hidden;
inline-size: min(100%, clamp(29rem, 38vw, 37rem)); inline-size: min(100%, clamp(34rem, 44vw, 44rem));
aspect-ratio: 1.42 / 1;
min-block-size: 20.5rem;
justify-self: start; justify-self: start;
display: grid; display: grid;
grid-template-rows: auto auto auto; grid-template-rows: auto 1fr auto;
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);
@@ -404,6 +471,7 @@
.chart-stage { .chart-stage {
position: relative; position: relative;
block-size: clamp(12rem, 15.5vw, 15rem); block-size: clamp(12rem, 15.5vw, 15rem);
min-block-size: 5rem;
overflow: hidden; overflow: hidden;
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;
@@ -474,25 +542,29 @@
.panel-foot { .panel-foot {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: nowrap;
gap: 0.8rem; gap: 0.8rem;
margin: 0; margin: 0;
padding: 0; padding: 0;
flex-shrink: 0;
} }
.foot-item { .foot-item {
margin: 0; margin: 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.28rem; gap: 0.22rem;
color: rgb(var(--hud-text-main-rgb) / 0.9); color: rgb(var(--hud-text-main-rgb) / 0.9);
font-size: 0.76rem; font-size: 0.68rem;
letter-spacing: 0.04em; letter-spacing: 0.03em;
white-space: nowrap;
flex-shrink: 0;
} }
.metric-text { .metric-text {
color: rgb(var(--hud-text-dim-rgb) / 0.82); color: rgb(var(--hud-text-dim-rgb) / 0.82);
text-transform: uppercase; text-transform: uppercase;
flex-shrink: 0;
} }
.dot { .dot {
@@ -519,16 +591,18 @@
@media (max-width: 1180px) { @media (max-width: 1180px) {
.signal-panel { .signal-panel {
inline-size: min(100%, clamp(24rem, 36vw, 31rem)); inline-size: min(100%, clamp(28rem, 40vw, 38rem));
aspect-ratio: 1.48 / 1; }
min-block-size: 17rem;
.chart-stage {
block-size: clamp(10rem, 13vw, 12rem);
} }
} }
@media (max-height: 900px) { @media (max-height: 900px) {
.signal-panel { .signal-panel {
inline-size: min(100%, clamp(24rem, 33vw, 30rem)); inline-size: min(100%, clamp(28rem, 38vw, 36rem));
min-block-size: 16.8rem; padding: 0.7rem 0.76rem 0.8rem;
} }
.chart-stage { .chart-stage {
@@ -538,46 +612,31 @@
@media (max-height: 760px) { @media (max-height: 760px) {
.signal-panel { .signal-panel {
inline-size: min(100%, clamp(21rem, 29vw, 26rem)); inline-size: min(100%, clamp(24rem, 34vw, 30rem));
min-block-size: 14.4rem; padding: 0.62rem 0.68rem 0.72rem;
padding: 0.7rem 0.76rem 0.8rem; gap: 0.48rem;
}
.panel-foot {
margin-block-start: 0.28rem;
} }
.chart-stage { .chart-stage {
block-size: clamp(8.3rem, 9.6vw, 9.8rem); block-size: clamp(8rem, 9.5vw, 9.8rem);
} }
} }
@media (max-height: 680px) { @media (max-height: 680px) {
.signal-panel { .signal-panel {
inline-size: min(100%, clamp(18.5rem, 24vw, 22.5rem)); inline-size: min(100%, clamp(20rem, 28vw, 26rem));
min-block-size: 12.4rem; padding: 0.52rem 0.58rem 0.6rem;
padding: 0.62rem 0.66rem 0.68rem; gap: 0.36rem;
}
.panel-head {
margin-block-end: 0.26rem;
}
.panel-foot {
margin-block-start: 0.18rem;
gap: 0.56rem;
} }
.chart-stage { .chart-stage {
block-size: clamp(7rem, 7.8vw, 8rem); block-size: clamp(6.5rem, 8vw, 7.5rem);
} }
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.signal-panel { .signal-panel {
inline-size: 100%; inline-size: 100%;
aspect-ratio: 1.7 / 1;
min-block-size: 0;
} }
} }
</style> </style>

View File

@@ -260,9 +260,17 @@
rowsKept: number; rowsKept: number;
} | null = null; } | null = null;
let devkitStatusTimer: number | null = null; let devkitStatusTimer: number | null = null;
let sessionStartedAt: number = Date.now();
$: uiCopy = copyByLocale[locale]; $: uiCopy = copyByLocale[locale];
$: configLinks = buildConfigLinks(locale, activeConfigLinkId, isConfigPanelOpen, isPrecisionTestOpen); $: configLinks = buildConfigLinks(
locale,
activeConfigLinkId,
isConfigPanelOpen,
isPrecisionTestOpen,
devkitEnabled,
isDevKitConfigOpen
);
$: leftSignalPanels = signalPanels.filter((panel) => panel.side === "left"); $: leftSignalPanels = signalPanels.filter((panel) => panel.side === "left");
$: rightSignalPanels = signalPanels.filter((panel) => panel.side === "right"); $: rightSignalPanels = signalPanels.filter((panel) => panel.side === "right");
$: rangeTicks = buildRangeTicks(rangeMin, rangeMax); $: rangeTicks = buildRangeTicks(rangeMin, rangeMax);
@@ -718,12 +726,12 @@
const safeIndex = clamp(index, 0, replayFrames.length - 1); const safeIndex = clamp(index, 0, replayFrames.length - 1);
const startIndex = Math.max(0, safeIndex - summaryPointsPerSeries + 1); const startIndex = Math.max(0, safeIndex - summaryPointsPerSeries + 1);
const points: number[] = []; const points: number[] = [];
const frameIds: number[] = []; const xSeconds: number[] = [];
for (let cursor = startIndex; cursor <= safeIndex; cursor += 1) { for (let cursor = startIndex; cursor <= safeIndex; cursor += 1) {
points.push(replayFrameTotal(replayFrames[cursor])); points.push(replayFrameTotal(replayFrames[cursor]));
frameIds.push(cursor + 1); xSeconds.push(replayFrames[cursor].dtsMs / 1000);
} }
return buildSummary(points, frameIds); return buildSummary(points, xSeconds);
} }
function applyReplayFrame(index: number): void { function applyReplayFrame(index: number): void {
@@ -952,10 +960,11 @@
? summaryValue.points[summaryValue.points.length - 1] ? summaryValue.points[summaryValue.points.length - 1]
: randomBetween(280, 1600); : randomBetween(280, 1600);
const next = Math.round(clamp(previous + randomBetween(-160, 160), 120, 2400) * 10) / 10; const next = Math.round(clamp(previous + randomBetween(-160, 160), 120, 2400) * 10) / 10;
const nowSeconds = Math.round((Date.now() - sessionStartedAt) / 100) / 10;
const previousXValues = const previousXValues =
summaryValue.xValues && summaryValue.xValues.length === summaryValue.points.length summaryValue.xValues && summaryValue.xValues.length === summaryValue.points.length
? summaryValue.xValues ? summaryValue.xValues
: summaryValue.points.map((_, index) => index + 1); : summaryValue.points.map((_, index) => nowSeconds);
const points = const points =
summaryValue.points.length >= summaryPointsPerSeries summaryValue.points.length >= summaryPointsPerSeries
? summaryValue.points.slice(1) ? summaryValue.points.slice(1)
@@ -964,7 +973,7 @@
previousXValues.length >= summaryPointsPerSeries ? previousXValues.slice(1) : previousXValues.slice(); previousXValues.length >= summaryPointsPerSeries ? previousXValues.slice(1) : previousXValues.slice();
points.push(next); points.push(next);
xValues.push((xValues[xValues.length - 1] ?? 0) + 1); xValues.push(nowSeconds);
return buildSummary(points, xValues); return buildSummary(points, xValues);
} }
@@ -977,7 +986,17 @@
return; return;
} }
signalPanels = showSignalPanels ? packet.panels : buildInactivePanels(); signalPanels = showSignalPanels ? packet.panels : buildInactivePanels();
if (packet.summary.points.length > 0) {
const nowSeconds = Math.round((Date.now() - sessionStartedAt) / 100) / 10;
const pointCount = packet.summary.points.length;
const spacing =
pointCount > 1 ? Math.min(1.2, nowSeconds / Math.max(pointCount - 1, 1)) : 0;
const startX = Math.max(0, nowSeconds - spacing * Math.max(pointCount - 1, 0));
const xValues = packet.summary.points.map((_, index) => Math.round((startX + index * spacing) * 10) / 10);
summary = { ...packet.summary, xValues };
} else {
summary = packet.summary; summary = packet.summary;
}
pressureMatrix = packet.pressureMatrix; pressureMatrix = packet.pressureMatrix;
hasSignalData = signalPanels.length > 0 || packet.summary.points.length > 0; hasSignalData = signalPanels.length > 0 || packet.summary.points.length > 0;
} }
@@ -1015,24 +1034,25 @@
currentLocale: LocaleCode, currentLocale: LocaleCode,
activeId: string, activeId: string,
isSettingsOpen: boolean, isSettingsOpen: boolean,
isPrecisionOpen: boolean isPrecisionOpen: boolean,
isDevKitEnabled: boolean,
isDevKitOpen: boolean
): HudConfigLink[] { ): HudConfigLink[] {
const labels = const labels =
currentLocale === "zh-CN" currentLocale === "zh-CN"
? { ? {
streamOn: "打开", streamOn: "打开",
streamOff: "关闭", streamOff: "关闭",
calibrate: "校准",
precisionTest: "游戏", precisionTest: "游戏",
settings: "参数" settings: "参数"
} }
: { : {
streamOn: "Open", streamOn: "Open",
streamOff: "Close", streamOff: "Close",
calibrate: "Calib",
precisionTest: "Game", precisionTest: "Game",
settings: "Setup" settings: "Setup"
}; };
const devkitLabel = currentLocale === "zh-CN" ? "开发工具" : "DevKit";
const links: HudConfigLink[] = [ const links: HudConfigLink[] = [
{ {
@@ -1047,12 +1067,6 @@
tone: "orange", tone: "orange",
active: activeId === "stream-off" active: activeId === "stream-off"
}, },
{
id: "calibrate",
label: labels.calibrate,
tone: "cyan",
active: activeId === "calibrate"
},
{ {
id: "precision-test", id: "precision-test",
label: labels.precisionTest, label: labels.precisionTest,
@@ -1067,12 +1081,12 @@
} }
]; ];
if (devkitEnabled) { if (isDevKitEnabled) {
links.push({ links.push({
id: "devkit", id: "devkit",
label: "DevKit", label: devkitLabel,
tone: "cyan", tone: "cyan",
active: isDevKitConfigOpen active: isDevKitOpen
}); });
} }
@@ -1195,6 +1209,12 @@
connectionNotice = ""; connectionNotice = "";
} }
$: if (updateNoticeVisible && pendingUpdate && !updateInstallBusy) {
connectionNotice = locale === "zh-CN"
? `发现新版本 ${pendingUpdate.version},是否现在下载并安装?`
: `Version ${pendingUpdate.version} is available. Download and install now?`;
}
function handleLocaleChange(event: CustomEvent<LocaleCode>): void { function handleLocaleChange(event: CustomEvent<LocaleCode>): void {
locale = event.detail; locale = event.detail;
} }
@@ -1726,6 +1746,7 @@
onMount(() => { onMount(() => {
let disposed = false; let disposed = false;
let unlistenHudStream: UnlistenFn | null = null; let unlistenHudStream: UnlistenFn | null = null;
let unlistenDevkitPztAngle: UnlistenFn | null = null;
let stopMockFeed: (() => void) | null = null; let stopMockFeed: (() => void) | null = null;
void ensureDefaultWindowSize(); void ensureDefaultWindowSize();
@@ -1749,6 +1770,23 @@
.catch((error) => { .catch((error) => {
console.error("Failed to listen for hud_stream:", error); console.error("Failed to listen for hud_stream:", error);
}); });
void listen<{ seq: number; timestampMs: number; dtsMs: number; angle: number }>(
"devkit_pzt_angle",
(event) => {
console.log("[devkit_pzt_angle]", event.payload);
}
)
.then((unlisten) => {
if (disposed) {
unlisten();
return;
}
unlistenDevkitPztAngle = unlisten;
})
.catch((error) => {
console.error("Failed to listen for devkit_pzt_angle:", error);
});
} else { } else {
stopMockFeed = startMockFeed(applyPacket); stopMockFeed = startMockFeed(applyPacket);
} }
@@ -1758,6 +1796,7 @@
pauseReplayPlayback(); pauseReplayPlayback();
stopMockFeed?.(); stopMockFeed?.();
unlistenHudStream?.(); unlistenHudStream?.();
unlistenDevkitPztAngle?.();
if (devkitStatusTimer != null) { if (devkitStatusTimer != null) {
window.clearInterval(devkitStatusTimer); window.clearInterval(devkitStatusTimer);
devkitStatusTimer = null; devkitStatusTimer = null;
@@ -1849,8 +1888,6 @@
rangeLabel={uiCopy.rangeLabel} rangeLabel={uiCopy.rangeLabel}
rangeMinLabel={uiCopy.rangeMinLabel} rangeMinLabel={uiCopy.rangeMinLabel}
rangeMaxLabel={uiCopy.rangeMaxLabel} rangeMaxLabel={uiCopy.rangeMaxLabel}
colorMapLabel={uiCopy.colorMapLabel}
{colorMapOptions}
replaySectionLabel={uiCopy.replaySectionLabel} replaySectionLabel={uiCopy.replaySectionLabel}
replayPlayLabel={uiCopy.replayPlayLabel} replayPlayLabel={uiCopy.replayPlayLabel}
replayPauseLabel={uiCopy.replayPauseLabel} replayPauseLabel={uiCopy.replayPauseLabel}
@@ -1863,6 +1900,7 @@
{replayProgress} {replayProgress}
{replayFileName} {replayFileName}
{replayFrameInfo} {replayFrameInfo}
{sessionStartedAt}
resetConfigLabel={uiCopy.resetConfigLabel} resetConfigLabel={uiCopy.resetConfigLabel}
applyLiveHint={uiCopy.applyLiveHint} applyLiveHint={uiCopy.applyLiveHint}
leftPanels={leftSignalPanels} leftPanels={leftSignalPanels}
@@ -1880,7 +1918,7 @@
> >
{#if !isPrecisionTestOpen} {#if !isPrecisionTestOpen}
<section class="range-scale" aria-label="Signal Range"> <section class="range-scale" aria-label="Signal Range">
<p class="range-label">Range</p> <p class="range-label">{locale === "zh-CN" ? "范围" : "Range"}</p>
<div class="range-track"> <div class="range-track">
{#each rangeTicks as tick} {#each rangeTicks as tick}
<span class="range-tick">{tick}</span> <span class="range-tick">{tick}</span>
@@ -1919,12 +1957,10 @@
/> />
{#if isDevKitConfigOpen && devkitEnabled} {#if isDevKitConfigOpen && devkitEnabled}
<div class="devkit-overlay" role="dialog" aria-label="DevKit Config"> <div class="devkit-overlay" role="dialog" aria-label={locale === "zh-CN" ? "开发工具配置" : "DevKit Config"}>
<div class="devkit-float"> <div class="devkit-float">
<DevKitConfigPanel <DevKitConfigPanel
running={devkitRunning} running={devkitRunning}
port={devkitPort}
framesSent={devkitFramesSent}
filterLiftEnabled={devkitFilterLift} filterLiftEnabled={devkitFilterLift}
saveAsXlsx={devkitSaveXlsx} saveAsXlsx={devkitSaveXlsx}
locale={locale} locale={locale}