Add P-2000 real-time monitor with WebSocket, map, and filter panel

- 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>
This commit is contained in:
edo 2026-05-01 10:16:38 +00:00
parent bc681126d6
commit 2c37d5934c
9 changed files with 3903 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
logs/
.env
*.log
.DS_Store

97
CLAUDE.md Normal file
View File

@ -0,0 +1,97 @@
# P-2000 Monitor
Real-time Dutch emergency services (P-2000) channel monitor. Koa server + WebSocket + RSS polling, dark shadcn-style UI.
## Stack
| Layer | Tech |
|-------------|-------------------------------------------------------|
| Server | Node.js 20, Koa 2, koa-static, koa-router |
| WebSocket | `ws` library — attached to same HTTP server |
| P-2000 data | RSS polling via axios + xml2js (30s interval) |
| Process mgr | PM2 with watch mode (`pm2.config.js`) |
| Frontend | Vanilla JS, TailwindCSS Play CDN, shadcn-style design |
## Port
`10000` (override via `PORT` env var)
## Running
```bash
# Install
npm install
# Dev (PM2 watch mode — auto-restart on src/ or server.js changes)
npm run dev
# Logs
npm run logs
# Stop
npm run stop
# Plain node (no PM2)
npm start
```
## Project layout
```
server.js # Koa HTTP + WebSocket server, P-2000 event bridge
src/p2000.js # EventEmitter: RSS fetcher, XML parser, type detector
public/index.html # Single-page frontend (TailwindCSS + vanilla JS)
pm2.config.js # PM2 watch config
logs/ # PM2 out/error logs (git-ignored)
```
## P-2000 Feed source
**Primary:** `https://112-nu.nl/hulpdiensten/rss` (all services combined, polled every 30s)
Per-service feeds also available but not used (combined is sufficient):
- `https://112-nu.nl/brandweer/rss`
- `https://112-nu.nl/ambulance/rss`
- `https://112-nu.nl/politie/rss`
Change the feed URL via `COMBINED_FEED` constant in `src/p2000.js`.
## Message types
Detected from `<category>` RSS tag first, then keyword fallback:
| Category code | Type |
|---------------|-------------|
| BRA, BRW | `fire` |
| AMB, MKA | `ambulance` |
| POL | `police` |
| KNRM, HELI | `rescue` |
Extend `CATEGORY_MAP` or `KEYWORD_MAP` in `src/p2000.js` to tune detection.
## WebSocket protocol
Client connects to `ws://localhost:10000/ws`
Server → Client messages:
```json
{ "type": "history", "messages": [ ...MessageObject ] }
{ "type": "message", "message": MessageObject }
```
`MessageObject`:
```json
{
"id": "unique-guid",
"title": "P1 Melding Brand - Rotterdam",
"description": "Extended alert text",
"pubDate": "2024-01-15T12:34:56.000Z",
"link": "https://...",
"type": "fire",
"region": "Zuid-Holland"
}
```
## Health check
`GET /health``{ status, uptime, messages }`

2588
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "p2000-monitor",
"version": "1.0.0",
"description": "Real-time Dutch P-2000 emergency channel monitor",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "pm2 start pm2.config.js --env development",
"stop": "pm2 stop p2000",
"logs": "pm2 logs p2000",
"restart": "pm2 restart p2000"
},
"dependencies": {
"@koa/router": "^15.4.0",
"axios": "^1.7.2",
"koa": "^2.15.3",
"koa-static": "^5.0.0",
"ws": "^8.18.0",
"xml2js": "^0.6.2"
},
"devDependencies": {
"pm2": "^5.4.2"
}
}

22
pm2.config.js Normal file
View File

@ -0,0 +1,22 @@
module.exports = {
apps: [
{
name: 'p2000',
script: 'server.js',
watch: ['server.js', 'src'],
ignore_watch: ['node_modules', 'public', '*.log'],
watch_delay: 1000,
env: {
NODE_ENV: 'development',
PORT: 10000,
},
env_production: {
NODE_ENV: 'production',
PORT: 10000,
},
error_file: 'logs/error.log',
out_file: 'logs/out.log',
log_date_format: 'YYYY-MM-DD HH:mm:ss',
},
],
};

858
public/index.html Normal file
View File

@ -0,0 +1,858 @@
<!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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// ── 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>

95
server.js Normal file
View File

