Refactor, move all pages to separate folder

This commit is contained in:
Guntis Smaukstelis
2025-03-14 11:31:47 +02:00
parent 968a06c0f0
commit a2d1608cb0
54 changed files with 95 additions and 104 deletions
+41
View File
@@ -0,0 +1,41 @@
import { Component, createEffect, Signal } from 'solid-js'
import { LoadingSpinner } from '../../components/spinner/LoadingSpinner'
import { CROP_BOUNDS, DrawOptions, GribMessage } from './interfaces'
import { drawGrib } from './draw/drawGrib'
export const DrawView: Component<{
isLoadingSignal: Signal<boolean>,
options: DrawOptions;
canvasSignal: Signal<HTMLCanvasElement | undefined>,
cachedMessagesSignal: Signal<GribMessage[]>,
cachedBuffersSignal: Signal<Uint8Array[]>,
cachedBitmasksSignal: Signal<Uint8Array[]>,
}> = ({
isLoadingSignal: [getIsLoading, setIsLoading],
options,
canvasSignal: [getCanvas, setCanvas],
cachedMessagesSignal: [getCachedMessages],
cachedBuffersSignal: [getCachedBuffers],
cachedBitmasksSignal: [getCachedBitmasks],
}) => {
createEffect(async () => {
setIsLoading(true)
const canvas = getCanvas()!
const ctx = canvas.getContext('2d')!
ctx.clearRect(0, 0, canvas.width, canvas.height)
const cropBounds = options.getIsCrop() ? CROP_BOUNDS : undefined
const contour = options.getIsContour()
const isInterpolated = options.getIsInterpolated()
if (getCachedMessages().length === 0) return;
await new Promise(resolve => setTimeout(resolve, 100))
// hack to show loading spinner
drawGrib(canvas, getCachedMessages(), getCachedBuffers(), getCachedBitmasks(), cropBounds, contour, isInterpolated)
setIsLoading(false)
})
return <>
{ getIsLoading() && <LoadingSpinner text='' />}
<canvas ref={setCanvas} style={{ display: getIsLoading() ? 'none' : 'block' }} />
</>
}
+73
View File
@@ -0,0 +1,73 @@
import { Accessor, batch, Component, createSignal, Setter, Signal } from 'solid-js'
import styles from './harmonie.module.css'
import { GribMessage } from './interfaces'
import { fetchGribBinaries } from './fetchGrib'
export const GribFile: Component<{
name: string,
setIsLoading: Setter<boolean>,
getFileGribList: Accessor<GribMessage[]>, // specific reference and forecast time (in one file)
getAllGribLists: Accessor<GribMessage[]>,
onClick: (name: string) => void,
cachedMessagesSignal: Signal<GribMessage[]>,
cachedBuffersSignal: Signal<Uint8Array[]>,
cachedBitmasksSignal: Signal<Uint8Array[]>,
}> = ({
name,
setIsLoading,
getFileGribList,
getAllGribLists,
onClick,
cachedMessagesSignal: [, setCachedMessages],
cachedBuffersSignal: [, setCachedBuffers],
cachedBitmasksSignal: [, setCachedBitmasks],
}) => {
const [getIsActive, setIsActive] = createSignal(false)
function onParamClick(paramId: number) {
setIsLoading(true);
const grib = getFileGribList()[paramId]
fetchGribBinaries(grib, getAllGribLists()).then(([messages, binaryBuffers, bitmasks]) => {
batch(() => {
setCachedMessages(messages)
setCachedBuffers(binaryBuffers)
setCachedBitmasks(bitmasks)
})
})
.catch(err => console.warn(err.message))
.finally(() => setIsLoading(false))
}
///// HACK - delete this
// createEffect(() => {
// if (
// getFileGribList().length
// && name === 'harmonie_2025-02-18T0900Z_2025-02-18T1900Z.grib'
// ) {
// onParamClick(5)
// }
// })
return <li
class={getIsActive() ? styles.active : ''}
onClick={() => onClick(name)}
>
<div class={styles.name} onClick={() => setIsActive(!getIsActive())}>{ trimName(name) }</div>
<ul class={styles.meteoParams}>
{ getFileGribList()
.map((grib, i) =>
<li onClick={() => onParamClick(i)}>{ grib.title.replace('meteorology, ', '') }</li>
)}
</ul>
</li>
}
function trimName(title: string): string {
let result = title
result = result.replace('harmonie_', '')
result = result.replace('.grib', '')
return result
}
+109
View File
@@ -0,0 +1,109 @@
import { Component, createSignal } from 'solid-js'
import { GribFile } from './GribFile'
import { GribMessage } from './interfaces'
import { DrawView } from './DrawView'
import { ReferenceTimes } from './ReferenceTimes'
import { fetchGribList, fetchGribListStructure } from './fetchGrib'
import styles from './harmonie.module.css'
import { SlideShow } from './SlideShow'
export const Harmonie: Component<{}> = () => {
const [getFileList, setFileList] = createSignal<string[]>([])
const [getIsLoading, setIsLoading] = createSignal(true)
const [getIsCrop, setIsCrop] = createSignal(true)
const [getIsContour, setIsContour] = createSignal(true)
const [getIsInterpolated, setIsInterpolated] = createSignal(true)
const [getGribList, setGribList] = createSignal<GribMessage[]>([])
const [getCanvas, setCanvas] = createSignal<HTMLCanvasElement>()
const [getImgList, setImgList] = createSignal<[string, ImageBitmap | undefined][]>([])
const [getRefDate, setRefDate] = createSignal('')
const cachedMessagesSignal = createSignal<GribMessage[]>([])
const cachedBuffersSignal = createSignal<Uint8Array[]>([])
const cachedBitmasksSignal = createSignal<Uint8Array[]>([])
fetchGribList()
.then(setFileList)
.finally(() => setIsLoading(false))
function getAllGribStructure() {
if(!getGribList().length) {
setIsLoading(true)
fetchGribListStructure()
.then(setGribList)
.finally(() => setIsLoading(false))
}
}
function getCurrentGribList(fileName: string): GribMessage[] {
const [referenceTime, forecastTime] = fileName.replace('harmonie_', '')
.replace('.grib', '')
.split('_')
if (!referenceTime || ! forecastTime) return []
return getGribList().filter(g =>
g.time.referenceTime === referenceTime
&& g.time.forecastTime === forecastTime
)
}
return <div class={styles.container}>
<div class={styles.column}>
<label>
Crop Latvia
<input type='checkbox' checked={getIsCrop()} onChange={()=>setIsCrop(!getIsCrop())} />
</label>
&nbsp;
<label>
Contour
<input type='checkbox' checked={getIsContour()} onChange={()=>setIsContour(!getIsContour())} />
</label>
&nbsp;
<label>
Interpolate
<input type='checkbox' checked={getIsInterpolated()} onChange={()=>setIsInterpolated(!getIsInterpolated())} />
</label>
<ReferenceTimes
setIsLoading={setIsLoading}
getFileList={getFileList}
getGribList={getGribList}
options={{ getIsCrop: getIsCrop, getIsContour: getIsContour, getIsInterpolated: getIsInterpolated }}
imgListSignal={[getImgList, setImgList]}
setRefDate={setRefDate}
onClick={getAllGribStructure}
/>
<ul class={styles.fileList}>
{getFileList().map(fileName =>
<GribFile
name={fileName}
setIsLoading={setIsLoading}
getFileGribList={() => getCurrentGribList(fileName)}
getAllGribLists={getGribList}
onClick={getAllGribStructure}
cachedMessagesSignal={cachedMessagesSignal}
cachedBuffersSignal={cachedBuffersSignal}
cachedBitmasksSignal={cachedBitmasksSignal}
/>
)}
</ul>
</div>
<div class={styles.column}>
<SlideShow
getIsLoading={getIsLoading}
getCanvas={getCanvas}
getImgList={getImgList}
getRefDate={getRefDate}
/>
<DrawView
isLoadingSignal={[getIsLoading, setIsLoading]}
options={{ getIsCrop: getIsCrop, getIsContour: getIsContour, getIsInterpolated: getIsInterpolated }}
canvasSignal={[getCanvas, setCanvas]}
cachedMessagesSignal={cachedMessagesSignal}
cachedBuffersSignal={cachedBuffersSignal}
cachedBitmasksSignal={cachedBitmasksSignal}
/>
</div>
</div>
}
+106
View File
@@ -0,0 +1,106 @@
import { Accessor, Component, createSignal, Setter, Signal } from 'solid-js'
import { CROP_BOUNDS, DrawOptions, GribMessage, MeteoParam } from './interfaces'
import { fetchGribBinaries } from './fetchGrib'
import { drawGrib } from './draw/drawGrib'
import styles from './harmonie.module.css'
import { processPromisesInBatches } from '../../helpers/progressivePromises'
const METEO_PARAMS: [string, MeteoParam][] = [
['temperature', { discipline: 0, category: 0, product: 0, levelType: -1, levelValue: -1, subType: 'now' }],
['precipitation', { discipline: 0, category: 1, product: 236, levelType: -1, levelValue: -1, subType: 'now' }],
['categorical precipitation', { discipline: 0, category: 1, product: 192, levelType: -1, levelValue: -1, subType: 'now' }],
['wind speed', { discipline: 0, category: 2, product: 1, levelType: -1, levelValue: -1, subType: 'now' }],
['wind speed gust', { discipline: 0, category: 2, product: 22, levelType: -1, levelValue: -1, subType: 'now' }],
['wind direction', { discipline: 0, category: 2, product: 192, levelType: -1, levelValue: -1, subType: 'now' }],
]
export const ReferenceTimes: Component<{
setIsLoading: Setter<boolean>,
getFileList: Accessor<string[]>,
getGribList: Accessor<GribMessage[]>,
options: DrawOptions,
imgListSignal: Signal<[string, ImageBitmap | undefined][]>,
setRefDate: Setter<string>,
onClick: () => void,
}> = ({
setIsLoading,
getFileList,
getGribList,
options,
imgListSignal: [getImgList, setImgList],
setRefDate,
onClick,
}) => {
const [getActiveDate, setActiveDate] = createSignal('')
const dateList = (): [string, number][] => {
const datesStr = getFileList().map(f => f.replace('harmonie_', '').split('_')[0])
const uniqueDates = [...new Set(datesStr)]
return uniqueDates.map(dateStr => [
dateStr,
datesStr.filter(d => d === dateStr).length
])
}
function onActiveDate(date: string) {
onClick()
const newValue = getActiveDate() === date ? '' : date
setActiveDate(newValue)
}
async function fetchDrawImgList(refDateStr: string, param: MeteoParam) {
setIsLoading(true)
const cropBounds = options.getIsCrop() ? CROP_BOUNDS : undefined
const contour = options.getIsContour()
const isInterpolated = options.getIsInterpolated()
const forecastList = getGribList()
.filter(g => g.time.referenceTime === refDateStr)
.filter(g => g.meteo.discipline === param.discipline && g.meteo.category === param.category && g.meteo.product === param.product)
const emptyImgList: [string, undefined][] = forecastList
.map((grib): [string, undefined] => [grib.time.forecastTime, undefined])
.sort((a, b) => a[0] > b[0] ? 1 : -1)
setImgList(emptyImgList)
const promiseFnsList = forecastList.map((grib): () => Promise<[string, ImageBitmap]> => async () => {
const canvas = document.createElement('canvas')
const [messages, buffers, bitmasks] = await fetchGribBinaries(grib, getGribList())
drawGrib(canvas, messages, buffers, bitmasks, cropBounds, contour, isInterpolated)
// just to be sure that draws
await new Promise(resolve => setTimeout(resolve))
const img = await createImageBitmap(canvas)
return [grib.time.forecastTime, img]
})
processPromisesInBatches(
promiseFnsList,
([forecastDate, img]) => {
const udpdatedImgList = [...getImgList()]
const idx = udpdatedImgList.findIndex(([d]) => forecastDate === d)
if (idx >= 0) udpdatedImgList[idx][1] = img
setImgList(udpdatedImgList)
},
).finally(() => {
setRefDate(refDateStr)
setIsLoading(false)
})
}
return <ul class={styles.dateList}>
{ dateList().map(([dateStr, count]) =>
<li>
<div onClick={() => onActiveDate(dateStr)}>
<b>{ dateStr }</b> ({ count })
</div>
<ul
class={styles.controls}
style={{ display: getActiveDate() === dateStr ? 'block' : 'none'}}
>
{ METEO_PARAMS.map(([paramName, param]) =>
<li onClick={() => fetchDrawImgList(dateStr, param)}>
{ paramName }
</li>
)}
</ul>
</li>)}
</ul>
}
+104
View File
@@ -0,0 +1,104 @@
import { Accessor, Component, createEffect, createSignal, onMount } from 'solid-js'
import styles from './harmonie.module.css'
import { downloadImagesAsZip } from '../../helpers/download'
export const SlideShow: Component<{
getIsLoading: Accessor<boolean>,
getCanvas: Accessor<HTMLCanvasElement | undefined>,
getImgList: Accessor<[string, ImageBitmap | undefined][]>,
getRefDate: Accessor<string>,
}> = ({
getIsLoading,
getCanvas,
getImgList,
getRefDate,
}) => {
const [getActive, setActive] = createSignal(-1)
const [getIsPlaying, setIsPlaying] = createSignal(false)
let canvas: HTMLCanvasElement
let ctx: CanvasRenderingContext2D
function clearCanvas() { ctx.clearRect(0, 0, canvas.width, canvas.height) }
onMount(() => {
canvas = getCanvas()!
ctx = canvas.getContext('2d')!
})
createEffect(() => getImgList() && clearCanvas())
function draw(i: number) {
clearCanvas()
setActive(i)
const img = getImgList()[i][1]
if (!img) return;
if (img.width !== canvas.width && img.height !== canvas.height) {
canvas.width = img.width
canvas.height = img.height
}
ctx.drawImage(img, 0, 0)
}
function next(delta = 1) {
const count = getImgList().length
if (!count) return;
const result = (getActive() + delta) % count
const nextValue = result >= 0 ? result : count - 1
setActive(nextValue)
draw(nextValue)
}
function prev() { next(-1) }
function areControlsVisible(): boolean {
return getImgList().length > 0 && !getIsLoading()
}
let playingTimeout = 0
function play() {
if (getIsPlaying()) {
clearTimeout(playingTimeout)
setIsPlaying(false)
return;
}
function loop() {
next()
playingTimeout = setTimeout(loop, 300)
}
setIsPlaying(true)
loop()
}
function download() {
const imgs = getImgList().filter(([,img]) => !!img) as [string, ImageBitmap][]
downloadImagesAsZip(imgs, getRefDate())
}
return <>
<div class={styles.slideShowControls} style={{visibility: areControlsVisible() ? 'visible' : 'hidden'}}>
<div class={styles.leftButtons}>
<input type='button' value='prev' onClick={prev} />
<input type='button' value={getIsPlaying()?'pause':'play'} onClick={play} />
<input type='button' value='next' onClick={() => next()} />
</div>
<input type='button' value='download .zip' onClick={download} />
</div>
<ul class={styles.slideShowList}>
{ getImgList().map(([forecastDate, img], i) =>
<li
class={`${img?styles.withImg:''} ${i===getActive()?styles.active:''}`}
onClick={() => draw(i)}
>
{ format(forecastDate) }
</li>)}
</ul>
</>
}
function format(date: string) {
return date.slice(11, 13) // 2025-02-23T1500Z -> 15
}
+34
View File
@@ -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
}
+31
View File
@@ -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))]
}
+53
View File
@@ -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] },
// ]
+253
View File
@@ -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
}
+135
View File
@@ -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]
}
+59
View File
@@ -0,0 +1,59 @@
import { apiHost } from '../../consts'
import { fetchBuffer, fetchJson } from '../../helpers/fetch'
import { getFakeHourPrecipitation, isPrecipitation } from './draw/precipitation'
import { getFakeWindDirection, isWindSpeed } from './draw/windDirection'
import { GribMessage } from './interfaces'
import { fetchWindData, isCalculatedWindDirection } from './draw/windDirection'
import { fetchHourPrecipitationData, isCalculatedHourPrecipitation } from './draw/precipitation'
export function fetchGribList(): Promise<string[]> {
return fetchJson(`${apiHost}/api/show/grib-list`)
.then((fileList: string[]) => {
return fileList.sort((a: string, b: string) => a < b ? 1 : -1)
})
}
export function fetchGribListStructure(): Promise<GribMessage[]> {
return fetchJson(`${apiHost}/api/show/grib-all-structure`)
.then((gribList: GribMessage[]) => {
return [
...gribList,
...gribList.filter(isWindSpeed).map(getFakeWindDirection),
...gribList.filter(isPrecipitation).map(getFakeHourPrecipitation),
].sort((a, b) => a.title > b.title ? 1 : -1)
})
}
export function fetchGribBinaries(
grib: GribMessage,
gribList: GribMessage[],
): Promise<[GribMessage[], Uint8Array[], Uint8Array[]]> {
const fileName = `harmonie_${grib.time.referenceTime}_${grib.time.forecastTime}.grib`
const bitmaskSection = grib.sections.find(section => section.id === 6)
const binarySection = grib.sections.find(section => section.id === 7)
if (!bitmaskSection || !binarySection) throw new Error('Grib does not have binary or bitmap section')
const bitmaskOffset = bitmaskSection.offset + 6
const bitmaskLength = bitmaskSection.size - 6
const bitmaskPromise = bitmaskSection.size > 6
? fetchBuffer(`${apiHost}/api/grib/binary-chunk/${bitmaskOffset}/${bitmaskLength}/${fileName}`).then(b=>[b])
: Promise.resolve([])
const binaryOffset = binarySection.offset + 5
const binaryLength = binarySection.size - 5
let fetchPromise: Promise<[GribMessage[], ArrayBuffer[], ArrayBuffer[]]> = Promise.all([
Promise.resolve([grib]),
fetchBuffer(`${apiHost}/api/grib/binary-chunk/${binaryOffset}/${binaryLength}/${fileName}`).then(b=>[b]),
bitmaskPromise,
])
if(isCalculatedWindDirection(grib)) fetchPromise = fetchWindData(grib, gribList)
if(isCalculatedHourPrecipitation(grib)) fetchPromise = fetchHourPrecipitationData(grib, gribList)
return fetchPromise
.then(([messages, buffers, bitmasks]) => [
messages,
buffers.map(b => new Uint8Array(b)),
bitmasks.map(b => new Uint8Array(b)),
])
}
+103
View File
@@ -0,0 +1,103 @@
.container {
display: flex;
min-height: 100vh;
}
.container .column {
flex: 1;
box-sizing: border-box;
text-align: left;
}
.container .column:nth-child(1) {
padding: 0;
flex-basis: 20%;
background-color: #f2f2f2;
min-width: 320px;
}
.container .column:nth-child(2) {
padding: 10px;
flex-basis: 80%;
background-color: #fff;
min-width: 1000px;
}
/* fileList */
.fileList {
padding: 0;
}
.fileList li {
padding: 5px 0;
}
.fileList > li:nth-child(odd) {
background-color: #ddd;
}
.fileList .active .name {
font-weight: bold;
}
.fileList .meteoParams {
display: none;
}
.fileList li.active .meteoParams {
display: block;
}
/* meteoParam */
.meteoParams li:hover {
text-decoration: underline;
}
.dateList {
padding-left: 0;
}
.dateList li {
color: #ddd;
background-color: #333;
padding: 5px 0 5px 10px;
}
.dateList li .controls {
padding: 10px 3px;
}
.slideShowControls {
display: flex;
justify-content: space-between;
}
.slideShowControls .leftButtons {
display: flex;
gap: 5px;
}
.slideShowList {
padding: 0;
}
.slideShowList li {
display: inline;
font-size: 14px;
padding: 3px 1px;
border-radius: 5px;
border: 2px solid transparent;
}
.withImg {
background-color: rgb(213, 248, 213);
}
.slideShowList li.active {
border: 2px solid green;
}
+56
View File
@@ -0,0 +1,56 @@
import { Accessor } from 'solid-js'
export type MeteoParam = {
discipline: number, // 0=meteo, 1=hydro, 2=land surface, 3=space products
category: number, // 0=temperature, 1=moisture, 6=cloud, 19=atmospheric
product: number,
subType: string, // now or over time avg/sum
levelType: number, // 102 - entire atmosphere, 103 - above ground, above sea level
levelValue: number, // meters above ground/sea level
}
export type MeteoConversion = {
reference: number, // float
binaryScale: number, // int
decimalScale: number, // int
}
export type MeteoGrid = {
cols: number,
rows: number,
template: number, // 0 - regular lat/lon
}
export type GribMessage = {
offset: number,
size: number,
version: number,
title: string,
meteo: MeteoParam,
grid: MeteoGrid,
time: GribTime,
bitsPerDataPoint: number,
subType: string,
conversion: MeteoConversion,
sections: GribSection[],
}
export type GribSection = {
offset: number,
size: number,
id: number,
}
export type GribTime = {
referenceTime: string,
forecastTime: string,
}
export type DrawOptions = {
getIsCrop: Accessor<boolean>,
getIsContour: Accessor<boolean>,
getIsInterpolated: Accessor<boolean>,
}
export const CROP_BOUNDS = { x: 1906-1-440, y: 895, width: 440, height: 380, angle: 26 }
export type CropBounds = typeof CROP_BOUNDS