Walker Formulation Academy for developers API reference Error catalog OpenAPI
Sections

Your own dashboard on our API

A page with four analytics blocks on your site: a server intermediary calls the API, the browser gets only the finished data.

The scheme

Browser  --GET /analytics-api/...-->  your server (proxy)  --Bearer key-->  API
Browser  <--finished JSON data-------  your server (proxy)  <--API response--

The API key lives only in the server environment. The key scopes are analytics:read and dashboards:read; creation and rotation are in Authentication and Permissions. The read limit is 600 requests a minute per key: give the blocks the proxy cached response (the example sets Cache-Control: private, max-age=60).

The server proxy

The proxy lets in only your authenticated session and forwards only the whitelisted routes and known parameters. Until you replace the sign-in check it is closed to everyone.

Node.js

// server.mjs - A dependency-free proxy (Node 18+). The key and API address come from the server environment and never reach the browser.
import http from 'node:http';

const API_BASE = process.env.API_BASE;
const API_KEY = process.env.API_KEY;

// A whitelist of routes and parameters: the proxy forwards nothing extra
const ROUTES = {
  '/analytics-api/timeseries': '/analytics/timeseries',
  '/analytics-api/traffic-sources': '/analytics/traffic-sources',
  '/analytics-api/funnel': '/analytics/funnel',
  '/analytics-api/visibility': '/analytics/visibility',
  '/analytics-api/overview': '/dashboards/overview',
  '/analytics-api/pulse': '/dashboards/pulse',
};
const PARAMS = ['metric', 'from', 'to', 'granularity', 'engine', 'blocks', 'include'];

function allowed(req) {
  return false;   // REPLACE: let only your own authenticated session in. While it returns false the proxy is closed to everyone.
}

http.createServer(async (req, res) => {
  const parts = (req.url || '').split('?');
  const target = ROUTES[parts[0]];
  if (req.method !== 'GET' || !target) { res.writeHead(404).end(); return; }
  if (!allowed(req)) { res.writeHead(403).end(); return; }

  const incoming = new URLSearchParams(parts[1] || '');
  const query = new URLSearchParams();
  PARAMS.forEach((k) => { if (incoming.has(k)) query.set(k, incoming.get(k)); });

  try {
    const upstream = await fetch(API_BASE + target + '?' + query, { headers: { Authorization: 'Bearer ' + API_KEY } });
    res.writeHead(upstream.status, { 'Content-Type': 'application/json', 'Cache-Control': 'private, max-age=60' });
    res.end(await upstream.text());
  } catch (err) {
    res.writeHead(502, { 'Content-Type': 'application/json' });
    res.end('{"error":"Could not reach the API"}');
  }
}).listen(3000);

PHP

<?php
// analytics-api.php - The same proxy in PHP (curl). The page calls analytics-api.php?r=<name>&...
session_start();
if (empty($_SESSION['user_id'])) { http_response_code(403); exit; }   // REPLACE: your own sign-in check

// A whitelist of routes and parameters: the proxy forwards nothing extra
$routes = [
    'timeseries' => '/analytics/timeseries',
    'traffic-sources' => '/analytics/traffic-sources',
    'funnel' => '/analytics/funnel',
    'visibility' => '/analytics/visibility',
    'overview' => '/dashboards/overview',
    'pulse' => '/dashboards/pulse',
];
$params = ['metric', 'from', 'to', 'granularity', 'engine', 'blocks', 'include'];

$name = $_GET['r'] ?? '';
if (!isset($routes[$name])) { http_response_code(404); exit; }

$query = array_intersect_key($_GET, array_flip($params));
$ch = curl_init(getenv('API_BASE') . $routes[$name] . '?' . http_build_query($query));
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 15,
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('API_KEY')],
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

header('Content-Type: application/json');
header('Cache-Control: private, max-age=60');
http_response_code($body === false ? 502 : $status);
echo $body === false ? '{"error":"Could not reach the API"}' : $body;

The page

A page of four blocksexample

Site analytics

