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