Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 30 additions & 12 deletions app/components/download-graph.hbs
Original file line number Diff line number Diff line change
@@ -1,12 +1,30 @@
{{#if this.loadTask.last.error}}
<span data-test-google-api-error>
There was an error loading the Google Charts code.
Please try again later.
</span>
{{else}}
<div
{{did-insert this.renderChart}}
{{did-update this.renderChart @data}}
...attributes
></div>
{{/if}}
<div
local-class="
wrapper
{{if this.chartjs.loadTask.lastSuccessful.value "auto-height"}}
"
data-test-download-graph
...attributes
{{did-insert this.loadChartJs}}
>
{{#if this.chartjs.loadTask.isRunning}}
<LoadingSpinner local-class="spinner" data-test-spinner />
{{else if this.chartjs.loadTask.lastSuccessful.value}}
<canvas
{{did-insert this.createChart}}
{{did-update this.updateChart @data}}
{{will-destroy this.destroyChart}}
/>
{{else}}
<div local-class="error" data-test-error>
<p>Sorry, there was a problem loading the graphing code.</p>
<button
type="button"
data-test-reload
{{on "click" this.reloadPage}}
>
Try again
</button>
</div>
{{/if}}
</div>
247 changes: 64 additions & 183 deletions app/components/download-graph.js
Original file line number Diff line number Diff line change
@@ -1,210 +1,91 @@
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { waitForPromise } from '@ember/test-waiters';
import Component from '@glimmer/component';

import { task } from 'ember-concurrency';

import { ExternalScriptError } from '../services/google-charts';
import { ignoreCancellation } from '../utils/concurrency';
import window from 'ember-window-mock';

// Colors by http://colorbrewer2.org/#type=diverging&scheme=RdBu&n=10
const COLORS = ['#67001f', '#b2182b', '#d6604d', '#f4a582', '#92c5de', '#4393c3', '#2166ac', '#053061'];
const BG_COLORS = ['#d3b5bc', '#eabdc0', '#f3d0ca', '#fce4d9', '#deedf5', '#c9deed', '#2166ac', '#053061'];

export default class DownloadGraph extends Component {
@service googleCharts;

resizeHandler = () => this.renderChart();

constructor() {
super(...arguments);

this.loadTask
.perform()
.catch(ignoreCancellation)
.catch(error => {
// ignore `ExternalScriptError` errors since we handle those in the template
if (!(error instanceof ExternalScriptError)) {
throw error;
}
});
@service chartjs;

window.addEventListener('resize', this.resizeHandler, false);
@action loadChartJs() {
waitForPromise(this.chartjs.loadTask.perform()).catch(() => {
// Ignore Promise rejections. We'll handle them through the derived state properties.
});
}

willDestroy() {
super.willDestroy(...arguments);
window.removeEventListener('resize', this.resizeHandler);
@action createChart(element) {
let Chart = this.chartjs.loadTask.lastSuccessful.value;

this.chart = new Chart(element, {
type: 'line',
data: this.data,
options: {
layout: {
padding: 10,
},
scales: {
xAxes: [{ type: 'time', time: { stepSize: 7, tooltipFormat: 'MMM D', unit: 'day' } }],
yAxes: [{ stacked: true, ticks: { min: 0, precision: 0 } }],
},
tooltips: {
mode: 'index',
intersect: false,
position: 'nearest',
},
},
});
}

@task(function* () {
if (!this.googleCharts.loaded) {
yield this.googleCharts.load();
this.renderChart();
}
})
loadTask;
@action updateChart() {
let { chart, animate } = this.chart;

@action
renderChart(element) {
if (element) {
this.chartElement = element;
} else if (this.chartElement) {
element = this.chartElement;
} else {
return;
}
if (chart) {
chart.data = this.data;

let data = this.args.data;

let subarray_length = (data[1] || []).length;

// Start at 1 to skip the date element in the 0th
// location in the array.
for (let k = 1; k < subarray_length; k++) {
let on = false;

// Start at 1 because the 0th entry in the array
// is an array of version numbers.
//
// End before the last element is reached because we never
// want to change the last element.
for (let i = 1; i < data.length - 1; i++) {
// k + 1 because the first entry in the array is the date
let value = data[i][k];

// If we are "off" and are looking at a zero
// replace the data at this point with `null`.
//
// Null tells google.visualization to stop drawing
// the line altogether.
if (!on && value === 0) {
data[i][k] = null;
}

// If we are off and the value is not zero, we
// need to turn back on. (keep the value the same though)
else if (!on && value !== 0) {
on = true;

// We previously wrote a null into data[i - 1][k + 1],
// so to make the graph look pretty, we'll switch it back
// to the zero that it was before.
if (i >= 2) {
data[i - 1][k] = 0;
}
}
// If we are on and the value is zero, turn off
// but keep the zero in the array
else if (on && value === 0) {
on = false;
}
if (animate) {
chart.update();
} else {
chart.update(0);
}
}
}

let { loaded, visualization } = this.googleCharts;

let show = data && loaded;
element.style.display = show ? '' : 'none';
if (!show) {
return;
}

let myData = visualization.arrayToDataTable(data);

let dateFmt = new visualization.DateFormat({
pattern: 'LLL d, yyyy',
});
dateFmt.format(myData, 0);

// Create a formatter to use for daily download numbers
let numberFormatWhole = new visualization.NumberFormat({
pattern: '#,##0',
});

// Create a formatter to use for 7-day average numbers
let numberFormatDecimal = new visualization.NumberFormat({
pattern: '#,##0.0',
});
@action destroyChart() {
this.chart.destroy();
}

// use a DataView to calculate an x-day moving average
let days = 7;
let view = new visualization.DataView(myData);
let moving_avg_func_for_col = function (col) {
return function (dt, row) {
// For the last rows (the *first* days, remember, the dataset is
// backwards), we cannot calculate the avg. of previous days.
if (row >= dt.getNumberOfRows() - days) {
return null;
}
@action reloadPage() {
window.location.reload();
}

let total = 0;
for (let i = days; i > 0; i--) {
total += dt.getValue(row + i, col);
}
let avg = total / days;
get data() {
let [labels, ...rows] = this.args.data;

let datasets = labels
.slice(1)
.map((label, index) => ({
data: rows.map(row => ({ x: row[0], y: row[index + 1] })),
label: label,
}))
.reverse()
.map(({ label, data }, index) => {
return {
v: avg,
f: numberFormatDecimal.formatValue(avg),
backgroundColor: BG_COLORS[index],
borderColor: COLORS[index],
borderWidth: 2,
cubicInterpolationMode: 'monotone',
data: data,
label: label,
pointHoverBorderWidth: 2,
pointHoverRadius: 5,
};
};
};

let columns = [0]; // 0 = datetime
let seriesOption = {};
let [headers] = data;
// Walk over the headers/colums in reverse order, as the newest version
// is at the end, but in the UI we want it at the top of the chart legend.

range(headers.length - 1, 0, -1).forEach((dataCol, i) => {
// Set the number format for the colum in the data table.
numberFormatWhole.format(myData, dataCol);
columns.push(dataCol); // add the column itself
columns.push({
// add a 'calculated' column, the moving average
type: 'number',
label: `${headers[dataCol]} ${days}-day avg.`,
calc: moving_avg_func_for_col(dataCol),
});
// Note: while the columns start with index 1 (because 0 is the time
// axis), the series configuration starts with index 0.
seriesOption[i * 2] = {
type: 'scatter',
color: COLORS[i % COLORS.length],
pointSize: 3,
pointShape: 'square',
};
seriesOption[i * 2 + 1] = {
type: 'area',
color: COLORS[i % COLORS.length],
lineWidth: 2,
curveType: 'function',
visibleInLegend: false,
};
});
view.setColumns(columns);

let chart = new visualization.ComboChart(element);
chart.draw(view, {
chartArea: { left: 85, width: '77%', height: '80%' },
hAxis: {
minorGridlines: { count: 8 },
},
vAxis: {
minorGridlines: { count: 5 },
viewWindow: { min: 0 },
},
isStacked: true,
focusTarget: 'category',
seriesType: 'scatter',
series: seriesOption,
});
}
}

function range(start, end, step) {
let array = [];
for (let i = start; i !== end; i += step) {
array.push(i);
return { datasets };
}
return array;
}
19 changes: 19 additions & 0 deletions app/components/download-graph.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
.wrapper {
display: grid;
place-items: center;
border: solid 1px var(--gray-border);
border-radius: 5px;
min-height: 400px;

&.auto-height {
min-height: auto;
}
}

.spinner {
transform: scale(3.0);
}

.error {
text-align: center;
}
7 changes: 0 additions & 7 deletions app/routes/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { shouldPolyfill as shouldPolyfillNumberFormat } from '@formatjs/intl-num
import { shouldPolyfill as shouldPolyfillPluralRules } from '@formatjs/intl-pluralrules/should-polyfill';

export default class ApplicationRoute extends Route {
@service googleCharts;
@service notifications;
@service progress;
@service session;
Expand All @@ -23,12 +22,6 @@ export default class ApplicationRoute extends Route {
// eslint-disable-next-line ember-concurrency/no-perform-without-catch
this.session.loadUserTask.perform();

// start loading the Google Charts JS library already
// and ignore any errors since we will catch them again
// anyway when we call `load()` from the `DownloadGraph`
// component
this.googleCharts.load().catch(() => {});

// load `Intl` polyfills if necessary
let polyfillImports = [];
if (shouldPolyfillGetCanonicalLocales()) {
Expand Down
12 changes: 12 additions & 0 deletions app/services/chartjs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import Service from '@ember/service';

import { task } from 'ember-concurrency';

export default class ChartJsLoader extends Service {
@(task(function* () {
let Chart = yield import('chart.js').then(module => module.default);
Chart.platform.disableCSSInjection = true;
return Chart;
}).drop())
loadTask;
}
Loading