[New Viz] Nightingale Rose Chart (#3676)

* Nightingale Rose Chart

* Review comments
This commit is contained in:
Jeff Niu
2018-02-03 23:18:24 -05:00
committed by Maxime Beauchemin
parent a616bf4082
commit fdd42ef4b6
8 changed files with 663 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

View File

@@ -1746,6 +1746,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'),

View File

@@ -1541,6 +1541,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,

View File

@@ -48,6 +48,7 @@ export const VIZ_TYPES = {
deck_multi: 'deck_multi',
deck_arc: 'deck_arc',
deck_polygon: 'deck_polygon',
rose: 'rose',
};
const vizMap = {
@@ -97,5 +98,6 @@ const vizMap = {
[VIZ_TYPES.deck_arc]: require('./deckgl/layers/arc.jsx').default,
[VIZ_TYPES.deck_polygon]: require('./deckgl/layers/polygon.jsx').default,
[VIZ_TYPES.deck_multi]: require('./deckgl/multi.jsx'),
[VIZ_TYPES.rose]: require('./rose.js'),
};
export default vizMap;

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -15,6 +15,7 @@ import hashlib
import inspect
from itertools import product
import logging
import math
import traceback
import uuid
import zlib
@@ -2184,6 +2185,32 @@ class PairedTTestViz(BaseViz):
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):
"""

View File

@@ -591,3 +591,43 @@ class PartitionVizTestCase(unittest.TestCase):
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)