Skip to main content
Showcases
Muze Showcase · 2026-07-17

Risk Matrix · Impact × Likelihood

A classic 5×5 risk matrix with colored zone cells built from range bars, item scatter on top, and a ranked companion table, built with Muze.

Loading viz…

What you’re looking at

A risk matrix places items on a grid of impact versus likelihood, the staple of project and operational risk reviews. Each cell is assigned a zone (Low, Medium, High, or Critical) by the classic risk score: the product of its 1-based impact and likelihood bands. Twelve projects, each scored 0–5 on both axes, are scattered on top; the companion table ranks the same items by that score and repeats the zone color as a badge, so the two views can never disagree.

The matrix rewards a quick read: anything drifting toward the top right deserves attention first. Because the zone bands derive from a single gridSize setting, the same chart switches to a 3×3 or 4×4 matrix by changing one number.

How it was built

One merged DataModel carries both record kinds, the generated zone cells and the queried items, tagged by a Record Type dimension, since DataModel has no union operation. Two canvas transforms split them back out, and three layers render them: range bars (with x0/y0 edges and size: 0 so the cells tile edge to edge) for the zone grid, a text layer that names each point by project (the pastel cell color already says the risk), and a point layer on top for the items.

Operators.share() lets the cell edges, cell midpoints, and item scores all ride the same pair of continuous axes, pinned to [0, gridSize]. A single zone-to-color map drives both the bar layer’s domainRangeMap and the table badges.

The Studio artifact additionally ships a useSampleData toggle, name-or-position column binding for the search query, and an optional normalizeToGrid mode that min-max rescales raw measures (revenue, counts, …) into the grid to show relative risk.

Take it with you

Paste these complete artifacts into Muze Studio. They render with built-in sample data; set settings.useSampleData = false to plot a search query (1 attribute + 2 measures with AVERAGE aggregation), binding columns by name in settings.columns when needed.

JavaScript

Self-contained chart: settings, query mapping with helpful error states, merged zone/item DataModel, table, and the three-layer render.

Preview
/**
 * Available Columns:
 * "region"
 * "Total sales"
 * "Measure names" // If 'measureValues' is enabled.
 * "Measure values" // If 'measureValues' is enabled.
 * --- END ---
 */


const { muze, getDataFromSearchQuery } = viz;
const DataModel = muze.DataModel;
const share = muze.Operators.share;

// IMP: If you have the datamodel, switch this sample data off, and access real data from getDataFromSearchQuery
const settings = {
  // true  = render sampleItems below and ignore the search query.
  // false = plot the search query (see the column rules up top).
  useSampleData: true,

  // Demo items used while useSampleData is true. Scores are 0-5.
  sampleItems: [
    { item: "Project A", impact: 0.4, likelihood: 3.5 },
    { item: "Project B", impact: 2.4, likelihood: 4.4 },
    { item: "Project C", impact: 4.5, likelihood: 4.4 },
    { item: "Project D", impact: 1.3, likelihood: 2.3 },
    { item: "Project E", impact: 1.7, likelihood: 2.6 },
    { item: "Project F", impact: 2.5, likelihood: 2.5 },
    { item: "Project G", impact: 1.8, likelihood: 1.8 },
    { item: "Project H", impact: 4.6, likelihood: 1.7 },
    { item: "Project I", impact: 1.4, likelihood: 0.8 },
    { item: "Project J", impact: 2.3, likelihood: 1.3 },
    { item: "Project K", impact: 3.7, likelihood: 3.6 },
    { item: "Project L", impact: 4.3, likelihood: 1.3 },
  ],

  // Bind query columns by name, or leave null to auto-pick (first
  // attribute = item, first measure = impact, second = likelihood).
  // Only used when useSampleData is false.
  columns: {
    item: null,        // e.g. "Project Name"
    impact: null,      // e.g. "Impact Score"
    likelihood: null,  // e.g. "Likelihood Score"
  },

  // Min-max rescale the two query measures into the grid band. Turn
  // this on to plot raw measures (revenue, counts, ...) instead of
  // 0-5 scores; positions then show RELATIVE risk across the queried
  // items, not absolute scores.
  normalizeToGrid: false,

  // Cells per side. The axis domains and the zone grid derive from
  // this, so this one number switches to any N x N matrix.
  gridSize: 5,

  // Zone bands, checked in order against the classic risk score:
  // impact band x likelihood band (both 1-based, so scores run from
  // 1 to gridSize^2). The last zone is the catch-all. Pastel cell
  // fills; badgeTextColor keeps the table badges readable on them.
  zones: [
    { name: "Low",      maxScore: 4,        color: "#6BBF95", badgeTextColor: "#1d4d36" },
    { name: "Medium",   maxScore: 10,       color: "#EEC24A", badgeTextColor: "#6a520f" },
    // High sits between the gold and coral anchors of the palette
    { name: "High",     maxScore: 16,       color: "#E89B60", badgeTextColor: "#743d13" },
    { name: "Critical", maxScore: Infinity, color: "#E07575", badgeTextColor: "#6e2020" },
  ],

  pointColor: "#312E81", // scatter points (white outline comes from the CSS tab)
  pointSize: 0.1,        // muze size value; the default (~0.06) draws a 4px-radius point
  labelColor: "#263238", // project labels above the points
  labelOffsetY: -15,     // pixels between a point and its label
};

