UNPKG

billboard.js

Version:

Re-usable easy interface JavaScript chart library, based on D3 v4+

378 lines (313 loc) 8.95 kB
/** * Copyright (c) 2017 ~ present NAVER Corp. * billboard.js project is licensed under the MIT license */ import { mouse as d3Mouse, event as d3Event, select as d3Select } from "d3-selection"; import {drag as d3Drag} from "d3-drag"; import {zoom as d3Zoom} from "d3-zoom"; import {document} from "../../module/browser"; import CLASS from "../../config/classes"; import {callFn, diffDomain, getMinMax, isDefined, isFunction} from "../../module/util"; export default { /** * Initialize zoom. * @private */ initZoom(): void { const $$ = this; $$.scale.zoom = null; $$.generateZoom(); $$.initZoomBehaviour(); }, /** * Bind zoom event * @param {boolean} bind Weather bind or unbound * @private */ bindZoomEvent(bind = true): void { const $$ = this; const {config, $el: {main}} = $$; const zoomEnabled = config.zoom_enabled; $$.redrawEventRect(); const eventRects = main.select(`.${CLASS.eventRects}`); if (zoomEnabled && bind) { // Do not bind zoom event when subchart is shown !config.subchart_show && $$.bindZoomOnEventRect(eventRects, zoomEnabled.type); } else if (bind === false) { $$.api.unzoom(); eventRects .on(".zoom", null) .on(".drag", null); } }, /** * Generate zoom * @private */ generateZoom(): void { const $$ = this; const {config, org, scale} = $$; const zoom = d3Zoom().duration(0) .on("start", $$.onZoomStart.bind($$)) .on("zoom", $$.onZoom.bind($$)) .on("end", $$.onZoomEnd.bind($$)); // get zoom extent // @ts-ignore zoom.orgScaleExtent = (): [number, number] => { const extent = config.zoom_extent || [1, 10]; return [extent[0], Math.max($$.getMaxDataCount() / extent[1], extent[1])]; }; // @ts-ignore zoom.updateScaleExtent = function(): zoom { const ratio = diffDomain($$.scale.x.orgDomain()) / diffDomain($$.getZoomDomain()); const extent = this.orgScaleExtent(); this.scaleExtent([extent[0] * ratio, extent[1] * ratio]); return this; }; /** * Update scale according zoom transform value * @param {object} transform transform object * @private */ // @ts-ignore zoom.updateTransformScale = (transform: object): void => { // in case of resize, update range of orgXScale org.xScale && org.xScale.range(scale.x.range()); // rescale from the original scale const newScale = transform[ config.axis_rotated ? "rescaleY" : "rescaleX" ](org.xScale || scale.x); const domain = $$.trimXDomain(newScale.domain()); const rescale = config.zoom_rescale; newScale.domain(domain, org.xDomain); scale.zoom = $$.getCustomizedScale(newScale); $$.axis.x.scale(scale.zoom); if (rescale) { // copy current initial x scale in case of rescale option is used !org.xScale && (org.xScale = scale.x.copy()); scale.x.domain(domain); } }; $$.zoom = zoom; }, /** * 'start' event listener * @private */ onZoomStart(): void { const $$ = this; const event = d3Event.sourceEvent; if (!event) { return; } $$.zoom.startEvent = event; callFn($$.config.zoom_onzoomstart, $$.api, event); }, /** * 'zoom' event listener * @private */ onZoom(): void { const $$ = this; const {config, scale, org} = $$; const event = d3Event; const {sourceEvent} = event; if ( !config.zoom_enabled || !event.sourceEvent || $$.filterTargetsToShow($$.data.targets).length === 0 || (!scale.zoom && sourceEvent.type.indexOf("touch") > -1 && sourceEvent.touches.length === 1) ) { return; } const isMousemove = sourceEvent.type === "mousemove"; const isZoomOut = sourceEvent.wheelDelta < 0; const {transform} = event; if (!isMousemove && isZoomOut && scale.x.domain().every((v, i) => v !== org.xDomain[i])) { scale.x.domain(org.xDomain); } $$.zoom.updateTransformScale(transform); if ($$.axis.isCategorized() && scale.x.orgDomain()[0] === org.xDomain[0]) { scale.x.domain([org.xDomain[0] - 1e-10, scale.x.orgDomain()[1]]); } $$.redraw({ withTransition: false, withY: config.zoom_rescale, withSubchart: false, withEventRect: false, withDimension: false }); $$.state.cancelClick = isMousemove; callFn(config.zoom_onzoom, $$.api, scale.zoom.domain()); }, /** * 'end' event listener * @private */ onZoomEnd(): void { const $$ = this; const {config, scale} = $$; let {startEvent} = $$.zoom; let event = d3Event && d3Event.sourceEvent; if ((startEvent && startEvent.type.indexOf("touch") > -1)) { startEvent = startEvent.changedTouches[0]; event = event.changedTouches[0]; } // if click, do nothing. otherwise, click interaction will be canceled. if (!startEvent || (event && startEvent.clientX === event.clientX && startEvent.clientY === event.clientY) ) { return; } $$.redrawEventRect(); $$.updateZoom(); callFn(config.zoom_onzoomend, $$.api, scale[scale.zoom ? "zoom" : "subX"].domain()); }, /** * Get zoom domain * @returns {Array} zoom domain * @private */ getZoomDomain(): [number, number] { const $$ = this; const {config, org} = $$; let [min, max] = org.xDomain; if (isDefined(config.zoom_x_min)) { min = getMinMax("min", [min, config.zoom_x_min]); } if (isDefined(config.zoom_x_max)) { max = getMinMax("max", [max, config.zoom_x_max]); } return [min, max]; }, /** * Update zoom * @param {boolean} force Force unzoom * @private */ updateZoom(force: boolean): void { const $$ = this; const {subX, x, zoom} = $$.scale; if (zoom) { const zoomDomain = zoom.domain(); const xDomain = subX.domain(); const delta = 0.015; // arbitrary value const isfullyShown = (zoomDomain[0] <= xDomain[0] || (zoomDomain[0] - delta) <= xDomain[0]) && (xDomain[1] <= zoomDomain[1] || xDomain[1] <= (zoomDomain[1] - delta)); // check if the zoomed chart is fully shown, then reset scale when zoom is out as initial if (force || isfullyShown) { $$.axis.x.scale(subX); x.domain(subX.orgDomain()); $$.scale.zoom = null; } } }, /** * Attach zoom event on <rect> * @param {d3.selection} eventRects evemt <rect> element * @param {string} type zoom type * @private */ bindZoomOnEventRect(eventRects, type: "drag" | "wheel"): void { const $$ = this; const behaviour = type === "drag" ? $$.zoomBehaviour : $$.zoom; eventRects .call(behaviour) .on("dblclick.zoom", null); }, /** * Initialize the drag behaviour used for zooming. * @private */ initZoomBehaviour(): void { const $$ = this; const {config, state} = $$; const isRotated = config.axis_rotated; let start = 0; let end = 0; let zoomRect; const prop = { axis: isRotated ? "y" : "x", attr: isRotated ? "height" : "width", index: isRotated ? 1 : 0 }; $$.zoomBehaviour = d3Drag() .clickDistance(4) .on("start", function() { $$.setDragStatus(true); if (!zoomRect) { zoomRect = $$.$el.main.append("rect") .attr("clip-path", state.clip.path) .attr("class", CLASS.zoomBrush) .attr("width", isRotated ? state.width : 0) .attr("height", isRotated ? 0 : state.height); } // @ts-ignore start = d3Mouse(this)[prop.index]; end = start; zoomRect .attr(prop.axis, start) .attr(prop.attr, 0); $$.onZoomStart(); }) .on("drag", function() { // @ts-ignore end = d3Mouse(this)[prop.index]; zoomRect .attr(prop.axis, Math.min(start, end)) .attr(prop.attr, Math.abs(end - start)); }) .on("end", function() { const scale = $$.scale.zoom || $$.scale.x; $$.setDragStatus(false); zoomRect .attr(prop.axis, 0) .attr(prop.attr, 0); if (start > end) { [start, end] = [end, start]; } if (start < 0) { end += Math.abs(start); start = 0; } if (start !== end) { $$.api.zoom([start, end].map(v => scale.invert(v))); $$.onZoomEnd(); } else { if ($$.isMultipleX()) { $$.clickHandlerForMultipleXS.bind(this)($$); } else { const event = d3Event.sourceEvent || d3Event; const [x, y] = "clientX" in event ? [event.clientX, event.clientY] : [event.x, event.y]; const target = document.elementFromPoint(x, y); $$.clickHandlerForSingleX.bind(target)(d3Select(target).datum(), $$); } } }); }, setZoomResetButton(): void { const $$ = this; const {config} = $$; const resetButton = config.zoom_resetButton; if (resetButton && config.zoom_enabled.type === "drag") { if (!$$.zoom.resetBtn) { $$.zoom.resetBtn = $$.$el.chart.append("div") .classed(CLASS.button, true) .append("span") .on("click", function() { isFunction(resetButton.onclick) && resetButton.onclick.bind($$.api)(this); $$.api.unzoom(); }) .classed(CLASS.buttonZoomReset, true) .text(resetButton.text || "Reset Zoom"); } else { $$.zoom.resetBtn.style("display", null); } } } };