180 lines
6.3 KiB
TypeScript
180 lines
6.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 { useTranslation } from 'react-i18next';
|
||
import {
|
||
getSensorControl,
|
||
type SensorControl,
|
||
type SliderControl,
|
||
} from '../../simulation/sensorControlConfig';
|
||
import { dispatchSensorUpdate, getLastSensorValues } 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 { t } = useTranslation();
|
||
const def = getSensorControl(metadataId);
|
||
|
||
// Local slider/button state — hydrated from the registry's last-known
|
||
// values for this componentId (so reopening a sensor or switching between
|
||
// two sensors of the same type shows each one's current state, not the
|
||
// previous panel's). Falls back to config defaults the first time a
|
||
// sensor is opened.
|
||
const [values, setValues] = useState<Record<string, number | boolean>>(() => {
|
||
const cached = getLastSensorValues(componentId);
|
||
if (cached) return { ...(def?.defaultValues ?? {}), ...cached };
|
||
return def ? { ...def.defaultValues } : {};
|
||
});
|
||
|
||
// Push defaults into simulation on first open for this sensor. Skipped
|
||
// when the sensor already has cached values — the simulation still holds
|
||
// them, no need to clobber.
|
||
useEffect(() => {
|
||
if (def && Object.keys(def.defaultValues).length > 0 && !getLastSensorValues(componentId)) {
|
||
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()}
|
||
// The canvas treats left mousedown on empty space as a pan gesture.
|
||
// Without stopping mousedown here, dragging the lux/temp/etc. slider
|
||
// thumb pans the canvas instead of moving the slider.
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
onPointerDown={(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={t('editor.sensorPanel.close')}>
|
||
×
|
||
</button>
|
||
</div>
|
||
{renderControls()}
|
||
</div>
|
||
);
|
||
};
|