p-2000/src/p2000.js
edo 2c37d5934c 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>
2026-05-01 10:16:38 +00:00

142 lines
4.2 KiB
JavaScript

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;