157 lines
5.3 KiB
TypeScript
157 lines
5.3 KiB
TypeScript
|
|
/**
|
|||
|
|
* 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>
|
|||
|
|
);
|
|||
|
|
};
|