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