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

198 lines
11 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, onCleanup, Show } from 'solid-js';
import { Portal } from 'solid-js/web';
import { AlertTriangle, CloudFog, CloudLightning, CloudRain, CloudSnow, Flame, ThermometerSnowflake, Waves, Wind, X } from 'lucide-solid';
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';
function phenomenonIcon(phenomenon: string) {
const normalized = (phenomenon || '').toLocaleLowerCase('lv-LV');
if (normalized.includes('vēj')) return Wind;
if (normalized.includes('pērkon') || normalized.includes('negais')) return CloudLightning;
if (normalized.includes('lietus') || normalized.includes('lietav')) return CloudRain;
if (normalized.includes('snieg')) return CloudSnow;
if (normalized.includes('migl')) return CloudFog;
if (normalized.includes('saln') || normalized.includes('sals')) return ThermometerSnowflake;
if (normalized.includes('karstum')) return Flame;
if (normalized.includes('plūd') || normalized.includes('ūdens')) return Waves;
return AlertTriangle;
}
function PhenomenonIcon(props: { phenomenon: string; size?: number }) {
const Icon = phenomenonIcon(props.phenomenon);
return <Icon size={props.size ?? 18} strokeWidth={1.8}/>;
}
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 [selectedId, setSelectedId] = createSignal<string>();
const selectedWarning = createMemo(() => filteredWarnings().find(item => item.id === selectedId()));
const [detailOpen, setDetailOpen] = createSignal(false);
const openWarning = (id: string) => { setSelectedId(id); setDetailOpen(true); };
const closeDetail = () => setDetailOpen(false);
createEffect(() => {
if (!detailOpen()) return;
const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') closeDetail(); };
document.addEventListener('keydown', onKeyDown);
onCleanup(() => document.removeEventListener('keydown', onKeyDown));
});
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 list = filteredWarnings();
if (!list.some(item => item.id === selectedId())) setSelectedId(list[0]?.id);
});
createEffect(() => {
const warning = selectedWarning();
setTitle(warning ? defaultTitle(warning.phenomenon) : '');
});
createEffect(() => {
const images = artwork();
const current = template();
const warning = selectedWarning();
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, warning, 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>Izvēlies vienu LVĢMC brīdinājumu, kas jārāda kartē; virsraksts aizpildās automātiski.</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: vējš" 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 =>
<button type="button" class={`warningCard ${warningSeverity(warning.intensity)}`} classList={{ active: warning.id === selectedId() }} aria-pressed={warning.id === selectedId()} onClick={() => openWarning(warning.id)}>
<PhenomenonIcon phenomenon={warning.phenomenon}/>
<strong>{warning.phenomenon || 'Brīdinājums'}</strong>
</button>
}</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>
<Show when={detailOpen() && selectedWarning()}>{warning =>
<Portal>
<div class="warningBackdrop" onClick={closeDetail}>
<div class="warningModal" onClick={event => event.stopPropagation()}>
<button type="button" class="warningModalClose" aria-label="Aizvērt" onClick={closeDetail}><X size={20}/></button>
<div class={`warningModalHeader ${warningSeverity(warning().intensity)}`}>
<PhenomenonIcon phenomenon={warning().phenomenon} size={30}/>
<div><strong>{warning().phenomenon || 'Brīdinājums'}</strong><span>{warning().intensity} · {warning().regions}</span></div>
</div>
<p class="warningModalPeriod">{formatPeriod(warning().validFrom, warning().validUntil)}</p>
<Show when={warning().description}><p>{warning().description}</p></Show>
<Show when={warning().risks}><p class="warningRisks">{warning().risks}</p></Show>
</div>
</div>
</Portal>
}</Show>
</section>;
}
function defaultTitle(phenomenon: string) {
return phenomenon ? `Brīdinājums: ${phenomenon}` : '';
}
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)}`;
}