Refactor, move all pages to separate folder
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import moment from "moment";
|
||||
import { createSignal } from "solid-js";
|
||||
|
||||
import { SelectCity } from "../../components/SelectCity";
|
||||
import { SelectTimeRange } from "../../components/SelectTimeRange";
|
||||
import { SelectField } from "../../components/SelectField";
|
||||
|
||||
import { SelectGranularity } from "./SelectGranularity";
|
||||
import { SelectKey } from "./SelectKey";
|
||||
import { QueryView } from "./result/QueryView";
|
||||
|
||||
const nowRounded = new Date(new Date().setMinutes(30));
|
||||
const dayAgo = moment(nowRounded).subtract(1, "days").subtract(30, "minutes").toDate();
|
||||
|
||||
export function Cities() {
|
||||
const [getCities, setCities] = createSignal<Set<string>>(new Set([]));
|
||||
const [getStart, setStart] = createSignal(dayAgo);
|
||||
const [getEnd, setEnd] = createSignal(nowRounded);
|
||||
const [getField, setField] = createSignal("tempMax");
|
||||
const [getKey, setKey] = createSignal("max");
|
||||
const [getGranularity, setGranularity] = createSignal("hour");
|
||||
|
||||
return (
|
||||
<div class="aggregator">
|
||||
<div class="container">
|
||||
<div class="column">
|
||||
<SelectCity getCities={getCities} setCities={setCities} />
|
||||
</div>
|
||||
<div class="column">
|
||||
<div>
|
||||
<SelectTimeRange
|
||||
getStart={getStart}
|
||||
setStart={setStart}
|
||||
getEnd={getEnd}
|
||||
setEnd={setEnd}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<h4>Select field</h4>
|
||||
<SelectField getField={getField} setField={setField} />
|
||||
</div>
|
||||
<SelectKey getKey={getKey} setKey={setKey} />
|
||||
<SelectGranularity getGranularity={getGranularity} setGranularity={setGranularity} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<QueryView
|
||||
getCities={getCities}
|
||||
getStart={getStart}
|
||||
getEnd={getEnd}
|
||||
getField={getField}
|
||||
getKey={getKey}
|
||||
getGranularity={getGranularity}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Accessor, Component, Setter } from "solid-js";
|
||||
|
||||
import { aggregateGranularity } from "../../consts";
|
||||
|
||||
export const SelectGranularity: Component<{
|
||||
getGranularity: Accessor<string>,
|
||||
setGranularity: Setter<string>,
|
||||
}> = ({ getGranularity, setGranularity }) => {
|
||||
return (
|
||||
<div>
|
||||
<h4>Select granularity</h4>
|
||||
<select onChange={(e) => setGranularity(e.target.value)}>
|
||||
{ aggregateGranularity.map(granularity =>
|
||||
<option
|
||||
value={granularity}
|
||||
selected={granularity === getGranularity() ? true : false}
|
||||
>{granularity}</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Accessor, Component, Setter } from "solid-js";
|
||||
|
||||
import { aggregateKey } from "../../consts";
|
||||
|
||||
export const SelectKey: Component<{
|
||||
getKey: Accessor<string>,
|
||||
setKey: Setter<string>,
|
||||
}> = (props) => {
|
||||
return (
|
||||
<div>
|
||||
<h4>Select key</h4>
|
||||
<ul>
|
||||
{ aggregateKey.map(key =>
|
||||
<label><li><input
|
||||
type="checkbox"
|
||||
name="aggregateKey"
|
||||
value={key}
|
||||
checked={key === props.getKey() ? true : false}
|
||||
onClick={() => props.setKey(key)}
|
||||
/>{key}</li></label>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Accessor, Component, Setter } from "solid-js";
|
||||
import { resultOrder, ResultOrderKeys } from "../../consts";
|
||||
|
||||
export const SelectOrder: Component<{
|
||||
getter: Accessor<ResultOrderKeys>,
|
||||
setter: Setter<ResultOrderKeys>,
|
||||
}> = ({ getter, setter }) => {
|
||||
return (
|
||||
<div>
|
||||
Order by:
|
||||
<select onChange={e => setter(e.target.value as ResultOrderKeys)}>
|
||||
{ Object.keys(resultOrder).map(orderKey =>
|
||||
<option
|
||||
value={orderKey}
|
||||
selected={orderKey === getter()}
|
||||
>
|
||||
{orderKey}
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import moment from "moment";
|
||||
|
||||
export function formatDateString(str: string): string {
|
||||
const date = moment(new Date(str));
|
||||
switch (str.length) {
|
||||
// year: 2023
|
||||
case 4: return date.format("yyyy");
|
||||
// year-month: 2023-06
|
||||
case 7: return date.format("yyyy-MM");
|
||||
// year-month-day: 2023-06-23
|
||||
case 10: return date.format("yyyy-MM-DD");
|
||||
// year-month-day hour:minute 2023-06-23T23:59
|
||||
default: return date.format("HH:mm");
|
||||
}
|
||||
}
|
||||
|
||||
export interface QueryResult {
|
||||
query: DataQuery;
|
||||
result: {[key: string]: any};
|
||||
}
|
||||
|
||||
export interface DataQuery {
|
||||
cities: string[];
|
||||
field: string;
|
||||
granularity: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export function isQueryResult(variable: any): variable is QueryResult {
|
||||
return variable
|
||||
&& variable.query
|
||||
&& isDataQuery(variable.query)
|
||||
&& variable.result
|
||||
&& (
|
||||
typeof variable.result === "object"
|
||||
&& !Array.isArray(variable.result)
|
||||
&& variable.result !== null
|
||||
);
|
||||
}
|
||||
|
||||
export function isDataQuery(variable: any): variable is DataQuery {
|
||||
return variable &&
|
||||
Array.isArray(variable.cities) &&
|
||||
typeof variable.field === 'string' &&
|
||||
typeof variable.granularity === 'string' &&
|
||||
typeof variable.key === 'string';
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Component, Match, Switch } from "solid-js";
|
||||
|
||||
import { CityChart } from "../../../components/chart/CityChart";
|
||||
import { DataQuery } from "../helpers";
|
||||
|
||||
export const GridResult: Component<{ city: string, query: DataQuery, result: any }> = ({ city, query, result }) => {
|
||||
return (
|
||||
<div class="item">
|
||||
<h4>{ city }</h4>
|
||||
<Switch fallback={<p>{ result }</p>}>
|
||||
<Match when={query.field === "phenomena"}>
|
||||
{ result.join(", ") }
|
||||
</Match>
|
||||
<Match when={isDateNumber(result)}>
|
||||
<CityChart city={()=>city} data={()=>result} query={()=>query}/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isDateNumber(value: unknown): value is [string, number | null][] {
|
||||
if (!Array.isArray(value)) return false;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
let item = value[i];
|
||||
if (
|
||||
!Array.isArray(item)
|
||||
|| item.length !== 2
|
||||
|| typeof item[0] !== "string"
|
||||
|| (typeof item[1] !== "number" && item[1] !== null)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import moment from 'moment'
|
||||
import { Accessor, Component, createResource, createSignal } from "solid-js";
|
||||
|
||||
import "../../../css/Result.css"
|
||||
import { FETCH_DELAY_MS, apiHost } from "../../../consts";
|
||||
import { isQueryResult } from "../helpers";
|
||||
import { LoadingSpinner } from "../../../components/spinner/LoadingSpinner";
|
||||
import { ResultView } from "./ResultView";
|
||||
|
||||
export const QueryView: Component<{
|
||||
getCities: Accessor<Set<string>>,
|
||||
getStart: Accessor<Date>,
|
||||
getEnd: Accessor<Date>,
|
||||
getField: Accessor<string>,
|
||||
getKey: Accessor<string>,
|
||||
getGranularity: Accessor<string>,
|
||||
}> = (props) => {
|
||||
const cities = () => [...props.getCities()].join(",");
|
||||
const queryStart = () => moment(props.getStart()).format("YYYYMMDD_HHmm");
|
||||
const queryEnd = () => moment(props.getEnd()).format("YYYYMMDD_HHmm");
|
||||
const [getTimestamp, setTimestamp] = createSignal<number>(0);
|
||||
|
||||
const fetchQuery = async (timestamp: number) => {
|
||||
if (cities() === "") return new Error("ERROR: Select cities!");
|
||||
await new Promise(resolve => setTimeout(resolve, FETCH_DELAY_MS))
|
||||
const response = await fetch(`${apiHost}/api/query/city/${cities()}/${queryStart()}-${queryEnd()}/${props.getGranularity()}/${props.getField()}/${props.getKey()}`);
|
||||
const json = await response.json();
|
||||
return json;
|
||||
}
|
||||
|
||||
const [queryResource] = createResource(getTimestamp, fetchQuery);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="button"
|
||||
class="primary"
|
||||
value="Query Data"
|
||||
onClick={() => setTimestamp(Date.now())}
|
||||
/>
|
||||
{ queryResource.loading && <LoadingSpinner text="Loading query" /> }
|
||||
{ queryResource.error && (
|
||||
<div>Error while querying: ${queryResource.error}</div>
|
||||
)}
|
||||
{ queryResource() && queryResource() instanceof Error &&
|
||||
<div>{ queryResource().message }</div>
|
||||
}
|
||||
{ queryResource() && isQueryResult( queryResource() ) &&
|
||||
<ResultView result={queryResource} />
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Component, createSignal, JSXElement, Resource, Show } from "solid-js";
|
||||
|
||||
import "../../../css/Result.css"
|
||||
import { ResultKeyVal, resultOrder, ResultOrderKeys } from "../../../consts";
|
||||
import { SelectOrder } from "../SelectOrder";
|
||||
import { QueryResult } from "../helpers";
|
||||
import { MapView } from "../../../components/map/MapView";
|
||||
import { GridResult } from "./GridResult";
|
||||
|
||||
type ResultView = "grid" | "list" | "map_1920x1080" | "map_1920x1080_wind" | "map_3840x1440" | "map_3840x1440_wind";
|
||||
const resultViews: Array<ResultView> = ["grid", "list", "map_1920x1080", "map_1920x1080_wind", "map_3840x1440", "map_3840x1440_wind"];
|
||||
|
||||
export const ResultView: Component<{
|
||||
result: Resource<QueryResult>
|
||||
}> = ({ result: resultResource }) => {
|
||||
const [getOrderKey, setOrderKey] = createSignal<ResultOrderKeys>("A -> Z");
|
||||
const [getResultView, setResultView] = createSignal<ResultView>("grid");
|
||||
|
||||
const cityData = () =>
|
||||
Object.entries(resultResource()!.result)
|
||||
.map(keyVal => [...keyVal] as ResultKeyVal)
|
||||
.sort((a, b) => resultOrder[getOrderKey()](a, b));
|
||||
|
||||
function showViewButton(viewName: string): boolean {
|
||||
if (!resultResource()) return false;
|
||||
if (["grid", "list"].includes(viewName) && resultResource()!.query.field === "phenomena") return true;
|
||||
if (["grid", "list"].includes(viewName) && resultResource()!.query.key !== "List") return true;
|
||||
if (!["grid", "list"].includes(viewName) && resultResource()!.query.key === "List") return false;
|
||||
if (resultResource()!.query.field === "phenomena") return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div class="resultTitle">
|
||||
<span>
|
||||
{resultViews.map(viewName =>
|
||||
<>
|
||||
<input
|
||||
type="button"
|
||||
class="secondary"
|
||||
value={viewName}
|
||||
disabled={!showViewButton(viewName)}
|
||||
onClick={() => setResultView(viewName)}
|
||||
/>
|
||||
|
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span style={{ visibility: ["grid", "list"].includes(getResultView()) ? "visible" : "hidden" }}>
|
||||
<SelectOrder getter={getOrderKey} setter={setOrderKey} />
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<Show when={getResultView() === "grid"}>
|
||||
<div class="grid-view">
|
||||
{cityData().map(([city, data]) =>
|
||||
<GridResult
|
||||
city={city}
|
||||
query={resultResource()!.query}
|
||||
result={data}
|
||||
/>)}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={getResultView() === "list"}>
|
||||
<div class="list-view">
|
||||
{cityData().map(([city, data]) =>
|
||||
<GridResult
|
||||
city={city}
|
||||
query={resultResource()!.query}
|
||||
result={data}
|
||||
/>)}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={getResultView() === "map_1920x1080"}>
|
||||
<MapView type="map_1920x1080" data={cityData} />
|
||||
</Show>
|
||||
<Show when={getResultView() === "map_1920x1080_wind"}>
|
||||
<MapView type="map_1920x1080_wind" data={cityData} />
|
||||
</Show>
|
||||
<Show when={getResultView() === "map_3840x1440"}>
|
||||
<MapView type="map_3840x1440" data={cityData} />
|
||||
</Show>
|
||||
<Show when={getResultView() === "map_3840x1440_wind"}>
|
||||
<MapView type="map_3840x1440_wind" data={cityData} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import moment from "moment";
|
||||
import { createSignal } from "solid-js";
|
||||
|
||||
import { QueryResult } from "./result/QueryResult";
|
||||
import { SelectTimeRange } from "../../components/SelectTimeRange";
|
||||
import { weatherFieldNumeric } from "../../consts";
|
||||
import { MultiSelectField } from "./MultiSelectField";
|
||||
|
||||
const nowRounded = new Date(new Date().setMinutes(30));
|
||||
const dayAgo = moment(nowRounded).subtract(1, "days").subtract(30, "minutes").toDate();
|
||||
|
||||
export function Country() {
|
||||
const [getStart, setStart] = createSignal(dayAgo);
|
||||
const [getEnd, setEnd] = createSignal(nowRounded);
|
||||
const [getFields, setFields] = createSignal(weatherFieldNumeric);
|
||||
|
||||
return (
|
||||
<div class="fileManager">
|
||||
<div class="container">
|
||||
<div class="column">
|
||||
<MultiSelectField
|
||||
getFields={getFields}
|
||||
setFields={setFields}
|
||||
/>
|
||||
</div>
|
||||
<div class="column">
|
||||
<SelectTimeRange
|
||||
getStart={getStart}
|
||||
setStart={setStart}
|
||||
getEnd={getEnd}
|
||||
setEnd={setEnd}
|
||||
/>
|
||||
</div>
|
||||
<div class="column">
|
||||
<QueryResult
|
||||
getFields={getFields}
|
||||
getStart={getStart}
|
||||
getEnd={getEnd}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Accessor, Component, Setter } from "solid-js";
|
||||
import { weatherFieldNumeric } from "../../consts";
|
||||
|
||||
export const MultiSelectField: Component<{
|
||||
getFields: Accessor<string[]>,
|
||||
setFields: Setter<string[]>,
|
||||
}> = ({ getFields, setFields }) => {
|
||||
|
||||
function handleSelect(fieldName: string) {
|
||||
const fields = [...getFields()];
|
||||
if (fields.includes(fieldName)) {
|
||||
const removeIndex = fields.indexOf(fieldName);
|
||||
fields.splice(removeIndex, 1);
|
||||
}
|
||||
else fields.push(fieldName);
|
||||
|
||||
setFields(fields);
|
||||
}
|
||||
|
||||
function selectAll(e: MouseEvent) {
|
||||
if (!e.target) return;
|
||||
const target = e.target as HTMLInputElement;
|
||||
const selectedFields = target.checked
|
||||
? [...weatherFieldNumeric]
|
||||
: [];
|
||||
|
||||
setFields(selectedFields);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
Select all <input
|
||||
type="checkbox"
|
||||
checked={true}
|
||||
onClick={selectAll}
|
||||
/>
|
||||
<ul>
|
||||
{weatherFieldNumeric.map(fieldName =>
|
||||
<li>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name={fieldName}
|
||||
checked={getFields().includes(fieldName)}
|
||||
onClick={() => handleSelect(fieldName)}
|
||||
/>
|
||||
{fieldName}
|
||||
</label>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import moment from "moment";
|
||||
import { Accessor, Component, createResource, createSignal } from "solid-js";
|
||||
|
||||
import { FETCH_DELAY_MS, apiHost } from "../../../consts";
|
||||
import { Result } from "./Result";
|
||||
import { LoadingSpinner } from "../../../components/spinner/LoadingSpinner";
|
||||
|
||||
|
||||
export const QueryResult: Component<{
|
||||
getStart: Accessor<Date>,
|
||||
getEnd: Accessor<Date>,
|
||||
getFields: Accessor<string[]>,
|
||||
}> = (props) => {
|
||||
const queryStart = () => moment(props.getStart()).format("YYYYMMDD_HHmm");
|
||||
const queryEnd = () => moment(props.getEnd()).format("YYYYMMDD_HHmm");
|
||||
const [getTimestamp, setTimestamp] = createSignal<number>(0);
|
||||
|
||||
const fetchQuery = async (timestamp: number) => {
|
||||
if (props.getFields().length === 0) return new Error("ERROR: Select weather parameters!");
|
||||
await new Promise(resolve => setTimeout(resolve, FETCH_DELAY_MS))
|
||||
const response = await fetch(`${apiHost}/api/query/country/${queryStart()}-${queryEnd()}/${props.getFields().join(",")}`);
|
||||
const json = await response.json();
|
||||
return json;
|
||||
}
|
||||
|
||||
const [queryResource] = createResource(getTimestamp, fetchQuery);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="button"
|
||||
class="primary"
|
||||
value="Query Data"
|
||||
onClick={() => setTimestamp(Date.now())}
|
||||
/>
|
||||
{ queryResource.loading && <LoadingSpinner text="Loading query" /> }
|
||||
{ queryResource.error && (
|
||||
<div>Error while querying: ${queryResource.error}</div>
|
||||
)}
|
||||
{ queryResource() && queryResource() instanceof Error &&
|
||||
<div>{ queryResource().message }</div>
|
||||
}
|
||||
{ queryResource() &&
|
||||
<Result result={queryResource} />
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Component, Resource } from "solid-js";
|
||||
|
||||
export interface ResultData {
|
||||
[field: string]: [number, number, number, number];
|
||||
}
|
||||
|
||||
export const Result: Component<{
|
||||
result: Resource<ResultData>
|
||||
}> = ({ result: resultResource }) => {
|
||||
const countryData = () => Object.entries(resultResource()!)
|
||||
.sort((a, b) => a[0] > b[0] ? 1 : -1)
|
||||
.map(filterOutSums)
|
||||
.map(([field, values]) => <tr>
|
||||
<td>{field}</td>
|
||||
<td>{values[0]}</td>
|
||||
<td>{values[1]}</td>
|
||||
<td>{values[2]}</td>
|
||||
<td>{values[3]}</td>
|
||||
</tr>)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<td>Param</td>
|
||||
<td>min</td>
|
||||
<td>max</td>
|
||||
<td>avg</td>
|
||||
<td>sum</td>
|
||||
</tr></thead>
|
||||
{ countryData() }
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CountryFieldValues = [number | undefined, number | undefined, number | undefined, number | undefined];
|
||||
|
||||
const sumFields = ["precipitation", "snowAvg", "sunDuration"];
|
||||
|
||||
function filterOutSums([field, values]: [string, CountryFieldValues]): [string, CountryFieldValues] {
|
||||
const modifiedValues: CountryFieldValues = [...values];
|
||||
if (!sumFields.includes(field)) modifiedValues[3] = undefined;
|
||||
|
||||
return [field, modifiedValues];
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import moment from "moment";
|
||||
import { Accessor, Component, Setter } from "solid-js";
|
||||
|
||||
import "../../css/calendar.css";
|
||||
|
||||
export const Calendar: Component<{
|
||||
getSelectedDate: Accessor<Date>,
|
||||
setSelectedDate: Setter<Date>,
|
||||
setCurrentMonth: Setter<Date>,
|
||||
datesWithData: () => Date[],
|
||||
}> = ({getSelectedDate, setSelectedDate, setCurrentMonth, datesWithData}) => {
|
||||
const dateStrArr = () => datesWithData().map(d => d.toDateString());
|
||||
|
||||
function changeMonths(delta: number): void {
|
||||
const newDate = moment(getSelectedDate()).add(delta, "months").toDate();
|
||||
setCurrentMonth(newDate);
|
||||
setSelectedDate(newDate);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>Calendar</h3>
|
||||
<div class="calendar-top">
|
||||
<button onClick={() => changeMonths(-1) }><<</button>
|
||||
<div class="calendar-year-month">
|
||||
{moment(getSelectedDate()).format("YYYY, MMM")}
|
||||
</div>
|
||||
<button onclick={() => changeMonths(1)}>>></button>
|
||||
</div>
|
||||
<div class="calendar">
|
||||
{ getPaddedMonth(getSelectedDate()).map(d => {
|
||||
const classArr = ["calendar-cell"];
|
||||
if (d.getMonth() !== getSelectedDate().getMonth()) classArr.push("prev-month");
|
||||
if (dateStrArr().includes(d.toDateString())) classArr.push("data-date");
|
||||
if (d.toDateString() === getSelectedDate().toDateString()) classArr.push("current-date");
|
||||
return (
|
||||
<div
|
||||
class={classArr.join(" ")}
|
||||
onClick={() => setSelectedDate(d)}
|
||||
>
|
||||
{d.getDate()}
|
||||
</div>
|
||||
)})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getPaddedMonth(date: Date): Date[] {
|
||||
const firstDayOfMonth = new Date(new Date(date).setDate(1));
|
||||
const datesArr = getMonthDates(firstDayOfMonth);
|
||||
|
||||
// padd start of the month
|
||||
const dateIterator = new Date(firstDayOfMonth);
|
||||
while (toEuropeanDay(dateIterator.getDay()) > 0) {
|
||||
dateIterator.setDate(dateIterator.getDate() - 1);
|
||||
datesArr.unshift(new Date(dateIterator));
|
||||
}
|
||||
|
||||
// padd end of the month
|
||||
dateIterator.setMonth(firstDayOfMonth.getMonth())
|
||||
dateIterator.setDate(datesArr[datesArr.length - 1].getDate())
|
||||
while (toEuropeanDay(dateIterator.getDay()) < 6) {
|
||||
dateIterator.setDate(dateIterator.getDate() + 1);
|
||||
datesArr.push(new Date(dateIterator));
|
||||
}
|
||||
|
||||
return datesArr;
|
||||
}
|
||||
|
||||
function getMonthDates(firstDayOfMonth: Date): Date[] {
|
||||
const date = new Date(firstDayOfMonth);
|
||||
const month = date.getMonth();
|
||||
const arr: Date[] = [];
|
||||
while (date.getMonth() === month) {
|
||||
arr.push(new Date(date));
|
||||
date.setDate(date.getDate() + 1);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
function toEuropeanDay(usaDay: number): number {
|
||||
return --usaDay === -1 ? 6 : usaDay;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import moment from "moment";
|
||||
import { createSignal } from "solid-js";
|
||||
|
||||
import "../../css/FileManager.css"
|
||||
import { DateList } from "./DateList";
|
||||
import { FetchFiles } from "./FetchFiles";
|
||||
import { FileContent } from "./FileContent";
|
||||
import { FileNameList } from "./FileNameList";
|
||||
|
||||
export function Database() {
|
||||
const [getDate, setDate] = createSignal(new Date());
|
||||
const [getFileName, setFileName] = createSignal("");
|
||||
|
||||
const stringDate = () => moment(getDate()).format("YYYYMMDD");
|
||||
|
||||
return (
|
||||
<div class="fileManager">
|
||||
<div class="container">
|
||||
<div class="column">
|
||||
<DateList getDate={getDate} setDate={setDate} />
|
||||
<FetchFiles />
|
||||
</div>
|
||||
<div class="column">
|
||||
<h3>{stringDate()}</h3>
|
||||
<FileNameList getDate={getDate} setFileName={setFileName} />
|
||||
</div>
|
||||
<div class="column">
|
||||
<h3>{getFileName()}</h3>
|
||||
<FileContent getFileName={getFileName} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import moment from "moment"
|
||||
import { Accessor, Component, createResource, createSignal, Setter } from "solid-js"
|
||||
|
||||
import { apiHost, FETCH_DELAY_MS } from "../../consts"
|
||||
import { Calendar } from "./Calendar"
|
||||
import { LoadingSpinner } from "../../components/spinner/LoadingSpinner"
|
||||
|
||||
export const DateList: Component<{getDate: Accessor<Date>, setDate: Setter<Date>;}> = (props) => {
|
||||
const [getCurrentMonth, setCurrentMonth] = createSignal(new Date());
|
||||
const fetchDates = async () => {
|
||||
const months = [
|
||||
moment(getCurrentMonth()).format("yyyyMM"),
|
||||
moment(getCurrentMonth()).subtract(1, "months").format("yyyyMM"),
|
||||
moment(getCurrentMonth()).add(1, "months").format("yyyyMM"),
|
||||
];
|
||||
await new Promise(resolve => setTimeout(resolve, FETCH_DELAY_MS))
|
||||
const response = await fetch(`${apiHost}/api/show/months/${months.join(",")}`);
|
||||
const json = await response.json();
|
||||
return json;
|
||||
}
|
||||
|
||||
const [datesResource] = createResource(getCurrentMonth, fetchDates);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{ datesResource.loading && <LoadingSpinner text="Loading calendar..." /> }
|
||||
{ datesResource.error && (
|
||||
<div>Error while loading dates: ${datesResource.error}</div>
|
||||
)}
|
||||
{ !datesResource.loading && datesResource() && <Calendar
|
||||
getSelectedDate={props.getDate}
|
||||
setSelectedDate={props.setDate}
|
||||
setCurrentMonth={setCurrentMonth}
|
||||
datesWithData={() => datesResource().map((str: string) => new Date(str))}
|
||||
/> }
|
||||
|
||||
{/* Backup date view */}
|
||||
{ datesResource() && (
|
||||
<ul>
|
||||
{/* { (datesResource() as any).map((date: any) =>
|
||||
<li onClick={() => clickDate(date)}>{date}</li>
|
||||
)} */}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import moment from "moment";
|
||||
import { createSignal } from "solid-js";
|
||||
import { apiHost } from "../../consts";
|
||||
|
||||
export function FetchFiles() {
|
||||
const [getStart, setStart] = createSignal(new Date());
|
||||
const [getEnd, setEnd] = createSignal(new Date());
|
||||
const [getFetchResult, setFetchResult] = createSignal("");
|
||||
const [isSpinner, setIsSpinner] = createSignal(false);
|
||||
|
||||
const startStr = () => moment(getStart()).format("YYYY-MM-DD");
|
||||
const endStr = () => moment(getEnd()).format("YYYY-MM-DD");
|
||||
|
||||
function handleSubmit(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
const dates = getDatesBetween(getStart(), getEnd());
|
||||
timerFetch(dates);
|
||||
}
|
||||
|
||||
async function timerFetch(dates: Date[]) {
|
||||
setIsSpinner(true);
|
||||
for (const date of dates) {
|
||||
const response = await fetch(`${apiHost}/api/fetch/date/${moment(date).format("YYYYMMDD")}`);
|
||||
const text = await response.text();
|
||||
setFetchResult(text);
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
setIsSpinner(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<form>
|
||||
<h3>Date range fetch</h3>
|
||||
<p>
|
||||
<input
|
||||
type="date"
|
||||
value={ startStr() }
|
||||
onChange={e => setStart(new Date(e.target.value))}
|
||||
/> start
|
||||
</p>
|
||||
<p>
|
||||
<input
|
||||
type="date"
|
||||
value={ endStr() }
|
||||
onChange={e => setEnd(new Date(e.target.value))}
|
||||
/> end
|
||||
</p>
|
||||
<p>
|
||||
<input type="submit" onClick={handleSubmit} value="Fetch .csv files" />
|
||||
</p>
|
||||
<p>result:</p>
|
||||
{ isSpinner() && <div>
|
||||
<span class="spinner"></span>
|
||||
<span style={{ "padding-left": "16px" }}>Fetching csv files</span>
|
||||
</div>
|
||||
}
|
||||
<p>{ getFetchResult() }</p>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function getDatesBetween(startDate: Date, endDate: Date): Date[] {
|
||||
const dates: Date[] = [];
|
||||
let currentDate = new Date(startDate); // start from the start date
|
||||
|
||||
while (currentDate <= endDate) {
|
||||
dates.push(new Date(currentDate)); // add current date to the list
|
||||
currentDate.setDate(currentDate.getDate() + 1); // increment the date
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Accessor, Component, createResource, JSXElement } from "solid-js";
|
||||
import { apiHost, FETCH_DELAY_MS } from "../../consts";
|
||||
import { PrettifyCSV } from "./PrettifyCSV";
|
||||
import { LoadingSpinner } from "../../components/spinner/LoadingSpinner";
|
||||
|
||||
export const FileContent: Component<{getFileName: Accessor<string>}> = (props) => {
|
||||
const fetchFileContent = async (fileName: string) => {
|
||||
if (fileName === "") return;
|
||||
await new Promise(resolve => setTimeout(resolve, FETCH_DELAY_MS))
|
||||
const response = await fetch(`${apiHost}/api/show/datetime/${fileName}`);
|
||||
const text = await response.json();
|
||||
return text;
|
||||
}
|
||||
|
||||
const [contentResource] = createResource(props.getFileName, fetchFileContent);
|
||||
const lines = () => contentResource();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{ contentResource.loading && <LoadingSpinner text="Loading date-time weather" /> }
|
||||
{ contentResource.error && (
|
||||
<div>Error while loading file content: ${contentResource.error}</div>
|
||||
)}
|
||||
{ contentResource() && (
|
||||
<PrettifyCSV lines={lines} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import moment from "moment"
|
||||
import { Accessor, Component, createResource, Setter } from "solid-js"
|
||||
import { apiHost, FETCH_DELAY_MS } from "../../consts"
|
||||
import { LoadingSpinner } from "../../components/spinner/LoadingSpinner"
|
||||
|
||||
export const FileNameList: Component<{
|
||||
getDate: Accessor<Date>,
|
||||
setFileName: Setter<string>,
|
||||
}> = (props) => {
|
||||
const fetchFileNames = async (fetchDate: Date) => {
|
||||
await new Promise(resolve => setTimeout(resolve, FETCH_DELAY_MS))
|
||||
const response = await fetch(`${apiHost}/api/show/date/${moment(fetchDate).format("YYYYMMDD")}`);
|
||||
const json = await response.json();
|
||||
return json;
|
||||
}
|
||||
|
||||
const [fileNameResource] = createResource(props.getDate, fetchFileNames);
|
||||
|
||||
function clickFileName(fileName: string) {
|
||||
props.setFileName(fileName);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{ fileNameResource.loading && <LoadingSpinner text={"Loading date: "+moment(props.getDate()).format("YYYYMMDD")} /> }
|
||||
{ fileNameResource.error && (
|
||||
<div>Error while loading file names: ${fileNameResource.error}</div>
|
||||
)}
|
||||
{ fileNameResource() && (
|
||||
<ul>
|
||||
{ (fileNameResource() as any).map((fileName: any) =>
|
||||
<li onClick={() => clickFileName(fileName)}>{fileName}</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
return <div>yoyoyoy</div>
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Component } from "solid-js";
|
||||
|
||||
export const PrettifyCSV: Component<{lines: () => string[];}> = ({ lines }) => {
|
||||
const header = () => lines().slice(0, 1);
|
||||
const content = () => lines().slice(1) ?? [];
|
||||
return (
|
||||
<div>
|
||||
<div>{header()}</div>
|
||||
<table>
|
||||
{content().map(line => <PrettifyLine line={line}/>)}
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PrettifyLine: Component<{line: string;}> = ({ line }) => {
|
||||
const splitLine = line.split(";");
|
||||
const params = splitLine.slice(0, 15);
|
||||
const phenomena = splitLine.slice(15);
|
||||
return (
|
||||
<tr>
|
||||
{ params.map(param => <td>{param}</td>)}
|
||||
<td>{ phenomena.join(",") }</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -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' }} />
|
||||
</>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
<label>
|
||||
Contour
|
||||
<input type='checkbox' checked={getIsContour()} onChange={()=>setIsContour(!getIsContour())} />
|
||||
</label>
|
||||
|
||||
<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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)),
|
||||
])
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Component, createSignal, onMount } from 'solid-js'
|
||||
import { apiHost } from '../../consts'
|
||||
import { fetchText } from '../../helpers/fetch'
|
||||
import { codeToIcon } from '../../components/weatherIcons/iconConsts'
|
||||
import { cityCoords } from '../../components/cityCoords'
|
||||
import { CityData, drawOnMap } from '../../components/map/canvasDraw'
|
||||
import { citiesToShow, csvCityListInOrder } from './consts'
|
||||
|
||||
import arrowUrl from '../../assets/arrow.webp'
|
||||
import { resolutionProps, windProps, WindProps } from '../../components/map/mapConsts'
|
||||
|
||||
export const CsvMapData: Component<{
|
||||
csvUrl: string,
|
||||
bgImgUrl: string,
|
||||
}> = ({
|
||||
csvUrl,
|
||||
bgImgUrl,
|
||||
}) => {
|
||||
const [getCityData, setCityData] = createSignal<CityData[]>([])
|
||||
const [getWindData, setWindData] = createSignal<[string, string, string]>(['', '', ''])
|
||||
|
||||
fetchText(`${apiHost}/api/show/lvgmc-forecast/${csvUrl}`)
|
||||
.then(csv => {
|
||||
const csvLines = csv.split('\n')
|
||||
const dayOrNight = csvLines[2].split(';')[1]
|
||||
console.log(csvLines)
|
||||
const citiesOffset = 4
|
||||
const cityData: CityData[] = csvCityListInOrder
|
||||
.map((city, i): CityData => {
|
||||
const parts = csvLines[i + citiesOffset].split(';')
|
||||
const temperature = Number(parts[1])
|
||||
const iconCode = Number(parts[2])
|
||||
const icon = codeToIcon(iconCode)
|
||||
const coord = cityCoords[city]
|
||||
if (!coord) throw new Error(`No coord found for city: ${city}`)
|
||||
return [city, coord, temperature, icon]
|
||||
})
|
||||
.filter(([city]) => citiesToShow.includes(city))
|
||||
|
||||
const windOffset = dayOrNight === 'DIENA' ? 30 : 26
|
||||
const windParts = csvLines[windOffset].split(';')
|
||||
const windSpped = windParts[1]
|
||||
const windGusts = windParts[3]
|
||||
const windDirection = windParts[5]
|
||||
setWindData([windDirection, windSpped, windGusts])
|
||||
|
||||
setCityData(cityData)
|
||||
draw()
|
||||
})
|
||||
|
||||
const arrowImg = new Image()
|
||||
arrowImg.src = arrowUrl
|
||||
|
||||
let canvas: HTMLCanvasElement
|
||||
let ctx: CanvasRenderingContext2D
|
||||
onMount(() => {
|
||||
ctx = canvas!.getContext('2d')!
|
||||
})
|
||||
|
||||
function draw(){
|
||||
const img = new Image()
|
||||
const tmpWindData: [string, string, string, boolean, WindProps] = [...getWindData(), true, windProps.map_3840x1440_wind]
|
||||
img.onload = () => {
|
||||
drawOnMap(
|
||||
ctx,
|
||||
[img, arrowImg],
|
||||
getCityData(),
|
||||
tmpWindData,
|
||||
resolutionProps.map_3840x1440_wind,
|
||||
)
|
||||
}
|
||||
img.src = bgImgUrl
|
||||
}
|
||||
|
||||
return <>
|
||||
<canvas
|
||||
ref={c =>canvas=c}
|
||||
width={'3840px'}
|
||||
height={'1440px'}
|
||||
style={{ 'width': '1000px', 'height': '374px' }}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Component } from 'solid-js'
|
||||
|
||||
import dayBgMap from '../../assets/map_3840x1440_wind.webp'
|
||||
import nightBgMap from '../../assets/map_night_3840x1440_wind.webp'
|
||||
import { CsvMapData } from './CsvMapData'
|
||||
|
||||
// Eiropa_LTV_pilsetas_nakama_dn.csv
|
||||
// Eiropa_LTV_pilsetas_tekosa_dn.csv
|
||||
// Latvija_LTV_pilsetas_nakama_dnn.csv
|
||||
// Latvija_LTV_pilsetas_tekosa_dn.csv
|
||||
// Latvija_faktiskais_laiks.csv
|
||||
|
||||
export const LvgmcForecast: Component<{}> = () => {
|
||||
return <>
|
||||
<p>Night</p>
|
||||
<CsvMapData
|
||||
csvUrl='Latvija_LTV_pilsetas_nakama_dnn.csv'
|
||||
bgImgUrl={nightBgMap}
|
||||
/>
|
||||
|
||||
<p>Day</p>
|
||||
<CsvMapData
|
||||
csvUrl='Latvija_LTV_pilsetas_tekosa_dn.csv'
|
||||
bgImgUrl={dayBgMap}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const csvCityListInOrder = ['Alūksne', 'Bauska', 'Cēsis', 'Daugavpils', 'Jelgava', 'Rīga', 'Liepāja', 'Madona', 'Rēzekne', 'Saldus', 'Valmiera', 'Ventspils', 'Ainaži', 'Dobele', 'Gulbene', 'Sigulda', 'Talsi', 'Jēkabpils']
|
||||
export const citiesToShow = ['Liepāja', 'Ventspils', 'Saldus', 'Talsi', 'Bauska', 'Rīga', 'Ainaži', 'Valmiera', 'Alūksne', 'Madona', 'Rēzekne', 'Jēkabpils', 'Daugavpils']
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Accessor, Component, Setter, createEffect, createResource, createSignal, onMount } from "solid-js";
|
||||
|
||||
import { coordToCity } from "./coordToCity";
|
||||
import mapUrl from "../../assets/map_1000x570.webp";
|
||||
import moment from "moment";
|
||||
import { FETCH_DELAY_MS, apiHost, cityList } from "../../consts";
|
||||
import { LoadingSpinner } from "../../components/spinner/LoadingSpinner";
|
||||
import { cityCoords } from "../../components/cityCoords";
|
||||
|
||||
export const MapResult: Component<{
|
||||
setCity: Setter<string | undefined>,
|
||||
getCities: Accessor<Set<string>>,
|
||||
getField: Accessor<string>,
|
||||
getStart: Accessor<Date>,
|
||||
getEnd: Accessor<Date>,
|
||||
}> = (props) => {
|
||||
const [getCanvas, setCanvas] = createSignal<HTMLCanvasElement>();
|
||||
const [getImg, setImg] = createSignal(new Image());
|
||||
|
||||
let ctx: CanvasRenderingContext2D | undefined;
|
||||
|
||||
onMount(() => {
|
||||
const canvas = getCanvas()!;
|
||||
canvas.addEventListener("click", event => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
|
||||
const x = Math.round((event.clientX - rect.left) * scaleX);
|
||||
const y = Math.round((event.clientY - rect.top) * scaleY);
|
||||
|
||||
const selectedCityCoords = Object.entries(cityCoords).filter(([city]) => props.getCities().has(city));
|
||||
const optionalCity = coordToCity(x, y, selectedCityCoords);
|
||||
props.setCity(optionalCity);
|
||||
});
|
||||
|
||||
ctx = canvas.getContext("2d")!;
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => setImg(img);
|
||||
img.src = mapUrl;
|
||||
});
|
||||
|
||||
const fieldDate = (): [string, Date, Date] => [
|
||||
props.getField(),
|
||||
props.getStart(),
|
||||
props.getEnd(),
|
||||
];
|
||||
|
||||
const [meteoValues] = createResource(fieldDate, async ([field, start, end]: [string, Date, Date]) => {
|
||||
await new Promise(resolve => setTimeout(resolve, FETCH_DELAY_MS));
|
||||
|
||||
const queryStart = moment(start).format("YYYYMMDD_HHmm");
|
||||
const queryEnd = moment(end).format("YYYYMMDD_HHmm");
|
||||
let aggregate: string = "avg";
|
||||
if (["tempMax", "windMax"].includes(field)) aggregate = "max";
|
||||
if (["tempMin", "visibilityMin"].includes(field)) aggregate = "min";
|
||||
if (["precipitation", "sunDuration"].includes(field)) aggregate = "sum";
|
||||
const response = await fetch(`${apiHost}/api/query/city/${[...cityList].join(",")}/${queryStart}-${queryEnd}/hour/${field}/${aggregate}`);
|
||||
const json = await response.json();
|
||||
return json.result;
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!ctx || !getImg() || !meteoValues()) return;
|
||||
const values = meteoValues();
|
||||
if (!isValidResult(values)) return;
|
||||
|
||||
const cityValues: [string, number | undefined][] = [...props.getCities()].map(city => [city, values[city]]);
|
||||
|
||||
drawOnMap(
|
||||
ctx!,
|
||||
[getImg()],
|
||||
cityValues,
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{ meteoValues.loading && <LoadingSpinner text="Drawing map..." /> }
|
||||
<canvas
|
||||
ref={setCanvas}
|
||||
style={{ display: meteoValues.loading ? "none" : "block" }}
|
||||
width={1000}
|
||||
height={570}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function drawOnMap(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
imgArr: [HTMLImageElement],
|
||||
cityValues: [string, number | undefined][],
|
||||
): void {
|
||||
ctx.drawImage(imgArr[0], 0, 0);
|
||||
ctx.fillStyle = "white";
|
||||
ctx.font = "18px Arial";
|
||||
ctx.strokeStyle = "black";
|
||||
ctx.lineWidth = 4;
|
||||
cityValues.forEach(([city, optionalValue]) => {
|
||||
const coord = cityCoords[city];
|
||||
if (!coord) return;
|
||||
|
||||
const value = optionalValue === undefined ? "" : optionalValue + "";
|
||||
|
||||
ctx.beginPath();
|
||||
// ctx.arc(coord.x, coord.y, 4, 0, 2 * Math.PI);
|
||||
const cityTextSize = ctx.measureText(city);
|
||||
ctx.strokeText(city, coord.x - cityTextSize.width/2, coord.y - 4);
|
||||
ctx.fillText(city, coord.x - cityTextSize.width/2, coord.y - 4);
|
||||
const valueTextSize = ctx.measureText(value);
|
||||
ctx.strokeText(value, coord.x - valueTextSize.width/2, coord.y + 14);
|
||||
ctx.fillText(value, coord.x - valueTextSize.width/2, coord.y + 14);
|
||||
ctx.fill();
|
||||
// const boxWidth = 60;
|
||||
// const boxHeight = 30;
|
||||
// ctx.strokeRect(coord.x-boxWidth/2, coord.y-boxHeight/2, boxWidth, boxHeight);
|
||||
});
|
||||
}
|
||||
|
||||
function isValidResult(obj: any): obj is { [key: string]: number } {
|
||||
if (typeof obj !== "object" || obj === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const key in obj) {
|
||||
if (typeof key !== "string") return false;
|
||||
if (typeof obj[key] !== "number") return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Accessor, Component, createResource } from "solid-js";
|
||||
import { FETCH_DELAY_MS, apiHost } from "../../consts";
|
||||
import moment from "moment";
|
||||
import { CityChart } from "../../components/chart/CityChart";
|
||||
import { LoadingSpinner } from '../../components/spinner/LoadingSpinner'
|
||||
|
||||
export const Result: Component<{
|
||||
getCity: Accessor<string | undefined>,
|
||||
getField: Accessor<string>,
|
||||
getStart: Accessor<Date>,
|
||||
getEnd: Accessor<Date>,
|
||||
}> = (props) => {
|
||||
const queryStart = () => moment(props.getStart()).format("YYYYMMDD_HHmm");
|
||||
const queryEnd = () => moment(props.getEnd()).format("YYYYMMDD_HHmm");
|
||||
|
||||
const fetchList = async ([city, field, getStart, getEnd]: [string | undefined, string, Date, Date]) => {
|
||||
if (city === undefined) return undefined;
|
||||
await new Promise(resolve => setTimeout(resolve, FETCH_DELAY_MS));
|
||||
const response = await fetch(`${apiHost}/api/query/city/${city}/${queryStart()}-${queryEnd()}/hour/${field}/list`);
|
||||
const json = await response.json();
|
||||
return json;
|
||||
}
|
||||
|
||||
const fetchMeteo = async ([city, getStart, getEnd]: [string | undefined, Date, Date]) => {
|
||||
if (city === undefined) return undefined;
|
||||
await new Promise(resolve => setTimeout(resolve, FETCH_DELAY_MS));
|
||||
const response = await fetch(`${apiHost}/api/query/city/${city}/${queryStart()}-${queryEnd()}/allFields`);
|
||||
const json = await response.json();
|
||||
return json;
|
||||
}
|
||||
|
||||
const cityFieldDate = (): [string | undefined, string, Date, Date] => [
|
||||
props.getCity(),
|
||||
props.getField(),
|
||||
props.getStart(),
|
||||
props.getEnd(),
|
||||
];
|
||||
const cityDate = (): [string | undefined, Date, Date] => [
|
||||
props.getCity(),
|
||||
props.getStart(),
|
||||
props.getEnd(),
|
||||
]
|
||||
const [listResource] = createResource(cityFieldDate, fetchList);
|
||||
const [meteoResource] = createResource(cityDate, fetchMeteo);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{
|
||||
props.getCity() === undefined && <div>Select city!</div>
|
||||
}
|
||||
|
||||
{ /* City weather field as chart */}
|
||||
{ listResource.loading && <LoadingSpinner text="Loading chart" /> }
|
||||
{ listResource.error && (
|
||||
<div>Error while querying: ${listResource.error}</div>
|
||||
)}
|
||||
{ listResource() && listResource() instanceof Error &&
|
||||
<div>{ listResource().message }</div>
|
||||
}
|
||||
{ listResource() && props.getCity() && !listResource.loading &&
|
||||
<div>
|
||||
<h3>{ props.getCity() }: { props.getField() }</h3>
|
||||
<CityChart
|
||||
city={() => listResource().query.cities[0]}
|
||||
data={() => listResource().result[listResource().query.cities[0]] ?? []}
|
||||
query={() => listResource().query}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
{ /* City all weather fields with double values */}
|
||||
{ meteoResource.loading && <LoadingSpinner text="Loading meteo data" /> }
|
||||
{ meteoResource.error && (
|
||||
<div>Error while querying: ${meteoResource.error}</div>
|
||||
)}
|
||||
{ meteoResource() && meteoResource() instanceof Error &&
|
||||
<div>{ meteoResource().message }</div>
|
||||
}
|
||||
{ meteoResource() && props.getCity() && !meteoResource.loading &&
|
||||
<ul class="stationResult">
|
||||
{ Object.entries(meteoResource())
|
||||
.sort((a, b) => a[0] > b[0] ? 1 : -1)
|
||||
.map(([key, value]: any) =>
|
||||
<li>{key}: {toStringFloat(value)}</li>
|
||||
)}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toStringFloat(val: any): string {
|
||||
const numVal = parseFloat(val);
|
||||
if (isNaN(numVal)) return "";
|
||||
|
||||
return (Math.round(numVal * 10) / 10) + "";
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Component, batch, createSignal } from "solid-js";
|
||||
import moment from "moment";
|
||||
|
||||
import { SelectCity } from "../../components/SelectCity";
|
||||
import { SelectField } from "../../components/SelectField";
|
||||
import { Result } from "./Result";
|
||||
import { MapResult } from "./MapResult";
|
||||
import "../../css/station.css"
|
||||
import { Spacer } from "../../components/Spacer";
|
||||
|
||||
export const Station: Component<{}> = () => {
|
||||
const [getShowCities, setShowCities] = createSignal(false);
|
||||
const [getCities, setCities] = createSignal<Set<string>>(new Set(["Ainaži", "Rīga", "Rēzekne", "Liepāja", "Daugavpils", "Ventspils", "Madona"]));
|
||||
const [getField, setField] = createSignal("tempMax");
|
||||
const [getCity, setCity] = createSignal<string | undefined>("Rīga");
|
||||
const [getStart, setStart] = createSignal(moment().subtract(1, "days").toDate());
|
||||
const [getEnd, setEnd] = createSignal(moment().toDate());
|
||||
|
||||
function handleDateChange(value: string) {
|
||||
const today = moment();
|
||||
const inputDate = moment(value, 'YYYY-MM-DD');
|
||||
if (today.isSame(inputDate, "date")) {
|
||||
batch(() => {
|
||||
setStart(today.clone().subtract(1, "days").toDate());
|
||||
setEnd(today.toDate());
|
||||
});
|
||||
} else {
|
||||
batch(() => {
|
||||
setStart(inputDate.set({ hour: 0, minute: 0, second: 0 }).toDate());
|
||||
setEnd(inputDate.set({ hour: 23, minute: 59, second: 59 }).toDate());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="stationWrapper">
|
||||
<div class="container">
|
||||
<div class="column">
|
||||
<div class="submenu">
|
||||
<input
|
||||
type="button"
|
||||
value="Select stations"
|
||||
onClick={() => setShowCities(!getShowCities())}
|
||||
/>
|
||||
<Spacer />
|
||||
<input
|
||||
type="date"
|
||||
value={moment(getEnd()).format("YYYY-MM-DD")}
|
||||
onChange={e => handleDateChange(e.target.value)}
|
||||
/>
|
||||
<Spacer />
|
||||
<SelectField getField={getField} setField={setField} />
|
||||
</div>
|
||||
<div class={"cities " + (getShowCities() ? "visible" : "hidden")}>
|
||||
<SelectCity getCities={getCities} setCities={setCities} />
|
||||
</div>
|
||||
<MapResult
|
||||
setCity={setCity}
|
||||
getCities={getCities}
|
||||
getField={getField}
|
||||
getStart={getStart}
|
||||
getEnd={getEnd}
|
||||
/>
|
||||
</div>
|
||||
<div class="column">
|
||||
<Result
|
||||
getCity={getCity}
|
||||
getField={getField}
|
||||
getStart={getStart}
|
||||
getEnd={getEnd}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export function coordToCity(
|
||||
x: number,
|
||||
y: number,
|
||||
cityCoords: [string, { x: number, y: number}][],
|
||||
boxWidth = 60,
|
||||
boxHeight = 30,
|
||||
): string | undefined {
|
||||
const optionalCity = cityCoords.find(([city, coord]) =>
|
||||
coord.x >= x-boxWidth/2
|
||||
&& coord.x <= x+boxWidth/2
|
||||
&& coord.y >= y-boxHeight/2
|
||||
&& coord.y <= y+boxHeight/2
|
||||
);
|
||||
|
||||
return optionalCity && optionalCity[0];
|
||||
}
|
||||
Reference in New Issue
Block a user