Merge pull request #10138 from hashicorp/f-ui/improved-stats-charts

UI: Line Chart new primitives and tooltip refactor
This commit is contained in:
Michael Lange
2021-03-09 17:58:42 -08:00
committed by GitHub
21 changed files with 650 additions and 65 deletions

View File

@@ -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;

View File

@@ -0,0 +1,14 @@
<div data-test-annotations class="line-chart-annotations" style={{this.chartAnnotationsStyle}} ...attributes>
{{#each this.processed key=@key as |annotation|}}
<div data-test-annotation class="chart-horizontal-annotation" style={{annotation.style}}>
<button
type="button"
title={{annotation.a11yLabel}}
class="indicator {{if annotation.isActive "is-active"}}"
{{on "click" (fn this.selectAnnotation annotation.annotation)}}>
{{annotation.label}}
</button>
<div class="line" />
</div>
{{/each}}
</div>

View File

@@ -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);
}
}

View File

@@ -0,0 +1,7 @@
<div data-test-chart-tooltip class="chart-tooltip {{if @active "active" "inactive"}}" style={{@style}} ...attributes>
<ol>
{{#each @data as |props|}}
{{yield props.series props.datum (inc props.index)}}
{{/each}}
</ol>
</div>

View File

@@ -1,13 +1,10 @@
<div data-test-annotations class="line-chart-annotations" style={{this.chartAnnotationsStyle}} ...attributes>
{{#each this.processed key=@key as |annotation|}}
<div data-test-annotation class="chart-annotation {{annotation.iconClass}} {{annotation.staggerClass}}" style={{annotation.style}}>
<div data-test-annotation class="chart-vertical-annotation {{annotation.iconClass}} {{annotation.staggerClass}}" style={{annotation.style}}>
<button
type="button"
title={{annotation.label}}
class="indicator {{if (or
(and @key (eq-by @key annotation.annotation @activeAnnotation))
(and (not @key) (eq annotation.annotation @activeAnnotation))
) "is-active"}}"
class="indicator {{if annotation.isActive "is-active"}}"
{{on "click" (fn this.selectAnnotation annotation.annotation)}}>
{{x-icon annotation.icon}}
</button>

View File

@@ -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);

View File

@@ -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,
};
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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;
}
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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';

View File

@@ -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;
}
}

View File

@@ -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,
);

View File

