Files
windwiderstand/src/lib/components/blocks/strommix/ResidualLoadChart.svelte
T
Peter Meier 88514b065a feat(strommix): history-driven panels (residual load, dunkelflaute, neg-price, flow balance)
Aggregator route /api/strommix now reads three RustyCMS history-mode
collections in parallel — strommix_history_de, price_de_history,
crossborder_history_de — and projects derived metrics on top of the raw
upstream snapshots stored there:

- todayHourly: per-slot Last + Wind+Solar for the residual-load chart
- negPriceHoursYtd: count of YTD slots where DE-LU day-ahead < 0
- crossborderTodayGwh / netCrossborderGwhYtd / crossborderByCountryGwhYtd:
  GW × slot-hours integrated into a per-country GWh balance
- lastDunkelflaute / worstDunkelflauten: contiguous runs where
  (wind+solar)/load drops below 10 % for at least 1 h, top 5 by duration

All four are null-graceful — collections that the CMS hasn't backfilled
yet leave the corresponding panel hidden instead of breaking the widget.

Five new components in lib/components/blocks/strommix/:
- ResidualLoadChart.svelte — SVG line chart, no chart-lib dep
- CapacityFactorBars.svelte — live vs installed Wind/Wind-Off/Solar
- DunkelflauteCounter.svelte — last phase card + top-5 list
- NegPriceCounter.svelte — YTD hours + manual EinsMan compensation
- FlowBalanceTodayYtd.svelte — today + YTD saldo with per-country breakdown

Block schema additions (block-types.ts + strommix-default.json5 in
cms_content): installed_*_gw / installed_as_of for the capacity-factor
bars (BNetzA Marktstammdatenregister snapshot, manually maintained), and
einsman_costs_ytd_eur_mio / einsman_costs_as_of for the NegPrice panel
(BNetzA quarterly report figures, no API).
2026-05-07 13:06:30 +02:00

200 lines
6.3 KiB
Svelte

<script lang="ts">
import type { ResidualLoadPoint } from "$lib/strommix";
/** Today's per-slot Last + Wetter-Erneuerbare. */
let { points }: { points: ResidualLoadPoint[] } = $props();
type Layout = {
width: number;
height: number;
padX: number;
padY: number;
innerW: number;
innerH: number;
};
const layout: Layout = $derived.by(() => {
const width = 600;
const height = 180;
const padX = 36;
const padY = 16;
return {
width,
height,
padX,
padY,
innerW: width - 2 * padX,
innerH: height - 2 * padY,
};
});
type Range = { minX: number; maxX: number; maxY: number };
const range: Range = $derived.by(() => {
if (points.length === 0) {
return { minX: 0, maxX: 1, maxY: 1 };
}
const minX = points[0].unix;
const maxX = points[points.length - 1].unix;
const maxY = Math.max(
...points.map((p) => Math.max(p.loadMw, p.weatherRenewableMw)),
1,
);
return { minX, maxX, maxY };
});
function xOf(unix: number): number {
const span = range.maxX - range.minX || 1;
return layout.padX + ((unix - range.minX) / span) * layout.innerW;
}
function yOf(mw: number): number {
const span = range.maxY || 1;
return layout.padY + (1 - mw / span) * layout.innerH;
}
const loadPath = $derived.by(() => {
if (points.length === 0) return "";
return points
.map(
(p, i) =>
`${i === 0 ? "M" : "L"} ${xOf(p.unix)} ${yOf(p.loadMw)}`,
)
.join(" ");
});
const renewPath = $derived.by(() => {
if (points.length === 0) return "";
return points
.map(
(p, i) =>
`${i === 0 ? "M" : "L"} ${xOf(p.unix)} ${yOf(p.weatherRenewableMw)}`,
)
.join(" ");
});
const gapPath = $derived.by(() => {
if (points.length === 0) return "";
const top = points
.map(
(p, i) =>
`${i === 0 ? "M" : "L"} ${xOf(p.unix)} ${yOf(p.loadMw)}`,
)
.join(" ");
const bottom = points
.slice()
.reverse()
.map((p) => `L ${xOf(p.unix)} ${yOf(p.weatherRenewableMw)}`)
.join(" ");
return `${top} ${bottom} Z`;
});
const ticks = $derived.by(() => {
if (points.length === 0) return [];
const result: { unix: number; label: string }[] = [];
const start = new Date(range.minX * 1000);
start.setMinutes(0, 0, 0);
const end = new Date(range.maxX * 1000);
for (let t = start.getTime(); t <= end.getTime(); t += 4 * 3_600_000) {
const d = new Date(t);
result.push({
unix: Math.floor(t / 1000),
label: d.getHours().toString().padStart(2, "0") + ":00",
});
}
return result;
});
const yTicks = $derived.by(() => {
const step = range.maxY > 60_000 ? 20_000 : 10_000;
const out: { mw: number; label: string }[] = [];
for (let mw = 0; mw <= range.maxY; mw += step) {
out.push({ mw, label: `${mw / 1000} GW` });
}
return out;
});
</script>
{#if points.length > 0}
<figure class="m-0">
<svg
class="block h-auto w-full font-sans"
viewBox="0 0 {layout.width} {layout.height}"
role="img"
aria-label="Residuallast heute: Last minus Wind und Solar im Tagesverlauf"
>
{#each yTicks as t (t.mw)}
<line
stroke="var(--color-stein-200)"
stroke-width="1"
x1={layout.padX}
x2={layout.width - layout.padX}
y1={yOf(t.mw)}
y2={yOf(t.mw)}
/>
<text
fill="var(--color-stein-500)"
font-size="10"
text-anchor="end"
x={layout.padX - 4}
y={yOf(t.mw) + 3}
>
{t.label}
</text>
{/each}
{#if gapPath}
<path d={gapPath} fill="var(--color-fire-200)" fill-opacity="0.55" stroke="none" />
{/if}
{#if renewPath}
<path
d={renewPath}
fill="none"
stroke="var(--color-wald-500)"
stroke-width="1.75"
/>
{/if}
{#if loadPath}
<path
d={loadPath}
fill="none"
stroke="var(--color-stein-800)"
stroke-width="1.75"
/>
{/if}
{#each ticks as t (t.unix)}
<line
stroke="var(--color-stein-200)"
stroke-width="1"
stroke-dasharray="2 3"
x1={xOf(t.unix)}
x2={xOf(t.unix)}
y1={layout.padY}
y2={layout.height - layout.padY}
/>
<text
fill="var(--color-stein-500)"
font-size="10"
text-anchor="middle"
x={xOf(t.unix)}
y={layout.height - 2}
>
{t.label}
</text>
{/each}
</svg>
<figcaption class="mt-2 flex flex-wrap gap-3 text-xs text-stein-600">
<span class="inline-flex items-center gap-1.5">
<span class="inline-block size-3 rounded-sm bg-stein-800"></span>
Last
</span>
<span class="inline-flex items-center gap-1.5">
<span class="inline-block size-3 rounded-sm bg-wald-500"></span>
Wind + Solar
</span>
<span class="inline-flex items-center gap-1.5">
<span class="inline-block size-3 rounded-sm bg-fire-200"></span>
Residuallast (Lücke)
</span>
</figcaption>
</figure>
{/if}