@progress/kendo-charts
Version:
Kendo UI platform-independent Charts library
499 lines (411 loc) • 17.8 kB
JavaScript
import { drawing as draw, geometry } from '@progress/kendo-drawing';
import PieSegment from './pie-segment';
import PieChartMixin from '../mixins/pie-chart-mixin';
import { ChartElement, Ring, Box, Point } from '../../core';
import { OUTSIDE_END, FADEIN, COLUMN } from '../constants';
import { bindSegments, evalOptions } from '../utils';
import { CIRCLE, RIGHT, CENTER } from '../../common/constants';
import { deepExtend, defined, find, isFunction, last, round, setDefaultOptions, valueOrDefault } from '../../common';
const PIE_SECTOR_ANIM_DELAY = 70;
class PieChart extends ChartElement {
constructor(plotArea, options) {
super(options);
this.plotArea = plotArea;
this.chartService = plotArea.chartService;
this.points = [];
this.legendItems = [];
this.render();
}
render() {
this.traverseDataPoints(this.addValue.bind(this));
}
traverseDataPoints(callback) {
const { options, plotArea: { options: { seriesColors = [] } } } = this;
const colorsCount = seriesColors.length;
const series = options.series;
const seriesCount = series.length;
for (let seriesIx = 0; seriesIx < seriesCount; seriesIx++) {
const currentSeries = series[seriesIx];
const data = currentSeries.data;
const { total, points, count } = bindSegments(currentSeries);
const anglePerValue = 360 / total;
let constantAngle;
if (!isFinite(anglePerValue)) {
constantAngle = 360 / count;
}
let currentAngle;
if (defined(currentSeries.startAngle)) {
currentAngle = currentSeries.startAngle;
} else {
currentAngle = options.startAngle;
}
if (seriesIx !== seriesCount - 1) {
if (currentSeries.labels.position === OUTSIDE_END) {
currentSeries.labels.position = CENTER;
}
}
for (let i = 0; i < points.length; i++) {
const pointData = points[i];
if (!pointData) {
continue;
}
const { fields, value, visible } = pointData;
const angle = value !== 0 ? (constantAngle || (value * anglePerValue)) : 0;
const explode = data.length !== 1 && Boolean(fields.explode);
if (!isFunction(currentSeries.color)) {
currentSeries.color = fields.color || seriesColors[i % colorsCount];
}
callback(value, new Ring(null, 0, 0, currentAngle, angle), {
owner: this,
category: defined(fields.category) ? fields.category : "",
pattern: defined(fields.pattern) ? fields.pattern : currentSeries.pattern,
index: i,
series: currentSeries,
seriesIx: seriesIx,
dataItem: data[i],
percentage: total !== 0 ? value / total : 0,
explode: explode,
visibleInLegend: fields.visibleInLegend,
visible: visible,
zIndex: seriesCount - seriesIx,
animationDelay: this.animationDelay(i, seriesIx, seriesCount)
});
if (visible !== false) {
currentAngle += angle;
}
}
}
}
evalSegmentOptions(options, value, fields) {
const series = fields.series;
evalOptions(options, {
value: value,
series: series,
dataItem: fields.dataItem,
category: fields.category,
percentage: fields.percentage
}, { defaults: series._defaults, excluded: [
"data", "content", "template", "visual", "toggle",
"ariaTemplate", "ariaContent"
] });
}
addValue(value, sector, fields) {
const segmentOptions = deepExtend({}, fields.series, { index: fields.index });
segmentOptions.pattern = fields.pattern || segmentOptions.pattern;
this.evalSegmentOptions(segmentOptions, value, fields);
this.createLegendItem(value, segmentOptions, fields);
if (fields.visible === false) {
return;
}
const segment = new PieSegment(value, sector, segmentOptions);
Object.assign(segment, fields);
this.append(segment);
this.points.push(segment);
}
reflow(targetBox) {
const { options, points, seriesConfigs = [] } = this;
const count = points.length;
const box = targetBox.clone();
const space = 5;
const minWidth = Math.min(box.width(), box.height());
const halfMinWidth = minWidth / 2;
const defaultPadding = minWidth - minWidth * 0.85;
const newBox = new Box(box.x1, box.y1, box.x1 + minWidth, box.y1 + minWidth);
const newBoxCenter = newBox.center();
const boxCenter = box.center();
const seriesCount = options.series.length;
const leftSideLabels = [];
const rightSideLabels = [];
let padding = valueOrDefault(options.padding, defaultPadding);
this.targetBox = targetBox;
padding = padding > halfMinWidth - space ? halfMinWidth - space : padding;
newBox.translate(boxCenter.x - newBoxCenter.x, boxCenter.y - newBoxCenter.y);
const radius = halfMinWidth - padding;
const center = new Point(
radius + newBox.x1 + padding,
radius + newBox.y1 + padding
);
for (let i = 0; i < count; i++) {
const segment = points[i];
const sector = segment.sector;
const seriesIndex = segment.seriesIx;
sector.radius = radius;
sector.center = center;
if (seriesConfigs.length) {
const seriesConfig = seriesConfigs[seriesIndex];
sector.innerRadius = seriesConfig.innerRadius;
sector.radius = seriesConfig.radius;
}
if (seriesIndex === seriesCount - 1 && segment.explode) {
sector.center = sector.clone().setRadius(sector.radius * 0.15).point(sector.middle());
}
segment.reflow(newBox);
const label = segment.label;
if (label) {
if (label.options.position === OUTSIDE_END) {
if (seriesIndex === seriesCount - 1) {
if (label.orientation === RIGHT) {
rightSideLabels.push(label);
} else {
leftSideLabels.push(label);
}
}
}
}
}
if (leftSideLabels.length > 0) {
leftSideLabels.sort(this.labelComparator(true));
this.leftLabelsReflow(leftSideLabels);
}
if (rightSideLabels.length > 0) {
rightSideLabels.sort(this.labelComparator(false));
this.rightLabelsReflow(rightSideLabels);
}
this.box = newBox;
}
leftLabelsReflow(labels) {
const distances = this.distanceBetweenLabels(labels);
this.distributeLabels(distances, labels);
}
rightLabelsReflow(labels) {
const distances = this.distanceBetweenLabels(labels);
this.distributeLabels(distances, labels);
}
distanceBetweenLabels(labels) {
const segment = last(this.points);
const sector = segment.sector;
const count = labels.length - 1;
const lr = sector.radius + segment.options.labels.distance;
const distances = [];
let firstBox = labels[0].box;
let distance = round(firstBox.y1 - (sector.center.y - lr - firstBox.height() - firstBox.height() / 2));
distances.push(distance);
for (let i = 0; i < count; i++) {
const secondBox = labels[i + 1].box;
firstBox = labels[i].box;
distance = round(secondBox.y1 - firstBox.y2);
distances.push(distance);
}
distance = round(sector.center.y + lr - labels[count].box.y2 - labels[count].box.height() / 2);
distances.push(distance);
return distances;
}
distributeLabels(distances, labels) {
const count = distances.length;
let left, right, remaining;
for (let i = 0; i < count; i++) {
remaining = -distances[i];
left = right = i;
while (remaining > 0 && (left >= 0 || right < count)) {
remaining = this._takeDistance(distances, i, --left, remaining);
remaining = this._takeDistance(distances, i, ++right, remaining);
}
}
this.reflowLabels(distances, labels);
}
_takeDistance(distances, anchor, position, amount) {
let result = amount;
if (distances[position] > 0) {
const available = Math.min(distances[position], result);
result -= available;
distances[position] -= available;
distances[anchor] += available;
}
return result;
}
reflowLabels(distances, labels) {
const segment = last(this.points);
const sector = segment.sector;
const labelOptions = segment.options.labels;
const labelsCount = labels.length;
const labelDistance = labelOptions.distance;
let boxY = sector.center.y - (sector.radius + labelDistance) - labels[0].box.height();
let boxX;
distances[0] += 2;
for (let i = 0; i < labelsCount; i++) {
const label = labels[i];
const box = label.box;
boxY += distances[i];
boxX = this.hAlignLabel(
box.x2,
sector.clone().expand(labelDistance),
boxY,
boxY + box.height(),
label.orientation === RIGHT);
if (label.orientation === RIGHT) {
if (labelOptions.align !== CIRCLE) {
boxX = sector.radius + sector.center.x + labelDistance;
}
label.reflow(new Box(boxX + box.width(), boxY, boxX, boxY));
} else {
if (labelOptions.align !== CIRCLE) {
boxX = sector.center.x - sector.radius - labelDistance;
}
label.reflow(new Box(boxX - box.width(), boxY, boxX, boxY));
}
boxY += box.height();
}
}
createVisual() {
const { options: { connectors }, points } = this;
const count = points.length;
const space = 4;
super.createVisual();
this._connectorLines = [];
for (let i = 0; i < count; i++) {
const segment = points[i];
const { sector, label } = segment;
const angle = sector.middle();
const connectorsColor = (segment.options.connectors || {}).color || connectors.color;
if (label) {
const connectorLine = new draw.Path({
stroke: {
color: connectorsColor,
width: connectors.width
},
animation: {
type: FADEIN,
delay: segment.animationDelay
}
});
if (label.options.position === OUTSIDE_END) {
const box = label.box;
const centerPoint = sector.center;
let start = sector.point(angle);
let middle = new Point(box.x1, box.center().y);
let sr, end, crossing;
start = sector.clone().expand(connectors.padding).point(angle);
connectorLine.moveTo(start.x, start.y);
// TODO: Extract into a method to remove duplication
if (label.orientation === RIGHT) {
end = new Point(box.x1 - connectors.padding, box.center().y);
crossing = intersection(centerPoint, start, middle, end);
middle = new Point(end.x - space, end.y);
crossing = crossing || middle;
crossing.x = Math.min(crossing.x, middle.x);
if (this.pointInCircle(crossing, sector.center, sector.radius + space) ||
crossing.x < sector.center.x) {
sr = sector.center.x + sector.radius + space;
if (segment.options.labels.align !== COLUMN) {
if (sr < middle.x) {
connectorLine.lineTo(sr, start.y);
} else {
connectorLine.lineTo(start.x + space * 2, start.y);
}
} else {
connectorLine.lineTo(sr, start.y);
}
connectorLine.lineTo(middle.x, end.y);
} else {
crossing.y = end.y;
connectorLine.lineTo(crossing.x, crossing.y);
}
} else {
end = new Point(box.x2 + connectors.padding, box.center().y);
crossing = intersection(centerPoint, start, middle, end);
middle = new Point(end.x + space, end.y);
crossing = crossing || middle;
crossing.x = Math.max(crossing.x, middle.x);
if (this.pointInCircle(crossing, sector.center, sector.radius + space) ||
crossing.x > sector.center.x) {
sr = sector.center.x - sector.radius - space;
if (segment.options.labels.align !== COLUMN) {
if (sr > middle.x) {
connectorLine.lineTo(sr, start.y);
} else {
connectorLine.lineTo(start.x - space * 2, start.y);
}
} else {
connectorLine.lineTo(sr, start.y);
}
connectorLine.lineTo(middle.x, end.y);
} else {
crossing.y = end.y;
connectorLine.lineTo(crossing.x, crossing.y);
}
}
connectorLine.lineTo(end.x, end.y);
this._connectorLines.push(connectorLine);
this.visual.append(connectorLine);
}
}
}
}
renderVisual() {
super.renderVisual();
if (find(this.options.series, options => options.autoFit)) {
const targetBox = this.targetBox;
const pieCenter = this.box.center();
const bbox = this.visual.bbox();
if (!bbox) {
return;
}
const bboxBottom = bbox.bottomRight();
const scale = Math.min(
(pieCenter.y - targetBox.y1) / (pieCenter.y - bbox.origin.y),
(targetBox.y2 - pieCenter.y) / (bboxBottom.y - pieCenter.y),
(pieCenter.x - targetBox.x1) / (pieCenter.x - bbox.origin.x),
(targetBox.x2 - pieCenter.x) / (bboxBottom.x - pieCenter.x)
);
if (scale < 1) {
this.visual.transform(geometry.transform().scale(scale, scale, [ pieCenter.x, pieCenter.y ]));
}
}
}
labelComparator(reverse) {
const reverseValue = reverse ? -1 : 1;
return function(a, b) {
const first = (a.parent.sector.middle() + 270) % 360;
const second = (b.parent.sector.middle() + 270) % 360;
return (first - second) * reverseValue;
};
}
hAlignLabel(originalX, sector, y1, y2, direction) {
const { radius, center: { x: cx, y: cy } } = sector;
const t = Math.min(Math.abs(cy - y1), Math.abs(cy - y2));
if (t > radius) {
return originalX;
}
return cx + Math.sqrt((radius * radius) - (t * t)) * (direction ? 1 : -1);
}
pointInCircle(point, center, radius) {
return Math.pow(center.x - point.x, 2) + Math.pow(center.y - point.y, 2) < Math.pow(radius, 2);
}
formatPointValue(point, format) {
return this.chartService.format.auto(format, point.value);
}
animationDelay(categoryIndex) {
return categoryIndex * PIE_SECTOR_ANIM_DELAY;
}
stackRoot() {
return this;
}
}
function intersection(a1, a2, b1, b2) {
const uat = (b2.x - b1.x) * (a1.y - b1.y) - (b2.y - b1.y) * (a1.x - b1.x);
const ub = (b2.y - b1.y) * (a2.x - a1.x) - (b2.x - b1.x) * (a2.y - a1.y);
let result;
if (ub !== 0) {
const ua = (uat / ub);
result = new Point(
a1.x + ua * (a2.x - a1.x),
a1.y + ua * (a2.y - a1.y)
);
}
return result;
}
setDefaultOptions(PieChart, {
startAngle: 90,
connectors: {
width: 2,
color: "#939393",
padding: 8
},
inactiveItems: {
markers: {},
labels: {}
}
});
deepExtend(PieChart.prototype, PieChartMixin);
PieChart.prototype.isStackRoot = true;
export default PieChart;