fix: 헤더 1행 배치 + 비허가 어구 2개 이상만 탐지 + 중국 어구그룹 감시 배지

- 헤더 mode-toggle: flex-wrap:nowrap + overflow-x:auto → 1행 가로 스크롤
- 비허가 어구 그룹: 어구 2개 이상일 때만 그룹 탐지/폴리곤 생성 (1개는 제외)
  → 조업구역내 어구 + 선단 현황은 현행 유지
- cnFishing 배지: '중국 어구그룹 감시 N개' (어구그룹 수=고유 모선명 수)
  → 어구 패턴 매칭 선박만 집계 (중국 어선 단독은 제외)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-03-23 13:03:03 +09:00
부모 0da477c53c
커밋 c515975185
3개의 변경된 파일32개의 추가작업 그리고 9개의 파일을 삭제

파일 보기

@ -1996,12 +1996,16 @@
/* ======================== */
.mode-toggle {
display: flex;
flex-wrap: nowrap;
gap: 4px;
background: var(--kcg-subtle);
border: 1px solid var(--kcg-border);
border-radius: 6px;
padding: 3px;
overflow-x: auto;
scrollbar-width: none;
}
.mode-toggle::-webkit-scrollbar { display: none; }
.mode-btn {
display: flex;

파일 보기

@ -304,12 +304,17 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
const outZone: { name: string; parent: Ship | null; gears: Ship[] }[] = [];
for (const [name, { parent, gears }] of gearGroupMap) {
const anchor = parent ?? gears[0];
if (!anchor) { outZone.push({ name, parent, gears }); continue; }
if (!anchor) {
// 비허가 어구: 2개 이상일 때만 그룹으로 탐지
if (gears.length >= 2) outZone.push({ name, parent, gears });
continue;
}
const zoneInfo = classifyFishingZone(anchor.lat, anchor.lng);
if (zoneInfo.zone !== 'OUTSIDE') {
inZone.push({ name, parent, gears, zone: zoneInfo.name });
} else {
outZone.push({ name, parent, gears });
// 비허가 어구: 2개 이상일 때만 그룹으로 탐지
if (gears.length >= 2) outZone.push({ name, parent, gears });
}
}
inZone.sort((a, b) => b.gears.length - a.gears.length);
@ -318,10 +323,15 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
}, [gearGroupMap]);
// 어구 클러스터 GeoJSON (수역 내: 붉은색, 수역 외: 오렌지)
// 비허가 어구(outZone)는 2개 이상만 폴리곤 생성
const gearClusterGeoJson = useMemo((): GeoJSON => {
const inZoneNames = new Set(inZoneGearGroups.map(g => g.name));
const outZoneNames = new Set(outZoneGearGroups.map(g => g.name));
const features: GeoJSON.Feature[] = [];
for (const [parentName, { parent, gears }] of gearGroupMap) {
// 비허가(outZone) 1개짜리는 폴리곤에서 제외
const isInZone = inZoneNames.has(parentName);
if (!isInZone && !outZoneNames.has(parentName)) continue;
const points: [number, number][] = gears.map(g => [g.lng, g.lat]);
if (parent) points.push([parent.lng, parent.lat]);
if (points.length < 3) continue;
@ -330,12 +340,12 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
padded.push(padded[0]);
features.push({
type: 'Feature',
properties: { name: parentName, gearCount: gears.length, inZone: inZoneNames.has(parentName) ? 1 : 0 },
properties: { name: parentName, gearCount: gears.length, inZone: isInZone ? 1 : 0 },
geometry: { type: 'Polygon', coordinates: [padded] },
});
}
return { type: 'FeatureCollection', features };
}, [gearGroupMap, inZoneGearGroups]);
}, [gearGroupMap, inZoneGearGroups, outZoneGearGroups]);
const handleGearGroupZoom = useCallback((parentName: string) => {
setSelectedGearGroup(prev => prev === parentName ? null : parentName);

파일 보기

@ -698,7 +698,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
case 'cableWatch': return all.filter(s => cableWatchSuspects.has(s.mmsi));
case 'dokdoWatch': return all.filter(s => dokdoWatchSuspects.has(s.mmsi));
case 'ferryWatch': return all.filter(s => s.mtCategory === 'passenger');
case 'cnFishing': return all.filter(s => (s.flag === 'CN' && s.mtCategory === 'fishing') || gearPattern.test(s.name || ''));
case 'cnFishing': return all.filter(s => gearPattern.test(s.name || ''));
default: return [];
}
};
@ -721,12 +721,21 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
<div className="absolute top-2.5 left-1/2 -translate-x-1/2 z-20 flex gap-1.5 backdrop-blur-lg">
{active.map(k => {
const color = FILTER_COLOR[k];
const count = getShipsForFilter(k).length;
const filterShips = getShipsForFilter(k);
const isOpen = activeBadgeFilter === k;
// cnFishing: 어구그룹 수(고유 모선명)로 표시, 나머지: 선박 수
let badgeLabel: string;
if (k === 'cnFishing') {
const groupNames = new Set(filterShips.map(s => (s.name || '').match(/^(.+?)_\d+/)?.[1]).filter(Boolean));
badgeLabel = `${groupNames.size}`;
} else {
badgeLabel = `${filterShips.length}`;
}
const badgeName = k === 'cnFishing' ? '중국 어구그룹 감시' : (FILTER_I18N_KEY[k] ? t(FILTER_I18N_KEY[k]) : k);
return (
<div
key={k}
className={`rounded-lg px-3 py-1.5 font-mono text-[11px] font-bold flex items-center gap-1.5 cursor-pointer select-none ${isOpen ? '' : 'animate-pulse'}`}
className={`rounded-lg px-3 py-1.5 font-mono text-[11px] font-bold flex items-center gap-1.5 cursor-pointer select-none whitespace-nowrap ${isOpen ? '' : 'animate-pulse'}`}
style={{
background: isOpen ? `${color}44` : `${color}22`,
border: `1px solid ${isOpen ? color : color + '88'}`,
@ -735,8 +744,8 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
onClick={() => setActiveBadgeFilter(prev => prev === k ? null : k)}
>
<span className="text-[13px]">{FILTER_ICON[k]}</span>
{FILTER_I18N_KEY[k] ? t(FILTER_I18N_KEY[k]) : k}
<span className="ml-0.5 text-white/80">{count}</span>
{badgeName}
<span className="ml-0.5 text-white/80">{badgeLabel}</span>
</div>
);
})}