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; 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(); 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({ 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({ 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({ 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({ 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({ 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({ 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, ]); }