UNPKG

billboard.js

Version:

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

420 lines (419 loc) • 15.7 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 { isArray, isFunction, isNumber, isString } from "../../module/util/type-checks.js"; import { toArray } from "../../module/util/object.js"; import { $COMMON } from "../../config/classes.js"; import AxisRendererHelper from "./AxisRendererHelper.js"; import { select } from "d3-selection"; //#region src/ChartInternal/Axis/AxisRenderer.ts /** * Copyright (c) 2017 ~ present NAVER Corp. * billboard.js project is licensed under the MIT license * @ignore */ /** * Get the corresponding main axis id for subchart axis ids. * @param {string} id Axis id * @returns {string} Base axis id * @private */ function getBaseAxisId(id) { return id === "subX" ? "x" : id === "subY" ? "y" : id === "subY2" ? "y2" : id; } /** * Get config option prefix for an axis id. * @param {string} id Axis id * @returns {string} Option prefix * @private */ function getAxisOptionPrefix(id) { const type = getBaseAxisId(id); return /^sub/.test(id) ? `subchart_axis_${type}` : `axis_${type}`; } var AxisRenderer = class { helper; config; params; g; generatedTicks = []; canReuseTickTextOnResize(isLeftRight) { const { config, params } = this; const { config: chartConfig, id, owner } = params; const isX = /^(x|subX)$/.test(id); const type = getBaseAxisId(id); const customTickFormat = /^sub/.test(id) ? chartConfig[`subchart_axis_${type}_tick_format`] || chartConfig[`axis_${type}_tick_format`] : chartConfig[`axis_${type}_tick_format`]; const categoryAutoWrap = params.tickMultiline && params.isCategory && !isLeftRight && !(params.tickWidth > 0); return !!(owner.state.resizing && !owner.state.flowing && !isFunction(chartConfig.axis_evalTextSize) && !customTickFormat && !params.tickTitle && !(isX && chartConfig.axis_x_tick_autorotate) && !categoryAutoWrap && config.withoutTransition); } canReuseTickNodesOnResize(tickNodes, ticks, isLeftRight, tickShow) { if (tickShow.tick || !tickShow.text || !this.canReuseTickTextOnResize(isLeftRight) || tickNodes.size() !== ticks.length) return false; const nodes = tickNodes.nodes(); for (let i = 0; i < ticks.length; i++) { const current = nodes[i].__data__; const next = ticks[i]; if (current instanceof Date || next instanceof Date) { if (+current !== +next) return false; } else if (current !== next) return false; } return true; } constructor(params = {}) { const config = { innerTickSize: 6, outerTickSize: params.outerTick ? 6 : 0, orient: "bottom", range: [], tickArguments: null, tickCentered: null, tickCulling: true, tickFormat: null, tickLength: 9, tickOffset: 0, tickPadding: 3, tickValues: null, transition: null, noTransition: params.noTransition }; config.tickLength = Math.max(config.innerTickSize, 0) + config.tickPadding; this.config = config; this.params = params; this.helper = new AxisRendererHelper(this); } /** * Create axis element * @param {d3.selection} g Axis selection * @private */ create(g) { const ctx = this; const { config, helper, params } = ctx; const { scale } = helper; const { orient } = config; const splitTickText = this.splitTickText.bind(ctx); const isLeftRight = /^(left|right)$/.test(orient); const isTopBottom = /^(top|bottom)$/.test(orient); const tickTransform = helper.getTickTransformSetter(isTopBottom ? "x" : "y"); const axisPx = tickTransform === helper.axisX ? "y" : "x"; const sign = /^(top|left)$/.test(orient) ? -1 : 1; const rotate = params.tickTextRotate; this.config.range = scale.rangeExtent ? scale.rangeExtent() : helper.scaleExtent((params.orgXScale || scale).range()); const { innerTickSize, tickLength, range } = config; const id = params.id; const type = getBaseAxisId(id); const { isSubAxes } = params; const tickTextPos = !isSubAxes && type && /^(x|y|y2)$/.test(type) ? params.config[`axis_${type}_tick_text_position`] : { x: 0, y: 0 }; const prefix = getAxisOptionPrefix(id); const axisShow = params.config[`${prefix}_show`]; const tickShow = isSubAxes ? { tick: true, text: true } : { tick: axisShow ? params.config[`${prefix}_tick_show`] : false, text: axisShow ? params.config[`${prefix}_tick_text_show`] : false }; const evalTextSize = params.config.axis_evalTextSize; let $g; g.each(function() { const g = select(this); let scale0 = this.__chart__ || scale; let scale1 = helper.copyScale(); $g = g; this.__chart__ = scale1; config.tickOffset = params.isCategory ? (scale1(1) - scale1(0)) / 2 : 0; const path = g.selectAll(".domain").data([0]); path.enter().append("path").attr("class", "domain").merge(path).attr("d", () => { const outerTickSized = config.outerTickSize * sign; return isTopBottom ? `M${range[0]},${outerTickSized}V0H${range[1]}V${outerTickSized}` : `M${outerTickSized},${range[0]}H0V${range[1]}H${outerTickSized}`; }); if (tickShow.tick || tickShow.text) { const ticks = config.tickValues || helper.generateTicks(scale1, isLeftRight || params.config.axis_rotated); ctx.generatedTicks = ticks; let tick = g.selectAll(".tick"); if (ctx.canReuseTickNodesOnResize(tick, ticks, isLeftRight || params.config.axis_rotated, tickShow)) tickTransform(tick, scale1); else { tick = tick.data(ticks, scale1); const tickEnter = tick.enter().insert("g", ".domain").attr("class", "tick"); const tickExit = tick.exit().remove(); tick = tickEnter.merge(tick); tickShow.tick && tickEnter.append("line"); tickShow.text && tickEnter.append("text"); const hasTickChange = !tickEnter.empty() || !tickExit.empty(); const reuseTickText = tickShow.text && !hasTickChange && ctx.canReuseTickTextOnResize(isLeftRight || params.config.axis_rotated); let sizeFor1Char = { w: 0, h: 0 }; let textUpdate = tick.selectAll("text.__bb-empty"); if (tickShow.text && !reuseTickText) { const tickText = tick.select("text"); const counts = []; textUpdate = tickText; if (isFunction(evalTextSize)) { sizeFor1Char = evalTextSize.bind(ctx.params.owner.api)(tickText.node(), id); if (this.classList.contains($COMMON.dummy)) this.sizeFor1Char = sizeFor1Char; } if (!sizeFor1Char || sizeFor1Char.w === 0 || sizeFor1Char.h === 0) sizeFor1Char = ctx.helper.getSizeFor1Char(orient, tickText, !!evalTextSize); let tspan = tickText.selectAll("tspan").data((d, index) => { let split; if (params.tickMultiline) split = splitTickText(d, scale1, ticks, isLeftRight, sizeFor1Char.w); else { const formatted = helper.textFormatted(d); split = isArray(formatted) ? formatted.concat() : [formatted]; } counts[index] = split.length; return split.map((splitted) => ({ index, splitted })); }); tspan.exit().remove(); tspan = tspan.enter().append("tspan").merge(tspan).text((d) => d.splitted); tspan.attr("x", isTopBottom ? 0 : tickLength * sign).attr("dx", (() => { let dx = 0; if (/(top|bottom)/.test(orient) && rotate) dx = 8 * Math.sin(Math.PI * (rotate / 180)) * (orient === "top" ? -1 : 1); return dx + (tickTextPos.x || 0); })()).attr("dy", (d, i) => { const defValue = ".71em"; let dy = 0; if (orient !== "top") { dy = sizeFor1Char.h; if (i === 0) dy = isLeftRight ? -((counts[d.index] - 1) * (sizeFor1Char.h / 2) - 3) : tickTextPos.y === 0 ? defValue : 0; } return isNumber(dy) && tickTextPos.y ? dy + tickTextPos.y : dy || defValue; }); } const lineUpdate = tick.select("line"); tickEnter.select("line").attr(`${axisPx}2`, innerTickSize * sign); tickEnter.select("text").attr(axisPx, tickLength * sign); ctx.setTickLineTextPosition(lineUpdate, textUpdate, sizeFor1Char); if (params.tickTitle) { const title = textUpdate.select("title"); const tickTitle = params.tickTitle; (title.empty() ? textUpdate.append("title") : title).text((index) => tickTitle[Number(index)]); } if (scale1.bandwidth) { const x = scale1; const dx = x.bandwidth() / 2; scale0 = (d) => x(d) + dx; scale1 = scale0; } else if (scale0.bandwidth) scale0 = scale1; else tickTransform(tickExit, scale1); tick = params.owner.state.flowing ? helper.transitionise(tick) : params.owner.$T(tick); tickTransform(tickEnter, scale0); tickTransform(tick.style("opacity", null), scale1); } } }); this.g = $g; } /** * Get generated ticks * @param {number} count Count of ticks * @returns {Array} Generated ticks * @private */ getGeneratedTicks(count) { const len = this.generatedTicks?.length - 1; let res = this.generatedTicks; if (len > count) { const interval = Math.round(len / count + .1); res = this.generatedTicks.map((v, i) => i % interval === 0 ? v : null).filter((v) => v !== null).splice(0, count); } return res; } /** * Get tick x/y coordinate * @returns {{x: number, y: number}} * @private */ getTickXY() { const { config } = this; const pos = { x: 0, y: 0 }; if (this.params.isCategory) { pos.x = config.tickCentered ? 0 : config.tickOffset; pos.y = config.tickCentered ? config.tickOffset : 0; } return pos; } /** * Get tick size * @param {object} d data object * @returns {number} * @private */ getTickSize(d) { const { scale } = this.helper; const { config } = this; const { innerTickSize, range } = config; const tickPosition = scale(d) + (config.tickCentered ? 0 : config.tickOffset); return range[0] < tickPosition && tickPosition < range[1] ? innerTickSize : 0; } /** * Set tick's line & text position * @param {d3.selection} lineUpdate Line selection * @param {d3.selection} textUpdate Text selection * @param {object} sizeFor1Char Size for 1 char * @private */ setTickLineTextPosition(lineUpdate, textUpdate, sizeFor1Char) { const tickPos = this.getTickXY(); const { innerTickSize, orient, tickLength, tickOffset } = this.config; const axisId = this.params.id; const rotate = this.params.tickTextRotate; const baseHeight = 6; const charHeight = sizeFor1Char.h / 2 - baseHeight; const textAnchorForText = (r) => { const value = ["start", "end"]; orient === "top" && value.reverse(); return !r ? "middle" : value[r > 0 ? 0 : 1]; }; const textTransform = (r) => r ? `rotate(${r})` : null; const yForText = (r) => { const r2 = r / (orient === "bottom" ? 15 : 23); return r ? 11.5 - 2.5 * r2 * (r > 0 ? 1 : -1) : tickLength; }; const { config: { axis_rotated: isRotated, axis_x_tick_text_inner: inner } } = this.params.owner; const tickLineInner = this.params.config[`axis_${getBaseAxisId(axisId)}_tick_inner`]; switch (orient) { case "bottom": lineUpdate.attr("x1", tickPos.x).attr("x2", tickPos.x).attr("y2", (d) => this.getTickSize.bind(this)(d) * (tickLineInner ? -1 : 1)); textUpdate.attr("x", 0).attr("y", yForText(rotate)).style("text-anchor", (d, i, { length }) => { if (!isRotated && i === 0 && (inner === true || inner.first)) return "start"; else if (!isRotated && i === length - 1 && (inner === true || inner.last)) return "end"; return textAnchorForText(rotate); }).attr("transform", textTransform(rotate)); break; case "top": lineUpdate.attr("x2", 0).attr("y2", tickLineInner ? innerTickSize : -innerTickSize); textUpdate.attr("x", 0).attr("y", -(yForText(rotate) + charHeight + baseHeight)).style("text-anchor", textAnchorForText(rotate)).attr("transform", textTransform(rotate)); break; case "left": lineUpdate.attr("x2", tickLineInner ? innerTickSize : -innerTickSize).attr("y1", tickPos.y).attr("y2", tickPos.y); textUpdate.attr("x", -tickLength).attr("y", tickOffset + (isRotated ? charHeight / 4 : charHeight)).style("text-anchor", "end"); break; case "right": lineUpdate.attr("x2", tickLineInner ? -innerTickSize : innerTickSize).attr("y2", 0); textUpdate.attr("x", tickLength).attr("y", charHeight).style("text-anchor", "start"); } } splitTickText(d, scale, ticks, isLeftRight, charWidth) { const { params } = this; const tickText = this.helper.textFormatted(d); const splitted = isString(tickText) && tickText.indexOf("\n") > -1 ? tickText.split("\n") : []; if (splitted.length) return splitted; if (isArray(tickText)) return tickText; let tickWidth = params.tickWidth; if (!tickWidth || tickWidth <= 0) tickWidth = isLeftRight ? 95 : params.isCategory ? (params.isInverted ? scale(ticks[0]) - scale(ticks[1]) : scale(ticks[1]) - scale(ticks[0])) - 12 : 110; function split(splitted, text) { let subtext; let spaceIndex; let textWidth; for (let i = 1; i < text.length; i++) { if (text.charAt(i) === " ") spaceIndex = i; subtext = text.substr(0, i + 1); textWidth = charWidth * subtext.length; if (tickWidth < textWidth) return split(splitted.concat(text.substr(0, spaceIndex || i)), text.slice(spaceIndex ? spaceIndex + 1 : i)); } return splitted.concat(text); } return split(splitted, String(tickText)); } scale(x) { if (!arguments.length) return this.helper.scale; this.helper.scale = x; return this; } orient(x) { if (!arguments.length) return this.config.orient; this.config.orient = x in { top: 1, right: 1, bottom: 1, left: 1 } ? String(x) : "bottom"; return this; } tickFormat(format) { const { config } = this; if (!arguments.length) return config.tickFormat; config.tickFormat = format; return this; } tickCentered(isCentered) { const { config } = this; if (!arguments.length) return config.tickCentered; config.tickCentered = isCentered; return this; } /** * Return tick's offset value. * The value will be set for 'category' axis type. * @returns {number} * @private */ tickOffset() { return this.config.tickOffset; } /** * Get tick interval count * @private * @param {number} size Total data size * @returns {number} */ tickInterval(size) { const { outerTickSize, tickOffset, tickValues } = this.config; let interval; if (this.params.isCategory) { const scale = this.params.owner.scale.zoom ?? this.helper.scale; interval = tickOffset * 2 || Math.abs(scale(1) - scale(0)); } else { const scale = this.params.owner.scale.zoom ?? this.helper.scale; interval = (this.g ? this.g.select("path.domain").node().getTotalLength() - outerTickSize * 2 : Math.abs(scale.range()[1] - scale.range()[0])) / (size || this.g?.selectAll("line").size() || 1); if (tickValues) for (let i = 0; i < tickValues.length - 1; i++) { const intervalByValue = scale(tickValues[i + 1]) - scale(tickValues[i]); if (intervalByValue && intervalByValue < interval) interval = intervalByValue; } } return interval === Infinity ? 0 : interval; } ticks(...args) { const { config } = this; if (!args.length) return config.tickArguments; config.tickArguments = toArray(args); return this; } tickCulling(culling) { const { config } = this; if (!arguments.length) return config.tickCulling; config.tickCulling = culling; return this; } tickValues(x) { const { config } = this; if (isFunction(x)) config.tickValues = () => x(this.helper.scale.domain()); else { if (!arguments.length) return config.tickValues; config.tickValues = x; } return this; } setTransition(t) { this.config.transition = t; return this; } }; //#endregion export { AxisRenderer as default };