Analytics blocks from API responses
Six ready blocks for your pages: tiles, line, sources, funnel, visibility and Pulse. Each is drawn from an analytics API response without libraries; a Chart.js variant is on a separate tab.
Shared base
All blocks share one CSS and a few helpers. An API key in the browser is not needed and is dangerous: the blocks read data from your server, which calls the analytics API - see the "Your own dashboard" example. A null in a response means "no data", not zero; the helpers account for it.
CSS
The --ab-* variables on the .ab root are what you recolour. Dark theme follows the system and data-theme.
.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; }
}JS helpers
// 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
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
The key figures of the period with the change against the previous period of equal length. A data gap is a dash, not a zero.
GET /dashboards/overview?blocks=kpi · scope dashboards:read
The block drawn from the example response below. The figures are made up.
Response JSON
Example: the figures are made up; the fields match the contract.
{
"data": {
"from": "2026-10-01",
"to": "2026-10-28",
"blocks": {
"kpi": {
"availability": "ok",
"data_as_of": { "g:search_console": "2026-10-25", "tracker": "2026-10-28", "crm": "2026-10-28" },
"tiles": [
{ "key": "impressions", "current": 182400, "prev": 171900, "delta_pct": 6.1 },
{ "key": "clicks", "current": 4210, "prev": 3880, "delta_pct": 8.5 },
{ "key": "sessions", "current": 3920, "prev": 3601, "delta_pct": 8.9 },
{ "key": "ai_people", "current": 212, "prev": 140, "delta_pct": 51.4 },
{
"key": "revenue",
"current": { "amount": "98000000.00", "currency": "IDR" },
"prev": { "amount": "77000000.00", "currency": "IDR" },
"delta_pct": 27.8
}
]
}
}
}
}HTML
<!-- The block container. Styles are from "Shared base"; the block code is included above. -->
<section class="ab" id="ab-tiles" aria-label="Metric tiles" aria-busy="true"></section>
<script src="blocks.js"></script>
<script>
var root = document.getElementById('ab-tiles');
// The address of your proxy: the API key stays on the server (see "Your own dashboard")
fetch('/analytics-api/overview?blocks=kpi')
.then(function (res) { if (!res.ok) throw new Error(String(res.status)); return res.json(); })
.then(function (body) { renderTiles(root, body.data.blocks.kpi.tiles); })
.catch(function () { root.textContent = 'Could not load the block'; })
.then(function () { root.removeAttribute('aria-busy'); });
</script>JS
// 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
One metric by day on inline SVG. Days without data break the line instead of pulling it to zero.
GET /analytics/timeseries · scope analytics:read
The block drawn from the example response below. The figures are made up.
Response JSON
Example: the figures are made up; the fields match the contract.
{
"data": {
"metric": "clicks",
"from": "2026-09-15",
"to": "2026-09-28",
"granularity": "day",
"agg": "sum",
"engine": "google",
"availability": "ok",
"reason": null,
"series": [
{ "date": "2026-09-15", "value": 388 },
{ "date": "2026-09-16", "value": 402 },
{ "date": "2026-09-17", "value": 371 },
{ "date": "2026-09-18", "value": 415 },
{ "date": "2026-09-19", "value": 440 },
{ "date": "2026-09-20", "value": 398 },
{ "date": "2026-09-21", "value": 352 },
{ "date": "2026-09-22", "value": 361 },
{ "date": "2026-09-23", "value": 409 },
{ "date": "2026-09-24", "value": 431 },
{ "date": "2026-09-25", "value": 452 },
{ "date": "2026-09-26", "value": 466 },
{ "date": "2026-09-27", "value": 447 },
{ "date": "2026-09-28", "value": 401 }
],
"comparison": { "current": 5783, "prev": 5210, "delta_pct": 11, "data_through": "2026-09-28" },
"summary": { "total": 5783, "latest": 401, "non_zero_days": 14 },
"cluster_applied": false,
"data_as_of": { "g:search_console": "2026-09-28" }
}
}HTML
<!-- The block container. Styles are from "Shared base"; the block code is included above. -->
<section class="ab" id="ab-timeseries" aria-label="Daily line" aria-busy="true"></section>
<script src="blocks.js"></script>
<script>
var root = document.getElementById('ab-timeseries');
// The address of your proxy: the API key stays on the server (see "Your own dashboard")
fetch('/analytics-api/timeseries?metric=clicks&from=2026-09-15&to=2026-09-28')
.then(function (res) { if (!res.ok) throw new Error(String(res.status)); return res.json(); })
.then(function (body) { renderTimeseries(root, body.data); })
.catch(function () { root.textContent = 'Could not load the block'; })
.then(function () { root.removeAttribute('aria-busy'); });
</script>JS
// 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);
}Chart.js
Code only: Chart.js is not loaded on this page. Install it from npm.
// npm install chart.js
import Chart from 'chart.js/auto';
// num() comes from the blocks code (an API value: number, money or null). null draws a gap, not zero.
export function chartTimeseries(canvas, item) {
return new Chart(canvas, {
type: 'line',
data: {
labels: item.series.map(function (p) { return p.date; }),
datasets: [{
label: item.metric,
data: item.series.map(function (p) { return num(p.value); }),
borderColor: '#2d6a4f', backgroundColor: 'rgba(45, 106, 79, 0.14)',
fill: true, tension: 0.25, spanGaps: false, pointRadius: 2
}]
},
options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true } } }
});
}Traffic sources
Bars per source: sessions and leads. Search, AI assistants, social, ads and direct visits.
GET /analytics/traffic-sources · scope analytics:read
The block drawn from the example response below. The figures are made up.
Response JSON
Example: the figures are made up; the fields match the contract.
{
"data": [
{ "source": "google", "kind": "search", "sessions": 820, "visitors": 640, "leads": 9 },
{ "source": "direct", "kind": "direct", "sessions": 410, "visitors": 330, "leads": 2 },
{ "source": "yandex", "kind": "search", "sessions": 260, "visitors": 214, "leads": 3 },
{
"source": "chatgpt",
"kind": "ai_assistant",
"sessions": 190,
"visitors": 150,
"leads": 4
},
{ "source": "social", "kind": "social", "sessions": 96, "visitors": 88, "leads": 1 }
],
"data_as_of": { "tracker": "2026-10-07" }
}HTML
<!-- The block container. Styles are from "Shared base"; the block code is included above. -->
<section class="ab" id="ab-traffic-sources" aria-label="Traffic sources" aria-busy="true"></section>
<script src="blocks.js"></script>
<script>
var root = document.getElementById('ab-traffic-sources');
// The address of your proxy: the API key stays on the server (see "Your own dashboard")
fetch('/analytics-api/traffic-sources?from=2026-09-15&to=2026-09-28')
.then(function (res) { if (!res.ok) throw new Error(String(res.status)); return res.json(); })
.then(function (body) { renderSources(root, body.data); })
.catch(function () { root.textContent = 'Could not load the block'; })
.then(function () { root.removeAttribute('aria-busy'); });
</script>JS
// 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);
}Chart.js
Code only: Chart.js is not loaded on this page. Install it from npm.
// npm install chart.js
import Chart from 'chart.js/auto';
// num() comes from the blocks code (an API value: number, money or null). null draws a gap, not zero.
export function chartSources(canvas, rows) {
return new Chart(canvas, {
type: 'bar',
data: {
labels: rows.map(function (r) { return r.source; }),
datasets: [
{ label: 'sessions', data: rows.map(function (r) { return r.sessions; }), backgroundColor: '#2d6a4f' },
{ label: 'leads', data: rows.map(function (r) { return r.leads; }), backgroundColor: '#c9a96e' }
]
},
options: { indexAxis: 'y', responsive: true, scales: { x: { beginAtZero: true } } }
});
}Funnel
Steps from impressions to won deals. The client computes the share of the previous step; a step without data is marked.
GET /analytics/funnel · scope analytics:read
The block drawn from the example response below. The figures are made up.
Response JSON
Example: the figures are made up; the fields match the contract.
{
"data": {
"engine": "google",
"from": "2026-10-01",
"to": "2026-10-28",
"steps": [
{
"key": "impressions",
"availability": "ok",
"sources": ["g:search_console"],
"unit": "count",
"current": 182400,
"prev": 171900,
"delta_pct": 6.1,
"data_through": "2026-10-25"
},
{
"key": "clicks",
"availability": "ok",
"sources": ["g:search_console"],
"unit": "count",
"current": 4210,
"prev": 3880,
"delta_pct": 8.5,
"data_through": "2026-10-25"
},
{
"key": "sessions",
"availability": "ok",
"sources": ["tracker"],
"unit": "count",
"current": 3920,
"prev": 3601,
"delta_pct": 8.9,
"data_through": "2026-10-28"
},
{
"key": "leads",
"availability": "ok",
"sources": ["crm"],
"unit": "count",
"current": 112,
"prev": 94,
"delta_pct": 19.1,
"data_through": "2026-10-28"
},
{
"key": "deals_created",
"availability": "no_data",
"sources": ["crm"],
"unit": "count",
"current": null,
"prev": null,
"delta_pct": null,
"data_through": null
},
{
"key": "deals_won",
"availability": "ok",
"sources": ["crm"],
"unit": "count",
"current": 17,
"prev": 12,
"delta_pct": 41.7,
"data_through": "2026-10-28"
},
{
"key": "revenue",
"availability": "ok",
"sources": ["crm"],
"unit": "money",
"current": { "amount": "98000000.00", "currency": "IDR" },
"prev": { "amount": "77000000.00", "currency": "IDR" },
"delta_pct": 27.8,
"data_through": "2026-10-28"
}
],
"by_channel": [],
"by_product": [],
"crm": { "availability": "ok" }
},
"data_as_of": { "g:search_console": "2026-10-25", "crm": "2026-10-28" }
}HTML
<!-- The block container. Styles are from "Shared base"; the block code is included above. -->
<section class="ab" id="ab-funnel" aria-label="Funnel" aria-busy="true"></section>
<script src="blocks.js"></script>
<script>
var root = document.getElementById('ab-funnel');
// The address of your proxy: the API key stays on the server (see "Your own dashboard")
fetch('/analytics-api/funnel?from=2026-09-15&to=2026-09-28')
.then(function (res) { if (!res.ok) throw new Error(String(res.status)); return res.json(); })
.then(function (body) { renderFunnel(root, body.data); })
.catch(function () { root.textContent = 'Could not load the block'; })
.then(function () { root.removeAttribute('aria-busy'); });
</script>JS
// 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);
}Chart.js
Code only: Chart.js is not loaded on this page. Install it from npm.
// npm install chart.js
import Chart from 'chart.js/auto';
// num() comes from the blocks code (an API value: number, money or null). null draws a gap, not zero.
export function chartFunnel(canvas, data) {
// Count steps with data only: money and unavailable steps do not share one scale.
var steps = data.steps.filter(function (s) { return s.availability === 'ok' && s.unit === 'count'; });
return new Chart(canvas, {
type: 'bar',
data: {
labels: steps.map(function (s) { return s.key; }),
datasets: [{ label: 'count', data: steps.map(function (s) { return num(s.current); }), backgroundColor: '#2d6a4f' }]
},
options: { indexAxis: 'y', responsive: true, plugins: { legend: { display: false } }, scales: { x: { type: 'logarithmic' } } }
});
}Visibility
Five sections, each with its own availability: a section may have no data or no source, and the block shows it.
GET /analytics/visibility · scope analytics:read
Query positions
- Top 318
- Top 1074
- Top 30210
- Below530
The block drawn from the example response below. The figures are made up.
Response JSON
Example: the figures are made up; the fields match the contract.
{
"data": {
"from": "2026-10-01",
"to": "2026-10-28",
"engine": "google",
"share": {
"availability": "ok",
"source": "g:search_console",
"data_as_of": { "g:search_console": "2026-10-25" },
"data": {
"value": 14.2,
"comparison": { "current": 14.2, "prev": 12.9, "delta_pct": 10.1 },
"keywords": 312,
"volume_total": 48200
}
},
"distribution": {
"availability": "ok",
"source": "g:search_console",
"data_as_of": { "g:search_console": "2026-10-25" },
"data": {
"top3": 18,
"top10": 74,
"top30": 210,
"outside": 530,
"total": 832,
"median": 21.5,
"average": 29.3
}
},
"aeo": {
"availability": "ok",
"source": "aeo",
"data_as_of": { "aeo": "2026-10-27" },
"data": {
"engines": [
{ "engine": "perplexity", "probes": 24, "cited": 3, "rate": 12.5 },
{ "engine": "chatgpt", "probes": 24, "cited": 2, "rate": 8.3 }
],
"probes": 48,
"cited": 5,
"rate": 10.4,
"comparison": { "current": 10.4, "prev": 9, "delta_pct": 15.6 }
}
},
"regions": {
"availability": "not_connected",
"reason": "no_country_dimension",
"source": "g:search_console",
"data_as_of": {},
"data": { "rows": [] }
}
}
}HTML
<!-- The block container. Styles are from "Shared base"; the block code is included above. -->
<section class="ab" id="ab-visibility" aria-label="Visibility" aria-busy="true"></section>
<script src="blocks.js"></script>
<script>
var root = document.getElementById('ab-visibility');
// The address of your proxy: the API key stays on the server (see "Your own dashboard")
fetch('/analytics-api/visibility?from=2026-09-15&to=2026-09-28')
.then(function (res) { if (!res.ok) throw new Error(String(res.status)); return res.json(); })
.then(function (body) { renderVisibility(root, body.data); })
.catch(function () { root.textContent = 'Could not load the block'; })
.then(function () { root.removeAttribute('aria-busy'); });
</script>JS
// 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);
}Chart.js
Code only: Chart.js is not loaded on this page. Install it from npm.
// npm install chart.js
import Chart from 'chart.js/auto';
// num() comes from the blocks code (an API value: number, money or null). null draws a gap, not zero.
export function chartVisibility(canvas, dist) {
var d = dist.data; // data.distribution.data: top3, top10, top30, outside (exclusive, they sum to total)
return new Chart(canvas, {
type: 'doughnut',
data: {
labels: ['top3', 'top10', 'top30', 'outside'],
datasets: [{ data: [d.top3, d.top10, d.top30, d.outside], backgroundColor: ['#1a3c2a', '#2d6a4f', '#7fb89b', '#cfd8d2'] }]
},
options: { responsive: true, cutout: '62%' }
});
}"Pulse" findings
A short list of what was noticed over the last days: what grew, what fell and what is worth checking.
GET /dashboards/pulse · scope dashboards:read
New in 7 days: 2
- Ниацинамид и пористостьниацинамид для пор · demand 3,400×2.4
- Пептиды в сывороткахпептидная сыворотка состав · demand 1,900—
The block drawn from the example response below. The figures are made up.
Response JSON
Example: the figures are made up; the fields match the contract.
{
"data": {
"period": { "from": "2026-09-13", "to": "2026-10-10" },
"new_7d": 2,
"truncated": false,
"alerts": [
{
"id": "alert_EXAMPLE_01",
"topic_id": "topic_EXAMPLE_01",
"topic_name": "Ниацинамид и пористость",
"keyword": "ниацинамид для пор",
"horizon": "short",
"status": "open",
"episode_start": "2026-10-04",
"last_seen": "2026-10-10",
"score": 0.82,
"volume": 3400,
"growth": 2.4,
"sources": [{ "code": "serp_suggest", "outcome": "spike", "value": 120, "base": 50 }],
"strategist_topic": { "id": "tpc_EXAMPLE_01", "status": "proposed" }
},
{
"id": "alert_EXAMPLE_02",
"topic_id": "topic_EXAMPLE_02",
"topic_name": "Пептиды в сыворотках",
"keyword": "пептидная сыворотка состав",
"horizon": "mid",
"status": "open",
"episode_start": "2026-10-06",
"last_seen": "2026-10-10",
"score": 0.64,
"volume": 1900,
"growth": null,
"sources": [{ "code": "serp_suggest", "outcome": "spike", "value": 40, "base": 0 }],
"strategist_topic": null
}
],
"data_as_of": { "pulse": "2026-10-10" }
}
}HTML
<!-- The block container. Styles are from "Shared base"; the block code is included above. -->
<section class="ab" id="ab-pulse" aria-label=""Pulse" findings" aria-busy="true"></section>
<script src="blocks.js"></script>
<script>
var root = document.getElementById('ab-pulse');
// The address of your proxy: the API key stays on the server (see "Your own dashboard")
fetch('/analytics-api/pulse')
.then(function (res) { if (!res.ok) throw new Error(String(res.status)); return res.json(); })
.then(function (body) { renderPulse(root, body.data); })
.catch(function () { root.textContent = 'Could not load the block'; })
.then(function () { root.removeAttribute('aria-busy'); });
</script>JS
// "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);
}Next
Put the blocks into a page with a server proxy - your own dashboard. Fields and scopes are in the dashboards description.