Use approved production map and branding assets

This commit is contained in:
b0txec
2026-08-20 22:23:23 +03:00
parent 568d880d3b
commit 16a8c685e7
20 changed files with 290 additions and 111 deletions
+2 -2
View File
@@ -6,7 +6,7 @@
|---|---|---| |---|---|---|
| Windows | Source editing and Git workflow only | Not applicable | | Windows | Source editing and Git workflow only | Not applicable |
| Rocky Linux | Docker development and production-like staging | `http://192.168.1.101:9190` | | 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 | `laikapstak.li` registered; Cloudflare delegation pending; services not started | | Temporary VPS | Month-long user-acceptance staging behind Cloudflare, Nginx, and Authelia | 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:
@@ -123,7 +123,7 @@ The temporary VPS must not become another development checkout. The proposed rel
Windows source and Git -> Rocky build and verification -> immutable image + checksum -> VPS deployment Windows source and Git -> Rocky build and verification -> immutable image + checksum -> VPS deployment
``` ```
Releases are manual during user acceptance and are identified by the source commit SHA. The VPS keeps WeatherTool in its own `/srv/weathertool` tree, publishes the application only to a loopback port, and relies on Nginx plus Authelia at the public boundary. See `VPS_STAGING_PLAN.md` before provisioning or changing the VPS. Releases are manual during user acceptance and are identified by the source commit SHA. The first installed release is `7dbc55b`. The VPS keeps WeatherTool in its own `/srv/weathertool` tree, publishes the application only to loopback ports, and relies on Nginx plus Authelia at the future public boundary. See `VPS_STAGING_PLAN.md` before changing the VPS deployment.
The committed templates live under `deploy/vps/`. They are not a second local The committed templates live under `deploy/vps/`. They are not a second local
development environment. Validate them on Rocky, build the exact Git revision, development environment. Validate them on Rocky, build the exact Git revision,
+6 -1
View File
@@ -13,7 +13,12 @@ This directory contains the working documentation for the WeatherTool modernizat
- Faktiskā symbol placement is automatic after manual image selection and is anchored to each rendered temperature badge. - Faktiskā symbol placement is automatic after manual image selection and is anchored to each rendered temperature badge.
- The first **Ūdens temperatūra** workspace is implemented with manual Latvian-named fields and required 1920×1080 and 3840×1440 exports; visual calibration remains in progress. - The first **Ūdens temperatūra** workspace is implemented with manual Latvian-named fields and required 1920×1080 and 3840×1440 exports; visual calibration remains in progress.
- Confirmed local Monda Regular/Bold files provide interface and generated-graphic typography; weather symbols use normalized transparent image assets. - Confirmed local Monda Regular/Bold files provide interface and generated-graphic typography; weather symbols use normalized transparent image assets.
- The version-controlled temporary VPS UAT bundle has passed Compose, Authelia, image-build, container-health, loopback HTTP, and database-port-isolation checks on Rocky. VPS directories exist, but no WeatherTool service has been started. - Release `7dbc55b` was built and checksummed on Rocky, transferred to the VPS, verified, and loaded as the immutable `weathertool:7dbc55b` image.
- The isolated VPS UAT stack is running and healthy: WeatherTool is bound to `127.0.0.1:8002`, Authelia to `127.0.0.1:9091`, and PostgreSQL has no host port. Public Nginx routing is not enabled yet.
- Cloudflare delegation is active, strict origin TLS covers only `laikapstak.li` and `auth.laikapstak.li`, and the public Nginx/Authelia login flow is operational without changing the existing HOP site.
- The VPS database contains a deliberately limited 14-day, 34-city synthetic dataset for newsroom visual testing. It must be cleared before authorized real-provider ingestion begins.
- Approved 1920×1080 and 3840×1440 PNG production bases are now the rendering source for Faktiskā and Ūdens temperatūra; code draws only the changing values, selected weather symbols, and wind data over those fixed newsroom graphics.
- Browser branding assets and Latvian Open Graph/Twitter metadata are included for favicon, Apple home-screen icon, and link-preview support. Public crawler access still depends on the Nginx/Authelia policy used for the metadata and preview image.
- This is not yet approved or hardened for workplace production. - This is not yet approved or hardened for workplace production.
## Documents ## Documents
+12 -9
View File
@@ -7,7 +7,7 @@ This document tracks proposed WeatherTool improvements. Work should be delivered
- **Windows development:** source editing and Git workflow only; Docker runtime testing is performed on Rocky. - **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`. - **Rocky staging:** production-like Docker deployment 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:** proposed month-long external user acceptance behind Cloudflare, Nginx, and Authelia; not yet provisioned. - **Temporary VPS staging:** release `7dbc55b` is publicly operational behind Cloudflare strict TLS, Nginx, and Authelia with a limited 14-day synthetic dataset.
- **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.
@@ -143,20 +143,23 @@ Status: pending
## Phase 8 — Temporary VPS user acceptance ## Phase 8 — Temporary VPS user acceptance
Status: planned Status: in progress
- [x] Document the proposed isolated VPS topology and trusted Rocky-to-VPS release flow. - [x] Document the proposed isolated VPS topology and trusted Rocky-to-VPS release flow.
- [ ] Choose the WeatherTool and Authelia hostnames and create their Cloudflare DNS records. - [x] Choose `laikapstak.li` and `auth.laikapstak.li`, create their proxied Cloudflare DNS records, and activate Cloudflare delegation.
- [ ] Record a read-only VPS inventory before provisioning and confirm ports, networks, storage, and capacity do not collide with existing services. - [x] Record a read-only VPS inventory before provisioning and confirm ports, networks, storage, and capacity do not collide with existing services.
- [ ] Provision a dedicated `/srv/weathertool` tree, Compose project, private network, database storage, and loopback-only application port. - [x] Provision a dedicated `/srv/weathertool` tree, Compose project, private network, database storage, and loopback-only application and Authelia ports.
- [ ] Configure Authelia with one temporary shared account, Argon2id password storage, rate limiting, and temporary IP bans. - [x] Configure Authelia with one temporary shared account, Argon2id password storage, rate limiting, and temporary IP bans.
- [ ] Add host-specific TLS certificates and Nginx authorization routing without weakening the existing Cloudflare-only firewall boundary. - [x] Add an exact-host Cloudflare origin certificate and Nginx authorization routing without disrupting the existing HOP site.
- [ ] Build and verify a commit-addressed immutable image on Rocky, then transfer its image archive and checksum to the VPS. - [x] Build and verify commit-addressed image `weathertool:7dbc55b` on Rocky, then transfer and checksum-verify its image archive on the VPS.
- [ ] Verify authentication, direct-origin blocking, application health, generated PNG downloads, backups, logs, and rollback. - [ ] Complete UAT verification: public authentication and application health pass; direct-origin blocking, logout, throttling, PNG downloads, backups, logs, and rollback remain.
- [ ] Run the month-long user test with manual releases and record feedback before any workplace-production decision. - [ ] Run the month-long user test with manual releases and record feedback before any workplace-production decision.
## Known current limitations ## Known current limitations
- The new fixed production PNG bases and social metadata are implemented locally and require a Rocky build plus UAT release before newsroom validation.
- Link-preview crawlers cannot authenticate through Authelia; the final Nginx policy must deliberately expose only the minimum preview metadata/assets if WhatsApp previews are required.
- Staging uses synthetic PostgreSQL station data. - Staging uses synthetic PostgreSQL station data.
- LVGMC forecast CSV fixtures are not yet available. - LVGMC forecast CSV fixtures are not yet available.
- HARMONIE GRIB fixtures are not yet available. - HARMONIE GRIB fixtures are not yet available.
+48 -16
View File
@@ -1,6 +1,6 @@
# Temporary VPS staging plan # Temporary VPS staging plan
Status: **Deployment bundle validated on Rocky; VPS runtime directories created; services not started** Status: **Release `7dbc55b` publicly operational behind Cloudflare, Nginx, and Authelia with temporary synthetic data**
This document defines the next deployment stage for approximately one month of This document defines the next deployment stage for approximately one month of
newsroom-user testing. It records the intended boundary before any VPS changes newsroom-user testing. It records the intended boundary before any VPS changes
@@ -57,9 +57,11 @@ Temporary VPS
Cloudflare -> Nginx -> Authelia authorization -> WeatherTool Cloudflare -> Nginx -> Authelia authorization -> WeatherTool
``` ```
The host root plus `releases/` and `backups/` now exist with owner The host root plus `releases/` and `backups/` exist with owner `ubuntu:ubuntu`
`ubuntu:ubuntu` and mode `0750`. No Compose file, secret, image, database, or and mode `0750`. Release `7dbc55b` was transferred from Rocky, verified against
running WeatherTool service has been placed there yet. its SHA-256 manifest, loaded as `weathertool:7dbc55b`, and installed with the
reviewed deployment bundle. The dedicated Compose stack is now running; no
existing HOP container, network, volume, port, or Nginx site was changed.
The VPS receives a built release artifact, not a development checkout. It gets The VPS receives a built release artifact, not a development checkout. It gets
no credential or route for the Rocky Git repository, the private Forgejo no credential or route for the Rocky Git repository, the private Forgejo
@@ -72,11 +74,11 @@ Proposed application root:
```text ```text
/srv/weathertool/ /srv/weathertool/
compose.yml compose.yml
.env.staging # untracked, root-owned, mode 0600 .env.staging # untracked, ubuntu-owned, mode 0600
config/ config/
authelia/ authelia/
configuration.yml # no secret values committed configuration.yml # no secret values committed
users.yml # Argon2id hash; protected on the VPS users_database.yml # Argon2id hash; protected on the VPS
data/ data/
postgres/ postgres/
authelia/ authelia/
@@ -133,8 +135,13 @@ include and Authelia service. The application remains unchanged.
The selected application hostname is `laikapstak.li`; the authentication portal The selected application hostname is `laikapstak.li`; the authentication portal
will use `auth.laikapstak.li`. OVH delivered and activated the domain on will use `auth.laikapstak.li`. OVH delivered and activated the domain on
2026-08-20 with registrar DNSSEC disabled. Cloudflare nameserver delegation and 2026-08-20 with registrar DNSSEC disabled. Proxied Cloudflare A records for both
zone activation are still pending. Before DNS publication: exact hostnames point to the VPS. OVH accepted the delegation change to
`riya.ns.cloudflare.com` and `rocco.ns.cloudflare.com`; Cloudflare delegation is
now active. A Cloudflare Origin CA certificate valid only for `laikapstak.li`
and `auth.laikapstak.li` is installed with a root-owned mode-`0600` private key,
and Cloudflare uses Full (strict) TLS. The dedicated Nginx site is enabled.
Operational requirements are:
1. Add an isolated Nginx virtual host without changing existing hosts. 1. Add an isolated Nginx virtual host without changing existing hosts.
2. Install a certificate valid for the exact WeatherTool/Authelia hostname or 2. Install a certificate valid for the exact WeatherTool/Authelia hostname or
@@ -174,9 +181,37 @@ Validation completed on Rocky on 2026-08-20:
- the app returned HTTP 200 through its loopback-only host binding; and - the app returned HTTP 200 through its loopback-only host binding; and
- PostgreSQL had no host port. - PostgreSQL had no host port.
Nginx syntax/integration validation, full Authelia login testing, artifact VPS progress completed on 2026-08-20:
transfer, VPS private startup, certificate installation, and public DNS remain
later checkpoints. - release archive and deployment archive transferred from Rocky;
- both SHA-256 checksums verified before loading;
- immutable image `weathertool:7dbc55b` loaded successfully;
- `.env.staging`, PostgreSQL password, three Authelia secrets, and the shared
account Argon2id hash created only on the VPS;
- secret files restricted to mode `0600` and the user database to `0640`;
- Compose configuration rendered successfully on the VPS;
- dedicated backend and edge networks created;
- PostgreSQL, WeatherTool, and Authelia all reported healthy;
- WeatherTool returned HTTP 200 on `127.0.0.1:8002`;
- Authelia returned HTTP 200 on `127.0.0.1:9091/api/health`; and
- PostgreSQL remained internal with no host port.
Additional public progress completed on 2026-08-20:
- exact-host Cloudflare origin certificate and matching private key verified;
- Nginx configuration validated successfully before reload;
- Nginx reloaded without disrupting the existing HOP site;
- the Authelia portal returned HTTP 200 through the private Nginx/TLS path;
- an unauthenticated WeatherTool request returned the expected Authelia 302;
- public browser login through `https://laikapstak.li` succeeded; and
- a checksummed seed derived from release `7dbc55b` inserted 11,458 hourly
observations for 34 cities covering 14 days.
The shortened seed exists only to support visual user testing. Before real API
credentials or scheduled ingestion are enabled, clear the synthetic `weather`
table in a controlled maintenance step while scheduled jobs remain disabled.
Logout, throttling/ban recovery, direct-origin rejection, representative PNG
downloads, and backup/rollback rehearsal remain acceptance checkpoints.
Known application constraint: provider clients are constructed during startup Known application constraint: provider clients are constructed during startup
even when `ENABLE_SCHEDULED_JOBS=false`. The synthetic UAT Compose file therefore even when `ENABLE_SCHEDULED_JOBS=false`. The synthetic UAT Compose file therefore
@@ -244,10 +279,7 @@ Proposed temporary-staging minimum:
## Decisions and prerequisites still open ## Decisions and prerequisites still open
- Cloudflare nameserver delegation and zone activation.
- Cloudflare origin certificate covering both selected hostnames.
- Deployment account and whether normal releases use public SSH or Tailscale.
- Final session and backup-retention values. - Final session and backup-retention values.
- Secure operator-side generation and delivery of the shared credential. - Secure delivery of the generated shared credential to the three testers.
- Nginx syntax validation against the VPS include/certificate layout. - Controlled removal of synthetic observations before real-provider ingestion.
- Health/readiness behavior suitable for automated release verification. - Health/readiness behavior suitable for automated release verification.
+19 -4
View File
@@ -1,12 +1,27 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="lv">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#eaf4ff" />
<link rel="shortcut icon" type="image/ico" href="/src/assets/favicon.ico" /> <meta name="description" content="Laikapstākļu datu un ētera grafiku sagatavošanas rīks." />
<meta property="og:type" content="website" />
<meta property="og:locale" content="lv_LV" />
<meta property="og:url" content="https://laikapstak.li/" />
<meta property="og:title" content="Laikapstākļi" />
<meta property="og:description" content="Laikapstākļu datu un ētera grafiku sagatavošanas rīks." />
<meta property="og:image" content="https://laikapstak.li/social-preview.png" />
<meta property="og:image:width" content="953" />
<meta property="og:image:height" content="898" />
<meta property="og:image:alt" content="Laikapstākļi" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Laikapstākļi" />
<meta name="twitter:description" content="Laikapstākļu datu un ētera grafiku sagatavošanas rīks." />
<meta name="twitter:image" content="https://laikapstak.li/social-preview.png" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="preload" href="/src/assets/Rubik-Medium.ttf" as="font" type="font/ttf" crossorigin="anonymous" /> <link rel="preload" href="/src/assets/Rubik-Medium.ttf" as="font" type="font/ttf" crossorigin="anonymous" />
<title><{ VITE_APP_TITLE }></title> <title>Laikapstākļi</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 MiB

