2026-08-22 21:26:13 +03:00
|
|
|
|
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';
|
2026-08-22 20:11:20 +03:00
|
|
|
|
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';
|
|
|
|
|
|
|
2026-08-22 21:26:13 +03:00
|
|
|
|
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}/>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-22 20:11:20 +03:00
|
|
|
|
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');
|
2026-08-22 20:31:04 +03:00
|
|
|
|
const [artwork, setArtwork] = createSignal<{ background: HTMLImageElement; border: HTMLImageElement }>();
|
2026-08-22 20:11:20 +03:00
|
|
|
|
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;
|
|
|
|
|
|
}));
|
2026-08-22 21:14:20 +03:00
|
|
|
|
const [selectedId, setSelectedId] = createSignal<string>();
|
|
|
|
|
|
const selectedWarning = createMemo(() => filteredWarnings().find(item => item.id === selectedId()));
|
2026-08-22 21:26:13 +03:00
|
|
|
|
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));
|
|
|
|
|
|
});
|
2026-08-22 20:11:20 +03:00
|
|
|
|
|
2026-08-22 20:31:04 +03:00
|
|
|
|
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;
|
2026-08-22 20:11:20 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-22 20:31:04 +03:00
|
|
|
|
createEffect(() => { output(); loadArtwork(); });
|
2026-08-22 21:14:20 +03:00
|
|
|
|
createEffect(() => {
|
|
|
|
|
|
const list = filteredWarnings();
|
|
|
|
|
|
if (!list.some(item => item.id === selectedId())) setSelectedId(list[0]?.id);
|
|
|
|
|
|
});
|
|
|
|
|
|
createEffect(() => {
|
|
|
|
|
|
const warning = selectedWarning();
|
|
|
|
|
|
setTitle(warning ? defaultTitle(warning.phenomenon) : '');
|
|
|
|
|
|
});
|
2026-08-22 20:11:20 +03:00
|
|
|
|
createEffect(() => {
|
2026-08-22 20:31:04 +03:00
|
|
|
|
const images = artwork();
|
2026-08-22 20:11:20 +03:00
|
|
|
|
const current = template();
|
2026-08-22 21:14:20 +03:00
|
|
|
|
const warning = selectedWarning();
|
2026-08-22 20:11:20 +03:00
|
|
|
|
const currentTitle = title();
|
2026-08-22 20:31:04 +03:00
|
|
|
|
if (!canvas || !images) return;
|
2026-08-22 20:11:20 +03:00
|
|
|
|
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(() =>
|
2026-08-22 21:14:20 +03:00
|
|
|
|
drawWarningMap(canvas.getContext('2d')!, images.background, images.border, current, warning, currentTitle),
|
2026-08-22 20:11:20 +03:00
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const downloadPng = () => canvas.toBlob(blob => blob && download(blob, `bridinajumi-${output()}.png`), 'image/png');
|
|
|
|
|
|
|
|
|
|
|
|
return <section class="pageWorkspace warningsPage">
|
|
|
|
|
|
<div class="pageHeading warningsHeading">
|
2026-08-22 21:14:20 +03:00
|
|
|
|
<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>
|
2026-08-22 20:11:20 +03:00
|
|
|
|
<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>
|
2026-08-22 21:14:20 +03:00
|
|
|
|
<label class="warningTitle"><span>Virsraksts</span><input type="text" value={title()} placeholder="Brīdinājums: vējš" onInput={event => setTitle(event.currentTarget.value)} /></label>
|
2026-08-22 20:11:20 +03:00
|
|
|
|
</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>
|
|
|
|
|
|
|
2026-08-22 21:26:13 +03:00
|
|
|
|
<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}/>
|
2026-08-22 21:14:20 +03:00
|
|
|
|
<strong>{warning.phenomenon || 'Brīdinājums'}</strong>
|
2026-08-22 21:26:13 +03:00
|
|
|
|
</button>
|
|
|
|
|
|
}</For></div></Show>
|
2026-08-22 20:11:20 +03:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2026-08-22 21:26:13 +03:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2026-08-22 20:11:20 +03:00
|
|
|
|
</section>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-22 21:14:20 +03:00
|
|
|
|
function defaultTitle(phenomenon: string) {
|
|
|
|
|
|
return phenomenon ? `Brīdinājums: ${phenomenon}` : '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-22 20:11:20 +03:00
|
|
|
|
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)}`;
|
|
|
|
|
|
}
|