@hyperviz/weather
Version:
Weather visualization module using OffscreenCanvas and Web Workers
455 lines (397 loc) • 12.8 kB
text/typescript
import { BaseProcessor } from "./base-processor.js";
import {
WeatherData,
TemperatureRenderOptions,
ProcessorType,
WeatherDataBase,
} from "../types/index.js";
/**
* 온도 시각화 프로세서
* 날씨 데이터의 온도 정보를 처리하고 시각화합니다.
*/
export class TemperatureProcessor extends BaseProcessor {
private colorMap: string[] = [];
private minTemperature: number = -10;
private maxTemperature: number = 40;
private interpolationMethod: "linear" | "bilinear" | "bicubic" = "bilinear";
/**
* 프로세서 타입 반환
*/
getType(): ProcessorType {
return "temperature";
}
/**
* 데이터 처리 메서드 - 기본 구현
*/
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];
// 타입 변환 없이 weatherData를 직접 사용
const points = this.extractTemperaturePoints(
weatherData,
bounds,
this.width,
this.height
);
const grid = this.createTemperatureGrid(points, this.width, this.height);
// 온도 데이터 시각화
this.renderTemperatureGrid(ctx, grid, this.width, this.height);
// 이미지 비트맵 생성 및 반환
const imageBitmap = await createImageBitmap(canvas);
return { imageData: imageBitmap };
}
/**
* 옵션 적용
*/
private applyOptions(options: TemperatureRenderOptions) {
if (!options) return;
if (options.colorScale) {
this.colorMap = options.colorScale;
}
if (options.minTemperature !== undefined) {
this.minTemperature = options.minTemperature;
}
if (options.maxTemperature !== undefined) {
this.maxTemperature = options.maxTemperature;
}
if (options.interpolation) {
this.interpolationMethod = options.interpolation;
}
}
/**
* 온도 데이터 포인트 추출
* 날씨 데이터에서 온도 값과 위치 정보를 추출하여 캔버스 좌표로 변환
*/
private extractTemperaturePoints(
weatherData: WeatherDataBase[],
bounds: number[],
width: number,
height: number
): { x: number; y: number; temperature: number }[] {
const points: { x: number; y: number; temperature: number }[] = [];
weatherData.forEach((data) => {
// temperature 필드가 있고, current 속성이 있는지 확인 (타입 안전하게 처리)
if (
data &&
typeof data === "object" &&
"temperature" in data &&
data.temperature &&
typeof data.temperature === "object" &&
"current" in data.temperature &&
typeof data.temperature.current === "number" &&
"location" in data &&
data.location &&
typeof data.location === "object" &&
"longitude" in data.location &&
"latitude" in data.location &&
typeof data.location.longitude === "number" &&
typeof data.location.latitude === "number"
) {
// 위도/경도를 캔버스 좌표로 변환
const x = this.mapLongitudeToX(data.location.longitude, bounds, width);
const y = this.mapLatitudeToY(data.location.latitude, bounds, height);
// 유효한 좌표인 경우에만 포인트 추가
if (x >= 0 && x < width && y >= 0 && y < height) {
points.push({
x,
y,
temperature: data.temperature.current,
});
}
}
});
return points;
}
/**
* 온도 그리드 생성
* 데이터 포인트를 기반으로 그리드 형태의 온도 데이터 생성
*/
private createTemperatureGrid(
points: { x: number; y: number; temperature: number }[],
width: number,
height: number
): number[][] {
// 그리드 초기화 (초기값은 NaN으로 설정)
const grid: number[][] = Array(height)
.fill(0)
.map(() => Array(width).fill(NaN));
// 직접 데이터 포인트 값 할당
points.forEach((point) => {
const x = Math.floor(point.x);
const y = Math.floor(point.y);
if (x >= 0 && x < width && y >= 0 && y < height) {
grid[y][x] = point.temperature;
}
});
// 보간법에 따라 그리드 채우기
this.interpolateGrid(grid, width, height);
return grid;
}
/**
* 그리드 데이터 보간
* 빈 그리드 셀을 주변 값을 사용하여 보간
*/
private interpolateGrid(grid: number[][], width: number, height: number) {
switch (this.interpolationMethod) {
case "linear":
this.linearInterpolation(grid, width, height);
break;
case "bilinear":
this.bilinearInterpolation(grid, width, height);
break;
case "bicubic":
// 실제로는 더 복잡한 구현이 필요하나, 간단한 구현으로 대체
this.bilinearInterpolation(grid, width, height);
break;
default:
this.linearInterpolation(grid, width, height);
}
}
/**
* 선형 보간법
* 가장 가까운 값을 찾아 거리에 따라 가중치를 적용하여 보간
*/
private linearInterpolation(grid: number[][], width: number, height: number) {
// 보간용 임시 그리드 생성
const result = Array(height)
.fill(0)
.map(() => Array(width).fill(NaN));
// 모든 셀에 대해 반복
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
// 이미 값이 있으면 그대로 유지
if (!isNaN(grid[y][x])) {
result[y][x] = grid[y][x];
continue;
}
// 가장 가까운 값을 찾기 위한 변수
let minDistance = Infinity;
let value = NaN;
// 모든 유효한 데이터 포인트 검사
for (let ny = 0; ny < height; ny++) {
for (let nx = 0; nx < width; nx++) {
if (!isNaN(grid[ny][nx])) {
// 거리 계산
const distance = Math.sqrt((nx - x) ** 2 + (ny - y) ** 2);
// 더 가까운 포인트를 찾으면 업데이트
if (distance < minDistance) {
minDistance = distance;
value = grid[ny][nx];
}
}
}
}
// 유효한 값이 있으면 할당
if (!isNaN(value)) {
result[y][x] = value;
}
}
}
// 결과를 원래 그리드에 복사
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
grid[y][x] = result[y][x];
}
}
}
/**
* 쌍선형 보간법
* 주변 4개의 점을 사용하여 보간
*/
private bilinearInterpolation(
grid: number[][],
width: number,
height: number
) {
// 유효한 데이터 포인트 찾기
const validPoints: { x: number; y: number; value: number }[] = [];
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (!isNaN(grid[y][x])) {
validPoints.push({ x, y, value: grid[y][x] });
}
}
}
// 보간용 임시 그리드 생성
const result = Array(height)
.fill(0)
.map(() => Array(width).fill(NaN));
// IDW(Inverse Distance Weighted) 보간법 적용
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
// 이미 값이 있으면 그대로 유지
if (!isNaN(grid[y][x])) {
result[y][x] = grid[y][x];
continue;
}
let weightSum = 0;
let valueSum = 0;
// 모든 유효한 포인트 사용
for (const point of validPoints) {
const distance = Math.sqrt((point.x - x) ** 2 + (point.y - y) ** 2);
// 같은 위치에 있으면 해당 값 사용
if (distance === 0) {
weightSum = 1;
valueSum = point.value;
break;
}
// 거리의 제곱에 반비례하는 가중치 계산
const weight = 1 / distance ** 2;
weightSum += weight;
valueSum += weight * point.value;
}
// 가중 평균 계산
if (weightSum > 0) {
result[y][x] = valueSum / weightSum;
}
}
}
// 결과를 원래 그리드에 복사
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
grid[y][x] = result[y][x];
}
}
}
/**
* 온도 그리드 렌더링
* 그리드 데이터를 색상으로 변환하여 캔버스에 렌더링
*/
private renderTemperatureGrid(
ctx: OffscreenCanvasRenderingContext2D,
grid: number[][],
width: number,
height: number
) {
// 캔버스에 직접 픽셀 그리기
const imageData = ctx.createImageData(width, height);
const data = imageData.data;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const temperature = grid[y][x];
const index = (y * width + x) * 4;
if (isNaN(temperature)) {
// 데이터가 없는 영역은 투명하게 처리
data[index + 3] = 0; // 알파 채널 0 (완전 투명)
} else {
// 온도 값에 따라 색상 계산
const color = this.getTemperatureColor(temperature);
data[index] = color.r; // R
data[index + 1] = color.g; // G
data[index + 2] = color.b; // B
data[index + 3] = 220; // 알파 채널 (약간 투명)
}
}
}
// 이미지 데이터 적용
ctx.putImageData(imageData, 0, 0);
}
/**
* 온도에 해당하는 색상 계산
*/
private getTemperatureColor(temperature: number): {
r: number;
g: number;
b: number;
} {
// 온도 범위 클램핑
const clampedTemperature = Math.max(
this.minTemperature,
Math.min(this.maxTemperature, temperature)
);
// 색상 스케일 내에서 정규화된 위치 계산 (0-1 사이)
const t =
(clampedTemperature - this.minTemperature) /
(this.maxTemperature - this.minTemperature);
// 위치에 해당하는 색상 인덱스 계산
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;
// 색상 문자열에서 RGB 추출
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),
};
}
/**
* 색상 문자열을 RGB 값으로 파싱
*/
private parseColor(color: string): { r: number; g: number; b: number } {
// 헥스 컬러 문자열 처리 (#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 };
}
// RGB 문자열 처리 (rgb(R, G, B))
else if (color.startsWith("rgb")) {
const match = color.match(/\d+/g);
if (match && match.length >= 3) {
return {
r: parseInt(match[0], 10),
g: parseInt(match[1], 10),
b: parseInt(match[2], 10),
};
}
}
// 기본값 반환 (회색)
return { r: 128, g: 128, b: 128 };
}
/**
* 경도를 X 좌표로 변환
*/
private mapLongitudeToX(
longitude: number,
bounds: number[],
width: number
): number {
const [minX, minY, maxX, maxY] = bounds;
return ((longitude - minX) / (maxX - minX)) * width;
}
/**
* 위도를 Y 좌표로 변환
*/
private mapLatitudeToY(
latitude: number,
bounds: number[],
height: number
): number {
const [minX, minY, maxX, maxY] = bounds;
return ((maxY - latitude) / (maxY - minY)) * height;
}
}