Index: src/assets/js/ward-sky.js
===================================================================
--- src/assets/js/ward-sky.js	(revision 5b6f93b3200dfbc83990a4775dd5310d15493abd)
+++ src/assets/js/ward-sky.js	(revision 3866994fe8be998ed18b62cee35531e7d013b18d)
@@ -254,51 +254,111 @@
   }
 
+  /** De schaal binnen zijn grenzen. Eén plek, zodat wiel en pinch gelijk zijn. */
+  _clampScale(s) { return Math.min(Math.max(s, 0.2), 2.5); }
+
+  /**
+   * Zoomen ROND een schermpunt, niet rond het canvas-midden: de wereld onder
+   * je vingers (of je muis) blijft staan, zodat de hemel zich als een kaart
+   * gedraagt in plaats van onder je weg te schuiven.
+   */
+  _zoomAround(px, py, factor) {
+    const next = this._clampScale(this.scale * factor);
+    const cx = this.w / 2, cy = this.h / 2;
+    // Het wereldpunt onder (px,py) mag na de zoom op dezelfde plek blijven.
+    const worldX = (px - cx - this.panX) / this.scale;
+    const worldY = (py - cy - this.panY) / this.scale;
+    this.panX = px - cx - worldX * next;
+    this.panY = py - cy - worldY * next;
+    this.scale = next;
+  }
+
   _wire() {
     const c = this.canvas;
-    let dragging = false, lastX = 0, lastY = 0, moved = 0;
-    let pinch = null;
+    // Alle vingers die nu op de hemel liggen. Eén = pannen of tikken, twee =
+    // knijpen. Een Map want pointers komen en gaan in willekeurige volgorde.
+    const pts = new Map();
+    let moved = 0;                 // hoeveel de ENE vinger bewoog (tik vs sleep)
+    let pinchDist = 0;             // vingerafstand bij het begin van een knijp
+    let becamePinch = false;       // ooit twee vingers? dan geen tik aan het eind
+
+    const local = (e) => {
+      const r = c.getBoundingClientRect();
+      return { x: e.clientX - r.left, y: e.clientY - r.top };
+    };
+    const twoPointerState = () => {
+      const [a, b] = [...pts.values()];
+      return {
+        dist: Math.hypot(a.x - b.x, a.y - b.y),
+        mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2,
+      };
+    };
 
     c.addEventListener('pointerdown', (e) => {
       if (!this.interactive) return;
-      dragging = true; moved = 0;
-      lastX = e.clientX; lastY = e.clientY;
       c.setPointerCapture(e.pointerId);
+      pts.set(e.pointerId, local(e));
+      moved = 0;
+      if (pts.size === 2) { becamePinch = true; pinchDist = twoPointerState().dist; }
     });
+
     c.addEventListener('pointermove', (e) => {
-      if (!this.interactive || !dragging) return;
-      const dx = e.clientX - lastX, dy = e.clientY - lastY;
-      moved += Math.abs(dx) + Math.abs(dy);
-      this.panX += dx; this.panY += dy;
-      lastX = e.clientX; lastY = e.clientY;
+      if (!this.interactive || !pts.has(e.pointerId)) return;
+      const prev = pts.get(e.pointerId);
+      const cur = local(e);
+      pts.set(e.pointerId, cur);
+
+      if (pts.size >= 2) {
+        // Knijpen: schaal volgt de verhouding van de vingerafstand, en we
+        // zoomen om het midden tussen de vingers.
+        const { dist, mx, my } = twoPointerState();
+        if (pinchDist > 0 && dist > 0) {
+          this._zoomAround(mx, my, dist / pinchDist);
+          pinchDist = dist;
+        }
+      } else {
+        // Eén vinger: pannen.
+        const dx = cur.x - prev.x, dy = cur.y - prev.y;
+        moved += Math.abs(dx) + Math.abs(dy);
+        this.panX += dx; this.panY += dy;
+      }
     });
-    const end = (e) => {
-      if (!this.interactive || !dragging) return;
-      dragging = false;
-      // Nauwelijks bewogen? Dan was het een tik en geen sleep.
-      if (moved < 6) {
-        const r = c.getBoundingClientRect();
-        const x = e.clientX - r.left;
-        const y = e.clientY - r.top;
+
+    const lift = (e) => {
+      if (!pts.has(e.pointerId)) return;
+      const cur = pts.get(e.pointerId);
+      pts.delete(e.pointerId);
+      // Nog één vinger over na een knijp: zijn afstand als nieuw ijkpunt, en
+      // niet meteen als tik tellen.
+      if (pts.size === 1) { pinchDist = 0; return; }
+      if (pts.size > 1) return;
+
+      // Laatste vinger los. Een tik alleen als er nooit geknepen is en de
+      // vinger nauwelijks bewoog.
+      if (!becamePinch && moved < 6) {
+        const { x, y } = cur;
         const hit = nearestFace(x, y, this.spots, {
           cx: this.w / 2, cy: this.h / 2, cell: CELL, scale: this.scale,
           panX: this.panX, panY: this.panY,
         });
-        if (hit != null) { this.onPick?.(hit); return; }
-        // Geen gezicht? Dan misschien de (+): zelfde middelpunt als getekend,
-        // met een raakzone die meeschaalt zodat mikken bij elke zoom klopt.
-        const s2 = Math.min(Math.max(this.scale, 0.5), 1.6);
-        const d = Math.hypot(x - (this.w / 2 + this.panX), y - (this.h / 2 + this.panY));
-        if (d <= 26 * s2) this.onNew?.();
+        if (hit != null) { this.onPick?.(hit); }
+        else {
+          // Geen gezicht? Dan misschien de (+): zelfde middelpunt als getekend,
+          // met een raakzone die meeschaalt zodat mikken bij elke zoom klopt.
+          const s2 = Math.min(Math.max(this.scale, 0.5), 1.6);
+          const d = Math.hypot(x - (this.w / 2 + this.panX), y - (this.h / 2 + this.panY));
+          if (d <= 26 * s2) this.onNew?.();
+        }
       }
-    };
-    c.addEventListener('pointerup', end);
-    c.addEventListener('pointercancel', () => { dragging = false; });
-
-    // Knijpen op een telefoon, wiel op een desktop; allebei zoomen om het
-    // midden, zodat de hemel zich als een kaart gedraagt.
+      becamePinch = false;
+    };
+    c.addEventListener('pointerup', lift);
+    c.addEventListener('pointercancel', (e) => { pts.delete(e.pointerId); if (pts.size < 2) pinchDist = 0; });
+
+    // Het wiel op een desktop: zoomen om de muis, dezelfde weg als de pinch.
     c.addEventListener('wheel', (e) => {
       if (!this.interactive) return;
       e.preventDefault();
-      this.scale = Math.min(Math.max(this.scale * (e.deltaY < 0 ? 1.1 : 0.9), 0.2), 2.5);
+      const { x, y } = local(e);
+      this._zoomAround(x, y, e.deltaY < 0 ? 1.1 : 0.9);
     }, { passive: false });
 
Index: test/ward-sky.test.js
===================================================================
--- test/ward-sky.test.js	(revision 5b6f93b3200dfbc83990a4775dd5310d15493abd)
+++ test/ward-sky.test.js	(revision 3866994fe8be998ed18b62cee35531e7d013b18d)
@@ -9,5 +9,5 @@
 import assert from 'node:assert/strict';
 
-const { orbitPlacement, nearestFace } = await import('../src/assets/js/ward-sky.js');
+const { orbitPlacement, nearestFace, SkyRenderer } = await import('../src/assets/js/ward-sky.js');
 
 const r = (s) => Math.hypot(s.x, s.y);
@@ -68,2 +68,33 @@
   assert.equal(at(1.5), 1);
 });
