Merge pull request #1281 from cptran777/horizontal-bar-graphs

Horizontal bar graphs
This commit is contained in:
Charlie Tran 2018-07-31 13:43:42 -07:00 committed by GitHub
commit b94bee826d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 356 additions and 7 deletions

View File

@ -29,4 +29,7 @@ export default class DatasetHealthContainer extends Component {
getContainerDataTask = task(function*(this: DatasetHealthContainer): IterableIterator<TaskInstance<Promise<any>>> {
// Do something in the future
});
// Mock data for testing demo purposes, to be deleted once we have actual data and further development
testSeries = [{ name: 'Test1', value: 10 }, { name: 'Test2', value: 5 }, { name: 'Test3', value: 3 }];
}

View File

@ -0,0 +1,175 @@
import Component from '@ember/component';
import { IChartDatum } from 'wherehows-web/typings/app/visualization/charts';
import { computed, get, set, setProperties } from '@ember/object';
import ComputedProperty from '@ember/object/computed';
interface IBarSeriesDatum extends IChartDatum {
yOffset: number;
barLength: number;
labelOffset: number;
}
/**
* This custom component exists outside of highcharts as the library does not provide the amount
* of capabilities we need to match up with our design vision for horizontal bar charts. As such,
* there are similarities between this component and a highcharts component but it has been
* tailor-fit to our needs
*
* Bar Chart Usage
* {{visualization/charts/horizontal-bar-chart
* series=[ { name: string, value: number, otherKey: otherValue } ]
* title="string"
* labelTagProperty="optionStringOverridesDefault"
* labelAppendTag="optionalStringAppendsEachTag"
* labelAppendValue="optionalStringSuchAs%"}}
*/
export default class HorizontalBarChart extends Component {
/**
* Sets the tag for the rendered html elemenet for the component
* @type {string}
*/
tagName = 'figure';
/**
* Sets the classes for the rendered html element for the component
* @type {Array<string>}
*/
classNames = ['vz-chart', 'viz-bar-chart', 'single-series'];
/**
* Represents the series of data needed to power our chart. Format is
* [ { name: string, value: number } ].
* Since this chart is only meant to handle a single series of data where each bar is connected
* to one value with one label, we don't have to worry about the idea of an "x axis * y axis"
* @type {Array<IChartDatum>}
*/
series: Array<IChartDatum>;
/**
* Helps to set the size of the svg element rendered by the component
* @type {number}
*/
size: number = 0;
/**
* Property in the series datum to use as the tag for each value in the bar legend. Note, each
* legend item will appear as VALUE | TAG
* @type {string}
* @default 'name'
*/
labelTagProperty: string;
/**
* Any string we want to append to each tag in the label, such as a unit.
* @type {string}
*/
labelAppendTag: string;
/**
* Any string that we want to append to each value in the label, such as %. Doing so would
* append every value, such as 60, in the label with % and appear as 60%
* @type {string}
*/
labelAppendValue: string;
/**
* Constant properties to be used in calculations for the size of the svg elements drawn
* @type {number}
*/
BAR_HEIGHT = 16;
BAR_MARGIN_BOTTOM = 8;
LABEL_HEIGHT = 15;
LABEL_MARGIN_BOTTOM = 16;
/**
* Overall width of our chart. If we have a size, that means that the component and available space
* has been measured.
* @type {ComputedProperty<number>}
*/
width: ComputedProperty<number> = computed('size', function(this: HorizontalBarChart): number {
return get(this, 'size') ? this.$(this.element).width() || 0 : 0;
});
/**
* Overall height of our chart, calculated based on the amount of items we have in our series
* @type {ComputedProperty<number>}
*/
height: ComputedProperty<number> = computed('categories', function(this: HorizontalBarChart): number {
return (get(this, 'series') || []).length * this.heightModifier();
});
/**
* Calculates information needed for the svg element to properly render each bar of our graph using the
* correct dimensions relative to the data it's receiving
* @type {ComputedProperty<IBarSeriesDatum[]}
*/
seriesData: ComputedProperty<Array<IBarSeriesDatum>> = computed('series', 'size', function(
this: HorizontalBarChart
): Array<IBarSeriesDatum> {
return (this.get('series') || []).map(this.bar.bind(this));
});
/**
* Sets our highest value for the chart's Y axis, based on the highest value inside the series
* @type {ComputedProperty<number>}
*/
maxY: ComputedProperty<number> = computed('series', function(this: HorizontalBarChart): number {
return (get(this, 'series') || []).reduce((memo, dataPoint) => {
if (dataPoint.value > memo) {
return dataPoint.value;
}
return memo;
}, Number.MIN_VALUE);
});
/**
* Returns a "modifier" that is the height of a single bar and label in the chart, and can be multiplied
* by the number of rows in the chart to get the total chart height
* @param this - explicit this keyword declaration for typescript
*/
heightModifier(this: HorizontalBarChart): number {
return (
get(this, 'BAR_HEIGHT') +
get(this, 'BAR_MARGIN_BOTTOM') +
get(this, 'LABEL_HEIGHT') +
get(this, 'LABEL_MARGIN_BOTTOM')
);
}
/**
* Used as a predicate function in the mapping function for the series array to be mapped into the
* seriesData array, this function adds values to each chart datum object so that the svg template
* can render each bar with the correct dimensions and position
* @param this - explicit this keyword declaration for typescript
* @param data - single datum object in our series
* @param index - current index in the series array
*/
bar(this: HorizontalBarChart, data: IChartDatum, index: number): IBarSeriesDatum {
const yOffset = 1 + index * this.heightModifier();
return {
...data,
yOffset,
barLength: Math.max(1, Math.floor(data.value / get(this, 'maxY') * get(this, 'width'))),
labelOffset: yOffset + get(this, 'BAR_HEIGHT') + get(this, 'BAR_MARGIN_BOTTOM') + get(this, 'LABEL_HEIGHT')
};
}
constructor() {
super(...arguments);
// Applying passed in properties or setting to default values
setProperties(this, {
labelTagProperty: this.labelTagProperty || 'name',
labelAppendTag: this.labelAppendTag || '',
labelAppendValue: this.labelAppendValue || ''
});
}
/**
* Once we have inserted our html element, we can determine the width (size) of our chart
*/
didInsertElement() {
this._super(...arguments);
set(this, 'size', this.$(this.element).width() || 0);
}
}

