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
|
## Setup
|
||||||
Rename `.env-sample` to `.env` and fill in credentials
|
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
|
## Run
|
||||||
```
|
```
|
||||||
docker-compose up --build --no-cache --force-recreate
|
docker-compose up --build --no-cache --force-recreate
|
||||||
@@ -40,7 +46,7 @@ server {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Local development
|
## Rocky development runtime
|
||||||
```
|
```
|
||||||
// 1st terminal
|
// 1st terminal
|
||||||
podman-compose up postgres
|
podman-compose up postgres
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
| Environment | Purpose | Address |
|
| Environment | Purpose | Address |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Windows | Source editing and Git workflow only | Not applicable |
|
| Windows | Source editing, review, and Git operations only; no compile, build, runtime, dependency installation, or application testing | Not applicable |
|
||||||
| Rocky Linux | Docker development and production-like staging | `http://192.168.1.101:9190` |
|
| Rocky Linux | Sole development runtime: compile, build, run, seed, test, 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 |
|
| 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 |
|
| Workplace production | Out of scope until reviewed and approved | Not documented here |
|
||||||
|
|
||||||
Rocky checkout:
|
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 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.
|
- 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,
|
Use Windows only to edit and review source files and perform Git operations. Do not
|
||||||
the running application, synthetic-data operations, and browser testing belong on
|
run Node/npm dependency installation, TypeScript checks, Vite builds, SBT/Scala
|
||||||
Rocky. Docker Desktop is not part of the normal project workflow.
|
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
|
## 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.
|
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
|
## Apskats
|
||||||
|
|
||||||
Purpose: aggregate selected fields across Latvia for a selected time range.
|
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
|
## 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`.
|
- 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ā.
|
- 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.
|
- 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
|
## Documents
|
||||||
|
|
||||||
- [Architecture and data flow](ARCHITECTURE.md) — components, data sources, data flow, and repository layout.
|
- [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.
|
- [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.
|
- [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.
|
- [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
|
## Environments and workflow
|
||||||
|
|
||||||
- **Windows development:** source editing and Git workflow only; Docker runtime testing is performed on Rocky.
|
- **Windows source workspace:** source editing, review, and Git operations only; do not install dependencies, compile, build, run, or test here.
|
||||||
- **Rocky staging:** production-like Docker deployment at `http://192.168.1.101:9190`.
|
- **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.
|
- **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.
|
- **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.
|
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.csv.{FileNameService}
|
||||||
import fetch.dmi
|
import fetch.dmi
|
||||||
import fetch.lvgmc
|
import fetch.lvgmc
|
||||||
|
import fetch.warnings.WarningService
|
||||||
import scheduler.Scheduler
|
import scheduler.Scheduler
|
||||||
import server.Server
|
import server.Server
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ object Main extends IOApp {
|
|||||||
scheduler <- Scheduler.of
|
scheduler <- Scheduler.of
|
||||||
dataService <- DataService.of
|
dataService <- DataService.of
|
||||||
fetchLvgmcService <- lvgmc.FetchService.of
|
fetchLvgmcService <- lvgmc.FetchService.of
|
||||||
|
warningService <- WarningService.of
|
||||||
|
|
||||||
|
|
||||||
fetchWeatherStations = for {
|
fetchWeatherStations = for {
|
||||||
@@ -40,7 +42,7 @@ object Main extends IOApp {
|
|||||||
fetchGrib <- dmi.FetchService.of(dataService)
|
fetchGrib <- dmi.FetchService.of(dataService)
|
||||||
fetchGribTask = scheduler.scheduleTask("Fetch Grib", List(43), fetchGrib.fetchRecentForecasts()).compile.drain
|
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
|
serverTask = server.run
|
||||||
|
|
||||||
scheduledTasks =
|
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.FileNameService
|
||||||
//import fetch.csv.FetchService
|
//import fetch.csv.FetchService
|
||||||
import fetch.lvgmc.FetchService
|
import fetch.lvgmc.FetchService
|
||||||
|
import fetch.warnings.WarningService
|
||||||
import fs2.io.file.{Files, Path}
|
import fs2.io.file.{Files, Path}
|
||||||
import server.ValidateRoutes.{AggFieldList, AggKey, CityList, DateTimeRange, Granularity, ValidateDate, ValidateDateTime, ValidateInt, ValidateMonths, ValidateZonedDateTime}
|
import server.ValidateRoutes.{AggFieldList, AggKey, CityList, DateTimeRange, Granularity, ValidateDate, ValidateDateTime, ValidateInt, ValidateMonths, ValidateZonedDateTime}
|
||||||
import org.http4s._
|
import org.http4s._
|
||||||
@@ -34,18 +35,26 @@ import scala.concurrent.duration.DurationInt
|
|||||||
|
|
||||||
|
|
||||||
object Server {
|
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 {
|
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 ResponseWrapper(result: Map[String, Option[Aggregate.AggregateValue]], query: UserQuery)
|
||||||
private case class LatestTemperature(city: String, observedAt: String, value: Option[Double])
|
private case class LatestTemperature(city: String, observedAt: String, value: Option[Double])
|
||||||
|
|
||||||
private val apiRoutes = HttpRoutes.of[IO] {
|
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
|
// http://0.0.0.0:8080/api/show/lvgmc-forecast/Latvija_LTV_pilsetas_tekosa_dn.csv
|
||||||
case GET -> Root / "show" / "lvgmc-forecast" / fileName =>
|
case GET -> Root / "show" / "lvgmc-forecast" / fileName =>
|
||||||
fetch.fetchFile(fileName).flatMap(bytes =>
|
fetch.fetchFile(fileName).flatMap(bytes =>
|
||||||
@@ -198,6 +207,7 @@ class Server(postgresService: PostgresService, dataService: DataService, fetch:
|
|||||||
"/database" -> staticcontent.fileService[IO](FileService.Config("./web/dist")),
|
"/database" -> staticcontent.fileService[IO](FileService.Config("./web/dist")),
|
||||||
"/harmonie" -> staticcontent.fileService[IO](FileService.Config("./web/dist")),
|
"/harmonie" -> staticcontent.fileService[IO](FileService.Config("./web/dist")),
|
||||||
"/lvgmc-forecast" -> 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
|
).orNotFound
|
||||||
|
|
||||||
def run: IO[ExitCode] =
|
def run: IO[ExitCode] =
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,6 @@
|
|||||||
import { Component, createSignal, onCleanup, onMount, ParentComponent } from 'solid-js'
|
import { Component, createSignal, onCleanup, onMount, ParentComponent } from 'solid-js'
|
||||||
import { A, Route, Router, useLocation } from '@solidjs/router'
|
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 styles from './css/menu.module.css';
|
||||||
import { Country } from './pages/country/Country';
|
import { Country } from './pages/country/Country';
|
||||||
@@ -13,6 +13,7 @@ import { LvgmcForecast } from './pages/lvgmc-forecast/LvgmcForecast'
|
|||||||
import { Home } from './pages/home/Home'
|
import { Home } from './pages/home/Home'
|
||||||
import { Faktiska } from './pages/map-graphics/MapGraphics'
|
import { Faktiska } from './pages/map-graphics/MapGraphics'
|
||||||
import { WaterTemperature } from './pages/water-temperature/WaterTemperature'
|
import { WaterTemperature } from './pages/water-temperature/WaterTemperature'
|
||||||
|
import { Warnings } from './pages/warnings/Warnings'
|
||||||
|
|
||||||
console.log("env:", import.meta.env.MODE)
|
console.log("env:", import.meta.env.MODE)
|
||||||
console.log("api host:", apiHost)
|
console.log("api host:", apiHost)
|
||||||
@@ -27,6 +28,7 @@ const App: Component = () => {
|
|||||||
<Route path="/cities" component={Cities} />
|
<Route path="/cities" component={Cities} />
|
||||||
<Route path="/faktiska" component={Faktiska} />
|
<Route path="/faktiska" component={Faktiska} />
|
||||||
<Route path="/udens-temperatura" component={WaterTemperature} />
|
<Route path="/udens-temperatura" component={WaterTemperature} />
|
||||||
|
<Route path="/bridinajumi" component={Warnings} />
|
||||||
<Route path="/latvia" component={Country} />
|
<Route path="/latvia" component={Country} />
|
||||||
<Route path="/database" component={Database} />
|
<Route path="/database" component={Database} />
|
||||||
</Router>
|
</Router>
|
||||||
@@ -46,6 +48,7 @@ const AppLayout: ParentComponent = (props) => {
|
|||||||
{path: '/database', label: 'Arhīvs', icon: Archive},
|
{path: '/database', label: 'Arhīvs', icon: Archive},
|
||||||
{path: '/harmonie', label: 'Harmonie', icon: Wind},
|
{path: '/harmonie', label: 'Harmonie', icon: Wind},
|
||||||
{path: '/lvgmc-forecast', label: 'LVĢMC', icon: DatabaseIcon},
|
{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)
|
const isSecondaryPage = () => secondaryPages.some(page => location.pathname === page.path)
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { A } from '@solidjs/router'
|
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'
|
import './home.css'
|
||||||
|
|
||||||
const tools = [
|
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:'/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:'/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:'/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:'/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:'/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},
|
{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