diff --git a/docs/DEVELOPMENT_AND_STAGING.md b/docs/DEVELOPMENT_AND_STAGING.md index bc2cdd9..f56cb4d 100644 --- a/docs/DEVELOPMENT_AND_STAGING.md +++ b/docs/DEVELOPMENT_AND_STAGING.md @@ -6,7 +6,7 @@ |---|---|---| | Windows | Source editing and Git workflow only | Not applicable | | Rocky Linux | Docker development and production-like staging | `http://192.168.1.101:9190` | -| Temporary VPS | Month-long user-acceptance staging behind Cloudflare, Nginx, and Authelia | `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 | 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 ``` -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 development environment. Validate them on Rocky, build the exact Git revision, diff --git a/docs/README.md b/docs/README.md index 3d82543..0f01685 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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. - 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. -- 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. ## Documents diff --git a/docs/UPDATE_ROADMAP.md b/docs/UPDATE_ROADMAP.md index 9846402..2d1e878 100644 --- a/docs/UPDATE_ROADMAP.md +++ b/docs/UPDATE_ROADMAP.md @@ -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. - **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. -- **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. 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 -Status: planned +Status: in progress - [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. -- [ ] 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. -- [ ] 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. -- [ ] Build and verify a commit-addressed immutable image on Rocky, then transfer its image archive and checksum to the VPS. -- [ ] Verify authentication, direct-origin blocking, application health, generated PNG downloads, backups, logs, and rollback. +- [x] Choose `laikapstak.li` and `auth.laikapstak.li`, create their proxied Cloudflare DNS records, and activate Cloudflare delegation. +- [x] Record a read-only VPS inventory before provisioning and confirm ports, networks, storage, and capacity do not collide with existing services. +- [x] Provision a dedicated `/srv/weathertool` tree, Compose project, private network, database storage, and loopback-only application and Authelia ports. +- [x] Configure Authelia with one temporary shared account, Argon2id password storage, rate limiting, and temporary IP bans. +- [x] Add an exact-host Cloudflare origin certificate and Nginx authorization routing without disrupting the existing HOP site. +- [x] Build and verify commit-addressed image `weathertool:7dbc55b` on Rocky, then transfer and checksum-verify its image archive on the VPS. +- [ ] 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. ## 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. - LVGMC forecast CSV fixtures are not yet available. - HARMONIE GRIB fixtures are not yet available. diff --git a/docs/VPS_STAGING_PLAN.md b/docs/VPS_STAGING_PLAN.md index 8567a89..f7319ad 100644 --- a/docs/VPS_STAGING_PLAN.md +++ b/docs/VPS_STAGING_PLAN.md @@ -1,6 +1,6 @@ # 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 newsroom-user testing. It records the intended boundary before any VPS changes @@ -57,9 +57,11 @@ Temporary VPS Cloudflare -> Nginx -> Authelia authorization -> WeatherTool ``` -The host root plus `releases/` and `backups/` now exist with owner -`ubuntu:ubuntu` and mode `0750`. No Compose file, secret, image, database, or -running WeatherTool service has been placed there yet. +The host root plus `releases/` and `backups/` exist with owner `ubuntu:ubuntu` +and mode `0750`. Release `7dbc55b` was transferred from Rocky, verified against +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 no credential or route for the Rocky Git repository, the private Forgejo @@ -72,11 +74,11 @@ Proposed application root: ```text /srv/weathertool/ compose.yml - .env.staging # untracked, root-owned, mode 0600 + .env.staging # untracked, ubuntu-owned, mode 0600 config/ authelia/ configuration.yml # no secret values committed - users.yml # Argon2id hash; protected on the VPS + users_database.yml # Argon2id hash; protected on the VPS data/ postgres/ authelia/ @@ -133,8 +135,13 @@ include and Authelia service. The application remains unchanged. The selected application hostname is `laikapstak.li`; the authentication portal will use `auth.laikapstak.li`. OVH delivered and activated the domain on -2026-08-20 with registrar DNSSEC disabled. Cloudflare nameserver delegation and -zone activation are still pending. Before DNS publication: +2026-08-20 with registrar DNSSEC disabled. Proxied Cloudflare A records for both +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. 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 - PostgreSQL had no host port. -Nginx syntax/integration validation, full Authelia login testing, artifact -transfer, VPS private startup, certificate installation, and public DNS remain -later checkpoints. +VPS progress completed on 2026-08-20: + +- 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 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 -- 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. -- Secure operator-side generation and delivery of the shared credential. -- Nginx syntax validation against the VPS include/certificate layout. +- Secure delivery of the generated shared credential to the three testers. +- Controlled removal of synthetic observations before real-provider ingestion. - Health/readiness behavior suitable for automated release verification. diff --git a/web/index.html b/web/index.html index bf8682e..71dddaa 100644 --- a/web/index.html +++ b/web/index.html @@ -1,12 +1,27 @@ - + - - + + + + + + + + + + + + + + + + + - <{ VITE_APP_TITLE }> + Laikapstākļi diff --git a/web/public/apple-touch-icon.png b/web/public/apple-touch-icon.png new file mode 100644 index 0000000..1b6895f Binary files /dev/null and b/web/public/apple-touch-icon.png differ diff --git a/web/public/favicon.png b/web/public/favicon.png new file mode 100644 index 0000000..e0bfd88 Binary files /dev/null and b/web/public/favicon.png differ diff --git a/web/public/social-preview.png b/web/public/social-preview.png new file mode 100644 index 0000000..c73fc6c Binary files /dev/null and b/web/public/social-preview.png differ diff --git a/web/src/assets/production/FAKTISKA_map_base_1920.png b/web/src/assets/production/FAKTISKA_map_base_1920.png new file mode 100644 index 0000000..2374b23 Binary files /dev/null and b/web/src/assets/production/FAKTISKA_map_base_1920.png differ diff --git a/web/src/assets/production/FAKTISKA_map_base_3840.png b/web/src/assets/production/FAKTISKA_map_base_3840.png new file mode 100644 index 0000000..5741490 Binary files /dev/null and b/web/src/assets/production/FAKTISKA_map_base_3840.png differ diff --git a/web/src/assets/production/UDENS_KARTE_PAMATS_1920.png b/web/src/assets/production/UDENS_KARTE_PAMATS_1920.png new file mode 100644 index 0000000..9ab0f2a Binary files /dev/null and b/web/src/assets/production/UDENS_KARTE_PAMATS_1920.png differ diff --git a/web/src/assets/production/UDENS_KARTE_PAMATS_3840.png b/web/src/assets/production/UDENS_KARTE_PAMATS_3840.png new file mode 100644 index 0000000..bc20152 Binary files /dev/null and b/web/src/assets/production/UDENS_KARTE_PAMATS_3840.png differ diff --git a/web/src/components/map/MapView.tsx b/web/src/components/map/MapView.tsx index 279a8f3..f649e98 100644 --- a/web/src/components/map/MapView.tsx +++ b/web/src/components/map/MapView.tsx @@ -94,12 +94,12 @@ export const MapView: Component<{ type: MapResolution, data: () => ResultKeyVal[ function downloadPng() { const canvas = getCanvas()!; - if (productionTemplate && (canvas.width !== 3840 || canvas.height !== 1440)) { - console.error(`Faktiskā export blocked: expected 3840 × 1440, received ${canvas.width} × ${canvas.height}`); + if (productionTemplate && (canvas.width !== props.width || canvas.height !== props.height)) { + console.error(`Faktiskā export blocked: expected ${props.width} × ${props.height}, received ${canvas.width} × ${canvas.height}`); 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') } diff --git a/web/src/components/map/canvasDraw.ts b/web/src/components/map/canvasDraw.ts index 73b47be..cb945b8 100644 --- a/web/src/components/map/canvasDraw.ts +++ b/web/src/components/map/canvasDraw.ts @@ -1,6 +1,7 @@ import { ResolutionPropsValue, WindProps, windProps } from "./mapConsts"; import { drawRotatedImage, getAngleFromString } from "./windAngles"; import { faktiskaMarkerLayout, FaktiskaStation } from "../../pages/map-graphics/faktiskaConfig"; +import { faktiskaTemplates, FaktiskaTemplate } from "../../pages/map-graphics/faktiskaTemplates"; import { getLoadedWeatherIcon } from "../weatherIcons/weatherIconAssets"; // cityName, {x, y}, weatherValue, icon @@ -18,12 +19,15 @@ export function drawOnMap( const [windDirection, windSpeed, windGusts, roundValues, windProp] = windData; const [bgImg, arrowImg] = imgArr; const [title, source] = overlayData; + const template = productionTemplate + ? faktiskaTemplates[(props.width === 1920 ? "faktiska_1920x1080" : "faktiska_3840x1440") as FaktiskaTemplate] + : undefined; // bg ctx.drawImage(bgImg, 0, 0); // 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 localY = props.offsetY + y * props.scale; const numericValue = roundValues ? Math.round(value) : value.toFixed(1); @@ -37,8 +41,9 @@ export function drawOnMap( // weather values cityData.forEach(([city, {x, y}, value, icon]) => { - const localX = props.offsetX + x * props.scale; - const localY = props.offsetY + y * props.scale; + const fixedMarker = template?.markers[city as FaktiskaStation]; + 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.textAlign = "center"; @@ -51,13 +56,13 @@ export function drawOnMap( if (icon) { const iconImage = getLoadedWeatherIcon(icon); if (!iconImage) return; - const iconSize = productionTemplate ? 190 : props.boxSize * 1.35; - const badgeWidth = getBadgeWidth(ctx, stringValue, props); + const iconSize = template?.iconSize ?? props.boxSize * 1.35; + const badgeWidth = template?.badgeSize ?? getBadgeWidth(ctx, stringValue, props); if (productionTemplate) { const layout = faktiskaMarkerLayout[city as FaktiskaStation]; const side = layout?.iconSide ?? "right"; - const gap = layout?.iconGap ?? 22; + const gap = layout?.iconGap ?? template!.iconGap; const offsetX = layout?.iconOffsetX ?? 0; const offsetY = layout?.iconOffsetY ?? 0; 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; - // wind values + if (template) { + drawFaktiskaWind(ctx, arrowImg, windDirection, windSpeed, windGusts, template); + return; + } + + // generic wind values ctx.font = `bold ${props.windFontSize}px Monda`; ctx.textAlign = "left"; 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); } +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 { ctx.font = `bold ${props.fontSize}px Monda`; return Math.max(props.boxSize, Math.ceil(ctx.measureText(value).width + props.fontSize * 0.8)); diff --git a/web/src/components/map/mapConsts.ts b/web/src/components/map/mapConsts.ts index 5dc8c3e..2f2a237 100644 --- a/web/src/components/map/mapConsts.ts +++ b/web/src/components/map/mapConsts.ts @@ -2,8 +2,9 @@ import map_1920x1080 from "../../assets/map_1920x1080.webp"; import map_1920x1080_wind from "../../assets/map_1920x1080_wind.webp"; import map_3840x1440 from "../../assets/map_3840x1440.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 = { offsetX: number; @@ -23,6 +24,8 @@ export const resolutionProps: Record = { "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_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 = { @@ -34,4 +37,6 @@ export type WindProps = { export const windProps: Record = { "map_1920x1080_wind": { offsetX: 0, offsetY: 0, scaleY: 1, }, "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 }, } diff --git a/web/src/pages/map-graphics/MapGraphics.tsx b/web/src/pages/map-graphics/MapGraphics.tsx index 6918b8a..57a20ed 100644 --- a/web/src/pages/map-graphics/MapGraphics.tsx +++ b/web/src/pages/map-graphics/MapGraphics.tsx @@ -3,6 +3,7 @@ import { MapView } from "../../components/map/MapView"; import { apiHost, ResultKeyVal } from "../../consts"; import { LoadingSpinner } from "../../components/spinner/LoadingSpinner"; import { faktiskaExport, faktiskaStations, FaktiskaStation } from "./faktiskaConfig"; +import type { FaktiskaTemplate } from "./faktiskaTemplates"; import "./mapGraphics.css"; type LatestTemperature = { city: FaktiskaStation; observedAt: string; value: number | null }; @@ -13,6 +14,10 @@ const emptyValues = (): Record => export function Faktiska() { const [values, setValues] = createSignal(emptyValues()); const [overrides, setOverrides] = createSignal>(new Set()); + const [template, setTemplate] = createSignal(faktiskaExport.defaultTemplate); + const productionSize = () => template() === "faktiska_1920x1080" + ? { width: 1920, height: 1080 } + : { width: 3840, height: 1440 }; const fetchLatest = async (): Promise => { const response = await fetch(`${apiHost}/api/query/latest-temperatures/${faktiskaStations.join(",")}`); @@ -63,7 +68,7 @@ export function Faktiska() { return
Daily newsroom production

