diff --git a/src/main/scala/db/PostgresService.scala b/src/main/scala/db/PostgresService.scala index 1ac766f..418323c 100644 --- a/src/main/scala/db/PostgresService.scala +++ b/src/main/scala/db/PostgresService.scala @@ -28,6 +28,18 @@ object PostgresService { } class PostgresService(transactor: Transactor[IO], log: Logger[IO]) { + def queryLatestTemperatures(cities: NonEmptyList[String]): IO[List[(String, LocalDateTime, Option[Double])]] = { + val query = + fr"SELECT DISTINCT ON (city) city, dateTime, tempAvg" ++ + fr" FROM weather" ++ + fr" WHERE " ++ Fragments.in(fr"city", cities) ++ + fr" ORDER BY city, dateTime DESC" + + query.query[(String, LocalDateTime, Option[Double])] + .to[List] + .transact(transactor) + } + def save(fileName: String, content: String): IO[String] = { val YearPattern: Regex = """(\d{4})\d{4}_\d{4}\.csv""".r diff --git a/src/main/scala/server/Server.scala b/src/main/scala/server/Server.scala index 75fcaac..28c08cd 100644 --- a/src/main/scala/server/Server.scala +++ b/src/main/scala/server/Server.scala @@ -49,6 +49,7 @@ class Server(postgresService: PostgresService, dataService: DataService, fetch: } private case class ResponseWrapper(result: Map[String, Option[Aggregate.AggregateValue]], query: UserQuery) + private case class LatestTemperature(city: String, observedAt: String, value: Option[Double]) private val apiRoutes = HttpRoutes.of[IO] { // http://0.0.0.0:8080/api/show/lvgmc-forecast/Latvija_LTV_pilsetas_tekosa_dn.csv @@ -127,6 +128,12 @@ class Server(postgresService: PostgresService, dataService: DataService, fetch: .map(result => ResponseWrapper(result, userQuery)) .flatMap(responseWrapper => Ok(responseWrapper.asJson.pretty)) + // Latest observation-time temperatures for a fixed production station set. + case GET -> Root / "query" / "latest-temperatures" / CityList(cities) => + postgresService.queryLatestTemperatures(cities) + .map(_.map { case (city, observedAt, value) => LatestTemperature(city, observedAt.toString, value) }) + .flatMap(result => Ok(result.asJson.pretty)) + // http://0.0.0.0:8080/api/query/city/Kolka/20230414_2200-20230501_1230/allFields case GET -> Root / "query" / "city" / (city: String) / DateTimeRange(from, to) / "allFields" => postgresService.queryCityAllFields(city, from, to).flatMap(result => Ok(result.asJson)) diff --git a/web/src/components/map/MapView.tsx b/web/src/components/map/MapView.tsx index 348e0f6..0e16c6b 100644 --- a/web/src/components/map/MapView.tsx +++ b/web/src/components/map/MapView.tsx @@ -10,7 +10,7 @@ import { CityData, drawOnMap } from "./canvasDraw"; import { IconInputs } from "../weatherIcons/IconInputs"; import { download } from '../../helpers/download' -export const MapView: Component<{ type: MapResolution, data: () => ResultKeyVal[], mode?: "temperature" | "faktiska" }> = ({ type, data, mode = "temperature" }) => { +export const MapView: Component<{ type: MapResolution, data: () => ResultKeyVal[], mode?: "temperature" | "faktiska", productionTemplate?: boolean }> = ({ type, data, mode = "temperature", productionTemplate = false }) => { const [getCanvas, setCanvas] = createSignal(); const [getImg, setImg] = createSignal(new Image()); const weatherIconsSignal = createSignal<{[key: string]: string;}>({}); @@ -28,7 +28,7 @@ export const MapView: Component<{ type: MapResolution, data: () => ResultKeyVal[ direction: createSignal(""), speed: createSignal(""), gusts: createSignal(""), - roundValues: createSignal(false), + roundValues: createSignal(mode === "faktiska"), } function getWindData(): [string, string, string, boolean, WindProps] { @@ -93,10 +93,10 @@ export const MapView: Component<{ type: MapResolution, data: () => ResultKeyVal[
Broadcast overlay controlsOptional manual wind and weather-symbol overrides

These settings do not change the queried city values. Use them only when preparing a finished broadcast graphic.

-
+ {!productionTemplate &&
-
+
}
Value and wind settings diff --git a/web/src/pages/map-graphics/MapGraphics.tsx b/web/src/pages/map-graphics/MapGraphics.tsx index 049a2f4..6918b8a 100644 --- a/web/src/pages/map-graphics/MapGraphics.tsx +++ b/web/src/pages/map-graphics/MapGraphics.tsx @@ -1,54 +1,93 @@ -import moment from "moment"; -import { createResource, createSignal, Show } from "solid-js"; -import { SelectCity } from "../../components/SelectCity"; -import { SelectTimeRange } from "../../components/SelectTimeRange"; +import { createEffect, createResource, createSignal, For, Show } from "solid-js"; import { MapView } from "../../components/map/MapView"; -import { MapResolution } from "../../components/map/mapConsts"; -import { FETCH_DELAY_MS, apiHost, ResultKeyVal } from "../../consts"; -import { isQueryResult, QueryResult } from "../cities/helpers"; +import { apiHost, ResultKeyVal } from "../../consts"; import { LoadingSpinner } from "../../components/spinner/LoadingSpinner"; +import { faktiskaExport, faktiskaStations, FaktiskaStation } from "./faktiskaConfig"; import "./mapGraphics.css"; -export function Faktiska() { - const now = new Date(new Date().setMinutes(30)); - const [cities, setCities] = createSignal>(new Set()); - const [start, setStart] = createSignal(moment(now).subtract(1, "day").toDate()); - const [end, setEnd] = createSignal(now); - const [field, setField] = createSignal("tempMax"); - const [key, setKey] = createSignal("max"); - const [resolution, setResolution] = createSignal<"1920x1080" | "3840x1440">("1920x1080"); - const [wind, setWind] = createSignal(false); - const [trigger, setTrigger] = createSignal(0); +type LatestTemperature = { city: FaktiskaStation; observedAt: string; value: number | null }; - const fetchData = async () => { - if (!cities().size) return undefined; - await new Promise(resolve => setTimeout(resolve, FETCH_DELAY_MS)); - const cityPath = [...cities()].join(","); - const range = `${moment(start()).format("YYYYMMDD_HHmm")}-${moment(end()).format("YYYYMMDD_HHmm")}`; - const response = await fetch(`${apiHost}/api/query/city/${cityPath}/${range}/hour/${field()}/${key()}`); - if (!response.ok) throw new Error(`Query failed (${response.status})`); +const emptyValues = (): Record => + Object.fromEntries(faktiskaStations.map(city => [city, ""])) as Record; + +export function Faktiska() { + const [values, setValues] = createSignal(emptyValues()); + const [overrides, setOverrides] = createSignal>(new Set()); + + const fetchLatest = async (): Promise => { + const response = await fetch(`${apiHost}/api/query/latest-temperatures/${faktiskaStations.join(",")}`); + if (!response.ok) throw new Error(`Latest observations failed (${response.status})`); return response.json(); }; - const [result] = createResource(trigger, fetchData); - const mapType = (): MapResolution => `map_${resolution()}${wind() ? "_wind" : ""}` as MapResolution; - const cityData = (): ResultKeyVal[] => Object.entries((result() as QueryResult).result).map(([city, value]) => [city, typeof value === "number" ? value : -99]); + const [latest, { refetch }] = createResource(fetchLatest); - return
-
Daily newsroom production

Faktiskā

Create the actual-weather map with temperatures and weather symbols.

{cities().size} cities selected
-
- -
-
-

2. Choose output

-
Output
-
-

3. Retrieve temperatures

-
- -
Could not load map data{result.error.message}
-

4. Add conditions and export

-
Choose cities and load dataThe production controls and map preview will appear here.
-
+ createEffect(() => { + const observations = latest(); + if (!observations) return; + const next = emptyValues(); + observations.forEach(observation => { + next[observation.city] = observation.value == null ? "" : observation.value.toString(); + }); + setValues(next); + setOverrides(new Set()); + }); + + const observationsByCity = () => new Map((latest() ?? []).map(item => [item.city, item])); + const newestTimestamp = () => { + const timestamps = (latest() ?? []).map(item => Date.parse(item.observedAt)).filter(Number.isFinite); + return timestamps.length ? new Date(Math.max(...timestamps)) : undefined; + }; + const isStale = (city: FaktiskaStation) => { + const newest = newestTimestamp(); + const observed = observationsByCity().get(city)?.observedAt; + return Boolean(newest && observed && newest.getTime() - Date.parse(observed) > 90 * 60 * 1000); + }; + const cityData = (): ResultKeyVal[] => faktiskaStations.flatMap(city => { + const value = Number(values()[city].replace(",", ".")); + return values()[city].trim() && Number.isFinite(value) ? [[city, value] as ResultKeyVal] : []; + }); + const updateValue = (city: FaktiskaStation, value: string) => { + setValues(current => ({ ...current, [city]: value })); + setOverrides(current => new Set(current).add(city)); + }; + const resetValue = (city: FaktiskaStation) => { + const observation = observationsByCity().get(city); + setValues(current => ({ ...current, [city]: observation?.value == null ? "" : observation.value.toString() })); + setOverrides(current => { + const next = new Set(current); + next.delete(city); + return next; + }); + }; + + return
+
+
Daily newsroom production

Faktiskā

Current temperatures with manually prepared weather symbols and wind.

+
{faktiskaExport.width} × {faktiskaExport.height} PNG
+ +
+
+
1. Latest temperatures{newestTimestamp() ? newestTimestamp()!.toLocaleString("lv-LV") : "Waiting for observations"}Newest available reading in the configured station set
+ +
+ +
Could not load observations. Existing manual fields remain available.
+
+ {city => { + const observation = () => observationsByCity().get(city); + return ; + }} +
+
+ +
+

2. Add weather symbols and wind, then export

+ +
; } diff --git a/web/src/pages/map-graphics/faktiskaConfig.ts b/web/src/pages/map-graphics/faktiskaConfig.ts new file mode 100644 index 0000000..6006316 --- /dev/null +++ b/web/src/pages/map-graphics/faktiskaConfig.ts @@ -0,0 +1,12 @@ +export const faktiskaStations = [ + "Liepāja", "Ventspils", "Stende", "Saldus", "Rīga", "Jelgava", "Ainaži", + "Valmiera", "Madona", "Alūksne", "Zīlāni", "Daugavpils", "Rēzekne", +] as const; + +export type FaktiskaStation = typeof faktiskaStations[number]; + +export const faktiskaExport = { + width: 3840, + height: 1440, + mapType: "map_3840x1440_wind" as const, +}; diff --git a/web/src/pages/map-graphics/mapGraphics.css b/web/src/pages/map-graphics/mapGraphics.css index d7ec0bc..fa6ed95 100644 --- a/web/src/pages/map-graphics/mapGraphics.css +++ b/web/src/pages/map-graphics/mapGraphics.css @@ -32,3 +32,26 @@ .dataOptionsGrid{grid-template-columns:1fr} .loadMapButton{justify-self:start} } + +/* Fixed Faktiskā newsroom workflow. The preview is responsive; export geometry is not. */ +.faktiskaWorkspace{max-width:1900px} +.productionSize{padding:10px 14px;border:1px solid rgba(77,130,190,.28);border-radius:10px;background:rgba(255,255,255,.66);font-weight:700} +.faktiskaDataBand{padding:2px 0 22px;border-bottom:1px solid rgba(90,126,166,.22)} +.faktiskaStatus{display:flex;align-items:center;justify-content:space-between;gap:24px;margin-bottom:18px} +.faktiskaStatus>div,.faktiskaStatus strong,.faktiskaStatus small{display:block} +.faktiskaStatus strong{margin-top:5px;font-size:18px} +.faktiskaStatus small{margin-top:3px;color:var(--text-muted)} +.stepLabel{font-family:Rubik,sans-serif;font-size:20px;font-weight:700} +.temperatureEditor{display:grid;grid-template-columns:repeat(7,minmax(150px,1fr));gap:10px} +.temperatureField{display:grid;grid-template-columns:minmax(0,1fr) auto;grid-template-areas:"heading reset" "input input";gap:8px;padding:11px 12px;border:1px solid rgba(117,151,190,.28);border-radius:12px;background:rgba(255,255,255,.52);transition:border-color 180ms,background 180ms} +.temperatureField.manual{border-color:rgba(36,119,197,.58);background:rgba(229,241,255,.76)} +.temperatureField.stale{border-color:rgba(203,138,40,.5)} +.temperatureField.missing{border-style:dashed} +.temperatureFieldHeading{grid-area:heading;min-width:0}.temperatureFieldHeading strong,.temperatureFieldHeading small{display:block}.temperatureFieldHeading small{margin-top:2px;color:var(--text-muted);font-size:10px} +.temperatureInput{grid-area:input;display:flex;align-items:center;gap:7px}.temperatureInput input{width:100%;min-width:0;font-size:19px;font-weight:700}.temperatureInput>span{color:var(--text-muted);font-weight:700} +.resetTemperature{grid-area:reset;align-self:start;min-height:0;padding:3px 5px;background:transparent;color:var(--accent-strong);font-family:Inter,sans-serif;font-size:10px} +.inlineNotice{margin:10px 0;padding:10px 12px;border-radius:9px;background:rgba(255,255,255,.62)}.inlineNotice.error{color:var(--danger)} +.faktiskaProduction{margin-top:20px}.faktiskaProduction>h2{font-size:22px} +@media(max-width:1450px){.temperatureEditor{grid-template-columns:repeat(5,minmax(150px,1fr))}} +@media(max-width:1050px){.temperatureEditor{grid-template-columns:repeat(3,minmax(150px,1fr))}} +@media(max-width:650px){.faktiskaStatus{align-items:flex-start;flex-direction:column}.temperatureEditor{grid-template-columns:1fr 1fr}}