mirror of
https://github.com/goauthentik/authentik
synced 2026-04-26 01:25:02 +02:00
Compare commits
4 Commits
ea4848c7c6
...
developer-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c11f407470 | ||
|
|
b7c6b961a1 | ||
|
|
e6adb72695 | ||
|
|
9cbdcd2cad |
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"goauthentik.io/internal/config"
|
||||
"goauthentik.io/internal/utils/sentry"
|
||||
"goauthentik.io/internal/utils/web"
|
||||
staticWeb "goauthentik.io/web"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -88,19 +90,81 @@ func (ws *WebServer) configureProxy() {
|
||||
}
|
||||
|
||||
func (ws *WebServer) proxyErrorHandler(rw http.ResponseWriter, req *http.Request, err error) {
|
||||
if !errors.Is(err, ErrAuthentikStarting) {
|
||||
ws.log.WithError(err).Warning("failed to proxy to backend")
|
||||
accept := req.Header.Get("Accept")
|
||||
|
||||
header := rw.Header()
|
||||
|
||||
if errors.Is(err, ErrAuthentikStarting) {
|
||||
header.Set("Retry-After", "5")
|
||||
|
||||
if strings.Contains(accept, "application/json") {
|
||||
header.Set("Content-Type", "application/json")
|
||||
|
||||
err = json.NewEncoder(rw).Encode(map[string]string{
|
||||
"error": "authentik starting",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
ws.log.WithError(err).Warning("failed to write error message")
|
||||
return
|
||||
}
|
||||
} else if strings.Contains(accept, "text/html") {
|
||||
header.Set("Content-Type", "text/html")
|
||||
rw.WriteHeader(http.StatusServiceUnavailable)
|
||||
|
||||
loadingSplashFile, err := staticWeb.StaticDir.Open("standalone/loading/startup.html")
|
||||
|
||||
if err != nil {
|
||||
ws.log.WithError(err).Warning("failed to open startup splash screen")
|
||||
return
|
||||
}
|
||||
|
||||
loadingSplashHTML, err := io.ReadAll(loadingSplashFile)
|
||||
|
||||
if err != nil {
|
||||
ws.log.WithError(err).Warning("failed to read startup splash screen")
|
||||
return
|
||||
}
|
||||
|
||||
_, err = rw.Write(loadingSplashHTML)
|
||||
|
||||
if err != nil {
|
||||
ws.log.WithError(err).Warning("failed to write startup splash screen")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
header.Set("Content-Type", "text/plain")
|
||||
rw.WriteHeader(http.StatusServiceUnavailable)
|
||||
|
||||
// Fallback to just a status message
|
||||
_, err = rw.Write([]byte("authentik starting"))
|
||||
|
||||
if err != nil {
|
||||
ws.log.WithError(err).Warning("failed to write initializing HTML")
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusBadGateway)
|
||||
|
||||
ws.log.WithError(err).Warning("failed to proxy to backend")
|
||||
|
||||
em := fmt.Sprintf("failed to connect to authentik backend: %v", err)
|
||||
// return json if the client asks for json
|
||||
if req.Header.Get("Accept") == "application/json" {
|
||||
|
||||
if strings.Contains(accept, "application/json") {
|
||||
header.Set("Content-Type", "application/json")
|
||||
rw.WriteHeader(http.StatusBadGateway)
|
||||
|
||||
err = json.NewEncoder(rw).Encode(map[string]string{
|
||||
"error": em,
|
||||
})
|
||||
} else {
|
||||
header.Set("Content-Type", "text/plain")
|
||||
rw.WriteHeader(http.StatusBadGateway)
|
||||
|
||||
_, err = rw.Write([]byte(em))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
ws.log.WithError(err).Warning("failed to write error message")
|
||||
}
|
||||
|
||||
@@ -48,6 +48,11 @@ const BASE_ESBUILD_OPTIONS = {
|
||||
plugins: [
|
||||
copy({
|
||||
assets: [
|
||||
{
|
||||
from: path.join(path.dirname(EntryPoint.StandaloneLoading.in), "startup", "**"),
|
||||
to: path.dirname(EntryPoint.StandaloneLoading.out),
|
||||
},
|
||||
|
||||
{
|
||||
from: path.join(patternflyPath, "patternfly.min.css"),
|
||||
to: ".",
|
||||
|
||||
802
web/src/standalone/loading/startup/demo-circles-filled.html
Normal file
802
web/src/standalone/loading/startup/demo-circles-filled.html
Normal file
@@ -0,0 +1,802 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
|
||||
<title>authentik</title>
|
||||
<style>
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-size: clamp(16px, 3dvw, 20px);
|
||||
background-color: #000;
|
||||
background-color: Canvas;
|
||||
color: #fff;
|
||||
color: CanvasText;
|
||||
font-family: system-ui, ui-sans-serif, sans-serif;
|
||||
text-align: center;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
min-height: 100dvh;
|
||||
margin: 0;
|
||||
padding-block: 0 6em;
|
||||
padding-inline: 0.5em;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 3.5em;
|
||||
max-width: 90dvw;
|
||||
}
|
||||
|
||||
.container {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: -1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.container canvas {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<svg
|
||||
class="logo"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 1000 144.29"
|
||||
aria-label="authentik"
|
||||
aria-role="heading"
|
||||
aria-level="1"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M106 41.08h25.39v101.2H106v-10.7a50 50 0 0 1-14.92 10.19 41.84 41.84 0 0 1-16.21 3.11q-19.61 0-33.91-15.21T26.64 91.86q0-23.43 13.85-38.41t33.63-15a42.78 42.78 0 0 1 17.09 3.44A46.82 46.82 0 0 1 106 52.24ZM79.29 61.91a25.65 25.65 0 0 0-19.56 8.33q-7.78 8.33-7.79 21.34t7.93 21.58a25.66 25.66 0 0 0 19.51 8.47 26.15 26.15 0 0 0 19.84-8.33q7.88-8.33 7.88-21.81 0-13.2-7.88-21.39t-19.93-8.19ZM168.39 41.08h25.67v48.74q0 14.22 2 19.76a17.24 17.24 0 0 0 6.29 8.61 18.06 18.06 0 0 0 10.65 3.07 18.6 18.6 0 0 0 10.77-3 17.7 17.7 0 0 0 6.57-8.88q1.59-4.36 1.59-18.7v-49.6h25.39V84q0 26.51-4.18 36.27a39.6 39.6 0 0 1-15.07 18.28q-10 6.38-25.3 6.37-16.65 0-26.93-7.44t-14.48-20.78q-3-9.21-3-33.49ZM297.3 3.78h25.39v37.3h15.07v21.85h-15.07v79.35H297.3V62.93h-13V41.08h13ZM362.86 2h25.21v49.3a57.74 57.74 0 0 1 15-9.63 38.56 38.56 0 0 1 15.25-3.21 34.36 34.36 0 0 1 25.39 10.42q8.83 9 8.84 26.51v66.88h-25V97.91q0-17.58-1.68-23.81t-5.71-9.3a16.07 16.07 0 0 0-10-3.07 18.85 18.85 0 0 0-13.26 5.11q-5.53 5.11-7.67 14-1.12 4.56-1.12 20.84v40.65h-25.25ZM589.91 99h-81.58q1.77 10.78 9.44 17.16t19.58 6.37a33.86 33.86 0 0 0 24.46-10l21.4 10a50.54 50.54 0 0 1-19.16 16.79q-11.16 5.44-26.51 5.44-23.82 0-38.79-15t-15-37.63q0-23.16 14.93-38.46t37.44-15.3q23.91 0 38.88 15.3t15 40.42Zm-25.4-20a25.48 25.48 0 0 0-9.92-13.77A28.81 28.81 0 0 0 537.4 60a30.42 30.42 0 0 0-18.64 5.95q-5 3.72-9.31 13.12ZM621.89 41.08h25.39v10.37q8.64-7.29 15.65-10.13a37.82 37.82 0 0 1 14.35-2.85A34.77 34.77 0 0 1 702.83 49q8.82 8.94 8.82 26.42v66.88h-25.11V98q0-18.12-1.63-24.06a16.44 16.44 0 0 0-5.66-9.06 15.8 15.8 0 0 0-10-3.11 18.73 18.73 0 0 0-13.23 5.15q-5.49 5.08-7.62 14.22-1.12 4.74-1.12 20.54v40.6h-25.39ZM750.71 3.78h25.39v37.3h15.07v21.85H776.1v79.35h-25.39V62.93h-13V41.08h13ZM826.09-.6a15.55 15.55 0 0 1 11.45 4.84A16.08 16.08 0 0 1 842.31 16a15.87 15.87 0 0 1-4.72 11.58 15.34 15.34 0 0 1-11.32 4.79 15.6 15.6 0 0 1-11.55-4.88 16.35 16.35 0 0 1-4.72-11.9 15.57 15.57 0 0 1 4.73-11.44A15.53 15.53 0 0 1 826.09-.6ZM813.39 41.08h25.39v101.2h-25.39zM873.47 2h25.39v80.8l37.39-41.72h31.89l-43.59 48.5 48.81 52.7h-31.53l-43-46.64v46.64h-25.36Z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<p>Server is starting up. Refreshing in a few seconds...</p>
|
||||
|
||||
<div class="container" aria-hidden="true">
|
||||
<canvas id="waves-canvas"></canvas>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
"use strict";
|
||||
|
||||
let timeoutID = -1;
|
||||
|
||||
function checkStatus() {
|
||||
console.log("Checking server status...");
|
||||
|
||||
return fetch(window.location.href, {
|
||||
method: "HEAD",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
console.log("Server is ready! Reloading...");
|
||||
clearTimeout(timeoutID);
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 503) {
|
||||
throw new Error("Server is still starting up...");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn(`Checking server status failed: ${error}`);
|
||||
timeoutID = setTimeout(checkStatus, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
|
||||
// if (window.location.origin.startsWith("http") && !queryParams.has("debug")) {
|
||||
// console.log("Checking server status...");
|
||||
// checkStatus();
|
||||
// }
|
||||
</script>
|
||||
|
||||
<script type="module">
|
||||
/**
|
||||
* @file Wave animation module.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Data offsets (11 properties per particle)
|
||||
*/
|
||||
const ParticleOffsets = {
|
||||
BASE_X: 0,
|
||||
BASE_Z: 1,
|
||||
BASE_Y: 2,
|
||||
R: 3,
|
||||
G: 4,
|
||||
B: 5,
|
||||
A: 6,
|
||||
SIZE: 7,
|
||||
PERSPECTIVE_SIZE: 8,
|
||||
HALF_SIZE: 9,
|
||||
PERSPECTIVE_DEPTH_ALPHA: 10,
|
||||
PARTICLE_SIZE: 11,
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {object} Particle
|
||||
* @property {number} baseX
|
||||
* @property {number} baseZ
|
||||
* @property {number} baseY
|
||||
* @property {number} x
|
||||
* @property {number} y
|
||||
* @property {number} r
|
||||
* @property {number} g
|
||||
* @property {number} b
|
||||
* @property {number} a
|
||||
* @property {number} size
|
||||
* @property {number} perspectiveSize
|
||||
* @property {number} halfSize
|
||||
* @property {number} perspectiveDepthAlpha
|
||||
*/
|
||||
class WavesCanvas {
|
||||
//#region Properties
|
||||
|
||||
width = 0;
|
||||
height = 0;
|
||||
dpi = Math.max(1, devicePixelRatio);
|
||||
|
||||
// Wave parameters
|
||||
turbulence = Math.PI * 6.0;
|
||||
pointSize = 1 * this.dpi;
|
||||
pointSizeCutoff = 5;
|
||||
speed = 10;
|
||||
waveHeight = 5;
|
||||
distance = 3;
|
||||
fov = (60 * Math.PI) / 180;
|
||||
|
||||
aspectRatio = 1;
|
||||
cameraZ = 95;
|
||||
|
||||
lodThreshold = this.cameraZ * 0.9;
|
||||
|
||||
/**
|
||||
* Tangent of the field of view, i.e the angle between the horizon and the camera
|
||||
*/
|
||||
f = Math.tan(Math.PI * 0.5 - 0.5 * this.fov);
|
||||
|
||||
/**
|
||||
* Flat array storing particle data: [baseX, baseZ, baseY, r, g, b, a, size, perspectiveSize, halfSize, perspectiveDepthAlpha]
|
||||
* @type {Float32Array}
|
||||
*/
|
||||
#particleData = new Float32Array(0);
|
||||
#particleOffsetCount = this.#particleData.length / ParticleOffsets.PARTICLE_SIZE;
|
||||
|
||||
// Performance optimizations
|
||||
/**
|
||||
* @type {ImageData}
|
||||
*/
|
||||
// @ts-expect-error - Assigned in resize listener
|
||||
#buffer;
|
||||
|
||||
#gridWidth = 0;
|
||||
#gridDepth = 0;
|
||||
#fieldWidth = 0;
|
||||
#fieldHeight = 0;
|
||||
#fieldDepth = 0;
|
||||
|
||||
#relativeWidth = 0;
|
||||
#relativeHeight = 0;
|
||||
|
||||
// Frustum culling bounds
|
||||
#frustumLeft = 0;
|
||||
#frustumRight = 0;
|
||||
#frustumTop = 0;
|
||||
#frustumBottom = 0;
|
||||
#frustumNear = 0;
|
||||
#frustumFar = 0;
|
||||
|
||||
/**
|
||||
* @type {HTMLCanvasElement}
|
||||
*/
|
||||
#canvas;
|
||||
|
||||
/**
|
||||
* @type {CanvasRenderingContext2D}
|
||||
*/
|
||||
#ctx;
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
/**
|
||||
* @param {string | HTMLCanvasElement} target
|
||||
*/
|
||||
constructor(target) {
|
||||
const element =
|
||||
typeof target === "string" ? document.querySelector(target) : target;
|
||||
|
||||
if (!(element instanceof HTMLCanvasElement)) {
|
||||
throw new TypeError("Invalid canvas element");
|
||||
}
|
||||
|
||||
this.#canvas = element;
|
||||
|
||||
const ctx = this.#canvas.getContext("2d", {
|
||||
alpha: false,
|
||||
desynchronized: true,
|
||||
});
|
||||
|
||||
if (!ctx) {
|
||||
throw new TypeError("Failed to get 2D context");
|
||||
}
|
||||
|
||||
this.#ctx = ctx;
|
||||
|
||||
// We apply a background color to the canvas element itself for a slight performance boost.
|
||||
this.#canvas.style.background = getComputedStyle(document.body).backgroundColor;
|
||||
// All containment rules are applied to the element to further improve performance.
|
||||
this.#canvas.style.contain = "strict";
|
||||
// Applying a null transform on the canvas forces the browser to use the GPU for rendering.
|
||||
this.#canvas.style.willChange = "transform";
|
||||
this.#canvas.style.transform = "translate3d(0, 0, 0)";
|
||||
this.#canvas.style.pointerEvents = "none";
|
||||
|
||||
window.addEventListener("resize", this.refresh);
|
||||
const colorSchemeChangeListener = window.matchMedia(
|
||||
"(prefers-color-scheme: dark)",
|
||||
);
|
||||
|
||||
colorSchemeChangeListener.addEventListener("change", this.refresh);
|
||||
}
|
||||
|
||||
refresh = () => {
|
||||
const container = this.#canvas.parentElement;
|
||||
|
||||
if (!container) return;
|
||||
|
||||
this.width = container.offsetWidth;
|
||||
this.height = container.offsetHeight;
|
||||
this.aspectRatio = this.width / this.height;
|
||||
|
||||
this.#relativeWidth = this.width * this.dpi;
|
||||
this.#relativeHeight = this.height * this.dpi;
|
||||
|
||||
this.#canvas.width = this.#relativeWidth;
|
||||
this.#canvas.height = this.#relativeHeight;
|
||||
|
||||
this.#canvas.style.width = this.width + "px";
|
||||
this.#canvas.style.height = this.height + "px";
|
||||
|
||||
this.#ctx.scale(this.dpi, this.dpi);
|
||||
|
||||
this.#ctx.fillStyle = getComputedStyle(document.body).backgroundColor;
|
||||
|
||||
this.#buffer = this.#ctx.createImageData(
|
||||
this.width * this.dpi,
|
||||
this.height * this.dpi,
|
||||
);
|
||||
|
||||
this.#gridWidth = 300 * this.aspectRatio;
|
||||
this.#gridDepth = 300;
|
||||
|
||||
this.#fieldWidth = this.#gridWidth;
|
||||
this.#fieldHeight = this.waveHeight * this.aspectRatio;
|
||||
this.#fieldDepth = this.#gridDepth;
|
||||
|
||||
this.#calculateFrustumBounds();
|
||||
this.#generateParticles();
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Frustum Culling
|
||||
|
||||
#calculateFrustumBounds() {
|
||||
const halfFov = this.fov / 2;
|
||||
const tanHalfFov = Math.tan(halfFov);
|
||||
|
||||
// Calculate frustum bounds at different depths
|
||||
this.#frustumNear = 1;
|
||||
this.#frustumFar = this.cameraZ + this.#gridDepth;
|
||||
|
||||
// At near plane
|
||||
const nearHeight = 2 * this.#frustumNear * tanHalfFov;
|
||||
const nearWidth = nearHeight * this.aspectRatio;
|
||||
|
||||
// Store half-widths and half-heights for faster testing
|
||||
this.#frustumLeft = -nearWidth / 2;
|
||||
this.#frustumRight = nearWidth / 2;
|
||||
this.#frustumTop = nearHeight / 2;
|
||||
this.#frustumBottom = -nearHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if a 3D point is within the view frustum
|
||||
* @param {number} x - World X coordinate
|
||||
* @param {number} y - World Y coordinate
|
||||
* @param {number} z - World Z coordinate
|
||||
* @returns {boolean}
|
||||
*/
|
||||
#isInFrustum(x, y, z) {
|
||||
const projectedZ = z + this.cameraZ;
|
||||
|
||||
// Behind camera or too far
|
||||
if (projectedZ <= this.#frustumNear || projectedZ > this.#frustumFar) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculate frustum bounds at this depth
|
||||
const scale = projectedZ / this.#frustumNear;
|
||||
const left = this.#frustumLeft * scale;
|
||||
const right = this.#frustumRight * scale;
|
||||
const top = this.#frustumTop * scale;
|
||||
const bottom = this.#frustumBottom * scale;
|
||||
|
||||
// Test if point is within frustum bounds
|
||||
return x >= left && x <= right && y >= bottom && y <= top;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Particle Generation
|
||||
|
||||
#generateParticles() {
|
||||
const particleCount =
|
||||
Math.floor(this.#gridWidth / this.distance) *
|
||||
Math.floor(this.#gridDepth / this.distance);
|
||||
|
||||
this.#particleData = new Float32Array(
|
||||
particleCount * ParticleOffsets.PARTICLE_SIZE,
|
||||
);
|
||||
this.#particleOffsetCount =
|
||||
this.#particleData.length / ParticleOffsets.PARTICLE_SIZE;
|
||||
|
||||
let particleIndex = 0;
|
||||
|
||||
for (let x = 0; x < this.#gridWidth; x += this.distance) {
|
||||
const baseX = Math.round(-this.#gridWidth / 2 + x);
|
||||
|
||||
for (let z = 0; z < this.#gridDepth; z += this.distance) {
|
||||
const baseZ = -this.#gridDepth / 2 + z;
|
||||
const depthFactor = (z / this.#gridDepth) * 0.9;
|
||||
const horizonFactor = (x / this.#gridWidth) * 1;
|
||||
|
||||
// Pre-calculate perspective size based on fixed depth
|
||||
const projectedZ = baseZ + this.cameraZ;
|
||||
const baseSize = (this.height / 250) * this.pointSize * this.dpi;
|
||||
|
||||
// More exaggerated scaling with logarithmic falloff for horizon effect
|
||||
const normalizedDepth = Math.max(0, (projectedZ - 50) / 200);
|
||||
// Log scale for dramatic falloff
|
||||
const logScale = Math.log(1 + normalizedDepth * 4) / Math.log(6);
|
||||
// Invert so closer = larger
|
||||
const distanceFactor = Math.max(0.05, 1 - logScale);
|
||||
const perspectiveSize = baseSize * distanceFactor * 3;
|
||||
|
||||
const alpha = Math.max(Math.floor(z / this.#gridDepth), 0.85);
|
||||
const depthAlpha = Math.max(0.6, Math.pow(distanceFactor, 2));
|
||||
|
||||
const offset = particleIndex * ParticleOffsets.PARTICLE_SIZE;
|
||||
|
||||
// Store particle data in flat array
|
||||
this.#particleData[offset + ParticleOffsets.BASE_X] = baseX;
|
||||
this.#particleData[offset + ParticleOffsets.BASE_Z] = baseZ;
|
||||
this.#particleData[offset + ParticleOffsets.BASE_Y] =
|
||||
this.cameraZ * -0.4;
|
||||
this.#particleData[offset + ParticleOffsets.R] = Math.min(
|
||||
Math.floor(253 / (depthFactor * horizonFactor)),
|
||||
255,
|
||||
);
|
||||
this.#particleData[offset + ParticleOffsets.G] = Math.min(
|
||||
Math.floor(75 / horizonFactor),
|
||||
255,
|
||||
);
|
||||
this.#particleData[offset + ParticleOffsets.B] = Math.min(
|
||||
Math.floor(45 / (depthFactor * horizonFactor * 2)),
|
||||
255,
|
||||
);
|
||||
this.#particleData[offset + ParticleOffsets.A] = alpha;
|
||||
this.#particleData[offset + ParticleOffsets.SIZE] = baseSize;
|
||||
this.#particleData[offset + ParticleOffsets.PERSPECTIVE_SIZE] =
|
||||
perspectiveSize;
|
||||
this.#particleData[offset + ParticleOffsets.HALF_SIZE] = Math.max(
|
||||
1,
|
||||
Math.round(perspectiveSize / 1.5),
|
||||
);
|
||||
this.#particleData[offset + ParticleOffsets.PERSPECTIVE_DEPTH_ALPHA] =
|
||||
depthAlpha ** 2;
|
||||
|
||||
particleIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Projection
|
||||
|
||||
/**
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} z
|
||||
* @returns {{x: number, y: number, z: number} | null}
|
||||
*/
|
||||
project3DTo2D(x, y, z) {
|
||||
const projectedX = (x * this.f) / this.aspectRatio;
|
||||
const projectedY = y * this.f;
|
||||
const projectedZ = z + this.cameraZ;
|
||||
|
||||
if (projectedZ <= 0) return null;
|
||||
|
||||
// Convert to screen coordinates
|
||||
// top of canvas is horizon (y=0), bottom is near
|
||||
|
||||
const screenX = ((projectedX / projectedZ) * this.width) / 2 + this.width / 2;
|
||||
const screenY =
|
||||
this.height / 2 -
|
||||
((projectedY / projectedZ) * this.height) / 2 -
|
||||
this.cameraZ * 2;
|
||||
|
||||
return { x: screenX, y: screenY, z: projectedZ };
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Rendering
|
||||
|
||||
/**
|
||||
* @param {number} x
|
||||
* @param {number} z
|
||||
* @param {number} time
|
||||
* @returns {number}
|
||||
*/
|
||||
calculateWaveY(x, z, time) {
|
||||
return (
|
||||
(Math.cos((x / this.#fieldWidth) * this.turbulence + time * this.speed) +
|
||||
Math.sin(
|
||||
(z / this.#fieldDepth) * this.turbulence + time * this.speed,
|
||||
)) *
|
||||
this.#fieldHeight
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a simple point particle (for distant objects)
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} r
|
||||
* @param {number} g
|
||||
* @param {number} b
|
||||
* @param {number} a
|
||||
*/
|
||||
// eslint-disable-next-line max-params
|
||||
#drawPointParticle(x, y, r, g, b, a) {
|
||||
const data = this.#buffer.data;
|
||||
const centerX = Math.round(x * this.dpi);
|
||||
const centerY = Math.round(y * this.dpi);
|
||||
const width = this.#relativeWidth;
|
||||
const height = this.#relativeHeight;
|
||||
|
||||
if (centerX >= 0 && centerX < width && centerY >= 0 && centerY < height) {
|
||||
const index = (centerY * width + centerX) * 4;
|
||||
const alpha = Math.round(a * 255);
|
||||
const blendFactor = alpha * 1.25;
|
||||
|
||||
data[index] = Math.min(255, data[index] + (r * blendFactor) / 255);
|
||||
data[index + 1] = Math.min(255, data[index + 1] + (g * blendFactor) / 255);
|
||||
data[index + 2] = Math.min(255, data[index + 2] + (b * blendFactor) / 255);
|
||||
data[index + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a hollow circle particle
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} halfSize
|
||||
* @param {number} r
|
||||
* @param {number} g
|
||||
* @param {number} b
|
||||
* @param {number} a
|
||||
*/
|
||||
// eslint-disable-next-line max-params
|
||||
#drawCircleParticle(x, y, halfSize, r, g, b, a) {
|
||||
const data = this.#buffer.data;
|
||||
const centerX = Math.round(x * this.dpi);
|
||||
const centerY = Math.round(y * this.dpi);
|
||||
|
||||
const width = this.#relativeWidth;
|
||||
const height = this.#relativeHeight;
|
||||
|
||||
const alpha = Math.round(a * 255);
|
||||
const radius = halfSize;
|
||||
|
||||
// Draw hollow circle - just the outline
|
||||
for (let py = -radius; py <= radius; py++) {
|
||||
for (let px = -radius; px <= radius; px++) {
|
||||
const distance = Math.sqrt(px * px + py * py);
|
||||
|
||||
// Only draw pixels that are on the circle edge (within 1 pixel of radius)
|
||||
if (distance >= radius - 1 && distance <= radius) {
|
||||
const xPixel = centerX + px;
|
||||
const yPixel = centerY + py;
|
||||
|
||||
if (
|
||||
xPixel >= 0 &&
|
||||
xPixel < width &&
|
||||
yPixel >= 0 &&
|
||||
yPixel < height
|
||||
) {
|
||||
const index = (yPixel * width + xPixel) * 4;
|
||||
const blendFactor = alpha * 1.25;
|
||||
|
||||
// Additive blending for glow effect
|
||||
data[index] = Math.min(
|
||||
255,
|
||||
data[index] + (r * blendFactor) / 255,
|
||||
);
|
||||
data[index + 1] = Math.min(
|
||||
255,
|
||||
data[index + 1] + (g * blendFactor) / 255,
|
||||
);
|
||||
data[index + 2] = Math.min(
|
||||
255,
|
||||
data[index + 2] + (b * blendFactor) / 255,
|
||||
);
|
||||
data[index + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a filled circle particle
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} halfSize
|
||||
* @param {number} r
|
||||
* @param {number} g
|
||||
* @param {number} b
|
||||
* @param {number} a
|
||||
*/
|
||||
// eslint-disable-next-line max-params
|
||||
#drawFilledCircleParticle(x, y, halfSize, r, g, b, a) {
|
||||
const data = this.#buffer.data;
|
||||
const centerX = Math.round(x * this.dpi);
|
||||
const centerY = Math.round(y * this.dpi);
|
||||
|
||||
const width = this.#relativeWidth;
|
||||
const height = this.#relativeHeight;
|
||||
|
||||
const alpha = Math.round(a * 255);
|
||||
const radius = halfSize;
|
||||
|
||||
// Draw solid circle
|
||||
for (let py = -radius; py <= radius; py++) {
|
||||
for (let px = -radius; px <= radius; px++) {
|
||||
const distance = Math.sqrt(px * px + py * py);
|
||||
|
||||
// Draw all pixels within the radius
|
||||
if (distance <= radius) {
|
||||
const xPixel = centerX + px;
|
||||
const yPixel = centerY + py;
|
||||
|
||||
if (
|
||||
xPixel >= 0 &&
|
||||
xPixel < width &&
|
||||
yPixel >= 0 &&
|
||||
yPixel < height
|
||||
) {
|
||||
const index = (yPixel * width + xPixel) * 4;
|
||||
const blendFactor = alpha * 1.25;
|
||||
|
||||
// Additive blending for glow effect
|
||||
data[index] = Math.min(
|
||||
255,
|
||||
data[index] + (r * blendFactor) / 255,
|
||||
);
|
||||
data[index + 1] = Math.min(
|
||||
255,
|
||||
data[index + 1] + (g * blendFactor) / 255,
|
||||
);
|
||||
data[index + 2] = Math.min(
|
||||
255,
|
||||
data[index + 2] + (b * blendFactor) / 255,
|
||||
);
|
||||
data[index + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw an isosceles triangle particle
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} halfSize
|
||||
* @param {number} r
|
||||
* @param {number} g
|
||||
* @param {number} b
|
||||
* @param {number} a
|
||||
*/
|
||||
// eslint-disable-next-line max-params
|
||||
#drawTriangleParticle(x, y, halfSize, r, g, b, a) {
|
||||
const data = this.#buffer.data;
|
||||
const centerX = Math.round(x * this.dpi);
|
||||
const centerY = Math.round(y * this.dpi);
|
||||
|
||||
const width = this.#relativeWidth;
|
||||
const height = this.#relativeHeight;
|
||||
|
||||
const alpha = Math.round(a * 255);
|
||||
|
||||
// Draw hollow isosceles triangle pointing up - just the outline
|
||||
for (let py = -halfSize; py <= halfSize; py++) {
|
||||
// Calculate max width at this height - steeper taper for more triangular shape
|
||||
// 0 at top, 1 at bottom
|
||||
const normalizedHeight = (py + halfSize) / (2 * halfSize);
|
||||
const triangleWidth = Math.round(halfSize * normalizedHeight);
|
||||
|
||||
for (let px = -triangleWidth; px <= triangleWidth; px++) {
|
||||
// Only draw if on the edge (left edge, right edge, or bottom edge)
|
||||
const isLeftEdge = px === -triangleWidth;
|
||||
const isRightEdge = px === triangleWidth;
|
||||
const isBottomEdge = py === halfSize;
|
||||
|
||||
if (isLeftEdge || isRightEdge || isBottomEdge) {
|
||||
const xPixel = centerX + px;
|
||||
const yPixel = centerY + py;
|
||||
|
||||
if (
|
||||
xPixel >= 0 &&
|
||||
xPixel < width &&
|
||||
yPixel >= 0 &&
|
||||
yPixel < height
|
||||
) {
|
||||
const index = (yPixel * width + xPixel) * 4;
|
||||
const blendFactor = alpha * 1.25;
|
||||
|
||||
// Additive blending for glow effect
|
||||
data[index] = Math.min(
|
||||
255,
|
||||
data[index] + (r * blendFactor) / 255,
|
||||
);
|
||||
data[index + 1] = Math.min(
|
||||
255,
|
||||
data[index + 1] + (g * blendFactor) / 255,
|
||||
);
|
||||
data[index + 2] = Math.min(
|
||||
255,
|
||||
data[index + 2] + (b * blendFactor) / 255,
|
||||
);
|
||||
data[index + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#render = (time = performance.now()) => {
|
||||
const timestamp = time / 6000;
|
||||
this.#buffer.data.fill(0);
|
||||
|
||||
for (let i = 0; i < this.#particleOffsetCount; i++) {
|
||||
const offset = i * ParticleOffsets.PARTICLE_SIZE;
|
||||
|
||||
const baseX = this.#particleData[offset + ParticleOffsets.BASE_X];
|
||||
const baseZ = this.#particleData[offset + ParticleOffsets.BASE_Z];
|
||||
const baseY = this.#particleData[offset + ParticleOffsets.BASE_Y];
|
||||
|
||||
const waveY = this.calculateWaveY(baseX, baseZ, timestamp);
|
||||
const worldY = baseY + waveY;
|
||||
|
||||
if (!this.#isInFrustum(baseX, worldY, baseZ)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const projected = this.project3DTo2D(baseX, worldY, baseZ);
|
||||
|
||||
if (
|
||||
!projected ||
|
||||
projected.x < -50 ||
|
||||
projected.x > this.width + 50 ||
|
||||
projected.y < 0 ||
|
||||
projected.y > this.height + 50
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const distance = Math.abs(projected.z - this.cameraZ);
|
||||
const perspectiveSize =
|
||||
this.#particleData[offset + ParticleOffsets.PERSPECTIVE_SIZE];
|
||||
|
||||
if (
|
||||
distance > this.lodThreshold ||
|
||||
perspectiveSize < this.pointSizeCutoff
|
||||
) {
|
||||
this.#drawPointParticle(
|
||||
projected.x,
|
||||
projected.y,
|
||||
this.#particleData[offset + ParticleOffsets.R],
|
||||
this.#particleData[offset + ParticleOffsets.G],
|
||||
this.#particleData[offset + ParticleOffsets.B],
|
||||
this.#particleData[
|
||||
offset + ParticleOffsets.PERSPECTIVE_DEPTH_ALPHA
|
||||
],
|
||||
);
|
||||
} else {
|
||||
// Close enough for detailed triangle
|
||||
this.#drawFilledCircleParticle(
|
||||
projected.x,
|
||||
projected.y,
|
||||
this.#particleData[offset + ParticleOffsets.HALF_SIZE],
|
||||
this.#particleData[offset + ParticleOffsets.R],
|
||||
this.#particleData[offset + ParticleOffsets.G],
|
||||
this.#particleData[offset + ParticleOffsets.B],
|
||||
this.#particleData[
|
||||
offset + ParticleOffsets.PERSPECTIVE_DEPTH_ALPHA
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.#ctx.putImageData(this.#buffer, 0, 0);
|
||||
this.#ctx.globalCompositeOperation = "soft-light";
|
||||
this.#ctx.fillRect(0, 0, this.#relativeWidth, this.#relativeHeight);
|
||||
this.#ctx.globalCompositeOperation = "source-over";
|
||||
|
||||
this.#renderFrameID = requestAnimationFrame(this.#render);
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Public Methods
|
||||
|
||||
#renderFrameID = -1;
|
||||
|
||||
play = () => {
|
||||
this.refresh();
|
||||
|
||||
this.#render();
|
||||
};
|
||||
|
||||
pause = () => {
|
||||
cancelAnimationFrame(this.#renderFrameID);
|
||||
};
|
||||
}
|
||||
|
||||
function drawAnimation() {
|
||||
const canvas = document.getElementById("waves-canvas");
|
||||
const waves = new WavesCanvas(canvas);
|
||||
waves.play();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", drawAnimation);
|
||||
} else {
|
||||
drawAnimation();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
802
web/src/standalone/loading/startup/demo-circles-hollow.html
Normal file
802
web/src/standalone/loading/startup/demo-circles-hollow.html
Normal file
@@ -0,0 +1,802 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
|
||||
<title>authentik</title>
|
||||
<style>
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-size: clamp(16px, 3dvw, 20px);
|
||||
background-color: #000;
|
||||
background-color: Canvas;
|
||||
color: #fff;
|
||||
color: CanvasText;
|
||||
font-family: system-ui, ui-sans-serif, sans-serif;
|
||||
text-align: center;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
min-height: 100dvh;
|
||||
margin: 0;
|
||||
padding-block: 0 6em;
|
||||
padding-inline: 0.5em;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 3.5em;
|
||||
max-width: 90dvw;
|
||||
}
|
||||
|
||||
.container {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: -1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.container canvas {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<svg
|
||||
class="logo"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 1000 144.29"
|
||||
aria-label="authentik"
|
||||
aria-role="heading"
|
||||
aria-level="1"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M106 41.08h25.39v101.2H106v-10.7a50 50 0 0 1-14.92 10.19 41.84 41.84 0 0 1-16.21 3.11q-19.61 0-33.91-15.21T26.64 91.86q0-23.43 13.85-38.41t33.63-15a42.78 42.78 0 0 1 17.09 3.44A46.82 46.82 0 0 1 106 52.24ZM79.29 61.91a25.65 25.65 0 0 0-19.56 8.33q-7.78 8.33-7.79 21.34t7.93 21.58a25.66 25.66 0 0 0 19.51 8.47 26.15 26.15 0 0 0 19.84-8.33q7.88-8.33 7.88-21.81 0-13.2-7.88-21.39t-19.93-8.19ZM168.39 41.08h25.67v48.74q0 14.22 2 19.76a17.24 17.24 0 0 0 6.29 8.61 18.06 18.06 0 0 0 10.65 3.07 18.6 18.6 0 0 0 10.77-3 17.7 17.7 0 0 0 6.57-8.88q1.59-4.36 1.59-18.7v-49.6h25.39V84q0 26.51-4.18 36.27a39.6 39.6 0 0 1-15.07 18.28q-10 6.38-25.3 6.37-16.65 0-26.93-7.44t-14.48-20.78q-3-9.21-3-33.49ZM297.3 3.78h25.39v37.3h15.07v21.85h-15.07v79.35H297.3V62.93h-13V41.08h13ZM362.86 2h25.21v49.3a57.74 57.74 0 0 1 15-9.63 38.56 38.56 0 0 1 15.25-3.21 34.36 34.36 0 0 1 25.39 10.42q8.83 9 8.84 26.51v66.88h-25V97.91q0-17.58-1.68-23.81t-5.71-9.3a16.07 16.07 0 0 0-10-3.07 18.85 18.85 0 0 0-13.26 5.11q-5.53 5.11-7.67 14-1.12 4.56-1.12 20.84v40.65h-25.25ZM589.91 99h-81.58q1.77 10.78 9.44 17.16t19.58 6.37a33.86 33.86 0 0 0 24.46-10l21.4 10a50.54 50.54 0 0 1-19.16 16.79q-11.16 5.44-26.51 5.44-23.82 0-38.79-15t-15-37.63q0-23.16 14.93-38.46t37.44-15.3q23.91 0 38.88 15.3t15 40.42Zm-25.4-20a25.48 25.48 0 0 0-9.92-13.77A28.81 28.81 0 0 0 537.4 60a30.42 30.42 0 0 0-18.64 5.95q-5 3.72-9.31 13.12ZM621.89 41.08h25.39v10.37q8.64-7.29 15.65-10.13a37.82 37.82 0 0 1 14.35-2.85A34.77 34.77 0 0 1 702.83 49q8.82 8.94 8.82 26.42v66.88h-25.11V98q0-18.12-1.63-24.06a16.44 16.44 0 0 0-5.66-9.06 15.8 15.8 0 0 0-10-3.11 18.73 18.73 0 0 0-13.23 5.15q-5.49 5.08-7.62 14.22-1.12 4.74-1.12 20.54v40.6h-25.39ZM750.71 3.78h25.39v37.3h15.07v21.85H776.1v79.35h-25.39V62.93h-13V41.08h13ZM826.09-.6a15.55 15.55 0 0 1 11.45 4.84A16.08 16.08 0 0 1 842.31 16a15.87 15.87 0 0 1-4.72 11.58 15.34 15.34 0 0 1-11.32 4.79 15.6 15.6 0 0 1-11.55-4.88 16.35 16.35 0 0 1-4.72-11.9 15.57 15.57 0 0 1 4.73-11.44A15.53 15.53 0 0 1 826.09-.6ZM813.39 41.08h25.39v101.2h-25.39zM873.47 2h25.39v80.8l37.39-41.72h31.89l-43.59 48.5 48.81 52.7h-31.53l-43-46.64v46.64h-25.36Z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<p>Server is starting up. Refreshing in a few seconds...</p>
|
||||
|
||||
<div class="container" aria-hidden="true">
|
||||
<canvas id="waves-canvas"></canvas>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
"use strict";
|
||||
|
||||
let timeoutID = -1;
|
||||
|
||||
function checkStatus() {
|
||||
console.log("Checking server status...");
|
||||
|
||||
return fetch(window.location.href, {
|
||||
method: "HEAD",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
console.log("Server is ready! Reloading...");
|
||||
clearTimeout(timeoutID);
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 503) {
|
||||
throw new Error("Server is still starting up...");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn(`Checking server status failed: ${error}`);
|
||||
timeoutID = setTimeout(checkStatus, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
|
||||
// if (window.location.origin.startsWith("http") && !queryParams.has("debug")) {
|
||||
// console.log("Checking server status...");
|
||||
// checkStatus();
|
||||
// }
|
||||
</script>
|
||||
|
||||
<script type="module">
|
||||
/**
|
||||
* @file Wave animation module.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Data offsets (11 properties per particle)
|
||||
*/
|
||||
const ParticleOffsets = {
|
||||
BASE_X: 0,
|
||||
BASE_Z: 1,
|
||||
BASE_Y: 2,
|
||||
R: 3,
|
||||
G: 4,
|
||||
B: 5,
|
||||
A: 6,
|
||||
SIZE: 7,
|
||||
PERSPECTIVE_SIZE: 8,
|
||||
HALF_SIZE: 9,
|
||||
PERSPECTIVE_DEPTH_ALPHA: 10,
|
||||
PARTICLE_SIZE: 11,
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {object} Particle
|
||||
* @property {number} baseX
|
||||
* @property {number} baseZ
|
||||
* @property {number} baseY
|
||||
* @property {number} x
|
||||
* @property {number} y
|
||||
* @property {number} r
|
||||
* @property {number} g
|
||||
* @property {number} b
|
||||
* @property {number} a
|
||||
* @property {number} size
|
||||
* @property {number} perspectiveSize
|
||||
* @property {number} halfSize
|
||||
* @property {number} perspectiveDepthAlpha
|
||||
*/
|
||||
class WavesCanvas {
|
||||
//#region Properties
|
||||
|
||||
width = 0;
|
||||
height = 0;
|
||||
dpi = Math.max(1, devicePixelRatio);
|
||||
|
||||
// Wave parameters
|
||||
turbulence = Math.PI * 6.0;
|
||||
pointSize = 1 * this.dpi;
|
||||
pointSizeCutoff = 5;
|
||||
speed = 10;
|
||||
waveHeight = 5;
|
||||
distance = 3;
|
||||
fov = (60 * Math.PI) / 180;
|
||||
|
||||
aspectRatio = 1;
|
||||
cameraZ = 95;
|
||||
|
||||
lodThreshold = this.cameraZ * 0.9;
|
||||
|
||||
/**
|
||||
* Tangent of the field of view, i.e the angle between the horizon and the camera
|
||||
*/
|
||||
f = Math.tan(Math.PI * 0.5 - 0.5 * this.fov);
|
||||
|
||||
/**
|
||||
* Flat array storing particle data: [baseX, baseZ, baseY, r, g, b, a, size, perspectiveSize, halfSize, perspectiveDepthAlpha]
|
||||
* @type {Float32Array}
|
||||
*/
|
||||
#particleData = new Float32Array(0);
|
||||
#particleOffsetCount = this.#particleData.length / ParticleOffsets.PARTICLE_SIZE;
|
||||
|
||||
// Performance optimizations
|
||||
/**
|
||||
* @type {ImageData}
|
||||
*/
|
||||
// @ts-expect-error - Assigned in resize listener
|
||||
#buffer;
|
||||
|
||||
#gridWidth = 0;
|
||||
#gridDepth = 0;
|
||||
#fieldWidth = 0;
|
||||
#fieldHeight = 0;
|
||||
#fieldDepth = 0;
|
||||
|
||||
#relativeWidth = 0;
|
||||
#relativeHeight = 0;
|
||||
|
||||
// Frustum culling bounds
|
||||
#frustumLeft = 0;
|
||||
#frustumRight = 0;
|
||||
#frustumTop = 0;
|
||||
#frustumBottom = 0;
|
||||
#frustumNear = 0;
|
||||
#frustumFar = 0;
|
||||
|
||||
/**
|
||||
* @type {HTMLCanvasElement}
|
||||
*/
|
||||
#canvas;
|
||||
|
||||
/**
|
||||
* @type {CanvasRenderingContext2D}
|
||||
*/
|
||||
#ctx;
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
/**
|
||||
* @param {string | HTMLCanvasElement} target
|
||||
*/
|
||||
constructor(target) {
|
||||
const element =
|
||||
typeof target === "string" ? document.querySelector(target) : target;
|
||||
|
||||
if (!(element instanceof HTMLCanvasElement)) {
|
||||
throw new TypeError("Invalid canvas element");
|
||||
}
|
||||
|
||||
this.#canvas = element;
|
||||
|
||||
const ctx = this.#canvas.getContext("2d", {
|
||||
alpha: false,
|
||||
desynchronized: true,
|
||||
});
|
||||
|
||||
if (!ctx) {
|
||||
throw new TypeError("Failed to get 2D context");
|
||||
}
|
||||
|
||||
this.#ctx = ctx;
|
||||
|
||||
// We apply a background color to the canvas element itself for a slight performance boost.
|
||||
this.#canvas.style.background = getComputedStyle(document.body).backgroundColor;
|
||||
// All containment rules are applied to the element to further improve performance.
|
||||
this.#canvas.style.contain = "strict";
|
||||
// Applying a null transform on the canvas forces the browser to use the GPU for rendering.
|
||||
this.#canvas.style.willChange = "transform";
|
||||
this.#canvas.style.transform = "translate3d(0, 0, 0)";
|
||||
this.#canvas.style.pointerEvents = "none";
|
||||
|
||||
window.addEventListener("resize", this.refresh);
|
||||
const colorSchemeChangeListener = window.matchMedia(
|
||||
"(prefers-color-scheme: dark)",
|
||||
);
|
||||
|
||||
colorSchemeChangeListener.addEventListener("change", this.refresh);
|
||||
}
|
||||
|
||||
refresh = () => {
|
||||
const container = this.#canvas.parentElement;
|
||||
|
||||
if (!container) return;
|
||||
|
||||
this.width = container.offsetWidth;
|
||||
this.height = container.offsetHeight;
|
||||
this.aspectRatio = this.width / this.height;
|
||||
|
||||
this.#relativeWidth = this.width * this.dpi;
|
||||
this.#relativeHeight = this.height * this.dpi;
|
||||
|
||||
this.#canvas.width = this.#relativeWidth;
|
||||
this.#canvas.height = this.#relativeHeight;
|
||||
|
||||
this.#canvas.style.width = this.width + "px";
|
||||
this.#canvas.style.height = this.height + "px";
|
||||
|
||||
this.#ctx.scale(this.dpi, this.dpi);
|
||||
|
||||
this.#ctx.fillStyle = getComputedStyle(document.body).backgroundColor;
|
||||
|
||||
this.#buffer = this.#ctx.createImageData(
|
||||
this.width * this.dpi,
|
||||
this.height * this.dpi,
|
||||
);
|
||||
|
||||
this.#gridWidth = 300 * this.aspectRatio;
|
||||
this.#gridDepth = 300;
|
||||
|
||||
this.#fieldWidth = this.#gridWidth;
|
||||
this.#fieldHeight = this.waveHeight * this.aspectRatio;
|
||||
this.#fieldDepth = this.#gridDepth;
|
||||
|
||||
this.#calculateFrustumBounds();
|
||||
this.#generateParticles();
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Frustum Culling
|
||||
|
||||
#calculateFrustumBounds() {
|
||||
const halfFov = this.fov / 2;
|
||||
const tanHalfFov = Math.tan(halfFov);
|
||||
|
||||
// Calculate frustum bounds at different depths
|
||||
this.#frustumNear = 1;
|
||||
this.#frustumFar = this.cameraZ + this.#gridDepth;
|
||||
|
||||
// At near plane
|
||||
const nearHeight = 2 * this.#frustumNear * tanHalfFov;
|
||||
const nearWidth = nearHeight * this.aspectRatio;
|
||||
|
||||
// Store half-widths and half-heights for faster testing
|
||||
this.#frustumLeft = -nearWidth / 2;
|
||||
this.#frustumRight = nearWidth / 2;
|
||||
this.#frustumTop = nearHeight / 2;
|
||||
this.#frustumBottom = -nearHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if a 3D point is within the view frustum
|
||||
* @param {number} x - World X coordinate
|
||||
* @param {number} y - World Y coordinate
|
||||
* @param {number} z - World Z coordinate
|
||||
* @returns {boolean}
|
||||
*/
|
||||
#isInFrustum(x, y, z) {
|
||||
const projectedZ = z + this.cameraZ;
|
||||
|
||||
// Behind camera or too far
|
||||
if (projectedZ <= this.#frustumNear || projectedZ > this.#frustumFar) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculate frustum bounds at this depth
|
||||
const scale = projectedZ / this.#frustumNear;
|
||||
const left = this.#frustumLeft * scale;
|
||||
const right = this.#frustumRight * scale;
|
||||
const top = this.#frustumTop * scale;
|
||||
const bottom = this.#frustumBottom * scale;
|
||||
|
||||
// Test if point is within frustum bounds
|
||||
return x >= left && x <= right && y >= bottom && y <= top;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Particle Generation
|
||||
|
||||
#generateParticles() {
|
||||
const particleCount =
|
||||
Math.floor(this.#gridWidth / this.distance) *
|
||||
Math.floor(this.#gridDepth / this.distance);
|
||||
|
||||
this.#particleData = new Float32Array(
|
||||
particleCount * ParticleOffsets.PARTICLE_SIZE,
|
||||
);
|
||||
this.#particleOffsetCount =
|
||||
this.#particleData.length / ParticleOffsets.PARTICLE_SIZE;
|
||||
|
||||
let particleIndex = 0;
|
||||
|
||||
for (let x = 0; x < this.#gridWidth; x += this.distance) {
|
||||
const baseX = Math.round(-this.#gridWidth / 2 + x);
|
||||
|
||||
for (let z = 0; z < this.#gridDepth; z += this.distance) {
|
||||
const baseZ = -this.#gridDepth / 2 + z;
|
||||
const depthFactor = (z / this.#gridDepth) * 0.9;
|
||||
const horizonFactor = (x / this.#gridWidth) * 1;
|
||||
|
||||
// Pre-calculate perspective size based on fixed depth
|
||||
const projectedZ = baseZ + this.cameraZ;
|
||||
const baseSize = (this.height / 250) * this.pointSize * this.dpi;
|
||||
|
||||
// More exaggerated scaling with logarithmic falloff for horizon effect
|
||||
const normalizedDepth = Math.max(0, (projectedZ - 50) / 200);
|
||||
// Log scale for dramatic falloff
|
||||
const logScale = Math.log(1 + normalizedDepth * 4) / Math.log(6);
|
||||
// Invert so closer = larger
|
||||
const distanceFactor = Math.max(0.05, 1 - logScale);
|
||||
const perspectiveSize = baseSize * distanceFactor * 3;
|
||||
|
||||
const alpha = Math.max(Math.floor(z / this.#gridDepth), 0.85);
|
||||
const depthAlpha = Math.max(0.6, Math.pow(distanceFactor, 2));
|
||||
|
||||
const offset = particleIndex * ParticleOffsets.PARTICLE_SIZE;
|
||||
|
||||
// Store particle data in flat array
|
||||
this.#particleData[offset + ParticleOffsets.BASE_X] = baseX;
|
||||
this.#particleData[offset + ParticleOffsets.BASE_Z] = baseZ;
|
||||
this.#particleData[offset + ParticleOffsets.BASE_Y] =
|
||||
this.cameraZ * -0.4;
|
||||
this.#particleData[offset + ParticleOffsets.R] = Math.min(
|
||||
Math.floor(253 / (depthFactor * horizonFactor)),
|
||||
255,
|
||||
);
|
||||
this.#particleData[offset + ParticleOffsets.G] = Math.min(
|
||||
Math.floor(75 / horizonFactor),
|
||||
255,
|
||||
);
|
||||
this.#particleData[offset + ParticleOffsets.B] = Math.min(
|
||||
Math.floor(45 / (depthFactor * horizonFactor * 2)),
|
||||
255,
|
||||
);
|
||||
this.#particleData[offset + ParticleOffsets.A] = alpha;
|
||||
this.#particleData[offset + ParticleOffsets.SIZE] = baseSize;
|
||||
this.#particleData[offset + ParticleOffsets.PERSPECTIVE_SIZE] =
|
||||
perspectiveSize;
|
||||
this.#particleData[offset + ParticleOffsets.HALF_SIZE] = Math.max(
|
||||
1,
|
||||
Math.round(perspectiveSize / 1.5),
|
||||
);
|
||||
this.#particleData[offset + ParticleOffsets.PERSPECTIVE_DEPTH_ALPHA] =
|
||||
depthAlpha ** 2;
|
||||
|
||||
particleIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Projection
|
||||
|
||||
/**
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} z
|
||||
* @returns {{x: number, y: number, z: number} | null}
|
||||
*/
|
||||
project3DTo2D(x, y, z) {
|
||||
const projectedX = (x * this.f) / this.aspectRatio;
|
||||
const projectedY = y * this.f;
|
||||
const projectedZ = z + this.cameraZ;
|
||||
|
||||
if (projectedZ <= 0) return null;
|
||||
|
||||
// Convert to screen coordinates
|
||||
// top of canvas is horizon (y=0), bottom is near
|
||||
|
||||
const screenX = ((projectedX / projectedZ) * this.width) / 2 + this.width / 2;
|
||||
const screenY =
|
||||
this.height / 2 -
|
||||
((projectedY / projectedZ) * this.height) / 2 -
|
||||
this.cameraZ * 2;
|
||||
|
||||
return { x: screenX, y: screenY, z: projectedZ };
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Rendering
|
||||
|
||||
/**
|
||||
* @param {number} x
|
||||
* @param {number} z
|
||||
* @param {number} time
|
||||
* @returns {number}
|
||||
*/
|
||||
calculateWaveY(x, z, time) {
|
||||
return (
|
||||
(Math.cos((x / this.#fieldWidth) * this.turbulence + time * this.speed) +
|
||||
Math.sin(
|
||||
(z / this.#fieldDepth) * this.turbulence + time * this.speed,
|
||||
)) *
|
||||
this.#fieldHeight
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a simple point particle (for distant objects)
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} r
|
||||
* @param {number} g
|
||||
* @param {number} b
|
||||
* @param {number} a
|
||||
*/
|
||||
// eslint-disable-next-line max-params
|
||||
#drawPointParticle(x, y, r, g, b, a) {
|
||||
const data = this.#buffer.data;
|
||||
const centerX = Math.round(x * this.dpi);
|
||||
const centerY = Math.round(y * this.dpi);
|
||||
const width = this.#relativeWidth;
|
||||
const height = this.#relativeHeight;
|
||||
|
||||
if (centerX >= 0 && centerX < width && centerY >= 0 && centerY < height) {
|
||||
const index = (centerY * width + centerX) * 4;
|
||||
const alpha = Math.round(a * 255);
|
||||
const blendFactor = alpha * 1.25;
|
||||
|
||||
data[index] = Math.min(255, data[index] + (r * blendFactor) / 255);
|
||||
data[index + 1] = Math.min(255, data[index + 1] + (g * blendFactor) / 255);
|
||||
data[index + 2] = Math.min(255, data[index + 2] + (b * blendFactor) / 255);
|
||||
data[index + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a hollow circle particle
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} halfSize
|
||||
* @param {number} r
|
||||
* @param {number} g
|
||||
* @param {number} b
|
||||
* @param {number} a
|
||||
*/
|
||||
// eslint-disable-next-line max-params
|
||||
#drawCircleParticle(x, y, halfSize, r, g, b, a) {
|
||||
const data = this.#buffer.data;
|
||||
const centerX = Math.round(x * this.dpi);
|
||||
const centerY = Math.round(y * this.dpi);
|
||||
|
||||
const width = this.#relativeWidth;
|
||||
const height = this.#relativeHeight;
|
||||
|
||||
const alpha = Math.round(a * 255);
|
||||
const radius = halfSize;
|
||||
|
||||
// Draw hollow circle - just the outline
|
||||
for (let py = -radius; py <= radius; py++) {
|
||||
for (let px = -radius; px <= radius; px++) {
|
||||
const distance = Math.sqrt(px * px + py * py);
|
||||
|
||||
// Only draw pixels that are on the circle edge (within 1 pixel of radius)
|
||||
if (distance >= radius - 1 && distance <= radius) {
|
||||
const xPixel = centerX + px;
|
||||
const yPixel = centerY + py;
|
||||
|
||||
if (
|
||||
xPixel >= 0 &&
|
||||
xPixel < width &&
|
||||
yPixel >= 0 &&
|
||||
yPixel < height
|
||||
) {
|
||||
const index = (yPixel * width + xPixel) * 4;
|
||||
const blendFactor = alpha * 1.25;
|
||||
|
||||
// Additive blending for glow effect
|
||||
data[index] = Math.min(
|
||||
255,
|
||||
data[index] + (r * blendFactor) / 255,
|
||||
);
|
||||
data[index + 1] = Math.min(
|
||||
255,
|
||||
data[index + 1] + (g * blendFactor) / 255,
|
||||
);
|
||||
data[index + 2] = Math.min(
|
||||
255,
|
||||
data[index + 2] + (b * blendFactor) / 255,
|
||||
);
|
||||
data[index + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a filled circle particle
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} halfSize
|
||||
* @param {number} r
|
||||
* @param {number} g
|
||||
* @param {number} b
|
||||
* @param {number} a
|
||||
*/
|
||||
// eslint-disable-next-line max-params
|
||||
#drawFilledCircleParticle(x, y, halfSize, r, g, b, a) {
|
||||
const data = this.#buffer.data;
|
||||
const centerX = Math.round(x * this.dpi);
|
||||
const centerY = Math.round(y * this.dpi);
|
||||
|
||||
const width = this.#relativeWidth;
|
||||
const height = this.#relativeHeight;
|
||||
|
||||
const alpha = Math.round(a * 255);
|
||||
const radius = halfSize;
|
||||
|
||||
// Draw solid circle
|
||||
for (let py = -radius; py <= radius; py++) {
|
||||
for (let px = -radius; px <= radius; px++) {
|
||||
const distance = Math.sqrt(px * px + py * py);
|
||||
|
||||
// Draw all pixels within the radius
|
||||
if (distance <= radius) {
|
||||
const xPixel = centerX + px;
|
||||
const yPixel = centerY + py;
|
||||
|
||||
if (
|
||||
xPixel >= 0 &&
|
||||
xPixel < width &&
|
||||
yPixel >= 0 &&
|
||||
yPixel < height
|
||||
) {
|
||||
const index = (yPixel * width + xPixel) * 4;
|
||||
const blendFactor = alpha * 1.25;
|
||||
|
||||
// Additive blending for glow effect
|
||||
data[index] = Math.min(
|
||||
255,
|
||||
data[index] + (r * blendFactor) / 255,
|
||||
);
|
||||
data[index + 1] = Math.min(
|
||||
255,
|
||||
data[index + 1] + (g * blendFactor) / 255,
|
||||
);
|
||||
data[index + 2] = Math.min(
|
||||
255,
|
||||
data[index + 2] + (b * blendFactor) / 255,
|
||||
);
|
||||
data[index + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw an isosceles triangle particle
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} halfSize
|
||||
* @param {number} r
|
||||
* @param {number} g
|
||||
* @param {number} b
|
||||
* @param {number} a
|
||||
*/
|
||||
// eslint-disable-next-line max-params
|
||||
#drawTriangleParticle(x, y, halfSize, r, g, b, a) {
|
||||
const data = this.#buffer.data;
|
||||
const centerX = Math.round(x * this.dpi);
|
||||
const centerY = Math.round(y * this.dpi);
|
||||
|
||||
const width = this.#relativeWidth;
|
||||
const height = this.#relativeHeight;
|
||||
|
||||
const alpha = Math.round(a * 255);
|
||||
|
||||
// Draw hollow isosceles triangle pointing up - just the outline
|
||||
for (let py = -halfSize; py <= halfSize; py++) {
|
||||
// Calculate max width at this height - steeper taper for more triangular shape
|
||||
// 0 at top, 1 at bottom
|
||||
const normalizedHeight = (py + halfSize) / (2 * halfSize);
|
||||
const triangleWidth = Math.round(halfSize * normalizedHeight);
|
||||
|
||||
for (let px = -triangleWidth; px <= triangleWidth; px++) {
|
||||
// Only draw if on the edge (left edge, right edge, or bottom edge)
|
||||
const isLeftEdge = px === -triangleWidth;
|
||||
const isRightEdge = px === triangleWidth;
|
||||
const isBottomEdge = py === halfSize;
|
||||
|
||||
if (isLeftEdge || isRightEdge || isBottomEdge) {
|
||||
const xPixel = centerX + px;
|
||||
const yPixel = centerY + py;
|
||||
|
||||
if (
|
||||
xPixel >= 0 &&
|
||||
xPixel < width &&
|
||||
yPixel >= 0 &&
|
||||
yPixel < height
|
||||
) {
|
||||
const index = (yPixel * width + xPixel) * 4;
|
||||
const blendFactor = alpha * 1.25;
|
||||
|
||||
// Additive blending for glow effect
|
||||
data[index] = Math.min(
|
||||
255,
|
||||
data[index] + (r * blendFactor) / 255,
|
||||
);
|
||||
data[index + 1] = Math.min(
|
||||
255,
|
||||
data[index + 1] + (g * blendFactor) / 255,
|
||||
);
|
||||
data[index + 2] = Math.min(
|
||||
255,
|
||||
data[index + 2] + (b * blendFactor) / 255,
|
||||
);
|
||||
data[index + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#render = (time = performance.now()) => {
|
||||
const timestamp = time / 6000;
|
||||
this.#buffer.data.fill(0);
|
||||
|
||||
for (let i = 0; i < this.#particleOffsetCount; i++) {
|
||||
const offset = i * ParticleOffsets.PARTICLE_SIZE;
|
||||
|
||||
const baseX = this.#particleData[offset + ParticleOffsets.BASE_X];
|
||||
const baseZ = this.#particleData[offset + ParticleOffsets.BASE_Z];
|
||||
const baseY = this.#particleData[offset + ParticleOffsets.BASE_Y];
|
||||
|
||||
const waveY = this.calculateWaveY(baseX, baseZ, timestamp);
|
||||
const worldY = baseY + waveY;
|
||||
|
||||
if (!this.#isInFrustum(baseX, worldY, baseZ)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const projected = this.project3DTo2D(baseX, worldY, baseZ);
|
||||
|
||||
if (
|
||||
!projected ||
|
||||
projected.x < -50 ||
|
||||
projected.x > this.width + 50 ||
|
||||
projected.y < 0 ||
|
||||
projected.y > this.height + 50
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const distance = Math.abs(projected.z - this.cameraZ);
|
||||
const perspectiveSize =
|
||||
this.#particleData[offset + ParticleOffsets.PERSPECTIVE_SIZE];
|
||||
|
||||
if (
|
||||
distance > this.lodThreshold ||
|
||||
perspectiveSize < this.pointSizeCutoff
|
||||
) {
|
||||
this.#drawPointParticle(
|
||||
projected.x,
|
||||
projected.y,
|
||||
this.#particleData[offset + ParticleOffsets.R],
|
||||
this.#particleData[offset + ParticleOffsets.G],
|
||||
this.#particleData[offset + ParticleOffsets.B],
|
||||
this.#particleData[
|
||||
offset + ParticleOffsets.PERSPECTIVE_DEPTH_ALPHA
|
||||
],
|
||||
);
|
||||
} else {
|
||||
// Close enough for detailed triangle
|
||||
this.#drawCircleParticle(
|
||||
projected.x,
|
||||
projected.y,
|
||||
this.#particleData[offset + ParticleOffsets.HALF_SIZE],
|
||||
this.#particleData[offset + ParticleOffsets.R],
|
||||
this.#particleData[offset + ParticleOffsets.G],
|
||||
this.#particleData[offset + ParticleOffsets.B],
|
||||
this.#particleData[
|
||||
offset + ParticleOffsets.PERSPECTIVE_DEPTH_ALPHA
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.#ctx.putImageData(this.#buffer, 0, 0);
|
||||
this.#ctx.globalCompositeOperation = "soft-light";
|
||||
this.#ctx.fillRect(0, 0, this.#relativeWidth, this.#relativeHeight);
|
||||
this.#ctx.globalCompositeOperation = "source-over";
|
||||
|
||||
this.#renderFrameID = requestAnimationFrame(this.#render);
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Public Methods
|
||||
|
||||
#renderFrameID = -1;
|
||||
|
||||
play = () => {
|
||||
this.refresh();
|
||||
|
||||
this.#render();
|
||||
};
|
||||
|
||||
pause = () => {
|
||||
cancelAnimationFrame(this.#renderFrameID);
|
||||
};
|
||||
}
|
||||
|
||||
function drawAnimation() {
|
||||
const canvas = document.getElementById("waves-canvas");
|
||||
const waves = new WavesCanvas(canvas);
|
||||
waves.play();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", drawAnimation);
|
||||
} else {
|
||||
drawAnimation();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
750
web/src/standalone/loading/startup/demo.html
Normal file
750
web/src/standalone/loading/startup/demo.html
Normal file
@@ -0,0 +1,750 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
|
||||
<title>authentik</title>
|
||||
<style>
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-size: clamp(16px, 3dvw, 20px);
|
||||
background-color: #000;
|
||||
background-color: Canvas;
|
||||
color: #fff;
|
||||
color: CanvasText;
|
||||
font-family: system-ui, ui-sans-serif, sans-serif;
|
||||
text-align: center;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
min-height: 100dvh;
|
||||
margin: 0;
|
||||
padding-block: 0 6em;
|
||||
padding-inline: 0.5em;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 3.5em;
|
||||
max-width: 90dvw;
|
||||
}
|
||||
|
||||
.container {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: -1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.container canvas {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<svg
|
||||
class="logo"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 1000 144.29"
|
||||
aria-label="authentik"
|
||||
aria-role="heading"
|
||||
aria-level="1"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M106 41.08h25.39v101.2H106v-10.7a50 50 0 0 1-14.92 10.19 41.84 41.84 0 0 1-16.21 3.11q-19.61 0-33.91-15.21T26.64 91.86q0-23.43 13.85-38.41t33.63-15a42.78 42.78 0 0 1 17.09 3.44A46.82 46.82 0 0 1 106 52.24ZM79.29 61.91a25.65 25.65 0 0 0-19.56 8.33q-7.78 8.33-7.79 21.34t7.93 21.58a25.66 25.66 0 0 0 19.51 8.47 26.15 26.15 0 0 0 19.84-8.33q7.88-8.33 7.88-21.81 0-13.2-7.88-21.39t-19.93-8.19ZM168.39 41.08h25.67v48.74q0 14.22 2 19.76a17.24 17.24 0 0 0 6.29 8.61 18.06 18.06 0 0 0 10.65 3.07 18.6 18.6 0 0 0 10.77-3 17.7 17.7 0 0 0 6.57-8.88q1.59-4.36 1.59-18.7v-49.6h25.39V84q0 26.51-4.18 36.27a39.6 39.6 0 0 1-15.07 18.28q-10 6.38-25.3 6.37-16.65 0-26.93-7.44t-14.48-20.78q-3-9.21-3-33.49ZM297.3 3.78h25.39v37.3h15.07v21.85h-15.07v79.35H297.3V62.93h-13V41.08h13ZM362.86 2h25.21v49.3a57.74 57.74 0 0 1 15-9.63 38.56 38.56 0 0 1 15.25-3.21 34.36 34.36 0 0 1 25.39 10.42q8.83 9 8.84 26.51v66.88h-25V97.91q0-17.58-1.68-23.81t-5.71-9.3a16.07 16.07 0 0 0-10-3.07 18.85 18.85 0 0 0-13.26 5.11q-5.53 5.11-7.67 14-1.12 4.56-1.12 20.84v40.65h-25.25ZM589.91 99h-81.58q1.77 10.78 9.44 17.16t19.58 6.37a33.86 33.86 0 0 0 24.46-10l21.4 10a50.54 50.54 0 0 1-19.16 16.79q-11.16 5.44-26.51 5.44-23.82 0-38.79-15t-15-37.63q0-23.16 14.93-38.46t37.44-15.3q23.91 0 38.88 15.3t15 40.42Zm-25.4-20a25.48 25.48 0 0 0-9.92-13.77A28.81 28.81 0 0 0 537.4 60a30.42 30.42 0 0 0-18.64 5.95q-5 3.72-9.31 13.12ZM621.89 41.08h25.39v10.37q8.64-7.29 15.65-10.13a37.82 37.82 0 0 1 14.35-2.85A34.77 34.77 0 0 1 702.83 49q8.82 8.94 8.82 26.42v66.88h-25.11V98q0-18.12-1.63-24.06a16.44 16.44 0 0 0-5.66-9.06 15.8 15.8 0 0 0-10-3.11 18.73 18.73 0 0 0-13.23 5.15q-5.49 5.08-7.62 14.22-1.12 4.74-1.12 20.54v40.6h-25.39ZM750.71 3.78h25.39v37.3h15.07v21.85H776.1v79.35h-25.39V62.93h-13V41.08h13ZM826.09-.6a15.55 15.55 0 0 1 11.45 4.84A16.08 16.08 0 0 1 842.31 16a15.87 15.87 0 0 1-4.72 11.58 15.34 15.34 0 0 1-11.32 4.79 15.6 15.6 0 0 1-11.55-4.88 16.35 16.35 0 0 1-4.72-11.9 15.57 15.57 0 0 1 4.73-11.44A15.53 15.53 0 0 1 826.09-.6ZM813.39 41.08h25.39v101.2h-25.39zM873.47 2h25.39v80.8l37.39-41.72h31.89l-43.59 48.5 48.81 52.7h-31.53l-43-46.64v46.64h-25.36Z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<p>Server is starting up. Refreshing in a few seconds...</p>
|
||||
|
||||
<div class="container" aria-hidden="true">
|
||||
<canvas id="waves-canvas"></canvas>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
"use strict";
|
||||
|
||||
let timeoutID = -1;
|
||||
|
||||
function checkStatus() {
|
||||
console.log("Checking server status...");
|
||||
|
||||
return fetch(window.location.href, {
|
||||
method: "HEAD",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
console.log("Server is ready! Reloading...");
|
||||
clearTimeout(timeoutID);
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 503) {
|
||||
throw new Error("Server is still starting up...");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn(`Checking server status failed: ${error}`);
|
||||
timeoutID = setTimeout(checkStatus, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
|
||||
// if (window.location.origin.startsWith("http") && !queryParams.has("debug")) {
|
||||
// console.log("Checking server status...");
|
||||
// checkStatus();
|
||||
// }
|
||||
</script>
|
||||
|
||||
<script type="module">
|
||||
/**
|
||||
* @file Wave animation module.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Data offsets (11 properties per particle)
|
||||
*/
|
||||
const ParticleOffsets = {
|
||||
BASE_X: 0,
|
||||
BASE_Z: 1,
|
||||
BASE_Y: 2,
|
||||
R: 3,
|
||||
G: 4,
|
||||
B: 5,
|
||||
A: 6,
|
||||
SIZE: 7,
|
||||
PERSPECTIVE_SIZE: 8,
|
||||
HALF_SIZE: 9,
|
||||
PERSPECTIVE_DEPTH_ALPHA: 10,
|
||||
PARTICLE_SIZE: 11,
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {object} Particle
|
||||
* @property {number} baseX
|
||||
* @property {number} baseZ
|
||||
* @property {number} baseY
|
||||
* @property {number} x
|
||||
* @property {number} y
|
||||
* @property {number} r
|
||||
* @property {number} g
|
||||
* @property {number} b
|
||||
* @property {number} a
|
||||
* @property {number} size
|
||||
* @property {number} perspectiveSize
|
||||
* @property {number} halfSize
|
||||
* @property {number} perspectiveDepthAlpha
|
||||
*/
|
||||
class WavesCanvas {
|
||||
//#region Properties
|
||||
|
||||
width = 0;
|
||||
height = 0;
|
||||
dpi = Math.max(1, devicePixelRatio);
|
||||
|
||||
// Wave parameters
|
||||
turbulence = Math.PI * 6.0;
|
||||
pointSize = 1 * this.dpi;
|
||||
pointSizeCutoff = 5;
|
||||
speed = 10;
|
||||
waveHeight = 5;
|
||||
distance = 3;
|
||||
|
||||
fov = (60 * Math.PI) / 180;
|
||||
aspectRatio = 1;
|
||||
cameraZ = 95;
|
||||
|
||||
lodThreshold = this.cameraZ * 0.9;
|
||||
|
||||
/**
|
||||
* Tangent of the field of view, i.e the angle between the horizon and the camera
|
||||
*/
|
||||
f = Math.tan(Math.PI * 0.5 - 0.5 * this.fov);
|
||||
|
||||
/**
|
||||
* Flat array storing particle data:
|
||||
* [baseX, baseZ, baseY, r, g, b, a, size, perspectiveSize, halfSize, perspectiveDepthAlpha]
|
||||
* @type {Float32Array}
|
||||
*/
|
||||
#particleData = new Float32Array(0);
|
||||
#particleOffsetCount = this.#particleData.length / ParticleOffsets.PARTICLE_SIZE;
|
||||
|
||||
// Performance optimizations
|
||||
/**
|
||||
* @type {ImageData}
|
||||
*/
|
||||
// @ts-expect-error - Assigned in resize listener
|
||||
#buffer;
|
||||
|
||||
#gridWidth = 0;
|
||||
#gridDepth = 0;
|
||||
#fieldWidth = 0;
|
||||
#fieldHeight = 0;
|
||||
#fieldDepth = 0;
|
||||
|
||||
#relativeWidth = 0;
|
||||
#relativeHeight = 0;
|
||||
|
||||
// Frustum culling bounds
|
||||
#frustumLeft = 0;
|
||||
#frustumRight = 0;
|
||||
#frustumTop = 0;
|
||||
#frustumBottom = 0;
|
||||
#frustumNear = 0;
|
||||
#frustumFar = 0;
|
||||
|
||||
/**
|
||||
* @type {HTMLCanvasElement}
|
||||
*/
|
||||
#canvas;
|
||||
|
||||
/**
|
||||
* @type {CanvasRenderingContext2D}
|
||||
*/
|
||||
#ctx;
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
/**
|
||||
* @param {string | HTMLCanvasElement} target
|
||||
* @param {"circle" | "triangle"} shape
|
||||
*/
|
||||
constructor(target, shape = "circle") {
|
||||
const element =
|
||||
typeof target === "string" ? document.querySelector(target) : target;
|
||||
|
||||
if (!(element instanceof HTMLCanvasElement)) {
|
||||
throw new TypeError("Invalid canvas element");
|
||||
}
|
||||
|
||||
this.#canvas = element;
|
||||
|
||||
const ctx = this.#canvas.getContext("2d", {
|
||||
alpha: false,
|
||||
desynchronized: true,
|
||||
});
|
||||
|
||||
if (!ctx) {
|
||||
throw new TypeError("Failed to get 2D context");
|
||||
}
|
||||
|
||||
this.#ctx = ctx;
|
||||
|
||||
// We apply a background color to the canvas element itself for a slight performance boost.
|
||||
this.#canvas.style.background = getComputedStyle(document.body).backgroundColor;
|
||||
// All containment rules are applied to the element to further improve performance.
|
||||
this.#canvas.style.contain = "strict";
|
||||
// Applying a null transform on the canvas forces the browser to use the GPU for rendering.
|
||||
this.#canvas.style.willChange = "transform";
|
||||
this.#canvas.style.transform = "translate3d(0, 0, 0)";
|
||||
this.#canvas.style.pointerEvents = "none";
|
||||
|
||||
this.drawShape =
|
||||
shape === "circle" ? this.#drawCircleParticle : this.#drawTriangleParticle;
|
||||
|
||||
window.addEventListener("resize", this.refresh);
|
||||
const colorSchemeChangeListener = window.matchMedia(
|
||||
"(prefers-color-scheme: dark)",
|
||||
);
|
||||
|
||||
colorSchemeChangeListener.addEventListener("change", this.refresh);
|
||||
}
|
||||
|
||||
refresh = () => {
|
||||
const container = this.#canvas.parentElement;
|
||||
|
||||
if (!container) return;
|
||||
|
||||
this.width = container.offsetWidth;
|
||||
this.height = container.offsetHeight;
|
||||
this.aspectRatio = this.width / this.height;
|
||||
|
||||
this.#relativeWidth = this.width * this.dpi;
|
||||
this.#relativeHeight = this.height * this.dpi;
|
||||
|
||||
this.#canvas.width = this.#relativeWidth;
|
||||
this.#canvas.height = this.#relativeHeight;
|
||||
|
||||
this.#canvas.style.width = this.width + "px";
|
||||
this.#canvas.style.height = this.height + "px";
|
||||
|
||||
this.#ctx.scale(this.dpi, this.dpi);
|
||||
|
||||
this.#ctx.fillStyle = getComputedStyle(document.body).backgroundColor;
|
||||
|
||||
this.#buffer = this.#ctx.createImageData(
|
||||
this.width * this.dpi,
|
||||
this.height * this.dpi,
|
||||
);
|
||||
|
||||
this.#gridWidth = 300 * this.aspectRatio;
|
||||
this.#gridDepth = 300;
|
||||
|
||||
this.#fieldWidth = this.#gridWidth;
|
||||
this.#fieldHeight = this.waveHeight * this.aspectRatio;
|
||||
this.#fieldDepth = this.#gridDepth;
|
||||
|
||||
const prefersReducedMotion = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
);
|
||||
|
||||
if (prefersReducedMotion.matches) {
|
||||
this.speed = 5;
|
||||
}
|
||||
|
||||
this.#calculateFrustumBounds();
|
||||
this.#generateParticles();
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Frustum Culling
|
||||
|
||||
#calculateFrustumBounds() {
|
||||
const halfFov = this.fov / 2;
|
||||
const tanHalfFov = Math.tan(halfFov);
|
||||
|
||||
this.#frustumNear = 1;
|
||||
this.#frustumFar = this.cameraZ + this.#gridDepth;
|
||||
|
||||
const nearHeight = 2 * this.#frustumNear * tanHalfFov;
|
||||
const nearWidth = nearHeight * this.aspectRatio;
|
||||
|
||||
this.#frustumLeft = -nearWidth / 2;
|
||||
this.#frustumRight = nearWidth / 2;
|
||||
this.#frustumTop = -1 * (this.aspectRatio / this.height);
|
||||
this.#frustumBottom = -1.25 * nearHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if a 3D point is within the view frustum
|
||||
* @param {number} x - World X coordinate
|
||||
* @param {number} y - World Y coordinate
|
||||
* @param {number} z - World Z coordinate
|
||||
* @returns {boolean}
|
||||
*/
|
||||
#isInFrustum(x, y, z) {
|
||||
const projectedZ = z + this.cameraZ;
|
||||
|
||||
// Behind camera or too far
|
||||
if (projectedZ <= this.#frustumNear || projectedZ > this.#frustumFar) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculate frustum bounds at this depth
|
||||
const scale = projectedZ / this.#frustumNear;
|
||||
const left = this.#frustumLeft * scale;
|
||||
const right = this.#frustumRight * scale;
|
||||
const top = this.#frustumTop * scale;
|
||||
const bottom = this.#frustumBottom * scale;
|
||||
|
||||
// Test if point is within frustum bounds
|
||||
return x >= left && x <= right && y >= bottom && y <= top;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Particle Generation
|
||||
|
||||
#generateParticles() {
|
||||
const particleCount =
|
||||
Math.floor(this.#gridWidth / this.distance) *
|
||||
Math.floor(this.#gridDepth / this.distance);
|
||||
|
||||
this.#particleData = new Float32Array(
|
||||
particleCount * ParticleOffsets.PARTICLE_SIZE,
|
||||
);
|
||||
this.#particleOffsetCount =
|
||||
this.#particleData.length / ParticleOffsets.PARTICLE_SIZE;
|
||||
|
||||
let particleIndex = 0;
|
||||
|
||||
for (let x = 0; x < this.#gridWidth; x += this.distance) {
|
||||
const baseX = Math.round(-this.#gridWidth / 2 + x);
|
||||
|
||||
for (let z = 0; z < this.#gridDepth; z += this.distance) {
|
||||
const baseZ = -this.#gridDepth / 2 + z;
|
||||
const depthFactor = (z / this.#gridDepth) * 0.9;
|
||||
const horizonFactor = (x / this.#gridWidth) * 1;
|
||||
|
||||
// Pre-calculate perspective size based on fixed depth
|
||||
const projectedZ = baseZ + this.cameraZ;
|
||||
const baseSize = (this.height / 250) * this.pointSize * this.dpi;
|
||||
|
||||
// More exaggerated scaling with logarithmic falloff for horizon effect
|
||||
const normalizedDepth = Math.max(0, (projectedZ - 50) / 200);
|
||||
// Log scale for dramatic falloff
|
||||
const logScale = Math.log(1 + normalizedDepth * 4) / Math.log(6);
|
||||
// Invert so closer = larger
|
||||
const distanceFactor = Math.max(0.05, 1 - logScale);
|
||||
const perspectiveSize = baseSize * distanceFactor * 3;
|
||||
|
||||
const alpha = Math.max(Math.floor(z / this.#gridDepth), 0.85);
|
||||
const depthAlpha = Math.max(0.6, Math.pow(distanceFactor, 2));
|
||||
|
||||
const offset = particleIndex * ParticleOffsets.PARTICLE_SIZE;
|
||||
|
||||
// Store particle data in flat array
|
||||
this.#particleData[offset + ParticleOffsets.BASE_X] = baseX;
|
||||
this.#particleData[offset + ParticleOffsets.BASE_Z] = baseZ;
|
||||
this.#particleData[offset + ParticleOffsets.BASE_Y] =
|
||||
this.cameraZ * -0.4;
|
||||
this.#particleData[offset + ParticleOffsets.R] = Math.min(
|
||||
Math.floor(253 / (depthFactor * horizonFactor)),
|
||||
255,
|
||||
);
|
||||
this.#particleData[offset + ParticleOffsets.G] = Math.min(
|
||||
Math.floor(75 / horizonFactor),
|
||||
255,
|
||||
);
|
||||
this.#particleData[offset + ParticleOffsets.B] = Math.min(
|
||||
Math.floor(45 / (depthFactor * horizonFactor * 2)),
|
||||
255,
|
||||
);
|
||||
this.#particleData[offset + ParticleOffsets.A] = alpha;
|
||||
this.#particleData[offset + ParticleOffsets.SIZE] = baseSize;
|
||||
this.#particleData[offset + ParticleOffsets.PERSPECTIVE_SIZE] =
|
||||
perspectiveSize;
|
||||
this.#particleData[offset + ParticleOffsets.HALF_SIZE] = Math.max(
|
||||
1,
|
||||
Math.round(perspectiveSize / 1.5),
|
||||
);
|
||||
this.#particleData[offset + ParticleOffsets.PERSPECTIVE_DEPTH_ALPHA] =
|
||||
depthAlpha ** 2;
|
||||
|
||||
particleIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Projection
|
||||
|
||||
/**
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} z
|
||||
* @returns {{x: number, y: number, z: number} | null}
|
||||
*/
|
||||
project3DTo2D(x, y, z) {
|
||||
const projectedX = (x * this.f) / this.aspectRatio;
|
||||
const projectedY = y * this.f;
|
||||
const projectedZ = z + this.cameraZ;
|
||||
|
||||
if (projectedZ <= 0) return null;
|
||||
|
||||
// Convert to screen coordinates
|
||||
// top of canvas is horizon (y=0), bottom is near
|
||||
|
||||
const screenX = ((projectedX / projectedZ) * this.width) / 2 + this.width / 2;
|
||||
const screenY =
|
||||
this.height / 2 -
|
||||
((projectedY / projectedZ) * this.height) / 2 -
|
||||
this.cameraZ * 2;
|
||||
|
||||
return { x: screenX, y: screenY, z: projectedZ };
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Rendering
|
||||
|
||||
/**
|
||||
* @param {number} x
|
||||
* @param {number} z
|
||||
* @param {number} time
|
||||
* @returns {number}
|
||||
*/
|
||||
calculateWaveY(x, z, time) {
|
||||
return (
|
||||
(Math.cos((x / this.#fieldWidth) * this.turbulence + time * this.speed) +
|
||||
Math.sin(
|
||||
(z / this.#fieldDepth) * this.turbulence + time * this.speed,
|
||||
)) *
|
||||
this.#fieldHeight
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a simple point particle (for distant objects)
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} r
|
||||
* @param {number} g
|
||||
* @param {number} b
|
||||
* @param {number} a
|
||||
*/
|
||||
// eslint-disable-next-line max-params
|
||||
#drawPointParticle(x, y, r, g, b, a) {
|
||||
const data = this.#buffer.data;
|
||||
const centerX = Math.round(x * this.dpi);
|
||||
const centerY = Math.round(y * this.dpi);
|
||||
const width = this.#relativeWidth;
|
||||
const height = this.#relativeHeight;
|
||||
|
||||
if (centerX >= 0 && centerX < width && centerY >= 0 && centerY < height) {
|
||||
const index = (centerY * width + centerX) * 4;
|
||||
const alpha = Math.round(a * 255);
|
||||
const blendFactor = alpha * 1.25;
|
||||
|
||||
data[index] = Math.min(255, data[index] + (r * blendFactor) / 255);
|
||||
data[index + 1] = Math.min(255, data[index + 1] + (g * blendFactor) / 255);
|
||||
data[index + 2] = Math.min(255, data[index + 2] + (b * blendFactor) / 255);
|
||||
data[index + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a hollow circle particle
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} halfSize
|
||||
* @param {number} r
|
||||
* @param {number} g
|
||||
* @param {number} b
|
||||
* @param {number} a
|
||||
*/
|
||||
// eslint-disable-next-line max-params
|
||||
#drawCircleParticle = (x, y, halfSize, r, g, b, a) => {
|
||||
const data = this.#buffer.data;
|
||||
const centerX = Math.round(x * this.dpi);
|
||||
const centerY = Math.round(y * this.dpi);
|
||||
|
||||
const width = this.#relativeWidth;
|
||||
const height = this.#relativeHeight;
|
||||
|
||||
const alpha = Math.round(a * 255);
|
||||
const radius = halfSize;
|
||||
|
||||
// Draw hollow circle - just the outline
|
||||
for (let py = -radius; py <= radius; py++) {
|
||||
for (let px = -radius; px <= radius; px++) {
|
||||
const distance = Math.sqrt(px * px + py * py);
|
||||
|
||||
// Only draw pixels that are on the circle edge (within 1 pixel of radius)
|
||||
if (distance >= radius - 1 && distance <= radius) {
|
||||
const xPixel = centerX + px;
|
||||
const yPixel = centerY + py;
|
||||
|
||||
if (
|
||||
xPixel >= 0 &&
|
||||
xPixel < width &&
|
||||
yPixel >= 0 &&
|
||||
yPixel < height
|
||||
) {
|
||||
const index = (yPixel * width + xPixel) * 4;
|
||||
const blendFactor = alpha * 1.25;
|
||||
|
||||
// Additive blending for glow effect
|
||||
data[index] = Math.min(
|
||||
255,
|
||||
data[index] + (r * blendFactor) / 255,
|
||||
);
|
||||
data[index + 1] = Math.min(
|
||||
255,
|
||||
data[index + 1] + (g * blendFactor) / 255,
|
||||
);
|
||||
data[index + 2] = Math.min(
|
||||
255,
|
||||
data[index + 2] + (b * blendFactor) / 255,
|
||||
);
|
||||
data[index + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Draw an isosceles triangle particle
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} halfSize
|
||||
* @param {number} r
|
||||
* @param {number} g
|
||||
* @param {number} b
|
||||
* @param {number} a
|
||||
*/
|
||||
// eslint-disable-next-line max-params
|
||||
#drawTriangleParticle = (x, y, halfSize, r, g, b, a) => {
|
||||
const data = this.#buffer.data;
|
||||
const centerX = Math.round(x * this.dpi);
|
||||
const centerY = Math.round(y * this.dpi);
|
||||
|
||||
const width = this.#relativeWidth;
|
||||
const height = this.#relativeHeight;
|
||||
|
||||
const alpha = Math.round(a * 255);
|
||||
|
||||
// Draw hollow isosceles triangle pointing up - just the outline
|
||||
for (let py = -halfSize; py <= halfSize; py++) {
|
||||
// Calculate max width at this height - steeper taper for more triangular shape
|
||||
// 0 at top, 1 at bottom
|
||||
const normalizedHeight = (py + halfSize) / (2 * halfSize);
|
||||
const triangleWidth = Math.round(halfSize * normalizedHeight);
|
||||
|
||||
for (let px = -triangleWidth; px <= triangleWidth; px++) {
|
||||
// Only draw if on the edge (left edge, right edge, or bottom edge)
|
||||
const isLeftEdge = px === -triangleWidth;
|
||||
const isRightEdge = px === triangleWidth;
|
||||
const isBottomEdge = py === halfSize;
|
||||
|
||||
if (isLeftEdge || isRightEdge || isBottomEdge) {
|
||||
const xPixel = centerX + px;
|
||||
const yPixel = centerY + py;
|
||||
|
||||
if (
|
||||
xPixel >= 0 &&
|
||||
xPixel < width &&
|
||||
yPixel >= 0 &&
|
||||
yPixel < height
|
||||
) {
|
||||
const index = (yPixel * width + xPixel) * 4;
|
||||
const blendFactor = alpha * 1.25;
|
||||
|
||||
// Additive blending for glow effect
|
||||
data[index] = Math.min(
|
||||
255,
|
||||
data[index] + (r * blendFactor) / 255,
|
||||
);
|
||||
data[index + 1] = Math.min(
|
||||
255,
|
||||
data[index + 1] + (g * blendFactor) / 255,
|
||||
);
|
||||
data[index + 2] = Math.min(
|
||||
255,
|
||||
data[index + 2] + (b * blendFactor) / 255,
|
||||
);
|
||||
data[index + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#render = (time = performance.now()) => {
|
||||
const timestamp = time / 6000;
|
||||
this.#buffer.data.fill(0);
|
||||
|
||||
for (let i = 0; i < this.#particleOffsetCount; i++) {
|
||||
const offset = i * ParticleOffsets.PARTICLE_SIZE;
|
||||
|
||||
const baseX = this.#particleData[offset + ParticleOffsets.BASE_X];
|
||||
const baseZ = this.#particleData[offset + ParticleOffsets.BASE_Z];
|
||||
const baseY = this.#particleData[offset + ParticleOffsets.BASE_Y];
|
||||
|
||||
const waveY = this.calculateWaveY(baseX, baseZ, timestamp);
|
||||
const worldY = baseY + waveY;
|
||||
|
||||
if (!this.#isInFrustum(baseX, worldY, baseZ)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const projected = this.project3DTo2D(baseX, worldY, baseZ);
|
||||
|
||||
if (
|
||||
!projected ||
|
||||
projected.x < -50 ||
|
||||
projected.x > this.width + 50 ||
|
||||
projected.y < 0 ||
|
||||
projected.y > this.height + 50
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const distance = Math.abs(projected.z - this.cameraZ);
|
||||
const perspectiveSize =
|
||||
this.#particleData[offset + ParticleOffsets.PERSPECTIVE_SIZE];
|
||||
|
||||
if (
|
||||
distance > this.lodThreshold ||
|
||||
perspectiveSize < this.pointSizeCutoff
|
||||
) {
|
||||
this.#drawPointParticle(
|
||||
projected.x,
|
||||
projected.y,
|
||||
this.#particleData[offset + ParticleOffsets.R],
|
||||
this.#particleData[offset + ParticleOffsets.G],
|
||||
this.#particleData[offset + ParticleOffsets.B],
|
||||
this.#particleData[
|
||||
offset + ParticleOffsets.PERSPECTIVE_DEPTH_ALPHA
|
||||
],
|
||||
);
|
||||
} else {
|
||||
this.#drawCircleParticle(
|
||||
projected.x,
|
||||
projected.y,
|
||||
this.#particleData[offset + ParticleOffsets.HALF_SIZE],
|
||||
this.#particleData[offset + ParticleOffsets.R],
|
||||
this.#particleData[offset + ParticleOffsets.G],
|
||||
this.#particleData[offset + ParticleOffsets.B],
|
||||
this.#particleData[
|
||||
offset + ParticleOffsets.PERSPECTIVE_DEPTH_ALPHA
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.#ctx.putImageData(this.#buffer, 0, 0);
|
||||
this.#ctx.globalCompositeOperation = "soft-light";
|
||||
this.#ctx.fillRect(0, 0, this.#relativeWidth, this.#relativeHeight);
|
||||
this.#ctx.globalCompositeOperation = "source-over";
|
||||
|
||||
this.#renderFrameID = requestAnimationFrame(this.#render);
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Public Methods
|
||||
|
||||
#renderFrameID = -1;
|
||||
|
||||
play = () => {
|
||||
this.refresh();
|
||||
|
||||
this.#render();
|
||||
};
|
||||
|
||||
pause = () => {
|
||||
cancelAnimationFrame(this.#renderFrameID);
|
||||
};
|
||||
}
|
||||
|
||||
function drawAnimation() {
|
||||
const canvas = document.getElementById("waves-canvas");
|
||||
const waves = new WavesCanvas(canvas);
|
||||
waves.play();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", drawAnimation);
|
||||
} else {
|
||||
drawAnimation();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
508
web/src/standalone/loading/startup/wave-boi.mjs
Normal file
508
web/src/standalone/loading/startup/wave-boi.mjs
Normal file
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* @file Wave animation module.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Data offsets (11 properties per particle)
|
||||
*/
|
||||
const ParticleOffsets = {
|
||||
BASE_X: 0,
|
||||
BASE_Z: 1,
|
||||
BASE_Y: 2,
|
||||
R: 3,
|
||||
G: 4,
|
||||
B: 5,
|
||||
A: 6,
|
||||
SIZE: 7,
|
||||
PERSPECTIVE_SIZE: 8,
|
||||
HALF_SIZE: 9,
|
||||
PERSPECTIVE_DEPTH_ALPHA: 10,
|
||||
PARTICLE_SIZE: 11,
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {object} Particle
|
||||
* @property {number} baseX
|
||||
* @property {number} baseZ
|
||||
* @property {number} baseY
|
||||
* @property {number} x
|
||||
* @property {number} y
|
||||
* @property {number} r
|
||||
* @property {number} g
|
||||
* @property {number} b
|
||||
* @property {number} a
|
||||
* @property {number} size
|
||||
* @property {number} perspectiveSize
|
||||
* @property {number} halfSize
|
||||
* @property {number} perspectiveDepthAlpha
|
||||
*/
|
||||
export class WavesCanvas {
|
||||
//#region Properties
|
||||
|
||||
width = 0;
|
||||
height = 0;
|
||||
dpi = Math.max(1, devicePixelRatio);
|
||||
|
||||
// Wave parameters
|
||||
turbulence = Math.PI * 6.0;
|
||||
pointSize = 1.5 * this.dpi;
|
||||
pointSizeCutoff = 5;
|
||||
speed = 10;
|
||||
waveHeight = 5;
|
||||
distance = 2;
|
||||
fov = (60 * Math.PI) / 180;
|
||||
|
||||
aspectRatio = 1;
|
||||
cameraZ = 95;
|
||||
|
||||
lodThreshold = this.cameraZ * 0.9;
|
||||
|
||||
/**
|
||||
* Tangent of the field of view, i.e the angle between the horizon and the camera
|
||||
*/
|
||||
f = Math.tan(Math.PI * 0.5 - 0.5 * this.fov);
|
||||
|
||||
/**
|
||||
* Flat array storing particle data: [baseX, baseZ, baseY, r, g, b, a, size, perspectiveSize, halfSize, perspectiveDepthAlpha]
|
||||
* @type {Float32Array}
|
||||
*/
|
||||
#particleData = new Float32Array(0);
|
||||
#particleOffsetCount = this.#particleData.length / ParticleOffsets.PARTICLE_SIZE;
|
||||
|
||||
// Performance optimizations
|
||||
/**
|
||||
* @type {ImageData}
|
||||
*/
|
||||
// @ts-expect-error - Assigned in resize listener
|
||||
#buffer;
|
||||
|
||||
#gridWidth = 0;
|
||||
#gridDepth = 0;
|
||||
#fieldWidth = 0;
|
||||
#fieldHeight = 0;
|
||||
#fieldDepth = 0;
|
||||
|
||||
#relativeWidth = 0;
|
||||
#relativeHeight = 0;
|
||||
|
||||
// Frustum culling bounds
|
||||
#frustumLeft = 0;
|
||||
#frustumRight = 0;
|
||||
#frustumTop = 0;
|
||||
#frustumBottom = 0;
|
||||
#frustumNear = 0;
|
||||
#frustumFar = 0;
|
||||
|
||||
/**
|
||||
* @type {HTMLCanvasElement}
|
||||
*/
|
||||
#canvas;
|
||||
|
||||
/**
|
||||
* @type {CanvasRenderingContext2D}
|
||||
*/
|
||||
#ctx;
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
/**
|
||||
* @param {string | HTMLCanvasElement} target
|
||||
*/
|
||||
constructor(target) {
|
||||
const element = typeof target === "string" ? document.querySelector(target) : target;
|
||||
|
||||
if (!(element instanceof HTMLCanvasElement)) {
|
||||
throw new TypeError("Invalid canvas element");
|
||||
}
|
||||
|
||||
this.#canvas = element;
|
||||
|
||||
const ctx = this.#canvas.getContext("2d", {
|
||||
alpha: false,
|
||||
desynchronized: true,
|
||||
});
|
||||
|
||||
if (!ctx) {
|
||||
throw new TypeError("Failed to get 2D context");
|
||||
}
|
||||
|
||||
this.#ctx = ctx;
|
||||
|
||||
// We apply a background color to the canvas element itself for a slight performance boost.
|
||||
this.#canvas.style.background = getComputedStyle(document.body).backgroundColor;
|
||||
// All containment rules are applied to the element to further improve performance.
|
||||
this.#canvas.style.contain = "strict";
|
||||
// Applying a null transform on the canvas forces the browser to use the GPU for rendering.
|
||||
this.#canvas.style.willChange = "transform";
|
||||
this.#canvas.style.transform = "translate3d(0, 0, 0)";
|
||||
this.#canvas.style.pointerEvents = "none";
|
||||
|
||||
window.addEventListener("resize", this.refresh);
|
||||
const colorSchemeChangeListener = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
colorSchemeChangeListener.addEventListener("change", this.refresh);
|
||||
}
|
||||
|
||||
refresh = () => {
|
||||
const container = this.#canvas.parentElement;
|
||||
|
||||
if (!container) return;
|
||||
|
||||
this.width = container.offsetWidth;
|
||||
this.height = container.offsetHeight;
|
||||
this.aspectRatio = this.width / this.height;
|
||||
|
||||
this.#relativeWidth = this.width * this.dpi;
|
||||
this.#relativeHeight = this.height * this.dpi;
|
||||
|
||||
this.#canvas.width = this.#relativeWidth;
|
||||
this.#canvas.height = this.#relativeHeight;
|
||||
|
||||
this.#canvas.style.width = this.width + "px";
|
||||
this.#canvas.style.height = this.height + "px";
|
||||
|
||||
this.#ctx.scale(this.dpi, this.dpi);
|
||||
|
||||
this.#ctx.fillStyle = getComputedStyle(document.body).backgroundColor;
|
||||
|
||||
this.#buffer = this.#ctx.createImageData(this.width * this.dpi, this.height * this.dpi);
|
||||
|
||||
this.#gridWidth = 300 * this.aspectRatio;
|
||||
this.#gridDepth = 300;
|
||||
|
||||
this.#fieldWidth = this.#gridWidth;
|
||||
this.#fieldHeight = this.waveHeight * this.aspectRatio;
|
||||
this.#fieldDepth = this.#gridDepth;
|
||||
|
||||
this.#calculateFrustumBounds();
|
||||
this.#generateParticles();
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Frustum Culling
|
||||
|
||||
#calculateFrustumBounds() {
|
||||
const halfFov = this.fov / 2;
|
||||
const tanHalfFov = Math.tan(halfFov);
|
||||
|
||||
// Calculate frustum bounds at different depths
|
||||
this.#frustumNear = 1;
|
||||
this.#frustumFar = this.cameraZ + this.#gridDepth;
|
||||
|
||||
// At near plane
|
||||
const nearHeight = 2 * this.#frustumNear * tanHalfFov;
|
||||
const nearWidth = nearHeight * this.aspectRatio;
|
||||
|
||||
// Store half-widths and half-heights for faster testing
|
||||
this.#frustumLeft = -nearWidth / 2;
|
||||
this.#frustumRight = nearWidth / 2;
|
||||
this.#frustumTop = nearHeight / 2;
|
||||
this.#frustumBottom = -nearHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if a 3D point is within the view frustum
|
||||
* @param {number} x - World X coordinate
|
||||
* @param {number} y - World Y coordinate
|
||||
* @param {number} z - World Z coordinate
|
||||
* @returns {boolean}
|
||||
*/
|
||||
#isInFrustum(x, y, z) {
|
||||
const projectedZ = z + this.cameraZ;
|
||||
|
||||
// Behind camera or too far
|
||||
if (projectedZ <= this.#frustumNear || projectedZ > this.#frustumFar) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculate frustum bounds at this depth
|
||||
const scale = projectedZ / this.#frustumNear;
|
||||
const left = this.#frustumLeft * scale;
|
||||
const right = this.#frustumRight * scale;
|
||||
const top = this.#frustumTop * scale;
|
||||
const bottom = this.#frustumBottom * scale;
|
||||
|
||||
// Test if point is within frustum bounds
|
||||
return x >= left && x <= right && y >= bottom && y <= top;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Particle Generation
|
||||
|
||||
#generateParticles() {
|
||||
const particleCount =
|
||||
Math.floor(this.#gridWidth / this.distance) *
|
||||
Math.floor(this.#gridDepth / this.distance);
|
||||
|
||||
this.#particleData = new Float32Array(particleCount * ParticleOffsets.PARTICLE_SIZE);
|
||||
this.#particleOffsetCount = this.#particleData.length / ParticleOffsets.PARTICLE_SIZE;
|
||||
|
||||
let particleIndex = 0;
|
||||
|
||||
for (let x = 0; x < this.#gridWidth; x += this.distance) {
|
||||
const baseX = Math.round(-this.#gridWidth / 2 + x);
|
||||
|
||||
for (let z = 0; z < this.#gridDepth; z += this.distance) {
|
||||
const baseZ = -this.#gridDepth / 2 + z;
|
||||
const depthFactor = (z / this.#gridDepth) * 0.9;
|
||||
const horizonFactor = (x / this.#gridWidth) * 1;
|
||||
|
||||
// Pre-calculate perspective size based on fixed depth
|
||||
const projectedZ = baseZ + this.cameraZ;
|
||||
const baseSize = (this.height / 250) * this.pointSize * this.dpi;
|
||||
|
||||
// More exaggerated scaling with logarithmic falloff for horizon effect
|
||||
const normalizedDepth = Math.max(0, (projectedZ - 50) / 200);
|
||||
// Log scale for dramatic falloff
|
||||
const logScale = Math.log(1 + normalizedDepth * 4) / Math.log(6);
|
||||
// Invert so closer = larger
|
||||
const distanceFactor = Math.max(0.05, 1 - logScale);
|
||||
const perspectiveSize = baseSize * distanceFactor * 3;
|
||||
|
||||
const alpha = Math.max(Math.floor(z / this.#gridDepth), 0.85);
|
||||
const depthAlpha = Math.max(0.6, Math.pow(distanceFactor, 2));
|
||||
|
||||
const offset = particleIndex * ParticleOffsets.PARTICLE_SIZE;
|
||||
|
||||
// Store particle data in flat array
|
||||
this.#particleData[offset + ParticleOffsets.BASE_X] = baseX;
|
||||
this.#particleData[offset + ParticleOffsets.BASE_Z] = baseZ;
|
||||
this.#particleData[offset + ParticleOffsets.BASE_Y] = this.cameraZ * -0.4;
|
||||
this.#particleData[offset + ParticleOffsets.R] = Math.min(
|
||||
Math.floor(253 / (depthFactor * horizonFactor)),
|
||||
255,
|
||||
);
|
||||
this.#particleData[offset + ParticleOffsets.G] = Math.min(
|
||||
Math.floor(75 / horizonFactor),
|
||||
255,
|
||||
);
|
||||
this.#particleData[offset + ParticleOffsets.B] = Math.min(
|
||||
Math.floor(45 / (depthFactor * horizonFactor * 2)),
|
||||
255,
|
||||
);
|
||||
this.#particleData[offset + ParticleOffsets.A] = alpha;
|
||||
this.#particleData[offset + ParticleOffsets.SIZE] = baseSize;
|
||||
this.#particleData[offset + ParticleOffsets.PERSPECTIVE_SIZE] = perspectiveSize;
|
||||
this.#particleData[offset + ParticleOffsets.HALF_SIZE] = Math.max(
|
||||
1,
|
||||
Math.round(perspectiveSize / 1.5),
|
||||
);
|
||||
this.#particleData[offset + ParticleOffsets.PERSPECTIVE_DEPTH_ALPHA] =
|
||||
depthAlpha ** 2;
|
||||
|
||||
particleIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Projection
|
||||
|
||||
/**
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} z
|
||||
* @returns {{x: number, y: number, z: number} | null}
|
||||
*/
|
||||
project3DTo2D(x, y, z) {
|
||||
const projectedX = (x * this.f) / this.aspectRatio;
|
||||
const projectedY = y * this.f;
|
||||
const projectedZ = z + this.cameraZ;
|
||||
|
||||
if (projectedZ <= 0) return null;
|
||||
|
||||
// Convert to screen coordinates
|
||||
// top of canvas is horizon (y=0), bottom is near
|
||||
|
||||
const screenX = ((projectedX / projectedZ) * this.width) / 2 + this.width / 2;
|
||||
const screenY =
|
||||
this.height / 2 - ((projectedY / projectedZ) * this.height) / 2 - this.cameraZ * 2;
|
||||
|
||||
return { x: screenX, y: screenY, z: projectedZ };
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Rendering
|
||||
|
||||
/**
|
||||
* @param {number} x
|
||||
* @param {number} z
|
||||
* @param {number} time
|
||||
* @returns {number}
|
||||
*/
|
||||
calculateWaveY(x, z, time) {
|
||||
return (
|
||||
(Math.cos((x / this.#fieldWidth) * this.turbulence + time * this.speed) +
|
||||
Math.sin((z / this.#fieldDepth) * this.turbulence + time * this.speed)) *
|
||||
this.#fieldHeight
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a simple point particle (for distant objects)
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} r
|
||||
* @param {number} g
|
||||
* @param {number} b
|
||||
* @param {number} a
|
||||
*/
|
||||
// eslint-disable-next-line max-params
|
||||
#drawPointParticle(x, y, r, g, b, a) {
|
||||
const data = this.#buffer.data;
|
||||
const centerX = Math.round(x * this.dpi);
|
||||
const centerY = Math.round(y * this.dpi);
|
||||
const width = this.#relativeWidth;
|
||||
const height = this.#relativeHeight;
|
||||
|
||||
if (centerX >= 0 && centerX < width && centerY >= 0 && centerY < height) {
|
||||
const index = (centerY * width + centerX) * 4;
|
||||
const alpha = Math.round(a * 255);
|
||||
const blendFactor = alpha * 1.25;
|
||||
|
||||
data[index] = Math.min(255, data[index] + (r * blendFactor) / 255);
|
||||
data[index + 1] = Math.min(255, data[index + 1] + (g * blendFactor) / 255);
|
||||
data[index + 2] = Math.min(255, data[index + 2] + (b * blendFactor) / 255);
|
||||
data[index + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw an isosceles triangle particle
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} halfSize
|
||||
* @param {number} r
|
||||
* @param {number} g
|
||||
* @param {number} b
|
||||
* @param {number} a
|
||||
*/
|
||||
// eslint-disable-next-line max-params
|
||||
#drawTriangleParticle(x, y, halfSize, r, g, b, a) {
|
||||
const data = this.#buffer.data;
|
||||
const centerX = Math.round(x * this.dpi);
|
||||
const centerY = Math.round(y * this.dpi);
|
||||
|
||||
const width = this.#relativeWidth;
|
||||
const height = this.#relativeHeight;
|
||||
|
||||
const alpha = Math.round(a * 255);
|
||||
|
||||
// Draw hollow isosceles triangle pointing up - just the outline
|
||||
for (let py = -halfSize; py <= halfSize; py++) {
|
||||
// Calculate max width at this height - steeper taper for more triangular shape
|
||||
// 0 at top, 1 at bottom
|
||||
const normalizedHeight = (py + halfSize) / (2 * halfSize);
|
||||
const triangleWidth = Math.round(halfSize * normalizedHeight);
|
||||
|
||||
for (let px = -triangleWidth; px <= triangleWidth; px++) {
|
||||
// Only draw if on the edge (left edge, right edge, or bottom edge)
|
||||
const isLeftEdge = px === -triangleWidth;
|
||||
const isRightEdge = px === triangleWidth;
|
||||
const isBottomEdge = py === halfSize;
|
||||
|
||||
if (isLeftEdge || isRightEdge || isBottomEdge) {
|
||||
const xPixel = centerX + px;
|
||||
const yPixel = centerY + py;
|
||||
|
||||
if (xPixel >= 0 && xPixel < width && yPixel >= 0 && yPixel < height) {
|
||||
const index = (yPixel * width + xPixel) * 4;
|
||||
const blendFactor = alpha * 1.25;
|
||||
|
||||
// Additive blending for glow effect
|
||||
data[index] = Math.min(255, data[index] + (r * blendFactor) / 255);
|
||||
data[index + 1] = Math.min(255, data[index + 1] + (g * blendFactor) / 255);
|
||||
data[index + 2] = Math.min(255, data[index + 2] + (b * blendFactor) / 255);
|
||||
data[index + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#render = (time = performance.now()) => {
|
||||
const timestamp = time / 6000;
|
||||
this.#buffer.data.fill(0);
|
||||
|
||||
for (let i = 0; i < this.#particleOffsetCount; i++) {
|
||||
const offset = i * ParticleOffsets.PARTICLE_SIZE;
|
||||
|
||||
const baseX = this.#particleData[offset + ParticleOffsets.BASE_X];
|
||||
const baseZ = this.#particleData[offset + ParticleOffsets.BASE_Z];
|
||||
const baseY = this.#particleData[offset + ParticleOffsets.BASE_Y];
|
||||
|
||||
const waveY = this.calculateWaveY(baseX, baseZ, timestamp);
|
||||
const worldY = baseY + waveY;
|
||||
|
||||
if (!this.#isInFrustum(baseX, worldY, baseZ)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const projected = this.project3DTo2D(baseX, worldY, baseZ);
|
||||
|
||||
if (
|
||||
!projected ||
|
||||
projected.x < -50 ||
|
||||
projected.x > this.width + 50 ||
|
||||
projected.y < 0 ||
|
||||
projected.y > this.height + 50
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const distance = Math.abs(projected.z - this.cameraZ);
|
||||
const perspectiveSize = this.#particleData[offset + ParticleOffsets.PERSPECTIVE_SIZE];
|
||||
|
||||
if (distance > this.lodThreshold || perspectiveSize < this.pointSizeCutoff) {
|
||||
this.#drawPointParticle(
|
||||
projected.x,
|
||||
projected.y,
|
||||
this.#particleData[offset + ParticleOffsets.R],
|
||||
this.#particleData[offset + ParticleOffsets.G],
|
||||
this.#particleData[offset + ParticleOffsets.B],
|
||||
this.#particleData[offset + ParticleOffsets.PERSPECTIVE_DEPTH_ALPHA],
|
||||
);
|
||||
} else {
|
||||
// Close enough for detailed triangle
|
||||
this.#drawTriangleParticle(
|
||||
projected.x,
|
||||
projected.y,
|
||||
this.#particleData[offset + ParticleOffsets.HALF_SIZE],
|
||||
this.#particleData[offset + ParticleOffsets.R],
|
||||
this.#particleData[offset + ParticleOffsets.G],
|
||||
this.#particleData[offset + ParticleOffsets.B],
|
||||
this.#particleData[offset + ParticleOffsets.PERSPECTIVE_DEPTH_ALPHA],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.#ctx.putImageData(this.#buffer, 0, 0);
|
||||
this.#ctx.globalCompositeOperation = "soft-light";
|
||||
this.#ctx.fillRect(0, 0, this.#relativeWidth, this.#relativeHeight);
|
||||
this.#ctx.globalCompositeOperation = "source-over";
|
||||
|
||||
this.#renderFrameID = requestAnimationFrame(this.#render);
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Public Methods
|
||||
|
||||
#renderFrameID = -1;
|
||||
|
||||
play = () => {
|
||||
this.refresh();
|
||||
|
||||
this.#render();
|
||||
};
|
||||
|
||||
pause = () => {
|
||||
cancelAnimationFrame(this.#renderFrameID);
|
||||
};
|
||||
}
|
||||
@@ -11,4 +11,6 @@ var RobotsTxt []byte
|
||||
//go:embed security.txt
|
||||
var SecurityTxt []byte
|
||||
|
||||
var StaticHandler = http.FileServer(http.Dir("./web/dist/"))
|
||||
var StaticDir = http.Dir("./web/dist/")
|
||||
|
||||
var StaticHandler = http.FileServer(StaticDir)
|
||||
|
||||
Reference in New Issue
Block a user