From 127138f19e0093e019d77bc37459dc0d239c0c9b Mon Sep 17 00:00:00 2001 From: Tasos Katsoulas Date: Fri, 22 May 2026 16:40:41 +0300 Subject: [PATCH 1/2] Update all sumo dashboards * Switch to chart.js * Drop D3 --- .../community/jinja2/community/metrics.html | 24 +- .../jinja2/dashboards/aggregated_metrics.html | 28 - .../jinja2/dashboards/locale_metrics.html | 23 +- kitsune/kpi/jinja2/kpi/dashboard.html | 64 +- .../kpi/static/kpi/js/components/Chart.es6.js | 183 -- kitsune/kpi/static/kpi/js/kpi.browserify.js | 299 -- .../questions/jinja2/questions/metrics.html | 10 +- kitsune/sumo/static/sumo/js/charts/index.js | 39 + kitsune/sumo/static/sumo/js/charts/kpi.js | 183 ++ .../static/sumo/js/charts/questionsMetrics.js | 184 ++ .../sumo/static/sumo/js/charts/retention.js | 195 ++ .../sumo/static/sumo/js/charts/wikiHistory.js | 132 + .../sumo/static/sumo/js/charts/wikiMetrics.js | 356 ++ kitsune/sumo/static/sumo/js/historycharts.js | 88 - .../sumo/static/sumo/js/libs/d3.layout.min.js | 2 - kitsune/sumo/static/sumo/js/libs/rickshaw.js | 2850 ----------------- .../sumo/js/questions.metrics-dashboard.js | 143 - kitsune/sumo/static/sumo/js/rickshaw_utils.js | 1324 -------- kitsune/sumo/static/sumo/js/wiki.dashboard.js | 250 -- .../static/sumo/scss/components/_index.scss | 4 +- .../sumo/scss/components/_rickshaw.scss | 322 -- .../sumo/scss/components/_rickshaw.sumo.scss | 245 -- .../static/sumo/scss/components/_wiki.scss | 4 - kitsune/wiki/jinja2/wiki/history.html | 9 +- package-lock.json | 82 +- package.json | 5 +- webpack/entrypoints.js | 11 +- 27 files changed, 1178 insertions(+), 5881 deletions(-) delete mode 100644 kitsune/kpi/static/kpi/js/components/Chart.es6.js delete mode 100644 kitsune/kpi/static/kpi/js/kpi.browserify.js create mode 100644 kitsune/sumo/static/sumo/js/charts/index.js create mode 100644 kitsune/sumo/static/sumo/js/charts/kpi.js create mode 100644 kitsune/sumo/static/sumo/js/charts/questionsMetrics.js create mode 100644 kitsune/sumo/static/sumo/js/charts/retention.js create mode 100644 kitsune/sumo/static/sumo/js/charts/wikiHistory.js create mode 100644 kitsune/sumo/static/sumo/js/charts/wikiMetrics.js delete mode 100644 kitsune/sumo/static/sumo/js/historycharts.js delete mode 100644 kitsune/sumo/static/sumo/js/libs/d3.layout.min.js delete mode 100644 kitsune/sumo/static/sumo/js/libs/rickshaw.js delete mode 100644 kitsune/sumo/static/sumo/js/questions.metrics-dashboard.js delete mode 100644 kitsune/sumo/static/sumo/js/rickshaw_utils.js delete mode 100644 kitsune/sumo/static/sumo/scss/components/_rickshaw.scss delete mode 100644 kitsune/sumo/static/sumo/scss/components/_rickshaw.sumo.scss diff --git a/kitsune/community/jinja2/community/metrics.html b/kitsune/community/jinja2/community/metrics.html index c76f7864959..fd0b1681440 100644 --- a/kitsune/community/jinja2/community/metrics.html +++ b/kitsune/community/jinja2/community/metrics.html @@ -31,13 +31,7 @@

{{ _('Retention') }}

