import { DEFAULT_PRECISION } from "./_constants" import { convertPathToNode } from "./_utils" import Sample from "./Sample" import Segment from "./Segment" // The main function responsible for getting data // This will take a path, number of samples, number of samples, and a precision value // It will return an array of Segments, which in turn contains an array of Samples // This can later be used to generate a stroked path, converted to outlines for a filled path, or flattened for plotting SVG circles export const getData = ({ path, segments, samples, precision = DEFAULT_PRECISION, }) => { // Convert the given path to a DOM node if it isn't already one path = convertPathToNode(path) // We decrement the number of samples per segment because when we group them later we will add on the first sample of the following segment if (samples > 1) samples-- // Get total length of path, total number of samples we will be generating, and two blank arrays to hold samples and segments const pathLength = path.getTotalLength(), totalSamples = segments * samples, allSamples = [], allSegments = [] // For the number of total samples, get the x, y, and progress values for each sample along the path for (let sample = 0; sample <= totalSamples; sample++) { const progress = sample / totalSamples let { x, y } = path.getPointAtLength(progress * pathLength) // If the user asks to round our x and y values, do so if (precision) { x = +x.toFixed(precision) y = +y.toFixed(precision) } // Create a new Sample and push it onto the allSamples array allSamples.push(new Sample({ x, y, progress })) } // Out of all the samples gathered previously, sort them into groups of segments // Each group includes the samples of the current segment, with the last sample being first sample from the next segment for (let segment = 0; segment < segments; segment++) { const currentStart = segment * samples, nextStart = currentStart + samples, segmentSamples = [] // Push all current samples onto segmentSamples for (let samInSeg = 0; samInSeg < samples; samInSeg++) { segmentSamples.push(allSamples[currentStart + samInSeg]) } // Push the first sample from the next segment onto segmentSamples segmentSamples.push(allSamples[nextStart]) // Create a new Segment with the samples from segmentSamples allSegments.push(new Segment({ samples: segmentSamples })) } // Return our group of segments return allSegments } // The function responsible for converting strokable data (from getData()) into fillable data // This allows any SVG path to be filled instead of just stroked, allowing for the user to fill and stroke paths simultaneously // We start by outlining the stroked data given a specified width and the we average together the edges where adjacent segments touch export const strokeToFill = (data, width, precision, pathClosed) => { const outlinedStrokes = outlineStrokes(data, width, precision), averagedSegmentJoins = averageSegmentJoins( outlinedStrokes, precision, pathClosed ) return averagedSegmentJoins } // An internal function for outlining stroked data const outlineStrokes = (data, width, precision) => { // We need to get the points perpendicular to a startPoint, given an angle, radius, and precision const getPerpSamples = (angle, radius, precision, startPoint) => { const p0 = new Sample({ ...startPoint, x: Math.sin(angle) * radius + startPoint.x, y: -Math.cos(angle) * radius + startPoint.y, }), p1 = new Sample({ ...startPoint, x: -Math.sin(angle) * radius + startPoint.x, y: Math.cos(angle) * radius + startPoint.y, }) // If the user asks to round our x and y values, do so if (precision) { p0.x = +p0.x.toFixed(precision) p0.y = +p0.y.toFixed(precision) p1.x = +p1.x.toFixed(precision) p1.y = +p1.y.toFixed(precision) } return [p0, p1] } // We need to set the radius (half of the width) and have a holding array for outlined Segments const radius = width / 2, outlinedData = [] for (let i = 0; i < data.length; i++) { const samples = data[i].samples, segmentSamples = [] // For each sample point and the following sample point (if there is one) compute the angle // Also compute the sample's various perpendicular points (with a distance of radius away from the sample point) for (let j = 0; j < samples.length; j++) { // If we're at the end of the segment and there are no further points, get outta here! if (samples[j + 1] === undefined) break const p0 = samples[j], // First point p1 = samples[j + 1], // Second point angle = Math.atan2(p1.y - p0.y, p1.x - p0.x), // Perpendicular angle to p0 and p1 p0Perps = getPerpSamples(angle, radius, precision, p0), // Get perpedicular points with a distance of radius away from p0 p1Perps = getPerpSamples(angle, radius, precision, p1) // Get perpedicular points with a distance of radius away from p1 // We only need the p0 perpendenciular points for the first sample // The p0 for j > 0 will always be the same as p1 anyhow, so let's not add redundant points if (j === 0) { segmentSamples.push(...p0Perps) } // Always push the second sample point's perpendicular points segmentSamples.push(...p1Perps) } // segmentSamples is out of order... // Given a segmentSamples length of 8, the points need to be rearranged from: 0, 2, 4, 6, 7, 5, 3, 1 outlinedData.push( new Segment({ samples: [ ...segmentSamples.filter((s, i) => i % 2 === 0), ...segmentSamples.filter((s, i) => i % 2 === 1).reverse(), ], }) ) } return outlinedData } // An internal function taking outlinedData (from outlineStrokes()) and averaging adjacent edges // If we didn't do this, our data would be fillable, but it would look stroked // This function fixes where segments overlap and underlap each other const averageSegmentJoins = (outlinedData, precision, pathClosed) => { // Find the average x and y between two points (p0 and p1) const avg = (p0, p1) => ({ x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2, }) // Recombine the new x and y positions with all the other keys in the object const combine = (segment, pos, avg) => ({ ...segment[pos], x: avg.x, y: avg.y, }) const init_outlinedData = JSON.parse(JSON.stringify(outlinedData)) //clone initial outlinedData Object for (let i = 0; i < outlinedData.length; i++) { // If path is closed: the current segment's samples; // If path is open: the current segments' samples, as long as it's not the last segment; Otherwise, the current segments' sample of the initial outlinedData object const currentSamples = pathClosed ? outlinedData[i].samples : outlinedData[i + 1] ? outlinedData[i].samples : init_outlinedData[i].samples, // If path is closed: the next segment's samples, otherwise, the first segment's samples // If path is open: the next segment's samples, otherwise, the first segment's samples of the initial outlinedData object nextSamples = pathClosed ? outlinedData[i + 1] ? outlinedData[i + 1].samples : outlinedData[0].samples : outlinedData[i + 1] ? outlinedData[i + 1].samples : init_outlinedData[0].samples, currentMiddle = currentSamples.length / 2, // The "middle" sample in the current segment's samples nextEnd = nextSamples.length - 1 // The last sample in the next segment's samples // Average two sets of outlined samples to create p0Average and p1Average const p0Average = avg(currentSamples[currentMiddle - 1], nextSamples[0]), p1Average = avg(currentSamples[currentMiddle], nextSamples[nextEnd]) // If the user asks to round our x and y values, do so if (precision) { p0Average.x = +p0Average.x.toFixed(precision) p0Average.y = +p0Average.y.toFixed(precision) p1Average.x = +p1Average.x.toFixed(precision) p1Average.y = +p1Average.y.toFixed(precision) } // Replace the previous values with new Samples currentSamples[currentMiddle - 1] = new Sample({ ...combine(currentSamples, currentMiddle - 1, p0Average), }) currentSamples[currentMiddle] = new Sample({ ...combine(currentSamples, currentMiddle, p1Average), }) nextSamples[0] = new Sample({ ...combine(nextSamples, 0, p0Average), }) nextSamples[nextEnd] = new Sample({ ...combine(nextSamples, nextEnd, p1Average), }) } return outlinedData }