kcg-monitoring/frontend/src/components/iran/AirportLayer.ts
htlee 3f2052a46e feat: 웹폰트 내장 + 이란 시설물 색상/가독성 개선
- @fontsource-variable Inter, Noto Sans KR, Fira Code 자체 호스팅
- 전체 font-family 통일 (CSS, deck.gl, 인라인 스타일)
- 이란 시설물 색상 사막 대비 고채도 팔레트로 교체
- 이란 라벨 fontWeight 600→700, alpha 200→255
- 접힘 패널 상하 패딩 균일화
2026-03-24 10:11:59 +09:00

153 lines
6.0 KiB
TypeScript

import { IconLayer, TextLayer } from '@deck.gl/layers';
import type { PickingInfo, Layer } from '@deck.gl/core';
import { svgToDataUri } from '../../utils/svgToDataUri';
import { FONT_MONO } from '../../styles/fonts';
import { middleEastAirports } from '../../data/airports';
import type { Airport } from '../../data/airports';
// ─── US base detection ───────────────────────────────────────────────────────
const US_BASE_ICAOS = new Set([
'OMAD', 'OTBH', 'OKAJ', 'LTAG', 'OEPS', 'ORAA', 'ORBD', 'OBBS', 'OMTH', 'HDCL',
]);
function isUSBase(airport: Airport): boolean {
return airport.type === 'military' && US_BASE_ICAOS.has(airport.icao);
}
// ─── Deduplication ───────────────────────────────────────────────────────────
const TYPE_PRIORITY: Record<Airport['type'], number> = {
military: 3, large: 2, medium: 1, small: 0,
};
function deduplicateByArea(airports: Airport[]): Airport[] {
const sorted = [...airports].sort((a, b) => {
const pa = TYPE_PRIORITY[a.type] + (isUSBase(a) ? 1 : 0);
const pb = TYPE_PRIORITY[b.type] + (isUSBase(b) ? 1 : 0);
return pb - pa;
});
const kept: Airport[] = [];
for (const ap of sorted) {
const tooClose = kept.some(
k => Math.abs(k.lat - ap.lat) < 0.8 && Math.abs(k.lng - ap.lng) < 0.8,
);
if (!tooClose) kept.push(ap);
}
return kept;
}
const DEDUPLICATED_AIRPORTS = deduplicateByArea(middleEastAirports);
export const IRAN_AIRPORT_COUNT = DEDUPLICATED_AIRPORTS.length;
// ─── Colors ──────────────────────────────────────────────────────────────────
function getAirportColor(airport: Airport): string {
if (isUSBase(airport)) return '#60a5fa';
if (airport.type === 'military') return '#f87171';
return '#38bdf8';
}
// ─── SVG generators ──────────────────────────────────────────────────────────
function militaryPlaneSvg(color: string): string {
return `<path d="M12 8.5 L12.8 11 L16.5 12.5 L16.5 13.5 L12.8 12.8 L12.5 15 L13.5 15.8 L13.5 16.5 L12 16 L10.5 16.5 L10.5 15.8 L11.5 15 L11.2 12.8 L7.5 13.5 L7.5 12.5 L11.2 11 L12 8.5Z" fill="${color}"/>`;
}
function civilPlaneSvg(color: string): string {
return `<path d="M12 8c-.5 0-.8.3-.8.5v2.7l-4.2 2v1l4.2-1.1v2.5l-1.1.8v.8L12 16l1.9.5v-.8l-1.1-.8v-2.5l4.2 1.1v-1l-4.2-2V8.5c0-.2-.3-.5-.8-.5z" fill="${color}"/>`;
}
function airportSvg(airport: Airport): string {
const color = getAirportColor(airport);
const isMil = airport.type === 'military';
const size = airport.type === 'large' ? 32 : airport.type === 'small' ? 24 : 28;
const plane = isMil ? militaryPlaneSvg(color) : civilPlaneSvg(color);
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" 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="2"/>
${plane}
</svg>`;
}
// ─── Module-level icon cache ─────────────────────────────────────────────────
const airportIconCache = new Map<string, string>();
function getAirportIconUrl(airport: Airport): string {
const isUS = isUSBase(airport);
const key = `${airport.type}-${isUS ? 'us' : 'std'}`;
if (!airportIconCache.has(key)) {
airportIconCache.set(key, svgToDataUri(airportSvg(airport)));
}
return airportIconCache.get(key)!;
}
function getIconDimension(airport: Airport): number {
return airport.type === 'large' ? 32 : airport.type === 'small' ? 24 : 28;
}
// ─── Label color ─────────────────────────────────────────────────────────────
function getAirportLabelColor(airport: Airport): [number, number, number, number] {
if (isUSBase(airport)) return [59, 130, 246, 255];
if (airport.type === 'military') return [239, 68, 68, 255];
return [245, 158, 11, 255];
}
// ─── Public interface ────────────────────────────────────────────────────────
export interface AirportLayerConfig {
visible: boolean;
sc: number;
onPick: (ap: Airport) => void;
}
export function createIranAirportLayers(config: AirportLayerConfig): Layer[] {
if (!config.visible) return [];
const { sc, onPick } = config;
return [
new IconLayer<Airport>({
id: 'iran-airport-icon',
data: DEDUPLICATED_AIRPORTS,
getPosition: (d) => [d.lng, d.lat],
getIcon: (d) => {
const dim = getIconDimension(d);
return {
url: getAirportIconUrl(d),
width: dim,
height: dim,
anchorX: dim / 2,
anchorY: dim / 2,
};
},
getSize: (d) => (d.type === 'large' ? 16 : d.type === 'small' ? 12 : 14) * sc,
updateTriggers: { getSize: [sc] },
pickable: true,
onClick: (info: PickingInfo<Airport>) => {
if (info.object) onPick(info.object);
return true;
},
}),
new TextLayer<Airport>({
id: 'iran-airport-label',
data: DEDUPLICATED_AIRPORTS,
getPosition: (d) => [d.lng, d.lat],
getText: (d) => d.nameKo ?? d.name,
getSize: 9 * sc,
updateTriggers: { getSize: [sc] },
getColor: (d) => getAirportLabelColor(d),
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: [0, 10],
fontFamily: FONT_MONO,
fontWeight: 700,
outlineWidth: 2,
outlineColor: [0, 0, 0, 200],
billboard: false,
characterSet: 'auto',
}),
];
}