Skip to content

Commit 9487372

Browse files
authored
94% there!
1 parent 9671873 commit 9487372

File tree

1 file changed

+79
-91
lines changed

1 file changed

+79
-91
lines changed

index.html

Lines changed: 79 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,7 @@
1414
display:flex; flex-direction:column; align-items:center; justify-content:center;
1515
padding:1rem; gap:1rem;
1616
}
17-
h1{
18-
font-size:clamp(1rem,2.6vw,1.4rem); letter-spacing:.1em; margin:0; text-align:center; color:#ddd;
19-
}
17+
h1{ font-size:clamp(1rem,2.6vw,1.4rem); letter-spacing:.1em; margin:0; text-align:center; color:#ddd }
2018
a{ color:#ddd; text-decoration:none } a:hover{ color:var(--ink); text-decoration:underline }
2119
canvas{ width:min(84vmin,980px); height:auto; aspect-ratio:4/1.6; display:block }
2220
footer{ color:#777; font-size:clamp(.7rem,1.5vw,.85rem) }
@@ -29,7 +27,7 @@ <h1>
2927
<a href="https://ukb-pyro.github.io/inf/" target="_blank" rel="noopener">Sonata</a>
3028
</h1>
3129

32-
<canvas id="stage" aria-label="Adagio morph: line → circle (closing ends) → ∞ → broken loop (two ends) → line"></canvas>
30+
<canvas id="stage" aria-label="Adagio sostenuto: line → circle → ∞ → broken → line"></canvas>
3331

3432
<footer>© 2025 Ukubona LLC</footer>
3533

@@ -38,7 +36,7 @@ <h1>
3836
const canvas = document.getElementById('stage');
3937
const ctx = canvas.getContext('2d');
4038

41-
// --- sizing (crisp on HiDPI) ---
39+
// HiDPI sizing
4240
function fit(){
4341
const cssW = canvas.getBoundingClientRect().width;
4442
const cssH = cssW / (4/1.6);
@@ -48,149 +46,139 @@ <h1>
4846
ctx.setTransform(dpr,0,0,dpr,0,0);
4947
}
5048

51-
// --- parameters ---
52-
const N = 1200; // dense sampling for imperceptible motion
53-
const LEG_MS = 16000; // adagio: 16s per leg
49+
// --- tempo & resolution ---
50+
const N = 1400; // dense sampling for imperceptible morphs
51+
const LEG_MS = 45000; // adagio sostenuto: 45s per leg
5452
const TWO_PI = Math.PI * 2;
5553

56-
// very gentle cosine ease
54+
// ultra-gentle easing (cosine-in-out)
5755
const ease = t => 0.5 - 0.5 * Math.cos(Math.PI * t);
5856

59-
// sample helpers
57+
// sample helper
6058
function sample(fn){
6159
const pts = new Array(N);
62-
for(let i=0;i<N;i++){
63-
pts[i] = fn(i / N);
64-
}
60+
for(let i=0;i<N;i++) pts[i] = fn(i/N);
6561
return pts;
6662
}
6763

68-
// canonical shapes, centered at origin
69-
const line = sample(t => [ (t*2-1), 0 ]);
70-
const circle = sample(t => [ Math.cos(TWO_PI*t), Math.sin(TWO_PI*t) ]);
71-
// Lemniscate of Gerono (∞), scaled vertically for pleasing proportion
72-
const lemni = sample(t => {
73-
const th = TWO_PI * t;
74-
const x = Math.cos(th);
75-
const y = 0.60 * Math.sin(th) * Math.cos(th); // 0.5*sin(2θ) scaled
76-
return [x, y];
64+
// canonical shapes (centered at origin, radius≈1)
65+
const line = sample(u => [ (u*2-1), 0 ]);
66+
const circle = sample(u => [ Math.cos(TWO_PI*u), Math.sin(TWO_PI*u) ]);
67+
const lemni = sample(u => { // lemniscate of Gerono, softened vertically
68+
const th = TWO_PI*u;
69+
return [ Math.cos(th), 0.60*Math.sin(th)*Math.cos(th) ];
7770
});
7871

79-
// index of the lemniscate's crossing (nearest to 0,0), used as "break" point
72+
// index near lemniscate crossing (for the "break")
8073
const lemniCrossIdx = (() => {
81-
let best=0, bestR=1e9;
74+
let k=0, best=1e9;
8275
for(let i=0;i<N;i++){
8376
const [x,y] = lemni[i];
84-
const r = x*x + y*y;
85-
if(r < bestR){ bestR=r; best=i; }
77+
const r2 = x*x + y*y;
78+
if(r2 < best){ best=r2; k=i; }
8679
}
87-
return best;
80+
return k;
8881
})();
8982

90-
// blend two shapes pointwise
91-
function blend(A, B, m){
83+
// pointwise blend
84+
function blend(A,B,m){
9285
const out = new Array(N);
9386
for(let i=0;i<N;i++){
94-
const ax=A[i], bx=B[i];
95-
out[i] = [ ax[0] + (bx[0]-ax[0])*m, ax[1] + (bx[1]-ax[1])*m ];
87+
const a=A[i], b=B[i];
88+
out[i] = [ a[0] + (b[0]-a[0])*m, a[1] + (b[1]-a[1])*m ];
9689
}
9790
return out;
9891
}
9992

100-
// subtle breathing to avoid mechanical feel
93+
// subtle breathing on global radius so it never feels mechanical
10194
function radius(now, base){
102-
return base * (1 + 0.02 * Math.sin(now/4200) + 0.01 * Math.cos(now/7600));
95+
return base * (1 + 0.02*Math.sin(now/5200) + 0.01*Math.cos(now/8800));
10396
}
10497

105-
// draw with optional "gap" on closed curves; gap starts at given index
106-
function drawPolyline(pts, opts){
107-
const {close=false, gapFrac=0, gapIndex=0, w, h} = opts;
98+
// draw a (possibly open) curve; when gapFrac>0 omit an arc (two ends)
99+
function strokeCurve(pts, {closed=false, gapFrac=0, gapIndex=0, w}){
108100
ctx.beginPath();
109101

110102
if(gapFrac > 0){
111-
const n = pts.length;
112-
const gapN = Math.max(2, Math.floor(n * gapFrac));
113-
const start = ((gapIndex % n) + n) % n;
114-
const count = n - gapN;
115-
for(let k=0;k<count;k++){
116-
const i = (start + k) % n;
103+
const n = pts.length;
104+
const gapN = Math.max(2, Math.floor(n*gapFrac));
105+
const s = ((gapIndex % n) + n) % n;
106+
const cnt = n - gapN;
107+
108+
// draw cnt points as a single open strand
109+
for(let k=0;k<cnt;k++){
110+
const i = (s + k) % n;
117111
const p = pts[i];
118112
if(k===0) ctx.moveTo(p[0], p[1]); else ctx.lineTo(p[0], p[1]);
119113
}
120-
// open ends (no closePath)
121-
}else{
122-
// full stroke
114+
} else {
115+
// full curve
123116
ctx.moveTo(pts[0][0], pts[0][1]);
124117
for(let i=1;i<pts.length;i++) ctx.lineTo(pts[i][0], pts[i][1]);
125-
if(close) ctx.closePath();
118+
if(closed) ctx.closePath();
126119
}
127120

128-
ctx.lineWidth = Math.max(2, Math.min(6, w*0.006));
129-
ctx.lineJoin = "round";
130-
ctx.lineCap = "round";
121+
ctx.lineWidth = Math.max(2, Math.min(6, w*0.006));
122+
ctx.lineJoin = "round";
123+
ctx.lineCap = "round";
131124
ctx.strokeStyle = "#9ad1ff";
132-
ctx.shadowColor = "rgba(154,209,255,.22)";
133-
ctx.shadowBlur = 8;
125+
ctx.shadowColor = "rgba(154,209,255,.18)";
126+
ctx.shadowBlur = 6;
134127
ctx.stroke();
135128
}
136129

137130
function frame(now){
138131
fit();
139-
const w = canvas.width / (window.devicePixelRatio||1);
140-
const h = canvas.height / (window.devicePixelRatio||1);
132+
133+
const w = canvas.width / (window.devicePixelRatio||1);
134+
const h = canvas.height / (window.devicePixelRatio||1);
141135
const cx = w/2, cy = h/2;
142136
const base = Math.min(w, h) * 0.38;
143-
const r = radius(now, base);
137+
const r = radius(now, base);
144138

145-
// 4-leg cycle:
146-
// 0) line -> circle (ends stay open; closes only at tail)
147-
// 1) circle -> lemni (closed)
148-
// 2) lemni -> circle (break open during this leg at the crossing)
149-
// 3) circle (open) -> line (open)
150-
const CYCLE = LEG_MS * 4;
151-
const t = (now % CYCLE);
152-
const leg = Math.floor(t / LEG_MS);
153-
const local = (t % LEG_MS) / LEG_MS;
154-
const m = ease(local);
139+
// four-leg cycle
140+
const CYCLE = LEG_MS*4;
141+
const t = (now % CYCLE);
142+
const leg = Math.floor(t / LEG_MS); // 0..3
143+
const local = (t % LEG_MS) / LEG_MS; // 0..1
144+
const m = ease(local);
155145

156-
let A, B, closeNow=false, gapFrac=0, gapIndex=0;
146+
let A, B, closed=false, gapFrac=0, gapIndex=0;
157147

158148
if(leg === 0){
159-
// line -> circle, keep ends visible until the very end
149+
// line -> circle (two teloi remain visible; seal only at transition edge)
160150
A = line; B = circle;
161-
closeNow = (m > 0.995); // seal at the last breath
162-
gapFrac = 0; // no artificial gap; openness comes from not closing
163-
gapIndex = 0;
164-
}else if(leg === 1){
165-
// circle -> infinity, closed throughout
151+
closed = false; // keep open; endpoints "kiss" at the very end
152+
gapFrac = 0; // no artificial gap
153+
} else if(leg === 1){
154+
// circle -> lemni (fully closed; absolutely no gap)
166155
A = circle; B = lemni;
167-
closeNow = true;
156+
closed = true;
168157
gapFrac = 0;
169-
gapIndex = 0;
170-
}else if(leg === 2){
171-
// infinity -> circle while BREAKING the loop (two ends) at the crossing
172-
A = lemni; B = circle;
173-
closeNow = false;
174-
// grow a visible break (up to ~22% of the loop)
175-
gapFrac = 0.22 * Math.pow(m, 0.85);
176-
gapIndex = lemniCrossIdx; // break begins at the ∞ crossing
177-
}else{
178-
// open circle -> line (stay open)
179-
A = circle; B = line;
180-
closeNow = false;
181-
gapFrac = 0.22; // keep two ends while it straightens
182-
gapIndex = 0;
158+
} else if(leg === 2){
159+
// lemni -> broken lemni (grow a gap at the crossing)
160+
A = lemni; B = lemni; // shape fixed; only gap changes
161+
closed = false;
162+
gapFrac = 0.22 * m; // up to ~22% arc removed
163+
gapIndex= lemniCrossIdx;
164+
} else {
165+
// broken lemni -> line (stay open; shrink gap smoothly while straightening)
166+
A = lemni; B = line;
167+
closed = false;
168+
// carry the break at start, then fade it out as we approach the line
169+
gapFrac = 0.22 * (1 - m);
170+
gapIndex= lemniCrossIdx;
183171
}
184172

173+
// blend & place
185174
const pts = blend(A, B, m);
186-
187-
// transform to canvas space
188-
for(let i=0;i<pts.length;i++){
189-
pts[i] = [ cx + pts[i][0]*r, cy + pts[i][1]*r ];
175+
for(let i=0;i<N;i++){
176+
pts[i][0] = cx + pts[i][0]*r;
177+
pts[i][1] = cy + pts[i][1]*r;
190178
}
191179

192180
ctx.clearRect(0,0,w,h);
193-
drawPolyline(pts, {close: closeNow, gapFrac, gapIndex, w, h});
181+
strokeCurve(pts, {closed, gapFrac, gapIndex, w});
194182

195183
requestAnimationFrame(frame);
196184
}

0 commit comments

Comments
 (0)