Initial Release
This commit is contained in:
175
app.css
Normal file
175
app.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
181
app.js
Normal file
181
app.js
Normal file
@@ -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 = `<span class="error">Camera A must have valid positive ISO, f-stop, and shutter speed.</span>`;
|
||||||
|
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 = `<span class="error">For Camera B, leave exactly one field empty (the one to calculate).</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.some((f) => !f.empty && (!isFinite(f.value) || f.value <= 0))) {
|
||||||
|
resultEl.innerHTML = `<span class="error">Camera B has invalid values. Use positive numbers, and shutter like 1/250 or 0.004.</span>`;
|
||||||
|
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 = `<span class="error">Could not compute shutter speed from given values.</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tBEl.value = tB.toPrecision(6);
|
||||||
|
|
||||||
|
resultEl.innerHTML = `
|
||||||
|
<div class="ok">Calculated shutter speed for Camera B: <span class="mono">${prettyShutter(tB)}</span></div>
|
||||||
|
<div style="margin-top:8px;">B now: <span class="mono">${formatISO(isoB)}, ${formatAperture(fB)}, ${prettyShutter(tB)}</span></div>
|
||||||
|
`;
|
||||||
|
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 = `<span class="error">Could not compute ISO from given values.</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isoBEl.value = Math.round(isoB).toString();
|
||||||
|
|
||||||
|
resultEl.innerHTML = `
|
||||||
|
<div class="ok">Calculated ISO for Camera B: <span class="mono">${formatISO(isoB)}</span></div>
|
||||||
|
<div style="margin-top:8px;">B now: <span class="mono">${formatISO(isoB)}, ${formatAperture(fB)}, ${prettyShutter(tB)}</span></div>
|
||||||
|
`;
|
||||||
|
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 = `<span class="error">Could not compute aperture from given values.</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fBEl.value = fB.toFixed(2);
|
||||||
|
|
||||||
|
resultEl.innerHTML = `
|
||||||
|
<div class="ok">Calculated aperture for Camera B: <span class="mono">${formatAperture(fB)}</span></div>
|
||||||
|
<div style="margin-top:8px;">B now: <span class="mono">${formatISO(isoB)}, ${formatAperture(fB)}, ${prettyShutter(tB)}</span></div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.";
|
||||||
|
}
|
||||||
82
index.html
Normal file
82
index.html
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Exposure Match Calculator (ISO / Aperture / Shutter)</title>
|
||||||
|
<link rel="stylesheet" href="app.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>Exposure Match Calculator</h1>
|
||||||
|
<p class="subtitle">
|
||||||
|
Enter a full reference exposure (Camera A). For Camera B, fill
|
||||||
|
exactly <strong>two</strong> fields and leave
|
||||||
|
<strong>one empty</strong>. The empty field is calculated for
|
||||||
|
equal exposure.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<section class="card">
|
||||||
|
<h2>Camera A (Reference: all required)</h2>
|
||||||
|
<div class="field">
|
||||||
|
<label for="isoA">ISO</label>
|
||||||
|
<input id="isoA" type="number" min="1" step="1" value="100" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="fA">F-Stop (aperture N)</label>
|
||||||
|
<input id="fA" type="number" min="0.1" step="0.1" value="2.0" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="tA">Shutter Speed</label>
|
||||||
|
<input id="tA" type="text" value="1/1000" />
|
||||||
|
<div class="hint">Examples: 1/250, 0.004, 2</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Camera B (Target: leave one empty)</h2>
|
||||||
|
<div class="field">
|
||||||
|
<label for="isoB">ISO (leave empty to calculate ISO)</label>
|
||||||
|
<input id="isoB" type="text" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="fB">F-Stop (leave empty to calculate aperture)</label>
|
||||||
|
<input id="fB" type="text" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="tB">Shutter (leave empty to calculate shutter)</label>
|
||||||
|
<input id="tB" type="text" />
|
||||||
|
<div class="hint">Examples: 1/125, 0.008, 1.5</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button id="calcBtn">Calculate Missing Value</button>
|
||||||
|
<button id="resetBtn" type="button">Reset</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="result" id="result">
|
||||||
|
Result will appear here.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="formula">
|
||||||
|
Exposure equivalence model (same scene brightness):<br />
|
||||||
|
<code>(N² / t) × (100 / ISO)</code> is constant, or
|
||||||
|
equivalently<br />
|
||||||
|
<code>t₂ = t₁ × (N₂² / N₁²) × (ISO₁ / ISO₂)</code>
|
||||||
|
</div>
|
||||||
|
<div class="small">
|
||||||
|
Note: This is a technical equivalence calculator (ignores
|
||||||
|
artistic/real-world differences like sensor noise, transmission
|
||||||
|
losses, etc.).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user