cursor-engine.ts 32 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070
  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. orbit: { trails: { count: 3, period: 2600, radius: 128 } },
  214. radar: { trails: { count: 2, period: 2000, radius: 132 } },
  215. progress: { trails: { count: 3, period: 1800, radius: 124 } },
  216. loading: { trails: { count: 2, period: 2200, radius: 126 } },
  217. uploading: { trails: { count: 2, period: 1700, radius: 122 } },
  218. sending: { trails: { count: 2, period: 1500, radius: 120 } },
  219. receiving: { trails: { count: 2, period: 1500, radius: 120 } },
  220. // The mascot standing aside to show a symbol.
  221. alerting: { glyph: { markup: GLYPH_BANG, period: 2600, hold: 1100 } },
  222. notifying: { glyph: { markup: GLYPH_BANG, period: 4200, hold: 900 } },
  223. confused: { glyph: { markup: GLYPH_QUERY, period: 5200, hold: 1200 } },
  224. }
  225. const SVG_NS = 'http://www.w3.org/2000/svg'
  226. /** Grows or shrinks a layer's pool of <path> children to exactly `count`. */
  227. function poolPaths(layer: SVGGElement, count: number): SVGPathElement[] {
  228. while (layer.childNodes.length > count) layer.removeChild(layer.lastChild!)
  229. while (layer.childNodes.length < count) {
  230. const path = document.createElementNS(SVG_NS, 'path')
  231. path.setAttribute('stroke-linecap', 'round')
  232. path.setAttribute('fill', 'none')
  233. layer.appendChild(path)
  234. }
  235. return Array.from(layer.querySelectorAll<SVGPathElement>(':scope > path'))
  236. }
  237. /** Confetti: short curved strokes thrown outward, arcing down as they fade. */
  238. function drawConfetti(
  239. layer: SVGGElement,
  240. spec: ConfettiSpec,
  241. elapsed: number,
  242. strength: number,
  243. cx: number,
  244. cy: number
  245. ) {
  246. const paths = poolPaths(layer, spec.count)
  247. for (let i = 0; i < spec.count; i++) {
  248. const path = paths[i]
  249. const seedA = hash01(i + 1)
  250. const seedB = hash01(i + 41)
  251. const seedC = hash01(i + 91)
  252. // Stagger the pieces across the burst window so they don't leave as one wall.
  253. const t = (((elapsed + seedC * spec.period) % spec.period) / spec.life) * 1
  254. if (t > 1) {
  255. path.setAttribute('opacity', '0')
  256. continue
  257. }
  258. const angle = seedA * TAU
  259. const travel = spec.spread * (0.45 + seedB * 0.55) * (1 - (1 - t) * (1 - t))
  260. const distance = (spec.origin + travel) * strength
  261. const gravity = 34 * strength * t * t
  262. const x = cx + Math.cos(angle) * distance
  263. const y = cy + Math.sin(angle) * distance + gravity
  264. const length = 11 + seedB * 12
  265. const spin = angle + (seedC - 0.5) * 5 * t
  266. const ex = x + Math.cos(spin) * length
  267. const ey = y + Math.sin(spin) * length
  268. // A slight bend reads as paper rather than a matchstick.
  269. const bend = (seedA - 0.5) * length * 0.7
  270. const mx = (x + ex) / 2 - Math.sin(spin) * bend
  271. const my = (y + ey) / 2 + Math.cos(spin) * bend
  272. path.setAttribute('d', `M${x.toFixed(1)} ${y.toFixed(1)} Q${mx.toFixed(1)} ${my.toFixed(1)} ${ex.toFixed(1)} ${ey.toFixed(1)}`)
  273. path.setAttribute('stroke', CONFETTI_COLORS[i % CONFETTI_COLORS.length])
  274. path.setAttribute('stroke-width', (4 + seedB * 2.4).toFixed(1))
  275. path.setAttribute('opacity', (t < 0.12 ? t / 0.12 : 1 - (t - 0.12) / 0.88).toFixed(3))
  276. }
  277. }
  278. /** Ribbons: arcs sweeping around the body on their own periods. */
  279. function drawTrails(
  280. layer: SVGGElement,
  281. spec: TrailSpec,
  282. elapsed: number,
  283. strength: number,
  284. cx: number,
  285. cy: number
  286. ) {
  287. const paths = poolPaths(layer, spec.count)
  288. const SAMPLES = 12
  289. for (let i = 0; i < spec.count; i++) {
  290. const path = paths[i]
  291. const seedA = hash01(i + 3)
  292. const seedB = hash01(i + 29)
  293. const direction = i % 2 === 0 ? 1 : -1
  294. const period = spec.period * (0.8 + seedA * 0.5)
  295. const radius = spec.radius * strength * (0.78 + seedB * 0.4)
  296. const start = direction * (elapsed / period) * TAU + seedA * TAU
  297. const span = 0.85 + seedB * 0.7
  298. let d = ''
  299. for (let s = 0; s <= SAMPLES; s++) {
  300. const k = s / SAMPLES
  301. const angle = start + span * k
  302. // Breathe the radius along the arc so the ribbon curls instead of tracing a circle.
  303. const r = radius * (1 + Math.sin(k * Math.PI) * 0.14)
  304. const x = cx + Math.cos(angle) * r
  305. const y = cy + Math.sin(angle) * r * 0.78
  306. d += (s === 0 ? 'M' : 'L') + x.toFixed(1) + ' ' + y.toFixed(1)
  307. }
  308. path.setAttribute('d', d)
  309. path.setAttribute('stroke', RIBBON_COLORS[i % RIBBON_COLORS.length])
  310. path.setAttribute('stroke-width', (5 + seedA * 3).toFixed(1))
  311. path.setAttribute('opacity', '0.85')
  312. }
  313. }
  314. /** How present the glyph is right now, 0..1, with eased edges. */
  315. function glyphAmount(spec: GlyphSpec, elapsed: number): number {
  316. const position = elapsed % spec.period
  317. const start = spec.period - spec.hold
  318. if (position < start) return 0
  319. const into = position - start
  320. const remaining = spec.hold - into
  321. const EASE = 200
  322. return Math.max(0, Math.min(1, Math.min(into, remaining) / EASE))
  323. }
  324. export interface EffectFrame {
  325. trails: SVGGElement | null
  326. confetti: SVGGElement | null
  327. glyph: SVGGElement | null
  328. bodyContent: SVGGElement | null
  329. state: CursorState
  330. elapsed: number
  331. strength: number
  332. paint: string
  333. showEffects: boolean
  334. showGlyphs: boolean
  335. }
  336. /** Called once per frame from the engine loop. */
  337. export function updateEffects(frame: EffectFrame) {
  338. const spec = EFFECTS[frame.state]
  339. const centre = FACE_BOX / 2
  340. const strength = frame.strength
  341. if (frame.trails) {
  342. if (spec?.trails && frame.showEffects && strength > 0) {
  343. drawTrails(frame.trails, spec.trails, frame.elapsed, strength, centre, centre)
  344. } else if (frame.trails.childNodes.length) {
  345. frame.trails.replaceChildren()
  346. }
  347. }
  348. if (frame.confetti) {
  349. if (spec?.confetti && frame.showEffects && strength > 0) {
  350. drawConfetti(frame.confetti, spec.confetti, frame.elapsed, strength, centre, centre)
  351. } else if (frame.confetti.childNodes.length) {
  352. frame.confetti.replaceChildren()
  353. }
  354. }
  355. if (frame.glyph && frame.bodyContent) {
  356. if (spec?.glyph && frame.showGlyphs) {
  357. const amount = glyphAmount(spec.glyph, frame.elapsed)
  358. const markup = spec.glyph.markup.replace(/\{\{GRADIENT\}\}/g, frame.paint)
  359. if (frame.glyph.getAttribute('data-glyph') !== markup) {
  360. frame.glyph.setAttribute('data-glyph', markup)
  361. frame.glyph.innerHTML = markup
  362. }
  363. frame.glyph.style.opacity = String(amount)
  364. // Scale up as it arrives, so the swap reads as a transformation.
  365. const scale = 0.72 + 0.28 * amount
  366. frame.glyph.setAttribute(
  367. 'transform',
  368. `translate(${centre} ${centre}) scale(${scale.toFixed(3)}) translate(${-centre} ${-centre})`
  369. )
  370. // The body steps aside rather than sitting behind the glyph.
  371. frame.bodyContent.style.opacity = String(1 - amount)
  372. } else {
  373. if (frame.glyph.getAttribute('data-glyph')) {
  374. frame.glyph.removeAttribute('data-glyph')
  375. frame.glyph.replaceChildren()
  376. }
  377. frame.glyph.style.opacity = '0'
  378. frame.bodyContent.style.opacity = '1'
  379. }
  380. }
  381. }
  382. /* ------------------------------------------------------------------ states */
  383. export type CursorState =
  384. | "sleeping"
  385. | "waking"
  386. | "idle"
  387. | "listening"
  388. | "thinking"
  389. | "searching"
  390. | "working"
  391. | "excited"
  392. | "surprised"
  393. | "suspicious"
  394. | "angry"
  395. | "drowsy"
  396. | "happy"
  397. | "curious"
  398. | "confused"
  399. | "bored"
  400. | "proud"
  401. | "shy"
  402. | "sad"
  403. | "laughing"
  404. | "scared"
  405. | "playful"
  406. | "celebrate"
  407. | "orbit"
  408. | "radar"
  409. | "progress"
  410. | "spawning"
  411. | "humming"
  412. | "loading"
  413. | "dictating"
  414. | "sending"
  415. | "receiving"
  416. | "uploading"
  417. | "writing"
  418. | "notifying"
  419. | "alerting"
  420. | "bouncing"
  421. | "dragging"
  422. | "powering-down"
  423. /**
  424. * Which expressions a state cycles through. The first is its resting face, chosen as the
  425. * pool's most forward-facing member so a mascot at rest looks at you rather than past you.
  426. */
  427. export const POOLS: Record<CursorState, number[]> = Object.fromEntries(Object.entries(LAOA_DATA.pools).map(([key,values]) => [key,[...values]])) as Record<CursorState, number[]>
  428. /** How long a state holds an expression before drifting to another, in ms. */
  429. const EXPR_CADENCE = {
  430. sleeping: [
  431. 6000,
  432. 10000
  433. ],
  434. waking: [
  435. 800,
  436. 800
  437. ],
  438. idle: [
  439. 9000,
  440. 16000
  441. ],
  442. listening: [
  443. 2800,
  444. 5000
  445. ],
  446. thinking: [
  447. 2000,
  448. 3600
  449. ],
  450. searching: [
  451. 1000,
  452. 1800
  453. ],
  454. working: [
  455. 1800,
  456. 3200
  457. ],
  458. excited: [
  459. 1100,
  460. 2000
  461. ],
  462. surprised: [
  463. 2500,
  464. 4000
  465. ],
  466. suspicious: [
  467. 2600,
  468. 4500
  469. ],
  470. angry: [
  471. 2200,
  472. 3800
  473. ],
  474. drowsy: [
  475. 4000,
  476. 8000
  477. ],
  478. happy: [
  479. 2500,
  480. 4500
  481. ],
  482. curious: [
  483. 1800,
  484. 3200
  485. ],
  486. confused: [
  487. 2200,
  488. 3800
  489. ],
  490. bored: [
  491. 3500,
  492. 6000
  493. ],
  494. proud: [
  495. 3500,
  496. 6000
  497. ],
  498. shy: [
  499. 3000,
  500. 5500
  501. ],
  502. sad: [
  503. 4000,
  504. 7000
  505. ],
  506. laughing: [
  507. 1200,
  508. 2400
  509. ],
  510. scared: [
  511. 900,
  512. 1800
  513. ],
  514. playful: [
  515. 1500,
  516. 3000
  517. ],
  518. celebrate: [
  519. 1400,
  520. 2600
  521. ],
  522. orbit: [
  523. 4000,
  524. 8000
  525. ],
  526. radar: [
  527. 4000,
  528. 8000
  529. ],
  530. progress: [
  531. 4000,
  532. 8000
  533. ],
  534. spawning: [
  535. 1200,
  536. 1200
  537. ],
  538. humming: [
  539. 5000,
  540. 9000
  541. ],
  542. loading: [
  543. 6000,
  544. 10000
  545. ],
  546. dictating: [
  547. 4000,
  548. 8000
  549. ],
  550. sending: [
  551. 4000,
  552. 8000
  553. ],
  554. receiving: [
  555. 4000,
  556. 8000
  557. ],
  558. uploading: [
  559. 4000,
  560. 8000
  561. ],
  562. writing: [
  563. 4000,
  564. 8000
  565. ],
  566. notifying: [
  567. 1500,
  568. 2600
  569. ],
  570. alerting: [
  571. 2000,
  572. 3600
  573. ],
  574. bouncing: [
  575. 3000,
  576. 6000
  577. ],
  578. dragging: [
  579. 1600,
  580. 3000
  581. ],
  582. "powering-down": [
  583. 6000,
  584. 9000
  585. ]
  586. } satisfies Record<CursorState, [number, number]>
  587. /** Blink cadence in ms, or null for states that never blink. */
  588. const BLINK = {
  589. sleeping: null,
  590. waking: null,
  591. idle: [
  592. 6000,
  593. 14000
  594. ],
  595. listening: [
  596. 3000,
  597. 7000
  598. ],
  599. thinking: [
  600. 3500,
  601. 7000
  602. ],
  603. searching: [
  604. 1600,
  605. 4000
  606. ],
  607. working: [
  608. 2800,
  609. 5500
  610. ],
  611. excited: [
  612. 2000,
  613. 4000
  614. ],
  615. surprised: [
  616. 1800,
  617. 3500
  618. ],
  619. suspicious: [
  620. 4500,
  621. 8000
  622. ],
  623. angry: [
  624. 3500,
  625. 7000
  626. ],
  627. drowsy: null,
  628. happy: [
  629. 2500,
  630. 5000
  631. ],
  632. curious: [
  633. 2500,
  634. 5500
  635. ],
  636. confused: [
  637. 2800,
  638. 5500
  639. ],
  640. bored: [
  641. 4000,
  642. 8000
  643. ],
  644. proud: [
  645. 3500,
  646. 7000
  647. ],
  648. shy: [
  649. 3000,
  650. 6000
  651. ],
  652. sad: [
  653. 4000,
  654. 8000
  655. ],
  656. laughing: [
  657. 2500,
  658. 5000
  659. ],
  660. scared: [
  661. 1200,
  662. 3000
  663. ],
  664. playful: [
  665. 2000,
  666. 4500
  667. ],
  668. celebrate: [
  669. 2200,
  670. 4500
  671. ],
  672. orbit: null,
  673. radar: null,
  674. progress: null,
  675. spawning: null,
  676. humming: [
  677. 4000,
  678. 8000
  679. ],
  680. loading: null,
  681. dictating: null,
  682. sending: null,
  683. receiving: null,
  684. uploading: null,
  685. writing: null,
  686. notifying: [
  687. 2000,
  688. 4000
  689. ],
  690. alerting: null,
  691. bouncing: null,
  692. dragging: [
  693. 2200,
  694. 4500
  695. ],
  696. "powering-down": null
  697. } satisfies Record<CursorState, [number, number] | null>
  698. /** Grouping, for pickers and docs. */
  699. export const STATE_GROUPS = {
  700. "Cycle de vie": [
  701. "sleeping",
  702. "waking",
  703. "idle",
  704. "listening",
  705. "thinking",
  706. "searching",
  707. "working"
  708. ],
  709. "Réactions": [
  710. "excited",
  711. "surprised",
  712. "suspicious",
  713. "angry",
  714. "drowsy",
  715. "happy",
  716. "curious",
  717. "confused",
  718. "bored",
  719. "proud",
  720. "shy",
  721. "sad",
  722. "laughing",
  723. "scared",
  724. "playful",
  725. "celebrate"
  726. ],
  727. "Morphes agent": [
  728. "orbit",
  729. "radar",
  730. "progress"
  731. ],
  732. "Cycle produit": [
  733. "spawning",
  734. "humming",
  735. "loading",
  736. "dictating",
  737. "writing",
  738. "sending",
  739. "receiving",
  740. "uploading",
  741. "notifying",
  742. "alerting",
  743. "dragging",
  744. "bouncing",
  745. "powering-down"
  746. ]
  747. } satisfies Record<string, CursorState[]>
  748. const isCursorState = (state: string): state is CursorState => state in POOLS
  749. export const CURSOR_STATES = Object.keys(POOLS).filter(isCursorState)
  750. /* ------------------------------------------------------------------- maths */
  751. const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v))
  752. const noTimestamp = (): number | null => null
  753. const toPath = (ring: Ring) =>
  754. 'M' + ring.map(p => p[0].toFixed(2) + ' ' + p[1].toFixed(2)).join('L') + 'Z'
  755. const clone = (rings: Ring[]): Ring[] =>
  756. rings.map(r => r.map((p): [number, number] => [p[0], p[1]]))
  757. /** Ring centroid for eye projection. */
  758. const ringCentre = (ring: Ring): [number, number] => {
  759. let x = 0
  760. let y = 0
  761. for (const p of ring) {
  762. x += p[0]
  763. y += p[1]
  764. }
  765. return [x / ring.length, y / ring.length]
  766. }
  767. /** Face-space transform placing the face inside a silhouette. */
  768. export const anchorTransform = (a: { x: number; y: number; scale: number }) =>
  769. `translate(${a.x} ${a.y}) scale(${a.scale}) translate(${-FACE_CENTRE[0]} ${-FACE_CENTRE[1]})`
  770. /** Overshooting ease, so a pop-in lands with a little life instead of stopping dead. */
  771. const easeOutBack = (t: number) => {
  772. const c = 1.7
  773. const u = t - 1
  774. return 1 + (c + 1) * u * u * u + c * u * u
  775. }
  776. const easeInOut = (t: number) => (t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t))
  777. /**
  778. * Builds the body's transform for this frame.
  779. *
  780. * `elapsed` is time since the state was entered, which is what one-shot entrances need;
  781. * loops read it too so every mascot on a page doesn't pulse in lockstep.
  782. */
  783. export function bodyTransform(motion: BodyMotion, elapsed: number, strength: number): string {
  784. if (strength <= 0) return ''
  785. const centre = FACE_BOX / 2
  786. const ground = FACE_BOX
  787. const wave = (period: number, phase = 0) => Math.sin((elapsed / period) * Math.PI * 2 + phase)
  788. let dx = 0
  789. let dy = 0
  790. let rotation = motion.tilt ? motion.tilt * strength : 0
  791. let scale = 1
  792. let sx = 1
  793. let sy = 1
  794. if (motion.bob) {
  795. const [amplitude, period] = motion.bob
  796. const p = wave(period)
  797. dy -= amplitude * strength * p
  798. if (motion.squash) {
  799. // Squash at the bottom of the arc, stretch at the top. Volume roughly conserved.
  800. const amount = motion.squash * strength * Math.max(0, -p)
  801. sy = 1 - amount * 0.5
  802. sx = 1 + amount * 0.5
  803. }
  804. }
  805. if (motion.circle) {
  806. const [radius, period] = motion.circle
  807. dx += radius * strength * wave(period)
  808. dy += radius * strength * wave(period, Math.PI / 2)
  809. }
  810. if (motion.sway) {
  811. const [degrees, period] = motion.sway
  812. rotation += degrees * strength * wave(period)
  813. }
  814. if (motion.pulse) {
  815. const [fraction, period] = motion.pulse
  816. scale *= 1 + fraction * strength * wave(period)
  817. }
  818. if (motion.jitter) {
  819. const [amplitude, period] = motion.jitter
  820. // Two incommensurate waves read as nervous rather than metronomic.
  821. dx += amplitude * strength * wave(period)
  822. dy += amplitude * strength * wave(period * 0.63, 1.1)
  823. }
  824. if (motion.enter) {
  825. const [from, duration] = motion.enter
  826. const t = elapsed / duration
  827. scale *= t >= 1 ? 1 : from + (1 - from) * easeOutBack(Math.max(t, 0))
  828. }
  829. if (motion.settle !== undefined) {
  830. const t = Math.min(Math.max(elapsed / SETTLE_MS, 0), 1)
  831. scale *= 1 + (motion.settle - 1) * easeInOut(t) * strength
  832. }
  833. const parts: string[] = []
  834. if (dx || dy) parts.push(`translate(${dx.toFixed(2)} ${dy.toFixed(2)})`)
  835. if (rotation) parts.push(`rotate(${rotation.toFixed(2)} ${centre} ${centre})`)
  836. if (scale !== 1) {
  837. parts.push(`translate(${centre} ${centre}) scale(${scale.toFixed(4)}) translate(${-centre} ${-centre})`)
  838. }
  839. if (sx !== 1 || sy !== 1) {
  840. // Squash pivots on the ground, not the middle — otherwise it floats instead of landing.
  841. parts.push(`translate(${centre} ${ground}) scale(${sx.toFixed(4)} ${sy.toFixed(4)}) translate(${-centre} ${-ground})`)
  842. }
  843. return parts.join(' ')
  844. }
  845. /* --------------------------------------------------------------- component */
  846. export interface CursorOptions {
  847. state: CursorState
  848. paused: boolean
  849. color: string
  850. silhouette: CursorSilhouette
  851. gaze?: { x?: number; y?: number }
  852. expression?: number
  853. fixedTime?: number
  854. }
  855. type Engine = ReturnType<typeof newEngine>
  856. function newEngine() {
  857. 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: '',
  858. 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} }
  859. }
  860. let nextId = 0
  861. export function mountCursorAvatar(svg: SVGSVGElement, initial: CursorOptions) {
  862. const uid = `maus-${++nextId}`
  863. const engine = { current: newEngine() }
  864. const paintRef = { current: initial.color }
  865. const node = <T extends Element>(id: string) => ({ current: svg.querySelector<T>(`[data-part="${id}"]`)! })
  866. svg.setAttribute('viewBox', VIEW_BOX)
  867. 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"/>`
  868. const eye0=node<SVGPathElement>('eye0'), eye1=node<SVGPathElement>('eye1')
  869. const bodyGroup=node<SVGGElement>('bodyGroup'), bodyContent=node<SVGGElement>('bodyContent'), trailLayer=node<SVGGElement>('trails'), confettiLayer=node<SVGGElement>('confetti'), glyphLayer=node<SVGGElement>('glyph')
  870. const selectExpression = (index:number, immediate = false) => {
  871. const e=engine.current, i=((index % EXPRESSION_COUNT)+EXPRESSION_COUNT)%EXPRESSION_COUNT
  872. if (i === e.expression && e.morph >= 1 && !immediate) return
  873. e.current=displayed(e)
  874. e.target=EXPRESSIONS[i];e.expression=i;e.morph=immediate?1:0;e.velocity=0
  875. }
  876. const draw = (e: Engine, now: number, spinTurn: number) => {
  877. const p = e.props
  878. const rings = displayed(e)
  879. const gx = clamp(p.gaze?.x ?? 0, -1, 1) * GAZE_TRAVEL.x
  880. const gy = clamp(p.gaze?.y ?? 0, -1, 1) * GAZE_TRAVEL.y
  881. const radians = (((p.turn ?? 0) + spinTurn) * Math.PI) / 180
  882. const base = p.eyeScale ?? 1
  883. const blink = blinkScale(e, now)
  884. rings.forEach((ring, index) => {
  885. const el = index === 0 ? eye0.current : eye1.current
  886. if (!el) return
  887. const c = ringCentre(ring)
  888. const baseLongitude = Math.asin(clamp((c[0] - SPHERE_C) / SPHERE_R, -1, 1))
  889. const longitude = baseLongitude + radians
  890. const depth = Math.cos(longitude)
  891. const perspective = Math.max(depth, 0.02) / Math.max(Math.cos(baseLongitude), 0.02)
  892. el.setAttribute('d', toPath(ring))
  893. el.setAttribute(
  894. 'transform',
  895. `translate(${(SPHERE_C + SPHERE_R * Math.sin(longitude) + gx).toFixed(2)} ${(
  896. c[1] + gy
  897. ).toFixed(2)}) scale(${clamp(perspective * base, 0.02, 2.4).toFixed(4)} ${clamp(
  898. blink * base,
  899. 0.02,
  900. 2.4
  901. ).toFixed(4)}) translate(${(-c[0]).toFixed(2)} ${(-c[1]).toFixed(2)})`
  902. )
  903. el.style.opacity = depth > 0.02 ? '1' : '0'
  904. })
  905. // The body. One-shot entrances need time since the state began, so track that here
  906. // rather than in an effect — the loop already has the clock.
  907. const bodyEl = bodyGroup.current
  908. if (bodyEl) {
  909. if (p.state !== e.lastState) {
  910. e.lastState = p.state
  911. e.stateStart = now
  912. }
  913. const transform = bodyTransform(
  914. MOTION[p.state] ?? {},
  915. now - e.stateStart,
  916. p.motionStrength ?? 1
  917. )
  918. if (transform !== e.lastBodyTransform) {
  919. e.lastBodyTransform = transform
  920. if (transform) bodyEl.setAttribute('transform', transform)
  921. else bodyEl.removeAttribute('transform')
  922. }
  923. }
  924. updateEffects({
  925. trails: trailLayer.current,
  926. confetti: confettiLayer.current,
  927. glyph: glyphLayer.current,
  928. bodyContent: bodyContent.current,
  929. state: p.state,
  930. elapsed: now - e.stateStart,
  931. strength: p.motionStrength ?? 1,
  932. paint: paintRef.current,
  933. showEffects: p.effects !== false,
  934. showGlyphs: p.glyphs !== false,
  935. })
  936. }
  937. let options=initial, frame=0, blinkTimer: ReturnType<typeof setTimeout>|undefined, expressionTimer:ReturnType<typeof setTimeout>|undefined, disposed=false
  938. function stop() { cancelAnimationFrame(frame);frame=0;clearTimeout(blinkTimer);clearTimeout(expressionTimer) }
  939. function armBlink() {
  940. const range=BLINK[options.state]
  941. if(options.paused||options.fixedTime!==undefined||!range)return
  942. blinkTimer=setTimeout(()=>{engine.current.blinkStart=performance.now();armBlink()},range[0]+Math.random()*(range[1]-range[0]))
  943. }
  944. function armExpression() {
  945. if(options.paused||options.fixedTime!==undefined||options.expression!==undefined)return
  946. const [lo,hi]=EXPR_CADENCE[options.state]
  947. 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))
  948. }
  949. function step(now:number) {
  950. frame=0
  951. if(disposed||options.paused||options.fixedTime!==undefined)return
  952. const e=engine.current, dt=Math.min((now-e.last)/1000,.1);e.last=now
  953. e.velocity+=(-14*e.velocity-49*(e.morph-1))*dt;e.morph+=e.velocity*dt
  954. if(!Number.isFinite(e.morph)){e.morph=1;e.velocity=0}
  955. draw(e,now,0);frame=requestAnimationFrame(step)
  956. }
  957. let lastBody = '', lastClip = ''
  958. function update(next:CursorOptions) {
  959. const changed=options.state!==next.state||options.expression!==next.expression
  960. const resume=options.paused!==next.paused||options.fixedTime!==next.fixedTime
  961. options=next
  962. svg.setAttribute('viewBox', next.silhouette.viewBox ?? VIEW_BOX)
  963. const e=engine.current, now=performance.now();paintRef.current=next.color
  964. e.props={...e.props,state:next.state,expression:next.expression,gaze:next.gaze,paused:next.paused,motionStrength:next.paused?0:1}
  965. const outline=node<SVGGElement>('outline').current, clip=node<SVGClipPathElement>('clip').current, face=node<SVGGElement>('face').current
  966. const body=next.silhouette.body.replace(/\{\{GRADIENT\}\}/g,next.color)
  967. if(lastBody!==body){outline.innerHTML=body;lastBody=body}
  968. if(lastClip!==next.silhouette.clip){clip.innerHTML=next.silhouette.clip;lastClip=next.silhouette.clip}
  969. outline.setAttribute('transform',next.silhouette.fit||'');clip.setAttribute('transform',next.silhouette.fit||'');face.setAttribute('transform',anchorTransform(next.silhouette.anchor))
  970. if(changed||next.paused||e.last===0)selectExpression(next.expression??POOLS[next.state][0],next.paused||e.last===0)
  971. if(changed||e.last===0){e.stateStart=now;e.lastState=next.state}
  972. e.last=now
  973. if(changed||resume||!frame){stop();armBlink();armExpression()}
  974. draw(e,next.paused ? e.stateStart+(next.fixedTime??0) : next.fixedTime===undefined?now:e.stateStart+next.fixedTime,0)
  975. if(!next.paused&&next.fixedTime===undefined&&!frame)frame=requestAnimationFrame(step)
  976. }
  977. update(initial)
  978. return {update,dispose(){disposed=true;stop();svg.replaceChildren()}}
  979. }
  980. function displayed(e: { current: Ring[]; target: Ring[]; morph: number }): Ring[] {
  981. const m = clamp(e.morph, 0, 1)
  982. return e.current.map((ring, eye) =>
  983. ring.map((p, i): [number, number] => [
  984. p[0] + (e.target[eye][i][0] - p[0]) * m,
  985. p[1] + (e.target[eye][i][1] - p[1]) * m,
  986. ])
  987. )
  988. }
  989. function blinkScale(e: { blinkStart: number | null }, now: number) {
  990. if (e.blinkStart === null) return 1
  991. const t = (now - e.blinkStart) / 320
  992. if (t >= 1) {
  993. e.blinkStart = null
  994. return 1
  995. }
  996. // Fast close, slower open.
  997. return Math.max(t < 0.42 ? 1 - t / 0.42 : (t - 0.42) / 0.58, 0.04)
  998. }