UNPKG

billboard.js

Version:

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

517 lines (454 loc) 13.9 kB
/** * Copyright (c) 2017 ~ present NAVER Corp. * billboard.js project is licensed under the MIT license * @ignore */ import {pointer as d3Pointer} from "d3-selection"; import type {d3Selection} from "../../../types/types"; import {document, window} from "../browser"; import {mergeObj, toArray} from "./object"; import {isString} from "./type-checks"; // Hoisted to module level to avoid recompilation on every addCssRules() call const RE_CSS_BB = /\s?(bb-)/g; const RE_CSS_DOTS = /\.+/g; /** * Convert a CSS selector string to dot-notation class selector * @param {string} s Selector string * @returns {string} * @private */ function getCssSelector(s: string): string { return s.replace(RE_CSS_BB, ".$1").replace(RE_CSS_DOTS, "."); } // ==================================== // Internal Helper (Not Exported) // ==================================== /** * Get boundingClientRect or BBox with caching. * Internal helper for getBoundingRect() and getBBox() * @param {boolean} relativeViewport Relative to viewport - true: will use .getBoundingClientRect(), false: will use .getBBox() * @param {SVGElement} node Target element * @param {boolean} forceEval Force evaluation * @returns {object} * @private */ function _getRect( relativeViewport: boolean, node: SVGElement & Partial<{rectClient: DOMRect, rectBBox: SVGRect}>, forceEval = false ): DOMRect | SVGRect { const _ = n => n[relativeViewport ? "getBoundingClientRect" : "getBBox"](); // cache per API: getBoundingClientRect(viewport coords) and getBBox(local coords) // return different values for the same node and must not share one slot const cacheKey = relativeViewport ? "rectClient" : "rectBBox"; if (forceEval) { return _(node); } else { // will cache the value if the element is not a SVGElement or the width is not set const needEvaluate = !(cacheKey in node) || ( node.hasAttribute("width") && node[cacheKey]!.width !== +(node.getAttribute("width") || 0) ); return needEvaluate ? (node[cacheKey] = _(node)) : node[cacheKey]!; } } // ==================================== // Exported // ==================================== /** * Set text value. If there're multiline add nodes. * @param {d3Selection} node Text node * @param {string} text Text value string * @param {Array} dy dy value for multilined text * @param {boolean} toMiddle To be alingned vertically middle * @private */ function setTextValue( node: d3Selection, text: string, dy: number[] = [-1, 1], toMiddle: boolean = false ) { if (!node || !isString(text)) { return; } if (text.indexOf("\n") === -1) { node.text(text); } else { const diff = [node.text(), text].map(v => v.replace(/[\s\n]/g, "")); if (diff[0] !== diff[1]) { const multiline = text.split("\n"); const len = toMiddle ? multiline.length - 1 : 1; // reset possible text node.html(""); multiline.forEach((v, i) => { node.append("tspan") .attr("x", 0) .attr("dy", `${i === 0 ? dy[0] * len : dy[1]}em`) .text(v); }); } } } /** * Substitution of SVGPathSeg API polyfill * @param {SVGGraphicsElement} path Target svg element * @returns {Array} * @private */ function getRectSegList(path: SVGGraphicsElement): {x: number, y: number}[] { /* * seg1 ---------- seg2 * | | * | | * | | * seg0 ---------- seg3 */ const {x, y, width, height} = getBBox(path, true); return [ {x, y: y + height}, // seg0 {x, y}, // seg1 {x: x + width, y}, // seg2 {x: x + width, y: y + height} // seg3 ]; } /** * Get svg bounding path box dimension * @param {SVGGraphicsElement} path Target svg element * @returns {object} * @private */ function getPathBox( path: SVGGraphicsElement ): {x: number, y: number, width: number, height: number} { const {width, height} = getBoundingRect(path); const items = getRectSegList(path); const x = items[0].x; const y = Math.min(items[0].y, items[1].y); return { x, y, width, height }; } /** * Get event's current position coordinates * @param {object} event Event object * @param {SVGElement|HTMLElement} element Target element * @returns {Array} [x, y] Coordinates x, y array * @private */ function getPointer(event, element?: HTMLElement | SVGElement): number[] { const touches = event && (event.touches || (event.sourceEvent && event.sourceEvent.touches))?.[0]; let pointer = [0, 0]; try { pointer = d3Pointer(touches || event, element); } catch {} return pointer.map(v => (isNaN(v) ? 0 : v)); } /** * Get boundingClientRect. * @param {SVGElement} node Target element * @param {boolean} forceEval Force evaluation * @returns {object} * @private */ function getBoundingRect(node, forceEval = false) { return _getRect(true, node, forceEval); } /** * Get BBox. * @param {SVGElement} node Target element * @param {boolean} forceEval Force evaluation * @returns {object} * @private */ function getBBox(node, forceEval = false) { return _getRect(false, node, forceEval); } /** * Add CSS rules * @param {object} style Style object * @param {string} selector Selector string * @param {Array} prop Prps arrary * @returns {number} Newely added rule index * @private */ function addCssRules(style, selector: string, prop: string[]): number { const {rootSelector = "", sheet} = style; const rule = `${rootSelector} ${getCssSelector(selector)} {${prop.join(";")}}`; return sheet[sheet.insertRule ? "insertRule" : "addRule"]( rule, sheet.cssRules.length ); } /** * Get css rules for specified stylesheets * @param {Array} styleSheets The stylesheets to get the rules from * @returns {Array} * @private */ function getCssRules(styleSheets: any[]) { let rules = []; styleSheets.forEach(sheet => { try { if (sheet.cssRules && sheet.cssRules.length) { rules = rules.concat(toArray(sheet.cssRules)); } } catch (e) { window.console?.warn(`Error while reading rules from ${sheet.href}: ${String(e)}`); } }); return rules; } /** * Get current window and container scroll position * @param {HTMLElement} node Target element * @returns {object} window scroll position * @private */ function getScrollPosition(node: HTMLElement) { return { x: (window.pageXOffset ?? window.scrollX ?? 0) + (node.scrollLeft ?? 0), y: (window.pageYOffset ?? window.scrollY ?? 0) + (node.scrollTop ?? 0) }; } /** * Get translation string from screen <--> svg point * @param {SVGGraphicsElement} node graphics element * @param {number} x target x point * @param {number} y target y point * @param {boolean} inverse inverse flag * @returns {object} */ function getTransformCTM(node: SVGGraphicsElement, x = 0, y = 0, inverse = true): DOMPoint { const point = new DOMPoint(x, y); const screen = <DOMMatrix>node.getScreenCTM(); const res = point.matrixTransform( inverse ? screen?.inverse() : screen ); if (inverse === false) { const rect = getBoundingRect(node); res.x -= rect.x; res.y -= rect.y; } return res; } /** * Gets the SVGMatrix of an SVGGElement * @param {SVGElement} node Node element * @returns {SVGMatrix} matrix * @private */ function getTranslation(node) { const transform = node ? node.transform : null; const baseVal = transform && transform.baseVal; return baseVal && baseVal.numberOfItems ? baseVal.getItem(0).matrix : {a: 0, b: 0, c: 0, d: 0, e: 0, f: 0}; } /** * Get position value from element's attribute or transform * @param {SVGElement} element SVG element * @param {string} type Coordinate type ("x" or "y") * @returns {number} Position value * @private */ function getElementPos(element: SVGElement | undefined, type: "x" | "y"): number { const attr = element?.getAttribute?.(type); if (attr) { return parseFloat(attr); } const matrix = getTranslation(element); return type === "x" ? matrix.e : matrix.f; } /** * Check if svg element has viewBox attribute * @param {d3Selection} svg Target svg selection * @returns {boolean} */ function hasViewBox(svg: d3Selection): boolean { const attr = svg.attr("viewBox"); // a valid viewBox has 4 numeric tokens: "min-x min-y width height" return attr ? attr.trim().split(/[\s,]+/).length === 4 : false; } /** * Determine if given node has the specified style * @param {d3Selection|SVGElement} node Target node * @param {object} condition Conditional style props object * @param {boolean} all If true, all condition should be matched * @returns {boolean} */ function hasStyle(node, condition: Record<string, string>, all = false): boolean { const isD3Node = !!node.node; let has = false; for (const [key, value] of Object.entries(condition)) { has = isD3Node ? node.style(key) === value : node.style[key] === value; // any-mode: stop on first match / all-mode: stop on first mismatch if (all ? !has : has) { break; } } return has; } /** * Return if the current doc is visible or not * @returns {boolean} * @private */ function isTabVisible(): boolean { return document?.hidden === false || document?.visibilityState === "visible"; } /** * Get the current input type * @param {boolean} mouse Config value: interaction.inputType.mouse * @param {boolean} touch Config value: interaction.inputType.touch * @returns {string} "mouse" | "touch" | null * @private */ function convertInputType(mouse: boolean, touch: boolean): "mouse" | "touch" | null { const {DocumentTouch, matchMedia, navigator} = window; // https://developer.mozilla.org/en-US/docs/Web/CSS/@media/pointer#coarse const hasPointerCoarse = matchMedia?.("(pointer:coarse)").matches; let hasTouch = false; if (touch) { // Some Edge desktop return true: https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/20417074/ if (navigator && "maxTouchPoints" in navigator) { hasTouch = navigator.maxTouchPoints > 0; // Ref: https://github.com/Modernizr/Modernizr/blob/master/feature-detects/touchevents.js // On IE11 with IE9 emulation mode, ('ontouchstart' in window) is returning true } else if ( "ontouchmove" in window || (DocumentTouch && document instanceof DocumentTouch) ) { hasTouch = true; } else { // https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent#avoiding_user_agent_detection if (hasPointerCoarse) { hasTouch = true; } else { // Only as a last resort, fall back to user agent sniffing const UA = navigator.userAgent; hasTouch = /\b(BlackBerry|webOS|iPhone|IEMobile)\b/i.test(UA) || /\b(Android|Windows Phone|iPad|iPod)\b/i.test(UA); } } } // For non-touch device, media feature condition is: '(pointer:coarse) = false' and '(pointer:fine) = true' // https://github.com/naver/billboard.js/issues/3854#issuecomment-2404183158 const hasMouse = mouse && !hasPointerCoarse && matchMedia?.("(pointer:fine)").matches; // fallback to 'mouse' if no input type is detected. return (hasMouse && "mouse") || (hasTouch && "touch") || "mouse"; } /** * Schedule a RAF update to batch multiple redraw requests * Manages a RAF state object to intelligently batch rapid updates while ensuring * immediate execution for the first call (for test compatibility) * @param {object} rafState RAF state object with pendingRaf property * @param {number|null} rafState.pendingRaf ID of pending RAF or null * @param {function} callback Function to execute in RAF * @returns {void} * @private */ function scheduleRAFUpdate(rafState: {pendingRaf: number | null}, callback: () => void): void { // If there's already a pending RAF, we're in a rapid update scenario // Cancel it and schedule a new one to batch the updates if (rafState.pendingRaf !== null) { window.cancelAnimationFrame(rafState.pendingRaf); // Schedule new RAF rafState.pendingRaf = window.requestAnimationFrame(() => { rafState.pendingRaf = null; callback(); }); } else { // First call - execute immediately for test compatibility // But set pending RAF to detect rapid consecutive calls rafState.pendingRaf = window.requestAnimationFrame(() => { rafState.pendingRaf = null; }); callback(); } } // emulate event const emulateEvent = { mouse: (() => { const getParams = () => ({ bubbles: false, cancelable: false, screenX: 0, screenY: 0, clientX: 0, clientY: 0 }); try { // eslint-disable-next-line no-new new MouseEvent("t"); return (el: SVGElement | HTMLElement, eventType: string, params = getParams()) => { el.dispatchEvent(new MouseEvent(eventType, params)); }; } catch { // Polyfills DOM4 MouseEvent return (el: SVGElement | HTMLElement, eventType: string, params = getParams()) => { const mouseEvent = document.createEvent("MouseEvent"); // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/initMouseEvent mouseEvent.initMouseEvent( eventType, params.bubbles, params.cancelable, window, 0, // the event's mouse click count params.screenX, params.screenY, params.clientX, params.clientY, false, false, false, false, 0, null ); el.dispatchEvent(mouseEvent); }; } })(), touch: (el: SVGElement | HTMLElement, eventType: string, params: any) => { const touchObj = new Touch(mergeObj({ identifier: Date.now(), target: el, radiusX: 2.5, radiusY: 2.5, rotationAngle: 10, force: 0.5 }, params)); el.dispatchEvent(new TouchEvent(eventType, { cancelable: true, bubbles: true, shiftKey: true, touches: [touchObj], targetTouches: [], changedTouches: [touchObj] })); } }; export { addCssRules, convertInputType, emulateEvent, getBBox, getBoundingRect, getCssRules, getElementPos, getPathBox, getPointer, getRectSegList, getScrollPosition, getTransformCTM, getTranslation, hasStyle, hasViewBox, isTabVisible, scheduleRAFUpdate, setTextValue };