kute.js/src/components/svgMorph.js
thednp 2a5bac2bb3 Changes V2.2.0:
* major JSDoc write up
* removed ESLint `no-bitwise` exception, it only applies to specific functions and not the entire code
* the `SVGCubicMorph` component will remove un-necessary `Z` path commands when is the case for better out of the box animation
* fixed a minor disambiguation with `filterEffects` and `drop-shadow` property and its `dropshadow` interpolation function
* TypeScript strong: all files are modules, easy to implement in any third party app
* updated `CubicBezier` and SVGPathCommander
* code cleanup
2021-12-08 23:43:31 +02:00

378 lines
11 KiB
JavaScript

import pathToCurve from 'svg-path-commander/src/convert/pathToCurve';
import pathToString from 'svg-path-commander/src/convert/pathToString';
import normalizePath from 'svg-path-commander/src/process/normalizePath';
import splitPath from 'svg-path-commander/src/process/splitPath';
import roundPath from 'svg-path-commander/src/process/roundPath';
import invalidPathValue from 'svg-path-commander/src/parser/invalidPathValue';
import getPathLength from 'svg-path-commander/src/util/getPathLength';
import getPointAtLength from 'svg-path-commander/src/util/getPointAtLength';
import getDrawDirection from 'svg-path-commander/src/util/getDrawDirection';
import epsilon from 'svg-path-commander/src/math/epsilon';
import midPoint from 'svg-path-commander/src/math/midPoint';
import distanceSquareRoot from 'svg-path-commander/src/math/distanceSquareRoot';
import { onStartSVGMorph } from './svgMorphBase';
import coords from '../interpolation/coords';
import defaultOptions from '../objects/defaultOptions';
import selector from '../util/selector';
// Component Util
// original script flubber
// https://github.com/veltman/flubber
/**
* Returns polygon length.
* @param {KUTE.polygonMorph} polygon target polygon
* @returns {number} length
*/
function polygonLength(polygon) {
return polygon.reduce((length, point, i) => (i
? length + distanceSquareRoot(polygon[i - 1], point)
: 0), 0);
}
/**
* Returns an existing polygin and its length or false if not polygon.
* @param {SVGPathCommander.pathArray} pathArray target polygon
* @returns {KUTE.exactRing} length
*/
function exactRing(pathArray) {
const polygon = [];
const pathlen = pathArray.length;
let segment = [];
let pathCommand = '';
if (!pathArray.length || pathArray[0][0] !== 'M') {
return false;
}
for (let i = 0; i < pathlen; i += 1) {
segment = pathArray[i];
[pathCommand] = segment;
if ((pathCommand === 'M' && i) || pathCommand === 'Z') {
break; // !!
} else if ('ML'.includes(pathCommand)) {
polygon.push([segment[1], segment[2]]);
} else {
return false;
}
}
return pathlen ? { polygon } : false;
}
/**
* Returns polygon length.
* @param {SVGPathCommander.pathArray} parsed target polygon
* @param {number} maxLength the maximum segment length
* @returns {KUTE.exactRing} length
*/
function approximatePolygon(parsed, maxLength) {
const ringPath = splitPath(pathToString(parsed))[0];
const curvePath = pathToCurve(ringPath);
const pathLength = getPathLength(curvePath);
const polygon = [];
let numPoints = 3;
let point;
if (maxLength && !Number.isNaN(maxLength) && +maxLength > 0) {
numPoints = Math.max(numPoints, Math.ceil(pathLength / maxLength));
}
for (let i = 0; i < numPoints; i += 1) {
point = getPointAtLength(curvePath, (pathLength * i) / numPoints);
polygon.push([point.x, point.y]);
}
// Make all rings clockwise
if (!getDrawDirection(curvePath)) {
polygon.reverse();
}
return {
polygon,
skipBisect: true,
};
}
/**
* Parses a path string and returns a polygon array.
* @param {string} str path string
* @param {number} maxLength maximum amount of points
* @returns {KUTE.exactRing} the polygon array we need
*/
function pathStringToPolygon(str, maxLength) {
const parsed = normalizePath(str);
return exactRing(parsed) || approximatePolygon(parsed, maxLength);
}
/**
* Rotates a polygon to better match its pair.
* @param {KUTE.polygonMorph} polygon the target polygon
* @param {KUTE.polygonMorph} vs the reference polygon
*/
function rotatePolygon(polygon, vs) {
const len = polygon.length;
let min = Infinity;
let bestOffset;
let sumOfSquares = 0;
let spliced;
let d;
let p;
for (let offset = 0; offset < len; offset += 1) {
sumOfSquares = 0;
for (let i = 0; i < vs.length; i += 1) {
p = vs[i];
d = distanceSquareRoot(polygon[(offset + i) % len], p);
sumOfSquares += d * d;
}
if (sumOfSquares < min) {
min = sumOfSquares;
bestOffset = offset;
}
}
if (bestOffset) {
spliced = polygon.splice(0, bestOffset);
polygon.splice(polygon.length, 0, ...spliced);
}
}
/**
* Sample additional points for a polygon to better match its pair.
* @param {KUTE.polygonObject} polygon the target polygon
* @param {number} numPoints the amount of points needed
*/
function addPoints(polygon, numPoints) {
const desiredLength = polygon.length + numPoints;
const step = polygonLength(polygon) / numPoints;
let i = 0;
let cursor = 0;
let insertAt = step / 2;
let a;
let b;
let segment;
while (polygon.length < desiredLength) {
a = polygon[i];
b = polygon[(i + 1) % polygon.length];
segment = distanceSquareRoot(a, b);
if (insertAt <= cursor + segment) {
polygon.splice(i + 1, 0, segment
? midPoint(a, b, (insertAt - cursor) / segment)
: a.slice(0));
insertAt += step;
} else {
cursor += segment;
i += 1;
}
}
}
/**
* Split segments of a polygon until it reaches a certain
* amount of points.
* @param {number[][]} polygon the target polygon
* @param {number} maxSegmentLength the maximum amount of points
*/
function bisect(polygon, maxSegmentLength = Infinity) {
let a = [];
let b = [];
for (let i = 0; i < polygon.length; i += 1) {
a = polygon[i];
b = i === polygon.length - 1 ? polygon[0] : polygon[i + 1];
// Could splice the whole set for a segment instead, but a bit messy
while (distanceSquareRoot(a, b) > maxSegmentLength) {
b = midPoint(a, b, 0.5);
polygon.splice(i + 1, 0, b);
}
}
}
/**
* Checks the validity of a polygon.
* @param {KUTE.polygonMorph} polygon the target polygon
* @returns {boolean} the result of the check
*/
function validPolygon(polygon) {
return Array.isArray(polygon)
&& polygon.every((point) => Array.isArray(point)
&& point.length === 2
&& !Number.isNaN(point[0])
&& !Number.isNaN(point[1]));
}
/**
* Returns a new polygon and its length from string or another `Array`.
* @param {KUTE.polygonMorph | string} input the target polygon
* @param {number} maxSegmentLength the maximum amount of points
* @returns {KUTE.polygonMorph} normalized polygon
*/
function getPolygon(input, maxSegmentLength) {
let skipBisect;
let polygon;
if (typeof (input) === 'string') {
const converted = pathStringToPolygon(input, maxSegmentLength);
({ polygon, skipBisect } = converted);
} else if (!Array.isArray(input)) {
throw Error(`${invalidPathValue}: ${input}`);
}
/** @type {KUTE.polygonMorph} */
const points = [...polygon];
if (!validPolygon(points)) {
throw Error(`${invalidPathValue}: ${points}`);
}
// TODO skip this test to avoid scale issues?
// Chosen epsilon (1e-6) is problematic for small coordinate range, we now use 1e-9
if (points.length > 1 && distanceSquareRoot(points[0], points[points.length - 1]) < epsilon) {
points.pop();
}
if (!skipBisect && maxSegmentLength
&& !Number.isNaN(maxSegmentLength) && (+maxSegmentLength) > 0) {
bisect(points, maxSegmentLength);
}
return points;
}
/**
* Returns two new polygons ready to tween.
* @param {string} path1 the first path string
* @param {string} path2 the second path string
* @param {number} precision the morphPrecision option value
* @returns {KUTE.polygonMorph[]} the two polygons
*/
function getInterpolationPoints(path1, path2, precision) {
const morphPrecision = precision || defaultOptions.morphPrecision;
const fromRing = getPolygon(path1, morphPrecision);
const toRing = getPolygon(path2, morphPrecision);
const diff = fromRing.length - toRing.length;
addPoints(fromRing, diff < 0 ? diff * -1 : 0);
addPoints(toRing, diff > 0 ? diff : 0);
rotatePolygon(fromRing, toRing);
return [roundPath(fromRing), roundPath(toRing)];
}
// Component functions
/**
* Returns the current `d` attribute value.
* @returns {string} the `d` attribute value
*/
function getSVGMorph(/* tweenProp */) {
return this.element.getAttribute('d');
}
/**
* Returns the property tween object.
* @param {string} _ the property name
* @param {string | KUTE.polygonObject} value the property value
* @returns {KUTE.polygonObject} the property tween object
*/
function prepareSVGMorph(/* tweenProp */_, value) {
const pathObject = {};
// remove newlines, they brake JSON strings sometimes
const pathReg = new RegExp('\\n', 'ig');
let elem = null;
if (value instanceof SVGPathElement) {
elem = value;
} else if (/^\.|^#/.test(value)) {
elem = selector(value);
}
// first make sure we return pre-processed values
if (typeof (value) === 'object' && value.polygon) {
return value;
} if (elem && ['path', 'glyph'].includes(elem.tagName)) {
pathObject.original = elem.getAttribute('d').replace(pathReg, '');
// maybe it's a string path already
} else if (!elem && typeof (value) === 'string') {
pathObject.original = value.replace(pathReg, '');
}
return pathObject;
}
/**
* Enables the `to()` method by preparing the tween object in advance.
* @param {string} prop the `path` property name
*/
function crossCheckSVGMorph(prop) {
if (this.valuesEnd[prop]) {
const pathArray1 = this.valuesStart[prop].polygon;
const pathArray2 = this.valuesEnd[prop].polygon;
// skip already processed paths
// allow the component to work with pre-processed values
if (!pathArray1 || !pathArray2
|| (pathArray1 && pathArray2 && pathArray1.length !== pathArray2.length)) {
const p1 = this.valuesStart[prop].original;
const p2 = this.valuesEnd[prop].original;
// process morphPrecision
const morphPrecision = this._morphPrecision
? parseInt(this._morphPrecision, 10)
: defaultOptions.morphPrecision;
const [path1, path2] = getInterpolationPoints(p1, p2, morphPrecision);
this.valuesStart[prop].polygon = path1;
this.valuesEnd[prop].polygon = path2;
}
}
}
// All Component Functions
const svgMorphFunctions = {
prepareStart: getSVGMorph,
prepareProperty: prepareSVGMorph,
onStart: onStartSVGMorph,
crossCheck: crossCheckSVGMorph,
};
// Component Full
const SVGMorph = {
component: 'svgMorph',
property: 'path',
defaultValue: [],
Interpolate: coords,
defaultOptions: { morphPrecision: 10 },
functions: svgMorphFunctions,
// Export utils to global for faster execution
Util: {
// component
addPoints,
bisect,
getPolygon,
validPolygon,
getInterpolationPoints,
pathStringToPolygon,
distanceSquareRoot,
midPoint,
approximatePolygon,
rotatePolygon,
// svg-path-commander
pathToString,
pathToCurve,
getPathLength,
getPointAtLength,
getDrawDirection,
roundPath,
},
};
export default SVGMorph;