+
+test('zoomen houdt het punt onder je vingers op zijn plek', () => {
+  // De kern van pinch-to-zoom: de wereld onder het midden tussen je vingers
+  // mag NIET wegschuiven. We rekenen _zoomAround na zonder een echt canvas,
+  // door de renderer een minimale staat te geven.
+  // We hebben geen DOM, dus bouwen een kaal object met alleen wat _zoomAround
+  // aanraakt en lenen de methode.
+  const fake = { w: 400, h: 400, scale: 1, panX: 0, panY: 0,
+    _clampScale: SkyRenderer.prototype._clampScale };
+  const zoom = SkyRenderer.prototype._zoomAround;
+
+  // Welk wereldpunt ligt nu onder (px,py)?
+  const worldUnder = (o, px, py) => ({
+    x: (px - o.w / 2 - o.panX) / o.scale,
+    y: (py - o.h / 2 - o.panY) / o.scale,
+  });
+  const px = 300, py = 120;
+  const before = worldUnder(fake, px, py);
+  zoom.call(fake, px, py, 1.6);   // inzoomen om dat punt
+  const after = worldUnder(fake, px, py);
+
+  assert.ok(Math.abs(before.x - after.x) < 1e-6, 'x schoof weg onder de vingers');
+  assert.ok(Math.abs(before.y - after.y) < 1e-6, 'y schoof weg onder de vingers');
+  assert.ok(fake.scale > 1, 'er is niet ingezoomd');
+});
+
+test('de zoom blijft binnen zijn grenzen', () => {
+  const clamp = SkyRenderer.prototype._clampScale;
+  assert.equal(clamp(10), 2.5);    // niet eindeloos in
+  assert.equal(clamp(0.01), 0.2);  // niet eindeloos uit
+});
