A technical companion
Under the hood of the UK Energy Dashboard
A plain-English walkthrough of how the dashboard is built and how every part actually works — the languages, the data, the charts, and the search — written for anyone curious about the engineering behind it.
The whole thing is one file
The entire dashboard — every event, every chart, every interaction, all the styling — lives inside a single self-contained .html file. There is no server, no database, no build step, and no installation. You double-click the file and it opens in any modern browser.
That decision shapes everything else. Because there's no separate database, the data travels inside the file as JavaScript. Because there's no build tool (no Webpack, no compiler), what you write is exactly what runs. And because there's no server, the file can be emailed, dropped on a shared drive, or published as a static page, and it behaves identically everywhere. It's the same philosophy as a self-contained spreadsheet: one artefact, fully portable, that does its whole job on its own.
It leans on exactly one external library, Chart.js, loaded from a public CDN to draw the graphs, plus three web fonts from Google Fonts. Everything else — the timeline, the modals, the filtering, the search engine — is written by hand. Click the layers below to see how the file is organised.
The split between data (script ①) and logic (script ②) is deliberate: the facts can be edited without touching the machinery, and the machinery never has to change just because a fact does.
Three languages, one page
A web page is built from three languages that each do one job. The dashboard uses all three, and the cleanest way to understand any part of it is to ask: which of the three is responsible here?
- HTML is the structure — the nouns. It says "there is a heading here, a button there, a box for a chart." It's the skeleton, with no opinion about colour or behaviour.
- CSS is the presentation — the adjectives. It says "headings are in this serif at this size, the accent colour is burnt orange, cards have a soft shadow." Change the CSS and the page looks different without any structure changing.
- JavaScript is the behaviour — the verbs. It says "when this tab is clicked, redraw the chart" and "when someone types in the search box, rank the events." It's the only one of the three that can react, calculate, or change the page after it loads.
So when you click a chart tab and the graph changes, three things cooperated: HTML provided the tab and the canvas, CSS made the active tab look active, and JavaScript heard the click and redrew the chart. Keeping these roles separate is what makes a page this size maintainable — a styling fix never risks breaking the data, and a data fix never risks breaking the layout.
Throughout this document, the code blocks are taken directly from the dashboard's source, lightly trimmed for clarity.
The design system & how dark mode works
Every colour, font, and spacing value in the dashboard is defined once, in a single place, as a named CSS variable (also called a custom property). Nothing in the dashboard hard-codes a colour like #c6630b; instead it refers to the variable var(--accent). This is what gives the whole thing a consistent, deliberate look — the "warm paper" aesthetic with a burnt-orange accent, a serif display face, and a faint grid background.
:root {
/* one definition each — referenced everywhere */
--paper-bg: #f5efe0; /* the warm page background */
--ink: #1a1714; /* primary text */
--accent: #c6630b; /* burnt-orange highlight */
--card: #fbf7ea; /* panel background */
--line: #c8bfab; /* hairline borders */
--font-display: 'Fraunces', serif; /* headings */
--font-body: 'Inter Tight', sans-serif; /* text */
--font-mono: 'JetBrains Mono', monospace; /* code/data */
}
Dark mode is then almost free. There's a second block that re-points the very same variable names to dark values, and it only applies when the page's root element carries the attribute data-theme="dark":
[data-theme="dark"] { --paper-bg: #141210; /* same names, dark values */ --ink: #f1ebdb; --accent: #e88830; --card: #1b1814; --line: #3a342a; }
Toggling the theme is therefore a one-line action in JavaScript — flip that one attribute on, and because everything reads from the variables, the entire interface recolours at once. There's no need to restyle each component individually.
// one attribute change recolours the whole page document.documentElement.setAttribute('data-theme', 'dark');
The swatches below are read live from this page's own variables. Use the Dark toggle at the top — every swatch updates instantly, for exactly the reason just described.
The data layer: one source of truth
The heart of the dashboard is a single list — an array — of 179 event objects. Everything you see is derived from this list: the timeline dots, the detail cards, the markers on the charts, and the search results. Edit an event here and it updates everywhere automatically, because there's only one copy of the truth.
Each event is an object — a labelled bundle of fields. Click any field name below to see what it does and where it surfaces in the dashboard.
Two more data structures sit alongside the events. Categories are a short list that gives the timeline its seven horizontal lanes and gives every event a colour and a home:
const LANES = [ {cat: 'regulation', name: 'Regulation and Policy'}, {cat: 'networks', name: 'Networks and Infrastructure'}, {cat: 'cap', name: 'Price Cap'}, {cat: 'ma', name: 'Mergers and Acquisitions'}, {cat: 'strategic', name: 'Strategic Moves'}, {cat: 'failure', name: 'Failures and SoLR'}, {cat: 'geopolitics', name: 'Geopolitics and Macro'}, ];
Themes are curated storylines — for example "Energy Crisis" — that group related events into a narrative arc. A theme doesn't copy the events; it just lists their ids, so the events stay in one place and a theme is simply a reading order through them. This is also why a single event can appear in several stories at once.
The timeline & the event cards
The timeline turns the data list into something you can scan. The code walks each of the seven categories, filters the events that belong to it, and places a dot for each one at a horizontal position that corresponds to its date.
That position is pure arithmetic. The timeline spans 2000–2030, and each event carries a year plus a yearFrac (how far through the year it falls, 0–1). The code converts those into a percentage across the track:
const YEAR_START = 2000, YEAR_RANGE = 31; // 2000…2030 function eventX(event) { const yearFrac = event.yearFrac ?? 0.5; const frac = (event.year - YEAR_START + yearFrac) / YEAR_RANGE; return frac * 100; // → a left-offset percentage }
Using a percentage rather than a fixed pixel value means the timeline stretches and shrinks gracefully with the window. Where several events would land on top of one another, the code nudges them into stacked slots so the dots don't collide.
Clicking a dot opens the detail card (a modal). The card isn't pre-written in the HTML — it's assembled on the spot from the event's fields: the date and category pills, the title and description, a "Why this matters" note, the narrative badges, related events, and the source links. The HTML holds just one empty modal shell; JavaScript fills it with whichever event you clicked and reveals it.
The charts: data made interactive
The graphs are drawn by Chart.js, the one external library. Chart.js takes a description of what you want — the numbers, the chart type, the axis settings, the colours — and renders it onto an HTML <canvas>, a drawing surface in the page.
There are nine charts, and each has its own small function that returns its configuration. A single dispatcher picks the right one by name — a clean pattern that keeps each chart's setup self-contained:
function configFor(chartType, colors) { if (chartType === 'cap') return capChartConfig(colors); if (chartType === 'market-share') return marketShareChartConfig(colors); if (chartType === 'wholesale') return wholesaleChartConfig(colors); // …six more, one per chart… }
Switching charts follows one repeatable rhythm: throw away the old chart, build the new configuration, and create a fresh chart. This "tear down and rebuild" approach keeps the state simple — there's never a stale half-updated chart lying around.
function renderChart(chartType) { if (chartInstance) chartInstance.destroy(); // 1. tear down const ctx = document.getElementById('mainChart').getContext('2d'); const colors = getThemeColors(); // 2. light or dark? const config = configFor(chartType, colors); // 3. build config chartInstance = new Chart(ctx, config); // 4. draw }
One subtlety worth knowing: Chart.js can't read the CSS variables directly, so the chart's text and gridline colours are fetched in JavaScript first (that's getThemeColors()) and handed in. That's why the charts also respond correctly when you flip to dark mode — they're redrawn with the dark palette.
The event-line plugin
The dashed vertical "event lines" you can toggle onto a chart — marking, say, the Ukraine invasion on the price-cap graph — aren't a built-in Chart.js feature. They're a small custom plugin: a piece of code that hooks into Chart.js's drawing process and, after the chart is painted, draws the extra lines on top at the right dates. The demo below is a faithful miniature of that mechanism.
Toggling the lines flips a single true/false flag and redraws — the same state-then-render idea used everywhere in the dashboard (§08). The dashed lines and labels are drawn by the plugin in burnt orange, and they recolour with the theme.
How search works, end to end
The search is the most intricate piece, so it's worth seeing it run. The box below uses a faithful copy of the dashboard's real search engine, over a small sample of events. Type into it and watch the pipeline below light up — then read how each stage works.
Stage 1 — Build an index, once
When the dashboard loads, it pre-processes every event into a search index. For each event it lowercases the key fields (title, description, why-it-matters, category, suppliers, date) and also stitches them into a single combined "haystack" string. Doing this once up front means each keystroke just searches ready-made text instead of re-processing 179 events every time.
const SEARCH_INDEX = EVENTS.map(e => ({ event: e, fields: { title: searchNorm(e.title), // searchNorm = lowercase description: searchNorm(e.description), implication: searchNorm(e.spImplication), category: searchNorm(catName + ' ' + e.category), suppliers: searchNorm(suppliers), date: searchNorm(e.date + ' ' + e.year), }, get blob() { return Object.values(this.fields).join(' … '); } }));
Stage 2 — Understand the query
Your query is lowercased and split into words (terms). Two extra touches make it forgiving. First, synonyms: a small dictionary maps acronyms to their full forms and back, so searching cfd also finds events that only say "Contracts for Difference," and vice versa. Second, the whole phrase is kept intact too, so a multi-word search like "price cap" can also be matched as one unit and rewarded for it.
const SEARCH_SYNONYMS = { 'cfd': ['contracts for difference'], 'solr': ['supplier of last resort'], 'epg': ['energy price guarantee'], // …and the reverse mappings… }; function expandTerm(term) { return [term, ...(SEARCH_SYNONYMS[term] || [])]; }
Stage 3 — Match, with whole-word precision
An event is a candidate only if every term in your query is found somewhere in its haystack (an "AND" search — more words narrow the results). Crucially, matching is done on whole words, not fragments, so searching rough finds the Rough gas store but not the word "through." This is handled by a small word-boundary test:
// the term must sit between non-letter/number edges function wordMatch(text, term) { const rx = new RegExp('(?:^|[^a-z0-9])' + escape(term) + '(?:$|[^a-z0-9])', 'i'); return rx.test(text); }
Stage 4 — Score & rank
Surviving events are scored so the most relevant rise to the top. A hit in the title is worth far more than one buried in the description, and matching the full phrase earns a bonus. A tiny nudge favours more recent events when scores are otherwise tied. The results are then sorted by score.
const fieldWeights = { title: 100, category: 40, suppliers: 35, date: 30, implication: 20, description: 15, }; // a title match outweighs a description match ~7×
Finally, the matched words are wrapped in a highlight in the result snippets (using the same whole-word rule), which is the highlighting you see in the live demo above. That's the entire engine — index once, expand the query, match whole words with an AND, score by field, sort.
Interactivity without a framework
Many modern web apps are built with a framework like React. The dashboard deliberately isn't — it's plain ("vanilla") JavaScript. For a self-contained file with no build step, that's a feature: nothing to install, nothing to compile, and the code is exactly what runs.
The model is simple to hold in your head. The current situation is kept in ordinary variables — which chart is showing, whether event lines are on, which companies are toggled. When something changes, the code updates a variable and then calls a render function that rebuilds the affected part of the page. There's no hidden magic reconciling the screen; the redraw is explicit.
let chartEventLinesOn = false; // a piece of state eventsButton.addEventListener('click', () => { chartEventLinesOn = !chartEventLinesOn; // 1. change the state renderChart(currentChart); // 2. redraw from it });
Interactions are wired with event listeners — "when this is clicked, run this function." That single pattern, repeated, powers the tabs, the filters, the timeline dots, the modal, and the search box. It's a small amount of vocabulary doing a lot of work, which is exactly why the whole dashboard fits in one readable file.
The Compare view: small multiples
The Compare feature shows several charts at once as small side-by-side "mini" charts — a technique called small multiples, which lets you read trends across different measures together rather than flipping between them.
It reuses the very same configuration functions as the main charts, just rendered smaller and into their own canvases (tracked in a lookup so each can be cleaned up independently). Each mini chart can even carry its own little view toggle — electricity vs gas market share, for instance — without disturbing the main view.
That last point needs care: because the chart configs read from shared state, the Compare code snapshots that state, swaps in the mini chart's own choice, builds the config, then restores the snapshot. The main chart never notices it happened. It's a tidy way to let two views of the same machinery coexist without one stepping on the other.
How it's built — and kept correct
A single file with 179 events and thousands of lines of logic could easily drift into inconsistency. It's kept honest by a disciplined workflow rather than by chance.
- Versioning. Every change produces a new numbered version (the current one is v118), so any edit can be compared against the last good copy and rolled back if needed.
- Automated checks. Before a version ships, scripts pull the two inline scripts out of the file and confirm they parse without error, that there are exactly the expected number of events with no duplicate ids, that the events are in date order, that every chart marker and every theme points at a real event, and that the page's tags are balanced. If any check fails, the version isn't shipped.
- One change at a time. Edits are made surgically and re-validated, so a small content fix can't quietly break the search index or the charts.
None of this is visible in the dashboard itself — it's the equivalent of a spell-check and a test suite running behind the scenes. It's what lets a file this dense stay accurate as it grows.
Glossary
A quick reference for the terms used above.
- HTML
- The structural language of a web page — defines the elements that exist (headings, buttons, boxes).
- CSS
- The styling language — controls how elements look: colour, type, spacing, layout.
- JavaScript
- The behaviour language — the only one that can react to clicks, calculate, and change the page after load.
- CSS variable
- A named, reusable value (e.g. --accent). Define once, reference everywhere; the basis of theming.
- Array
- An ordered list of items — here, the list of 179 events.
- Object
- A labelled bundle of values — here, a single event with its title, date, and so on.
- Library
- Pre-written code you bring in to do a job — here, Chart.js for drawing graphs.
- CDN
- A public network that hosts common libraries so a page can load them over the internet.
- Canvas
- A blank drawing surface element in the page; Chart.js paints the graphs onto it.
- Plugin
- A small add-on that hooks into a library — here, to draw the dashed event lines Chart.js doesn't provide.
- Modal
- A pop-over panel layered over the page — here, the event detail card.
- Event listener
- An instruction of the form "when X happens, run this" — how every interaction is wired.
- Index (search)
- Text prepared in advance so searching is fast — built once when the page loads.
- Framework
- A large structure (like React) for building apps. The dashboard deliberately uses none.