Gapminder Tools Page is a static SPA that wraps the Vizabi chart framework with a CMS-driven configuration layer. It hosts interactive data visualizations (bubble charts, line charts, bar ranks, maps, etc.) on gapminder.org and white-label partner sites.
| Layer | Technology | Notes |
|---|---|---|
| Charts | Vizabi framework (@vizabi/core, @vizabi/shared-components, individual chart packages) |
MobX-based reactive data model, D3-rendered UI components |
| Data model / reactivity | MobX 5 | Used inside Vizabi; tools-page itself uses mobx.observable, autorun, reaction, toJS, runInAction |
| DOM manipulation | D3 v6 | Used as the primary DOM library (not just for SVG), see "D3 as DOM Library" section |
| Maps | Mapbox GL JS, Deck.gl | Loaded on-demand for map tools (extapimap, combo, bubblechart) |
| Styling | Stylus → CSS, CSS custom properties, runtime CSS injection from CMS | Compiled by rollup-plugin-stylus-compiler + PostCSS |
| Bundler | Rollup 4 (ESM output) | Tools (charts) are lazy-loaded via dynamic import(), vendors loaded as UMD <script> tags |
| Backend / CMS | Supabase (PostgreSQL + Auth + RPC) | All site configuration, theme, toolset, configs, permalinks, ACL |
| URL serialization | URLON | Compact JSON-like encoding in the URL hash |
| Hosting | DigitalOcean VM (nginx, static files) or Cloudflare Workers/Pages | Blue/green deployment via bash scripts; see Key Commands |
| Telemetry | Google Analytics (gtag.js), Rollbar (error tracking) | Conditional on hostname |
| Package management | npm | package.json with "type": "module" (ESM) |
tools-page/
├── src/
│ ├── index.js # Entry point: registers data readers, launches App()
│ ├── index.html # SPA shell with <script> tags for vendor UMDs
│ ├── app/
│ │ ├── app.js # Main App() async function — bootstraps everything
│ │ ├── index.styl # Root stylesheet importing all component styles
│ │ ├── d3extensions.js # D3 v3→v4 compatibility shim (d3.rebind)
│ │ ├── core/ # Services and utilities
│ │ │ ├── url.js # URL state management (URLON hash, history API)
│ │ │ ├── cms.js # CMS data loader (Supabase RPC + JSON fallbacks)
│ │ │ ├── tool.js # Tool lifecycle: lazy-load, instantiate, wire MobX→URL
│ │ │ ├── theme.js # Runtime theming: CSS injection, layout DOM, fonts
│ │ │ ├── language.js # i18n translator using Intl.DisplayNames + CMS strings
│ │ │ ├── default-config.service.js # Preferential config save/restore (ACL-gated)
│ │ │ ├── bitly.service.js # URL shortening (Bitly fallback) + share-link modal
│ │ │ ├── location.service.js # URL hash + embed URL helpers
│ │ │ ├── links-resolve.js # Permalink/short-link resolution via Supabase
│ │ │ ├── chart-transition.js # State transfer between chart types (select/show/time)
│ │ │ ├── deprecated-url.js # URL upgrader pipeline (legacy URL format support)
│ │ │ ├── embedded-bridge.js # postMessage bridge for iframe embedding
│ │ │ ├── event-analytics.service.js # GA timing events for chart loads
│ │ │ ├── utils.js # deepExtend, debounce, translateNode, URLON helpers
│ │ │ ├── utilsForAssetPaths.js # Path resolution for slugs + <base href>
│ │ │ ├── table.js # Generic JSONB table editor (D3 data-join based)
│ │ │ ├── icons.js # Inline SVG icon constants (ICON_GAPMINDER, etc.)
│ │ │ ├── download-utils.js # SVG screenshot export
│ │ │ ├── timelogger.js # Performance timer for splash/full load metrics
│ │ │ └── deprecated-url.rules/ # URL migration rules (concept renames, v1→v2, etc.)
│ │ ├── auth/ # Authentication
│ │ │ ├── supabase.service.js # Supabase client init, login/logout/signup/delete
│ │ │ ├── user-login.js # Login/signup UI (email+password, OAuth GitHub/Google)
│ │ │ └── user-login.styl
│ │ ├── chart-switcher/ # Tool selector dropdown + icon-bar variant
│ │ ├── language-switcher/ # Locale dropdown
│ │ ├── social-buttons/ # Share (Twitter, Facebook, mail, link, download, embed code)
│ │ ├── menu/ # Desktop nav menu + hamburger mobile menu
│ │ ├── logo/ # Configurable logo component
│ │ ├── footer/ # Footer links + partner logos
│ │ ├── howto/ # Video tutorial dialog
│ │ ├── see-also/ # Thumbnail grid of other chart types
│ │ ├── related-items/ # Related content links + video block
│ │ ├── message/ # Toast/banner message component
│ │ ├── page/ # Global page styles + RTL + vizabi overrides
│ │ └── _resources/ # Stylus variables, mixins, font definitions
│ ├── config/ # Per-site static config fallbacks
│ │ ├── properties.json # Build-time injected site properties (Supabase keys, site ID)
│ │ ├── gapminder/ # Gapminder-specific: toolset.json, datasources.json, chart configs
│ │ ├── boendebarometern/ # White-label site: Boendebarometern
│ │ └── boendebarometern--healthatlas/ # Sub-page config variant
│ ├── assets/ # Static assets (fonts, images, i18n JSONs, translation JSONs)
│ ├── data/ # Embedded CSV data (basic.csv)
│ └── shims/ # Node builtin shims (empty.js for `fs`)
├── vizabi-tools.js # Lazy-loader registry: dynamic import() for each chart tool + CSS/Mapbox/Deck
├── rollup.config.js # Build config (ESM output, vendor copy, stylus, aliases)
├── dev-server.js # Express dev server with slug rewriting + SPA fallback
├── build.prod.blue.sh # Blue production build + deploy script
├── build.prod.green.sh # Green swap script (make blue build live)
├── build.prod.backup.sh # Backup current green to /home/green.bak
├── build.prod.restore.sh # Restore green.bak
├── build.stage.sh # Stage environment build
├── build.dev.sh # Dev environment build (all deps → "latest")
└── package.json
index.js → registers data readers (DDFcsv, Excel, DDFservice) into Vizabi.stores.dataSources → calls App(properties).
App() in app.js:
- Resolves page slug from URL path via
getPageSlug() - Loads CMS data from Supabase (
cms.load()) — toolset, configs, theme, locales, etc. - Resolves short-links/permalinks if
?for=slugquery param present - Initializes URL state service (
url.init()) — parses URLON hash, upgrades deprecated URLs - Initializes translator, bitly service, preferential config service
- Creates
Toolinstance (the chart lifecycle manager) - Applies theme (DOM layout injection + CSS variables + fonts)
- Instantiates all UI view components (Logo, ChartSwitcher, Menu, Footer, etc.)
- Wires event dispatch listeners for tool changes, language, projector mode, auth
- Calls
tool.setTool()to render the initial chart
The app uses hash-based routing with window.history.pushState/replaceState. The URL structure is:
https://domain.com/[optional-page-slug]/?[query-params]#chart-type=bubbles&url=v2&model=...&ui=...
- Page slug: Optional path segment after base (e.g.,
/tools/healthatlas/). Detected bygetPageSlug()— if the last path segment isn't part of<base href>, it's treated as a slug that selects a CMS theme/config. - Query params:
?for=slugnamefor permalinks,?embedded=truefor iframe mode. - Hash: URLON-encoded state object containing
chart-type,url(version),model(data config),ui(UI config).
URL state is managed by url.js:
init()— parse URL, apply defaults, return state APIupdateURL()(debounced 310ms) — serialize current model+UI diff into hashpopState()— handle browser back/forwardd3.dispatch— central event bus with named events (toolChanged,languageChanged,projectorChanged,authStateChange, etc.)
Legacy URL support: deprecated-url.js applies a pipeline of upgrade rules to handle old URL formats (v1→v2, concept renames, entityset renames, legacy tools-page format, world adapter).
Every UI component follows the same constructor pattern:
const ComponentName = function({ dom, translator, state, data, getTheme }) {
const template = `<div class="...">...</div>`;
const CLASS = "ComponentName";
const theme = getTheme(CLASS) || {};
const placeHolders = d3.selectAll(dom);
if (!placeHolders || placeHolders.empty()) return;
placeHolders.html(template);
// Apply CMS theme styles
if (theme.style)
Object.entries(theme.style).forEach(([key, value]) => placeHolders.style(key, value));
// ... component logic, event listeners, data joins ...
// Translation support
translate();
state.dispatch.on("translate.componentName", translate);
function translate() {
placeHolders.selectAll("[data-text]").each(utils.translateNode(translator));
}
};Key conventions:
- Constructor receives
{ dom, translator, state, data, getTheme }— the CSS selector, i18n function, URL state service, CMS data, and theme getter. - Template is an HTML string injected via
d3.selection.html(). - DOM is managed entirely via D3 selections (no framework).
- CMS theme styling is applied inline from
theme.styleobject. - Translation uses
data-textattributes resolved bytranslateNode(). - Events are wired via
state.dispatch.on("eventName.namespace", handler)— D3 dispatch with namespacing. - No virtual DOM, no reactive rendering — imperative D3 manipulation.
D3 is used as a general-purpose DOM library far beyond SVG charting:
- Selection & manipulation:
d3.select(".too-header"),.html(),.text(),.classed(),.style(),.attr() - Data joins:
selectAll().data().join()for lists (menu items, chart switcher options, footer links) - Event handling:
.on("click", handler),d3.dispatch()for app-wide events - HTTP:
d3.json(),d3.csv()for loading config files and remote data - Utilities:
d3.rollup(),d3.timeFormat() - D3 is loaded as a UMD global (
window.d3) — not imported via ESM
┌─────────────┐ ┌──────────────┐ ┌───────────┐
│ Supabase │───>│ cms.js │───>│ App() │
│ (CMS data) │ │ (load + │ │ toolset, │
│ │ │ validate) │ │ configs │
└─────────────┘ └──────────────┘ └─────┬─────┘
│
v
┌─────────────┐ ┌──────────────┐ ┌───────────┐
│ URL hash │<──>│ url.js │<──>│ tool.js │
│ (URLON) │ │ (state mgr) │ │ (Vizabi │
│ │ │ + dispatch │ │ lifecycle)│
└─────────────┘ └──────────────┘ └─────┬─────┘
│
v
┌───────────┐ ┌──────────────┐
│ Vizabi │────>│ Data Reader │
│ (MobX │ │ (DDFcsv / │
│ model) │ │ DDFservice)│
└───────────┘ └──────┬───────┘
│
v
┌───────────┐
│ Waffle │
│ Server │
│ (BW API) │
└───────────┘
- CMS data loads at startup from Supabase via
get_known_pageRPC → returns toolset, datasources, theme, configs, locales, etc. - Fallback chain: Supabase DB → local JSON files in
src/config/→ hardcoded defaults inproperties.json. - URL state is the single source of truth for the active tool and its configuration. Parsed from URLON hash, managed in
url.js. - Tool lifecycle (
tool.js): When a tool changes, it lazy-loads the chart JS, instantiates Vizabi with merged config (essential → preferential → URL state), and sets up a MobXautorunthat diffs the model against defaults and writes meaningful changes back to the URL hash. - Data readers fetch data from the Waffle Server (small-waffle.gapminder.org) using the DDF query protocol. The reader type (
ddfbw,ddfcsv,excel) is configured per datasource indatasources.json.
| Table/RPC | Purpose | Key columns |
|---|---|---|
get_known_page (RPC) |
Returns full page config for a site+slug | _site, _slug → returns: id, toolset, datasources, theme_*, locales, menu_items, footer_*, concept_mapping, entityset_mapping, related |
get_known_deepconfigs (RPC) |
Returns tool configs for a page | _page_id → returns array with tool_id, config, type ("essential"/"preferential") |
configs |
Tool configurations | tool_id, config (JSONB), page_id, type ("essential"/"preferential"/"user"), note |
links |
Permalinks/short links | slug, page_id, page_config (JSONB), created_by, created_at, expires_at |
is_editor_or_owner_acl (RPC) |
ACL check | s (scope: "page"/"site"), r (resource path) → returns boolean |
soft-delete-user (Edge Function) |
Account deletion | Called via supabase.functions.invoke() |
Access control is handled by the is_editor_or_owner_acl Supabase RPC function:
- Scopes:
"page"(specific page within a site) and"site"(entire site) - Resources: formatted as
"site/slug"(e.g.,"gapminder/healthatlas") or"site/__home__"for root pages - Usage:
PreferentialConfigServicechecks editor/owner status before allowing preferential config save/restore operations - UI gating: The "Save current view as preferential" and "Reset preferential view" buttons in the user login panel are only functional for page editors
table.js exports a reusable D3-based component for editing tabular JSONB data:
- Renders as nested
<table>with<tr>rows for each key-value pair - Supports cell types: inline contenteditable text, checkbox toggle, dropdown select
- Emits events via
d3.dispatch:"edit","remove","prop_change" - Used for datasource configuration editing
- Pattern:
d3.select(container).call(Table(), { rowdata, propTypes })
Data is fetched from "Small Waffle" server at small-waffle.gapminder.org:
- Protocol: DDF (Data Description Format) query protocol
- Reader:
DDFServiceReader(registered as"ddfbw"reader type in Vizabi) - Datasources: Each datasource in
datasources.jsonspecifiesurl,dataset, andbranch - Auth: Data readers accept
authTokenandpermalinkTokenfor access to private datasets. Tokens are propagated from the Supabase session throughtool.js→ reader config. - Datasets:
fasttrack,sg,wdi,population,povcalnet,billy,country-flags
When loaded in an iframe (?embedded=true):
embedded-bridge.jssets up apostMessagebridge with the parent window- Parent can send
response-configmessages to set chart state - Child sends
set-right-configmessages when URL state changes - The wrapper element gets class
embedded-viewto hide chrome
| Command | Description |
|---|---|
npm start |
Dev mode: rollup watch + Express dev server on port 4200 |
npm run dev |
Rollup watch only (NODE_ENV=development) |
npm run build |
Production build (requires BASE env var) |
npm run serve |
Start Express dev server only |
BASE=/ npm run build |
Build for root deployment (supports page slugs) |
BASE=/tools/ npm run build |
Build for /tools/ subfolder (Gapminder.org production) |
BASE=./ npm run build |
Build for relative paths (works in any folder, no slug support) |
| Script | Description |
|---|---|
build.prod.blue.sh |
Builds "blue" production: clones from main, installs, builds with BASE=/tools/, copies to versioned folder, uploads Rollbar source maps, triggers Percy visual tests |
build.prod.green.sh |
Makes a versioned blue build live: cp -r /home/vX.Y.Z/* /home/tools-page/build/tools/ |
build.prod.backup.sh |
Backs up current green to /home/green.bak/ |
build.prod.restore.sh |
Restores from /home/green.bak/ |
build.stage.sh |
Builds stage from main with BASE=/ |
build.dev.sh |
Builds dev from develop with all @vizabi/* deps set to "latest" |
- Blue = newly built version, served at
tools-blue.gapminder.org:8080 - Green = live production at
www.gapminder.org/tools/ - Flow:
build.prod.blue.sh→ verify on blue →build.prod.backup.sh(safety) →build.prod.green.sh vX.Y.Z(promote) - Rollback:
build.prod.restore.sh
- Entry:
src/app/index.stylimports all component stylesheets - Variables:
_resources/variables.styldefines breakpoints and CSS custom property defaults (theme tokens) - Mixins:
_resources/mixins.styldefinescss-var()helper,:rootdefaults, responsive breakpoints - Components: Each component dir has its own
.stylfile, scoped with.too-prefix - Vizabi overrides:
page/override-vizabi.stylinjects theme variables into Vizabi's CSS scope - RTL support:
page/page.rtl.styl
Theme tokens are defined as CSS custom properties and can be overridden at runtime from CMS:
--color-primary, --color-grey, --color-black, --color-background,
--color-button-border, --color-button-background, --color-button-hover,
--color-logotext, --color-menutext, --color-chartswitchertext,
--color-footer-text, --color-footer-link,
--header-height-small, --header-height-large, --header-height-xlarge,
--body-min-width, ...
theme.js applies CMS-driven theming at runtime:
- Layout: Reads
theme_layoutand appends DOM elements into wrapper selectors (e.g.,".too-header .too-start": ["too-logo", "too-chart-switcher"]) - CSS Variables: Reads
theme_variablesand injects:root { --token: value; }via<style>tag - Custom CSS: Reads
theme_styleand injects generic CSS rules via<style>tag - Fonts: Reads
theme_fontsand generates@font-facedeclarations - Meta: Applies
theme_meta(title, favicon, og:tags, social share image)
This enables the same codebase to serve visually distinct sites (Gapminder, Boendebarometern, etc.) via CMS-only configuration.
| Name | Range |
|---|---|
small |
≤ 728px |
medium |
729px – 1023px |
large |
≥ 1024px |
xlarge |
≥ 1366px |
Loaded from build/vendor/ via <script> tags in index.html (not bundled):
| File | Global | Purpose |
|---|---|---|
d3.js |
d3 |
D3 v6 — DOM, data, HTTP, SVG |
mobx.js |
mobx |
MobX 5 — reactive state |
Vizabi.js |
Vizabi |
Vizabi core — data model, stores, dataframes |
VizabiSharedComponents.js |
VizabiSharedComponents |
Shared UI components (LayoutService, LocaleService, Utils) |
supabase.js |
supabase |
Supabase JS client |
reader-ddfcsv.js |
DDFCsvReader |
DDF CSV file reader |
reader-ddfservice.js |
DDFServiceReader |
DDF Waffle Server reader |
reader-excel.js |
ExcelReader |
Excel file reader |
urlon.js |
urlon |
URL-safe object notation |
mapbox-gl.js |
mapboxgl |
Mapbox GL (loaded on-demand for map tools) |
deck.js |
deck |
Deck.gl core+layers (loaded on-demand, concatenated at build time) |
Chart tool JS is NOT in vendor — it's loaded via dynamic import() from vizabi-tools.js. Each chart's CSS is injected as a <link> tag on first use.
These packages are mapped to globals via rollup-plugin-external-globals and excluded from the bundle:
"@vizabi/core" → "Vizabi"
"@vizabi/shared-components" → "VizabiSharedComponents"
"@deck.gl/core" → "deck"
"@deck.gl/layers" → "deck"
"d3" → "d3"
"mobx" → "mobx"- Client init:
supabase.service.jscreates a Supabase client fromS_URLandS_KEY(anon key) inproperties.json - Methods: email+password signup/login, OAuth (GitHub, Google) via popup window
- Session management:
supabase.auth.onAuthStateChange()fires on login/logout/token refresh → sets auth token in URL state → propagates to data readers for private dataset access - Account actions: change email, change password, soft-delete account (via Edge Function)
- OAuth redirect: Uses
redirectTo: location.origin + "/tools/auth/"with popup window pattern
- User logs in → Supabase returns JWT session
onAuthStateChangehandler callsstate.setAuthToken({event, session})url.jsstores token and dispatches"authStateChange"eventtool.jslistens → callssetVizabiUserAuth()→ pushes token to each data reader'ssetAuthToken()- Data readers include token in requests to waffle server for private datasets
- Permalink tokens: separate
permalinkTokenallows shared access to private data via short links
- CSS classes use
too-prefix for tools-page components,vzb-prefix for Vizabi components - Component constructors are PascalCase functions (not classes)
- Config IDs use lowercase with hyphens (
bubbles,barrank,linechart) site= deployment target ID (e.g.,"gapminder","boendebarometern")pageSlug= optional URL path segment selecting a sub-configuration within a site
- URL hash state (user interaction → URLON)
- Permalink state (
?for=slug→ loaded fromlinkstable) - Preferential config (editor-saved config from
configstable, type"preferential") - Essential config (base config from
configstable, type"essential") - Local fallback config (JS module in
src/config/{site}/) - Vizabi component defaults in multiple layers: root component (tool), then subcompoent defaults following the tree
The same codebase serves multiple sites via:
properties.json— build-time injected via Rollup alias (toolsPage_properties) — containssite,S_URL,S_KEY, and inline fallback theme/configsrc/config/{site}/— per-site fallback config files (toolset.json, datasources.json, chart configs)src/config/{site}--{slug}/— page-slug-specific config overrides- CMS (Supabase) — runtime config taking precedence over local files
- The
<base href>tag inindex.htmlis set at build time by theBASEenv variable - All asset URLs must go through
resolveAssetUrl()orresolvePublicUrl()fromutilsForAssetPaths.js - These functions honor
<base href>and correctly resolve paths whether deployed at root, in a subfolder, or with a virtual page slug
d3.dispatch is used as the central event bus. Events use namespacing ("eventName.listenerId"):
| Event | Payload | Triggered when |
|---|---|---|
toolChanged |
{ id, previousToolId } |
Chart type switches |
toolStateChangeFromPage |
{ model, ui } |
Browser back/forward triggers state update |
toolReset |
— | Tool reset to defaults |
languageChanged |
locale id | Locale changes |
projectorChanged |
boolean | Projector mode toggles |
authStateChange |
event | Login/logout/token refresh |
showMessage |
{ message, timeout } |
Display toast |
translate |
locale id | Re-translate all components |
menuOpen / menuClose |
— | Mobile menu state |
setPreferentialConfig |
— | Editor saves current view |
restorePreferentialConfig |
— | Editor resets to essential |
- Vendor exports: Some
@vizabi/*packages haveexportsfields that block deep path resolution viarequire.resolve(). Usepath.resolve(__dirname, "node_modules/...")as workaround in rollup config. - URLON hash encoding:
#symbols in color codes must be encoded as%23in URLON strings. The app handles this inencodeUrlHash()/decodeUrlHash(). - D3 is not imported: D3, MobX, Vizabi, etc. are UMD globals. Don't add
import d3 from "d3"— used3directly. - No framework: This is vanilla JS with D3. No React, Vue, Svelte, or similar.
- MobX version: MobX 5 (not 6+). API differences: no
makeObservable, uses decorators-freeobservable(),autorun(),reaction(). - Page slug detection: Based on URL path analysis against
<base href>. Adding new slugs doesn't require code changes — just CMS config.