@ -0,0 +1,95 @@
const http = require('http');
const path = require('path');
const Koa = require('koa');
const Router = require('@koa/router');
const serve = require('koa-static');
const { WebSocketServer, OPEN } = require('ws');
const P2000 = require('./src/p2000');
const { geocode, messageToQuery } = require('./src/geocoder');
const PORT = Number(process.env.PORT) || 10000;
const MAX_HISTORY = 500;
// ── Koa app ──────────────────────────────────────────────────────────────────
const app = new Koa();
const router = new Router();
app.use(serve(path.join(__dirname, 'public')));
router.get('/health', ctx => {
ctx.body = { status: 'ok', uptime: process.uptime(), messages: history.length };
});
// Paginated history — ?offset=0&limit=30&type=all
router.get('/api/messages', ctx => {
const offset = Math.max(0, parseInt(ctx.query.offset) || 0);
const limit = Math.min(100, Math.max(1, parseInt(ctx.query.limit) || 30));
const type = ctx.query.type || 'all';
const pool = type === 'all' ? history : history.filter(m => m.type === type);
ctx.body = {
messages: pool.slice(offset, offset + limit),
total: pool.length,
offset,
limit,
};
});
// Geocode a message by id — proxies Nominatim so the client never hits it directly
router.get('/api/geocode', async ctx => {
const msg = history.find(m => m.id === ctx.query.id);
if (!msg) { ctx.status = 404; ctx.body = { error: 'not found' }; return; }
const query = messageToQuery(msg);
if (!query) { ctx.body = { coords: null }; return; }
const coords = await geocode(query);
ctx.body = { coords, query };
});
app.use(router.routes()).use(router.allowedMethods());
// ── HTTP server ───────────────────────────────────────────────────────────────
const server = http.createServer(app.callback());
// ── WebSocket server ──────────────────────────────────────────────────────────
const wss = new WebSocketServer({ server, path: '/ws' });
const history = [];
function broadcast(payload) {
const data = JSON.stringify(payload);
for (const client of wss.clients) {
if (client.readyState === OPEN) client.send(data);
}
}
wss.on('connection', ws => {
// Send existing history so the UI isn't blank on first load
ws.send(JSON.stringify({ type: 'history', messages: history }));
ws.on('error', err => console.error('[ws] client error:', err.message));
});
// ── P-2000 poller ─────────────────────────────────────────────────────────────
const p2000 = new P2000();
p2000.on('batch', messages => {
history.push(...messages);
if (history.length > MAX_HISTORY) history.splice(0, history.length - MAX_HISTORY);
broadcast({ type: 'history', messages: history });
});
p2000.on('message', msg => {
history.unshift(msg);
if (history.length > MAX_HISTORY) history.pop();
broadcast({ type: 'message', message: msg });
});
p2000.start();
// ── Start ─────────────────────────────────────────────────────────────────────
server.listen(PORT, () => {
console.log(`[server] Listening on http://localhost:${PORT}`);
});
// Graceful shutdown
process.on('SIGINT', () => { p2000.stop(); server.close(() => process.exit(0)); });
process.on('SIGTERM', () => { p2000.stop(); server.close(() => process.exit(0)); });

73
src/geocoder.js Normal file
View File

@ -0,0 +1,73 @@
const axios = require('axios');
const cache = new Map(); // query → {lat,lng,display} | null
const queue = [];
let busy = false;
async function drain() {
if (busy || queue.length === 0) return;
busy = true;
const { query, resolve } = queue.shift();
if (cache.has(query)) {
resolve(cache.get(query));
busy = false;
drain();
return;
}
try {
const { data } = await axios.get('https://nominatim.openstreetmap.org/search', {
params: { q: query, format: 'json', limit: 1, countrycodes: 'nl' },
headers: {
'User-Agent': 'p2000-monitor/1.0 (open source)',
'Accept-Language': 'nl,en',
},
timeout: 8000,
});
const hit = data[0] ?? null;
const result = hit ? { lat: +hit.lat, lng: +hit.lon, display: hit.display_name } : null;
cache.set(query, result);
resolve(result);
console.log(`[geocoder] ${result ? '✓' : '✗'} "${query}"`);
} catch (err) {
console.warn(`[geocoder] error "${query}":`, err.message);
resolve(null);
}
// Nominatim policy: max 1 req/s
await new Promise(r => setTimeout(r, 1150));
busy = false;
drain();
}
function geocode(query) {
return new Promise(resolve => {
if (cache.has(query)) { resolve(cache.get(query)); return; }
queue.push({ query, resolve });
drain();
});
}
// Build the best geocodable query string from a P-2000 message
function messageToQuery(msg) {
const desc = msg.description || '';
// 1. Postal code — most accurate in NL
const pcM = desc.match(/\b([1-9]\d{3})\s*([A-Z]{2})\b/);
if (pcM) return `${pcM[1]}${pcM[2]}, Netherlands`;
// 2. Recognised Dutch street-type keyword + city
const stM = desc.match(
/\b([A-Z][a-zÀ-ÿ\-]+(straat|weg|laan|plein|kade|dijk|singel|boulevard|dreef|gracht|pad|baan|ring|markt|allee|steeg|hof)[a-z]*(?:\s+\d+[a-z]?)?)\b/i
);
if (stM && msg.location) return `${stM[1]}, ${msg.location}, Netherlands`;
if (stM) return `${stM[1]}, Netherlands`;
// 3. City name from URL slug
if (msg.location) return `${msg.location}, Netherlands`;
return null;
}
module.exports = { geocode, messageToQuery };

