@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
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Morph: a dotted outline cycling circle → triangle → square → circle —
|
|
2
|
+
// the "shaping" state. Each shape is a continuous closed path
|
|
3
|
+
// parameterised by arc length (top-centre start, clockwise). Every
|
|
4
|
+
// frame the engine blends the two neighbouring paths, then lays the
|
|
5
|
+
// dots EVENLY along the blended outline — spacing stays uniform at
|
|
6
|
+
// every instant of the morph, holds and transitions alike. Plain
|
|
7
|
+
// circle fills only: no canvas/SVG filters, fully cross-browser.
|
|
8
|
+
|
|
9
|
+
import type { Dot, ModeDraw } from './types';
|
|
10
|
+
import { paint } from './core';
|
|
11
|
+
|
|
12
|
+
type Path = (f: number) => [number, number];
|
|
13
|
+
|
|
14
|
+
function smoothE(x: number): number {
|
|
15
|
+
return x * x * (3 - 2 * x);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function polyPath(verts: ReadonlyArray<readonly [number, number]>): Path {
|
|
19
|
+
const V = verts.length;
|
|
20
|
+
const L: number[] = [];
|
|
21
|
+
let total = 0;
|
|
22
|
+
for (let i = 0; i < V; i++) {
|
|
23
|
+
const a = verts[i];
|
|
24
|
+
const b = verts[(i + 1) % V];
|
|
25
|
+
const l = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
26
|
+
L.push(l);
|
|
27
|
+
total += l;
|
|
28
|
+
}
|
|
29
|
+
return (f) => {
|
|
30
|
+
let target = f * total;
|
|
31
|
+
let i = 0;
|
|
32
|
+
while (target > L[i] && i < V - 1) {
|
|
33
|
+
target -= L[i];
|
|
34
|
+
i++;
|
|
35
|
+
}
|
|
36
|
+
const a = verts[i];
|
|
37
|
+
const b = verts[(i + 1) % V];
|
|
38
|
+
const ff = L[i] ? Math.min(1, target / L[i]) : 0;
|
|
39
|
+
return [a[0] + (b[0] - a[0]) * ff, a[1] + (b[1] - a[1]) * ff];
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const CIRCLE: Path = (f) => {
|
|
44
|
+
const a = -Math.PI / 2 + f * 2 * Math.PI;
|
|
45
|
+
return [Math.cos(a) * 0.24, Math.sin(a) * 0.24];
|
|
46
|
+
};
|
|
47
|
+
const TRIANGLE = polyPath([
|
|
48
|
+
[0.0, -0.26],
|
|
49
|
+
[0.24, 0.16],
|
|
50
|
+
[-0.24, 0.16],
|
|
51
|
+
]);
|
|
52
|
+
// 5-vertex walk so the path STARTS at top-centre like the other shapes
|
|
53
|
+
const SQUARE = polyPath([
|
|
54
|
+
[0, -0.2],
|
|
55
|
+
[0.2, -0.2],
|
|
56
|
+
[0.2, 0.2],
|
|
57
|
+
[-0.2, 0.2],
|
|
58
|
+
[-0.2, -0.2],
|
|
59
|
+
]);
|
|
60
|
+
const CYCLE: Path[] = [CIRCLE, TRIANGLE, SQUARE];
|
|
61
|
+
|
|
62
|
+
// low floor keeps sparse outlines possible while never degenerating
|
|
63
|
+
function morphN(d: number): number {
|
|
64
|
+
return Math.max(6, Math.round(34 * d));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const HOLD = 1.4;
|
|
68
|
+
const MORPH = 0.9;
|
|
69
|
+
const SEG = HOLD + MORPH;
|
|
70
|
+
|
|
71
|
+
// This state was tuned in inkform, which paints it through a blur +
|
|
72
|
+
// threshold "goo" filter; we draw plain circles instead, since `ctx.filter`
|
|
73
|
+
// and SVG filter refs are not safe to rely on across Chrome / Safari /
|
|
74
|
+
// Firefox. The dot GEOMETRY is identical either way — the threshold just
|
|
75
|
+
// yields a hard edge where a plain fill has an antialiased one, so these
|
|
76
|
+
// dots read a touch softer than inkform's. Don't "correct" for that by
|
|
77
|
+
// shrinking the radius: it makes the mark genuinely smaller than the tuning.
|
|
78
|
+
|
|
79
|
+
export const drawMorph: ModeDraw = (ctx, size, t, dark, o) => {
|
|
80
|
+
const K = CYCLE.length;
|
|
81
|
+
const tc = t % (SEG * K);
|
|
82
|
+
const k = Math.floor(tc / SEG);
|
|
83
|
+
const local = tc - k * SEG;
|
|
84
|
+
const m = local > HOLD ? smoothE((local - HOLD) / MORPH) : 0;
|
|
85
|
+
const sprd = o.spread ?? 1;
|
|
86
|
+
|
|
87
|
+
// blend the two shape PATHS at m, then measure the blended outline
|
|
88
|
+
const pA = CYCLE[k];
|
|
89
|
+
const pB = CYCLE[(k + 1) % K];
|
|
90
|
+
const M = 160;
|
|
91
|
+
const pts: Array<[number, number]> = [];
|
|
92
|
+
for (let i = 0; i < M; i++) {
|
|
93
|
+
const f = i / M;
|
|
94
|
+
const a = pA(f);
|
|
95
|
+
const b = pB(f);
|
|
96
|
+
pts.push([(a[0] + (b[0] - a[0]) * m) * sprd, (a[1] + (b[1] - a[1]) * m) * sprd]);
|
|
97
|
+
}
|
|
98
|
+
const L: number[] = [];
|
|
99
|
+
let total = 0;
|
|
100
|
+
for (let i = 0; i < M; i++) {
|
|
101
|
+
const a = pts[i];
|
|
102
|
+
const b = pts[(i + 1) % M];
|
|
103
|
+
const l = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
104
|
+
L.push(l);
|
|
105
|
+
total += l;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// dot radius depends ONLY on rDot (the size knob); the count sets the
|
|
109
|
+
// gaps. Formed shapes breathe a little (uniform pulse).
|
|
110
|
+
const n = morphN(o.iconD ?? 1);
|
|
111
|
+
const re = (o.rDot ?? 0.021) * 1.35 * sprd;
|
|
112
|
+
const pulse = 1 + 0.02 * Math.sin(local * 3.1);
|
|
113
|
+
|
|
114
|
+
const dots: Dot[] = [];
|
|
115
|
+
const c2 = size / 2;
|
|
116
|
+
let seg = 0;
|
|
117
|
+
let acc = 0;
|
|
118
|
+
for (let k2 = 0; k2 < n; k2++) {
|
|
119
|
+
const target = (k2 / n) * total;
|
|
120
|
+
while (acc + L[seg] < target && seg < M - 1) {
|
|
121
|
+
acc += L[seg];
|
|
122
|
+
seg++;
|
|
123
|
+
}
|
|
124
|
+
const a = pts[seg];
|
|
125
|
+
const b = pts[(seg + 1) % M];
|
|
126
|
+
const f = L[seg] ? Math.min(1, (target - acc) / L[seg]) : 0;
|
|
127
|
+
const x = (a[0] + (b[0] - a[0]) * f) * pulse;
|
|
128
|
+
const y = (a[1] + (b[1] - a[1]) * f) * pulse;
|
|
129
|
+
dots.push({
|
|
130
|
+
x: c2 + x * size,
|
|
131
|
+
y: c2 + y * size,
|
|
132
|
+
z: 0,
|
|
133
|
+
r: Math.max(0.35, re * size),
|
|
134
|
+
white: 0.1,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
paint(ctx, dots, dark, o.rMin);
|
|
138
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Orbits: particles on tilted orbits — the "working" state. No nucleus
|
|
2
|
+
// (the tuned preset runs coreless): just ghost paths and the particles
|
|
3
|
+
// doing the work.
|
|
4
|
+
|
|
5
|
+
import type { Dot, ModeDraw } from './types';
|
|
6
|
+
import { hashD, makeProj, paint, radiusScale } from './core';
|
|
7
|
+
|
|
8
|
+
export const drawOrbits: ModeDraw = (ctx, size, t, dark, o) => {
|
|
9
|
+
const cx = size / 2;
|
|
10
|
+
const cy = size / 2;
|
|
11
|
+
const R = (size / 2) * 0.82;
|
|
12
|
+
const pt = makeProj(t * 0.12, 0.3, cx, cy, 1);
|
|
13
|
+
const rs = radiusScale(size, o.rsPow ?? 0.6);
|
|
14
|
+
|
|
15
|
+
const dots: Dot[] = [];
|
|
16
|
+
const orbitN = o.orbitN ?? 12;
|
|
17
|
+
const ghostN = o.ghostN ?? 40;
|
|
18
|
+
const particles = o.particles ?? 3;
|
|
19
|
+
|
|
20
|
+
// orbits: each a tilted circle — a ghost path + running particles
|
|
21
|
+
for (let orb = 0; orb < orbitN; orb++) {
|
|
22
|
+
const h1 = hashD(orb, 1.7);
|
|
23
|
+
const h2 = hashD(orb, 5.2);
|
|
24
|
+
const h3 = hashD(orb, 8.9);
|
|
25
|
+
const ro = R * (0.45 + 0.52 * h1);
|
|
26
|
+
const th = h1 * 2 * Math.PI;
|
|
27
|
+
const phi = Math.acos(2 * h2 - 1);
|
|
28
|
+
// orbit plane basis (u, v ⟂ normal n)
|
|
29
|
+
const nx = Math.sin(phi) * Math.cos(th);
|
|
30
|
+
const ny = Math.cos(phi);
|
|
31
|
+
const nz = Math.sin(phi) * Math.sin(th);
|
|
32
|
+
let ux = -ny;
|
|
33
|
+
let uy = nx;
|
|
34
|
+
const uz = 0;
|
|
35
|
+
const ul = Math.max(1e-6, Math.sqrt(ux * ux + uy * uy));
|
|
36
|
+
ux /= ul;
|
|
37
|
+
uy /= ul;
|
|
38
|
+
const vx = ny * uz - nz * uy;
|
|
39
|
+
const vy = nz * ux - nx * uz;
|
|
40
|
+
const vz = nx * uy - ny * ux;
|
|
41
|
+
const speed = (0.25 + 0.55 * h3) * (h3 > 0.5 ? 1 : -1);
|
|
42
|
+
|
|
43
|
+
// ghost path
|
|
44
|
+
for (let k = 0; k < ghostN; k++) {
|
|
45
|
+
const a = (k / ghostN) * 2 * Math.PI;
|
|
46
|
+
const [px, py, z] = pt(
|
|
47
|
+
(ux * Math.cos(a) + vx * Math.sin(a)) * ro,
|
|
48
|
+
(uy * Math.cos(a) + vy * Math.sin(a)) * ro,
|
|
49
|
+
(uz * Math.cos(a) + vz * Math.sin(a)) * ro,
|
|
50
|
+
);
|
|
51
|
+
const depth = (z / ro + 1) / 2;
|
|
52
|
+
dots.push({
|
|
53
|
+
x: px,
|
|
54
|
+
y: py,
|
|
55
|
+
z,
|
|
56
|
+
r: (o.ghostR ?? 0.9) * rs,
|
|
57
|
+
white: 0.72,
|
|
58
|
+
a: (o.ghostA ?? 0.5) * (0.4 + 0.6 * depth),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
// the particles doing the work
|
|
62
|
+
for (let m = 0; m < particles; m++) {
|
|
63
|
+
const a = t * speed + (m / particles) * 2 * Math.PI + h2 * 6;
|
|
64
|
+
const [px, py, z] = pt(
|
|
65
|
+
(ux * Math.cos(a) + vx * Math.sin(a)) * ro,
|
|
66
|
+
(uy * Math.cos(a) + vy * Math.sin(a)) * ro,
|
|
67
|
+
(uz * Math.cos(a) + vz * Math.sin(a)) * ro,
|
|
68
|
+
);
|
|
69
|
+
const depth = (z / ro + 1) / 2;
|
|
70
|
+
dots.push({
|
|
71
|
+
x: px,
|
|
72
|
+
y: py,
|
|
73
|
+
z,
|
|
74
|
+
r: ((o.partR ?? 1.2) + (o.partRDepth ?? 1.6) * depth) * rs,
|
|
75
|
+
white: 0.3 - 0.22 * depth,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
paint(ctx, dots, dark, o.rMin);
|
|
80
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Density profiles + the multiplier machinery that scales them. The base
|
|
2
|
+
// rows are inkform's `fine` profiles; each shipped preset (state × size)
|
|
3
|
+
// applies count / radius multipliers on top, resolved once per mount.
|
|
4
|
+
|
|
5
|
+
export interface ModeOpts {
|
|
6
|
+
[key: string]: number | undefined;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// 2-D lattices (rings × dots-per-ring) come in pairs — each side takes
|
|
10
|
+
// √scale so the TOTAL dot count scales by `scale`; flat lists scale
|
|
11
|
+
// linearly. `iconD` sets the morph outline's sampling density.
|
|
12
|
+
const COUNT_PAIRS: ReadonlyArray<readonly [string, string]> = [
|
|
13
|
+
['latRings', 'lonDensity'],
|
|
14
|
+
['rings', 'lonDensity'],
|
|
15
|
+
['lanes', 'segs'],
|
|
16
|
+
];
|
|
17
|
+
const COUNT_KEYS = ['orbitN', 'ghostN', 'nodeN', 'strandN', 'signals'] as const;
|
|
18
|
+
const ICON_DENSITY_KEYS = ['iconD'] as const;
|
|
19
|
+
|
|
20
|
+
// Every key that sets a dot's rendered radius — scaling all of them keeps
|
|
21
|
+
// a dot's near/far falloff intact while shrinking or growing the mark.
|
|
22
|
+
const RADIUS_KEYS = [
|
|
23
|
+
'rBase',
|
|
24
|
+
'rDepth',
|
|
25
|
+
'rActive',
|
|
26
|
+
'rDot',
|
|
27
|
+
'ghostR',
|
|
28
|
+
'partR',
|
|
29
|
+
'partRDepth',
|
|
30
|
+
'nodeR',
|
|
31
|
+
'nodeRDepth',
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
export function scaleCounts(opts: ModeOpts, scale: number): ModeOpts {
|
|
35
|
+
const out: ModeOpts = { ...opts };
|
|
36
|
+
const done = new Set<string>();
|
|
37
|
+
const rt = Math.sqrt(scale);
|
|
38
|
+
for (const [a, b] of COUNT_PAIRS) {
|
|
39
|
+
const va = out[a];
|
|
40
|
+
const vb = out[b];
|
|
41
|
+
if (va != null && vb != null && !done.has(a) && !done.has(b)) {
|
|
42
|
+
out[a] = Math.max(2, Math.round(va * rt));
|
|
43
|
+
out[b] = Math.max(2, Math.round(vb * rt));
|
|
44
|
+
done.add(a);
|
|
45
|
+
done.add(b);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
for (const k of COUNT_KEYS) {
|
|
49
|
+
const v = out[k];
|
|
50
|
+
// 0 means the mode opted out of that layer entirely (ring has no ghost
|
|
51
|
+
// sphere) — scaling must not resurrect it as a single stray dot
|
|
52
|
+
if (v != null && v !== 0 && !done.has(k)) out[k] = Math.max(1, Math.round(v * scale));
|
|
53
|
+
}
|
|
54
|
+
for (const k of ICON_DENSITY_KEYS) {
|
|
55
|
+
const v = out[k];
|
|
56
|
+
if (v != null) out[k] = Math.max(0.02, v * scale);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function scaleRadii(opts: ModeOpts, scale: number): ModeOpts {
|
|
62
|
+
const out: ModeOpts = { ...opts };
|
|
63
|
+
for (const k of RADIUS_KEYS) {
|
|
64
|
+
const v = out[k];
|
|
65
|
+
if (v != null) out[k] = v * scale;
|
|
66
|
+
}
|
|
67
|
+
// remember the multiplier itself — spacing-derived radii (the morph
|
|
68
|
+
// outline) use it, since they aren't based on any single radius key
|
|
69
|
+
out.rSizeMul = (out.rSizeMul ?? 1) * scale;
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Base (fine) profiles per mode, before preset multipliers. */
|
|
74
|
+
export const BASE_PROFILES: Record<string, ModeOpts> = {
|
|
75
|
+
globe: {
|
|
76
|
+
latRings: 17,
|
|
77
|
+
lonDensity: 44,
|
|
78
|
+
rBase: 0.6,
|
|
79
|
+
rDepth: 1.7,
|
|
80
|
+
rBoost: 1.0,
|
|
81
|
+
inkFar: 0.62,
|
|
82
|
+
inkSpan: 0.54,
|
|
83
|
+
rsPow: 0.6,
|
|
84
|
+
rMin: 0.3,
|
|
85
|
+
},
|
|
86
|
+
orbits: {
|
|
87
|
+
orbitN: 12,
|
|
88
|
+
ghostN: 40,
|
|
89
|
+
ghostR: 0.9,
|
|
90
|
+
ghostA: 0.5,
|
|
91
|
+
particles: 3,
|
|
92
|
+
partR: 1.2,
|
|
93
|
+
partRDepth: 1.6,
|
|
94
|
+
rsPow: 0.6,
|
|
95
|
+
rMin: 0.3,
|
|
96
|
+
},
|
|
97
|
+
rubik: {
|
|
98
|
+
latRings: 15,
|
|
99
|
+
lonDensity: 40,
|
|
100
|
+
moveCount: 14,
|
|
101
|
+
rBase: 0.6,
|
|
102
|
+
rDepth: 1.7,
|
|
103
|
+
rActive: 0.3,
|
|
104
|
+
inkFar: 0.62,
|
|
105
|
+
inkSpan: 0.54,
|
|
106
|
+
rsPow: 0.6,
|
|
107
|
+
rMin: 0.3,
|
|
108
|
+
},
|
|
109
|
+
wave: {
|
|
110
|
+
rings: 15,
|
|
111
|
+
lonDensity: 40,
|
|
112
|
+
rBase: 0.6,
|
|
113
|
+
rDepth: 1.7,
|
|
114
|
+
rsPow: 0.6,
|
|
115
|
+
rMin: 0.3,
|
|
116
|
+
},
|
|
117
|
+
web: {
|
|
118
|
+
nodeN: 30,
|
|
119
|
+
thr: 0.72,
|
|
120
|
+
signals: 5,
|
|
121
|
+
nodeR: 1.4,
|
|
122
|
+
nodeRDepth: 1.8,
|
|
123
|
+
lineW: 0.8,
|
|
124
|
+
rsPow: 0.6,
|
|
125
|
+
rMin: 0.3,
|
|
126
|
+
},
|
|
127
|
+
braid: {
|
|
128
|
+
strandN: 52,
|
|
129
|
+
turns: 3.0,
|
|
130
|
+
ghostN: 150,
|
|
131
|
+
rBase: 1.2,
|
|
132
|
+
rDepth: 1.8,
|
|
133
|
+
rsPow: 0.6,
|
|
134
|
+
rMin: 0.3,
|
|
135
|
+
},
|
|
136
|
+
ribbon: {
|
|
137
|
+
lanes: 5,
|
|
138
|
+
segs: 88,
|
|
139
|
+
ghostN: 150,
|
|
140
|
+
rBase: 1.1,
|
|
141
|
+
rDepth: 1.7,
|
|
142
|
+
rsPow: 0.6,
|
|
143
|
+
rMin: 0.3,
|
|
144
|
+
},
|
|
145
|
+
// ring shares ribbon's painter; faceOn cancels the camera tilt and moves
|
|
146
|
+
// the undulation onto the radius, and there is no ghost sphere behind it
|
|
147
|
+
ring: {
|
|
148
|
+
lanes: 5,
|
|
149
|
+
segs: 88,
|
|
150
|
+
ghostN: 0,
|
|
151
|
+
faceOn: 1,
|
|
152
|
+
rBase: 1.1,
|
|
153
|
+
rDepth: 1.7,
|
|
154
|
+
rsPow: 0.6,
|
|
155
|
+
rMin: 0.3,
|
|
156
|
+
},
|
|
157
|
+
morph: {
|
|
158
|
+
rDot: 0.021,
|
|
159
|
+
iconD: 1,
|
|
160
|
+
rMin: 0.25,
|
|
161
|
+
},
|
|
162
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Mode key → frame painter. Kept separate from the presets so tree
|
|
2
|
+
// shaking can in principle drop unused modes in custom builds.
|
|
3
|
+
|
|
4
|
+
import type { ModeKey } from '../presets';
|
|
5
|
+
import type { ModeDraw } from './types';
|
|
6
|
+
import { drawBraid } from './braid';
|
|
7
|
+
import { drawGlobe, drawRubik, drawWave } from './lattice';
|
|
8
|
+
import { drawMorph } from './morph';
|
|
9
|
+
import { drawOrbits } from './orbits';
|
|
10
|
+
import { drawRibbon } from './ribbon';
|
|
11
|
+
import { drawWeb } from './web';
|
|
12
|
+
|
|
13
|
+
export const MODE_DRAWS: Record<ModeKey, ModeDraw> = {
|
|
14
|
+
orbits: drawOrbits,
|
|
15
|
+
globe: drawGlobe,
|
|
16
|
+
rubik: drawRubik,
|
|
17
|
+
wave: drawWave,
|
|
18
|
+
web: drawWeb,
|
|
19
|
+
braid: drawBraid,
|
|
20
|
+
ribbon: drawRibbon,
|
|
21
|
+
// ring shares ribbon's painter — the `faceOn` profile flag switches it
|
|
22
|
+
ring: drawRibbon,
|
|
23
|
+
morph: drawMorph,
|
|
24
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Ribbon: an undulating sash of parallel strands rides a great circle —
|
|
2
|
+
// the "composing" state. The tuned preset freezes the 3D tumble
|
|
3
|
+
// (spin 0), leaving the traveling undulation on a fixed band.
|
|
4
|
+
//
|
|
5
|
+
// The same painter also drives "breathing" (ring), via the `faceOn` flag:
|
|
6
|
+
// a face-on circle whose radius — not its out-of-plane offset — undulates,
|
|
7
|
+
// so it reads as a ring slowly morphing rather than a sash in orbit.
|
|
8
|
+
|
|
9
|
+
import type { Dot, ModeDraw } from './types';
|
|
10
|
+
import { fibDir, makeProj, paint, radiusScale } from './core';
|
|
11
|
+
|
|
12
|
+
export const drawRibbon: ModeDraw = (ctx, size, t, dark, o) => {
|
|
13
|
+
const cx = size / 2;
|
|
14
|
+
const cy = size / 2;
|
|
15
|
+
const R = (size / 2) * 0.78;
|
|
16
|
+
// spin scales the 3D tumble; spin=0 freezes the band's orientation,
|
|
17
|
+
// leaving only the traveling undulation
|
|
18
|
+
const spin = o.spin ?? 1;
|
|
19
|
+
const camTilt = 0.3;
|
|
20
|
+
const pt = makeProj(t * 0.1 * spin, camTilt, cx, cy, 1);
|
|
21
|
+
const rs = radiusScale(size, o.rsPow ?? 0.6);
|
|
22
|
+
|
|
23
|
+
const dots: Dot[] = [];
|
|
24
|
+
const ghostN = o.ghostN ?? 150;
|
|
25
|
+
for (let i = 0; i < ghostN; i++) {
|
|
26
|
+
const d = fibDir(i, ghostN);
|
|
27
|
+
const [px, py, z] = pt(d[0] * R, d[1] * R, d[2] * R);
|
|
28
|
+
const depth = (z / R + 1) / 2;
|
|
29
|
+
dots.push({ x: px, y: py, z, r: 0.8 * rs, white: 0.78, a: 0.1 + 0.22 * depth });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The band plane, precessing (frozen when spin=0). The projection squashes
|
|
33
|
+
// the band's great circle vertically by cos(ta + camTilt); face-on sets
|
|
34
|
+
// ta = -camTilt so that term is 1 and the band reads as a true circle
|
|
35
|
+
// rather than ribbon's tilted ellipse.
|
|
36
|
+
const ya = t * 0.24 * spin;
|
|
37
|
+
const ta = o.faceOn ? -camTilt : 0.55 + 0.3 * Math.sin(t * 0.18) * spin;
|
|
38
|
+
const ux = Math.cos(ya);
|
|
39
|
+
const uy = 0;
|
|
40
|
+
const uz = Math.sin(ya);
|
|
41
|
+
const vx = -uz * Math.sin(ta);
|
|
42
|
+
const vy = Math.cos(ta);
|
|
43
|
+
const vz = ux * Math.sin(ta);
|
|
44
|
+
// plane normal n = u × v
|
|
45
|
+
const nx = uy * vz - uz * vy;
|
|
46
|
+
const ny = uz * vx - ux * vz;
|
|
47
|
+
const nz = ux * vy - uy * vx;
|
|
48
|
+
|
|
49
|
+
// Radial lobes swell past R, so pull the base radius in by (most of) the
|
|
50
|
+
// wobble amplitude. The silhouette then stays inside the frame however far
|
|
51
|
+
// the deformation is pushed, while lobes keep getting deeper relative to
|
|
52
|
+
// the mean radius.
|
|
53
|
+
const wobAmp = 0.23 * (o.wobMul ?? 1);
|
|
54
|
+
const baseR = o.faceOn ? R / (1 + 0.85 * wobAmp) : R;
|
|
55
|
+
|
|
56
|
+
const baseLanes = o.lanes ?? 5;
|
|
57
|
+
const segs = o.segs ?? 88;
|
|
58
|
+
const lanes = Math.max(1, Math.round(baseLanes * (o.bandMul ?? 1)));
|
|
59
|
+
for (let w = 0; w < lanes; w++) {
|
|
60
|
+
const laneOff = (w - (lanes - 1) / 2) * 0.075;
|
|
61
|
+
const edge = Math.abs(w - (lanes - 1) / 2) / Math.max(1, (lanes - 1) / 2);
|
|
62
|
+
for (let k = 0; k < segs; k++) {
|
|
63
|
+
const a = (k / segs) * 2 * Math.PI;
|
|
64
|
+
// the undulation: two traveling waves along the band; wobMul
|
|
65
|
+
// scales the deformation — 0 is a clean band
|
|
66
|
+
const wob =
|
|
67
|
+
(0.16 * Math.sin(a * 3 - t * 1.7 + w * 0.22) + 0.07 * Math.sin(a * 5 + t * 1.1)) *
|
|
68
|
+
(o.wobMul ?? 1);
|
|
69
|
+
// A normal-direction wobble is cancelled by the re-normalisation below:
|
|
70
|
+
// the point lands back on the sphere, so the silhouette is pinned at R
|
|
71
|
+
// and the deformation can only ever pull dots inward. Face-on instead
|
|
72
|
+
// modulates the in-plane RADIUS, so lobes genuinely swell outward and
|
|
73
|
+
// pinch inward. Ribbon keeps the original out-of-plane sash wobble.
|
|
74
|
+
const radial = o.faceOn ? 1 + wob : 1;
|
|
75
|
+
const off = o.faceOn ? laneOff : laneOff + wob;
|
|
76
|
+
const x = ux * Math.cos(a) + vx * Math.sin(a) + nx * off;
|
|
77
|
+
const y = uy * Math.cos(a) + vy * Math.sin(a) + ny * off;
|
|
78
|
+
const z = uz * Math.cos(a) + vz * Math.sin(a) + nz * off;
|
|
79
|
+
const l = Math.sqrt(x * x + y * y + z * z);
|
|
80
|
+
const rr = baseR * radial;
|
|
81
|
+
const [px, py, zr] = pt((x / l) * rr, (y / l) * rr, (z / l) * rr);
|
|
82
|
+
const depth = (zr / R + 1) / 2;
|
|
83
|
+
dots.push({
|
|
84
|
+
x: px,
|
|
85
|
+
y: py,
|
|
86
|
+
z: zr,
|
|
87
|
+
r: ((o.rBase ?? 1.1) + (o.rDepth ?? 1.7) * depth) * (1 - 0.25 * edge) * rs,
|
|
88
|
+
white: 0.52 - 0.44 * depth + 0.18 * edge,
|
|
89
|
+
a: 0.4 + 0.6 * depth,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
paint(ctx, dots, dark, o.rMin);
|
|
94
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Engine-level contracts shared by every mode implementation.
|
|
2
|
+
|
|
3
|
+
import type { ModeOpts } from './profiles';
|
|
4
|
+
|
|
5
|
+
export type { Dot, Line } from './core';
|
|
6
|
+
|
|
7
|
+
/** One frame painter: draws a mode into a 2D context at CSS-px `size`. */
|
|
8
|
+
export type ModeDraw = (
|
|
9
|
+
ctx: CanvasRenderingContext2D,
|
|
10
|
+
size: number,
|
|
11
|
+
t: number,
|
|
12
|
+
dark: boolean,
|
|
13
|
+
opts: ModeOpts,
|
|
14
|
+
) => void;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Web: a constellation wires itself — the "connecting" state. Nodes drift
|
|
2
|
+
// on the sphere under slow value noise; any pair closer than `thr` grows an
|
|
3
|
+
// edge, and bright packets run along randomly re-picked node pairs.
|
|
4
|
+
|
|
5
|
+
import type { Dot, Line, ModeDraw } from './types';
|
|
6
|
+
import {
|
|
7
|
+
fibDir,
|
|
8
|
+
frac,
|
|
9
|
+
hashD,
|
|
10
|
+
lerp,
|
|
11
|
+
makeProj,
|
|
12
|
+
paint,
|
|
13
|
+
paintLines,
|
|
14
|
+
radiusScale,
|
|
15
|
+
vnoise,
|
|
16
|
+
} from './core';
|
|
17
|
+
|
|
18
|
+
export const drawWeb: ModeDraw = (ctx, size, t, dark, o) => {
|
|
19
|
+
const cx = size / 2;
|
|
20
|
+
const cy = size / 2;
|
|
21
|
+
const R = (size / 2) * 0.8 * (o.spread ?? 1);
|
|
22
|
+
// note the projector carries the radius as its scale, so node vectors stay
|
|
23
|
+
// unit-length and distances below are in unit-sphere space
|
|
24
|
+
const pt = makeProj(t * 0.12, 0.32, cx, cy, R);
|
|
25
|
+
const rs = radiusScale(size, o.rsPow ?? 0.6);
|
|
26
|
+
|
|
27
|
+
const nodeN = o.nodeN ?? 30;
|
|
28
|
+
const thr = o.thr ?? 0.72;
|
|
29
|
+
const nodeR = o.nodeR ?? 1.4;
|
|
30
|
+
const nodeRDepth = o.nodeRDepth ?? 1.8;
|
|
31
|
+
|
|
32
|
+
// nodes: fib lattice + slow noise wander, renormalised to the surface
|
|
33
|
+
const nodes: Array<[number, number, number]> = [];
|
|
34
|
+
for (let i = 0; i < nodeN; i++) {
|
|
35
|
+
const d = fibDir(i, nodeN);
|
|
36
|
+
const x = d[0] + 0.3 * (vnoise(i * 0.31 + 9, t * 0.24) - 0.5) * 2;
|
|
37
|
+
const y = d[1] + 0.3 * (vnoise(i * 0.53 + 27, t * 0.21) - 0.5) * 2;
|
|
38
|
+
const z = d[2] + 0.3 * (vnoise(i * 0.77 + 55, t * 0.27) - 0.5) * 2;
|
|
39
|
+
const l = Math.sqrt(x * x + y * y + z * z);
|
|
40
|
+
nodes.push([x / l, y / l, z / l]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const lines: Line[] = [];
|
|
44
|
+
const dots: Dot[] = [];
|
|
45
|
+
|
|
46
|
+
// edges between close neighbours, alpha by proximity + depth
|
|
47
|
+
for (let i = 0; i < nodeN; i++) {
|
|
48
|
+
for (let j = i + 1; j < nodeN; j++) {
|
|
49
|
+
const dx = nodes[i][0] - nodes[j][0];
|
|
50
|
+
const dy = nodes[i][1] - nodes[j][1];
|
|
51
|
+
const dz = nodes[i][2] - nodes[j][2];
|
|
52
|
+
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
53
|
+
if (dist >= thr) continue;
|
|
54
|
+
const [x1, y1, z1] = pt(nodes[i][0], nodes[i][1], nodes[i][2]);
|
|
55
|
+
const [x2, y2, z2] = pt(nodes[j][0], nodes[j][1], nodes[j][2]);
|
|
56
|
+
const depth = ((z1 + z2) / 2 + 1) / 2;
|
|
57
|
+
lines.push({
|
|
58
|
+
x1,
|
|
59
|
+
y1,
|
|
60
|
+
x2,
|
|
61
|
+
y2,
|
|
62
|
+
white: 0.42,
|
|
63
|
+
a: (1 - dist / thr) * (0.3 + 0.55 * depth),
|
|
64
|
+
w: Math.max(0.6, (o.lineW ?? 0.8) * rs),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (let i = 0; i < nodeN; i++) {
|
|
70
|
+
const [px, py, z] = pt(nodes[i][0], nodes[i][1], nodes[i][2]);
|
|
71
|
+
const depth = (z + 1) / 2;
|
|
72
|
+
const pulse = 1 + 0.25 * Math.sin(t * 1.4 + i * 2.7);
|
|
73
|
+
dots.push({
|
|
74
|
+
x: px,
|
|
75
|
+
y: py,
|
|
76
|
+
z,
|
|
77
|
+
r: (nodeR + nodeRDepth * depth) * pulse * rs,
|
|
78
|
+
white: 0.55 - 0.45 * depth,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// signals: bright packets running between paired nodes
|
|
83
|
+
const signals = o.signals ?? 5;
|
|
84
|
+
for (let s = 0; s < signals; s++) {
|
|
85
|
+
const seg = Math.floor(t * 0.55 + s * 7.31);
|
|
86
|
+
const a = Math.floor(hashD(seg, s * 3.1 + 1.7) * nodeN);
|
|
87
|
+
const b = Math.floor(hashD(seg, s * 5.7 + 4.2) * nodeN);
|
|
88
|
+
if (a === b) continue;
|
|
89
|
+
const f = frac(t * 0.55 + s * 7.31);
|
|
90
|
+
const x = lerp(nodes[a][0], nodes[b][0], f);
|
|
91
|
+
const y = lerp(nodes[a][1], nodes[b][1], f);
|
|
92
|
+
const z = lerp(nodes[a][2], nodes[b][2], f);
|
|
93
|
+
const l = Math.max(1e-6, Math.sqrt(x * x + y * y + z * z));
|
|
94
|
+
const [px, py, zr] = pt(x / l, y / l, z / l);
|
|
95
|
+
const depth = (zr + 1) / 2;
|
|
96
|
+
dots.push({
|
|
97
|
+
x: px,
|
|
98
|
+
y: py,
|
|
99
|
+
z: zr,
|
|
100
|
+
r: (nodeR * 1.5 + nodeRDepth * depth) * rs,
|
|
101
|
+
white: 0.05,
|
|
102
|
+
a: 0.5 + 0.5 * depth,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
paintLines(ctx, lines, dark);
|
|
107
|
+
paint(ctx, dots, dark, o.rMin);
|
|
108
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { ThinkingOrb } from './ThinkingOrb.tsrx';
|
|
2
|
+
|
|
3
|
+
export type { ThinkingOrbProps, OrbState, OrbSize, OrbTheme } from './types';
|
|
4
|
+
|
|
5
|
+
export { resolvePreset, STATE_TO_MODE, type ModeKey, type Resolved } from './presets';
|
|
6
|
+
export { MODE_DRAWS } from './engine/registry';
|