diff --git a/ui/app/components/chart-primitives/area.js b/ui/app/components/chart-primitives/area.js index 07d02de1e..c8e033ae6 100644 --- a/ui/app/components/chart-primitives/area.js +++ b/ui/app/components/chart-primitives/area.js @@ -5,7 +5,10 @@ import uniquely from 'nomad-ui/utils/properties/uniquely'; export default class ChartPrimitiveArea extends Component { get colorClass() { - return this.args.colorClass || `${this.args.colorScale}-${this.args.index}`; + if (this.args.colorClass) return this.args.colorClass; + if (this.args.colorScale && this.args.index != null) + return `${this.args.colorScale} ${this.args.colorScale}-${this.args.index + 1}`; + return 'is-primary'; } @uniquely('area-mask') maskId; diff --git a/ui/app/components/chart-primitives/h-annotations.hbs b/ui/app/components/chart-primitives/h-annotations.hbs new file mode 100644 index 000000000..78999e2b4 --- /dev/null +++ b/ui/app/components/chart-primitives/h-annotations.hbs @@ -0,0 +1,14 @@ +
+ {{#each this.processed key=@key as |annotation|}} +
+ +
+
+ {{/each}} +
diff --git a/ui/app/components/chart-primitives/h-annotations.js b/ui/app/components/chart-primitives/h-annotations.js new file mode 100644 index 000000000..e189fc23c --- /dev/null +++ b/ui/app/components/chart-primitives/h-annotations.js @@ -0,0 +1,49 @@ +import Component from '@glimmer/component'; +import { htmlSafe } from '@ember/template'; +import { action, get } from '@ember/object'; +import styleString from 'nomad-ui/utils/properties/glimmer-style-string'; + +export default class ChartPrimitiveVAnnotations extends Component { + @styleString + get chartAnnotationsStyle() { + return { + width: this.args.width, + left: this.args.left, + }; + } + + get processed() { + const { scale, prop, annotations, format, labelProp } = this.args; + + if (!annotations || !annotations.length) return null; + + let sortedAnnotations = annotations.sortBy(prop).reverse(); + + return sortedAnnotations.map(annotation => { + const y = scale(annotation[prop]); + const x = 0; + const formattedY = format()(annotation[prop]); + + return { + annotation, + style: htmlSafe(`transform:translate(${x}px,${y}px)`), + label: annotation[labelProp], + a11yLabel: `${annotation[labelProp]} at ${formattedY}`, + isActive: this.annotationIsActive(annotation), + }; + }); + } + + annotationIsActive(annotation) { + const { key, activeAnnotation } = this.args; + if (!activeAnnotation) return false; + + if (key) return get(annotation, key) === get(activeAnnotation, key); + return annotation === activeAnnotation; + } + + @action + selectAnnotation(annotation) { + if (this.args.annotationClick) this.args.annotationClick(annotation); + } +} diff --git a/ui/app/components/chart-primitives/tooltip.hbs b/ui/app/components/chart-primitives/tooltip.hbs new file mode 100644 index 000000000..e1718c27d --- /dev/null +++ b/ui/app/components/chart-primitives/tooltip.hbs @@ -0,0 +1,7 @@ +
+
    + {{#each @data as |props|}} + {{yield props.series props.datum (inc props.index)}} + {{/each}} +
+
diff --git a/ui/app/components/chart-primitives/v-annotations.hbs b/ui/app/components/chart-primitives/v-annotations.hbs index f7b13a1d1..7b50ec745 100644 --- a/ui/app/components/chart-primitives/v-annotations.hbs +++ b/ui/app/components/chart-primitives/v-annotations.hbs @@ -1,13 +1,10 @@
{{#each this.processed key=@key as |annotation|}} -
+
diff --git a/ui/app/components/chart-primitives/v-annotations.js b/ui/app/components/chart-primitives/v-annotations.js index f05481778..a49c0f268 100644 --- a/ui/app/components/chart-primitives/v-annotations.js +++ b/ui/app/components/chart-primitives/v-annotations.js @@ -1,6 +1,6 @@ import Component from '@glimmer/component'; import { htmlSafe } from '@ember/template'; -import { action } from '@ember/object'; +import { action, get } from '@ember/object'; import styleString from 'nomad-ui/utils/properties/glimmer-style-string'; const iconFor = { @@ -51,10 +51,20 @@ export default class ChartPrimitiveVAnnotations extends Component { iconClass: iconClassFor[annotation.type], staggerClass: prevHigh ? 'is-staggered' : '', label: `${annotation.type} event at ${formattedX}`, + isActive: this.annotationIsActive(annotation), }; }); } + annotationIsActive(annotation) { + const { key, activeAnnotation } = this.args; + console.log(key, activeAnnotation, annotation); + if (!activeAnnotation) return false; + + if (key) return get(annotation, key) === get(activeAnnotation, key); + return annotation === activeAnnotation; + } + @action selectAnnotation(annotation) { if (this.args.annotationClick) this.args.annotationClick(annotation); diff --git a/ui/app/components/line-chart.js b/ui/app/components/line-chart.js index ec8d88852..995df3f81 100644 --- a/ui/app/components/line-chart.js +++ b/ui/app/components/line-chart.js @@ -68,6 +68,7 @@ export default class LineChart extends Component { @tracked height = 0; @tracked isActive = false; @tracked activeDatum = null; + @tracked activeData = []; @tracked tooltipPosition = null; @tracked element = null; @tracked ready = false; @@ -82,7 +83,11 @@ export default class LineChart extends Component { return this.args.yProp || 'value'; } get data() { - return this.args.data || []; + if (!this.args.data) return []; + if (this.args.dataProp) { + return this.args.data.mapBy(this.args.dataProp).flat(); + } + return this.args.data; } get curve() { return this.args.curve || 'linear'; @@ -188,7 +193,7 @@ export default class LineChart extends Component { .axisRight() .scale(this.yScale) .tickValues(ticks) - .tickSize(-this.yAxisOffset) + .tickSize(-this.canvasDimensions.width) .tickFormat(''); } @@ -216,6 +221,12 @@ export default class LineChart extends Component { return Math.max(0, this.width - this.yAxisWidth); } + get canvasDimensions() { + const [left, right] = this.xScale.range(); + const [top, bottom] = this.yScale.range(); + return { left, width: right - left, top, height: bottom - top }; + } + @action onInsert(element) { this.element = element; @@ -241,37 +252,72 @@ export default class LineChart extends Component { canvas.on('mouseleave', () => { run.schedule('afterRender', this, () => (this.isActive = false)); this.activeDatum = null; + this.activeData = []; }); } updateActiveDatum(mouseX) { - const { xScale, xProp, yScale, yProp, data } = this; + if (!this.data || !this.data.length) return; - if (!data || !data.length) return; + const { xScale, xProp, yScale, yProp } = this; + let { dataProp, data } = this.args; - // Map the mouse coordinate to the index in the data array - const bisector = d3Array.bisector(d => d[xProp]).left; - const x = xScale.invert(mouseX); - const index = bisector(data, x, 1); - - // The data point on either side of the cursor - const dLeft = data[index - 1]; - const dRight = data[index]; - - let datum; - - // If there is only one point, it's the activeDatum - if (dLeft && !dRight) { - datum = dLeft; - } else { - // Pick the closer point - datum = x - dLeft[xProp] > dRight[xProp] - x ? dRight : dLeft; + if (!dataProp) { + dataProp = 'data'; + data = [{ data: this.data }]; } - this.activeDatum = datum; + // Map screen coordinates to data domain + const bisector = d3Array.bisector(d => d[xProp]).left; + const x = xScale.invert(mouseX); + + // Find the closest datum to the cursor for each series + const activeData = data.map((series, seriesIndex) => { + const dataset = series[dataProp]; + const index = bisector(dataset, x, 1); + + // The data point on either side of the cursor + const dLeft = dataset[index - 1]; + const dRight = dataset[index]; + + let datum; + + // If there is only one point, it's the activeDatum + if (dLeft && !dRight) { + datum = dLeft; + } else { + // Pick the closer point + datum = x - dLeft[xProp] > dRight[xProp] - x ? dRight : dLeft; + } + + return { + series, + datum: { + formattedX: this.xFormat(this.args.timeseries)(datum[xProp]), + formattedY: this.yFormat()(datum[yProp]), + datum, + }, + index: seriesIndex, + }; + }); + + // Of the selected data, determine which is closest + const closestDatum = activeData.sort( + (a, b) => Math.abs(a.datum.datum[xProp] - x) - Math.abs(b.datum.datum[xProp] - x) + )[0]; + + // If any other selected data are beyond a distance threshold, drop them from the list + // xScale is used here to measure distance in screen-space rather than data-space. + const dist = Math.abs(xScale(closestDatum.datum.datum[xProp]) - mouseX); + const filteredData = activeData.filter( + d => Math.abs(xScale(d.datum.datum[xProp]) - mouseX) < dist + 10 + ); + + this.activeData = filteredData; + this.activeDatum = closestDatum.datum.datum; this.tooltipPosition = { - left: xScale(datum[xProp]), - top: yScale(datum[yProp]) - 10, + left: xScale(this.activeDatum[xProp]), + top: yScale(this.activeDatum[yProp]) - 10, }; } diff --git a/ui/app/styles/charts/chart-annotation.scss b/ui/app/styles/charts/chart-annotation.scss index 958027271..f88ecb283 100644 --- a/ui/app/styles/charts/chart-annotation.scss +++ b/ui/app/styles/charts/chart-annotation.scss @@ -1,4 +1,4 @@ -.chart-annotation { +.chart-vertical-annotation { position: absolute; height: 100%; @@ -53,3 +53,31 @@ z-index: -1; } } + +.chart-horizontal-annotation { + position: absolute; + width: 100%; + + .indicator { + transform: translateY(-50%); + display: block; + border: none; + border-radius: 100px; + background: $red; + color: $white; + font-weight: $weight-semibold; + font-size: $size-7; + margin-left: 10px; + pointer-events: auto; + } + + .line { + position: absolute; + left: 0; + top: 0; + height: 1px; + width: 100%; + background: $red; + z-index: -1; + } +} diff --git a/ui/app/styles/charts/colors.scss b/ui/app/styles/charts/colors.scss index dce0a3ddf..50f370607 100644 --- a/ui/app/styles/charts/colors.scss +++ b/ui/app/styles/charts/colors.scss @@ -58,6 +58,18 @@ $lost: $dark; } } + @each $name, $scale in $chart-scales { + &.swatch-#{$name} { + background: nth($scale, -1); + } + + @each $color in $scale { + &.swatch-#{$name}-#{index($scale, $color)} { + background: $color; + } + } + } + &.queued { box-shadow: inset 0 0 0 1px rgba($black, 0.3); background: $queued; diff --git a/ui/app/styles/charts/line-chart.scss b/ui/app/styles/charts/line-chart.scss index 3f447c58a..798beba6c 100644 --- a/ui/app/styles/charts/line-chart.scss +++ b/ui/app/styles/charts/line-chart.scss @@ -1,3 +1,17 @@ +@mixin standard-gradient($class, $color) { + linearGradient.#{$class} { + > .start { + stop-color: $color; + stop-opacity: 0.6; + } + + > .end { + stop-color: $color; + stop-opacity: 0.05; + } + } +} + .chart.line-chart { display: block; height: 100%; @@ -82,4 +96,18 @@ } } } + + @each $name, $scale in $chart-scales { + @include standard-gradient($name, nth($scale, -1)); + .area.#{$name} .line { + stroke: nth($scale, -1); + } + + @each $color in $scale { + @include standard-gradient((#{$name}-#{index($scale, $color)}), $color); + .area.#{$name}-#{index($scale, $color)} .line { + stroke: $color; + } + } + } } diff --git a/ui/app/styles/charts/tooltip.scss b/ui/app/styles/charts/tooltip.scss index 82f74958b..81a727b18 100644 --- a/ui/app/styles/charts/tooltip.scss +++ b/ui/app/styles/charts/tooltip.scss @@ -67,8 +67,7 @@ } } - ol > li, - p { + ol > li { display: flex; flex-flow: row; flex-wrap: nowrap; @@ -93,14 +92,18 @@ .value { padding-left: 1em; } + + + li { + border-top: 1px solid $grey-light; + } } - ol > li { - .label { + &.with-active-datum { + li .label { color: rgba($black, 0.6); } - &.active { + li.is-active { color: $black; background: $white-ter; @@ -108,9 +111,5 @@ color: $black; } } - - + li { - border-top: 1px solid $grey-light; - } } } diff --git a/ui/app/styles/core.scss b/ui/app/styles/core.scss index db9fdb95d..786e83e1c 100644 --- a/ui/app/styles/core.scss +++ b/ui/app/styles/core.scss @@ -2,7 +2,6 @@ @import './utils/reset.scss'; @import './utils/z-indices'; @import './utils/product-colors'; -@import './utils/structure-colors'; @import './utils/bumper'; @import './utils/layout'; @@ -15,6 +14,8 @@ // Bring in the rest of Bulma @import 'bulma/bulma'; +@import './utils/structure-colors'; + // Override Bulma details where appropriate @import './core/buttons'; @import './core/breadcrumb'; diff --git a/ui/app/styles/storybook.scss b/ui/app/styles/storybook.scss index 4362e8fd2..e14d7c167 100644 --- a/ui/app/styles/storybook.scss +++ b/ui/app/styles/storybook.scss @@ -122,6 +122,17 @@ flex-wrap: wrap; align-items: center; justify-content: center; + + &.with-spacing { + > * { + margin-right: 1em; + margin-bottom: 1em; + } + } + + &.is-left-aligned { + justify-content: flex-start; + } } .chart-container { @@ -162,4 +173,17 @@ } } } + + .mock-hover-region { + width: 200px; + height: 100px; + position: relative; + border-radius: $radius; + margin: 1em 0; + padding: 1em; + border: 1px solid $grey-blue; + background: $white-ter; + color: $grey; + font-weight: $weight-bold; + } } diff --git a/ui/app/styles/utils/structure-colors.scss b/ui/app/styles/utils/structure-colors.scss index 270379ccc..b62158fa7 100644 --- a/ui/app/styles/utils/structure-colors.scss +++ b/ui/app/styles/utils/structure-colors.scss @@ -8,9 +8,15 @@ $ui-gray-800: #373a42; $ui-gray-900: #1f2124; $red-500: #c73445; +$red-400: #d15866; $red-300: #db7d88; $red-200: #e5a2aa; +$blue-500: #1563ff; +$blue-400: #387aff; +$blue-300: #5b92ff; +$blue-200: #8ab1ff; + $teal-500: #25ba81; $teal-300: #74d3ae; $teal-200: #9bdfc5; @@ -25,3 +31,13 @@ $yellow-400: #face30; $yellow-700: #a07d02; $green-500: #2eb039; + +// Chart Color Scales +$chart-reds: $red-500, $red-400, $red-300, $red-200; +$chart-blues: $blue-500, $blue-400, $blue-300, $blue-200; +$chart-ordinal: $orange, $yellow, $green, $turquoise, $blue, $purple, $red; +$chart-scales: ( + 'reds': $chart-reds, + 'blues': $chart-blues, + 'ordinal': $chart-ordinal, +); diff --git a/ui/app/templates/components/distribution-bar.hbs b/ui/app/templates/components/distribution-bar.hbs index 5b366a0ef..9f42b9c7c 100644 --- a/ui/app/templates/components/distribution-bar.hbs +++ b/ui/app/templates/components/distribution-bar.hbs @@ -12,10 +12,10 @@ activeDatum=this.activeDatum )}} {{else}} -
+
    {{#each this._data as |datum index|}} -
  1. +
  2. {{datum.label}} diff --git a/ui/app/templates/components/line-chart.hbs b/ui/app/templates/components/line-chart.hbs index 361baba3e..7927fe2c6 100644 --- a/ui/app/templates/components/line-chart.hbs +++ b/ui/app/templates/components/line-chart.hbs @@ -18,7 +18,6 @@ {{#if this.ready}} {{yield (hash Area=(component "chart-primitives/area" - colorClass=this.chartClass curve="linear" xScale=this.xScale yScale=this.yScale @@ -30,7 +29,7 @@ {{/if}} - + {{#if this.ready}} {{yield (hash @@ -40,15 +39,16 @@ scale=this.xScale prop=this.xProp height=this.xAxisOffset) + HAnnotations=(component "chart-primitives/h-annotations" + format=this.yFormat + scale=this.yScale + prop=this.yProp + left=this.canvasDimensions.left + width=this.canvasDimensions.width) + Tooltip=(component "chart-primitives/tooltip" + active=this.activeData.length + style=this.tooltipStyle + data=this.activeData) ) to="after"}} {{/if}} -
    -

    - - - {{this.activeDatumLabel}} - - {{this.activeDatumValue}} -

    -
diff --git a/ui/app/templates/components/scale-events-chart.hbs b/ui/app/templates/components/scale-events-chart.hbs index 7a60a1803..20d188d52 100644 --- a/ui/app/templates/components/scale-events-chart.hbs +++ b/ui/app/templates/components/scale-events-chart.hbs @@ -9,6 +9,12 @@ @data={{this.data}} /> <:after as |c|> + +
  • + {{datum.formattedX}} + {{datum.formattedY}} +
  • +
    + <:after as |c|> + +
  • + {{datum.formattedX}} + {{datum.formattedY}} +
  • +
    + diff --git a/ui/stories/charts/line-chart.stories.js b/ui/stories/charts/line-chart.stories.js index 4f1f9219f..d4ca1792f 100644 --- a/ui/stories/charts/line-chart.stories.js +++ b/ui/stories/charts/line-chart.stories.js @@ -44,15 +44,31 @@ export let Standard = () => { <:svg as |c|> + <:after as |c|> + +
  • + {{datum.formattedX}} + {{datum.formattedY}} +
  • +
    + {{/if}}
    {{#if this.lineChartMild}} - + <:svg as |c|> - + + <:after as |c|> + +
  • + {{datum.formattedX}} + {{datum.formattedY}} +
  • +
    +
    {{/if}}
    @@ -70,19 +86,35 @@ export let FluidWidth = () => {
    Fluid-width Line Chart
    {{#if this.lineChartData}} - + <:svg as |c|> - + + <:after as |c|> + +
  • + {{datum.formattedX}} + {{datum.formattedY}} +
  • +
    +
    {{/if}}
    {{#if this.lineChartMild}} - + <:svg as |c|> - + + <:after as |c|> + +
  • + {{datum.formattedX}} + {{datum.formattedY}} +
  • +
    +
    {{/if}}
    @@ -106,7 +138,6 @@ export let LiveData = () => { @xProp="ts" @yProp="val" @timeseries={{true}} - @chartClass="is-primary" @xFormat={{this.controller.secondsFormat}}> <:svg as |c|> @@ -152,10 +183,18 @@ export let Gaps = () => {
    Line Chart data with gaps
    {{#if this.lineChartGapData}} - + <:svg as |c|> + <:after as |c|> + +
  • + {{datum.formattedX}} + {{datum.formattedY}} +
  • +
    +
    {{/if}}
    @@ -176,7 +215,7 @@ export let Gaps = () => { }; }; -export let Annotations = () => { +export let VerticalAnnotations = () => { return { template: hbs`
    Line Chart data with annotations
    @@ -218,6 +257,12 @@ export let Annotations = () => { @annotations={{this.annotations}} @annotationClick={{action (mut this.activeAnnotation)}} @activeAnnotation={{this.activeAnnotation}} /> + +
  • + {{datum.formattedX}} + {{datum.formattedY}} +
  • +
    {{/if}} @@ -278,6 +323,50 @@ export let Annotations = () => { }; }; +export let HorizontalAnnotations = () => { + return { + template: hbs` +
    + {{#if (and this.data this.annotations)}} + + <:svg as |c|> + + + <:after as |c|> + + + + {{/if}} +
    + `, + context: { + data: DelayedArray.create( + new Array(180).fill(null).map((_, idx) => ({ + y: Math.sin((idx * 4 * Math.PI) / 180) * 100 + 200, + x: moment() + .add(idx, 'd') + .toDate(), + })) + ), + annotations: [ + { + y: 300, + info: 'High', + }, + { + y: 100, + info: 'Low', + }, + ], + }, + }; +}; + export let StepLine = () => { return { template: hbs` @@ -291,6 +380,14 @@ export let StepLine = () => { <:svg as |c|> + <:after as |c|> + +
  • + {{datum.formattedX}} + {{datum.formattedY}} +
  • +
    +

    {{this.activeAnnotation.info}}

    {{/if}} @@ -311,3 +408,61 @@ export let StepLine = () => { }, }; }; + +export let MultiLine = () => ({ + template: hbs` +
    Multiple Lines on One Chart
    +
    + {{#if this.data}} + + <:svg as |c|> + {{#each this.data as |series idx|}} + + {{/each}} + + <:after as |c|> + +
  • + {{series.name}} + {{datum.formattedY}} +
  • +
    + +
    +

    {{this.activeAnnotation.info}}

    + {{/if}} +
    + `, + context: { + data: DelayedArray.create([ + { + name: 'Series 1', + data: [ + { x: 3, y: 7 }, + { x: 4, y: 5 }, + { x: 5, y: 8 }, + { x: 6, y: 9 }, + { x: 7, y: 10 }, + { x: 8, y: 8 }, + { x: 9, y: 6 }, + ], + }, + { + name: 'Series 2', + data: [ + { x: 1, y: 5 }, + { x: 2, y: 1 }, + { x: 3, y: 2 }, + { x: 4, y: 2 }, + { x: 5, y: 9 }, + { x: 6, y: 3 }, + { x: 7, y: 4 }, + ], + }, + ]), + }, +}); diff --git a/ui/stories/charts/primitives.stories.js b/ui/stories/charts/primitives.stories.js new file mode 100644 index 000000000..ef05d8468 --- /dev/null +++ b/ui/stories/charts/primitives.stories.js @@ -0,0 +1,68 @@ +import hbs from 'htmlbars-inline-precompile'; + +export default { + title: 'Charts|Primitives', +}; + +export let Tooltip = () => ({ + template: hbs` +
    Single Entry
    +
    + +
  • + {{series.name}} + {{series.value}} +
  • +
    +
    + +
    Multiple Entries
    +
    + +
  • + {{series.name}} + {{datum.value}} +
  • +
    +
    + +
    Active Entry
    +
    + +
  • + {{series.name}} + {{datum.value}} +
  • +
    +
    + +
    Color Scales
    +
    + {{#each this.scales as |scale|}} +
    + {{scale}} + +
  • + {{series.name}} + {{datum.value}} +
  • +
    +
    + {{/each}} +
    + `, + context: { + style: 'left:70%;top:50%;', + dataSingle: [{ series: { name: 'Example', value: 12 } }], + dataMultiple: [ + { series: { name: 'One' }, datum: { value: 12 }, index: 0 }, + { series: { name: 'Two' }, datum: { value: 24 }, index: 1 }, + { series: { name: 'Three' }, datum: { value: 36 }, index: 2 }, + { series: { name: 'Four' }, datum: { value: 48 }, index: 3 }, + { series: { name: 'Five' }, datum: { value: 60 }, index: 4 }, + { series: { name: 'Six' }, datum: { value: 72 }, index: 5 }, + { series: { name: 'Seven' }, datum: { value: 84 }, index: 6 }, + ], + scales: ['reds', 'blues', 'ordinal'], + }, +}); diff --git a/ui/tests/integration/components/line-chart-test.js b/ui/tests/integration/components/line-chart-test.js index fb74f07c6..16d9fa77e 100644 --- a/ui/tests/integration/components/line-chart-test.js +++ b/ui/tests/integration/components/line-chart-test.js @@ -1,4 +1,4 @@ -import { findAll, click, render } from '@ember/test-helpers'; +import { find, findAll, click, render, triggerEvent } from '@ember/test-helpers'; import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; import hbs from 'htmlbars-inline-precompile'; @@ -160,4 +160,118 @@ module('Integration | Component | line-chart', function(hooks) { await componentA11yAudit(this.element, assert); }); + + test('horizontal annotations render in order', async function(assert) { + const annotations = [ + { y: 2, label: 'label one' }, + { y: 9, label: 'label three' }, + { y: 2.4, label: 'label two' }, + ]; + this.setProperties({ + annotations, + data: [ + { x: 1, y: 1 }, + { x: 10, y: 10 }, + ], + }); + + await render(hbs` + + <:after as |c|> + + + + `); + + const annotationEls = findAll('[data-test-annotation]'); + annotations + .sortBy('y') + .reverse() + .forEach((annotation, index) => { + assert.equal(annotationEls[index].textContent.trim(), annotation.label); + }); + }); + + test('the tooltip includes information on the data closest to the mouse', async function(assert) { + const series1 = [ + { x: 1, y: 2 }, + { x: 3, y: 3 }, + { x: 5, y: 4 }, + ]; + const series2 = [ + { x: 2, y: 10 }, + { x: 4, y: 9 }, + { x: 6, y: 8 }, + ]; + this.setProperties({ + data: [ + { series: 'One', data: series1 }, + { series: 'Two', data: series2 }, + ], + }); + + await render(hbs` +
    + + <:svg as |c|> + {{#each this.data as |series idx|}} + + {{/each}} + + <:after as |c|> + +
  • + {{series.series}} + {{datum.formattedY}} +
  • +
    + +
    +
    + `); + + // All tooltip events are attached to the hover target + const hoverTarget = find('[data-test-hover-target]'); + + // Mouse to data mapping happens based on the clientX of the MouseEvent + const bbox = hoverTarget.getBoundingClientRect(); + // The MouseEvent needs to be translated based on the location of the hover target + const xOffset = bbox.x; + // An interval here is the width between x values given the fixed dimensions of the line chart + // and the domain of the data + const interval = bbox.width / 5; + + // MouseEnter triggers the tooltip visibility + await triggerEvent(hoverTarget, 'mouseenter'); + // MouseMove positions the tooltip and updates the active datum + await triggerEvent(hoverTarget, 'mousemove', { clientX: xOffset + interval * 1 + 5 }); + assert.equal(findAll('[data-test-chart-tooltip] li').length, 1); + assert.equal(find('[data-test-chart-tooltip] .label').textContent.trim(), this.data[1].series); + assert.equal( + find('[data-test-chart-tooltip] .value').textContent.trim(), + series2.find(d => d.x === 2).y + ); + + // When the mouse falls between points and each series has points with different x values, + // points will only be shown in the tooltip if they are close enough to the closest point + // to the cursor. + // This event is intentionally between points such that both points are within proximity. + const expected = [ + { label: this.data[0].series, value: series1.find(d => d.x === 3).y }, + { label: this.data[1].series, value: series2.find(d => d.x === 2).y }, + ]; + await triggerEvent(hoverTarget, 'mousemove', { clientX: xOffset + interval * 1.5 + 5 }); + assert.equal(findAll('[data-test-chart-tooltip] li').length, 2); + findAll('[data-test-chart-tooltip] li').forEach((tooltipEntry, index) => { + assert.equal(tooltipEntry.querySelector('.label').textContent.trim(), expected[index].label); + assert.equal(tooltipEntry.querySelector('.value').textContent.trim(), expected[index].value); + }); + }); });