Faktiskā

Current temperatures with manually prepared weather symbols and wind.

-
{faktiskaExport.width} × {faktiskaExport.height} PNG
+
{productionSize().width} × {productionSize().height} PNG
@@ -87,7 +92,13 @@ export function Faktiska() {

2. Add weather symbols and wind, then export

- +
+ + +
+ {currentTemplate => + + }
; } diff --git a/web/src/pages/map-graphics/faktiskaConfig.ts b/web/src/pages/map-graphics/faktiskaConfig.ts index f4e3675..0048564 100644 --- a/web/src/pages/map-graphics/faktiskaConfig.ts +++ b/web/src/pages/map-graphics/faktiskaConfig.ts @@ -38,7 +38,5 @@ export const faktiskaMarkerLayout: Record }; export const faktiskaExport = { - width: 3840, - height: 1440, - mapType: "map_3840x1440_wind" as const, + defaultTemplate: "faktiska_3840x1440" as const, }; diff --git a/web/src/pages/map-graphics/faktiskaTemplates.ts b/web/src/pages/map-graphics/faktiskaTemplates.ts new file mode 100644 index 0000000..b72bcb0 --- /dev/null +++ b/web/src/pages/map-graphics/faktiskaTemplates.ts @@ -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; + wind: { + x: number; + directionY: number; + speedY: number; + gustsY: number; + fontSize: number; + unitFontSize: number; + arrowSize: number; + }; +}; + +export const faktiskaTemplates: Record = { + 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 }, + }, +}; diff --git a/web/src/pages/map-graphics/mapGraphics.css b/web/src/pages/map-graphics/mapGraphics.css index 9700b1e..d8e3953 100644 --- a/web/src/pages/map-graphics/mapGraphics.css +++ b/web/src/pages/map-graphics/mapGraphics.css @@ -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} .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} +.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: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}} diff --git a/web/src/pages/water-temperature/WaterTemperature.tsx b/web/src/pages/water-temperature/WaterTemperature.tsx index 774d4ef..ddc9769 100644 --- a/web/src/pages/water-temperature/WaterTemperature.tsx +++ b/web/src/pages/water-temperature/WaterTemperature.tsx @@ -1,7 +1,7 @@ import { createEffect, createSignal, For, onMount } from 'solid-js'; -import map1920 from '../../assets/map_1920x1080.webp'; -import map3840 from '../../assets/map_3840x1440.webp'; +import map1920 from '../../assets/production/UDENS_KARTE_PAMATS_1920.png'; +import map3840 from '../../assets/production/UDENS_KARTE_PAMATS_3840.png'; import { download } from '../../helpers/download'; import './waterTemperature.css'; @@ -9,17 +9,22 @@ type OutputSize = '1920x1080' | '3840x1440'; type WaterArea = 'Jūra' | 'Līcis' | 'Kurzeme' | 'Zemgale' | 'Vidzeme' | 'Latgale'; type RangeValue = { min: string; max: string }; -const areas: Array<{ name: WaterArea; x: number; y: number }> = [ - { name: 'Jūra', x: 690, y: 720 }, - { name: 'Līcis', x: 1640, y: 455 }, - { name: 'Kurzeme', x: 1275, y: 785 }, - { name: 'Zemgale', x: 1880, y: 920 }, - { name: 'Vidzeme', x: 2235, y: 525 }, - { name: 'Latgale', x: 2540, y: 985 }, -]; +const areaNames: WaterArea[] = ['Jūra', 'Līcis', 'Kurzeme', 'Zemgale', 'Vidzeme', 'Latgale']; +const layouts: Record }> = { + '1920x1080': { fontSize: 52, positions: { + 'Jūra': { x: 237, y: 439 }, 'Līcis': { x: 815, y: 355 }, + 'Kurzeme': { x: 601, y: 523 }, 'Zemgale': { x: 981, y: 587 }, + 'Vidzeme': { x: 1236, y: 397 }, 'Latgale': { x: 1393, y: 673 }, + } }, + '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 => Object.fromEntries( - areas.map(({ name }) => [name, { min: '', max: '' }]), + areaNames.map(name => [name, { min: '', max: '' }]), ) as Record; export function WaterTemperature() { @@ -32,7 +37,7 @@ export function WaterTemperature() { ? { width: 3840, height: 1440, map: map3840 } : { 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) => { setRanges(current => ({ ...current, [name]: { ...current[name], [key]: value } })); @@ -57,8 +62,7 @@ export function WaterTemperature() { const ctx = canvas.getContext('2d')!; ctx.clearRect(0, 0, width, height); ctx.drawImage(image, 0, 0, width, height); - drawNameplate(ctx, width, height); - drawRanges(ctx, width, height, ranges()); + drawRanges(ctx, output(), ranges()); }; onMount(loadBackground); @@ -74,7 +78,7 @@ export function WaterTemperature() { return
ĒTERA GRAFIKA

Ūdens temperatūra

Manuāli ievadi temperatūras diapazonus un sagatavo karti eksportam.

- {complete() ? '6 diapazoni aizpildīti' : `${areas.filter(({ name }) => ranges()[name].min && ranges()[name].max).length} no 6 aizpildīti`} + {complete() ? '6 diapazoni aizpildīti' : `${areaNames.filter(name => ranges()[name].min && ranges()[name].max).length} no 6 aizpildīti`}
@@ -87,7 +91,7 @@ export function WaterTemperature() {

2. Ievadi temperatūras

-
{({ name }) =>
+
{name =>
{name} ... @@ -104,57 +108,18 @@ export function WaterTemperature() {
; } -function drawRanges(ctx: CanvasRenderingContext2D, width: number, height: number, values: Record) { - const scaleX = width / 3840; - 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; +function drawRanges(ctx: CanvasRenderingContext2D, output: OutputSize, values: Record) { + const layout = layouts[output]; ctx.textAlign = 'center'; 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]; if (!range.min.trim() || !range.max.trim()) return; const text = `${range.min}...${range.max}`; - const localX = x * scaleX; - const localY = y * scaleY; - 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); + const { x, y } = layout.positions[name]; + ctx.fillText(text, x, y); }); } - -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); -}