Search code examples
cssreactjssvgtailwind-css

Make SVG responsive while centering paths in React


I've searched for this a lot, including GPT4, and I was not able to find a working answer.

Detailed description:

  1. I have an array of points, which are drawn inside an svg as small circles. The svg is inside a container together with a title. I'm working with tailwind here and almost everything is flex based. The container itself is a "flex flex-col w-full flex-1" So it takes all space, and the svg tag also have "w-full flex-1" to stretch itself to fit the remaining space on the container after the title.
  2. I want this to be responsive (and its not working, after making the window smaller the svg keeps a small width instead of actually being 100% of the container's width).
  3. I want all the points rendered inside the svg to be centered (what I mean by that is that if there only one point at 0;0, I want 0;0 to be at the center of the figure, and If I have, lets say, points 0;0 and 2;2, I want the svg to be centered at the medium point 1;1).
  4. The svg tag itself should fit the entire remaining space of the container, but its viewBox should recalculate everytime a point is added or the window is resized so that all the points are shown (centered) and with a minimum padding around the smaller dimension at the time.

This is what I've got so far, with some comments:

const PreviewPanel = () => {

  //stuff to get the array of points called pointsArr
  
  const viewBox = useMemo(() => {

    //Find min and max points to define bounds
    let minX = pointsArr[0]!.coords.x;
    let maxX = pointsArr[0]!.coords.x;
    let minY = pointsArr[0]!.coords.y;
    let maxY = pointsArr[0]!.coords.y;

    store.points.forEach((point) => {
      minX = Math.min(minX, point.coords.x);
      maxX = Math.max(maxX, point.coords.x);
      minY = Math.min(minY, point.coords.y);
      maxY = Math.max(maxY, point.coords.y);
    });
  

    const pointsWidth = maxX - minX;
    const pointsHeight = maxY - minY;

    const padding = 0.1;
    
    //Not in use right now, but I've tried it.
    //const centroidX = pointsWidth / 2;
    //const centroidY = pointsHeight / 2;

    const viewBoxX = minX - pointsWidth * padding;
    const viewBoxY = -maxY + pointsHeight * padding;
    const viewBoxWidth = pointsWidth * (1 + 2 * padding);
    const viewBoxHeight = pointsHeight * (1 + 2 * padding);

    return `${viewBoxX} ${viewBoxY} ${viewBoxWidth} ${viewBoxHeight}`;
  }, [store]);

  return (
    <div className="flex w-full flex-1 flex-col items-center rounded-md border-2 border-c_discrete max-h-full">
      <div className="border-b-2 border-b-c_discrete">{title}</div>
      <div className="flex w-full flex-1 border-2 border-green-400">
        <svg
          //width="100%"    //<--Setting width and height here didnt work
          //height="100%"
          viewBox={viewBox}
          //preserveAspectRatio="xMidYMid meet" //<---I've tried this too
          className="border-2 border-black" //I've tried setting flex-1 or w-full h-full here too
        >
          <g transform={`scale(1, -1)`}>
            {pointsArr.map((point, index) => {
              return (
                <path
                  key={"svg_path_"+point.id}
                  d={getPointPath(point)} //<--This is just a function that calculates the path, and its working fine
                  stroke={stroke}
                  strokeWidth="0.05"
                  fill={fill}
                />
              );
            })}
          </g>
        </svg>
      </div>
    </div>
  );
};

Problems:

  1. Svg tag is taking the whole width and height of the container until I rescale the window to a smaller size and then back, the width keeps its value when it was smaller.

  2. When adding points svg becomes way taller then it should, and it overflows. I don't want to deal with overflow because I want it to always fit exactly the container (or what it remains after the title).

  3. Points are not being centered on the image


Solution

  • Got it by creating a custom 'useDimensions' hook, that keeps track of the container dimensions when resizing.

    This is the custom hook:

    function useDimensions<T extends HTMLElement = HTMLDivElement>() {
        const ref = useRef<T|null>(null);
        const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
    
        useEffect(() => {
            function updateSize() {
                if(ref.current) {
    
                    
                    setDimensions({
                        width: ref.current.offsetWidth,
                        height: ref.current.offsetHeight
                    });
                }
            }
    
            window.addEventListener('resize', updateSize);
            updateSize();
    
            return () => window.removeEventListener('resize', updateSize);
        }, [ref]);
    
        return [ref, dimensions] as const;
    }
    
    export default useDimensions;
    

    This is inside the component:

      const [ref, dimensions] = useDimensions();
    
      const svgRef = useRef<SVGSVGElement>(null);
    
      const [viewBox, setViewBox] = useState("0 0 100 100");
      const [svgDim, setSvgDim] = useState({ width: 200, height: 200 });
    
      useEffect(() => {
        if (!store || !store.points || store.points.size === 0) return;
    
        const pointsArr = Array.from(store.points.values());
    
        //Find min and max points to define bounds
        let minX = pointsArr[0]!.coords.x;
        let maxX = pointsArr[0]!.coords.x;
        let minY = pointsArr[0]!.coords.y;
        let maxY = pointsArr[0]!.coords.y;
    
        store.points.forEach((point) => {
          minX = Math.min(minX, point.coords.x);
          maxX = Math.max(maxX, point.coords.x);
          minY = Math.min(minY, point.coords.y);
          maxY = Math.max(maxY, point.coords.y);
        });
    
        let pointsWidth = maxX - minX;
        let pointsHeight = maxY - minY;
    
        let padding = 0.5; //of the maximum dimension
    
        if (pointsArr.length == 1 && pointsArr[0]) {
          pointsWidth = pointsArr[0].size * 2;
          pointsHeight = pointsWidth;
          padding = 10;
        }
    
        const viewAR = pointsWidth / pointsHeight;
    
        padding =
          viewAR >= 1 ? (padding *= pointsWidth) : (padding *= pointsHeight);
    
        let viewBoxX = +(minX - padding);
        let viewBoxY = -(maxY + padding);
        let viewBoxWidth = pointsWidth + 2 * padding;
        let viewBoxHeight = pointsHeight + 2 * padding;
    
        const pixelAR = dimensions.width / dimensions.height;
    
        if (viewAR >= pixelAR) {
          //image is fatter than container, so image width needs to be cap set to the container width
          //and the image height should be set to keep viewAR
          setSvgDim({
            width: dimensions.width,
            height: dimensions.width / viewAR,
          });
        } else {
          //image is taller than container, so image height should be cap set to container height
          //and image width should be set to keep viewAR
          setSvgDim({
            width: viewAR * dimensions.height,
            height: dimensions.height,
          });
        }
    
        setViewBox(`${viewBoxX} ${viewBoxY} ${viewBoxWidth} ${viewBoxHeight}`);
      }, [store, store?.points, ref, svgRef, dimensions.width, dimensions.height]);
    

    This is the svg tag in the return:

        <svg
          width={svgDim.width > 0 ? svgDim.width : "100%"}
          height={svgDim.height > 0 ? svgDim.height : "100%"}
          viewBox={viewBox}
          preserveAspectRatio="xMidYMid"
          className="border-2 border-c_disabled2 border-opacity-10"
          ref={svgRef}
        >