From ffb89024f14883be5e5264a1aa25b5143c0100e5 Mon Sep 17 00:00:00 2001 From: b0txec Date: Tue, 18 Aug 2026 22:31:56 +0300 Subject: [PATCH] Add dedicated Faktiska map production workflow --- web/src/App.tsx | 4 +- web/src/components/map/MapView.tsx | 17 +++--- .../components/weatherIcons/IconInputs.tsx | 59 +++++-------------- web/src/css/aggregator.css | 1 + web/src/css/weatherIcons.css | 3 +- web/src/pages/cities/Cities.tsx | 2 +- web/src/pages/cities/result/ResultView.tsx | 19 +----- web/src/pages/home/Home.tsx | 1 + web/src/pages/map-graphics/MapGraphics.tsx | 54 +++++++++++++++++ web/src/pages/map-graphics/mapGraphics.css | 1 + 10 files changed, 89 insertions(+), 72 deletions(-) create mode 100644 web/src/pages/map-graphics/MapGraphics.tsx create mode 100644 web/src/pages/map-graphics/mapGraphics.css diff --git a/web/src/App.tsx b/web/src/App.tsx index 8261ed8..6d9a1c0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -11,6 +11,7 @@ import { WeatherIconStub } from './components/weatherIcons/WeatherIconStub'; import { Harmonie } from './pages/harmonie/Harmonie' import { LvgmcForecast } from './pages/lvgmc-forecast/LvgmcForecast' import { Home } from './pages/home/Home' +import { MapGraphics } from './pages/map-graphics/MapGraphics' console.log("env:", import.meta.env.MODE) console.log("api host:", apiHost) @@ -23,6 +24,7 @@ const App: Component = () => { + @@ -31,7 +33,7 @@ const App: Component = () => { const AppLayout: ParentComponent = (props) => { const pages = [ - ['/station', 'Stations'], ['/cities', 'City analysis'], ['/latvia', 'Latvia overview'], + ['/station', 'Stations'], ['/cities', 'City analysis'], ['/map-graphics', 'Map graphics'], ['/latvia', 'Latvia overview'], ['/database', 'Data archive'], ['/harmonie', 'HARMONIE'], ['/lvgmc-forecast', 'LVĢMC forecast'], ] return ( diff --git a/web/src/components/map/MapView.tsx b/web/src/components/map/MapView.tsx index 9b7ddb9..348e0f6 100644 --- a/web/src/components/map/MapView.tsx +++ b/web/src/components/map/MapView.tsx @@ -10,11 +10,11 @@ import { CityData, drawOnMap } from "./canvasDraw"; import { IconInputs } from "../weatherIcons/IconInputs"; import { download } from '../../helpers/download' -export const MapView: Component<{ type: MapResolution, data: () => ResultKeyVal[] }> = ({ type, data }) => { +export const MapView: Component<{ type: MapResolution, data: () => ResultKeyVal[], mode?: "temperature" | "faktiska" }> = ({ type, data, mode = "temperature" }) => { const [getCanvas, setCanvas] = createSignal(); const [getImg, setImg] = createSignal(new Image()); - const weatherIconsSingal = createSignal<{[key: string]: string;}>({}); - const [getWeatherIcons] = weatherIconsSingal; + const weatherIconsSignal = createSignal<{[key: string]: string;}>({}); + const [getWeatherIcons] = weatherIconsSignal; const [getTitle, setTitle] = createSignal(""); const [getSource, setSource] = createSignal(""); @@ -90,15 +90,16 @@ export const MapView: Component<{ type: MapResolution, data: () => ResultKeyVal[ return (
Map preview{props.width} × {props.height} px export
-
+
Broadcast overlay controlsOptional manual wind and weather-symbol overrides

These settings do not change the queried city values. Use them only when preparing a finished broadcast graphic.

-
-
+
+
+ Value and wind settings
{ props.showWind && } -
-
+ + {mode === "faktiska" && city)} weatherIconsSignal={weatherIconsSignal} />}
diff --git a/web/src/components/weatherIcons/IconInputs.tsx b/web/src/components/weatherIcons/IconInputs.tsx index 97c362f..e364e81 100644 --- a/web/src/components/weatherIcons/IconInputs.tsx +++ b/web/src/components/weatherIcons/IconInputs.tsx @@ -1,48 +1,17 @@ -import { Component, Signal } from "solid-js"; -import { weatherIconCities, weatherIcons } from "./iconConsts"; - +import { Component, createSignal, For, Show, Signal } from "solid-js"; +import { weatherIcons } from "./iconConsts"; import "../../css/weatherIcons.css"; -export const IconInputs: Component<{ weatherIconsSingal: Signal<{[key: string]: string;}>; }> = ({ - weatherIconsSingal: [getWeatherIcons, setWeatherIcons] -}) => { +export const IconInputs: Component<{cities: string[]; weatherIconsSignal: Signal>}> = (props) => { + const [icons, setIcons] = props.weatherIconsSignal; + const [selected, setSelected] = createSignal(weatherIcons[0]); + const assign = (city: string) => setIcons({...icons(), [city]: selected()}); + const clear = (city: string) => { const next = {...icons()}; delete next[city]; setIcons(next); }; + const applyAll = () => setIcons(Object.fromEntries(props.cities.map(city => [city, selected()]))); - function onIconChange(city: string, icon: string) { - const weatherIcons = { ...getWeatherIcons() }; - weatherIcons[city] = icon; - if (icon === "") delete weatherIcons[city]; - - setWeatherIcons(weatherIcons); - } - - return ( - <> -
- Weather symbols

Click a symbol to copy it, then paste it into a city field below.

- { weatherIcons.map(icon => - copyToClipboard(icon)}>{ icon }) - } -
-
- { weatherIconCities.map(city => -
- {city}: -   - onIconChange(city, e.target.value)} - /> -   -
) - } -
- - ) -} - -function copyToClipboard(icon: string) { - navigator.clipboard.writeText(icon); -} + return
+
Weather symbols

Select a font symbol, apply it to all cities, then change the exceptions.

+
{icon => }
+
{city =>
{city}
}
+
; +}; diff --git a/web/src/css/aggregator.css b/web/src/css/aggregator.css index 2628537..b9504ef 100644 --- a/web/src/css/aggregator.css +++ b/web/src/css/aggregator.css @@ -13,4 +13,5 @@ font-size: 11px; } .overlayFields{display:grid;grid-template-columns:minmax(260px,2fr) minmax(160px,1fr);gap:12px;padding:16px 16px 0}.overlayFields label,.overlayFields span{display:block}.overlayFields span{margin-bottom:6px;font-size:12px;font-weight:700}.overlayFields input{width:100%} +.mapControlSections{display:grid;gap:18px;padding:16px}.valueControls{padding-bottom:16px;border-bottom:1px solid var(--border)} .pageWorkspace{max-width:1800px;margin:auto;padding:34px 28px 70px}.pageHeading{display:flex;align-items:end;justify-content:space-between;gap:24px;margin-bottom:24px}.pageHeading h1{margin:5px 0 6px;font-size:34px}.pageHeading p{margin:0;color:var(--text-muted)}.selectionCount{padding:10px 14px;border:1px solid var(--border);border-radius:10px;background:var(--surface);font-weight:700}.aggregator>.container{display:grid;grid-template-columns:240px 300px minmax(0,1fr);gap:18px;align-items:start}.panel{padding:20px;border:1px solid var(--border);border-radius:16px;background:var(--surface);box-shadow:var(--shadow-sm)}.panel h2{margin:0 0 18px;font-size:16px}.cityPanel{max-height:calc(100vh - 160px);overflow:auto}.cityPanel ul{margin:12px 0;padding:0;columns:1}.cityPanel li{padding:3px 0}.queryPanel h4{margin:22px 0 9px}.queryPanel p{margin:10px 0}.resultsPanel{min-width:0}.emptyState{display:flex;min-height:260px;align-items:center;justify-content:center;flex-direction:column;gap:8px;margin-top:18px;padding:30px;border:1px dashed #bac6d4;border-radius:16px;background:rgba(255,255,255,.55);color:var(--text-muted);text-align:center}.emptyState strong{color:var(--text);font-size:18px}.mapWorkspace{margin-top:14px}.mapToolbar{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}.mapToolbar strong,.mapToolbar span{display:block}.mapToolbar span{margin-top:3px;color:var(--text-muted);font-size:12px}.broadcastControls{margin-bottom:14px;border:1px solid var(--border);border-radius:12px;background:var(--surface)}.broadcastControls summary{padding:14px 16px;cursor:pointer}.broadcastControls summary span,.broadcastControls summary strong,.broadcastControls summary small{display:block}.broadcastControls summary small,.controlHelp,.fieldHint{color:var(--text-muted);font-size:12px}.broadcastControls[open] summary{border-bottom:1px solid var(--border)}.broadcastControls .grid-1-2{padding:16px}.controlHelp{margin:16px 16px 0}.canvasFrame{overflow:hidden;border:1px solid var(--border);border-radius:14px;background:#d8dee6;box-shadow:var(--shadow-sm)}.canvasFrame canvas{display:block}.weatherIconButton{border-radius:7px}.fieldHint{margin:4px 0 10px}@media(max-width:1200px){.aggregator>.container{grid-template-columns:220px 1fr}.resultsPanel{grid-column:1/-1}.cityPanel{max-height:none}}@media(max-width:700px){.pageWorkspace{padding:24px 14px}.pageHeading{align-items:start;flex-direction:column}.aggregator>.container{grid-template-columns:1fr}.resultsPanel{grid-column:auto}} diff --git a/web/src/css/weatherIcons.css b/web/src/css/weatherIcons.css index e791efe..a9ac486 100644 --- a/web/src/css/weatherIcons.css +++ b/web/src/css/weatherIcons.css @@ -22,4 +22,5 @@ font-family: 'Daira by LTV grafika'; font-size: 20px; color: white; -} \ No newline at end of file +} +.weatherIconButton.selected{background:var(--accent);color:#fff;box-shadow:0 0 0 2px var(--accent-pale)} diff --git a/web/src/pages/cities/Cities.tsx b/web/src/pages/cities/Cities.tsx index 6cc4c40..8ab0ecf 100644 --- a/web/src/pages/cities/Cities.tsx +++ b/web/src/pages/cities/Cities.tsx @@ -22,7 +22,7 @@ export function Cities() { return (
-
Historical observations

City analysis

Choose stations and a calculation, then compare results or prepare a map graphic.

{getCities().size} cities selected
+
Historical observations

City analysis

Choose stations and a calculation, then compare the resulting values.

{getCities().size} cities selected
- - - - - - - - - - - -
); diff --git a/web/src/pages/home/Home.tsx b/web/src/pages/home/Home.tsx index ee84fcf..d0e4be0 100644 --- a/web/src/pages/home/Home.tsx +++ b/web/src/pages/home/Home.tsx @@ -4,6 +4,7 @@ import './home.css' const tools = [ ['/station','Monitor','Stations','Inspect recent measurements on a map and open detailed station charts.','◉'], ['/cities','Analyse & export','City analysis','Compare cities, aggregate values, and prepare map graphics.','▦'], + ['/map-graphics','Produce','Map graphics','Build temperature and Faktiskā newsroom graphics with export-ready overlays.','▣'], ['/latvia','Summarise','Latvia overview','Review country-wide minimum, maximum, average, and total values.','⌁'], ['/database','Browse','Data archive','Find available dates and inspect individual hourly records.','▤'], ['/harmonie','Forecast model','HARMONIE','Render forecast fields from locally stored HARMONIE GRIB files.','◫'], diff --git a/web/src/pages/map-graphics/MapGraphics.tsx b/web/src/pages/map-graphics/MapGraphics.tsx new file mode 100644 index 0000000..34c14bb --- /dev/null +++ b/web/src/pages/map-graphics/MapGraphics.tsx @@ -0,0 +1,54 @@ +import moment from "moment"; +import { createResource, createSignal, Show } from "solid-js"; +import { SelectCity } from "../../components/SelectCity"; +import { SelectTimeRange } from "../../components/SelectTimeRange"; +import { MapView } from "../../components/map/MapView"; +import { MapResolution } from "../../components/map/mapConsts"; +import { FETCH_DELAY_MS, apiHost, ResultKeyVal } from "../../consts"; +import { isQueryResult, QueryResult } from "../cities/helpers"; +import { LoadingSpinner } from "../../components/spinner/LoadingSpinner"; +import "./mapGraphics.css"; + +export function MapGraphics() { + const now = new Date(new Date().setMinutes(30)); + const [cities, setCities] = createSignal>(new Set()); + const [start, setStart] = createSignal(moment(now).subtract(1, "day").toDate()); + const [end, setEnd] = createSignal(now); + const [field, setField] = createSignal("tempMax"); + const [key, setKey] = createSignal("max"); + const [mode, setMode] = createSignal<"temperature" | "faktiska">("faktiska"); + const [resolution, setResolution] = createSignal<"1920x1080" | "3840x1440">("1920x1080"); + const [wind, setWind] = createSignal(false); + const [trigger, setTrigger] = createSignal(0); + + const fetchData = async () => { + if (!cities().size) return undefined; + await new Promise(resolve => setTimeout(resolve, FETCH_DELAY_MS)); + const cityPath = [...cities()].join(","); + const range = `${moment(start()).format("YYYYMMDD_HHmm")}-${moment(end()).format("YYYYMMDD_HHmm")}`; + const response = await fetch(`${apiHost}/api/query/city/${cityPath}/${range}/hour/${field()}/${key()}`); + if (!response.ok) throw new Error(`Query failed (${response.status})`); + return response.json(); + }; + const [result] = createResource(trigger, fetchData); + const mapType = (): MapResolution => `map_${resolution()}${wind() ? "_wind" : ""}` as MapResolution; + const cityData = (): ResultKeyVal[] => Object.entries((result() as QueryResult).result).map(([city, value]) => [city, typeof value === "number" ? value : -99]); + + return
+
Newsroom production

Map graphics

Create a temperature map or Faktiskā weather map and export the finished PNG.

{cities().size} cities selected
+
+ +
+

2. Choose graphic

+
Graphic type
+
Output
+
+

3. Retrieve temperatures

+ +
Could not load map data{result.error.message}
+

4. Finish and export

+
Choose cities and load dataThe production controls and map preview will appear here.
+
+
+
; +} diff --git a/web/src/pages/map-graphics/mapGraphics.css b/web/src/pages/map-graphics/mapGraphics.css new file mode 100644 index 0000000..8401627 --- /dev/null +++ b/web/src/pages/map-graphics/mapGraphics.css @@ -0,0 +1 @@ +.graphicsSetup{display:grid;grid-template-columns:240px minmax(0,1fr);gap:18px;align-items:start}.graphicsMain{display:grid;gap:18px}.productionOptions,.dataOptions{padding:20px}.choiceGroup{display:grid;grid-template-columns:130px 1fr;gap:16px;align-items:start;margin-top:16px}.choiceGroup>span,.dataOptions label>span{display:block;font-size:12px;font-weight:700}.choiceGroup>div{display:flex;flex-wrap:wrap;gap:9px}.choiceGroup button{min-width:150px;text-align:left}.choiceGroup button small{display:block;margin-top:3px;color:var(--text-muted);font-family:Inter,sans-serif}.choiceGroup button.active{border-color:var(--accent);background:var(--accent-pale);color:var(--accent-strong)}.windChoice{display:flex;align-items:center;gap:7px;padding:9px 12px}.dataOptionsGrid{display:grid;grid-template-columns:minmax(250px,1.4fr) 1fr 1fr;gap:18px;align-items:end;margin-bottom:16px}.dataOptions select{width:100%;margin-top:6px}.mapProduction>h2{margin:0 0 10px}.symbolEditor{min-width:0}.symbolEditorHeading{display:flex;align-items:start;justify-content:space-between;gap:12px}.symbolPalette{display:flex;flex-wrap:wrap;gap:5px;margin:10px 0 16px}.citySymbols{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:8px}.citySymbolRow{display:grid;grid-template-columns:1fr 52px 54px;gap:6px;align-items:center;padding:7px;border:1px solid var(--border);border-radius:9px}.assignedSymbol{font-family:'Daira by LTV grafika';font-size:28px;padding:2px}.clearSymbol{padding:5px;font-size:11px}@media(max-width:900px){.graphicsSetup{grid-template-columns:1fr}.dataOptionsGrid{grid-template-columns:1fr}.choiceGroup{grid-template-columns:1fr}}