From 6613160ff490026ffad21030239a4f09eced3bb7 Mon Sep 17 00:00:00 2001 From: Eliad Moosavi Date: Mon, 1 Oct 2018 16:49:47 -0400 Subject: [PATCH] feat(core): first attempt at combo-chart --- packages/core/demo/demo-data/combo.ts | 75 +++++++++++++++++ packages/core/demo/demo-data/index.ts | 1 + packages/core/demo/index.html | 18 ++++ packages/core/demo/index.ts | 27 +++++- packages/core/src/bar-chart.ts | 26 ++++-- packages/core/src/base-axis-chart.ts | 73 ++++++++++------ packages/core/src/base-chart.ts | 6 +- packages/core/src/combo-chart.ts | 110 +++++++++++++++++++++++++ packages/core/src/index.ts | 4 +- packages/core/src/line-chart.ts | 35 ++++++-- packages/core/src/stacked-bar-chart.ts | 26 +++++- 11 files changed, 354 insertions(+), 47 deletions(-) create mode 100644 packages/core/demo/demo-data/combo.ts create mode 100644 packages/core/src/combo-chart.ts diff --git a/packages/core/demo/demo-data/combo.ts b/packages/core/demo/demo-data/combo.ts new file mode 100644 index 0000000000..09e9bbed00 --- /dev/null +++ b/packages/core/demo/demo-data/combo.ts @@ -0,0 +1,75 @@ +import { colors } from "./colors"; + +export const comboData = { + labels: ["Qty", "More", "Sold", "Restocking", "Misc"], + datasets: [ + { + label: "Dataset 1", + backgroundColors: [colors[0]], + data: [ + 65000, + -29123, + -35213, + 51213, + 16932 + ], + chartType: "BarChart" + }, + { + label: "Dataset 2", + backgroundColors: [colors[2]], + data: [ + -12312, + 23232, + 34232, + -12312, + -34234 + ], + chartType: "BarChart" + }, + { + label: "Dataset 3", + backgroundColors: [colors[3]], + data: [ + -32423, + 21313, + 64353, + 24134, + 32423 + ], + chartType: "BarChart" + }, + { + label: "Dataset 4", + backgroundColors: [colors[1]], + data: [ + 32432, + 11312, + 3234, + 43534, + 34234 + ], + chartType: "LineChart" + } + ] +}; + +export const comboOptions = { + scales: { + x: { + title: "2018 Annual Sales Figures", + }, + y: { + formatter: axisValue => `${axisValue / 1000}k`, + yMaxAdjuster: yMaxValue => yMaxValue * 1.1, + }, + y2: { + ticks: { + max: 70, + min: -60 + } + } + }, + legendClickable: true, + containerResizable: true +}; diff --git a/packages/core/demo/demo-data/index.ts b/packages/core/demo/demo-data/index.ts index 00948b637d..82ed98e4ec 100644 --- a/packages/core/demo/demo-data/index.ts +++ b/packages/core/demo/demo-data/index.ts @@ -3,3 +3,4 @@ export { colors } from "./colors"; export * from "./bar"; export * from "./pie-donut"; export * from "./line"; +export * from "./combo"; diff --git a/packages/core/demo/index.html b/packages/core/demo/index.html index 1262a598a4..e51f8eb7e9 100644 --- a/packages/core/demo/index.html +++ b/packages/core/demo/index.html @@ -45,6 +45,24 @@

A reusable framework-agnostic D3 charting library.

