=e-1){var s=u[n];return s.x0=i,s.y0=o,s.x1=a,void(s.y1=c)}var l=f[n],h=r/2+l,d=n+1,p=e-1;for(;d>>1;f[g]c-o){var _=r?(i*v+a*y)/r:a;t(n,d,y,i,o,_,c),t(d,e,v,_,o,a,c)}else{var b=r?(o*v+c*y)/r:c;t(n,d,y,i,o,a,b),t(d,e,v,i,b,a,c)}}(0,c,t.value,n,e,r,i)},t.treemapDice=Ap,t.treemapResquarify=Lp,t.treemapSlice=Ip,t.treemapSliceDice=function(t,n,e,r,i){(1&t.depth?Ip:Ap)(t,n,e,r,i)},t.treemapSquarify=Yp,t.tsv=Mc,t.tsvFormat=lc,t.tsvFormatBody=hc,t.tsvFormatRow=pc,t.tsvFormatRows=dc,t.tsvFormatValue=gc,t.tsvParse=fc,t.tsvParseRows=sc,t.union=function(...t){const n=new InternSet;for(const e of t)for(const t of e)n.add(t);return n},t.unixDay=_y,t.unixDays=by,t.utcDay=yy,t.utcDays=vy,t.utcFriday=By,t.utcFridays=Vy,t.utcHour=hy,t.utcHours=dy,t.utcMillisecond=Wg,t.utcMilliseconds=Zg,t.utcMinute=cy,t.utcMinutes=fy,t.utcMonday=qy,t.utcMondays=jy,t.utcMonth=Qy,t.utcMonths=Jy,t.utcSaturday=Yy,t.utcSaturdays=Wy,t.utcSecond=iy,t.utcSeconds=oy,t.utcSunday=Fy,t.utcSundays=Ly,t.utcThursday=Oy,t.utcThursdays=Gy,t.utcTickInterval=av,t.utcTicks=ov,t.utcTuesday=Uy,t.utcTuesdays=Hy,t.utcWednesday=Iy,t.utcWednesdays=Xy,t.utcWeek=Fy,t.utcWeeks=Ly,t.utcYear=ev,t.utcYears=rv,t.variance=x,t.version="7.9.0",t.window=pn,t.xml=Sc,t.zip=function(){return gt(arguments)},t.zoom=function(){var t,n,e,r=Ew,i=Nw,o=zw,a=Cw,u=Pw,c=[0,1/0],f=[[-1/0,-1/0],[1/0,1/0]],s=250,l=ri,h=$t("start","zoom","end"),d=500,p=150,g=0,y=10;function v(t){t.property("__zoom",kw).on("wheel.zoom",T,{passive:!1}).on("mousedown.zoom",A).on("dblclick.zoom",S).filter(u).on("touchstart.zoom",E).on("touchmove.zoom",N).on("touchend.zoom touchcancel.zoom",k).style("-webkit-tap-highlight-color","rgba(0,0,0,0)")}function _(t,n){return(n=Math.max(c[0],Math.min(c[1],n)))===t.k?t:new ww(n,t.x,t.y)}function b(t,n,e){var r=n[0]-e[0]*t.k,i=n[1]-e[1]*t.k;return r===t.x&&i===t.y?t:new ww(t.k,r,i)}function m(t){return[(+t[0][0]+ +t[1][0])/2,(+t[0][1]+ +t[1][1])/2]}function x(t,n,e,r){t.on("start.zoom",(function(){w(this,arguments).event(r).start()})).on("interrupt.zoom end.zoom",(function(){w(this,arguments).event(r).end()})).tween("zoom",(function(){var t=this,o=arguments,a=w(t,o).event(r),u=i.apply(t,o),c=null==e?m(u):"function"==typeof e?e.apply(t,o):e,f=Math.max(u[1][0]-u[0][0],u[1][1]-u[0][1]),s=t.__zoom,h="function"==typeof n?n.apply(t,o):n,d=l(s.invert(c).concat(f/s.k),h.invert(c).concat(f/h.k));return function(t){if(1===t)t=h;else{var n=d(t),e=f/n[2];t=new ww(e,c[0]-n[0]*e,c[1]-n[1]*e)}a.zoom(null,t)}}))}function w(t,n,e){return!e&&t.__zooming||new M(t,n)}function M(t,n){this.that=t,this.args=n,this.active=0,this.sourceEvent=null,this.extent=i.apply(t,n),this.taps=0}function T(t,...n){if(r.apply(this,arguments)){var e=w(this,n).event(t),i=this.__zoom,u=Math.max(c[0],Math.min(c[1],i.k*Math.pow(2,a.apply(this,arguments)))),s=ne(t);if(e.wheel)e.mouse[0][0]===s[0]&&e.mouse[0][1]===s[1]||(e.mouse[1]=i.invert(e.mouse[0]=s)),clearTimeout(e.wheel);else{if(i.k===u)return;e.mouse=[s,i.invert(s)],Gi(this),e.start()}Sw(t),e.wheel=setTimeout((function(){e.wheel=null,e.end()}),p),e.zoom("mouse",o(b(_(i,u),e.mouse[0],e.mouse[1]),e.extent,f))}}function A(t,...n){if(!e&&r.apply(this,arguments)){var i=t.currentTarget,a=w(this,n,!0).event(t),u=Zn(t.view).on("mousemove.zoom",(function(t){if(Sw(t),!a.moved){var n=t.clientX-s,e=t.clientY-l;a.moved=n*n+e*e>g}a.event(t).zoom("mouse",o(b(a.that.__zoom,a.mouse[0]=ne(t,i),a.mouse[1]),a.extent,f))}),!0).on("mouseup.zoom",(function(t){u.on("mousemove.zoom mouseup.zoom",null),ue(t.view,a.moved),Sw(t),a.event(t).end()}),!0),c=ne(t,i),s=t.clientX,l=t.clientY;ae(t.view),Aw(t),a.mouse=[c,this.__zoom.invert(c)],Gi(this),a.start()}}function S(t,...n){if(r.apply(this,arguments)){var e=this.__zoom,a=ne(t.changedTouches?t.changedTouches[0]:t,this),u=e.invert(a),c=e.k*(t.shiftKey?.5:2),l=o(b(_(e,c),a,u),i.apply(this,n),f);Sw(t),s>0?Zn(this).transition().duration(s).call(x,l,a,t):Zn(this).call(v.transform,l,a,t)}}function E(e,...i){if(r.apply(this,arguments)){var o,a,u,c,f=e.touches,s=f.length,l=w(this,i,e.changedTouches.length===s).event(e);for(Aw(e),a=0;a {
+ it('gives selections a transition() method', () => {
+ const el = document.createElement('div');
+ document.body.appendChild(el);
+
+ const sel = select(el).append('div').style('opacity', '0');
+ expect(typeof sel.transition).toBe('function');
+
+ // exercise the chain matrix.js actually uses for tooltip fades
+ expect(() => sel.transition().delay(10).duration(150).style('opacity', 1)).not.toThrow();
+ });
+
+ it('provides the other d3 helpers matrix.js uses', () => {
+ const el = document.createElement('div');
+ expect(typeof local().set).toBe('function');
+ expect(typeof select(el).raise).toBe('function');
+ });
+});
diff --git a/src/dataParser.ts b/src/dataParser.ts
index e4f311d..a319f6a 100644
--- a/src/dataParser.ts
+++ b/src/dataParser.ts
@@ -1,95 +1,281 @@
-import { DataFrameView, Field, FieldType, getFieldDisplayName, GrafanaTheme2, PanelData } from '@grafana/data';
-import { Category, DataMatrixCell, LegendData, MatrixData, MatrixOptions } from './types';
+import { DataFrameView } from '@grafana/data';
+import { buildLegend } from './legend';
/**
- * this function creates an adjacency matrix to be consumed by the matrix diagram
+ * this function creates an adjacency matrix to be consumed by the chord
* function returns the matrix + forward and reverse lookup Maps to go from
* source and target id to description assumes that data coming to us has at
* least 3 columns if no preferences provided, assumes the first 3 columns are
* source and target dimensions then value to display
- * @param {PanelData} data Data for the matrix diagram
- * @param {MatrixOptions} options Panel configuration
- * @param {GrafanaTheme2} theme Grafana theme
- * @return {MatrixData}
+ * @param {*} data Data for the chord diagram
+ * @param {string} src The data series that will act as the source
+ * @param {string} target The data series that will act as * the target
+ * @param {string} val The data series that will act as the value
+ * @return {[rowNames, colNames, dataMatrix]}
*/
-export function parseData(data: PanelData, options: MatrixOptions, theme: GrafanaTheme2): MatrixData {
+// ---- Color ramp helpers (self-contained, no d3 dependency) ----
+function hexToRgb(h: string): [number, number, number] {
+ let s = h.replace('#', '');
+ if (s.length === 3) {
+ s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2];
+ }
+ return [parseInt(s.slice(0, 2), 16), parseInt(s.slice(2, 4), 16), parseInt(s.slice(4, 6), 16)];
+}
+function rgbToHex(r: number, g: number, b: number): string {
+ const c = (x: number) => Math.round(Math.max(0, Math.min(255, x))).toString(16).padStart(2, '0');
+ return '#' + c(r) + c(g) + c(b);
+}
+// Build an interpolator over a list of hex stops, t in [0,1].
+// Non-finite input (NaN/Infinity) falls back to the low end rather than throwing.
+function makeRamp(stops: string[]): (t: number) => string {
+ const rgb = stops.map(hexToRgb);
+ const n = rgb.length - 1;
+ return (t: number) => {
+ if (!isFinite(t)) {
+ t = 0;
+ }
+ t = Math.max(0, Math.min(1, t));
+ const f = t * n;
+ let i = Math.floor(f);
+ if (i < 0) {
+ i = 0;
+ } else if (i > n - 1) {
+ i = n - 1;
+ }
+ const frac = f - i;
+ const a = rgb[i];
+ const b = rgb[i + 1];
+ return rgbToHex(a[0] + (b[0] - a[0]) * frac, a[1] + (b[1] - a[1]) * frac, a[2] + (b[2] - a[2]) * frac);
+ };
+}
+
+// Sequential single-hue blue ramp, light -> dark (dataviz reference palette)
+const SEQ_STOPS = ['#cde2fb', '#9ec5f4', '#6da7ec', '#3987e5', '#256abf', '#184f95', '#0d366b'];
+
+/**
+ * Return a field's values as a plain array, tolerant of Grafana's differing
+ * representations: a plain array (Grafana 13+ runtime), a Vector with toArray(),
+ * or a Vector-like object with length + get(). Using Object.values() directly on a
+ * Vector returns the internal buffer, not the values — this normalizes that away.
+ */
+function fieldValuesArray(field: any): any[] {
+ const v = field && field.values;
+ if (!v) {
+ return [];
+ }
+ if (Array.isArray(v)) {
+ return v;
+ }
+ if (typeof v.toArray === 'function') {
+ return v.toArray();
+ }
+ if (typeof v.length === 'number' && typeof v.get === 'function') {
+ const out: any[] = [];
+ for (let i = 0; i < v.length; i++) {
+ out.push(v.get(i));
+ }
+ return out;
+ }
+ return Object.values(v);
+}
+
+/**
+ * Spectral seriation: order rows and columns so similar ones sit next to each other,
+ * revealing block structure. Builds the value matrix, column-centers it, then finds
+ * the dominant right singular vector via power iteration (dependency-free). Rows are
+ * ordered by their projection onto that vector; columns by the vector itself.
+ * Returns null score maps when the matrix is too small or degenerate (caller falls
+ * back to name order). Deterministic (fixed seed) so re-renders don't reshuffle.
+ */
+function computeSeriation(
+ rowKeys: string[],
+ colKeys: string[],
+ cellRaw: Map
+): { rowScores: Map | null; colScores: Map | null } {
+ const nR = rowKeys.length;
+ const nC = colKeys.length;
+ const none = { rowScores: null, colScores: null };
+ if (nR < 3 || nC < 3) {
+ return none;
+ }
+
+ // build value matrix M (nR x nC)
+ const M: number[][] = [];
+ for (let i = 0; i < nR; i++) {
+ const row = new Array(nC);
+ for (let j = 0; j < nC; j++) {
+ row[j] = cellRaw.get(`${rowKeys[i]}||${colKeys[j]}`) || 0;
+ }
+ M.push(row);
+ }
+ // column-center (PCA): removes the all-positive component so the ordering reflects
+ // covariance structure rather than raw totals
+ for (let j = 0; j < nC; j++) {
+ let s = 0;
+ for (let i = 0; i < nR; i++) {
+ s += M[i][j];
+ }
+ const mean = s / nR;
+ for (let i = 0; i < nR; i++) {
+ M[i][j] -= mean;
+ }
+ }
+
+ const normalize = (x: number[]): number[] | null => {
+ let n = 0;
+ for (const e of x) {
+ n += e * e;
+ }
+ n = Math.sqrt(n);
+ if (n < 1e-12) {
+ return null;
+ }
+ return x.map((e) => e / n);
+ };
+
+ // deterministic non-degenerate seed
+ let v: number[] | null = new Array(nC).fill(0).map((_, j) => Math.cos(j * 0.7) + 1.5);
+ v = normalize(v);
+ if (!v) {
+ return none;
+ }
+ // power iteration on M^T M without materializing it: v <- normalize(M^T (M v))
+ for (let iter = 0; iter < 60; iter++) {
+ const u = new Array(nR).fill(0);
+ for (let i = 0; i < nR; i++) {
+ let s = 0;
+ const Mi = M[i];
+ for (let j = 0; j < nC; j++) {
+ s += Mi[j] * v![j];
+ }
+ u[i] = s;
+ }
+ const w = new Array(nC).fill(0);
+ for (let i = 0; i < nR; i++) {
+ const Mi = M[i];
+ const ui = u[i];
+ for (let j = 0; j < nC; j++) {
+ w[j] += Mi[j] * ui;
+ }
+ }
+ const nv = normalize(w);
+ if (!nv) {
+ return none;
+ }
+ v = nv;
+ }
+
+ // final row scores = M v (projection onto the dominant direction)
+ const rowScores = new Map();
+ for (let i = 0; i < nR; i++) {
+ let s = 0;
+ const Mi = M[i];
+ for (let j = 0; j < nC; j++) {
+ s += Mi[j] * v![j];
+ }
+ rowScores.set(rowKeys[i], s);
+ }
+ const colScores = new Map();
+ for (let j = 0; j < nC; j++) {
+ colScores.set(colKeys[j], v![j]);
+ }
+ return { rowScores, colScores };
+}
+
+export function parseData(data: { series: any[] }, options: any, theme: any) {
const series = data.series[0];
if (series === null || series === undefined) {
// no data, bail
console.error('no data');
- return { rowNames: null, colNames: null, colCategories: [], rowCategories: [], data: null, legend: null };
+ return { rows: null, columns: null, colMetadata: [], colCategories: [], rowMetadata: [], rowCategories: [], data: null, legend: null, rowTotals: [], colTotals: [], valueDomain: null };
}
const frame = new DataFrameView(series);
if (frame === null || frame === undefined) {
// no data, bail
console.error('no data');
- return { rowNames: null, colNames: null, colCategories: [], rowCategories: [], data: null, legend: null };
+ return { rows: null, columns: null, colMetadata: [], colCategories: [], rowMetadata: [], rowCategories: [], data: null, legend: null, rowTotals: [], colTotals: [], valueDomain: null };
}
// set fields
- const sourceField = series.fields.find((f: Field) =>
- options.sourceField !== undefined && (
- options.sourceField === f.name
- || options.sourceField === f.config?.displayNameFromDS
- || options.sourceField === getFieldDisplayName(f)
- )
- ) ?? series.fields.find((f: Field) => f.type === FieldType.string);
- const targetField = series.fields.find((f: Field) =>
- options.targetField !== undefined && (
- options.targetField === f.name
- || options.targetField === f.config?.displayNameFromDS
- || options.targetField === getFieldDisplayName(f)
- )
- ) ?? series.fields.find((f: Field) => f.type === FieldType.string && f.name !== sourceField?.name);
- const colCategoryField = series.fields.find((f: Field) =>
- options.colCategoryField !== undefined && (
- options.colCategoryField === f.name
- || options.colCategoryField === f.config?.displayNameFromDS
- || options.colCategoryField === getFieldDisplayName(f)
- )
- );
- const rowCategoryField = series.fields.find((f: Field) =>
- options.rowCategoryField !== undefined && (
- options.rowCategoryField === f.name
- || options.rowCategoryField === f.config?.displayNameFromDS
- || options.rowCategoryField === getFieldDisplayName(f)
- )
- );
+ let sourceKey = options.sourceField;
+ let targetKey = options.targetField;
+ let colCategoryKey = options.colCategoryField;
+ let rowCategoryKey = options.rowCategoryField;
+ if (!sourceKey) {
+ sourceKey = 0;
+ }
+ if (!targetKey) {
+ targetKey = 1;
+ }
+
// assign valueField to the specified field or use the first number field by default
- const valueField = series.fields.find((f: Field) =>
- options.valueField !== undefined && (
- options.valueField === f.name
- || options.valueField === f.config?.displayNameFromDS
- || options.valueField === getFieldDisplayName(f)
- )
- ) ?? series.fields.find((f: Field) => f.type === FieldType.number);
- const sourceKey = sourceField?.name;
- const targetKey = targetField?.name;
- const colCategoryKey = colCategoryField?.name;
- const rowCategoryKey = rowCategoryField?.name;
- const valKey = valueField?.name;
-
- if (
- sourceKey === undefined || targetKey === undefined
- || valueField === undefined || valKey === undefined
- ) {
- // no data, bail
- console.error('no data');
- return { rowNames: null, colNames: null, colCategories: [], rowCategories: [], data: null, legend: null };
+ const val = options.valueField;
+ const valueField = val
+ ? data.series.map((series: { fields: any[] }) => series.fields.find((field: { name: any }) => field.name === val))
+ : data.series.map((series: { fields: any[] }) =>
+ series.fields.find((field: { type: string }) => field.type === 'number')
+ );
+ const valKey = valueField[0].name;
+
+ // ---- Determine the value domain (min/max) for the built-in color ramps ----
+ let allNumericValues: number[] = fieldValuesArray(frame.fields[valKey]).filter(
+ (v) => typeof v === 'number' && !isNaN(v)
+ );
+ const dataMin = allNumericValues.length ? Math.min(...allNumericValues) : 0;
+ const dataMax = allNumericValues.length ? Math.max(...allNumericValues) : 0;
+ // Effective color domain: auto (data min/max) or manual (fixed, for comparable panels)
+ let domainMin = dataMin;
+ let domainMax = dataMax;
+ if (options.colorDomainMode === 'manual') {
+ if (typeof options.colorDomainMin === 'number') {
+ domainMin = options.colorDomainMin;
+ }
+ if (typeof options.colorDomainMax === 'number') {
+ domainMax = options.colorDomainMax;
+ }
+ }
+ const valueDomain = allNumericValues.length || options.colorDomainMode === 'manual' ? { min: domainMin, max: domainMax } : null;
+
+ // ---- Build the color scale for the chosen colorMode ----
+ // Default to 'standard' (Standard Options thresholds): it is what the plugin declares
+ // as its color source (preferThresholdMode), and it is how panels saved before this
+ // option existed were colored — so the fallback restores them rather than restyling them.
+ const colorMode = options.colorMode || 'standard';
+ const isDark = !!theme.isDark;
+ let colorScale: ((v: number) => string) | null = null;
+ if (colorMode === 'sequential') {
+ // In dark mode flip the ramp so high magnitude reads bright, low recedes to the surface
+ const stops = isDark ? [...SEQ_STOPS].reverse() : SEQ_STOPS;
+ const ramp = makeRamp(stops);
+ colorScale = (v: number) => {
+ if (domainMax === domainMin) {
+ return ramp(isDark ? 1 : 0.5);
+ }
+ return ramp((v - domainMin) / (domainMax - domainMin));
+ };
+ } else if (colorMode === 'diverging') {
+ const mid = typeof options.divergingMidpoint === 'number' ? options.divergingMidpoint : 0;
+ const gray = isDark ? '#383835' : '#f0efec';
+ // blue (low) <-> neutral gray (mid) <-> red (high)
+ const ramp = makeRamp(['#184f95', '#3987e5', gray, '#e34948', '#b3261e']);
+ const maxAbs = Math.max(Math.abs(mid - domainMin), Math.abs(domainMax - mid)) || 1;
+ colorScale = (v: number) => ramp(0.5 + 0.5 * ((v - mid) / maxAbs));
}
// function that maps value to color specified by Standard Options panel.
// if value is null or was not returned by query, use different value
const nullColor = theme.visualization.getColorByName(options.nullColor);
const defaultColor = theme.visualization.getColorByName(options.defaultColor);
- function colorMap(v: any): string {
- if (v === null) {
+ function colorMap(v: any) {
+ if (v == null) {
return nullColor;
} else if (v === -1) {
return defaultColor;
+ } else if (colorScale) {
+ return colorScale(v);
} else {
- return valueField.display(v).color;
+ return valueField[0].display(v).color;
}
}
@@ -97,186 +283,363 @@ export function parseData(data: PanelData, options: MatrixOptions, theme: Grafan
const colGrouping = !!(colCategoryKey && options.enableColGrouping);
const rowGrouping = !!(rowCategoryKey && options.enableRowGrouping);
- // Store unique set of row and column headings/categories
- const rowNamesSet = new Set();
- const colNamesSet = new Set();
- const rowCategoriesMap = new Map();
- const colCategoriesMap = new Map();
+ // Aggregation function to combine multiple values for the same cell based on user-selected method
+ // By default: aggregate is set to "sum"
+ // Falls back to "last" for non-numeric values (strings, etc.)
+ function aggregate(values: any[], method: string): any {
+ if (values.length === 0) {
+ return null;
+ }
+ // For non-numeric values, only "last" is meaningful
+ if (typeof values[0] !== 'number') {
+ return values[values.length - 1];
+ }
+ switch (method) {
+ case 'sum': return values.reduce((a: number, b: number) => a + b, 0);
+ case 'avg': return values.reduce((a: number, b: number) => a + b, 0) / values.length;
+ case 'min': return Math.min(...values);
+ case 'max': return Math.max(...values);
+ case 'last': return values[values.length - 1];
+ default: return values.reduce((a: number, b: number) => a + b, 0);
+ }
+ }
+
+ // Build composite keys from data
+ // When grouping is enabled, use "category::name" as the key to allow
+ // the same name in multiple categories. Otherwise, just use the name.
+ let compositeColKeys: string[] = [];
+ let compositeRowKeys: string[] = [];
+
+ // Marginal sums per composite key (used for "total" sort and for tracking cells that were seen)
+ const colKeySums = new Map();
+ const rowKeySums = new Map();
+ // Raw aggregated value per (rowKey||colKey) pair (used for "cluster" seriation).
+ const cellRaw = new Map();
+ // Track every (rowKey, colKey) pair that appears in the data, even when null,
+ // so we can tell "queried and null" apart from "no data at all".
+ const seenCells = new Set();
// IF static list toggle is set, use input list
if (options.inputList) {
- if (options.staticRows !== undefined) {
- options.staticRows.split(',').forEach((item: string) => rowNamesSet.add(item));
- }
- if (options.staticColumns !== undefined) {
- options.staticColumns.split(',').forEach((item: string) => colNamesSet.add(item));
- }
+ // Tolerate empty/undefined lists (the option can be enabled before the fields
+ // are filled in) and trim whitespace so " a, b " doesn't create phantom / padded keys.
+ const splitList = (s: string | undefined) =>
+ (s || '')
+ .split(',')
+ .map((t: string) => t.trim())
+ .filter((t: string) => t.length > 0);
+ const staticRows = splitList(options.staticRows);
+ const staticCols = splitList(options.staticColumns);
+ compositeRowKeys = Array.from(new Set(staticRows));
+ compositeColKeys = Array.from(new Set(staticCols));
} else {
- // Collect all unique row and column headings/categories
+ // Build unique composite keys from data
+ const colKeySet = new Set();
+ const rowKeySet = new Set();
+
frame.forEach((row) => {
- const rowName = row[sourceKey];
- if (!rowNamesSet.has(rowName)) {
- // new row heading
- rowNamesSet.add(rowName);
-
- const categoryName = row[rowCategoryKey];
- if (rowGrouping && categoryName != null) {
- if (!rowCategoriesMap.has(categoryName)) {
- // new row category
- rowCategoriesMap.set(categoryName, {
- name: categoryName,
- items: [rowName],
- });
- } else {
- // append row heading to existing row category
- const category = rowCategoriesMap.get(categoryName);
- category?.items.push(rowName);
- }
- }
+ const rowName = String(row[sourceKey]);
+ const colName = String(row[targetKey]);
+ const colCat = colGrouping && row[colCategoryKey] != null ? String(row[colCategoryKey]) : '';
+ const rowCat = rowGrouping && row[rowCategoryKey] != null ? String(row[rowCategoryKey]) : '';
+
+ const colKey = colGrouping ? `${colCat}::${colName}` : colName;
+ const rowKey = rowGrouping ? `${rowCat}::${rowName}` : rowName;
+
+ colKeySet.add(colKey);
+ rowKeySet.add(rowKey);
+ seenCells.add(`${rowKey}||${colKey}`);
+
+ const v = row[valKey];
+ if (typeof v === 'number' && !isNaN(v)) {
+ colKeySums.set(colKey, (colKeySums.get(colKey) || 0) + v);
+ rowKeySums.set(rowKey, (rowKeySums.get(rowKey) || 0) + v);
+ const pk = `${rowKey}||${colKey}`;
+ cellRaw.set(pk, (cellRaw.get(pk) || 0) + v);
}
+ });
- const colName = row[targetKey];
- if (!colNamesSet.has(colName)) {
- // new column heading
- colNamesSet.add(colName);
-
- const categoryName = row[colCategoryKey];
- if (colGrouping && categoryName != null) {
- if (!colCategoriesMap.has(categoryName)) {
- // new column category
- colCategoriesMap.set(categoryName, {
- name: categoryName,
- items: [colName],
- });
- } else {
- // append column heading to existing column category
- const category = colCategoriesMap.get(categoryName);
- category?.items.push(colName);
- }
- }
+ // Sort keys by name (default), by marginal total, or by cluster (seriation)
+ const byName = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
+ const byTotal = (sums: Map) => (a: string, b: string) => {
+ const diff = (sums.get(b) || 0) - (sums.get(a) || 0);
+ return diff !== 0 ? diff : byName(a, b);
+ };
+ const byScore = (scores: Map) => (a: string, b: string) => {
+ const diff = (scores.get(a) || 0) - (scores.get(b) || 0);
+ return diff !== 0 ? diff : byName(a, b);
+ };
+
+ // Compute cluster ordering only when requested
+ let rowScores: Map | null = null;
+ let colScores: Map | null = null;
+ if (options.rowSort === 'cluster' || options.colSort === 'cluster') {
+ const seriation = computeSeriation(Array.from(rowKeySet), Array.from(colKeySet), cellRaw);
+ rowScores = seriation.rowScores;
+ colScores = seriation.colScores;
+ }
+
+ const pickComparator = (mode: string, sums: Map, scores: Map | null) => {
+ if (mode === 'total') {
+ return byTotal(sums);
}
+ if (mode === 'cluster' && scores) {
+ return byScore(scores);
+ }
+ return byName;
+ };
+
+ compositeColKeys = Array.from(colKeySet).sort(pickComparator(options.colSort, colKeySums, colScores));
+ compositeRowKeys = Array.from(rowKeySet).sort(pickComparator(options.rowSort, rowKeySums, rowScores));
+ }
+
+ // Extract display names from composite keys
+ let colNames = compositeColKeys.map((k) => colGrouping && k.includes('::') ? k.split('::').slice(1).join('::') : k);
+ let rowNames = compositeRowKeys.map((k) => rowGrouping && k.includes('::') ? k.split('::').slice(1).join('::') : k);
+
+ // Build column metadata and category groupings
+ let colMetadata: any[] = [];
+ let colCategories: any[] = [];
+
+ if (colGrouping) {
+ // Group columns by category using composite keys
+ const categoryToColumns = new Map>();
+
+ compositeColKeys.forEach((key) => {
+ const [cat, ...nameParts] = key.split('::');
+ const name = nameParts.join('::');
+ if (!categoryToColumns.has(cat)) {
+ categoryToColumns.set(cat, []);
+ }
+ categoryToColumns.get(cat)!.push({ name, key });
+ });
+
+ // Build CategoryGroup array (sorted by category name)
+ const sortedCategories = Array.from(categoryToColumns.keys()).sort();
+ let globalIndex = 0;
+
+ sortedCategories.forEach((catName) => {
+ const columnsInCat = categoryToColumns.get(catName)!;
+ colCategories.push({
+ name: catName,
+ columns: columnsInCat.map((c) => c.name),
+ startIndex: globalIndex,
+ endIndex: globalIndex + columnsInCat.length - 1,
+ });
+ globalIndex += columnsInCat.length;
});
+
+ // Build ColumnInfo array and re-order colNames/compositeColKeys to match grouped order
+ const reorderedColKeys: string[] = [];
+ const reorderedColNames: string[] = [];
+ colCategories.forEach((cat: any, catIndex: number) => {
+ cat.columns.forEach((colName: string, indexInCat: number) => {
+ colMetadata.push({
+ name: colName,
+ category: cat.name,
+ categoryIndex: catIndex,
+ indexInCategory: indexInCat,
+ });
+ reorderedColKeys.push(`${cat.name}::${colName}`);
+ reorderedColNames.push(colName);
+ });
+ });
+
+ compositeColKeys = reorderedColKeys;
+ colNames = reorderedColNames;
+ } else {
+ // No categories: create default metadata
+ colMetadata = colNames.map((name: string, idx: number) => ({
+ name,
+ category: '',
+ categoryIndex: 0,
+ indexInCategory: idx,
+ }));
}
- if (rowNamesSet.size === 0 || colNamesSet.size === 0) {
- // no data, bail
- console.error('no data');
- return { rowNames: null, colNames: null, colCategories: [], rowCategories: [], data: null, legend: null };
+ // Build row metadata and row category groupings
+ let rowMetadata: any[] = [];
+ let rowCategories: any[] = [];
+
+ if (rowGrouping) {
+ // Group rows by category using composite keys
+ const categoryToRows = new Map>();
+
+ compositeRowKeys.forEach((key) => {
+ const [cat, ...nameParts] = key.split('::');
+ const name = nameParts.join('::');
+ if (!categoryToRows.has(cat)) {
+ categoryToRows.set(cat, []);
+ }
+ categoryToRows.get(cat)!.push({ name, key });
+ });
+
+ // Build RowCategoryGroup array (sorted by category name)
+ const sortedRowCategories = Array.from(categoryToRows.keys()).sort();
+ let globalRowIndex = 0;
+
+ sortedRowCategories.forEach((catName) => {
+ const rowsInCat = categoryToRows.get(catName)!;
+ rowCategories.push({
+ name: catName,
+ rows: rowsInCat.map((r) => r.name),
+ startIndex: globalRowIndex,
+ endIndex: globalRowIndex + rowsInCat.length - 1,
+ });
+ globalRowIndex += rowsInCat.length;
+ });
+
+ // Build RowInfo array and re-order rowNames/compositeRowKeys to match grouped order
+ const reorderedRowKeys: string[] = [];
+ const reorderedRowNames: string[] = [];
+ rowCategories.forEach((cat: any, catIndex: number) => {
+ cat.rows.forEach((rowName: string, indexInCat: number) => {
+ rowMetadata.push({
+ name: rowName,
+ category: cat.name,
+ categoryIndex: catIndex,
+ indexInCategory: indexInCat,
+ });
+ reorderedRowKeys.push(`${cat.name}::${rowName}`);
+ reorderedRowNames.push(rowName);
+ });
+ });
+
+ compositeRowKeys = reorderedRowKeys;
+ rowNames = reorderedRowNames;
+ } else {
+ // No row categories: create default metadata
+ rowMetadata = rowNames.map((name: string, idx: number) => ({
+ name,
+ category: '',
+ categoryIndex: 0,
+ indexInCategory: idx,
+ }));
}
- const numSquaresInMatrix = rowNamesSet.size * colNamesSet.size;
+ const numSquaresInMatrix = rowNames.length * colNames.length;
if (numSquaresInMatrix > 50000) {
- return { rowNames: null, colNames: null, colCategories: [], rowCategories: [], data: 'too many inputs', legend: null };
+ return { rows: null, columns: null, colMetadata: [], colCategories: [], rowMetadata: [], rowCategories: [], data: 'too many inputs', legend: null, rowTotals: [], colTotals: [], valueDomain: null };
}
- const rowNames: any[] = Array.from(rowNamesSet);
- const colNames: any[] = Array.from(colNamesSet);
- const rowCategories: Category[] = [];
- const colCategories: Category[] = [];
+ // Phase 1: Collect all values per cell using composite keys
+ const cellValues = new Map();
- switch(options.sortType) {
- case "natural-asc":
- case "natural-desc":
- const naturalSort = (a: any, b: any) => {
- return a.toString().localeCompare(b.toString(), undefined, { numeric: true });
- };
+ frame.forEach((row) => {
+ const rowName = String(row[sourceKey]);
+ const colName = String(row[targetKey]);
+ const colCat = colGrouping && row[colCategoryKey] != null ? String(row[colCategoryKey]) : '';
+ const rowCat = rowGrouping && row[rowCategoryKey] != null ? String(row[rowCategoryKey]) : '';
- let sort = naturalSort;
- if (options.sortType.endsWith("-desc")) {
- sort = (a, b) => naturalSort(b, a);
- }
+ const colKey = colGrouping ? `${colCat}::${colName}` : colName;
+ const rowKey = rowGrouping ? `${rowCat}::${rowName}` : rowName;
- // sort row and column headings
- rowNames.sort(sort);
- colNames.sort(sort);
+ const r = compositeRowKeys.indexOf(rowKey);
+ const c = compositeColKeys.indexOf(colKey);
+ const v = row[valKey];
+ const isValid = v != null && !(typeof v === 'number' && isNaN(v));
- // sort row and column categories and items in each category
- Array.from(rowCategoriesMap.keys()).sort(sort).forEach((name) => {
- const category = rowCategoriesMap.get(name);
- if (category !== undefined) {
- category.items.sort(sort);
- rowCategories.push(category);
- }
- });
- Array.from(colCategoriesMap.keys()).sort(sort).forEach((name) => {
- const category = colCategoriesMap.get(name);
- if (category !== undefined) {
- category.items.sort(sort);
- colCategories.push(category);
+ if (r > -1 && c > -1 && isValid) {
+ const cellKey = `${r}:${c}`;
+ if (!cellValues.has(cellKey)) {
+ cellValues.set(cellKey, []);
}
- });
- break;
- case "none":
- rowCategories.push(...rowCategoriesMap.values());
- colCategories.push(...colCategoriesMap.values());
- break;
- }
+ cellValues.get(cellKey)!.push(v);
+ }
+ });
- // create data matrix
- const dataMatrix: DataMatrixCell[][] = [];
+ // Phase 2: Aggregate values and build data matrix
+ // Cells start as -1 (no data). Cells that appeared in the query but had only
+ // null values become explicit null cells so they can be styled distinctly.
+ let dataMatrix: any[][] = [];
for (let i = 0; i < rowNames.length; i++) {
dataMatrix.push(new Array(colNames.length).fill(-1));
}
+ // running totals aligned to the final row/column order
+ const rowTotals = new Array(rowNames.length).fill(0);
+ const colTotals = new Array(colNames.length).fill(0);
- frame.forEach((row) => {
- const rowName = row[sourceKey];
- const colName = row[targetKey];
- const r = rowNames.indexOf(rowName);
- const c = colNames.indexOf(colName);
- const v = row[valKey];
- if (r > -1 && c > -1) {
- dataMatrix[r][c] = {
- row: rowName,
- col: colName,
- val: v,
- color: colorMap(v),
- display: valueField.display(v),
- };
+ const aggMethod = options.aggregationMethod || 'sum';
+
+ cellValues.forEach((values, cellKey) => {
+ const [r, c] = cellKey.split(':').map(Number);
+ const v = values.length === 1 ? values[0] : aggregate(values, aggMethod);
+ dataMatrix[r][c] = {
+ row: rowNames[r],
+ col: colNames[c],
+ val: v,
+ color: colorMap(v),
+ display: valueField[0].display(v),
+ isNull: false,
+ };
+ if (typeof v === 'number' && !isNaN(v)) {
+ rowTotals[r] += v;
+ colTotals[c] += v;
}
});
- // parse data for legend
- const legendData: LegendData[] = [];
- if (options.showLegend) {
- let tempValues: any[] = [];
- if (options.legendType === 'range') {
- // get min & max, steps
- let allValues: number[] = Object.values(frame.fields[valKey].values);
- let thisMin = Math.min(...allValues);
- let thisMax = Math.max(...allValues);
- let step = (thisMax - thisMin) / 10;
- // push 10 steps to use for legend
- tempValues = [];
- for(let i = 0; i <= 10; i++) {
- tempValues.push(thisMin + (i*step));
+ // Mark null cells: seen in the query but no non-null value landed in the cell
+ if (!options.inputList) {
+ for (let r = 0; r < compositeRowKeys.length; r++) {
+ for (let c = 0; c < compositeColKeys.length; c++) {
+ if (dataMatrix[r][c] === -1 && seenCells.has(`${compositeRowKeys[r]}||${compositeColKeys[c]}`)) {
+ dataMatrix[r][c] = {
+ row: rowNames[r],
+ col: colNames[c],
+ val: null,
+ color: nullColor,
+ display: valueField[0].display(null),
+ isNull: true,
+ };
+ }
}
- } else {
- // get unique categories
- let allValues: string[] = Object.values(frame.fields[valKey].values);
- let unique = new Set(allValues);
- tempValues = [...unique];
}
- tempValues.forEach((val) => {
- // find display values, unit & color for each
- // store in array
- let text = valueField.display(val).text;
- if (valueField.display(val).suffix) {
- text = text + ` ${valueField.display(val).suffix}`;
+ }
+
+ // Which special-cell states are present, so the legend knows whether to key them.
+ // Collected here while we already hold the matrix, rather than re-walking it later.
+ let hasNull = false;
+ let hasNoData = false;
+ for (let r = 0; r < dataMatrix.length && !(hasNull && hasNoData); r++) {
+ for (let c = 0; c < dataMatrix[r].length; c++) {
+ const cell = dataMatrix[r][c];
+ if (cell === -1) {
+ hasNoData = true;
+ } else if (cell.isNull) {
+ hasNull = true;
}
- legendData.push({
- label: text,
- color: colorMap(val),
- });
- });
+ }
}
+ // The legend describes whatever actually colored the cells — threshold steps when
+ // the field's thresholds are the color source, a ramp when one is in play.
+ const legendModel = options.showLegend
+ ? buildLegend(valueField[0], options, theme, {
+ dataMin,
+ dataMax,
+ domain: valueDomain,
+ colorScale,
+ colorMap,
+ uniqueValues: [...new Set(fieldValuesArray(frame.fields[valKey]))].filter(
+ (v) => v != null && !(typeof v === 'number' && isNaN(v))
+ ),
+ hasNull,
+ hasNoData,
+ })
+ : null;
+
let dataObject = {
- rowNames: rowNames,
- colNames: colNames,
+ rows: rowNames,
+ columns: colNames,
+ colMetadata: colMetadata,
colCategories: colCategories,
+ rowMetadata: rowMetadata,
rowCategories: rowCategories,
data: dataMatrix,
- legend: legendData,
+ legend: legendModel,
+ rowTotals: rowTotals,
+ colTotals: colTotals,
+ valueDomain: valueDomain,
};
return dataObject;
}
diff --git a/src/legend.test.ts b/src/legend.test.ts
new file mode 100644
index 0000000..e958646
--- /dev/null
+++ b/src/legend.test.ts
@@ -0,0 +1,419 @@
+import { createTheme, FieldType, getDisplayProcessor, ThresholdsMode, toDataFrame } from '@grafana/data';
+import {
+ buildLegend,
+ CATEGORICAL_CAP,
+ estimateLegendHeight,
+ layoutLegend,
+ LEGEND_LAYOUT,
+ normalizeLegendType,
+ resolveLegendKind,
+} from './legend';
+import { LegendModel } from './types';
+
+const theme = createTheme();
+
+/** A numeric value field with a real display processor, so legend labels are
+ * formatted through exactly the same path the cells use. */
+function makeField(overrides: any = {}, values: number[] = [0, 5, 10, 20, 50, 100]) {
+ const frame = toDataFrame({
+ fields: [{ name: 'gbps', type: FieldType.number, values, config: overrides }],
+ });
+ const field = frame.fields[0];
+ field.display = getDisplayProcessor({ field, theme });
+ return field;
+}
+
+const absoluteThresholds = {
+ thresholds: {
+ mode: ThresholdsMode.Absolute,
+ steps: [
+ { value: -Infinity, color: 'green' },
+ { value: 20, color: 'orange' },
+ { value: 50, color: 'red' },
+ ],
+ },
+};
+
+const baseOptions: any = {
+ showLegend: true,
+ legendType: 'auto',
+ colorMode: 'standard',
+ valueText: 'Gbps',
+};
+
+function ctxFor(values: number[] = [0, 5, 10, 20, 50, 100], extra: any = {}) {
+ const min = Math.min(...values);
+ const max = Math.max(...values);
+ return {
+ dataMin: min,
+ dataMax: max,
+ domain: { min, max },
+ colorScale: null,
+ colorMap: (v: any) => (typeof v === 'number' && v >= 50 ? '#ff0000' : '#00ff00'),
+ uniqueValues: values,
+ hasNull: false,
+ hasNoData: false,
+ ...extra,
+ };
+}
+
+describe('legend kind resolution', () => {
+ test('standard color mode with thresholds gives a threshold legend', () => {
+ const field = makeField(absoluteThresholds);
+ expect(resolveLegendKind(field, baseOptions, true)).toBe('thresholds');
+ });
+
+ test("the panel's own ramps give a gradient legend", () => {
+ const field = makeField(absoluteThresholds);
+ expect(resolveLegendKind(field, { ...baseOptions, colorMode: 'sequential' }, true)).toBe('gradient');
+ expect(resolveLegendKind(field, { ...baseOptions, colorMode: 'diverging' }, true)).toBe('gradient');
+ });
+
+ test('a continuous Grafana color scheme gives a gradient legend', () => {
+ const field = makeField({ ...absoluteThresholds, color: { mode: 'continuous-GrYlRd' } });
+ expect(resolveLegendKind(field, baseOptions, true)).toBe('gradient');
+ });
+
+ test('a fixed color gives a categorical legend', () => {
+ const field = makeField({ color: { mode: 'fixed', fixedColor: 'blue' } });
+ expect(resolveLegendKind(field, baseOptions, true)).toBe('categorical');
+ });
+
+ test('non-numeric values give a categorical legend whatever the color mode', () => {
+ const field = makeField(absoluteThresholds);
+ expect(resolveLegendKind(field, { ...baseOptions, colorMode: 'sequential' }, false)).toBe('categorical');
+ });
+
+ test('an explicit Legend Type overrides auto-detection', () => {
+ const field = makeField(absoluteThresholds);
+ expect(resolveLegendKind(field, { ...baseOptions, legendType: 'categorical' }, true)).toBe('categorical');
+ expect(resolveLegendKind(field, { ...baseOptions, legendType: 'gradient' }, true)).toBe('gradient');
+ });
+
+ test('legend types saved by older dashboards still resolve', () => {
+ // 'range' was the old continuous option; it now defers to Auto
+ expect(normalizeLegendType('range')).toBe('auto');
+ expect(normalizeLegendType('categorical')).toBe('categorical');
+ expect(normalizeLegendType(undefined)).toBe('auto');
+ });
+});
+
+describe('threshold legend is built from the thresholds, not the data range', () => {
+ test('one entry per step, with the configured boundaries', () => {
+ const model = buildLegend(makeField(absoluteThresholds), baseOptions, theme, ctxFor());
+ expect(model.kind).toBe('thresholds');
+ expect(model.items).toHaveLength(3);
+ expect(model.items.map((i) => [i.from, i.to])).toEqual([
+ [null, 20],
+ [20, 50],
+ [50, null],
+ ]);
+ });
+
+ test('boundary labels never print an infinity', () => {
+ const model = buildLegend(makeField(absoluteThresholds), baseOptions, theme, ctxFor());
+ expect(model.items[0].label).toBe('< 20');
+ expect(model.items[1].label).toBe('20 – 50');
+ expect(model.items[2].label).toBe('≥ 50');
+ expect(JSON.stringify(model.items)).not.toContain('Infinity');
+ });
+
+ test('a legend min floors the first band instead of leaving it unbounded', () => {
+ const model = buildLegend(makeField(absoluteThresholds), { ...baseOptions, legendMin: 0 }, theme, ctxFor());
+ expect(model.items[0].from).toBe(0);
+ expect(model.items[0].label).toBe('0 – 20');
+ // the floor is printed at the bar's left edge
+ expect(model.items[0].fromLabel).toBe('0');
+ // the rest of the scale is untouched
+ expect(model.items.map((i) => i.to)).toEqual([20, 50, null]);
+ });
+
+ test('a non-zero legend min works, and omitting it keeps the unbounded reading', () => {
+ const floored = buildLegend(makeField(absoluteThresholds), { ...baseOptions, legendMin: 10 }, theme, ctxFor());
+ expect(floored.items[0].label).toBe('10 – 20');
+
+ const open = buildLegend(makeField(absoluteThresholds), baseOptions, theme, ctxFor());
+ expect(open.items[0].from).toBeNull();
+ expect(open.items[0].label).toBe('< 20');
+ expect(open.items[0].fromLabel).toBe('');
+ });
+
+ test('labels carry the field unit, matching the cells', () => {
+ const field = makeField({ ...absoluteThresholds, unit: 'bps' });
+ const model = buildLegend(field, baseOptions, theme, ctxFor());
+ // getDisplayProcessor scales bps and appends a suffix
+ expect(model.items[1].label).toMatch(/–/);
+ expect(model.items[2].label.startsWith('≥ ')).toBe(true);
+ });
+
+ test('a step above every data point is still shown', () => {
+ // data tops out at 30 but a threshold sits at 90
+ const field = makeField({
+ thresholds: {
+ mode: ThresholdsMode.Absolute,
+ steps: [
+ { value: -Infinity, color: 'green' },
+ { value: 90, color: 'red' },
+ ],
+ },
+ });
+ const model = buildLegend(field, baseOptions, theme, ctxFor([1, 10, 30]));
+ expect(model.items).toHaveLength(2);
+ expect(model.items[1].from).toBe(90);
+ });
+
+ test('steps are sorted even when the config is out of order', () => {
+ const field = makeField({
+ thresholds: {
+ mode: ThresholdsMode.Absolute,
+ steps: [
+ { value: 50, color: 'red' },
+ { value: -Infinity, color: 'green' },
+ { value: 20, color: 'orange' },
+ ],
+ },
+ });
+ const model = buildLegend(field, baseOptions, theme, ctxFor());
+ expect(model.items.map((i) => i.to)).toEqual([20, 50, null]);
+ });
+
+ test('step colors are resolved through the theme', () => {
+ const model = buildLegend(makeField(absoluteThresholds), baseOptions, theme, ctxFor());
+ model.items.forEach((item) => {
+ expect(item.color).toMatch(/^(#|rgb)/);
+ });
+ expect(new Set(model.items.map((i) => i.color)).size).toBe(3);
+ });
+
+ test('percentage thresholds convert to absolute values over the field range', () => {
+ const field = makeField(
+ {
+ min: 0,
+ max: 200,
+ thresholds: {
+ mode: ThresholdsMode.Percentage,
+ steps: [
+ { value: -Infinity, color: 'green' },
+ { value: 25, color: 'orange' },
+ { value: 75, color: 'red' },
+ ],
+ },
+ },
+ [0, 100, 200]
+ );
+ const model = buildLegend(field, baseOptions, theme, ctxFor([0, 100, 200]));
+ // 25% and 75% of 0..200
+ expect(model.items[1].from).toBe(50);
+ expect(model.items[2].from).toBe(150);
+ });
+
+ test('with no thresholds configured it falls back to a gradient rather than nothing', () => {
+ const field = makeField({ thresholds: { mode: ThresholdsMode.Absolute, steps: [] } });
+ const model = buildLegend(field, baseOptions, theme, ctxFor());
+ expect(model.kind).toBe('gradient');
+ expect(model.stops.length).toBeGreaterThan(1);
+ });
+});
+
+describe('gradient legend', () => {
+ test('samples the panel ramp across the color domain', () => {
+ const colorScale = (v: number) => (v > 50 ? '#ff0000' : '#0000ff');
+ const model = buildLegend(
+ makeField(absoluteThresholds),
+ { ...baseOptions, colorMode: 'sequential' },
+ theme,
+ ctxFor([0, 100], { colorScale })
+ );
+ expect(model.kind).toBe('gradient');
+ expect(model.stops.length).toBeGreaterThan(1);
+ expect(model.stops[0].offset).toBe(0);
+ expect(model.stops[model.stops.length - 1].offset).toBe(1);
+ expect(model.stops[0].color).toBe('#0000ff');
+ expect(model.stops[model.stops.length - 1].color).toBe('#ff0000');
+ });
+
+ test('ticks label the ends and middle of the domain', () => {
+ const model = buildLegend(
+ makeField(),
+ { ...baseOptions, colorMode: 'sequential' },
+ theme,
+ ctxFor([0, 100], { colorScale: () => '#123456' })
+ );
+ expect(model.ticks.map((t) => t.value)).toEqual([0, 50, 100]);
+ expect(model.ticks[0].offset).toBe(0);
+ expect(model.ticks[2].offset).toBe(1);
+ });
+
+ test('diverging adds an emphasized midpoint tick', () => {
+ const model = buildLegend(
+ makeField(),
+ { ...baseOptions, colorMode: 'diverging', divergingMidpoint: 20 },
+ theme,
+ ctxFor([0, 100], { colorScale: () => '#123456' })
+ );
+ const mid = model.ticks.find((t) => t.emphasis);
+ expect(mid).toBeDefined();
+ expect(mid!.value).toBe(20);
+ expect(mid!.offset).toBeCloseTo(0.2);
+ });
+
+ test('a midpoint sitting at the centre replaces the centre tick instead of stacking on it', () => {
+ const model = buildLegend(
+ makeField(),
+ { ...baseOptions, colorMode: 'diverging', divergingMidpoint: 50 },
+ theme,
+ ctxFor([0, 100], { colorScale: () => '#123456' })
+ );
+ expect(model.ticks).toHaveLength(3);
+ expect(model.ticks.filter((t) => t.emphasis)).toHaveLength(1);
+ });
+});
+
+describe('categorical legend', () => {
+ test('caps high-cardinality fields instead of drawing one entry per value', () => {
+ const many = Array.from({ length: 200 }, (_, i) => i);
+ const model = buildLegend(makeField(), { ...baseOptions, legendType: 'categorical' }, theme, ctxFor(many));
+ expect(model.items).toHaveLength(CATEGORICAL_CAP);
+ expect(model.truncated).toBe(200 - CATEGORICAL_CAP);
+ });
+
+ test('entries are sorted and stand for their exact value', () => {
+ const model = buildLegend(
+ makeField(),
+ { ...baseOptions, legendType: 'categorical' },
+ theme,
+ ctxFor([30, 10, 20])
+ );
+ expect(model.items.map((i) => i.from)).toEqual([10, 20, 30]);
+ model.items.forEach((item) => expect(item.from).toBe(item.to));
+ expect(model.truncated).toBe(0);
+ });
+});
+
+describe('null / no-data key', () => {
+ test('is flagged only when such cells exist', () => {
+ const off = buildLegend(makeField(absoluteThresholds), baseOptions, theme, ctxFor());
+ expect(off.states).toEqual({ hasNull: false, hasNoData: false });
+ const on = buildLegend(
+ makeField(absoluteThresholds),
+ baseOptions,
+ theme,
+ ctxFor([1, 2], { hasNull: true, hasNoData: true })
+ );
+ expect(on.states).toEqual({ hasNull: true, hasNoData: true });
+ });
+});
+
+describe('layout', () => {
+ const thresholdModel = () => buildLegend(makeField(absoluteThresholds), baseOptions, theme, ctxFor());
+
+ test('a threshold bar that fits stays a bar', () => {
+ const layout = layoutLegend(thresholdModel(), 600);
+ expect(layout.form).toBe('bar');
+ expect(layout.barW).toBeLessThanOrEqual(600);
+ expect(layout.stepW).toBeGreaterThan(0);
+ });
+
+ test('a threshold bar that will not fit wraps into a list', () => {
+ const many = {
+ thresholds: {
+ mode: ThresholdsMode.Absolute,
+ steps: [{ value: -Infinity, color: 'green' }].concat(
+ Array.from({ length: 20 }, (_, i) => ({ value: (i + 1) * 1000, color: 'red' }))
+ ),
+ },
+ };
+ const model = buildLegend(makeField(many), baseOptions, theme, ctxFor());
+ const layout = layoutLegend(model, 200);
+ expect(layout.form).toBe('list');
+ expect(layout.rows.length).toBeGreaterThan(1);
+ });
+
+ test('nothing is laid out wider than the panel', () => {
+ const many = Array.from({ length: 60 }, (_, i) => i * 11);
+ const model = buildLegend(makeField(), { ...baseOptions, legendType: 'categorical' }, theme, ctxFor(many));
+ const layout = layoutLegend(model, 400);
+ layout.rows.forEach((row) => {
+ const last = row[row.length - 1];
+ expect(last.x + last.width).toBeLessThanOrEqual(400);
+ });
+ });
+
+ test('height grows with entries and shrinks as the panel widens', () => {
+ const many = Array.from({ length: 20 }, (_, i) => i * 7);
+ const model = buildLegend(makeField(), { ...baseOptions, legendType: 'categorical' }, theme, ctxFor(many));
+ const narrow = estimateLegendHeight(model, 200);
+ const wide = estimateLegendHeight(model, 1200);
+ expect(narrow).toBeGreaterThan(wide);
+
+ const few = buildLegend(makeField(), { ...baseOptions, legendType: 'categorical' }, theme, ctxFor([1, 2]));
+ expect(estimateLegendHeight(few, 400)).toBeLessThan(estimateLegendHeight(model, 400));
+ });
+
+ test('the null / no-data keys ride beside the bar, costing width but no height', () => {
+ const plain = thresholdModel();
+ const keyed = buildLegend(
+ makeField(absoluteThresholds),
+ baseOptions,
+ theme,
+ ctxFor([1, 2], { hasNull: true, hasNoData: true })
+ );
+ expect(estimateLegendHeight(keyed, 600)).toBe(estimateLegendHeight(plain, 600));
+
+ const layout = layoutLegend(keyed, 600);
+ const plainLayout = layoutLegend(plain, 600);
+ expect(layout.width).toBeGreaterThan(plainLayout.width);
+ expect(layout.keys.map((k) => k.kind)).toEqual(['null', 'noData']);
+ // one row, clear of the bar, and inside the bar's vertical band
+ layout.keys.forEach((key) => {
+ expect(key.x).toBeGreaterThan(LEGEND_LAYOUT.padX + layout.barW);
+ expect(key.y).toBe(layout.keys[0].y);
+ expect(key.y).toBeGreaterThanOrEqual(layout.bodyY);
+ expect(key.y + LEGEND_LAYOUT.swatch).toBeLessThanOrEqual(layout.bodyY + LEGEND_LAYOUT.barH);
+ });
+ expect(layout.keys[1].x).toBeGreaterThan(layout.keys[0].x);
+ });
+
+ test('the keys drop below the bar when there is no room beside it', () => {
+ const keyed = buildLegend(
+ makeField(absoluteThresholds),
+ baseOptions,
+ theme,
+ ctxFor([1, 2], { hasNull: true, hasNoData: true })
+ );
+ const narrow = layoutLegend(keyed, 150);
+ expect(narrow.form).toBe('bar');
+ narrow.keys.forEach((key) => {
+ expect(key.y).toBeGreaterThan(narrow.bodyY + LEGEND_LAYOUT.barH);
+ });
+ expect(narrow.height).toBeGreaterThan(estimateLegendHeight(thresholdModel(), 150));
+ });
+
+ test('a list legend puts the keys on the last row when it fits', () => {
+ const keyed = buildLegend(
+ makeField(),
+ { ...baseOptions, legendType: 'categorical' },
+ theme,
+ ctxFor([1, 2], { hasNull: true, hasNoData: true })
+ );
+ const layout = layoutLegend(keyed, 600);
+ expect(layout.form).toBe('list');
+ const lastRow = layout.rows[layout.rows.length - 1];
+ const lastEntry = lastRow[lastRow.length - 1];
+ expect(layout.keys[0].y).toBe(layout.bodyY + (layout.rows.length - 1) * LEGEND_LAYOUT.rowStep);
+ expect(layout.keys[0].x).toBeGreaterThan(LEGEND_LAYOUT.padX + lastEntry.x + lastEntry.width);
+ const unkeyed = { ...keyed, states: { hasNull: false, hasNoData: false } } as LegendModel;
+ expect(layout.height).toBe(estimateLegendHeight(unkeyed, 600));
+ });
+
+ test('a truncation note reserves its own row', () => {
+ const many = Array.from({ length: 200 }, (_, i) => i);
+ const model = buildLegend(makeField(), { ...baseOptions, legendType: 'categorical' }, theme, ctxFor(many));
+ const capped = { ...model, truncated: 0 } as LegendModel;
+ expect(estimateLegendHeight(model, 600)).toBeGreaterThan(estimateLegendHeight(capped, 600));
+ });
+
+ test('a missing legend reserves nothing', () => {
+ expect(estimateLegendHeight(null, 600)).toBe(0);
+ });
+});
diff --git a/src/legend.ts b/src/legend.ts
new file mode 100644
index 0000000..92c8751
--- /dev/null
+++ b/src/legend.ts
@@ -0,0 +1,606 @@
+import { getFieldColorModeForField, getMinMaxAndDelta, sortThresholds, ThresholdsMode } from '@grafana/data';
+import { LegendItem, LegendKind, LegendModel, LegendStop, LegendTick } from './types';
+
+/**
+ * Builds the legend's data model.
+ *
+ * The whole point of this module is that the legend describes the *actual* source of
+ * cell color. When cells are colored from the field's thresholds (Standard Options),
+ * the legend is built from those threshold steps — their real colors and their real
+ * boundary values — not from an arbitrary sampling of the data's min/max. Steps that
+ * no data happens to fall into are still shown; that a band is empty is information.
+ *
+ * The model is deliberately free of any drawing concerns so it can be unit tested,
+ * and so `layoutLegend` can size the legend before anything is rendered.
+ */
+
+/** Distinct categorical entries drawn before we give up and summarize the rest. */
+export const CATEGORICAL_CAP = 24;
+
+/** Number of color samples taken along a continuous ramp to build the gradient. */
+const GRADIENT_SAMPLES = 12;
+
+/** Shared geometry, in px. Exported so the renderer and the sizing math cannot drift. */
+export const LEGEND_LAYOUT = {
+ padX: 4,
+ padTop: 2,
+ padBottom: 4,
+ titleH: 15,
+ barH: 14,
+ /** Space under the bar for boundary / tick labels. */
+ barLabelH: 17,
+ /** Vertical pitch of a swatch row (list form, and the null / no-data key). */
+ rowStep: 18,
+ swatch: 12,
+ swatchGap: 6,
+ /** Horizontal gap between entries on the same list row. */
+ itemGap: 14,
+ /** Horizontal gap between the color scale and the null / no-data keys beside it. */
+ keyGap: 16,
+ /** Approximate advance width of an 11px sans-serif character. */
+ charW: 6.4,
+ /** Minimum width of one segment in the stepped-bar form. */
+ minStepW: 34,
+ /** Preferred width of a continuous gradient bar. */
+ gradientW: 200,
+};
+
+/** Approximate rendered width of an 11px label. Good enough for layout decisions;
+ * the renderer never relies on it for anything that would clip. */
+function textW(s: string): number {
+ return String(s == null ? '' : s).length * LEGEND_LAYOUT.charW;
+}
+
+/** Format a value exactly the way the cells format it, so legend numbers and cell
+ * tooltips agree on units and decimals. Falls back to String() when a field has no
+ * display processor attached (i.e. outside a real panel render). */
+function fmt(field: any, v: any): string {
+ try {
+ if (field && typeof field.display === 'function') {
+ const d = field.display(v);
+ // Grafana's suffix usually already carries its own leading space
+ const suffix = d.suffix ? String(d.suffix).trim() : '';
+ return suffix ? `${d.text} ${suffix}` : d.text;
+ }
+ } catch (e) {
+ // fall through to the raw value
+ }
+ return String(v);
+}
+
+/** Label for a half-open band, avoiding ever printing an infinity. */
+function bandLabel(field: any, from: number | null, to: number | null): string {
+ if (from == null && to == null) {
+ return 'all values';
+ }
+ if (from == null) {
+ return `< ${fmt(field, to)}`;
+ }
+ if (to == null) {
+ return `≥ ${fmt(field, from)}`;
+ }
+ return `${fmt(field, from)} – ${fmt(field, to)}`;
+}
+
+function safeColorMode(field: any): any {
+ try {
+ return getFieldColorModeForField(field) || {};
+ } catch (e) {
+ return {};
+ }
+}
+
+/** The min/max percentage thresholds are relative to. Mirrors what `getScaleCalculator`
+ * uses internally (field.state.range, else field config min/max, else the data), so
+ * legend boundaries land on the same values the cells were colored by. */
+function fieldRange(field: any, dataMin: number, dataMax: number): { min: number; max: number; delta: number } {
+ const stateRange = field && field.state ? field.state.range : null;
+ if (stateRange && isFinite(stateRange.min) && isFinite(stateRange.max)) {
+ const delta = isFinite(stateRange.delta) ? stateRange.delta : stateRange.max - stateRange.min;
+ return { min: stateRange.min, max: stateRange.max, delta };
+ }
+ try {
+ const m: any = getMinMaxAndDelta(field);
+ if (isFinite(m.min) && isFinite(m.max)) {
+ return { min: m.min, max: m.max, delta: isFinite(m.delta) ? m.delta : m.max - m.min };
+ }
+ } catch (e) {
+ // fall through to the data range
+ }
+ return { min: dataMin, max: dataMax, delta: dataMax - dataMin };
+}
+
+function hasColoredValueMappings(field: any): boolean {
+ const mappings = field && field.config ? field.config.mappings : null;
+ if (!Array.isArray(mappings) || mappings.length === 0) {
+ return false;
+ }
+ return mappings.some((m: any) => {
+ const opts = m && m.options;
+ if (!opts) {
+ return false;
+ }
+ if (opts.result && opts.result.color) {
+ return true;
+ }
+ // value maps store one result per matched value
+ return Object.keys(opts).some((k) => opts[k] && opts[k].color);
+ });
+}
+
+/** Old dashboards saved `range` / `categorical`; map them onto the new vocabulary
+ * so upgrading a panel never blanks its legend. */
+export function normalizeLegendType(legendType: any): 'auto' | LegendKind {
+ switch (legendType) {
+ case 'thresholds':
+ case 'gradient':
+ case 'categorical':
+ return legendType;
+ case 'range':
+ // 'range' used to mean "whatever continuous form fits" — that is now Auto's job.
+ case 'auto':
+ case undefined:
+ case null:
+ case '':
+ return 'auto';
+ default:
+ return 'auto';
+ }
+}
+
+/** Decide which form the legend takes, following whatever actually colors the cells. */
+export function resolveLegendKind(field: any, options: any, isNumeric: boolean): LegendKind {
+ const requested = normalizeLegendType(options.legendType);
+ if (requested !== 'auto') {
+ return requested;
+ }
+ if (!isNumeric) {
+ return 'categorical';
+ }
+ const colorMode = options.colorMode || 'standard';
+ if (colorMode === 'sequential' || colorMode === 'diverging') {
+ // the panel's own ramp colors the cells, so the legend is that ramp
+ return 'gradient';
+ }
+ const fcm = safeColorMode(field);
+ if (fcm.isContinuous) {
+ return 'gradient';
+ }
+ if (fcm.id === 'fixed' || hasColoredValueMappings(field)) {
+ return 'categorical';
+ }
+ return 'thresholds';
+}
+
+/** One entry per configured threshold step, in ascending order, whether or not any
+ * data falls inside it. The first step is unbounded below (that is how
+ * `getActiveThreshold` treats it) and the last is unbounded above.
+ *
+ * `legendMin` floors that first step: with it the band reads "0 – 30" instead of
+ * "< 30", and its `from` becomes the bound, so hovering it highlights exactly the
+ * cells the label claims. Leave it undefined for the unbounded reading. */
+export function buildThresholdItems(
+ field: any,
+ theme: any,
+ dataMin: number,
+ dataMax: number,
+ legendMin?: number
+): LegendItem[] {
+ const cfg = field && field.config ? field.config.thresholds : null;
+ const rawSteps = cfg && Array.isArray(cfg.steps) ? cfg.steps : [];
+ if (rawSteps.length === 0) {
+ return [];
+ }
+ const steps = sortThresholds(rawSteps.slice());
+ const isPercent = cfg.mode === ThresholdsMode.Percentage;
+ const range = isPercent ? fieldRange(field, dataMin, dataMax) : null;
+ const toAbsolute = (stepValue: number): number => {
+ if (!isPercent || !range || !isFinite(stepValue)) {
+ return stepValue;
+ }
+ return range.min + (stepValue / 100) * range.delta;
+ };
+
+ const bounded = typeof legendMin === 'number' && isFinite(legendMin);
+
+ return steps.map((step: any, i: number) => {
+ const from = i === 0 ? (bounded ? (legendMin as number) : null) : toAbsolute(step.value);
+ const to = i === steps.length - 1 ? null : toAbsolute(steps[i + 1].value);
+ return {
+ label: bandLabel(field, from, to),
+ color: theme.visualization.getColorByName(step.color),
+ from,
+ to,
+ fromLabel: from == null ? '' : fmt(field, from),
+ };
+ });
+}
+
+/** Color stops for a continuous bar, taken from whichever ramp is in play. */
+function buildStops(
+ field: any,
+ options: any,
+ theme: any,
+ domain: { min: number; max: number } | null,
+ colorScale: ((v: number) => string) | null,
+ colorMap: (v: any) => string
+): LegendStop[] {
+ const colorMode = options.colorMode || 'standard';
+ const offsets: number[] = [];
+ for (let i = 0; i < GRADIENT_SAMPLES; i++) {
+ offsets.push(i / (GRADIENT_SAMPLES - 1));
+ }
+
+ if ((colorMode === 'sequential' || colorMode === 'diverging') && colorScale && domain) {
+ return offsets.map((t) => ({ offset: t, color: colorScale(domain.min + t * (domain.max - domain.min)) }));
+ }
+
+ // A Grafana continuous-* scheme already publishes its own stops — use them directly
+ // rather than re-sampling through the display processor.
+ const fcm = safeColorMode(field);
+ if (fcm.isContinuous && typeof fcm.getColors === 'function') {
+ const colors = fcm.getColors(theme) || [];
+ if (colors.length > 0) {
+ return colors.map((color: string, i: number) => ({
+ offset: colors.length > 1 ? i / (colors.length - 1) : 0,
+ color,
+ }));
+ }
+ }
+
+ if (domain) {
+ return offsets.map((t) => ({ offset: t, color: colorMap(domain.min + t * (domain.max - domain.min)) }));
+ }
+ return [];
+}
+
+/** Min / midpoint / max labels for a continuous bar, plus the diverging midpoint. */
+function buildTicks(field: any, options: any, domain: { min: number; max: number } | null): LegendTick[] {
+ if (!domain) {
+ return [];
+ }
+ const span = domain.max - domain.min;
+ const at = (value: number): LegendTick => ({
+ value,
+ label: fmt(field, value),
+ offset: span ? (value - domain.min) / span : 0,
+ });
+ if (!span) {
+ return [at(domain.min)];
+ }
+ const ticks: LegendTick[] = [at(domain.min), at(domain.min + span / 2), at(domain.max)];
+
+ if ((options.colorMode || 'standard') === 'diverging') {
+ const mid = typeof options.divergingMidpoint === 'number' ? options.divergingMidpoint : 0;
+ const offset = (mid - domain.min) / span;
+ if (offset > 0.02 && offset < 0.98) {
+ const emphasized = { ...at(mid), emphasis: true };
+ // don't stack the midpoint on top of the centre tick
+ if (Math.abs(offset - 0.5) < 0.08) {
+ ticks[1] = emphasized;
+ } else {
+ ticks.splice(2, 0, emphasized);
+ }
+ }
+ }
+ return ticks;
+}
+
+/** Distinct values as swatches, sorted, capped so a high-cardinality field cannot
+ * produce hundreds of entries. */
+function buildCategoricalItems(
+ field: any,
+ uniqueValues: any[],
+ colorMap: (v: any) => string
+): { items: LegendItem[]; truncated: number } {
+ const values = uniqueValues.slice().sort((a, b) => {
+ if (typeof a === 'number' && typeof b === 'number') {
+ return a - b;
+ }
+ return String(a).localeCompare(String(b));
+ });
+ const shown = values.slice(0, CATEGORICAL_CAP);
+ const items = shown.map((v) => ({
+ label: fmt(field, v),
+ color: colorMap(v),
+ // an exact-value band: `from === to` means "only cells equal to this"
+ from: typeof v === 'number' ? v : null,
+ to: typeof v === 'number' ? v : null,
+ fromLabel: '',
+ }));
+ return { items, truncated: values.length - shown.length };
+}
+
+export type LegendContext = {
+ dataMin: number;
+ dataMax: number;
+ /** Effective color domain — may be the manual range rather than the data's. */
+ domain: { min: number; max: number } | null;
+ /** The panel's own ramp (sequential / diverging modes only). */
+ colorScale: ((v: number) => string) | null;
+ /** The same value -> color function the cells were painted with. */
+ colorMap: (v: any) => string;
+ /** Distinct non-null values present in the value field. */
+ uniqueValues: any[];
+ hasNull: boolean;
+ hasNoData: boolean;
+};
+
+export function buildLegend(field: any, options: any, theme: any, ctx: LegendContext): LegendModel {
+ const isNumeric = ctx.uniqueValues.some((v) => typeof v === 'number' && !isNaN(v)) || ctx.uniqueValues.length === 0;
+ let kind = resolveLegendKind(field, options, isNumeric);
+
+ const model: LegendModel = {
+ kind,
+ title: options.valueText || (field && field.name) || '',
+ items: [],
+ stops: [],
+ domain: ctx.domain,
+ ticks: [],
+ states: { hasNull: ctx.hasNull, hasNoData: ctx.hasNoData },
+ truncated: 0,
+ };
+
+ if (kind === 'thresholds') {
+ model.items = buildThresholdItems(field, theme, ctx.dataMin, ctx.dataMax, options.legendMin);
+ if (model.items.length === 0) {
+ // No thresholds configured at all — a gradient still describes the cells honestly.
+ kind = 'gradient';
+ model.kind = kind;
+ }
+ }
+
+ if (kind === 'gradient') {
+ model.stops = buildStops(field, options, theme, ctx.domain, ctx.colorScale, ctx.colorMap);
+ model.ticks = buildTicks(field, options, ctx.domain);
+ if (model.stops.length === 0) {
+ // nothing numeric to ramp over; fall back to naming the values we do have
+ kind = 'categorical';
+ model.kind = kind;
+ }
+ }
+
+ if (kind === 'categorical') {
+ const { items, truncated } = buildCategoricalItems(field, ctx.uniqueValues, ctx.colorMap);
+ model.items = items;
+ model.truncated = truncated;
+ }
+
+ return model;
+}
+
+/* ------------------------------------------------------------------ *
+ * Layout
+ *
+ * Computed from the model alone so the panel can reserve exactly the height the
+ * legend needs *before* drawing it — the old code reserved a flat 64px whatever
+ * the legend turned out to be.
+ * ------------------------------------------------------------------ */
+
+export type LegendRowEntry = { item: LegendItem; x: number; width: number };
+
+/** A placed null / no-data swatch. The keys ride beside the color scale when there is
+ * room for them, so they cost no vertical space at all. */
+export type LegendKeyEntry = {
+ kind: 'null' | 'noData';
+ label: string;
+ x: number;
+ y: number;
+ width: number;
+};
+
+export type LegendLayout = {
+ /** `bar` = one contiguous stepped / gradient bar. `list` = wrapped swatch rows. */
+ form: 'bar' | 'list';
+ width: number;
+ height: number;
+ /** Bar form: total bar width, and the width of one step (thresholds only). */
+ barW: number;
+ stepW: number;
+ /** List form: entries already assigned to wrapped rows. */
+ rows: LegendRowEntry[][];
+ /** y of the bar / first swatch row. */
+ bodyY: number;
+ /** Placed null / no-data keys, in draw order. Empty when neither state is present. */
+ keys: LegendKeyEntry[];
+};
+
+function swatchEntryWidth(label: string): number {
+ const L = LEGEND_LAYOUT;
+ return L.swatch + L.swatchGap + textW(label);
+}
+
+function entryWidth(item: LegendItem): number {
+ return swatchEntryWidth(item.label);
+}
+
+/** The state keys a model needs, unplaced. Order matters: null then "no data". */
+function stateKeys(model: LegendModel): Array<{ kind: 'null' | 'noData'; label: string; width: number }> {
+ const keys: Array<{ kind: 'null' | 'noData'; label: string; width: number }> = [];
+ if (model.states.hasNull) {
+ keys.push({ kind: 'null', label: 'null', width: swatchEntryWidth('null') });
+ }
+ if (model.states.hasNoData) {
+ keys.push({ kind: 'noData', label: 'no data', width: swatchEntryWidth('no data') });
+ }
+ return keys;
+}
+
+/** Lay the keys out left-to-right on one row starting at `x`. */
+function placeKeys(
+ keys: Array<{ kind: 'null' | 'noData'; label: string; width: number }>,
+ x: number,
+ y: number
+): LegendKeyEntry[] {
+ const L = LEGEND_LAYOUT;
+ let cursor = x;
+ return keys.map((key) => {
+ const placed = { ...key, x: cursor, y };
+ cursor += key.width + L.itemGap;
+ return placed;
+ });
+}
+
+/** Total width of a one-row key block, gaps included. */
+function keysWidth(keys: Array<{ width: number }>): number {
+ const L = LEGEND_LAYOUT;
+ if (keys.length === 0) {
+ return 0;
+ }
+ return keys.reduce((sum, key) => sum + key.width, 0) + (keys.length - 1) * L.itemGap;
+}
+
+/** Greedily pack entries into rows no wider than `avail`. Always emits at least one
+ * entry per row so a single over-long label cannot produce an empty row. */
+function packRows(items: LegendItem[], avail: number): LegendRowEntry[][] {
+ const L = LEGEND_LAYOUT;
+ const rows: LegendRowEntry[][] = [];
+ let row: LegendRowEntry[] = [];
+ let x = 0;
+ items.forEach((item) => {
+ const w = entryWidth(item);
+ if (row.length > 0 && x + w > avail) {
+ rows.push(row);
+ row = [];
+ x = 0;
+ }
+ row.push({ item, x, width: w });
+ x += w + L.itemGap;
+ });
+ if (row.length > 0) {
+ rows.push(row);
+ }
+ return rows;
+}
+
+export function layoutLegend(model: LegendModel | null, availableWidth: number): LegendLayout {
+ const L = LEGEND_LAYOUT;
+ const empty: LegendLayout = {
+ form: 'list',
+ width: 0,
+ height: 0,
+ barW: 0,
+ stepW: 0,
+ rows: [],
+ bodyY: 0,
+ keys: [],
+ };
+ if (!model) {
+ return empty;
+ }
+
+ const avail = Math.max(120, (availableWidth || 0) - L.padX * 2);
+ const bodyY = L.padTop + L.titleH;
+ const keys = stateKeys(model);
+ const keysW = keysWidth(keys);
+
+ /** Both bar forms place their keys the same way: beside the bar when they fit,
+ * on their own row underneath when they don't. */
+ const barKeys = (barW: number, scaleRight: number) => {
+ // the bar's own labels already reserve barLabelH below it
+ const scaleBottom = bodyY + L.barH + L.barLabelH;
+ if (keys.length === 0) {
+ return { keys: [] as LegendKeyEntry[], right: scaleRight, bottom: scaleBottom };
+ }
+ const besideX = L.padX + barW + L.keyGap;
+ if (besideX + keysW <= avail) {
+ // centred on the bar, so the keys cost no height at all
+ return {
+ keys: placeKeys(keys, besideX, bodyY + (L.barH - L.swatch) / 2),
+ right: Math.max(scaleRight, besideX + keysW),
+ bottom: scaleBottom,
+ };
+ }
+ return {
+ keys: placeKeys(keys, L.padX, scaleBottom),
+ right: Math.max(scaleRight, L.padX + keysW),
+ bottom: scaleBottom + L.rowStep,
+ };
+ };
+
+ // --- continuous bar -------------------------------------------------
+ if (model.kind === 'gradient') {
+ const barW = Math.min(L.gradientW, avail);
+ const widestTick = model.ticks.reduce((m, t) => Math.max(m, textW(t.label)), 0);
+ const placed = barKeys(barW, Math.max(L.padX + barW + widestTick / 2, L.padX + textW(model.title)));
+ return {
+ form: 'bar',
+ width: placed.right + L.padX,
+ height: placed.bottom + L.padBottom,
+ barW,
+ stepW: 0,
+ rows: [],
+ bodyY,
+ keys: placed.keys,
+ };
+ }
+
+ // --- stepped threshold bar, when the boundary labels can fit ---------
+ if (model.kind === 'thresholds' && model.items.length > 1) {
+ // one label sits under each internal division
+ const widestBoundary = model.items.slice(1).reduce((m, item) => Math.max(m, textW(item.fromLabel)), 0);
+ // a bounded first step also prints its value at the bar's left edge; keep that
+ // start-anchored label clear of the centred label at the first division
+ const firstW = textW(model.items[0].fromLabel);
+ const stepW = Math.max(L.minStepW, widestBoundary + 8, firstW + widestBoundary / 2 + 6);
+ const barW = stepW * model.items.length;
+ if (barW <= avail) {
+ const placed = barKeys(barW, Math.max(L.padX + barW, L.padX + textW(model.title)));
+ return {
+ form: 'bar',
+ width: placed.right + L.padX,
+ height: placed.bottom + L.padBottom,
+ barW,
+ stepW,
+ rows: [],
+ bodyY,
+ keys: placed.keys,
+ };
+ }
+ }
+
+ // --- wrapped swatch list (categorical, or thresholds that won't fit) --
+ const rows = packRows(model.items, avail);
+ const noteRows = model.truncated > 0 ? 1 : 0;
+ const listBottom = bodyY + (rows.length + noteRows) * L.rowStep;
+ const widestRow = rows.reduce((m, row) => {
+ const last = row[row.length - 1];
+ return last ? Math.max(m, last.x + last.width) : m;
+ }, 0);
+
+ // tack the keys onto the end of the last row when it has room — but never onto a
+ // row a truncation note is about to follow
+ const lastRow = rows[rows.length - 1];
+ const lastRight = lastRow ? lastRow[lastRow.length - 1].x + lastRow[lastRow.length - 1].width : 0;
+ const canShareRow = keys.length > 0 && noteRows === 0 && lastRow && lastRight + L.itemGap + keysW <= avail;
+ let placedKeys: LegendKeyEntry[] = [];
+ let keysRight = 0;
+ let bottom = listBottom;
+ if (keys.length > 0) {
+ if (canShareRow) {
+ const x = L.padX + lastRight + L.itemGap;
+ placedKeys = placeKeys(keys, x, bodyY + (rows.length - 1) * L.rowStep);
+ keysRight = x + keysW;
+ } else {
+ placedKeys = placeKeys(keys, L.padX, listBottom);
+ keysRight = L.padX + keysW;
+ bottom = listBottom + L.rowStep;
+ }
+ }
+
+ return {
+ form: 'list',
+ width: Math.max(L.padX + widestRow, L.padX + textW(model.title), keysRight) + L.padX,
+ height: bottom + L.padBottom,
+ barW: 0,
+ stepW: 0,
+ rows,
+ bodyY,
+ keys: placedKeys,
+ };
+}
+
+/** Total vertical space the legend needs, so the grid can be sized around it. */
+export function estimateLegendHeight(model: LegendModel | null, availableWidth: number): number {
+ return layoutLegend(model, availableWidth).height;
+}
diff --git a/src/legendRender.js b/src/legendRender.js
new file mode 100644
index 0000000..b4720a0
--- /dev/null
+++ b/src/legendRender.js
@@ -0,0 +1,321 @@
+import { select } from 'd3-selection';
+import { LEGEND_LAYOUT, layoutLegend } from './legend';
+
+/**
+ * Draws the legend model produced by `./legend.ts`.
+ *
+ * Every form here describes the real source of cell color, and every entry knows the
+ * value band it stands for, so hovering it highlights exactly the cells it covers
+ * rather than an approximate tolerance window.
+ *
+ * All geometry comes from `layoutLegend`, the same function the panel uses to reserve
+ * vertical space before the grid is sized — the legend can never claim more room than
+ * it was given.
+ */
+
+const FONT_SIZE = '11px';
+
+/** Draw the "null" / "no data" keys wherever `layoutLegend` placed them — beside the
+ * color scale when it left room, otherwise on their own row beneath it. */
+function drawStateKeys(svg, keys, opts, theme, handlers) {
+ const L = LEGEND_LAYOUT;
+ keys.forEach((key) => {
+ const isNull = key.kind === 'null';
+ const fill = isNull ? opts.nullColor : opts.distinctNoData ? 'none' : opts.defaultColor;
+ const stroke = isNull ? 'none' : opts.distinctNoData ? opts.noDataStroke : 'none';
+ svg
+ .append('rect')
+ .attr('x', key.x)
+ .attr('y', key.y)
+ .attr('width', L.swatch)
+ .attr('height', L.swatch)
+ .attr('rx', Math.min(opts.cornerRadius, 3))
+ .attr('fill', fill)
+ .attr('stroke', stroke)
+ .attr('stroke-width', stroke === 'none' ? 0 : 1)
+ .style('cursor', 'pointer')
+ .on('mouseover', function () {
+ handlers.highlightByState(isNull);
+ })
+ .on('mouseout', function () {
+ handlers.clearHighlight();
+ })
+ .append('title')
+ .text(key.label);
+ svg
+ .append('text')
+ .attr('x', key.x + L.swatch + L.swatchGap)
+ .attr('y', key.y + L.swatch - 2)
+ .attr('font-size', FONT_SIZE)
+ .style('font-family', theme.typography.fontFamily)
+ .attr('fill', theme.colors.text.secondary)
+ .text(key.label);
+ });
+}
+
+/** Continuous ramp: a smooth bar with a real tick axis. */
+function drawGradient(svg, model, layout, id, theme, handlers) {
+ const L = LEGEND_LAYOUT;
+ const barW = layout.barW;
+ const y = layout.bodyY;
+
+ const gradId = `matrix-grad-${id}`;
+ const grad = svg
+ .append('defs')
+ .append('linearGradient')
+ .attr('id', gradId)
+ .attr('x1', '0%')
+ .attr('x2', '100%')
+ .attr('y1', '0%')
+ .attr('y2', '0%');
+ model.stops.forEach((s) => {
+ grad
+ .append('stop')
+ .attr('offset', `${s.offset * 100}%`)
+ .attr('stop-color', s.color);
+ });
+
+ const domain = model.domain;
+ const span = domain ? domain.max - domain.min : 0;
+ svg
+ .append('rect')
+ .attr('x', L.padX)
+ .attr('y', y)
+ .attr('width', barW)
+ .attr('height', L.barH)
+ .attr('rx', 2)
+ .attr('fill', `url(#${gradId})`)
+ .attr('stroke', theme.colors.border.weak)
+ .attr('stroke-width', 1)
+ .style('cursor', domain ? 'crosshair' : null)
+ .on('mousemove', function (event) {
+ if (!domain) {
+ return;
+ }
+ const mx = Math.max(0, Math.min(barW, event.offsetX - L.padX));
+ const at = domain.min + (mx / barW) * span;
+ // a narrow window around the cursor, so sweeping the bar sweeps the matrix
+ const band = (span || 1) * 0.03;
+ handlers.highlightByRange(at - band, at + band);
+ })
+ .on('mouseout', function () {
+ handlers.clearHighlight();
+ });
+
+ const labelY = y + L.barH + 13;
+ model.ticks.forEach((tick) => {
+ const tx = L.padX + tick.offset * barW;
+ if (tick.emphasis) {
+ // the diverging midpoint reads as a line through the ramp
+ svg
+ .append('line')
+ .attr('x1', tx)
+ .attr('x2', tx)
+ .attr('y1', y - 2)
+ .attr('y2', y + L.barH + 2)
+ .attr('stroke', theme.colors.text.primary)
+ .attr('stroke-width', 1);
+ } else {
+ svg
+ .append('line')
+ .attr('x1', tx)
+ .attr('x2', tx)
+ .attr('y1', y + L.barH)
+ .attr('y2', y + L.barH + 3)
+ .attr('stroke', theme.colors.border.weak)
+ .attr('stroke-width', 1);
+ }
+ svg
+ .append('text')
+ .attr('x', tx)
+ .attr('y', labelY)
+ .attr('text-anchor', tick.offset <= 0.01 ? 'start' : tick.offset >= 0.99 ? 'end' : 'middle')
+ .attr('font-size', FONT_SIZE)
+ .style('font-family', theme.typography.fontFamily)
+ .attr('fill', theme.colors.text.primary)
+ .text(tick.label);
+ });
+}
+
+/** Thresholds: contiguous equal-width steps with the configured boundary values
+ * printed under each division. Step widths are equal because the outer bands are
+ * unbounded — there is no honest proportional width for "≥ 80". */
+function drawSteppedBar(svg, model, layout, opts, theme, handlers) {
+ const L = LEGEND_LAYOUT;
+ const y = layout.bodyY;
+ const stepW = layout.stepW;
+
+ model.items.forEach((item, i) => {
+ svg
+ .append('rect')
+ .attr('x', L.padX + i * stepW)
+ .attr('y', y)
+ .attr('width', stepW)
+ .attr('height', L.barH)
+ .attr('fill', item.color)
+ .style('cursor', 'pointer')
+ .on('mouseover', function () {
+ handlers.highlightByRange(item.from, item.to);
+ })
+ .on('mouseout', function () {
+ handlers.clearHighlight();
+ })
+ .append('title')
+ .text(item.label);
+ });
+
+ // single outline over the whole bar, so the steps read as one scale
+ svg
+ .append('rect')
+ .attr('x', L.padX)
+ .attr('y', y)
+ .attr('width', layout.barW)
+ .attr('height', L.barH)
+ .attr('rx', 2)
+ .attr('fill', 'none')
+ .attr('stroke', theme.colors.border.weak)
+ .attr('stroke-width', 1)
+ .style('pointer-events', 'none');
+
+ // boundary values sit at the divisions between steps, not under their centres
+ const labelY = y + L.barH + 13;
+
+ // a bounded first step prints its floor at the bar's left edge, so the scale reads
+ // "0 30 70" rather than starting at nothing
+ if (model.items[0] && model.items[0].fromLabel) {
+ svg
+ .append('line')
+ .attr('x1', L.padX)
+ .attr('x2', L.padX)
+ .attr('y1', y + L.barH)
+ .attr('y2', y + L.barH + 3)
+ .attr('stroke', theme.colors.border.weak)
+ .attr('stroke-width', 1);
+ svg
+ .append('text')
+ .attr('x', L.padX)
+ .attr('y', labelY)
+ .attr('text-anchor', 'start')
+ .attr('font-size', FONT_SIZE)
+ .style('font-family', theme.typography.fontFamily)
+ .attr('fill', theme.colors.text.primary)
+ .text(model.items[0].fromLabel);
+ }
+
+ model.items.slice(1).forEach((item, i) => {
+ const tx = L.padX + (i + 1) * stepW;
+ svg
+ .append('line')
+ .attr('x1', tx)
+ .attr('x2', tx)
+ .attr('y1', y + L.barH)
+ .attr('y2', y + L.barH + 3)
+ .attr('stroke', theme.colors.border.weak)
+ .attr('stroke-width', 1);
+ svg
+ .append('text')
+ .attr('x', tx)
+ .attr('y', labelY)
+ .attr('text-anchor', 'middle')
+ .attr('font-size', FONT_SIZE)
+ .style('font-family', theme.typography.fontFamily)
+ .attr('fill', theme.colors.text.primary)
+ .text(item.fromLabel);
+ });
+}
+
+/** Wrapped swatch + label rows. Used for categorical values, and for threshold
+ * steps whose labels are too wide to fit one bar across the panel. */
+function drawList(svg, model, layout, opts, theme, handlers) {
+ const L = LEGEND_LAYOUT;
+ layout.rows.forEach((row, ri) => {
+ const y = layout.bodyY + ri * L.rowStep;
+ row.forEach((entry) => {
+ svg
+ .append('rect')
+ .attr('x', L.padX + entry.x)
+ .attr('y', y)
+ .attr('width', L.swatch)
+ .attr('height', L.swatch)
+ .attr('rx', Math.min(opts.cornerRadius, 3))
+ .attr('fill', entry.item.color)
+ .style('cursor', 'pointer')
+ .on('mouseover', function () {
+ handlers.highlightByRange(entry.item.from, entry.item.to);
+ })
+ .on('mouseout', function () {
+ handlers.clearHighlight();
+ })
+ .append('title')
+ .text(entry.item.label);
+ svg
+ .append('text')
+ .attr('x', L.padX + entry.x + L.swatch + L.swatchGap)
+ .attr('y', y + L.swatch - 2)
+ .attr('font-size', FONT_SIZE)
+ .style('font-family', theme.typography.fontFamily)
+ .attr('fill', theme.colors.text.primary)
+ .text(entry.item.label);
+ });
+ });
+
+ if (model.truncated > 0) {
+ svg
+ .append('text')
+ .attr('x', L.padX)
+ .attr('y', layout.bodyY + layout.rows.length * L.rowStep + L.swatch - 2)
+ .attr('font-size', FONT_SIZE)
+ .style('font-family', theme.typography.fontFamily)
+ .attr('fill', theme.colors.text.secondary)
+ .text(`+${model.truncated} more`);
+ }
+}
+
+/**
+ * @param {HTMLElement} elem Container the matrix was drawn into
+ * @param {*} model The legend model from buildLegend()
+ * @param {*} id The panel id
+ * @param {*} opts { availableWidth, cornerRadius, nullColor, defaultColor, distinctNoData, noDataStroke }
+ * @param {*} theme Grafana theme
+ * @param {*} handlers { highlightByRange, highlightByState, clearHighlight }
+ */
+function renderLegend(elem, model, id, opts, theme, handlers) {
+ if (!model) {
+ return;
+ }
+ const L = LEGEND_LAYOUT;
+ const layout = layoutLegend(model, opts.availableWidth);
+
+ const svg = select(elem)
+ .append('div')
+ .attr('class', `matrix-legend-${id}`)
+ .append('svg')
+ .attr('id', `legend-${id}`)
+ .attr('width', layout.width)
+ .attr('height', layout.height)
+ .attr('role', 'img')
+ .attr('aria-label', `${model.title} legend`);
+
+ if (model.title) {
+ svg
+ .append('text')
+ .attr('x', L.padX)
+ .attr('y', L.padTop + 11)
+ .attr('font-size', FONT_SIZE)
+ .style('font-family', theme.typography.fontFamily)
+ .attr('fill', theme.colors.text.secondary)
+ .text(model.title);
+ }
+
+ if (model.kind === 'gradient') {
+ drawGradient(svg, model, layout, id, theme, handlers);
+ } else if (layout.form === 'bar') {
+ drawSteppedBar(svg, model, layout, opts, theme, handlers);
+ } else {
+ drawList(svg, model, layout, opts, theme, handlers);
+ }
+
+ drawStateKeys(svg, layout.keys, opts, theme, handlers);
+}
+
+export { renderLegend };
diff --git a/src/legendRender.test.js b/src/legendRender.test.js
new file mode 100644
index 0000000..9470472
--- /dev/null
+++ b/src/legendRender.test.js
@@ -0,0 +1,300 @@
+import { createTheme, FieldType, getDisplayProcessor, ThresholdsMode, toDataFrame } from '@grafana/data';
+import { buildLegend, layoutLegend } from './legend';
+import { renderLegend } from './legendRender.js';
+
+const theme = createTheme();
+
+function makeField(config = {}, values = [0, 5, 10, 20, 50, 100]) {
+ const frame = toDataFrame({ fields: [{ name: 'gbps', type: FieldType.number, values, config }] });
+ const field = frame.fields[0];
+ field.display = getDisplayProcessor({ field, theme });
+ return field;
+}
+
+const steps = (...pairs) => pairs.map(([value, color]) => ({ value, color }));
+
+const options = { showLegend: true, legendType: 'auto', colorMode: 'standard', valueText: 'Gbps' };
+
+const renderOpts = {
+ availableWidth: 600,
+ cornerRadius: 2,
+ nullColor: '#e6e6e6',
+ defaultColor: '#dddddd',
+ distinctNoData: true,
+ noDataStroke: '#cccccc',
+};
+
+function ctx(values = [0, 5, 10, 20, 50, 100], extra = {}) {
+ const min = Math.min(...values);
+ const max = Math.max(...values);
+ return {
+ dataMin: min,
+ dataMax: max,
+ domain: { min, max },
+ colorScale: null,
+ colorMap: () => '#336699',
+ uniqueValues: values,
+ hasNull: false,
+ hasNoData: false,
+ ...extra,
+ };
+}
+
+/** Render into a detached container and hand back the container + legend svg. */
+function draw(model, width = 600, handlers = {}) {
+ const el = document.createElement('div');
+ document.body.appendChild(el);
+ const calls = { range: [], state: [], clear: 0 };
+ renderLegend(
+ el,
+ model,
+ 'test',
+ { ...renderOpts, availableWidth: width },
+ theme,
+ {
+ highlightByRange: (from, to) => calls.range.push([from, to]),
+ highlightByState: (wantNull) => calls.state.push(wantNull),
+ clearHighlight: () => (calls.clear += 1),
+ ...handlers,
+ }
+ );
+ return { el, svg: el.querySelector('svg'), calls };
+}
+
+const texts = (el) => [...el.querySelectorAll('text')].map((t) => t.textContent);
+const titles = (el) => [...el.querySelectorAll('title')].map((t) => t.textContent);
+
+const thresholdModel = (config, values, opts = {}) =>
+ buildLegend(makeField(config), { ...options, ...opts }, theme, ctx(values));
+
+describe('threshold legend rendering', () => {
+ const three = {
+ thresholds: {
+ mode: ThresholdsMode.Absolute,
+ steps: steps([-Infinity, 'green'], [20, 'orange'], [50, 'red']),
+ },
+ };
+
+ test('draws one segment per step and prints the boundaries between them', () => {
+ const { el } = draw(thresholdModel(three));
+ // 3 step rects + 1 outline
+ expect(el.querySelectorAll('rect')).toHaveLength(4);
+ expect(texts(el)).toEqual(['Gbps', '20', '50']);
+ expect(titles(el)).toEqual(['< 20', '20 – 50', '≥ 50']);
+ });
+
+ test('a legend min prints the floor at the left edge and bounds the first band', () => {
+ const model = thresholdModel(three, undefined, { legendMin: 0 });
+ const { el, calls } = draw(model);
+ expect(texts(el)).toEqual(['Gbps', '0', '20', '50']);
+ expect(titles(el)).toEqual(['0 – 20', '20 – 50', '≥ 50']);
+
+ const floorLabel = [...el.querySelectorAll('text')].find((t) => t.textContent === '0');
+ expect(+floorLabel.getAttribute('x')).toBe(4);
+ expect(floorLabel.getAttribute('text-anchor')).toBe('start');
+
+ // hovering the first step now highlights the bounded range, not everything below 20
+ el.querySelectorAll('rect')[0].dispatchEvent(new MouseEvent('mouseover'));
+ expect(calls.range).toEqual([[0, 20]]);
+ });
+
+ test('segments are contiguous and stay inside the declared svg width', () => {
+ const model = thresholdModel(three);
+ const { el, svg } = draw(model);
+ const layout = layoutLegend(model, 600);
+ const rects = [...el.querySelectorAll('rect')].slice(0, 3);
+ rects.forEach((r, i) => {
+ expect(+r.getAttribute('x')).toBeCloseTo(4 + i * layout.stepW);
+ expect(+r.getAttribute('width')).toBeCloseTo(layout.stepW);
+ });
+ const right = +rects[2].getAttribute('x') + +rects[2].getAttribute('width');
+ expect(right).toBeLessThanOrEqual(+svg.getAttribute('width'));
+ });
+
+ test('hovering a step highlights exactly that band', () => {
+ const { el, calls } = draw(thresholdModel(three));
+ const rects = [...el.querySelectorAll('rect')];
+ rects[0].dispatchEvent(new MouseEvent('mouseover'));
+ rects[1].dispatchEvent(new MouseEvent('mouseover'));
+ rects[2].dispatchEvent(new MouseEvent('mouseover'));
+ expect(calls.range).toEqual([
+ [null, 20],
+ [20, 50],
+ [50, null],
+ ]);
+ rects[0].dispatchEvent(new MouseEvent('mouseout'));
+ expect(calls.clear).toBe(1);
+ });
+
+ test('too many steps for the width fall back to a wrapped list', () => {
+ const many = {
+ thresholds: {
+ mode: ThresholdsMode.Absolute,
+ steps: steps([-Infinity, 'green']).concat(
+ Array.from({ length: 9 }, (_, i) => ({ value: (i + 1) * 1000, color: 'red' }))
+ ),
+ },
+ };
+ const model = thresholdModel(many);
+ const { el, svg } = draw(model, 200);
+ expect(layoutLegend(model, 200).form).toBe('list');
+ // every entry still gets a swatch and a readable band label
+ expect(el.querySelectorAll('rect')).toHaveLength(10);
+ expect(titles(el)).toHaveLength(10);
+ // and the drawing stays within the svg it declared
+ [...el.querySelectorAll('rect')].forEach((r) => {
+ expect(+r.getAttribute('x') + +r.getAttribute('width')).toBeLessThanOrEqual(+svg.getAttribute('width'));
+ });
+ });
+});
+
+describe('gradient legend rendering', () => {
+ test('emits a gradient fill with stops and a tick axis', () => {
+ const model = buildLegend(makeField(), { ...options, colorMode: 'sequential' }, theme, {
+ ...ctx([0, 100]),
+ colorScale: (v) => (v > 50 ? '#ff0000' : '#0000ff'),
+ });
+ const { el } = draw(model);
+ expect(el.querySelectorAll('linearGradient')).toHaveLength(1);
+ expect(el.querySelectorAll('stop').length).toBeGreaterThan(1);
+ const bar = el.querySelector('rect');
+ expect(bar.getAttribute('fill')).toMatch(/^url\(#matrix-grad-/);
+ expect(texts(el)).toEqual(['Gbps', '0', '50', '100']);
+ });
+
+ test('sweeping the bar highlights a window around the cursor value', () => {
+ const model = buildLegend(makeField(), { ...options, colorMode: 'sequential' }, theme, {
+ ...ctx([0, 100]),
+ colorScale: () => '#123456',
+ });
+ const { el, calls } = draw(model);
+ const bar = el.querySelector('rect');
+ const ev = new MouseEvent('mousemove');
+ Object.defineProperty(ev, 'offsetX', { get: () => 104 }); // padX 4 + half of a 200px bar
+ bar.dispatchEvent(ev);
+ expect(calls.range).toHaveLength(1);
+ const [from, to] = calls.range[0];
+ expect((from + to) / 2).toBeCloseTo(50, 5);
+ expect(to - from).toBeGreaterThan(0);
+ });
+
+ test('the diverging midpoint is drawn as a line through the bar', () => {
+ const model = buildLegend(
+ makeField(),
+ { ...options, colorMode: 'diverging', divergingMidpoint: 30 },
+ theme,
+ { ...ctx([0, 100]), colorScale: () => '#123456' }
+ );
+ const { el } = draw(model);
+ expect(texts(el)).toContain('30');
+ // the emphasized tick spans the bar height; plain ticks are 3px stubs
+ const spans = [...el.querySelectorAll('line')].map((l) => +l.getAttribute('y2') - +l.getAttribute('y1'));
+ expect(Math.max(...spans)).toBeGreaterThan(10);
+ });
+});
+
+describe('categorical legend rendering', () => {
+ test('wraps into rows instead of running off the panel', () => {
+ const values = Array.from({ length: 40 }, (_, i) => i * 3);
+ const model = buildLegend(makeField(), { ...options, legendType: 'categorical' }, theme, ctx(values));
+ const { el, svg } = draw(model, 300);
+ const layout = layoutLegend(model, 300);
+ expect(layout.rows.length).toBeGreaterThan(1);
+ const width = +svg.getAttribute('width');
+ [...el.querySelectorAll('rect')].forEach((r) => {
+ expect(+r.getAttribute('x') + +r.getAttribute('width')).toBeLessThanOrEqual(width);
+ });
+ // distinct y positions prove it actually wrapped
+ expect(new Set([...el.querySelectorAll('rect')].map((r) => r.getAttribute('y'))).size).toBe(layout.rows.length);
+ });
+
+ test('summarizes the entries past the cap rather than drawing them', () => {
+ const values = Array.from({ length: 200 }, (_, i) => i);
+ const model = buildLegend(makeField(), { ...options, legendType: 'categorical' }, theme, ctx(values));
+ const { el } = draw(model, 600);
+ expect(el.querySelectorAll('rect')).toHaveLength(24);
+ expect(texts(el)).toContain('+176 more');
+ });
+});
+
+describe('null / no-data key', () => {
+ test('is drawn only for the states present, and highlights them', () => {
+ const model = buildLegend(
+ makeField({ thresholds: { mode: ThresholdsMode.Absolute, steps: steps([-Infinity, 'green'], [50, 'red']) } }),
+ options,
+ theme,
+ ctx([1, 2], { hasNull: true, hasNoData: true })
+ );
+ const { el, calls } = draw(model);
+ expect(texts(el)).toEqual(expect.arrayContaining(['null', 'no data']));
+ const keyRects = [...el.querySelectorAll('rect')].slice(-2);
+ keyRects[0].dispatchEvent(new MouseEvent('mouseover'));
+ keyRects[1].dispatchEvent(new MouseEvent('mouseover'));
+ expect(calls.state).toEqual([true, false]);
+ });
+
+ test('sits beside the bar on one row rather than stacked underneath it', () => {
+ const model = buildLegend(
+ makeField({ thresholds: { mode: ThresholdsMode.Absolute, steps: steps([-Infinity, 'green'], [50, 'red']) } }),
+ options,
+ theme,
+ ctx([1, 2], { hasNull: true, hasNoData: true })
+ );
+ const layout = layoutLegend(model, 600);
+ const { el } = draw(model, 600);
+ const keyRects = [...el.querySelectorAll('rect')].slice(-2);
+ const y = keyRects.map((r) => +r.getAttribute('y'));
+ const x = keyRects.map((r) => +r.getAttribute('x'));
+
+ // same row, left to right, clear of the bar
+ expect(y[0]).toBe(y[1]);
+ expect(x[1]).toBeGreaterThan(x[0]);
+ expect(x[0]).toBeGreaterThan(layout.barW);
+ // inside the bar's vertical band, so they add no height
+ expect(y[0]).toBeGreaterThanOrEqual(layout.bodyY);
+ expect(y[0] + 12).toBeLessThanOrEqual(layout.bodyY + 14);
+ });
+
+ test('is absent when no such cells exist', () => {
+ const model = buildLegend(
+ makeField({ thresholds: { mode: ThresholdsMode.Absolute, steps: steps([-Infinity, 'green'], [50, 'red']) } }),
+ options,
+ theme,
+ ctx()
+ );
+ const { el } = draw(model);
+ expect(texts(el)).not.toContain('null');
+ expect(texts(el)).not.toContain('no data');
+ });
+});
+
+describe('the drawn legend never exceeds the box it declared', () => {
+ test.each([
+ ['thresholds', { thresholds: { mode: ThresholdsMode.Absolute, steps: steps([-Infinity, 'g'], [20, 'o'], [50, 'r']) } }, 'auto', 600],
+ ['narrow thresholds', { thresholds: { mode: ThresholdsMode.Absolute, steps: steps([-Infinity, 'g'], [20, 'o'], [50, 'r']) } }, 'auto', 150],
+ ['categorical', {}, 'categorical', 300],
+ ['gradient', {}, 'gradient', 600],
+ ])('%s', (_name, config, legendType, width) => {
+ const model = buildLegend(makeField(config), { ...options, legendType }, theme, ctx([0, 5, 10, 20, 50, 100], { hasNull: true, hasNoData: true }));
+ const { el, svg } = draw(model, width);
+ const nodes = [...el.querySelectorAll('rect, text, line')];
+
+ const declaredH = +svg.getAttribute('height');
+ const lowest = nodes.reduce((m, node) => {
+ const y = +(node.getAttribute('y') || node.getAttribute('y2') || 0);
+ const h = +(node.getAttribute('height') || 0);
+ return Math.max(m, y + h);
+ }, 0);
+ expect(lowest).toBeLessThanOrEqual(declaredH);
+
+ // text advance is unmeasurable in jsdom, so this covers the boxes and rules —
+ // enough to catch a key block placed off the right edge
+ const declaredW = +svg.getAttribute('width');
+ const rightmost = nodes.reduce((m, node) => {
+ const x = +(node.getAttribute('x') || node.getAttribute('x2') || 0);
+ const w = +(node.getAttribute('width') || 0);
+ return Math.max(m, x + w);
+ }, 0);
+ expect(rightmost).toBeLessThanOrEqual(declaredW);
+ });
+});
diff --git a/src/matrix.d.ts b/src/matrix.d.ts
index 7ef8bb1..bcdec50 100644
--- a/src/matrix.d.ts
+++ b/src/matrix.d.ts
@@ -1,12 +1,17 @@
-import { MatrixData, MatrixOptions } from './types';
-
export function matrix(
- rowNames: any[],
- colNames: any[],
- matrix: DataMatrixCell[][],
+ rowNames: any,
+ colNames: any,
+ matrix: any,
id: number,
- options: MatrixOptions,
- legend: LegendData[],
- colCategories: Category[],
- rowCategories: Category[],
+ width: number,
+ height: number,
+ options: any,
+ legend: any,
+ colMetadata: any,
+ colCategories: any,
+ rowMetadata: any,
+ rowCategories: any,
+ rowTotals: any,
+ colTotals: any,
+ valueDomain: any
): LegacyRef | undefined;
diff --git a/src/matrix.js b/src/matrix.js
index 68a415a..e19e0a5 100644
--- a/src/matrix.js
+++ b/src/matrix.js
@@ -1,52 +1,308 @@
import { useD3 } from './useD3.js';
-import * as d3 from './d3.min.js';
+import { estimateLegendHeight } from './legend';
+import { renderLegend } from './legendRender.js';
+import { select, local } from 'd3-selection';
+import 'd3-transition'; // side-effect import: adds selection.transition()
import { css } from '@emotion/css';
import { GrafanaTheme2, textUtil } from '@grafana/data';
import { useStyles2, useTheme2 } from '@grafana/ui';
-import { Heading } from './types';
+// 2.1.1 dropped the sanitize-html dependency in favor of Grafana's built-in
+// sanitizer; keep the same local `sanitizeHtml` name so the rest of the file is unchanged.
const sanitizeHtml = textUtil.sanitize;
+/** Pick a readable text color (dark ink or white) for a given fill color. */
+function contrastText(fill) {
+ try {
+ let s = String(fill).trim();
+ if (s.startsWith('#')) {
+ s = s.replace('#', '');
+ if (s.length === 3) {
+ s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2];
+ }
+ const r = parseInt(s.slice(0, 2), 16);
+ const g = parseInt(s.slice(2, 4), 16);
+ const b = parseInt(s.slice(4, 6), 16);
+ // relative luminance (sRGB approximation)
+ const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
+ return lum > 0.6 ? '#0b0b0b' : '#ffffff';
+ }
+ if (s.startsWith('rgb')) {
+ const nums = s.match(/[\d.]+/g);
+ if (nums && nums.length >= 3) {
+ const lum = (0.299 * +nums[0] + 0.587 * +nums[1] + 0.114 * +nums[2]) / 255;
+ return lum > 0.6 ? '#0b0b0b' : '#ffffff';
+ }
+ }
+ } catch (e) {
+ // fall through
+ }
+ return '#ffffff';
+}
+
/** Create the matrix diagram using d3.
- * @param {SvgInHtml} elem The parent svg element that will house this diagram
- * @param {number} id The panel id
- * @param {any[]} rowNames Row names
- * @param {any[]} colNames Column names
- * @param {DataMatrixCell[][]} matrix The data that will populate the diagram
- * @param {MatrixOptions} options Panel configuration
- * @param {GrafanaTheme} theme Grafana theme
- * @param {LegendData[]} legend Legend data
- * @param {CSSReturnValue} styles CSS styles
- * @param {Category[]} column categories
- * @param {Category[]} row categories
+ * @param {*} elem The parent svg element that will house this diagram
+ * @param {*} id The panel id
+ * @param {number} panelWidth The current width of the panel
+ * @param {number} panelHeight The current height of the panel
+ * @param {*} matrix The data that will populate the diagram
+ * @param {GrafanaTheme} theme
+ * @param {CSSReturnValue} styles
*/
-function createViz(elem, id, rowNames, colNames, matrix, options, theme, legend, styles, colCategories, rowCategories) {
+function createViz(
+ elem,
+ id,
+ panelWidth,
+ panelHeight,
+ rowNames,
+ colNames,
+ matrix,
+ options,
+ theme,
+ legend,
+ styles,
+ colMetadata,
+ colCategories,
+ rowMetadata,
+ rowCategories,
+ rowTotals,
+ colTotals,
+ valueDomain
+) {
const srcText = sanitizeHtml(options.sourceText),
targetText = sanitizeHtml(options.targetText),
valText = sanitizeHtml(options.valueText),
- cellSize = options.cellSize,
cellPadding = options.cellPadding / 100, // convert the cellPadding integer to a float that can be used by d3
+ cornerRadius = options.cellCornerRadius != null ? options.cellCornerRadius : 2,
txtLength = options.txtLength,
txtSize = options.txtSize / 10, //convert this val to EM scaling 90 = .9em 100 = 1em ... etc
linkURL = options.url,
urlVar1 = options.urlVar1,
urlVar2 = options.urlVar2,
- defaultColor = theme.visualization.getColorByName(options.defaultColor);
+ nullColor = theme.visualization.getColorByName(options.nullColor),
+ defaultColor = theme.visualization.getColorByName(options.defaultColor),
+ distinctNoData = options.distinctNoData !== false,
+ highlightRowCol = options.highlightRowCol !== false,
+ showCellValues = options.showCellValues !== false,
+ showGroupDividers = options.showGroupDividers !== false,
+ sizeEncodesValue = !!options.sizeEncodesValue,
+ showMarginalTotals = !!options.showMarginalTotals,
+ freezeLabels = options.freezeLabels !== false;
- // urlOther = options.urlOther,
- // urlOtherText = options.urlOtherText;
+ // neutral theme tokens used for chrome
+ const noDataStroke = theme.colors.border.weak;
+ const bandFill = theme.colors.background.secondary;
+
+ // value-threshold focus
+ const focusEnabled = !!options.focusEnabled;
+ const focusMode = options.focusMode || 'dim';
+ const focusMin = typeof options.focusMin === 'number' ? options.focusMin : null;
+ const focusMax = typeof options.focusMax === 'number' ? options.focusMax : null;
+ function inFocus(d) {
+ if (!focusEnabled) {
+ return true;
+ }
+ if (d === -1 || d.isNull || typeof d.val !== 'number') {
+ return false;
+ }
+ if (focusMin !== null && d.val < focusMin) {
+ return false;
+ }
+ if (focusMax !== null && d.val > focusMax) {
+ return false;
+ }
+ return true;
+ }
+ function baseOpacity(d) {
+ return focusEnabled && !inFocus(d) && focusMode === 'dim' ? 0.15 : 1;
+ }
+ function isHidden(d) {
+ return focusEnabled && focusMode === 'hide' && !inFocus(d);
+ }
+
+ // ---- cross-highlight, pin, tooltip & keyboard infrastructure ----
+ // (function declarations are hoisted; they reference rectArea/xAxisGroup/etc.
+ // which are assigned later but only ever called at event time.)
+ let pinned = null; // { r, c } where either may be null for a row-only / column-only pin
+ const rcMap = new Map(); // 'r:c' -> rect node, built after cells render
+
+ function applyHighlight(r, c) {
+ rectArea.selectAll('rect.matrix-cell').style('opacity', function (cd) {
+ const rr = +this.getAttribute('data-r');
+ const cc = +this.getAttribute('data-c');
+ const on = (r !== null && rr === r) || (c !== null && cc === c);
+ return on ? baseOpacity(cd) : 0.2;
+ });
+ svg.selectAll('.cell-value').style('opacity', function () {
+ const rr = +this.getAttribute('data-r');
+ const cc = +this.getAttribute('data-c');
+ return (r !== null && rr === r) || (c !== null && cc === c) ? 1 : 0.12;
+ });
+ xAxisGroup.selectAll('text').attr('font-weight', function () {
+ return c !== null && +this.getAttribute('data-c') === c ? 'bold' : null;
+ });
+ yAxisGroup.selectAll('text').attr('font-weight', function () {
+ return r !== null && +this.getAttribute('data-r') === r ? 'bold' : null;
+ });
+ }
+ function clearHighlight() {
+ if (pinned) {
+ applyHighlight(pinned.r, pinned.c);
+ return;
+ }
+ rectArea.selectAll('rect.matrix-cell').style('opacity', function (cd) {
+ return baseOpacity(cd);
+ });
+ svg.selectAll('.cell-value').style('opacity', 1);
+ xAxisGroup.selectAll('text').attr('font-weight', null);
+ yAxisGroup.selectAll('text').attr('font-weight', null);
+ }
+ function togglePin(r, c) {
+ if (pinned && pinned.r === r && pinned.c === c) {
+ pinned = null;
+ clearHighlight();
+ } else {
+ pinned = { r, c };
+ applyHighlight(r, c);
+ }
+ }
+ // Highlight the cells a legend entry actually stands for: the half-open band
+ // [from, to), with null meaning unbounded on that side. `from === to` is the
+ // exact-value case used by categorical entries.
+ function inBand(val, from, to) {
+ if (typeof val !== 'number' || isNaN(val)) {
+ return false;
+ }
+ if (from != null && to != null && from === to) {
+ return val === from;
+ }
+ if (from != null && val < from) {
+ return false;
+ }
+ if (to != null && val >= to) {
+ return false;
+ }
+ return true;
+ }
+ function highlightByRange(from, to) {
+ rectArea.selectAll('rect.matrix-cell').style('opacity', function (cd) {
+ const on = cd !== -1 && !cd.isNull && inBand(cd.val, from, to);
+ return on ? 1 : 0.15;
+ });
+ }
+ // highlight only null cells (wantNull=true) or only no-data cells (wantNull=false)
+ function highlightByState(wantNull) {
+ rectArea.selectAll('rect.matrix-cell').style('opacity', function (cd) {
+ const on = wantNull ? cd !== -1 && cd.isNull : cd === -1;
+ return on ? 1 : 0.15;
+ });
+ }
+
+ function cellTooltipHtml(d) {
+ const thisRow = sanitizeHtml(d.row);
+ const thisColumn = sanitizeHtml(d.col);
+ const thisText = sanitizeHtml(d.isNull ? 'null' : d.display.text);
+ const thisSuffix = sanitizeHtml(d.isNull ? '' : d.display.suffix);
+ return `${thisText} ${thisSuffix ? thisSuffix : ''}
+`;
+ }
+ // shared activation used by both hover and keyboard focus
+ function activateCell(node, d) {
+ if (d === -1) {
+ return;
+ }
+ select(node).raise().attr('stroke', theme.colors.text.primary).attr('stroke-width', 2);
+ if (highlightRowCol) {
+ applyHighlight(+node.getAttribute('data-r'), +node.getAttribute('data-c'));
+ }
+ tooltip
+ .html(() => cellTooltipHtml(d))
+ .transition()
+ .duration(150)
+ .style('opacity', 1);
+ }
+ function deactivateCell(node, d) {
+ select(node).attr('stroke', cellStroke(d)).attr('stroke-width', cellStrokeWidth(d));
+ if (highlightRowCol) {
+ clearHighlight();
+ }
+ tooltip.transition().delay(100).duration(150).style('opacity', 0);
+ }
+ function positionTooltipAtCell(node) {
+ try {
+ const cr = node.getBoundingClientRect();
+ const er = elem.getBoundingClientRect();
+ const sx = elem.parentElement ? elem.parentElement.scrollLeft : 0;
+ const sy = elem.parentElement ? elem.parentElement.scrollTop : 0;
+ moveTooltip(
+ { offsetX: cr.left - er.left + sx + cr.width / 2, offsetY: cr.top - er.top + sy + cr.height / 2 },
+ elem,
+ tooltip
+ );
+ } catch (e) {
+ /* no-op */
+ }
+ }
+ function cellNodeAt(r, c) {
+ return rcMap.get(r + ':' + c) || null;
+ }
+ function handleCellKeydown(event, node) {
+ let r = +node.getAttribute('data-r');
+ let c = +node.getAttribute('data-c');
+ const key = event.key;
+ let moved = true;
+ if (key === 'ArrowRight') {
+ c++;
+ } else if (key === 'ArrowLeft') {
+ c--;
+ } else if (key === 'ArrowDown') {
+ r++;
+ } else if (key === 'ArrowUp') {
+ r--;
+ } else if (key === 'Enter' || key === ' ') {
+ if (!linkURL) {
+ togglePin(r, c);
+ }
+ moved = false;
+ } else if (key === 'Escape') {
+ pinned = null;
+ clearHighlight();
+ moved = false;
+ } else {
+ return;
+ }
+ event.preventDefault();
+ if (!moved) {
+ return;
+ }
+ const target = cellNodeAt(r, c);
+ if (target && target !== node) {
+ node.setAttribute('tabindex', '-1');
+ target.setAttribute('tabindex', '0');
+ target.focus();
+ }
+ }
// do a bit of work to setup the visual layout of the wiget --------
- if (elem === null) {
- console.error('bailing after failing to find parent element');
+ // Bail if the element isn't mounted (e.g. the panel is showing an empty/table
+ // state) or the data isn't a real matrix. The render hook is always called for
+ // stable React hook order, so createViz may run before there's anything to draw.
+ if (!elem || !Array.isArray(rowNames) || !Array.isArray(colNames) || !Array.isArray(matrix) || rowNames.length === 0 || colNames.length === 0) {
return;
}
// find the length of the longest name. this will inform the margin and name truncation
- const longestColName = colNames.reduce((a, b) => {
+ let longestColName = colNames.reduce((a, b) => {
return a.length > b.length ? a : b;
});
- const longestRowName = rowNames.reduce((a, b) => {
+ let longestRowName = rowNames.reduce((a, b) => {
return a.length > b.length ? a : b;
});
@@ -57,84 +313,134 @@ function createViz(elem, id, rowNames, colNames, matrix, options, theme, legend,
const maxColTxtLength = longestColName.length < txtLength ? longestColName.length : txtLength + 3;
const maxRowTxtLength = longestRowName.length < txtLength ? longestRowName.length : txtLength + 3;
- // Calculate the margins needed
- const colTxtOffset = maxColTxtLength * txtSize * 5 + 25;
- const rowTxtOffset = maxRowTxtLength * txtSize * 5 + 25;
+ // Measure the ACTUAL rendered width of the longest label (labels are first
+ // capped to `txtLength` characters, then the margin is sized to fit that width).
+ // Rotated x-axis labels contribute their width to the TOP margin. This replaces
+ // the old character-count estimate, which underestimated proportional-font width
+ // and caused labels to be truncated to only a few characters.
+ const labelPad = 24; // breathing room beyond the measured text
+ const measuredColW = measureMaxLabelWidth(elem, colNames, txtSize, theme.typography.fontFamily, txtLength);
+ const measuredRowW = measureMaxLabelWidth(elem, rowNames, txtSize, theme.typography.fontFamily, txtLength);
+ // fall back to a character-count estimate (8px/char/em) if measurement is unavailable
+ const colFallback = maxColTxtLength * txtSize * 8;
+ const rowFallback = maxRowTxtLength * txtSize * 8;
+ const effColW = measuredColW > 0 ? measuredColW : colFallback;
+ const effRowW = measuredRowW > 0 ? measuredRowW : rowFallback;
+ // Column label orientation: horizontal (short labels) vs rotated -90 (long labels).
+ // The auto decision needs the final cell width, so it is finalized after cellSize
+ // is computed below; here we assume rotated (the larger top margin) so fit-sizing
+ // stays conservative.
+ const labelOrientation = options.labelOrientation || 'auto';
+ const colLineHeight = Math.ceil(txtSize * 18) + 12;
+ let colHorizontal = labelOrientation === 'horizontal';
+ let colTxtOffset = (colHorizontal ? colLineHeight : effColW) + labelPad;
+ let rowTxtOffset = effRowW + labelPad;
// Category header configuration
- const colCategoryHeaderHeight = options.enableColGrouping && colCategories && colCategories.length > 0
+ const colGrouping = options.enableColGrouping && colCategories && colCategories.length > 0;
+ const rowGrouping = options.enableRowGrouping && rowCategories && rowCategories.length > 0;
+
+ const colCategoryHeaderHeight = colGrouping
? (options.colCategoryHeaderHeight !== undefined ? options.colCategoryHeaderHeight : 40)
: 0;
- const colCategoryGap = options.enableColGrouping && colCategories && colCategories.length > 0
+ const colCategoryGap = colGrouping
? (options.colCategoryGap !== undefined ? options.colCategoryGap : 4)
: 0;
+ const rowCategoryHeaderWidth = rowGrouping
+ ? (options.rowCategoryHeaderWidth !== undefined ? options.rowCategoryHeaderWidth : 100)
+ : 0;
+ const rowCategoryGap = rowGrouping
+ ? (options.rowCategoryGap !== undefined ? options.rowCategoryGap : 4)
+ : 0;
+
+ // marginal total bands (extra space reserved on the right/bottom)
+ const marginalBand = showMarginalTotals ? 48 : 0;
+
+ // ---- Determine cell size (fixed px, or fit-to-panel) ----
+ const nCols = colNames.length;
+ const nRows = rowNames.length;
+ const colGapTotal = colGrouping ? (colCategories.length - 1) * colCategoryGap : 0;
+ const rowGapTotal = rowGrouping ? (rowCategories.length - 1) * rowCategoryGap : 0;
+
+ let cellSize = options.cellSize;
+ if (options.sizeMode === 'fit' && panelWidth > 0 && panelHeight > 0) {
+ const marginLeftEst = rowTxtOffset + rowCategoryHeaderWidth;
+ const marginTopEst = colTxtOffset + colCategoryHeaderHeight;
+ // reserve exactly what this legend needs, not a flat guess
+ const legendReserve = options.showLegend ? estimateLegendHeight(legend, panelWidth) : 0;
+ const availW = panelWidth - marginLeftEst - marginalBand - colGapTotal - 4;
+ const availH = panelHeight - marginTopEst - marginalBand - rowGapTotal - legendReserve - 4;
+ const fitW = availW / nCols;
+ const fitH = availH / nRows;
+ let fit = Math.floor(Math.min(fitW, fitH));
+ if (!isFinite(fit) || fit < 4) {
+ fit = 4;
+ }
+ if (fit > 80) {
+ fit = 80;
+ }
+ cellSize = fit;
+ }
+
+ // Finalize column-label orientation now that the real cell width is known.
+ // Auto: horizontal when the longest label fits inside a cell; otherwise rotate.
+ if (labelOrientation === 'auto') {
+ colHorizontal = effColW <= cellSize - 4;
+ } else {
+ colHorizontal = labelOrientation === 'horizontal';
+ }
+ colTxtOffset = (colHorizontal ? colLineHeight : effColW) + labelPad;
+
+ const bandwidth = cellSize * (1 - cellPadding);
+ // per-cell offset so the padded cell is centered in its slot
+ const cellInset = (cellSize - bandwidth) / 2;
// Calculate column positions with gaps between colCategories
- const columnPositions: Heading[] = [];
+ let columnPositions = [];
let totalWidth = 0;
- if (options.enableColGrouping && colCategories && colCategories.length > 0) {
- // Calculate positions with category gaps
+ if (colGrouping) {
colCategories.forEach((category, catIndex) => {
- const numCols = category.items.length;
+ const numCols = category.columns.length;
const groupWidth = numCols * cellSize;
-
- category.items.forEach((colName, indexInCat) => {
+ category.columns.forEach((colName, indexInCat) => {
columnPositions.push({
name: colName,
- x: totalWidth + (indexInCat * cellSize),
+ x: totalWidth + indexInCat * cellSize,
width: cellSize,
category: category.name,
- categoryIndex: catIndex
+ categoryIndex: catIndex,
});
});
-
totalWidth += groupWidth;
if (catIndex < colCategories.length - 1) {
totalWidth += colCategoryGap;
}
});
} else {
- // No grouping: standard layout
colNames.forEach((colName, idx) => {
- columnPositions.push({
- name: colName,
- x: idx * cellSize,
- width: cellSize,
- category: '',
- categoryIndex: 0
- });
+ columnPositions.push({ name: colName, x: idx * cellSize, width: cellSize, category: '', categoryIndex: 0 });
});
totalWidth = colNames.length * cellSize;
}
- // Row category configuration
- const rowCategoryHeaderWidth = options.enableRowGrouping && rowCategories && rowCategories.length > 0
- ? (options.rowCategoryHeaderWidth !== undefined ? options.rowCategoryHeaderWidth : 100)
- : 0;
- const rowCategoryGap = options.enableRowGrouping && rowCategories && rowCategories.length > 0
- ? (options.rowCategoryGap !== undefined ? options.rowCategoryGap : 4)
- : 0;
-
- // Calculate row positions with gaps between colCategories
- const rowPositions: Heading[] = [];
+ // Calculate row positions with gaps between rowCategories
+ let rowPositions = [];
let totalHeight = 0;
- if (options.enableRowGrouping && rowCategories && rowCategories.length > 0) {
+ if (rowGrouping) {
rowCategories.forEach((category, catIndex) => {
- const numRows = category.items.length;
+ const numRows = category.rows.length;
const groupHeight = numRows * cellSize;
-
- category.items.forEach((rowName, indexInCat) => {
+ category.rows.forEach((rowName, indexInCat) => {
rowPositions.push({
name: rowName,
- y: totalHeight + (indexInCat * cellSize),
+ y: totalHeight + indexInCat * cellSize,
height: cellSize,
category: category.name,
- categoryIndex: catIndex
+ categoryIndex: catIndex,
});
});
-
totalHeight += groupHeight;
if (catIndex < rowCategories.length - 1) {
totalHeight += rowCategoryGap;
@@ -142,19 +448,18 @@ function createViz(elem, id, rowNames, colNames, matrix, options, theme, legend,
});
} else {
rowNames.forEach((rowName, idx) => {
- rowPositions.push({
- name: rowName,
- y: idx * cellSize,
- height: cellSize,
- category: '',
- categoryIndex: 0
- });
+ rowPositions.push({ name: rowName, y: idx * cellSize, height: cellSize, category: '', categoryIndex: 0 });
});
totalHeight = rowNames.length * cellSize;
}
// set the dimensions and margins of the graph
- const margin = { top: colTxtOffset + colCategoryHeaderHeight, right: 0, bottom: 0, left: rowTxtOffset + rowCategoryHeaderWidth },
+ let margin = {
+ top: colTxtOffset + colCategoryHeaderHeight,
+ right: marginalBand,
+ bottom: marginalBand,
+ left: rowTxtOffset + rowCategoryHeaderWidth,
+ },
width = totalWidth,
height = totalHeight;
@@ -163,16 +468,14 @@ function createViz(elem, id, rowNames, colNames, matrix, options, theme, legend,
elem.replaceChildren();
}
- const tooltip = d3
- .select(elem)
+ const tooltip = select(elem)
.append('div')
.attr('class', `${styles.tooltip} matrix-tooltip-${id}`)
.style('opacity', 0);
// append the svg object to the body of the page
- const svgClass = `svg-${id}`;
- const svgMatrix = d3
- .select(elem)
+ let svgClass = `svg-${id}`;
+ const svg = select(elem)
.append('svg')
.attr('id', svgClass)
.attr('width', width + margin.left + margin.right)
@@ -180,60 +483,109 @@ function createViz(elem, id, rowNames, colNames, matrix, options, theme, legend,
.append('g')
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
- // Build X scales and axis:
- // Index-based scale to support duplicate column names across categories
- const x = function(colIndex) {
+ // index-based scales to support duplicate names across categories
+ let x = function (colIndex) {
return columnPositions[colIndex] ? columnPositions[colIndex].x : 0;
};
- x.bandwidth = function() {
- return cellSize * (1 - cellPadding);
+ let y = function (rowIndex) {
+ return rowPositions[rowIndex] ? rowPositions[rowIndex].y : 0;
};
- // Create axis manually with proper styling
- const xAxisGroup = svgMatrix.append('g').attr('class', 'x-axis').attr('font-size', '10');
- columnPositions.forEach(pos => {
- const label = xAxisGroup.append('text')
- .attr('transform', `translate(${pos.x + cellSize / 2},-12)rotate(-90)`)
- .attr('text-anchor', 'start')
- .attr('font-size', txtSize + 'em')
- .style('font-family', theme.typography.fontFamily)
- .attr('fill', theme.colors.text.primary)
- .text(pos.name);
+ // ---- Category group background bands (drawn first, behind everything) ----
+ if (showGroupDividers && (colGrouping || rowGrouping)) {
+ const bandGroup = svg.append('g').attr('class', `group-bands-${id}`).lower();
+ if (colGrouping) {
+ colCategories.forEach((category, catIndex) => {
+ if (catIndex % 2 === 1) {
+ const startPos = columnPositions[category.startIndex];
+ const endPos = columnPositions[category.endIndex];
+ if (startPos && endPos) {
+ bandGroup
+ .append('rect')
+ .attr('x', startPos.x - cellInset)
+ .attr('y', -cellInset)
+ .attr('width', endPos.x + cellSize - startPos.x)
+ .attr('height', height + cellInset)
+ .attr('fill', bandFill)
+ .attr('opacity', 0.5);
+ }
+ }
+ });
+ }
+ if (rowGrouping) {
+ rowCategories.forEach((category, catIndex) => {
+ if (catIndex % 2 === 1) {
+ const startPos = rowPositions[category.startIndex];
+ const endPos = rowPositions[category.endIndex];
+ if (startPos && endPos) {
+ bandGroup
+ .append('rect')
+ .attr('x', -cellInset)
+ .attr('y', startPos.y - cellInset)
+ .attr('width', width + cellInset)
+ .attr('height', endPos.y + cellSize - startPos.y)
+ .attr('fill', bandFill)
+ .attr('opacity', 0.5);
+ }
+ }
+ });
+ }
+ }
- // Apply truncation and tooltips
- label.call(truncateLabel, txtLength);
- label.on('mouseover', function (event, d) {
- tooltip
- .html(sanitizeHtml(pos.name))
- .transition()
- .duration(150)
- .style('opacity', 1);
- })
- .on('mousemove', function (event) {
- moveTooltip(event, elem, tooltip);
- })
- .on('mouseout', function () {
- tooltip
- .transition()
- .delay(100)
- .duration(150)
- .style('opacity', 0);
- });
+ // ---- X axis labels (rotated -90 by default; horizontal when they fit) ----
+ const xAxisGroup = svg.append('g').attr('class', 'x-axis');
+ columnPositions.forEach((pos, idx) => {
+ let label;
+ if (colHorizontal) {
+ label = xAxisGroup
+ .append('text')
+ .attr('data-c', idx)
+ .attr('x', pos.x + cellSize / 2)
+ .attr('y', -8)
+ .attr('text-anchor', 'middle')
+ .attr('font-size', txtSize + 'em')
+ .style('font-family', theme.typography.fontFamily)
+ .attr('fill', theme.colors.text.primary)
+ .text(pos.name);
+ label.call(truncateToPixels, cellSize - 2);
+ } else {
+ label = xAxisGroup
+ .append('text')
+ .attr('data-c', idx)
+ .attr('transform', `translate(${pos.x + cellSize / 2},-12)rotate(-90)`)
+ .attr('text-anchor', 'start')
+ .attr('font-size', txtSize + 'em')
+ .style('font-family', theme.typography.fontFamily)
+ .attr('fill', theme.colors.text.primary)
+ .text(pos.name);
+ label.call(truncateToPixels, colTxtOffset - 16);
+ }
+ if (highlightRowCol) {
+ label.style('cursor', 'pointer');
+ }
+ label
+ .on('mouseover', function () {
+ tooltip.html(sanitizeHtml(pos.name)).transition().duration(150).style('opacity', 1);
+ })
+ .on('mousemove', function (event) {
+ moveTooltip(event, elem, tooltip);
+ })
+ .on('mouseout', function () {
+ tooltip.transition().delay(100).duration(150).style('opacity', 0);
+ })
+ .on('click', function () {
+ if (highlightRowCol) {
+ togglePin(null, idx);
+ }
+ });
});
- // Build Y scales and axis:
- // Index-based scale to support duplicate row names across categories
- const y = function(rowIndex) {
- return rowPositions[rowIndex] ? rowPositions[rowIndex].y : 0;
- };
- y.bandwidth = function() {
- return cellSize * (1 - cellPadding);
- };
-
- // Create Y-axis manually with proper styling
- const yAxisGroup = svgMatrix.append('g').attr('class', 'y-axis').attr('font-size', '10');
- rowPositions.forEach(pos => {
- const label = yAxisGroup.append('text')
+ // ---- Y axis labels ----
+ const yAxisGroup = svg.append('g').attr('class', 'y-axis');
+ rowPositions.forEach((pos, idx) => {
+ const label = yAxisGroup
+ .append('text')
+ .attr('data-r', idx)
.attr('x', -10)
.attr('y', pos.y + cellSize / 2)
.attr('text-anchor', 'end')
@@ -243,169 +595,136 @@ function createViz(elem, id, rowNames, colNames, matrix, options, theme, legend,
.attr('fill', theme.colors.text.primary)
.text(pos.name);
- label.call(truncateLabel, txtLength);
- label.on('mouseover', function (event, d) {
- tooltip
- .html(sanitizeHtml(pos.name))
- .transition()
- .duration(150)
- .style('opacity', 1);
- })
- .on('mousemove', function (event) {
- moveTooltip(event, elem, tooltip);
- })
- .on('mouseout', function () {
- tooltip
- .transition()
- .delay(100)
- .duration(150)
- .style('opacity', 0);
- });
+ label.call(truncateToPixels, rowTxtOffset - 16);
+ if (highlightRowCol) {
+ label.style('cursor', 'pointer');
+ }
+ label
+ .on('mouseover', function () {
+ tooltip.html(sanitizeHtml(pos.name)).transition().duration(150).style('opacity', 1);
+ })
+ .on('mousemove', function (event) {
+ moveTooltip(event, elem, tooltip);
+ })
+ .on('mouseout', function () {
+ tooltip.transition().delay(100).duration(150).style('opacity', 0);
+ })
+ .on('click', function () {
+ if (highlightRowCol) {
+ togglePin(idx, null);
+ }
+ });
});
- // Render category headers
- if (options.enableColGrouping && colCategories && colCategories.length > 0) {
- const categoryHeaderGroup = svgMatrix.append('g')
+ // ---- Column category headers ----
+ let colHeaderGroup = null;
+ if (colGrouping) {
+ colHeaderGroup = svg
+ .append('g')
.attr('class', `category-headers-${id}`)
- .attr('font-size', '10')
.attr('transform', `translate(0, ${-colTxtOffset - colCategoryHeaderHeight})`);
- colCategories.forEach((category, catIndex) => {
- // find first and last column heading for this category
- let startPos = undefined;
- let endPos = undefined;
- for (let i = 0; i < columnPositions.length; i++) {
- if (columnPositions[i].category === category.name) {
- if (startPos === undefined) {
- // first column heading for the category
- startPos = columnPositions[i];
- endPos = columnPositions[i];
- } else {
- endPos = columnPositions[i];
- }
- } else if (startPos !== undefined) {
- // no more column headings for this category
- break;
- }
- }
-
+ colCategories.forEach((category) => {
+ const startPos = columnPositions[category.startIndex];
+ const endPos = columnPositions[category.endIndex];
if (startPos && endPos) {
const groupX = startPos.x;
- const groupWidth = (endPos.x + cellSize) - startPos.x;
-
- // Category label rotated vertically; truncated to fit colCategoryHeaderHeight.
- const colGroupLabelMaxChars = Math.max(3, Math.floor((colCategoryHeaderHeight - 12) / (txtSize * 1.2 * 8)));
- categoryHeaderGroup.append('text')
+ colHeaderGroup
+ .append('text')
.attr('transform', `translate(${groupX + cellSize / 2}, ${colCategoryHeaderHeight - 12})rotate(-90)`)
.attr('text-anchor', 'start')
- .attr('font-size', (txtSize * 1.2) + 'em')
+ .attr('font-size', txtSize * 1.2 + 'em')
.attr('font-weight', 'bold')
.attr('fill', theme.colors.text.primary)
.style('font-family', theme.typography.fontFamily)
.text(category.name)
- .call(truncateLabel, colGroupLabelMaxChars)
- .on('mouseover', function (event, d) {
- tooltip
- .html(sanitizeHtml(category.name))
- .transition()
- .duration(150)
- .style('opacity', 1);
+ .call(truncateToPixels, colCategoryHeaderHeight - 12)
+ .on('mouseover', function () {
+ tooltip.html(sanitizeHtml(category.name)).transition().duration(150).style('opacity', 1);
})
.on('mousemove', function (event) {
moveTooltip(event, elem, tooltip);
})
.on('mouseout', function () {
- tooltip
- .transition()
- .delay(100)
- .duration(150)
- .style('opacity', 0);
+ tooltip.transition().delay(100).duration(150).style('opacity', 0);
});
}
});
}
- // Render row category headers
- if (options.enableRowGrouping && rowCategories && rowCategories.length > 0) {
- const rowCategoryHeaderGroup = svgMatrix.append('g')
+ // ---- Row category headers ----
+ let rowHeaderGroup = null;
+ if (rowGrouping) {
+ rowHeaderGroup = svg
+ .append('g')
.attr('class', `row-category-headers-${id}`)
- .attr('font-size', '10')
.attr('transform', `translate(${-rowTxtOffset - rowCategoryHeaderWidth}, 0)`);
- rowCategories.forEach((category, catIndex) => {
- // find first and last row heading for this category
- let startPos = undefined;
- let endPos = undefined;
- for (let i = 0; i < rowPositions.length; i++) {
- if (rowPositions[i].category === category.name) {
- if (startPos === undefined) {
- // first row heading for the category
- startPos = rowPositions[i];
- endPos = rowPositions[i];
- } else {
- endPos = rowPositions[i];
- }
- } else if (startPos !== undefined) {
- // no more row headings for this category
- break;
- }
- }
-
- if (startPos && endPos) {
+ rowCategories.forEach((category) => {
+ const startPos = rowPositions[category.startIndex];
+ if (startPos) {
const groupY = startPos.y;
- const groupHeight = (endPos.y + cellSize) - startPos.y;
-
- // Group label: right-aligned so text grows leftward into rowCategoryHeaderWidth.
- // Truncated when wider than the allocated area; tooltip shows full name.
- const groupLabelMaxChars = Math.max(3, Math.floor((rowCategoryHeaderWidth - 10) / (txtSize * 1.2 * 8)));
- rowCategoryHeaderGroup.append('text')
+ rowHeaderGroup
+ .append('text')
.attr('x', rowCategoryHeaderWidth - 5)
.attr('y', groupY + cellSize / 2)
.attr('text-anchor', 'end')
.attr('dominant-baseline', 'middle')
- .attr('font-size', (txtSize * 1.2) + 'em')
+ .attr('font-size', txtSize * 1.2 + 'em')
.attr('font-weight', 'bold')
.attr('fill', theme.colors.text.primary)
.style('font-family', theme.typography.fontFamily)
.text(category.name)
- .call(truncateLabel, groupLabelMaxChars)
- .on('mouseover', function (event, d) {
- tooltip
- .html(sanitizeHtml(category.name))
- .transition()
- .duration(150)
- .style('opacity', 1);
+ .call(truncateToPixels, rowCategoryHeaderWidth - 8)
+ .on('mouseover', function () {
+ tooltip.html(sanitizeHtml(category.name)).transition().duration(150).style('opacity', 1);
})
.on('mousemove', function (event) {
moveTooltip(event, elem, tooltip);
})
.on('mouseout', function () {
- tooltip
- .transition()
- .delay(100)
- .duration(150)
- .style('opacity', 0);
+ tooltip.transition().delay(100).duration(150).style('opacity', 0);
});
}
});
}
- //build the matrix /////////////////////////////////////////
-
- //use d3's local stuff to record where we are in the outer loop
- const outer = d3.local();
-
- const svg_g = d3.select('#' + svgClass).selectAll('svg > g');
+ // ---- helpers for cell appearance (used at creation + hover restore) ----
+ function cellFill(d) {
+ if (d === -1) {
+ return distinctNoData ? 'none' : defaultColor;
+ }
+ return d.color || defaultColor;
+ }
+ function cellStroke(d) {
+ if (d === -1 && distinctNoData) {
+ return noDataStroke;
+ }
+ return 'none';
+ }
+ function cellStrokeWidth(d) {
+ return d === -1 && distinctNoData ? 1 : 0;
+ }
+ // area-proportional side length within the slot when size encoding is on
+ function encodedSide(d) {
+ if (!sizeEncodesValue || d === -1 || d.val == null || !valueDomain) {
+ return bandwidth;
+ }
+ const span = valueDomain.max - valueDomain.min || 1;
+ let norm = (d.val - valueDomain.min) / span;
+ norm = Math.max(0.05, Math.min(1, norm));
+ return bandwidth * Math.sqrt(norm);
+ }
- //create the area where we will put all the boxes
+ // ---- the matrix cells ----
+ let outer = local();
+ let svg_g = select('#' + svgClass).selectAll('svg > g');
const rectClass = `rectArea-${id}`;
const rectArea = svg_g.append('g').attr('class', rectClass);
- //this selection breaks the data down to the row level. This is
- //needed because the underlying datastructure is a 2d array
- const rows = rectArea.selectAll('g').data(matrix).enter().append('g').attr('class', 'row');
+ let rows = rectArea.selectAll('g').data(matrix).enter().append('g').attr('class', 'row');
- const rects = rows
+ let rects = rows
.selectAll('rect')
.data(function (d, i) {
outer.set(this, i);
@@ -426,193 +745,274 @@ function createViz(elem, id, rowNames, colNames, matrix, options, theme, legend,
}
})
.append('rect')
- .attr('id', `rect-${id}`)
- .attr('x', function (d, i, j) {
- return x(i);
+ .attr('class', 'matrix-cell')
+ .attr('data-c', function (d, i) {
+ return i;
})
- .attr('y', function (d, i, j) {
- const outer_counter = outer.get(this);
- return y(outer_counter);
+ .attr('data-r', function () {
+ return outer.get(this);
})
- .attr('width', x.bandwidth())
- .attr('height', y.bandwidth())
- //this places a 'data' attribute into the HTML to make debugging easier. Allows you to see the inner/outer loop counts and the datum used
- .attr('data', function (d, i) {
- const outer_counter = outer.get(this);
- const str = '' + outer_counter + ':' + i + ' ' + rowNames[outer_counter] + ':' + colNames[i] + ' ' + d;
- return str;
+ .attr('rx', cornerRadius)
+ .attr('ry', cornerRadius)
+ .attr('x', function (d, i) {
+ const side = encodedSide(d);
+ return x(i) + cellInset + (bandwidth - side) / 2;
})
- .attr('fill', (d) => {
- if (d.color) {
- return d.color;
- } else {
- return defaultColor;
- }
+ .attr('y', function (d) {
+ const side = encodedSide(d);
+ return y(outer.get(this)) + cellInset + (bandwidth - side) / 2;
+ })
+ .attr('width', function (d) {
+ return encodedSide(d);
})
- // the tooltip for boxes
+ .attr('height', function (d) {
+ return encodedSide(d);
+ })
+ .attr('fill', cellFill)
+ .attr('stroke', cellStroke)
+ .attr('stroke-width', cellStrokeWidth)
+ .style('opacity', baseOpacity)
+ .style('display', function (d) {
+ return isHidden(d) ? 'none' : null;
+ })
+ .attr('role', 'gridcell')
+ .attr('tabindex', -1)
.on('mouseover', function (event, d) {
- if (d !== -1) {
- //turn down the opacity slightly to show the hover
- d3.select(this)
- // .attr('opacity', '.75')
- .attr('width', x.bandwidth() + 5)
- .attr('height', y.bandwidth() + 5)
- .attr('transform', 'translate(-1, -1)');
-
- tooltip
- .html(() => {
- const thisRow = sanitizeHtml(d.row);
- const thisColumn =sanitizeHtml(d.col);
- const thisText = sanitizeHtml(d.display.text);
- const thisSuffix = sanitizeHtml(d.display.suffix);
- const text = ``;
- return text;
- })
- .transition()
- .duration(150)
- .style('opacity', 1);
- }
+ activateCell(this, d);
})
.on('mousemove', function (event) {
moveTooltip(event, elem, tooltip);
})
- .on('mouseout', function () {
- //reset the opacity and move the tooltip out of the way. If we dont move it it will prevent hovering over other boxes.
- d3.select(this)
- // .attr('opacity', '1')
- .attr('transform', 'translate(0, 0)')
- .attr('width', x.bandwidth())
- .attr('height', y.bandwidth());
-
- tooltip
- .transition()
- .delay(100)
- .duration(150)
- .style('opacity', 0)
+ .on('mouseout', function (event, d) {
+ deactivateCell(this, d);
+ })
+ .on('focus', function (event, d) {
+ activateCell(this, d);
+ positionTooltipAtCell(this);
+ })
+ .on('blur', function (event, d) {
+ deactivateCell(this, d);
})
- .on('click', function (d) {
- if(linkURL) {
+ .on('keydown', function (event) {
+ handleCellKeydown(event, this);
+ })
+ .on('click', function (event, d) {
+ if (linkURL) {
tooltip.remove();
+ } else if (d !== -1 && highlightRowCol) {
+ togglePin(+this.getAttribute('data-r'), +this.getAttribute('data-c'));
+ }
+ });
+
+ // ---- accessibility: build the r:c lookup, per-cell aria-labels, roving tabindex ----
+ rectArea.selectAll('rect.matrix-cell').each(function () {
+ const rr = this.getAttribute('data-r');
+ const cc = this.getAttribute('data-c');
+ rcMap.set(rr + ':' + cc, this);
+ const rowName = rowNames[+rr];
+ const colName = colNames[+cc];
+ const d = matrix[+rr][+cc];
+ let valStr;
+ if (d === -1) {
+ valStr = 'no data';
+ } else if (d.isNull) {
+ valStr = 'null';
+ } else {
+ valStr = `${d.display.text}${d.display.suffix ? ' ' + d.display.suffix : ''}`;
+ }
+ this.setAttribute('aria-label', `${srcText} ${rowName}, ${targetText} ${colName}, ${valText} ${valStr}`);
+ });
+ // make the first visible cell keyboard-reachable (roving tabindex entry point)
+ const firstCell = rcMap.get('0:0') || rectArea.select('rect.matrix-cell').node();
+ if (firstCell) {
+ firstCell.setAttribute('tabindex', '0');
+ }
+ // label the grid for screen readers
+ select('#' + svgClass)
+ .attr('role', 'img')
+ .attr('aria-label', `Matrix of ${rowNames.length} rows by ${colNames.length} columns`);
+ rectArea.attr('role', 'grid');
+ rows.attr('role', 'row');
+
+ // ---- In-cell value labels (only when they fit) ----
+ if (showCellValues) {
+ const labelGroup = svg.append('g').attr('class', `cell-values-${id}`).style('pointer-events', 'none');
+ const labelFontPx = Math.min(bandwidth * 0.42, 13);
+ if (labelFontPx >= 7) {
+ for (let r = 0; r < matrix.length; r++) {
+ for (let c = 0; c < matrix[r].length; c++) {
+ const d = matrix[r][c];
+ if (d === -1 || d.isNull || d.val == null) {
+ continue;
+ }
+ // declutter: no label on cells outside the focused range
+ if (focusEnabled && !inFocus(d)) {
+ continue;
+ }
+ const text = d.display && d.display.text != null ? String(d.display.text) : String(d.val);
+ // estimate rendered width; skip if it won't fit with padding
+ const estWidth = text.length * labelFontPx * 0.6;
+ if (estWidth > bandwidth - 4) {
+ continue;
+ }
+ labelGroup
+ .append('text')
+ .attr('class', 'cell-value')
+ .attr('data-r', r)
+ .attr('data-c', c)
+ .attr('x', x(c) + cellSize / 2)
+ .attr('y', y(r) + cellSize / 2)
+ .attr('text-anchor', 'middle')
+ .attr('dominant-baseline', 'central')
+ .attr('font-size', `${labelFontPx}px`)
+ .style('font-family', theme.typography.fontFamily)
+ .attr('fill', contrastText(d.color))
+ .text(text);
+ }
}
+ }
+ }
+
+ // ---- Marginal total bars (row sums on the right, column sums on the bottom) ----
+ if (showMarginalTotals) {
+ const barColor = theme.colors.text.secondary;
+ const maxRowTotal = Math.max(1, ...rowTotals.map((v) => Math.abs(v)));
+ const maxColTotal = Math.max(1, ...colTotals.map((v) => Math.abs(v)));
+ const marginalGroup = svg.append('g').attr('class', `marginal-totals-${id}`).style('pointer-events', 'none');
+
+ // row totals -> horizontal bars just right of the grid
+ rowPositions.forEach((pos, i) => {
+ const len = (Math.abs(rowTotals[i]) / maxRowTotal) * (marginalBand - 8);
+ marginalGroup
+ .append('rect')
+ .attr('x', width + 4)
+ .attr('y', pos.y + cellInset)
+ .attr('width', Math.max(0, len))
+ .attr('height', bandwidth)
+ .attr('rx', Math.min(cornerRadius, 3))
+ .attr('fill', barColor)
+ .attr('opacity', 0.55);
+ });
+
+ // column totals -> vertical bars just below the grid
+ columnPositions.forEach((pos, j) => {
+ const len = (Math.abs(colTotals[j]) / maxColTotal) * (marginalBand - 8);
+ marginalGroup
+ .append('rect')
+ .attr('x', pos.x + cellInset)
+ .attr('y', height + 4)
+ .attr('width', bandwidth)
+ .attr('height', Math.max(0, len))
+ .attr('rx', Math.min(cornerRadius, 3))
+ .attr('fill', barColor)
+ .attr('opacity', 0.55);
});
+ }
+
+ // ---- Freeze axis labels: counter-translate label groups on scroll ----
+ if (freezeLabels) {
+ const scroller = elem.parentElement;
+ if (scroller) {
+ const colHeaderBaseY = -colTxtOffset - colCategoryHeaderHeight;
+ const rowHeaderBaseX = -rowTxtOffset - rowCategoryHeaderWidth;
+ const onScroll = () => {
+ const sx = scroller.scrollLeft;
+ const sy = scroller.scrollTop;
+ xAxisGroup.attr('transform', `translate(0, ${sy})`);
+ yAxisGroup.attr('transform', `translate(${sx}, 0)`);
+ if (colHeaderGroup) {
+ colHeaderGroup.attr('transform', `translate(0, ${colHeaderBaseY + sy})`);
+ }
+ if (rowHeaderGroup) {
+ rowHeaderGroup.attr('transform', `translate(${rowHeaderBaseX + sx}, 0)`);
+ }
+ };
+ // avoid stacking listeners across re-renders
+ if (scroller.__matrixScrollHandler) {
+ scroller.removeEventListener('scroll', scroller.__matrixScrollHandler);
+ }
+ scroller.__matrixScrollHandler = onScroll;
+ scroller.addEventListener('scroll', onScroll, { passive: true });
+ }
+ }
////// LEGEND ////////////
- if (options.showLegend) {
- const legendClass = `legend-${id}`;
+ if (options.showLegend && legend) {
+ renderLegend(
+ elem,
+ legend,
+ id,
+ {
+ availableWidth: panelWidth,
+ cornerRadius,
+ nullColor,
+ defaultColor,
+ distinctNoData,
+ noDataStroke,
+ },
+ theme,
+ { highlightByRange, highlightByState, clearHighlight }
+ );
+ }
+}
- d3
- .select(elem)
- .append('div')
- .attr('class', `matrix-legend-${id}`)
+/** Measure the widest rendered label (each capped to capChars) so the axis margin
+ * can be sized to actually fit it. Uses a hidden, off-layout SVG so it never flashes.
+ * @param {HTMLElement} elem The container element (already attached to the DOM)
+ * @param {string[]} names The label strings
+ * @param {number} fontEm The font size in em (matches the axis label font-size)
+ * @param {string} fontFamily The font family used for the labels
+ * @param {number} capChars Truncate labels to at most this many characters before measuring
+ * @return {number} The maximum rendered width in pixels (0 if measurement is unavailable)
+ */
+function measureMaxLabelWidth(elem, names, fontEm, fontFamily, capChars) {
+ let maxW = 0;
+ try {
+ const tmp = select(elem)
.append('svg')
- .attr('id', legendClass);
-
-////////////// range - bar //////////////////////
- if (options.legendType === 'range') {
- const svgLegend = d3.select(`#${legendClass}`);
- svgLegend
- // legend bar starts at x=25, legend squares are 10x10, allow 9px per label character
- .attr('width', 25 + (legend.length - 1) * 10 + legend[legend.length - 1].label.length * 9)
- // legend label starts at y=50, allow 16px per label character
- .attr('height', 50 + 16)
- .append('g')
- .selectAll('legendBars')
- .data(legend)
- .enter()
- .append('rect')
- .attr('class', `legend-bar-${id}`)
- .attr('width', 10)
- .attr('height', 10)
- .attr('fill', function (d) {
- return d.color;
- })
- .attr('x', function (d, i) {
- return 25 + i * 10;
- })
- .attr('y', 20);
- svgLegend
- .append('g')
- .selectAll('legendLabels')
- .data(legend)
- .enter()
- .append('text')
- .attr('x', function (d, i) {
- return 20 + i * 10;
- })
- .attr('y', 50)
- .text(function (d, i) {
- if ((i === 0) | (i === legend.length - 1)) {
- return d.label;
- } else {
- return;
- }
- })
- .attr('fill', theme.colors.text.primary);
- } else {
-/////////// categorical - circles ////////////////////////////
- const svgLegend = d3.select(`#${legendClass}`);
- svgLegend
- // legend bar starts at x=25, legend circles are drawn every 75px and have a 20px diameter,
- // allow 9px per label character
- .attr('width', 25 + (legend.length - 1) * 75 + 20 + legend[legend.length - 1].label.length * 9)
- // legend label starts at y=50, allow 16px per label character
- .attr('height', 50 + 16)
- .append('g')
- .selectAll('legendCircles')
- .data(legend)
- .enter()
- .append('circle')
- .attr('class', `legend-circle-${id}`)
- .attr('r', 10)
- .attr('fill', function (d) {
- return d.color;
- })
- .attr('cx', function (d, i) {
- return 25 + i * 75;
- })
- .attr('cy', 20);
- svg
- .append('g')
- .selectAll('legendLabels')
- .data(legend)
- .enter()
+ .attr('class', 'matrix-label-measure')
+ .style('position', 'absolute')
+ .style('visibility', 'hidden')
+ .style('width', '0')
+ .style('height', '0')
+ .style('overflow', 'visible');
+ names.forEach((n) => {
+ const s = String(n);
+ const t = s.length > capChars ? s.slice(0, capChars) + '…' : s;
+ const node = tmp
.append('text')
- .attr('x', function (d, i) {
- return 15 + i * 75;
- })
- .attr('y', 50)
- .text(function (d) { return d.label })
- .attr('fill', theme.colors.text.primary);
- }
+ .attr('font-size', fontEm + 'em')
+ .style('font-family', fontFamily)
+ .text(t)
+ .node();
+ const w = node && node.getComputedTextLength ? node.getComputedTextLength() : 0;
+ if (w > maxW) {
+ maxW = w;
+ }
+ });
+ tmp.remove();
+ } catch (e) {
+ return 0;
}
+ return maxW;
}
-function truncateLabel(text, width) {
+/** Trim an SVG selection until it fits within maxPixels, adding an ellipsis. */
+function truncateToPixels(text, maxPixels) {
text.each(function () {
- let label = d3.select(this).text();
- if (label.length > width) {
- label = label.slice(0, width) + '...';
+ const node = this;
+ const full = select(node).text();
+ if (maxPixels <= 0) {
+ return;
+ }
+ if (node.getComputedTextLength() <= maxPixels) {
+ return;
+ }
+ let label = full;
+ while (label.length > 1 && node.getComputedTextLength() > maxPixels) {
+ label = label.slice(0, -1);
+ select(node).text(label + '…');
}
- d3.select(this).text(label);
});
}
@@ -638,7 +1038,7 @@ function moveTooltip(event, elem, tooltip) {
if (event.offsetX - mouseDistance >= xMin && event.offsetX + mouseDistance >= xMax) {
// Draw tooltip left of the mouse
- xPos = Math.max(event.offsetX - tooltipRect.width - mouseDistance, xMin)
+ xPos = Math.max(event.offsetX - tooltipRect.width - mouseDistance, xMin);
} else {
// Draw tooltip right of the mouse
xPos = Math.min(event.offsetX + mouseDistance, xMax);
@@ -646,15 +1046,13 @@ function moveTooltip(event, elem, tooltip) {
if (event.offsetY - mouseDistance >= yMin && event.offsetY + mouseDistance >= yMax) {
// Draw tooltip above the mouse
- yPos = Math.max(event.offsetY - tooltipRect.height - mouseDistance, yMin)
+ yPos = Math.max(event.offsetY - tooltipRect.height - mouseDistance, yMin);
} else {
// Draw tooltip below the mouse
yPos = Math.min(event.offsetY + mouseDistance, yMax);
}
- tooltip
- .style('left', `${xPos}px`)
- .style('top', `${yPos}px`)
+ tooltip.style('left', `${xPos}px`).style('top', `${yPos}px`);
}
const getStyles = (theme: GrafanaTheme2) => {
@@ -666,18 +1064,23 @@ const getStyles = (theme: GrafanaTheme2) => {
font-size: ${theme.typography.size.sm};
font-weight: ${theme.typography.fontWeightRegular};
border: 1px solid ${theme.colors.border.weak};
- border-radius: ${theme.shape.radius.default}:
+ border-radius: ${theme.shape.radius.default};
box-shadow: ${theme.shadows.z3};
- padding: 5px;
+ padding: 8px 10px;
z-index: 500;
position: absolute;
width: fit-content;
`,
+ tooltipValue: css`
+ font-size: ${theme.typography.h5.fontSize};
+ font-weight: ${theme.typography.fontWeightBold};
+ margin-bottom: 4px;
+ `,
tooltipTable: css`
display: grid;
grid-template-columns: max-content 1fr;
gap: 2px;
- padding: 3px;
+ padding: 0;
`,
tooltipTableCell: css`
display: flex;
@@ -695,23 +1098,53 @@ const getStyles = (theme: GrafanaTheme2) => {
};
/**
- *
- * @param {any[]} rowNames Row names
- * @param {any[]} colNames Column names
- * @param {DataMatrixCell[][]} matrix Data for the matrix diagram
- * @param {number} id The panel id
- * @param {MatrixOptions} options Panel configuration
- * @param {LegendData[]} legend Legend data
- * @param {Category[]} column categories
- * @param {Category[]} row categories
- * @return {SvgInHtml} A d3 callback
+ * @param {*} matrix Data for the matrix diagram
+ * @param {*} id The panel id
+ * @param {number} width Width of panel
+ * @param {number} height Height of panel
+ * @return {*} A d3 callback
*/
-function matrix(rowNames, colNames, matrix, id, options, legend, colCategories, rowCategories) {
- /* eslint-disable react-hooks/rules-of-hooks */
+/* eslint-disable react-hooks/rules-of-hooks */
+function matrix(
+ rowNames,
+ colNames,
+ matrix,
+ id,
+ width,
+ height,
+ options,
+ legend,
+ colMetadata,
+ colCategories,
+ rowMetadata,
+ rowCategories,
+ rowTotals,
+ colTotals,
+ valueDomain
+) {
const theme = useTheme2();
const styles = useStyles2(getStyles);
const ref = useD3((svg) => {
- createViz(svg, id, rowNames, colNames, matrix, options, theme, legend, styles, colCategories, rowCategories);
+ createViz(
+ svg,
+ id,
+ width,
+ height,
+ rowNames,
+ colNames,
+ matrix,
+ options,
+ theme,
+ legend,
+ styles,
+ colMetadata,
+ colCategories,
+ rowMetadata,
+ rowCategories,
+ rowTotals,
+ colTotals,
+ valueDomain
+ );
});
return ref;
}
diff --git a/src/module.ts b/src/module.ts
old mode 100644
new mode 100755
index f0cd151..2c48b3d
--- a/src/module.ts
+++ b/src/module.ts
@@ -1,4 +1,4 @@
-import { Field, FieldConfigProperty, FieldType, PanelPlugin } from '@grafana/data';
+import { FieldOverrideContext, getFieldDisplayName, PanelPlugin, FieldConfigProperty } from '@grafana/data';
// import { standardOptionsCompat } from 'grafana-plugin-support';
import { MatrixOptions } from './types';
import { EsnetMatrix } from './EsnetMatrix';
@@ -15,6 +15,8 @@ import { EsnetMatrix } from './EsnetMatrix';
const OptionsCategory = ['Display'];
const URLCategory = ['Link Options'];
const RowOptions = ['Row/Column Options'];
+const ColorCategory = ['Colors'];
+const LayoutCategory = ['Layout'];
const urlBool = (addUrl: boolean) => (config: MatrixOptions) => config.addUrl === addUrl;
// const eurlOtherBool = (urlOther: boolean) => (config: MatrixOptions) => config.urlOther === urlOther;
@@ -45,86 +47,154 @@ plugin.useFieldConfig({
]
});
-plugin.setMigrationHandler((panel) => {
- if (panel.options.sortType === undefined) {
- panel.options.sortType = 'natural-asc'
- }
-
- return panel.options;
-});
-
plugin.setPanelOptions((builder) => {
/////////--------- Row and Column options ---------////////////////
- builder.addSelect({
- path: 'sortType',
- name: 'Sort Type',
- description: 'Sorting to apply to row/column headings',
- category: RowOptions,
- defaultValue: 'natural-asc',
- settings: {
- allowCustomValue: false,
- options: [
- { value: 'none', label: 'None' },
- { value: 'natural-asc', label: 'Natural ascending' },
- { value: 'natural-desc', label: 'Natural descending' },
- ],
- },
- });
builder.addBooleanSwitch({
path: 'inputList',
- name: 'Use Static Row/Column Headings',
+ name: 'Use Static Row/Column Lists',
category: RowOptions,
defaultValue: false,
});
builder.addTextInput({
path: 'staticRows',
- name: 'Row Headings',
+ name: 'Row Array',
description: 'Terms to use as matrix rows (comma separated)',
category: RowOptions,
showIf: staticBool(true),
});
builder.addTextInput({
path: 'staticColumns',
- name: 'Column Headings',
+ name: 'Column Array',
description: 'Terms to use as matrix columns (comma separated)',
category: RowOptions,
showIf: staticBool(true),
});
- builder.addFieldNamePicker({
+ builder.addSelect({
path: 'sourceField',
name: 'Rows Field',
description: 'Select the field that should be used for the rows',
category: RowOptions,
settings: {
- filter: (field: Field) => field.type === FieldType.string,
+ allowCustomValue: false,
+ options: [],
+ getOptions: async (context: FieldOverrideContext) => {
+ const options = [];
+ if (context && context.data) {
+ for (const frame of context.data) {
+ for (const field of frame.fields) {
+ const name = getFieldDisplayName(field, frame, context.data);
+ const value = name;
+ options.push({ value, label: name });
+ }
+ }
+ }
+ return Promise.resolve(options);
+ },
},
+ // defaultValue: options[0],
});
- builder.addFieldNamePicker({
+ builder.addSelect({
path: 'targetField',
name: 'Columns Field',
description: 'Select the field to use for the columns',
category: RowOptions,
settings: {
- filter: (field: Field) => field.type === FieldType.string,
+ allowCustomValue: false,
+ options: [],
+ getOptions: async (context: FieldOverrideContext) => {
+ const options = [];
+ if (context && context.data) {
+ for (const frame of context.data) {
+ for (const field of frame.fields) {
+ const name = getFieldDisplayName(field, frame, context.data);
+ const value = name;
+ options.push({ value, label: name });
+ }
+ }
+ }
+ return Promise.resolve(options);
+ },
},
});
- builder.addFieldNamePicker({
+ builder.addSelect({
path: 'valueField',
name: 'Value Field',
description: 'Select the numeric field used to color the matrix cells.',
category: RowOptions,
settings: {
- filter: (field: Field) => field.type === FieldType.number,
+ allowCustomValue: false,
+ options: [],
+ getOptions: async (context: FieldOverrideContext) => {
+ const options = [];
+ if (context && context.data) {
+ for (const frame of context.data) {
+ for (const field of frame.fields) {
+ if (field.type === 'number') {
+ const name = getFieldDisplayName(field, frame, context.data);
+ const value = name;
+ options.push({ value, label: name });
+ }
+ }
+ }
+ }
+ return Promise.resolve(options);
+ },
},
});
- builder.addFieldNamePicker({
+ builder.addSelect({
+ path: 'rowSort',
+ name: 'Row Order',
+ description:
+ 'How to order the rows. Total sorts by each row\'s summed value (largest first). Cluster reorders similar rows next to each other to reveal block structure.',
+ category: RowOptions,
+ defaultValue: 'name',
+ settings: {
+ allowCustomValue: false,
+ options: [
+ { value: 'name', label: 'By name (A→Z)' },
+ { value: 'total', label: 'By total (largest first)' },
+ { value: 'cluster', label: 'By cluster (seriation)' },
+ ],
+ },
+ });
+ builder.addSelect({
+ path: 'colSort',
+ name: 'Column Order',
+ description:
+ 'How to order the columns. Total sorts by each column\'s summed value (largest first). Cluster reorders similar columns next to each other.',
+ category: RowOptions,
+ defaultValue: 'name',
+ settings: {
+ allowCustomValue: false,
+ options: [
+ { value: 'name', label: 'By name (A→Z)' },
+ { value: 'total', label: 'By total (largest first)' },
+ { value: 'cluster', label: 'By cluster (seriation)' },
+ ],
+ },
+ });
+ builder.addSelect({
path: 'colCategoryField',
name: 'Column Category Field',
description: 'Select the field to use for grouping columns into categories',
category: RowOptions,
showIf: colGroupingBool(true),
settings: {
- filter: (field: Field) => field.type === FieldType.string,
+ allowCustomValue: false,
+ options: [],
+ getOptions: async (context: FieldOverrideContext) => {
+ const options = [{ value: '', label: 'None' }];
+ if (context && context.data) {
+ for (const frame of context.data) {
+ for (const field of frame.fields) {
+ const name = getFieldDisplayName(field, frame, context.data);
+ const value = name;
+ options.push({ value, label: name });
+ }
+ }
+ }
+ return Promise.resolve(options);
+ },
},
});
builder.addBooleanSwitch({
@@ -171,14 +241,28 @@ plugin.setPanelOptions((builder) => {
});
////////------------ Row Grouping Options ----------------/////////////
- builder.addFieldNamePicker({
+ builder.addSelect({
path: 'rowCategoryField',
name: 'Row Category Field',
description: 'Select the field to use for grouping rows into categories',
category: RowOptions,
showIf: rowGroupingBool(true),
settings: {
- filter: (field: Field) => field.type === FieldType.string,
+ allowCustomValue: false,
+ options: [],
+ getOptions: async (context: FieldOverrideContext) => {
+ const options = [{ value: '', label: 'None' }];
+ if (context && context.data) {
+ for (const frame of context.data) {
+ for (const field of frame.fields) {
+ const name = getFieldDisplayName(field, frame, context.data);
+ const value = name;
+ options.push({ value, label: name });
+ }
+ }
+ }
+ return Promise.resolve(options);
+ },
},
});
builder.addNumberInput({
@@ -211,26 +295,59 @@ plugin.setPanelOptions((builder) => {
});
////////------------ General Matrix Options ----------------/////////////
+ builder.addSelect({
+ path: 'aggregationMethod',
+ name: 'Aggregation Method',
+ description: 'How to aggregate values when multiple data points map to the same cell',
+ category: OptionsCategory,
+ showIf: (config: MatrixOptions) => config.enableColGrouping || config.enableRowGrouping,
+ defaultValue: 'sum',
+ settings: {
+ allowCustomValue: false,
+ options: [
+ { value: 'sum', label: 'Sum' },
+ { value: 'avg', label: 'Average' },
+ { value: 'min', label: 'Min' },
+ { value: 'max', label: 'Max' },
+ { value: 'last', label: 'Last' },
+ ],
+ },
+ });
builder.addBooleanSwitch({
path: 'showLegend',
name: 'Show Legend',
+ description:
+ 'Adds a legend below the matrix, built from whatever colors the cells — the steps under Standard Options > Thresholds, or the active color ramp.',
category: OptionsCategory,
defaultValue: false,
});
builder.addSelect({
path: 'legendType',
name: 'Legend Type',
+ description:
+ 'Auto follows the cell color source. Pin a form to override it — note that a form which disagrees with how cells are colored will read as misleading.',
category: OptionsCategory,
showIf: legendBool(true),
- defaultValue: 'range',
+ defaultValue: 'auto',
settings: {
allowCustomValue: false,
options: [
- { value: 'categorical', label: 'categorical' },
- { value: 'range', label: 'range' },
+ { value: 'auto', label: 'Auto' },
+ { value: 'thresholds', label: 'Thresholds (steps)' },
+ { value: 'gradient', label: 'Gradient (continuous)' },
+ { value: 'categorical', label: 'Categorical (swatches)' },
],
},
});
+ builder.addNumberInput({
+ path: 'legendMin',
+ name: 'Legend Min',
+ description:
+ 'Lower bound of the legend scale. The lowest threshold step is unbounded below, so it normally reads "< 30"; with a bound it reads "0 – 30", and hovering it highlights only that range. Leave blank for unbounded.',
+ category: OptionsCategory,
+ showIf: legendBool(true),
+ defaultValue: 0,
+ });
builder.addTextInput({
path: 'sourceText',
name: 'Source Text',
@@ -284,6 +401,82 @@ plugin.setPanelOptions((builder) => {
defaultValue: 5,
});
+ builder.addNumberInput({
+ path: 'cellCornerRadius',
+ name: 'Cell Corner Radius',
+ description: 'Rounds the corners of each cell (pixels). Set to 0 for square cells.',
+ category: OptionsCategory,
+ settings: {
+ placeholder: 'Auto',
+ integer: true,
+ min: 0,
+ max: 20,
+ },
+ defaultValue: 2,
+ });
+
+ builder.addBooleanSwitch({
+ path: 'highlightRowCol',
+ name: 'Highlight Row & Column on Hover',
+ description: 'Dims other cells and emphasizes the hovered cell\'s row and column.',
+ category: OptionsCategory,
+ defaultValue: true,
+ });
+
+ builder.addBooleanSwitch({
+ path: 'showCellValues',
+ name: 'Show Values in Cells',
+ description: 'Prints the value inside each cell when it is large enough to fit.',
+ category: OptionsCategory,
+ defaultValue: true,
+ });
+
+ builder.addBooleanSwitch({
+ path: 'showGroupDividers',
+ name: 'Show Category Group Dividers',
+ description: 'Draws subtle background bands behind grouped rows/columns.',
+ category: OptionsCategory,
+ showIf: (config: MatrixOptions) => config.enableColGrouping || config.enableRowGrouping,
+ defaultValue: true,
+ });
+
+ builder.addBooleanSwitch({
+ path: 'focusEnabled',
+ name: 'Focus Value Range',
+ description: 'Spotlight cells whose value falls within a range; others are dimmed or hidden.',
+ category: OptionsCategory,
+ defaultValue: false,
+ });
+ builder.addNumberInput({
+ path: 'focusMin',
+ name: 'Focus Min',
+ description: 'Lower bound of the focused value range (leave blank for no lower bound).',
+ category: OptionsCategory,
+ showIf: (config: MatrixOptions) => config.focusEnabled,
+ });
+ builder.addNumberInput({
+ path: 'focusMax',
+ name: 'Focus Max',
+ description: 'Upper bound of the focused value range (leave blank for no upper bound).',
+ category: OptionsCategory,
+ showIf: (config: MatrixOptions) => config.focusEnabled,
+ });
+ builder.addSelect({
+ path: 'focusMode',
+ name: 'Out-of-Focus Cells',
+ description: 'How to treat cells outside the focused range.',
+ category: OptionsCategory,
+ showIf: (config: MatrixOptions) => config.focusEnabled,
+ defaultValue: 'dim',
+ settings: {
+ allowCustomValue: false,
+ options: [
+ { value: 'dim', label: 'Dim' },
+ { value: 'hide', label: 'Hide' },
+ ],
+ },
+ });
+
builder.addNumberInput({
path: 'txtLength',
name: 'Text Length',
@@ -311,21 +504,143 @@ plugin.setPanelOptions((builder) => {
},
defaultValue: 10,
});
+ builder.addSelect({
+ path: 'colorMode',
+ name: 'Cell Color Mode',
+ description:
+ 'How cell colors are derived. Sequential = single-hue ramp for magnitude. Diverging = two hues around a baseline. Standard = use the Standard Options color scheme / thresholds.',
+ category: ColorCategory,
+ defaultValue: 'standard',
+ settings: {
+ allowCustomValue: false,
+ options: [
+ { value: 'standard', label: 'Standard Options (thresholds)' },
+ { value: 'sequential', label: 'Sequential (single hue)' },
+ { value: 'diverging', label: 'Diverging (around a baseline)' },
+ ],
+ },
+ });
+ builder.addNumberInput({
+ path: 'divergingMidpoint',
+ name: 'Diverging Midpoint',
+ description: 'The value treated as neutral (gray). Values above and below diverge to the two hues.',
+ category: ColorCategory,
+ showIf: (config: MatrixOptions) => config.colorMode === 'diverging',
+ defaultValue: 0,
+ });
+ builder.addSelect({
+ path: 'colorDomainMode',
+ name: 'Color Scale Range',
+ description:
+ 'Auto scales colors to this panel\'s min/max. Manual lets you pin the range so multiple matrix panels share one comparable scale.',
+ category: ColorCategory,
+ showIf: (config: MatrixOptions) => config.colorMode !== 'standard',
+ defaultValue: 'auto',
+ settings: {
+ allowCustomValue: false,
+ options: [
+ { value: 'auto', label: 'Auto (data min/max)' },
+ { value: 'manual', label: 'Manual (fixed range)' },
+ ],
+ },
+ });
+ builder.addNumberInput({
+ path: 'colorDomainMin',
+ name: 'Color Scale Min',
+ description: 'Value mapped to the low end of the color scale.',
+ category: ColorCategory,
+ showIf: (config: MatrixOptions) => config.colorMode !== 'standard' && config.colorDomainMode === 'manual',
+ defaultValue: 0,
+ });
+ builder.addNumberInput({
+ path: 'colorDomainMax',
+ name: 'Color Scale Max',
+ description: 'Value mapped to the high end of the color scale.',
+ category: ColorCategory,
+ showIf: (config: MatrixOptions) => config.colorMode !== 'standard' && config.colorDomainMode === 'manual',
+ defaultValue: 100,
+ });
+ builder.addBooleanSwitch({
+ path: 'distinctNoData',
+ name: 'Distinguish No-Data Cells',
+ description: 'Renders cells with no matching data as empty/outlined so they read differently from null values.',
+ category: ColorCategory,
+ defaultValue: true,
+ });
builder.addColorPicker({
path: 'nullColor',
name: 'Null Color',
description: 'The color to use when the query returns a null value',
- category: OptionsCategory,
+ category: ColorCategory,
defaultValue: '#E6E6E6',
});
builder.addColorPicker({
path: 'defaultColor',
name: 'No Data Color',
description: 'The color to use when there is no data returned by the query',
- category: OptionsCategory,
+ category: ColorCategory,
defaultValue: '#E6E6E6',
});
+ /////////----------- Layout & encoding options ---------------////////////////
+ builder.addSelect({
+ path: 'sizeMode',
+ name: 'Sizing',
+ description: 'Fit scales cells to fill the panel. Fixed uses the exact Cell Size in pixels (may scroll).',
+ category: LayoutCategory,
+ defaultValue: 'fit',
+ settings: {
+ allowCustomValue: false,
+ options: [
+ { value: 'fit', label: 'Fit to panel' },
+ { value: 'fixed', label: 'Fixed cell size' },
+ ],
+ },
+ });
+ builder.addBooleanSwitch({
+ path: 'freezeLabels',
+ name: 'Freeze Axis Labels',
+ description: 'Keeps the row and column labels pinned while the cell grid scrolls.',
+ category: LayoutCategory,
+ defaultValue: true,
+ });
+ builder.addSelect({
+ path: 'labelOrientation',
+ name: 'Column Label Orientation',
+ description: 'Auto shows horizontal labels when they fit in a cell, otherwise rotates them vertically.',
+ category: LayoutCategory,
+ defaultValue: 'auto',
+ settings: {
+ allowCustomValue: false,
+ options: [
+ { value: 'auto', label: 'Auto' },
+ { value: 'rotated', label: 'Rotated (vertical)' },
+ { value: 'horizontal', label: 'Horizontal' },
+ ],
+ },
+ });
+ builder.addBooleanSwitch({
+ path: 'showTableView',
+ name: 'Show as Table',
+ description: 'Renders an accessible HTML table instead of the matrix — a text/screen-reader-friendly view of the same data.',
+ category: LayoutCategory,
+ defaultValue: false,
+ });
+ builder.addBooleanSwitch({
+ path: 'showMarginalTotals',
+ name: 'Show Marginal Totals',
+ description: 'Adds small summary bars of row and column totals along the edges.',
+ category: LayoutCategory,
+ defaultValue: false,
+ });
+ builder.addBooleanSwitch({
+ path: 'sizeEncodesValue',
+ name: 'Size Encodes Value',
+ description: 'Scales each cell\'s filled area by its value, so magnitude reads through both color and size.',
+ category: LayoutCategory,
+ defaultValue: false,
+ });
+
/////////----------- Link URL options ---------------////////////////
builder.addBooleanSwitch({
path: 'addUrl',
diff --git a/src/types.ts b/src/types.ts
index b8edff1..9e81a14 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -15,10 +15,40 @@ export interface MatrixOptions {
rowCategoryGap: number;
cellSize: number;
cellPadding: number;
+ cellCornerRadius: number;
txtLength: number;
txtSize: number;
nullColor: string;
defaultColor: string;
+ distinctNoData: boolean;
+ highlightRowCol: boolean;
+ // Color presets (Phase 2)
+ colorMode: string; // 'standard' | 'sequential' | 'diverging'
+ divergingMidpoint: number;
+ // Shared / explicit color domain (Round 2)
+ colorDomainMode: string; // 'auto' | 'manual'
+ colorDomainMin: number;
+ colorDomainMax: number;
+ // Value-threshold focus (Round 2)
+ focusEnabled: boolean;
+ focusMin: number;
+ focusMax: number;
+ focusMode: string; // 'dim' | 'hide'
+ // Sorting / seriation (Phase 2 + Round 2)
+ rowSort: string; // 'name' | 'total' | 'cluster'
+ colSort: string; // 'name' | 'total' | 'cluster'
+ // Grouping visuals (Phase 2)
+ showGroupDividers: boolean;
+ // In-cell value labels (Phase 2)
+ showCellValues: boolean;
+ // Layout & encoding (Phase 3)
+ sizeMode: string; // 'fixed' | 'fit'
+ freezeLabels: boolean;
+ showMarginalTotals: boolean;
+ sizeEncodesValue: boolean;
+ // Interaction & accessibility (Round 2)
+ labelOrientation: string; // 'auto' | 'rotated' | 'horizontal'
+ showTableView: boolean;
sourceText: string;
targetText: string;
valueText: string;
@@ -32,8 +62,8 @@ export interface MatrixOptions {
staticRows: string;
staticColumns: string;
showLegend: boolean;
- legendType: string;
- thresholds: any[];
+ legendType: string; // 'auto' | 'thresholds' | 'gradient' | 'categorical'
+ legendMin?: number; // floor of the first threshold band; blank = unbounded below
}
export type Category = {
@@ -55,7 +85,7 @@ export type MatrixData = {
colCategories: Category[];
rowCategories: Category[];
data: DataMatrixCell[][] | string | null;
- legend: LegendData[] | null;
+ legend: LegendModel | null;
};
export type DataMatrixCell = {
@@ -66,7 +96,54 @@ export type DataMatrixCell = {
display: DisplayValue;
};
-export type LegendData = {
+/** Which form the legend takes. Chosen automatically from the cell color source
+ * unless the user pins one via the Legend Type option. */
+export type LegendKind = 'thresholds' | 'gradient' | 'categorical';
+
+/** One discrete legend entry (a threshold step, or a distinct category).
+ * `from`/`to` describe the half-open value band [from, to) the entry stands for,
+ * so hovering it can highlight exactly the cells it covers. `null` means unbounded
+ * on that side, and `from === to` means the single exact value (categorical entries). */
+export type LegendItem = {
label: string;
color: string;
+ from: number | null;
+ to: number | null;
+ /** `from` formatted like the cells — the boundary printed under a stepped bar.
+ * Empty for the unbounded first step. */
+ fromLabel: string;
+};
+
+/** A single color stop along a continuous legend bar. `offset` is 0..1. */
+export type LegendStop = {
+ offset: number;
+ color: string;
+};
+
+/** A labeled tick on a continuous legend bar. `offset` is 0..1 along the bar.
+ * `emphasis` marks a tick that should also be drawn as a line across the bar
+ * (used for the diverging midpoint). */
+export type LegendTick = {
+ value: number;
+ label: string;
+ offset: number;
+ emphasis?: boolean;
+};
+
+export type LegendModel = {
+ kind: LegendKind;
+ /** Value-field name/label, drawn above the legend. */
+ title: string;
+ /** Entries for `thresholds` and `categorical`; empty for `gradient`. */
+ items: LegendItem[];
+ /** Color stops for `gradient`; empty otherwise. */
+ stops: LegendStop[];
+ /** Value range a `gradient` bar spans; null when there is nothing numeric to span. */
+ domain: { min: number; max: number } | null;
+ /** Axis labels under a `gradient` bar. */
+ ticks: LegendTick[];
+ /** Whether to draw the null / no-data key. */
+ states: { hasNull: boolean; hasNoData: boolean };
+ /** How many `categorical` entries were dropped past the display cap (0 when none). */
+ truncated: number;
};
diff --git a/src/verify.test.tsx b/src/verify.test.tsx
new file mode 100644
index 0000000..7cf00cc
--- /dev/null
+++ b/src/verify.test.tsx
@@ -0,0 +1,172 @@
+import { toDataFrame, FieldType, createTheme, getDisplayProcessor, ThresholdsMode } from '@grafana/data';
+import { parseData } from './dataParser';
+
+const theme = createTheme();
+
+const thresholds = {
+ mode: ThresholdsMode.Absolute,
+ steps: [
+ { value: -Infinity, color: 'green' },
+ { value: 20, color: 'red' },
+ ],
+};
+
+function makeData(withCategories = false, values?: any[]) {
+ const src = ['a', 'a', 'a', 'b', 'b', 'c', 'c'];
+ const tgt = ['x', 'y', 'z', 'x', 'y', 'x', 'z'];
+ const gbps = values ?? [10, 5, null, 0, 20, 7, 100];
+ const fields: any[] = [
+ { name: 'source', type: FieldType.string, values: src },
+ { name: 'target', type: FieldType.string, values: tgt },
+ { name: 'gbps', type: FieldType.number, values: gbps, config: { thresholds } },
+ ];
+ if (withCategories) {
+ fields.push({ name: 'src_region', type: FieldType.string, values: ['R1', 'R1', 'R1', 'R2', 'R2', 'R1', 'R2'] });
+ fields.push({ name: 'dst_region', type: FieldType.string, values: ['C1', 'C2', 'C2', 'C1', 'C2', 'C1', 'C2'] });
+ }
+ const frame = toDataFrame({ fields });
+ const vf = frame.fields.find((f) => f.type === FieldType.number)!;
+ vf.display = getDisplayProcessor({ field: vf, theme });
+ return { series: [frame] } as any;
+}
+
+const base: any = {
+ sourceField: 'source',
+ targetField: 'target',
+ valueField: 'gbps',
+ sourceText: 'From',
+ targetText: 'To',
+ valueText: 'Gbps',
+ cellSize: 20,
+ cellPadding: 5,
+ txtLength: 50,
+ txtSize: 10,
+ nullColor: '#E6E6E6',
+ defaultColor: '#E6E6E6',
+};
+
+const combos: Array<[string, any, boolean]> = [
+ ['standard + threshold legend', { ...base, colorMode: 'standard', showLegend: true, legendType: 'auto' }, false],
+ ['sequential + legend', { ...base, colorMode: 'sequential', showLegend: true, legendType: 'auto' }, false],
+ ['diverging + midpoint', { ...base, colorMode: 'diverging', divergingMidpoint: 20, showLegend: true, legendType: 'auto' }, false],
+ ['standard + categorical legend', { ...base, colorMode: 'standard', showLegend: true, legendType: 'categorical' }, false],
+ ['legacy legendType "range"', { ...base, colorMode: 'standard', showLegend: true, legendType: 'range' }, false],
+ ['manual domain', { ...base, colorDomainMode: 'manual', colorDomainMin: 0, colorDomainMax: 50, showLegend: true }, false],
+ ['sort total', { ...base, rowSort: 'total', colSort: 'total' }, false],
+ ['sort cluster', { ...base, rowSort: 'cluster', colSort: 'cluster' }, false],
+ ['grouping + cluster', { ...base, enableColGrouping: true, colCategoryField: 'dst_region', enableRowGrouping: true, rowCategoryField: 'src_region', rowSort: 'cluster', colSort: 'cluster', showLegend: true }, true],
+];
+
+describe('parseData runs without runtime errors across option combos', () => {
+ test.each(combos)('%s', (_n, options, withCats) => {
+ const parsed = parseData(makeData(withCats), options, theme);
+ expect(parsed).toBeTruthy();
+ expect(Array.isArray(parsed.rows)).toBe(true);
+ expect(Array.isArray(parsed.columns)).toBe(true);
+ expect(Array.isArray(parsed.data)).toBe(true);
+ // data matrix shape matches rows x columns
+ expect(parsed.data!.length).toBe(parsed.rows!.length);
+ (parsed.data as any[][]).forEach((row: any[]) => expect(row.length).toBe(parsed.columns!.length));
+ // marginal totals align to axes
+ expect(parsed.rowTotals.length).toBe(parsed.rows!.length);
+ expect(parsed.colTotals.length).toBe(parsed.columns!.length);
+ });
+});
+
+describe('null vs no-data distinction', () => {
+ test('null value cell is flagged isNull; missing pair stays -1 (no data)', () => {
+ const parsed = parseData(makeData(false), { ...base }, theme);
+ const ri = parsed.rows!.indexOf('a');
+ const ci = parsed.columns!.indexOf('z');
+ // a->z was present with a null value => null cell
+ const nullCell = parsed.data![ri][ci];
+ expect(nullCell).not.toBe(-1);
+ expect(nullCell.isNull).toBe(true);
+ // b->z never appeared in the data => no-data sentinel
+ const bi = parsed.rows!.indexOf('b');
+ const bz = parsed.data![bi][parsed.columns!.indexOf('z')];
+ expect(bz).toBe(-1);
+ });
+});
+
+describe('cluster seriation returns a valid permutation', () => {
+ test('rows/columns are a permutation of the name-sorted set', () => {
+ const named = parseData(makeData(false), { ...base, rowSort: 'name', colSort: 'name' }, theme);
+ const clustered = parseData(makeData(false), { ...base, rowSort: 'cluster', colSort: 'cluster' }, theme);
+ expect([...clustered.rows!].sort()).toEqual([...named.rows!].sort());
+ expect([...clustered.columns!].sort()).toEqual([...named.columns!].sort());
+ });
+});
+
+describe('color domain is computed from the data', () => {
+ test('auto valueDomain reflects the data min/max (values [0..100])', () => {
+ const parsed = parseData(makeData(false), { ...base, colorMode: 'sequential' }, theme);
+ expect(parsed.valueDomain).toEqual({ min: 0, max: 100 });
+ });
+ test('manual valueDomain reflects manual min/max', () => {
+ const parsed = parseData(makeData(false), { ...base, colorMode: 'sequential', colorDomainMode: 'manual', colorDomainMin: -10, colorDomainMax: 200 }, theme);
+ expect(parsed.valueDomain).toEqual({ min: -10, max: 200 });
+ });
+ test('sequential cells actually vary in color across the domain', () => {
+ const parsed = parseData(makeData(false), { ...base, colorMode: 'sequential' }, theme);
+ const colors = new Set();
+ (parsed.data as any[][]).forEach((row: any[]) =>
+ row.forEach((cell: any) => {
+ if (cell !== -1 && !cell.isNull) {
+ colors.add(cell.color);
+ }
+ })
+ );
+ // if the domain were empty/broken every cell would share one color
+ expect(colors.size).toBeGreaterThan(1);
+ });
+});
+
+describe('legend comes out of the parser describing the real color source', () => {
+ test('default color mode is thresholds, and the legend is built from them', () => {
+ const parsed = parseData(makeData(false), { ...base, showLegend: true }, theme);
+ expect(parsed.legend!.kind).toBe('thresholds');
+ // the two configured steps, not 10 samples of the data range
+ expect(parsed.legend!.items).toHaveLength(2);
+ expect(parsed.legend!.items.map((i: any) => [i.from, i.to])).toEqual([
+ [null, 20],
+ [20, null],
+ ]);
+ });
+
+ test('the null / no-data key reflects the matrix the parser just built', () => {
+ // a->z is present but null; b->z never appears at all
+ const parsed = parseData(makeData(false), { ...base, showLegend: true }, theme);
+ expect(parsed.legend!.states).toEqual({ hasNull: true, hasNoData: true });
+ });
+
+ test('a ramp color mode yields a gradient instead', () => {
+ const parsed = parseData(makeData(false), { ...base, colorMode: 'sequential', showLegend: true }, theme);
+ expect(parsed.legend!.kind).toBe('gradient');
+ expect(parsed.legend!.stops.length).toBeGreaterThan(1);
+ });
+
+ test('no legend is built when the option is off', () => {
+ const parsed = parseData(makeData(false), { ...base, showLegend: false }, theme);
+ expect(parsed.legend).toBeNull();
+ });
+});
+
+describe('sort by total orders by descending marginal sum', () => {
+ test('row with the largest total sorts first', () => {
+ // totals: a=15, b=20, c=107 => expected order c, b, a
+ const parsed = parseData(makeData(false), { ...base, rowSort: 'total' }, theme);
+ expect(parsed.rows![0]).toBe('c');
+ });
+});
+
+describe('empty / degenerate inputs do not throw', () => {
+ test('no series returns null data', () => {
+ const parsed = parseData({ series: [] } as any, { ...base }, theme);
+ expect(parsed.data).toBeNull();
+ });
+ test('all-null values still parse (every present cell is null)', () => {
+ const parsed = parseData(makeData(false, [null, null, null, null, null, null, null]), { ...base }, theme);
+ expect(Array.isArray(parsed.data)).toBe(true);
+ });
+});