kcg-monitoring/frontend/src/hooks/layers/createPortLayers.ts

155 lines
6.5 KiB
TypeScript

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>`;
}
// ─── Module-level icon caches ─────────────────────────────────────────────────
const portIconCache = new Map<string, string>();
const WIND_ICON_URL = svgToDataUri(windTurbineSvg(36));
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) {
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,
updateTriggers: { getSize: [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: 12 * sc * fc.fs,
updateTriggers: { getSize: [sc, fc.fs] },
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,
fontSettings: { sdf: true },
outlineWidth: 3,
outlineColor: [0, 0, 0, 255],
billboard: false,
characterSet: 'auto',
}),
);
}
// ── Wind Farms ─────────────────────────────────────────────────────────
if (config.windFarm) {
layers.push(
new IconLayer<WindFarm>({
id: 'static-windfarm-icon',
data: KOREA_WIND_FARMS,
getPosition: (d) => [d.lng, d.lat],
getIcon: () => ({ url: WIND_ICON_URL, width: 36, height: 36, anchorX: 18, anchorY: 18 }),
getSize: 18 * sc,
updateTriggers: { getSize: [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: 12 * sc * fc.fs,
updateTriggers: { getSize: [sc, fc.fs] },
getColor: [...hexToRgb(WIND_COLOR), 255] as [number, number, number, number],
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: [0, 10],
fontFamily: 'monospace',
fontWeight: 700,
fontSettings: { sdf: true },
outlineWidth: 3,
outlineColor: [0, 0, 0, 255],
billboard: false,
characterSet: 'auto',
}),
);
}
return layers;
}