4790d571fd
- 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>
92 lines
2.8 KiB
Svelte
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}
|