Initial commit — Mek-Tech Live Labs Showcase v1.0

This commit is contained in:
mo
2026-07-12 21:54:28 +00:00
commit 070ab80d4a
30 changed files with 6187 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
const fs = require('fs');
const path = require('path');
const CACHE_DIR = process.env.CACHE_DIR || path.join(__dirname, '../../cache');
const MAX_BYTES = 50 * 1024 * 1024;
function ensureDir() {
if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });
}
function cachePath(key) {
return path.join(CACHE_DIR, key.replace(/[^a-zA-Z0-9._-]/g, '_') + '.json');
}
function get(key, maxAgeMs) {
ensureDir();
const p = cachePath(key);
if (!fs.existsSync(p)) return null;
try {
const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
if (maxAgeMs && Date.now() - raw.fetchedAt > maxAgeMs) return null;
return raw.data;
} catch (e) {
return null;
}
}
function set(key, data) {
ensureDir();
fs.writeFileSync(cachePath(key), JSON.stringify({ fetchedAt: Date.now(), data }, null, 0));
pruneIfNeeded();
}
function pruneIfNeeded() {
ensureDir();
const files = fs.readdirSync(CACHE_DIR).map(f => {
const p = path.join(CACHE_DIR, f);
return { p, mtime: fs.statSync(p).mtimeMs, size: fs.statSync(p).size };
}).sort((a, b) => a.mtime - b.mtime);
let total = files.reduce((s, f) => s + f.size, 0);
for (const f of files) {
if (total <= MAX_BYTES) break;
fs.unlinkSync(f.p);
total -= f.size;
}
}
module.exports = { get, set, CACHE_DIR };
+47
View File
@@ -0,0 +1,47 @@
const cache = require('./cache');
const { getCameraById } = require('./sources/smart-city-infra');
const UA = 'Mozilla/5.0 (compatible; MekTechShowcase/1.0)';
async function fetchImage(url) {
const res = await fetch(url, {
headers: { 'User-Agent': UA, Accept: 'image/*' },
signal: AbortSignal.timeout(8000)
});
if (!res.ok) return null;
const type = res.headers.get('content-type') || '';
if (!type.includes('image')) return null;
const buffer = Buffer.from(await res.arrayBuffer());
if (buffer.length < 800) return null;
return { buffer, contentType: type.split(';')[0] || 'image/jpeg' };
}
async function fetchCameraFrame(cameraId) {
const cam = getCameraById(cameraId);
if (!cam) return null;
const cacheKey = `cam-frame-${cameraId}`;
const cached = cache.get(cacheKey, 1100);
if (cached) return cached;
if (cam.youtubeId) {
const urls = [
`https://i.ytimg.com/vi/${cam.youtubeId}/maxresdefault_live.jpg`,
`https://i.ytimg.com/vi/${cam.youtubeId}/hqdefault_live.jpg`,
`https://i.ytimg.com/vi/${cam.youtubeId}/hqdefault.jpg`
];
for (const url of urls) {
try {
const frame = await fetchImage(`${url}?t=${Date.now()}`);
if (frame) {
cache.set(cacheKey, frame);
return frame;
}
} catch (_) { /* try next */ }
}
}
return null;
}
module.exports = { fetchCameraFrame };
+116
View File
@@ -0,0 +1,116 @@
const cache = require('../cache');
const SANCTIONS_NAMES = [
'IVANOV PETR', 'KIM JONG UN', 'RODRIGUEZ CARLOS', 'AL-RASHID OMAR',
'PETROV SERGEI', 'WANG WEI', 'MULLER HANS', 'SILVA ANA', 'NAKAMURA YUKI', 'PETROVA ANNA'
];
let txCounter = 0;
const hitsTimeline = [];
const riskBuckets = { low: 0, medium: 0, high: 0, critical: 0 };
async function loadSanctions() {
const cached = cache.get('sanctions-list', 24 * 60 * 60 * 1000);
if (cached) return cached;
const list = SANCTIONS_NAMES.map((name, i) => ({
id: i + 1, name, list: 'EU Consolidated', country: ['RU', 'KP', 'VE', 'SY', 'RU', 'CN', 'DE', 'BR', 'JP', 'RU'][i]
}));
cache.set('sanctions-list', list);
return list;
}
function generateTx(sanctions) {
txCounter++;
const hit = Math.random() < 0.09;
const name = hit
? sanctions[Math.floor(Math.random() * sanctions.length)].name
: `CUSTOMER ${Math.floor(Math.random() * 9000 + 1000)}`;
const amount = +(Math.random() * 50000 + 100).toFixed(2);
const risk = hit ? Math.round(85 + Math.random() * 15) : Math.round(Math.random() * 55);
if (risk < 25) riskBuckets.low++;
else if (risk < 50) riskBuckets.medium++;
else if (risk < 75) riskBuckets.high++;
else riskBuckets.critical++;
const tx = {
id: `TX-${Date.now()}-${txCounter}`,
name, amount,
currency: ['EUR', 'USD', 'GBP'][Math.floor(Math.random() * 3)],
country: ['NL', 'DE', 'BE', 'FR', 'UK', 'US', 'RU', 'CN'][Math.floor(Math.random() * 8)],
risk, hit,
type: ['SEPA', 'SWIFT', 'CARD', 'CRYPTO'][Math.floor(Math.random() * 4)],
ts: new Date().toISOString()
};
return tx;
}
async function getFraudSnapshot() {
const sanctions = await loadSanctions();
const recent = Array.from({ length: 25 }, () => generateTx(sanctions));
const hits = recent.filter(t => t.hit).length;
hitsTimeline.push({ t: new Date().toISOString().slice(11, 19), hits, screened: recent.length });
if (hitsTimeline.length > 30) hitsTimeline.shift();
const countryRisk = ['NL', 'DE', 'BE', 'FR', 'UK', 'US', 'RU', 'CN'].map(c => ({
country: c,
txs: Math.round(50 + Math.random() * 200),
hits: c === 'RU' || c === 'CN' ? Math.round(3 + Math.random() * 8) : Math.round(Math.random() * 2)
}));
const riskDistribution = [
{ name: 'Laag', value: riskBuckets.low || 120, fill: '#22c55e' },
{ name: 'Medium', value: riskBuckets.medium || 80, fill: '#f59e0b' },
{ name: 'Hoog', value: riskBuckets.high || 40, fill: '#f97316' },
{ name: 'Kritiek', value: riskBuckets.critical || 15, fill: '#ef4444' }
];
const typeBreakdown = [
{ type: 'SEPA', blocked: Math.round(Math.random() * 5), cleared: 420 },
{ type: 'SWIFT', blocked: Math.round(Math.random() * 8), cleared: 180 },
{ type: 'CARD', blocked: Math.round(Math.random() * 12), cleared: 890 },
{ type: 'CRYPTO', blocked: Math.round(Math.random() * 15), cleared: 65 }
];
const insights = [
{ type: 'warn', label: 'Compliance', text: `${sanctions.length} entiteiten op EU-lijst — elke tx gescored in <20ms.` },
{ type: 'info', label: 'ML', text: 'Velocity + geo-mismatch rules vangen 94% demo-anomalies — uitbreidbaar met eigen modellen.' },
{ type: 'ok', label: 'Audit', text: 'Volledige audit trail exporteerbaar voor DNB/AFM toezicht.' },
{ type: 'warn', label: 'Hits', text: `${hits} hits in huidige batch — RU/CN geo-risico geconcentreerd.` },
{ type: 'info', label: 'Throughput', text: `${1240 + Math.floor(Math.random() * 50)} tx/min — SSE stream elke 1,5s.` },
{ type: 'ok', label: 'Actie', text: 'False-positive review queue koppelbaar — zelfde Global Ops UI als treasury & retail.' }
];
const contextBlocks = [
{ title: 'Waarom Fraud Command Center?', body: 'Financiële instellingen moeten sancties, PEP en anomalies combineren — realtime, auditable, zonder vendor lock-in.' },
{ title: 'Wat u hier ziet', body: 'Live tx stream, sanctions hits, risk verdeling, geo-risico en type breakdown — refresh elke 1,5 seconden.' },
{ title: 'Technische aanpak Mek-Tech', body: 'EU Consolidated List + rule engine + SSE. ML models plug-in ready. Geen opslag van demo-tx.' },
{ title: 'ROI voor uw organisatie', body: 'Snellere compliance response, lagere false-positive kosten, audit-ready logging.' },
{ title: 'Productie-architectuur', body: 'Payment gateway → screening service → Kafka audit log → dit dashboard + case management.' },
{ title: 'Volgende stap', body: 'Pilot met echte sanctions feed + 3 custom rules — productie POC binnen 3 weken.' }
];
return {
ts: new Date().toISOString(),
refreshMs: 1500,
sanctionsCount: sanctions.length,
recent,
hitsLastMinute: hits,
hitsTimeline: [...hitsTimeline],
countryRisk,
riskDistribution,
typeBreakdown,
avgLatencyMs: Math.round(12 + Math.random() * 8),
screeningRate: 1240 + Math.floor(Math.random() * 50),
blockRate: +(hits / recent.length * 100).toFixed(2),
insights,
contextBlocks,
criticalCount: recent.filter(t => t.risk > 85).length,
context: {
pitch: 'Global Fraud & Compliance Operations — sanctions screening live.',
deployment: 'EU Consolidated List · OpenSanctions · ECB Payment Stats'
}
};
}
module.exports = { getFraudSnapshot, generateTx, loadSanctions };
+69
View File
@@ -0,0 +1,69 @@
const cache = require('../cache');
async function getRetailData() {
const regions = [
{ region: 'Noord-Holland', index: 108.2, yoy: 2.1, stores: 8420, onlineShare: 34, footfall: 112 },
{ region: 'Zuid-Holland', index: 105.6, yoy: 1.4, stores: 7100, onlineShare: 31, footfall: 105 },
{ region: 'Noord-Brabant', index: 103.1, yoy: 0.8, stores: 5200, onlineShare: 28, footfall: 98 },
{ region: 'Gelderland', index: 101.4, yoy: 0.3, stores: 3800, onlineShare: 26, footfall: 94 },
{ region: 'Utrecht', index: 106.8, yoy: 1.9, stores: 2900, onlineShare: 36, footfall: 108 },
{ region: 'Limburg', index: 99.2, yoy: -0.4, stores: 2100, onlineShare: 24, footfall: 88 },
{ region: 'Overijssel', index: 100.5, yoy: 0.5, stores: 2400, onlineShare: 25, footfall: 91 },
{ region: 'Groningen', index: 98.7, yoy: -0.2, stores: 1200, onlineShare: 22, footfall: 85 }
];
const history = ['Jan', 'Feb', 'Mrt', 'Apr', 'Mei', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dec'].map((month, i) => ({
month, index: +(100 + Math.sin(i / 2) * 3 + i * 0.15).toFixed(1),
online: +(28 + i * 0.8 + Math.sin(i) * 2).toFixed(1),
footfall: +(95 + Math.cos(i / 2) * 8).toFixed(1)
}));
const categories = [
{ name: 'Supermarkt', share: 32, growth: 1.2 },
{ name: 'Mode', share: 18, growth: -0.8 },
{ name: 'Elektronica', share: 14, growth: 2.4 },
{ name: 'Bouwmarkt', share: 12, growth: 0.5 },
{ name: 'E-commerce', share: 24, growth: 8.2 }
];
const scatter = regions.map(r => ({
region: r.region, stores: r.stores, index: r.index, yoy: r.yoy
}));
const sorted = regions.sort((a, b) => b.index - a.index);
const insights = [
{ type: 'ok', label: 'Groei', text: 'Noord-Holland leidt met index 108 — combineer CBS-data met footfall sensoren voor locatiekeuze.' },
{ type: 'warn', label: 'Druk', text: 'Limburg negatief YoY — gerichte promoties en regionale voorraadoptimalisatie.' },
{ type: 'info', label: 'Omnichannel', text: 'Online share stijgt ~0.8%/maand — dashboard koppelt winkel + web in één view.' },
{ type: 'info', label: 'Footfall', text: `Gem. footfall index ${Math.round(sorted.reduce((a, r) => a + r.footfall, 0) / sorted.length)} — piek in Randstad.` },
{ type: 'warn', label: 'Mode', text: 'Mode categorie 0.8% — herbalanceer assortiment vs elektronica (+2.4%).' },
{ type: 'ok', label: 'Actie', text: 'Scatter-analyse toont waar winkeldichtheid vs index outlier is — expansion ready.' }
];
const contextBlocks = [
{ title: 'Waarom Retail Command Center?', body: 'Retailers moeten snel zien waar omzet groeit, footfall daalt en online kanibaliseert — per regio, categorie en kanaal.' },
{ title: 'Wat u hier ziet', body: 'Regionale omzetindices, categorie mix, 12-maanden trend, footfall vs online — live refresh elke 2 seconden.' },
{ title: 'Technische aanpak Mek-Tech', body: 'CBS StatLine + footfall API + eigen geo-lagen. SSE stream naar browser zonder zware BI stack.' },
{ title: 'ROI voor uw organisatie', body: 'Betere locatiekeuze (15% mislukte openings), snellere category decisions, omnichannel alignment.' },
{ title: 'Productie-architectuur', body: 'Data warehouse + footfall sensors → Kafka → dit dashboard. Store-level drill-down optioneel.' },
{ title: 'Volgende stap', body: 'Pilot met 3 regios + eigen winkeldata overlay — dashboard op maat binnen 34 weken.' }
];
return {
ts: new Date().toISOString(),
refreshMs: 2000,
nationalIndex: 104.3, nationalYoy: 1.2,
regions: sorted,
history, categories, scatter, insights, contextBlocks,
totalStores: regions.reduce((a, r) => a + r.stores, 0),
avgOnlineShare: +(regions.reduce((a, r) => a + r.onlineShare, 0) / regions.length).toFixed(1),
avgFootfall: Math.round(sorted.reduce((a, r) => a + r.footfall, 0) / sorted.length),
storesAtRisk: sorted.filter(r => r.yoy < 0).length,
context: {
pitch: 'Global Retail Operations — regio-intelligence, omnichannel & footfall live.',
deployment: 'CBS StatLine · OpenStreetMap · Footfall API'
}
};
}
module.exports = { getRetailData };
+118
View File
@@ -0,0 +1,118 @@
/** Smart City infrastructure — Amsterdam / Randstad focus, realistic coordinates */
const PUBLIC_HLS = [
'https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8',
'https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8',
'https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_16x9/bipbop_16x9_variant.m3u8',
'https://cph-p2p-msl.akamaized.net/hls/live/2000341/test/master.m3u8'
];
const PUBLIC_MP4 = [
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4',
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4'
];
/** Live YouTube 24/7 NL city webcams — embed-friendly streams */
const YOUTUBE_BY_CITY = {
Amsterdam: 'Bvo4l7LP-nc',
'Amsterdam CS': 'Bvo4l7LP-nc',
Rotterdam: 'MbetYro0DO8',
'Den Haag': '0SIEe-jH-Gw',
Utrecht: 'va3DX5Mg7YM',
Eindhoven: '8lsA80yEi2A'
};
const CAMERAS = [
{ id: 'cam-dam', name: 'Dam Square — Noord', city: 'Amsterdam', lat: 52.3731, lng: 4.8932, type: 'traffic', zone: 'Centrum', streamIdx: 0, youtubeId: YOUTUBE_BY_CITY.Amsterdam, install: '2019', resolution: '4K' },
{ id: 'cam-cs', name: 'Centraal Station — Hoofdingang', city: 'Amsterdam', lat: 52.3791, lng: 4.9003, type: 'security', zone: 'Centrum', streamIdx: 1, youtubeId: YOUTUBE_BY_CITY['Amsterdam CS'], install: '2021', resolution: '1080p' },
{ id: 'cam-ij', name: 'IJ-tunnel — Westelijke Oprit', city: 'Amsterdam', lat: 52.3847, lng: 4.9021, type: 'traffic', zone: 'Noord', streamIdx: 2, youtubeId: YOUTUBE_BY_CITY.Amsterdam, install: '2018', resolution: '1080p' },
{ id: 'cam-a10', name: 'A10 Ring — Coentunnel Zuid', city: 'Amsterdam', lat: 52.4012, lng: 4.8688, type: 'traffic', zone: 'Westpoort', streamIdx: 0, youtubeId: YOUTUBE_BY_CITY.Amsterdam, install: '2020', resolution: '4K' },
{ id: 'cam-museum', name: 'Museumplein — Fietsenstalling', city: 'Amsterdam', lat: 52.3579, lng: 4.8811, type: 'security', zone: 'Zuid', streamIdx: 1, youtubeId: YOUTUBE_BY_CITY.Amsterdam, install: '2022', resolution: '1080p' },
{ id: 'cam-zuidas', name: 'Zuidas — Mahlerplein', city: 'Amsterdam', lat: 52.3383, lng: 4.8724, type: 'traffic', zone: 'Zuidas', streamIdx: 2, youtubeId: YOUTUBE_BY_CITY.Amsterdam, install: '2023', resolution: '4K' },
{ id: 'cam-rot-cs', name: 'Rotterdam CS — Proveniers', city: 'Rotterdam', lat: 51.9244, lng: 4.4692, type: 'security', zone: 'Centrum', streamIdx: 0, youtubeId: YOUTUBE_BY_CITY.Rotterdam, install: '2021', resolution: '1080p' },
{ id: 'cam-rot-erasmus', name: 'Erasmusbrug — Noord', city: 'Rotterdam', lat: 51.9087, lng: 4.4863, type: 'traffic', zone: 'Kop van Zuid', streamIdx: 1, youtubeId: YOUTUBE_BY_CITY.Rotterdam, install: '2019', resolution: '4K' },
{ id: 'cam-dh-bin', name: 'Den Haag — Binnenhof', city: 'Den Haag', lat: 52.0796, lng: 4.3131, type: 'security', zone: 'Centrum', streamIdx: 2, youtubeId: YOUTUBE_BY_CITY['Den Haag'], install: '2020', resolution: '1080p' },
{ id: 'cam-ut-cs', name: 'Utrecht CS — Jaarbeurszijde', city: 'Utrecht', lat: 52.0893, lng: 5.1106, type: 'traffic', zone: 'Centrum', streamIdx: 0, youtubeId: YOUTUBE_BY_CITY.Utrecht, install: '2022', resolution: '1080p' },
{ id: 'cam-eind-hs', name: 'Eindhoven — Strijp-S', city: 'Eindhoven', lat: 51.4514, lng: 5.4842, type: 'security', zone: 'Strijp', streamIdx: 1, youtubeId: YOUTUBE_BY_CITY.Eindhoven, install: '2023', resolution: '4K' },
{ id: 'cam-har-port', name: 'Den Haag — Haven', city: 'Den Haag', lat: 52.0954, lng: 4.2658, type: 'traffic', zone: 'Haven', streamIdx: 2, youtubeId: YOUTUBE_BY_CITY['Den Haag'], install: '2018', resolution: '1080p' }
];
const LAMPPOSTS = [
{ id: 'lp-001', lat: 52.3728, lng: 4.8945, street: 'Damrak', brightness: 78, energyWh: 124, motion: true, camLinked: 'cam-dam' },
{ id: 'lp-002', lat: 52.3742, lng: 4.8918, street: 'Rokin', brightness: 65, energyWh: 98, motion: true, camLinked: 'cam-dam' },
{ id: 'lp-003', lat: 52.3785, lng: 4.9015, street: 'Oosterdok', brightness: 90, energyWh: 156, motion: false, camLinked: 'cam-cs' },
{ id: 'lp-004', lat: 52.3812, lng: 4.8998, street: 'Oosterdokskade', brightness: 55, energyWh: 87, motion: true, camLinked: 'cam-cs' },
{ id: 'lp-005', lat: 52.3835, lng: 4.9035, street: 'Piet Heinkade', brightness: 72, energyWh: 112, motion: false, camLinked: 'cam-ij' },
{ id: 'lp-006', lat: 52.3565, lng: 4.8825, street: 'Van Baerlestraat', brightness: 48, energyWh: 76, motion: true, camLinked: 'cam-museum' },
{ id: 'lp-007', lat: 52.3395, lng: 4.8735, street: 'Gustav Mahlerlaan', brightness: 82, energyWh: 134, motion: true, camLinked: 'cam-zuidas' },
{ id: 'lp-008', lat: 52.3378, lng: 4.8712, street: 'Beethovenstraat', brightness: 60, energyWh: 95, motion: false, camLinked: 'cam-zuidas' },
{ id: 'lp-009', lat: 52.4005, lng: 4.8675, street: 'Sloterdijkweg', brightness: 95, energyWh: 168, motion: true, camLinked: 'cam-a10' },
{ id: 'lp-010', lat: 52.9235, lng: 4.4712, street: 'Proveniersstraat', brightness: 70, energyWh: 108, motion: true, camLinked: 'cam-rot-cs' },
{ id: 'lp-011', lat: 52.9075, lng: 4.4878, street: 'Wilhelminakade', brightness: 88, energyWh: 142, motion: false, camLinked: 'cam-rot-erasmus' },
{ id: 'lp-012', lat: 52.0788, lng: 4.3145, street: 'Hofweg', brightness: 52, energyWh: 82, motion: true, camLinked: 'cam-dh-bin' },
{ id: 'lp-013', lat: 52.0885, lng: 5.1095, street: 'Moreelsepark', brightness: 68, energyWh: 105, motion: false, camLinked: 'cam-ut-cs' },
{ id: 'lp-014', lat: 52.4505, lng: 5.4835, street: 'Torenallee', brightness: 75, energyWh: 118, motion: true, camLinked: 'cam-eind-hs' },
{ id: 'lp-015', lat: 52.3735, lng: 4.8965, street: 'Paleisstraat', brightness: 42, energyWh: 68, motion: true, camLinked: 'cam-dam' },
{ id: 'lp-016', lat: 52.3708, lng: 4.8922, street: 'Dam', brightness: 58, energyWh: 91, motion: true, camLinked: 'cam-dam' }
];
let tick = 0;
function jitter(v, pct = 0.06) {
return Math.max(1, +(v * (1 + (Math.random() - 0.5) * pct * 2)).toFixed(1));
}
function getStreamUrl(idx) {
return PUBLIC_HLS[idx % PUBLIC_HLS.length];
}
function getMp4Url(idx) {
return PUBLIC_MP4[idx % PUBLIC_MP4.length];
}
function getCameraById(id) {
const cam = CAMERAS.find(c => c.id === id);
if (!cam) return null;
return {
...cam,
streamUrl: getStreamUrl(cam.streamIdx),
mp4Url: getMp4Url(cam.streamIdx),
frameUrl: `/api/smart-city/camera/${cam.id}/frame`,
online: Math.random() > 0.02,
fps: 24 + Math.floor(Math.random() * 6),
bitrate: 4 + Math.floor(Math.random() * 8),
viewers: Math.floor(Math.random() * 12) + 1,
lastMotion: new Date(Date.now() - Math.random() * 120000).toISOString()
};
}
function evolveInfrastructure() {
tick++;
const cameras = CAMERAS.map(c => ({
...c,
streamUrl: getStreamUrl(c.streamIdx),
mp4Url: getMp4Url(c.streamIdx),
frameUrl: `/api/smart-city/camera/${c.id}/frame`,
online: Math.random() > 0.015,
fps: 24 + Math.floor(Math.random() * 8),
bitrateMbps: +(3.5 + Math.random() * 6).toFixed(1),
latencyMs: Math.round(80 + Math.random() * 120),
recording: true,
aiTags: ['vehicle', 'pedestrian', 'bicycle'].filter(() => Math.random() > 0.4).slice(0, 2)
}));
const lampposts = LAMPPOSTS.map(lp => ({
...lp,
brightness: Math.round(Math.min(100, Math.max(20, lp.brightness + (Math.random() - 0.5) * 8))),
energyWh: Math.round(jitter(lp.energyWh, 0.04)),
motion: Math.random() > 0.55,
lux: Math.round(20 + Math.random() * 80),
tempC: +(15 + Math.random() * 8).toFixed(1),
co2saved: +(0.02 + Math.random() * 0.08).toFixed(3)
}));
return { cameras, lampposts };
}
module.exports = { CAMERAS, LAMPPOSTS, getCameraById, evolveInfrastructure, getStreamUrl, PUBLIC_HLS, PUBLIC_MP4 };
+209
View File
@@ -0,0 +1,209 @@
const { evolveInfrastructure, getCameraById } = require('./smart-city-infra');
const NL_STATIONS = [
{ id: 1, name: 'Amsterdam-NO2', city: 'Amsterdam', lat: 52.37, lng: 4.90, no2: 32, pm25: 14, o3: 45, population: 921402 },
{ id: 2, name: 'Rotterdam-Centrum', city: 'Rotterdam', lat: 51.92, lng: 4.48, no2: 38, pm25: 18, o3: 42, population: 655468 },
{ id: 3, name: 'Den Haag', city: 'Den Haag', lat: 52.07, lng: 4.30, no2: 29, pm25: 12, o3: 48, population: 552995 },
{ id: 4, name: 'Utrecht', city: 'Utrecht', lat: 52.09, lng: 5.12, no2: 31, pm25: 13, o3: 46, population: 361924 },
{ id: 5, name: 'Eindhoven', city: 'Eindhoven', lat: 51.44, lng: 5.48, no2: 35, pm25: 16, o3: 44, population: 246417 },
{ id: 6, name: 'Groningen', city: 'Groningen', lat: 53.22, lng: 6.57, no2: 22, pm25: 9, o3: 52, population: 233273 },
{ id: 7, name: 'Maastricht', city: 'Maastricht', lat: 50.85, lng: 5.69, no2: 26, pm25: 11, o3: 50, population: 121565 },
{ id: 8, name: 'Nijmegen', city: 'Nijmegen', lat: 51.81, lng: 5.84, no2: 28, pm25: 12, o3: 47, population: 177359 },
{ id: 9, name: 'Arnhem', city: 'Arnhem', lat: 51.99, lng: 5.90, no2: 27, pm25: 11, o3: 49, population: 164096 },
{ id: 10, name: 'Breda', city: 'Breda', lat: 51.57, lng: 4.77, no2: 30, pm25: 13, o3: 46, population: 184403 },
{ id: 11, name: 'Tilburg', city: 'Tilburg', lat: 51.56, lng: 5.09, no2: 33, pm25: 15, o3: 43, population: 224702 },
{ id: 12, name: 'Almere', city: 'Almere', lat: 52.35, lng: 5.22, no2: 24, pm25: 10, o3: 51, population: 218096 },
{ id: 13, name: 'Haarlem', city: 'Haarlem', lat: 52.39, lng: 4.64, no2: 30, pm25: 13, o3: 47, population: 162543 },
{ id: 14, name: 'Enschede', city: 'Enschede', lat: 52.22, lng: 6.89, no2: 25, pm25: 10, o3: 50, population: 159732 },
{ id: 15, name: 'Apeldoorn', city: 'Apeldoorn', lat: 52.21, lng: 5.97, no2: 23, pm25: 9, o3: 52, population: 164781 },
{ id: 16, name: 'Amersfoort', city: 'Amersfoort', lat: 52.16, lng: 5.39, no2: 26, pm25: 11, o3: 48, population: 158712 },
{ id: 17, name: 'Zwolle', city: 'Zwolle', lat: 52.52, lng: 6.08, no2: 24, pm25: 10, o3: 51, population: 130592 },
{ id: 18, name: 'Leiden', city: 'Leiden', lat: 52.16, lng: 4.49, no2: 28, pm25: 12, o3: 47, population: 125565 }
];
function jitterInt(v, pct = 0.08) {
return Math.max(5, Math.round(v * (1 + (Math.random() - 0.5) * pct * 2)));
}
function buildHourlyTrend(baseNo2, basePm25) {
const hours = [];
const now = new Date();
for (let h = 0; h < 24; h++) {
const rush = h >= 7 && h <= 9 ? 1.28 : h >= 16 && h <= 19 ? 1.22 : h >= 0 && h <= 5 ? 0.72 : 1;
hours.push({
hour: `${String(h).padStart(2, '0')}:00`,
no2: Math.round(baseNo2 * rush * (0.92 + Math.random() * 0.16)),
pm25: Math.round(basePm25 * (0.88 + Math.random() * 0.2)),
traffic: Math.round(rush * 100 * (0.8 + Math.random() * 0.4)),
noise: Math.round(45 + rush * 25 + Math.random() * 10)
});
}
hours[now.getHours()].no2 = jitterInt(hours[now.getHours()].no2, 0.03);
return hours;
}
function buildLiveSeconds(baseNo2) {
const pts = [];
for (let i = 59; i >= 0; i--) {
pts.push({
sec: i,
no2: jitterInt(baseNo2 * (0.95 + Math.sin(i / 8) * 0.08), 0.04),
cameras: 12,
events: Math.random() > 0.92 ? 1 : 0
});
}
return pts;
}
async function getSmartCityData() {
const { cameras, lampposts } = evolveInfrastructure();
const stations = NL_STATIONS.map(s => ({
...s,
no2: jitterInt(s.no2),
pm25: jitterInt(s.pm25),
o3: jitterInt(s.o3),
source: 'rivm-sim'
}));
const avgNo2 = Math.round(stations.reduce((a, s) => a + s.no2, 0) / stations.length);
const avgPm25 = Math.round(stations.reduce((a, s) => a + s.pm25, 0) / stations.length);
const aboveThreshold = stations.filter(s => s.no2 > 40 || s.pm25 > 25).length;
const cityMap = {};
stations.forEach(s => {
const c = s.city;
if (!cityMap[c]) cityMap[c] = { city: c, no2: 0, pm25: 0, count: 0, lat: s.lat, lng: s.lng };
cityMap[c].no2 += s.no2;
cityMap[c].pm25 += s.pm25;
cityMap[c].count++;
});
const cityRankings = Object.values(cityMap)
.map(c => ({ ...c, no2: Math.round(c.no2 / c.count), pm25: Math.round(c.pm25 / c.count) }))
.sort((a, b) => b.no2 - a.no2);
const statusDistribution = [
{ name: 'Goed', value: stations.filter(s => s.no2 <= 28 && s.pm25 <= 15).length, fill: '#22c55e' },
{ name: 'Matig', value: stations.filter(s => (s.no2 > 28 && s.no2 <= 40) || (s.pm25 > 15 && s.pm25 <= 25)).length, fill: '#f59e0b' },
{ name: 'Onvoldoende', value: stations.filter(s => s.no2 > 40 || s.pm25 > 25).length, fill: '#ef4444' }
];
const hourlyTrend = buildHourlyTrend(avgNo2, avgPm25);
const liveSeconds = buildLiveSeconds(avgNo2);
const weeklyTrend = ['Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za', 'Zo'].map((d, i) => ({
day: d,
no2: Math.round(avgNo2 * (0.9 + Math.sin(i) * 0.15 + Math.random() * 0.1)),
pm25: Math.round(avgPm25 * (0.85 + Math.cos(i) * 0.12 + Math.random() * 0.1))
}));
const pollutantRadar = [
{ pollutant: 'NO₂', value: avgNo2, full: 60 },
{ pollutant: 'PM2.5', value: avgPm25, full: 35 },
{ pollutant: 'O₃', value: Math.round(stations.reduce((a, s) => a + s.o3, 0) / stations.length), full: 70 },
{ pollutant: 'SO₂', value: Math.round(8 + Math.random() * 6), full: 25 },
{ pollutant: 'CO', value: Math.round(3 + Math.random() * 4), full: 15 }
];
const cameraStats = {
total: cameras.length,
online: cameras.filter(c => c.online).length,
traffic: cameras.filter(c => c.type === 'traffic').length,
security: cameras.filter(c => c.type === 'security').length,
avgLatency: Math.round(cameras.reduce((a, c) => a + c.latencyMs, 0) / cameras.length)
};
const lamppostStats = {
total: lampposts.length,
activeMotion: lampposts.filter(l => l.motion).length,
avgBrightness: Math.round(lampposts.reduce((a, l) => a + l.brightness, 0) / lampposts.length),
energyTodayKwh: +(lampposts.reduce((a, l) => a + l.energyWh, 0) / 1000).toFixed(2)
};
const recentEvents = [];
if (Math.random() > 0.3) {
const cam = cameras[Math.floor(Math.random() * cameras.length)];
recentEvents.push({
ts: new Date().toISOString(),
type: ['motion', 'crowd', 'vehicle', 'incident'][Math.floor(Math.random() * 4)],
camera: cam.name,
severity: ['info', 'warn', 'alert'][Math.floor(Math.random() * 3)]
});
}
lampposts.filter(l => l.motion).slice(0, 2).forEach(lp => {
recentEvents.push({
ts: new Date().toISOString(),
type: 'motion-lamp',
camera: lp.street,
severity: 'info'
});
});
const insights = [
{ type: 'warn', label: 'Live nu', text: `${cameraStats.online}/${cameraStats.total} camera's online · gem. latency ${cameraStats.avgLatency}ms · refresh 1,5s.` },
{ type: 'info', label: 'Piekuren', text: `NO₂ piek ~08:00 (${hourlyTrend[8]?.no2} µg/m³) gekoppeld aan verkeerscamera's A10/IJ-tunnel.` },
{ type: 'ok', label: 'Smart lighting', text: `${lamppostStats.activeMotion} lantaarnpalen met motion-detect — ${lamppostStats.energyTodayKwh} kWh vandaag.` },
{ type: 'warn', label: 'Stad', text: `${cityRankings[0]?.city}: hoogste NO₂ (${cityRankings[0]?.no2}). Drill-down via kaart → camera feed.` },
{ type: 'info', label: 'Integratie', text: 'Productie: koppel gemeente RTSP/ONVIF via edge gateway — zelfde dashboard UI.' },
{ type: 'ok', label: 'Compliance', text: 'AVG: anonimiseer video analytics; bewaartermijnen per camera-zone configureerbaar.' }
];
const contextBlocks = [
{
title: 'Waarom Smart City dashboards?',
body: 'Gemeenten beheren duizenden assets: camera\'s, sensoren, lantaarnpalen, verkeerslichtmasten. Zonder unified view reageer je te laat op incidenten, overshoot je energie-budget en mis je datagedreven beleid.'
},
{
title: 'Wat u hier ziet',
body: 'Een Global Operations Center voor de Randstad: live camera-streams, IoT-lantaarnpalen, luchtkwaliteit en event feed — alles op één kaart met sub-second refresh.'
},
{
title: 'Technische aanpak Mek-Tech',
body: 'Open data (RIVM, KNMI) + RTSP-ingest via edge + SSE/WebSocket naar browser. Geen vendor lock-in; dashboards op maat in dagen i.p.v. maanden.'
},
{
title: 'ROI voor uw organisatie',
body: 'Snellere incident response (40% meld-naar-actie tijd), energiebesparing smart lighting (1525%), betere burgercommunicatie via open kaartlagen.'
},
{
title: 'Productie-architectuur',
body: 'Camera gateway → Kafka/EventHub → time-series DB → dit dashboard. Video blijft on-prem; metadata & analytics in cloud of gemeente-DC.'
},
{
title: 'Volgende stap',
body: 'Pilot op 1 wijk: 20 camera\'s + 50 lantaarnpalen + luchtsensoren. Mek-Tech levert integratie, UI en SLA binnen 46 weken.'
}
];
return {
ts: new Date().toISOString(),
refreshMs: 1500,
stations,
cameras,
lampposts,
cityRankings,
avgNo2,
avgPm25,
aboveThreshold,
statusDistribution,
hourlyTrend,
liveSeconds,
weeklyTrend,
pollutantRadar,
insights,
contextBlocks,
cameraStats,
lamppostStats,
recentEvents,
healthIndex: Math.max(0, Math.min(100, 100 - Math.round(avgNo2 * 1.2 + avgPm25 * 1.5))),
sensorsOnline: stations.length + cameras.length + lampposts.length,
populationCovered: stations.reduce((a, s) => a + (s.population || 50000), 0),
rivmLive: false,
mapCenter: { lat: 52.37, lng: 4.90, zoom: 11 },
context: {
pitch: 'Global Smart City Command Center — camera\'s, lantaarnpalen, lucht & verkeer realtime.',
deployment: 'Amsterdam · Rotterdam · Den Haag · Utrecht · Eindhoven'
}
};
}
module.exports = { getSmartCityData, getCameraById };
+68
View File
@@ -0,0 +1,68 @@
const cache = require('../cache');
async function getSupplyChainData() {
const flows = [
{ from: 'China', to: 'Rotterdam', value: 42.5, delay: 2.1, mode: 'Sea' },
{ from: 'VS', to: 'Rotterdam', value: 18.2, delay: 1.4, mode: 'Sea' },
{ from: 'Duitsland', to: 'Amsterdam', value: 12.8, delay: 0.6, mode: 'Road' },
{ from: 'België', to: 'Rotterdam', value: 9.4, delay: 0.4, mode: 'Road' },
{ from: 'VK', to: 'Rotterdam', value: 7.1, delay: 1.8, mode: 'Sea' },
{ from: 'India', to: 'Rotterdam', value: 5.2, delay: 3.2, mode: 'Sea' }
];
const throughput = Array.from({ length: 12 }, (_, i) => ({
month: ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D'][i],
rotterdam: Math.round(820 + Math.sin(i / 2) * 40 + i * 3),
amsterdam: Math.round(95 + Math.sin(i / 3) * 8 + i * 0.5),
antwerp: Math.round(210 + Math.cos(i / 2) * 15)
}));
const modeSplit = [
{ name: 'Zee', value: 68, fill: '#f59e0b' },
{ name: 'Weg', value: 22, fill: '#00d4ff' },
{ name: 'Spoor', value: 7, fill: '#22c55e' },
{ name: 'Lucht', value: 3, fill: '#7c3aed' }
];
const delayByRoute = flows.map(f => ({ route: `${f.from}${f.to}`, delay: f.delay, volume: f.value }));
const portLocations = [
{ name: 'Rotterdam', lat: 51.95, lng: 4.12, throughput: 868, risk: 34 },
{ name: 'Amsterdam', lat: 52.37, lng: 4.90, throughput: 98, risk: 22 },
{ name: 'Moerdijk', lat: 51.72, lng: 4.62, throughput: 45, risk: 18 }
];
const insights = [
{ type: 'warn', label: 'Risico', text: '72% import via top-3 landen — concentratierisico zichtbaar in flow view.' },
{ type: 'info', label: 'Haven', text: 'Rotterdam 868M ton — koppel ETA-data voor proactieve vertraging-alerts.' },
{ type: 'ok', label: 'Actie', text: 'Multimodaal split toont waar rail/barge investering ROI heeft.' },
{ type: 'warn', label: 'Vertraging', text: `Gem. vertraging ${+(flows.reduce((a, f) => a + f.delay, 0) / flows.length).toFixed(1)} d — India-route 3.2d monitor.` },
{ type: 'info', label: 'Throughput', text: 'Rotterdam +3% YoY — Antwerpen concurrentie in zelfde view.' },
{ type: 'ok', label: 'Integratie', text: 'ERP/WMS ETA feed → SSE alerts — zelfde Global Ops shell als andere verticals.' }
];
const contextBlocks = [
{ title: 'Waarom Supply Chain Tower?', body: 'Logistiek directeuren missen vaak één view op havens, routes, vertraging en concentratierisico — tot er disruption is.' },
{ title: 'Wat u hier ziet', body: 'Import flows, haven throughput trends, multimodaal split, route delays — refresh elke 2,5 seconden.' },
{ title: 'Technische aanpak Mek-Tech', body: 'Eurostat COMEXT + haven open data + eigen ERP koppeling. Lightweight SSE i.p.v. zware SCM suite.' },
{ title: 'ROI voor uw organisatie', body: 'Proactieve vertraging-alerts, betere modal shift beslissingen, lagere voorraad-buffer.' },
{ title: 'Productie-architectuur', body: 'Port API + AIS + ERP → event hub → dit dashboard. Drill-down per shipment optioneel.' },
{ title: 'Volgende stap', body: 'Pilot Rotterdamwarehouse corridor met ETA alerts — live binnen 4 weken.' }
];
return {
ts: new Date().toISOString(),
refreshMs: 2500,
totalThroughput: 868, throughputYoy: 3.2, flows, throughput,
riskScore: 34, concentrationTop3: 72.5, modeSplit, delayByRoute, portLocations, insights, contextBlocks,
avgDelay: +(flows.reduce((a, f) => a + f.delay, 0) / flows.length).toFixed(1),
activeRoutes: flows.length,
delayedRoutes: flows.filter(f => f.delay > 2).length,
context: {
pitch: 'Global Supply Chain Control Tower — havens, flows & risico live.',
deployment: 'Eurostat COMEXT · Port of Rotterdam · CBS Logistiek'
}
};
}
module.exports = { getSupplyChainData };
+127
View File
@@ -0,0 +1,127 @@
const cache = require('../cache');
const PAIRS = [
{ key: 'EUR/USD', url: 'https://data-api.ecb.europa.eu/service/data/EXR/D.USD.EUR.SP00.A?lastNObservations=90&format=jsondata' },
{ key: 'EUR/GBP', url: 'https://data-api.ecb.europa.eu/service/data/EXR/D.GBP.EUR.SP00.A?lastNObservations=90&format=jsondata' },
{ key: 'EUR/JPY', url: 'https://data-api.ecb.europa.eu/service/data/EXR/D.JPY.EUR.SP00.A?lastNObservations=90&format=jsondata' },
{ key: 'EUR/CHF', url: 'https://data-api.ecb.europa.eu/service/data/EXR/D.CHF.EUR.SP00.A?lastNObservations=90&format=jsondata' }
];
function parseEcbJson(data, key) {
try {
const series = data?.dataSets?.[0]?.series;
if (!series) return null;
const firstKey = Object.keys(series)[0];
const obs = series[firstKey]?.observations;
if (!obs) return null;
const dates = data.structure?.dimensions?.observation?.[0]?.values || [];
const points = Object.entries(obs).map(([idx, val]) => ({
i: parseInt(idx, 10),
rate: val[0]
})).sort((a, b) => a.i - b.i);
const history = points.map(p => ({
date: dates[p.i]?.id || `t${p.i}`,
rate: p.rate
}));
const rates = history.map(h => h.rate);
const vol = rates.length > 5
? Math.sqrt(rates.slice(-30).reduce((s, r, i, arr) => {
if (i === 0) return 0;
const d = (r - arr[i - 1]) / arr[i - 1];
return s + d * d;
}, 0) / 29) * 100
: 0;
return {
pair: key,
current: points[points.length - 1]?.rate,
previous: points[points.length - 2]?.rate,
weekAgo: points[Math.max(0, points.length - 6)]?.rate,
history,
volatility: +vol.toFixed(3),
high30: Math.max(...rates.slice(-30)),
low30: Math.min(...rates.slice(-30))
};
} catch (e) {
return null;
}
}
async function fetchPair(pair) {
const cached = cache.get(`ecb-${pair.key}`, 30 * 60 * 1000);
if (cached) return cached;
try {
const res = await fetch(pair.url, { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error(`ECB ${res.status}`);
const data = await res.json();
const parsed = parseEcbJson(data, pair.key);
if (parsed) cache.set(`ecb-${pair.key}`, parsed);
return parsed;
} catch (e) {
return cached || fallbackPair(pair.key);
}
}
function fallbackPair(key) {
const base = { 'EUR/USD': 1.08, 'EUR/GBP': 0.86, 'EUR/JPY': 163.5, 'EUR/CHF': 0.97 }[key] || 1;
const history = Array.from({ length: 90 }, (_, i) => ({
date: `D-${90 - i}`,
rate: +(base + Math.sin(i / 6) * 0.03 + (Math.random() - 0.5) * 0.008).toFixed(4)
}));
return {
pair: key, current: history[history.length - 1].rate, previous: history[history.length - 2].rate,
weekAgo: history[history.length - 6].rate, history, volatility: 0.12, high30: base * 1.02, low30: base * 0.98, fallback: true
};
}
async function getTradingData() {
const pairs = (await Promise.all(PAIRS.map(fetchPair))).filter(Boolean);
const ticker = pairs.map(p => ({
pair: p.pair, rate: p.current,
change: p.previous ? +(((p.current - p.previous) / p.previous) * 100).toFixed(3) : 0,
weekChange: p.weekAgo ? +(((p.current - p.weekAgo) / p.weekAgo) * 100).toFixed(2) : 0,
fallback: !!p.fallback
}));
const correlation = pairs.length >= 2 ? [
{ pair: 'EUR/USD vs GBP', corr: 0.72 + Math.random() * 0.1 },
{ pair: 'EUR/USD vs JPY', corr: -0.45 + Math.random() * 0.1 },
{ pair: 'EUR/GBP vs CHF', corr: 0.58 + Math.random() * 0.08 }
] : [];
const volumeProfile = ['09:00', '11:00', '13:00', '15:00', '17:00'].map(t => ({
time: t, volume: Math.round(40 + Math.random() * 60)
}));
const insights = [
{ type: 'info', label: 'EUR/USD', text: `Spot ${pairs[0]?.current?.toFixed(4)} — volatiliteit ${pairs[0]?.volatility}% (30d). Ideaal voor treasury risk dashboards.` },
{ type: 'warn', label: 'Alert', text: 'Configureer drempels per currency pair — SSE pusht alerts binnen 2s naar traders.' },
{ type: 'ok', label: 'Bron', text: 'ECB SDW open API — geen Bloomberg-licentie nodig voor FX monitoring.' },
{ type: 'info', label: 'Correlatie', text: 'Cross-pair correlaties helpen hedge-ratios te kalibreren — live bijgewerkt.' },
{ type: 'warn', label: 'Volatiliteit', text: `${pairs[0]?.pair || 'EUR/USD'} 30d range: ${pairs[0] ? ((pairs[0].high30 - pairs[0].low30) * 10000).toFixed(0) : '—'} bps — monitor bij ECB speeches.` },
{ type: 'ok', label: 'Integratie', text: 'Koppel treasury limits, ERP en dealing room — zelfde Global Ops UI als andere verticals.' }
];
const contextBlocks = [
{ title: 'Waarom FX Command Center?', body: 'Treasury teams hebben realtime zicht nodig op exposure, volatiliteit en correlaties — zonder dure terminal-licenties of vertraagde batch-exports.' },
{ title: 'Wat u hier ziet', body: 'Live ECB koersen, multi-pair ticker, 90-dagen historie, volatiliteit en sessie-volume — refresh elke 2 seconden via SSE.' },
{ title: 'Technische aanpak Mek-Tech', body: 'Open ECB SDW API + eigen cache + SSE push. Alerts rules engine koppelt aan Slack, Teams of dealing room.' },
{ title: 'ROI voor uw organisatie', body: 'Snellere hedge-beslissingen, lagere datakosten vs Bloomberg/Reuters, audit trail voor risk committees.' },
{ title: 'Productie-architectuur', body: 'ECB feed → normalisatie → time-series DB → dit dashboard. Optioneel: bank internal rates overlay.' },
{ title: 'Volgende stap', body: 'Pilot met 4 currency pairs + alert drempels + export naar treasury systeem — live binnen 2 weken.' }
];
return {
ts: new Date().toISOString(),
refreshMs: 2000,
pairs, ticker, correlation, volumeProfile, insights, contextBlocks,
spreadEurUsd: pairs[0] ? +((pairs[0].high30 - pairs[0].low30) * 10000).toFixed(1) : 0,
alertCount: Math.floor(Math.random() * 3),
sessionVolume: Math.round(volumeProfile.reduce((a, v) => a + v.volume, 0)),
context: {
pitch: 'Global FX Operations Center — ECB live data, treasury alerts, multi-pair risk.',
deployment: 'ECB SDW · DNB Statistieken · Treasury SSE'
}
};
}
module.exports = { getTradingData };