billboard.js
Version:
Re-usable easy interface JavaScript chart library, based on D3 v4+
429 lines (350 loc) • 10.7 kB
text/typescript
/**
* Copyright (c) 2017 ~ present NAVER Corp.
* billboard.js project is licensed under the MIT license
*/
import {select as d3Select} from "d3-selection";
import {$AXIS, $COMMON, $LEVEL, $RADAR, $SHAPE, $TEXT} from "../../config/classes";
import {KEY} from "../../module/Cache";
import {
getBoundingRect,
getMinMax,
getPathBox,
getRange,
isDefined,
isEmpty,
isNumber,
isUndefined,
setTextValue,
toArray
} from "../../module/util";
/**
* Get the position value
* @param {boolean} isClockwise If the direction is clockwise
* @param {string} type Coordinate type 'x' or 'y'
* @param {number} edge Number of edge
* @param {number} pos The indexed position
* @param {number} range Range value
* @param {number} ratio Ratio value
* @returns {number}
* @private
*/
function getPosition(isClockwise: boolean, type: "x" | "y", edge: number, pos: number,
range: number, ratio: number): number {
const index = isClockwise && pos > 0 ? edge - pos : pos;
const r = 2 * Math.PI;
const func = type === "x" ? Math.sin : Math.cos;
return range * (1 - ratio * func(index * r / edge));
}
// cache key
const cacheKeyPoints = KEY.radarPoints;
const cacheKeyTextWidth = KEY.radarTextWidth;
export default {
initRadar(): void {
const $$ = this;
const {config, state: {current}, $el} = $$;
if ($$.hasType("radar")) {
$el.radar = $el.main.select(`.${$COMMON.chart}`).append("g")
.attr("class", $RADAR.chartRadars);
// level
$el.radar.levels = $el.radar.append("g")
.attr("class", $LEVEL.levels);
// axis
$el.radar.axes = $el.radar.append("g")
.attr("class", $AXIS.axis);
// shapes
$el.radar.shapes = $el.radar.append("g")
.attr("class", $SHAPE.shapes);
current.dataMax = config.radar_axis_max || $$.getMinMaxData().max[0].value;
if (config.radar_axis_text_show) {
config.interaction_enabled && $$.bindRadarEvent();
// it needs to calculate dimension at the initialization
$$.updateRadarLevel();
$$.updateRadarAxes();
}
}
},
getRadarSize(): [number, number] {
const $$ = this;
const {config, state: {arcWidth, arcHeight}} = $$;
const padding = config.axis_x_categories.length < 4 ? -20 : 10;
const size = (Math.min(arcWidth, arcHeight) - padding) / 2;
return [size, size];
},
updateTargetsForRadar(targets): void {
const $$ = this;
const {config} = $$;
if (isEmpty(config.axis_x_categories)) {
config.axis_x_categories = getRange(0, getMinMax("max", targets.map(v =>
v.values.length
)));
}
$$.generateRadarPoints();
},
getRadarPosition(type, index: number, range, ratio: number): number {
const $$ = this;
const {config} = $$;
const [width, height] = $$.getRadarSize();
const edge = config.axis_x_categories.length;
const isClockwise = config.radar_direction_clockwise;
const pos = toArray(type).map(v =>
getPosition(
isClockwise,
v,
edge,
index,
isDefined(range) ? range : (type === "x" ? width : height),
isNumber(ratio) ? ratio : config.radar_size_ratio
)
);
return pos.length === 1 ? pos[0] : pos;
},
/**
* Generate data points
* @private
*/
generateRadarPoints(): void {
const $$ = this;
const targets = $$.data.targets;
const [width, height] = $$.getRadarSize();
const points = $$.cache.get(cacheKeyPoints) || {};
const size = points._size;
// recalculate position only when the previous dimension has been changed
if (!size || (size.width !== width && size.height !== height)) {
targets.forEach(d => {
points[d.id] = d.values.map((v, i) => (
$$.getRadarPosition(["x", "y"], i, undefined, $$.getRatio("radar", v))
));
});
points._size = {width, height};
$$.cache.add(cacheKeyPoints, points);
}
},
redrawRadar(): void {
const $$ = this;
const {radar, main} = $$.$el;
const translate = $$.getTranslate("radar");
// Adjust radar, circles and texts' position
if (translate) {
radar.attr("transform", translate);
main.select(`.${$TEXT.chartTexts}`).attr("transform", translate);
$$.generateRadarPoints();
$$.updateRadarLevel();
$$.updateRadarAxes();
$$.updateRadarShape();
}
},
generateGetRadarPoints(): Function {
const points = this.cache.get(cacheKeyPoints);
return (d, i) => {
const point = points[d.id][i];
return [
point,
point,
point,
point
];
};
},
updateRadarLevel(): void {
const $$ = this;
const {config, state, $el: {radar}} = $$;
const [width, height] = $$.getRadarSize();
const depth = config.radar_level_depth;
const edge = config.axis_x_categories.length;
const showText = config.radar_level_text_show;
const radarLevels = radar.levels;
const levelData = getRange(0, depth);
const radius = config.radar_size_ratio * Math.min(width, height);
const levelRatio = levelData.map(l => radius * ((l + 1) / depth));
const levelTextFormat = (config.radar_level_text_format || function() {}).bind($$.api);
// Generate points
const points = levelData.map(v => {
const range = levelRatio[v];
const pos = getRange(0, edge).map(i =>
(
$$.getRadarPosition(["x", "y"], i, range, 1)
).join(",")
);
return pos.join(" ");
});
const level = radarLevels
.selectAll(`.${$LEVEL.level}`)
.data(levelData);
level.exit().remove();
const levelEnter = level.enter().append("g")
.attr("class", (d, i) => `${$LEVEL.level} ${$LEVEL.level}-${i}`);
levelEnter.append("polygon")
.style("visibility", config.radar_level_show ? null : "hidden");
if (showText) {
if (radarLevels.select("text").empty()) {
radarLevels
.append("text")
.attr("dx", "-.5em")
.attr("dy", "-.7em")
.style("text-anchor", "end")
.text(() => levelTextFormat(0));
}
levelEnter.append("text")
.attr("dx", "-.5em")
.style("text-anchor", "end")
.text(d =>
levelTextFormat(
state.current.dataMax / levelData.length * (d + 1)
)
);
}
levelEnter
.merge(level)
.attr("transform",
d => `translate(${width - levelRatio[d]}, ${height - levelRatio[d]})`)
.selectAll("polygon")
.attr("points", d => points[d]);
// update level text position
if (showText) {
radarLevels.selectAll("text")
.attr("x", d => (isUndefined(d) ? width : points[d].split(",")[0]))
.attr("y", d => (isUndefined(d) ? height : 0));
}
},
updateRadarAxes(): void {
const $$ = this;
const {config, $el: {radar}} = $$;
const [width, height] = $$.getRadarSize();
const categories = config.axis_x_categories;
let axis = radar.axes.selectAll("g")
.data(categories);
axis.exit().remove();
const axisEnter = axis.enter().append("g")
.attr("class", (d, i) => `${$AXIS.axis}-${i}`);
config.radar_axis_line_show && axisEnter.append("line");
config.radar_axis_text_show && axisEnter.append("text");
axis = axisEnter.merge(axis);
// axis line
if (config.radar_axis_line_show) {
axis.select("line")
.attr("x1", width)
.attr("y1", height)
.attr("x2", (d, i) => $$.getRadarPosition("x", i))
.attr("y2", (d, i) => $$.getRadarPosition("y", i));
}
// axis text
if (config.radar_axis_text_show) {
const {x = 0, y = 0} = config.radar_axis_text_position;
const textWidth = $$.cache.get(cacheKeyTextWidth) || 0;
axis.select("text")
.style("text-anchor", "middle")
.attr("dy", ".5em")
.call(selection => {
selection.each(function(d) {
setTextValue(d3Select(this), String(d), [-0.6, 1.2]);
});
})
.datum((d, i) => ({index: i}))
.attr("transform", function(d) {
if (isUndefined(this.width)) {
// cache evaluated axis text width
this.width = getBoundingRect(this, true).width / 2;
}
let posX = $$.getRadarPosition("x", d.index, undefined, 1);
let posY = Math.round($$.getRadarPosition("y", d.index, undefined, 1));
if (posX > width) {
posX += this.width + x;
} else if (Math.round(posX) < width) {
posX -= this.width + x;
}
if (posY > height) {
// update vertical centered edge axis text dy position
if (posY / 2 === height && this.firstChild.tagName === "tspan") {
this.firstChild.setAttribute("dy", "0em");
}
posY += y;
} else if (posY < height) {
posY -= y;
}
return `translate(${posX} ${posY})`;
});
if (!textWidth) {
const widths = [radar.axes, radar.levels].map(v => getPathBox(v.node()).width);
if (widths.every(v => v > 0)) {
$$.cache.add(cacheKeyTextWidth, widths[0] - widths[1]);
}
}
}
},
bindRadarEvent(): void {
const $$ = this;
const {config, state, $el: {radar, svg}} = $$;
const focusOnly = $$.isPointFocusOnly();
const {inputType, transiting} = state;
const isMouse = inputType === "mouse";
const hide = event => {
state.event = event;
if (!config.interaction_onout) {
return;
}
// const index = getIndex(event);
const index = $$.getDataIndexFromEvent(event);
const noIndex = isUndefined(index);
if (isMouse || noIndex) {
$$.hideTooltip();
focusOnly ? $$.hideCircleFocus() : $$.unexpandCircles();
if (isMouse) {
$$.setOverOut(false, index);
} else if (noIndex) {
$$.callOverOutForTouch();
}
}
};
radar.axes
.on(isMouse ? "mouseover " : "touchstart", event => {
if (transiting) { // skip while transiting
return;
}
state.event = event;
const index = $$.getDataIndexFromEvent(event);
$$.selectRectForSingle(svg.node(), index);
isMouse ? $$.setOverOut(true, index) : $$.callOverOutForTouch(index);
})
.on("mouseout", isMouse ? hide : null);
if (!isMouse) {
svg.on("touchstart", hide);
}
},
updateRadarShape(): void {
const $$ = this;
const targets = $$.data.targets.filter(d => $$.isRadarType(d));
const points = $$.cache.get(cacheKeyPoints);
const areas = $$.$el.radar.shapes
.selectAll("polygon")
.data($$.filterNullish(targets));
const areasEnter = areas.enter().append("g")
.attr("class", $$.getChartClass("Radar"));
$$.$T(areas.exit())
.remove();
areasEnter
.append("polygon")
.merge(areas)
.style("fill", $$.color)
.style("stroke", $$.color)
.attr("points", d => points[d.id].join(" "));
$$.updateTargetForCircle(targets, areasEnter);
},
/**
* Get data point x coordinate
* @param {object} d Data object
* @returns {number}
* @private
*/
radarCircleX(d): number {
return this.cache.get(cacheKeyPoints)[d.id][d.index][0];
},
/**
* Get data point y coordinate
* @param {object} d Data object
* @returns {number}
* @private
*/
radarCircleY(d): number {
return this.cache.get(cacheKeyPoints)[d.id][d.index][1];
}
};