feat: 시설 Popup 디자인 통합 + LAYERS 카운트 통일 + 해외시설 토글 수정 #148
@ -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',
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user