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
@@ -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;
}