diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 241a5a5..d94524a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,21 @@ import { useTranslation } from 'react-i18next'; import LoginPage from './components/auth/LoginPage'; import CollectorMonitor from './components/common/CollectorMonitor'; import { FieldAnalysisModal } from './components/korea/FieldAnalysisModal'; +// 정적 데이터 카운트용 import (이미 useStaticDeckLayers에서 번들 포함됨) +import { EAST_ASIA_PORTS } from './data/ports'; +import { KOREAN_AIRPORTS } from './services/airports'; +import { MILITARY_BASES } from './data/militaryBases'; +import { GOV_BUILDINGS } from './data/govBuildings'; +import { KOREA_WIND_FARMS } from './data/windFarms'; +import { NK_LAUNCH_SITES } from './data/nkLaunchSites'; +import { NK_MISSILE_EVENTS } from './data/nkMissileEvents'; +import { COAST_GUARD_FACILITIES } from './services/coastGuard'; +import { NAV_WARNINGS } from './services/navWarning'; +import { PIRACY_ZONES } from './services/piracy'; +import { KOREA_SUBMARINE_CABLES } from './services/submarineCable'; +import { HAZARD_FACILITIES } from './data/hazardFacilities'; +import { CN_POWER_PLANTS, CN_MILITARY_FACILITIES } from './data/cnFacilities'; +import { JP_POWER_PLANTS, JP_MILITARY_FACILITIES } from './data/jpFacilities'; import './App.css'; function App() { @@ -630,48 +645,49 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { satelliteCount={koreaData.satPositions.length} extraLayers={[ // 해양안전 - { key: 'cables', label: t('layers.cables'), color: '#00e5ff', group: '해양안전' }, - { key: 'cctv', label: t('layers.cctv'), color: '#ff6b6b', count: 15, group: '해양안전' }, - { key: 'coastGuard', label: t('layers.coastGuard'), color: '#4dabf7', count: 56, group: '해양안전' }, - { key: 'navWarning', label: t('layers.navWarning'), color: '#eab308', group: '해양안전' }, - { key: 'osint', label: t('layers.osint'), color: '#ef4444', group: '해양안전' }, - { key: 'eez', label: t('layers.eez'), color: '#3b82f6', group: '해양안전' }, - { key: 'piracy', label: t('layers.piracy'), color: '#ef4444', group: '해양안전' }, - { key: 'nkMissile', label: '🚀 미사일 낙하', color: '#ef4444', count: 4, group: '해양안전' }, + { key: 'cables', label: t('layers.cables'), color: '#00e5ff', count: KOREA_SUBMARINE_CABLES.length, group: '해양안전' }, + { key: 'coastGuard', label: t('layers.coastGuard'), color: '#4dabf7', count: COAST_GUARD_FACILITIES.length, group: '해양안전' }, + { key: 'navWarning', label: t('layers.navWarning'), color: '#eab308', count: NAV_WARNINGS.length, group: '해양안전' }, + { key: 'osint', label: t('layers.osint'), color: '#ef4444', count: koreaData.osintFeed.length, group: '해양안전' }, + { key: 'eez', label: t('layers.eez'), color: '#3b82f6', count: 1, group: '해양안전' }, + { key: 'piracy', label: t('layers.piracy'), color: '#ef4444', count: PIRACY_ZONES.length, group: '해양안전' }, + { key: 'nkMissile', label: '🚀 미사일 낙하', color: '#ef4444', count: NK_MISSILE_EVENTS.length, group: '해양안전' }, + { key: 'nkLaunch', label: '🇰🇵 발사/포병진지', color: '#dc2626', count: NK_LAUNCH_SITES.length, group: '해양안전' }, // 국가기관망 - { key: 'infra', label: t('layers.infra'), color: '#ffc107', group: '에너지/발전시설' }, - { key: 'airports', label: t('layers.airports'), color: '#a78bfa', count: 59, group: '국가기관망' }, - { key: 'windFarm', label: '풍력단지', color: '#00bcd4', count: 8, group: '에너지/발전시설' }, - { key: 'ports', label: '항구', color: '#3b82f6', count: 46, group: '국가기관망' }, - { key: 'militaryBases', label: '군사시설', color: '#ef4444', count: 38, group: '국가기관망' }, - { key: 'govBuildings', label: '정부기관', color: '#f59e0b', count: 32, group: '국가기관망' }, - { key: 'nkLaunch', label: '🇰🇵 발사/포병진지', color: '#dc2626', count: 19, group: '해양안전' }, - // 위험시설 - { key: 'hazardPetrochemical', label: '해안인접석유화학단지', color: '#f97316', count: 5, group: '위험시설' }, - { key: 'hazardLng', label: 'LNG저장기지', color: '#06b6d4', count: 10, group: '위험시설' }, - { key: 'hazardOilTank', label: '유류저장탱크', color: '#eab308', count: 15, group: '위험시설' }, - { key: 'hazardPort', label: '위험물항만하역시설', color: '#ef4444', count: 6, group: '위험시설' }, + { key: 'ports', label: '항구', color: '#3b82f6', count: EAST_ASIA_PORTS.length, group: '국가기관망' }, + { key: 'airports', label: t('layers.airports'), color: '#a78bfa', count: KOREAN_AIRPORTS.length, group: '국가기관망' }, + { key: 'militaryBases', label: '군사시설', color: '#ef4444', count: MILITARY_BASES.length, group: '국가기관망' }, + { key: 'govBuildings', label: '정부기관', color: '#f59e0b', count: GOV_BUILDINGS.length, group: '국가기관망' }, // 에너지/발전시설 - { key: 'energyNuclear', label: '원자력발전', color: '#a855f7', count: 5, group: '에너지/발전시설' }, - { key: 'energyThermal', label: '화력발전소', color: '#64748b', count: 5, group: '에너지/발전시설' }, + { key: 'infra', label: t('layers.infra'), color: '#ffc107', group: '에너지/발전시설' }, + { key: 'windFarm', label: '풍력단지', color: '#00bcd4', count: KOREA_WIND_FARMS.length, group: '에너지/발전시설' }, + { key: 'energyNuclear', label: '원자력발전', color: '#a855f7', count: HAZARD_FACILITIES.filter(f => f.type === 'nuclear').length, group: '에너지/발전시설' }, + { key: 'energyThermal', label: '화력발전소', color: '#64748b', count: HAZARD_FACILITIES.filter(f => f.type === 'thermal').length, group: '에너지/발전시설' }, + // 위험시설 + { key: 'hazardPetrochemical', label: '해안인접석유화학단지', color: '#f97316', count: HAZARD_FACILITIES.filter(f => f.type === 'petrochemical').length, group: '위험시설' }, + { key: 'hazardLng', label: 'LNG저장기지', color: '#06b6d4', count: HAZARD_FACILITIES.filter(f => f.type === 'lng').length, group: '위험시설' }, + { key: 'hazardOilTank', label: '유류저장탱크', color: '#eab308', count: HAZARD_FACILITIES.filter(f => f.type === 'oilTank').length, group: '위험시설' }, + { key: 'hazardPort', label: '위험물항만하역시설', color: '#ef4444', count: HAZARD_FACILITIES.filter(f => f.type === 'hazardPort').length, group: '위험시설' }, // 산업공정/제조시설 - { key: 'industryShipyard', label: '조선소 도장시설', color: '#0ea5e9', count: 6, group: '산업공정/제조시설' }, - { key: 'industryWastewater', label: '폐수/하수처리장', color: '#10b981', count: 5, group: '산업공정/제조시설' }, - { key: 'industryHeavy', label: '시멘트/제철소', color: '#94a3b8', count: 5, group: '산업공정/제조시설' }, + { key: 'industryShipyard', label: '조선소 도장시설', color: '#0ea5e9', count: HAZARD_FACILITIES.filter(f => f.type === 'shipyard').length, group: '산업공정/제조시설' }, + { key: 'industryWastewater', label: '폐수/하수처리장', color: '#10b981', count: HAZARD_FACILITIES.filter(f => f.type === 'wastewater').length, group: '산업공정/제조시설' }, + { key: 'industryHeavy', label: '시멘트/제철소', color: '#94a3b8', count: HAZARD_FACILITIES.filter(f => f.type === 'heavyIndustry').length, group: '산업공정/제조시설' }, ]} overseasItems={[ { key: 'overseasChina', label: '🇨🇳 중국', color: '#ef4444', + count: CN_POWER_PLANTS.length + CN_MILITARY_FACILITIES.length, children: [ - { key: 'cnPower', label: '발전소', color: '#a855f7' }, - { key: 'cnMilitary', label: '주요군사시설', color: '#ef4444' }, + { key: 'cnPower', label: '발전소', color: '#a855f7', count: CN_POWER_PLANTS.length }, + { key: 'cnMilitary', label: '주요군사시설', color: '#ef4444', count: CN_MILITARY_FACILITIES.length }, ], }, { key: 'overseasJapan', label: '🇯🇵 일본', color: '#f472b6', + count: JP_POWER_PLANTS.length + JP_MILITARY_FACILITIES.length, children: [ - { key: 'jpPower', label: '발전소', color: '#a855f7' }, - { key: 'jpMilitary', label: '주요군사시설', color: '#ef4444' }, + { key: 'jpPower', label: '발전소', color: '#a855f7', count: JP_POWER_PLANTS.length }, + { key: 'jpMilitary', label: '주요군사시설', color: '#ef4444', count: JP_MILITARY_FACILITIES.length }, ], }, ]} diff --git a/frontend/src/components/common/LayerPanel.tsx b/frontend/src/components/common/LayerPanel.tsx index 1d1ecdc..b41844e 100644 --- a/frontend/src/components/common/LayerPanel.tsx +++ b/frontend/src/components/common/LayerPanel.tsx @@ -107,12 +107,27 @@ interface ExtraLayer { group?: string; } -const GROUP_META: Record = { - '항공망': { label: '항공망', color: '#22d3ee' }, - '국가기관망': { label: '국가기관망', color: '#f59e0b' }, - '해양안전': { label: '해양안전', color: '#3b82f6' }, +const GROUP_META: Record = { + '항공망': { label: '항공망', color: '#22d3ee' }, + '해양안전': { label: '해양안전', color: '#3b82f6' }, + '국가기관망': { label: '국가기관망', color: '#f59e0b' }, + '위험시설': { label: '위험시설', color: '#ef4444', superGroup: '위험/산업 인프라' }, + '에너지/발전시설': { label: '에너지/발전시설', color: '#a855f7', superGroup: '위험/산업 인프라' }, + '산업공정/제조시설': { label: '산업공정/제조시설', color: '#0ea5e9', superGroup: '위험/산업 인프라' }, }; +const SUPER_GROUP_META: Record = { + '위험/산업 인프라': { label: '위험/산업 인프라', color: '#f97316' }, +}; + +interface OverseasItem { + key: string; + label: string; + color: string; + count?: number; + children?: OverseasItem[]; +} + interface LayerPanelProps { layers: Record; onToggle: (key: string) => void; @@ -122,6 +137,7 @@ interface LayerPanelProps { shipTotal: number; satelliteCount: number; extraLayers?: ExtraLayer[]; + overseasItems?: OverseasItem[]; hiddenAcCategories: Set; hiddenShipCategories: Set; onAcCategoryToggle: (cat: string) => void; @@ -143,6 +159,7 @@ export function LayerPanel({ shipTotal, satelliteCount, extraLayers, + overseasItems, hiddenAcCategories, hiddenShipCategories, onAcCategoryToggle, @@ -174,9 +191,10 @@ export function LayerPanel({ }); }, []); - const militaryCount = Object.entries(aircraftByCategory) + const _militaryCount = Object.entries(aircraftByCategory) .filter(([cat]) => cat !== 'civilian' && cat !== 'unknown') .reduce((sum, [, c]) => sum + c, 0); + void _militaryCount; // 이란 탭에서 사용 가능 — 해외시설 분리 후 미사용 return (
@@ -186,7 +204,8 @@ export function LayerPanel({ {/* Ships tree */} a + b, 0)})`} + label="국적 분류" + count={Object.values(shipsByNationality).reduce((a, b) => a + b, 0)} color="#8b5cf6" active expandable @@ -342,7 +362,8 @@ export function LayerPanel({ {/* Aircraft tree */} onToggle('satellites')} @@ -421,47 +443,92 @@ export function LayerPanel({ ungrouped.push(el); } } + + // 수퍼그룹 별로 그룹 분류 + const superGrouped: Record = {}; // superGroup → groupNames[] + const noSuperGroup: string[] = []; + for (const groupName of Object.keys(grouped)) { + const sg = GROUP_META[groupName]?.superGroup; + if (sg) { + if (!superGrouped[sg]) superGrouped[sg] = []; + superGrouped[sg].push(groupName); + } else { + noSuperGroup.push(groupName); + } + } + + const renderGroup = (groupName: string, indent = false) => { + const meta = GROUP_META[groupName] || { label: groupName, color: '#888' }; + const isGroupExpanded = expanded.has(`group-${groupName}`); + const items = grouped[groupName] || []; + return ( +
+ toggleExpand(`group-${groupName}`)} + onExpand={() => toggleExpand(`group-${groupName}`)} + /> + {isGroupExpanded && ( +
+ {items.map(el => ( + onToggle(el.key)} + /> + ))} +
+ )} +
+ ); + }; + return ( <> - {/* Grouped layers */} - {Object.entries(grouped).map(([groupName, items]) => { - const meta = GROUP_META[groupName] || { label: groupName, color: '#888' }; - const isGroupExpanded = expanded.has(`group-${groupName}`); + {/* 수퍼그룹 없는 그룹들 (항공망·해양안전·국가기관망) */} + {noSuperGroup.map(g => renderGroup(g))} + + {/* 수퍼그룹으로 묶인 그룹들 */} + {Object.entries(superGrouped).map(([sgName, groupNames]) => { + const sgMeta = SUPER_GROUP_META[sgName] || { label: sgName, color: '#f97316' }; + const isSgExpanded = expanded.has(`supergroup-${sgName}`); return ( -
+
toggleExpand(`group-${groupName}`)} - onExpand={() => toggleExpand(`group-${groupName}`)} + isExpanded={isSgExpanded} + onToggle={() => toggleExpand(`supergroup-${sgName}`)} + onExpand={() => toggleExpand(`supergroup-${sgName}`)} /> - {isGroupExpanded && ( + {isSgExpanded && (
- {items.map(el => ( - onToggle(el.key)} - /> - ))} + {groupNames.map(g => renderGroup(g, true))}
)}
); })} - {/* Ungrouped layers */} + + {/* 그룹 없는 개별 레이어 */} {ungrouped.map(el => ( onToggle(el.key)} @@ -473,14 +540,54 @@ export function LayerPanel({
- {/* Military only filter */} + {/* 해외시설 — 접기/펼치기 전용 (토글은 하위 항목에서 개별 제어) */} { + const parentOn = layers[item.key] ? 1 : 0; + const childrenOn = item.children?.filter(c => layers[c.key]).length ?? 0; + return sum + parentOn + childrenOn; + }, 0) ?? 0} color="#f97316" - active={layers.militaryOnly ?? false} - onToggle={() => onToggle('militaryOnly')} + active={expanded.has('overseas-section')} + expandable + isExpanded={expanded.has('overseas-section')} + onToggle={() => toggleExpand('overseas-section')} + onExpand={() => toggleExpand('overseas-section')} /> + {expanded.has('overseas-section') && overseasItems && overseasItems.length > 0 && ( +
+ {overseasItems.map(item => ( +
+ onToggle(item.key)} + onExpand={() => toggleExpand(`overseas-${item.key}`)} + /> + {item.children?.length && expanded.has(`overseas-${item.key}`) && ( +
+ {item.children.map(child => ( + onToggle(child.key)} + /> + ))} +
+ )} +
+ ))} +
+ )}
); @@ -495,6 +602,7 @@ function LayerTreeItem({ active, expandable, isExpanded, + count, onToggle, onExpand, }: { @@ -504,6 +612,7 @@ function LayerTreeItem({ active: boolean; expandable?: boolean; isExpanded?: boolean; + count?: number; onToggle: () => void; onExpand?: () => void; }) { @@ -523,13 +632,16 @@ function LayerTreeItem({ type="button" className={`layer-toggle ${active ? 'active' : ''}`} onClick={onToggle} - style={{ padding: 0, gap: '6px' }} + style={{ padding: 0, gap: '6px', flex: 1, width: '100%' }} > - {label} + {label} + {count != null && ( + {count} + )}
); diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index 583d736..2c40b73 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -175,12 +175,20 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF ); }, []); - // 줌 레벨별 아이콘/심볼 스케일 배율 + // 줌 레벨별 아이콘/심볼 스케일 배율 — z4=1.0x 기준, 2단계씩 상향 + // 줌 레벨별 아이콘/심볼 스케일 배율 — z4=0.8x, z6=1.0x 시작, 2단계씩 상향 const zoomScale = useMemo(() => { - if (zoomLevel <= 6) return 0.6; - if (zoomLevel <= 9) return 1.0; - if (zoomLevel <= 12) return 1.4; - return 1.8; + if (zoomLevel <= 4) return 0.8; + if (zoomLevel <= 5) return 0.9; + if (zoomLevel <= 6) return 1.0; + if (zoomLevel <= 7) return 1.2; + if (zoomLevel <= 8) return 1.5; + if (zoomLevel <= 9) return 1.8; + if (zoomLevel <= 10) return 2.2; + if (zoomLevel <= 11) return 2.5; + if (zoomLevel <= 12) return 2.8; + if (zoomLevel <= 13) return 3.5; + return 4.2; }, [zoomLevel]); // 불법어선 강조 — deck.gl ScatterplotLayer + TextLayer @@ -632,34 +640,200 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF ...selectedFleetLayers, ...analysisDeckLayers, ].filter(Boolean)} /> - {/* 정적 마커 클릭 Popup */} + {/* 정적 마커 클릭 Popup — 통합 리치 디자인 */} {staticPickInfo && (() => { const obj = staticPickInfo.object; + const kind = staticPickInfo.kind; const lat = obj.lat ?? obj.launchLat ?? 0; const lng = obj.lng ?? obj.launchLng ?? 0; if (!lat || !lng) return null; + + // ── kind + subType 기반 메타 결정 ── + const SUB_META: Record> = { + hazard: { + petrochemical: { icon: '🏭', color: '#f97316', label: '석유화학단지' }, + lng: { icon: '🔵', color: '#06b6d4', label: 'LNG저장기지' }, + oilTank: { icon: '🛢️', color: '#eab308', label: '유류저장탱크' }, + hazardPort: { icon: '⚠️', color: '#ef4444', label: '위험물항만하역' }, + nuclear: { icon: '☢️', color: '#a855f7', label: '원자력발전소' }, + thermal: { icon: '🔥', color: '#64748b', label: '화력발전소' }, + shipyard: { icon: '🚢', color: '#0ea5e9', label: '조선소' }, + wastewater: { icon: '💧', color: '#10b981', label: '폐수처리장' }, + heavyIndustry: { icon: '⚙️', color: '#94a3b8', label: '시멘트/제철소' }, + }, + overseas: { + nuclear: { icon: '☢️', color: '#ef4444', label: '핵발전소' }, + thermal: { icon: '🔥', color: '#f97316', label: '화력발전소' }, + naval: { icon: '⚓', color: '#3b82f6', label: '해군기지' }, + airbase: { icon: '✈️', color: '#22d3ee', label: '공군기지' }, + army: { icon: '🪖', color: '#22c55e', label: '육군기지' }, + shipyard:{ icon: '🚢', color: '#94a3b8', label: '조선소' }, + }, + militaryBase: { + naval: { icon: '⚓', color: '#3b82f6', label: '해군기지' }, + airforce:{ icon: '✈️', color: '#22d3ee', label: '공군기지' }, + army: { icon: '🪖', color: '#22c55e', label: '육군기지' }, + missile: { icon: '🚀', color: '#ef4444', label: '미사일기지' }, + joint: { icon: '⭐', color: '#f59e0b', label: '합동사령부' }, + }, + govBuilding: { + executive: { icon: '🏛️', color: '#f59e0b', label: '행정부' }, + legislature: { icon: '🏛️', color: '#3b82f6', label: '입법부' }, + military_hq: { icon: '⭐', color: '#ef4444', label: '군사령부' }, + intelligence: { icon: '🔍', color: '#8b5cf6', label: '정보기관' }, + foreign: { icon: '🌐', color: '#06b6d4', label: '외교부' }, + maritime: { icon: '⚓', color: '#0ea5e9', label: '해양기관' }, + defense: { icon: '🛡️', color: '#dc2626', label: '국방부' }, + }, + nkLaunch: { + icbm: { icon: '🚀', color: '#dc2626', label: 'ICBM 발사장' }, + irbm: { icon: '🚀', color: '#ef4444', label: 'IRBM/MRBM' }, + srbm: { icon: '🎯', color: '#f97316', label: '단거리탄도미사일' }, + slbm: { icon: '🔱', color: '#3b82f6', label: 'SLBM 발사' }, + cruise: { icon: '✈️', color: '#8b5cf6', label: '순항미사일' }, + artillery: { icon: '💥', color: '#eab308', label: '해안포/장사정포' }, + mlrs: { icon: '💥', color: '#f59e0b', label: '방사포(MLRS)' }, + }, + coastGuard: { + hq: { icon: '🏢', color: '#3b82f6', label: '본청' }, + regional: { icon: '🏢', color: '#60a5fa', label: '지방청' }, + station: { icon: '🚔', color: '#22d3ee', label: '해양경찰서' }, + substation: { icon: '🏠', color: '#94a3b8', label: '파출소' }, + vts: { icon: '📡', color: '#f59e0b', label: 'VTS센터' }, + navy: { icon: '⚓', color: '#3b82f6', label: '해군부대' }, + }, + airport: { + international: { icon: '✈️', color: '#a78bfa', label: '국제공항' }, + domestic: { icon: '🛩️', color: '#60a5fa', label: '국내공항' }, + military: { icon: '✈️', color: '#ef4444', label: '군용비행장' }, + }, + navWarning: { + danger: { icon: '⚠️', color: '#ef4444', label: '위험' }, + caution: { icon: '⚠️', color: '#eab308', label: '주의' }, + info: { icon: 'ℹ️', color: '#3b82f6', label: '정보' }, + }, + piracy: { + critical: { icon: '☠️', color: '#ef4444', label: '극고위험' }, + high: { icon: '☠️', color: '#f97316', label: '고위험' }, + moderate: { icon: '☠️', color: '#eab308', label: '주의' }, + }, + }; + + const KIND_DEFAULT: Record = { + port: { icon: '⚓', color: '#3b82f6', label: '항구' }, + windFarm: { icon: '🌀', color: '#00bcd4', label: '풍력단지' }, + militaryBase: { icon: '🪖', color: '#ef4444', label: '군사시설' }, + govBuilding: { icon: '🏛️', color: '#f59e0b', label: '정부기관' }, + nkLaunch: { icon: '🚀', color: '#dc2626', label: '북한 발사장' }, + nkMissile: { icon: '💣', color: '#ef4444', label: '미사일 낙하' }, + coastGuard: { icon: '🚔', color: '#4dabf7', label: '해양경찰' }, + airport: { icon: '✈️', color: '#a78bfa', label: '공항' }, + navWarning: { icon: '⚠️', color: '#eab308', label: '항행경보' }, + piracy: { icon: '☠️', color: '#ef4444', label: '해적위험' }, + infra: { icon: '⚡', color: '#ffeb3b', label: '발전/변전' }, + hazard: { icon: '⚠️', color: '#ef4444', label: '위험시설' }, + cnFacility: { icon: '📍', color: '#ef4444', label: '중국시설' }, + jpFacility: { icon: '📍', color: '#f472b6', label: '일본시설' }, + }; + + // subType 키 결정 + const subKey = obj.type ?? obj.subType ?? obj.level ?? ''; + const subGroup = kind === 'cnFacility' || kind === 'jpFacility' ? 'overseas' : kind; + const meta = SUB_META[subGroup]?.[subKey] ?? KIND_DEFAULT[kind] ?? { icon: '📍', color: '#64748b', label: kind }; + + // 국가 플래그 + const COUNTRY_FLAG: Record = { CN: '🇨🇳', JP: '🇯🇵', KP: '🇰🇵', KR: '🇰🇷', TW: '🇹🇼' }; + const flag = kind === 'cnFacility' ? '🇨🇳' : kind === 'jpFacility' ? '🇯🇵' : COUNTRY_FLAG[obj.country] ?? ''; + const countryName = kind === 'cnFacility' ? '중국' : kind === 'jpFacility' ? '일본' + : { CN: '중국', JP: '일본', KP: '북한', KR: '한국', TW: '대만' }[obj.country] ?? ''; + + // 이름 결정 + const title = obj.nameKo || obj.name || obj.launchNameKo || obj.title || kind; + return ( setStaticPickInfo(null)} - closeOnClick={false} - style={{ maxWidth: 280 }} + onClose={() => setStaticPickInfo(null)} closeOnClick={false} + maxWidth="280px" className="gl-popup" > -
-
- {obj.nameKo || obj.name || obj.launchNameKo || obj.type || staticPickInfo.kind} +
+ {/* 컬러 헤더 */} +
+ {meta.icon} {title}
- {obj.description &&
{obj.description}
} - {obj.date &&
날짜: {obj.date} {obj.time || ''}
} - {obj.missileType &&
미사일: {obj.missileType}
} - {obj.range &&
사거리: {obj.range}
} - {obj.operator &&
운영: {obj.operator}
} - {obj.capacity &&
용량: {obj.capacity}
} - {staticPickInfo.kind === 'hazard' && obj.address && ( -
📍 {obj.address}
+ {/* 배지 행 */} +
+ + {meta.label} + + {flag && ( + + {flag} {countryName} + + )} + {kind === 'hazard' && ( + ⚠️ 위험시설 + )} + {kind === 'port' && ( + + {obj.type === 'major' ? '주요항' : '중소항'} + + )} + {kind === 'airport' && obj.intl && ( + 국제선 + )} +
+ {/* 설명 */} + {obj.description && ( +
{obj.description}
)} - {(staticPickInfo.kind === 'cnFacility' || staticPickInfo.kind === 'jpFacility') && obj.subType && ( -
유형: {obj.subType}
+ {obj.detail && ( +
{obj.detail}
)} + {obj.note && ( +
{obj.note}
+ )} + {/* 필드 그리드 */} +
+ {obj.operator &&
운영: {obj.operator}
} + {obj.capacity &&
규모: {obj.capacity}
} + {obj.output &&
출력: {obj.output}
} + {obj.source &&
연료: {obj.source}
} + {obj.capacityMW &&
용량: {obj.capacityMW}MW
} + {obj.turbines &&
터빈: {obj.turbines}기
} + {obj.status &&
상태: {obj.status}
} + {obj.year &&
연도: {obj.year}년
} + {obj.region &&
지역: {obj.region}
} + {obj.org &&
기관: {obj.org}
} + {obj.area &&
해역: {obj.area}
} + {obj.altitude &&
고도: {obj.altitude}
} + {obj.address &&
주소: {obj.address}
} + {obj.recentUse &&
최근 사용: {obj.recentUse}
} + {obj.recentIncidents != null &&
최근 1년: {obj.recentIncidents}건
} + {obj.icao &&
ICAO: {obj.icao}
} + {kind === 'nkMissile' && ( + <> + {obj.typeKo &&
미사일: {obj.typeKo}
} + {obj.date &&
발사일: {obj.date} {obj.time}
} + {obj.distanceKm &&
사거리: {obj.distanceKm}km
} + {obj.altitudeKm &&
최고고도: {obj.altitudeKm}km
} + {obj.flightMin &&
비행시간: {obj.flightMin}분
} + {obj.launchNameKo &&
발사지: {obj.launchNameKo}
} + + )} + {obj.name && obj.nameKo && obj.name !== obj.nameKo && ( +
영문: {obj.name}
+ )} +
+ {lat.toFixed(4)}°N, {lng.toFixed(4)}°E +
+
); diff --git a/frontend/src/components/layers/DeckGLOverlay.tsx b/frontend/src/components/layers/DeckGLOverlay.tsx index 154d59c..9e787db 100644 --- a/frontend/src/components/layers/DeckGLOverlay.tsx +++ b/frontend/src/components/layers/DeckGLOverlay.tsx @@ -12,7 +12,10 @@ interface Props { */ export function DeckGLOverlay({ layers }: Props) { const overlay = useControl( - () => new MapboxOverlay({ interleaved: true }), + () => new MapboxOverlay({ + interleaved: true, + getCursor: ({ isHovering }) => isHovering ? 'pointer' : '', + }), ); overlay.setProps({ layers }); return null; diff --git a/frontend/src/components/layers/ShipLayer.tsx b/frontend/src/components/layers/ShipLayer.tsx index 664ee33..dd40192 100644 --- a/frontend/src/components/layers/ShipLayer.tsx +++ b/frontend/src/components/layers/ShipLayer.tsx @@ -559,7 +559,7 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM layout={{ 'visibility': highlightKorean ? 'visible' : 'none', 'text-field': ['get', 'name'], - 'text-size': 9, + 'text-size': ['interpolate', ['linear'], ['zoom'], 4, 8, 6, 9, 8, 11, 10, 14, 12, 16, 13, 18, 14, 20], 'text-offset': [0, 2.2], 'text-anchor': 'top', 'text-allow-overlap': false, @@ -577,7 +577,15 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM type="symbol" layout={{ 'icon-image': 'ship-triangle', - 'icon-size': ['get', 'size'], + 'icon-size': ['interpolate', ['linear'], ['zoom'], + 4, ['*', ['get', 'size'], 0.8], + 6, ['*', ['get', 'size'], 1.0], + 8, ['*', ['get', 'size'], 1.5], + 10, ['*', ['get', 'size'], 2.2], + 12, ['*', ['get', 'size'], 2.8], + 13, ['*', ['get', 'size'], 3.5], + 14, ['*', ['get', 'size'], 4.2], + ], 'icon-rotate': ['get', 'heading'], 'icon-rotation-alignment': 'map', 'icon-allow-overlap': true, diff --git a/frontend/src/hooks/useStaticDeckLayers.ts b/frontend/src/hooks/useStaticDeckLayers.ts index 7a9a6fe..0d4c796 100644 --- a/frontend/src/hooks/useStaticDeckLayers.ts +++ b/frontend/src/hooks/useStaticDeckLayers.ts @@ -926,7 +926,7 @@ export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] { data: hazardData, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo, - getSize: 9 * ss, + getSize: 9 * sc, getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 200], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -963,7 +963,7 @@ export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] { data: cnData, getPosition: (d) => [d.lng, d.lat], getText: (d) => CN_META[d.subType]?.icon ?? '📍', - getSize: 16 * ss, + getSize: 16 * sc, getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 255], getTextAnchor: 'middle', getAlignmentBaseline: 'center', @@ -982,7 +982,7 @@ export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] { data: cnData, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.name, - getSize: 9 * ss, + getSize: 9 * sc, getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 200], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -1018,7 +1018,7 @@ export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] { data: jpData, getPosition: (d) => [d.lng, d.lat], getText: (d) => JP_META[d.subType]?.icon ?? '📍', - getSize: 16 * ss, + getSize: 16 * sc, getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 255], getTextAnchor: 'middle', getAlignmentBaseline: 'center', @@ -1037,7 +1037,7 @@ export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] { data: jpData, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.name, - getSize: 9 * ss, + getSize: 9 * sc, getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 200], getTextAnchor: 'middle', getAlignmentBaseline: 'top',