4c40c244df
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.
227 lines
10 KiB
TypeScript
227 lines
10 KiB
TypeScript
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>
|
||
);
|
||
}
|