785 lines
23 KiB
HTML
785 lines
23 KiB
HTML
<!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>
|