feat(web-platform): release V0.7.1 数据模块
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled

This commit is contained in:
2026-09-01 15:57:03 +08:00
parent 3b4ced6437
commit 89d1c8cb57
12 changed files with 1057 additions and 5 deletions
@@ -0,0 +1,74 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { DataRecordingPanel } from './DataRecordingPanel';
import type { DataRecorderStatus } from '../../simulation/DataRecorder';
const status: DataRecorderStatus = {
recording: false,
limitReached: false,
sampleCount: 2,
segmentCount: 1,
config: { bodyId: 1, sampleRateHz: 50, maxSamples: 30_000 },
body: { id: 1, name: 'base' },
latest: {
sequence: 1,
segment: 0,
simulationTime: 0.02,
values: {
speed_horizontal: 1.25,
speed_3d: 1.3,
velocity_x: 1.25,
velocity_y: 0,
velocity_z: 0.1,
pitch: 0.1,
roll: -0.05,
yaw: 0.2,
height: 0.45,
position_x: 0.1,
position_y: 0,
contact_count: 4,
control_rms: 2,
actuator_force_rms: 3,
actuator_power_abs: 8,
},
},
summary: {
duration: 0.02,
distanceHorizontal: 0.1,
maxHorizontalSpeed: 1.25,
maxAbsRoll: 0.05,
maxAbsPitch: 0.1,
minHeight: 0.44,
maxHeight: 0.46,
},
};
describe('DataRecordingPanel', () => {
it('展示实时遥测并提供配置、记录和导出操作', () => {
const configure = vi.fn(),
start = vi.fn(),
exportData = vi.fn();
render(
<DataRecordingPanel
status={status}
bodies={[
{ id: 0, name: 'world', parentId: 0 },
{ id: 1, name: 'base', parentId: 0 },
{ id: 2, name: 'payload', parentId: 1 },
]}
onConfigure={configure}
onStart={start}
onStop={vi.fn()}
onClear={vi.fn()}
onExport={exportData}
/>,
);
expect(screen.getAllByText('1.250 m/s').length).toBeGreaterThan(0);
expect(screen.getAllByText('5.73°').length).toBeGreaterThan(0);
fireEvent.change(screen.getByLabelText('记录 Body'), { target: { value: '2' } });
expect(configure).toHaveBeenCalledWith({ bodyId: 2 });
fireEvent.click(screen.getByRole('button', { name: '开始记录' }));
expect(start).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: '导出 CSV' }));
expect(exportData).toHaveBeenCalledWith('csv');
});
});
@@ -0,0 +1,198 @@
import { Circle, Download, Square, Trash2 } from 'lucide-react';
import type { BodyInfo } from '../../simulation/SimulationSession';
import type { DataRecorderConfig, DataRecorderStatus } from '../../simulation/DataRecorder';
import { Badge, Button, PropertyRow, Select } from '../../components/ui';
const SAMPLE_RATES = [10, 20, 50, 100, 200];
const radiansToDegrees = 180 / Math.PI;
function number(value: number | undefined, digits = 3): string {
return Number.isFinite(value) ? value!.toFixed(digits) : '—';
}
function degrees(value: number | undefined): string {
return Number.isFinite(value) ? `${(value! * radiansToDegrees).toFixed(2)}°` : '—';
}
export function DataRecordingPanel({
status,
bodies,
onConfigure,
onStart,
onStop,
onClear,
onExport,
}: {
status: DataRecorderStatus;
bodies: BodyInfo[];
onConfigure: (patch: Partial<DataRecorderConfig>) => void;
onStart: () => void;
onStop: () => void;
onClear: () => void;
onExport: (format: 'csv' | 'json') => void;
}) {
const values = status.latest?.values,
availableBodies = bodies.filter(
(body) => body.id > 0 && !body.name.startsWith('__platform_map_'),
);
return (
<div className="space-y-3 p-3">
<section className="rounded-lg border border-border bg-surface p-3">
<div className="mb-3 flex items-center justify-between gap-2">
<div>
<h3 className="text-xs font-semibold text-text-primary">仿真遥测记录</h3>
<p className="mt-0.5 text-[10px] text-text-tertiary">
数据仅保存在当前浏览器会话,可导出 CSV 或 JSON。
</p>
</div>
<Badge tone={status.recording ? 'success' : status.limitReached ? 'warning' : 'neutral'}>
{status.recording ? '记录中' : status.limitReached ? '已达上限' : '已停止'}
</Badge>
</div>
<label className="block text-xs text-text-secondary">
<span className="mb-1 block">记录 Body</span>
<Select
aria-label="记录 Body"
className="w-full"
value={status.config.bodyId}
disabled={status.recording}
onChange={(event) => onConfigure({ bodyId: Number(event.target.value) })}
>
{(availableBodies.length ? availableBodies : [status.body]).map((body) => (
<option key={body.id} value={body.id}>
{body.name}
</option>
))}
</Select>
</label>
<div className="mt-2 grid grid-cols-2 gap-2">
<label className="block text-xs text-text-secondary">
<span className="mb-1 block">采样频率</span>
<Select
aria-label="采样频率"
className="w-full"
value={status.config.sampleRateHz}
disabled={status.recording}
onChange={(event) => onConfigure({ sampleRateHz: Number(event.target.value) })}
>
{SAMPLE_RATES.map((rate) => (
<option key={rate} value={rate}>
{rate} Hz
</option>
))}
</Select>
</label>
<label className="block text-xs text-text-secondary">
<span className="mb-1 block">样本上限</span>
<input
aria-label="样本上限"
type="number"
min={100}
max={1_000_000}
step={1000}
className="field h-8 w-full px-2 text-xs"
value={status.config.maxSamples}
disabled={status.recording}
onChange={(event) => {
const value = Number(event.target.value);
if (Number.isFinite(value) && value >= 100) onConfigure({ maxSamples: value });
}}
/>
</label>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
{status.recording ? (
<Button
variant="primary"
icon={<Square className="h-3 w-3 fill-current" />}
onClick={onStop}
>
停止记录
</Button>
) : (
<Button
variant="primary"
icon={<Circle className="h-3 w-3 fill-current" />}
onClick={onStart}
disabled={status.limitReached}
>
开始记录
</Button>
)}
<Button
variant="danger"
icon={<Trash2 className="h-3.5 w-3.5" />}
onClick={onClear}
disabled={!status.sampleCount}
>
清空
</Button>
</div>
<div className="mt-3 border-t border-border pt-2">
<PropertyRow label="样本" value={`${status.sampleCount} / ${status.config.maxSamples}`} />
<PropertyRow label="有效时长" value={`${number(status.summary.duration, 2)} s`} />
<PropertyRow label="分段" value={status.segmentCount} />
</div>
</section>
<section className="rounded-lg border border-border bg-surface p-3">
<h3 className="mb-2 text-xs font-semibold text-text-primary">实时运动状态</h3>
<PropertyRow label="水平速度" value={`${number(values?.speed_horizontal)} m/s`} />
<PropertyRow label="三维速度" value={`${number(values?.speed_3d)} m/s`} />
<PropertyRow
label="世界系速度 XYZ"
value={`${number(values?.velocity_x)}, ${number(values?.velocity_y)}, ${number(values?.velocity_z)} m/s`}
/>
<PropertyRow label="俯仰角" value={degrees(values?.pitch)} />
<PropertyRow label="侧倾角" value={degrees(values?.roll)} />
<PropertyRow label="偏航角" value={degrees(values?.yaw)} />
<PropertyRow label="机身高度" value={`${number(values?.height)} m`} />
<PropertyRow
label="世界系位置 XY"
value={`${number(values?.position_x)}, ${number(values?.position_y)} m`}
/>
<PropertyRow label="场景接触数" value={number(values?.contact_count, 0)} />
<PropertyRow label="控制输入 RMS" value={number(values?.control_rms)} />
<PropertyRow label="驱动力 RMS" value={number(values?.actuator_force_rms)} />
<PropertyRow label="绝对驱动功率" value={`${number(values?.actuator_power_abs)} W`} />
</section>
<section className="rounded-lg border border-border bg-surface p-3">
<h3 className="mb-2 text-xs font-semibold text-text-primary">本次记录摘要</h3>
<PropertyRow
label="累计水平里程"
value={`${number(status.summary.distanceHorizontal)} m`}
/>
<PropertyRow
label="最大水平速度"
value={`${number(status.summary.maxHorizontalSpeed)} m/s`}
/>
<PropertyRow label="最大俯仰角" value={degrees(status.summary.maxAbsPitch)} />
<PropertyRow label="最大侧倾角" value={degrees(status.summary.maxAbsRoll)} />
<PropertyRow
label="高度范围"
value={`${number(status.summary.minHeight)} – ${number(status.summary.maxHeight)} m`}
/>
<div className="mt-3 grid grid-cols-2 gap-2">
<Button
icon={<Download className="h-3.5 w-3.5" />}
disabled={!status.sampleCount}
onClick={() => onExport('csv')}
>
导出 CSV
</Button>
<Button
icon={<Download className="h-3.5 w-3.5" />}
disabled={!status.sampleCount}
onClick={() => onExport('json')}
>
导出 JSON
</Button>
</div>
</section>
<p className="text-[10px] leading-4 text-text-tertiary">
每次仿真重置会创建新分段,避免跨重置计算出错误速度。开发接口支持注册额外标量通道,导出列名保持稳定。
</p>
</div>
);
}
@@ -1,5 +1,13 @@
import { useState, type ReactNode } from 'react';
import { Box, FolderTree, Info, Map as MapIcon, Settings2, SlidersHorizontal } from 'lucide-react';
import {
Box,
Database,
FolderTree,
Info,
Map as MapIcon,
Settings2,
SlidersHorizontal,
} from 'lucide-react';
import type { MapEntry, ModelEntry } from '../../project/types';
import {
countProjectSearchResults,
@@ -16,6 +24,7 @@ import type {
SimulationSnapshot,
} from '../../simulation/SimulationSession';
import type { UrdfBaseMode, UrdfLoadMode } from '../../simulation/PhysicsAdapter';
import type { DataRecorderConfig } from '../../simulation/DataRecorder';
import type { ViewerSelection } from '../../viewer/MuJoCoViewer';
import type { ControllerCommand, ControllerStatus } from '../../controller/types';
import type { RLCommand, RLPolicyStatus } from '../../rl/types';
@@ -34,6 +43,7 @@ import { ProjectBreadcrumb } from './ProjectBreadcrumb';
import { PythonControllerPanel } from './PythonControllerPanel';
import { RLPolicyPanel } from './RLPolicyPanel';
import { LocalTrainingPanel } from './LocalTrainingPanel';
import { DataRecordingPanel } from './DataRecordingPanel';
import { PhysicalMapPanel } from './PhysicalMapPanel';
import { MapAssetLibrary } from './MapAssetLibrary';
import {
@@ -297,9 +307,14 @@ interface ModelControlsProps {
onEditorSnapping: (translation: number | null, rotationDegrees: number | null) => void;
onMapDisplay: (visual: boolean, collision: boolean) => void;
onMapTabOpen?: () => void;
onDataRecorderConfigure: (patch: Partial<DataRecorderConfig>) => void;
onDataRecordingStart: () => void;
onDataRecordingStop: () => void;
onDataRecordingClear: () => void;
onDataRecordingExport: (format: 'csv' | 'json') => void;
}
export function ModelControlsSidebar(props: ModelControlsProps) {
const [tab, setTab] = useState<'properties' | 'controls' | 'map'>('properties'),
const [tab, setTab] = useState<'properties' | 'controls' | 'data' | 'map'>('properties'),
s = props.snapshot;
if (!s)
return (
@@ -535,6 +550,22 @@ export function ModelControlsSidebar(props: ModelControlsProps) {
icon: <SlidersHorizontal className="h-3.5 w-3.5" />,
content: controls,
},
{
value: 'data',
label: '数据',
icon: <Database className="h-3.5 w-3.5" />,
content: (
<DataRecordingPanel
status={s.telemetry}
bodies={s.bodies}
onConfigure={props.onDataRecorderConfigure}
onStart={props.onDataRecordingStart}
onStop={props.onDataRecordingStop}
onClear={props.onDataRecordingClear}
onExport={props.onDataRecordingExport}
/>
),
},
{
value: 'map',
label: '地图',