View File

@ -6,7 +6,7 @@
/// @param {String} $path - asset path
/// @return {Url}
@function asset($base, $type, $path) {
@return url($base + $type + $path);
@return url($base+$type+$path);
}
/// Returns URL to an image based on its path
@ -90,7 +90,7 @@
blue: (oxford: rgb(53, 75, 87), curious: rgb(26, 161, 217), eastern: rgb(26, 132, 188), blue5: rgb(26, 161, 217)),
grey: (light: rgb(237, 237, 237), dark: rgb(68, 68, 68), mid: rgb(153, 153, 153)),
black: (dune: rgb(41, 39, 36)),
white: (base: rgb(255, 255, 255), catskill: rgb(243, 247, 249), earlydawn:rgb(255, 249, 232))
white: (base: rgb(255, 255, 255), catskill: rgb(243, 247, 249), earlydawn: rgb(255, 249, 232))
);
@return map-get(map-get($color-scheme, $color), $hue);
@ -285,3 +285,57 @@
@return $color;
}
@function get-dataviz-color($value) {
$color-palette-dataviz: (
order:
(
get-color(blue5),
get-color(teal7),
get-color(purple5),
get-color(slate3),
get-color(orange5),
get-color(pink7),
get-color(blue3),
get-color(teal5),
get-color(purple3),
get-color(slate7),
get-color(orange3),
get-color(pink5),
get-color(blue7),
get-color(teal3),
get-color(purple7),
get-color(slate5),
get-color(orange7),
get-color(pink3)
),
positive: get-color(green6),
negative: get-color(red6)
);
$list: map-get($color-palette-dataviz, order);
@if (type-of($value) == 'number') {
$index: $value % length($list);
@if ($value < 1) {
@error 'get-dataviz-color requires a number greater than 0, got #{$value}.';
}
@if ($index == 0) {
@return nth($list, length($list));
}
@return nth($list, $index);
}
@if ($value == 'positive') {
@return map-get($color-palette-dataviz, positive);
}
@if ($value == 'negative') {
@return map-get($color-palette-dataviz, negative);
}
@error '#{$value} is not a valid data-visualization-color option';
}

View File

