stats B11: EP-Online energy labels (monthly total file → homes per label for NL, Gouda, Gouda PC4s; aggregates only), stats.ep_label_agg, monthly timer with key from ~/.config/stats/ep-online.env, charts on /stats/energy and /stats/gouda
Some checks failed
Deploy production / deploy (push) Has been cancelled
Some checks failed
Deploy production / deploy (push) Has been cancelled
This commit is contained in:
@@ -354,3 +354,18 @@ export const statsUsasAgencyFy = statsSchema.table(
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.toptierCode, t.fiscalYear] })]
|
||||
);
|
||||
|
||||
// ── B11 EP-Online (registered energy labels, homes; aggregates only, no addresses) ─────────
|
||||
|
||||
export const statsEpLabelAgg = statsSchema.table(
|
||||
'ep_label_agg',
|
||||
{
|
||||
/** Publication date of the monthly total file. */
|
||||
snapshot: date('snapshot', { mode: 'string' }).notNull(),
|
||||
/** NL, GOUDA (postcodes 2800–2809) or a Gouda PC4. */
|
||||
area: text('area').notNull(),
|
||||
label: text('label').notNull(),
|
||||
homes: integer('homes').notNull()
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.snapshot, t.area, t.label] })]
|
||||
);
|
||||
|
||||
88
src/lib/stats/energylabels.ts
Normal file
88
src/lib/stats/energylabels.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
// EP-Online energy labels on /stats/energy and /stats/gouda (collector: scripts/stats/eponline.ts).
|
||||
import type { ChartTheme, StatPoint } from './chart';
|
||||
import type { EChartsCoreOption } from 'echarts/core';
|
||||
|
||||
/** Best to worst (NTA 8800 scale). */
|
||||
export const EP_LABELS = ['A++++', 'A+++', 'A++', 'A+', 'A', 'B', 'C', 'D', 'E', 'F', 'G'];
|
||||
/** Dark green → red, like the label sticker. */
|
||||
export const EP_COLORS = [
|
||||
'#00632f',
|
||||
'#00733a',
|
||||
'#008a44',
|
||||
'#00a24f',
|
||||
'#22b14c',
|
||||
'#8cc63e',
|
||||
'#fff200',
|
||||
'#fdb913',
|
||||
'#f37021',
|
||||
'#ed1c24',
|
||||
'#b5121b'
|
||||
];
|
||||
export const GOUDA_PC4 = Array.from({ length: 10 }, (_, i) => String(2800 + i));
|
||||
|
||||
export const epShareId = (label: string, area: string) => `eponline:share:${label}:${area}`;
|
||||
export const epHomesId = (area: string) => `eponline:homes:${area}`;
|
||||
export const epSeriesIds = (areas: string[]) =>
|
||||
areas.flatMap((a) => [epHomesId(a), ...EP_LABELS.map((l) => epShareId(l, a))]);
|
||||
|
||||
/** Share of homes with label A or better per snapshot. */
|
||||
export function aOrBetter(get: (id: string) => StatPoint[], area: string): StatPoint[] {
|
||||
const sum = new Map<string, number>();
|
||||
for (const l of EP_LABELS.slice(0, 5))
|
||||
for (const [p, v] of get(epShareId(l, area))) sum.set(p, (sum.get(p) ?? 0) + v);
|
||||
return [...sum].sort((a, b) => a[0].localeCompare(b[0]));
|
||||
}
|
||||
|
||||
/** Horizontal 100% stacked bars: one row per area, one segment per label (latest snapshot). */
|
||||
export function labelStackOption(
|
||||
theme: ChartTheme,
|
||||
rows: { name: string; shares: number[] }[]
|
||||
): EChartsCoreOption {
|
||||
const pct = new Intl.NumberFormat('en-GB', { maximumFractionDigits: 1 });
|
||||
return {
|
||||
animation: false,
|
||||
grid: { left: 8, right: 16, top: 36, bottom: 8, containLabel: true },
|
||||
legend: {
|
||||
top: 0,
|
||||
itemWidth: 12,
|
||||
itemHeight: 10,
|
||||
textStyle: { color: theme.muted, fontSize: 11 }
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
backgroundColor: theme.surface,
|
||||
borderColor: theme.border,
|
||||
textStyle: { color: theme.text, fontSize: 12 },
|
||||
valueFormatter: (v: number) => `${pct.format(v)}%`
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
max: 100,
|
||||
axisLabel: { color: theme.muted, fontSize: 11, formatter: '{value}%' },
|
||||
splitLine: { lineStyle: { color: theme.border, opacity: 0.6 } }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
inverse: true,
|
||||
data: rows.map((r) => r.name),
|
||||
axisLabel: { color: theme.text, fontSize: 11 },
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false }
|
||||
},
|
||||
series: EP_LABELS.map((label, i) => ({
|
||||
name: label,
|
||||
type: 'bar',
|
||||
stack: 'labels',
|
||||
barMaxWidth: 22,
|
||||
itemStyle: { color: EP_COLORS[i] },
|
||||
data: rows.map((r) => r.shares[i] ?? 0),
|
||||
label: {
|
||||
show: true,
|
||||
color: i >= 6 && i <= 8 ? '#111' : '#fff',
|
||||
fontSize: 10,
|
||||
formatter: (p: { value: number }) => (p.value >= 6 ? `${pct.format(p.value)}` : '')
|
||||
}
|
||||
}))
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cached, loadSeries } from '#lib/server/stats.js';
|
||||
import { epSeriesIds } from '#lib/stats/energylabels.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
const EU = ['NL', 'DE', 'FR', 'BE', 'IT', 'ES', 'PL', 'SE', 'DK', 'AT', 'EU27_2020'];
|
||||
@@ -54,7 +55,8 @@ export const load: PageServerLoad = async () => ({
|
||||
'cbs:cars:petrol',
|
||||
'cbs:cars:diesel',
|
||||
'eurostat:road_eqr_carpda:NR.ELC.NL',
|
||||
'eurostat:road_eqr_carpda:NR.TOTAL.NL'
|
||||
'eurostat:road_eqr_carpda:NR.TOTAL.NL',
|
||||
...epSeriesIds(['NL', 'GOUDA'])
|
||||
])
|
||||
)
|
||||
});
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
import { barRankOption, latest, lineOption, type StatPoint } from '#lib/stats/chart.js';
|
||||
import { compact, num, period } from '#lib/stats/format.js';
|
||||
import { COUNTRY } from '#lib/stats/regions.js';
|
||||
import {
|
||||
aOrBetter,
|
||||
epHomesId,
|
||||
epShareId,
|
||||
EP_LABELS,
|
||||
labelStackOption
|
||||
} from '#lib/stats/energylabels.js';
|
||||
import Zap from 'lucide-svelte/icons/zap';
|
||||
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
||||
|
||||
@@ -13,6 +20,11 @@
|
||||
const a = $derived(data.attributions);
|
||||
const pts = (id: string): StatPoint[] => s[id]?.points ?? [];
|
||||
const last = (id: string) => latest(s[id]?.points);
|
||||
// EP-Online: share of homes per energy label, latest monthly snapshot.
|
||||
const epShares = (area: string) => EP_LABELS.map((l) => last(epShareId(l, area))?.[1] ?? 0);
|
||||
const epHomes = $derived(last(epHomesId('NL')));
|
||||
const epAorBetter = $derived(aOrBetter(pts, 'NL'));
|
||||
const epGoudaAorBetter = $derived(aOrBetter(pts, 'GOUDA'));
|
||||
const EU = ['NL', 'DE', 'FR', 'BE', 'IT', 'ES', 'PL', 'SE', 'DK', 'AT', 'EU27_2020'];
|
||||
|
||||
/** a ÷ b × k on shared dates. */
|
||||
@@ -592,6 +604,43 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if epHomes}
|
||||
<h2 class="stats-section-title">Energy labels of homes</h2>
|
||||
<div class="stats-grid">
|
||||
<StatChart
|
||||
title="Homes by energy label"
|
||||
subtitle={`% of homes with a registered label · ${num(epHomes[1], 0)} homes in NL · ${period(epHomes[0], 'M')}`}
|
||||
sources={['eponline']}
|
||||
attributions={a}
|
||||
wide
|
||||
height="220px"
|
||||
note="Latest registered label per home (BAG dwelling) in EP-Online's monthly total file; labels on building-permit designs are left out. Gouda = postcodes 2800–2809."
|
||||
option={(t) =>
|
||||
labelStackOption(t, [
|
||||
{ name: 'Netherlands', shares: epShares('NL') },
|
||||
{ name: 'Gouda', shares: epShares('GOUDA') }
|
||||
])}
|
||||
/>
|
||||
{#if epAorBetter.length > 1}
|
||||
<StatChart
|
||||
title="Homes labelled A or better"
|
||||
subtitle="% of homes with a registered label, monthly"
|
||||
sources={['eponline']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{ name: 'Netherlands', points: epAorBetter, color: '#0f172a', dashed: true },
|
||||
{ name: 'Gouda', points: epGoudaAorBetter, color: '#f97316' }
|
||||
],
|
||||
{ unit: '%', decimals: 1 }
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.live-link {
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cached, loadCbsCross, loadSeries } from '#lib/server/stats.js';
|
||||
import { epSeriesIds, GOUDA_PC4 } from '#lib/stats/energylabels.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
// Gouda (GM0513) and its neighbours; NL01 is the national total.
|
||||
@@ -33,7 +34,8 @@ export const load: PageServerLoad = async () => {
|
||||
(k) => `cbs:pop:${k}:GM0513`
|
||||
),
|
||||
'cbs:house:hpi:cr28',
|
||||
'cbs:house:hpi:nl'
|
||||
'cbs:house:hpi:nl',
|
||||
...epSeriesIds(['NL', 'GOUDA', ...GOUDA_PC4])
|
||||
])
|
||||
),
|
||||
cached('stats:gouda:kwb', () =>
|
||||
|
||||
@@ -3,12 +3,24 @@
|
||||
import Kpi from '#lib/stats/Kpi.svelte';
|
||||
import { barRankOption, change, latest, lineOption, type StatPoint } from '#lib/stats/chart.js';
|
||||
import { compact, num, period } from '#lib/stats/format.js';
|
||||
import { epShareId, EP_LABELS, GOUDA_PC4, labelStackOption } from '#lib/stats/energylabels.js';
|
||||
|
||||
let { data } = $props();
|
||||
const s = $derived(data.series);
|
||||
const a = $derived(data.attributions);
|
||||
const pts = (id: string): StatPoint[] => s[id]?.points ?? [];
|
||||
const last = (id: string) => latest(s[id]?.points);
|
||||
// EP-Online energy labels per Gouda postcode (PC4), latest monthly snapshot.
|
||||
const epShares = (area: string) => EP_LABELS.map((l) => last(epShareId(l, area))?.[1] ?? 0);
|
||||
const epPeriod = $derived(last(epShareId('A', 'GOUDA'))?.[0]);
|
||||
const epRows = $derived([
|
||||
{ name: 'Netherlands', shares: epShares('NL') },
|
||||
{ name: 'Gouda (2800–2809)', shares: epShares('GOUDA') },
|
||||
...GOUDA_PC4.filter((p) => s[epShareId('A', p)]?.points.length).map((p) => ({
|
||||
name: p,
|
||||
shares: epShares(p)
|
||||
}))
|
||||
]);
|
||||
|
||||
const NAMES: Record<string, string> = {
|
||||
NL01: 'Netherlands',
|
||||
@@ -336,6 +348,18 @@
|
||||
{ unit: '€', decimals: 0 }
|
||||
)}
|
||||
/>
|
||||
{#if epPeriod}
|
||||
<StatChart
|
||||
title="Energy labels by postcode"
|
||||
subtitle={`% of homes with a registered label · ${period(epPeriod, 'M')}`}
|
||||
sources={['eponline']}
|
||||
attributions={a}
|
||||
wide
|
||||
height="440px"
|
||||
note="Latest registered label per home in EP-Online's monthly total file (permit-stage labels left out). 2809 is mostly new-build (Westergouwe)."
|
||||
option={(t) => labelStackOption(t, epRows)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<h2 class="stats-section-title">Neighbourhoods ({data.kwbYear})</h2>
|
||||
|
||||
Reference in New Issue
Block a user