wing-ops/frontend/src/components/incidents/utils/hnsDispersionLayers.ts

243 lines
8.1 KiB
TypeScript

/**
* HNS 대기확산 결과(rsltData)를 deck.gl 레이어로 변환하는 유틸리티
*
* - rsltData에 저장된 inputParams + coord + weather 로 확산 엔진 재실행
* - MapView와 동일한 BitmapLayer (캔버스 히트맵) + ScatterplotLayer (AEGL 원) 생성
*/
import { BitmapLayer, ScatterplotLayer } from '@deck.gl/layers';
import { computeDispersion } from '@tabs/hns/utils/dispersionEngine';
import { getSubstanceToxicity } from '@tabs/hns/utils/toxicityData';
import { hexToRgba } from '@common/components/map/mapUtils';
import type { HnsAnalysisItem } from '@tabs/hns/services/hnsApi';
import type {
MeteoParams,
SourceParams,
SimParams,
DispersionModel,
AlgorithmType,
StabilityClass,
} from '@tabs/hns/utils/dispersionTypes';
// MapView와 동일한 색상 정지점
const COLOR_STOPS: [number, number, number, number][] = [
[34, 197, 94, 220], // green (저농도)
[234, 179, 8, 235], // yellow
[249, 115, 22, 245], // orange
[239, 68, 68, 250], // red (고농도)
[185, 28, 28, 255], // dark red (초고농도)
];
/** rsltData.weather → MeteoParams 변환 */
function toMeteo(weather: Record<string, unknown>): MeteoParams {
return {
windSpeed: (weather.windSpeed as number) ?? 5.0,
windDirDeg: (weather.windDirection as number) ?? 270,
stability: ((weather.stability as string) ?? 'D') as StabilityClass,
temperature: ((weather.temperature as number) ?? 15) + 273.15,
pressure: 101325,
mixingHeight: 800,
};
}
/** rsltData.inputParams + toxicity → SourceParams 변환 */
function toSource(
inputParams: Record<string, unknown>,
tox: ReturnType<typeof getSubstanceToxicity>,
): SourceParams {
return {
Q: (inputParams.emissionRate as number) ?? tox.Q,
QTotal: (inputParams.totalRelease as number) ?? tox.QTotal,
x0: 0,
y0: 0,
z0: (inputParams.releaseHeight as number) ?? 0.5,
releaseDuration:
inputParams.releaseType === '연속 유출'
? ((inputParams.releaseDuration as number) ?? 300)
: 0,
molecularWeight: tox.mw,
vaporPressure: tox.vaporPressure,
densityGas: tox.densityGas,
poolRadius: (inputParams.poolRadius as number) ?? tox.poolRadius,
};
}
const SIM_PARAMS: SimParams = {
xRange: [-100, 10000],
yRange: [-2000, 2000],
nx: 300,
ny: 200,
zRef: 1.5,
tStart: 0,
tEnd: 600,
dt: 30,
};
/** 농도 포인트 배열 → 캔버스 BitmapLayer */
function buildBitmapLayer(
id: string,
points: Array<{ lon: number; lat: number; concentration: number }>,
visible: boolean,
): BitmapLayer | null {
const filtered = points.filter((p) => p.concentration > 0.01);
if (filtered.length === 0) return null;
const maxConc = Math.max(...points.map((p) => p.concentration));
const minConc = Math.min(...filtered.map((p) => p.concentration));
const logMin = Math.log(minConc);
const logMax = Math.log(maxConc);
const logRange = logMax - logMin || 1;
let minLon = Infinity, maxLon = -Infinity, minLat = Infinity, maxLat = -Infinity;
for (const p of points) {
if (p.lon < minLon) minLon = p.lon;
if (p.lon > maxLon) maxLon = p.lon;
if (p.lat < minLat) minLat = p.lat;
if (p.lat > maxLat) maxLat = p.lat;
}
const padLon = (maxLon - minLon) * 0.02;
const padLat = (maxLat - minLat) * 0.02;
minLon -= padLon; maxLon += padLon;
minLat -= padLat; maxLat += padLat;
const W = 1200, H = 960;
const canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d')!;
ctx.clearRect(0, 0, W, H);
for (const p of filtered) {
const ratio = Math.max(0, Math.min(1, (Math.log(p.concentration) - logMin) / logRange));
const t = ratio * (COLOR_STOPS.length - 1);
const lo = Math.floor(t);
const hi = Math.min(lo + 1, COLOR_STOPS.length - 1);
const f = t - lo;
const r = Math.round(COLOR_STOPS[lo][0] + (COLOR_STOPS[hi][0] - COLOR_STOPS[lo][0]) * f);
const g = Math.round(COLOR_STOPS[lo][1] + (COLOR_STOPS[hi][1] - COLOR_STOPS[lo][1]) * f);
const b = Math.round(COLOR_STOPS[lo][2] + (COLOR_STOPS[hi][2] - COLOR_STOPS[lo][2]) * f);
const a = (COLOR_STOPS[lo][3] + (COLOR_STOPS[hi][3] - COLOR_STOPS[lo][3]) * f) / 255;
const px = ((p.lon - minLon) / (maxLon - minLon)) * W;
const py = (1 - (p.lat - minLat) / (maxLat - minLat)) * H;
ctx.fillStyle = `rgba(${r},${g},${b},${a.toFixed(2)})`;
ctx.beginPath();
ctx.arc(px, py, 6, 0, Math.PI * 2);
ctx.fill();
}
const imageUrl = canvas.toDataURL('image/png');
return new BitmapLayer({
id,
image: imageUrl,
bounds: [minLon, minLat, maxLon, maxLat],
opacity: 1.0,
pickable: false,
visible,
});
}
/**
* HnsAnalysisItem[] → deck.gl 레이어 배열 (BitmapLayer + ScatterplotLayer)
*
* IncidentsView의 useMemo 에서 사용
*/
export function buildHnsDispersionLayers(
analyses: HnsAnalysisItem[],
visible: boolean = true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): any[] {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const layers: any[] = [];
for (const analysis of analyses) {
const rslt = analysis.rsltData;
if (!rslt) continue;
const coord = rslt.coord as { lon: number; lat: number } | undefined;
const inputParams = rslt.inputParams as Record<string, unknown> | undefined;
const weather = rslt.weather as Record<string, unknown> | undefined;
const zones = rslt.zones as
| Array<{ level: string; color: string; radius: number; angle: number }>
| undefined;
if (!coord || !inputParams || !weather) continue;
// ── 1. 확산 엔진 재실행 ──────────────────────────
const substanceName = (inputParams.substance as string) ?? '톨루엔 (Toluene)';
const tox = getSubstanceToxicity(substanceName);
const meteo = toMeteo(weather);
const source = toSource(inputParams, tox);
const releaseType = (inputParams.releaseType as string) ?? '연속 유출';
const modelType: DispersionModel =
releaseType === '연속 유출' ? 'plume'
: releaseType === '순간 유출' ? 'puff'
: 'dense_gas';
const algo = ((inputParams.algorithm as string) ?? 'ALOHA (EPA)') as AlgorithmType;
let points: Array<{ lon: number; lat: number; concentration: number }> = [];
try {
const result = computeDispersion({
meteo,
source,
sim: SIM_PARAMS,
modelType,
originLon: coord.lon,
originLat: coord.lat,
substanceName,
t: SIM_PARAMS.dt,
algorithm: algo,
});
points = result.points;
} catch {
// 재계산 실패 시 히트맵 생략, 원 레이어만 표출
}
// ── 2. BitmapLayer (히트맵 콘) ────────────────────
if (points.length > 0) {
const bitmapLayer = buildBitmapLayer(
`hns-bitmap-${analysis.hnsAnlysSn}`,
points,
visible,
);
if (bitmapLayer) layers.push(bitmapLayer);
}
// ── 3. ScatterplotLayer (AEGL 원) ─────────────────
if (zones?.length) {
const zoneData = zones
.filter((z) => z.radius > 0)
.map((zone, idx) => ({
position: [coord.lon, coord.lat] as [number, number],
radius: zone.radius,
fillColor: hexToRgba(zone.color, 40) as [number, number, number, number],
lineColor: hexToRgba(zone.color, 200) as [number, number, number, number],
level: zone.level,
idx,
}));
if (zoneData.length > 0) {
layers.push(
new ScatterplotLayer({
id: `hns-zones-${analysis.hnsAnlysSn}`,
data: zoneData,
getPosition: (d: (typeof zoneData)[0]) => d.position,
getRadius: (d: (typeof zoneData)[0]) => d.radius,
getFillColor: (d: (typeof zoneData)[0]) => d.fillColor,
getLineColor: (d: (typeof zoneData)[0]) => d.lineColor,
getLineWidth: 2,
stroked: true,
radiusUnits: 'meters' as const,
pickable: false,
visible,
}),
);
}
}
}
return layers;
}