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
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:
@@ -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: '地图',
|
||||
|
||||
Reference in New Issue
Block a user