feat: boids algo
bf7a1dd3
5 file(s) · +422 −296
| 12 | 12 | <a target="_blank" rel="noreferrer" class="underline" href="https://github.com/stevedylandev/stevedylan.dev">Source Code</a> |
|
| 13 | 13 | <a class="underline" href="mailto:contact@stevedylan.dev">Contact</a> |
|
| 14 | 14 | <a class="underline" href="/kill-your-lawn">Kill Your Lawn</a> |
|
| 15 | + | <a href="/murmurations" class="flex items-center gap-1.5 murmuration-flock" aria-label="Murmurations"> |
|
| 16 | + | {Array.from({ length: 7 }).map(() => ( |
|
| 17 | + | <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 256 256" class="murmuration-bird"> |
|
| 18 | + | <path fill="currentColor" d="M237.33 106.21L61.41 41l-.16-.05a16 16 0 0 0-20.35 20.3a1 1 0 0 0 .05.16l65.26 175.92A15.77 15.77 0 0 0 121.28 248h.3a15.77 15.77 0 0 0 15-11.29l.06-.2l21.84-78l78-21.84l.2-.06a16 16 0 0 0 .62-30.38Zm-87.49 38.09a8 8 0 0 0-5.54 5.54l-23 82.16l-.06-.17L56 56l175.82 65.22l.16.06Z"/> |
|
| 19 | + | </svg> |
|
| 20 | + | ))} |
|
| 21 | + | </a> |
|
| 22 | + | <script> |
|
| 23 | + | document.querySelectorAll<SVGElement>('.murmuration-flock .murmuration-bird').forEach((el) => { |
|
| 24 | + | const deg = Math.random() * 360; |
|
| 25 | + | el.style.transform = `rotate(${deg}deg)`; |
|
| 26 | + | }); |
|
| 27 | + | </script> |
|
| 15 | 28 | <a href="https://blog.kagi.com/small-web-updates"> |
|
| 16 | 29 | <Image inferSize={true} src="https://kagifeedback.org/assets/files/2025-11-27/1764250950-635837-80x15-2.png" alt="kagi small web"/> |
|
| 17 | 30 | </a> |
|
| 18 | - | <a href="/googleballs" class="flex items-center gap-1" aria-label="Google Balls"> |
|
| 19 | - | <span style="background:#4285F4;width:6px;height:6px;border-radius:50%;display:inline-block;"></span> |
|
| 20 | - | <span style="background:#EA4335;width:6px;height:6px;border-radius:50%;display:inline-block;"></span> |
|
| 21 | - | <span style="background:#FBBC05;width:6px;height:6px;border-radius:50%;display:inline-block;"></span> |
|
| 22 | - | <span style="background:#34A853;width:6px;height:6px;border-radius:50%;display:inline-block;"></span> |
|
| 23 | - | </a> |
|
| 24 | 31 | </footer> |
| 1 | - | --- |
|
| 2 | - | --- |
|
| 3 | - | ||
| 4 | - | <canvas id="googleballs-canvas" width="400" height="400"></canvas> |
|
| 5 | - | ||
| 6 | - | <script> |
|
| 7 | - | (function() { |
|
| 8 | - | var canvas = document.getElementById("googleballs-canvas"); |
|
| 9 | - | var canvasHeight; |
|
| 10 | - | var canvasWidth; |
|
| 11 | - | var ctx; |
|
| 12 | - | var lastFrameTime = 0; |
|
| 13 | - | var animationId = null; |
|
| 14 | - | var pointCollection; |
|
| 15 | - | ||
| 16 | - | function init() { |
|
| 17 | - | updateCanvasDimensions(); |
|
| 18 | - | ||
| 19 | - | var g = [new Point(202, 78, 0.0, 9, "#ed9d33"), new Point(348, 83, 0.0, 9, "#d44d61"), new Point(256, 69, 0.0, 9, "#4f7af2"), new Point(214, 59, 0.0, 9, "#ef9a1e"), new Point(265, 36, 0.0, 9, "#4976f3"), new Point(300, 78, 0.0, 9, "#269230"), new Point(294, 59, 0.0, 9, "#1f9e2c"), new Point(45, 88, 0.0, 9, "#1c48dd"), new Point(268, 52, 0.0, 9, "#2a56ea"), new Point(73, 83, 0.0, 9, "#3355d8"), new Point(294, 6, 0.0, 9, "#36b641"), new Point(235, 62, 0.0, 9, "#2e5def"), new Point(353, 42, 0.0, 8, "#d53747"), new Point(336, 52, 0.0, 8, "#eb676f"), new Point(208, 41, 0.0, 8, "#f9b125"), new Point(321, 70, 0.0, 8, "#de3646"), new Point(8, 60, 0.0, 8, "#2a59f0"), new Point(180, 81, 0.0, 8, "#eb9c31"), new Point(146, 65, 0.0, 8, "#c41731"), new Point(145, 49, 0.0, 8, "#d82038"), new Point(246, 34, 0.0, 8, "#5f8af8"), new Point(169, 69, 0.0, 8, "#efa11e"), new Point(273, 99, 0.0, 8, "#2e55e2"), new Point(248, 120, 0.0, 8, "#4167e4"), new Point(294, 41, 0.0, 8, "#0b991a"), new Point(267, 114, 0.0, 8, "#4869e3"), new Point(78, 67, 0.0, 8, "#3059e3"), new Point(294, 23, 0.0, 8, "#10a11d"), new Point(117, 83, 0.0, 8, "#cf4055"), new Point(137, 80, 0.0, 8, "#cd4359"), new Point(14, 71, 0.0, 8, "#2855ea"), new Point(331, 80, 0.0, 8, "#ca273c"), new Point(25, 82, 0.0, 8, "#2650e1"), new Point(233, 46, 0.0, 8, "#4a7bf9"), new Point(73, 13, 0.0, 8, "#3d65e7"), new Point(327, 35, 0.0, 6, "#f47875"), new Point(319, 46, 0.0, 6, "#f36764"), new Point(256, 81, 0.0, 6, "#1d4eeb"), new Point(244, 88, 0.0, 6, "#698bf1"), new Point(194, 32, 0.0, 6, "#fac652"), new Point(97, 56, 0.0, 6, "#ee5257"), new Point(105, 75, 0.0, 6, "#cf2a3f"), new Point(42, 4, 0.0, 6, "#5681f5"), new Point(10, 27, 0.0, 6, "#4577f6"), new Point(166, 55, 0.0, 6, "#f7b326"), new Point(266, 88, 0.0, 6, "#2b58e8"), new Point(178, 34, 0.0, 6, "#facb5e"), new Point(100, 65, 0.0, 6, "#e02e3d"), new Point(343, 32, 0.0, 6, "#f16d6f"), new Point(59, 5, 0.0, 6, "#507bf2"), new Point(27, 9, 0.0, 6, "#5683f7"), new Point(233, 116, 0.0, 6, "#3158e2"), new Point(123, 32, 0.0, 6, "#f0696c"), new Point(6, 38, 0.0, 6, "#3769f6"), new Point(63, 62, 0.0, 6, "#6084ef"), new Point(6, 49, 0.0, 6, "#2a5cf4"), new Point(108, 36, 0.0, 6, "#f4716e"), new Point(169, 43, 0.0, 6, "#f8c247"), new Point(137, 37, 0.0, 6, "#e74653"), new Point(318, 58, 0.0, 6, "#ec4147"), new Point(226, 100, 0.0, 5, "#4876f1"), new Point(101, 46, 0.0, 5, "#ef5c5c"), new Point(226, 108, 0.0, 5, "#2552ea"), new Point(17, 17, 0.0, 5, "#4779f7"), new Point(232, 93, 0.0, 5, "#4b78f1")]; |
|
| 20 | - | ||
| 21 | - | var gLength = g.length; |
|
| 22 | - | for (var i = 0; i < gLength; i++) { |
|
| 23 | - | g[i].curPos.x = (canvasWidth/2 - 180) + g[i].curPos.x; |
|
| 24 | - | g[i].curPos.y = (canvasHeight/2 - 65) + g[i].curPos.y; |
|
| 25 | - | ||
| 26 | - | g[i].originalPos.x = (canvasWidth/2 - 180) + g[i].originalPos.x; |
|
| 27 | - | g[i].originalPos.y = (canvasHeight/2 - 65) + g[i].originalPos.y; |
|
| 28 | - | } |
|
| 29 | - | ||
| 30 | - | pointCollection = new PointCollection(); |
|
| 31 | - | pointCollection.points = g; |
|
| 32 | - | ||
| 33 | - | window.addEventListener('resize', updateCanvasDimensions); |
|
| 34 | - | window.addEventListener('mousemove', onMove); |
|
| 35 | - | ||
| 36 | - | canvas.ontouchmove = function(e) { |
|
| 37 | - | e.preventDefault(); |
|
| 38 | - | onTouchMove(e); |
|
| 39 | - | }; |
|
| 40 | - | ||
| 41 | - | canvas.ontouchstart = function(e) { |
|
| 42 | - | e.preventDefault(); |
|
| 43 | - | }; |
|
| 44 | - | ||
| 45 | - | lastFrameTime = 0; |
|
| 46 | - | animationId = requestAnimationFrame(animateRAF); |
|
| 47 | - | } |
|
| 48 | - | ||
| 49 | - | function stop() { |
|
| 50 | - | if (animationId) { |
|
| 51 | - | cancelAnimationFrame(animationId); |
|
| 52 | - | animationId = null; |
|
| 53 | - | } |
|
| 54 | - | window.removeEventListener('resize', updateCanvasDimensions); |
|
| 55 | - | window.removeEventListener('mousemove', onMove); |
|
| 56 | - | canvas.ontouchmove = null; |
|
| 57 | - | canvas.ontouchstart = null; |
|
| 58 | - | pointCollection = null; |
|
| 59 | - | lastFrameTime = 0; |
|
| 60 | - | if (ctx) { |
|
| 61 | - | ctx.clearRect(0, 0, canvasWidth, canvasHeight); |
|
| 62 | - | } |
|
| 63 | - | } |
|
| 64 | - | ||
| 65 | - | function animateRAF(timestamp) { |
|
| 66 | - | if (!lastFrameTime) lastFrameTime = timestamp; |
|
| 67 | - | var deltaTime = (timestamp - lastFrameTime) / 1000; |
|
| 68 | - | lastFrameTime = timestamp; |
|
| 69 | - | ||
| 70 | - | deltaTime = Math.min(deltaTime, 0.1); |
|
| 71 | - | ||
| 72 | - | draw(); |
|
| 73 | - | update(deltaTime); |
|
| 74 | - | ||
| 75 | - | animationId = requestAnimationFrame(animateRAF); |
|
| 76 | - | } |
|
| 77 | - | ||
| 78 | - | function updateCanvasDimensions() { |
|
| 79 | - | var modal = document.getElementById('googleballs-modal'); |
|
| 80 | - | if (!modal) return; |
|
| 81 | - | ||
| 82 | - | var dpr = window.devicePixelRatio || 1; |
|
| 83 | - | var logicalWidth = modal.clientWidth; |
|
| 84 | - | var logicalHeight = modal.clientHeight; |
|
| 85 | - | ||
| 86 | - | canvas.width = logicalWidth * dpr; |
|
| 87 | - | canvas.height = logicalHeight * dpr; |
|
| 88 | - | canvas.style.width = logicalWidth + 'px'; |
|
| 89 | - | canvas.style.height = logicalHeight + 'px'; |
|
| 90 | - | ||
| 91 | - | canvasWidth = logicalWidth; |
|
| 92 | - | canvasHeight = logicalHeight; |
|
| 93 | - | ||
| 94 | - | if (canvas.getContext) { |
|
| 95 | - | ctx = canvas.getContext('2d'); |
|
| 96 | - | ctx.scale(dpr, dpr); |
|
| 97 | - | } |
|
| 98 | - | ||
| 99 | - | recenterPoints(); |
|
| 100 | - | draw(); |
|
| 101 | - | } |
|
| 102 | - | ||
| 103 | - | function recenterPoints() { |
|
| 104 | - | if (!pointCollection) return; |
|
| 105 | - | ||
| 106 | - | var offsetX = (canvasWidth / 2 - 180); |
|
| 107 | - | var offsetY = (canvasHeight / 2 - 65); |
|
| 108 | - | ||
| 109 | - | for (var i = 0; i < pointCollection.points.length; i++) { |
|
| 110 | - | var point = pointCollection.points[i]; |
|
| 111 | - | ||
| 112 | - | var relX = point.originalPos.x - (canvasWidth / 2 - 180); |
|
| 113 | - | var relY = point.originalPos.y - (canvasHeight / 2 - 65); |
|
| 114 | - | ||
| 115 | - | point.originalPos.x = offsetX + relX; |
|
| 116 | - | point.originalPos.y = offsetY + relY; |
|
| 117 | - | ||
| 118 | - | point.curPos.x = point.originalPos.x; |
|
| 119 | - | point.curPos.y = point.originalPos.y; |
|
| 120 | - | } |
|
| 121 | - | } |
|
| 122 | - | ||
| 123 | - | function onMove(e) { |
|
| 124 | - | if (pointCollection) { |
|
| 125 | - | var modal = document.getElementById('googleballs-modal'); |
|
| 126 | - | var rect = modal.getBoundingClientRect(); |
|
| 127 | - | pointCollection.mousePos.set(e.clientX - rect.left, e.clientY - rect.top); |
|
| 128 | - | } |
|
| 129 | - | } |
|
| 130 | - | ||
| 131 | - | function onTouchMove(e) { |
|
| 132 | - | if (pointCollection) { |
|
| 133 | - | var modal = document.getElementById('googleballs-modal'); |
|
| 134 | - | var rect = modal.getBoundingClientRect(); |
|
| 135 | - | pointCollection.mousePos.set( |
|
| 136 | - | e.targetTouches[0].clientX - rect.left, |
|
| 137 | - | e.targetTouches[0].clientY - rect.top |
|
| 138 | - | ); |
|
| 139 | - | } |
|
| 140 | - | } |
|
| 141 | - | ||
| 142 | - | function draw() { |
|
| 143 | - | if (!ctx) return; |
|
| 144 | - | ctx.clearRect(0, 0, canvasWidth, canvasHeight); |
|
| 145 | - | if (pointCollection) pointCollection.draw(); |
|
| 146 | - | } |
|
| 147 | - | ||
| 148 | - | function update(dt) { |
|
| 149 | - | if (pointCollection) pointCollection.update(dt); |
|
| 150 | - | } |
|
| 151 | - | ||
| 152 | - | function Vector(x, y, z) { |
|
| 153 | - | this.x = x; |
|
| 154 | - | this.y = y; |
|
| 155 | - | this.z = z; |
|
| 156 | - | ||
| 157 | - | this.addX = function(x) { this.x += x; }; |
|
| 158 | - | this.addY = function(y) { this.y += y; }; |
|
| 159 | - | this.addZ = function(z) { this.z += z; }; |
|
| 160 | - | this.set = function(x, y, z) { this.x = x; this.y = y; this.z = z; }; |
|
| 161 | - | } |
|
| 162 | - | ||
| 163 | - | function PointCollection() { |
|
| 164 | - | this.mousePos = new Vector(0, 0); |
|
| 165 | - | this.points = []; |
|
| 166 | - | ||
| 167 | - | this.update = function(dt) { |
|
| 168 | - | var pointsLength = this.points.length; |
|
| 169 | - | for (var i = 0; i < pointsLength; i++) { |
|
| 170 | - | var point = this.points[i]; |
|
| 171 | - | if (point == null) continue; |
|
| 172 | - | ||
| 173 | - | var dx = this.mousePos.x - point.curPos.x; |
|
| 174 | - | var dy = this.mousePos.y - point.curPos.y; |
|
| 175 | - | var dd = (dx * dx) + (dy * dy); |
|
| 176 | - | var d = Math.sqrt(dd); |
|
| 177 | - | ||
| 178 | - | if (d < 150) { |
|
| 179 | - | point.targetPos.x = point.curPos.x - dx; |
|
| 180 | - | point.targetPos.y = point.curPos.y - dy; |
|
| 181 | - | } else { |
|
| 182 | - | point.targetPos.x = point.originalPos.x; |
|
| 183 | - | point.targetPos.y = point.originalPos.y; |
|
| 184 | - | } |
|
| 185 | - | ||
| 186 | - | point.update(dt); |
|
| 187 | - | } |
|
| 188 | - | }; |
|
| 189 | - | ||
| 190 | - | this.draw = function() { |
|
| 191 | - | var pointsLength = this.points.length; |
|
| 192 | - | for (var i = 0; i < pointsLength; i++) { |
|
| 193 | - | var point = this.points[i]; |
|
| 194 | - | if (point == null) continue; |
|
| 195 | - | point.draw(); |
|
| 196 | - | } |
|
| 197 | - | }; |
|
| 198 | - | } |
|
| 199 | - | ||
| 200 | - | function Point(x, y, z, size, colour) { |
|
| 201 | - | this.colour = colour; |
|
| 202 | - | this.curPos = new Vector(x, y, z); |
|
| 203 | - | this.friction = 0.8; |
|
| 204 | - | this.originalPos = new Vector(x, y, z); |
|
| 205 | - | this.radius = size; |
|
| 206 | - | this.size = size; |
|
| 207 | - | this.springStrength = 0.1; |
|
| 208 | - | this.targetPos = new Vector(x, y, z); |
|
| 209 | - | this.velocity = new Vector(0.0, 0.0, 0.0); |
|
| 210 | - | ||
| 211 | - | this.update = function(dt) { |
|
| 212 | - | var targetFrameTime = 0.03; |
|
| 213 | - | var timeScale = dt / targetFrameTime; |
|
| 214 | - | ||
| 215 | - | var dx = this.targetPos.x - this.curPos.x; |
|
| 216 | - | var ax = dx * this.springStrength * timeScale; |
|
| 217 | - | this.velocity.x += ax; |
|
| 218 | - | this.velocity.x *= Math.pow(this.friction, timeScale); |
|
| 219 | - | this.curPos.x += this.velocity.x * timeScale; |
|
| 220 | - | ||
| 221 | - | var dy = this.targetPos.y - this.curPos.y; |
|
| 222 | - | var ay = dy * this.springStrength * timeScale; |
|
| 223 | - | this.velocity.y += ay; |
|
| 224 | - | this.velocity.y *= Math.pow(this.friction, timeScale); |
|
| 225 | - | this.curPos.y += this.velocity.y * timeScale; |
|
| 226 | - | ||
| 227 | - | var dox = this.originalPos.x - this.curPos.x; |
|
| 228 | - | var doy = this.originalPos.y - this.curPos.y; |
|
| 229 | - | var dd = (dox * dox) + (doy * doy); |
|
| 230 | - | var d = Math.sqrt(dd); |
|
| 231 | - | ||
| 232 | - | this.targetPos.z = d/100 + 1; |
|
| 233 | - | var dz = this.targetPos.z - this.curPos.z; |
|
| 234 | - | var az = dz * this.springStrength * timeScale; |
|
| 235 | - | this.velocity.z += az; |
|
| 236 | - | this.velocity.z *= Math.pow(this.friction, timeScale); |
|
| 237 | - | this.curPos.z += this.velocity.z * timeScale; |
|
| 238 | - | ||
| 239 | - | this.radius = this.size * this.curPos.z; |
|
| 240 | - | if (this.radius < 1) this.radius = 1; |
|
| 241 | - | }; |
|
| 242 | - | ||
| 243 | - | this.draw = function() { |
|
| 244 | - | ctx.fillStyle = this.colour; |
|
| 245 | - | ctx.beginPath(); |
|
| 246 | - | ctx.arc(this.curPos.x, this.curPos.y, this.radius, 0, Math.PI*2, true); |
|
| 247 | - | ctx.fill(); |
|
| 248 | - | }; |
|
| 249 | - | } |
|
| 250 | - | ||
| 251 | - | // Expose init/stop for the modal controller |
|
| 252 | - | window.__googleballs = { init: init, stop: stop }; |
|
| 253 | - | })(); |
|
| 254 | - | </script> |
|
| 255 | - | ||
| 256 | - | <style> |
|
| 257 | - | #googleballs-canvas { |
|
| 258 | - | position: absolute; |
|
| 259 | - | top: 0; |
|
| 260 | - | left: 0; |
|
| 261 | - | } |
|
| 262 | - | </style> |
| 1 | + | --- |
|
| 2 | + | --- |
|
| 3 | + | ||
| 4 | + | <canvas id="murmurations-canvas"></canvas> |
|
| 5 | + | ||
| 6 | + | <script> |
|
| 7 | + | (() => { |
|
| 8 | + | const canvas = document.getElementById('murmurations-canvas') as HTMLCanvasElement; |
|
| 9 | + | const ctx = canvas.getContext('2d')!; |
|
| 10 | + | ||
| 11 | + | let W: number, H: number; |
|
| 12 | + | const DPR = Math.min(window.devicePixelRatio || 1, 2); |
|
| 13 | + | ||
| 14 | + | function viewportSize() { |
|
| 15 | + | const vv = window.visualViewport; |
|
| 16 | + | return { |
|
| 17 | + | w: vv ? vv.width : window.innerWidth, |
|
| 18 | + | h: vv ? vv.height : window.innerHeight, |
|
| 19 | + | }; |
|
| 20 | + | } |
|
| 21 | + | ||
| 22 | + | function resize() { |
|
| 23 | + | const { w, h } = viewportSize(); |
|
| 24 | + | W = w; |
|
| 25 | + | H = h; |
|
| 26 | + | canvas.width = W * DPR; |
|
| 27 | + | canvas.height = H * DPR; |
|
| 28 | + | canvas.style.width = W + 'px'; |
|
| 29 | + | canvas.style.height = H + 'px'; |
|
| 30 | + | ctx.setTransform(DPR, 0, 0, DPR, 0, 0); |
|
| 31 | + | ctx.lineCap = 'round'; |
|
| 32 | + | ctx.strokeStyle = 'rgba(245, 243, 238, 0.78)'; |
|
| 33 | + | ctx.lineWidth = 1.4; |
|
| 34 | + | } |
|
| 35 | + | resize(); |
|
| 36 | + | window.addEventListener('resize', resize); |
|
| 37 | + | window.addEventListener('orientationchange', resize); |
|
| 38 | + | if (window.visualViewport) { |
|
| 39 | + | window.visualViewport.addEventListener('resize', resize); |
|
| 40 | + | } |
|
| 41 | + | ||
| 42 | + | const TOPOLOGICAL_K = 7; |
|
| 43 | + | const PERCEPTION = 70; |
|
| 44 | + | const SEP_RANGE = 14; |
|
| 45 | + | const MAX_SPEED = 3.4; |
|
| 46 | + | const MIN_SPEED = 2.2; |
|
| 47 | + | const MAX_FORCE = 0.12; |
|
| 48 | + | ||
| 49 | + | const W_SEP = 1.8; |
|
| 50 | + | const W_ALIGN = 1.4; |
|
| 51 | + | const W_COH = 0.9; |
|
| 52 | + | const W_ATTRACT = 0.06; |
|
| 53 | + | const W_NOISE = 0.015; |
|
| 54 | + | ||
| 55 | + | const COUNT = Math.min(420, Math.floor((W * H) / 3200)); |
|
| 56 | + | ||
| 57 | + | const attractor = { |
|
| 58 | + | x: W / 2, y: H / 2, |
|
| 59 | + | t: Math.random() * 1000, |
|
| 60 | + | update() { |
|
| 61 | + | this.t += 0.003; |
|
| 62 | + | const cx = W / 2, cy = H / 2; |
|
| 63 | + | const rx = W * 0.32, ry = H * 0.28; |
|
| 64 | + | this.x = cx + Math.sin(this.t * 1.3) * rx + Math.cos(this.t * 0.7) * rx * 0.3; |
|
| 65 | + | this.y = cy + Math.cos(this.t * 1.1) * ry + Math.sin(this.t * 1.7) * ry * 0.25; |
|
| 66 | + | } |
|
| 67 | + | }; |
|
| 68 | + | ||
| 69 | + | class Boid { |
|
| 70 | + | x: number; y: number; vx: number; vy: number; ax = 0; ay = 0; |
|
| 71 | + | constructor() { |
|
| 72 | + | this.x = Math.random() * W; |
|
| 73 | + | this.y = Math.random() * H; |
|
| 74 | + | const a = Math.random() * Math.PI * 2; |
|
| 75 | + | const s = MIN_SPEED + Math.random() * (MAX_SPEED - MIN_SPEED); |
|
| 76 | + | this.vx = Math.cos(a) * s; |
|
| 77 | + | this.vy = Math.sin(a) * s; |
|
| 78 | + | } |
|
| 79 | + | ||
| 80 | + | edges() { |
|
| 81 | + | const m = 20; |
|
| 82 | + | if (this.x < -m) this.x = W + m; |
|
| 83 | + | else if (this.x > W + m) this.x = -m; |
|
| 84 | + | if (this.y < -m) this.y = H + m; |
|
| 85 | + | else if (this.y > H + m) this.y = -m; |
|
| 86 | + | } |
|
| 87 | + | ||
| 88 | + | flock(candidates: Boid[]) { |
|
| 89 | + | const dists: { o: Boid; d2: number; dx: number; dy: number }[] = []; |
|
| 90 | + | for (let i = 0; i < candidates.length; i++) { |
|
| 91 | + | const o = candidates[i]; |
|
| 92 | + | if (o === this) continue; |
|
| 93 | + | const dx = o.x - this.x; |
|
| 94 | + | const dy = o.y - this.y; |
|
| 95 | + | const d2 = dx * dx + dy * dy; |
|
| 96 | + | if (d2 < PERCEPTION * PERCEPTION && d2 > 0) { |
|
| 97 | + | dists.push({ o, d2, dx, dy }); |
|
| 98 | + | } |
|
| 99 | + | } |
|
| 100 | + | dists.sort((a, b) => a.d2 - b.d2); |
|
| 101 | + | const k = Math.min(TOPOLOGICAL_K, dists.length); |
|
| 102 | + | ||
| 103 | + | let alignX = 0, alignY = 0; |
|
| 104 | + | let cohX = 0, cohY = 0; |
|
| 105 | + | let sepX = 0, sepY = 0; |
|
| 106 | + | let sepCount = 0; |
|
| 107 | + | ||
| 108 | + | for (let i = 0; i < k; i++) { |
|
| 109 | + | const { o, d2, dx, dy } = dists[i]; |
|
| 110 | + | alignX += o.vx; |
|
| 111 | + | alignY += o.vy; |
|
| 112 | + | cohX += o.x; |
|
| 113 | + | cohY += o.y; |
|
| 114 | + | ||
| 115 | + | if (d2 < SEP_RANGE * SEP_RANGE) { |
|
| 116 | + | const d = Math.sqrt(d2); |
|
| 117 | + | const f = 1 / (d + 0.001); |
|
| 118 | + | sepX -= (dx / d) * f; |
|
| 119 | + | sepY -= (dy / d) * f; |
|
| 120 | + | sepCount++; |
|
| 121 | + | } |
|
| 122 | + | } |
|
| 123 | + | ||
| 124 | + | let fx = 0, fy = 0; |
|
| 125 | + | ||
| 126 | + | if (k > 0) { |
|
| 127 | + | alignX /= k; alignY /= k; |
|
| 128 | + | const m = Math.hypot(alignX, alignY); |
|
| 129 | + | if (m > 0) { |
|
| 130 | + | alignX = alignX / m * MAX_SPEED - this.vx; |
|
| 131 | + | alignY = alignY / m * MAX_SPEED - this.vy; |
|
| 132 | + | const sm = Math.hypot(alignX, alignY); |
|
| 133 | + | if (sm > MAX_FORCE) { alignX = alignX / sm * MAX_FORCE; alignY = alignY / sm * MAX_FORCE; } |
|
| 134 | + | fx += alignX * W_ALIGN; |
|
| 135 | + | fy += alignY * W_ALIGN; |
|
| 136 | + | } |
|
| 137 | + | ||
| 138 | + | cohX = cohX / k - this.x; |
|
| 139 | + | cohY = cohY / k - this.y; |
|
| 140 | + | const cm = Math.hypot(cohX, cohY); |
|
| 141 | + | if (cm > 0) { |
|
| 142 | + | cohX = cohX / cm * MAX_SPEED - this.vx; |
|
| 143 | + | cohY = cohY / cm * MAX_SPEED - this.vy; |
|
| 144 | + | const sm = Math.hypot(cohX, cohY); |
|
| 145 | + | if (sm > MAX_FORCE) { cohX = cohX / sm * MAX_FORCE; cohY = cohY / sm * MAX_FORCE; } |
|
| 146 | + | fx += cohX * W_COH; |
|
| 147 | + | fy += cohY * W_COH; |
|
| 148 | + | } |
|
| 149 | + | } |
|
| 150 | + | ||
| 151 | + | if (sepCount > 0) { |
|
| 152 | + | const m = Math.hypot(sepX, sepY); |
|
| 153 | + | if (m > 0) { |
|
| 154 | + | sepX = sepX / m * MAX_SPEED - this.vx; |
|
| 155 | + | sepY = sepY / m * MAX_SPEED - this.vy; |
|
| 156 | + | const sm = Math.hypot(sepX, sepY); |
|
| 157 | + | if (sm > MAX_FORCE * 2) { sepX = sepX / sm * MAX_FORCE * 2; sepY = sepY / sm * MAX_FORCE * 2; } |
|
| 158 | + | fx += sepX * W_SEP; |
|
| 159 | + | fy += sepY * W_SEP; |
|
| 160 | + | } |
|
| 161 | + | } |
|
| 162 | + | ||
| 163 | + | const adx = attractor.x - this.x; |
|
| 164 | + | const ady = attractor.y - this.y; |
|
| 165 | + | const ad = Math.hypot(adx, ady); |
|
| 166 | + | if (ad > 0) { |
|
| 167 | + | fx += (adx / ad) * W_ATTRACT; |
|
| 168 | + | fy += (ady / ad) * W_ATTRACT; |
|
| 169 | + | } |
|
| 170 | + | ||
| 171 | + | fx += (Math.random() - 0.5) * W_NOISE; |
|
| 172 | + | fy += (Math.random() - 0.5) * W_NOISE; |
|
| 173 | + | ||
| 174 | + | this.ax += fx; |
|
| 175 | + | this.ay += fy; |
|
| 176 | + | } |
|
| 177 | + | ||
| 178 | + | update() { |
|
| 179 | + | this.vx += this.ax; |
|
| 180 | + | this.vy += this.ay; |
|
| 181 | + | const sp = Math.hypot(this.vx, this.vy); |
|
| 182 | + | if (sp > MAX_SPEED) { |
|
| 183 | + | this.vx = this.vx / sp * MAX_SPEED; |
|
| 184 | + | this.vy = this.vy / sp * MAX_SPEED; |
|
| 185 | + | } else if (sp < MIN_SPEED && sp > 0) { |
|
| 186 | + | this.vx = this.vx / sp * MIN_SPEED; |
|
| 187 | + | this.vy = this.vy / sp * MIN_SPEED; |
|
| 188 | + | } |
|
| 189 | + | this.x += this.vx; |
|
| 190 | + | this.y += this.vy; |
|
| 191 | + | this.ax = 0; |
|
| 192 | + | this.ay = 0; |
|
| 193 | + | } |
|
| 194 | + | ||
| 195 | + | draw() { |
|
| 196 | + | const angle = Math.atan2(this.vy, this.vx); |
|
| 197 | + | const cos = Math.cos(angle); |
|
| 198 | + | const sin = Math.sin(angle); |
|
| 199 | + | const len = 3.2; |
|
| 200 | + | ctx.beginPath(); |
|
| 201 | + | ctx.moveTo(this.x - cos * len, this.y - sin * len); |
|
| 202 | + | ctx.lineTo(this.x + cos * len * 0.6, this.y + sin * len * 0.6); |
|
| 203 | + | ctx.stroke(); |
|
| 204 | + | } |
|
| 205 | + | } |
|
| 206 | + | ||
| 207 | + | const boids: Boid[] = []; |
|
| 208 | + | const cx0 = W / 2, cy0 = H / 2; |
|
| 209 | + | for (let i = 0; i < COUNT; i++) { |
|
| 210 | + | const b = new Boid(); |
|
| 211 | + | const r = Math.random() * Math.min(W, H) * 0.25; |
|
| 212 | + | const a = Math.random() * Math.PI * 2; |
|
| 213 | + | b.x = cx0 + Math.cos(a) * r; |
|
| 214 | + | b.y = cy0 + Math.sin(a) * r; |
|
| 215 | + | boids.push(b); |
|
| 216 | + | } |
|
| 217 | + | ||
| 218 | + | const cellSize = PERCEPTION; |
|
| 219 | + | const grid = new Map<string, Boid[]>(); |
|
| 220 | + | function buildGrid() { |
|
| 221 | + | grid.clear(); |
|
| 222 | + | for (let i = 0; i < boids.length; i++) { |
|
| 223 | + | const b = boids[i]; |
|
| 224 | + | const cx = Math.floor(b.x / cellSize); |
|
| 225 | + | const cy = Math.floor(b.y / cellSize); |
|
| 226 | + | const k = cx + ',' + cy; |
|
| 227 | + | let arr = grid.get(k); |
|
| 228 | + | if (!arr) { arr = []; grid.set(k, arr); } |
|
| 229 | + | arr.push(b); |
|
| 230 | + | } |
|
| 231 | + | } |
|
| 232 | + | function neighbors(b: Boid): Boid[] { |
|
| 233 | + | const cx = Math.floor(b.x / cellSize); |
|
| 234 | + | const cy = Math.floor(b.y / cellSize); |
|
| 235 | + | const out: Boid[] = []; |
|
| 236 | + | for (let dx = -1; dx <= 1; dx++) { |
|
| 237 | + | for (let dy = -1; dy <= 1; dy++) { |
|
| 238 | + | const arr = grid.get((cx + dx) + ',' + (cy + dy)); |
|
| 239 | + | if (arr) for (let i = 0; i < arr.length; i++) out.push(arr[i]); |
|
| 240 | + | } |
|
| 241 | + | } |
|
| 242 | + | return out; |
|
| 243 | + | } |
|
| 244 | + | ||
| 245 | + | function loop() { |
|
| 246 | + | ctx.fillStyle = 'rgba(18, 17, 19, 0.55)'; |
|
| 247 | + | ctx.fillRect(0, 0, W, H); |
|
| 248 | + | ||
| 249 | + | attractor.update(); |
|
| 250 | + | buildGrid(); |
|
| 251 | + | ||
| 252 | + | for (let i = 0; i < boids.length; i++) { |
|
| 253 | + | boids[i].flock(neighbors(boids[i])); |
|
| 254 | + | } |
|
| 255 | + | for (let i = 0; i < boids.length; i++) { |
|
| 256 | + | boids[i].update(); |
|
| 257 | + | boids[i].edges(); |
|
| 258 | + | boids[i].draw(); |
|
| 259 | + | } |
|
| 260 | + | ||
| 261 | + | requestAnimationFrame(loop); |
|
| 262 | + | } |
|
| 263 | + | loop(); |
|
| 264 | + | })(); |
|
| 265 | + | </script> |
|
| 266 | + | ||
| 267 | + | <style> |
|
| 268 | + | #murmurations-canvas { |
|
| 269 | + | position: fixed; |
|
| 270 | + | inset: 0; |
|
| 271 | + | width: 100%; |
|
| 272 | + | height: 100%; |
|
| 273 | + | display: block; |
|
| 274 | + | touch-action: none; |
|
| 275 | + | } |
|
| 276 | + | </style> |
| 1 | - | --- |
|
| 2 | - | import GoogleBalls from "@/components/page/GoogleBalls.astro"; |
|
| 3 | - | --- |
|
| 4 | - | ||
| 5 | - | <html lang="en"> |
|
| 6 | - | <head> |
|
| 7 | - | <meta charset="utf-8" /> |
|
| 8 | - | <meta name="viewport" content="width=device-width, initial-scale=1" /> |
|
| 9 | - | <meta name="apple-mobile-web-app-capable" content="yes" /> |
|
| 10 | - | <meta name="theme-color" content="#121113" /> |
|
| 11 | - | <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> |
|
| 12 | - | <title>Google Balls</title> |
|
| 13 | - | <style> |
|
| 14 | - | * { margin: 0; padding: 0; box-sizing: border-box; } |
|
| 15 | - | html, body { width: 100%; height: 100%; overflow: hidden; background: #121113; } |
|
| 16 | - | </style> |
|
| 17 | - | </head> |
|
| 18 | - | <body> |
|
| 19 | - | <div id="googleballs-modal" style="position:fixed;inset:0;background:#121113;"> |
|
| 20 | - | <a href="https://googleballs.com" target="_blank" rel="noreferrer" style="position:absolute;top:16px;left:16px;color:#fff;font-size:16px;cursor:pointer;z-index:10;text-decoration:underline;">Google Balls</a> |
|
| 21 | - | <a href="/" style="position:absolute;top:16px;right:16px;z-index:10;color:#fff;font-size:16px;text-decoration:underline;">Back</a> |
|
| 22 | - | <GoogleBalls /> |
|
| 23 | - | </div> |
|
| 24 | - | <script> |
|
| 25 | - | if (window.__googleballs) window.__googleballs.init(); |
|
| 26 | - | </script> |
|
| 27 | - | </body> |
|
| 28 | - | </html> |
| 1 | + | --- |
|
| 2 | + | import Murmurations from "@/components/page/Murmurations.astro"; |
|
| 3 | + | --- |
|
| 4 | + | ||
| 5 | + | <html lang="en"> |
|
| 6 | + | <head> |
|
| 7 | + | <meta charset="utf-8" /> |
|
| 8 | + | <meta name="viewport" content="width=device-width, initial-scale=1" /> |
|
| 9 | + | <meta name="apple-mobile-web-app-capable" content="yes" /> |
|
| 10 | + | <meta name="theme-color" content="#121113" /> |
|
| 11 | + | <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> |
|
| 12 | + | <title>Murmurations</title> |
|
| 13 | + | <style> |
|
| 14 | + | * { margin: 0; padding: 0; box-sizing: border-box; } |
|
| 15 | + | html, body { |
|
| 16 | + | width: 100%; |
|
| 17 | + | height: 100vh; |
|
| 18 | + | height: 100dvh; |
|
| 19 | + | overflow: hidden; |
|
| 20 | + | background: #121113; |
|
| 21 | + | overscroll-behavior: none; |
|
| 22 | + | touch-action: none; |
|
| 23 | + | -webkit-tap-highlight-color: transparent; |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | .top-link { |
|
| 27 | + | position: fixed; |
|
| 28 | + | top: max(16px, env(safe-area-inset-top)); |
|
| 29 | + | color: #f5f3ee; |
|
| 30 | + | font-size: 16px; |
|
| 31 | + | z-index: 10; |
|
| 32 | + | text-decoration: underline; |
|
| 33 | + | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace; |
|
| 34 | + | padding: 8px; |
|
| 35 | + | margin: -8px; |
|
| 36 | + | } |
|
| 37 | + | .top-link.title { left: max(16px, env(safe-area-inset-left)); } |
|
| 38 | + | .top-link.back { right: max(16px, env(safe-area-inset-right)); } |
|
| 39 | + | ||
| 40 | + | .info-btn { |
|
| 41 | + | position: fixed; |
|
| 42 | + | bottom: max(16px, env(safe-area-inset-bottom)); |
|
| 43 | + | right: max(16px, env(safe-area-inset-right)); |
|
| 44 | + | width: 40px; |
|
| 45 | + | height: 40px; |
|
| 46 | + | border-radius: 50%; |
|
| 47 | + | border: 1px solid rgba(245, 243, 238, 0.6); |
|
| 48 | + | background: rgba(18, 17, 19, 0.6); |
|
| 49 | + | color: #f5f3ee; |
|
| 50 | + | font-family: ui-serif, Georgia, serif; |
|
| 51 | + | font-style: italic; |
|
| 52 | + | font-size: 18px; |
|
| 53 | + | cursor: pointer; |
|
| 54 | + | z-index: 10; |
|
| 55 | + | display: flex; |
|
| 56 | + | align-items: center; |
|
| 57 | + | justify-content: center; |
|
| 58 | + | padding: 0; |
|
| 59 | + | -webkit-tap-highlight-color: transparent; |
|
| 60 | + | } |
|
| 61 | + | .info-btn:hover, .info-btn:active { background: rgba(245, 243, 238, 0.15); } |
|
| 62 | + | ||
| 63 | + | .info-panel { |
|
| 64 | + | position: fixed; |
|
| 65 | + | bottom: calc(max(16px, env(safe-area-inset-bottom)) + 52px); |
|
| 66 | + | right: max(16px, env(safe-area-inset-right)); |
|
| 67 | + | left: max(16px, env(safe-area-inset-left)); |
|
| 68 | + | max-width: 340px; |
|
| 69 | + | margin-left: auto; |
|
| 70 | + | background: rgba(18, 17, 19, 0.94); |
|
| 71 | + | border: 1px solid rgba(245, 243, 238, 0.2); |
|
| 72 | + | color: #f5f3ee; |
|
| 73 | + | padding: 16px 18px; |
|
| 74 | + | font-size: 13px; |
|
| 75 | + | line-height: 1.5; |
|
| 76 | + | z-index: 10; |
|
| 77 | + | display: none; |
|
| 78 | + | font-family: ui-sans-serif, system-ui, sans-serif; |
|
| 79 | + | } |
|
| 80 | + | .info-panel.open { display: block; } |
|
| 81 | + | .info-panel h2 { |
|
| 82 | + | font-size: 14px; |
|
| 83 | + | margin-bottom: 8px; |
|
| 84 | + | font-weight: 600; |
|
| 85 | + | } |
|
| 86 | + | .info-panel p { margin-bottom: 8px; } |
|
| 87 | + | .info-panel p:last-child { margin-bottom: 0; } |
|
| 88 | + | .info-panel a { color: #f5f3ee; text-decoration: underline; } |
|
| 89 | + | ||
| 90 | + | @media (max-width: 480px) { |
|
| 91 | + | .top-link { font-size: 14px; } |
|
| 92 | + | .info-panel { font-size: 12px; padding: 14px; } |
|
| 93 | + | } |
|
| 94 | + | </style> |
|
| 95 | + | </head> |
|
| 96 | + | <body> |
|
| 97 | + | <span class="top-link title">Murmurations</span> |
|
| 98 | + | <a href="/" class="top-link back">Back</a> |
|
| 99 | + | <Murmurations /> |
|
| 100 | + | <button id="info-btn" class="info-btn" aria-label="About this simulation" aria-expanded="false">i</button> |
|
| 101 | + | <div id="info-panel" class="info-panel" role="dialog" aria-label="About this simulation"> |
|
| 102 | + | <h2>Boids & Starlings</h2> |
|
| 103 | + | <p> |
|
| 104 | + | A <em>murmuration</em> is the swirling, shape-shifting flight of thousands of starlings at dusk. |
|
| 105 | + | No leader directs them — coherence emerges from each bird reacting to its closest neighbors. |
|
| 106 | + | </p> |
|
| 107 | + | <p> |
|
| 108 | + | This simulation uses <a href="https://en.wikipedia.org/wiki/Boids" target="_blank" rel="noreferrer">Craig Reynolds' boids algorithm</a>: |
|
| 109 | + | each agent follows three rules — <strong>separation</strong> (avoid crowding), |
|
| 110 | + | <strong>alignment</strong> (match neighbor heading), and <strong>cohesion</strong> (steer toward neighbor center). |
|
| 111 | + | </p> |
|
| 112 | + | <p> |
|
| 113 | + | Real starlings track ~7 nearest neighbors regardless of distance — topological, not metric. |
|
| 114 | + | That fixed count is why turning waves cross the flock so fast and stay crisp at any density. |
|
| 115 | + | </p> |
|
| 116 | + | </div> |
|
| 117 | + | <script> |
|
| 118 | + | const btn = document.getElementById('info-btn')!; |
|
| 119 | + | const panel = document.getElementById('info-panel')!; |
|
| 120 | + | btn.addEventListener('click', (e) => { |
|
| 121 | + | e.stopPropagation(); |
|
| 122 | + | const open = panel.classList.toggle('open'); |
|
| 123 | + | btn.setAttribute('aria-expanded', String(open)); |
|
| 124 | + | }); |
|
| 125 | + | document.addEventListener('click', (e) => { |
|
| 126 | + | if (!panel.classList.contains('open')) return; |
|
| 127 | + | if (panel.contains(e.target as Node)) return; |
|
| 128 | + | panel.classList.remove('open'); |
|
| 129 | + | btn.setAttribute('aria-expanded', 'false'); |
|
| 130 | + | }); |
|
| 131 | + | </script> |
|
| 132 | + | </body> |
|
| 133 | + | </html> |