UNPKG

@hyperviz/weather

Version:

Weather visualization module using OffscreenCanvas and Web Workers

316 lines (273 loc) 9.16 kB
import { BaseProcessor } from "./base-processor.js"; import { WeatherDataBase, ProcessorType, PrecipitationRenderOptions, } from "../types/index.js"; /** * 강수량 시각화 프로세서 * 날씨 데이터의 강수량 정보를 처리하고 시각화합니다. */ export class PrecipitationProcessor extends BaseProcessor { private colorMap: string[] = []; private minPrecipitation: number = 0; private maxPrecipitation: number = 50; private opacity: number = 0.7; /** * 프로세서 타입 반환 */ getType(): ProcessorType { return "precipitation"; } /** * 데이터 처리 메서드 - 기본 구현 */ 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.renderPrecipitation( ctx, weatherData, bounds as [number, number, number, number] ); // 이미지 비트맵 생성 및 반환 const imageBitmap = await createImageBitmap(canvas); return { imageData: imageBitmap }; } /** * 옵션 적용 */ private applyOptions(options: PrecipitationRenderOptions) { if (!options) return; if (options.colorScale) { this.colorMap = options.colorScale; } if (options.minPrecipitation !== undefined) { this.minPrecipitation = options.minPrecipitation; } if (options.maxPrecipitation !== undefined) { this.maxPrecipitation = options.maxPrecipitation; } if (options.opacity !== undefined) { this.opacity = options.opacity; } } /** * 강수량 렌더링 */ private renderPrecipitation( ctx: OffscreenCanvasRenderingContext2D, weatherData: WeatherDataBase[], bounds: number[] ) { // 이미지 데이터 생성 const imageData = ctx.createImageData(this.width, this.height); const data = imageData.data; // 초기화 - 모든 픽셀을 투명하게 설정 for (let i = 0; i < data.length; i += 4) { data[i + 3] = 0; // 알파 채널을 0으로 설정 (완전 투명) } // 각 날씨 데이터 포인트 처리 weatherData.forEach((point) => { // 강수량 데이터가 있는지 확인 if ( point && typeof point === "object" && "precipitation" in point && point.precipitation && typeof point.precipitation === "object" && "amount" in point.precipitation && typeof point.precipitation.amount === "number" && "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" ) { // 강수량 값 가져오기 const precipAmount = point.precipitation.amount; // 강수량이 0보다 크면 렌더링 if (precipAmount > 0) { // 위도/경도를 캔버스 좌표로 변환 const [x, y] = this.mapToCanvas( point.location.longitude, point.location.latitude, bounds as [number, number, number, number] ); // 반경 계산 - 강수량이 많을수록 더 넓게 표시 const radius = this.calculateRadius(precipAmount); // 색상 계산 const color = this.getPrecipitationColor(precipAmount); // 원형 강수 패턴 그리기 this.drawPrecipitationPattern(data, x, y, radius, color); } } }); // 이미지 데이터를 캔버스에 그리기 ctx.putImageData(imageData, 0, 0); } /** * 강수량에 따른 반경 계산 */ private calculateRadius(precipitation: number): number { // 기본 반경 const baseRadius = 15; // 강수량이 많을수록 더 큰 반경 사용 const scaleFactor = Math.min( 1.0, Math.max(0.2, precipitation / this.maxPrecipitation) ); return baseRadius * (0.5 + scaleFactor); } /** * 강수량에 따른 색상 계산 */ private getPrecipitationColor(precipitation: number): { r: number; g: number; b: number; a: number; } { // 강수량 범위 클램핑 const clampedPrecip = Math.max( this.minPrecipitation, Math.min(this.maxPrecipitation, precipitation) ); // 색상 스케일 내에서 정규화된 위치 계산 (0-1 사이) const t = (clampedPrecip - this.minPrecipitation) / (this.maxPrecipitation - this.minPrecipitation); // 위치에 해당하는 색상 인덱스 계산 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: 0, g: 0, b: 255, a: 128 }; } /** * 강수 패턴 그리기 (원형 그라데이션) */ private drawPrecipitationPattern( imageData: Uint8ClampedArray, centerX: number, centerY: number, radius: number, color: { r: number; g: number; b: number; a: number } ) { // 원형 패턴의 경계 상자 계산 const left = Math.max(0, Math.floor(centerX - radius)); const top = Math.max(0, Math.floor(centerY - radius)); const right = Math.min(this.width - 1, Math.ceil(centerX + radius)); const bottom = Math.min(this.height - 1, Math.ceil(centerY + radius)); // 각 픽셀 처리 for (let y = top; y <= bottom; y++) { for (let x = left; x <= right; x++) { // 중심으로부터의 거리 계산 const distance = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2); // 원 내부인 경우에만 처리 if (distance <= radius) { // 가장자리로 갈수록 투명해지는 효과 const alpha = Math.max(0, 1 - distance / radius) * (color.a / 255) * this.opacity; // 현재 픽셀의 인덱스 계산 const idx = (y * this.width + x) * 4; // 기존 색상과 새 색상 혼합 (알파 블렌딩) // 현재 픽셀에 이미 색상이 있는 경우 더 강한 색상 사용 const currentAlpha = imageData[idx + 3] / 255; if (currentAlpha < alpha) { imageData[idx] = color.r; imageData[idx + 1] = color.g; imageData[idx + 2] = color.b; imageData[idx + 3] = alpha * 255; } } } } } }