1100 lines
41 KiB
React
1100 lines
41 KiB
React
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 (
|
||
<div className="rounded-lg border border-slate-800 bg-slate-900/60 p-4">
|
||
<div className="mb-3 flex items-center justify-between">
|
||
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-400">Bokeh preview</h2>
|
||
<span className="text-[10px] uppercase tracking-wide text-slate-500">Scene distance: {distanceLabel}</span>
|
||
</div>
|
||
{previewItems.length === 0 ? (
|
||
<div className="text-sm text-slate-500">Select at least one lens to preview its blur.</div>
|
||
) : (
|
||
<div className="flex flex-wrap justify-center gap-4">
|
||
{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 (
|
||
<div key={item.lens.id} className="flex w-32 flex-col items-center gap-2 text-center">
|
||
<div
|
||
className="flex items-center justify-center rounded-full border"
|
||
style={{
|
||
width: `${diameter}px`,
|
||
height: `${diameter}px`,
|
||
borderColor: paletteColor,
|
||
background: `${paletteColor}1A`,
|
||
boxShadow: `0 0 12px ${paletteColor}33`,
|
||
}}
|
||
>
|
||
<span className="sr-only">{item.lens.name}</span>
|
||
</div>
|
||
<div className="text-xs text-slate-200">
|
||
<div className="font-medium text-slate-100">{item.lens.name}</div>
|
||
<div className="text-[10px] uppercase tracking-wide text-slate-500">
|
||
Blur {formatNumber(item.blurMm, 2)} mm
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const PreviewDistanceControl = ({ value, onChange, max }) => {
|
||
const isInfinity = value >= max;
|
||
const displayValue = isInfinity ? '∞' : `${formatNumber(value, value >= 10 ? 0 : 1)} m`;
|
||
|
||
return (
|
||
<div className="rounded-lg border border-slate-800 bg-slate-900/60 p-4">
|
||
<label className="flex items-center justify-between text-xs uppercase tracking-wide text-slate-400">
|
||
<span>Preview distance</span>
|
||
<span className="text-slate-200">{displayValue}</span>
|
||
</label>
|
||
<input
|
||
type="range"
|
||
min={PREVIEW_DISTANCE_MIN}
|
||
max={max}
|
||
step="0.5"
|
||
value={value}
|
||
onChange={(event) => onChange(Number.parseFloat(event.target.value))}
|
||
className="mt-3 w-full"
|
||
/>
|
||
<div className="mt-2 text-[11px] text-slate-500">Drag to compare blur at absolute scene distances. Set to max for infinity.</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
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: `<b>%{text}</b><br>${axisConfig.label}: %{x:.2f}<br>Blur: %{customdata[0]:.2f}% diag (%{customdata[1]:.2f} mm)<extra></extra>`,
|
||
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 (
|
||
<div className="flex flex-col gap-4 p-4">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div>
|
||
<h2 className="text-lg font-semibold text-slate-100">Blur comparison</h2>
|
||
<p className="text-xs uppercase tracking-wide text-slate-400">Blur radius as % of sensor diagonal</p>
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<select
|
||
value={plotType}
|
||
onChange={(event) => setPlotType(event.target.value)}
|
||
className="rounded border border-slate-700 bg-slate-900 px-3 py-2 text-sm focus:border-accent focus:outline-none"
|
||
>
|
||
{plotTypeOptions.map((option) => (
|
||
<option key={option.value} value={option.value}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="h-[420px] min-h-[320px] overflow-hidden rounded-lg border border-slate-800 bg-slate-900/60 lg:h-[calc(100vh-18rem)]">
|
||
{lensCurves.length > 0 ? (
|
||
<Plot
|
||
data={plotData}
|
||
layout={{
|
||
paper_bgcolor: 'rgba(15, 23, 42, 1)',
|
||
plot_bgcolor: 'rgba(15, 23, 42, 1)',
|
||
font: { color: '#e2e8f0' },
|
||
margin: { l: 60, r: 20, t: 30, b: 50 },
|
||
legend: { orientation: 'h', yanchor: 'bottom', y: 1.02 },
|
||
xaxis: {
|
||
title: axisConfig.label,
|
||
gridcolor: 'rgba(148, 163, 184, 0.2)',
|
||
zerolinecolor: 'rgba(148, 163, 184, 0.4)',
|
||
...(axisConfig.ticksuffix ? { ticksuffix: axisConfig.ticksuffix } : {}),
|
||
},
|
||
yaxis: {
|
||
title: 'Blur radius (% of sensor diagonal)',
|
||
type: 'linear',
|
||
gridcolor: 'rgba(148, 163, 184, 0.2)',
|
||
zerolinecolor: 'rgba(148, 163, 184, 0.4)',
|
||
range: [0, maxY * 1.1],
|
||
ticksuffix: ' %',
|
||
},
|
||
shapes: previewLineShape,
|
||
}}
|
||
useResizeHandler
|
||
style={{ width: '100%', height: '100%' }}
|
||
config={{ responsive: true, displaylogo: false, modeBarButtonsToRemove: ['select2d', 'lasso2d'] }}
|
||
/>
|
||
) : (
|
||
<div className="flex h-full items-center justify-center text-sm text-slate-400">Select at least one lens to render a curve.</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
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 (
|
||
<div className="space-y-6 p-4">
|
||
<section>
|
||
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-400 mb-2">Lenses & sensors</h2>
|
||
<p className="mb-3 text-xs text-slate-500">Each lens automatically pairs with its native sensor format for accurate comparisons.</p>
|
||
<div className="mb-3 flex flex-wrap gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => applyLensPreset('normal')}
|
||
className="rounded border border-slate-800 bg-slate-900/50 px-2 py-1 text-xs uppercase tracking-wide text-slate-300 transition hover:border-accent/40 hover:text-slate-100"
|
||
title="Select a go-to normal lens for each sensor"
|
||
>
|
||
Normal picks
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => applyLensPreset('wide')}
|
||
className="rounded border border-slate-800 bg-slate-900/50 px-2 py-1 text-xs uppercase tracking-wide text-slate-300 transition hover:border-accent/40 hover:text-slate-100"
|
||
title="Focus on wider fields of view"
|
||
>
|
||
Wide picks
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => applyLensPreset('portrait')}
|
||
className="rounded border border-slate-800 bg-slate-900/50 px-2 py-1 text-xs uppercase tracking-wide text-slate-300 transition hover:border-accent/40 hover:text-slate-100"
|
||
title="Highlight portrait-friendly focal lengths"
|
||
>
|
||
Portrait picks
|
||
</button>
|
||
</div>
|
||
<div className="mb-3 flex flex-wrap gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setLensSensorFilter('all')}
|
||
className={`rounded border px-2 py-1 text-xs uppercase tracking-wide transition ${
|
||
lensSensorFilter === 'all'
|
||
? 'border-accent/60 bg-slate-900/80 text-slate-100 shadow-[0_0_0_1px_rgba(56,189,248,0.35)]'
|
||
: 'border-slate-800 bg-slate-900/50 text-slate-400 hover:border-accent/40 hover:text-slate-200'
|
||
}`}
|
||
>
|
||
All
|
||
</button>
|
||
{sensorFilterOptions.map((sensor) => {
|
||
const isActive = lensSensorFilter === sensor.id;
|
||
const label = sensor.shortLabel ?? sensor.name;
|
||
return (
|
||
<button
|
||
key={sensor.id}
|
||
type="button"
|
||
onClick={() => setLensSensorFilter(sensor.id)}
|
||
className={`rounded border px-2 py-1 text-xs uppercase tracking-wide transition ${
|
||
isActive
|
||
? 'border-accent/60 bg-slate-900/80 text-slate-100 shadow-[0_0_0_1px_rgba(56,189,248,0.35)]'
|
||
: 'border-slate-800 bg-slate-900/50 text-slate-400 hover:border-accent/40 hover:text-slate-200'
|
||
}`}
|
||
title={sensor.name}
|
||
>
|
||
{label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="flex flex-col gap-2">
|
||
{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 (
|
||
<label
|
||
key={lens.id}
|
||
onMouseEnter={() => setActiveLensId(lens.id)}
|
||
onFocus={() => setActiveLensId(lens.id)}
|
||
className={`flex cursor-pointer items-center justify-between rounded border px-3 py-2 text-sm transition ${
|
||
isActive
|
||
? 'border-accent/70 bg-slate-900/80 text-slate-100 shadow-[0_0_0_1px_rgba(56,189,248,0.35)]'
|
||
: isSelected
|
||
? 'border-accent/40 bg-slate-900/60 text-slate-100'
|
||
: 'border-slate-700 bg-slate-900/40 text-slate-400'
|
||
}`}
|
||
>
|
||
<span className="flex flex-col">
|
||
<span className="font-medium text-slate-100">{lens.name}</span>
|
||
<span className="text-xs text-slate-400">{lensMeta}</span>
|
||
</span>
|
||
<input
|
||
type="checkbox"
|
||
checked={isSelected}
|
||
onChange={() => {
|
||
toggleLensSelection(lens.id);
|
||
if (!isSelected) {
|
||
setActiveLensId(lens.id);
|
||
}
|
||
}}
|
||
className="h-4 w-4 accent-accent"
|
||
/>
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const StatsTable = ({ lensStats, checkpoints }) => (
|
||
<div className="overflow-x-auto rounded-lg border border-slate-800 bg-slate-900/60">
|
||
<table className="min-w-full divide-y divide-slate-800 text-sm">
|
||
<thead className="text-left text-xs uppercase tracking-wide text-slate-400">
|
||
<tr>
|
||
<th className="px-3 py-2">Lens</th>
|
||
<th className="px-3 py-2">Sensor</th>
|
||
<th className="px-3 py-2">Subject distance</th>
|
||
<th className="px-3 py-2">Entrance pupil</th>
|
||
{checkpoints.map((checkpoint) => (
|
||
<th key={checkpoint} className="px-3 py-2 text-right">
|
||
{formatNumber(checkpoint, checkpoint >= 1 ? 0 : 2)} m
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-900/60">
|
||
{lensStats.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={4 + checkpoints.length} className="px-3 py-6 text-center text-sm text-slate-400">
|
||
Select at least one lens to compare blur metrics.
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
lensStats.map(({ lens, sensor, apertureUsed, subjectDistance, entrancePupil, checkpointStats }) => {
|
||
const lensSummaryParts = [lens.brand, `${lens.focalLength}mm`, formatAperture(apertureUsed)];
|
||
const lensSummary = lensSummaryParts.filter(Boolean).join(' · ');
|
||
return (
|
||
<tr key={lens.id} className="hover:bg-slate-900/60">
|
||
<td className="px-3 py-2">
|
||
<div className="font-medium text-slate-100">{lens.name}</div>
|
||
<div className="text-xs text-slate-400">{lensSummary}</div>
|
||
</td>
|
||
<td className="px-3 py-2 text-slate-200">{sensor?.name ?? '—'}</td>
|
||
<td className="px-3 py-2 text-slate-200">{formatNumber(subjectDistance, 2)} m</td>
|
||
<td className="px-3 py-2 text-slate-200">{formatNumber(entrancePupil, 1)} mm</td>
|
||
{checkpointStats.map((stat) => (
|
||
<td key={stat.separation} className="px-3 py-2 text-right text-slate-100">
|
||
<div>{formatNumber(stat.blurPercentOfDiagonal, 2)}%</div>
|
||
<div className="text-xs text-slate-400">{formatNumber(stat.blurMm, 2)} mm</div>
|
||
</td>
|
||
))}
|
||
</tr>
|
||
);
|
||
})
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
|
||
|
||
const FramingPanel = ({
|
||
framingMode,
|
||
setFramingMode,
|
||
subjectHeight,
|
||
setSubjectHeight,
|
||
frameFill,
|
||
setFrameFill,
|
||
fixedSubjectDistance,
|
||
setFixedSubjectDistance,
|
||
customSubjectDistance,
|
||
setCustomSubjectDistance,
|
||
customDimension,
|
||
setCustomDimension,
|
||
}) => {
|
||
const distanceValue = framingMode === FramingModes.FIXED_DISTANCE ? fixedSubjectDistance : customSubjectDistance;
|
||
|
||
return (
|
||
<div className="rounded-lg border border-slate-800 bg-slate-900/60 p-4">
|
||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-400">Framing mode</h2>
|
||
<div className="space-y-3">
|
||
<div className="space-y-1">
|
||
<label className="text-xs uppercase tracking-wide text-slate-400">Mode</label>
|
||
<select
|
||
value={framingMode}
|
||
onChange={(event) => setFramingMode(event.target.value)}
|
||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-sm focus:border-accent focus:outline-none"
|
||
>
|
||
{framingOptions.map((option) => (
|
||
<option key={option.value} value={option.value}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{(framingMode === FramingModes.FIXED_DISTANCE || framingMode === FramingModes.CUSTOM) && (
|
||
<div className="space-y-1">
|
||
<label className="flex items-center justify-between text-xs uppercase tracking-wide text-slate-400">
|
||
<span>{framingMode === FramingModes.FIXED_DISTANCE ? 'Subject distance' : 'Custom subject distance'}</span>
|
||
<span className="text-slate-200">{formatNumber(distanceValue, 2)} m</span>
|
||
</label>
|
||
<input
|
||
type="range"
|
||
min="0.5"
|
||
max="15"
|
||
step="0.1"
|
||
value={distanceValue}
|
||
onChange={(event) => {
|
||
const value = Number.parseFloat(event.target.value);
|
||
setCustomSubjectDistance(value);
|
||
setFixedSubjectDistance(value);
|
||
}}
|
||
className="w-full"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{(framingMode === FramingModes.MATCH_HEIGHT || framingMode === FramingModes.MATCH_DIAGONAL || framingMode === FramingModes.CUSTOM) && (
|
||
<>
|
||
<div className="space-y-1">
|
||
<label className="flex items-center justify-between text-xs uppercase tracking-wide text-slate-400">
|
||
<span>Subject height</span>
|
||
<span className="text-slate-200">{formatNumber(subjectHeight, 2)} m</span>
|
||
</label>
|
||
<input
|
||
type="range"
|
||
min="0.5"
|
||
max="2.5"
|
||
step="0.01"
|
||
value={subjectHeight}
|
||
onChange={(event) => setSubjectHeight(Number.parseFloat(event.target.value))}
|
||
className="w-full"
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<label className="flex items-center justify-between text-xs uppercase tracking-wide text-slate-400">
|
||
<span>Frame fill</span>
|
||
<span className="text-slate-200">{formatNumber(frameFill * 100, 0)}%</span>
|
||
</label>
|
||
<input
|
||
type="range"
|
||
min="0.2"
|
||
max="0.95"
|
||
step="0.01"
|
||
value={frameFill}
|
||
onChange={(event) => setFrameFill(Number.parseFloat(event.target.value))}
|
||
className="w-full"
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{framingMode === FramingModes.CUSTOM && (
|
||
<div className="space-y-1">
|
||
<label className="text-xs uppercase tracking-wide text-slate-400">Custom framing dimension</label>
|
||
<select
|
||
value={customDimension}
|
||
onChange={(event) => setCustomDimension(event.target.value)}
|
||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-sm focus:border-accent focus:outline-none"
|
||
>
|
||
<option value="height">Match frame height</option>
|
||
<option value="diagonal">Match frame diagonal</option>
|
||
</select>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const OpticsPanel = ({
|
||
foregroundRange,
|
||
setForegroundRange,
|
||
maxSeparation,
|
||
setMaxSeparation,
|
||
checkpointsInput,
|
||
setCheckpointsInput,
|
||
foregroundRangeLimit,
|
||
maxSeparationLimit,
|
||
}) => (
|
||
<div className="rounded-lg border border-slate-800 bg-slate-900/60 p-4">
|
||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-400">Optics</h2>
|
||
<div className="space-y-3">
|
||
<div className="space-y-1">
|
||
<label className="flex items-center justify-between text-xs uppercase tracking-wide text-slate-400">
|
||
<span>Foreground range</span>
|
||
<span className="text-slate-200">{formatNumber(foregroundRange, 1)} m</span>
|
||
</label>
|
||
<input
|
||
type="range"
|
||
min="0"
|
||
max={foregroundRangeLimit}
|
||
step="0.1"
|
||
value={foregroundRange}
|
||
onChange={(event) => setForegroundRange(Number.parseFloat(event.target.value))}
|
||
className="w-full"
|
||
/>
|
||
<div className="text-xs text-slate-500">Shared span: 0 – {formatNumber(foregroundRangeLimit, 1)} m</div>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<label className="flex items-center justify-between text-xs uppercase tracking-wide text-slate-400">
|
||
<span>Max background range</span>
|
||
<span className="text-slate-200">{formatNumber(maxSeparation, 1)} m</span>
|
||
</label>
|
||
<input
|
||
type="range"
|
||
min="1"
|
||
max={maxSeparationLimit}
|
||
step="1"
|
||
value={maxSeparation}
|
||
onChange={(event) => setMaxSeparation(Number.parseFloat(event.target.value))}
|
||
className="w-full"
|
||
/>
|
||
<div className="text-xs text-slate-500">Shared span: 0 – {formatNumber(maxSeparationLimit, 0)} m</div>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<label className="text-xs uppercase tracking-wide text-slate-400">Stats checkpoints (m, comma separated)</label>
|
||
<input
|
||
type="text"
|
||
value={checkpointsInput}
|
||
onChange={(event) => 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"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
|
||
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 (
|
||
<div className="min-h-screen bg-slate-950 text-slate-100">
|
||
<header className="border-b border-slate-900/70 bg-slate-950/90 px-6 py-4 backdrop-blur">
|
||
<div className="flex w-full flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-semibold text-slate-100">Lens & Sensor Blur Comparison</h1>
|
||
<p className="text-sm text-slate-400">
|
||
Explore blur radius as % of sensor diagonal across lens and sensor combinations.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="flex min-h-[calc(100vh-4.5rem)] w-full flex-col lg:flex-row">
|
||
<aside className="w-full border-b border-slate-900/60 bg-slate-950/60 lg:w-72 lg:border-b-0 lg:border-r">
|
||
<ControlsPanel
|
||
selectedLensIds={selectedLensIds}
|
||
toggleLensSelection={toggleLensSelection}
|
||
activeLensId={activeLensId}
|
||
setActiveLensId={setActiveLensId}
|
||
lensSensorFilter={lensSensorFilter}
|
||
setLensSensorFilter={setLensSensorFilter}
|
||
sensorFilterOptions={sensorFilterOptions}
|
||
applyLensPreset={applyLensPreset}
|
||
/>
|
||
</aside>
|
||
|
||
<main className="flex flex-1 min-h-0 flex-col bg-slate-950/40 overflow-hidden">
|
||
<PlotSection
|
||
lensCurves={lensCurves}
|
||
plotType={plotType}
|
||
setPlotType={setPlotType}
|
||
previewDistanceMeters={previewDistanceMeters}
|
||
/>
|
||
|
||
<div className="space-y-6 px-4 pb-6 lg:hidden">
|
||
<PreviewPanel lensCurves={lensCurves} previewDistanceMeters={previewDistanceMeters} />
|
||
<PreviewDistanceControl
|
||
value={previewDistance}
|
||
onChange={setPreviewDistance}
|
||
max={PREVIEW_DISTANCE_MAX}
|
||
/>
|
||
<FramingPanel
|
||
framingMode={framingMode}
|
||
setFramingMode={setFramingMode}
|
||
subjectHeight={subjectHeight}
|
||
setSubjectHeight={setSubjectHeight}
|
||
frameFill={frameFill}
|
||
setFrameFill={setFrameFill}
|
||
fixedSubjectDistance={fixedSubjectDistance}
|
||
setFixedSubjectDistance={setFixedSubjectDistance}
|
||
customSubjectDistance={customSubjectDistance}
|
||
setCustomSubjectDistance={setCustomSubjectDistance}
|
||
customDimension={customDimension}
|
||
setCustomDimension={setCustomDimension}
|
||
/>
|
||
|
||
<OpticsPanel
|
||
foregroundRange={foregroundRange}
|
||
setForegroundRange={setForegroundRange}
|
||
maxSeparation={maxSeparation}
|
||
setMaxSeparation={setMaxSeparation}
|
||
checkpointsInput={checkpointsInput}
|
||
setCheckpointsInput={setCheckpointsInput}
|
||
foregroundRangeLimit={foregroundRangeLimit}
|
||
maxSeparationLimit={maxSeparationLimit}
|
||
/>
|
||
</div>
|
||
|
||
<div className="px-4 pb-6">
|
||
<div className="space-y-3">
|
||
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-400">Blur check points</h2>
|
||
<StatsTable lensStats={statsPayload} checkpoints={checkpoints} />
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
<aside className="hidden w-full max-w-xs border-l border-slate-900/60 bg-slate-950/60 lg:block">
|
||
<div className="space-y-6 px-4 py-6">
|
||
<PreviewPanel lensCurves={lensCurves} previewDistanceMeters={previewDistanceMeters} />
|
||
<PreviewDistanceControl
|
||
value={previewDistance}
|
||
onChange={setPreviewDistance}
|
||
max={PREVIEW_DISTANCE_MAX}
|
||
/>
|
||
<FramingPanel
|
||
framingMode={framingMode}
|
||
setFramingMode={setFramingMode}
|
||
subjectHeight={subjectHeight}
|
||
setSubjectHeight={setSubjectHeight}
|
||
frameFill={frameFill}
|
||
setFrameFill={setFrameFill}
|
||
fixedSubjectDistance={fixedSubjectDistance}
|
||
setFixedSubjectDistance={setFixedSubjectDistance}
|
||
customSubjectDistance={customSubjectDistance}
|
||
setCustomSubjectDistance={setCustomSubjectDistance}
|
||
customDimension={customDimension}
|
||
setCustomDimension={setCustomDimension}
|
||
/>
|
||
|
||
<OpticsPanel
|
||
foregroundRange={foregroundRange}
|
||
setForegroundRange={setForegroundRange}
|
||
maxSeparation={maxSeparation}
|
||
setMaxSeparation={setMaxSeparation}
|
||
checkpointsInput={checkpointsInput}
|
||
setCheckpointsInput={setCheckpointsInput}
|
||
foregroundRangeLimit={foregroundRangeLimit}
|
||
maxSeparationLimit={maxSeparationLimit}
|
||
/>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
|
||
<footer className="border-t border-slate-900/70 bg-slate-950/80 px-6 py-3 text-xs text-slate-500">
|
||
<p>
|
||
Questions or feedback? Reach out at{' '}
|
||
<a className="text-accent hover:underline" href="mailto:lensblur@posteo.de">
|
||
lensblur@posteo.de
|
||
</a>
|
||
.
|
||
</p>
|
||
</footer>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default App;
|