signal-batch/frontend/src/pages/AreaStats.tsx
htlee 0cdb46d063 perf: API 응답 최적화 + 점진적 렌더링 + 해구 choropleth 지도
백엔드:
- haegu/realtime: DB 공간 JOIN(12s) → 인메모리 캐시 순회(~50ms)
- batch/statistics: N+1 JobExplorer(1.1s) → 단일 SQL 집계(~100ms)
- batch/daily-stats: N+1×7일(9s) → 직접 SQL 2쿼리(~200ms)
- throughput: pg_total_relation_size 매번 호출(1.4s) → Caffeine 5분 캐시
- quality: 풀스캔(0.6s) → 24시간 범위 제한

프론트엔드:
- Promise.allSettled 차단 → 개별 .then() 점진적 렌더링
- useCachedState 훅: 페이지 전환 시 이전 데이터 즉시 표시
- AreaStats: 해구 폴리곤 choropleth 지도 + 선박수 범례 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 20:24:28 +09:00

329 lines
12 KiB
TypeScript

import { useEffect, useRef, useCallback } from 'react'
import maplibregl from 'maplibre-gl'
import { usePoller } from '../hooks/usePoller.ts'
import { useCachedState } from '../hooks/useCachedState.ts'
import { useI18n } from '../hooks/useI18n.ts'
import { monitorApi } from '../api/monitorApi.ts'
import { gisApi } from '../api/gisApi.ts'
import type { HaeguBoundary } from '../api/gisApi.ts'
import type { ThroughputMetrics, DataQuality, HaeguStat } from '../api/types.ts'
import MapContainer from '../components/map/MapContainer.tsx'
import MetricCard from '../components/charts/MetricCard.tsx'
import StatusBadge from '../components/common/StatusBadge.tsx'
import { formatNumber, formatDateTime } from '../utils/formatters.ts'
const POLL_INTERVAL = 30_000
/** 범례에 표시할 항목 */
const LEGEND_ITEMS = [
{ label: '1-9', color: 'rgba(59,130,246,0.45)' },
{ label: '10-49', color: 'rgba(16,185,129,0.6)' },
{ label: '50-99', color: 'rgba(245,158,11,0.7)' },
{ label: '100-199', color: 'rgba(239,68,68,0.7)' },
{ label: '200+', color: 'rgba(139,92,246,0.8)' },
]
export default function AreaStats() {
const { t } = useI18n()
const [haegu, setHaegu] = useCachedState<HaeguStat[]>('area.haegu', [])
const [throughput, setThroughput] = useCachedState<ThroughputMetrics | null>('area.throughput', null)
const [quality, setQuality] = useCachedState<DataQuality | null>('area.quality', null)
const [boundaries, setBoundaries] = useCachedState<HaeguBoundary[]>('area.boundaries', [])
const mapRef = useRef<maplibregl.Map | null>(null)
const popupRef = useRef<maplibregl.Popup | null>(null)
usePoller(() => {
monitorApi.getHaeguRealtimeStats().then(setHaegu).catch(() => {})
monitorApi.getThroughput().then(setThroughput).catch(() => {})
monitorApi.getQuality().then(setQuality).catch(() => {})
}, POLL_INTERVAL)
// 경계 데이터는 1회만 로딩
useEffect(() => {
if (boundaries.length === 0) {
gisApi.getHaeguBoundaries().then(setBoundaries).catch(() => {})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// GeoJSON 생성 함수
const buildGeoJson = useCallback((): GeoJSON.FeatureCollection => {
const statsMap = new Map<number, HaeguStat>()
for (const h of haegu) {
statsMap.set(h.haegu_no, h)
}
const features: GeoJSON.Feature[] = []
for (const b of boundaries) {
if (!b.geom_json) continue
try {
const geometry = JSON.parse(b.geom_json)
const stat = statsMap.get(b.haegu_no)
features.push({
type: 'Feature',
properties: {
haegu_no: b.haegu_no,
haegu_name: stat?.haegu_name ?? `대해구 ${b.haegu_no}`,
vessels: stat?.current_vessels ?? 0,
avg_speed: stat?.avg_speed ?? 0,
avg_density: stat?.avg_density ?? 0,
},
geometry,
})
} catch {
// skip invalid geom
}
}
return { type: 'FeatureCollection', features }
}, [boundaries, haegu])
// 지도 레이어 업데이트
useEffect(() => {
const map = mapRef.current
if (!map || boundaries.length === 0) return
const geojson = buildGeoJson()
if (map.getSource('haegu')) {
(map.getSource('haegu') as maplibregl.GeoJSONSource).setData(geojson)
} else {
map.addSource('haegu', { type: 'geojson', data: geojson })
// fill 색상 step expression
// ['step', input, default, stop1, output1, stop2, output2, ...]
const fillColor = [
'step',
['get', 'vessels'],
'rgba(59,130,246,0.05)', // default (vessels < 0)
0, 'rgba(59,130,246,0.25)', // 0
1, 'rgba(59,130,246,0.45)', // 1-9
10, 'rgba(16,185,129,0.55)', // 10-49
50, 'rgba(245,158,11,0.6)', // 50-99
100, 'rgba(239,68,68,0.6)', // 100-199
200, 'rgba(139,92,246,0.7)', // 200+
] as unknown as maplibregl.ExpressionSpecification
map.addLayer({
id: 'haegu-fill',
type: 'fill',
source: 'haegu',
paint: {
'fill-color': fillColor,
'fill-opacity': 0.8,
},
})
map.addLayer({
id: 'haegu-line',
type: 'line',
source: 'haegu',
paint: {
'line-color': 'rgba(100,116,139,0.5)',
'line-width': 1,
},
})
// hover popup
const popup = new maplibregl.Popup({
closeButton: false,
closeOnClick: false,
maxWidth: '220px',
})
popupRef.current = popup
map.on('mousemove', 'haegu-fill', (e) => {
if (!e.features || e.features.length === 0) return
map.getCanvas().style.cursor = 'pointer'
const props = e.features[0].properties
if (!props) return
popup
.setLngLat(e.lngLat)
.setHTML(
`<div style="font-size:13px;line-height:1.5">
<strong>${props.haegu_name}</strong><br/>
${t('area.currentVessels')}: <b>${props.vessels}</b>${t('area.vessels')}<br/>
${t('area.avgSpeed')}: ${Number(props.avg_speed).toFixed(1)} kn
</div>`,
)
.addTo(map)
})
map.on('mouseleave', 'haegu-fill', () => {
map.getCanvas().style.cursor = ''
popup.remove()
})
}
}, [boundaries, haegu, buildGeoJson, t])
const handleMapReady = useCallback((map: maplibregl.Map) => {
mapRef.current = map
}, [])
// cleanup popup on unmount
useEffect(() => {
return () => {
popupRef.current?.remove()
}
}, [])
return (
<div className="space-y-6 fade-in">
<h1 className="text-2xl font-bold">{t('area.title')}</h1>
{/* Summary Cards */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<MetricCard
title={t('area.activeHaegu')}
value={haegu.length}
subtitle={t('area.activeHaeguDesc')}
/>
<MetricCard
title={t('area.totalVessels')}
value={formatNumber(haegu.reduce((sum, h) => sum + (h.current_vessels ?? 0), 0))}
/>
<MetricCard
title={t('area.dataQuality')}
value={quality?.qualityScore ?? '-'}
trend={quality?.qualityScore === 'GOOD' ? 'up' : quality?.qualityScore === 'NEEDS_ATTENTION' ? 'down' : 'neutral'}
/>
<MetricCard
title={t('area.avgDensity')}
value={haegu.length > 0
? (haegu.reduce((sum, h) => sum + (h.avg_density ?? 0), 0) / haegu.length).toFixed(2)
: '-'}
/>
</div>
{/* Haegu Map (choropleth) */}
<div className="sb-card">
<div className="sb-card-header">{t('area.haeguMap')}</div>
<div className="relative" style={{ height: 480 }}>
<MapContainer onMapReady={handleMapReady} />
{/* Legend */}
<div className="absolute bottom-4 left-4 rounded-lg bg-surface/90 px-3 py-2 shadow-lg backdrop-blur-sm">
<div className="mb-1.5 text-xs font-medium text-muted">{t('area.mapLegend')}</div>
<div className="space-y-1">
{LEGEND_ITEMS.map((item) => (
<div key={item.label} className="flex items-center gap-2 text-xs">
<span
className="inline-block h-3 w-5 rounded"
style={{ backgroundColor: item.color }}
/>
<span>{item.label}</span>
</div>
))}
</div>
</div>
</div>
</div>
{/* Haegu Stats Table */}
<div className="sb-card">
<div className="sb-card-header">{t('area.haeguStats')}</div>
{haegu.length === 0 ? (
<div className="py-8 text-center text-sm text-muted">{t('common.noData')}</div>
) : (
<div className="sb-table-wrapper">
<table className="sb-table">
<thead>
<tr>
<th>{t('area.haeguNo')}</th>
<th>{t('area.haeguName')}</th>
<th className="text-right">{t('area.currentVessels')}</th>
<th className="text-right">{t('area.avgSpeed')}</th>
<th className="text-right">{t('area.avgDensityCol')}</th>
<th>{t('area.lastUpdate')}</th>
</tr>
</thead>
<tbody>
{haegu.map(h => (
<tr key={h.haegu_no}>
<td className="font-mono">{h.haegu_no}</td>
<td>{h.haegu_name}</td>
<td className="text-right font-bold">{formatNumber(h.current_vessels)}</td>
<td className="text-right">{(h.avg_speed ?? 0).toFixed(1)} kn</td>
<td className="text-right">{(h.avg_density ?? 0).toFixed(4)}</td>
<td className="text-xs text-muted">{formatDateTime(h.last_update)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Throughput + Quality */}
<div className="grid gap-4 lg:grid-cols-2">
{/* Throughput */}
<div className="sb-card">
<div className="sb-card-header">{t('area.throughput')}</div>
{throughput ? (
<div className="space-y-3">
{throughput.avgVesselsPerMinute != null && (
<div className="grid grid-cols-2 gap-3 text-center text-sm">
<div>
<div className="text-lg font-bold">{Math.round(throughput.avgVesselsPerMinute)}</div>
<div className="text-xs text-muted">{t('area.vesselsPerMin')}</div>
</div>
<div>
<div className="text-lg font-bold">{formatNumber(Math.round(throughput.avgVesselsPerHour ?? 0))}</div>
<div className="text-xs text-muted">{t('area.vesselsPerHour')}</div>
</div>
</div>
)}
{throughput.partitionSizes && throughput.partitionSizes.length > 0 && (
<div>
<div className="mb-2 text-xs font-medium text-muted">{t('area.tableSizes')}</div>
<div className="space-y-1">
{throughput.partitionSizes.map((p, i) => (
<div key={i} className="flex items-center justify-between rounded bg-surface-hover px-3 py-1.5 text-sm">
<span className="font-mono text-xs">{p.tablename}</span>
<span className="font-medium">{p.size}</span>
</div>
))}
</div>
</div>
)}
{(!throughput.avgVesselsPerMinute && (!throughput.partitionSizes || throughput.partitionSizes.length === 0)) && (
<div className="py-4 text-center text-sm text-muted">{t('common.noData')}</div>
)}
</div>
) : (
<div className="py-4 text-center text-sm text-muted">{t('common.loading')}</div>
)}
</div>
{/* Data Quality */}
<div className="sb-card">
<div className="sb-card-header">{t('area.dataQualityTitle')}</div>
{quality ? (
<div className="space-y-3">
<div className="flex items-baseline gap-2">
<StatusBadge status={quality.qualityScore === 'GOOD' ? 'COMPLETED' : quality.qualityScore === 'ERROR' ? 'FAILED' : 'STOPPED'} />
<span className="text-lg font-bold">{quality.qualityScore}</span>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="rounded-lg bg-surface-hover p-3">
<div className="text-xs text-muted">{t('area.duplicates')}</div>
<div className="text-lg font-bold">{formatNumber(quality.duplicateRecords)}</div>
</div>
<div className="rounded-lg bg-surface-hover p-3">
<div className="text-xs text-muted">{t('area.stalePositions')}</div>
<div className="text-lg font-bold">{formatNumber(quality.stalePositions)}</div>
</div>
</div>
<div className="text-xs text-muted">
{t('area.checkedAt')}: {formatDateTime(quality.checkedAt)}
</div>
</div>
) : (
<div className="py-4 text-center text-sm text-muted">{t('common.loading')}</div>
)}
</div>
</div>
</div>
)
}