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 |
| 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,
+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.
- 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
+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.
- **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.
+48 -16
View File
@@ -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.
+19 -4
View File
@@ -1,12 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<html lang="lv">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<link rel="shortcut icon" type="image/ico" href="/src/assets/favicon.ico" />
<meta name="theme-color" content="#eaf4ff" />
<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" />
<title><{ VITE_APP_TITLE }></title>
<title>Laikapstākļi</title>
</head>
<body>
<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() {
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')
}
+73 -8
View File
@@ -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));
+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_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<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_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<string, WindProps> = {
"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 },
}
+13 -2
View File
@@ -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<FaktiskaStation, string> =>
export function Faktiska() {
const [values, setValues] = createSignal(emptyValues());
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 response = await fetch(`${apiHost}/api/query/latest-temperatures/${faktiskaStations.join(",")}`);
@@ -63,7 +68,7 @@ export function Faktiska() {
return <div class="mapGraphicsPage pageWorkspace faktiskaWorkspace">
<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 class="productionSize">{faktiskaExport.width} × {faktiskaExport.height} PNG</div>
<div class="productionSize">{productionSize().width} × {productionSize().height} PNG</div>
</div>
<section class="faktiskaDataBand">
@@ -87,7 +92,13 @@ export function Faktiska() {
<section class="mapProduction faktiskaProduction">
<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>
</div>;
}
+1 -3
View File
@@ -38,7 +38,5 @@ export const faktiskaMarkerLayout: Record<FaktiskaStation, FaktiskaMarkerLayout>
};
export const faktiskaExport = {
width: 3840,
height: 1440,
mapType: "map_3840x1440_wind" as const,
defaultTemplate: "faktiska_3840x1440" 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}
.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}}
@@ -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<OutputSize, { fontSize: number; positions: Record<WaterArea, { x: number; y: number }> }> = {
'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<WaterArea, RangeValue> => Object.fromEntries(
areas.map(({ name }) => [name, { min: '', max: '' }]),
areaNames.map(name => [name, { min: '', max: '' }]),
) as Record<WaterArea, RangeValue>;
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 <section class="pageWorkspace waterPage">
<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>
<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>
<section class="waterSetup" aria-labelledby="water-settings">
@@ -87,7 +91,7 @@ export function WaterTemperature() {
</div>
<div class="waterFields">
<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>
<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>
@@ -104,57 +108,18 @@ export function WaterTemperature() {
</section>;
}
function drawRanges(ctx: CanvasRenderingContext2D, width: number, height: number, values: Record<WaterArea, RangeValue>) {
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<WaterArea, RangeValue>) {
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);
}