veritatisquam
Version:
Carbon charting components, lerna monorepo
486 lines (414 loc) • 12.7 kB
text/typescript
// Internal Imports
import { Component } from '../component';
import { DOMUtils } from '../../services';
import { Tools } from '../../tools';
import {
CalloutDirections,
Roles,
Events,
Alignments,
ColorClassNameTypes,
} from '../../interfaces';
import * as Configuration from '../../configuration';
// D3 Imports
import { select } from 'd3-selection';
import { arc, pie } from 'd3-shape';
import { interpolate } from 'd3-interpolate';
// Pie slice tween function
function arcTween(a, arcFunc) {
const i = interpolate(this._current, a);
return (t) => {
this._current = i(t);
return arcFunc(this._current);
};
}
export class Pie extends Component {
type = 'pie';
// We need to store our arcs
// So that addEventListeners()
// Can access them
arc: any;
hoverArc: any;
init() {
const eventsFragment = this.services.events;
// Highlight correct circle on legend item hovers
eventsFragment.addEventListener(
Events.Legend.ITEM_HOVER,
this.handleLegendOnHover
);
// Un-highlight circles on legend item mouseouts
eventsFragment.addEventListener(
Events.Legend.ITEM_MOUSEOUT,
this.handleLegendMouseOut
);
}
getInnerRadius() {
return Configuration.pie.innerRadius;
}
render(animate = true) {
const self = this;
const svg = this.getContainerSVG();
// remove any slices that are valued at 0 because they dont need to be rendered and will create extra padding
const displayData = this.model.getDisplayData()
.filter(data => data.value > 0);
const options = this.getOptions();
const { groupMapsTo } = options.data;
// Compute the outer radius needed
const radius = this.computeRadius();
this.arc = arc().innerRadius(this.getInnerRadius()).outerRadius(radius);
// Set the hover arc radius
this.hoverArc = arc()
.innerRadius(this.getInnerRadius())
.outerRadius(radius + Configuration.pie.hoverArc.outerRadiusOffset);
// Setup the pie layout
const pieLayout = pie()
.value((d: any) => d.value)
.sort(null)
.padAngle(Configuration.pie.padAngle);
// Sort pie layout data based off of the indecies the layout creates
const pieLayoutData = pieLayout(displayData).sort(
(a: any, b: any) => a.index - b.index
);
// Update data on all slices
const slicesGroup = DOMUtils.appendOrSelect(svg, 'g.slices')
.attr('role', Roles.GROUP)
.attr('data-name', 'slices');
const paths = slicesGroup
.selectAll('path.slice')
.data(pieLayoutData, (d) => d.data[groupMapsTo]);
// Remove slices that need to be exited
paths.exit().attr('opacity', 0).remove();
// Add new slices that are being introduced
const enteringPaths = paths
.enter()
.append('path')
.classed('slice', true)
.attr('opacity', 0);
// Update styles & position on existing and entering slices
enteringPaths
.merge(paths)
.attr('class', (d) =>
this.model.getColorClassName({
classNameTypes: [ColorClassNameTypes.FILL],
dataGroupName: d.data[groupMapsTo],
originalClassName: 'slice',
})
)
.style('fill', (d) => self.model.getFillColor(d.data[groupMapsTo]))
.attr('d', this.arc)
.transition(
this.services.transitions.getTransition(
'pie-slice-enter-update',
animate
)
)
.attr('opacity', 1)
// a11y
.attr('role', Roles.GRAPHICS_SYMBOL)
.attr('aria-roledescription', 'slice')
.attr(
'aria-label',
(d) =>
`${d.value}, ${
Tools.convertValueToPercentage(
d.data.value,
displayData
) + '%'
}`
)
// Tween
.attrTween('d', function (a) {
return arcTween.bind(this)(a, self.arc);
});
// Draw the slice labels
const labelData = pieLayoutData.filter((x) => x.value > 0);
const labelsGroup = DOMUtils.appendOrSelect(svg, 'g.labels')
.attr('role', Roles.GROUP)
.attr('data-name', 'labels');
const labels = labelsGroup
.selectAll('text.pie-label')
.data(labelData, (d: any) => d.data[groupMapsTo]);
// Remove labels that are existing
labels.exit().attr('opacity', 0).remove();
// Add labels that are being introduced
const enteringLabels = labels
.enter()
.append('text')
.classed('pie-label', true);
// Update styles & position on existing & entering labels
const calloutData = [];
enteringLabels
.merge(labels)
.style('text-anchor', 'middle')
.text((d) => {
if (options.pie.labels.formatter) {
return options.pie.labels.formatter(d);
}
return (
Tools.convertValueToPercentage(d.data.value, displayData) +
'%'
);
})
// Calculate dimensions in order to transform
.datum(function (d) {
const marginedRadius = radius + 7;
const theta = (d.endAngle - d.startAngle) / 2 + d.startAngle;
const deg = (theta / Math.PI) * 180;
const textLength = this.getComputedTextLength();
d.textOffsetX = textLength / 2;
d.textOffsetY = deg > 90 && deg < 270 ? 10 : 0;
d.xPosition =
(d.textOffsetX + marginedRadius) * Math.sin(theta);
d.yPosition =
(d.textOffsetY + marginedRadius) * -Math.cos(theta);
return d;
})
.attr('transform', function (d, i) {
const totalSlices = labelData.length;
const sliceAngleDeg =
(d.endAngle - d.startAngle) * (180 / Math.PI);
// check if last 2 slices (or just last) are < the threshold
if (i >= totalSlices - 2) {
if (
sliceAngleDeg < Configuration.pie.callout.minSliceDegree
) {
let labelTranslateX, labelTranslateY;
if (d.index === totalSlices - 1) {
labelTranslateX =
d.xPosition +
Configuration.pie.callout.offsetX +
Configuration.pie.callout.textMargin +
d.textOffsetX;
labelTranslateY =
d.yPosition - Configuration.pie.callout.offsetY;
// Set direction of callout
d.direction = CalloutDirections.RIGHT;
calloutData.push(d);
} else {
labelTranslateX =
d.xPosition -
Configuration.pie.callout.offsetX -
d.textOffsetX -
Configuration.pie.callout.textMargin;
labelTranslateY =
d.yPosition - Configuration.pie.callout.offsetY;
// Set direction of callout
d.direction = CalloutDirections.LEFT;
calloutData.push(d);
}
return `translate(${labelTranslateX}, ${labelTranslateY})`;
}
}
return `translate(${d.xPosition}, ${d.yPosition})`;
});
// Render pie label callouts
this.renderCallouts(calloutData);
const optionName = Tools.getProperty(options, 'donut')
? 'donut'
: 'pie';
const alignment = Tools.getProperty(options, optionName, 'alignment');
const { width } = DOMUtils.getSVGElementSize(this.getParent(), {
useAttr: true,
});
// Position Pie
let pieTranslateX = radius + Configuration.pie.xOffset;
if (alignment === Alignments.CENTER) {
pieTranslateX = width / 2;
} else if (alignment === Alignments.RIGHT) {
pieTranslateX = width - radius - Configuration.pie.xOffset;
}
let pieTranslateY = radius + Configuration.pie.yOffset;
if (calloutData.length > 0) {
pieTranslateY += Configuration.pie.yOffsetCallout;
}
svg.attr('transform', `translate(${pieTranslateX}, ${pieTranslateY})`);
// Add event listeners
this.addEventListeners();
}
renderCallouts(calloutData: any[]) {
const svg = DOMUtils.appendOrSelect(
this.getContainerSVG(),
'g.callouts'
)
.attr('role', Roles.GROUP)
.attr('data-name', 'callouts');
// Update data on callouts
const callouts = svg.selectAll('g.callout').data(calloutData);
callouts.exit().remove();
const enteringCallouts = callouts
.enter()
.append('g')
.classed('callout', true)
// a11y
.attr('role', `${Roles.GRAPHICS_SYMBOL} ${Roles.GROUP}`)
.attr('aria-roledescription', 'label callout');
// Update data values for each callout
// For the horizontal and vertical lines to use
enteringCallouts.merge(callouts).datum(function (d) {
const { xPosition, yPosition, direction } = d;
if (direction === CalloutDirections.RIGHT) {
d.startPos = {
x: xPosition,
y: yPosition + d.textOffsetY,
};
// end position for the callout line
d.endPos = {
x: xPosition + Configuration.pie.callout.offsetX,
y:
yPosition -
Configuration.pie.callout.offsetY +
d.textOffsetY,
};
// the intersection point of the vertical and horizontal line
d.intersectPointX =
d.endPos.x - Configuration.pie.callout.horizontalLineLength;
} else {
// start position for the callout line
d.startPos = {
x: xPosition,
y: yPosition + d.textOffsetY,
};
// end position for the callout line should be bottom aligned to the title
d.endPos = {
x: xPosition - Configuration.pie.callout.offsetX,
y:
yPosition -
Configuration.pie.callout.offsetY +
d.textOffsetY,
};
// the intersection point of the vertical and horizontal line
d.intersectPointX =
d.endPos.x + Configuration.pie.callout.horizontalLineLength;
}
// Store the necessary data in the DOM element
return d;
});
// draw vertical line
const enteringVerticalLines = enteringCallouts
.append('line')
.classed('vertical-line', true);
enteringVerticalLines
.merge(svg.selectAll('line.vertical-line'))
.datum(function (d: any) {
return select(this.parentNode).datum();
})
.style('stroke-width', '1px')
.attr('x1', (d) => d.startPos.x)
.attr('y1', (d) => d.startPos.y)
.attr('x2', (d) => d.intersectPointX)
.attr('y2', (d) => d.endPos.y);
// draw horizontal line
const enteringHorizontalLines = enteringCallouts
.append('line')
.classed('horizontal-line', true);
enteringHorizontalLines
.merge(svg.selectAll('line.horizontal-line'))
.datum(function (d: any) {
return select(this.parentNode).datum();
})
.style('stroke-width', '1px')
.attr('x1', (d) => d.intersectPointX)
.attr('y1', (d) => d.endPos.y)
.attr('x2', (d) => d.endPos.x)
.attr('y2', (d) => d.endPos.y);
}
// Highlight elements that match the hovered legend item
handleLegendOnHover = (event: CustomEvent) => {
const { hoveredElement } = event.detail;
const { groupMapsTo } = this.getOptions().data;
this.parent
.selectAll('path.slice')
.transition(
this.services.transitions.getTransition('legend-hover-bar')
)
.attr('opacity', (d) =>
d.data[groupMapsTo] !== hoveredElement.datum()['name'] ? 0.3 : 1
);
};
// Un-highlight all elements
handleLegendMouseOut = (event: CustomEvent) => {
this.parent
.selectAll('path.slice')
.transition(
this.services.transitions.getTransition('legend-mouseout-bar')
)
.attr('opacity', 1);
};
addEventListeners() {
const self = this;
this.parent
.selectAll('path.slice')
.on('mouseover', function (datum) {
const hoveredElement = select(this);
hoveredElement
.classed('hovered', true)
.transition(
self.services.transitions.getTransition(
'pie_slice_mouseover'
)
)
.attr('d', self.hoverArc);
// Dispatch mouse event
self.services.events.dispatchEvent(Events.Pie.SLICE_MOUSEOVER, {
element: select(this),
datum,
});
const { groupMapsTo } = self.getOptions().data;
// Show tooltip
self.services.events.dispatchEvent(Events.Tooltip.SHOW, {
hoveredElement,
items: [
{
label: datum.data[groupMapsTo],
value: datum.data.value,
},
],
});
})
.on('mousemove', function (datum) {
const hoveredElement = select(this);
// Dispatch mouse event
self.services.events.dispatchEvent(Events.Pie.SLICE_MOUSEMOVE, {
element: hoveredElement,
datum,
});
// Show tooltip
self.services.events.dispatchEvent(Events.Tooltip.MOVE);
})
.on('click', function (datum) {
// Dispatch mouse event
self.services.events.dispatchEvent(Events.Pie.SLICE_CLICK, {
element: select(this),
datum,
});
})
.on('mouseout', function (datum) {
const hoveredElement = select(this);
hoveredElement
.classed('hovered', false)
.transition(
self.services.transitions.getTransition(
'pie_slice_mouseover'
)
)
.attr('d', self.arc);
// Dispatch mouse event
self.services.events.dispatchEvent(Events.Pie.SLICE_MOUSEOUT, {
element: hoveredElement,
datum,
});
// Hide tooltip
self.services.events.dispatchEvent(Events.Tooltip.HIDE, {
hoveredElement,
});
});
}
// Helper functions
protected computeRadius() {
const { width, height } = DOMUtils.getSVGElementSize(this.parent, {
useAttrs: true,
});
const radius: number = Math.min(width, height) / 2;
return radius + Configuration.pie.radiusOffset;
}
}