perf: 렌더링 성능 최적화 — deck.gl updateTriggers + 선박 토글 MapLibre filter 전환
- deck.gl updateTriggers 적용: 정적 레이어(4개 sub-hook) + 분석 레이어 + KoreaMap 인라인 레이어 → 줌 변경 시 accessor 재평가 최소화 - 선박 카테고리/국적 토글: JS-level 배열 필터링 → MapLibre GPU-side filter 표현식 → 토글 시 13K GeoJSON 재생성 + GPU 재업로드 제거 - Ship.mtCategory/natGroup 사전 계산: propagateShips 후 1회 계산, 이후 Set.has() O(1) → getMarineTrafficCategory() 13K×N회 호출 제거 - onPick useCallback 안정화: 매 렌더마다 28개 정적 레이어 불필요 재생성 방지 - SVG 데이터 URI 모듈 레벨 캐싱: 함수 호출 간 캐시 유지 - useAnalysisDeckLayers 데이터/스타일 분리: 줌 변경 시 ships 필터링 스킵 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
f4ee67a71a
커밋
13427f32bb
@ -229,7 +229,7 @@ export const KoreaDashboard = ({
|
||||
)}
|
||||
<KoreaMap
|
||||
ships={koreaFiltersResult.filteredShips}
|
||||
allShips={koreaData.visibleShips}
|
||||
allShips={koreaData.ships}
|
||||
aircraft={koreaData.visibleAircraft}
|
||||
satellites={koreaData.satPositions}
|
||||
layers={koreaLayers}
|
||||
@ -241,6 +241,8 @@ export const KoreaDashboard = ({
|
||||
dokdoWatchSuspects={koreaFiltersResult.dokdoWatchSuspects}
|
||||
dokdoAlerts={koreaFiltersResult.dokdoAlerts}
|
||||
vesselAnalysis={vesselAnalysis}
|
||||
hiddenShipCategories={hiddenShipCategories}
|
||||
hiddenNationalities={hiddenNationalities}
|
||||
/>
|
||||
<div className="map-overlay-left">
|
||||
<LayerPanel
|
||||
|
||||
@ -61,6 +61,8 @@ interface Props {
|
||||
dokdoWatchSuspects: Set<string>;
|
||||
dokdoAlerts: { mmsi: string; name: string; dist: number; time: number }[];
|
||||
vesselAnalysis?: UseVesselAnalysisResult;
|
||||
hiddenShipCategories?: Set<string>;
|
||||
hiddenNationalities?: Set<string>;
|
||||
}
|
||||
|
||||
// MarineTraffic-style: satellite + dark ocean + nautical overlay
|
||||
@ -133,7 +135,7 @@ const FILTER_I18N_KEY: Record<string, string> = {
|
||||
ferryWatch: 'filters.ferryWatchMonitor',
|
||||
};
|
||||
|
||||
export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis }: Props) {
|
||||
export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis, hiddenShipCategories, hiddenNationalities }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const mapRef = useRef<MapRef>(null);
|
||||
const [infra, setInfra] = useState<PowerFacility[]>([]);
|
||||
@ -152,6 +154,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
}
|
||||
}, []);
|
||||
const [staticPickInfo, setStaticPickInfo] = useState<StaticPickInfo | null>(null);
|
||||
const handleStaticPick = useCallback((info: StaticPickInfo) => setStaticPickInfo(info), []);
|
||||
const [analysisPanelOpen, setAnalysisPanelOpen] = useLocalStorage('analysisPanelOpen', false);
|
||||
|
||||
useEffect(() => {
|
||||
@ -224,6 +227,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
filled: true,
|
||||
radiusUnits: 'meters',
|
||||
lineWidthUnits: 'pixels',
|
||||
updateTriggers: { getRadius: [zoomScale] },
|
||||
}), [illegalFishingData, zoomScale]);
|
||||
|
||||
const illegalFishingLabelLayer = useMemo(() => new TextLayer({
|
||||
@ -241,6 +245,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
updateTriggers: { getSize: [zoomScale] },
|
||||
}), [illegalFishingData, zoomScale]);
|
||||
|
||||
// 수역 라벨 TextLayer — illegalFishing 필터 활성 시만 표시
|
||||
@ -277,6 +282,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
updateTriggers: { getSize: [zoomScale] },
|
||||
});
|
||||
}, [koreaFilters.illegalFishing, zoomScale]);
|
||||
|
||||
@ -309,7 +315,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
cnMilitary: !!layers.cnMilitary,
|
||||
jpPower: !!layers.jpPower,
|
||||
jpMilitary: !!layers.jpMilitary,
|
||||
onPick: (info) => setStaticPickInfo(info),
|
||||
onPick: handleStaticPick,
|
||||
sizeScale: zoomScale,
|
||||
});
|
||||
|
||||
@ -332,6 +338,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
radiusUnits: 'pixels',
|
||||
lineWidthUnits: 'pixels',
|
||||
getLineWidth: 1.5,
|
||||
updateTriggers: { getRadius: [zoomScale] },
|
||||
}));
|
||||
|
||||
// 어구 이름 라벨
|
||||
@ -350,6 +357,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
outlineColor: [0, 0, 0, 220],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
updateTriggers: { getSize: [zoomScale] },
|
||||
}));
|
||||
|
||||
// 모선 강조 — 큰 원 + 라벨
|
||||
@ -366,6 +374,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
radiusUnits: 'pixels',
|
||||
lineWidthUnits: 'pixels',
|
||||
getLineWidth: 3,
|
||||
updateTriggers: { getRadius: [zoomScale] },
|
||||
}));
|
||||
layers.push(new TextLayer({
|
||||
id: 'selected-gear-parent-label',
|
||||
@ -383,6 +392,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
outlineColor: [0, 0, 0, 220],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
updateTriggers: { getSize: [zoomScale] },
|
||||
}));
|
||||
}
|
||||
|
||||
@ -421,6 +431,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
radiusUnits: 'pixels',
|
||||
lineWidthUnits: 'pixels',
|
||||
getLineWidth: 2,
|
||||
updateTriggers: { getRadius: [zoomScale] },
|
||||
}));
|
||||
|
||||
// 소속 선박 이름 라벨
|
||||
@ -445,6 +456,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
outlineColor: [0, 0, 0, 220],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
updateTriggers: { getSize: [zoomScale], getText: [vesselAnalysis] },
|
||||
}));
|
||||
|
||||
// 리더 선박 추가 강조 (큰 외곽 링)
|
||||
@ -465,6 +477,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
radiusUnits: 'pixels',
|
||||
lineWidthUnits: 'pixels',
|
||||
getLineWidth: 3,
|
||||
updateTriggers: { getRadius: [zoomScale] },
|
||||
}));
|
||||
}
|
||||
|
||||
@ -555,7 +568,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} analysisMap={vesselAnalysis?.analysisMap} />}
|
||||
{layers.ships && <ShipLayer ships={allShips ?? ships} militaryOnly={layers.militaryOnly} analysisMap={vesselAnalysis?.analysisMap} hiddenShipCategories={hiddenShipCategories} hiddenNationalities={hiddenNationalities} />}
|
||||
{/* Transship suspect labels — Marker DOM, inline styles kept for dynamic border color */}
|
||||
{transshipSuspects.size > 0 && ships.filter(s => transshipSuspects.has(s.mmsi)).map(s => (
|
||||
<Marker key={`ts-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="bottom">
|
||||
|
||||
@ -3,7 +3,9 @@ import { Marker, Popup, Source, Layer, useMap } from 'react-map-gl/maplibre';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Ship, VesselAnalysisDto } from '../../types';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import { MT_TYPE_HEX, getMTType, NAVY_COLORS, FLAG_EMOJI, SIZE_MAP, isMilitary, getShipColor } from '../../utils/shipClassification';
|
||||
import { MT_TYPE_COLORS, MT_TYPE_HEX, getMTType, NAVY_COLORS, FLAG_EMOJI, SIZE_MAP, isMilitary, getShipColor } from '../../utils/shipClassification';
|
||||
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
||||
import { getNationalityGroup } from '../../hooks/useKoreaData';
|
||||
|
||||
interface Props {
|
||||
ships: Ship[];
|
||||
@ -13,6 +15,8 @@ interface Props {
|
||||
focusMmsi?: string | null;
|
||||
onFocusClear?: () => void;
|
||||
analysisMap?: Map<string, VesselAnalysisDto>;
|
||||
hiddenShipCategories?: Set<string>;
|
||||
hiddenNationalities?: Set<string>;
|
||||
}
|
||||
|
||||
|
||||
@ -268,7 +272,7 @@ function ensureTriangleImage(map: maplibregl.Map) {
|
||||
}
|
||||
|
||||
// ── Main layer (WebGL symbol rendering — triangles) ──
|
||||
export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusMmsi, onFocusClear, analysisMap }: Props) {
|
||||
export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusMmsi, onFocusClear, analysisMap, hiddenShipCategories, hiddenNationalities }: Props) {
|
||||
const { current: map } = useMap();
|
||||
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
|
||||
const [imageReady, setImageReady] = useState(false);
|
||||
@ -283,12 +287,6 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
||||
}
|
||||
}, [focusMmsi, onFocusClear]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let result = ships;
|
||||
if (militaryOnly) result = result.filter(s => isMilitary(s.category));
|
||||
return result;
|
||||
}, [ships, militaryOnly]);
|
||||
|
||||
// Add triangle image to map
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
@ -302,9 +300,9 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
||||
return () => { m.off('load', addIcon); };
|
||||
}, [map]);
|
||||
|
||||
// Build GeoJSON for all ships
|
||||
// Build GeoJSON from ALL ships (category/nationality filtering is GPU-side via MapLibre filter)
|
||||
const shipGeoJson = useMemo(() => {
|
||||
const features: GeoJSON.Feature[] = filtered.map(ship => ({
|
||||
const features: GeoJSON.Feature[] = ships.map(ship => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
mmsi: ship.mmsi,
|
||||
@ -315,6 +313,8 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
||||
isKorean: ship.flag === 'KR' ? 1 : 0,
|
||||
isCheonghae: ship.mmsi === '440001981' ? 1 : 0,
|
||||
heading: ship.heading,
|
||||
mtCategory: getMarineTrafficCategory(ship.typecode, ship.category),
|
||||
natGroup: getNationalityGroup(ship.flag),
|
||||
},
|
||||
geometry: {
|
||||
type: 'Point' as const,
|
||||
@ -322,7 +322,25 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
||||
},
|
||||
}));
|
||||
return { type: 'FeatureCollection' as const, features };
|
||||
}, [filtered]);
|
||||
}, [ships]);
|
||||
|
||||
// MapLibre filter expression — GPU-side category/nationality/military filtering (no GeoJSON rebuild on toggle)
|
||||
type FilterExpr = (string | number | string[] | FilterExpr)[];
|
||||
const shipVisibilityFilter = useMemo((): FilterExpr => {
|
||||
const conditions: FilterExpr[] = [];
|
||||
if (militaryOnly) {
|
||||
conditions.push(['==', ['get', 'isMil'], 1]);
|
||||
}
|
||||
if (hiddenShipCategories && hiddenShipCategories.size > 0) {
|
||||
conditions.push(['!', ['in', ['get', 'mtCategory'], ['literal', [...hiddenShipCategories]]]]);
|
||||
}
|
||||
if (hiddenNationalities && hiddenNationalities.size > 0) {
|
||||
conditions.push(['!', ['in', ['get', 'natGroup'], ['literal', [...hiddenNationalities]]]]);
|
||||
}
|
||||
if (conditions.length === 0) return ['has', 'mmsi'];
|
||||
if (conditions.length === 1) return conditions[0];
|
||||
return ['all', ...conditions];
|
||||
}, [militaryOnly, hiddenShipCategories, hiddenNationalities]);
|
||||
|
||||
// hoveredMmsi 변경 시 feature-state로 hover 표시 (GeoJSON 재생성 없이)
|
||||
useEffect(() => {
|
||||
@ -369,7 +387,7 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
||||
};
|
||||
}, [map]);
|
||||
|
||||
const selectedShip = selectedMmsi ? filtered.find(s => s.mmsi === selectedMmsi) ?? null : null;
|
||||
const selectedShip = selectedMmsi ? ships.find(s => s.mmsi === selectedMmsi) ?? null : null;
|
||||
|
||||
// Python 분석 결과 기반 선단 그룹 (cluster_id로 그룹핑)
|
||||
const selectedFleetMembers = useMemo(() => {
|
||||
@ -415,7 +433,7 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
||||
}, [selectedFleetMembers]);
|
||||
|
||||
// Carrier labels — only a few, so DOM markers are fine
|
||||
const carriers = useMemo(() => filtered.filter(s => s.category === 'carrier'), [filtered]);
|
||||
const carriers = useMemo(() => ships.filter(s => s.category === 'carrier'), [ships]);
|
||||
|
||||
|
||||
|
||||
@ -437,6 +455,7 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
||||
<Layer
|
||||
id="ships-hover-ring"
|
||||
type="circle"
|
||||
filter={shipVisibilityFilter}
|
||||
paint={{
|
||||
'circle-radius': ['case', ['boolean', ['feature-state', 'hovered'], false], 18, 0],
|
||||
'circle-color': 'rgba(255, 255, 255, 0.1)',
|
||||
@ -482,6 +501,7 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
||||
<Layer
|
||||
id="ships-triangles"
|
||||
type="symbol"
|
||||
filter={shipVisibilityFilter}
|
||||
layout={{
|
||||
'icon-image': 'ship-triangle',
|
||||
'icon-size': ['interpolate', ['linear'], ['zoom'],
|
||||
|
||||
@ -59,6 +59,10 @@ function infraSvg(f: PowerFacility): string {
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ─── Module-level icon caches ─────────────────────────────────────────────────
|
||||
|
||||
const infraIconCache = new Map<string, string>();
|
||||
|
||||
// ─── createFacilityLayers ─────────────────────────────────────────────────────
|
||||
|
||||
export function createFacilityLayers(
|
||||
@ -79,7 +83,6 @@ export function createFacilityLayers(
|
||||
|
||||
// ── Infra ──────────────────────────────────────────────────────────────
|
||||
if (config.infra && config.infraFacilities.length > 0) {
|
||||
const infraIconCache = new Map<string, string>();
|
||||
function getInfraIconUrl(f: PowerFacility): string {
|
||||
const key = `${f.type}-${f.source ?? ''}`;
|
||||
if (!infraIconCache.has(key)) {
|
||||
@ -98,6 +101,7 @@ export function createFacilityLayers(
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: (d) => ({ url: getInfraIconUrl(d), width: 14, height: 14, anchorX: 7, anchorY: 7 }),
|
||||
getSize: 7 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<PowerFacility>) => {
|
||||
if (info.object) onPick({ kind: 'infra', object: info.object });
|
||||
@ -110,6 +114,7 @@ export function createFacilityLayers(
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: (d) => ({ url: getInfraIconUrl(d), width: 24, height: 24, anchorX: 12, anchorY: 12 }),
|
||||
getSize: 12 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<PowerFacility>) => {
|
||||
if (info.object) onPick({ kind: 'infra', object: info.object });
|
||||
@ -122,6 +127,7 @@ export function createFacilityLayers(
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name),
|
||||
getSize: 8 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => [...hexToRgb(infraColor(d)), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
@ -161,6 +167,7 @@ export function createFacilityLayers(
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => HAZARD_META[d.type]?.icon ?? '⚠️',
|
||||
getSize: 16 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 255],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
@ -180,6 +187,7 @@ export function createFacilityLayers(
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo,
|
||||
getSize: 9 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 200],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
@ -217,6 +225,7 @@ export function createFacilityLayers(
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => CN_META[d.subType]?.icon ?? '📍',
|
||||
getSize: 16 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 255],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
@ -236,6 +245,7 @@ export function createFacilityLayers(
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.name,
|
||||
getSize: 9 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 200],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
@ -272,6 +282,7 @@ export function createFacilityLayers(
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => JP_META[d.subType]?.icon ?? '📍',
|
||||
getSize: 16 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 255],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
@ -291,6 +302,7 @@ export function createFacilityLayers(
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.name,
|
||||
getSize: 9 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 200],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
|
||||
@ -35,6 +35,11 @@ function missileImpactSvg(color: string): string {
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ─── Module-level icon caches ─────────────────────────────────────────────────
|
||||
|
||||
const launchIconCache = new Map<string, string>();
|
||||
const impactIconCache = new Map<string, string>();
|
||||
|
||||
export function createMilitaryLayers(
|
||||
config: { militaryBases: boolean; govBuildings: boolean; nkLaunch: boolean; nkMissile: boolean },
|
||||
fc: LayerFactoryConfig,
|
||||
@ -59,6 +64,7 @@ export function createMilitaryLayers(
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => TYPE_ICON[d.type] ?? '⭐',
|
||||
getSize: 14 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: [255, 255, 255, 220],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
@ -76,6 +82,7 @@ export function createMilitaryLayers(
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => (d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo),
|
||||
getSize: 8 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => [...hexToRgb(TYPE_COLOR[d.type] ?? '#a78bfa'), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
@ -107,6 +114,7 @@ export function createMilitaryLayers(
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => GOV_TYPE_ICON[d.type] ?? '🏛',
|
||||
getSize: 12 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: [255, 255, 255, 220],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
@ -124,6 +132,7 @@ export function createMilitaryLayers(
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo),
|
||||
getSize: 8 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => [...hexToRgb(GOV_TYPE_COLOR[d.type] ?? '#f59e0b'), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
@ -147,6 +156,7 @@ export function createMilitaryLayers(
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => NK_LAUNCH_TYPE_META[d.type]?.icon ?? '🚀',
|
||||
getSize: (d) => (d.type === 'artillery' || d.type === 'mlrs' ? 10 : 13) * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: [255, 255, 255, 220],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
@ -164,6 +174,7 @@ export function createMilitaryLayers(
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo),
|
||||
getSize: 8 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => [...hexToRgb(NK_LAUNCH_TYPE_META[d.type]?.color ?? '#f97316'), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
@ -180,14 +191,12 @@ export function createMilitaryLayers(
|
||||
|
||||
// ── NK Missile Events — IconLayer ─────────────────────────────────────
|
||||
if (config.nkMissile) {
|
||||
const launchIconCache = new Map<string, string>();
|
||||
function getLaunchIconUrl(type: string): string {
|
||||
if (!launchIconCache.has(type)) {
|
||||
launchIconCache.set(type, svgToDataUri(missileLaunchSvg(getMissileColor(type))));
|
||||
}
|
||||
return launchIconCache.get(type)!;
|
||||
}
|
||||
const impactIconCache = new Map<string, string>();
|
||||
function getImpactIconUrl(type: string): string {
|
||||
if (!impactIconCache.has(type)) {
|
||||
impactIconCache.set(type, svgToDataUri(missileImpactSvg(getMissileColor(type))));
|
||||
@ -227,6 +236,7 @@ export function createMilitaryLayers(
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: (d) => ({ url: getLaunchIconUrl(d.ev.type), width: 24, height: 24, anchorX: 12, anchorY: 12 }),
|
||||
getSize: 12 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => {
|
||||
const today = new Date().toISOString().slice(0, 10) === d.ev.date;
|
||||
return [255, 255, 255, today ? 255 : 90] as [number, number, number, number];
|
||||
@ -238,6 +248,7 @@ export function createMilitaryLayers(
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: (d) => ({ url: getImpactIconUrl(d.ev.type), width: 32, height: 32, anchorX: 16, anchorY: 16 }),
|
||||
getSize: 16 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => {
|
||||
const today = new Date().toISOString().slice(0, 10) === d.ev.date;
|
||||
return [255, 255, 255, today ? 255 : 100] as [number, number, number, number];
|
||||
@ -254,6 +265,7 @@ export function createMilitaryLayers(
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => `${d.ev.date.slice(5)} ${d.ev.time} ← ${d.ev.launchNameKo}`,
|
||||
getSize: 8 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => [...hexToRgb(getMissileColor(d.ev.type)), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
|
||||
@ -123,6 +123,13 @@ function piracySvg(color: string, size: number): string {
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ─── Module-level icon caches ─────────────────────────────────────────────────
|
||||
|
||||
const cgIconCache = new Map<CoastGuardType, string>();
|
||||
const apIconCache = new Map<string, string>();
|
||||
const nwIconCache = new Map<string, string>();
|
||||
const piracyIconCache = new Map<string, string>();
|
||||
|
||||
export function createNavigationLayers(
|
||||
config: { coastGuard: boolean; airports: boolean; navWarning: boolean; piracy: boolean },
|
||||
fc: LayerFactoryConfig,
|
||||
@ -133,7 +140,6 @@ export function createNavigationLayers(
|
||||
|
||||
// ── Coast Guard ────────────────────────────────────────────────────────
|
||||
if (config.coastGuard) {
|
||||
const cgIconCache = new Map<CoastGuardType, string>();
|
||||
function getCgIconUrl(type: CoastGuardType): string {
|
||||
if (!cgIconCache.has(type)) {
|
||||
const size = CG_TYPE_SIZE[type];
|
||||
@ -152,6 +158,7 @@ export function createNavigationLayers(
|
||||
return { url: getCgIconUrl(d.type), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 };
|
||||
},
|
||||
getSize: (d) => CG_TYPE_SIZE[d.type] * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<CoastGuardFacility>) => {
|
||||
if (info.object) onPick({ kind: 'coastGuard', object: info.object });
|
||||
@ -168,6 +175,7 @@ export function createNavigationLayers(
|
||||
return d.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청';
|
||||
},
|
||||
getSize: 8 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => [...hexToRgb(CG_TYPE_COLOR[d.type]), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
@ -184,7 +192,6 @@ export function createNavigationLayers(
|
||||
|
||||
// ── Airports ───────────────────────────────────────────────────────────
|
||||
if (config.airports) {
|
||||
const apIconCache = new Map<string, string>();
|
||||
function getApIconUrl(ap: KoreanAirport): string {
|
||||
const color = apColor(ap);
|
||||
const size = ap.intl ? 40 : 32;
|
||||
@ -205,6 +212,7 @@ export function createNavigationLayers(
|
||||
return { url: getApIconUrl(d), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 };
|
||||
},
|
||||
getSize: (d) => (d.intl ? 20 : 16) * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<KoreanAirport>) => {
|
||||
if (info.object) onPick({ kind: 'airport', object: info.object });
|
||||
@ -217,6 +225,7 @@ export function createNavigationLayers(
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.nameKo.replace('국제공항', '').replace('공항', ''),
|
||||
getSize: 9 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => [...hexToRgb(apColor(d)), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
@ -233,7 +242,6 @@ export function createNavigationLayers(
|
||||
|
||||
// ── NavWarning ─────────────────────────────────────────────────────────
|
||||
if (config.navWarning) {
|
||||
const nwIconCache = new Map<string, string>();
|
||||
function getNwIconUrl(w: NavWarning): string {
|
||||
const key = `${w.level}-${w.org}`;
|
||||
if (!nwIconCache.has(key)) {
|
||||
@ -253,6 +261,7 @@ export function createNavigationLayers(
|
||||
return { url: getNwIconUrl(d), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 };
|
||||
},
|
||||
getSize: (d) => (d.level === 'danger' ? 16 : 14) * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<NavWarning>) => {
|
||||
if (info.object) onPick({ kind: 'navWarning', object: info.object });
|
||||
@ -265,6 +274,7 @@ export function createNavigationLayers(
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.id,
|
||||
getSize: 8 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => [...hexToRgb(NW_ORG_COLOR[d.org]), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
@ -281,7 +291,6 @@ export function createNavigationLayers(
|
||||
|
||||
// ── Piracy ─────────────────────────────────────────────────────────────
|
||||
if (config.piracy) {
|
||||
const piracyIconCache = new Map<string, string>();
|
||||
function getPiracyIconUrl(zone: PiracyZone): string {
|
||||
const key = zone.level;
|
||||
if (!piracyIconCache.has(key)) {
|
||||
@ -302,6 +311,7 @@ export function createNavigationLayers(
|
||||
return { url: getPiracyIconUrl(d), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 };
|
||||
},
|
||||
getSize: (d) => (d.level === 'critical' ? 28 : d.level === 'high' ? 24 : 20) * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<PiracyZone>) => {
|
||||
if (info.object) onPick({ kind: 'piracy', object: info.object });
|
||||
@ -314,6 +324,7 @@ export function createNavigationLayers(
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.nameKo,
|
||||
getSize: 9 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => [...hexToRgb(PIRACY_LEVEL_COLOR[d.level] ?? '#ef4444'), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
|
||||
@ -45,6 +45,11 @@ function windTurbineSvg(size: number): string {
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ─── Module-level icon caches ─────────────────────────────────────────────────
|
||||
|
||||
const portIconCache = new Map<string, string>();
|
||||
const WIND_ICON_URL = svgToDataUri(windTurbineSvg(36));
|
||||
|
||||
export function createPortLayers(
|
||||
config: { ports: boolean; windFarm: boolean },
|
||||
fc: LayerFactoryConfig,
|
||||
@ -55,7 +60,6 @@ export function createPortLayers(
|
||||
|
||||
// ── Ports ───────────────────────────────────────────────────────────────
|
||||
if (config.ports) {
|
||||
const portIconCache = new Map<string, string>();
|
||||
function getPortIconUrl(p: Port): string {
|
||||
const key = `${p.country}-${p.type}`;
|
||||
if (!portIconCache.has(key)) {
|
||||
@ -79,6 +83,7 @@ export function createPortLayers(
|
||||
anchorY: d.type === 'major' ? 16 : 12,
|
||||
}),
|
||||
getSize: (d) => (d.type === 'major' ? 16 : 12) * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<Port>) => {
|
||||
if (info.object) onPick({ kind: 'port', object: info.object });
|
||||
@ -91,6 +96,7 @@ export function createPortLayers(
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.nameKo.replace('항', ''),
|
||||
getSize: 9 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => [...hexToRgb(PORT_COUNTRY_COLOR[d.country] ?? PORT_COUNTRY_COLOR.KR), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
@ -107,14 +113,14 @@ export function createPortLayers(
|
||||
|
||||
// ── Wind Farms ─────────────────────────────────────────────────────────
|
||||
if (config.windFarm) {
|
||||
const windUrl = svgToDataUri(windTurbineSvg(36));
|
||||
layers.push(
|
||||
new IconLayer<WindFarm>({
|
||||
id: 'static-windfarm-icon',
|
||||
data: KOREA_WIND_FARMS,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: () => ({ url: windUrl, width: 36, height: 36, anchorX: 18, anchorY: 18 }),
|
||||
getIcon: () => ({ url: WIND_ICON_URL, width: 36, height: 36, anchorX: 18, anchorY: 18 }),
|
||||
getSize: 18 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<WindFarm>) => {
|
||||
if (info.object) onPick({ kind: 'windFarm', object: info.object });
|
||||
@ -127,6 +133,7 @@ export function createPortLayers(
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name),
|
||||
getSize: 9 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: [...hexToRgb(WIND_COLOR), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
|
||||
@ -41,6 +41,12 @@ const RISK_PRIORITY: Record<string, number> = {
|
||||
MEDIUM: 2,
|
||||
};
|
||||
|
||||
interface AnalysisData {
|
||||
riskData: AnalyzedShip[];
|
||||
darkData: AnalyzedShip[];
|
||||
spoofData: AnalyzedShip[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 분석 결과 기반 deck.gl 레이어를 반환하는 훅.
|
||||
* AnalysisOverlay DOM Marker 대체 — GPU 렌더링으로 성능 향상.
|
||||
@ -51,8 +57,11 @@ export function useAnalysisDeckLayers(
|
||||
activeFilter: string | null,
|
||||
sizeScale: number = 1.0,
|
||||
): Layer[] {
|
||||
return useMemo(() => {
|
||||
if (analysisMap.size === 0) return [];
|
||||
// 데이터 준비: ships 필터/정렬/슬라이스 — sizeScale 변경 시 재실행 안 됨
|
||||
const { riskData, darkData, spoofData } = useMemo<AnalysisData>(() => {
|
||||
if (analysisMap.size === 0) {
|
||||
return { riskData: [], darkData: [], spoofData: [] };
|
||||
}
|
||||
|
||||
const analyzedShips: AnalyzedShip[] = ships
|
||||
.filter(s => analysisMap.has(s.mmsi))
|
||||
@ -70,6 +79,19 @@ export function useAnalysisDeckLayers(
|
||||
})
|
||||
.slice(0, 100);
|
||||
|
||||
const darkData = analyzedShips.filter(({ dto }) => dto.algorithms.darkVessel.isDark);
|
||||
|
||||
const spoofData = analyzedShips.filter(
|
||||
({ dto }) => dto.algorithms.gpsSpoofing.spoofingScore > 0.5,
|
||||
);
|
||||
|
||||
return { riskData, darkData, spoofData };
|
||||
}, [analysisMap, ships, activeFilter]);
|
||||
|
||||
// 레이어 생성: sizeScale 변경 시에만 재실행 (데이터 연산 없음)
|
||||
return useMemo<Layer[]>(() => {
|
||||
if (riskData.length === 0 && darkData.length === 0 && spoofData.length === 0) return [];
|
||||
|
||||
const layers: Layer[] = [];
|
||||
|
||||
// 위험도 원형 마커
|
||||
@ -86,6 +108,7 @@ export function useAnalysisDeckLayers(
|
||||
radiusUnits: 'pixels',
|
||||
lineWidthUnits: 'pixels',
|
||||
getLineWidth: 2,
|
||||
updateTriggers: { getRadius: [sizeScale] },
|
||||
}),
|
||||
);
|
||||
|
||||
@ -101,6 +124,7 @@ export function useAnalysisDeckLayers(
|
||||
return `${name}\n${label} ${d.dto.algorithms.riskScore.score}`;
|
||||
},
|
||||
getSize: 10 * sizeScale,
|
||||
updateTriggers: { getSize: [sizeScale] },
|
||||
getColor: (d) => RISK_RGBA_BORDER[d.dto.algorithms.riskScore.level] ?? [200, 200, 200, 255],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
@ -114,10 +138,7 @@ export function useAnalysisDeckLayers(
|
||||
);
|
||||
|
||||
// 다크베셀 (activeFilter === 'darkVessel' 일 때만)
|
||||
if (activeFilter === 'darkVessel') {
|
||||
const darkData = analyzedShips.filter(({ dto }) => dto.algorithms.darkVessel.isDark);
|
||||
|
||||
if (darkData.length > 0) {
|
||||
if (activeFilter === 'darkVessel' && darkData.length > 0) {
|
||||
layers.push(
|
||||
new ScatterplotLayer<AnalyzedShip>({
|
||||
id: 'dark-vessel-markers',
|
||||
@ -131,6 +152,7 @@ export function useAnalysisDeckLayers(
|
||||
radiusUnits: 'pixels',
|
||||
lineWidthUnits: 'pixels',
|
||||
getLineWidth: 2,
|
||||
updateTriggers: { getRadius: [sizeScale] },
|
||||
}),
|
||||
);
|
||||
|
||||
@ -145,6 +167,7 @@ export function useAnalysisDeckLayers(
|
||||
return gap > 0 ? `AIS 소실 ${Math.round(gap)}분` : 'DARK';
|
||||
},
|
||||
getSize: 10 * sizeScale,
|
||||
updateTriggers: { getSize: [sizeScale] },
|
||||
getColor: [168, 85, 247, 255],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
@ -157,10 +180,8 @@ export function useAnalysisDeckLayers(
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// GPS 스푸핑 라벨
|
||||
const spoofData = analyzedShips.filter(({ dto }) => dto.algorithms.gpsSpoofing.spoofingScore > 0.5);
|
||||
if (spoofData.length > 0) {
|
||||
layers.push(
|
||||
new TextLayer<AnalyzedShip>({
|
||||
@ -183,5 +204,5 @@ export function useAnalysisDeckLayers(
|
||||
}
|
||||
|
||||
return layers;
|
||||
}, [analysisMap, ships, activeFilter, sizeScale]);
|
||||
}, [riskData, darkData, spoofData, sizeScale, activeFilter]);
|
||||
}
|
||||
|
||||
@ -145,13 +145,17 @@ export function useKoreaData({
|
||||
// Propagate Korea aircraft (live only — no waypoint propagation needed)
|
||||
const aircraft = useMemo(() => propagateAircraft(baseAircraftKorea, currentTime), [baseAircraftKorea, currentTime]);
|
||||
|
||||
// Korea region ships
|
||||
const ships = useMemo(
|
||||
() => propagateShips(baseShipsKorea, currentTime, isLive),
|
||||
[baseShipsKorea, currentTime, isLive],
|
||||
);
|
||||
// Korea region ships — pre-compute mtCategory/natGroup for O(1) filter lookups
|
||||
const ships = useMemo(() => {
|
||||
const propagated = propagateShips(baseShipsKorea, currentTime, isLive);
|
||||
for (const s of propagated) {
|
||||
s.mtCategory = getMarineTrafficCategory(s.typecode, s.category);
|
||||
s.natGroup = getNationalityGroup(s.flag);
|
||||
}
|
||||
return propagated;
|
||||
}, [baseShipsKorea, currentTime, isLive]);
|
||||
|
||||
// Category-filtered data for map rendering
|
||||
// Category-filtered data for map rendering (Set.has = O(1) per ship)
|
||||
const visibleAircraft = useMemo(
|
||||
() => aircraft.filter(a => !hiddenAcCategories.has(a.category)),
|
||||
[aircraft, hiddenAcCategories],
|
||||
@ -159,8 +163,8 @@ export function useKoreaData({
|
||||
|
||||
const visibleShips = useMemo(
|
||||
() => ships.filter(s =>
|
||||
!hiddenShipCategories.has(getMarineTrafficCategory(s.typecode, s.category))
|
||||
&& !hiddenNationalities.has(getNationalityGroup(s.flag)),
|
||||
!hiddenShipCategories.has(s.mtCategory!)
|
||||
&& !hiddenNationalities.has(s.natGroup!),
|
||||
),
|
||||
[ships, hiddenShipCategories, hiddenNationalities],
|
||||
);
|
||||
@ -172,8 +176,7 @@ export function useKoreaData({
|
||||
const shipsByCategory = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const s of ships) {
|
||||
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
|
||||
counts[mtCat] = (counts[mtCat] || 0) + 1;
|
||||
counts[s.mtCategory!] = (counts[s.mtCategory!] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}, [ships]);
|
||||
@ -181,8 +184,7 @@ export function useKoreaData({
|
||||
const shipsByNationality = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const s of ships) {
|
||||
const nat = getNationalityGroup(s.flag);
|
||||
counts[nat] = (counts[nat] || 0) + 1;
|
||||
counts[s.natGroup!] = (counts[s.natGroup!] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}, [ships]);
|
||||
@ -190,7 +192,7 @@ export function useKoreaData({
|
||||
const fishingByNationality = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const s of ships) {
|
||||
if (getMarineTrafficCategory(s.typecode, s.category) !== 'fishing') continue;
|
||||
if (s.mtCategory !== 'fishing') continue;
|
||||
const flag = s.flag || 'unknown';
|
||||
const group = flag === 'CN' ? 'CN' : flag === 'KR' ? 'KR' : flag === 'JP' ? 'JP' : 'other';
|
||||
counts[group] = (counts[group] || 0) + 1;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState, useMemo, useRef } from 'react';
|
||||
import { useLocalStorage } from './useLocalStorage';
|
||||
import { KOREA_SUBMARINE_CABLES } from '../services/submarineCable';
|
||||
import { getMarineTrafficCategory } from '../utils/marineTraffic';
|
||||
// mtCategory는 Ship 객체에 사전 계산됨 (useKoreaData)
|
||||
import { classifyFishingZone } from '../utils/fishingAnalysis';
|
||||
import type { Ship, VesselAnalysisDto } from '../types';
|
||||
|
||||
@ -101,7 +101,7 @@ export function useKoreaFilters(
|
||||
|
||||
const candidates = koreaShips.filter(s => {
|
||||
if (s.speed >= 2) return false;
|
||||
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
|
||||
const mtCat = s.mtCategory;
|
||||
if (mtCat !== 'tanker' && mtCat !== 'cargo' && mtCat !== 'fishing') return false;
|
||||
if (isNearForeignCoast(s)) return false;
|
||||
return isOffshore(s);
|
||||
@ -310,10 +310,9 @@ export function useKoreaFilters(
|
||||
const filteredShips = useMemo(() => {
|
||||
if (!anyFilterOn) return visibleShips;
|
||||
return visibleShips.filter(s => {
|
||||
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
|
||||
if (filters.illegalFishing) {
|
||||
// 특정어업수역 Ⅰ~Ⅳ 내 비한국 어선만 불법어선으로 판별
|
||||
if (mtCat === 'fishing' && s.flag !== 'KR') {
|
||||
if (s.mtCategory === 'fishing' && s.flag !== 'KR') {
|
||||
const zoneInfo = classifyFishingZone(s.lat, s.lng);
|
||||
if (zoneInfo.zone !== 'OUTSIDE') return true;
|
||||
}
|
||||
@ -328,9 +327,9 @@ export function useKoreaFilters(
|
||||
if (filters.darkVessel && darkVesselSet.has(s.mmsi)) return true;
|
||||
if (filters.cableWatch && cableWatchSet.has(s.mmsi)) return true;
|
||||
if (filters.dokdoWatch && dokdoWatchSet.has(s.mmsi)) return true;
|
||||
if (filters.ferryWatch && getMarineTrafficCategory(s.typecode, s.category) === 'passenger') return true;
|
||||
if (filters.ferryWatch && s.mtCategory === 'passenger') return true;
|
||||
if (cnFishingOn) {
|
||||
const isCnFishing = s.flag === 'CN' && getMarineTrafficCategory(s.typecode, s.category) === 'fishing';
|
||||
const isCnFishing = s.flag === 'CN' && s.mtCategory === 'fishing';
|
||||
const isGearPattern = /^.+?_\d+_\d+_?$/.test(s.name || '');
|
||||
if (isCnFishing || isGearPattern) return true;
|
||||
}
|
||||
|
||||
@ -107,6 +107,8 @@ export interface Ship {
|
||||
activeEnd?: number; // unix ms - when ship leaves area
|
||||
shipImagePath?: string | null; // signal-batch image path
|
||||
shipImageCount?: number; // number of available images
|
||||
mtCategory?: string; // pre-computed getMarineTrafficCategory result (cached for O(1) filter)
|
||||
natGroup?: string; // pre-computed getNationalityGroup result (cached for O(1) filter)
|
||||
}
|
||||
|
||||
// Iran oil/gas facility
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user