kcg-monitoring/frontend/src/components/korea/FleetClusterLayer.tsx
htlee f0c991c9ec refactor: deck.gl 전면 전환 — DOM Marker → GPU 렌더링
- deck.gl 9.2 설치 + DeckGLOverlay(MapboxOverlay interleaved) 통합
- 정적 마커 11종 → useStaticDeckLayers (IconLayer/TextLayer, SVG DataURI)
- 분석 오버레이 → useAnalysisDeckLayers (ScatterplotLayer/TextLayer)
- 불법어선/어구/수역 라벨 → deck.gl ScatterplotLayer/TextLayer
- 줌 레벨별 스케일 (0~6: 0.6x, 7~9: 1.0x, 10~12: 1.4x, 13+: 1.8x)
- NK 미사일 궤적 PathLayer 추가 + 정적 마커 클릭 Popup
- 해저케이블 날짜변경선(180도) 좌표 보정
- 기존 DOM Marker 제거로 렌더링 성능 대폭 개선

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

775 lines
30 KiB
TypeScript

import { useState, useEffect, useMemo, useCallback } from 'react';
import { Source, Layer } from 'react-map-gl/maplibre';
import type { GeoJSON } from 'geojson';
import type { Ship, VesselAnalysisDto } from '../../types';
import { fetchFleetCompanies } from '../../services/vesselAnalysis';
import type { FleetCompany } from '../../services/vesselAnalysis';
export interface SelectedGearGroupData {
parent: Ship | null;
gears: Ship[];
groupName: string;
}
interface Props {
ships: Ship[];
analysisMap: Map<string, VesselAnalysisDto>;
clusters: Map<number, string[]>;
onShipSelect?: (mmsi: string) => void;
onFleetZoom?: (bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }) => void;
onSelectedGearChange?: (data: SelectedGearGroupData | null) => void;
}
// 두 벡터의 외적 (2D) — Graham scan에서 왼쪽 회전 여부 판별
function cross(o: [number, number], a: [number, number], b: [number, number]): number {
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
}
// Graham scan 기반 볼록 껍질 (반시계 방향)
function convexHull(points: [number, number][]): [number, number][] {
const n = points.length;
if (n < 2) return points.slice();
const sorted = points.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const lower: [number, number][] = [];
for (const p of sorted) {
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) {
lower.pop();
}
lower.push(p);
}
const upper: [number, number][] = [];
for (let i = sorted.length - 1; i >= 0; i--) {
const p = sorted[i];
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) {
upper.pop();
}
upper.push(p);
}
// lower + upper (첫/끝 중복 제거)
lower.pop();
upper.pop();
return lower.concat(upper);
}
// 중심에서 각 꼭짓점 방향으로 padding 확장
function padPolygon(hull: [number, number][], padding: number): [number, number][] {
if (hull.length === 0) return hull;
const cx = hull.reduce((s, p) => s + p[0], 0) / hull.length;
const cy = hull.reduce((s, p) => s + p[1], 0) / hull.length;
return hull.map(([x, y]) => {
const dx = x - cx;
const dy = y - cy;
const len = Math.sqrt(dx * dx + dy * dy);
if (len === 0) return [x + padding, y + padding] as [number, number];
const scale = (len + padding) / len;
return [cx + dx * scale, cy + dy * scale] as [number, number];
});
}
// cluster_id 해시 → HSL 색상
function clusterColor(id: number): string {
const h = (id * 137) % 360;
return `hsl(${h}, 80%, 55%)`;
}
// HSL 문자열 → hex 근사 (MapLibre는 hsl() 지원하므로 직접 사용 가능)
// GeoJSON feature에 color 속성으로 주입
interface ClusterPolygonFeature {
type: 'Feature';
id: number;
properties: { clusterId: number; color: string };
geometry: { type: 'Polygon'; coordinates: [number, number][][] };
}
interface ClusterLineFeature {
type: 'Feature';
id: number;
properties: { clusterId: number; color: string };
geometry: { type: 'LineString'; coordinates: [number, number][] };
}
type ClusterFeature = ClusterPolygonFeature | ClusterLineFeature;
export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, onFleetZoom, onSelectedGearChange }: Props) {
const [companies, setCompanies] = useState<Map<number, FleetCompany>>(new Map());
const [expanded, setExpanded] = useState(true);
const [expandedFleet, setExpandedFleet] = useState<number | null>(null);
const [hoveredFleetId, setHoveredFleetId] = useState<number | null>(null);
const [expandedGearGroup, setExpandedGearGroup] = useState<string | null>(null);
const [selectedGearGroup, setSelectedGearGroup] = useState<string | null>(null);
useEffect(() => {
fetchFleetCompanies().then(setCompanies).catch(() => {});
}, []);
// 선박명 → mmsi 맵 (어구 매칭용)
const gearsByParent = useMemo(() => {
const map = new Map<string, Ship[]>(); // parent_mmsi → gears
const gearPattern = /^(.+?)_\d+_\d*$/;
const parentNames = new Map<string, string>(); // name → mmsi
for (const s of ships) {
if (s.name && !gearPattern.test(s.name)) {
parentNames.set(s.name.trim(), s.mmsi);
}
}
for (const s of ships) {
const m = s.name?.match(gearPattern);
if (!m) continue;
const parentMmsi = parentNames.get(m[1].trim());
if (parentMmsi) {
const arr = map.get(parentMmsi) ?? [];
arr.push(s);
map.set(parentMmsi, arr);
}
}
return map;
}, [ships]);
// ships map (mmsi → Ship)
const shipMap = useMemo(() => {
const m = new Map<string, Ship>();
for (const s of ships) m.set(s.mmsi, s);
return m;
}, [ships]);
// 비허가 어구 클러스터: parentName → { parent: Ship | null, gears: Ship[] }
const gearGroupMap = useMemo(() => {
const gearPattern = /^(.+?)_\d+_\d+_?$/;
const MAX_DIST_DEG = 0.15; // ~10NM
const STALE_MS = 60 * 60_000;
const now = Date.now();
const nameToShip = new Map<string, Ship>();
for (const s of ships) {
const nm = (s.name || '').trim();
if (nm && !gearPattern.test(nm)) {
nameToShip.set(nm, s);
}
}
// 1단계: 같은 모선명 어구 수집 (60분 이내만)
const rawGroups = new Map<string, Ship[]>();
for (const s of ships) {
if (now - s.lastSeen > STALE_MS) continue;
const m = (s.name || '').match(gearPattern);
if (!m) continue;
const parentName = m[1].trim();
const arr = rawGroups.get(parentName) ?? [];
arr.push(s);
rawGroups.set(parentName, arr);
}
// 2단계: 거리 기반 서브 클러스터링 (같은 이름이라도 멀면 분리)
const map = new Map<string, { parent: Ship | null; gears: Ship[] }>();
for (const [parentName, gears] of rawGroups) {
const parent = nameToShip.get(parentName) ?? null;
// 기준점: 모선 있으면 모선 위치, 없으면 첫 어구
const anchor = parent ?? gears[0];
if (!anchor) continue;
const nearby = gears.filter(g => {
const dlat = Math.abs(g.lat - anchor.lat);
const dlng = Math.abs(g.lng - anchor.lng);
return dlat <= MAX_DIST_DEG && dlng <= MAX_DIST_DEG;
});
if (nearby.length === 0) continue;
map.set(parentName, { parent, gears: nearby });
}
return map;
}, [ships]);
// 선택된 어구 그룹 데이터를 부모에 전달 (deck.gl 렌더링용)
useEffect(() => {
if (!selectedGearGroup) {
onSelectedGearChange?.(null);
return;
}
const entry = gearGroupMap.get(selectedGearGroup);
if (entry) {
onSelectedGearChange?.({ parent: entry.parent, gears: entry.gears, groupName: selectedGearGroup });
} else {
onSelectedGearChange?.(null);
}
}, [selectedGearGroup, gearGroupMap, onSelectedGearChange]);
// 비허가 어구 클러스터 GeoJSON
const gearClusterGeoJson = useMemo((): GeoJSON => {
const features: GeoJSON.Feature[] = [];
for (const [parentName, { parent, gears }] of gearGroupMap) {
const points: [number, number][] = gears.map(g => [g.lng, g.lat]);
if (parent) points.push([parent.lng, parent.lat]);
if (points.length < 3) continue;
const hull = convexHull(points);
const padded = padPolygon(hull, 0.01);
padded.push(padded[0]);
features.push({
type: 'Feature',
properties: { name: parentName, gearCount: gears.length },
geometry: { type: 'Polygon', coordinates: [padded] },
});
}
return { type: 'FeatureCollection', features };
}, [gearGroupMap]);
// 어구 그룹 목록 (어구 수 내림차순)
const gearGroupList = useMemo(() => {
return Array.from(gearGroupMap.entries())
.map(([name, { parent, gears }]) => ({ name, parent, gears }))
.sort((a, b) => b.gears.length - a.gears.length);
}, [gearGroupMap]);
const handleGearGroupZoom = useCallback((parentName: string) => {
setSelectedGearGroup(prev => prev === parentName ? null : parentName);
setExpandedGearGroup(parentName);
const entry = gearGroupMap.get(parentName);
if (!entry) return;
const all: Ship[] = [...entry.gears];
if (entry.parent) all.push(entry.parent);
if (all.length === 0) return;
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
for (const s of all) {
if (s.lat < minLat) minLat = s.lat;
if (s.lat > maxLat) maxLat = s.lat;
if (s.lng < minLng) minLng = s.lng;
if (s.lng > maxLng) maxLng = s.lng;
}
if (minLat === Infinity) return;
onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
}, [gearGroupMap, onFleetZoom]);
// GeoJSON 피처 생성
const polygonFeatures = useMemo((): ClusterFeature[] => {
const features: ClusterFeature[] = [];
for (const [clusterId, mmsiList] of clusters) {
const points: [number, number][] = [];
for (const mmsi of mmsiList) {
const ship = shipMap.get(mmsi);
if (ship) points.push([ship.lng, ship.lat]);
}
if (points.length < 2) continue;
const color = clusterColor(clusterId);
if (points.length === 2) {
features.push({
type: 'Feature',
id: clusterId,
properties: { clusterId, color },
geometry: { type: 'LineString', coordinates: points },
});
continue;
}
const hull = convexHull(points);
const padded = padPolygon(hull, 0.02);
// 폴리곤 닫기
const ring = [...padded, padded[0]];
features.push({
type: 'Feature',
id: clusterId,
properties: { clusterId, color },
geometry: { type: 'Polygon', coordinates: [ring] },
});
}
return features;
}, [clusters, shipMap]);
const polygonGeoJSON = useMemo((): GeoJSON => ({
type: 'FeatureCollection',
features: polygonFeatures.filter(f => f.geometry.type === 'Polygon'),
}), [polygonFeatures]);
const lineGeoJSON = useMemo((): GeoJSON => ({
type: 'FeatureCollection',
features: polygonFeatures.filter(f => f.geometry.type === 'LineString'),
}), [polygonFeatures]);
// 호버 하이라이트용 단일 폴리곤
const hoveredGeoJSON = useMemo((): GeoJSON => {
if (hoveredFleetId === null) return { type: 'FeatureCollection', features: [] };
const f = polygonFeatures.find(p => p.properties.clusterId === hoveredFleetId && p.geometry.type === 'Polygon');
if (!f) return { type: 'FeatureCollection', features: [] };
return { type: 'FeatureCollection', features: [f] };
}, [hoveredFleetId, polygonFeatures]);
const handleFleetZoom = useCallback((clusterId: number) => {
const mmsiList = clusters.get(clusterId) ?? [];
if (mmsiList.length === 0) return;
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
for (const mmsi of mmsiList) {
const ship = shipMap.get(mmsi);
if (!ship) continue;
if (ship.lat < minLat) minLat = ship.lat;
if (ship.lat > maxLat) maxLat = ship.lat;
if (ship.lng < minLng) minLng = ship.lng;
if (ship.lng > maxLng) maxLng = ship.lng;
}
if (minLat === Infinity) return;
onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
}, [clusters, shipMap, onFleetZoom]);
const fleetList = useMemo(() => {
return Array.from(clusters.entries())
.map(([id, mmsiList]) => ({ id, mmsiList }))
.sort((a, b) => b.mmsiList.length - a.mmsiList.length);
}, [clusters]);
// 패널 스타일 (AnalysisStatsPanel 패턴)
const panelStyle: React.CSSProperties = {
position: 'absolute',
bottom: 60,
left: 10,
zIndex: 10,
minWidth: 220,
maxWidth: 300,
backgroundColor: 'rgba(12, 24, 37, 0.92)',
border: '1px solid rgba(99, 179, 237, 0.25)',
borderRadius: 8,
color: '#e2e8f0',
fontFamily: 'monospace, sans-serif',
fontSize: 11,
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
pointerEvents: 'auto',
};
const headerStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '6px 10px',
borderBottom: expanded ? '1px solid rgba(99, 179, 237, 0.15)' : 'none',
cursor: 'default',
userSelect: 'none',
flexShrink: 0,
};
const toggleButtonStyle: React.CSSProperties = {
background: 'none',
border: 'none',
color: '#94a3b8',
cursor: 'pointer',
fontSize: 10,
padding: '0 2px',
lineHeight: 1,
};
return (
<>
{/* 선단 폴리곤 레이어 */}
<Source id="fleet-cluster-fill" type="geojson" data={polygonGeoJSON}>
<Layer
id="fleet-cluster-fill-layer"
type="fill"
paint={{
'fill-color': ['get', 'color'],
'fill-opacity': 0.1,
}}
/>
<Layer
id="fleet-cluster-line-layer"
type="line"
paint={{
'line-color': ['get', 'color'],
'line-opacity': 0.5,
'line-width': 1.5,
}}
/>
</Source>
{/* 2척 선단 라인 */}
<Source id="fleet-cluster-line" type="geojson" data={lineGeoJSON}>
<Layer
id="fleet-cluster-line-only"
type="line"
paint={{
'line-color': ['get', 'color'],
'line-opacity': 0.5,
'line-width': 1.5,
'line-dasharray': [4, 2],
}}
/>
</Source>
{/* 호버 하이라이트 (별도 Source) */}
<Source id="fleet-cluster-hovered" type="geojson" data={hoveredGeoJSON}>
<Layer
id="fleet-cluster-hovered-fill"
type="fill"
paint={{
'fill-color': ['get', 'color'],
'fill-opacity': 0.25,
}}
/>
</Source>
{/* 선택된 어구 그룹 하이라이트 폴리곤 (deck.gl에서 어구 아이콘 + 모선 마커 표시) */}
{selectedGearGroup && (() => {
const entry = gearGroupMap.get(selectedGearGroup);
if (!entry) return null;
const points: [number, number][] = entry.gears.map(g => [g.lng, g.lat]);
if (entry.parent) points.push([entry.parent.lng, entry.parent.lat]);
const hlFeatures: GeoJSON.Feature[] = [];
if (points.length >= 3) {
const hull = convexHull(points);
const padded = padPolygon(hull, 0.01);
padded.push(padded[0]);
hlFeatures.push({
type: 'Feature',
properties: {},
geometry: { type: 'Polygon', coordinates: [padded] },
});
}
if (hlFeatures.length === 0) return null;
const hlGeoJson: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: hlFeatures };
return (
<Source id="gear-cluster-selected" type="geojson" data={hlGeoJson}>
<Layer id="gear-selected-fill" type="fill" paint={{ 'fill-color': '#f97316', 'fill-opacity': 0.25 }} />
<Layer id="gear-selected-line" type="line" paint={{ 'line-color': '#f97316', 'line-width': 3, 'line-opacity': 0.9 }} />
</Source>
);
})()}
{/* 비허가 어구 클러스터 폴리곤 */}
<Source id="gear-clusters" type="geojson" data={gearClusterGeoJson}>
<Layer
id="gear-cluster-fill-layer"
type="fill"
paint={{
'fill-color': 'rgba(249, 115, 22, 0.08)',
}}
/>
<Layer
id="gear-cluster-line-layer"
type="line"
paint={{
'line-color': '#f97316',
'line-opacity': 0.7,
'line-width': 1.5,
'line-dasharray': [4, 2],
}}
/>
</Source>
{/* 선단 목록 패널 */}
<div style={panelStyle}>
<div style={headerStyle}>
<span style={{ fontWeight: 700, color: '#63b3ed', letterSpacing: 0.5 }}>
({fleetList.length})
</span>
<button
style={toggleButtonStyle}
onClick={() => setExpanded(prev => !prev)}
aria-label={expanded ? '패널 접기' : '패널 펼치기'}
>
{expanded ? '▲' : '▼'}
</button>
</div>
{expanded && (
<div style={{ maxHeight: 500, overflowY: 'auto', padding: '4px 0' }}>
{fleetList.length === 0 ? (
<div style={{ color: '#64748b', textAlign: 'center', padding: '8px 10px' }}>
</div>
) : (
fleetList.map(({ id, mmsiList }) => {
const company = companies.get(id);
const companyName = company?.nameCn ?? `선단 #${id}`;
const color = clusterColor(id);
const isOpen = expandedFleet === id;
const isHovered = hoveredFleetId === id;
const mainVessels = mmsiList.filter(mmsi => {
const dto = analysisMap.get(mmsi);
return dto?.algorithms.fleetRole.role === 'LEADER' || dto?.algorithms.fleetRole.role === 'MEMBER';
});
const gearCount = mmsiList.reduce((acc, mmsi) => acc + (gearsByParent.get(mmsi)?.length ?? 0), 0);
return (
<div key={id}>
{/* 선단 행 */}
<div
onMouseEnter={() => setHoveredFleetId(id)}
onMouseLeave={() => setHoveredFleetId(null)}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '4px 10px',
cursor: 'pointer',
backgroundColor: isHovered ? 'rgba(255,255,255,0.06)' : 'transparent',
borderLeft: isOpen ? `2px solid ${color}` : '2px solid transparent',
transition: 'background-color 0.1s',
}}
>
{/* 펼침 토글 */}
<span
onClick={() => setExpandedFleet(prev => (prev === id ? null : id))}
style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }}
>
{isOpen ? '▾' : '▸'}
</span>
{/* 색상 인디케이터 */}
<span style={{
width: 8, height: 8, borderRadius: '50%',
backgroundColor: color, flexShrink: 0,
}} />
{/* 회사명 */}
<span
onClick={() => setExpandedFleet(prev => (prev === id ? null : id))}
style={{
flex: 1,
color: '#e2e8f0',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
cursor: 'pointer',
}}
title={company ? `${company.nameCn} / ${company.nameEn}` : `선단 #${id}`}
>
{companyName}
</span>
{/* 선박 수 */}
<span style={{ color: '#64748b', fontSize: 10, flexShrink: 0 }}>
({mmsiList.length})
</span>
{/* zoom 버튼 */}
<button
onClick={e => { e.stopPropagation(); handleFleetZoom(id); }}
style={{
background: 'none',
border: '1px solid rgba(99,179,237,0.3)',
borderRadius: 3,
color: '#63b3ed',
fontSize: 9,
cursor: 'pointer',
padding: '1px 4px',
flexShrink: 0,
}}
title="이 선단으로 지도 이동"
>
zoom
</button>
</div>
{/* 선단 상세 */}
{isOpen && (
<div style={{
paddingLeft: 22,
paddingRight: 10,
paddingBottom: 6,
fontSize: 10,
color: '#94a3b8',
borderLeft: `2px solid ${color}33`,
marginLeft: 10,
}}>
{/* 선박 목록 */}
<div style={{ color: '#64748b', fontSize: 9, marginBottom: 3 }}>:</div>
{(mainVessels.length > 0 ? mainVessels : mmsiList).map(mmsi => {
const ship = shipMap.get(mmsi);
const dto = analysisMap.get(mmsi);
const role = dto?.algorithms.fleetRole.role ?? 'MEMBER';
const displayName = ship?.name || mmsi;
return (
<div
key={mmsi}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
marginBottom: 2,
}}
>
<span style={{ flex: 1, color: '#cbd5e1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{displayName}
</span>
<span style={{ color: role === 'LEADER' ? '#fbbf24' : '#64748b', fontSize: 9, flexShrink: 0 }}>
({role === 'LEADER' ? 'MAIN' : 'SUB'})
</span>
<button
onClick={() => onShipSelect?.(mmsi)}
style={{
background: 'none',
border: 'none',
color: '#63b3ed',
fontSize: 10,
cursor: 'pointer',
padding: '0 2px',
flexShrink: 0,
}}
title="선박으로 이동"
aria-label={`${displayName} 선박으로 이동`}
>
</button>
</div>
);
})}
{/* 어구 목록 */}
{gearCount > 0 && (
<>
<div style={{ color: '#64748b', fontSize: 9, marginTop: 4, marginBottom: 2 }}>
: {gearCount}
</div>
{mmsiList.flatMap(mmsi => gearsByParent.get(mmsi) ?? []).map(gear => (
<div key={gear.mmsi} style={{ color: '#475569', fontSize: 9, marginBottom: 1 }}>
{gear.name || gear.mmsi}
</div>
))}
</>
)}
</div>
)}
</div>
);
})
)}
{/* 비허가 어구 그룹 섹션 */}
{gearGroupList.length > 0 && (
<>
<div style={{
borderTop: '1px solid rgba(249,115,22,0.25)',
margin: '6px 10px',
}} />
<div style={{
padding: '2px 10px 4px',
fontSize: 10,
color: '#f97316',
fontWeight: 700,
letterSpacing: 0.3,
}}>
({gearGroupList.length})
</div>
{gearGroupList.map(({ name, parent, gears }) => {
const isOpen = expandedGearGroup === name;
return (
<div key={name}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '3px 10px',
cursor: 'pointer',
borderLeft: isOpen ? '2px solid #f97316' : '2px solid transparent',
transition: 'background-color 0.1s',
}}
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(255,255,255,0.04)'; }}
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.backgroundColor = 'transparent'; }}
>
<span
onClick={() => setExpandedGearGroup(prev => (prev === name ? null : name))}
style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }}
>
{isOpen ? '▾' : '▸'}
</span>
<span style={{
width: 8, height: 8, borderRadius: '50%',
backgroundColor: '#f97316', flexShrink: 0,
}} />
<span
onClick={() => setExpandedGearGroup(prev => (prev === name ? null : name))}
style={{
flex: 1,
color: '#e2e8f0',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
cursor: 'pointer',
}}
title={name}
>
{name}
</span>
<span style={{ color: '#64748b', fontSize: 10, flexShrink: 0 }}>
({gears.length})
</span>
<button
type="button"
onClick={e => { e.stopPropagation(); handleGearGroupZoom(name); }}
style={{
background: 'none',
border: '1px solid rgba(249,115,22,0.4)',
borderRadius: 3,
color: '#f97316',
fontSize: 9,
cursor: 'pointer',
padding: '1px 4px',
flexShrink: 0,
}}
title="이 어구 그룹으로 지도 이동"
>
zoom
</button>
</div>
{isOpen && (
<div style={{
paddingLeft: 24,
paddingRight: 10,
paddingBottom: 4,
fontSize: 9,
color: '#94a3b8',
borderLeft: '2px solid rgba(249,115,22,0.2)',
marginLeft: 10,
}}>
{parent && (
<div style={{ color: '#fbbf24', marginBottom: 2 }}>
: {parent.name || parent.mmsi}
</div>
)}
<div style={{ color: '#64748b', marginBottom: 2 }}> :</div>
{gears.map(g => (
<div key={g.mmsi} style={{
display: 'flex',
alignItems: 'center',
gap: 4,
marginBottom: 1,
}}>
<span style={{ flex: 1, color: '#475569', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{g.name || g.mmsi}
</span>
<button
type="button"
onClick={() => onShipSelect?.(g.mmsi)}
style={{
background: 'none',
border: 'none',
color: '#f97316',
fontSize: 10,
cursor: 'pointer',
padding: '0 2px',
flexShrink: 0,
}}
title="어구 위치로 이동"
aria-label={`${g.name || g.mmsi} 위치로 이동`}
>
</button>
</div>
))}
</div>
)}
</div>
);
})}
</>
)}
</div>
)}
</div>
</>
);
}
export default FleetClusterLayer;