billboard.js
Version:
Re-usable easy interface JavaScript chart library, based on D3 v4+
455 lines (383 loc) • 11.7 kB
text/typescript
/**
* Copyright (c) 2017 ~ present NAVER Corp.
* billboard.js project is licensed under the MIT license
*/
import {
select as d3Select,
event as d3Event
} from "d3-selection";
import {
brushX as d3BrushX,
brushY as d3BrushY,
brushSelection as d3BrushSelection
} from "d3-brush";
import CLASS from "../../config/classes";
import {brushEmpty, capitalize, isArray, isFunction, getRandom, parseDate} from "../../module/util";
export default {
/**
* Initialize the brush.
* @private
*/
initBrush(): void {
const $$ = this;
const {config, scale, $el: {subchart}} = $$;
const isRotated = config.axis_rotated;
// set the brush
$$.brush = isRotated ? d3BrushY() : d3BrushX();
// set "brush" event
const brushHandler = () => {
$$.redrawForBrush();
};
const getBrushSize = () => {
const brush = $$.$el.svg.select(`.${CLASS.brush} .overlay`);
const brushSize = {width: 0, height: 0};
if (brush.size()) {
brushSize.width = +brush.attr("width");
brushSize.height = +brush.attr("height");
}
return brushSize[isRotated ? "width" : "height"];
};
let lastDomain;
let timeout;
$$.brush
.on("start", () => {
$$.state.inputType === "touch" && $$.hideTooltip();
brushHandler();
})
.on("brush", brushHandler)
.on("end", () => {
lastDomain = scale.x.orgDomain();
});
$$.brush.updateResize = function() {
timeout && clearTimeout(timeout);
timeout = setTimeout(() => {
const selection = this.getSelection();
lastDomain && d3BrushSelection(selection.node()) &&
this.move(selection, lastDomain.map(scale.subX.orgScale()));
}, 0);
};
$$.brush.update = function() {
const extent = this.extent()();
if (extent[1].filter(v => isNaN(v)).length === 0) {
subchart.main && subchart.main.select(`.${CLASS.brush}`).call(this);
}
return this;
};
// set the brush extent
$$.brush.scale = function(scale) {
const h = config.subchart_size_height || getBrushSize();
let extent = $$.getExtent();
if (!extent && scale.range) {
extent = [[0, 0], [scale.range()[1], h]];
} else if (isArray(extent)) {
extent = extent.map((v, i) => [v, i > 0 ? h : i]);
}
// [[x0, y0], [x1, y1]], where [x0, y0] is the top-left corner and [x1, y1] is the bottom-right corner
isRotated && extent[1].reverse();
this.extent(extent);
// when extent updates, brush selection also be re-applied
// https://github.com/d3/d3/issues/2918
this.update();
};
$$.brush.getSelection = () => (
// @ts-ignore
subchart.main ? subchart.main.select(`.${CLASS.brush}`) : d3Select([])
);
},
/**
* Initialize the subchart.
* @private
*/
initSubchart(): void {
const $$ = this;
const {config, state: {clip, hasAxis}, $el: {defs, svg, subchart, axis}} = $$;
if (!hasAxis) {
return;
}
const visibility = config.subchart_show ? "visible" : "hidden";
const clipId = `${clip.id}-subchart`;
const clipPath = $$.getClipPath(clipId);
clip.idSubchart = clipId;
$$.appendClip(defs, clipId);
$$.initBrush();
subchart.main = svg.append("g").attr("transform", $$.getTranslate("context"));
const {main} = subchart;
main.style("visibility", visibility);
// Define g for chart area
main.append("g")
.attr("clip-path", clipPath)
.attr("class", CLASS.chart);
// Define g for bar chart area
$$.hasType("bar") && main.select(`.${CLASS.chart}`)
.append("g")
.attr("class", CLASS.chartBars);
// Define g for line chart area
main.select(`.${CLASS.chart}`)
.append("g")
.attr("class", CLASS.chartLines);
// Add extent rect for Brush
main.append("g")
.attr("clip-path", clipPath)
.attr("class", CLASS.brush)
.call($$.brush);
// ATTENTION: This must be called AFTER chart added
// Add Axis
axis.subX = main.append("g")
.attr("class", CLASS.axisX)
.attr("transform", $$.getTranslate("subX"))
.attr("clip-path", config.axis_rotated ? "" : clip.pathXAxis)
.style("visibility", config.subchart_axis_x_show ? visibility : "hidden");
},
/**
* Update sub chart
* @param {object} targets $$.data.targets
* @private
*/
updateTargetsForSubchart(targets): void {
const $$ = this;
const {config, state, $el: {subchart: {main}}} = $$;
const classChartBar = $$.classChartBar.bind($$);
const classBars = $$.classBars.bind($$);
const classChartLine = $$.classChartLine.bind($$);
const classLines = $$.classLines.bind($$);
const classAreas = $$.classAreas.bind($$);
if (config.subchart_show) {
// -- Bar --//
const barUpdate = main.select(`.${CLASS.chartBars}`)
.selectAll(`.${CLASS.chartBar}`)
.data(targets)
.attr("class", classChartBar);
const barEnter = barUpdate.enter()
.append("g")
.style("opacity", "0")
.attr("class", classChartBar)
.merge(barUpdate);
// Bars for each data
barEnter.append("g")
.attr("class", classBars);
// -- Line --//
const lineUpdate = main.select(`.${CLASS.chartLines}`)
.selectAll(`.${CLASS.chartLine}`)
.data(targets)
.attr("class", classChartLine);
const lineEnter = lineUpdate.enter().append("g")
.style("opacity", "0")
.attr("class", classChartLine)
.merge(lineUpdate);
// Lines for each data
lineEnter.append("g")
.attr("class", classLines);
// Area
$$.hasType("area") && lineEnter.append("g")
.attr("class", classAreas);
// -- Brush --//
main.selectAll(`.${CLASS.brush} rect`)
.attr(config.axis_rotated ? "width" : "height", config.axis_rotated ? state.width2 : state.height2);
}
},
/**
* Update the bar of the sub chart
* @param {object} durationForExit Transition duration
* @private
*/
updateBarForSubchart(durationForExit): void {
const $$ = this;
const {$el: {subchart}} = $$;
subchart.bar = subchart.main.selectAll(`.${CLASS.bars}`).selectAll(`.${CLASS.bar}`)
.data($$.barData.bind($$));
subchart.bar
.exit()
.transition()
.duration(durationForExit)
.style("opacity", "0")
.remove();
subchart.bar = subchart.bar
.enter()
.append("path")
.attr("class", $$.classBar.bind($$))
.style("stroke", "none")
.style("fill", $$.color)
.merge(subchart.bar)
.style("opacity", $$.initialOpacity.bind($$));
},
/**
* Redraw the bar of the subchart
* @param {string} drawBarOnSub path in subchart line
* @param {boolean} withTransition whether or not to transition
* @param {number} duration transition duration
* @private
*/
redrawBarForSubchart(drawBarOnSub: string, withTransition: boolean, duration: number): void {
const {bar} = this.$el.subchart;
(withTransition ? bar.transition(getRandom()).duration(duration) : bar)
.attr("d", drawBarOnSub)
.style("opacity", "1");
},
/**
* Update the line of the sub chart
* @param {number} durationForExit Fade-out transition duration
* @private
*/
updateLineForSubchart(durationForExit): void {
const $$ = this;
const {$el: {subchart}} = $$;
subchart.line = subchart.main.selectAll(`.${CLASS.lines}`)
.selectAll(`.${CLASS.line}`)
.data($$.lineData.bind($$));
subchart.line
.exit()
.transition()
.duration(durationForExit)
.style("opacity", "0")
.remove();
subchart.line = subchart.line
.enter()
.append("path")
.attr("class", $$.classLine.bind($$))
.style("stroke", $$.color)
.merge(subchart.line)
.style("opacity", $$.initialOpacity.bind($$));
},
/**
* Redraw the line of the subchart
* @private
* @param {string} drawLineOnSub path in subchart line
* @param {boolean} withTransition whether or not to transition
* @param {number} duration transition duration
*/
redrawLineForSubchart(drawLineOnSub: string, withTransition: boolean, duration: number): void {
const {line} = this.$el.subchart;
(withTransition ? line.transition(getRandom()).duration(duration) : line)
.attr("d", drawLineOnSub)
.style("opacity", "1");
},
/**
* Update the area of the sub chart
* @param {number} durationForExit Fade-out transition duration
* @private
*/
updateAreaForSubchart(durationForExit): void {
const $$ = this;
const {$el: {subchart}} = $$;
subchart.area = subchart.main.selectAll(`.${CLASS.areas}`)
.selectAll(`.${CLASS.area}`)
.data($$.lineData.bind($$));
subchart.area
.exit()
.transition()
.duration(durationForExit)
.style("opacity", "0")
.remove();
subchart.area = subchart.area
.enter()
.append("path")
.attr("class", $$.classArea.bind($$))
.style("fill", $$.color)
.style("opacity", function() {
$$.state.orgAreaOpacity = d3Select(this).style("opacity");
return "0";
})
.merge(subchart.area)
.style("opacity", "0");
},
/**
* Redraw the area of the subchart
* @private
* @param {string} drawAreaOnSub path in subchart line
* @param {boolean} withTransition whether or not to transition
* @param {number} duration transition duration
*/
redrawAreaForSubchart(drawAreaOnSub: string, withTransition: boolean, duration: number): void {
const {area} = this.$el.subchart;
(withTransition ? area.transition(getRandom()).duration(duration) : area)
.attr("d", drawAreaOnSub)
.style("fill", this.color)
.style("opacity", this.state.orgAreaOpacity);
},
/**
* Redraw subchart.
* @private
* @param {boolean} withSubchart whether or not to show subchart
* @param {number} duration duration
* @param {object} shape Shape's info
*/
redrawSubchart(withSubchart: boolean, duration: number, shape): void {
const $$ = this;
const {config, $el: {subchart: {main}}} = $$;
main.style("visibility", config.subchart_show ? "visible" : "hidden");
// subchart
if (config.subchart_show) {
// reflect main chart to extent on subchart if zoomed
if (d3Event && d3Event.type === "zoom") {
$$.brush.update();
}
// update subchart elements if needed
if (withSubchart) {
// extent rect
!brushEmpty($$) && $$.brush.update();
Object.keys(shape.type).forEach(v => {
const name = capitalize(v);
const draw = $$[`generateDraw${name}`](shape.indices[v], true);
$$[`update${name}ForSubchart`](duration);
$$[`redraw${name}ForSubchart`](draw, duration, duration);
});
}
}
},
/**
* Redraw the brush.
* @private
*/
redrawForBrush() {
const $$ = this;
const {config: {subchart_onbrush: onBrush, zoom_rescale: withY}, scale} = $$;
$$.redraw({
withTransition: false,
withY,
withSubchart: false,
withUpdateXDomain: true,
withDimension: false
});
onBrush.bind($$.api)(scale.x.orgDomain());
},
/**
* Transform context
* @param {boolean} withTransition indicates transition is enabled
* @param {object} transitions The return value of the generateTransitions method of Axis.
* @private
*/
transformContext(withTransition, transitions): void {
const $$ = this;
const {main} = $$.$el.subchart;
let subXAxis;
if (transitions && transitions.axisSubX) {
subXAxis = transitions.axisSubX;
} else {
subXAxis = main.select(`.${CLASS.axisX}`);
if (withTransition) {
subXAxis = subXAxis.transition();
}
}
main.attr("transform", $$.getTranslate("context"));
subXAxis.attr("transform", $$.getTranslate("subX"));
},
/**
* Get extent value
* @returns {Array} default extent
* @private
*/
getExtent(): number[] {
const $$ = this;
const {config, scale} = $$;
let extent = config.axis_x_extent;
if (extent) {
if (isFunction(extent)) {
extent = extent.bind($$.api)($$.getXDomain($$.data.targets), scale.subX);
} else if ($$.axis.isTimeSeries() && extent.every(isNaN)) {
const fn = parseDate.bind($$);
extent = extent.map(v => scale.subX(fn(v)));
}
}
return extent;
}
};