Skip to content

Commit

Permalink
imp: add alternative display mode
Browse files Browse the repository at this point in the history
This commit adds an alternative display mode for treemap. In the
original mode, leaf nodes are displayed inside the rectangle of their
parent, that is itself inside the rectangle of its parent, and so on.
With the new mode "headerBoxes" the parent rectangles do not contain
their children, but are displayed as headers above them.

The new mode is activated by setting the displayMode option to
"headerBoxes" in the dataset.
  • Loading branch information
hokolomopo committed Jan 29, 2025
1 parent 058f36e commit 5be45aa
Show file tree
Hide file tree
Showing 11 changed files with 237 additions and 15 deletions.
42 changes: 32 additions & 10 deletions src/controller.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {Chart, DatasetController, registry} from 'chart.js';
import {toFont, valueOrDefault, isObject, clipArea, unclipArea} from 'chart.js/helpers';
import {group, requireVersion, normalizeTreeToArray, getGroupKey} from './utils';
import {shouldDrawCaption, parseBorderWidth} from './element';
import {shouldDrawCaption, parseBorderWidth, getCaptionHeight} from './element';
import squarify from './squarify';
import {version} from '../package.json';
import {arrayNotEqual, rectNotEqual, scaleRect} from './helpers/index';
Expand All @@ -14,6 +14,8 @@ function buildData(tree, dataset, keys, mainRect) {
const groups = dataset.groups || [];
const glen = groups.length;
const sp = valueOrDefault(dataset.spacing, 0);
const spX = dataset.displayMode === 'headerBoxes' ? 0 : sp;
const spY = sp;
const captions = dataset.captions || {};
const font = toFont(captions.font);
const padding = valueOrDefault(captions.padding, 3);
Expand All @@ -26,17 +28,25 @@ function buildData(tree, dataset, keys, mainRect) {
const ret = gsq.slice();
if (gidx < glen - 1) {
gsq.forEach((sq) => {
const bw = parseBorderWidth(dataset.borderWidth, sq.w / 2, sq.h / 2);
const bw = dataset.displayMode === 'headerBoxes'
? {l: 0, r: 0, t: 0, b: 0}
: parseBorderWidth(dataset.borderWidth, sq.w / 2, sq.h / 2);
const subRect = {
...rect,
x: sq.x + sp + bw.l,
y: sq.y + sp + bw.t,
w: sq.w - 2 * sp - bw.l - bw.r,
h: sq.h - 2 * sp - bw.t - bw.b,
x: sq.x + spX + bw.l,
y: sq.y + spY + bw.t,
w: sq.w - 2 * spX - bw.l - bw.r,
h: sq.h - 2 * spY - bw.t - bw.b,
};
if (shouldDrawCaption(subRect, captions)) {
subRect.y += font.lineHeight + padding * 2;
subRect.h -= font.lineHeight + padding * 2;
if (shouldDrawCaption(dataset.displayMode, subRect, captions)) {
const captionHeight = getCaptionHeight(dataset.displayMode, subRect, font, padding);
if (dataset.displayMode === 'headerBoxes') {
subRect.y += captionHeight - spY;
subRect.h -= captionHeight - spY * 2;
} else {
subRect.y += captionHeight;
subRect.h -= captionHeight;
}
}
gdata.forEach((gEl) => {
ret.push(...recur(gEl.children, gidx + 1, subRect, sq.g, sq.s));
Expand All @@ -46,9 +56,21 @@ function buildData(tree, dataset, keys, mainRect) {
return ret;
}

return glen
const result = glen
? recur(tree, 0, mainRect)
: squarify(tree, mainRect, keys);
return result.map((d) => {
if (dataset.displayMode !== 'headerBoxes' || d.l === glen - 1) {
return d;
}
const rect = {...d, h: d.h - 2 * spY};
if (!shouldDrawCaption(dataset.displayMode, rect, captions)) {
return undefined;
}
const captionHeight = getCaptionHeight(dataset.displayMode, rect, font, padding);
return {...d, h: captionHeight};
}).filter((d) => d);

}

export default class TreemapController extends DatasetController {
Expand Down
56 changes: 51 additions & 5 deletions src/element.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,46 +98,91 @@ function addNormalRectPath(ctx, rect) {
ctx.rect(rect.x, rect.y, rect.w, rect.h);
}

export function shouldDrawCaption(rect, options) {
export function shouldDrawCaption(displayMode, rect, options) {
if (!options || options.display === false) {
return false;
}
if (displayMode === 'headerBoxes') {
return true;
}
const {w, h} = rect;
const font = toFont(options.font);
const min = font.lineHeight;
const padding = limit(valueOrDefault(options.padding, 3) * 2, 0, Math.min(w, h));
return (w - padding) > min && (h - padding) > min;
}

export function getCaptionHeight(displayMode, rect, font, padding) {
if (displayMode !== 'headerBoxes') {
return font.lineHeight + padding * 2;
}
const captionHeight = font.lineHeight + padding * 2;
return rect.h < 2 * captionHeight ? rect.h / 3 : captionHeight;
}

function drawText(ctx, rect, options, item, levels) {
const {captions, labels} = options;
const {captions, labels, displayMode} = options;
ctx.save();
ctx.beginPath();
ctx.rect(rect.x, rect.y, rect.w, rect.h);
ctx.clip();
const isLeaf = item && (!defined(item.l) || item.l === levels);
if (isLeaf && labels.display) {
drawLabel(ctx, rect, options);
} else if (!isLeaf && shouldDrawCaption(rect, captions)) {
} else if (!isLeaf && shouldDrawCaption(displayMode, rect, captions)) {
drawCaption(ctx, rect, options, item);
}
ctx.restore();
}

function drawCaption(ctx, rect, options, item) {
const {captions, spacing, rtl} = options;
const {captions, spacing, rtl, displayMode} = options;
const {color, hoverColor, font, hoverFont, padding, align, formatter} = captions;
const oColor = (rect.active ? hoverColor : color) || color;
const oAlign = align || (rtl ? 'right' : 'left');
const optFont = (rect.active ? hoverFont : font) || font;
const oFont = toFont(optFont);
const fonts = [oFont];
if (oFont.lineHeight > rect.h) {
return;
}
let text = formatter || item.g;
const captionSize = measureLabelSize(ctx, [formatter], fonts);
if (captionSize.width + 2 * padding > rect.w) {
text = sliceTextToFitWidth(ctx, text, rect.w - 2 * padding, fonts);
}

const lh = oFont.lineHeight / 2;
const x = calculateX(rect, oAlign, padding);
ctx.fillStyle = oColor;
ctx.font = oFont.string;
ctx.textAlign = oAlign;
ctx.textBaseline = 'middle';
ctx.fillText(formatter || item.g, x, rect.y + padding + spacing + lh);
const y = displayMode === 'headerBoxes' ? rect.y + rect.h / 2 : rect.y + padding + spacing + lh;
ctx.fillText(text, x, y);
}

function sliceTextToFitWidth(ctx, text, width, fonts) {
const ellipsis = '...';
const ellipsisWidth = measureLabelSize(ctx, [ellipsis], fonts).width;
if (ellipsisWidth >= width) {
return '';
}
let lowerBoundLen = 1;
let upperBoundLen = text.length;
let currentWidth;
while (lowerBoundLen <= upperBoundLen) {
const currentLen = Math.floor((lowerBoundLen + upperBoundLen) / 2);
const currentText = text.slice(0, currentLen);
currentWidth = measureLabelSize(ctx, [currentText], fonts).width;
if (currentWidth + ellipsisWidth > width) {
upperBoundLen = currentLen - 1;
} else {
lowerBoundLen = currentLen + 1;
}
}
const slicedText = text.slice(0, Math.max(0, lowerBoundLen - 1));
return slicedText ? slicedText + ellipsis : '';
}

function measureLabelSize(ctx, lines, fonts) {
Expand Down Expand Up @@ -393,6 +438,7 @@ TreemapElement.defaults = {
rtl: false,
spacing: 0.5,
unsorted: false,
displayMode: 'containerBoxes',
};

TreemapElement.descriptors = {
Expand Down
35 changes: 35 additions & 0 deletions test/fixtures/headersbox/grouped-large.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
const arrayN = (n) => Array.from({length: n}).map((_, i) => i);

const groups = arrayN(10);
const tree = groups.reduce((acc, grp) => [
...acc,
...arrayN(grp * 10).map(i => ({grp: `group: ${grp}`, sub: `sub: ${i}`, value: (i % 10) * 10}))
], []);

export default {
config: {
type: 'treemap',
data: {
datasets: [{
tree,
backgroundColor: (ctx) => ctx.raw.l ? 'dimgray' : 'silver',
borderColor: (ctx) => ctx.raw.l ? 'white' : 'black',
borderWidth: 0,
spacing: 1,
key: 'value',
groups: ['grp', 'sub'],
displayMode: 'headerBoxes',
}]
},
options: {
events: []
}
},
options: {
spriteText: true,
canvas: {
height: 300,
width: 800
}
}
};
Binary file added test/fixtures/headersbox/grouped-large.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
35 changes: 35 additions & 0 deletions test/fixtures/headersbox/grouped.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
const arrayN = (n) => Array.from({length: n}).map((_, i) => i);

const groups = arrayN(4);
const tree = groups.reduce((acc, grp) => [
...acc,
...arrayN(grp * 4).map(i => ({grp: `group: ${grp}`, sub: `sub: ${i}`, value: (i % 4) * 4}))
], []);

export default {
config: {
type: 'treemap',
data: {
datasets: [{
tree,
backgroundColor: (ctx) => ctx.raw.l ? 'lightblue' : 'dimgray',
borderColor: 'dimgray',
borderWidth: 1,
spacing: 1,
key: 'value',
groups: ['grp', 'sub'],
displayMode: 'headerBoxes'
}]
},
options: {
events: []
}
},
options: {
spriteText: true,
canvas: {
height: 300,
width: 800
}
}
};
Binary file added test/fixtures/headersbox/grouped.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
38 changes: 38 additions & 0 deletions test/fixtures/headersbox/large-captions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const arrayN = (n) => Array.from({length: n}).map((_, i) => i);

const groups = arrayN(10);
const tree = groups.reduce((acc, grp) => [
...acc,
...arrayN(grp * 10).map(i => ({grp: `group: ${grp}`, sub: `sub: ${i}`, value: (i % 10) * 10}))
], []);

export default {
config: {
type: 'treemap',
data: {
datasets: [{
tree,
backgroundColor: (ctx) => ctx.raw.l ? 'dimgray' : 'silver',
borderColor: (ctx) => ctx.raw.l ? 'white' : 'black',
borderWidth: 0,
spacing: 1,
key: 'value',
groups: ['grp', 'sub'],
displayMode: 'headerBoxes',
captions: {
padding: 20,
}
}]
},
options: {
events: []
}
},
options: {
spriteText: true,
canvas: {
height: 200,
width: 800
}
}
};
Binary file added test/fixtures/headersbox/large-captions.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
45 changes: 45 additions & 0 deletions test/fixtures/headersbox/no-captions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
const arrayN = (n) => Array.from({length: n}).map((_, i) => i);

const groups = arrayN(5);
const tree = groups.reduce((acc, grp) => [
...acc,
...arrayN(grp * 5).map(i => ({grp: `group: ${grp}`, sub: `sub: ${i}`, value: (i % 5) * 5}))
], []);

const colors = ['red', 'green', 'blue', 'yellow', 'purple'];

export default {
config: {
type: 'treemap',
data: {
datasets: [{
tree,
backgroundColor: (ctx) => {
if (ctx.raw.l === 0) {
return 'dimgray';
}
const parentGrp = ctx.raw._data.grp;
return colors[parentGrp[parentGrp.length - 1]];
},
borderWidth: 0,
spacing: 1,
key: 'value',
groups: ['grp', 'sub'],
displayMode: 'headerBoxes',
captions: {
display: false,
}
}]
},
options: {
events: []
}
},
options: {
spriteText: true,
canvas: {
height: 300,
width: 800
}
}
};
Binary file added test/fixtures/headersbox/no-captions.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions test/specs/controller.spec.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
describe('auto', jasmine.fixtures('basic'));
describe('auto', jasmine.fixtures('grouped'));
describe('auto', jasmine.fixtures('headersbox'));
describe('auto', jasmine.fixtures('events'));
describe('auto', jasmine.fixtures('advanced'));
describe('auto', jasmine.fixtures('issues'));
Expand Down

0 comments on commit 5be45aa

Please sign in to comment.