monte
Version:
A visualization framework for D3.js and SVG. Ships with prebuilt charts and components.
666 lines (564 loc) • 22.9 kB
JavaScript
import { ENTER, EXIT, SYMBOL_SIZE, UPDATE } from '../../const/d3';
import { polarLabelCssPrefix, polarLabelOuter } from '../../util/polarLabels';
import { MonteOptionError } from '../../support/MonteOptionError';
import { PolarChart } from './PolarChart';
import { TAU } from '../../const/math';
import { arcLabelTween } from '../../util/tween';
import { commonEventNames } from '../../tools/commonEventNames';
import { gaugeLabelRotateTangentFlip } from '../../util/polarLabelRotations';
import { getPolarCoord } from '../../tools/polar';
import { isDefined } from '../../tools/is';
import { noop } from '../../tools/noop';
import { radiansToDegrees } from '../../tools/polar';
import { radiusContrain } from '../../util/dimension';
import { readTransforms } from '../../tools/transform';
import { removeClassByPattern } from '../../tools/css';
import { resetScaleDomain } from '../../tools/resetScaleDomain';
import { upperFirst } from '../../tools/string';
const LABEL_CSS_PATTERN = new RegExp(`^${polarLabelCssPrefix}*`);
const EVENT_UPDATING_LABELS = 'updatingLabels';
const EVENT_UPDATED_LABELS = 'updatedLabels';
const EVENTS = [ EVENT_UPDATING_LABELS, EVENT_UPDATED_LABELS ];
const LABEL = 'label';
const AREA = 'area';
const POINT = 'point';
const RAY = 'ray';
const WEB = 'web';
const WEB_MODE_LINE = 'line';
const WEB_MODE_ARC = 'arc';
export const WEB_MODES = [ WEB_MODE_LINE, WEB_MODE_ARC ];
const RADAR_CHART_DEFAULTS = {
chartCss: 'monte-radar-chart',
outerRadius: radiusContrain,
// Callback function to customize the area generator.
areaCustomize: null,
areaProp: 'value',
areaCssScale: noop,
areaCssScaleAccessor: PolarChart.generateScaleAccessor('areaCssScale', 'area'),
areaFillScale: noop,
areaFillScaleAccessor: PolarChart.generateScaleAccessor('areaFillScale', 'area'),
areaStrokeScale: noop,
areaStrokeScaleAccessor: PolarChart.generateScaleAccessor('areaStrokeScale', 'area'),
startAngle: 0,
endAngle: TAU,
radiusScale: d3.scaleLinear,
radiusDomainCustomize: null,
// Radius Labels
suppressRadiusLabels: false,
radiusLabelLayer: 'support',
radiusLabelRotation: gaugeLabelRotateTangentFlip,
radiusLabelAngle: 0,
radiusLabelFillScale: noop,
radiusLabelFillScaleAccessor: function(d) { return this.opts.radiusLabelFillScale(d); },
radiusLabel: (d) => d,
radiusLabelXAdjust: '',
radiusLabelYAdjust: '0.35em',
// Web
suppressWeb: false,
webArcCustomize: null,
webLevels: function() {
return this.radius.ticks();
},
webInnerLevels: [],
webMode: WEB_MODE_LINE,
// Ray Labels
suppressLabels: false,
labelPlacement: polarLabelOuter,
labelRotation: gaugeLabelRotateTangentFlip,
labelAngle: 0,
labelProp: 'value',
labelFillScale: noop,
labelFillScaleAccessor: PolarChart.generateScaleAccessor('labelFillScale', 'label'),
label: (d) => d.prop,
labelXAdjust: '',
labelYAdjust: '0.35em',
suppressPoints: false,
pointProp: '',
pointFillScale: noop,
pointFillScaleAccessor: PolarChart.generateScaleAccessor('pointFillScale', 'pointProp'),
pointStrokeScale: noop,
pointStrokeScaleAccessor: PolarChart.generateScaleAccessor('pointStrokeScale', 'pointProp'),
// Scale function for CSS class to apply per line. Input: line index, Output: String of CSS Class.
pointCssScale: noop,
pointCssScaleAccessor: PolarChart.generateScaleAccessor('pointCssScale', 'pointProp'),
// Static CSS class(es) to apply to every line.
pointCss: 'point',
pointSize: SYMBOL_SIZE,
pointSymbol: (symbol) => symbol.type(d3.symbolCircle),
areaValuesProp: 'values',
rayIdProp: 'id',
rayValueProp: 'value',
};
export class RadarChart extends PolarChart {
_initOptions(...options) {
super._initOptions(...options, RADAR_CHART_DEFAULTS);
}
_initCore() {
super._initCore();
this.radius = this.tryInvoke(this.opts.radiusScale);
this.areaRadialLine = d3.radialLine()
.radius((d) => this.radius(d.radius))
.angle((d) => d.angle)
.curve(d3.curveLinearClosed);
this.webArc = d3.arc()
.innerRadius((d) => this.radius(d.radius))
.outerRadius((d) => this.radius(d.radius));
}
_initCustomize() {
super._initCustomize();
if (this.opts.webArcCustomize) {
this.fnInvoke(this.opts.webArcCustomize, this.webArc);
}
if (this.opts.areaRadialLineCustomize) {
this.fnInvoke(this.opts.areaRadialLineCustomize, this.areaRadialLine);
}
}
_initPublicEvents(...events) {
super._initPublicEvents(...events,
...commonEventNames(AREA), // Area events
...commonEventNames(POINT), // POINT events
...EVENTS
);
}
_resetStyleDomains() {
super._resetStyleDomains();
resetScaleDomain(this.opts.areaCssScale);
resetScaleDomain(this.opts.areaFillScale);
resetScaleDomain(this.opts.areaStrokeScale);
resetScaleDomain(this.opts.labelFillScale);
resetScaleDomain(this.opts.radiusLabelFillScale);
resetScaleDomain(this.opts.pointFillScale);
resetScaleDomain(this.opts.pointStrokeScale);
resetScaleDomain(this.opts.pointCssScale);
}
_boundsUpdate() {
super._boundsUpdate();
const or = this.tryInvoke(this.opts.outerRadius, this.width, this.height);
this.radius.range([0, or]);
}
_data(data) {
super._data(data);
const propsMap = {};
this.displayData.forEach((area) => {
const values = this.getProp('areaValues', area);
// For each area look at the ids. Compile a list of all ids across all areas.
values.forEach((ray) => {
const rayId = this.getProp('rayId', ray);
if (!propsMap[rayId]) {
propsMap[rayId] = 1;
}
else {
propsMap[rayId]++;
}
});
});
const props = Object.keys(propsMap);
let currentMax = 0;
// Find max across all properties
props.forEach((prop) => {
data.forEach((d) => {
if (currentMax < d[prop]) {
currentMax = d[prop];
}
});
});
const domain = isDefined(this.opts.radiusDomainCustomize) ?
this.tryInvoke(this.opts.radiusDomainCustomize, [0, currentMax]) :
[0, currentMax];
this.radius.domain(domain);
const { rayAngleMap, rayData } = this.__rayData(props);
this.rayAngleMap = rayAngleMap;
this.rayData = rayData;
this.props = props;
}
__rayData(props) {
const startAngle = this.tryInvoke(this.opts.startAngle);
const endAngle = this.tryInvoke(this.opts.endAngle);
const rayCount = props.length;
const rayInterval = (endAngle - startAngle) / rayCount;
const rayAngleMap = {};
const rayData = [];
let activeAngle = startAngle;
for (let i = 0; i < rayCount; i++) {
rayAngleMap[props[i]] = activeAngle;
rayData.push({
prop: props[i],
angle: activeAngle,
});
activeAngle += rayInterval;
}
return { rayAngleMap, rayData };
}
_update() {
const rayGrps = this._updateRays();
const areaGrps = this._updateAreas();
this._updateWeb();
const suppressPoints = this.tryInvoke(this.opts.suppressPoints);
if (!suppressPoints) {
this._updatePoints(areaGrps);
}
if (!this.opts.suppressLabels) {
this._updateRayLabels(rayGrps);
}
if (!this.opts.suppressRadiusLabels) {
this._updateRadiusLabels(rayGrps);
}
}
_updateAreas() {
// Data join for the area
const areaGrps = this.draw.selectAll('.monte-radar-area-grp')
.data(this.displayData, this.opts.dataKey);
// Create new area and update existing
const enterAreas = areaGrps.enter().append('g').classed('monte-radar-area-grp', true)
.append('path')
.classed('monte-radar-area', true)
.call(this.__bindCommonEvents(AREA));
this._updateAreaSelections(enterAreas, ENTER);
this._updateAreaSelections(areaGrps.selectAll('.monte-radar-area'), UPDATE);
// Fade out removed area
areaGrps.exit()
.call((sel) => this.fnInvoke(this.opts.areaExitSelectionCustomize, sel))
.transition()
.call(this._transitionSetup(AREA, EXIT))
.style('opacity', 0)
.call((t) => this.fnInvoke(this.opts.areaExitTransitionCustomize, t))
.remove();
return areaGrps.merge(areaGrps.enter().selectAll('.monte-radar-area-grp'));
}
_updateAreaSelections(sel, stage) {
const selectionFnName = AREA + upperFirst(stage) + 'SelectionCustomize';
const transitionFnName = AREA + upperFirst(stage) + 'TransitionCustomize';
sel.attr('class', (d, i) => this._buildCss([
'monte-radar-area',
this.opts.areaCss,
this.opts.areaCssScaleAccessor,
d.css], d, i))
.call((sel) => this.fnInvoke(this.opts[selectionFnName], sel))
.transition()
.call(this._transitionSetup(AREA, stage))
.attr('d', (d) => {
const areaValues = this.getProp('areaValues', d);
const values = [];
this.props.forEach((p) => {
// Find the correct value element for a particular label
const idKey = this.getPropKey('rayId');
const valueKey = this.getPropKey('rayValue');
const radius = findBy(areaValues, idKey, p, valueKey);
values.push({
radius,
angle: this.rayAngleMap[p],
});
});
return this.areaRadialLine(values);
})
.style('fill', this.optionReaderFunc('areaFillScaleAccessor'))
.call((t) => this.fnInvoke(this.opts[transitionFnName], t));
}
_updateRays() {
const or = this.tryInvoke(this.opts.outerRadius, this.width, this.height);
const rayGrps = this.support.selectAll('.monte-radar-ray-grp').data(this.rayData);
rayGrps.enter().append('g').classed('monte-radar-ray-grp', true)
.append('line')
.classed('monte-radar-ray', true)
.attr('x0', 0)
.attr('y0', 0)
.attr('x1', (d) => getPolarCoord(or, d.angle)[0])
.attr('y1', (d) => getPolarCoord(or, d.angle)[1])
.attr('opacity', 0)
.call((sel) => this.fnInvoke(this.opts.rayEnterSelectionCustomize, sel))
.transition()
.call(this._transitionSetup(RAY, ENTER))
.attr('opacity', 1)
.call((t) => this.fnInvoke(this.opts.rayEnterTransitionCustomize, t));
rayGrps.select('.monte-radar-ray')
.call((sel) => this.fnInvoke(this.opts.rayUpdateSelectionCustomize, sel))
.transition()
.call(this._transitionSetup(RAY, UPDATE))
.attr('x1', (d) => getPolarCoord(or, d.angle)[0])
.attr('y1', (d) => getPolarCoord(or, d.angle)[1])
.attr('opacity', 1)
.call((t) => this.fnInvoke(this.opts.rayUpdateTransitionCustomize, t));
rayGrps.call((sel) => this.fnInvoke(this.opts.rayExitSelectionCustomize, sel))
.transition()
.call(this._transitionSetup(RAY, EXIT))
.attr('opacity', 0)
.call((t) => this.fnInvoke(this.opts.rayExitTransitionCustomize, t));
return rayGrps.merge(rayGrps.enter().selectAll('.monte-radar-ray-grp'));
}
_updateRayLabels(rayGrps) {
this.emit(EVENT_UPDATING_LABELS);
const labelPlacement = this.tryInvoke(this.opts.labelPlacement);
const css = this.tryInvoke(labelPlacement.css);
// Clear old label CSS from chart and add new.
removeClassByPattern(this.bound, LABEL_CSS_PATTERN);
this.classed(css, true);
rayGrps.each((d, i, nodes) => {
const node = d3.select(nodes[i]);
this._updateRayLabel(node, d, i, nodes);
});
this.emit(EVENT_UPDATED_LABELS);
}
_updateRayLabel(rayGrp, d, i, nodes) {
const lbl = rayGrp.selectAll('.monte-radar-ray-label').data([d]);
const labelPlacement = this.tryInvoke(this.opts.labelPlacement);
const labelRadius = this.tryInvoke(labelPlacement.radius, this.width, this.height);
const radius = this.tryInvoke(labelRadius, d, i, nodes);
const angle = d.angle;
const rotate = radiansToDegrees(this.tryInvoke(this.opts.labelRotation, d.angle, i, nodes));
const coord = getPolarCoord(radius, angle);
lbl.enter().append('text')
.attr('class', 'monte-radar-ray-label')
.attr('dx', (d1) => this.tryInvoke(this.opts.labelXAdjust, d1, i, nodes))
.attr('dy', (d1) => this.tryInvoke(this.opts.labelYAdjust, d1, i, nodes))
.attr('transform', () => `translate(${coord}) rotate(${rotate})`)
.attr('angle', angle)
.attr('radius', labelRadius)
.style('opacity', 0)
.style('fill', this.optionReaderFunc('labelFillScaleAccessor'))
.text((d1) => this.tryInvoke(this.opts.label, d1, i, nodes))
.call((sel) => this.fnInvoke(this.opts.labelEnterSelectionCustomize, sel))
.transition()
.call((t) => {
const ts = this._transitionSettings(LABEL, ENTER);
this._transitionConfigure(t, ts, d, i, nodes);
})
.style('opacity', 1)
.call((t) => this.fnInvoke(this.opts.labelEnterTransitionCustomize, t));
lbl.style('fill', this.optionReaderFunc('labelFillScaleAccessor'))
.style('opacity', 1)
.attr('dx', (d1) => this.tryInvoke(this.opts.labelXAdjust, d1, i, nodes))
.attr('dy', (d1) => this.tryInvoke(this.opts.labelYAdjust, d1, i, nodes))
.call((sel) => this.fnInvoke(this.opts.labelUpdateSelectionCustomize, sel))
.transition()
.call((t) => {
const ts = this._transitionSettings(LABEL, UPDATE);
this._transitionConfigure(t, ts, d, i, nodes);
})
.attrTween('transform', () => {
const currentTransforms = readTransforms(lbl.attr('transform'));
const from = {
angle: +lbl.attr('angle'),
radius: +lbl.attr('radius'),
rotate: currentTransforms.rotate || 0,
};
const to = { angle, radius, rotate };
return arcLabelTween(from, to, radius);
})
.attr('angle', angle)
.attr('radius', labelRadius)
.text((d1) => this.tryInvoke(this.opts.label, d1, i, nodes))
.call((t) => this.fnInvoke(this.opts.labelUpdateTransitionCustomize, t));
lbl.exit()
.call((sel) => this.fnInvoke(this.opts.labelExitSelectionCustomize, sel))
.transition()
.call((t) => {
const ts = this._transitionSettings(LABEL, EXIT);
this._transitionConfigure(t, ts, d, i, nodes);
})
.style('opacity', 0)
.call((t) => this.fnInvoke(this.opts.labelExitTransitionCustomize, t))
.remove();
}
_updateRadiusLabels() {
const levels = this.tryInvoke(this.opts.webLevels);
const layer = this.tryInvoke(this.opts.radiusLabelLayer);
const lbls = this[layer].selectAll('.monte-radar-radius-label').data(levels);
const transform = (d, i, nodes) => {
const radius = this.radius(d);
const angle = this.tryInvoke(this.opts.radiusLabelAngle, angle, i, nodes);
const coord = getPolarCoord(radius, angle);
const radRot = this.tryInvoke(this.opts.radiusLabelRotation, angle, i, nodes);
const rotate = radiansToDegrees(radRot);
return `translate(${coord}) rotate(${rotate})`;
};
lbls.enter().append('text').classed('monte-radar-radius-label', true)
.attr('class', 'monte-radar-ray-label')
.attr('dx', this.optionReaderFunc('radiusLabelXAdjust'))
.attr('dy', this.optionReaderFunc('radiusLabelYAdjust'))
.style('opacity', 0)
.style('fill', this.optionReaderFunc('radiusLabelFillScaleAccessor'))
.attr('transform', 'translate(0, 0)')
.text(this.optionReaderFunc('radiusLabel'))
.call((sel) => this.fnInvoke(this.opts.radiusLabelEnterSelectionCustomize, sel))
.transition()
.call(this._transitionSetup(LABEL, ENTER))
.style('opacity', 1)
.attr('transform', transform)
.call((t) => this.fnInvoke(this.opts.radiusLabelEnterTransitionCustomize, t));
lbls.style('fill', this.optionReaderFunc('radiusLabelFillScaleAccessor'))
.style('opacity', 1)
.call((sel) => this.fnInvoke(this.opts.radiusLabelUpdateSelectionCustomize, sel))
.transition()
.call(this._transitionSetup(LABEL, UPDATE))
.attr('transform', transform)
.text(this.optionReaderFunc('radiusLabel'))
.call((t) => this.fnInvoke(this.opts.radiusLabelUpdateTransitionCustomize, t));
lbls.exit()
.call((sel) => this.fnInvoke(this.opts.radiusLabelExitSelectionCustomize, sel))
.transition()
.call(this._transitionSetup(LABEL, EXIT))
.style('opacity', 0)
.call((t) => this.fnInvoke(this.opts.radiusLabelExitTransitionCustomize, t))
.remove();
}
_updateWeb() {
const mode = this.tryInvoke(this.opts.webMode);
if (mode === WEB_MODE_ARC) {
this._updateWebArc();
}
else if (mode === WEB_MODE_LINE) {
this._updateWebLine();
}
else {
throw MonteOptionError.InvalidEnumOption('webMode', mode);
}
}
_updateWebArc() {
const minorLevels = this.tryInvoke(this.opts.webInnerLevels);
const levels = this.tryInvoke(this.opts.webLevels);
const startAngle = this.tryInvoke(this.opts.startAngle);
const endAngle = this.tryInvoke(this.opts.endAngle);
const arcs = minorLevels.map((d) => ({
radius: d,
startAngle,
endAngle,
type: 'minor',
}));
levels.forEach((d) => {
arcs.push({
radius: d,
startAngle,
endAngle,
type: 'major',
});
});
const webs = this.bg.selectAll('.monte-radar-web').data(arcs);
webs.enter().append('path')
.attr('class', (d) => d.type)
.classed('monte-radar-web', true)
.attr('d', (d) => this.webArc(d))
.call((sel) => this.fnInvoke(this.opts.webEnterSelectionCustomize, sel))
.transition()
.call(this._transitionSetup(WEB, ENTER))
.call((t) => this.fnInvoke(this.opts.webEnterTransitionCustomize, t));
webs.call((sel) => this.fnInvoke(this.opts.webUpdateSelectionCustomize, sel))
.transition()
.call(this._transitionSetup(WEB, UPDATE))
.attr('d', (d) => this.webArc(d))
.call((t) => this.fnInvoke(this.opts.webUpdateTransitionCustomize, t));
webs.exit()
.call((sel) => this.fnInvoke(this.opts.webExitSelectionCustomize, sel))
.transition()
.call(this._transitionSetup(WEB, EXIT))
.call((t) => this.fnInvoke(this.opts.webExitTransitionCustomize, t))
.remove();
}
_updateWebLine() {
const minorLevels = this.tryInvoke(this.opts.webInnerLevels);
const levels = this.tryInvoke(this.opts.webLevels);
const lines = minorLevels.map((d) => ({
radius: d,
values: this.rayData.map((r) => ({ radius: d, angle: r.angle })),
type: 'minor',
}));
levels.forEach((d) => {
lines.push({
radius: d,
values: this.rayData.map((r) => ({ radius: d, angle: r.angle })),
type: 'major',
});
});
const webs = this.bg.selectAll('.monte-radar-web').data(lines);
webs.enter().append('path')
.attr('class', (d) => d.type)
.classed('monte-radar-web', true)
.attr('d', (d) => this.areaRadialLine(d.values))
.call((sel) => this.fnInvoke(this.opts.webEnterSelectionCustomize, sel))
.transition()
.call(this._transitionSetup(WEB, ENTER))
.call((t) => this.fnInvoke(this.opts.webEnterTransitionCustomize, t));
webs.call((sel) => this.fnInvoke(this.opts.webUpdateSelectionCustomize, sel))
.transition()
.call(this._transitionSetup(WEB, UPDATE))
.attr('d', (d) => this.areaRadialLine(d.values))
.call((t) => this.fnInvoke(this.opts.webUpdateTransitionCustomize, t));
webs.exit()
.call((sel) => this.fnInvoke(this.opts.webExitSelectionCustomize, sel))
.transition()
.call(this._transitionSetup(WEB, EXIT))
.call((t) => this.fnInvoke(this.opts.webExitTransitionCustomize, t))
.remove();
}
_updatePoints(areaGrps) {
areaGrps.each((d, i, nodes) => this._updateAreaPoints(nodes[i], d, i));
}
_updateAreaPoints(areaNode, areaDatum, areaIndex) {
const areaGrp = d3.select(areaNode);
const pointsData = Object.keys(areaDatum).map((d) => ({ prop: d, value: areaDatum[d] }));
// Data join for the points
const points = areaGrp.selectAll('.monte-point').data(pointsData);
const genSym = (d, i) => {
const size = this.tryInvoke(this.opts.pointSize, d, i);
const symbase = d3.symbol().size(size);
const symbol = this.opts.pointSymbol(symbase, d, i);
return symbol(d, i);
};
// Create new points
points.enter().append('path')
.call(this.__bindCommonEvents(POINT))
.attr('d', genSym)
.attr('transform', (d) => this._translatePoint(d))
.attr('class', (d) => this._buildCss(
['monte-point',
this.opts.pointCss,
this.opts.pointCssScaleAccessor,
d.css], areaDatum, areaIndex))
.call((sel) => this.fnInvoke(this.opts.pointEnterSelectionCustomize, sel))
.transition()
.call(this._transitionSetup(POINT, ENTER))
.style('fill', this.optionReaderFunc('pointFillScaleAccessor'))
.style('stroke', this.optionReaderFunc('pointStrokeScaleAccessor'))
.attr('transform', (d) => this._translatePoint(d))
.call((sel) => this.fnInvoke(this.opts.pointEnterTransitionCustomize, sel));
// Update existing points
points.attr('class', (d) => this._buildCss(
['monte-point',
this.opts.pointCss,
this.opts.pointCssScaleAccessor,
d.css], areaDatum, areaIndex))
.call((sel) => this.fnInvoke(this.opts.pointUpdateSelectionCustomize, sel))
.transition()
.call(this._transitionSetup(POINT, UPDATE))
.style('fill', this.optionReaderFunc('pointFillScaleAccessor'))
.style('stroke', this.optionReaderFunc('pointStrokeScaleAccessor'))
.attr('transform', (d) => this._translatePoint(d))
.attr('d', genSym)
.call((sel) => this.fnInvoke(this.opts.pointUpdateTransitionCustomize, sel));
// Fade out removed points.
points.exit()
.call((sel) => this.fnInvoke(this.opts.pointExitSelectionCustomize, sel))
.transition()
.call(this._transitionSetup(POINT, EXIT))
.style('opacity', 0)
.call((sel) => this.fnInvoke(this.opts.pointExitTransitionCustomize, sel))
.remove();
}
_translatePoint(d) {
const angle = this.rayAngleMap[d.prop];
const point = getPolarCoord(this.radius(d.value), angle);
return `translate(${point[0]}, ${point[1]})`;
}
}
RadarChart.EVENTS = EVENTS;
function findBy(objArray, searchKey, searchKeyMatch, valueKey) {
let v = null;
for (let i = 0; i < objArray.length; i++) {
if (objArray[i]) {
if (objArray[i][searchKey] === searchKeyMatch) {
v = objArray[i][valueKey];
break;
}
}
}
return v;
}