| /** |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| /* eslint no-use-before-define: ["error", { "functions": false }] */ |
| /* eslint-disable no-restricted-syntax */ |
| /* eslint-disable react/sort-prop-types */ |
| import d3 from 'd3'; |
| import PropTypes from 'prop-types'; |
| import nv from 'nvd3'; |
| import { getTimeFormatter, getNumberFormatter, CategoricalColorNamespace } from '@superset-ui/core'; |
| import './Rose.css'; |
| |
| const propTypes = { |
| // Data is an object hashed by numeric value, perhaps timestamp |
| data: PropTypes.objectOf( |
| PropTypes.arrayOf( |
| PropTypes.shape({ |
| key: PropTypes.arrayOf(PropTypes.string), |
| name: PropTypes.arrayOf(PropTypes.string), |
| time: PropTypes.number, |
| value: PropTypes.number, |
| }), |
| ), |
| ), |
| width: PropTypes.number, |
| height: PropTypes.number, |
| dateTimeFormat: PropTypes.string, |
| numberFormat: PropTypes.string, |
| useRichTooltip: PropTypes.bool, |
| useAreaProportions: PropTypes.bool, |
| }; |
| |
| 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 Rose(element, props) { |
| const { |
| data, |
| width, |
| height, |
| colorScheme, |
| dateTimeFormat, |
| numberFormat, |
| useRichTooltip, |
| useAreaProportions, |
| } = props; |
| |
| const div = d3.select(element); |
| div.classed('superset-legacy-chart-rose', true); |
| |
| 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 = getNumberFormatter(numberFormat); |
| const timeFormat = getTimeFormatter(dateTimeFormat); |
| const colorFn = CategoricalColorNamespace.getScale(colorScheme); |
| |
| 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 svg = div.append('svg').attr('width', width).attr('height', 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 = useRichTooltip |
| ? adatum[times[timeIndex]] |
| .filter(v => !state.disabled[v.id % numGroups]) |
| .map(v => ({ |
| key: v.name, |
| value: v.value, |
| color: colorFn(v.name), |
| highlight: v.id === d.arcId, |
| })) |
| : [{ key: d.name, value: d.val, color: colorFn(d.name) }]; |
| |
| return { |
| key: 'Date', |
| value: d.time, |
| series, |
| }; |
| } |
| |
| legend.width(width).color(d => colorFn(d.key)); |
| legendWrap.datum(legendData(datum)).call(legend); |
| |
| tooltip.headerFormatter(timeFormat).valueFormatter(format); |
| |
| // Compute max radius, which the largest value will occupy |
| const roseHeight = height - legend.height(); |
| const margin = { top: legend.height() }; |
| const edgeMargin = 35; // space between outermost radius and slice edge |
| const maxRadius = Math.min(width, roseHeight) / 2 - edgeMargin; |
| const labelThreshold = 0.05; |
| const gro = 8; // mouseover radius growth in pixels |
| const mini = 0.075; |
| |
| const centerTranslate = `translate(${width / 2},${roseHeight / 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 += 1; |
| } |
| |
| // Compute angle occupied by each time grain |
| const dtheta = (Math.PI * 2) / numGrains; |
| const angles = []; |
| for (let i = 0; i <= numGrains; i += 1) { |
| angles.push(dtheta * i - Math.PI / 2); |
| } |
| |
| // Compute proportion |
| const P = maxRadius / maxSum; |
| const Q = P * maxRadius; |
| const computeOuterRadius = (value, innerRadius) => |
| useAreaProportions |
| ? 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 += 1) { |
| 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, time } = v; |
| 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 += 1; |
| innerRadius = outerRadius; |
| } |
| const labelArc = { ...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 doTween(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 => colorFn(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', newState => { |
| if (state.disabled !== newState.disabled) { |
| state.disabled = newState.disabled; |
| updateActive(); |
| } |
| }); |
| } |
| |
| Rose.displayName = 'Rose'; |
| Rose.propTypes = propTypes; |
| |
| export default Rose; |