diff --git a/web/src/pages/warnings/Warnings.tsx b/web/src/pages/warnings/Warnings.tsx index 986ac2b..0c474d7 100644 --- a/web/src/pages/warnings/Warnings.tsx +++ b/web/src/pages/warnings/Warnings.tsx @@ -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>({}); + 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 = {}; + 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
@@ -153,7 +319,29 @@ export function Warnings() {
} -
3. Kartes priekšskatījums{template().width} × {template().height} px eksports
+ 0}>
+
+

3. Pievieno simbolus

+

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.

+
{icon => + + }
+
{warning => +
+ {warning.phenomenon || 'Brīdinājums'} · {warning.intensity} + + {placement => } + + + +
+ }
+
+
+ +
4. Kartes priekšskatījums{template().width} × {template().height} px eksports
{warning => diff --git a/web/src/pages/warnings/warningDraw.ts b/web/src/pages/warnings/warningDraw.ts index d3a0252..4353335 100644 --- a/web/src/pages/warnings/warningDraw.ts +++ b/web/src/pages/warnings/warningDraw.ts @@ -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, +) { + 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, diff --git a/web/src/pages/warnings/warnings.css b/web/src/pages/warnings/warnings.css index ed4aaad..e5bae88 100644 --- a/web/src/pages/warnings/warnings.css +++ b/web/src/pages/warnings/warnings.css @@ -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}