Add LVGMC warning map workflow
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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(() => {
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
};
|
||||
@@ -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}}
|
||||
Reference in New Issue
Block a user