Files
WeatherTool/web/src/pages/warnings/Warnings.tsx
T

132 lines
7.7 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, createMemo, createResource, createSignal, For, Show } from 'solid-js';
import { apiHost } from '../../consts';
import { download } from '../../helpers/download';
import { LoadingSpinner } from '../../components/spinner/LoadingSpinner';
import { drawWarningMap, type WeatherWarning, warningSeverity } from './warningDraw';
import { warningTemplates, type WarningOutput } from './warningTemplates';
import './warnings.css';
type WarningResponse = { source: string; fetchedAt: string; warnings: WeatherWarning[] };
const fetchWarnings = async (): Promise<WarningResponse> => {
const response = await fetch(`${apiHost}/api/warnings`);
if (!response.ok) throw new Error(`Warnings failed (${response.status})`);
return response.json();
};
export function Warnings() {
const [output, setOutput] = createSignal<WarningOutput>('3840x1440');
const [title, setTitle] = createSignal('');
const [selectedDate, setSelectedDate] = createSignal('all');
const [phenomenon, setPhenomenon] = createSignal('all');
const [artwork, setArtwork] = createSignal<{ background: HTMLImageElement; border: HTMLImageElement }>();
const [data, { refetch }] = createResource(fetchWarnings);
let canvas!: HTMLCanvasElement;
const template = () => warningTemplates[output()];
const loadedWarnings = () => data.error ? [] : data()?.warnings ?? [];
const availableDates = createMemo(() => warningDates(loadedWarnings()));
const phenomena = createMemo(() => [...new Set(loadedWarnings().map(item => item.phenomenon).filter(Boolean))].sort((a, b) => a.localeCompare(b, 'lv')));
const filteredWarnings = createMemo(() => loadedWarnings().filter(warning => {
const dateMatches = selectedDate() === 'all' || activeOnDate(warning, selectedDate());
const phenomenonMatches = phenomenon() === 'all' || warning.phenomenon === phenomenon();
return dateMatches && phenomenonMatches;
}));
const loadArtwork = () => {
const current = template();
setArtwork(undefined);
const background = new Image();
const border = new Image();
let loaded = 0;
const finish = () => {
loaded += 1;
if (loaded === 2 && template() === current) setArtwork({ background, border });
};
background.onload = finish;
border.onload = finish;
background.src = current.map;
border.src = current.border;
};
createEffect(() => { output(); loadArtwork(); });
createEffect(() => {
const images = artwork();
const current = template();
const warnings = filteredWarnings();
const currentTitle = title();
if (!canvas || !images) return;
if (canvas.width !== current.width) canvas.width = current.width;
if (canvas.height !== current.height) canvas.height = current.height;
void document.fonts.load(`700 ${current.title.fontSize}px Monda`).then(() =>
drawWarningMap(canvas.getContext('2d')!, images.background, images.border, current, warnings, currentTitle),
);
});
const downloadPng = () => canvas.toBlob(blob => blob && download(blob, `bridinajumi-${output()}.png`), 'image/png');
return <section class="pageWorkspace warningsPage">
<div class="pageHeading warningsHeading">
<div><span class="eyebrow">ĒTERA GRAFIKA</span><h1>Brīdinājumi</h1><p>Aktuālie LVĢMC brīdinājumu poligoni un manuāli ievadāms virsraksts.</p></div>
<span class="selectionCount">{filteredWarnings().length} {filteredWarnings().length === 1 ? 'brīdinājums' : 'brīdinājumi'}</span>
</div>
<section class="warningsSetup" aria-labelledby="warnings-settings">
<div><h2 id="warnings-settings">1. 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="warningFilters"><h2>2. Izvēlies brīdinājumu</h2><div class="warningFilterGrid">
<label><span>Diena</span><select value={selectedDate()} onChange={event => setSelectedDate(event.currentTarget.value)}><option value="all">Viss periods</option><For each={availableDates()}>{date => <option value={date}>{formatDate(date)}</option>}</For></select></label>
<label><span>Parādība</span><select value={phenomenon()} onChange={event => setPhenomenon(event.currentTarget.value)}><option value="all">Visas parādības</option><For each={phenomena()}>{item => <option value={item}>{item}</option>}</For></select></label>
<label class="warningTitle"><span>Virsraksts</span><input type="text" value={title()} placeholder="BRĪDINĀJUMS PAR STIPRU VĒJU" onInput={event => setTitle(event.currentTarget.value)} /></label>
</div></div>
</section>
<Show when={data.loading}><LoadingSpinner text="Ielādē LVĢMC brīdinājumus"/></Show>
<Show when={data.error}><div class="inlineNotice error"><strong>Brīdinājumu datus neizdevās ielādēt.</strong><button class="secondary" onClick={() => refetch()}>Mēģināt vēlreiz</button></div></Show>
<Show when={!data.loading && !data.error && filteredWarnings().length === 0}><div class="inlineNotice">Izvēlētajam periodam nav aktīvu brīdinājumu. Karti joprojām var priekšskatīt, bet tajā nebūs iekrāsotu teritoriju.</div></Show>
<Show when={filteredWarnings().length > 0}><div class="warningSummary"><For each={filteredWarnings()}>{warning => <article class={`warningCard ${warningSeverity(warning.intensity)}`}><strong>{warning.phenomenon || 'Brīdinājums'}</strong><span>{warning.intensity} · {warning.regions}</span><small>{formatPeriod(warning.validFrom, warning.validUntil)}</small></article>}</For></div></Show>
<section class="warningsPreview"><div class="mapToolbar"><div><strong>3. Kartes priekšskatījums</strong><span>{template().width} × {template().height} px eksports</span></div><button class="primary" disabled={data.loading || Boolean(data.error)} onClick={downloadPng}>Lejupielādēt PNG</button></div><div class="canvasFrame"><canvas ref={canvas} width={template().width} height={template().height}/></div></section>
</section>;
}
function datePart(value: string) {
const match = value.trim().match(/^(\d{4}-\d{2}-\d{2})/);
return match?.[1];
}
function warningDates(warnings: WeatherWarning[]) {
const dates = new Set<string>();
warnings.forEach(warning => {
const from = datePart(warning.validFrom);
const until = datePart(warning.validUntil);
if (!from || !until) return;
const cursor = new Date(`${from}T12:00:00Z`);
const last = new Date(`${until}T12:00:00Z`);
while (cursor.getTime() <= last.getTime()) {
dates.add(cursor.toISOString().slice(0, 10));
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
});
return [...dates].sort();
}
function activeOnDate(warning: WeatherWarning, date: string) {
const from = datePart(warning.validFrom);
const until = datePart(warning.validUntil);
if (!from || !until) return false;
return from <= date && until >= date;
}
function formatDate(value: string) {
return new Intl.DateTimeFormat('lv-LV', { timeZone: 'Europe/Riga', weekday: 'short', day: '2-digit', month: '2-digit' }).format(new Date(`${value}T12:00:00Z`));
}
function formatPeriod(fromValue: string, untilValue: string) {
const compact = (value: string) => value.trim().replace(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}).*$/, '$3.$2. $4:$5');
return `${compact(fromValue)}${compact(untilValue)}`;
}