v2.6.0

<bmx-driver-tree>

A KPI driver tree and what-if model: a result - operating profit, cash, cost per case, yield - broken down into the levers that drive it, each an input or a formula over other drivers.

8 properties · 4 events · 7 methods · 4 parts

Example

Choose a blue-edged input and move its slider, or type a value: every driver above it is worked out again and the change shows in green or red against the comparison. Sensitivity ranks the inputs by how much they move profit; Bridge walks from plan to actual one driver at a time.

Show markup
<div role="group" aria-label="Try it" style="display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 12px; align-items: center">
  <button type="button" id="ex-dt-plan">Plan against actual</button>
  <button type="button" id="ex-dt-upside">Upside against plan</button>
  <button type="button" id="ex-dt-goal">What conversion makes £30,000 profit?</button>
  <button type="button" id="ex-dt-bridge">Explain the gap</button>
  <label style="display: inline-flex; gap: 6px; align-items: center"><input type="checkbox" id="ex-dt-edit" /> Edit the model</label>
</div>
<bmx-driver-tree id="ex-dt" label="Lantern &amp; Loom, October 2026" style="--bmx-driver-tree-height: 36rem"></bmx-driver-tree>
<p class="note" id="ex-dt-out" role="status" style="margin: 12px 0 0">Choose a blue-edged input and move its slider, or type a value: every driver above it is worked out again and the change shows in green or red against the comparison. Sensitivity ranks the inputs by how much they move profit; Bridge walks from plan to actual one driver at a time.</p>

<script type="module">
  await customElements.whenDefined('bmx-driver-tree');
  const tree = document.getElementById('ex-dt');
  const out = document.getElementById('ex-dt-out');

  // A fictional online homeware shop's monthly profit. Advertising spend drives
  // paid visits and is a cost, and orders drive both sales and fulfilment, so
  // each appears twice in the tree.
  tree.model = {
    title: 'Operating profit',
    root: 'profit',
    currency: 'GBP',
    locale: 'en-GB',
    nodes: [
      { id: 'profit', name: 'Operating profit', formula: 'gross_profit - operating_costs', format: 'currency', description: 'What is left after the goods and the running costs.' },
      { id: 'gross_profit', name: 'Gross profit', formula: 'net_sales * gross_margin', format: 'currency' },
      { id: 'net_sales', name: 'Net sales', formula: 'gross_sales * (1 - return_rate)', format: 'currency' },
      { id: 'gross_sales', name: 'Gross sales', formula: 'orders * aov', format: 'currency' },
      { id: 'orders', name: 'Orders', formula: 'sessions * conversion', format: 'count' },
      { id: 'sessions', name: 'Sessions', formula: 'organic_sessions + paid_sessions', format: 'count' },
      { id: 'organic_sessions', name: 'Organic sessions', value: 180000, format: 'count', min: 120000, max: 240000, step: 1000 },
      { id: 'paid_sessions', name: 'Paid sessions', formula: 'ad_spend / cost_per_click', format: 'count' },
      { id: 'ad_spend', name: 'Advertising spend', value: 42000, format: 'currency', min: 20000, max: 70000, step: 500, better: 'down' },
      { id: 'cost_per_click', name: 'Cost per click', value: 0.7, format: 'currency', decimals: 2, min: 0.4, max: 1.2, step: 0.01, better: 'down' },
      { id: 'conversion', name: 'Conversion rate', value: 0.021, format: 'percent', decimals: 2, min: 0.012, max: 0.032, step: 0.0005 },
      { id: 'aov', name: 'Average order value', value: 64, format: 'currency', decimals: 2, min: 50, max: 80, step: 0.5 },
      { id: 'return_rate', name: 'Return rate', value: 0.08, format: 'percent', decimals: 1, min: 0.04, max: 0.15, step: 0.005, better: 'down' },
      { id: 'gross_margin', name: 'Gross margin', value: 0.52, format: 'percent', decimals: 1, min: 0.45, max: 0.6, step: 0.005 },
      { id: 'operating_costs', name: 'Operating costs', formula: 'ad_spend + fulfilment + fixed_costs', format: 'currency', better: 'down' },
      { id: 'fulfilment', name: 'Fulfilment', formula: 'orders * cost_per_order', format: 'currency', better: 'down' },
      { id: 'cost_per_order', name: 'Cost per order', value: 6.5, format: 'currency', decimals: 2, min: 5, max: 9, step: 0.05, better: 'down' },
      { id: 'fixed_costs', name: 'Fixed costs', value: 58000, format: 'currency', min: 50000, max: 66000, step: 500, better: 'down', description: 'Rent, salaries, software and the warehouse lease.' },
    ],
    scenarios: [
      { id: 'upside', name: 'Upside', values: { conversion: 0.024, aov: 66, ad_spend: 48000 } },
      { id: 'downside', name: 'Downside', values: { conversion: 0.018, return_rate: 0.11, cost_per_click: 0.85 } },
    ],
    actuals: { organic_sessions: 171500, ad_spend: 45200, cost_per_click: 0.74, conversion: 0.0228, aov: 61.4, return_rate: 0.093, gross_margin: 0.505, cost_per_order: 6.8, fixed_costs: 57200 },
  };
  tree.scenario = 'actual';
  tree.compare = 'plan';
  tree.selected = 'profit';

  const money = n => n.toLocaleString('en-GB', { style: 'currency', currency: 'GBP', maximumFractionDigits: 0 });

  document.getElementById('ex-dt-plan').addEventListener('click', () => {
    tree.view = 'tree';
    tree.scenario = 'actual';
    tree.compare = 'plan';
  });
  document.getElementById('ex-dt-upside').addEventListener('click', () => {
    tree.view = 'tree';
    tree.scenario = 'upside';
    tree.compare = 'plan';
  });
  document.getElementById('ex-dt-goal').addEventListener('click', async () => {
    tree.view = 'tree';
    tree.scenario = 'plan';
    tree.compare = '';
    const result = await tree.goalSeek('profit', 30000, 'conversion', true);
    out.textContent = result.ok
      ? `goalSeek(): a conversion rate of ${(result.value * 100).toFixed(2)}% gives ${money(result.achieved)} profit - applied as a what-if.`
      : `goalSeek(): ${result.message}`;
  });
  document.getElementById('ex-dt-bridge').addEventListener('click', () => {
    tree.scenario = 'actual';
    tree.compare = 'plan';
    tree.view = 'bridge';
  });
  document.getElementById('ex-dt-edit').addEventListener('change', e => (tree.editable = e.target.checked));

  tree.addEventListener('bmxValuesChange', e => {
    const profit = e.detail.values.profit;
    if (Number.isFinite(profit)) out.textContent = `bmxValuesChange: operating profit ${money(profit)} for ${e.detail.scenario}.`;
  });
  tree.addEventListener('bmxChange', e => (out.textContent = `bmxChange (${e.detail.reason}): the model has ${e.detail.model.nodes.length} drivers and ${e.detail.model.scenarios.length} scenarios.`));
