reviewzy 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +2 -2
- package/src/dashboard/mount.ts +20 -3
- package/src/dashboard/static/components.css +7579 -600
- package/src/dashboard/static/cursor-rules.js +79 -0
- package/src/dashboard/static/cursor.css +134 -0
- package/src/dashboard/static/cursor.js +1749 -0
- package/src/dashboard/static/dashboard.css +281 -258
- package/src/dashboard/static/dashboard.js +331 -209
- package/src/dashboard/static/fonts/jetbrainsmono-latin-ext.woff2 +0 -0
- package/src/dashboard/static/fonts/jetbrainsmono-latin.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-cyrillic.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-latin-ext.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-latin.woff2 +0 -0
- package/src/dashboard/static/icons.svg +184 -0
- package/src/dashboard/static/tokens.css +607 -110
- package/src/dashboard/static/ui.js +2487 -0
- package/src/dashboard/views.tsx +1204 -950
- package/src/dashboard/word-diff.ts +18 -25
- package/src/db/migrations.ts +5 -6
- package/src/db/queries.ts +38 -1
- package/src/mcp/await-approved.ts +17 -10
- package/src/mcp/fetch-approved.ts +11 -6
- package/src/mcp/file-entries.ts +45 -22
- package/src/mcp/list-entries.ts +51 -55
- package/src/mcp/mark-applied.ts +5 -5
- package/src/mcp/server.ts +8 -3
|
@@ -0,0 +1,1749 @@
|
|
|
1
|
+
/* ambient layer, cursor v3: the jelly mark lit up with speed, its tube trail in
|
|
2
|
+
the comet blue, leaving the mark 4px narrower than it is across the motion and stopping
|
|
3
|
+
2px short of its keyline, the parting dot grid, droplets and click. Desktop fine-pointer
|
|
4
|
+
only, silent under `prefers-reduced-motion`. Rules, mechanisms and anti-patterns: cursor.md.
|
|
5
|
+
Load as `<script type="module" src="cursor.js"></script>` after cursor.css. */
|
|
6
|
+
|
|
7
|
+
import { SEMANTIC_POINTER, SEMANTIC_BLOCKED, collectCursorSelectors } from './cursor-rules.js';
|
|
8
|
+
|
|
9
|
+
const reducedQuery = matchMedia('(prefers-reduced-motion: reduce)');
|
|
10
|
+
// the gate is the media query outside which the sheet hides #cursor and stops hiding the
|
|
11
|
+
// native cursor, so no device arms the hidden cursor for a mark the stylesheet already hid.
|
|
12
|
+
// Both queries are watched: a visitor switching reduced motion on mid-session gets the
|
|
13
|
+
// native cursor back at once, from the module standing down and from the sheet alike, and
|
|
14
|
+
// the drawn one back on the next move after switching it off again (a page booted under
|
|
15
|
+
// reduced motion never builds it)
|
|
16
|
+
const fineQuery = matchMedia('(pointer: fine) and (hover: hover)');
|
|
17
|
+
// a list that carries a `change` listener is never read: in Chrome a `.matches` read can eat
|
|
18
|
+
// the list's next change event (cursor.md, the anti-patterns), so the gate reads through
|
|
19
|
+
// lists of its own that nothing listens on
|
|
20
|
+
const reducedNow = matchMedia(reducedQuery.media), fineNow = matchMedia(fineQuery.media);
|
|
21
|
+
const motionAllowed = () => fineNow.matches && !reducedNow.matches;
|
|
22
|
+
const onGateChange = fn => {
|
|
23
|
+
reducedQuery.addEventListener('change', fn);
|
|
24
|
+
fineQuery.addEventListener('change', fn);
|
|
25
|
+
};
|
|
26
|
+
const root = document.documentElement;
|
|
27
|
+
|
|
28
|
+
/* Tokens resolve through a 1x1 canvas: getComputedStyle serializes oklch back
|
|
29
|
+
as oklch, while fillStyle takes any colour the engine knows. A value
|
|
30
|
+
fillStyle rejects leaves the previous one standing, hence the sentinel. */
|
|
31
|
+
const swatch = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
|
|
32
|
+
const resolveColour = (prop, fallback) => {
|
|
33
|
+
const css = getComputedStyle(root).getPropertyValue(prop).trim();
|
|
34
|
+
if (!css) return fallback;
|
|
35
|
+
swatch.fillStyle = '#000000';
|
|
36
|
+
swatch.fillStyle = css;
|
|
37
|
+
if (swatch.fillStyle === '#000000' && css !== '#000000') return fallback;
|
|
38
|
+
swatch.clearRect(0, 0, 1, 1);
|
|
39
|
+
swatch.fillRect(0, 0, 1, 1);
|
|
40
|
+
const d = swatch.getImageData(0, 0, 1, 1).data;
|
|
41
|
+
return [d[0], d[1], d[2]];
|
|
42
|
+
};
|
|
43
|
+
const onThemeChange = fn => new MutationObserver(fn).observe(root, { attributeFilter: ['data-theme'] });
|
|
44
|
+
|
|
45
|
+
/* Every spring here is underdamped (zeta < 1). With its target held over a step, its exact
|
|
46
|
+
solution maps the offset from the target and the velocity through one matrix, so a frame
|
|
47
|
+
of any length lands where the continuous spring would be: no step size, the same curve at
|
|
48
|
+
every refresh rate. dt is in the unit omega's radians are per. */
|
|
49
|
+
const springMatrix = (omega, zeta, dt) => {
|
|
50
|
+
const damping = zeta * omega, wd = omega * Math.sqrt(1 - zeta * zeta);
|
|
51
|
+
const decay = Math.exp(-damping * dt), cos = Math.cos(wd * dt), sin = Math.sin(wd * dt) / wd;
|
|
52
|
+
return {
|
|
53
|
+
xx: decay * (cos + damping * sin), xv: decay * sin,
|
|
54
|
+
vx: -decay * omega * omega * sin, vv: decay * (cos - damping * sin),
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// an input the page receives after a frame that passed its stamp is still played from its stamp,
|
|
59
|
+
// this far back at most; an older one is played from this far back
|
|
60
|
+
const MAX_LATE_MS = 64;
|
|
61
|
+
|
|
62
|
+
// ---- the dot grid: a still texture, a spotlight at the pointer, and a wake ----
|
|
63
|
+
// the grid's move handler, built only when motion is allowed at boot. It rides the one
|
|
64
|
+
// module-level pointermove listener that also looks for a late sheet, so a boot during a move
|
|
65
|
+
// still pushes the grid on that move
|
|
66
|
+
let gridTrack = null;
|
|
67
|
+
const bootGrid = () => {
|
|
68
|
+
let canvas = document.getElementById('bg-dots');
|
|
69
|
+
if (!canvas) {
|
|
70
|
+
canvas = document.createElement('canvas');
|
|
71
|
+
canvas.id = 'bg-dots';
|
|
72
|
+
canvas.setAttribute('aria-hidden', 'true');
|
|
73
|
+
document.body.prepend(canvas);
|
|
74
|
+
}
|
|
75
|
+
const ctx = canvas.getContext('2d');
|
|
76
|
+
const gap = 40, R = 150;
|
|
77
|
+
// `fromX`/`fromY`: where the next push measures from; `slack`: the speed cap's unspent allowance
|
|
78
|
+
const mouse = { x: 0, y: 0, t: 0, active: false, fromX: 0, fromY: 0, slack: 0 };
|
|
79
|
+
let w, h, dots = [], cols = 0, rows = 0, originX = 0, originY = 0;
|
|
80
|
+
let scheduled = false, flowing = false, lastFrame = 0;
|
|
81
|
+
let base = [54, 56, 71], lit = [104, 187, 239];
|
|
82
|
+
// the lift keeps its peak whatever the floor, so a raised --dot-rest shortens
|
|
83
|
+
// the spotlight's travel instead of pushing it past the top of the ramp
|
|
84
|
+
const REST = 0.24, PEAK = 0.80;
|
|
85
|
+
let rest = REST;
|
|
86
|
+
const readTokens = () => {
|
|
87
|
+
base = resolveColour('--line', base);
|
|
88
|
+
lit = resolveColour('--accent', lit);
|
|
89
|
+
const raw = parseFloat(getComputedStyle(root).getPropertyValue('--dot-rest'));
|
|
90
|
+
rest = Number.isFinite(raw) ? Math.min(Math.max(raw, 0), PEAK) : REST;
|
|
91
|
+
};
|
|
92
|
+
root.style.setProperty('--dot-gap', gap + 'px');
|
|
93
|
+
|
|
94
|
+
/* THE WAKE. A moving pointer pushes every dot inside WAKE_R away from itself, by the
|
|
95
|
+
distance it moved since its last move (speed times the interval, so a stroke's whole
|
|
96
|
+
push follows its length rather than the event rate), and each dot rides a damped spring
|
|
97
|
+
back to its home: omega 0.02 rad/ms at zeta 0.5. A dot that settles is snapped to zero,
|
|
98
|
+
so the resting frame is byte-identical to the one before the wake. A parted dot also
|
|
99
|
+
lights up by how far it sits from home, so the wake glows while it swings back. */
|
|
100
|
+
const WAKE_R = 110, WAKE_K = 0.012, WAKE_MAX = 14, OMEGA = 0.02, ZETA = 0.5, WAKE_SPEED = 3;
|
|
101
|
+
const EPS = 0.02;
|
|
102
|
+
// the dots play this long at most past the last frame or move: a quiet stall is skipped, and a
|
|
103
|
+
// stall the pointer moves through plays its real time
|
|
104
|
+
const MAX_FLOW_MS = 50;
|
|
105
|
+
// the speed cap is an allowance of WAKE_SPEED per ms that carries this much of it unspent, so
|
|
106
|
+
// stamps rounded to whole ms, which can put two samples' travel in one interval, push in full
|
|
107
|
+
const WAKE_SLACK_MS = 1;
|
|
108
|
+
|
|
109
|
+
const build = () => {
|
|
110
|
+
const dpr = Math.min(devicePixelRatio || 1, 2);
|
|
111
|
+
w = innerWidth; h = innerHeight;
|
|
112
|
+
canvas.width = w * dpr; canvas.height = h * dpr;
|
|
113
|
+
canvas.style.width = w + 'px'; canvas.style.height = h + 'px';
|
|
114
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
115
|
+
dots = [];
|
|
116
|
+
cols = Math.ceil(w / gap) + 1; rows = Math.ceil(h / gap) + 1;
|
|
117
|
+
originX = (w - (cols - 1) * gap) / 2; originY = (h - (rows - 1) * gap) / 2;
|
|
118
|
+
for (let y = 0; y < rows; y++) for (let x = 0; x < cols; x++) {
|
|
119
|
+
dots.push({ bx: originX + x * gap, by: originY + y * gap, ox: 0, oy: 0, vx: 0, vy: 0, pushed: false });
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const draw = () => {
|
|
123
|
+
ctx.clearRect(0, 0, w, h);
|
|
124
|
+
const on = motionAllowed() && mouse.active;
|
|
125
|
+
const shx = on ? (mouse.x - w / 2) / w * 10 : 0, shy = on ? (mouse.y - h / 2) / h * 10 : 0;
|
|
126
|
+
for (const d of dots) {
|
|
127
|
+
const px = d.bx + d.ox + shx, py = d.by + d.oy + shy;
|
|
128
|
+
let f = 0;
|
|
129
|
+
if (on) {
|
|
130
|
+
const dist = Math.hypot(px - mouse.x, py - mouse.y);
|
|
131
|
+
if (dist < R) f = (1 - dist / R) ** 2;
|
|
132
|
+
}
|
|
133
|
+
if (d.ox || d.oy) f = Math.max(f, Math.min(1, Math.hypot(d.ox, d.oy) / WAKE_MAX) * 0.7);
|
|
134
|
+
const a = rest + f * (PEAK - rest), r = 1.15 + f * 1.45;
|
|
135
|
+
const c = f ? base.map((b, i) => Math.round(b + (lit[i] - b) * f)) : base;
|
|
136
|
+
ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI * 2);
|
|
137
|
+
ctx.fillStyle = `rgba(${c[0]},${c[1]},${c[2]},${a})`; ctx.fill();
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
const schedule = () => { if (!scheduled && !flowing) { scheduled = true; requestAnimationFrame(() => { scheduled = false; draw(); }); } };
|
|
141
|
+
|
|
142
|
+
const resting = d => !d.ox && !d.oy && !d.vx && !d.vy;
|
|
143
|
+
const stepDot = (d, k) => {
|
|
144
|
+
const { ox, oy, vx, vy } = d;
|
|
145
|
+
d.ox = k.xx * ox + k.xv * vx; d.vx = k.vx * ox + k.vv * vx;
|
|
146
|
+
d.oy = k.xx * oy + k.xv * vy; d.vy = k.vx * oy + k.vv * vy;
|
|
147
|
+
// the cap is a wall: a dot that reaches it stops there with its outward speed gone, so
|
|
148
|
+
// a scribble held in one place cannot fling it further
|
|
149
|
+
const off = Math.hypot(d.ox, d.oy);
|
|
150
|
+
if (off > WAKE_MAX) {
|
|
151
|
+
const outward = (d.vx * d.ox + d.vy * d.oy) / off;
|
|
152
|
+
if (outward > 0) { d.vx -= outward * d.ox / off; d.vy -= outward * d.oy / off; }
|
|
153
|
+
d.ox *= WAKE_MAX / off; d.oy *= WAKE_MAX / off;
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// the instant the dots' clock stops at until the next frame or move
|
|
158
|
+
const flowsUntil = () => Math.max(lastFrame, mouse.t) + MAX_FLOW_MS;
|
|
159
|
+
|
|
160
|
+
// the spring loop runs only while a dot is off its home, and parks on the resting frame.
|
|
161
|
+
// `lastFrame` is the instant the dots stand at: the push that woke the loop, then each
|
|
162
|
+
// frame, or a move landing more than MAX_FLOW_MS past it (catchUp). A frame stamped before
|
|
163
|
+
// that push (an input landing after the vsync it is dispatched in) steps nothing.
|
|
164
|
+
const flow = now => {
|
|
165
|
+
const dt = Math.max(0, Math.min(now, flowsUntil()) - lastFrame);
|
|
166
|
+
lastFrame = Math.max(lastFrame, now);
|
|
167
|
+
const k = springMatrix(OMEGA, ZETA, dt);
|
|
168
|
+
let moving = false;
|
|
169
|
+
for (const d of dots) {
|
|
170
|
+
if (resting(d)) continue;
|
|
171
|
+
stepDot(d, k);
|
|
172
|
+
// a dot pushed since the last frame is never snapped on it: a weak push at the wake's
|
|
173
|
+
// edge adds up with the next one at every refresh rate, not only where frames are rare
|
|
174
|
+
if (!d.pushed && Math.abs(d.ox) < EPS && Math.abs(d.oy) < EPS && Math.abs(d.vx) < EPS / 16 && Math.abs(d.vy) < EPS / 16) {
|
|
175
|
+
d.ox = d.oy = d.vx = d.vy = 0;
|
|
176
|
+
} else moving = true;
|
|
177
|
+
d.pushed = false;
|
|
178
|
+
}
|
|
179
|
+
draw();
|
|
180
|
+
if (moving) requestAnimationFrame(flow);
|
|
181
|
+
else flowing = false;
|
|
182
|
+
};
|
|
183
|
+
// a move landing more than MAX_FLOW_MS after the instant the dots stand at first brings them
|
|
184
|
+
// to it on their own clock, skipping the stall between as a frame would, so a push is never
|
|
185
|
+
// folded back more than MAX_FLOW_MS nor across a stall the clock skipped
|
|
186
|
+
const catchUp = at => {
|
|
187
|
+
if (!flowing || at - lastFrame <= MAX_FLOW_MS) return;
|
|
188
|
+
const k = springMatrix(OMEGA, ZETA, Math.min(at, flowsUntil()) - lastFrame);
|
|
189
|
+
for (const d of dots) if (!resting(d)) stepDot(d, k);
|
|
190
|
+
lastFrame = at;
|
|
191
|
+
};
|
|
192
|
+
const part = (x, y, travel, at) => {
|
|
193
|
+
// the dots are drawn under the parallax shift, so the wake is centred where they paint
|
|
194
|
+
x -= (mouse.x - w / 2) / w * 10; y -= (mouse.y - h / 2) / h * 10;
|
|
195
|
+
const push = travel * WAKE_K;
|
|
196
|
+
// only the grid cells the wake can reach are visited: the pitch is regular, so the
|
|
197
|
+
// index window is arithmetic, and a parted dot sits at most WAKE_MAX from its cell
|
|
198
|
+
const reach = WAKE_R + WAKE_MAX;
|
|
199
|
+
const x0 = Math.max(0, Math.floor((x - reach - originX) / gap)), x1 = Math.min(cols - 1, Math.ceil((x + reach - originX) / gap));
|
|
200
|
+
const y0 = Math.max(0, Math.floor((y - reach - originY) / gap)), y1 = Math.min(rows - 1, Math.ceil((y + reach - originY) / gap));
|
|
201
|
+
// a push wakes the loop at its own instant; one landing while the loop runs meets each
|
|
202
|
+
// dot where it stands at `at`, and is folded by the spring's own motion to the instant
|
|
203
|
+
// the dots stand at, back to it, or forward from a stamp up to MAX_LATE_MS before it, so
|
|
204
|
+
// the dots move from `at` on as if the push had landed then
|
|
205
|
+
if (!flowing) { flowing = true; lastFrame = at; requestAnimationFrame(flow); }
|
|
206
|
+
const since = Math.max(at - lastFrame, -MAX_LATE_MS);
|
|
207
|
+
const ahead = springMatrix(OMEGA, ZETA, since), back = springMatrix(OMEGA, ZETA, -since);
|
|
208
|
+
for (let row = y0; row <= y1; row++) for (let col = x0; col <= x1; col++) {
|
|
209
|
+
const d = dots[row * cols + col];
|
|
210
|
+
const dx = d.bx + ahead.xx * d.ox + ahead.xv * d.vx - x, dy = d.by + ahead.xx * d.oy + ahead.xv * d.vy - y;
|
|
211
|
+
const dist = Math.hypot(dx, dy);
|
|
212
|
+
if (dist >= WAKE_R || dist < 0.5) continue;
|
|
213
|
+
const f = (1 - dist / WAKE_R) ** 2 * push;
|
|
214
|
+
const kx = dx / dist * f, ky = dy / dist * f;
|
|
215
|
+
d.ox += back.xv * kx; d.vx += back.vv * kx;
|
|
216
|
+
d.oy += back.xv * ky; d.vy += back.vv * ky;
|
|
217
|
+
d.pushed = true;
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
readTokens(); build(); draw();
|
|
222
|
+
if (motionAllowed()) {
|
|
223
|
+
gridTrack = e => {
|
|
224
|
+
if (!motionAllowed()) return;
|
|
225
|
+
// one push per move the pointer made, coalesced into this event or not, each at the
|
|
226
|
+
// move's own instant held inside [the last move's, now], so the pushes are the same
|
|
227
|
+
// however the browser batches the moves into dispatches
|
|
228
|
+
const batch = e.getCoalescedEvents ? e.getCoalescedEvents() : [];
|
|
229
|
+
for (const c of batch.length ? batch : [e]) {
|
|
230
|
+
const at = Math.min(Math.max(c.timeStamp, mouse.t), performance.now());
|
|
231
|
+
catchUp(at);
|
|
232
|
+
// a gap past 80ms is a new stroke, never a 1000px jump to push with
|
|
233
|
+
const stroke = mouse.active && mouse.t && at - mouse.t <= 80;
|
|
234
|
+
// a sample sharing the instant of the one before it moves the pointer and pushes
|
|
235
|
+
// nothing: the next later one pushes for both, so no travel is lost to stamps in whole ms
|
|
236
|
+
if (stroke && at === mouse.t) { mouse.x = c.clientX; mouse.y = c.clientY; continue; }
|
|
237
|
+
if (stroke) {
|
|
238
|
+
// the speed cap as a distance, so it binds the same at every event rate
|
|
239
|
+
const allowance = mouse.slack + WAKE_SPEED * (at - mouse.t);
|
|
240
|
+
const travel = Math.min(Math.hypot(c.clientX - mouse.fromX, c.clientY - mouse.fromY), allowance);
|
|
241
|
+
mouse.slack = Math.min(allowance - travel, WAKE_SPEED * WAKE_SLACK_MS);
|
|
242
|
+
part(c.clientX, c.clientY, travel, at);
|
|
243
|
+
} else mouse.slack = 0;
|
|
244
|
+
mouse.x = mouse.fromX = c.clientX; mouse.y = mouse.fromY = c.clientY; mouse.t = at; mouse.active = true;
|
|
245
|
+
}
|
|
246
|
+
schedule();
|
|
247
|
+
};
|
|
248
|
+
root.addEventListener('mouseleave', () => { mouse.active = false; schedule(); });
|
|
249
|
+
// a gate closing mid-session puts every dot home now, not after its swing
|
|
250
|
+
onGateChange(() => {
|
|
251
|
+
if (motionAllowed()) return;
|
|
252
|
+
for (const d of dots) d.ox = d.oy = d.vx = d.vy = 0;
|
|
253
|
+
mouse.active = false;
|
|
254
|
+
schedule();
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
addEventListener('resize', () => { build(); draw(); });
|
|
258
|
+
onThemeChange(() => { readTokens(); schedule(); });
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
// ---- the jelly drop (replaces the native cursor) ----
|
|
262
|
+
const bootMark = () => {
|
|
263
|
+
const host = document.createElement('div');
|
|
264
|
+
host.id = 'cursor';
|
|
265
|
+
host.setAttribute('aria-hidden', 'true');
|
|
266
|
+
const canvas = document.createElement('canvas');
|
|
267
|
+
canvas.id = 'cursor-paint';
|
|
268
|
+
host.append(canvas);
|
|
269
|
+
document.body.appendChild(host);
|
|
270
|
+
const ctx = canvas.getContext('2d');
|
|
271
|
+
// the bloom is its own element under the host: a canvas blend mode composites only
|
|
272
|
+
// against the canvas, so the one way to lighten the page without shading it is the
|
|
273
|
+
// halo's, a CSS mix-blend-mode on a node the page composites
|
|
274
|
+
const bloom = document.createElement('div');
|
|
275
|
+
bloom.id = 'cursor-glow';
|
|
276
|
+
bloom.setAttribute('aria-hidden', 'true');
|
|
277
|
+
document.body.appendChild(bloom);
|
|
278
|
+
|
|
279
|
+
const rootStyle = getComputedStyle(root);
|
|
280
|
+
const token = (name, fallback) => parseFloat(rootStyle.getPropertyValue(name)) || fallback;
|
|
281
|
+
const SIZE = {
|
|
282
|
+
idle: token('--cursor-size-idle', 18),
|
|
283
|
+
hover: token('--cursor-size-hover', 24),
|
|
284
|
+
press: token('--cursor-size-press', 19),
|
|
285
|
+
pressIdle: token('--cursor-size-press-idle', 14),
|
|
286
|
+
blocked: token('--cursor-size-blocked', 24),
|
|
287
|
+
};
|
|
288
|
+
const RIPPLE_MS = token('--dur-far', 260);
|
|
289
|
+
const RIPPLE_R0 = 10, RIPPLE_R1 = 36; // a 20px ring opening to 72px
|
|
290
|
+
const SHOW_MS = token('--dur-near', 180), HIDE_MS = SHOW_MS * 0.6; // the mark's own fade, an exit at 0.6x
|
|
291
|
+
const STROKE = 2, DOT_R = 2, KEYLINE = 2.6;
|
|
292
|
+
// the trail stops GAP_PX short of the keyline's outer edge, so a dark ring of one width
|
|
293
|
+
// always separates the mark from its trail: the hole cut under the trail is the mark's
|
|
294
|
+
// outline grown by half the keyline plus the gap
|
|
295
|
+
const GAP_PX = 2, HOLE_PX = KEYLINE / 2 + GAP_PX;
|
|
296
|
+
const TAU = Math.PI * 2;
|
|
297
|
+
|
|
298
|
+
// the system's --ease-out, cubic-bezier(0.16, 1, 0.3, 1), solved for x by Newton's method
|
|
299
|
+
const cubicBezier = (x1, y1, x2, y2) => {
|
|
300
|
+
const at = (a, b, t) => ((1 - 3 * b + 3 * a) * t * t + (3 * b - 6 * a) * t + 3 * a) * t;
|
|
301
|
+
const slope = (a, b, t) => 3 * (1 - 3 * b + 3 * a) * t * t + 2 * (3 * b - 6 * a) * t + 3 * a;
|
|
302
|
+
return x => {
|
|
303
|
+
let t = x;
|
|
304
|
+
for (let i = 0; i < 8; i++) {
|
|
305
|
+
const d = slope(x1, x2, t);
|
|
306
|
+
if (Math.abs(d) < 1e-6) break;
|
|
307
|
+
t -= (at(x1, x2, t) - x) / d;
|
|
308
|
+
}
|
|
309
|
+
return at(y1, y2, Math.min(1, Math.max(0, t)));
|
|
310
|
+
};
|
|
311
|
+
};
|
|
312
|
+
const easeOut = cubicBezier(0.16, 1, 0.3, 1);
|
|
313
|
+
|
|
314
|
+
// The trail is a time window, as long as the last TAIL_MS of travel, that drains
|
|
315
|
+
// into the ring when the pointer stops and is dropped once that drain has run the window
|
|
316
|
+
// out: nothing is left at rest. Stillness past DRAIN_MS is not travel: the window is
|
|
317
|
+
// measured on a clock that stops with the pointer, while the path it holds ages in real
|
|
318
|
+
// time, so a pointer that stops between steps leaves a shorter tail than its path alone.
|
|
319
|
+
// At a crawl the window stretches until it holds the mark's larger half size of path
|
|
320
|
+
// past the hole, so a short tail shows behind the mark; CRAWL_MS caps it at a second of
|
|
321
|
+
// travel. A run of travel begins from rest or from a stop, and its tail grows from
|
|
322
|
+
// nothing as path accrues past the hole (see drawTrail).
|
|
323
|
+
const TAIL_MS = 240, CRAWL_MS = 1000;
|
|
324
|
+
// the centre line gets a point at least every ARC_PX of path and every STEP_MS of time.
|
|
325
|
+
// MAX_STEPS holds about 8000px of path in one window that way; a window wanting more is
|
|
326
|
+
// laid proportionally coarser, never cut short
|
|
327
|
+
const ARC_PX = 2, STEP_MS = 2, MAX_STEPS = 4096;
|
|
328
|
+
// a span at least GAP_MIN_PX long and over GAP_RATIO times the chords on both sides of it
|
|
329
|
+
// is samples the browser lost; each side's direction is read over GAP_TANGENT_PX of path,
|
|
330
|
+
// and the fill lays points about a neighbouring chord apart: at least GAP_SPACING_PX, at
|
|
331
|
+
// most GAP_MAX_POINTS of them
|
|
332
|
+
const GAP_MIN_PX = 24, GAP_RATIO = 3, GAP_TANGENT_PX = 8, GAP_SPACING_PX = 4, GAP_MAX_POINTS = 64;
|
|
333
|
+
// the drawn centre line and the recorded half sizes are low-passed over this much arc,
|
|
334
|
+
// resampled every RESAMPLE_PX
|
|
335
|
+
const SMOOTH_PX = 8, RESAMPLE_PX = 2;
|
|
336
|
+
// a pointer quiet this long has stopped and the tube starts to drain; a mouse creeping a
|
|
337
|
+
// pixel every 80ms is still travelling
|
|
338
|
+
const DRAIN_MS = 90;
|
|
339
|
+
const TRAIL_PEAK = 0.9; // effective alpha at the head
|
|
340
|
+
const TEXT_PEAK = 0.6; // over text, so the glyph just behind the caret shows through
|
|
341
|
+
const TRIM_PX = 4; // the trail starts this much narrower than the mark is across the motion
|
|
342
|
+
const TANGENT_PX = 1; // a grid point's direction is read over at least this much path
|
|
343
|
+
const HEAD_TANGENT_PX = 16; // and the head's over this much path behind it
|
|
344
|
+
const EXIT_TANGENT_PX = 2; // and the line's where it leaves the hole over this much each side
|
|
345
|
+
// past where the line leaves the hole the tube eases from the mark's own width across the
|
|
346
|
+
// motion to the narrow axis its samples recorded over this much path
|
|
347
|
+
const START_EASE_PX = 16;
|
|
348
|
+
// the start reads across the line, not the head, once the exit lies this far past the
|
|
349
|
+
// hole's edge and the turn there clears the hole by as much (startWidth). A smaller scale
|
|
350
|
+
// reads the line sooner at a hairpin, and sooner on a fast shake's turns that barely clear
|
|
351
|
+
// the hole, where the line read jumps from frame to frame
|
|
352
|
+
const START_RAMP_PX = 3.3;
|
|
353
|
+
const PIXEL_ALPHA = 0.5 / 255; // a stamp fainter than this rounds to nothing on the cleared canvas
|
|
354
|
+
const gridX = new Float32Array(MAX_STEPS + 1), gridY = new Float32Array(MAX_STEPS + 1);
|
|
355
|
+
const gridAge = new Float32Array(MAX_STEPS + 1);
|
|
356
|
+
const gridDist = new Float32Array(MAX_STEPS + 1); // path length back from the head
|
|
357
|
+
const gridEnd = new Float32Array(MAX_STEPS + 1); // where that point's run of travel ends, as path length back from the head
|
|
358
|
+
const gridPause = new Float32Array(MAX_STEPS + 1); // stillness past DRAIN_MS, summed over the gaps since that point was laid
|
|
359
|
+
const pauseAfter = []; // the same per path point, filled each frame
|
|
360
|
+
const knots = []; // where each run of path points at one position starts, filled each frame
|
|
361
|
+
const spanX = [0, 0, 0, 0], spanY = [0, 0, 0, 0]; // the four control points of the span being resampled
|
|
362
|
+
// the mark's half sizes under its squeeze, less the trim, as each point's samples recorded
|
|
363
|
+
// them, low-passed along the path; the tube's half width there (before the age fade and the
|
|
364
|
+
// run caps); and the trail's peak alpha there (lower over text)
|
|
365
|
+
const gridW = new Float32Array(MAX_STEPS + 1), gridH = new Float32Array(MAX_STEPS + 1);
|
|
366
|
+
const gridAcross = new Float32Array(MAX_STEPS + 1);
|
|
367
|
+
const gridPeak = new Float32Array(MAX_STEPS + 1);
|
|
368
|
+
const gridTx = new Float32Array(MAX_STEPS + 1), gridTy = new Float32Array(MAX_STEPS + 1);
|
|
369
|
+
const gridT = new Float64Array(MAX_STEPS + 1); // the instant each point was laid at
|
|
370
|
+
const gridArc = new Float64Array(MAX_STEPS + 1); // path length from the tail, for the low-pass
|
|
371
|
+
// the centre line and the half sizes resampled evenly by arc, before and after the
|
|
372
|
+
// low-pass, and its full-width kernel
|
|
373
|
+
let evenX = new Float64Array(4096), evenY = new Float64Array(4096), evenW = new Float64Array(4096), evenH = new Float64Array(4096);
|
|
374
|
+
let lowX = new Float64Array(4096), lowY = new Float64Array(4096), lowW = new Float64Array(4096), lowH = new Float64Array(4096);
|
|
375
|
+
const kernel = new Float64Array(Math.floor(6 * SMOOTH_PX / RESAMPLE_PX) + 1);
|
|
376
|
+
|
|
377
|
+
// Jelly: stretch along the velocity, area held, through a spring loose enough
|
|
378
|
+
// to swing past round into a squash when the pointer stops.
|
|
379
|
+
const JELLY_MAX = 0.42, JELLY_OMEGA = 20, JELLY_ZETA = 0.28, VEL_WINDOW_MS = 24, STILL_MS = 40;
|
|
380
|
+
const JELLY_GAIN = 0.28, JELLY_CURVE = 0.75; // stretch at 1000px/s, and how it grows past that
|
|
381
|
+
const SHAKE_KICK = 9;
|
|
382
|
+
// the motion between two frames of a running loop plays this long at most, however many
|
|
383
|
+
// inputs split it: a stalled tab plays on from where it was instead of jumping ahead
|
|
384
|
+
const MAX_STEP_S = 0.064;
|
|
385
|
+
const SHAPE_OMEGA = 30, SHAPE_ZETA = 0.55;
|
|
386
|
+
|
|
387
|
+
// The light-up: the mark's ink brightens and a bloom grows under it with speed, both on
|
|
388
|
+
// an eased tone rather than the raw interval, so a stop drifts down instead of popping.
|
|
389
|
+
// The bloom's box is its largest radius; it scales down from there.
|
|
390
|
+
const TONE_MS = 120, FULL_SPEED = 1.4, GLOW_R0 = 14, GLOW_R1 = 20, GLOW_PEAK = 0.105;
|
|
391
|
+
const GLOW_BOX = GLOW_R0 + GLOW_R1;
|
|
392
|
+
|
|
393
|
+
// Droplets shed off the head only past SPRAY_MIN px/ms: SPRAY_RATE * x^2 / (x + SPRAY_EASE)
|
|
394
|
+
// droplets a ms, x the speed past SPRAY_MIN, so a stroke just past it sheds a few and the
|
|
395
|
+
// rate's slope nears SPRAY_RATE per px/ms further up; their orange share ramps from none at
|
|
396
|
+
// SPRAY_MIN to ORANGE_MAX at SPRAY_FULL. The cap bounds the shed and refuses a new droplet
|
|
397
|
+
// rather than evicting a live one: every particle plays its whole life, and a click's
|
|
398
|
+
// splash is never refused.
|
|
399
|
+
const SPRAY_MIN = 2.5, SPRAY_EASE = 1, SPRAY_FULL = 4, SPRAY_RATE = 0.08, SPRAY_CAP = 96, ORANGE_MAX = 0.5;
|
|
400
|
+
|
|
401
|
+
let dpr = 1;
|
|
402
|
+
let ink = [98, 186, 241], litInk = [190, 225, 250], keyline = [43, 45, 60], refuse = [233, 120, 130];
|
|
403
|
+
let spray = [190, 225, 250], sprayHot = [240, 170, 120], rippleInk = [160, 215, 250];
|
|
404
|
+
let sprite = null;
|
|
405
|
+
// the light-up moves the ink by its tone times the widest channel gap to the lit ink, and
|
|
406
|
+
// the bloom shows nothing under 0.02 of it: a tone below this rounds to the plain ink
|
|
407
|
+
let toneRest = 0.003;
|
|
408
|
+
|
|
409
|
+
const rgb = c => `rgb(${c[0]},${c[1]},${c[2]})`;
|
|
410
|
+
const mix = (a, b, t) => a.map((v, i) => Math.round(v + (b[i] - v) * t));
|
|
411
|
+
|
|
412
|
+
// one soft disc, stamped along the path: overlapping stamps shade the trail
|
|
413
|
+
// like a tube, bright in the middle and soft at the edge, with no seams
|
|
414
|
+
const buildSprite = (core, edge) => {
|
|
415
|
+
const s = document.createElement('canvas');
|
|
416
|
+
s.width = s.height = 64;
|
|
417
|
+
const g = s.getContext('2d');
|
|
418
|
+
const grad = g.createRadialGradient(32, 32, 0, 32, 32, 32);
|
|
419
|
+
grad.addColorStop(0, `rgba(${core[0]},${core[1]},${core[2]},1)`);
|
|
420
|
+
grad.addColorStop(0.35, `rgba(${core[0]},${core[1]},${core[2]},1)`);
|
|
421
|
+
grad.addColorStop(0.75, `rgba(${edge[0]},${edge[1]},${edge[2]},0.85)`);
|
|
422
|
+
grad.addColorStop(1, `rgba(${edge[0]},${edge[1]},${edge[2]},0)`);
|
|
423
|
+
g.fillStyle = grad;
|
|
424
|
+
g.fillRect(0, 0, 64, 64);
|
|
425
|
+
return s;
|
|
426
|
+
};
|
|
427
|
+
const readColours = () => {
|
|
428
|
+
ink = resolveColour('--cursor-ink', ink);
|
|
429
|
+
litInk = resolveColour('--cursor-lit', litInk);
|
|
430
|
+
keyline = resolveColour('--cursor-keyline', keyline);
|
|
431
|
+
refuse = resolveColour('--cursor-refuse', refuse);
|
|
432
|
+
spray = resolveColour('--cursor-spray', spray);
|
|
433
|
+
sprayHot = resolveColour('--cursor-spray-hot', sprayHot);
|
|
434
|
+
rippleInk = resolveColour('--cursor-ripple', rippleInk);
|
|
435
|
+
sprite = buildSprite(resolveColour('--cursor-trail-core', ink), resolveColour('--cursor-trail-edge', ink));
|
|
436
|
+
toneRest = Math.min(0.02, 0.5 / Math.max(1, ...ink.map((c, i) => Math.abs(litInk[i] - c))));
|
|
437
|
+
};
|
|
438
|
+
const sizeCanvas = () => {
|
|
439
|
+
dpr = Math.min(devicePixelRatio || 1, 2);
|
|
440
|
+
canvas.width = Math.round(innerWidth * dpr);
|
|
441
|
+
canvas.height = Math.round(innerHeight * dpr);
|
|
442
|
+
canvas.style.width = innerWidth + 'px';
|
|
443
|
+
canvas.style.height = innerHeight + 'px';
|
|
444
|
+
dirty = null;
|
|
445
|
+
request();
|
|
446
|
+
};
|
|
447
|
+
// a window dragged to another monitor changes the ratio with no resize event
|
|
448
|
+
const watchRatio = () => {
|
|
449
|
+
matchMedia(`(resolution: ${devicePixelRatio}dppx)`).addEventListener('change', () => { sizeCanvas(); watchRatio(); }, { once: true });
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
// ---- state ----
|
|
453
|
+
const usable = list => list.filter(sel => {
|
|
454
|
+
try { host.matches(sel); return true; } catch { return false; }
|
|
455
|
+
}).join(', ');
|
|
456
|
+
const found = collectCursorSelectors(document.styleSheets);
|
|
457
|
+
const hitPointer = usable([...found.pointer, SEMANTIC_POINTER]);
|
|
458
|
+
const hitInert = usable(found.inert);
|
|
459
|
+
const hitBlocked = usable([...found.blocked, SEMANTIC_BLOCKED]);
|
|
460
|
+
|
|
461
|
+
/* A field is its resolved type, asked of an input only: `el.type` answers
|
|
462
|
+
`text` for a missing or unknown attribute, which is what renders, while
|
|
463
|
+
`<a>`, `<li>` and `<link>` reflect a `type="text"` attribute verbatim. */
|
|
464
|
+
const TEXT_TYPES = ['text', 'search', 'email', 'password', 'url', 'tel', 'number'];
|
|
465
|
+
const isField = el => (el instanceof HTMLInputElement ? TEXT_TYPES.includes(el.type)
|
|
466
|
+
: el instanceof HTMLTextAreaElement) || el.matches('[contenteditable="true"]');
|
|
467
|
+
|
|
468
|
+
const range = document.createRange();
|
|
469
|
+
// the runs that are the element's own: its text nodes and those of its inline
|
|
470
|
+
// descendants, never a block child's, whose glyphs retarget the event to it
|
|
471
|
+
// anyway. A hovered page or body therefore costs its direct children, not
|
|
472
|
+
// every run beneath them.
|
|
473
|
+
let walkRoot = null;
|
|
474
|
+
const walker = document.createTreeWalker(document, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, {
|
|
475
|
+
acceptNode: n => n.nodeType === Node.TEXT_NODE ? NodeFilter.FILTER_ACCEPT
|
|
476
|
+
: n === walkRoot || getComputedStyle(n).display.startsWith('inline') ? NodeFilter.FILTER_SKIP : NodeFilter.FILTER_REJECT,
|
|
477
|
+
});
|
|
478
|
+
// the caret promises glyphs under the pointer, so hit-test text runs, never a
|
|
479
|
+
// box: a block heading's box is mostly empty space. A run's rect is the glyph
|
|
480
|
+
// box, shorter than its line box, so the leading above and below a run counts
|
|
481
|
+
// as the run's, and so does the gap between two of the element's own runs
|
|
482
|
+
// stacked (an inline child taller than its line opens one wider than the
|
|
483
|
+
// leading), or a caret moving down through prose would swell back into the
|
|
484
|
+
// ring at every line; a block child's margin is no such gap
|
|
485
|
+
const overText = (el, x, y) => {
|
|
486
|
+
if (isField(el)) return true;
|
|
487
|
+
// CodeMirror-style editors hide glyphs behind pointer-events:none spans
|
|
488
|
+
if (el.closest('[contenteditable="true"]')) return true;
|
|
489
|
+
const line = parseFloat(getComputedStyle(el).lineHeight);
|
|
490
|
+
let above = -Infinity, below = Infinity;
|
|
491
|
+
walkRoot = el;
|
|
492
|
+
walker.currentNode = el;
|
|
493
|
+
for (let n = walker.nextNode(); n && el.contains(n); n = walker.nextNode()) {
|
|
494
|
+
if (!n.textContent.trim()) continue;
|
|
495
|
+
range.selectNodeContents(n);
|
|
496
|
+
for (const r of range.getClientRects()) {
|
|
497
|
+
if (x < r.left || x > r.right) continue;
|
|
498
|
+
const leading = Number.isFinite(line) ? Math.max(0, (line - r.height) / 2) : 0;
|
|
499
|
+
if (y >= r.top - leading && y <= r.bottom + leading) return true;
|
|
500
|
+
if (r.bottom <= y) above = Math.max(above, r.bottom);
|
|
501
|
+
else if (r.top >= y) below = Math.min(below, r.top);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return Number.isFinite(line) && below - above <= line * 1.5;
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
function stateFor(el, x, y) {
|
|
508
|
+
if (!el) return '';
|
|
509
|
+
if (hitBlocked && el.closest(hitBlocked)) return 'blocked';
|
|
510
|
+
const click = hitPointer && el.closest(hitPointer);
|
|
511
|
+
if (click) {
|
|
512
|
+
// a pointer switched back off outranks the clickable rule only from its own element or below
|
|
513
|
+
const off = hitInert && el.closest(hitInert);
|
|
514
|
+
if (!off || !(off === click || click.contains(off))) return 'hover';
|
|
515
|
+
}
|
|
516
|
+
return overText(el, x, y) ? 'text' : '';
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const pointer = { x: 0, y: 0 };
|
|
520
|
+
let state = '', caretSource = null, caretH = 20;
|
|
521
|
+
let placed = false, proven = false, detached = false, bailed = false, gated = false;
|
|
522
|
+
let pressedState = null, rootRetries = 0;
|
|
523
|
+
let frame = 0, applyQueued = false, queuedTarget = null, dirty = null;
|
|
524
|
+
// `last` is the instant the motion stands at, `frameAt` that of the last frame or wake. A
|
|
525
|
+
// parked loop requests no frames and keeps the goal its springs were heading for: a hidden
|
|
526
|
+
// mark parks with them still moving
|
|
527
|
+
let last = 0, frameAt = 0, parked = true, parkedGoal = null;
|
|
528
|
+
|
|
529
|
+
const inViewTransition = () => document.getAnimations().some(a =>
|
|
530
|
+
a.effect && a.effect.pseudoElement === '::view-transition-new(root)');
|
|
531
|
+
|
|
532
|
+
function apply(el) {
|
|
533
|
+
const target = el || document.elementFromPoint(pointer.x, pointer.y);
|
|
534
|
+
// mid view transition every hit test answers the root; hold the state and
|
|
535
|
+
// re-derive, and a root answer outliving the retries with no swap is honest
|
|
536
|
+
if (target === root) {
|
|
537
|
+
if (rootRetries++ < 5 || inViewTransition()) {
|
|
538
|
+
setTimeout(() => { if (!host.classList.contains('press')) queueApply(null); }, 80);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
rootRetries = 0;
|
|
543
|
+
state = stateFor(target, pointer.x, pointer.y);
|
|
544
|
+
host.classList.toggle('hover', state === 'hover');
|
|
545
|
+
host.classList.toggle('blocked', state === 'blocked');
|
|
546
|
+
host.classList.toggle('text', state === 'text');
|
|
547
|
+
if (state === 'text' && target && target !== caretSource) {
|
|
548
|
+
caretSource = target;
|
|
549
|
+
// past 20px type a proportional caret reads as a rule drawn on the page
|
|
550
|
+
const size = parseFloat(getComputedStyle(target).fontSize);
|
|
551
|
+
if (size) caretH = Math.min(Math.round(size * 1.2), 24);
|
|
552
|
+
} else if (state !== 'text') caretSource = null;
|
|
553
|
+
}
|
|
554
|
+
// overText reads client rects, which forces layout: once a frame, never once a move
|
|
555
|
+
function queueApply(el) {
|
|
556
|
+
queuedTarget = el;
|
|
557
|
+
applyQueued = true;
|
|
558
|
+
request();
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ---- motion ----
|
|
562
|
+
const samples = []; // { x, y, t, pause, net, w, h, peak, fill?, start? }, oldest first
|
|
563
|
+
let behind = null; // the path's point before the oldest sample kept: the last one dropped at another position, or the end of its fill
|
|
564
|
+
const ripples = []; // { x, y, t0 }
|
|
565
|
+
const drops = []; // { x, y, vx, vy, born, life, r, hot }
|
|
566
|
+
const spring = () => ({ x: 0, v: 0 });
|
|
567
|
+
const hw = spring(), hh = spring(), fill = spring(), dot = spring(), slash = spring(), red = spring();
|
|
568
|
+
const jp = spring(), jq = spring(); // stretch as a traceless tensor: amount and axis in one vector
|
|
569
|
+
hw.x = hh.x = SIZE.idle / 2; dot.x = 1;
|
|
570
|
+
let tone = 0; // the eased speed the light-up answers
|
|
571
|
+
// the mark's and trail's own fade, in and out with `visible`: droplets and ripples keep
|
|
572
|
+
// their own alpha, so a pointer leaving the window never cuts one short
|
|
573
|
+
let shown = 0;
|
|
574
|
+
let headX = 1, headY = 0; // the last direction the head travelled
|
|
575
|
+
let left = false; // the pointer left the window: the next move starts a new stroke
|
|
576
|
+
let lastEvent = null, sprayDebt = 0, hotDebt = 0, lastSpeed = 0;
|
|
577
|
+
|
|
578
|
+
// k: a springMatrix over the step
|
|
579
|
+
const step = (s, target, k) => {
|
|
580
|
+
const off = s.x - target;
|
|
581
|
+
s.x = target + k.xx * off + k.xv * s.v;
|
|
582
|
+
s.v = k.vx * off + k.vv * s.v;
|
|
583
|
+
};
|
|
584
|
+
const rest = (s, target, eps) => {
|
|
585
|
+
if (Math.abs(s.x - target) < eps && Math.abs(s.v) < eps * 20) { s.x = target; s.v = 0; return true; }
|
|
586
|
+
return false;
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
// the pointer's position at time t, linear between samples
|
|
590
|
+
const posAt = t => {
|
|
591
|
+
const n = samples.length;
|
|
592
|
+
if (t >= samples[n - 1].t) return [samples[n - 1].x, samples[n - 1].y];
|
|
593
|
+
if (t <= samples[0].t) return [samples[0].x, samples[0].y];
|
|
594
|
+
let i = n - 1;
|
|
595
|
+
while (i > 0 && samples[i - 1].t > t) i--;
|
|
596
|
+
const a = samples[i - 1], b = samples[i];
|
|
597
|
+
const u = b.t > a.t ? (t - a.t) / (b.t - a.t) : 1;
|
|
598
|
+
return [a.x + (b.x - a.x) * u, a.y + (b.y - a.y) * u];
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
// a pointer quiet for STILL_MS counts as stopped, and stays stopped until its next move
|
|
602
|
+
const pointerStill = now => samples.length < 2 || now - samples[samples.length - 1].t > STILL_MS;
|
|
603
|
+
|
|
604
|
+
// the pointer's velocity in px/ms over the last VEL_WINDOW_MS of samples, reaching back no
|
|
605
|
+
// earlier than `from`. The window ends at the newest sample, never at `now`, and spans
|
|
606
|
+
// samples rather than events, so the read is the same at 125Hz and 1000Hz, however the
|
|
607
|
+
// moves were batched, and a fast mouse's whole-pixel steps average out over it
|
|
608
|
+
function velocity(from) {
|
|
609
|
+
const head = samples[samples.length - 1];
|
|
610
|
+
const span = Math.min(VEL_WINDOW_MS, head.t - Math.max(from, samples[0].t));
|
|
611
|
+
if (span <= 1) return [0, 0];
|
|
612
|
+
const [ox, oy] = posAt(head.t - span);
|
|
613
|
+
return [(head.x - ox) / span, (head.y - oy) / span];
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function jellyTarget(now) {
|
|
617
|
+
if (pointerStill(now)) return [0, 0, 0];
|
|
618
|
+
const [mx, my] = velocity(-Infinity);
|
|
619
|
+
const vx = mx * 1000, vy = my * 1000;
|
|
620
|
+
const v = Math.hypot(vx, vy);
|
|
621
|
+
const amount = Math.min(JELLY_MAX, JELLY_GAIN * Math.pow(v / 1000, JELLY_CURVE));
|
|
622
|
+
const theta = Math.atan2(vy, vx);
|
|
623
|
+
return [amount * Math.cos(2 * theta), amount * Math.sin(2 * theta), v / 1000];
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function targets() {
|
|
627
|
+
if (state === 'text') return { w: 1, h: caretH / 2, f: 1, d: 0, sl: 0, r: 0 };
|
|
628
|
+
const pressed = host.classList.contains('press');
|
|
629
|
+
let size = SIZE.idle, f = 0, d = 1, sl = 0, r = 0;
|
|
630
|
+
// the hover fill sits over the text under the pointer: 12% of --cursor-ink, 26% pressed
|
|
631
|
+
if (state === 'hover') { size = pressed ? SIZE.press : SIZE.hover; f = pressed ? 0.26 : 0.12; d = 0; }
|
|
632
|
+
else if (state === 'blocked') { size = SIZE.blocked; d = 0; sl = 1; r = 1; }
|
|
633
|
+
else if (pressed) size = SIZE.pressIdle;
|
|
634
|
+
return { w: size / 2, h: size / 2, f, d, sl, r };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// what a sample records of the mark: its half sizes under the jelly's squeeze across its
|
|
638
|
+
// stretch (a ring at speed is narrower across than its size, so a fast stroke draws a
|
|
639
|
+
// narrower tube), less half the trim, floored at the caret's own half width so its line
|
|
640
|
+
// moving down stays that line; and the trail's peak alpha there. The squeeze is the narrow
|
|
641
|
+
// axis, so the recorded width does not depend on the direction the head had
|
|
642
|
+
const record = () => {
|
|
643
|
+
const squeeze = 1 / jellyStretch(shapeRoundness());
|
|
644
|
+
return {
|
|
645
|
+
w: Math.max(1, hw.x * squeeze - TRIM_PX / 2), h: Math.max(1, hh.x * squeeze - TRIM_PX / 2),
|
|
646
|
+
peak: state === 'text' ? TEXT_PEAK : TRAIL_PEAK,
|
|
647
|
+
};
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
// samples[i]'s direction of travel, read over GAP_TANGENT_PX of path on the side `dir`
|
|
651
|
+
// points to (-1 behind it, +1 ahead), and the sample that reach ends at; null with none,
|
|
652
|
+
// and null when the reach would cross a pause: a hand that stopped may leave another way
|
|
653
|
+
function travelAt(i, dir) {
|
|
654
|
+
const p = samples[i];
|
|
655
|
+
for (let j = i + dir; j >= 0 && j < samples.length; j += dir) {
|
|
656
|
+
if (samples[dir < 0 ? j + 1 : j].pause > 0) return null;
|
|
657
|
+
const dx = (samples[j].x - p.x) * dir, dy = (samples[j].y - p.y) * dir, d = Math.hypot(dx, dy);
|
|
658
|
+
if (d >= GAP_TANGENT_PX) return { x: dx / d, y: dy / d, at: j };
|
|
659
|
+
}
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// the first chord from samples[i] toward `dir` that moves at all; 0 with none
|
|
664
|
+
function chordAt(i, dir) {
|
|
665
|
+
for (let j = i; j + dir >= 0 && j + dir < samples.length; j += dir) {
|
|
666
|
+
const c = Math.hypot(samples[j + dir].x - samples[j].x, samples[j + dir].y - samples[j].y);
|
|
667
|
+
if (c > 0) return c;
|
|
668
|
+
}
|
|
669
|
+
return 0;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// points along a cubic Hermite from a to b, leaving a along `from` and arriving at b along
|
|
673
|
+
// `to` (a missing one is the other mirrored across the chord, close to the circular arc
|
|
674
|
+
// through both for a gentle turn), about `near` apart; each is timed and sized at its
|
|
675
|
+
// share of the way from a to b
|
|
676
|
+
function hermite(a, b, chord, from, to, near) {
|
|
677
|
+
const ex = (b.x - a.x) / chord, ey = (b.y - a.y) / chord;
|
|
678
|
+
const mirror = d => { const along = 2 * (d.x * ex + d.y * ey); return { x: along * ex - d.x, y: along * ey - d.y }; };
|
|
679
|
+
const ta = from || mirror(to), tb = to || mirror(from);
|
|
680
|
+
const m = Math.min(GAP_MAX_POINTS, Math.ceil(chord / Math.max(near, GAP_SPACING_PX)) - 1);
|
|
681
|
+
const points = [];
|
|
682
|
+
for (let k = 1; k <= m; k++) {
|
|
683
|
+
const u = k / (m + 1), u2 = u * u, u3 = u2 * u;
|
|
684
|
+
const h00 = 2 * u3 - 3 * u2 + 1, h10 = (u3 - 2 * u2 + u) * chord, h01 = 3 * u2 - 2 * u3, h11 = (u3 - u2) * chord;
|
|
685
|
+
points.push({
|
|
686
|
+
x: h00 * a.x + h10 * ta.x + h01 * b.x + h11 * tb.x, y: h00 * a.y + h10 * ta.y + h01 * b.y + h11 * tb.y,
|
|
687
|
+
t: a.t + (b.t - a.t) * u, pause: 0,
|
|
688
|
+
w: a.w + (b.w - a.w) * u, h: a.h + (b.h - a.h) * u, peak: a.peak + (b.peak - a.peak) * u,
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
return points;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// The points filling the span into samples[i] when it is a stretch the browser lost
|
|
695
|
+
// samples over (GAP_MIN_PX, and GAP_RATIO times the chords on both sides): drawn as one
|
|
696
|
+
// chord, the spline would put a whole turn at each end of it. Times and sizes run evenly
|
|
697
|
+
// across the fill as they would across the bare span, so only positions change. A span
|
|
698
|
+
// ending in a pause passes the same test only once a chord ahead of it is known: a start
|
|
699
|
+
// from rest has a first chord short beside the next and fails it, while a stall that lost
|
|
700
|
+
// samples mid-travel is a long chord between short ones. The result is kept on the
|
|
701
|
+
// sample once the direction ahead is read off samples that can no longer change; from
|
|
702
|
+
// then on the fill never reshapes as the window drops the samples behind it.
|
|
703
|
+
function fillAt(i) {
|
|
704
|
+
const b = samples[i];
|
|
705
|
+
if (b.fill !== undefined) return b.fill;
|
|
706
|
+
const a = samples[i - 1], newest = samples.length - 1;
|
|
707
|
+
const chord = Math.hypot(b.x - a.x, b.y - a.y);
|
|
708
|
+
if (chord < GAP_MIN_PX) {
|
|
709
|
+
if (i < newest) b.fill = null;
|
|
710
|
+
return null;
|
|
711
|
+
}
|
|
712
|
+
const ahead = chordAt(i, 1);
|
|
713
|
+
if (b.pause > 0 && !ahead) return null;
|
|
714
|
+
const near = Math.max(chordAt(i - 1, -1), ahead);
|
|
715
|
+
const from = travelAt(i - 1, -1), to = travelAt(i, 1);
|
|
716
|
+
const lost = near > 0 && chord > GAP_RATIO * near && (from || to);
|
|
717
|
+
const points = lost ? hermite(a, b, chord, from, to, near) : null;
|
|
718
|
+
if (to && to.at < newest) b.fill = points;
|
|
719
|
+
return points;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// the samples with their fills, then the pointer now. A filled span is taken for travel
|
|
723
|
+
// whose samples were lost rather than a stop, so the pause at its far end ends no run of
|
|
724
|
+
// travel: the tube runs through the fill unpinched
|
|
725
|
+
function pathPoints(now) {
|
|
726
|
+
const pts = [];
|
|
727
|
+
for (let i = 0; i < samples.length; i++) {
|
|
728
|
+
const fill = i ? fillAt(i) : null;
|
|
729
|
+
if (fill) pts.push(...fill);
|
|
730
|
+
pts.push(fill && samples[i].pause > 0 ? { ...samples[i], pause: 0 } : samples[i]);
|
|
731
|
+
}
|
|
732
|
+
pts.push({ x: pointer.x, y: pointer.y, t: now, pause: 0, ...record() });
|
|
733
|
+
return pts;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// the grid's instants from t0 to now: inside each span, evenly in time, as many as its
|
|
737
|
+
// chord asks for at ARC_PX and its time at STEP_MS, carried across spans so a span asking
|
|
738
|
+
// for less than one point shares one with the next
|
|
739
|
+
function layGrid(pts, t0, now) {
|
|
740
|
+
const wanted = (a, b) => {
|
|
741
|
+
const lo = Math.max(a.t, t0), hi = Math.min(b.t, now);
|
|
742
|
+
if (hi <= lo) return 0;
|
|
743
|
+
const share = (hi - lo) / (b.t - a.t);
|
|
744
|
+
return Math.max(Math.hypot(b.x - a.x, b.y - a.y) * share / ARC_PX, (hi - lo) / STEP_MS);
|
|
745
|
+
};
|
|
746
|
+
let total = 0;
|
|
747
|
+
for (let i = 1; i < pts.length; i++) total += wanted(pts[i - 1], pts[i]);
|
|
748
|
+
const scale = Math.min(1, (MAX_STEPS - 1) / total);
|
|
749
|
+
let count = 0, laid = 0;
|
|
750
|
+
gridT[0] = t0;
|
|
751
|
+
for (let i = 1; i < pts.length; i++) {
|
|
752
|
+
const a = pts[i - 1], b = pts[i];
|
|
753
|
+
const want = wanted(a, b) * scale;
|
|
754
|
+
if (want <= 0) continue;
|
|
755
|
+
const lo = Math.max(a.t, t0), hi = Math.min(b.t, now);
|
|
756
|
+
for (let k = Math.floor(laid) + 1; k < laid + want; k++) gridT[++count] = lo + (hi - lo) * (k - laid) / want;
|
|
757
|
+
laid += want;
|
|
758
|
+
}
|
|
759
|
+
gridT[++count] = now;
|
|
760
|
+
return count;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/* The centre line. The path is a uniform cubic B-spline over the samples: its bend is
|
|
764
|
+
continuous along the whole path, which passes a third of the way from each sample
|
|
765
|
+
toward the midpoint of its two neighbours, so a fast stroke's sparse samples draw one
|
|
766
|
+
curve rather than straight runs joined at bends. Each span runs over the time between
|
|
767
|
+
its two samples, so ages stay true to when the pointer was there, and over a run of
|
|
768
|
+
samples at one position the path holds still for as long as the run lasts. A stretch
|
|
769
|
+
where the browser lost samples is filled first (fillAt), so its spacing changes
|
|
770
|
+
gradually and the spline has no turn to pile up at either end. The grid lays a point at
|
|
771
|
+
least every ARC_PX of each span's chord and every STEP_MS of its time (layGrid), so a
|
|
772
|
+
fast stretch draws in short chords and a jump that took a fraction of a millisecond
|
|
773
|
+
still gets its points. The positions and the recorded sizes then pass through one
|
|
774
|
+
low-pass along the path (smoothAlongPath), and the tube's widths are laid on the result
|
|
775
|
+
(layWidths). A new sample reshapes the last two spans and, through the low-pass, the
|
|
776
|
+
line and its width up to 3 x SMOOTH_PX of path behind them, and an unsettled fill
|
|
777
|
+
(fillAt) across its whole gap; the tail's last 3 x SMOOTH_PX moves as the window
|
|
778
|
+
drains it. */
|
|
779
|
+
function buildGrid(now, win) {
|
|
780
|
+
const pts = pathPoints(now);
|
|
781
|
+
const n = pts.length;
|
|
782
|
+
const t0 = Math.max(pts[0].t, now - win);
|
|
783
|
+
if (n < 2 || now <= t0) return 0;
|
|
784
|
+
// the still time the pointer has spent since each sample, summed from the head back
|
|
785
|
+
pauseAfter.length = n;
|
|
786
|
+
pauseAfter[n - 1] = 0;
|
|
787
|
+
for (let i = n - 2; i >= 0; i--) pauseAfter[i] = pauseAfter[i + 1] + pts[i + 1].pause;
|
|
788
|
+
knots.length = 0;
|
|
789
|
+
for (let i = 0; i < n; i++) if (!i || pts[i].x !== pts[i - 1].x || pts[i].y !== pts[i - 1].y) knots.push(i);
|
|
790
|
+
const lastKnot = knots.length - 1;
|
|
791
|
+
// span k's four control points: the first point of each run at one position. Before the
|
|
792
|
+
// first stands `behind`, so a sample leaving the window never reshapes what is still
|
|
793
|
+
// drawn; with none dropped yet, the mirror of the second, which starts the curve on the
|
|
794
|
+
// first sample. Past the head stands the mirror of the one before it, which ends the
|
|
795
|
+
// curve on the head along the last chord with no bend
|
|
796
|
+
const loadSpan = k => {
|
|
797
|
+
for (let c = 0; c < 4; c++) {
|
|
798
|
+
const i = k - 1 + c;
|
|
799
|
+
if (i < 0 || i > lastKnot) continue;
|
|
800
|
+
const p = pts[knots[i]];
|
|
801
|
+
spanX[c] = p.x; spanY[c] = p.y;
|
|
802
|
+
}
|
|
803
|
+
if (k === 0) {
|
|
804
|
+
spanX[0] = behind ? behind.x : 2 * spanX[1] - spanX[2];
|
|
805
|
+
spanY[0] = behind ? behind.y : 2 * spanY[1] - spanY[2];
|
|
806
|
+
}
|
|
807
|
+
if (k + 1 === lastKnot) { spanX[3] = 2 * spanX[2] - spanX[1]; spanY[3] = 2 * spanY[2] - spanY[1]; }
|
|
808
|
+
};
|
|
809
|
+
const count = layGrid(pts, t0, now);
|
|
810
|
+
let k = 0, loaded = -1, b = 0;
|
|
811
|
+
for (let j = 0; j <= count; j++) {
|
|
812
|
+
const t = gridT[j];
|
|
813
|
+
while (k < lastKnot && t >= pts[knots[k + 1]].t) k++;
|
|
814
|
+
let x, y;
|
|
815
|
+
if (k === lastKnot) {
|
|
816
|
+
x = pts[knots[k]].x; y = pts[knots[k]].y;
|
|
817
|
+
} else {
|
|
818
|
+
if (k !== loaded) { loaded = k; loadSpan(k); }
|
|
819
|
+
const leave = pts[knots[k + 1] - 1].t, arrive = pts[knots[k + 1]].t;
|
|
820
|
+
const u = t > leave ? (t - leave) / (arrive - leave) : 0;
|
|
821
|
+
const v = 1 - u, u2 = u * u, u3 = u2 * u;
|
|
822
|
+
const b0 = v * v * v / 6, b1 = (3 * u3 - 6 * u2 + 4) / 6, b2 = (-3 * u3 + 3 * u2 + 3 * u + 1) / 6, b3 = u3 / 6;
|
|
823
|
+
x = b0 * spanX[0] + b1 * spanX[1] + b2 * spanX[2] + b3 * spanX[3];
|
|
824
|
+
y = b0 * spanY[0] + b1 * spanY[1] + b2 * spanY[2] + b3 * spanY[3];
|
|
825
|
+
}
|
|
826
|
+
gridX[j] = x; gridY[j] = y; gridAge[j] = now - t;
|
|
827
|
+
// the recorded sizes ride the samples that bracket this instant
|
|
828
|
+
while (b < n - 2 && t > pts[b + 1].t) b++;
|
|
829
|
+
const s0 = pts[b], s1 = pts[b + 1];
|
|
830
|
+
const f = s1.t > s0.t ? Math.min(1, Math.max(0, (t - s0.t) / (s1.t - s0.t))) : 1;
|
|
831
|
+
gridW[j] = s0.w + (s1.w - s0.w) * f;
|
|
832
|
+
gridH[j] = s0.h + (s1.h - s0.h) * f;
|
|
833
|
+
gridPeak[j] = s0.peak + (s1.peak - s0.peak) * f;
|
|
834
|
+
gridPause[j] = pauseAfter[b + 1] + Math.min(s1.pause, s1.t - t);
|
|
835
|
+
}
|
|
836
|
+
smoothAlongPath(count);
|
|
837
|
+
// each grid point's direction, read over a chord of at least TANGENT_PX around it, so a
|
|
838
|
+
// slow pointer's sub-pixel jitter cannot swing the width the recorded sizes give it
|
|
839
|
+
let tx = headX, ty = headY;
|
|
840
|
+
for (let j = 0; j <= count; j++) {
|
|
841
|
+
let a = j, c = j, dx = 0, dy = 0, len = 0;
|
|
842
|
+
while (len < TANGENT_PX && (a > 0 || c < count) && c - a < 16) {
|
|
843
|
+
a = Math.max(0, a - 1); c = Math.min(count, c + 1);
|
|
844
|
+
dx = gridX[c] - gridX[a]; dy = gridY[c] - gridY[a]; len = Math.hypot(dx, dy);
|
|
845
|
+
}
|
|
846
|
+
if (len >= TANGENT_PX / 2) { tx = dx / len; ty = dy / len; }
|
|
847
|
+
gridTx[j] = tx; gridTy[j] = ty;
|
|
848
|
+
}
|
|
849
|
+
readHeadDirection(count);
|
|
850
|
+
gridDist[count] = 0;
|
|
851
|
+
for (let j = count - 1; j >= 0; j--) gridDist[j] = gridDist[j + 1] + Math.hypot(gridX[j + 1] - gridX[j], gridY[j + 1] - gridY[j]);
|
|
852
|
+
// a run of travel ends where the pointer stopped (a pause was recorded): the path
|
|
853
|
+
// before that stop is a drained tube of its own, and the run's tail is the path since
|
|
854
|
+
let stop = count;
|
|
855
|
+
for (let j = count; j >= 0; j--) {
|
|
856
|
+
while (stop >= 0 && gridPause[stop] <= gridPause[j] + 1e-3) stop--;
|
|
857
|
+
gridEnd[j] = stop >= 0 ? gridDist[stop] : gridDist[0];
|
|
858
|
+
}
|
|
859
|
+
layWidths(count);
|
|
860
|
+
return count;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// the head's direction, read over HEAD_TANGENT_PX of path behind it: the hole's edge and
|
|
864
|
+
// the window's floor are the mark's extent along it, so a slow hand's per-sample wobble,
|
|
865
|
+
// which the low-pass leaves at the head, must not swing it. Past half the reach, it stops
|
|
866
|
+
// where the path turned back on itself (a point whose own direction opposes the chord
|
|
867
|
+
// read so far), so once the new leg is half the reach long a hairpin's old leg no longer
|
|
868
|
+
// pulls the head's direction off it; the first half is never cut, since a slow hand's
|
|
869
|
+
// wobble there turns single points sideways
|
|
870
|
+
function readHeadDirection(count) {
|
|
871
|
+
let a = count, dx = 0, dy = 0, len = 0;
|
|
872
|
+
const turnsBack = j => len >= HEAD_TANGENT_PX / 2 && gridTx[j] * dx + gridTy[j] * dy < 0;
|
|
873
|
+
while (len < HEAD_TANGENT_PX && a > 0 && !turnsBack(a - 1)) {
|
|
874
|
+
a--;
|
|
875
|
+
dx = gridX[count] - gridX[a]; dy = gridY[count] - gridY[a]; len = Math.hypot(dx, dy);
|
|
876
|
+
}
|
|
877
|
+
if (len >= TANGENT_PX / 2) { gridTx[count] = dx / len; gridTy[count] = dy / len; }
|
|
878
|
+
headX = gridTx[count]; headY = gridTy[count];
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// the tube's half width at each grid point: the recorded half sizes across the path, and
|
|
882
|
+
// from the head back to where the line leaves the hole the start, so the visible tube
|
|
883
|
+
// leaves the ring at the ring's width less the trim even where the jelly lags a turn or
|
|
884
|
+
// the line doubles back inside the ring at a hairpin. The start is the mark's extent
|
|
885
|
+
// across the tube where it leaves the hole, less half the trim (startWidth), read on the
|
|
886
|
+
// first frame that draws the newest sample and kept on it: a tube draining after a stop
|
|
887
|
+
// or a stall holds the width the hand left it with while the jelly relaxes or the ring
|
|
888
|
+
// grows over a target. Past that point the width eases to the recorded one over
|
|
889
|
+
// START_EASE_PX along a smoothstep, so it has no kink where the two meet, and the narrow
|
|
890
|
+
// axis behind keeps a turn from swelling the tube
|
|
891
|
+
function layWidths(count) {
|
|
892
|
+
const edge = holeEdge(), out = holeExit(count);
|
|
893
|
+
const leaves = Math.max(edge, gridDist[out]);
|
|
894
|
+
const newest = samples[samples.length - 1];
|
|
895
|
+
if (newest.start === undefined) newest.start = startWidth(count, edge, out);
|
|
896
|
+
const start = newest.start;
|
|
897
|
+
for (let j = 0; j <= count; j++) {
|
|
898
|
+
const recorded = Math.hypot(gridW[j] * gridTy[j], gridH[j] * gridTx[j]);
|
|
899
|
+
const u = Math.min(1, Math.max(0, (gridDist[j] - leaves) / START_EASE_PX));
|
|
900
|
+
gridAcross[j] = recorded + (start - recorded) * (1 - u * u * (3 - 2 * u));
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// where the line, back from the head, first leaves the hole (outsideHole): the index of the
|
|
905
|
+
// first grid point outside it, so the start reaches every point that could show past the
|
|
906
|
+
// ring; the line's far end when it never leaves
|
|
907
|
+
function holeExit(count) {
|
|
908
|
+
for (let j = count - 1; j >= 0; j--) if (outsideHole(gridX[j], gridY[j])) return j;
|
|
909
|
+
return 0;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// the start: the mark's half extent, less half the trim, across the direction the tube
|
|
913
|
+
// takes where it leaves the hole, at grid point `out`. A line leaving at the hole's edge
|
|
914
|
+
// runs on from the head, and its own direction there bends with whatever turn the low-pass
|
|
915
|
+
// rounds just outside the hole, so the start reads across the head's direction; a line
|
|
916
|
+
// leaving past the edge has turned inside the hole, and the start reads across the line
|
|
917
|
+
// where it leaves, once that turn clears the hole. The line's read takes over along a
|
|
918
|
+
// smoothstep as the exit lies up to START_RAMP_PX past the edge and as the turn clears the
|
|
919
|
+
// hole by up to START_RAMP_PX. The exit is a grid point, so a drifting exit still moves the
|
|
920
|
+
// width a grid step at a time, and a turn dipping back inside the hole moves the exit to
|
|
921
|
+
// the leg beyond it at once, which can switch the start from the head's read to that leg's
|
|
922
|
+
function startWidth(count, edge, out) {
|
|
923
|
+
const past = Math.min(1, Math.max(0, (gridDist[out] - edge) / START_RAMP_PX));
|
|
924
|
+
const head = trimmedAcross(-headY, headX);
|
|
925
|
+
if (past === 0) return head;
|
|
926
|
+
const u = past * Math.min(1, clearance(out) / START_RAMP_PX);
|
|
927
|
+
return head + (startAcross(count, out) - head) * u * u * (3 - 2 * u);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// how far the line clears the hole past its exit at grid point `out`: the farthest it lies
|
|
931
|
+
// outside the hole while it stays outside, over the START_EASE_PX of path the start eases
|
|
932
|
+
// across. The scan stops at the first point START_RAMP_PX or more out and returns its
|
|
933
|
+
// distance, so the result can pass START_RAMP_PX; the caller clamps it
|
|
934
|
+
function clearance(out) {
|
|
935
|
+
let most = 0;
|
|
936
|
+
for (let k = out; k >= 0 && gridDist[k] - gridDist[out] <= START_EASE_PX && most < START_RAMP_PX; k--) {
|
|
937
|
+
const gap = outlineDistance(gridX[k] - pointer.x, gridY[k] - pointer.y) - HOLE_PX;
|
|
938
|
+
if (gap <= 0) break;
|
|
939
|
+
most = Math.max(most, gap);
|
|
940
|
+
}
|
|
941
|
+
return most;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// the start across the line where it leaves the hole, its direction read over
|
|
945
|
+
// EXIT_TANGENT_PX of path each side of grid point `out`: at a hairpin the line curves
|
|
946
|
+
// within a few px of that point, and a longer read takes in the turn
|
|
947
|
+
function startAcross(count, out) {
|
|
948
|
+
let a = out, c = Math.min(count, out + 1);
|
|
949
|
+
while (a > 0 && gridDist[a] - gridDist[out] < EXIT_TANGENT_PX) a--;
|
|
950
|
+
while (c < count && gridDist[out] - gridDist[c] < EXIT_TANGENT_PX) c++;
|
|
951
|
+
const dx = gridX[c] - gridX[a], dy = gridY[c] - gridY[a], len = Math.hypot(dx, dy);
|
|
952
|
+
return len > 1e-6 ? trimmedAcross(-dy / len, dx / len) : trimmedAcross(-headY, headX);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// the mark's half extent along a direction less half the trim, floored at a pixel so the
|
|
956
|
+
// caret's line moving down stays a line
|
|
957
|
+
const trimmedAcross = (nx, ny) => Math.max(1, support(nx, ny) - TRIM_PX / 2);
|
|
958
|
+
|
|
959
|
+
const fit = (a, n) => a.length >= n ? a : new Float64Array(Math.max(n, 2 * a.length));
|
|
960
|
+
|
|
961
|
+
// A Gaussian over the centre line and the half sizes by arc length, sigma SMOOTH_PX,
|
|
962
|
+
// narrowed to a third of the path left to the head and to the tail, so both ends stay
|
|
963
|
+
// where they are: the head stays on the pointer at the mark's own trimmed size while a
|
|
964
|
+
// slow stroke's per-sample wobble, which the spline passes a good share of, averages out,
|
|
965
|
+
// and a burst after a stall widens or narrows the tube over the arc the line bends over,
|
|
966
|
+
// not over the few px between its samples. The filter runs on an even resample of the
|
|
967
|
+
// path, read back at each grid point's own arc length through a cubic: a linear read-back
|
|
968
|
+
// would bend the line only at the resample points.
|
|
969
|
+
function smoothAlongPath(count) {
|
|
970
|
+
gridArc[0] = 0;
|
|
971
|
+
for (let j = 1; j <= count; j++) gridArc[j] = gridArc[j - 1] + Math.hypot(gridX[j] - gridX[j - 1], gridY[j] - gridY[j - 1]);
|
|
972
|
+
const n = Math.ceil(gridArc[count] / RESAMPLE_PX) + 1;
|
|
973
|
+
if (n < 3) return;
|
|
974
|
+
const h = gridArc[count] / (n - 1);
|
|
975
|
+
evenX = fit(evenX, n); evenY = fit(evenY, n); evenW = fit(evenW, n); evenH = fit(evenH, n);
|
|
976
|
+
lowX = fit(lowX, n); lowY = fit(lowY, n); lowW = fit(lowW, n); lowH = fit(lowH, n);
|
|
977
|
+
resampleEvenly(count, n, h);
|
|
978
|
+
lowPass(n, h);
|
|
979
|
+
readBack(count, n, h);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function resampleEvenly(count, n, h) {
|
|
983
|
+
let seg = 0;
|
|
984
|
+
for (let k = 0; k < n; k++) {
|
|
985
|
+
const at = k === n - 1 ? gridArc[count] : k * h;
|
|
986
|
+
while (seg < count - 1 && gridArc[seg + 1] < at) seg++;
|
|
987
|
+
const len = gridArc[seg + 1] - gridArc[seg], u = len > 0 ? Math.min(1, (at - gridArc[seg]) / len) : 1;
|
|
988
|
+
evenX[k] = gridX[seg] + (gridX[seg + 1] - gridX[seg]) * u;
|
|
989
|
+
evenY[k] = gridY[seg] + (gridY[seg + 1] - gridY[seg]) * u;
|
|
990
|
+
evenW[k] = gridW[seg] + (gridW[seg + 1] - gridW[seg]) * u;
|
|
991
|
+
evenH[k] = gridH[seg] + (gridH[seg + 1] - gridH[seg]) * u;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function lowPass(n, h) {
|
|
996
|
+
const reach = Math.floor(3 * SMOOTH_PX / h);
|
|
997
|
+
for (let q = 0; q <= reach; q++) kernel[q] = Math.exp(-0.5 * (q * h / SMOOTH_PX) ** 2);
|
|
998
|
+
for (let k = 0; k < n; k++) {
|
|
999
|
+
const sigma = Math.min(SMOOTH_PX, (n - 1 - k) * h / 3, k * h / 3);
|
|
1000
|
+
if (sigma < h / 2) { lowX[k] = evenX[k]; lowY[k] = evenY[k]; lowW[k] = evenW[k]; lowH[k] = evenH[k]; continue; }
|
|
1001
|
+
const full = sigma === SMOOTH_PX;
|
|
1002
|
+
const taps = full ? reach : Math.floor(3 * sigma / h), c = -0.5 * (h / sigma) ** 2;
|
|
1003
|
+
let sx = evenX[k], sy = evenY[k], sw = evenW[k], sh = evenH[k], total = 1;
|
|
1004
|
+
for (let q = 1; q <= taps; q++) {
|
|
1005
|
+
const weight = full ? kernel[q] : Math.exp(c * q * q);
|
|
1006
|
+
sx += weight * (evenX[k - q] + evenX[k + q]); sy += weight * (evenY[k - q] + evenY[k + q]);
|
|
1007
|
+
sw += weight * (evenW[k - q] + evenW[k + q]); sh += weight * (evenH[k - q] + evenH[k + q]);
|
|
1008
|
+
total += 2 * weight;
|
|
1009
|
+
}
|
|
1010
|
+
lowX[k] = sx / total; lowY[k] = sy / total; lowW[k] = sw / total; lowH[k] = sh / total;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// Catmull-Rom through the filtered resample, at each grid point's arc length
|
|
1015
|
+
function readBack(count, n, h) {
|
|
1016
|
+
for (let j = 0; j <= count; j++) {
|
|
1017
|
+
const at = Math.min(n - 1, gridArc[j] / h), k1 = Math.min(n - 2, Math.floor(at)), g = at - k1;
|
|
1018
|
+
const k0 = Math.max(0, k1 - 1), k2 = k1 + 1, k3 = Math.min(n - 1, k1 + 2);
|
|
1019
|
+
const g2 = g * g, g3 = g2 * g;
|
|
1020
|
+
const c0 = -0.5 * g3 + g2 - 0.5 * g, c1 = 1.5 * g3 - 2.5 * g2 + 1, c2 = -1.5 * g3 + 2 * g2 + 0.5 * g, c3 = 0.5 * g3 - 0.5 * g2;
|
|
1021
|
+
gridX[j] = c0 * lowX[k0] + c1 * lowX[k1] + c2 * lowX[k2] + c3 * lowX[k3];
|
|
1022
|
+
gridY[j] = c0 * lowY[k0] + c1 * lowY[k1] + c2 * lowY[k2] + c3 * lowY[k3];
|
|
1023
|
+
gridW[j] = c0 * lowW[k0] + c1 * lowW[k1] + c2 * lowW[k2] + c3 * lowW[k3];
|
|
1024
|
+
gridH[j] = c0 * lowH[k0] + c1 * lowH[k1] + c2 * lowH[k2] + c3 * lowH[k3];
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// the path where the trail's front edge sits, `edge` px behind the head: its age, and
|
|
1029
|
+
// that age less the time the pointer has stood still since it was laid (past DRAIN_MS,
|
|
1030
|
+
// the pauses recorded on the samples plus the current one). The taper's clock starts
|
|
1031
|
+
// at the second, so travel time alone never dims the trail at the hole however slowly
|
|
1032
|
+
// it goes, a stop drains the tube instead of holding it until the window empties, and a
|
|
1033
|
+
// move after a pause keeps the old path at the level it drained to rather than
|
|
1034
|
+
// restarting its clock
|
|
1035
|
+
function edgeAge(count, edge, still) {
|
|
1036
|
+
let j = count;
|
|
1037
|
+
while (j > 0 && gridDist[j] < edge) j--;
|
|
1038
|
+
const span = gridDist[j] - gridDist[j + 1];
|
|
1039
|
+
const u = span > 0 ? (edge - gridDist[j + 1]) / span : 1;
|
|
1040
|
+
const age = gridAge[j + 1] + (gridAge[j] - gridAge[j + 1]) * Math.min(1, u);
|
|
1041
|
+
return { age, age0: Math.max(0, age - gridPause[j] - Math.max(0, still - DRAIN_MS)) };
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const grow = (b, x, y, r) => {
|
|
1045
|
+
if (!b) return [x - r, y - r, x + r, y + r];
|
|
1046
|
+
b[0] = Math.min(b[0], x - r); b[1] = Math.min(b[1], y - r);
|
|
1047
|
+
b[2] = Math.max(b[2], x + r); b[3] = Math.max(b[3], y + r);
|
|
1048
|
+
return b;
|
|
1049
|
+
};
|
|
1050
|
+
|
|
1051
|
+
// the jelly's stretch along its axis, `a`; it squeezes to 1/a across it
|
|
1052
|
+
function jellyStretch(roundness) {
|
|
1053
|
+
return 1 + Math.min(JELLY_MAX, Math.hypot(jp.x, jq.x)) * roundness;
|
|
1054
|
+
}
|
|
1055
|
+
// the jelly's axis, as an angle
|
|
1056
|
+
const jellyAngle = () => Math.atan2(jq.x, jp.x) / 2;
|
|
1057
|
+
// the jelly as a symmetric tensor: stretch `a` along the axis, 1/a across it
|
|
1058
|
+
function jellyTensor(roundness) {
|
|
1059
|
+
const phi = jellyAngle();
|
|
1060
|
+
const a = jellyStretch(roundness), b = 1 / a;
|
|
1061
|
+
const c = Math.cos(phi), s = Math.sin(phi);
|
|
1062
|
+
return [a * c * c + b * s * s, (a - b) * c * s, a * s * s + b * c * c];
|
|
1063
|
+
}
|
|
1064
|
+
const shapeRoundness = () => Math.min(hw.x, hh.x) / Math.max(hw.x, hh.x, 1e-3);
|
|
1065
|
+
// the jelly's matrix, centred on the pointer, in device pixels
|
|
1066
|
+
function jellyMatrix(x, y, T) {
|
|
1067
|
+
return [T[0] * dpr, T[1] * dpr, T[1] * dpr, T[2] * dpr, x * dpr, y * dpr];
|
|
1068
|
+
}
|
|
1069
|
+
const shape = (w, h, inset) => {
|
|
1070
|
+
const iw = Math.max(0, w - inset), ih = Math.max(0, h - inset);
|
|
1071
|
+
ctx.beginPath();
|
|
1072
|
+
ctx.roundRect(-iw, -ih, 2 * iw, 2 * ih, Math.min(iw, ih));
|
|
1073
|
+
};
|
|
1074
|
+
// the live mark's half extent along a direction: the support function of the drawn
|
|
1075
|
+
// rounded rect (corner radius = its smaller half size) under its jelly tensor
|
|
1076
|
+
function support(vx, vy) {
|
|
1077
|
+
const T = jellyTensor(shapeRoundness());
|
|
1078
|
+
const mx = T[0] * vx + T[1] * vy, my = T[1] * vx + T[2] * vy;
|
|
1079
|
+
const rho = Math.min(hw.x, hh.x);
|
|
1080
|
+
return (hw.x - rho) * Math.abs(mx) + (hh.x - rho) * Math.abs(my) + rho * Math.hypot(mx, my);
|
|
1081
|
+
}
|
|
1082
|
+
// the hole's reach from the pointer along a direction: the support of the mark's outline
|
|
1083
|
+
// grown by HOLE_PX, which equals the outline's radius on its axes and overstates it
|
|
1084
|
+
// between them
|
|
1085
|
+
const holeReach = (vx, vy) => support(vx, vy) + HOLE_PX;
|
|
1086
|
+
// how far behind the head the trail's front edge sits
|
|
1087
|
+
const holeEdge = () => holeReach(headX, headY);
|
|
1088
|
+
// whether a point lies outside the hole: farther than HOLE_PX from the mark's drawn
|
|
1089
|
+
// outline. Within the jelly's narrow half size plus HOLE_PX of the pointer it is inside,
|
|
1090
|
+
// and past the hole's reach toward it outside; between the two the distance is solved
|
|
1091
|
+
function outsideHole(x, y) {
|
|
1092
|
+
const dx = x - pointer.x, dy = y - pointer.y, d = Math.hypot(dx, dy);
|
|
1093
|
+
if (d <= Math.min(hw.x, hh.x) / jellyStretch(shapeRoundness()) + HOLE_PX) return false;
|
|
1094
|
+
if (d > holeReach(dx / d, dy / d)) return true;
|
|
1095
|
+
return outlineDistance(dx, dy) > HOLE_PX;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// the distance from a point, given from the pointer, to the mark's drawn outline (zero
|
|
1099
|
+
// inside). The outline is the jelly's image of a rounded rect whose corner radius is its
|
|
1100
|
+
// smaller half size: an ellipse swept along a segment, none when the mark is round, so the
|
|
1101
|
+
// distance is the least distance to that ellipse over the sweep, convex along the segment
|
|
1102
|
+
function outlineDistance(dx, dy) {
|
|
1103
|
+
// the drawn half sizes: a spring overshooting toward the caret can pass zero
|
|
1104
|
+
const w = Math.max(1e-3, hw.x), h = Math.max(1e-3, hh.x);
|
|
1105
|
+
const rho = Math.min(w, h), a = jellyStretch(shapeRoundness());
|
|
1106
|
+
const phi = jellyAngle(), c = Math.cos(phi), s = Math.sin(phi);
|
|
1107
|
+
const lx = w - rho, ly = h - rho;
|
|
1108
|
+
// the point and the sweep's half length along the jelly's axis and across it
|
|
1109
|
+
const pu = dx * c + dy * s, pv = dy * c - dx * s;
|
|
1110
|
+
const su = a * (lx * c + ly * s), sv = (ly * c - lx * s) / a;
|
|
1111
|
+
const gap = t => ellipseGap(rho * a, rho / a, Math.abs(pu - t * su), Math.abs(pv - t * sv));
|
|
1112
|
+
return su || sv ? convexMin(gap) : gap(0);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// the distance from a point to a filled ellipse with semi-axes e0, e1 > 0, the point
|
|
1116
|
+
// given in the ellipse's first quadrant: zero inside, else the root of a function of one
|
|
1117
|
+
// variable that falls monotonically, bisected (Eberly, "Distance from a Point to an
|
|
1118
|
+
// Ellipse, an Ellipsoid, or a Hyperellipsoid"). The bisection's bracket holds only with
|
|
1119
|
+
// the major axis first, so swapped axes are mirrored across the diagonal, point included
|
|
1120
|
+
function ellipseGap(e0, e1, y0, y1) {
|
|
1121
|
+
if (e0 < e1) return ellipseGap(e1, e0, y1, y0);
|
|
1122
|
+
const z0 = y0 / e0, z1 = y1 / e1;
|
|
1123
|
+
if (z0 * z0 + z1 * z1 <= 1) return 0;
|
|
1124
|
+
const r = (e0 / e1) ** 2, n = r * z0;
|
|
1125
|
+
let lo = z1 - 1, hi = Math.hypot(n, z1) - 1, s = lo;
|
|
1126
|
+
for (let i = 0; i < 64; i++) {
|
|
1127
|
+
s = (lo + hi) / 2;
|
|
1128
|
+
if (s === lo || s === hi) break;
|
|
1129
|
+
const q0 = n / (s + r), q1 = z1 / (s + 1);
|
|
1130
|
+
if (q0 * q0 + q1 * q1 > 1) lo = s; else hi = s;
|
|
1131
|
+
}
|
|
1132
|
+
return Math.hypot(r * y0 / (s + r) - y0, y1 / (s + 1) - y1);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// the least value of a convex function over [-1, 1], by golden-section search to 1e-6
|
|
1136
|
+
function convexMin(f) {
|
|
1137
|
+
const g = (Math.sqrt(5) - 1) / 2;
|
|
1138
|
+
let lo = -1, hi = 1, p = hi - g * (hi - lo), q = lo + g * (hi - lo), fp = f(p), fq = f(q);
|
|
1139
|
+
while (hi - lo > 1e-6) {
|
|
1140
|
+
if (fp <= fq) { hi = q; q = p; fq = fp; p = hi - g * (hi - lo); fp = f(p); }
|
|
1141
|
+
else { lo = p; p = q; fp = fq; q = lo + g * (hi - lo); fq = f(q); }
|
|
1142
|
+
}
|
|
1143
|
+
return Math.min(fp, fq);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// the net clock: sample time less the stillness past DRAIN_MS. Its head runs DRAIN_MS past
|
|
1147
|
+
// the newest sample and stops there, so a longer stop neither stretches the window nor drops
|
|
1148
|
+
// the samples the tube is still draining from, and the first move after it finds the window
|
|
1149
|
+
// as it was
|
|
1150
|
+
const netHead = now => {
|
|
1151
|
+
const newest = samples[samples.length - 1];
|
|
1152
|
+
return newest.net + Math.min(now - newest.t, DRAIN_MS);
|
|
1153
|
+
};
|
|
1154
|
+
// a pointer still for DRAIN_MS plus the window has drained the tube: its stroke is over
|
|
1155
|
+
const strokeOver = (t, win) => t - samples[samples.length - 1].t > DRAIN_MS + win;
|
|
1156
|
+
|
|
1157
|
+
// how far back the window reaches now, on the net clock: TAIL_MS, or the time it takes the
|
|
1158
|
+
// path to reach the mark's larger half size past the hole when that is longer, CRAWL_MS at
|
|
1159
|
+
// most. The reach ends inside the segment that completes it, so a flick that settles
|
|
1160
|
+
// into a crawl keeps only that much of its path past TAIL_MS, never its whole length.
|
|
1161
|
+
// The span leaves the pauses out, so the sample that ends a pause does not lengthen the
|
|
1162
|
+
// window by it and swell the tube that was draining
|
|
1163
|
+
function windowMs(now) {
|
|
1164
|
+
const floor = holeEdge() + Math.max(hw.x, hh.x);
|
|
1165
|
+
let d = 0, net = samples[samples.length - 1].net;
|
|
1166
|
+
for (let i = samples.length - 1; i > 0 && d < floor; i--) {
|
|
1167
|
+
const seg = Math.hypot(samples[i].x - samples[i - 1].x, samples[i].y - samples[i - 1].y);
|
|
1168
|
+
const need = floor - d;
|
|
1169
|
+
net = seg > need ? samples[i].net - (samples[i].net - samples[i - 1].net) * need / seg : samples[i - 1].net;
|
|
1170
|
+
d += seg;
|
|
1171
|
+
}
|
|
1172
|
+
return Math.min(CRAWL_MS, Math.max(TAIL_MS, netHead(now) - net));
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// the tube: soft discs stamped along the grid, spaced by distance so the density,
|
|
1176
|
+
// and with it the alpha, holds at every speed, each stamp's alpha solved from the
|
|
1177
|
+
// overlap. A stamp's radius is the grid's half width there (layWidths: the start where
|
|
1178
|
+
// the line leaves the hole, the ring's width less the trim as the hand last moved,
|
|
1179
|
+
// easing to the recorded half sizes read across the path; the caret's trimmed half
|
|
1180
|
+
// height moving sideways, its own half width moving down), so over text the trail is a
|
|
1181
|
+
// line downward and a caret-tall tube sideways, and a turn keeps the past its width. The
|
|
1182
|
+
// stamping runs up to the pointer; the hole cut afterwards removes the mark's own share.
|
|
1183
|
+
// Two caps keep the tail from showing more than the path it has. A stamp is never
|
|
1184
|
+
// brighter than the front edge, so the part of the path that stood hidden in the hole
|
|
1185
|
+
// at a stop cannot come out brighter than the tube that drained beside it. And a
|
|
1186
|
+
// stamp's radius is held to the path left behind it in its run of travel (gridEnd) and,
|
|
1187
|
+
// inside the hole, to the hypotenuse of its depth in the hole and the run's path past
|
|
1188
|
+
// the hole, so the tail is a half disc of that path's radius at the hole's edge,
|
|
1189
|
+
// tapering to a point at the run's old end, and a run begun after a stop grows from
|
|
1190
|
+
// nothing like a stroke from rest: a full-radius disc a pixel past the hole's edge is
|
|
1191
|
+
// a crescent hugging the ring. It reports whether any stamp could set a pixel.
|
|
1192
|
+
function drawTrail(count, edge, win, front, box) {
|
|
1193
|
+
let carry = 0, drawn = false;
|
|
1194
|
+
const life = Math.max(1, win - front.age0);
|
|
1195
|
+
const frontP = Math.max(0, 1 - (front.age - front.age0) / life);
|
|
1196
|
+
for (let j = 0; j < count; j++) {
|
|
1197
|
+
const ax = gridX[j], ay = gridY[j], bx = gridX[j + 1], by = gridY[j + 1];
|
|
1198
|
+
const seg = Math.hypot(bx - ax, by - ay);
|
|
1199
|
+
if (seg < 1e-3) continue;
|
|
1200
|
+
const across = gridAcross[j];
|
|
1201
|
+
let at = carry;
|
|
1202
|
+
while (at <= seg) {
|
|
1203
|
+
const u = at / seg;
|
|
1204
|
+
const age = gridAge[j] + (gridAge[j + 1] - gridAge[j]) * u;
|
|
1205
|
+
const p = Math.max(0, Math.min(frontP, 1 - (age - front.age0) / life));
|
|
1206
|
+
const dist = gridDist[j] + (gridDist[j + 1] - gridDist[j]) * u;
|
|
1207
|
+
const runLeft = gridEnd[j] - dist;
|
|
1208
|
+
const r = Math.min(across * Math.pow(p, 0.6), runLeft, Math.hypot(Math.max(0, edge - dist), Math.max(0, gridEnd[j] - edge)));
|
|
1209
|
+
const spacing = Math.max(0.7, 0.32 * r);
|
|
1210
|
+
if (r > 0.3) {
|
|
1211
|
+
const overlap = Math.max(1, (2 * r) / spacing);
|
|
1212
|
+
const A = (gridPeak[j] + (gridPeak[j + 1] - gridPeak[j]) * u) * Math.pow(p, 0.9);
|
|
1213
|
+
ctx.globalAlpha = shown * (1 - Math.pow(1 - A, 1 / overlap));
|
|
1214
|
+
const x = ax + (bx - ax) * u, y = ay + (by - ay) * u;
|
|
1215
|
+
ctx.drawImage(sprite, (x - r) * dpr, (y - r) * dpr, 2 * r * dpr, 2 * r * dpr);
|
|
1216
|
+
box.b = grow(box.b, x, y, r + 1);
|
|
1217
|
+
if (ctx.globalAlpha >= PIXEL_ALPHA) drawn = true;
|
|
1218
|
+
}
|
|
1219
|
+
at += spacing;
|
|
1220
|
+
}
|
|
1221
|
+
carry = at - seg;
|
|
1222
|
+
}
|
|
1223
|
+
ctx.globalAlpha = 1;
|
|
1224
|
+
return drawn;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// the hole: the mark's live outline, grown under the unit transform as the keyline is
|
|
1228
|
+
// drawn, so the trail's front edge is the mark's back outline GAP_PX outside its keyline
|
|
1229
|
+
// whatever the jelly does to the shape
|
|
1230
|
+
function cutHole(x, y, T) {
|
|
1231
|
+
ctx.globalCompositeOperation = 'destination-out';
|
|
1232
|
+
ctx.setTransform(...jellyMatrix(x, y, T));
|
|
1233
|
+
shape(hw.x, hh.x, 0);
|
|
1234
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
1235
|
+
ctx.fill();
|
|
1236
|
+
ctx.lineWidth = 2 * HOLE_PX;
|
|
1237
|
+
ctx.stroke();
|
|
1238
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
function placeGlow(x, y) {
|
|
1242
|
+
const r = GLOW_R0 + GLOW_R1 * tone;
|
|
1243
|
+
bloom.style.transform = `translate3d(${x - GLOW_BOX}px, ${y - GLOW_BOX}px, 0) scale(${(r / GLOW_BOX).toFixed(4)})`;
|
|
1244
|
+
bloom.style.opacity = tone < 0.02 ? '0' : (GLOW_PEAK * tone * shown).toFixed(4);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
function drawMark(x, y, T, box) {
|
|
1248
|
+
const w = hw.x, h = hh.x;
|
|
1249
|
+
const M = jellyMatrix(x, y, T);
|
|
1250
|
+
const refusal = Math.min(1, Math.max(0, red.x));
|
|
1251
|
+
// the stroke and the dot light up with speed; the fill stays the plain ink, so the text
|
|
1252
|
+
// under the pointer sits under the same 12% or 26% of --accent at every speed
|
|
1253
|
+
const colour = rgb(mix(mix(ink, litInk, tone), refuse, refusal));
|
|
1254
|
+
const fillColour = rgb(mix(ink, refuse, refusal));
|
|
1255
|
+
const unit = () => ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
1256
|
+
// strokes are laid under the unit transform, so the line keeps its width
|
|
1257
|
+
// however far the path is stretched
|
|
1258
|
+
// the ink path is inset before the stretch, so across a full stretch its
|
|
1259
|
+
// outer edge creeps 0.3px past the shape; KEYLINE still leaves a 1px keyline there
|
|
1260
|
+
ctx.globalAlpha = shown;
|
|
1261
|
+
ctx.setTransform(...M); shape(w, h, 0); unit();
|
|
1262
|
+
ctx.lineWidth = KEYLINE;
|
|
1263
|
+
ctx.strokeStyle = rgb(keyline);
|
|
1264
|
+
ctx.stroke();
|
|
1265
|
+
const f = Math.min(1, Math.max(0, fill.x));
|
|
1266
|
+
if (f > 0.004) {
|
|
1267
|
+
ctx.globalAlpha = shown * f;
|
|
1268
|
+
ctx.fillStyle = fillColour;
|
|
1269
|
+
ctx.setTransform(...M); shape(w, h, 0); unit();
|
|
1270
|
+
ctx.fill();
|
|
1271
|
+
ctx.globalAlpha = shown;
|
|
1272
|
+
}
|
|
1273
|
+
if (w > STROKE / 2 + 0.05 && h > STROKE / 2 + 0.05 && f < 0.999) {
|
|
1274
|
+
ctx.setTransform(...M); shape(w, h, STROKE / 2); unit();
|
|
1275
|
+
ctx.lineWidth = STROKE;
|
|
1276
|
+
ctx.strokeStyle = colour;
|
|
1277
|
+
ctx.stroke();
|
|
1278
|
+
}
|
|
1279
|
+
const sl = Math.min(1, Math.max(0, slash.x));
|
|
1280
|
+
if (sl > 0.004) {
|
|
1281
|
+
const k = Math.max(0, Math.min(w, h) - STROKE) * Math.SQRT1_2;
|
|
1282
|
+
ctx.globalAlpha = shown * sl;
|
|
1283
|
+
ctx.setTransform(...M);
|
|
1284
|
+
ctx.beginPath(); ctx.moveTo(-k, -k); ctx.lineTo(k, k);
|
|
1285
|
+
unit();
|
|
1286
|
+
ctx.lineCap = 'butt';
|
|
1287
|
+
ctx.lineWidth = STROKE + 2; ctx.strokeStyle = rgb(keyline); ctx.stroke();
|
|
1288
|
+
ctx.lineWidth = STROKE; ctx.strokeStyle = colour; ctx.stroke();
|
|
1289
|
+
ctx.globalAlpha = shown;
|
|
1290
|
+
}
|
|
1291
|
+
const d = Math.min(1, Math.max(0, dot.x));
|
|
1292
|
+
if (d > 0.004) {
|
|
1293
|
+
ctx.globalAlpha = shown * d;
|
|
1294
|
+
ctx.beginPath(); ctx.arc(x, y, DOT_R + 1, 0, TAU);
|
|
1295
|
+
ctx.fillStyle = rgb(keyline); ctx.fill();
|
|
1296
|
+
ctx.beginPath(); ctx.arc(x, y, DOT_R, 0, TAU);
|
|
1297
|
+
ctx.fillStyle = colour; ctx.fill();
|
|
1298
|
+
}
|
|
1299
|
+
ctx.globalAlpha = 1;
|
|
1300
|
+
const extent = Math.max(w, h) * (1 + JELLY_MAX) + 3;
|
|
1301
|
+
box.b = grow(box.b, x, y, extent);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// the click: one 1px ring leaving the release point on --ease-out, its page
|
|
1305
|
+
// keyline outside it, opening from 20px to 72px as it fades
|
|
1306
|
+
function drawRipples(now, box) {
|
|
1307
|
+
for (let i = ripples.length - 1; i >= 0; i--) {
|
|
1308
|
+
const rp = ripples[i];
|
|
1309
|
+
const u = (now - rp.t0) / RIPPLE_MS;
|
|
1310
|
+
if (u >= 1) { ripples.splice(i, 1); continue; }
|
|
1311
|
+
const e = easeOut(u);
|
|
1312
|
+
const r = RIPPLE_R0 + (RIPPLE_R1 - RIPPLE_R0) * e;
|
|
1313
|
+
ctx.globalAlpha = 0.9 * (1 - e);
|
|
1314
|
+
ctx.lineWidth = 1;
|
|
1315
|
+
ctx.beginPath(); ctx.arc(rp.x, rp.y, r + 1, 0, TAU);
|
|
1316
|
+
ctx.strokeStyle = rgb(keyline); ctx.stroke();
|
|
1317
|
+
ctx.beginPath(); ctx.arc(rp.x, rp.y, r, 0, TAU);
|
|
1318
|
+
ctx.strokeStyle = rgb(rippleInk); ctx.stroke();
|
|
1319
|
+
box.b = grow(box.b, rp.x, rp.y, r + 3);
|
|
1320
|
+
}
|
|
1321
|
+
ctx.globalAlpha = 1;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// each droplet lives its own life and fades as the square; its speed keeps DROP_DRAG of
|
|
1325
|
+
// itself per millisecond and it travels the exact integral of that decay on its own clock,
|
|
1326
|
+
// from its birth, so the spray goes the same distance at every refresh rate and an input
|
|
1327
|
+
// that moves the springs' clock never shortens a droplet already flying. A stalled tab
|
|
1328
|
+
// plays on, as the springs do, from where it was
|
|
1329
|
+
const DROP_DRAG = 0.998, DROP_DRAG_LOG = Math.log(DROP_DRAG);
|
|
1330
|
+
const fly = (d, ms) => {
|
|
1331
|
+
const drag = DROP_DRAG ** ms, travel = (drag - 1) / DROP_DRAG_LOG;
|
|
1332
|
+
d.x += d.vx * travel; d.y += d.vy * travel;
|
|
1333
|
+
d.vx *= drag; d.vy *= drag;
|
|
1334
|
+
};
|
|
1335
|
+
const dropExpired = now => {
|
|
1336
|
+
for (let i = drops.length - 1; i >= 0; i--) if ((now - drops[i].born) / drops[i].life >= 1) drops.splice(i, 1);
|
|
1337
|
+
};
|
|
1338
|
+
function drawDrops(now, box) {
|
|
1339
|
+
dropExpired(now);
|
|
1340
|
+
ctx.strokeStyle = rgb(keyline);
|
|
1341
|
+
ctx.lineWidth = 1;
|
|
1342
|
+
for (let i = drops.length - 1; i >= 0; i--) {
|
|
1343
|
+
const d = drops[i];
|
|
1344
|
+
const u = (now - d.born) / d.life;
|
|
1345
|
+
fly(d, Math.min(MAX_STEP_S * 1000, now - d.at));
|
|
1346
|
+
d.at = now;
|
|
1347
|
+
ctx.globalAlpha = (1 - u) * (1 - u);
|
|
1348
|
+
ctx.fillStyle = rgb(d.hot ? sprayHot : spray);
|
|
1349
|
+
ctx.beginPath(); ctx.arc(d.x, d.y, d.r * (1 - u * 0.5), 0, TAU);
|
|
1350
|
+
ctx.fill();
|
|
1351
|
+
ctx.stroke();
|
|
1352
|
+
box.b = grow(box.b, d.x, d.y, d.r + 2);
|
|
1353
|
+
}
|
|
1354
|
+
ctx.globalAlpha = 1;
|
|
1355
|
+
}
|
|
1356
|
+
// a droplet is born at the instant of the pointer sample that sheds it, not when its
|
|
1357
|
+
// handler runs, so neither when the browser dispatches that sample (up to MAX_LATE_MS after
|
|
1358
|
+
// a frame passed its stamp) nor how many samples it coalesces into one event moves the spray,
|
|
1359
|
+
// short of the cap
|
|
1360
|
+
const spawn = (x, y, vx, vy, life, r, share, born) => {
|
|
1361
|
+
// the share is paid as a running debt, so the orange count is exact, never a coin flip
|
|
1362
|
+
hotDebt += share;
|
|
1363
|
+
const hot = hotDebt >= 1;
|
|
1364
|
+
if (hot) hotDebt -= 1;
|
|
1365
|
+
const d = { x, y, vx, vy, born, at: born, life, r, hot };
|
|
1366
|
+
// one born before the motion's last step flies to it, as that step's frame would have flown it
|
|
1367
|
+
if (born < last) { fly(d, last - born); d.at = last; }
|
|
1368
|
+
drops.push(d);
|
|
1369
|
+
};
|
|
1370
|
+
const orangeShare = v => ORANGE_MAX * Math.min(1, Math.max(0, (v - SPRAY_MIN) / (SPRAY_FULL - SPRAY_MIN)));
|
|
1371
|
+
// droplets shed off the head when the pointer is fast: backwards along the motion with
|
|
1372
|
+
// a sideways scatter, the way spray leaves a stone skipped across water. It runs once per
|
|
1373
|
+
// recorded sample and reads the pointer's velocity over the samples of its stroke, so the
|
|
1374
|
+
// spray depends on neither how the moves were batched nor a single whole-pixel step
|
|
1375
|
+
const shed = (x, y, t) => {
|
|
1376
|
+
const prev = lastEvent;
|
|
1377
|
+
// a gap past 80ms is a new stroke, not a speed; the caret sheds nothing
|
|
1378
|
+
const fresh = !prev || t - prev.t > 80 || state === 'text';
|
|
1379
|
+
lastEvent = { x, y, t, from: fresh ? t : prev.from };
|
|
1380
|
+
if (fresh) { sprayDebt = 0; lastSpeed = 0; return; }
|
|
1381
|
+
const dt = Math.max(0, t - prev.t);
|
|
1382
|
+
const [vx, vy] = velocity(lastEvent.from);
|
|
1383
|
+
const v = Math.hypot(vx, vy);
|
|
1384
|
+
lastSpeed = v;
|
|
1385
|
+
if (v <= SPRAY_MIN) { sprayDebt = 0; return; }
|
|
1386
|
+
const past = v - SPRAY_MIN;
|
|
1387
|
+
sprayDebt += SPRAY_RATE * past * past / (past + SPRAY_EASE) * dt;
|
|
1388
|
+
const share = orangeShare(v);
|
|
1389
|
+
const ux = vx / v, uy = vy / v;
|
|
1390
|
+
const born = instant(t);
|
|
1391
|
+
while (sprayDebt >= 1) {
|
|
1392
|
+
sprayDebt -= 1;
|
|
1393
|
+
// a droplet whose life ended by this one's birth holds no slot, drawn yet or not
|
|
1394
|
+
if (drops.length >= SPRAY_CAP) dropExpired(born);
|
|
1395
|
+
if (drops.length >= SPRAY_CAP) continue;
|
|
1396
|
+
const back = 0.05 + Math.random() * 0.12, side = (Math.random() - 0.5) * 0.5;
|
|
1397
|
+
spawn(x - ux * Math.random() * 6, y - uy * Math.random() * 6,
|
|
1398
|
+
-ux * back - uy * side, -uy * back + ux * side,
|
|
1399
|
+
320 + Math.random() * 220, 1.4 + Math.random() * 1.4, share, born);
|
|
1400
|
+
}
|
|
1401
|
+
};
|
|
1402
|
+
// a click drops a stone: a ring of droplets leaves the release point under the ripple
|
|
1403
|
+
const splash = (x, y, at) => {
|
|
1404
|
+
// a click at rest is a slow click: the last speed counts only within the stroke gap
|
|
1405
|
+
const share = lastEvent && at - lastEvent.t <= 80 ? orangeShare(lastSpeed) : 0;
|
|
1406
|
+
for (let i = 0; i < 10; i++) {
|
|
1407
|
+
const a = (i + Math.random() * 0.6) / 10 * TAU;
|
|
1408
|
+
const v = 0.12 + Math.random() * 0.1;
|
|
1409
|
+
spawn(x, y, Math.cos(a) * v, Math.sin(a) * v, 320 + Math.random() * 120, 1.5 + Math.random() * 1.1, share, at);
|
|
1410
|
+
}
|
|
1411
|
+
};
|
|
1412
|
+
|
|
1413
|
+
// standing down for a finger or a closed gate is temporary, a thrown error is not. Arming
|
|
1414
|
+
// restores `cursor-v3` too, in case a page script rewrote <html>'s class since boot
|
|
1415
|
+
const arm = () => { if (!bailed && !gated) root.classList.add('cursor-v3', 'custom-cursor'); };
|
|
1416
|
+
addEventListener('error', () => { bailed = true; root.classList.remove('custom-cursor'); });
|
|
1417
|
+
onGateChange(e => {
|
|
1418
|
+
gated = !motionAllowed();
|
|
1419
|
+
if (!gated) return;
|
|
1420
|
+
respond(e.timeStamp, () => { host.classList.remove('visible', 'press'); samples.length = 0; });
|
|
1421
|
+
// reduced motion asked for mid-flight outranks a droplet's life; the stand-down is
|
|
1422
|
+
// the finger's, so the first mouse move after the gate reopens re-arms
|
|
1423
|
+
detached = true;
|
|
1424
|
+
root.classList.remove('custom-cursor');
|
|
1425
|
+
drops.length = 0; ripples.length = 0;
|
|
1426
|
+
lastEvent = null;
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
// a frame wanted with no input behind it (a resize, a theme, a retry)
|
|
1430
|
+
function request() {
|
|
1431
|
+
if (parked) reach(performance.now());
|
|
1432
|
+
if (!frame) frame = requestAnimationFrame(tick);
|
|
1433
|
+
}
|
|
1434
|
+
// a move at `at` wakes a parked loop at its instant; on a running loop it lands on the
|
|
1435
|
+
// frame's goal, as every move of a stroke does, so a stroke's moves never split a frame
|
|
1436
|
+
function moved(at) {
|
|
1437
|
+
if (parked) reach(at);
|
|
1438
|
+
request();
|
|
1439
|
+
}
|
|
1440
|
+
// a press, a release, a leave, a scroll, a gate: what it changes starts at its own instant,
|
|
1441
|
+
// whether the loop runs or sleeps. The first statement of its handler, so the springs it
|
|
1442
|
+
// reads stand at that instant; `change` is what it does to the goal. Stamped before the
|
|
1443
|
+
// motion's last step, the springs stay there and gain the change's motion since its instant.
|
|
1444
|
+
// Returns that instant
|
|
1445
|
+
function respond(at, change = () => {}) {
|
|
1446
|
+
const t = instant(at);
|
|
1447
|
+
reach(t);
|
|
1448
|
+
const before = t < last && goalAt(last);
|
|
1449
|
+
change();
|
|
1450
|
+
if (before) retarget(before, goalAt(last), last - t);
|
|
1451
|
+
request();
|
|
1452
|
+
return t;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
// an input's instant: its stamp, MAX_LATE_MS before the motion's last step at the earliest,
|
|
1456
|
+
// and never past now (a stamp on another clock)
|
|
1457
|
+
function instant(at) {
|
|
1458
|
+
return Math.min(Math.max(at, last - MAX_LATE_MS), performance.now());
|
|
1459
|
+
}
|
|
1460
|
+
// Brings the motion from `last` to an input's instant on the goal it had until then, or
|
|
1461
|
+
// leaves it at `last` for one stamped before it (respond then plays its change). A
|
|
1462
|
+
// parked loop's springs stood at their goal, or moved on unseen behind a hidden mark toward
|
|
1463
|
+
// a goal no input changed, so one exact step, snapped where a ticking loop's frames would
|
|
1464
|
+
// have snapped it, lands them where that loop would be. A running loop steps like a frame
|
|
1465
|
+
// ending at the input, inside the frame's clamp; the next frame steps from there.
|
|
1466
|
+
function reach(at) {
|
|
1467
|
+
const t = Math.max(instant(at), last);
|
|
1468
|
+
if (parked) {
|
|
1469
|
+
if (parkedGoal && t > last) {
|
|
1470
|
+
advance((t - last) / 1000, parkedGoal);
|
|
1471
|
+
settle(parkedGoal);
|
|
1472
|
+
}
|
|
1473
|
+
frameAt = t;
|
|
1474
|
+
} else if (t > last) {
|
|
1475
|
+
advance(stepTo(t), goalAt(t));
|
|
1476
|
+
}
|
|
1477
|
+
last = t;
|
|
1478
|
+
parked = false;
|
|
1479
|
+
}
|
|
1480
|
+
// the seconds from `last` to t that fall inside the first MAX_STEP_S after the last frame,
|
|
1481
|
+
// so the steps between two frames add up to MAX_STEP_S at most
|
|
1482
|
+
const stepTo = t => {
|
|
1483
|
+
const end = frameAt + MAX_STEP_S * 1000;
|
|
1484
|
+
return (Math.min(t, end) - Math.min(last, end)) / 1000;
|
|
1485
|
+
};
|
|
1486
|
+
const park = goal => { parked = true; parkedGoal = goal; };
|
|
1487
|
+
|
|
1488
|
+
const goalAt = now => {
|
|
1489
|
+
const [tp, tq, v] = jellyTarget(now);
|
|
1490
|
+
return { ...targets(), jp: tp, jq: tq, tone: Math.min(1, v / FULL_SPEED), shown: host.classList.contains('visible') ? 1 : 0 };
|
|
1491
|
+
};
|
|
1492
|
+
|
|
1493
|
+
// each spring, the goal field it heads for, and its matrix in `matrices`
|
|
1494
|
+
const springs = [[hw, 'w', 'size'], [hh, 'h', 'size'], [fill, 'f', 'layer'], [dot, 'd', 'layer'],
|
|
1495
|
+
[slash, 'sl', 'layer'], [red, 'r', 'layer'], [jp, 'jp', 'wobble'], [jq, 'jq', 'wobble']];
|
|
1496
|
+
const matrices = dt => ({
|
|
1497
|
+
size: springMatrix(SHAPE_OMEGA, SHAPE_ZETA, dt), layer: springMatrix(SHAPE_OMEGA, 0.9, dt),
|
|
1498
|
+
wobble: springMatrix(JELLY_OMEGA, JELLY_ZETA, dt),
|
|
1499
|
+
});
|
|
1500
|
+
const fade = (from, goal, ms) => goal ? Math.min(1, from + ms / SHOW_MS) : Math.max(0, from - ms / HIDE_MS);
|
|
1501
|
+
|
|
1502
|
+
// every spring and ease over dt seconds, the goal held across the step
|
|
1503
|
+
function advance(dt, goal) {
|
|
1504
|
+
const k = matrices(dt);
|
|
1505
|
+
for (const [s, field, kind] of springs) step(s, goal[field], k[kind]);
|
|
1506
|
+
tone = goal.tone + (tone - goal.tone) * Math.exp(-dt * 1000 / TONE_MS);
|
|
1507
|
+
shown = fade(shown, goal.shown, dt * 1000);
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
// adds to a spring what one resting at zero does under k heading for `target` from speed `v`:
|
|
1511
|
+
// the springs are linear, so that is what a goal change or a kick that long ago adds now
|
|
1512
|
+
const superpose = (s, target, v, k) => {
|
|
1513
|
+
const r = { x: 0, v };
|
|
1514
|
+
step(r, target, k);
|
|
1515
|
+
s.x += r.x; s.v += r.v;
|
|
1516
|
+
};
|
|
1517
|
+
// a goal change `ms` before the motion's last step, played from its own instant: the springs
|
|
1518
|
+
// and the light-up's ease are linear in their goal
|
|
1519
|
+
function retarget(from, to, ms) {
|
|
1520
|
+
const k = matrices(ms / 1000);
|
|
1521
|
+
for (const [s, field, kind] of springs) superpose(s, to[field] - from[field], 0, k[kind]);
|
|
1522
|
+
tone += (to.tone - from.tone) * (1 - Math.exp(-ms / TONE_MS));
|
|
1523
|
+
if (from.shown === to.shown) return;
|
|
1524
|
+
// the fade is linear short of its ends: rewound to the change on the old goal and replayed on
|
|
1525
|
+
// the new; one standing at its end is taken to have stood there since the change
|
|
1526
|
+
const atChange = shown === from.shown ? shown : Math.min(1, Math.max(0, fade(shown, from.shown, -ms)));
|
|
1527
|
+
shown = fade(atChange, to.shown, ms);
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
// snaps whatever has come to rest onto its goal; true once nothing that shows still moves
|
|
1531
|
+
function settle(goal) {
|
|
1532
|
+
const toneSettled = Math.abs(tone - goal.tone) < (goal.tone ? 0.003 : toneRest);
|
|
1533
|
+
if (toneSettled) tone = goal.tone;
|
|
1534
|
+
// a mark drawing no toned ink (the caret's filled line, the refusal red) shows the
|
|
1535
|
+
// light-up only through the bloom, which shows nothing under 0.02: there the ease runs on
|
|
1536
|
+
// unseen, still for the park, and a wake's exact step carries it
|
|
1537
|
+
const tonedInk = goal.r < 1 && (goal.f < 0.999 || goal.d > 0.004 || goal.sl > 0.004);
|
|
1538
|
+
const toneStill = toneSettled || (!goal.tone && !tonedInk && tone < 0.02);
|
|
1539
|
+
// 0.003 of stretch is 0.03px on the idle ring: below that the wobble is done. The stretch
|
|
1540
|
+
// moves an outline by about itself times the mark's smaller half size (the tensor scales
|
|
1541
|
+
// it by the roundness), so a thinner mark coming to rest, the caret's line, is done at
|
|
1542
|
+
// the stretch that moves it 0.03px
|
|
1543
|
+
const thin = 0.003 * (SIZE.idle / 2) / Math.max(Math.min(goal.w, goal.h), 1);
|
|
1544
|
+
const wobbleRest = goal.jp || goal.jq ? 0.003 : Math.max(0.003, thin);
|
|
1545
|
+
return [rest(hw, goal.w, 0.01), rest(hh, goal.h, 0.01), rest(fill, goal.f, 0.002), rest(dot, goal.d, 0.002),
|
|
1546
|
+
rest(slash, goal.sl, 0.002), rest(red, goal.r, 0.002), rest(jp, goal.jp, wobbleRest), rest(jq, goal.jq, wobbleRest),
|
|
1547
|
+
toneStill, shown === goal.shown]
|
|
1548
|
+
.every(Boolean);
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
function tick() {
|
|
1552
|
+
frame = 0;
|
|
1553
|
+
const now = performance.now();
|
|
1554
|
+
const dt = stepTo(now);
|
|
1555
|
+
last = frameAt = now;
|
|
1556
|
+
if (applyQueued) { applyQueued = false; apply(queuedTarget); }
|
|
1557
|
+
|
|
1558
|
+
// drop samples the window has passed, keeping one to interpolate the tail from
|
|
1559
|
+
const win = samples.length ? windowMs(now) : TAIL_MS;
|
|
1560
|
+
while (samples.length > 1 && samples[1].net < netHead(now) - win) {
|
|
1561
|
+
const gone = samples.shift(), lead = samples[0].fill;
|
|
1562
|
+
if (gone.x !== samples[0].x || gone.y !== samples[0].y) behind = lead ? lead[lead.length - 1] : gone;
|
|
1563
|
+
}
|
|
1564
|
+
if (samples.length && strokeOver(now, win)) samples.length = 0;
|
|
1565
|
+
const goal = goalAt(now);
|
|
1566
|
+
advance(dt, goal);
|
|
1567
|
+
const settled = settle(goal);
|
|
1568
|
+
|
|
1569
|
+
if (dirty) {
|
|
1570
|
+
const pad = 2;
|
|
1571
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
1572
|
+
ctx.clearRect(Math.floor((dirty[0] - pad) * dpr), Math.floor((dirty[1] - pad) * dpr),
|
|
1573
|
+
Math.ceil((dirty[2] - dirty[0] + 2 * pad) * dpr), Math.ceil((dirty[3] - dirty[1] + 2 * pad) * dpr));
|
|
1574
|
+
}
|
|
1575
|
+
const box = { b: null };
|
|
1576
|
+
if (!placed) { dirty = null; park(goal); return; }
|
|
1577
|
+
|
|
1578
|
+
const x = pointer.x, y = pointer.y;
|
|
1579
|
+
const T = jellyTensor(shapeRoundness());
|
|
1580
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
1581
|
+
const count = samples.length ? buildGrid(now, win) : 0;
|
|
1582
|
+
let trailShows = false;
|
|
1583
|
+
if (count && sprite) {
|
|
1584
|
+
const edge = holeEdge();
|
|
1585
|
+
const still = now - samples[samples.length - 1].t;
|
|
1586
|
+
const front = edgeAge(count, edge, still);
|
|
1587
|
+
trailShows = drawTrail(count, edge, win, front, box);
|
|
1588
|
+
if (trailShows) cutHole(x, y, T);
|
|
1589
|
+
}
|
|
1590
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
1591
|
+
drawDrops(now, box);
|
|
1592
|
+
drawRipples(now, box);
|
|
1593
|
+
placeGlow(x, y);
|
|
1594
|
+
drawMark(x, y, T, box);
|
|
1595
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
1596
|
+
dirty = box.b;
|
|
1597
|
+
host.dataset.x = x;
|
|
1598
|
+
host.dataset.y = y;
|
|
1599
|
+
// the hidden cursor arms only once the mark has drawn, so a mark that dies
|
|
1600
|
+
// at boot never leaves the page cursorless
|
|
1601
|
+
if (!proven) { proven = true; arm(); }
|
|
1602
|
+
|
|
1603
|
+
// the loop parks on the first frame that will repeat. A still pointer's goal stays put
|
|
1604
|
+
// until the next input and its tube never grows back, so with droplets and ripples
|
|
1605
|
+
// (their own alpha) gone, that frame is one whose mark is hidden (shown 0, the trail and
|
|
1606
|
+
// the bloom gone with it), or one with every spring at rest and no stamp that shows.
|
|
1607
|
+
const hidden = shown === 0 && goal.shown === 0;
|
|
1608
|
+
const unchanging = !ripples.length && !drops.length && pointerStill(now) && (hidden || (settled && !trailShows));
|
|
1609
|
+
if (unchanging) park(goal);
|
|
1610
|
+
else frame = requestAnimationFrame(tick);
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
readColours();
|
|
1614
|
+
sizeCanvas();
|
|
1615
|
+
watchRatio();
|
|
1616
|
+
addEventListener('resize', sizeCanvas);
|
|
1617
|
+
onThemeChange(() => { readColours(); request(); });
|
|
1618
|
+
|
|
1619
|
+
// the native cursor hides on the first real mouse move, never at boot: a
|
|
1620
|
+
// touchscreen laptop matches (pointer: fine) too
|
|
1621
|
+
addEventListener('pointermove', e => {
|
|
1622
|
+
if (e.pointerType !== 'mouse' || gated) return;
|
|
1623
|
+
const batch = e.getCoalescedEvents ? e.getCoalescedEvents() : [];
|
|
1624
|
+
const moves = batch.length ? batch : [e];
|
|
1625
|
+
const at = moves[0].timeStamp;
|
|
1626
|
+
moved(at);
|
|
1627
|
+
// a return from outside the window starts a new stroke: joining it to the old one
|
|
1628
|
+
// would draw a tube across a stretch the pointer never travelled. A stroke also ends at
|
|
1629
|
+
// its own instant, whether or not a frame ran past it
|
|
1630
|
+
if (left) { left = false; samples.length = 0; }
|
|
1631
|
+
else if (samples.length && strokeOver(at, windowMs(at))) samples.length = 0;
|
|
1632
|
+
for (const c of moves) {
|
|
1633
|
+
const tail = samples[samples.length - 1];
|
|
1634
|
+
if (tail && c.timeStamp <= tail.t) { tail.x = c.clientX; tail.y = c.clientY; }
|
|
1635
|
+
else {
|
|
1636
|
+
if (!tail) behind = null;
|
|
1637
|
+
const pause = tail ? Math.max(0, c.timeStamp - tail.t - DRAIN_MS) : 0;
|
|
1638
|
+
samples.push({ x: c.clientX, y: c.clientY, t: c.timeStamp, pause, net: tail ? tail.net + c.timeStamp - tail.t - pause : 0, ...record() });
|
|
1639
|
+
}
|
|
1640
|
+
shed(c.clientX, c.clientY, c.timeStamp);
|
|
1641
|
+
}
|
|
1642
|
+
pointer.x = e.clientX; pointer.y = e.clientY;
|
|
1643
|
+
if (!placed || detached) {
|
|
1644
|
+
placed = true; detached = false;
|
|
1645
|
+
if (proven) arm();
|
|
1646
|
+
}
|
|
1647
|
+
host.classList.add('visible');
|
|
1648
|
+
queueApply(e.target instanceof Element ? e.target : null);
|
|
1649
|
+
}, { passive: true });
|
|
1650
|
+
|
|
1651
|
+
// the trail fades with the mark and ages out; the next move drops it
|
|
1652
|
+
root.addEventListener('mouseleave', e => {
|
|
1653
|
+
respond(e.timeStamp, () => host.classList.remove('visible'));
|
|
1654
|
+
left = true;
|
|
1655
|
+
lastEvent = null;
|
|
1656
|
+
});
|
|
1657
|
+
// not while a press is held: the pressed element owns the pointer through a
|
|
1658
|
+
// capture, and a drag's own scroll would re-derive the state off the raw point
|
|
1659
|
+
addEventListener('scroll', e => {
|
|
1660
|
+
if (host.classList.contains('press')) return;
|
|
1661
|
+
respond(e.timeStamp);
|
|
1662
|
+
queueApply(null);
|
|
1663
|
+
}, { passive: true, capture: true });
|
|
1664
|
+
|
|
1665
|
+
// capture on the press pair, so a handler that stops propagation cannot strand the mark
|
|
1666
|
+
addEventListener('pointerdown', e => {
|
|
1667
|
+
if (e.pointerType !== 'mouse') {
|
|
1668
|
+
respond(e.timeStamp, () => host.classList.remove('visible'));
|
|
1669
|
+
detached = true;
|
|
1670
|
+
root.classList.remove('custom-cursor');
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
if (e.button !== 0 || gated) return;
|
|
1674
|
+
respond(e.timeStamp, () => host.classList.add('press'));
|
|
1675
|
+
pressedState = state;
|
|
1676
|
+
}, { capture: true });
|
|
1677
|
+
|
|
1678
|
+
// a press lost to blur, cancel or a drag must not leave its click feedback for the next button
|
|
1679
|
+
const release = e => {
|
|
1680
|
+
pressedState = null;
|
|
1681
|
+
if (!host.classList.contains('press')) return;
|
|
1682
|
+
respond(e.timeStamp, () => host.classList.remove('press'));
|
|
1683
|
+
};
|
|
1684
|
+
addEventListener('pointerup', e => {
|
|
1685
|
+
if (e.pointerType !== 'mouse') return;
|
|
1686
|
+
const at = respond(e.timeStamp);
|
|
1687
|
+
const was = pressedState;
|
|
1688
|
+
release(e);
|
|
1689
|
+
if (was === 'blocked') {
|
|
1690
|
+
// a refused click shakes its head: a sideways kick into the jelly, centre unmoved
|
|
1691
|
+
superpose(jp, 0, SHAKE_KICK, matrices((last - at) / 1000).wobble);
|
|
1692
|
+
} else if (was === 'hover') {
|
|
1693
|
+
ripples.push({ x: e.clientX, y: e.clientY, t0: at });
|
|
1694
|
+
splash(e.clientX, e.clientY, at);
|
|
1695
|
+
}
|
|
1696
|
+
queueApply(null); // the click's handler may have removed or moved the target
|
|
1697
|
+
}, { capture: true });
|
|
1698
|
+
addEventListener('pointercancel', release, { capture: true });
|
|
1699
|
+
addEventListener('blur', release);
|
|
1700
|
+
|
|
1701
|
+
// a native drag brings the OS cursor and a ghost back, and no `cursor: none` outranks either
|
|
1702
|
+
document.querySelectorAll('a[href], img').forEach(el => { el.draggable = false; });
|
|
1703
|
+
addEventListener('dragstart', e => {
|
|
1704
|
+
release(e);
|
|
1705
|
+
if (root.classList.contains('custom-cursor')) e.preventDefault();
|
|
1706
|
+
}, { capture: true });
|
|
1707
|
+
/* A press held on `a[href]` hands the cursor to the link-drag machinery before
|
|
1708
|
+
any dragstart exists, and refusing mousedown is already too late, so the
|
|
1709
|
+
pointerdown default is refused and focus set by hand. `focusVisible: false`
|
|
1710
|
+
or every click paints the keyboard ring. Primary mouse only: the middle
|
|
1711
|
+
button opens a tab and the right one the context menu. */
|
|
1712
|
+
addEventListener('pointerdown', e => {
|
|
1713
|
+
if (e.pointerType !== 'mouse' || e.button !== 0) return;
|
|
1714
|
+
if (!root.classList.contains('custom-cursor')) return;
|
|
1715
|
+
const link = e.target instanceof Element && e.target.closest('a[href]');
|
|
1716
|
+
if (!link) return;
|
|
1717
|
+
e.preventDefault();
|
|
1718
|
+
link.focus({ preventScroll: true, focusVisible: false });
|
|
1719
|
+
}, { capture: true });
|
|
1720
|
+
};
|
|
1721
|
+
|
|
1722
|
+
/* A browser cache can serve another version's cursor.css beside this file, and a page can
|
|
1723
|
+
load the sheet after the module runs (a script-inserted link, a media or preload swap, an
|
|
1724
|
+
async module). The layer boots only once its own sheet applies, told by a marker no v1 or
|
|
1725
|
+
v2 sheet declares, and marks the page `cursor-v3`, which the sheet's hidden-cursor rule
|
|
1726
|
+
needs beside `custom-cursor`. Until then it looks again on window load and on every
|
|
1727
|
+
mouse move, so a late sheet still gets the whole layer and a legacy one never does. An
|
|
1728
|
+
async module can also run before `<body>` exists; the layer then boots on load or on the
|
|
1729
|
+
first mouse move once the body is there. */
|
|
1730
|
+
const ownSheet = () => getComputedStyle(root).getPropertyValue('--cursor-v3') !== '';
|
|
1731
|
+
let booted = false;
|
|
1732
|
+
const boot = () => {
|
|
1733
|
+
if (booted || !document.body || !ownSheet()) return;
|
|
1734
|
+
booted = true;
|
|
1735
|
+
root.classList.add('cursor-v3');
|
|
1736
|
+
bootGrid();
|
|
1737
|
+
if (motionAllowed()) bootMark();
|
|
1738
|
+
};
|
|
1739
|
+
// the grid's track, and the late look for the sheet. pointermove, never mousemove: a refused
|
|
1740
|
+
// pointerdown default (the link guard, the drawn scrollbar's thumb) suppresses every
|
|
1741
|
+
// compatibility mouse event until the release, which froze the grid for a press-drag.
|
|
1742
|
+
// Registered before the ring's, so on each move the grid reads the pointer first
|
|
1743
|
+
addEventListener('pointermove', e => {
|
|
1744
|
+
if (e.pointerType !== 'mouse') return;
|
|
1745
|
+
boot();
|
|
1746
|
+
if (gridTrack) gridTrack(e);
|
|
1747
|
+
}, { passive: true });
|
|
1748
|
+
addEventListener('load', boot, { once: true });
|
|
1749
|
+
boot();
|