UNPKG

gobierto-vizzs

Version:

Shared data visualizations for Gobierto projects

355 lines (294 loc) 10.6 kB
import Base from "../commons/base"; import { select, selectAll } from 'd3-selection'; import { max, sum, union, group } from 'd3-array'; import { scaleLinear, scaleOrdinal, scaleBand } from 'd3-scale'; import { stack, stackOrderAscending, stackOffsetExpand, stackOrderReverse, stackOffsetNone } from 'd3-shape'; import { axisBottom, axisLeft } from 'd3-axis'; import "d3-transition"; import "./BarChartStacked.css" export default class BarChartStacked extends Base { constructor(container, data, options = {}) { super(container, data, options) this.tooltip = options.tooltip || this.defaultTooltip this.onClick = options.onClick || (() => {}) // main properties to display this.xAxisProp = options.x || "date"; this.yAxisProp = options.y || "group"; this.countProp = options.count; this.showLegend = options.showLegend; this.sortStack = options.sortStack; this.ratio = options.ratio || "absolute"; this.xTickFormat = options.xTickFormat || (d => d); this.yTickFormat = options.yTickFormat || (d => d.toLocaleString()); this.orientationLegend = options.orientationLegend || "left"; this.height = options.height || 400; this.categories = options.categories; this.series = options.series; this.xTickValues = options.xTickValues; this.yTickValues = options.yTickValues; this.margin = { top: 12, bottom: 36, left: this.orientationLegend === 'left' ? 240 : 84, right: this.orientationLegend === 'left' ? 48 : 240, ...options.margin }; // chart size this.getDimensions(); // static elements (do not redraw) this.setupElements(); if (data.length) { this.setData(data) } } getDimensions() { const { width } = this.container.getBoundingClientRect(); this.width = width - this.margin.left - this.margin.right; } setupElements() { this.svg = select(this.container).classed("gv-container", true).append("svg").attr("class", "gv-plot"); this.g = this.svg.append("g").attr("transform", `translate(${this.margin.left} ${this.margin.top})`); this.g.append("g").attr("class", "axis axis-x"); this.g.append("g").attr("class", "axis axis-y"); this.tooltipContainer = select(this.container).append("div").attr("class", "gv-tooltip gv-tooltip-bar-stacked"); this.legendContainer = select(this.container) .append("div") .attr("class", "gv-legend-bar-stacked") .classed("gv-legend-bar-stacked-right", this.orientationLegend === "right"); this.g .append("text") .attr("class", "axis-x-legend") .attr("x", this.orientationLegend === "right" ? this.width + 10 : -60) .attr("y", this.height + 9) .attr("dy", "0.71em") .attr("text-anchor", this.orientationLegend === "right" ? "start" : "end") .text(this.xAxisProp); } build() { this.setScales(); this.g .select(".axis-x") .attr("transform", `translate(0 ${this.height})`) .call(this.xAxis.bind(this)); this.g .select(".axis-y") .call(this.yAxis.bind(this)); this.g .selectAll(".bar-stacked-group") .data(this.stack) .join("g") .attr("class", "bar-stacked-group") .attr("id", ({ key }) => key) .attr("fill", ({ key }) => this.scaleColor(key)) .selectAll("rect") .data(d => d) .join("rect") .attr("class", "bar-stacked-rect") .attr("x", d => this.scaleX(d.data[0])) .attr("width", this.scaleX.bandwidth()) .transition() .duration(400) .attr("y", d => this.scaleY(d[1])) .attr("height", d => this.scaleY(d[0]) - this.scaleY(d[1])) .attr("cursor", "pointer") .selection() .on("touchmove", e => e.preventDefault()) .on("pointermove", this.onPointerMove.bind(this)) .on("pointerout", this.onPointerOut.bind(this)) if (this.showLegend) { this.buildLegends(); } } buildLegends() { this.legendContainer .selectAll(".bar-stack-label") .remove() const items = this.sortStack ? this.stack.sort((a, b) => sum(b, d => d[1] - d[0]) - sum(a, d => d[1] - d[0])) : this.stack this.legendContainer .selectAll(".bar-stack-label") .data(items) .join( enter => { const g = enter .append("div") .attr("class", "bar-stack-label") g.append("span") .attr("class", "bar-stack-label-rect") .attr("style", ({ key }) => `background-color: ${this.scaleColor(key)}`) g.append("span") .attr("class", "bar-stacked-legend-text") .attr("title", ({ key }) => key) .text(({ key }) => key) return g; }, update => update, exit => exit.remove() ) .on("pointermove", function(_, d) { const { key } = d const groups = selectAll('.bar-stacked-group') groups.filter(({ key: k }) => k !== key) .style("opacity", .1) groups.filter(({ key: k }) => k === key) .style("opacity", 1) }) .on("pointerout", () => { selectAll('.bar-stacked-group') .style("opacity", 1) }) } xAxis(g) { g.call( axisBottom(this.scaleX) .tickValues(this.xTickValues) .tickFormat(d => this.xTickFormat(d)) .tickPadding(6) .tickSize(10) ); // remove baseline g.select(".domain").remove(); // remove default formats g.attr("font-family", null).attr("font-size", null); } yAxis(g) { g.call( axisLeft(this.scaleY) .tickValues(this.yTickValues || this.scaleY.ticks().filter(x => !(this.ratio !== "percentage" && !Number.isInteger(x)))) .tickSize(-this.width) .tickFormat((d) => this.ratio === "percentage" ? d.toLocaleString(undefined, { style: "percent" }) : this.yTickFormat(d)) ); // remove baseline g.select(".domain").remove(); // remove default formats g.attr("font-family", null).attr("font-size", null); // change line style defaults g.selectAll("line").attr("stroke-dasharray", 1).attr("stroke", "var(--gv-grey)"); } async setData(data) { this.rawData = data this.data = this.parse(data) this.series = this.series || union(this.data.map((d) => d[this.yAxisProp])); // only set the color scale, as of the first time you get the data if (!this.scaleColor) { this.setColorScale(); } const grouped = group(this.data, d => d[this.xAxisProp], d => d[this.yAxisProp]) // https://d3js.org/d3-shape/stack#_stack this.stack = stack() .keys(this.series) .value(([, group], key) => { const item = group.get(key); if (!item) return 0; return !!this.countProp ? // in case countProp is defined, we group using that property // otherwise, we count the amount of items item.reduce((acc, d) => acc + d[this.countProp], 0) : item.length; }) .order(this.sortStack ? stackOrderAscending : stackOrderReverse) .offset(this.ratio === "percentage" ? stackOffsetExpand : stackOffsetNone)(grouped); await this.getLocale() this.build(); } setColorScale() { this.scaleColor = scaleOrdinal() .domain(this.series) .range(this.PALETTE) } setScales() { this.svg .attr("width", `${this.width + this.margin.left + this.margin.right}`) .attr("height", `${this.height + this.margin.top + this.margin.bottom}`); this.scaleY = scaleLinear() .domain([0, this.ratio === "percentage" ? 1 : max(this.stack, d => max(d, (d) => d[1]))]) .nice() .range([this.height, 0]); this.scaleX = scaleBand() .domain(this.categories || [...new Set(this.data.map((d) => d[this.xAxisProp]))]) .paddingInner(0.5) .rangeRound([(this.width / this.data.map((d) => d[this.xAxisProp]).length) / 2, this.width - (this.width / this.data.map((d) => d[this.xAxisProp]).length) / 2]); } onPointerMove(event, d) { 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") .transition() .duration(200) .style("opacity", 1); } onPointerOut() { this.tooltipContainer.style("pointer-events", "none").transition().duration(200).style("opacity", 0); } parse(data) { // 1. remove those elements with no X axis nor Y axis data // 2. enforce numeric type for countProp return data.reduce((acc, item) => { // enforce data object to define X-axis and Y-axis properties if (Object.hasOwnProperty(item, this.xAxisProp) || Object.hasOwnProperty(item, this.yAxisProp)) return acc if (this.countProp) { // whenever this property is defined, ensure to be numeric item[this.countProp] = +item[this.countProp] || 0 } acc.push(item) return acc }, []).sort(this.sortBy(this.xAxisProp)); } defaultTooltip(d) { const tooltipContent = Array.from(d.data[1]).map(([key, values]) => { const value = this.countProp ? values.reduce((acc, item) => acc + item[this.countProp], 0) : values.length; return ` <div class="tooltip-barchart-stacked-grid"> <span style="background-color: ${this.scaleColor(key)}" class="tooltip-barchart-stacked-grid-key-color"></span> <span class="tooltip-barchart-stacked-grid-key">${key}:</span> <span class="tooltip-barchart-stacked-grid-value">${value}</span> </div>` }); return ` <span class="tooltip-barchart-stacked-title">${this.xTickFormat(d.data[0])}</span> ${tooltipContent.join("")} `; } setX(value) { this.xAxisProp = value } setY(value) { this.yAxisProp = value } setCount(value) { this.countProp = value } setXTickValues(value) { this.xTickValues = value } setYTickValues(value) { this.yTickValues = value } setSortStack(value) { this.sortStack = value } setTooltip(value) { this.tooltip = value } setOnClick(value) { this.onClick = value } setRatio(value) { this.ratio = value } setCategories(value) { this.categories = value } setSeries(value) { this.series = value } setMargin(value) { this.margin = { ...this.margin, ...value } this.container.replaceChildren() this.getDimensions() this.setupElements() this.build() } }