Files
WeatherTool/web/src/components/map/MapView.tsx
T
b0txec 4c40c244df
CI / backend (push) Successful in 1m7s
CI / frontend (push) Successful in 41s
Add click-to-edit temperature badges on the Faktiskā map
The "Rādīt stacijas" panel is the only way to fix a value today, and it
opens all 13 fields at once even when only one or two need a tweak. Since
most values are already correct, that's the wrong default interaction for
the common case.

Click a temperature badge directly on the map to edit it inline instead.
Scoped strictly to mode==="faktiska" (Kartes/Ūdens/Brīdinājumi share the
same MapView component and are unaffected). Mirrors Brīdinājumi's existing
canvas-coordinate pattern exactly (getBoundingClientRect scaling via
canvasPoint) rather than inventing a new one, hit-testing against
Faktiskā's already-fixed per-resolution marker positions (faktiskaTemplates)
-- no dragging or calibration needed, just a box test against 13 known
points. The floating input is a plain DOM element positioned over the
canvas, not drawn onto it, so it's preview-only like Brīdinājumi's drag
handles and never reaches the exported PNG.

Reuses the existing updateValue function via a new onEditValue callback
prop, so overrides/reset/the "Manuāli" badge in the stations panel all stay
in sync automatically -- no parallel state to maintain. Added a hover
cursor and a one-line hint ("Klikšķini uz temperatūras kartē, lai to
mainītu") for discoverability, since the whole point was fixing an
undiscoverable interaction, not trading it for another one.

Verified end-to-end: hover shows a pointer cursor over a badge; clicking
shows a correctly positioned input prefilled with the real current value;
Enter commits and the canvas redraws immediately; the change is reflected
in the "Rādīt stacijas" panel as a manual override; Escape cancels without
committing; works correctly at both 1920x1080 and 3840x1440 (different
badge size and marker positions per template); no console errors; Kartes
(a different MapView mode) is unaffected.
2026-08-25 11:21:11 +03:00

227 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Component, createEffect, createSignal, onMount, Show } from "solid-js";
import { ResultKeyVal } from "../../consts";
import { WindInputs, WindSignals } from "./WindInputs";
import arrowUrl from "../../assets/arrow.webp";
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'
import { faktiskaTemplates } from "../../pages/map-graphics/faktiskaTemplates";
import type { FaktiskaTemplate } from "../../pages/map-graphics/faktiskaTemplates";
export const MapView: Component<{ type: MapResolution, data: () => ResultKeyVal[], mode?: "temperature" | "faktiska", productionTemplate?: boolean, onEditValue?: (city: string, value: string) => void }> = ({ type, data, mode = "temperature", productionTemplate = false, onEditValue }) => {
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("");
const [getSource, setSource] = createSignal("");
let lastCoords: CityData[] = [];
const props = resolutionProps[type];
// Faktiskā only: click a temperature badge on the map to edit it inline,
// instead of needing the "Rādīt stacijas" panel for a one-off tweak.
// Same canvas-coordinate conversion as Brīdinājumi's drag/resize handles
// (getBoundingClientRect scaling) -- preview-only, this input is a plain
// DOM element positioned over the canvas and never reaches the export.
const clickToEditEnabled = mode === "faktiska" && productionTemplate;
const [hoveredCity, setHoveredCity] = createSignal<string>();
const [editingCity, setEditingCity] = createSignal<string>();
const [editValue, setEditValue] = createSignal("");
const canvasPoint = (event: { clientX: number, clientY: number }) => {
const canvas = getCanvas()!;
const rect = canvas.getBoundingClientRect();
return {
x: (event.clientX - rect.left) * (canvas.width / rect.width),
y: (event.clientY - rect.top) * (canvas.height / rect.height),
};
};
const badgeHitTest = (point: { x: number, y: number }): string | undefined => {
if (!clickToEditEnabled) return undefined;
const faktiskaTemplate = faktiskaTemplates[type as FaktiskaTemplate];
const half = faktiskaTemplate.badgeSize / 2;
for (const [city, marker] of Object.entries(faktiskaTemplate.markers)) {
if (Math.abs(point.x - marker.x) <= half && Math.abs(point.y - marker.y) <= half) return city;
}
return undefined;
};
const screenPointFromCanvas = (x: number, y: number) => {
const canvas = getCanvas()!;
const rect = canvas.getBoundingClientRect();
return {
left: rect.left + x * (rect.width / canvas.width),
top: rect.top + y * (rect.height / canvas.height),
};
};
const onCanvasPointerMove = (event: PointerEvent) => {
if (!clickToEditEnabled) return;
setHoveredCity(badgeHitTest(canvasPoint(event)));
};
const onCanvasClick = (event: MouseEvent) => {
if (!clickToEditEnabled) return;
const city = badgeHitTest(canvasPoint(event));
if (!city) return;
const current = data().find(([c]) => c === city)?.[1];
setEditValue(typeof current === "number" ? current.toString().replace(".", ",") : "");
setEditingCity(city);
};
const commitEdit = () => {
const city = editingCity();
if (city) onEditValue?.(city, editValue());
setEditingCity(undefined);
};
const arrowImg = new Image();
arrowImg.src = arrowUrl;
const windSignals: WindSignals = {
direction: createSignal(""),
speed: createSignal(""),
gusts: createSignal(""),
roundValues: createSignal(mode === "faktiska"),
}
function getWindData(): [string, string, string, boolean, WindProps] {
return [
windSignals.direction[0](),
windSignals.speed[0](),
windSignals.gusts[0](),
windSignals.roundValues[0](),
windProps[type],
];
}
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 = () => {
setImg(img);
drawOnMap(
ctx,
[img, arrowImg],
lastCoords,
getWindData(),
[getTitle(), getSource()],
props,
productionTemplate,
);
};
img.src = props.map;
});
createEffect(() => {
fontReady();
iconsReady();
const ctx = getCanvas()!.getContext("2d")!;
const weatherIcons = getWeatherIcons();
const coordsAndData: CityData[] = data()
.map(([city, value]) => [
city,
cityCoords[city as keyof typeof cityCoords],
typeof value === "number" ? value : -99,
weatherIcons[city],
]);
lastCoords = coordsAndData;
drawOnMap(
ctx,
[getImg(), arrowImg],
coordsAndData,
getWindData(),
[getTitle(), getSource()],
props,
productionTemplate,
);
});
const getStyleSize = () => {
return { "width": "100%", "height": "auto" };
}
function downloadPng() {
const canvas = getCanvas()!;
if (productionTemplate && (canvas.width !== props.width || canvas.height !== props.height)) {
console.error(`Faktiskā export blocked: expected ${props.width} × ${props.height}, received ${canvas.width} × ${canvas.height}`);
return;
}
const filename = productionTemplate ? `faktiska-${props.width}x${props.height}.png` : `weather-${type}.png`;
canvas.toBlob(blob => blob && download(blob, filename), 'image/png')
}
return (
<div class="mapWorkspace">
<div class="mapToolbar"><div><strong>{productionTemplate ? "Kartes priekšskatījums" : "Map preview"}</strong><span>{props.width} × {props.height} px {productionTemplate ? "eksports" : "export"}</span>{clickToEditEnabled && <span class="mapEditHint">Klikšķini uz temperatūras kartē, lai to mainītu</span>}</div><button class="primary" onClick={downloadPng}>{productionTemplate ? "Lejupielādēt PNG" : "Download PNG"}</button></div>
<details class="broadcastControls" open={!productionTemplate}>
<summary><span><strong>{productionTemplate ? "Kartes noformējums" : "Broadcast overlay controls"}</strong>{!productionTemplate && <small>Optional manual wind and weather-symbol overrides</small>}</span></summary>
{!productionTemplate && <p class="controlHelp">These settings do not change the queried city values. Use them only when preparing a finished broadcast graphic.</p>}
{!productionTemplate && <div class="overlayFields">
<label><span>Title</span><input type="text" placeholder="19. APRĪĻA GAISA TEMPERATŪRAS REKORDI" value={getTitle()} onInput={e => setTitle(e.target.value)} /></label>
<label><span>Source</span><input type="text" placeholder="LVĢMC" value={getSource()} onInput={e => setSource(e.target.value)} /></label>
</div>}
<div class="mapControlSections">
<section class="valueControls">
<strong>{productionTemplate ? "Temperatūra un vējš" : "Value and wind settings"}</strong>
<div class="windControlRow">
<label class="roundControl">
<input
type="checkbox"
checked={windSignals.roundValues[0]()}
onChange={e => windSignals.roundValues[1](e.target.checked)}
/>
<span>{productionTemplate ? "Noapaļot temperatūras" : "Round temperatures"}</span>
</label>
{ props.showWind && <WindInputs signals={windSignals} productionTemplate={productionTemplate} /> }
</div>
</section>
{mode === "faktiska" && <IconInputs cities={data().map(([city]) => city)} weatherIconsSignal={weatherIconsSignal} />}
</div>
</details>
<div class="canvasFrame">
<canvas
ref={setCanvas}
width={props.width}
height={props.height}
style={{ ...getStyleSize(), cursor: hoveredCity() ? "pointer" : "default" }}
onPointerMove={onCanvasPointerMove}
onPointerLeave={() => setHoveredCity(undefined)}
onClick={onCanvasClick}
/>
<Show when={editingCity()}>{city => {
const marker = () => faktiskaTemplates[type as FaktiskaTemplate].markers[city() as keyof typeof faktiskaTemplates[FaktiskaTemplate]["markers"]];
const pos = () => screenPointFromCanvas(marker().x, marker().y);
return <input
class="mapValueEditor"
ref={el => { setTimeout(() => { el.focus(); el.select(); }, 0); }}
style={{ position: "fixed", left: `${pos().left}px`, top: `${pos().top}px`, transform: "translate(-50%, -50%)" }}
type="text"
inputmode="decimal"
value={editValue()}
onInput={e => setEditValue(e.currentTarget.value)}
onKeyDown={e => {
if (e.key === "Enter") commitEdit();
if (e.key === "Escape") setEditingCity(undefined);
}}
onBlur={commitEdit}
aria-label={`${city()} temperatūra`}
/>;
}}</Show>
</div>
</div>
);
}