AFN AFN Widgets
Gallery

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.

PropertyTypeWhat 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

FieldWhat 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 series and value come back null and rows holds 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

VarsEffectBest 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.

URLBehaviourUse 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

SymptomCause
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.