Impressions
182,400
▲ +6.1%
Clicks
4,210
▲ +8.5%
Sessions
3,920
▲ +8.9%
People from AI assistants
212
▲ +51.4%
Revenue
£5,240
▲ +27.8%
Clicks5,783▲ +11%
2026-09-15data through 2026-09-282026-09-28
Google
820leads: 9
Direct
410leads: 2
Yandex
260leads: 3
ChatGPT
190leads: 4
Social
96leads: 1
Impressions
182,400▲ +6.1%
Clicks
4,2102.3% of previous step
Sessions
3,92093.1% of previous step
Leads
1122.9% of previous step
Deals created
—No data for the period
Deals won
1715.2% of previous step
Revenue
£5,240▲ +27.8%

Drawn from example responses; the figures are made up. On a phone the blocks stack in one column.

HTML

<main class="dash" data-dash>
  <h1 class="dash-title">Site analytics</h1>
  <section class="dash-wide ab" data-block="overview" aria-label="Key figures" aria-busy="true">Loading</section>
  <div class="dash-grid">
    <section class="ab" data-block="timeseries" aria-label="Search clicks" aria-busy="true">Loading</section>
    <section class="ab" data-block="traffic-sources" aria-label="Sources" aria-busy="true">Loading</section>
    <section class="ab" data-block="funnel" aria-label="Funnel" aria-busy="true">Loading</section>
  </div>
</main>

CSS

.dash { max-width: 1100px; margin: 0 auto; padding: 16px; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.dash-title { margin: 0 0 16px; font-size: 22px; font-weight: 700; }
.dash-wide { margin-bottom: 16px; }
.dash-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); gap: 16px; }
/* + the blocks CSS (the .ab class and --ab-* variables) */