@ -1,4 +1,5 @@
@import "base";
@import "fonts";
@import "typography";
@import "helpers";
@import 'base';
@import 'fonts';
@import 'typography';
@import 'helpers';
@import 'visualization';

View File

@ -0,0 +1,9 @@
/* Sets the default colors for the visualization colors. When rendering a series of data, the color
order for each item should always be the same */
@for $i from 1 through 18 {
$color: get-dataviz-color($i);
.highcharts-color-#{$i - 1} {
fill: $color;
stroke: $color;
}
}

View File

@ -25,6 +25,7 @@
@import 'entity-header/all';
@import 'dataset-fabric/all';
@import 'dataset-relationships/all';
@import 'visualization/all';
@import 'nacho/nacho-button';
@import 'nacho/nacho-global-search';

View File

@ -0,0 +1 @@
@import 'charts/all';

View File

@ -0,0 +1 @@
@import 'bar-chart';

View File

@ -0,0 +1,25 @@
.viz-bar-chart {
.highcharts-root {
.highcharts {
&-label,
&-data-label {
fill: $text-color;
stroke: $text-color;
text {
font-weight: 100;
}
.highcharts-emphasized {
font-weight: 400;
}
}
}
}
&__title {
font-weight: bold;
font-size: 15px;
margin-bottom: 16px;
}
}

View File

@ -1 +1,7 @@
Coming Soon!
Coming Soon!
<div style="width: 50%">
{{visualization/charts/horizontal-bar-chart
title="Test Chart"
series=testSeries
labelAppendValue="%"}}
</div>

View File

@ -0,0 +1,20 @@
<h5 class="viz-bar-chart__title">{{title}}</h5>
<svg version="1.1" class="highcharts-root" xmlns="http://www.w3.org/2000/svg" width="100%" height="{{height}}" viewBox="0 0 {{width}} {{height}}">
<g class="highcharts-series-group">
<g class="highcharts-series highcharts-series-0 highcharts-bar-series highcharts-tracker highcharts-series-hover">
{{#each seriesData as |datum index|}}
<rect x="0" y="{{datum.yOffset}}" height="16" width="{{datum.barLength}}" class="highcharts-color-{{index}}" rx="2px" ry="2px"></rect>
{{/each}}
</g>
<g class="highcharts-data-labels highcharts-series-0 highcharts-bar-series highcharts-color-0 highcharts-tracker">
{{#each seriesData as |datum index|}}
<g class="highcharts-label highcharts-data-label">
<text x="0" y="{{datum.labelOffset}}">
<tspan class="highcharts-emphasized">{{datum.value}}{{labelAppendValue}}</tspan>
<tspan> | {{get datum labelTagProperty}}</tspan>
</text>
</g>
{{/each}}
</g>
</g>
</svg>

View File

@ -0,0 +1,7 @@
/**
* Expected basic chart data object for a single item in a chart series.
*/
export interface IChartDatum {
name: string;
value: number;
}

View File

@ -0,0 +1,46 @@
import { moduleForComponent, test } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
moduleForComponent(
'visualization/charts/horizontal-bar-chart',
'Integration | Component | visualization/charts/horizontal-bar-chart',
{ integration: true }
);
/* Selectors */
const chartTitle = '.viz-bar-chart__title';
const chartBar = 'rect';
const chartLabel = '.highcharts-data-label';
test('it renders', async function(assert) {
this.render(hbs`{{visualization/charts/horizontal-bar-chart}}`);
assert.ok(this.$(), 'Renders without errors');
});
test('it displays the correct graph information', async function(assert) {
const title = 'Pokemon Values';
const series = [
{ name: 'Mewtwo', value: 150 },
{ name: 'Alakazam', value: 65 },
{ name: 'Pikachu', value: 25 },
{ name: 'Charmander', value: 4 }
];
this.setProperties({ title, series });
this.render(hbs`{{visualization/charts/horizontal-bar-chart
series=series
title=title}}`);
assert.ok(this.$(), 'Still renders without errors');
assert.equal(this.$(chartBar).length, series.length, 'Renders 3 bars');
assert.equal(this.$(chartLabel).length, series.length, 'Renders 3 labels');
assert.equal(
this.$('text:eq(0)')
.text()
.trim()
.replace(/[ \n]/g, ''),
'150|Mewtwo',
'Renders the correct first label'
);
});