1 /* SPDX-License-Identifier: MIT
2 * ===========================================================================
3 * Mystify Web Animation v1.0
4 * <https://fietkau.plus/#mystify>
6 * Copyright (C) 2022 Julian Fietkau
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:
20 * The above copyright notice and this permission notice shall be included
21 * in all copies or substantial portions of the Software.
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 * ===========================================================================
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;
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.
70 interface PolygonSVGElement extends SVGSVGElement {
71 // All points in this drawing in their current state grouped by polygon
73 // All directions in this drawing, one corresponding to each point
74 directions?: Direction[];
75 // One color per polygon
77 // One trail per polygon, pre-formatted as an SVG path definition
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);
89 svg.setAttribute('fill', 'none');
90 svg.setAttribute('shape-rendering', 'optimizeSpeed');
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);
109 l: 0.3 + 0.4 * Math.random(),
113 svg.colors.push(color);
114 for(let pointIndex: number = 0; pointIndex < pointsPerPolygon; pointIndex++) {
119 svg.points.push(point);
120 let direction: Direction = {
121 dx: Math.random() - 0.5,
122 dy: Math.random() - 0.5,
124 svg.directions.push(direction);
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)));
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)));
142 color.l = Math.min(0.7, Math.max(0.3, color.l)); // Clamp within 0.3 and 0.7.
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;
151 point.x *= -1; // mirror at 0
152 svg.directions[pointIndex].dx = 0.2 + Math.abs(Math.random() - 0.5);
155 point.x = 1 + (point.x - 1) * -1; // mirror at 1
156 svg.directions[pointIndex].dx = -0.2 - Math.abs(Math.random() - 0.5);
158 point.y += svg.directions[pointIndex].dy * movementSpeed;
160 point.y *= -1; // mirror at 0
161 svg.directions[pointIndex].dy = 0.2 + Math.abs(Math.random() - 0.5);
164 point.y = 1 + (point.y - 1) * -1; // mirror at 1
165 svg.directions[pointIndex].dy = -0.2 - Math.abs(Math.random() - 0.5);
168 requestAnimationFrame((timestamp) => render(svg));
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
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
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);
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];
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;
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]));
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);
236 trail.children[trailIndex].setAttribute('d', svg.trails[polygonIndex * trailLength + trailIndex]);
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);