diff --git a/frontend/src/__tests__/log-lux-slider.test.ts b/frontend/src/__tests__/log-lux-slider.test.ts new file mode 100644 index 00000000..934e9432 --- /dev/null +++ b/frontend/src/__tests__/log-lux-slider.test.ts @@ -0,0 +1,64 @@ +/** + * Log-scale illumination sliders. + * + * An LDR responds logarithmically, so on the old linear 0-1000 lux slider + * the whole useful range of the night-light example sat in the first ~2% of + * travel (its 500-count threshold crosses at ~20 lux). The slider now runs + * in log position space; these pin the mapping and its round-trip. + */ +import { describe, it, expect } from 'vitest'; +import { + LOG_SLIDER_STEPS, + logSliderToValue, + logValueToSlider, + SENSOR_CONTROLS, +} from '../simulation/sensorControlConfig'; + +describe('logSliderToValue', () => { + it('anchors both ends exactly', () => { + expect(logSliderToValue(0, 0, 1000)).toBe(0); + expect(logSliderToValue(LOG_SLIDER_STEPS, 0, 1000)).toBe(1000); + }); + + it('gives the low decades real travel', () => { + // ~20 lux (the night-light threshold) must sit well inside the track, + // not at 2% like the linear map put it. + const pos20 = logValueToSlider(20, 0, 1000); + expect(pos20 / LOG_SLIDER_STEPS).toBeGreaterThan(0.3); + expect(pos20 / LOG_SLIDER_STEPS).toBeLessThan(0.6); + // Mid-travel lands in the tens of lux, not at 500. + const mid = logSliderToValue(LOG_SLIDER_STEPS / 2, 0, 1000); + expect(mid).toBeGreaterThan(10); + expect(mid).toBeLessThan(100); + }); + + it('is monotonic and clamps out-of-range positions', () => { + let prev = -1; + for (let p = 0; p <= LOG_SLIDER_STEPS; p += 50) { + const v = logSliderToValue(p, 0, 1000); + expect(v).toBeGreaterThanOrEqual(prev); + prev = v; + } + expect(logSliderToValue(-10, 0, 1000)).toBe(0); + expect(logSliderToValue(LOG_SLIDER_STEPS + 10, 0, 1000)).toBe(1000); + }); + + it('round-trips within a step', () => { + for (const v of [0, 1, 4, 20, 100, 500, 1000]) { + const back = logSliderToValue(logValueToSlider(v, 0, 1000), 0, 1000); + // log axis: resolution is relative, so allow 1% of the value (min 1) + expect(Math.abs(back - v)).toBeLessThanOrEqual(Math.max(1, v * 0.01)); + } + }); +}); + +describe('config wiring', () => { + it('the illumination sliders are the log ones', () => { + for (const id of ['photoresistor-sensor', 'photodiode']) { + const lux = SENSOR_CONTROLS[id].controls.find( + (c) => c.type === 'slider' && c.key === 'lux', + ); + expect(lux && 'scale' in lux && lux.scale).toBe('log'); + } + }); +}); diff --git a/frontend/src/components/simulator/SensorControlPanel.tsx b/frontend/src/components/simulator/SensorControlPanel.tsx index 6a90be77..83ea627e 100644 --- a/frontend/src/components/simulator/SensorControlPanel.tsx +++ b/frontend/src/components/simulator/SensorControlPanel.tsx @@ -8,11 +8,7 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { - getSensorControl, - type SensorControl, - type SliderControl, -} from '../../simulation/sensorControlConfig'; +import { getSensorControl, type SensorControl, type SliderControl, LOG_SLIDER_STEPS, logSliderToValue, logValueToSlider } from '../../simulation/sensorControlConfig'; import { dispatchSensorUpdate, getLastSensorValues } from '../../simulation/SensorUpdateRegistry'; import './SensorControlPanel.css'; @@ -115,6 +111,10 @@ export const SensorControlPanel: React.FC = ({ 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); + // Log-scale sliders (illumination): the range input runs in POSITION + // space and the value is derived, so the interesting low decades get + // real travel instead of the first few pixels. + const isLog = sc.scale === 'log'; return (
@@ -124,11 +124,18 @@ export const SensorControlPanel: React.FC = ({ handleSlider(sc.key, e.target.value)} + min={isLog ? 0 : sc.min} + max={isLog ? LOG_SLIDER_STEPS : sc.max} + step={isLog ? 1 : sc.step} + value={isLog ? logValueToSlider(val, sc.min, sc.max) : val} + onChange={(e) => + handleSlider( + sc.key, + isLog + ? String(logSliderToValue(parseFloat(e.target.value), sc.min, sc.max)) + : e.target.value, + ) + } /> {displayVal} diff --git a/frontend/src/simulation/parts/ComplexParts.ts b/frontend/src/simulation/parts/ComplexParts.ts index 6f1b60af..7dfdaaf8 100644 --- a/frontend/src/simulation/parts/ComplexParts.ts +++ b/frontend/src/simulation/parts/ComplexParts.ts @@ -3,6 +3,7 @@ import type { AnySimulator } from './PartSimulationRegistry'; import { RP2040Simulator } from '../RP2040Simulator'; import { getADC, setAdcVoltage, emitPropertyChange } from './partUtils'; import { registerSensorUpdate, unregisterSensorUpdate } from '../SensorUpdateRegistry'; +import { LOG_SLIDER_STEPS, logSliderToValue } from '../sensorControlConfig'; // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -167,9 +168,15 @@ PartSimulationRegistry.register('photoresistor-sensor', { const volts = (val / 1023.0) * 5.0; setAdcVoltage(avrSimulator, pinAO, volts); } - // Mirror to store — maps the slider 0-1023 back to lux 0-1000 - // so the SPICE photoresistor handler re-computes its R_ldr. - emitPropertyChange(componentId, 'lux', Math.round((val / 1023) * 1000)); + // Mirror to store — the slider position maps to lux LOGARITHMICALLY + // (same curve as the Sensors popover): an LDR's whole useful range + // lives in the low decades, and the linear map crammed it into the + // first few percent of travel. + emitPropertyChange( + componentId, + 'lux', + logSliderToValue((val / 1023) * LOG_SLIDER_STEPS, 0, 1000), + ); } }; element.addEventListener('input', onInput); diff --git a/frontend/src/simulation/sensorControlConfig.ts b/frontend/src/simulation/sensorControlConfig.ts index 720b8152..e23c599e 100644 --- a/frontend/src/simulation/sensorControlConfig.ts +++ b/frontend/src/simulation/sensorControlConfig.ts @@ -18,6 +18,11 @@ export interface SliderControl { defaultValue: number; /** Optional custom formatter — e.g. to show "24.0°C" instead of "24" */ formatValue?: (v: number) => string; + /** 'log': the slider POSITION is logarithmic in the value. For quantities + * perceived and sensed logarithmically (illumination on an LDR), a linear + * slider crams all the behaviour into its first few percent — the + * night-light example toggled at 2% of travel. Requires min >= 0. */ + scale?: 'log'; } export interface ButtonControl { @@ -39,6 +44,26 @@ export interface SensorControlDef { const oneDecimal = (v: number) => v.toFixed(1); const twoDecimal = (v: number) => v.toFixed(2); +/** Resolution of the position axis for log-scale sliders. */ +export const LOG_SLIDER_STEPS = 1000; + +/** Log-scale slider: position 0..LOG_SLIDER_STEPS -> value min..max. + * value = min + 10^(p/STEPS * log10(span+1)) - 1, so position 0 lands + * EXACTLY on min (a log axis has no true zero; the +1 shift gives it one) + * and full travel lands exactly on max. */ +export function logSliderToValue(pos: number, min: number, max: number): number { + const p = Math.min(Math.max(pos, 0), LOG_SLIDER_STEPS) / LOG_SLIDER_STEPS; + const span = max - min; + return Math.round(min + Math.pow(10, p * Math.log10(span + 1)) - 1); +} + +/** Inverse of logSliderToValue — where an existing value sits on the axis. */ +export function logValueToSlider(value: number, min: number, max: number): number { + const span = max - min; + const v = Math.min(Math.max(value, min), max) - min; + return Math.round((Math.log10(v + 1) / Math.log10(span + 1)) * LOG_SLIDER_STEPS); +} + // ─── Sensor Control Definitions ────────────────────────────────────────────── export const SENSOR_CONTROLS: Record = { @@ -291,6 +316,7 @@ export const SENSOR_CONTROLS: Record = { step: 1, unit: 'lux', defaultValue: 500, + scale: 'log', }, ], defaultValues: { lux: 500 }, @@ -309,6 +335,7 @@ export const SENSOR_CONTROLS: Record = { step: 1, unit: 'lux', defaultValue: 500, + scale: 'log', }, ], defaultValues: { lux: 500 },