Files
windwiderstand/src/lib/components/blocks/strommix/RenewableSparkline.svelte
T
Peter Meier 4790d571fd feat(strommix): hero variant, 24h sparkline, trend, polishing
- New "hero" variant between compact and full: pct + status +
  sparkline + trend, no sub-cards or accordion
- 24h Wind+Solar sparkline below hero pct, accent-colored
- Trend arrow vs previous hour with delta percentage points
- Polling pauses on tab hidden, refetches on visibility return
  if last fetch >5min old
- Skeleton loader replaces "Lade…" text (matches final layout)
- Retry button on error state (silent vs explicit fetch)
- Stale-flag now prominent red badge in header bar
- Stand-time wrapped in <time datetime title> for full-date hover
- loadMw=0 falls back to last hourly slot instead of red error
- Negative-price banner replaces standalone price card (dedup)
- Production breakdown grid: clean auto/1fr label-value alignment
- Print CSS: details auto-open, chevrons hidden
- All German strings moved to translation keys
- STROMMIX_DEFAULT_THRESHOLDS const as single source for buckets

Includes data-block="LiveStrommix" attribute for the new
identifier scheme + new RenewableSparkline.svelte component.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:44:57 +02:00

92 lines
2.8 KiB
Svelte

<script lang="ts">
import type { ResidualLoadPoint } from "$lib/strommix";
import { useTranslate, T } from "$lib/translations";
/** Daily per-slot data — the sparkline plots `weatherRenewableMw / loadMw`
* per slot. Compact, decorative, sits below the hero %. */
let {
points,
accent = "currentColor",
}: { points: ResidualLoadPoint[]; accent?: string } = $props();
const t = useTranslate();
const W = 240;
const H = 36;
const PAD = 2;
const series = $derived.by(() => {
return points
.filter((p) => p.loadMw > 0)
.map((p) => ({
unix: p.unix,
pct: (p.weatherRenewableMw / p.loadMw) * 100,
}));
});
const range = $derived.by(() => {
if (series.length === 0) return { minX: 0, maxX: 1, minY: 0, maxY: 100 };
const xs = series.map((s) => s.unix);
const ys = series.map((s) => s.pct);
return {
minX: Math.min(...xs),
maxX: Math.max(...xs),
minY: Math.max(0, Math.min(...ys) - 5),
maxY: Math.min(100, Math.max(...ys) + 5),
};
});
function xOf(unix: number): number {
const span = range.maxX - range.minX || 1;
return PAD + ((unix - range.minX) / span) * (W - 2 * PAD);
}
function yOf(pct: number): number {
const span = range.maxY - range.minY || 1;
return PAD + (1 - (pct - range.minY) / span) * (H - 2 * PAD);
}
const linePath = $derived.by(() => {
if (series.length < 2) return "";
return series
.map(
(s, i) => `${i === 0 ? "M" : "L"} ${xOf(s.unix).toFixed(1)} ${yOf(s.pct).toFixed(1)}`,
)
.join(" ");
});
const areaPath = $derived.by(() => {
if (series.length < 2) return "";
const top = linePath;
const last = series[series.length - 1];
const first = series[0];
return `${top} L ${xOf(last.unix).toFixed(1)} ${(H - PAD).toFixed(1)} L ${xOf(first.unix).toFixed(1)} ${(H - PAD).toFixed(1)} Z`;
});
const ariaLabel = $derived.by(() => {
if (series.length === 0) return "";
return t(T.strommix_sparkline_aria, {
from: Math.round(series[0].pct),
to: Math.round(series[series.length - 1].pct),
});
});
</script>
{#if series.length >= 2}
<svg
viewBox="0 0 {W} {H}"
preserveAspectRatio="none"
class="strommix-sparkline w-full max-w-[240px] h-9 mx-auto"
role="img"
aria-label={ariaLabel}
>
<path d={areaPath} fill={accent} fill-opacity="0.15" />
<path
d={linePath}
stroke={accent}
stroke-width="1.5"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{/if}