.ab {
  --ab-bg: #ffffff; --ab-fg: #1f2d26; --ab-muted: #4f6458; --ab-line: #e4ddcf; --ab-track: #eef1ec;
  --ab-accent: #2d6a4f; --ab-soft: #e8f5e9; --ab-up: #17703f; --ab-down: #b3261e; --ab-radius: 16px;
  --ab-d1: #1a3c2a; --ab-d2: #2d6a4f; --ab-d3: #7fb89b; --ab-d4: #cfd8d2;
  --ab-direct: #6b7a72; --ab-ai: #a9741f; --ab-social: #7a3db8; --ab-ads: #0b6bcb;
  background: var(--ab-bg); color: var(--ab-fg); border: 1px solid var(--ab-line); border-radius: var(--ab-radius);
  padding: 16px; font: 14px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; min-width: 0;
}
@media (prefers-color-scheme: dark) {
  .ab:not([data-theme="light"]) {
    --ab-bg: #14201a; --ab-fg: #e6eee9; --ab-muted: #a3b9ab; --ab-line: #2b3d32; --ab-track: #1f2f26;
    --ab-accent: #6ee7b7; --ab-soft: #1b2f24; --ab-up: #6ee7b7; --ab-down: #ff8a80;
    --ab-d1: #6ee7b7; --ab-d2: #3fae85; --ab-d3: #2a7a5c; --ab-d4: #3a4b41;
    --ab-direct: #9fb1a6; --ab-ai: #e0b266; --ab-social: #c9a0f5; --ab-ads: #6cb2ff;
  }
}
.ab[data-theme="dark"] {
  --ab-bg: #14201a; --ab-fg: #e6eee9; --ab-muted: #a3b9ab; --ab-line: #2b3d32; --ab-track: #1f2f26;
  --ab-accent: #6ee7b7; --ab-soft: #1b2f24; --ab-up: #6ee7b7; --ab-down: #ff8a80;
  --ab-d1: #6ee7b7; --ab-d2: #3fae85; --ab-d3: #2a7a5c; --ab-d4: #3a4b41;
  --ab-direct: #9fb1a6; --ab-ai: #e0b266; --ab-social: #c9a0f5; --ab-ads: #6cb2ff;
}
.ab-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; }
.ab-tile { background: var(--ab-soft); border-radius: 12px; padding: 12px 14px; min-width: 0; }
.ab-label, .ab-sub, .ab-axis { font-size: 12px; color: var(--ab-muted); }
.ab-value { font-size: 24px; font-weight: 700; line-height: 1.25; overflow-wrap: anywhere; }
.ab-delta { font-size: 12px; font-weight: 600; color: var(--ab-muted); }
.ab-up { color: var(--ab-up); }
.ab-down { color: var(--ab-down); }
.ab-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 12px; }
.ab-big { font-size: 26px; font-weight: 700; }
.ab-plot { height: 160px; margin-top: 10px; }
.ab-plot svg, .ab-bar svg, .ab-stack svg { display: block; width: 100%; height: 100%; }
.ab-grid { stroke: var(--ab-line); stroke-width: 1; vector-effect: non-scaling-stroke; }
.ab-line { fill: none; stroke: var(--ab-accent); stroke-width: 2.5; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
.ab-area { fill: var(--ab-accent); opacity: 0.14; }
.ab-axis { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 2px 8px; margin-top: 6px; }
.ab-rows { display: grid; gap: 12px; }
.ab-row { display: grid; grid-template-columns: minmax(90px, 150px) minmax(0, 1fr) minmax(96px, auto); gap: 4px 12px; align-items: center; }
.ab-name { font-weight: 600; overflow-wrap: anywhere; }
.ab-bar { height: 10px; border-radius: 5px; overflow: hidden; }
.ab-bar-none { background: var(--ab-track); opacity: 0.5; }
.ab-num { display: flex; flex-direction: column; align-items: flex-end; text-align: right; line-height: 1.3; }
.ab-track { fill: var(--ab-track); }
.ab-fill { fill: var(--ab-accent); }
.ab-k-direct { fill: var(--ab-direct); }
.ab-k-ai_assistant { fill: var(--ab-ai); }
.ab-k-social { fill: var(--ab-social); }
.ab-k-ads { fill: var(--ab-ads); }
.ab-sub-title { margin: 16px 0 8px; font-family: inherit; font-size: 13px; font-weight: 600; line-height: 1.4; color: var(--ab-fg); }
p.ab-sub { margin: 0 0 8px; }
.ab-stack { height: 14px; border-radius: 7px; overflow: hidden; }
.ab-d1 { fill: var(--ab-d1); } .ab-d2 { fill: var(--ab-d2); } .ab-d3 { fill: var(--ab-d3); } .ab-d4 { fill: var(--ab-d4); }
.ab-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; list-style: none; margin: 10px 0 0; padding: 0; }
.ab-legend li { display: flex; gap: 6px; align-items: baseline; }
.ab-legend li::before { content: ""; width: 10px; height: 10px; border-radius: 3px; background: var(--ab-d1); align-self: center; }
.ab-legend .ab-d2-key::before { background: var(--ab-d2); }
.ab-legend .ab-d3-key::before { background: var(--ab-d3); }
.ab-legend .ab-d4-key::before { background: var(--ab-d4); }
.ab-list { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 8px; }
.ab-item { display: flex; justify-content: space-between; gap: 12px; align-items: center; padding: 10px 12px; border: 1px solid var(--ab-line); border-radius: 12px; }
.ab-item-text { display: flex; flex-direction: column; min-width: 0; }
.ab-chip { background: var(--ab-soft); color: var(--ab-up); font-weight: 700; border-radius: 999px; padding: 2px 10px; white-space: nowrap; }
.ab-note { margin: 0 0 8px; padding: 12px; border-radius: 12px; background: var(--ab-soft); color: var(--ab-muted); }
@media (max-width: 480px) {
  .ab-row { grid-template-columns: minmax(0, 1fr) auto; }
  .ab-bar { grid-column: 1 / -1; grid-row: 2; }
  .ab-num { grid-column: 2; grid-row: 1; }
}

dashboard.js

// The blocks are drawn by the functions from the blocks code (renderTiles, renderTimeseries, renderSources, renderFunnel): paste it above.
function endpoint(name, query) {
  return '/analytics-api/' + name + '?' + query;   // For the PHP proxy: return '/analytics-api.php?r=' + name + '&' + query;
}
function iso(d) { return d.toISOString().slice(0, 10); }
function period(days) {                             // to is yesterday (UTC): the last full day
  var to = new Date(Date.now() - 86400000), from = new Date(to.getTime() - (days - 1) * 86400000);
  return 'from=' + iso(from) + '&to=' + iso(to);
}

