Add LVGMC warning map workflow

This commit is contained in:
b0txec
2026-08-22 20:11:20 +03:00
parent 218ca2fa85
commit bc5bee171c
14 changed files with 538 additions and 19 deletions
+4 -1
View File
@@ -1,6 +1,6 @@
import { Component, createSignal, onCleanup, onMount, ParentComponent } from 'solid-js'
import { A, Route, Router, useLocation } from '@solidjs/router'
import { Archive, Database as DatabaseIcon, Map, Menu, Wind, X } from 'lucide-solid'
import { AlertTriangle, Archive, Database as DatabaseIcon, Map, Menu, Wind, X } from 'lucide-solid'
import styles from './css/menu.module.css';
import { Country } from './pages/country/Country';
@@ -13,6 +13,7 @@ import { LvgmcForecast } from './pages/lvgmc-forecast/LvgmcForecast'
import { Home } from './pages/home/Home'
import { Faktiska } from './pages/map-graphics/MapGraphics'
import { WaterTemperature } from './pages/water-temperature/WaterTemperature'
import { Warnings } from './pages/warnings/Warnings'
console.log("env:", import.meta.env.MODE)
console.log("api host:", apiHost)
@@ -27,6 +28,7 @@ const App: Component = () => {
<Route path="/cities" component={Cities} />
<Route path="/faktiska" component={Faktiska} />
<Route path="/udens-temperatura" component={WaterTemperature} />
<Route path="/bridinajumi" component={Warnings} />
<Route path="/latvia" component={Country} />
<Route path="/database" component={Database} />
</Router>
@@ -46,6 +48,7 @@ const AppLayout: ParentComponent = (props) => {
{path: '/database', label: 'Arhīvs', icon: Archive},
{path: '/harmonie', label: 'Harmonie', icon: Wind},
{path: '/lvgmc-forecast', label: 'LVĢMC', icon: DatabaseIcon},
{path: '/bridinajumi', label: 'Brīdinājumi', icon: AlertTriangle},
]
const isSecondaryPage = () => secondaryPages.some(page => location.pathname === page.path)
onMount(() => {
+2 -1
View File
@@ -1,5 +1,5 @@
import { A } from '@solidjs/router'
import { Archive, BarChart3, CloudSun, Database, Map, RadioTower, Waves, Wind } from 'lucide-solid'
import { AlertTriangle, Archive, BarChart3, CloudSun, Database, Map, RadioTower, Waves, Wind } from 'lucide-solid'
import './home.css'
const tools = [
@@ -7,6 +7,7 @@ const tools = [
{path:'/cities', title:'Kartes', description:'Vērtību salīdzināšana un temperatūras kartes.', icon:BarChart3},
{path:'/faktiska', title:'Faktiskā', description:'Temperatūras, laikapstākļu simboli un vējš.', icon:CloudSun},
{path:'/udens-temperatura', title:'Ūdens', description:'Ūdens temperatūru diapazoni ētera kartei.', icon:Waves},
{path:'/bridinajumi', title:'Brīdinājumi', description:'Aktuālie LVĢMC brīdinājumi ētera kartei.', icon:AlertTriangle},
{path:'/latvia', title:'Apskats', description:'Latvijas laikapstākļu kopsavilkums.', icon:Map},
{path:'/database', title:'Arhīvs', description:'Datumi un saglabātie novērojumi.', icon:Archive},
{path:'/harmonie', title:'Harmonie', description:'Lokāli saglabāto prognožu lauku kartes.', icon:Wind},
+122
View File
@@ -0,0 +1,122 @@
import { createEffect, createMemo, createResource, createSignal, For, onMount, 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 [background, setBackground] = createSignal<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 loadBackground = () => {
const image = new Image();
image.onload = () => setBackground(image);
image.src = template().map;
};
onMount(loadBackground);
createEffect(() => { output(); loadBackground(); });
createEffect(() => {
const image = background();
const current = template();
const warnings = filteredWarnings();
const currentTitle = title();
if (!canvas || !image) 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')!, image, 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)}`;
}
+129
View File
@@ -0,0 +1,129 @@
import type { WarningTemplate } from './warningTemplates';
export type WarningPoint = { lat: number; lon: number };
export type WarningPolygon = { id: string; points: WarningPoint[] };
export type WeatherWarning = {
id: string;
number: string;
intensity: string;
phenomenon: string;
regions: string;
validFrom: string;
validUntil: string;
description: string;
risks: string;
polygons: WarningPolygon[];
};
const severityStyles = {
yellow: { fill: 'rgba(255, 214, 0, .76)', stroke: '#6d5b00', label: 'DZELTENS' },
orange: { fill: 'rgba(242, 126, 32, .78)', stroke: '#713300', label: 'ORANŽS' },
red: { fill: 'rgba(204, 28, 32, .80)', stroke: '#650006', label: 'SARKANS' },
} as const;
export type Severity = keyof typeof severityStyles;
export function warningSeverity(value: string): Severity {
const normalized = value.toLocaleLowerCase('lv-LV');
if (normalized.includes('sarkan') || normalized.includes('red')) return 'red';
if (normalized.includes('oranž') || normalized.includes('orange')) return 'orange';
return 'yellow';
}
export function drawWarningMap(
ctx: CanvasRenderingContext2D,
background: HTMLImageElement,
template: WarningTemplate,
warnings: WeatherWarning[],
title: string,
) {
ctx.clearRect(0, 0, template.width, template.height);
ctx.drawImage(background, 0, 0, template.width, template.height);
[...warnings]
.sort((a, b) => severityRank(warningSeverity(a.intensity)) - severityRank(warningSeverity(b.intensity)))
.forEach(warning => drawWarning(ctx, template, warning));
drawTitle(ctx, template, title);
drawLegend(ctx, template, warnings);
}
function drawWarning(ctx: CanvasRenderingContext2D, template: WarningTemplate, warning: WeatherWarning) {
const severity = warningSeverity(warning.intensity);
const style = severityStyles[severity];
warning.polygons.forEach(polygon => {
if (polygon.points.length < 3) return;
ctx.beginPath();
polygon.points.forEach((point, index) => {
const projected = project(template, point);
if (index === 0) ctx.moveTo(projected.x, projected.y);
else ctx.lineTo(projected.x, projected.y);
});
ctx.closePath();
ctx.fillStyle = style.fill;
ctx.fill();
ctx.strokeStyle = style.stroke;
ctx.lineWidth = template.width === 1920 ? 3 : 5;
ctx.lineJoin = 'round';
ctx.stroke();
});
}
function project(template: WarningTemplate, point: WarningPoint) {
const { geo } = template;
return {
x: geo.left + (point.lon - geo.west) / (geo.east - geo.west) * (geo.right - geo.left),
y: geo.top + (geo.north - point.lat) / (geo.north - geo.south) * (geo.bottom - geo.top),
};
}
function drawTitle(ctx: CanvasRenderingContext2D, template: WarningTemplate, value: string) {
const text = value.trim().toLocaleUpperCase('lv-LV');
if (!text) return;
const layout = template.title;
let fontSize = layout.fontSize;
ctx.font = `700 ${fontSize}px Monda`;
let width = ctx.measureText(text).width + layout.horizontalPadding * 2;
if (width > layout.maximumWidth) {
fontSize *= layout.maximumWidth / width;
ctx.font = `700 ${fontSize}px Monda`;
width = layout.maximumWidth;
}
const x = template.width - layout.right - width;
ctx.fillStyle = '#9b0008';
ctx.fillRect(x, layout.top, width, layout.height);
ctx.fillStyle = '#fff';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(text, x + layout.horizontalPadding, layout.top + layout.height / 2);
}
function drawLegend(ctx: CanvasRenderingContext2D, template: WarningTemplate, warnings: WeatherWarning[]) {
const severities = [...new Set(warnings.map(item => warningSeverity(item.intensity)))]
.sort((a, b) => severityRank(b) - severityRank(a));
if (!severities.length) return;
const layout = template.legend;
ctx.font = `700 ${layout.fontSize}px Monda`;
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
severities.forEach((severity, index) => {
const style = severityStyles[severity];
const y = template.height - layout.bottom - index * (layout.swatch + layout.gap);
const labelWidth = ctx.measureText(style.label).width;
const swatchX = template.width - layout.right - labelWidth - layout.gap - layout.swatch;
ctx.fillStyle = style.fill;
ctx.fillRect(swatchX, y - layout.swatch / 2, layout.swatch, layout.swatch);
ctx.strokeStyle = style.stroke;
ctx.lineWidth = template.width === 1920 ? 2 : 3;
ctx.strokeRect(swatchX, y - layout.swatch / 2, layout.swatch, layout.swatch);
ctx.fillStyle = '#fff';
ctx.shadowColor = 'rgba(0,0,0,.85)';
ctx.shadowBlur = template.width === 1920 ? 4 : 7;
ctx.fillText(style.label, template.width - layout.right, y);
ctx.shadowBlur = 0;
});
}
function severityRank(severity: Severity) {
return severity === 'red' ? 3 : severity === 'orange' ? 2 : 1;
}
@@ -0,0 +1,34 @@
import map1920 from '../../assets/map_1920x1080.webp';
import map3840 from '../../assets/map_3840x1440.webp';
export type WarningOutput = '1920x1080' | '3840x1440';
export type WarningTemplate = {
width: number;
height: number;
map: string;
geo: { west: number; east: number; north: number; south: number; left: number; right: number; top: number; bottom: number };
title: { right: number; top: number; height: number; fontSize: number; horizontalPadding: number; maximumWidth: number };
legend: { right: number; bottom: number; swatch: number; gap: number; fontSize: number };
};
// The two newsroom canvases have independent crops. These pixel bounds align
// Latvia's geographic extent to the white country outline in each source map.
export const warningTemplates: Record<WarningOutput, WarningTemplate> = {
'1920x1080': {
width: 1920,
height: 1080,
map: map1920,
geo: { west: 20.97, east: 28.24, north: 58.09, south: 55.67, left: 360, right: 1655, top: 115, bottom: 864 },
title: { right: 88, top: 38, height: 62, fontSize: 30, horizontalPadding: 24, maximumWidth: 900 },
legend: { right: 88, bottom: 58, swatch: 27, gap: 14, fontSize: 20 },
},
'3840x1440': {
width: 3840,
height: 1440,
map: map3840,
geo: { west: 20.97, east: 28.24, north: 58.09, south: 55.67, left: 985, right: 3150, top: 80, bottom: 1400 },
title: { right: 376, top: 76, height: 124, fontSize: 60, horizontalPadding: 48, maximumWidth: 1700 },
legend: { right: 376, bottom: 76, swatch: 42, gap: 22, fontSize: 30 },
},
};
+1
View File
@@ -0,0 +1 @@
.warningsPage{max-width:1900px}.warningsHeading{margin-bottom:18px}.warningsSetup{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)}.warningsSetup h2{margin:0 0 14px;font-size:20px}.warningFilterGrid{display:grid;grid-template-columns:220px 260px minmax(320px,1fr);gap:12px}.warningFilterGrid label{display:grid;gap:5px}.warningFilterGrid label span{font-size:12px;color:var(--text-muted)}.warningTitle input{width:100%}.warningSummary{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:10px;margin:18px 0}.warningCard{display:grid;gap:3px;padding:12px 14px;border:1px solid var(--border);border-left-width:7px;border-radius:10px;background:rgba(255,255,255,.6)}.warningCard.yellow{border-left-color:#ffd600}.warningCard.orange{border-left-color:#f27e20}.warningCard.red{border-left-color:#cc1c20}.warningCard span,.warningCard small{color:var(--text-muted)}.warningsPreview{padding-top:24px}.warningsPreview canvas{display:block;width:100%;height:auto}.inlineNotice button{margin-left:14px}@media(max-width:1100px){.warningsSetup{grid-template-columns:1fr}.warningFilterGrid{grid-template-columns:repeat(2,minmax(220px,1fr))}.warningTitle{grid-column:1/-1}}@media(max-width:650px){.warningFilterGrid,.resolutionChoices{grid-template-columns:1fr}.warningTitle{grid-column:auto}}