+ + +
diff --git a/packages/core/demo/index.ts b/packages/core/demo/index.ts index d01f0d74a4..7739657133 100644 --- a/packages/core/demo/index.ts +++ b/packages/core/demo/index.ts @@ -3,7 +3,7 @@ import { LineChart, PieChart, DonutChart, - DonutCenter + ComboChart } from "../src/index"; // Styles @@ -25,7 +25,10 @@ import { curvedLineOptions, curvedLineData, lineData, - lineOptions + lineOptions, + // Combo + comboData, + comboOptions } from "./demo-data/index"; const chartTypes = [ @@ -41,6 +44,12 @@ const chartTypes = [ options: simpleBarOptions, data: simpleBarData }, + { + id: "combo", + name: "Combo", + options: comboOptions, + data: comboData + }, { id: "stacked-bar", name: "Bar", @@ -158,7 +167,7 @@ const changeDemoData = (chartType: any, oldData: any, delay?: number) => { return newDataset; }); - if (removeADataset) { + if (removeADataset && chartType !== "combo") { const randomIndex = Math.floor(Math.random() * (newData.datasets.length - 1)); newData.datasets.splice(randomIndex, randomIndex); } @@ -244,6 +253,18 @@ chartTypes.forEach(type => { setDemoActionsEventListener(type.id, type.data); + break; + case "combo": + classyCharts[type.id] = new ComboChart( + classyContainer, + { + data: type.data, + options: Object.assign({}, type.options, {type: type.id}), + } + ); + + setDemoActionsEventListener(type.id, type.data); + break; case "curved-line": case "line": diff --git a/packages/core/src/bar-chart.ts b/packages/core/src/bar-chart.ts index 57ac8df0a1..ff09cb4180 100644 --- a/packages/core/src/bar-chart.ts +++ b/packages/core/src/bar-chart.ts @@ -43,19 +43,33 @@ export class BarChart extends BaseAxisChart { super(holder, configs); + // To be used for combo chart instances of a bar chart + const { axis } = configs.options; + if (axis) { + const { bar: margins } = Configuration.charts.margin; + const chartSize = this.getChartSize(); + const width = chartSize.width - margins.left - margins.right; + + this.x1 = scaleBand().rangeRound([0, width]).padding(Configuration.bars.spacing.bars); + this.x1.domain(configs.data.datasets.map(dataset => dataset.label)).rangeRound([0, this.x.bandwidth()]); + } + this.options.type = "bar"; } - setXScale(noAnimation?: boolean) { + setXScale(xScale?: any) { const { bar: margins } = Configuration.charts.margin; - const chartSize = this.getChartSize(); const width = chartSize.width - margins.left - margins.right; - this.x = scaleBand().rangeRound([0, width]).padding(Configuration.bars.spacing.datasets); - this.x1 = scaleBand().rangeRound([0, width]).padding(Configuration.bars.spacing.bars); + if (xScale) { + this.x = xScale; + } else { + this.x = scaleBand().rangeRound([0, width]).padding(Configuration.bars.spacing.datasets); + this.x.domain(this.displayData.labels); + } - this.x.domain(this.displayData.labels); + this.x1 = scaleBand().rangeRound([0, width]).padding(Configuration.bars.spacing.bars); this.x1.domain(this.displayData.datasets.map(dataset => dataset.label)).rangeRound([0, this.x.bandwidth()]); } @@ -218,7 +232,7 @@ export class BarChart extends BaseAxisChart { this.updateXandYGrid(true); // Scale out the domains - this.setXScale(true); + this.setXScale(); this.setYScale(); // Set the x & y axis as well as their labels diff --git a/packages/core/src/base-axis-chart.ts b/packages/core/src/base-axis-chart.ts index 3a2605197e..7cd39e63a1 100644 --- a/packages/core/src/base-axis-chart.ts +++ b/packages/core/src/base-axis-chart.ts @@ -19,12 +19,18 @@ export class BaseAxisChart extends BaseChart { constructor(holder: Element, configs: any) { super(holder, configs); + + const { axis } = configs.options; + if (axis) { + this.x = axis.x; + this.y = axis.y; + this.y2 = axis.y2; + } } setSVG(): any { super.setSVG(); - const chartSize = this.getChartSize(); this.container.classed("chart-axis", true); this.innerWrap.append("g") .attr("class", "x grid"); @@ -39,22 +45,32 @@ export class BaseAxisChart extends BaseChart { this.displayData = data; } - this.setSVG(); + // If an axis exists + const xAxisRef = select(this.holder).select(".axis.x"); + if (!xAxisRef.node()) { + this.setSVG(); - // Scale out the domains - // Set the x & y axis as well as their labels - this.setXScale(); - this.setXAxis(); - this.setYScale(); - this.setYAxis(); + // Scale out the domains + // Set the x & y axis as well as their labels + this.setXScale(); + this.setXAxis(); + this.setYScale(); + this.setYAxis(); + + // Draw the x & y grid + this.drawXGrid(); + this.drawYGrid(); - // Draw the x & y grid - this.drawXGrid(); - this.drawYGrid(); + this.addOrUpdateLegend(); + } else { + const holderRef = select(this.holder); + + this.innerWrap = holderRef.select("g.inner-wrap"); + this.svg = holderRef.select("svg.chart-svg"); + } this.draw(); - this.addOrUpdateLegend(); this.addDataPointEventListener(); } @@ -166,15 +182,19 @@ export class BaseAxisChart extends BaseChart { * Axis & Grids * *************************************/ - setXScale(noAnimation?: boolean) { - const { bar: margins } = Configuration.charts.margin; - const { scales } = this.options; + setXScale(xScale?: any) { + if (xScale) { + this.x = xScale; + } else { + const { bar: margins } = Configuration.charts.margin; + const { scales } = this.options; - const chartSize = this.getChartSize(); - const width = chartSize.width - margins.left - margins.right; + const chartSize = this.getChartSize(); + const width = chartSize.width - margins.left - margins.right; - this.x = scaleBand().rangeRound([0, width]).padding(Configuration.scales.x.padding); - this.x.domain(this.displayData.labels); + this.x = scaleBand().rangeRound([0, width]).padding(Configuration.scales.x.padding); + this.x.domain(this.displayData.labels); + } } setXAxis(noAnimation?: boolean) { @@ -184,7 +204,9 @@ export class BaseAxisChart extends BaseChart { const t = noAnimation ? this.getInstantTransition() : this.getDefaultTransition(); - const xAxis = axisBottom(this.x).tickSize(0).tickSizeOuter(0); + const xAxis = axisBottom(this.x) + .tickSize(0) + .tickSizeOuter(0); let xAxisRef = this.svg.select("g.x.axis"); // If the exists in the chart SVG, just update it @@ -273,7 +295,7 @@ export class BaseAxisChart extends BaseChart { return yMin; } - setYScale() { + setYScale(yScale?: any) { const chartSize = this.getChartSize(); const height = chartSize.height - this.innerWrap.select(".x.axis").node().getBBox().height; @@ -281,9 +303,12 @@ export class BaseAxisChart extends BaseChart { const yMin = this.getYMin(); const yMax = this.getYMax(); - - this.y = scaleLinear().range([height, 0]); - this.y.domain([Math.min(yMin, 0), yMax]); + if (yScale) { + this.y = yScale; + } else { + this.y = scaleLinear().range([height, 0]); + this.y.domain([Math.min(yMin, 0), yMax]); + } if (scales.y2 && scales.y2.ticks.max) { this.y2 = scaleLinear().rangeRound([height, 0]); diff --git a/packages/core/src/base-chart.ts b/packages/core/src/base-chart.ts index a57098ae8b..bcc8790b1b 100644 --- a/packages/core/src/base-chart.ts +++ b/packages/core/src/base-chart.ts @@ -74,9 +74,7 @@ export class BaseChart { } setData(data: any) { - const { selectors } = Configuration; - const innerWrapElement = this.holder.querySelector(selectors.INNERWRAP); - const initialDraw = innerWrapElement === null; + const initialDraw = !this.innerWrap; const newDataIsAPromise = Promise.resolve(data) === data; // Dispatch the update event @@ -234,7 +232,7 @@ export class BaseChart { setSVG(): any { const chartSize = this.getChartSize(); this.svg = this.container.append("svg") - .classed("chart-svg", true); + .classed("chart-svg " + this.options.type, true); this.innerWrap = this.svg.append("g") .classed("inner-wrap", true); diff --git a/packages/core/src/combo-chart.ts b/packages/core/src/combo-chart.ts new file mode 100644 index 0000000000..dec5bcf9f8 --- /dev/null +++ b/packages/core/src/combo-chart.ts @@ -0,0 +1,110 @@ +import { BaseAxisChart } from "./base-axis-chart"; + +import * as ChartTypes from "./index"; + +// TODO - Support adding/removing charts when updating data +export class ComboChart extends BaseAxisChart { + // Includes all the sub-charts + charts = []; + + constructor(holder: Element, configs: any) { + super(holder, configs); + + this.options.type = "combo"; + } + + // Extract data related to the specific sub-chart + extractDataForChart(chartType: string) { + return Object.assign({}, this.displayData, { + datasets: this.displayData.datasets.filter(_dataset => _dataset.chartType === chartType) + }); + } + + update() { + super.update(); + + if (this.charts && this.charts.length > 0) { + this.updateChildrenScales(); + this.setChildrenData(); + } + } + + // This only needs to be performed in the sub-chart instances + interpolateValues(newData: any) { + return; + } + + // This only needs to be performed in the sub-chart instances + addDataPointEventListener() { + return; + } + + draw() { + // If charts have been initialized + if (this.charts.length) { + return; + } + + this.displayData.datasets.forEach(dataset => { + // If the chart type is valid + if (ChartTypes[dataset.chartType]) { + // If the chart for this dataset has not already been created + if (this.charts.findIndex(chart => chart.type === dataset.chartType) === -1) { + if (ChartTypes[dataset.chartType].prototype instanceof BaseAxisChart) { + const chartConfigs = { + data: this.extractDataForChart(dataset.chartType), + options: Object.assign({}, this.options, { + axis: { + x: this.x, + y: this.y, + y2: this.y2 + } + }) + }; + + const chart = new ChartTypes[dataset.chartType]( + this.holder, + chartConfigs + ); + + // Override sub-chart update function + chart.update = function () { + this.displayData = this.updateDisplayData(); + + this.interpolateValues(this.displayData); + }; + + // Add chart to the array of sub-charts + this.charts.push({ + type: dataset.chartType, + instance: chart + }); + } else { + console.error(`Chart type ${dataset.chartType} not supported in Combo - your chart should extend BaseAxisChart`); + } + } + } else { + console.error(`Invalid chart type: "${dataset.chartType}"`); + } + }); + } + + // Pass down the x & y scales to the sub-charts + updateChildrenScales() { + this.charts.forEach(chart => { + chart.instance.setXScale(this.x); + chart.instance.setYScale(this.y); + }); + } + + // Extract data related to each sub-chart and set them + setChildrenData() { + this.charts.forEach(chart => { + const chartData = this.extractDataForChart(chart.type); + + chart.instance.setData(chartData); + + console.log(`SET ${chart.type} data to`, chartData); + }); + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ae9392e7d4..c377693cec 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,6 +5,7 @@ import { PieChart } from "./pie-chart"; import { DonutChart, DonutCenter } from "./donut-chart"; import { BarChart } from "./bar-chart"; import { LineChart } from "./line-chart"; +import { ComboChart } from "./combo-chart"; import "./style.scss"; @@ -15,5 +16,6 @@ export { DonutChart, DonutCenter, BarChart, - LineChart + LineChart, + ComboChart }; diff --git a/packages/core/src/line-chart.ts b/packages/core/src/line-chart.ts index 3e18fdb02b..5d3dafe661 100644 --- a/packages/core/src/line-chart.ts +++ b/packages/core/src/line-chart.ts @@ -19,13 +19,6 @@ export class LineChart extends BaseAxisChart { super(holder, configs); this.options.type = "line"; - - const { line: margins } = Configuration.charts.margin; - // D3 line generator function - this.lineGenerator = line() - .x((d, i) => this.x(this.displayData.labels[i]) + margins.left) - .y((d: any) => this.y(d)) - .curve(getD3Curve(this.options.curve) || getD3Curve("curveLinear")); } getLegendType() { @@ -58,6 +51,12 @@ export class LineChart extends BaseAxisChart { this.innerWrap.attr("transform", `translate(${margins.left}, ${margins.top})`); + // D3 line generator function + this.lineGenerator = line() + .x((d, i) => this.x(this.displayData.labels[i]) + margins.left) + .y((d: any) => this.y(d)) + .curve(getD3Curve(this.options.curve) || getD3Curve("curveLinear")); + const gLines = this.innerWrap.selectAll("g.lines") .data(this.displayData.datasets) .enter() @@ -195,7 +194,7 @@ export class LineChart extends BaseAxisChart { this.updateXandYGrid(true); // Scale out the domains - this.setXScale(true); + this.setXScale(); this.setYScale(); // Set the x & y axis as well as their labels @@ -206,4 +205,24 @@ export class LineChart extends BaseAxisChart { super.resizeChart(); } + + addDataPointEventListener() { + const self = this; + + this.svg.selectAll("circle.dot") + .on("mouseover", function(d) { + select(this) + .attr("stroke", self.colorScale[d.datasetLabel](d.label)) + .attr("stroke-opacity", Configuration.lines.points.mouseover.strokeOpacity); + }) + .on("mouseout", function(d) { + select(this) + .attr("stroke", self.colorScale[d.datasetLabel](d.label)) + .attr("stroke-opacity", Configuration.lines.points.mouseout.strokeOpacity); + }) + .on("click", function(d) { + self.showTooltip(d, this); + self.reduceOpacity(this); + }); + } } diff --git a/packages/core/src/stacked-bar-chart.ts b/packages/core/src/stacked-bar-chart.ts index bc8afb25d9..432cd344a8 100644 --- a/packages/core/src/stacked-bar-chart.ts +++ b/packages/core/src/stacked-bar-chart.ts @@ -176,7 +176,7 @@ export class StackedBarChart extends BaseAxisChart { this.updateXandYGrid(true); // Scale out the domains - this.setXScale(true); + this.setXScale(); this.setYScale(); // Set the x & y axis as well as their labels @@ -207,4 +207,28 @@ export class StackedBarChart extends BaseAxisChart { .attr("stroke-width", Configuration.bars.default.strokeWidth) .attr("stroke-opacity", d => this.options.accessibility ? 1 : 0); } + + addDataPointEventListener() { + const self = this; + const { accessibility } = this.options; + + this.svg.selectAll("rect") + .on("mouseover", function(d) { + select(this) + .attr("stroke-width", Configuration.bars.mouseover.strokeWidth) + .attr("stroke", self.colorScale[d.datasetLabel](d.label)) + .attr("stroke-opacity", Configuration.bars.mouseover.strokeOpacity); + }) + .on("mouseout", function(d) { + const { strokeWidth, strokeWidthAccessible } = Configuration.bars.mouseout; + select(this) + .attr("stroke-width", accessibility ? strokeWidthAccessible : strokeWidth) + .attr("stroke", accessibility ? self.colorScale[d.datasetLabel](d.label) : "none") + .attr("stroke-opacity", Configuration.bars.mouseout.strokeOpacity); + }) + .on("click", function(d) { + self.showTooltip(d, this); + self.reduceOpacity(this); + }); + } }