UNPKG

billboard.js

Version:

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

346 lines (345 loc) • 12.6 kB
/*! * 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 { window as win } from "../../module/browser.js"; import { diffDomain, isFunction } from "../../module/util/type-checks.js"; import { callFn } from "../../module/util/object.js"; import { getPointer, scheduleRAFUpdate } from "../../module/util/dom.js"; import { $COMMON, $ZOOM } from "../../config/classes.js"; import { drag } from "d3-drag"; import { zoom, zoomIdentity, zoomTransform } from "d3-zoom"; //#region src/ChartInternal/interactions/zoom.ts /** * Copyright (c) 2017 ~ present NAVER Corp. * billboard.js project is licensed under the MIT license */ /** * Get the element that owns zoom interaction state. * @param {object} $$ ChartInternal instance * @returns {object} d3 selection * @private */ function getZoomTarget($$) { return $$.state.isCanvasMode ? $$.$el.canvas : $$.$el.eventRect; } /** * Convert a canvas-space transform to plot-space transform. * @param {object} $$ ChartInternal instance * @param {object} transform d3 zoom transform * @returns {object} Plot-space transform * @private */ function toPlotZoomTransform($$, transform) { if (!$$.state.isCanvasMode) return transform; const { margin } = $$.state; return zoomIdentity.translate(transform.x + (transform.k - 1) * margin.left, transform.y + (transform.k - 1) * margin.top).scale(transform.k); } /** * Convert a plot-space transform to canvas-space transform. * @param {object} $$ ChartInternal instance * @param {object} transform d3 zoom transform * @returns {object} Canvas-space transform * @private */ function toCanvasZoomTransform($$, transform) { if (!$$.state.isCanvasMode) return transform; const { margin } = $$.state; return zoomIdentity.translate(transform.x - (transform.k - 1) * margin.left, transform.y - (transform.k - 1) * margin.top).scale(transform.k); } /** * Get plot-local pointer coordinates for zoom drag. * @param {object} $$ ChartInternal instance * @param {object} event Event object * @param {HTMLElement|SVGElement} element Event element * @returns {Array} Plot-local pointer * @private */ function getZoomDragPointer($$, event, element) { const pointer = getPointer(event, element); if ($$.state.isCanvasMode) { const { margin } = $$.state; pointer[0] -= margin.left; pointer[1] -= margin.top; } return pointer; } var zoom_default = { /** * Initialize zoom. * @private */ initZoom() { const $$ = this; $$.scale.zoom = null; $$.generateZoom(); $$.config.zoom_type === "drag" && $$.initZoomBehaviour(); }, /** * Bind zoom event * @param {boolean} bind Weather bind or unbound * @private */ bindZoomEvent(bind = true) { const $$ = this; const { config } = $$; if (config.zoom_enabled && bind) !config.subchart_show && $$.bindZoomOnEventRect(); else if (bind === false) { $$.api.unzoom(); $$.unbindZoomEvent(); } }, /** * Generate zoom * @private */ generateZoom() { const $$ = this; const { config, org, scale } = $$; const zoom$1 = zoom().duration(0).on("start", $$.onZoomStart.bind($$)).on("zoom", $$.onZoom.bind($$)).on("end", $$.onZoomEnd.bind($$)); zoom$1.orgScaleExtent = () => { const extent = config.zoom_extent || [1, 10]; return [extent[0], Math.max($$.getMaxDataCount() / extent[1], extent[1])]; }; zoom$1.updateScaleExtent = function() { 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 * @param {boolean} correctTransform if the d3 transform should be updated after rescaling * @private */ zoom$1.updateTransformScale = (transform, correctTransform) => { const isRotated = config.axis_rotated; const transformForScale = toPlotZoomTransform($$, transform); org.xScale?.range(scale.x.range()); const newScale = transformForScale[isRotated ? "rescaleY" : "rescaleX"](org.xScale || scale.x); if (newScale.domain().some((v) => /(Invalid Date|NaN)/.test(v.toString()))) return; const domain = $$.trimXDomain(newScale.domain()); const rescale = config.zoom_rescale; newScale.domain(domain, org.xDomain); if (correctTransform) { const t = newScale(scale.x.domain()[0]); const targetTransform = toCanvasZoomTransform($$, zoomIdentity.translate(isRotated ? transformForScale.x : t, isRotated ? t : transformForScale.y).scale(transform.k)); getZoomTarget($$)?.property("__zoom", targetTransform); } scale.zoom = $$.getCustomizedXScale(newScale); $$.axis.x.scale(scale.zoom); if (rescale) { !org.xScale && (org.xScale = scale.x.copy()); scale.x.domain(domain); } else if (org.xScale) { scale.x.domain(org.xScale.domain()); org.xScale = null; } }; /** * Get zoom domain * @returns {Array} zoom domain * @private */ zoom$1.getDomain = () => { const domain = scale[scale.zoom ? "zoom" : "subX"].domain(); if ($$.axis.isCategorized()) domain[1] -= 2; return domain; }; $$.zoom = zoom$1; }, /** * 'start' event listener * @param {object} event Event object * @private */ onZoomStart(event) { const $$ = this; const { sourceEvent } = event; if (sourceEvent) { $$.zoom.startEvent = sourceEvent; $$.state.zooming = true; callFn($$.config.zoom_onzoomstart, $$.api, event); } }, /** * 'zoom' event listener * @param {object} event Event object * @private */ onZoom(event) { const $$ = this; const { config, scale, state, org } = $$; const { sourceEvent } = event; const isUnZoom = event?.transform === zoomIdentity; if (!config.zoom_enabled || $$.filterTargetsToShow($$.data.targets).length === 0 || !scale.zoom && sourceEvent?.type.indexOf("touch") > -1 && sourceEvent?.touches.length === 1) return; if (event.sourceEvent) { state.zooming = true; state.domain = void 0; } 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, config.zoom_type === "wheel" && sourceEvent); const doTransition = config.transition_duration > 0 && !config.subchart_show && (state.dragging || isUnZoom || !event.sourceEvent); const useRAF = sourceEvent && isMousemove && config.zoom_type !== "wheel"; const executeRedraw = () => { if (!$$.config) return; $$.redraw({ withTransition: doTransition, withY: config.zoom_rescale, withSubchart: false, withEventRect: false, withDimension: false }); $$.state.cancelClick = isMousemove; !isUnZoom && callFn(config.zoom_onzoom, $$.api, $$.state.domain ?? $$.zoom.getDomain()); }; useRAF ? scheduleRAFUpdate($$.state, executeRedraw) : executeRedraw(); }, /** * 'end' event listener * @param {object} event Event object * @private */ onZoomEnd(event) { const $$ = this; const { config, state } = $$; const zoom = $$.zoom; if (!zoom) return; let { startEvent } = zoom; let e = event?.sourceEvent; const isUnZoom = event?.transform === zoomIdentity; if (startEvent?.type.indexOf("touch") > -1) { startEvent = startEvent.changedTouches[0]; e = e?.changedTouches?.[0]; } if (config.zoom_type === "drag" && e && startEvent.clientX === e.clientX && startEvent.clientY === e.clientY) return; state.zooming = false; !state.isCanvasMode && $$.redrawEventRect(); $$.updateZoom(); !isUnZoom && (e || state.dragging) && callFn(config.zoom_onzoomend, $$.api, $$.state.domain ?? $$.zoom.getDomain()); }, /** * Update zoom * @param {boolean} force Force unzoom * @private */ updateZoom(force) { const $$ = this; const { subX, x, zoom } = $$.scale; if (zoom) { const zoomDomain = zoom.domain(); const xDomain = subX.domain(); const delta = .015; const isfullyShown = $$.config.axis_x_inverted ? (zoomDomain[0] >= xDomain[0] || zoomDomain[0] + delta >= xDomain[0]) && (xDomain[1] >= zoomDomain[1] || xDomain[1] >= zoomDomain[1] + delta) : (zoomDomain[0] <= xDomain[0] || zoomDomain[0] - delta <= xDomain[0]) && (xDomain[1] <= zoomDomain[1] || xDomain[1] <= zoomDomain[1] - delta); if (force || isfullyShown) { $$.axis.x.scale(subX); x.domain(subX.orgDomain()); $$.scale.zoom = null; } } }, /** * Set zoom transform to event rect * @param {function} x x Axis scale function * @param {Array} domain Domain value to be set * @private */ updateCurrentZoomTransform(x, domain) { const $$ = this; const { config } = $$; const isRotated = config.axis_rotated; const translate = [-x(domain[0]), 0]; const transform = zoomIdentity.scale(x.range()[1] / (x(domain[1]) - x(domain[0]))).translate(...isRotated ? translate.reverse() : translate); getZoomTarget($$)?.call($$.zoom.transform, toCanvasZoomTransform($$, transform)); }, /** * Attach zoom event on <rect> * @private */ bindZoomOnEventRect() { const $$ = this; const { config, $el: { svg } } = $$; const target = getZoomTarget($$); const behaviour = config.zoom_type === "drag" ? $$.zoomBehaviour : $$.zoom; $$.state.isCanvasMode && behaviour?.touchable?.(() => config.interaction_inputType_touch !== false); if (!$$.state.isCanvasMode && win.GestureEvent && /^((?!chrome|android|mobile).)*safari/i.test(win.navigator?.userAgent)) svg.on("wheel", () => {}); target?.call(behaviour).on("dblclick.zoom", null); }, /** * Initialize the drag behaviour used for zooming. * @private */ initZoomBehaviour() { const $$ = this; const { config, state } = $$; const isRotated = config.axis_rotated; let start = 0; let end = 0; let zoomRect; let extent; const prop = { axis: isRotated ? "y" : "x", attr: isRotated ? "height" : "width", index: isRotated ? 1 : 0 }; const clampPointer = (v) => { const [lo, hi] = extent ?? [0, isRotated ? state.height : state.width]; return Math.min(Math.max(v, lo), hi); }; $$.zoomBehaviour = drag().touchable(() => !state.isCanvasMode || config.interaction_inputType_touch !== false).clickDistance(4).on("start", function(event) { extent = $$.scale.zoom ? null : $$.axis.getExtent(); state.event = event; $$.setDragStatus(true); $$.unselectRect(); if (!state.isCanvasMode && !zoomRect) zoomRect = $$.$el.main.append("rect").attr("clip-path", state.clip.path).attr("class", $ZOOM.zoomBrush).attr("width", isRotated ? state.width : 0).attr("height", isRotated ? 0 : state.height); zoomRect?.attr(isRotated ? "width" : "height", isRotated ? state.width : state.height); start = clampPointer(getZoomDragPointer($$, event, this)[prop.index]); end = start; state.isCanvasMode ? $$.renderCanvasZoomBrush?.(start, end) : zoomRect.attr(prop.axis, start).attr(prop.attr, 0); $$.onZoomStart(event); }).on("drag", function(event) { end = clampPointer(getZoomDragPointer($$, event, this)[prop.index]); state.isCanvasMode ? $$.renderCanvasZoomBrush?.(start, end) : zoomRect.attr(prop.axis, Math.min(start, end)).attr(prop.attr, Math.abs(end - start)); }).on("end", (event) => { const scale = $$.scale.zoom || $$.scale.x; state.event = event; end = clampPointer(end); state.isCanvasMode ? $$.clearCanvasZoomBrush?.() : 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))); $$.setDragStatus(false); }); }, setZoomResetButton() { const $$ = this; const { config, $el } = $$; const resetButton = config.zoom_resetButton; if (resetButton && config.zoom_type === "drag") { if (!$el.zoomResetBtn) $el.zoomResetBtn = $$.$el.chart.append("div").classed($COMMON.button, true).append("span").on("click", function() { isFunction(resetButton.onclick) && resetButton.onclick.bind($$.api)(this); $$.api.unzoom(); }).classed($ZOOM.buttonZoomReset, true).text(resetButton.text || "Reset Zoom"); else $el.zoomResetBtn.style("display", null); } }, getZoomTransform() { const $$ = this; const node = getZoomTarget($$)?.node(); return node ? toPlotZoomTransform($$, zoomTransform(node)) : { k: 1 }; } }; //#endregion export { zoom_default as default };