kcg-ai-monitoring/frontend/src/features/risk-assessment/RiskMap.tsx

501 lines
25 KiB
TypeScript
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useRef, useCallback } from 'react';
import { BaseMap, createStaticLayers, createHeatmapLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { HeatPoint } from '@lib/map';
import { Card, CardContent } from '@shared/components/ui/card';
import { Button } from '@shared/components/ui/button';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { Map, Layers, Clock, BarChart3, AlertTriangle, Printer, Download, Ship, TrendingUp } from 'lucide-react';
import { AreaChart as EcAreaChart, LineChart as EcLineChart, PieChart as EcPieChart, BarChart as EcBarChart } from '@lib/charts';
const MTIS_BADGE = (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-blue-500/15 border border-blue-500/30 rounded text-[9px] text-blue-300 font-normal">
[MTIS ]
</span>
);
const COLLECTING_BADGE = (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-orange-500/15 border border-orange-500/30 rounded text-[9px] text-orange-300 font-normal">
AI
</span>
);
/*
* SFR-05: 격자 기반 불법조업 위험도 지도 생성·시각화
* + MTIS 해양사고 통계 연계 (중앙해양안전심판원)
* ① 년도별 통계 ② 선박 특성별 ③ 사고종류별 ④ 시간적 특성별 ⑤ 사고율
*/
type Tab = 'heatmap' | 'yearly' | 'shipProp' | 'accType' | 'timeStat' | 'accRate';
// ─── 위험도 격자 데이터 ──────────────────
const RISK_LEVELS = [
{ level: 5, label: '매우높음', color: '#ef4444', count: 42, pct: 2.3 },
{ level: 4, label: '높음', color: '#f97316', count: 128, pct: 6.9 },
{ level: 3, label: '보통', color: '#eab308', count: 356, pct: 19.3 },
{ level: 2, label: '낮음', color: '#3b82f6', count: 687, pct: 37.3 },
{ level: 1, label: '안전', color: '#22c55e', count: 629, pct: 34.2 },
];
// prediction_risk_grid 테이블 데이터 수집 전이라 빈 격자 반환
const generateGrid = (): number[][] => [];
const ZONE_SUMMARY = [
{ zone: '서해 NLL', risk: 87, trend: '+5', vessels: 18 },
{ zone: 'EEZ 북부', risk: 72, trend: '+3', vessels: 24 },
{ zone: '서해 5도', risk: 68, trend: '-2', vessels: 12 },
{ zone: 'EEZ 서부', risk: 55, trend: '+1', vessels: 31 },
{ zone: '남해 연안', risk: 32, trend: '-4', vessels: 45 },
{ zone: '동해 EEZ', risk: 28, trend: '0', vessels: 15 },
];
// ─── ① 년도별 통계 (MTIS) ──────────────────
const YEARLY = [
{ year: '2018', accidents: 2671, casualties: 449, deaths: 77, missing: 40, ships: 3120 },
{ year: '2019', accidents: 2620, casualties: 419, deaths: 72, missing: 35, ships: 3050 },
{ year: '2020', accidents: 2307, casualties: 368, deaths: 65, missing: 28, ships: 2680 },
{ year: '2021', accidents: 2319, casualties: 392, deaths: 71, missing: 32, ships: 2700 },
{ year: '2022', accidents: 2478, casualties: 412, deaths: 68, missing: 30, ships: 2890 },
{ year: '2023', accidents: 2541, casualties: 398, deaths: 62, missing: 27, ships: 2960 },
{ year: '2024', accidents: 2380, casualties: 371, deaths: 58, missing: 25, ships: 2770 },
{ year: '2025', accidents: 2290, casualties: 345, deaths: 52, missing: 22, ships: 2650 },
];
// ─── ② 선박 특성별 ──────────────────────
const BY_SHIP_TYPE = [
{ type: '어선', count: 1542, pct: 64.2, color: '#ef4444' },
{ type: '화물선', count: 312, pct: 13.0, color: '#f97316' },
{ type: '여객선', count: 89, pct: 3.7, color: '#eab308' },
{ type: '유조선', count: 67, pct: 2.8, color: '#8b5cf6' },
{ type: '예인선', count: 145, pct: 6.0, color: '#3b82f6' },
{ type: '레저', count: 178, pct: 7.4, color: '#06b6d4' },
{ type: '기타', count: 67, pct: 2.8, color: '#6b7280' },
];
const BY_TONNAGE = [
{ range: '5톤 미만', count: 892, pct: 37.2 },
{ range: '5~20톤', count: 534, pct: 22.3 },
{ range: '20~100톤', count: 412, pct: 17.2 },
{ range: '100~500톤', count: 298, pct: 12.4 },
{ range: '500~3000톤', count: 156, pct: 6.5 },
{ range: '3000톤 이상', count: 108, pct: 4.5 },
];
const BY_AGE = [
{ range: '5년 미만', count: 189, pct: 7.9 },
{ range: '5~10년', count: 312, pct: 13.0 },
{ range: '10~20년', count: 578, pct: 24.1 },
{ range: '20~30년', count: 687, pct: 28.6 },
{ range: '30~40년', count: 423, pct: 17.6 },
{ range: '40년 이상', count: 211, pct: 8.8 },
];
// ─── ③ 사고종류별 ──────────────────────
const ACC_TYPES = [
{ type: '충돌', count: 687, pct: 28.6, color: '#ef4444' },
{ type: '좌초', count: 312, pct: 13.0, color: '#f97316' },
{ type: '전복', count: 245, pct: 10.2, color: '#eab308' },
{ type: '침몰', count: 178, pct: 7.4, color: '#a855f7' },
{ type: '화재·폭발', count: 156, pct: 6.5, color: '#ef4444' },
{ type: '기관손상', count: 423, pct: 17.6, color: '#3b82f6' },
{ type: '안전사고', count: 234, pct: 9.7, color: '#06b6d4' },
{ type: '기타', count: 165, pct: 6.9, color: '#6b7280' },
];
// ─── ④ 시간적 특성별 ──────────────────────
const BY_MONTH = [
{ m: '1월', count: 178 }, { m: '2월', count: 156 }, { m: '3월', count: 198 },
{ m: '4월', count: 234 }, { m: '5월', count: 267 }, { m: '6월', count: 245 },
{ m: '7월', count: 289 }, { m: '8월', count: 312 }, { m: '9월', count: 256 },
{ m: '10월', count: 223 }, { m: '11월', count: 189 }, { m: '12월', count: 165 },
];
const BY_HOUR = [
{ h: '00~04시', count: 189 }, { h: '04~08시', count: 234 }, { h: '08~12시', count: 567 },
{ h: '12~16시', count: 612 }, { h: '16~20시', count: 489 }, { h: '20~24시', count: 309 },
];
const BY_DAY = [
{ d: '월', count: 356 }, { d: '화', count: 378 }, { d: '수', count: 345 },
{ d: '목', count: 334 }, { d: '금', count: 389 }, { d: '토', count: 312 }, { d: '일', count: 286 },
];
// ─── ⑤ 사고율 ──────────────────────────
const ACC_RATE = [
{ type: '어선', registered: 62345, accidents: 1542, rate: 2.47 },
{ type: '여객선', registered: 1234, accidents: 89, rate: 7.21 },
{ type: '화물선', registered: 4567, accidents: 312, rate: 6.83 },
{ type: '유조선', registered: 890, accidents: 67, rate: 7.53 },
{ type: '예인선', registered: 3456, accidents: 145, rate: 4.20 },
{ type: '레저', registered: 28900, accidents: 178, rate: 0.62 },
];
// ─── 히트맵 포인트 데이터 (한반도 주변 해역) ──────
// [lat, lng, intensity] — 서해 NLL·EEZ 위주 고위험 분포
function generateHeatPoints(): [number, number, number][] {
const points: [number, number, number][] = [];
// 서해 NLL 인근 (최고 위험)
for (let i = 0; i < 120; i++) {
points.push([37.6 + Math.random() * 0.6, 124.3 + Math.random() * 1.2, 0.7 + Math.random() * 0.3]);
}
// EEZ 북부 (고위험)
for (let i = 0; i < 90; i++) {
points.push([36.5 + Math.random() * 1.0, 123.5 + Math.random() * 1.5, 0.5 + Math.random() * 0.4]);
}
// 서해 5도 수역 (고위험)
for (let i = 0; i < 70; i++) {
points.push([37.0 + Math.random() * 0.8, 124.5 + Math.random() * 1.0, 0.6 + Math.random() * 0.3]);
}
// EEZ 서부 (중위험)
for (let i = 0; i < 60; i++) {
points.push([35.5 + Math.random() * 1.0, 123.0 + Math.random() * 1.5, 0.3 + Math.random() * 0.4]);
}
// 남해 연안 (저위험)
for (let i = 0; i < 40; i++) {
points.push([34.0 + Math.random() * 0.8, 126.0 + Math.random() * 2.0, 0.1 + Math.random() * 0.3]);
}
// 동해 EEZ (저위험)
for (let i = 0; i < 30; i++) {
points.push([36.0 + Math.random() * 1.5, 130.0 + Math.random() * 1.5, 0.1 + Math.random() * 0.25]);
}
// 제주 남방 (중위험)
for (let i = 0; i < 35; i++) {
points.push([32.5 + Math.random() * 1.0, 125.5 + Math.random() * 2.0, 0.2 + Math.random() * 0.35]);
}
return points;
}
const HEAT_POINTS = generateHeatPoints();
export function RiskMap() {
// prediction_risk_grid 데이터 수집 전이라 빈 격자 유지
const grid = generateGrid();
void grid;
const [tab, setTab] = useState<Tab>('heatmap');
const mapRef = useRef<MapHandle>(null);
const buildLayers = useCallback(() => {
if (tab !== 'heatmap') return [];
return [
...createStaticLayers(),
createHeatmapLayer('risk-heat', HEAT_POINTS as HeatPoint[], { radiusPixels: 25 }),
];
}, [tab]);
useMapLayers(mapRef, buildLayers, [tab]);
return (
<PageContainer>
<PageHeader
icon={Map}
iconColor="text-red-400"
title="격자 기반 불법조업 위험도 지도"
description="SFR-05 | 위험도 히트맵 (수집 중) + MTIS 해양사고 통계 (중앙해양안전심판원)"
actions={
<>
{COLLECTING_BADGE}
<Button variant="secondary" size="sm" icon={<Printer className="w-3 h-3" />}>
</Button>
<Button variant="secondary" size="sm" icon={<Download className="w-3 h-3" />}>
</Button>
</>
}
/>
{/* 탭 */}
<div className="flex gap-0 border-b border-border">
{([
{ key: 'heatmap' as Tab, icon: Layers, label: '위험도 히트맵' },
{ key: 'yearly' as Tab, icon: TrendingUp, label: '년도별 통계' },
{ key: 'shipProp' as Tab, icon: Ship, label: '선박 특성별' },
{ key: 'accType' as Tab, icon: AlertTriangle, label: '사고종류별' },
{ key: 'timeStat' as Tab, icon: Clock, label: '시간적 특성별' },
{ key: 'accRate' as Tab, icon: BarChart3, label: '사고율' },
]).map(t => (
<button type="button" key={t.key} onClick={() => setTab(t.key)}
className={`flex items-center gap-1.5 px-4 py-2 text-[11px] font-medium border-b-2 ${tab === t.key ? 'text-red-400 border-red-400' : 'text-hint border-transparent hover:text-label'}`}>
<t.icon className="w-3.5 h-3.5" />{t.label}
</button>
))}
</div>
{/* ── 위험도 히트맵 (지도 기반) ── */}
{tab === 'heatmap' && (
<div className="space-y-3">
<div className="flex items-center gap-2 px-3 py-2 bg-orange-500/10 border border-orange-500/30 rounded-lg">
<AlertTriangle className="w-3.5 h-3.5 text-orange-400" />
<span className="text-[10px] text-orange-300">
prediction_risk_grid . / / .
</span>
</div>
{/* 위험도 등급 요약 카드 */}
<div className="flex items-center gap-2 text-[10px] text-hint"> 출처: AI ( )</div>
<div className="flex gap-2">
{RISK_LEVELS.map(r => (
<div key={r.level} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: r.color }} />
<span className="text-sm font-bold text-heading">{r.count}</span>
<span className="text-[9px] text-hint">{r.label} ({r.pct}%)</span>
</div>
))}
</div>
<div className="grid grid-cols-3 gap-3">
{/* 지도 히트맵 */}
<Card className="col-span-2 bg-surface-raised border-border">
<CardContent className="p-0 relative">
<BaseMap
ref={mapRef}
center={[35.8, 127.0]}
zoom={7}
height={480}
className="w-full rounded-lg overflow-hidden"
/>
{/* 범례 오버레이 */}
<div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
<div className="text-[9px] text-muted-foreground font-bold mb-1.5"> </div>
<div className="flex items-center gap-1">
<span className="text-[8px] text-blue-400"></span>
<div className="w-32 h-2.5 rounded-full" style={{
background: 'linear-gradient(to right, #1e40af, #3b82f6, #22c55e, #eab308, #f97316, #ef4444)',
}} />
<span className="text-[8px] text-red-400"></span>
</div>
<div className="flex items-center gap-3 mt-1.5 pt-1.5 border-t border-border">
<div className="flex items-center gap-1"><div className="w-4 h-0 border-t-2 border-dashed border-red-500/60" /><span className="text-[8px] text-hint">EEZ</span></div>
<div className="flex items-center gap-1"><div className="w-4 h-0 border-t-2 border-dashed border-orange-500/80" /><span className="text-[8px] text-hint">NLL</span></div>
</div>
</div>
</CardContent>
</Card>
{/* 해역별 위험도 사이드 패널 */}
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-2"> </div>
<div className="space-y-2">
{ZONE_SUMMARY.map(z => (
<div key={z.zone} className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground w-16 truncate">{z.zone}</span>
<div className="flex-1 h-2 bg-switch-background/60 rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${z.risk}%`, backgroundColor: z.risk > 70 ? '#ef4444' : z.risk > 50 ? '#f97316' : '#22c55e' }} />
</div>
<span className="text-[10px] text-heading font-bold w-6 text-right">{z.risk}</span>
<span className={`text-[9px] w-6 ${z.trend.startsWith('+') ? 'text-red-400' : 'text-green-400'}`}>{z.trend}</span>
</div>
))}
</div>
<div className="mt-4 pt-3 border-t border-border">
<div className="text-[12px] font-bold text-heading mb-2"> </div>
<div className="space-y-1.5">
{RISK_LEVELS.map(r => (
<div key={r.level} className="flex items-center gap-2">
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: r.color }} />
<span className="text-[10px] text-muted-foreground flex-1">{r.label}</span>
<span className="text-[10px] text-heading font-bold">{r.count}</span>
<span className="text-[9px] text-hint">{r.pct}%</span>
</div>
))}
</div>
</div>
<div className="mt-4 pt-3 border-t border-border">
<div className="text-[9px] text-hint">
단위: 1km × 1km<br/>
주기: 6시간<br/>
기반: AIS·SAR··VMS
</div>
</div>
</CardContent></Card>
</div>
</div>
)}
{/* ── ① 년도별 통계 ── */}
{tab === 'yearly' && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-[10px] text-hint">{MTIS_BADGE}<span>출처: 중앙해양안전심판원 (MTIS)</span></div>
<div className="grid grid-cols-2 gap-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-3"> </div>
<EcAreaChart
data={YEARLY}
xKey="year"
series={[
{ key: 'accidents', name: '사고건수', color: '#3b82f6' },
{ key: 'ships', name: '관련선박', color: '#8b5cf6' },
]}
height={200}
/>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-3"> </div>
<EcLineChart
data={YEARLY}
xKey="year"
series={[
{ key: 'casualties', name: '인명피해', color: '#ef4444' },
{ key: 'deaths', name: '사망', color: '#f97316' },
{ key: 'missing', name: '실종', color: '#eab308' },
]}
height={200}
/>
</CardContent></Card>
</div>
<Card><CardContent className="p-0">
<table className="w-full text-[10px]">
<thead><tr className="border-b border-border text-hint">
{['년도', '사고건수', '관련선박', '인명피해', '사망', '실종'].map(h => <th key={h} className="px-3 py-2 text-left font-medium">{h}</th>)}
</tr></thead>
<tbody>{YEARLY.map(y => (
<tr key={y.year} className="border-b border-border hover:bg-surface-overlay">
<td className="px-3 py-1.5 text-heading font-bold">{y.year}</td>
<td className="px-3 py-1.5 text-blue-400 font-bold">{y.accidents.toLocaleString()}</td>
<td className="px-3 py-1.5 text-label">{y.ships.toLocaleString()}</td>
<td className="px-3 py-1.5 text-red-400">{y.casualties}</td>
<td className="px-3 py-1.5 text-orange-400">{y.deaths}</td>
<td className="px-3 py-1.5 text-yellow-400">{y.missing}</td>
</tr>
))}</tbody>
</table>
</CardContent></Card>
</div>
)}
{/* ── ② 선박 특성별 ── */}
{tab === 'shipProp' && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-[10px] text-hint">{MTIS_BADGE}<span>출처: 중앙해양안전심판원 (MTIS)</span></div>
<div className="grid grid-cols-3 gap-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-3"></div>
<EcPieChart
data={BY_SHIP_TYPE.map(d => ({ name: d.type, value: d.count, color: d.color }))}
height={180}
innerRadius={35}
outerRadius={70}
/>
<div className="space-y-1 mt-2">{BY_SHIP_TYPE.map(d => (
<div key={d.type} className="flex justify-between text-[10px]"><div className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full" style={{ backgroundColor: d.color }} /><span className="text-muted-foreground">{d.type}</span></div><span className="text-heading font-bold">{d.count} ({d.pct}%)</span></div>
))}</div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-3"></div>
<EcBarChart
data={BY_TONNAGE}
xKey="range"
series={[{ key: 'count', name: '사고건수', color: '#3b82f6' }]}
height={180}
horizontal
/>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-3"></div>
<EcBarChart
data={BY_AGE}
xKey="range"
series={[{ key: 'count', name: '사고건수', color: '#f97316' }]}
height={180}
horizontal
/>
</CardContent></Card>
</div>
</div>
)}
{/* ── ③ 사고종류별 ── */}
{tab === 'accType' && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-[10px] text-hint">{MTIS_BADGE}<span>출처: 중앙해양안전심판원 (MTIS)</span></div>
<div className="grid grid-cols-2 gap-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-3"> </div>
<EcBarChart
data={ACC_TYPES}
xKey="type"
series={[{ key: 'count', name: '사고건수' }]}
height={220}
itemColors={ACC_TYPES.map(d => d.color)}
/>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-3"></div>
<div className="space-y-2">
{ACC_TYPES.map(a => (
<div key={a.type} className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground w-20">{a.type}</span>
<div className="flex-1 h-3 bg-switch-background/60 rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${a.pct}%`, backgroundColor: a.color }} />
</div>
<span className="text-[10px] text-heading font-bold w-14 text-right">{a.count}</span>
<span className="text-[9px] text-hint w-10 text-right">{a.pct}%</span>
</div>
))}
</div>
</CardContent></Card>
</div>
</div>
)}
{/* ── ④ 시간적 특성별 ── */}
{tab === 'timeStat' && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-[10px] text-hint">{MTIS_BADGE}<span>출처: 중앙해양안전심판원 (MTIS)</span></div>
<div className="grid grid-cols-3 gap-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-3"> </div>
<EcBarChart
data={BY_MONTH}
xKey="m"
series={[{ key: 'count', name: '건수' }]}
height={180}
itemColors={BY_MONTH.map(d => d.count > 270 ? '#ef4444' : d.count > 220 ? '#f97316' : '#3b82f6')}
/>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-3"> </div>
<EcBarChart
data={BY_HOUR}
xKey="h"
series={[{ key: 'count', name: '건수' }]}
height={180}
itemColors={BY_HOUR.map(d => d.count > 500 ? '#ef4444' : d.count > 300 ? '#f97316' : '#3b82f6')}
/>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-3"> </div>
<EcBarChart
data={BY_DAY}
xKey="d"
series={[{ key: 'count', name: '건수', color: '#8b5cf6' }]}
height={180}
/>
</CardContent></Card>
</div>
</div>
)}
{/* ── ⑤ 사고율 ── */}
{tab === 'accRate' && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-[10px] text-hint">{MTIS_BADGE}<span>출처: 중앙해양안전심판원 (MTIS) · = ( / ) × 100</span></div>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-3"> </div>
<div className="grid grid-cols-2 gap-3">
<EcBarChart
data={ACC_RATE}
xKey="type"
series={[{ key: 'rate', name: '사고율 %' }]}
height={220}
itemColors={ACC_RATE.map(d => d.rate > 5 ? '#ef4444' : d.rate > 2 ? '#f97316' : '#22c55e')}
/>
<div className="space-y-2">
{ACC_RATE.map(r => (
<div key={r.type} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
<span className="text-[10px] text-heading font-medium w-16">{r.type}</span>
<div className="flex-1 text-[9px] text-hint"> {r.registered.toLocaleString()}</div>
<span className="text-[10px] text-muted-foreground"> {r.accidents}</span>
<span className={`text-[11px] font-bold ${r.rate > 5 ? 'text-red-400' : r.rate > 2 ? 'text-orange-400' : 'text-green-400'}`}>{r.rate}%</span>
</div>
))}
</div>
</div>
</CardContent></Card>
</div>
)}
</PageContainer>
);
}