var BLOCKS = {
  'overview': { query: 'blocks=kpi&' + period(28), draw: function (root, body) { renderTiles(root, body.data.blocks.kpi.tiles); } },
  'timeseries': { query: 'metric=clicks&' + period(28), draw: function (root, body) { renderTimeseries(root, body.data); } },
  'traffic-sources': { query: period(28), draw: function (root, body) { renderSources(root, body.data); } },
  'funnel': { query: period(28), draw: function (root, body) { renderFunnel(root, body.data); } }
};

Array.prototype.forEach.call(document.querySelectorAll('[data-block]'), function (root) {
  var name = root.getAttribute('data-block'), block = BLOCKS[name];
  fetch(endpoint(name, block.query))
    .then(function (res) { if (!res.ok) throw new Error(String(res.status)); return res.json(); })
    .then(function (body) { root.textContent = ''; block.draw(root, body); })
    .catch(function () { root.textContent = 'Could not load the block'; })   // each block fails on its own
    .then(function () { root.removeAttribute('aria-busy'); });
});

blocks.js

Include it before dashboard.js. Each block is described on the "Analytics blocks" page.

// Labels: edit for your site. The keys (clicks, google, ...) come from the API.
var LOCALE = 'en-GB';
var T = {
  impressions: 'Impressions', clicks: 'Clicks', sessions: 'Sessions', ai_people: 'People from AI assistants', revenue: 'Revenue',
  leads: 'Leads', deals_created: 'Deals created', deals_won: 'Deals won',
  google: 'Google', yandex: 'Yandex', bing: 'Bing', chatgpt: 'ChatGPT', perplexity: 'Perplexity', ai_other: 'Other AI',
  social: 'Social', ads: 'Ads', direct: 'Direct', other: 'Other'
};
var M = {
  dash: '—', noBase: 'no base', no_data: 'No data for the period', no_source: 'Source not connected',
  not_connected: 'Section not connected', through: 'data through', total: 'total', leads: 'leads', prevStep: 'of previous step',
  share: 'Visibility share', spread: 'Query positions', top3: 'Top 3', top10: 'Top 10', top30: 'Top 30', outside: 'Below',
  aeo: 'Citations in AI answers', volume: 'demand', new7: 'New in 7 days'
};

