Add dashboard UI foundation and map export workflow

This commit is contained in:
b0txec
2026-08-18 21:44:09 +03:00
parent 90631a7586
commit bb1d9fc78c
13 changed files with 90 additions and 87 deletions
+7 -7
View File
@@ -37,20 +37,20 @@ Status: in progress
## Phase 1 — Behavior discovery and bug inventory
Status: pending
Status: in progress
- [ ] Walk through every page with synthetic data.
- [ ] Document the actual purpose and intended users of each workflow.
- [ ] Separate analytical dashboard features from broadcast-graphic authoring tools.
- [x] Separate analytical dashboard features from broadcast-graphic authoring tools on the city-results map.
- [ ] Record unclear controls, missing units, broken states, and layout problems.
- [ ] Fix the `atmPressire` frontend field typo.
- [ ] Fix shifted Database export columns caused by duplicated `tempMax`.
- [ ] Correct malformed integer route handling.
- [ ] Add consistent loading, empty, and error states.
- [ ] Add consistent loading, empty, and error states. (City selection empty state completed.)
## Phase 2 — Test safety net
Status: pending
Status: in progress
- [ ] Add backend route tests for representative station and country queries.
- [ ] Add database integration tests for aggregation and export behavior.
@@ -114,10 +114,10 @@ Status: pending
- [ ] Separate historical analysis, live station monitoring, database inspection, HARMONIE visualization, and broadcast graphics.
- [ ] Replace technical/internal labels with task-oriented language.
- [ ] Replace the character-based weather-icon entry workflow with a visual picker or automatic mapping.
- [ ] Explain or automate manual wind and weather-icon inputs.
- [x] Explain and visually separate manual wind and weather-icon inputs from queried data.
- [ ] Add units, legends, contextual help, and clear date semantics.
- [ ] Establish a responsive layout, typography, spacing, and component system.
- [ ] Design explicit export/download workflows for broadcast assets.
- [x] Establish the first responsive layout, typography, spacing, and component-system foundation.
- [x] Add an explicit PNG download workflow for broadcast map assets.
- [ ] Test target resolutions and real workplace display conditions.
- [ ] Check keyboard navigation, contrast, focus states, and screen-reader labeling.
+18 -19
View File
@@ -1,5 +1,5 @@
import { Component } from 'solid-js'
import { Route, Router } from '@solidjs/router'
import { A, Route, Router } from '@solidjs/router'
import styles from './css/menu.module.css';
import { Country } from './pages/country/Country';
@@ -10,36 +10,35 @@ import { apiHost } from './consts';
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'
console.log("env:", import.meta.env.MODE)
console.log("api host:", apiHost)
const App: Component = () => {
const pages = ['station', 'cities', 'latvia', 'database', 'harmonie', 'lvgmc-forecast']
const pages = [
['/station', 'Stations'], ['/cities', 'City analysis'], ['/latvia', 'Latvia overview'],
['/database', 'Data archive'], ['/harmonie', 'HARMONIE'], ['/lvgmc-forecast', 'LVĢMC forecast'],
]
return (
<div>
<div class="grid-1-1">
<div><WeatherIconStub /></div>
<div>
<ul class={styles.menu}>
{ pages.map(page =>
<li>
<a href={page}>{page}</a>
</li>
)}
</ul>
</div>
</div>
<Router>
<Route path="/" component={Harmonie} />
<Router>
<div class="appShell">
<WeatherIconStub />
<header class={styles.header}>
<A class={styles.brand} href="/"><span class={styles.brandMark}>W</span><span><strong>WeatherTool</strong><small>Weather data workspace</small></span></A>
<nav aria-label="Main navigation"><ul class={styles.menu}>{pages.map(([path, label]) => <li><A href={path} activeClass={styles.active}>{label}</A></li>)}</ul></nav>
</header>
<main class="appContent">
<Route path="/" component={Home} />
<Route path="/harmonie" component={Harmonie} />
<Route path="/lvgmc-forecast" component={LvgmcForecast} />
<Route path="/station" component={Station} />
<Route path="/cities" component={Cities} />
<Route path="/latvia" component={Country} />
<Route path="/database" component={Database} />
</Router>
</div>
</main>
</div>
</Router>
);
};
+17 -7
View File
@@ -8,6 +8,7 @@ import { cityCoords } from "../cityCoords";
import { MapResolution, resolutionProps, windProps, WindProps } from "./mapConsts";
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 }) => {
const [getCanvas, setCanvas] = createSignal<HTMLCanvasElement>();
@@ -75,13 +76,19 @@ export const MapView: Component<{ type: MapResolution, data: () => ResultKeyVal[
});
const getStyleSize = () => {
return props.width === 1920
? { "width": "1000px", "height": "562px" }
: { "width": "1000px", "height": "374px" };
return { "width": "100%", "height": "auto" };
}
function downloadPng() {
getCanvas()!.toBlob(blob => blob && download(blob, `weather-${type}.png`), 'image/png')
}
return (
<>
<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>
<details class="broadcastControls">
<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>
<div class="grid-1-2">
<div>
<div>
@@ -98,12 +105,15 @@ export const MapView: Component<{ type: MapResolution, data: () => ResultKeyVal[
</div>
<div><IconInputs weatherIconsSingal={weatherIconsSingal} /></div>
</div>
</details>
<div class="canvasFrame">
<canvas
ref={setCanvas}
width={props.width+"px"}
height={props.height+"px"}
width={props.width}
height={props.height}
style={getStyleSize()}
/>
</>
</div>
</div>
);
}
@@ -18,6 +18,7 @@ export const IconInputs: Component<{ weatherIconsSingal: Signal<{[key: string]:
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>)
}
+1
View File
@@ -4,6 +4,7 @@
justify-content: space-between;
padding: 10px 0;
}
.resultTitle>span:first-child{display:flex;flex-wrap:wrap;gap:7px}.resultTitle>span:first-child br{display:none}.resultTitle input{margin:0}.grid-view{margin-top:12px}.grid-view .item,.list-view .item{border:1px solid var(--border);border-radius:12px;background:var(--surface)!important;box-shadow:var(--shadow-sm)}
.resultTitle h3 {
margin: 0;
+1
View File
@@ -12,3 +12,4 @@
.timeRangeTitle div {
font-size: 11px;
}
.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}}
+9 -8
View File
@@ -1,6 +1,9 @@
:root{--text:#182230;--text-muted:#667085;--surface:#fff;--surface-soft:#f8fafc;--surface-strong:#edf1f5;--border:#dfe5ec;--accent:#2477c5;--accent-strong:#185a98;--accent-soft:#91bfe8;--accent-pale:#e9f3fc;--success:#269368;--danger:#c84646;--shadow-sm:0 5px 16px rgba(16,24,40,.05);--shadow-md:0 16px 36px rgba(16,24,40,.10)}
*{box-sizing:border-box}
body {
margin: 0;
font-family: Arial, sans-serif;
font-family: Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
color:var(--text);background:#f3f5f8;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@@ -31,17 +34,14 @@ thead {
font-display: swap;
}
.primary {
background-color: #ffcc66;
border: none;
padding: 5px 10px;
.primary,.secondary,button {
min-height:38px;background-color:var(--surface-strong);border:1px solid transparent;border-radius:9px;padding:8px 14px;
cursor: pointer;
font-family: 'Rubik', sans-serif;
}
.primary:hover {
background-color: #ffb92d;
}
.primary{background:var(--accent);color:#fff}.primary:hover{background:var(--accent-strong)}
input[type=text],input[type=date],input[type=datetime-local],select{min-height:38px;padding:7px 10px;border:1px solid #cfd7e2;border-radius:8px;background:#fff}button:disabled,input:disabled{cursor:not-allowed;opacity:.48}.appContent{min-height:calc(100vh - 73px)}
table td {
padding: 3px 7px;
@@ -82,3 +82,4 @@ table tr:hover {
grid-template-columns: 1fr 2fr;
gap: 10px;
}
.weatherIconStub{position:fixed!important;width:1px;height:1px;overflow:hidden;opacity:0;pointer-events:none}
+3 -28
View File
@@ -1,28 +1,3 @@
.menu {
padding: 8px 20px;
text-align: right;
}
.menu li{
display: inline;
padding: 10px 0 10px 10px;
cursor: pointer;
}
.menu li:not(:first-child)::before {
content: '•';
margin-right: 1ch;
}
.menu li a {
color: black;
text-decoration: none;
}
.menu li a:hover {
text-decoration: underline;
}
.active {
font-weight: bold;
}
.header{position:sticky;top:0;z-index:100;display:flex;align-items:center;justify-content:space-between;gap:28px;min-height:72px;padding:0 30px;border-bottom:1px solid var(--border);background:rgba(249,250,252,.92);backdrop-filter:blur(16px)}
.brand{display:flex;align-items:center;gap:11px;color:var(--text);text-decoration:none}.brandMark{display:grid;place-items:center;width:38px;height:38px;border-radius:11px;background:var(--accent);color:#fff;font-family:Rubik,sans-serif;font-size:19px}.brand strong,.brand small{display:block}.brand small{margin-top:2px;color:var(--text-muted);font-size:11px}.menu{display:flex;align-items:center;gap:4px;margin:0;padding:0;list-style:none}.menu li{margin:0;padding:0}.menu a{display:block;padding:10px 12px;border-radius:9px;color:var(--text-muted);font-size:13px;font-weight:700;text-decoration:none}.menu a:hover{background:var(--surface-strong);color:var(--text)}.menu a.active{background:var(--accent-pale);color:var(--accent-strong)}
@media(max-width:980px){.header{align-items:flex-start;flex-direction:column;padding:14px 18px}.menu{max-width:100%;overflow-x:auto}.menu a{white-space:nowrap}}
+10 -7
View File
@@ -21,12 +21,15 @@ export function Cities() {
const [getGranularity, setGranularity] = createSignal("hour");
return (
<div class="aggregator">
<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="container">
<div class="column">
<aside class="column panel cityPanel">
<h2>1. Choose cities</h2>
<SelectCity getCities={getCities} setCities={setCities} />
</div>
<div class="column">
</aside>
<aside class="column panel queryPanel">
<h2>2. Configure query</h2>
<div>
<SelectTimeRange
getStart={getStart}
@@ -43,8 +46,8 @@ export function Cities() {
<SelectKey getKey={getKey} setKey={setKey} />
<SelectGranularity getGranularity={getGranularity} setGranularity={setGranularity} />
</div>
</div>
<div class="column">
</aside>
<section class="column resultsPanel">
<QueryView
getCities={getCities}
getStart={getStart}
@@ -53,7 +56,7 @@ export function Cities() {
getKey={getKey}
getGranularity={getGranularity}
/>
</div>
</section>
</div>
</div>
);
+3 -4
View File
@@ -21,7 +21,7 @@ export const QueryView: Component<{
const [getTimestamp, setTimestamp] = createSignal<number>(0);
const fetchQuery = async (timestamp: number) => {
if (cities() === "") return new Error("ERROR: Select cities!");
if (cities() === "") return undefined;
await new Promise(resolve => setTimeout(resolve, FETCH_DELAY_MS))
const response = await fetch(`${apiHost}/api/query/city/${cities()}/${queryStart()}-${queryEnd()}/${props.getGranularity()}/${props.getField()}/${props.getKey()}`);
const json = await response.json();
@@ -36,15 +36,14 @@ export const QueryView: Component<{
type="button"
class="primary"
value="Query Data"
disabled={cities() === ""}
onClick={() => setTimestamp(Date.now())}
/>
{ queryResource.loading && <LoadingSpinner text="Loading query" /> }
{ queryResource.error && (
<div>Error while querying: ${queryResource.error}</div>
)}
{ queryResource() && queryResource() instanceof Error &&
<div>{ queryResource().message }</div>
}
{ cities() === "" && <div class="emptyState"><strong>Select at least one city</strong><span>Your results and map options will appear here after you choose cities and run the query.</span></div> }
{ queryResource() && isQueryResult( queryResource() ) &&
<ResultView result={queryResource} />
}
+2 -2
View File
@@ -9,6 +9,7 @@ import { GridResult } from "./GridResult";
type ResultView = "grid" | "list" | "map_1920x1080" | "map_1920x1080_wind" | "map_3840x1440" | "map_3840x1440_wind";
const resultViews: Array<ResultView> = ["grid", "list", "map_1920x1080", "map_1920x1080_wind", "map_3840x1440", "map_3840x1440_wind"];
const viewLabels: Record<ResultView,string> = {grid:'Cards',list:'List',map_1920x1080:'HD map',map_1920x1080_wind:'HD map + wind',map_3840x1440:'Ultrawide map',map_3840x1440_wind:'Ultrawide + wind'}
export const ResultView: Component<{
result: Resource<QueryResult>
@@ -40,11 +41,10 @@ export const ResultView: Component<{
<input
type="button"
class="secondary"
value={viewName}
value={viewLabels[viewName]}
disabled={!showViewButton(viewName)}
onClick={() => setResultView(viewName)}
/>
&nbsp; | &nbsp;
</>
)}
</span>
+12
View File
@@ -0,0 +1,12 @@
import { A } from '@solidjs/router'
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.','▦'],
['/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.','◫'],
['/lvgmc-forecast','Forecast products','LVĢMC forecast','Open prepared LVĢMC city forecasts and broadcast tables.','☁'],
]
export function Home() { return <section class="homePage"><div class="homeHero"><div><p class="eyebrow">Weather data and production tools</p><h1>Choose a workspace</h1><p class="heroCopy">Explore station history, compare cities, inspect forecasts, or prepare weather graphics from one place.</p></div><div class="environmentBadge"><span class="statusDot"/>Development dataset<small>Synthetic station observations are active</small></div></div><div class="toolGrid">{tools.map(([path,eyebrow,title,description,icon]) => <A class="toolCard" href={path}><span class="toolIcon">{icon}</span><span class="eyebrow">{eyebrow}</span><h2>{title}</h2><p>{description}</p><span class="cardAction">Open workspace </span></A>)}</div></section> }
+1
View File
@@ -0,0 +1 @@
.homePage{max-width:1440px;margin:auto;padding:56px 32px 80px}.homeHero{display:flex;justify-content:space-between;align-items:end;gap:48px;margin-bottom:40px}.homeHero h1{margin:8px 0 14px;font-size:clamp(38px,5vw,68px);line-height:.98;letter-spacing:-.045em}.heroCopy{max-width:720px;margin:0;color:var(--text-muted);font-size:18px;line-height:1.6}.eyebrow{color:var(--accent-strong);font-size:12px;font-weight:700;letter-spacing:.12em;text-transform:uppercase}.environmentBadge{min-width:250px;padding:16px 18px;border:1px solid var(--border);border-radius:14px;background:var(--surface);font-weight:700;box-shadow:var(--shadow-sm)}.environmentBadge small{display:block;margin:5px 0 0 18px;color:var(--text-muted);font-weight:400}.statusDot{display:inline-block;width:9px;height:9px;margin-right:9px;border-radius:50%;background:var(--success);box-shadow:0 0 0 4px rgba(38,147,104,.12)}.toolGrid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}.toolCard{position:relative;min-height:230px;padding:26px;overflow:hidden;border:1px solid var(--border);border-radius:18px;background:var(--surface);color:var(--text);text-decoration:none;box-shadow:var(--shadow-sm);transition:.18s}.toolCard:hover{transform:translateY(-3px);border-color:var(--accent-soft);box-shadow:var(--shadow-md)}.toolCard h2{margin:28px 0 10px;font-size:24px}.toolCard p{max-width:90%;margin:0;color:var(--text-muted);line-height:1.55}.toolIcon{position:absolute;top:20px;right:22px;color:var(--accent);font-size:31px}.cardAction{position:absolute;bottom:24px;left:26px;font-weight:700}@media(max-width:960px){.toolGrid{grid-template-columns:repeat(2,1fr)}.homeHero{align-items:start;flex-direction:column}}@media(max-width:640px){.homePage{padding:36px 18px}.toolGrid{grid-template-columns:1fr}}