Add draggable, resizable weather-symbol placement to Brīdinājumi

This commit is contained in:
b0txec
2026-08-23 09:32:47 +03:00
parent 65f761fc8c
commit c97875d6fe
3 changed files with 223 additions and 8 deletions
+195 -7
View File
@@ -4,10 +4,31 @@ import { AlertTriangle, CloudFog, CloudLightning, CloudRain, CloudSnow, Flame, T
import { apiHost } from '../../consts';
import { download } from '../../helpers/download';
import { LoadingSpinner } from '../../components/spinner/LoadingSpinner';
import { drawWarningMap, type WeatherWarning, warningSeverity } from './warningDraw';
import { warningTemplates, type WarningOutput } from './warningTemplates';
import { WeatherIcon } from '../../components/weatherIcons/WeatherIcon';
import { weatherIcons } from '../../components/weatherIcons/iconConsts';
import { getLoadedWeatherIcon, loadWeatherIcon } from '../../components/weatherIcons/weatherIconAssets';
import { drawWarningIcons, drawWarningMap, type IconPlacement, type WeatherWarning, warningSeverity } from './warningDraw';
import { warningTemplates, type WarningOutput, type WarningTemplate } from './warningTemplates';
import '../../css/weatherIcons.css';
import './warnings.css';
const DEFAULT_ICON_SIZE = 0.11;
const MIN_ICON_SIZE = 0.02;
const MAX_ICON_SIZE = 0.35;
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function handleSizeFor(pixelSize: number) {
return Math.max(18, pixelSize * 0.22);
}
function placementPixels(template: WarningTemplate, placement: IconPlacement) {
const size = placement.size * template.width;
return { cx: placement.x * template.width, cy: placement.y * template.height, size };
}
function phenomenonIcon(phenomenon: string) {
const normalized = (phenomenon || '').toLocaleLowerCase('lv-LV');
if (normalized.includes('vēj')) return Wind;
@@ -63,6 +84,124 @@ export function Warnings() {
const detailWarning = createMemo(() => filteredWarnings().find(item => item.id === detailId()));
const closeDetail = () => setDetailId(undefined);
const [placements, setPlacements] = createSignal<Record<string, IconPlacement>>({});
const [selectedIcon, setSelectedIcon] = createSignal(weatherIcons[0]);
const activePlacements = createMemo(() => selectedWarnings()
.map(warning => placements()[warning.id])
.filter((placement): placement is IconPlacement => Boolean(placement)));
const assignIcon = (id: string) => setPlacements(prev => ({
...prev,
[id]: prev[id] ? { ...prev[id], code: selectedIcon() } : { code: selectedIcon(), x: 0.5, y: 0.5, size: DEFAULT_ICON_SIZE },
}));
const clearIcon = (id: string) => setPlacements(prev => {
if (!(id in prev)) return prev;
const next = { ...prev };
delete next[id];
return next;
});
createEffect(() => {
const validIds = new Set(loadedWarnings().map(warning => warning.id));
setPlacements(prev => {
let changed = false;
const next: Record<string, IconPlacement> = {};
for (const [id, placement] of Object.entries(prev)) {
if (validIds.has(id)) next[id] = placement; else changed = true;
}
return changed ? next : prev;
});
});
type DragState = { id: string; mode: 'move' | 'resize'; offsetX: number; offsetY: number; startSize: number; startDistance: number };
let drag: DragState | null = null;
const canvasPoint = (event: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
return {
x: (event.clientX - rect.left) * (canvas.width / rect.width),
y: (event.clientY - rect.top) * (canvas.height / rect.height),
};
};
const hitTest = (point: { x: number; y: number }): { id: string; mode: 'move' | 'resize' } | null => {
const current = template();
const entries = selectedWarnings().map(warning => [warning.id, placements()[warning.id]] as const).reverse();
for (const [id, placement] of entries) {
if (!placement) continue;
const { cx, cy, size } = placementPixels(current, placement);
const handle = handleSizeFor(size);
const hx = cx + size / 2, hy = cy + size / 2;
if (Math.abs(point.x - hx) <= handle / 2 && Math.abs(point.y - hy) <= handle / 2) return { id, mode: 'resize' };
}
for (const [id, placement] of entries) {
if (!placement) continue;
const { cx, cy, size } = placementPixels(current, placement);
if (Math.abs(point.x - cx) <= size / 2 && Math.abs(point.y - cy) <= size / 2) return { id, mode: 'move' };
}
return null;
};
const onCanvasPointerDown = (event: PointerEvent) => {
const point = canvasPoint(event);
const hit = hitTest(point);
if (!hit) return;
const placement = placements()[hit.id];
const { cx, cy } = placementPixels(template(), placement);
drag = {
id: hit.id,
mode: hit.mode,
offsetX: point.x - cx,
offsetY: point.y - cy,
startSize: placement.size,
startDistance: Math.hypot(point.x - cx, point.y - cy) || 1,
};
canvas.setPointerCapture(event.pointerId);
};
const onCanvasPointerMove = (event: PointerEvent) => {
const point = canvasPoint(event);
if (!drag) {
const hit = hitTest(point);
canvas.style.cursor = hit ? (hit.mode === 'resize' ? 'nwse-resize' : 'grab') : 'default';
return;
}
const current = template();
if (drag.mode === 'move') {
const x = clamp((point.x - drag.offsetX) / current.width, 0, 1);
const y = clamp((point.y - drag.offsetY) / current.height, 0, 1);
setPlacements(prev => ({ ...prev, [drag!.id]: { ...prev[drag!.id], x, y } }));
} else {
const placement = placements()[drag.id];
const { cx, cy } = placementPixels(current, placement);
const distance = Math.hypot(point.x - cx, point.y - cy) || 1;
const size = clamp(drag.startSize * (distance / drag.startDistance), MIN_ICON_SIZE, MAX_ICON_SIZE);
setPlacements(prev => ({ ...prev, [drag!.id]: { ...prev[drag!.id], size } }));
}
};
const onCanvasPointerUp = (event: PointerEvent) => {
if (drag) canvas.releasePointerCapture(event.pointerId);
drag = null;
};
const drawInteractionHandles = (ctx: CanvasRenderingContext2D, current: WarningTemplate, list: IconPlacement[]) => {
list.forEach(placement => {
const { cx, cy, size } = placementPixels(current, placement);
const handle = handleSizeFor(size);
ctx.save();
ctx.setLineDash([6, 5]);
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(37, 99, 235, .9)';
ctx.strokeRect(cx - size / 2, cy - size / 2, size, size);
ctx.setLineDash([]);
ctx.fillStyle = 'rgba(37, 99, 235, .95)';
ctx.fillRect(cx + size / 2 - handle / 2, cy + size / 2 - handle / 2, handle, handle);
ctx.strokeStyle = '#fff';
ctx.strokeRect(cx + size / 2 - handle / 2, cy + size / 2 - handle / 2, handle, handle);
ctx.restore();
});
};
createEffect(() => {
if (!detailId()) return;
const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') closeDetail(); };
@@ -108,15 +247,42 @@ export function Warnings() {
const current = template();
const warnings = selectedWarnings();
const currentTitle = title();
const active = activePlacements();
if (!canvas || !images) return;
if (canvas.width !== current.width) canvas.width = current.width;
if (canvas.height !== current.height) canvas.height = current.height;
void document.fonts.load(`700 ${current.title.fontSize}px Monda`).then(() =>
drawWarningMap(canvas.getContext('2d')!, images.background, images.border, current, warnings, currentTitle),
);
const iconCodes = [...new Set(active.map(placement => placement.code))];
void Promise.all([
document.fonts.load(`700 ${current.title.fontSize}px Monda`),
...iconCodes.map(loadWeatherIcon),
]).then(() => {
const ctx = canvas.getContext('2d')!;
drawWarningMap(ctx, images.background, images.border, current, warnings, currentTitle);
const iconImages = Object.fromEntries(
iconCodes.map(code => [code, getLoadedWeatherIcon(code)]).filter((entry): entry is [string, HTMLImageElement] => Boolean(entry[1])),
);
drawWarningIcons(ctx, current, active, iconImages);
drawInteractionHandles(ctx, current, active);
});
});
const downloadPng = () => canvas.toBlob(blob => blob && download(blob, `bridinajumi-${output()}.png`), 'image/png');
const downloadPng = () => {
const images = artwork();
if (!canvas || !images) return;
const current = template();
const active = activePlacements();
const ctx = canvas.getContext('2d')!;
// Redraw without the drag/resize handles so the exported PNG stays clean.
drawWarningMap(ctx, images.background, images.border, current, selectedWarnings(), title());
const iconImages = Object.fromEntries(
active.map(placement => [placement.code, getLoadedWeatherIcon(placement.code)]).filter((entry): entry is [string, HTMLImageElement] => Boolean(entry[1])),
);
drawWarningIcons(ctx, current, active, iconImages);
canvas.toBlob(blob => {
if (blob) download(blob, `bridinajumi-${output()}.png`);
drawInteractionHandles(ctx, current, active);
}, 'image/png');
};
return <section class="pageWorkspace warningsPage">
<div class="pageHeading warningsHeading">
@@ -153,7 +319,29 @@ export function Warnings() {
</div>
}</For></div></Show>
<section class="warningsPreview"><div class="mapToolbar"><div><strong>3. Kartes priekšskatījums</strong><span>{template().width} × {template().height} px eksports</span></div><button class="primary" disabled={data.loading || Boolean(data.error)} onClick={downloadPng}>Lejupielādēt PNG</button></div><div class="canvasFrame"><canvas ref={canvas} width={template().width} height={template().height}/></div></section>
<Show when={selectedWarnings().length > 0}><section class="warningsSetup warningSymbols" aria-labelledby="warning-symbols">
<div class="fullSpan">
<h2 id="warning-symbols">3. Pievieno simbolus</h2>
<p class="fieldHint">Izvēlies simbolu un piešķir to brīdinājumam; kartē velc simbolu, lai to pārvietotu, un velc stūra rokturi, lai mainītu izmēru.</p>
<div class="symbolPalette" role="radiogroup" aria-label="Izvēlies simbolu"><For each={weatherIcons}>{icon =>
<button type="button" role="radio" aria-checked={selectedIcon() === icon} aria-label={`Simbols ${icon}`} class="weatherIconButton" classList={{ selected: selectedIcon() === icon }} onClick={() => setSelectedIcon(icon)}>
<WeatherIcon code={icon} size={40} tone={selectedIcon() === icon ? 'inverse' : 'default'}/>
</button>
}</For></div>
<div class="warningSymbolTargets"><For each={selectedWarnings()}>{warning =>
<div class={`warningSymbolRow ${warningSeverity(warning.intensity)}`}>
<span class="warningSymbolLabel">{warning.phenomenon || 'Brīdinājums'} · {warning.intensity}</span>
<span class="currentSymbol" classList={{ empty: !placements()[warning.id] }}>
<Show when={placements()[warning.id]} fallback="—">{placement => <WeatherIcon code={placement().code} size={32}/>}</Show>
</span>
<button type="button" onClick={() => assignIcon(warning.id)}>Piešķirt</button>
<button type="button" class="clearSymbol" disabled={!placements()[warning.id]} onClick={() => clearIcon(warning.id)} aria-label={`Noņemt ${warning.phenomenon || 'brīdinājuma'} simbolu`}>×</button>
</div>
}</For></div>
</div>
</section></Show>
<section class="warningsPreview"><div class="mapToolbar"><div><strong>4. Kartes priekšskatījums</strong><span>{template().width} × {template().height} px eksports</span></div><button class="primary" disabled={data.loading || Boolean(data.error)} onClick={downloadPng}>Lejupielādēt PNG</button></div><div class="canvasFrame"><canvas ref={canvas} width={template().width} height={template().height} onPointerDown={onCanvasPointerDown} onPointerMove={onCanvasPointerMove} onPointerUp={onCanvasPointerUp} onPointerCancel={onCanvasPointerUp}/></div></section>
<Show when={detailWarning()}>{warning =>
<Portal>
+18
View File
@@ -30,6 +30,24 @@ export function warningSeverity(value: string): Severity {
return 'yellow';
}
// Editorial icon placements are stored as fractions of the canvas so the same
// relative position/size holds regardless of which output resolution is active.
export type IconPlacement = { code: string; x: number; y: number; size: number };
export function drawWarningIcons(
ctx: CanvasRenderingContext2D,
template: WarningTemplate,
placements: IconPlacement[],
iconImages: Record<string, HTMLImageElement>,
) {
placements.forEach(placement => {
const image = iconImages[placement.code];
if (!image) return;
const size = placement.size * template.width;
ctx.drawImage(image, placement.x * template.width - size / 2, placement.y * template.height - size / 2, size, size);
});
}
export function drawWarningMap(
ctx: CanvasRenderingContext2D,
background: HTMLImageElement,
+10 -1
View File
@@ -1,4 +1,13 @@
.warningsPage{max-width:1900px}.warningsHeading{margin-bottom:18px}.warningsSetup{display:grid;grid-template-columns:260px minmax(0,1fr);gap:28px;padding:18px 0 24px;border-top:1px solid var(--border);border-bottom:1px solid var(--border)}.warningsSetup h2{margin:0 0 14px;font-size:20px}.warningFilterGrid{display:grid;grid-template-columns:220px 260px minmax(320px,1fr);gap:12px}.warningFilterGrid label{display:grid;gap:5px}.warningFilterGrid label span{font-size:12px;color:var(--text-muted)}.warningTitle input{width:100%}.warningsPreview{padding-top:24px}.warningsPreview canvas{display:block;width:100%;height:auto}.inlineNotice button{margin-left:14px}
.warningsPage{max-width:1900px}.warningsHeading{margin-bottom:18px}.warningsSetup{display:grid;grid-template-columns:260px minmax(0,1fr);gap:28px;padding:18px 0 24px;border-top:1px solid var(--border);border-bottom:1px solid var(--border)}.warningsSetup h2{margin:0 0 14px;font-size:20px}.warningFilterGrid{display:grid;grid-template-columns:220px 260px minmax(320px,1fr);gap:12px}.warningFilterGrid label{display:grid;gap:5px}.warningFilterGrid label span{font-size:12px;color:var(--text-muted)}.warningTitle input{width:100%}.warningsPreview{padding-top:24px}.warningsPreview canvas{display:block;width:100%;height:auto;touch-action:none;cursor:default}.inlineNotice button{margin-left:14px}
.fullSpan{grid-column:1/-1}
.warningSymbols{padding-bottom:24px}
.warningSymbolTargets{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:8px;margin-top:14px}
.warningSymbolRow{display:grid;grid-template-columns:1fr 44px auto 36px;gap:8px;align-items:center;padding:8px 10px;border:1px solid var(--border);border-left-width:5px;border-radius:12px;background:#fff}
.warningSymbolRow.yellow{border-left-color:#caa000}
.warningSymbolRow.orange{border-left-color:#c1580f}
.warningSymbolRow.red{border-left-color:#b91c1c}
.warningSymbolLabel{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:700;font-size:13px}
.warningSummary{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:18px 0}
.warningCard{position:relative;display:flex;align-items:center;gap:9px;height:44px;flex:0 0 auto;padding:0 12px 0 10px;border:1.5px solid var(--border);border-radius:12px;background:rgba(255,255,255,.65);transition:box-shadow 200ms}