billboard.js
Version:
Re-usable easy interface JavaScript chart library, based on D3 v4+
438 lines (437 loc) • 18.8 kB
JavaScript
/*!
* Copyright (c) 2017 ~ present NAVER Corp.
* billboard.js project is licensed under the MIT license
*
* billboard.js, JavaScript chart library
* https://naver.github.io/billboard.js/
*
* @version 4.1.1
*/
import { SUBCHART_BRUSH_HANDLE_PATH } from "../../config/const.js";
import { window as win } from "../../module/browser.js";
import { isArray } from "../../module/util/type-checks.js";
import { capitalize } from "../../module/util/object.js";
import { emulateEvent, getBoundingRect, getPointer } from "../../module/util/dom.js";
import { brushEmpty } from "../../module/util/brush.js";
import classes_default from "../../config/classes.js";
import { getMainCoordFromSubchartCoord, isContinuousGridFocusEnabled } from "../internals/subchart.util.js";
import { select } from "d3-selection";
import { brushSelection, brushX, brushY } from "d3-brush";
//#region src/ChartInternal/interactions/subchart.ts
/**
* Copyright (c) 2017 ~ present NAVER Corp.
* billboard.js project is licensed under the MIT license
*/
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) {
const source = $$.$el.grid?.main?.select(`line.${classes_default.xgridFocus}`).node();
if (!source || !win.getComputedStyle) return;
const style = win.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, fallback) {
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($$) {
if (!isContinuousGridFocusEnabled($$)) return false;
const { config, state, $el } = $$;
const mainLine = $el.grid?.main?.select(`line.${classes_default.xgridFocus}:not(.${classes_default.xgridFocusContinuous})`);
const line = $el.main.select(`line.${classes_default.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($$) {
$$.$el.main?.select(`line.${classes_default.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) {
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, event, context) {
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);
}
var subchart_default = {
/**
* Whether subchart brush interaction is enabled.
* @returns {boolean}
* @private
*/
isSubchartBrushEnabled() {
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) {
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() {
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;
$$.brush = (isRotated ? brushY() : brushX()).handleSize(5);
$$.brush.on("start brush end", (event) => {
const { selection, sourceEvent, target, type } = event;
if (type === "start") {
$$.state.inputType === "touch" && $$.hideTooltip();
lastSelection = sourceEvent ? selection : null;
}
if (/(start|brush)/.test(type)) {
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();
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 && brushSelection(selection.node()) && this.move(selection, lastDomain.map(scale.subX.orgScale()));
}, 0);
};
$$.brush.update = function() {
if (this.extent()()[1].filter((v) => isNaN(v)).length === 0) subchart.main?.select(`.${classes_default.brush}`).call(this);
return this;
};
$$.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]);
isRotated && extent[1].reverse();
this.extent(extent);
this.update();
};
$$.brush.getSelection = () => subchart.main ? subchart.main.select(`.${classes_default.brush}`) : select([]);
},
/**
* Initialize the subchart.
* @private
*/
initSubchart() {
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(classes_default.subchart, true).attr("transform", $$.getTranslate("context"));
const { main } = subchart;
main.style("visibility", visibility);
main.append("g").attr("clip-path", clipPath).attr("class", classes_default.chart);
$$.withSubchartTypeContext(() => {
SUBCHART_TYPES.forEach((v) => {
const type = capitalize(/^(bubble|scatter)$/.test(v) ? "circle" : v);
if ($$.hasType(v) || $$.hasTypeOf(type)) {
const chart = main.select(`.${classes_default.chart}`);
const chartClassName = classes_default[`chart${type}s`];
if (chart.select(`.${chartClassName}`).empty()) chart.append("g").attr("class", chartClassName);
}
});
});
const brush = main.append("g").attr("clip-path", clipPath).attr("class", classes_default.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", `${classes_default.eventRects} ${classes_default.eventRects}-subchart`).style("fill-opacity", "0").style("pointer-events", $$.isSubchartBrushEnabled() ? "none" : "all").append("rect").attr("class", `${classes_default.eventRect} ${classes_default.eventRect}-subchart`).attr("width", $$.state.width2).attr("height", $$.state.height2);
$$.bindSubchartEventRect(subchart.eventRect);
main.append("g").attr("clip-path", clipPath).attr("class", classes_default.xgridFocus).append("line").attr("class", classes_default.xgridFocus).style("visibility", "hidden");
axis.subX = main.append("g").attr("class", classes_default.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", classes_default.axisY).attr("transform", $$.getTranslate("subY")).style("visibility", config.subchart_axis_y_show ? visibility : "hidden");
axis.subY2 = main.append("g").attr("class", classes_default.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) {
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) {
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(`.${classes_default[`chart${`${name}s`}`]}`);
if (isPointType) {
const circle = shapeChart.selectAll(`.${classes_default.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(`.${classes_default[`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();
v === "line" && $$.hasTypeOf("Area") && shapeEnter.append("g").attr("class", $$.getClass("areas", true));
}
});
});
main.selectAll(`.${classes_default.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) {
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, duration) {
const $$ = this;
const { config, $el: { subchart: { main } }, state } = $$;
const withTransition = !!duration;
main.style("visibility", config.subchart_show ? null : "hidden");
main.select(`.${classes_default.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");
if (config.subchart_show) {
if (state.event?.type === "zoom") $$.brush.update();
if (withSubchart) {
const initRange = config.subchart_init_range;
!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);
$$[`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, void 0, 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) {
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 focus = (Array.isArray(data) ? data : [data]).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.${classes_default.xgridFocus} line.${classes_default.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() {
hideContinuousSubchartGridFocus(this);
this.$el.subchart.main?.select(`g.${classes_default.xgridFocus} line.${classes_default.xgridFocus}`).style("visibility", "hidden");
},
/**
* Redraw the brush.
* @param {boolean} [callCallbck=true] Call 'onbrush' callback or not.
* @private
*/
redrawForBrush(callCallbck = true) {
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) {
const $$ = this;
const { $el: { subchart }, $T } = $$;
const subXAxis = transitions?.axisSubX ? transitions.axisSubX : $T(subchart.main.select(`.${classes_default.axisX}`), withTransition);
const subYAxis = transitions?.axisSubY ? transitions.axisSubY : $T(subchart.main.select(`.${classes_default.axisY}`), withTransition);
const subY2Axis = transitions?.axisSubY2 ? transitions.axisSubY2 : $T(subchart.main.select(`.${classes_default.axisY2}`), withTransition);
subchart.main.attr("transform", $$.getTranslate("context"));
subXAxis.attr("transform", $$.getTranslate("subX"));
subYAxis.attr("transform", $$.getTranslate("subY"));
subY2Axis.attr("transform", $$.getTranslate("subY2"));
}
};
//#endregion
export { subchart_default as default };