diff --git a/packages/core/demo/data/index.ts b/packages/core/demo/data/index.ts index 3729f88a03..e48e737787 100644 --- a/packages/core/demo/data/index.ts +++ b/packages/core/demo/data/index.ts @@ -6,6 +6,7 @@ import * as pieDemos from "./pie"; import * as scatterDemos from "./scatter"; import * as stepDemos from "./step"; import * as timeSeriesAxisDemos from "./time-series-axis"; +import * as radarDemos from "./radar"; export * from "./bar"; export * from "./bubble"; @@ -14,6 +15,7 @@ export * from "./line"; export * from "./pie"; export * from "./scatter"; export * from "./step"; +export * from "./radar"; import { createChartSandbox, @@ -66,6 +68,11 @@ export const chartTypes = { vanilla: "DonutChart", angular: "ibm-donut-chart", vue: "ccv-donut-chart" + }, + RadarChart: { + vanilla: "RadarChart", + angular: "ibm-radar-chart", + vue: "ccv-radar-chart" } }; @@ -336,6 +343,26 @@ let allDemoGroups = [ chartType: chartTypes.LineChart } ] + }, + { + title: "Radar", + demos: [ + { + data: radarDemos.radarData, + options: radarDemos.radarOptions, + chartType: chartTypes.RadarChart + }, + { + data: radarDemos.radarWithMissingDataData, + options: radarDemos.radarWithMissingDataOptions, + chartType: chartTypes.RadarChart + }, + { + data: radarDemos.radarDenseData, + options: radarDemos.radarDenseOptions, + chartType: chartTypes.RadarChart + } + ] } ] as any; diff --git a/packages/core/demo/data/radar.ts b/packages/core/demo/data/radar.ts new file mode 100644 index 0000000000..b87fbb3790 --- /dev/null +++ b/packages/core/demo/data/radar.ts @@ -0,0 +1,103 @@ + +// simple radar +export const radarData = [ + { product: "Product 1", feature: "Price", score: 60 }, + { product: "Product 1", feature: "Usability", score: 92 }, + { product: "Product 1", feature: "Availability", score: 5 }, + { product: "Product 1", feature: "Performance", score: 85 }, + { product: "Product 1", feature: "Quality", score: 60 }, + { product: "Product 2", feature: "Price", score: 70 }, + { product: "Product 2", feature: "Usability", score: 63 }, + { product: "Product 2", feature: "Availability", score: 78 }, + { product: "Product 2", feature: "Performance", score: 50 }, + { product: "Product 2", feature: "Quality", score: 30 } +]; +export const radarOptions = { + title: "Radar", + radar: { + axes: { + angle: "feature", + value: "score" + } + }, + data: { + groupMapsTo: "product" + } +}; + +// radar with missing data +export const radarWithMissingDataData = [ + { group: "Sugar", key: "London", value: 25 }, + { group: "Oil", key: "London", value: 6 }, + { group: "Water", key: "London", value: 12 }, + { group: "Sugar", key: "Milan", value: 13 }, + { group: "Oil", key: "Milan", value: 6 }, + { group: "Water", key: "Milan", value: 28 }, + { group: "Sugar", key: "Paris", value: 19 }, + { group: "Oil", key: "Paris", value: 16 }, + { group: "Water", key: "Paris", value: 10 }, + { group: "Sugar", key: "New York", value: 11 }, + { group: "Oil", key: "New York", value: 18 }, + { group: "Water", key: "New York", value: 8 }, + { group: "Sugar", key: "Sydney", value: 12 }, + { group: "Oil", key: "Sydney", value: 16 } +]; +export const radarWithMissingDataOptions = { + title: "Radar - Missing data" +}; + +// radar dense +export const radarDenseData = [ + { month: "January", activity: "Eating", hoursAvg: 2 }, + { month: "January", activity: "Drinking", hoursAvg: 6 }, + { month: "January", activity: "Sleeping", hoursAvg: 6 }, + { month: "January", activity: "Working", hoursAvg: 8 }, + { month: "January", activity: "Walking", hoursAvg: 1 }, + { month: "January", activity: "Running", hoursAvg: 0.5 }, + { month: "January", activity: "Cycling", hoursAvg: 1 }, + { month: "January", activity: "Swimming", hoursAvg: 0 }, + { month: "February", activity: "Eating", hoursAvg: 1.5 }, + { month: "February", activity: "Drinking", hoursAvg: 9 }, + { month: "February", activity: "Sleeping", hoursAvg: 7 }, + { month: "February", activity: "Working", hoursAvg: 9 }, + { month: "February", activity: "Walking", hoursAvg: 2 }, + { month: "February", activity: "Running", hoursAvg: 2 }, + { month: "February", activity: "Cycling", hoursAvg: 0 }, + { month: "February", activity: "Swimming", hoursAvg: 1.5 }, + { month: "March", activity: "Eating", hoursAvg: 3 }, + { month: "March", activity: "Drinking", hoursAvg: 5 }, + { month: "March", activity: "Sleeping", hoursAvg: 5 }, + { month: "March", activity: "Working", hoursAvg: 6 }, + { month: "March", activity: "Walking", hoursAvg: 3 }, + { month: "March", activity: "Running", hoursAvg: 9 }, + { month: "March", activity: "Cycling", hoursAvg: 1 }, + { month: "March", activity: "Swimming", hoursAvg: 7 }, + { month: "April", activity: "Eating", hoursAvg: 5 }, + { month: "April", activity: "Drinking", hoursAvg: 1 }, + { month: "April", activity: "Sleeping", hoursAvg: 4 }, + { month: "April", activity: "Working", hoursAvg: 2 }, + { month: "April", activity: "Walking", hoursAvg: 5 }, + { month: "April", activity: "Running", hoursAvg: 4 }, + { month: "April", activity: "Cycling", hoursAvg: 6 }, + { month: "April", activity: "Swimming", hoursAvg: 3 }, + { month: "May", activity: "Eating", hoursAvg: 7 }, + { month: "May", activity: "Drinking", hoursAvg: 0 }, + { month: "May", activity: "Sleeping", hoursAvg: 5 }, + { month: "May", activity: "Working", hoursAvg: 4 }, + { month: "May", activity: "Walking", hoursAvg: 8 }, + { month: "May", activity: "Running", hoursAvg: 2 }, + { month: "May", activity: "Cycling", hoursAvg: 3 }, + { month: "May", activity: "Swimming", hoursAvg: 1 } +]; +export const radarDenseOptions = { + title: "Radar - Dense", + radar: { + axes: { + angle: "activity", + value: "hoursAvg" + } + }, + data: { + groupMapsTo: "month" + } +}; diff --git a/packages/core/src/charts/index.ts b/packages/core/src/charts/index.ts index 652290e923..3eb4bae05f 100644 --- a/packages/core/src/charts/index.ts +++ b/packages/core/src/charts/index.ts @@ -6,3 +6,4 @@ export * from "./line"; export * from "./scatter"; export * from "./pie"; export * from "./donut"; +export * from "./radar"; diff --git a/packages/core/src/charts/radar.ts b/packages/core/src/charts/radar.ts new file mode 100644 index 0000000000..56499552cd --- /dev/null +++ b/packages/core/src/charts/radar.ts @@ -0,0 +1,53 @@ +// Internal Imports +import { Chart } from "../chart"; +import * as Configuration from "../configuration"; +import { + ChartConfig, + RadarChartOptions +} from "../interfaces/index"; +import { Tools } from "../tools"; + +// Components +import { + // the imports below are needed because of typescript bug (error TS4029) + Legend, + LayoutComponent, + TooltipRadar +} from "../components/index"; +import { Radar } from "../components/graphs/radar"; + +export class RadarChart extends Chart { + // TODO - Optimize the use of "extending" + constructor(holder: Element, chartConfigs: ChartConfig, extending = false) { + super(holder, chartConfigs); + + // TODO - Optimize the use of "extending" + if (extending) { + return; + } + + // Merge the default options for this chart + // With the user provided options + this.model.setOptions( + Tools.mergeDefaultChartOptions( + Configuration.options.radarChart, + chartConfigs.options + ) + ); + + // Initialize data, services, components etc. + this.init(holder, chartConfigs); + } + + getComponents() { + // Specify what to render inside the graph-frame + const graphFrameComponents = [ + new Radar(this.model, this.services) + ]; + + // get the base chart components and export with tooltip + const components: any[] = this.getChartComponents(graphFrameComponents); + components.push(new TooltipRadar(this.model, this.services)); + return components; + } +} diff --git a/packages/core/src/components/essentials/tooltip-radar.ts b/packages/core/src/components/essentials/tooltip-radar.ts new file mode 100644 index 0000000000..6faf280f90 --- /dev/null +++ b/packages/core/src/components/essentials/tooltip-radar.ts @@ -0,0 +1,33 @@ +import { Tooltip } from "./tooltip"; +import { Tools } from "../../tools"; + +export class TooltipRadar extends Tooltip { + getMultilineTooltipHTML(data: any) { + const options = this.model.getOptions(); + const { groupMapsTo } = options.data; + const { angle, value } = options.radar.axes; + + // sort them so they are in the same order as the graph + data.sort((a, b) => b[value] - a[value]); + + return ""; + } +} diff --git a/packages/core/src/components/graphs/radar.ts b/packages/core/src/components/graphs/radar.ts new file mode 100644 index 0000000000..6764f591f6 --- /dev/null +++ b/packages/core/src/components/graphs/radar.ts @@ -0,0 +1,461 @@ +// Internal Imports +import { Component } from "../component"; +import { DOMUtils } from "../../services"; +import { Events, TooltipTypes, Roles } from "../../interfaces"; +import { Tools } from "../../tools"; +import { + Point, + Angle, + radialLabelPlacement, + radToDeg, + polarToCartesianCoords, + distanceBetweenPointOnCircAndVerticalDiameter +} from "../../services/angle-utils"; + +// 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; + +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) { + this.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.model.getOptions(); + const { angle, value } = Tools.getProperty(options, "radar", "axes"); + const groupMapsTo = Tools.getProperty(options, "data", "groupMapsTo"); + const { xLabelPadding, yLabelPadding, yTicksNumber, minRange, xAxisRectHeight, opacity } = Tools.getProperty(options, "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() + .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() + .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() + .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(this.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() + ) + ); + + // y labels (show only the min and the max labels) + const yLabels = DOMUtils.appendOrSelect(this.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() + ) + ); + + // x axes + const xAxes = DOMUtils.appendOrSelect(this.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(this.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(this.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", "blob") + .attr("role", Roles.GRAPHICS_SYMBOL) + .attr("opacity", 0) + .attr("transform", `translate(${c.x}, ${c.y})`) + .attr("fill", group => colorScale(group.name)) + .style("fill-opacity", opacity.selected) + .attr("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 + .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(this.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 => 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) + .attr("fill", d => colorScale(d[groupMapsTo])); + + // rectangles + const xAxesRect = DOMUtils.appendOrSelect(this.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) + .attr("fill", "red") + .style("fill-opacity", 0) + .attr("transform", key => `rotate(${radToDeg(xScale(key))}, ${c.x}, ${c.y})`); + + // 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.svg, `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.model.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.model.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; + const opacity = Tools.getProperty(this.model.getOptions(), "radar", "opacity"); + 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 Tools.getProperty(opacity, "unselected"); + } + return Tools.getProperty(opacity, "selected"); + }); + } + + handleLegendMouseOut = (event: CustomEvent) => { + const opacity = Tools.getProperty(this.model.getOptions(), "radar", "opacity"); + this.parent.selectAll("g.blobs path") + .transition(this.services.transitions.getTransition("legend-mouseout-blob")) + .style("fill-opacity", Tools.getProperty(opacity, "selected")); + } + + 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 }, dotsRadius } = Tools.getProperty(this.model.getOptions(), "radar"); + + // events on x axes rects + this.parent.selectAll(".x-axes-rect > rect") + .on("mouseover", function(datum) { + // Dispatch mouse event + self.services.events.dispatchEvent(Events.Radar.X_AXIS_MOUSEOVER, { + element: select(this), + datum + }); + }) + .on("mousemove", 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", true) + .attr("stroke-dasharray", "4 4"); + dots.classed("hovered", true) + .attr("opacity", 1) + .attr("r", dotsRadius); + + // Dispatch mouse event + self.services.events.dispatchEvent(Events.Radar.X_AXIS_MOUSEMOVE, { + element: hoveredElement, + datum + }); + + // get the items that should be highlighted + const itemsToHighlight = self.displayDataNormalized.filter(d => d[angle] === datum); + + // Show tooltip + self.services.events.dispatchEvent(Events.Tooltip.SHOW, { + hoveredElement, + multidata: itemsToHighlight, + type: TooltipTypes.GRIDLINE + }); + }) + .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("hide-tooltip", { hoveredElement }); + self.services.events.dispatchEvent(Events.Tooltip.HIDE, { hoveredElement }); + }); + } +} diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts index 61f3d44f2d..daebc0869d 100644 --- a/packages/core/src/components/index.ts +++ b/packages/core/src/components/index.ts @@ -7,6 +7,7 @@ export * from "./essentials/tooltip"; export * from "./essentials/tooltip-bar"; export * from "./essentials/tooltip-pie"; export * from "./essentials/tooltip-scatter"; +export * from "./essentials/tooltip-radar"; // GRAPHS export * from "./graphs/bar-simple"; diff --git a/packages/core/src/configuration.ts b/packages/core/src/configuration.ts index 9ab8868c50..66608c71b8 100644 --- a/packages/core/src/configuration.ts +++ b/packages/core/src/configuration.ts @@ -9,6 +9,7 @@ import { PieChartOptions, DonutChartOptions, BubbleChartOptions, + RadarChartOptions, // Components GridOptions, AxesOptions, @@ -274,6 +275,34 @@ const donutChart: DonutChartOptions = Tools.merge({}, pieChart, { } } as DonutChartOptions); +/** + * options specific to radar charts + */ +const radarChart: RadarChartOptions = Tools.merge({}, chart, { + radar: { + axes: { + angle: "key", + value: "value" + }, + opacity: { + unselected: 0.1, + selected: 0.3 + }, + xLabelPadding: 10, + yLabelPadding: 8, + yTicksNumber: 4, + minRange: 10, + xAxisRectHeight: 50, + dotsRadius: 5 + }, + tooltip: { + gridline: { + enabled: true + }, + valueFormatter: value => value !== null && value !== undefined ? value : "N/A" + } +} as RadarChartOptions); + export const options = { chart, axisChart, @@ -284,7 +313,8 @@ export const options = { lineChart, scatterChart, pieChart, - donutChart + donutChart, + radarChart }; /** diff --git a/packages/core/src/interfaces/charts.ts b/packages/core/src/interfaces/charts.ts index 89b220fde2..3d3b77dc3a 100644 --- a/packages/core/src/interfaces/charts.ts +++ b/packages/core/src/interfaces/charts.ts @@ -189,3 +189,25 @@ export interface DonutChartOptions extends PieChartOptions { }; }; } + +/** + * options specific to radar charts + */ +export interface RadarChartOptions extends BaseChartOptions { + radar?: { + opacity: { + unselected: number, + selected: number + }, + axes: { + angle: string, + value: string + } + xLabelPadding: number, + yLabelPadding: number, + yTicksNumber: number, + minRange: number, + xAxisRectHeight: number, + dotsRadius: number + }; +} diff --git a/packages/core/src/interfaces/enums.ts b/packages/core/src/interfaces/enums.ts index 7416ca237d..c6f83571cb 100644 --- a/packages/core/src/interfaces/enums.ts +++ b/packages/core/src/interfaces/enums.ts @@ -104,3 +104,21 @@ export enum CalloutDirections { LEFT = "left", RIGHT = "right" } + +/** + * enum of all possible attributes used to aling text horizontally + */ +export enum TextAnchor { + START = "start", + MIDDLE = "middle", + END = "end" +} + +/** + * enum of all possible attributes used to aling text vertically + */ +export enum DominantBaseline { + BASELINE = "baseline", + MIDDLE = "middle", + HANGING = "hanging" +} diff --git a/packages/core/src/interfaces/events.ts b/packages/core/src/interfaces/events.ts index 93ab6b034a..72c458ada4 100644 --- a/packages/core/src/interfaces/events.ts +++ b/packages/core/src/interfaces/events.ts @@ -63,6 +63,16 @@ export enum Line { POINT_MOUSEOUT = "scatter-mouseout" } +/** + * enum of all radar graph events + */ +export enum Radar { + X_AXIS_MOUSEOVER = "radar-x-axis-mouseover", + X_AXIS_MOUSEMOVE = "radar-x-axis-mousemove", + X_AXIS_CLICK = "radar-x-axis-click", + X_AXIS_MOUSEOUT = "radar-x-axis-mouseout" +} + /** * enum of all tooltip events */ diff --git a/packages/core/src/services/angle-utils.ts b/packages/core/src/services/angle-utils.ts new file mode 100644 index 0000000000..c76aec56e2 --- /dev/null +++ b/packages/core/src/services/angle-utils.ts @@ -0,0 +1,65 @@ +import { TextAnchor, DominantBaseline } from "../interfaces/enums"; + +export interface Point { + x: number; + y: number; +} + +export type Angle = number; + +interface LabelAlignment { + textAnchor: TextAnchor; + dominantBaseline: DominantBaseline; +} + +export function radialLabelPlacement(angleRadians: Angle): LabelAlignment { + const angle = mod(radToDeg(angleRadians), 360); + + if (isInRange(angle, [0, 10]) || isInRange(angle, [350, 0])) { + return { textAnchor: TextAnchor.START, dominantBaseline: DominantBaseline.MIDDLE }; + } else if (isInRange(angle, [10, 80])) { + return { textAnchor: TextAnchor.START, dominantBaseline: DominantBaseline.HANGING }; + } else if (isInRange(angle, [80, 100])) { + return { textAnchor: TextAnchor.MIDDLE, dominantBaseline: DominantBaseline.HANGING }; + } else if (isInRange(angle, [100, 170])) { + return { textAnchor: TextAnchor.END, dominantBaseline: DominantBaseline.HANGING }; + } else if (isInRange(angle, [170, 190])) { + return { textAnchor: TextAnchor.END, dominantBaseline: DominantBaseline.MIDDLE }; + } else if (isInRange(angle, [190, 260])) { + return { textAnchor: TextAnchor.END, dominantBaseline: DominantBaseline.BASELINE }; + } else if (isInRange(angle, [260, 280])) { + return { textAnchor: TextAnchor.MIDDLE, dominantBaseline: DominantBaseline.BASELINE }; + } else { // 280 - 350 + return { textAnchor: TextAnchor.START, dominantBaseline: DominantBaseline.BASELINE }; + } +} + +function mod(n: number, m: number) { + return ((n % m) + m) % m; +} + +function isInRange(x: number, [min, max]: [number, number]) { + return x >= min && x <= max; +} + +export function radToDeg(rad: Angle): Angle { + return rad * (180 / Math.PI); +} + +export function degToRad(deg: Angle): Angle { + return deg * (Math.PI / 180); +} + +export function polarToCartesianCoords(a: Angle, r: number, t: Point = { x: 0, y: 0 }): Point { + const x = r * Math.cos(a) + t.x; + const y = r * Math.sin(a) + t.y; + return { x, y }; +} + +// Return the distance between a point (described with polar coordinates) +// on a circumference and the vertical diameter. +// If the point is on the left if the diameter, its distance is positive, +// if it is on the right of the diameter, its distance is negative. +export function distanceBetweenPointOnCircAndVerticalDiameter(a: Angle, r: number) { + return r * Math.sin(a - Math.PI / 2); +} diff --git a/packages/core/src/styles/graphs/_radar.scss b/packages/core/src/styles/graphs/_radar.scss new file mode 100644 index 0000000000..d461571124 --- /dev/null +++ b/packages/core/src/styles/graphs/_radar.scss @@ -0,0 +1,22 @@ + +.#{$prefix}--#{$charts-prefix}--radar { + .blobs path { + stroke-width: 1.5px; + } + + .y-axes path, + .x-axes line { + stroke-width: 1px; + stroke: $ui-03; + } + + .x-axes line.hovered { + @if $ui-background == map-get($carbon--theme--g90, "ui-background") { + stroke: $carbon--white-0; + } @else if $ui-background == map-get($carbon--theme--g100, "ui-background") { + stroke: $carbon--white-0; + } @else { + stroke: $carbon--black-100; + } + } +} diff --git a/packages/core/src/styles/graphs/index.scss b/packages/core/src/styles/graphs/index.scss index 8edd327212..625897fe3c 100644 --- a/packages/core/src/styles/graphs/index.scss +++ b/packages/core/src/styles/graphs/index.scss @@ -1,3 +1,4 @@ @import "./bubble"; @import "./line"; @import "./scatter"; +@import "./radar"; diff --git a/packages/core/src/tools.ts b/packages/core/src/tools.ts index 2d16b6dd5d..1cbcb2ee61 100644 --- a/packages/core/src/tools.ts +++ b/packages/core/src/tools.ts @@ -12,6 +12,8 @@ import { uniq as lodashUnique, clamp as lodashClamp, isEqual as lodashIsEqual, + flatMapDeep as lodashFlatMapDeep, + kebabCase as lodashKebabCase, // the imports below are needed because of typescript bug (error TS4029) Cancelable, DebounceSettings @@ -26,6 +28,8 @@ export namespace Tools { export const removeArrayDuplicates = lodashUnique; export const clamp = lodashClamp; export const isEqual = lodashIsEqual; + export const flatMapDeep = lodashFlatMapDeep; + export const kebabCase = lodashKebabCase; /** * Returns default chart options merged with provided options,