Rede plexus
Nós que derivam devagar e se ligam quando ficam perto; o cursor empurra a rede e a mola a traz de volta.
usa o PAR-01
O código
particulas-motor.js + plexus.js + markup.html
/* ===== particulas-motor.js ========================================================= */
/* OCTA · motor de cenas de partículas — a base compartilhada pelos componentes PAR-*.
Cena de three.js atrás de uma dobra, com física leve e o cursor mandando de verdade.
Nasceu no site da primeira proposta da OCTA ao Pedro (27/08/2026), onde as seis cenas viviam
num arquivo só. Aqui o motor está separado da cena: você inclui ESTE arquivo uma vez e, depois
dele, só o arquivo da cena que for usar.
Três regras de desempenho que não são opcionais:
- **pausa fora da tela** (IntersectionObserver): cena invisível não queima bateria;
- **DPR no teto de 2**: acima disso o ganho é invisível e o custo não;
- **`prefers-reduced-motion` desenha UM quadro e para** — a cena existe, não se mexe.
Configuração, no próprio <canvas>:
data-cena="plexus" qual cena (obrigatório)
data-cor="#212121" a cor dos nós e das linhas
data-cor-destaque="#e8ad32" o acento (algumas cenas usam)
Exige three.js (r12x+) carregado antes. Sem ele, o canvas some em silêncio — a página não quebra.
*/
window.OctaParticulas = (function () {
'use strict';
var REDUZIR = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var CELULAR = window.innerWidth < 700;
var cenas = {};
function textura(desenhar) {
var cv = document.createElement('canvas');
cv.width = cv.height = 64;
desenhar(cv.getContext('2d'));
return new THREE.CanvasTexture(cv);
}
var PONTO = null, ANEL = null;
function texturas() {
if (PONTO) { return; }
PONTO = textura(function (g) { g.fillStyle = '#fff'; g.beginPath(); g.arc(32, 32, 30, 0, Math.PI * 2); g.fill(); });
ANEL = textura(function (g) { g.strokeStyle = '#fff'; g.lineWidth = 7; g.beginPath(); g.arc(32, 32, 24, 0, Math.PI * 2); g.stroke(); });
}
function Cena(canvas) {
texturas();
this.canvas = canvas;
this.tipo = canvas.getAttribute('data-cena');
this.dobra = canvas.parentElement;
this.COR = new THREE.Color(canvas.getAttribute('data-cor') || '#212121');
this.DESTAQUE = new THREE.Color(canvas.getAttribute('data-cor-destaque') || '#e8ad32');
this.PONTO = PONTO;
this.ANEL = ANEL;
this.CELULAR = CELULAR;
this.renderer = new THREE.WebGLRenderer({ canvas: canvas, alpha: true, antialias: true });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
this.scene = new THREE.Scene();
/* câmera ortográfica em PIXEL: x e y da cena são as coordenadas da tela, e a conta de
física fica legível — sem conversão de espaço no meio do passo */
this.camera = new THREE.OrthographicCamera(0, 1, 1, 0, -500, 500);
this.mouse = { x: -9999, y: -9999 };
this.t = 0;
this.rodando = false;
var eu = this;
this.dobra.addEventListener('pointermove', function (e) {
var r = eu.canvas.getBoundingClientRect();
eu.mouse.x = e.clientX - r.left;
eu.mouse.y = e.clientY - r.top;
});
this.dobra.addEventListener('pointerleave', function () { eu.mouse.x = -9999; eu.mouse.y = -9999; });
window.addEventListener('resize', function () { eu.redimensionar(); });
this.redimensionar();
var montar = cenas[this.tipo];
if (!montar) { throw new Error('cena desconhecida: ' + this.tipo); }
montar.call(this);
this.montada = true;
if (REDUZIR) { this.passo(0.016); this.desenhar(); return; }
if ('IntersectionObserver' in window) {
new IntersectionObserver(function (en) {
eu.rodando = en[0].isIntersecting;
if (eu.rodando) { eu.laco(); }
}, { rootMargin: '80px' }).observe(canvas);
} else {
this.rodando = true;
this.laco();
}
}
Cena.prototype.redimensionar = function () {
var w = this.dobra.clientWidth, h = this.dobra.clientHeight;
this.w = w; this.h = h;
this.renderer.setSize(w, h, false);
this.camera.right = w; this.camera.top = 0; this.camera.bottom = h; this.camera.left = 0;
this.camera.updateProjectionMatrix();
if (this.montada && this.aoRedimensionar) { this.aoRedimensionar(); }
};
Cena.prototype.laco = function () {
if (!this.rodando || document.hidden) { return; }
var eu = this;
this.passo(0.016);
this.desenhar();
requestAnimationFrame(function () { eu.laco(); });
};
Cena.prototype.desenhar = function () { this.renderer.render(this.scene, this.camera); };
Cena.prototype.passo = function (dt) { if (this.passoFn) { this.passoFn(dt); } };
function iniciar() {
if (!window.THREE) { return; }
Array.prototype.forEach.call(document.querySelectorAll('canvas[data-cena]'), function (cv) {
try { new Cena(cv); }
catch (e) { cv.style.display = 'none'; } // cena que falha some; a página segue
});
}
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', iniciar); }
else { setTimeout(iniciar, 0); } // dá um tique para os arquivos de cena se registrarem
return {
registrar: function (nome, montar) { cenas[nome] = montar; },
Cena: Cena,
celular: CELULAR,
reduzir: REDUZIR
};
})();
/* ===== plexus.js =================================================================== */
/* PAR-01 · Rede plexus — nós que derivam e se ligam quando ficam perto, e o cursor empurra.
Exige particulas-motor.js antes. Três variantes, pelo data-cena:
plexus rede densa (84 nós no desktop, 30 no celular)
plexus-esparsa rede rala, para dobra com muito texto
plexus-ancorada rede presa à casa, que deixa o topo livre para o conteúdo */
(function () {
'use strict';
function montar() {
var esparsa = this.tipo === 'plexus-esparsa', ancorada = this.tipo === 'plexus-ancorada';
var N = esparsa ? (this.CELULAR ? 14 : 26) : (this.CELULAR ? 30 : 84);
this.ligacao = ancorada ? 165 : (esparsa ? 200 : 195);
this.alfa = ancorada ? .5 : (esparsa ? .4 : 1);
this.molaCasa = ancorada ? .03 : .012;
this.deriva = ancorada ? 12 : 26;
this.nos = [];
for (var i = 0; i < N; i++) {
var hx, hy, t = 0;
/* na ancorada, o topo central fica vago: é onde o texto da dobra vive */
do { hx = Math.random() * this.w; hy = Math.random() * this.h; t++; }
while (ancorada && t < 50 && hx > this.w * .08 && hx < this.w * .92 && hy < this.h * .34);
this.nos.push({ x: hx, y: hy, hx: hx, hy: hy,
vx: (Math.random() - .5) * .3, vy: (Math.random() - .5) * .3,
wt: Math.random() * 6.28 });
}
var pg = new THREE.BufferGeometry();
pg.setAttribute('position', new THREE.BufferAttribute(new Float32Array(N * 3), 3));
pg.setAttribute('color', new THREE.BufferAttribute(new Float32Array(N * 3), 3));
this.pontos = new THREE.Points(pg, new THREE.PointsMaterial({
size: this.CELULAR ? 4 : 5, vertexColors: true, map: this.PONTO, transparent: true, alphaTest: .5 }));
this.scene.add(this.pontos);
var maxSeg = N * 12;
var lg = new THREE.BufferGeometry();
lg.setAttribute('position', new THREE.BufferAttribute(new Float32Array(maxSeg * 6), 3));
lg.setAttribute('color', new THREE.BufferAttribute(new Float32Array(maxSeg * 6), 3));
this.linhas = new THREE.LineSegments(lg, new THREE.LineBasicMaterial({ vertexColors: true }));
this.scene.add(this.linhas);
var eu = this;
this.passoFn = function (dt) { passo.call(eu, dt); };
}
function passo(dt) {
var n = this.nos, i, j, R = 240; // raio de influência do cursor
for (i = 0; i < n.length; i++) {
var p = n[i];
p.wt += dt * .5;
/* a casa do nó deriva devagar: a rede vive, mas nunca abandona a cena */
var casaX = p.hx + Math.cos(p.wt) * this.deriva;
var casaY = p.hy + Math.sin(p.wt * .8) * this.deriva;
p.vx += (casaX - p.x) * this.molaCasa;
p.vy += (casaY - p.y) * this.molaCasa;
var dx = p.x - this.mouse.x, dy = p.y - this.mouse.y, d = Math.hypot(dx, dy);
if (d < R && d > 1) { var f = (1 - d / R) * 1.8; p.vx += (dx / d) * f; p.vy += (dy / d) * f; }
p.vx *= .9; p.vy *= .9;
p.x += p.vx; p.y += p.vy;
}
var pp = this.pontos.geometry.attributes.position.array;
var pc = this.pontos.geometry.attributes.color.array;
for (i = 0; i < n.length; i++) {
pp[i * 3] = n[i].x; pp[i * 3 + 1] = n[i].y; pp[i * 3 + 2] = 0;
pc[i * 3] = this.COR.r; pc[i * 3 + 1] = this.COR.g; pc[i * 3 + 2] = this.COR.b;
}
this.pontos.geometry.attributes.position.needsUpdate = true;
this.pontos.geometry.attributes.color.needsUpdate = true;
/* as ligações se refazem a cada quadro: a opacidade cai com a distância, e o segmento
some antes de chegar ao limite — é o que dá a sensação de rede viva em vez de malha fixa */
var lp = this.linhas.geometry.attributes.position.array;
var lc = this.linhas.geometry.attributes.color.array;
var k = 0, max = lp.length / 6;
for (i = 0; i < n.length && k < max; i++) {
for (j = i + 1; j < n.length && k < max; j++) {
var ddx = n[i].x - n[j].x, ddy = n[i].y - n[j].y, dd = Math.hypot(ddx, ddy);
if (dd > this.ligacao) { continue; }
var a = (1 - dd / this.ligacao) * this.alfa;
lp[k * 6] = n[i].x; lp[k * 6 + 1] = n[i].y; lp[k * 6 + 2] = 0;
lp[k * 6 + 3] = n[j].x; lp[k * 6 + 4] = n[j].y; lp[k * 6 + 5] = 0;
for (var c = 0; c < 2; c++) {
lc[k * 6 + c * 3] = this.COR.r * a;
lc[k * 6 + c * 3 + 1] = this.COR.g * a;
lc[k * 6 + c * 3 + 2] = this.COR.b * a;
}
k++;
}
}
/* o resto do buffer colapsa num ponto: segmento de comprimento zero não desenha */
for (; k < max; k++) { for (var z = 0; z < 6; z++) { lp[k * 6 + z] = 0; lc[k * 6 + z] = 0; } }
this.linhas.geometry.attributes.position.needsUpdate = true;
this.linhas.geometry.attributes.color.needsUpdate = true;
}
OctaParticulas.registrar('plexus', montar);
OctaParticulas.registrar('plexus-esparsa', montar);
OctaParticulas.registrar('plexus-ancorada', montar);
})();
/* ===== markup.html ================================================================= */
<!-- PAR-01 · Rede plexus. O <canvas> precisa de um PAI POSICIONADO com altura própria: a cena
mede o pai, não o canvas. three.js vem antes; o motor antes da cena.
data-cor e data-cor-destaque são opcionais (padrão: os do IDV da OCTA). -->
<div class="dobra" style="position:relative;min-height:60vh;overflow:hidden">
<canvas data-cena="plexus" data-cor="#ebebeb" aria-hidden="true"
style="position:absolute;inset:0;width:100%;height:100%"></canvas>
<!-- o conteúdo da dobra vai aqui, com position:relative para ficar por cima -->
</div>
<script src="three.min.js"></script>
<script src="particulas-motor.js"></script>
<script src="plexus.js"></script>
Já rodou em
- site da proposta ao Pedro (27/08/2026) — a dobra de abertura usa a variante densa, e duas dobras internas usam a ancorada
Exige
- three.js (r12x+), carregado antes do motor
Como se configura
data-cena — qual cena montar (obrigatorio)data-cor — a cor dos nos e das linhas (padrao #212121)data-cor-destaque — o acento (padrao #e8ad32)
A ficha
O que o componente entrega
Exige: three.js (r12x+) carregado antes, e particulas-motor.js carregado antes da cena. O <canvas> precisa de um pai posicionado com altura própria — a cena mede o pai, não o canvas.
Variantes. Três variantes pelo data-cena: plexus (densa, 84 nós no desktop), plexus-esparsa (rala, para dobra com muito texto) e plexus-ancorada (presa à casa, com o topo central livre para o conteúdo).
Três regras de desempenho que vêm do motor, e não são opcionais:
- pausa fora da tela (
IntersectionObservercom 80 px de folga): cena invisível não queima bateria; - DPR no teto de 2: acima disso o ganho é invisível e o custo não;
prefers-reduced-motiondesenha um quadro e para — a cena existe, não se mexe.
A câmera é ortográfica em pixel: x e y da cena são as coordenadas da tela, o que deixa a conta de física legível, sem conversão de espaço no meio do passo.
Detalhes que importam:
- A ligação se refaz a cada quadro e a opacidade cai com a distância — é o que dá rede viva em vez de malha fixa.
- O buffer de segmentos tem teto (
N × 12); o que sobra colapsa num ponto, que não desenha. - A variante ancorada sorteia a casa dos nós fora do topo central, para o texto da dobra respirar.
O que a adaptação da OCTA mudou
- O motor saiu do arquivo da cena. No site de origem as seis cenas viviam num
motion.jsde 444 linhas; aqui você inclui o motor uma vez e, depois dele, só a cena que for usar. - A cor deixou de ser constante no código e virou
data-cor/data-cor-destaqueno<canvas>. - A cena que falha some em silêncio e a página segue — o erro não derruba a dobra.
Lacunas
- Com muitos nós o custo é O(N²) por quadro: 84 nós é o teto medido; acima disso cai quadro no celular.
- Sem cursor (toque), a rede só deriva — a interação é de ponteiro.