/** * 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): 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, tox: ReturnType, ): 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 | undefined; const weather = rslt.weather as Record | 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; }