/* --------------------------------------------------------------------
   2. GET THE ITEMS
   Either the built-in sample rows, or the search query mapped into
   { item, impact, likelihood } objects. Query problems render as a
   message in the chart area instead of a blank widget.
   -------------------------------------------------------------------- */

const fail = (message) => {
  const chartEl = document.getElementById("chart");
  if (chartEl) {
    chartEl.innerHTML =
      '<div class="rm-error"><strong>Risk Matrix &mdash; check your search query</strong>' +
      "<p>" + message + "</p></div>";
  }
  throw new Error(message);
};

let items;
let itemCol = "Project";
let impactCol = "Impact";
let likelihoodCol = "Likelihood";

if (settings.useSampleData) {
  items = settings.sampleItems;
} else {
  const queryOutput = getDataFromSearchQuery().getData();
  const querySchema = queryOutput.schema;

  // ThoughtSpot pseudo-columns and DataModel internals (e.g. __id__);
  // never auto-picked as data columns
  const PSEUDO_COLUMNS = ["Measure names", "Measure values"];
  const isInternal = (f) =>
    PSEUDO_COLUMNS.includes(f.name) || f.name.startsWith("__");

  const attributes = querySchema.filter(
    (f) => f.type === "dimension" && !isInternal(f)
  );
  const measures = querySchema.filter(
    (f) => f.type === "measure" && !isInternal(f)
  );

  const queryShape = [...attributes, ...measures]
    .map((f) => '"' + f.name + '" (' + (f.type === "dimension" ? "attribute" : "measure") + ")")
    .join(", ");

  // One column: by name when configured in settings, else by position
  const resolveColumn = (configuredName, pool, position, role) => {
    if (configuredName) {
      const match = querySchema.find((f) => f.name === configuredName);
      if (!match) {
        fail(
          'settings.columns.' + role + ' is set to "' + configuredName +
          '", but the query has no such column. Query columns: ' + queryShape
        );
      }
      return match.name;
    }
    if (!pool[position]) {
      fail(
        "This chart needs 1 attribute (the item) + 2 measures (impact and likelihood, " +
        "0-" + settings.gridSize + " scores with AVERAGE aggregation). " +
        "Query columns: " + queryShape
      );
    }
    return pool[position].name;
  };

  itemCol = resolveColumn(settings.columns.item, attributes, 0, "item");
  impactCol = resolveColumn(settings.columns.impact, measures, 0, "impact");
  likelihoodCol = resolveColumn(settings.columns.likelihood, measures, 1, "likelihood");

  const colIdx = (name) => querySchema.findIndex((f) => f.name === name);
  const itemIdx = colIdx(itemCol);
  const impactIdx = colIdx(impactCol);
  const likelihoodIdx = colIdx(likelihoodCol);

  items = queryOutput.data.map((row) => ({
    item: row[itemIdx],
    impact: Number(row[impactIdx]),
    likelihood: Number(row[likelihoodIdx]),
  }));

  if (settings.normalizeToGrid) {
    // Min-max rescale each measure into the grid, inset a little so
    // the extreme points don't sit on the chart border.
    const rescale = (values) => {
      const min = Math.min(...values);
      const max = Math.max(...values);
      const lo = 0.2;
      const hi = settings.gridSize - 0.2;
      return values.map((v) =>
        max === min ? (lo + hi) / 2 : lo + ((v - min) / (max - min)) * (hi - lo)
      );
    };
    const impacts = rescale(items.map((p) => p.impact));
    const likelihoods = rescale(items.map((p) => p.likelihood));
    items = items.map((p, i) => ({
      ...p,
      impact: impacts[i],
      likelihood: likelihoods[i],
    }));
  } else {
    // Scores outside the pinned axes still render, but off the
    // colored grid - call it out so points don't silently go missing.
    const inGrid = (v) => v >= 0 && v <= settings.gridSize;
    const offGrid = items.filter((p) => !inGrid(p.impact) || !inGrid(p.likelihood));
    if (items.length && offGrid.length === items.length) {
      fail(
        'Every item has scores outside 0-' + settings.gridSize + ', so nothing lands ' +
        'on the grid. "' + impactCol + '" and "' + likelihoodCol + '" should be 0-' +
        settings.gridSize + " scores with AVERAGE aggregation (not raw totals), or " +
        "set settings.normalizeToGrid = true to rescale raw measures automatically."
      );
    } else if (offGrid.length) {
      console.warn(
        "Risk Matrix: " + offGrid.length + " of " + items.length + " items have " +
        "scores outside 0-" + settings.gridSize + " and plot outside the colored grid."
      );
    }
  }
}

