@juspay/svelte-ui-components 4.32.1 → 4.33.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.
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The single place that maps a public `OrbState` name to the mode function
3
+ * that draws it. `ThinkingOrb.svelte` only ever imports `MODES`, never a mode
4
+ * file directly, so adding a state is a one-line change here plus a new
5
+ * export in whichever mode file it belongs to.
6
+ */
7
+ import type { OrbState } from '../../properties';
8
+ import type { ModeFn } from '../types';
9
+ export declare const MODES: Record<OrbState, ModeFn>;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The single place that maps a public `OrbState` name to the mode function
3
+ * that draws it. `ThinkingOrb.svelte` only ever imports `MODES`, never a mode
4
+ * file directly, so adding a state is a one-line change here plus a new
5
+ * export in whichever mode file it belongs to.
6
+ */
7
+ import { listening, searching, solving, working } from './sphere';
8
+ import { connecting, weaving } from './network';
9
+ import { breathing, composing, shaping } from './form';
10
+ export const MODES = {
11
+ working,
12
+ searching,
13
+ solving,
14
+ listening,
15
+ connecting,
16
+ weaving,
17
+ composing,
18
+ breathing,
19
+ shaping
20
+ };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The two "network" modes: `connecting` draws a small graph of nodes on a
3
+ * sphere with pulses running its edges, and `weaving` draws three strands
4
+ * braided around a sphere. Both are built from the same handful of points
5
+ * placed on a sphere and rotated over time, so they share their sizing and
6
+ * projection setup below.
7
+ */
8
+ import type { ModeFn } from '../types';
9
+ /** A few nodes on a sphere; lines to near neighbours carry small travelling pulses. */
10
+ export declare const connecting: ModeFn;
11
+ /** Three strands wrapped around a sphere, crossing as they spiral from top to bottom. */
12
+ export declare const weaving: ModeFn;
@@ -0,0 +1,181 @@
1
+ /**
2
+ * The two "network" modes: `connecting` draws a small graph of nodes on a
3
+ * sphere with pulses running its edges, and `weaving` draws three strands
4
+ * braided around a sphere. Both are built from the same handful of points
5
+ * placed on a sphere and rotated over time, so they share their sizing and
6
+ * projection setup below.
7
+ */
8
+ import { hashUnit, modeSizing, project, rotateX, rotateY, sampleSphere } from '../space';
9
+ const clamp01 = (value) => Math.max(0, Math.min(1, value));
10
+ /** Both modes read as a sphere of this radius, centred in the canvas -- the spec's ~40% of `size`. */
11
+ const SPHERE_RADIUS_RATIO = 0.4;
12
+ /** The count/radius scaler, canvas centre and world sphere radius every mode below draws into. */
13
+ const sphereGeometry = (ctx) => ({
14
+ sizing: modeSizing(ctx.size, ctx.density, ctx.dotScale),
15
+ originPx: ctx.size / 2,
16
+ sphereRadius: ctx.size * SPHERE_RADIUS_RATIO
17
+ });
18
+ const squaredDistance = (a, b) => {
19
+ const dx = a.x - b.x;
20
+ const dy = a.y - b.y;
21
+ const dz = a.z - b.z;
22
+ return dx * dx + dy * dy + dz * dz;
23
+ };
24
+ // ---------------------------------------------------------------------------
25
+ // connecting -- a few nodes on a sphere, linked to their nearest neighbours,
26
+ // with small bright pulses running along the links.
27
+ // ---------------------------------------------------------------------------
28
+ const NODE_BASE_COUNT = 16;
29
+ const MIN_NODE_COUNT = 6;
30
+ const NEIGHBOR_COUNT = 3;
31
+ const NODE_BASE_RADIUS = 3.4;
32
+ const PULSE_BASE_RADIUS = 2;
33
+ const EDGE_BASE_WIDTH = 1.1;
34
+ const NODE_ROTATE_SPEED = 0.35;
35
+ const NODE_TILT = 0.45;
36
+ const EDGE_FLICKER_SPEED = 0.5;
37
+ const EDGE_MIN_ALPHA = 0.25;
38
+ const PULSE_SPEED = 0.6;
39
+ /**
40
+ * Every unordered pair `(i, j)` where `j` is one of `i`'s `perNode` closest
41
+ * points (by straight-line distance), deduplicated so a mutual pair is only
42
+ * one edge. `points` are pre-rotation world positions: a rigid rotation
43
+ * never changes which points are nearest, so the graph itself can be built
44
+ * once per frame from them and stay stable as the sphere spins.
45
+ */
46
+ const nearestNeighborEdges = (points, perNode) => {
47
+ const indices = points.map((_, index) => index);
48
+ const candidates = indices.flatMap((i) => indices
49
+ .filter((j) => j !== i)
50
+ .sort((a, b) => squaredDistance(points[i], points[a]) - squaredDistance(points[i], points[b]))
51
+ .slice(0, perNode)
52
+ .map((j) => (i < j ? [i, j] : [j, i])));
53
+ return candidates.filter(([a, b], index) => candidates.findIndex(([x, y]) => x === a && y === b) === index);
54
+ };
55
+ /** A few nodes on a sphere; lines to near neighbours carry small travelling pulses. */
56
+ export const connecting = (t, ctx) => {
57
+ const { sizing, originPx, sphereRadius } = sphereGeometry(ctx);
58
+ const nodeCount = Math.max(MIN_NODE_COUNT, sizing.count(NODE_BASE_COUNT));
59
+ const basePoints = Array.from({ length: nodeCount }, (_, index) => sampleSphere(index, nodeCount, sphereRadius));
60
+ const projected = basePoints
61
+ .map((point) => rotateX(point, NODE_TILT))
62
+ .map((point) => rotateY(point, t * NODE_ROTATE_SPEED))
63
+ .map((point) => project(point, originPx, sphereRadius));
64
+ const nodeDots = projected.map((p) => ({
65
+ x: p.x,
66
+ y: p.y,
67
+ depth: p.depth,
68
+ radius: sizing.radius(NODE_BASE_RADIUS),
69
+ ink: clamp01(1 - p.depth * 0.85),
70
+ alpha: 0.5 + p.depth * 0.45
71
+ }));
72
+ const edges = nearestNeighborEdges(basePoints, NEIGHBOR_COUNT);
73
+ const strokes = edges.map(([a, b], index) => {
74
+ const pa = projected[a];
75
+ const pb = projected[b];
76
+ const depth = (pa.depth + pb.depth) / 2;
77
+ const flicker = 0.5 +
78
+ 0.5 *
79
+ Math.sin(t * EDGE_FLICKER_SPEED * Math.PI * 2 + hashUnit(index * 97 + 11) * Math.PI * 2);
80
+ return {
81
+ x1: pa.x,
82
+ y1: pa.y,
83
+ x2: pb.x,
84
+ y2: pb.y,
85
+ depth,
86
+ width: sizing.radius(EDGE_BASE_WIDTH),
87
+ ink: clamp01(1 - depth * 0.7),
88
+ alpha: EDGE_MIN_ALPHA + (1 - EDGE_MIN_ALPHA) * flicker
89
+ };
90
+ });
91
+ const pulses = edges.map(([a, b], index) => {
92
+ const pa = projected[a];
93
+ const pb = projected[b];
94
+ const travel = 0.5 + 0.5 * Math.sin(t * PULSE_SPEED * Math.PI * 2 + hashUnit(index * 53 + 7) * Math.PI * 2);
95
+ return {
96
+ x: pa.x + (pb.x - pa.x) * travel,
97
+ y: pa.y + (pb.y - pa.y) * travel,
98
+ depth: pa.depth + (pb.depth - pa.depth) * travel,
99
+ radius: sizing.radius(PULSE_BASE_RADIUS),
100
+ ink: 0.05,
101
+ alpha: 0.9
102
+ };
103
+ });
104
+ return { dots: [...nodeDots, ...pulses], strokes };
105
+ };
106
+ // ---------------------------------------------------------------------------
107
+ // weaving -- three strands spiralling from top to bottom around a sphere, offset
108
+ // in phase so they interleave; the depth sort in `paintFrame` is what makes
109
+ // them cross over and under each other.
110
+ // ---------------------------------------------------------------------------
111
+ const STRAND_COUNT = 3;
112
+ const STRAND_POINTS_BASE_COUNT = 26;
113
+ const MIN_STRAND_POINTS = 10;
114
+ /** Lower than a bare spiral would use: the braid swing below is what supplies
115
+ * the winding, so the longitude term only needs to carry the strands most of
116
+ * the way around, not add its own extra turns on top. */
117
+ const STRAND_WRAPS = 1.1;
118
+ const STRAND_ROTATE_SPEED = 0.3;
119
+ const STRAND_TILT = 0.5;
120
+ const STRAND_DOT_BASE_RADIUS = 1.6;
121
+ const STRAND_STROKE_BASE_WIDTH = 1.4;
122
+ /** How many times a strand swings past its neighbours along its own pole-to-
123
+ * pole length -- enough that a still frame always lands inside more than one
124
+ * crossing, not so many the braid blurs into texture. */
125
+ const STRAND_BRAID_CYCLES = 2.5;
126
+ /** A constant 120-degree offset alone keeps the three strands in the same
127
+ * relative order the whole way, which reads as one thick spiral rather than
128
+ * three distinct ones. Two strands only visibly swap places, i.e. actually
129
+ * cross, once the swing below overtakes half their 120-degree separation;
130
+ * this sits comfortably past that so every pair of strands crosses, not just
131
+ * wobbles toward each other. */
132
+ const STRAND_BRAID_SWING = 1.3;
133
+ const strandBaseAngle = (strand) => (strand / STRAND_COUNT) * Math.PI * 2;
134
+ /**
135
+ * The `pointIndex`-th of `pointsPerStrand` points of `strand`, spiralling
136
+ * from the top of the sphere to the bottom. Each strand's longitude oscillates around its own
137
+ * 120-degree slot (the braid swing) on top of the shared spiral term, so
138
+ * strands actually change places rather than staying a fixed distance apart;
139
+ * folding `t` into the shared longitude term spins the whole braid rigidly,
140
+ * which is what keeps every point exactly on the sphere at every instant.
141
+ */
142
+ const strandPoint = (strand, pointIndex, pointsPerStrand, sphereRadius, t) => {
143
+ const u = pointIndex / (pointsPerStrand - 1);
144
+ const phi = u * Math.PI - Math.PI / 2;
145
+ const y = sphereRadius * Math.sin(phi);
146
+ const ringRadius = sphereRadius * Math.cos(phi);
147
+ const base = strandBaseAngle(strand);
148
+ const swing = STRAND_BRAID_SWING * Math.sin(u * STRAND_BRAID_CYCLES * Math.PI * 2 + base);
149
+ const theta = u * STRAND_WRAPS * Math.PI * 2 + base + swing + t * STRAND_ROTATE_SPEED;
150
+ const spun = { x: ringRadius * Math.cos(theta), y, z: ringRadius * Math.sin(theta) };
151
+ return rotateX(spun, STRAND_TILT);
152
+ };
153
+ /** Three strands wrapped around a sphere, crossing as they spiral from top to bottom. */
154
+ export const weaving = (t, ctx) => {
155
+ const { sizing, originPx, sphereRadius } = sphereGeometry(ctx);
156
+ const pointsPerStrand = Math.max(MIN_STRAND_POINTS, sizing.count(STRAND_POINTS_BASE_COUNT));
157
+ const strands = Array.from({ length: STRAND_COUNT }, (_, strand) => Array.from({ length: pointsPerStrand }, (_, pointIndex) => project(strandPoint(strand, pointIndex, pointsPerStrand, sphereRadius, t), originPx, sphereRadius)));
158
+ const dots = strands.flatMap((points, strand) => points.map((p) => ({
159
+ x: p.x,
160
+ y: p.y,
161
+ depth: p.depth,
162
+ radius: sizing.radius(STRAND_DOT_BASE_RADIUS),
163
+ ink: clamp01(1 - p.depth * 0.8 + strand * 0.08),
164
+ alpha: 0.55 + p.depth * 0.4
165
+ })));
166
+ const strokes = strands.flatMap((points, strand) => points.slice(1).map((p, i) => {
167
+ const previous = points[i];
168
+ const depth = (previous.depth + p.depth) / 2;
169
+ return {
170
+ x1: previous.x,
171
+ y1: previous.y,
172
+ x2: p.x,
173
+ y2: p.y,
174
+ depth,
175
+ width: sizing.radius(STRAND_STROKE_BASE_WIDTH),
176
+ ink: clamp01(1 - depth * 0.75 + strand * 0.06),
177
+ alpha: 0.6 + depth * 0.35
178
+ };
179
+ }));
180
+ return { dots, strokes };
181
+ };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Sphere-family modes: marks that read as points spread across, or orbiting,
3
+ * a solid sphere. Each mode is a pure function of `(t, ctx)` -- the geometry,
4
+ * tuning constants and small helpers below are local to this file so every
5
+ * mode's numbers stay easy to read next to the mode that uses them.
6
+ */
7
+ import type { ModeFn } from '../types';
8
+ export declare const working: ModeFn;
9
+ export declare const searching: ModeFn;
10
+ export declare const solving: ModeFn;
11
+ export declare const listening: ModeFn;
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Sphere-family modes: marks that read as points spread across, or orbiting,
3
+ * a solid sphere. Each mode is a pure function of `(t, ctx)` -- the geometry,
4
+ * tuning constants and small helpers below are local to this file so every
5
+ * mode's numbers stay easy to read next to the mode that uses them.
6
+ */
7
+ import { easeInOutCubic, easeOutCubic, hashUnit, modeSizing, project, rotateX, rotateY, rotateZ, sampleSphere } from '../space';
8
+ const TWO_PI = Math.PI * 2;
9
+ /**
10
+ * `project`'s perspective makes a near point's on-screen radius bulge past
11
+ * its world radius, so every mode keeps its sphere a little inside the 40%
12
+ * mark to leave that bulge room before the canvas edge.
13
+ */
14
+ const WORLD_RADIUS_RATIO = 0.38;
15
+ const clamp01 = (value) => Math.max(0, Math.min(1, value));
16
+ const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
17
+ const originOf = (ctx) => ctx.size / 2;
18
+ const worldRadiusOf = (ctx) => ctx.size * WORLD_RADIUS_RATIO;
19
+ /** Near marks read brighter (low ink); far ones fade toward the background. */
20
+ const INK_NEAR = 0.05;
21
+ const INK_FAR = 0.72;
22
+ const shadeByDepth = (depth) => INK_FAR - (INK_FAR - INK_NEAR) * clamp01(depth);
23
+ const toDot = (at, radius, ink, alpha) => ({
24
+ x: at.x,
25
+ y: at.y,
26
+ depth: at.depth,
27
+ radius,
28
+ ink: clamp01(ink),
29
+ alpha: clamp01(alpha)
30
+ });
31
+ const toStroke = (from, to, width, ink, alpha) => ({
32
+ x1: from.x,
33
+ y1: from.y,
34
+ x2: to.x,
35
+ y2: to.y,
36
+ depth: (from.depth + to.depth) / 2,
37
+ width,
38
+ ink: clamp01(ink),
39
+ alpha: clamp01(alpha)
40
+ });
41
+ // ---------------------------------------------------------------------------
42
+ // working -- particles travelling several tilted orbits around a centre
43
+ // ---------------------------------------------------------------------------
44
+ /** Particles travel in short trailing arcs ("trains") rather than scattering
45
+ * independently around their orbit -- a lone random phase per particle reads
46
+ * as noise once density thins the count out at 32/20px, while a handful of
47
+ * particles held close together always reads as motion along a path. */
48
+ const WORKING_TRAIN_BASE_COUNT = 22;
49
+ /** Keeps at least this many short arcs on screen at each size. At 32/64px the
50
+ * area scaler alone already clears this floor, so it only bites at 20px. More
51
+ * than one train per orbit (WORKING_ORBIT_COUNT = 6) matters because depth
52
+ * shading fades whichever half of each orbit is on the far side of the
53
+ * sphere at a given instant almost to the background -- one train per orbit
54
+ * left whole quadrants with no train currently on the near side to fill
55
+ * them; two independently-phased laps around the six orbits keeps at least
56
+ * a couple of quadrants covered at every instant, not just on average. */
57
+ const WORKING_MIN_TRAINS = { 64: 3, 32: 3, 20: 12 };
58
+ const WORKING_TRAIN_LENGTH = 5;
59
+ /** How far apart, in radians, a train's particles trail behind its lead --
60
+ * wide enough to read as a short curved arc, short enough to stay a cluster
61
+ * rather than smearing back into the full-orbit scatter this replaces. */
62
+ const WORKING_TRAIN_ARC_SPAN = 0.5;
63
+ const WORKING_BASE_RADIUS_PX = 1.6;
64
+ const WORKING_ORBIT_COUNT = 6;
65
+ const WORKING_ANGULAR_SPEED = 1.1;
66
+ /** How far an orbit's `weight` can push its dots' ink away from the shared
67
+ * depth shading, so each of the six paths reads as its own band rather than
68
+ * all of them blurring into one undifferentiated cloud. */
69
+ const WORKING_ORBIT_INK_SPREAD = 0.3;
70
+ /** However low an orbit's `weight` sits, its dots never fade past this floor. */
71
+ const WORKING_ORBIT_ALPHA_FLOOR = 0.65;
72
+ /** A train's trailing-most particle never fades past this floor, so the arc's
73
+ * tail stays legible instead of thinning away to nothing. */
74
+ const WORKING_TRAIL_ALPHA_FLOOR = 0.35;
75
+ /** Every particle's own orbital plane, radius, pace and shading weight, keyed by its orbit. */
76
+ const workingOrbit = (orbit) => {
77
+ const seed = orbit * 31 + 7;
78
+ return {
79
+ tilt: (hashUnit(seed) - 0.5) * Math.PI,
80
+ roll: hashUnit(seed + 3) * TWO_PI,
81
+ orbitRadiusFactor: 0.5 + 0.45 * hashUnit(seed + 5),
82
+ speedFactor: 0.6 + 1.3 * hashUnit(seed + 9),
83
+ weight: 0.35 + 0.65 * hashUnit(seed + 15)
84
+ };
85
+ };
86
+ export const working = (t, ctx) => {
87
+ const sizing = modeSizing(ctx.size, ctx.density, ctx.dotScale);
88
+ const origin = originOf(ctx);
89
+ const worldRadius = worldRadiusOf(ctx);
90
+ const trainCount = Math.max(WORKING_MIN_TRAINS[ctx.size], sizing.count(WORKING_TRAIN_BASE_COUNT));
91
+ const arcStep = WORKING_TRAIN_ARC_SPAN / (WORKING_TRAIN_LENGTH - 1);
92
+ const dots = [];
93
+ for (let train = 0; train < trainCount; train += 1) {
94
+ const orbit = workingOrbit(train % WORKING_ORBIT_COUNT);
95
+ const leadPhase = hashUnit(train * 5 + 1) * TWO_PI;
96
+ const leadAngle = leadPhase + t * WORKING_ANGULAR_SPEED * orbit.speedFactor;
97
+ const orbitRadius = worldRadius * orbit.orbitRadiusFactor;
98
+ for (let slot = 0; slot < WORKING_TRAIN_LENGTH; slot += 1) {
99
+ const angle = leadAngle - slot * arcStep;
100
+ const planar = {
101
+ x: Math.cos(angle) * orbitRadius,
102
+ y: 0,
103
+ z: Math.sin(angle) * orbitRadius
104
+ };
105
+ const tilted = rotateX(planar, orbit.tilt);
106
+ const point = rotateZ(tilted, orbit.roll);
107
+ const projected = project(point, origin, worldRadius);
108
+ const seed = train * 13 + slot * 7 + 2;
109
+ const trail = 1 - slot / (WORKING_TRAIN_LENGTH - 1);
110
+ const ink = shadeByDepth(projected.depth) +
111
+ (hashUnit(seed) - 0.5) * 0.08 -
112
+ (orbit.weight - 0.5) * WORKING_ORBIT_INK_SPREAD;
113
+ const alpha = (WORKING_TRAIL_ALPHA_FLOOR + (1 - WORKING_TRAIL_ALPHA_FLOOR) * trail) *
114
+ (WORKING_ORBIT_ALPHA_FLOOR + (1 - WORKING_ORBIT_ALPHA_FLOOR) * orbit.weight);
115
+ const radius = sizing.radius(WORKING_BASE_RADIUS_PX) * (0.75 + 0.5 * hashUnit(seed + 3));
116
+ dots.push(toDot(projected, radius, ink, alpha));
117
+ }
118
+ }
119
+ return { dots, strokes: [] };
120
+ };
121
+ // ---------------------------------------------------------------------------
122
+ // searching -- a dotted globe with a meridian band sweeping around it
123
+ // ---------------------------------------------------------------------------
124
+ const SEARCHING_BASE_COUNT = 150;
125
+ const SEARCHING_BASE_RADIUS_PX = 1.5;
126
+ const SEARCHING_BAND_HALF_WIDTH = 0.42;
127
+ const SEARCHING_SWEEP_SPEED = 1.15;
128
+ const SEARCHING_SPIN_SPEED = 0.18;
129
+ const SEARCHING_TILT = -0.55;
130
+ const SEARCHING_ARC_SEGMENTS = 10;
131
+ /** How much of the pole-to-pole range the band's guide arc spans, short of
132
+ * the poles themselves so its end segments stay distinct marks rather than
133
+ * converging to a single point. */
134
+ const SEARCHING_ARC_LATITUDE_SPAN = 0.92;
135
+ /** How much darker, more opaque and larger a dot gets the closer the sweep
136
+ * passes over it, so the band reads as a clear lit stripe against the rest
137
+ * of the dotted globe instead of a faint gradient. */
138
+ const SEARCHING_BAND_INK_BOOST = 0.75;
139
+ const SEARCHING_BAND_ALPHA_BOOST = 0.35;
140
+ const SEARCHING_BAND_RADIUS_BOOST = 1.3;
141
+ /** The guide arc itself: wide, dark and opaque so the sweep's leading edge
142
+ * is legible on its own, not just via the dots it lifts. */
143
+ const SEARCHING_ARC_STROKE_RADIUS = 2.2;
144
+ const SEARCHING_ARC_INK = 0;
145
+ const SEARCHING_ARC_ALPHA = 0.7;
146
+ /** Shortest signed-free distance between two angles, always in 0..π. */
147
+ const angleDistance = (a, b) => {
148
+ const diff = Math.abs(a - b) % TWO_PI;
149
+ return diff > Math.PI ? TWO_PI - diff : diff;
150
+ };
151
+ export const searching = (t, ctx) => {
152
+ const sizing = modeSizing(ctx.size, ctx.density, ctx.dotScale);
153
+ const origin = originOf(ctx);
154
+ const worldRadius = worldRadiusOf(ctx);
155
+ const count = sizing.count(SEARCHING_BASE_COUNT);
156
+ const sweepAngle = t * SEARCHING_SWEEP_SPEED;
157
+ const dots = Array.from({ length: count }, (_unused, i) => {
158
+ const surface = sampleSphere(i, count, worldRadius);
159
+ const tilted = rotateX(surface, SEARCHING_TILT);
160
+ const point = rotateY(tilted, t * SEARCHING_SPIN_SPEED);
161
+ const longitude = Math.atan2(point.z, point.x);
162
+ const distance = angleDistance(longitude, sweepAngle);
163
+ const lift = easeOutCubic(clamp01(1 - distance / SEARCHING_BAND_HALF_WIDTH));
164
+ const projected = project(point, origin, worldRadius);
165
+ const ink = shadeByDepth(projected.depth) - lift * SEARCHING_BAND_INK_BOOST;
166
+ const alpha = 0.55 + projected.depth * 0.25 + lift * SEARCHING_BAND_ALPHA_BOOST;
167
+ const radius = sizing.radius(SEARCHING_BASE_RADIUS_PX) * (1 + lift * SEARCHING_BAND_RADIUS_BOOST);
168
+ return toDot(projected, radius, ink, alpha);
169
+ });
170
+ const arcPoints = Array.from({ length: SEARCHING_ARC_SEGMENTS + 1 }, (_unused, j) => {
171
+ const latFrac = j / SEARCHING_ARC_SEGMENTS;
172
+ const phi = (latFrac - 0.5) * Math.PI * SEARCHING_ARC_LATITUDE_SPAN;
173
+ const point = {
174
+ x: Math.cos(phi) * Math.cos(sweepAngle) * worldRadius,
175
+ y: Math.sin(phi) * worldRadius,
176
+ z: Math.cos(phi) * Math.sin(sweepAngle) * worldRadius
177
+ };
178
+ return project(point, origin, worldRadius);
179
+ });
180
+ const strokeWidth = sizing.radius(SEARCHING_ARC_STROKE_RADIUS);
181
+ const strokes = Array.from({ length: SEARCHING_ARC_SEGMENTS }, (_unused, j) => toStroke(arcPoints[j], arcPoints[j + 1], strokeWidth, SEARCHING_ARC_INK, SEARCHING_ARC_ALPHA));
182
+ return { dots, strokes };
183
+ };
184
+ // ---------------------------------------------------------------------------
185
+ // solving -- tiers that snap a quarter turn out of alignment, hold scrambled,
186
+ // then resolve back, on a loop. Landmark dots and fixed seam rings make the
187
+ // misalignment legible: an otherwise-uniform, evenly-sampled dot texture
188
+ // looks identical under any rotation about the spin axis, so turning a
189
+ // "band" of it invisibly wouldn't read as scrambling at all.
190
+ // ---------------------------------------------------------------------------
191
+ const SOLVING_BASE_COUNT = 130;
192
+ const SOLVING_BASE_RADIUS_PX = 1.5;
193
+ const SOLVING_TIER_COUNT = 4;
194
+ const SOLVING_TURN_DURATION = 0.16;
195
+ const SOLVING_TURN_GAP = 0.3;
196
+ const SOLVING_HOLD_GAP = 0.55;
197
+ const SOLVING_TILT = -0.5;
198
+ /** Each tier carries this many oversized landmark dots at fixed, evenly
199
+ * spaced longitudes, so a quarter turn visibly displaces a recognisable
200
+ * cluster relative to its neighbours instead of only reshuffling texture
201
+ * that looks the same before and after. */
202
+ const SOLVING_LANDMARKS_PER_TIER = 3;
203
+ const SOLVING_LANDMARK_RADIUS_PX = 3.1;
204
+ const SOLVING_LANDMARK_INK_BOOST = 0.45;
205
+ const SOLVING_LANDMARK_ALPHA = 0.9;
206
+ /** A static ring of short strokes at each cut between tiers, unaffected by
207
+ * any turn, so the "cut into bands" structure reads even while the puzzle
208
+ * sits solved and nothing is moving. */
209
+ const SOLVING_SEAM_SEGMENTS = 12;
210
+ const SOLVING_SEAM_STROKE_RADIUS = 1.05;
211
+ const SOLVING_SEAM_INK = 0.15;
212
+ const SOLVING_SEAM_ALPHA = 0.5;
213
+ const solvingTurnStart = (tier) => tier * SOLVING_TURN_GAP;
214
+ const solvingTurnEnd = (tier) => solvingTurnStart(tier) + SOLVING_TURN_DURATION;
215
+ const SOLVING_HOLD_START = solvingTurnEnd(SOLVING_TIER_COUNT - 1) + SOLVING_HOLD_GAP;
216
+ const solvingReturnStart = (tier) => SOLVING_HOLD_START + (SOLVING_TIER_COUNT - 1 - tier) * SOLVING_TURN_GAP;
217
+ const solvingReturnEnd = (tier) => solvingReturnStart(tier) + SOLVING_TURN_DURATION;
218
+ /** Full snap-scramble-then-resolve length; the mode repeats every this many seconds. */
219
+ const SOLVING_CYCLE = solvingReturnEnd(0) + SOLVING_HOLD_GAP;
220
+ /** 0 (solved) snaps out to 1 (quarter-turned) and back. The snap out eases
221
+ * only at its landing (a fast start reads as a sudden turn); the return to
222
+ * solved eases at both ends, reading as a calmer resolve. */
223
+ const solvingTierTurn = (tier, cyclePos) => {
224
+ const turnStart = solvingTurnStart(tier);
225
+ const turnEnd = solvingTurnEnd(tier);
226
+ const returnStart = solvingReturnStart(tier);
227
+ const returnEnd = solvingReturnEnd(tier);
228
+ if (cyclePos <= turnStart) {
229
+ return 0;
230
+ }
231
+ if (cyclePos < turnEnd) {
232
+ return easeOutCubic((cyclePos - turnStart) / SOLVING_TURN_DURATION);
233
+ }
234
+ if (cyclePos < returnStart) {
235
+ return 1;
236
+ }
237
+ if (cyclePos < returnEnd) {
238
+ return 1 - easeInOutCubic((cyclePos - returnStart) / SOLVING_TURN_DURATION);
239
+ }
240
+ return 0;
241
+ };
242
+ const solvingTierOf = (yUnit) => clamp(Math.floor(((1 - yUnit) / 2) * SOLVING_TIER_COUNT), 0, SOLVING_TIER_COUNT - 1);
243
+ const solvingTierDirection = (tier) => (hashUnit(tier * 53 + 21) < 0.5 ? -1 : 1);
244
+ /** The y-unit (-1..1) at the centre of a tier's latitude band. */
245
+ const solvingTierCenterYUnit = (tier) => 1 - (2 * tier + 1) / SOLVING_TIER_COUNT;
246
+ /** The y-unit (-1..1) of the fixed cut between one tier and the next. */
247
+ const solvingSeamYUnit = (seam) => 1 - (2 * (seam + 1)) / SOLVING_TIER_COUNT;
248
+ const solvingTurnAngle = (tier, cyclePos) => solvingTierTurn(tier, cyclePos) * solvingTierDirection(tier) * (Math.PI / 2);
249
+ export const solving = (t, ctx) => {
250
+ const sizing = modeSizing(ctx.size, ctx.density, ctx.dotScale);
251
+ const origin = originOf(ctx);
252
+ const worldRadius = worldRadiusOf(ctx);
253
+ const count = sizing.count(SOLVING_BASE_COUNT);
254
+ const cyclePos = t - SOLVING_CYCLE * Math.floor(t / SOLVING_CYCLE);
255
+ const texture = Array.from({ length: count }, (_unused, i) => {
256
+ const surface = sampleSphere(i, count, worldRadius);
257
+ const yUnit = worldRadius > 0 ? surface.y / worldRadius : 0;
258
+ const tier = solvingTierOf(yUnit);
259
+ const turned = rotateY(surface, solvingTurnAngle(tier, cyclePos));
260
+ const point = rotateX(turned, SOLVING_TILT);
261
+ const projected = project(point, origin, worldRadius);
262
+ const ink = shadeByDepth(projected.depth);
263
+ const alpha = 0.55 + 0.3 * hashUnit(i * 3 + 9);
264
+ const radius = sizing.radius(SOLVING_BASE_RADIUS_PX);
265
+ return toDot(projected, radius, ink, alpha);
266
+ });
267
+ const landmarks = Array.from({ length: SOLVING_TIER_COUNT * SOLVING_LANDMARKS_PER_TIER }, (_unused, i) => {
268
+ const tier = Math.floor(i / SOLVING_LANDMARKS_PER_TIER);
269
+ const slot = i % SOLVING_LANDMARKS_PER_TIER;
270
+ const yUnit = solvingTierCenterYUnit(tier);
271
+ const y = yUnit * worldRadius;
272
+ const ringRadius = Math.sqrt(Math.max(0, 1 - yUnit * yUnit)) * worldRadius;
273
+ const phase = hashUnit(tier * 29 + 5) * TWO_PI;
274
+ const longitude = phase + (slot / SOLVING_LANDMARKS_PER_TIER) * TWO_PI;
275
+ const base = {
276
+ x: Math.cos(longitude) * ringRadius,
277
+ y,
278
+ z: Math.sin(longitude) * ringRadius
279
+ };
280
+ const turned = rotateY(base, solvingTurnAngle(tier, cyclePos));
281
+ const point = rotateX(turned, SOLVING_TILT);
282
+ const projected = project(point, origin, worldRadius);
283
+ const ink = shadeByDepth(projected.depth) - SOLVING_LANDMARK_INK_BOOST;
284
+ return toDot(projected, sizing.radius(SOLVING_LANDMARK_RADIUS_PX), ink, SOLVING_LANDMARK_ALPHA);
285
+ });
286
+ const seamWidth = sizing.radius(SOLVING_SEAM_STROKE_RADIUS);
287
+ const seams = Array.from({ length: SOLVING_TIER_COUNT - 1 }, (_unused, seam) => {
288
+ const yUnit = solvingSeamYUnit(seam);
289
+ const y = yUnit * worldRadius;
290
+ const ringRadius = Math.sqrt(Math.max(0, 1 - yUnit * yUnit)) * worldRadius;
291
+ const ringPoints = Array.from({ length: SOLVING_SEAM_SEGMENTS + 1 }, (_unusedPoint, j) => {
292
+ const angle = (j / SOLVING_SEAM_SEGMENTS) * TWO_PI;
293
+ const point = {
294
+ x: Math.cos(angle) * ringRadius,
295
+ y,
296
+ z: Math.sin(angle) * ringRadius
297
+ };
298
+ return project(rotateX(point, SOLVING_TILT), origin, worldRadius);
299
+ });
300
+ return Array.from({ length: SOLVING_SEAM_SEGMENTS }, (_unusedSegment, j) => toStroke(ringPoints[j], ringPoints[j + 1], seamWidth, SOLVING_SEAM_INK, SOLVING_SEAM_ALPHA));
301
+ }).flat();
302
+ return { dots: [...texture, ...landmarks], strokes: seams };
303
+ };
304
+ // ---------------------------------------------------------------------------
305
+ // listening -- stacked horizontal rings with a wave rolling through them
306
+ // ---------------------------------------------------------------------------
307
+ /** Fewer, fatter rings at the smaller presets read as distinct stacked bands;
308
+ * six thin rings at 20px used to compress into a single smudge. */
309
+ const LISTENING_RING_COUNT = { 64: 5, 32: 4, 20: 3 };
310
+ const LISTENING_DOTS_PER_RING_BASE = 26;
311
+ /** A ring below this many dots reads as a loose cluster of points rather than
312
+ * a line, especially once it is a small ellipse at 20px. */
313
+ const LISTENING_MIN_DOTS_PER_RING = 10;
314
+ const LISTENING_BASE_RADIUS_PX = 1.5;
315
+ const LISTENING_WAVE_SPEED = 0.6;
316
+ const LISTENING_SWELL = 0.55;
317
+ const LISTENING_LIFT_INK = 0.45;
318
+ const LISTENING_SPIN_SPEED = 0.12;
319
+ /** A ring's own vertical thickness once tilted is (its radius) x sin(tilt); a
320
+ * ring near the equator is nearly as wide as the sphere itself, so even this
321
+ * shallow a tilt must stay well under the gap between ring centres below or
322
+ * neighbouring rings' ellipses swallow the empty space between them. Kept
323
+ * shallow rather than flattened to zero so a ring still reads as an ellipse
324
+ * seen slightly from above, not a flat line. */
325
+ const LISTENING_TILT = -0.13;
326
+ /** Rings are stacked between +/- this fraction of the world radius, short of
327
+ * the poles so the top and bottom rings stay full circles, not points. */
328
+ const LISTENING_Y_SPAN = 0.82;
329
+ const listeningRing = (ring, ringCount, worldRadius) => {
330
+ const yUnit = (-1 + (2 * ring + 1) / ringCount) * LISTENING_Y_SPAN;
331
+ const y = yUnit * worldRadius;
332
+ const ringRadius = Math.sqrt(Math.max(0, worldRadius * worldRadius - y * y));
333
+ const phase = hashUnit(ring * 17 + 3) * TWO_PI;
334
+ const wave = (t) => (Math.sin(TWO_PI * (ring / ringCount - t * LISTENING_WAVE_SPEED)) + 1) / 2;
335
+ return { y, ringRadius, phase, wave };
336
+ };
337
+ export const listening = (t, ctx) => {
338
+ const sizing = modeSizing(ctx.size, ctx.density, ctx.dotScale);
339
+ const origin = originOf(ctx);
340
+ const worldRadius = worldRadiusOf(ctx);
341
+ const ringCount = LISTENING_RING_COUNT[ctx.size];
342
+ const totalBase = ringCount * LISTENING_DOTS_PER_RING_BASE;
343
+ const dotsPerRing = Math.max(LISTENING_MIN_DOTS_PER_RING, Math.round(sizing.count(totalBase) / ringCount));
344
+ const dots = Array.from({ length: ringCount }, (_unused, r) => {
345
+ const ring = listeningRing(r, ringCount, worldRadius);
346
+ const wave = ring.wave(t);
347
+ return Array.from({ length: dotsPerRing }, (_unusedDot, k) => {
348
+ const angle = (k / dotsPerRing) * TWO_PI + ring.phase;
349
+ const planar = {
350
+ x: Math.cos(angle) * ring.ringRadius,
351
+ y: ring.y,
352
+ z: Math.sin(angle) * ring.ringRadius
353
+ };
354
+ const tilted = rotateX(planar, LISTENING_TILT);
355
+ const point = rotateY(tilted, t * LISTENING_SPIN_SPEED);
356
+ const projected = project(point, origin, worldRadius);
357
+ const ink = shadeByDepth(projected.depth) - wave * LISTENING_LIFT_INK;
358
+ const alpha = 0.6 + wave * 0.3;
359
+ const radius = sizing.radius(LISTENING_BASE_RADIUS_PX) * (1 + wave * LISTENING_SWELL);
360
+ return toDot(projected, radius, ink, alpha);
361
+ });
362
+ }).flat();
363
+ return { dots, strokes: [] };
364
+ };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Turns one `Frame` into canvas calls. The only place that knows about theme
3
+ * and colour -- a mode never sees `dark` or `tint`, only the 0..1 `ink` it
4
+ * assigns each mark.
5
+ */
6
+ import type { Rgb } from '../../types';
7
+ import type { Frame } from './types';
8
+ /** What `paintFrame` needs beyond the frame itself: the theme and the base colour. */
9
+ export type PaintOptions = {
10
+ dark: boolean;
11
+ tint: Rgb;
12
+ };
13
+ /**
14
+ * Maps `ink` (0 = full `tint`) to an exact, testable colour string: fading
15
+ * toward white in light theme and toward black in dark theme, so a mark
16
+ * further away can fade into either background without ever reading as the
17
+ * wrong theme's ink.
18
+ */
19
+ export declare const inkColor: (tint: Rgb, ink: number, dark: boolean) => string;
20
+ /**
21
+ * Clears the canvas and draws a `Frame`: every stroke, then every dot, each
22
+ * group sorted far-to-near by `depth` so nearer marks are painted last and
23
+ * sit on top.
24
+ */
25
+ export declare const paintFrame: (ctx: CanvasRenderingContext2D, frame: Frame, options: PaintOptions) => void;