Integration guide
How to put an AFN widget into your app. 81 widgets, one integration pattern,
no SDK and no npm dependency — a widget is a single JavaScript module you point a
script tag at.
How it works
A widget is a standard web component. Your page loads one module, drops one custom element, and assigns the rows. Nothing else is required — no build step, no wrapper library, no framework in particular.
You own the data
Widgets never fetch anything. You query whatever source you like and hand over an array of rows. Nothing about your data passes through us.
We own the rendering
Charts, layout, responsiveness, light and dark themes, formatting, empty and error states. You pass numbers; the widget decides how they look.
The contract is the seam
Each widget declares the columns it expects. Match those column names and it renders. That contract is what versioning protects.
Quick start
All three of these are working today against this deployment. Every widget page has the same snippets pre-filled with that widget's own columns and sample rows — start from there. If you are picking a first widget, Pull-Through is the one we suggest.
<!-- 1. Load the widget. It self-registers <afn-pullthrough>. -->
<script type="module" src="https://widgets-d.afnai.com/cdn/afn-pullthrough/v1/afn-pullthrough.js"></script>
<!-- 2. Drop the tag wherever you want it. -->
<afn-pullthrough id="w"></afn-pullthrough>
<!-- 3. Feed it rows. -->
<script>
const el = document.querySelector('#w');
el.data = [
{ Category: 'Started', BranchCount: 235, PeerGroupCount: 1120, CompanyCount: 16491 },
{ Category: 'Credit Pulled', BranchCount: 195, PeerGroupCount: 946, CompanyCount: 13720 },
{ Category: 'Funded', BranchCount: 71, PeerGroupCount: 340, CompanyCount: 3253 }
];
el.params = { cohort: 'BranchCount' };
</script>
Properties, not attributes. el.data = rows works because data
is a JavaScript property; an HTML attribute could only ever carry a string.
The contract
Three properties, and only the first is required.
| Property | Type | What it is |
|---|---|---|
| .data | array of objects | Flat rows. Keys are the column names listed on the widget's page. Extra keys are ignored. |
| .params | object | Widget-specific display choices, such as which cohort a funnel shows. Most widgets have none. |
| .options | object | Presentation. theme: 'light' | 'dark' is the one you will use. |
Assigning any of them re-renders. Sizing is automatic — the widget observes its own container, so give the element a width and it fills it.
The contract is readable at runtime, which is handy in tests:
// Every widget declares its own contract as a static property. This is the
// authoritative list of columns -- the same one shown on each widget's page.
const contract = customElements.get('afn-pullthrough').contract;
contract.version // '1.0.0'
contract.data // [{ name: 'Category', type: 'string', required: true }, ...]
contract.params // [{ name: 'cohort', type: 'enum', values: [...], default: ... }]
Three events are emitted:
const el = document.querySelector('afn-pullthrough');
// Fired once the widget has rendered with the data you gave it.
el.addEventListener('ready', e => console.log('rendered', e.detail));
// Fired instead of 'ready' when the rows do not satisfy the contract. The
// widget shows its own error card, so you do not have to handle this.
el.addEventListener('error', e => console.warn('widget error', e.detail));
// Fired when the user clicks a mark. See "Clicks and drill-down" below.
el.addEventListener('widget-select', e => console.log('clicked', e.detail));
// A widget also declares what it emits, so you can check at runtime:
el.constructor.contract.events; // ['ready', 'error', 'widget-select']
Clicks and drill-down
Widgets do not navigate anywhere or open anything by themselves — what a click
means belongs to your app. Every widget reports the click through one event,
widget-select,
and you decide what happens: route to a detail page, open a drawer, filter a sibling
widget, log it.
const el = document.querySelector('afn-pullthrough');
el.addEventListener('widget-select', (e) => {
const { label, value, formatted, series, index, rows, params } = e.detail;
// `rows` holds the ORIGINAL row objects you fed in -- the same object
// references, not copies -- so anything extra you carried on a row
// (an id, a URL, a foreign key) is still there to drill down with.
const loanId = rows[0].YourOwnIdColumn;
window.location = `/loans?stage=${encodeURIComponent(label)}&id=${loanId}`;
});
// The event bubbles and crosses the shadow boundary, so one listener on a
// container works for a whole dashboard of widgets:
dashboard.addEventListener('widget-select', (e) => route(e.detail));
What the payload carries
| Field | What it is |
|---|---|
| rows | The row objects you fed in, by reference. Usually one. This is the field to build drill-down on — your own id columns are still attached. |
| label | What was clicked, as shown: a category, slice name, state, county, date, tile caption. |
| value | The number behind that mark. null in the one case noted below. |
| formatted | The same number as the widget printed it ($2.1M, 19.7%), so your UI can match without re-implementing the formatting. |
| series | Which series, on a grouped, stacked or dual-axis chart. null on single-series widgets. On a funnel this is the cohort column on screen. |
| index | Position of the mark within the widget. |
| widget, type | Tag name and chart type — useful when one listener serves a whole dashboard. |
| params, nativeEvent | The widget's current params, and the underlying mouse event if you need cursor position for a context menu. |
Making it look clickable
// The event fires whether or not you set this. `interactive` only turns on
// the hover/focus affordance -- a widget nobody listens to should not look
// clickable, so it is off by default. Set it when you ARE handling clicks.
el.options = { interactive: true };
On the card and leaderboard widgets this also makes each tile or row a real button — focusable, and activated by Enter or Space — so drill-down is not mouse-only. Chart marks are not individually keyboard-reachable; if that matters for your audience, keep a non-chart route to the same destination.
Two behaviours worth knowing
-
Line and area charts report the category, not one point.
A line is a couple of pixels wide, so a click anywhere in the plot area resolves
to the nearest category — the same thing its tooltip already does. On a
multi-series line that means
seriesandvaluecome backnullandrowsholds every series' row for that category, because adding unrelated series together (ratings, turn times) would be meaningless. Clicking exactly on a point marker still gives you the precise series. - Gauges and activity rings resolve by position. A gauge has one datum, so anywhere on the dial reports that metric. Rings report whichever concentric band you clicked; the gap between bands and the hole in the middle report nothing at all.
Colours
Every colour in every widget can be replaced from your own stylesheet. You do not have to — leave it alone and you get the AFN palette, which is the right answer for most internal apps. But if the widget has to sit inside your brand, set custom properties on the element and it will follow.
/* Set as many or as few as you like. Anything you leave out keeps the
widget's own default, so a partial override still looks coherent. */
afn-pullthrough {
/* chart: one monochrome scale, interpolated to whatever length the
chart needs -- 9 funnel stages, a choropleth gradient, a gauge arc */
--afnw-ramp-from: #fed7aa;
--afnw-ramp-to: #7c2d12;
/* ...or control discrete series individually instead */
/* --afnw-series-1: #f0abfc; --afnw-series-2: #e879f9; */
/* card */
--afnw-surface: #fffbf5;
--afnw-ink: #431407;
--afnw-muted: #a8734a;
--afnw-accent: #c2410c;
}
Each widget's page generates this block for you, pre-filled with that widget's own current values — switch Colours to add my brand in the embed panel. That is the easiest way to start, since the var set differs by chart type.
Chart colours: two ways
| Vars | Effect | Best for |
|---|---|---|
| --afnw-ramp-from --afnw-ramp-to |
One monochrome scale, interpolated to whatever length the chart needs. Takes priority over the series vars. | Anything sequential — funnel stages, choropleth, calendar heatmap, gauge arc, gradient bars. Also the quickest way to put a whole widget in one brand hue. |
| --afnw-series-1 … --afnw-series-9 |
One colour per series. Any you leave unset keep their default. | Categorical charts where the colours mean something — pie slices, grouped or stacked bars, multiple lines, activity rings. |
Card colours
| --afnw-surface --afnw-surface-2 | Card background, and inset panels such as KPI tiles |
| --afnw-ink --afnw-ink-2 --afnw-muted | Primary text, secondary text, and labels or axis text |
| --afnw-line --afnw-line-2 | Gridlines and the card border |
| --afnw-accent --afnw-accent-deep | The bar across the top of the card, and highlights |
| --afnw-warn-bg --afnw-insight-ink | The insight strip's background and its text |
| --afnw-track --afnw-bar --afnw-tile | Progress rails, ranked-list bars, KPI tile backgrounds |
Without CSS
// Equivalent to the CSS above, for a brand colour that arrives at
// runtime rather than sitting in a stylesheet.
el.options = {
colors: { 'ramp-from': '#fed7aa', 'ramp-to': '#7c2d12', ink: '#431407' }
};
Two things to know. Overriding a text colour without also overriding the
background it sits on is the one way to make a widget unreadable — if you
darken --afnw-ink, set --afnw-surface too. And the
medal colours on ranked lists stay gold, silver and bronze on purpose; those are
meaning, not decoration.
Where your data comes from
Entirely your call. A widget cannot tell whether its rows came from SQL, a REST call, a cache or a hard-coded fixture — it only checks that the columns are there. So use whatever your app already uses, and shape the result to the contract.
If you are sourcing from the existing Command Center procedures
Expect to write a transform. Those procedures were built to feed the old chart library, so they return presentation rather than data. Specifically:
- Numbers often arrive pre-formatted as strings, such as
'$1.2 MM'. Widgets want a number and do their own formatting. - Several return one wide row (
Category1…4,Goal1…4) where the widget wants one row per item. - Some carry presentation columns — hex colours, arrow directions, axis minimums — that widgets ignore.
- Several are slow. Cache the result; do not call one on every page load.
Also check before reaching for the v_* view behind a procedure: the
ones we have examined are per-loan detail, with the procedure applying the
aggregation, the role scoping and the business rules. Where that holds, querying
the view directly will not reproduce the numbers.
Versions
Every widget URL carries a version. You choose how much movement you accept.
| URL | Behaviour | Use it when |
|---|---|---|
| /cdn/<tag>/v1/… | Newest 1.x. Picks up fixes and improvements automatically. Never a breaking change. | Default. This is what we recommend. |
| /cdn/<tag>/1.0.0/… | Frozen. These exact bytes, cached for a year, forever. | You need a byte-stable dependency for audit or release reasons. |
| /cdn/<tag>/latest/… | Newest anything, including the next major. | Never, in an app. It exists for previewing in this gallery. |
A major bump means the data contract changed —
a new required column, a rename, a different row shape. That is the only thing that can
break your integration, and it can never reach you through a vN URL: a new
major gets a new URL, and yours keeps working. Patch and minor releases are exactly the
promise that your existing rows keep rendering.
Before you embed: tell us your origin
Widgets only load on approved origins. Send us the scheme, host and port your app
is served from — for example https://tile.afncc.com — and we
add it before you start. It is one configuration setting on our side, applied in
seconds, with no deployment.
Until then every module request from your app is refused and no widget renders.
If widgets work in a plain HTML file but not in your app, this is almost always why.
Scheme, host and port all have to match, so localhost and
127.0.0.1 count as different origins — though local development
on any port of either is already allowed, so you can try widgets out
without asking us for anything.
When something breaks
| Symptom | Cause |
|---|---|
| Nothing renders, console shows a failed module request | Your origin is not allowlisted. See above. |
| The element stays empty with no error | You assigned .data before the module finished loading. Await customElements.whenDefined(tag) first. |
| The widget shows its own error card | The rows do not match the contract, usually a column name. Compare against the widget's page; names are case-sensitive. |
| Data renders but the card looks unstyled | Nothing to fix. Widgets carry their own styles in a shadow root, so your CSS neither leaks in nor is needed. |
| A map draws its card and numbers but no map | Map geometry is fetched separately at runtime and that request failed. Everything else renders first by design. |
| Clicking a mark does nothing | Nothing to fix in the widget. Clicks are reported, never acted on — your app has to handle widget-select and decide what a click means. |
A click on a line chart returns value: null |
Expected on a multi-series line: the click resolved to a category, so every series' row is in rows. See above. |
| Hydration error in Next.js | A custom element rendered during SSR. Make it client-only. |
| Opening an HTML file directly does nothing | ES modules cannot load over file://. Serve the page over HTTP. |
One note on page weight: widgets share a single copy of their charting library, fetched once per page from this host regardless of how many widgets you embed. Twenty widgets on a page cost roughly one library download, not twenty.