@hyperviz/weather
Version:
Weather visualization module using OffscreenCanvas and Web Workers
363 lines (308 loc) • 9.3 kB
text/typescript
import { BaseProcessor } from "./base-processor.js";
import { ProcessorType, WeatherDataBase } from "../types/index.js";
/**
* 바람 움직임 파티클
*/
interface WindParticle {
x: number;
y: number;
age: number;
u: number;
v: number;
dx: number;
dy: number;
speed: number;
}
/**
* 바람 데이터 시각화 프로세서
*/
export class WindProcessor extends BaseProcessor {
private particles: WindParticle[] = [];
private particleCount: number = 2000;
private particleLifetime: number = 60; // 프레임 단위
private colorScale: string[] = [];
private lineWidth: number = 1;
private velocityScale: number = 1 / 30;
private minVelocity: number = 0;
private maxVelocity: number = 10;
private fadeOpacity: number = 0.92;
private dropRate: number = 0.003;
private dropRateBump: number = 0.01;
private speedFactor: number = 0.25;
private canvas: OffscreenCanvas | null = null;
private ctx: OffscreenCanvasRenderingContext2D | null = null;
// width와 height는 BaseProcessor에서 상속받음
// 애니메이션 관련 변수
private animationFrameId: number | null = null;
private lastFrameTime: number = 0;
/**
* 프로세서 타입 반환
*/
getType(): ProcessorType {
return "wind";
}
/**
* 데이터 처리
*/
async process(data: any): Promise<any> {
// 간단한 데이터 전처리 수행
return data;
}
/**
* 오프스크린 캔버스에 렌더링
*/
async render(
canvas: OffscreenCanvas,
weatherData: WeatherDataBase[],
options: any
): Promise<{ imageData?: ImageBitmap; metadata?: any }> {
// 캔버스 설정
this.canvas = canvas;
this.width = canvas.width;
this.height = canvas.height;
this.ctx = canvas.getContext("2d") as OffscreenCanvasRenderingContext2D;
// 옵션 설정
this.applyOptions(options);
// 파티클 초기화
this.initParticles(weatherData, options);
// 애니메이션 시작
this.startAnimation();
// 프레임을 몇 개 렌더링한 후 결과 반환
await this.renderFrames(30); // 30프레임 렌더링
// 애니메이션 정지
this.stopAnimation();
// 이미지 생성 및 반환
const imageData = await createImageBitmap(canvas);
return {
imageData,
metadata: {
timestamp: Date.now(),
type: this.getType(),
},
};
}
/**
* 옵션 적용
*/
private applyOptions(options: any) {
if (!options) return;
// 옵션 적용
this.particleCount = options.particleCount || this.particleCount;
this.particleLifetime = options.particleAge || this.particleLifetime;
this.colorScale = options.colorScale || this.colorScale;
this.lineWidth = options.lineWidth || this.lineWidth;
this.velocityScale = options.velocityScale || this.velocityScale;
this.minVelocity = options.minVelocity || this.minVelocity;
this.maxVelocity = options.maxVelocity || this.maxVelocity;
this.fadeOpacity = options.fadeOpacity || this.fadeOpacity;
this.dropRate = options.dropRate || this.dropRate;
this.dropRateBump = options.dropRateBump || this.dropRateBump;
this.speedFactor = options.speedFactor || this.speedFactor;
}
/**
* 파티클 초기화
*/
private initParticles(weatherData: WeatherDataBase[], options: any = {}) {
this.particles = [];
// 데이터가 없으면 무작위 파티클 생성
if (!weatherData || weatherData.length === 0) {
this.createRandomParticles();
return;
}
// 데이터 기반 파티클 생성
for (const data of weatherData) {
if (
!data.location ||
typeof data.location.lon !== "number" ||
typeof data.location.lat !== "number" ||
!data.wind
) {
continue;
}
// 벡터 필드 값 추출
const u = data.wind.u || 0;
const v = data.wind.v || 0;
// 위치를 캔버스 좌표로 변환
const [x, y] = this.mapToCanvas(
data.location.lon,
data.location.lat,
options.bounds || [0, 0, 1, 1]
);
// 파티클 추가
if (x >= 0 && x < this.width && y >= 0 && y < this.height) {
this.particles.push({
x,
y,
age: Math.random() * this.particleLifetime,
u,
v,
dx: u * this.velocityScale,
dy: v * this.velocityScale,
speed: Math.sqrt(u * u + v * v),
});
}
}
// 충분한 파티클이 없으면 추가 생성
const density = options?.particleDensity || 1.0;
const targetCount = Math.max(
this.particleCount,
((this.width * this.height) / 1000) * density
);
while (this.particles.length < targetCount) {
const u = (Math.random() - 0.5) * 2;
const v = (Math.random() - 0.5) * 2;
const speed = Math.sqrt(u * u + v * v);
this.particles.push({
x: Math.random() * this.width,
y: Math.random() * this.height,
age: Math.random() * this.particleLifetime,
u,
v,
dx: u * this.velocityScale,
dy: v * this.velocityScale,
speed,
});
}
}
/**
* 무작위 파티클 생성
*/
private createRandomParticles() {
const count = this.particleCount;
for (let i = 0; i < count; i++) {
const u = (Math.random() - 0.5) * 2;
const v = (Math.random() - 0.5) * 2;
const speed = Math.sqrt(u * u + v * v);
this.particles.push({
x: Math.random() * this.width,
y: Math.random() * this.height,
age: Math.random() * this.particleLifetime,
u,
v,
dx: u * this.velocityScale,
dy: v * this.velocityScale,
speed,
});
}
}
/**
* 파티클 그리기
*/
private drawParticles() {
if (!this.ctx) return;
// ctx 객체 참조를 로컬 변수에 저장
const ctx = this.ctx;
// 선 스타일 설정
ctx.strokeStyle = "rgba(0, 191, 255, 0.85)";
ctx.lineWidth = 1.5;
// 모든 파티클 업데이트 및 그리기
this.particles.forEach((particle, index) => {
// 파티클 이동
particle.x += particle.dx;
particle.y += particle.dy;
// 나이 증가
particle.age++;
// 화면 밖으로 나가거나 수명이 다한 경우 재설정
if (
particle.age > this.particleLifetime ||
particle.x < 0 ||
particle.x > this.width ||
particle.y < 0 ||
particle.y > this.height ||
Math.random() < this.dropRate
) {
particle.x = Math.random() * this.width;
particle.y = Math.random() * this.height;
particle.age = 0;
}
// 이전 위치 계산
const prevX = particle.x - particle.dx;
const prevY = particle.y - particle.dy;
// 선 그리기
ctx.beginPath();
ctx.moveTo(prevX, prevY);
ctx.lineTo(particle.x, particle.y);
ctx.stroke();
});
}
/**
* 애니메이션 프레임 렌더링
*/
private renderFrame() {
if (!this.ctx || !this.canvas) return;
const now = performance.now();
const dt = now - this.lastFrameTime;
this.lastFrameTime = now;
// 프레임 시간이 너무 길면 건너뜀 (브라우저 탭이 비활성화된 경우 등)
if (dt > 1000) {
return;
}
// ctx가 존재하는지 다시 확인
const ctx = this.ctx;
if (!ctx) return;
// 페이드 아웃 효과
ctx.globalAlpha = this.fadeOpacity;
ctx.globalCompositeOperation = "destination-in";
ctx.fillStyle = "rgba(0, 0, 0, 1)";
ctx.fillRect(0, 0, this.width, this.height);
// 블렌딩 모드 원래대로
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
// 파티클 그리기
this.drawParticles();
}
/**
* 애니메이션 시작
*/
private startAnimation() {
this.lastFrameTime = performance.now();
this.animationFrameId = null;
}
/**
* 여러 프레임 렌더링
*/
private async renderFrames(count: number): Promise<void> {
return new Promise<void>((resolve) => {
let frameCount = 0;
const renderLoop = () => {
this.renderFrame();
frameCount++;
if (frameCount < count) {
requestAnimationFrame(renderLoop);
} else {
resolve();
}
};
requestAnimationFrame(renderLoop);
});
}
/**
* 애니메이션 정지
*/
private stopAnimation() {
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
}
/**
* 지도 좌표를 캔버스 좌표로 변환
*/
protected mapToCanvas(
lon: number,
lat: number,
bounds: [number, number, number, number]
): [number, number] {
const [minX, minY, maxX, maxY] = bounds;
// 지도의 가로/세로 범위
const mapWidth = maxX - minX;
const mapHeight = maxY - minY;
// 0~1 사이의 비율로 위치 계산
const xRatio = (lon - minX) / mapWidth;
const yRatio = (lat - minY) / mapHeight;
// 캔버스 좌표로 변환 (y축은 반전)
const canvasX = xRatio * this.width;
const canvasY = (1 - yRatio) * this.height;
return [canvasX, canvasY];
}
}