From 7847406b106cdb67ac346329308ea5b199b814ed Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Sun, 5 Oct 2025 11:31:09 +0200 Subject: [PATCH] Add medium-format lens updates and preview controls --- src/App.jsx | 216 +++++++++++++++++++++++++++++++++++++++++--- src/data/lenses.js | 18 +++- src/data/sensors.js | 13 ++- 3 files changed, 233 insertions(+), 14 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 76022ea..9da0dfe 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,6 +1,6 @@ import React, { useMemo, useState, useCallback, useEffect } from 'react'; import Plot from 'react-plotly.js'; -import { findSensorById } from './data/sensors.js'; +import { defaultSensors, findSensorById } from './data/sensors.js'; import { defaultLenses } from './data/lenses.js'; import { FramingModes, @@ -12,8 +12,58 @@ import { } from './utils/optics.js'; const COLOR_PALETTE = ['#38bdf8', '#a855f7', '#f97316', '#22c55e', '#e11d48', '#6366f1', '#14b8a6', '#f59e0b']; +const PREVIEW_DISTANCE_MIN = 0.5; +const PREVIEW_DISTANCE_MAX = 50; +const NORMAL_RATIO_MIN = 0.7; +const NORMAL_RATIO_MAX = 1.5; -const INITIAL_SELECTED_LENS_IDS = defaultLenses.slice(0, 3).map((lens) => lens.id); +const computeInitialLensIds = () => { + const lensesBySensor = defaultLenses.reduce((acc, lens) => { + if (!acc[lens.sensorId]) { + acc[lens.sensorId] = []; + } + acc[lens.sensorId].push(lens); + return acc; + }, {}); + + const selectedIds = defaultSensors + .map((sensor) => { + const lenses = lensesBySensor[sensor.id]; + if (!lenses || lenses.length === 0) { + return null; + } + + const scored = lenses.map((lens) => { + const aperture = Number.isFinite(lens.maxAperture) ? lens.maxAperture : Infinity; + const delta = Math.abs(lens.focalLength - sensor.diagonal); + const ratio = sensor.diagonal > 0 ? lens.focalLength / sensor.diagonal : Infinity; + const withinNormalBand = ratio >= NORMAL_RATIO_MIN && ratio <= NORMAL_RATIO_MAX; + return { lens, aperture, delta, withinNormalBand }; + }); + + const normalCandidates = scored.filter((entry) => entry.withinNormalBand); + const candidates = normalCandidates.length > 0 ? normalCandidates : scored; + + candidates.sort((a, b) => { + if (a.aperture !== b.aperture) { + return a.aperture - b.aperture; + } + if (a.delta !== b.delta) { + return a.delta - b.delta; + } + return a.lens.focalLength - b.lens.focalLength; + }); + + return candidates[0]?.lens.id ?? null; + }) + .filter(Boolean); + + return selectedIds.length > 0 + ? Array.from(new Set(selectedIds)) + : defaultLenses.slice(0, 3).map((lens) => lens.id); +}; + +const INITIAL_SELECTED_LENS_IDS = computeInitialLensIds(); const createInitialColorAssignments = (lensIds) => { const assignments = {}; @@ -133,10 +183,31 @@ const filterDatasetForPlotType = (dataset, plotType) => { return dataset; }; -const PreviewPanel = ({ lensCurves }) => { +const PreviewPanel = ({ lensCurves, previewDistanceMeters }) => { const previewItems = lensCurves.map((curve) => { - const infinityStat = curve.checkpointStats.find((stat) => !Number.isFinite(stat.separation)); - const blurMm = infinityStat?.blurMm ?? 0; + const targetDistance = previewDistanceMeters; + const safeDistance = Number.isFinite(targetDistance) ? Math.max(targetDistance, PREVIEW_DISTANCE_MIN) : targetDistance; + const targetSeparation = Number.isFinite(safeDistance) ? safeDistance - curve.subjectDistance : Infinity; + + let stat = curve.checkpointStats.find((entry) => { + if (!Number.isFinite(targetSeparation)) { + return !Number.isFinite(entry.separation); + } + return Number.isFinite(entry.separation) && Math.abs(entry.separation - targetSeparation) < 1e-6; + }); + + if (!stat) { + const derivedStats = buildStatsAtCheckpoints({ + lens: curve.lens, + sensor: curve.sensor, + aperture: curve.apertureUsed, + subjectDistance: curve.subjectDistance, + checkpoints: [targetSeparation], + }); + stat = derivedStats[0]; + } + + const blurMm = stat?.blurMm ?? 0; return { lens: curve.lens, @@ -148,12 +219,18 @@ const PreviewPanel = ({ lensCurves }) => { const maxBlurMm = previewItems.reduce((maxValue, item) => Math.max(maxValue, item.blurMm), 0); const denominator = maxBlurMm > 0 ? maxBlurMm : 1; + const distanceLabel = Number.isFinite(previewDistanceMeters) + ? `${formatNumber(previewDistanceMeters, previewDistanceMeters >= 10 ? 0 : 1)} m` + : '∞'; return (
-

Bokeh preview

+
+

Bokeh preview

+ Scene distance: {distanceLabel} +
{previewItems.length === 0 ? ( -
Select at least one lens to preview its blur at infinity.
+
Select at least one lens to preview its blur.
) : (
{previewItems.map((item) => { @@ -178,7 +255,7 @@ const PreviewPanel = ({ lensCurves }) => {
{item.lens.name}
- ∞ blur {formatNumber(item.blurMm, 2)} mm + Blur {formatNumber(item.blurMm, 2)} mm
@@ -190,7 +267,31 @@ const PreviewPanel = ({ lensCurves }) => { ); }; -const PlotSection = ({ lensCurves, plotType, setPlotType }) => { +const PreviewDistanceControl = ({ value, onChange, max }) => { + const isInfinity = value >= max; + const displayValue = isInfinity ? '∞' : `${formatNumber(value, value >= 10 ? 0 : 1)} m`; + + return ( +
+ + onChange(Number.parseFloat(event.target.value))} + className="mt-3 w-full" + /> +
Drag to compare blur at absolute scene distances. Set to max for infinity.
+
+ ); +}; + +const PlotSection = ({ lensCurves, plotType, setPlotType, previewDistanceMeters }) => { const axisConfig = getAxisConfig(plotType); const plotData = lensCurves.map((curve, index) => { @@ -227,6 +328,28 @@ const PlotSection = ({ lensCurves, plotType, setPlotType }) => { ...lensCurves.flatMap((curve) => curve.dataset.map((point) => point.blurPercentOfDiagonal)) ); + const shouldShowPreviewLine = + Number.isFinite(previewDistanceMeters) && (plotType === PlotTypes.DISTANCE || plotType === PlotTypes.ZOOMED); + const previewLineShape = shouldShowPreviewLine + ? [ + { + type: 'line', + xref: 'x', + yref: 'paper', + x0: previewDistanceMeters, + x1: previewDistanceMeters, + y0: 0, + y1: 1, + line: { + color: 'rgba(148, 163, 184, 0.35)', + width: 1, + dash: 'dot', + }, + layer: 'below', + }, + ] + : undefined; + return (
@@ -273,6 +396,7 @@ const PlotSection = ({ lensCurves, plotType, setPlotType }) => { range: [0, maxY * 1.1], ticksuffix: ' %', }, + shapes: previewLineShape, }} useResizeHandler style={{ width: '100%', height: '100%' }} @@ -291,14 +415,56 @@ const ControlsPanel = ({ toggleLensSelection, activeLensId, setActiveLensId, + lensSensorFilter, + setLensSensorFilter, + sensorFilterOptions, }) => { + const lensesToRender = useMemo(() => { + if (lensSensorFilter === 'all') { + return defaultLenses; + } + return defaultLenses.filter((lens) => lens.sensorId === lensSensorFilter); + }, [lensSensorFilter]); + return (

Lenses & sensors

Each lens automatically pairs with its native sensor format for accurate comparisons.

+
+ + {sensorFilterOptions.map((sensor) => { + const isActive = lensSensorFilter === sensor.id; + const label = sensor.shortLabel ?? sensor.name; + return ( + + ); + })} +
- {defaultLenses.map((lens) => { + {lensesToRender.map((lens) => { const isSelected = selectedLensIds.includes(lens.id); const isActive = activeLensId === lens.id; const sensor = findSensorById(lens.sensorId); @@ -573,6 +739,7 @@ const OpticsPanel = ({ const App = () => { const [selectedLensIds, setSelectedLensIds] = useState(INITIAL_SELECTED_LENS_IDS); + const [lensSensorFilter, setLensSensorFilter] = useState('all'); const [framingMode, setFramingMode] = useState(FramingModes.MATCH_HEIGHT); const [subjectHeight, setSubjectHeight] = useState(1.7); const [frameFill, setFrameFill] = useState(0.75); @@ -587,6 +754,17 @@ const App = () => { const [checkpointsInput, setCheckpointsInput] = useState('3,6,10'); const [activeLensId, setActiveLensId] = useState(null); const [lensColors, setLensColors] = useState(() => createInitialColorAssignments(INITIAL_SELECTED_LENS_IDS)); + const [previewDistance, setPreviewDistance] = useState(PREVIEW_DISTANCE_MAX); + + const sensorFilterOptions = useMemo(() => { + const lensSensorIds = new Set(defaultLenses.map((lens) => lens.sensorId)); + return defaultSensors.filter((sensor) => lensSensorIds.has(sensor.id)); + }, []); + + const previewDistanceMeters = useMemo( + () => (previewDistance >= PREVIEW_DISTANCE_MAX ? Infinity : previewDistance), + [previewDistance] + ); const checkpoints = useMemo(() => { const userValues = parseCheckpointInput(checkpointsInput); @@ -750,6 +928,9 @@ const App = () => { toggleLensSelection={toggleLensSelection} activeLensId={activeLensId} setActiveLensId={setActiveLensId} + lensSensorFilter={lensSensorFilter} + setLensSensorFilter={setLensSensorFilter} + sensorFilterOptions={sensorFilterOptions} /> @@ -758,10 +939,16 @@ const App = () => { lensCurves={lensCurves} plotType={plotType} setPlotType={setPlotType} + previewDistanceMeters={previewDistanceMeters} />
- + + {