Compare commits

...
8 Commits
Author SHA1 Message Date
nils 21dcdee1f0 Rename blur labels to diameter
Build & Deploy / build (push) Successful in 2m33s
Build & Deploy / deploy (push) Successful in 41s
2025-10-05 13:39:59 +02:00
nils 39afb5c9e0 Align preview blur metric with plot 2025-10-05 13:33:32 +02:00
nils 43d028a41f Add lens presets and contact footer
Build & Deploy / build (push) Successful in 2m41s
Build & Deploy / deploy (push) Successful in 19s
2025-10-05 12:52:09 +02:00
nils 7847406b10 Add medium-format lens updates and preview controls
Build & Deploy / build (push) Successful in 2m38s
Build & Deploy / deploy (push) Successful in 22s
2025-10-05 11:31:09 +02:00
nils 607e2f6667 Normalize bokeh preview sizing
Build & Deploy / build (push) Successful in 2m32s
Build & Deploy / deploy (push) Successful in 20s
2025-09-29 10:55:18 +02:00
nils 94ee8a16fb Add lens bokeh preview panel
Build & Deploy / build (push) Successful in 2m28s
Build & Deploy / deploy (push) Successful in 16s
2025-09-29 10:14:00 +02:00
nils eb8d699afe Stabilize lens color assignments 2025-09-29 10:07:33 +02:00
nils 8c9a8c390a Install aws cli
Build & Deploy / build (push) Successful in 2m32s
Build & Deploy / deploy (push) Successful in 41s
2025-09-29 07:44:59 +02:00
6 changed files with 612 additions and 31 deletions
+31 -16
View File
@@ -37,25 +37,40 @@ jobs:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_REGION }} aws-region: ${{ secrets.AWS_REGION }}
- name: Install AWS CLI
run: |
set -euo pipefail
case "$(uname -m)" in
x86_64|amd64) AWS_ARCH="x86_64" ;;
aarch64|arm64) AWS_ARCH="aarch64" ;;
*) echo "Unsupported arch: $(uname -m)" >&2; exit 1 ;;
esac
curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-${AWS_ARCH}.zip" -o awscliv2.zip
unzip -q awscliv2.zip
sudo ./aws/install --update -i /usr/local/aws-cli -b /usr/local/bin
aws --version
- name: Sync hashed assets to S3 - name: Sync hashed assets to S3
uses: aws-actions/aws-cli-action@v2 env:
with: BUCKET_NAME: ${{ secrets.BUCKET_NAME }}
command: >- run: |
s3 sync dist/ "s3://${{ secrets.BUCKET_NAME }}" aws s3 sync dist/ "s3://${BUCKET_NAME}" \
--delete --delete \
--exclude "index.html" --exclude "index.html" \
--cache-control "public, max-age=31536000, immutable" --cache-control "public, max-age=31536000, immutable"
- name: Upload index.html - name: Upload index.html
uses: aws-actions/aws-cli-action@v2 env:
with: BUCKET_NAME: ${{ secrets.BUCKET_NAME }}
command: >- run: |
s3 cp dist/index.html "s3://${{ secrets.BUCKET_NAME }}/index.html" aws s3 cp dist/index.html "s3://${BUCKET_NAME}/index.html" \
--cache-control "no-cache, no-store, must-revalidate" --cache-control "no-cache, no-store, must-revalidate" \
--content-type "text/html" --content-type "text/html"
- name: Invalidate CloudFront - name: Invalidate CloudFront
uses: aws-actions/aws-cli-action@v2 env:
with: CLOUDFRONT_ID: ${{ secrets.CLOUDFRONT_ID }}
command: >- run: |
cloudfront create-invalidation aws cloudfront create-invalidation \
--distribution-id "${{ secrets.CLOUDFRONT_ID }}" --distribution-id "${CLOUDFRONT_ID}" \
--paths "/" "/index.html" --paths "/" "/index.html"
+403 -9
View File
@@ -1,7 +1,12 @@
import React, { useMemo, useState, useCallback, useEffect } from 'react'; import React, { useMemo, useState, useCallback, useEffect } from 'react';
import Plot from 'react-plotly.js'; import Plot from 'react-plotly.js';
import { findSensorById } from './data/sensors.js'; import { defaultSensors, findSensorById } from './data/sensors.js';
import { defaultLenses } from './data/lenses.js'; import { defaultLenses } from './data/lenses.js';
import {
selectNormalLensIdsForSensors,
selectWideLensIdsForSensors,
selectPortraitLensIdsForSensors,
} from './utils/lensSelection.js';
import { import {
FramingModes, FramingModes,
PlotTypes, PlotTypes,
@@ -12,6 +17,77 @@ import {
} from './utils/optics.js'; } from './utils/optics.js';
const COLOR_PALETTE = ['#38bdf8', '#a855f7', '#f97316', '#22c55e', '#e11d48', '#6366f1', '#14b8a6', '#f59e0b']; 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 = [ const plotTypeOptions = [
{ value: PlotTypes.DISTANCE, label: 'Absolute scene distance' }, { value: PlotTypes.DISTANCE, label: 'Absolute scene distance' },
@@ -73,13 +149,126 @@ const filterDatasetForPlotType = (dataset, plotType) => {
return dataset; return dataset;
}; };
const PlotSection = ({ lensCurves, plotType, setPlotType }) => { 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;
const blurPercent = stat?.blurPercentOfDiagonal ??
((curve.sensor?.diagonal ?? 0) > 0 ? (blurMm / curve.sensor.diagonal) * 100 : 0);
return {
lens: curve.lens,
blurMm,
blurPercent,
sensorDiagonal: curve.sensor?.diagonal ?? 1,
colorIndex: curve.colorIndex,
};
});
const maxBlurPercent = previewItems.reduce((maxValue, item) => Math.max(maxValue, item.blurPercent), 0);
const denominator = maxBlurPercent > 0 ? maxBlurPercent : 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.blurPercent / 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.blurPercent, 2)}% diag
</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 axisConfig = getAxisConfig(plotType);
const plotData = lensCurves.map((curve, index) => { const plotData = lensCurves.map((curve, index) => {
const filteredDataset = filterDatasetForPlotType(curve.dataset, plotType); const filteredDataset = filterDatasetForPlotType(curve.dataset, plotType);
const xValues = filteredDataset.map(axisConfig.accessor); const xValues = filteredDataset.map(axisConfig.accessor);
const yValues = filteredDataset.map((point) => point.blurPercentOfDiagonal); const yValues = filteredDataset.map((point) => point.blurPercentOfDiagonal);
const paletteIndex =
typeof curve.colorIndex === 'number' ? curve.colorIndex % COLOR_PALETTE.length : index % COLOR_PALETTE.length;
return { return {
x: xValues, x: xValues,
@@ -96,7 +285,7 @@ const PlotSection = ({ lensCurves, plotType, setPlotType }) => {
name: `${curve.lens.name} (${formatAperture(curve.apertureUsed)})`, name: `${curve.lens.name} (${formatAperture(curve.apertureUsed)})`,
line: { line: {
width: 2, width: 2,
color: COLOR_PALETTE[index % COLOR_PALETTE.length], 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>`, 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), text: Array(filteredDataset.length).fill(curve.lens.name),
@@ -108,12 +297,34 @@ const PlotSection = ({ lensCurves, plotType, setPlotType }) => {
...lensCurves.flatMap((curve) => curve.dataset.map((point) => point.blurPercentOfDiagonal)) ...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 ( return (
<div className="flex flex-col gap-4 p-4"> <div className="flex flex-col gap-4 p-4">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div> <div>
<h2 className="text-lg font-semibold text-slate-100">Blur comparison</h2> <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> <p className="text-xs uppercase tracking-wide text-slate-400">Blur diameter as % of sensor diagonal</p>
</div> </div>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<select <select
@@ -147,13 +358,14 @@ const PlotSection = ({ lensCurves, plotType, setPlotType }) => {
...(axisConfig.ticksuffix ? { ticksuffix: axisConfig.ticksuffix } : {}), ...(axisConfig.ticksuffix ? { ticksuffix: axisConfig.ticksuffix } : {}),
}, },
yaxis: { yaxis: {
title: 'Blur radius (% of sensor diagonal)', title: 'Blur diameter (% of sensor diagonal)',
type: 'linear', type: 'linear',
gridcolor: 'rgba(148, 163, 184, 0.2)', gridcolor: 'rgba(148, 163, 184, 0.2)',
zerolinecolor: 'rgba(148, 163, 184, 0.4)', zerolinecolor: 'rgba(148, 163, 184, 0.4)',
range: [0, maxY * 1.1], range: [0, maxY * 1.1],
ticksuffix: ' %', ticksuffix: ' %',
}, },
shapes: previewLineShape,
}} }}
useResizeHandler useResizeHandler
style={{ width: '100%', height: '100%' }} style={{ width: '100%', height: '100%' }}
@@ -172,14 +384,114 @@ const ControlsPanel = ({
toggleLensSelection, toggleLensSelection,
activeLensId, activeLensId,
setActiveLensId, 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 ( return (
<div className="space-y-6 p-4"> <div className="space-y-6 p-4">
<section> <section>
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-400 mb-2">Lenses & sensors</h2> <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> <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"> <div className="flex flex-col gap-2">
{defaultLenses.map((lens) => { {lensesToRender.map((lens) => {
const isSelected = selectedLensIds.includes(lens.id); const isSelected = selectedLensIds.includes(lens.id);
const isActive = activeLensId === lens.id; const isActive = activeLensId === lens.id;
const sensor = findSensorById(lens.sensorId); const sensor = findSensorById(lens.sensorId);
@@ -453,7 +765,8 @@ const OpticsPanel = ({
const App = () => { const App = () => {
const [selectedLensIds, setSelectedLensIds] = useState(defaultLenses.slice(0, 3).map((lens) => lens.id)); const [selectedLensIds, setSelectedLensIds] = useState(INITIAL_SELECTED_LENS_IDS);
const [lensSensorFilter, setLensSensorFilter] = useState('all');
const [framingMode, setFramingMode] = useState(FramingModes.MATCH_HEIGHT); const [framingMode, setFramingMode] = useState(FramingModes.MATCH_HEIGHT);
const [subjectHeight, setSubjectHeight] = useState(1.7); const [subjectHeight, setSubjectHeight] = useState(1.7);
const [frameFill, setFrameFill] = useState(0.75); const [frameFill, setFrameFill] = useState(0.75);
@@ -467,6 +780,18 @@ const App = () => {
const [maxSeparationLimit, setMaxSeparationLimit] = useState(80); const [maxSeparationLimit, setMaxSeparationLimit] = useState(80);
const [checkpointsInput, setCheckpointsInput] = useState('3,6,10'); const [checkpointsInput, setCheckpointsInput] = useState('3,6,10');
const [activeLensId, setActiveLensId] = useState(null); 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 checkpoints = useMemo(() => {
const userValues = parseCheckpointInput(checkpointsInput); const userValues = parseCheckpointInput(checkpointsInput);
@@ -492,6 +817,47 @@ const App = () => {
}); });
}, []); }, []);
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(() => { const lensCurves = useMemo(() => {
return selectedLenses return selectedLenses
.map((lens) => { .map((lens) => {
@@ -536,10 +902,11 @@ const App = () => {
dataset, dataset,
checkpointStats, checkpointStats,
entrancePupil: checkpointStats[0]?.entrancePupil ?? 0, entrancePupil: checkpointStats[0]?.entrancePupil ?? 0,
colorIndex: lensColors[lens.id] ?? 0,
}; };
}) })
.filter(Boolean); .filter(Boolean);
}, [selectedLenses, framingMode, fixedSubjectDistance, subjectHeight, frameFill, customSubjectDistance, customDimension, separationSamples, checkpoints]); }, [selectedLenses, framingMode, fixedSubjectDistance, subjectHeight, frameFill, customSubjectDistance, customDimension, separationSamples, checkpoints, lensColors]);
const statsPayload = lensCurves.map((curve) => ({ const statsPayload = lensCurves.map((curve) => ({
lens: curve.lens, lens: curve.lens,
@@ -612,7 +979,7 @@ const App = () => {
<div> <div>
<h1 className="text-2xl font-semibold text-slate-100">Lens & Sensor Blur Comparison</h1> <h1 className="text-2xl font-semibold text-slate-100">Lens & Sensor Blur Comparison</h1>
<p className="text-sm text-slate-400"> <p className="text-sm text-slate-400">
Explore blur radius as % of sensor diagonal across lens and sensor combinations. Explore blur diameter as % of sensor diagonal across lens and sensor combinations.
</p> </p>
</div> </div>
</div> </div>
@@ -625,6 +992,10 @@ const App = () => {
toggleLensSelection={toggleLensSelection} toggleLensSelection={toggleLensSelection}
activeLensId={activeLensId} activeLensId={activeLensId}
setActiveLensId={setActiveLensId} setActiveLensId={setActiveLensId}
lensSensorFilter={lensSensorFilter}
setLensSensorFilter={setLensSensorFilter}
sensorFilterOptions={sensorFilterOptions}
applyLensPreset={applyLensPreset}
/> />
</aside> </aside>
@@ -633,9 +1004,16 @@ const App = () => {
lensCurves={lensCurves} lensCurves={lensCurves}
plotType={plotType} plotType={plotType}
setPlotType={setPlotType} setPlotType={setPlotType}
previewDistanceMeters={previewDistanceMeters}
/> />
<div className="space-y-6 px-4 pb-6 lg:hidden"> <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 <FramingPanel
framingMode={framingMode} framingMode={framingMode}
setFramingMode={setFramingMode} setFramingMode={setFramingMode}
@@ -673,6 +1051,12 @@ const App = () => {
<aside className="hidden w-full max-w-xs border-l border-slate-900/60 bg-slate-950/60 lg:block"> <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"> <div className="space-y-6 px-4 py-6">
<PreviewPanel lensCurves={lensCurves} previewDistanceMeters={previewDistanceMeters} />
<PreviewDistanceControl
value={previewDistance}
onChange={setPreviewDistance}
max={PREVIEW_DISTANCE_MAX}
/>
<FramingPanel <FramingPanel
framingMode={framingMode} framingMode={framingMode}
setFramingMode={setFramingMode} setFramingMode={setFramingMode}
@@ -701,6 +1085,16 @@ const App = () => {
</div> </div>
</aside> </aside>
</div> </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> </div>
); );
}; };
+17 -1
View File
@@ -34,12 +34,28 @@ export const defaultLenses = [
{ id: 'fujifilm_500_56', brand: 'Fujifilm', name: 'XF 500mm f/5.6', focalLength: 500, maxAperture: 5.6, sensorId: 'aps_c_fuji' }, { id: 'fujifilm_500_56', brand: 'Fujifilm', name: 'XF 500mm f/5.6', focalLength: 500, maxAperture: 5.6, sensorId: 'aps_c_fuji' },
{ id: 'voigtlander_35_09', brand: 'Voigtländer', name: 'Nokton 35mm f/0.9 (APS-C)', focalLength: 35, maxAperture: 0.9, sensorId: 'aps_c_fuji' }, { id: 'voigtlander_35_09', brand: 'Voigtländer', name: 'Nokton 35mm f/0.9 (APS-C)', focalLength: 35, maxAperture: 0.9, sensorId: 'aps_c_fuji' },
// GFX (44×33) primes // 44×33 medium format primes
{ id: 'fuji_gf_23_4', brand: 'Fujifilm', name: 'GF 23mm f/4 R LM WR', focalLength: 23, maxAperture: 4, sensorId: 'gfx_44x33' }, { id: 'fuji_gf_23_4', brand: 'Fujifilm', name: 'GF 23mm f/4 R LM WR', focalLength: 23, maxAperture: 4, sensorId: 'gfx_44x33' },
{ id: 'fuji_gf_55_17', brand: 'Fujifilm', name: 'GF 55mm f/1.7 R WR', focalLength: 55, maxAperture: 1.7, sensorId: 'gfx_44x33' }, { id: 'fuji_gf_55_17', brand: 'Fujifilm', name: 'GF 55mm f/1.7 R WR', focalLength: 55, maxAperture: 1.7, sensorId: 'gfx_44x33' },
{ id: 'fuji_gf_63_28', brand: 'Fujifilm', name: 'GF 63mm f/2.8 R WR', focalLength: 63, maxAperture: 2.8, sensorId: 'gfx_44x33' }, { id: 'fuji_gf_63_28', brand: 'Fujifilm', name: 'GF 63mm f/2.8 R WR', focalLength: 63, maxAperture: 2.8, sensorId: 'gfx_44x33' },
{ id: 'fuji_gf_80_17', brand: 'Fujifilm', name: 'GF 80mm f/1.7 R WR', focalLength: 80, maxAperture: 1.7, sensorId: 'gfx_44x33' }, { id: 'fuji_gf_80_17', brand: 'Fujifilm', name: 'GF 80mm f/1.7 R WR', focalLength: 80, maxAperture: 1.7, sensorId: 'gfx_44x33' },
{ id: 'fuji_gf_110_2', brand: 'Fujifilm', name: 'GF 110mm f/2 R LM WR', focalLength: 110, maxAperture: 2, sensorId: 'gfx_44x33' }, { id: 'fuji_gf_110_2', brand: 'Fujifilm', name: 'GF 110mm f/2 R LM WR', focalLength: 110, maxAperture: 2, sensorId: 'gfx_44x33' },
{ id: 'mitakon_65_14', brand: 'Mitakon', name: 'Mitakon Speedmaster 65mm f/1.4', focalLength: 65, maxAperture: 1.4, sensorId: 'gfx_44x33' },
{ id: 'mitakon_80_16', brand: 'Mitakon', name: 'Mitakon Speedmaster 80mm f/1.6', focalLength: 80, maxAperture: 1.6, sensorId: 'gfx_44x33' },
// Hasselblad XCD primes
{ id: 'hasselblad_xcd_25_25', brand: 'Hasselblad', name: 'XCD 25mm f/2.5', focalLength: 25, maxAperture: 2.5, sensorId: 'gfx_44x33' },
{ id: 'hasselblad_xcd_38_25', brand: 'Hasselblad', name: 'XCD 38mm f/2.5', focalLength: 38, maxAperture: 2.5, sensorId: 'gfx_44x33' },
{ id: 'hasselblad_xcd_55_25', brand: 'Hasselblad', name: 'XCD 55mm f/2.5', focalLength: 55, maxAperture: 2.5, sensorId: 'gfx_44x33' },
{ id: 'hasselblad_xcd_80_19', brand: 'Hasselblad', name: 'XCD 80mm f/1.9', focalLength: 80, maxAperture: 1.9, sensorId: 'gfx_44x33' },
{ id: 'hasselblad_xcd_90_25', brand: 'Hasselblad', name: 'XCD 90mm f/2.5', focalLength: 90, maxAperture: 2.5, sensorId: 'gfx_44x33' },
// Phase One (XF) primes
{ id: 'phaseone_sk_80_28_ls', brand: 'Schneider Kreuznach', name: 'Schneider Kreuznach 80mm LS f/2.8', focalLength: 80, maxAperture: 2.8, sensorId: 'phase_one_645' },
{ id: 'phaseone_sk_110_28_ls', brand: 'Schneider Kreuznach', name: 'Schneider Kreuznach 110mm LS f/2.8', focalLength: 110, maxAperture: 2.8, sensorId: 'phase_one_645' },
{ id: 'phaseone_sk_120_4_ls_macro', brand: 'Schneider Kreuznach', name: 'Schneider Kreuznach 120mm LS Macro f/4', focalLength: 120, maxAperture: 4, sensorId: 'phase_one_645' },
{ id: 'phaseone_sk_150_28_ls_if', brand: 'Schneider Kreuznach', name: 'Schneider Kreuznach 150mm LS f/2.8 IF', focalLength: 150, maxAperture: 2.8, sensorId: 'phase_one_645' },
{ id: 'phaseone_sk_240_45_ls_if', brand: 'Schneider Kreuznach', name: 'Schneider Kreuznach 240mm LS f/4.5 IF', focalLength: 240, maxAperture: 4.5, sensorId: 'phase_one_645' },
]; ];
export const findLensById = (lensId, lenses = defaultLenses) => export const findLensById = (lensId, lenses = defaultLenses) =>
+12 -1
View File
@@ -7,6 +7,7 @@ const baseSensors = [
width: 36, width: 36,
height: 24, height: 24,
aspectRatio: '3:2', aspectRatio: '3:2',
shortLabel: 'FF',
}, },
{ {
id: 'aps_c_fuji', id: 'aps_c_fuji',
@@ -14,13 +15,23 @@ const baseSensors = [
width: 23.5, width: 23.5,
height: 15.6, height: 15.6,
aspectRatio: '3:2', aspectRatio: '3:2',
shortLabel: 'APS-C',
}, },
{ {
id: 'gfx_44x33', id: 'gfx_44x33',
name: 'GFX Medium Format 44×33', name: 'Medium Format 44×33',
width: 44, width: 44,
height: 33, height: 33,
aspectRatio: '4:3', aspectRatio: '4:3',
shortLabel: '44×33',
},
{
id: 'phase_one_645',
name: 'Phase One 645 53.7×40.4',
width: 53.7,
height: 40.4,
aspectRatio: '4:3',
shortLabel: 'P1',
}, },
]; ];
+138
View File
@@ -0,0 +1,138 @@
export const NORMAL_RATIO_RANGE = {
min: 0.7,
max: 1.5,
};
export const scoreLensForNormalView = (lens, sensor, ratioRange = NORMAL_RATIO_RANGE) => {
const aperture = Number.isFinite(lens?.maxAperture) ? lens.maxAperture : Infinity;
const sensorDiagonal = Number.isFinite(sensor?.diagonal) ? sensor.diagonal : Infinity;
const cropFactor = Number.isFinite(sensor?.cropFactor) ? sensor.cropFactor : 1;
const delta = Number.isFinite(sensorDiagonal) ? Math.abs(lens.focalLength - sensorDiagonal) : Infinity;
const ratio = sensorDiagonal > 0 ? lens.focalLength / sensorDiagonal : Infinity;
const withinNormalBand = ratio >= ratioRange.min && ratio <= ratioRange.max;
const effectiveFocalLength = lens.focalLength * cropFactor;
return {
lens,
aperture,
delta,
ratio,
withinNormalBand,
cropFactor,
effectiveFocalLength,
};
};
const groupLensesBySensor = (lenses) =>
lenses.reduce((acc, lens) => {
if (!acc[lens.sensorId]) {
acc[lens.sensorId] = [];
}
acc[lens.sensorId].push(lens);
return acc;
}, {});
export const selectNormalLensForSensor = (lenses, sensor, ratioRange = NORMAL_RATIO_RANGE) => {
if (!Array.isArray(lenses) || lenses.length === 0 || !sensor) {
return null;
}
const scored = lenses.map((lens) => scoreLensForNormalView(lens, sensor, ratioRange));
const normalCandidates = scored.filter((entry) => entry.withinNormalBand);
const candidates = normalCandidates.length > 0 ? normalCandidates : scored;
candidates.sort((a, b) => {
if (a.aperture !== b.aperture) {
return a.aperture - b.aperture;
}
if (a.delta !== b.delta) {
return a.delta - b.delta;
}
return a.lens.focalLength - b.lens.focalLength;
});
return candidates[0]?.lens ?? null;
};
export const selectWideLensForSensor = (lenses, sensor, ratioRange = NORMAL_RATIO_RANGE) => {
if (!Array.isArray(lenses) || lenses.length === 0 || !sensor) {
return null;
}
const scored = lenses.map((lens) => scoreLensForNormalView(lens, sensor, ratioRange));
const wideCandidates = scored.filter((entry) => entry.ratio < ratioRange.min);
const candidates = wideCandidates.length > 0 ? wideCandidates : scored;
candidates.sort((a, b) => {
if (a.ratio !== b.ratio) {
return a.ratio - b.ratio;
}
if (a.aperture !== b.aperture) {
return a.aperture - b.aperture;
}
return a.lens.focalLength - b.lens.focalLength;
});
return candidates[0]?.lens ?? null;
};
export const selectPortraitLensForSensor = (lenses, sensor, ratioRange = NORMAL_RATIO_RANGE) => {
if (!Array.isArray(lenses) || lenses.length === 0 || !sensor) {
return null;
}
const PORTRAIT_TARGET_EQUIV_MM = 85;
const PORTRAIT_MIN_EQUIV_MM = 70;
const scored = lenses.map((lens) => scoreLensForNormalView(lens, sensor, ratioRange));
const portraitCandidates = scored.filter((entry) => entry.effectiveFocalLength >= PORTRAIT_MIN_EQUIV_MM);
const candidates = portraitCandidates.length > 0 ? portraitCandidates : scored;
candidates.sort((a, b) => {
const deltaA = Math.abs(a.effectiveFocalLength - PORTRAIT_TARGET_EQUIV_MM);
const deltaB = Math.abs(b.effectiveFocalLength - PORTRAIT_TARGET_EQUIV_MM);
if (deltaA !== deltaB) {
return deltaA - deltaB;
}
if (a.aperture !== b.aperture) {
return a.aperture - b.aperture;
}
if (a.effectiveFocalLength !== b.effectiveFocalLength) {
return b.effectiveFocalLength - a.effectiveFocalLength;
}
return b.lens.focalLength - a.lens.focalLength;
});
return candidates[0]?.lens ?? null;
};
const selectLensIdsForSensorsBySelector = (sensors, lenses, selector, ratioRange = NORMAL_RATIO_RANGE) => {
if (!Array.isArray(sensors) || sensors.length === 0) {
return [];
}
const lensMap = groupLensesBySensor(lenses);
const selectedIds = sensors
.map((sensor) => {
const sensorLenses = lensMap[sensor.id];
if (!sensorLenses || sensorLenses.length === 0) {
return null;
}
const match = selector(sensorLenses, sensor, ratioRange);
return match?.id ?? null;
})
.filter(Boolean);
return selectedIds.length > 0 ? Array.from(new Set(selectedIds)) : [];
};
export const selectNormalLensIdsForSensors = (sensors, lenses, ratioRange = NORMAL_RATIO_RANGE) =>
selectLensIdsForSensorsBySelector(sensors, lenses, selectNormalLensForSensor, ratioRange);
export const selectWideLensIdsForSensors = (sensors, lenses, ratioRange = NORMAL_RATIO_RANGE) =>
selectLensIdsForSensorsBySelector(sensors, lenses, selectWideLensForSensor, ratioRange);
export const selectPortraitLensIdsForSensors = (sensors, lenses, ratioRange = NORMAL_RATIO_RANGE) =>
selectLensIdsForSensorsBySelector(sensors, lenses, selectPortraitLensForSensor, ratioRange);
+11 -4
View File
@@ -83,13 +83,20 @@ export const calculateBlurCircle = ({ focalLengthMm, aperture, subjectDistanceM,
const safeAperture = coercePositiveNumber(aperture, 1); const safeAperture = coercePositiveNumber(aperture, 1);
const f = focalLengthMm; const f = focalLengthMm;
const u = metersToMillimeters(coercePositiveNumber(subjectDistanceM, 0.5)); const u = metersToMillimeters(coercePositiveNumber(subjectDistanceM, 0.5));
const s = metersToMillimeters(coercePositiveNumber(sceneDistanceM, 0.51));
if (Math.abs(s - u) < 1e-6) { if (Math.abs(u - f) < 1e-6) {
return 0; return 0;
} }
if (Math.abs(u - f) < 1e-6) { if (!Number.isFinite(sceneDistanceM)) {
const denominator = safeAperture * (u - f);
return Math.abs((f ** 2) / denominator);
}
const sceneMeters = Math.max(sceneDistanceM, 0.2);
const s = metersToMillimeters(sceneMeters);
if (Math.abs(s - u) < 1e-6) {
return 0; return 0;
} }
@@ -197,7 +204,7 @@ export const buildStatsAtCheckpoints = ({
sceneDistanceM: Infinity, sceneDistanceM: Infinity,
}); });
} else { } else {
sceneDistance = safeSubjectDistance + separation; sceneDistance = Math.max(safeSubjectDistance + separation, 0.2);
blurMm = calculateBlurCircle({ blurMm = calculateBlurCircle({
focalLengthMm: lens.focalLength, focalLengthMm: lens.focalLength,
aperture, aperture,