+3 -3
View File
@@ -94,12 +94,12 @@ export const MapView: Component<{ type: MapResolution, data: () => ResultKeyVal[
function downloadPng() { function downloadPng() {
const canvas = getCanvas()!; const canvas = getCanvas()!;
if (productionTemplate && (canvas.width !== 3840 || canvas.height !== 1440)) { if (productionTemplate && (canvas.width !== props.width || canvas.height !== props.height)) {
console.error(`Faktiskā export blocked: expected 3840 × 1440, received ${canvas.width} × ${canvas.height}`); console.error(`Faktiskā export blocked: expected ${props.width} × ${props.height}, received ${canvas.width} × ${canvas.height}`);
return; return;
} }
const filename = productionTemplate ? "faktiska-3840x1440.png" : `weather-${type}.png`; const filename = productionTemplate ? `faktiska-${props.width}x${props.height}.png` : `weather-${type}.png`;
canvas.toBlob(blob => blob && download(blob, filename), 'image/png') canvas.toBlob(blob => blob && download(blob, filename), 'image/png')
} }
+73 -8
View File
@@ -1,6 +1,7 @@
import { ResolutionPropsValue, WindProps, windProps } from "./mapConsts"; import { ResolutionPropsValue, WindProps, windProps } from "./mapConsts";
import { drawRotatedImage, getAngleFromString } from "./windAngles"; import { drawRotatedImage, getAngleFromString } from "./windAngles";
import { faktiskaMarkerLayout, FaktiskaStation } from "../../pages/map-graphics/faktiskaConfig"; import { faktiskaMarkerLayout, FaktiskaStation } from "../../pages/map-graphics/faktiskaConfig";
import { faktiskaTemplates, FaktiskaTemplate } from "../../pages/map-graphics/faktiskaTemplates";
import { getLoadedWeatherIcon } from "../weatherIcons/weatherIconAssets"; import { getLoadedWeatherIcon } from "../weatherIcons/weatherIconAssets";
// cityName, {x, y}, weatherValue, icon // cityName, {x, y}, weatherValue, icon
@@ -18,12 +19,15 @@ export function drawOnMap(
const [windDirection, windSpeed, windGusts, roundValues, windProp] = windData; const [windDirection, windSpeed, windGusts, roundValues, windProp] = windData;
const [bgImg, arrowImg] = imgArr; const [bgImg, arrowImg] = imgArr;
const [title, source] = overlayData; const [title, source] = overlayData;
const template = productionTemplate
? faktiskaTemplates[(props.width === 1920 ? "faktiska_1920x1080" : "faktiska_3840x1440") as FaktiskaTemplate]
: undefined;
// bg // bg
ctx.drawImage(bgImg, 0, 0); ctx.drawImage(bgImg, 0, 0);
// City value badges use a consistent height and expand horizontally to fit the value. // City value badges use a consistent height and expand horizontally to fit the value.
cityData.forEach(([, {x, y}, value]) => { if (!template) cityData.forEach(([, {x, y}, value]) => {
const localX = props.offsetX + x * props.scale; const localX = props.offsetX + x * props.scale;
const localY = props.offsetY + y * props.scale; const localY = props.offsetY + y * props.scale;
const numericValue = roundValues ? Math.round(value) : value.toFixed(1); const numericValue = roundValues ? Math.round(value) : value.toFixed(1);
@@ -37,8 +41,9 @@ export function drawOnMap(
// weather values // weather values
cityData.forEach(([city, {x, y}, value, icon]) => { cityData.forEach(([city, {x, y}, value, icon]) => {
const localX = props.offsetX + x * props.scale; const fixedMarker = template?.markers[city as FaktiskaStation];
const localY = props.offsetY + y * props.scale; const localX = fixedMarker?.x ?? props.offsetX + x * props.scale;
const localY = fixedMarker?.y ?? props.offsetY + y * props.scale;
ctx.font = `bold ${props.fontSize}px Monda`; ctx.font = `bold ${props.fontSize}px Monda`;
ctx.textAlign = "center"; ctx.textAlign = "center";
@@ -51,13 +56,13 @@ export function drawOnMap(
if (icon) { if (icon) {
const iconImage = getLoadedWeatherIcon(icon); const iconImage = getLoadedWeatherIcon(icon);
if (!iconImage) return; if (!iconImage) return;
const iconSize = productionTemplate ? 190 : props.boxSize * 1.35; const iconSize = template?.iconSize ?? props.boxSize * 1.35;
const badgeWidth = getBadgeWidth(ctx, stringValue, props); const badgeWidth = template?.badgeSize ?? getBadgeWidth(ctx, stringValue, props);
if (productionTemplate) { if (productionTemplate) {
const layout = faktiskaMarkerLayout[city as FaktiskaStation]; const layout = faktiskaMarkerLayout[city as FaktiskaStation];
const side = layout?.iconSide ?? "right"; const side = layout?.iconSide ?? "right";
const gap = layout?.iconGap ?? 22; const gap = layout?.iconGap ?? template!.iconGap;
const offsetX = layout?.iconOffsetX ?? 0; const offsetX = layout?.iconOffsetX ?? 0;
const offsetY = layout?.iconOffsetY ?? 0; const offsetY = layout?.iconOffsetY ?? 0;
const iconCenterX = side === "right" const iconCenterX = side === "right"
@@ -70,11 +75,16 @@ export function drawOnMap(
} }
}); });
drawTitleOverlay(ctx, title, source, props.width); if (!template) drawTitleOverlay(ctx, title, source, props.width);
if (!props.showWind) return; if (!props.showWind) return;
// wind values if (template) {
drawFaktiskaWind(ctx, arrowImg, windDirection, windSpeed, windGusts, template);
return;
}
// generic wind values
ctx.font = `bold ${props.windFontSize}px Monda`; ctx.font = `bold ${props.windFontSize}px Monda`;
ctx.textAlign = "left"; ctx.textAlign = "left";
ctx.textBaseline = "top"; ctx.textBaseline = "top";
@@ -98,6 +108,61 @@ export function drawOnMap(
ctx.fillText("M/S", windProp.offsetX + boxMiddleX + gustsWidth / 2 - 22, (windProp.offsetY + 805)*windProp.scaleY); ctx.fillText("M/S", windProp.offsetX + boxMiddleX + gustsWidth / 2 - 22, (windProp.offsetY + 805)*windProp.scaleY);
} }
function drawFaktiskaWind(
ctx: CanvasRenderingContext2D,
arrowImg: HTMLImageElement,
direction: string,
speed: string,
gusts: string,
template: (typeof faktiskaTemplates)[FaktiskaTemplate],
) {
const { wind } = template;
ctx.fillStyle = "#FFFFFF";
ctx.textBaseline = "middle";
if (direction.trim()) {
ctx.font = `bold ${wind.fontSize}px Monda`;
const label = direction.trim().toUpperCase();
const labelWidth = ctx.measureText(label).width;
const gap = wind.arrowSize * 0.28;
const groupWidth = wind.arrowSize + gap + labelWidth;
const arrowX = wind.x - groupWidth / 2 + wind.arrowSize / 2;
const labelX = wind.x + groupWidth / 2 - labelWidth / 2;
drawScaledRotatedImage(ctx, arrowImg, arrowX, wind.directionY, wind.arrowSize, getAngleFromString(label));
ctx.textAlign = "center";
ctx.fillText(label, labelX, wind.directionY);
}
drawWindMeasurement(ctx, speed, wind.x, wind.speedY, wind.fontSize, wind.unitFontSize);
drawWindMeasurement(ctx, gusts, wind.x, wind.gustsY, wind.fontSize, wind.unitFontSize);
}
function drawScaledRotatedImage(ctx: CanvasRenderingContext2D, image: HTMLImageElement, centerX: number, centerY: number, size: number, angleInDegrees: number) {
ctx.save();
ctx.translate(centerX, centerY);
ctx.rotate(angleInDegrees * Math.PI / 180);
ctx.drawImage(image, -size / 2, -size / 2, size, size);
ctx.restore();
}
function drawWindMeasurement(ctx: CanvasRenderingContext2D, value: string, x: number, y: number, fontSize: number, unitFontSize: number) {
if (!value.trim()) return;
const label = value.trim();
ctx.font = `bold ${fontSize}px Monda`;
const valueWidth = ctx.measureText(label).width;
ctx.font = `bold ${unitFontSize}px Monda`;
const unit = "M/S";
const unitWidth = ctx.measureText(unit).width;
const gap = unitFontSize * 0.45;
const totalWidth = valueWidth + gap + unitWidth;
const startX = x - totalWidth / 2;
ctx.textAlign = "left";
ctx.font = `bold ${fontSize}px Monda`;
ctx.fillText(label, startX, y);
ctx.font = `bold ${unitFontSize}px Monda`;
ctx.fillText(unit, startX + valueWidth + gap, y + fontSize * 0.12);
}
function getBadgeWidth(ctx: CanvasRenderingContext2D, value: string, props: ResolutionPropsValue): number { function getBadgeWidth(ctx: CanvasRenderingContext2D, value: string, props: ResolutionPropsValue): number {
ctx.font = `bold ${props.fontSize}px Monda`; ctx.font = `bold ${props.fontSize}px Monda`;
return Math.max(props.boxSize, Math.ceil(ctx.measureText(value).width + props.fontSize * 0.8)); return Math.max(props.boxSize, Math.ceil(ctx.measureText(value).width + props.fontSize * 0.8));
+6 -1
View File
@@ -2,8 +2,9 @@ import map_1920x1080 from "../../assets/map_1920x1080.webp";
import map_1920x1080_wind from "../../assets/map_1920x1080_wind.webp"; import map_1920x1080_wind from "../../assets/map_1920x1080_wind.webp";
import map_3840x1440 from "../../assets/map_3840x1440.webp"; import map_3840x1440 from "../../assets/map_3840x1440.webp";
import map_3840x1440_wind from "../../assets/map_3840x1440_wind.webp"; import map_3840x1440_wind from "../../assets/map_3840x1440_wind.webp";
import { faktiskaTemplates } from "../../pages/map-graphics/faktiskaTemplates";
export type MapResolution = "map_1920x1080" | "map_1920x1080_wind" | "map_3840x1440" | "map_3840x1440_wind"; export type MapResolution = "map_1920x1080" | "map_1920x1080_wind" | "map_3840x1440" | "map_3840x1440_wind" | "faktiska_1920x1080" | "faktiska_3840x1440";
export type ResolutionPropsValue = { export type ResolutionPropsValue = {
offsetX: number; offsetX: number;
@@ -23,6 +24,8 @@ export const resolutionProps: Record<string, ResolutionPropsValue> = {
"map_1920x1080_wind": { offsetX: 90, offsetY: 120, width: 1920, height: 1080, scale: 1.35, fontSize: 30, boxSize: 80, showWind: true, map: map_1920x1080_wind, windFontSize: 45, }, "map_1920x1080_wind": { offsetX: 90, offsetY: 120, width: 1920, height: 1080, scale: 1.35, fontSize: 30, boxSize: 80, showWind: true, map: map_1920x1080_wind, windFontSize: 45, },
"map_3840x1440": { offsetX: 800, offsetY: 120, width: 3840, height: 1440, scale: 2.2, fontSize: 68, boxSize: 140, showWind: false, map: map_3840x1440, windFontSize: 60, }, "map_3840x1440": { offsetX: 800, offsetY: 120, width: 3840, height: 1440, scale: 2.2, fontSize: 68, boxSize: 140, showWind: false, map: map_3840x1440, windFontSize: 60, },
"map_3840x1440_wind": { offsetX: 800, offsetY: 120, width: 3840, height: 1440, scale: 2.2, fontSize: 68, boxSize: 140, showWind: true, map: map_3840x1440_wind, windFontSize: 60, }, "map_3840x1440_wind": { offsetX: 800, offsetY: 120, width: 3840, height: 1440, scale: 2.2, fontSize: 68, boxSize: 140, showWind: true, map: map_3840x1440_wind, windFontSize: 60, },
"faktiska_1920x1080": { offsetX: 0, offsetY: 0, width: 1920, height: 1080, scale: 1, fontSize: faktiskaTemplates.faktiska_1920x1080.fontSize, boxSize: faktiskaTemplates.faktiska_1920x1080.badgeSize, showWind: true, map: faktiskaTemplates.faktiska_1920x1080.map, windFontSize: faktiskaTemplates.faktiska_1920x1080.wind.fontSize },
"faktiska_3840x1440": { offsetX: 0, offsetY: 0, width: 3840, height: 1440, scale: 1, fontSize: faktiskaTemplates.faktiska_3840x1440.fontSize, boxSize: faktiskaTemplates.faktiska_3840x1440.badgeSize, showWind: true, map: faktiskaTemplates.faktiska_3840x1440.map, windFontSize: faktiskaTemplates.faktiska_3840x1440.wind.fontSize },
} }
export type WindProps = { export type WindProps = {
@@ -34,4 +37,6 @@ export type WindProps = {
export const windProps: Record<string, WindProps> = { export const windProps: Record<string, WindProps> = {
"map_1920x1080_wind": { offsetX: 0, offsetY: 0, scaleY: 1, }, "map_1920x1080_wind": { offsetX: 0, offsetY: 0, scaleY: 1, },
"map_3840x1440_wind": { offsetX: 1610, offsetY: 75, scaleY: 1.24, }, "map_3840x1440_wind": { offsetX: 1610, offsetY: 75, scaleY: 1.24, },
"faktiska_1920x1080": { offsetX: 0, offsetY: 0, scaleY: 1 },
"faktiska_3840x1440": { offsetX: 0, offsetY: 0, scaleY: 1 },
} }
+13 -2
View File
@@ -3,6 +3,7 @@ import { MapView } from "../../components/map/MapView";
import { apiHost, ResultKeyVal } from "../../consts"; import { apiHost, ResultKeyVal } from "../../consts";
import { LoadingSpinner } from "../../components/spinner/LoadingSpinner"; import { LoadingSpinner } from "../../components/spinner/LoadingSpinner";
import { faktiskaExport, faktiskaStations, FaktiskaStation } from "./faktiskaConfig"; import { faktiskaExport, faktiskaStations, FaktiskaStation } from "./faktiskaConfig";
import type { FaktiskaTemplate } from "./faktiskaTemplates";
import "./mapGraphics.css"; import "./mapGraphics.css";
type LatestTemperature = { city: FaktiskaStation; observedAt: string; value: number | null }; type LatestTemperature = { city: FaktiskaStation; observedAt: string; value: number | null };
@@ -13,6 +14,10 @@ const emptyValues = (): Record<FaktiskaStation, string> =>
export function Faktiska() { export function Faktiska() {
const [values, setValues] = createSignal(emptyValues()); const [values, setValues] = createSignal(emptyValues());
const [overrides, setOverrides] = createSignal<Set<FaktiskaStation>>(new Set()); const [overrides, setOverrides] = createSignal<Set<FaktiskaStation>>(new Set());
const [template, setTemplate] = createSignal<FaktiskaTemplate>(faktiskaExport.defaultTemplate);
const productionSize = () => template() === "faktiska_1920x1080"
? { width: 1920, height: 1080 }
: { width: 3840, height: 1440 };
const fetchLatest = async (): Promise<LatestTemperature[]> => { const fetchLatest = async (): Promise<LatestTemperature[]> => {
const response = await fetch(`${apiHost}/api/query/latest-temperatures/${faktiskaStations.join(",")}`); const response = await fetch(`${apiHost}/api/query/latest-temperatures/${faktiskaStations.join(",")}`);
@@ -63,7 +68,7 @@ export function Faktiska() {
return <div class="mapGraphicsPage pageWorkspace faktiskaWorkspace"> return <div class="mapGraphicsPage pageWorkspace faktiskaWorkspace">
<div class="pageHeading"> <div class="pageHeading">
<div><span class="eyebrow">Daily newsroom production</span><h1>Faktiskā</h1><p>Current temperatures with manually prepared weather symbols and wind.</p></div> <div><span class="eyebrow">Daily newsroom production</span><h1>Faktiskā</h1><p>Current temperatures with manually prepared weather symbols and wind.</p></div>
<div class="productionSize">{faktiskaExport.width} × {faktiskaExport.height} PNG</div> <div class="productionSize">{productionSize().width} × {productionSize().height} PNG</div>
</div> </div>
<section class="faktiskaDataBand"> <section class="faktiskaDataBand">
@@ -87,7 +92,13 @@ export function Faktiska() {
<section class="mapProduction faktiskaProduction"> <section class="mapProduction faktiskaProduction">
<h2>2. Add weather symbols and wind, then export</h2> <h2>2. Add weather symbols and wind, then export</h2>
<MapView type={faktiskaExport.mapType} data={cityData} mode="faktiska" productionTemplate/> <div class="resolutionChoices faktiskaResolutionChoices" aria-label="Faktiskā export size">
<button classList={{ active: template() === "faktiska_1920x1080" }} onClick={() => setTemplate("faktiska_1920x1080")}>1920 × 1080</button>
<button classList={{ active: template() === "faktiska_3840x1440" }} onClick={() => setTemplate("faktiska_3840x1440")}>3840 × 1440</button>
</div>
<Show when={template()} keyed>{currentTemplate =>
<MapView type={currentTemplate} data={cityData} mode="faktiska" productionTemplate/>
}</Show>
</section> </section>
</div>; </div>;
} }
+1 -3
View File
@@ -38,7 +38,5 @@ export const faktiskaMarkerLayout: Record<FaktiskaStation, FaktiskaMarkerLayout>
}; };
export const faktiskaExport = { export const faktiskaExport = {
width: 3840, defaultTemplate: "faktiska_3840x1440" as const,
height: 1440,
mapType: "map_3840x1440_wind" as const,
}; };
@@ -0,0 +1,79 @@
import faktiska1920 from '../../assets/production/FAKTISKA_map_base_1920.png';
import faktiska3840 from '../../assets/production/FAKTISKA_map_base_3840.png';
import type { FaktiskaStation } from './faktiskaConfig';
export type FaktiskaTemplate = 'faktiska_1920x1080' | 'faktiska_3840x1440';
type TemplateDefinition = {
width: number;
height: number;
map: string;
fontSize: number;
badgeSize: number;
iconSize: number;
iconGap: number;
markers: Record<FaktiskaStation, { x: number; y: number }>;
wind: {
x: number;
directionY: number;
speedY: number;
gustsY: number;
fontSize: number;
unitFontSize: number;
arrowSize: number;
};
};
export const faktiskaTemplates: Record<FaktiskaTemplate, TemplateDefinition> = {
faktiska_1920x1080: {
width: 1920,
height: 1080,
map: faktiska1920,
fontSize: 46,
badgeSize: 84,
iconSize: 112,
iconGap: 10,
markers: {
'Liepāja': { x: 167, y: 680 },
'Ventspils': { x: 253, y: 370 },
'Stende': { x: 444, y: 453 },
'Saldus': { x: 426, y: 623 },
'Rīga': { x: 732, y: 459 },
'Jelgava': { x: 763, y: 650 },
'Ainaži': { x: 728, y: 187 },
'Valmiera': { x: 874, y: 283 },
'Madona': { x: 1047, y: 474 },
'Alūksne': { x: 1218, y: 335 },
'Zīlāni': { x: 1027, y: 623 },
'Daugavpils': { x: 1183, y: 781 },
'Rēzekne': { x: 1272, y: 585 },
},
wind: { x: 1663, directionY: 386, speedY: 600, gustsY: 810, fontSize: 45, unitFontSize: 18, arrowSize: 44 },
},
faktiska_3840x1440: {
width: 3840,
height: 1440,
map: faktiska3840,
fontSize: 72,
badgeSize: 134,
iconSize: 190,
iconGap: 14,
markers: {
'Liepāja': { x: 952, y: 1026 },
'Ventspils': { x: 1086, y: 540 },
'Stende': { x: 1388, y: 671 },
'Saldus': { x: 1359, y: 905 },
'Rīga': { x: 1838, y: 681 },
'Jelgava': { x: 1888, y: 979 },
'Ainaži': { x: 1833, y: 253 },
'Valmiera': { x: 2061, y: 404 },
'Madona': { x: 2333, y: 703 },
'Alūksne': { x: 2603, y: 486 },
'Zīlāni': { x: 2301, y: 937 },
'Daugavpils': { x: 2548, y: 1185 },
'Rēzekne': { x: 2686, y: 878 },
},
wind: { x: 3258, directionY: 570, speedY: 830, gustsY: 1090, fontSize: 64, unitFontSize: 25, arrowSize: 64 },
},
};
@@ -52,6 +52,7 @@
.resetTemperature{grid-area:reset;align-self:start;min-height:0;padding:3px 5px;background:transparent;color:var(--accent-strong);font-family:Inter,sans-serif;font-size:10px} .resetTemperature{grid-area:reset;align-self:start;min-height:0;padding:3px 5px;background:transparent;color:var(--accent-strong);font-family:Inter,sans-serif;font-size:10px}
.inlineNotice{margin:10px 0;padding:10px 12px;border-radius:9px;background:rgba(255,255,255,.62)}.inlineNotice.error{color:var(--danger)} .inlineNotice{margin:10px 0;padding:10px 12px;border-radius:9px;background:rgba(255,255,255,.62)}.inlineNotice.error{color:var(--danger)}
.faktiskaProduction{margin-top:20px}.faktiskaProduction>h2{font-size:22px} .faktiskaProduction{margin-top:20px}.faktiskaProduction>h2{font-size:22px}
.faktiskaResolutionChoices{display:flex;gap:8px;margin:0 0 14px}.faktiskaResolutionChoices button{min-width:150px}.faktiskaResolutionChoices button.active{border-color:var(--accent);background:var(--accent-pale);color:var(--accent-strong)}
@media(max-width:1450px){.temperatureEditor{grid-template-columns:repeat(5,minmax(150px,1fr))}} @media(max-width:1450px){.temperatureEditor{grid-template-columns:repeat(5,minmax(150px,1fr))}}
@media(max-width:1050px){.temperatureEditor{grid-template-columns:repeat(3,minmax(150px,1fr))}} @media(max-width:1050px){.temperatureEditor{grid-template-columns:repeat(3,minmax(150px,1fr))}}
@media(max-width:650px){.faktiskaStatus{align-items:flex-start;flex-direction:column}.temperatureEditor{grid-template-columns:1fr 1fr}} @media(max-width:650px){.faktiskaStatus{align-items:flex-start;flex-direction:column}.temperatureEditor{grid-template-columns:1fr 1fr}}
@@ -1,7 +1,7 @@
import { createEffect, createSignal, For, onMount } from 'solid-js'; import { createEffect, createSignal, For, onMount } from 'solid-js';
import map1920 from '../../assets/map_1920x1080.webp'; import map1920 from '../../assets/production/UDENS_KARTE_PAMATS_1920.png';
import map3840 from '../../assets/map_3840x1440.webp'; import map3840 from '../../assets/production/UDENS_KARTE_PAMATS_3840.png';
import { download } from '../../helpers/download'; import { download } from '../../helpers/download';
import './waterTemperature.css'; import './waterTemperature.css';
@@ -9,17 +9,22 @@ type OutputSize = '1920x1080' | '3840x1440';
type WaterArea = 'Jūra' | 'Līcis' | 'Kurzeme' | 'Zemgale' | 'Vidzeme' | 'Latgale'; type WaterArea = 'Jūra' | 'Līcis' | 'Kurzeme' | 'Zemgale' | 'Vidzeme' | 'Latgale';
type RangeValue = { min: string; max: string }; type RangeValue = { min: string; max: string };
const areas: Array<{ name: WaterArea; x: number; y: number }> = [ const areaNames: WaterArea[] = ['Jūra', 'Līcis', 'Kurzeme', 'Zemgale', 'Vidzeme', 'Latgale'];
{ name: 'Jūra', x: 690, y: 720 }, const layouts: Record<OutputSize, { fontSize: number; positions: Record<WaterArea, { x: number; y: number }> }> = {
{ name: 'Līcis', x: 1640, y: 455 }, '1920x1080': { fontSize: 52, positions: {
{ name: 'Kurzeme', x: 1275, y: 785 }, 'Jūra': { x: 237, y: 439 }, 'Līcis': { x: 815, y: 355 },
{ name: 'Zemgale', x: 1880, y: 920 }, 'Kurzeme': { x: 601, y: 523 }, 'Zemgale': { x: 981, y: 587 },
{ name: 'Vidzeme', x: 2235, y: 525 }, 'Vidzeme': { x: 1236, y: 397 }, 'Latgale': { x: 1393, y: 673 },
{ name: 'Latgale', x: 2540, y: 985 }, } },
]; '3840x1440': { fontSize: 88, positions: {
'Jūra': { x: 693, y: 721 }, 'Līcis': { x: 1643, y: 455 },
'Kurzeme': { x: 1274, y: 786 }, 'Zemgale': { x: 1815, y: 914 },
'Vidzeme': { x: 2234, y: 525 }, 'Latgale': { x: 2539, y: 987 },
} },
};
const emptyRanges = (): Record<WaterArea, RangeValue> => Object.fromEntries( const emptyRanges = (): Record<WaterArea, RangeValue> => Object.fromEntries(
areas.map(({ name }) => [name, { min: '', max: '' }]), areaNames.map(name => [name, { min: '', max: '' }]),
) as Record<WaterArea, RangeValue>; ) as Record<WaterArea, RangeValue>;
export function WaterTemperature() { export function WaterTemperature() {
@@ -32,7 +37,7 @@ export function WaterTemperature() {
? { width: 3840, height: 1440, map: map3840 } ? { width: 3840, height: 1440, map: map3840 }
: { width: 1920, height: 1080, map: map1920 }; : { width: 1920, height: 1080, map: map1920 };
const complete = () => areas.every(({ name }) => ranges()[name].min.trim() && ranges()[name].max.trim()); const complete = () => areaNames.every(name => ranges()[name].min.trim() && ranges()[name].max.trim());
const updateRange = (name: WaterArea, key: keyof RangeValue, value: string) => { const updateRange = (name: WaterArea, key: keyof RangeValue, value: string) => {
setRanges(current => ({ ...current, [name]: { ...current[name], [key]: value } })); setRanges(current => ({ ...current, [name]: { ...current[name], [key]: value } }));
@@ -57,8 +62,7 @@ export function WaterTemperature() {
const ctx = canvas.getContext('2d')!; const ctx = canvas.getContext('2d')!;
ctx.clearRect(0, 0, width, height); ctx.clearRect(0, 0, width, height);
ctx.drawImage(image, 0, 0, width, height); ctx.drawImage(image, 0, 0, width, height);
drawNameplate(ctx, width, height); drawRanges(ctx, output(), ranges());
drawRanges(ctx, width, height, ranges());
}; };
onMount(loadBackground); onMount(loadBackground);
@@ -74,7 +78,7 @@ export function WaterTemperature() {
return <section class="pageWorkspace waterPage"> return <section class="pageWorkspace waterPage">
<div class="pageHeading waterHeading"> <div class="pageHeading waterHeading">
<div><span class="eyebrow">ĒTERA GRAFIKA</span><h1>Ūdens temperatūra</h1><p>Manuāli ievadi temperatūras diapazonus un sagatavo karti eksportam.</p></div> <div><span class="eyebrow">ĒTERA GRAFIKA</span><h1>Ūdens temperatūra</h1><p>Manuāli ievadi temperatūras diapazonus un sagatavo karti eksportam.</p></div>
<span class="selectionCount">{complete() ? '6 diapazoni aizpildīti' : `${areas.filter(({ name }) => ranges()[name].min && ranges()[name].max).length} no 6 aizpildīti`}</span> <span class="selectionCount">{complete() ? '6 diapazoni aizpildīti' : `${areaNames.filter(name => ranges()[name].min && ranges()[name].max).length} no 6 aizpildīti`}</span>
</div> </div>
<section class="waterSetup" aria-labelledby="water-settings"> <section class="waterSetup" aria-labelledby="water-settings">
@@ -87,7 +91,7 @@ export function WaterTemperature() {
</div> </div>
<div class="waterFields"> <div class="waterFields">
<h2>2. Ievadi temperatūras</h2> <h2>2. Ievadi temperatūras</h2>
<div class="rangeGrid"><For each={areas}>{({ name }) => <fieldset class="rangeField"> <div class="rangeGrid"><For each={areaNames}>{name => <fieldset class="rangeField">
<legend>{name}</legend> <legend>{name}</legend>
<label><span>No</span><input type="number" step="1" inputmode="numeric" value={ranges()[name].min} onInput={event => updateRange(name, 'min', event.currentTarget.value)} /></label> <label><span>No</span><input type="number" step="1" inputmode="numeric" value={ranges()[name].min} onInput={event => updateRange(name, 'min', event.currentTarget.value)} /></label>
<span class="rangeSeparator">...</span> <span class="rangeSeparator">...</span>
@@ -104,57 +108,18 @@ export function WaterTemperature() {
</section>; </section>;
} }
function drawRanges(ctx: CanvasRenderingContext2D, width: number, height: number, values: Record<WaterArea, RangeValue>) { function drawRanges(ctx: CanvasRenderingContext2D, output: OutputSize, values: Record<WaterArea, RangeValue>) {
const scaleX = width / 3840; const layout = layouts[output];
const scaleY = height / 1440;
// Backgrounds use their target aspect ratio, but production graphics scale
// uniformly so boxes and type are never stretched vertically or horizontally.
const graphicScale = scaleX;
const fontSize = 88 * graphicScale;
const badgeHeight = 132 * graphicScale;
const padding = 56 * graphicScale;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
ctx.font = `700 ${fontSize}px Monda`; ctx.font = `700 ${layout.fontSize}px Monda`;
ctx.fillStyle = '#000';
areas.forEach(({ name, x, y }) => { areaNames.forEach(name => {
const range = values[name]; const range = values[name];
if (!range.min.trim() || !range.max.trim()) return; if (!range.min.trim() || !range.max.trim()) return;
const text = `${range.min}...${range.max}`; const text = `${range.min}...${range.max}`;
const localX = x * scaleX; const { x, y } = layout.positions[name];
const localY = y * scaleY; ctx.fillText(text, x, y);
const badgeWidth = Math.max(300 * scaleX, ctx.measureText(text).width + padding * 2);
ctx.fillStyle = '#f4f4f4';
ctx.fillRect(localX - badgeWidth / 2, localY - badgeHeight / 2, badgeWidth, badgeHeight);
ctx.fillStyle = '#000';
ctx.fillText(text, localX, localY + 2 * scaleY);
}); });
} }
function drawNameplate(ctx: CanvasRenderingContext2D, width: number, height: number) {
const scaleX = width / 3840;
const scaleY = height / 1440;
const graphicScale = scaleX;
const x = 2698 * scaleX;
const y = 145 * scaleY;
const titleWidth = 1062 * graphicScale;
const titleHeight = 124 * graphicScale;
const sourceWidth = 290 * graphicScale;
const sourceHeight = 60 * graphicScale;
ctx.fillStyle = '#8f090b';
ctx.fillRect(x, y, titleWidth, titleHeight);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = `700 ${56 * graphicScale}px Monda`;
ctx.fillStyle = '#fff';
ctx.fillText('ŪDENS TEMPERATŪRA', x + titleWidth / 2, y + titleHeight / 2);
const sourceX = x + titleWidth - sourceWidth;
ctx.fillStyle = '#f4f4f4';
ctx.fillRect(sourceX, y + titleHeight, sourceWidth, sourceHeight);
ctx.fillStyle = '#000';
ctx.font = `700 ${39 * graphicScale}px Monda`;
ctx.textAlign = 'center';
ctx.fillText('LVĢMC', sourceX + sourceWidth / 2, y + titleHeight + sourceHeight / 2);
}