function esc(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
function el(tag, cls, text) {
  var n = document.createElement(tag);
  if (cls) n.className = cls;
  if (text != null) n.textContent = text;   // textContent, not innerHTML: the text never becomes markup
  return n;
}
// An API value: a number, money {amount, currency} or null. null means "no data", not 0.
function num(v) { return v == null ? null : (typeof v === 'object' ? Number(v.amount) : v); }
function fmt(v) {
  if (v == null) return M.dash;
  if (typeof v === 'object') return new Intl.NumberFormat(LOCALE, { style: 'currency', currency: v.currency, maximumFractionDigits: 0 }).format(Number(v.amount));
  return new Intl.NumberFormat(LOCALE, { maximumFractionDigits: 1 }).format(v);
}
function delta(v) {
  if (v == null) return el('span', 'ab-delta', M.noBase);
  var sign = v > 0 ? '▲ +' : (v < 0 ? '▼ ' : '');
  return el('span', 'ab-delta' + (v > 0 ? ' ab-up' : (v < 0 ? ' ab-down' : '')), sign + fmt(v) + '%');
}
function note(root, availability) { root.appendChild(el('p', 'ab-note', M[availability] || M.no_data)); }
function bar(percent, cls) {
  var b = el('div', 'ab-bar');
  b.innerHTML = '<svg viewBox="0 0 100 10" preserveAspectRatio="none" aria-hidden="true"><rect class="ab-track" width="100" height="10"/><rect class="' + cls + '" width="' + Math.max(0.8, Math.min(100, percent)).toFixed(1) + '" height="10"/></svg>';
  return b;
}

// Metric tiles: GET /dashboards/overview?blocks=kpi -> data.blocks.kpi.tiles
function renderTiles(root, tiles) {
  var grid = el('div', 'ab-tiles');
  tiles.forEach(function (t) {
    var tile = el('div', 'ab-tile');
    tile.appendChild(el('div', 'ab-label', T[t.key] || t.key));
    tile.appendChild(el('div', 'ab-value', fmt(t.current)));
    tile.appendChild(delta(t.delta_pct));
    grid.appendChild(tile);
  });
  root.appendChild(grid);
}

// Daily line: GET /analytics/timeseries (one metric) -> data
function renderTimeseries(root, item) {
  if (item.availability !== 'ok') { note(root, item.availability); return; }
  var vals = item.series.map(function (p) { return num(p.value); });
  var present = vals.filter(function (v) { return v != null; });
  var max = Math.max.apply(null, present.concat([1]));
  var W = 640, H = 200, step = vals.length > 1 ? W / (vals.length - 1) : 0, d = '', open = false, grid = '';
  vals.forEach(function (v, i) {
    if (v == null) { open = false; return; }          // a gap in the data: do not draw the line across it
    d += (open ? 'L' : 'M') + (i * step).toFixed(1) + ' ' + (H - 8 - (v / max) * (H - 16)).toFixed(1);
    open = true;
  });
  for (var g = 0; g <= 4; g++) { var y = (8 + g * (H - 16) / 4).toFixed(1); grid += '<line class="ab-grid" x1="0" x2="' + W + '" y1="' + y + '" y2="' + y + '"/>'; }
  var area = present.length === vals.length ? '<path class="ab-area" d="' + d + 'L' + W + ' ' + H + 'L0 ' + H + 'Z"/>' : '';
  var head = el('div', 'ab-head');
  head.appendChild(el('strong', 'ab-title', T[item.metric] || item.metric));
  head.appendChild(el('span', 'ab-big', fmt(item.summary ? item.summary.total : null)));
  head.appendChild(delta(item.comparison.delta_pct));
  var plot = el('div', 'ab-plot');
  plot.setAttribute('role', 'img');
  plot.setAttribute('aria-label', (T[item.metric] || item.metric) + ', ' + item.from + ' – ' + item.to + ', ' + M.total + ' ' + fmt(item.summary ? item.summary.total : null));
  plot.innerHTML = '<svg viewBox="0 0 ' + W + ' ' + H + '" preserveAspectRatio="none" aria-hidden="true">' + grid + area + '<path class="ab-line" d="' + d + '"/></svg>';
  var axis = el('div', 'ab-axis');
  axis.appendChild(el('span', '', item.series[0].date));
  axis.appendChild(el('span', '', M.through + ' ' + item.comparison.data_through));
  axis.appendChild(el('span', '', item.series[item.series.length - 1].date));
  root.appendChild(head); root.appendChild(plot); root.appendChild(axis);
}

// Source bars: GET /analytics/traffic-sources -> data (an array)
function renderSources(root, rows) {
  if (!rows.length) { note(root, 'no_data'); return; }
  var max = Math.max.apply(null, rows.map(function (r) { return r.sessions; }));
  var list = el('div', 'ab-rows');
  rows.forEach(function (r) {
    var row = el('div', 'ab-row');
    row.appendChild(el('span', 'ab-name', T[r.source] || r.source));
    row.appendChild(bar(r.sessions / max * 100, 'ab-fill ab-k-' + esc(r.kind)));
    var nums = el('span', 'ab-num');
    nums.appendChild(el('strong', '', fmt(r.sessions)));
    nums.appendChild(el('span', 'ab-sub', M.leads + ': ' + fmt(r.leads)));
    row.appendChild(nums);
    list.appendChild(row);
  });
  root.appendChild(list);
}

// Funnel: GET /analytics/funnel -> data. The step shares are computed by the client.
function renderFunnel(root, data) {
  var list = el('div', 'ab-rows'), prev = null;
  data.steps.forEach(function (s) {
    var row = el('div', 'ab-row');
    var ok = s.availability === 'ok';
    row.appendChild(el('span', 'ab-name', T[s.key] || s.key));
    var cur = ok ? num(s.current) : null;
    if (ok && s.unit === 'count') {
      var rate = prev ? cur / prev * 100 : 100;      // share of the previous step that has data
      row.appendChild(bar(rate, 'ab-fill ab-k-search'));
    } else {
      row.appendChild(el('div', 'ab-bar ab-bar-none'));
    }
    var nums = el('span', 'ab-num');
    nums.appendChild(el('strong', '', ok ? fmt(s.current) : M.dash));
    if (!ok) nums.appendChild(el('span', 'ab-sub', M[s.availability] || M.no_data));
    else if (s.unit === 'count' && prev) nums.appendChild(el('span', 'ab-sub', fmt(cur / prev * 100) + '% ' + M.prevStep));
    else nums.appendChild(delta(s.delta_pct));
    row.appendChild(nums);
    list.appendChild(row);
    if (ok && s.unit === 'count' && cur > 0) prev = cur;
  });
  root.appendChild(list);
}

// Visibility: GET /analytics/visibility -> data. Five sections, each with its own availability.
function renderVisibility(root, v) {
  var s = v.share, dist = v.distribution, aeo = v.aeo;
  var box = el('div', 'ab-tiles');
  var a = el('div', 'ab-tile');
  a.appendChild(el('div', 'ab-label', M.share));
  if (s.availability === 'ok' && s.data.value != null) {
    a.appendChild(el('div', 'ab-value', fmt(s.data.value) + '%'));
    a.appendChild(delta(s.data.comparison ? s.data.comparison.delta_pct : null));
  } else { a.appendChild(el('div', 'ab-value', M.dash)); a.appendChild(el('span', 'ab-delta', M[s.availability] || M.no_data)); }
  box.appendChild(a);
  var b = el('div', 'ab-tile');
  b.appendChild(el('div', 'ab-label', M.aeo));
  if (aeo.availability === 'ok') {
    b.appendChild(el('div', 'ab-value', fmt(aeo.data.rate) + '%'));
    b.appendChild(delta(aeo.data.comparison ? aeo.data.comparison.delta_pct : null));
  } else { b.appendChild(el('div', 'ab-value', M.dash)); b.appendChild(el('span', 'ab-delta', M[aeo.availability] || M.no_data)); }
  box.appendChild(b);
  root.appendChild(box);
  root.appendChild(el('h4', 'ab-sub-title', M.spread));
  if (dist.availability !== 'ok') { note(root, dist.availability); return; }
  var parts = ['top3', 'top10', 'top30', 'outside'], x = 0, rects = '';
  parts.forEach(function (k, i) {
    var w = dist.data[k] / dist.data.total * 100;
    rects += '<rect class="ab-d' + (i + 1) + '" x="' + x.toFixed(1) + '" width="' + w.toFixed(1) + '" height="14"/>';
    x += w;
  });
  var stack = el('div', 'ab-stack');
  stack.innerHTML = '<svg viewBox="0 0 100 14" preserveAspectRatio="none" aria-hidden="true">' + rects + '</svg>';
  root.appendChild(stack);
  var legend = el('ul', 'ab-legend');
  parts.forEach(function (k, i) {
    var li = el('li', 'ab-d' + (i + 1) + '-key');
    li.appendChild(el('span', '', M[k]));
    li.appendChild(el('strong', '', fmt(dist.data[k])));
    legend.appendChild(li);
  });
  root.appendChild(legend);
}

// "Pulse" findings: GET /dashboards/pulse -> data
function renderPulse(root, data) {
  if (!data.alerts.length) { note(root, 'no_data'); return; }
  root.appendChild(el('p', 'ab-sub', M.new7 + ': ' + fmt(data.new_7d)));
  var list = el('ul', 'ab-list');
  data.alerts.forEach(function (a) {
    var li = el('li', 'ab-item');
    var text = el('span', 'ab-item-text');
    text.appendChild(el('strong', '', a.topic_name || a.keyword));
    text.appendChild(el('span', 'ab-sub', a.keyword + ' · ' + M.volume + ' ' + fmt(a.volume)));
    li.appendChild(text);
    li.appendChild(el('span', 'ab-chip', a.growth == null ? M.dash : '×' + fmt(a.growth)));
    list.appendChild(li);
  });
  root.appendChild(list);
}

Security