diff --git a/README.md b/README.md index 446dfc6..86edd15 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/DEVELOPMENT_AND_STAGING.md b/docs/DEVELOPMENT_AND_STAGING.md index ebc80f2..5d9889f 100644 --- a/docs/DEVELOPMENT_AND_STAGING.md +++ b/docs/DEVELOPMENT_AND_STAGING.md @@ -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 diff --git a/docs/PRODUCT_WORKFLOWS.md b/docs/PRODUCT_WORKFLOWS.md index 27c0327..a1e1160 100644 --- a/docs/PRODUCT_WORKFLOWS.md +++ b/docs/PRODUCT_WORKFLOWS.md @@ -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. diff --git a/docs/README.md b/docs/README.md index 68aba04..2b169e2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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. diff --git a/docs/UPDATE_ROADMAP.md b/docs/UPDATE_ROADMAP.md index 8499aa1..96e8aa7 100644 --- a/docs/UPDATE_ROADMAP.md +++ b/docs/UPDATE_ROADMAP.md @@ -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. diff --git a/src/main/scala/Main.scala b/src/main/scala/Main.scala index 6d2dda0..589b31a 100644 --- a/src/main/scala/Main.scala +++ b/src/main/scala/Main.scala @@ -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 = diff --git a/src/main/scala/fetch/warnings/WarningService.scala b/src/main/scala/fetch/warnings/WarningService.scala new file mode 100644 index 0000000..f3394c3 --- /dev/null +++ b/src/main/scala/fetch/warnings/WarningService.scala @@ -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) +} diff --git a/src/main/scala/server/Server.scala b/src/main/scala/server/Server.scala index 505bd3d..5ae1a3c 100644 --- a/src/main/scala/server/Server.scala +++ b/src/main/scala/server/Server.scala @@ -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] = diff --git a/web/src/App.tsx b/web/src/App.tsx index 654092e..4e4cd9a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 = () => { + @@ -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(() => { diff --git a/web/src/pages/home/Home.tsx b/web/src/pages/home/Home.tsx index 0d8f35f..79d3f40 100644 --- a/web/src/pages/home/Home.tsx +++ b/web/src/pages/home/Home.tsx @@ -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}, diff --git a/web/src/pages/warnings/Warnings.tsx b/web/src/pages/warnings/Warnings.tsx new file mode 100644 index 0000000..0a1a0ed --- /dev/null +++ b/web/src/pages/warnings/Warnings.tsx @@ -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 => { + 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('3840x1440'); + const [title, setTitle] = createSignal(''); + const [selectedDate, setSelectedDate] = createSignal('all'); + const [phenomenon, setPhenomenon] = createSignal('all'); + const [background, setBackground] = createSignal(); + 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
+
+
ĒTERA GRAFIKA

Brīdinājumi

Aktuālie LVĢMC brīdinājumu poligoni un manuāli ievadāms virsraksts.

+ {filteredWarnings().length} {filteredWarnings().length === 1 ? 'brīdinājums' : 'brīdinājumi'} +
+ +
+

1. Izvēlies izmēru

+ + +
+

2. Izvēlies brīdinājumu

+ + + +
+
+ + +
Brīdinājumu datus neizdevās ielādēt.
+
Izvēlētajam periodam nav aktīvu brīdinājumu. Karti joprojām var priekšskatīt, bet tajā nebūs iekrāsotu teritoriju.
+ + 0}>
{warning =>
{warning.phenomenon || 'Brīdinājums'}{warning.intensity} · {warning.regions}{formatPeriod(warning.validFrom, warning.validUntil)}
}
+ +
3. Kartes priekšskatījums{template().width} × {template().height} px eksports
+
; +} + +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(); + 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)}`; +} diff --git a/web/src/pages/warnings/warningDraw.ts b/web/src/pages/warnings/warningDraw.ts new file mode 100644 index 0000000..99244fd --- /dev/null +++ b/web/src/pages/warnings/warningDraw.ts @@ -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; +} diff --git a/web/src/pages/warnings/warningTemplates.ts b/web/src/pages/warnings/warningTemplates.ts new file mode 100644 index 0000000..313a4bb --- /dev/null +++ b/web/src/pages/warnings/warningTemplates.ts @@ -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 = { + '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 }, + }, +}; diff --git a/web/src/pages/warnings/warnings.css b/web/src/pages/warnings/warnings.css new file mode 100644 index 0000000..d0db4b5 --- /dev/null +++ b/web/src/pages/warnings/warnings.css @@ -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}}