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}
/>
-
+
+
{