Initial commit — Mek-Tech Live Labs Showcase v1.0
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
cache/
|
||||
frontend/dist/
|
||||
.env
|
||||
*.log
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY frontend ./frontend
|
||||
COPY server ./server
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
COPY server ./server
|
||||
COPY --from=builder /app/frontend/dist ./frontend/dist
|
||||
ENV PORT=3010
|
||||
ENV CACHE_DIR=/app/cache
|
||||
RUN mkdir -p /app/cache
|
||||
EXPOSE 3010
|
||||
CMD ["node", "server/index.js"]
|
||||
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="referrer" content="strict-origin-when-cross-origin" />
|
||||
<title>Mek-Tech Live Labs</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import Shell from './layouts/Shell';
|
||||
import Hub from './pages/Hub';
|
||||
import Trading from './pages/Trading';
|
||||
import Retail from './pages/Retail';
|
||||
import SupplyChain from './pages/SupplyChain';
|
||||
import Fraud from './pages/Fraud';
|
||||
import SmartCity from './pages/SmartCity';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Shell>
|
||||
<Routes>
|
||||
<Route path="/" element={<Hub />} />
|
||||
<Route path="/trading" element={<Trading />} />
|
||||
<Route path="/retail" element={<Retail />} />
|
||||
<Route path="/supply-chain" element={<SupplyChain />} />
|
||||
<Route path="/fraud" element={<Fraud />} />
|
||||
<Route path="/smart-city" element={<SmartCity />} />
|
||||
</Routes>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { LiveBadge } from './shared';
|
||||
import { InsightStrip } from './dashboard';
|
||||
|
||||
export function GlobalOpsLayout({
|
||||
badge = 'GLOBAL OPERATIONS CENTER',
|
||||
title,
|
||||
subtitle,
|
||||
lastUpdate,
|
||||
refreshMs,
|
||||
contextBlocks,
|
||||
insights,
|
||||
children
|
||||
}) {
|
||||
return (
|
||||
<div className="ops-global">
|
||||
<div className="global-header">
|
||||
<div>
|
||||
<span className="global-badge">{badge}</span>
|
||||
<h1 className="global-title">{title}</h1>
|
||||
{subtitle && <p className="global-sub">{subtitle}</p>}
|
||||
</div>
|
||||
<div className="global-header-right">
|
||||
<LiveBadge lastUpdate={lastUpdate} intervalMs={refreshMs} />
|
||||
{refreshMs && <span className="refresh-badge">⚡ {refreshMs}ms SSE</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{contextBlocks?.length > 0 && (
|
||||
<div className="context-grid">
|
||||
{contextBlocks.map((block, i) => (
|
||||
<div key={i} className="context-card glass">
|
||||
<h3>{block.title}</h3>
|
||||
<p>{block.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InsightStrip items={insights} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
|
||||
const REFRESH_MS = 1200;
|
||||
const PRIMARY_REFRESH_MS = 800;
|
||||
|
||||
function CctvOverlay({ camera }) {
|
||||
return (
|
||||
<div className="cctv-overlay-text">
|
||||
<div>{camera.name}</div>
|
||||
<div>{camera.city} · {camera.zone}</div>
|
||||
<div className="cctv-clock">{new Date().toLocaleTimeString('nl-NL')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SnapshotFeed({ camera, compact, fast }) {
|
||||
const [tick, setTick] = useState(0);
|
||||
const [err, setErr] = useState(false);
|
||||
const interval = fast ? PRIMARY_REFRESH_MS : REFRESH_MS;
|
||||
|
||||
useEffect(() => {
|
||||
setErr(false);
|
||||
const iv = setInterval(() => setTick(t => t + 1), interval);
|
||||
return () => clearInterval(iv);
|
||||
}, [camera?.id, interval]);
|
||||
|
||||
const src = camera?.frameUrl
|
||||
? `${camera.frameUrl}?t=${tick}`
|
||||
: camera?.youtubeId
|
||||
? `https://i.ytimg.com/vi/${camera.youtubeId}/hqdefault_live.jpg?t=${tick}`
|
||||
: null;
|
||||
|
||||
if (!src || err) {
|
||||
return (
|
||||
<div className="cctv-synthetic">
|
||||
<div className="cctv-scanlines" />
|
||||
<CctvOverlay camera={camera} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
key={`${camera.id}-${tick}`}
|
||||
src={src}
|
||||
alt={camera.name}
|
||||
className="cctv-snapshot"
|
||||
onError={() => setErr(true)}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
/>
|
||||
<div className="cctv-scanlines cctv-scanlines-light" />
|
||||
{!compact && <CctvOverlay camera={camera} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function YoutubeFeed({ camera, onFail }) {
|
||||
const failed = useRef(false);
|
||||
const params = new URLSearchParams({
|
||||
autoplay: '1',
|
||||
mute: '1',
|
||||
controls: '1',
|
||||
playsinline: '1',
|
||||
rel: '0',
|
||||
modestbranding: '1',
|
||||
iv_load_policy: '3',
|
||||
fs: '0'
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
failed.current = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (!failed.current) onFail?.();
|
||||
}, 12000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [camera?.id, onFail]);
|
||||
|
||||
const embedUrl = `https://www.youtube.com/embed/${camera.youtubeId}?${params}`;
|
||||
|
||||
return (
|
||||
<iframe
|
||||
title={camera.name}
|
||||
src={embedUrl}
|
||||
className="cctv-youtube"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
onError={() => { failed.current = true; onFail?.(); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HlsFeed({ camera, onFail }) {
|
||||
const videoRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!camera?.streamUrl || !videoRef.current) return;
|
||||
const video = videoRef.current;
|
||||
let hls;
|
||||
let cancelled = false;
|
||||
|
||||
async function play() {
|
||||
try {
|
||||
if (camera.mp4Url) {
|
||||
video.src = camera.mp4Url;
|
||||
video.loop = true;
|
||||
await video.play();
|
||||
return;
|
||||
}
|
||||
|
||||
if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = camera.streamUrl;
|
||||
await video.play();
|
||||
return;
|
||||
}
|
||||
|
||||
const Hls = (await import('hls.js')).default;
|
||||
if (!Hls.isSupported()) {
|
||||
onFail?.();
|
||||
return;
|
||||
}
|
||||
|
||||
hls = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: true,
|
||||
startLevel: -1,
|
||||
capLevelToPlayerSize: false,
|
||||
maxBufferLength: 12,
|
||||
maxMaxBufferLength: 20
|
||||
});
|
||||
|
||||
hls.loadSource(camera.streamUrl);
|
||||
hls.attachMedia(video);
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
if (cancelled) return;
|
||||
if (hls.levels?.length) hls.currentLevel = hls.levels.length - 1;
|
||||
video.play().catch(() => onFail?.());
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.ERROR, (_, data) => {
|
||||
if (cancelled || !data.fatal) return;
|
||||
onFail?.();
|
||||
});
|
||||
} catch (_) {
|
||||
onFail?.();
|
||||
}
|
||||
}
|
||||
|
||||
play();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
hls?.destroy();
|
||||
};
|
||||
}, [camera?.id, camera?.streamUrl, camera?.mp4Url, onFail]);
|
||||
|
||||
return <video ref={videoRef} muted autoPlay playsInline loop className="cctv-video" />;
|
||||
}
|
||||
|
||||
export function LiveCameraFeed({ camera, compact, className = '', isPrimary = false }) {
|
||||
const [mode, setMode] = useState('snapshot');
|
||||
|
||||
const failYoutube = useCallback(() => setMode('hls'), []);
|
||||
const failHls = useCallback(() => setMode('snapshot'), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!camera) return;
|
||||
if (isPrimary && camera.youtubeId) setMode('youtube');
|
||||
else if (camera.frameUrl || camera.youtubeId) setMode('snapshot');
|
||||
else setMode('hls');
|
||||
}, [camera?.id, camera?.youtubeId, camera?.frameUrl, isPrimary]);
|
||||
|
||||
if (!camera) return null;
|
||||
|
||||
const showYoutube = mode === 'youtube' && camera.youtubeId && isPrimary;
|
||||
const showHls = mode === 'hls';
|
||||
const showSnapshot = mode === 'snapshot' || (!showYoutube && !showHls);
|
||||
|
||||
const modeLabel = showYoutube ? 'YouTube live' : showHls ? 'HD gateway' : 'Live frames';
|
||||
|
||||
return (
|
||||
<div className={`cctv-feed ${compact ? 'cctv-compact' : ''} ${className}`}>
|
||||
<div className="cctv-header">
|
||||
<span className="cctv-live"><span className="live-dot" /> LIVE</span>
|
||||
{!compact && <span className="cctv-name">{camera.name}</span>}
|
||||
{!compact && (
|
||||
<span className="cctv-meta">
|
||||
{camera.fps}fps · {camera.bitrateMbps}Mbps · {camera.latencyMs}ms · {modeLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="cctv-screen">
|
||||
{showYoutube ? (
|
||||
<YoutubeFeed camera={camera} onFail={failYoutube} />
|
||||
) : showHls ? (
|
||||
<HlsFeed camera={camera} onFail={failHls} />
|
||||
) : showSnapshot ? (
|
||||
<SnapshotFeed camera={camera} compact={compact} fast={isPrimary && !compact} />
|
||||
) : null}
|
||||
<div className="cctv-tags">
|
||||
{(camera.aiTags || []).slice(0, compact ? 1 : 3).map(t => (
|
||||
<span key={t} className="cctv-tag">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{!compact && (
|
||||
<div className="cctv-footer">
|
||||
<span>{camera.type === 'traffic' ? '🚦 Verkeer' : '🛡️ Security'}</span>
|
||||
<span>{camera.resolution}</span>
|
||||
<span className={camera.online ? 'kpi-up' : 'kpi-down'}>{camera.online ? 'ONLINE' : 'OFFLINE'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CameraWall({ cameras, selectedId, onSelect }) {
|
||||
return (
|
||||
<div className="camera-wall">
|
||||
{(cameras || []).map(cam => (
|
||||
<button
|
||||
key={cam.id}
|
||||
type="button"
|
||||
className={`camera-thumb ${selectedId === cam.id ? 'active' : ''} ${!cam.online ? 'offline' : ''}`}
|
||||
onClick={() => onSelect(cam)}
|
||||
>
|
||||
<LiveCameraFeed camera={cam} compact />
|
||||
<div className="thumb-label">{cam.name}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { MapContainer, TileLayer, CircleMarker, Marker, Popup, useMap } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
function aqiColor(no2, pm25) {
|
||||
const score = (no2 || 0) * 0.6 + (pm25 || 0) * 1.2;
|
||||
if (score > 45) return '#ef4444';
|
||||
if (score > 30) return '#f59e0b';
|
||||
return '#22c55e';
|
||||
}
|
||||
|
||||
const cameraIcon = (active, online) => L.divIcon({
|
||||
className: 'custom-marker',
|
||||
html: `<div class="marker-cam ${active ? 'active' : ''} ${online ? '' : 'offline'}"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M17 10.5V7a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-3.5l4 4v-11l-4 4z"/></svg></div>`,
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16]
|
||||
});
|
||||
|
||||
const lampIcon = (motion, active) => L.divIcon({
|
||||
className: 'custom-marker',
|
||||
html: `<div class="marker-lamp ${active ? 'active' : ''} ${motion ? 'motion' : ''}"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9v1zm3-19C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7z"/></svg></div>`,
|
||||
iconSize: [28, 28],
|
||||
iconAnchor: [14, 14]
|
||||
});
|
||||
|
||||
function MapController({ center, zoom }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (center) map.setView([center.lat, center.lng], zoom || 11, { animate: true });
|
||||
}, [center, zoom, map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function SmartCityCommandMap({
|
||||
stations, cameras, lampposts, layers,
|
||||
selectedCamera, selectedLamp, onSelectCamera, onSelectLamp
|
||||
}) {
|
||||
const center = useMemo(() => [52.37, 4.90], []);
|
||||
|
||||
return (
|
||||
<div className="globe-map-scene">
|
||||
<div className="globe-grid-overlay" />
|
||||
<div className="globe-map-frame">
|
||||
<MapContainer center={center} zoom={11} className="leaflet-globe" scrollWheelZoom zoomControl>
|
||||
<TileLayer
|
||||
attribution='© CARTO · Mek-Tech GIS'
|
||||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||
/>
|
||||
<MapController center={{ lat: 52.37, lng: 4.90 }} zoom={11} />
|
||||
|
||||
{layers.air && (stations || []).map(s => {
|
||||
const color = aqiColor(s.no2, s.pm25);
|
||||
return (
|
||||
<CircleMarker key={`s-${s.id}`} center={[s.lat, s.lng]} radius={5 + (s.no2 || 0) / 8}
|
||||
pathOptions={{ color, fillColor: color, fillOpacity: 0.45, weight: 1 }}>
|
||||
<Popup><strong>{s.name}</strong><br />NO₂ {s.no2} · PM2.5 {s.pm25}</Popup>
|
||||
</CircleMarker>
|
||||
);
|
||||
})}
|
||||
|
||||
{layers.cameras && (cameras || []).map(cam => (
|
||||
<Marker
|
||||
key={cam.id}
|
||||
position={[cam.lat, cam.lng]}
|
||||
icon={cameraIcon(selectedCamera?.id === cam.id, cam.online)}
|
||||
eventHandlers={{ click: () => onSelectCamera?.(cam) }}
|
||||
>
|
||||
<Popup>
|
||||
<strong>{cam.name}</strong><br />
|
||||
{cam.type} · {cam.zone}<br />
|
||||
<button type="button" className="popup-btn" onClick={() => onSelectCamera?.(cam)}>Open live stream</button>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{layers.lamps && (lampposts || []).map(lp => (
|
||||
<Marker
|
||||
key={lp.id}
|
||||
position={[lp.lat, lp.lng]}
|
||||
icon={lampIcon(lp.motion, selectedLamp?.id === lp.id)}
|
||||
eventHandlers={{ click: () => onSelectLamp?.(lp) }}
|
||||
>
|
||||
<Popup>
|
||||
<strong>{lp.street}</strong><br />
|
||||
Helderheid {lp.brightness}% · {lp.energyWh}Wh<br />
|
||||
{lp.motion ? '⚡ Motion detected' : 'Geen motion'}
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
</div>
|
||||
<div className="globe-map-hud">
|
||||
<span>RANDSTAD GIS</span>
|
||||
<span>{cameras?.filter(c => c.online).length}/{cameras?.length} CAM</span>
|
||||
<span>{lampposts?.length} LP</span>
|
||||
<span className="hud-live">● 1.5s REFRESH</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CityPulse3D({ cities }) {
|
||||
const top = (cities || []).slice(0, 8);
|
||||
const max = Math.max(...top.map(c => c.no2 || 0), 1);
|
||||
return (
|
||||
<div className="city-pulse-3d">
|
||||
<div className="pulse-grid">
|
||||
{top.map((c, i) => {
|
||||
const h = 20 + ((c.no2 || 0) / max) * 120;
|
||||
const color = aqiColor(c.no2, c.pm25);
|
||||
return (
|
||||
<div key={c.city} className="pulse-col" style={{ animationDelay: `${i * 0.08}s` }}>
|
||||
<div className="pulse-bar" style={{ height: h, background: `linear-gradient(180deg, ${color}, ${color}44)`, boxShadow: `0 0 20px ${color}66` }} />
|
||||
<div className="pulse-label">{c.city?.slice(0, 6)}</div>
|
||||
<div className="pulse-val">{c.no2}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
export const TOOLTIP = {
|
||||
contentStyle: { background: '#0e1626', border: '1px solid rgba(0,212,255,0.25)', borderRadius: 8 },
|
||||
labelStyle: { color: '#8ba3bc' }
|
||||
};
|
||||
|
||||
export const CHART_GRID = { stroke: 'rgba(255,255,255,0.06)' };
|
||||
|
||||
export function UseCaseHero({ title, badge, subtitle, valueProps, sources }) {
|
||||
return (
|
||||
<div className="hero-block glass">
|
||||
<div className="hero-top">
|
||||
<div>
|
||||
{badge && <span className="hero-badge">{badge}</span>}
|
||||
<h1 className="page-title" style={{ marginBottom: '0.35rem' }}>{title}</h1>
|
||||
<p className="page-sub" style={{ marginBottom: 0 }}>{subtitle}</p>
|
||||
</div>
|
||||
{sources && (
|
||||
<div className="source-tags">
|
||||
{sources.map(s => <span key={s} className="source-tag">{s}</span>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{valueProps?.length > 0 && (
|
||||
<div className="value-props">
|
||||
{valueProps.map(v => (
|
||||
<div key={v.title} className="value-prop">
|
||||
<div className="vp-icon">{v.icon}</div>
|
||||
<div>
|
||||
<div className="vp-title">{v.title}</div>
|
||||
<div className="vp-desc">{v.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InsightStrip({ items }) {
|
||||
if (!items?.length) return null;
|
||||
return (
|
||||
<div className="insight-strip">
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className={`insight-item insight-${item.type || 'info'}`}>
|
||||
<span className="insight-label">{item.label}</span>
|
||||
<span className="insight-text">{item.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartGrid({ children, cols = 2 }) {
|
||||
return <div className={`chart-grid cols-${cols}`}>{children}</div>;
|
||||
}
|
||||
|
||||
export function ChartCard({ title, subtitle, children, tall }) {
|
||||
return (
|
||||
<div className={`glass chart-wrap ${tall ? 'chart-tall' : ''}`}>
|
||||
<div className="chart-title">{title}</div>
|
||||
{subtitle && <div className="chart-subtitle">{subtitle}</div>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useStream(vertical) {
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [lastUpdate, setLastUpdate] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let es;
|
||||
const connect = () => {
|
||||
es = new EventSource(`/stream/${vertical}`);
|
||||
es.onmessage = (ev) => {
|
||||
try {
|
||||
setData(JSON.parse(ev.data));
|
||||
setLastUpdate(new Date());
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
setTimeout(connect, 3000);
|
||||
};
|
||||
};
|
||||
connect();
|
||||
return () => es?.close();
|
||||
}, [vertical]);
|
||||
|
||||
return { data, error, lastUpdate };
|
||||
}
|
||||
|
||||
export function LiveBadge({ lastUpdate, intervalMs }) {
|
||||
const ms = lastUpdate ? Date.now() - lastUpdate : null;
|
||||
const ago = ms != null
|
||||
? ms < 3000 ? 'zojuist' : `${(ms / 1000).toFixed(1)}s geleden`
|
||||
: 'verbinden...';
|
||||
return (
|
||||
<span className="live-badge">
|
||||
<span className="live-dot" />
|
||||
Live · {ago}{intervalMs ? ` · ${intervalMs}ms` : ''}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function KpiCard({ label, value, sub, trend }) {
|
||||
return (
|
||||
<div className="glass kpi-card">
|
||||
<div className="kpi-label">{label}</div>
|
||||
<div className="kpi-value">{value ?? '—'}</div>
|
||||
{sub && (
|
||||
<div className={`kpi-sub ${trend === 'up' ? 'kpi-up' : trend === 'down' ? 'kpi-down' : ''}`}>
|
||||
{sub}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
|
||||
const NAV = [
|
||||
{ path: '/', label: 'Hub' },
|
||||
{ path: '/trading', label: 'Trading' },
|
||||
{ path: '/retail', label: 'Retail' },
|
||||
{ path: '/supply-chain', label: 'Supply Chain' },
|
||||
{ path: '/fraud', label: 'Fraud' },
|
||||
{ path: '/smart-city', label: 'Smart City' }
|
||||
];
|
||||
|
||||
export default function Shell({ children }) {
|
||||
const loc = useLocation();
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) document.documentElement.requestFullscreen();
|
||||
else document.exitFullscreen();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<header className="shell-header">
|
||||
<Link to="/" className="brand">
|
||||
<span className="brand-dot" />
|
||||
Mek-Tech Live Labs
|
||||
</Link>
|
||||
<nav style={{ display: 'flex', gap: '0.35rem', flexWrap: 'wrap' }}>
|
||||
{NAV.map(n => (
|
||||
<Link
|
||||
key={n.path}
|
||||
to={n.path}
|
||||
className="btn"
|
||||
style={{
|
||||
opacity: loc.pathname === n.path ? 1 : 0.65,
|
||||
borderColor: loc.pathname === n.path ? 'var(--accent)' : undefined
|
||||
}}
|
||||
>
|
||||
{n.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<button type="button" className="btn fullscreen-btn" onClick={toggleFullscreen}>
|
||||
Presentatie
|
||||
</button>
|
||||
</header>
|
||||
<main className="shell-main">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||||
Legend, PieChart, Pie, Cell, ComposedChart, Area, AreaChart
|
||||
} from 'recharts';
|
||||
import { useStream, KpiCard } from '../components/shared';
|
||||
import { GlobalOpsLayout } from '../components/GlobalOpsLayout';
|
||||
import { ChartGrid, ChartCard, TOOLTIP } from '../components/dashboard';
|
||||
|
||||
export default function Fraud() {
|
||||
const { data, lastUpdate } = useStream('fraud');
|
||||
|
||||
return (
|
||||
<GlobalOpsLayout
|
||||
badge="GLOBAL FRAUD & COMPLIANCE"
|
||||
title="Fraud & Compliance — Realtime Screening"
|
||||
subtitle={`${data?.context?.pitch} · ${data?.context?.deployment}`}
|
||||
lastUpdate={lastUpdate}
|
||||
refreshMs={data?.refreshMs || 1500}
|
||||
contextBlocks={data?.contextBlocks}
|
||||
insights={data?.insights}
|
||||
>
|
||||
<div className="kpi-grid kpi-grid-dense">
|
||||
<KpiCard label="Sanctions entiteiten" value={data?.sanctionsCount ?? 0} sub="EU Consolidated" />
|
||||
<KpiCard label="Screening/min" value={data?.screeningRate ?? '—'} sub="Throughput" />
|
||||
<KpiCard label="Latency p50" value={`${data?.avgLatencyMs ?? '—'} ms`} sub="End-to-end" />
|
||||
<KpiCard label="Block rate" value={`${data?.blockRate ?? 0}%`} sub="Deze batch" trend="down" />
|
||||
<KpiCard label="Hits batch" value={data?.hitsLastMinute ?? 0} sub="Sanctions match" />
|
||||
<KpiCard label="Kritiek risk" value={data?.criticalCount ?? 0} sub="Score > 85" trend="down" />
|
||||
<KpiCard label="Tx stream" value={data?.recent?.length ?? 0} sub="Live batch" />
|
||||
<KpiCard label="Refresh" value={`${data?.refreshMs || 1500}ms`} sub="SSE interval" />
|
||||
</div>
|
||||
|
||||
<ChartGrid cols={2}>
|
||||
<ChartCard title="Hits timeline — live stream">
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<AreaChart data={data?.hitsTimeline || []}>
|
||||
<XAxis dataKey="t" tick={{ fill: '#8ba3bc', fontSize: 9 }} />
|
||||
<YAxis tick={{ fill: '#8ba3bc', fontSize: 10 }} />
|
||||
<Tooltip {...TOOLTIP} />
|
||||
<Area type="monotone" dataKey="hits" stroke="#ef4444" fill="rgba(239,68,68,0.2)" strokeWidth={2} isAnimationActive={false} />
|
||||
<Line type="monotone" dataKey="screened" stroke="#00d4ff" strokeWidth={1} dot={false} isAnimationActive={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
<ChartCard title="Risk score verdeling">
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie data={data?.riskDistribution || []} dataKey="value" nameKey="name" cx="50%" cy="50%" innerRadius={50} outerRadius={85} label isAnimationActive={false}>
|
||||
{(data?.riskDistribution || []).map((e, i) => <Cell key={i} fill={e.fill} />)}
|
||||
</Pie>
|
||||
<Tooltip {...TOOLTIP} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</ChartGrid>
|
||||
|
||||
<ChartGrid cols={2}>
|
||||
<ChartCard title="Hits per land" subtitle="Geo-risk concentratie">
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<ComposedChart data={data?.countryRisk || []}>
|
||||
<XAxis dataKey="country" tick={{ fill: '#8ba3bc', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#8ba3bc', fontSize: 10 }} />
|
||||
<Tooltip {...TOOLTIP} />
|
||||
<Legend />
|
||||
<Bar dataKey="txs" fill="#00d4ff" name="Transacties" radius={[4, 4, 0, 0]} isAnimationActive={false} />
|
||||
<Line type="monotone" dataKey="hits" stroke="#ef4444" strokeWidth={2} name="Hits" isAnimationActive={false} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
<ChartCard title="Blocked vs cleared per type">
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={data?.typeBreakdown || []}>
|
||||
<XAxis dataKey="type" tick={{ fill: '#8ba3bc', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#8ba3bc', fontSize: 10 }} />
|
||||
<Tooltip {...TOOLTIP} />
|
||||
<Legend />
|
||||
<Bar dataKey="cleared" stackId="a" fill="#22c55e" name="Cleared" isAnimationActive={false} />
|
||||
<Bar dataKey="blocked" stackId="a" fill="#ef4444" name="Blocked" isAnimationActive={false} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</ChartGrid>
|
||||
|
||||
<ChartCard title="Live transactiestroom — realtime SSE" subtitle="Synthetische demo-data tegen echte sanctions-lijst">
|
||||
<div style={{ overflowX: 'auto', maxHeight: 320 }}>
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th><th>Naam</th><th>Type</th><th>Bedrag</th><th>Land</th><th>Risk</th><th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data?.recent || []).map(tx => (
|
||||
<tr key={tx.id} className={tx.hit ? 'tx-hit' : ''}>
|
||||
<td style={{ fontFamily: 'monospace', fontSize: '0.65rem' }}>{tx.id.slice(-10)}</td>
|
||||
<td>{tx.name}</td>
|
||||
<td>{tx.type}</td>
|
||||
<td>{tx.amount.toLocaleString('nl-NL')} {tx.currency}</td>
|
||||
<td>{tx.country}</td>
|
||||
<td><span style={{ color: tx.risk > 70 ? 'var(--red)' : tx.risk > 40 ? 'var(--orange)' : 'var(--green)' }}>{tx.risk}</span></td>
|
||||
<td>{tx.hit ? '🚨 HIT' : '✓ OK'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ChartCard>
|
||||
</GlobalOpsLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { LiveBadge } from '../components/shared';
|
||||
|
||||
const VERTICALS = [
|
||||
{ path: '/trading', icon: '📈', title: 'Trading & FX', desc: 'ECB live koersen, volatiliteit & treasury alerts', color: '#00d4ff', refresh: '2000ms' },
|
||||
{ path: '/retail', icon: '🛒', title: 'Retail Intelligence', desc: 'Regio-indices, footfall & omnichannel live', color: '#22c55e', refresh: '2000ms' },
|
||||
{ path: '/supply-chain', icon: '🚢', title: 'Supply Chain', desc: 'Haven throughput, import flows & vertraging', color: '#f59e0b', refresh: '2500ms' },
|
||||
{ path: '/fraud', icon: '🛡️', title: 'Fraud Detection', desc: 'Sanctions screening & live tx stream', color: '#ef4444', refresh: '1500ms' },
|
||||
{ path: '/smart-city', icon: '🏙️', title: 'Smart City', desc: 'Camera\'s, lantaarnpalen & luchtkwaliteit Randstad', color: '#7c3aed', refresh: '1500ms' }
|
||||
];
|
||||
|
||||
const CONTEXT = [
|
||||
{ title: 'Global Operations Center', body: 'Vijf sector use cases in één uniform command center — live data, dense KPI\'s, context blocks en SSE refresh.' },
|
||||
{ title: 'Open data first', body: 'ECB, CBS, haven data, EU sanctions en smart city feeds — geen vendor lock-in, snel deploybaar.' },
|
||||
{ title: 'Demo → productie', body: 'Elke vertical is een startpunt voor een maatwerk pilot bij uw organisatie — Mek-Tech levert integratie + UI.' },
|
||||
{ title: 'Live by design', body: 'SSE streams tussen 1,5–2,5 seconden. Charts zonder zware page reload — ops center feel.' },
|
||||
{ title: 'Showcase vs CRM', body: 'Deze omgeving (:3010) is voor klantdemo\'s. Intern CRM blijft op :3000 voor consulting workflow.' },
|
||||
{ title: 'Start hier', body: 'Kies een vertical — elke pagina heeft dezelfde Global Ops layout, insights strip en realtime badges.' }
|
||||
];
|
||||
|
||||
export default function Hub() {
|
||||
const [status, setStatus] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const load = () => fetch('/api/status').then(r => r.json()).then(setStatus).catch(() => {});
|
||||
load();
|
||||
const iv = setInterval(load, 15000);
|
||||
return () => clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
const activeCount = status ? Object.values(status.verticals || {}).filter(v => v.ok).length : 0;
|
||||
|
||||
return (
|
||||
<div className="ops-global">
|
||||
<div className="global-header">
|
||||
<div>
|
||||
<span className="global-badge">MEK-TECH LIVE LABS</span>
|
||||
<h1 className="global-title">Open Data Global Operations Hub</h1>
|
||||
<p className="global-sub">Vijf sector dashboards in uniform ops center design — realtime SSE, interactieve charts, live camera feeds.</p>
|
||||
</div>
|
||||
<div className="global-header-right">
|
||||
<LiveBadge lastUpdate={status ? new Date(status.ts) : null} />
|
||||
<span className="refresh-badge">⚡ {activeCount}/5 feeds actief</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="context-grid">
|
||||
{CONTEXT.map((block, i) => (
|
||||
<div key={i} className="context-card glass">
|
||||
<h3>{block.title}</h3>
|
||||
<p>{block.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hub-grid">
|
||||
{VERTICALS.map((v, i) => {
|
||||
const st = status?.verticals?.[v.path.slice(1)];
|
||||
return (
|
||||
<motion.div
|
||||
key={v.path}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.08 }}
|
||||
>
|
||||
<Link to={v.path} className="glass hub-card" style={{ borderTopColor: v.color, borderTopWidth: 2, borderTopStyle: 'solid' }}>
|
||||
<div className="hub-icon">{v.icon}</div>
|
||||
<div className="hub-title">{v.title}</div>
|
||||
<div className="hub-desc">{v.desc}</div>
|
||||
<div className="hub-meta">
|
||||
<span style={{ color: st?.ok ? 'var(--green)' : 'var(--muted)' }}>
|
||||
{st?.ok ? '● Feed actief' : '○ Status laden...'}
|
||||
</span>
|
||||
<span className="refresh-badge" style={{ fontSize: '0.58rem' }}>⚡ {v.refresh}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
BarChart, Bar, LineChart, Line, AreaChart, Area, XAxis, YAxis, Tooltip,
|
||||
ResponsiveContainer, Legend, PieChart, Pie, Cell, ComposedChart, ScatterChart, Scatter, ZAxis
|
||||
} from 'recharts';
|
||||
import { useStream, KpiCard } from '../components/shared';
|
||||
import { GlobalOpsLayout } from '../components/GlobalOpsLayout';
|
||||
import { ChartGrid, ChartCard, TOOLTIP } from '../components/dashboard';
|
||||
|
||||
export default function Retail() {
|
||||
const { data, lastUpdate } = useStream('retail');
|
||||
|
||||
return (
|
||||
<GlobalOpsLayout
|
||||
badge="GLOBAL RETAIL OPERATIONS"
|
||||
title="Retail Intelligence — Regio & Omnichannel"
|
||||
subtitle={`${data?.context?.pitch} · ${data?.context?.deployment}`}
|
||||
lastUpdate={lastUpdate}
|
||||
refreshMs={data?.refreshMs || 2000}
|
||||
contextBlocks={data?.contextBlocks}
|
||||
insights={data?.insights}
|
||||
>
|
||||
<div className="kpi-grid kpi-grid-dense">
|
||||
<KpiCard label="Landindex" value={data?.nationalIndex} sub={`YoY +${data?.nationalYoy}%`} trend="up" />
|
||||
<KpiCard label="Winkels NL" value={(data?.totalStores || 0).toLocaleString('nl-NL')} sub="Vestigingen" />
|
||||
<KpiCard label="Gem. online share" value={`${data?.avgOnlineShare ?? '—'}%`} sub="Omnichannel" />
|
||||
<KpiCard label="Top regio" value={data?.regions?.[0]?.region} sub={`Index ${data?.regions?.[0]?.index}`} />
|
||||
<KpiCard label="Footfall gem." value={data?.avgFootfall ?? '—'} sub="Index NL" />
|
||||
<KpiCard label="Regio's at risk" value={data?.storesAtRisk ?? 0} sub="Negatief YoY" trend="down" />
|
||||
<KpiCard label="Categorieën" value={data?.categories?.length ?? 0} sub="Mix tracked" />
|
||||
<KpiCard label="Refresh" value={`${data?.refreshMs || 2000}ms`} sub="SSE interval" />
|
||||
</div>
|
||||
|
||||
<ChartGrid cols={2}>
|
||||
<ChartCard title="Omzetindex per regio">
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={data?.regions || []} layout="vertical" margin={{ left: 85 }}>
|
||||
<XAxis type="number" tick={{ fill: '#8ba3bc', fontSize: 10 }} />
|
||||
<YAxis type="category" dataKey="region" tick={{ fill: '#8ba3bc', fontSize: 9 }} width={80} />
|
||||
<Tooltip {...TOOLTIP} />
|
||||
<Bar dataKey="index" fill="#22c55e" radius={[0, 4, 4, 0]} isAnimationActive={false} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
<ChartCard title="Categorie mix & groei %">
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<ComposedChart data={data?.categories || []}>
|
||||
<XAxis dataKey="name" tick={{ fill: '#8ba3bc', fontSize: 9 }} />
|
||||
<YAxis yAxisId="l" tick={{ fill: '#8ba3bc', fontSize: 10 }} />
|
||||
<YAxis yAxisId="r" orientation="right" tick={{ fill: '#8ba3bc', fontSize: 10 }} />
|
||||
<Tooltip {...TOOLTIP} />
|
||||
<Legend />
|
||||
<Bar yAxisId="l" dataKey="share" fill="#00d4ff" name="Marktaandeel %" radius={[4, 4, 0, 0]} isAnimationActive={false} />
|
||||
<Line yAxisId="r" type="monotone" dataKey="growth" stroke="#f59e0b" strokeWidth={2} name="Groei %" isAnimationActive={false} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</ChartGrid>
|
||||
|
||||
<ChartGrid cols={2}>
|
||||
<ChartCard title="12 maanden — index, online & footfall">
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<AreaChart data={data?.history || []}>
|
||||
<XAxis dataKey="month" tick={{ fill: '#8ba3bc', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#8ba3bc', fontSize: 10 }} />
|
||||
<Tooltip {...TOOLTIP} />
|
||||
<Legend />
|
||||
<Area type="monotone" dataKey="index" stroke="#22c55e" fill="rgba(34,197,94,0.15)" isAnimationActive={false} />
|
||||
<Area type="monotone" dataKey="online" stroke="#00d4ff" fill="rgba(0,212,255,0.1)" isAnimationActive={false} />
|
||||
<Line type="monotone" dataKey="footfall" stroke="#f59e0b" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
<ChartCard title="Stores vs index (bubble = YoY)" subtitle="Strategische portfolio-analyse">
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<ScatterChart>
|
||||
<XAxis type="number" dataKey="stores" name="Winkels" tick={{ fill: '#8ba3bc', fontSize: 10 }} />
|
||||
<YAxis type="number" dataKey="index" name="Index" tick={{ fill: '#8ba3bc', fontSize: 10 }} />
|
||||
<ZAxis type="number" dataKey="yoy" range={[80, 400]} />
|
||||
<Tooltip {...TOOLTIP} cursor={{ strokeDasharray: '3 3' }} />
|
||||
<Scatter data={data?.scatter || []} fill="#7c3aed" isAnimationActive={false} />
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</ChartGrid>
|
||||
|
||||
<ChartCard title="Footfall index per regio">
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={data?.regions || []}>
|
||||
<XAxis dataKey="region" tick={{ fill: '#8ba3bc', fontSize: 8 }} angle={-25} textAnchor="end" height={60} />
|
||||
<YAxis tick={{ fill: '#8ba3bc', fontSize: 10 }} />
|
||||
<Tooltip {...TOOLTIP} />
|
||||
<Bar dataKey="footfall" fill="#f59e0b" name="Footfall index" radius={[4, 4, 0, 0]} isAnimationActive={false} />
|
||||
<Bar dataKey="onlineShare" fill="#00d4ff" name="Online %" radius={[4, 4, 0, 0]} isAnimationActive={false} />
|
||||
<Legend />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</GlobalOpsLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
LineChart, Line, AreaChart, Area, BarChart, Bar, XAxis, YAxis, Tooltip,
|
||||
ResponsiveContainer, Legend, RadarChart, Radar, PolarGrid, PolarAngleAxis,
|
||||
PieChart, Pie, Cell, ComposedChart
|
||||
} from 'recharts';
|
||||
import { useStream, KpiCard } from '../components/shared';
|
||||
import { GlobalOpsLayout } from '../components/GlobalOpsLayout';
|
||||
import { ChartGrid, ChartCard, TOOLTIP } from '../components/dashboard';
|
||||
import SmartCityCommandMap, { CityPulse3D } from '../components/SmartCityMap';
|
||||
import { LiveCameraFeed, CameraWall } from '../components/LiveCameraFeed';
|
||||
|
||||
const LAYER_DEFAULT = { cameras: true, lamps: true, air: true };
|
||||
|
||||
export default function SmartCity() {
|
||||
const { data, lastUpdate } = useStream('smart-city');
|
||||
const [layers, setLayers] = useState(LAYER_DEFAULT);
|
||||
const [selectedCamera, setSelectedCamera] = useState(null);
|
||||
const [selectedLamp, setSelectedLamp] = useState(null);
|
||||
|
||||
const activeCamera = selectedCamera || data?.cameras?.[0];
|
||||
|
||||
const toggleLayer = (key) => setLayers(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
|
||||
const eventFeed = useMemo(() => data?.recentEvents || [], [data?.recentEvents]);
|
||||
|
||||
return (
|
||||
<GlobalOpsLayout
|
||||
badge="GLOBAL OPERATIONS CENTER"
|
||||
title="Smart City — Randstad Command"
|
||||
subtitle={`${data?.context?.pitch} · ${data?.context?.deployment}`}
|
||||
lastUpdate={lastUpdate}
|
||||
refreshMs={data?.refreshMs || 1500}
|
||||
contextBlocks={data?.contextBlocks}
|
||||
insights={data?.insights}
|
||||
>
|
||||
<div className="kpi-grid kpi-grid-dense">
|
||||
<KpiCard label="Camera's live" value={`${data?.cameraStats?.online ?? 0}/${data?.cameraStats?.total ?? 0}`} sub={`Latency ${data?.cameraStats?.avgLatency ?? '—'}ms`} />
|
||||
<KpiCard label="Lantaarnpalen" value={data?.lamppostStats?.total ?? 0} sub={`${data?.lamppostStats?.activeMotion ?? 0} motion actief`} />
|
||||
<KpiCard label="Energie vandaag" value={`${data?.lamppostStats?.energyTodayKwh ?? '—'} kWh`} sub={`Gem. ${data?.lamppostStats?.avgBrightness ?? '—'}% dim`} />
|
||||
<KpiCard label="Gezondheidsindex" value={data?.healthIndex ?? '—'} sub="Composite AQI" trend={data?.healthIndex > 60 ? 'up' : 'down'} />
|
||||
<KpiCard label="NO₂ live" value={`${data?.avgNo2 ?? '—'} µg/m³`} sub="WHO: 25" />
|
||||
<KpiCard label="PM2.5" value={`${data?.avgPm25 ?? '—'} µg/m³`} sub="WHO: 15" />
|
||||
<KpiCard label="Assets totaal" value={data?.sensorsOnline ?? 0} sub="Sensoren + cam + LP" />
|
||||
<KpiCard label="Events (live)" value={eventFeed.length} sub="Laatste refresh" />
|
||||
</div>
|
||||
|
||||
{/* Layer toggles */}
|
||||
<div className="layer-bar">
|
||||
<span className="layer-label">Kaartlagen:</span>
|
||||
{[['cameras', '📹 Camera\'s'], ['lamps', '💡 Lantaarnpalen'], ['air', '🌫️ Luchtkwaliteit']].map(([k, label]) => (
|
||||
<button key={k} type="button" className={`layer-chip ${layers[k] ? 'on' : ''}`} onClick={() => toggleLayer(k)}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Main command layout: map + live video */}
|
||||
<div className="command-layout">
|
||||
<div className="command-map-col glass">
|
||||
<div className="chart-title">GIS Command Map — Randstad</div>
|
||||
<div className="chart-subtitle">Klik camera voor live stream · groen/geel/rood = luchtkwaliteit</div>
|
||||
<SmartCityCommandMap
|
||||
stations={data?.stations}
|
||||
cameras={data?.cameras}
|
||||
lampposts={data?.lampposts}
|
||||
layers={layers}
|
||||
selectedCamera={activeCamera}
|
||||
selectedLamp={selectedLamp}
|
||||
onSelectCamera={setSelectedCamera}
|
||||
onSelectLamp={setSelectedLamp}
|
||||
/>
|
||||
</div>
|
||||
<div className="command-video-col">
|
||||
<div className="glass" style={{ padding: '0.75rem', marginBottom: '0.75rem' }}>
|
||||
<div className="chart-title">Primary feed — {activeCamera?.name || 'Selecteer camera'}</div>
|
||||
<LiveCameraFeed camera={activeCamera} isPrimary />
|
||||
</div>
|
||||
{selectedLamp && (
|
||||
<div className="glass lamp-detail">
|
||||
<div className="chart-title">Smart lantaarnpaal — {selectedLamp.street}</div>
|
||||
<div className="lamp-stats">
|
||||
<div><span>Helderheid</span><strong>{selectedLamp.brightness}%</strong></div>
|
||||
<div><span>Energie</span><strong>{selectedLamp.energyWh} Wh</strong></div>
|
||||
<div><span>Lux</span><strong>{selectedLamp.lux}</strong></div>
|
||||
<div><span>Temp</span><strong>{selectedLamp.tempC}°C</strong></div>
|
||||
<div><span>CO₂ bespaard</span><strong>{selectedLamp.co2saved} kg</strong></div>
|
||||
<div><span>Motion</span><strong className={selectedLamp.motion ? 'kpi-up' : ''}>{selectedLamp.motion ? 'JA' : 'NEE'}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="event-feed glass">
|
||||
<div className="chart-title">Live event feed</div>
|
||||
{(eventFeed.length ? eventFeed : [{ type: 'idle', camera: 'Monitoring…', severity: 'info', ts: new Date().toISOString() }]).map((ev, i) => (
|
||||
<div key={i} className={`event-row event-${ev.severity}`}>
|
||||
<span className="event-time">{ev.ts?.slice(11, 19)}</span>
|
||||
<span className="event-type">{ev.type}</span>
|
||||
<span className="event-loc">{ev.camera}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Camera wall — all streams */}
|
||||
<ChartCard title="Camera wall — alle live streams" subtitle="Klik thumbnail voor primary feed · live NL webcam gateway (productie = gemeente RTSP/ONVIF)">
|
||||
<CameraWall cameras={data?.cameras} selectedId={activeCamera?.id} onSelect={setSelectedCamera} />
|
||||
</ChartCard>
|
||||
|
||||
<ChartGrid cols={2}>
|
||||
<ChartCard title="Live NO₂ — afgelopen 60 seconden" subtitle="Sub-second telemetry simulatie">
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<AreaChart data={data?.liveSeconds || []}>
|
||||
<XAxis dataKey="sec" tick={{ fill: '#8ba3bc', fontSize: 9 }} reversed />
|
||||
<YAxis tick={{ fill: '#8ba3bc', fontSize: 10 }} />
|
||||
<Tooltip {...TOOLTIP} />
|
||||
<Area type="monotone" dataKey="no2" stroke="#00d4ff" fill="rgba(0,212,255,0.2)" strokeWidth={2} isAnimationActive={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
<ChartCard title="3D City Pulse — NO₂ volume">
|
||||
<CityPulse3D cities={data?.cityRankings} />
|
||||
</ChartCard>
|
||||
</ChartGrid>
|
||||
|
||||
<ChartGrid cols={2}>
|
||||
<ChartCard title="24u profiel — lucht, verkeer, geluid">
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<ComposedChart data={data?.hourlyTrend || []}>
|
||||
<XAxis dataKey="hour" tick={{ fill: '#8ba3bc', fontSize: 9 }} interval={2} />
|
||||
<YAxis yAxisId="l" tick={{ fill: '#8ba3bc', fontSize: 10 }} />
|
||||
<YAxis yAxisId="r" orientation="right" tick={{ fill: '#8ba3bc', fontSize: 10 }} />
|
||||
<Tooltip {...TOOLTIP} />
|
||||
<Legend />
|
||||
<Area yAxisId="l" type="monotone" dataKey="no2" fill="rgba(0,212,255,0.12)" stroke="#00d4ff" name="NO₂" isAnimationActive={false} />
|
||||
<Line yAxisId="r" type="monotone" dataKey="traffic" stroke="#f59e0b" strokeWidth={2} dot={false} name="Verkeer" isAnimationActive={false} />
|
||||
<Line yAxisId="r" type="monotone" dataKey="noise" stroke="#7c3aed" strokeWidth={1} dot={false} name="Geluid dB" isAnimationActive={false} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
<ChartCard title="Pollutant radar">
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<RadarChart data={data?.pollutantRadar || []}>
|
||||
<PolarGrid stroke="rgba(255,255,255,0.08)" />
|
||||
<PolarAngleAxis dataKey="pollutant" tick={{ fill: '#8ba3bc', fontSize: 10 }} />
|
||||
<Radar dataKey="value" stroke="#7c3aed" fill="#7c3aed" fillOpacity={0.35} isAnimationActive={false} />
|
||||
<Tooltip {...TOOLTIP} />
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</ChartGrid>
|
||||
|
||||
<ChartGrid cols={2}>
|
||||
<ChartCard title="Lantaarnpaal energie per straat">
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={(data?.lampposts || []).slice(0, 10)}>
|
||||
<XAxis dataKey="street" tick={{ fill: '#8ba3bc', fontSize: 8 }} angle={-20} textAnchor="end" height={50} />
|
||||
<YAxis tick={{ fill: '#8ba3bc', fontSize: 10 }} />
|
||||
<Tooltip {...TOOLTIP} />
|
||||
<Bar dataKey="energyWh" fill="#f59e0b" name="Wh" radius={[4, 4, 0, 0]} isAnimationActive={false} />
|
||||
<Bar dataKey="brightness" fill="#00d4ff" name="Helderheid %" radius={[4, 4, 0, 0]} isAnimationActive={false} />
|
||||
<Legend />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
<ChartCard title="Camera types & status">
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie data={[
|
||||
{ name: 'Verkeer', value: data?.cameraStats?.traffic || 0, fill: '#f59e0b' },
|
||||
{ name: 'Security', value: data?.cameraStats?.security || 0, fill: '#00d4ff' },
|
||||
{ name: 'Offline', value: (data?.cameraStats?.total || 0) - (data?.cameraStats?.online || 0), fill: '#64748b' }
|
||||
]} dataKey="value" cx="50%" cy="50%" innerRadius={45} outerRadius={80} label />
|
||||
<Tooltip {...TOOLTIP} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</ChartGrid>
|
||||
</GlobalOpsLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
BarChart, Bar, LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||||
Legend, PieChart, Pie, Cell, ComposedChart, ScatterChart, Scatter
|
||||
} from 'recharts';
|
||||
import { useStream, KpiCard } from '../components/shared';
|
||||
import { GlobalOpsLayout } from '../components/GlobalOpsLayout';
|
||||
import { ChartGrid, ChartCard, TOOLTIP } from '../components/dashboard';
|
||||
|
||||
export default function SupplyChain() {
|
||||
const { data, lastUpdate } = useStream('supply-chain');
|
||||
|
||||
return (
|
||||
<GlobalOpsLayout
|
||||
badge="GLOBAL SUPPLY CHAIN TOWER"
|
||||
title="Supply Chain Control Tower"
|
||||
subtitle={`${data?.context?.pitch} · ${data?.context?.deployment}`}
|
||||
lastUpdate={lastUpdate}
|
||||
refreshMs={data?.refreshMs || 2500}
|
||||
contextBlocks={data?.contextBlocks}
|
||||
insights={data?.insights}
|
||||
>
|
||||
<div className="kpi-grid kpi-grid-dense">
|
||||
<KpiCard label="Throughput NL" value={`${data?.totalThroughput ?? '—'}M`} sub={`YoY +${data?.throughputYoy}%`} trend="up" />
|
||||
<KpiCard label="Risicoscore" value={data?.riskScore} sub="/100 concentratie" />
|
||||
<KpiCard label="Gem. vertraging" value={`${data?.avgDelay ?? '—'} d`} sub="Per route" />
|
||||
<KpiCard label="Top-3 import share" value={`${data?.concentrationTop3}%`} sub="Concentratie" />
|
||||
<KpiCard label="Actieve routes" value={data?.activeRoutes ?? 0} sub="Import flows" />
|
||||
<KpiCard label="Vertraagd" value={data?.delayedRoutes ?? 0} sub="> 2 dagen" trend="down" />
|
||||
<KpiCard label="Havens" value={data?.portLocations?.length ?? 0} sub="In scope" />
|
||||
<KpiCard label="Refresh" value={`${data?.refreshMs || 2500}ms`} sub="SSE interval" />
|
||||
</div>
|
||||
|
||||
<ChartGrid cols={2}>
|
||||
<ChartCard title="Import flows (M ton / TEU eq.)">
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={data?.flows || []}>
|
||||
<XAxis dataKey="from" tick={{ fill: '#8ba3bc', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#8ba3bc', fontSize: 10 }} />
|
||||
<Tooltip {...TOOLTIP} />
|
||||
<Bar dataKey="value" fill="#f59e0b" radius={[4, 4, 0, 0]} isAnimationActive={false} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
<ChartCard title="Transport mode split">
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<PieChart>
|
||||
<Pie data={data?.modeSplit || []} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={95} label isAnimationActive={false}>
|
||||
{(data?.modeSplit || []).map((e, i) => <Cell key={i} fill={e.fill} />)}
|
||||
</Pie>
|
||||
<Tooltip {...TOOLTIP} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</ChartGrid>
|
||||
|
||||
<ChartCard title="Maandelijkse throughput — Rotterdam · Amsterdam · Antwerpen">
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<LineChart data={data?.throughput || []}>
|
||||
<XAxis dataKey="month" tick={{ fill: '#8ba3bc', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#8ba3bc', fontSize: 10 }} />
|
||||
<Tooltip {...TOOLTIP} />
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey="rotterdam" stroke="#f59e0b" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<Line type="monotone" dataKey="amsterdam" stroke="#00d4ff" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<Line type="monotone" dataKey="antwerp" stroke="#22c55e" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
|
||||
<ChartGrid cols={2}>
|
||||
<ChartCard title="Vertraging per route (dagen)">
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={data?.delayByRoute || []} layout="vertical" margin={{ left: 100 }}>
|
||||
<XAxis type="number" tick={{ fill: '#8ba3bc', fontSize: 10 }} />
|
||||
<YAxis type="category" dataKey="route" tick={{ fill: '#8ba3bc', fontSize: 8 }} width={95} />
|
||||
<Tooltip {...TOOLTIP} />
|
||||
<Bar dataKey="delay" fill="#ef4444" radius={[0, 4, 4, 0]} isAnimationActive={false} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
<ChartCard title="Volume vs vertraging" subtitle="Prioriteer alternatieve routes">
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<ScatterChart>
|
||||
<XAxis type="number" dataKey="volume" name="Volume" tick={{ fill: '#8ba3bc', fontSize: 10 }} />
|
||||
<YAxis type="number" dataKey="delay" name="Delay" tick={{ fill: '#8ba3bc', fontSize: 10 }} />
|
||||
<Tooltip {...TOOLTIP} />
|
||||
<Scatter data={data?.delayByRoute || []} fill="#f59e0b" isAnimationActive={false} />
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</ChartGrid>
|
||||
</GlobalOpsLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
LineChart, Line, AreaChart, Area, BarChart, Bar, XAxis, YAxis, Tooltip,
|
||||
ResponsiveContainer, Legend, ComposedChart
|
||||
} from 'recharts';
|
||||
import { useStream, KpiCard } from '../components/shared';
|
||||
import { GlobalOpsLayout } from '../components/GlobalOpsLayout';
|
||||
import { ChartGrid, ChartCard, TOOLTIP } from '../components/dashboard';
|
||||
|
||||
export default function Trading() {
|
||||
const { data, lastUpdate } = useStream('trading');
|
||||
const main = data?.pairs?.[0];
|
||||
|
||||
return (
|
||||
<GlobalOpsLayout
|
||||
badge="GLOBAL FX OPERATIONS"
|
||||
title="Trading & FX Risk Dashboard"
|
||||
subtitle={`${data?.context?.pitch} · ${data?.context?.deployment}`}
|
||||
lastUpdate={lastUpdate}
|
||||
refreshMs={data?.refreshMs || 2000}
|
||||
contextBlocks={data?.contextBlocks}
|
||||
insights={data?.insights}
|
||||
>
|
||||
{data?.ticker && (
|
||||
<div className="glass ticker">
|
||||
{data.ticker.map(t => (
|
||||
<div key={t.pair} className="ticker-item">
|
||||
<span className="ticker-pair">{t.pair}</span>
|
||||
<span className="ticker-rate">{t.rate?.toFixed(4)}</span>
|
||||
<span className={t.change >= 0 ? 'kpi-up' : 'kpi-down'} style={{ marginLeft: '0.4rem', fontSize: '0.75rem' }}>
|
||||
{t.change >= 0 ? '+' : ''}{t.change}%
|
||||
</span>
|
||||
<span style={{ color: 'var(--muted)', fontSize: '0.65rem', marginLeft: '0.35rem' }}>7d: {t.weekChange}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="kpi-grid kpi-grid-dense">
|
||||
<KpiCard label="EUR/USD" value={main?.current?.toFixed(4)} sub={main?.fallback ? 'Fallback' : 'ECB live'} />
|
||||
<KpiCard label="30d volatiliteit" value={`${main?.volatility ?? '—'}%`} sub="Realized vol" />
|
||||
<KpiCard label="30d range (bps)" value={data?.spreadEurUsd ?? '—'} sub="High − low" />
|
||||
<KpiCard label="Pairs actief" value={data?.pairs?.length ?? 0} sub="Streaming" />
|
||||
<KpiCard label="Alerts" value={data?.alertCount ?? 0} sub="Drempels actief" />
|
||||
<KpiCard label="Sessie volume" value={data?.sessionVolume ?? '—'} sub="Relatief index" />
|
||||
<KpiCard label="Correlaties" value={data?.correlation?.length ?? 0} sub="Cross-pair" />
|
||||
<KpiCard label="Refresh" value={`${data?.refreshMs || 2000}ms`} sub="SSE interval" />
|
||||
</div>
|
||||
|
||||
<ChartGrid cols={2}>
|
||||
{(data?.pairs || []).slice(0, 2).map(p => (
|
||||
<ChartCard key={p.pair} title={`${p.pair} — 90 dagen area`}>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<AreaChart data={p.history}>
|
||||
<defs>
|
||||
<linearGradient id={`g${p.pair.replace(/\W/g, '')}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#00d4ff" stopOpacity={0.4} />
|
||||
<stop offset="100%" stopColor="#00d4ff" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="date" tick={{ fill: '#8ba3bc', fontSize: 9 }} tickFormatter={v => String(v).slice(-5)} />
|
||||
<YAxis domain={['auto', 'auto']} tick={{ fill: '#8ba3bc', fontSize: 10 }} width={50} />
|
||||
<Tooltip {...TOOLTIP} />
|
||||
<Area type="monotone" dataKey="rate" stroke="#00d4ff" fill={`url(#g${p.pair.replace(/\W/g, '')})`} strokeWidth={2} isAnimationActive={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
))}
|
||||
</ChartGrid>
|
||||
|
||||
<ChartGrid cols={2}>
|
||||
<ChartCard title="FX correlatie matrix" subtitle="Portfolio hedge planning">
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={data?.correlation || []}>
|
||||
<XAxis dataKey="pair" tick={{ fill: '#8ba3bc', fontSize: 9 }} />
|
||||
<YAxis domain={[-1, 1]} tick={{ fill: '#8ba3bc', fontSize: 10 }} />
|
||||
<Tooltip {...TOOLTIP} />
|
||||
<Bar dataKey="corr" fill="#7c3aed" radius={[4, 4, 0, 0]} isAnimationActive={false} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
<ChartCard title="Volume profiel (sessie)" subtitle="Liquiditeit per uur">
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<ComposedChart data={data?.volumeProfile || []}>
|
||||
<XAxis dataKey="time" tick={{ fill: '#8ba3bc', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#8ba3bc', fontSize: 10 }} />
|
||||
<Tooltip {...TOOLTIP} />
|
||||
<Bar dataKey="volume" fill="#f59e0b" radius={[4, 4, 0, 0]} isAnimationActive={false} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</ChartGrid>
|
||||
|
||||
{(data?.pairs || []).slice(2).map(p => (
|
||||
<ChartCard key={p.pair} title={`${p.pair} — line + band`}>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={p.history}>
|
||||
<XAxis dataKey="date" tick={{ fill: '#8ba3bc', fontSize: 9 }} tickFormatter={v => String(v).slice(-5)} />
|
||||
<YAxis domain={['auto', 'auto']} tick={{ fill: '#8ba3bc', fontSize: 10 }} width={50} />
|
||||
<Tooltip {...TOOLTIP} />
|
||||
<Line type="monotone" dataKey="rate" stroke="#22c55e" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
))}
|
||||
</GlobalOpsLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
:root {
|
||||
--bg: #060b14;
|
||||
--bg-card: rgba(14, 22, 38, 0.72);
|
||||
--border: rgba(0, 212, 255, 0.15);
|
||||
--accent: #00d4ff;
|
||||
--accent2: #7c3aed;
|
||||
--green: #22c55e;
|
||||
--red: #ef4444;
|
||||
--orange: #f59e0b;
|
||||
--text: #e8f0fe;
|
||||
--muted: #8ba3bc;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 20% -10%, rgba(0, 212, 255, 0.12), transparent),
|
||||
radial-gradient(ellipse 60% 40% at 80% 100%, rgba(124, 58, 237, 0.1), transparent);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
#root { position: relative; z-index: 1; }
|
||||
|
||||
.shell { min-height: 100vh; display: flex; flex-direction: column; }
|
||||
|
||||
.shell-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
backdrop-filter: blur(12px);
|
||||
background: rgba(6, 11, 20, 0.85);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
font-weight: 800;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.brand-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 12px var(--accent);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.shell-main { flex: 1; padding: 1.5rem; max-width: 1600px; margin: 0 auto; width: 100%; }
|
||||
|
||||
.glass {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
padding: 1.1rem 1.25rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.kpi-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, var(--accent), transparent);
|
||||
}
|
||||
|
||||
.kpi-label { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin-bottom: 0.35rem; }
|
||||
.kpi-value { font-size: 1.75rem; font-weight: 800; line-height: 1.1; }
|
||||
.kpi-sub { font-size: 0.72rem; color: var(--muted); margin-top: 0.25rem; }
|
||||
.kpi-up { color: var(--green); }
|
||||
.kpi-down { color: var(--red); }
|
||||
|
||||
.page-title { font-size: 1.6rem; font-weight: 800; margin-bottom: 0.25rem; }
|
||||
.page-sub { color: var(--muted); font-size: 0.85rem; margin-bottom: 1.5rem; }
|
||||
|
||||
.live-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
color: var(--green);
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--green);
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.hub-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.hub-card {
|
||||
padding: 1.5rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.hub-card:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: rgba(0, 212, 255, 0.45);
|
||||
box-shadow: 0 8px 32px rgba(0, 212, 255, 0.12);
|
||||
}
|
||||
|
||||
.hub-icon { font-size: 2rem; margin-bottom: 0.75rem; }
|
||||
.hub-title { font-size: 1.1rem; font-weight: 700; margin-bottom: 0.35rem; }
|
||||
.hub-desc { font-size: 0.78rem; color: var(--muted); line-height: 1.45; }
|
||||
.hub-meta { margin-top: 0.75rem; display: flex; justify-content: space-between; align-items: center; font-size: 0.68rem; gap: 0.5rem; flex-wrap: wrap; }
|
||||
|
||||
.chart-wrap { padding: 1.25rem; margin-bottom: 1rem; }
|
||||
.chart-title { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); margin-bottom: 1rem; }
|
||||
|
||||
.ticker {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
overflow-x: auto;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.ticker-item { white-space: nowrap; font-size: 0.85rem; }
|
||||
.ticker-pair { color: var(--muted); margin-right: 0.4rem; }
|
||||
.ticker-rate { font-weight: 700; font-size: 1rem; }
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0.9rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(0, 212, 255, 0.08);
|
||||
color: var(--accent);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn:hover { background: rgba(0, 212, 255, 0.15); }
|
||||
|
||||
.tx-table { width: 100%; border-collapse: collapse; font-size: 0.78rem; }
|
||||
.tx-table th { text-align: left; padding: 0.5rem; color: var(--muted); font-size: 0.65rem; text-transform: uppercase; border-bottom: 1px solid var(--border); }
|
||||
.tx-table td { padding: 0.5rem; border-bottom: 1px solid rgba(255,255,255,0.04); }
|
||||
.tx-hit { background: rgba(239, 68, 68, 0.08); }
|
||||
|
||||
.map-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.map-station {
|
||||
padding: 0.65rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.station-good { border-color: rgba(34, 197, 94, 0.4); }
|
||||
.station-warn { border-color: rgba(245, 158, 11, 0.4); }
|
||||
.station-bad { border-color: rgba(239, 68, 68, 0.4); }
|
||||
|
||||
.fullscreen-btn { margin-left: auto; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.shell-main { padding: 1rem; }
|
||||
.kpi-value { font-size: 1.4rem; }
|
||||
.chart-grid.cols-2 { grid-template-columns: 1fr; }
|
||||
.map-3d-container { height: 300px; transform: none; }
|
||||
}
|
||||
|
||||
.hero-block { padding: 1.25rem 1.4rem; margin-bottom: 1rem; }
|
||||
.hero-top { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem; }
|
||||
.hero-badge { display: inline-block; font-size: 0.62rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--accent); background: rgba(0,212,255,0.1); border: 1px solid rgba(0,212,255,0.25); padding: 0.2rem 0.55rem; border-radius: 999px; margin-bottom: 0.4rem; }
|
||||
.source-tags { display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: flex-start; }
|
||||
.source-tag { font-size: 0.62rem; padding: 0.2rem 0.5rem; border-radius: 6px; background: rgba(124,58,237,0.15); color: #c4b5fd; border: 1px solid rgba(124,58,237,0.3); }
|
||||
.value-props { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.75rem; }
|
||||
.value-prop { display: flex; gap: 0.65rem; padding: 0.65rem; background: rgba(0,0,0,0.25); border-radius: 10px; border: 1px solid rgba(255,255,255,0.06); }
|
||||
.vp-icon { font-size: 1.4rem; flex-shrink: 0; }
|
||||
.vp-title { font-size: 0.78rem; font-weight: 700; margin-bottom: 0.15rem; }
|
||||
.vp-desc { font-size: 0.68rem; color: var(--muted); line-height: 1.4; }
|
||||
.insight-strip { display: flex; flex-direction: column; gap: 0.45rem; margin-bottom: 1rem; }
|
||||
.insight-item { padding: 0.55rem 0.75rem; border-radius: 8px; font-size: 0.74rem; border-left: 3px solid var(--accent); background: rgba(0,212,255,0.06); }
|
||||
.insight-warn { border-left-color: var(--orange); background: rgba(245,158,11,0.08); }
|
||||
.insight-ok { border-left-color: var(--green); background: rgba(34,197,94,0.08); }
|
||||
.insight-label { font-weight: 700; margin-right: 0.5rem; }
|
||||
.insight-text { color: var(--muted); }
|
||||
.chart-grid { display: grid; gap: 1rem; margin-bottom: 1rem; }
|
||||
.chart-grid.cols-2 { grid-template-columns: repeat(2, 1fr); }
|
||||
.chart-grid.cols-1 { grid-template-columns: 1fr; }
|
||||
.chart-subtitle { font-size: 0.65rem; color: var(--muted); margin: -0.5rem 0 0.75rem; }
|
||||
.chart-tall { min-height: 420px; }
|
||||
.map-3d-scene { position: relative; perspective: 1200px; padding: 0.5rem 0 1.5rem; }
|
||||
.map-3d-floor { position: absolute; bottom: 12px; left: 5%; right: 5%; height: 40px; background: linear-gradient(180deg, rgba(0,212,255,0.08), transparent); transform: rotateX(72deg) scaleY(0.5); border: 1px solid rgba(0,212,255,0.15); border-radius: 8px; pointer-events: none; }
|
||||
.map-3d-container { height: 420px; border-radius: 12px; overflow: hidden; transform: rotateX(12deg); transform-origin: center bottom; box-shadow: 0 24px 60px rgba(0,0,0,0.5), 0 0 40px rgba(0,212,255,0.08); border: 1px solid rgba(0,212,255,0.2); }
|
||||
.leaflet-dark, .leaflet-container { height: 100%; width: 100%; background: #0a1018; }
|
||||
.map-legend { display: flex; gap: 1rem; justify-content: center; margin-top: 0.75rem; font-size: 0.68rem; color: var(--muted); }
|
||||
.map-legend i { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 0.3rem; vertical-align: middle; }
|
||||
.city-pulse-3d { perspective: 800px; padding: 1rem 0.5rem 0.5rem; }
|
||||
.pulse-grid { display: flex; align-items: flex-end; justify-content: space-around; gap: 0.35rem; height: 200px; transform: rotateX(8deg); }
|
||||
.pulse-col { display: flex; flex-direction: column; align-items: center; flex: 1; animation: riseIn 0.6s ease-out both; }
|
||||
.pulse-bar { width: 100%; max-width: 36px; border-radius: 6px 6px 2px 2px; transition: height 0.5s ease; }
|
||||
.pulse-label { font-size: 0.58rem; color: var(--muted); margin-top: 0.35rem; }
|
||||
.pulse-val { font-size: 0.62rem; font-weight: 700; color: var(--accent); }
|
||||
@keyframes riseIn { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
/* ===== GLOBAL OPS CENTER (all verticals) ===== */
|
||||
.ops-global,
|
||||
.smart-city-global { position: relative; }
|
||||
.global-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem; padding-bottom: 1rem; border-bottom: 1px solid rgba(0,212,255,0.2); }
|
||||
.global-badge { font-size: 0.6rem; font-weight: 800; letter-spacing: 0.15em; color: var(--accent); display: block; margin-bottom: 0.35rem; }
|
||||
.global-title { font-size: 1.75rem; font-weight: 800; background: linear-gradient(90deg, #e8f0fe, #00d4ff); -webkit-background-clip: text; -webkit-text-fill-color: transparent; margin-bottom: 0.25rem; }
|
||||
.global-sub { font-size: 0.8rem; color: var(--muted); max-width: 720px; }
|
||||
.global-header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 0.35rem; }
|
||||
.refresh-badge { font-size: 0.65rem; color: var(--orange); font-weight: 700; font-family: monospace; }
|
||||
|
||||
.context-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 0.65rem; margin-bottom: 1rem; }
|
||||
.context-card { padding: 0.85rem 1rem; }
|
||||
.context-card h3 { font-size: 0.72rem; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.35rem; }
|
||||
.context-card p { font-size: 0.72rem; color: var(--muted); line-height: 1.5; }
|
||||
|
||||
.kpi-grid-dense { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
|
||||
|
||||
.layer-bar { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem; }
|
||||
.layer-label { font-size: 0.68rem; color: var(--muted); font-weight: 600; }
|
||||
.layer-chip { font-size: 0.68rem; padding: 0.35rem 0.75rem; border-radius: 999px; border: 1px solid var(--border); background: rgba(0,0,0,0.3); color: var(--muted); cursor: pointer; font-family: inherit; transition: all 0.15s; }
|
||||
.layer-chip.on { border-color: var(--accent); color: var(--accent); background: rgba(0,212,255,0.12); box-shadow: 0 0 12px rgba(0,212,255,0.15); }
|
||||
|
||||
.command-layout { display: grid; grid-template-columns: 1.2fr 0.8fr; gap: 1rem; margin-bottom: 1rem; min-height: 520px; }
|
||||
.command-map-col { padding: 0.75rem; overflow: hidden; }
|
||||
.command-video-col { display: flex; flex-direction: column; gap: 0; min-height: 0; }
|
||||
|
||||
.globe-map-scene { position: relative; height: 460px; }
|
||||
.globe-grid-overlay { position: absolute; inset: 0; pointer-events: none; background-image: linear-gradient(rgba(0,212,255,0.04) 1px, transparent 1px), linear-gradient(90deg, rgba(0,212,255,0.04) 1px, transparent 1px); background-size: 24px 24px; z-index: 2; border-radius: 10px; }
|
||||
.globe-map-frame { height: 100%; border-radius: 10px; overflow: hidden; border: 1px solid rgba(0,212,255,0.35); box-shadow: 0 0 30px rgba(0,212,255,0.1), inset 0 0 60px rgba(0,0,0,0.4); }
|
||||
.leaflet-globe { height: 100%; width: 100%; background: #050810; }
|
||||
.globe-map-hud { position: absolute; bottom: 8px; left: 12px; right: 12px; display: flex; gap: 1rem; font-size: 0.6rem; font-family: monospace; color: var(--accent); z-index: 1000; pointer-events: none; text-shadow: 0 0 8px rgba(0,212,255,0.5); }
|
||||
.hud-live { margin-left: auto; color: var(--green); }
|
||||
|
||||
.custom-marker { background: none !important; border: none !important; }
|
||||
.marker-cam { width: 32px; height: 32px; border-radius: 8px; background: rgba(0,212,255,0.2); border: 2px solid var(--accent); color: var(--accent); display: flex; align-items: center; justify-content: center; box-shadow: 0 0 12px rgba(0,212,255,0.4); transition: transform 0.15s; }
|
||||
.marker-cam.active { transform: scale(1.2); background: rgba(0,212,255,0.4); box-shadow: 0 0 20px rgba(0,212,255,0.7); }
|
||||
.marker-cam.offline { opacity: 0.4; border-color: #64748b; color: #64748b; }
|
||||
.marker-lamp { width: 28px; height: 28px; border-radius: 50%; background: rgba(245,158,11,0.2); border: 2px solid var(--orange); color: var(--orange); display: flex; align-items: center; justify-content: center; }
|
||||
.marker-lamp.motion { animation: lampPulse 1.5s infinite; box-shadow: 0 0 14px rgba(245,158,11,0.6); }
|
||||
.marker-lamp.active { transform: scale(1.15); }
|
||||
@keyframes lampPulse { 0%,100%{opacity:1} 50%{opacity:0.6} }
|
||||
|
||||
.popup-btn { margin-top: 0.35rem; font-size: 0.65rem; padding: 0.25rem 0.5rem; background: var(--accent); color: #000; border: none; border-radius: 4px; cursor: pointer; font-weight: 700; }
|
||||
|
||||
/* CCTV feeds */
|
||||
.cctv-feed { border-radius: 8px; overflow: hidden; background: #000; }
|
||||
.cctv-header { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.5rem; background: rgba(0,0,0,0.8); font-size: 0.62rem; flex-wrap: wrap; }
|
||||
.cctv-live { color: var(--red); font-weight: 800; display: flex; align-items: center; gap: 0.25rem; }
|
||||
.cctv-name { flex: 1; color: var(--text); font-weight: 600; }
|
||||
.cctv-meta { color: var(--muted); font-family: monospace; }
|
||||
.cctv-screen { position: relative; aspect-ratio: 16/9; background: #0a0a0a; overflow: hidden; }
|
||||
.cctv-compact .cctv-screen { aspect-ratio: 16/10; min-height: 80px; }
|
||||
.cctv-video { width: 100%; height: 100%; object-fit: cover; }
|
||||
.cctv-snapshot { width: 100%; height: 100%; object-fit: cover; display: block; background: #000; }
|
||||
.cctv-youtube { width: 100%; height: 100%; border: 0; display: block; background: #000; }
|
||||
.cctv-scanlines-light { opacity: 0.35; pointer-events: none; }
|
||||
.cctv-synthetic { width: 100%; height: 100%; background: linear-gradient(135deg, #1a1a2e 0%, #0f0f18 50%, #16213e 100%); position: relative; }
|
||||
.cctv-scanlines { position: absolute; inset: 0; background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.15) 2px, rgba(0,0,0,0.15) 4px); pointer-events: none; }
|
||||
.cctv-noise { position: absolute; inset: 0; background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); opacity: 0.12; animation: noiseShift 0.5s steps(2) infinite; }
|
||||
@keyframes noiseShift { to { transform: translate(2px, 1px); } }
|
||||
.cctv-overlay-text { position: absolute; bottom: 8px; left: 8px; font-size: 0.62rem; font-family: monospace; color: rgba(255,255,255,0.85); text-shadow: 0 1px 3px #000; line-height: 1.4; }
|
||||
.cctv-clock { color: var(--accent); font-weight: 700; margin-top: 0.15rem; }
|
||||
.cctv-tags { position: absolute; top: 6px; right: 6px; display: flex; gap: 0.25rem; }
|
||||
.cctv-tag { font-size: 0.55rem; padding: 0.15rem 0.35rem; background: rgba(0,212,255,0.25); border: 1px solid rgba(0,212,255,0.4); border-radius: 4px; color: var(--accent); text-transform: uppercase; }
|
||||
.cctv-footer { display: flex; justify-content: space-between; padding: 0.35rem 0.5rem; font-size: 0.62rem; background: rgba(0,0,0,0.85); color: var(--muted); }
|
||||
|
||||
.camera-wall { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 0.5rem; }
|
||||
.camera-thumb { padding: 0; border: 2px solid var(--border); border-radius: 8px; overflow: hidden; cursor: pointer; background: #000; text-align: left; font-family: inherit; transition: border-color 0.15s; }
|
||||
.camera-thumb.active { border-color: var(--accent); box-shadow: 0 0 16px rgba(0,212,255,0.25); }
|
||||
.camera-thumb.offline { opacity: 0.55; }
|
||||
.thumb-label { font-size: 0.58rem; padding: 0.3rem 0.4rem; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; background: rgba(0,0,0,0.9); }
|
||||
|
||||
.lamp-detail { padding: 0.75rem; margin-bottom: 0.75rem; }
|
||||
.lamp-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem; margin-top: 0.5rem; }
|
||||
.lamp-stats div { background: rgba(0,0,0,0.3); padding: 0.45rem; border-radius: 6px; font-size: 0.68rem; }
|
||||
.lamp-stats span { display: block; color: var(--muted); font-size: 0.58rem; margin-bottom: 0.15rem; }
|
||||
|
||||
.event-feed { padding: 0.75rem; flex: 1; overflow-y: auto; max-height: 160px; }
|
||||
.event-row { display: flex; gap: 0.5rem; font-size: 0.65rem; padding: 0.35rem 0; border-bottom: 1px solid rgba(255,255,255,0.04); font-family: monospace; }
|
||||
.event-time { color: var(--accent); min-width: 55px; }
|
||||
.event-type { color: var(--orange); min-width: 80px; text-transform: uppercase; }
|
||||
.event-warn { color: var(--orange); }
|
||||
.event-alert { color: var(--red); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.command-layout { grid-template-columns: 1fr; }
|
||||
.globe-map-scene { height: 320px; }
|
||||
.context-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
root: path.resolve(__dirname),
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3010',
|
||||
'/stream': { target: 'http://localhost:3010', ws: false },
|
||||
'/health': 'http://localhost:3010'
|
||||
}
|
||||
}
|
||||
});
|
||||
Generated
+3540
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "mek-tech-showcase",
|
||||
"version": "1.0.0",
|
||||
"description": "Mek-Tech Live Labs — Open data showcase dashboards",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
|
||||
"dev:server": "node server/index.js",
|
||||
"dev:client": "vite --config frontend/vite.config.js",
|
||||
"build": "vite build --config frontend/vite.config.js",
|
||||
"start": "NODE_ENV=production node server/index.js",
|
||||
"postinstall": "cd frontend 2>/dev/null || true"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"concurrently": "^9.1.2",
|
||||
"vite": "^6.0.7",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"recharts": "^2.15.0",
|
||||
"framer-motion": "^11.15.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"hls.js": "^1.5.18"
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const trading = require('./lib/sources/trading');
|
||||
const retail = require('./lib/sources/retail');
|
||||
const supplyChain = require('./lib/sources/supply-chain');
|
||||
const smartCity = require('./lib/sources/smart-city');
|
||||
const fraud = require('./lib/sources/fraud');
|
||||
const { fetchCameraFrame } = require('./lib/camera-proxy');
|
||||
|
||||
const PORT = process.env.PORT || 3010;
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
next();
|
||||
});
|
||||
app.use(express.json());
|
||||
|
||||
const SOURCES = {
|
||||
trading: { label: 'Trading & FX', fetch: () => trading.getTradingData(), interval: 2000 },
|
||||
retail: { label: 'Retail Intelligence', fetch: () => retail.getRetailData(), interval: 2000 },
|
||||
'supply-chain': { label: 'Supply Chain', fetch: () => supplyChain.getSupplyChainData(), interval: 2500 },
|
||||
fraud: { label: 'Fraud Detection', fetch: () => fraud.getFraudSnapshot(), interval: 1500 },
|
||||
'smart-city': { label: 'Smart City', fetch: () => smartCity.getSmartCityData(), interval: 1500 }
|
||||
};
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', service: 'mek-tech-showcase', ts: new Date().toISOString() });
|
||||
});
|
||||
|
||||
app.get('/api/status', async (req, res) => {
|
||||
const status = {};
|
||||
for (const [key, src] of Object.entries(SOURCES)) {
|
||||
try {
|
||||
await src.fetch();
|
||||
status[key] = { ok: true, label: src.label };
|
||||
} catch (e) {
|
||||
status[key] = { ok: false, label: src.label, error: e.message };
|
||||
}
|
||||
}
|
||||
res.json({ ts: new Date().toISOString(), verticals: status });
|
||||
});
|
||||
|
||||
app.get('/api/smart-city/camera/:id', (req, res) => {
|
||||
const cam = smartCity.getCameraById(req.params.id);
|
||||
if (!cam) return res.status(404).json({ error: 'Camera not found' });
|
||||
res.json(cam);
|
||||
});
|
||||
|
||||
app.get('/api/smart-city/camera/:id/frame', async (req, res) => {
|
||||
try {
|
||||
const frame = await fetchCameraFrame(req.params.id);
|
||||
if (!frame) return res.status(502).json({ error: 'Frame unavailable' });
|
||||
res.setHeader('Content-Type', frame.contentType);
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.send(frame.buffer);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/:vertical', async (req, res) => {
|
||||
const src = SOURCES[req.params.vertical];
|
||||
if (!src) return res.status(404).json({ error: 'Unknown vertical' });
|
||||
try {
|
||||
const data = await src.fetch();
|
||||
res.json(data);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/stream/:vertical', async (req, res) => {
|
||||
const vertical = req.params.vertical;
|
||||
const src = SOURCES[vertical];
|
||||
if (!src) return res.status(404).end();
|
||||
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders();
|
||||
|
||||
let closed = false;
|
||||
req.on('close', () => { closed = true; });
|
||||
|
||||
async function push() {
|
||||
if (closed) return;
|
||||
try {
|
||||
const data = await src.fetch();
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
} catch (e) {
|
||||
res.write(`event: error\ndata: ${JSON.stringify({ error: e.message })}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
await push();
|
||||
const iv = setInterval(async () => {
|
||||
if (closed) { clearInterval(iv); return; }
|
||||
await push();
|
||||
}, src.interval);
|
||||
|
||||
req.on('close', () => clearInterval(iv));
|
||||
});
|
||||
|
||||
const distPath = path.join(__dirname, '../frontend/dist');
|
||||
if (fs.existsSync(distPath)) {
|
||||
app.use(express.static(distPath));
|
||||
app.get('*', (req, res) => {
|
||||
if (req.path.startsWith('/api') || req.path.startsWith('/stream')) return res.status(404).end();
|
||||
res.sendFile(path.join(distPath, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Mek-Tech Showcase running on http://0.0.0.0:${PORT}`);
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 regio’s + eigen winkeldata overlay — dashboard op maat binnen 3–4 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 };
|
||||
@@ -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 };
|
||||
@@ -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 (−15–25%), 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 4–6 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 };
|
||||
@@ -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 Rotterdam–warehouse 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 };
|
||||
@@ -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-ratio’s 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 };
|
||||
Reference in New Issue
Block a user