</script>

Tree the result on the left, its drivers to the right, each card with its value and its change against plan, actuals or another scenario (green when it is good news, red when not, by each driver's own direction); fold branches; drag a slider on any input and watch every result move. Sensitivity a tornado of every lever's effect on a result. Bridge why actual (or a scenario) differs from plan: the change in a result shared fairly between the inputs that moved (Shapley values), as a waterfall. Scenarios plan, scenarios, actuals and the current what-if side by side, inputs and results, with each scenario editable. Goal seek what an input must be for a result to reach a goal.

<bmx-driver-tree id="dt"></bmx-driver-tree>
<script>
  dt.model = { root: 'profit', currency: 'GBP', nodes: [
    { id: 'profit', name: 'Profit', formula: 'revenue - costs', format: 'currency' },
    { id: 'revenue', name: 'Revenue', formula: 'units * price', format: 'currency' },
    { id: 'units', name: 'Units', value: 1200, format: 'count', min: 800, max: 2000 },
    { id: 'price', name: 'Price', value: 25, format: 'currency' },
    { id: 'costs', name: 'Costs', value: 18000, format: 'currency', better: 'down' } ] };
</script>

With editable, drivers are added, renamed, given formulas and deleted in the page, and the whole model arrives in bmxChange. Formulas are parsed and evaluated here - never run as code.

Accessibility: the tree is a tree (arrow keys move through it, Space folds,

  • and - nudge an input); each card says its value and change in words; every chart has its figures as a table.

Properties

PropertyAttributeTypeDefaultDescription
compare compare string — What each value is compared with: plan, actual, a scenario's id, or empty for nothing. Left unset, anything but the plan is compared with the plan. Mutable.
editable editable boolean false Drivers can be added, changed and deleted.
label label string — The accessible name. Default: the model's title.
model model BmxDriverModel | Record<string, unknown> | string — The model: { title, root, currency, nodes, scenarios, actuals }, or JSON. Mutable: edits replace it.
scenario scenario string 'plan' What is shown: plan, actual, or a scenario's id. Mutable.
selected selected string — The chosen driver. Mutable.
strings strings Partial<BmxDriverTreeStrings> | string — Words to show instead of the English ones, as an object or JSON.
view view BmxDriverTreeView 'tree' The view. Mutable.

Events

EventDetailDescription
bmxChange BmxDriverChangeDetail Fired when the model is edited, with all of it.
bmxExport BmxDriverExportDetail Fired when an export is made from the Export menu. Cancel it to keep it from downloading.
bmxSelect BmxDriverSelectDetail Fired when another driver is chosen.
bmxValuesChange BmxDriverValuesDetail Fired when the inputs change, with every driver's value.

Methods

MethodSignatureDescription
getModel getModel() => Promise<BmxDriverModel> The model as it stands.
getValues getValues(scenario?: string) => Promise<Record<string, number>> Every driver's value: for the scenario showing (with the what-if), or for another.
goalSeek goalSeek(target: string, goal: number, input: string, apply?: boolean) => Promise<BmxDriverGoalSeek> Finds the value of input that makes target reach goal; with apply, sets it.
resetInputs resetInputs() => Promise<void> Clears the what-if.
saveScenario saveScenario(name: string, id?: string) => Promise<string> Saves the current inputs as a scenario (new, or replacing one with the same id); returns its id.
select select(id: string) => Promise<void> Chooses a driver.
setInputs setInputs(values: Record<string, number>) => Promise<void> Sets inputs on top of the scenario showing (a what-if).

CSS shadow parts

PartDescription
card A driver's card.
panel The details of the chosen driver.
toolbar The tools.
tree The tree view.

CSS custom properties

PropertyDescription
--bmx-driver-tree-accent Inputs, the chosen driver and the base line.
--bmx-driver-tree-bad Changes that are bad news.
--bmx-driver-tree-good Changes that are good news.
--bmx-driver-tree-height The height of the tree view.
--bmx-driver-tree-panel-width The width of the details panel.
--bmx-driver-tree-radius Corner radius of the element.