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.