Files
WeatherTool/web/src/pages/map-graphics/MapGraphics.tsx
T

105 lines
6.2 KiB
TypeScript
Raw Normal View History

2026-08-19 17:11:58 +03:00
import { createEffect, createResource, createSignal, For, Show } from "solid-js";
import { MapView } from "../../components/map/MapView";
2026-08-19 17:11:58 +03:00
import { apiHost, ResultKeyVal } from "../../consts";
import { LoadingSpinner } from "../../components/spinner/LoadingSpinner";
2026-08-19 17:11:58 +03:00
import { faktiskaExport, faktiskaStations, FaktiskaStation } from "./faktiskaConfig";
import type { FaktiskaTemplate } from "./faktiskaTemplates";
import "./mapGraphics.css";
2026-08-19 17:11:58 +03:00
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() {
2026-08-19 17:11:58 +03:00
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 };
2026-08-19 17:11:58 +03:00
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();
};
2026-08-19 17:11:58 +03:00
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);
2026-08-21 10:18:52 +03:00
setOverrides(new Set<FaktiskaStation>());
2026-08-19 17:11:58 +03:00
});
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>
2026-08-19 17:11:58 +03:00
<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>
2026-08-19 17:11:58 +03:00
</section>
</div>;
}