Replace Daira glyphs with normalized weather icons

This commit is contained in:
b0txec
2026-08-20 10:46:26 +03:00
parent 6c9290bbfc
commit 0d641bd38f
57 changed files with 113 additions and 112 deletions
+4
View File
@@ -8,12 +8,14 @@ import { cityCoords } from "../cityCoords";
import { MapResolution, resolutionProps, windProps, WindProps } from "./mapConsts";
import { CityData, drawOnMap } from "./canvasDraw";
import { IconInputs } from "../weatherIcons/IconInputs";
import { preloadWeatherIcons } from "../weatherIcons/weatherIconAssets";
import { download } from '../../helpers/download'
export const MapView: Component<{ type: MapResolution, data: () => ResultKeyVal[], mode?: "temperature" | "faktiska", productionTemplate?: boolean }> = ({ type, data, mode = "temperature", productionTemplate = false }) => {
const [getCanvas, setCanvas] = createSignal<HTMLCanvasElement>();
const [getImg, setImg] = createSignal(new Image());
const [fontReady, setFontReady] = createSignal(false);
const [iconsReady, setIconsReady] = createSignal(mode !== "faktiska");
const weatherIconsSignal = createSignal<{[key: string]: string;}>({});
const [getWeatherIcons] = weatherIconsSignal;
const [getTitle, setTitle] = createSignal("");
@@ -44,6 +46,7 @@ export const MapView: Component<{ type: MapResolution, data: () => ResultKeyVal[
onMount(() => {
void document.fonts.load(`700 ${props.fontSize}px Monda`).then(() => setFontReady(true));
if (mode === "faktiska") void preloadWeatherIcons().then(() => setIconsReady(true));
const ctx = getCanvas()!.getContext("2d")!;
const img = new Image();
img.onload = () => {
@@ -63,6 +66,7 @@ export const MapView: Component<{ type: MapResolution, data: () => ResultKeyVal[
createEffect(() => {
fontReady();
iconsReady();
const ctx = getCanvas()!.getContext("2d")!;
const weatherIcons = getWeatherIcons();
const coordsAndData: CityData[] = data()
+14 -21
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 { getLoadedWeatherIcon } from "../weatherIcons/weatherIconAssets";
// cityName, {x, y}, weatherValue, icon
export type CityData = [cityName: string, {x: number, y: number }, weatherValue: number, icon: string | undefined]
@@ -48,11 +49,10 @@ export function drawOnMap(
ctx.fillText(stringValue, localX, localY);
if (icon) {
ctx.fillStyle = "#FFFFFF";
ctx.font = `normal ${props.fontSize * (productionTemplate ? 4.2 : 5)}px Daira by LTV grafika`;
const iconMetrics = ctx.measureText(icon);
const glyphWidth = iconMetrics.actualBoundingBoxLeft + iconMetrics.actualBoundingBoxRight;
const badgeWidth = getBadgeWidth(ctx, stringValue, props, true);
const iconImage = getLoadedWeatherIcon(icon);
if (!iconImage) return;
const iconSize = productionTemplate ? 190 : props.boxSize * 1.35;
const badgeWidth = getBadgeWidth(ctx, stringValue, props);
if (productionTemplate) {
const layout = faktiskaMarkerLayout[city as FaktiskaStation];
@@ -61,11 +61,11 @@ export function drawOnMap(
const offsetX = layout?.iconOffsetX ?? 0;
const offsetY = layout?.iconOffsetY ?? 0;
const iconCenterX = side === "right"
? localX + badgeWidth / 2 + gap + glyphWidth / 2 + offsetX
: localX - badgeWidth / 2 - gap - glyphWidth / 2 + offsetX;
drawOpticallyCenteredGlyph(ctx, icon, iconCenterX, localY + offsetY);
? localX + badgeWidth / 2 + gap + iconSize / 2 + offsetX
: localX - badgeWidth / 2 - gap - iconSize / 2 + offsetX;
drawWeatherIcon(ctx, iconImage, iconCenterX, localY + offsetY, iconSize);
} else {
drawOpticallyCenteredGlyph(ctx, icon, localX + props.boxSize / 2 + 10 + iconMetrics.width / 2, localY);
drawWeatherIcon(ctx, iconImage, localX + badgeWidth / 2 + 10 + iconSize / 2, localY, iconSize);
}
}
});
@@ -98,20 +98,13 @@ export function drawOnMap(
ctx.fillText("M/S", windProp.offsetX + boxMiddleX + gustsWidth / 2 - 22, (windProp.offsetY + 805)*windProp.scaleY);
}
function getBadgeWidth(ctx: CanvasRenderingContext2D, value: string, props: ResolutionPropsValue, restoreValueFont = false): number {
if (restoreValueFont) ctx.font = `bold ${props.fontSize}px Monda`;
const width = Math.max(props.boxSize, Math.ceil(ctx.measureText(value).width + props.fontSize * 0.8));
if (restoreValueFont) ctx.font = `normal ${props.fontSize * 4.2}px Daira by LTV grafika`;
return width;
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));
}
function drawOpticallyCenteredGlyph(ctx: CanvasRenderingContext2D, glyph: string, centerX: number, centerY: number): void {
const metrics = ctx.measureText(glyph);
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
const x = centerX + (metrics.actualBoundingBoxLeft - metrics.actualBoundingBoxRight) / 2;
const y = centerY + (metrics.actualBoundingBoxAscent - metrics.actualBoundingBoxDescent) / 2;
ctx.fillText(glyph, x, y);
export function drawWeatherIcon(ctx: CanvasRenderingContext2D, image: HTMLImageElement, centerX: number, centerY: number, size: number): void {
ctx.drawImage(image, centerX - size / 2, centerY - size / 2, size, size);
}
function drawTitleOverlay(ctx: CanvasRenderingContext2D, title: string, source: string, canvasWidth: number): void {
+5 -3
View File
@@ -3,6 +3,8 @@ import { codeToIcon } from '../weatherIcons/iconConsts'
import rigaBgUrl from '../../assets/bg-riga.webp'
import arrowUrl from '../../assets/arrow.webp'
import { drawRotatedImage, getAngleFromString } from '../map/windAngles'
import { getLoadedWeatherIcon, preloadWeatherIcons } from '../weatherIcons/weatherIconAssets'
import { drawWeatherIcon } from '../map/canvasDraw'
type TableWeather = {
hour: string,
@@ -44,7 +46,7 @@ export const TableFour: Component<{
return { hour, temperature, windDirection, windSpeed, icon }
})
drawTable(canvas!, rigaBg, arrowImg, data, weekDayLV)
void preloadWeatherIcons(data.map(item => item.icon)).then(() => drawTable(canvas!, rigaBg, arrowImg, data, weekDayLV))
})
return <>
@@ -82,8 +84,8 @@ function drawTable(
ctx.fillStyle = '#FFFFFF'
ctx.fillText(weather.hour, midX, rigaBg.height * 0.2)
ctx.font = `normal 240px Daira by LTV grafika`
ctx.fillText(weather.icon, midX, rigaBg.height * 0.5)
const iconImage = getLoadedWeatherIcon(weather.icon)
if (iconImage) drawWeatherIcon(ctx, iconImage, midX, rigaBg.height * 0.5, 220)
ctx.font = 'normal 50px Monda'
ctx.fillText(weather.temperature, midX, rigaBg.height * 0.75)
+5 -3
View File
@@ -3,6 +3,8 @@ import { codeToIcon } from '../weatherIcons/iconConsts'
import table2BgUrl from '../../assets/bg-table2-left.webp'
import arrowUrl from '../../assets/arrow.webp'
import { drawRotatedImage, getAngleFromString } from '../map/windAngles'
import { getLoadedWeatherIcon, preloadWeatherIcons } from '../weatherIcons/weatherIconAssets'
import { drawWeatherIcon } from '../map/canvasDraw'
type TableWeather = {
temperature: string,
@@ -50,7 +52,7 @@ export const TableTwo: Component<{
},
]
drawTable(canvas!, table2Bg, arrowImg, data, weekDayLV)
void preloadWeatherIcons(data.map(item => item.icon)).then(() => drawTable(canvas!, table2Bg, arrowImg, data, weekDayLV))
})
return <>
@@ -86,8 +88,8 @@ function drawTable(
ctx.textAlign = 'center'
ctx.fillStyle = '#FFFFFF'
ctx.font = `normal 240px Daira by LTV grafika`
ctx.fillText(weather.icon, midX, rigaBg.height * 0.4)
const iconImage = getLoadedWeatherIcon(weather.icon)
if (iconImage) drawWeatherIcon(ctx, iconImage, midX, rigaBg.height * 0.4, 220)
ctx.font = 'normal 50px Monda'
ctx.fillText(weather.temperature, midX, rigaBg.height * 0.7)
@@ -1,7 +1,7 @@
import { Component, createSignal, For, Show, Signal } from "solid-js";
import { weatherIcons } from "./iconConsts";
import "../../css/weatherIcons.css";
import { WeatherGlyph } from "./WeatherGlyph";
import { WeatherIcon } from "./WeatherIcon";
export const IconInputs: Component<{cities: string[]; weatherIconsSignal: Signal<Record<string, string>>}> = (props) => {
const [icons, setIcons] = props.weatherIconsSignal;
@@ -11,9 +11,9 @@ export const IconInputs: Component<{cities: string[]; weatherIconsSignal: Signal
const applyAll = () => setIcons(Object.fromEntries(props.cities.map(city => [city, selected()])));
return <section class="symbolEditor">
<div class="symbolEditorHeading"><div><strong>Weather symbols</strong><p class="fieldHint">Choose one symbol, apply it broadly, then update the exceptions below.</p></div><div class="bulkSymbol"><span class="selectedPreview"><WeatherGlyph glyph={selected()} size={40} tone="inverse"/></span><button type="button" onClick={applyAll}>Apply to all cities</button></div></div>
<div class="symbolPalette" role="radiogroup" aria-label="Choose weather symbol"><For each={weatherIcons}>{icon => <button type="button" role="radio" aria-checked={selected() === icon} aria-label={`Weather symbol ${icon}`} class="weatherIconButton" classList={{selected: selected() === icon}} onClick={() => setSelected(icon)}><WeatherGlyph glyph={icon} size={40} tone={selected() === icon ? "inverse" : "default"}/></button>}</For></div>
<div class="symbolEditorHeading"><div><strong>Weather symbols</strong><p class="fieldHint">Choose one symbol, apply it broadly, then update the exceptions below.</p></div><div class="bulkSymbol"><span class="selectedPreview"><WeatherIcon code={selected()} size={40} tone="inverse"/></span><button type="button" onClick={applyAll}>Apply to all cities</button></div></div>
<div class="symbolPalette" role="radiogroup" aria-label="Choose weather symbol"><For each={weatherIcons}>{icon => <button type="button" role="radio" aria-checked={selected() === icon} aria-label={`Weather symbol ${icon}`} class="weatherIconButton" classList={{selected: selected() === icon}} onClick={() => setSelected(icon)}><WeatherIcon code={icon} size={40} tone={selected() === icon ? "inverse" : "default"}/></button>}</For></div>
<div class="assignmentHeading"><strong>City assignments</strong><span>Use the selected symbol for each exception.</span></div>
<div class="citySymbols"><For each={props.cities}>{city => <div class="citySymbolRow"><span class="cityName">{city}</span><span class="currentSymbol" classList={{empty: !icons()[city]}}><Show when={icons()[city]} fallback="—">{icon => <WeatherGlyph glyph={icon()} size={40}/>}</Show></span><button type="button" class="assignAction" onClick={() => assign(city)}>Use selected</button><button type="button" class="clearSymbol" disabled={!icons()[city]} onClick={() => clear(city)} aria-label={`Clear ${city} symbol`}>×</button></div>}</For></div>
<div class="citySymbols"><For each={props.cities}>{city => <div class="citySymbolRow"><span class="cityName">{city}</span><span class="currentSymbol" classList={{empty: !icons()[city]}}><Show when={icons()[city]} fallback="—">{icon => <WeatherIcon code={icon()} size={40}/>}</Show></span><button type="button" class="assignAction" onClick={() => assign(city)}>Use selected</button><button type="button" class="clearSymbol" disabled={!icons()[city]} onClick={() => clear(city)} aria-label={`Clear ${city} symbol`}>×</button></div>}</For></div>
</section>;
};
@@ -1,23 +0,0 @@
import { Component, createEffect, onMount } from "solid-js";
export const WeatherGlyph: Component<{glyph: string; size?: number; tone?: "default" | "inverse"}> = (props) => {
let canvas!: HTMLCanvasElement;
const boxSize = () => props.size ?? 40;
const draw = async () => {
const size = boxSize();
await document.fonts.load(`${Math.round(size * 0.82)}px "Daira by LTV grafika"`);
canvas.width = size; canvas.height = size;
const ctx = canvas.getContext("2d")!;
ctx.clearRect(0, 0, size, size);
ctx.font = `normal ${Math.round(size * 0.82)}px "Daira by LTV grafika"`;
ctx.fillStyle = props.tone === "inverse" ? "#ffffff" : "#17212d";
ctx.textAlign = "left"; ctx.textBaseline = "alphabetic";
const metrics = ctx.measureText(props.glyph);
const x = size / 2 + (metrics.actualBoundingBoxLeft - metrics.actualBoundingBoxRight) / 2;
const y = size / 2 + (metrics.actualBoundingBoxAscent - metrics.actualBoundingBoxDescent) / 2;
ctx.fillText(props.glyph, x, y);
};
onMount(draw);
createEffect(() => { props.glyph; props.tone; boxSize(); if (canvas) void draw(); });
return <canvas ref={canvas} class="weatherGlyph" style={{width: `${boxSize()}px`, height: `${boxSize()}px`}} aria-hidden="true"/>;
};
@@ -0,0 +1,14 @@
import { Component } from "solid-js";
import { getWeatherIconUrl } from "./weatherIconAssets";
export const WeatherIcon: Component<{code: string; size?: number; tone?: "default" | "inverse"}> = (props) => {
const boxSize = () => props.size ?? 40;
return <img
src={getWeatherIconUrl(props.code)}
class="weatherIconAsset"
classList={{inverse: props.tone === "inverse"}}
style={{width: `${boxSize()}px`, height: `${boxSize()}px`}}
alt=""
aria-hidden="true"
/>;
};
@@ -1,8 +0,0 @@
import { Component } from "solid-js";
/* Put this in page so browser actually downloads ltv font */
export const WeatherIconStub: Component<{}> = () => {
return (
<div class="weatherIconStub">ABCDEF</div>
)
}
@@ -1,4 +1,3 @@
/* font-family: 'Daira by LTV grafika'; */
export const weatherIcons = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "a", "b", "c", "d", "e", "f", "g", "h", "i"]
export const weatherIconCities = ['Ainaži', 'Alūksne', 'Bauska', 'Daugavpils', 'Liepāja', 'Madona', 'Priekuļi', 'Rēzekne', 'Rīga', 'Saldus', 'Stende', 'Ventspils', 'Zīlāni']
@@ -179,4 +178,4 @@ export const codeIconMap: { [key: number]: string } = {
2617: 'd',
2618: 'd',
2619: 'f',
}
}
@@ -0,0 +1,43 @@
import { weatherIcons } from "./iconConsts";
const iconModules = import.meta.glob("../../assets/weather-icons/*.png", {
eager: true,
query: "?url",
import: "default",
}) as Record<string, string>;
const imageCache = new Map<string, HTMLImageElement>();
function assetFilename(code: string): string {
return code === code.toLowerCase() ? `${code}${code}.png` : `${code}.png`;
}
export function getWeatherIconUrl(code: string): string {
const filename = assetFilename(code);
const entry = Object.entries(iconModules).find(([path]) => path.endsWith(`/${filename}`));
if (!entry) throw new Error(`Missing weather icon asset: ${filename}`);
return entry[1];
}
export function loadWeatherIcon(code: string): Promise<HTMLImageElement> {
const cached = imageCache.get(code);
if (cached?.complete && cached.naturalWidth > 0) return Promise.resolve(cached);
return new Promise((resolve, reject) => {
const image = cached ?? new Image();
imageCache.set(code, image);
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`Failed to load weather icon: ${code}`));
if (!image.src) image.src = getWeatherIconUrl(code);
});
}
export function preloadWeatherIcons(codes: readonly string[] = weatherIcons): Promise<HTMLImageElement[]> {
return Promise.all(codes.map(loadWeatherIcon));
}
export function getLoadedWeatherIcon(code: string): HTMLImageElement | undefined {
const image = imageCache.get(code);
return image?.complete && image.naturalWidth > 0 ? image : undefined;
}