@@ -12,10 +12,10 @@
activeDatum=this.activeDatum
)}}
{{else}}
<div class="chart-tooltip {{if this.isActive "active" "inactive"}}" style={{this.tooltipStyle}}>
<div class="chart-tooltip with-active-datum {{if this.isActive "active" "inactive"}}" style={{this.tooltipStyle}}>
<ol>
{{#each this._data as |datum index|}}
<li class="{{if (eq datum.label this.activeDatum.label) "active"}}">
<li class="{{if (eq datum.label this.activeDatum.label) "is-active"}}">
<span class="label {{if (eq datum.value 0) "is-empty"}}">
<span class="color-swatch {{if datum.className datum.className (concat "swatch-" index)}}" />
{{datum.label}}

Before

Width:  |  Height:  |  Size: 831 B

After

Width:  |  Height:  |  Size: 852 B

View File

@@ -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}}
<g aria-hidden="true" class="x-axis axis" transform="translate(0, {{this.xAxisOffset}})"></g>
<g aria-hidden="true" class="y-axis axis" transform="translate({{this.yAxisOffset}}, 0)"></g>
<rect class="hover-target" x="0" y="0" width="{{this.yAxisOffset}}" height="{{this.xAxisOffset}}" />
<rect data-test-hover-target class="hover-target" x="0" y="0" width="{{this.yAxisOffset}}" height="{{this.xAxisOffset}}" />
</svg>
{{#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}}
<div class="chart-tooltip is-snappy {{if this.isActive "active" "inactive"}}" style={{this.tooltipStyle}}>
<p>
<span class="label">
<span class="color-swatch {{this.chartClass}}" />
{{this.activeDatumLabel}}
</span>
<span class="value">{{this.activeDatumValue}}</span>
</p>
</div>
</div>

View File

@@ -9,6 +9,12 @@
@data={{this.data}} />
</:svg>
<:after as |c|>
<c.Tooltip class="is-snappy" as |series datum|>
<li>
<span class="label"><span class="color-swatch is-primary" />{{datum.formattedX}}</span>
<span class="value">{{datum.formattedY}}</span>
</li>
</c.Tooltip>
<c.VAnnotations
@annotations={{this.annotations}}
@key="event.uid"

View File

@@ -13,4 +13,12 @@
<:svg as |c|>
<c.Area @data={{@data}} @colorClass={{@chartClass}} />
</:svg>
<:after as |c|>
<c.Tooltip class="is-snappy" as |series datum|>
<li>
<span class="label"><span class="color-swatch {{@chartClass}}" />{{datum.formattedX}}</span>
<span class="value">{{datum.formattedY}}</span>
</li>
</c.Tooltip>
</:after>
</LineChart>

View File

@@ -44,15 +44,31 @@ export let Standard = () => {
<:svg as |c|>
<c.Area @data={{this.lineChartData}} />
</:svg>
<:after as |c|>
<c.Tooltip class="is-snappy" as |series datum|>
<li>
<span class="label"><span class="color-swatch is-primary" />{{datum.formattedX}}</span>
<span class="value">{{datum.formattedY}}</span>
</li>
</c.Tooltip>
</:after>
</LineChart>
{{/if}}
</div>
<div class="block" style="height:100px; width: 400px;">
{{#if this.lineChartMild}}
<LineChart @data={{this.lineChartMild}} @xProp="year" @yProp="value" @chartClass="is-info">
<LineChart @data={{this.lineChartMild}} @xProp="year" @yProp="value">
<:svg as |c|>
<c.Area @data={{this.lineChartMild}} />
<c.Area @data={{this.lineChartMild}} @colorClass="is-info" />
</:svg>
<:after as |c|>
<c.Tooltip class="is-snappy" as |series datum|>
<li>
<span class="label"><span class="color-swatch is-info" />{{datum.formattedX}}</span>
<span class="value">{{datum.formattedY}}</span>
</li>
</c.Tooltip>
</:after>
</LineChart>
{{/if}}
</div>
@@ -70,19 +86,35 @@ export let FluidWidth = () => {
<h5 class="title is-5">Fluid-width Line Chart</h5>
<div class="block" style="height:250px;">
{{#if this.lineChartData}}
<LineChart @data={{this.lineChartData}} @xProp="year" @yProp="value" @chartClass="is-danger">
<LineChart @data={{this.lineChartData}} @xProp="year" @yProp="value">
<:svg as |c|>
<c.Area @data={{this.lineChartData}} />
<c.Area @data={{this.lineChartData}} @colorClass="is-danger" />
</:svg>
<:after as |c|>
<c.Tooltip class="is-snappy" as |series datum|>
<li>
<span class="label"><span class="color-swatch is-danger" />{{datum.formattedX}}</span>
<span class="value">{{datum.formattedY}}</span>
</li>
</c.Tooltip>
</:after>
</LineChart>
{{/if}}
</div>
<div class="block" style="height:250px;">
{{#if this.lineChartMild}}
<LineChart @data={{this.lineChartMild}} @xProp="year" @yProp="value" @chartClass="is-warning">
<LineChart @data={{this.lineChartMild}} @xProp="year" @yProp="value">
<:svg as |c|>
<c.Area @data={{this.lineChartMild}} />
<c.Area @data={{this.lineChartMild}} @colorClass="is-warning" />
</:svg>
<:after as |c|>
<c.Tooltip class="is-snappy" as |series datum|>
<li>
<span class="label"><span class="color-swatch is-warning" />{{datum.formattedX}}</span>
<span class="value">{{datum.formattedY}}</span>
</li>
</c.Tooltip>
</:after>
</LineChart>
{{/if}}
</div>
@@ -106,7 +138,6 @@ export let LiveData = () => {
@xProp="ts"
@yProp="val"
@timeseries={{true}}
@chartClass="is-primary"
@xFormat={{this.controller.secondsFormat}}>
<:svg as |c|>
<c.Area @data={{this.controller.lineChartLive}} />
@@ -152,10 +183,18 @@ export let Gaps = () => {
<h5 class="title is-5">Line Chart data with gaps</h5>
<div class="block" style="height:250px">
{{#if this.lineChartGapData}}
<LineChart @data={{this.lineChartGapData}} @xProp="year" @yProp="value" @chartClass="is-primary">
<LineChart @data={{this.lineChartGapData}} @xProp="year" @yProp="value">
<:svg as |c|>
<c.Area @data={{this.lineChartGapData}} />
</:svg>
<:after as |c|>
<c.Tooltip class="is-snappy" as |series datum|>
<li>
<span class="label"><span class="color-swatch is-primary" />{{datum.formattedX}}</span>
<span class="value">{{datum.formattedY}}</span>
</li>
</c.Tooltip>
</:after>
</LineChart>
{{/if}}
</div>
@@ -176,7 +215,7 @@ export let Gaps = () => {
};
};
export let Annotations = () => {
export let VerticalAnnotations = () => {
return {
template: hbs`
<h5 class="title is-5">Line Chart data with annotations</h5>
@@ -218,6 +257,12 @@ export let Annotations = () => {
@annotations={{this.annotations}}
@annotationClick={{action (mut this.activeAnnotation)}}
@activeAnnotation={{this.activeAnnotation}} />
<c.Tooltip class="is-snappy" as |series datum|>
<li>
<span class="label"><span class="color-swatch is-primary" />{{datum.formattedX}}</span>
<span class="value">{{datum.formattedY}}</span>
</li>
</c.Tooltip>
</:after>
</LineChart>
{{/if}}
@@ -278,6 +323,50 @@ export let Annotations = () => {
};
};
export let HorizontalAnnotations = () => {
return {
template: hbs`
<div class="block" style="height:250px">
{{#if (and this.data this.annotations)}}
<LineChart
class="with-annotations"
@timeseries={{true}}
@xProp="x"
@yProp="y"
@data={{this.data}}>
<:svg as |c|>
<c.Area @data={{this.data}} @annotationClick={{action (mut this.activeAnnotation)}} />
</:svg>
<:after as |c|>
<c.HAnnotations @annotations={{this.annotations}} @labelProp="info" />
</:after>
</LineChart>
{{/if}}
</div>
`,
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|>
<c.Area @data={{this.data}} @curve="stepAfter" />
</:svg>
<:after as |c|>
<c.Tooltip class="is-snappy" as |series datum|>
<li>
<span class="label"><span class="color-swatch is-primary" />{{datum.formattedX}}</span>
<span class="value">{{datum.formattedY}}</span>
</li>
</c.Tooltip>
</:after>
</LineChart>
<p>{{this.activeAnnotation.info}}</p>
{{/if}}
@@ -311,3 +408,61 @@ export let StepLine = () => {
},
};
};
export let MultiLine = () => ({
template: hbs`
<h5 class="title is-5">Multiple Lines on One Chart</h5>
<div class="block" style="height:250px">
{{#if this.data}}
<LineChart
@xProp="x"
@yProp="y"
@dataProp="data"
@data={{this.data}}>
<:svg as |c|>
{{#each this.data as |series idx|}}
<c.Area @data={{series.data}} @colorScale="reds" @index={{idx}} />
{{/each}}
</:svg>
<:after as |c|>
<c.Tooltip class="is-snappy" as |series datum index|>
<li>
<span class="label"><span class="color-swatch swatch-reds swatch-reds-{{index}}" />{{series.name}}</span>
<span class="value">{{datum.formattedY}}</span>
</li>
</c.Tooltip>
</:after>
</LineChart>
<p>{{this.activeAnnotation.info}}</p>
{{/if}}
</div>
`,
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 },
],
},
]),
},
});

View File

@@ -0,0 +1,68 @@
import hbs from 'htmlbars-inline-precompile';
export default {
title: 'Charts|Primitives',
};
export let Tooltip = () => ({
template: hbs`
<h5 class="title is-5">Single Entry</h5>
<div class="mock-hover-region" style="width:300px;height:100px">
<ChartPrimitives::Tooltip @active={{true}} @style={{this.style}} @data={{this.dataSingle}} as |series|>
<li>
<span class="label"><span class="color-swatch swatch-reds" />{{series.name}}</span>
<span class="value">{{series.value}}</span>
</li>
</ChartPrimitives::Tooltip>
</div>
<h5 class="title is-5">Multiple Entries</h5>
<div class="mock-hover-region" style="width:300px;height:100px">
<ChartPrimitives::Tooltip @active={{true}} @style={{this.style}} @data={{take 4 this.dataMultiple}} as |series datum index|>
<li>
<span class="label"><span class="color-swatch swatch-reds swatch-reds-{{index}}" />{{series.name}}</span>
<span class="value">{{datum.value}}</span>
</li>
</ChartPrimitives::Tooltip>
</div>
<h5 class="title is-5">Active Entry</h5>
<div class="mock-hover-region" style="width:300px;height:100px">
<ChartPrimitives::Tooltip @active={{true}} @style={{this.style}} @data={{take 4 this.dataMultiple}} class="with-active-datum" as |series datum index|>
<li class="{{if (eq series.name "Three") "is-active"}}">
<span class="label"><span class="color-swatch swatch-reds swatch-reds-{{index}}" />{{series.name}}</span>
<span class="value">{{datum.value}}</span>
</li>
</ChartPrimitives::Tooltip>
</div>
<h5 class="title is-5">Color Scales</h5>
<div class="multiples is-left-aligned with-spacing">
{{#each this.scales as |scale|}}
<div class="mock-hover-region" style="width:300px;height:200px">
{{scale}}
<ChartPrimitives::Tooltip @active={{true}} @style="left:70%;top:75%" @data={{this.dataMultiple}} as |series datum index|>
<li>
<span class="label"><span class="color-swatch swatch-{{scale}} swatch-{{scale}}-{{index}}" />{{series.name}}</span>
<span class="value">{{datum.value}}</span>
</li>
</ChartPrimitives::Tooltip>
</div>
{{/each}}
</div>
`,
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'],
},
});

View File

@@ -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`
<LineChart
@xProp="x"
@yProp="y"
@data={{this.data}}>
<:after as |c|>
<c.HAnnotations @annotations={{this.annotations}} @labelProp="label" />
</:after>
</LineChart>
`);
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`
<div style="width:500px;margin-top:100px">
<LineChart
@xProp="x"
@yProp="y"
@dataProp="data"
@data={{this.data}}>
<:svg as |c|>
{{#each this.data as |series idx|}}
<c.Area @data={{series.data}} @colorScale="blues" @index={{idx}} />
{{/each}}
</:svg>
<:after as |c|>
<c.Tooltip as |series datum index|>
<li>
<span class="label"><span class="color-swatch swatch-blues swatch-blues-{{index}}" />{{series.series}}</span>
<span class="value">{{datum.formattedY}}</span>
</li>
</c.Tooltip>
</:after>
</LineChart>
</div>
`);
// 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);
});
});
});