From 1c6bf1039c0f3dee7e695d24c16d61637fc95d7f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?L=27=C3=A9lectron=20rare?=
<108685187+electron-rare@users.noreply.github.com>
Date: Wed, 13 May 2026 09:34:19 +0200
Subject: [PATCH] feat(web-realart): favicon + WebGL scene overhaul
Add favicon.svg. Refactor WebGL app.js with new scene
structure. Add OSC feed display to audio/hydra/index pages.
Update docker-compose ports and service config.
---
web_realart/docker-compose.yml | 6 +-
web_realart/public/audio/index.html | 1 +
web_realart/public/favicon.svg | 1 +
web_realart/public/hydra/index.html | 1 +
web_realart/public/index.html | 1 +
web_realart/public/webgl/app.js | 187 ++++++++++++++--------------
web_realart/public/webgl/index.html | 4 +-
7 files changed, 106 insertions(+), 95 deletions(-)
create mode 100644 web_realart/public/favicon.svg
diff --git a/web_realart/docker-compose.yml b/web_realart/docker-compose.yml
index e38c739..bec6466 100644
--- a/web_realart/docker-compose.yml
+++ b/web_realart/docker-compose.yml
@@ -19,13 +19,17 @@ services:
environment:
- HTTP_PORT=4400
- DATA_PORT_IN=57124
+ volumes:
+ # Hot reload des assets statiques sans rebuild (rsync suffit)
+ - ./public:/app/public:ro
+ - ./server.js:/app/server.js:ro
networks:
- traefik
- data
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- - "traefik.http.routers.realart.rule=Host(`real.art.saillant.cc`)"
+ - "traefik.http.routers.realart.rule=Host(`realart.saillant.cc`)"
- "traefik.http.routers.realart.entrypoints=websecure"
- "traefik.http.routers.realart.tls=true"
- "traefik.http.routers.realart.tls.certresolver=letsencrypt"
diff --git a/web_realart/public/audio/index.html b/web_realart/public/audio/index.html
index b154b9b..3c77989 100644
--- a/web_realart/public/audio/index.html
+++ b/web_realart/public/audio/index.html
@@ -45,6 +45,7 @@
background: #0a0a12; border: 1px solid rgba(255,255,255,0.06);
border-radius: 6px; }
+
/ realart · audio (portage SC)
diff --git a/web_realart/public/favicon.svg b/web_realart/public/favicon.svg
new file mode 100644
index 0000000..96a0093
--- /dev/null
+++ b/web_realart/public/favicon.svg
@@ -0,0 +1 @@
+
diff --git a/web_realart/public/hydra/index.html b/web_realart/public/hydra/index.html
index c62ea25..8e9317d 100644
--- a/web_realart/public/hydra/index.html
+++ b/web_realart/public/hydra/index.html
@@ -32,6 +32,7 @@
display: flex; gap: 0.4rem; padding: 0.8rem 1rem; flex-wrap: wrap;
justify-content: center; background: linear-gradient(transparent, rgba(0,0,0,0.6)); }
+
diff --git a/web_realart/public/index.html b/web_realart/public/index.html
index 976f7d1..a5bb774 100644
--- a/web_realart/public/index.html
+++ b/web_realart/public/index.html
@@ -47,6 +47,7 @@
footer { color: #5b5b6f; font-size: 0.75rem; }
footer a { color: #7a7a90; }
+
real.art
diff --git a/web_realart/public/webgl/app.js b/web_realart/public/webgl/app.js
index e43bfdc..b3b688f 100644
--- a/web_realart/public/webgl/app.js
+++ b/web_realart/public/webgl/app.js
@@ -13,9 +13,9 @@
// =====================================================================
import * as THREE from "three";
import {
- Fn, vec2, vec3, vec4, float, mix, smoothstep, length, sin, cos,
- uniform, time, attribute, uv, positionLocal, mx_fractal_noise_float,
- abs as tslAbs, clamp, pow as tslPow, varyingProperty,
+ Fn, vec2, vec3, vec4, float, mix, smoothstep, length, sin,
+ uniform, time, uv, positionLocal, mx_fractal_noise_float,
+ abs as tslAbs, pow as tslPow,
} from "three/tsl";
// ---------------------------------------------------------------------
@@ -25,7 +25,9 @@ import {
const canvas = document.getElementById("c");
const backendEl = document.getElementById("backend");
-const hasWebGPU = !!navigator.gpu;
+// debug : ?webgl=1 pour forcer le fallback WebGL2 (verifier si bug WebGPU Points)
+const forceWebGL = new URLSearchParams(location.search).has("webgl");
+const hasWebGPU = !!navigator.gpu && !forceWebGL;
const renderer = new THREE.WebGPURenderer({
canvas,
antialias: true,
@@ -124,91 +126,94 @@ scene.add(globe);
// ---------------------------------------------------------------------
// Particules : quake/strike/plane projetees sur la sphere
+// Implementation via InstancedMesh (3 meshs, un par kind) : plus fiable
+// que Points + TSL en three.js r171 (le pipeline Points est instable).
// ---------------------------------------------------------------------
-const MAX_PTS = 1024;
-const ptPositions = new Float32Array(MAX_PTS * 3);
-const ptKinds = new Float32Array(MAX_PTS);
-const ptT0s = new Float32Array(MAX_PTS);
-const ptMags = new Float32Array(MAX_PTS);
-let ptCount = 0;
+const MAX_PTS_KIND = 256;
+const KIND_COLORS = [
+ new THREE.Color(1.0, 0.25, 0.05), // quake = rouge orange
+ new THREE.Color(0.7, 0.95, 1.0), // strike = cyan
+ new THREE.Color(0.3, 0.95, 0.7), // plane = turquoise
+];
+const KIND_LIFE = [10000, 2500, 8000]; // ms
+const KIND_BASE_SIZE = [0.012, 0.015, 0.008]; // base, multiplie par (1 + mag*K) par kind
function lonLatToVec3(lon, lat, r) {
const lonR = lon * Math.PI / 180;
const latR = lat * Math.PI / 180;
- return [
+ return new THREE.Vector3(
r * Math.cos(latR) * Math.cos(lonR),
r * Math.sin(latR),
r * Math.cos(latR) * Math.sin(lonR),
- ];
+ );
}
+// Pool de plain Meshes par kind. InstancedMesh + MeshBasicNodeMaterial est
+// instable en three.js r171 + WebGPU TSL (matrice instance ignoree).
+const sphereInstGeo = new THREE.IcosahedronGeometry(1.0, 1);
+const ptGroups = KIND_COLORS.map((col, kind) => {
+ const mat = new THREE.MeshBasicNodeMaterial({
+ color: col,
+ transparent: true,
+ blending: THREE.AdditiveBlending,
+ depthWrite: false,
+ });
+ const group = new THREE.Group();
+ const pool = [];
+ for (let i = 0; i < MAX_PTS_KIND; i++) {
+ const m = new THREE.Mesh(sphereInstGeo, mat);
+ m.scale.set(0, 0, 0);
+ m.visible = false;
+ m.frustumCulled = false;
+ m.userData.t0 = 0;
+ m.userData.baseScale = 0;
+ group.add(m);
+ pool.push(m);
+ }
+ group.userData = { pool, head: 0 };
+ scene.add(group);
+ return group;
+});
+
function addPoint(lon, lat, kind, mag) {
- if (ptCount >= MAX_PTS) {
- ptPositions.copyWithin(0, 256 * 3, ptCount * 3);
- ptKinds .copyWithin(0, 256, ptCount);
- ptT0s .copyWithin(0, 256, ptCount);
- ptMags .copyWithin(0, 256, ptCount);
- ptCount -= 256;
- }
- const r = kind === 2 ? 1.05 + mag * 0.3 : 1.01;
- const [x, y, z] = lonLatToVec3(lon, lat, r);
- const i = ptCount;
- ptPositions[i*3+0] = x;
- ptPositions[i*3+1] = y;
- ptPositions[i*3+2] = z;
- ptKinds[i] = kind;
- ptT0s[i] = performance.now();
- ptMags[i] = mag;
- ptCount++;
- geomDirty = true;
+ const group = ptGroups[kind];
+ if (!group) return;
+ const i = group.userData.head;
+ group.userData.head = (i + 1) % MAX_PTS_KIND;
+ const r = kind === 2 ? 1.05 + mag * 0.25 : 1.015;
+ const base = KIND_BASE_SIZE[kind];
+ // mag scaling : quake (0..8) -> *1+mag*0.5 ; strike (1..10) -> *1+mag*0.2 ; plane (0..1) -> *1+mag*0.5
+ const magK = kind === 0 ? 0.5 : kind === 1 ? 0.2 : 0.5;
+ const s = base * (1.0 + mag * magK);
+ const pos = lonLatToVec3(lon, lat, r);
+ const m = group.userData.pool[i];
+ m.position.copy(pos);
+ m.userData.baseScale = s;
+ m.userData.t0 = performance.now();
+ m.scale.set(s, s, s);
+ m.visible = true;
}
-window.onFeed("/data/usgs/event", (a) => addPoint(a[1], a[2], 0, Math.max(0, a[0])));
+window.onFeed("/data/usgs/event", (a) => addPoint(a[1], a[2], 0, Math.max(0, a[0])));
window.onFeed("/data/blitzortung/strike", (a) => addPoint(a[1], a[0], 1, Math.min(10, a[3] || 1)));
window.onFeed("/data/opensky/plane", (a) => addPoint(a[1], a[2], 2, (a[3] || 0) / 12000));
-const ptGeo = new THREE.BufferGeometry();
-ptGeo.setAttribute("position", new THREE.BufferAttribute(ptPositions, 3));
-ptGeo.setAttribute("kind", new THREE.BufferAttribute(ptKinds, 1));
-ptGeo.setAttribute("t0", new THREE.BufferAttribute(ptT0s, 1));
-ptGeo.setAttribute("mag", new THREE.BufferAttribute(ptMags, 1));
-ptGeo.setDrawRange(0, 0);
+// Pre-seed visuel : 6 points repartis tant que les vrais feeds n'ont pas
+// encore push (OpenSky peut etre rate-limited 429, quake/strike sporadiques).
+function seedDemo() {
+ const demos = [
+ [-122, 37, 0, 5.5], [139, 35, 0, 5.2], [-74, -12, 0, 4.8],
+ [2.35, 48.85, 1, 4], [13.4, 52.5, 1, 3], [20, -1, 1, 5],
+ [-40, 50, 2, 0.8], [-30, 45, 2, 0.7], [-50, 55, 2, 0.9],
+ ];
+ for (const [lon, lat, kind, mag] of demos) addPoint(lon, lat, kind, mag);
+}
+seedDemo();
+// re-seed periodique pour qu'il y ait toujours qq chose si feeds vides
+setInterval(seedDemo, 7000);
-const ptMat = new THREE.PointsNodeMaterial({
- transparent: true,
- blending: THREE.AdditiveBlending,
- depthWrite: false,
-});
-
-const aKind = attribute("kind");
-const aT0 = attribute("t0");
-const aMag = attribute("mag");
-
-const lifeNode = aKind.lessThan(0.5).select(float(8.0),
- aKind.lessThan(1.5).select(float(2.5), float(6.0)));
-const ageNode = uNow.sub(aT0).div(1000.0);
-const age01 = clamp(ageNode.div(lifeNode), 0.0, 1.0);
-
-ptMat.sizeNode = Fn(() => {
- const base = aKind.lessThan(0.5).select(aMag.mul(6.0).add(8.0),
- aKind.lessThan(1.5).select(aMag.mul(0.6).add(4.0), float(3.0)));
- return base.mul(age01.mul(-0.5).add(1.0));
-})();
-
-ptMat.colorNode = Fn(() => {
- const quakeCol = mix(vec3(1.0, 0.4, 0.0), vec3(1.0, 0.1, 0.0),
- clamp(aMag.div(8.0), 0.0, 1.0));
- const strikeCol = vec3(0.7, 0.9, 1.0).add(age01.mul(-0.4).add(0.4));
- const planeCol = vec3(0.3, 0.9, 0.7);
- const col = aKind.lessThan(0.5).select(quakeCol,
- aKind.lessThan(1.5).select(strikeCol, planeCol));
- const alpha = age01.mul(-1.0).add(1.0);
- return vec4(col, alpha);
-})();
-
-const points = new THREE.Points(ptGeo, ptMat);
-scene.add(points);
-let geomDirty = false;
+// expose pour debug
+window.__realart = { ptGroups, scene, camera, addPoint, seedDemo };
// ---------------------------------------------------------------------
// HUD update
@@ -247,30 +252,26 @@ function frame() {
const t = (now - t0Start) / 1000;
globe.rotation.y = t * 0.05 * (1 + window.f.kp01());
globe.rotation.x = -0.3;
+ // particules : meme rotation que le globe (group transforme tous les meshes enfants)
+ for (const g of ptGroups) { g.rotation.copy(globe.rotation); }
- // GC particules age > 12 s
- if (ptCount > 0) {
- let writeI = 0;
- for (let i = 0; i < ptCount; i++) {
- if (now - ptT0s[i] < 12000) {
- if (writeI !== i) {
- ptPositions.copyWithin(writeI*3, i*3, i*3 + 3);
- ptKinds[writeI] = ptKinds[i];
- ptT0s[writeI] = ptT0s[i];
- ptMags[writeI] = ptMags[i];
- }
- writeI++;
+ // Animation : scale decroit avec age, expiration -> invisible
+ for (let k = 0; k < ptGroups.length; k++) {
+ const pool = ptGroups[k].userData.pool;
+ const life = KIND_LIFE[k];
+ for (let i = 0; i < MAX_PTS_KIND; i++) {
+ const m = pool[i];
+ if (!m.visible) continue;
+ const age = now - m.userData.t0;
+ if (age >= life) {
+ m.visible = false;
+ m.userData.t0 = 0;
+ continue;
}
+ const sFactor = 1.0 - (age / life) * 0.5;
+ const s = m.userData.baseScale * sFactor;
+ m.scale.set(s, s, s);
}
- if (writeI !== ptCount) { ptCount = writeI; geomDirty = true; }
- }
- if (geomDirty) {
- ptGeo.setDrawRange(0, ptCount);
- ptGeo.attributes.position.needsUpdate = true;
- ptGeo.attributes.kind .needsUpdate = true;
- ptGeo.attributes.t0 .needsUpdate = true;
- ptGeo.attributes.mag .needsUpdate = true;
- geomDirty = false;
}
renderer.autoClear = false;
diff --git a/web_realart/public/webgl/index.html b/web_realart/public/webgl/index.html
index 754f915..4144706 100644
--- a/web_realart/public/webgl/index.html
+++ b/web_realart/public/webgl/index.html
@@ -29,11 +29,13 @@
{
"imports": {
"three": "https://unpkg.com/three@0.171.0/build/three.webgpu.js",
+ "three/webgpu": "https://unpkg.com/three@0.171.0/build/three.webgpu.js",
"three/tsl": "https://unpkg.com/three@0.171.0/build/three.tsl.js",
"three/addons/": "https://unpkg.com/three@0.171.0/examples/jsm/"
}
}
+
@@ -52,6 +54,6 @@
backend : …
-
+