Constellation weave: glowing threads through the spotlit family

Click a rhyme and a soft thread of light weaves under the text through
every member of its family — a spine following the line-ending rhymes
with internal rhymes forking off, drawn-in on click, nodes sized by
each word's strength, anchored to the rhyming tail. Renders beneath
the glyphs (z-index 0) with a gentle gaussian glow so it reads as
light from underneath, not a stroke on top.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 03:52:11 -04:00
parent a973f35e20
commit 87a6fa5ec5
+92 -2
View File
@@ -117,6 +117,9 @@
#highlight .anno { color: #6a5f52; }
#highlight .hdr { color: #8a7d6c; font-weight: 700; }
#gutter { position: absolute; inset: 0; pointer-events: none; z-index: 3; overflow: hidden; }
#weave { position: absolute; inset: 0; pointer-events: none; z-index: 0; width: 100%; height: 100%; }
@keyframes weavedraw { to { stroke-dashoffset: 0; } }
@keyframes weavepop { 0% { opacity: 0; transform: scale(0); } 100% { opacity: .95; } }
#gutter .bracket {
position: absolute; left: 7px; width: 7px;
border: 2.5px solid currentColor; border-right: 0;
@@ -290,6 +293,7 @@
<div class="editor-shell">
<div id="stresslayer"></div>
<div id="gutter"></div>
<svg id="weave"></svg>
<div id="highlight"></div>
<textarea id="editor" spellcheck="false" placeholder="Start writing. Leave a blank line between stanzas.
Rhymes are detected phonetically — same color = same sound.
@@ -368,6 +372,7 @@ const editor = document.getElementById('editor');
const highlight = document.getElementById('highlight');
const stresslayer = document.getElementById('stresslayer');
const gutter = document.getElementById('gutter');
const weave = document.getElementById('weave');
const stressToggle = document.getElementById('stressToggle');
stressToggle.checked = false;
@@ -630,6 +635,7 @@ function render(){
// whole word fills dimly; the rhyming part gets a bright underline.
// gray = an ending still waiting for its answer
let style = '';
let tk = '';
const shadows = [];
if(w || p){
const t = (w && (focusGid === null || w.g === focusGid)) ? w
@@ -643,6 +649,7 @@ function render(){
style += 'color:#1a120c;font-weight:600;'; // dark ink on the lit chip
}
style += `background:color-mix(in srgb, ${colorOf(t)} ${alpha}%, transparent);`;
if(w){ tk = ` data-tk="${i}:${w.s}"`; if(w.rs == null || a >= w.rs) tk += ` data-tl="${i}:${w.s}"`; }
// faint underline on the rhyming tail (rs..e) of a word — a
// whisper of precision under the full-word fill
if(w && w.rs != null && a >= w.rs){
@@ -655,7 +662,7 @@ function render(){
if(al) shadows.push(`inset 0 -2px 0 0 color-mix(in srgb, var(--r${al.g % COLORS}) 75%, transparent)`);
if(nr) style += 'text-decoration:underline dotted color-mix(in srgb, var(--accent-2) 60%, transparent);text-underline-offset:3px;';
if(shadows.length) style += `box-shadow:${shadows.join(',')};`;
h += `<span class="hseg" style="${style}">${text}</span>`;
h += `<span class="hseg"${tk} style="${style}">${text}</span>`;
}
}
const lcls = 'lmark' + (/^\s*#/.test(line) ? ' hdr' : /^\s*[([]/.test(line) ? ' anno' : '');
@@ -665,6 +672,7 @@ function render(){
renderStress(lines);
syncLayerHeights();
renderGutter();
renderWeave();
highlight.scrollTop = editor.scrollTop;
highlight.scrollLeft = editor.scrollLeft;
buildReadout();
@@ -697,6 +705,88 @@ function spineFor(memberLines, color, cls){
});
}
function renderWeave(){
weave.innerHTML = '';
if(focusGid === null || !analysis || !rhymeToggle.checked) return;
const g = analysis.groups.find(x=>x.id === focusGid);
if(!g) return;
const shell = highlight.getBoundingClientRect();
const pts = [];
analysis.tokens.filter(t=>t.g===focusGid && !t.ph).forEach(t=>{
const seg = highlight.querySelector(`[data-tl="${t.l}:${t.s}"]`)
|| highlight.querySelector(`[data-tk="${t.l}:${t.s}"]`);
if(!seg) return;
const r = seg.getBoundingClientRect();
pts.push({x: r.left - shell.left + r.width/2,
y: r.top - shell.top + r.height/2,
str: t.str != null ? t.str : 1, end: !!t.end});
});
if(pts.length < 2) return;
const ns='http://www.w3.org/2000/svg';
const col = getComputedStyle(document.documentElement).getPropertyValue(`--r${g.color % COLORS}`).trim();
weave.innerHTML = `<defs>`
+ `<filter id="wg" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="3"/></filter>`
+ `<filter id="wgs" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur stdDeviation="0.6"/></filter>`
+ `</defs>`;
// the spine follows the strong rhymes — line endings — and internal
// rhymes fork off it. (no endings in the family? the whole set is the spine)
let spine = pts.filter(p=>p.end).sort((a,b)=>a.y-b.y || a.x-b.x);
let branches;
if(spine.length >= 2){ branches = pts.filter(p=>!p.end); }
else if(spine.length === 1){ branches = pts.filter(p=>!p.end); }
else { spine = pts.slice().sort((a,b)=>a.y-b.y || a.x-b.x); branches = []; }
function smooth(ps){
let d = `M ${ps[0].x.toFixed(1)} ${ps[0].y.toFixed(1)}`;
for(let i=0;i<ps.length-1;i++){
const p0=ps[i-1]||ps[i], p1=ps[i], p2=ps[i+1], p3=ps[i+2]||p2, t=0.8;
const c1x=p1.x+(p2.x-p0.x)/6*t, c1y=p1.y+(p2.y-p0.y)/6*t;
const c2x=p2.x-(p3.x-p1.x)/6*t, c2y=p2.y-(p3.y-p1.y)/6*t;
d += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2.x.toFixed(1)} ${p2.y.toFixed(1)}`;
}
return d;
}
const mkpath=(d,sw,op,filt,anim)=>{
const p=document.createElementNS(ns,'path');
p.setAttribute('d',d); p.setAttribute('fill','none'); p.setAttribute('stroke',col);
p.setAttribute('stroke-width',sw); p.setAttribute('stroke-linecap','round');
p.setAttribute('stroke-linejoin','round'); p.setAttribute('opacity',op);
if(filt === 'glow') p.setAttribute('filter','url(#wg)');
else if(filt === 'soft') p.setAttribute('filter','url(#wgs)');
if(anim){ p.setAttribute('pathLength','1'); p.style.strokeDasharray='1';
p.style.strokeDashoffset='1'; p.style.animation='weavedraw .5s ease-out forwards'; }
weave.appendChild(p);
};
// forks: each internal rhyme tendrils to its nearest spine node
branches.forEach(b=>{
let near=spine[0], best=1e9;
spine.forEach(s=>{ const dd=(s.x-b.x)**2+(s.y-b.y)**2; if(dd<best){best=dd;near=s;} });
const mx=(b.x+near.x)/2, my=(b.y+near.y)/2 - Math.min(18, Math.abs(b.x-near.x)*0.25);
const d=`M ${b.x.toFixed(1)} ${b.y.toFixed(1)} Q ${mx.toFixed(1)} ${my.toFixed(1)}, ${near.x.toFixed(1)} ${near.y.toFixed(1)}`;
mkpath(d, '1.1', '0.55', 'soft', false);
});
// the spine, glowing and drawn-in
if(spine.length >= 2){
const d=smooth(spine);
mkpath(d, '6', '0.3', 'glow', false);
mkpath(d, '2.2', '0.6', 'soft', true);
}
pts.forEach((p,i)=>{
const c=document.createElementNS(ns,'circle');
c.setAttribute('cx',p.x.toFixed(1)); c.setAttribute('cy',p.y.toFixed(1));
c.setAttribute('r',((p.end?2.0:1.3) + 2.0*p.str).toFixed(1)); c.setAttribute('fill',col);
c.setAttribute('opacity', p.end ? '0.7' : '0.5');
c.style.transformOrigin=`${p.x.toFixed(1)}px ${p.y.toFixed(1)}px`;
c.style.animation=`weavepop .35s ${(0.04*i).toFixed(2)}s ease-out both`;
weave.appendChild(c);
});
}
function renderGutter(){
gutter.innerHTML = '';
if(!analysis) return;
@@ -823,7 +913,7 @@ function buildReadout(){
}
editor.addEventListener('input', ()=>{ focusGid = caretGid(); render(); analyzeSoon(); });
editor.addEventListener('scroll', ()=>{ highlight.scrollTop = stresslayer.scrollTop = editor.scrollTop; highlight.scrollLeft = stresslayer.scrollLeft = editor.scrollLeft; renderGutter(); });
editor.addEventListener('scroll', ()=>{ highlight.scrollTop = stresslayer.scrollTop = editor.scrollTop; highlight.scrollLeft = stresslayer.scrollLeft = editor.scrollLeft; renderGutter(); renderWeave(); });
editor.addEventListener('keyup', ()=>{ updateSpotlight(); buildReadout(); });
editor.addEventListener('click', ()=>{ updateSpotlight(); buildReadout(); });