Focus Brīdinājumi on one selected warning with templated titles

This commit is contained in:
b0txec
2026-08-22 21:14:20 +03:00
parent f53b27d386
commit 924e4105b1
4 changed files with 36 additions and 46 deletions
+30 -5
View File
@@ -32,6 +32,8 @@ export function Warnings() {
const phenomenonMatches = phenomenon() === 'all' || warning.phenomenon === phenomenon();
return dateMatches && phenomenonMatches;
}));
const [selectedId, setSelectedId] = createSignal<string>();
const selectedWarning = createMemo(() => filteredWarnings().find(item => item.id === selectedId()));
const loadArtwork = () => {
const current = template();
@@ -50,16 +52,24 @@ export function Warnings() {
};
createEffect(() => { output(); loadArtwork(); });
createEffect(() => {
const list = filteredWarnings();
if (!list.some(item => item.id === selectedId())) setSelectedId(list[0]?.id);
});
createEffect(() => {
const warning = selectedWarning();
setTitle(warning ? defaultTitle(warning.phenomenon) : '');
});
createEffect(() => {
const images = artwork();
const current = template();
const warnings = filteredWarnings();
const warning = selectedWarning();
const currentTitle = title();
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),
drawWarningMap(canvas.getContext('2d')!, images.background, images.border, current, warning, currentTitle),
);
});
@@ -67,7 +77,7 @@ export function Warnings() {
return <section class="pageWorkspace warningsPage">
<div class="pageHeading warningsHeading">
<div><span class="eyebrow">ĒTERA GRAFIKA</span><h1>Brīdinājumi</h1><p>Aktuālie LVĢMC brīdinājumu poligoni un manuāli ievams virsraksts.</p></div>
<div><span class="eyebrow">ĒTERA GRAFIKA</span><h1>Brīdinājumi</h1><p>Izvēlies vienu LVĢMC brīdinājumu, kas jārāda kartē; virsraksts aizpildās automātiski.</p></div>
<span class="selectionCount">{filteredWarnings().length} {filteredWarnings().length === 1 ? 'brīdinājums' : 'brīdinājumi'}</span>
</div>
@@ -79,7 +89,7 @@ export function Warnings() {
<div class="warningFilters"><h2>2. Izvēlies brīdinājumu</h2><div class="warningFilterGrid">
<label><span>Diena</span><select value={selectedDate()} onChange={event => setSelectedDate(event.currentTarget.value)}><option value="all">Viss periods</option><For each={availableDates()}>{date => <option value={date}>{formatDate(date)}</option>}</For></select></label>
<label><span>Parādība</span><select value={phenomenon()} onChange={event => setPhenomenon(event.currentTarget.value)}><option value="all">Visas parādības</option><For each={phenomena()}>{item => <option value={item}>{item}</option>}</For></select></label>
<label class="warningTitle"><span>Virsraksts</span><input type="text" value={title()} placeholder="BRĪDINĀJUMS PAR STIPRU VĒJU" onInput={event => setTitle(event.currentTarget.value)} /></label>
<label class="warningTitle"><span>Virsraksts</span><input type="text" value={title()} placeholder="Brīdinājums: vējš" onInput={event => setTitle(event.currentTarget.value)} /></label>
</div></div>
</section>
@@ -87,12 +97,27 @@ export function Warnings() {
<Show when={data.error}><div class="inlineNotice error"><strong>Brīdinājumu datus neizdevās ielādēt.</strong><button class="secondary" onClick={() => refetch()}>Mēģināt vēlreiz</button></div></Show>
<Show when={!data.loading && !data.error && filteredWarnings().length === 0}><div class="inlineNotice">Izvēlētajam periodam nav aktīvu brīdinājumu. Karti joprojām var priekšskatīt, bet tajā nebūs iekrāsotu teritoriju.</div></Show>
<Show when={filteredWarnings().length > 0}><div class="warningSummary"><For each={filteredWarnings()}>{warning => <article class={`warningCard ${warningSeverity(warning.intensity)}`}><strong>{warning.phenomenon || 'Brīdinājums'}</strong><span>{warning.intensity} · {warning.regions}</span><small>{formatPeriod(warning.validFrom, warning.validUntil)}</small></article>}</For></div></Show>
<Show when={filteredWarnings().length > 0}><div class="warningSummary"><For each={filteredWarnings()}>{warning => {
const active = () => warning.id === selectedId();
return <button type="button" class={`warningCard ${warningSeverity(warning.intensity)}`} classList={{ active: active() }} aria-pressed={active()} onClick={() => setSelectedId(warning.id)}>
<strong>{warning.phenomenon || 'Brīdinājums'}</strong>
<Show when={active()}><div class="warningCardDetails">
<span>{warning.intensity} · {warning.regions}</span>
<small>{formatPeriod(warning.validFrom, warning.validUntil)}</small>
<Show when={warning.description}><p>{warning.description}</p></Show>
<Show when={warning.risks}><p class="warningRisks">{warning.risks}</p></Show>
</div></Show>
</button>;
}}</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>
</section>;
}
function defaultTitle(phenomenon: string) {
return phenomenon ? `Brīdinājums: ${phenomenon}` : '';
}
function datePart(value: string) {
const match = value.trim().match(/^(\d{4}-\d{2}-\d{2})/);
return match?.[1];
+5 -37
View File
@@ -16,9 +16,9 @@ export type WeatherWarning = {
};
const severityStyles = {
yellow: { fill: 'rgba(255, 214, 0, .76)', stroke: '#6d5b00', label: 'DZELTENS' },
orange: { fill: 'rgba(242, 126, 32, .78)', stroke: '#713300', label: 'ORANŽS' },
red: { fill: 'rgba(204, 28, 32, .80)', stroke: '#650006', label: 'SARKANS' },
yellow: { fill: 'rgba(255, 214, 0, .76)' },
orange: { fill: 'rgba(242, 126, 32, .78)' },
red: { fill: 'rgba(204, 28, 32, .80)' },
} as const;
export type Severity = keyof typeof severityStyles;
@@ -35,7 +35,7 @@ export function drawWarningMap(
background: HTMLImageElement,
border: HTMLImageElement,
template: WarningTemplate,
warnings: WeatherWarning[],
warning: WeatherWarning | undefined,
title: string,
) {
ctx.clearRect(0, 0, template.width, template.height);
@@ -46,9 +46,7 @@ export function drawWarningMap(
warningLayer.height = template.height;
const warningContext = warningLayer.getContext('2d')!;
[...warnings]
.sort((a, b) => severityRank(warningSeverity(a.intensity)) - severityRank(warningSeverity(b.intensity)))
.forEach(warning => drawWarning(warningContext, template, warning));
if (warning) drawWarning(warningContext, template, warning);
ctx.save();
ctx.filter = `blur(${template.feather}px)`;
@@ -60,7 +58,6 @@ export function drawWarningMap(
ctx.drawImage(border, 0, 0, template.width, template.height);
drawTitle(ctx, template, title);
drawLegend(ctx, template, warnings);
}
function drawWarning(ctx: CanvasRenderingContext2D, template: WarningTemplate, warning: WeatherWarning) {
@@ -118,32 +115,3 @@ function drawTitle(ctx: CanvasRenderingContext2D, template: WarningTemplate, val
ctx.fillText('LVĢMC', x + width - sourceWidth / 2, layout.top + layout.height + sourceHeight / 2);
}
function drawLegend(ctx: CanvasRenderingContext2D, template: WarningTemplate, warnings: WeatherWarning[]) {
const severities = [...new Set(warnings.map(item => warningSeverity(item.intensity)))]
.sort((a, b) => severityRank(b) - severityRank(a));
if (!severities.length) return;
const layout = template.legend;
ctx.font = `700 ${layout.fontSize}px Monda`;
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
severities.forEach((severity, index) => {
const style = severityStyles[severity];
const y = template.height - layout.bottom - index * (layout.swatch + layout.gap);
const labelWidth = ctx.measureText(style.label).width;
const swatchX = template.width - layout.right - labelWidth - layout.gap - layout.swatch;
ctx.fillStyle = style.fill;
ctx.fillRect(swatchX, y - layout.swatch / 2, layout.swatch, layout.swatch);
ctx.strokeStyle = style.stroke;
ctx.lineWidth = template.width === 1920 ? 2 : 3;
ctx.strokeRect(swatchX, y - layout.swatch / 2, layout.swatch, layout.swatch);
ctx.fillStyle = '#fff';
ctx.shadowColor = 'rgba(0,0,0,.85)';
ctx.shadowBlur = template.width === 1920 ? 4 : 7;
ctx.fillText(style.label, template.width - layout.right, y);
ctx.shadowBlur = 0;
});
}
function severityRank(severity: Severity) {
return severity === 'red' ? 3 : severity === 'orange' ? 2 : 1;
}
@@ -13,7 +13,6 @@ export type WarningTemplate = {
feather: number;
geo: { a: number; b: number; c: number; d: number; e: number; f: number };
title: { right: number; top: number; height: number; fontSize: number; horizontalPadding: number; maximumWidth: number };
legend: { right: number; bottom: number; swatch: number; gap: number; fontSize: number };
};
// `geo` maps (lon, lat) to pixel space as x = a*lon + b*lat + c, y = d*lon + e*lat + f.
@@ -33,7 +32,6 @@ export const warningTemplates: Record<WarningOutput, WarningTemplate> = {
feather: 16,
geo: { a: 171.6971, b: -21.1156, c: -2040.5772, d: -5.3027, e: -306.1028, f: 18050.1672 },
title: { right: 88, top: 38, height: 62, fontSize: 30, horizontalPadding: 24, maximumWidth: 900 },
legend: { right: 88, bottom: 58, swatch: 27, gap: 14, fontSize: 20 },
},
'3840x1440': {
width: 3840,
@@ -43,6 +41,5 @@ export const warningTemplates: Record<WarningOutput, WarningTemplate> = {
feather: 28,
geo: { a: 279.8026, b: -34.4106, c: -2997.9777, d: -8.6414, e: -498.8342, f: 29339.5317 },
title: { right: 376, top: 76, height: 124, fontSize: 60, horizontalPadding: 48, maximumWidth: 1700 },
legend: { right: 376, bottom: 76, swatch: 42, gap: 22, fontSize: 30 },
},
};
+1 -1
View File
@@ -1 +1 @@
.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%}.warningSummary{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:10px;margin:18px 0}.warningCard{display:grid;gap:3px;padding:12px 14px;border:1px solid var(--border);border-left-width:7px;border-radius:10px;background:rgba(255,255,255,.6)}.warningCard.yellow{border-left-color:#ffd600}.warningCard.orange{border-left-color:#f27e20}.warningCard.red{border-left-color:#cc1c20}.warningCard span,.warningCard small{color:var(--text-muted)}.warningsPreview{padding-top:24px}.warningsPreview canvas{display:block;width:100%;height:auto}.inlineNotice button{margin-left:14px}@media(max-width:1100px){.warningsSetup{grid-template-columns:1fr}.warningFilterGrid{grid-template-columns:repeat(2,minmax(220px,1fr))}.warningTitle{grid-column:1/-1}}@media(max-width:650px){.warningFilterGrid,.resolutionChoices{grid-template-columns:1fr}.warningTitle{grid-column:auto}}
.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%}.warningSummary{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));align-items:start;gap:10px;margin:18px 0}.warningCard{display:grid;gap:3px;padding:12px 14px;border:1px solid var(--border);border-left-width:7px;border-radius:10px;background:rgba(255,255,255,.6);width:100%;appearance:none;font:inherit;text-align:left;cursor:pointer}.warningCard.yellow{border-left-color:#ffd600}.warningCard.orange{border-left-color:#f27e20}.warningCard.red{border-left-color:#cc1c20}.warningCard span,.warningCard small{color:var(--text-muted)}.warningCard.active{background:rgba(255,255,255,.95);border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-pale)}.warningCardDetails{display:grid;gap:4px;margin-top:6px}.warningCardDetails p{margin:0;font-size:13px;line-height:1.4;color:var(--text-muted)}.warningRisks{font-style:italic}.warningsPreview{padding-top:24px}.warningsPreview canvas{display:block;width:100%;height:auto}.inlineNotice button{margin-left:14px}@media(max-width:1100px){.warningsSetup{grid-template-columns:1fr}.warningFilterGrid{grid-template-columns:repeat(2,minmax(220px,1fr))}.warningTitle{grid-column:1/-1}}@media(max-width:650px){.warningFilterGrid,.resolutionChoices{grid-template-columns:1fr}.warningTitle{grid-column:auto}}