diff --git a/wherehows-web/app/components/datasets/containers/dataset-health.ts b/wherehows-web/app/components/datasets/containers/dataset-health.ts index 7f24e1e60e..0cd799eb2f 100644 --- a/wherehows-web/app/components/datasets/containers/dataset-health.ts +++ b/wherehows-web/app/components/datasets/containers/dataset-health.ts @@ -29,4 +29,7 @@ export default class DatasetHealthContainer extends Component { getContainerDataTask = task(function*(this: DatasetHealthContainer): IterableIterator>> { // 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 }]; } diff --git a/wherehows-web/app/components/visualization/charts/horizontal-bar-chart.ts b/wherehows-web/app/components/visualization/charts/horizontal-bar-chart.ts new file mode 100644 index 0000000000..e9f9a20050 --- /dev/null +++ b/wherehows-web/app/components/visualization/charts/horizontal-bar-chart.ts @@ -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} + */ + 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} + */ + series: Array; + + /** + * 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} + */ + width: ComputedProperty = 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} + */ + height: ComputedProperty = 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> = computed('series', 'size', function( + this: HorizontalBarChart + ): Array { + 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} + */ + maxY: ComputedProperty = 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); + } +} diff --git a/wherehows-web/app/styles/abstracts/_functions.scss b/wherehows-web/app/styles/abstracts/_functions.scss index 6d9def771a..0891429082 100644 --- a/wherehows-web/app/styles/abstracts/_functions.scss +++ b/wherehows-web/app/styles/abstracts/_functions.scss @@ -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'; +} diff --git a/wherehows-web/app/styles/base/_all.scss b/wherehows-web/app/styles/base/_all.scss index 31e2c59826..caec94262c 100644 --- a/wherehows-web/app/styles/base/_all.scss +++ b/wherehows-web/app/styles/base/_all.scss @@ -1,4 +1,5 @@ -@import "base"; -@import "fonts"; -@import "typography"; -@import "helpers"; \ No newline at end of file +@import 'base'; +@import 'fonts'; +@import 'typography'; +@import 'helpers'; +@import 'visualization'; diff --git a/wherehows-web/app/styles/base/_visualization.scss b/wherehows-web/app/styles/base/_visualization.scss new file mode 100644 index 0000000000..9f7c540162 --- /dev/null +++ b/wherehows-web/app/styles/base/_visualization.scss @@ -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; + } +} diff --git a/wherehows-web/app/styles/components/_all.scss b/wherehows-web/app/styles/components/_all.scss index 880f9c19dd..4fa83fbc98 100644 --- a/wherehows-web/app/styles/components/_all.scss +++ b/wherehows-web/app/styles/components/_all.scss @@ -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'; diff --git a/wherehows-web/app/styles/components/visualization/_all.scss b/wherehows-web/app/styles/components/visualization/_all.scss new file mode 100644 index 0000000000..206bb90212 --- /dev/null +++ b/wherehows-web/app/styles/components/visualization/_all.scss @@ -0,0 +1 @@ +@import 'charts/all'; diff --git a/wherehows-web/app/styles/components/visualization/charts/_all.scss b/wherehows-web/app/styles/components/visualization/charts/_all.scss new file mode 100644 index 0000000000..771e6fb151 --- /dev/null +++ b/wherehows-web/app/styles/components/visualization/charts/_all.scss @@ -0,0 +1 @@ +@import 'bar-chart'; diff --git a/wherehows-web/app/styles/components/visualization/charts/_bar-chart.scss b/wherehows-web/app/styles/components/visualization/charts/_bar-chart.scss new file mode 100644 index 0000000000..d0af343a6e --- /dev/null +++ b/wherehows-web/app/styles/components/visualization/charts/_bar-chart.scss @@ -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; + } +} diff --git a/wherehows-web/app/templates/components/datasets/containers/dataset-health.hbs b/wherehows-web/app/templates/components/datasets/containers/dataset-health.hbs index 809b2abd25..2fb02b5407 100644 --- a/wherehows-web/app/templates/components/datasets/containers/dataset-health.hbs +++ b/wherehows-web/app/templates/components/datasets/containers/dataset-health.hbs @@ -1 +1,7 @@ -Coming Soon! \ No newline at end of file +Coming Soon! +
+ {{visualization/charts/horizontal-bar-chart + title="Test Chart" + series=testSeries + labelAppendValue="%"}} +
\ No newline at end of file diff --git a/wherehows-web/app/templates/components/visualization/charts/horizontal-bar-chart.hbs b/wherehows-web/app/templates/components/visualization/charts/horizontal-bar-chart.hbs new file mode 100644 index 0000000000..4026b44ee3 --- /dev/null +++ b/wherehows-web/app/templates/components/visualization/charts/horizontal-bar-chart.hbs @@ -0,0 +1,20 @@ +
{{title}}
+ + + + {{#each seriesData as |datum index|}} + + {{/each}} + + + {{#each seriesData as |datum index|}} + + + {{datum.value}}{{labelAppendValue}} + | {{get datum labelTagProperty}} + + + {{/each}} + + + \ No newline at end of file diff --git a/wherehows-web/app/typings/app/visualization/charts.d.ts b/wherehows-web/app/typings/app/visualization/charts.d.ts new file mode 100644 index 0000000000..93d6693a4e --- /dev/null +++ b/wherehows-web/app/typings/app/visualization/charts.d.ts @@ -0,0 +1,7 @@ +/** + * Expected basic chart data object for a single item in a chart series. + */ +export interface IChartDatum { + name: string; + value: number; +} diff --git a/wherehows-web/tests/integration/components/visualization/charts/horizontal-bar-chart-test.js b/wherehows-web/tests/integration/components/visualization/charts/horizontal-bar-chart-test.js new file mode 100644 index 0000000000..571fa7ed2d --- /dev/null +++ b/wherehows-web/tests/integration/components/visualization/charts/horizontal-bar-chart-test.js @@ -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' + ); +});