/** * Gaze follow: the eyes track a point. §4.5 of `docs/motion-spec.md`, and the * layer `motion.css` twice reserves `.mo-eyes`'s `transform` for. * * This is the one motion layer that needs JavaScript. Everything else in the * library is a stylesheet the browser runs on its own, because everything else * is a function of the clock alone. A gaze is a function of where the pointer * is, which no keyframe can know, so it ships as an entry point rather than as * more CSS. * * ## Two layers, and the seam between them is a frame clock * * `step` is the pursuit as pure arithmetic: no DOM, no time source, no state it * owns. `gaze` is the browser driver around it: measure a box, listen to a * pointer, park when nothing moves, write two custom properties. * * The split is not tidiness. The filter is recursive, so frame 200 depends on * frame 199, and anything that renders frames out of order cannot run a driver * at all. `apps/video` renders a Remotion film across several workers in * arbitrary order, and integrating as it went would silently produce a * different film per worker. It solves the whole track forwards at module load * instead and reads rows back, which it can only do because the arithmetic is * separable from the clock. Keeping the two apart here is what lets the film be * the shipped behaviour rather than a flattering imitation of it. * * ## It writes custom properties and nothing else * * Not a class, and that is a finding rather than a preference. `.mo-root`'s * `className` is composed by the adapters from `animate` and the expression, so * a framework rewrites that attribute wholesale whenever either changes, taking * any imperatively added class with it. The failure is the quiet kind: the * driver keeps running and the eyes simply stop moving. * * So a driver that writes a class into a framework's DOM is racing the * framework for that attribute, and it loses without saying so. Custom * properties are uncontested, so this only ever sets `--mo-track-x` and * `--mo-track-y`, and the host stylesheet decides where the layer applies. * * ## Why this is smoothed when the saccade is not * * `motion.css` is emphatic that easing the idle saccade gives floating * eyeballs, and it is right: a saccade is ballistic, so anything but a snap * between holds reads wrong. This is not a saccade. Eyes following a moving * target run *smooth pursuit*, a different oculomotor system that is continuous * by construction, so the filter here is the correct shape for the thing being * modelled rather than a softened saccade. * * That only holds while the target moves at pursuit speeds. A pointer that * jumps across the screen is not something an eye pursues, it is something an * eye saccades to, which is what `SNAP` is. */ /** * Pursuit time constant in ms: how long the eyes take to cover ~63% of the way * to a new target. */ export declare const SETTLE = 110; /** * Target movement in one frame, as a fraction of the excursion, past which the * eyes stop pursuing and jump. * * A full reversal is 2, so this is four fifths of one: enough that ordinary * sweeping never trips it, and little enough that a teleport always does. Above * it the target has not moved, it has been replaced (a scroll, a tab return, a * pointer re-entering the window), and an eye answers that with a saccade. * * **Expressed against the normalised direction, not in CSS pixels.** The * excursion is the stylesheet's to set, so a threshold in pixels would be a * second place to change whenever `--mo-track-travel` is retuned, and the two * would drift apart without either looking wrong on its own. `apps/demo` used * to compare in pixels and now does not; this is the form that survived. */ export declare const SNAP = 1.6; /** * The near field, as a fraction of the blobatar's own radius: inside this the * excursion eases to zero. * * `dx / d` is a unit vector, so its direction is undefined at the centre and * violently sensitive just outside it. A pointer crossing a blobatar's own * footprint sweeps that direction through 180 degrees in the handful of frames * it takes to get across, at full excursion the whole way: the eyes snap about * wildly as the cursor passes over rather than tracking it, and it is worse * than it sounds because `SNAP` reads those flips as target jumps and takes the * smoothing off exactly when it is most needed. * * Easing the *amplitude* to zero over the near field kills the singularity at * its source. The direction is still noisy in there, but it is multiplied by * almost nothing, so nothing moves. Physically it is also the honest answer: * there is no direction to look in at something you are already on. Pointing * straight at a face makes it look straight back at you. * * Scaled by the blobatar rather than fixed, because this fraction of a 24px * cell and of a 200px hero are different distances and both are "just about to * be on top of it". It was once filed as the one thing a large blobatar needs * that a grid does not, and that was wrong: a grid needs it at any cell size a * pointer can get inside, which `apps/demo` reaches by 100px. */ export declare const DEADZONE = 0.55; /** * How far the eyes must move, in CSS pixels, for a frame to be worth writing. * * A flat threshold on the direction is the wrong shape, and expensively so: * 0.002 of a unit vector is a different amount of movement on a 24px avatar * and on a 200px one, and on the small one it is a fraction of a pixel nobody * can resolve. The exponential never actually arrives, so this number is the * only thing deciding when the writes stop, and set below what a display can * show it keeps handing the style engine work for tens of frames after the * motion is over. Measured in `apps/demo`, that tail was most of what the layer * cost: a forty-frame convergence per blobatar became three or four writes. * * A sixth of a pixel is under the threshold on any display. Nothing about the * motion changes: the smoothing still runs every frame at full precision, and * the last value written is still within one step of the target. Only how often * the result is handed on. */ export declare const VISIBLE_PX = 0.15; /** * How much the stand-down has to move before it is worth a write. * * Coarser than the excursion's threshold by an order of magnitude, and * deliberately: this scales the idle rove's *seeds*, and a 1% change in the * amplitude of a glance nobody is watching for is not a thing anyone can see. * It is also the channel that has to be written above `.mo-eyes` to be seen at * all, so each write costs a wider invalidation than the excursion's does. */ export declare const HOLD_EPS = 0.01; /** Cubic smoothstep, so every ramp here is flat at both ends. */ export declare const smoothstep: (t: number) => number; /** * The smoothing factor for one frame. * * Frame-rate independent, so the pursuit is not visibly quicker on a 120Hz * display than on a 60Hz one. That is the bug where an animation "feels * different on my laptop" and nobody can say why. * * A `settle` of 0 removes the smoothing entirely, which is worth looking at * once to see the floating-eyeball argument from the other side. */ export declare const pursuit: (dt: number, settle?: number) => number; /** * `VISIBLE_PX` converted into units of the direction, for a blobatar this wide * on screen at this excursion. * * A viewBox unit is `width / 100` CSS pixels and the direction is scaled by the * travel, so this is the only space the comparisons in a driver can be made in. * Per blobatar rather than per field, because a field does not have one answer: * the same grid at 24px and at 200px wants thresholds an order of magnitude * apart. */ export declare function threshold(width: number, travel: number): number; /** * How near the edge of the disc a mark is allowed to park, 0 to 1. * * A rotation large enough carries a mark round the back of the head, where * there is nothing to draw — which is true of a real head and wrong here. The * excursion is a stylesheet's to set and nothing stops it being set to more * head than there is: `triangle`'s fitted head is 9 units tall, so an excursion * of 24 is a pitch of 159°, and the eyes do not turn away, they *vanish*. A * face that blinks out of existence because someone typed a large number is not * a failure anyone can read. * * So the mark stops at the edge instead of passing it. At 0.97 the depth is * still 0.24, which is an eye down to about a quarter of its width: thin enough * to read as turned almost fully away, wide enough to be a face. Raising this * toward 1 buys a little more turn and takes away the guarantee that there is * always something on screen. */ export declare const LIMB = 0.97; /** * The convergence tilt at the corners, in degrees. * * Under §4.7 this was a tuned coefficient per saccade stop, and the * differential between the eyes had to be asserted by a test. Here it is the * shear the projection already produces, so the opposite signs per eye and the * vanishing on the pure axes fall out rather than being arranged. * * 4° against the static per-blobatar lean capped at 12° in `layout()`, and * against §4.7's own 2.4° peak. `test/gaze.test.ts` pins the measured peak, so * retuning this fails there and has to be written down rather than drifting. */ export declare const TILT = 4; /** One eye's rest position, as a fraction of the face's radius on each axis. */ export interface Mark { x: number; y: number; } /** Where a mark has gone, and what shape it is when it gets there. */ export interface Projection { /** Offset from rest, in the same fractions the mark came in as. */ dx: number; dy: number; /** Foreshortening, 0 to 1, relative to the mark's own resting width. */ sx: number; sy: number; /** Convergence tilt, in degrees. */ t: number; } /** * A mark on a sphere, turned and projected — the gaze's answer to §4.7's wrap. * * ## Why this is a projection and not the idle wrap with a different clock * * §4.7 is six tuned stops of `@keyframes mo-wrap`, locked frame-for-frame to * the saccade's six fixations. It cannot be pointed at an arbitrary direction, * because it has no arbitrary direction to be pointed at: its input is which * stop the saccade is in. The gaze's input is a continuous unit vector, so the * cues have to be a continuous function of it, and once they are a function * there is no reason for it to be a fitted one. This is the sphere. * * ## Why it is a rotation and not two angles * * It was two angles, one per axis, each clamped at the limb — and that is not a * sphere, it is a square. A diagonal aim drove both to their limits at once and * put the mark at the *corner*, which is `√2` from the centre of a disc of * radius 1, so the eye left the head on every diagonal while behaving perfectly * on the axes. That is the failure this shape cannot have: the mark is lifted * onto the unit sphere, rotated as a vector, and projected, so `x² + y² ≤ 1` * holds by construction in every direction rather than on two of them. * * A mark carried onto the far side is hidden rather than drawn, which is what * "the eye went round the back of the head" has to mean. * * ## Why the excursion stops being a translation * * A translation is what lets an eye leave the head. `travel` is still a * distance in viewBox units and still means what the README says, but it is * read as an arc along the surface rather than a slide across it: the turn is * `travel / radius` radians, and the mark lands where the rotation puts it. For * a small turn `sin θ ≈ θ`, so a face at the documented 1.5 to 4 units moves * exactly as far as it did when this was a translate, which is the whole reason * the excursion did not have to become an angle to get this. * * For a large one the two part company, and that is the point. The projection * saturates: a mark cannot pass the limb, and it arrives there at no width. So * an eye asked for more excursion than the head has goes to the edge and * vanishes, where the translate sent it out over the page. Nothing clips it. * There is no `clipPath`, no id, and the guarantee in `test/blobatar.test.ts` * that many blobatars on one page cannot collide is untouched. * * `m` is the mark's rest position as a fraction of the face's radius *on each * axis*, and `yaw`/`pitch` are the turn in radians. Per axis, because the face * is not round: `capsule` is 37 units wide and 20 tall, and one mean radius put * its limb 44% below the eyes it was supposed to contain, which is an eye * sitting well under the chin. Normalising each axis by its own radius makes * the head an ellipsoid, and the ellipse inscribed in a superellipse is inside * it everywhere. */ export declare function project(m: Mark, yaw: number, pitch: number): Projection; /** One blobatar's smoothed gaze direction, as the caller keeps it. */ export interface Aim { /** Signed direction, each component in -1..1 and scaled by the excursion. */ x: number; y: number; } export interface StepInput extends Aim { /** Vector from the blobatar's centre to the target, in CSS pixels. */ dx: number; dy: number; /** The blobatar's drawn radius in CSS pixels: what `DEADZONE` is a fraction of. */ radius: number; /** This frame's smoothing factor, from `pursuit`. */ k: number; /** * External amplitude multiplier, 0 to 1. * * The seam for anything that decides *whether* this blobatar is looking, as * opposed to where: a distance falloff across a field, an engagement ramp at * the head of a film. Kept out of the near-field ease below because the two * answer different questions, and a host that cross-fades the idle rove * against the gaze wants this value rather than the combined amplitude. Feed * it the eased one and a blobatar starts roving idly the moment the pointer * lands on top of it, which is stranger to watch than the flick it replaced. */ gain?: number; /** Saccade threshold in units of the excursion. Defaults to `SNAP`. */ snap?: number; } export interface StepResult extends Aim { /** Where the eyes are aimed *now*, before smoothing: the pursuit's target. */ tx: number; ty: number; /** * The interpolation factor this step actually used: `k`, or 1 if the saccade * branch fired. * * Returned because a host cross-fading a second channel on the same clock (a * stand-down, a per-blobatar gain) wants that channel to jump when the eyes * jump rather than to glide on serenely through a teleport. */ f: number; } /** * One pursuit step, with the saccade branch. Pure: no clock, no DOM, no state. * * The target is returned alongside the new position because arrival has to be * decided against it and not inferred from whether a write happened. The write * threshold measures distance from the *last written value*, not from the * target, so a frame whose increment lands under it is silent while the eyes * are still travelling, and one silent frame parks a driver's loop and strands * them. The residual that strands is up to `eps / k`, which at a 90ms settle is * around six times `eps`: not a rounding error, a visibly wrong direction held * until the pointer moves again. A driver snaps to `tx`/`ty` once inside its * threshold of them, and convergence then terminates on the value. */ export declare function step(i: StepInput): StepResult; /** * A blobatar's face, as the projection needs it: where the eyes rest, and how * big the head they turn on is. * * The radii are semi-axes in viewBox units and the marks are fractions of them, * so a `Mark` is already normalised for `project` and neither number changes * when the page scrolls or the blobatar is drawn at a different size. */ export interface Face { marks: Mark[]; /** The face's radius on each axis, in viewBox units. */ rx: number; ry: number; } /** * Measure one blobatar's face off its own rendered geometry. * * Exported because the driver is not the only thing that has to know where an * eye rests. `apps/video` solves its pursuit forwards at module load and cannot * run a driver at all, but it still has to land the same eye in the same place, * and the fitted head is the one input it cannot derive from a name. A second * copy of the bisection below would keep rendering plausibly while the two * quietly disagreed about where the limb is, which is the failure this file * spends its whole length trying not to have. * * `el` is the ``. Returns `null` for a subtree with no layout box: a * blobatar that is `display: none` or detached, and so cannot be looked at * anyway. A caller holding a previous measurement should keep the one it has. * * `getBBox` is the element's own geometry and ignores every transform on it and * above it, so this is the *rest* position however far the gaze, the saccade or * the expression have currently moved things. That is what makes it safe to * call at any time rather than only before anything starts. */ export declare function survey(el: Element): Face | null; /** * What a pair of eyes can be pointed at. * * One vocabulary, used by `lookAt` and by `GazeOptions.target` alike, because * "where it starts out looking" and "where it looks now" are the same question * asked at two moments and answering them differently is how a caller ends up * with two mental models of one driver. * * The union is four things and they are not four modes. Everything below the * aim is arithmetic on a single point: the members differ only in where that * point comes from and, for the last two, in whether the idle glance is allowed * back afterwards. Nothing here reimplements the pursuit, the near field or the * saccade branch, and a target swapped for another is a substitution rather * than a switch — the eyes glide from wherever they are, and `SNAP` decides * which of those is a glide and which is a jump. * * - `{ x, y }` — a point in **client** coordinates. A caret, a drop shadow, a * spot in the viewport. The caller owns re-aiming it when the page moves, * which is the honest split: a point is a point and the driver cannot know * what produced it. * - `Element` — that element's box centre, re-read whenever the driver * re-measures its own, so it survives scrolling and reflow without the caller * listening for either. This is the common case and the reason it is not left * to `{ x, y }`: a caller doing it by hand writes the two listeners this * already runs, and usually forgets the second. * - `"pointer"` — the cursor. The pointer is one *source* of a target and not * the target itself, which is why it is a word here rather than the absence * of one. * - `"rest"` — the blobatar's own centre, held. The near-field ease takes the * excursion to zero because there is no direction to look in at something you * are already on, so the eyes glide to the middle over the same curve as * everything else and stay. The idle glance stays stood down: this is a face * deliberately not looking, which is a pose and not an absence. * - `null` — nothing. The eyes ease home *and* the idle saccade comes back, so * the blobatar goes on living its own life with the driver attached and * watching. This is where a driver starts: constructing one arms the layer * and aims it at nothing, and it costs nothing until it is aimed — the eyes * are already home, the stand-down is already zero, and the loop parks on its * first frame. `null` is the empty target and it means the empty thing. * * `null` and `"rest"` are the pair worth reading twice, because "stop looking * at that" is genuinely two requests. A password field revealing its contents * wants `"rest"` — the joke is a face pointedly not reading your screen, and a * face that went back to glancing idly around the room would be telling the * truth about nothing. A tour that has finished pointing at things wants * `null`, because the creature should go back to being a creature. * * Neither is `stop()`, which is teardown: it removes every listener and both * properties, and the eyes snap rather than glide. */ export type GazeTarget = { x: number; y: number; } | Element | "pointer" | "rest" | null; export interface GazeOptions { /** Pursuit time constant in ms. Defaults to `SETTLE`. */ settle?: number; /** Saccade threshold in units of the excursion. Defaults to `SNAP`. */ snap?: number; /** * What to watch. Defaults to `null`, which is nothing. * * Sugar for constructing the driver already aimed, and `lookAt` is the same * seam afterwards taking the same union. `gaze(svg, { target: "pointer" })` * is the cursor-following blobatar most pages want, and it is spelled out * rather than assumed: constructing a driver arms the layer, and what it * looks at is the caller's to say. A default that started following the * pointer would make the one member of the union nobody had to ask for the * one that moves the eyes. * * **The likeliest way to end up with a face that never moves is now two * things rather than one.** `--mo-track-travel` is registered at `0px`, so a * page that loads `gaze.css` and sets the excursion nowhere has a driver * running and no eyes moving; a driver that was never aimed is the same * symptom from the other side. Both are visible from the call site, which is * the trade: nothing happens implicitly, so nothing happens by accident * either. */ target?: GazeTarget; } /** A running gaze. */ export interface Gaze { /** * Point the eyes at something. See `GazeTarget` for the five things that * something can be. * * The pointer is one *source* of a target and not the target itself, which is * the whole shape of this seam: it takes a point, an element, or one of the * two words, and everything downstream is the same arithmetic either way. * * It also settles what the pointer leaving the window means while something * else is being watched: nothing. A target that is present because the caller * says so does not stop being present because the cursor went to another * window. */ lookAt: (t: GazeTarget) => void; /** * Re-measure the box. * * Scroll, resize and the element's own resizes are already watched. This is * for the host that moved it some other way, and for a caller who knows the * layout settled before any observer will say so. */ remeasure: () => void; /** Teardown. Removes every listener and both properties. */ stop: () => void; } /** * Start the gaze on one blobatar. * * `el` is the element whose box is measured, normally the ``. The * properties are written to `.mo-eyes` inside it when there is one, because * that is the element the stylesheet reads them on and writing higher is an * invalidation of the blobatar's whole subtree every frame for a value three of * its elements use. `apps/demo` measured that at n=200: 31fps writing to * `.mo-root`, 40fps writing to the eyes. The JS was never the bill, at 0.2ms * against frames of 40ms and up. It is what the writes provoke afterwards. */ export declare function gaze(el: SVGSVGElement, opts?: GazeOptions): Gaze; //# sourceMappingURL=gaze.d.ts.map