+/* SPDX-License-Identifier: MIT
+ * ===========================================================================
+ * Mystify Web Animation v1.0
+ * <https://fietkau.plus/#mystify>
+ *
+ * Copyright (C) 2022 Julian Fietkau
+ *
+ * This script can animate SVG elements to look like the Mystify screensaver
+ * from early versions of MS Windows. It animates two polygons in shifting
+ * colors that leave behind shadows. There isn't much to it, it's just a small
+ * learning exercise in TypeScript.
+ * ===========================================================================
+ * Permission is hereby granted, free of charge, to any person obtaining a
+ * copy of this software and associated documentation files (the “Software”),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included
+ * in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS
+ * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT
+ * OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR
+ * THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ * ===========================================================================
+ */
+
+'use strict';
+(() => {
+
+// This is my first time writing any TypeScript. Feel free to copy and reuse in
+// accordance with the license, but I recommend not trying to learn from this.
+
+// The FPS setting impacts the calculation steps, the actual rendering happens
+// through requestAnimationFrame.
+const fpsTarget: number = 60;
+const movementSpeed: number = 0.9 / fpsTarget;
+const numPolygons: number = 2;
+const pointsPerPolygon: number = 4;
+const trailDelay: number = 15; // Add new trail after this many milliseconds.
+const trailLength: number = 12; // Number of trail segments
+const mainStrokeWidth: number = 2;
+// The border calculation assumes that trailStrokeWidth <= mainStrokeWidth.
+const trailStrokeWidth: number = 1.5;
+
+interface Point {
+ x: number;
+ y: number;
+}
+interface Direction {
+ dx: number;
+ dy: number;
+}
+interface Color {
+ h: number;
+ s: number;
+ l: number;
+ // So colors can change relatively smoothly, each color holds information
+ // on its recent change. dh and dl are only slightly modified each update
+ // and then added to d and l respectively, allowing for a more contiguous
+ // drift through the color space.
+ dh: number;
+ dl: number;
+}
+interface PolygonSVGElement extends SVGSVGElement {
+ // All points in this drawing in their current state grouped by polygon
+ points?: Point[];
+ // All directions in this drawing, one corresponding to each point
+ directions?: Direction[];
+ // One color per polygon
+ colors?: Color[];
+ // One trail per polygon, pre-formatted as an SVG path definition
+ trails?: string[];
+}
+
+// Call once for each container upon startup to perform initialization.
+// Container must be a HTMLElement that contains at least one SVG element.
+// The SVG must have a viewBox attribute set.
+function initialize(container: HTMLElement): void {
+ let svg: PolygonSVGElement = container.querySelector('svg');
+ while(svg.children.length > 0) {
+ svg.removeChild(svg.firstChild);
+ }
+ svg.setAttribute('fill', 'none');
+ svg.setAttribute('shape-rendering', 'optimizeSpeed');
+
+ svg.points = [];
+ svg.directions = [];
+ svg.colors = [];
+ // Initialize trails to needed size, but empty, so we can do index-based access
+ // even with incomplete trails right after startup.
+ svg.trails = Array(numPolygons * trailLength);
+
+ for(let polygonIndex: number = 0; polygonIndex < numPolygons; polygonIndex++) {
+ let trail = document.createElementNS('http://www.w3.org/2000/svg', 'g');
+ trail.setAttribute('stroke-width', trailStrokeWidth.toString());
+ svg.appendChild(trail);
+ let path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+ path.setAttribute('stroke-width', mainStrokeWidth.toString());
+ svg.appendChild(path);
+ let color: Color = {
+ h: Math.random(),
+ s: 1.0,
+ l: 0.3 + 0.4 * Math.random(),
+ dh: 0,
+ dl: 0,
+ };
+ svg.colors.push(color);
+ for(let pointIndex: number = 0; pointIndex < pointsPerPolygon; pointIndex++) {
+ let point: Point = {
+ x: Math.random(),
+ y: Math.random(),
+ };
+ svg.points.push(point);
+ let direction: Direction = {
+ dx: Math.random() - 0.5,
+ dy: Math.random() - 0.5,
+ };
+ svg.directions.push(direction);
+ }
+ }
+}
+
+// Call as often as desired to calculate updated values for the current points
+// and colors. Will internally call render() through requestAnimationFrame().
+function updateMain(containers: HTMLElement[]): void {
+ containers.forEach((container) => {
+ if(container.dataset.paused == 'true') return;
+ let svg: PolygonSVGElement = container.querySelector('svg');
+ for(let polygonIndex: number = 0; polygonIndex < numPolygons; polygonIndex++) {
+ let color: Color = svg.colors[polygonIndex];
+ color.dh += Math.min(0.003, Math.max(-0.003, 0.0001 * (Math.random() - 0.5)));
+ color.h += color.dh;
+ color.h = (color.h < 0) ? color.h + 1 : color.h % 1; // Wrap around at 0 and 1.
+ color.dl += Math.min(0.003, Math.max(-0.003, 0.0001 * (Math.random() - 0.5)));
+ color.l += color.dl;
+ color.l = Math.min(0.7, Math.max(0.3, color.l)); // Clamp within 0.3 and 0.7.
+ }
+ for(let pointIndex: number = 0; pointIndex < svg.points.length; pointIndex++) {
+ let point: Point = svg.points[pointIndex];
+ // Code duplication could be reduced here by iterating over the x and y
+ // properties by name, but mixed access through dot and bracket notations did
+ // not play nice with my build process.
+ point.x += svg.directions[pointIndex].dx * movementSpeed;
+ if(point.x < 0) {
+ point.x *= -1; // mirror at 0
+ svg.directions[pointIndex].dx = 0.2 + Math.abs(Math.random() - 0.5);
+ }
+ if(point.x > 1) {
+ point.x = 1 + (point.x - 1) * -1; // mirror at 1
+ svg.directions[pointIndex].dx = -0.2 - Math.abs(Math.random() - 0.5);
+ }
+ point.y += svg.directions[pointIndex].dy * movementSpeed;
+ if(point.y < 0) {
+ point.y *= -1; // mirror at 0
+ svg.directions[pointIndex].dy = 0.2 + Math.abs(Math.random() - 0.5);
+ }
+ if(point.y > 1) {
+ point.y = 1 + (point.y - 1) * -1; // mirror at 1
+ svg.directions[pointIndex].dy = -0.2 - Math.abs(Math.random() - 0.5);
+ }
+ }
+ requestAnimationFrame((timestamp) => render(svg));
+ });
+}
+
+// Convert relative x and y values (used by the Point type) to absolute coordinates
+// for use in SVG path definitions. Half of mainStrokeWidth is subtracted on each
+// side as a margin.
+function toPathCoords(x: number, y: number, width: number, height: number): string {
+ let fullX = x * (width - mainStrokeWidth) + 0.5 * mainStrokeWidth;
+ let fullY = y * (height - mainStrokeWidth) + 0.5 * mainStrokeWidth;
+ return fullX.toFixed(3) + ' ' + fullY.toFixed(3);
+}
+
+// Convert an array of points to a full SVG path definition. Requires viewBox width
+// and height.
+function toPathDef(points: Point[], width: number, height: number): string {
+ let pathDef: string = 'M ' + toPathCoords(points[0].x, points[0].y, width, height);
+ for(let remainingPointIndex: number = 1; remainingPointIndex < points.length; remainingPointIndex++) {
+ let point: Point = points[remainingPointIndex];
+ pathDef += 'L ' + toPathCoords(point.x, point.y, width, height);
+ }
+ pathDef +=' Z';
+ return pathDef;
+}
+
+// Calculate a new trail step for each polygon. It is recommended to call this
+// less often than updateMain.
+function updateTrail(containers: HTMLElement[]): void {
+ containers.forEach((container) => {
+ if(container.dataset.paused == 'true') return;
+ let svg: PolygonSVGElement = container.querySelector('svg');
+ let width = parseInt(svg.getAttribute('viewBox').split(' ')[2], 10);
+ let height = parseInt(svg.getAttribute('viewBox').split(' ')[3], 10);
+ for(let polygonIndex: number = 0; polygonIndex < numPolygons; polygonIndex++) {
+ for(let trailIndex = trailLength - 1; trailIndex >= 1; trailIndex--) {
+ svg.trails[polygonIndex * trailLength + trailIndex] = svg.trails[polygonIndex * trailLength + trailIndex - 1];
+ }
+ let points: Point[] = svg.points.slice(polygonIndex * pointsPerPolygon, (polygonIndex + 1) * pointsPerPolygon);
+ let currentPathDef: string = toPathDef(points, width, height);
+ svg.trails[polygonIndex * trailLength] = currentPathDef;
+ }
+ });
+}
+
+// Convert a Color to a CSS color specification string.
+function toCSS(color: Color): string {
+ return 'hsl(' + color.h.toFixed(3) + 'turn ' + Math.round(100 * color.s).toString() + '% ' + Math.round(100 * color.l).toString() + '%)';
+}
+
+// Take the current data and manipulate the output SVG to show it.
+function render(svg: PolygonSVGElement): void {
+ let polygons = svg.querySelectorAll(':scope > path');
+ let width = parseInt(svg.getAttribute('viewBox').split(' ')[2], 10);
+ let height = parseInt(svg.getAttribute('viewBox').split(' ')[3], 10);
+ for(let polygonIndex: number = 0; polygonIndex < polygons.length; polygonIndex++) {
+ // Unpack NodeList to Array so we can slice().
+ [...svg.children].slice(polygonIndex * 2, polygonIndex * 2 + 2).forEach((elem) => {
+ elem.setAttribute('stroke', toCSS(svg.colors[polygonIndex]));
+ });
+ let points: Point[] = svg.points.slice(polygonIndex * pointsPerPolygon, (polygonIndex + 1) * pointsPerPolygon);
+ polygons[polygonIndex].setAttribute('d', toPathDef(points, width, height));
+ let trail = polygons[polygonIndex].previousElementSibling;
+ for(let trailIndex = 0; trailIndex < trailLength; trailIndex++) {
+ if(trail.children.length < trailIndex + 1) {
+ let newTrail = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+ newTrail.setAttribute('opacity', (1 - trailIndex / trailLength).toFixed(3));
+ trail.appendChild(newTrail);
+ }
+ trail.children[trailIndex].setAttribute('d', svg.trails[polygonIndex * trailLength + trailIndex]);
+ }
+ }
+}
+
+// This code theoretically supports multiple Mystify containers within the same document.
+let containers: NodeListOf<HTMLElement> = document.querySelectorAll<HTMLElement>('.mystify');
+containers.forEach((container) => initialize(container));
+
+setInterval(updateMain, 1000 / fpsTarget, containers);
+setInterval(updateTrail, trailDelay, containers);
+
+})();
+