Initialize project without preview renderer

This commit is contained in:
2025-09-28 23:30:02 +02:00
commit 2852c88a77
16 changed files with 11459 additions and 0 deletions
+706
View File
@@ -0,0 +1,706 @@
import React, { useMemo, useState, useCallback, useEffect } from 'react';
import Plot from 'react-plotly.js';
import { findSensorById } from './data/sensors.js';
import { defaultLenses } from './data/lenses.js';
import {
FramingModes,
PlotTypes,
buildBlurDataset,
buildSeparationSamples,
buildStatsAtCheckpoints,
computeSubjectDistance,
} from './utils/optics.js';
const COLOR_PALETTE = ['#38bdf8', '#a855f7', '#f97316', '#22c55e', '#e11d48', '#6366f1', '#14b8a6', '#f59e0b'];
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 };
case PlotTypes.ZOOMED:
return { label: 'Scene distance (m)', accessor: (point) => point.sceneDistance };
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 };
case PlotTypes.DIOPTER:
return { label: 'Diopter difference (1/m)', accessor: (point) => point.diopterDifference };
default:
return { label: 'Scene distance (m)', accessor: (point) => point.sceneDistance };
}
};
const filterDatasetForPlotType = (dataset, plotType) => {
if (plotType === PlotTypes.ZOOMED) {
return dataset.filter((point) => Math.abs(point.focusOffset) <= 3);
}
return dataset;
};
const PlotSection = ({ lensCurves, plotType, setPlotType }) => {
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);
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[index % COLOR_PALETTE.length],
},
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))
);
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)',
},
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],
},
}}
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,
}) => {
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="flex flex-col gap-2">
{defaultLenses.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(defaultLenses.slice(0, 3).map((lens) => lens.id));
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 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 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,
};
})
.filter(Boolean);
}, [selectedLenses, framingMode, fixedSubjectDistance, subjectHeight, frameFill, customSubjectDistance, customDimension, separationSamples, checkpoints]);
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}
/>
</aside>
<main className="flex flex-1 min-h-0 flex-col bg-slate-950/40 overflow-hidden">
<PlotSection
lensCurves={lensCurves}
plotType={plotType}
setPlotType={setPlotType}
/>
<div className="space-y-6 px-4 pb-6 lg:hidden">
<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">
<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>
</div>
);
};
export default App;