@tsparticles/plugin-polygon-mask
Version:
tsParticles polygon mask plugin
410 lines (409 loc) • 15.8 kB
JavaScript
import "./pathseg.js";
import { OutModeDirection, deepExtend, double, getDistance, getDistances, getRandom, half, isArray, isString, itemFromArray, originPoint, percentDenominator, safeDocument, } from "@tsparticles/engine";
import { calcClosestPointOnSegment, drawPolygonMask, drawPolygonMaskPath, parsePaths, segmentBounce } from "./utils.js";
import { PolygonMaskInlineArrangement } from "./Enums/PolygonMaskInlineArrangement.js";
import { PolygonMaskType } from "./Enums/PolygonMaskType.js";
const noPolygonDataLoaded = `No polygon data loaded.`, noPolygonFound = `No polygon found, you need to specify SVG url in config.`;
export class PolygonMaskInstance {
dimension;
offset;
paths;
raw;
redrawTimeout;
#container;
#moveRadius;
#pluginManager;
#scale;
constructor(pluginManager, container) {
this.#container = container;
this.#pluginManager = pluginManager;
this.dimension = {
height: 0,
width: 0,
};
this.#moveRadius = 0;
this.#scale = 1;
}
clickPositionValid(position) {
const options = this.#container.actualOptions.polygon;
return (!!options?.enable &&
options.type !== PolygonMaskType.none &&
options.type !== PolygonMaskType.inline &&
this.#checkInsidePolygon(position));
}
draw(context) {
if (!this.paths?.length) {
return;
}
const options = this.#container.actualOptions.polygon;
if (!options?.enable) {
return;
}
const polygonDraw = options.draw;
if (!polygonDraw.enable) {
return;
}
const rawData = this.raw;
for (const path of this.paths) {
const path2d = path.path2d;
if (path2d && this.offset) {
drawPolygonMaskPath(this.#pluginManager, context, path2d, polygonDraw.stroke, this.offset, this.#container.hdr);
}
else if (rawData) {
drawPolygonMask(this.#pluginManager, context, rawData, polygonDraw.stroke, this.#container.hdr);
}
}
}
async init() {
const container = this.#container, polygonMaskOptions = container.actualOptions.polygon, pxRatio = container.retina.pixelRatio;
if (!polygonMaskOptions) {
return;
}
this.#moveRadius = polygonMaskOptions.move.radius * pxRatio;
this.#scale = polygonMaskOptions.scale * pxRatio;
if (polygonMaskOptions.enable) {
await this.#initRawData();
}
}
particleBounce(particle, delta, direction) {
return this.#polygonBounce(particle, delta, direction);
}
particlePosition(position) {
const options = this.#container.actualOptions.polygon, defaultLength = 0;
if (!(options?.enable && (this.raw?.length ?? defaultLength) > defaultLength)) {
return;
}
return deepExtend({}, position ?? this.#randomPoint());
}
particlesInitialization() {
const options = this.#container.actualOptions.polygon;
if (options?.enable &&
options.type === PolygonMaskType.inline &&
(options.inline.arrangement === PolygonMaskInlineArrangement.onePerPoint ||
options.inline.arrangement === PolygonMaskInlineArrangement.perPoint)) {
this.#drawPoints();
return true;
}
return false;
}
resize() {
const container = this.#container, options = container.actualOptions.polygon;
if (!(options?.enable && options.type !== PolygonMaskType.none)) {
return;
}
if (this.redrawTimeout) {
clearTimeout(this.redrawTimeout);
}
const timeout = 250;
this.redrawTimeout = setTimeout(() => {
void (async () => {
await this.#initRawData(true);
await container.particles.redraw();
})();
}, timeout);
}
stop() {
delete this.raw;
delete this.paths;
}
#checkInsidePolygon(position) {
const container = this.#container, options = container.actualOptions.polygon;
if (!options?.enable || options.type === PolygonMaskType.none || options.type === PolygonMaskType.inline) {
return true;
}
if (!this.raw) {
throw new Error(noPolygonFound);
}
const canvasSize = container.canvas.size, x = position?.x ?? getRandom() * canvasSize.width, y = position?.y ?? getRandom() * canvasSize.height, indexOffset = 1;
let inside = false;
for (let i = 0, j = this.raw.length - indexOffset; i < this.raw.length; j = i++) {
const pi = this.raw[i], pj = this.raw[j];
if (!pi || !pj) {
continue;
}
const intersect = pi.y > y !== pj.y > y && x < ((pj.x - pi.x) * (y - pi.y)) / (pj.y - pi.y) + pi.x;
if (intersect) {
inside = !inside;
}
}
if (options.type === PolygonMaskType.inside) {
return inside;
}
else {
return !inside;
}
}
#createPath2D() {
const container = this.#container, options = container.actualOptions.polygon;
if (!options || !this.paths?.length) {
return;
}
for (const path of this.paths) {
const pathData = path.element.getAttribute("d");
if (pathData) {
const path2d = new Path2D(pathData), matrix = safeDocument().createElementNS("http://www.w3.org/2000/svg", "svg").createSVGMatrix(), finalPath = new Path2D(), transform = matrix.scale(this.#scale);
finalPath.addPath(path2d, transform);
path.path2d = finalPath;
}
else {
delete path.path2d;
}
if (path.path2d ?? !this.raw) {
continue;
}
path.path2d = new Path2D();
const firstIndex = 0, firstPoint = this.raw[firstIndex];
if (!firstPoint) {
continue;
}
path.path2d.moveTo(firstPoint.x, firstPoint.y);
this.raw.forEach((pos, i) => {
if (i > firstIndex) {
path.path2d?.lineTo(pos.x, pos.y);
}
});
path.path2d.closePath();
}
}
async #downloadSvgPath(svgUrl, force) {
const options = this.#container.actualOptions.polygon;
if (!options) {
return;
}
const url = svgUrl ?? options.url, forceDownload = force ?? false;
if (!url || (this.paths !== undefined && !forceDownload)) {
return this.raw;
}
const req = await fetch(url);
if (!req.ok) {
throw new Error(`Error occurred during polygon mask download`);
}
return this.#parseSvgPath(await req.text(), force);
}
#drawPoints() {
if (!this.raw) {
return;
}
for (const item of this.raw) {
this.#container.particles.addParticle({
x: item.x,
y: item.y,
});
}
}
#getEquidistantPointByIndex(index) {
const container = this.#container, options = container.actualOptions, polygonMaskOptions = options.polygon;
if (!polygonMaskOptions) {
return;
}
if (!this.raw?.length || !this.paths?.length) {
throw new Error(noPolygonDataLoaded);
}
let offset = 0, point;
const baseAccumulator = 0, totalLength = this.paths.reduce((tot, path) => tot + path.length, baseAccumulator), distance = totalLength / options.particles.number.value;
for (const path of this.paths) {
const pathDistance = distance * index - offset;
if (pathDistance <= path.length) {
point = path.element.getPointAtLength(pathDistance);
break;
}
else {
offset += path.length;
}
}
const scale = this.#scale;
return {
x: (point?.x ?? originPoint.x) * scale + (this.offset?.x ?? originPoint.x),
y: (point?.y ?? originPoint.y) * scale + (this.offset?.y ?? originPoint.y),
};
}
#getPointByIndex(index) {
if (!this.raw?.length) {
throw new Error(noPolygonDataLoaded);
}
const coords = this.raw[index % this.raw.length];
if (!coords) {
return;
}
return {
x: coords.x,
y: coords.y,
};
}
#getRandomPoint() {
if (!this.raw?.length) {
throw new Error(noPolygonDataLoaded);
}
const coords = itemFromArray(this.raw);
if (!coords) {
return;
}
return {
x: coords.x,
y: coords.y,
};
}
#getRandomPointByLength() {
const container = this.#container, options = container.actualOptions.polygon;
if (!options) {
return;
}
if (!this.raw?.length || !this.paths?.length) {
throw new Error(noPolygonDataLoaded);
}
const path = itemFromArray(this.paths);
if (!path) {
return;
}
const offset = 1, distance = Math.floor(getRandom() * path.length) + offset, point = path.element.getPointAtLength(distance), scale = this.#scale;
return {
x: point.x * scale + (this.offset?.x ?? originPoint.x),
y: point.y * scale + (this.offset?.y ?? originPoint.y),
};
}
async #initRawData(force) {
const options = this.#container.actualOptions.polygon;
if (!options) {
return;
}
if (options.url) {
this.raw = await this.#downloadSvgPath(options.url, force);
}
else if (options.data) {
const data = options.data;
let svg;
if (isString(data)) {
svg = data;
}
else {
const getPath = (p) => `<path d="${p}" />`, path = isArray(data.path) ? data.path.map(getPath).join("") : getPath(data.path), namespaces = 'xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"';
svg = `<svg ${namespaces} width="${data.size.width.toString()}" height="${data.size.height.toString()}">${path}</svg>`;
}
this.raw = this.#parseSvgPath(svg, force);
}
this.#createPath2D();
this.#container.dispatchEvent("polygonMaskLoaded");
}
#parseSvgPath(xml, force) {
const forceDownload = force ?? false;
if (this.paths !== undefined && !forceDownload) {
return this.raw;
}
const container = this.#container, options = container.actualOptions.polygon;
if (!options) {
return;
}
const parser = new DOMParser(), doc = parser.parseFromString(xml, "image/svg+xml"), firstIndex = 0, svg = doc.getElementsByTagName("svg")[firstIndex];
if (!svg) {
return;
}
let svgPaths = svg.getElementsByTagName("path");
if (!svgPaths.length) {
svgPaths = doc.getElementsByTagName("path");
}
this.paths = [];
for (let i = 0; i < svgPaths.length; i++) {
const path = svgPaths.item(i);
if (path) {
this.paths.push({
element: path,
length: path.getTotalLength(),
});
}
}
const scale = this.#scale;
this.dimension.width = parseFloat(svg.getAttribute("width") ?? "0") * scale;
this.dimension.height = parseFloat(svg.getAttribute("height") ?? "0") * scale;
const position = options.position ?? {
x: 50,
y: 50,
}, canvasSize = container.canvas.size;
this.offset = {
x: (canvasSize.width * position.x) / percentDenominator - this.dimension.width * half,
y: (canvasSize.height * position.y) / percentDenominator - this.dimension.height * half,
};
return parsePaths(this.paths, scale, this.offset);
}
#polygonBounce(particle, _delta, direction) {
const options = this.#container.actualOptions.polygon;
if (!this.raw || !options?.enable || direction !== OutModeDirection.top) {
return false;
}
if (options.type === PolygonMaskType.inside || options.type === PolygonMaskType.outside) {
let closest, dx, dy;
const pos = particle.getPosition(), radius = particle.getRadius(), offset = 1;
for (let i = 0, j = this.raw.length - offset; i < this.raw.length; j = i++) {
const pi = this.raw[i], pj = this.raw[j];
if (!pi || !pj) {
continue;
}
closest = calcClosestPointOnSegment(pi, pj, pos);
const dist = getDistances(pos, closest);
[dx, dy] = [dist.dx, dist.dy];
if (dist.distance < radius) {
segmentBounce(pi, pj, particle.velocity);
return true;
}
}
if (closest && dx !== undefined && dy !== undefined && !this.#checkInsidePolygon(pos)) {
const factor = { x: 1, y: 1 }, diameter = radius * double, inverse = -1;
if (pos.x >= closest.x) {
factor.x = -1;
}
if (pos.y >= closest.y) {
factor.y = -1;
}
particle.position.x = closest.x + diameter * factor.x;
particle.position.y = closest.y + diameter * factor.y;
particle.velocity.mult(inverse);
return true;
}
}
else if (options.type === PolygonMaskType.inline) {
const dist = getDistance(particle.initialPosition, particle.getPosition()), { velocity } = particle;
if (dist > this.#moveRadius) {
velocity.x = velocity.y * half - velocity.x;
velocity.y = velocity.x * half - velocity.y;
return true;
}
}
return false;
}
#randomPoint() {
const container = this.#container, options = container.actualOptions.polygon;
if (!options) {
return;
}
let position;
if (options.type === PolygonMaskType.inline) {
switch (options.inline.arrangement) {
case PolygonMaskInlineArrangement.randomPoint:
position = this.#getRandomPoint();
break;
case PolygonMaskInlineArrangement.randomLength:
position = this.#getRandomPointByLength();
break;
case PolygonMaskInlineArrangement.equidistant:
position = this.#getEquidistantPointByIndex(container.particles.count);
break;
case PolygonMaskInlineArrangement.onePerPoint:
case PolygonMaskInlineArrangement.perPoint:
default:
position = this.#getPointByIndex(container.particles.count);
}
}
else {
const canvasSize = container.canvas.size;
position = {
x: getRandom() * canvasSize.width,
y: getRandom() * canvasSize.height,
};
}
if (this.#checkInsidePolygon(position)) {
return position;
}
else {
return this.#randomPoint();
}
}
}