import React, { useMemo, useState, useCallback, useEffect } from 'react'; import Plot from 'react-plotly.js'; import { defaultSensors, findSensorById } from './data/sensors.js'; import { defaultLenses } from './data/lenses.js'; import { selectNormalLensIdsForSensors, selectWideLensIdsForSensors, selectPortraitLensIdsForSensors, } from './utils/lensSelection.js'; import { FramingModes, PlotTypes, buildBlurDataset, buildSeparationSamples, buildStatsAtCheckpoints, computeSubjectDistance, } 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 computeInitialLensIds = () => { const normalLensIds = selectNormalLensIdsForSensors(defaultSensors, defaultLenses); if (normalLensIds.length > 0) { return normalLensIds; } return defaultLenses.slice(0, 3).map((lens) => lens.id); }; const INITIAL_SELECTED_LENS_IDS = computeInitialLensIds(); const createInitialColorAssignments = (lensIds) => { const assignments = {}; lensIds.forEach((lensId, index) => { assignments[lensId] = index % COLOR_PALETTE.length; }); return assignments; }; const reconcileColorAssignments = (selectedLensIds, previousAssignments) => { if (selectedLensIds.length === 0) { return selectedLensIds.length === Object.keys(previousAssignments).length ? previousAssignments : {}; } const nextAssignments = {}; const usedIndices = new Set(); selectedLensIds.forEach((lensId) => { const priorIndex = previousAssignments[lensId]; if (typeof priorIndex === 'number' && !usedIndices.has(priorIndex)) { nextAssignments[lensId] = priorIndex; usedIndices.add(priorIndex); } }); const availableIndices = []; for (let i = 0; i < COLOR_PALETTE.length; i += 1) { if (!usedIndices.has(i)) { availableIndices.push(i); } } let overflowIndex = 0; selectedLensIds.forEach((lensId) => { if (typeof nextAssignments[lensId] === 'number') { return; } let assignedIndex; if (availableIndices.length > 0) { assignedIndex = availableIndices.shift(); } else { assignedIndex = overflowIndex % COLOR_PALETTE.length; overflowIndex += 1; } nextAssignments[lensId] = assignedIndex; usedIndices.add(assignedIndex); }); const previousKeys = Object.keys(previousAssignments); const nextKeys = Object.keys(nextAssignments); const assignmentsChanged = previousKeys.length !== nextKeys.length || selectedLensIds.some((lensId) => nextAssignments[lensId] !== previousAssignments[lensId]); return assignmentsChanged ? nextAssignments : previousAssignments; }; const plotTypeOptions = [ { value: PlotTypes.DISTANCE, label: 'Absolute scene distance' }, { value: PlotTypes.NORMALIZED, label: 'Blur vs normalized distance' }, { value: PlotTypes.SEPARATION, label: 'Focus-relative distance' }, { value: PlotTypes.DIOPTER, label: 'Blur vs diopter difference' }, ]; const framingOptions = [ { value: FramingModes.FIXED_DISTANCE, label: 'Fixed subject distance' }, { value: FramingModes.MATCH_DIAGONAL, label: 'Normalize by diagonal' }, { value: FramingModes.MATCH_HEIGHT, label: 'Normalize by height' }, { value: FramingModes.CUSTOM, label: 'Custom framing' }, ]; const parseCheckpointInput = (input) => { if (!input) return [3, 6, 10]; const values = input .split(',') .map((token) => Number.parseFloat(token.trim())) .filter((value) => Number.isFinite(value) && value > 0) .sort((a, b) => a - b); return values.length > 0 ? values : [3, 6, 10]; }; const formatNumber = (value, digits = 2) => { const numeric = Number.parseFloat(value); if (!Number.isFinite(numeric)) return '—'; return numeric.toFixed(digits); }; const formatAperture = (value) => { const numeric = Number.parseFloat(value); if (!Number.isFinite(numeric)) return 'ƒ/—'; return `ƒ/${numeric.toFixed(1)}`; }; const getAxisConfig = (plotType) => { switch (plotType) { case PlotTypes.DISTANCE: return { label: 'Scene distance (m)', accessor: (point) => point.sceneDistance, ticksuffix: ' m' }; case PlotTypes.ZOOMED: return { label: 'Scene distance (m)', accessor: (point) => point.sceneDistance, ticksuffix: ' m' }; case PlotTypes.NORMALIZED: return { label: 'Scene distance ÷ subject distance', accessor: (point) => point.normalizedDistance }; case PlotTypes.SEPARATION: return { label: 'Focus-relative distance (m)', accessor: (point) => point.focusOffset, ticksuffix: ' m' }; case PlotTypes.DIOPTER: return { label: 'Diopter difference (1/m)', accessor: (point) => point.diopterDifference, ticksuffix: ' 1/m' }; default: return { label: 'Scene distance (m)', accessor: (point) => point.sceneDistance, ticksuffix: ' m' }; } }; const filterDatasetForPlotType = (dataset, plotType) => { if (plotType === PlotTypes.ZOOMED) { return dataset.filter((point) => Math.abs(point.focusOffset) <= 3); } return dataset; }; const PreviewPanel = ({ lensCurves, previewDistanceMeters }) => { const previewItems = lensCurves.map((curve) => { 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, blurMm, sensorDiagonal: curve.sensor?.diagonal ?? 1, colorIndex: curve.colorIndex, }; }); 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

