501 lines
25 KiB
TypeScript
501 lines
25 KiB
TypeScript
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>
|
||
);
|
||
}
|