mirror of
https://github.com/datahub-project/datahub.git
synced 2025-09-25 17:15:09 +00:00
Merge pull request #1281 from cptran777/horizontal-bar-graphs
Horizontal bar graphs
This commit is contained in:
commit
b94bee826d
@ -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 }];
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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';
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
@import "base";
|
||||
@import "fonts";
|
||||
@import "typography";
|
||||
@import "helpers";
|
||||
@import 'base';
|
||||
@import 'fonts';
|
||||
@import 'typography';
|
||||
@import 'helpers';
|
||||
@import 'visualization';
|
||||
|
9
wherehows-web/app/styles/base/_visualization.scss
Normal file
9
wherehows-web/app/styles/base/_visualization.scss
Normal 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;
|
||||
}
|
||||
}
|
@ -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';
|
||||
|
@ -0,0 +1 @@
|
||||
@import 'charts/all';
|
@ -0,0 +1 @@
|
||||
@import 'bar-chart';
|
@ -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;
|
||||
}
|
||||
}
|
@ -1 +1,7 @@
|
||||
Coming Soon!
|
||||
Coming Soon!
|
||||
<div style="width: 50%">
|
||||
{{visualization/charts/horizontal-bar-chart
|
||||
title="Test Chart"
|
||||
series=testSeries
|
||||
labelAppendValue="%"}}
|
||||
</div>
|
@ -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>
|
7
wherehows-web/app/typings/app/visualization/charts.d.ts
vendored
Normal file
7
wherehows-web/app/typings/app/visualization/charts.d.ts
vendored
Normal 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;
|
||||
}
|
@ -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'
|
||||
);
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user