{# The locale code is not localizable - the data are for English only. #}

{{ _('Contributor Satisfaction: %(locale)s', locale='en-US') }}

-
-
-
-
-
-
-
+
{{ _('Contributor Satisfaction: %(locale)s', lo

{{ _('Contributor Satisfaction: l10n') }}

-
-
-
-
-
-
-
+
{{ _('Contributor Satisfaction: l10n') }}

{{ _('Contributor Satisfaction: Support Forum') }}

-
-
-
-
-
-
-
+
diff --git a/kitsune/dashboards/jinja2/dashboards/aggregated_metrics.html b/kitsune/dashboards/jinja2/dashboards/aggregated_metrics.html index eacfd64db47..60ea8de4b5c 100644 --- a/kitsune/dashboards/jinja2/dashboards/aggregated_metrics.html +++ b/kitsune/dashboards/jinja2/dashboards/aggregated_metrics.html @@ -23,48 +23,20 @@

{{ title }}

{# Double % required below to escape % for gettext #}

{{ _('Top 100 Articles: %% Localized') }}

-
-
-
-
-
-
-
{# Double % required below to escape % for gettext #}

{{ _('Top 20 Articles: %% Localized') }}

-
-
-
-
-
-
-
{# Double % required below to escape % for gettext #}

{{ _('All Articles: %% Localized') }}

-
-
-
-
-
-
-

{{ _('Active Contributors') }}

-
-
-
-
-
-
-
diff --git a/kitsune/dashboards/jinja2/dashboards/locale_metrics.html b/kitsune/dashboards/jinja2/dashboards/locale_metrics.html index 7e89220f2f0..82b7d61343c 100644 --- a/kitsune/dashboards/jinja2/dashboards/locale_metrics.html +++ b/kitsune/dashboards/jinja2/dashboards/locale_metrics.html @@ -18,36 +18,15 @@

{{ title }}

{% if current_locale != settings.WIKI_DEFAULT_LANGUAGE %}

{{ _('Localization Percentage') }}

-
-
-
-
-
-
-
{% endif %}

{{ _('Active Contributors') }}

-
-
-
-
-
-
-
-
+

{{ _('Helpful Votes') }}

-
-
-
-
-
-
-
{% endblock %} diff --git a/kitsune/kpi/jinja2/kpi/dashboard.html b/kitsune/kpi/jinja2/kpi/dashboard.html index 5c09802a53d..17cd4d1949a 100644 --- a/kitsune/kpi/jinja2/kpi/dashboard.html +++ b/kitsune/kpi/jinja2/kpi/dashboard.html @@ -20,13 +20,7 @@

{{ _('Retention') }}

{{ _('Contributor Satisfaction') }}

-
-
-
-
-
-
-
+
{{ _('Contributor Satisfaction') }}

{{ _('Questions') }}

-
-
-
-
-
-
-
+
{{ _('Daily Unique Visitors') }}

{{ _('L10n Coverage') }}

-
-
-
-
-
-
-
+
diff --git a/kitsune/kpi/static/kpi/js/components/Chart.es6.js b/kitsune/kpi/static/kpi/js/components/Chart.es6.js deleted file mode 100644 index 924675015e3..00000000000 --- a/kitsune/kpi/static/kpi/js/components/Chart.es6.js +++ /dev/null @@ -1,183 +0,0 @@ -import _filter from "underscore/modules/filter"; -import d3 from "d3"; - -/* jshint esnext: true */ - -export default class Chart { - constructor($container, options) { - let defaults = { - chartColors: ['#EE2820', '#F25743', '#F58667', '#F9B58B', '#FDE4AF', '#E3F1B6', '#AADA9F', '#72C489', '#39AD72', '#00975C'], - axes: { - xAxis: { - labels: [], - labelOffsets: { x: 15, y: 23 } - }, - yAxis: { - labels: [], - labelOffsets: { x: 5, y: 23 } - }, - getPosition: (position, axis, index) => { - let gridSize = (position === 'y') ? this.gridSize/2 : this.gridSize; - - if (position === axis[0]) { - return index * gridSize; - } else { - return -gridSize; - } - } - }, - colorScale() { - return d3.scale.quantize() - .domain([0, 100]) - .range(this.chartColors) - }, - margin: { top: 40, right: 0, bottom: 20, left: 75 }, - width: 860, - height: 440, - grid: { rows: 12, columns: 12 }, - gridSize: 71, - buckets: 10, - legendElementWidth: 95, - data: [], - dom: { - graphContainer: $container.find('.graph').get()[0] - } - }; - - $.extend(true, this, defaults, options); - - this.init(); - } - - // Render whatever pieces of the chart we can while waiting for data - preRender() { - // draw the container svg for the chart - this.dom.svg = d3.select(this.dom.graphContainer).append('svg') - .attr('width', this.width + this.margin.left + this.margin.right) - .attr('height', this.height + this.margin.top + this.margin.bottom) - .append('g') - .attr('transform', `translate(${this.margin.left}, ${this.margin.top})`); - - this.dom.svg.append('g') - .attr('class', 'data'); - - this.setupAxis('xAxis'); - this.setupLegend(); - } - - setupAxis(axis) { - let axisGroup = this.dom.svg.append('g') - .attr('class', axis); - - axisGroup.selectAll() - .data(this.axes[axis].labels) - .enter().append('text') - .text((d,i) => d) - .attr('x', (d, i) => { - return this.axes.getPosition('x', axis, i) + this.axes[axis].labelOffsets.x; - }) - .attr('y', (d, i) => { - return this.axes.getPosition('y', axis, i) + this.axes[axis].labelOffsets.y; - }) - } - - setupLegend() { - let legendData = this.chartColors; - let legendYPosition = (this.grid.rows * this.gridSize/2 + 15); - let legendXPositions = i => (this.legendElementWidth * i) - this.gridSize; - - let legend = this.dom.svg.append('g') - .attr('class', 'legend'); - - legend.selectAll('rect') - .data(legendData, function(d) { return d; }) - .enter().append('rect') - .attr('x', (d, i) => legendXPositions(i)) - .attr('y', legendYPosition) - .attr('width', this.legendElementWidth) - .attr('height', this.gridSize / 3.6) - .style('fill', (d, i) => this.chartColors[i]); - - legend.selectAll('text') - .data(legendData, function(d) { return d; }) - .enter().append('text') - .text((d, i) => `≥${Math.round(i / this.buckets * 100)}%`) - .attr('x', (d, i) => legendXPositions(i) + 7) - .attr('y', legendYPosition + 14); - } - - init() { - this.preRender(); - } - - setupGrid(filteredData, filter) { - let kindFilter = filter; - - this.dom.cohorts = this.dom.svg.select('.data').selectAll('g') - .data(filteredData); - - this.dom.cohorts.enter().append('g'); - - this.dom.cohorts - .attr('width', this.width) - .attr('height', this.gridSize/2) - .attr('x', 0) - .attr('y', (d, i) => i * this.gridSize / 2) - .attr('class', `cohort-group ${kindFilter}`) - .attr('id', (d, i) => `cohort-group-${i}`); - - this.dom.cohorts.exit().remove(); - } - - populateData(filter) { - let self = this; - let kindFilter = filter; - let filteredData = _filter(this.data, function(datum, index) { - return datum.kind === kindFilter; - }); - - this.setupGrid(filteredData, kindFilter); - - this.dom.cohorts.each((cohort, i) => { - let cohortGroupNumber = i; - let cohortOriginalSize = cohort.size; - let gSelection = d3.select('#cohort-group-' + i); - - let coloredBoxes = gSelection.selectAll('rect') - .data(cohort.retention_metrics); - - let sizeText = gSelection.selectAll('text') - .data(cohort.retention_metrics); - - coloredBoxes.enter().append('rect'); - - coloredBoxes - .attr('class', 'retention-week') - .attr('height', this.gridSize/2) - .attr('width', this.gridSize) - .attr('x', (d, i) => i * this.gridSize) - .attr('y', (d, i) => cohortGroupNumber * this.gridSize/2) - .style('fill', (d) => { - return this.colorScale()(Math.floor((d.size / cohortOriginalSize) * 100) || 0); - }) - .style('stroke', '#000') - .style('stroke-opacity', 0.05) - .style('stroke-width', 1); - - coloredBoxes.exit().remove(); - - sizeText.enter().append('text'); - - sizeText - .text(function(d, i) { - let percentage = Math.floor((d.size / cohortOriginalSize) * 100) || 0; - return `${d.size} (${percentage}%)`; - }) - .attr('x', (d, i) => i * this.gridSize + 10) - .attr('y', (d, i) => (cohortGroupNumber * this.gridSize/2) + 23); - - sizeText.exit().remove(); - - }); - } -} diff --git a/kitsune/kpi/static/kpi/js/kpi.browserify.js b/kitsune/kpi/static/kpi/js/kpi.browserify.js deleted file mode 100644 index 591a860b475..00000000000 --- a/kitsune/kpi/static/kpi/js/kpi.browserify.js +++ /dev/null @@ -1,299 +0,0 @@ -import Chart from './components/Chart.es6'; -import _each from "underscore/modules/each"; -import _range from "underscore/modules/range"; -import _uniq from "underscore/modules/uniq"; -import _pluck from "underscore/modules/pluck"; -import { Graph } from "sumo/js/rickshaw_utils"; - -let chartSetups = { - 'retention': { - 'options': { - axes: { - xAxis: { - labels() { - let labelsArray = []; - _each(_range(1, 13), function(val) { - labelsArray.push(gettext('Week') + ' ' + val); - }); - return labelsArray; - } - } - } - } - }, - 'csat': { - 'bucket': true, - 'descriptors': [ - { - name: gettext('Average Satisfaction'), - slug: 'csat', - func(d) { return d.csat / 100; }, - type: 'percent' - } - ] - }, - 'questions': { - 'bucket': true, - 'descriptors': [ - { - name: gettext('Questions'), - slug: 'questions', - func: Graph.identity('questions'), - color: '#5d84b2', - axisGroup: 'questions', - area: true - }, - { - name: gettext('Solved'), - slug: 'num_solved', - func: Graph.identity('solved'), - color: '#aa4643', - axisGroup: 'questions', - area: true - }, - { - name: gettext('% Solved'), - slug: 'solved', - func: Graph.fraction('solved', 'questions'), - color: '#aa4643', - axisGroup: 'percent', - type: 'percent' - }, - { - name: gettext('Responded in 24 hours'), - slug: 'num_responded_24', - func: Graph.identity('responded_24'), - color: '#89a54e', - axisGroup: 'questions', - area: true - }, - { - name: gettext('% Responded in 24 hours'), - slug: 'responded_24', - func: Graph.fraction('responded_24', 'questions'), - color: '#89a54e', - axisGroup: 'percent', - type: 'percent' - }, - { - name: gettext('Responded in 72 hours'), - slug: 'num_responded_72', - func: Graph.identity('responded_72'), - color: '#80699b', - axisGroup: 'questions', - area: true - }, - { - name: gettext('% Responded in 72 hours'), - slug: 'responded_72', - func: Graph.fraction('responded_72', 'questions'), - color: '#80699b', - axisGroup: 'percent', - type: 'percent' - }, - { - name: gettext('Not responded in 24 hours'), - slug: 'not_responded_24', - color: '#C98531', - func: Graph.difference('questions', 'responded_24'), - area: true - }, - { - name: gettext('Not responded in 72 hours'), - slug: 'not_responded_72', - color: '#DB75C2', - func: Graph.difference('questions', 'responded_72'), - area: true - } - ] - }, - 'vote': { - 'bucket': true, - 'descriptors': [ - { - name: gettext('Article Votes: % Helpful'), - slug: 'wiki_percent', - func: Graph.fraction('kb_helpful', 'kb_votes'), - type: 'percent' - }, - { - name: gettext('Answer Votes: % Helpful'), - slug: 'ans_percent', - func: Graph.fraction('ans_helpful', 'ans_votes'), - type: 'percent' - } - ] - }, - 'activeContributors': { - 'bucket': false, - 'descriptors': [ - { - name: gettext('en-US KB'), - slug: 'en_us', - func: Graph.identity('en_us') - }, - { - name: gettext('non en-US KB'), - slug: 'non_en_us', - func: Graph.identity('non_en_us') - }, - { - name: gettext('Support Forums'), - slug: 'support_forum', - func: Graph.identity('support_forum') - } - ] - }, - 'ctr': { - 'bucket': true, - 'descriptors': [ - { - name: gettext('Click Through Rate %'), - slug: 'ctr', - func: Graph.fraction('clicks', 'searches'), - type: 'percent' - } - ] - }, - 'visitors': { - 'bucket': true, - 'descriptors': [ - { - name: gettext('Visitors'), - slug: 'visitors', - func: Graph.identity('visitors') - } - ] - }, - 'l10n': { - 'container': $('#kpi-l10n'), - 'bucket': true, - 'descriptors': [ - { - name: gettext('L10n Coverage'), - slug: 'l10n', - // the api returns 0 to 100, we want 0.0 to 1.0. - func(d) { return d.coverage / 100; }, - type: 'percent' - } - ] - }, - 'exitSurvey': { - 'bucket': true, - 'descriptors': [ - { - name: gettext('Percent Yes'), - slug: 'percent_yes', - func: Graph.percentage('yes', 'no', 'dont_know'), - axisGroup: 'percent', - type: 'percent' - }, - { - name: gettext('Yes'), - slug: 'yes', - func: Graph.identity('yes'), - axisGroup: 'response' - }, - { - name: gettext('No'), - slug: 'no', - func: Graph.identity('no'), - axisGroup: 'response' - }, - { - name: gettext("I don't know"), - slug: 'dont_know', - func: Graph.identity('dont_know'), - axisGroup: 'response' - } - ] - } -}; - -$('.graph').each(function() { - let $graphElem = $(this); - let chartType = $graphElem.data('chart-type'); - let chartSlug = $graphElem.data('slug'); - let chartSettings = chartSetups[chartSlug]; - chartSettings.container = $graphElem.closest('section'); - - (chartType === 'd3') ? makeRetentionChart(chartSettings) : makeKPIGraph(chartSettings); -}) - -function getChartData(url, propertyKey) { - let dataReady = $.Deferred(); - let datumsToCollect = propertyKey; - let fetchData = (url, existingData) => { - $.getJSON(url, function(data) { - existingData = existingData.concat(data[datumsToCollect] || data); - if (data.next) { - return fetchData(data.next, existingData); - } else { - dataReady.resolve(existingData); - } - }).fail(function(error) { - dataReady.reject(error); - }); - } - - fetchData(url, []); - return dataReady; -} - -function handleDataError($container) { - let errorMsg = document.createElement('p'); - $(errorMsg).text(gettext('Error loading graph')).addClass('error'); - $container.empty().append(errorMsg); -} - -function makeRetentionChart(settings) { - let $container = settings.container; - let startDate = new Date(); - startDate.setDate(startDate.getDate() - startDate.getDay() - 84); - startDate = startDate.toISOString().split("T")[0]; - let defaultContributorType = $container.data('contributor-type') || 'contributor'; - let urlToFetch = `${$container.data('url')}?start=${startDate}`; - let fetchDataset = getChartData(urlToFetch, 'results'); - let retentionChart = new Chart($container, settings.options); - let $errorContainer = $container.children('div'); - - fetchDataset.done(data => { - retentionChart.data = data; - retentionChart.axes.yAxis.labels = _uniq(_pluck(data, 'start')); - retentionChart.setupAxis('yAxis'); - retentionChart.populateData(defaultContributorType); - - $('#toggle-cohort-type').change(function() { - let cohortType = $(this).val(); - retentionChart.populateData(cohortType); - }); - - }).fail((xhr, status, error) => { - handleDataError($errorContainer); - }); -} - -function makeKPIGraph(settings) { - let $container = settings.container; - let fetchDataset = getChartData($container.data('url'), 'objects'); - let $errorContainer = $container.children('div'); - fetchDataset.done(data => { - new Graph($container, { - data: { - datums: data, - seriesSpec: settings.descriptors - }, - options: { - legend: 'mini', - slider: true, - bucket: settings.bucket - }, - graph: { - width: 880, - height: 300 - }, - }).render(); - }).fail((xhr, status, error) => { - handleDataError($errorContainer); - }); -} diff --git a/kitsune/questions/jinja2/questions/metrics.html b/kitsune/questions/jinja2/questions/metrics.html index b33c66aab14..9e07be93752 100644 --- a/kitsune/questions/jinja2/questions/metrics.html +++ b/kitsune/questions/jinja2/questions/metrics.html @@ -46,15 +46,7 @@

{{ _('Support Forum Metrics') }}

{% if product %} {% set api_url = api_url|urlparams(product=product.slug) %} {% endif %} -
-
-
-
-
-
-
-
-
+
{% endblock %} {% block side %} diff --git a/kitsune/sumo/static/sumo/js/charts/index.js b/kitsune/sumo/static/sumo/js/charts/index.js new file mode 100644 index 00000000000..dbd257fbbae --- /dev/null +++ b/kitsune/sumo/static/sumo/js/charts/index.js @@ -0,0 +1,39 @@ +import { + Chart, + LineController, + LineElement, + PointElement, + LinearScale, + TimeScale, + Legend, + Tooltip, + Title, + Filler, + CategoryScale, +} from "chart.js"; +// Side-effect import: registers the date-fns adapter with Chart.js +import "chartjs-adapter-date-fns"; +import { MatrixController, MatrixElement } from "chartjs-chart-matrix"; + +Chart.register( + LineController, + LineElement, + PointElement, + LinearScale, + TimeScale, + Legend, + Tooltip, + Title, + Filler, + CategoryScale, + MatrixController, + MatrixElement +); + +export function renderLineChart(el, config) { + return new Chart(el, config); +} + +export function renderMatrixChart(el, config) { + return new Chart(el, config); +} diff --git a/kitsune/sumo/static/sumo/js/charts/kpi.js b/kitsune/sumo/static/sumo/js/charts/kpi.js new file mode 100644 index 00000000000..1ce2e958a44 --- /dev/null +++ b/kitsune/sumo/static/sumo/js/charts/kpi.js @@ -0,0 +1,183 @@ +import { renderLineChart } from "sumo/js/charts"; +import { parseISO } from "date-fns"; + +const COLORS = { + blue: "#5d84b2", + red: "#aa4643", + green: "#89a54e", + purple: "#80699b", + orange: "#C98531", + magenta: "#DB75C2", + cyan: "#3d96ae", + brown: "#a47d7c", + lime: "#b5ca92", +}; + +// Each entry: list of datasets to plot. Each dataset has: +// - label: gettext-wrapped display label +// - color: hex +// - axis: "y" (counts) or "y1" (percent) +// - extract: (object) -> y value (or null) +// - dashed?: bool +// - filled?: bool +const CHART_SPECS = { + csat: () => [ + { + label: gettext("Average Satisfaction"), + color: COLORS.blue, + axis: "y1", + filled: true, + extract: (o) => (o.csat != null ? o.csat : null), // already 0-100 from API + }, + ], + questions: () => [ + { label: gettext("Questions"), color: COLORS.blue, axis: "y", filled: true, extract: (o) => o.questions }, + { label: gettext("Solved"), color: COLORS.red, axis: "y", filled: true, extract: (o) => o.solved }, + { label: gettext("Responded in 24 hours"), color: COLORS.green, axis: "y", filled: true, extract: (o) => o.responded_24 }, + { label: gettext("Responded in 72 hours"), color: COLORS.purple, axis: "y", filled: true, extract: (o) => o.responded_72 }, + { label: gettext("Not responded in 24 hours"), color: COLORS.orange, axis: "y", filled: true, extract: (o) => (o.questions || 0) - (o.responded_24 || 0) }, + { label: gettext("Not responded in 72 hours"), color: COLORS.magenta, axis: "y", filled: true, extract: (o) => (o.questions || 0) - (o.responded_72 || 0) }, + { label: gettext("% Solved"), color: COLORS.red, axis: "y1", dashed: true, extract: (o) => o.questions > 0 ? (100 * o.solved) / o.questions : null }, + { label: gettext("% Responded in 24 hours"), color: COLORS.green, axis: "y1", dashed: true, extract: (o) => o.questions > 0 ? (100 * o.responded_24) / o.questions : null }, + { label: gettext("% Responded in 72 hours"), color: COLORS.purple, axis: "y1", dashed: true, extract: (o) => o.questions > 0 ? (100 * o.responded_72) / o.questions : null }, + ], + vote: () => [ + { label: gettext("Article Votes: % Helpful"), color: COLORS.blue, axis: "y1", extract: (o) => o.kb_votes > 0 ? (100 * o.kb_helpful) / o.kb_votes : null }, + { label: gettext("Answer Votes: % Helpful"), color: COLORS.red, axis: "y1", extract: (o) => o.ans_votes > 0 ? (100 * o.ans_helpful) / o.ans_votes : null }, + ], + activeContributors: () => [ + { label: gettext("en-US KB"), color: COLORS.blue, axis: "y", extract: (o) => o.en_us }, + { label: gettext("non en-US KB"), color: COLORS.red, axis: "y", extract: (o) => o.non_en_us }, + { label: gettext("Support Forums"), color: COLORS.green, axis: "y", extract: (o) => o.support_forum }, + ], + ctr: () => [ + { label: gettext("Click Through Rate %"), color: COLORS.blue, axis: "y1", extract: (o) => o.searches > 0 ? (100 * o.clicks) / o.searches : null }, + ], + visitors: () => [ + { label: gettext("Visitors"), color: COLORS.blue, axis: "y", filled: true, extract: (o) => o.visitors }, + ], + l10n: () => [ + { label: gettext("L10n Coverage"), color: COLORS.blue, axis: "y1", extract: (o) => o.coverage }, + ], + exitSurvey: () => [ + { label: gettext("Percent Yes"), color: COLORS.green, axis: "y1", dashed: true, extract: (o) => (o.yes + o.no + o.dont_know) > 0 ? (100 * o.yes) / (o.yes + o.no + o.dont_know) : null }, + { label: gettext("Yes"), color: COLORS.green, axis: "y", extract: (o) => o.yes }, + { label: gettext("No"), color: COLORS.red, axis: "y", extract: (o) => o.no }, + { label: gettext("I don't know"), color: COLORS.brown, axis: "y", extract: (o) => o.dont_know }, + ], +}; + +async function fetchAllPages(url, maxPages = 60) { + const out = []; + let next = url; + let count = 0; + while (next && count < maxPages) { + const r = await fetch(next); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const d = await r.json(); + out.push(...(d.objects || [])); + next = d.next; + count++; + } + return out; +} + +function replaceWithCanvas(section, height = 320) { + section.querySelectorAll(".rickshaw").forEach((el) => el.remove()); + const wrap = document.createElement("div"); + wrap.style.cssText = `position: relative; height: ${height}px; width: 100%; margin-bottom: 16px;`; + const canvas = document.createElement("canvas"); + wrap.appendChild(canvas); + section.appendChild(wrap); + return canvas; +} + +function buildConfig(datums, specFactory) { + const specs = specFactory(); + const hasY1 = specs.some((s) => s.axis === "y1"); + const datasets = specs.map((s) => ({ + label: s.label, + borderColor: s.color, + backgroundColor: s.color, + pointRadius: 0, + borderWidth: 1.5, + fill: !!s.filled, + borderDash: s.dashed ? [6, 4] : undefined, + yAxisID: s.axis, + data: datums.map((o) => ({ x: parseISO(o.date), y: s.extract(o) })), + })); + + return { + type: "line", + data: { datasets }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + x: { + type: "time", + time: { + tooltipFormat: "PP", + displayFormats: { day: "MMM d", week: "MMM d", month: "MMM yyyy", quarter: "MMM yyyy", year: "yyyy" }, + }, + }, + y: { + display: specs.some((s) => s.axis === "y"), + beginAtZero: true, + position: "left", + title: { display: true, text: gettext("Count") }, + }, + y1: { + display: hasY1, + beginAtZero: true, + position: "right", + min: 0, + max: 100, + grid: { drawOnChartArea: false }, + title: { display: true, text: gettext("Percent") }, + }, + }, + plugins: { + legend: { position: "bottom" }, + tooltip: { + mode: "index", + callbacks: { + label: (ctx) => { + const v = ctx.parsed.y; + if (v == null) return null; + const isPct = ctx.dataset.yAxisID === "y1"; + return `${ctx.dataset.label}: ${isPct ? v.toFixed(1) + "%" : v.toLocaleString()}`; + }, + }, + }, + }, + }, + }; +} + +document.addEventListener("DOMContentLoaded", () => { + const graphDivs = document.querySelectorAll('.graph[data-chart-type="rickshaw"]'); + if (graphDivs.length === 0) return; + + const schedule = window.requestIdleCallback || ((cb) => setTimeout(cb, 0)); + + graphDivs.forEach(async (div) => { + const slug = div.dataset.slug; + const specFactory = CHART_SPECS[slug]; + const section = div.closest("section"); + if (!specFactory || !section) return; + + const url = section.dataset.url; + if (!url) return; + + try { + const datums = await fetchAllPages(url); + const sorted = [...datums].sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0)); + const canvas = replaceWithCanvas(section); + schedule(() => renderLineChart(canvas, buildConfig(sorted, specFactory))); + } catch { + const target = section.querySelector(".rickshaw") || section; + target.textContent = gettext("Error loading graph"); + } + }); +}); diff --git a/kitsune/sumo/static/sumo/js/charts/questionsMetrics.js b/kitsune/sumo/static/sumo/js/charts/questionsMetrics.js new file mode 100644 index 00000000000..6f1203eed3d --- /dev/null +++ b/kitsune/sumo/static/sumo/js/charts/questionsMetrics.js @@ -0,0 +1,184 @@ +import { renderLineChart } from "sumo/js/charts"; +import { parseISO } from "date-fns"; + +document.addEventListener("DOMContentLoaded", () => { + const container = document.getElementById("questions-metrics"); + if (!container) return; + + fetch(container.dataset.url) + .then((response) => response.json()) + .then((data) => { + const objects = data.objects + .map((o) => ({ + date: o.date, + questions: o.questions || 0, + solved: o.solved || 0, + responded_24: o.responded_24 || 0, + responded_72: o.responded_72 || 0, + })) + .sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0)); + + container.innerHTML = ""; + const wrap = document.createElement("div"); + wrap.style.position = "relative"; + wrap.style.height = "300px"; + wrap.style.width = "100%"; + const canvas = document.createElement("canvas"); + wrap.appendChild(canvas); + container.appendChild(wrap); + + const schedule = window.requestIdleCallback || ((cb) => setTimeout(cb, 0)); + schedule(() => { + renderLineChart(canvas, buildConfig(objects)); + }); + }) + .catch(() => { + container.textContent = gettext("Error loading graph"); + }); +}); + +function buildConfig(objects) { + return { + type: "line", + data: { + datasets: [ + { + label: gettext("Questions"), + borderColor: "#5d84b2", + backgroundColor: "#5d84b2", + yAxisID: "y", + pointRadius: 0, + borderWidth: 1.5, + fill: true, + data: objects.map((o) => ({ x: parseISO(o.date), y: o.questions })), + }, + { + label: gettext("Solved"), + borderColor: "#aa4643", + backgroundColor: "#aa4643", + yAxisID: "y", + pointRadius: 0, + borderWidth: 1.5, + fill: true, + data: objects.map((o) => ({ x: parseISO(o.date), y: o.solved })), + }, + { + label: gettext("Responded in 24 hours"), + borderColor: "#89a54e", + backgroundColor: "#89a54e", + yAxisID: "y", + pointRadius: 0, + borderWidth: 1.5, + fill: true, + data: objects.map((o) => ({ x: parseISO(o.date), y: o.responded_24 })), + }, + { + label: gettext("Responded in 72 hours"), + borderColor: "#80699b", + backgroundColor: "#80699b", + yAxisID: "y", + pointRadius: 0, + borderWidth: 1.5, + fill: true, + data: objects.map((o) => ({ x: parseISO(o.date), y: o.responded_72 })), + }, + { + label: gettext("% Solved"), + borderColor: "#aa4643", + backgroundColor: "#aa4643", + borderDash: [6, 4], + yAxisID: "y1", + pointRadius: 0, + borderWidth: 1.5, + fill: false, + data: objects.map((o) => ({ + x: parseISO(o.date), + y: o.questions > 0 ? (100 * o.solved) / o.questions : null, + })), + }, + { + label: gettext("% Responded in 24 hours"), + borderColor: "#89a54e", + backgroundColor: "#89a54e", + borderDash: [6, 4], + yAxisID: "y1", + pointRadius: 0, + borderWidth: 1.5, + fill: false, + data: objects.map((o) => ({ + x: parseISO(o.date), + y: o.questions > 0 ? (100 * o.responded_24) / o.questions : null, + })), + }, + { + label: gettext("% Responded in 72 hours"), + borderColor: "#80699b", + backgroundColor: "#80699b", + borderDash: [6, 4], + yAxisID: "y1", + pointRadius: 0, + borderWidth: 1.5, + fill: false, + data: objects.map((o) => ({ + x: parseISO(o.date), + y: o.questions > 0 ? (100 * o.responded_72) / o.questions : null, + })), + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + x: { + type: "time", + time: { + tooltipFormat: "PP", + displayFormats: { + day: "MMM d", + week: "MMM d", + month: "MMM yyyy", + quarter: "MMM yyyy", + year: "yyyy", + }, + }, + }, + y: { + position: "left", + beginAtZero: true, + title: { + display: true, + text: gettext("Questions"), + }, + }, + y1: { + position: "right", + min: 0, + max: 100, + grid: { drawOnChartArea: false }, + title: { + display: true, + text: gettext("Percent"), + }, + }, + }, + plugins: { + legend: { + position: "bottom", + }, + tooltip: { + mode: "index", + callbacks: { + label: (ctx) => { + const v = ctx.parsed.y; + if (v == null) return null; + const isPct = ctx.dataset.yAxisID === "y1"; + const formatted = isPct ? `${v.toFixed(1)}%` : v.toLocaleString(); + return `${ctx.dataset.label}: ${formatted}`; + }, + }, + }, + }, + }, + }; +} diff --git a/kitsune/sumo/static/sumo/js/charts/retention.js b/kitsune/sumo/static/sumo/js/charts/retention.js new file mode 100644 index 00000000000..9128cc03a32 --- /dev/null +++ b/kitsune/sumo/static/sumo/js/charts/retention.js @@ -0,0 +1,195 @@ +const CHART_COLORS = [ + "#EE2820", + "#F25743", + "#F58667", + "#F9B58B", + "#FDE4AF", + "#E3F1B6", + "#AADA9F", + "#72C489", + "#39AD72", + "#00975C", +]; + + +function colorFor(percentage) { + return CHART_COLORS[Math.min(9, Math.floor((percentage || 0) / 10))]; +} + +function computeStartDate() { + const d = new Date(); + d.setDate(d.getDate() - d.getDay() - 84); + return d.toISOString().split("T")[0]; +} + +async function fetchAllPages(url) { + const results = []; + let next = url; + while (next) { + const response = await fetch(next); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + results.push(...(data.results || [])); + next = data.next || null; + } + return results; +} + +function buildGridData(allData, kind) { + const filtered = allData.filter((item) => item.kind === kind); + // Newest cohorts at top — sort descending by start date + filtered.sort((a, b) => (a.start > b.start ? -1 : a.start < b.start ? 1 : 0)); + const limited = filtered.slice(0, 12); + + const points = []; + for (const cohort of limited) { + const cohortSize = cohort.size; + cohort.retention_metrics.forEach((metric, idx) => { + const percentage = cohortSize > 0 ? (metric.size / cohortSize) * 100 : 0; + points.push({ + x: String(idx + 1), + y: cohort.start, + v: metric.size, + percentage, + }); + }); + } + + const rowLabels = limited.map((c) => c.start); + return { points, rowLabels }; +} + +function buildLegend() { + const legend = document.createElement("div"); + legend.className = "retention-legend"; + legend.style.cssText = "display:flex;flex-wrap:wrap;gap:4px;margin-top:8px;font-size:12px;"; + + CHART_COLORS.forEach((color, i) => { + const span = document.createElement("span"); + span.style.cssText = `background-color:${color};padding:2px 6px;color:#333;border-radius:2px;`; + span.textContent = `≥${i * 10}%`; + legend.appendChild(span); + }); + + return legend; +} + +function buildGridElement(points, rowLabels) { + const COLUMNS = 12; + // Index points by [row][col] + const rowIndex = new Map(rowLabels.map((d, i) => [d, i])); + const cells = rowLabels.map(() => new Array(COLUMNS).fill(null)); + for (const p of points) { + const r = rowIndex.get(p.y); + const c = parseInt(p.x, 10) - 1; + if (r != null && c >= 0 && c < COLUMNS) cells[r][c] = p; + } + + const grid = document.createElement("div"); + grid.className = "retention-grid"; + grid.style.cssText = ` + display: grid; + grid-template-columns: auto repeat(${COLUMNS}, 1fr); + gap: 1px; + background: #ddd; + border: 1px solid #ddd; + font-size: 12px; + `; + + // Header row: top-left empty, then column labels 1..12 + const corner = document.createElement("div"); + corner.style.cssText = "background:#fff;padding:4px;"; + grid.appendChild(corner); + for (let c = 1; c <= COLUMNS; c++) { + const cell = document.createElement("div"); + cell.style.cssText = "background:#fff;padding:4px;text-align:center;font-weight:600;"; + cell.textContent = String(c); + grid.appendChild(cell); + } + + // Body rows + for (let r = 0; r < rowLabels.length; r++) { + const rowHeader = document.createElement("div"); + rowHeader.style.cssText = "background:#fff;padding:4px;white-space:nowrap;"; + rowHeader.textContent = rowLabels[r]; + grid.appendChild(rowHeader); + + for (let c = 0; c < COLUMNS; c++) { + const p = cells[r][c]; + const cell = document.createElement("div"); + const bg = p ? colorFor(p.percentage) : "#f4f4f4"; + cell.style.cssText = ` + background:${bg}; + padding:4px; + text-align:center; + color:#222; + `; + if (p) { + const pct = Math.round(p.percentage); + cell.textContent = `${p.v.toLocaleString()} (${pct}%)`; + cell.title = `${rowLabels[r]} · ${gettext("Week")} ${c + 1}: ${p.v.toLocaleString()} (${pct}%)`; + } + grid.appendChild(cell); + } + } + + return grid; +} + +function renderRetentionChart(chartArea, allData, kind) { + const { points, rowLabels } = buildGridData(allData, kind); + + // Remove any previous grid + legend, preserve inline-controls + for (const child of Array.from(chartArea.children)) { + if (!child.classList.contains("inline-controls")) child.remove(); + } + + if (rowLabels.length === 0) { + const msg = document.createElement("p"); + msg.textContent = gettext("No cohort data available for this period."); + chartArea.appendChild(msg); + return; + } + + const header = document.createElement("p"); + header.textContent = gettext("Weeks since cohort start"); + header.style.cssText = "font-size:12px;color:#666;margin:8px 0 4px;"; + chartArea.appendChild(header); + + const grid = buildGridElement(points, rowLabels); + chartArea.appendChild(grid); + chartArea.appendChild(buildLegend()); +} + +document.addEventListener("DOMContentLoaded", () => { + const container = document.getElementById("kpi-cohort-analysis"); + if (!container) return; + + const startDate = computeStartDate(); + const url = `${container.dataset.url}?start=${startDate}`; + const defaultKind = container.dataset.contributorType || "contributor:kb:en-US"; + const chartArea = container.querySelector("#retention-chart") || container; + + fetchAllPages(url) + .then((allData) => { + renderRetentionChart(chartArea, allData, defaultKind); + + const select = document.getElementById("toggle-cohort-type"); + if (select) { + select.addEventListener("change", () => { + renderRetentionChart(chartArea, allData, select.value); + }); + } + }) + .catch(() => { + for (const child of Array.from(chartArea.children)) { + if (!child.classList.contains("inline-controls")) { + child.remove(); + } + } + const err = document.createElement("p"); + err.className = "error"; + err.textContent = gettext("Error loading graph"); + chartArea.appendChild(err); + }); +}); diff --git a/kitsune/sumo/static/sumo/js/charts/wikiHistory.js b/kitsune/sumo/static/sumo/js/charts/wikiHistory.js new file mode 100644 index 00000000000..5908a097862 --- /dev/null +++ b/kitsune/sumo/static/sumo/js/charts/wikiHistory.js @@ -0,0 +1,132 @@ +import { renderLineChart } from "sumo/js/charts"; +import { fromUnixTime } from "date-fns"; + +document.addEventListener("DOMContentLoaded", () => { + const showGraph = document.getElementById("show-graph"); + if (!showGraph) return; + + showGraph.addEventListener("click", function handler() { + showGraph.textContent = gettext("Loading..."); + // Remove click handler immediately so double-clicks don't re-trigger + showGraph.removeEventListener("click", handler); + + const helpfulGraph = document.getElementById("helpful-graph"); + + fetch(helpfulGraph.dataset.url) + .then((response) => response.json()) + .then((data) => { + if (data.datums.length === 0) { + showGraph.textContent = gettext("No votes data"); + return; + } + + helpfulGraph.innerHTML = ""; + const wrap = document.createElement("div"); + wrap.style.position = "relative"; + wrap.style.height = "300px"; + wrap.style.width = "100%"; + const canvas = document.createElement("canvas"); + wrap.appendChild(canvas); + helpfulGraph.appendChild(wrap); + + const schedule = window.requestIdleCallback || ((cb) => setTimeout(cb, 0)); + schedule(() => { + renderLineChart(canvas, buildConfig(downsample(data.datums, 500))); + showGraph.style.display = "none"; + }); + }) + .catch(() => { + showGraph.textContent = gettext("Error loading graph"); + }); + }); +}); + +function downsample(datums, target) { + if (datums.length <= target) return datums; + const step = Math.ceil(datums.length / target); + return datums.filter((_, i) => i % step === 0); +} + +function buildConfig(datums) { + return { + type: "line", + data: { + datasets: [ + { + label: gettext("Yes"), + borderColor: "#21de2b", + backgroundColor: "#21de2b", + yAxisID: "y", + pointRadius: 0, + data: datums.map((d) => ({ x: fromUnixTime(d.date), y: d.yes })), + }, + { + label: gettext("No"), + borderColor: "#de2b21", + backgroundColor: "#de2b21", + yAxisID: "y", + pointRadius: 0, + data: datums.map((d) => ({ x: fromUnixTime(d.date), y: d.no })), + }, + { + label: gettext("Percent"), + borderColor: "#2b21de", + backgroundColor: "#2b21de", + yAxisID: "y1", + pointRadius: 0, + data: datums.map((d) => ({ + x: fromUnixTime(d.date), + // Avoid division by zero for days with no votes + y: d.yes + d.no > 0 ? (100 * d.yes) / (d.yes + d.no) : null, + })), + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + x: { + type: "time", + time: { + tooltipFormat: "PP", + displayFormats: { + day: "MMM d", + week: "MMM d", + month: "MMM yyyy", + quarter: "MMM yyyy", + year: "yyyy", + }, + }, + }, + y: { + position: "left", + beginAtZero: true, + title: { + display: true, + text: gettext("Votes"), + }, + }, + y1: { + position: "right", + min: 0, + max: 100, + // Suppress grid lines on the right axis to avoid double grid rendering + grid: { drawOnChartArea: false }, + title: { + display: true, + text: gettext("Percent"), + }, + }, + }, + plugins: { + legend: { + position: "bottom", + }, + tooltip: { + mode: "index", + }, + }, + }, + }; +} diff --git a/kitsune/sumo/static/sumo/js/charts/wikiMetrics.js b/kitsune/sumo/static/sumo/js/charts/wikiMetrics.js new file mode 100644 index 00000000000..12bbcde57e7 --- /dev/null +++ b/kitsune/sumo/static/sumo/js/charts/wikiMetrics.js @@ -0,0 +1,356 @@ +import { renderLineChart } from "sumo/js/charts"; +import { parseISO } from "date-fns"; + +const COLORS = [ + "#5d84b2", "#aa4643", "#89a54e", "#80699b", + "#3d96ae", "#db843d", "#92a8cd", "#a47d7c", + "#b5ca92", "#c98531", "#db75c2", +]; + +const CHART_CONFIGS = [ + { id: "percent-localized-top100", code: "percent_localized_top100", yLabel: () => gettext("% Localized"), max: 100 }, + { id: "percent-localized-top20", code: "percent_localized_top20", yLabel: () => gettext("% Localized"), max: 100 }, + { id: "percent-localized-all", code: "percent_localized_all", yLabel: () => gettext("% Localized"), max: 100 }, + { id: "active-contributors", code: "active_contributors", yLabel: () => gettext("Contributors"), max: null }, +]; + +document.addEventListener("DOMContentLoaded", () => { + if (document.body.classList.contains("aggregated-metrics")) { + initAggregatedMetrics(); + } + // Hook for iteration 5 — locale-metrics page reuses this module + if (document.body.classList.contains("locale-metrics")) { + initLocaleMetrics(); + } +}); + +async function initAggregatedMetrics() { + const firstSection = document.getElementById("percent-localized-top100"); + if (!firstSection) return; + const url = firstSection.dataset.url; + if (!url) return; + + const allResults = await fetchAllPages(url, 60); + document.querySelector(".loading-data")?.remove(); + document.getElementById("dashboard-readouts").style.display = ""; + + const grouped = groupResultsByCode(allResults); + const allLocales = [...new Set(allResults.map((r) => r.locale))].sort(); + + const picker = document.getElementById("locale-picker"); + const sidebarLocales = picker?.dataset.locales + ? JSON.parse(picker.dataset.locales).map((l) => (Array.isArray(l) ? l[0] : l)) + : allLocales; + // The first sidebar locale is en-US (the source); default-checked = the next 10 + const defaultLocales = new Set(sidebarLocales.slice(1, 11)); + + if (picker) { + picker.querySelectorAll("input[type=checkbox]").forEach((cb, idx) => { + cb.checked = idx > 0 && idx <= 10; + }); + } + + const schedule = window.requestIdleCallback || ((cb) => setTimeout(cb, 0)); + const charts = []; + schedule(() => { + for (const cfg of CHART_CONFIGS) { + const section = document.getElementById(cfg.id); + if (!section) continue; + const dataForCode = grouped.get(cfg.code) || new Map(); + const chart = renderMultiLocaleChart(section, dataForCode, allLocales, defaultLocales, cfg); + if (chart) charts.push({ chart }); + } + + if (picker) { + picker.querySelectorAll("input[type=checkbox]").forEach((cb) => { + cb.addEventListener("change", () => updateVisibleLocales(charts)); + }); + } + }); +} + +async function initLocaleMetrics() { + const wikimetricSection = document.getElementById("active-contributors") + || document.getElementById("localization-metrics"); + const voteSection = document.getElementById("kpi-vote"); + + const schedule = window.requestIdleCallback || ((cb) => setTimeout(cb, 0)); + + // Wiki-metric charts — single locale, derive series from `code` + if (wikimetricSection?.dataset.url) { + try { + const results = await fetchAllPages(wikimetricSection.dataset.url, 60); + const byCodeAndDate = groupByCodeAndDate(results); + + schedule(() => { + const localization = document.getElementById("localization-metrics"); + if (localization) { + renderLocaleLocalizationChart(localization, byCodeAndDate); + } + const contributors = document.getElementById("active-contributors"); + if (contributors) { + renderLocaleContributorsChart(contributors, byCodeAndDate); + } + }); + } catch { + const target = wikimetricSection.querySelector(".rickshaw") || wikimetricSection; + target.textContent = gettext("Error loading graph"); + } + } + + // Helpful-votes chart — separate endpoint + if (voteSection?.dataset.url) { + try { + const response = await fetch(voteSection.dataset.url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + schedule(() => renderHelpfulVotesChart(voteSection, data.objects || [])); + } catch { + const target = voteSection.querySelector(".rickshaw") || voteSection; + target.textContent = gettext("Error loading graph"); + } + } +} + +function groupByCodeAndDate(results) { + // Returns Map> + const out = new Map(); + for (const r of results) { + if (!out.has(r.code)) out.set(r.code, new Map()); + out.get(r.code).set(r.date, r.value); + } + return out; +} + +function replaceWithCanvas(section, height = 320) { + const old = section.querySelector(".rickshaw"); + if (old) old.remove(); + const wrap = document.createElement("div"); + wrap.style.cssText = `position: relative; height: ${height}px; width: 100%; margin-bottom: 16px;`; + const canvas = document.createElement("canvas"); + wrap.appendChild(canvas); + section.appendChild(wrap); + return canvas; +} + +function renderLocaleLocalizationChart(section, byCodeAndDate) { + const canvas = replaceWithCanvas(section); + const codes = [ + { code: "percent_localized_top100", label: gettext("Top 100 Articles"), color: "#5d84b2" }, + { code: "percent_localized_top20", label: gettext("Top 20 Articles"), color: "#aa4643" }, + { code: "percent_localized_all", label: gettext("All Articles"), color: "#89a54e" }, + ]; + + const allDates = new Set(); + for (const { code } of codes) { + const byDate = byCodeAndDate.get(code); + if (byDate) for (const d of byDate.keys()) allDates.add(d); + } + const sortedDates = [...allDates].sort(); + const dateObjs = sortedDates.map(parseISO); + + const datasets = codes.map(({ code, label, color }) => { + const byDate = byCodeAndDate.get(code) || new Map(); + return { + label, + borderColor: color, + backgroundColor: color, + pointRadius: 0, + borderWidth: 1.5, + fill: false, + data: sortedDates.map((d, i) => ({ x: dateObjs[i], y: byDate.has(d) ? byDate.get(d) : null })), + }; + }); + + renderLineChart(canvas, lineChartOptions(datasets, { + yTitle: gettext("% Localized"), + max: 100, + valueFormatter: (v) => `${v.toFixed(1)}%`, + })); +} + +function renderLocaleContributorsChart(section, byCodeAndDate) { + const canvas = replaceWithCanvas(section); + const byDate = byCodeAndDate.get("active_contributors") || new Map(); + const sorted = [...byDate.keys()].sort(); + const dataset = { + label: gettext("Active Contributors"), + borderColor: "#5d84b2", + backgroundColor: "rgba(93,132,178,0.15)", + pointRadius: 0, + borderWidth: 1.5, + fill: true, + data: sorted.map(d => ({ x: parseISO(d), y: byDate.get(d) })), + }; + renderLineChart(canvas, lineChartOptions([dataset], { + yTitle: gettext("Contributors"), + max: null, + valueFormatter: (v) => v.toLocaleString(), + })); +} + +function renderHelpfulVotesChart(section, objects) { + const canvas = replaceWithCanvas(section); + const sorted = [...objects].sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0)); + const dataset = { + label: gettext("Article Votes: % Helpful"), + borderColor: "#aa4643", + backgroundColor: "rgba(170,70,67,0.15)", + pointRadius: 0, + borderWidth: 1.5, + fill: true, + data: sorted.map(o => ({ + x: parseISO(o.date), + y: o.kb_votes > 0 ? (100 * o.kb_helpful) / o.kb_votes : null, + })), + }; + renderLineChart(canvas, lineChartOptions([dataset], { + yTitle: gettext("% Helpful"), + max: 100, + valueFormatter: (v) => `${v.toFixed(1)}%`, + })); +} + +function lineChartOptions(datasets, { yTitle, max, valueFormatter }) { + return { + type: "line", + data: { datasets }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + x: { + type: "time", + time: { + tooltipFormat: "PP", + displayFormats: { day: "MMM d", week: "MMM d", month: "MMM yyyy", quarter: "MMM yyyy", year: "yyyy" }, + }, + }, + y: { + beginAtZero: true, + max, + title: { display: true, text: yTitle }, + }, + }, + plugins: { + legend: { position: "bottom" }, + tooltip: { + mode: "index", + callbacks: { + label: (ctx) => { + const v = ctx.parsed.y; + if (v == null) return null; + return `${ctx.dataset.label}: ${valueFormatter(v)}`; + }, + }, + }, + }, + }, + }; +} + +async function fetchAllPages(url, maxPages) { + const results = []; + let next = url; + let count = 0; + while (next && count < maxPages) { + const response = await fetch(next); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + results.push(...(data.results || [])); + next = data.next; + count++; + } + return results; +} + +function groupResultsByCode(results) { + // Returns Map>> + const out = new Map(); + for (const r of results) { + if (!out.has(r.code)) out.set(r.code, new Map()); + const byDate = out.get(r.code); + if (!byDate.has(r.date)) byDate.set(r.date, new Map()); + byDate.get(r.date).set(r.locale, r.value); + } + return out; +} + +function renderMultiLocaleChart(section, dataForCode, allLocales, visibleLocales, cfg) { + const oldRickshaw = section.querySelector(".rickshaw"); + if (oldRickshaw) oldRickshaw.remove(); + + const wrap = document.createElement("div"); + wrap.style.cssText = "position: relative; height: 320px; width: 100%; margin-bottom: 16px;"; + const canvas = document.createElement("canvas"); + wrap.appendChild(canvas); + section.appendChild(wrap); + + const dates = Array.from(dataForCode.keys()).sort(); + const dateObjs = dates.map((d) => parseISO(d)); + const datasets = allLocales.map((locale, i) => ({ + label: locale, + borderColor: COLORS[i % COLORS.length], + backgroundColor: COLORS[i % COLORS.length], + pointRadius: 0, + borderWidth: 1.2, + fill: false, + hidden: !visibleLocales.has(locale), + data: dates.map((d, idx) => ({ x: dateObjs[idx], y: dataForCode.get(d).get(locale) ?? null })), + })); + + return renderLineChart(canvas, { + type: "line", + data: { datasets }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + x: { + type: "time", + time: { + tooltipFormat: "PP", + displayFormats: { day: "MMM d", week: "MMM d", month: "MMM yyyy", quarter: "MMM yyyy", year: "yyyy" }, + }, + }, + y: { + beginAtZero: true, + max: cfg.max, + title: { display: true, text: cfg.yLabel() }, + }, + }, + plugins: { + legend: { + position: "bottom", + display: false, + }, + tooltip: { + mode: "nearest", + intersect: false, + callbacks: { + label: (ctx) => { + const v = ctx.parsed.y; + if (v == null) return null; + const formatted = cfg.max === 100 ? `${v.toFixed(1)}%` : v.toLocaleString(); + return `${ctx.dataset.label}: ${formatted}`; + }, + }, + }, + }, + }, + }); +} + +function updateVisibleLocales(charts) { + const checkedLocales = new Set( + Array.from(document.querySelectorAll("#locale-picker input[type=checkbox]:checked")).map( + (cb) => cb.value, + ), + ); + for (const { chart } of charts) { + chart.data.datasets.forEach((ds) => { + ds.hidden = !checkedLocales.has(ds.label); + }); + chart.update(); + } +} diff --git a/kitsune/sumo/static/sumo/js/historycharts.js b/kitsune/sumo/static/sumo/js/historycharts.js deleted file mode 100644 index 65b9fa27b73..00000000000 --- a/kitsune/sumo/static/sumo/js/historycharts.js +++ /dev/null @@ -1,88 +0,0 @@ -import { Graph } from "sumo/js/rickshaw_utils"; - -/* -* Scripts to support Graphs on wiki article history. -*/ - -(function ($) { - function init() { - $('#show-graph').off('click'); - $('#show-graph').html(gettext('Loading...')); - $('#show-graph').css('color', '#333333').css('cursor', 'auto').css('text-decoration', 'none'); - initGraph(); - } - - function initGraph() { - $.ajax({ - type: 'GET', - url: $('#helpful-graph').data('url'), - success: function (data) { - if (data.datums.length > 0) { - rickshawGraph(data); - $('#show-graph').hide(); - } else { - $('#show-graph').html(gettext('No votes data')); - $('#show-graph').off('click'); - } - }, - error: function () { - $('#show-graph').html(gettext('Error loading graph')); - $('#show-graph').off('click'); - } - }); - } - - function rickshawGraph(data) { - var $container = $('#helpful-graph'); - var sets = {}; - - sets[gettext('Votes')] = ['yes', 'no']; - sets[gettext('Percent')] = ['percent']; - - data.seriesSpec = [ - { - name: gettext('Yes'), - slug: 'yes', - func: Graph.identity('yes'), - color: '#21de2b', - axisGroup: 'votes' - }, - { - name: gettext('No'), - slug: 'no', - func: Graph.identity('no'), - color: '#de2b21', - axisGroup: 'votes' - }, - { - name: gettext('Percent'), - slug: 'percent', - func: Graph.percentage('yes', 'no'), - color: '#2b21de', - axisGroup: 'percent', - type: 'percent' - } - ]; - - $container.show(); - var graph = new Graph($container, { - data: data, - graph: { - width: 600 - }, - options: { - bucket: true, - legend: false, - sets: true, - timeline: true - }, - metadata: { - sets: sets - } - }); - - graph.render(); - } - - $('#show-graph').on("click", init); -})(jQuery); diff --git a/kitsune/sumo/static/sumo/js/libs/d3.layout.min.js b/kitsune/sumo/static/sumo/js/libs/d3.layout.min.js deleted file mode 100644 index 1da2530cdd6..00000000000 --- a/kitsune/sumo/static/sumo/js/libs/d3.layout.min.js +++ /dev/null @@ -1,2 +0,0 @@ -import d3 from "d3"; -(function(){function a(a){var b=a.source,d=a.target,e=c(b,d),f=[b];while(b!==e)b=b.parent,f.push(b);var g=f.length;while(d!==e)f.splice(g,0,d),d=d.parent;return f}function b(a){var b=[],c=a.parent;while(c!=null)b.push(a),a=c,c=c.parent;return b.push(a),b}function c(a,c){if(a===c)return a;var d=b(a),e=b(c),f=d.pop(),g=e.pop(),h=null;while(f===g)h=f,f=d.pop(),g=e.pop();return h}function g(a){a.fixed|=2}function h(a){a!==f&&(a.fixed&=1)}function i(){j(),f.fixed&=1,e=f=null}function j(){f.px+=d3.event.dx,f.py+=d3.event.dy,e.resume()}function k(a,b,c){var d=0,e=0;a.charge=0;if(!a.leaf){var f=a.nodes,g=f.length,h=-1,i;while(++hd&&(c=b,d=e);return c}function u(a){return a.reduce(v,0)}function v(a,b){return a+b[1]}function w(a,b){return x(a,Math.ceil(Math.log(b.length)/Math.LN2+1))}function x(a,b){var c=-1,d=+a[0],e=(a[1]-d)/b,f=[];while(++c<=b)f[c]=e*c+d;return f}function y(a){return[d3.min(a),d3.max(a)]}function z(a,b){return a.sort=d3.rebind(a,b.sort),a.children=d3.rebind(a,b.children),a.links=D,a.value=d3.rebind(a,b.value),a.nodes=function(b){return E=!0,(a.nodes=a)(b)},a}function A(a){return a.children}function B(a){return a.value}function C(a,b){return b.value-a.value}function D(a){return d3.merge(a.map(function(a){return(a.children||[]).map(function(b){return{source:a,target:b}})}))}function F(a,b){return a.value-b.value}function G(a,b){var c=a._pack_next;a._pack_next=b,b._pack_prev=a,b._pack_next=c,c._pack_prev=b}function H(a,b){a._pack_next=b,b._pack_prev=a}function I(a,b){var c=b.x-a.x,d=b.y-a.y,e=a.r+b.r;return e*e-c*c-d*d>.001}function J(a){function l(a){b=Math.min(a.x-a.r,b),c=Math.max(a.x+a.r,c),d=Math.min(a.y-a.r,d),e=Math.max(a.y+a.r,e)}var b=Infinity,c=-Infinity,d=Infinity,e=-Infinity,f=a.length,g,h,i,j,k;a.forEach(K),g=a[0],g.x=-g.r,g.y=0,l(g);if(f>1){h=a[1],h.x=h.r,h.y=0,l(h);if(f>2){i=a[2],O(g,h,i),l(i),G(g,i),g._pack_prev=i,G(i,h),h=g._pack_next;for(var m=3;m0?(H(g,j),h=j,m--):(H(j,h),g=j,m--)}}}var q=(b+c)/2,r=(d+e)/2,s=0;for(var m=0;m0&&(a=d)}return a}function X(a,b){return a.x-b.x}function Y(a,b){return b.x-a.x}function Z(a,b){return a.depth-b.depth}function $(a,b){function c(a,d){var e=a.children;if(e&&(i=e.length)){var f,g=null,h=-1,i;while(++h=0)f=d[e]._tree,f.prelim+=b,f.mod+=b,b+=f.shift+(c+=f.change)}function ba(a,b,c){a=a._tree,b=b._tree;var d=c/(b.number-a.number);a.change+=d,b.change-=d,b.shift+=c,b.prelim+=c,b.mod+=c}function bb(a,b,c){return a._tree.ancestor.parent==b.parent?a._tree.ancestor:c}function bc(a){return{x:a.x,y:a.y,dx:a.dx,dy:a.dy}}function bd(a,b){var c=a.x+b[3],d=a.y+b[0],e=a.dx-b[1]-b[3],f=a.dy-b[0]-b[2];return e<0&&(c+=e/2,e=0),f<0&&(d+=f/2,f=0),{x:c,y:d,dx:e,dy:f}}d3.layout={},d3.layout.bundle=function(){return function(b){var c=[],d=-1,e=b.length;while(++de&&(e=h),d.push(h)}for(g=0;g=i[0]&&o<=i[1]&&(k=g[d3.bisect(j,o,1,m)-1],k.y+=n,k.push(e[f]));return g}var a=!0,b=Number,c=y,d=w;return e.value=function(a){return arguments.length?(b=a,e):b},e.range=function(a){return arguments.length?(c=d3.functor(a),e):c},e.bins=function(a){return arguments.length?(d=typeof a=="number"?function(b){return x(b,a)}:d3.functor(a),e):d},e.frequency=function(b){return arguments.length?(a=!!b,e):a},e},d3.layout.hierarchy=function(){function e(f,h,i){var j=b.call(g,f,h),k=E?f:{data:f};k.depth=h,i.push(k);if(j&&(m=j.length)){var l=-1,m,n=k.children=[],o=0,p=h+1;while(++l0&&(ba(bb(g,a,d),a,m),i+=m,j+=m),k+=g._tree.mod,i+=e._tree.mod,l+=h._tree.mod,j+=f._tree.mod;g&&!V(f)&&(f._tree.thread=g,f._tree.mod+=k-j),e&&!U(h)&&(h._tree.thread=e,h._tree.mod+=i-l,d=a)}return d}var f=a.call(this,d,e),g=f[0];$(g,function(a,b){a._tree={ancestor:a,prelim:0,mod:0,change:0,shift:0,number:b?b._tree.number+1:0}}),h(g),i(g,-g._tree.prelim);var k=W(g,Y),l=W(g,X),m=W(g,Z),n=k.x-b(k,l)/2,o=l.x+b(l,k)/2,p=m.depth||1;return $(g,function(a){a.x=(a.x-n)/(o-n)*c[0],a.y=a.depth/p*c[1],delete a._tree}),f}var a=d3.layout.hierarchy().sort(null).value(null),b=T,c=[1,1];return d.separation=function(a){return arguments.length?(b=a,d):b},d.size=function(a){return arguments.length?(c=a,d):c},z(d,a)},d3.layout.treemap=function(){function i(a,b){var c=-1,d=a.length,e,f;while(++c0)d.push(g=f[o-1]),d.area+=g.area,(k=l(d,n))<=h?(f.pop(),h=k):(d.area-=d.pop().area,m(d,n,c,!1),n=Math.min(c.dx,c.dy),d.length=d.area=0,h=Infinity);d.length&&(m(d,n,c,!0),d.length=d.area=0),b.forEach(j)}}function k(a){var b=a.children;if(b&&b.length){var c=e(a),d=b.slice(),f,g=[];i(d,c.dx*c.dy/a.value),g.area=0;while(f=d.pop())g.push(f),g.area+=f.area,f.z!=null&&(m(g,f.z?c.dx:c.dy,c,!d.length),g.length=g.area=0);b.forEach(k)}}function l(a,b){var c=a.area,d,e=0,f=Infinity,g=-1,i=a.length;while(++ge&&(e=d)}return c*=c,b*=b,c?Math.max(b*e*h/c,c/(b*f*h)):Infinity}function m(a,c,d,e){var f=-1,g=a.length,h=d.x,i=d.y,j=c?b(a.area/c):0,k;if(c==d.dx){if(e||j>d.dy)j=j?d.dy:0;while(++fd.dx)j=j?d.dx:0;while(++f this.window.xMax) isInRange = false; - - return isInRange; - } - - return true; - }; - - this.onUpdate = function(callback) { - this.updateCallbacks.push(callback); - }; - - this.registerRenderer = function(renderer) { - this._renderers = this._renderers || {}; - this._renderers[renderer.name] = renderer; - }; - - this.configure = function(args) { - - if (args.width || args.height) { - this.setSize(args); - } - - Rickshaw.keys(this.defaults).forEach( function(k) { - this[k] = k in args ? args[k] - : k in this ? this[k] - : this.defaults[k]; - }, this ); - - this.setRenderer(args.renderer || this.renderer.name, args); - }; - - this.setRenderer = function(name, args) { - - if (!this._renderers[name]) { - throw "couldn't find renderer " + name; - } - this.renderer = this._renderers[name]; - - if (typeof args == 'object') { - this.renderer.configure(args); - } - }; - - this.setSize = function(args) { - - args = args || {}; - - if (typeof window !== undefined) { - var style = window.getComputedStyle(this.element, null); - var elementWidth = parseInt(style.getPropertyValue('width')); - var elementHeight = parseInt(style.getPropertyValue('height')); - } - - this.width = args.width || elementWidth || 400; - this.height = args.height || elementHeight || 250; - - this.vis && this.vis - .attr('width', this.width) - .attr('height', this.height); - } - - this.initialize(args); -}; -Rickshaw.namespace('Rickshaw.Fixtures.Color'); - -Rickshaw.Fixtures.Color = function() { - - this.schemes = {}; - - this.schemes.spectrum14 = [ - '#ecb796', - '#dc8f70', - '#b2a470', - '#92875a', - '#716c49', - '#d2ed82', - '#bbe468', - '#a1d05d', - '#e7cbe6', - '#d8aad6', - '#a888c2', - '#9dc2d3', - '#649eb9', - '#387aa3' - ].reverse(); - - this.schemes.spectrum2000 = [ - '#57306f', - '#514c76', - '#646583', - '#738394', - '#6b9c7d', - '#84b665', - '#a7ca50', - '#bfe746', - '#e2f528', - '#fff726', - '#ecdd00', - '#d4b11d', - '#de8800', - '#de4800', - '#c91515', - '#9a0000', - '#7b0429', - '#580839', - '#31082b' - ]; - - this.schemes.spectrum2001 = [ - '#2f243f', - '#3c2c55', - '#4a3768', - '#565270', - '#6b6b7c', - '#72957f', - '#86ad6e', - '#a1bc5e', - '#b8d954', - '#d3e04e', - '#ccad2a', - '#cc8412', - '#c1521d', - '#ad3821', - '#8a1010', - '#681717', - '#531e1e', - '#3d1818', - '#320a1b' - ]; - - this.schemes.classic9 = [ - '#423d4f', - '#4a6860', - '#848f39', - '#a2b73c', - '#ddcb53', - '#c5a32f', - '#7d5836', - '#963b20', - '#7c2626', - '#491d37', - '#2f254a' - ].reverse(); - - this.schemes.httpStatus = { - 503: '#ea5029', - 502: '#d23f14', - 500: '#bf3613', - 410: '#efacea', - 409: '#e291dc', - 403: '#f457e8', - 408: '#e121d2', - 401: '#b92dae', - 405: '#f47ceb', - 404: '#a82a9f', - 400: '#b263c6', - 301: '#6fa024', - 302: '#87c32b', - 307: '#a0d84c', - 304: '#28b55c', - 200: '#1a4f74', - 206: '#27839f', - 201: '#52adc9', - 202: '#7c979f', - 203: '#a5b8bd', - 204: '#c1cdd1' - }; - - this.schemes.colorwheel = [ - '#b5b6a9', - '#858772', - '#785f43', - '#96557e', - '#4682b4', - '#65b9ac', - '#73c03a', - '#cb513a' - ].reverse(); - - this.schemes.cool = [ - '#5e9d2f', - '#73c03a', - '#4682b4', - '#7bc3b8', - '#a9884e', - '#c1b266', - '#a47493', - '#c09fb5' - ]; - - this.schemes.munin = [ - '#00cc00', - '#0066b3', - '#ff8000', - '#ffcc00', - '#330099', - '#990099', - '#ccff00', - '#ff0000', - '#808080', - '#008f00', - '#00487d', - '#b35a00', - '#b38f00', - '#6b006b', - '#8fb300', - '#b30000', - '#bebebe', - '#80ff80', - '#80c9ff', - '#ffc080', - '#ffe680', - '#aa80ff', - '#ee00cc', - '#ff8080', - '#666600', - '#ffbfff', - '#00ffcc', - '#cc6699', - '#999900' - ]; -}; -Rickshaw.namespace('Rickshaw.Fixtures.RandomData'); - -Rickshaw.Fixtures.RandomData = function(timeInterval) { - - var addData; - timeInterval = timeInterval || 1; - - var lastRandomValue = 200; - - var timeBase = Math.floor(new Date().getTime() / 1000); - - this.addData = function(data) { - - var randomValue = Math.random() * 100 + 15 + lastRandomValue; - var index = data[0].length; - - var counter = 1; - - data.forEach( function(series) { - var randomVariance = Math.random() * 20; - var v = randomValue / 25 + counter++ - + (Math.cos((index * counter * 11) / 960) + 2) * 15 - + (Math.cos(index / 7) + 2) * 7 - + (Math.cos(index / 17) + 2) * 1; - - series.push( { x: (index * timeInterval) + timeBase, y: v + randomVariance } ); - } ); - - lastRandomValue = randomValue * .85; - } -}; - -Rickshaw.namespace('Rickshaw.Fixtures.Time'); - -Rickshaw.Fixtures.Time = function() { - - var tzOffset = new Date().getTimezoneOffset() * 60; - - var self = this; - - this.months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - - this.units = [ - { - name: 'decade', - seconds: 86400 * 365.25 * 10, - formatter: function(d) { return (parseInt(d.getUTCFullYear() / 10) * 10) } - }, { - name: 'year', - seconds: 86400 * 365.25, - formatter: function(d) { return d.getUTCFullYear() } - }, { - name: 'month', - seconds: 86400 * 30.5, - formatter: function(d) { return self.months[d.getUTCMonth()] } - }, { - name: 'week', - seconds: 86400 * 7, - formatter: function(d) { return self.formatDate(d) } - }, { - name: 'day', - seconds: 86400, - formatter: function(d) { return d.getUTCDate() } - }, { - name: '6 hour', - seconds: 3600 * 6, - formatter: function(d) { return self.formatTime(d) } - }, { - name: 'hour', - seconds: 3600, - formatter: function(d) { return self.formatTime(d) } - }, { - name: '15 minute', - seconds: 60 * 15, - formatter: function(d) { return self.formatTime(d) } - }, { - name: 'minute', - seconds: 60, - formatter: function(d) { return d.getUTCMinutes() } - }, { - name: '15 second', - seconds: 15, - formatter: function(d) { return d.getUTCSeconds() + 's' } - }, { - name: 'second', - seconds: 1, - formatter: function(d) { return d.getUTCSeconds() + 's' } - } - ]; - - this.unit = function(unitName) { - return this.units.filter( function(unit) { return unitName == unit.name } ).shift(); - }; - - this.formatDate = function(d) { - return d.toUTCString().match(/, (\w+ \w+ \w+)/)[1]; - }; - - this.formatTime = function(d) { - return d.toUTCString().match(/(\d+:\d+):/)[1]; - }; - - this.ceil = function(time, unit) { - - if (unit.name == 'month') { - - var nearFuture = new Date((time + unit.seconds - 1) * 1000); - - var rounded = new Date(0); - rounded.setUTCFullYear(nearFuture.getUTCFullYear()); - rounded.setUTCMonth(nearFuture.getUTCMonth()); - rounded.setUTCDate(1); - rounded.setUTCHours(0); - rounded.setUTCMinutes(0); - rounded.setUTCSeconds(0); - rounded.setUTCMilliseconds(0); - - return rounded.getTime() / 1000; - } - - if (unit.name == 'year') { - - var nearFuture = new Date((time + unit.seconds - 1) * 1000); - - var rounded = new Date(0); - rounded.setUTCFullYear(nearFuture.getUTCFullYear()); - rounded.setUTCMonth(0); - rounded.setUTCDate(1); - rounded.setUTCHours(0); - rounded.setUTCMinutes(0); - rounded.setUTCSeconds(0); - rounded.setUTCMilliseconds(0); - - return rounded.getTime() / 1000; - } - - return Math.ceil(time / unit.seconds) * unit.seconds; - }; -}; -Rickshaw.namespace('Rickshaw.Fixtures.Number'); - -Rickshaw.Fixtures.Number.formatKMBT = function(y) { - let abs_y = Math.abs(y); - if (abs_y >= 1000000000000) { return y / 1000000000000 + "T" } - else if (abs_y >= 1000000000) { return y / 1000000000 + "B" } - else if (abs_y >= 1000000) { return y / 1000000 + "M" } - else if (abs_y >= 1000) { return y / 1000 + "K" } - else if (abs_y < 1 && y > 0) { return y.toFixed(2) } - else if (abs_y == 0) { return '' } - else { return y } -}; - -Rickshaw.Fixtures.Number.formatBase1024KMGTP = function(y) { - let abs_y = Math.abs(y); - if (abs_y >= 1125899906842624) { return y / 1125899906842624 + "P" } - else if (abs_y >= 1099511627776){ return y / 1099511627776 + "T" } - else if (abs_y >= 1073741824) { return y / 1073741824 + "G" } - else if (abs_y >= 1048576) { return y / 1048576 + "M" } - else if (abs_y >= 1024) { return y / 1024 + "K" } - else if (abs_y < 1 && y > 0) { return y.toFixed(2) } - else if (abs_y == 0) { return '' } - else { return y } -}; -Rickshaw.namespace("Rickshaw.Color.Palette"); - -Rickshaw.Color.Palette = function(args) { - - var color = new Rickshaw.Fixtures.Color(); - - args = args || {}; - this.schemes = {}; - - this.scheme = color.schemes[args.scheme] || args.scheme || color.schemes.colorwheel; - this.runningIndex = 0; - this.generatorIndex = 0; - - if (args.interpolatedStopCount) { - var schemeCount = this.scheme.length - 1; - var i, j, scheme = []; - for (i = 0; i < schemeCount; i++) { - scheme.push(this.scheme[i]); - var generator = d3.interpolateHsl(this.scheme[i], this.scheme[i + 1]); - for (j = 1; j < args.interpolatedStopCount; j++) { - scheme.push(generator((1 / args.interpolatedStopCount) * j)); - } - } - scheme.push(this.scheme[this.scheme.length - 1]); - this.scheme = scheme; - } - this.rotateCount = this.scheme.length; - - this.color = function(key) { - return this.scheme[key] || this.scheme[this.runningIndex++] || this.interpolateColor() || '#808080'; - }; - - this.interpolateColor = function() { - if (!Array.isArray(this.scheme)) return; - var color; - if (this.generatorIndex == this.rotateCount * 2 - 1) { - color = d3.interpolateHsl(this.scheme[this.generatorIndex], this.scheme[0])(0.5); - this.generatorIndex = 0; - this.rotateCount *= 2; - } else { - color = d3.interpolateHsl(this.scheme[this.generatorIndex], this.scheme[this.generatorIndex + 1])(0.5); - this.generatorIndex++; - } - this.scheme.push(color); - return color; - }; - -}; -Rickshaw.namespace('Rickshaw.Graph.Ajax'); - -Rickshaw.Graph.Ajax = Rickshaw.Class.create( { - - initialize: function(args) { - - this.dataURL = args.dataURL; - - this.onData = args.onData || function(d) { return d }; - this.onComplete = args.onComplete || function() {}; - this.onError = args.onError || function() {}; - - this.args = args; // pass through to Rickshaw.Graph - - this.request(); - }, - - request: function() { - - $.ajax( { - url: this.dataURL, - dataType: 'json', - success: this.success.bind(this), - error: this.error.bind(this) - } ); - }, - - error: function() { - - console.log("error loading dataURL: " + this.dataURL); - this.onError(this); - }, - - success: function(data, status) { - - data = this.onData(data); - this.args.series = this._splice({ data: data, series: this.args.series }); - - this.graph = this.graph || new Rickshaw.Graph(this.args); - this.graph.render(); - - this.onComplete(this); - }, - - _splice: function(args) { - - var data = args.data; - var series = args.series; - - if (!args.series) return data; - - series.forEach( function(s) { - - var seriesKey = s.key || s.name; - if (!seriesKey) throw "series needs a key or a name"; - - data.forEach( function(d) { - - var dataKey = d.key || d.name; - if (!dataKey) throw "data needs a key or a name"; - - if (seriesKey == dataKey) { - var properties = ['color', 'name', 'data']; - properties.forEach( function(p) { - if (d[p]) s[p] = d[p]; - } ); - } - } ); - } ); - - return series; - } -} ); - -Rickshaw.namespace('Rickshaw.Graph.Annotate'); - -Rickshaw.Graph.Annotate = function(args) { - - var graph = this.graph = args.graph; - this.elements = { timeline: args.element }; - - var self = this; - - this.data = {}; - - this.elements.timeline.classList.add('rickshaw_annotation_timeline'); - - this.add = function(time, content, end_time) { - self.data[time] = self.data[time] || {'boxes': []}; - self.data[time].boxes.push({content: content, end: end_time}); - }; - - this.update = function() { - - Rickshaw.keys(self.data).forEach( function(time) { - - var annotation = self.data[time]; - var left = self.graph.x(time); - - if (left < 0 || left > self.graph.x.range()[1]) { - if (annotation.element) { - annotation.line.classList.add('offscreen'); - annotation.element.style.display = 'none'; - } - - annotation.boxes.forEach( function(box) { - if ( box.rangeElement ) box.rangeElement.classList.add('offscreen'); - }); - - return; - } - - if (!annotation.element) { - var element = annotation.element = document.createElement('div'); - element.classList.add('annotation'); - this.elements.timeline.appendChild(element); - element.addEventListener('click', function(e) { - element.classList.toggle('active'); - annotation.line.classList.toggle('active'); - annotation.boxes.forEach( function(box) { - if ( box.rangeElement ) box.rangeElement.classList.toggle('active'); - }); - }, false); - - } - - annotation.element.style.left = left + 'px'; - annotation.element.style.display = 'block'; - - annotation.boxes.forEach( function(box) { - - - var element = box.element; - - if (!element) { - element = box.element = document.createElement('div'); - element.classList.add('content'); - element.innerHTML = box.content; - annotation.element.appendChild(element); - - annotation.line = document.createElement('div'); - annotation.line.classList.add('annotation_line'); - self.graph.element.appendChild(annotation.line); - - if ( box.end ) { - box.rangeElement = document.createElement('div'); - box.rangeElement.classList.add('annotation_range'); - self.graph.element.appendChild(box.rangeElement); - } - - } - - if ( box.end ) { - - var annotationRangeStart = left; - var annotationRangeEnd = Math.min( self.graph.x(box.end), self.graph.x.range()[1] ); - - // annotation makes more sense at end - if ( annotationRangeStart > annotationRangeEnd ) { - annotationRangeEnd = left; - annotationRangeStart = Math.max( self.graph.x(box.end), self.graph.x.range()[0] ); - } - - var annotationRangeWidth = annotationRangeEnd - annotationRangeStart; - - box.rangeElement.style.left = annotationRangeStart + 'px'; - box.rangeElement.style.width = annotationRangeWidth + 'px' - - box.rangeElement.classList.remove('offscreen'); - } - - annotation.line.classList.remove('offscreen'); - annotation.line.style.left = left + 'px'; - } ); - }, this ); - }; - - this.graph.onUpdate( function() { self.update() } ); -}; -Rickshaw.namespace('Rickshaw.Graph.Axis.Time'); - -Rickshaw.Graph.Axis.Time = function(args) { - - var self = this; - - this.graph = args.graph; - this.elements = []; - this.ticksTreatment = args.ticksTreatment || 'plain'; - this.fixedTimeUnit = args.timeUnit; - - var time = new Rickshaw.Fixtures.Time(); - - this.appropriateTimeUnit = function() { - - var unit; - var units = time.units; - - var domain = this.graph.x.domain(); - var rangeSeconds = domain[1] - domain[0]; - - units.forEach( function(u) { - if (Math.floor(rangeSeconds / u.seconds) >= 2) { - unit = unit || u; - } - } ); - - return (unit || time.units[time.units.length - 1]); - }; - - this.tickOffsets = function() { - - var domain = this.graph.x.domain(); - - var unit = this.fixedTimeUnit || this.appropriateTimeUnit(); - var count = Math.ceil((domain[1] - domain[0]) / unit.seconds); - - var runningTick = domain[0]; - - var offsets = []; - - for (var i = 0; i < count; i++) { - - var tickValue = time.ceil(runningTick, unit); - runningTick = tickValue + unit.seconds / 2; - - offsets.push( { value: tickValue, unit: unit } ); - } - - return offsets; - }; - - this.render = function() { - - this.elements.forEach( function(e) { - e.parentNode.removeChild(e); - } ); - - this.elements = []; - - var offsets = this.tickOffsets(); - - offsets.forEach( function(o) { - - if (self.graph.x(o.value) > self.graph.x.range()[1]) return; - - var element = document.createElement('div'); - element.style.left = self.graph.x(o.value) + 'px'; - element.classList.add('x_tick'); - element.classList.add(self.ticksTreatment); - - var title = document.createElement('div'); - title.classList.add('title'); - title.innerHTML = o.unit.formatter(new Date(o.value * 1000)); - element.appendChild(title); - - self.graph.element.appendChild(element); - self.elements.push(element); - - } ); - }; - - this.graph.onUpdate( function() { self.render() } ); -}; - -Rickshaw.namespace('Rickshaw.Graph.Axis.X'); - -Rickshaw.Graph.Axis.X = function(args) { - - var self = this; - var berthRate = 0.10; - - this.initialize = function(args) { - - this.graph = args.graph; - this.orientation = args.orientation || 'top'; - - var pixelsPerTick = args.pixelsPerTick || 75; - this.ticks = args.ticks || Math.floor(this.graph.width / pixelsPerTick); - this.tickSize = args.tickSize || 4; - this.ticksTreatment = args.ticksTreatment || 'plain'; - - if (args.element) { - - this.element = args.element; - this._discoverSize(args.element, args); - - this.vis = d3.select(args.element) - .append("svg:svg") - .attr('height', this.height) - .attr('width', this.width) - .attr('class', 'rickshaw_graph x_axis_d3'); - - this.element = this.vis[0][0]; - this.element.style.position = 'relative'; - - this.setSize({ width: args.width, height: args.height }); - - } else { - this.vis = this.graph.vis; - } - - this.graph.onUpdate( function() { self.render() } ); - }; - - this.setSize = function(args) { - - args = args || {}; - if (!this.element) return; - - this._discoverSize(this.element.parentNode, args); - - this.vis - .attr('height', this.height) - .attr('width', this.width * (1 + berthRate)); - - var berth = Math.floor(this.width * berthRate / 2); - this.element.style.left = -1 * berth + 'px'; - }; - - this.render = function() { - - if (this.graph.width !== this._renderWidth) this.setSize({ auto: true }); - - var axis = d3.svg.axis().scale(this.graph.x).orient(this.orientation); - axis.tickFormat( args.tickFormat || function(x) { return x } ); - - var berth = Math.floor(this.width * berthRate / 2) || 0; - - if (this.orientation == 'top') { - var yOffset = this.height || this.graph.height; - var transform = 'translate(' + berth + ',' + yOffset + ')'; - } else { - var transform = 'translate(' + berth + ', 0)'; - } - - if (this.element) { - this.vis.selectAll('*').remove(); - } - - this.vis - .append("svg:g") - .attr("class", ["x_ticks_d3", this.ticksTreatment].join(" ")) - .attr("transform", transform) - .call(axis.ticks(this.ticks).tickSubdivide(0).tickSize(this.tickSize)); - - var gridSize = (this.orientation == 'bottom' ? 1 : -1) * this.graph.height; - - this.graph.vis - .append("svg:g") - .attr("class", "x_grid_d3") - .call(axis.ticks(this.ticks).tickSubdivide(0).tickSize(gridSize)); - - this._renderHeight = this.graph.height; - }; - - this._discoverSize = function(element, args) { - - if (typeof window !== 'undefined') { - - var style = window.getComputedStyle(element, null); - var elementHeight = parseInt(style.getPropertyValue('height')); - - if (!args.auto) { - var elementWidth = parseInt(style.getPropertyValue('width')); - } - } - - this.width = (args.width || elementWidth || this.graph.width) * (1 + berthRate); - this.height = args.height || elementHeight || 40; - }; - - this.initialize(args); -}; - -Rickshaw.namespace('Rickshaw.Graph.Axis.Y'); - -Rickshaw.Graph.Axis.Y = function(args) { - - var self = this; - var berthRate = 0.10; - - this.initialize = function(args) { - - this.graph = args.graph; - this.orientation = args.orientation || 'right'; - - var pixelsPerTick = args.pixelsPerTick || 75; - this.ticks = args.ticks || Math.floor(this.graph.height / pixelsPerTick); - this.tickSize = args.tickSize || 4; - this.ticksTreatment = args.ticksTreatment || 'plain'; - - if (args.element) { - - this.element = args.element; - this.vis = d3.select(args.element) - .append("svg:svg") - .attr('class', 'rickshaw_graph y_axis'); - - this.element = this.vis[0][0]; - this.element.style.position = 'relative'; - - this.setSize({ width: args.width, height: args.height }); - - } else { - this.vis = this.graph.vis; - } - - this.graph.onUpdate( function() { self.render() } ); - }; - - this.setSize = function(args) { - - args = args || {}; - - if (!this.element) return; - - if (typeof window !== 'undefined') { - - var style = window.getComputedStyle(this.element.parentNode, null); - var elementWidth = parseInt(style.getPropertyValue('width')); - - if (!args.auto) { - var elementHeight = parseInt(style.getPropertyValue('height')); - } - } - - this.width = args.width || elementWidth || this.graph.width * berthRate; - this.height = args.height || elementHeight || this.graph.height; - - this.vis - .attr('width', this.width) - .attr('height', this.height * (1 + berthRate)); - - var berth = this.height * berthRate; - this.element.style.top = -1 * berth + 'px'; - }; - - this.render = function() { - - if (this.graph.height !== this._renderHeight) this.setSize({ auto: true }); - - var axis = d3.svg.axis().scale(this.graph.y).orient(this.orientation); - axis.tickFormat( args.tickFormat || function(y) { return y } ); - - if (this.orientation == 'left') { - var berth = this.height * berthRate; - var transform = 'translate(' + this.width + ', ' + berth + ')'; - } - - if (this.element) { - this.vis.selectAll('*').remove(); - } - - this.vis - .append("svg:g") - .attr("class", ["y_ticks", this.ticksTreatment].join(" ")) - .attr("transform", transform) - .call(axis.ticks(this.ticks).tickSubdivide(0).tickSize(this.tickSize)) - - var gridSize = (this.orientation == 'right' ? 1 : -1) * this.graph.width; - - this.graph.vis - .append("svg:g") - .attr("class", "y_grid") - .call(axis.ticks(this.ticks).tickSubdivide(0).tickSize(gridSize)); - - this._renderHeight = this.graph.height; - }; - - this.initialize(args); -}; - -Rickshaw.namespace('Rickshaw.Graph.Behavior.Series.Highlight'); - -Rickshaw.Graph.Behavior.Series.Highlight = function(args) { - - this.graph = args.graph; - this.legend = args.legend; - - var self = this; - - var colorSafe = {}; - var activeLine = null; - - this.addHighlightEvents = function (l) { - - l.element.addEventListener( 'mouseover', function(e) { - - if (activeLine) return; - else activeLine = l; - - self.legend.lines.forEach( function(line, index) { - - if (l === line) { - - // if we're not in a stacked renderer bring active line to the top - if (index > 0 && self.graph.renderer.unstack) { - - var seriesIndex = self.graph.series.length - index - 1; - line.originalIndex = seriesIndex; - - var series = self.graph.series.splice(seriesIndex, 1)[0]; - self.graph.series.push(series); - } - return; - } - - colorSafe[line.series.name] = colorSafe[line.series.name] || line.series.color; - line.series.color = d3.interpolateRgb(line.series.color, d3.rgb('#d8d8d8'))(0.8).toString(); - } ); - - self.graph.update(); - - }, false ); - - l.element.addEventListener( 'mouseout', function(e) { - - if (!activeLine) return; - else activeLine = null; - - self.legend.lines.forEach( function(line) { - - // return reordered series to its original place - if (l === line && line.hasOwnProperty('originalIndex')) { - - var series = self.graph.series.pop(); - self.graph.series.splice(line.originalIndex, 0, series); - delete line['originalIndex']; - } - - if (colorSafe[line.series.name]) { - line.series.color = colorSafe[line.series.name]; - } - } ); - - self.graph.update(); - - }, false ); - }; - - if (this.legend) { - this.legend.lines.forEach( function(l) { - self.addHighlightEvents(l); - } ); - } - -}; -Rickshaw.namespace('Rickshaw.Graph.Behavior.Series.Order'); - -Rickshaw.Graph.Behavior.Series.Order = function(args) { - - this.graph = args.graph; - this.legend = args.legend; - - var self = this; - - $(function() { - $(self.legend.list).sortable( { - containment: 'parent', - tolerance: 'pointer', - update: function( event, ui ) { - var series = []; - $(self.legend.list).find('li').each( function(index, item) { - if (!item.series) return; - series.push(item.series); - } ); - - for (var i = self.graph.series.length - 1; i >= 0; i--) { - self.graph.series[i] = series.shift(); - } - - self.graph.update(); - } - } ); - $(self.legend.list).disableSelection(); - }); - - //hack to make jquery-ui sortable behave - this.graph.onUpdate( function() { - var h = window.getComputedStyle(self.legend.element).height; - self.legend.element.style.height = h; - } ); -}; -Rickshaw.namespace('Rickshaw.Graph.Behavior.Series.Toggle'); - -Rickshaw.Graph.Behavior.Series.Toggle = function(args) { - - this.graph = args.graph; - this.legend = args.legend; - - var self = this; - - this.addAnchor = function(line) { - var anchor = document.createElement('a'); - anchor.innerHTML = '✔'; - anchor.classList.add('action'); - line.element.insertBefore(anchor, line.element.firstChild); - - anchor.onclick = function(e) { - if (line.series.disabled) { - line.series.enable(); - line.element.classList.remove('disabled'); - } else { - line.series.disable(); - line.element.classList.add('disabled'); - } - } - - var label = line.element.getElementsByTagName('span')[0]; - label.onclick = function(e){ - - var disableAllOtherLines = line.series.disabled; - if ( ! disableAllOtherLines ) { - for ( var i = 0; i < self.legend.lines.length; i++ ) { - var l = self.legend.lines[i]; - if ( line.series === l.series ) { - // noop - } else if ( l.series.disabled ) { - // noop - } else { - disableAllOtherLines = true; - break; - } - } - } - - // show all or none - if ( disableAllOtherLines ) { - - // these must happen first or else we try ( and probably fail ) to make a no line graph - line.series.enable(); - line.element.classList.remove('disabled'); - - self.legend.lines.forEach(function(l){ - if ( line.series === l.series ) { - // noop - } else { - l.series.disable(); - l.element.classList.add('disabled'); - } - }); - - } else { - - self.legend.lines.forEach(function(l){ - l.series.enable(); - l.element.classList.remove('disabled'); - }); - - } - - }; - - }; - - if (this.legend) { - - $(this.legend.list).sortable( { - start: function(event, ui) { - ui.item.bind('no.onclick', - function(event) { - event.preventDefault(); - } - ); - }, - stop: function(event, ui) { - setTimeout(function(){ - ui.item.off('no.onclick'); - }, 250); - } - }) - - this.legend.lines.forEach( function(l) { - self.addAnchor(l); - } ); - } - - this._addBehavior = function() { - - this.graph.series.forEach( function(s) { - - s.disable = function() { - - if (self.graph.series.length <= 1) { - throw('only one series left'); - } - - s.disabled = true; - self.graph.update(); - }; - - s.enable = function() { - s.disabled = false; - self.graph.update(); - }; - } ); - }; - this._addBehavior(); - - this.updateBehaviour = function () { this._addBehavior() }; - -}; -Rickshaw.namespace('Rickshaw.Graph.HoverDetail'); - -Rickshaw.Graph.HoverDetail = Rickshaw.Class.create({ - - initialize: function(args) { - - var graph = this.graph = args.graph; - - this.xFormatter = args.xFormatter || function(x) { - return new Date( x * 1000 ).toUTCString(); - }; - - this.yFormatter = args.yFormatter || function(y) { - return y === null ? y : y.toFixed(2); - }; - - var element = this.element = document.createElement('div'); - element.className = 'detail'; - - this.visible = true; - graph.element.appendChild(element); - - this.lastEvent = null; - this._addListeners(); - - this.onShow = args.onShow; - this.onHide = args.onHide; - this.onRender = args.onRender; - - this.formatter = args.formatter || this.formatter; - - }, - - formatter: function(series, x, y, formattedX, formattedY, d) { - return series.name + ': ' + formattedY; - }, - - update: function(e) { - - e = e || this.lastEvent; - if (!e) return; - this.lastEvent = e; - - if (!e.target.nodeName.match(/^(path|svg|rect)$/)) return; - - var graph = this.graph; - - var eventX = e.offsetX || e.layerX; - var eventY = e.offsetY || e.layerY; - - var j = 0; - var points = []; - var nearestPoint; - - this.graph.series.active().forEach( function(series) { - - var data = this.graph.stackedData[j++]; - - var domainX = graph.x.invert(eventX); - - var domainIndexScale = d3.scale.linear() - .domain([data[0].x, data.slice(-1)[0].x]) - .range([0, data.length - 1]); - - var approximateIndex = Math.round(domainIndexScale(domainX)); - var dataIndex = Math.min(approximateIndex || 0, data.length - 1); - - for (var i = approximateIndex; i < data.length - 1;) { - - if (!data[i] || !data[i + 1]) break; - if (data[i].x <= domainX && data[i + 1].x > domainX) { dataIndex = i; break } - - if (data[i + 1].x <= domainX) { i++ } else { i-- } - } - - if (dataIndex < 0) dataIndex = 0; - var value = data[dataIndex]; - - var distance = Math.sqrt( - Math.pow(Math.abs(graph.x(value.x) - eventX), 2) + - Math.pow(Math.abs(graph.y(value.y + value.y0) - eventY), 2) - ); - - var xFormatter = series.xFormatter || this.xFormatter; - var yFormatter = series.yFormatter || this.yFormatter; - - var point = { - formattedXValue: xFormatter(value.x), - formattedYValue: yFormatter(value.y), - series: series, - value: value, - distance: distance, - order: j, - name: series.name - }; - - if (!nearestPoint || distance < nearestPoint.distance) { - nearestPoint = point; - } - - points.push(point); - - }, this ); - - - nearestPoint.active = true; - - var domainX = nearestPoint.value.x; - var formattedXValue = nearestPoint.formattedXValue; - - this.element.innerHTML = ''; - this.element.style.left = graph.x(domainX) + 'px'; - - this.visible && this.render( { - points: points, - detail: points, // for backwards compatibility - mouseX: eventX, - mouseY: eventY, - formattedXValue: formattedXValue, - domainX: domainX - } ); - }, - - hide: function() { - this.visible = false; - this.element.classList.add('inactive'); - - if (typeof this.onHide == 'function') { - this.onHide(); - } - }, - - show: function() { - this.visible = true; - this.element.classList.remove('inactive'); - - if (typeof this.onShow == 'function') { - this.onShow(); - } - }, - - render: function(args) { - - var graph = this.graph; - var points = args.points; - var point = points.filter( function(p) { return p.active } ).shift(); - - if (point.value.y === null) return; - - var formattedXValue = this.xFormatter(point.value.x); - var formattedYValue = this.yFormatter(point.value.y); - - this.element.innerHTML = ''; - this.element.style.left = graph.x(point.value.x) + 'px'; - - var xLabel = document.createElement('div'); - - xLabel.className = 'x_label'; - xLabel.innerHTML = formattedXValue; - this.element.appendChild(xLabel); - - var item = document.createElement('div'); - - item.className = 'item'; - item.innerHTML = this.formatter(point.series, point.value.x, point.value.y, formattedXValue, formattedYValue, point); - item.style.top = this.graph.y(point.value.y0 + point.value.y) + 'px'; - - this.element.appendChild(item); - - var dot = document.createElement('div'); - - dot.className = 'dot'; - dot.style.top = item.style.top; - dot.style.borderColor = point.series.color; - - this.element.appendChild(dot); - - if (point.active) { - item.className = 'item active'; - dot.className = 'dot active'; - } - - this.show(); - - if (typeof this.onRender == 'function') { - this.onRender(args); - } - }, - - _addListeners: function() { - - this.graph.element.addEventListener( - 'mousemove', - function(e) { - this.visible = true; - this.update(e) - }.bind(this), - false - ); - - this.graph.onUpdate( function() { this.update() }.bind(this) ); - - this.graph.element.addEventListener( - 'mouseout', - function(e) { - if (e.relatedTarget && !(e.relatedTarget.compareDocumentPosition(this.graph.element) & Node.DOCUMENT_POSITION_CONTAINS)) { - this.hide(); - } - }.bind(this), - false - ); - } -}); - -Rickshaw.namespace('Rickshaw.Graph.JSONP'); - -Rickshaw.Graph.JSONP = Rickshaw.Class.create( Rickshaw.Graph.Ajax, { - - request: function() { - - $.ajax( { - url: this.dataURL, - dataType: 'jsonp', - success: this.success.bind(this), - error: this.error.bind(this) - } ); - } -} ); -Rickshaw.namespace('Rickshaw.Graph.Legend'); - -Rickshaw.Graph.Legend = function(args) { - - var element = this.element = args.element; - var graph = this.graph = args.graph; - - var self = this; - - element.classList.add('rickshaw_legend'); - - var list = this.list = document.createElement('ul'); - element.appendChild(list); - - var series = graph.series - .map( function(s) { return s } ) - - if (!args.naturalOrder) { - series = series.reverse(); - } - - this.lines = []; - - this.addLine = function (series) { - var line = document.createElement('li'); - line.className = 'line'; - if (series.disabled) { - line.className += ' disabled'; - } - - var swatch = document.createElement('div'); - swatch.className = 'swatch'; - swatch.style.backgroundColor = series.color; - - line.appendChild(swatch); - - var label = document.createElement('span'); - label.className = 'label'; - label.innerHTML = series.name; - - line.appendChild(label); - list.appendChild(line); - - line.series = series; - - if (series.noLegend) { - line.style.display = 'none'; - } - - var _line = { element: line, series: series }; - if (self.shelving) { - self.shelving.addAnchor(_line); - self.shelving.updateBehaviour(); - } - if (self.highlighter) { - self.highlighter.addHighlightEvents(_line); - } - self.lines.push(_line); - }; - - series.forEach( function(s) { - self.addLine(s); - } ); - - graph.onUpdate( function() {} ); -}; -Rickshaw.namespace('Rickshaw.Graph.RangeSlider'); - -Rickshaw.Graph.RangeSlider = function(args) { - - var element = this.element = args.element; - var graph = this.graph = args.graph; - - $( function() { - $(element).slider( { - - range: true, - min: graph.dataDomain()[0], - max: graph.dataDomain()[1], - values: [ - graph.dataDomain()[0], - graph.dataDomain()[1] - ], - slide: function( event, ui ) { - - graph.window.xMin = ui.values[0]; - graph.window.xMax = ui.values[1]; - graph.update(); - - // if we're at an extreme, stick there - if (graph.dataDomain()[0] == ui.values[0]) { - graph.window.xMin = undefined; - } - if (graph.dataDomain()[1] == ui.values[1]) { - graph.window.xMax = undefined; - } - } - } ); - } ); - - element[0].style.width = graph.width + 'px'; - - graph.onUpdate( function() { - - var values = $(element).slider('option', 'values'); - - $(element).slider('option', 'min', graph.dataDomain()[0]); - $(element).slider('option', 'max', graph.dataDomain()[1]); - - if (graph.window.xMin == undefined) { - values[0] = graph.dataDomain()[0]; - } - if (graph.window.xMax == undefined) { - values[1] = graph.dataDomain()[1]; - } - - $(element).slider('option', 'values', values); - - } ); -}; - -Rickshaw.namespace("Rickshaw.Graph.Renderer"); - -Rickshaw.Graph.Renderer = Rickshaw.Class.create( { - - initialize: function(args) { - this.graph = args.graph; - this.tension = args.tension || this.tension; - this.graph.unstacker = this.graph.unstacker || new Rickshaw.Graph.Unstacker( { graph: this.graph } ); - this.configure(args); - }, - - seriesPathFactory: function() { - //implement in subclass - }, - - seriesStrokeFactory: function() { - // implement in subclass - }, - - defaults: function() { - return { - tension: 0.8, - strokeWidth: 2, - unstack: true, - padding: { top: 0.01, right: 0, bottom: 0.01, left: 0 }, - stroke: false, - fill: false - }; - }, - - domain: function() { - - var values = { xMin: [], xMax: [], y: [] }; - - var stackedData = this.graph.stackedData || this.graph.stackData(); - var firstPoint = stackedData[0][0]; - - var xMin = firstPoint.x; - var xMax = firstPoint.x - - var yMin = firstPoint.y + firstPoint.y0; - var yMax = firstPoint.y + firstPoint.y0; - - stackedData.forEach( function(series) { - - series.forEach( function(d) { - - if (d.y == undefined) return; - - var y = d.y + d.y0; - - if (y < yMin) yMin = y; - if (y > yMax) yMax = y; - } ); - - if (series[0].x < xMin) xMin = series[0].x; - if (series[series.length - 1].x > xMax) xMax = series[series.length - 1].x; - } ); - - xMin -= (xMax - xMin) * this.padding.left; - xMax += (xMax - xMin) * this.padding.right; - - yMin = this.graph.min === 'auto' ? yMin : this.graph.min || 0; - yMax = this.graph.max === undefined ? yMax : this.graph.max; - - if (this.graph.min === 'auto' || yMin < 0) { - yMin -= (yMax - yMin) * this.padding.bottom; - } - - if (this.graph.max === undefined) { - yMax += (yMax - yMin) * this.padding.top; - } - - return { x: [xMin, xMax], y: [yMin, yMax] }; - }, - - render: function() { - - var graph = this.graph; - - graph.vis.selectAll('*').remove(); - - var nodes = graph.vis.selectAll("path") - .data(this.graph.stackedData) - .enter().append("svg:path") - .attr("d", this.seriesPathFactory()); - - var i = 0; - graph.series.forEach( function(series) { - if (series.disabled) return; - series.path = nodes[0][i++]; - this._styleSeries(series); - }, this ); - }, - - _styleSeries: function(series) { - - var fill = this.fill ? series.color : 'none'; - var stroke = this.stroke ? series.color : 'none'; - - series.path.setAttribute('fill', fill); - series.path.setAttribute('stroke', stroke); - series.path.setAttribute('stroke-width', this.strokeWidth); - series.path.setAttribute('class', series.className); - }, - - configure: function(args) { - - args = args || {}; - - Rickshaw.keys(this.defaults()).forEach( function(key) { - - if (!args.hasOwnProperty(key)) { - this[key] = this[key] || this.graph[key] || this.defaults()[key]; - return; - } - - if (typeof this.defaults()[key] == 'object') { - - Rickshaw.keys(this.defaults()[key]).forEach( function(k) { - - this[key][k] = - args[key][k] !== undefined ? args[key][k] : - this[key][k] !== undefined ? this[key][k] : - this.defaults()[key][k]; - }, this ); - - } else { - this[key] = - args[key] !== undefined ? args[key] : - this[key] !== undefined ? this[key] : - this.graph[key] !== undefined ? this.graph[key] : - this.defaults()[key]; - } - - }, this ); - }, - - setStrokeWidth: function(strokeWidth) { - if (strokeWidth !== undefined) { - this.strokeWidth = strokeWidth; - } - }, - - setTension: function(tension) { - if (tension !== undefined) { - this.tension = tension; - } - } -} ); - -Rickshaw.namespace('Rickshaw.Graph.Renderer.Line'); - -Rickshaw.Graph.Renderer.Line = Rickshaw.Class.create( Rickshaw.Graph.Renderer, { - - name: 'line', - - defaults: function($super) { - - return Rickshaw.extend( $super(), { - unstack: true, - fill: false, - stroke: true - } ); - }, - - seriesPathFactory: function() { - - var graph = this.graph; - - var factory = d3.svg.line() - .x( function(d) { return graph.x(d.x) } ) - .y( function(d) { return graph.y(d.y) } ) - .interpolate(this.graph.interpolation).tension(this.tension) - - factory.defined && factory.defined( function(d) { return d.y !== null } ); - return factory; - } -} ); - -Rickshaw.namespace('Rickshaw.Graph.Renderer.Stack'); - -Rickshaw.Graph.Renderer.Stack = Rickshaw.Class.create( Rickshaw.Graph.Renderer, { - - name: 'stack', - - defaults: function($super) { - - return Rickshaw.extend( $super(), { - fill: true, - stroke: false, - unstack: false - } ); - }, - - seriesPathFactory: function() { - - var graph = this.graph; - - var factory = d3.svg.area() - .x( function(d) { return graph.x(d.x) } ) - .y0( function(d) { return graph.y(d.y0) } ) - .y1( function(d) { return graph.y(d.y + d.y0) } ) - .interpolate(this.graph.interpolation).tension(this.tension); - - factory.defined && factory.defined( function(d) { return d.y !== null } ); - return factory; - } -} ); - -Rickshaw.namespace('Rickshaw.Graph.Renderer.Bar'); - -Rickshaw.Graph.Renderer.Bar = Rickshaw.Class.create( Rickshaw.Graph.Renderer, { - - name: 'bar', - - defaults: function($super) { - - var defaults = Rickshaw.extend( $super(), { - gapSize: 0.05, - unstack: false - } ); - - delete defaults.tension; - return defaults; - }, - - initialize: function($super, args) { - args = args || {}; - this.gapSize = args.gapSize || this.gapSize; - $super(args); - }, - - domain: function($super) { - - var domain = $super(); - - var frequentInterval = this._frequentInterval(); - domain.x[1] += parseInt(frequentInterval.magnitude); - - return domain; - }, - - barWidth: function() { - - var stackedData = this.graph.stackedData || this.graph.stackData(); - var data = stackedData.slice(-1).shift(); - - var frequentInterval = this._frequentInterval(); - var barWidth = this.graph.x(data[0].x + frequentInterval.magnitude * (1 - this.gapSize)); - - return barWidth; - }, - - render: function() { - - var graph = this.graph; - - graph.vis.selectAll('*').remove(); - - var barWidth = this.barWidth(); - var barXOffset = 0; - - var activeSeriesCount = graph.series.filter( function(s) { return !s.disabled; } ).length; - var seriesBarWidth = this.unstack ? barWidth / activeSeriesCount : barWidth; - - var transform = function(d) { - // add a matrix transform for negative values - var matrix = [ 1, 0, 0, (d.y < 0 ? -1 : 1), 0, (d.y < 0 ? graph.y.magnitude(Math.abs(d.y)) * 2 : 0) ]; - return "matrix(" + matrix.join(',') + ")"; - }; - - graph.series.forEach( function(series) { - - if (series.disabled) return; - - var nodes = graph.vis.selectAll("path") - .data(series.stack.filter( function(d) { return d.y !== null } )) - .enter().append("svg:rect") - .attr("x", function(d) { return graph.x(d.x) + barXOffset }) - .attr("y", function(d) { return (graph.y(d.y0 + Math.abs(d.y))) * (d.y < 0 ? -1 : 1 ) }) - .attr("width", seriesBarWidth) - .attr("height", function(d) { return graph.y.magnitude(Math.abs(d.y)) }) - .attr("transform", transform); - - Array.prototype.forEach.call(nodes[0], function(n) { - n.setAttribute('fill', series.color); - } ); - - if (this.unstack) barXOffset += seriesBarWidth; - - }, this ); - }, - - _frequentInterval: function() { - - var stackedData = this.graph.stackedData || this.graph.stackData(); - var data = stackedData.slice(-1).shift(); - - var intervalCounts = {}; - - for (var i = 0; i < data.length - 1; i++) { - var interval = data[i + 1].x - data[i].x; - intervalCounts[interval] = intervalCounts[interval] || 0; - intervalCounts[interval]++; - } - - var frequentInterval = { count: 0 }; - - Rickshaw.keys(intervalCounts).forEach( function(i) { - if (frequentInterval.count < intervalCounts[i]) { - - frequentInterval = { - count: intervalCounts[i], - magnitude: i - }; - } - } ); - - //this._frequentInterval = function() { return frequentInterval }; - - return frequentInterval; - } -} ); - -Rickshaw.namespace('Rickshaw.Graph.Renderer.Area'); - -Rickshaw.Graph.Renderer.Area = Rickshaw.Class.create( Rickshaw.Graph.Renderer, { - - name: 'area', - - defaults: function($super) { - - return Rickshaw.extend( $super(), { - unstack: false, - fill: false, - stroke: false - } ); - }, - - seriesPathFactory: function() { - - var graph = this.graph; - - var factory = d3.svg.area() - .x( function(d) { return graph.x(d.x) } ) - .y0( function(d) { return graph.y(d.y0) } ) - .y1( function(d) { return graph.y(d.y + d.y0) } ) - .interpolate(graph.interpolation).tension(this.tension) - - factory.defined && factory.defined( function(d) { return d.y !== null } ); - return factory; - }, - - seriesStrokeFactory: function() { - - var graph = this.graph; - - var factory = d3.svg.line() - .x( function(d) { return graph.x(d.x) } ) - .y( function(d) { return graph.y(d.y + d.y0) } ) - .interpolate(graph.interpolation).tension(this.tension) - - factory.defined && factory.defined( function(d) { return d.y !== null } ); - return factory; - }, - - render: function() { - - var graph = this.graph; - - graph.vis.selectAll('*').remove(); - - // insert or stacked areas so strokes lay on top of areas - var method = this.unstack ? 'append' : 'insert'; - - var nodes = graph.vis.selectAll("path") - .data(this.graph.stackedData) - .enter()[method]("svg:g", 'g'); - - nodes.append("svg:path") - .attr("d", this.seriesPathFactory()) - .attr("class", 'area'); - - if (this.stroke) { - nodes.append("svg:path") - .attr("d", this.seriesStrokeFactory()) - .attr("class", 'line'); - } - - var i = 0; - graph.series.forEach( function(series) { - if (series.disabled) return; - series.path = nodes[0][i++]; - this._styleSeries(series); - }, this ); - }, - - _styleSeries: function(series) { - - if (!series.path) return; - - d3.select(series.path).select('.area') - .attr('fill', series.color); - - if (this.stroke) { - d3.select(series.path).select('.line') - .attr('fill', 'none') - .attr('stroke', series.stroke || d3.interpolateRgb(series.color, 'black')(0.125)) - .attr('stroke-width', this.strokeWidth); - } - - if (series.className) { - series.path.setAttribute('class', series.className); - } - } -} ); - -Rickshaw.namespace('Rickshaw.Graph.Renderer.ScatterPlot'); - -Rickshaw.Graph.Renderer.ScatterPlot = Rickshaw.Class.create( Rickshaw.Graph.Renderer, { - - name: 'scatterplot', - - defaults: function($super) { - - return Rickshaw.extend( $super(), { - unstack: true, - fill: true, - stroke: false, - padding:{ top: 0.01, right: 0.01, bottom: 0.01, left: 0.01 }, - dotSize: 4 - } ); - }, - - initialize: function($super, args) { - $super(args); - }, - - render: function() { - - var graph = this.graph; - - graph.vis.selectAll('*').remove(); - - graph.series.forEach( function(series) { - - if (series.disabled) return; - - var nodes = graph.vis.selectAll("path") - .data(series.stack.filter( function(d) { return d.y !== null } )) - .enter().append("svg:circle") - .attr("cx", function(d) { return graph.x(d.x) }) - .attr("cy", function(d) { return graph.y(d.y) }) - .attr("r", function(d) { return ("r" in d) ? d.r : graph.renderer.dotSize}); - - Array.prototype.forEach.call(nodes[0], function(n) { - n.setAttribute('fill', series.color); - } ); - - }, this ); - } -} ); -Rickshaw.namespace('Rickshaw.Graph.Smoother'); - -Rickshaw.Graph.Smoother = function(args) { - - this.graph = args.graph; - this.element = args.element; - - var self = this; - - this.aggregationScale = 1; - - if (this.element) { - - $( function() { - $(self.element).slider( { - min: 1, - max: 100, - slide: function( event, ui ) { - self.setScale(ui.value); - self.graph.update(); - } - } ); - } ); - } - - self.graph.stackData.hooks.data.push( { - name: 'smoother', - orderPosition: 50, - f: function(data) { - - if (self.aggregationScale == 1) return data; - - var aggregatedData = []; - - data.forEach( function(seriesData) { - - var aggregatedSeriesData = []; - - while (seriesData.length) { - - var avgX = 0, avgY = 0; - var slice = seriesData.splice(0, self.aggregationScale); - - slice.forEach( function(d) { - avgX += d.x / slice.length; - avgY += d.y / slice.length; - } ); - - aggregatedSeriesData.push( { x: avgX, y: avgY } ); - } - - aggregatedData.push(aggregatedSeriesData); - } ); - - return aggregatedData; - } - } ); - - this.setScale = function(scale) { - - if (scale < 1) { - throw "scale out of range: " + scale; - } - - this.aggregationScale = scale; - this.graph.update(); - } -}; - -Rickshaw.namespace('Rickshaw.Graph.Unstacker'); - -Rickshaw.Graph.Unstacker = function(args) { - - this.graph = args.graph; - var self = this; - - this.graph.stackData.hooks.after.push( { - name: 'unstacker', - f: function(data) { - - if (!self.graph.renderer.unstack) return data; - - data.forEach( function(seriesData) { - seriesData.forEach( function(d) { - d.y0 = 0; - } ); - } ); - - return data; - } - } ); -}; - -Rickshaw.namespace('Rickshaw.Series'); - -Rickshaw.Series = Rickshaw.Class.create( Array, { - - initialize: function (data, palette, options) { - - options = options || {} - - this.palette = new Rickshaw.Color.Palette(palette); - - this.timeBase = typeof(options.timeBase) === 'undefined' ? - Math.floor(new Date().getTime() / 1000) : - options.timeBase; - - var timeInterval = typeof(options.timeInterval) == 'undefined' ? - 1000 : - options.timeInterval; - - this.setTimeInterval(timeInterval); - - if (data && (typeof(data) == "object") && (data instanceof Array)) { - data.forEach( function(item) { this.addItem(item) }, this ); - } - }, - - addItem: function(item) { - - if (typeof(item.name) === 'undefined') { - throw('addItem() needs a name'); - } - - item.color = (item.color || this.palette.color(item.name)); - item.data = (item.data || []); - - // backfill, if necessary - if ((item.data.length == 0) && this.length && (this.getIndex() > 0)) { - this[0].data.forEach( function(plot) { - item.data.push({ x: plot.x, y: 0 }); - } ); - } else if (item.data.length == 0) { - item.data.push({ x: this.timeBase - (this.timeInterval || 0), y: 0 }); - } - - this.push(item); - - if (this.legend) { - this.legend.addLine(this.itemByName(item.name)); - } - }, - - addData: function(data) { - - var index = this.getIndex(); - - Rickshaw.keys(data).forEach( function(name) { - if (! this.itemByName(name)) { - this.addItem({ name: name }); - } - }, this ); - - this.forEach( function(item) { - item.data.push({ - x: (index * this.timeInterval || 1) + this.timeBase, - y: (data[item.name] || 0) - }); - }, this ); - }, - - getIndex: function () { - return (this[0] && this[0].data && this[0].data.length) ? this[0].data.length : 0; - }, - - itemByName: function(name) { - - for (var i = 0; i < this.length; i++) { - if (this[i].name == name) - return this[i]; - } - }, - - setTimeInterval: function(iv) { - this.timeInterval = iv / 1000; - }, - - setTimeBase: function (t) { - this.timeBase = t; - }, - - dump: function() { - - var data = { - timeBase: this.timeBase, - timeInterval: this.timeInterval, - items: [] - }; - - this.forEach( function(item) { - - var newItem = { - color: item.color, - name: item.name, - data: [] - }; - - item.data.forEach( function(plot) { - newItem.data.push({ x: plot.x, y: plot.y }); - } ); - - data.items.push(newItem); - } ); - - return data; - }, - - load: function(data) { - - if (data.timeInterval) { - this.timeInterval = data.timeInterval; - } - - if (data.timeBase) { - this.timeBase = data.timeBase; - } - - if (data.items) { - data.items.forEach( function(item) { - this.push(item); - if (this.legend) { - this.legend.addLine(this.itemByName(item.name)); - } - - }, this ); - } - } -} ); - -Rickshaw.Series.zeroFill = function(series) { - Rickshaw.Series.fill(series, 0); -}; - -Rickshaw.Series.fill = function(series, fill) { - - var x; - var i = 0; - - var data = series.map( function(s) { return s.data } ); - - while ( i < Math.max.apply(null, data.map( function(d) { return d.length } )) ) { - - x = Math.min.apply( null, - data - .filter(function(d) { return d[i] }) - .map(function(d) { return d[i].x }) - ); - - data.forEach( function(d) { - if (!d[i] || d[i].x != x) { - d.splice(i, 0, { x: x, y: fill }); - } - } ); - - i++; - } -}; - -Rickshaw.namespace('Rickshaw.Series.FixedDuration'); - -Rickshaw.Series.FixedDuration = Rickshaw.Class.create(Rickshaw.Series, { - - initialize: function (data, palette, options) { - - var options = options || {} - - if (typeof(options.timeInterval) === 'undefined') { - throw new Error('FixedDuration series requires timeInterval'); - } - - if (typeof(options.maxDataPoints) === 'undefined') { - throw new Error('FixedDuration series requires maxDataPoints'); - } - - this.palette = new Rickshaw.Color.Palette(palette); - this.timeBase = typeof(options.timeBase) === 'undefined' ? Math.floor(new Date().getTime() / 1000) : options.timeBase; - this.setTimeInterval(options.timeInterval); - - if (this[0] && this[0].data && this[0].data.length) { - this.currentSize = this[0].data.length; - this.currentIndex = this[0].data.length; - } else { - this.currentSize = 0; - this.currentIndex = 0; - } - - this.maxDataPoints = options.maxDataPoints; - - - if (data && (typeof(data) == "object") && (data instanceof Array)) { - data.forEach( function (item) { this.addItem(item) }, this ); - this.currentSize += 1; - this.currentIndex += 1; - } - - // reset timeBase for zero-filled values if needed - this.timeBase -= (this.maxDataPoints - this.currentSize) * this.timeInterval; - - // zero-fill up to maxDataPoints size if we don't have that much data yet - if ((typeof(this.maxDataPoints) !== 'undefined') && (this.currentSize < this.maxDataPoints)) { - for (var i = this.maxDataPoints - this.currentSize - 1; i > 0; i--) { - this.currentSize += 1; - this.currentIndex += 1; - this.forEach( function (item) { - item.data.unshift({ x: ((i-1) * this.timeInterval || 1) + this.timeBase, y: 0, i: i }); - }, this ); - } - } - }, - - addData: function($super, data) { - - $super(data) - - this.currentSize += 1; - this.currentIndex += 1; - - if (this.maxDataPoints !== undefined) { - while (this.currentSize > this.maxDataPoints) { - this.dropData(); - } - } - }, - - dropData: function() { - - this.forEach(function(item) { - item.data.splice(0, 1); - } ); - - this.currentSize -= 1; - }, - - getIndex: function () { - return this.currentIndex; - } -} ); diff --git a/kitsune/sumo/static/sumo/js/questions.metrics-dashboard.js b/kitsune/sumo/static/sumo/js/questions.metrics-dashboard.js deleted file mode 100644 index ad3fea32079..00000000000 --- a/kitsune/sumo/static/sumo/js/questions.metrics-dashboard.js +++ /dev/null @@ -1,143 +0,0 @@ -import "jquery-ui/ui/widgets/datepicker"; -import { Graph } from "sumo/js/rickshaw_utils"; - -(function() { - - function init() { - makeTopicsGraph(); - makeMetricsGraph(); - } - - function makeTopicsGraph() { - var $topics, datums, seriesSpec, key; - - $('input[type=date]').attr('type','text').datepicker({ - dateFormat: 'yy-mm-dd' - }); - - $topics = $('#topic-stats'); - if ($topics.length === 0) { - return; - } - - datums = $topics.data('graph'); - seriesSpec = []; - - for (key in datums[0]) { - if (key === 'date' || !datums[0].hasOwnProperty(key)) { - continue; - } - // TODO: these names should be localized. - seriesSpec.push({ - name: key, - slug: key, - func: Graph.identity(key) - }); - } - - new Graph($topics, { - data: { - datums: datums, - seriesSpec: seriesSpec - }, - graph: { - renderer: 'bar', - width: 690, - unstack: false - }, - options: { - slider: false - } - }).render(); - } - - function makeMetricsGraph() { - var $container = $('#questions-metrics'); - $.getJSON($container.data('url'), function(data) { - // Fill in 0s so bucketing doesn't freak out... - var objects = data.objects; - objects.forEach(function(object) { - object.questions = object.questions || 0; - object.solved = object.solved || 0; - object.responded_24 = object.responded_24 || 0; - object.responded_72 = object.responded_72 || 0; - }); - - new Graph($container, { - data: { - datums: objects, - seriesSpec: [ - { - name: gettext('Questions'), - slug: 'questions', - func: Graph.identity('questions'), - color: '#5d84b2', - axisGroup: 'questions', - area: true - }, - { - name: gettext('Solved'), - slug: 'num_solved', - func: Graph.identity('solved'), - color: '#aa4643', - axisGroup: 'questions', - area: true - }, - { - name: gettext('% Solved'), - slug: 'solved', - func: Graph.fraction('solved', 'questions'), - color: '#aa4643', - axisGroup: 'percent', - type: 'percent' - }, - { - name: gettext('Responded in 24 hours'), - slug: 'num_responded_24', - func: Graph.identity('responded_24'), - color: '#89a54e', - axisGroup: 'questions', - area: true - }, - { - name: gettext('% Responded in 24 hours'), - slug: 'responded_24', - func: Graph.fraction('responded_24', 'questions'), - color: '#89a54e', - axisGroup: 'percent', - type: 'percent' - }, - { - name: gettext('Responded in 72 hours'), - slug: 'num_responded_72', - func: Graph.identity('responded_72'), - color: '#80699b', - axisGroup: 'questions', - area: true - }, - { - name: gettext('% Responded in 72 hours'), - slug: 'responded_72', - func: Graph.fraction('responded_72', 'questions'), - color: '#80699b', - axisGroup: 'percent', - type: 'percent' - } - ] - }, - options: { - legend: 'mini', - slider: true, - bucket: true - }, - graph: { - width: 880, - height: 300 - }, - }).render(); - }); - } - - $(init); - -})(); diff --git a/kitsune/sumo/static/sumo/js/rickshaw_utils.js b/kitsune/sumo/static/sumo/js/rickshaw_utils.js deleted file mode 100644 index 7c0ae106e97..00000000000 --- a/kitsune/sumo/static/sumo/js/rickshaw_utils.js +++ /dev/null @@ -1,1324 +0,0 @@ -import "jquery-ui/ui/widgets/datepicker"; -import "jquery-ui/ui/widgets/slider"; -import _filter from "underscore/modules/filter"; -import _map from "underscore/modules/map"; -import _each from "underscore/modules/each"; -import Rickshaw from "./libs/rickshaw"; -import { dateFormat } from "sumo/js/main"; -import d3 from "d3"; - -/* class Graph */ -export function Graph($elem, extra) { - var defaults = { - toRender: [], - options: { - bucket: false, - daterange: true, - hover: true, - init: true, - legend: true, - sets: false, - slider: true, - timeline: false, - xAxis: true, - yAxis: true - }, - - data: { - datums: [], - seriesSpec: [], - - annotations: [], - bucketed: [] - }, - - metadata: { - colors: {}, - sets: {}, - bucketMethods: {} - }, - - graph: { - renderer: 'area', - interpolation: 'linear', - stroke: true, - unstack: true - }, - hover: {}, - yAxis: {}, - - rickshaw: {}, - dom: {}, - axisGroups: {}, - d3: { - axises: {} - } - }; - - // true means do a deep merge. - $.extend(true, this, defaults, extra); - - this.dom.elem = $elem; - - if (this.options.init) { - this.init(); - } -}; - -(function () { - 'use strict'; - - Graph.prototype.init = function () { - window.G = this; - this.initBucketUI(); - this.initData(); - this.initGraph(); - this.initAxises(); - this.initSlider(); - this.initDateRange(); - this.initLegend(); - this.initSets(); - this.initTimeline(); - }; - - Graph.prototype.initData = function () { - var i, d; - for (i = 0; i < this.data.datums.length; i += 1) { - d = this.data.datums[i]; - d.date = Graph.toSeconds(d.date || d.created || d.start); - d.created = undefined; - d.start = undefined; - } - - this.rebucket(); - }; - - Graph.prototype.rebucket = function () { - var buckets, bucketed, i, d, axisGroup, axis, series, name, date, chopLimit, now; - buckets = {}; - bucketed = []; - - // Bucket data - if (this.data.bucketSize) { - for (i = 0; i < this.data.datums.length; i += 1) { - // make a copy. - d = $.extend({}, this.data.datums[i]); - date = new Date(d.date * 1000); - - // NB: These are resilient to borders in months and years because - // JS's Date has the neat property that - // new Date(2013, 4, -1) === new Date(2013, 3, 29) - // new Date(2013, 0, -60) === new Date(2012, 10, 1) - // This might be the only nice thing about JS's Date. - switch (this.data.bucketSize) { - case 'day': - // Get midnight of today (ie, the boundary between today and yesterday) - d.date = new Date(date.getFullYear(), date.getMonth(), date.getDate()); - break; - case 'week': - // Get the most recent Sunday. - d.date = new Date(date.getFullYear(), date.getMonth(), date.getDate() - date.getDay()); - break; - case 'month': - // Get the first of this month. - d.date = new Date(date.getFullYear(), date.getMonth(), 1); - break; - default: - throw 'Unknown bucket size ' + this.data.bucketSize; - } - d.date = d.date / 1000; - - if (buckets[d.date] === undefined) { - buckets[d.date] = [d]; - } else { - buckets[d.date].push(d); - } - } - - bucketed = $.map(buckets, function (dList) { - var out, key; - out = $.extend({}, dList[0]); - - for (key in out) { - if (out.hasOwnProperty(key) && key !== 'date') { - for (i = 1; i < dList.length; i += 1) { - out[key] += dList[i][key]; - } - } - } - - return out; - }); - - } else { - bucketed = this.data.datums.slice(); - } - - /* Data points that are too near the present represent a UX problem. - * The data in them is not representative of a full time period, so - * they appear to be downward trending. `chopLimit` represents the - * boundary of what is considered to be "too new". Bug #876912. */ - now = new Date(); - if (this.data.bucketSize === 'week') { - // Get most recent Sunday. - chopLimit = new Date(now.getFullYear(), now.getMonth(), now.getDate() - now.getDay()); - } else if (this.data.bucketSize === 'month') { - // Get the first of the current month. - chopLimit = new Date(now.getFullYear(), now.getMonth(), 1); - } else { - // Get midnight of today (ie, the boundary between today and yesterday) - chopLimit = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - } - bucketed = _filter(bucketed, function (datum) { - return datum.date < chopLimit / 1000; - }); - - this.data.series = this.makeSeries(bucketed, this.data.seriesSpec); - - // Scale data based on axis groups - this.axisGroups = {}; - for (i = 0; i < this.data.series.length; i += 1) { - series = this.data.series[i]; - name = series.axisGroup; - if (this.axisGroups[name] === undefined) { - this.axisGroups[name] = { - max: -Infinity - }; - } - - // Only adjust the axis max if the series is enabled. - if (!series.disabled) { - this.axisGroups[name].max = Math.max(this.axisGroups[name].max, series.max); - } - } - - function mapHandler(point) { - return { - x: point.x, - y: point.y / axisGroup.max - }; - } - - for (i = 0; i < this.data.series.length; i += 1) { - series = this.data.series[i]; - axisGroup = this.axisGroups[series.axisGroup]; - series.data = _map(series.data, mapHandler); - series.scale = axisGroup.max; - axis = this.d3.axises[series.axisGroup]; - if (axis) { - axis.setScale(axisGroup.max); - } - } - }; - - /* Take an array of datums and make a set of named x/y series, suitable - * for Rickshaw. Each series is generated by one of the key functions. - * - * `descriptors` is an array of objects that define a name, a slug, and - * a function to calculate data. Each data function will be used as a - * map function on the datum objects to generate a series. - * - * Each descriptor may also optionally contain: - * color: The color to draw this series in. The default is to use a - * color generated by rickshaw. - * disabled: If true, this graph will not be drawn. The default is - * false. - * axisGroup: The name of the axisGroup this series belongs to. - * - * Output will have all the above values, as well as the maximum - * values within the current graph window. - */ - Graph.prototype.makeSeries = function (objects, descriptors) { - var i; - var series = []; - var desc; - var min, max, data; - var windowMin, windowMax; - var stroke, fill; - var r, g, b; - var palette = new Rickshaw.Color.Palette(); - - if (this.rickshaw.graph) { - windowMin = this.rickshaw.graph.window.xMin || -Infinity; - windowMax = this.rickshaw.graph.window.xMax || +Infinity; - } else { - windowMin = -Infinity; - windowMax = +Infinity; - } - - function mapHandler(datum) { - var val = desc.func(datum); - - if (isNaN(val) ) { - val = 0; - } - - if (windowMin <= datum.date && datum.date <= windowMax) { - min = Math.min(min, val); - max = Math.max(max, val); - } - - return {x: datum.date, y: val}; - } - - function yFormatter(value) { - return Math.floor(value * 100) + '%'; - } - - for (i = 0; i < descriptors.length; i += 1) { - min = Infinity; - max = -Infinity; - desc = descriptors[i]; - - data = _map(objects, mapHandler); - - if (max <= 0 || isNaN(max) || !isFinite(max)) { - max = 1; - } - - if (this.graph.renderer === 'area') { - stroke = desc.color || palette.color(desc.name); - if (desc.area) { - r = parseInt(desc.color.slice(1, 3), 16); - g = parseInt(desc.color.slice(3, 5), 16); - b = parseInt(desc.color.slice(5, 7), 16); - fill = interpolate('rgba(%s,%s,%s,0.5)', [r, g, b]); - } else { - fill = 'rgba(0, 0, 0, 0.0)'; - } - } else { - // This is a bar graph. 'fill' is really color. - stroke = undefined; - fill = desc.color; - } - - series[i] = { - name: desc.name, - slug: desc.slug, - disabled: desc.disabled || false, - type: desc.type || 'value', - - stroke: stroke, - color: fill, - axisGroup: desc.axisGroup, - min: min, - max: max, - data: data - }; - - if (series[i].type === 'percent') { - series[i].yFormatter = yFormatter; - } - } - - // Rickshaw gets angry when its data isn't sorted. - function sortCallback (v1, v2) { - return v1.x - v2.x; - } - - for (i = 0; i < descriptors.length; i += 1) { - series[i].data.sort(sortCallback); - } - - return series; - }; - - Graph.prototype.getGraphData = function () { - var palette = new Rickshaw.Color.Palette(); - var series = new Rickshaw.Series(this.data.series, palette); - - series.active = function () { - // filter by active. - return $.map(this, function (s) { - if (!s.disabled) { - return s; - } - }); - }; - - return series; - }; - - Graph.prototype.initBucketUI = function () { - if (!this.options.bucket) { return; } - - var i; - var bucketSizes = [ - {value: 'day', text: gettext('Daily')}, - {value: 'week', text: gettext('Weekly')}, - {value: 'month', text: gettext('Monthly')} - ]; - - var $bucket = $('
') - .appendTo(this.dom.elem.find('.inline-controls')); - var $select = $('', - to_input: '' - }, true); - - $inlines = this.dom.elem.find('.inline-controls'); - var $label = $('') - .html(label_html) - .appendTo($('
').appendTo($inlines)); - $presets = $('
').appendTo($inlines); - - $label.find('input[type=date]').attr('type','text').datepicker({ - dateFormat: 'yy-mm-dd' - }); - - $label.on('change', 'input', function () { - var $this = $(this); - var val = $this.val(); - if ($this.prop('name') === 'start') { - self.setRange(val, undefined); - } else { - self.setRange(undefined, val); - } - }); - - this.rickshaw.graph.onUpdate(function () { - var window = self.rickshaw.graph.window; - - now = +new Date() / 1000; - var start = window.xMin || (now - all_ago); - var end = window.xMax || now; - - if (self.options.slider) { - self.slider.slider('values', [start, end]); - } - - start = new Date(start * 1000); - end = new Date(end * 1000); - - var fmt = '%(year)s-%(month)s-%(date)s'; - $label.find('[name=start]').val(dateFormat(fmt, start)); - $label.find('[name=end]').val(dateFormat(fmt, end)); - }); - - var clickHandler = function() { - now = +new Date() / 1000; - var min = (now - $(this).data('days-ago')); - - self.rickshaw.graph.window.xMin = min; - self.rickshaw.graph.window.xMax = undefined; - if (self.options.slider) { - self.slider.slider('values', [min, now]); - } - - self.rebucket(); - self.update(); - }; - - for (i = 0; i < presets.length; i += 1) { - $(' - {% endif %} -
+ {% if revisions.count() > 1 %} +
+ +
+ {% endif %} {% set reached_current = False %} {% set reached_ready_for_l10n = False %} {% for rev in revisions %} diff --git a/kitsune/wiki/views.py b/kitsune/wiki/views.py index fd6e8c88d0a..ac0d6319feb 100644 --- a/kitsune/wiki/views.py +++ b/kitsune/wiki/views.py @@ -1,7 +1,6 @@ import json import logging -import time -from datetime import datetime +from datetime import datetime, timedelta from datetime import time as datetime_time from functools import wraps @@ -22,6 +21,7 @@ from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy as _lazy from django.utils.translation.trans_real import parse_accept_lang_header +from django.views.decorators.cache import cache_page from django.views.decorators.http import require_GET, require_http_methods, require_POST from kitsune.access.decorators import login_required @@ -1429,21 +1429,30 @@ def handle_vote(request, document_slug): return HttpResponseRedirect(revision.document.get_absolute_url()) +HELPFUL_VOTES_WINDOW_DAYS = 730 + + @require_GET +@cache_page(60 * 5) def get_helpful_votes_async(request, document_slug): document = get_visible_document_or_404( request.user, locale=request.LANGUAGE_CODE, slug=document_slug ) + default_start = (timezone.now() - timedelta(days=HELPFUL_VOTES_WINDOW_DAYS)).date() + try: + start = datetime.fromisoformat(request.GET.get("start", "")).date() + except ValueError: + start = default_start + datums = [] flag_data = [] rev_data = [] revisions = set() created_list = [] - timestamps_with_data = set() results = ( - HelpfulVote.objects.filter(revision__document=document) + HelpfulVote.objects.filter(revision__document=document, created__gte=start) .values(date_created=TruncDate("created")) .annotate( revisions=ArrayAgg("revision_id"), @@ -1456,54 +1465,32 @@ def get_helpful_votes_async(request, document_slug): for res in results: revisions.update(res["revisions"]) created_list.append(res["date_created"]) - timestamp = (time.mktime(res["date_created"].timetuple()) // 86400) * 86400 - datums.append( { "yes": res["count_helpful"], "no": res["count_unhelpful"], - "date": timestamp, + "date": res["date_created"].isoformat(), } ) - timestamps_with_data.add(timestamp) if not created_list: send = {"datums": [], "annotations": []} return HttpResponse(json.dumps(send), content_type="application/json") - # The "created_list" is a list of date objects, while "min_created" and - # "max_created" are datetime objects that span the period from the beginning - # of the first day to the end of the last day that the document was voted on. min_created = datetime.combine(min(created_list), datetime_time.min) max_created = datetime.combine(max(created_list), datetime_time.max) - # Zero fill the data. - end = time.mktime(timezone.now().timetuple()) - while timestamp <= end: - if timestamp not in timestamps_with_data: - datums.append( - { - "yes": 0, - "no": 0, - "date": timestamp, - } - ) - timestamps_with_data.add(timestamp) - timestamp += 24 * 60 * 60 - for flag in ImportantDate.objects.filter(date__gte=min_created, date__lte=max_created): - flag_data.append({"x": int(time.mktime(flag.date.timetuple())), "text": _(flag.text)}) + flag_data.append({"x": flag.date.isoformat(), "text": _(flag.text)}) for rev in Revision.objects.filter( pk__in=revisions, created__range=(min_created, max_created) ): rdate = rev.reviewed or rev.created rev_data.append( - {"x": int(time.mktime(rdate.timetuple())), "text": str(_("Revision %s")) % rev.created} + {"x": rdate.date().isoformat(), "text": str(_("Revision %s")) % rev.created} ) - # Rickshaw wants data like - # [{'name': 'series1', 'data': [{'x': 1362774285, 'y': 100}, ...]},] send = {"datums": datums, "annotations": []} if flag_data: diff --git a/package-lock.json b/package-lock.json index 5824c8a7730..6359032f057 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "@urql/svelte": "^4.0.0", "chart.js": "^4.5.1", "chartjs-adapter-date-fns": "^3.0.0", - "chartjs-chart-matrix": "^3.0.4", "codemirror": "^5.65.21", "date-fns": "^4.2.1", "graphql": "^16.13.1", @@ -4842,15 +4841,6 @@ "date-fns": ">=2.0.0" } }, - "node_modules/chartjs-chart-matrix": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/chartjs-chart-matrix/-/chartjs-chart-matrix-3.0.4.tgz", - "integrity": "sha512-thkswkjZEtmZph+JUU65GjSxfAIKkLedVAhKz6umIs8zO+y+gHIuzovEtS1FqRXzubMXCX2RcglbQjHsL8g0Xw==", - "license": "MIT", - "peerDependencies": { - "chart.js": ">=3.0.0" - } - }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", diff --git a/package.json b/package.json index 52583321c84..a4b93333459 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,6 @@ "@urql/svelte": "^4.0.0", "chart.js": "^4.5.1", "chartjs-adapter-date-fns": "^3.0.0", - "chartjs-chart-matrix": "^3.0.4", "codemirror": "^5.65.21", "date-fns": "^4.2.1", "graphql": "^16.13.1",