Doolan
Doolan

Reputation: 1641

How to evenly space slices on donut chart?

I'm unable to figure out how to evenly space each slice on this donut chart.

Screenshot of Donut Chart

import { storyblokEditable } from "@storyblok/react";

import { type BlokType } from "../../../types/storyblok";

interface AnimatedPieBlokProps {
  blok: BlokType;
}

function AnimatedPieBlok({ blok }: AnimatedPieBlokProps) {
  const { pieSlices } = blok;
  const totalSlices = pieSlices.length;
  const calculatedSlices = pieSlices.map((slice: BlokType) => ({
    ...slice,
    value: 360 / totalSlices
  }));
  const total = calculatedSlices.reduce((sum: number, item: BlokType) => sum + item.value, 0);
  const spacing = 1; // Set the spacing between slices
  const sliceAngle = (360 - (spacing * totalSlices)) / totalSlices;
  let cumulativeValue = 0;


  return (
    <div {...storyblokEditable(blok)} key={blok._uid} className="flex flex-row justify-center">
      <svg width="450" height="450" viewBox="0 0 200 200" className="flex-1">
        {calculatedSlices.map((item: BlokType, index: number) => {
          const words = item.title.split(' ');
          const firstWord = words[0];
          const secondWord = words[1];
          const startAngle = cumulativeValue * (Math.PI / 180);
          const endAngle = (cumulativeValue + sliceAngle) * (Math.PI / 180);

          // Adjust the angles to create spacing
          const adjustedStartAngle = startAngle + (spacing / 100); // Adjust for spacing
          const adjustedEndAngle = endAngle - (spacing / 100); // Adjust for spacing

          const x1 = 100 + 100 * Math.cos(startAngle);
          const y1 = 100 + 100 * Math.sin(startAngle);
          const x2 = 100 + 100 * Math.cos(endAngle);
          const y2 = 100 + 100 * Math.sin(endAngle);

          // Calculate the midpoint for the label and image
          const midAngle = (adjustedStartAngle + adjustedEndAngle) / 2;
          const labelX = 100 + 65 * Math.cos(midAngle); // Adjust the radius for label position
          const labelY = 100 + 65 * Math.sin(midAngle); // Adjust the radius for label position
          const imageX = 100 + 40 * Math.cos(midAngle); // Adjust the radius for image position
          const imageY = 100 + 40 * Math.sin(midAngle); // Adjust the radius for image position

          cumulativeValue += sliceAngle + spacing;

          return (
            <g
            key={index}
            className="transition-transform duration-300 ease-in-out hover:scale-105 relative hover:z-10"
            >
              <path
                d={`M 100,100 L ${x1},${y1} A 100,100 0 ${item.value / total > 0.5 ? 1 : 0} 1 ${x2},${y2} Z`}
                fill={item.backgroundColor}
              />
              <image
                href={item.image.filename} // Use the imageUrl from the dataset
                x={imageX - 15} // Center the image (adjust as needed)
                y={imageY - 15} // Center the image (adjust as needed)
                width="30" // Set the width of the image
                height="30" // Set the height of the image
              />
              <text
                x={labelX}
                y={labelY}
                fill="white"
                fontSize="7px"
                textAnchor="middle"
                alignmentBaseline="middle"
              >
                <tspan>{firstWord}</tspan>
                {secondWord ? <tspan x={labelX} dy="1.2em">{secondWord}</tspan> : null}
              </text>
          </g>
        );
        })}
        <circle cx="100" cy="100" r="30" fill="white" />
        <circle cx="100" cy="100" r="29" fill="none" stroke="#140965" strokeWidth="3" />
      </svg>
    </div>
  );
}

export default AnimatedPieBlok;

I've tried using other AI tools along with other stackoverflow posts to try and resolve the issue but haven't been able to get it to work properly.

Upvotes: 0

Views: 55

Answers (2)

Key is to use pathLength, and then you do not need any Math

Wrapped in a native JavaScript Web Component (JSWC) for ease of use
with shadowDOM so <style> is scoped and does not leak out to the rest of the DOM page:

