25
loading...
This website collects cookies to deliver better user experience
stroke-dasharray
and stroke-dashoffset
). Then we'll talk about a new way to do it, I'll go over the math and SVG usage in great detail, and we'll finally implement the solution with React & Angular.stroke-dasharray
and stroke-dashoffset
properties to draw a slice of border around SVG circles. Read the post to see how he do it.z-index
hides all the other elements below it. Have a look at the illustration below, and pay attention to the ID of the circle that is highlighted (42).<path>
. The <path>
element is the most powerful element in the SVG library of basic shapes. It can be used to create lines, curves, arcs, and more.<path>
element is defined by one parameter: d
. The d
attribute contains a series of commands and parameters used by those commands (Docs here).viewBox="0 0 100 100"
. The most top-left point is 0,0
and the most bottom-right point is 100,100
.<svg viewBox="0 0 100 100">
<path fill="tomato"
d="M 100 50
A 50 50 0 0 0 50 0
L 50 50"
/>
</svg>
100, 50
, then we drew an arc (with a radius of 50
) to the 50, 0
point. And finally, we drew a line back to the center (50, 50
).100, 50
).sweep-flag
. Say we set it to true (1), we have this:large-arc-flag
, if we set it to 1, we have this:50, 0
), and we move there while taking a negative angle direction.cos(angle)
for the horizontal position and sin(angle)
for the verticle position. The angle must be in radiants so if we want the position of our point that is placed at 45 degrees we have to first transform it to radiant. To do this we multiply it by PI and we divide by 180.const position = [
Math.cos(45 * Math.PI / 180),
Math.sin(45 * Math.PI / 180)
]
// [0.707106781186547, 0.707106781186547]
function getCoordFromDegrees(angle, radius, svgSize) {
const x = Math.cos(angle * Math.PI / 180);
const y = Math.sin(angle * Math.PI / 180);
const coordX = x * radius + svgSize / 2;
const coordY = y * -radius + svgSize / 2;
return [coordX, coordY];
}
getCoordFromDegree(45, 50, 100); // [85.35499, 14.64500]
<svg viewBox="0 0 100 100">
<path fill="tomato"
d="M 100 50
A 50 50 0 0 0 85.35499, 14.64500
L 50 50"
/>
</svg>
getCoordFromDegrees(45, 30, 100)
(30 because radius - 20). sweep-flag
to 1
because of the negative direction.<svg viewBox="0 0 100 100">
<path fill="tomato"
d="M 100 50
A 50 50 0 0 0 85.35499 14.64500
L 71.213 28.78700
A 30 30 0 0 1 80 50"
/>
</svg>
large-arc-flag
to 1
.path
to center (transform-origin: center;
) since we rotate it relative to the center.export interface DonutSlice {
id: number;
percent: number;
color: string;
label?: string;
onClickCb?: () => void;
}
interface DonutSliceWithCommands extends DonutSlice {
offset: number; // This is the offset that we will use to rotate the slices
commands: string; // This will be what goes inside the d attribute of the path tag
}
class CalculusHelper {
getSlicesWithCommandsAndOffsets(
donutSlices: DonutSlice[],
radius: number,
svgSize: number,
borderSize: number
): DonutSliceWithCommands[] {
let previousPercent = 0;
return donutSlices.map((slice) => {
const sliceWithCommands: DonutSliceWithCommands = {
...slice,
commands: this.getSliceCommands(slice, radius, svgSize, borderSize),
offset: previousPercent * 3.6 * -1,
};
previousPercent += slice.percent;
return sliceWithCommands;
});
}
getSliceCommands(
donutSlice: DonutSlice,
radius: number,
svgSize: number,
borderSize: number
): string {
const degrees = this.percentToDegrees(donutSlice.percent);
const longPathFlag = degrees > 180 ? 1 : 0;
const innerRadius = radius - borderSize;
const commands: string[] = [];
commands.push(`M ${svgSize / 2 + radius} ${svgSize / 2}`);
commands.push(
`A ${radius} ${radius} 0 ${longPathFlag} 0 ${this.getCoordFromDegrees(
degrees,
radius,
svgSize
)}`
);
commands.push(
`L ${this.getCoordFromDegrees(degrees, innerRadius, svgSize)}`
);
commands.push(
`A ${innerRadius} ${innerRadius} 0 ${longPathFlag} 1 ${
svgSize / 2 + innerRadius
} ${svgSize / 2}`
);
return commands.join(' ');
}
getCoordFromDegrees(angle: number, radius: number, svgSize: number): string {
const x = Math.cos((angle * Math.PI) / 180);
const y = Math.sin((angle * Math.PI) / 180);
const coordX = x * radius + svgSize / 2;
const coordY = y * -radius + svgSize / 2;
return [coordX, coordY].join(' ');
}
percentToDegrees(percent: number): number {
return percent * 3.6;
}
}
export default ({
data,
radius,
viewBox,
borderSize,
clickCb,
}: {
data: DonutSlice[];
radius: number;
viewBox: number;
borderSize: number;
clickCb: (slice: DonutSlice) => void
}) => {
const helper = new CalculusHelper();
return (
data && (
<svg viewBox={'0 0 ' + viewBox + ' ' + viewBox}>
{helper
.getSlicesWithCommandsAndOffsets(data, radius, viewBox, borderSize)
.map((slice) => (
<path
onClick={() => clickCb(slice)}
fill={slice.color}
d={slice.commands}
transform={'rotate(' + slice.offset + ')'}
>
<title>{slice.label}</title>
</path>
))}
</svg>
)
);
};
@Pipe({
name: 'slicesWithCommandsAndOffset',
pure: true,
})
export class DonutChartPipe implements PipeTransform {
transform(donutSlices: DonutSlice[], radius: number, svgSize: number, borderSize: number): DonutSliceWithCommands[] {
let previousPercent = 0;
return donutSlices.map(slice => {
const sliceWithCommands: DonutSliceWithCommands = {
...slice,
commands: this.getSliceCommands(slice, radius, svgSize, borderSize),
offset: previousPercent * 3.6 * -1, // we multiply by -1 because CSS rotation move in clockwise direction whereas degrees move in counter-clockwise direction
};
previousPercent += slice.percent;
return sliceWithCommands;
});
}
getSliceCommands(donutSlice: DonutSlice, radius: number, svgSize: number, borderSize: number): string {
const degrees = this.percentToDegrees(donutSlice.percent);
const longPathFlag = degrees > 180 ? 1 : 0;
const innerRadius = radius - borderSize;
const commands: string[] = [];
commands.push(`M ${ svgSize / 2 + radius } ${ svgSize / 2 }`);
commands.push(`A ${ radius } ${ radius } 0 ${ longPathFlag } 0 ${ this.getCoordFromDegrees(degrees, radius, svgSize) }`);
commands.push(`L ${ this.getCoordFromDegrees(degrees, innerRadius, svgSize) }`);
commands.push(`A ${ innerRadius } ${ innerRadius } 0 ${ longPathFlag } 1 ${ svgSize / 2 + innerRadius } ${ svgSize / 2 }`);
return commands.join(' ');
}
getCoordFromDegrees(angle: number, radius: number, svgSize: number): string {
const x = Math.cos(angle * Math.PI / 180);
const y = Math.sin(angle * Math.PI / 180);
const coordX = x * radius + svgSize / 2;
const coordY = y * -radius + svgSize / 2;
return [coordX, coordY].join(' ');
}
percentToDegrees(percent: number): number {
return percent * 3.6;
}
}
@Component({
selector: 'app-donut-chart',
styleUrls: ['./donut-chart.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<svg [attr.viewBox]="'0 0 ' + viewBox + ' ' + viewBox" *ngIf="data">
<path *ngFor="let slice of data | slicesWithCommandsAndOffset:radius:viewBox:borderSize;
trackBy: trackByFn;
let index = index"
[attr.fill]="slice.color"
[attr.d]="slice.commands"
[attr.transform]="'rotate(' + slice.offset + ')'"
(click)="slice.onClickCb ? slice.onClickCb() : null"
>
<title>{{slice.label}}</title>
</path>
</svg>
`,
})
export class DonutChartComponent implements OnInit {
@Input() radius = 50;
@Input() viewBox = 100;
@Input() borderSize = 20;
@Input() data: DonutSlice[] = [];
ngOnInit() {
const sum = this.data?.reduce((accu, slice) => accu + slice.percent, 0);
if (sum !== 100) {
throw new Error(`The sum of all slices of the donut chart must equal to 100%. Found: ${ sum }.`);
}
}
trackByFn(index: number, slice: DonutSlice) {
return slice.id;
}
}