馃悕馃悕馃悕
at dev 528 lines 15 kB view raw
1 2$css(` 3 .proj-shift-container { 4 display: flex; 5 flex-direction: row; 6 } 7 8 .overlay { 9 user-select: none; 10 position: absolute; 11 z-index: 1; 12 top: 0; 13 left: 0; 14 pointer-events: none; 15 } 16`); 17 18import { greek } from "/math/math.js"; 19 20export async function main(target) { 21 //const container = document.createElement("div"); 22 //container.classList = "full proj-shift-container"; 23 24 let phi = 0.0; 25 let psi = 1.0; 26 let c = 0.85; 27 let iterations = 100; 28 let escape_distance = 2; 29 let centerX = -0.5; 30 let centerY = 0.0; 31 let zoom = 4.0; 32 33 let showTrajectory = false; 34 35 function complexMag(z) { 36 return Math.sqrt(z.x * z.x + z.y * z.y); 37 } 38 39 function complexAngle(z) { 40 return Math.atan2(z.y, z.x); 41 } 42 43 function projectiveShift(x, phi, psi) { 44 const xMag = complexMag(x); 45 const xAngle = complexAngle(x); 46 const angleDiff = xAngle - phi; 47 const newMag = xMag + Math.cos(angleDiff); 48 return { 49 x: newMag * Math.cos(xAngle * psi), 50 y: newMag * Math.sin(xAngle * psi) 51 }; 52 } 53 54 function iteratePolar(x, phi, psi, c) { 55 const shifted = projectiveShift(x, phi, psi); 56 return { x: shifted.x - c, y: shifted.y }; 57 } 58 59 function computeTrajectory(startZ, maxIters, escapeThreshold) { 60 const trajectory = [startZ]; 61 let z = { x: startZ.x, y: startZ.y }; 62 63 for (let i = 0; i < maxIters; i++) { 64 const magSq = z.x * z.x + z.y * z.y; 65 if (magSq > escapeThreshold * escapeThreshold) { 66 break; 67 } 68 z = iteratePolar(z, phi, psi, c); 69 trajectory.push({ x: z.x, y: z.y }); 70 } 71 72 return trajectory; 73 } 74 75 function complexToPixel(z) { 76 const aspect = width / height; 77 const scale = 4.0 / zoom; 78 const px = (z.x - centerX) * width / (scale * aspect) + width * 0.5; 79 const py = (z.y - centerY) * height / scale + height * 0.5; 80 return { x: px, y: py }; 81 } 82 83 84 85 // z_{n+1} = (r + cos(胃-蠁))e^(i胃蠄) - c 86 const controls = await $prepMod("control/panel", ["Parameters", [ 87 { 88 type: "number", 89 label: "c", 90 value: c, 91 min: 0, 92 max: 1, 93 step: 0.001, 94 onUpdate: (value, set) => { 95 c = value; 96 render(); 97 } 98 }, 99 { 100 type: "number", 101 label: greek["psi"], 102 value: psi, 103 min: 0, 104 max: 12, 105 step: 0.001, 106 onUpdate: (value, set) => { 107 psi = value; 108 render(); 109 } 110 }, 111 { 112 type: "number", 113 label: greek["phi"], 114 value: phi, 115 min: 0, 116 max: $tau, 117 step: 0.001, 118 onUpdate: (value, set) => { 119 phi = value; 120 render(); 121 } 122 }, 123 { 124 type: "number", 125 label: "iterations", 126 min: 0, 127 max: 500, 128 step: 1, 129 value: iterations, 130 onUpdate: (value, set) => { 131 iterations = value; 132 if (iterations < 0) { 133 iterations = 0; 134 set(0); 135 } 136 render(); 137 } 138 }, 139 { 140 type: "number", 141 label: "escape distance", 142 value: escape_distance, 143 min: 0, 144 max: 10, 145 step: 0.1, 146 onUpdate: (value, set) => { 147 escape_distance = value; 148 render(); 149 } 150 }, 151 ]]); 152 153 const renderStack = $div("full"); 154 renderStack.style.position = "relative"; 155 156 const gpuModule = await $mod("gpu/webgpu", renderStack); 157 158 const canvas = gpuModule.canvas; 159 const context = gpuModule.context; 160 161 canvas.setAttribute("aria-label", "Interactive visualization of the iterated projective shift map."); 162 canvas.setAttribute("role", "application"); 163 canvas.setAttribute("aria-keyshortcuts", "f"); 164 165 const compShader = await $gpu.loadShader("proj_shift"); 166 const blitShader = await $gpu.loadShader("blit"); 167 168 if (!compShader || !blitShader) return; 169 170 const computePipeline = $gpu.device.createComputePipeline({ 171 layout: "auto", 172 compute: { 173 module: compShader, 174 entryPoint: "main" 175 } 176 }); 177 178 const renderPipeline = $gpu.device.createRenderPipeline({ 179 layout: "auto", 180 vertex: { 181 module: blitShader, 182 entryPoint: "vert" 183 }, 184 fragment: { 185 module: blitShader, 186 entryPoint: "frag", 187 targets: [ { format: $gpu.canvasFormat } ] 188 }, 189 primitive: { 190 topology: "triangle-list" 191 } 192 }); 193 194 const overlay = $svgElement("svg"); 195 overlay.classList = "full overlay"; 196 197 overlay.setAttribute("aria-label", "Overlay visualizing the trajectory starting from the point under the cursor.") 198 199 const dot = $svgElement("circle"); 200 dot.setAttribute("r", "3") 201 dot.setAttribute("fill", "red"); 202 dot.style.display = "block"; 203 204 overlay.appendChild(dot); 205 renderStack.appendChild(overlay); 206 207 function showControls() { 208 if (controls.parentNode) return; 209 210 return ["show controls", async () => { 211 await $mod("layout/split", renderStack.parentNode, [{content: [controls, renderStack], percents: [20, 80]}]); 212 }]; 213 } 214 215 function toggleTrajectory() { 216 if (showTrajectory) return ["hide trajectory", () => {showTrajectory = false}]; 217 return ["show trajectory", () => {showTrajectory = true}]; 218 } 219 220 renderStack.$preventCollapse = true; 221 renderStack.$contextMenu = { 222 items: [showControls, toggleTrajectory] 223 }; 224 225 await $mod("layout/split", target, [{ content: [controls, renderStack], percents: [20, 80]}]); 226 227 let width = canvas.clientWidth; 228 let height = canvas.clientHeight; 229 canvas.width = width; 230 canvas.height = height; 231 232 let outputTexture = $gpu.device.createTexture({ 233 size: [width, height], 234 format: "rgba8unorm", 235 usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING, 236 }); 237 238 const uniformBuffer = $gpu.device.createBuffer({ 239 size: 10 * 4, 240 usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, 241 }); 242 243 const sampler = $gpu.device.createSampler({ magFilter: "nearest", minfilter: "nearest" }); 244 245 let computeBindGroup = $gpu.device.createBindGroup({ 246 layout: computePipeline.getBindGroupLayout(0), 247 entries: [ 248 { binding: 0, resource: { buffer: uniformBuffer } }, 249 { binding: 1, resource: outputTexture.createView() } 250 ] 251 }); 252 253 let renderBindGroup = $gpu.device.createBindGroup({ 254 layout: renderPipeline.getBindGroupLayout(0), 255 entries: [ 256 { binding: 0, resource: sampler }, 257 { binding: 1, resource: outputTexture.createView() } 258 ] 259 }); 260 261 let canRender = true; 262 263 function resize() { 264 width = canvas.clientWidth; 265 height = canvas.clientHeight; 266 267 overlay.setAttribute("viewBox", `0 0 ${width} ${height}`); 268 overlay.setAttribute("width", width); 269 overlay.setAttribute("height", height); 270 271 canvas.width = width; 272 canvas.height = height; 273 274 if (width * height <= 0) { 275 canRender = false; 276 return; 277 } 278 279 canRender = true; 280 281 outputTexture.destroy(); 282 283 outputTexture = $gpu.device.createTexture({ 284 size: [width, height], 285 format: "rgba8unorm", 286 usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING, 287 }); 288 289 computeBindGroup = $gpu.device.createBindGroup({ 290 layout: computePipeline.getBindGroupLayout(0), 291 entries: [ 292 { binding: 0, resource: { buffer: uniformBuffer } }, 293 { binding: 1, resource: outputTexture.createView() } 294 ] 295 }); 296 297 renderBindGroup = $gpu.device.createBindGroup({ 298 layout: renderPipeline.getBindGroupLayout(0), 299 entries: [ 300 { binding: 0, resource: sampler }, 301 { binding: 1, resource: outputTexture.createView() } 302 ] 303 }); 304 305 render(); 306 } 307 308 function updateUniforms() { 309 // TODO: save the same buffer 310 const uniformData = new ArrayBuffer(10 * 4); 311 const view = new DataView(uniformData); 312 313 /* 314 center_x: f32, 315 center_y: f32, 316 zoom: f32, 317 phi: f32, 318 c: f32, 319 width: u32, 320 height: u32, 321 max_iter: u32, 322 escape_distance: f32, 323 psi: f32, 324 */ 325 326 // TODO less brittle hard coded numbers jfc 327 view.setFloat32(0, centerX, true); 328 view.setFloat32(4, centerY, true); 329 view.setFloat32(8, zoom, true); 330 view.setFloat32(12, phi, true); 331 view.setFloat32(16, c, true); 332 view.setUint32(20, width, true); 333 view.setUint32(24, height, true); 334 view.setUint32(28, iterations, true); 335 view.setFloat32(32, escape_distance, true); 336 view.setFloat32(36, psi, true); 337 338 $gpu.device.queue.writeBuffer(uniformBuffer, 0, uniformData); 339 } 340 341 const resizeObserver = new ResizeObserver(resize); 342 resizeObserver.observe(canvas); 343 344 function render() { 345 if (!canRender) { 346 console.warn("Cannot render; aborting render."); 347 return; 348 } 349 350 updateUniforms(); 351 352 const commandEncoder = $gpu.device.createCommandEncoder(); 353 354 const computePass = commandEncoder.beginComputePass(); 355 computePass.setPipeline(computePipeline); 356 computePass.setBindGroup(0, computeBindGroup); 357 computePass.dispatchWorkgroups( 358 Math.ceil(width / 16), 359 Math.ceil(height / 16), 360 1 361 ); 362 computePass.end(); 363 364 const renderPass = commandEncoder.beginRenderPass({ 365 colorAttachments: [ 366 { 367 view: context.getCurrentTexture().createView(), 368 loadOp: "clear", 369 storeOp: "store" 370 } 371 ] 372 }); 373 374 renderPass.setPipeline(renderPipeline); 375 renderPass.setBindGroup(0, renderBindGroup); 376 renderPass.draw(6); // 1 quad, 2 tris 377 renderPass.end(); 378 379 $gpu.device.queue.submit([commandEncoder.finish()]); 380 } 381 382 // Lots of unreviewed code from claude here: 383 384 let isDragging = false; 385 let lastMouseX = 0; 386 let lastMouseY = 0; 387 388 canvas.addEventListener("pointerdown", (e) => { 389 if (e.button !== 0) return; 390 isDragging = true; 391 const rect = canvas.getBoundingClientRect(); 392 lastMouseX = e.clientX - rect.left; 393 lastMouseY = e.clientY - rect.top; 394 canvas.style.cursor = "all-scroll"; 395 }); 396 397 canvas.addEventListener("pointermove", (e) => { 398 const rect = canvas.getBoundingClientRect(); 399 const mouseX = e.clientX - rect.left; 400 const mouseY = e.clientY - rect.top; 401 402 dot.style.display = "block"; 403 dot.setAttribute("cx", mouseX); 404 dot.setAttribute("cy", mouseY); 405 406 const scale = 4.0 / zoom; 407 const aspect = width / height; 408 409 410// Compute and display trajectory 411 const complexX = (mouseX - width * 0.5) * scale * aspect / width + centerX; 412 const complexY = (mouseY - height * 0.5) * scale / height + centerY; 413 414 const startZ = { x: complexX, y: complexY }; 415 const trajectory = computeTrajectory(startZ, Math.min(iterations, 500), escape_distance); 416 417 // Clear existing trajectory 418 const existingPath = overlay.querySelector(".trajectory-path"); 419 if (existingPath) { 420 existingPath.remove(); 421 } 422 423 if (showTrajectory && trajectory.length > 1) { 424 const path = $svgElement("path"); 425 path.setAttribute("class", "trajectory-path"); 426 path.setAttribute("fill", "none"); 427 path.setAttribute("stroke", "lime"); 428 path.setAttribute("stroke-width", "2"); 429 path.setAttribute("opacity", "1.0"); 430 431 let pathData = ""; 432 for (let i = 0; i < trajectory.length; i++) { 433 const pixel = complexToPixel(trajectory[i]); 434 435 // Skip points outside visible area 436 if (pixel.x < -10 || pixel.x > width + 10 || 437 pixel.y < -10 || pixel.y > height + 10) { 438 //continue; 439 } 440 441 if (pathData === "") { 442 pathData = `M ${pixel.x} ${pixel.y}`; 443 } else { 444 pathData += ` L ${pixel.x} ${pixel.y}`; 445 } 446 } 447 448 if (pathData !== "") { 449 path.setAttribute("d", pathData); 450 overlay.appendChild(path); 451 } 452 } 453 454 455 if (!isDragging) return; 456 457 e.preventDefault(); 458 e.stopPropagation(); 459 e.stopImmediatePropagation(); 460 461 const deltaX = mouseX - lastMouseX; 462 const deltaY = mouseY - lastMouseY; 463 464 // Convert pixel delta to complex plane delta 465 const complexDeltaX = -deltaX * scale * aspect / width; 466 const complexDeltaY = -deltaY * scale / height; 467 468 centerX += complexDeltaX; 469 centerY += complexDeltaY; 470 471 lastMouseX = mouseX; 472 lastMouseY = mouseY; 473 474 render(); 475 }); 476 477 // Mouse up - stop dragging 478 canvas.addEventListener("pointerup", () => { 479 if (!isDragging) return; 480 isDragging = false; 481 canvas.style.cursor = "crosshair"; 482 }); 483 484 // Mouse leave - stop dragging if mouse leaves canvas 485 canvas.addEventListener("pointerleave", () => { 486 if (!isDragging) return; 487 isDragging = false; 488 canvas.style.cursor = "crosshair"; 489 dot.style.display = "block"; 490 }); 491 492 // Wheel - zoom in/out 493 canvas.addEventListener("wheel", (e) => { 494 e.preventDefault(); 495 496 const rect = canvas.getBoundingClientRect(); 497 const mouseX = e.clientX - rect.left; 498 const mouseY = e.clientY - rect.top; 499 500 // Convert mouse position to complex coordinates before zoom 501 const scale = 4.0 / zoom; 502 const aspect = width / height; 503 const complexX = (mouseX - width * 0.5) * scale * aspect / width + centerX; 504 const complexY = (mouseY - height * 0.5) * scale / height + centerY; 505 506 // Zoom factor - negative deltaY means zoom in 507 const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; 508 zoom *= zoomFactor; 509 510 // Adjust center to keep mouse position fixed in complex plane 511 const newScale = 4.0 / zoom; 512 const newCenterX = complexX - (mouseX - width * 0.5) * newScale * aspect / width; 513 const newCenterY = complexY - (mouseY - height * 0.5) * newScale / height; 514 515 centerX = newCenterX; 516 centerY = newCenterY; 517 518 render(); 519 }); 520 521 canvas.style.cursor = "crosshair"; 522 523 render(); 524 525 526 return { replace: true }; 527} 528