UNPKG

gobierto-vizzs

Version:

Shared data visualizations for Gobierto projects

389 lines (331 loc) 12.7 kB
import Base from "../commons/base"; import { select } from "d3-selection"; import { treemap, hierarchy, treemapBinary } from "d3-hierarchy"; import { scaleLinear, scaleOrdinal } from "d3-scale"; import { interpolate } from "d3-interpolate"; import { group, rollup, sum } from "d3-array"; import "d3-transition"; import "./TreeMap.css" export default class TreeMap extends Base { constructor(container, data, options = {}) { super(container, data, options); this.breadcrumb = options.breadcrumb || this.defaultBreadcrumb; this.itemTemplate = options.itemTemplate || this.defaultItemTemplate; this.tooltip = options.tooltip || this.defaultTooltip; this.margin = { top: 30, bottom: 0, left: 0, right: 0, ...options.margin }; this.onLeafClick = options.onLeafClick || (() => {}) // main properties to display this.groupProp = options.group || "group"; this.valueProp = options.value; // if no value, use length to compute size this.idProp = options.id || "id"; this.rootTitle = options.rootTitle || "root"; // chart size this.getDimensions(); // static elements (do not redraw) this.setupElements(); if (data.length) { this.setData(data); } } getDimensions() { const { width, height } = this.container.getBoundingClientRect(); const minHeight = height > 0 ? height : width / (2 / 1); this.width = width - this.margin.left - this.margin.right; this.height = minHeight - this.margin.top - this.margin.bottom; } setupElements() { this.svg = select(this.container) .classed("gv-container", true) .append("svg") .attr("class", "gv-plot") .on("pointerleave", this.onPointerLeave.bind(this)) this.tooltipContainer = select(this.container).append("div").attr("class", "gv-tooltip"); } build() { const TRANSITION_DURATION = 350 this.svg.attr( "viewBox", `0 0 ${this.width + this.margin.left + this.margin.right} ${this.height + this.margin.top + this.margin.bottom}` ); const render = (group, root) => { if(root === null) { return } this.getGroupItems = [...new Set(this.rawData.map(item => item[this.groupProp[0]]))] let rootNodes = root.children.length > 1 ? root.children.concat(root) : root.children[0].children const node = group.selectAll("g").data(rootNodes).join("g"); /*Create a "fake" breadcumb, if the group data only contains one value, we filter it to go to the next level, but we lose the breadcrumb title, so in this case we have to add it*/ if(root.children.length === 1) { this.svg.append('text') .attr('id', 'first-breadcrumb') .attr('class', 'treemap-breadcrumb') .attr('y', '21px') .attr('x', '0') .style('text-anchor', 'start') .style('font-weight', 'bold') .text(this.getGroupItems[0]); } else { this.svg .select('#first-breadcrumb') .remove() } node .on("touchmove", e => e.preventDefault()) .on("pointerenter", (e, d) => d === root && this.onPointerLeave(e, d)) .on("pointermove", this.onPointerMove.bind(this)) .attr("cursor", "pointer") .attr("class", (d) => (d === root ? "treemap-breadcrumb" : "treemap-item")) .on("click", (e, d) => { //Disable the click on breadcrumbs if we are in the first level. if ((d === root) && this.getGroupItems.length === 1 && root.parent.data.title === "root") { return } else { return (d === root ? zoomout(root) : d.height === 0 ? this.onLeafClick(e, d) : zoomin(d)) } }); node .append("rect") .attr("id", (d) => (d.leafUid = `tm-leaf-${this.seed()}`)) .attr("data-id", (d) => d.data[this.idProp]) .attr("fill", (d) => { if (d === root) return "transparent"; while (d.depth > 1) d = d.parent; return this.scaleColor(d.data[this.idProp]); }) .attr("stroke", "#fff"); node .append("clipPath") .attr("id", (d) => (d.clipUid = `tm-clip-${this.seed()}`)) .append("use") .attr("xlink:href", (d) => new URL(`#${d.leafUid}`, location)); node .append("foreignObject") .attr("clip-path", (d) => d.clipUid) .append("xhtml:div") .attr("class", (d) => (d === root ? "treemap-breadcrumb" : "treemap-item")) .html((d) => (d === root ? this.breadcrumb(this.nodePath(d)) : this.itemTemplate(d))) group.transition().duration(TRANSITION_DURATION).call(position, root); }; const position = (group, root) => { const g = group .selectAll("g") .attr("transform", (d) => d === root ? `translate(0, 0)` : `translate(${this.scaleX(d.x0)} ${this.scaleY(d.y0) + this.margin.top})` ); g.select("rect") .attr("width", (d) => (d === root ? this.width : this.scaleX(d.x1) - this.scaleX(d.x0))) .attr("height", (d) => (d === root ? this.margin.top : this.scaleY(d.y1) - this.scaleY(d.y0))); g.select("foreignObject") .attr("width", (d) => (d === root ? this.width : this.scaleX(d.x1) - this.scaleX(d.x0))) .attr("height", (d) => (d === root ? this.margin.top : this.scaleY(d.y1) - this.scaleY(d.y0))) .selectChild() .call((e) => e .style("opacity", 0) .transition() .duration(TRANSITION_DURATION) .style("opacity", 1) ) .on("end", (d, ix, nodes) => { if (d === root) return null const node = nodes[ix] if (node && node.parentNode) { let { width: w, height: h } = node.getBoundingClientRect() const { width: pW, height: pH } = node.parentNode.getBoundingClientRect() // if the template does not fit in the parent if ((w > pW) || (h > pH)) { while ((w > pW) || (h > pH)) { if (node.lastChild) { // remove children one by one, until the template fits node.lastChild.remove(); ({ width: w, height: h } = node.getBoundingClientRect()) } else break } } } }) }; const zoomin = (d) => { const group0 = group.attr("pointer-events", "none"); const group1 = (group = this.svg.append("g").call(render, d)); this.scaleX.domain([d.x0, d.x1]); this.scaleY.domain([d.y0, d.y1]); this.svg .transition() .duration(TRANSITION_DURATION) .call((t) => group0.transition(t).remove().call(position, d.parent)) .call((t) => group1 .transition(t) .attrTween("opacity", () => interpolate(0, 1)) .call(position, d) ); }; // When zooming out, draw the old nodes on top, and fade them out. const zoomout = (d) => { const group0 = group.attr("pointer-events", "none"); const group1 = (group = this.svg.insert("g", "*").call(render, d.parent)); this.scaleX.domain([d.parent.x0, d.parent.x1]); this.scaleY.domain([d.parent.y0, d.parent.y1]); this.svg .transition() .duration(TRANSITION_DURATION) .call((t) => group0 .transition(t) .remove() .attrTween("opacity", () => interpolate(1, 0)) .call(position, d) ) .call((t) => group1.transition(t).call(position, d.parent)); }; // if there's no value to sum, just count the node const valueFn = this.valueProp ? hierarchy(this.data) .sum((d) => d[this.valueProp]) .sort((a, b) => b.value - a.value) : hierarchy(this.data) .count() .sort((a, b) => b.value - a.value); // tile function required to place the "groupData" (see parse func.) const root = treemap().tile(this.tile.bind(this))(valueFn); this.setScales(); // clean the elements before render this.svg.selectAll("*").remove() let group = this.svg.append("g").call(render, root); } async setData(data) { this.rawData = data this.data = this.parse(data); if (!this.scaleColor) { this.setColorScale(); } // wait for the locales resolution before draw anything await this.getLocale(); this.build(); } setScales() { this.scaleX = scaleLinear().rangeRound([0, this.width]); this.scaleY = scaleLinear().rangeRound([0, this.height]); } setColorScale() { this.scaleColor = scaleOrdinal().range(this.PALETTE); } onPointerMove(event, d) { // the breadcrumb group is always the last item, so, if there's no next sibling, it's breadcrumb const isBreadcrumb = !event.target.closest("g").nextSibling; if (!this.cursorInsideTooltip && !isBreadcrumb && d.parent) { const tooltip = this.tooltipContainer.html(this.tooltip(d)); const [x, y] = this.tooltipPosition(event, this.tooltipContainer.node(), 10); tooltip .style("top", `${y}px`) .style("left", `${x}px`) .style("pointer-events", "auto") .call((t) => t.transition().duration(400).style("opacity", 1)) .on("pointerover", () => (this.cursorInsideTooltip = true)) .on("pointerleave", () => (this.cursorInsideTooltip = false)); } } onPointerLeave() { if (!this.cursorInsideTooltip) { this.tooltipContainer.style("pointer-events", "none").transition().delay(1000).duration(400).style("opacity", 0); } } tile(node, x0, y0, x1, y1) { treemapBinary(node, 0, 0, this.width, this.height); for (const child of node.children) { child.x0 = x0 + (child.x0 / this.width) * (x1 - x0); child.x1 = x0 + (child.x1 / this.width) * (x1 - x0); child.y0 = y0 + (child.y0 / this.height) * (y1 - y0); child.y1 = y0 + (child.y1 / this.height) * (y1 - y0); } } parse(data) { const reduce = this.valueProp ? (v) => sum(v, (d) => d[this.valueProp]) : () => {}; const groupBys = Array.isArray(this.groupProp) ? this.groupProp.map((prop) => (d) => d[prop]) : [(d) => d[this.groupProp]]; // since rollup "reduces" the data, it only works for creating the categories const rollupData = rollup(data, reduce, ...groupBys); // still needing which items belongs to what category, so appends also the group function const groupData = group(data, ...groupBys); // hierarchies always require an object return { [this.idProp]: this.rootTitle, children: this.nest(rollupData, groupData) }; } nest(rollup, group) { // https://observablehq.com/@bayre/unrolling-a-d3-rollup return Array.from(rollup, ([key, value]) => value instanceof Map ? { [this.idProp]: key, children: this.nest(value, group.get(key)) } : { [this.idProp]: key, children: group.get(key) } ); } nodePath(d) { const nodes = d .ancestors() .reverse() .map((d) => d.data[this.idProp]); return this.data.children.length > 1 ? nodes : nodes.filter(node => node !== 'root') } defaultBreadcrumb(d) { return d.map((pathName) => `<span>${pathName}</span>`).join("&nbsp;/&nbsp;"); } defaultItemTemplate(d) { return [ `<div><strong>${d.data[this.idProp]}</strong></div>`, `<div>${d.value.toLocaleString()}</div>`, d.children && `<div>${d.children.length}</div>`, ].join(""); } defaultTooltip(d) { return d.children && d.data.children.map(x => ` <div class="treemap-tooltip-block"> ${[ `<div class="treemap-tooltip-id">${x[this.idProp]}</div>`, x[this.valueProp] && `<div class="treemap-tooltip-values">${x[this.valueProp].toLocaleString()}</div>` ].join("")} </div> `).join(""); } setGroup(value) { this.groupProp = value this.setData(this.rawData) } setValue(value) { this.valueProp = value this.setData(this.rawData) } setId(value) { this.idProp = value this.setData(this.rawData) } setRootTitle(value) { this.rootTitle = value this.setData(this.rawData) } setItemTemplate(value) { this.itemTemplate = value this.build() } setBreadcrumb(value) { this.breadcrumb = value this.build() } setTooltip(value) { this.tooltip = value this.build() } setOnLeafClick(value) { this.onLeafClick = value this.build() } setMargin(value) { this.margin = { ...this.margin, ...value } this.container.replaceChildren() this.getDimensions() this.setupElements() this.build() } }