백엔드: - 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>
329 lines
12 KiB
TypeScript
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>
|
|
)
|
|
}
|