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
+7 -1
View File
@@ -5,6 +5,12 @@ Tool for downloading historical and real-time data from weather stations in Latv
## Setup
Rename `.env-sample` to `.env` and fill in credentials
## Environment boundary
- **Windows:** source editing, review, and Git operations only. Do not compile, build, run containers, install project dependencies, or test the application here.
- **Rocky Linux:** the only development runtime; compile, build, run, seed, and test WeatherTool here.
- **Ubuntu VPS:** deployment target only. It receives the reviewed release artifact built and verified on Rocky; it is not a development or build host.
## Run
```
docker-compose up --build --no-cache --force-recreate
@@ -40,7 +46,7 @@ server {
}
```
## Local development
## Rocky development runtime
```
// 1st terminal
podman-compose up postgres
+10 -7
View File
@@ -4,9 +4,9 @@
| Environment | Purpose | Address |
|---|---|---|
| Windows | Source editing and Git workflow only | Not applicable |
| Rocky Linux | Docker development and production-like staging | `http://192.168.1.101:9190` |
| Temporary VPS | Month-long user-acceptance staging behind Cloudflare, Nginx, and Authelia | Public UAT operational at `https://laikapstak.li` with temporary synthetic data |
| Windows | Source editing, review, and Git operations only; no compile, build, runtime, dependency installation, or application testing | Not applicable |
| Rocky Linux | Sole development runtime: compile, build, run, seed, test, and production-like staging | `http://192.168.1.101:9190` |
| Ubuntu VPS | Deployment target and month-long user-acceptance environment; never a development or build host | Public UAT operational at `https://laikapstak.li` with temporary synthetic data |
| Workplace production | Out of scope until reviewed and approved | Not documented here |
Rocky checkout:
@@ -36,11 +36,14 @@ codex/staging-baseline
- Do not change ownership or permissions of the container-managed `postgres/` directory as a deployment workaround.
- Do not use the current staging procedure as an undocumented workplace-production procedure.
## Windows development
## Windows source workspace
Use Windows to edit and review source files and to create Git commits. Docker builds,
the running application, synthetic-data operations, and browser testing belong on
Rocky. Docker Desktop is not part of the normal project workflow.
Use Windows only to edit and review source files and perform Git operations. Do not
run Node/npm dependency installation, TypeScript checks, Vite builds, SBT/Scala
compilation, Docker/Podman commands, the application, synthetic-data operations,
or browser application tests on Windows. All compile, build, development-runtime,
and test work belongs on Rocky. The Ubuntu VPS only receives a release artifact
that has already been built and verified on Rocky.
## Commit and transfer workflow
+14
View File
@@ -108,6 +108,20 @@ The renderer uses canvas `TextMetrics` visible-glyph bounds (`actualBoundingBoxL
Implementation status: both native exports are implemented and visually validated against the supplied production templates. Later newsroom feedback can still tune the per-template rectangles without changing the manual-entry workflow.
## Brīdinājumi
Purpose: prepare a newsroom map from the current LVĢMC open hydrometeorological-warning dataset without tracing warning areas manually.
Operator flow:
1. Open **Brīdinājumi**; warning metadata and ordered polygon coordinates load through the WeatherTool backend.
2. Select the independent 1920×1080 or 3840×1440 production canvas.
3. Optionally filter the current warning period by day and phenomenon.
4. Enter the editorial title. Warning geometry, severity colors, legend, source data, and validity information remain data-driven.
5. Preview and download the native-resolution PNG.
The two exports use separate geographic-to-pixel calibration and layout values. Neither output is produced by resizing the other. Upstream times are interpreted in the `Europe/Riga` timezone. Empty warning data is a valid state and produces an uncolored base map.
## Apskats
Purpose: aggregate selected fields across Latvia for a selected time range.
+2 -2
View File
@@ -4,7 +4,7 @@ This directory contains the working documentation for the WeatherTool modernizat
## Current status
- Development source is edited and committed on Windows; Docker builds and runtime testing are performed on Rocky.
- Windows is restricted to source editing, review, and Git operations. Rocky is the sole compile, build, development-runtime, and test environment. The Ubuntu VPS is a deployment target only.
- A production-like staging copy runs through Docker Compose on Rocky Linux at `http://192.168.1.101:9190`.
- Staging uses deterministic synthetic observations for the repository station set plus Valmiera, required by Faktiskā.
- Scheduled external-provider jobs are disabled in development and staging.
@@ -26,7 +26,7 @@ This directory contains the working documentation for the WeatherTool modernizat
## Documents
- [Architecture and data flow](ARCHITECTURE.md) — components, data sources, data flow, and repository layout.
- [Development and staging](DEVELOPMENT_AND_STAGING.md) — Git workflow, local build, Rocky deployment, synthetic data, verification, and rollback.
- [Development and staging](DEVELOPMENT_AND_STAGING.md) — Windows source/Git workflow, Rocky development and verification, VPS deployment, synthetic data, and rollback.
- [Product workflows](PRODUCT_WORKFLOWS.md) — the intended purpose and current status of each visible workspace.
- [Update roadmap](UPDATE_ROADMAP.md) — phased technical, security, dependency, testing, and UI work.
- [Third-party notices](THIRD_PARTY_NOTICES.md) — licenses and attribution for adapted interface components.
+3 -3
View File
@@ -4,10 +4,10 @@ This document tracks proposed WeatherTool improvements. Work should be delivered
## Environments and workflow
- **Windows development:** source editing and Git workflow only; Docker runtime testing is performed on Rocky.
- **Rocky staging:** production-like Docker deployment at `http://192.168.1.101:9190`.
- **Windows source workspace:** source editing, review, and Git operations only; do not install dependencies, compile, build, run, or test here.
- **Rocky development and staging:** the sole compile, build, development-runtime, and test environment, with production-like Docker staging at `http://192.168.1.101:9190`.
- **Git over SSH:** Windows pushes reviewed commits to a private bare repository on Rocky; the Rocky staging checkout pulls those commits and rebuilds.
- **Temporary VPS staging:** release `ef64895` is publicly operational behind Cloudflare strict TLS, Nginx, and Authelia with a limited 14-day synthetic dataset.
- **Ubuntu VPS deployment:** release `ef64895` is publicly operational behind Cloudflare strict TLS, Nginx, and Authelia with a limited 14-day synthetic dataset; the VPS does not compile or build the project.
- **Workplace production:** remains separate until changes are reviewed, tested, and explicitly approved for workplace use.
Do not synchronize `.env`, database directories, generated dependencies, build output, or provider credentials between machines.
+3 -1
View File
@@ -6,6 +6,7 @@ import db.{DBConnection, PostgresService}
import fetch.csv.{FileNameService}
import fetch.dmi
import fetch.lvgmc
import fetch.warnings.WarningService
import scheduler.Scheduler
import server.Server
@@ -24,6 +25,7 @@ object Main extends IOApp {
scheduler <- Scheduler.of
dataService <- DataService.of
fetchLvgmcService <- lvgmc.FetchService.of
warningService <- WarningService.of
fetchWeatherStations = for {
@@ -40,7 +42,7 @@ object Main extends IOApp {
fetchGrib <- dmi.FetchService.of(dataService)
fetchGribTask = scheduler.scheduleTask("Fetch Grib", List(43), fetchGrib.fetchRecentForecasts()).compile.drain
server <- Server.of(postgresService, dataService, fetchLvgmcService)
server <- Server.of(postgresService, dataService, fetchLvgmcService, warningService)
serverTask = server.run
scheduledTasks =
@@ -0,0 +1,194 @@
package fetch.warnings
import cats.effect.{IO, Ref}
import cats.syntax.all._
import io.circe.{Json, parser}
import org.http4s.Uri
import org.http4s.client.Client
import org.http4s.ember.client.EmberClientBuilder
import org.typelevel.log4cats.Logger
import org.typelevel.log4cats.slf4j.Slf4jLogger
import java.time.{Duration, Instant}
final case class WarningPoint(lat: Double, lon: Double)
final case class WarningPolygon(id: String, points: Vector[WarningPoint])
final case class WeatherWarning(
id: String,
number: String,
intensity: String,
phenomenon: String,
regions: String,
validFrom: String,
validUntil: String,
description: String,
risks: String,
polygons: Vector[WarningPolygon]
)
final case class WarningResponse(
source: String,
fetchedAt: String,
warnings: Vector[WeatherWarning]
)
final class WarningService private (logger: Logger[IO], cache: Ref[IO, Option[(Instant, WarningResponse)]]) {
private val cacheLifetime = Duration.ofMinutes(5)
private val polygonPageSize = 32000
private val metadataUrl = sys.env.getOrElse(
"LVGMC_WARNINGS_METADATA_URL",
"https://data.gov.lv/dati/api/3/action/datastore_search?resource_id=59c111fb-8c9a-4a63-8284-0a64a2920681&limit=1000"
)
private val polygonsUrl = sys.env.getOrElse(
"LVGMC_WARNINGS_POLYGONS_URL",
"https://data.gov.lv/dati/api/3/action/datastore_search?resource_id=01dc7d3c-34e5-4cc3-8f1a-aaf022872a02"
)
def fetchWarnings: IO[WarningResponse] =
for {
now <- IO.delay(Instant.now)
cached <- cache.get
response <- cached match {
case Some((storedAt, value)) if Duration.between(storedAt, now).compareTo(cacheLifetime) < 0 =>
IO.pure(value)
case stale =>
fetchFresh.attempt.flatMap {
case Right(value) => cache.set(Some(now -> value)).as(value)
case Left(error) => stale match {
case Some((_, value)) => logger.warn(error)("Using stale LVĢMC warning data") *> IO.pure(value)
case None => IO.raiseError(error)
}
}
}
} yield response
private def fetchFresh: IO[WarningResponse] =
EmberClientBuilder.default[IO].build.use { client =>
for {
metadataUri <- Uri.fromString(metadataUrl).liftTo[IO]
polygonsBaseUri <- Uri.fromString(polygonsUrl).liftTo[IO]
metadataBody <- client.expect[String](metadataUri)
metadata <- parseRecords(metadataBody, "warning metadata")
eventIds = metadata.flatMap(record => stringValue(record, "WEATHER_WARNING_EV_ID")).distinct
polygonRows <- eventIds.traverse(eventId => fetchPolygonRows(client, polygonsBaseUri, eventId)).map(_.flatten)
polygons = buildPolygons(polygonRows)
warnings = metadata.flatMap(record => buildWarning(record, polygons))
.filter(_.polygons.nonEmpty)
_ <- logger.info(s"Loaded ${warnings.size} LVĢMC warnings")
} yield WarningResponse("LVĢMC", Instant.now.toString, warnings)
}
private def parseRecords(body: String, label: String): IO[Vector[Json]] =
parsePage(body, label).map(_._2)
private def parsePage(body: String, label: String): IO[(Int, Vector[Json])] =
IO.fromEither(
parser
.parse(body)
.leftMap(error => new RuntimeException(s"Unable to parse $label: ${error.message}"))
.flatMap { json =>
val result = json.hcursor.downField("result")
(for {
total <- result.downField("total").as[Int]
records <- result.downField("records").as[Vector[Json]]
} yield total -> records)
.leftMap(error => new RuntimeException(s"Invalid $label response: ${error.message}"))
}
)
private def fetchPolygonRows(
client: Client[IO],
baseUri: Uri,
eventId: String,
offset: Int = 0,
accumulated: Vector[Json] = Vector.empty
): IO[Vector[Json]] = {
val filterValue = eventId.toLongOption.map(Json.fromLong).getOrElse(Json.fromString(eventId))
val filters = Json.obj("WEATHER_WARNING_EV_ID" -> filterValue).noSpaces
val uri = baseUri
.withQueryParam("limit", polygonPageSize.toString)
.withQueryParam("offset", offset.toString)
.withQueryParam("sort", "_id asc")
.withQueryParam("filters", filters)
client.expect[String](uri).flatMap(parsePage(_, s"warning polygons for $eventId")).flatMap {
case (total, rows) =>
val allRows = accumulated ++ rows
if (allRows.size >= total || rows.isEmpty) IO.pure(allRows)
else fetchPolygonRows(client, baseUri, eventId, offset + rows.size, allRows)
}
}
private def buildPolygons(
rows: Vector[Json]
): Map[String, Vector[WarningPolygon]] =
rows
.flatMap { row =>
for {
eventId <- stringValue(row, "WEATHER_WARNING_EV_ID")
polygonId <- stringValue(row, "POLIGON_ID")
lat <- doubleValue(row, "LAT")
lon <- doubleValue(row, "LON")
} yield ((eventId, polygonId), intValue(row, "NPK").getOrElse(0), WarningPoint(lat, lon))
}
.groupBy(_._1)
.toVector
.groupBy(_._1._1)
.view
.mapValues { polygonGroups =>
polygonGroups
.map { case ((_, polygonId), points) =>
WarningPolygon(polygonId, points.sortBy(_._2).map(_._3))
}
.sortBy(_.id)
.toVector
}
.toMap
private def buildWarning(
record: Json,
polygons: Map[String, Vector[WarningPolygon]]
): Option[WeatherWarning] =
stringValue(record, "WEATHER_WARNING_EV_ID").map { eventId =>
WeatherWarning(
id = eventId,
number = stringValue(record, "WARNING_NO").getOrElse(""),
intensity = stringValue(record, "INTENSITY_LV")
.orElse(stringValue(record, "INTENSITY_EN"))
.getOrElse(""),
phenomenon = stringValue(record, "PARADIBA").getOrElse(""),
regions = stringValue(record, "REGIONS").getOrElse(""),
validFrom = stringValue(record, "TIME_FROM").getOrElse(""),
validUntil = stringValue(record, "TIME_TILL").getOrElse(""),
description = stringValue(record, "TEKSTS_LV").getOrElse(""),
risks = stringValue(record, "RISKS_LV").getOrElse(""),
polygons = polygons.getOrElse(eventId, Vector.empty)
)
}
private def field(json: Json, name: String): Option[Json] =
json.asObject.flatMap(_(name)).filterNot(_.isNull)
private def stringValue(json: Json, name: String): Option[String] =
field(json, name).flatMap(value =>
value.asString.orElse(value.asNumber.map(_.toString)).map(_.trim).filter(_.nonEmpty)
)
private def doubleValue(json: Json, name: String): Option[Double] =
field(json, name).flatMap(value =>
value.asNumber.map(_.toDouble).orElse(value.asString.flatMap(_.trim.toDoubleOption))
)
private def intValue(json: Json, name: String): Option[Int] =
field(json, name).flatMap(value =>
value.asNumber.flatMap(_.toInt).orElse(value.asString.flatMap(_.trim.toIntOption))
)
}
object WarningService {
def of: IO[WarningService] =
for {
logger <- Slf4jLogger.create[IO]
cache <- Ref.of[IO, Option[(Instant, WarningResponse)]](None)
} yield new WarningService(logger, cache)
}
+13 -3
View File
@@ -8,6 +8,7 @@ import db.PostgresService
import fetch.csv.FileNameService
//import fetch.csv.FetchService
import fetch.lvgmc.FetchService
import fetch.warnings.WarningService
import fs2.io.file.{Files, Path}
import server.ValidateRoutes.{AggFieldList, AggKey, CityList, DateTimeRange, Granularity, ValidateDate, ValidateDateTime, ValidateInt, ValidateMonths, ValidateZonedDateTime}
import org.http4s._
@@ -34,18 +35,26 @@ import scala.concurrent.duration.DurationInt
object Server {
def of(postgresService: PostgresService, dataService: DataService, fetch: FetchService): IO[Server] = {
def of(postgresService: PostgresService, dataService: DataService, fetch: FetchService, warnings: WarningService): IO[Server] = {
Slf4jLogger.create[IO].map {
new Server(postgresService, dataService, fetch, _)
new Server(postgresService, dataService, fetch, warnings, _)
}
}
}
class Server(postgresService: PostgresService, dataService: DataService, fetch: FetchService, log: Logger[IO]) {
class Server(postgresService: PostgresService, dataService: DataService, fetch: FetchService, warnings: WarningService, log: Logger[IO]) {
private case class ResponseWrapper(result: Map[String, Option[Aggregate.AggregateValue]], query: UserQuery)
private case class LatestTemperature(city: String, observedAt: String, value: Option[Double])
private val apiRoutes = HttpRoutes.of[IO] {
case GET -> Root / "warnings" =>
warnings.fetchWarnings
.flatMap(result => Ok(result.asJson))
.handleErrorWith { error =>
log.error(error)("Unable to load LVĢMC warnings") *>
ServiceUnavailable("LVĢMC brīdinājumu dati pašlaik nav pieejami")
}
// http://0.0.0.0:8080/api/show/lvgmc-forecast/Latvija_LTV_pilsetas_tekosa_dn.csv
case GET -> Root / "show" / "lvgmc-forecast" / fileName =>
fetch.fetchFile(fileName).flatMap(bytes =>
@@ -198,6 +207,7 @@ class Server(postgresService: PostgresService, dataService: DataService, fetch:
"/database" -> staticcontent.fileService[IO](FileService.Config("./web/dist")),
"/harmonie" -> staticcontent.fileService[IO](FileService.Config("./web/dist")),
"/lvgmc-forecast" -> staticcontent.fileService[IO](FileService.Config("./web/dist")),
"/bridinajumi" -> staticcontent.fileService[IO](FileService.Config("./web/dist")),
).orNotFound
def run: IO[ExitCode] =
+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}}