feat: 시설 Popup 디자인 통합 + LAYERS 카운트 통일 + 해외시설 토글 수정 (#148)

This commit is contained in:
htlee 2026-03-23 08:21:59 +09:00
부모 2f0ff22d1b
커밋 e26a4db6e0
4개의 변경된 파일385개의 추가작업 그리고 91개의 파일을 삭제

파일 보기

@ -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 },
],
},
]}

파일 보기

@ -107,12 +107,27 @@ interface ExtraLayer {
group?: string;
}
const GROUP_META: Record<string, { label: string; color: string }> = {
'항공망': { label: '항공망', color: '#22d3ee' },
'국가기관망': { label: '국가기관망', color: '#f59e0b' },
'해양안전': { label: '해양안전', color: '#3b82f6' },
const GROUP_META: Record<string, { label: string; color: string; superGroup?: string }> = {
'항공망': { 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<string, { label: string; color: string }> = {
'위험/산업 인프라': { label: '위험/산업 인프라', color: '#f97316' },
};
interface OverseasItem {
key: string;
label: string;
color: string;
count?: number;
children?: OverseasItem[];
}
interface LayerPanelProps {
layers: Record<string, boolean>;
onToggle: (key: string) => void;
@ -122,6 +137,7 @@ interface LayerPanelProps {
shipTotal: number;
satelliteCount: number;
extraLayers?: ExtraLayer[];
overseasItems?: OverseasItem[];
hiddenAcCategories: Set<string>;
hiddenShipCategories: Set<string>;
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 (
<div className="layer-panel">
@ -186,7 +204,8 @@ export function LayerPanel({
{/* Ships tree */}
<LayerTreeItem
layerKey="ships"
label={`${t('layers.ships')} (${shipTotal})`}
label={t('layers.ships')}
count={shipTotal}
color="#fb923c"
active={layers.ships}
expandable
@ -297,7 +316,8 @@ export function LayerPanel({
<>
<LayerTreeItem
layerKey="nationality"
label={`국적 분류 (${Object.values(shipsByNationality).reduce((a, b) => 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 */}
<LayerTreeItem
layerKey="aircraft"
label={`${t('layers.aircraft')} (${aircraftTotal})`}
label={t('layers.aircraft')}
count={aircraftTotal}
color="#22d3ee"
active={layers.aircraft}
expandable
@ -401,7 +422,8 @@ export function LayerPanel({
{/* Satellites */}
<LayerTreeItem
layerKey="satellites"
label={`${t('layers.satellites')} (${satelliteCount})`}
label={t('layers.satellites')}
count={satelliteCount}
color="#ef4444"
active={layers.satellites}
onToggle={() => onToggle('satellites')}
@ -421,47 +443,92 @@ export function LayerPanel({
ungrouped.push(el);
}
}
// 수퍼그룹 별로 그룹 분류
const superGrouped: Record<string, string[]> = {}; // 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 (
<div key={groupName} style={indent ? { paddingLeft: 10 } : undefined}>
<LayerTreeItem
layerKey={`group-${groupName}`}
label={meta.label}
color={meta.color}
active
expandable
isExpanded={isGroupExpanded}
onToggle={() => toggleExpand(`group-${groupName}`)}
onExpand={() => toggleExpand(`group-${groupName}`)}
/>
{isGroupExpanded && (
<div className="layer-tree-children">
{items.map(el => (
<LayerTreeItem
key={el.key}
layerKey={el.key}
label={el.label}
count={el.count}
color={el.color}
active={layers[el.key] ?? false}
onToggle={() => onToggle(el.key)}
/>
))}
</div>
)}
</div>
);
};
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 (
<div key={groupName}>
<div key={sgName}>
<LayerTreeItem
layerKey={`group-${groupName}`}
label={meta.label}
color={meta.color}
layerKey={`supergroup-${sgName}`}
label={sgMeta.label}
color={sgMeta.color}
active
expandable
isExpanded={isGroupExpanded}
onToggle={() => toggleExpand(`group-${groupName}`)}
onExpand={() => toggleExpand(`group-${groupName}`)}
isExpanded={isSgExpanded}
onToggle={() => toggleExpand(`supergroup-${sgName}`)}
onExpand={() => toggleExpand(`supergroup-${sgName}`)}
/>
{isGroupExpanded && (
{isSgExpanded && (
<div className="layer-tree-children">
{items.map(el => (
<LayerTreeItem
key={el.key}
layerKey={el.key}
label={el.count != null ? `${el.label} (${el.count})` : el.label}
color={el.color}
active={layers[el.key] ?? false}
onToggle={() => onToggle(el.key)}
/>
))}
{groupNames.map(g => renderGroup(g, true))}
</div>
)}
</div>
);
})}
{/* Ungrouped layers */}
{/* 그룹 없는 개별 레이어 */}
{ungrouped.map(el => (
<LayerTreeItem
key={el.key}
layerKey={el.key}
label={el.count != null ? `${el.label} (${el.count})` : el.label}
label={el.label}
count={el.count}
color={el.color}
active={layers[el.key] ?? false}
onToggle={() => onToggle(el.key)}
@ -473,14 +540,54 @@ export function LayerPanel({
<div className="layer-divider" />
{/* Military only filter */}
{/* 해외시설 — 접기/펼치기 전용 (토글은 하위 항목에서 개별 제어) */}
<LayerTreeItem
layerKey="militaryOnly"
label={`${t('layers.militaryOnly')} (${militaryCount})`}
layerKey="overseas-section"
label="해외시설"
count={overseasItems?.reduce((sum, item) => {
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 && (
<div className="layer-tree-children">
{overseasItems.map(item => (
<div key={item.key}>
<LayerTreeItem
layerKey={item.key}
label={item.label}
color={item.color}
active={layers[item.key] ?? false}
expandable={!!item.children?.length}
isExpanded={expanded.has(`overseas-${item.key}`)}
onToggle={() => onToggle(item.key)}
onExpand={() => toggleExpand(`overseas-${item.key}`)}
/>
{item.children?.length && expanded.has(`overseas-${item.key}`) && (
<div className="layer-tree-children">
{item.children.map(child => (
<LayerTreeItem
key={child.key}
layerKey={child.key}
label={child.label}
color={child.color}
active={layers[child.key] ?? false}
onToggle={() => onToggle(child.key)}
/>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
);
@ -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%' }}
>
<span
className="layer-dot"
style={{ backgroundColor: active ? color : '#444' }}
/>
{label}
<span style={{ flex: 1 }}>{label}</span>
{count != null && (
<span style={{ fontSize: 9, color: 'var(--kcg-dim)', flexShrink: 0 }}>{count}</span>
)}
</button>
</div>
);

파일 보기

@ -632,34 +632,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<string, Record<string, { icon: string; color: string; label: string }>> = {
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<string, { icon: string; color: string; label: string }> = {
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<string, string> = { 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 (
<Popup longitude={lng} latitude={lat} anchor="bottom"
onClose={() => setStaticPickInfo(null)}
closeOnClick={false}
style={{ maxWidth: 280 }}
onClose={() => setStaticPickInfo(null)} closeOnClick={false}
maxWidth="280px" className="gl-popup"
>
<div style={{ fontFamily: 'monospace', fontSize: 11, color: '#333', padding: 4 }}>
<div style={{ fontWeight: 700, marginBottom: 4 }}>
{obj.nameKo || obj.name || obj.launchNameKo || obj.type || staticPickInfo.kind}
<div className="popup-body-sm" style={{ minWidth: 200 }}>
{/* 컬러 헤더 */}
<div className="popup-header" style={{ background: meta.color, color: '#000', gap: 6, padding: '4px 8px' }}>
<span>{meta.icon}</span> {title}
</div>
{obj.description && <div style={{ fontSize: 10, color: '#666' }}>{obj.description}</div>}
{obj.date && <div style={{ fontSize: 10 }}>: {obj.date} {obj.time || ''}</div>}
{obj.missileType && <div style={{ fontSize: 10 }}>: {obj.missileType}</div>}
{obj.range && <div style={{ fontSize: 10 }}>: {obj.range}</div>}
{obj.operator && <div style={{ fontSize: 10 }}>: {obj.operator}</div>}
{obj.capacity && <div style={{ fontSize: 10 }}>: {obj.capacity}</div>}
{staticPickInfo.kind === 'hazard' && obj.address && (
<div style={{ fontSize: 10, color: '#888' }}>📍 {obj.address}</div>
{/* 배지 행 */}
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
<span style={{
background: meta.color, color: '#000',
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
{meta.label}
</span>
{flag && (
<span style={{ background: '#333', color: '#ccc', padding: '1px 6px', borderRadius: 3, fontSize: 10 }}>
{flag} {countryName}
</span>
)}
{kind === 'hazard' && (
<span style={{
background: 'rgba(239,68,68,0.15)', color: '#ef4444',
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 600,
border: '1px solid rgba(239,68,68,0.3)',
}}> </span>
)}
{kind === 'port' && (
<span style={{ background: '#333', color: '#ccc', padding: '1px 6px', borderRadius: 3, fontSize: 10 }}>
{obj.type === 'major' ? '주요항' : '중소항'}
</span>
)}
{kind === 'airport' && obj.intl && (
<span style={{ background: '#333', color: '#ccc', padding: '1px 6px', borderRadius: 3, fontSize: 10 }}></span>
)}
</div>
{/* 설명 */}
{obj.description && (
<div style={{ fontSize: 10, color: '#999', marginBottom: 4, lineHeight: 1.5 }}>{obj.description}</div>
)}
{(staticPickInfo.kind === 'cnFacility' || staticPickInfo.kind === 'jpFacility') && obj.subType && (
<div style={{ fontSize: 10, color: '#888' }}>: {obj.subType}</div>
{obj.detail && (
<div style={{ fontSize: 10, color: '#888', marginBottom: 4, lineHeight: 1.4 }}>{obj.detail}</div>
)}
{obj.note && (
<div style={{ fontSize: 10, color: '#888', marginBottom: 4, lineHeight: 1.4 }}>{obj.note}</div>
)}
{/* 필드 그리드 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{obj.operator && <div><span className="popup-label">: </span>{obj.operator}</div>}
{obj.capacity && <div><span className="popup-label">: </span><strong>{obj.capacity}</strong></div>}
{obj.output && <div><span className="popup-label">: </span><strong>{obj.output}</strong></div>}
{obj.source && <div><span className="popup-label">: </span>{obj.source}</div>}
{obj.capacityMW && <div><span className="popup-label">: </span><strong>{obj.capacityMW}MW</strong></div>}
{obj.turbines && <div><span className="popup-label">: </span>{obj.turbines}</div>}
{obj.status && <div><span className="popup-label">: </span>{obj.status}</div>}
{obj.year && <div><span className="popup-label">: </span>{obj.year}</div>}
{obj.region && <div><span className="popup-label">: </span>{obj.region}</div>}
{obj.org && <div><span className="popup-label">: </span>{obj.org}</div>}
{obj.area && <div><span className="popup-label">: </span>{obj.area}</div>}
{obj.altitude && <div><span className="popup-label">: </span>{obj.altitude}</div>}
{obj.address && <div><span className="popup-label">: </span>{obj.address}</div>}
{obj.recentUse && <div><span className="popup-label"> : </span>{obj.recentUse}</div>}
{obj.recentIncidents != null && <div><span className="popup-label"> 1: </span><strong>{obj.recentIncidents}</strong></div>}
{obj.icao && <div><span className="popup-label">ICAO: </span>{obj.icao}</div>}
{kind === 'nkMissile' && (
<>
{obj.typeKo && <div><span className="popup-label">: </span>{obj.typeKo}</div>}
{obj.date && <div><span className="popup-label">: </span>{obj.date} {obj.time}</div>}
{obj.distanceKm && <div><span className="popup-label">: </span>{obj.distanceKm}km</div>}
{obj.altitudeKm && <div><span className="popup-label">: </span>{obj.altitudeKm}km</div>}
{obj.flightMin && <div><span className="popup-label">: </span>{obj.flightMin}</div>}
{obj.launchNameKo && <div><span className="popup-label">: </span>{obj.launchNameKo}</div>}
</>
)}
{obj.name && obj.nameKo && obj.name !== obj.nameKo && (
<div><span className="popup-label">: </span>{obj.name}</div>
)}
<div style={{ fontSize: 9, color: '#666', marginTop: 2 }}>
{lat.toFixed(4)}°N, {lng.toFixed(4)}°E
</div>
</div>
</div>
</Popup>
);

파일 보기

@ -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',