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 { 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 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 =>
|
||||
<Portal>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user