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('area.haegu', []) const [throughput, setThroughput] = useCachedState('area.throughput', null) const [quality, setQuality] = useCachedState('area.quality', null) const [boundaries, setBoundaries] = useCachedState('area.boundaries', []) const mapRef = useRef(null) const popupRef = useRef(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() 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( `
${props.haegu_name}
${t('area.currentVessels')}: ${props.vessels}${t('area.vessels')}
${t('area.avgSpeed')}: ${Number(props.avg_speed).toFixed(1)} kn
`, ) .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 (

{t('area.title')}

{/* Summary Cards */}
sum + (h.current_vessels ?? 0), 0))} /> 0 ? (haegu.reduce((sum, h) => sum + (h.avg_density ?? 0), 0) / haegu.length).toFixed(2) : '-'} />
{/* Haegu Map (choropleth) */}
{t('area.haeguMap')}
{/* Legend */}
{t('area.mapLegend')}
{LEGEND_ITEMS.map((item) => (
{item.label}
))}
{/* Haegu Stats Table */}
{t('area.haeguStats')}
{haegu.length === 0 ? (
{t('common.noData')}
) : (
{haegu.map(h => ( ))}
{t('area.haeguNo')} {t('area.haeguName')} {t('area.currentVessels')} {t('area.avgSpeed')} {t('area.avgDensityCol')} {t('area.lastUpdate')}
{h.haegu_no} {h.haegu_name} {formatNumber(h.current_vessels)} {(h.avg_speed ?? 0).toFixed(1)} kn {(h.avg_density ?? 0).toFixed(4)} {formatDateTime(h.last_update)}
)}
{/* Throughput + Quality */}
{/* Throughput */}
{t('area.throughput')}
{throughput ? (
{throughput.avgVesselsPerMinute != null && (
{Math.round(throughput.avgVesselsPerMinute)}
{t('area.vesselsPerMin')}
{formatNumber(Math.round(throughput.avgVesselsPerHour ?? 0))}
{t('area.vesselsPerHour')}
)} {throughput.partitionSizes && throughput.partitionSizes.length > 0 && (
{t('area.tableSizes')}
{throughput.partitionSizes.map((p, i) => (
{p.tablename} {p.size}
))}
)} {(!throughput.avgVesselsPerMinute && (!throughput.partitionSizes || throughput.partitionSizes.length === 0)) && (
{t('common.noData')}
)}
) : (
{t('common.loading')}
)}
{/* Data Quality */}
{t('area.dataQualityTitle')}
{quality ? (
{quality.qualityScore}
{t('area.duplicates')}
{formatNumber(quality.duplicateRecords)}
{t('area.stalePositions')}
{formatNumber(quality.stalePositions)}
{t('area.checkedAt')}: {formatDateTime(quality.checkedAt)}
) : (
{t('common.loading')}
)}
) }