UNPKG

veritatisquam

Version:
918 lines (869 loc) 22.6 kB
// 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); }); } }