billboard.js
Version:
Re-usable easy interface JavaScript chart library, based on D3 v4+
392 lines (323 loc) • 10.7 kB
text/typescript
/**
* Copyright (c) 2017 ~ present NAVER Corp.
* billboard.js project is licensed under the MIT license
*/
import {select as d3Select} 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, parseDate} from "../../module/util";
export default {
/**
* Initialize the brush.
* @private
*/
initBrush(): void {
const $$ = this;
const {config, scale, $el: {subchart}} = $$;
const isRotated = config.axis_rotated;
let lastDomain;
let timeout;
// set the brush
$$.brush = (
isRotated ? d3BrushY() : d3BrushX()
).handleSize(5);
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"];
};
// bind brush event
$$.brush.on("start brush end", event => {
const {selection, target, type} = event;
if (type === "start") {
$$.state.inputType === "touch" && $$.hideTooltip();
}
if (/(start|brush)/.test(type)) {
$$.redrawForBrush();
}
if (type === "end") {
lastDomain = scale.x.orgDomain();
}
// handle brush's handle position & visibility
if (target?.handle) {
if (selection === null) {
$$.brush.handle.attr("display", "none");
} else {
$$.brush.handle.attr("display", null)
.attr("transform", (d, i) => {
const pos = isRotated ?
[33, selection[i] - (i === 0 ? 30 : 24)] : [selection[i], 3];
return `translate(${pos})`;
});
}
}
});
$$.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?.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 ? null : "hidden";
const clipId = `${clip.id}-subchart`;
const clipPath = $$.getClipPath(clipId);
clip.idSubchart = clipId;
$$.appendClip(defs, clipId);
$$.initBrush();
subchart.main = svg.append("g")
.classed(CLASS.subchart, true)
.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 chart types area
["bar", "line", "bubble", "candlestick", "scatter"].forEach(v => {
const type = capitalize(/^(bubble|scatter)$/.test(v) ? "circle" : v);
if ($$.hasType(v) || $$.hasTypeOf(type)) {
const chart = main.select(`.${CLASS.chart}`);
const chartClassName = CLASS[`chart${type}s`];
if (chart.select(`.${chartClassName}`).empty()) {
chart
.append("g")
.attr("class", chartClassName);
}
}
});
// Add extent rect for Brush
const brush = main.append("g")
.attr("clip-path", clipPath)
.attr("class", CLASS.brush)
.call($$.brush);
config.subchart_showHandle && $$.addBrushHandle(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");
},
/**
* Add brush handle
* Enabled when: subchart.showHandle=true
* @param {d3Selection} brush Brush selection
* @private
*/
addBrushHandle(brush): void {
const $$ = this;
const {config} = $$;
const isRotated = config.axis_rotated;
const initRange = config.subchart_init_range;
const customHandleClass = "handle--custom";
// brush handle shape's path
const path = isRotated ? [
"M 5.2491724,29.749209 a 6,6 0 0 0 -5.50000003,-6.5 H -5.7508276 a 6,6 0 0 0 -6.0000004,6.5 z m -5.00000003,-2 H -6.7508276 m 6.99999997,-2 H -6.7508276Z",
"M 5.2491724,23.249172 a 6,-6 0 0 1 -5.50000003,6.5 H -5.7508276 a 6,-6 0 0 1 -6.0000004,-6.5 z m -5.00000003,2 H -6.7508276 m 6.99999997,2 H -6.7508276Z"
] : [
"M 0 18 A 6 6 0 0 0 -6.5 23.5 V 29 A 6 6 0 0 0 0 35 Z M -2 23 V 30 M -4 23 V 30Z",
"M 0 18 A 6 6 0 0 1 6.5 23.5 V 29 A 6 6 0 0 1 0 35 Z M 2 23 V 30 M 4 23 V 30Z"
];
$$.brush.handle = brush.selectAll(`.${customHandleClass}`)
.data(isRotated ?
[{type: "n"}, {type: "s"}] :
[{type: "w"}, {type: "e"}]
)
.enter()
.append("path")
.attr("class", customHandleClass)
.attr("cursor", `${isRotated ? "ns" : "ew"}-resize`)
.attr("d", d => path[+/[se]/.test(d.type)])
.attr("display", initRange ? null : "none");
},
/**
* Update sub chart
* @param {object} targets $$.data.targets
* @private
*/
updateTargetsForSubchart(targets): void {
const $$ = this;
const {config, state, $el: {subchart: {main}}} = $$;
if (config.subchart_show) {
["bar", "line", "bubble", "candlestick", "scatter"]
.filter(v => $$.hasType(v) || $$.hasTypeOf(capitalize(v)))
.forEach(v => {
const isPointType = /^(bubble|scatter)$/.test(v);
const name = capitalize(isPointType ? "circle" : v);
const chartClass = $$.getChartClass(name, true);
const shapeClass = $$.getClass(isPointType ? "circles" : `${v}s`, true);
const shapeChart = main.select(`.${CLASS[`chart${`${name}s`}`]}`);
if (isPointType) {
const circle = shapeChart
.selectAll(`.${CLASS.circles}`)
.data(targets.filter($$[`is${capitalize(v)}Type`].bind($$)))
.attr("class", shapeClass);
circle.exit().remove();
circle.enter().append("g")
.attr("class", shapeClass);
} else {
const shapeUpdate = shapeChart
.selectAll(`.${CLASS[`chart${name}`]}`)
.attr("class", chartClass)
.data(targets.filter($$[`is${name}Type`].bind($$)));
const shapeEnter = shapeUpdate.enter()
.append("g")
.style("opacity", "0")
.attr("class", chartClass)
.append("g")
.attr("class", shapeClass);
shapeUpdate.exit().remove();
// Area
v === "line" && $$.hasTypeOf("Area") &&
shapeEnter.append("g").attr("class", $$.getClass("areas", true));
}
});
// -- Brush --//
main.selectAll(`.${CLASS.brush} rect`)
.attr(config.axis_rotated ? "width" : "height", config.axis_rotated ? state.width2 : state.height2);
}
},
/**
* 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}}, state} = $$;
const withTransition = !!duration;
main.style("visibility", config.subchart_show ? null : "hidden");
// subchart
if (config.subchart_show) {
// reflect main chart to extent on subchart if zoomed
if (state.event?.type === "zoom") {
$$.brush.update();
}
// update subchart elements if needed
if (withSubchart) {
const initRange = config.subchart_init_range;
// extent rect
!brushEmpty($$) && $$.brush.update();
Object.keys(shape.type).forEach(v => {
const name = capitalize(v);
const drawFn = $$[`generateDraw${name}`](shape.indices[v], true);
// call shape's update & redraw method
$$[`update${name}`](withTransition, true);
$$[`redraw${name}`](drawFn, withTransition, true);
});
if ($$.hasType("bubble") || $$.hasType("scatter")) {
const {cx} = shape.pos;
const cy = $$.updateCircleY(true);
$$.updateCircle(true);
$$.redrawCircle(cx, cy, withTransition, undefined, true);
}
!state.rendered && initRange && $$.brush.move(
$$.brush.getSelection(),
initRange.map($$.scale.x)
);
}
}
},
/**
* 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 {$el: {subchart}, $T} = $$;
const subXAxis = transitions?.axisSubX ?
transitions.axisSubX :
$T(subchart.main.select(`.${CLASS.axisX}`), withTransition);
subchart.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;
}
};