mirror of
https://github.com/kemko/nomad.git
synced 2026-01-08 11:25:41 +03:00
Merge pull request #10138 from hashicorp/f-ui/improved-stats-charts
UI: Line Chart new primitives and tooltip refactor
This commit is contained in:
@@ -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;
|
||||
|
||||
14
ui/app/components/chart-primitives/h-annotations.hbs
Normal file
14
ui/app/components/chart-primitives/h-annotations.hbs
Normal 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>
|
||||
49
ui/app/components/chart-primitives/h-annotations.js
Normal file
49
ui/app/components/chart-primitives/h-annotations.js
Normal 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);
|
||||
}
|
||||
}
|
||||
7
ui/app/components/chart-primitives/tooltip.hbs
Normal file
7
ui/app/components/chart-primitives/tooltip.hbs
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
68
ui/stories/charts/primitives.stories.js
Normal file
68
ui/stories/charts/primitives.stories.js
Normal 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'],
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user