UNPKG

@tsparticles/plugin-polygon-mask

Version:

tsParticles polygon mask plugin

410 lines (409 loc) 15.8 kB
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(); } } }