Files
WeatherTool/web/src/pages/map-graphics/MapGraphics.tsx
T
2026-08-21 10:18:52 +03:00

105 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createEffect, createResource, createSignal, For, Show } from "solid-js";
import { MapView } from "../../components/map/MapView";
import { apiHost, ResultKeyVal } from "../../consts";
import { LoadingSpinner } from "../../components/spinner/LoadingSpinner";
import { faktiskaExport, faktiskaStations, FaktiskaStation } from "./faktiskaConfig";
import type { FaktiskaTemplate } from "./faktiskaTemplates";
import "./mapGraphics.css";
type LatestTemperature = { city: FaktiskaStation; observedAt: string; value: number | null };
const emptyValues = (): Record<FaktiskaStation, string> =>
Object.fromEntries(faktiskaStations.map(city => [city, ""])) as Record<FaktiskaStation, string>;
export function Faktiska() {
const [values, setValues] = createSignal(emptyValues());
const [overrides, setOverrides] = createSignal<Set<FaktiskaStation>>(new Set());
const [template, setTemplate] = createSignal<FaktiskaTemplate>(faktiskaExport.defaultTemplate);
const productionSize = () => template() === "faktiska_1920x1080"
? { width: 1920, height: 1080 }
: { width: 3840, height: 1440 };
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();
};
const [latest, { refetch }] = createResource(fetchLatest);
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<FaktiskaStation>());
});
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">{productionSize().width} × {productionSize().height} PNG</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>
</section>
<section class="mapProduction faktiskaProduction">
<h2>2. Add weather symbols and wind, then export</h2>
<div class="resolutionChoices faktiskaResolutionChoices" aria-label="Faktiskā export size">
<button classList={{ active: template() === "faktiska_1920x1080" }} onClick={() => setTemplate("faktiska_1920x1080")}>1920 × 1080</button>
<button classList={{ active: template() === "faktiska_3840x1440" }} onClick={() => setTemplate("faktiska_3840x1440")}>3840 × 1440</button>
</div>
<Show when={template()} keyed>{currentTemplate =>
<MapView type={currentTemplate} data={cityData} mode="faktiska" productionTemplate/>
}</Show>
</section>
</div>;
}