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 { getBlockLayoutClasses } from "$lib/block-layout";
|
||||||
import type { LiveStrommixBlockData } from "$lib/block-types";
|
import type { LiveStrommixBlockData } from "$lib/block-types";
|
||||||
import type { StrommixResponse } from "$lib/strommix";
|
import type { StrommixResponse } from "$lib/strommix";
|
||||||
|
import { STROMMIX_DEFAULT_THRESHOLDS } from "$lib/strommix";
|
||||||
import { useTranslate, T } from "$lib/translations";
|
import { useTranslate, T } from "$lib/translations";
|
||||||
import Icon from "@iconify/svelte";
|
import Icon from "@iconify/svelte";
|
||||||
import "$lib/iconify-offline";
|
import "$lib/iconify-offline";
|
||||||
@@ -12,20 +13,30 @@
|
|||||||
import DunkelflauteCounter from "./strommix/DunkelflauteCounter.svelte";
|
import DunkelflauteCounter from "./strommix/DunkelflauteCounter.svelte";
|
||||||
import NegPriceCounter from "./strommix/NegPriceCounter.svelte";
|
import NegPriceCounter from "./strommix/NegPriceCounter.svelte";
|
||||||
import FlowBalanceTodayYtd from "./strommix/FlowBalanceTodayYtd.svelte";
|
import FlowBalanceTodayYtd from "./strommix/FlowBalanceTodayYtd.svelte";
|
||||||
|
import RenewableSparkline from "./strommix/RenewableSparkline.svelte";
|
||||||
|
|
||||||
let { block }: { block: LiveStrommixBlockData } = $props();
|
let { block }: { block: LiveStrommixBlockData } = $props();
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
|
|
||||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||||
const isCompact = $derived(block.variant === "compact");
|
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
|
// Live data + lifecycle
|
||||||
let live = $state<StrommixResponse | null>(null);
|
let live = $state<StrommixResponse | null>(null);
|
||||||
let loadError = $state<string | null>(null);
|
let loadError = $state<string | null>(null);
|
||||||
|
let isLoading = $state(false);
|
||||||
let lastFetchAt = $state<number | null>(null);
|
let lastFetchAt = $state<number | null>(null);
|
||||||
let pollTimer: ReturnType<typeof setInterval> | 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 {
|
try {
|
||||||
const res = await fetch("/api/strommix", { cache: "no-store" });
|
const res = await fetch("/api/strommix", { cache: "no-store" });
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
@@ -34,35 +45,115 @@
|
|||||||
loadError = null;
|
loadError = null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loadError = e instanceof Error ? e.message : String(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(() => {
|
onMount(() => {
|
||||||
loadLive();
|
loadLive();
|
||||||
pollTimer = setInterval(loadLive, 5 * 60 * 1000); // re-poll every 5 min
|
startPolling();
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
document.addEventListener("visibilitychange", handleVisibility);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
onDestroy(() => {
|
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
|
// Derived numbers
|
||||||
const renewablePct = $derived.by(() => {
|
const renewablePct = $derived.by(() => {
|
||||||
if (!live || live.loadMw <= 0) return null;
|
if (!live || effectiveLoadMw <= 0) return null;
|
||||||
return (live.weatherRenewableMw / live.loadMw) * 100;
|
return (live.weatherRenewableMw / effectiveLoadMw) * 100;
|
||||||
});
|
});
|
||||||
|
|
||||||
type Bucket = "dunkelflaute" | "niedrig" | "mittel" | "hoch";
|
type Bucket = "dunkelflaute" | "niedrig" | "mittel" | "hoch";
|
||||||
const bucket = $derived.by((): Bucket | null => {
|
const bucket = $derived.by((): Bucket | null => {
|
||||||
if (renewablePct == null) return null;
|
if (renewablePct == null) return null;
|
||||||
const t1 = block.threshold_dunkelflaute ?? 15;
|
const t1 = block.threshold_dunkelflaute ?? STROMMIX_DEFAULT_THRESHOLDS.dunkelflaute;
|
||||||
const t2 = block.threshold_niedrig ?? 35;
|
const t2 = block.threshold_niedrig ?? STROMMIX_DEFAULT_THRESHOLDS.niedrig;
|
||||||
const t3 = block.threshold_mittel ?? 60;
|
const t3 = block.threshold_mittel ?? STROMMIX_DEFAULT_THRESHOLDS.mittel;
|
||||||
if (renewablePct < t1) return "dunkelflaute";
|
if (renewablePct < t1) return "dunkelflaute";
|
||||||
if (renewablePct < t2) return "niedrig";
|
if (renewablePct < t2) return "niedrig";
|
||||||
if (renewablePct < t3) return "mittel";
|
if (renewablePct < t3) return "mittel";
|
||||||
return "hoch";
|
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(() => {
|
const statusLabel = $derived.by(() => {
|
||||||
if (!bucket) return "";
|
if (!bucket) return "";
|
||||||
const labels: Record<Bucket, string | undefined> = {
|
const labels: Record<Bucket, string | undefined> = {
|
||||||
@@ -145,6 +236,22 @@
|
|||||||
minute: "2-digit",
|
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
|
/** Cross-border data is considered stale (and worth flagging) when its
|
||||||
* timestamp is more than 30 min behind the production-snapshot. cbpf
|
* timestamp is more than 30 min behind the production-snapshot. cbpf
|
||||||
@@ -230,10 +337,29 @@
|
|||||||
if (!live) return null;
|
if (!live) return null;
|
||||||
return live.netFlowGw >= 0 ? "Export" : "Import";
|
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>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="live-strommix {layoutClasses}"
|
class="live-strommix {layoutClasses}"
|
||||||
|
data-block="LiveStrommix"
|
||||||
data-block-type="live_strommix"
|
data-block-type="live_strommix"
|
||||||
data-block-slug={block._slug}
|
data-block-slug={block._slug}
|
||||||
>
|
>
|
||||||
@@ -303,7 +429,7 @@
|
|||||||
Source-Badge zeigt klar woher die zahlen kommen ohne dass
|
Source-Badge zeigt klar woher die zahlen kommen ohne dass
|
||||||
man die Detail-Aufklappkachel öffnen muss. -->
|
man die Detail-Aufklappkachel öffnen muss. -->
|
||||||
<div
|
<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">
|
<span class="inline-flex items-center gap-1.5 uppercase tracking-wide">
|
||||||
<Icon
|
<Icon
|
||||||
@@ -315,6 +441,12 @@
|
|||||||
</span>
|
</span>
|
||||||
{#if live}
|
{#if live}
|
||||||
<span class="inline-flex items-center gap-2 normal-case font-normal">
|
<span class="inline-flex items-center gap-2 normal-case font-normal">
|
||||||
|
{#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}">
|
<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" />
|
<Icon icon="mdi:database-outline" class="size-3" aria-hidden="true" />
|
||||||
{#if live.dataSource === "smard"}
|
{#if live.dataSource === "smard"}
|
||||||
@@ -325,26 +457,47 @@
|
|||||||
Energy-Charts
|
Energy-Charts
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
|
{/if}
|
||||||
{#if live.measuredAtUnix}
|
{#if live.measuredAtUnix}
|
||||||
<span>
|
<time
|
||||||
|
datetime={fmtDateTimeAttr(live.measuredAtUnix)}
|
||||||
|
title={fmtDateTimeFull(live.measuredAtUnix)}
|
||||||
|
>
|
||||||
{t(T.strommix_stand, { time: fmtTime(live.measuredAtUnix) })}
|
{t(T.strommix_stand, { time: fmtTime(live.measuredAtUnix) })}
|
||||||
</span>
|
</time>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main: percentage + status -->
|
<!-- Main: percentage + status -->
|
||||||
{#if loadError}
|
{#if loadError && !live}
|
||||||
<div class="p-6 text-sm text-fire-700">
|
<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 })}
|
{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>
|
</div>
|
||||||
{:else if !live}
|
{:else if !live}
|
||||||
<div class="p-6 text-center text-stein-500 text-sm">
|
<!-- Skeleton: matches the final layout's footprint so the
|
||||||
{t(T.strommix_loading)}
|
% 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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="p-6 sm:p-8 text-center">
|
<div class="p-4 sm:p-6 text-center">
|
||||||
{#if renewablePct != null}
|
{#if renewablePct != null}
|
||||||
<!-- Key change keys the % display so a fresh fetch
|
<!-- Key change keys the % display so a fresh fetch
|
||||||
triggers a brief CSS fade-in transition. The
|
triggers a brief CSS fade-in transition. The
|
||||||
@@ -352,26 +505,47 @@
|
|||||||
supports it. -->
|
supports it. -->
|
||||||
{#key live.measuredAtUnix}
|
{#key live.measuredAtUnix}
|
||||||
<div
|
<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)}%
|
{Math.round(renewablePct)}%
|
||||||
</div>
|
</div>
|
||||||
{/key}
|
{/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)}
|
{t(T.strommix_wind_solar_share)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="mt-3 text-lg font-semibold {accentClasses.label}"
|
class="mt-2 text-base font-semibold {accentClasses.label}"
|
||||||
>
|
>
|
||||||
→ {statusLabel}
|
→ {statusLabel}
|
||||||
</div>
|
</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
|
<div
|
||||||
class="prose prose-xs mt-2 max-w-md mx-auto text-xs leading-snug text-stein-700 [&_p]:my-1"
|
class="prose prose-xs mt-2 max-w-md mx-auto text-xs leading-snug text-stein-700 [&_p]:my-1"
|
||||||
>
|
>
|
||||||
{@html argumentHtml}
|
{@html argumentHtml}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if usingLoadFallback}
|
||||||
|
<div class="mt-2 text-[11px] text-stein-500 italic">
|
||||||
|
{t(T.strommix_load_missing)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Production data arrived but load is missing → no percentage,
|
<!-- Production data arrived but load is missing → no percentage,
|
||||||
but we still show what we have so the widget isn't blank. -->
|
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.
|
<!-- Context cards: konventionell + saldo, je eigenes box-block.
|
||||||
Stack mobile, side-by-side ab sm. Kein farbakzent damit haupt-
|
Stack mobile, side-by-side ab sm. Kein farbakzent damit haupt-
|
||||||
karte mit %-zahl optisch dominiert. -->
|
karte mit %-zahl optisch dominiert. Hero-Variante zeigt nur
|
||||||
{#if live}
|
Hauptzahl + Sparkline; sub-cards würden den minimalen Charakter
|
||||||
<div class="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
brechen. -->
|
||||||
<div class="rounded-xs border border-stein-200 bg-white px-4 py-3 text-sm shadow-sm">
|
{#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">
|
<div class="text-xs text-stein-600 uppercase tracking-wide">
|
||||||
{t(T.strommix_conventional_now)}
|
{t(T.strommix_conventional_now)}
|
||||||
</div>
|
</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)}
|
{fmtMw(live.conventionalMw)}
|
||||||
</div>
|
</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)}
|
{t(T.strommix_conventional_formula)}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="text-xs text-stein-600 uppercase tracking-wide">
|
||||||
{importDirection === "Import"
|
{importDirection === "Import"
|
||||||
? t(T.strommix_import)
|
? t(T.strommix_import)
|
||||||
@@ -408,16 +584,21 @@
|
|||||||
? t(T.strommix_export)
|
? t(T.strommix_export)
|
||||||
: t(T.strommix_saldo)}
|
: t(T.strommix_saldo)}
|
||||||
</div>
|
</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 === "Import"}↓{/if}
|
||||||
{#if importDirection === "Export"}↑{/if}
|
{#if importDirection === "Export"}↑{/if}
|
||||||
{fmtGw(live.netFlowGw)}
|
{fmtGw(live.netFlowGw)}
|
||||||
</div>
|
</div>
|
||||||
{#if live.crossborderMeasuredAtUnix}
|
{#if live.crossborderMeasuredAtUnix}
|
||||||
<div class="text-[11px] text-stein-500 mt-1">
|
<div class="text-[11px] text-stein-500 mt-0.5">
|
||||||
|
<time
|
||||||
|
datetime={fmtDateTimeAttr(live.crossborderMeasuredAtUnix)}
|
||||||
|
title={fmtDateTimeFull(live.crossborderMeasuredAtUnix)}
|
||||||
|
>
|
||||||
{t(T.strommix_stand, {
|
{t(T.strommix_stand, {
|
||||||
time: fmtTime(live.crossborderMeasuredAtUnix),
|
time: fmtTime(live.crossborderMeasuredAtUnix),
|
||||||
})}
|
})}
|
||||||
|
</time>
|
||||||
{#if cbStale}<span class="text-fire-700">
|
{#if cbStale}<span class="text-fire-700">
|
||||||
· {t(T.strommix_delayed)}</span
|
· {t(T.strommix_delayed)}</span
|
||||||
>{/if}
|
>{/if}
|
||||||
@@ -426,10 +607,50 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Price card — separater block, full-width damit der vergleich
|
{#if (live.priceDeEurMwh ?? 0) < 0}
|
||||||
DE/FR + slot-info nicht in den 2-spaltigen flow oben gepresst
|
<!-- Negativpreis-warnung als eigener prominenter banner statt
|
||||||
werden muss. -->
|
inline-badge. Greift wenn Day-Ahead-Preis negativ — sehr
|
||||||
<div class="mt-3 rounded-xs border border-stein-200 bg-white px-4 py-3 text-sm shadow-sm">
|
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 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">
|
||||||
|
{@html disclaimerHtml}
|
||||||
|
</div>
|
||||||
|
{/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">
|
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
|
||||||
<span class="font-semibold">
|
<span class="font-semibold">
|
||||||
{t(T.strommix_price_de, { price: fmtPrice(live.priceDeEurMwh) })}
|
{t(T.strommix_price_de, { price: fmtPrice(live.priceDeEurMwh) })}
|
||||||
@@ -441,42 +662,73 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if live.priceMeasuredAtUnix}
|
{#if live.priceMeasuredAtUnix}
|
||||||
<span class="text-[11px] text-stein-500">
|
<span class="text-[11px] text-stein-500">
|
||||||
· {t(T.strommix_slot, { time: fmtTime(live.priceMeasuredAtUnix) })}
|
· <time
|
||||||
|
datetime={fmtDateTimeAttr(live.priceMeasuredAtUnix)}
|
||||||
|
title={fmtDateTimeFull(live.priceMeasuredAtUnix)}
|
||||||
|
>{t(T.strommix_slot, { time: fmtTime(live.priceMeasuredAtUnix) })}</time>
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Negativpreis-warnung als eigener prominenter banner statt
|
<!-- History-Panels gebündelt im Akkordeon: Residuallast,
|
||||||
inline-badge. Greift wenn Day-Ahead-Preis negativ — sehr
|
Dunkelflaute, Negativpreise, Cross-Border, Capacity-Factor.
|
||||||
aussagekräftiger marktzustand der nicht versteckt werden
|
Default zu — Hauptkennzahlen oben sollen dominieren. -->
|
||||||
sollte. -->
|
{#if live && hasHistoryPanels}
|
||||||
{#if (live.priceDeEurMwh ?? 0) < 0}
|
<details class="strommix-history mt-3 group">
|
||||||
<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">
|
<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 icon="mdi:chevron-right" class="size-4 transition-transform group-open:rotate-90" aria-hidden="true" />
|
||||||
icon="mdi:alert-circle"
|
{t(T.strommix_history_summary)}
|
||||||
class="size-6 shrink-0 text-fire-700"
|
</summary>
|
||||||
aria-hidden="true"
|
|
||||||
|
{#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}
|
||||||
|
|
||||||
|
{#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 class="min-w-0">
|
|
||||||
<div class="text-base font-bold text-fire-800">
|
|
||||||
{t(T.strommix_negative_price)}: {fmtPrice(live.priceDeEurMwh)}
|
|
||||||
</div>
|
</div>
|
||||||
{#if showDisclaimer}
|
|
||||||
<div class="mt-1 text-xs leading-snug text-fire-900/80 prose prose-xs max-w-none [&_p]:my-0">
|
|
||||||
{@html disclaimerHtml}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Capacity-factor bars: live vs. installiert. Nur full-Variante; in
|
{#if live.negPriceHoursYtd != null || typeof block.einsman_costs_ytd_eur_mio === "number"}
|
||||||
compact wäre's optisch zu dicht. Renders nur, wenn Stammdaten
|
<div class="mt-2 rounded-xs border border-stein-200 bg-white px-4 py-2.5 shadow-sm">
|
||||||
gesetzt sind. -->
|
<NegPriceCounter
|
||||||
{#if live && !isCompact && (block.installed_wind_onshore_gw || block.installed_solar_gw)}
|
negHoursYtd={live.negPriceHoursYtd}
|
||||||
<div class="mt-4 rounded-xs border border-stein-200 bg-white px-4 py-3 shadow-sm">
|
einsmanEurMio={block.einsman_costs_ytd_eur_mio ?? null}
|
||||||
|
einsmanAsOf={block.einsman_costs_as_of}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#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
|
<CapacityFactorBars
|
||||||
windOnshoreLiveMw={live.windOnshoreMw}
|
windOnshoreLiveMw={live.windOnshoreMw}
|
||||||
windOffshoreLiveMw={live.windOffshoreMw}
|
windOffshoreLiveMw={live.windOffshoreMw}
|
||||||
@@ -488,72 +740,26 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
</details>
|
||||||
<!-- 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}
|
|
||||||
|
|
||||||
<!-- 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}
|
|
||||||
|
|
||||||
<!-- 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}
|
|
||||||
|
|
||||||
<!-- 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}
|
{/if}
|
||||||
|
|
||||||
<!-- Production breakdown: aufklappbar damit user die Summe nachvollziehen
|
<!-- Production breakdown: aufklappbar damit user die Summe nachvollziehen
|
||||||
kann. Werte sind aufeinander abgestimmt (gleicher SMARD-15-min-Slot). -->
|
kann. Werte sind aufeinander abgestimmt (gleicher SMARD-15-min-Slot).
|
||||||
{#if live}
|
Hero-Variante kapselt den minimalen Modus — Detail-Aufklapper würde
|
||||||
|
das brechen. -->
|
||||||
|
{#if live && !isHero}
|
||||||
<details class="mt-3 text-xs">
|
<details class="mt-3 text-xs">
|
||||||
<summary
|
<summary
|
||||||
class="cursor-pointer text-stein-600 hover:text-stein-900"
|
class="cursor-pointer text-stein-600 hover:text-stein-900"
|
||||||
>
|
>
|
||||||
{t(T.strommix_check_values)}
|
{t(T.strommix_check_values)}
|
||||||
</summary>
|
</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
|
<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">
|
<div class="text-stein-500">
|
||||||
{t(T.strommix_field_wind_onshore)}
|
{t(T.strommix_field_wind_onshore)}
|
||||||
@@ -664,4 +870,30 @@
|
|||||||
.live-strommix [class*="border-stein"] {
|
.live-strommix [class*="border-stein"] {
|
||||||
transition: background-color 350ms ease, border-color 350ms ease, color 350ms ease;
|
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>
|
</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}
|
||||||
@@ -6,6 +6,16 @@
|
|||||||
* consumes it.
|
* consumes it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** Default percentage thresholds (Wind+Solar / Last × 100) for bucket
|
||||||
|
* classification. Single source of truth — both the CMS schema
|
||||||
|
* (`live_strommix.json5`) and the block (`StrommixBlock.svelte`) read
|
||||||
|
* these. Editor can override per-instance via `threshold_*` fields. */
|
||||||
|
export const STROMMIX_DEFAULT_THRESHOLDS = {
|
||||||
|
dunkelflaute: 15,
|
||||||
|
niedrig: 35,
|
||||||
|
mittel: 60,
|
||||||
|
} as const;
|
||||||
|
|
||||||
export type StrommixResponse = {
|
export type StrommixResponse = {
|
||||||
// Production-snapshot timestamp (Unix sec) — used as the "anchor" for
|
// Production-snapshot timestamp (Unix sec) — used as the "anchor" for
|
||||||
// the rest of the values. Cross-border + prices may have their own,
|
// the rest of the values. Cross-border + prices may have their own,
|
||||||
|
|||||||
@@ -114,6 +114,28 @@ const TRANSLATION_KEYS = [
|
|||||||
"calendar_time_suffix",
|
"calendar_time_suffix",
|
||||||
"calendar_select_day",
|
"calendar_select_day",
|
||||||
"calendar_next_events",
|
"calendar_next_events",
|
||||||
|
"calendar_all_upcoming",
|
||||||
|
"calendar_today",
|
||||||
|
"calendar_tomorrow",
|
||||||
|
"calendar_in_days", // {{n}}
|
||||||
|
"calendar_no_future_events",
|
||||||
|
"calendar_clear_filter",
|
||||||
|
"calendar_event_count", // {{n}}
|
||||||
|
"calendar_add_to_google",
|
||||||
|
"calendar_download_ics",
|
||||||
|
"calendar_open_maps",
|
||||||
|
"calendar_show_past",
|
||||||
|
"calendar_hide_past",
|
||||||
|
"calendar_past_events",
|
||||||
|
"calendar_filter_by_tag",
|
||||||
|
"calendar_all_tags",
|
||||||
|
"calendar_multi_day_range", // {{from}}, {{to}}
|
||||||
|
"calendar_starts_in", // {{n}}
|
||||||
|
"calendar_starts_in_minutes", // {{n}}
|
||||||
|
"calendar_running_now",
|
||||||
|
"calendar_jump_to_month",
|
||||||
|
"calendar_today_marker",
|
||||||
|
"calendar_untitled",
|
||||||
"calendar_show_more",
|
"calendar_show_more",
|
||||||
"calendar_show_less",
|
"calendar_show_less",
|
||||||
"post_overview_all",
|
"post_overview_all",
|
||||||
@@ -207,6 +229,17 @@ const TRANSLATION_KEYS = [
|
|||||||
// Compact variant for homepage / sidebar.
|
// Compact variant for homepage / sidebar.
|
||||||
"strommix_compact_label", // {{pct}}, {{status}}
|
"strommix_compact_label", // {{pct}}, {{status}}
|
||||||
"strommix_compact_more",
|
"strommix_compact_more",
|
||||||
|
// Akkordeon + history-panel headings.
|
||||||
|
"strommix_history_summary",
|
||||||
|
"strommix_residual_today_title",
|
||||||
|
"strommix_residual_today_desc",
|
||||||
|
// Loading / error UX.
|
||||||
|
"strommix_retry",
|
||||||
|
"strommix_trend_up", // {{delta}}
|
||||||
|
"strommix_trend_down", // {{delta}}
|
||||||
|
"strommix_trend_flat",
|
||||||
|
"strommix_sparkline_aria", // {{from}}, {{to}}
|
||||||
|
"strommix_stale_badge",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type TranslationKey = (typeof TRANSLATION_KEYS)[number];
|
export type TranslationKey = (typeof TRANSLATION_KEYS)[number];
|
||||||
|
|||||||
Reference in New Issue
Block a user