243 lines
8.1 KiB
TypeScript
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;
|
|
}
|