UNPKG

@tsparticles/plugin-polygon-mask

Version:

tsParticles polygon mask plugin

391 lines (390 loc) 16.8 kB
import { OutModeDirection, deepExtend, errorPrefix, getDistance, getDistances, getRandom, isArray, isString, itemFromArray, percentDenominator, } 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 = `${errorPrefix} No polygon data loaded.`, noPolygonFound = `${errorPrefix} No polygon found, you need to specify SVG url in config.`, origin = { x: 0, y: 0, }, half = 0.5, double = 2; export class PolygonMaskInstance { constructor(container, engine) { this._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], 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 options.type === PolygonMaskType.outside ? !inside : false; } }; this._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 = document.createElementNS("http://www.w3.org/2000/svg", "svg").createSVGMatrix(), finalPath = new Path2D(), transform = matrix.scale(this._scale); if (finalPath.addPath) { finalPath.addPath(path2d, transform); path.path2d = finalPath; } else { delete path.path2d; } } else { delete path.path2d; } if (path.path2d ?? !this.raw) { continue; } path.path2d = new Path2D(); const firstIndex = 0, firstPoint = this.raw[firstIndex]; 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(); } }; this._downloadSvgPath = async (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(`${errorPrefix} occurred during polygon mask download`); } return this._parseSvgPath(await req.text(), force); }; this._drawPoints = () => { if (!this.raw) { return; } for (const item of this.raw) { void this._container.particles.addParticle({ x: item.x, y: item.y, }); } }; this._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 ?? origin.x) * scale + (this.offset?.x ?? origin.x), y: (point?.y ?? origin.y) * scale + (this.offset?.y ?? origin.y), }; }; this._getPointByIndex = index => { if (!this.raw?.length) { throw new Error(noPolygonDataLoaded); } const coords = this.raw[index % this.raw.length]; return { x: coords.x, y: coords.y, }; }; this._getRandomPoint = () => { if (!this.raw?.length) { throw new Error(noPolygonDataLoaded); } const coords = itemFromArray(this.raw); return { x: coords.x, y: coords.y, }; }; this._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), 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 ?? origin.x), y: point.y * scale + (this.offset?.y ?? origin.y), }; }; this._initRawData = async (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); const namespaces = 'xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"'; svg = `<svg ${namespaces} width="${data.size.width}" height="${data.size.height}">${path}</svg>`; } this.raw = this._parseSvgPath(svg, force); } this._createPath2D(); this._engine.dispatchEvent("polygonMaskLoaded", { container: this._container, }); }; this._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]; 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); }; this._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]; 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 && particle.initialPosition) { 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; }; this._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(); } }; this._container = container; this._engine = engine; 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 (!context) { continue; } if (path2d && this.offset) { drawPolygonMaskPath(this._engine, context, path2d, polygonDraw.stroke, this.offset); } else if (rawData) { drawPolygonMask(this._engine, context, rawData, polygonDraw.stroke); } } } 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 ? 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; } }