Build fixed Faktiska production workflow
This commit is contained in:
@@ -28,6 +28,18 @@ object PostgresService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class PostgresService(transactor: Transactor[IO], log: Logger[IO]) {
|
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] = {
|
def save(fileName: String, content: String): IO[String] = {
|
||||||
val YearPattern: Regex = """(\d{4})\d{4}_\d{4}\.csv""".r
|
val YearPattern: Regex = """(\d{4})\d{4}_\d{4}\.csv""".r
|
||||||
|
|
||||||
|
|||||||
@@ -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 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] {
|
private val apiRoutes = HttpRoutes.of[IO] {
|
||||||
// http://0.0.0.0:8080/api/show/lvgmc-forecast/Latvija_LTV_pilsetas_tekosa_dn.csv
|
// 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))
|
.map(result => ResponseWrapper(result, userQuery))
|
||||||
.flatMap(responseWrapper => Ok(responseWrapper.asJson.pretty))
|
.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
|
// 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" =>
|
case GET -> Root / "query" / "city" / (city: String) / DateTimeRange(from, to) / "allFields" =>
|
||||||
postgresService.queryCityAllFields(city, from, to).flatMap(result => Ok(result.asJson))
|
postgresService.queryCityAllFields(city, from, to).flatMap(result => Ok(result.asJson))
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { CityData, drawOnMap } from "./canvasDraw";
|
|||||||
import { IconInputs } from "../weatherIcons/IconInputs";
|
import { IconInputs } from "../weatherIcons/IconInputs";
|
||||||
import { download } from '../../helpers/download'
|
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<HTMLCanvasElement>();
|
const [getCanvas, setCanvas] = createSignal<HTMLCanvasElement>();
|
||||||
const [getImg, setImg] = createSignal(new Image());
|
const [getImg, setImg] = createSignal(new Image());
|
||||||
const weatherIconsSignal = createSignal<{[key: string]: string;}>({});
|
const weatherIconsSignal = createSignal<{[key: string]: string;}>({});
|
||||||
@@ -28,7 +28,7 @@ export const MapView: Component<{ type: MapResolution, data: () => ResultKeyVal[
|
|||||||
direction: createSignal(""),
|
direction: createSignal(""),
|
||||||
speed: createSignal(""),
|
speed: createSignal(""),
|
||||||
gusts: createSignal(""),
|
gusts: createSignal(""),
|
||||||
roundValues: createSignal(false),
|
roundValues: createSignal(mode === "faktiska"),
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWindData(): [string, string, string, boolean, WindProps] {
|
function getWindData(): [string, string, string, boolean, WindProps] {
|
||||||
@@ -93,10 +93,10 @@ export const MapView: Component<{ type: MapResolution, data: () => ResultKeyVal[
|
|||||||
<details class="broadcastControls" open>
|
<details class="broadcastControls" open>
|
||||||
<summary><span><strong>Broadcast overlay controls</strong><small>Optional manual wind and weather-symbol overrides</small></span></summary>
|
<summary><span><strong>Broadcast overlay controls</strong><small>Optional manual wind and weather-symbol overrides</small></span></summary>
|
||||||
<p class="controlHelp">These settings do not change the queried city values. Use them only when preparing a finished broadcast graphic.</p>
|
<p class="controlHelp">These settings do not change the queried city values. Use them only when preparing a finished broadcast graphic.</p>
|
||||||
<div class="overlayFields">
|
{!productionTemplate && <div class="overlayFields">
|
||||||
<label><span>Title</span><input type="text" placeholder="19. APRĪĻA GAISA TEMPERATŪRAS REKORDI" value={getTitle()} onInput={e => setTitle(e.target.value)} /></label>
|
<label><span>Title</span><input type="text" placeholder="19. APRĪĻA GAISA TEMPERATŪRAS REKORDI" value={getTitle()} onInput={e => setTitle(e.target.value)} /></label>
|
||||||
<label><span>Source</span><input type="text" placeholder="LVĢMC" value={getSource()} onInput={e => setSource(e.target.value)} /></label>
|
<label><span>Source</span><input type="text" placeholder="LVĢMC" value={getSource()} onInput={e => setSource(e.target.value)} /></label>
|
||||||
</div>
|
</div>}
|
||||||
<div class="mapControlSections">
|
<div class="mapControlSections">
|
||||||
<section class="valueControls">
|
<section class="valueControls">
|
||||||
<strong>Value and wind settings</strong>
|
<strong>Value and wind settings</strong>
|
||||||
|
|||||||
@@ -1,54 +1,93 @@
|
|||||||
import moment from "moment";
|
import { createEffect, createResource, createSignal, For, Show } from "solid-js";
|
||||||
import { createResource, createSignal, Show } from "solid-js";
|
|
||||||
import { SelectCity } from "../../components/SelectCity";
|
|
||||||
import { SelectTimeRange } from "../../components/SelectTimeRange";
|
|
||||||
import { MapView } from "../../components/map/MapView";
|
import { MapView } from "../../components/map/MapView";
|
||||||
import { MapResolution } from "../../components/map/mapConsts";
|
import { apiHost, ResultKeyVal } from "../../consts";
|
||||||
import { FETCH_DELAY_MS, apiHost, ResultKeyVal } from "../../consts";
|
|
||||||
import { isQueryResult, QueryResult } from "../cities/helpers";
|
|
||||||
import { LoadingSpinner } from "../../components/spinner/LoadingSpinner";
|
import { LoadingSpinner } from "../../components/spinner/LoadingSpinner";
|
||||||
|
import { faktiskaExport, faktiskaStations, FaktiskaStation } from "./faktiskaConfig";
|
||||||
import "./mapGraphics.css";
|
import "./mapGraphics.css";
|
||||||
|
|
||||||
export function Faktiska() {
|
type LatestTemperature = { city: FaktiskaStation; observedAt: string; value: number | null };
|
||||||
const now = new Date(new Date().setMinutes(30));
|
|
||||||
const [cities, setCities] = createSignal<Set<string>>(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);
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
const emptyValues = (): Record<FaktiskaStation, string> =>
|
||||||
if (!cities().size) return undefined;
|
Object.fromEntries(faktiskaStations.map(city => [city, ""])) as Record<FaktiskaStation, string>;
|
||||||
await new Promise(resolve => setTimeout(resolve, FETCH_DELAY_MS));
|
|
||||||
const cityPath = [...cities()].join(",");
|
export function Faktiska() {
|
||||||
const range = `${moment(start()).format("YYYYMMDD_HHmm")}-${moment(end()).format("YYYYMMDD_HHmm")}`;
|
const [values, setValues] = createSignal(emptyValues());
|
||||||
const response = await fetch(`${apiHost}/api/query/city/${cityPath}/${range}/hour/${field()}/${key()}`);
|
const [overrides, setOverrides] = createSignal<Set<FaktiskaStation>>(new Set());
|
||||||
if (!response.ok) throw new Error(`Query failed (${response.status})`);
|
|
||||||
|
const fetchLatest = async (): Promise<LatestTemperature[]> => {
|
||||||
|
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();
|
return response.json();
|
||||||
};
|
};
|
||||||
const [result] = createResource(trigger, fetchData);
|
const [latest, { refetch }] = createResource(fetchLatest);
|
||||||
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]);
|
|
||||||
|
|
||||||
return <div class="mapGraphicsPage pageWorkspace">
|
createEffect(() => {
|
||||||
<div class="pageHeading"><div><span class="eyebrow">Daily newsroom production</span><h1>Faktiskā</h1><p>Create the actual-weather map with temperatures and weather symbols.</p></div><div class="selectionCount">{cities().size} cities selected</div></div>
|
const observations = latest();
|
||||||
<div class="graphicsSetup">
|
if (!observations) return;
|
||||||
<aside class="cityPanel workspaceRail"><h2>1. Choose cities</h2><SelectCity getCities={cities} setCities={setCities}/></aside>
|
const next = emptyValues();
|
||||||
<section class="graphicsMain">
|
observations.forEach(observation => {
|
||||||
<div class="productionToolbar">
|
next[observation.city] = observation.value == null ? "" : observation.value.toString();
|
||||||
<div class="productionOptions"><h2>2. Choose output</h2>
|
});
|
||||||
<div class="choiceGroup"><span>Output</span><div><button classList={{active: resolution() === "1920x1080"}} onClick={() => setResolution("1920x1080")}>1920 × 1080</button><button classList={{active: resolution() === "3840x1440"}} onClick={() => setResolution("3840x1440")}>3840 × 1440</button><label class="windChoice"><input type="checkbox" checked={wind()} onChange={e => setWind(e.target.checked)}/> Wind layout</label></div></div>
|
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 <div class="mapGraphicsPage pageWorkspace faktiskaWorkspace">
|
||||||
|
<div class="pageHeading">
|
||||||
|
<div><span class="eyebrow">Daily newsroom production</span><h1>Faktiskā</h1><p>Current temperatures with manually prepared weather symbols and wind.</p></div>
|
||||||
|
<div class="productionSize">{faktiskaExport.width} × {faktiskaExport.height} PNG</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dataOptions"><h2>3. Retrieve temperatures</h2><div class="dataOptionsGrid"><SelectTimeRange getStart={start} setStart={setStart} getEnd={end} setEnd={setEnd}/><label><span>Temperature field</span><select value={field()} onChange={e => setField(e.target.value)}><option value="tempMax">Maximum temperature</option><option value="tempMin">Minimum temperature</option><option value="tempAvg">Average temperature</option></select></label><label><span>Calculation</span><select value={key()} onChange={e => setKey(e.target.value)}><option value="max">Maximum</option><option value="min">Minimum</option><option value="avg">Average</option></select></label><button class="primary loadMapButton" disabled={!cities().size} onClick={() => setTrigger(Date.now())}>Load map data</button></div></div>
|
|
||||||
|
<section class="faktiskaDataBand">
|
||||||
|
<div class="faktiskaStatus">
|
||||||
|
<div><span class="stepLabel">1. Latest temperatures</span><strong>{newestTimestamp() ? newestTimestamp()!.toLocaleString("lv-LV") : "Waiting for observations"}</strong><small>Newest available reading in the configured station set</small></div>
|
||||||
|
<button class="secondary" disabled={latest.loading} onClick={() => refetch()}>Refresh data</button>
|
||||||
|
</div>
|
||||||
|
<Show when={latest.loading}><LoadingSpinner text="Loading latest temperatures"/></Show>
|
||||||
|
<Show when={latest.error}><div class="inlineNotice error"><strong>Could not load observations.</strong> Existing manual fields remain available.</div></Show>
|
||||||
|
<div class="temperatureEditor">
|
||||||
|
<For each={faktiskaStations}>{city => {
|
||||||
|
const observation = () => observationsByCity().get(city);
|
||||||
|
return <label class="temperatureField" classList={{manual: overrides().has(city), stale: isStale(city), missing: observation()?.value == null}}>
|
||||||
|
<span class="temperatureFieldHeading"><strong>{city}</strong><small>{overrides().has(city) ? "Manual" : isStale(city) ? "Older reading" : observation()?.observedAt ? new Date(observation()!.observedAt).toLocaleTimeString("lv-LV", {hour:"2-digit", minute:"2-digit"}) : "No data"}</small></span>
|
||||||
|
<span class="temperatureInput"><input type="text" inputmode="decimal" value={values()[city]} onInput={event => updateValue(city, event.currentTarget.value)} aria-label={`${city} temperature`}/><span>°C</span></span>
|
||||||
|
<button type="button" class="resetTemperature" disabled={!overrides().has(city)} onClick={() => resetValue(city)}>Reset</button>
|
||||||
|
</label>;
|
||||||
|
}}</For>
|
||||||
</div>
|
</div>
|
||||||
<Show when={result.loading}><LoadingSpinner text="Loading map data"/></Show>
|
|
||||||
<Show when={result.error}><div class="emptyState"><strong>Could not load map data</strong><span>{result.error.message}</span></div></Show>
|
|
||||||
<Show when={result() && isQueryResult(result())}><section class="mapProduction"><h2>4. Add conditions and export</h2><MapView type={mapType()} data={cityData} mode="faktiska"/></section></Show>
|
|
||||||
<Show when={!result() && !result.loading && !result.error}><div class="emptyState"><strong>Choose cities and load data</strong><span>The production controls and map preview will appear here.</span></div></Show>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
|
||||||
|
<section class="mapProduction faktiskaProduction">
|
||||||
|
<h2>2. Add weather symbols and wind, then export</h2>
|
||||||
|
<MapView type={faktiskaExport.mapType} data={cityData} mode="faktiska" productionTemplate/>
|
||||||
|
</section>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -32,3 +32,26 @@
|
|||||||
.dataOptionsGrid{grid-template-columns:1fr}
|
.dataOptionsGrid{grid-template-columns:1fr}
|
||||||
.loadMapButton{justify-self:start}
|
.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}}
|
||||||
|
|||||||
Reference in New Issue
Block a user