/* --------------------------------------------------------------------
   3. BUILD THE CHART DATA
   One merged DataModel holds both record kinds - the zone grid
   generated here and the items - tagged by "Record Type" so
   per-layer transforms can split them back out (DataModel has no
   union operation).
   -------------------------------------------------------------------- */

// Internal field names of the merged dataset
const F = {
  recordType: "Record Type",
  zoneId: "Zone Id",
  risk: "Risk",
  item: "Item",
  impact: "Impact",
  impactStart: "Impact Start",
  impactMid: "Impact Mid",
  likelihood: "Likelihood",
  likelihoodStart: "Likelihood Start",
  likelihoodMid: "Likelihood Mid",
};

// Zone for a cell, by the classic risk score: impact band x
// likelihood band (both 1-based).
const zoneForCell = (col, row) =>
  settings.zones.find((z) => col * row <= z.maxScore) ||
  settings.zones[settings.zones.length - 1];

// Background: gridSize x gridSize cells. x0/y0 = cell start edge,
// x1/y1 = cell end edge.
const zoneCells = [];
for (let row = 1; row <= settings.gridSize; row++) {
  for (let col = 1; col <= settings.gridSize; col++) {
    zoneCells.push({
      risk: zoneForCell(col, row).name,
      x0: col - 1,
      x1: col,
      y0: row - 1,
      y1: row,
    });
  }
}

const mergedRows = [
  ...zoneCells.map((z, i) => ({
    [F.recordType]: "zone",
    [F.zoneId]: "zone-" + i,
    [F.risk]: z.risk,
    [F.item]: null,
    [F.impact]: z.x1,
    [F.impactStart]: z.x0,
    [F.impactMid]: (z.x0 + z.x1) / 2,
    [F.likelihood]: z.y1,
    [F.likelihoodStart]: z.y0,
    [F.likelihoodMid]: (z.y0 + z.y1) / 2,
  })),
  ...items.map((p, i) => ({
    [F.recordType]: "item",
    [F.zoneId]: "item-" + i,
    [F.risk]: null,
    [F.item]: p.item,
    [F.impact]: p.impact,
    [F.impactStart]: null,
    [F.impactMid]: null,
    [F.likelihood]: p.likelihood,
    [F.likelihoodStart]: null,
    [F.likelihoodMid]: null,
  })),
];

