Skip to content

Commit a0234a3

Browse files
committed
feat(frontend): weather symbols in the forecast rows, fluid glowbox clocks
Show the condition per forecast slot instead of temperature alone: the aamu/päivä/ilta/yö strip carries the icon for the hour it sampled (night variant chosen from today's sunrise/sunset), and each day in the 7-day drawer chart carries its daily symbol. Chart.js draws x-tick labels on canvas and cannot render a Lucide icon, so the day labels move to HTML: canvas ticks off, and a plotBounds plugin reports chartArea so the label row can align to it. Each cell wraps, which lets the icon drop under the day letter on a phone. The glowbox clock faces were laid out in hard pixels (520-640 wide) and overflowed a phone column. They now cap at their design width and scale below it, holding aspect; the canvases redraw to their container, so the faces stay sharp instead of being CSS-scaled. On mobile they span the whole column, so they also drop below the wordmark and fullscreen button.
1 parent 65a3e68 commit a0234a3

4 files changed

Lines changed: 192 additions & 21 deletions

File tree

‎frontend/src/components/CurrentTime.tsx‎

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import React, { memo } from "react";
77
import useCurrentTime from "../hooks/useCurrentTime";
88
import useGlowboxTheme from "../hooks/useGlowboxTheme";
99
import useLocal from "../hooks/useLocal";
10+
import { mq } from "../mq";
1011

1112
type CurrentTimeProps = {} & React.HTMLAttributes<HTMLDivElement>;
1213

@@ -28,6 +29,19 @@ const DISPLAY_FONTS = [
2829
{ family: "Lcd" },
2930
];
3031

32+
// The glowbox faces are canvases that redraw to their container (ResizeObserver),
33+
// so the wrapper spans the column and each face caps itself at its design width.
34+
const DISPLAY_WRAPPER = {
35+
width: "100%",
36+
display: "flex",
37+
justifyContent: "center",
38+
cursor: "pointer",
39+
userSelect: "none",
40+
// On a phone these faces span the whole column, which is also where the
41+
// wordmark and the fullscreen button sit — drop below them.
42+
[mq[0]]: { marginTop: 26 },
43+
} as const;
44+
3145
const CurrentTime: React.FC<CurrentTimeProps> = ({ className }) => {
3246
const theme = useTheme();
3347
const currentTime = useCurrentTime();
@@ -50,19 +64,19 @@ const CurrentTime: React.FC<CurrentTimeProps> = ({ className }) => {
5064
}}
5165
>
5266
{displayFont.family === "Nixie" ? (
53-
<div onClick={cycleFont} css={{ cursor: "pointer", userSelect: "none" }}>
67+
<div onClick={cycleFont} css={DISPLAY_WRAPPER}>
5468
<NixieClock hh={hh} mm={mm} ss={ss} />
5569
</div>
5670
) : displayFont.family === "Vfd" ? (
57-
<div onClick={cycleFont} css={{ cursor: "pointer", userSelect: "none" }}>
71+
<div onClick={cycleFont} css={DISPLAY_WRAPPER}>
5872
<VfdClock hh={hh} mm={mm} ss={ss} />
5973
</div>
6074
) : displayFont.family === "SplitFlap" ? (
61-
<div onClick={cycleFont} css={{ cursor: "pointer", userSelect: "none" }}>
75+
<div onClick={cycleFont} css={DISPLAY_WRAPPER}>
6276
<SplitFlapClock hh={hh} mm={mm} ss={ss} />
6377
</div>
6478
) : displayFont.family === "Lcd" ? (
65-
<div onClick={cycleFont} css={{ cursor: "pointer", userSelect: "none" }}>
79+
<div onClick={cycleFont} css={DISPLAY_WRAPPER}>
6680
<LcdClock hh={hh} mm={mm} ss={ss} />
6781
</div>
6882
) : (
@@ -141,10 +155,27 @@ const NixieClock: React.FC<NixieClockProps> = ({ hh, mm, ss }) => {
141155
["s1", ss[1]],
142156
];
143157

158+
// 520 = six 78px tubes + two 26px colons; below that width the row scales down
159+
// as one and every tube keeps its share.
144160
return (
145-
<div css={{ display: "flex", flexDirection: "row" }}>
161+
<div
162+
css={{
163+
display: "flex",
164+
flexDirection: "row",
165+
width: "min(100%, 520px)",
166+
aspectRatio: "520 / 120",
167+
}}
168+
>
146169
{tubes.map(([slot, value]) => (
147-
<div key={slot} css={{ height: 120, width: ["c1", "c0"].includes(slot) ? 26 : 78 }}>
170+
<div
171+
key={slot}
172+
css={{
173+
flexGrow: ["c1", "c0"].includes(slot) ? 26 : 78,
174+
flexBasis: 0,
175+
minWidth: 0,
176+
height: "100%",
177+
}}
178+
>
148179
<NixieTube value={value} theme={glowboxTheme} />
149180
</div>
150181
))}
@@ -184,7 +215,7 @@ const VfdClock: React.FC<NixieClockProps> = ({ hh, mm, ss }) => {
184215
const glowboxTheme = useGlowboxTheme();
185216

186217
return (
187-
<div css={{ width: 640, height: 128 }}>
218+
<div css={{ width: "min(100%, 640px)", aspectRatio: "640 / 128" }}>
188219
<VfdPanel
189220
frame={VFD_FRAME}
190221
layout={VFD_LAYOUT}
@@ -209,7 +240,7 @@ const SplitFlapClock: React.FC<NixieClockProps> = ({ hh, mm, ss }) => {
209240
const glowboxTheme = useGlowboxTheme();
210241

211242
return (
212-
<div css={{ width: 560, height: 100 }}>
243+
<div css={{ width: "min(100%, 560px)", aspectRatio: "560 / 100" }}>
213244
<SplitFlap
214245
cols={8}
215246
rows={1}
@@ -229,7 +260,7 @@ const LcdClock: React.FC<NixieClockProps> = ({ hh, mm, ss }) => {
229260
const glowboxTheme = useGlowboxTheme();
230261

231262
return (
232-
<div css={{ width: 480, height: 128 }}>
263+
<div css={{ width: "min(100%, 480px)", aspectRatio: "480 / 128" }}>
233264
<LcdModule
234265
cols={8}
235266
rows={1}

‎frontend/src/components/FmiWeatherBox.tsx‎

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import useSWR from "swr";
88
import { api, jsonFetcher } from "../api";
99
import useLocationSettings from "../hooks/useLocationSettings";
1010
import useScreenshotMode from "../hooks/useScreenshotMode";
11+
import { mq } from "../mq";
1112
import { PvForecast } from "../types/pv";
1213
import { WeatherData } from "../types/weather/fmi";
1314
import { weatherBorderColor } from "../weather/asciiSky";
@@ -131,13 +132,18 @@ const WeatherBox: React.FC<WeatherBoxProps> = ({ className }) => {
131132
temp: d.temperatureMax,
132133
rain: d.precipitation,
133134
pvKwh: includePv ? pv.kwh : null,
135+
weatherSymbol: d.weatherSymbol,
134136
label: `${format(new Date(d.date), "EEEEEE", {
135137
locale: fi,
136138
})}`,
137139
};
138140
});
139141

140142
const segments = getFmiTemperatureSegments(hourly);
143+
// Sunrise/sunset as plain hours: a segment sample outside them gets the night
144+
// icon variant (moon for a clear sky).
145+
const sunriseHour = new Date(today.sunrise).getHours();
146+
const sunsetHour = new Date(today.sunset).getHours();
141147

142148
const realIsNight = now < new Date(current.sunrise) || now > new Date(current.sunset);
143149
// In demo mode the test controls can override the rendered weather so every
@@ -166,9 +172,14 @@ const WeatherBox: React.FC<WeatherBoxProps> = ({ className }) => {
166172
<div
167173
css={{
168174
position: "relative",
175+
display: "flex",
169176
backgroundColor: theme.colors.background.light,
170-
height: "200px",
177+
// Taller than the plot alone: the day labels moved out of the canvas
178+
// and wrap to two lines on a phone.
179+
height: "220px",
171180
padding: 10,
181+
boxSizing: "border-box",
182+
[mq[0]]: { height: "240px" },
172183
}}
173184
>
174185
<WeatherChart data={chartData} days={7} />
@@ -306,6 +317,7 @@ const WeatherBox: React.FC<WeatherBoxProps> = ({ className }) => {
306317
flexDirection: "column",
307318
borderRight: `1px ${borderColor ?? theme.colors.border} solid`,
308319
width: "25%",
320+
minWidth: 0,
309321
textAlign: "center",
310322
"&:last-of-type": {
311323
borderRight: "none",
@@ -322,14 +334,25 @@ const WeatherBox: React.FC<WeatherBoxProps> = ({ className }) => {
322334
>
323335
{s.title}
324336
</span>
325-
<span
337+
<div
326338
css={{
327339
marginTop: 0.25,
328-
fontVariantNumeric: "tabular-nums",
340+
display: "flex",
341+
flexDirection: "row",
342+
justifyContent: "center",
343+
alignItems: "center",
344+
gap: 6,
345+
[mq[0]]: { flexWrap: "wrap", gap: 4 },
329346
}}
330347
>
331-
{s.temp}°
332-
</span>
348+
<span css={{ fontVariantNumeric: "tabular-nums" }}>{s.temp}°</span>
349+
<WeatherIcon
350+
weatherSymbol={s.weatherSymbol}
351+
isNight={s.hour < sunriseHour || s.hour >= sunsetHour}
352+
size={16}
353+
css={{ color: theme.colors.text.muted, flexShrink: 0 }}
354+
/>
355+
</div>
333356
</div>
334357
))}
335358
</div>

‎frontend/src/components/WeatherChart.tsx‎

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,13 @@ import {
1212
PointElement,
1313
} from "chart.js";
1414
import ChartDataLabels, { Context } from "chartjs-plugin-datalabels";
15-
import React from "react";
15+
import React, { createElement, useMemo, useState } from "react";
1616
import { Chart } from "react-chartjs-2";
1717
import { useMediaQuery } from "usehooks-ts";
1818

19+
import { mq } from "../mq";
20+
import { getFmiWeatherIcon } from "../weather/fmi/icons";
21+
1922
ChartJS.register(
2023
BarController,
2124
BarElement,
@@ -30,9 +33,17 @@ type WeatherChartData = {
3033
temp: number;
3134
rain: number;
3235
pvKwh?: number | null;
36+
weatherSymbol?: number;
3337
label: string;
3438
};
3539

40+
// The plot box in canvas pixels. The day labels are HTML (chart.js can't draw a
41+
// Lucide icon), so they are laid out over this box instead of as x-axis ticks.
42+
type PlotBox = {
43+
left: number;
44+
width: number;
45+
};
46+
3647
type WeatherChartProps = {
3748
data: Array<WeatherChartData>;
3849
days?: number;
@@ -53,6 +64,30 @@ const WeatherChart: React.FC<WeatherChartProps> = ({ data: unfilteredData, days
5364
const isMobile = useMediaQuery("(max-width: 600px)");
5465
const labelFontSize = isMobile ? 8 : 10;
5566
const labelCharPx = isMobile ? 5 : 6.5;
67+
const [plot, setPlot] = useState<PlotBox | null>(null);
68+
69+
// Reports the plot box so the HTML day labels can be aligned to it. Guarded by
70+
// a sub-pixel comparison: the state write re-renders, which updates the chart,
71+
// which fires this again — identical numbers end the cycle after one pass.
72+
const plugins = useMemo(
73+
() => [
74+
ChartDataLabels,
75+
{
76+
id: "plotBounds",
77+
afterLayout: (chart: ChartJS) => {
78+
const { left, right } = chart.chartArea;
79+
const width = right - left;
80+
if (width <= 0) return;
81+
setPlot((prev) =>
82+
prev && Math.abs(prev.left - left) < 0.5 && Math.abs(prev.width - width) < 0.5
83+
? prev
84+
: { left, width },
85+
);
86+
},
87+
},
88+
],
89+
[],
90+
);
5691

5792
const data = days === undefined ? unfilteredData : unfilteredData.slice(0, days);
5893

@@ -126,7 +161,8 @@ const WeatherChart: React.FC<WeatherChartProps> = ({ data: unfilteredData, days
126161
grid: {
127162
display: false,
128163
},
129-
ticks: { color: theme.mode === "dark" ? "#d6d6d6" : "#525252" },
164+
// Drawn as HTML below the canvas so each day can carry its weather icon.
165+
ticks: { display: false },
130166
},
131167
yTemp: {
132168
position: "left",
@@ -163,7 +199,62 @@ const WeatherChart: React.FC<WeatherChartProps> = ({ data: unfilteredData, days
163199
},
164200
};
165201

166-
return <Chart type="bar" options={options} data={chartData} plugins={[ChartDataLabels]} />;
202+
const tickColor = theme.mode === "dark" ? "#d6d6d6" : "#525252";
203+
const iconSize = isMobile ? 12 : 14;
204+
205+
return (
206+
<div
207+
css={{
208+
flex: 1,
209+
minWidth: 0,
210+
display: "flex",
211+
flexDirection: "column",
212+
height: "100%",
213+
minHeight: 0,
214+
}}
215+
>
216+
<div css={{ flex: 1, minHeight: 0 }}>
217+
<Chart type="bar" options={options} data={chartData} plugins={plugins} />
218+
</div>
219+
<div
220+
css={{
221+
display: "flex",
222+
flexDirection: "row",
223+
marginLeft: plot?.left ?? 0,
224+
width: plot?.width ?? "100%",
225+
// The bar categories sit at even fractions of the plot box, so equal
226+
// cells centre on their bars.
227+
visibility: plot ? "visible" : "hidden",
228+
}}
229+
>
230+
{data.map((d) => (
231+
<div
232+
key={d.label}
233+
css={{
234+
flex: 1,
235+
minWidth: 0,
236+
display: "flex",
237+
flexWrap: "wrap",
238+
justifyContent: "center",
239+
alignItems: "center",
240+
gap: 4,
241+
color: tickColor,
242+
fontSize: 12,
243+
lineHeight: 1.2,
244+
[mq[0]]: { fontSize: 11 },
245+
}}
246+
>
247+
<span>{d.label}</span>
248+
{d.weatherSymbol != null &&
249+
createElement(getFmiWeatherIcon(d.weatherSymbol), {
250+
size: iconSize,
251+
strokeWidth: 1.75,
252+
})}
253+
</div>
254+
))}
255+
</div>
256+
</div>
257+
);
167258
};
168259

169260
export default WeatherChart;

‎frontend/src/weather/fmi/utils.ts‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import { HourlyForecast } from "../../types/weather/fmi";
33
type Segment = {
44
title: "aamu" | "päivä" | "ilta" | "yö";
55
temp: number;
6+
weatherSymbol: number;
7+
// Local hour of the sampled forecast — the caller pairs it with sunrise/sunset
8+
// to pick the day or night icon variant.
9+
hour: number;
610
};
711

812
export const getFmiTemperatureSegments = (forecasts: HourlyForecast[] = []) => {
@@ -18,18 +22,40 @@ export const getFmiTemperatureSegments = (forecasts: HourlyForecast[] = []) => {
1822
const time = new Date(forecast.time);
1923
const hour = time.getHours();
2024

25+
const symbol = forecast.weatherSymbol;
26+
2127
if (result.morning === undefined && hour >= 5 && hour < 12) {
2228
result.morning = forecast.temperature;
23-
segments.push({ title: "aamu", temp: Math.round(result.morning ?? 0) });
29+
segments.push({
30+
title: "aamu",
31+
temp: Math.round(result.morning ?? 0),
32+
weatherSymbol: symbol,
33+
hour,
34+
});
2435
} else if (result.day === undefined && hour >= 12 && hour < 17) {
2536
result.day = forecast.temperature;
26-
segments.push({ title: "päivä", temp: Math.round(result.day ?? 0) });
37+
segments.push({
38+
title: "päivä",
39+
temp: Math.round(result.day ?? 0),
40+
weatherSymbol: symbol,
41+
hour,
42+
});
2743
} else if (result.evening === undefined && hour >= 17 && hour < 21) {
2844
result.evening = forecast.temperature;
29-
segments.push({ title: "ilta", temp: Math.round(result.evening ?? 0) });
45+
segments.push({
46+
title: "ilta",
47+
temp: Math.round(result.evening ?? 0),
48+
weatherSymbol: symbol,
49+
hour,
50+
});
3051
} else if (result.night === undefined && (hour >= 21 || hour < 5)) {
3152
result.night = forecast.temperature;
32-
segments.push({ title: "yö", temp: Math.round(result.night ?? 0) });
53+
segments.push({
54+
title: "yö",
55+
temp: Math.round(result.night ?? 0),
56+
weatherSymbol: symbol,
57+
hour,
58+
});
3359
}
3460

3561
if (

0 commit comments

Comments
 (0)