Files
loddkalkulator/index.html
2026-01-14 09:24:20 +01:00

785 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="no">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Loddkalkulator</title>
<style>
:root{
--w75:#22c55e; /* 7.5 = grønn */
--w11:#facc15; /* 11 = gul */
--w15:#ef4444; /* 15 = rød */
--w17:#3b82f6; /* 17 = blå */
--bg:#0b1020;
--card:#121a33;
--muted:#94a3b8;
--text:#e2e8f0;
--good:#34d399;
--bad:#fb7185;
--border:rgba(148,163,184,.18);
--shadow:0 18px 50px rgba(0,0,0,.35);
--radius:18px;
}
@media (prefers-color-scheme: light){
:root{
--bg:#f6f7fb;
--card:#ffffff;
--muted:#52627a;
--text:#0f172a;
--good:#059669;
--bad:#e11d48;
--border:rgba(15,23,42,.12);
--shadow:0 18px 50px rgba(15,23,42,.10);
}
}
*{box-sizing:border-box}
body{
margin:0;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";
background: radial-gradient(1200px 700px at 10% 0%, rgba(96,165,250,.18), transparent 45%),
radial-gradient(900px 600px at 100% 10%, rgba(52,211,153,.14), transparent 45%),
var(--bg);
color:var(--text);
min-height:100vh;
display:flex;
align-items:center;
justify-content:center;
padding:28px;
}
/* Hindrer dobbel-tap zoom på iOS Safari */
button, .btn-step, input, .pill, .resBadge {
touch-action: manipulation;
}
.wrap{width:min(980px, 100%);}
header{
display:flex;
align-items:flex-end;
justify-content:space-between;
gap:16px;
margin-bottom:18px;
}
h1{margin:0;font-size: clamp(22px, 2.4vw, 30px); letter-spacing:-.02em;}
.sub{margin:0;color:var(--muted); font-size:14px; line-height:1.4}
.grid{
display:grid;
grid-template-columns: 1.2fr .8fr;
gap:16px;
align-items:start;
}
@media (max-width: 980px){
.grid{grid-template-columns: 1fr;}
}
.card{
background: linear-gradient(180deg, rgba(255,255,255,.05), transparent 38%), var(--card);
border:1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding:18px;
}
.row{display:grid; grid-template-columns: 1fr 1fr; gap:12px;}
@media (max-width: 540px){ .row{grid-template-columns: 1fr;} }
.row3{display:grid; grid-template-columns: 1fr 1fr 1fr; gap:12px;}
@media (max-width: 740px){ .row3{grid-template-columns: 1fr;} }
label{display:block; font-size:12px; color:var(--muted); margin:0 0 6px 2px;}
input, button{ font:inherit; }
input{
width:100%;
padding:12px 12px;
border-radius:14px;
border:1px solid var(--border);
background: rgba(255,255,255,.04);
color:var(--text);
outline:none;
}
input::placeholder{color:rgba(148,163,184,.7)}
input:focus{border-color: rgba(96,165,250,.65); box-shadow: 0 0 0 3px rgba(96,165,250,.20)}
.table{
width:100%;
border-collapse:separate;
border-spacing:0;
overflow:hidden;
border:1px solid var(--border);
border-radius: 16px;
}
.table th,.table td{
padding:12px 12px;
border-bottom:1px solid var(--border);
vertical-align:middle;
text-align:left;
}
.table tr:last-child td{border-bottom:none;}
.table th{font-size:12px; color:var(--muted); font-weight:600; background: rgba(255,255,255,.03)}
.table .wcol{width:120px}
.table input{padding:10px 10px; border-radius:12px}
.actions{display:flex; gap:10px; flex-wrap:wrap; margin-top:14px;}
button{
border:none;
border-radius: 14px;
padding:11px 14px;
cursor:pointer;
font-weight:650;
letter-spacing:.01em;
transition: transform .05s ease, filter .15s ease;
}
button:active{transform: translateY(1px)}
.btn-secondary{background: rgba(255,255,255,.06); color:var(--text); border:1px solid var(--border)}
.btn-secondary:hover{filter:brightness(1.08)}
.pill{
display:inline-flex;
gap:8px;
align-items:center;
padding:8px 10px;
border-radius:999px;
border:1px solid var(--border);
background: rgba(255,255,255,.04);
color:var(--muted);
font-size:12px;
user-select:none;
}
.swatch{
width:14px;
height:14px;
border-radius:999px;
display:inline-block;
border:1px solid rgba(255,255,255,.25);
box-shadow: inset 0 0 0 1px rgba(0,0,0,.08);
}
.swatch.w75{background:var(--w75)}
.swatch.w11{background:var(--w11)}
.swatch.w15{background:var(--w15)}
.swatch.w17{background:var(--w17)}
.big{
display:grid;
gap:6px;
padding:14px;
border-radius: 16px;
border:1px solid var(--border);
background: rgba(255,255,255,.04);
}
.big .k{font-size:12px; color:var(--muted)}
.big .v{font-size:22px; font-weight:800; letter-spacing:-.02em}
.delta.good{color:var(--good)}
.delta.bad{color:var(--bad)}
.mono{font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;}
.note{color:var(--muted); font-size:12px; line-height:1.45}
.error{color:var(--bad); font-weight:650}
/* Steppers */
.stepper{ display:flex; align-items:center; gap:10px; }
.btn-step{
width:34px; height:34px; padding:0;
border-radius:12px;
border:1px solid var(--border);
background: rgba(255,255,255,.06);
color:var(--text);
font-weight:800;
line-height:1;
display:inline-flex;
align-items:center;
justify-content:center;
cursor:pointer;
}
.btn-step:hover{filter:brightness(1.08)}
.btn-step:active{transform: translateY(1px)}
input.countbox{
width:64px;
appearance:textfield;
}
input.countbox::-webkit-outer-spin-button,
input.countbox::-webkit-inner-spin-button{
-webkit-appearance: none;
margin: 0;
}
/* Manual total */
.manualTotalBox{
margin-top:12px;
padding:14px;
border-radius:16px;
border:1px solid var(--border);
background: radial-gradient(600px 260px at 20% 0%, rgba(96,165,250,.16), transparent 55%),
rgba(255,255,255,.04);
}
.manualTotalLabel{font-size:12px; color:var(--muted); margin-bottom:6px}
.manualTotalVal{font-size:34px; font-weight:900; letter-spacing:-.02em; line-height:1.1}
/* Resultat cell styling (samme bakgrunn som Total) */
.resCell{ text-align:left; }
.resBadge{
display:inline-flex;
align-items:center;
justify-content:center;
min-width:44px;
padding:8px 10px;
border-radius:14px;
border:1px solid var(--border);
background: radial-gradient(600px 260px at 20% 0%, rgba(96,165,250,.16), transparent 55%),
rgba(255,255,255,.04);
font-weight:950;
font-size:18px;
letter-spacing:-.02em;
line-height:1;
}
@media (max-width: 540px){
.resBadge{font-size:17px;}
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div>
<h1>Loddkalkulator</h1>
</div>
</header>
<div class="grid">
<section class="card">
<div class="row">
<div style="grid-column: 1 / -1;">
<label for="target">Målvekt</label>
<input id="target" placeholder="f.eks. 170,5" inputmode="decimal" />
</div>
</div>
<div style="height:12px"></div>
<table class="table" aria-label="Tilgjengelige lodd">
<thead>
<tr>
<th class="wcol">Lodd (kg)</th>
<th>Tilgjengelig (heltall)</th>
<th>Resultat</th>
</tr>
</thead>
<tbody id="weightsBody"></tbody>
</table>
<div class="actions">
<button class="btn-secondary" id="resetBtn">Nullstill</button>
<span class="pill" id="statusPill" style="margin-left:auto">Klar</span>
</div>
<div class="row3" style="margin-top:12px">
<div class="big">
<div class="k">Oppnådd</div>
<div class="v" id="achieved"></div>
</div>
<div class="big">
<div class="k">Avvik (oppnådd mål)</div>
<div class="v" id="deviation"></div>
</div>
<div class="big">
<div class="k">Totalt antall lodd</div>
<div class="v" id="total"></div>
</div>
</div>
<div id="error" class="note error" style="display:none; margin-top:10px"></div>
</section>
<section class="card" id="manualCard">
<div class="big" style="border:none; background:transparent; padding:0">
<div class="k">Manuell kalkulator</div>
<table class="table" aria-label="Manuell kalkulator" style="margin-top:10px">
<thead>
<tr>
<th class="wcol">Lodd (kg)</th>
<th style="width:170px">Antall</th>
</tr>
</thead>
<tbody id="calcBody"></tbody>
</table>
<div class="actions">
<button class="btn-secondary" id="manualResetBtn" type="button">Nullstill</button>
</div>
<div class="manualTotalBox">
<div class="manualTotalLabel">Total</div>
<div class="manualTotalVal mono" id="manualTotal">0 kg</div>
</div>
</div>
</section>
</div>
</div>
<script>
const DEFAULT_WEIGHTS = [7.5, 11, 15, 17];
const OVER_SEARCH = 5;
function gcd(a, b){ a = Math.abs(a); b = Math.abs(b); while (b){ const t=a%b; a=b; b=t; } return a; }
function lcm(a, b){ return Math.abs(a*b)/gcd(a,b); }
function parseDecimalToFraction(x){
if (typeof x !== 'string') x = String(x);
x = x.trim().replace(',', '.');
if (!x) return null;
if (!/^[-+]?\d*(?:\.\d*)?$/.test(x)) return null;
if (x === '+' || x === '-' || x === '.' || x === '+.' || x === '-.') return null;
const sign = x.startsWith('-') ? -1 : 1;
if (x.startsWith('-') || x.startsWith('+')) x = x.slice(1);
const parts = x.split('.');
const intPart = parts[0] ? parseInt(parts[0], 10) : 0;
const fracPart = (parts[1] ?? '');
const denom = fracPart.length ? Math.pow(10, fracPart.length) : 1;
const fracNum = fracPart.length ? parseInt(fracPart, 10) : 0;
let n = intPart * denom + fracNum;
n *= sign;
let d = denom;
const g = gcd(n, d);
n /= g; d /= g;
return {n, d};
}
function fractionToNumber(fr){ return fr.n / fr.d; }
// ---- Solver: meet-in-the-middle (robust bounded) ----
function closestWeights(targetStr, weights, available, overSearch){
const tFr = parseDecimalToFraction(targetStr);
if (!tFr) throw new Error('Ugyldig målvekt.');
if (fractionToNumber(tFr) < 0) throw new Error('Målvekt må være ≥ 0.');
const wFr = weights.map(w => parseDecimalToFraction(String(w)));
let scale = 1;
for (const fr of [...wFr, tFr]) scale = lcm(scale, fr.d);
const wInt = wFr.map(fr => Math.round(fr.n * (scale / fr.d)));
const tInt = Math.round(tFr.n * (scale / tFr.d));
const maxW = Math.max(...wInt);
let limit;
const allFinite = available.every(a => a !== null);
if (allFinite){
const maxPossible = wInt.reduce((sum, wi, i) => sum + wi * (available[i] || 0), 0);
limit = Math.min(tInt + overSearch * maxW, maxPossible);
} else {
limit = tInt + overSearch * maxW;
}
limit = Math.max(0, limit);
const eff = wInt.map((wi, i) => {
const maxUseful = wi > 0 ? Math.floor(limit / wi) : 0;
const a = available[i];
if (a === null) return maxUseful;
return Math.min(a, maxUseful);
});
const [w0,w1,w2,w3] = wInt;
const [e0,e1,e2,e3] = eff;
const bestA = new Map(); // sum -> {c0,c1,cnt}
for (let c0=0;c0<=e0;c0++){
const s0 = c0*w0;
for (let c1=0;c1<=e1;c1++){
const sum = s0 + c1*w1;
if (sum > limit) break;
const cnt = c0 + c1;
const prev = bestA.get(sum);
if (!prev || cnt < prev.cnt){
bestA.set(sum, {c0, c1, cnt});
}
}
}
const sumsA = Array.from(bestA.entries())
.map(([sum, obj]) => ({sum, ...obj}))
.sort((a,b)=>a.sum-b.sum);
const sumsAOnly = sumsA.map(x => x.sum);
function lowerBound(arr, x){
let lo = 0, hi = arr.length;
while (lo < hi){
const mid = (lo + hi) >> 1;
if (arr[mid] < x) lo = mid + 1;
else hi = mid;
}
return lo;
}
let bestSum = null;
let bestAbsDev = Infinity;
let bestCnt = Infinity;
let bestCounts = [0,0,0,0];
for (let c2=0;c2<=e2;c2++){
const s2 = c2*w2;
for (let c3=0;c3<=e3;c3++){
const sumB = s2 + c3*w3;
if (sumB > limit) break;
const desiredA = tInt - sumB;
const maxA = limit - sumB;
const targetA = Math.max(0, Math.min(desiredA, maxA));
const pos = lowerBound(sumsAOnly, targetA);
for (const idx of [pos-1, pos]){
if (idx < 0 || idx >= sumsA.length) continue;
const a = sumsA[idx];
const sumA = a.sum;
if (sumA > maxA) continue;
const totalSum = sumA + sumB;
const absDev = Math.abs(totalSum - tInt);
const cnt = a.cnt + c2 + c3;
if (absDev < bestAbsDev || (absDev === bestAbsDev && cnt < bestCnt)){
bestAbsDev = absDev;
bestCnt = cnt;
bestSum = totalSum;
bestCounts = [a.c0, a.c1, c2, c3];
}
}
}
}
if (bestSum === null) throw new Error('Ingen kombinasjon mulig med disse begrensningene.');
const achieved = bestSum / scale;
const deviation = (bestSum - tInt) / scale;
const total = bestCounts.reduce((a,b)=>a+b,0);
return { achieved, deviation, counts: bestCounts, total };
}
// ---- UI ----
const weightsBody = document.getElementById('weightsBody');
const targetEl = document.getElementById('target');
const statusPill = document.getElementById('statusPill');
const achievedEl = document.getElementById('achieved');
const deviationEl = document.getElementById('deviation');
const totalEl = document.getElementById('total');
const errorEl = document.getElementById('error');
// Manual calculator
const calcBodyEl = document.getElementById('calcBody');
const manualTotalEl = document.getElementById('manualTotal');
const manualResetBtn = document.getElementById('manualResetBtn');
function fmtKg(x){
const s = (Math.round(x*1000000)/1000000).toString();
return s.replace('.', ',') + ' kg';
}
function weightClass(w){
const k = String(w);
if (k === '7.5' || k === '7,5') return 'w75';
if (k === '11') return 'w11';
if (k === '15') return 'w15';
if (k === '17') return 'w17';
return '';
}
function setStatus(text, kind=''){
statusPill.textContent = text;
statusPill.style.color = kind==='error' ? 'var(--bad)' : 'var(--muted)';
statusPill.style.borderColor = kind==='error' ? 'rgba(251,113,133,.45)' : 'var(--border)';
}
function showError(msg){
errorEl.style.display = 'block';
errorEl.textContent = msg;
setStatus('Feil', 'error');
}
function clearError(){
errorEl.style.display = 'none';
errorEl.textContent = '';
setStatus('Klar');
}
function renderWeights(weights){
weightsBody.innerHTML = '';
for (let i=0;i<weights.length;i++){
const w = weights[i];
const cls = weightClass(w);
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="mono">
<span class="swatch ${cls}" style="vertical-align:middle; margin-right:10px"></span>${String(w).replace('.', ',')}
</td>
<td>
<div class="stepper">
<button type="button" class="btn-step" data-role="avail" data-act="dec" data-idx="${i}" aria-label="Minus tilgjengelig ${w} kg"></button>
<input
type="number"
min="0"
step="1"
class="countbox mono avail"
id="avail-${i}"
data-idx="${i}"
placeholder="∞"
inputmode="numeric"
/>
<button type="button" class="btn-step" data-role="avail" data-act="inc" data-idx="${i}" aria-label="Pluss tilgjengelig ${w} kg">+</button>
</div>
</td>
<td class="mono resCell">
<span class="resBadge" id="res-${i}">0</span>
</td>
`;
weightsBody.appendChild(tr);
}
}
function renderResultColumn(countsArr){
for (let i = 0; i < DEFAULT_WEIGHTS.length; i++){
const el = document.getElementById(`res-${i}`);
if (!el) continue;
el.textContent = String(countsArr?.[i] ?? 0);
}
}
function getAvailability(){
const avail = new Array(DEFAULT_WEIGHTS.length).fill(null);
for (let i=0;i<DEFAULT_WEIGHTS.length;i++){
const inp = document.getElementById(`avail-${i}`);
if (!inp) continue;
const v = inp.value.trim();
if (!v){
avail[i] = null; // tomt = uendelig
} else {
if (!/^\d+$/.test(v)) throw new Error('Tilgjengelig må være et heltall (eller tomt).');
avail[i] = parseInt(v, 10);
}
}
return avail;
}
function setClearedOutputs(){
achievedEl.textContent = '';
deviationEl.textContent = '';
deviationEl.className = 'v delta';
totalEl.textContent = '';
renderResultColumn(new Array(DEFAULT_WEIGHTS.length).fill(0));
}
function updateResult(res){
achievedEl.textContent = fmtKg(res.achieved);
let dev = res.deviation;
if (Math.abs(dev) < 1e-12) dev = 0;
const sign = dev > 0 ? '+' : '';
deviationEl.textContent = `${sign}${String(dev).replace('.', ',')} kg`;
deviationEl.className = 'v delta ' + (dev === 0 ? 'good' : 'bad');
totalEl.textContent = String(res.total);
renderResultColumn(res.counts);
setStatus('Ferdig');
}
// --- Auto-solve (debounced) ---
let solveTimer = null;
function scheduleSolve(){
if (solveTimer) clearTimeout(solveTimer);
solveTimer = setTimeout(runSolveNow, 180);
}
function runSolveNow(){
solveTimer = null;
clearError();
const target = targetEl.value.trim();
if (!target || target === '-' || target === '+' || target === '.' || target === '-.' || target === '+.'){
setClearedOutputs();
return;
}
try{
const avail = getAvailability();
const res = closestWeights(String(target).replace('.', ','), DEFAULT_WEIGHTS, avail, OVER_SEARCH);
// Airbag: aldri vis resultat som bryter tilgjengelighet
for (let i=0;i<DEFAULT_WEIGHTS.length;i++){
const a = avail[i];
if (a !== null && res.counts[i] > a){
throw new Error(`Intern feil: overskred tilgjengelig for ${DEFAULT_WEIGHTS[i]} kg (har ${a}, fikk ${res.counts[i]}).`);
}
}
updateResult(res);
} catch (e){
setClearedOutputs();
showError(e instanceof Error ? e.message : String(e));
}
}
// ---- Availability +/- handling + input triggers ----
function bumpAvailability(idx, delta){
const inp = document.getElementById(`avail-${idx}`);
if (!inp) return;
// Tomt = ∞.
// Touch-logikk:
// - ∞ + => 1
// - ∞ - => 0
// - 0 - => ∞ (tomt felt)
const raw = inp.value.trim();
let v = raw === '' ? null : parseInt(raw, 10);
if (v === null){
// fra ∞
inp.value = (delta > 0) ? '1' : '0';
return;
}
if (!Number.isFinite(v) || v < 0) v = 0;
if (v === 0 && delta < 0){
// 0 -> ∞
inp.value = '';
return;
}
inp.value = String(Math.max(0, v + delta));
}
targetEl.addEventListener('input', scheduleSolve);
targetEl.addEventListener('change', scheduleSolve);
weightsBody.addEventListener('input', (e) => {
if (!e.target.classList.contains('avail')) return;
scheduleSolve();
});
weightsBody.addEventListener('change', (e) => {
if (!e.target.classList.contains('avail')) return;
scheduleSolve();
});
// availability stepper clicks (delegation)
document.addEventListener('click', (e) => {
const btn = e.target.closest && e.target.closest('button.btn-step[data-role="avail"]');
if (!btn) return;
const idx = parseInt(btn.dataset.idx, 10);
if (!Number.isFinite(idx)) return;
const act = btn.dataset.act;
if (act === 'inc') bumpAvailability(idx, +1);
if (act === 'dec') bumpAvailability(idx, -1);
scheduleSolve();
});
document.getElementById('resetBtn').addEventListener('click', () => {
targetEl.value = '';
document.querySelectorAll('input.avail').forEach(i => i.value=''); // tilbake til ∞
setClearedOutputs();
clearError();
});
// ---- Manual calculator ----
function renderManualCalculator(){
calcBodyEl.innerHTML = '';
for (let i = 0; i < DEFAULT_WEIGHTS.length; i++){
const w = DEFAULT_WEIGHTS[i];
const cls = weightClass(w);
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="mono">
<span class="swatch ${cls}" style="vertical-align:middle; margin-right:10px"
title="${String(w).replace('.', ',')} kg"></span>${String(w).replace('.', ',')}
</td>
<td>
<div class="stepper">
<button type="button" class="btn-step" data-act="dec" data-idx="${i}"></button>
<input type="number" min="0" step="1"
class="countbox mono mcount"
id="mcount-${i}" data-idx="${i}" value="0" inputmode="numeric" />
<button type="button" class="btn-step" data-act="inc" data-idx="${i}">+</button>
</div>
</td>
`;
calcBodyEl.appendChild(tr);
}
}
const manualCounts = new Array(DEFAULT_WEIGHTS.length).fill(0);
function updateManualTotal(){
let total = 0;
for (let i=0;i<DEFAULT_WEIGHTS.length;i++) total += DEFAULT_WEIGHTS[i] * manualCounts[i];
manualTotalEl.textContent = fmtKg(total);
for (let i=0;i<DEFAULT_WEIGHTS.length;i++){
const el = document.getElementById(`mcount-${i}`);
if (el) el.value = String(manualCounts[i]);
}
}
document.addEventListener('click', (e) => {
const btn = e.target.closest && e.target.closest('button.btn-step:not([data-role="avail"])');
if (!btn) return;
const idx = parseInt(btn.dataset.idx, 10);
if (!Number.isFinite(idx)) return;
const act = btn.dataset.act;
if (act === 'inc') manualCounts[idx] += 1;
if (act === 'dec') manualCounts[idx] = Math.max(0, manualCounts[idx] - 1);
updateManualTotal();
});
document.addEventListener('input', (e) => {
const inp = e.target.closest && e.target.closest('input.mcount');
if (!inp) return;
const idx = parseInt(inp.dataset.idx, 10);
if (!Number.isFinite(idx)) return;
let v = parseInt(inp.value, 10);
if (!Number.isFinite(v) || v < 0) v = 0;
manualCounts[idx] = v;
updateManualTotal();
});
manualResetBtn.addEventListener('click', () => {
for (let i=0;i<manualCounts.length;i++) manualCounts[i] = 0;
updateManualTotal();
});
// init
renderWeights(DEFAULT_WEIGHTS);
setClearedOutputs();
renderManualCalculator();
updateManualTotal();
</script>
</body>
</html>