feat: feature/rainbow-component Added Gradient Path locally and started work on rainbow effect wrapper component

This commit is contained in:
Nikolaj Frey 2023-07-05 14:49:14 +10:00
parent 49e6152c28
commit 302302fa8f
10 changed files with 477 additions and 44 deletions

View File

@ -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 (
<Box
sx={{
"svg g:nth-child(2)": {
display: "none",
rainbowPsych: {
rainbowPsych: "rainbowPsych",
},
"@keyframes rainbow-psych": {
...colourKeyframes,
},
...colourClasses,
}}
width="100%"
height="100%"
@ -51,9 +88,17 @@ export const Rainbow = (props: any): JSX.Element => {
viewBox={viewBox}
width="100%"
height="100%"
preserveAspectRatio="none"
// preserveAspectRatio="none"
>
<rect x={0} y={0} width={100} height={100} rx={10} ry={10}></rect>
<rect
// stroke="url(#linear-gradient)"
x={0}
y={0}
width={100}
height={100}
rx={10}
ry={10}
></rect>
{/* <path
fill="none"
stroke="blue"
@ -64,7 +109,16 @@ export const Rainbow = (props: any): JSX.Element => {
</svg>
</Box>
)
}, [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 (
<Center

View File

@ -0,0 +1,94 @@
import { DEFAULT_PRECISION } from "./_constants"
import { getData, strokeToFill } from "./_data"
import { convertPathToNode, segmentToD, styleAttrs, svgElem } from "./_utils"
export const GradientPath = class {
constructor({ path, segments, samples, precision = DEFAULT_PRECISION }) {
// If the path being passed isn't a DOM node already, make it one
this.path = convertPathToNode(path)
this.segments = segments
this.samples = samples
this.precision = precision
// Check if nodeName is path and that the path is closed, otherwise it's closed by default
this.pathClosed =
this.path.nodeName == "path"
? this.path.getAttribute("d").match(/z/gi)
: true
// Store the render cycles that the user creates
this.renders = []
// Append a group to the SVG to capture everything we render and ensure our paths and circles are properly encapsulated
this.svg = path.closest("svg")
this.group = svgElem("g", {
class: "gradient-path",
})
// Get the data
this.data = getData({ path, segments, samples, precision })
// Append the main group to the SVG
this.svg.appendChild(this.group)
// Remove the main path once we have the data values
this.path.parentNode.removeChild(this.path)
}
render({ type, stroke, strokeWidth, fill, width }) {
// Store information from this render cycle
const renderCycle = {}
// Create a group for each element
const elemGroup = svgElem("g", { class: `element-${type}` })
this.group.appendChild(elemGroup)
renderCycle.group = elemGroup
if (type === "path") {
// If we specify a width and fill, then we need to outline the path and then average the join points of the segments
// If we do not specify a width and fill, then we will be stroking and can leave the data "as is"
renderCycle.data =
width && fill
? strokeToFill(this.data, width, this.precision, this.pathClosed)
: this.data
for (let j = 0; j < renderCycle.data.length; j++) {
const { samples, progress } = renderCycle.data[j]
// Create a path for each segment and append it to its elemGroup
elemGroup.appendChild(
svgElem("path", {
class: "path-segment",
d: segmentToD(samples),
...styleAttrs(fill, stroke, strokeWidth, progress),
})
)
}
} else if (type === "circle") {
renderCycle.data = this.data.flatMap(({ samples }) => 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
}
}

8
remix/app/gp/Sample.js Normal file
View File

@ -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;
}
}

8
remix/app/gp/Segment.js Normal file
View File

@ -0,0 +1,8 @@
import { getMiddleSample } from './_utils';
export default class Segment {
constructor({ samples }) {
this.samples = samples;
this.progress = getMiddleSample(samples).progress;
}
}

View File

@ -0,0 +1 @@
export const DEFAULT_PRECISION = 2;

219
remix/app/gp/_data.js Normal file
View File

@ -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;
};

71
remix/app/gp/_utils.js Normal file
View File

@ -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();

2
remix/app/gp/index.js Normal file
View File

@ -0,0 +1,2 @@
export { default as GradientPath } from './GradientPath';
export { getData, strokeToFill } from './_data';

View File

@ -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": {

View File

@ -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