UNPKG

billboard.js

Version:

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

465 lines (464 loc) • 17.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 { isBoolean, isFunction } from "../../module/util/type-checks.js"; import { getBoundingRect, getPointer, getScrollPosition } from "../../module/util/dom.js"; import { $COMMON, $EVENT, $SHAPE } from "../../config/classes.js"; //#region src/ChartInternal/interactions/eventrect.ts var eventrect_default = { /** * Initialize the area that detects the event. * Add a container for the zone that detects the event. * @private */ initEventRect() { this.$el.main.select(`.${$COMMON.chart}`).append("g").attr("class", $EVENT.eventRects).style("fill-opacity", "0"); }, /** * Redraws the area that detects the event. * @private */ redrawEventRect() { const $$ = this; const { config, state, $el } = $$; const isMultipleX = $$.isMultipleX(); const isInverted = config.axis_x_inverted; if ($el.eventRect) $$.updateEventRect($el.eventRect, true); else if ($$.data.targets.length) { const eventRectUpdate = $$.$el.main.select(`.${$EVENT.eventRects}`).style("cursor", config.zoom_enabled && config.zoom_type !== "drag" ? config.axis_rotated ? "ns-resize" : "ew-resize" : null).classed($EVENT.eventRectsMultiple, isMultipleX).classed($EVENT.eventRectsSingle, !isMultipleX).selectAll(`.${$EVENT.eventRect}`).data([0]).enter().append("rect"); $$.updateEventRect(eventRectUpdate); $$.updateEventType(eventRectUpdate); eventRectUpdate.call($$.getDraggableSelection()); $el.eventRect = eventRectUpdate; if ($$.state.inputType === "touch" && !$el.svg.on("touchstart.eventRect") && !$$.hasArcType()) $$.bindTouchOnEventRect(); state.rendered && $$.updateEventRect($el.eventRect, true); } if (!isMultipleX) { const xAxisTickValues = $$.getMaxDataCountTarget(); if (!config.data_xSort || isInverted) xAxisTickValues.sort((a, b) => isInverted ? b.x - a.x : a.x - b.x); $$.updateDataIndexByX(xAxisTickValues); $$.updateXs(xAxisTickValues); $$.updatePointClass?.(true); state.eventReceiver.data = xAxisTickValues; } $$.updateEventRectData(); }, bindTouchOnEventRect() { const $$ = this; const { config, state, $el: { eventRect, svg } } = $$; const selectRect = (context) => { if ($$.isMultipleX()) $$.selectRectForMultipleXs(context); else { const index = $$.getDataIndexFromEvent(state.event); $$.callOverOutForTouch(index); index === -1 ? $$.unselectRect() : $$.selectRectForSingle(context, index); } }; const unselectRect = () => { $$.unselectRect(); $$.callOverOutForTouch(); }; const preventDefault = config.interaction_inputType_touch.preventDefault; const isPrevented = isBoolean(preventDefault) && preventDefault || false; const preventThreshold = !isNaN(preventDefault) && preventDefault || null; let startPx; const passiveOption = !isPrevented && preventThreshold === null; const preventEvent = (event) => { const eventType = event.type; const currentXY = event.changedTouches[0][`client${config.axis_rotated ? "Y" : "X"}`]; if (eventType === "touchstart") { if (isPrevented) event.preventDefault(); else if (preventThreshold !== null) startPx = currentXY; } else if (eventType === "touchmove") { if (isPrevented || startPx === true || preventThreshold !== null && Math.abs(startPx - currentXY) >= preventThreshold) { startPx = true; event.preventDefault(); } } }; eventRect.on("touchstart", (event) => { state.event = event; $$.updateEventRect(); }, { passive: passiveOption }).on("touchstart.eventRect touchmove.eventRect", (event) => { state.event = event; if (!eventRect.empty() && eventRect.classed($EVENT.eventRect)) { if (state.dragging || state.flowing || $$.hasArcType() || event.touches.length > 1) return; preventEvent(event); selectRect(eventRect.node()); } else unselectRect(); }, { passive: passiveOption }).on("touchend.eventRect", (event) => { state.event = event; if (!eventRect.empty() && eventRect.classed($EVENT.eventRect)) { if ($$.hasArcType() || !$$.toggleShape || state.cancelClick) state.cancelClick && (state.cancelClick = false); } }, { passive: passiveOption }); svg.on("touchstart", (event) => { state.event = event; const { target } = event; if (target && target !== eventRect.node()) unselectRect(); }, { passive: passiveOption }); }, /** * Update event rect size * @param {d3Selection} eventRect Event <rect> element * @param {boolean} force Force to update * @private */ updateEventRect(eventRect, force = false) { const { state, $el } = this; const { eventReceiver, width, height, rendered, resizing } = state; const rectElement = eventRect || $el.eventRect; const updateClientRect = () => { if (eventReceiver) { const scrollPos = getScrollPosition($el.chart.node()); eventReceiver.rect = getBoundingRect(rectElement.node(), true).toJSON(); eventReceiver.rect.top += scrollPos.y; eventReceiver.rect.left += scrollPos.x; } }; if (!rendered || resizing || force) { rectElement.attr("x", 0).attr("y", 0).attr("width", width).attr("height", height); if (!rendered || force) rectElement.classed($EVENT.eventRect, true); } updateClientRect(); }, /** * Update event type (single or multiple x) * @param {d3Selection | boolean} target Target element or boolean to rebind event */ updateEventType(target) { const $$ = this; const isRebindCall = isBoolean(target); const eventRect = isRebindCall ? $$.$el.eventRect : target; const unbindEvent = isRebindCall ? target !== eventRect?.datum().multipleX : false; if (eventRect) { unbindEvent && eventRect?.on("mouseover mousemove mouseout click", null); $$.isMultipleX() ? $$.generateEventRectsForMultipleXs(eventRect) : $$.generateEventRectsForSingleX(eventRect); } }, /** * Updates the location and size of the eventRect. * @private */ updateEventRectData() { const $$ = this; const { config, scale, state } = $$; const xScale = scale.zoom || scale.x; const isRotated = config.axis_rotated; const isMultipleX = $$.isMultipleX(); const xDomain = xScale?.domain(); const fingerprint = xDomain ? `${xDomain[0]}_${xDomain[1]}_${state.width}_${state.height}_${$$.data.targets.length}_${state.dataGeneration}_${[...state.hiddenTargetIds].join(",")}` : null; if (fingerprint && fingerprint === state._eventRectFingerprint) return; state._eventRectFingerprint = fingerprint; let x; let y; let w; let h; $$.updateEventType(isMultipleX); if (isMultipleX) { x = 0; y = 0; w = state.width; h = state.height; } else { let rectW; let rectX; if ($$.axis.isCategorized()) { rectW = $$.getEventRectWidth(); rectX = (d) => xScale(d.x) - rectW / 2; } else { const getPrevNextX = ({ index }) => ({ prev: $$.getPrevX(index), next: $$.getNextX(index) }); rectW = (d) => { const x = getPrevNextX(d); const xDomain = xScale.domain(); let val; if (x.prev === null && x.next === null) val = isRotated ? state.height : state.width; else if (x.prev === null) val = (xScale(x.next) + xScale(d.x)) / 2; else if (x.next === null) val = xScale(xDomain[1]) - (xScale(x.prev) + xScale(d.x)) / 2; else val = Math.max(0, (xScale(x.next) - xScale(x.prev)) / 2); return val; }; rectX = (d) => { const x = getPrevNextX(d); let val; if (x.prev === null && x.next === null) val = 0; else if (x.prev === null) val = xScale(xScale.domain()[0]); else val = (xScale(d.x) + xScale(x.prev)) / 2; return val; }; } x = isRotated ? 0 : rectX; y = isRotated ? rectX : 0; w = isRotated ? state.width : rectW; h = isRotated ? rectW : state.height; } const { eventReceiver } = state; const call = (fn, v) => isFunction(fn) ? fn(v) : fn; eventReceiver.coords.splice(eventReceiver.data.length); eventReceiver.data.forEach((d, i) => { eventReceiver.coords[i] = { x: call(x, d), y: call(y, d), w: call(w, d), h: call(h, d) }; }); }, /** * Seletct rect for single x value * @param {d3Selection} context Event rect element * @param {number} index x Axis index * @private */ selectRectForSingle(context, index) { const $$ = this; const { config, state, $el: { main, circle } } = $$; const isSelectionEnabled = config.data_selection_enabled; const isSelectionGrouped = config.data_selection_grouped; const isSelectable = config.data_selection_isselectable; const isTooltipGrouped = config.tooltip_grouped; const selectedData = $$.getAllValuesOnIndex(index); if (isTooltipGrouped) { $$.showTooltip(selectedData, context); $$.showGridFocus?.(selectedData); $$.showSubchartGridFocus?.(selectedData); if (!isSelectionEnabled || isSelectionGrouped) return; } !circle && main.selectAll(`.${$COMMON.EXPANDED}:not(.${$SHAPE.shape}-${index})`).classed($COMMON.EXPANDED, false); const shapeAtIndex = main.selectAll(`.${$SHAPE.shape}-${index}`).classed($COMMON.EXPANDED, true).style("cursor", isSelectable ? "pointer" : null).filter(function(d) { return $$.isWithinShape(this, d); }); shapeAtIndex.call((selected) => { const d = selected.data(); if (isSelectionEnabled && (isSelectionGrouped || isSelectable?.bind($$.api)(d))) context.style.cursor = "pointer"; if (!isTooltipGrouped) { $$.showTooltip(d, context); $$.showGridFocus?.(d); $$.showSubchartGridFocus?.(d); $$.unexpandCircles?.(); selected.each((d) => $$.setExpand(index, d.id)); } }); if (!isTooltipGrouped && shapeAtIndex.empty()) { const mouse = getPointer(state.event, context); const closestData = selectedData.filter((d) => { if ($$.isTargetToShow(d.id)) return $$.dist(d, mouse) < $$.getPointSensitivity(d); return false; }); if (closestData.length > 0) { let closest = closestData[0]; let minDist = $$.dist(closest, mouse); for (let i = 1; i < closestData.length; i++) { const d = closestData[i]; const dist = $$.dist(d, mouse); if (dist < minDist) { minDist = dist; closest = d; } } $$.showTooltip([closest], context); $$.showGridFocus?.([closest]); $$.showSubchartGridFocus?.([closest]); $$.unexpandCircles?.(); $$.setExpand(index, closest.id, true); if (isSelectionEnabled && (isSelectionGrouped || isSelectable?.bind($$.api)(closest))) context.style.cursor = "pointer"; } else if (config.interaction_onout) { $$.hideGridFocus?.(); $$.hideSubchartGridFocus?.(); $$.hideTooltip(); !isSelectionGrouped && $$.setExpand(index); } } }, /** * Select rect for multiple x values * @param {d3Selection} context Event rect element * @param {boolean} [triggerEvent=true] Whether trigger event or not * @private */ selectRectForMultipleXs(context, triggerEvent = true) { const $$ = this; const { config, state } = $$; const targetsToShow = $$.getTargetsToShow(); if (state.dragging || $$.hasArcType(targetsToShow)) return; const mouse = getPointer(state.event, context); const closest = $$.findClosestFromTargets(targetsToShow, mouse); if (triggerEvent && state.mouseover && (!closest || closest.id !== state.mouseover.id)) { config.data_onout.call($$.api, state.mouseover); state.mouseover = void 0; } if (!closest) { $$.unselectRect(); return; } const selectedData = ($$.isBubbleType(closest) || $$.isScatterType(closest) || !config.tooltip_grouped ? [closest] : $$.filterByX(targetsToShow, closest.x)).map((d) => $$.addName(d)); $$.showTooltip(selectedData, context); $$.setExpand(closest.index, closest.id, true); $$.showGridFocus?.(selectedData); $$.showSubchartGridFocus?.(selectedData); const dist = $$.dist(closest, mouse); if ($$.isBarType(closest.id) || dist < $$.getPointSensitivity(closest)) { $$.$el.eventRect.style("cursor", "pointer"); if (triggerEvent && (!state.mouseover || state.mouseover.x !== closest.x || state.mouseover.id !== closest.id)) { config.data_onover.call($$.api, closest); state.mouseover = closest; } } }, /** * Unselect EventRect. * @private */ unselectRect() { const $$ = this; const { state, $el: { circle, tooltip } } = $$; state._lastTooltipMouse = null; if (state.isCanvasMode) { $$.clearCanvasFocus?.(); tooltip && $$.hideTooltip(); return; } $$.$el.eventRect?.style("cursor", null); $$.hideGridFocus?.(); $$.hideSubchartGridFocus?.(); if (tooltip) { $$.hideTooltip(); $$._handleLinkedCharts(false); } circle && !$$.isPointFocusOnly() && $$.unexpandCircles(); $$.expandBarTypeShapes(false); }, /** * Create eventRect for each data on the x-axis. * Register touch and drag events. * @param {object} eventRectEnter d3.select($EVENT.eventRects) object. * @returns {object} d3.select($EVENT.eventRects) object. * @private */ generateEventRectsForSingleX(eventRectEnter) { const $$ = this; const { config, state } = $$; const { eventReceiver } = state; const rect = eventRectEnter.style("cursor", config.data_selection_enabled && config.data_selection_grouped ? "pointer" : null).on("click", function(event) { state.event = event; const { currentIdx, data } = eventReceiver; const d = data[currentIdx === -1 ? $$.getDataIndexFromEvent(event) : currentIdx]; $$.clickHandlerForSingleX.bind(this)(d, $$); }).datum({ multipleX: false }); if (state.inputType === "mouse") { const getData = (event) => { const index = event ? $$.getDataIndexFromEvent(event) : eventReceiver.currentIdx; return index > -1 ? eventReceiver.data[index] : null; }; rect.on("mouseover", (event) => { state.event = event; $$.updateEventRect(); Object.values($$.$el.axisTooltip).forEach((v) => v?.style("display", null)); }).on("mousemove", function(event) { const d = getData(event); state.event = event; if (!d) return; let { index } = d; const stepType = config.line_step_type; if (config.line_step_tooltipMatch && $$.hasType("step") && /^step\-(before|after)$/.test(stepType)) { const scale = $$.scale.zoom || $$.scale.x; const xs = $$.axis.xs[index]; const inverted = scale.invert(getPointer(event, this)[0]); if (stepType === "step-after" && inverted < xs) index -= 1; else if (stepType === "step-before" && inverted > xs) index += 1; } $$.showAxisGridFocus?.(); const eventOnSameIdx = config.tooltip_grouped && index === eventReceiver.currentIdx; if (state.dragging || state.flowing || $$.hasArcType() || eventOnSameIdx) { if (config.tooltip_show && eventOnSameIdx) { const [mx, my] = getPointer(event, this); const last = state._lastTooltipMouse; if (!last || (mx - last[0]) ** 2 + (my - last[1]) ** 2 >= 9) { state._lastTooltipMouse = [mx, my]; $$.setTooltipPosition(); } } return; } if (index !== eventReceiver.currentIdx) { $$.setOverOut(false, eventReceiver.currentIdx); eventReceiver.currentIdx = index; } index === -1 ? $$.unselectRect() : $$.selectRectForSingle(this, index); $$.setOverOut(index !== -1, index); }).on("mouseout", (event) => { state.event = event; if (!$$.config || $$.hasArcType() || eventReceiver.currentIdx === -1 || !config.interaction_onout) return; $$.hideAxisGridFocus?.(); $$.unselectRect(); $$.setOverOut(false, eventReceiver.currentIdx); eventReceiver.currentIdx = -1; }); } return rect; }, clickHandlerForSingleX(d, ctx) { const $$ = ctx; const { config, state, $el: { main } } = $$; if (!d || $$.hasArcType() || state.cancelClick) { state.cancelClick && (state.cancelClick = false); return; } const { index } = d; main.selectAll(`.${$SHAPE.shape}-${index}`).each(function(d2) { if (config.data_selection_grouped || $$.isWithinShape(this, d2)) { $$.toggleShape?.(this, d2, index); config.data_onclick.bind($$.api)(d2, this); } }); }, /** * Create an eventRect, * Register touch and drag events. * @param {object} eventRectEnter d3.select($EVENT.eventRects) object. * @private */ generateEventRectsForMultipleXs(eventRectEnter) { const $$ = this; const { config, state } = $$; eventRectEnter.on("click", function(event) { state.event = event; $$.clickHandlerForMultipleXS.bind(this)($$); }).datum({ multipleX: true }); if (state.inputType === "mouse") eventRectEnter.on("mouseover mousemove", function(event) { state.event = event; $$.selectRectForMultipleXs(this); }).on("mouseout", (event) => { state.event = event; if (!$$.config || $$.hasArcType() || !config.interaction_onout) return; $$.unselectRect(); }); }, clickHandlerForMultipleXS(ctx) { const $$ = ctx; const { config, state } = $$; const targetsToShow = $$.getTargetsToShow(); if ($$.hasArcType(targetsToShow)) return; const mouse = getPointer(state.event, this); const closest = $$.findClosestFromTargets(targetsToShow, mouse); if (!closest) return; const sensitivity = $$.getPointSensitivity(closest); if ($$.isBarType(closest.id) || $$.dist(closest, mouse) < sensitivity) $$.$el.main.selectAll(`.${$SHAPE.shapes}${$$.getTargetSelectorSuffix(closest.id)}`).selectAll(`.${$SHAPE.shape}-${closest.index}`).each(function() { if (config.data_selection_grouped || $$.isWithinShape(this, closest)) { $$.toggleShape?.(this, closest, closest.index); config.data_onclick.bind($$.api)(closest, this); } }); } }; //#endregion export { eventrect_default as default };