PageSourceSearch

https://estimote.com/javascript/spacetime-geofence-animation.js?v=13-continuous-rotation

js estimote.com collected 2026-10-01 09:10:40 UTC 8,420 bytes, 251 lines download raw bytes

1/* SpaceTimeOS "How does it work?": a cursor draws a geofence point by point. */
2(() => {
3  document.querySelectorAll('.spacetime-room-demo').forEach(scene => {
4  const cursor = scene?.querySelector('.room-cursor');
5  const employee = scene?.querySelector('.room-persona');
6  const shape = scene?.querySelector('.geofence-shape');
7  const pointLayer = scene?.querySelector('.geofence-points');
8  if (!scene || !cursor || !employee || !shape || !pointLayer) return;
9
10  const start = { x: 55, y: 52 };
11  const vertices = [
12    { x: 65.3, y: 23.7 },
13    { x: 81.8, y: 25.9 },
14    { x: 84, y: 39.1 },
15    { x: 78.5, y: 52.3 },
16    { x: 62, y: 47.9 }
17  ];
18  const moveDuration = 620;
19  const pointPause = 180;
20  // The person enters through the left wall once, then keeps walking around
21  // the closed route and passes through the geofence on every lap.
22  const employeeOutside = { x: 7, y: 64 };
23  const employeeInside = { x: 14.5, y: 64 };
24  const loopBottom = { x: 62, y: 67 };
25  const loopRight = { x: 77, y: 46 };
26  const loopTop = { x: 62, y: 27 };
27  const loopLeft = { x: 46, y: 46 };
28  const approachCurveControl = { x: 39, y: 73 };
29  const walkSpeed = 13;
30  const wait = duration => new Promise(resolve => setTimeout(resolve, duration));
31  let employeeAngle = 0;
32
33  function setCursorPosition(point) {
34    cursor.style.left = `${point.x}%`;
35    cursor.style.top = `${point.y}%`;
36  }
37
38  function setEmployeePose(point, angle = 0) {
39    employeeAngle = angle;
40    employee.style.left = `${point.x}%`;
41    employee.style.top = `${point.y}%`;
42    employee.style.transform = `translate(-50%, -50%) rotate(${angle}deg)`;
43  }
44
45  function setEmployeeVisibility(visible) {
46    employee.style.opacity = visible ? '1' : '0';
47  }
48
49  function drawShape(count) {
50    const activeVertices = vertices.slice(0, count);
51    if (!activeVertices.length) {
52      shape.setAttribute('d', '');
53      shape.classList.remove('is-filled');
54      return;
55    }
56    const commands = activeVertices.map((point, index) => `${index ? 'L' : 'M'} ${point.x} ${point.y}`);
57    if (activeVertices.length >= 3) commands.push('Z');
58    shape.setAttribute('d', commands.join(' '));
59    shape.classList.toggle('is-filled', activeVertices.length >= 3);
60  }
61
62  function placePoint(point) {
63    const marker = document.createElement('span');
64    marker.className = 'geofence-point';
65    marker.style.left = `${point.x}%`;
66    marker.style.top = `${point.y}%`;
67    pointLayer.append(marker);
68    marker.animate(
69      [
70        { opacity: 0, transform: 'translate(-50%, -50%) scale(.35)' },
71        { opacity: 1, transform: 'translate(-50%, -50%) scale(1)' }
72      ],
73      { duration: 180, easing: 'cubic-bezier(.2,.8,.2,1)', fill: 'both' }
74    );
75  }
76
77  async function moveCursor(from, to) {
78    const animation = cursor.animate(
79      [
80        { left: `${from.x}%`, top: `${from.y}%` },
81        { left: `${to.x}%`, top: `${to.y}%` }
82      ],
83      { duration: moveDuration, easing: 'cubic-bezier(.4,0,.2,1)', fill: 'both' }
84    );
85    await animation.finished;
86    setCursorPosition(to);
87    animation.cancel();
88  }
89
90  function quadraticPoint(from, control, to, progress) {
91    const remainder = 1 - progress;
92    return {
93      x: remainder * remainder * from.x + 2 * remainder * progress * control.x + progress * progress * to.x,
94      y: remainder * remainder * from.y + 2 * remainder * progress * control.y + progress * progress * to.y
95    };
96  }
97
98  function quadraticAngle(from, control, to, progress) {
99    const dx = 2 * (1 - progress) * (control.x - from.x) + 2 * progress * (to.x - control.x);
100    const dy = 2 * (1 - progress) * (control.y - from.y) + 2 * progress * (to.y - control.y);
101    return Math.atan2(dy, dx) * 180 / Math.PI - 90;
102  }
103
104  function quadraticLength(from, control, to) {
105    let length = 0;
106    let previous = from;
107    for (let index = 1; index <= 32; index += 1) {
108      const point = quadraticPoint(from, control, to, index / 32);
109      length += Math.hypot(point.x - previous.x, point.y - previous.y);
110      previous = point;
111    }
112    return length;
113  }
114
115  async function moveEmployeeAlongCurve(from, control, to, onThreshold, triggerAt = .68) {
116    const curveLength = quadraticLength(from, control, to);
117    const duration = Math.max(900, curveLength / walkSpeed * 1000);
118    const strideCount = Math.max(2, Math.round(duration / 680));
119    const steps = 32;
120    const keyframes = [];
121    let angle = employeeAngle;
122    for (let index = 0; index <= steps; index += 1) {
123      const progress = index / steps;
124      const point = quadraticPoint(from, control, to, progress);
125      const pathAngle = quadraticAngle(from, control, to, progress);
126      // Keep equivalent headings on the same turn when atan2 wraps at ±180°.
127      // Otherwise CSS interpolates a full spin between adjacent keyframes.
128      angle = pathAngle + Math.round((angle - pathAngle) / 360) * 360;
129      const stride = Math.sin(progress * strideCount * Math.PI * 2);
130      const sway = stride * 2.4;
131      const lift = 1 + Math.abs(stride) * .025;
132      keyframes.push({
133        left: `${point.x}%`,
134        top: `${point.y}%`,
135        transform: `translate(-50%, -50%) rotate(${angle + sway}deg) scale(${lift})`,
136        offset: progress
137      });
138    }
139    const animation = employee.animate(keyframes, {
140      duration,
141      easing: 'linear',
142      fill: 'both'
143    });
144    const thresholdTrigger = onThreshold ? wait(duration * triggerAt).then(onThreshold) : Promise.resolve();
145    await Promise.all([animation.finished, thresholdTrigger]);
146    setEmployeePose(to, angle);
147    animation.cancel();
148    return angle;
149  }
150
151  function showFinishedState() {
152    pointLayer.replaceChildren();
153    vertices.forEach(placePoint);
154    drawShape(vertices.length);
155    cursor.style.opacity = '0';
156    scene.classList.remove('geofence-is-active');
157    setEmployeePose(loopBottom, quadraticAngle(loopLeft, { x: 46, y: 67 }, loopBottom, 1));
158    setEmployeeVisibility(true);
159  }
160
161  if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
162    showFinishedState();
163    return;
164  }
165
166  async function play() {
167    pointLayer.replaceChildren();
168    drawShape(0);
169    scene.classList.remove('geofence-is-active');
170    setEmployeePose(employeeOutside, 90);
171    setEmployeeVisibility(false);
172    setCursorPosition(start);
173    cursor.style.opacity = '1';
174    await wait(500);
175
176    let previous = start;
177    let entrancePromise;
178    for (let index = 0; index < vertices.length; index += 1) {
179      const point = vertices[index];
180      await moveCursor(previous, point);
181      placePoint(point);
182      drawShape(index + 1);
183      if (index === vertices.length - 1) {
184        // Begin entering at the exact moment the final geofence point appears.
185        entrancePromise = moveEmployeeAlongCurve(
186          employeeOutside,
187          { x: 10.8, y: 64 },
188          employeeInside,
189          () => setEmployeeVisibility(true),
190          .72
191        );
192      }
193      await wait(pointPause);
194      previous = point;
195    }
196
197    const fadeOut = cursor.animate(
198      [{ opacity: 1 }, { opacity: 0 }],
199      { duration: 260, easing: 'ease-out', fill: 'forwards' }
200    );
201    await fadeOut.finished;
202    cursor.style.opacity = '0';
203    fadeOut.cancel();
204    await wait(240);
205
206    // The one-time entrance finishes while the cursor is fading away.
207    await entrancePromise;
208    await moveEmployeeAlongCurve(
209      employeeInside,
210      approachCurveControl,
211      loopBottom
212    );
213
214    // From here on the person follows one continuous, human-paced circuit.
215    while (document.contains(scene)) {
216      await moveEmployeeAlongCurve(
217        loopBottom,
218        { x: 77, y: 67 },
219        loopRight,
220        () => scene.classList.add('geofence-is-active'),
221        .63
222      );
223      await moveEmployeeAlongCurve(
224        loopRight,
225        { x: 77, y: 27 },
226        loopTop,
227        () => scene.classList.remove('geofence-is-active'),
228        .88
229      );
230      await moveEmployeeAlongCurve(
231        loopTop,
232        { x: 46, y: 27 },
233        loopLeft
234      );
235      await moveEmployeeAlongCurve(
236        loopLeft,
237        { x: 46, y: 67 },
238        loopBottom
239      );
240    }
241  }
242
243  const visibilityObserver = new IntersectionObserver(entries => {
244    if (!entries.some(entry => entry.isIntersecting && entry.intersectionRatio >= .2)) return;
245    visibilityObserver.disconnect();
246    play();
247  }, { threshold: [.2] });
248
249  visibilityObserver.observe(scene);
250  });
251})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.