From 43d028a41f172620774f0e9eb1e8f3c49ac29d06 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Sun, 5 Oct 2025 12:52:09 +0200 Subject: [PATCH] Add lens presets and contact footer --- src/App.jsx | 168 ++++++++++++++++++++++++++----------- src/utils/lensSelection.js | 138 ++++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+), 48 deletions(-) create mode 100644 src/utils/lensSelection.js diff --git a/src/App.jsx b/src/App.jsx index 9da0dfe..59e571e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 = ({

Lenses & sensors

Each lens automatically pairs with its native sensor format for accurate comparisons.

+
+ + + +
+ + ); }; diff --git a/src/utils/lensSelection.js b/src/utils/lensSelection.js new file mode 100644 index 0000000..fbf0cb3 --- /dev/null +++ b/src/utils/lensSelection.js @@ -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);