Files
lenssim/src/App.jsx
T
nils 43d028a41f
Build & Deploy / build (push) Successful in 2m41s
Build & Deploy / deploy (push) Successful in 19s
Add lens presets and contact footer
2025-10-05 12:52:09 +02:00

1100 lines
41 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;