veritatisquam
Version:
Carbon charting components, lerna monorepo
918 lines (869 loc) • 22.6 kB
text/typescript
// Internal Imports
import { Component } from '../component';
import { DOMUtils } from '../../services';
import { Events, Roles, ColorClassNameTypes } from '../../interfaces';
import { Tools } from '../../tools';
import {
Point,
Angle,
radialLabelPlacement,
radToDeg,
polarToCartesianCoords,
distanceBetweenPointOnCircAndVerticalDiameter,
} from '../../services/angle-utils';
import * as Configuration from '../../configuration';
// D3 Imports
import { select } from 'd3-selection';
import { scaleBand, scaleLinear, ScaleLinear } from 'd3-scale';
import { max, extent } from 'd3-array';
import { lineRadial, curveLinearClosed } from 'd3-shape';
// used to make transitions
let oldYScale: ScaleLinear<number, number>;
export class Radar extends Component {
type = 'radar';
svg: SVGElement;
groupMapsTo: string;
uniqueKeys: string[];
uniqueGroups: string[];
displayDataNormalized: any;
groupedDataNormalized: any;
init() {
const { events } = this.services;
// Highlight correct line legend item hovers
events.addEventListener(
Events.Legend.ITEM_HOVER,
this.handleLegendOnHover
);
// Un-highlight lines on legend item mouseouts
events.addEventListener(
Events.Legend.ITEM_MOUSEOUT,
this.handleLegendMouseOut
);
}
render(animate = true) {
const svg = this.getContainerSVG();
const { width, height } = DOMUtils.getSVGElementSize(this.parent, {
useAttrs: true,
});
const data = this.model.getData();
const displayData = this.model.getDisplayData();
const groupedData = this.model.getGroupedData();
const options = this.getOptions();
const { angle, value } = Tools.getProperty(options, 'radar', 'axes');
const groupMapsTo = Tools.getProperty(options, 'data', 'groupMapsTo');
const {
xLabelPadding,
yLabelPadding,
yTicksNumber,
minRange,
xAxisRectHeight,
} = Configuration.radar;
this.uniqueKeys = Array.from(new Set(data.map((d) => d[angle])));
this.uniqueGroups = Array.from(
new Set(data.map((d) => d[groupMapsTo]))
);
this.displayDataNormalized = this.normalizeFlatData(displayData);
this.groupedDataNormalized = this.normalizeGroupedData(groupedData);
const labelHeight = this.getLabelDimensions(this.uniqueKeys[0]).height;
const margin = 2 * (labelHeight + yLabelPadding);
const size = Math.min(width, height);
const diameter = size - margin;
const radius = diameter / 2;
if (radius <= 0) {
return;
}
// given a key, return the corresponding angle in radiants
// rotated by -PI/2 because we want angle 0° at -y (12 o’clock)
const xScale = scaleBand<string>()
.domain(this.displayDataNormalized.map((d) => d[angle]))
.range(
[0, 2 * Math.PI].map((a) => a - Math.PI / 2) as [Angle, Angle]
);
const yScale = scaleLinear()
.domain([
0,
max(
this.displayDataNormalized.map((d) => d[value]) as number[]
),
])
.range([minRange, radius])
.nice(yTicksNumber);
const yTicks = yScale.ticks(yTicksNumber);
const colorScale = (group: string): string =>
this.model.getFillColor(group);
// constructs a new radial line generator
// the angle accessor returns the angle in radians with 0° at -y (12 o’clock)
// so map back the angle
const radialLineGenerator = lineRadial<any>()
.angle((d) => xScale(d[angle]) + Math.PI / 2)
.radius((d) => yScale(d[value]))
.curve(curveLinearClosed);
// this line generator is necessary in order to make a transition of a value from the
// position it occupies using the old scale to the position it occupies using the new scale
const oldRadialLineGenerator = lineRadial<any>()
.angle(radialLineGenerator.angle())
.radius((d) => (oldYScale ? oldYScale(d[value]) : minRange))
.curve(radialLineGenerator.curve());
// compute the space that each x label needs
const horizSpaceNeededByEachXLabel = this.uniqueKeys.map((key) => {
const tickWidth = this.getLabelDimensions(key).width;
// compute the distance between the point that the label rapresents and the vertical diameter
const distanceFromDiameter = distanceBetweenPointOnCircAndVerticalDiameter(
xScale(key),
radius
);
// the space each label occupies is the sum of these two values
return tickWidth + distanceFromDiameter;
});
const leftPadding = max(horizSpaceNeededByEachXLabel);
// center coordinates
const c: Point = {
x: leftPadding + xLabelPadding,
y: height / 2,
};
/////////////////////////////
// Drawing the radar
/////////////////////////////
// y axes
const yAxes = DOMUtils.appendOrSelect(svg, 'g.y-axes').attr(
'role',
Roles.GROUP
);
const yAxisUpdate = yAxes
.selectAll('path')
.data(yTicks, (tick) => tick);
// for each tick, create array of data corresponding to the points composing the shape
const shapeData = (tick: number) =>
this.uniqueKeys.map((key) => ({ [angle]: key, [value]: tick }));
yAxisUpdate.join(
(enter) =>
enter
.append('path')
.attr('role', Roles.GRAPHICS_SYMBOL)
.attr('opacity', 0)
.attr('transform', `translate(${c.x}, ${c.y})`)
.attr('fill', 'none')
.attr('d', (tick) =>
oldRadialLineGenerator(shapeData(tick))
)
.call((selection) =>
selection
.transition(
this.services.transitions.getTransition(
'radar_y_axes_enter',
animate
)
)
.attr('opacity', 1)
.attr('d', (tick) =>
radialLineGenerator(shapeData(tick))
)
),
(update) =>
update.call((selection) =>
selection
.transition(
this.services.transitions.getTransition(
'radar_y_axes_update',
animate
)
)
.attr('opacity', 1)
.attr('transform', `translate(${c.x}, ${c.y})`)
.attr('d', (tick) =>
radialLineGenerator(shapeData(tick))
)
),
(exit) =>
exit.call((selection) =>
selection
.transition(
this.services.transitions.getTransition(
'radar_y_axes_exit',
animate
)
)
.attr('d', (tick) =>
radialLineGenerator(shapeData(tick))
)
.attr('opacity', 0)
.remove()
)
);
// x axes
const xAxes = DOMUtils.appendOrSelect(svg, 'g.x-axes').attr(
'role',
Roles.GROUP
);
const xAxisUpdate = xAxes
.selectAll('line')
.data(this.uniqueKeys, (key) => key);
xAxisUpdate.join(
(enter) =>
enter
.append('line')
.attr('role', Roles.GRAPHICS_SYMBOL)
.attr('opacity', 0)
.attr('class', (key) => `x-axis-${Tools.kebabCase(key)}`) // replace spaces with -
.attr('stroke-dasharray', '0')
.attr(
'x1',
(key) => polarToCartesianCoords(xScale(key), 0, c).x
)
.attr(
'y1',
(key) => polarToCartesianCoords(xScale(key), 0, c).y
)
.attr(
'x2',
(key) => polarToCartesianCoords(xScale(key), 0, c).x
)
.attr(
'y2',
(key) => polarToCartesianCoords(xScale(key), 0, c).y
)
.call((selection) =>
selection
.transition(
this.services.transitions.getTransition(
'radar_x_axes_enter',
animate
)
)
.attr('opacity', 1)
.attr(
'x1',
(key) =>
polarToCartesianCoords(
xScale(key),
yScale.range()[0],
c
).x
)
.attr(
'y1',
(key) =>
polarToCartesianCoords(
xScale(key),
yScale.range()[0],
c
).y
)
.attr(
'x2',
(key) =>
polarToCartesianCoords(
xScale(key),
yScale.range()[1],
c
).x
)
.attr(
'y2',
(key) =>
polarToCartesianCoords(
xScale(key),
yScale.range()[1],
c
).y
)
),
(update) =>
update.call((selection) =>
selection
.transition(
this.services.transitions.getTransition(
'radar_x_axes_update',
animate
)
)
.attr('opacity', 1)
.attr(
'x1',
(key) =>
polarToCartesianCoords(
xScale(key),
yScale.range()[0],
c
).x
)
.attr(
'y1',
(key) =>
polarToCartesianCoords(
xScale(key),
yScale.range()[0],
c
).y
)
.attr(
'x2',
(key) =>
polarToCartesianCoords(
xScale(key),
yScale.range()[1],
c
).x
)
.attr(
'y2',
(key) =>
polarToCartesianCoords(
xScale(key),
yScale.range()[1],
c
).y
)
),
(exit) =>
exit.call((selection) =>
selection
.transition(
this.services.transitions.getTransition(
'radar_x_axes_exit',
animate
)
)
.attr('opacity', 0)
.remove()
)
);
// x labels
const xLabels = DOMUtils.appendOrSelect(svg, 'g.x-labels').attr(
'role',
Roles.GROUP
);
const xLabelUpdate = xLabels.selectAll('text').data(this.uniqueKeys);
xLabelUpdate.join(
(enter) =>
enter
.append('text')
.text((key) => key)
.attr('opacity', 0)
.attr(
'x',
(key) =>
polarToCartesianCoords(
xScale(key),
yScale.range()[1] + xLabelPadding,
c
).x
)
.attr(
'y',
(key) =>
polarToCartesianCoords(
xScale(key),
yScale.range()[1] + xLabelPadding,
c
).y
)
.style(
'text-anchor',
(key) => radialLabelPlacement(xScale(key)).textAnchor
)
.style(
'dominant-baseline',
(key) =>
radialLabelPlacement(xScale(key)).dominantBaseline
)
.call((selection) =>
selection
.transition(
this.services.transitions.getTransition(
'radar_x_labels_enter',
animate
)
)
.attr('opacity', 1)
),
(update) =>
update.call((selection) =>
selection
.transition(
this.services.transitions.getTransition(
'radar_x_labels_update',
animate
)
)
.attr('opacity', 1)
.attr(
'x',
(key) =>
polarToCartesianCoords(
xScale(key),
yScale.range()[1] + xLabelPadding,
c
).x
)
.attr(
'y',
(key) =>
polarToCartesianCoords(
xScale(key),
yScale.range()[1] + xLabelPadding,
c
).y
)
),
(exit) =>
exit.call((selection) =>
selection
.transition(
this.services.transitions.getTransition(
'radar_x_labels_exit',
animate
)
)
.attr('opacity', 0)
.remove()
)
);
// blobs
const blobs = DOMUtils.appendOrSelect(svg, 'g.blobs').attr(
'role',
Roles.GROUP
);
const blobUpdate = blobs
.selectAll('path')
.data(this.groupedDataNormalized, (group) => group.name);
blobUpdate.join(
(enter) =>
enter
.append('path')
.attr('class', (group) =>
this.model.getColorClassName({
classNameTypes: [
ColorClassNameTypes.FILL,
ColorClassNameTypes.STROKE,
],
dataGroupName: group.name,
originalClassName: 'blob',
})
)
.attr('role', Roles.GRAPHICS_SYMBOL)
.attr('opacity', 0)
.attr('transform', `translate(${c.x}, ${c.y})`)
.style('fill', (group) => colorScale(group.name))
.style('fill-opacity', Configuration.radar.opacity.selected)
.style('stroke', (group) => colorScale(group.name))
.attr('d', (group) => oldRadialLineGenerator(group.data))
.call((selection) =>
selection
.transition(
this.services.transitions.getTransition(
'radar_blobs_enter',
animate
)
)
.attr('opacity', 1)
.attr('d', (group) =>
radialLineGenerator(group.data)
)
),
(update) => {
update
.attr('class', (group) =>
this.model.getColorClassName({
classNameTypes: [
ColorClassNameTypes.FILL,
ColorClassNameTypes.STROKE,
],
dataGroupName: group.name,
originalClassName: 'blob',
})
)
.style('fill', (group) => colorScale(group.name))
.style('stroke', (group) => colorScale(group.name));
update.call((selection) =>
selection
.transition(
this.services.transitions.getTransition(
'radar_blobs_update',
animate
)
)
.attr('opacity', 1)
.attr('transform', `translate(${c.x}, ${c.y})`)
.attr('d', (group) => radialLineGenerator(group.data))
);
},
(exit) =>
exit.call((selection) =>
selection
.transition(
this.services.transitions.getTransition(
'radar_blobs_exit',
animate
)
)
.attr('d', (group) => radialLineGenerator(group.data))
.attr('opacity', 0)
.remove()
)
);
// data dots
const dots = DOMUtils.appendOrSelect(svg, 'g.dots').attr(
'role',
Roles.GROUP
);
const dotsUpdate = dots
.selectAll('circle')
.data(this.displayDataNormalized);
dotsUpdate
.join(
(enter) =>
enter.append('circle').attr('role', Roles.GRAPHICS_SYMBOL),
(update) => update,
(exit) => exit.remove()
)
.attr('class', (d) =>
this.model.getColorClassName({
classNameTypes: [ColorClassNameTypes.FILL],
dataGroupName: d[groupMapsTo],
originalClassName: Tools.kebabCase(d[angle]),
})
)
.attr(
'cx',
(d) =>
polarToCartesianCoords(
xScale(d[angle]),
yScale(d[value]),
c
).x
)
.attr(
'cy',
(d) =>
polarToCartesianCoords(
xScale(d[angle]),
yScale(d[value]),
c
).y
)
.attr('r', 0)
.attr('opacity', 0)
.style('fill', (d) => colorScale(d[groupMapsTo]));
// rectangles
const xAxesRect = DOMUtils.appendOrSelect(svg, 'g.x-axes-rect').attr(
'role',
Roles.GROUP
);
const xAxisRectUpdate = xAxesRect
.selectAll('rect')
.data(this.uniqueKeys);
xAxisRectUpdate
.join(
(enter) =>
enter.append('rect').attr('role', Roles.GRAPHICS_SYMBOL),
(update) => update,
(exit) => exit.remove()
)
.attr('x', c.x)
.attr('y', c.y - xAxisRectHeight / 2)
.attr('width', yScale.range()[1])
.attr('height', xAxisRectHeight)
.style('fill', 'red')
.style('fill-opacity', 0)
.attr(
'transform',
(key) => `rotate(${radToDeg(xScale(key))}, ${c.x}, ${c.y})`
);
// y labels (show only the min and the max labels)
const yLabels = DOMUtils.appendOrSelect(svg, 'g.y-labels').attr(
'role',
Roles.GROUP
);
const yLabelUpdate = yLabels.selectAll('text').data(extent(yTicks));
yLabelUpdate.join(
(enter) =>
enter
.append('text')
.attr('opacity', 0)
.text((tick) => tick)
.attr(
'x',
(tick) =>
polarToCartesianCoords(
-Math.PI / 2,
yScale(tick),
c
).x + yLabelPadding
)
.attr(
'y',
(tick) =>
polarToCartesianCoords(
-Math.PI / 2,
yScale(tick),
c
).y
)
.style('text-anchor', 'start')
.style('dominant-baseline', 'middle')
.call((selection) =>
selection
.transition(
this.services.transitions.getTransition(
'radar_y_labels_enter',
animate
)
)
.attr('opacity', 1)
),
(update) =>
update.call((selection) =>
selection
.transition(
this.services.transitions.getTransition(
'radar_y_labels_update',
animate
)
)
.text((tick) => tick)
.attr('opacity', 1)
.attr(
'x',
(tick) =>
polarToCartesianCoords(
-Math.PI / 2,
yScale(tick),
c
).x + yLabelPadding
)
.attr(
'y',
(tick) =>
polarToCartesianCoords(
-Math.PI / 2,
yScale(tick),
c
).y
)
),
(exit) =>
exit.call((selection) =>
selection
.transition(
this.services.transitions.getTransition(
'radar_y_labels_exit',
animate
)
)
.attr('opacity', 0)
.remove()
)
);
const alignment = Tools.getProperty(options, 'radar', 'alignment');
const alignmentOffset = DOMUtils.getAlignmentOffset(
alignment,
svg,
this.getParent()
);
svg.attr('transform', `translate(${alignmentOffset}, 0)`);
// Add event listeners
this.addEventListeners();
oldYScale = yScale; // save the current scale as the old one
}
// append temporarily the label to get the exact space that it occupies
getLabelDimensions = (label: string) => {
const tmpTick = DOMUtils.appendOrSelect(
this.getContainerSVG(),
`g.tmp-tick`
);
const tmpTickText = DOMUtils.appendOrSelect(tmpTick, `text`).text(
label
);
const { width, height } = DOMUtils.getSVGElementSize(
tmpTickText.node(),
{ useBBox: true }
);
tmpTick.remove();
return { width, height };
};
// Given a flat array of objects, if there are missing data on key,
// creates corresponding data with value = null
normalizeFlatData = (dataset: any) => {
const options = this.getOptions();
const { angle, value } = Tools.getProperty(options, 'radar', 'axes');
const groupMapsTo = Tools.getProperty(options, 'data', 'groupMapsTo');
const completeBlankData = Tools.flatMapDeep(
this.uniqueKeys.map((key) => {
return this.uniqueGroups.map((group) => ({
[angle]: key,
[groupMapsTo]: group,
[value]: null,
}));
})
);
return Tools.merge(completeBlankData, dataset);
};
// Given a a grouped array of objects, if there are missing data on key,
// creates corresponding data with value = null
normalizeGroupedData = (dataset: any) => {
const options = this.getOptions();
const { angle, value } = Tools.getProperty(options, 'radar', 'axes');
const groupMapsTo = Tools.getProperty(options, 'data', 'groupMapsTo');
return dataset.map(({ name, data }) => {
const completeBlankData = this.uniqueKeys.map((k) => ({
[groupMapsTo]: name,
[angle]: k,
[value]: null,
}));
return { name, data: Tools.merge(completeBlankData, data) };
});
};
handleLegendOnHover = (event: CustomEvent) => {
const { hoveredElement } = event.detail;
this.parent
.selectAll('g.blobs path')
.transition(
this.services.transitions.getTransition('legend-hover-blob')
)
.style('fill-opacity', (group) => {
if (group.name !== hoveredElement.datum().name) {
return Configuration.radar.opacity.unselected;
}
return Configuration.radar.opacity.selected;
})
.style('stroke-opacity', (group) => {
if (group.name !== hoveredElement.datum().name) {
return Configuration.radar.opacity.unselected;
}
return 1;
});
};
handleLegendMouseOut = (event: CustomEvent) => {
this.parent
.selectAll('g.blobs path')
.transition(
this.services.transitions.getTransition('legend-mouseout-blob')
)
.style('fill-opacity', Configuration.radar.opacity.selected)
.style('stroke-opacity', 1);
};
destroy() {
// Remove event listeners
this.parent
.selectAll('.x-axes-rect > rect')
.on('mouseover', null)
.on('mousemove', null)
.on('mouseout', null);
// Remove legend listeners
const eventsFragment = this.services.events;
eventsFragment.removeEventListener(
Events.Legend.ITEM_HOVER,
this.handleLegendOnHover
);
eventsFragment.removeEventListener(
Events.Legend.ITEM_MOUSEOUT,
this.handleLegendMouseOut
);
}
addEventListeners() {
const self = this;
const {
axes: { angle },
} = Tools.getProperty(this.getOptions(), 'radar');
// events on x axes rects
this.parent
.selectAll('.x-axes-rect > rect')
.on('mouseover', function (datum) {
const hoveredElement = select(this);
// Dispatch mouse event
self.services.events.dispatchEvent(
Events.Radar.X_AXIS_MOUSEOVER,
{
element: hoveredElement,
datum,
}
);
const axisLine = self.parent.select(
`.x-axes .x-axis-${Tools.kebabCase(datum)}`
);
const dots = self.parent.selectAll(
`.dots circle.${Tools.kebabCase(datum)}`
);
// Change style
axisLine
.classed('hovered', true)
.attr('stroke-dasharray', '4 4');
dots.classed('hovered', true)
.attr('opacity', 1)
.attr('r', Configuration.radar.dotsRadius);
// get the items that should be highlighted
const itemsToHighlight = self.displayDataNormalized.filter(
(d) => d[angle] === datum
);
const options = self.getOptions();
const { groupMapsTo } = options.data;
const valueMapsTo = Tools.getProperty(
options,
'radar',
'axes',
'value'
);
// Show tooltip
self.services.events.dispatchEvent(Events.Tooltip.SHOW, {
hoveredElement,
items: itemsToHighlight
.filter(
(datum) => typeof datum[valueMapsTo] === 'number'
)
.map((datum) => ({
label: datum[groupMapsTo],
value: datum[valueMapsTo],
color: self.model.getFillColor(datum[groupMapsTo]),
class: self.model.getColorClassName({
classNameTypes: [ColorClassNameTypes.TOOLTIP],
dataGroupName: datum[groupMapsTo],
}),
})),
});
})
.on('mousemove', function (datum) {
const hoveredElement = select(this);
// Dispatch mouse event
self.services.events.dispatchEvent(
Events.Radar.X_AXIS_MOUSEMOVE,
{
element: hoveredElement,
datum,
}
);
self.services.events.dispatchEvent(Events.Tooltip.MOVE);
})
.on('click', function (datum) {
// Dispatch mouse event
self.services.events.dispatchEvent(Events.Radar.X_AXIS_CLICK, {
element: select(this),
datum,
});
})
.on('mouseout', function (datum) {
const hoveredElement = select(this);
const axisLine = self.parent.select(
`.x-axes .x-axis-${Tools.kebabCase(datum)}`
);
const dots = self.parent.selectAll(
`.dots circle.${Tools.kebabCase(datum)}`
);
// Change style
axisLine
.classed('hovered', false)
.attr('stroke-dasharray', '0');
dots.classed('hovered', false).attr('opacity', 0).attr('r', 0);
// Dispatch mouse event
self.services.events.dispatchEvent(
Events.Radar.X_AXIS_MOUSEOUT,
{
element: hoveredElement,
datum,
}
);
// Hide tooltip
self.services.events.dispatchEvent(Events.Tooltip.HIDE);
});
}
}