Auto-populate Ūdens temperatures from real LVĢMC data, with manual override
Mirrors the Faktiskā pattern: fetch on load, auto-fill min/max per zone, track manual overrides, per-zone reset, loading/error states.
This commit is contained in:
@@ -1,14 +1,17 @@
|
||||
import { createEffect, createSignal, For, onMount } from 'solid-js';
|
||||
import { createEffect, createResource, createSignal, For, onMount, Show } from 'solid-js';
|
||||
|
||||
import map1920 from '../../assets/production/UDENS_KARTE_PAMATS_1920.png';
|
||||
import map3840 from '../../assets/production/UDENS_KARTE_PAMATS_3840.png';
|
||||
import { apiHost } from '../../consts';
|
||||
import { download } from '../../helpers/download';
|
||||
import { LoadingSpinner } from '../../components/spinner/LoadingSpinner';
|
||||
import './waterTemperature.css';
|
||||
|
||||
type OutputSize = '1920x1080' | '3840x1440';
|
||||
type WaterArea = 'Jūra' | 'Līcis' | 'Kurzeme' | 'Zemgale' | 'Vidzeme' | 'Latgale';
|
||||
type RangeValue = { min: string; max: string };
|
||||
type LabelBox = { x: number; y: number; width: number; height: number };
|
||||
type WaterReading = { area: WaterArea; observedAt: string; value: number | null };
|
||||
|
||||
const areaNames: WaterArea[] = ['Jūra', 'Līcis', 'Kurzeme', 'Zemgale', 'Vidzeme', 'Latgale'];
|
||||
const layouts: Record<OutputSize, { fontSize: number; horizontalPadding: number; boxes: Record<WaterArea, LabelBox> }> = {
|
||||
@@ -37,6 +40,7 @@ const emptyRanges = (): Record<WaterArea, RangeValue> => Object.fromEntries(
|
||||
export function WaterTemperature() {
|
||||
const [output, setOutput] = createSignal<OutputSize>('3840x1440');
|
||||
const [ranges, setRanges] = createSignal(emptyRanges());
|
||||
const [overrides, setOverrides] = createSignal<Set<WaterArea>>(new Set());
|
||||
const [background, setBackground] = createSignal<HTMLImageElement>();
|
||||
let canvas!: HTMLCanvasElement;
|
||||
|
||||
@@ -46,8 +50,47 @@ export function WaterTemperature() {
|
||||
|
||||
const complete = () => areaNames.every(name => ranges()[name].min.trim() && ranges()[name].max.trim());
|
||||
|
||||
const fetchLatest = async (): Promise<WaterReading[]> => {
|
||||
const response = await fetch(`${apiHost}/api/water-temperatures`);
|
||||
if (!response.ok) throw new Error(`Water temperatures failed (${response.status})`);
|
||||
const body: { source: string; readings: WaterReading[] } = await response.json();
|
||||
return body.readings;
|
||||
};
|
||||
const [latest, { refetch }] = createResource(fetchLatest);
|
||||
|
||||
createEffect(() => {
|
||||
const readings = latest();
|
||||
if (!readings) return;
|
||||
const next = emptyRanges();
|
||||
readings.forEach(reading => {
|
||||
if (reading.value == null) return;
|
||||
const rounded = Math.round(reading.value).toString();
|
||||
next[reading.area] = { min: rounded, max: rounded };
|
||||
});
|
||||
setRanges(next);
|
||||
setOverrides(new Set<WaterArea>());
|
||||
});
|
||||
|
||||
const observationsByArea = () => new Map((latest() ?? []).map(reading => [reading.area, reading]));
|
||||
const newestTimestamp = () => {
|
||||
const timestamps = (latest() ?? []).map(reading => Date.parse(reading.observedAt)).filter(Number.isFinite);
|
||||
return timestamps.length ? new Date(Math.max(...timestamps)) : undefined;
|
||||
};
|
||||
|
||||
const updateRange = (name: WaterArea, key: keyof RangeValue, value: string) => {
|
||||
setRanges(current => ({ ...current, [name]: { ...current[name], [key]: value } }));
|
||||
setOverrides(current => new Set(current).add(name));
|
||||
};
|
||||
|
||||
const resetRange = (name: WaterArea) => {
|
||||
const reading = observationsByArea().get(name);
|
||||
const rounded = reading?.value == null ? '' : Math.round(reading.value).toString();
|
||||
setRanges(current => ({ ...current, [name]: { min: rounded, max: rounded } }));
|
||||
setOverrides(current => {
|
||||
const next = new Set(current);
|
||||
next.delete(name);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const loadBackground = () => {
|
||||
@@ -84,31 +127,47 @@ export function WaterTemperature() {
|
||||
|
||||
return <section class="pageWorkspace waterPage">
|
||||
<div class="pageHeading waterHeading">
|
||||
<div><span class="eyebrow">ĒTERA GRAFIKA</span><h1>Ūdens temperatūra</h1><p>Manuāli ievadi temperatūras diapazonus un sagatavo karti eksportam.</p></div>
|
||||
<div><span class="eyebrow">ĒTERA GRAFIKA</span><h1>Ūdens temperatūra</h1><p>Automātiski ielādētas temperatūras, ar iespēju tās manuāli labot pirms eksporta.</p></div>
|
||||
<span class="selectionCount">{complete() ? '6 diapazoni aizpildīti' : `${areaNames.filter(name => ranges()[name].min && ranges()[name].max).length} no 6 aizpildīti`}</span>
|
||||
</div>
|
||||
|
||||
<section class="waterDataBand">
|
||||
<div class="waterStatus">
|
||||
<div><span class="stepLabel">1. Jaunākie ūdens temperatūras dati</span><strong>{newestTimestamp() ? newestTimestamp()!.toLocaleString('lv-LV') : 'Gaida novērojumus'}</strong><small>LVĢMC hidrometeoroloģiskie novērojumi</small></div>
|
||||
<button class="secondary" disabled={latest.loading} onClick={() => refetch()}>Atjaunot datus</button>
|
||||
</div>
|
||||
<Show when={latest.loading}><LoadingSpinner text="Ielādē ūdens temperatūras"/></Show>
|
||||
<Show when={latest.error}><div class="inlineNotice error"><strong>Novērojumus neizdevās ielādēt.</strong> Manuālie lauki joprojām ir pieejami.</div></Show>
|
||||
</section>
|
||||
|
||||
<section class="waterSetup" aria-labelledby="water-settings">
|
||||
<div class="waterOutput">
|
||||
<h2 id="water-settings">1. Izvēlies izmēru</h2>
|
||||
<h2 id="water-settings">2. Izvēlies izmēru</h2>
|
||||
<div class="resolutionChoices">
|
||||
<button classList={{ active: output() === '1920x1080' }} onClick={() => setOutput('1920x1080')}>1920 × 1080</button>
|
||||
<button classList={{ active: output() === '3840x1440' }} onClick={() => setOutput('3840x1440')}>3840 × 1440</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="waterFields">
|
||||
<h2>2. Ievadi temperatūras</h2>
|
||||
<div class="rangeGrid"><For each={areaNames}>{name => <fieldset class="rangeField">
|
||||
<h2>3. Ievadi temperatūras</h2>
|
||||
<div class="rangeGrid"><For each={areaNames}>{name => {
|
||||
const observation = () => observationsByArea().get(name);
|
||||
return <fieldset class="rangeField" classList={{ manual: overrides().has(name), missing: observation()?.value == null }}>
|
||||
<legend>{name}</legend>
|
||||
<div class="rangeFieldMeta">
|
||||
<span>{overrides().has(name) ? 'Manuāli' : observation()?.observedAt ? new Date(observation()!.observedAt).toLocaleTimeString('lv-LV', { hour: '2-digit', minute: '2-digit' }) : 'Nav datu'}</span>
|
||||
<button type="button" class="resetTemperature" disabled={!overrides().has(name)} onClick={() => resetRange(name)}>Atiestatīt</button>
|
||||
</div>
|
||||
<label><span>No</span><input type="number" step="1" inputmode="numeric" value={ranges()[name].min} onInput={event => updateRange(name, 'min', event.currentTarget.value)} /></label>
|
||||
<span class="rangeSeparator">...</span>
|
||||
<label><span>Līdz</span><input type="number" step="1" inputmode="numeric" value={ranges()[name].max} onInput={event => updateRange(name, 'max', event.currentTarget.value)} /></label>
|
||||
</fieldset>}</For></div>
|
||||
</fieldset>;
|
||||
}}</For></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="waterPreview">
|
||||
<div class="mapToolbar"><div><strong>3. Kartes priekšskatījums</strong><span>{dimensions().width} × {dimensions().height} px eksports</span></div><button class="primary" disabled={!complete()} onClick={downloadPng}>Lejupielādēt PNG</button></div>
|
||||
<div class="mapToolbar"><div><strong>4. Kartes priekšskatījums</strong><span>{dimensions().width} × {dimensions().height} px eksports</span></div><button class="primary" disabled={!complete()} onClick={downloadPng}>Lejupielādēt PNG</button></div>
|
||||
{!complete() && <p class="waterNotice">Aizpildi visus sešus diapazonus, lai lejupielādētu gatavo karti.</p>}
|
||||
<div class="canvasFrame"><canvas ref={canvas} width={dimensions().width} height={dimensions().height} /></div>
|
||||
</section>
|
||||
|
||||
@@ -1 +1 @@
|
||||
.waterPage{max-width:1900px}.waterHeading{margin-bottom:18px}.waterSetup{display:grid;grid-template-columns:260px minmax(0,1fr);gap:28px;padding:18px 0 24px;border-top:1px solid var(--border);border-bottom:1px solid var(--border)}.waterSetup h2{margin:0 0 14px;font-size:20px}.resolutionChoices{display:grid;gap:9px}.resolutionChoices button{text-align:left}.resolutionChoices button.active{border-color:var(--accent);background:var(--accent-pale);color:var(--accent-strong);box-shadow:inset 0 0 0 1px var(--accent)}.rangeGrid{display:grid;grid-template-columns:repeat(3,minmax(250px,1fr));gap:12px}.rangeField{display:grid;grid-template-columns:1fr auto 1fr;gap:8px;align-items:end;margin:0;padding:10px 12px 12px;border:1px solid var(--border);border-radius:12px;background:rgba(255,255,255,.46)}.rangeField legend{padding:0 5px;font-weight:700}.rangeField label{display:grid;gap:4px}.rangeField label span{font-size:11px;color:var(--text-muted)}.rangeField input{width:100%;font-size:18px;font-weight:700}.rangeSeparator{padding-bottom:9px;font-weight:700}.waterPreview{padding-top:24px}.waterNotice{margin:0 0 10px;color:var(--text-muted);font-size:13px}.waterPreview canvas{display:block;width:100%;height:auto}@media(max-width:1050px){.waterSetup{grid-template-columns:1fr}.resolutionChoices{grid-template-columns:repeat(2,minmax(0,1fr))}.rangeGrid{grid-template-columns:repeat(2,minmax(220px,1fr))}}@media(max-width:650px){.rangeGrid,.resolutionChoices{grid-template-columns:1fr}}
|
||||
.waterPage{max-width:1900px}.waterHeading{margin-bottom:18px}.waterSetup{display:grid;grid-template-columns:260px minmax(0,1fr);gap:28px;padding:18px 0 24px;border-top:1px solid var(--border);border-bottom:1px solid var(--border)}.waterSetup h2{margin:0 0 14px;font-size:20px}.resolutionChoices{display:grid;gap:9px}.resolutionChoices button{text-align:left}.resolutionChoices button.active{border-color:var(--accent);background:var(--accent-pale);color:var(--accent-strong);box-shadow:inset 0 0 0 1px var(--accent)}.rangeGrid{display:grid;grid-template-columns:repeat(3,minmax(250px,1fr));gap:12px}.rangeField{display:grid;grid-template-columns:1fr auto 1fr;gap:8px;align-items:end;margin:0;padding:10px 12px 12px;border:1px solid var(--border);border-radius:12px;background:rgba(255,255,255,.46);transition:border-color 180ms,background 180ms}.rangeField.manual{border-color:rgba(36,119,197,.58);background:rgba(229,241,255,.76)}.rangeField.missing{border-style:dashed}.rangeField legend{padding:0 5px;font-weight:700}.rangeFieldMeta{grid-column:1/-1;display:flex;align-items:center;justify-content:space-between;gap:8px;margin:-2px 0 2px}.rangeFieldMeta span{font-size:10px;color:var(--text-muted)}.rangeField label{display:grid;gap:4px}.rangeField label span{font-size:11px;color:var(--text-muted)}.rangeField input{width:100%;font-size:18px;font-weight:700}.rangeSeparator{padding-bottom:9px;font-weight:700}.waterDataBand{padding:2px 0 22px;border-bottom:1px solid rgba(90,126,166,.22)}.waterStatus{display:flex;align-items:center;justify-content:space-between;gap:24px}.waterStatus>div,.waterStatus strong,.waterStatus small{display:block}.waterStatus strong{margin-top:5px;font-size:18px}.waterStatus small{margin-top:3px;color:var(--text-muted)}.waterPreview{padding-top:24px}.waterNotice{margin:0 0 10px;color:var(--text-muted);font-size:13px}.waterPreview canvas{display:block;width:100%;height:auto}@media(max-width:1050px){.waterSetup{grid-template-columns:1fr}.resolutionChoices{grid-template-columns:repeat(2,minmax(0,1fr))}.rangeGrid{grid-template-columns:repeat(2,minmax(220px,1fr))}}@media(max-width:650px){.rangeGrid,.resolutionChoices{grid-template-columns:1fr}.waterStatus{align-items:flex-start;flex-direction:column}}
|
||||
|
||||
Reference in New Issue
Block a user