refactor: Phase 2 완료 — useStaticDeckLayers 분할 (1,086줄→85줄)
- useStaticDeckLayers를 조합 훅으로 전환 (서브훅 호출만) - createPortLayers: 항구 + 풍력단지 (145줄) - createNavigationLayers: 해경 + 공항 + 항행경보 + 해적 (332줄) - createMilitaryLayers: 군사시설 + 정부기관 + NK 발사/미사일 (272줄) - createFacilityLayers: 인프라 + 위험시설 + CN/JP 시설 (310줄) - layers/types.ts: 공유 타입 + hexToRgb (49줄) - 각 서브훅은 SVG/색상 상수를 자체 포함 (독립 모듈)
This commit is contained in:
부모
19e5ff23aa
커밋
8acf8824fb
310
frontend/src/hooks/layers/createFacilityLayers.ts
Normal file
310
frontend/src/hooks/layers/createFacilityLayers.ts
Normal file
@ -0,0 +1,310 @@
|
||||
import { IconLayer, TextLayer } from '@deck.gl/layers';
|
||||
import { svgToDataUri } from '../../utils/svgToDataUri';
|
||||
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 {
|
||||
hexToRgb,
|
||||
type LayerFactoryConfig,
|
||||
type Layer,
|
||||
type PickingInfo,
|
||||
type PowerFacility,
|
||||
type HazardFacility,
|
||||
type HazardType,
|
||||
type CnFacility,
|
||||
type JpFacility,
|
||||
} from './types';
|
||||
|
||||
// ─── Infra SVG ────────────────────────────────────────────────────────────────
|
||||
|
||||
const INFRA_SOURCE_COLOR: Record<string, string> = {
|
||||
nuclear: '#e040fb',
|
||||
coal: '#795548',
|
||||
gas: '#ff9800',
|
||||
oil: '#5d4037',
|
||||
hydro: '#2196f3',
|
||||
solar: '#ffc107',
|
||||
wind: '#00bcd4',
|
||||
biomass: '#4caf50',
|
||||
};
|
||||
const INFRA_SUBSTATION_COLOR = '#ffeb3b';
|
||||
|
||||
const WIND_COLOR = '#00bcd4';
|
||||
|
||||
function windTurbineSvg(size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<line x1="12" y1="10" x2="11" y2="23" stroke="${WIND_COLOR}" stroke-width="1.5"/>
|
||||
<line x1="12" y1="10" x2="13" y2="23" stroke="${WIND_COLOR}" stroke-width="1.5"/>
|
||||
<circle cx="12" cy="9" r="1.8" fill="${WIND_COLOR}"/>
|
||||
<path d="M12 9 L11.5 1 Q12 0 12.5 1 Z" fill="${WIND_COLOR}" opacity="0.9"/>
|
||||
<path d="M12 9 L18 14 Q18.5 13 17.5 12.5 Z" fill="${WIND_COLOR}" opacity="0.9"/>
|
||||
<path d="M12 9 L6 14 Q5.5 13 6.5 12.5 Z" fill="${WIND_COLOR}" opacity="0.9"/>
|
||||
<line x1="8" y1="23" x2="16" y2="23" stroke="${WIND_COLOR}" stroke-width="1.5"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function infraColor(f: PowerFacility): string {
|
||||
if (f.type === 'substation') return INFRA_SUBSTATION_COLOR;
|
||||
return INFRA_SOURCE_COLOR[f.source ?? ''] ?? '#9e9e9e';
|
||||
}
|
||||
|
||||
function infraSvg(f: PowerFacility): string {
|
||||
const color = infraColor(f);
|
||||
if (f.source === 'wind') {
|
||||
return windTurbineSvg(14).replace(`stroke="${WIND_COLOR}"`, `stroke="${color}"`).replace(new RegExp(`fill="${WIND_COLOR}"`, 'g'), `fill="${color}"`);
|
||||
}
|
||||
const size = f.type === 'substation' ? 7 : 12;
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.5" y="0.5" width="${size - 1}" height="${size - 1}" rx="1" fill="#111" stroke="${color}" stroke-width="1"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ─── createFacilityLayers ─────────────────────────────────────────────────────
|
||||
|
||||
export function createFacilityLayers(
|
||||
config: {
|
||||
infra: boolean;
|
||||
infraFacilities: PowerFacility[];
|
||||
hazardTypes: HazardType[];
|
||||
cnPower: boolean;
|
||||
cnMilitary: boolean;
|
||||
jpPower: boolean;
|
||||
jpMilitary: boolean;
|
||||
},
|
||||
fc: LayerFactoryConfig,
|
||||
): Layer[] {
|
||||
const layers: Layer[] = [];
|
||||
const sc = fc.sc;
|
||||
const onPick = fc.onPick;
|
||||
|
||||
// ── Infra ──────────────────────────────────────────────────────────────
|
||||
if (config.infra && config.infraFacilities.length > 0) {
|
||||
const infraIconCache = new Map<string, string>();
|
||||
function getInfraIconUrl(f: PowerFacility): string {
|
||||
const key = `${f.type}-${f.source ?? ''}`;
|
||||
if (!infraIconCache.has(key)) {
|
||||
infraIconCache.set(key, svgToDataUri(infraSvg(f)));
|
||||
}
|
||||
return infraIconCache.get(key)!;
|
||||
}
|
||||
|
||||
const plants = config.infraFacilities.filter(f => f.type === 'plant');
|
||||
const substations = config.infraFacilities.filter(f => f.type === 'substation');
|
||||
|
||||
layers.push(
|
||||
new IconLayer<PowerFacility>({
|
||||
id: 'static-infra-substation',
|
||||
data: substations,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: (d) => ({ url: getInfraIconUrl(d), width: 14, height: 14, anchorX: 7, anchorY: 7 }),
|
||||
getSize: 7 * sc,
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<PowerFacility>) => {
|
||||
if (info.object) onPick({ kind: 'infra', object: info.object });
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
new IconLayer<PowerFacility>({
|
||||
id: 'static-infra-plant',
|
||||
data: plants,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: (d) => ({ url: getInfraIconUrl(d), width: 24, height: 24, anchorX: 12, anchorY: 12 }),
|
||||
getSize: 12 * sc,
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<PowerFacility>) => {
|
||||
if (info.object) onPick({ kind: 'infra', object: info.object });
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
new TextLayer<PowerFacility>({
|
||||
id: 'static-infra-plant-label',
|
||||
data: plants,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name),
|
||||
getSize: 8 * sc,
|
||||
getColor: (d) => [...hexToRgb(infraColor(d)), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 8],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 600,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Hazard Facilities ──────────────────────────────────────────────────
|
||||
if (config.hazardTypes.length > 0) {
|
||||
const hazardTypeSet = new Set(config.hazardTypes);
|
||||
const hazardData = HAZARD_FACILITIES.filter(f => hazardTypeSet.has(f.type));
|
||||
|
||||
const HAZARD_META: Record<string, { icon: string; color: [number, number, number, number] }> = {
|
||||
petrochemical: { icon: '🏭', color: [249, 115, 22, 255] },
|
||||
lng: { icon: '🔵', color: [6, 182, 212, 255] },
|
||||
oilTank: { icon: '🛢️', color: [234, 179, 8, 255] },
|
||||
hazardPort: { icon: '⚠️', color: [239, 68, 68, 255] },
|
||||
nuclear: { icon: '☢️', color: [168, 85, 247, 255] },
|
||||
thermal: { icon: '🔥', color: [100, 116, 139, 255] },
|
||||
shipyard: { icon: '🚢', color: [14, 165, 233, 255] },
|
||||
wastewater: { icon: '💧', color: [16, 185, 129, 255] },
|
||||
heavyIndustry: { icon: '⚙️', color: [148, 163, 184, 255] },
|
||||
};
|
||||
|
||||
if (hazardData.length > 0) {
|
||||
layers.push(
|
||||
new TextLayer<HazardFacility>({
|
||||
id: 'static-hazard-emoji',
|
||||
data: hazardData,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => HAZARD_META[d.type]?.icon ?? '⚠️',
|
||||
getSize: 16 * sc,
|
||||
getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 255],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<HazardFacility>) => {
|
||||
if (info.object) onPick({ kind: 'hazard', object: info.object });
|
||||
return true;
|
||||
},
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
);
|
||||
layers.push(
|
||||
new TextLayer<HazardFacility>({
|
||||
id: 'static-hazard-label',
|
||||
data: hazardData,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo,
|
||||
getSize: 9 * sc,
|
||||
getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 200],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 12],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 600,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── CN Facilities ──────────────────────────────────────────────────────
|
||||
{
|
||||
const CN_META: Record<string, { icon: string; color: [number, number, number, number] }> = {
|
||||
nuclear: { icon: '☢️', color: [239, 68, 68, 255] },
|
||||
thermal: { icon: '🔥', color: [249, 115, 22, 255] },
|
||||
naval: { icon: '⚓', color: [59, 130, 246, 255] },
|
||||
airbase: { icon: '✈️', color: [34, 211, 238, 255] },
|
||||
army: { icon: '🪖', color: [34, 197, 94, 255] },
|
||||
shipyard: { icon: '🚢', color: [148, 163, 184, 255] },
|
||||
};
|
||||
const cnData: CnFacility[] = [
|
||||
...(config.cnPower ? CN_POWER_PLANTS : []),
|
||||
...(config.cnMilitary ? CN_MILITARY_FACILITIES : []),
|
||||
];
|
||||
if (cnData.length > 0) {
|
||||
layers.push(
|
||||
new TextLayer<CnFacility>({
|
||||
id: 'static-cn-emoji',
|
||||
data: cnData,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => CN_META[d.subType]?.icon ?? '📍',
|
||||
getSize: 16 * sc,
|
||||
getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 255],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<CnFacility>) => {
|
||||
if (info.object) onPick({ kind: 'cnFacility', object: info.object });
|
||||
return true;
|
||||
},
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
);
|
||||
layers.push(
|
||||
new TextLayer<CnFacility>({
|
||||
id: 'static-cn-label',
|
||||
data: cnData,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.name,
|
||||
getSize: 9 * sc,
|
||||
getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 200],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 12],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 600,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── JP Facilities ──────────────────────────────────────────────────────
|
||||
{
|
||||
const JP_META: Record<string, { icon: string; color: [number, number, number, number] }> = {
|
||||
nuclear: { icon: '☢️', color: [239, 68, 68, 255] },
|
||||
thermal: { icon: '🔥', color: [249, 115, 22, 255] },
|
||||
naval: { icon: '⚓', color: [59, 130, 246, 255] },
|
||||
airbase: { icon: '✈️', color: [34, 211, 238, 255] },
|
||||
army: { icon: '🪖', color: [34, 197, 94, 255] },
|
||||
};
|
||||
const jpData: JpFacility[] = [
|
||||
...(config.jpPower ? JP_POWER_PLANTS : []),
|
||||
...(config.jpMilitary ? JP_MILITARY_FACILITIES : []),
|
||||
];
|
||||
if (jpData.length > 0) {
|
||||
layers.push(
|
||||
new TextLayer<JpFacility>({
|
||||
id: 'static-jp-emoji',
|
||||
data: jpData,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => JP_META[d.subType]?.icon ?? '📍',
|
||||
getSize: 16 * sc,
|
||||
getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 255],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<JpFacility>) => {
|
||||
if (info.object) onPick({ kind: 'jpFacility', object: info.object });
|
||||
return true;
|
||||
},
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
);
|
||||
layers.push(
|
||||
new TextLayer<JpFacility>({
|
||||
id: 'static-jp-label',
|
||||
data: jpData,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.name,
|
||||
getSize: 9 * sc,
|
||||
getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 200],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 12],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 600,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return layers;
|
||||
}
|
||||
272
frontend/src/hooks/layers/createMilitaryLayers.ts
Normal file
272
frontend/src/hooks/layers/createMilitaryLayers.ts
Normal file
@ -0,0 +1,272 @@
|
||||
import { IconLayer, TextLayer, PathLayer } from '@deck.gl/layers';
|
||||
import type { PickingInfo, Layer } from '@deck.gl/core';
|
||||
import { svgToDataUri } from '../../utils/svgToDataUri';
|
||||
import { MILITARY_BASES } from '../../data/militaryBases';
|
||||
import type { MilitaryBase } from '../../data/militaryBases';
|
||||
import { GOV_BUILDINGS } from '../../data/govBuildings';
|
||||
import type { GovBuilding } from '../../data/govBuildings';
|
||||
import { NK_LAUNCH_SITES, NK_LAUNCH_TYPE_META } from '../../data/nkLaunchSites';
|
||||
import type { NKLaunchSite } from '../../data/nkLaunchSites';
|
||||
import { NK_MISSILE_EVENTS } from '../../data/nkMissileEvents';
|
||||
import type { NKMissileEvent } from '../../data/nkMissileEvents';
|
||||
import { hexToRgb } from './types';
|
||||
import type { LayerFactoryConfig } from './types';
|
||||
|
||||
// ─── NKMissile SVG ────────────────────────────────────────────────────────────
|
||||
|
||||
function getMissileColor(type: string): string {
|
||||
if (type.includes('ICBM')) return '#dc2626';
|
||||
if (type.includes('IRBM')) return '#ef4444';
|
||||
if (type.includes('SLBM')) return '#3b82f6';
|
||||
return '#f97316';
|
||||
}
|
||||
|
||||
function missileLaunchSvg(color: string): string {
|
||||
return `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<polygon points="12,2 22,20 2,20" fill="${color}" stroke="#fff" stroke-width="1"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function missileImpactSvg(color: string): string {
|
||||
return `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.5"/>
|
||||
<line x1="7" y1="7" x2="17" y2="17" stroke="${color}" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<line x1="17" y1="7" x2="7" y2="17" stroke="${color}" stroke-width="2.5" stroke-linecap="round"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
export function createMilitaryLayers(
|
||||
config: { militaryBases: boolean; govBuildings: boolean; nkLaunch: boolean; nkMissile: boolean },
|
||||
fc: LayerFactoryConfig,
|
||||
): Layer[] {
|
||||
const layers: Layer[] = [];
|
||||
const sc = fc.sc;
|
||||
const onPick = fc.onPick;
|
||||
|
||||
// ── Military Bases — TextLayer (이모지) ───────────────────────────────
|
||||
if (config.militaryBases) {
|
||||
const TYPE_COLOR: Record<string, string> = {
|
||||
naval: '#3b82f6', airforce: '#f59e0b', army: '#22c55e',
|
||||
missile: '#ef4444', joint: '#a78bfa',
|
||||
};
|
||||
const TYPE_ICON: Record<string, string> = {
|
||||
naval: '⚓', airforce: '✈', army: '🪖', missile: '🚀', joint: '⭐',
|
||||
};
|
||||
layers.push(
|
||||
new TextLayer<MilitaryBase>({
|
||||
id: 'static-militarybase-emoji',
|
||||
data: MILITARY_BASES,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => TYPE_ICON[d.type] ?? '⭐',
|
||||
getSize: 14 * sc,
|
||||
getColor: [255, 255, 255, 220],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<MilitaryBase>) => {
|
||||
if (info.object) onPick({ kind: 'militaryBase', object: info.object });
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
new TextLayer<MilitaryBase>({
|
||||
id: 'static-militarybase-label',
|
||||
data: MILITARY_BASES,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => (d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo),
|
||||
getSize: 8 * sc,
|
||||
getColor: (d) => [...hexToRgb(TYPE_COLOR[d.type] ?? '#a78bfa'), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 9],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Gov Buildings — TextLayer (이모지) ─────────────────────────────────
|
||||
if (config.govBuildings) {
|
||||
const GOV_TYPE_COLOR: Record<string, string> = {
|
||||
executive: '#f59e0b', legislature: '#a78bfa', military_hq: '#ef4444',
|
||||
intelligence: '#6366f1', foreign: '#3b82f6', maritime: '#06b6d4', defense: '#dc2626',
|
||||
};
|
||||
const GOV_TYPE_ICON: Record<string, string> = {
|
||||
executive: '🏛', legislature: '🏛', military_hq: '⭐',
|
||||
intelligence: '🔍', foreign: '🌐', maritime: '⚓', defense: '🛡',
|
||||
};
|
||||
layers.push(
|
||||
new TextLayer<GovBuilding>({
|
||||
id: 'static-govbuilding-emoji',
|
||||
data: GOV_BUILDINGS,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => GOV_TYPE_ICON[d.type] ?? '🏛',
|
||||
getSize: 12 * sc,
|
||||
getColor: [255, 255, 255, 220],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<GovBuilding>) => {
|
||||
if (info.object) onPick({ kind: 'govBuilding', object: info.object });
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
new TextLayer<GovBuilding>({
|
||||
id: 'static-govbuilding-label',
|
||||
data: GOV_BUILDINGS,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo),
|
||||
getSize: 8 * sc,
|
||||
getColor: (d) => [...hexToRgb(GOV_TYPE_COLOR[d.type] ?? '#f59e0b'), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 8],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── NK Launch Sites — TextLayer (이모지) ──────────────────────────────
|
||||
if (config.nkLaunch) {
|
||||
layers.push(
|
||||
new TextLayer<NKLaunchSite>({
|
||||
id: 'static-nklaunch-emoji',
|
||||
data: NK_LAUNCH_SITES,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => NK_LAUNCH_TYPE_META[d.type]?.icon ?? '🚀',
|
||||
getSize: (d) => (d.type === 'artillery' || d.type === 'mlrs' ? 10 : 13) * sc,
|
||||
getColor: [255, 255, 255, 220],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<NKLaunchSite>) => {
|
||||
if (info.object) onPick({ kind: 'nkLaunch', object: info.object });
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
new TextLayer<NKLaunchSite>({
|
||||
id: 'static-nklaunch-label',
|
||||
data: NK_LAUNCH_SITES,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo),
|
||||
getSize: 8 * sc,
|
||||
getColor: (d) => [...hexToRgb(NK_LAUNCH_TYPE_META[d.type]?.color ?? '#f97316'), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 8],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── NK Missile Events — IconLayer ─────────────────────────────────────
|
||||
if (config.nkMissile) {
|
||||
const launchIconCache = new Map<string, string>();
|
||||
function getLaunchIconUrl(type: string): string {
|
||||
if (!launchIconCache.has(type)) {
|
||||
launchIconCache.set(type, svgToDataUri(missileLaunchSvg(getMissileColor(type))));
|
||||
}
|
||||
return launchIconCache.get(type)!;
|
||||
}
|
||||
const impactIconCache = new Map<string, string>();
|
||||
function getImpactIconUrl(type: string): string {
|
||||
if (!impactIconCache.has(type)) {
|
||||
impactIconCache.set(type, svgToDataUri(missileImpactSvg(getMissileColor(type))));
|
||||
}
|
||||
return impactIconCache.get(type)!;
|
||||
}
|
||||
|
||||
interface LaunchPoint { ev: NKMissileEvent; lat: number; lng: number }
|
||||
interface ImpactPoint { ev: NKMissileEvent; lat: number; lng: number }
|
||||
|
||||
const launchData: LaunchPoint[] = NK_MISSILE_EVENTS.map(ev => ({ ev, lat: ev.launchLat, lng: ev.launchLng }));
|
||||
const impactData: ImpactPoint[] = NK_MISSILE_EVENTS.map(ev => ({ ev, lat: ev.impactLat, lng: ev.impactLng }));
|
||||
|
||||
// 발사→착탄 궤적선
|
||||
const trajectoryData = NK_MISSILE_EVENTS.map(ev => ({
|
||||
path: [[ev.launchLng, ev.launchLat], [ev.impactLng, ev.impactLat]] as [number, number][],
|
||||
color: hexToRgb(getMissileColor(ev.type)),
|
||||
}));
|
||||
layers.push(
|
||||
new PathLayer<{ path: [number, number][]; color: [number, number, number] }>({
|
||||
id: 'static-nkmissile-trajectory',
|
||||
data: trajectoryData,
|
||||
getPath: (d) => d.path,
|
||||
getColor: (d) => [...d.color, 150] as [number, number, number, number],
|
||||
getWidth: 2,
|
||||
widthUnits: 'pixels',
|
||||
getDashArray: [6, 3],
|
||||
dashJustified: true,
|
||||
extensions: [],
|
||||
}),
|
||||
);
|
||||
|
||||
layers.push(
|
||||
new IconLayer<LaunchPoint>({
|
||||
id: 'static-nkmissile-launch',
|
||||
data: launchData,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: (d) => ({ url: getLaunchIconUrl(d.ev.type), width: 24, height: 24, anchorX: 12, anchorY: 12 }),
|
||||
getSize: 12 * sc,
|
||||
getColor: (d) => {
|
||||
const today = new Date().toISOString().slice(0, 10) === d.ev.date;
|
||||
return [255, 255, 255, today ? 255 : 90] as [number, number, number, number];
|
||||
},
|
||||
}),
|
||||
new IconLayer<ImpactPoint>({
|
||||
id: 'static-nkmissile-impact',
|
||||
data: impactData,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: (d) => ({ url: getImpactIconUrl(d.ev.type), width: 32, height: 32, anchorX: 16, anchorY: 16 }),
|
||||
getSize: 16 * sc,
|
||||
getColor: (d) => {
|
||||
const today = new Date().toISOString().slice(0, 10) === d.ev.date;
|
||||
return [255, 255, 255, today ? 255 : 100] as [number, number, number, number];
|
||||
},
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<ImpactPoint>) => {
|
||||
if (info.object) onPick({ kind: 'nkMissile', object: info.object.ev });
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
new TextLayer<ImpactPoint>({
|
||||
id: 'static-nkmissile-label',
|
||||
data: impactData,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => `${d.ev.date.slice(5)} ${d.ev.time} ← ${d.ev.launchNameKo}`,
|
||||
getSize: 8 * sc,
|
||||
getColor: (d) => [...hexToRgb(getMissileColor(d.ev.type)), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 10],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return layers;
|
||||
}
|
||||
332
frontend/src/hooks/layers/createNavigationLayers.ts
Normal file
332
frontend/src/hooks/layers/createNavigationLayers.ts
Normal file
@ -0,0 +1,332 @@
|
||||
import { IconLayer, TextLayer } from '@deck.gl/layers';
|
||||
import type { PickingInfo, Layer } from '@deck.gl/core';
|
||||
import { svgToDataUri } from '../../utils/svgToDataUri';
|
||||
import { COAST_GUARD_FACILITIES } from '../../services/coastGuard';
|
||||
import type { CoastGuardFacility, CoastGuardType } from '../../services/coastGuard';
|
||||
import { KOREAN_AIRPORTS } from '../../services/airports';
|
||||
import type { KoreanAirport } from '../../services/airports';
|
||||
import { NAV_WARNINGS } from '../../services/navWarning';
|
||||
import type { NavWarning, NavWarningLevel, TrainingOrg } from '../../services/navWarning';
|
||||
import { PIRACY_ZONES, PIRACY_LEVEL_COLOR } from '../../services/piracy';
|
||||
import type { PiracyZone } from '../../services/piracy';
|
||||
import { hexToRgb } from './types';
|
||||
import type { LayerFactoryConfig } from './types';
|
||||
|
||||
// ─── CoastGuard ───────────────────────────────────────────────────────────────
|
||||
|
||||
const CG_TYPE_COLOR: Record<CoastGuardType, string> = {
|
||||
hq: '#ff6b6b',
|
||||
regional: '#ffa94d',
|
||||
station: '#4dabf7',
|
||||
substation: '#69db7c',
|
||||
vts: '#da77f2',
|
||||
navy: '#3b82f6',
|
||||
};
|
||||
|
||||
function coastGuardSvg(type: CoastGuardType, size: number): string {
|
||||
const color = CG_TYPE_COLOR[type];
|
||||
if (type === 'navy') {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 16 Q12 20 22 16 L22 12 Q12 8 2 12 Z" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<line x1="12" y1="4" x2="12" y2="12" stroke="${color}" stroke-width="1.5"/>
|
||||
<circle cx="12" cy="4" r="2" fill="${color}"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12" stroke="${color}" stroke-width="1"/>
|
||||
</svg>`;
|
||||
}
|
||||
if (type === 'vts') {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<line x1="12" y1="18" x2="12" y2="10" stroke="${color}" stroke-width="1.5"/>
|
||||
<circle cx="12" cy="9" r="2" fill="none" stroke="${color}" stroke-width="1"/>
|
||||
<path d="M7 7 Q12 3 17 7" fill="none" stroke="${color}" stroke-width="0.8" opacity="0.6"/>
|
||||
</svg>`;
|
||||
}
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2 L20 6 L20 13 Q20 19 12 22 Q4 19 4 13 L4 6 Z" fill="rgba(0,0,0,0.6)" stroke="${color}" stroke-width="1.2"/>
|
||||
<circle cx="12" cy="9" r="2" fill="none" stroke="#fff" stroke-width="1"/>
|
||||
<line x1="12" y1="11" x2="12" y2="18" stroke="#fff" stroke-width="1"/>
|
||||
<path d="M8 16 Q12 20 16 16" fill="none" stroke="#fff" stroke-width="1"/>
|
||||
<line x1="9" y1="13" x2="15" y2="13" stroke="#fff" stroke-width="0.8"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
const CG_TYPE_SIZE: Record<CoastGuardType, number> = {
|
||||
hq: 24,
|
||||
regional: 20,
|
||||
station: 16,
|
||||
substation: 13,
|
||||
vts: 14,
|
||||
navy: 18,
|
||||
};
|
||||
|
||||
// ─── Airport ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const AP_COUNTRY_COLOR: Record<string, { intl: string; domestic: string }> = {
|
||||
KR: { intl: '#a78bfa', domestic: '#7c8aaa' },
|
||||
CN: { intl: '#ef4444', domestic: '#b91c1c' },
|
||||
JP: { intl: '#f472b6', domestic: '#9d174d' },
|
||||
KP: { intl: '#f97316', domestic: '#c2410c' },
|
||||
TW: { intl: '#10b981', domestic: '#059669' },
|
||||
};
|
||||
|
||||
function apColor(ap: KoreanAirport): string {
|
||||
const cc = AP_COUNTRY_COLOR[ap.country ?? 'KR'] ?? AP_COUNTRY_COLOR.KR;
|
||||
return ap.intl ? cc.intl : cc.domestic;
|
||||
}
|
||||
|
||||
function airportSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<path d="M12 5 C11.4 5 11 5.4 11 5.8 L11 10 L6 13 L6 14.2 L11 12.5 L11 16.5 L9.2 17.8 L9.2 18.8 L12 18 L14.8 18.8 L14.8 17.8 L13 16.5 L13 12.5 L18 14.2 L18 13 L13 10 L13 5.8 C13 5.4 12.6 5 12 5Z"
|
||||
fill="${color}" stroke="#fff" stroke-width="0.3"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ─── NavWarning ───────────────────────────────────────────────────────────────
|
||||
|
||||
const NW_ORG_COLOR: Record<TrainingOrg, string> = {
|
||||
'해군': '#8b5cf6',
|
||||
'해병대': '#22c55e',
|
||||
'공군': '#f97316',
|
||||
'육군': '#ef4444',
|
||||
'해경': '#3b82f6',
|
||||
'국과연': '#eab308',
|
||||
};
|
||||
|
||||
function navWarningSvg(level: NavWarningLevel, org: TrainingOrg, size: number): string {
|
||||
const color = NW_ORG_COLOR[org];
|
||||
if (level === 'danger') {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 3 L22 20 L2 20 Z" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.5"/>
|
||||
<line x1="12" y1="9" x2="12" y2="14" stroke="${color}" stroke-width="2" stroke-linecap="round"/>
|
||||
<circle cx="12" cy="17" r="1" fill="${color}"/>
|
||||
</svg>`;
|
||||
}
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2 L22 12 L12 22 L2 12 Z" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<line x1="12" y1="8" x2="12" y2="13" stroke="${color}" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<circle cx="12" cy="16" r="1" fill="${color}"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ─── Piracy ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function piracySvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<ellipse cx="12" cy="10" rx="8" ry="9" fill="rgba(0,0,0,0.6)" stroke="${color}" stroke-width="1.5"/>
|
||||
<ellipse cx="8.5" cy="9" rx="2" ry="2.2" fill="${color}" opacity="0.9"/>
|
||||
<ellipse cx="15.5" cy="9" rx="2" ry="2.2" fill="${color}" opacity="0.9"/>
|
||||
<path d="M11 13 L12 14.5 L13 13" stroke="${color}" stroke-width="1" fill="none"/>
|
||||
<path d="M7 17 Q12 21 17 17" stroke="${color}" stroke-width="1.2" fill="none"/>
|
||||
<line x1="4" y1="20" x2="20" y2="24" stroke="${color}" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<line x1="20" y1="20" x2="4" y2="24" stroke="${color}" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
export function createNavigationLayers(
|
||||
config: { coastGuard: boolean; airports: boolean; navWarning: boolean; piracy: boolean },
|
||||
fc: LayerFactoryConfig,
|
||||
): Layer[] {
|
||||
const layers: Layer[] = [];
|
||||
const sc = fc.sc;
|
||||
const onPick = fc.onPick;
|
||||
|
||||
// ── Coast Guard ────────────────────────────────────────────────────────
|
||||
if (config.coastGuard) {
|
||||
const cgIconCache = new Map<CoastGuardType, string>();
|
||||
function getCgIconUrl(type: CoastGuardType): string {
|
||||
if (!cgIconCache.has(type)) {
|
||||
const size = CG_TYPE_SIZE[type];
|
||||
cgIconCache.set(type, svgToDataUri(coastGuardSvg(type, size * 2)));
|
||||
}
|
||||
return cgIconCache.get(type)!;
|
||||
}
|
||||
|
||||
layers.push(
|
||||
new IconLayer<CoastGuardFacility>({
|
||||
id: 'static-coastguard-icon',
|
||||
data: COAST_GUARD_FACILITIES,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: (d) => {
|
||||
const sz = CG_TYPE_SIZE[d.type] * 2;
|
||||
return { url: getCgIconUrl(d.type), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 };
|
||||
},
|
||||
getSize: (d) => CG_TYPE_SIZE[d.type] * sc,
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<CoastGuardFacility>) => {
|
||||
if (info.object) onPick({ kind: 'coastGuard', object: info.object });
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
new TextLayer<CoastGuardFacility>({
|
||||
id: 'static-coastguard-label',
|
||||
data: COAST_GUARD_FACILITIES.filter(f => f.type === 'hq' || f.type === 'regional' || f.type === 'navy' || f.type === 'vts'),
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => {
|
||||
if (d.type === 'vts') return 'VTS';
|
||||
if (d.type === 'navy') return d.name.replace('해군', '').replace('사령부', '사').replace('전대', '전').replace('전단', '전').trim().slice(0, 8);
|
||||
return d.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청';
|
||||
},
|
||||
getSize: 8 * sc,
|
||||
getColor: (d) => [...hexToRgb(CG_TYPE_COLOR[d.type]), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 8],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Airports ───────────────────────────────────────────────────────────
|
||||
if (config.airports) {
|
||||
const apIconCache = new Map<string, string>();
|
||||
function getApIconUrl(ap: KoreanAirport): string {
|
||||
const color = apColor(ap);
|
||||
const size = ap.intl ? 40 : 32;
|
||||
const key = `${color}-${size}`;
|
||||
if (!apIconCache.has(key)) {
|
||||
apIconCache.set(key, svgToDataUri(airportSvg(color, size)));
|
||||
}
|
||||
return apIconCache.get(key)!;
|
||||
}
|
||||
|
||||
layers.push(
|
||||
new IconLayer<KoreanAirport>({
|
||||
id: 'static-airports-icon',
|
||||
data: KOREAN_AIRPORTS,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: (d) => {
|
||||
const sz = d.intl ? 40 : 32;
|
||||
return { url: getApIconUrl(d), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 };
|
||||
},
|
||||
getSize: (d) => (d.intl ? 20 : 16) * sc,
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<KoreanAirport>) => {
|
||||
if (info.object) onPick({ kind: 'airport', object: info.object });
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
new TextLayer<KoreanAirport>({
|
||||
id: 'static-airports-label',
|
||||
data: KOREAN_AIRPORTS,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.nameKo.replace('국제공항', '').replace('공항', ''),
|
||||
getSize: 9 * sc,
|
||||
getColor: (d) => [...hexToRgb(apColor(d)), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 10],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── NavWarning ─────────────────────────────────────────────────────────
|
||||
if (config.navWarning) {
|
||||
const nwIconCache = new Map<string, string>();
|
||||
function getNwIconUrl(w: NavWarning): string {
|
||||
const key = `${w.level}-${w.org}`;
|
||||
if (!nwIconCache.has(key)) {
|
||||
const size = w.level === 'danger' ? 32 : 28;
|
||||
nwIconCache.set(key, svgToDataUri(navWarningSvg(w.level, w.org, size)));
|
||||
}
|
||||
return nwIconCache.get(key)!;
|
||||
}
|
||||
|
||||
layers.push(
|
||||
new IconLayer<NavWarning>({
|
||||
id: 'static-navwarning-icon',
|
||||
data: NAV_WARNINGS,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: (d) => {
|
||||
const sz = d.level === 'danger' ? 32 : 28;
|
||||
return { url: getNwIconUrl(d), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 };
|
||||
},
|
||||
getSize: (d) => (d.level === 'danger' ? 16 : 14) * sc,
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<NavWarning>) => {
|
||||
if (info.object) onPick({ kind: 'navWarning', object: info.object });
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
new TextLayer<NavWarning>({
|
||||
id: 'static-navwarning-label',
|
||||
data: NAV_WARNINGS,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.id,
|
||||
getSize: 8 * sc,
|
||||
getColor: (d) => [...hexToRgb(NW_ORG_COLOR[d.org]), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 9],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Piracy ─────────────────────────────────────────────────────────────
|
||||
if (config.piracy) {
|
||||
const piracyIconCache = new Map<string, string>();
|
||||
function getPiracyIconUrl(zone: PiracyZone): string {
|
||||
const key = zone.level;
|
||||
if (!piracyIconCache.has(key)) {
|
||||
const color = PIRACY_LEVEL_COLOR[zone.level];
|
||||
const size = zone.level === 'critical' ? 56 : zone.level === 'high' ? 48 : 40;
|
||||
piracyIconCache.set(key, svgToDataUri(piracySvg(color, size)));
|
||||
}
|
||||
return piracyIconCache.get(key)!;
|
||||
}
|
||||
|
||||
layers.push(
|
||||
new IconLayer<PiracyZone>({
|
||||
id: 'static-piracy-icon',
|
||||
data: PIRACY_ZONES,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: (d) => {
|
||||
const sz = d.level === 'critical' ? 56 : d.level === 'high' ? 48 : 40;
|
||||
return { url: getPiracyIconUrl(d), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 };
|
||||
},
|
||||
getSize: (d) => (d.level === 'critical' ? 28 : d.level === 'high' ? 24 : 20) * sc,
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<PiracyZone>) => {
|
||||
if (info.object) onPick({ kind: 'piracy', object: info.object });
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
new TextLayer<PiracyZone>({
|
||||
id: 'static-piracy-label',
|
||||
data: PIRACY_ZONES,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.nameKo,
|
||||
getSize: 9 * sc,
|
||||
getColor: (d) => [...hexToRgb(PIRACY_LEVEL_COLOR[d.level] ?? '#ef4444'), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 14],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return layers;
|
||||
}
|
||||
145
frontend/src/hooks/layers/createPortLayers.ts
Normal file
145
frontend/src/hooks/layers/createPortLayers.ts
Normal file
@ -0,0 +1,145 @@
|
||||
import { IconLayer, TextLayer } from '@deck.gl/layers';
|
||||
import type { PickingInfo, Layer } from '@deck.gl/core';
|
||||
import { svgToDataUri } from '../../utils/svgToDataUri';
|
||||
import { EAST_ASIA_PORTS } from '../../data/ports';
|
||||
import type { Port } from '../../data/ports';
|
||||
import { KOREA_WIND_FARMS } from '../../data/windFarms';
|
||||
import type { WindFarm } from '../../data/windFarms';
|
||||
import { hexToRgb } from './types';
|
||||
import type { LayerFactoryConfig } from './types';
|
||||
|
||||
// ─── Port colors ──────────────────────────────────────────────────────────────
|
||||
|
||||
const PORT_COUNTRY_COLOR: Record<string, string> = {
|
||||
KR: '#3b82f6',
|
||||
CN: '#ef4444',
|
||||
JP: '#f472b6',
|
||||
KP: '#f97316',
|
||||
TW: '#10b981',
|
||||
};
|
||||
|
||||
// ─── Port SVG ────────────────────────────────────────────────────────────────
|
||||
|
||||
function portSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="5" r="2.5" stroke="${color}" stroke-width="1.5" fill="none"/>
|
||||
<line x1="12" y1="7.5" x2="12" y2="21" stroke="${color}" stroke-width="1.5"/>
|
||||
<line x1="7" y1="12" x2="17" y2="12" stroke="${color}" stroke-width="1.5"/>
|
||||
<path d="M5 18 Q8 21 12 21 Q16 21 19 18" stroke="${color}" stroke-width="1.5" fill="none"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ─── Wind Turbine SVG ─────────────────────────────────────────────────────────
|
||||
|
||||
const WIND_COLOR = '#00bcd4';
|
||||
|
||||
function windTurbineSvg(size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<line x1="12" y1="10" x2="11" y2="23" stroke="${WIND_COLOR}" stroke-width="1.5"/>
|
||||
<line x1="12" y1="10" x2="13" y2="23" stroke="${WIND_COLOR}" stroke-width="1.5"/>
|
||||
<circle cx="12" cy="9" r="1.8" fill="${WIND_COLOR}"/>
|
||||
<path d="M12 9 L11.5 1 Q12 0 12.5 1 Z" fill="${WIND_COLOR}" opacity="0.9"/>
|
||||
<path d="M12 9 L18 14 Q18.5 13 17.5 12.5 Z" fill="${WIND_COLOR}" opacity="0.9"/>
|
||||
<path d="M12 9 L6 14 Q5.5 13 6.5 12.5 Z" fill="${WIND_COLOR}" opacity="0.9"/>
|
||||
<line x1="8" y1="23" x2="16" y2="23" stroke="${WIND_COLOR}" stroke-width="1.5"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
export function createPortLayers(
|
||||
config: { ports: boolean; windFarm: boolean },
|
||||
fc: LayerFactoryConfig,
|
||||
): Layer[] {
|
||||
const layers: Layer[] = [];
|
||||
const sc = fc.sc;
|
||||
const onPick = fc.onPick;
|
||||
|
||||
// ── Ports ───────────────────────────────────────────────────────────────
|
||||
if (config.ports) {
|
||||
const portIconCache = new Map<string, string>();
|
||||
function getPortIconUrl(p: Port): string {
|
||||
const key = `${p.country}-${p.type}`;
|
||||
if (!portIconCache.has(key)) {
|
||||
const color = PORT_COUNTRY_COLOR[p.country] ?? PORT_COUNTRY_COLOR.KR;
|
||||
const size = p.type === 'major' ? 32 : 24;
|
||||
portIconCache.set(key, svgToDataUri(portSvg(color, size)));
|
||||
}
|
||||
return portIconCache.get(key)!;
|
||||
}
|
||||
|
||||
layers.push(
|
||||
new IconLayer<Port>({
|
||||
id: 'static-ports-icon',
|
||||
data: EAST_ASIA_PORTS,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: (d) => ({
|
||||
url: getPortIconUrl(d),
|
||||
width: d.type === 'major' ? 32 : 24,
|
||||
height: d.type === 'major' ? 32 : 24,
|
||||
anchorX: d.type === 'major' ? 16 : 12,
|
||||
anchorY: d.type === 'major' ? 16 : 12,
|
||||
}),
|
||||
getSize: (d) => (d.type === 'major' ? 16 : 12) * sc,
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<Port>) => {
|
||||
if (info.object) onPick({ kind: 'port', object: info.object });
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
new TextLayer<Port>({
|
||||
id: 'static-ports-label',
|
||||
data: EAST_ASIA_PORTS,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.nameKo.replace('항', ''),
|
||||
getSize: 9 * sc,
|
||||
getColor: (d) => [...hexToRgb(PORT_COUNTRY_COLOR[d.country] ?? PORT_COUNTRY_COLOR.KR), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 8],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Wind Farms ─────────────────────────────────────────────────────────
|
||||
if (config.windFarm) {
|
||||
const windUrl = svgToDataUri(windTurbineSvg(36));
|
||||
layers.push(
|
||||
new IconLayer<WindFarm>({
|
||||
id: 'static-windfarm-icon',
|
||||
data: KOREA_WIND_FARMS,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: () => ({ url: windUrl, width: 36, height: 36, anchorX: 18, anchorY: 18 }),
|
||||
getSize: 18 * sc,
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<WindFarm>) => {
|
||||
if (info.object) onPick({ kind: 'windFarm', object: info.object });
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
new TextLayer<WindFarm>({
|
||||
id: 'static-windfarm-label',
|
||||
data: KOREA_WIND_FARMS,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name),
|
||||
getSize: 9 * sc,
|
||||
getColor: [...hexToRgb(WIND_COLOR), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 10],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return layers;
|
||||
}
|
||||
49
frontend/src/hooks/layers/types.ts
Normal file
49
frontend/src/hooks/layers/types.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import type { PickingInfo, Layer } from '@deck.gl/core';
|
||||
import type { Port } from '../../data/ports';
|
||||
import type { WindFarm } from '../../data/windFarms';
|
||||
import type { MilitaryBase } from '../../data/militaryBases';
|
||||
import type { GovBuilding } from '../../data/govBuildings';
|
||||
import type { NKLaunchSite } from '../../data/nkLaunchSites';
|
||||
import type { NKMissileEvent } from '../../data/nkMissileEvents';
|
||||
import type { CoastGuardFacility } from '../../services/coastGuard';
|
||||
import type { KoreanAirport } from '../../services/airports';
|
||||
import type { NavWarning } from '../../services/navWarning';
|
||||
import type { PiracyZone } from '../../services/piracy';
|
||||
import type { PowerFacility } from '../../services/infra';
|
||||
import type { HazardFacility, HazardType } from '../../data/hazardFacilities';
|
||||
import type { CnFacility } from '../../data/cnFacilities';
|
||||
import type { JpFacility } from '../../data/jpFacilities';
|
||||
|
||||
export type StaticPickedObject =
|
||||
| Port | WindFarm | MilitaryBase | GovBuilding
|
||||
| NKLaunchSite | NKMissileEvent | CoastGuardFacility | KoreanAirport
|
||||
| NavWarning | PiracyZone | PowerFacility | HazardFacility
|
||||
| CnFacility | JpFacility;
|
||||
|
||||
export type StaticLayerKind =
|
||||
| 'port' | 'windFarm' | 'militaryBase' | 'govBuilding'
|
||||
| 'nkLaunch' | 'nkMissile' | 'coastGuard' | 'airport'
|
||||
| 'navWarning' | 'piracy' | 'infra' | 'hazard'
|
||||
| 'cnFacility' | 'jpFacility';
|
||||
|
||||
export interface StaticPickInfo {
|
||||
kind: StaticLayerKind;
|
||||
object: StaticPickedObject;
|
||||
}
|
||||
|
||||
export interface LayerFactoryConfig {
|
||||
sc: number; // sizeScale
|
||||
onPick: (info: StaticPickInfo) => void;
|
||||
}
|
||||
|
||||
export type { PickingInfo, Layer };
|
||||
export type { Port, WindFarm, MilitaryBase, GovBuilding, NKLaunchSite, NKMissileEvent };
|
||||
export type { CoastGuardFacility, KoreanAirport, NavWarning, PiracyZone };
|
||||
export type { PowerFacility, HazardFacility, HazardType, CnFacility, JpFacility };
|
||||
|
||||
export function hexToRgb(hex: string): [number, number, number] {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return [r, g, b];
|
||||
}
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
불러오는 중...
Reference in New Issue
Block a user