Add lens presets and contact footer
Build & Deploy / build (push) Successful in 2m41s
Build & Deploy / deploy (push) Successful in 19s

This commit is contained in:
2025-10-05 12:52:09 +02:00
parent 7847406b10
commit 43d028a41f
2 changed files with 258 additions and 48 deletions
+120 -48
View File
@@ -2,6 +2,11 @@ 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,
@@ -14,53 +19,14 @@ import {
const COLOR_PALETTE = ['#38bdf8', '#a855f7', '#f97316', '#22c55e', '#e11d48', '#6366f1', '#14b8a6', '#f59e0b'];
const PREVIEW_DISTANCE_MIN = 0.5;
const PREVIEW_DISTANCE_MAX = 50;
const NORMAL_RATIO_MIN = 0.7;
const NORMAL_RATIO_MAX = 1.5;
const computeInitialLensIds = () => {
const lensesBySensor = defaultLenses.reduce((acc, lens) => {
if (!acc[lens.sensorId]) {
acc[lens.sensorId] = [];
}
acc[lens.sensorId].push(lens);
return acc;
}, {});
const normalLensIds = selectNormalLensIdsForSensors(defaultSensors, defaultLenses);
const selectedIds = defaultSensors
.map((sensor) => {
const lenses = lensesBySensor[sensor.id];
if (!lenses || lenses.length === 0) {
return null;
}
if (normalLensIds.length > 0) {
return normalLensIds;
}
const scored = lenses.map((lens) => {
const aperture = Number.isFinite(lens.maxAperture) ? lens.maxAperture : Infinity;
const delta = Math.abs(lens.focalLength - sensor.diagonal);
const ratio = sensor.diagonal > 0 ? lens.focalLength / sensor.diagonal : Infinity;
const withinNormalBand = ratio >= NORMAL_RATIO_MIN && ratio <= NORMAL_RATIO_MAX;
return { lens, aperture, delta, withinNormalBand };
});
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.id ?? null;
})
.filter(Boolean);
return selectedIds.length > 0
? Array.from(new Set(selectedIds))
: defaultLenses.slice(0, 3).map((lens) => lens.id);
return defaultLenses.slice(0, 3).map((lens) => lens.id);
};
const INITIAL_SELECTED_LENS_IDS = computeInitialLensIds();
@@ -418,12 +384,44 @@ const ControlsPanel = ({
lensSensorFilter,
setLensSensorFilter,
sensorFilterOptions,
applyLensPreset,
}) => {
const lensesToRender = useMemo(() => {
if (lensSensorFilter === 'all') {
return defaultLenses;
}
return defaultLenses.filter((lens) => lens.sensorId === lensSensorFilter);
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 (
@@ -431,6 +429,32 @@ const ControlsPanel = ({
<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"
@@ -790,6 +814,43 @@ 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]);
@@ -931,6 +992,7 @@ const App = () => {
lensSensorFilter={lensSensorFilter}
setLensSensorFilter={setLensSensorFilter}
sensorFilterOptions={sensorFilterOptions}
applyLensPreset={applyLensPreset}
/>
</aside>
@@ -1020,6 +1082,16 @@ const App = () => {
</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>
);
};
+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);