kcg-monitoring/frontend/src/hooks/useFleetClusterDeckLayers.ts
htlee f09186a187 feat: 어구 리플레이 서브클러스터 분리 렌더링 + 일치율 감쇠 개선
- 서브클러스터별 독립 폴리곤/센터/center trail 렌더링
- 반경 밖 이탈 선박 강제 감쇠 (OUT_OF_RANGE)
- Backend correlation API에 sub_cluster_id 추가
- 모델 패널 5개 항상 표시, 드롭다운 기본값 70%
- DISPLAY_STALE_SEC (time_bucket 기반) 폴리곤 노출 필터
- AIS 수집 bbox 122~132E/31~39N 확장
- historyActive 시 deck.gl 이중 렌더링 수정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 09:01:03 +09:00

553 lines
21 KiB
TypeScript

import { useMemo } from 'react';
import type { Layer } from '@deck.gl/core';
import { GeoJsonLayer, IconLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import { SHIP_ICON_MAPPING } from '../utils/shipIconSvg';
import { MODEL_ORDER, MODEL_COLORS } from '../components/korea/fleetClusterConstants';
import type { FleetClusterGeoJsonResult } from '../components/korea/useFleetClusterGeoJson';
import { FONT_MONO } from '../styles/fonts';
import { clusterLabels } from '../utils/labelCluster';
// ── Config ────────────────────────────────────────────────────────────────────
export interface FleetClusterDeckConfig {
selectedGearGroup: string | null;
hoveredMmsi: string | null;
hoveredGearGroup: string | null; // gear polygon hover highlight
enabledModels: Set<string>;
historyActive: boolean;
hasCorrelationTracks: boolean;
zoomScale: number;
zoomLevel: number; // integer zoom for label clustering
fontScale?: number; // fontScale.analysis (default 1)
focusMode?: boolean; // 집중 모드 — 라이브 폴리곤/마커 숨김
onPolygonClick?: (features: PickedPolygonFeature[], coordinate: [number, number]) => void;
onPolygonHover?: (info: { lng: number; lat: number; type: 'fleet' | 'gear'; id: string | number } | null) => void;
}
export interface PickedPolygonFeature {
type: 'fleet' | 'gear';
clusterId?: number;
name?: string;
gearCount?: number;
inZone?: boolean;
}
// ── Hex → RGBA (module-level cache) ──────────────────────────────────────────
const hexRgbaCache = new Map<string, [number, number, number, number]>();
function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
const cacheKey = `${hex}-${alpha}`;
const cached = hexRgbaCache.get(cacheKey);
if (cached) return cached;
const h = hex.replace('#', '');
let r = parseInt(h.substring(0, 2), 16) || 0;
let g = parseInt(h.substring(2, 4), 16) || 0;
let b = parseInt(h.substring(4, 6), 16) || 0;
// 어두운 색상 밝기 보정 (바다 배경 대비)
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
if (lum < 0.3) {
const boost = 0.3 / Math.max(lum, 0.01);
r = Math.min(255, Math.round(r * boost));
g = Math.min(255, Math.round(g * boost));
b = Math.min(255, Math.round(b * boost));
}
const rgba: [number, number, number, number] = [r, g, b, alpha];
hexRgbaCache.set(cacheKey, rgba);
return rgba;
}
// ── Gear cluster color helpers ────────────────────────────────────────────────
const GEAR_IN_ZONE_FILL: [number, number, number, number] = [220, 38, 38, 25]; // #dc2626 opacity 0.10
const GEAR_IN_ZONE_LINE: [number, number, number, number] = [220, 38, 38, 200]; // #dc2626
const GEAR_OUT_ZONE_FILL: [number, number, number, number] = [249, 115, 22, 25]; // #f97316 opacity 0.10
const GEAR_OUT_ZONE_LINE: [number, number, number, number] = [249, 115, 22, 200]; // #f97316
const ICON_PX = 64;
// ── Point-in-polygon (ray casting) ──────────────────────────────────────────
function pointInRing(point: [number, number], ring: number[][]): boolean {
const [px, py] = point;
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const xi = ring[i][0], yi = ring[i][1];
const xj = ring[j][0], yj = ring[j][1];
if ((yi > py) !== (yj > py) && px < (xj - xi) * (py - yi) / (yj - yi) + xi) {
inside = !inside;
}
}
return inside;
}
function pointInPolygon(point: [number, number], geometry: GeoJSON.Geometry): boolean {
if (geometry.type === 'Polygon') {
return pointInRing(point, geometry.coordinates[0]);
}
if (geometry.type === 'MultiPolygon') {
return geometry.coordinates.some(poly => pointInRing(point, poly[0]));
}
return false;
}
/** Find all fleet/gear polygons at a given coordinate */
function findPolygonsAtPoint(
point: [number, number],
fleetFc: GeoJSON.FeatureCollection,
gearFc: GeoJSON.FeatureCollection,
): PickedPolygonFeature[] {
const results: PickedPolygonFeature[] = [];
for (const f of fleetFc.features) {
if (pointInPolygon(point, f.geometry)) {
results.push({
type: 'fleet',
clusterId: f.properties?.clusterId,
name: f.properties?.name,
});
}
}
for (const f of gearFc.features) {
if (pointInPolygon(point, f.geometry)) {
results.push({
type: 'gear',
name: f.properties?.name,
gearCount: f.properties?.gearCount,
inZone: f.properties?.inZone === 1,
});
}
}
return results;
}
// ── Hook ──────────────────────────────────────────────────────────────────────
/**
* Converts FleetClusterGeoJsonResult (produced by useFleetClusterGeoJson) into
* deck.gl Layer instances.
*
* Uses useMemo — fleet data changes infrequently (every 5 minutes) and on user
* interaction (hover, select). No Zustand subscribe pattern needed.
*/
export function useFleetClusterDeckLayers(
geo: FleetClusterGeoJsonResult | null,
config: FleetClusterDeckConfig,
): Layer[] {
const {
selectedGearGroup,
hoveredMmsi,
hoveredGearGroup,
enabledModels,
historyActive,
zoomScale,
zoomLevel,
fontScale: fs = 1,
onPolygonClick,
onPolygonHover,
} = config;
const focusMode = config.focusMode ?? false;
return useMemo((): Layer[] => {
if (!geo || focusMode) return [];
const layers: Layer[] = [];
// ── 1. Fleet polygons (fleetPolygonGeoJSON) ──────────────────────────────
const fleetPoly = geo.fleetPolygonGeoJSON as GeoJSON.FeatureCollection;
if (fleetPoly.features.length > 0) {
layers.push(new GeoJsonLayer({
id: 'fleet-polygons',
data: fleetPoly,
getFillColor: (f: GeoJSON.Feature) =>
hexToRgba(f.properties?.color ?? '#63b3ed', 25),
getLineColor: (f: GeoJSON.Feature) =>
hexToRgba(f.properties?.color ?? '#63b3ed', 128),
getLineWidth: 1.5,
lineWidthUnits: 'pixels',
filled: true,
stroked: true,
pickable: true,
onHover: (info) => {
if (info.object) {
const f = info.object as GeoJSON.Feature;
const cid = f.properties?.clusterId;
if (cid != null) {
onPolygonHover?.({ lng: info.coordinate![0], lat: info.coordinate![1], type: 'fleet', id: cid });
}
} else {
onPolygonHover?.(null);
}
},
onClick: (info) => {
if (!info.object || !info.coordinate || !onPolygonClick) return;
const pt: [number, number] = [info.coordinate[0], info.coordinate[1]];
onPolygonClick(findPolygonsAtPoint(pt, fleetPoly, gearFc), pt);
},
updateTriggers: {},
}));
}
// ── 2. Hovered fleet highlight (hoveredGeoJSON) ──────────────────────────
const hoveredPoly = geo.hoveredGeoJSON as GeoJSON.FeatureCollection;
if (hoveredPoly.features.length > 0) {
layers.push(new GeoJsonLayer({
id: 'fleet-hover-highlight',
data: hoveredPoly,
getFillColor: (f: GeoJSON.Feature) =>
hexToRgba(f.properties?.color ?? '#63b3ed', 64),
getLineColor: (f: GeoJSON.Feature) =>
hexToRgba(f.properties?.color ?? '#63b3ed', 200),
getLineWidth: 2,
lineWidthUnits: 'pixels',
filled: true,
stroked: true,
pickable: false,
}));
}
// ── 3. Fleet 2-ship lines (lineGeoJSON) ──────────────────────────────────
// Currently always empty (server handles 2-ship fleets as Polygon), kept for future
const lineFc = geo.lineGeoJSON as GeoJSON.FeatureCollection;
if (lineFc.features.length > 0) {
layers.push(new GeoJsonLayer({
id: 'fleet-lines',
data: lineFc,
getLineColor: (f: GeoJSON.Feature) =>
hexToRgba(f.properties?.color ?? '#63b3ed', 180),
getLineWidth: 1.5,
lineWidthUnits: 'pixels',
filled: false,
stroked: true,
pickable: false,
}));
}
// ── 4. Gear cluster polygons (gearClusterGeoJson) ────────────────────────
const gearFc = geo.gearClusterGeoJson as GeoJSON.FeatureCollection;
if (gearFc.features.length > 0) {
layers.push(new GeoJsonLayer({
id: 'gear-cluster-polygons',
data: gearFc,
getFillColor: (f: GeoJSON.Feature) =>
f.properties?.inZone === 1 ? GEAR_IN_ZONE_FILL : GEAR_OUT_ZONE_FILL,
getLineColor: (f: GeoJSON.Feature) =>
f.properties?.inZone === 1 ? GEAR_IN_ZONE_LINE : GEAR_OUT_ZONE_LINE,
getLineWidth: 1.5,
lineWidthUnits: 'pixels',
filled: true,
stroked: true,
pickable: true,
onHover: (info) => {
if (info.object) {
const f = info.object as GeoJSON.Feature;
const name = f.properties?.name;
if (name) {
onPolygonHover?.({ lng: info.coordinate![0], lat: info.coordinate![1], type: 'gear', id: name });
}
} else {
onPolygonHover?.(null);
}
},
onClick: (info) => {
if (!info.object || !info.coordinate || !onPolygonClick) return;
const pt: [number, number] = [info.coordinate[0], info.coordinate[1]];
onPolygonClick(findPolygonsAtPoint(pt, fleetPoly, gearFc), pt);
},
}));
}
// ── 4b. Gear hover highlight ──────────────────────────────────────────
if (hoveredGearGroup && gearFc.features.length > 0) {
const hoveredGearFeatures = gearFc.features.filter(
f => f.properties?.name === hoveredGearGroup,
);
if (hoveredGearFeatures.length > 0) {
layers.push(new GeoJsonLayer({
id: 'gear-hover-highlight',
data: { type: 'FeatureCollection' as const, features: hoveredGearFeatures },
getFillColor: (f: GeoJSON.Feature) =>
f.properties?.inZone === 1 ? [220, 38, 38, 64] : [249, 115, 22, 64],
getLineColor: (f: GeoJSON.Feature) =>
f.properties?.inZone === 1 ? [220, 38, 38, 255] : [249, 115, 22, 255],
getLineWidth: 2.5,
lineWidthUnits: 'pixels',
filled: true,
stroked: true,
pickable: false,
}));
}
}
// ── 5. Selected gear highlight (selectedGearHighlightGeoJson) ────────────
if (geo.selectedGearHighlightGeoJson && geo.selectedGearHighlightGeoJson.features.length > 0) {
layers.push(new GeoJsonLayer({
id: 'gear-selected-highlight',
data: geo.selectedGearHighlightGeoJson,
getFillColor: [249, 115, 22, 40],
getLineColor: [249, 115, 22, 230],
getLineWidth: 2,
lineWidthUnits: 'pixels',
filled: true,
stroked: true,
pickable: false,
}));
}
// ── 6. Member markers (memberMarkersGeoJson) — skip when historyActive ───
if (!historyActive) {
const memberFc = geo.memberMarkersGeoJson as GeoJSON.FeatureCollection;
if (memberFc.features.length > 0) {
layers.push(new IconLayer<GeoJSON.Feature>({
id: 'fleet-member-icons',
data: memberFc.features,
getPosition: (f: GeoJSON.Feature) =>
(f.geometry as GeoJSON.Point).coordinates as [number, number],
getIcon: (f: GeoJSON.Feature) =>
f.properties?.isGear === 1
? SHIP_ICON_MAPPING['gear-diamond']
: SHIP_ICON_MAPPING['ship-triangle'],
getSize: (f: GeoJSON.Feature) =>
(f.properties?.baseSize ?? 0.14) * zoomScale * ICON_PX,
getAngle: (f: GeoJSON.Feature) =>
f.properties?.isGear === 1 ? 0 : -(f.properties?.cog ?? 0),
getColor: (f: GeoJSON.Feature) =>
hexToRgba(f.properties?.color ?? '#9e9e9e'),
sizeUnits: 'pixels',
sizeMinPixels: 3,
billboard: false,
pickable: false,
updateTriggers: {
getSize: [zoomScale, fs],
},
}));
const clusteredMembers = clusterLabels(
memberFc.features,
f => (f.geometry as GeoJSON.Point).coordinates as [number, number],
zoomLevel,
);
layers.push(new TextLayer<GeoJSON.Feature>({
id: 'fleet-member-labels',
data: clusteredMembers,
getPosition: (f: GeoJSON.Feature) =>
(f.geometry as GeoJSON.Point).coordinates as [number, number],
getText: (f: GeoJSON.Feature) => {
const isParent = f.properties?.isParent === 1;
return isParent ? `\u2605 ${f.properties?.name ?? ''}` : (f.properties?.name ?? '');
},
getSize: 8 * zoomScale * fs,
getColor: (f: GeoJSON.Feature) =>
hexToRgba(f.properties?.color ?? '#e2e8f0'),
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: [0, 10],
fontFamily: FONT_MONO,
background: true,
getBackgroundColor: [0, 0, 0, 200],
backgroundPadding: [3, 1],
billboard: false,
characterSet: 'auto',
updateTriggers: {
getSize: [zoomScale, fs],
},
}));
}
}
// ── 7. Picker highlight (pickerHighlightGeoJson) ──────────────────────────
const pickerFc = geo.pickerHighlightGeoJson as GeoJSON.FeatureCollection;
if (pickerFc.features.length > 0) {
layers.push(new GeoJsonLayer({
id: 'fleet-picker-highlight',
data: pickerFc,
getFillColor: [255, 255, 255, 25],
getLineColor: [255, 255, 255, 200],
getLineWidth: 2,
lineWidthUnits: 'pixels',
filled: true,
stroked: true,
pickable: false,
}));
}
// ── Correlation layers (only when gear group selected, skip during replay) ─
if (selectedGearGroup && !historyActive) {
// ── 8. Operational polygons (per model) ────────────────────────────────
for (const op of geo.operationalPolygons) {
if (!enabledModels.has(op.modelName)) continue;
if (op.geojson.features.length === 0) continue;
const modelColor = MODEL_COLORS[op.modelName] ?? '#94a3b8';
layers.push(new GeoJsonLayer({
id: `fleet-op-polygon-${op.modelName}`,
data: op.geojson,
getFillColor: hexToRgba(modelColor, 30),
getLineColor: hexToRgba(modelColor, 180),
getLineWidth: 1.5,
lineWidthUnits: 'pixels',
filled: true,
stroked: true,
pickable: false,
}));
}
// ── 9. Correlation trails (correlationTrailGeoJson) ────────────────────
const trailFc = geo.correlationTrailGeoJson as GeoJSON.FeatureCollection;
if (trailFc.features.length > 0) {
layers.push(new GeoJsonLayer({
id: 'fleet-correlation-trails',
data: trailFc,
getLineColor: (f: GeoJSON.Feature) =>
hexToRgba(f.properties?.color ?? '#60a5fa', 160),
getLineWidth: 1.5,
lineWidthUnits: 'pixels',
filled: false,
stroked: true,
pickable: false,
}));
}
// ── 10. Correlation vessels (correlationVesselGeoJson) ─────────────────
const corrVesselFc = geo.correlationVesselGeoJson as GeoJSON.FeatureCollection;
if (corrVesselFc.features.length > 0) {
layers.push(new IconLayer<GeoJSON.Feature>({
id: 'fleet-correlation-vessel-icons',
data: corrVesselFc.features,
getPosition: (f: GeoJSON.Feature) =>
(f.geometry as GeoJSON.Point).coordinates as [number, number],
getIcon: () => SHIP_ICON_MAPPING['ship-triangle'],
getSize: () =>
0.14 * zoomScale * ICON_PX,
getAngle: (f: GeoJSON.Feature) => -(f.properties?.cog ?? 0),
getColor: (f: GeoJSON.Feature) =>
hexToRgba(f.properties?.color ?? '#60a5fa'),
sizeUnits: 'pixels',
sizeMinPixels: 3,
billboard: false,
pickable: false,
updateTriggers: {
getSize: [zoomScale, fs],
},
}));
const clusteredCorr = clusterLabels(
corrVesselFc.features,
f => (f.geometry as GeoJSON.Point).coordinates as [number, number],
zoomLevel,
);
layers.push(new TextLayer<GeoJSON.Feature>({
id: 'fleet-correlation-vessel-labels',
data: clusteredCorr,
getPosition: (f: GeoJSON.Feature) =>
(f.geometry as GeoJSON.Point).coordinates as [number, number],
getText: (f: GeoJSON.Feature) => f.properties?.name ?? '',
getSize: 8 * zoomScale * fs,
getColor: (f: GeoJSON.Feature) =>
hexToRgba(f.properties?.color ?? '#60a5fa'),
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: [0, 10],
fontFamily: FONT_MONO,
background: true,
getBackgroundColor: [0, 0, 0, 200],
backgroundPadding: [3, 1],
billboard: false,
characterSet: 'auto',
updateTriggers: {
getSize: [zoomScale, fs],
},
}));
}
// ── 11. Model badges (modelBadgesGeoJson) ─────────────────────────────
// Rendered as small ScatterplotLayer dots, one layer per active model.
// Position is offset in world coordinates (small lng offset per model index).
// Badge size is intentionally small (4px) as visual indicators only.
const badgeFc = geo.modelBadgesGeoJson as GeoJSON.FeatureCollection;
if (badgeFc.features.length > 0) {
MODEL_ORDER.forEach((modelName, i) => {
if (!enabledModels.has(modelName)) return;
const modelColor = MODEL_COLORS[modelName] ?? '#94a3b8';
const activeFeatures = badgeFc.features.filter(
(f) => f.properties?.[`m${i}`] === 1,
);
if (activeFeatures.length === 0) return;
// Small lng offset per model index to avoid overlap (≈ 300m at z10)
const lngOffset = i * 0.003;
layers.push(new ScatterplotLayer<GeoJSON.Feature>({
id: `fleet-model-badge-${modelName}`,
data: activeFeatures,
getPosition: (f: GeoJSON.Feature) => {
const [lng, lat] = (f.geometry as GeoJSON.Point).coordinates;
return [lng + lngOffset, lat] as [number, number];
},
getRadius: 4,
getFillColor: hexToRgba(modelColor, 230),
getLineColor: [0, 0, 0, 200],
getLineWidth: 1,
stroked: true,
filled: true,
radiusUnits: 'pixels',
lineWidthUnits: 'pixels',
pickable: false,
}));
});
}
// ── 12. Hover highlight (hoverHighlightGeoJson + trail) ───────────────
if (hoveredMmsi) {
const hoverFc = geo.hoverHighlightGeoJson as GeoJSON.FeatureCollection;
if (hoverFc.features.length > 0) {
layers.push(new ScatterplotLayer<GeoJSON.Feature>({
id: 'fleet-hover-ring',
data: hoverFc.features,
getPosition: (f: GeoJSON.Feature) =>
(f.geometry as GeoJSON.Point).coordinates as [number, number],
getRadius: 18,
getFillColor: [255, 255, 255, 20],
getLineColor: [255, 255, 255, 200],
getLineWidth: 2,
stroked: true,
filled: true,
radiusUnits: 'pixels',
lineWidthUnits: 'pixels',
pickable: false,
}));
}
const hoverTrailFc = geo.hoverHighlightTrailGeoJson as GeoJSON.FeatureCollection;
if (hoverTrailFc.features.length > 0) {
layers.push(new GeoJsonLayer({
id: 'fleet-hover-trail',
data: hoverTrailFc,
getLineColor: [255, 255, 255, 150],
getLineWidth: 1.5,
lineWidthUnits: 'pixels',
filled: false,
stroked: true,
pickable: false,
}));
}
}
}
return layers;
}, [
geo,
selectedGearGroup,
hoveredMmsi,
hoveredGearGroup,
enabledModels,
historyActive,
zoomScale,
zoomLevel,
fs,
focusMode,
onPolygonClick,
onPolygonHover,
]);
}