From b887317c6b53449256141edbb25fc39346c01696 Mon Sep 17 00:00:00 2001 From: Guntis Smaukstelis Date: Thu, 3 Aug 2023 00:03:48 +0300 Subject: [PATCH] Server returns query data, bar chart, styling charts --- src/main/scala/parse/Aggregate.scala | 9 ++ src/main/scala/server/Server.scala | 13 ++- web/src/aggregator/Aggregator.tsx | 2 +- web/src/aggregator/CityChart.tsx | 84 --------------- web/src/aggregator/Result.tsx | 14 ++- web/src/aggregator/chart/CityChart.tsx | 55 ++++++++++ web/src/aggregator/chart/CityLargeChart.tsx | 46 ++++++++ web/src/aggregator/{ => chart}/CityResult.tsx | 9 +- web/src/aggregator/chart/CustomChart.ts | 100 ++++++++++++++++++ web/src/aggregator/helpers.ts | 30 ++++++ web/src/css/overlay.css | 29 +++++ 11 files changed, 295 insertions(+), 96 deletions(-) delete mode 100644 web/src/aggregator/CityChart.tsx create mode 100644 web/src/aggregator/chart/CityChart.tsx create mode 100644 web/src/aggregator/chart/CityLargeChart.tsx rename web/src/aggregator/{ => chart}/CityResult.tsx (67%) create mode 100644 web/src/aggregator/chart/CustomChart.ts create mode 100644 web/src/aggregator/helpers.ts create mode 100644 web/src/css/overlay.css diff --git a/src/main/scala/parse/Aggregate.scala b/src/main/scala/parse/Aggregate.scala index 40a688a..af81e59 100644 --- a/src/main/scala/parse/Aggregate.scala +++ b/src/main/scala/parse/Aggregate.scala @@ -18,6 +18,15 @@ object Aggregate { granularity: ChronoUnit, ) + implicit val userQueryEncoder: Encoder[UserQuery] = new Encoder[UserQuery] { + override def apply(userQuery: UserQuery): Json = Json.obj( + "cities" -> Json.fromValues(userQuery.cities.map(Json.fromString)), + "field" -> Json.fromString(userQuery.field), + "key" -> Json.fromString(userQuery.key.toString), + "granularity" -> Json.fromString(userQuery.granularity.toString) + ) + } + sealed trait AggregateKey object AggregateKey { case object Min extends AggregateKey diff --git a/src/main/scala/server/Server.scala b/src/main/scala/server/Server.scala index 6b769bc..cbf538a 100644 --- a/src/main/scala/server/Server.scala +++ b/src/main/scala/server/Server.scala @@ -5,7 +5,7 @@ import cats.implicits.toTraverseOps import com.comcast.ip4s.IpLiteralSyntax import db.DataService import fetch.FetchService -import parse.{Parser, WeatherData} +import parse.{Aggregate, Parser, WeatherData} import server.ValidateRoutes.{AggKey, CityList, DateTimeRange, Granularity, ValidDate} import io.circe.{Json, Printer} import org.http4s._ @@ -16,8 +16,10 @@ import org.http4s.server.middleware.CORS import org.http4s.server.middleware.CORSConfig import org.http4s.server.staticcontent.FileService import org.http4s.ember.server.EmberServerBuilder +import io.circe.generic.auto._ import io.circe.syntax._ import parse.Aggregate.AggregateValueImplicits.aggregateValueEncoder +import parse.Aggregate.userQueryEncoder import parse.Aggregate.{AggregateKey, UserQuery} import org.http4s.circe.jsonEncoder import org.typelevel.log4cats.Logger @@ -45,13 +47,18 @@ class Server(dataService: DataService, fetch: FetchService, log: Logger[IO]) { } } + private case class ResponseWrapper(result: Map[String, Option[Aggregate.AggregateValue]], query: UserQuery) + private val apiRoutes = HttpRoutes.of[IO] { // http://0.0.0.0:8080/api/query/20230414_2200-20230501_1230/Liepāja,Rēzekne/tempMax/max case GET -> Root / "query" / DateTimeRange(from, to) / Granularity(granularity) / CityList(cities) / field / AggKey(key) => + val userQuery = UserQuery(cities, field, key, granularity) + dataService.getInRange(from, to) - .map(Parser.queryData(UserQuery(cities, field, key, granularity), _)) - .flatMap(result => Ok(result.asJson.pretty)) + .map(Parser.queryData(userQuery, _)) + .map(result => ResponseWrapper(result, userQuery)) + .flatMap(responseWrapper => Ok(responseWrapper.asJson.pretty)) // http://0.0.0.0:8080/api/fetch/date/20230514 case GET -> Root / "fetch" / "date" / ValidDate(date) => diff --git a/web/src/aggregator/Aggregator.tsx b/web/src/aggregator/Aggregator.tsx index 0176b5a..96c1bee 100644 --- a/web/src/aggregator/Aggregator.tsx +++ b/web/src/aggregator/Aggregator.tsx @@ -16,7 +16,7 @@ export function Aggregator() { const [getStart, setStart] = createSignal(dayAgo); const [getEnd, setEnd] = createSignal(nowRounded); const [getField, setField] = createSignal("tempMax"); - const [getKey, setKey] = createSignal("max"); + const [getKey, setKey] = createSignal("list"); const [getGranularity, setGranularity] = createSignal("hour"); return ( diff --git a/web/src/aggregator/CityChart.tsx b/web/src/aggregator/CityChart.tsx deleted file mode 100644 index aae74b5..0000000 --- a/web/src/aggregator/CityChart.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { onMount, onCleanup, createSignal, Component } from "solid-js"; -import { Chart, CategoryScale, LinearScale, LineController, PointElement, LineElement, Title } from 'chart.js'; -import moment from "moment"; - -// Register the controllers, elements, scales, and plugins we'll be using -Chart.register(LineController, LinearScale, PointElement, LineElement, Title, CategoryScale); - -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 const CityChart: Component<{ data: [string, number | null][] }> = (props) => { - const [canvas, setCanvas] = createSignal(); - 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 temperatures = data.map(item => item[1]); - - onMount(() => { - const ctx = canvas()!.getContext('2d')!; - - chart = new Chart(ctx, { - type: 'line', - data: { - labels: timestamps, - datasets: [{ - label: 'Temperature', - data: temperatures, - fill: false, - borderColor: 'rgb(75, 192, 192)', - tension: 0.1, - }] - }, - options: { - spanGaps: false, - plugins: { - title: { - // display: true, - text: "Rīga", - }, - }, - scales: { - x: { - beginAtZero: true, - title: { - // display: true, - text: 'Day' - }, - }, - y: { - beginAtZero: true, - title: { - // display: true, - text: 'Temperature (°C)' - } - } - } - } - }); - }); - - onCleanup(() => { - chart.destroy(); - }); - - return ( - - ); -} \ No newline at end of file diff --git a/web/src/aggregator/Result.tsx b/web/src/aggregator/Result.tsx index 14de578..f6f2346 100644 --- a/web/src/aggregator/Result.tsx +++ b/web/src/aggregator/Result.tsx @@ -4,7 +4,8 @@ import { Accessor, Component, createResource, createSignal } from "solid-js"; import "../css/Result.css" import { apiHost, ResultKeyVal, resultOrder, ResultOrderKeys } from "../consts"; import { SelectOrder } from "./SelectOrder"; -import { CityResult } from "./CityResult"; +import { CityResult } from "./chart/CityResult"; +import { isDataQuery } from "./helpers"; export const Result: Component<{ getCities: Accessor>, @@ -54,12 +55,17 @@ export const Result: Component<{ { queryResource() && queryResource() instanceof Error &&
{ queryResource().message }
} - { queryResource() && ( + { queryResource() && queryResource().result && isDataQuery(queryResource().query) && (
- { Object.entries(queryResource()) + { Object.entries(queryResource().result) .map(keyVal => [...keyVal] as ResultKeyVal) .sort((a, b) => resultOrder[getOrderKey()](a, b)) - .map(cityData => ) + .map(cityData => + ) }
)} diff --git a/web/src/aggregator/chart/CityChart.tsx b/web/src/aggregator/chart/CityChart.tsx new file mode 100644 index 0000000..b33c93e --- /dev/null +++ b/web/src/aggregator/chart/CityChart.tsx @@ -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(); + 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 ( +
+ +
+ setIsLarge(false)} /> +
+
+ ); +} \ No newline at end of file diff --git a/web/src/aggregator/chart/CityLargeChart.tsx b/web/src/aggregator/chart/CityLargeChart.tsx new file mode 100644 index 0000000..8759d87 --- /dev/null +++ b/web/src/aggregator/chart/CityLargeChart.tsx @@ -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(); + 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 ( +
+
+ +
+
+ ); +} \ No newline at end of file diff --git a/web/src/aggregator/CityResult.tsx b/web/src/aggregator/chart/CityResult.tsx similarity index 67% rename from web/src/aggregator/CityResult.tsx rename to web/src/aggregator/chart/CityResult.tsx index 2c5eecd..c8c477b 100644 --- a/web/src/aggregator/CityResult.tsx +++ b/web/src/aggregator/chart/CityResult.tsx @@ -1,14 +1,15 @@ import { Component } from "solid-js"; import { CityChart } from "./CityChart"; +import { DataQuery } from "../helpers"; -export const CityResult: Component<{ city: string, value: any }> = ({ city, value }) => { +export const CityResult: Component<{ city: string, query: DataQuery, result: any }> = ({ city, query, result }) => { return (

{ city }

{ - isDateNumber(value) - ? - : value + isDateNumber(result) + ? + : result }
); diff --git a/web/src/aggregator/chart/CustomChart.ts b/web/src/aggregator/chart/CustomChart.ts new file mode 100644 index 0000000..2177d2d --- /dev/null +++ b/web/src/aggregator/chart/CustomChart.ts @@ -0,0 +1,100 @@ +import { + BarElement, + BarController, + CategoryScale, + Chart, + LinearScale, + LineController, + PointElement, + LineElement, + Title, +} from "chart.js"; + +// Register the controllers, elements, scales, and plugins we'll be using +Chart.register( + BarElement, + BarController, + LineController, + LinearScale, + PointElement, + LineElement, + Title, + CategoryScale, +); + +// Chart.defaults.backgroundColor = "#FF0000"; + +const barChartData = ["precipitation", "sunDuration"]; + +export function createCustomChart( + ctx: CanvasRenderingContext2D, + showBig: boolean, + timestamps: string[], + values: Array, + 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(); + }, +}; \ No newline at end of file diff --git a/web/src/aggregator/helpers.ts b/web/src/aggregator/helpers.ts new file mode 100644 index 0000000..0f7d433 --- /dev/null +++ b/web/src/aggregator/helpers.ts @@ -0,0 +1,30 @@ +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 DataQuery { + cities: string[]; + field: string; + granularity: string; + key: string; +} + +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'; +} diff --git a/web/src/css/overlay.css b/web/src/css/overlay.css new file mode 100644 index 0000000..f3cd83d --- /dev/null +++ b/web/src/css/overlay.css @@ -0,0 +1,29 @@ +/* Overlay styles */ +.overlay { + display: block; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.7); + z-index: 999; +} + +.overlay-content { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 90%; + max-width: 90%; + max-height: 90%; + background-color: white; +} + +.overlay-content img { + display: block; + max-width: 100%; + max-height: 100%; + margin: 0 auto; +} \ No newline at end of file