|
|
|
@@ -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>
|
|
|
|
|