UNPKG

@hyperviz/weather

Version:

Weather visualization module using OffscreenCanvas and Web Workers

421 lines (365 loc) 12.1 kB
import { BaseProcessor } from "./base-processor.js"; import { WeatherDataBase, ProcessorType, CloudRenderOptions, } from "../types/index.js"; /** * 구름 시각화 프로세서 * 날씨 데이터의 구름 정보를 처리하고 시각화합니다. */ export class CloudProcessor extends BaseProcessor { private colorMap: string[] = []; private minCloud: number = 0; private maxCloud: number = 100; private opacity: number = 0.7; /** * 프로세서 타입 반환 */ getType(): ProcessorType { return "cloud"; } /** * 데이터 처리 메서드 - 기본 구현 */ async process(data: any): Promise<any> { // 간단한 데이터 전처리 return data; } /** * 오프스크린 캔버스에 렌더링 */ async render( canvas: OffscreenCanvas, weatherData: WeatherDataBase[], options: any ): Promise<any> { // 캔버스 크기 설정 canvas.width = this.width; canvas.height = this.height; // 옵션 적용 this.applyOptions(options); // 컨텍스트 가져오기 const ctx = canvas.getContext("2d"); if (!ctx) { throw new Error("캔버스 컨텍스트를 가져올 수 없습니다."); } // 캔버스 초기화 ctx.clearRect(0, 0, this.width, this.height); // 데이터 포인트가 없으면 빈 이미지 반환 if (!weatherData || weatherData.length === 0) { return { imageData: await createImageBitmap(canvas) }; } const bounds = options.bounds || [0, 0, 1, 1]; // 구름 데이터 포인트 추출 및 시각화 this.renderCloud( ctx, weatherData, bounds as [number, number, number, number] ); // 이미지 비트맵 생성 및 반환 const imageBitmap = await createImageBitmap(canvas); return { imageData: imageBitmap }; } /** * 옵션 적용 */ private applyOptions(options: CloudRenderOptions) { if (!options) return; if (options.colorScale) { this.colorMap = options.colorScale; } if (options.minCloud !== undefined) { this.minCloud = options.minCloud; } if (options.maxCloud !== undefined) { this.maxCloud = options.maxCloud; } if (options.opacity !== undefined) { this.opacity = options.opacity; } } /** * 구름 렌더링 */ private renderCloud( ctx: OffscreenCanvasRenderingContext2D, weatherData: WeatherDataBase[], bounds: [number, number, number, number] ) { // 이미지 데이터 생성 const imageData = ctx.createImageData(this.width, this.height); const data = imageData.data; // 그리드 생성 const gridSize = 32; // 그리드 셀 크기 const grid = this.createCloudGrid(weatherData, bounds, gridSize); // 구름 이미지 렌더링 this.renderCloudGrid(ctx, data, grid, gridSize); // 이미지 데이터를 캔버스에 그리기 ctx.putImageData(imageData, 0, 0); } /** * 구름 그리드 생성 */ private createCloudGrid( weatherData: WeatherDataBase[], bounds: [number, number, number, number], gridSize: number ): number[][] { // 그리드 초기화 const rows = Math.ceil(this.height / gridSize); const columns = Math.ceil(this.width / gridSize); const grid: number[][] = Array(rows) .fill(0) .map(() => Array(columns).fill(NaN)); // 각 날씨 데이터 포인트 처리 weatherData.forEach((point) => { // 구름 데이터가 있는지 확인 if ( point && typeof point === "object" && ("cloud" in point || "cloudCoverage" in point) && "location" in point && point.location && typeof point.location === "object" && "longitude" in point.location && "latitude" in point.location && typeof point.location.longitude === "number" && typeof point.location.latitude === "number" ) { // 구름 커버리지 값 가져오기 let cloudCoverage: number | undefined = undefined; if ( "cloudCoverage" in point && typeof point.cloudCoverage === "number" ) { cloudCoverage = point.cloudCoverage; } else if ( "cloud" in point && point.cloud && typeof point.cloud === "object" && "coverage" in point.cloud && typeof point.cloud.coverage === "number" ) { cloudCoverage = point.cloud.coverage; } // 구름 값이 있으면 그리드에 추가 if (cloudCoverage !== undefined) { // 위도/경도를 캔버스 좌표로 변환 const [x, y] = this.mapToCanvas( point.location.longitude, point.location.latitude, bounds ); // 그리드 인덱스 계산 const gridX = Math.floor(x / gridSize); const gridY = Math.floor(y / gridSize); // 유효한 그리드 위치인지 확인 if (gridX >= 0 && gridX < columns && gridY >= 0 && gridY < rows) { // 이미 값이 있으면 평균 계산, 없으면 설정 if (!isNaN(grid[gridY][gridX])) { grid[gridY][gridX] = (grid[gridY][gridX] + cloudCoverage) / 2; } else { grid[gridY][gridX] = cloudCoverage; } } } } }); // 빈 셀 보간 this.interpolateCloudGrid(grid, rows, columns); return grid; } /** * 그리드 보간 - 빈 셀을 주변 값으로 채움 */ private interpolateCloudGrid( grid: number[][], rows: number, columns: number ) { // 임시 그리드 생성 (복사) const tempGrid = JSON.parse(JSON.stringify(grid)); // 빈 셀 보간 for (let y = 0; y < rows; y++) { for (let x = 0; x < columns; x++) { // 현재 셀이 비어있으면 주변 값으로 보간 if (isNaN(grid[y][x])) { const neighbors: number[] = []; let sum = 0; // 주변 8개 셀 검사 for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { if (dx === 0 && dy === 0) continue; const nx = x + dx; const ny = y + dy; // 그리드 경계 확인 if (nx >= 0 && nx < columns && ny >= 0 && ny < rows) { // 값이 있으면 추가 if (!isNaN(grid[ny][nx])) { neighbors.push(grid[ny][nx]); sum += grid[ny][nx]; } } } } // 주변에 값이 있으면 평균 적용 if (neighbors.length > 0) { tempGrid[y][x] = sum / neighbors.length; } else { // 주변에 값이 없으면 기본값 (0) 사용 tempGrid[y][x] = 0; } } } } // 업데이트된 값 반영 for (let y = 0; y < rows; y++) { for (let x = 0; x < columns; x++) { grid[y][x] = tempGrid[y][x]; } } } /** * 구름 그리드 렌더링 */ private renderCloudGrid( ctx: OffscreenCanvasRenderingContext2D, imageData: Uint8ClampedArray, grid: number[][], gridSize: number ) { const rows = grid.length; const columns = grid[0].length; // 각 그리드 셀 그리기 for (let gridY = 0; gridY < rows; gridY++) { for (let gridX = 0; gridX < columns; gridX++) { const cloudValue = grid[gridY][gridX]; // 구름 값이 유효하면 렌더링 if (!isNaN(cloudValue)) { // 색상 계산 const color = this.getCloudColor(cloudValue); // 그리드 셀의 경계 계산 const startX = gridX * gridSize; const startY = gridY * gridSize; const endX = Math.min(startX + gridSize, this.width); const endY = Math.min(startY + gridSize, this.height); // 그리드 셀 내의 각 픽셀 그리기 for (let y = startY; y < endY; y++) { for (let x = startX; x < endX; x++) { // 클라우드 패턴에 변화 추가 (약간의 랜덤성) const distFromCenter = Math.sqrt( Math.pow((x - (startX + endX) / 2) / gridSize, 2) + Math.pow((y - (startY + endY) / 2) / gridSize, 2) ); // 셀 가장자리로 갈수록 투명도 증가 const fadeOut = Math.max(0, 1 - distFromCenter * 1.2); // 그리드 사이의 부드러운 전환을 위한 계수 const alpha = (color.a / 255) * fadeOut * this.opacity; // 픽셀 인덱스 계산 const idx = (y * this.width + x) * 4; // 픽셀이 캔버스 범위 내에 있는지 확인 if ( x >= 0 && x < this.width && y >= 0 && y < this.height && alpha > 0 ) { // 알파 블렌딩 - 기존 픽셀과 새 픽셀을 알파값에 따라 혼합 const existingAlpha = imageData[idx + 3] / 255; // 알파 블렌딩 if (existingAlpha < alpha) { imageData[idx] = color.r; imageData[idx + 1] = color.g; imageData[idx + 2] = color.b; imageData[idx + 3] = alpha * 255; } } } } } } } } /** * 구름 커버리지에 따른 색상 계산 */ private getCloudColor(cloudCoverage: number): { r: number; g: number; b: number; a: number; } { // 구름 커버리지 범위 클램핑 const clampedValue = Math.max( this.minCloud, Math.min(this.maxCloud, cloudCoverage) ); // 색상 스케일 내에서 정규화된 위치 계산 (0-1 사이) const t = (clampedValue - this.minCloud) / (this.maxCloud - this.minCloud); // 위치에 해당하는 색상 인덱스 계산 const index = Math.min( Math.floor(t * (this.colorMap.length - 1)), this.colorMap.length - 2 ); const nextIndex = index + 1; // 두 색상 사이의 보간 비율 계산 const ratio = t * (this.colorMap.length - 1) - index; // 색상 문자열에서 RGBA 추출 const color1 = this.parseColor(this.colorMap[index]); const color2 = this.parseColor(this.colorMap[nextIndex]); // 선형 보간으로 최종 색상 계산 return { r: Math.round(color1.r * (1 - ratio) + color2.r * ratio), g: Math.round(color1.g * (1 - ratio) + color2.g * ratio), b: Math.round(color1.b * (1 - ratio) + color2.b * ratio), a: Math.round(color1.a * (1 - ratio) + color2.a * ratio), }; } /** * 색상 문자열을 RGBA 값으로 파싱 */ private parseColor(color: string): { r: number; g: number; b: number; a: number; } { // RGBA 문자열 처리 (rgba(R, G, B, A)) if (color.startsWith("rgba")) { const match = color.match( /rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([\d.]+)\s*\)/i ); if (match && match.length >= 5) { return { r: parseInt(match[1], 10), g: parseInt(match[2], 10), b: parseInt(match[3], 10), a: parseFloat(match[4]) * 255, }; } } // RGB 문자열 처리 (rgb(R, G, B)) if (color.startsWith("rgb")) { const match = color.match(/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/i); if (match && match.length >= 4) { return { r: parseInt(match[1], 10), g: parseInt(match[2], 10), b: parseInt(match[3], 10), a: 255, }; } } // 헥스 컬러 문자열 처리 (#RRGGBB) if (color.startsWith("#")) { const r = parseInt(color.substring(1, 3), 16); const g = parseInt(color.substring(3, 5), 16); const b = parseInt(color.substring(5, 7), 16); return { r, g, b, a: 255 }; } // 기본값 반환 (흰색) return { r: 255, g: 255, b: 255, a: 128 }; } }