Pattern Filler Tool
Technologies Used:
- Vue.js
- Pinia
- XState
- Web Workers
- HTML Canvas with OffscreenCanvas
The pattern filler is a web app where you can upload any SVG shape and pack it with circles. You get to play around with how it does this, adjusting things like how big or small the circles are, how much space is between them, and how they're arranged—either randomly scattered or neatly spaced out.
I built it using Vue.js for the UI components, with Pinia for state management and XState for handling the state transitions in the UI. A Web Worker takes over the heavy lifting of calculating where all the circles fit based on your settings. This way, the app doesn't get bogged down, even if you're creating a design with thousands of circles. To determine whether the circles will fit within the SVG paths, the SVG is drawn to an offscreen HTML Canvas, which lets the app figure out where the circles can fit within the SVG paths by using canvas methods like getImageData without slowing down or freezing up. It also renders the SVG and supports click events on the paths and packed circles, allowing users to further refine what they want to pack and the colors used for the circles. Rendering the circles within the SVG allows users to download the final design with the packed circles as an SVG for further adjustments in Adobe Illustrator or a similar tool.
Writing the algorithm to evenly pack the circles, taking into account unique shapes and complexities around determining the distance from the circles to the SVG paths, was tricky but I enjoyed the challenge. For the even packing method, I create a grid structure and keep track of the circles within the grid as I iterate over each pixel within a path. For each pixel, I check the neighboring circles on all sides of the current pixel and account for the surrounding circle sizes and positions when determining the size and position for the new circle. In addition, I include the correct distance between circles (based on a user-defined parameter) to make sure that circles don't overlap.
Initially, determining how close a position is from the edge of the SVG path seemed tricky. I first tried to use the getImageData
method on the HTML canvas element provides information on whether a pixel is filled in (i.e., has a color) or not, but this method doesn't tell you if the pixel is on the edge of a filled space. Then, I discovered that the HTML canvas also has a method called isPointInStroke
that will tell you if an (x, y) coordinate is within the stroke of a path with a stroke. Using a combination of isPointInStroke
with isPointInPath
on the canvas element allowed me to ensure that each circle would be both within the path and not overlapping with the stroke around the path.
In the original version of the app, I ran the algorithm to generate the circles in the browser on the main thread. This was sufficient for scenarios where not many circles were generated, but it was very sluggish in other cases. When blocking the main thread for long-running tasks, you can't do other things with the UI, like show a progress indicator or handle user interactions. That's why I ultimately moved the algorithm for generating the circles into a Web Worker.
My husband, a digital artist, uses the tool as part of his process for creating digital art pieces.
Challenges
Too Many DOM Elements
The primary challenge right now is making sure it doesn't overwhelm the browser with too many elements at once. If you have a path with a bounding box that's 500px x 500px and you set the minimum circle radius to under 1px, it could generate thousands of circles that then need to be rendered as circle SVG elements within the SVG. The browser DOM can only handle so many elements at a time, though, so this can cause the browser to become sluggish or crash in the worst case scenario.
One way this is commonly solved for things like tables that have thousands of rows is to use a virtualized implementation. With a virtualized table, for instance, only a set number of rows are ever rendered in the DOM at a time. The table dynamically loads row data as the user scrolls, always keeping data for a few rows above and below the visible rows available for smooth scrolling. This approach won't work for the pattern filler tool, though, because the entire artwork is generally visible all at once and the user experience would be strange if not all of the circles were visible at once.
I'm currently prototyping a solution where the tool renders the SVG and the packed circles in a HTML canvas element to show the output to users. Drawing to the canvas element is fairly quick and won't have the browser memory limitations encountered when rendering lots of DOM elements. The circles, with their sizes and positions, would still be stored in an array. The tool could still facilitate click interactions that let users select paths and circles by capturing the click position, searching the path data and circle array for elements under the click position, and drawing a bounding box around relevent elements on the canvas. It's not as straightforward to find the selected items, but it's very possible. Then, to facilitate downloading the SVG with the packed circles, the circle array could be used to insert the circles in the SVG as part of the export process, without needing to draw them to the screen. I'm planning to share more about my progress on this in a future blog post.
Responsive Design around Processing Capabilities
Right now, the tool is specifically designed to be used on desktop/laptop computers and larger tablets. Eventually, it would be nice to bring the experience to devices with smaller screens as well. There will be some work to do in terms of the UI, like resizing the HTML canvas and the SVG paths appropriately. If those aren't resized together correctly, then the circles won't be packed within the correct coordinates.
However, the biggest challenge to making the app responsive will be around processing capabilities. For devices with memory and/or power constraints, it will be difficult for the packing algorithms to run and for the browser to be able to display all of the elements using the current approach of rendering the circles within the SVG.
Re-adjusting the Packed Circles to Account for Stroke Width
Currently, if you set a stroke width on the circles before packing the paths, the packing algorithms will take the stroke width into account with packing the circles. If you change the stroke width on the circles after packing the paths, though, the algorithm isn't re-run, so you can end up with overlapping circles. This might be desired for some designs, but not in others. I'm exploring the idea of adding an option to adjust the circle positions when applying a different stroke width after the packing process has run.