const matrixSchema = [
  { name: F.recordType, type: "dimension" },
  { name: F.zoneId, type: "dimension" },
  { name: F.risk, type: "dimension" },
  { name: F.item, type: "dimension" },
  { name: F.impact, type: "measure", defAggFn: "avg" },
  { name: F.impactStart, type: "measure", defAggFn: "avg" },
  { name: F.impactMid, type: "measure", defAggFn: "avg" },
  { name: F.likelihood, type: "measure", defAggFn: "avg" },
  { name: F.likelihoodStart, type: "measure", defAggFn: "avg" },
  { name: F.likelihoodMid, type: "measure", defAggFn: "avg" },
];

const matrixData = new DataModel(DataModel.loadDataSync(mergedRows, matrixSchema));

/* --------------------------------------------------------------------
   4. ITEMS TABLE (optional)
   Fills #data-table when the HTML tab defines it; delete that div for
   a chart-only widget. Rendered BEFORE the chart mounts so the flex
   layout settles and the chart-width measurement below accounts for
   the table's width.
   -------------------------------------------------------------------- */

const tableEl = document.getElementById("data-table");
if (tableEl) {
  // Band lookup uses ceil() because bands are the intervals (n-1, n]:
  // an item at impact 2.3 sits in impact band 3.
  const band = (v) => Math.min(settings.gridSize, Math.max(1, Math.ceil(v)));
  const zoneForItem = (p) => zoneForCell(band(p.impact), band(p.likelihood));

  const bodyRows = [...items]
    .sort((a, b) => b.impact * b.likelihood - a.impact * a.likelihood)
    .map((p) => {
      const zone = zoneForItem(p);
      return (
        "<tr>" +
        "<td>" + p.item + "</td>" +
        '<td class="num">' + p.impact.toFixed(1) + "</td>" +
        '<td class="num">' + p.likelihood.toFixed(1) + "</td>" +
        '<td><span class="risk-badge" style="background:' + zone.color +
          ";color:" + (zone.badgeTextColor || "#fff") + '">' + zone.name + "</span></td>" +
        "</tr>"
      );
    })
    .join("");

  tableEl.innerHTML =
    "<table>" +
    "<caption>Items</caption>" +
    "<thead><tr>" +
    "<th>" + itemCol + "</th>" +
    "<th>" + impactCol + "</th>" +
    "<th>" + likelihoodCol + "</th>" +
    "<th>Risk</th>" +
    "</tr></thead>" +
    "<tbody>" + bodyRows + "</tbody>" +
    "</table>";
}

/* --------------------------------------------------------------------
   5. RENDER THE CHART
   Three layers over the merged dataset: range bars for the zone
   cells, text for the zone labels, points for the items (last layer
   renders on top).
   -------------------------------------------------------------------- */

// One color map drives both the cells and the table badges, so the
// two views can never disagree.
const zoneColorMap = {};
settings.zones.forEach((z) => {
  zoneColorMap[z.name] = z.color;
});

// Size the canvas to the #chart container so the chart fits whatever
// studio preview / liveboard tile it lands in.
const chartBounds = document.getElementById("chart").getBoundingClientRect();
const chartWidth = Math.max(320, Math.floor(chartBounds.width) || 550);
const chartHeight = Math.max(320, Math.floor(chartBounds.height) || 500);

