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

This commit is contained in:
mo
2026-07-12 21:54:28 +00:00
commit 070ab80d4a
30 changed files with 6187 additions and 0 deletions
+15
View File
@@ -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>
+23
View File
@@ -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>
);
}
+236
View File
@@ -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>
);
}
+123
View File
@@ -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='&copy; 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>
);
}
+66
View File
@@ -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>
);
}
+58
View File
@@ -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>
);
}
+49
View File
@@ -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>
);
}
+13
View File
@@ -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>
);
+110
View File
@@ -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>
);
}
+85
View File
@@ -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,52,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>
);
}
+100
View File
@@ -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>
);
}
+179
View File
@@ -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>
);
}
+94
View File
@@ -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>
);
}
+107
View File
@@ -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>
);
}
+350
View File
@@ -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; }
}
+20
View File
@@ -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'
}
}
});