<pie-chart gap="11"></pie-chart>
<pie-chart slices="8" gap="8"></pie-chart>
<pie-chart stroke="green"></pie-chart>

<script>
customElements.define( "pie-chart", class extends HTMLElement {
    connectedCallback() {
      const slices = ~~(this.getAttribute("slices") || 6);
      const stroke = this.getAttribute("stroke")||"blue";
      const strokeWidth = 20;
      const gap = ~~(this.getAttribute("gap") || 12);
      const sliceAngle = 360 / slices - gap;
      this.attachShadow({ mode: "open" }).innerHTML = `
        <style>
          :host { display: inline-block; width: 160px; background:grey }
          path  { fill: none; stroke: ${stroke}; stroke-width: ${strokeWidth}; 
                  transform:rotate(var(--rotation)); transform-origin: 50% 50%;
                  stroke-dashoffset:var(--offset); stroke-dasharray:var(--dash) 360 }
        </style>
        <svg viewBox="0 0 100 100">
          <circle cx="50" cy="50" r="40" stroke-width="${strokeWidth}" stroke="white" fill="none"></circle>
        </svg>`;
      const paths = Array.from({ length: slices }).map((_, idx) => {
        const slice = document.createElementNS("http://www.w3.org/2000/svg", "path");
        slice.setAttribute("pathLength", 360);
        slice.setAttribute("d", "M50,10 A40,40 0 1,1 10,50 A40,40 0 1,1 50,10");
        slice.style.setProperty("--dash", sliceAngle);
        slice.style.setProperty("--offset", gap);
        slice.style.setProperty("--rotation", `${idx * (sliceAngle + gap)}deg`);
        return slice;
      });
      this.shadowRoot.querySelector("svg").append(...paths)
    }});
</script>
<pie-chart gap="11"></pie-chart>
<pie-chart slices="8" gap="8"></pie-chart>
<pie-chart stroke="green"></pie-chart>

From Chris his better answer

Still a Web Component with shadowDOM so multiple instances can be used

<script>
  customElements.define(
    "pie-chart",
    class extends HTMLElement {
      connectedCallback() {
        setTimeout(() => { // wait till innerHTML is parsed
          const slices = this.innerHTML.split(",");
          const angle = 360 / slices.length;
          this.attachShadow({ mode: "open" }).innerHTML =
            `<style>:host { display:inline-block; width:160px; background:grey }</style>
          <svg viewBox="0 0 100 100">
            <defs>
              <mask id="m">
                <circle r="50" cx="50" cy="50" fill="white" />
                <circle r="15" cx="50" cy="50" fill="black" />
              </mask>
            </defs>
            <g mask="url(#m)" font-family="sans-serif" font-size="7" dominant-baseline="middle" text-anchor="middle">
            <g transform="translate(50 50)" fill="none" stroke-width="100">` +
            slices.map((slice,idx) => {
            let [color,label] = slice.split(":");
            return `<g transform="rotate(${idx * angle}) translate(1 0) rotate(-22.5)">
                     <circle r="50" stroke="${color}" stroke-dasharray="${angle} 360" pathLength="360" />
                     <text transform="rotate(22.5) translate(30 0) rotate(-${idx*angle})" 
                           fill="black">${label}</text>
            </g>`}).join("") + `</g></g></svg>`;
          })
      }})
</script>

<pie-chart>orange:one,green:two,tomato:three,lightblue:four,red:five,purple:six,yellow:seven,cyan:eight</pie-chart>

Upvotes: 0

chrwahl
chrwahl

Reputation: 13090

You can use the stroke-dasharray combined with pathLength to create slices, and then move the slices a bit (for the two examples, two different values: translate(.5 0) and translate(1 0)). This will make the gap between the slices even. To make the inner and outer edge of the slices look nice, mask off all the slices with a mask that has circles in white and black.

My examples are static -- you can probably figure out the dynamic version yourself.

