web: Automatic reload during server start up. (#16030)

* web: Automatic reload during server start up.

* web: Flesh out reload behavior.

* web: Flesh out wave boi.
This commit is contained in:
Teffen Ellis
2025-08-26 17:13:22 +02:00
committed by GitHub
parent a8ecc9b530
commit 04a8357708
5 changed files with 802 additions and 6 deletions

View File

@@ -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")
}

View File

@@ -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: ".",

View File

@@ -0,0 +1,140 @@
<!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">
import("/static/dist/standalone/loading/wave-boi.mjs")
.then(({ WavesCanvas }) => {
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();
}
})
.catch((error) => {
console.debug("Skipping waves canvas:", error);
});
</script>
</body>
</html>

View File

@@ -0,0 +1,585 @@
/**
* @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 * 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;
/**
* @type {Uint8ClampedArray}
*/
// @ts-expect-error - Assigned in resize listener
#sample;
#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;
/**
* The method to use to draw the waves.
* @type {(x: number, y: number, halfSize: number, r: number, g: number, b: number, a: number) => void}
*/
#drawShape;
//#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.#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();
this.#ctx.fillStyle = getComputedStyle(document.body).backgroundColor;
this.#ctx.fillRect(0, 0, this.#relativeWidth, this.#relativeHeight);
// Grab a single pixel from the canvas and store it for later blending.
this.#sample = this.#ctx.getImageData(0, 0, 1, 1).data;
};
//#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.#drawShape(
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],
);
}
}
// Image data doesn't support blending, so we need to do it manually.
for (let i = 0; i < this.#buffer.data.length; i += 4) {
this.#buffer.data[i] ||= this.#sample[0];
this.#buffer.data[i + 1] ||= this.#sample[1];
this.#buffer.data[i + 2] ||= this.#sample[2];
this.#buffer.data[i + 3] ||= this.#sample[3];
}
this.#ctx.putImageData(this.#buffer, 0, 0);
this.#renderFrameID = requestAnimationFrame(this.#render);
};
//#endregion
//#region Public Methods
#renderFrameID = -1;
play = () => {
this.refresh();
this.#render();
};
pause = () => {
cancelAnimationFrame(this.#renderFrameID);
};
}

View File

@@ -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)