muze
  .canvas()
  // share() lets multiple measures ride the same continuous axis
  .columns([share(F.impact, F.impactStart, F.impactMid)])
  .rows([share(F.likelihood, F.likelihoodStart, F.likelihoodMid)])
  // Record Type MUST be listed here even though no encoding uses it:
  // muze projects away unreferenced fields before running transforms,
  // and a select on a missing field silently matches every row.
  .detail([F.recordType, F.zoneId, F.item])
  .transform({
    zonesModel: (dt) =>
      dt.select({
        conditions: [{ field: F.recordType, value: "zone", operator: "eq" }],
        operator: "and",
      }),
    itemsModel: (dt) =>
      dt.select({
        conditions: [{ field: F.recordType, value: "item", operator: "eq" }],
        operator: "and",
      }),
  })
  .layers([
    {
      // Layer 1: background risk-matrix cells as range bars
      mark: "bar",
      source: "zonesModel",
      encoding: {
        x: F.impact,
        x0: F.impactStart,
        y: F.likelihood,
        y0: F.likelihoodStart,
        color: F.risk,
        // Bar layers default encoding.size to 10px, which muze
        // subtracts from a continuous-axis range bar's span, leaving
        // a white gutter between adjacent cells; zero it so the
        // cells tile edge to edge.
        size: { value: 0 },
      },
      interactive: false,
    },
    {
      // Layer 2: project labels above each point. The cell color already
      // says the risk, so the label names the item instead of the zone.
      mark: "text",
      source: "itemsModel",
      encoding: {
        text: { field: F.item },
        x: F.impact,
        y: {
          field: F.likelihood,
          value: ({ translatedValue }) => translatedValue + settings.labelOffsetY,
        },
        color: { value: () => settings.labelColor },
      },
      calculateDomain: false,
      interactive: false,
    },
    {
      // Layer 3: item scatter points on top (last = topmost)
      mark: "point",
      source: "itemsModel",
      encoding: {
        x: F.impact,
        y: F.likelihood,
        color: { value: () => settings.pointColor },
        // muze only honors a static point size when value is a function
        size: { value: () => settings.pointSize },
      },
    },
  ])
  .config({
    axes: {
      x: { domain: [0, settings.gridSize], name: impactCol, padding: 0 },
      y: { domain: [0, settings.gridSize], name: likelihoodCol, padding: 0 },
    },
    legend: {
      color: {
        show: false,
        fields: {
          [F.risk]: { domainRangeMap: zoneColorMap },
        },
      },
    },
  })
  .data(matrixData)
  .height(chartHeight)
  .width(chartWidth)
  .mount("#chart");

CSS

Chart/table split layout and the ranked table styling.

Preview
html, body {
  margin: 0;
  padding: 0;
  width: 100vw;
  height: 100vh;
  overflow: hidden;
}

#chart {
  width: 100%;
  height: 100%;
}

  #layout {
    display: flex;
    align-items: flex-start;
    gap: 32px;
    height: calc(100vh - 16px);
    font-family: -apple-system, "Segoe UI", sans-serif;
  }
  #chart {
    flex: 1 1 auto;
    min-width: 320px;
    height: 100%;
  }
  #data-table { flex: none; }
  #data-table table { border-collapse: collapse; font-size: 13px; }
  #data-table caption {
    font-weight: 600;
    font-size: 14px;
    padding: 6px 0 10px;
    text-align: left;
  }
  #data-table th, #data-table td {
    border: 1px solid #ddd;
    padding: 6px 12px;
    text-align: left;
  }
  #data-table th { background: #f5f5f5; }
  #data-table td.num { text-align: right; }
  #data-table .risk-badge {
    display: inline-block;
    padding: 2px 10px;
    border-radius: 10px;
    color: #fff;
    font-size: 12px;
  }
  .rm-error {
    padding: 24px;
    font-size: 13px;
    line-height: 1.5;
    color: #5f2120;
  }
  /* White seams between the zone cells */
  #chart .muze-layer-bar rect {
    stroke: #ffffff !important;
    stroke-width: 4px !important;
    shape-rendering: crispEdges;
  }
  /* White outline around the scatter points */
  #chart .muze-layer-point path {
    stroke: #ffffff !important;
    stroke-width: 2px !important;
  }

HTML

The Muze Studio mount elements; delete #data-table for a chart-only widget.

Preview
<!-- Risk Matrix — Muze Studio sample (HTML tab)

     Chart on the left, item table on the right. Delete the
     #data-table div if you only want the chart; chart.js skips the
     table when the element is absent. -->

     <div id="layout">
      <div id="chart"></div>
      <div id="data-table"></div>
    </div>

Dataset (CSV)

12 projects with 0-5 impact and likelihood scores.