- Koa server on port 10000 with WebSocket live feed from 112-nu.nl RSS - PM2 watch mode for auto-restart on file changes - Dark navy UI with per-type color accents (fire/ambulance/police/rescue) - Slide-in filter panel with service type + 12 Dutch province filters - Card click opens detail modal: parsed priority (A1/A2/MGS), vehicle number, rit/bon number, alarm type, meldkamer, and eenheden - Server-side Nominatim geocoder (cached, rate-limited) powering an interactive Leaflet/OpenStreetMap map in the modal (CartoDB Voyager tiles) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
859 lines
48 KiB
HTML
859 lines
48 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="nl" class="dark">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>P-2000 Monitor</title>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script>tailwind.config = { darkMode: 'class' };</script>
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
:root {
|
|
--bg: #0c1826;
|
|
--s1: #112236;
|
|
--s2: #162d44;
|
|
--s3: #1c3654;
|
|
--b1: rgba(96,148,210,.18);
|
|
--b2: rgba(96,148,210,.1);
|
|
--tx: #dde6f8;
|
|
--mu: #6e8ab5;
|
|
--mu2: #3e5578;
|
|
--fire: #fb923c;
|
|
--amb: #2dd4a0;
|
|
--pol: #818cf8;
|
|
--res: #fbbf24;
|
|
--oth: #7a90b8;
|
|
|
|
--fire-bg: rgba(251,146,60,.07);
|
|
--amb-bg: rgba(45,212,160,.06);
|
|
--pol-bg: rgba(129,140,248,.07);
|
|
--res-bg: rgba(251,191,36,.06);
|
|
}
|
|
|
|
html, body { height: 100%; overflow: hidden; }
|
|
body {
|
|
background: var(--bg);
|
|
color: var(--tx);
|
|
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
font-size: 14px;
|
|
display: flex; flex-direction: column;
|
|
}
|
|
::-webkit-scrollbar { width: 4px; }
|
|
::-webkit-scrollbar-track { background: transparent; }
|
|
::-webkit-scrollbar-thumb { background: rgba(96,148,210,.25); border-radius: 2px; }
|
|
|
|
/* ── Header ──────────────────────────────────────────────────────── */
|
|
#hdr {
|
|
flex-shrink: 0;
|
|
background: rgba(12,24,38,.96);
|
|
border-bottom: 1px solid var(--b1);
|
|
backdrop-filter: blur(12px);
|
|
}
|
|
.hdr-main {
|
|
display: flex; align-items: center; gap: 10px;
|
|
padding: 0 14px; height: 48px;
|
|
}
|
|
.logo {
|
|
display: flex; align-items: center; gap: 7px;
|
|
font-weight: 700; font-size: 15px; color: #fff;
|
|
letter-spacing: -.3px; flex-shrink: 0;
|
|
}
|
|
.sep { width: 1px; height: 18px; background: var(--b1); flex-shrink: 0; }
|
|
.hdr-space { flex: 1; }
|
|
.hdr-right { display: flex; align-items: center; gap: 6px; }
|
|
|
|
.hd-btn {
|
|
display: inline-flex; align-items: center; gap: 5px;
|
|
height: 30px; padding: 0 11px; border-radius: 7px;
|
|
border: 1px solid var(--b1); background: var(--s1);
|
|
color: var(--mu); font-size: 12px; font-weight: 500;
|
|
cursor: pointer; white-space: nowrap; user-select: none;
|
|
transition: all .15s;
|
|
}
|
|
.hd-btn:hover { background: var(--s2); color: var(--tx); border-color: rgba(96,148,210,.3); }
|
|
.hd-btn.on { color: var(--tx); border-color: rgba(129,140,248,.4); background: var(--s2); }
|
|
|
|
.conn-p {
|
|
display: inline-flex; align-items: center; gap: 5px;
|
|
padding: 0 10px; height: 28px; border-radius: 9999px;
|
|
border: 1px solid var(--b1); background: var(--s1);
|
|
font-size: 11px; color: var(--mu);
|
|
}
|
|
.cdot { width: 6px; height: 6px; border-radius: 50%; }
|
|
.cdot.on { background: var(--amb); box-shadow: 0 0 5px var(--amb); animation: pulse 2s infinite; }
|
|
.cdot.off { background: var(--fire); }
|
|
.cdot.wait { background: var(--res); animation: pulse .8s infinite; }
|
|
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
|
|
|
|
/* ── Stats bar ───────────────────────────────────────────────────── */
|
|
.stats-bar {
|
|
display: flex; align-items: center; gap: 0;
|
|
border-top: 1px solid var(--b2);
|
|
padding: 0 14px;
|
|
height: 34px; overflow: hidden;
|
|
}
|
|
.sstat {
|
|
display: flex; align-items: center; gap: 6px;
|
|
padding: 0 14px; height: 100%;
|
|
border-right: 1px solid var(--b2);
|
|
font-size: 12px;
|
|
}
|
|
.sstat:first-child { padding-left: 0; }
|
|
.sstat .n { font-weight: 700; font-size: 14px; }
|
|
.sstat .lbl { color: var(--mu); }
|
|
.sstat .dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
|
|
|
/* ── Feed ────────────────────────────────────────────────────────── */
|
|
#feed-wrap { flex: 1; overflow-y: auto; padding: 10px 12px 24px; }
|
|
#feed { max-width: 700px; margin: 0 auto; display: flex; flex-direction: column; gap: 4px; }
|
|
|
|
/* ── Cards ───────────────────────────────────────────────────────── */
|
|
.card {
|
|
position: relative; overflow: hidden;
|
|
border: 1px solid var(--b2); border-radius: 9px;
|
|
padding: 10px 13px 10px 14px;
|
|
cursor: pointer;
|
|
transition: transform .1s, border-color .15s, background .15s;
|
|
animation: cin .22s ease-out;
|
|
}
|
|
.card::before {
|
|
content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
|
|
}
|
|
.card::after {
|
|
content: ''; position: absolute; inset: 0; opacity: 1;
|
|
pointer-events: none;
|
|
}
|
|
.card:active { transform: scale(.995); }
|
|
|
|
.c-fire { background: var(--s1); }
|
|
.c-fire::before { background: var(--fire); }
|
|
.c-fire::after { background: linear-gradient(105deg, var(--fire-bg) 0%, transparent 55%); }
|
|
.c-ambulance { background: var(--s1); }
|
|
.c-ambulance::before { background: var(--amb); }
|
|
.c-ambulance::after { background: linear-gradient(105deg, var(--amb-bg) 0%, transparent 55%); }
|
|
.c-police { background: var(--s1); }
|
|
.c-police::before { background: var(--pol); }
|
|
.c-police::after { background: linear-gradient(105deg, var(--pol-bg) 0%, transparent 55%); }
|
|
.c-rescue { background: var(--s1); }
|
|
.c-rescue::before { background: var(--res); }
|
|
.c-rescue::after { background: linear-gradient(105deg, var(--res-bg) 0%, transparent 55%); }
|
|
.c-other { background: var(--s1); }
|
|
.c-other::before { background: var(--oth); }
|
|
.c-other::after { background: linear-gradient(105deg, rgba(122,144,184,.05) 0%, transparent 55%); }
|
|
|
|
.card:hover { border-color: var(--b1); }
|
|
.c-fire:hover { background: color-mix(in srgb, var(--s1) 80%, var(--fire) 20%); }
|
|
.c-ambulance:hover { background: color-mix(in srgb, var(--s1) 82%, var(--amb) 18%); }
|
|
.c-police:hover { background: color-mix(in srgb, var(--s1) 80%, var(--pol) 20%); }
|
|
.c-rescue:hover { background: color-mix(in srgb, var(--s1) 82%, var(--res) 18%); }
|
|
.c-other:hover { background: var(--s2); }
|
|
|
|
.card.new { animation: cin .22s ease-out, flash .5s ease-out .1s; }
|
|
@keyframes cin { from { opacity:0; transform:translateY(-5px); } to { opacity:1; transform:translateY(0); } }
|
|
@keyframes flash { 0%,100%{} 40%{ border-color: rgba(129,140,248,.35); } }
|
|
|
|
.c-row { display: flex; align-items: flex-start; gap: 8px; position: relative; z-index: 1; }
|
|
.c-left { flex: 1; min-width: 0; }
|
|
.c-right{ flex-shrink: 0; text-align: right; }
|
|
|
|
.c-meta { display: flex; align-items: center; gap: 5px; margin-bottom: 3px; flex-wrap: wrap; }
|
|
.ttag {
|
|
display: inline-flex; align-items: center; gap: 3px;
|
|
font-size: 9.5px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
|
|
padding: 1px 6px; border-radius: 4px; flex-shrink: 0;
|
|
}
|
|
.t-fire { background: rgba(251,146,60,.18); color: var(--fire); border: 1px solid rgba(251,146,60,.28); }
|
|
.t-ambulance { background: rgba(45,212,160,.15); color: var(--amb); border: 1px solid rgba(45,212,160,.25); }
|
|
.t-police { background: rgba(129,140,248,.18); color: var(--pol); border: 1px solid rgba(129,140,248,.28); }
|
|
.t-rescue { background: rgba(251,191,36,.18); color: var(--res); border: 1px solid rgba(251,191,36,.28); }
|
|
.t-other { background: rgba(122,144,184,.12); color: var(--oth); border: 1px solid rgba(122,144,184,.2); }
|
|
|
|
.ltag {
|
|
display: inline-flex; align-items: center; gap: 3px;
|
|
font-size: 10.5px; color: var(--mu); padding: 1px 5px;
|
|
border: 1px solid var(--b2); border-radius: 4px;
|
|
}
|
|
.prio-tag {
|
|
display: inline-flex; align-items: center;
|
|
font-size: 9.5px; font-weight: 700; letter-spacing: .04em;
|
|
padding: 1px 5px; border-radius: 4px;
|
|
}
|
|
.prio-critical { background: rgba(251,146,60,.18); color: var(--fire); border: 1px solid rgba(251,146,60,.28); }
|
|
.prio-urgent { background: rgba(251,191,36,.15); color: var(--res); border: 1px solid rgba(251,191,36,.25); }
|
|
.prio-normal { background: rgba(45,212,160,.12); color: var(--amb); border: 1px solid rgba(45,212,160,.2); }
|
|
|
|
.c-title { font-size: 12.5px; font-weight: 500; color: #dde6f8; line-height: 1.4; }
|
|
.c-sub { font-size: 11px; color: var(--mu); margin-top: 2px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
|
.c-sub .pill {
|
|
display: inline-flex; align-items: center; gap: 3px;
|
|
padding: 1px 6px; border-radius: 4px;
|
|
background: rgba(255,255,255,.05); border: 1px solid var(--b2);
|
|
font-size: 10px; font-weight: 500; color: var(--mu);
|
|
}
|
|
.c-time { font-size: 11px; color: var(--mu); font-variant-numeric: tabular-nums; }
|
|
.c-ago { font-size: 10px; color: var(--mu2); margin-top: 2px; }
|
|
|
|
/* ── State ────────────────────────────────────────────────────────── */
|
|
.sv { text-align: center; padding: 72px 0; color: var(--mu2); font-size: 13px; }
|
|
.sv .ic { font-size: 28px; margin-bottom: 8px; opacity: .4; }
|
|
|
|
/* ── Load more ────────────────────────────────────────────────────── */
|
|
#lm-wrap { display: none; justify-content: center; margin-top: 8px; }
|
|
#lm-btn {
|
|
display: inline-flex; align-items: center; gap: 5px;
|
|
padding: 6px 16px; border-radius: 7px;
|
|
border: 1px solid var(--b1); background: var(--s1);
|
|
color: var(--mu); font-size: 12px; cursor: pointer; transition: all .15s;
|
|
}
|
|
#lm-btn:hover:not(:disabled) { background: var(--s2); color: var(--tx); }
|
|
#lm-btn:disabled { opacity: .4; cursor: default; }
|
|
|
|
/* ── Filter panel ─────────────────────────────────────────────────── */
|
|
#ov { display:none; position:fixed; inset:0; background:rgba(5,12,22,.6); z-index:40; backdrop-filter:blur(3px); }
|
|
#ov.on { display:block; }
|
|
#pnl {
|
|
position:fixed; right:0; top:0; bottom:0; width:272px; z-index:50;
|
|
background: #0e1e30;
|
|
border-left: 1px solid var(--b1);
|
|
display:flex; flex-direction:column;
|
|
transform:translateX(100%); transition:transform .22s cubic-bezier(.4,0,.2,1);
|
|
}
|
|
#pnl.on { transform:translateX(0); }
|
|
.ph { display:flex; align-items:center; justify-content:space-between; padding:13px 14px; border-bottom:1px solid var(--b1); }
|
|
.ph h2 { font-size:13px; font-weight:600; color:#fff; }
|
|
.px { width:26px; height:26px; border-radius:5px; border:1px solid var(--b1); background:transparent; color:var(--mu); cursor:pointer; font-size:14px; display:flex; align-items:center; justify-content:center; transition:all .12s; }
|
|
.px:hover { background:var(--s2); color:var(--tx); }
|
|
.pb { flex:1; overflow-y:auto; padding:13px; display:flex; flex-direction:column; gap:16px; }
|
|
.ps h3 { font-size:10px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--mu2); margin-bottom:7px; }
|
|
.pf { padding:10px 14px; border-top:1px solid var(--b1); }
|
|
.rst { width:100%; padding:7px; border-radius:6px; border:1px solid var(--b1); background:transparent; color:var(--mu); font-size:12px; cursor:pointer; transition:all .12s; }
|
|
.rst:hover { background:var(--s2); color:var(--tx); }
|
|
|
|
.tb-list, .rb-list { display:flex; flex-direction:column; gap:2px; }
|
|
.tb, .rb {
|
|
display:flex; align-items:center; gap:7px; padding:6px 9px;
|
|
border-radius:6px; border:1px solid transparent;
|
|
background:transparent; color:var(--mu); font-size:12.5px;
|
|
cursor:pointer; transition:all .1s; text-align:left; width:100%;
|
|
}
|
|
.tb:hover, .rb:hover { background:var(--s2); color:var(--tx); }
|
|
.tb.on, .rb.on { background:var(--s2); color:var(--tx); border-color:var(--b1); }
|
|
.tb .tbd { width:7px; height:7px; border-radius:50%; flex-shrink:0; }
|
|
.tb .tbn, .rb .rbn { margin-left:auto; font-size:10px; color:var(--mu2); }
|
|
|
|
.tgl-row { display:flex; align-items:center; justify-content:space-between; }
|
|
.tgl-row label { font-size:12.5px; color:var(--mu); cursor:pointer; }
|
|
.tgl { position:relative; display:inline-block; width:34px; height:18px; flex-shrink:0; }
|
|
.tgl input { opacity:0; width:0; height:0; }
|
|
.tgl-s { position:absolute; inset:0; background:rgba(96,148,210,.2); border-radius:18px; cursor:pointer; transition:.18s; }
|
|
.tgl-s::before { content:''; position:absolute; width:12px; height:12px; left:3px; bottom:3px; background:#7a8ab8; border-radius:50%; transition:.18s; }
|
|
.tgl input:checked + .tgl-s { background:rgba(129,140,248,.4); }
|
|
.tgl input:checked + .tgl-s::before { transform:translateX(16px); background:var(--pol); }
|
|
|
|
/* ── Detail modal ─────────────────────────────────────────────────── */
|
|
#mdl-ov {
|
|
display:none; position:fixed; inset:0;
|
|
background:rgba(6,14,24,.75); z-index:60;
|
|
backdrop-filter:blur(6px);
|
|
align-items:center; justify-content:center; padding:16px;
|
|
}
|
|
#mdl-ov.on { display:flex; }
|
|
#mdl {
|
|
background: #0e1e2f;
|
|
border: 1px solid var(--b1);
|
|
border-radius: 14px;
|
|
width: 100%; max-width: 480px;
|
|
max-height: 90vh; overflow-y: auto;
|
|
animation: cin .22s ease-out;
|
|
box-shadow: 0 24px 80px rgba(0,0,0,.6);
|
|
}
|
|
|
|
/* colored top band on modal */
|
|
.mdl-top {
|
|
padding: 16px 16px 14px;
|
|
border-bottom: 1px solid var(--b2);
|
|
position: relative; overflow: hidden;
|
|
}
|
|
.mdl-top::before {
|
|
content: ''; position: absolute; inset: 0;
|
|
opacity: 1; pointer-events: none;
|
|
}
|
|
.mdl-top-fire::before { background: linear-gradient(135deg, var(--fire-bg) 0%, transparent 60%); }
|
|
.mdl-top-ambulance::before { background: linear-gradient(135deg, var(--amb-bg) 0%, transparent 60%); }
|
|
.mdl-top-police::before { background: linear-gradient(135deg, var(--pol-bg) 0%, transparent 60%); }
|
|
.mdl-top-rescue::before { background: linear-gradient(135deg, var(--res-bg) 0%, transparent 60%); }
|
|
|
|
.mdl-top-row { display:flex; align-items:flex-start; justify-content:space-between; gap:8px; position:relative; z-index:1; }
|
|
.mdl-badges { display:flex; align-items:center; gap:5px; flex-wrap:wrap; margin-bottom:8px; }
|
|
.mdl-title { font-size:15px; font-weight:600; color:#fff; line-height:1.4; position:relative; z-index:1; }
|
|
.mdl-x { width:28px; height:28px; border-radius:6px; border:1px solid var(--b1); background:rgba(14,30,47,.8); color:var(--mu); cursor:pointer; font-size:14px; display:flex; align-items:center; justify-content:center; transition:all .12s; flex-shrink:0; }
|
|
.mdl-x:hover { background:var(--s2); color:var(--tx); }
|
|
|
|
.mdl-body { padding: 14px 16px; display:flex; flex-direction:column; gap:12px; }
|
|
|
|
/* info grid */
|
|
.info-grid { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
|
.info-cell {
|
|
background: var(--s1); border:1px solid var(--b2); border-radius:8px;
|
|
padding: 9px 11px;
|
|
}
|
|
.info-cell.span2 { grid-column: 1/-1; }
|
|
.info-lbl { font-size:9.5px; font-weight:700; letter-spacing:.07em; text-transform:uppercase; color:var(--mu2); margin-bottom:3px; }
|
|
.info-val { font-size:13px; color:var(--tx); font-weight:500; line-height:1.4; }
|
|
.info-val.mono { font-family: ui-monospace, monospace; letter-spacing:.02em; }
|
|
.info-val.dim { color:var(--mu); font-weight:400; font-size:12px; }
|
|
|
|
#map-wrap { border-radius:9px; overflow:hidden; border:1px solid var(--b2); background:var(--s1); position:relative; }
|
|
#map-el { height:200px; width:100%; }
|
|
#map-spin { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; background:var(--s1); font-size:12px; color:var(--mu); gap:6px; }
|
|
/* Leaflet overrides */
|
|
.leaflet-container { background: #e8e0d8; }
|
|
.leaflet-control-attribution { background: rgba(255,255,255,.8) !important; color: #555 !important; font-size:9px !important; }
|
|
.leaflet-control-attribution a { color: #3366cc !important; }
|
|
.leaflet-bar a { background: #fff !important; color: #333 !important; border-color: #ccc !important; }
|
|
.leaflet-bar a:hover { background: #f4f4f4 !important; }
|
|
.leaflet-popup-content-wrapper { background:var(--s2); color:var(--tx); border:1px solid var(--b1); border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,.4); }
|
|
.leaflet-popup-tip { background:var(--s2); }
|
|
.mdl-desc { font-size:12.5px; color:var(--mu); line-height:1.55; background:var(--s1); border:1px solid var(--b2); border-radius:8px; padding:10px 12px; font-family:ui-monospace,monospace; white-space:pre-wrap; word-break:break-all; }
|
|
.mdl-link { display:inline-flex; align-items:center; gap:5px; font-size:12px; color:var(--pol); text-decoration:none; padding:7px 12px; border:1px solid rgba(129,140,248,.2); border-radius:7px; transition:all .12s; }
|
|
.mdl-link:hover { background:rgba(129,140,248,.08); }
|
|
|
|
/* ── Toast ────────────────────────────────────────────────────────── */
|
|
#toast { display:none; position:fixed; bottom:16px; right:16px; z-index:70; background:var(--s2); border:1px solid var(--b1); border-radius:9px; padding:8px 13px; font-size:12px; color:var(--tx); box-shadow:0 8px 32px rgba(0,0,0,.5); max-width:270px; align-items:center; gap:7px; }
|
|
#toast.on { display:flex; animation:cin .2s ease-out; }
|
|
@keyframes spin { to { transform:rotate(360deg); } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<!-- ══ Header ═══════════════════════════════════════════════════════════ -->
|
|
<header id="hdr">
|
|
<div class="hdr-main">
|
|
<div class="logo">
|
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
|
<circle cx="10" cy="10" r="8.5" stroke="var(--fire)" stroke-width="1.3" stroke-opacity=".3"/>
|
|
<circle cx="10" cy="10" r="5" stroke="var(--fire)" stroke-width="1.3" stroke-opacity=".6"/>
|
|
<circle cx="10" cy="10" r="2" fill="var(--fire)"/>
|
|
<line x1="10" y1="1.5" x2="10" y2="4" stroke="var(--fire)" stroke-width="1.2" stroke-opacity=".5"/>
|
|
<line x1="10" y1="16" x2="10" y2="18.5" stroke="var(--fire)" stroke-width="1.2" stroke-opacity=".5"/>
|
|
<line x1="1.5" y1="10" x2="4" y2="10" stroke="var(--fire)" stroke-width="1.2" stroke-opacity=".5"/>
|
|
<line x1="16" y1="10" x2="18.5" y2="10" stroke="var(--fire)" stroke-width="1.2" stroke-opacity=".5"/>
|
|
</svg>
|
|
P-2000
|
|
</div>
|
|
<div class="sep"></div>
|
|
<div class="hdr-space"></div>
|
|
<div class="hdr-right">
|
|
<button class="hd-btn" id="filter-btn" onclick="openPanel()">
|
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
|
|
<line x1="1" y1="3.5" x2="11" y2="3.5"/>
|
|
<line x1="3" y1="6" x2="9" y2="6"/>
|
|
<line x1="5" y1="8.5" x2="7" y2="8.5"/>
|
|
</svg>
|
|
<span id="filter-label">Filters</span>
|
|
</button>
|
|
<div class="conn-p">
|
|
<span class="cdot wait" id="cdot"></span>
|
|
<span id="clbl">…</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="stats-bar">
|
|
<div class="sstat"><span class="dot" style="background:var(--fire)"></span><span class="n" id="cnt-fire" style="color:var(--fire)">0</span><span class="lbl">Brand</span></div>
|
|
<div class="sstat"><span class="dot" style="background:var(--amb)"></span> <span class="n" id="cnt-amb" style="color:var(--amb)">0</span> <span class="lbl">Ambulance</span></div>
|
|
<div class="sstat"><span class="dot" style="background:var(--pol)"></span> <span class="n" id="cnt-pol" style="color:var(--pol)">0</span> <span class="lbl">Politie</span></div>
|
|
<div class="sstat"><span class="dot" style="background:var(--res)"></span> <span class="n" id="cnt-res" style="color:var(--res)">0</span> <span class="lbl">Redding</span></div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- ══ Feed ═════════════════════════════════════════════════════════════ -->
|
|
<div id="feed-wrap">
|
|
<div id="feed">
|
|
<div class="sv" id="sv-load"><div class="ic" style="animation:pulse 1s infinite">📡</div>Verbinden…</div>
|
|
<div class="sv" id="sv-empty" style="display:none"><div class="ic">🔍</div>Geen meldingen voor dit filter.</div>
|
|
</div>
|
|
<div id="lm-wrap">
|
|
<button id="lm-btn" onclick="loadOlder()"><span id="lm-ic">↑</span> <span id="lm-lb">Oudere meldingen</span></button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ══ Filter panel ══════════════════════════════════════════════════════ -->
|
|
<div id="ov" onclick="closePanel()"></div>
|
|
<div id="pnl">
|
|
<div class="ph"><h2>Filters</h2><button class="px" onclick="closePanel()">✕</button></div>
|
|
<div class="pb">
|
|
|
|
<div class="ps">
|
|
<h3>Dienst</h3>
|
|
<div class="tb-list">
|
|
<button class="tb on" data-t="all" onclick="setType('all')"> <span class="tbd" style="background:var(--mu)"></span> Alle diensten <span class="tbn" id="tbc-all">0</span></button>
|
|
<button class="tb" data-t="fire" onclick="setType('fire')"> <span class="tbd" style="background:var(--fire)"></span> Brandweer <span class="tbn" id="tbc-fire">0</span></button>
|
|
<button class="tb" data-t="ambulance" onclick="setType('ambulance')"><span class="tbd" style="background:var(--amb)"></span> Ambulance <span class="tbn" id="tbc-ambulance">0</span></button>
|
|
<button class="tb" data-t="police" onclick="setType('police')"> <span class="tbd" style="background:var(--pol)"></span> Politie <span class="tbn" id="tbc-police">0</span></button>
|
|
<button class="tb" data-t="rescue" onclick="setType('rescue')"> <span class="tbd" style="background:var(--res)"></span> Reddingsdienst <span class="tbn" id="tbc-rescue">0</span></button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="ps">
|
|
<h3>Provincie</h3>
|
|
<div class="rb-list" id="rb-list">
|
|
<button class="rb on" data-r="" onclick="setRegion('')">Alle provincies<span class="rbn" id="rbc-all">0</span></button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="ps">
|
|
<h3>Weergave</h3>
|
|
<div class="tgl-row">
|
|
<label for="asc">Auto-scroll</label>
|
|
<label class="tgl"><input type="checkbox" id="asc" checked onchange="st.autoScroll=this.checked"><span class="tgl-s"></span></label>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
<div class="pf"><button class="rst" onclick="resetFilters()">Filters wissen</button></div>
|
|
</div>
|
|
|
|
<!-- ══ Detail modal ══════════════════════════════════════════════════════ -->
|
|
<div id="mdl-ov" onclick="e=>{if(e.target===e.currentTarget)closeModal()}">
|
|
<div id="mdl">
|
|
<div class="mdl-top" id="mdl-top">
|
|
<div class="mdl-top-row">
|
|
<div id="mdl-badges" class="mdl-badges"></div>
|
|
<button class="mdl-x" onclick="closeModal()">✕</button>
|
|
</div>
|
|
<div class="mdl-title" id="mdl-title"></div>
|
|
</div>
|
|
<div class="mdl-body" id="mdl-body"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ══ Toast ══════════════════════════════════════════════════════════════ -->
|
|
<div id="toast"><span id="t-ic"></span><span id="t-tx"></span></div>
|
|
|
|
<script>
|
|
// ── Province → city slugs ─────────────────────────────────────────────────
|
|
const PROVINCES = {
|
|
'Groningen': ['groningen','veendam','stadskanaal','winschoten','delfzijl','hoogezand','leek','zuidhorn','bedum','loppersum','appingedam','ten-boer','de-marne'],
|
|
'Friesland': ['leeuwarden','sneek','heerenveen','drachten','harlingen','dokkum','franeker','bolsward','joure','burgum','lemmer','makkum','surhuisterveen'],
|
|
'Drenthe': ['assen','emmen','meppel','hoogeveen','coevorden','borger','beilen','roden','klazienaveen','westerbork','nieuw-amsterdam'],
|
|
'Overijssel': ['zwolle','deventer','enschede','hengelo','almelo','kampen','oldenzaal','haaksbergen','hardenberg','ommen','steenwijk','rijssen','hellendoorn','vriezenveen','losser'],
|
|
'Gelderland': ['arnhem','nijmegen','apeldoorn','ede','doetinchem','tiel','zutphen','wageningen','harderwijk','culemborg','winterswijk','aalten','wijchen','beuningen','elburg','nunspeet','barneveld','rheden','zevenaar','duiven','westervoort','huissen','bemmel','doesburg'],
|
|
'Utrecht': ['utrecht','amersfoort','veenendaal','zeist','nieuwegein','houten','de-bilt','soest','baarn','bunschoten','leusden','wijk-bij-duurstede','rhenen','ijsselstein','lopik','montfoort','vianen','maarssen','woerden','abcoude','leidsche-rijn'],
|
|
'Noord-Holland':['amsterdam','haarlem','alkmaar','zaandam','purmerend','amstelveen','velsen-noord','hoorn','den-helder','heerhugowaard','hilversum','enkhuizen','medemblik','edam','volendam','castricum','heiloo','schagen','langedijk','beverwijk','zaanstad','diemen','weesp','muiden','naarden','bussum','huizen','blaricum','laren','uitgeest','krommenie','wormerveer'],
|
|
'Zuid-Holland': ['rotterdam','den-haag','leiden','dordrecht','delft','zoetermeer','gouda','schiedam','spijkenisse','vlaardingen','ridderkerk','capelle-aan-den-ijssel','barendrecht','hendrik-ido-ambacht','papendrecht','zwijndrecht','alblasserdam','sliedrecht','gorinchem','alphen-aan-den-rijn','bodegraven','waddinxveen','berkel-en-rodenrijs','bergschenhoek','nootdorp','leidschendam','voorburg','rijswijk','westland','naaldwijk','maassluis','rhoon','krimpen-aan-den-ijssel','nieuwerkerk-aan-den-ijssel','pijnacker','mijnsheerenland','heinenoord','oud-beijerland','strijen','hoek-van-holland'],
|
|
'Zeeland': ['middelburg','vlissingen','goes','terneuzen','zierikzee','hulst','axel','sas-van-gent','kapelle','yerseke','renesse','domburg','breskens','oostburg','schoondijke'],
|
|
'Noord-Brabant':['eindhoven','tilburg','breda','s-hertogenbosch','helmond','oss','roosendaal','bergen-op-zoom','waalwijk','best','son','boxmeer','veghel','schijndel','vught','nuenen','veldhoven','geldrop','deurne','cuijk','grave','oisterwijk','dongen','oosterhout','etten-leur','valkenswaard','gemert','boekel','asten','someren','rucphen','zundert','bavel','hoogerheide'],
|
|
'Limburg': ['maastricht','venlo','sittard','heerlen','roermond','weert','venray','kerkrade','brunssum','landgraaf','stein','geleen','valkenburg','gulpen','eijsden','tegelen','horst','panningen','sevenum','maasbree','baarlo'],
|
|
'Flevoland': ['almere','lelystad','emmeloord','dronten','zeewolde','urk','biddinghuizen','swifterbant'],
|
|
};
|
|
|
|
const SLUG2PROV = {};
|
|
for (const [p, cs] of Object.entries(PROVINCES)) for (const c of cs) SLUG2PROV[c] = p;
|
|
|
|
function slug2prov(slug) {
|
|
if (!slug) return null;
|
|
const s = slug.toLowerCase();
|
|
if (SLUG2PROV[s]) return SLUG2PROV[s];
|
|
for (const [k,v] of Object.entries(SLUG2PROV)) if (s.startsWith(k)||k.startsWith(s)) return v;
|
|
return null;
|
|
}
|
|
|
|
// ── P-2000 message parser ────────────────────────────────────────────────────
|
|
function parseP2000(msg) {
|
|
const title = msg.title || '';
|
|
const desc = msg.description || '';
|
|
const full = `${title} ${desc}`;
|
|
const r = {};
|
|
|
|
// Priority — from description leading code
|
|
const pm = desc.match(/^(A1|A2|A3|A4|B1?|B2|B3|P\s?1|P\s?2)\b/i);
|
|
if (pm) {
|
|
const code = pm[1].replace(/\s/,'').toUpperCase();
|
|
const labels = { A1:'A1 — Levensbedreigend', A2:'A2 — Spoed', A3:'A3 — Niet spoed', A4:'A4', B:'B', B1:'B1 — Niet spoed', B2:'B2 — Herhalingsrit', B3:'B3', P1:'P1 — Spoed', P2:'P2 — Dringend' };
|
|
const level = /^A1|^P1|^MGS/.test(code) ? 'critical' : /^A2|^P2|^MS/.test(code) ? 'urgent' : 'normal';
|
|
r.priority = { code, label: labels[code]||code, level };
|
|
} else if (/met grote spoed/i.test(title)) r.priority = { code:'MGS', label:'Met Grote Spoed', level:'critical' };
|
|
else if (/met spoed/i.test(title)) r.priority = { code:'MS', label:'Met Spoed', level:'urgent' };
|
|
else if (/spoedambulance/i.test(full)) r.priority = { code:'SPA', label:'Spoedambulance', level:'urgent' };
|
|
|
|
// Directe inzet
|
|
r.dia = /directe\s*inzet|dia:\s*ja|\(dia\)/i.test(full);
|
|
|
|
// Vehicle: AMBU + number
|
|
const ambuM = desc.match(/\bAMBU\s+(\d{4,6})\b/i);
|
|
if (ambuM) r.vehicle = { type:'Ambulance', number: ambuM[1] };
|
|
else {
|
|
// standalone vehicle after priority code
|
|
const vm = desc.match(/^[AB][123]?\s+(?:\([^)]+\)\s+)?(\d{4,6})\b/i);
|
|
if (vm) r.vehicle = { type: msg.type==='fire'?'Voertuig':'Ambulance', number: vm[1] };
|
|
}
|
|
if (!r.vehicle) {
|
|
const tm = title.match(/\bvoertuig(?:nummer)?[:\s]+(\d{4,6})/i) || title.match(/\bAMBU[:\s]+(\d{4,6})/i) || title.match(/\bAmbulan[c]e\s+(\d{4,6})\b/i);
|
|
if (tm) r.vehicle = { type:'Ambulance', number: tm[1] };
|
|
// plain number in title like "Met Grote Spoed 10184"
|
|
const nm = title.match(/(?:spoed|spoed\s+\S+)\s+(\d{4,6})\b/i);
|
|
if (nm && !r.vehicle) r.vehicle = { type:'Voertuig', number: nm[1] };
|
|
}
|
|
|
|
// Fire units
|
|
const fu = desc.match(/\b(TS|RV|HV|AL|OVD|GW|WTS|BON-\d+|SER|KNRM|GRIP\s?\d?)\b/gi);
|
|
if (fu?.length) r.units = [...new Set(fu.map(u=>u.toUpperCase()))];
|
|
|
|
// Rit / bon number
|
|
const rm = full.match(/rit(?:nummer)?[:\s]+(\d{4,8})/i) || full.match(/\bbon\s+(\d{4,8})/i);
|
|
r.rit = rm?.[1] || null;
|
|
|
|
// Meldkamer (fire dispatch centre in title)
|
|
const mk = title.match(/meldkamer[:\s]+([^,\n]+)/i);
|
|
if (mk) r.meldkamer = mk[1].trim();
|
|
|
|
// Incident channel
|
|
const ch = title.match(/incidentkanaal\s+(\d+)/i);
|
|
if (ch) r.kanaal = ch[1];
|
|
|
|
// Alarm type
|
|
if (/\bOMS\b/.test(full)) r.alarmType = 'OMS — Automatisch alarm';
|
|
else if (/\bNL-Alert\b/i.test(full)) r.alarmType = 'NL-Alert';
|
|
else if (/brandmelding/i.test(full)) r.alarmType = 'Brandmelding';
|
|
else if (/inbraakalarm/i.test(full)) r.alarmType = 'Inbraakalarm';
|
|
else if (/verkeersongeval/i.test(full)) r.alarmType = 'Verkeersongeval';
|
|
else if (/gaslek/i.test(full)) r.alarmType = 'Gaslek';
|
|
else if (/reanimatie/i.test(full)) r.alarmType = 'Reanimatie';
|
|
else if (/bewusteloos/i.test(full)) r.alarmType = 'Bewusteloze persoon';
|
|
|
|
// Postal code
|
|
const pc = desc.match(/\b([1-9]\d{3}\s*[A-Z]{2})\b/);
|
|
if (pc) r.postalCode = pc[1].replace(/\s/,'').toUpperCase();
|
|
|
|
return r;
|
|
}
|
|
|
|
// ── Province → city slugs mapping (state) ─────────────────────────────────
|
|
const st = {
|
|
messages: [],
|
|
counts: { fire:0, ambulance:0, police:0, rescue:0, other:0 },
|
|
typeFilter: 'all',
|
|
regionFilter: '',
|
|
autoScroll: true,
|
|
};
|
|
|
|
const LABELS = { fire:'Brandweer', ambulance:'Ambulance', police:'Politie', rescue:'Redding', other:'Overig' };
|
|
const ICONS = { fire:'🔥', ambulance:'🚑', police:'🚔', rescue:'⛑️', other:'📻' };
|
|
|
|
// ── Date helpers ────────────────────────────────────────────────────────────
|
|
function parseD(s) { return new Date(String(s).replace(/\s*Z\s*$/i,'').replace(/\s*[+-]\d{2}:?\d{2}\s*$/,'')); }
|
|
function fmtT(s) { try { return parseD(s).toLocaleTimeString('nl-NL',{hour:'2-digit',minute:'2-digit',second:'2-digit'}); } catch{return s;} }
|
|
function fmtDT(s) { try { return parseD(s).toLocaleString('nl-NL',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit',second:'2-digit'}); } catch{return s;} }
|
|
function ago(s) { const d=Math.floor((Date.now()-parseD(s))/1000); return d<60?`${d}s`:d<3600?`${Math.floor(d/60)}m`:`${Math.floor(d/3600)}u`; }
|
|
function esc(s) { return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
|
|
|
// ── Visibility ──────────────────────────────────────────────────────────────
|
|
function vis(m) {
|
|
if (st.typeFilter!=='all' && m.type!==st.typeFilter) return false;
|
|
if (st.regionFilter && slug2prov(m.locationSlug)!==st.regionFilter) return false;
|
|
return true;
|
|
}
|
|
function activeN() { return (st.typeFilter!=='all'?1:0)+(st.regionFilter?1:0); }
|
|
|
|
// ── Card ─────────────────────────────────────────────────────────────────────
|
|
function mkCard(msg, isNew=false) {
|
|
const p = parseP2000(msg);
|
|
const el = document.createElement('div');
|
|
el.className = `card c-${msg.type}${isNew?' new':''}`;
|
|
el.dataset.id = msg.id;
|
|
el.onclick = () => openModal(msg);
|
|
|
|
const loc = msg.location ? `<span class="ltag"><svg width="8" height="9" viewBox="0 0 10 12" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="5" cy="4.5" r="2"/><path d="M5 11s-4-3.5-4-6.5a4 4 0 018 0C9 7.5 5 11 5 11z"/></svg>${esc(msg.location)}</span>` : '';
|
|
const prioTag = p.priority ? `<span class="prio-tag prio-${p.priority.level}">${p.priority.code}</span>` : '';
|
|
const diaTag = p.dia ? `<span class="prio-tag prio-urgent">DIA</span>` : '';
|
|
|
|
// sub-line: vehicle/rit/units
|
|
const subParts = [];
|
|
if (p.vehicle) subParts.push(`<span class="pill">${p.vehicle.type} ${p.vehicle.number}</span>`);
|
|
if (p.units?.length) subParts.push(`<span class="pill">${p.units.join(' · ')}</span>`);
|
|
if (p.rit) subParts.push(`<span class="pill">Rit ${p.rit}</span>`);
|
|
if (p.alarmType) subParts.push(`<span class="pill">${p.alarmType}</span>`);
|
|
|
|
el.innerHTML = `
|
|
<div class="c-row">
|
|
<div class="c-left">
|
|
<div class="c-meta">
|
|
<span class="ttag t-${msg.type}">${LABELS[msg.type]??msg.type}</span>
|
|
${prioTag}${diaTag}${loc}
|
|
</div>
|
|
<div class="c-title">${esc(msg.title)}</div>
|
|
${subParts.length?`<div class="c-sub">${subParts.join('')}</div>`:''}
|
|
</div>
|
|
<div class="c-right">
|
|
<div class="c-time">${fmtT(msg.pubDate)}</div>
|
|
<div class="c-ago" data-ts="${msg.pubDate}">${ago(msg.pubDate)}</div>
|
|
</div>
|
|
</div>`;
|
|
return el;
|
|
}
|
|
|
|
// ── DOM ───────────────────────────────────────────────────────────────────────
|
|
const feedEl = document.getElementById('feed');
|
|
const svLoad = document.getElementById('sv-load');
|
|
const svEmpty = document.getElementById('sv-empty');
|
|
|
|
function render() {
|
|
feedEl.querySelectorAll('.card').forEach(c=>c.remove());
|
|
const v = st.messages.filter(vis);
|
|
v.forEach(m => feedEl.appendChild(mkCard(m)));
|
|
svLoad.style.display = 'none';
|
|
svEmpty.style.display = v.length===0?'block':'none';
|
|
updLM();
|
|
if (st.autoScroll) document.getElementById('feed-wrap').scrollTop=0;
|
|
}
|
|
|
|
function prepend(msg) {
|
|
st.messages.unshift(msg);
|
|
if (st.messages.length>500) st.messages.pop();
|
|
st.counts[msg.type]=(st.counts[msg.type]??0)+1;
|
|
updCounts(); updLM();
|
|
if (!vis(msg)) return;
|
|
feedEl.insertBefore(mkCard(msg,true), feedEl.firstChild);
|
|
svLoad.style.display=svEmpty.style.display='none';
|
|
if (st.autoScroll) document.getElementById('feed-wrap').scrollTop=0;
|
|
showToast(msg);
|
|
}
|
|
|
|
function loadHist(msgs) {
|
|
st.messages=msgs;
|
|
st.counts={fire:0,ambulance:0,police:0,rescue:0,other:0};
|
|
msgs.forEach(m=>{st.counts[m.type]=(st.counts[m.type]??0)+1;});
|
|
updCounts(); render();
|
|
}
|
|
|
|
// ── Counts ────────────────────────────────────────────────────────────────────
|
|
function updCounts() {
|
|
const total=st.messages.length;
|
|
[['fire','fire'],['ambulance','amb'],['police','pol'],['rescue','res']].forEach(([t,id])=>{
|
|
const n=st.counts[t]??0;
|
|
document.getElementById(`cnt-${id}`).textContent=n;
|
|
document.getElementById(`tbc-${t}`).textContent=n;
|
|
});
|
|
document.getElementById('tbc-all').textContent=total;
|
|
|
|
const rc={};
|
|
st.messages.forEach(m=>{ const p=slug2prov(m.locationSlug); if(p) rc[p]=(rc[p]??0)+1; });
|
|
document.querySelectorAll('.rb[data-r]').forEach(b=>{
|
|
const r=b.dataset.r;
|
|
b.querySelector('.rbn').textContent=r?rc[r]??0:total;
|
|
});
|
|
updFilterBtn();
|
|
}
|
|
|
|
function updFilterBtn() {
|
|
const n=activeN();
|
|
document.getElementById('filter-label').textContent=n?`Filters (${n})`:'Filters';
|
|
document.getElementById('filter-btn').classList.toggle('on',n>0);
|
|
}
|
|
|
|
// ── Filters ───────────────────────────────────────────────────────────────────
|
|
function setType(t) { st.typeFilter=t; document.querySelectorAll('.tb').forEach(b=>b.classList.toggle('on',b.dataset.t===t)); updFilterBtn(); render(); }
|
|
function setRegion(r) { st.regionFilter=r; document.querySelectorAll('.rb').forEach(b=>b.classList.toggle('on',b.dataset.r===r)); updFilterBtn(); render(); }
|
|
function resetFilters() { setType('all'); setRegion(''); }
|
|
|
|
// Build province buttons
|
|
(function(){
|
|
const list=document.getElementById('rb-list');
|
|
for (const name of Object.keys(PROVINCES)) {
|
|
const b=document.createElement('button');
|
|
b.className='rb'; b.dataset.r=name; b.onclick=()=>setRegion(name);
|
|
b.innerHTML=`${esc(name)}<span class="rbn">0</span>`;
|
|
list.appendChild(b);
|
|
}
|
|
})();
|
|
|
|
// ── Panel ─────────────────────────────────────────────────────────────────────
|
|
function openPanel() { document.getElementById('ov').classList.add('on'); document.getElementById('pnl').classList.add('on'); }
|
|
function closePanel() { document.getElementById('ov').classList.remove('on'); document.getElementById('pnl').classList.remove('on'); }
|
|
|
|
// ── Modal ─────────────────────────────────────────────────────────────────────
|
|
function cell(label, value, cls='') {
|
|
return `<div class="info-cell${cls?` ${cls}`:''}"><div class="info-lbl">${esc(label)}</div><div class="info-val${cls.includes('mono')?' mono':''}">${value}</div></div>`;
|
|
}
|
|
|
|
// ── Leaflet map ───────────────────────────────────────────────────────────────
|
|
let leafletMap = null;
|
|
const TYPE_COLORS = { fire:'#fb923c', ambulance:'#2dd4a0', police:'#818cf8', rescue:'#fbbf24', other:'#7a90b8' };
|
|
|
|
function destroyMap() {
|
|
if (leafletMap) { leafletMap.remove(); leafletMap = null; }
|
|
}
|
|
|
|
function initMap(lat, lng, type, labelHtml) {
|
|
const el = document.getElementById('map-el');
|
|
destroyMap();
|
|
|
|
leafletMap = L.map(el, { center:[lat,lng], zoom:15, zoomControl:true, attributionControl:true });
|
|
|
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', {
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com">CARTO</a>',
|
|
subdomains: 'abcd', maxZoom: 19, detectRetina: true,
|
|
}).addTo(leafletMap);
|
|
|
|
const color = TYPE_COLORS[type] ?? TYPE_COLORS.other;
|
|
const icon = L.divIcon({
|
|
className: '',
|
|
html: `<div style="width:18px;height:18px;border-radius:50%;background:${color};border:3px solid rgba(255,255,255,.85);box-shadow:0 2px 14px rgba(0,0,0,.6)"></div>`,
|
|
iconSize: [18,18], iconAnchor: [9,9], popupAnchor: [0,-12],
|
|
});
|
|
L.marker([lat,lng], { icon }).bindPopup(labelHtml, { maxWidth:220 }).addTo(leafletMap);
|
|
|
|
// Leaflet needs the container to be visible before sizing
|
|
requestAnimationFrame(() => leafletMap?.invalidateSize());
|
|
}
|
|
|
|
async function loadMap(msg) {
|
|
const wrap = document.getElementById('map-wrap');
|
|
const spin = document.getElementById('map-spin');
|
|
if (!wrap) return;
|
|
|
|
wrap.style.display = 'block';
|
|
spin.style.display = 'flex';
|
|
document.getElementById('map-el').innerHTML = '';
|
|
|
|
try {
|
|
const res = await fetch(`/api/geocode?id=${encodeURIComponent(msg.id)}`);
|
|
const { coords, query } = await res.json();
|
|
spin.style.display = 'none';
|
|
|
|
if (coords) {
|
|
const label = `<b>${esc(msg.location || query)}</b>`;
|
|
initMap(coords.lat, coords.lng, msg.type, label);
|
|
} else {
|
|
spin.innerHTML = '<span style="opacity:.5">📍 Locatie niet gevonden</span>';
|
|
spin.style.display = 'flex';
|
|
}
|
|
} catch {
|
|
spin.innerHTML = '<span style="opacity:.5">Kaart niet beschikbaar</span>';
|
|
}
|
|
}
|
|
|
|
function openModal(msg) {
|
|
const p = parseP2000(msg);
|
|
const prov = slug2prov(msg.locationSlug);
|
|
|
|
// Top area
|
|
document.getElementById('mdl-top').className = `mdl-top mdl-top-${msg.type}`;
|
|
const badges = [`<span class="ttag t-${msg.type}" style="font-size:11px;padding:2px 8px">${ICONS[msg.type]??''} ${LABELS[msg.type]??msg.type}</span>`];
|
|
if (p.priority) badges.push(`<span class="prio-tag prio-${p.priority.level}" style="font-size:11px;padding:2px 8px">${p.priority.label}</span>`);
|
|
if (p.dia) badges.push(`<span class="prio-tag prio-urgent" style="font-size:11px;padding:2px 8px">Directe Inzet</span>`);
|
|
document.getElementById('mdl-badges').innerHTML = badges.join('');
|
|
document.getElementById('mdl-title').textContent = msg.title;
|
|
|
|
// Build body
|
|
const body = document.getElementById('mdl-body');
|
|
body.innerHTML = '';
|
|
|
|
// Map container
|
|
const mapWrap = document.createElement('div');
|
|
mapWrap.id = 'map-wrap';
|
|
mapWrap.innerHTML = `<div id="map-el"></div><div id="map-spin"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="animation:spin 1s linear infinite;flex-shrink:0"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>Locatie zoeken…</div>`;
|
|
body.appendChild(mapWrap);
|
|
|
|
// Info grid
|
|
const grid = document.createElement('div');
|
|
grid.className = 'info-grid';
|
|
const cells = [];
|
|
|
|
if (p.vehicle) cells.push(cell('Voertuig', `${p.vehicle.type} · ${p.vehicle.number}`, 'mono'));
|
|
if (p.rit) cells.push(cell('Ritnummer', p.rit, 'mono'));
|
|
if (p.units?.length) cells.push(cell('Eenheden', p.units.join(', '), 'mono'));
|
|
if (p.alarmType) cells.push(cell('Type melding', p.alarmType));
|
|
if (p.meldkamer) cells.push(cell('Meldkamer', p.meldkamer, p.kanaal?'':'span2'));
|
|
if (p.kanaal) cells.push(cell('Kanaal', p.kanaal, 'mono'));
|
|
if (msg.location) {
|
|
const locText = prov ? `${msg.location} · ${prov}` : msg.location;
|
|
cells.push(cell('Locatie', locText, p.postalCode?'':'span2'));
|
|
}
|
|
if (p.postalCode) cells.push(cell('Postcode', p.postalCode, 'mono'));
|
|
cells.push(cell('Tijd', fmtDT(msg.pubDate), 'span2'));
|
|
|
|
if (cells.length) { grid.innerHTML = cells.join(''); body.appendChild(grid); }
|
|
|
|
// Raw description
|
|
if (msg.description) {
|
|
const pre = document.createElement('div');
|
|
pre.className='mdl-desc'; pre.textContent = msg.description;
|
|
body.appendChild(pre);
|
|
}
|
|
|
|
// Link
|
|
if (msg.link) {
|
|
const a = document.createElement('a');
|
|
a.className='mdl-link'; a.href=msg.link; a.target='_blank'; a.rel='noopener';
|
|
a.innerHTML=`<svg width="11" height="11" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M5 3H3a1 1 0 00-1 1v5a1 1 0 001 1h5a1 1 0 001-1V7m-1-4h3m0 0v3m0-3L6 6"/></svg>Bekijk op 112-nu.nl`;
|
|
body.appendChild(a);
|
|
}
|
|
|
|
document.getElementById('mdl-ov').classList.add('on');
|
|
// Start geocoding after modal is visible (map needs dimensions)
|
|
requestAnimationFrame(() => loadMap(msg));
|
|
}
|
|
function closeModal() {
|
|
document.getElementById('mdl-ov').classList.remove('on');
|
|
destroyMap();
|
|
}
|
|
|
|
// ── Load older ────────────────────────────────────────────────────────────────
|
|
let loadingOld=false;
|
|
function updLM() { document.getElementById('lm-wrap').style.display=st.messages.length>0?'flex':'none'; }
|
|
async function loadOlder() {
|
|
if (loadingOld) return;
|
|
loadingOld=true;
|
|
const btn=document.getElementById('lm-btn');
|
|
document.getElementById('lm-lb').textContent='Laden…';
|
|
document.getElementById('lm-ic').textContent='⏳';
|
|
btn.disabled=true;
|
|
try {
|
|
const r=await fetch(`/api/messages?offset=${st.messages.length}&limit=30`);
|
|
const {messages,total}=await r.json();
|
|
if (!messages.length) { document.getElementById('lm-lb').textContent='Geen oudere meldingen'; document.getElementById('lm-ic').textContent='✓'; return; }
|
|
messages.forEach(m=>{ if(!st.messages.some(x=>x.id===m.id)){st.messages.push(m);st.counts[m.type]=(st.counts[m.type]??0)+1;} });
|
|
updCounts();
|
|
messages.filter(vis).forEach(m=>feedEl.appendChild(mkCard(m)));
|
|
const rem=total-st.messages.length;
|
|
document.getElementById('lm-lb').textContent=rem>0?`Meer laden (~${rem})`:'Oudere meldingen';
|
|
document.getElementById('lm-ic').textContent='↑';
|
|
} catch { document.getElementById('lm-lb').textContent='Fout bij laden'; document.getElementById('lm-ic').textContent='✗'; }
|
|
finally { loadingOld=false; btn.disabled=false; }
|
|
}
|
|
|
|
// ── Toast ─────────────────────────────────────────────────────────────────────
|
|
let tTimer=null;
|
|
function showToast(msg) {
|
|
document.getElementById('t-ic').textContent=ICONS[msg.type]??'📻';
|
|
document.getElementById('t-tx').textContent=msg.title.slice(0,50)+(msg.title.length>50?'…':'');
|
|
const t=document.getElementById('toast'); t.classList.add('on');
|
|
clearTimeout(tTimer); tTimer=setTimeout(()=>t.classList.remove('on'),4000);
|
|
}
|
|
|
|
setInterval(()=>document.querySelectorAll('[data-ts]').forEach(el=>el.textContent=ago(el.dataset.ts)),30_000);
|
|
|
|
// ── WebSocket ──────────────────────────────────────────────────────────────────
|
|
const WS=`${location.protocol==='https:'?'wss':'ws'}://${location.host}/ws`;
|
|
let ws,retry=1000;
|
|
function connStatus(s){
|
|
document.getElementById('cdot').className=`cdot ${s==='connected'?'on':s==='disconnected'?'off':'wait'}`;
|
|
document.getElementById('clbl').textContent={connected:'Verbonden',disconnected:'Verbroken',connecting:'…'}[s];
|
|
}
|
|
function connect(){
|
|
connStatus('connecting'); ws=new WebSocket(WS);
|
|
ws.onopen=()=>{connStatus('connected');retry=1000;};
|
|
ws.onmessage=({data})=>{let e;try{e=JSON.parse(data)}catch{return};if(e.type==='history')loadHist(e.messages);if(e.type==='message')prepend(e.message);};
|
|
ws.onclose=()=>{connStatus('disconnected');setTimeout(connect,Math.min(retry,30000));retry=Math.min(retry*2,30000);};
|
|
ws.onerror=()=>ws.close();
|
|
}
|
|
document.addEventListener('keydown',e=>{if(e.key==='Escape'){closePanel();closeModal();}});
|
|
connect();
|
|
</script>
|
|
</body>
|
|
</html>
|