Scene distance: {distanceLabel}
{previewItems.length === 0 ? (
Select at least one lens to preview its blur.
) : (
{previewItems.map((item) => { const normalized = Math.min(item.blurMm / denominator, 1); const diameter = Math.max(18, 30 + normalized * 70); const paletteColor = COLOR_PALETTE[item.colorIndex % COLOR_PALETTE.length]; return (
{item.lens.name}
{item.lens.name}
Blur {formatNumber(item.blurMm, 2)} mm
); })}
)}
); }; 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) => { const filteredDataset = filterDatasetForPlotType(curve.dataset, plotType); const xValues = filteredDataset.map(axisConfig.accessor); const yValues = filteredDataset.map((point) => point.blurPercentOfDiagonal); const paletteIndex = typeof curve.colorIndex === 'number' ? curve.colorIndex % COLOR_PALETTE.length : index % COLOR_PALETTE.length; return { x: xValues, y: yValues, customdata: filteredDataset.map((point) => [ point.blurPercentOfDiagonal, point.blurMm, point.sceneDistance, point.focusOffset, point.normalizedDistance, point.diopterDifference, ]), mode: 'lines', name: `${curve.lens.name} (${formatAperture(curve.apertureUsed)})`, line: { width: 2, color: COLOR_PALETTE[paletteIndex], }, hovertemplate: `%{text}
${axisConfig.label}: %{x:.2f}
Blur: %{customdata[0]:.2f}% diag (%{customdata[1]:.2f} mm)`, text: Array(filteredDataset.length).fill(curve.lens.name), }; }); const maxY = Math.max( 0.1, ...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 (

Blur comparison

Blur radius as % of sensor diagonal

{lensCurves.length > 0 ? ( ) : (
Select at least one lens to render a curve.
)}
); }; const ControlsPanel = ({ selectedLensIds, toggleLensSelection, activeLensId, setActiveLensId, lensSensorFilter, setLensSensorFilter, sensorFilterOptions, applyLensPreset, }) => { const lensesToRender = useMemo(() => { const sensorCache = new Map(); const getSensor = (sensorId) => { if (sensorCache.has(sensorId)) { return sensorCache.get(sensorId); } const sensor = findSensorById(sensorId); sensorCache.set(sensorId, sensor); return sensor; }; const lensList = lensSensorFilter === 'all' ? defaultLenses : defaultLenses.filter((lens) => lens.sensorId === lensSensorFilter); return [...lensList].sort((lensA, lensB) => { const sensorA = getSensor(lensA.sensorId); const sensorB = getSensor(lensB.sensorId); const cropFactorA = Number.isFinite(sensorA?.cropFactor) ? sensorA.cropFactor : 1; const cropFactorB = Number.isFinite(sensorB?.cropFactor) ? sensorB.cropFactor : 1; const effectiveFocalLengthA = lensA.focalLength * cropFactorA; const effectiveFocalLengthB = lensB.focalLength * cropFactorB; if (effectiveFocalLengthA !== effectiveFocalLengthB) { return effectiveFocalLengthA - effectiveFocalLengthB; } if (lensA.focalLength !== lensB.focalLength) { return lensA.focalLength - lensB.focalLength; } return lensA.name.localeCompare(lensB.name); }); }, [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 ( ); })}
{lensesToRender.map((lens) => { const isSelected = selectedLensIds.includes(lens.id); const isActive = activeLensId === lens.id; const sensor = findSensorById(lens.sensorId); const sensorLabel = sensor ? sensor.name : 'Unknown sensor'; const lensMetaParts = [ lens.brand, `${lens.focalLength}mm`, `ƒ/${lens.maxAperture}`, sensorLabel, ].filter(Boolean); const lensMeta = lensMetaParts.join(' · '); return ( ); })}
); }; const StatsTable = ({ lensStats, checkpoints }) => (
{checkpoints.map((checkpoint) => ( ))} {lensStats.length === 0 ? ( ) : ( lensStats.map(({ lens, sensor, apertureUsed, subjectDistance, entrancePupil, checkpointStats }) => { const lensSummaryParts = [lens.brand, `${lens.focalLength}mm`, formatAperture(apertureUsed)]; const lensSummary = lensSummaryParts.filter(Boolean).join(' · '); return ( {checkpointStats.map((stat) => ( ))} ); }) )}
Lens Sensor Subject distance Entrance pupil {formatNumber(checkpoint, checkpoint >= 1 ? 0 : 2)} m
Select at least one lens to compare blur metrics.
{lens.name}
{lensSummary}
{sensor?.name ?? '—'} {formatNumber(subjectDistance, 2)} m {formatNumber(entrancePupil, 1)} mm
{formatNumber(stat.blurPercentOfDiagonal, 2)}%
{formatNumber(stat.blurMm, 2)} mm
); const FramingPanel = ({ framingMode, setFramingMode, subjectHeight, setSubjectHeight, frameFill, setFrameFill, fixedSubjectDistance, setFixedSubjectDistance, customSubjectDistance, setCustomSubjectDistance, customDimension, setCustomDimension, }) => { const distanceValue = framingMode === FramingModes.FIXED_DISTANCE ? fixedSubjectDistance : customSubjectDistance; return (

Framing mode

{(framingMode === FramingModes.FIXED_DISTANCE || framingMode === FramingModes.CUSTOM) && (
{ const value = Number.parseFloat(event.target.value); setCustomSubjectDistance(value); setFixedSubjectDistance(value); }} className="w-full" />
)} {(framingMode === FramingModes.MATCH_HEIGHT || framingMode === FramingModes.MATCH_DIAGONAL || framingMode === FramingModes.CUSTOM) && ( <>
setSubjectHeight(Number.parseFloat(event.target.value))} className="w-full" />
setFrameFill(Number.parseFloat(event.target.value))} className="w-full" />
)} {framingMode === FramingModes.CUSTOM && (
)}
); }; const OpticsPanel = ({ foregroundRange, setForegroundRange, maxSeparation, setMaxSeparation, checkpointsInput, setCheckpointsInput, foregroundRangeLimit, maxSeparationLimit, }) => (

Optics

setForegroundRange(Number.parseFloat(event.target.value))} className="w-full" />
Shared span: 0 – {formatNumber(foregroundRangeLimit, 1)} m
setMaxSeparation(Number.parseFloat(event.target.value))} className="w-full" />
Shared span: 0 – {formatNumber(maxSeparationLimit, 0)} m
setCheckpointsInput(event.target.value)} placeholder="3,6,10" className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-sm focus:border-accent focus:outline-none" />
); 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); const [fixedSubjectDistance, setFixedSubjectDistance] = useState(2); const [customSubjectDistance, setCustomSubjectDistance] = useState(2); const [customDimension, setCustomDimension] = useState('height'); const [foregroundRange, setForegroundRange] = useState(1.5); const [foregroundRangeLimit, setForegroundRangeLimit] = useState(5); const [plotType, setPlotType] = useState(PlotTypes.DISTANCE); const [maxSeparation, setMaxSeparation] = useState(25); const [maxSeparationLimit, setMaxSeparationLimit] = useState(80); 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); const withInfinity = userValues.includes(Infinity) ? userValues : [...userValues, Infinity]; return withInfinity; }, [checkpointsInput]); const separationSamples = useMemo( () => buildSeparationSamples({ maxSeparation, foregroundRange }), [maxSeparation, foregroundRange] ); const selectedLenses = useMemo( () => defaultLenses.filter((lens) => selectedLensIds.includes(lens.id)), [selectedLensIds] ); const toggleLensSelection = useCallback((lensId) => { setSelectedLensIds((current) => { if (current.includes(lensId)) { return current.filter((id) => id !== lensId); } return [...current, lensId]; }); }, []); const applyLensPreset = useCallback( (presetKey) => { const sensorsForFilter = lensSensorFilter === 'all' ? defaultSensors : defaultSensors.filter((sensor) => sensor.id === lensSensorFilter); const effectiveSensors = sensorsForFilter.length > 0 ? sensorsForFilter : defaultSensors; let nextLensIds = []; switch (presetKey) { case 'wide': nextLensIds = selectWideLensIdsForSensors(effectiveSensors, defaultLenses); break; case 'portrait': nextLensIds = selectPortraitLensIdsForSensors(effectiveSensors, defaultLenses); break; case 'normal': default: nextLensIds = selectNormalLensIdsForSensors(effectiveSensors, defaultLenses); break; } if (nextLensIds.length === 0) { const fallback = selectNormalLensIdsForSensors(defaultSensors, defaultLenses); if (fallback.length === 0) { return; } nextLensIds = fallback; } setSelectedLensIds(nextLensIds); setActiveLensId((current) => (current && nextLensIds.includes(current) ? current : nextLensIds[0] ?? null)); }, [lensSensorFilter, setSelectedLensIds, setActiveLensId] ); useEffect(() => { setLensColors((currentAssignments) => reconcileColorAssignments(selectedLensIds, currentAssignments)); }, [selectedLensIds]); const lensCurves = useMemo(() => { return selectedLenses .map((lens) => { const sensor = findSensorById(lens.sensorId); if (!sensor) { return null; } const apertureUsed = lens.maxAperture; const subjectDistance = computeSubjectDistance({ framingMode, lens, sensor, fixedSubjectDistance, subjectHeight, frameFill, customSubjectDistance, customDimension, }); const dataset = buildBlurDataset({ lens, sensor, aperture: apertureUsed, subjectDistance, separationSamples, }); const checkpointStats = buildStatsAtCheckpoints({ lens, sensor, aperture: apertureUsed, subjectDistance, checkpoints, }); return { lens, sensor, subjectDistance, apertureUsed, dataset, checkpointStats, entrancePupil: checkpointStats[0]?.entrancePupil ?? 0, colorIndex: lensColors[lens.id] ?? 0, }; }) .filter(Boolean); }, [selectedLenses, framingMode, fixedSubjectDistance, subjectHeight, frameFill, customSubjectDistance, customDimension, separationSamples, checkpoints, lensColors]); const statsPayload = lensCurves.map((curve) => ({ lens: curve.lens, sensor: curve.sensor, subjectDistance: curve.subjectDistance, apertureUsed: curve.apertureUsed, entrancePupil: curve.checkpointStats[0]?.entrancePupil ?? 0, checkpointStats: curve.checkpointStats, })); useEffect(() => { if (!lensCurves.length) { setActiveLensId(null); return; } if (!activeLensId || !lensCurves.some((curve) => curve.lens.id === activeLensId)) { setActiveLensId(lensCurves[0].lens.id); } }, [lensCurves, activeLensId]); useEffect(() => { if (!lensCurves.length) { return; } let minFocusOffset = 0; let maxFocusOffset = 0; lensCurves.forEach((curve) => { curve.dataset.forEach((point) => { if (!Number.isFinite(point.focusOffset)) { return; } if (point.focusOffset < minFocusOffset) { minFocusOffset = point.focusOffset; } if (point.focusOffset > maxFocusOffset) { maxFocusOffset = point.focusOffset; } }); }); const requiredForeground = Math.abs(minFocusOffset); const requiredBackground = maxFocusOffset; if (requiredForeground > foregroundRangeLimit + 1e-6) { const nextLimit = Math.max(foregroundRangeLimit, Math.ceil(requiredForeground * 10) / 10); setForegroundRangeLimit(nextLimit); } if (requiredBackground > maxSeparationLimit + 1e-6) { const nextLimit = Math.max(maxSeparationLimit, Math.ceil(requiredBackground)); setMaxSeparationLimit(nextLimit); } if (requiredForeground > foregroundRange + 1e-6) { setForegroundRange(Number(requiredForeground.toFixed(2))); } if (requiredBackground > maxSeparation + 1e-6) { setMaxSeparation(Number(requiredBackground.toFixed(2))); } }, [lensCurves, foregroundRange, maxSeparation, foregroundRangeLimit, maxSeparationLimit]); return (

Lens & Sensor Blur Comparison

Explore blur radius as % of sensor diagonal across lens and sensor combinations.

Blur check points

); }; export default App;