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:
parent
bc681126d6
commit
2c37d5934c
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
logs/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
97
CLAUDE.md
Normal file
97
CLAUDE.md
Normal 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
2588
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal 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
22
pm2.config.js
Normal 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
858
public/index.html
Normal 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,'&').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>
|
||||||
95
server.js
Normal file
95
server.js
Normal 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
73
src/geocoder.js
Normal 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
141
src/p2000.js
Normal 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;
|
||||||
Loading…
x
Reference in New Issue
Block a user