From cb38916131b49fe76558db4eae5075047aba3c55 Mon Sep 17 00:00:00 2001 From: Fabian Wolter Date: Wed, 8 Apr 2026 15:49:54 +0200 Subject: [PATCH] Initial Release --- app.css | 175 +++++++++++++++++++++++++++++++++++++++++++++++++++ app.js | 181 +++++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 82 ++++++++++++++++++++++++ 3 files changed, 438 insertions(+) create mode 100644 app.css create mode 100644 app.js create mode 100644 index.html diff --git a/app.css b/app.css new file mode 100644 index 0000000..87f603d --- /dev/null +++ b/app.css @@ -0,0 +1,175 @@ +:root { + --bg: #0f1220; + --panel: #171a2b; + --panel-2: #1f2438; + --text: #eef2ff; + --muted: #b8c0e0; + --accent: #7aa2ff; + --ok: #67e8a5; + --error: #ff8b8b; + --border: #2e3552; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: + system-ui, + -apple-system, + Segoe UI, + Roboto, + Helvetica, + Arial, + sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; +} + +.wrap { + max-width: 1000px; + margin: 0 auto; + padding: 28px 16px 48px; +} + +h1 { + margin: 0 0 8px; + font-size: 1.7rem; + letter-spacing: 0.2px; +} + +.subtitle { + margin: 0 0 24px; + color: var(--muted); + font-size: 0.98rem; + line-height: 1.45; +} + +.grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +@media (max-width: 900px) { + .grid { + grid-template-columns: 1fr; + } +} + +.card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 14px; + padding: 16px; +} + +.card h2 { + margin: 0 0 14px; + font-size: 1.05rem; + color: #dbe4ff; +} + +.field { + margin-bottom: 12px; +} + +label { + display: block; + margin-bottom: 6px; + font-size: 0.9rem; + color: var(--muted); +} + +input { + width: 100%; + border: 1px solid var(--border); + background: #0f1324; + color: var(--text); + border-radius: 10px; + padding: 10px 12px; + font-size: 0.98rem; + outline: none; +} + +input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(122, 162, 255, 0.18); +} + +.hint { + font-size: 0.82rem; + color: var(--muted); + margin-top: 4px; +} + +.actions { + margin-top: 6px; + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +button { + border: 1px solid var(--border); + background: #243051; + color: var(--text); + border-radius: 10px; + padding: 10px 14px; + cursor: pointer; + font-weight: 600; +} + +button:hover { + filter: brightness(1.08); +} + +button:active { + transform: translateY(1px); +} + +.result { + margin-top: 18px; + background: #10182e; + border: 1px solid var(--border); + border-radius: 12px; + padding: 14px; + line-height: 1.45; +} + +.ok { + color: var(--ok); + font-weight: 700; +} + +.error { + color: var(--error); + font-weight: 700; +} + +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + background: rgba(255, 255, 255, 0.06); + padding: 2px 6px; + border-radius: 6px; +} + +.formula { + margin-top: 16px; + color: var(--muted); + font-size: 0.88rem; + line-height: 1.5; +} + +.formula code { + color: #d6e2ff; +} + +.small { + font-size: 0.86rem; + color: var(--muted); + margin-top: 10px; +} diff --git a/app.js b/app.js new file mode 100644 index 0000000..aa8bb37 --- /dev/null +++ b/app.js @@ -0,0 +1,181 @@ +const isoAEl = document.getElementById("isoA"); +const fAEl = document.getElementById("fA"); +const tAEl = document.getElementById("tA"); + +const isoBEl = document.getElementById("isoB"); +const fBEl = document.getElementById("fB"); +const tBEl = document.getElementById("tB"); + +const resultEl = document.getElementById("result"); + +document.getElementById("calcBtn").addEventListener("click", calculate); +document.getElementById("resetBtn").addEventListener("click", reset); + +document.addEventListener("keydown", (e) => { + if (e.key === "Enter") calculate(); +}); + +function parseShutter(value) { + const raw = String(value ?? "").trim(); + if (!raw) return NaN; + + if (raw.includes("/")) { + const parts = raw.split("/"); + if (parts.length !== 2) return NaN; + const num = parseFloat(parts[0]); + const den = parseFloat(parts[1]); + if (!isFinite(num) || !isFinite(den) || den === 0) return NaN; + return num / den; + } + + const v = parseFloat(raw); + if (!isFinite(v)) return NaN; + return v; +} + +function parsePositiveOrEmpty(value, parser = parseFloat) { + const raw = String(value ?? "").trim(); + if (raw === "") return { empty: true, value: null }; + const parsed = parser(raw); + if (!isFinite(parsed) || parsed <= 0) return { empty: false, value: NaN }; + return { empty: false, value: parsed }; +} + +function prettyShutter(seconds) { + if (!(seconds > 0) || !isFinite(seconds)) return "Invalid"; + + // exact decimal seconds (up to 8 decimals, trimmed) + const exactSeconds = seconds.toFixed(8).replace(/0+$/, "").replace(/\.$/, ""); + + if (seconds < 1) { + // exact reciprocal (not snapped to common shutter values) + const reciprocal = 1 / seconds; + const reciprocalStr = reciprocal + .toFixed(6) + .replace(/0+$/, "") + .replace(/\.$/, ""); + + return `1/${reciprocalStr} s (${exactSeconds} s)`; + } + + return `${exactSeconds} s`; +} + +function formatAperture(n) { + if (!(n > 0) || !isFinite(n)) return "Invalid"; + return `f/${n.toFixed(2).replace(/0+$/, "").replace(/\.$/, "")}`; +} + +function formatISO(iso) { + if (!(iso > 0) || !isFinite(iso)) return "Invalid"; + return `ISO ${Math.round(iso)}`; +} + +function calculate() { + // Reference (must all be present) + const isoA = parseFloat(isoAEl.value); + const fA = parseFloat(fAEl.value); + const tA = parseShutter(tAEl.value); + + if ( + !(isoA > 0) || + !(fA > 0) || + !(tA > 0) || + !isFinite(isoA) || + !isFinite(fA) || + !isFinite(tA) + ) { + resultEl.innerHTML = `Camera A must have valid positive ISO, f-stop, and shutter speed.`; + return; + } + + // Camera B: exactly one missing + const isoBParsed = parsePositiveOrEmpty(isoBEl.value, parseFloat); + const fBParsed = parsePositiveOrEmpty(fBEl.value, parseFloat); + const tBParsed = parsePositiveOrEmpty(tBEl.value, parseShutter); + + const fields = [ + { key: "iso", ...isoBParsed }, + { key: "f", ...fBParsed }, + { key: "t", ...tBParsed }, + ]; + + const emptyFields = fields.filter((f) => f.empty); + if (emptyFields.length !== 1) { + resultEl.innerHTML = `For Camera B, leave exactly one field empty (the one to calculate).`; + return; + } + + if (fields.some((f) => !f.empty && (!isFinite(f.value) || f.value <= 0))) { + resultEl.innerHTML = `Camera B has invalid values. Use positive numbers, and shutter like 1/250 or 0.004.`; + return; + } + + const missing = emptyFields[0].key; + let isoB = isoBParsed.value; + let fB = fBParsed.value; + let tB = tBParsed.value; + + // From: + // tB = tA * (fB^2 / fA^2) * (isoA / isoB) + // Rearranged for each missing variable: + if (missing === "t") { + tB = tA * ((fB * fB) / (fA * fA)) * (isoA / isoB); + if (!(tB > 0) || !isFinite(tB)) { + resultEl.innerHTML = `Could not compute shutter speed from given values.`; + return; + } + tBEl.value = tB.toPrecision(6); + + resultEl.innerHTML = ` +
Calculated shutter speed for Camera B: ${prettyShutter(tB)}
+
B now: ${formatISO(isoB)}, ${formatAperture(fB)}, ${prettyShutter(tB)}
+`; + return; + } + + if (missing === "iso") { + // isoB = isoA * tA * fB^2 / ( tB * fA^2 ) + isoB = (isoA * tA * (fB * fB)) / (tB * (fA * fA)); + if (!(isoB > 0) || !isFinite(isoB)) { + resultEl.innerHTML = `Could not compute ISO from given values.`; + return; + } + isoBEl.value = Math.round(isoB).toString(); + + resultEl.innerHTML = ` +
Calculated ISO for Camera B: ${formatISO(isoB)}
+
B now: ${formatISO(isoB)}, ${formatAperture(fB)}, ${prettyShutter(tB)}
+`; + return; + } + + if (missing === "f") { + // fB^2 = (tB * fA^2 * isoB) / (tA * isoA) + // fB = sqrt(...) + const inside = (tB * (fA * fA) * isoB) / (tA * isoA); + fB = Math.sqrt(inside); + if (!(fB > 0) || !isFinite(fB)) { + resultEl.innerHTML = `Could not compute aperture from given values.`; + return; + } + fBEl.value = fB.toFixed(2); + + resultEl.innerHTML = ` +
Calculated aperture for Camera B: ${formatAperture(fB)}
+
B now: ${formatISO(isoB)}, ${formatAperture(fB)}, ${prettyShutter(tB)}
+`; + } +} + +function reset() { + isoAEl.value = "100"; + fAEl.value = "2.0"; + tAEl.value = "1/1000"; + + isoBEl.value = ""; + fBEl.value = ""; + tBEl.value = ""; + + resultEl.textContent = "Result will appear here."; +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..8acf495 --- /dev/null +++ b/index.html @@ -0,0 +1,82 @@ + + + + + + + Exposure Match Calculator (ISO / Aperture / Shutter) + + + + +
+

Exposure Match Calculator

+

+ Enter a full reference exposure (Camera A). For Camera B, fill + exactly two fields and leave + one empty. The empty field is calculated for + equal exposure. +

+ +
+
+

Camera A (Reference: all required)

+
+ + +
+
+ + +
+
+ + +
Examples: 1/250, 0.004, 2
+
+
+ +
+

Camera B (Target: leave one empty)

+
+ + +
+
+ + +
+
+ + +
Examples: 1/125, 0.008, 1.5
+
+ +
+ + +
+ +
+ Result will appear here. +
+
+
+ +
+ Exposure equivalence model (same scene brightness):
+ (N² / t) × (100 / ISO) is constant, or + equivalently
+ t₂ = t₁ × (N₂² / N₁²) × (ISO₁ / ISO₂) +
+
+ Note: This is a technical equivalence calculator (ignores + artistic/real-world differences like sensor noise, transmission + losses, etc.). +
+
+ + + + +