import Sample from './Sample';
import Segment from './Segment';
import { convertPathToNode } from './_utils';
import { DEFAULT_PRECISION } from './_constants';
// 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;
};