Optically center weather font glyphs

This commit is contained in:
b0txec
2026-08-18 23:01:07 +03:00
parent 82199b8a45
commit 0c78d3c1b0
4 changed files with 40 additions and 5 deletions
+10 -1
View File
@@ -49,7 +49,7 @@ export function drawOnMap(
ctx.fillStyle = "#FFFFFF"; ctx.fillStyle = "#FFFFFF";
ctx.font = `normal ${props.fontSize * 5}px Daira by LTV grafika`; ctx.font = `normal ${props.fontSize * 5}px Daira by LTV grafika`;
const iconSize = ctx.measureText(icon); const iconSize = ctx.measureText(icon);
ctx.fillText(icon, localX + props.boxSize/2 + 10 + iconSize.width/2, localY); drawOpticallyCenteredGlyph(ctx, icon, localX + props.boxSize/2 + 10 + iconSize.width/2, localY);
} }
}); });
@@ -81,6 +81,15 @@ export function drawOnMap(
ctx.fillText("M/S", windProp.offsetX + boxMiddleX + gustsWidth / 2 - 22, (windProp.offsetY + 805)*windProp.scaleY); ctx.fillText("M/S", windProp.offsetX + boxMiddleX + gustsWidth / 2 - 22, (windProp.offsetY + 805)*windProp.scaleY);
} }
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);
}
function drawTitleOverlay(ctx: CanvasRenderingContext2D, title: string, source: string, canvasWidth: number): void { function drawTitleOverlay(ctx: CanvasRenderingContext2D, title: string, source: string, canvasWidth: number): void {
if (!title.trim() && !source.trim()) return; if (!title.trim() && !source.trim()) return;
@@ -1,6 +1,7 @@
import { Component, createSignal, For, Show, Signal } from "solid-js"; import { Component, createSignal, For, Show, Signal } from "solid-js";
import { weatherIcons } from "./iconConsts"; import { weatherIcons } from "./iconConsts";
import "../../css/weatherIcons.css"; import "../../css/weatherIcons.css";
import { WeatherGlyph } from "./WeatherGlyph";
export const IconInputs: Component<{cities: string[]; weatherIconsSignal: Signal<Record<string, string>>}> = (props) => { export const IconInputs: Component<{cities: string[]; weatherIconsSignal: Signal<Record<string, string>>}> = (props) => {
const [icons, setIcons] = props.weatherIconsSignal; const [icons, setIcons] = props.weatherIconsSignal;
@@ -10,9 +11,9 @@ export const IconInputs: Component<{cities: string[]; weatherIconsSignal: Signal
const applyAll = () => setIcons(Object.fromEntries(props.cities.map(city => [city, selected()]))); const applyAll = () => setIcons(Object.fromEntries(props.cities.map(city => [city, selected()])));
return <section class="symbolEditor"> 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">{selected()}</span><button type="button" onClick={applyAll}>Apply to all cities</button></div></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"><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)}>{icon}</button>}</For></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="assignmentHeading"><strong>City assignments</strong><span>Use the selected symbol for each exception.</span></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="—">{icons()[city]}</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 => <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>
</section>; </section>;
}; };
@@ -0,0 +1,23 @@
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"/>;
};
+3 -1
View File
@@ -2,7 +2,7 @@
font-family: 'Daira by LTV grafika'; font-family: 'Daira by LTV grafika';
font-size: 30px; font-size: 30px;
display:inline-flex;align-items:center;justify-content:center; display:inline-flex;align-items:center;justify-content:center;
width:46px;height:46px;min-height:46px; width:50px;height:50px;min-height:50px;
background:#f4f6f8;border:1px solid #d9e0e8;border-radius:10px; background:#f4f6f8;border:1px solid #d9e0e8;border-radius:10px;
padding:0;margin:0; padding:0;margin:0;
cursor: pointer; cursor: pointer;
@@ -24,3 +24,5 @@
} }
.weatherIconButton.selected{background:#17212d;border-color:#17212d;color:#fff;box-shadow:0 0 0 3px var(--accent-pale)} .weatherIconButton.selected{background:#17212d;border-color:#17212d;color:#fff;box-shadow:0 0 0 3px var(--accent-pale)}
.weatherIconButton:focus-visible{outline:3px solid var(--accent-soft);outline-offset:2px} .weatherIconButton:focus-visible{outline:3px solid var(--accent-soft);outline-offset:2px}
.weatherGlyph{display:block;flex:none;color:inherit;pointer-events:none}
.symbolPalette{grid-template-columns:repeat(auto-fill,50px)!important}.selectedPreview{width:46px!important;height:46px!important}.selectedPreview,.currentSymbol{font-family:inherit!important}