@octanejs/thinking-orbs 0.1.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/LICENSE +21 -0
- package/README.md +34 -0
- package/package.json +48 -0
- package/src/ThinkingOrb.tsrx +109 -0
- package/src/engine/braid.ts +50 -0
- package/src/engine/core.ts +136 -0
- package/src/engine/lattice.ts +210 -0
- package/src/engine/morph.ts +138 -0
- package/src/engine/orbits.ts +80 -0
- package/src/engine/profiles.ts +162 -0
- package/src/engine/registry.ts +24 -0
- package/src/engine/ribbon.ts +94 -0
- package/src/engine/types.ts +14 -0
- package/src/engine/web.ts +108 -0
- package/src/index.ts +6 -0
- package/src/presets.ts +106 -0
- package/src/theme.ts +83 -0
- package/src/types.ts +34 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dominic Gannaway
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# @octanejs/thinking-orbs
|
|
2
|
+
|
|
3
|
+
Octane binding for [`thinking-orbs`](https://www.npmjs.com/package/thinking-orbs) — dotted thought-orb loading indicators for AI and agent UIs.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install @octanejs/thinking-orbs
|
|
7
|
+
pnpm add @octanejs/thinking-orbs
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Usage
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { ThinkingOrb } from '@octanejs/thinking-orbs';
|
|
14
|
+
|
|
15
|
+
function Status() {
|
|
16
|
+
return <ThinkingOrb state="searching" size={64} />;
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Compatibility
|
|
21
|
+
|
|
22
|
+
Pinned to `thinking-orbs@0.2.0`. The framework-neutral canvas engine is reused verbatim; `ThinkingOrb` is reimplemented in `.tsrx`.
|
|
23
|
+
|
|
24
|
+
## Known differences
|
|
25
|
+
|
|
26
|
+
None documented yet.
|
|
27
|
+
|
|
28
|
+
## Tests
|
|
29
|
+
|
|
30
|
+
Organized per the hook-form / react-parity contract:
|
|
31
|
+
|
|
32
|
+
- `tests/conformance/` — package-authored contract tests (ordinary CI shards)
|
|
33
|
+
- `tests/differential/` — Octane vs React oracle (dedicated project + `globalSetup`)
|
|
34
|
+
- Vitest projects declare `testExecution.group: 'react-parity'` on parity-owned lanes
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@octanejs/thinking-orbs",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22.22.2"
|
|
8
|
+
},
|
|
9
|
+
"description": "thinking-orbs for the octane renderer — dotted thought-orb loading indicators for AI and agent UIs.",
|
|
10
|
+
"author": {
|
|
11
|
+
"name": "Dominic Gannaway",
|
|
12
|
+
"email": "dg@domgan.com"
|
|
13
|
+
},
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/octanejs/octane.git",
|
|
20
|
+
"directory": "packages/thinking-orbs"
|
|
21
|
+
},
|
|
22
|
+
"main": "src/index.ts",
|
|
23
|
+
"module": "src/index.ts",
|
|
24
|
+
"types": "src/index.ts",
|
|
25
|
+
"files": [
|
|
26
|
+
"src",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"sideEffects": false,
|
|
30
|
+
"exports": {
|
|
31
|
+
".": "./src/index.ts"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"octane": "0.1.50"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@tsrx/react": "^0.2.61",
|
|
38
|
+
"esbuild": "^0.28.1",
|
|
39
|
+
"react": "^19.2.7",
|
|
40
|
+
"react-dom": "^19.2.7",
|
|
41
|
+
"thinking-orbs": "0.2.0",
|
|
42
|
+
"vitest": "^4.1.10",
|
|
43
|
+
"octane": "0.1.50"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"test": "vitest run"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Ported from thinking-orbs@0.2.0 src/ThinkingOrb.tsx
|
|
2
|
+
// (https://github.com/Jakubantalik/thinking-orbs).
|
|
3
|
+
import { useEffect, useRef } from 'octane';
|
|
4
|
+
import { MODE_DRAWS } from './engine/registry';
|
|
5
|
+
import { resolvePreset } from './presets';
|
|
6
|
+
import { useReducedMotion, useResolvedDark } from './theme';
|
|
7
|
+
import type { ThinkingOrbProps } from './types';
|
|
8
|
+
|
|
9
|
+
const LABELS: Record<string, string> = {
|
|
10
|
+
working: 'Working…',
|
|
11
|
+
searching: 'Searching…',
|
|
12
|
+
solving: 'Solving…',
|
|
13
|
+
listening: 'Listening…',
|
|
14
|
+
connecting: 'Connecting…',
|
|
15
|
+
weaving: 'Weaving…',
|
|
16
|
+
composing: 'Composing…',
|
|
17
|
+
breathing: 'Thinking…',
|
|
18
|
+
shaping: 'Shaping…',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function ThinkingOrb({
|
|
22
|
+
state = 'working',
|
|
23
|
+
size = 64,
|
|
24
|
+
theme = 'auto',
|
|
25
|
+
speed = 1,
|
|
26
|
+
paused = false,
|
|
27
|
+
style,
|
|
28
|
+
'aria-label': ariaLabel,
|
|
29
|
+
...rest
|
|
30
|
+
}: ThinkingOrbProps) {
|
|
31
|
+
const ref = useRef<HTMLCanvasElement | null>(null);
|
|
32
|
+
const dark = useResolvedDark(theme, ref);
|
|
33
|
+
const reduced = useReducedMotion();
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const canvas = ref.current;
|
|
37
|
+
if (!canvas) return;
|
|
38
|
+
const dpr = Math.min(2, typeof devicePixelRatio !== 'undefined' && devicePixelRatio || 1);
|
|
39
|
+
canvas.width = Math.round(size * dpr);
|
|
40
|
+
canvas.height = Math.round(size * dpr);
|
|
41
|
+
const ctx = canvas.getContext('2d');
|
|
42
|
+
if (!ctx) return;
|
|
43
|
+
const context = ctx;
|
|
44
|
+
|
|
45
|
+
const { mode, speed: baseSpeed, opts } = resolvePreset(state, size);
|
|
46
|
+
const draw = MODE_DRAWS[mode];
|
|
47
|
+
const effSpeed = baseSpeed * speed;
|
|
48
|
+
|
|
49
|
+
function frame(tSec: number): void {
|
|
50
|
+
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
51
|
+
context.clearRect(0, 0, size, size);
|
|
52
|
+
draw(context, size, tSec, dark, opts);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (reduced) {
|
|
56
|
+
frame(0.6);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let raf = 0;
|
|
61
|
+
let running = false;
|
|
62
|
+
function loop(): void {
|
|
63
|
+
frame((performance.now() / 1000) * effSpeed);
|
|
64
|
+
if (running) raf = requestAnimationFrame(loop);
|
|
65
|
+
}
|
|
66
|
+
function start(): void {
|
|
67
|
+
if (running || paused) return;
|
|
68
|
+
running = true;
|
|
69
|
+
raf = requestAnimationFrame(loop);
|
|
70
|
+
}
|
|
71
|
+
function stop(): void {
|
|
72
|
+
running = false;
|
|
73
|
+
cancelAnimationFrame(raf);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
frame((performance.now() / 1000) * effSpeed);
|
|
77
|
+
|
|
78
|
+
let visible = true;
|
|
79
|
+
const io =
|
|
80
|
+
typeof IntersectionObserver !== 'undefined'
|
|
81
|
+
? new IntersectionObserver(([entry]) => {
|
|
82
|
+
visible = entry.isIntersecting;
|
|
83
|
+
if (visible && document.visibilityState !== 'hidden') start();
|
|
84
|
+
else stop();
|
|
85
|
+
})
|
|
86
|
+
: null;
|
|
87
|
+
io?.observe(canvas);
|
|
88
|
+
function onVis(): void {
|
|
89
|
+
if (document.visibilityState === 'hidden') stop();
|
|
90
|
+
else if (visible) start();
|
|
91
|
+
}
|
|
92
|
+
document.addEventListener('visibilitychange', onVis);
|
|
93
|
+
if (!io) start();
|
|
94
|
+
|
|
95
|
+
return () => {
|
|
96
|
+
stop();
|
|
97
|
+
io?.disconnect();
|
|
98
|
+
document.removeEventListener('visibilitychange', onVis);
|
|
99
|
+
};
|
|
100
|
+
}, [state, size, dark, speed, paused, reduced]);
|
|
101
|
+
|
|
102
|
+
return <canvas
|
|
103
|
+
ref={ref}
|
|
104
|
+
role="img"
|
|
105
|
+
aria-label={ariaLabel ?? LABELS[state]}
|
|
106
|
+
style={{ width: size, height: size, display: 'block', ...style }}
|
|
107
|
+
{...rest}
|
|
108
|
+
/>;
|
|
109
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Braid: three strands plait around the sphere — the "weaving" state.
|
|
2
|
+
// Each strand runs pole to pole on a helix, and a radial breathing term
|
|
3
|
+
// makes them trade places, reading as the over/under of a plait.
|
|
4
|
+
|
|
5
|
+
import type { Dot, ModeDraw } from './types';
|
|
6
|
+
import { fibDir, frac, makeProj, paint, radiusScale } from './core';
|
|
7
|
+
|
|
8
|
+
export const drawBraid: ModeDraw = (ctx, size, t, dark, o) => {
|
|
9
|
+
const cx = size / 2;
|
|
10
|
+
const cy = size / 2;
|
|
11
|
+
const R = (size / 2) * 0.76;
|
|
12
|
+
const pt = makeProj(t * 0.4, 0.3, cx, cy, 1);
|
|
13
|
+
const rs = radiusScale(size, o.rsPow ?? 0.6);
|
|
14
|
+
|
|
15
|
+
const dots: Dot[] = [];
|
|
16
|
+
const ghostN = o.ghostN ?? 150;
|
|
17
|
+
for (let i = 0; i < ghostN; i++) {
|
|
18
|
+
const d = fibDir(i, ghostN);
|
|
19
|
+
const [px, py, z] = pt(d[0] * R, d[1] * R, d[2] * R);
|
|
20
|
+
const depth = (z / R + 1) / 2;
|
|
21
|
+
dots.push({ x: px, y: py, z, r: 0.8 * rs, white: 0.78, a: 0.1 + 0.22 * depth });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const strandN = o.strandN ?? 52;
|
|
25
|
+
const turns = o.turns ?? 3;
|
|
26
|
+
for (let s = 0; s < 3; s++) {
|
|
27
|
+
const phase = (s / 3) * 2 * Math.PI;
|
|
28
|
+
for (let i = 0; i < strandN; i++) {
|
|
29
|
+
// u walks pole to pole; the frac() drift slides the whole strand along
|
|
30
|
+
const u = (frac(i / strandN + t * 0.045) * 2 - 1) * 0.96;
|
|
31
|
+
const surf = Math.sqrt(Math.max(0, 1 - u * u));
|
|
32
|
+
const endFade = Math.min(1, (1 - Math.abs(u)) / 0.1);
|
|
33
|
+
const a = u * Math.PI * turns + phase;
|
|
34
|
+
// radial breathing: strands trade places — the over/under of a plait
|
|
35
|
+
const weave = 1 + 0.075 * Math.sin(u * Math.PI * turns * 2 + phase * 2 + t * 0.8);
|
|
36
|
+
const rr = surf * R * weave;
|
|
37
|
+
const [px, py, zr] = pt(Math.cos(a) * rr, u * R * weave, Math.sin(a) * rr);
|
|
38
|
+
const depth = (zr / R + 1) / 2;
|
|
39
|
+
dots.push({
|
|
40
|
+
x: px,
|
|
41
|
+
y: py,
|
|
42
|
+
z: zr,
|
|
43
|
+
r: ((o.rBase ?? 1.2) + (o.rDepth ?? 1.8) * depth) * rs,
|
|
44
|
+
white: 0.55 - 0.45 * depth,
|
|
45
|
+
a: endFade * (0.45 + 0.55 * depth),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
paint(ctx, dots, dark, o.rMin);
|
|
50
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Shared primitives for the dotted 3D thought-orbs. Ported from inkform
|
|
2
|
+
// (PlotterLab's HalftoneSphere lineage): honestly 3D — rotated,
|
|
3
|
+
// depth-shaded, z-sorted. Depth is carried by dot size and ink weight
|
|
4
|
+
// alone. Plain 2D canvas fills only: no ctx.filter, no SVG filters, so
|
|
5
|
+
// every mode renders identically in Chrome, Safari and Firefox.
|
|
6
|
+
|
|
7
|
+
export interface Dot {
|
|
8
|
+
x: number;
|
|
9
|
+
y: number;
|
|
10
|
+
z: number;
|
|
11
|
+
r: number;
|
|
12
|
+
/** Ink value: 0 = darkest ink on paper. Mirrored on dark themes. */
|
|
13
|
+
white: number;
|
|
14
|
+
a?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A stroked edge between two projected points (the `connecting` web). */
|
|
18
|
+
export interface Line {
|
|
19
|
+
x1: number;
|
|
20
|
+
y1: number;
|
|
21
|
+
x2: number;
|
|
22
|
+
y2: number;
|
|
23
|
+
/** Ink value, same convention as `Dot.white`. */
|
|
24
|
+
white: number;
|
|
25
|
+
a?: number;
|
|
26
|
+
w: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type Projector = (x: number, y: number, z: number) => [number, number, number];
|
|
30
|
+
|
|
31
|
+
export function lerp(a: number, b: number, f: number): number {
|
|
32
|
+
return a + (b - a) * f;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function frac(x: number): number {
|
|
36
|
+
return x - Math.floor(x);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Value noise on a 2D lattice — smooth, deterministic, cheap. */
|
|
40
|
+
export function vnoise(x: number, y: number): number {
|
|
41
|
+
const xi = Math.floor(x);
|
|
42
|
+
const yi = Math.floor(y);
|
|
43
|
+
let fx = x - xi;
|
|
44
|
+
let fy = y - yi;
|
|
45
|
+
fx = fx * fx * (3 - 2 * fx);
|
|
46
|
+
fy = fy * fy * (3 - 2 * fy);
|
|
47
|
+
const a = hashD(xi, yi);
|
|
48
|
+
const b = hashD(xi + 1, yi);
|
|
49
|
+
const c = hashD(xi, yi + 1);
|
|
50
|
+
const d = hashD(xi + 1, yi + 1);
|
|
51
|
+
return a + (b - a) * fx + (c - a) * fy + (a - b - c + d) * fx * fy;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Deterministic hash in [0, 1). */
|
|
55
|
+
export function hashD(a: number, b: number): number {
|
|
56
|
+
const h = Math.sin(a * 12.9898 + b * 78.233) * 43758.5453;
|
|
57
|
+
return h - Math.floor(h);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Stable directions on a unit sphere (Fibonacci lattice). */
|
|
61
|
+
export function fibDir(i: number, n: number): [number, number, number] {
|
|
62
|
+
const golden = Math.PI * (3 - Math.sqrt(5));
|
|
63
|
+
const y = 1 - (2 * (i + 0.5)) / n;
|
|
64
|
+
const rad = Math.sqrt(1 - y * y);
|
|
65
|
+
const a = i * golden;
|
|
66
|
+
return [rad * Math.cos(a), y, rad * Math.sin(a)];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Shortest signed angular distance, wrapped to (-π, π]. */
|
|
70
|
+
export function angleDelta(a: number, b: number): number {
|
|
71
|
+
return Math.atan2(Math.sin(a - b), Math.cos(a - b));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Shared spin + tilt + orthographic projection. */
|
|
75
|
+
export function makeProj(
|
|
76
|
+
yaw: number,
|
|
77
|
+
tilt: number,
|
|
78
|
+
cx: number,
|
|
79
|
+
cy: number,
|
|
80
|
+
scale: number,
|
|
81
|
+
): Projector {
|
|
82
|
+
const st = Math.sin(tilt);
|
|
83
|
+
const ct = Math.cos(tilt);
|
|
84
|
+
const sy = Math.sin(yaw);
|
|
85
|
+
const cyw = Math.cos(yaw);
|
|
86
|
+
return (x, y, z) => {
|
|
87
|
+
const x1 = x * cyw + z * sy;
|
|
88
|
+
const z1 = -x * sy + z * cyw;
|
|
89
|
+
const y1 = y * ct - z1 * st;
|
|
90
|
+
const z2 = y * st + z1 * ct;
|
|
91
|
+
return [cx + x1 * scale, cy - y1 * scale, z2];
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Painter: z-sort far→near, matte grayscale dots. On dark substrates the
|
|
97
|
+
* ink value is mirrored (1 - white) so near dots read bright — the same
|
|
98
|
+
* depth language on an inverted substrate.
|
|
99
|
+
*/
|
|
100
|
+
export function paint(ctx: CanvasRenderingContext2D, dots: Dot[], dark: boolean, rMin = 0.3): void {
|
|
101
|
+
dots.sort((a, b) => a.z - b.z);
|
|
102
|
+
for (const d of dots) {
|
|
103
|
+
const alpha = d.a ?? 1;
|
|
104
|
+
if (alpha < 0.02) continue;
|
|
105
|
+
const w = Math.min(1, Math.max(0, d.white));
|
|
106
|
+
const g = Math.round((dark ? 1 - w : w) * 255);
|
|
107
|
+
ctx.fillStyle = `rgba(${g},${g},${g},${alpha})`;
|
|
108
|
+
ctx.beginPath();
|
|
109
|
+
ctx.arc(d.x, d.y, Math.max(rMin, d.r), 0, Math.PI * 2);
|
|
110
|
+
ctx.fill();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Stroke pass for edge-based modes. Runs before `paint` so nodes sit on top. */
|
|
115
|
+
export function paintLines(ctx: CanvasRenderingContext2D, lines: Line[], dark: boolean): void {
|
|
116
|
+
for (const l of lines) {
|
|
117
|
+
const alpha = l.a ?? 1;
|
|
118
|
+
if (alpha < 0.02) continue;
|
|
119
|
+
const w = Math.min(1, Math.max(0, l.white));
|
|
120
|
+
const g = Math.round((dark ? 1 - w : w) * 255);
|
|
121
|
+
ctx.strokeStyle = `rgba(${g},${g},${g},${alpha})`;
|
|
122
|
+
ctx.lineWidth = l.w;
|
|
123
|
+
ctx.beginPath();
|
|
124
|
+
ctx.moveTo(l.x1, l.y1);
|
|
125
|
+
ctx.lineTo(l.x2, l.y2);
|
|
126
|
+
ctx.stroke();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Dot radii were tuned for a 300pt frame; sub-linear scaling keeps small
|
|
132
|
+
* spinners legible. Lower pow = radii shrink less with size.
|
|
133
|
+
*/
|
|
134
|
+
export function radiusScale(size: number, pow: number): number {
|
|
135
|
+
return (size / 300) ** pow;
|
|
136
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// The sphere-lattice modes: globe (searching), rubik (solving) and
|
|
2
|
+
// wave (listening). All draw a lat/long dot field with mode-specific
|
|
3
|
+
// motion, then hand off to the shared z-sorted painter.
|
|
4
|
+
|
|
5
|
+
import type { Dot, ModeDraw } from './types';
|
|
6
|
+
import { angleDelta, hashD, makeProj, paint, radiusScale } from './core';
|
|
7
|
+
|
|
8
|
+
// --- the shared solver heartbeat (rubik) ------------------------------
|
|
9
|
+
// Rapid eased moves scramble, then replay in reverse (palindrome) so
|
|
10
|
+
// everything clicks back to solved, rests, repeats.
|
|
11
|
+
|
|
12
|
+
interface Move {
|
|
13
|
+
axis: 0 | 1 | 2;
|
|
14
|
+
lo: number;
|
|
15
|
+
hi: number;
|
|
16
|
+
ang: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function solveCycle(time: number, count: number, slotDur: number, rest: number) {
|
|
20
|
+
const cyc = 2 * count * slotDur + rest;
|
|
21
|
+
const tc = time % cyc;
|
|
22
|
+
const amount = new Array<number>(count).fill(0);
|
|
23
|
+
let active = -1;
|
|
24
|
+
if (tc < 2 * count * slotDur) {
|
|
25
|
+
const slot = Math.floor(tc / slotDur);
|
|
26
|
+
const p = (tc - slot * slotDur) / slotDur;
|
|
27
|
+
const cl = Math.min(1, p / 0.7);
|
|
28
|
+
const ep = 1 - (1 - cl) ** 3; // machine ease-out
|
|
29
|
+
if (slot < count) {
|
|
30
|
+
for (let i = 0; i < slot; i++) amount[i] = 1;
|
|
31
|
+
amount[slot] = ep;
|
|
32
|
+
active = slot;
|
|
33
|
+
} else {
|
|
34
|
+
const u = 2 * count - 1 - slot;
|
|
35
|
+
for (let i = 0; i < u; i++) amount[i] = 1;
|
|
36
|
+
amount[u] = 1 - ep;
|
|
37
|
+
active = u;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { amount, active };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function applyMoves(
|
|
44
|
+
pt3: [number, number, number],
|
|
45
|
+
moves: Move[],
|
|
46
|
+
sc: { amount: number[]; active: number },
|
|
47
|
+
): [number, number, number, boolean] {
|
|
48
|
+
let [x, y, z] = pt3;
|
|
49
|
+
let inActive = false;
|
|
50
|
+
for (let i = 0; i < moves.length; i++) {
|
|
51
|
+
if (sc.amount[i] <= 0) continue;
|
|
52
|
+
const mv = moves[i];
|
|
53
|
+
const coord = mv.axis === 0 ? x : mv.axis === 1 ? y : z;
|
|
54
|
+
if (coord < mv.lo || coord >= mv.hi) continue;
|
|
55
|
+
if (i === sc.active) inActive = true;
|
|
56
|
+
const a = mv.ang * sc.amount[i];
|
|
57
|
+
const ca = Math.cos(a);
|
|
58
|
+
const sa = Math.sin(a);
|
|
59
|
+
if (mv.axis === 0) {
|
|
60
|
+
const y2 = y * ca - z * sa;
|
|
61
|
+
z = y * sa + z * ca;
|
|
62
|
+
y = y2;
|
|
63
|
+
} else if (mv.axis === 1) {
|
|
64
|
+
const x2 = x * ca + z * sa;
|
|
65
|
+
z = -x * sa + z * ca;
|
|
66
|
+
x = x2;
|
|
67
|
+
} else {
|
|
68
|
+
const x2 = x * ca - y * sa;
|
|
69
|
+
y = x * sa + y * ca;
|
|
70
|
+
x = x2;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return [x, y, z, inActive];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function makeMoves(count: number): Move[] {
|
|
77
|
+
const moves: Move[] = [];
|
|
78
|
+
for (let i = 0; i < count; i++) {
|
|
79
|
+
const axis = Math.min(2, Math.floor(hashD(i, 2.3) * 3)) as 0 | 1 | 2;
|
|
80
|
+
const lo = -1.0 + 0.5 * Math.min(3, Math.floor(hashD(i, 5.9) * 4));
|
|
81
|
+
const dir = hashD(i, 7.7) < 0.5 ? 1 : -1;
|
|
82
|
+
moves.push({ axis, lo, hi: lo + 0.5, ang: (dir * Math.PI) / 2 });
|
|
83
|
+
}
|
|
84
|
+
return moves;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// --- Globe: lat/long field, a scan meridian sweeps — searching --------
|
|
88
|
+
|
|
89
|
+
export const drawGlobe: ModeDraw = (ctx, size, t, dark, o) => {
|
|
90
|
+
const spin = 0.5;
|
|
91
|
+
const cx = size / 2;
|
|
92
|
+
const cy = size / 2;
|
|
93
|
+
const radius = (size / 2) * 0.82;
|
|
94
|
+
const tilt = 0.4 + 0.06 * Math.sin(t * 0.35);
|
|
95
|
+
const pt = makeProj(t * spin, tilt, cx, cy, radius);
|
|
96
|
+
// scan sweeps relative to the spin; scanMul scales that relative rate
|
|
97
|
+
const scan = t * (spin + (1.7 - spin) * (o.scanMul ?? 1));
|
|
98
|
+
const rs = radiusScale(size, o.rsPow ?? 0.6);
|
|
99
|
+
const dimBase = o.dimBase ?? 1;
|
|
100
|
+
|
|
101
|
+
const dots: Dot[] = [];
|
|
102
|
+
const latRings = o.latRings ?? 17;
|
|
103
|
+
const lonDensity = o.lonDensity ?? 44;
|
|
104
|
+
for (let li = 0; li <= latRings; li++) {
|
|
105
|
+
const lat = -Math.PI / 2 + (li / latRings) * Math.PI;
|
|
106
|
+
const cosLat = Math.cos(lat);
|
|
107
|
+
const sinLat = Math.sin(lat);
|
|
108
|
+
const lonCount = Math.max(1, Math.round(Math.abs(cosLat) * lonDensity));
|
|
109
|
+
for (let lj = 0; lj < lonCount; lj++) {
|
|
110
|
+
const lon = (lj / lonCount) * 2 * Math.PI;
|
|
111
|
+
const [px, py, z] = pt(cosLat * Math.cos(lon), sinLat, cosLat * Math.sin(lon));
|
|
112
|
+
const depth = (z + 1) / 2;
|
|
113
|
+
// the scan: a moving meridian read as a size ripple, not a shine
|
|
114
|
+
const d = angleDelta(lon + t * spin, scan);
|
|
115
|
+
const boost = Math.exp(-(d * d) / 0.18) * Math.max(0, z);
|
|
116
|
+
dots.push({
|
|
117
|
+
x: px,
|
|
118
|
+
y: py,
|
|
119
|
+
z,
|
|
120
|
+
r: ((o.rBase ?? 0.6) + (o.rDepth ?? 1.7) * depth + (o.rBoost ?? 1) * boost) * rs,
|
|
121
|
+
white: (o.inkFar ?? 0.62) - (o.inkSpan ?? 0.54) * depth,
|
|
122
|
+
// dimBase < 1 fades un-scanned dots so the meridian reads clearly
|
|
123
|
+
a: dimBase + (1 - dimBase) * Math.min(1, boost),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
paint(ctx, dots, dark, o.rMin);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// --- Rubik: bands twist in quarter turns, scramble → solve — solving --
|
|
131
|
+
|
|
132
|
+
export const drawRubik: ModeDraw = (ctx, size, t, dark, o) => {
|
|
133
|
+
const cx = size / 2;
|
|
134
|
+
const cy = size / 2;
|
|
135
|
+
const R = (size / 2) * 0.82;
|
|
136
|
+
const pt = makeProj(t * 0.55, 0.35 + 0.1 * Math.sin(t * 0.9), cx, cy, R);
|
|
137
|
+
const rs = radiusScale(size, o.rsPow ?? 0.6);
|
|
138
|
+
const moveCount = o.moveCount ?? 14;
|
|
139
|
+
const moves = makeMoves(moveCount);
|
|
140
|
+
const sc = solveCycle(t, moveCount, 0.42, 1.2);
|
|
141
|
+
|
|
142
|
+
const dots: Dot[] = [];
|
|
143
|
+
const latRings = o.latRings ?? 15;
|
|
144
|
+
const lonDensity = o.lonDensity ?? 40;
|
|
145
|
+
for (let li = 0; li <= latRings; li++) {
|
|
146
|
+
const lat = -Math.PI / 2 + (li / latRings) * Math.PI;
|
|
147
|
+
const cosLat = Math.cos(lat);
|
|
148
|
+
const sinLat = Math.sin(lat);
|
|
149
|
+
const lonCount = Math.max(1, Math.round(Math.abs(cosLat) * lonDensity));
|
|
150
|
+
for (let lj = 0; lj < lonCount; lj++) {
|
|
151
|
+
const lon = (lj / lonCount) * 2 * Math.PI;
|
|
152
|
+
const [x, y, z, inActive] = applyMoves(
|
|
153
|
+
[cosLat * Math.cos(lon), sinLat, cosLat * Math.sin(lon)],
|
|
154
|
+
moves,
|
|
155
|
+
sc,
|
|
156
|
+
);
|
|
157
|
+
const [px, py, zr] = pt(x, y, z);
|
|
158
|
+
const depth = (zr + 1) / 2;
|
|
159
|
+
// the band being turned inks a touch darker — the "hand"
|
|
160
|
+
dots.push({
|
|
161
|
+
x: px,
|
|
162
|
+
y: py,
|
|
163
|
+
z: zr,
|
|
164
|
+
r:
|
|
165
|
+
((o.rBase ?? 0.6) + (o.rDepth ?? 1.7) * depth + (inActive ? (o.rActive ?? 0.3) : 0)) * rs,
|
|
166
|
+
white: (o.inkFar ?? 0.62) - (o.inkSpan ?? 0.54) * depth - (inActive ? 0.14 : 0),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
paint(ctx, dots, dark, o.rMin);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// --- Wave: a waveform rolls through the rings — listening -------------
|
|
174
|
+
|
|
175
|
+
export const drawWave: ModeDraw = (ctx, size, t, dark, o) => {
|
|
176
|
+
const cx = size / 2;
|
|
177
|
+
const cy = size / 2;
|
|
178
|
+
// 0.76 base × 1.15 — the undulation pulls the sphere inward, so wave read
|
|
179
|
+
// ~15% smaller than the other lattice modes; scaled up to match them
|
|
180
|
+
const R = (size / 2) * 0.874;
|
|
181
|
+
const pt = makeProj(t * 0.18, 0.38, cx, cy, 1);
|
|
182
|
+
const rs = radiusScale(size, o.rsPow ?? 0.6);
|
|
183
|
+
|
|
184
|
+
const dots: Dot[] = [];
|
|
185
|
+
const rings = o.rings ?? 15;
|
|
186
|
+
const lonDensity = o.lonDensity ?? 40;
|
|
187
|
+
for (let ri = 0; ri <= rings; ri++) {
|
|
188
|
+
const lat = -Math.PI / 2 + (ri / rings) * Math.PI;
|
|
189
|
+
const cosLat = Math.cos(lat);
|
|
190
|
+
const sinLat = Math.sin(lat);
|
|
191
|
+
// two waves, different tempi — organic, never quite repeating
|
|
192
|
+
const w = 0.62 * Math.sin(t * 2.1 - ri * 0.52) + 0.38 * Math.sin(t * 1.27 + ri * 0.83);
|
|
193
|
+
const rr = R * (0.88 + 0.105 * w);
|
|
194
|
+
const lonCount = Math.max(1, Math.round(Math.abs(cosLat) * lonDensity));
|
|
195
|
+
for (let lj = 0; lj < lonCount; lj++) {
|
|
196
|
+
const lon = (lj / lonCount) * 2 * Math.PI;
|
|
197
|
+
const [px, py, z] = pt(cosLat * Math.cos(lon) * rr, sinLat * rr, cosLat * Math.sin(lon) * rr);
|
|
198
|
+
const depth = (z / R + 1) / 2;
|
|
199
|
+
const crest = Math.max(0, w);
|
|
200
|
+
dots.push({
|
|
201
|
+
x: px,
|
|
202
|
+
y: py,
|
|
203
|
+
z,
|
|
204
|
+
r: ((o.rBase ?? 0.6) + (o.rDepth ?? 1.7) * depth) * (1 + 0.4 * crest) * rs,
|
|
205
|
+
white: 0.66 - 0.56 * depth - 0.1 * crest,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
paint(ctx, dots, dark, o.rMin);
|
|
210
|
+
};
|