Mystify – blob

You can use Git to clone the repository via the web URL. Download snapshot (zip)
Initial version
[Mystify] / mystify.ts
1 /* SPDX-License-Identifier: MIT
2  * ===========================================================================
3  * Mystify Web Animation v1.0
4  * <https://fietkau.plus/#mystify>
5  *
6  * Copyright (C) 2022 Julian Fietkau
7  *
8  * This script can animate SVG elements to look like the Mystify screensaver
9  * from early versions of MS Windows. It animates two polygons in shifting
10  * colors that leave behind shadows. There isn't much to it, it's just a small
11  * learning exercise in TypeScript.
12  * ===========================================================================
13  * Permission is hereby granted, free of charge, to any person obtaining a
14  * copy of this software and associated documentation files (the “Software”),
15  * to deal in the Software without restriction, including without limitation
16  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
17  * and/or sell copies of the Software, and to permit persons to whom the
18  * Software is furnished to do so, subject to the following conditions:
19  *
20  * The above copyright notice and this permission notice shall be included
21  * in all copies or substantial portions of the Software.
22  *
23  * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS
24  * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
25  * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
26  * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
27  * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT
28  * OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR
29  * THE USE OR OTHER DEALINGS IN THE SOFTWARE.
30  * ===========================================================================
31  */
33 'use strict';
34 (() => {
36 // This is my first time writing any TypeScript. Feel free to copy and reuse in
37 // accordance with the license, but I recommend not trying to learn from this.
39 // The FPS setting impacts the calculation steps, the actual rendering happens
40 // through requestAnimationFrame.
41 const fpsTarget: number = 60;
42 const movementSpeed: number = 0.9 / fpsTarget;
43 const numPolygons: number = 2;
44 const pointsPerPolygon: number = 4;
45 const trailDelay: number = 15; // Add new trail after this many milliseconds.
46 const trailLength: number = 12; // Number of trail segments
47 const mainStrokeWidth: number = 2;
48 // The border calculation assumes that trailStrokeWidth <= mainStrokeWidth.
49 const trailStrokeWidth: number = 1.5;
51 interface Point {
52   x: number;
53   y: number;
54 }
55 interface Direction {
56   dx: number;
57   dy: number;
58 }
59 interface Color {
60   h: number;
61   s: number;
62   l: number;
63   // So colors can change relatively smoothly, each color holds information
64   // on its recent change. dh and dl are only slightly modified each update
65   // and then added to d and l respectively, allowing for a more contiguous
66   // drift through the color space.
67   dh: number;
68   dl: number;
69 }
70 interface PolygonSVGElement extends SVGSVGElement {
71   // All points in this drawing in their current state grouped by polygon
72   points?: Point[];
73   // All directions in this drawing, one corresponding to each point
74   directions?: Direction[];
75   // One color per polygon
76   colors?: Color[];
77   // One trail per polygon, pre-formatted as an SVG path definition
78   trails?: string[];
79 }
81 // Call once for each container upon startup to perform initialization.
82 // Container must be a HTMLElement that contains at least one SVG element.
83 // The SVG must have a viewBox attribute set.
84 function initialize(container: HTMLElement): void {
85   let svg: PolygonSVGElement = container.querySelector('svg');
86   while(svg.children.length > 0) {
87     svg.removeChild(svg.firstChild);
88   }
89   svg.setAttribute('fill', 'none');
90   svg.setAttribute('shape-rendering', 'optimizeSpeed');
92   svg.points = [];
93   svg.directions = [];
94   svg.colors = [];
95   // Initialize trails to needed size, but empty, so we can do index-based access
96   // even with incomplete trails right after startup.
97   svg.trails = Array(numPolygons * trailLength);
99   for(let polygonIndex: number = 0; polygonIndex < numPolygons; polygonIndex++) {
100     let trail = document.createElementNS('http://www.w3.org/2000/svg', 'g');
101     trail.setAttribute('stroke-width', trailStrokeWidth.toString());
102     svg.appendChild(trail);
103     let path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
104     path.setAttribute('stroke-width', mainStrokeWidth.toString());
105     svg.appendChild(path);
106     let color: Color = {
107       h: Math.random(),
108       s: 1.0,
109       l: 0.3 + 0.4 * Math.random(),
110       dh: 0,
111       dl: 0,
112     };
113     svg.colors.push(color);
114     for(let pointIndex: number = 0; pointIndex < pointsPerPolygon; pointIndex++) {
115       let point: Point = {
116         x: Math.random(),
117         y: Math.random(),
118       };
119       svg.points.push(point);
120       let direction: Direction = {
121         dx: Math.random() - 0.5,
122         dy: Math.random() - 0.5,
123       };
124       svg.directions.push(direction);
125     }
126   }
129 // Call as often as desired to calculate updated values for the current points
130 // and colors. Will internally call render() through requestAnimationFrame().
131 function updateMain(containers: HTMLElement[]): void {
132   containers.forEach((container) => {
133     if(container.dataset.paused == 'true') return;
134     let svg: PolygonSVGElement = container.querySelector('svg');
135     for(let polygonIndex: number = 0; polygonIndex < numPolygons; polygonIndex++) {
136       let color: Color = svg.colors[polygonIndex];
137       color.dh += Math.min(0.003, Math.max(-0.003, 0.0001 * (Math.random() - 0.5)));
138       color.h += color.dh;
139       color.h = (color.h < 0) ? color.h + 1 : color.h % 1; // Wrap around at 0 and 1.
140       color.dl += Math.min(0.003, Math.max(-0.003, 0.0001 * (Math.random() - 0.5)));
141       color.l += color.dl;
142       color.l = Math.min(0.7, Math.max(0.3, color.l)); // Clamp within 0.3 and 0.7.
143     }
144     for(let pointIndex: number = 0; pointIndex < svg.points.length; pointIndex++) {
145       let point: Point = svg.points[pointIndex];
146       // Code duplication could be reduced here by iterating over the x and y
147       // properties by name, but mixed access through dot and bracket notations did
148       // not play nice with my build process.
149       point.x += svg.directions[pointIndex].dx * movementSpeed;
150       if(point.x < 0) {
151         point.x *= -1; // mirror at 0
152         svg.directions[pointIndex].dx = 0.2 + Math.abs(Math.random() - 0.5);
153       }
154       if(point.x > 1) {
155         point.x = 1 + (point.x - 1) * -1; // mirror at 1
156         svg.directions[pointIndex].dx = -0.2 - Math.abs(Math.random() - 0.5);
157       }
158       point.y += svg.directions[pointIndex].dy * movementSpeed;
159       if(point.y < 0) {
160         point.y *= -1; // mirror at 0
161         svg.directions[pointIndex].dy = 0.2 + Math.abs(Math.random() - 0.5);
162       }
163       if(point.y > 1) {
164         point.y = 1 + (point.y - 1) * -1; // mirror at 1
165         svg.directions[pointIndex].dy = -0.2 - Math.abs(Math.random() - 0.5);
166       }
167     }
168     requestAnimationFrame((timestamp) => render(svg));
169   });
172 // Convert relative x and y values (used by the Point type) to absolute coordinates
173 // for use in SVG path definitions. Half of mainStrokeWidth is subtracted on each
174 // side as a margin.
175 function toPathCoords(x: number, y: number, width: number, height: number): string {
176   let fullX = x * (width - mainStrokeWidth) + 0.5 * mainStrokeWidth;
177   let fullY = y * (height - mainStrokeWidth) + 0.5 * mainStrokeWidth;
178   return fullX.toFixed(3) + ' ' + fullY.toFixed(3);
181 // Convert an array of points to a full SVG path definition. Requires viewBox width
182 // and height.
183 function toPathDef(points: Point[], width: number, height: number): string {
184   let pathDef: string = 'M ' + toPathCoords(points[0].x, points[0].y, width, height);
185   for(let remainingPointIndex: number = 1; remainingPointIndex < points.length; remainingPointIndex++) {
186     let point: Point = points[remainingPointIndex];
187     pathDef += 'L ' + toPathCoords(point.x, point.y, width, height);
188   }
189   pathDef +=' Z';
190   return pathDef;
193 // Calculate a new trail step for each polygon. It is recommended to call this
194 // less often than updateMain.
195 function updateTrail(containers: HTMLElement[]): void {
196   containers.forEach((container) => {
197     if(container.dataset.paused == 'true') return;
198     let svg: PolygonSVGElement = container.querySelector('svg');
199     let width = parseInt(svg.getAttribute('viewBox').split(' ')[2], 10);
200     let height = parseInt(svg.getAttribute('viewBox').split(' ')[3], 10);
201     for(let polygonIndex: number = 0; polygonIndex < numPolygons; polygonIndex++) {
202       for(let trailIndex = trailLength - 1; trailIndex >= 1; trailIndex--) {
203         svg.trails[polygonIndex * trailLength + trailIndex] = svg.trails[polygonIndex * trailLength + trailIndex - 1];
204       }
205       let points: Point[] = svg.points.slice(polygonIndex * pointsPerPolygon, (polygonIndex + 1) * pointsPerPolygon);
206       let currentPathDef: string = toPathDef(points, width, height);
207       svg.trails[polygonIndex * trailLength] = currentPathDef;
208     }
209   });
212 // Convert a Color to a CSS color specification string.
213 function toCSS(color: Color): string {
214   return 'hsl(' + color.h.toFixed(3) + 'turn ' + Math.round(100 * color.s).toString() + '% ' + Math.round(100 * color.l).toString() + '%)';
217 // Take the current data and manipulate the output SVG to show it.
218 function render(svg: PolygonSVGElement): void {
219   let polygons = svg.querySelectorAll(':scope > path');
220   let width = parseInt(svg.getAttribute('viewBox').split(' ')[2], 10);
221   let height = parseInt(svg.getAttribute('viewBox').split(' ')[3], 10);
222   for(let polygonIndex: number = 0; polygonIndex < polygons.length; polygonIndex++) {
223     // Unpack NodeList to Array so we can slice().
224     [...svg.children].slice(polygonIndex * 2, polygonIndex * 2 + 2).forEach((elem) => {
225       elem.setAttribute('stroke', toCSS(svg.colors[polygonIndex]));
226     });
227     let points: Point[] = svg.points.slice(polygonIndex * pointsPerPolygon, (polygonIndex + 1) * pointsPerPolygon);
228     polygons[polygonIndex].setAttribute('d', toPathDef(points, width, height));
229     let trail = polygons[polygonIndex].previousElementSibling;
230     for(let trailIndex = 0; trailIndex < trailLength; trailIndex++) {
231       if(trail.children.length < trailIndex + 1) {
232         let newTrail = document.createElementNS('http://www.w3.org/2000/svg', 'path');
233         newTrail.setAttribute('opacity', (1 - trailIndex / trailLength).toFixed(3));
234         trail.appendChild(newTrail);
235       }
236       trail.children[trailIndex].setAttribute('d', svg.trails[polygonIndex * trailLength + trailIndex]);
237     }
238   }
241 // This code theoretically supports multiple Mystify containers within the same document. 
242 let containers: NodeListOf<HTMLElement> = document.querySelectorAll<HTMLElement>('.mystify');
243 containers.forEach((container) => initialize(container));
245 setInterval(updateMain, 1000 / fpsTarget, containers);
246 setInterval(updateTrail, trailDelay, containers);
248 })();