Website_from_Claude / template.html
KristofferSolberg's picture
Upload 2 files
a1bbfb8 verified
Raw History Blame Contribute Delete
18.9 kB
<!DOCTYPE html>
<html lang="en" data-theme="green">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Maritime Accident Terminal</title>
<meta name="description" content="Pick a vessel group and a year to see the predicted number of maritime accidents in Norway.">
<meta name="theme-color" content="#030905">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' fill='%23030905'/><path d='M4 20c4 4 8-4 12 0s8 4 12 0' stroke='%233dff7a' stroke-width='3' fill='none'/></svg>">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=VT323&display=swap" rel="stylesheet">
<style>
:root{--bg:#030905;--ph:#3dff7a;--dim:#2bbf63;--faint:#0f3d21;--rgb:61,255,122}
[data-theme=amber]{--bg:#0a0602;--ph:#ffb53d;--dim:#d18f26;--faint:#3d2a0a;--rgb:255,181,61}
[data-theme=ice]{--bg:#02080a;--ph:#6df0ff;--dim:#37b8c9;--faint:#0c3640;--rgb:109,240,255}
*{box-sizing:border-box}
html{background:#000}
body{margin:0;min-height:100vh;display:grid;place-items:center;padding:20px;background:#000;color:var(--ph);
font-family:'Share Tech Mono',ui-monospace,'Courier New',monospace;font-size:16px;line-height:1.45}
.glow{text-shadow:0 0 6px rgba(var(--rgb),.55),0 0 18px rgba(var(--rgb),.25)}
.device{width:min(1020px,100%);border:2px solid var(--dim);border-radius:20px;padding:14px;background:#000;
box-shadow:0 0 0 6px #000,0 0 0 7px var(--faint),0 0 40px rgba(var(--rgb),.12)}
.screen{position:relative;border:1px solid var(--faint);border-radius:10px;overflow:hidden;min-height:600px;
background:radial-gradient(ellipse at 50% 40%,rgba(var(--rgb),.09),transparent 70%),var(--bg)}
.screen::before{content:"";position:absolute;inset:0;pointer-events:none;z-index:6;
background:radial-gradient(ellipse at center,transparent 55%,rgba(0,0,0,.6) 100%)}
.screen::after{content:"";position:absolute;inset:0;pointer-events:none;z-index:7;
background:repeating-linear-gradient(to bottom,rgba(0,0,0,.30) 0 1px,transparent 1px 3px)}
.content{position:relative;z-index:2;animation:flick 7s infinite}
@keyframes flick{0%,100%{opacity:1}47%{opacity:1}50%{opacity:.965}53%{opacity:1}}
header{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap;padding:20px 24px 0}
h1{font-family:'VT323',monospace;font-weight:400;font-size:clamp(34px,6vw,52px);line-height:1;margin:0;letter-spacing:.05em;text-transform:uppercase}
.sub{margin:4px 0 0;color:var(--dim);font-size:14px}
.themes{display:flex;gap:8px;align-items:center;color:var(--dim);font-size:13px}
.themes button{width:22px;height:22px;border:1px solid var(--dim);background:var(--c);cursor:pointer;padding:0}
.themes button[aria-pressed=true]{outline:2px solid var(--ph);outline-offset:2px}
nav{display:flex;gap:0;margin:18px 24px 0;border-bottom:1px solid var(--dim)}
nav button{font:inherit;font-size:18px;letter-spacing:.12em;text-transform:uppercase;color:var(--dim);background:none;border:0;
padding:8px 22px;cursor:pointer}
nav button:hover{color:var(--ph)}
nav button[aria-selected=true]{background:var(--ph);color:var(--bg);text-shadow:none}
button:focus-visible,select:focus-visible,input:focus-visible{outline:2px solid var(--ph);outline-offset:3px}
.panel{display:none;padding:22px 24px 8px}
.panel.on{display:block;animation:in .35s ease-out}
@keyframes in{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
.grid{display:grid;grid-template-columns:minmax(250px,5fr) 7fr;gap:28px}
@media (max-width:780px){.grid{grid-template-columns:1fr}.screen{min-height:0}}
.field{margin-bottom:26px}
label,.lbl{display:block;color:var(--dim);font-size:13px;letter-spacing:.14em;text-transform:uppercase;margin-bottom:8px}
.sel{position:relative}
.sel::after{content:"\25BC";position:absolute;right:14px;top:50%;transform:translateY(-50%);color:var(--dim);font-size:12px;pointer-events:none}
select{width:100%;appearance:none;-webkit-appearance:none;font:inherit;font-size:20px;color:var(--ph);background:var(--bg);
border:1px solid var(--dim);border-radius:0;padding:12px 40px 12px 14px;cursor:pointer}
select option{background:#000;color:var(--ph)}
.gloss{margin:6px 0 0;color:var(--dim);font-size:14px;min-height:21px}
.yearrow{display:flex;align-items:center;gap:12px}
.yearrow input{flex:1;min-width:0}
.step{width:40px;height:40px;font:inherit;font-size:22px;color:var(--ph);background:none;border:1px solid var(--dim);cursor:pointer}
.step:hover{background:var(--ph);color:var(--bg)}
#yearOut{font-family:'VT323',monospace;font-size:76px;line-height:1;margin:0 0 6px}
input[type=range]{-webkit-appearance:none;appearance:none;height:32px;background:transparent;cursor:pointer}
input[type=range]::-webkit-slider-runnable-track{height:2px;background:var(--dim)}
input[type=range]::-moz-range-track{height:2px;background:var(--dim)}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:28px;margin-top:-13px;background:var(--ph);border:0;border-radius:0;box-shadow:0 0 10px rgba(var(--rgb),.6)}
input[type=range]::-moz-range-thumb{width:16px;height:28px;background:var(--ph);border:0;border-radius:0;box-shadow:0 0 10px rgba(var(--rgb),.6)}
.ends{display:flex;justify-content:space-between;color:var(--dim);font-size:13px;margin-top:2px}
.readout{border:1px solid var(--faint);padding:18px 20px;background:rgba(var(--rgb),.03)}
.cap{color:var(--dim);font-size:13px;letter-spacing:.14em;text-transform:uppercase}
.big{font-family:'VT323',monospace;font-size:clamp(96px,15vw,160px);line-height:.9;margin:8px 0 4px}
.stats{margin:14px 0 0;padding:0;list-style:none}
.stats li{display:flex;gap:8px;align-items:baseline;margin:4px 0}
.stats .dots{flex:1;border-bottom:1px dotted var(--dim);transform:translateY(-4px)}
.stats .k{color:var(--dim)}
.note{margin:14px 0 0;padding:8px 10px;border:1px dashed var(--dim);color:var(--dim);font-size:14px}
.note[hidden]{display:none}
.chart{margin-top:18px}
.chart svg{width:100%;height:auto;display:block}
.chart text{fill:var(--dim);font-family:'Share Tech Mono',monospace;font-size:11px}
.row{display:grid;grid-template-columns:22px minmax(120px,210px) 1fr 54px;gap:10px;align-items:center;width:100%;
font:inherit;color:var(--ph);background:none;border:0;border-bottom:1px solid var(--faint);padding:9px 0;text-align:left;cursor:pointer}
.row:hover{background:rgba(var(--rgb),.06)}
.row .mark{color:var(--ph);visibility:hidden}
.row.sel-on .mark{visibility:visible}
.row .nm small{display:block;color:var(--dim);font-size:12px}
.bar{height:12px;background:var(--faint)}
.bar i{display:block;height:100%;background:var(--ph);box-shadow:0 0 8px rgba(var(--rgb),.5)}
.row .v{text-align:right;font-family:'VT323',monospace;font-size:26px;line-height:1}
.about p{max-width:68ch;margin:0 0 14px}
.about h2{font-family:'VT323',monospace;font-weight:400;font-size:28px;letter-spacing:.06em;margin:18px 0 6px;text-transform:uppercase}
.status{display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;margin:18px 24px 0;padding:10px 0 16px;border-top:1px solid var(--dim);color:var(--dim);font-size:13px;letter-spacing:.08em;text-transform:uppercase}
.cursor{animation:blink 1s steps(2) infinite}
@keyframes blink{50%{opacity:0}}
#boot{position:absolute;inset:0;z-index:5;background:var(--bg);padding:28px;display:flex;flex-direction:column;justify-content:flex-end;
font-family:'VT323',monospace;font-size:26px;line-height:1.3;cursor:pointer}
#boot .skip{color:var(--dim);font-size:20px;margin-top:10px}
noscript{display:block;padding:24px}
@media (prefers-reduced-motion:reduce){.content,.panel.on,.cursor{animation:none}}
</style>
</head>
<body>
<div class="device">
<div class="screen" id="screen">
<div class="content">
<header>
<div>
<h1 class="glow">Maritime Accident Terminal</h1>
<p class="sub">Norway &middot; predicted accidents per year, by vessel group</p>
</div>
<div class="themes" role="group" aria-label="Screen colour">
<span>Colour</span>
<button style="--c:#3dff7a" data-theme="green" aria-label="Green" aria-pressed="true"></button>
<button style="--c:#ffb53d" data-theme="amber" aria-label="Amber" aria-pressed="false"></button>
<button style="--c:#6df0ff" data-theme="ice" aria-label="Ice blue" aria-pressed="false"></button>
</div>
</header>
<nav role="tablist" aria-label="Screens">
<button role="tab" id="t-predict" aria-selected="true" aria-controls="p-predict">Predict</button>
<button role="tab" id="t-compare" aria-selected="false" aria-controls="p-compare">Compare</button>
<button role="tab" id="t-about" aria-selected="false" aria-controls="p-about">About</button>
</nav>
<section class="panel on" id="p-predict" role="tabpanel" aria-labelledby="t-predict">
<div class="grid">
<div>
<div class="field">
<label for="group">Vessel group</label>
<div class="sel"><select id="group"></select></div>
<p class="gloss" id="gloss"></p>
</div>
<div class="field">
<label for="year">Accident year</label>
<div id="yearOut" class="glow" aria-hidden="true"></div>
<div class="yearrow">
<button class="step" id="dec" aria-label="Previous year">&minus;</button>
<input type="range" id="year" step="1">
<button class="step" id="inc" aria-label="Next year">+</button>
</div>
<div class="ends"><span id="yMin"></span><span id="yMax"></span></div>
</div>
</div>
<div class="readout" aria-live="polite">
<div class="cap" id="cap"></div>
<div class="big glow" id="big">0</div>
<ul class="stats">
<li><span class="k">Raw model output</span><span class="dots"></span><span id="sRaw"></span></li>
<li><span class="k">Change vs previous year</span><span class="dots"></span><span id="sDelta"></span></li>
<li><span class="k" id="sRankK">Rank</span><span class="dots"></span><span id="sRank"></span></li>
</ul>
<p class="note" id="flatNote" hidden>The model stops changing after <b id="flatYear"></b>, so later years repeat that level. Read them as &ldquo;the latest trend continues&rdquo;, not as a forecast.</p>
<div class="chart" id="chart"></div>
</div>
</div>
</section>
<section class="panel" id="p-compare" role="tabpanel" aria-labelledby="t-compare" hidden>
<p class="cap" id="cmpCap" style="margin-top:0"></p>
<div id="cmp"></div>
</section>
<section class="panel about" id="p-about" role="tabpanel" aria-labelledby="t-about" hidden>
<h2>What this is</h2>
<p>An XGBoost regression model estimates how many maritime accidents happen in Norway in a given year for a given vessel group. Choose the vessel group and the year and the terminal shows the model's answer.</p>
<h2>How it works</h2>
<p>The two inputs have a limited set of values, so every possible answer was computed in advance with the trained model and stored in this page. There is no server and nothing is sent anywhere. The numbers are identical to what the model returns.</p>
<h2>Read with care</h2>
<p>These are yearly totals for a whole vessel group, not the risk of a single ship or voyage. Tree models cannot extend a trend past the years they were trained on, so far-future years mostly repeat the last known level.</p>
</section>
<div class="status"><span id="stGroup"></span><span id="stYear"></span><span>XGBoost regressor<span class="cursor"> &#9608;</span></span></div>
</div>
<div id="boot" aria-hidden="true"><div id="bootText"></div><div class="skip">[ click or press any key ]</div></div>
</div>
</div>
<noscript>This terminal needs JavaScript to run.</noscript>
<script>
const D = /*__DATA__*/null;
const GLOSS = {"Fiskefartøy":"Fishing vessel","Flyttbar innretning":"Mobile offshore unit","Fritidsfartøy":"Recreational craft",
"Lasteskip":"Cargo ship","Passasjerskip":"Passenger ship","Ukjent":"Unknown","Ukjent fartøy":"Unknown vessel"};
const $ = s => document.querySelector(s);
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const [Y0, Y1] = D.years;
const state = {group: D.groups[0], year: Math.min(Math.max(2024, Y0), Y1)};
const idx = () => state.year - Y0;
// ---------- setup controls ----------
const sel = $('#group'), yr = $('#year');
sel.innerHTML = D.groups.map(g => `<option value="${esc(g)}">${esc(g)}</option>`).join('');
yr.min = Y0; yr.max = Y1; $('#yMin').textContent = Y0; $('#yMax').textContent = Y1;
sel.addEventListener('change', () => { state.group = sel.value; render(); });
yr.addEventListener('input', () => { state.year = +yr.value; render(); });
$('#dec').addEventListener('click', () => { state.year = Math.max(Y0, state.year - 1); render(); });
$('#inc').addEventListener('click', () => { state.year = Math.min(Y1, state.year + 1); render(); });
// tabs
const tabs = ['predict', 'compare', 'about'];
tabs.forEach(t => $('#t-' + t).addEventListener('click', () => showTab(t)));
function showTab(name) {
tabs.forEach(t => {
const on = t === name;
$('#t-' + t).setAttribute('aria-selected', on);
const p = $('#p-' + t); p.classList.toggle('on', on); p.hidden = !on;
});
}
document.querySelector('nav').addEventListener('keydown', e => {
const i = tabs.findIndex(t => $('#t-' + t).getAttribute('aria-selected') === 'true');
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
const n = tabs[(i + (e.key === 'ArrowRight' ? 1 : tabs.length - 1)) % tabs.length];
showTab(n); $('#t-' + n).focus();
}
});
// colour theme
function setTheme(t) {
document.documentElement.dataset.theme = t;
document.querySelectorAll('.themes button').forEach(b => b.setAttribute('aria-pressed', b.dataset.theme === t));
try { localStorage.setItem('mat-theme', t); } catch (e) {}
}
document.querySelectorAll('.themes button').forEach(b => b.addEventListener('click', () => setTheme(b.dataset.theme)));
try { const t = localStorage.getItem('mat-theme'); if (t) setTheme(t); } catch (e) {}
// ---------- rendering ----------
let shown = 0, raf;
function setBig(n) {
const el = $('#big');
cancelAnimationFrame(raf);
if (reduce) { el.textContent = n; shown = n; return; }
const from = shown, t0 = performance.now(), dur = 380;
const step = t => {
const k = Math.min(1, (t - t0) / dur);
shown = Math.round(from + (n - from) * (1 - Math.pow(1 - k, 3)));
el.textContent = shown;
if (k < 1) raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
}
function chart(g) {
const v = D.raw[g], n = v.length, W = 600, H = 210, pl = 38, pr = 10, pt = 12, pb = 26;
const max = Math.max(1, ...v) * 1.1;
const x = i => pl + (W - pl - pr) * i / (n - 1), y = val => pt + (H - pt - pb) * (1 - val / max);
const pts = v.map((val, i) => `${x(i).toFixed(1)},${y(val).toFixed(1)}`).join(' ');
const i = idx();
let flat = '';
if (D.flatFrom && D.flatFrom < Y1) {
const x0 = x(D.flatFrom - Y0);
flat = `<rect x="${x0}" y="${pt}" width="${x(n - 1) - x0}" height="${H - pt - pb}" fill="rgba(var(--rgb),.06)"/>
<text x="${x0 + 6}" y="${pt + 12}">flat after ${D.flatFrom}</text>`;
}
const grid = [0, .5, 1].map(f => {
const val = max * f, yy = y(val);
return `<line x1="${pl}" x2="${W - pr}" y1="${yy}" y2="${yy}" stroke="var(--faint)"/><text x="${pl - 6}" y="${yy + 4}" text-anchor="end">${val < 10 ? val.toFixed(1) : Math.round(val)}</text>`;
}).join('');
const mid = Math.round((Y0 + Y1) / 2);
return `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="Predicted accidents per year for ${esc(g)}, ${Y0} to ${Y1}">
${grid}${flat}
<polyline points="${pts}" fill="none" stroke="var(--ph)" stroke-width="2" stroke-linejoin="round" style="filter:drop-shadow(0 0 4px rgba(var(--rgb),.7))"/>
<line x1="${x(i)}" x2="${x(i)}" y1="${pt}" y2="${H - pb}" stroke="var(--ph)" stroke-dasharray="3 4"/>
<circle cx="${x(i)}" cy="${y(v[i])}" r="5" fill="var(--bg)" stroke="var(--ph)" stroke-width="2"/>
<text x="${x(0)}" y="${H - 8}" text-anchor="middle">${Y0}</text>
<text x="${x(mid - Y0)}" y="${H - 8}" text-anchor="middle">${mid}</text>
<text x="${x(n - 1)}" y="${H - 8}" text-anchor="middle">${Y1}</text></svg>`;
}
function render() {
const g = state.group, i = idx();
sel.value = g; yr.value = state.year;
$('#gloss').textContent = GLOSS[g] || '';
$('#yearOut').textContent = state.year;
$('#cap').textContent = `Predicted accidents in ${state.year}`;
setBig(D.n[g][i]);
$('#sRaw').textContent = D.raw[g][i].toFixed(2);
if (i > 0) {
const d = D.n[g][i] - D.n[g][i - 1];
$('#sDelta').textContent = (d > 0 ? '+' : '') + d;
} else $('#sDelta').textContent = 'n/a';
const order = D.groups.slice().sort((a, b) => D.raw[b][i] - D.raw[a][i]);
$('#sRankK').textContent = `Rank among groups in ${state.year}`;
$('#sRank').textContent = `${order.indexOf(g) + 1} / ${D.groups.length}`;
const flat = D.flatFrom && state.year > D.flatFrom;
$('#flatNote').hidden = !flat; $('#flatYear').textContent = D.flatFrom;
$('#chart').innerHTML = chart(g);
// compare screen
$('#cmpCap').textContent = `All vessel groups, ${state.year} (use the slider on the Predict screen to change the year)`;
const top = Math.max(1, ...order.map(k => D.raw[k][i]));
$('#cmp').innerHTML = order.map(k => `
<button class="row ${k === g ? 'sel-on' : ''}" data-g="${esc(k)}">
<span class="mark">&#9654;</span>
<span class="nm">${esc(k)}<small>${esc(GLOSS[k] || '')}</small></span>
<span class="bar"><i style="width:${(D.raw[k][i] / top * 100).toFixed(1)}%"></i></span>
<span class="v">${D.n[k][i]}</span></button>`).join('');
document.querySelectorAll('#cmp .row').forEach(b => b.addEventListener('click', () => { state.group = b.dataset.g; render(); }));
$('#stGroup').textContent = `Group: ${g}`;
$('#stYear').textContent = `Year: ${state.year}`;
}
// ---------- boot sequence ----------
(function boot() {
const el = $('#boot');
const done = () => { el.remove(); };
if (reduce) return done();
const lines = ['MARITIME ACCIDENT TERMINAL', 'LOADING MODEL ........ OK', `${D.groups.length} VESSEL GROUPS INDEXED`, `${Y0}-${Y1} READY`];
let k = 0;
const timer = setInterval(() => {
if (k < lines.length) $('#bootText').innerHTML += esc(lines[k++]) + '<br>';
else { clearInterval(timer); setTimeout(done, 350); }
}, 260);
const skip = () => { clearInterval(timer); done(); removeEventListener('keydown', skip); };
el.addEventListener('click', skip); addEventListener('keydown', skip, {once: true});
})();
render();
</script>
</body>
</html>