Add lens presets and contact footer
This commit is contained in:
+120
-48
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user