cursor-engine.ts 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083
  1. import { LAOA_DATA } from './laoa-data'
  2. // Adapted from OpenMausBot (3a84701), Apache-2.0. See THIRD_PARTY_NOTICES.md.
  3. /**
  4. * CursorAvatar — an animated mascot built on the "cursor" silhouette.
  5. *
  6. * Data and frame functions retained from OpenMausBot; the host below is framework-neutral.
  7. *
  8. * import CursorAvatar from './CursorAvatar'
  9. *
  10. * <CursorAvatar state="thinking" silhouette={MASCOT_BODIES.cursor} size={160} />
  11. *
  12. * The state drives everything — which expressions cycle, how often, and when it blinks.
  13. * Set `state` and it animates itself; see CURSOR_STATES for the full list.
  14. *
  15. * Props of note:
  16. * state one of CURSOR_STATES
  17. * expression pin a single face and stop the cycling
  18. * lookAround how much each expression glances around. 0 = always straight ahead
  19. * gaze / turn aim the eyes, or rotate the head around its implied sphere
  20. *
  21. * Made with Blob Studio.
  22. */
  23. import { MASCOT_BODIES } from "./mascot-bodies"
  24. import {
  25. EXPRESSIONS,
  26. EXPRESSION_COUNT,
  27. FACE_BOX,
  28. FACE_CENTRE,
  29. GAZE_TRAVEL,
  30. type Ring,
  31. } from "./cursor-face-data"
  32. export {
  33. EXPRESSIONS,
  34. EXPRESSION_COUNT,
  35. FACE_BOX,
  36. FACE_CENTRE,
  37. GAZE_TRAVEL,
  38. }
  39. export type { Ring }
  40. /* ------------------------------------------------------------------- shape */
  41. export interface CursorSilhouette {
  42. viewBox?: string
  43. /** Human-readable name, used for the accessible label. */
  44. name: string
  45. /** Transform mapping the artwork into the 228.541-unit face box. '' for none. */
  46. fit: string
  47. /** SVG markup for the body. The token {{GRADIENT}} is replaced with the instance gradient. */
  48. body: string
  49. /** SVG markup defining the clip region — the union of the silhouette's filled shapes. */
  50. clip: string
  51. /** Where the face sits inside the silhouette, in face-space units. */
  52. anchor: { x: number; y: number; scale: number }
  53. }
  54. // The generator solves this body's face placement once; `MASCOT_BODIES.cursor`
  55. // is the single source of truth desktop and iOS both build from. Its shape
  56. // carries one extra field (`id`) that `CursorSilhouette` does not, so it is
  57. // derived here rather than assigned directly.
  58. const { id: _cursorBodyId, ...cursorSilhouette } = MASCOT_BODIES.cursor;
  59. export const DEFAULT_SILHOUETTE: CursorSilhouette = cursorSilhouette;
  60. export const DEFAULT_GRADIENT: [string, string, string] = ["#9FE6B5","#3FAE6E","#1C7A4C"]
  61. const VIEW_BOX = `-15 -15 ${FACE_BOX + 30} ${FACE_BOX + 30}`
  62. const SPHERE_C = 114.2705
  63. const SPHERE_R = 105
  64. /* ------------------------------------------------------------------ motion */
  65. /**
  66. * How the body itself moves. The face engine on its own leaves the silhouette perfectly
  67. * still, which reads as dead for states literally named `bouncing` or `spawning`.
  68. *
  69. * All of this is shape-agnostic — it moves whatever silhouette it is given, so an uploaded
  70. * logo animates exactly like the built-in circle.
  71. *
  72. * bob vertical travel, [amplitude in face units, period ms]
  73. * sway rotation, [degrees, period ms]
  74. * pulse uniform scale, [fraction, period ms] — breathing
  75. * circle orbital drift, [radius, period ms]
  76. * jitter fast nervous shake, [amplitude, period ms]
  77. * tilt constant lean, degrees
  78. * squash 0..1, how much a bob squashes the body at the bottom of its arc
  79. * enter one-shot on entering the state, [starting scale, duration ms]
  80. * settle scale it eases to and holds, for exits like powering-down
  81. */
  82. export interface BodyMotion {
  83. bob?: [number, number]
  84. sway?: [number, number]
  85. pulse?: [number, number]
  86. circle?: [number, number]
  87. jitter?: [number, number]
  88. tilt?: number
  89. squash?: number
  90. enter?: [number, number]
  91. settle?: number
  92. }
  93. export const MOTION = {
  94. // Lifecycle — quiet, breathing, alive but not busy.
  95. sleeping: { pulse: [0.028, 4600], tilt: 2 },
  96. waking: { enter: [0.92, 700], pulse: [0.03, 2200] },
  97. idle: { pulse: [0.014, 3600] },
  98. listening: { bob: [2, 2600], pulse: [0.012, 2600] },
  99. thinking: { sway: [1.6, 3000], pulse: [0.01, 3000] },
  100. searching: { bob: [3, 1400], sway: [2.2, 1400] },
  101. working: { bob: [2.5, 900], squash: 0.22 },
  102. // Reactions — the loud half.
  103. excited: { bob: [9, 520], sway: [3, 1040], squash: 0.35 },
  104. surprised: { enter: [1.14, 340], jitter: [0.8, 120] },
  105. suspicious: { sway: [2.4, 2600], tilt: -3 },
  106. angry: { jitter: [1.3, 95], tilt: 2 },
  107. drowsy: { pulse: [0.026, 5000], tilt: 3 },
  108. happy: { bob: [5, 820], squash: 0.28 },
  109. curious: { sway: [3.4, 1900], tilt: -4 },
  110. confused: { sway: [3, 2200] },
  111. bored: { pulse: [0.016, 5200], tilt: 2 },
  112. proud: { bob: [1.6, 2400], pulse: [0.02, 2400] },
  113. shy: { pulse: [0.016, 3000], tilt: 4 },
  114. sad: { pulse: [0.02, 4600], tilt: 3 },
  115. laughing: { bob: [7, 430], squash: 0.4 },
  116. scared: { jitter: [2.2, 75] },
  117. playful: { bob: [6, 620], sway: [5, 1240], squash: 0.3 },
  118. celebrate: { bob: [10, 480], sway: [4, 960], squash: 0.35 },
  119. // Agent morphs — the mascot standing in for a process.
  120. orbit: { circle: [6, 3200] },
  121. radar: { sway: [6, 2400], pulse: [0.012, 2400] },
  122. progress: { pulse: [0.022, 1600] },
  123. // Product cycle.
  124. spawning: { enter: [0.02, 820], pulse: [0.014, 3600] },
  125. humming: { pulse: [0.016, 2800] },
  126. loading: { sway: [2.2, 1500], pulse: [0.012, 1500] },
  127. dictating: { bob: [2, 2000] },
  128. writing: { bob: [1.6, 1100] },
  129. sending: { bob: [3, 900] },
  130. receiving: { bob: [3, 900] },
  131. uploading: { bob: [3, 1000] },
  132. notifying: { bob: [4, 700], sway: [2.5, 700] },
  133. alerting: { jitter: [2.6, 85] },
  134. dragging: { tilt: -6, sway: [2, 900] },
  135. bouncing: { bob: [12, 560], squash: 0.45 },
  136. 'powering-down': { settle: 0.05, tilt: 4 },
  137. } satisfies Record<CursorState, BodyMotion>
  138. /** How long a `settle` takes to reach its resting scale. */
  139. const SETTLE_MS = 1400
  140. /* ----------------------------------------------------------------- effects */
  141. /**
  142. * Confetti, motion ribbons, and glyph morphs.
  143. *
  144. * All three are shape-agnostic: they orbit, burst around, or stand in for whatever
  145. * silhouette they are given, so an uploaded logo behaves exactly like the built-in circle.
  146. *
  147. * These layers are driven imperatively from the frame loop rather than through React
  148. * state. A celebrate burst is ~14 elements changing every frame, and a page showing all
  149. * states at once would otherwise re-render continuously.
  150. */
  151. const TAU = Math.PI * 2
  152. /** Deterministic per-particle randomness — same seed, same particle, every run. */
  153. const hash01 = (n: number) => {
  154. const x = Math.sin(n * 127.1 + 311.7) * 43758.5453
  155. return x - Math.floor(x)
  156. }
  157. const CONFETTI_COLORS = ['#F472B6', '#C084FC', '#818CF8', '#38BDF8', '#34D399', '#FACC15', '#FB7185']
  158. const RIBBON_COLORS = ['#4ADE80', '#34D399', '#22D3EE', '#60A5FA']
  159. export interface ConfettiSpec {
  160. /** How many pieces are in flight per burst. */
  161. count: number
  162. /** Milliseconds between bursts. */
  163. period: number
  164. /** How long one piece lives, ms. */
  165. life: number
  166. /** Radius the pieces launch from, so a burst clears the face instead of covering it. */
  167. origin: number
  168. /** How far pieces travel beyond that, in face units. */
  169. spread: number
  170. }
  171. export interface TrailSpec {
  172. /** How many ribbons orbit the body. */
  173. count: number
  174. /** Milliseconds for one full orbit. */
  175. period: number
  176. /** Orbit radius, in face units. */
  177. radius: number
  178. }
  179. export interface GlyphSpec {
  180. /** Markup drawn in place of the body. {{GRADIENT}} is replaced with the body paint. */
  181. markup: string
  182. /** Milliseconds between appearances. */
  183. period: number
  184. /** How long the glyph is held, ms. */
  185. hold: number
  186. }
  187. export interface StateEffects {
  188. confetti?: ConfettiSpec
  189. trails?: TrailSpec
  190. glyph?: GlyphSpec
  191. }
  192. interface EffectsByState extends Partial<Record<CursorState, StateEffects>> {}
  193. /**
  194. * The exclamation mark the mascot becomes when something needs attention — drawn as a
  195. * tapered bar and a dot so it reads as a character rather than a rectangle.
  196. */
  197. const GLYPH_BANG =
  198. '<path fill="{{GRADIENT}}" d="M99 58 Q99 43 114.3 43 Q129.6 43 129.6 58 L123 150 Q122 161 114.3 161 Q106.6 161 105.6 150 Z"/>' +
  199. '<circle fill="{{GRADIENT}}" cx="114.3" cy="188" r="15"/>'
  200. /** The question mark for genuine confusion. Stroked, so it stays light against the body. */
  201. const GLYPH_QUERY =
  202. '<path fill="none" stroke="{{GRADIENT}}" stroke-width="19" stroke-linecap="round" ' +
  203. 'd="M88 76 A27 27 0 1 1 114.3 112 L114.3 132"/>' +
  204. '<circle fill="{{GRADIENT}}" cx="114.3" cy="170" r="13"/>'
  205. export const EFFECTS: EffectsByState = {
  206. // Celebration — the loud burst.
  207. // Travel is deliberately bounded: the viewBox only carries 15 units of margin, so a
  208. // piece thrown much past ~130 from centre would be clipped mid-flight.
  209. celebrate: { confetti: { count: 16, period: 1500, life: 1300, origin: 74, spread: 54 } },
  210. excited: { confetti: { count: 9, period: 2000, life: 1100, origin: 72, spread: 44 } },
  211. laughing: { confetti: { count: 7, period: 2400, life: 1000, origin: 70, spread: 38 } },
  212. // Work in flight — ribbons circling the body.
  213. working: { trails: { count: 3, period: 2200, radius: 126 } },
  214. orbit: { trails: { count: 3, period: 2600, radius: 128 } },
  215. radar: { trails: { count: 2, period: 2000, radius: 132 } },
  216. progress: { trails: { count: 3, period: 1800, radius: 124 } },
  217. loading: { trails: { count: 2, period: 2200, radius: 126 } },
  218. uploading: { trails: { count: 2, period: 1700, radius: 122 } },
  219. sending: { trails: { count: 2, period: 1500, radius: 120 } },
  220. receiving: { trails: { count: 2, period: 1500, radius: 120 } },
  221. // The mascot standing aside to show a symbol.
  222. alerting: { glyph: { markup: GLYPH_BANG, period: 2600, hold: 1100 } },
  223. notifying: { glyph: { markup: GLYPH_BANG, period: 4200, hold: 900 } },
  224. confused: { glyph: { markup: GLYPH_QUERY, period: 5200, hold: 1200 } },
  225. }
  226. const SVG_NS = 'http://www.w3.org/2000/svg'
  227. /** Grows or shrinks a layer's pool of <path> children to exactly `count`. */
  228. function poolPaths(layer: SVGGElement, count: number): SVGPathElement[] {
  229. while (layer.childNodes.length > count) layer.removeChild(layer.lastChild!)
  230. while (layer.childNodes.length < count) {
  231. const path = document.createElementNS(SVG_NS, 'path')
  232. path.setAttribute('stroke-linecap', 'round')
  233. path.setAttribute('fill', 'none')
  234. layer.appendChild(path)
  235. }
  236. return Array.from(layer.querySelectorAll<SVGPathElement>(':scope > path'))
  237. }
  238. /** Confetti: short curved strokes thrown outward, arcing down as they fade. */
  239. function drawConfetti(
  240. layer: SVGGElement,
  241. spec: ConfettiSpec,
  242. elapsed: number,
  243. strength: number,
  244. cx: number,
  245. cy: number
  246. ) {
  247. const paths = poolPaths(layer, spec.count)
  248. for (let i = 0; i < spec.count; i++) {
  249. const path = paths[i]
  250. const seedA = hash01(i + 1)
  251. const seedB = hash01(i + 41)
  252. const seedC = hash01(i + 91)
  253. // Stagger the pieces across the burst window so they don't leave as one wall.
  254. const t = (((elapsed + seedC * spec.period) % spec.period) / spec.life) * 1
  255. if (t > 1) {
  256. path.setAttribute('opacity', '0')
  257. continue
  258. }
  259. const angle = seedA * TAU
  260. const travel = spec.spread * (0.45 + seedB * 0.55) * (1 - (1 - t) * (1 - t))
  261. const distance = (spec.origin + travel) * strength
  262. const gravity = 34 * strength * t * t
  263. const x = cx + Math.cos(angle) * distance
  264. const y = cy + Math.sin(angle) * distance + gravity
  265. const length = 11 + seedB * 12
  266. const spin = angle + (seedC - 0.5) * 5 * t
  267. const ex = x + Math.cos(spin) * length
  268. const ey = y + Math.sin(spin) * length
  269. // A slight bend reads as paper rather than a matchstick.
  270. const bend = (seedA - 0.5) * length * 0.7
  271. const mx = (x + ex) / 2 - Math.sin(spin) * bend
  272. const my = (y + ey) / 2 + Math.cos(spin) * bend
  273. path.setAttribute('d', `M${x.toFixed(1)} ${y.toFixed(1)} Q${mx.toFixed(1)} ${my.toFixed(1)} ${ex.toFixed(1)} ${ey.toFixed(1)}`)
  274. path.setAttribute('stroke', CONFETTI_COLORS[i % CONFETTI_COLORS.length])
  275. path.setAttribute('stroke-width', (4 + seedB * 2.4).toFixed(1))
  276. path.setAttribute('opacity', (t < 0.12 ? t / 0.12 : 1 - (t - 0.12) / 0.88).toFixed(3))
  277. }
  278. }
  279. /** Ribbons: arcs sweeping around the body on their own periods. */
  280. function drawTrails(
  281. layer: SVGGElement,
  282. spec: TrailSpec,
  283. elapsed: number,
  284. strength: number,
  285. cx: number,
  286. cy: number
  287. ) {
  288. const paths = poolPaths(layer, spec.count)
  289. const SAMPLES = 12
  290. for (let i = 0; i < spec.count; i++) {
  291. const path = paths[i]
  292. const seedA = hash01(i + 3)
  293. const seedB = hash01(i + 29)
  294. const direction = i % 2 === 0 ? 1 : -1
  295. const period = spec.period * (0.8 + seedA * 0.5)
  296. const radius = spec.radius * strength * (0.78 + seedB * 0.4)
  297. const start = direction * (elapsed / period) * TAU + seedA * TAU
  298. const span = 0.85 + seedB * 0.7
  299. let d = ''
  300. for (let s = 0; s <= SAMPLES; s++) {
  301. const k = s / SAMPLES
  302. const angle = start + span * k
  303. // Breathe the radius along the arc so the ribbon curls instead of tracing a circle.
  304. const r = radius * (1 + Math.sin(k * Math.PI) * 0.14)
  305. const x = cx + Math.cos(angle) * r
  306. const y = cy + Math.sin(angle) * r * 0.78
  307. d += (s === 0 ? 'M' : 'L') + x.toFixed(1) + ' ' + y.toFixed(1)
  308. }
  309. path.setAttribute('d', d)
  310. path.setAttribute('stroke', RIBBON_COLORS[i % RIBBON_COLORS.length])
  311. path.setAttribute('stroke-width', (5 + seedA * 3).toFixed(1))
  312. path.setAttribute('opacity', '0.85')
  313. }
  314. }
  315. /** How present the glyph is right now, 0..1, with eased edges. */
  316. function glyphAmount(spec: GlyphSpec, elapsed: number): number {
  317. const position = elapsed % spec.period
  318. const start = spec.period - spec.hold
  319. if (position < start) return 0
  320. const into = position - start
  321. const remaining = spec.hold - into
  322. const EASE = 200
  323. return Math.max(0, Math.min(1, Math.min(into, remaining) / EASE))
  324. }
  325. export interface EffectFrame {
  326. trails: SVGGElement | null
  327. confetti: SVGGElement | null
  328. glyph: SVGGElement | null
  329. bodyContent: SVGGElement | null
  330. state: CursorState
  331. elapsed: number
  332. strength: number
  333. paint: string
  334. showEffects: boolean
  335. showGlyphs: boolean
  336. showTrails?: boolean
  337. }
  338. /** Called once per frame from the engine loop. */
  339. export function updateEffects(frame: EffectFrame) {
  340. const spec = EFFECTS[frame.state]
  341. const centre = FACE_BOX / 2
  342. const strength = frame.strength
  343. if (frame.trails) {
  344. if (spec?.trails && frame.showEffects && frame.showTrails !== false && strength > 0) {
  345. drawTrails(frame.trails, spec.trails, frame.elapsed, strength, centre, centre)
  346. } else if (frame.trails.childNodes.length) {
  347. frame.trails.replaceChildren()
  348. }
  349. }
  350. if (frame.confetti) {
  351. if (spec?.confetti && frame.showEffects && strength > 0) {
  352. drawConfetti(frame.confetti, spec.confetti, frame.elapsed, strength, centre, centre)
  353. } else if (frame.confetti.childNodes.length) {
  354. frame.confetti.replaceChildren()
  355. }
  356. }
  357. if (frame.glyph && frame.bodyContent) {
  358. if (spec?.glyph && frame.showGlyphs) {
  359. const amount = glyphAmount(spec.glyph, frame.elapsed)
  360. const markup = spec.glyph.markup.replace(/\{\{GRADIENT\}\}/g, frame.paint)
  361. if (frame.glyph.getAttribute('data-glyph') !== markup) {
  362. frame.glyph.setAttribute('data-glyph', markup)
  363. frame.glyph.innerHTML = markup
  364. }
  365. frame.glyph.style.opacity = String(amount)
  366. // Scale up as it arrives, so the swap reads as a transformation.
  367. const scale = 0.72 + 0.28 * amount
  368. frame.glyph.setAttribute(
  369. 'transform',
  370. `translate(${centre} ${centre}) scale(${scale.toFixed(3)}) translate(${-centre} ${-centre})`
  371. )
  372. // The body steps aside rather than sitting behind the glyph.
  373. frame.bodyContent.style.opacity = String(1 - amount)
  374. } else {
  375. if (frame.glyph.getAttribute('data-glyph')) {
  376. frame.glyph.removeAttribute('data-glyph')
  377. frame.glyph.replaceChildren()
  378. }
  379. frame.glyph.style.opacity = '0'
  380. frame.bodyContent.style.opacity = '1'
  381. }
  382. }
  383. }
  384. /* ------------------------------------------------------------------ states */
  385. export type CursorState =
  386. | "sleeping"
  387. | "waking"
  388. | "idle"
  389. | "listening"
  390. | "thinking"
  391. | "searching"
  392. | "working"
  393. | "excited"
  394. | "surprised"
  395. | "suspicious"
  396. | "angry"
  397. | "drowsy"
  398. | "happy"
  399. | "curious"
  400. | "confused"
  401. | "bored"
  402. | "proud"
  403. | "shy"
  404. | "sad"
  405. | "laughing"
  406. | "scared"
  407. | "playful"
  408. | "celebrate"
  409. | "orbit"
  410. | "radar"
  411. | "progress"
  412. | "spawning"
  413. | "humming"
  414. | "loading"
  415. | "dictating"
  416. | "sending"
  417. | "receiving"
  418. | "uploading"
  419. | "writing"
  420. | "notifying"
  421. | "alerting"
  422. | "bouncing"
  423. | "dragging"
  424. | "powering-down"
  425. /**
  426. * Which expressions a state cycles through. The first is its resting face, chosen as the
  427. * pool's most forward-facing member so a mascot at rest looks at you rather than past you.
  428. */
  429. export const POOLS: Record<CursorState, number[]> = Object.fromEntries(Object.entries(LAOA_DATA.pools).map(([key,values]) => [key,[...values]])) as Record<CursorState, number[]>
  430. /** How long a state holds an expression before drifting to another, in ms. */
  431. const EXPR_CADENCE = {
  432. sleeping: [
  433. 6000,
  434. 10000
  435. ],
  436. waking: [
  437. 800,
  438. 800
  439. ],
  440. idle: [
  441. 9000,
  442. 16000
  443. ],
  444. listening: [
  445. 2800,
  446. 5000
  447. ],
  448. thinking: [
  449. 2000,
  450. 3600
  451. ],
  452. searching: [
  453. 1000,
  454. 1800
  455. ],
  456. working: [
  457. 1800,
  458. 3200
  459. ],
  460. excited: [
  461. 1100,
  462. 2000
  463. ],
  464. surprised: [
  465. 2500,
  466. 4000
  467. ],
  468. suspicious: [
  469. 2600,
  470. 4500
  471. ],
  472. angry: [
  473. 2200,
  474. 3800
  475. ],
  476. drowsy: [
  477. 4000,
  478. 8000
  479. ],
  480. happy: [
  481. 2500,
  482. 4500
  483. ],
  484. curious: [
  485. 1800,
  486. 3200
  487. ],
  488. confused: [
  489. 2200,
  490. 3800
  491. ],
  492. bored: [
  493. 3500,
  494. 6000
  495. ],
  496. proud: [
  497. 3500,
  498. 6000
  499. ],
  500. shy: [
  501. 3000,
  502. 5500
  503. ],
  504. sad: [
  505. 4000,
  506. 7000
  507. ],
  508. laughing: [
  509. 1200,
  510. 2400
  511. ],
  512. scared: [
  513. 900,
  514. 1800
  515. ],
  516. playful: [
  517. 1500,
  518. 3000
  519. ],
  520. celebrate: [
  521. 1400,
  522. 2600
  523. ],
  524. orbit: [
  525. 4000,
  526. 8000
  527. ],
  528. radar: [
  529. 4000,
  530. 8000
  531. ],
  532. progress: [
  533. 4000,
  534. 8000
  535. ],
  536. spawning: [
  537. 1200,
  538. 1200
  539. ],
  540. humming: [
  541. 5000,
  542. 9000
  543. ],
  544. loading: [
  545. 6000,
  546. 10000
  547. ],
  548. dictating: [
  549. 4000,
  550. 8000
  551. ],
  552. sending: [
  553. 4000,
  554. 8000
  555. ],
  556. receiving: [
  557. 4000,
  558. 8000
  559. ],
  560. uploading: [
  561. 4000,
  562. 8000
  563. ],
  564. writing: [
  565. 4000,
  566. 8000
  567. ],
  568. notifying: [
  569. 1500,
  570. 2600
  571. ],
  572. alerting: [
  573. 2000,
  574. 3600
  575. ],
  576. bouncing: [
  577. 3000,
  578. 6000
  579. ],
  580. dragging: [
  581. 1600,
  582. 3000
  583. ],
  584. "powering-down": [
  585. 6000,
  586. 9000
  587. ]
  588. } satisfies Record<CursorState, [number, number]>
  589. /** Blink cadence in ms, or null for states that never blink. */
  590. const BLINK = {
  591. sleeping: null,
  592. waking: null,
  593. idle: [
  594. 6000,
  595. 14000
  596. ],
  597. listening: [
  598. 3000,
  599. 7000
  600. ],
  601. thinking: [
  602. 3500,
  603. 7000
  604. ],
  605. searching: [
  606. 1600,
  607. 4000
  608. ],
  609. working: [
  610. 2800,
  611. 5500
  612. ],
  613. excited: [
  614. 2000,
  615. 4000
  616. ],
  617. surprised: [
  618. 1800,
  619. 3500
  620. ],
  621. suspicious: [
  622. 4500,
  623. 8000
  624. ],
  625. angry: [
  626. 3500,
  627. 7000
  628. ],
  629. drowsy: null,
  630. happy: [
  631. 2500,
  632. 5000
  633. ],
  634. curious: [
  635. 2500,
  636. 5500
  637. ],
  638. confused: [
  639. 2800,
  640. 5500
  641. ],
  642. bored: [
  643. 4000,
  644. 8000
  645. ],
  646. proud: [
  647. 3500,
  648. 7000
  649. ],
  650. shy: [
  651. 3000,
  652. 6000
  653. ],
  654. sad: [
  655. 4000,
  656. 8000
  657. ],
  658. laughing: [
  659. 2500,
  660. 5000
  661. ],
  662. scared: [
  663. 1200,
  664. 3000
  665. ],
  666. playful: [
  667. 2000,
  668. 4500
  669. ],
  670. celebrate: [
  671. 2200,
  672. 4500
  673. ],
  674. orbit: null,
  675. radar: null,
  676. progress: null,
  677. spawning: null,
  678. humming: [
  679. 4000,
  680. 8000
  681. ],
  682. loading: null,
  683. dictating: null,
  684. sending: null,
  685. receiving: null,
  686. uploading: null,
  687. writing: null,
  688. notifying: [
  689. 2000,
  690. 4000
  691. ],
  692. alerting: null,
  693. bouncing: null,
  694. dragging: [
  695. 2200,
  696. 4500
  697. ],
  698. "powering-down": null
  699. } satisfies Record<CursorState, [number, number] | null>
  700. /** Grouping, for pickers and docs. */
  701. export const STATE_GROUPS = {
  702. "Cycle de vie": [
  703. "sleeping",
  704. "waking",
  705. "idle",
  706. "listening",
  707. "thinking",
  708. "searching",
  709. "working"
  710. ],
  711. "Réactions": [
  712. "excited",
  713. "surprised",
  714. "suspicious",
  715. "angry",
  716. "drowsy",
  717. "happy",
  718. "curious",
  719. "confused",
  720. "bored",
  721. "proud",
  722. "shy",
  723. "sad",
  724. "laughing",
  725. "scared",
  726. "playful",
  727. "celebrate"
  728. ],
  729. "Morphes agent": [
  730. "orbit",
  731. "radar",
  732. "progress"
  733. ],
  734. "Cycle produit": [
  735. "spawning",
  736. "humming",
  737. "loading",
  738. "dictating",
  739. "writing",
  740. "sending",
  741. "receiving",
  742. "uploading",
  743. "notifying",
  744. "alerting",
  745. "dragging",
  746. "bouncing",
  747. "powering-down"
  748. ]
  749. } satisfies Record<string, CursorState[]>
  750. const isCursorState = (state: string): state is CursorState => state in POOLS
  751. export const CURSOR_STATES = Object.keys(POOLS).filter(isCursorState)
  752. /* ------------------------------------------------------------------- maths */
  753. const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v))
  754. const noTimestamp = (): number | null => null
  755. const toPath = (ring: Ring) =>
  756. 'M' + ring.map(p => p[0].toFixed(2) + ' ' + p[1].toFixed(2)).join('L') + 'Z'
  757. const clone = (rings: Ring[]): Ring[] =>
  758. rings.map(r => r.map((p): [number, number] => [p[0], p[1]]))
  759. /** Ring centroid for eye projection. */
  760. const ringCentre = (ring: Ring): [number, number] => {
  761. let x = 0
  762. let y = 0
  763. for (const p of ring) {
  764. x += p[0]
  765. y += p[1]
  766. }
  767. return [x / ring.length, y / ring.length]
  768. }
  769. /** Face-space transform placing the face inside a silhouette. */
  770. export const anchorTransform = (a: { x: number; y: number; scale: number }) =>
  771. `translate(${a.x} ${a.y}) scale(${a.scale}) translate(${-FACE_CENTRE[0]} ${-FACE_CENTRE[1]})`
  772. /** Overshooting ease, so a pop-in lands with a little life instead of stopping dead. */
  773. const easeOutBack = (t: number) => {
  774. const c = 1.7
  775. const u = t - 1
  776. return 1 + (c + 1) * u * u * u + c * u * u
  777. }
  778. const easeInOut = (t: number) => (t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t))
  779. /**
  780. * Builds the body's transform for this frame.
  781. *
  782. * `elapsed` is time since the state was entered, which is what one-shot entrances need;
  783. * loops read it too so every mascot on a page doesn't pulse in lockstep.
  784. */
  785. export function bodyTransform(motion: BodyMotion, elapsed: number, strength: number): string {
  786. if (strength <= 0) return ''
  787. const centre = FACE_BOX / 2
  788. const ground = FACE_BOX
  789. const wave = (period: number, phase = 0) => Math.sin((elapsed / period) * Math.PI * 2 + phase)
  790. let dx = 0
  791. let dy = 0
  792. let rotation = motion.tilt ? motion.tilt * strength : 0
  793. let scale = 1
  794. let sx = 1
  795. let sy = 1
  796. if (motion.bob) {
  797. const [amplitude, period] = motion.bob
  798. const p = wave(period)
  799. dy -= amplitude * strength * p
  800. if (motion.squash) {
  801. // Squash at the bottom of the arc, stretch at the top. Volume roughly conserved.
  802. const amount = motion.squash * strength * Math.max(0, -p)
  803. sy = 1 - amount * 0.5
  804. sx = 1 + amount * 0.5
  805. }
  806. }
  807. if (motion.circle) {
  808. const [radius, period] = motion.circle
  809. dx += radius * strength * wave(period)
  810. dy += radius * strength * wave(period, Math.PI / 2)
  811. }
  812. if (motion.sway) {
  813. const [degrees, period] = motion.sway
  814. rotation += degrees * strength * wave(period)
  815. }
  816. if (motion.pulse) {
  817. const [fraction, period] = motion.pulse
  818. scale *= 1 + fraction * strength * wave(period)
  819. }
  820. if (motion.jitter) {
  821. const [amplitude, period] = motion.jitter
  822. // Two incommensurate waves read as nervous rather than metronomic.
  823. dx += amplitude * strength * wave(period)
  824. dy += amplitude * strength * wave(period * 0.63, 1.1)
  825. }
  826. if (motion.enter) {
  827. const [from, duration] = motion.enter
  828. const t = elapsed / duration
  829. scale *= t >= 1 ? 1 : from + (1 - from) * easeOutBack(Math.max(t, 0))
  830. }
  831. if (motion.settle !== undefined) {
  832. const t = Math.min(Math.max(elapsed / SETTLE_MS, 0), 1)
  833. scale *= 1 + (motion.settle - 1) * easeInOut(t) * strength
  834. }
  835. const parts: string[] = []
  836. if (dx || dy) parts.push(`translate(${dx.toFixed(2)} ${dy.toFixed(2)})`)
  837. if (rotation) parts.push(`rotate(${rotation.toFixed(2)} ${centre} ${centre})`)
  838. if (scale !== 1) {
  839. parts.push(`translate(${centre} ${centre}) scale(${scale.toFixed(4)}) translate(${-centre} ${-centre})`)
  840. }
  841. if (sx !== 1 || sy !== 1) {
  842. // Squash pivots on the ground, not the middle — otherwise it floats instead of landing.
  843. parts.push(`translate(${centre} ${ground}) scale(${sx.toFixed(4)} ${sy.toFixed(4)}) translate(${-centre} ${-ground})`)
  844. }
  845. return parts.join(' ')
  846. }
  847. /* --------------------------------------------------------------- component */
  848. export interface CursorOptions {
  849. state: CursorState
  850. paused: boolean
  851. color: string
  852. silhouette: CursorSilhouette
  853. gaze?: { x?: number; y?: number }
  854. expression?: number
  855. fixedTime?: number
  856. /** Live activity rings. A saved "working" face does not wear them. */
  857. ribbons?: boolean
  858. /** Draw activity around an uploaded avatar without drawing a replacement face. */
  859. effectsOnly?: boolean
  860. }
  861. type Engine = ReturnType<typeof newEngine>
  862. function newEngine() {
  863. return { current: clone(EXPRESSIONS[0]), target: EXPRESSIONS[0], expression: 0, morph: 1, velocity: 0, blinkStart: noTimestamp(), spinStart: noTimestamp(), spinDuration: 900, last: 0, stateStart: 0, lastState: 'idle' as CursorState, lastBodyTransform: '',
  864. props: {state: 'idle' as CursorState, expression: undefined as number | undefined, gaze: undefined as {x?:number;y?:number}|undefined, turn:0, spring:7, eyeScale:1, paused:true, motionStrength:0, effects:true, glyphs:true, ribbons:true} }
  865. }
  866. let nextId = 0
  867. export function mountCursorAvatar(svg: SVGSVGElement, initial: CursorOptions) {
  868. const uid = `maus-${++nextId}`
  869. const engine = { current: newEngine() }
  870. const paintRef = { current: initial.color }
  871. const node = <T extends Element>(id: string) => ({ current: svg.querySelector<T>(`[data-part="${id}"]`)! })
  872. svg.setAttribute('viewBox', VIEW_BOX)
  873. svg.innerHTML = `<defs><clipPath id="${uid}-clip" data-part="clip" /></defs><g data-part="trails"/><g data-part="bodyGroup"><g data-part="bodyContent"><g data-part="outline"/><g clip-path="url(#${uid}-clip)"><g data-part="face"><path data-part="eye0" fill="white"/><path data-part="eye1" fill="white"/></g></g></g><g data-part="glyph" opacity="0"/></g><g data-part="confetti"/>`
  874. const eye0=node<SVGPathElement>('eye0'), eye1=node<SVGPathElement>('eye1')
  875. const bodyGroup=node<SVGGElement>('bodyGroup'), bodyContent=node<SVGGElement>('bodyContent'), trailLayer=node<SVGGElement>('trails'), confettiLayer=node<SVGGElement>('confetti'), glyphLayer=node<SVGGElement>('glyph')
  876. const selectExpression = (index:number, immediate = false) => {
  877. const e=engine.current, i=((index % EXPRESSION_COUNT)+EXPRESSION_COUNT)%EXPRESSION_COUNT
  878. if (i === e.expression && e.morph >= 1 && !immediate) return
  879. e.current=displayed(e)
  880. e.target=EXPRESSIONS[i];e.expression=i;e.morph=immediate?1:0;e.velocity=0
  881. }
  882. const draw = (e: Engine, now: number, spinTurn: number) => {
  883. const p = e.props
  884. const rings = displayed(e)
  885. const gx = clamp(p.gaze?.x ?? 0, -1, 1) * GAZE_TRAVEL.x
  886. const gy = clamp(p.gaze?.y ?? 0, -1, 1) * GAZE_TRAVEL.y
  887. const radians = (((p.turn ?? 0) + spinTurn) * Math.PI) / 180
  888. const base = p.eyeScale ?? 1
  889. const blink = blinkScale(e, now)
  890. rings.forEach((ring, index) => {
  891. const el = index === 0 ? eye0.current : eye1.current
  892. if (!el) return
  893. const c = ringCentre(ring)
  894. const baseLongitude = Math.asin(clamp((c[0] - SPHERE_C) / SPHERE_R, -1, 1))
  895. const longitude = baseLongitude + radians
  896. const depth = Math.cos(longitude)
  897. const perspective = Math.max(depth, 0.02) / Math.max(Math.cos(baseLongitude), 0.02)
  898. el.setAttribute('d', toPath(ring))
  899. el.setAttribute(
  900. 'transform',
  901. `translate(${(SPHERE_C + SPHERE_R * Math.sin(longitude) + gx).toFixed(2)} ${(
  902. c[1] + gy
  903. ).toFixed(2)}) scale(${clamp(perspective * base, 0.02, 2.4).toFixed(4)} ${clamp(
  904. blink * base,
  905. 0.02,
  906. 2.4
  907. ).toFixed(4)}) translate(${(-c[0]).toFixed(2)} ${(-c[1]).toFixed(2)})`
  908. )
  909. el.style.opacity = depth > 0.02 ? '1' : '0'
  910. })
  911. // The body. One-shot entrances need time since the state began, so track that here
  912. // rather than in an effect — the loop already has the clock.
  913. const bodyEl = bodyGroup.current
  914. if (bodyEl) {
  915. if (p.state !== e.lastState) {
  916. e.lastState = p.state
  917. e.stateStart = now
  918. }
  919. const transform = bodyTransform(
  920. MOTION[p.state] ?? {},
  921. now - e.stateStart,
  922. p.motionStrength ?? 1
  923. )
  924. if (transform !== e.lastBodyTransform) {
  925. e.lastBodyTransform = transform
  926. if (transform) bodyEl.setAttribute('transform', transform)
  927. else bodyEl.removeAttribute('transform')
  928. }
  929. }
  930. updateEffects({
  931. trails: trailLayer.current,
  932. confetti: confettiLayer.current,
  933. glyph: glyphLayer.current,
  934. bodyContent: bodyContent.current,
  935. state: p.state,
  936. elapsed: now - e.stateStart,
  937. strength: p.motionStrength ?? 1,
  938. paint: paintRef.current,
  939. showEffects: p.effects !== false,
  940. showGlyphs: p.glyphs !== false,
  941. showTrails: p.ribbons !== false,
  942. })
  943. }
  944. let options=initial, frame=0, blinkTimer: ReturnType<typeof setTimeout>|undefined, expressionTimer:ReturnType<typeof setTimeout>|undefined, disposed=false
  945. function stop() { cancelAnimationFrame(frame);frame=0;clearTimeout(blinkTimer);clearTimeout(expressionTimer) }
  946. function armBlink() {
  947. const range=BLINK[options.state]
  948. if(options.paused||options.fixedTime!==undefined||!range)return
  949. blinkTimer=setTimeout(()=>{engine.current.blinkStart=performance.now();armBlink()},range[0]+Math.random()*(range[1]-range[0]))
  950. }
  951. function armExpression() {
  952. if(options.paused||options.fixedTime!==undefined||options.expression!==undefined)return
  953. const [lo,hi]=EXPR_CADENCE[options.state]
  954. expressionTimer=setTimeout(()=>{const pool=POOLS[options.state].filter(i=>i!==engine.current.expression);selectExpression(pool.length?pool[Math.floor(Math.random()*pool.length)]:POOLS[options.state][0]);armExpression()},lo+Math.random()*(hi-lo))
  955. }
  956. function step(now:number) {
  957. frame=0
  958. if(disposed||options.paused||options.fixedTime!==undefined)return
  959. const e=engine.current, dt=Math.min((now-e.last)/1000,.1);e.last=now
  960. e.velocity+=(-14*e.velocity-49*(e.morph-1))*dt;e.morph+=e.velocity*dt
  961. if(!Number.isFinite(e.morph)){e.morph=1;e.velocity=0}
  962. draw(e,now,0);frame=requestAnimationFrame(step)
  963. }
  964. let lastBody = '', lastClip = ''
  965. function update(next:CursorOptions) {
  966. const changed=options.state!==next.state||options.expression!==next.expression
  967. const resume=options.paused!==next.paused||options.fixedTime!==next.fixedTime
  968. options=next
  969. svg.setAttribute('viewBox', next.silhouette.viewBox ?? VIEW_BOX)
  970. const e=engine.current, now=performance.now();paintRef.current=next.color
  971. e.props={...e.props,state:next.state,expression:next.expression,gaze:next.gaze,paused:next.paused,motionStrength:next.paused?0:1,ribbons:next.ribbons!==false}
  972. const [vx,vy,vw,vh] = (next.silhouette.viewBox ?? VIEW_BOX).split(/\s+/).map(Number)
  973. const cx = vx + vw / 2, cy = vy + vh / 2
  974. const hasTrails = !next.paused && next.ribbons !== false && !!EFFECTS[next.state]?.trails
  975. bodyGroup.current.style.visibility = next.effectsOnly ? 'hidden' : ''
  976. // Leave room for live ribbons while keeping the saved silhouette and resting size.
  977. bodyContent.current.setAttribute('transform', hasTrails ? `translate(${cx} ${cy}) scale(.72) translate(${-cx} ${-cy})` : '')
  978. trailLayer.current.setAttribute('transform', `translate(${cx} ${cy}) scale(${Math.min(vw,vh) / FACE_BOX}) translate(${-FACE_BOX / 2} ${-FACE_BOX / 2})`)
  979. const outline=node<SVGGElement>('outline').current, clip=node<SVGClipPathElement>('clip').current, face=node<SVGGElement>('face').current
  980. const body=next.silhouette.body.replace(/\{\{GRADIENT\}\}/g,next.color)
  981. if(lastBody!==body){outline.innerHTML=body;lastBody=body}
  982. if(lastClip!==next.silhouette.clip){clip.innerHTML=next.silhouette.clip;lastClip=next.silhouette.clip}
  983. outline.setAttribute('transform',next.silhouette.fit||'');clip.setAttribute('transform',next.silhouette.fit||'');face.setAttribute('transform',anchorTransform(next.silhouette.anchor))
  984. if(changed||next.paused||e.last===0)selectExpression(next.expression??POOLS[next.state][0],next.paused||e.last===0)
  985. if(changed||e.last===0){e.stateStart=now;e.lastState=next.state}
  986. e.last=now
  987. if(changed||resume||!frame){stop();armBlink();armExpression()}
  988. draw(e,next.paused ? e.stateStart+(next.fixedTime??0) : next.fixedTime===undefined?now:e.stateStart+next.fixedTime,0)
  989. if(!next.paused&&next.fixedTime===undefined&&!frame)frame=requestAnimationFrame(step)
  990. }
  991. update(initial)
  992. return {update,dispose(){disposed=true;stop();svg.replaceChildren()}}
  993. }
  994. function displayed(e: { current: Ring[]; target: Ring[]; morph: number }): Ring[] {
  995. const m = clamp(e.morph, 0, 1)
  996. return e.current.map((ring, eye) =>
  997. ring.map((p, i): [number, number] => [
  998. p[0] + (e.target[eye][i][0] - p[0]) * m,
  999. p[1] + (e.target[eye][i][1] - p[1]) * m,
  1000. ])
  1001. )
  1002. }
  1003. function blinkScale(e: { blinkStart: number | null }, now: number) {
  1004. if (e.blinkStart === null) return 1
  1005. const t = (now - e.blinkStart) / 320
  1006. if (t >= 1) {
  1007. e.blinkStart = null
  1008. return 1
  1009. }
  1010. // Fast close, slower open.
  1011. return Math.max(t < 0.42 ? 1 - t / 0.42 : (t - 0.42) / 0.58, 0.04)
  1012. }