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 Feb 6, 2025
1 parent 058f36e commit 83e1698
Show file tree
Hide file tree
Showing 17 changed files with 375 additions and 12 deletions.
1 change: 1 addition & 0 deletions docs/.vuepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export default defineConfig({
'tree',
'captions',
'dividers',
'displayMode',
'rtl',
'datalabels',
'zoom'
Expand Down
91 changes: 91 additions & 0 deletions docs/samples/displayMode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Display Mode

```js chart-editor
// <block:setup:3>
let DISPLAY_MODE = 'containerBoxes';

function capitalizeFirstLetter(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
// </block:setup>

// <block:options:2>
const options = {
plugins: {
title: {
display: true,
text: 'US area by division / state'
},
legend: {
display: false
},
tooltip: {
callbacks: {
title(items) {
return capitalizeFirstLetter(items[0].dataset.key);
},
label(item) {
const dataItem = item.raw;
const obj = dataItem._data;
const label = obj.state || obj.division || obj.region;
return label + ': ' + dataItem.v;
}
}
}
}
};
// </block:options>

// <block:config:0>
const config = {
type: 'treemap',
data: {
datasets: [{
tree: Data.statsByState,
key: 'area',
groups: ['division', 'state'],
spacing: 2,
borderWidth: 1,
borderColor: 'rgba(200,200,200,1)',
backgroundColor: (ctx) => {
if (ctx.type !== 'data') {
return 'transparent';
}
if (DISPLAY_MODE === 'containerBoxes') {
return 'rgba(220,230,220,0.3)';
}
return ctx.raw.l ? 'rgb(220,230,220)' : 'lightgray';
},
displayMode: DISPLAY_MODE,
captions: {
padding: 6,
},
}]
},
options: options
};

// </block:config>
function toggle(chart, mode) {
const dataset = {...config.data.datasets[0], displayMode: mode};
DISPLAY_MODE = mode;
chart.data.datasets = [dataset];
chart.update();
}

const actions = [
{
name: 'Container Boxes',
handler: (chart) => toggle(chart, 'containerBoxes')
},
{
name: 'Header Boxes',
handler: (chart) => toggle(chart, 'headerBoxes')
},
];

module.exports = {
actions,
config,
};
```
9 changes: 9 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ These are used to set display properties for a specific dataset.
| [`tree`](#general) | `number[]` \| `object[]` \| `object` | - | **required**
| [`treeLeafKey`](#general) | `string` | - | `_leaf` |
| [`unsorted`](#general) | `boolean` | - | `false`
| [`displayMode`](#styling) | `string` | - | `'containerBoxes'`

All these values, if `undefined`, fallback to the scopes described in [option resolution](https://www.chartjs.org/docs/latest/general/options.html).

Expand Down Expand Up @@ -150,13 +151,21 @@ The style of the treemap element can be controlled with the following properties
| [`borderRadius`](#borderradius) | Radius of the rectangle of treemap element (in pixels).
| `borderWidth` | The treemap element border width (in pixels).
| `spacing` | Fixed distance (in pixels) between all treemap elements.
| [`displayMode`](#displaymode) | How to display the treemap parent groups.

If the value is `undefined`, fallbacks to the associated `elements.treemap.*` options.

#### borderRadius

If this value is a number, it is applied to all corners of the rectangle (topLeft, topRight, bottomLeft, bottomRight). If this value is an object, the `topLeft` property defines the top-left corners border radius. Similarly, the `topRight`, `bottomLeft`, and `bottomRight` properties can also be specified. Omitted corners have radius of 0.

#### displayMode

This property supports two values:

* `'containerBoxes'` (default): The parent group is represented as a large rectangle, with its child elements displayed inside it.
* `'headerBoxes'`: The parent group appears as a header, with its child elements positioned below it.

### Interactions

The interaction with each element can be controlled with the following properties:
Expand Down
28 changes: 21 additions & 7 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 @@ -13,7 +13,7 @@ function buildData(tree, dataset, keys, mainRect) {
}
const groups = dataset.groups || [];
const glen = groups.length;
const sp = valueOrDefault(dataset.spacing, 0);
const sp = dataset.displayMode === 'headerBoxes' ? 0 : valueOrDefault(dataset.spacing, 0);
const captions = dataset.captions || {};
const font = toFont(captions.font);
const padding = valueOrDefault(captions.padding, 3);
Expand All @@ -26,17 +26,20 @@ 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,
};
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);
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 +49,20 @@ 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;
}
if (!shouldDrawCaption(dataset.displayMode, d, captions)) {
return undefined;
}
const captionHeight = getCaptionHeight(dataset.displayMode, d, 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
47 changes: 47 additions & 0 deletions test/fixtures/grouped/captionsTruncating.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
const longCategory1 = 'This is a long category name that should be truncated';
const longCategory2 = 'This is another long category name that should be truncated';
const longSubcategory1 = 'This is a long subcategory name that should be truncated';
const longSubcategory2 = 'This is another long subcategory name that should be truncated';

const data = [
{category: longCategory1, subcategory: longSubcategory1, value: 1},
{category: longCategory1, subcategory: longSubcategory1, value: 5},
{category: longCategory1, subcategory: longSubcategory1, value: 3},
{category: longCategory1, subcategory: longSubcategory2, value: 2},
{category: longCategory1, subcategory: longSubcategory2, value: 1},
{category: longCategory1, subcategory: longSubcategory2, value: 8},
{category: longCategory2, subcategory: longSubcategory1, value: 4},
{category: longCategory2, subcategory: longSubcategory1, value: 5},
{category: longCategory2, subcategory: longSubcategory2, value: 4},
{category: longCategory2, subcategory: longSubcategory2, value: 1},
];

export default {
tolerance: 0.0050,
config: {
type: 'treemap',
data: {
datasets: [{
tree: data,
key: 'value',
groups: ['category', 'subcategory', 'value'],
backgroundColor: 'lightGreen',
captions: {
display: true,
align: 'center',
padding: 10,
},
}]
},
options: {
events: []
}
},
options: {
spriteText: true,
canvas: {
height: 256,
width: 512
}
}
};
Binary file added test/fixtures/grouped/captionsTruncating.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-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.
Loading

0 comments on commit 83e1698

Please sign in to comment.