Query and display weather data for all country

This commit is contained in:
Guntis Smaukstelis
2023-12-24 23:54:16 +02:00
parent 7305718c34
commit a48b9df91f
35 changed files with 193 additions and 63 deletions
+57
View File
@@ -0,0 +1,57 @@
import moment from "moment";
import { createSignal } from "solid-js";
import { QueryResult } from "./result/QueryResult";
import { SelectCity } from "./SelectCity";
import { SelectField } from "./SelectField";
import { SelectGranularity } from "./SelectGranularity";
import { SelectKey } from "./SelectKey";
import { SelectTimeRange } from "../components/SelectTimeRange";
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">
<h2>Cities</h2>
<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>
<SelectField getField={getField} setField={setField} />
<SelectKey getKey={getKey} setKey={setKey} />
<SelectGranularity getGranularity={getGranularity} setGranularity={setGranularity} />
</div>
</div>
<div class="column">
<QueryResult
getCities={getCities}
getStart={getStart}
getEnd={getEnd}
getField={getField}
getKey={getKey}
getGranularity={getGranularity}
/>
</div>
</div>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { Accessor, Component, createEffect, Setter } from "solid-js";
import { cityList } from "../consts";
export const SelectCity: Component<{
getCities: Accessor<Set<string>>,
setCities: Setter<Set<string>>,
}> = ({ getCities, setCities }) => {
function handleSelect(e: MouseEvent) {
if (!e.target) return;
const target = e.target as HTMLInputElement;
const { checked: selected, value: city } = target;
const selectedCities = new Set([...getCities()]);
if (selected) selectedCities.add(city);
else selectedCities.delete(city);
setCities(selectedCities);
}
function selectAll(e: MouseEvent) {
if (!e.target) return;
const target = e.target as HTMLInputElement;
const selectedCities = target.checked
? new Set([...cityList])
: new Set([]);
setCities(selectedCities);
}
return (
<div>
Select all <input
type="checkbox"
onClick={selectAll}
/>
<ul>{cityList.map(city =>
<label>
<li>
<input
type="checkbox"
name="city"
value={city}
checked={getCities().has(city)}
onClick={handleSelect}
/>
{city}
</li>
</label>
)}
</ul>
</div>
);
};
+22
View File
@@ -0,0 +1,22 @@
import { Accessor, Component, Setter } from "solid-js";
import { weatherField } from "../consts";
export const SelectField: Component<{
getField: Accessor<string>,
setField: Setter<string>,
}> = (props) => {
return (
<div>
<h4>Select field</h4>
<select onChange={(e) => props.setField(e.target.value)}>
{ weatherField.map(field =>
<option
value={field}
selected={field === props.getField() ? true : false}
>{field}</option>
)}
</select>
</div>
);
}
+22
View File
@@ -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>
);
}
+25
View File
@@ -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>
);
}
+23
View File
@@ -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>
);
};
+55
View File
@@ -0,0 +1,55 @@
import { onMount, onCleanup, createSignal, Component } from "solid-js";
import { Chart } from "chart.js";
import { DataQuery, formatDateString } from "../helpers";
import { CityLargeChart } from "./CityLargeChart";
import { createCustomChart } from "./CustomChart";
export const CityChart: Component<{
city: string;
data: [string, number | null][];
query: DataQuery;
}> = (props) => {
const [getCanvas, setCanvas] = createSignal<HTMLCanvasElement>();
const [getIsLarge, setIsLarge] = createSignal(false);
let chart: Chart | undefined;
const data: [string, number | null][] = props.data.map(([dateStr, value]) => [
formatDateString(dateStr),
value,
]);
// Split the data into two arrays for the x and y axis
const timestamps = data.map(item => item[0]);
const values = data.map(item => item[1]);
onMount(() => {
const ctx = getCanvas()!.getContext("2d")!;
chart = createCustomChart(
ctx,
false,
timestamps,
values,
props.city,
props.query.field,
props.query.granularity,
);
});
function handleCanvasClick() {
setIsLarge(!getIsLarge());
}
onCleanup(() => {
chart?.destroy();
});
return (
<div>
<canvas ref={setCanvas} onClick={handleCanvasClick} />
<div style={{ display: getIsLarge() ? "block" : "none" }}>
<CityLargeChart city={props.city} data={props.data} query={props.query} close={() => setIsLarge(false)} />
</div>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { onMount, createSignal, Component } from "solid-js";
import { Chart } from "chart.js";
import { DataQuery, formatDateString } from "../helpers";
import "../../css/overlay.css"
import { createCustomChart } from "./CustomChart";
export const CityLargeChart: Component<{
city: string;
data: [string, number | null][];
query: DataQuery;
close: () => void
}> = (props) => {
const [getCanvas, setCanvas] = createSignal<HTMLCanvasElement>();
let chart: Chart;
const data: [string, number | null][] = props.data.map(([dateStr, value]) => [
formatDateString(dateStr),
value,
]);
// Split the data into two arrays for the x and y axis
const timestamps = data.map(item => item[0]);
const values = data.map(item => item[1]);
onMount(() => {
const ctx = getCanvas()!.getContext('2d')!;
chart = createCustomChart(
ctx,
true,
timestamps,
values,
props.city,
props.query.field,
props.query.granularity,
);
});
return (
<div class="overlay" onClick={props.close}>
<div class="overlay-content">
<canvas ref={setCanvas} />
</div>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { Component } from "solid-js";
import { CityChart } from "./CityChart";
import { DataQuery } from "../helpers";
export const CityResult: Component<{ city: string, query: DataQuery, result: any }> = ({ city, query, result }) => {
return (
<div class="result-item">
<h4>{ city }</h4>
{
isDateNumber(result)
? <CityChart city={city} data={result} query={query}/>
: result
}
</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;
}
+98
View File
@@ -0,0 +1,98 @@
import {
BarElement,
BarController,
CategoryScale,
Chart,
LinearScale,
LineController,
PointElement,
LineElement,
Title,
} from "chart.js";
// Register chart elements which will be used
Chart.register(
BarElement,
BarController,
LineController,
LinearScale,
PointElement,
LineElement,
Title,
CategoryScale,
);
const barChartData = ["precipitation", "sunDuration", "snowAvg"];
export function createCustomChart(
ctx: CanvasRenderingContext2D,
showBig: boolean,
timestamps: string[],
values: Array<number | null>,
city: string,
field: string,
granularity: string,
): Chart {
const dataConfig = {
labels: timestamps,
datasets: [{
label: field,
data: values,
fill: false,
borderColor: "rgb(75, 192, 192)",
backgroundColor: "rgb(75, 192, 192)",
tension: 0.1,
}]
};
const optionsConfig = {
plugins: {
title: {
display: showBig,
text: city,
},
},
scales: {
x: {
beginAtZero: true,
title: {
display: showBig,
text: granularity,
},
},
y: {
beginAtZero: true,
title: {
display: showBig,
text: field,
}
}
},
};
if (!barChartData.includes(field)) {
dataConfig.datasets[0].backgroundColor = "#FFFFFF";
}
return new Chart(ctx, {
type: barChartData.includes(field) ? "bar" : "line",
data: dataConfig,
options: {
...optionsConfig,
animation: false,
spanGaps: false,
},
plugins: [ canvas_bg_plugin ],
});
}
const canvas_bg_plugin = {
id: "canvas_bg_plugin",
beforeDraw: (chart: any, args: any, options: any) => {
const { ctx } = chart;
ctx.save();
ctx.fillStyle = "#FFFFFF";
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.restore();
},
};
+47
View File
@@ -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';
}
+130
View File
@@ -0,0 +1,130 @@
import { Component, createEffect, createSignal, onMount } from "solid-js";
import { ResultKeyVal } from "../../consts";
import { cityCoords } from "./cityCoords";
import { drawRotatedImage, getAngleFromString } from "./windAngles";
import { WindInputs, WindSignals } from "./WindInputs";
import mapUrl from "../../assets/map_1920x1080.webp";
import arrowUrl from "../../assets/arrow.webp";
export const MapView: Component<{ data: () => ResultKeyVal[] }> = ({ data }) => {
const [getCanvas, setCanvas] = createSignal<HTMLCanvasElement>();
const [getImg, setImg] = createSignal(new Image());
let lastCoords: [string, {x: number, y: number }, number][] = [];
const arrowImg = new Image();
arrowImg.src = arrowUrl;
const windSignals: WindSignals = {
direction: createSignal(""),
speed: createSignal(""),
gusts: createSignal(""),
roundValues: createSignal(false),
}
function getWindData(): [string, string, string, boolean] {
return [
windSignals.direction[0](),
windSignals.speed[0](),
windSignals.gusts[0](),
windSignals.roundValues[0](),
];
}
onMount(() => {
const ctx = getCanvas()!.getContext("2d")!;
const img = new Image();
img.onload = () => {
setImg(img);
drawOnMap(
ctx,
[img, arrowImg],
lastCoords,
getWindData(),
);
};
img.src = mapUrl;
});
createEffect(() => {
const ctx = getCanvas()!.getContext("2d")!;
const coordsAndData: [string, {x: number, y: number }, number][] = data()
.map(([city, value]) => [
city,
cityCoords[city as keyof typeof cityCoords],
typeof value === "number" ? value : -99,
]);
lastCoords = coordsAndData;
drawOnMap(
ctx,
[getImg(),arrowImg],
coordsAndData,
getWindData(),
);
});
return (
<>
<WindInputs signals={windSignals} />
<canvas ref={setCanvas} width="1920px" height="1080px" />
</>
);
}
function drawOnMap(
ctx: CanvasRenderingContext2D,
imgArr: [HTMLImageElement, HTMLImageElement],
coords: [string, {x: number, y: number }, number][],
windData: [string, string, string, boolean],
): void {
const size = 80;
const [windDirection, windSpeed, windGusts, roundValues] = windData;
const [bgImg, arrowImg] = imgArr;
// bg
ctx.drawImage(bgImg, 0, 0);
// city boxes
ctx.fillStyle = "#FFFFFF";
coords.forEach(([, {x, y}, value]) => {
ctx.rect(x, y, size, size);
});
ctx.fill();
// weather values
ctx.font = "bold 30px Rubik";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = "#000000";
coords.forEach(([, {x, y}, value]) => {
ctx.rect(x, y, size, size);
const numericValue = roundValues ? Math.round(value) : value;
const stringValue = numericValue.toString().replace(".", ",");
ctx.fillText(stringValue, x + size/2, y + size/2);
});
// wind values
ctx.font = "bold 45px Rubik";
ctx.textAlign = "left";
ctx.textBaseline = "top";
ctx.fillStyle = "#FFFFFF";
const boxMiddleX = 1675;
const directionWidth = ctx.measureText(windDirection).width;
ctx.fillText(windDirection, boxMiddleX - directionWidth / 2 + 30, 370);
const angleInDegrees = getAngleFromString(windDirection);
drawRotatedImage(ctx, arrowImg, boxMiddleX - directionWidth / 2 - 30, 370, angleInDegrees);
const speedWidth = ctx.measureText(windSpeed).width;
ctx.fillText(windSpeed, boxMiddleX - speedWidth / 2 - 30, 575);
ctx.font = "bold 25px Rubik";
ctx.fillText("M/S", boxMiddleX + speedWidth / 2 - 22, 590);
ctx.font = "bold 45px Rubik";
const gustsWidth = ctx.measureText(windGusts).width;
ctx.fillText(windGusts, boxMiddleX - gustsWidth / 2 - 30, 790);
ctx.font = "bold 25px Rubik";
ctx.fillText("M/S", boxMiddleX + gustsWidth / 2 - 22, 805);
}
+53
View File
@@ -0,0 +1,53 @@
import { Component, Signal } from "solid-js";
export interface WindSignals {
direction: Signal<string>;
speed: Signal<string>;
gusts: Signal<string>;
roundValues: Signal<boolean>;
}
export const WindInputs: Component<{signals: WindSignals}> = (
{ signals: {
direction: [getDirection, setDirection],
speed: [getSpeed, setSpeed],
gusts: [getGusts, setGusts],
roundValues: [getRoundValues, setRoundValues],
}}
) => {
return (
<>
<div>
<label>
<input
type="checkbox"
checked={getRoundValues()}
onChange={e => setRoundValues(e.target.checked)}
/>
Round values
</label>
</div>
<div>
<input
type="text"
onInput={e => setDirection(e.target.value)}
/>
Wind direction
</div>
<div>
<input
type="text"
onInput={e => setSpeed(e.target.value)}
/>
Wind speed
</div>
<div>
<input
type="text"
onInput={e => setGusts(e.target.value)}
/>
Gusts
</div>
</>
);
}
+35
View File
@@ -0,0 +1,35 @@
export const cityCoords = {
"Ainaži": { x: 685, y: 146 },
"Alūksne": { x: 1176, y: 294 },
"Bauska": { x: 721, y: 608 },
"Dagda": { x: 1280, y: 680 },
"Daugavgrīva": { x: 640, y: 410 },
"Daugavpils": { x: 1140, y: 739 },
"Dobele": { x: 550, y: 580 },
"Gulbene": { x: 1100, y: 370 },
"Jelgava": { x: 620, y: 570 },
"Kalnciems": { x: 600, y: 500 },
"Kolka": { x: 380, y: 200 },
"Kuldīga": { x: 300, y: 470 },
"Lielpēči": { x: 780, y: 490 },
"Liepāja": { x: 124, y: 638 },
"Madona": { x: 1004, y: 432 },
"Mērsrags": { x: 480, y: 300 },
"Pāvilosta": { x: 160, y: 450 },
"Piedruja": { x: 1290, y: 770 },
"Priekuļi": { x: 900, y: 320 },
"Rēzekne": { x: 1229, y: 544 },
"Rīga": { x: 689, y: 430 },
"Rucava": { x: 140, y: 710 },
"Rūjiena": { x: 870, y: 140 },
"Saldus": { x: 383, y: 561 },
"Sigulda": { x: 810, y: 380 },
"Sīļi": { x: 1050, y: 640 },
"Skrīveri": { x: 880, y: 530 },
"Skulte": { x: 710, y: 320 },
"Stende": { x: 402, y: 400 },
"Ventspils": { x: 210, y: 329 },
"Vičaki": { x: 270, y: 260},
"Zīlāni": { x: 983, y: 581 },
"Zosēni": { x: 970, y: 370 },
};
+27
View File
@@ -0,0 +1,27 @@
const windAngles: {[key: string]: number} = {
"R": 0,
"ZR": 45,
"Z": 90,
"ZA": 135,
"A": 180,
"DA": 225,
"D": 270,
"DR": 315,
}
export function getAngleFromString(str: string): number {
return windAngles[str] ?? 0;
}
export function drawRotatedImage(
ctx: CanvasRenderingContext2D,
img: HTMLImageElement,
x: number,
y: number,
angleInDegrees: number): void {
const angleInRadians = angleInDegrees * (Math.PI / 180);
ctx.translate(x + img.width / 2, y + img.height / 2);
ctx.rotate(angleInRadians);
ctx.drawImage(img, -img.width / 2, -img.height / 2, img.width, img.height);
ctx.setTransform(1, 0, 0, 1, 0, 0);
}
+57
View File
@@ -0,0 +1,57 @@
import moment from "moment";
import { Accessor, Component, createResource, createSignal } from "solid-js";
import "../../css/Result.css"
import { apiHost } from "../../consts";
import { isQueryResult } from "../helpers";
import { Result } from "./Result";
export const QueryResult: 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, 500))
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 && (
<div>
<span class="spinner"></span>
<span style={{ "padding-left": "16px" }}>Loading query</span>
</div>
)}
{ queryResource.error && (
<div>Error while querying: ${queryResource.error}</div>
)}
{ queryResource() && queryResource() instanceof Error &&
<div>{ queryResource().message }</div>
}
{ queryResource() && isQueryResult( queryResource() ) &&
<Result result={queryResource} />
}
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { Component, createSignal, Resource } from "solid-js";
import "../../css/Result.css"
import { ResultKeyVal, resultOrder, ResultOrderKeys } from "../../consts";
import { SelectOrder } from "../SelectOrder";
import { CityResult } from "../chart/CityResult";
import { QueryResult } from "../helpers";
import { MapView } from "../map/MapView";
export const Result: Component<{
result: Resource<QueryResult>
}> = ({ result: resultResource }) => {
const [getOrderKey, setOrderKey] = createSignal<ResultOrderKeys>("A -> Z");
type ResultView = "text" | "map";
const [getResultView, setResultView] = createSignal<ResultView>("text");
const cityData = () =>
Object.entries(resultResource()!.result)
.map(keyVal => [...keyVal] as ResultKeyVal)
.sort((a, b) => resultOrder[getOrderKey()](a, b));
return (
<div>
<div class="resultTitle">
<h3>Result:</h3>
<span>
<input
type="button"
class="secondary"
value="text"
onClick={() => setResultView("text")}
/>
&nbsp; | &nbsp;
<input
type="button"
class="secondary"
value="map"
onClick={() => setResultView("map")}
/>
</span>
<span style={{ visibility: getResultView() === "text" ? "visible" : "hidden" }}>
<SelectOrder getter={getOrderKey} setter={setOrderKey} />
</span>
</div>
<div class={getResultView() === "text" ? "result-container" : ""}>
{ getResultView() === "text"
? cityData().map(([city, data]) =>
<CityResult
city={city}
query={resultResource()!.query}
result={data}
/>)
: <MapView data={cityData} />
}
</div>
</div>
);
}