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

311 lines
12 KiB
TypeScript

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;
}