141
src/p2000.js Normal file
View File

@ -0,0 +1,141 @@
const EventEmitter = require('events');
const axios = require('axios');
const xml2js = require('xml2js');
// 112-nu.nl per-service feeds + combined fallback
const FEED_URLS = [
'https://112-nu.nl/hulpdiensten/rss', // all services combined
'https://112-nu.nl/brandweer/rss',
'https://112-nu.nl/ambulance/rss',
'https://112-nu.nl/politie/rss',
];
const COMBINED_FEED = FEED_URLS[0];
const POLL_INTERVAL_MS = 30_000;
// 112-nu.nl category codes → internal type
const CATEGORY_MAP = {
BRA: 'fire', BRW: 'fire', BRAND: 'fire',
AMB: 'ambulance', MKA: 'ambulance',
POL: 'police', POLICE: 'police',
KNRM: 'rescue', HELI: 'rescue', TRAUMA: 'rescue',
};
// Keyword fallback for when category is missing
const KEYWORD_MAP = [
{ type: 'fire', words: ['brand', 'brandweer', 'uitslaande', 'bbrand', 'binnenbraand'] },
{ type: 'ambulance', words: ['ambulance', 'ambu', 'medisch', 'reanimatie', 'a1 ', 'a2 '] },
{ type: 'police', words: ['politie', 'prio 1', 'prio 2', 'prio1', 'prio2'] },
{ type: 'rescue', words: ['reddingsbrigade', 'knrm', 'grip', 'trauma', 'helikopter'] },
];
function detectType(category, title, description) {
if (category) {
const mapped = CATEGORY_MAP[category.toUpperCase().trim()];
if (mapped) return mapped;
}
const lower = `${title} ${description}`.toLowerCase();
for (const { type, words } of KEYWORD_MAP) {
if (words.some(w => lower.includes(w))) return type;
}
return 'other';
}
function extractId(item) {
const raw = item.guid?.[0];
if (typeof raw === 'object') return raw._ ?? JSON.stringify(raw);
return raw || item.link?.[0] || `${item.title?.[0]}|${item.pubDate?.[0]}`;
}
function extractLocation(link) {
const m = link?.match(/\/melding\/\d+\/([^\/]+)\//);
if (!m) return { display: null, slug: null };
const slug = m[1];
const display = slug.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
return { display, slug };
}
function parseItems(rssData) {
const items = rssData?.rss?.channel?.[0]?.item ?? [];
return items.map(item => {
const title = item.title?.[0] ?? '';
const description = item.description?.[0] ?? '';
const category = item.category?.[0] ?? '';
const link = item.link?.[0] ?? '';
const { display: location, slug: locationSlug } = extractLocation(link);
return {
id: extractId(item),
title: title.trim(),
description: description.trim(),
pubDate: item.pubDate?.[0] ?? new Date().toISOString(),
link,
type: detectType(category, title, description),
category: category || null,
location,
locationSlug,
image: item.image?.[0] ?? null,
};
});
}
class P2000 extends EventEmitter {
constructor() {
super();
this._seenIds = new Set();
this._timer = null;
this._running = false;
}
async _fetchFeed() {
try {
const { data } = await axios.get(COMBINED_FEED, {
timeout: 12_000,
headers: { 'User-Agent': 'p2000-monitor/1.0' },
responseType: 'text',
});
const parsed = await xml2js.parseStringPromise(data, { explicitArray: true, trim: true });
return parseItems(parsed);
} catch (err) {
console.warn(`[p2000] Feed error: ${err.message}`);
return [];
}
}
async poll() {
const messages = await this._fetchFeed();
const isFirstRun = this._seenIds.size === 0;
const fresh = messages.filter(m => !this._seenIds.has(m.id));
for (const msg of fresh) this._seenIds.add(msg.id);
if (isFirstRun) {
this.emit('batch', messages.slice(0, 50));
console.log(`[p2000] Initial batch: ${messages.length} messages`);
return;
}
if (fresh.length) {
console.log(`[p2000] ${fresh.length} new message(s)`);
}
// Emit oldest first so UI prepends in correct order
for (const msg of fresh.reverse()) {
this.emit('message', msg);
}
}
start() {
if (this._running) return;
this._running = true;
this.poll();
this._timer = setInterval(() => this.poll(), POLL_INTERVAL_MS);
console.log(`[p2000] Polling ${COMBINED_FEED} every ${POLL_INTERVAL_MS / 1000}s`);
}
stop() {
if (this._timer) clearInterval(this._timer);
this._running = false;
}
}
module.exports = P2000;