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.
This commit is contained in:
L'électron rare
2026-05-13 09:34:19 +02:00
parent 8237be9b15
commit 1c6bf1039c
7 changed files with 106 additions and 95 deletions
+5 -1
View File
@@ -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"
+1
View File
@@ -45,6 +45,7 @@
background: #0a0a12; border: 1px solid rgba(255,255,255,0.06);
border-radius: 6px; }
</style>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
<h1><a href="/">/ realart</a> · audio (portage SC)</h1>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" fill="#050308"/><circle cx="16" cy="16" r="10" fill="none" stroke="#5b8bff" stroke-width="1.5"/><circle cx="16" cy="16" r="2" fill="#ff5b5b"/></svg>

After

Width:  |  Height:  |  Size: 238 B

+1
View File
@@ -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)); }
</style>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
<canvas id="hydra-canvas"></canvas>
+1
View File
@@ -47,6 +47,7 @@
footer { color: #5b5b6f; font-size: 0.75rem; }
footer a { color: #7a7a90; }
</style>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
<h1>real<span class="dot">.</span>art</h1>
+94 -93
View File
@@ -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;
+3 -1
View File
@@ -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/"
}
}
</script>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
<canvas id="c"></canvas>
@@ -52,6 +54,6 @@
<div id="renderer">backend : <span id="backend"></span></div>
<script src="/feeds_client.js"></script>
<script type="module" src="./app.js"></script>
<script type="module">import "./app.js?v=15";</script>
</body>
</html>