馃悕馃悕馃悕
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