Add draggable, resizable weather-symbol placement to Brīdinājumi
This commit is contained in:
@@ -4,10 +4,31 @@ import { AlertTriangle, CloudFog, CloudLightning, CloudRain, CloudSnow, Flame, T
|
|||||||
import { apiHost } from '../../consts';
|
import { apiHost } from '../../consts';
|
||||||
import { download } from '../../helpers/download';
|
import { download } from '../../helpers/download';
|
||||||
import { LoadingSpinner } from '../../components/spinner/LoadingSpinner';
|
import { LoadingSpinner } from '../../components/spinner/LoadingSpinner';
|
||||||
import { drawWarningMap, type WeatherWarning, warningSeverity } from './warningDraw';
|
import { WeatherIcon } from '../../components/weatherIcons/WeatherIcon';
|
||||||
import { warningTemplates, type WarningOutput } from './warningTemplates';
|
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';
|
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) {
|
function phenomenonIcon(phenomenon: string) {
|
||||||
const normalized = (phenomenon || '').toLocaleLowerCase('lv-LV');
|
const normalized = (phenomenon || '').toLocaleLowerCase('lv-LV');
|
||||||
if (normalized.includes('vēj')) return Wind;
|
if (normalized.includes('vēj')) return Wind;
|
||||||
@@ -63,6 +84,124 @@ export function Warnings() {
|
|||||||
const detailWarning = createMemo(() => filteredWarnings().find(item => item.id === detailId()));
|
const detailWarning = createMemo(() => filteredWarnings().find(item => item.id === detailId()));
|
||||||
const closeDetail = () => setDetailId(undefined);
|
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(() => {
|
createEffect(() => {
|
||||||
if (!detailId()) return;
|
if (!detailId()) return;
|
||||||
const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') closeDetail(); };
|
const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') closeDetail(); };
|
||||||
@@ -108,15 +247,42 @@ export function Warnings() {
|
|||||||
const current = template();
|
const current = template();
|
||||||
const warnings = selectedWarnings();
|
const warnings = selectedWarnings();
|
||||||
const currentTitle = title();
|
const currentTitle = title();
|
||||||
|
const active = activePlacements();
|
||||||
if (!canvas || !images) return;
|
if (!canvas || !images) return;
|
||||||
if (canvas.width !== current.width) canvas.width = current.width;
|
if (canvas.width !== current.width) canvas.width = current.width;
|
||||||
if (canvas.height !== current.height) canvas.height = current.height;
|
if (canvas.height !== current.height) canvas.height = current.height;
|
||||||
void document.fonts.load(`700 ${current.title.fontSize}px Monda`).then(() =>
|
const iconCodes = [...new Set(active.map(placement => placement.code))];
|
||||||
drawWarningMap(canvas.getContext('2d')!, images.background, images.border, current, warnings, currentTitle),
|
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">
|
return <section class="pageWorkspace warningsPage">
|
||||||
<div class="pageHeading warningsHeading">
|
<div class="pageHeading warningsHeading">
|
||||||
@@ -153,7 +319,29 @@ export function Warnings() {
|
|||||||
</div>
|
</div>
|
||||||
}</For></div></Show>
|
}</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 tā 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 =>
|
<Show when={detailWarning()}>{warning =>
|
||||||
<Portal>
|
<Portal>
|
||||||
|
|||||||
@@ -30,6 +30,24 @@ export function warningSeverity(value: string): Severity {
|
|||||||
return 'yellow';
|
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(
|
export function drawWarningMap(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
background: HTMLImageElement,
|
background: HTMLImageElement,
|
||||||
|
|||||||
@@ -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}
|
.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}
|
.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}
|
||||||
|
|||||||
Reference in New Issue
Block a user