Add dedicated Faktiska map production workflow

This commit is contained in:
b0txec
2026-08-18 22:31:56 +03:00
parent 67decb330b
commit ffb89024f1
10 changed files with 89 additions and 72 deletions
+3 -1
View File
@@ -11,6 +11,7 @@ import { WeatherIconStub } from './components/weatherIcons/WeatherIconStub';
import { Harmonie } from './pages/harmonie/Harmonie' import { Harmonie } from './pages/harmonie/Harmonie'
import { LvgmcForecast } from './pages/lvgmc-forecast/LvgmcForecast' import { LvgmcForecast } from './pages/lvgmc-forecast/LvgmcForecast'
import { Home } from './pages/home/Home' import { Home } from './pages/home/Home'
import { MapGraphics } from './pages/map-graphics/MapGraphics'
console.log("env:", import.meta.env.MODE) console.log("env:", import.meta.env.MODE)
console.log("api host:", apiHost) console.log("api host:", apiHost)
@@ -23,6 +24,7 @@ const App: Component = () => {
<Route path="/lvgmc-forecast" component={LvgmcForecast} /> <Route path="/lvgmc-forecast" component={LvgmcForecast} />
<Route path="/station" component={Station} /> <Route path="/station" component={Station} />
<Route path="/cities" component={Cities} /> <Route path="/cities" component={Cities} />
<Route path="/map-graphics" component={MapGraphics} />
<Route path="/latvia" component={Country} /> <Route path="/latvia" component={Country} />
<Route path="/database" component={Database} /> <Route path="/database" component={Database} />
</Router> </Router>
@@ -31,7 +33,7 @@ const App: Component = () => {
const AppLayout: ParentComponent = (props) => { const AppLayout: ParentComponent = (props) => {
const pages = [ 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'], ['/database', 'Data archive'], ['/harmonie', 'HARMONIE'], ['/lvgmc-forecast', 'LVĢMC forecast'],
] ]
return ( return (
+9 -8
View File
@@ -10,11 +10,11 @@ import { CityData, drawOnMap } from "./canvasDraw";
import { IconInputs } from "../weatherIcons/IconInputs"; import { IconInputs } from "../weatherIcons/IconInputs";
import { download } from '../../helpers/download' 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<HTMLCanvasElement>(); const [getCanvas, setCanvas] = createSignal<HTMLCanvasElement>();
const [getImg, setImg] = createSignal(new Image()); const [getImg, setImg] = createSignal(new Image());
const weatherIconsSingal = createSignal<{[key: string]: string;}>({}); const weatherIconsSignal = createSignal<{[key: string]: string;}>({});
const [getWeatherIcons] = weatherIconsSingal; const [getWeatherIcons] = weatherIconsSignal;
const [getTitle, setTitle] = createSignal(""); const [getTitle, setTitle] = createSignal("");
const [getSource, setSource] = createSignal(""); const [getSource, setSource] = createSignal("");
@@ -90,15 +90,16 @@ export const MapView: Component<{ type: MapResolution, data: () => ResultKeyVal[
return ( return (
<div class="mapWorkspace"> <div class="mapWorkspace">
<div class="mapToolbar"><div><strong>Map preview</strong><span>{props.width} × {props.height} px export</span></div><button class="primary" onClick={downloadPng}>Download PNG</button></div> <div class="mapToolbar"><div><strong>Map preview</strong><span>{props.width} × {props.height} px export</span></div><button class="primary" onClick={downloadPng}>Download PNG</button></div>
<details class="broadcastControls"> <details class="broadcastControls" open>
<summary><span><strong>Broadcast overlay controls</strong><small>Optional manual wind and weather-symbol overrides</small></span></summary> <summary><span><strong>Broadcast overlay controls</strong><small>Optional manual wind and weather-symbol overrides</small></span></summary>
<p class="controlHelp">These settings do not change the queried city values. Use them only when preparing a finished broadcast graphic.</p> <p class="controlHelp">These settings do not change the queried city values. Use them only when preparing a finished broadcast graphic.</p>
<div class="overlayFields"> <div class="overlayFields">
<label><span>Title</span><input type="text" placeholder="19. APRĪĻA GAISA TEMPERATŪRAS REKORDI" value={getTitle()} onInput={e => setTitle(e.target.value)} /></label> <label><span>Title</span><input type="text" placeholder="19. APRĪĻA GAISA TEMPERATŪRAS REKORDI" value={getTitle()} onInput={e => setTitle(e.target.value)} /></label>
<label><span>Source</span><input type="text" placeholder="LVĢMC" value={getSource()} onInput={e => setSource(e.target.value)} /></label> <label><span>Source</span><input type="text" placeholder="LVĢMC" value={getSource()} onInput={e => setSource(e.target.value)} /></label>
</div> </div>
<div class="grid-1-2"> <div class="mapControlSections">
<div> <section class="valueControls">
<strong>Value and wind settings</strong>
<div> <div>
<label> <label>
<input <input
@@ -110,8 +111,8 @@ export const MapView: Component<{ type: MapResolution, data: () => ResultKeyVal[
</label> </label>
</div> </div>
{ props.showWind && <WindInputs signals={windSignals} /> } { props.showWind && <WindInputs signals={windSignals} /> }
</div> </section>
<div><IconInputs weatherIconsSingal={weatherIconsSingal} /></div> {mode === "faktiska" && <IconInputs cities={data().map(([city]) => city)} weatherIconsSignal={weatherIconsSignal} />}
</div> </div>
</details> </details>
<div class="canvasFrame"> <div class="canvasFrame">
+14 -45
View File
@@ -1,48 +1,17 @@
import { Component, Signal } from "solid-js"; import { Component, createSignal, For, Show, Signal } from "solid-js";
import { weatherIconCities, weatherIcons } from "./iconConsts"; import { weatherIcons } from "./iconConsts";
import "../../css/weatherIcons.css"; import "../../css/weatherIcons.css";
export const IconInputs: Component<{ weatherIconsSingal: Signal<{[key: string]: string;}>; }> = ({ export const IconInputs: Component<{cities: string[]; weatherIconsSignal: Signal<Record<string, string>>}> = (props) => {
weatherIconsSingal: [getWeatherIcons, setWeatherIcons] 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) { return <section class="symbolEditor">
const weatherIcons = { ...getWeatherIcons() }; <div class="symbolEditorHeading"><div><strong>Weather symbols</strong><p class="fieldHint">Select a font symbol, apply it to all cities, then change the exceptions.</p></div><button type="button" onClick={applyAll}>Apply selected to all</button></div>
weatherIcons[city] = icon; <div class="symbolPalette" role="listbox" aria-label="Weather symbols"><For each={weatherIcons}>{icon => <button type="button" class="weatherIconButton" classList={{selected: selected() === icon}} onClick={() => setSelected(icon)}>{icon}</button>}</For></div>
if (icon === "") delete weatherIcons[city]; <div class="citySymbols"><For each={props.cities}>{city => <div class="citySymbolRow"><span>{city}</span><button type="button" class="assignedSymbol" onClick={() => assign(city)} title="Assign selected symbol"><Show when={icons()[city]} fallback="Assign">{icons()[city]}</Show></button><button type="button" class="clearSymbol" disabled={!icons()[city]} onClick={() => clear(city)}>Clear</button></div>}</For></div>
</section>;
setWeatherIcons(weatherIcons); };
}
return (
<>
<div>
<strong>Weather symbols</strong><p class="fieldHint">Click a symbol to copy it, then paste it into a city field below.</p>
{ weatherIcons.map(icon =>
<span class="weatherIconButton" onClick={() => copyToClipboard(icon)}>{ icon }</span>)
}
</div>
<div>
{ weatherIconCities.map(city =>
<div style="float: left;">
{city}:
&nbsp;
<input
type="text"
class="weatherIconInput"
size="1"
maxlength="1"
onInput={e => onIconChange(city, e.target.value)}
/>
&nbsp;
</div>)
}
</div>
</>
)
}
function copyToClipboard(icon: string) {
navigator.clipboard.writeText(icon);
}
+1
View File
@@ -13,4 +13,5 @@
font-size: 11px; 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%} .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}} .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}}
+2 -1
View File
@@ -22,4 +22,5 @@
font-family: 'Daira by LTV grafika'; font-family: 'Daira by LTV grafika';
font-size: 20px; font-size: 20px;
color: white; color: white;
} }
.weatherIconButton.selected{background:var(--accent);color:#fff;box-shadow:0 0 0 2px var(--accent-pale)}
+1 -1
View File
@@ -22,7 +22,7 @@ export function Cities() {
return ( return (
<div class="aggregator pageWorkspace"> <div class="aggregator pageWorkspace">
<div class="pageHeading"><div><span class="eyebrow">Historical observations</span><h1>City analysis</h1><p>Choose stations and a calculation, then compare results or prepare a map graphic.</p></div><div class="selectionCount">{getCities().size} cities selected</div></div> <div class="pageHeading"><div><span class="eyebrow">Historical observations</span><h1>City analysis</h1><p>Choose stations and a calculation, then compare the resulting values.</p></div><div class="selectionCount">{getCities().size} cities selected</div></div>
<div class="container"> <div class="container">
<aside class="column panel cityPanel"> <aside class="column panel cityPanel">
<h2>1. Choose cities</h2> <h2>1. Choose cities</h2>
+3 -16
View File
@@ -4,12 +4,11 @@ import "../../../css/Result.css"
import { ResultKeyVal, resultOrder, ResultOrderKeys } from "../../../consts"; import { ResultKeyVal, resultOrder, ResultOrderKeys } from "../../../consts";
import { SelectOrder } from "../SelectOrder"; import { SelectOrder } from "../SelectOrder";
import { QueryResult } from "../helpers"; import { QueryResult } from "../helpers";
import { MapView } from "../../../components/map/MapView";
import { GridResult } from "./GridResult"; import { GridResult } from "./GridResult";
type ResultView = "grid" | "list" | "map_1920x1080" | "map_1920x1080_wind" | "map_3840x1440" | "map_3840x1440_wind"; type ResultView = "grid" | "list";
const resultViews: Array<ResultView> = ["grid", "list", "map_1920x1080", "map_1920x1080_wind", "map_3840x1440", "map_3840x1440_wind"]; const resultViews: Array<ResultView> = ["grid", "list"];
const viewLabels: Record<ResultView,string> = {grid:'grid',list:'list',map_1920x1080:'map_1920x1080',map_1920x1080_wind:'map_1920x1080_wind',map_3840x1440:'map_3840x1440',map_3840x1440_wind:'map_3840x1440_wind'} const viewLabels: Record<ResultView,string> = {grid:'grid',list:'list'}
export const ResultView: Component<{ export const ResultView: Component<{
result: Resource<QueryResult> result: Resource<QueryResult>
@@ -73,18 +72,6 @@ export const ResultView: Component<{
/>)} />)}
</div> </div>
</Show> </Show>
<Show when={getResultView() === "map_1920x1080"}>
<MapView type="map_1920x1080" data={cityData} />
</Show>
<Show when={getResultView() === "map_1920x1080_wind"}>
<MapView type="map_1920x1080_wind" data={cityData} />
</Show>
<Show when={getResultView() === "map_3840x1440"}>
<MapView type="map_3840x1440" data={cityData} />
</Show>
<Show when={getResultView() === "map_3840x1440_wind"}>
<MapView type="map_3840x1440_wind" data={cityData} />
</Show>
</div> </div>
</div> </div>
); );
+1
View File
@@ -4,6 +4,7 @@ import './home.css'
const tools = [ const tools = [
['/station','Monitor','Stations','Inspect recent measurements on a map and open detailed station charts.','◉'], ['/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.','▦'], ['/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.','⌁'], ['/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.','▤'], ['/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.','◫'], ['/harmonie','Forecast model','HARMONIE','Render forecast fields from locally stored HARMONIE GRIB files.','◫'],
@@ -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<Set<string>>(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 <div class="mapGraphicsPage pageWorkspace">
<div class="pageHeading"><div><span class="eyebrow">Newsroom production</span><h1>Map graphics</h1><p>Create a temperature map or Faktiskā weather map and export the finished PNG.</p></div><div class="selectionCount">{cities().size} cities selected</div></div>
<div class="graphicsSetup">
<aside class="panel cityPanel"><h2>1. Choose cities</h2><SelectCity getCities={cities} setCities={setCities}/></aside>
<section class="graphicsMain">
<div class="panel productionOptions"><h2>2. Choose graphic</h2>
<div class="choiceGroup"><span>Graphic type</span><div><button classList={{active: mode() === "temperature"}} onClick={() => setMode("temperature")}>Temperature map<small>Temperatures only</small></button><button classList={{active: mode() === "faktiska"}} onClick={() => setMode("faktiska")}>Faktiskā<small>Temperatures + weather symbols</small></button></div></div>
<div class="choiceGroup"><span>Output</span><div><button classList={{active: resolution() === "1920x1080"}} onClick={() => setResolution("1920x1080")}>1920 × 1080</button><button classList={{active: resolution() === "3840x1440"}} onClick={() => setResolution("3840x1440")}>3840 × 1440</button><label class="windChoice"><input type="checkbox" checked={wind()} onChange={e => setWind(e.target.checked)}/> Wind layout</label></div></div>
</div>
<div class="panel dataOptions"><h2>3. Retrieve temperatures</h2><div class="dataOptionsGrid"><SelectTimeRange getStart={start} setStart={setStart} getEnd={end} setEnd={setEnd}/><label><span>Temperature field</span><select value={field()} onChange={e => setField(e.target.value)}><option value="tempMax">Maximum temperature</option><option value="tempMin">Minimum temperature</option><option value="tempAvg">Average temperature</option></select></label><label><span>Calculation</span><select value={key()} onChange={e => setKey(e.target.value)}><option value="max">Maximum</option><option value="min">Minimum</option><option value="avg">Average</option></select></label></div><button class="primary" disabled={!cities().size} onClick={() => setTrigger(Date.now())}>Load map data</button></div>
<Show when={result.loading}><LoadingSpinner text="Loading map data"/></Show>
<Show when={result.error}><div class="emptyState"><strong>Could not load map data</strong><span>{result.error.message}</span></div></Show>
<Show when={result() && isQueryResult(result())}><section class="mapProduction"><h2>4. Finish and export</h2><MapView type={mapType()} data={cityData} mode={mode()}/></section></Show>
<Show when={!result() && !result.loading && !result.error}><div class="emptyState"><strong>Choose cities and load data</strong><span>The production controls and map preview will appear here.</span></div></Show>
</section>
</div>
</div>;
}
@@ -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}}