Refactor, move all pages to separate folder
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import { u8ToBits } from '../../../helpers/u8ToBits.js'
|
||||
import { MeteoGrid } from '../interfaces.js'
|
||||
|
||||
export function applyBitmask(
|
||||
grid: MeteoGrid,
|
||||
buffer: Uint8Array,
|
||||
bitmask: Uint8Array,
|
||||
bytesPerPoint: number,
|
||||
): Uint8Array {
|
||||
const newBuffer = new Uint8Array(grid.rows * grid.cols * bytesPerPoint)
|
||||
|
||||
let i=0, bufferI=0
|
||||
for (; i<bitmask.length; i++) {
|
||||
const bits = u8ToBits(bitmask[i])
|
||||
for (let bitI=0; bitI<bits.length; bitI++) {
|
||||
const newI = (i*8 + bitI) * bytesPerPoint
|
||||
if (newI >= newBuffer.length) {
|
||||
break;
|
||||
}
|
||||
if (bits[bitI]) {
|
||||
newBuffer[newI] = buffer[bufferI]
|
||||
newBuffer[newI+1] = buffer[bufferI+1]
|
||||
newBuffer[newI+2] = buffer[bufferI+2]
|
||||
bufferI += bytesPerPoint
|
||||
} else {
|
||||
newBuffer[newI] = 255
|
||||
newBuffer[newI+1] = 255
|
||||
newBuffer[newI+2] = 255
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newBuffer
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { CropBounds, GribMessage } from '../interfaces'
|
||||
|
||||
export function extractFromBounds(
|
||||
grib: GribMessage,
|
||||
source: Uint8Array,
|
||||
cropBounds: CropBounds,
|
||||
): Uint8Array {
|
||||
const { grid, bitsPerDataPoint } = grib
|
||||
const bytesPerPoint = bitsPerDataPoint / 8
|
||||
const { x, y, width, height } = cropBounds
|
||||
|
||||
if (
|
||||
x < 0
|
||||
|| y < 0
|
||||
|| x + width > grid.cols-1
|
||||
|| y + height > grid.rows-1
|
||||
) {
|
||||
throw new Error('Extract bbox out of grid bounds')
|
||||
}
|
||||
|
||||
const output = new Uint8Array(width*height*bytesPerPoint)
|
||||
for (let row=y, i=0; row < y+height; row++) {
|
||||
const inputOffset = (row*grid.cols + x)*bytesPerPoint
|
||||
const readBytes = width*bytesPerPoint
|
||||
const inputBuffer = source.slice(inputOffset, inputOffset+readBytes)
|
||||
output.set(inputBuffer, i)
|
||||
i += readBytes
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
type RGBAu8 = [number, number, number, number]
|
||||
|
||||
const DRIZZLE: RGBAu8 = [5, 200, 0, 255]
|
||||
const RAIN: RGBAu8 = [50, 150, 0, 255]
|
||||
const SLEET: RGBAu8 = [255, 175, 0, 255]
|
||||
const SNOW: RGBAu8 = [0, 160, 255, 255]
|
||||
const FREEZING_DRIZZLE: RGBAu8 = [255, 100, 120, 255]
|
||||
const FREEZING_RAIN: RGBAu8 = [255, 0, 0, 255]
|
||||
const GRAUPEL: RGBAu8 = [230, 40, 250, 255]
|
||||
const HAIL: RGBAu8 = [180, 0, 250, 255]
|
||||
|
||||
export function categoricalRainColors(value: number): RGBAu8 {
|
||||
switch (value) {
|
||||
case 0:
|
||||
return DRIZZLE
|
||||
case 1*32:
|
||||
return RAIN
|
||||
case 2*32:
|
||||
return SLEET
|
||||
case 3*32:
|
||||
return SNOW
|
||||
case 4*32:
|
||||
return FREEZING_DRIZZLE
|
||||
case 5*32:
|
||||
return FREEZING_RAIN
|
||||
case 6*32:
|
||||
return GRAUPEL
|
||||
case 7*32:
|
||||
return HAIL
|
||||
default:
|
||||
return [255, 255, 255, 0]
|
||||
}
|
||||
}
|
||||
|
||||
export function hexToU8(hex: string): [number, number, number] {
|
||||
return [parseInt('0x'+hex.slice(0, 2)), parseInt('0x'+hex.slice(2, 4)), parseInt('0x'+hex.slice(4, 6))]
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export type ColorEntry = { value: number, color: [number, number, number, number]}
|
||||
|
||||
export const WIND_SPEED: ColorEntry[] = [
|
||||
{ value: 40, color: [170, 0, 190, 255] },
|
||||
{ value: 35, color: [170, 0, 190, 255] },
|
||||
{ value: 30, color: [225, 20, 0, 255] },
|
||||
{ value: 25, color: [225, 20, 0, 255] },
|
||||
{ value: 20, color: [255, 160, 0, 255] },
|
||||
{ value: 15, color: [255, 250, 170, 255] },
|
||||
{ value: 10, color: [80, 240, 80, 255] },
|
||||
{ value: 5, color: [45, 155, 150, 255] },
|
||||
{ value: 0, color: [180, 240, 250, 255] },
|
||||
]
|
||||
|
||||
export const PRECIPITATION: ColorEntry[] = [
|
||||
{ value: 30, color: [126, 26, 99, 255] },
|
||||
{ value: 25, color: [120, 27, 131, 255] },
|
||||
{ value: 20, color: [84, 20, 130, 255] },
|
||||
{ value: 15, color: [49, 16, 129, 255] },
|
||||
{ value: 10, color: [9, 15, 129, 255] },
|
||||
{ value: 6, color: [0, 24, 150, 255] },
|
||||
{ value: 4, color: [0, 43, 186, 255] },
|
||||
{ value: 2, color: [10, 70, 220, 255] },
|
||||
{ value: 1, color: [40, 109, 246, 255] },
|
||||
{ value: 0.5, color: [80, 162, 248, 255] },
|
||||
{ value: 0.2, color: [118, 202, 249, 255] },
|
||||
{ value: 0.1, color: [152, 233, 252, 255] },
|
||||
{ value: 0.05, color: [255, 255, 255, 255] },
|
||||
]
|
||||
|
||||
// from ltv
|
||||
export const TEMPERATURES: ColorEntry[] = [
|
||||
{ value: 35, color: [155, 30, 30, 255] },
|
||||
{ value: 10, color: [250, 225, 5, 255] },
|
||||
{ value: 0, color: [80, 190, 240, 255] },
|
||||
{ value: -15, color: [30, 70, 155, 255] },
|
||||
{ value: -30, color: [140, 30, 190, 255] },
|
||||
]
|
||||
|
||||
// from yr.no
|
||||
// export const TEMPERATURES: ColorEntry[] = [
|
||||
// { value: 50, color: [133, 0, 62, 255] },
|
||||
// { value: 40, color: [195, 0, 0, 255] },
|
||||
// { value: 30, color: [255, 76, 56, 255] },
|
||||
// { value: 20, color: [255, 175, 111, 255] },
|
||||
// { value: 10, color: [255, 243, 81, 255] },
|
||||
// { value: 0, color: [195, 246, 215, 255] },
|
||||
// { value: -10, color: [94, 231, 240, 255] },
|
||||
// { value: -20, color: [63, 201, 243, 255] },
|
||||
// { value: -30, color: [79, 157, 232, 255] },
|
||||
// { value: -40, color: [0, 81, 163, 255] },
|
||||
// { value: -50, color: [79, 15, 134, 255] },
|
||||
// ]
|
||||
@@ -0,0 +1,253 @@
|
||||
import { interpolateColors } from '../../../helpers/interpolateColors'
|
||||
import { CropBounds, GribMessage, MeteoParam } from '../interfaces'
|
||||
import { applyBitmask } from './bitmask'
|
||||
import { extractFromBounds } from './bounds'
|
||||
import { categoricalRainColors } from './categoricalRain'
|
||||
import { hourPrecipitationColors, precipitationColors } from './precipitation'
|
||||
import { temperatureColors } from './temperature'
|
||||
import { isCalculatedWindDirection, windDirectionArrows, windDirectionColors, windSpeedColors } from './windDirection'
|
||||
|
||||
import latvia_border from '../../../assets/latvia_contour.webp'
|
||||
const latviaBoderImg = new Image()
|
||||
latviaBoderImg.onload = () => console.log('latvia_contour loaded...')
|
||||
latviaBoderImg.src = latvia_border
|
||||
|
||||
/*
|
||||
* final cropped size should be 1365x576px - divided by 3 (455x192) or 3.5 (390x165)
|
||||
* image should be rotade 26 degrees
|
||||
* currently image is 400x300px
|
||||
*/
|
||||
export function drawGrib(
|
||||
canvas: HTMLCanvasElement,
|
||||
messages: GribMessage[],
|
||||
buffers: Uint8Array[],
|
||||
bitmasks: Uint8Array[],
|
||||
cropBounds: CropBounds | undefined,
|
||||
isContour: boolean,
|
||||
isInterpolated: boolean,
|
||||
): void {
|
||||
// normally we have one message/buffer/bitmask?. special cases have multiple like wind direction
|
||||
const [grib] = messages
|
||||
|
||||
let { grid } = grib
|
||||
let { cols, rows } = grid
|
||||
|
||||
let modifiedBuffers = buffers.map((buffer, i) => {
|
||||
const bytesPerPoint = messages[i].bitsPerDataPoint / 8
|
||||
return bitmasks[i] ? applyBitmask(grid, buffer, bitmasks[i], bytesPerPoint) : buffer
|
||||
})
|
||||
|
||||
if (cropBounds) {
|
||||
modifiedBuffers = modifiedBuffers.map(buffer => extractFromBounds(grib, buffer, cropBounds))
|
||||
cols = cropBounds.width
|
||||
rows = cropBounds.height
|
||||
}
|
||||
|
||||
canvas.width = cols
|
||||
canvas.height = rows
|
||||
// canvas.style.width = '100%'
|
||||
// canvas.style.minWidth = '1365px'
|
||||
// canvas.style.border = '1px solid red'
|
||||
const ctx = canvas.getContext('2d')!
|
||||
let imgData = ctx.createImageData(cols, rows)
|
||||
|
||||
fillImageData(imgData, messages, modifiedBuffers, isInterpolated)
|
||||
ctx.putImageData(imgData, 0, 0)
|
||||
flipCanvasV(canvas, ctx)
|
||||
|
||||
if (cropBounds) {
|
||||
drawRotate(canvas, ctx, cropBounds.angle, isInterpolated, 3.5)
|
||||
}
|
||||
|
||||
if (isCalculatedWindDirection(grib)) {
|
||||
const directionArrows = windDirectionArrows(messages, modifiedBuffers, cols, rows, cropBounds)
|
||||
ctx.drawImage(directionArrows, 0, 0)
|
||||
}
|
||||
|
||||
if (isContour && cropBounds) {
|
||||
drawContour(canvas, ctx) // draw latvia contour only on cropped image
|
||||
}
|
||||
}
|
||||
|
||||
const CATEGORICAL_RAIN = [0, 1, 192]
|
||||
const TOTAL_PRECIPITATION = [0, 1, 52]
|
||||
const HOUR_PRECIPITATION = [0, 1, 236]
|
||||
const RAIN_PRECIPITATION = [0, 1, 65]
|
||||
const TEMPERATURE = [0, 0, 0]
|
||||
const WIND_DIRECTION = [0, 2, 192]
|
||||
const WIND_SPEED = [0, 2, 1]
|
||||
const WIND_SPEED_GUST = [0, 2, 22]
|
||||
|
||||
function fillImageData(
|
||||
imgData: ImageData,
|
||||
messages: GribMessage[],
|
||||
buffers: Uint8Array[],
|
||||
isInterpolated: boolean,
|
||||
) {
|
||||
const [grib] = messages
|
||||
const [buffer] = buffers
|
||||
const colors: [string, string] = ['#0000ff', '#ffff00']
|
||||
|
||||
const { meteo, conversion, bitsPerDataPoint } = grib
|
||||
const bytesPerPoint = grib.bitsPerDataPoint / 8
|
||||
const fromColor = rgbHexToU8(colors[0])
|
||||
const toColor = rgbHexToU8(colors[1])
|
||||
|
||||
const cols = imgData.width
|
||||
const rows = imgData.height
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
|
||||
const bufferI = (row * cols + col) * bytesPerPoint
|
||||
const index = (row * cols + col) * 4
|
||||
const firstByte = buffer[bufferI]
|
||||
const encodedValue = toInt(buffer.slice(bufferI, bufferI+bitsPerDataPoint/8))
|
||||
|
||||
let color = [255, 255, 255, 255]
|
||||
if (isMeteoEqual(meteo, CATEGORICAL_RAIN)) {
|
||||
color = categoricalRainColors(firstByte)
|
||||
}
|
||||
else if (isMeteoEqual(meteo, TOTAL_PRECIPITATION)) {
|
||||
color = precipitationColors(encodedValue, conversion, isInterpolated)
|
||||
}
|
||||
else if (isMeteoEqual(meteo, HOUR_PRECIPITATION)) {
|
||||
const [, nowPrec, prevPrec] = buffers
|
||||
const encodedValNow = toInt(nowPrec.slice(bufferI, bufferI+bitsPerDataPoint/8))
|
||||
const encodedValPrev = toInt(prevPrec.slice(bufferI, bufferI+bitsPerDataPoint/8))
|
||||
const [, metaNow, metaPrev] = messages
|
||||
color = hourPrecipitationColors(encodedValNow, metaNow.conversion, encodedValPrev, metaPrev.conversion, isInterpolated)
|
||||
}
|
||||
else if (isMeteoEqual(meteo, RAIN_PRECIPITATION)) {
|
||||
color = precipitationColors(encodedValue, conversion, isInterpolated)
|
||||
}
|
||||
else if (isMeteoEqual(meteo, TEMPERATURE)) {
|
||||
color = temperatureColors(encodedValue, conversion, isInterpolated)
|
||||
}
|
||||
else if (isMeteoEqual(meteo, WIND_DIRECTION)) {
|
||||
const [, bufferU, bufferV] = buffers
|
||||
const encodedValU = toInt(bufferU.slice(bufferI, bufferI+bitsPerDataPoint/8))
|
||||
const encodedValV = toInt(bufferV.slice(bufferI, bufferI+bitsPerDataPoint/8))
|
||||
const [, metaU, metaV] = messages // first message fake one 0-2-192
|
||||
color = windDirectionColors(encodedValU, encodedValV, metaU!.conversion, metaV!.conversion, isInterpolated)
|
||||
}
|
||||
else if (isMeteoEqual(meteo, WIND_SPEED)) {
|
||||
color = windSpeedColors(encodedValue, conversion, isInterpolated)
|
||||
}
|
||||
else if (isMeteoEqual(meteo, WIND_SPEED_GUST)) {
|
||||
color = windSpeedColors(encodedValue, conversion, isInterpolated)
|
||||
}
|
||||
else {
|
||||
color = interpolateColors(firstByte, fromColor, toColor)
|
||||
}
|
||||
|
||||
imgData.data[index] = color[0]
|
||||
imgData.data[index + 1] = color[1]
|
||||
imgData.data[index + 2] = color[2]
|
||||
imgData.data[index + 3] = color[3]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function flipCanvasV(
|
||||
canvas: HTMLCanvasElement,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
) {
|
||||
const tmpCanvas = document.createElement('canvas')!
|
||||
const tmpCtx = tmpCanvas.getContext('2d')!
|
||||
tmpCanvas.width = canvas.width
|
||||
tmpCanvas.height = canvas.height
|
||||
|
||||
tmpCtx.save()
|
||||
tmpCtx.scale(1, -1)
|
||||
tmpCtx.drawImage(canvas, 0, -canvas.height)
|
||||
tmpCtx.restore()
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.drawImage(tmpCanvas, 0, 0)
|
||||
}
|
||||
|
||||
export function drawRotate(
|
||||
canvas: HTMLCanvasElement,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
angleDegrees: number,
|
||||
isInterpolated = false,
|
||||
scale = 1,
|
||||
) {
|
||||
const tempCanvas = document.createElement('canvas')
|
||||
tempCanvas.width = canvas.width
|
||||
tempCanvas.height = canvas.height
|
||||
const tempCtx = tempCanvas.getContext('2d')!
|
||||
tempCtx.save()
|
||||
tempCtx.translate(tempCanvas.width/2, tempCanvas.height/2)
|
||||
tempCtx.rotate(angleDegrees * Math.PI/180)
|
||||
tempCtx.drawImage(canvas, -canvas.width/2, -canvas.height/2)
|
||||
tempCtx.restore()
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
// canvas.width = 390
|
||||
// canvas.height = 165
|
||||
canvas.width = 1365
|
||||
canvas.height = 576
|
||||
// console.log(ctx.imageSmoothingEnabled, ctx.imageSmoothingQuality)
|
||||
if (isInterpolated) {
|
||||
ctx.imageSmoothingEnabled = true
|
||||
ctx.imageSmoothingQuality = 'high' // Options: 'low', 'medium', 'high'
|
||||
} else {
|
||||
ctx.imageSmoothingEnabled = false
|
||||
}
|
||||
ctx.save()
|
||||
ctx.translate(canvas.width/2, canvas.height/2)
|
||||
ctx.scale(scale, scale)
|
||||
ctx.drawImage(tempCanvas, -tempCanvas.width/2, -tempCanvas.height/2)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function drawContour(
|
||||
canvas: HTMLCanvasElement,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
): void {
|
||||
ctx.save()
|
||||
ctx.translate(canvas.width/2 +120, canvas.height/2 -20)
|
||||
// TODO create contour image exact scale when sizes will be accepted
|
||||
const scale = 5.3/3.5
|
||||
const scaledWidth = latviaBoderImg.width/scale
|
||||
const scaledHeight = latviaBoderImg.height/scale
|
||||
ctx.drawImage(latviaBoderImg,
|
||||
-scaledWidth/2, -scaledHeight/2,
|
||||
scaledWidth, scaledHeight
|
||||
)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
|
||||
function rgbHexToU8(hex: string): RGBu8 {
|
||||
return [
|
||||
parseInt(`0x${hex.slice(1, 3)}`),
|
||||
parseInt(`0x${hex.slice(3, 5)}`),
|
||||
parseInt(`0x${hex.slice(5, 7)}`),
|
||||
]
|
||||
}
|
||||
|
||||
type RGBu8 = [number, number, number]
|
||||
|
||||
export function isMeteoEqual(meteo: MeteoParam, arr: number[]): boolean {
|
||||
const arr2 = [meteo.discipline, meteo.category, meteo.product]
|
||||
return arr.length === arr2.length && arr.every((value, index) => value === arr2[index])
|
||||
}
|
||||
|
||||
function toInt(bytes: Uint8Array): number {
|
||||
return bytes.reduce((acc, curr) => acc * 256 + curr)
|
||||
}
|
||||
|
||||
export function toSignedInt(bytes: Uint8Array): number {
|
||||
const unsigned = toInt(bytes)
|
||||
|
||||
const signBit = 1 << (bytes.length * 8 - 1) // Example: 16-bit -> 0x8000
|
||||
if (unsigned & signBit) {
|
||||
// If the sign bit is set, compute the two's complement
|
||||
return unsigned - (1 << (bytes.length * 8))
|
||||
}
|
||||
|
||||
return unsigned // If the sign bit is not set, return as is
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import moment from 'moment'
|
||||
import { valueToColorInterpolated, valueToColorThreshold } from '../../../helpers/interpolateColors'
|
||||
import { GribMessage, MeteoConversion } from '../interfaces'
|
||||
import { PRECIPITATION } from './constants'
|
||||
import { fetchBuffer } from '../../../helpers/fetch'
|
||||
import { apiHost } from '../../../consts'
|
||||
|
||||
|
||||
export function precipitationColors(
|
||||
encodedValue: number,
|
||||
{ reference, binaryScale, decimalScale}: MeteoConversion,
|
||||
isInterpolated = true,
|
||||
): [number, number, number, number] {
|
||||
const rainMM = (reference + encodedValue * Math.pow(2, binaryScale)) * Math.pow(10, -decimalScale)
|
||||
|
||||
return isInterpolated
|
||||
? valueToColorInterpolated(rainMM, PRECIPITATION)
|
||||
: valueToColorThreshold(rainMM, PRECIPITATION)
|
||||
}
|
||||
|
||||
export function hourPrecipitationColors(
|
||||
encodedValNow: number,
|
||||
convNow: MeteoConversion,
|
||||
encodedValPrev: number,
|
||||
convPrev: MeteoConversion,
|
||||
isInterpolated = true,
|
||||
): [number, number, number, number] {
|
||||
const nowVal = (convNow.reference + encodedValNow * Math.pow(2, convNow.binaryScale)) * Math.pow(10, -convNow.decimalScale)
|
||||
const prevVal = (convPrev.reference + encodedValPrev * Math.pow(2, convPrev.binaryScale)) * Math.pow(10, -convPrev.decimalScale)
|
||||
|
||||
const rainMM = nowVal - prevVal
|
||||
return isInterpolated
|
||||
? valueToColorInterpolated(rainMM, PRECIPITATION)
|
||||
: valueToColorThreshold(rainMM, PRECIPITATION)
|
||||
}
|
||||
|
||||
export function fetchHourPrecipitationData(
|
||||
customMessage: GribMessage,
|
||||
gribArr: GribMessage[],
|
||||
): Promise<[GribMessage[], ArrayBuffer[], ArrayBuffer[]]> {
|
||||
const totalPrecipitation = gribArr.find(g => isPrecipitation(g)
|
||||
&& g.time.referenceTime === customMessage.time.referenceTime
|
||||
&& g.time.forecastTime === customMessage.time.forecastTime
|
||||
)
|
||||
if (!totalPrecipitation) throw new Error('Not found total precipitation')
|
||||
|
||||
const { forecastTime } = totalPrecipitation.time
|
||||
const prevForecastTime = moment(forecastTime.replace(/(\d{2})(\d{2})Z/, '$1:$2:00Z'))
|
||||
.subtract(1, 'hours')
|
||||
.utc()
|
||||
.format("YYYY-MM-DDTHHmm")+'Z'
|
||||
|
||||
const prevTotalPrecipitation = gribArr.find(g => isPrecipitation(g)
|
||||
&& g.time.referenceTime === customMessage.time.referenceTime
|
||||
&& g.time.forecastTime === prevForecastTime
|
||||
)
|
||||
|
||||
const section7now = totalPrecipitation.sections.find(section => section.id === 7)
|
||||
const section7prev = prevTotalPrecipitation?.sections.find(section => section.id === 7)
|
||||
if (!section7now) throw new Error('Didnt found binary section for total precipitation')
|
||||
|
||||
const nowBinaryOffset = section7now.offset + 5
|
||||
const nowBinaryLength = section7now.size - 5
|
||||
const nowFileName = `harmonie_${totalPrecipitation.time.referenceTime}_${totalPrecipitation.time.forecastTime}.grib`
|
||||
|
||||
// for oldest message there is no more -1h message
|
||||
let prevPromise: Promise<ArrayBuffer> | undefined
|
||||
if (prevTotalPrecipitation && section7prev) {
|
||||
const prevBinaryOffset = section7prev.offset + 5
|
||||
const prevBinaryLength = section7prev.size - 5
|
||||
const prevFileName = `harmonie_${prevTotalPrecipitation.time.referenceTime}_${prevTotalPrecipitation.time.forecastTime}.grib`
|
||||
prevPromise = fetchBuffer(`${apiHost}/api/grib/binary-chunk/${prevBinaryOffset}/${prevBinaryLength}/${prevFileName}`)
|
||||
}
|
||||
if (!prevPromise) prevPromise = new Promise(resolve => resolve(new Uint8Array(nowBinaryLength).buffer))
|
||||
|
||||
return Promise.all([
|
||||
fetchBuffer(`${apiHost}/api/grib/binary-chunk/${nowBinaryOffset}/${nowBinaryLength}/${nowFileName}`),
|
||||
prevPromise,
|
||||
]).then(([bufferNow, bufferPrev]) => {
|
||||
const messages = [customMessage, totalPrecipitation, prevTotalPrecipitation ?? totalPrecipitation]
|
||||
const buffers = [bufferNow, bufferNow, bufferPrev]
|
||||
return [messages, buffers, []]
|
||||
})
|
||||
}
|
||||
|
||||
export function isPrecipitation(grib: GribMessage): boolean {
|
||||
return grib.meteo.discipline === 0 && grib.meteo.category === 1 && grib.meteo.product === 52
|
||||
}
|
||||
|
||||
export function isCalculatedHourPrecipitation(grib: GribMessage): boolean {
|
||||
return grib.meteo.discipline === 0 && grib.meteo.category === 1 && grib.meteo.product === 236
|
||||
}
|
||||
|
||||
export function getFakeHourPrecipitation(totalPrecipitation: GribMessage): GribMessage {
|
||||
const modifiedPrecipitation = structuredClone(totalPrecipitation)
|
||||
modifiedPrecipitation.meteo = {...modifiedPrecipitation.meteo, product: 236}
|
||||
modifiedPrecipitation.title = 'meteorology, moisture, hour precipitation rate'
|
||||
return modifiedPrecipitation
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { valueToColorInterpolated, valueToColorThreshold } from '../../../helpers/interpolateColors'
|
||||
import { MeteoConversion } from '../interfaces'
|
||||
import { TEMPERATURES } from './constants'
|
||||
|
||||
|
||||
export function temperatureColors(
|
||||
encodedValue: number,
|
||||
{ reference, binaryScale, decimalScale}: MeteoConversion,
|
||||
isInterpolated = true,
|
||||
): [number, number, number, number] {
|
||||
const temperatureC = (reference + encodedValue * Math.pow(2, binaryScale)) * Math.pow(10, -decimalScale) - 273.15
|
||||
|
||||
return isInterpolated
|
||||
? valueToColorInterpolated(temperatureC, TEMPERATURES)
|
||||
: valueToColorThreshold(temperatureC, TEMPERATURES)
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { apiHost } from '../../../consts'
|
||||
import { fetchBuffer } from '../../../helpers/fetch'
|
||||
import { valueToColorInterpolated, valueToColorThreshold } from '../../../helpers/interpolateColors'
|
||||
import { CropBounds, GribMessage, MeteoConversion } from '../interfaces'
|
||||
import { WIND_SPEED } from './constants'
|
||||
import { drawRotate, flipCanvasV } from './drawGrib'
|
||||
import { rotateWind } from './windRotate'
|
||||
|
||||
export function windDirectionArrows(
|
||||
messages: GribMessage[],
|
||||
buffers: Uint8Array[],
|
||||
cols: number,
|
||||
rows: number,
|
||||
cropBounds: CropBounds | undefined,
|
||||
): HTMLCanvasElement {
|
||||
const cellSize = cropBounds ? 21 : 16
|
||||
const scale = cropBounds ? 3.5 : 1
|
||||
// const scale = 1
|
||||
|
||||
const [, metaU, metaV] = messages
|
||||
const { conversion: convU } = metaU
|
||||
const { conversion: convV } = metaV
|
||||
const [, bufferU, bufferV] = buffers
|
||||
const directions: number[][] = []
|
||||
const bytesPerPoint = metaU.bitsPerDataPoint/8
|
||||
|
||||
const lambert = (messages[0].grid as any).lambert
|
||||
let rot_lat = lambert[0] / 1_000_000
|
||||
let rot_lon = lambert[1] / 1_000_000
|
||||
// rot_lat = -9999.0
|
||||
// rot_lon = -9999.0
|
||||
let reg_lat = 56.530592
|
||||
let reg_lon = -2.918742
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
directions[row] = []
|
||||
for (let col = 0; col < cols; col++) {
|
||||
const bufferI = (row * cols + col) * bytesPerPoint
|
||||
|
||||
const encodedU = toInt(bufferU.slice(bufferI, bufferI+bytesPerPoint))
|
||||
const encodedV = toInt(bufferV.slice(bufferI, bufferI+bytesPerPoint))
|
||||
const windSpeedU = (convU.reference + encodedU * Math.pow(2, convU.binaryScale)) * Math.pow(10, -convU.decimalScale)
|
||||
const windSpeedV = (convV.reference + encodedV * Math.pow(2, convV.binaryScale)) * Math.pow(10, -convV.decimalScale)
|
||||
|
||||
// const directionDeg = (Math.atan2(windSpeedU, windSpeedV)*180/Math.PI + 360 +45) % 360
|
||||
const directionDeg = rotateWind(rot_lat, rot_lon, reg_lat, reg_lon, windSpeedU, windSpeedV)[0]
|
||||
const directionRad = (Math.PI / 180) * directionDeg
|
||||
|
||||
directions[row][col] = directionRad
|
||||
}
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas')!
|
||||
const ctx = canvas.getContext('2d')!
|
||||
canvas.width = cols * scale
|
||||
canvas.height = rows * scale
|
||||
|
||||
const gridH = Math.floor(scale * directions.length/cellSize)
|
||||
const gridW = Math.floor(scale * directions[0].length/cellSize)
|
||||
for (let row = 0; row < gridH; row++) {
|
||||
for (let col = 0; col < gridW; col++) {
|
||||
const directionCol = Math.floor(col/scale)
|
||||
const directionRow = Math.floor(row/scale)
|
||||
const directionAvg = true
|
||||
? getAvgDirection(directions, directionRow, directionCol, cellSize)
|
||||
: getDirection(directions, directionRow, directionCol, cellSize)
|
||||
|
||||
const centerX = col * cellSize + cellSize/2
|
||||
const centerY = row * cellSize + cellSize/2
|
||||
const arrowLength = cellSize*0.9
|
||||
|
||||
ctx.save()
|
||||
ctx.translate(centerX, centerY)
|
||||
ctx.rotate(directionAvg)
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-arrowLength / 2, 0)
|
||||
ctx.lineTo(arrowLength / 2, 0)
|
||||
ctx.stroke()
|
||||
|
||||
const arrowheadSize = cellSize/3.5
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(arrowLength / 2, 0)
|
||||
ctx.lineTo(arrowLength / 2 - arrowheadSize, -arrowheadSize / 2)
|
||||
ctx.lineTo(arrowLength / 2 - arrowheadSize, arrowheadSize / 2)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
flipCanvasV(canvas, ctx)
|
||||
if (cropBounds) {
|
||||
drawRotate(canvas, ctx, cropBounds.angle, true)
|
||||
}
|
||||
return canvas
|
||||
}
|
||||
|
||||
function getDirection(directions: number[][], gridRow: number, gridCol: number, cellSize: number) {
|
||||
const directionRow = gridRow * cellSize + Math.round(cellSize/2)
|
||||
const directionCol = gridCol * cellSize + Math.round(cellSize/2)
|
||||
return directions[directionRow][directionCol]
|
||||
}
|
||||
|
||||
// in radians
|
||||
function getAvgDirection(directions: number[][], gridRow: number, gridCol: number, cellSize: number) {
|
||||
let sumSin = 0; // Sum of sine components
|
||||
let sumCos = 0; // Sum of cosine components
|
||||
|
||||
for (let row = 0; row < cellSize; row++) {
|
||||
for (let col = 0; col < cellSize; col++) {
|
||||
const directionRow = gridRow * cellSize + row;
|
||||
const directionCol = gridCol * cellSize + col;
|
||||
|
||||
const directionRad = directions[directionRow][directionCol]
|
||||
|
||||
sumSin += Math.sin(directionRad);
|
||||
sumCos += Math.cos(directionRad);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute the average direction
|
||||
const avgDirection = Math.atan2(sumSin, sumCos); // Result is in radians
|
||||
return (avgDirection + 2 * Math.PI) % (2 * Math.PI); // Ensure the result is in [0, 2π)
|
||||
}
|
||||
|
||||
// actually calculates and draws wind speed
|
||||
export function windDirectionColors(
|
||||
encodedU: number,
|
||||
encodedV: number,
|
||||
convU: MeteoConversion,
|
||||
convV: MeteoConversion,
|
||||
isInterpolated: boolean,
|
||||
) {
|
||||
const windSpeedU = (convU.reference + encodedU * Math.pow(2, convU.binaryScale)) * Math.pow(10, -convU.decimalScale)
|
||||
const windSpeedV = (convV.reference + encodedV * Math.pow(2, convV.binaryScale)) * Math.pow(10, -convV.decimalScale)
|
||||
const windSpeed = Math.sqrt(Math.pow(windSpeedU, 2) + Math.pow(windSpeedV, 2))
|
||||
|
||||
return isInterpolated
|
||||
? valueToColorInterpolated(windSpeed, WIND_SPEED)
|
||||
: valueToColorThreshold(windSpeed, WIND_SPEED)
|
||||
}
|
||||
|
||||
export function windSpeedColors(
|
||||
encodedValue: number,
|
||||
{ reference, binaryScale, decimalScale}: MeteoConversion,
|
||||
isInterpolated: boolean,
|
||||
) {
|
||||
const windSpeed = (reference + encodedValue * Math.pow(2, binaryScale)) * Math.pow(10, -decimalScale)
|
||||
return isInterpolated
|
||||
? valueToColorInterpolated(windSpeed, WIND_SPEED)
|
||||
: valueToColorThreshold(windSpeed, WIND_SPEED)
|
||||
}
|
||||
|
||||
export function fetchWindData(
|
||||
grib: GribMessage,
|
||||
gribList: GribMessage[],
|
||||
): Promise<[GribMessage[], ArrayBuffer[], ArrayBuffer[]]> {
|
||||
const fileName = `harmonie_${grib.time.referenceTime}_${grib.time.forecastTime}.grib`
|
||||
const sameDateGribList = gribList.filter(g => g.time.referenceTime === grib.time.referenceTime && g.time.forecastTime === grib.time.forecastTime)
|
||||
const windU = sameDateGribList.find(m => m.meteo.discipline===0 && m.meteo.category===2 && m.meteo.product===2 && m.meteo.levelType===103 && m.meteo.levelValue===10)
|
||||
const windV = sameDateGribList.find(m => m.meteo.discipline===0 && m.meteo.category===2 && m.meteo.product===3 && m.meteo.levelType===103 && m.meteo.levelValue===10)
|
||||
if (!windU || !windV) throw new Error('Didnt found u/v components of wind')
|
||||
|
||||
const section7u = windU.sections.find(section => section.id === 7)
|
||||
const section7v = windV.sections.find(section => section.id === 7)
|
||||
if (!section7u || !section7v) throw new Error('Didnt found binary section for wind u/v')
|
||||
|
||||
const uBinaryOffset = section7u.offset + 5
|
||||
const uBinaryLength = section7u.size - 5
|
||||
|
||||
const vBinaryOffset = section7v.offset + 5
|
||||
const vBinaryLength = section7v.size - 5
|
||||
|
||||
return Promise.all([
|
||||
fetchBuffer(`${apiHost}/api/grib/binary-chunk/${uBinaryOffset}/${uBinaryLength}/${fileName}`),
|
||||
fetchBuffer(`${apiHost}/api/grib/binary-chunk/${vBinaryOffset}/${vBinaryLength}/${fileName}`),
|
||||
]).then(([bufferU, bufferV]) => {
|
||||
const messages = [grib, windU, windV]
|
||||
const buffers = [bufferU, bufferU, bufferV]
|
||||
return [messages, buffers, []]
|
||||
})
|
||||
}
|
||||
|
||||
function toInt(bytes: Uint8Array): number {
|
||||
return bytes.reduce((acc, curr) => acc * 256 + curr)
|
||||
}
|
||||
|
||||
export function isWindSpeed(grib: GribMessage): boolean {
|
||||
return grib.meteo.discipline === 0 && grib.meteo.category === 2 && grib.meteo.product === 1
|
||||
}
|
||||
|
||||
export function isCalculatedWindDirection(grib: GribMessage): boolean {
|
||||
return grib.meteo.discipline === 0 && grib.meteo.category === 2 && grib.meteo.product === 192
|
||||
}
|
||||
|
||||
export function getFakeWindDirection(windSpeed: GribMessage): GribMessage {
|
||||
const modifiedWindSpeed = structuredClone(windSpeed)
|
||||
modifiedWindSpeed.meteo = {...modifiedWindSpeed.meteo, product: 192}
|
||||
modifiedWindSpeed.title = 'meteorology, momentum, wind direction 10m (calc u,v)'
|
||||
return modifiedWindSpeed
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// converted from C code https://opendatadocs.dmi.govcloud.dk/Data/Forecast_Data_Weather_Model_HARMONIE
|
||||
export function rotateWind(
|
||||
rot_lat: number,
|
||||
rot_lon: number,
|
||||
reg_lat: number,
|
||||
reg_lon: number,
|
||||
u_in: number,
|
||||
v_in: number,
|
||||
southpole_lat = 26.5,
|
||||
southpole_lon = -40,
|
||||
): [direction: number, strength: number, u_out: number, v_out: number]
|
||||
/* Given either a point in the regular grid (set `*rot_lat' <= -999.0)
|
||||
* or a point in the rotated grid, calculate the corresponding point
|
||||
* in the opposite grid, change the (u, v)-vector from rotated to
|
||||
* regular grid and calculate the wind force (`*strength') and the
|
||||
* wind direction in the regular grid. `southpole_lat' and
|
||||
* `southpole_lon' defines the coordinate of the southpole in
|
||||
* the roated grid */
|
||||
{
|
||||
/* Find the missing point, whether is is the rotated or the regular */
|
||||
if (rot_lat <= -999.0) [rot_lat, rot_lon] = reg2rot(reg_lat, reg_lon, southpole_lat, southpole_lon)
|
||||
else [reg_lat, reg_lon] = rot2reg(rot_lat, rot_lon, southpole_lat, southpole_lon);
|
||||
|
||||
/* Calculate the wind strength */
|
||||
const strength = Math.sqrt(u_in*u_in + v_in*v_in);
|
||||
|
||||
/* Add a small distance in the direction of the wind to the rotated
|
||||
* grid point, changing the distance into degrees */
|
||||
const rot_lat2 = rot_lat + 0.1*v_in/(strength);
|
||||
let clat = Math.cos(rot_lat*Math.PI/180.0)
|
||||
if (0.0001 > clat && clat > -0.0001) {
|
||||
throw new Error("Internal error: Too close to pole to calculate rotated wind")
|
||||
}
|
||||
const rot_lon2 = rot_lon + 0.1*u_in/(strength * clat);
|
||||
|
||||
/* Translate new rotated grid point to regular grid */
|
||||
const [reg_lat2, reg_lon2] = rot2reg(rot_lat2, rot_lon2, southpole_lat, southpole_lon)
|
||||
|
||||
/* Transform offset in lat-lon to offset in x-y */
|
||||
clat = Math.cos(reg_lat*Math.PI/180.0)
|
||||
const dx = clat*(reg_lon2 - reg_lon)
|
||||
|
||||
/* Calculate the direction of the wind vector in the regular grid */
|
||||
const direc = Math.atan2(reg_lat2 - reg_lat, dx);
|
||||
|
||||
/* Regular direction in degrees */
|
||||
let direction = 630.0 - direc*180.0 / Math.PI
|
||||
while (direction > 360.0) direction -= 360.0
|
||||
|
||||
const u_out = Math.cos(direc) * strength
|
||||
const v_out = Math.sin(direc) * strength
|
||||
|
||||
return [direction, strength, u_out, v_out]
|
||||
}
|
||||
|
||||
|
||||
function rot2reg(
|
||||
rot_lat: number,
|
||||
rot_lon: number,
|
||||
southpole_lat: number,
|
||||
southpole_lon: number,
|
||||
): [reg_lat: number, reg_lon: number]
|
||||
/* Convert from rotated latitude-longitude to regular latitude-longitude
|
||||
with the transformation defined by the southpole coordinates.
|
||||
Coordinates are given in degrees N (negative for S) and degrees E
|
||||
(negative for W). */
|
||||
{
|
||||
const to_rad = Math.PI/180.0
|
||||
const to_deg = 1.0/to_rad
|
||||
|
||||
const sin_y_cen = Math.sin(to_rad*(southpole_lat + 90.0))
|
||||
const cos_y_cen = Math.cos(to_rad*(southpole_lat + 90.0))
|
||||
|
||||
const sin_x_rot = Math.sin(to_rad*rot_lon)
|
||||
const cos_x_rot = Math.cos(to_rad*rot_lon)
|
||||
const sin_y_rot = Math.sin(to_rad*rot_lat)
|
||||
const cos_y_rot = Math.cos(to_rad*rot_lat)
|
||||
let sin_y_reg = cos_y_cen*sin_y_rot + sin_y_cen*cos_y_rot*cos_x_rot
|
||||
if (sin_y_reg < -1.0) sin_y_reg = -1.0
|
||||
if (sin_y_reg > 1.0) sin_y_reg = 1.0
|
||||
|
||||
const reg_lat = to_deg*Math.asin(sin_y_reg)
|
||||
|
||||
const cos_y_reg = Math.cos(reg_lat*to_rad);
|
||||
let cos_lon_rad = (cos_y_cen*cos_y_rot*cos_x_rot - sin_y_cen*sin_y_rot)/cos_y_reg;
|
||||
if (cos_lon_rad < -1.0) cos_lon_rad = -1.0;
|
||||
if (cos_lon_rad > 1.0) cos_lon_rad = 1.0;
|
||||
const sin_lon_rad = cos_y_rot*sin_x_rot/cos_y_reg;
|
||||
let lon_rad = Math.acos(cos_lon_rad);
|
||||
if (sin_lon_rad < 0.0) lon_rad = -lon_rad;
|
||||
|
||||
const reg_lon = to_deg*lon_rad + southpole_lon;
|
||||
|
||||
return [reg_lat, reg_lon]
|
||||
}
|
||||
|
||||
|
||||
function reg2rot(
|
||||
reg_lat: number,
|
||||
reg_lon: number,
|
||||
southpole_lat: number,
|
||||
southpole_lon: number,
|
||||
): [rot_lat: number, rot_lon: number]
|
||||
/* Convert from regular latitude-longitude to rotated latitude-longitude
|
||||
with the transformation defined by the southpole coordinates.
|
||||
Coordinates are given in degrees N (negative for S) and degrees E
|
||||
(negative for W). */
|
||||
{
|
||||
const to_rad = Math.PI/180.0
|
||||
const to_deg = 1.0/to_rad
|
||||
const sin_y_cen = Math.sin(to_rad*(southpole_lat + 90.0));
|
||||
const cos_y_cen = Math.cos(to_rad*(southpole_lat + 90.0));
|
||||
|
||||
const lon_rad = to_rad*(reg_lon - southpole_lon)
|
||||
const sin_lon_rad = Math.sin(lon_rad)
|
||||
const cos_lon_rad = Math.cos(lon_rad)
|
||||
const sin_y_reg = Math.sin(to_rad*reg_lat)
|
||||
const cos_y_reg = Math.cos(to_rad*reg_lat)
|
||||
let sin_y_rot = cos_y_cen*sin_y_reg - sin_y_cen*cos_y_reg*cos_lon_rad
|
||||
if (sin_y_rot < -1.0) sin_y_rot = -1.0
|
||||
if (sin_y_rot > 1.0) sin_y_rot = 1.0
|
||||
|
||||
const rot_lat = Math.asin(sin_y_rot)*to_deg
|
||||
|
||||
const cos_y_rot = Math.cos(rot_lat*to_rad)
|
||||
let cos_x_rot = (cos_y_cen*cos_y_reg*cos_lon_rad + sin_y_cen*sin_y_reg)/cos_y_rot
|
||||
if (cos_x_rot < -1.0) cos_x_rot = -1.0
|
||||
if (cos_x_rot > 1.0) cos_x_rot = 1.0
|
||||
const sin_x_rot = cos_y_reg*sin_lon_rad/cos_y_rot
|
||||
|
||||
let rot_lon = Math.acos(cos_x_rot)*to_deg
|
||||
if (sin_x_rot < 0.0) rot_lon = -rot_lon
|
||||
|
||||
return [rot_lat, rot_lon]
|
||||
}
|
||||
Reference in New Issue
Block a user