Initialize project without preview renderer
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# OS cruft
|
||||||
|
.DS_Store
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"sourceType": "unambiguous",
|
||||||
|
"presets": [
|
||||||
|
["@babel/preset-env", { "targets": ">0.25%, not dead", "modules": "commonjs" }],
|
||||||
|
["@babel/preset-react", { "runtime": "automatic" }]
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# --- Configuration -----------------------------------------------------------
|
||||||
|
# Provide your own values via environment variables or edit the defaults below.
|
||||||
|
BUCKET_NAME="${BUCKET_NAME:-lenssim-static-prod}"
|
||||||
|
CLOUDFRONT_ID="${CLOUDFRONT_ID:-EM92WLNGPGOVA}"
|
||||||
|
CLOUDFRONT_DOMAIN="${CLOUDFRONT_DOMAIN:-d3z407nghipey.cloudfront.net}"
|
||||||
|
AWS_REGION="${AWS_REGION:-eu-central-1}"
|
||||||
|
|
||||||
|
if [[ -z "${BUCKET_NAME}" ]]; then
|
||||||
|
echo "❌ Please set BUCKET_NAME (env var or inside deploy.sh) before deploying." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${CLOUDFRONT_ID}" == "YOUR_CLOUDFRONT_DIST_ID" || -z "${CLOUDFRONT_ID}" ]]; then
|
||||||
|
echo "❌ Please set CLOUDFRONT_ID (env var or inside deploy.sh) before deploying." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🚀 Building LensSim SPA..."
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
echo "📦 Syncing hashed assets to s3://${BUCKET_NAME}..."
|
||||||
|
aws s3 sync dist/ s3://${BUCKET_NAME} \
|
||||||
|
--delete \
|
||||||
|
--exclude "index.html" \
|
||||||
|
--cache-control "public, max-age=31536000, immutable"
|
||||||
|
|
||||||
|
echo "🗂 Uploading index.html with no-cache headers..."
|
||||||
|
aws s3 cp dist/index.html s3://${BUCKET_NAME}/index.html \
|
||||||
|
--cache-control "no-cache, no-store, must-revalidate" \
|
||||||
|
--content-type "text/html"
|
||||||
|
|
||||||
|
echo "🔄 Creating CloudFront invalidation for shell files..."
|
||||||
|
aws cloudfront create-invalidation \
|
||||||
|
--distribution-id "${CLOUDFRONT_ID}" \
|
||||||
|
--paths "/" "/index.html" \
|
||||||
|
--output text > /dev/null
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "✅ Deployment finished."
|
||||||
|
echo "🌐 CloudFront URL: https://${CLOUDFRONT_DOMAIN}/"
|
||||||
|
echo "🪣 S3 Website (if enabled): http://${BUCKET_NAME}.s3-website.${AWS_REGION}.amazonaws.com/"
|
||||||
|
echo "⏱️ Note: CloudFront edge caches can take a few minutes to refresh."
|
||||||
Generated
+10227
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "lenssim",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "webpack serve --mode development",
|
||||||
|
"build": "webpack --mode production"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"dependencies": {
|
||||||
|
"plotly.js": "^3.1.0",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1",
|
||||||
|
"react-plotly.js": "^2.6.0",
|
||||||
|
"regl": "^2.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.28.4",
|
||||||
|
"@babel/preset-env": "^7.28.3",
|
||||||
|
"@babel/preset-react": "^7.27.1",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"babel-loader": "^10.0.0",
|
||||||
|
"css-loader": "^7.1.2",
|
||||||
|
"html-webpack-plugin": "^5.6.4",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"postcss-loader": "^8.2.0",
|
||||||
|
"style-loader": "^4.0.0",
|
||||||
|
"tailwindcss": "^3.4.13",
|
||||||
|
"webpack": "^5.101.3",
|
||||||
|
"webpack-cli": "^6.0.1",
|
||||||
|
"webpack-dev-server": "^5.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Lens & Sensor Blur Comparison</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-slate-950 text-slate-100">
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+706
@@ -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;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
export const defaultLenses = [
|
||||||
|
// Full-frame primes
|
||||||
|
{ id: 'ff_24_14', name: 'FF 24mm f/1.4', focalLength: 24, maxAperture: 1.4, sensorId: 'full_frame' },
|
||||||
|
{ id: 'ff_35_14', name: 'FF 35mm f/1.4', focalLength: 35, maxAperture: 1.4, sensorId: 'full_frame' },
|
||||||
|
{ id: 'ff_35_18', name: 'FF 35mm f/1.8', focalLength: 35, maxAperture: 1.8, sensorId: 'full_frame' },
|
||||||
|
{ id: 'ff_50_12', name: 'FF 50mm f/1.2', focalLength: 50, maxAperture: 1.2, sensorId: 'full_frame' },
|
||||||
|
{ id: 'ff_50_14', name: 'FF 50mm f/1.4', focalLength: 50, maxAperture: 1.4, sensorId: 'full_frame' },
|
||||||
|
{ id: 'ff_50_18', name: 'FF 50mm f/1.8', focalLength: 50, maxAperture: 1.8, sensorId: 'full_frame' },
|
||||||
|
{ id: 'ff_70_28', name: 'FF 70mm f/2.8', focalLength: 70, maxAperture: 2.8, sensorId: 'full_frame' },
|
||||||
|
{ id: 'ff_85_12', name: 'FF 85mm f/1.2', focalLength: 85, maxAperture: 1.2, sensorId: 'full_frame' },
|
||||||
|
{ id: 'ff_85_18', name: 'FF 85mm f/1.8', focalLength: 85, maxAperture: 1.8, sensorId: 'full_frame' },
|
||||||
|
{ id: 'ff_90_28', name: 'FF 90mm f/2.8', focalLength: 90, maxAperture: 2.8, sensorId: 'full_frame' },
|
||||||
|
{ id: 'ff_105_14', name: 'FF 105mm f/1.4', focalLength: 105, maxAperture: 1.4, sensorId: 'full_frame' },
|
||||||
|
{ id: 'ff_135_18', name: 'FF 135mm f/1.8', focalLength: 135, maxAperture: 1.8, sensorId: 'full_frame' },
|
||||||
|
{ id: 'ff_200_28', name: 'FF 200mm f/2.8', focalLength: 200, maxAperture: 2.8, sensorId: 'full_frame' },
|
||||||
|
|
||||||
|
// Telephoto staples (full-frame)
|
||||||
|
{ id: 'ff_300_28', name: 'FF 300mm f/2.8', focalLength: 300, maxAperture: 2.8, sensorId: 'full_frame' },
|
||||||
|
{ id: 'ff_400_28', name: 'FF 400mm f/2.8', focalLength: 400, maxAperture: 2.8, sensorId: 'full_frame' },
|
||||||
|
{ id: 'ff_600_4', name: 'FF 600mm f/4', focalLength: 600, maxAperture: 4, sensorId: 'full_frame' },
|
||||||
|
|
||||||
|
// Fuji X (APS-C) primes
|
||||||
|
{ id: 'fujifilm_16_14', brand: 'Fujifilm', name: 'XF 16mm f/1.4 R WR', focalLength: 16, maxAperture: 1.4, sensorId: 'aps_c_fuji' },
|
||||||
|
{ id: 'fujifilm_23_14', brand: 'Fujifilm', name: 'XF 23mm f/1.4 R LM WR', focalLength: 23, maxAperture: 1.4, sensorId: 'aps_c_fuji' },
|
||||||
|
{ id: 'fujifilm_33_14', brand: 'Fujifilm', name: 'XF 33mm f/1.4 R LM WR', focalLength: 33, maxAperture: 1.4, sensorId: 'aps_c_fuji' },
|
||||||
|
{ id: 'fujifilm_35_2', brand: 'Fujifilm', name: 'XF 35mm f/2 R WR', focalLength: 35, maxAperture: 2, sensorId: 'aps_c_fuji' },
|
||||||
|
{ id: 'fujifilm_56_12', brand: 'Fujifilm', name: 'XF 56mm f/1.2 R WR', focalLength: 56, maxAperture: 1.2, sensorId: 'aps_c_fuji' },
|
||||||
|
{ id: 'sigma_56_14', brand: 'Sigma', name: 'Sigma 56mm f/1.4', focalLength: 56, maxAperture: 1.4, sensorId: 'aps_c_fuji' },
|
||||||
|
{ id: 'fujifilm_80_28_macro', brand: 'Fujifilm', name: 'XF 80mm f/2.8 R LM OIS WR Macro', focalLength: 80, maxAperture: 2.8, sensorId: 'aps_c_fuji' },
|
||||||
|
{ id: 'fujifilm_90_2', brand: 'Fujifilm', name: 'XF 90mm f/2 R LM WR', focalLength: 90, maxAperture: 2, sensorId: 'aps_c_fuji' },
|
||||||
|
{ id: 'fujifilm_200_2', brand: 'Fujifilm', name: 'XF 200mm f/2', focalLength: 200, maxAperture: 2, 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' },
|
||||||
|
|
||||||
|
// GFX (44×33) 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_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_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' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const findLensById = (lensId, lenses = defaultLenses) =>
|
||||||
|
lenses.find((lens) => lens.id === lensId) || lenses[0];
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
const FULL_FRAME_DIAGONAL_MM = Math.sqrt(36 ** 2 + 24 ** 2);
|
||||||
|
|
||||||
|
const baseSensors = [
|
||||||
|
{
|
||||||
|
id: 'full_frame',
|
||||||
|
name: 'Full Frame 36×24',
|
||||||
|
width: 36,
|
||||||
|
height: 24,
|
||||||
|
aspectRatio: '3:2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'aps_c_fuji',
|
||||||
|
name: 'APS-C Fuji 23.5×15.6',
|
||||||
|
width: 23.5,
|
||||||
|
height: 15.6,
|
||||||
|
aspectRatio: '3:2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gfx_44x33',
|
||||||
|
name: 'GFX Medium Format 44×33',
|
||||||
|
width: 44,
|
||||||
|
height: 33,
|
||||||
|
aspectRatio: '4:3',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const deriveSensor = (sensor) => {
|
||||||
|
const diagonal = Math.sqrt(sensor.width ** 2 + sensor.height ** 2);
|
||||||
|
const cropFactor = FULL_FRAME_DIAGONAL_MM / diagonal;
|
||||||
|
return {
|
||||||
|
...sensor,
|
||||||
|
diagonal,
|
||||||
|
cropFactor,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const defaultSensors = baseSensors.map(deriveSensor);
|
||||||
|
|
||||||
|
export const findSensorById = (sensorId) =>
|
||||||
|
defaultSensors.find((sensor) => sensor.id === sensorId) || defaultSensors[0];
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
const container = document.getElementById('root');
|
||||||
|
const root = createRoot(container);
|
||||||
|
root.render(<App />);
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
export const FramingModes = {
|
||||||
|
FIXED_DISTANCE: 'fixed-distance',
|
||||||
|
MATCH_DIAGONAL: 'match-diagonal',
|
||||||
|
MATCH_HEIGHT: 'match-height',
|
||||||
|
CUSTOM: 'custom-framing',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PlotTypes = {
|
||||||
|
DISTANCE: 'distance',
|
||||||
|
ZOOMED: 'zoomed',
|
||||||
|
NORMALIZED: 'normalized',
|
||||||
|
SEPARATION: 'separation',
|
||||||
|
DIOPTER: 'diopter',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MM_PER_METER = 1000;
|
||||||
|
const DEFAULT_SUBJECT_WIDTH_RATIO = 0.35; // approximate shoulder-to-height ratio for diagonal framing
|
||||||
|
|
||||||
|
const coercePositiveNumber = (value, fallback) => {
|
||||||
|
const numeric = Number(value);
|
||||||
|
return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const metersToMillimeters = (valueMeters) => valueMeters * MM_PER_METER;
|
||||||
|
const millimetersToMeters = (valueMillimeters) => valueMillimeters / MM_PER_METER;
|
||||||
|
|
||||||
|
export const calculateSensorDiagonal = (sensor) =>
|
||||||
|
Math.sqrt(sensor.width ** 2 + sensor.height ** 2);
|
||||||
|
|
||||||
|
export const calculateEntrancePupil = (focalLengthMm, aperture) =>
|
||||||
|
focalLengthMm / coercePositiveNumber(aperture, 1.0);
|
||||||
|
|
||||||
|
const calculateMagnification = (imageSizeMm, objectSizeMm) => {
|
||||||
|
const safeImage = coercePositiveNumber(imageSizeMm, 1);
|
||||||
|
const safeObject = coercePositiveNumber(objectSizeMm, 1);
|
||||||
|
return safeImage / safeObject;
|
||||||
|
};
|
||||||
|
|
||||||
|
const distanceForMagnification = (focalLengthMm, magnification) => {
|
||||||
|
const safeMag = coercePositiveNumber(magnification, 1e-6);
|
||||||
|
return focalLengthMm * (1 + 1 / safeMag);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const computeSubjectDistance = ({
|
||||||
|
framingMode,
|
||||||
|
lens,
|
||||||
|
sensor,
|
||||||
|
fixedSubjectDistance,
|
||||||
|
subjectHeight,
|
||||||
|
frameFill,
|
||||||
|
customSubjectDistance,
|
||||||
|
customDimension = 'height',
|
||||||
|
}) => {
|
||||||
|
const focalLength = lens.focalLength;
|
||||||
|
const safeFrameFill = Math.min(Math.max(frameFill ?? 0.8, 1e-3), 0.999);
|
||||||
|
const subjectHeightMeters = coercePositiveNumber(subjectHeight, 1.7);
|
||||||
|
const subjectHeightMm = metersToMillimeters(subjectHeightMeters);
|
||||||
|
|
||||||
|
if (framingMode === FramingModes.FIXED_DISTANCE) {
|
||||||
|
return coercePositiveNumber(fixedSubjectDistance, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (framingMode === FramingModes.CUSTOM && customSubjectDistance) {
|
||||||
|
return coercePositiveNumber(customSubjectDistance, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dimensionSelector = framingMode === FramingModes.MATCH_DIAGONAL || customDimension === 'diagonal'
|
||||||
|
? sensor.diagonal
|
||||||
|
: sensor.height;
|
||||||
|
|
||||||
|
const subjectDimensionMm = framingMode === FramingModes.MATCH_DIAGONAL || customDimension === 'diagonal'
|
||||||
|
? subjectHeightMm * Math.sqrt(1 + DEFAULT_SUBJECT_WIDTH_RATIO ** 2)
|
||||||
|
: subjectHeightMm;
|
||||||
|
|
||||||
|
const imageSizeMm = safeFrameFill * dimensionSelector;
|
||||||
|
const magnification = calculateMagnification(imageSizeMm, subjectDimensionMm);
|
||||||
|
const subjectDistanceMm = distanceForMagnification(focalLength, magnification);
|
||||||
|
|
||||||
|
return Math.max(millimetersToMeters(subjectDistanceMm), 0.2);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calculateBlurCircle = ({ focalLengthMm, aperture, subjectDistanceM, sceneDistanceM }) => {
|
||||||
|
const safeAperture = coercePositiveNumber(aperture, 1);
|
||||||
|
const f = focalLengthMm;
|
||||||
|
const u = metersToMillimeters(coercePositiveNumber(subjectDistanceM, 0.5));
|
||||||
|
const s = metersToMillimeters(coercePositiveNumber(sceneDistanceM, 0.51));
|
||||||
|
|
||||||
|
if (Math.abs(s - u) < 1e-6) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(u - f) < 1e-6) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numerator = f ** 2 * Math.abs(u - s);
|
||||||
|
const denominator = safeAperture * s * (u - f);
|
||||||
|
|
||||||
|
return numerator / Math.abs(denominator);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildSeparationSamples = ({
|
||||||
|
foregroundRange = 0,
|
||||||
|
maxSeparation = 30,
|
||||||
|
steps = 160,
|
||||||
|
} = {}) => {
|
||||||
|
const clampedForeground = Math.max(foregroundRange, 0);
|
||||||
|
const clampedBackground = Math.max(maxSeparation, 0.1);
|
||||||
|
const sampleCount = Math.max(Math.floor(steps), 5);
|
||||||
|
const offsets = [];
|
||||||
|
|
||||||
|
const totalSpan = clampedForeground + clampedBackground;
|
||||||
|
const frontShare = totalSpan > 0 ? clampedForeground / totalSpan : 0;
|
||||||
|
let frontCount = clampedForeground > 0 ? Math.max(Math.floor(sampleCount * frontShare), 3) : 0;
|
||||||
|
frontCount = Math.min(frontCount, sampleCount - 3);
|
||||||
|
|
||||||
|
if (clampedForeground > 0 && frontCount > 0) {
|
||||||
|
for (let i = frontCount; i >= 1; i -= 1) {
|
||||||
|
const t = i / (frontCount + 1);
|
||||||
|
const eased = t ** 1.4; // denser near the subject plane
|
||||||
|
offsets.push(-clampedForeground * eased);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offsets.push(0);
|
||||||
|
|
||||||
|
const backCount = Math.max(sampleCount - offsets.length, 3);
|
||||||
|
for (let i = 1; i <= backCount; i += 1) {
|
||||||
|
const t = i / (backCount + 1);
|
||||||
|
const eased = t ** 1.2;
|
||||||
|
offsets.push(clampedBackground * eased);
|
||||||
|
}
|
||||||
|
|
||||||
|
return offsets;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildBlurDataset = ({
|
||||||
|
lens,
|
||||||
|
sensor,
|
||||||
|
aperture,
|
||||||
|
subjectDistance,
|
||||||
|
separationSamples,
|
||||||
|
}) => {
|
||||||
|
const entrancePupil = calculateEntrancePupil(lens.focalLength, aperture);
|
||||||
|
const safeSubjectDistance = coercePositiveNumber(subjectDistance, 0.5);
|
||||||
|
|
||||||
|
return separationSamples.map((offset) => {
|
||||||
|
let sceneDistance = safeSubjectDistance + offset;
|
||||||
|
sceneDistance = Math.max(sceneDistance, 0.2);
|
||||||
|
const focusOffset = sceneDistance - safeSubjectDistance;
|
||||||
|
|
||||||
|
const blurMm = calculateBlurCircle({
|
||||||
|
focalLengthMm: lens.focalLength,
|
||||||
|
aperture,
|
||||||
|
subjectDistanceM: safeSubjectDistance,
|
||||||
|
sceneDistanceM: sceneDistance,
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizedDistance = sceneDistance / safeSubjectDistance;
|
||||||
|
const diopterDifference = (1 / safeSubjectDistance) - (1 / sceneDistance);
|
||||||
|
const percentOfDiagonal = (blurMm / sensor.diagonal) * 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
focusOffset,
|
||||||
|
sceneDistance,
|
||||||
|
normalizedDistance,
|
||||||
|
diopterDifference,
|
||||||
|
blurMm,
|
||||||
|
blurPercentOfDiagonal: percentOfDiagonal,
|
||||||
|
};
|
||||||
|
}).map((point) => ({
|
||||||
|
...point,
|
||||||
|
entrancePupil,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildStatsAtCheckpoints = ({
|
||||||
|
lens,
|
||||||
|
sensor,
|
||||||
|
aperture,
|
||||||
|
subjectDistance,
|
||||||
|
checkpoints,
|
||||||
|
}) => {
|
||||||
|
const entrancePupil = calculateEntrancePupil(lens.focalLength, aperture);
|
||||||
|
const safeSubjectDistance = coercePositiveNumber(subjectDistance, 0.5);
|
||||||
|
|
||||||
|
return checkpoints.map((separation) => {
|
||||||
|
let sceneDistance;
|
||||||
|
let blurMm;
|
||||||
|
|
||||||
|
if (!Number.isFinite(separation)) {
|
||||||
|
sceneDistance = Infinity;
|
||||||
|
blurMm = calculateBlurCircle({
|
||||||
|
focalLengthMm: lens.focalLength,
|
||||||
|
aperture,
|
||||||
|
subjectDistanceM: safeSubjectDistance,
|
||||||
|
sceneDistanceM: Infinity,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
sceneDistance = safeSubjectDistance + separation;
|
||||||
|
blurMm = calculateBlurCircle({
|
||||||
|
focalLengthMm: lens.focalLength,
|
||||||
|
aperture,
|
||||||
|
subjectDistanceM: safeSubjectDistance,
|
||||||
|
sceneDistanceM: sceneDistance,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
lensId: lens.id,
|
||||||
|
lensName: lens.name,
|
||||||
|
separation,
|
||||||
|
blurMm,
|
||||||
|
blurPercentOfDiagonal: (blurMm / sensor.diagonal) * 100,
|
||||||
|
entrancePupil,
|
||||||
|
subjectDistance: safeSubjectDistance,
|
||||||
|
sceneDistance,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
module.exports = {
|
||||||
|
content: ['./public/index.html', './src/**/*.{js,jsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: '#0f172a',
|
||||||
|
accent: '#38bdf8',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||||
|
|
||||||
|
module.exports = (env, argv) => {
|
||||||
|
const isProduction = argv.mode === 'production';
|
||||||
|
|
||||||
|
return {
|
||||||
|
entry: './src/index.jsx',
|
||||||
|
output: {
|
||||||
|
path: path.resolve(__dirname, 'dist'),
|
||||||
|
filename: isProduction ? 'static/js/[name].[contenthash].js' : 'static/js/bundle.js',
|
||||||
|
clean: true,
|
||||||
|
publicPath: '/',
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
extensions: ['.js', '.jsx'],
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.(js|jsx)$/,
|
||||||
|
exclude: /node_modules/,
|
||||||
|
use: 'babel-loader',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.css$/,
|
||||||
|
use: [
|
||||||
|
'style-loader',
|
||||||
|
{
|
||||||
|
loader: 'css-loader',
|
||||||
|
options: {
|
||||||
|
importLoaders: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'postcss-loader',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.(png|jpg|jpeg|svg|gif)$/i,
|
||||||
|
type: 'asset/resource',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
new HtmlWebpackPlugin({
|
||||||
|
template: path.resolve(__dirname, 'public/index.html'),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
devtool: isProduction ? 'source-map' : 'eval-source-map',
|
||||||
|
devServer: {
|
||||||
|
static: path.resolve(__dirname, 'public'),
|
||||||
|
historyApiFallback: true,
|
||||||
|
hot: true,
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 3000,
|
||||||
|
open: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user