diff --git a/src/controller.js b/src/controller.js index e55e0bc..4e53cf2 100644 --- a/src/controller.js +++ b/src/controller.js @@ -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'; @@ -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); @@ -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)); @@ -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 { diff --git a/src/element.js b/src/element.js index ee1d888..b6111f2 100644 --- a/src/element.js +++ b/src/element.js @@ -98,10 +98,13 @@ 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; @@ -109,8 +112,16 @@ export function shouldDrawCaption(rect, options) { 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); @@ -118,26 +129,60 @@ function drawText(ctx, rect, options, item, levels) { 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) { @@ -393,6 +438,7 @@ TreemapElement.defaults = { rtl: false, spacing: 0.5, unsorted: false, + displayMode: 'containerBoxes', }; TreemapElement.descriptors = { diff --git a/test/fixtures/headersbox/grouped-large.js b/test/fixtures/headersbox/grouped-large.js new file mode 100644 index 0000000..c57d6e4 --- /dev/null +++ b/test/fixtures/headersbox/grouped-large.js @@ -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 + } + } +}; diff --git a/test/fixtures/headersbox/grouped-large.png b/test/fixtures/headersbox/grouped-large.png new file mode 100644 index 0000000..885c484 Binary files /dev/null and b/test/fixtures/headersbox/grouped-large.png differ diff --git a/test/fixtures/headersbox/grouped.js b/test/fixtures/headersbox/grouped.js new file mode 100644 index 0000000..314bd7d --- /dev/null +++ b/test/fixtures/headersbox/grouped.js @@ -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 + } + } +}; diff --git a/test/fixtures/headersbox/grouped.png b/test/fixtures/headersbox/grouped.png new file mode 100644 index 0000000..1eb3b48 Binary files /dev/null and b/test/fixtures/headersbox/grouped.png differ diff --git a/test/fixtures/headersbox/large-captions.js b/test/fixtures/headersbox/large-captions.js new file mode 100644 index 0000000..fbe1c68 --- /dev/null +++ b/test/fixtures/headersbox/large-captions.js @@ -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 + } + } +}; diff --git a/test/fixtures/headersbox/large-captions.png b/test/fixtures/headersbox/large-captions.png new file mode 100644 index 0000000..6c5e330 Binary files /dev/null and b/test/fixtures/headersbox/large-captions.png differ diff --git a/test/fixtures/headersbox/no-captions.js b/test/fixtures/headersbox/no-captions.js new file mode 100644 index 0000000..a7123be --- /dev/null +++ b/test/fixtures/headersbox/no-captions.js @@ -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 + } + } +}; diff --git a/test/fixtures/headersbox/no-captions.png b/test/fixtures/headersbox/no-captions.png new file mode 100644 index 0000000..11fcd5a Binary files /dev/null and b/test/fixtures/headersbox/no-captions.png differ diff --git a/test/specs/controller.spec.js b/test/specs/controller.spec.js index 2d35aea..66935be 100644 --- a/test/specs/controller.spec.js +++ b/test/specs/controller.spec.js @@ -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'));