velxio/frontend/src/components/simulator/SensorControlPanel.tsx

162 lines
5.3 KiB
TypeScript
Raw Normal View History

/**
* SensorControlPanel wokwi-style interactive sensor controls.
*
* Appears at the top-left of the simulation canvas when a sensor component is
* clicked during simulation. Provides sliders and buttons that feed values
* directly into the running simulation via SensorUpdateRegistry.
*/
import React, { useEffect, useState } from 'react';
import {
SENSOR_CONTROLS,
type SensorControl,
type SliderControl,
} from '../../simulation/sensorControlConfig';
import { dispatchSensorUpdate } from '../../simulation/SensorUpdateRegistry';
import './SensorControlPanel.css';
interface SensorControlPanelProps {
componentId: string;
metadataId: string;
sensorName: string;
onClose: () => void;
}
// ── Section grouping for MPU6050 ────────────────────────────────────────────
interface SensorSection {
label: string;
icon: string;
keys: string[];
}
const MPU6050_SECTIONS: SensorSection[] = [
{ label: 'Acceleration', icon: '↗', keys: ['accelX', 'accelY', 'accelZ'] },
{ label: 'Rotation', icon: '↻', keys: ['gyroX', 'gyroY', 'gyroZ'] },
{ label: 'Temperature', icon: '🌡', keys: ['temp'] },
];
// Keys that use single-char axis labels (X / Y / Z) rather than the full key name
const AXIS_KEYS = new Set(['accelX', 'accelY', 'accelZ', 'gyroX', 'gyroY', 'gyroZ']);
// ── Component ───────────────────────────────────────────────────────────────
export const SensorControlPanel: React.FC<SensorControlPanelProps> = ({
componentId,
metadataId,
sensorName,
onClose,
}) => {
const def = SENSOR_CONTROLS[metadataId];
// Local slider/button state — initialised from config defaults
const [values, setValues] = useState<Record<string, number | boolean>>(
def ? { ...def.defaultValues } : {},
);
// Push initial defaults into simulation on mount
useEffect(() => {
if (def && Object.keys(def.defaultValues).length > 0) {
dispatchSensorUpdate(componentId, def.defaultValues);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [componentId]);
// Close on Escape
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
if (!def) return null;
// ── Handlers ──────────────────────────────────────────────────────────────
const handleSlider = (key: string, raw: string) => {
const v = parseFloat(raw);
setValues((prev) => ({ ...prev, [key]: v }));
dispatchSensorUpdate(componentId, { [key]: v });
};
const handleButton = (key: string) => {
dispatchSensorUpdate(componentId, { [key]: true });
};
// ── Render helpers ────────────────────────────────────────────────────────
const renderControl = (ctrl: SensorControl) => {
if (ctrl.type === 'button') {
return (
<button
key={ctrl.key}
className="sensor-trigger-button"
onClick={() => handleButton(ctrl.key)}
>
{ctrl.label}
</button>
);
}
// Slider
const sc = ctrl as SliderControl;
const val = (values[sc.key] as number) ?? sc.defaultValue;
const displayVal = sc.formatValue ? sc.formatValue(val) : String(val);
const isAxisKey = AXIS_KEYS.has(sc.key);
return (
<div key={sc.key} className="sensor-control-row">
<span className={isAxisKey ? 'sensor-control-label' : 'sensor-control-label-wide'}>
{isAxisKey ? sc.label : sc.label}
</span>
<input
type="range"
className="sensor-slider"
min={sc.min}
max={sc.max}
step={sc.step}
value={val}
onChange={(e) => handleSlider(sc.key, e.target.value)}
/>
<span className="sensor-value-display">
{displayVal}
{sc.unit ? ` ${sc.unit}` : ''}
</span>
</div>
);
};
// For MPU6050 render sections; for everything else render controls flat
const renderControls = () => {
if (metadataId === 'mpu6050') {
return MPU6050_SECTIONS.map((section) => {
const sectionControls = def.controls.filter((c) => section.keys.includes(c.key));
return (
<React.Fragment key={section.label}>
<div className="sensor-section-label">
<span className="sensor-section-icon">{section.icon}</span>
{section.label}
</div>
{sectionControls.map(renderControl)}
</React.Fragment>
);
});
}
return def.controls.map(renderControl);
};
return (
<div className="sensor-control-panel" onClick={(e) => e.stopPropagation()}>
<div className="sensor-panel-header">
<span className="sensor-panel-title">{sensorName || def.title}</span>
<button className="sensor-panel-close" onClick={onClose} title="Close">
×
</button>
</div>
{renderControls()}
</div>
);
};