diff --git a/remix/app/components/Rainbow/Rainbow.tsx b/remix/app/components/Rainbow/Rainbow.tsx
index de9fb16..0f8252f 100644
--- a/remix/app/components/Rainbow/Rainbow.tsx
+++ b/remix/app/components/Rainbow/Rainbow.tsx
@@ -1,6 +1,7 @@
import React from "react"
import { Box, Center } from "@chakra-ui/react"
-import { GradientPath } from "gradient-path"
+
+import { GradientPath } from "~/gp/GradientPath"
export const Rainbow = (props: any): JSX.Element => {
const rainbow = ["#f34a4a", "#ffbc48", "#58ca70", "#47b5e6", "#a555e8"]
@@ -34,13 +35,49 @@ export const Rainbow = (props: any): JSX.Element => {
const svgRef = React.useRef(null)
+ const colourKeyframes = React.useMemo(() => {
+ const ret = {}
+
+ repeatedColours.map((colour, i) => {
+ const keyframe = `${(i / (repeatedColours.length - 1)) * 100}%`
+ ret[keyframe] = {
+ fill: colour,
+ stroke: colour,
+ }
+ })
+
+ return ret
+ }, [repeatedColours])
+
+ const colourClasses = React.useMemo(() => {
+ const ret = {}
+
+ const steps = 500
+
+ repeatedColours.forEach((color, i) => {
+ for (let j = 0; j < steps; j++) {
+ const nth = `:nth-child(${repeatedColours?.length * j}n+${i + j})`
+ ret[".path-segment" + nth] = {
+ // animation: `rainbow-psych 2s linear infinite`,
+ // "animation-delay": `-${(i + j) * 0.05}s`,
+ }
+ }
+ })
+
+ return ret
+ }, [repeatedColours])
+
const svg = React.useMemo(() => {
return (
{
viewBox={viewBox}
width="100%"
height="100%"
- preserveAspectRatio="none"
+ // preserveAspectRatio="none"
>
-
+
{/* {
)
- }, [pathString, strokeWidth, extraStroke, viewBox])
+ }, [
+ pathString,
+ strokeWidth,
+ extraStroke,
+ viewBox,
+ repeatedColours,
+ colors,
+ colourKeyframes,
+ colourClasses,
+ ])
React.useEffect(() => {
const path = svgRef.current.querySelector("rect")
@@ -72,9 +126,9 @@ export const Rainbow = (props: any): JSX.Element => {
if (path) {
const gp = new GradientPath({
path,
- segments: props?.segments || 2000,
- samples: props?.samples || 10,
- precision: props?.precision || 10,
+ segments: props?.segments || 250,
+ samples: props?.samples || 5,
+ precision: props?.precision || 5,
})
const colors = repeatedColours?.map((color, idx) => {
@@ -87,46 +141,18 @@ export const Rainbow = (props: any): JSX.Element => {
gp.render({
type: "path",
width: 10,
- fill: colors,
- strokeWidth: 1,
- stroke: colors,
+ fill: ["orange", "blue", "orange"],
+ // fill: colors,
+ strokeWidth: 0.5,
+ stroke: ["orange", "blue", "orange"],
+ // stroke: colors,
})
- let prevColours = colors
-
- const interval = setInterval(() => {
- // pop first colour and append new first colour to end
- const prevColoursClone = prevColours.map((colour) => {
- return {
- ...colour,
- }
- })
- const newColoursStart = prevColoursClone.slice(1)
- const newColours = [...newColoursStart, { ...newColoursStart[0] }]
-
- const adjustedPosition = newColours.map((colour, idx) => {
- return {
- ...colour,
- pos: idx / (newColours.length - 1),
- }
- })
-
- prevColours = adjustedPosition
-
- gp.render({
- type: "path",
- width: 10,
- fill: adjustedPosition,
- strokeWidth: 1,
- stroke: adjustedPosition,
- })
- }, 1000)
-
return () => {
- clearInterval(interval)
+ // clearInterval(interval)
}
}
- }, [])
+ }, [props, repeatedColours])
return (
samples)
+
+ for (let j = 0; j < renderCycle.data.length; j++) {
+ const { x, y, progress } = renderCycle.data[j]
+
+ // Create a circle for each sample and append it to its elemGroup
+ elemGroup.appendChild(
+ svgElem("circle", {
+ class: "circle-sample",
+ cx: x,
+ cy: y,
+ r: width / 2,
+ ...styleAttrs(fill, stroke, strokeWidth, progress),
+ })
+ )
+ }
+ }
+
+ // Save the information in the current renderCycle and pop it onto the renders array
+ this.renders.push(renderCycle)
+
+ // Return this for method chaining
+ return this
+ }
+}
diff --git a/remix/app/gp/Sample.js b/remix/app/gp/Sample.js
new file mode 100644
index 0000000..76432d3
--- /dev/null
+++ b/remix/app/gp/Sample.js
@@ -0,0 +1,8 @@
+export default class Sample {
+ constructor({ x, y, progress, segment }) {
+ this.x = x;
+ this.y = y;
+ this.progress = progress;
+ this.segment = segment;
+ }
+}
diff --git a/remix/app/gp/Segment.js b/remix/app/gp/Segment.js
new file mode 100644
index 0000000..310902c
--- /dev/null
+++ b/remix/app/gp/Segment.js
@@ -0,0 +1,8 @@
+import { getMiddleSample } from './_utils';
+
+export default class Segment {
+ constructor({ samples }) {
+ this.samples = samples;
+ this.progress = getMiddleSample(samples).progress;
+ }
+}
diff --git a/remix/app/gp/_constants.js b/remix/app/gp/_constants.js
new file mode 100644
index 0000000..ed3a507
--- /dev/null
+++ b/remix/app/gp/_constants.js
@@ -0,0 +1 @@
+export const DEFAULT_PRECISION = 2;
diff --git a/remix/app/gp/_data.js b/remix/app/gp/_data.js
new file mode 100644
index 0000000..e0256d6
--- /dev/null
+++ b/remix/app/gp/_data.js
@@ -0,0 +1,219 @@
+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;
+};
diff --git a/remix/app/gp/_utils.js b/remix/app/gp/_utils.js
new file mode 100644
index 0000000..181fd60
--- /dev/null
+++ b/remix/app/gp/_utils.js
@@ -0,0 +1,71 @@
+import tinygradient from 'tinygradient';
+
+// An internal function to help with easily creating SVG elements with an object of attributes
+export const svgElem = (type, attrs) => {
+ const elem = document.createElementNS('http://www.w3.org/2000/svg', type),
+ attributes = Object.keys(attrs);
+
+ for (let i = 0; i < attributes.length; i++) {
+ const attr = attributes[i];
+
+ elem.setAttribute(attr, attrs[attr]);
+ }
+
+ return elem;
+};
+
+// An internal function to help with the repetition of adding fill, stroke, and stroke-width attributes
+export const styleAttrs = (fill, stroke, strokeWidth, progress) => {
+ const determineColor = (type, progress) =>
+ typeof type === 'string' ? type : tinygradient(type).rgbAt(progress);
+
+ const attrs = {};
+
+ if (stroke) {
+ attrs['stroke'] = determineColor(stroke, progress);
+ attrs['stroke-width'] = strokeWidth;
+ }
+
+ if (fill) {
+ attrs['fill'] = determineColor(fill, progress);
+ }
+
+ return attrs;
+};
+
+// An internal function to convert any array of samples into a "d" attribute to be passed to an SVG path
+export const segmentToD = samples => {
+ let d = '';
+
+ for (let i = 0; i < samples.length; i++) {
+ const { x, y } = samples[i],
+ prevSample = i === 0 ? null : samples[i - 1];
+
+ if (i === 0 && i !== samples.length - 1) {
+ d += `M${x},${y}`;
+ } else if (x !== prevSample.x && y !== prevSample.y) {
+ d += `L${x},${y}`;
+ } else if (x !== prevSample.x) {
+ d += `H${x}`;
+ } else if (y !== prevSample.y) {
+ d += `V${y}`;
+ }
+
+ if (i === samples.length - 1) {
+ d += 'Z';
+ }
+ }
+
+ return d;
+};
+
+// An internal function for getting the colors of a segment, we need to get middle most sample (sorted by progress along the path)
+export const getMiddleSample = samples => {
+ const sortedSamples = [...samples].sort((a, b) => a.progress - b.progress);
+
+ return sortedSamples[(sortedSamples.length / 2) | 0];
+};
+
+// An internal function for converting any D3 selection or DOM-like element into a DOM node
+export const convertPathToNode = path =>
+ path instanceof Element || path instanceof HTMLDocument ? path : path.node();
diff --git a/remix/app/gp/index.js b/remix/app/gp/index.js
new file mode 100644
index 0000000..6d907e1
--- /dev/null
+++ b/remix/app/gp/index.js
@@ -0,0 +1,2 @@
+export { default as GradientPath } from './GradientPath';
+export { getData, strokeToFill } from './_data';
diff --git a/remix/package.json b/remix/package.json
index 9c39a39..2bcca82 100644
--- a/remix/package.json
+++ b/remix/package.json
@@ -20,6 +20,7 @@
"react": "^18.2.0",
"react-click-away-listener": "^2.2.3",
"react-dom": "^18.2.0",
+ "tinygradient": "^1.1.5",
"uuid": "^9.0.0"
},
"devDependencies": {
diff --git a/remix/pnpm-lock.yaml b/remix/pnpm-lock.yaml
index 3627028..7ff7889 100644
--- a/remix/pnpm-lock.yaml
+++ b/remix/pnpm-lock.yaml
@@ -41,6 +41,9 @@ dependencies:
react-dom:
specifier: ^18.2.0
version: 18.2.0(react@18.2.0)
+ tinygradient:
+ specifier: ^1.1.5
+ version: 1.1.5
uuid:
specifier: ^9.0.0
version: 9.0.0