<svg viewBox="0 0 100 100" width="300">
  <defs>
    <mask id="m1">
      <circle r="50" cx="50" cy="50" fill="white" />
      <circle r="15" cx="50" cy="50" fill="black" />
    </mask>
  </defs>
  <g mask="url(#m1)" font-family="sans-serif" font-size="6"
   dominant-baseline="middle" text-anchor="middle">
    <g transform="translate(50 50)" fill="none" stroke-width="100">
      <g transform="rotate(0) translate(.5 0) rotate(-60)">
        <circle r="50" stroke="red" stroke-dasharray="120 360" pathLength="360" />
        <text transform="rotate(60) translate(30 0)"
         fill="black">Lorem</text>
      </g>
      <g transform="rotate(120) translate(.5 0) rotate(-60)">
        <circle r="50" stroke="orange" stroke-dasharray="120 360" pathLength="360" />
        <text transform="rotate(60) translate(30 0) rotate(-120)"
          fill="black">Lorem</text>
      </g>
      <g transform="rotate(240) translate(.5 0) rotate(-60)">
        <circle r="50" stroke="green" stroke-dasharray="120 360" pathLength="360" />
        <text transform="rotate(60) translate(30 0) rotate(-240)"
          fill="black">Lorem</text>
      </g>
    </g>
  </g>
</svg>

<svg viewBox="0 0 100 100" width="300">
  <defs>
    <mask id="m2">
      <circle r="50" cx="50" cy="50" fill="white" />
      <circle r="15" cx="50" cy="50" fill="black" />
    </mask>
  </defs>
  <g mask="url(#m2)" font-family="sans-serif" font-size="5"
   dominant-baseline="middle" text-anchor="middle">
    <g transform="translate(50 50)" fill="none" stroke-width="100">
      <g transform="rotate(0) translate(1 0) rotate(-22.5)">
        <circle r="50" stroke="red" stroke-dasharray="45 360" pathLength="360" />
        <text transform="rotate(22.5) translate(30 0)"
         fill="black">Lorem</text>
      </g>
      <g transform="rotate(45) translate(1 0) rotate(-22.5)">
        <circle r="50" stroke="orange" stroke-dasharray="45 360" pathLength="360" />
        <text transform="rotate(22.5) translate(30 0) rotate(-45)"
          fill="black">Lorem</text>
      </g>
      <g transform="rotate(90) translate(1 0) rotate(-22.5)">
        <circle r="50" stroke="green" stroke-dasharray="45 360" pathLength="360" />
        <text transform="rotate(22.5) translate(30 0) rotate(-90)"
          fill="black">Lorem</text>
      </g>
      <g transform="rotate(135) translate(1 0) rotate(-22.5)">
        <circle r="50" stroke="tomato" stroke-dasharray="45 360" pathLength="360" />
        <text transform="rotate(22.5) translate(30 0) rotate(-135)"
          fill="black">Lorem</text>
      </g>
      <g transform="rotate(180) translate(1 0) rotate(-22.5)">
        <circle r="50" stroke="lightblue" stroke-dasharray="45 360" pathLength="360" />
        <text transform="rotate(22.5) translate(30 0) rotate(-180)"
          fill="black">Lorem</text>
      </g>
      <g transform="rotate(225) translate(1 0) rotate(-22.5)">
        <circle r="50" stroke="red" stroke-dasharray="45 360" pathLength="360" />
        <text transform="rotate(22.5) translate(30 0) rotate(-225)"
          fill="black">Lorem</text>
      </g>
      <g transform="rotate(270) translate(1 0) rotate(-22.5)">
        <circle r="50" stroke="orange" stroke-dasharray="45 360" pathLength="360" />
        <text transform="rotate(22.5) translate(30 0) rotate(-270)"
          fill="black">Lorem</text>
      </g>
      <g transform="rotate(315) translate(1 0) rotate(-22.5)">
        <circle r="50" stroke="green" stroke-dasharray="45 360" pathLength="360" />
        <text transform="rotate(22.5) translate(30 0) rotate(-315)"
          fill="black">Lorem</text>
      </g>
    </g>
  </g>
</svg>

Upvotes: 0

Related Questions