billboard.js
Version:
Re-usable easy interface JavaScript chart library, based on D3 v4+
741 lines (621 loc) • 20.3 kB
text/typescript
/**
* Copyright (c) 2017 ~ present NAVER Corp.
* billboard.js project is licensed under the MIT license
*/
import {brushSelection as d3BrushSelection, brushX as d3BrushX, brushY as d3BrushY} from "d3-brush";
import {select as d3Select} from "d3-selection";
import CLASS from "../../config/classes";
import {SUBCHART_BRUSH_HANDLE_PATH} from "../../config/const";
import {window} from "../../module/browser";
import {
brushEmpty,
capitalize,
emulateEvent,
getBoundingRect,
getPointer,
isArray
} from "../../module/util";
import {
getMainCoordFromSubchartCoord,
isContinuousGridFocusEnabled
} from "../internals/subchart.util";
const SUBCHART_TYPES = ["bar", "line", "bubble", "candlestick", "scatter"];
const FOCUS_GRID_STYLE_PROPS = [
"opacity",
"stroke",
"stroke-dasharray",
"stroke-dashoffset",
"stroke-linecap",
"stroke-linejoin",
"stroke-miterlimit",
"stroke-opacity",
"stroke-width"
];
/**
* Copy main focus grid computed styles to the subchart focus grid line.
* @param {object} $$ ChartInternal instance
* @param {object} line Subchart focus grid line selection
* @private
*/
function syncSubchartGridFocusStyle($$, line): void {
const source = $$.$el.grid?.main?.select(`line.${CLASS.xgridFocus}`).node();
if (!source || !window.getComputedStyle) {
return;
}
const style = window.getComputedStyle(source);
FOCUS_GRID_STYLE_PROPS.forEach(prop => {
const value = style.getPropertyValue(prop);
value && line.style(prop, value);
});
}
/**
* Convert an SVG attr value to finite number with fallback.
* @param {string|null} value Attribute value
* @param {number} fallback Fallback value
* @returns {number} Numeric value
* @private
*/
function getFiniteAttr(value: string | null, fallback: number): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
/**
* Show a single clipped-free x focus grid line spanning main chart and subchart.
* @param {object} $$ ChartInternal instance
* @returns {boolean} Whether continuous focus line was rendered
* @private
*/
function showContinuousSubchartGridFocus($$): boolean {
if (!isContinuousGridFocusEnabled($$)) {
return false;
}
const {config, state, $el} = $$;
const mainLine = $el.grid?.main?.select(
`line.${CLASS.xgridFocus}:not(.${CLASS.xgridFocusContinuous})`
);
const line = $el.main.select(`line.${CLASS.xgridFocusContinuous}`);
if (!mainLine?.node() || !line.node()) {
return false;
}
const x2 = state.margin2.left - state.margin.left + state.width2;
const y2 = state.margin2.top - state.margin.top + state.height2;
syncSubchartGridFocusStyle($$, line);
config.axis_rotated ?
line
.attr("x1", getFiniteAttr(mainLine.attr("x1"), 0))
.attr("x2", x2)
.attr("y1", getFiniteAttr(mainLine.attr("y1"), -10))
.attr("y2", getFiniteAttr(mainLine.attr("y2"), -10)) :
line
.attr("x1", getFiniteAttr(mainLine.attr("x1"), -10))
.attr("x2", getFiniteAttr(mainLine.attr("x2"), -10))
.attr("y1", getFiniteAttr(mainLine.attr("y1"), 0))
.attr("y2", y2);
line.style("visibility", null);
mainLine.style("visibility", "hidden");
return true;
}
/**
* Hide continuous subchart focus grid line.
* @param {object} $$ ChartInternal instance
* @private
*/
function hideContinuousSubchartGridFocus($$): void {
$$.$el.main
?.select(`line.${CLASS.xgridFocusContinuous}`)
.style("visibility", "hidden");
}
/**
* Get main chart pointer coordinates that correspond to a subchart pointer event.
* @param {object} $$ ChartInternal instance
* @param {Event} event Input event
* @param {SVGElement} context Subchart event rect
* @returns {Array|null} Main chart local coordinates
* @private
*/
function getMainPointFromSubchartEvent($$, event, context): [number, number] | null {
const {config, state} = $$;
const [x, y] = getPointer(event, context);
const mainCoord = getMainCoordFromSubchartCoord($$, config.axis_rotated ? y : x);
if (mainCoord === null) {
return null;
}
return config.axis_rotated ? [state.width / 2, mainCoord] : [mainCoord, state.height / 2];
}
/**
* Dispatch a subchart pointer event through the main chart event rect.
* @param {object} $$ ChartInternal instance
* @param {string} type Event type
* @param {Event} event Input event
* @param {SVGElement} context Subchart event rect
* @private
*/
function dispatchSubchartEvent($$, type: string, event, context): void {
const mainEventRect = $$.$el.eventRect?.node?.();
if (!mainEventRect) {
return;
}
const point = getMainPointFromSubchartEvent($$, event, context) || [0, 0];
const rect = getBoundingRect(mainEventRect, true);
const clientX = rect.left + point[0];
const clientY = rect.top + point[1];
const params = {
bubbles: true,
cancelable: true,
screenX: clientX,
screenY: clientY,
clientX,
clientY
};
/^touch/.test(type) ?
emulateEvent.touch(mainEventRect, type, params) :
emulateEvent.mouse(mainEventRect, type, params);
}
export default {
/**
* Whether subchart brush interaction is enabled.
* @returns {boolean}
* @private
*/
isSubchartBrushEnabled(): boolean {
const {config} = this;
return config.subchart_show && config.subchart_brush_enabled !== false;
},
/**
* Bind event rect handlers for subchart hover interactions.
* @param {d3Selection} eventRect Subchart event rect selection
* @private
*/
bindSubchartEventRect(eventRect): void {
const $$ = this;
const {config, state} = $$;
eventRect.on("mouseover mousemove mouseout touchstart touchmove touchend", null);
if (!config.interaction_enabled) {
return;
}
if (state.inputType === "mouse") {
eventRect
.on("mouseover mousemove", function(event) {
if ($$.isSubchartBrushEnabled()) {
return;
}
dispatchSubchartEvent($$, event.type, event, this);
})
.on("mouseout", function(event) {
if ($$.isSubchartBrushEnabled()) {
return;
}
dispatchSubchartEvent($$, "mouseout", event, this);
});
} else if (state.inputType === "touch") {
eventRect
.on("touchstart touchmove touchend", function(event) {
if ($$.isSubchartBrushEnabled() || event.touches?.length > 1) {
return;
}
dispatchSubchartEvent($$, event.type, event, this);
});
}
},
/**
* Initialize the brush.
* @private
*/
initBrush(): void {
const $$ = this;
const {config, scale, $el: {subchart}, state} = $$;
const isRotated = config.axis_rotated;
const height = config.subchart_size_height;
let lastDomain;
let lastSelection;
let timeout;
// set the brush
$$.brush = (
isRotated ? d3BrushY() : d3BrushX()
).handleSize(5);
// bind brush event
$$.brush.on("start brush end", event => {
const {selection, sourceEvent, target, type} = event;
if (type === "start") {
$$.state.inputType === "touch" && $$.hideTooltip();
lastSelection = sourceEvent ? selection : null;
// sourceEvent && (state.domain = null);
}
// if (type === "brush") {
if (/(start|brush)/.test(type)) {
// when brush selection updates happens on one edge, update only chainging edge and
// is only for adjustment of given domain range to be used to return current domain range.
type === "brush" && sourceEvent && state.domain &&
lastSelection?.forEach((v, i) => {
if (v !== selection[i]) {
state.domain[i] = scale.x.orgDomain()[i];
}
});
$$.redrawForBrush(type !== "start");
}
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 = [selection[i], height / 2];
return `translate(${isRotated ? pos.reverse() : 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;
let extent = $$.axis.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);
$$.withSubchartTypeContext(() => {
// Define g for chart types area
SUBCHART_TYPES.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)
.style("pointer-events", $$.isSubchartBrushEnabled() ? null : "none")
.call($$.brush);
config.subchart_showHandle && $$.addBrushHandle(brush);
subchart.eventRect = main.append("g")
.attr("clip-path", clipPath)
.attr("class", `${CLASS.eventRects} ${CLASS.eventRects}-subchart`)
.style("fill-opacity", "0")
.style("pointer-events", $$.isSubchartBrushEnabled() ? "none" : "all")
.append("rect")
.attr("class", `${CLASS.eventRect} ${CLASS.eventRect}-subchart`)
.attr("width", $$.state.width2)
.attr("height", $$.state.height2);
$$.bindSubchartEventRect(subchart.eventRect);
main.append("g")
.attr("clip-path", clipPath)
.attr("class", CLASS.xgridFocus)
.append("line")
.attr("class", CLASS.xgridFocus)
.style("visibility", "hidden");
// 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");
axis.subY = main.append("g")
.attr("class", CLASS.axisY)
.attr("transform", $$.getTranslate("subY"))
.style("visibility", config.subchart_axis_y_show ? visibility : "hidden");
axis.subY2 = main.append("g")
.attr("class", CLASS.axisY2)
.attr("transform", $$.getTranslate("subY2"))
.style("visibility", config.subchart_axis_y2_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";
const path = SUBCHART_BRUSH_HANDLE_PATH[isRotated ? "y" : "x"];
$$.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) ? "end" : "start"])
.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) {
$$.withSubchartTypeContext(() => {
SUBCHART_TYPES
.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);
$$.$el.subchart.eventRect
?.attr("width", state.width2)
.attr("height", state.height2)
.style("pointer-events", $$.isSubchartBrushEnabled() ? "none" : "all");
}
},
/**
* Update subchart y domains using subchart type options.
* @param {object} targets Targets to show
* @private
*/
updateSubchartYDomain(targets): void {
const $$ = this;
const {scale} = $$;
const targetsToShow = targets || $$.filterTargetsToShow($$.data.targets);
scale.subY?.domain($$.getYDomain(targetsToShow, "y"));
scale.subY2?.domain($$.getYDomain(targetsToShow, "y2"));
},
/**
* Redraw subchart.
* @private
* @param {boolean} withSubchart whether or not to show subchart
* @param {number} duration duration
*/
redrawSubchart(withSubchart: boolean, duration: number): void {
const $$ = this;
const {config, $el: {subchart: {main}}, state} = $$;
const withTransition = !!duration;
main.style("visibility", config.subchart_show ? null : "hidden");
main.select(`.${CLASS.brush}`)
.style("pointer-events", $$.isSubchartBrushEnabled() ? null : "none");
$$.$el.subchart.eventRect
?.style("pointer-events", $$.isSubchartBrushEnabled() ? "none" : "all");
$$.$el.axis.subY
?.style("visibility", config.subchart_axis_y_show ? null : "hidden");
$$.$el.axis.subY2
?.style("visibility", config.subchart_axis_y2_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();
$$.withSubchartTypeContext(() => {
const targetsToShow = state._targetsToShow ||
$$.filterTargetsToShow($$.data.targets);
$$.updateSubchartYDomain(targetsToShow);
const subchartShape = $$.getDrawShape();
Object.keys(subchartShape.type).forEach(v => {
const name = capitalize(v);
const drawFn = $$[`generateDraw${name}`](subchartShape.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} = subchartShape.pos;
const cy = $$.updateCircleY(true);
$$.updateCircle(true);
$$.redrawCircle(cx, cy, withTransition, undefined, true);
}
});
if (!state.rendered && initRange) {
state.domain = initRange;
$$.brush.move(
$$.brush.getSelection(),
initRange.map($$.scale.x)
);
}
}
}
},
/**
* Show a focus grid line on subchart following main chart focus.
* @param {Array} data Selected data
* @private
*/
showSubchartGridFocus(data): void {
const $$ = this;
const {config, state, $el: {subchart: {main}}} = $$;
if (
!main ||
!config.subchart_show ||
$$.isSubchartBrushEnabled() ||
config.grid_focus_show === false ||
config.subchart_grid_focus === false ||
!config.tooltip_show ||
config.axis_tooltip
) {
return;
}
const focusData = Array.isArray(data) ? data : [data];
const focus = focusData.find(d => d && $$.getBaseValue(d) != null);
if (!focus) {
$$.hideSubchartGridFocus();
return;
}
const pos = $$.scale.subX(focus.x);
if (!Number.isFinite(pos)) {
$$.hideSubchartGridFocus();
return;
}
const line = main.select(`g.${CLASS.xgridFocus} line.${CLASS.xgridFocus}`);
if (showContinuousSubchartGridFocus($$)) {
line.style("visibility", "hidden");
return;
}
syncSubchartGridFocusStyle($$, line);
config.axis_rotated ?
line
.attr("x1", 0)
.attr("x2", state.width2)
.attr("y1", pos)
.attr("y2", pos) :
line
.attr("x1", pos)
.attr("x2", pos)
.attr("y1", 0)
.attr("y2", state.height2);
line.style("visibility", null);
},
/**
* Hide subchart focus grid line.
* @private
*/
hideSubchartGridFocus(): void {
hideContinuousSubchartGridFocus(this);
this.$el.subchart.main
?.select(`g.${CLASS.xgridFocus} line.${CLASS.xgridFocus}`)
.style("visibility", "hidden");
},
/**
* Redraw the brush.
* @param {boolean} [callCallbck=true] Call 'onbrush' callback or not.
* @private
*/
redrawForBrush(callCallbck = true): void {
const $$ = this;
const {
config: {
subchart_onbrush: onBrush,
zoom_rescale: withY
},
scale,
state
} = $$;
$$.redraw({
withTransition: false,
withY,
withSubchart: false,
withUpdateXDomain: true,
withDimension: false
});
callCallbck && state.rendered &&
onBrush.bind($$.api)(state.domain ?? 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);
const subYAxis = transitions?.axisSubY ?
transitions.axisSubY :
$T(subchart.main.select(`.${CLASS.axisY}`), withTransition);
const subY2Axis = transitions?.axisSubY2 ?
transitions.axisSubY2 :
$T(subchart.main.select(`.${CLASS.axisY2}`), withTransition);
subchart.main.attr("transform", $$.getTranslate("context"));
subXAxis.attr("transform", $$.getTranslate("subX"));
subYAxis.attr("transform", $$.getTranslate("subY"));
subY2Axis.attr("transform", $$.getTranslate("subY2"));
}
};