diff --git a/packages/core/demo/data/index.ts b/packages/core/demo/data/index.ts index 099476b985..c0d14e747d 100644 --- a/packages/core/demo/data/index.ts +++ b/packages/core/demo/data/index.ts @@ -187,6 +187,11 @@ let allDemoGroups = [ chartType: chartTypes.LineChart, isDemoExample: true }, + { + options: lineDemos.lineTimeSeriesDenseOptions, + data: lineDemos.lineTimeSeriesDenseData, + chartType: chartTypes.LineChart + }, { options: lineDemos.lineOptions, data: lineDemos.lineData, diff --git a/packages/core/demo/data/line.ts b/packages/core/demo/data/line.ts index 4eac27597e..5449b5f88f 100644 --- a/packages/core/demo/data/line.ts +++ b/packages/core/demo/data/line.ts @@ -77,6 +77,67 @@ export const lineTimeSeriesOptions = { curve: "curveMonotoneX" }; +export const lineTimeSeriesDenseData = [ + { group: "Dataset 1", date: new Date(2019, 0, 1), value: 10000 }, + { group: "Dataset 1", date: new Date(2019, 0, 1, 5), value: 12000 }, + { group: "Dataset 1", date: new Date(2019, 0, 1, 10), value: 14000 }, + { group: "Dataset 1", date: new Date(2019, 0, 2), value: 25000 }, + { group: "Dataset 1", date: new Date(2019, 0, 2, 2), value: 26000 }, + { group: "Dataset 1", date: new Date(2019, 0, 3), value: 10000 }, + { group: "Dataset 1", date: new Date(2019, 0, 3, 5), value: 10000 }, + { group: "Dataset 1", date: new Date(2019, 0, 3, 10), value: 12000 }, + { group: "Dataset 1", date: new Date(2019, 0, 5), value: 45000 }, + { group: "Dataset 1", date: new Date(2019, 0, 7), value: 49000 }, + { group: "Dataset 1", date: new Date(2019, 0, 7, 15), value: 45000 }, + { group: "Dataset 1", date: new Date(2019, 0, 9), value: 50000 }, + { group: "Dataset 1", date: new Date(2019, 0, 9, 5), value: 52000 }, + { group: "Dataset 1", date: new Date(2019, 0, 9, 15), value: 55000 }, + { group: "Dataset 1", date: new Date(2019, 0, 10), value: 50000 }, + { group: "Dataset 1", date: new Date(2019, 0, 12), value: 65000 }, + { group: "Dataset 1", date: new Date(2019, 0, 13), value: 80000 }, + { group: "Dataset 1", date: new Date(2019, 0, 14, 10), value: 85000 }, + { group: "Dataset 1", date: new Date(2019, 0, 15, 7), value: 90000 }, + { group: "Dataset 1", date: new Date(2019, 0, 15, 18), value: 70000 }, + { group: "Dataset 2", date: new Date(2019, 0, 1), value: 20000 }, + { group: "Dataset 2", date: new Date(2019, 0, 1, 3), value: 22000 }, + { group: "Dataset 2", date: new Date(2019, 0, 1, 16), value: 24000 }, + { group: "Dataset 2", date: new Date(2019, 0, 2), value: 35000 }, + { group: "Dataset 2", date: new Date(2019, 0, 2, 7), value: 36000 }, + { group: "Dataset 2", date: new Date(2019, 0, 3), value: 20000 }, + { group: "Dataset 2", date: new Date(2019, 0, 3, 6), value: 20000 }, + { group: "Dataset 2", date: new Date(2019, 0, 3, 18), value: 22000 }, + { group: "Dataset 2", date: new Date(2019, 0, 5), value: 62000 }, + { group: "Dataset 2", date: new Date(2019, 0, 6), value: 52000 }, + { group: "Dataset 2", date: new Date(2019, 0, 7), value: 52000 }, + { group: "Dataset 2", date: new Date(2019, 0, 7, 15), value: 52000 }, + { group: "Dataset 2", date: new Date(2019, 0, 9), value: 60000 }, + { group: "Dataset 2", date: new Date(2019, 0, 9, 5), value: 62000 }, + { group: "Dataset 2", date: new Date(2019, 0, 9, 10), value: 62000 }, + { group: "Dataset 2", date: new Date(2019, 0, 12), value: 65000 }, + { group: "Dataset 2", date: new Date(2019, 0, 14), value: 40000 }, + { group: "Dataset 2", date: new Date(2019, 0, 15, 5), value: 45000 }, + { group: "Dataset 2", date: new Date(2019, 0, 15, 10), value: 35000 }, + { group: "Dataset 2", date: new Date(2019, 0, 15, 18), value: 30000 } +]; + + +export const lineTimeSeriesDenseOptions = { + title: "Line (dense time series)", + axes: { + bottom: { + title: "2019 Annual Sales Figures", + mapsTo: "date", + scaleType: "time" + }, + left: { + mapsTo: "value", + title: "Conversion rate", + scaleType: "linear" + } + }, + curve: "curveMonotoneX" +}; + export const lineTimeSeriesDataRotatedTicks = [ { group: "Dataset 1", date: new Date(2019, 11, 30), value: 32100 }, { group: "Dataset 1", date: new Date(2019, 11, 31), value: 23500 }, diff --git a/packages/core/src/charts/bubble.ts b/packages/core/src/charts/bubble.ts index 8f77853174..a0c7458109 100644 --- a/packages/core/src/charts/bubble.ts +++ b/packages/core/src/charts/bubble.ts @@ -10,7 +10,7 @@ import { Tools } from "../tools"; // Components import { Grid, - Line, + Ruler, Bubble, TwoDimensionalAxes, // the imports below are needed because of typescript bug (error TS4029) @@ -42,6 +42,7 @@ export class BubbleChart extends AxisChart { const graphFrameComponents = [ new TwoDimensionalAxes(this.model, this.services), new Grid(this.model, this.services), + new Ruler(this.model, this.services), new Bubble(this.model, this.services) ]; diff --git a/packages/core/src/charts/line.ts b/packages/core/src/charts/line.ts index 6944a59782..a6e1130fa2 100644 --- a/packages/core/src/charts/line.ts +++ b/packages/core/src/charts/line.ts @@ -11,6 +11,7 @@ import { Tools } from "../tools"; import { Grid, Line, + Ruler, Scatter, TwoDimensionalAxes, // the imports below are needed because of typescript bug (error TS4029) @@ -42,6 +43,7 @@ export class LineChart extends AxisChart { const graphFrameComponents = [ new TwoDimensionalAxes(this.model, this.services), new Grid(this.model, this.services), + new Ruler(this.model, this.services), new Line(this.model, this.services), new Scatter(this.model, this.services) ]; diff --git a/packages/core/src/charts/scatter.ts b/packages/core/src/charts/scatter.ts index ce54703093..6c3927f050 100644 --- a/packages/core/src/charts/scatter.ts +++ b/packages/core/src/charts/scatter.ts @@ -10,7 +10,7 @@ import { Tools } from "../tools"; // Components import { Grid, - Line, + Ruler, Scatter, TwoDimensionalAxes, // the imports below are needed because of typescript bug (error TS4029) @@ -42,6 +42,7 @@ export class ScatterChart extends AxisChart { const graphFrameComponents = [ new TwoDimensionalAxes(this.model, this.services), new Grid(this.model, this.services), + new Ruler(this.model, this.services), new Scatter(this.model, this.services) ]; diff --git a/packages/core/src/components/axes/grid.ts b/packages/core/src/components/axes/grid.ts index 58a33101c2..64d05b6bbf 100644 --- a/packages/core/src/components/axes/grid.ts +++ b/packages/core/src/components/axes/grid.ts @@ -21,10 +21,6 @@ export class Grid extends Component { this.drawXGrid(); this.drawYGrid(); - - if (Tools.getProperty(this.model.getOptions(), "tooltip", "gridline", "enabled")) { - this.addGridEventListeners(); - } } drawXGrid() { @@ -141,48 +137,6 @@ export class Grid extends Component { return xGridlines; } - /** - * Adds the listener on the X grid to trigger multiple point tooltips along the x axis. - */ - addGridEventListeners() { - const self = this; - const svg = this.parent; - const grid = DOMUtils.appendOrSelect(svg, "rect.chart-grid-backdrop"); - - grid.on("mousemove mouseover", function() { - const chartContainer = self.services.domUtils.getMainSVG(); - const pos = mouse(chartContainer); - const hoveredElement = select(this); - - // remove the styling on the lines - const allgridlines = svg.selectAll(".x.grid .tick"); - allgridlines.classed("active", false); - - const activeGridline = self.getActiveGridline(pos); - if (activeGridline.empty()) { - return self.services.events.dispatchEvent(Events.Tooltip.HIDE); - } - - // set active class to control dasharray and theme colors - activeGridline.classed("active", true); - - // get the items that should be highlighted - const itemsToHighlight = self.services.cartesianScales.getDataFromDomain(activeGridline.datum()); - - self.services.events.dispatchEvent(Events.Tooltip.SHOW, { - hoveredElement, - multidata: itemsToHighlight, - type: TooltipTypes.GRIDLINE - }); - }) - .on("mouseout", function() { - svg.selectAll(".x.grid .tick") - .classed("active", false); - - self.services.events.dispatchEvent(Events.Tooltip.HIDE); - }); - } - drawBackdrop() { const svg = this.parent; diff --git a/packages/core/src/components/axes/ruler.ts b/packages/core/src/components/axes/ruler.ts new file mode 100644 index 0000000000..741333490f --- /dev/null +++ b/packages/core/src/components/axes/ruler.ts @@ -0,0 +1,195 @@ +// Internal Imports +import { Component } from "../component"; +import { DOMUtils } from "../../services"; +import { TooltipTypes, ScaleTypes } from "../../interfaces"; +import { Tools } from "../../tools"; + +// D3 Imports +import { mouse, Selection } from "d3-selection"; +import { scaleLinear } from "d3-scale"; + +type GenericSvgSelection = Selection; + +const THRESHOLD = 5; + +/** check if x is inside threshold area extents */ +function pointIsWithinThreshold(dx: number, x: number) { + return dx > x - THRESHOLD && dx < x + THRESHOLD; +} + +/** + * a compatibility function that accepts ordinal scales too + * as those do not support .invert() by default, + * so a scale clone is created to invert domain with range + */ +function invertedScale(scale) { + if (scale.invert) { + return scale.invert; + } + + return scaleLinear() + .domain(scale.range()) + .range(scale.domain()); +} + +export class Ruler extends Component { + type = "ruler"; + backdrop: GenericSvgSelection; + hoveredElements: GenericSvgSelection; + + render() { + this.drawBackdrop(); + this.addBackdropEventListeners(); + } + + showRuler([x, y]: [number, number]) { + const svg = this.parent; + const ruler = DOMUtils.appendOrSelect(svg, "g.ruler"); + const line = DOMUtils.appendOrSelect(ruler, "line.ruler-line"); + const dataPointElements: GenericSvgSelection = svg.selectAll( + "[role=graphics-symbol]" + ); + const displayData = this.model.getDisplayData(); + const domainScale = this.services.cartesianScales.getDomainScale(); + const rangeScale = this.services.cartesianScales.getRangeScale(); + const [yScaleEnd, yScaleStart] = rangeScale.range(); + + const scaledData: {domainValue: number, originalData: any}[] = displayData.map((d, i) => ({ + domainValue: this.services.cartesianScales.getDomainValue(d, i), + originalData: d + })); + + /** + * Find matches, reduce is used instead of filter + * to only get elements which belong to the same axis coordinate + */ + const dataPointsMatchingRulerLine: {domainValue: number, originalData: any}[] = + scaledData.reduce((accum, currentValue) => { + // store the first element of the accumulator array to compare it with current element being processed + const sampleAccumValue = accum[0] ? accum[0].domainValue : undefined; + + // if accumulator is not empty and current value is bigger than already existing value in the accumulator, skip current iteration + if (sampleAccumValue !== undefined && currentValue.domainValue > sampleAccumValue) { + return accum; + } + + // there's a match and currentValue is either less then or equal to already stored values + if (pointIsWithinThreshold(currentValue.domainValue, x)) { + if (sampleAccumValue !== undefined && currentValue < sampleAccumValue) { + // there's a closer data point in the threshold area, so reinstantiate array + accum = [currentValue]; + } else { + // currentValue is equal to already stored values, there's another match on the same coordinate + accum.push(currentValue); + } + } + + return accum; + }, []); + + // some data point match + if (dataPointsMatchingRulerLine.length > 0) { + const highlightItems = dataPointsMatchingRulerLine.map(d => d.originalData) + .filter(d => { + const rangeIdentifier = this.services.cartesianScales.getRangeIdentifier(); + const value = d[rangeIdentifier]; + return value !== null && value !== undefined; + }); + + // get elements on which we should trigger mouse events + const hoveredElements = dataPointElements.filter((d, i) => + dataPointsMatchingRulerLine.includes(d) + ); + + /** if we pass from a trigger area to another one + * mouseout on previous elements won't get dispatched + * so we need to do it manually + */ + if ( + this.hoveredElements && + this.hoveredElements.size() > 0 && + !Tools.isEqual(this.hoveredElements, hoveredElements) + ) { + this.hideRuler(); + } + + hoveredElements.dispatch("mouseover"); + + // set current hovered elements + this.hoveredElements = hoveredElements; + + this.services.events.dispatchEvent("show-tooltip", { + hoveredElement: line, + multidata: highlightItems, + type: TooltipTypes.GRIDLINE + }); + + ruler.attr("opacity", 1); + + // line snaps to matching point + const sampleMatch = dataPointsMatchingRulerLine[0]; + line.attr("y1", yScaleStart) + .attr("y2", yScaleEnd) + .attr("x1", sampleMatch.domainValue) + .attr("x2", sampleMatch.domainValue); + } else { + ruler.attr("opacity", 0); + dataPointElements.dispatch("mouseout"); + } + } + + hideRuler() { + const svg = this.parent; + const ruler = DOMUtils.appendOrSelect(svg, "g.ruler"); + const dataPointElements = svg.selectAll("[role=graphics-symbol]"); + + dataPointElements.dispatch("mouseout"); + ruler.attr("opacity", 0); + } + + /** + * Adds the listener on the X grid to trigger multiple point tooltips along the x axis. + */ + addBackdropEventListeners() { + const self = this; + + this.backdrop + .on("mousemove mouseover", function() { + const chartContainer = self.services.domUtils.getMainSVG(); + const pos = mouse(chartContainer); + + self.showRuler(pos); + }) + .on("mouseout", function() { + self.hideRuler(); + self.services.events.dispatchEvent("hide-tooltip"); + }); + } + + drawBackdrop() { + const svg = this.parent; + + const domainScale = this.services.cartesianScales.getDomainScale(); + const rangeScale = this.services.cartesianScales.getRangeScale(); + + const [xScaleStart, xScaleEnd] = domainScale.range(); + const [yScaleEnd, yScaleStart] = rangeScale.range(); + + // Get height from the grid + this.backdrop = DOMUtils.appendOrSelect(svg, "svg.chart-grid-backdrop"); + const backdropRect = DOMUtils.appendOrSelect( + this.backdrop, + "rect.chart-grid-backdrop" + ); + + this.backdrop + .merge(backdropRect) + .attr("x", xScaleStart) + .attr("y", yScaleStart) + .attr("width", xScaleEnd - xScaleStart) + .attr("height", yScaleEnd - yScaleStart) + .lower(); + + backdropRect.attr("width", "100%").attr("height", "100%"); + } +} diff --git a/packages/core/src/components/graphs/bubble.ts b/packages/core/src/components/graphs/bubble.ts index dcde6a8a64..b277370270 100644 --- a/packages/core/src/components/graphs/bubble.ts +++ b/packages/core/src/components/graphs/bubble.ts @@ -1,6 +1,7 @@ // Internal Imports import { Scatter } from "./scatter"; import { DOMUtils } from "../../services"; +import { Roles } from "../../interfaces"; // D3 Imports import { Selection } from "d3-selection"; @@ -39,6 +40,7 @@ export class Bubble extends Scatter { selection.raise() .classed("dot", true) + .attr("role", Roles.GRAPHICS_SYMBOL) .attr("cx", (d, i) => this.services.cartesianScales.getDomainValue(d, i)) .transition(this.services.transitions.getTransition("bubble-update-enter", animate)) .attr("cy", (d, i) => this.services.cartesianScales.getRangeValue(d, i)) diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts index c323f462c5..61f3d44f2d 100644 --- a/packages/core/src/components/index.ts +++ b/packages/core/src/components/index.ts @@ -26,4 +26,5 @@ export * from "./layout/layout"; export * from "./axes/two-dimensional-axes"; export * from "./axes/axis"; export * from "./axes/grid"; +export * from "./axes/ruler"; export * from "./axes/zero-line"; diff --git a/packages/core/src/configuration.ts b/packages/core/src/configuration.ts index c04f3b49b7..9ab8868c50 100644 --- a/packages/core/src/configuration.ts +++ b/packages/core/src/configuration.ts @@ -79,7 +79,7 @@ export const baseTooltip: TooltipOptions = { export const axisChartTooltip: AxisTooltipOptions = Tools.merge({}, baseTooltip, { gridline: { enabled: true, - threshold: 0.25 + threshold: 0.02 } } as AxisTooltipOptions); diff --git a/packages/core/src/styles/components/_ruler.scss b/packages/core/src/styles/components/_ruler.scss new file mode 100644 index 0000000000..dd2530e4b6 --- /dev/null +++ b/packages/core/src/styles/components/_ruler.scss @@ -0,0 +1,21 @@ +.#{$prefix}--#{$charts-prefix}--ruler { + line.ruler-line { + @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; + } + + stroke-width: 1px; + stroke-dasharray: 2; + pointer-events: none; + } + + text.axis-tooltip-text { + fill: $carbon--white-0; + dominant-baseline: middle; + text-anchor: middle; + } +} diff --git a/packages/core/src/styles/components/index.scss b/packages/core/src/styles/components/index.scss index 8823767eb7..94854a2fa3 100644 --- a/packages/core/src/styles/components/index.scss +++ b/packages/core/src/styles/components/index.scss @@ -1,6 +1,7 @@ @import "./axis"; @import "./callouts"; @import "./grid"; +@import "./ruler"; @import "./zero-line"; @import "./layout"; @import "./legend"; diff --git a/packages/core/src/tools.ts b/packages/core/src/tools.ts index 2fb852e6eb..4d1d71a2f0 100644 --- a/packages/core/src/tools.ts +++ b/packages/core/src/tools.ts @@ -11,6 +11,7 @@ import { cloneDeep as lodashCloneDeep, uniq as lodashUnique, clamp as lodashClamp, + isEqual as lodashIsEqual, // the imports below are needed because of typescript bug (error TS4029) Cancelable, DebounceSettings @@ -24,6 +25,7 @@ export namespace Tools { export const merge = lodashMerge; export const removeArrayDuplicates = lodashUnique; export const clamp = lodashClamp; + export const isEqual = lodashIsEqual; /** * Returns default chart options merged with provided options,