billboard.js
Version:
Re-usable easy interface JavaScript chart library, based on D3 v4+
593 lines (476 loc) • 14.2 kB
text/typescript
/**
* Copyright (c) 2017 ~ present NAVER Corp.
* billboard.js project is licensed under the MIT license
* @ignore
*/
import {select as d3Select} from "d3-selection";
import type {d3Selection} from "../../../types/types";
import {isArray, isFunction, isNumber, isString, toArray} from "../../module/util";
import Helper from "./AxisRendererHelper";
export default class AxisRenderer {
private helper;
private config;
private params;
private g;
private generatedTicks: (Date | number)[];
constructor(params: any = {}) {
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 Helper(this);
}
/**
* Create axis element
* @param {d3.selection} g Axis selection
* @private
*/
create(g: d3Selection): void {
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);
// line/text enter and path update
const tickTransform = helper.getTickTransformSetter(isTopBottom ? "x" : "y");
const axisPx = tickTransform === helper.axisX ? "y" : "x";
const sign = /^(top|left)$/.test(orient) ? -1 : 1;
// tick text helpers
const rotate = params.tickTextRotate;
this.config.range = scale.rangeExtent ?
scale.rangeExtent() :
helper.scaleExtent((params.orgXScale || scale).range());
const {innerTickSize, tickLength, range} = config;
// // get the axis' tick position configuration
const id = params.id;
const tickTextPos = id && /^(x|y|y2)$/.test(id) ?
params.config[`axis_${id}_tick_text_position`] :
{x: 0, y: 0};
// tick visiblity
const prefix = id === "subX" ? `subchart_axis_x` : `axis_${id}`;
const axisShow = params.config[`${prefix}_show`];
const tickShow = {
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 = d3Select(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;
// update selection - data join
const path = g.selectAll(".domain").data([0]);
// enter + update selection
path.enter().append("path")
.attr("class", "domain")
// https://observablehq.com/@d3/d3-selection-2-0
.merge(path as d3Selection)
.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) {
// count of tick data in array
const ticks = config.tickValues || helper.generateTicks(scale1, isLeftRight);
// set generated ticks
ctx.generatedTicks = ticks;
// update selection
let tick: d3Selection = g.selectAll(".tick")
.data(ticks, scale1);
// enter selection
const tickEnter = tick
.enter()
.insert("g", ".domain")
.attr("class", "tick");
// MEMO: No exit transition. The reason is this transition affects max tick width calculation because old tick will be included in the ticks.
const tickExit = tick.exit().remove();
// enter + update selection
tick = tickEnter.merge(tick);
tickShow.tick && tickEnter.append("line");
tickShow.text && tickEnter.append("text");
const tickText = tick.select("text");
const sizeFor1Char = isFunction(evalTextSize) ?
evalTextSize.bind(ctx.params.owner.api)(tickText.node()) :
Helper.getSizeFor1Char(tickText, evalTextSize);
const counts: number[] = [];
let tspan: d3Selection = tickText
.selectAll("tspan")
.data((d, index) => {
const split = params.tickMultiline ?
splitTickText(d, scale1, ticks, isLeftRight, sizeFor1Char.w) :
(
isArray(helper.textFormatted(d)) ?
helper.textFormatted(d).concat() :
[helper.textFormatted(d)]
);
counts[index] = split.length;
return split.map(splitted => ({index, splitted}));
});
tspan.exit().remove();
tspan = tspan
.enter()
.append("tspan")
.merge(tspan)
.text(d => d.splitted);
// set <tspan>'s position
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: number | string = 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");
const textUpdate = tick.select("text");
tickEnter.select("line").attr(`${axisPx}2`, innerTickSize * sign);
tickEnter.select("text").attr(axisPx, tickLength * sign);
ctx.setTickLineTextPosition(lineUpdate, textUpdate);
// Append <title> for tooltip display
if (params.tickTitle) {
const title = textUpdate.select("title");
(title.empty() ? textUpdate.append("title") : title)
.text(index => params.tickTitle[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);
}
// when .flow(), it should follow flow's transition config
// otherwise make to use ChartInternals.$T()
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: number): (Date | number)[] {
const len = this.generatedTicks?.length - 1;
let res = this.generatedTicks;
if (len > count) {
const interval = Math.round((len / count) + 0.1);
res = this.generatedTicks
.map((v, i) => (i % interval === 0 ? v : null))
.filter(v => v !== null)
.splice(0, count) as (Date | number)[];
}
return res;
}
/**
* Get tick x/y coordinate
* @returns {{x: number, y: number}}
* @private
*/
getTickXY(): {x: number, y: number} {
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): number {
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
* @private
*/
setTickLineTextPosition(lineUpdate, textUpdate): void {
const tickPos = this.getTickXY();
const {innerTickSize, orient, tickLength, tickOffset} = this.config;
const rotate = this.params.tickTextRotate;
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;
switch (orient) {
case "bottom":
lineUpdate
.attr("x1", tickPos.x)
.attr("x2", tickPos.x)
.attr("y2", this.getTickSize.bind(this));
textUpdate
.attr("x", 0)
.attr("y", yForText(rotate))
.style("text-anchor", textAnchorForText(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", -innerTickSize);
textUpdate
.attr("x", 0)
.attr("y", -yForText(rotate) * 2)
.style("text-anchor", textAnchorForText(rotate))
.attr("transform", textTransform(rotate));
break;
case "left":
lineUpdate
.attr("x2", -innerTickSize)
.attr("y1", tickPos.y)
.attr("y2", tickPos.y);
textUpdate
.attr("x", -tickLength)
.attr("y", tickOffset)
.style("text-anchor", "end");
break;
case "right":
lineUpdate
.attr("x2", innerTickSize)
.attr("y2", 0);
textUpdate
.attr("x", tickLength)
.attr("y", 0)
.style("text-anchor", "start");
}
}
// this should be called only when category axis
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
);
}
// split given text by tick width size
// eslint-disable-next-line
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 text width gets over tick width, split by space index or current index
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?): AxisRenderer {
if (!arguments.length) {
return this.helper.scale;
}
this.helper.scale = x;
return this;
}
orient(x): AxisRenderer {
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): AxisRenderer {
const {config} = this;
if (!arguments.length) {
return config.tickFormat;
}
config.tickFormat = format;
return this;
}
tickCentered(isCentered: boolean): AxisRenderer {
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(): number {
return this.config.tickOffset;
}
/**
* Get tick interval count
* @private
* @param {number} size Total data size
* @returns {number}
*/
tickInterval(size: number): number {
const {outerTickSize, tickOffset, tickValues} = this.config;
let interval;
if (this.params.isCategory) {
interval = tickOffset * 2;
} else {
const scale = this.params.owner.scale.zoom ?? this.helper.scale;
const length = this.g.select("path.domain")
.node()
.getTotalLength() - outerTickSize * 2;
interval = length / (size || this.g.selectAll("line").size());
// get the interval by its values
const intervalByValue = tickValues ?
tickValues
.map((v, i, arr) => {
const next = i + 1;
return next < arr.length ? scale(arr[next]) - scale(v) : null;
}).filter(Boolean) :
[];
interval = Math.min(...intervalByValue, interval);
}
return interval === Infinity ? 0 : interval;
}
ticks(...args): AxisRenderer {
const {config} = this;
if (!args.length) {
return config.tickArguments;
}
config.tickArguments = toArray(args);
return this;
}
tickCulling(culling): AxisRenderer {
const {config} = this;
if (!arguments.length) {
return config.tickCulling;
}
config.tickCulling = culling;
return this;
}
tickValues(
x?: (number | Date | string)[] | Function
): AxisRenderer | (number | Date | string)[] {
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): AxisRenderer {
this.config.transition = t;
return this;
}
}