You are viewing a plain text version of this content. The canonical link for it is here.
Posted to dev@superset.apache.org by GitBox <gi...@apache.org> on 2017/10/31 06:39:30 UTC

[GitHub] Mogball closed pull request #3676: [New Viz] Nightingale Rose Chart

Mogball closed pull request #3676: [New Viz] Nightingale Rose Chart
URL: https://github.com/apache/incubator-superset/pull/3676
 
 
   

This is a PR merged from a forked repository.
As GitHub hides the original diff on merge, it is displayed below for
the sake of provenance:

As this is a foreign pull request (from a fork), the diff is supplied
below (as it won't show otherwise due to GitHub magic):

diff --git a/superset/assets/images/viz_thumbnails/rose.png b/superset/assets/images/viz_thumbnails/rose.png
new file mode 100644
index 0000000000..763fa2b120
Binary files /dev/null and b/superset/assets/images/viz_thumbnails/rose.png differ
diff --git a/superset/assets/javascripts/explore/stores/controls.jsx b/superset/assets/javascripts/explore/stores/controls.jsx
index da8f22dc6f..c3ae33f2fa 100644
--- a/superset/assets/javascripts/explore/stores/controls.jsx
+++ b/superset/assets/javascripts/explore/stores/controls.jsx
@@ -1477,6 +1477,17 @@ export const controls = {
     controlName: 'TimeSeriesColumnControl',
   },
 
+  rose_area_proportion: {
+    type: 'CheckboxControl',
+    label: t('Use Area Proportions'),
+    description: t(
+      'Check if the Rose Chart should use segment area instead of ' +
+      'segment radius for proportioning',
+    ),
+    default: false,
+    renderTrigger: true,
+  },
+
   time_series_option: {
     type: 'SelectControl',
     label: t('Options'),
diff --git a/superset/assets/javascripts/explore/stores/visTypes.js b/superset/assets/javascripts/explore/stores/visTypes.js
index 0975555016..90a47fd468 100644
--- a/superset/assets/javascripts/explore/stores/visTypes.js
+++ b/superset/assets/javascripts/explore/stores/visTypes.js
@@ -1156,6 +1156,25 @@ export const visTypes = {
     ],
   },
 
+  rose: {
+    label: t('Time Series - Nightingale Rose Chart'),
+    showOnExplore: true,
+    requiresTime: true,
+    controlPanelSections: [
+      sections.NVD3TimeSeries[0],
+      {
+        label: t('Chart Options'),
+        expanded: false,
+        controlSetRows: [
+          ['color_scheme'],
+          ['number_format', 'date_time_format'],
+          ['rich_tooltip', 'rose_area_proportion'],
+        ],
+      },
+      sections.NVD3TimeSeries[1],
+    ],
+  },
+
   partition: {
     label: 'Partition Diagram',
     showOnExplore: true,
diff --git a/superset/assets/visualizations/main.js b/superset/assets/visualizations/main.js
index 78e81ab6d7..e0595e5dec 100644
--- a/superset/assets/visualizations/main.js
+++ b/superset/assets/visualizations/main.js
@@ -35,6 +35,7 @@ const vizMap = {
   dual_line: require('./nvd3_vis.js'),
   event_flow: require('./EventFlow.jsx'),
   paired_ttest: require('./paired_ttest.jsx'),
+  rose: require('./rose.jsx'),
   partition: require('./partition.js'),
 };
 export default vizMap;
diff --git a/superset/assets/visualizations/rose.css b/superset/assets/visualizations/rose.css
new file mode 100644
index 0000000000..809df93349
--- /dev/null
+++ b/superset/assets/visualizations/rose.css
@@ -0,0 +1,24 @@
+.rose path {
+  transition: fill-opacity 180ms linear;
+  stroke: #fff;
+  stroke-width: 1px;
+  stroke-opacity: 1;
+  fill-opacity: 0.75;
+}
+
+.rose text {
+  font: 400 12px Arial, sans-serif;
+  pointer-events: none;
+}
+
+.rose .clickable path {
+  cursor: pointer;
+}
+
+.rose .hover path {
+  fill-opacity: 1;
+}
+
+.nv-legend .nv-series {
+  cursor: pointer;
+}
diff --git a/superset/assets/visualizations/rose.jsx b/superset/assets/visualizations/rose.jsx
new file mode 100644
index 0000000000..336adc7b11
--- /dev/null
+++ b/superset/assets/visualizations/rose.jsx
@@ -0,0 +1,540 @@
+/* eslint no-use-before-define: ["error", { "functions": false }] */
+import d3 from 'd3';
+import nv from 'nvd3';
+
+import { d3TimeFormatPreset } from '../javascripts/modules/utils';
+import { getColorFromScheme } from '../javascripts/modules/colors';
+
+import './rose.css';
+
+function copyArc(d) {
+  return {
+    startAngle: d.startAngle,
+    endAngle: d.endAngle,
+    innerRadius: d.innerRadius,
+    outerRadius: d.outerRadius,
+  };
+}
+
+function sortValues(a, b) {
+  if (a.value === b.value) {
+    return a.name > b.name ? 1 : -1;
+  }
+  return b.value - a.value;
+}
+
+function roseVis(slice, payload) {
+  const data = payload.data;
+  const fd = slice.formData;
+  const div = d3.select(slice.selector);
+
+  const datum = data;
+  const times = Object.keys(datum)
+    .map(t => parseInt(t, 10))
+    .sort((a, b) => a - b);
+  const numGrains = times.length;
+  const numGroups = datum[times[0]].length;
+  const format = d3.format(fd.number_format);
+  const timeFormat = d3TimeFormatPreset(fd.date_time_format);
+
+  d3.select('.nvtooltip').remove();
+  div.selectAll('*').remove();
+
+  const arc = d3.svg.arc();
+  const legend = nv.models.legend();
+  const tooltip = nv.models.tooltip();
+  const state = { disabled: datum[times[0]].map(() => false) };
+  const color = name => getColorFromScheme(name, fd.color_scheme);
+
+  const svg = div
+    .append('svg')
+    .attr('width', slice.width())
+    .attr('height', slice.height());
+
+  const g = svg
+    .append('g')
+    .attr('class', 'rose')
+    .append('g');
+
+  const legendWrap = g
+    .append('g')
+    .attr('class', 'legendWrap');
+
+  function legendData(adatum) {
+    return adatum[times[0]].map((v, i) => ({
+      disabled: state.disabled[i],
+      key: v.name,
+    }));
+  }
+
+  function tooltipData(d, i, adatum) {
+    const timeIndex = Math.floor(d.arcId / numGroups);
+    const series = fd.rich_tooltip ?
+      adatum[times[timeIndex]]
+        .filter(v => !state.disabled[v.id % numGroups])
+        .map(v => ({
+          key: v.name,
+          value: v.value,
+          color: color(v.name),
+          highlight: v.id === d.arcId,
+        })) : [{ key: d.name, value: d.val, color: color(d.name) }];
+    return {
+      key: 'Date',
+      value: d.time,
+      series,
+    };
+  }
+
+  legend
+    .width(slice.width())
+    .color(d => getColorFromScheme(d.key, fd.color_scheme));
+  legendWrap
+    .datum(legendData(datum))
+    .call(legend);
+
+  tooltip
+    .headerFormatter(timeFormat)
+    .valueFormatter(format);
+
+  // Compute max radius, which the largest value will occupy
+  const width = slice.width();
+  const height = slice.height() - legend.height();
+  const margin = { top: legend.height() };
+  const edgeMargin = 35; // space between outermost radius and slice edge
+  const maxRadius = Math.min(width, height) / 2 - edgeMargin;
+  const labelThreshold = 0.05;
+  const gro = 8; // mouseover radius growth in pixels
+  const mini = 0.075;
+
+  const centerTranslate = `translate(${width / 2},${height / 2 + margin.top})`;
+  const roseWrap = g
+    .append('g')
+    .attr('transform', centerTranslate)
+    .attr('class', 'roseWrap');
+
+  const labelsWrap = g
+    .append('g')
+    .attr('transform', centerTranslate)
+    .attr('class', 'labelsWrap');
+
+  const groupLabelsWrap = g
+    .append('g')
+    .attr('transform', centerTranslate)
+    .attr('class', 'groupLabelsWrap');
+
+  // Compute inner and outer angles for each data point
+  function computeArcStates(adatum) {
+    // Find the max sum of values across all time
+    let maxSum = 0;
+    let grain = 0;
+    const sums = [];
+    for (const t of times) {
+      const sum = datum[t].reduce((a, v, i) =>
+        a + (state.disabled[i] ? 0 : v.value), 0,
+      );
+      maxSum = sum > maxSum ? sum : maxSum;
+      sums[grain] = sum;
+      grain++;
+    }
+
+    // Compute angle occupied by each time grain
+    const dtheta = Math.PI * 2 / numGrains;
+    const angles = [];
+    for (let i = 0; i <= numGrains; i++) {
+      angles.push(dtheta * i - Math.PI / 2);
+    }
+
+    // Compute proportion
+    const P = maxRadius / maxSum;
+    const Q = P * maxRadius;
+    const computeOuterRadius = (value, innerRadius) => fd.rose_area_proportion ?
+      Math.sqrt(Q * value + innerRadius * innerRadius) :
+      P * value + innerRadius;
+
+    const arcSt = {
+      data: [],
+      extend: {},
+      push: {},
+      pieStart: {},
+      pie: {},
+      pieOver: {},
+      mini: {},
+      labels: [],
+      groupLabels: [],
+    };
+    let arcId = 0;
+    for (let i = 0; i < numGrains; i++) {
+      const t = times[i];
+      const startAngle = angles[i];
+      const endAngle = angles[i + 1];
+      const G = 2 * Math.PI / sums[i];
+      let innerRadius = 0;
+      let outerRadius;
+      let pieStartAngle = 0;
+      let pieEndAngle;
+      for (const v of adatum[t]) {
+        const val = state.disabled[arcId % numGroups] ? 0 : v.value;
+        const name = v.name;
+        const time = v.time;
+        v.id = arcId;
+        outerRadius = computeOuterRadius(val, innerRadius);
+        arcSt.data.push({ startAngle, endAngle, innerRadius, outerRadius, name, arcId, val, time });
+        arcSt.extend[arcId] = {
+          startAngle, endAngle, innerRadius, name, outerRadius: outerRadius + gro,
+        };
+        arcSt.push[arcId] = {
+          startAngle, endAngle, innerRadius: innerRadius + gro, outerRadius: outerRadius + gro,
+        };
+        arcSt.pieStart[arcId] = {
+          startAngle, endAngle, innerRadius: mini * maxRadius, outerRadius: maxRadius,
+        };
+        arcSt.mini[arcId] = {
+          startAngle, endAngle, innerRadius: innerRadius * mini, outerRadius: outerRadius * mini,
+        };
+        arcId++;
+        innerRadius = outerRadius;
+      }
+      const labelArc = Object.assign({}, arcSt.data[i * numGroups]);
+      labelArc.outerRadius = maxRadius + 20;
+      labelArc.innerRadius = maxRadius + 15;
+      arcSt.labels.push(labelArc);
+      for (const v of adatum[t].concat().sort(sortValues)) {
+        const val = state.disabled[v.id % numGroups] ? 0 : v.value;
+        pieEndAngle = G * val + pieStartAngle;
+        arcSt.pie[v.id] = {
+          startAngle: pieStartAngle,
+          endAngle: pieEndAngle,
+          innerRadius: maxRadius * mini,
+          outerRadius: maxRadius,
+          percent: v.value / sums[i],
+        };
+        arcSt.pieOver[v.id] = {
+          startAngle: pieStartAngle,
+          endAngle: pieEndAngle,
+          innerRadius: maxRadius * mini,
+          outerRadius: maxRadius + gro,
+        };
+        pieStartAngle = pieEndAngle;
+      }
+    }
+    arcSt.groupLabels = arcSt.data.slice(0, numGroups);
+    return arcSt;
+  }
+
+  let arcSt = computeArcStates(datum);
+
+  function tween(target, resFunc) {
+    return function (d) {
+      const interpolate = d3.interpolate(copyArc(d), copyArc(target));
+      return t => resFunc(Object.assign(d, interpolate(t)));
+    };
+  }
+
+  function arcTween(target) {
+    return tween(target, d => arc(d));
+  }
+
+  function translateTween(target) {
+    return tween(target, d => `translate(${arc.centroid(d)})`);
+  }
+
+  // Grab the ID range of segments stand between
+  // this segment and the edge of the circle
+  const segmentsToEdgeCache = {};
+  function getSegmentsToEdge(arcId) {
+    if (segmentsToEdgeCache[arcId]) {
+      return segmentsToEdgeCache[arcId];
+    }
+    const timeIndex = Math.floor(arcId / numGroups);
+    segmentsToEdgeCache[arcId] = [arcId + 1, numGroups * (timeIndex + 1) - 1];
+    return segmentsToEdgeCache[arcId];
+  }
+
+  // Get the IDs of all segments in a timeIndex
+  const segmentsInTimeCache = {};
+  function getSegmentsInTime(arcId) {
+    if (segmentsInTimeCache[arcId]) {
+      return segmentsInTimeCache[arcId];
+    }
+    const timeIndex = Math.floor(arcId / numGroups);
+    segmentsInTimeCache[arcId] = [timeIndex * numGroups, (timeIndex + 1) * numGroups - 1];
+    return segmentsInTimeCache[arcId];
+  }
+
+  let clickId = -1;
+  let inTransition = false;
+  const ae = roseWrap
+    .selectAll('g')
+    .data(JSON.parse(JSON.stringify(arcSt.data))) // deep copy data state
+    .enter()
+    .append('g')
+    .attr('class', 'segment')
+    .classed('clickable', true)
+    .on('mouseover', mouseover)
+    .on('mouseout', mouseout)
+    .on('mousemove', mousemove)
+    .on('click', click);
+
+  const labels = labelsWrap
+    .selectAll('g')
+    .data(JSON.parse(JSON.stringify(arcSt.labels)))
+    .enter()
+    .append('g')
+    .attr('class', 'roseLabel')
+    .attr('transform', d => `translate(${arc.centroid(d)})`);
+
+  labels
+    .append('text')
+    .style('text-anchor', 'middle')
+    .style('fill', '#000')
+    .text(d => timeFormat(d.time));
+
+  const groupLabels = groupLabelsWrap
+    .selectAll('g')
+    .data(JSON.parse(JSON.stringify(arcSt.groupLabels)))
+    .enter()
+    .append('g');
+
+  groupLabels
+    .style('opacity', 0)
+    .attr('class', 'roseGroupLabels')
+    .append('text')
+    .style('text-anchor', 'middle')
+    .style('fill', '#000')
+    .text(d => d.name);
+
+  const arcs = ae
+    .append('path')
+    .attr('class', 'arc')
+    .attr('fill', d => color(d.name))
+    .attr('d', arc);
+
+  function mousemove() {
+    tooltip();
+  }
+
+  function mouseover(b, i) {
+    tooltip.data(tooltipData(b, i, datum)).hidden(false);
+    const $this = d3.select(this);
+    $this.classed('hover', true);
+    if (clickId < 0 && !inTransition) {
+      $this
+        .select('path')
+        .interrupt()
+        .transition()
+        .duration(180)
+        .attrTween('d', arcTween(arcSt.extend[i]));
+      const edge = getSegmentsToEdge(i);
+      arcs
+        .filter(d => edge[0] <= d.arcId && d.arcId <= edge[1])
+        .interrupt()
+        .transition()
+        .duration(180)
+        .attrTween('d', d => arcTween(arcSt.push[d.arcId])(d));
+    } else if (!inTransition) {
+      const segments = getSegmentsInTime(clickId);
+      if (segments[0] <= b.arcId && b.arcId <= segments[1]) {
+        $this
+          .select('path')
+          .interrupt()
+          .transition()
+          .duration(180)
+          .attrTween('d', arcTween(arcSt.pieOver[i]));
+      }
+    }
+  }
+
+  function mouseout(b, i) {
+    tooltip.hidden(true);
+    const $this = d3.select(this);
+    $this.classed('hover', false);
+    if (clickId < 0 && !inTransition) {
+      $this
+        .select('path')
+        .interrupt()
+        .transition()
+        .duration(180)
+        .attrTween('d', arcTween(arcSt.data[i]));
+      const edge = getSegmentsToEdge(i);
+      arcs
+        .filter(d => edge[0] <= d.arcId && d.arcId <= edge[1])
+        .interrupt()
+        .transition()
+        .duration(180)
+        .attrTween('d', d => arcTween(arcSt.data[d.arcId])(d));
+    } else if (!inTransition) {
+      const segments = getSegmentsInTime(clickId);
+      if (segments[0] <= b.arcId && b.arcId <= segments[1]) {
+        $this
+          .select('path')
+          .interrupt()
+          .transition()
+          .duration(180)
+          .attrTween('d', arcTween(arcSt.pie[i]));
+      }
+    }
+  }
+
+  function click(b, i) {
+    if (inTransition) {
+      return;
+    }
+    const delay = d3.event.altKey ? 3750 : 375;
+    const segments = getSegmentsInTime(i);
+    if (clickId < 0) {
+      inTransition = true;
+      clickId = i;
+      labels
+        .interrupt()
+        .transition()
+        .duration(delay)
+        .attrTween('transform', d => translateTween({
+          outerRadius: 0,
+          innerRadius: 0,
+          startAngle: d.startAngle,
+          endAngle: d.endAngle,
+        })(d))
+        .style('opacity', 0);
+      groupLabels
+        .attr('transform', `translate(${arc.centroid({
+          outerRadius: maxRadius + 20,
+          innerRadius: maxRadius + 15,
+          startAngle: arcSt.data[i].startAngle,
+          endAngle: arcSt.data[i].endAngle,
+        })})`)
+        .interrupt()
+        .transition()
+        .delay(delay)
+        .duration(delay)
+        .attrTween('transform', d => translateTween({
+          outerRadius: maxRadius + 20,
+          innerRadius: maxRadius + 15,
+          startAngle: arcSt.pie[segments[0] + d.arcId].startAngle,
+          endAngle: arcSt.pie[segments[0] + d.arcId].endAngle,
+        })(d))
+        .style('opacity', d =>
+          state.disabled[d.arcId] || arcSt.pie[segments[0] + d.arcId].percent < labelThreshold ?
+          0 : 1);
+      ae.classed('clickable', d => segments[0] > d.arcId || d.arcId > segments[1]);
+      arcs
+        .filter(d => segments[0] <= d.arcId && d.arcId <= segments[1])
+        .interrupt()
+        .transition()
+        .duration(delay)
+        .attrTween('d', d => arcTween(arcSt.pieStart[d.arcId])(d))
+        .transition()
+        .duration(delay)
+        .attrTween('d', d => arcTween(arcSt.pie[d.arcId])(d))
+        .each('end', () => { inTransition = false; });
+      arcs
+        .filter(d => segments[0] > d.arcId || d.arcId > segments[1])
+        .interrupt()
+        .transition()
+        .duration(delay)
+        .attrTween('d', d => arcTween(arcSt.mini[d.arcId])(d));
+    } else if (clickId < segments[0] || segments[1] < clickId) {
+      inTransition = true;
+      const clickSegments = getSegmentsInTime(clickId);
+      labels
+        .interrupt()
+        .transition()
+        .delay(delay)
+        .duration(delay)
+        .attrTween('transform', d => translateTween(arcSt.labels[d.arcId / numGroups])(d))
+        .style('opacity', 1);
+      groupLabels
+        .interrupt()
+        .transition()
+        .duration(delay)
+        .attrTween('transform', translateTween({
+          outerRadius: maxRadius + 20,
+          innerRadius: maxRadius + 15,
+          startAngle: arcSt.data[clickId].startAngle,
+          endAngle: arcSt.data[clickId].endAngle,
+        }))
+        .style('opacity', 0);
+      ae.classed('clickable', true);
+      arcs
+        .filter(d => clickSegments[0] <= d.arcId && d.arcId <= clickSegments[1])
+        .interrupt()
+        .transition()
+        .duration(delay)
+        .attrTween('d', d => arcTween(arcSt.pieStart[d.arcId])(d))
+        .transition()
+        .duration(delay)
+        .attrTween('d', d => arcTween(arcSt.data[d.arcId])(d))
+        .each('end', () => { clickId = -1; inTransition = false; });
+      arcs
+        .filter(d => clickSegments[0] > d.arcId || d.arcId > clickSegments[1])
+        .interrupt()
+        .transition()
+        .delay(delay)
+        .duration(delay)
+        .attrTween('d', d => arcTween(arcSt.data[d.arcId])(d));
+    }
+  }
+
+  function updateActive() {
+    const delay = d3.event.altKey ? 3000 : 300;
+    legendWrap
+      .datum(legendData(datum))
+      .call(legend);
+    const nArcSt = computeArcStates(datum);
+    inTransition = true;
+    if (clickId < 0) {
+      arcs
+        .style('opacity', 1)
+        .interrupt()
+        .transition()
+        .duration(delay)
+        .attrTween('d', d => arcTween(nArcSt.data[d.arcId])(d))
+        .each('end', () => {
+          inTransition = false;
+          arcSt = nArcSt;
+        })
+        .transition()
+        .duration(0)
+        .style('opacity', d => state.disabled[d.arcId % numGroups] ? 0 : 1);
+    } else {
+      const segments = getSegmentsInTime(clickId);
+      arcs
+        .style('opacity', 1)
+        .interrupt()
+        .transition()
+        .duration(delay)
+        .attrTween('d', d => segments[0] <= d.arcId && d.arcId <= segments[1] ?
+          arcTween(nArcSt.pie[d.arcId])(d) :
+          arcTween(nArcSt.mini[d.arcId])(d),
+        )
+        .each('end', () => {
+          inTransition = false;
+          arcSt = nArcSt;
+        })
+        .transition()
+        .duration(0)
+        .style('opacity', d => state.disabled[d.arcId % numGroups] ? 0 : 1);
+      groupLabels
+        .interrupt()
+        .transition()
+        .duration(delay)
+        .attrTween('transform', d => translateTween({
+          outerRadius: maxRadius + 20,
+          innerRadius: maxRadius + 15,
+          startAngle: nArcSt.pie[segments[0] + d.arcId].startAngle,
+          endAngle: nArcSt.pie[segments[0] + d.arcId].endAngle,
+        })(d))
+        .style('opacity', d =>
+          state.disabled[d.arcId] || arcSt.pie[segments[0] + d.arcId].percent < labelThreshold ?
+          0 : 1);
+    }
+  }
+
+  legend.dispatch.on('stateChange', function (newState) {
+    if (state.disabled !== newState.disabled) {
+      state.disabled = newState.disabled;
+      updateActive();
+    }
+  });
+}
+
+module.exports = roseVis;
diff --git a/superset/viz.py b/superset/viz.py
index 1d701b0a65..fdc2796e7f 100644
--- a/superset/viz.py
+++ b/superset/viz.py
@@ -15,6 +15,8 @@
 import traceback
 import uuid
 import zlib
+import time
+import math
 
 from collections import defaultdict
 from itertools import product
@@ -1791,6 +1793,32 @@ def get_data(self, df):
         return data
 
 
+class RoseViz(NVD3TimeSeriesViz):
+
+    viz_type = 'rose'
+    verbose_name = _("Time Series - Nightingale Rose Chart")
+    sort_series = False
+    is_timeseries = True
+
+    def get_data(self, df):
+        data = super(RoseViz, self).get_data(df)
+        result = {}
+        for datum in data:
+            key = datum['key']
+            for val in datum['values']:
+                timestamp = val['x'].value
+                if not result.get(timestamp):
+                    result[timestamp] = []
+                value = 0 if math.isnan(val['y']) else val['y']
+                result[timestamp].append({
+                    'key': key,
+                    'value': value,
+                    'name': ', '.join(key) if isinstance(key, list) else key,
+                    'time': val['x'],
+                })
+        return result
+
+
 class PartitionViz(NVD3TimeSeriesViz):
 
     """
diff --git a/tests/viz_tests.py b/tests/viz_tests.py
index fec424a25a..d16ca7a2bc 100644
--- a/tests/viz_tests.py
+++ b/tests/viz_tests.py
@@ -360,3 +360,43 @@ def test_get_data_calls_correct_method(self):
         test_viz.get_data(df)
         self.assertEqual('agg_sum', test_viz.levels_for.mock_calls[3][1][0])
         self.assertEqual(7, len(test_viz.nest_values.mock_calls))
+
+
+class RoseVisTestCase(unittest.TestCase):
+
+    def test_rose_vis_get_data(self):
+        raw = {}
+        t1 = pd.Timestamp('2000')
+        t2 = pd.Timestamp('2002')
+        t3 = pd.Timestamp('2004')
+        raw[DTTM_ALIAS] = [t1, t2, t3, t1, t2, t3, t1, t2, t3]
+        raw['groupA'] = ['a1', 'a1', 'a1', 'b1', 'b1', 'b1', 'c1', 'c1', 'c1']
+        raw['groupB'] = ['a2', 'a2', 'a2', 'b2', 'b2', 'b2', 'c2', 'c2', 'c2']
+        raw['groupC'] = ['a3', 'a3', 'a3', 'b3', 'b3', 'b3', 'c3', 'c3', 'c3']
+        raw['metric1'] = [1, 2, 3, 4, 5, 6, 7, 8, 9]
+        df = pd.DataFrame(raw)
+        fd = {
+            'metrics': ['metric1'],
+            'groupby': ['groupA'],
+        }
+        test_viz = viz.RoseViz(Mock(), fd)
+        test_viz.metrics = fd['metrics']
+        res = test_viz.get_data(df)
+        expected = {
+            946684800000000000: [
+                {'time': t1, 'value': 1, 'key': ('a1',), 'name': ('a1',)},
+                {'time': t1, 'value': 4, 'key': ('b1',), 'name': ('b1',)},
+                {'time': t1, 'value': 7, 'key': ('c1',), 'name': ('c1',)}
+            ],
+            1009843200000000000: [
+                {'time': t2, 'value': 2, 'key': ('a1',), 'name': ('a1',)},
+                {'time': t2, 'value': 5, 'key': ('b1',), 'name': ('b1',)},
+                {'time': t2, 'value': 8, 'key': ('c1',), 'name': ('c1',)}
+            ],
+            1072915200000000000: [
+                {'time': t3, 'value': 3, 'key': ('a1',), 'name': ('a1',)},
+                {'time': t3, 'value': 6, 'key': ('b1',), 'name': ('b1',)},
+                {'time': t3, 'value': 9, 'key': ('c1',), 'name': ('c1',)}
+            ],
+        }
+        self.assertEqual(expected, res)


 

----------------------------------------------------------------
This is an automated message from the Apache Git Service.
To respond to the message, please log on GitHub and use the
URL above to go to the specific comment.
 
For queries about this service, please contact Infrastructure at:
users@infra.apache.org


With regards,
Apache Git Services