- FontScaleContext + FontScalePanel: 시설/선박/분석/지역 4그룹 × 0.5~2.0 범위 - LAYERS 패널 하단 슬라이더 UI, localStorage 영속화 - Korea static 14개 + Iran 4개 + 분석 3개 + KoreaMap 5개 TextLayer 적용 - MapLibre 선박 라벨/국가명 실시간 반영 - 모든 useMemo deps + updateTriggers에 fontScale 포함
348 lines
15 KiB
TypeScript
348 lines
15 KiB
TypeScript
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>`;
|
|
}
|
|
|
|
// ─── Module-level icon caches ─────────────────────────────────────────────────
|
|
|
|
const cgIconCache = new Map<CoastGuardType, string>();
|
|
const apIconCache = new Map<string, string>();
|
|
const nwIconCache = new Map<string, string>();
|
|
const piracyIconCache = new Map<string, string>();
|
|
|
|
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) {
|
|
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,
|
|
updateTriggers: { getSize: [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: 12 * sc * fc.fs,
|
|
updateTriggers: { getSize: [sc, fc.fs] },
|
|
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,
|
|
fontSettings: { sdf: true },
|
|
outlineWidth: 8,
|
|
outlineColor: [0, 0, 0, 255],
|
|
billboard: false,
|
|
characterSet: 'auto',
|
|
}),
|
|
);
|
|
}
|
|
|
|
// ── Airports ───────────────────────────────────────────────────────────
|
|
if (config.airports) {
|
|
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,
|
|
updateTriggers: { getSize: [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: 12 * sc * fc.fs,
|
|
updateTriggers: { getSize: [sc, fc.fs] },
|
|
getColor: (d) => [...hexToRgb(apColor(d)), 255] as [number, number, number, number],
|
|
getTextAnchor: 'middle',
|
|
getAlignmentBaseline: 'top',
|
|
getPixelOffset: [0, 10],
|
|
fontFamily: 'monospace',
|
|
fontWeight: 700,
|
|
fontSettings: { sdf: true },
|
|
outlineWidth: 8,
|
|
outlineColor: [0, 0, 0, 255],
|
|
billboard: false,
|
|
characterSet: 'auto',
|
|
}),
|
|
);
|
|
}
|
|
|
|
// ── NavWarning ─────────────────────────────────────────────────────────
|
|
if (config.navWarning) {
|
|
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,
|
|
updateTriggers: { getSize: [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: 12 * sc * fc.fs,
|
|
updateTriggers: { getSize: [sc, fc.fs] },
|
|
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,
|
|
fontSettings: { sdf: true },
|
|
outlineWidth: 8,
|
|
outlineColor: [0, 0, 0, 255],
|
|
billboard: false,
|
|
characterSet: 'auto',
|
|
}),
|
|
);
|
|
}
|
|
|
|
// ── Piracy ─────────────────────────────────────────────────────────────
|
|
if (config.piracy) {
|
|
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,
|
|
updateTriggers: { getSize: [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: 12 * sc * fc.fs,
|
|
updateTriggers: { getSize: [sc, fc.fs] },
|
|
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,
|
|
fontSettings: { sdf: true },
|
|
outlineWidth: 8,
|
|
outlineColor: [0, 0, 0, 255],
|
|
billboard: false,
|
|
characterSet: 'auto',
|
|
}),
|
|
);
|
|
}
|
|
|
|
return layers;
|
|
}
|