import { select as d3Select, selectAll as d3SelectAll } from 'd3-selection';
import { arc as d3Arc } from 'd3-shape';
import { max as d3Max, range as d3Range, extent as d3Extent, min as d3Min } from 'd3-array';
import color from 'color';
import { scaleQuantize as d3ScaleQuantize } from 'd3-scale';
import OECDChart from './OECDChart';
function rad2deg(rad) {
return rad / Math.PI * 180;
}
function getColorRange(base, amount, lightness) {
return d3Range(0, amount, 1).map(step => {
return color(base)
.mix(color('#fff'), (lightness / 100) * step / amount);
});
}
function getExtent(data, rows) {
const maxValues = data.map(d => d3Max(rows, row => d[row]));
const minValues = data.map(d => d3Min(rows, row => d[row]));
const min = d3Min(minValues);
const max = d3Max(maxValues);
return [min, max];
}
/**
* A RadialBarChart Component.
*
* @example <caption>browser usage:</caption>
var RadialBarChartExample = new OECDCharts.RadialBarChart({
container: '#RadialBarChartExample',
title: 'Radial Bar Chart',
renderInfoButton: true,
rows: ['political', 'societal', 'economic', 'environmental', 'security'],
rowLabels: ['Political Label', 'Societal Label', 'Economic Label', 'Environmental Label', 'SecurityLabel'],
rowColors: ['#492242', '#026c6d', '#9d461d', '#4b6d27', '#eaae15'],
columns: 'country',
data: [
{"country":"Yemen","political":0.08377711086932882,"societal":0.42466597374735526,"economic":0.8177145671512998,"environmental":0.30107512488904686,"security":0.7208149715058103},
{"country":"Zimbabwe","political":0.19584282893442895,"societal":0.5565456581754631,"economic":0.5097090125574546,"environmental":0.13615832272842088,"security":0.5664120991477379},
//...
]
});
* @example <caption>ES6 modules usage:</caption>
* import { RadialBarChart } from 'oecd-simple-charts';
* import 'oecd-simple-charts/build/oecd-simple-charts.css'
*
* const radialBarChart = new RadialBarChart({ chartOptions });
* @constructor
* @param {object} options - The options object for the pearl chart.
* @param {string} options.container - The DOM element to use as container
* @param {string} options.title - The title to display
* @param {bool} [options.renderInfoButton = false] - The info-Icon for the tooltip, renders after the title
* @param {array} options.data - The data as array. i.e.:
* ```
* [
* {"country":"Yemen","political":0.08377711086932882,"societal":0.42466597374735526,"economic":0.8177145671512998,"environmental":0.30107512488904686,"security":0.7208149715058103},
* {"country":"Zimbabwe","political":0.19584282893442895,"societal":0.5565456581754631,"economic":0.5097090125574546,"environmental":0.13615832272842088,"security":0.5664120991477379},
* //...
* ]
* ```
*/
class RadialBarChart extends OECDChart {
constructor(options = {}) {
super();
this.defaultOptions = {
container: null,
innerRadius: 50,
innerMargin: 100,
labelOffset: 10,
data: [],
rows: [],
rowLabels: [],
columns: '',
sortBy: false,
colorSteps: 5,
lightnessFactor: 80,
strokeColor: '#fff',
strokeWidth: 0.5,
hoverStrokeColor: '#111',
hoverStrokeWidth: 2,
hoverOpacity: 0.5,
legendLabelTop: 'Fragility',
legendLabelLeft: 'Severe',
legendLabelRight: 'Minor'
};
this.activeRow = null;
this.init(options);
}
render() {
const {
container,
innerRadius,
innerMargin,
data,
labelOffset,
rows,
rowColors,
rowLabels,
columns,
colorSteps,
lightnessFactor,
strokeColor,
strokeWidth,
sortBy,
} = this.options;
const that = this;
const sortedData = sortBy ? data.sort((a, b) => b[sortBy] - a[sortBy]) : data;
const d3Container = d3Select(container);
const size = d3Container.node().clientWidth;
d3Container.selectAll('.oecd-chart__svg').remove();
const svg = d3Container
.append('svg')
.classed('OECDCharts__RadialBarChart', true)
.classed('oecd-chart__svg', true)
.attr('width', size)
.attr('height', size)
.append('g');
const centeredGroup = svg
.append('g')
.attr('transform', `translate(${size / 2}, ${size / 2})`);
const radius = size / 2;
const innerHeight = radius - innerMargin;
const chartHeight = innerHeight - innerRadius;
const arcWidth = chartHeight / rows.length;
const step = (Math.PI * 1.5) / sortedData.length;
const getStartAngle = (d, i) => i * step;
const getEndAngle = (d, i) => i * step + step;
const getAnimationDelay = (i) => i * (500 / sortedData.length);
const arcGenerator = d3Arc()
.innerRadius((d, i) => (rows.length - i - 1) * arcWidth + innerRadius)
.outerRadius((d, i) => (rows.length - i - 1) * arcWidth + innerRadius + arcWidth)
.startAngle((d, i) => d.startAngle)
.endAngle((d, i) => d.endAngle);
// const extent = getExtent(data, rows);
const colorData = rows.map((row, i) => {
const colors = getColorRange(rowColors[i], colorSteps, lightnessFactor);
const extent = d3Extent(data, d => +d[row]);
return d3ScaleQuantize().domain(extent).range(colors.slice(0).reverse());
});
const arcGroups = centeredGroup
.selectAll('.arc-group')
.data(sortedData)
.enter()
.append('g')
.classed('arc-group', true)
.on('mouseenter', this.handleGroupMouseEnter(this))
.on('mouseleave', this.handleGroupMouseLeave.bind(this));
const arcPaths = arcGroups
.append('g')
.classed('arc-container', true)
.selectAll('.arc')
.data((d, i) => rows.map((row, rowIndex) => {
const value = +d[row];
return {
value,
startAngle: getStartAngle(d, i),
endAngle: getEndAngle(d, i),
color: colorData[rowIndex](value),
index: i,
parentData: d,
rowIndex
}
}))
.enter()
.append('path')
.attr('d', arcGenerator)
.attr('fill', d => d.color)
.attr('stroke', strokeColor)
.attr('stroke-width', strokeWidth)
.on('mouseenter', function(d) {
// this.parentNode.appendChild(this);
// d3Select(this)
// .attr('stroke-width', hoverStrokeWidth)
// .attr('stroke', hoverStrokeColor);
that.event.emit('mouseenter', d.parentData);
})
.on('mouseleave', function(d) {
// d3Select(this)
// .attr('stroke-width', 1)
// .attr('stroke', strokeColor);
that.event.emit('mouseleave', d.parentData);
})
.on('click', function(d) {
// this.parentNode.appendChild(this);
// d3Select(this)
// .attr('stroke-width', hoverStrokeWidth)
// .attr('stroke', hoverStrokeColor);
that.event.emit('click', d.parentData);
});
arcGroups
.append('g')
.attr('transform', (d, i) => {
const angle = i * step + (step / 2) - Math.PI / 2;
const distance = innerHeight + labelOffset;
const x = distance * Math.cos(angle);
const y = distance * Math.sin(angle);
return `translate(${x}, ${y})`;
})
.append('text')
.text((d, i) => d[columns])
.attr('transform', (d, i) => {
const angle = i * step + (step / 2) - Math.PI / 2;
const rotation = angle > Math.PI / 2 ? rad2deg(angle + Math.PI) : rad2deg(angle);
return `rotate(${rotation})`;
})
.attr('dominant-baseline', 'middle')
.filter((d, i) => i * step + (step / 2) - Math.PI / 2 > Math.PI / 2)
.attr('text-anchor', 'end');
// .attr('x', radius - innerMargin + labelOffset)
// .attr('transform-origin', + ' 0')
// .each((d, i) => {
// console.log(getEndAngle(d, i));
// });
// const arcGroupLabelContainers = arcGroups
// .append('g')
// .classed('label-container', true)
// .attr('transform', (d, i) => `rotate(${rad2deg(i * step + (step / 2)) - 90})`)
// arcGroupLabelContainers
// .filter((d, i) => i > data.length / 3 * 2)
// .attr('transform', (d, i) => `scale(-1,1) rotate(${rad2deg(i * step + (step / 2))})`)
// // .attr('transform-origin', radius - innerMargin + labelOffset + ' 0')
// .attr('text-anchor', 'end')
// arcGroupLabelContainers
// .append('text')
// .classed('column-label', true)
// .attr('x', radius - innerMargin + labelOffset)
// .attr('y', 0)
// .attr('dominant-baseline', 'middle')
// .text((d, i) => d[columns])
arcGroups
.attr('opacity', 0)
.transition()
.duration(0)
.delay((d, i) => getAnimationDelay(i))
.attr('opacity', 1);
const legendGroup = svg.append('g')
.classed('legend-group', true)
.attr('transform', `translate(0, ${innerMargin})`);
const legendRows = legendGroup.selectAll('.legend-row')
.data(rowLabels)
.enter()
.append('g')
.attr('transform', (d, i) => `translate(50, ${arcWidth * i})`)
.classed('legend-row', true)
.classed('is-active', (d, i) => i === this.activeRow)
.on('click', (d, i, nodes, ol) => {
this.activeRow = i;
this.options.sortBy = rows[i];
this.event.emit('sort', this.options.sortBy);
this.update(this.options);
});
const svgRowLabels = legendRows.append('text')
// .append('tspan')
.text(d => d)
.classed('row-label', true);
const longestLabel = d3Max(svgRowLabels.nodes(), label => label.getBoundingClientRect().width);
const remainingSpace = radius - longestLabel - 80;
const legendXSpace = ~~longestLabel + 20;
svgRowLabels
.attr('x', ~~longestLabel + 10)
// .attr('y', arcWidth / 2)
.attr('text-anchor', 'end')
.attr('alignment-baseline', 'middle')
.attr('dominant-baseline', 'middle');
const legendColorGroups = legendRows
.append('g')
.classed('legend-color-blocks', true)
.attr('transform', `translate(${legendXSpace}, 0)`);
const colorBlockWidth = remainingSpace / colorSteps;
const blockHeight = Math.min(arcWidth, 30);
const blockOffset = Math.max(0, (arcWidth - blockHeight) / 2);
svgRowLabels
.attr('y', blockOffset + blockHeight / 2);
legendColorGroups
.selectAll('.color-block')
.data((d, i) => colorData[i].range().slice(0).reverse())
.enter()
.append('rect')
.classed('color-block', true)
.attr('x', (d, i) => i * colorBlockWidth)
.attr('y', blockOffset)
.attr('width', colorBlockWidth)
.attr('height', blockHeight)
.attr('fill', (d, i) => d)
legendGroup
// .filter((d,i) => i === 0)
.append('text')
.classed('legend-label', true)
.attr('x', legendXSpace + 50)
.attr('y', blockOffset - 5)
.text(this.options.legendLabelTop)
.attr('dominant-baseline', 'baseline');
const lastGroup = legendColorGroups
.filter((d, i) => i === legendColorGroups.size() - 1)
lastGroup
.append('text')
.classed('legend-label', true)
.attr('y', blockOffset + blockHeight + 5)
.attr('dominant-baseline', 'hanging')
.text(this.options.legendLabelLeft);
lastGroup
.append('text')
.classed('legend-label', true)
.attr('text-anchor', 'end')
.attr('dominant-baseline', 'hanging')
.attr('y', blockOffset + blockHeight + 5)
.attr('x', colorBlockWidth * colorData.length)
.text(this.options.legendLabelRight);
this.arcGroups = arcGroups;
}
update(options = {}) {
this.options = Object.assign({}, this.options, options);
this.render();
}
handleGroupMouseEnter(that) {
return function(d, i) {
this.parentNode.appendChild(this);
that.arcGroups.style('opacity', that.options.hoverOpacity).filter((d, j) => i === j).style('opacity', 1);
that.event.emit('mouseenter.group', d);
}
}
handleGroupMouseLeave(d, i) {
this.arcGroups.style('opacity', 1);
this.event.emit('mouseleave.group', d);
}
}
export default RadialBarChart;