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>
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||
import type { LiveStrommixBlockData } from "$lib/block-types";
|
||||
import type { StrommixResponse } from "$lib/strommix";
|
||||
import { STROMMIX_DEFAULT_THRESHOLDS } from "$lib/strommix";
|
||||
import { useTranslate, T } from "$lib/translations";
|
||||
import Icon from "@iconify/svelte";
|
||||
import "$lib/iconify-offline";
|
||||
@@ -12,20 +13,30 @@
|
||||
import DunkelflauteCounter from "./strommix/DunkelflauteCounter.svelte";
|
||||
import NegPriceCounter from "./strommix/NegPriceCounter.svelte";
|
||||
import FlowBalanceTodayYtd from "./strommix/FlowBalanceTodayYtd.svelte";
|
||||
import RenewableSparkline from "./strommix/RenewableSparkline.svelte";
|
||||
|
||||
let { block }: { block: LiveStrommixBlockData } = $props();
|
||||
const t = useTranslate();
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
const isCompact = $derived(block.variant === "compact");
|
||||
const isHero = $derived(block.variant === "hero");
|
||||
/** Variants without sub-cards / accordion / details. Hero & compact
|
||||
* keep things minimal; full renders the entire dashboard. */
|
||||
const isMinimal = $derived(isCompact || isHero);
|
||||
|
||||
// Live data + lifecycle
|
||||
let live = $state<StrommixResponse | null>(null);
|
||||
let loadError = $state<string | null>(null);
|
||||
let isLoading = $state(false);
|
||||
let lastFetchAt = $state<number | null>(null);
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function loadLive() {
|
||||
/** Fetch live snapshot. `silent` keeps the existing data on screen
|
||||
* during a background re-poll; only the first load and explicit
|
||||
* retries flip `isLoading` so the skeleton appears. */
|
||||
async function loadLive(silent = false) {
|
||||
if (!silent) isLoading = true;
|
||||
try {
|
||||
const res = await fetch("/api/strommix", { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
@@ -34,35 +45,115 @@
|
||||
loadError = null;
|
||||
} catch (e) {
|
||||
loadError = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
if (!silent) isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-poll every 5 minutes while the document is visible. Pause on
|
||||
* hidden tab so embedded/idle widgets don't burn API quota. On show
|
||||
* trigger an immediate silent refresh if the last fetch is stale
|
||||
* (>5 min old) so the visitor sees current data, not whatever was
|
||||
* on screen when they switched away. */
|
||||
function startPolling() {
|
||||
if (pollTimer) return;
|
||||
pollTimer = setInterval(() => loadLive(true), 5 * 60 * 1000);
|
||||
}
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
function handleVisibility() {
|
||||
if (typeof document === "undefined") return;
|
||||
if (document.hidden) {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
startPolling();
|
||||
const stale = !lastFetchAt || Date.now() - lastFetchAt > 5 * 60 * 1000;
|
||||
if (stale) loadLive(true);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadLive();
|
||||
pollTimer = setInterval(loadLive, 5 * 60 * 1000); // re-poll every 5 min
|
||||
startPolling();
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("visibilitychange", handleVisibility);
|
||||
}
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
stopPolling();
|
||||
if (typeof document !== "undefined") {
|
||||
document.removeEventListener("visibilitychange", handleVisibility);
|
||||
}
|
||||
});
|
||||
|
||||
/** Current Last in MW. Falls back to the most recent history slot
|
||||
* if the live snapshot lost its load value (SMARD load filter
|
||||
* occasionally lags behind production). Keeps the % live instead of
|
||||
* showing the red "Lastdaten fehlen" banner for the brief lag
|
||||
* window. */
|
||||
const effectiveLoadMw = $derived.by(() => {
|
||||
if (!live) return 0;
|
||||
if (live.loadMw > 0) return live.loadMw;
|
||||
const history = live.todayHourly;
|
||||
if (!history || history.length === 0) return 0;
|
||||
for (let i = history.length - 1; i >= 0; i--) {
|
||||
if (history[i].loadMw > 0) return history[i].loadMw;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
const usingLoadFallback = $derived(
|
||||
!!live && live.loadMw <= 0 && effectiveLoadMw > 0,
|
||||
);
|
||||
|
||||
// Derived numbers
|
||||
const renewablePct = $derived.by(() => {
|
||||
if (!live || live.loadMw <= 0) return null;
|
||||
return (live.weatherRenewableMw / live.loadMw) * 100;
|
||||
if (!live || effectiveLoadMw <= 0) return null;
|
||||
return (live.weatherRenewableMw / effectiveLoadMw) * 100;
|
||||
});
|
||||
|
||||
type Bucket = "dunkelflaute" | "niedrig" | "mittel" | "hoch";
|
||||
const bucket = $derived.by((): Bucket | null => {
|
||||
if (renewablePct == null) return null;
|
||||
const t1 = block.threshold_dunkelflaute ?? 15;
|
||||
const t2 = block.threshold_niedrig ?? 35;
|
||||
const t3 = block.threshold_mittel ?? 60;
|
||||
const t1 = block.threshold_dunkelflaute ?? STROMMIX_DEFAULT_THRESHOLDS.dunkelflaute;
|
||||
const t2 = block.threshold_niedrig ?? STROMMIX_DEFAULT_THRESHOLDS.niedrig;
|
||||
const t3 = block.threshold_mittel ?? STROMMIX_DEFAULT_THRESHOLDS.mittel;
|
||||
if (renewablePct < t1) return "dunkelflaute";
|
||||
if (renewablePct < t2) return "niedrig";
|
||||
if (renewablePct < t3) return "mittel";
|
||||
return "hoch";
|
||||
});
|
||||
|
||||
/** Compare current % to the slot one hour back in `todayHourly` to
|
||||
* give a quick "is it climbing or dropping?" cue alongside the
|
||||
* status label. Threshold ≥0.5 %-pt to filter flat noise. */
|
||||
type Trend = { direction: "up" | "down" | "flat"; deltaPct: number };
|
||||
const trend = $derived.by((): Trend | null => {
|
||||
if (renewablePct == null || !live?.todayHourly) return null;
|
||||
const slots = live.todayHourly;
|
||||
if (slots.length < 2) return null;
|
||||
const nowUnix = live.measuredAtUnix ?? slots[slots.length - 1].unix;
|
||||
const target = nowUnix - 3600;
|
||||
let prev: typeof slots[number] | null = null;
|
||||
for (let i = slots.length - 1; i >= 0; i--) {
|
||||
if (slots[i].unix <= target && slots[i].loadMw > 0) {
|
||||
prev = slots[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!prev) return null;
|
||||
const prevPct = (prev.weatherRenewableMw / prev.loadMw) * 100;
|
||||
const delta = renewablePct - prevPct;
|
||||
if (Math.abs(delta) < 0.5) return { direction: "flat", deltaPct: 0 };
|
||||
return {
|
||||
direction: delta > 0 ? "up" : "down",
|
||||
deltaPct: Math.abs(delta),
|
||||
};
|
||||
});
|
||||
|
||||
const statusLabel = $derived.by(() => {
|
||||
if (!bucket) return "";
|
||||
const labels: Record<Bucket, string | undefined> = {
|
||||
@@ -145,6 +236,22 @@
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
function fmtDateTimeAttr(unix: number | null): string {
|
||||
if (!unix) return "";
|
||||
return new Date(unix * 1000).toISOString();
|
||||
}
|
||||
function fmtDateTimeFull(unix: number | null): string {
|
||||
if (!unix) return "";
|
||||
const d = new Date(unix * 1000);
|
||||
return d.toLocaleString("de-DE", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/** Cross-border data is considered stale (and worth flagging) when its
|
||||
* timestamp is more than 30 min behind the production-snapshot. cbpf
|
||||
@@ -230,10 +337,29 @@
|
||||
if (!live) return null;
|
||||
return live.netFlowGw >= 0 ? "Export" : "Import";
|
||||
});
|
||||
|
||||
/** History-Panels (Residuallast, Dunkelflaute, NegPreis, Flow,
|
||||
* Capacity-Factor) sind sekundärer Kontext. Im Akkordeon gebündelt
|
||||
* damit die Hauptkennzahlen oben dominieren — aufklappbar wenn
|
||||
* Leser mehr Tiefe will. */
|
||||
const hasHistoryPanels = $derived.by(() => {
|
||||
if (!live || isMinimal) return false;
|
||||
return !!(
|
||||
(block.installed_wind_onshore_gw || block.installed_solar_gw) ||
|
||||
(live.todayHourly && live.todayHourly.length > 0) ||
|
||||
live.lastDunkelflaute ||
|
||||
(live.worstDunkelflauten && live.worstDunkelflauten.length > 0) ||
|
||||
live.negPriceHoursYtd != null ||
|
||||
typeof block.einsman_costs_ytd_eur_mio === "number" ||
|
||||
live.crossborderTodayGwh ||
|
||||
live.netCrossborderGwhYtd != null
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="live-strommix {layoutClasses}"
|
||||
data-block="LiveStrommix"
|
||||
data-block-type="live_strommix"
|
||||
data-block-slug={block._slug}
|
||||
>
|
||||
@@ -303,7 +429,7 @@
|
||||
Source-Badge zeigt klar woher die zahlen kommen ohne dass
|
||||
man die Detail-Aufklappkachel öffnen muss. -->
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-x-3 gap-y-1 justify-between px-4 py-2 border-b border-current/10 text-xs font-semibold {accentClasses.label}"
|
||||
class="flex flex-wrap items-center gap-x-3 gap-y-1 justify-between px-4 py-1.5 border-b border-current/10 text-xs font-semibold {accentClasses.label}"
|
||||
>
|
||||
<span class="inline-flex items-center gap-1.5 uppercase tracking-wide">
|
||||
<Icon
|
||||
@@ -315,36 +441,63 @@
|
||||
</span>
|
||||
{#if live}
|
||||
<span class="inline-flex items-center gap-2 normal-case font-normal">
|
||||
<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] uppercase tracking-wide {accentClasses.badge}">
|
||||
<Icon icon="mdi:database-outline" class="size-3" aria-hidden="true" />
|
||||
{#if live.dataSource === "smard"}
|
||||
SMARD
|
||||
{:else if live.dataSource === "smard+energy-charts"}
|
||||
SMARD + EC
|
||||
{:else}
|
||||
Energy-Charts
|
||||
{/if}
|
||||
</span>
|
||||
{#if live.measuredAtUnix}
|
||||
<span>
|
||||
{t(T.strommix_stand, { time: fmtTime(live.measuredAtUnix) })}
|
||||
{#if isStale}
|
||||
<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] uppercase tracking-wide bg-fire-700 text-fire-50">
|
||||
<Icon icon="mdi:cloud-off-outline" class="size-3" aria-hidden="true" />
|
||||
{t(T.strommix_stale_badge)}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] uppercase tracking-wide {accentClasses.badge}">
|
||||
<Icon icon="mdi:database-outline" class="size-3" aria-hidden="true" />
|
||||
{#if live.dataSource === "smard"}
|
||||
SMARD
|
||||
{:else if live.dataSource === "smard+energy-charts"}
|
||||
SMARD + EC
|
||||
{:else}
|
||||
Energy-Charts
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{#if live.measuredAtUnix}
|
||||
<time
|
||||
datetime={fmtDateTimeAttr(live.measuredAtUnix)}
|
||||
title={fmtDateTimeFull(live.measuredAtUnix)}
|
||||
>
|
||||
{t(T.strommix_stand, { time: fmtTime(live.measuredAtUnix) })}
|
||||
</time>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Main: percentage + status -->
|
||||
{#if loadError}
|
||||
<div class="p-6 text-sm text-fire-700">
|
||||
{t(T.strommix_load_error, { error: loadError })}
|
||||
{#if loadError && !live}
|
||||
<div class="p-6 text-sm text-fire-700 flex flex-wrap items-center gap-3">
|
||||
<Icon icon="mdi:alert-circle-outline" class="size-5 shrink-0" aria-hidden="true" />
|
||||
<span class="flex-1 min-w-0">
|
||||
{t(T.strommix_load_error, { error: loadError })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-xs border border-fire-300 bg-fire-100 px-3 py-1 text-xs font-semibold text-fire-800 hover:bg-fire-200 disabled:opacity-50"
|
||||
onclick={() => loadLive()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Icon icon="mdi:refresh" class="size-3.5 {isLoading ? 'animate-spin' : ''}" aria-hidden="true" />
|
||||
{t(T.strommix_retry)}
|
||||
</button>
|
||||
</div>
|
||||
{:else if !live}
|
||||
<div class="p-6 text-center text-stein-500 text-sm">
|
||||
{t(T.strommix_loading)}
|
||||
<!-- Skeleton: matches the final layout's footprint so the
|
||||
% drop-in doesn't shift the rest of the page. -->
|
||||
<div class="p-4 sm:p-6 text-center" aria-busy="true" aria-live="polite">
|
||||
<div class="strommix-skel mx-auto h-16 sm:h-20 w-44 sm:w-56 rounded-xs bg-stein-200/70"></div>
|
||||
<div class="strommix-skel mx-auto mt-3 h-3 w-40 rounded-xs bg-stein-200/60"></div>
|
||||
<div class="strommix-skel mx-auto mt-2 h-4 w-32 rounded-xs bg-stein-200/60"></div>
|
||||
<span class="sr-only">{t(T.strommix_loading)}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-6 sm:p-8 text-center">
|
||||
<div class="p-4 sm:p-6 text-center">
|
||||
{#if renewablePct != null}
|
||||
<!-- Key change keys the % display so a fresh fetch
|
||||
triggers a brief CSS fade-in transition. The
|
||||
@@ -352,26 +505,47 @@
|
||||
supports it. -->
|
||||
{#key live.measuredAtUnix}
|
||||
<div
|
||||
class="strommix-pct text-7xl sm:text-8xl font-bold tabular-nums leading-none {accentClasses.pct}"
|
||||
class="strommix-pct text-5xl sm:text-7xl font-bold tabular-nums leading-none {accentClasses.pct}"
|
||||
>
|
||||
{Math.round(renewablePct)}%
|
||||
</div>
|
||||
{/key}
|
||||
<div class="mt-2 text-sm sm:text-base text-stein-700">
|
||||
<div class="mt-1.5 text-sm text-stein-700">
|
||||
{t(T.strommix_wind_solar_share)}
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 text-lg font-semibold {accentClasses.label}"
|
||||
class="mt-2 text-base font-semibold {accentClasses.label}"
|
||||
>
|
||||
→ {statusLabel}
|
||||
</div>
|
||||
{#if argumentHtml}
|
||||
{#if trend}
|
||||
<div class="mt-1 text-[11px] text-stein-500 tabular-nums">
|
||||
{#if trend.direction === "up"}
|
||||
{t(T.strommix_trend_up, { delta: trend.deltaPct.toFixed(1).replace(".", ",") })}
|
||||
{:else if trend.direction === "down"}
|
||||
{t(T.strommix_trend_down, { delta: trend.deltaPct.toFixed(1).replace(".", ",") })}
|
||||
{:else}
|
||||
{t(T.strommix_trend_flat)}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if live.todayHourly && live.todayHourly.length >= 2}
|
||||
<div class="mt-2 {accentClasses.label}">
|
||||
<RenewableSparkline points={live.todayHourly} accent="currentColor" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if argumentHtml && !isHero}
|
||||
<div
|
||||
class="prose prose-xs mt-2 max-w-md mx-auto text-xs leading-snug text-stein-700 [&_p]:my-1"
|
||||
>
|
||||
{@html argumentHtml}
|
||||
</div>
|
||||
{/if}
|
||||
{#if usingLoadFallback}
|
||||
<div class="mt-2 text-[11px] text-stein-500 italic">
|
||||
{t(T.strommix_load_missing)}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Production data arrived but load is missing → no percentage,
|
||||
but we still show what we have so the widget isn't blank. -->
|
||||
@@ -386,21 +560,23 @@
|
||||
|
||||
<!-- Context cards: konventionell + saldo, je eigenes box-block.
|
||||
Stack mobile, side-by-side ab sm. Kein farbakzent damit haupt-
|
||||
karte mit %-zahl optisch dominiert. -->
|
||||
{#if live}
|
||||
<div class="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div class="rounded-xs border border-stein-200 bg-white px-4 py-3 text-sm shadow-sm">
|
||||
karte mit %-zahl optisch dominiert. Hero-Variante zeigt nur
|
||||
Hauptzahl + Sparkline; sub-cards würden den minimalen Charakter
|
||||
brechen. -->
|
||||
{#if live && !isHero}
|
||||
<div class="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<div class="rounded-xs border border-stein-200 bg-white px-4 py-2.5 text-sm shadow-sm">
|
||||
<div class="text-xs text-stein-600 uppercase tracking-wide">
|
||||
{t(T.strommix_conventional_now)}
|
||||
</div>
|
||||
<div class="text-2xl font-semibold tabular-nums text-stein-900">
|
||||
<div class="text-xl font-semibold tabular-nums text-stein-900">
|
||||
{fmtMw(live.conventionalMw)}
|
||||
</div>
|
||||
<div class="text-[11px] text-stein-500 mt-1">
|
||||
<div class="text-[11px] text-stein-500 mt-0.5">
|
||||
{t(T.strommix_conventional_formula)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-xs border border-stein-200 bg-white px-4 py-3 text-sm shadow-sm">
|
||||
<div class="rounded-xs border border-stein-200 bg-white px-4 py-2.5 text-sm shadow-sm">
|
||||
<div class="text-xs text-stein-600 uppercase tracking-wide">
|
||||
{importDirection === "Import"
|
||||
? t(T.strommix_import)
|
||||
@@ -408,16 +584,21 @@
|
||||
? t(T.strommix_export)
|
||||
: t(T.strommix_saldo)}
|
||||
</div>
|
||||
<div class="text-2xl font-semibold tabular-nums text-stein-900">
|
||||
<div class="text-xl font-semibold tabular-nums text-stein-900">
|
||||
{#if importDirection === "Import"}↓{/if}
|
||||
{#if importDirection === "Export"}↑{/if}
|
||||
{fmtGw(live.netFlowGw)}
|
||||
</div>
|
||||
{#if live.crossborderMeasuredAtUnix}
|
||||
<div class="text-[11px] text-stein-500 mt-1">
|
||||
{t(T.strommix_stand, {
|
||||
time: fmtTime(live.crossborderMeasuredAtUnix),
|
||||
})}
|
||||
<div class="text-[11px] text-stein-500 mt-0.5">
|
||||
<time
|
||||
datetime={fmtDateTimeAttr(live.crossborderMeasuredAtUnix)}
|
||||
title={fmtDateTimeFull(live.crossborderMeasuredAtUnix)}
|
||||
>
|
||||
{t(T.strommix_stand, {
|
||||
time: fmtTime(live.crossborderMeasuredAtUnix),
|
||||
})}
|
||||
</time>
|
||||
{#if cbStale}<span class="text-fire-700">
|
||||
· {t(T.strommix_delayed)}</span
|
||||
>{/if}
|
||||
@@ -426,41 +607,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Price card — separater block, full-width damit der vergleich
|
||||
DE/FR + slot-info nicht in den 2-spaltigen flow oben gepresst
|
||||
werden muss. -->
|
||||
<div class="mt-3 rounded-xs border border-stein-200 bg-white px-4 py-3 text-sm shadow-sm">
|
||||
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
|
||||
<span class="font-semibold">
|
||||
{t(T.strommix_price_de, { price: fmtPrice(live.priceDeEurMwh) })}
|
||||
</span>
|
||||
{#if enableFr && live.priceFrEurMwh != null}
|
||||
<span class="text-stein-600">
|
||||
{t(T.strommix_price_fr_inline, { price: fmtPrice(live.priceFrEurMwh) })}
|
||||
</span>
|
||||
{/if}
|
||||
{#if live.priceMeasuredAtUnix}
|
||||
<span class="text-[11px] text-stein-500">
|
||||
· {t(T.strommix_slot, { time: fmtTime(live.priceMeasuredAtUnix) })}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Negativpreis-warnung als eigener prominenter banner statt
|
||||
inline-badge. Greift wenn Day-Ahead-Preis negativ — sehr
|
||||
aussagekräftiger marktzustand der nicht versteckt werden
|
||||
sollte. -->
|
||||
{#if (live.priceDeEurMwh ?? 0) < 0}
|
||||
<div class="mt-3 flex items-start gap-3 rounded-xs border-2 border-fire-500 bg-fire-50 px-4 py-3 text-sm shadow-sm">
|
||||
<!-- Negativpreis-warnung als eigener prominenter banner statt
|
||||
inline-badge. Greift wenn Day-Ahead-Preis negativ — sehr
|
||||
aussagekräftiger marktzustand der nicht versteckt werden
|
||||
sollte. Ersetzt die separate Price-Card komplett, weil
|
||||
derselbe Preis sonst zweimal genannt würde. -->
|
||||
<div class="mt-2 flex items-start gap-3 rounded-xs border-2 border-fire-500 bg-fire-50 px-4 py-2.5 text-sm shadow-sm">
|
||||
<Icon
|
||||
icon="mdi:alert-circle"
|
||||
class="size-6 shrink-0 text-fire-700"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="text-base font-bold text-fire-800">
|
||||
{t(T.strommix_negative_price)}: {fmtPrice(live.priceDeEurMwh)}
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-0.5">
|
||||
<span class="text-base font-bold text-fire-800">
|
||||
{t(T.strommix_negative_price)}: {fmtPrice(live.priceDeEurMwh)}
|
||||
</span>
|
||||
{#if enableFr && live.priceFrEurMwh != null}
|
||||
<span class="text-xs text-fire-900/70">
|
||||
{t(T.strommix_price_fr_inline, { price: fmtPrice(live.priceFrEurMwh) })}
|
||||
</span>
|
||||
{/if}
|
||||
{#if live.priceMeasuredAtUnix}
|
||||
<span class="text-[11px] text-fire-900/60">
|
||||
· <time
|
||||
datetime={fmtDateTimeAttr(live.priceMeasuredAtUnix)}
|
||||
title={fmtDateTimeFull(live.priceMeasuredAtUnix)}
|
||||
>{t(T.strommix_slot, { time: fmtTime(live.priceMeasuredAtUnix) })}</time>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if showDisclaimer}
|
||||
<div class="mt-1 text-xs leading-snug text-fire-900/80 prose prose-xs max-w-none [&_p]:my-0">
|
||||
@@ -469,91 +645,121 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Price card — separater block, full-width damit DE/FR-
|
||||
Vergleich + Slot-Info sauber nebeneinander stehen. Bei
|
||||
Negativpreisen unterdrückt; der Negativbanner oben zeigt
|
||||
dann denselben Preis prominenter. -->
|
||||
<div class="mt-2 rounded-xs border border-stein-200 bg-white px-4 py-2 text-sm shadow-sm">
|
||||
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
|
||||
<span class="font-semibold">
|
||||
{t(T.strommix_price_de, { price: fmtPrice(live.priceDeEurMwh) })}
|
||||
</span>
|
||||
{#if enableFr && live.priceFrEurMwh != null}
|
||||
<span class="text-stein-600">
|
||||
{t(T.strommix_price_fr_inline, { price: fmtPrice(live.priceFrEurMwh) })}
|
||||
</span>
|
||||
{/if}
|
||||
{#if live.priceMeasuredAtUnix}
|
||||
<span class="text-[11px] text-stein-500">
|
||||
· <time
|
||||
datetime={fmtDateTimeAttr(live.priceMeasuredAtUnix)}
|
||||
title={fmtDateTimeFull(live.priceMeasuredAtUnix)}
|
||||
>{t(T.strommix_slot, { time: fmtTime(live.priceMeasuredAtUnix) })}</time>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Capacity-factor bars: live vs. installiert. Nur full-Variante; in
|
||||
compact wäre's optisch zu dicht. Renders nur, wenn Stammdaten
|
||||
gesetzt sind. -->
|
||||
{#if live && !isCompact && (block.installed_wind_onshore_gw || block.installed_solar_gw)}
|
||||
<div class="mt-4 rounded-xs border border-stein-200 bg-white px-4 py-3 shadow-sm">
|
||||
<CapacityFactorBars
|
||||
windOnshoreLiveMw={live.windOnshoreMw}
|
||||
windOffshoreLiveMw={live.windOffshoreMw}
|
||||
solarLiveMw={live.solarMw}
|
||||
installedWindOnshoreGw={block.installed_wind_onshore_gw ?? 0}
|
||||
installedWindOffshoreGw={block.installed_wind_offshore_gw ?? 0}
|
||||
installedSolarGw={block.installed_solar_gw ?? 0}
|
||||
installedAsOf={block.installed_as_of}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- History-Panels gebündelt im Akkordeon: Residuallast,
|
||||
Dunkelflaute, Negativpreise, Cross-Border, Capacity-Factor.
|
||||
Default zu — Hauptkennzahlen oben sollen dominieren. -->
|
||||
{#if live && hasHistoryPanels}
|
||||
<details class="strommix-history mt-3 group">
|
||||
<summary class="cursor-pointer rounded-xs border border-stein-200 bg-white px-4 py-2 text-sm font-semibold text-stein-700 shadow-sm hover:bg-stein-50 inline-flex items-center gap-2">
|
||||
<Icon icon="mdi:chevron-right" class="size-4 transition-transform group-open:rotate-90" aria-hidden="true" />
|
||||
{t(T.strommix_history_summary)}
|
||||
</summary>
|
||||
|
||||
<!-- Residuallast-Kurve heute: Last minus Wind+Solar. Nur full-Variante,
|
||||
und nur wenn der Aggregator Historien-Daten geliefert hat (CMS mit
|
||||
history-mode-Schema im Hintergrund — sonst null). -->
|
||||
{#if live && !isCompact && live.todayHourly && live.todayHourly.length > 0}
|
||||
<div class="mt-4 rounded-xs border border-stein-200 bg-white px-4 py-3 shadow-sm">
|
||||
<h4 class="text-sm font-semibold text-stein-800">
|
||||
Residuallast heute
|
||||
</h4>
|
||||
<p class="mt-0.5 text-[11px] text-stein-600">
|
||||
Last minus Wind+Solar im Tagesverlauf. Die schraffierte Lücke
|
||||
füllen konventionelle Kraftwerke und Importe.
|
||||
</p>
|
||||
<div class="mt-2">
|
||||
<ResidualLoadChart points={live.todayHourly} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if live.todayHourly && live.todayHourly.length > 0}
|
||||
<div class="mt-2 rounded-xs border border-stein-200 bg-white px-4 py-2.5 shadow-sm">
|
||||
<h4 class="text-sm font-semibold text-stein-800">
|
||||
{t(T.strommix_residual_today_title)}
|
||||
</h4>
|
||||
<p class="mt-0.5 text-[11px] text-stein-600">
|
||||
{t(T.strommix_residual_today_desc)}
|
||||
</p>
|
||||
<div class="mt-2">
|
||||
<ResidualLoadChart points={live.todayHourly} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Dunkelflaute-Counter: letzte Phase + Top-5 Liste. Nur full-Variante.
|
||||
Renders nur, wenn Aggregator History-Daten + qualifying runs hat. -->
|
||||
{#if live && !isCompact && (live.lastDunkelflaute || (live.worstDunkelflauten && live.worstDunkelflauten.length > 0))}
|
||||
<div class="mt-4 rounded-xs border border-stein-200 bg-white px-4 py-3 shadow-sm">
|
||||
<DunkelflauteCounter
|
||||
last={live.lastDunkelflaute}
|
||||
worst={live.worstDunkelflauten}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if live.lastDunkelflaute || (live.worstDunkelflauten && live.worstDunkelflauten.length > 0)}
|
||||
<div class="mt-2 rounded-xs border border-stein-200 bg-white px-4 py-2.5 shadow-sm">
|
||||
<DunkelflauteCounter
|
||||
last={live.lastDunkelflaute}
|
||||
worst={live.worstDunkelflauten}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Negativpreis- + EinsMan-Counter. Nur full. Renders wenn entweder
|
||||
History-Aggregat oder hand-gepflegte EinsMan-Zahl vorhanden. -->
|
||||
{#if live && !isCompact && (live.negPriceHoursYtd != null || typeof block.einsman_costs_ytd_eur_mio === "number")}
|
||||
<div class="mt-4 rounded-xs border border-stein-200 bg-white px-4 py-3 shadow-sm">
|
||||
<NegPriceCounter
|
||||
negHoursYtd={live.negPriceHoursYtd}
|
||||
einsmanEurMio={block.einsman_costs_ytd_eur_mio ?? null}
|
||||
einsmanAsOf={block.einsman_costs_as_of}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if live.negPriceHoursYtd != null || typeof block.einsman_costs_ytd_eur_mio === "number"}
|
||||
<div class="mt-2 rounded-xs border border-stein-200 bg-white px-4 py-2.5 shadow-sm">
|
||||
<NegPriceCounter
|
||||
negHoursYtd={live.negPriceHoursYtd}
|
||||
einsmanEurMio={block.einsman_costs_ytd_eur_mio ?? null}
|
||||
einsmanAsOf={block.einsman_costs_as_of}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Cross-Border-Bilanz: heute + YTD pro Land. Nur full, nur wenn
|
||||
History-Daten da sind. -->
|
||||
{#if live && !isCompact && (live.crossborderTodayGwh || live.netCrossborderGwhYtd != null)}
|
||||
<div class="mt-4 rounded-xs border border-stein-200 bg-white px-4 py-3 shadow-sm">
|
||||
<FlowBalanceTodayYtd
|
||||
today={live.crossborderTodayGwh}
|
||||
ytd={live.netCrossborderGwhYtd != null && live.crossborderByCountryGwhYtd
|
||||
? { total: live.netCrossborderGwhYtd, perCountry: live.crossborderByCountryGwhYtd }
|
||||
: null}
|
||||
/>
|
||||
</div>
|
||||
{#if live.crossborderTodayGwh || live.netCrossborderGwhYtd != null}
|
||||
<div class="mt-2 rounded-xs border border-stein-200 bg-white px-4 py-2.5 shadow-sm">
|
||||
<FlowBalanceTodayYtd
|
||||
today={live.crossborderTodayGwh}
|
||||
ytd={live.netCrossborderGwhYtd != null && live.crossborderByCountryGwhYtd
|
||||
? { total: live.netCrossborderGwhYtd, perCountry: live.crossborderByCountryGwhYtd }
|
||||
: null}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if block.installed_wind_onshore_gw || block.installed_solar_gw}
|
||||
<div class="mt-2 rounded-xs border border-stein-200 bg-white px-4 py-2.5 shadow-sm">
|
||||
<CapacityFactorBars
|
||||
windOnshoreLiveMw={live.windOnshoreMw}
|
||||
windOffshoreLiveMw={live.windOffshoreMw}
|
||||
solarLiveMw={live.solarMw}
|
||||
installedWindOnshoreGw={block.installed_wind_onshore_gw ?? 0}
|
||||
installedWindOffshoreGw={block.installed_wind_offshore_gw ?? 0}
|
||||
installedSolarGw={block.installed_solar_gw ?? 0}
|
||||
installedAsOf={block.installed_as_of}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</details>
|
||||
{/if}
|
||||
|
||||
<!-- Production breakdown: aufklappbar damit user die Summe nachvollziehen
|
||||
kann. Werte sind aufeinander abgestimmt (gleicher SMARD-15-min-Slot). -->
|
||||
{#if live}
|
||||
kann. Werte sind aufeinander abgestimmt (gleicher SMARD-15-min-Slot).
|
||||
Hero-Variante kapselt den minimalen Modus — Detail-Aufklapper würde
|
||||
das brechen. -->
|
||||
{#if live && !isHero}
|
||||
<details class="mt-3 text-xs">
|
||||
<summary
|
||||
class="cursor-pointer text-stein-600 hover:text-stein-900"
|
||||
>
|
||||
{t(T.strommix_check_values)}
|
||||
</summary>
|
||||
<!-- grid: label/value-Paare als zwei klar getrennte Spalten
|
||||
(auto / 1fr) statt grid-cols-2 — sonst alterniert das
|
||||
Mapping pro Zelle und wirkt unruhig. Auf sm doppelt: 2
|
||||
Paare nebeneinander. -->
|
||||
<div
|
||||
class="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 rounded-xs border border-stein-200 bg-stein-50 px-3 py-2 tabular-nums sm:grid-cols-3"
|
||||
class="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 rounded-xs border border-stein-200 bg-stein-50 px-3 py-2 tabular-nums sm:grid-cols-[auto_1fr_auto_1fr] sm:gap-x-4"
|
||||
>
|
||||
<div class="text-stein-500">
|
||||
{t(T.strommix_field_wind_onshore)}
|
||||
@@ -664,4 +870,30 @@
|
||||
.live-strommix [class*="border-stein"] {
|
||||
transition: background-color 350ms ease, border-color 350ms ease, color 350ms ease;
|
||||
}
|
||||
/* Skeleton placeholder pulse — matches widget rhythm so the
|
||||
loading state doesn't feel like a hang. */
|
||||
.strommix-skel {
|
||||
animation: strommix-skel-pulse 1400ms ease-in-out infinite;
|
||||
}
|
||||
@keyframes strommix-skel-pulse {
|
||||
0%, 100% { opacity: 0.6; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.strommix-skel { animation: none; }
|
||||
}
|
||||
/* Print: open all <details> children so the printed page shows
|
||||
the full picture, not just the summary chevron. The chevron
|
||||
itself is hidden — it's only meaningful interactively. */
|
||||
@media print {
|
||||
.live-strommix details > *:not(summary) {
|
||||
display: block !important;
|
||||
}
|
||||
.live-strommix details > summary {
|
||||
list-style: none;
|
||||
}
|
||||
.live-strommix details > summary > :global(svg) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<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}
|
||||
Reference in New Issue
Block a user