gds-lens 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,47 @@ follows [semantic versioning](https://semver.org/spec/v2.0.0.html).
7
7
  From 1.0.0 on, a breaking change to the element's API waits for a major
8
8
  version. Before that, `0.x` releases changed it freely.
9
9
 
10
+ ## [1.1.0] - 2026-08-31
11
+
12
+ ### Added
13
+
14
+ - Touch: one finger pans the layout, two pinch to zoom about the point between
15
+ them and drag the view with it. Lifting one finger of a pinch hands the
16
+ gesture to the other rather than ending it, so a pinch that relaxes into a
17
+ drag does not jump.
18
+
19
+ ### Changed
20
+
21
+ - On a touch-only device (a coarse pointer *and* no hover, so a touchscreen
22
+ laptop is not one) the control panel starts collapsed to its title bar, and
23
+ both it and the cell hierarchy are capped at 85% of the viewport width. A
24
+ panel sized for a window is most of a phone screen, and the layout is what
25
+ someone opening the viewer came for.
26
+ - Measure mode is offered greyed out where there is no hovering pointer. It is
27
+ placed by clicking two points, and without hover the snap indicator only
28
+ appears after the tap that already used it, under a fingertip.
29
+ - The demo page drops its tagline, its file button, its status line and its
30
+ what-this-is-built-from footer line below 620px. Two of those cannot work on
31
+ touch anyway: there is no drag-and-drop, and the file picker's extension
32
+ filter greys out `.gds`/`.oas` in the iOS Files app.
33
+
34
+ ### Fixed
35
+
36
+ - Touch input did not work at all on iOS. The gestures were handled in the
37
+ renderer, through `emscripten_set_touchstart_callback`, and that callback
38
+ never runs on iOS Safari: the events reach the canvas and the handler behind
39
+ them does not fire. Emscripten resolves mouse and touch targets through the
40
+ same code path, so the mouse handlers next to them were fine, which is what
41
+ made this invisible from a desktop. The gestures are now ordinary listeners
42
+ on the element, driving the camera through the exported `getCamera` /
43
+ `setCamera` -- the same arithmetic, with nothing between the DOM event and
44
+ the state it changes. Covered by a test that spells out the touch lists.
45
+ - A two-finger pinch over the viewer zoomed the page on iOS instead of the
46
+ layout. Safari answers a pinch with its own page zoom, delivered as a
47
+ proprietary `GestureEvent`, and it does that over an element that has already
48
+ claimed the gesture with `touch-action: none`. Refused on the canvas alone,
49
+ so a pinch anywhere else in an embedding page still zooms it.
50
+
10
51
  ## [1.0.3] - 2026-08-31
11
52
 
12
53
  ### Fixed
@@ -348,7 +389,9 @@ web page rather than for a webview.
348
389
  worker-loading route and shipped unsubstituted. The `createWorker` host hook
349
390
  replaces it.
350
391
 
351
- [Unreleased]: https://github.com/EthanLowenthal/GDS-Lens/compare/v1.0.2...HEAD
392
+ [Unreleased]: https://github.com/EthanLowenthal/GDS-Lens/compare/v1.1.0...HEAD
393
+ [1.1.0]: https://github.com/EthanLowenthal/GDS-Lens/compare/v1.0.3...v1.1.0
394
+ [1.0.3]: https://github.com/EthanLowenthal/GDS-Lens/compare/v1.0.2...v1.0.3
352
395
  [1.0.2]: https://github.com/EthanLowenthal/GDS-Lens/compare/v1.0.1...v1.0.2
353
396
  [1.0.1]: https://github.com/EthanLowenthal/GDS-Lens/compare/v1.0.0...v1.0.1
354
397
  [1.0.0]: https://github.com/EthanLowenthal/GDS-Lens/compare/v0.1.1...v1.0.0
package/README.md CHANGED
@@ -32,10 +32,10 @@ import "gds-lens"; // registers <gds-lens>
32
32
  <gds-lens src="chip.gds" style="width: 100%; height: 600px"></gds-lens>
33
33
  ```
34
34
 
35
- That's the whole thing. Pan with the mouse, zoom with the wheel. The import
36
- pulls in one self-contained module the parser, the renderer, the WebAssembly
37
- binary, and the control panel so there is nothing to copy and nothing
38
- else to serve.
35
+ That's the whole thing. Pan with the mouse, zoom with the wheel; on a touch
36
+ screen, drag to pan and pinch to zoom. The import pulls in one self-contained
37
+ module the parser, the renderer, the WebAssembly binary, and the control
38
+ panel — so there is nothing to copy and nothing else to serve.
39
39
 
40
40
  Or drive it from JavaScript:
41
41
 
@@ -895,7 +895,12 @@ var init_viewer = __esm({
895
895
  against it rather than against the page. */
896
896
  :host { display: block; position: relative; width: 100%; height: 100%; overflow: hidden;
897
897
  background: var(--bg); user-select: none; color: var(--fg); }
898
- canvas { width: 100%; height: 100%; display: block; }
898
+ /* touch-action: none hands every gesture over the canvas to the touch
899
+ handlers in renderer.cpp: without it the browser claims a drag for
900
+ page scrolling and a pinch for its own page zoom before the handlers
901
+ see the second event, and it is also what turns off double-tap zoom
902
+ over the layout. */
903
+ canvas { width: 100%; height: 100%; display: block; touch-action: none; }
899
904
  /* The engine readout (polygon/label counts + the per-frame #renderStats
900
905
  span, both written from renderer.cpp). It lives inside #debugPanel
901
906
  rather than floating over the canvas on its own: it's debug-only
@@ -980,6 +985,15 @@ var init_viewer = __esm({
980
985
  overlay (30) and debug panel (40) rather than lil-gui's 1001. */
981
986
  #guiHost { position: absolute; top: 0; right: 15px; max-height: 100%; z-index: 20; display: flex; }
982
987
  #guiHost > .lil-gui.lil-root { max-height: 100%; }
988
+ /* Both panels are sized for a window, and a phone is narrower than
989
+ either of them plus anything worth seeing. Capped rather than
990
+ reflowed: they still open over the layout, but never across all of
991
+ it. (The control panel also starts collapsed here -- see touchOnly
992
+ in viewer.js.) */
993
+ @media (pointer: coarse) and (hover: none) {
994
+ #guiHost > .lil-gui.lil-root { max-width: 85vw; }
995
+ #hierarchyPanel { width: min(260px, 85vw); }
996
+ }
983
997
  /* Checkbox rows (layer list + top-level toggles): lil-gui gives the
984
998
  label a 45% min-width and no overflow handling, so a long name like
985
999
  "250/74 \u2013 SIN_WG_S" would push into the checkbox column. Widen the
@@ -1021,6 +1035,10 @@ var init_viewer = __esm({
1021
1035
  buttons a readable width. */
1022
1036
  .lil-gui .lil-controller.mode-row .lil-name { min-width: 30%; }
1023
1037
  .lil-gui .lil-controller.mode-row .mode-widget { gap: 4px; }
1038
+ /* Greyed out where the mode is not offered (see touchOnly in
1039
+ viewer.js). lil-gui dims a whole disabled controller, but has no rule
1040
+ for one disabled button inside a live row. */
1041
+ .lil-gui .lil-controller.mode-row .mode-widget button:disabled { opacity: 0.5; cursor: default; }
1024
1042
  .lil-gui .lil-controller.mode-row .mode-widget button { flex: 1; }
1025
1043
  /* :hover/:active spelled out too -- lil-gui's own button:hover rule
1026
1044
  outranks a bare .mode-active, which would grey out the selected
@@ -3126,6 +3144,7 @@ function createViewer(mountTarget) {
3126
3144
  const hostCall = (name, ...args) => hostCan(name) ? host[name](...args) : void 0;
3127
3145
  trace("[GDS] host ready; Worker:", typeof Worker, "Blob:", typeof Blob, "bundled worker:", !!workerBundle);
3128
3146
  const gui = new GUI({ width: 260, container: viewerRoot.getElementById("guiHost"), injectStyles: false });
3147
+ const touchOnly = typeof window.matchMedia === "function" && window.matchMedia("(pointer: coarse) and (hover: none)").matches;
3129
3148
  const actions = {
3130
3149
  // Clicking the row always opens the file dialog (load, or replace the
3131
3150
  // current file); the injected ✕ (see setFileChip) handles unloading.
@@ -3154,6 +3173,7 @@ function createViewer(mountTarget) {
3154
3173
  const lypController = displayFolder.add(actions, "loadLypFile").name("Load .lyp File");
3155
3174
  const markerController = displayFolder.add(actions, "loadMarkerFile").name("Load Marker File (.lyrdb / DRC)");
3156
3175
  displayFolder.add(actions, "resetView").name("Reset View");
3176
+ if (touchOnly) gui.close();
3157
3177
  const MODES = [
3158
3178
  { id: "pan", label: "Pan", title: "Drag to pan the view, wheel to zoom" },
3159
3179
  {
@@ -3176,6 +3196,10 @@ function createViewer(mountTarget) {
3176
3196
  btn.type = "button";
3177
3197
  btn.textContent = mode.label;
3178
3198
  btn.title = mode.title;
3199
+ if (mode.id === "measure" && touchOnly) {
3200
+ btn.disabled = true;
3201
+ btn.title = "Measuring needs a pointer that hovers: use a mouse or a trackpad.";
3202
+ }
3179
3203
  btn.addEventListener("click", () => setMode(mode.id));
3180
3204
  modeWidget.appendChild(btn);
3181
3205
  modeButtons.set(mode.id, btn);
@@ -3184,6 +3208,7 @@ function createViewer(mountTarget) {
3184
3208
  modeRow.appendChild(modeWidget);
3185
3209
  gui.$children.prepend(modeRow);
3186
3210
  function setMode(id) {
3211
+ if (id === "measure" && touchOnly) return;
3187
3212
  if (currentMode === id) return;
3188
3213
  currentMode = id;
3189
3214
  for (const [modeId, btn] of modeButtons) {
@@ -3222,10 +3247,110 @@ function createViewer(mountTarget) {
3222
3247
  }
3223
3248
  const glCanvas = els.glCanvas;
3224
3249
  if (glCanvas) {
3250
+ let zoomAbout = function(camera, newZoom, pixelX, pixelY) {
3251
+ const dx = pixelX - glCanvas.clientWidth / 2;
3252
+ const dy = glCanvas.clientHeight / 2 - pixelY;
3253
+ const worldX = camera.panX + dx / camera.zoom;
3254
+ const worldY = camera.panY + dy / camera.zoom;
3255
+ return { zoom: newZoom, panX: worldX - dx / newZoom, panY: worldY - dy / newZoom };
3256
+ };
3225
3257
  glCanvas.addEventListener("mousedown", () => {
3226
3258
  if (currentMode !== "measure") return;
3227
3259
  setTimeout(() => modulePromise.then(refreshRulerRow), 0);
3228
3260
  });
3261
+ let panId = null;
3262
+ let pinch = null;
3263
+ let lastX = 0, lastY = 0;
3264
+ const MIN_PINCH_PX = 8;
3265
+ const touchById = (list, id) => {
3266
+ for (const touch of list) if (touch.identifier === id) return touch;
3267
+ return null;
3268
+ };
3269
+ const spread = (a, b) => Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
3270
+ const onCanvas = (event) => event.composedPath().includes(glCanvas);
3271
+ const touchIdle = () => currentMode === "measure" || !resolvedModule;
3272
+ hostElement.addEventListener("touchstart", (event) => {
3273
+ if (touchIdle() || !onCanvas(event)) return;
3274
+ const touches = event.touches;
3275
+ if (touches.length >= 2) {
3276
+ if (!pinch) {
3277
+ const [a, b] = touches;
3278
+ pinch = { a: a.identifier, b: b.identifier, dist: spread(a, b) };
3279
+ panId = null;
3280
+ lastX = (a.clientX + b.clientX) / 2;
3281
+ lastY = (a.clientY + b.clientY) / 2;
3282
+ }
3283
+ } else if (touches.length === 1) {
3284
+ panId = touches[0].identifier;
3285
+ lastX = touches[0].clientX;
3286
+ lastY = touches[0].clientY;
3287
+ }
3288
+ event.preventDefault();
3289
+ }, { passive: false });
3290
+ hostElement.addEventListener("touchmove", (event) => {
3291
+ if (touchIdle() || !onCanvas(event)) return;
3292
+ let camera = resolvedModule.getCamera();
3293
+ if (pinch) {
3294
+ const a = touchById(event.touches, pinch.a);
3295
+ const b = touchById(event.touches, pinch.b);
3296
+ if (a && b) {
3297
+ const midX = (a.clientX + b.clientX) / 2;
3298
+ const midY = (a.clientY + b.clientY) / 2;
3299
+ camera = {
3300
+ zoom: camera.zoom,
3301
+ panX: camera.panX - (midX - lastX) / camera.zoom,
3302
+ panY: camera.panY + (midY - lastY) / camera.zoom
3303
+ };
3304
+ lastX = midX;
3305
+ lastY = midY;
3306
+ const dist = spread(a, b);
3307
+ if (dist >= MIN_PINCH_PX && pinch.dist >= MIN_PINCH_PX) {
3308
+ const rect = glCanvas.getBoundingClientRect();
3309
+ camera = zoomAbout(
3310
+ camera,
3311
+ camera.zoom * dist / pinch.dist,
3312
+ midX - rect.left,
3313
+ midY - rect.top
3314
+ );
3315
+ pinch.dist = dist;
3316
+ }
3317
+ resolvedModule.setCamera(camera.zoom, camera.panX, camera.panY);
3318
+ event.preventDefault();
3319
+ }
3320
+ return;
3321
+ }
3322
+ const finger = panId === null ? null : touchById(event.touches, panId);
3323
+ if (!finger) return;
3324
+ resolvedModule.setCamera(
3325
+ camera.zoom,
3326
+ camera.panX - (finger.clientX - lastX) / camera.zoom,
3327
+ camera.panY + (finger.clientY - lastY) / camera.zoom
3328
+ );
3329
+ lastX = finger.clientX;
3330
+ lastY = finger.clientY;
3331
+ event.preventDefault();
3332
+ }, { passive: false });
3333
+ const endTouch = (event) => {
3334
+ if (pinch) {
3335
+ const a = touchById(event.touches, pinch.a);
3336
+ const b = touchById(event.touches, pinch.b);
3337
+ if (a && b) return;
3338
+ const survivor = a || b;
3339
+ pinch = null;
3340
+ panId = survivor ? survivor.identifier : null;
3341
+ if (survivor) {
3342
+ lastX = survivor.clientX;
3343
+ lastY = survivor.clientY;
3344
+ }
3345
+ return;
3346
+ }
3347
+ if (panId !== null && !touchById(event.touches, panId)) panId = null;
3348
+ };
3349
+ hostElement.addEventListener("touchend", endTouch);
3350
+ hostElement.addEventListener("touchcancel", endTouch);
3351
+ for (const type of ["gesturestart", "gesturechange", "gestureend"]) {
3352
+ glCanvas.addEventListener(type, (event) => event.preventDefault());
3353
+ }
3229
3354
  }
3230
3355
  function setFileChip(controller, name, { idleLabel, idleTitle, unloadTitle, onUnload }) {
3231
3356
  const existingX = controller.domElement.querySelector(".lyp-unload");
@@ -871,7 +871,12 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
871
871
  against it rather than against the page. */
872
872
  :host { display: block; position: relative; width: 100%; height: 100%; overflow: hidden;
873
873
  background: var(--bg); user-select: none; color: var(--fg); }
874
- canvas { width: 100%; height: 100%; display: block; }
874
+ /* touch-action: none hands every gesture over the canvas to the touch
875
+ handlers in renderer.cpp: without it the browser claims a drag for
876
+ page scrolling and a pinch for its own page zoom before the handlers
877
+ see the second event, and it is also what turns off double-tap zoom
878
+ over the layout. */
879
+ canvas { width: 100%; height: 100%; display: block; touch-action: none; }
875
880
  /* The engine readout (polygon/label counts + the per-frame #renderStats
876
881
  span, both written from renderer.cpp). It lives inside #debugPanel
877
882
  rather than floating over the canvas on its own: it's debug-only
@@ -956,6 +961,15 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
956
961
  overlay (30) and debug panel (40) rather than lil-gui's 1001. */
957
962
  #guiHost { position: absolute; top: 0; right: 15px; max-height: 100%; z-index: 20; display: flex; }
958
963
  #guiHost > .lil-gui.lil-root { max-height: 100%; }
964
+ /* Both panels are sized for a window, and a phone is narrower than
965
+ either of them plus anything worth seeing. Capped rather than
966
+ reflowed: they still open over the layout, but never across all of
967
+ it. (The control panel also starts collapsed here -- see touchOnly
968
+ in viewer.js.) */
969
+ @media (pointer: coarse) and (hover: none) {
970
+ #guiHost > .lil-gui.lil-root { max-width: 85vw; }
971
+ #hierarchyPanel { width: min(260px, 85vw); }
972
+ }
959
973
  /* Checkbox rows (layer list + top-level toggles): lil-gui gives the
960
974
  label a 45% min-width and no overflow handling, so a long name like
961
975
  "250/74 \u2013 SIN_WG_S" would push into the checkbox column. Widen the
@@ -997,6 +1011,10 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
997
1011
  buttons a readable width. */
998
1012
  .lil-gui .lil-controller.mode-row .lil-name { min-width: 30%; }
999
1013
  .lil-gui .lil-controller.mode-row .mode-widget { gap: 4px; }
1014
+ /* Greyed out where the mode is not offered (see touchOnly in
1015
+ viewer.js). lil-gui dims a whole disabled controller, but has no rule
1016
+ for one disabled button inside a live row. */
1017
+ .lil-gui .lil-controller.mode-row .mode-widget button:disabled { opacity: 0.5; cursor: default; }
1000
1018
  .lil-gui .lil-controller.mode-row .mode-widget button { flex: 1; }
1001
1019
  /* :hover/:active spelled out too -- lil-gui's own button:hover rule
1002
1020
  outranks a bare .mode-active, which would grey out the selected
@@ -3102,6 +3120,7 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
3102
3120
  const hostCall = (name, ...args) => hostCan(name) ? host[name](...args) : void 0;
3103
3121
  trace("[GDS] host ready; Worker:", typeof Worker, "Blob:", typeof Blob, "bundled worker:", !!workerBundle);
3104
3122
  const gui = new GUI({ width: 260, container: viewerRoot.getElementById("guiHost"), injectStyles: false });
3123
+ const touchOnly = typeof window.matchMedia === "function" && window.matchMedia("(pointer: coarse) and (hover: none)").matches;
3105
3124
  const actions = {
3106
3125
  // Clicking the row always opens the file dialog (load, or replace the
3107
3126
  // current file); the injected ✕ (see setFileChip) handles unloading.
@@ -3130,6 +3149,7 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
3130
3149
  const lypController = displayFolder.add(actions, "loadLypFile").name("Load .lyp File");
3131
3150
  const markerController = displayFolder.add(actions, "loadMarkerFile").name("Load Marker File (.lyrdb / DRC)");
3132
3151
  displayFolder.add(actions, "resetView").name("Reset View");
3152
+ if (touchOnly) gui.close();
3133
3153
  const MODES = [
3134
3154
  { id: "pan", label: "Pan", title: "Drag to pan the view, wheel to zoom" },
3135
3155
  {
@@ -3152,6 +3172,10 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
3152
3172
  btn.type = "button";
3153
3173
  btn.textContent = mode.label;
3154
3174
  btn.title = mode.title;
3175
+ if (mode.id === "measure" && touchOnly) {
3176
+ btn.disabled = true;
3177
+ btn.title = "Measuring needs a pointer that hovers: use a mouse or a trackpad.";
3178
+ }
3155
3179
  btn.addEventListener("click", () => setMode(mode.id));
3156
3180
  modeWidget.appendChild(btn);
3157
3181
  modeButtons.set(mode.id, btn);
@@ -3160,6 +3184,7 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
3160
3184
  modeRow.appendChild(modeWidget);
3161
3185
  gui.$children.prepend(modeRow);
3162
3186
  function setMode(id) {
3187
+ if (id === "measure" && touchOnly) return;
3163
3188
  if (currentMode === id) return;
3164
3189
  currentMode = id;
3165
3190
  for (const [modeId, btn] of modeButtons) {
@@ -3198,10 +3223,110 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
3198
3223
  }
3199
3224
  const glCanvas = els.glCanvas;
3200
3225
  if (glCanvas) {
3226
+ let zoomAbout = function(camera, newZoom, pixelX, pixelY) {
3227
+ const dx = pixelX - glCanvas.clientWidth / 2;
3228
+ const dy = glCanvas.clientHeight / 2 - pixelY;
3229
+ const worldX = camera.panX + dx / camera.zoom;
3230
+ const worldY = camera.panY + dy / camera.zoom;
3231
+ return { zoom: newZoom, panX: worldX - dx / newZoom, panY: worldY - dy / newZoom };
3232
+ };
3201
3233
  glCanvas.addEventListener("mousedown", () => {
3202
3234
  if (currentMode !== "measure") return;
3203
3235
  setTimeout(() => modulePromise.then(refreshRulerRow), 0);
3204
3236
  });
3237
+ let panId = null;
3238
+ let pinch = null;
3239
+ let lastX = 0, lastY = 0;
3240
+ const MIN_PINCH_PX = 8;
3241
+ const touchById = (list, id) => {
3242
+ for (const touch of list) if (touch.identifier === id) return touch;
3243
+ return null;
3244
+ };
3245
+ const spread = (a, b) => Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
3246
+ const onCanvas = (event) => event.composedPath().includes(glCanvas);
3247
+ const touchIdle = () => currentMode === "measure" || !resolvedModule;
3248
+ hostElement.addEventListener("touchstart", (event) => {
3249
+ if (touchIdle() || !onCanvas(event)) return;
3250
+ const touches = event.touches;
3251
+ if (touches.length >= 2) {
3252
+ if (!pinch) {
3253
+ const [a, b] = touches;
3254
+ pinch = { a: a.identifier, b: b.identifier, dist: spread(a, b) };
3255
+ panId = null;
3256
+ lastX = (a.clientX + b.clientX) / 2;
3257
+ lastY = (a.clientY + b.clientY) / 2;
3258
+ }
3259
+ } else if (touches.length === 1) {
3260
+ panId = touches[0].identifier;
3261
+ lastX = touches[0].clientX;
3262
+ lastY = touches[0].clientY;
3263
+ }
3264
+ event.preventDefault();
3265
+ }, { passive: false });
3266
+ hostElement.addEventListener("touchmove", (event) => {
3267
+ if (touchIdle() || !onCanvas(event)) return;
3268
+ let camera = resolvedModule.getCamera();
3269
+ if (pinch) {
3270
+ const a = touchById(event.touches, pinch.a);
3271
+ const b = touchById(event.touches, pinch.b);
3272
+ if (a && b) {
3273
+ const midX = (a.clientX + b.clientX) / 2;
3274
+ const midY = (a.clientY + b.clientY) / 2;
3275
+ camera = {
3276
+ zoom: camera.zoom,
3277
+ panX: camera.panX - (midX - lastX) / camera.zoom,
3278
+ panY: camera.panY + (midY - lastY) / camera.zoom
3279
+ };
3280
+ lastX = midX;
3281
+ lastY = midY;
3282
+ const dist = spread(a, b);
3283
+ if (dist >= MIN_PINCH_PX && pinch.dist >= MIN_PINCH_PX) {
3284
+ const rect = glCanvas.getBoundingClientRect();
3285
+ camera = zoomAbout(
3286
+ camera,
3287
+ camera.zoom * dist / pinch.dist,
3288
+ midX - rect.left,
3289
+ midY - rect.top
3290
+ );
3291
+ pinch.dist = dist;
3292
+ }
3293
+ resolvedModule.setCamera(camera.zoom, camera.panX, camera.panY);
3294
+ event.preventDefault();
3295
+ }
3296
+ return;
3297
+ }
3298
+ const finger = panId === null ? null : touchById(event.touches, panId);
3299
+ if (!finger) return;
3300
+ resolvedModule.setCamera(
3301
+ camera.zoom,
3302
+ camera.panX - (finger.clientX - lastX) / camera.zoom,
3303
+ camera.panY + (finger.clientY - lastY) / camera.zoom
3304
+ );
3305
+ lastX = finger.clientX;
3306
+ lastY = finger.clientY;
3307
+ event.preventDefault();
3308
+ }, { passive: false });
3309
+ const endTouch = (event) => {
3310
+ if (pinch) {
3311
+ const a = touchById(event.touches, pinch.a);
3312
+ const b = touchById(event.touches, pinch.b);
3313
+ if (a && b) return;
3314
+ const survivor = a || b;
3315
+ pinch = null;
3316
+ panId = survivor ? survivor.identifier : null;
3317
+ if (survivor) {
3318
+ lastX = survivor.clientX;
3319
+ lastY = survivor.clientY;
3320
+ }
3321
+ return;
3322
+ }
3323
+ if (panId !== null && !touchById(event.touches, panId)) panId = null;
3324
+ };
3325
+ hostElement.addEventListener("touchend", endTouch);
3326
+ hostElement.addEventListener("touchcancel", endTouch);
3327
+ for (const type of ["gesturestart", "gesturechange", "gestureend"]) {
3328
+ glCanvas.addEventListener(type, (event) => event.preventDefault());
3329
+ }
3205
3330
  }
3206
3331
  function setFileChip(controller, name, { idleLabel, idleTitle, unloadTitle, onUnload }) {
3207
3332
  const existingX = controller.domElement.querySelector(".lyp-unload");
@@ -871,7 +871,12 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
871
871
  against it rather than against the page. */
872
872
  :host { display: block; position: relative; width: 100%; height: 100%; overflow: hidden;
873
873
  background: var(--bg); user-select: none; color: var(--fg); }
874
- canvas { width: 100%; height: 100%; display: block; }
874
+ /* touch-action: none hands every gesture over the canvas to the touch
875
+ handlers in renderer.cpp: without it the browser claims a drag for
876
+ page scrolling and a pinch for its own page zoom before the handlers
877
+ see the second event, and it is also what turns off double-tap zoom
878
+ over the layout. */
879
+ canvas { width: 100%; height: 100%; display: block; touch-action: none; }
875
880
  /* The engine readout (polygon/label counts + the per-frame #renderStats
876
881
  span, both written from renderer.cpp). It lives inside #debugPanel
877
882
  rather than floating over the canvas on its own: it's debug-only
@@ -956,6 +961,15 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
956
961
  overlay (30) and debug panel (40) rather than lil-gui's 1001. */
957
962
  #guiHost { position: absolute; top: 0; right: 15px; max-height: 100%; z-index: 20; display: flex; }
958
963
  #guiHost > .lil-gui.lil-root { max-height: 100%; }
964
+ /* Both panels are sized for a window, and a phone is narrower than
965
+ either of them plus anything worth seeing. Capped rather than
966
+ reflowed: they still open over the layout, but never across all of
967
+ it. (The control panel also starts collapsed here -- see touchOnly
968
+ in viewer.js.) */
969
+ @media (pointer: coarse) and (hover: none) {
970
+ #guiHost > .lil-gui.lil-root { max-width: 85vw; }
971
+ #hierarchyPanel { width: min(260px, 85vw); }
972
+ }
959
973
  /* Checkbox rows (layer list + top-level toggles): lil-gui gives the
960
974
  label a 45% min-width and no overflow handling, so a long name like
961
975
  "250/74 \u2013 SIN_WG_S" would push into the checkbox column. Widen the
@@ -997,6 +1011,10 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
997
1011
  buttons a readable width. */
998
1012
  .lil-gui .lil-controller.mode-row .lil-name { min-width: 30%; }
999
1013
  .lil-gui .lil-controller.mode-row .mode-widget { gap: 4px; }
1014
+ /* Greyed out where the mode is not offered (see touchOnly in
1015
+ viewer.js). lil-gui dims a whole disabled controller, but has no rule
1016
+ for one disabled button inside a live row. */
1017
+ .lil-gui .lil-controller.mode-row .mode-widget button:disabled { opacity: 0.5; cursor: default; }
1000
1018
  .lil-gui .lil-controller.mode-row .mode-widget button { flex: 1; }
1001
1019
  /* :hover/:active spelled out too -- lil-gui's own button:hover rule
1002
1020
  outranks a bare .mode-active, which would grey out the selected
@@ -3102,6 +3120,7 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
3102
3120
  const hostCall = (name, ...args) => hostCan(name) ? host[name](...args) : void 0;
3103
3121
  trace("[GDS] host ready; Worker:", typeof Worker, "Blob:", typeof Blob, "bundled worker:", !!workerBundle);
3104
3122
  const gui = new GUI({ width: 260, container: viewerRoot.getElementById("guiHost"), injectStyles: false });
3123
+ const touchOnly = typeof window.matchMedia === "function" && window.matchMedia("(pointer: coarse) and (hover: none)").matches;
3105
3124
  const actions = {
3106
3125
  // Clicking the row always opens the file dialog (load, or replace the
3107
3126
  // current file); the injected ✕ (see setFileChip) handles unloading.
@@ -3130,6 +3149,7 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
3130
3149
  const lypController = displayFolder.add(actions, "loadLypFile").name("Load .lyp File");
3131
3150
  const markerController = displayFolder.add(actions, "loadMarkerFile").name("Load Marker File (.lyrdb / DRC)");
3132
3151
  displayFolder.add(actions, "resetView").name("Reset View");
3152
+ if (touchOnly) gui.close();
3133
3153
  const MODES = [
3134
3154
  { id: "pan", label: "Pan", title: "Drag to pan the view, wheel to zoom" },
3135
3155
  {
@@ -3152,6 +3172,10 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
3152
3172
  btn.type = "button";
3153
3173
  btn.textContent = mode.label;
3154
3174
  btn.title = mode.title;
3175
+ if (mode.id === "measure" && touchOnly) {
3176
+ btn.disabled = true;
3177
+ btn.title = "Measuring needs a pointer that hovers: use a mouse or a trackpad.";
3178
+ }
3155
3179
  btn.addEventListener("click", () => setMode(mode.id));
3156
3180
  modeWidget.appendChild(btn);
3157
3181
  modeButtons.set(mode.id, btn);
@@ -3160,6 +3184,7 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
3160
3184
  modeRow.appendChild(modeWidget);
3161
3185
  gui.$children.prepend(modeRow);
3162
3186
  function setMode(id) {
3187
+ if (id === "measure" && touchOnly) return;
3163
3188
  if (currentMode === id) return;
3164
3189
  currentMode = id;
3165
3190
  for (const [modeId, btn] of modeButtons) {
@@ -3198,10 +3223,110 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
3198
3223
  }
3199
3224
  const glCanvas = els.glCanvas;
3200
3225
  if (glCanvas) {
3226
+ let zoomAbout = function(camera, newZoom, pixelX, pixelY) {
3227
+ const dx = pixelX - glCanvas.clientWidth / 2;
3228
+ const dy = glCanvas.clientHeight / 2 - pixelY;
3229
+ const worldX = camera.panX + dx / camera.zoom;
3230
+ const worldY = camera.panY + dy / camera.zoom;
3231
+ return { zoom: newZoom, panX: worldX - dx / newZoom, panY: worldY - dy / newZoom };
3232
+ };
3201
3233
  glCanvas.addEventListener("mousedown", () => {
3202
3234
  if (currentMode !== "measure") return;
3203
3235
  setTimeout(() => modulePromise.then(refreshRulerRow), 0);
3204
3236
  });
3237
+ let panId = null;
3238
+ let pinch = null;
3239
+ let lastX = 0, lastY = 0;
3240
+ const MIN_PINCH_PX = 8;
3241
+ const touchById = (list, id) => {
3242
+ for (const touch of list) if (touch.identifier === id) return touch;
3243
+ return null;
3244
+ };
3245
+ const spread = (a, b) => Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
3246
+ const onCanvas = (event) => event.composedPath().includes(glCanvas);
3247
+ const touchIdle = () => currentMode === "measure" || !resolvedModule;
3248
+ hostElement.addEventListener("touchstart", (event) => {
3249
+ if (touchIdle() || !onCanvas(event)) return;
3250
+ const touches = event.touches;
3251
+ if (touches.length >= 2) {
3252
+ if (!pinch) {
3253
+ const [a, b] = touches;
3254
+ pinch = { a: a.identifier, b: b.identifier, dist: spread(a, b) };
3255
+ panId = null;
3256
+ lastX = (a.clientX + b.clientX) / 2;
3257
+ lastY = (a.clientY + b.clientY) / 2;
3258
+ }
3259
+ } else if (touches.length === 1) {
3260
+ panId = touches[0].identifier;
3261
+ lastX = touches[0].clientX;
3262
+ lastY = touches[0].clientY;
3263
+ }
3264
+ event.preventDefault();
3265
+ }, { passive: false });
3266
+ hostElement.addEventListener("touchmove", (event) => {
3267
+ if (touchIdle() || !onCanvas(event)) return;
3268
+ let camera = resolvedModule.getCamera();
3269
+ if (pinch) {
3270
+ const a = touchById(event.touches, pinch.a);
3271
+ const b = touchById(event.touches, pinch.b);
3272
+ if (a && b) {
3273
+ const midX = (a.clientX + b.clientX) / 2;
3274
+ const midY = (a.clientY + b.clientY) / 2;
3275
+ camera = {
3276
+ zoom: camera.zoom,
3277
+ panX: camera.panX - (midX - lastX) / camera.zoom,
3278
+ panY: camera.panY + (midY - lastY) / camera.zoom
3279
+ };
3280
+ lastX = midX;
3281
+ lastY = midY;
3282
+ const dist = spread(a, b);
3283
+ if (dist >= MIN_PINCH_PX && pinch.dist >= MIN_PINCH_PX) {
3284
+ const rect = glCanvas.getBoundingClientRect();
3285
+ camera = zoomAbout(
3286
+ camera,
3287
+ camera.zoom * dist / pinch.dist,
3288
+ midX - rect.left,
3289
+ midY - rect.top
3290
+ );
3291
+ pinch.dist = dist;
3292
+ }
3293
+ resolvedModule.setCamera(camera.zoom, camera.panX, camera.panY);
3294
+ event.preventDefault();
3295
+ }
3296
+ return;
3297
+ }
3298
+ const finger = panId === null ? null : touchById(event.touches, panId);
3299
+ if (!finger) return;
3300
+ resolvedModule.setCamera(
3301
+ camera.zoom,
3302
+ camera.panX - (finger.clientX - lastX) / camera.zoom,
3303
+ camera.panY + (finger.clientY - lastY) / camera.zoom
3304
+ );
3305
+ lastX = finger.clientX;
3306
+ lastY = finger.clientY;
3307
+ event.preventDefault();
3308
+ }, { passive: false });
3309
+ const endTouch = (event) => {
3310
+ if (pinch) {
3311
+ const a = touchById(event.touches, pinch.a);
3312
+ const b = touchById(event.touches, pinch.b);
3313
+ if (a && b) return;
3314
+ const survivor = a || b;
3315
+ pinch = null;
3316
+ panId = survivor ? survivor.identifier : null;
3317
+ if (survivor) {
3318
+ lastX = survivor.clientX;
3319
+ lastY = survivor.clientY;
3320
+ }
3321
+ return;
3322
+ }
3323
+ if (panId !== null && !touchById(event.touches, panId)) panId = null;
3324
+ };
3325
+ hostElement.addEventListener("touchend", endTouch);
3326
+ hostElement.addEventListener("touchcancel", endTouch);
3327
+ for (const type of ["gesturestart", "gesturechange", "gestureend"]) {
3328
+ glCanvas.addEventListener(type, (event) => event.preventDefault());
3329
+ }
3205
3330
  }
3206
3331
  function setFileChip(controller, name, { idleLabel, idleTitle, unloadTitle, onUnload }) {
3207
3332
  const existingX = controller.domElement.querySelector(".lyp-unload");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gds-lens",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "GDSII & OASIS layout parsing and WebGL2 rendering for the browser",
5
5
  "license": "MIT",
6
6
  "author": "Ethan Lowenthal",
package/src/viewer.css CHANGED
@@ -110,7 +110,12 @@
110
110
  against it rather than against the page. */
111
111
  :host { display: block; position: relative; width: 100%; height: 100%; overflow: hidden;
112
112
  background: var(--bg); user-select: none; color: var(--fg); }
113
- canvas { width: 100%; height: 100%; display: block; }
113
+ /* touch-action: none hands every gesture over the canvas to the touch
114
+ handlers in renderer.cpp: without it the browser claims a drag for
115
+ page scrolling and a pinch for its own page zoom before the handlers
116
+ see the second event, and it is also what turns off double-tap zoom
117
+ over the layout. */
118
+ canvas { width: 100%; height: 100%; display: block; touch-action: none; }
114
119
  /* The engine readout (polygon/label counts + the per-frame #renderStats
115
120
  span, both written from renderer.cpp). It lives inside #debugPanel
116
121
  rather than floating over the canvas on its own: it's debug-only
@@ -195,6 +200,15 @@
195
200
  overlay (30) and debug panel (40) rather than lil-gui's 1001. */
196
201
  #guiHost { position: absolute; top: 0; right: 15px; max-height: 100%; z-index: 20; display: flex; }
197
202
  #guiHost > .lil-gui.lil-root { max-height: 100%; }
203
+ /* Both panels are sized for a window, and a phone is narrower than
204
+ either of them plus anything worth seeing. Capped rather than
205
+ reflowed: they still open over the layout, but never across all of
206
+ it. (The control panel also starts collapsed here -- see touchOnly
207
+ in viewer.js.) */
208
+ @media (pointer: coarse) and (hover: none) {
209
+ #guiHost > .lil-gui.lil-root { max-width: 85vw; }
210
+ #hierarchyPanel { width: min(260px, 85vw); }
211
+ }
198
212
  /* Checkbox rows (layer list + top-level toggles): lil-gui gives the
199
213
  label a 45% min-width and no overflow handling, so a long name like
200
214
  "250/74 – SIN_WG_S" would push into the checkbox column. Widen the
@@ -236,6 +250,10 @@
236
250
  buttons a readable width. */
237
251
  .lil-gui .lil-controller.mode-row .lil-name { min-width: 30%; }
238
252
  .lil-gui .lil-controller.mode-row .mode-widget { gap: 4px; }
253
+ /* Greyed out where the mode is not offered (see touchOnly in
254
+ viewer.js). lil-gui dims a whole disabled controller, but has no rule
255
+ for one disabled button inside a live row. */
256
+ .lil-gui .lil-controller.mode-row .mode-widget button:disabled { opacity: 0.5; cursor: default; }
239
257
  .lil-gui .lil-controller.mode-row .mode-widget button { flex: 1; }
240
258
  /* :hover/:active spelled out too -- lil-gui's own button:hover rule
241
259
  outranks a bare .mode-active, which would grey out the selected
package/src/viewer.js CHANGED
@@ -311,6 +311,14 @@ export function createViewer(mountTarget) {
311
311
  // unstyled. Its CSS is carried in viewer.css instead (see the lil-gui block
312
312
  // there), alongside everything else the shadow root owns.
313
313
  const gui = new GUI({ width: 260, container: viewerRoot.getElementById("guiHost"), injectStyles: false });
314
+
315
+ // A touch-only device: coarse pointer *and* no hover, so a touchscreen
316
+ // laptop (coarse pointer, but a mouse too) is not one. Two things follow
317
+ // from it -- the panel starts collapsed, and measure mode is withheld --
318
+ // and both are about the same thing: what a fingertip can do, and how much
319
+ // room there is to do it in.
320
+ const touchOnly = typeof window.matchMedia === "function"
321
+ && window.matchMedia("(pointer: coarse) and (hover: none)").matches;
314
322
  const actions = {
315
323
  // Clicking the row always opens the file dialog (load, or replace the
316
324
  // current file); the injected ✕ (see setFileChip) handles unloading.
@@ -363,6 +371,13 @@ export function createViewer(mountTarget) {
363
371
  const markerController = displayFolder.add(actions, "loadMarkerFile").name("Load Marker File (.lyrdb / DRC)");
364
372
  displayFolder.add(actions, "resetView").name("Reset View");
365
373
 
374
+ // On a phone the panel is most of the screen, so it starts collapsed to its
375
+ // title bar rather than opening over the layout: the point of arriving is
376
+ // to see the design, and one tap on the bar brings the controls back. Left
377
+ // open everywhere a pointer hovers, where it costs a corner of a much
378
+ // larger window.
379
+ if (touchOnly) gui.close();
380
+
366
381
  // ---- Interaction mode (Pan / Measure) ----
367
382
  // The canvas can only do one thing with a click, so the two are exclusive
368
383
  // modes rather than an "on top of panning" toggle: in Pan mode a drag moves
@@ -399,6 +414,16 @@ export function createViewer(mountTarget) {
399
414
  btn.type = "button";
400
415
  btn.textContent = mode.label;
401
416
  btn.title = mode.title;
417
+ // Measuring is left to the compatibility mouse events a tap produces
418
+ // (the canvas's touch handlers in renderer.cpp bow out while the mode
419
+ // is on), and on a touch-only device that is a poor tool: with no
420
+ // hover the snap indicator only appears after the tap that already
421
+ // used it, and the point it snapped to is under a fingertip. Offered
422
+ // greyed out rather than half-working.
423
+ if (mode.id === "measure" && touchOnly) {
424
+ btn.disabled = true;
425
+ btn.title = "Measuring needs a pointer that hovers: use a mouse or a trackpad.";
426
+ }
402
427
  btn.addEventListener("click", () => setMode(mode.id));
403
428
  modeWidget.appendChild(btn);
404
429
  modeButtons.set(mode.id, btn);
@@ -413,6 +438,8 @@ export function createViewer(mountTarget) {
413
438
  gui.$children.prepend(modeRow);
414
439
 
415
440
  function setMode(id) {
441
+ // Also guards the M key, which calls straight in here.
442
+ if (id === "measure" && touchOnly) return;
416
443
  if (currentMode === id) return;
417
444
  currentMode = id;
418
445
  for (const [modeId, btn] of modeButtons) {
@@ -470,6 +497,167 @@ export function createViewer(mountTarget) {
470
497
  if (currentMode !== "measure") return;
471
498
  setTimeout(() => modulePromise.then(refreshRulerRow), 0);
472
499
  });
500
+
501
+ // ---- Touch: one finger pans, two pinch about their midpoint ----
502
+ //
503
+ // Here rather than in renderer.cpp, where the mouse and wheel handlers
504
+ // live, because Emscripten's touch marshalling never reaches the C++
505
+ // callback on iOS Safari: the events arrive at the canvas, and the
506
+ // handler behind emscripten_set_touchstart_callback does not run.
507
+ // Driving the camera through getCamera/setCamera is the same
508
+ // arithmetic (see zoom_about_canvas_point in renderer.cpp) with
509
+ // nothing between the DOM event and the state it changes.
510
+ //
511
+ // Bound to the host element and filtered by composedPath rather than
512
+ // bound to the canvas: a touch sequence keeps the target it started
513
+ // on, so this sees a whole gesture that began on the layout, and never
514
+ // sees one that began on the control panel -- which is what leaves the
515
+ // layer list scrollable.
516
+ //
517
+ // Fingers are tracked by identifier, never by their index in the touch
518
+ // list: that list is rebuilt on every event, so the slot a gesture
519
+ // started in can hold a different finger a frame later.
520
+ let panId = null;
521
+ let pinch = null; // { a, b, dist } while two fingers are down
522
+ let lastX = 0, lastY = 0; // client px: the pan finger, or the midpoint
523
+
524
+ // Below this the fingers are close enough that the distance ratio is
525
+ // mostly noise, and one jittery frame would multiply the zoom by an
526
+ // arbitrary amount.
527
+ const MIN_PINCH_PX = 8;
528
+
529
+ const touchById = (list, id) => {
530
+ for (const touch of list) if (touch.identifier === id) return touch;
531
+ return null;
532
+ };
533
+ const spread = (a, b) => Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
534
+ const onCanvas = (event) => event.composedPath().includes(glCanvas);
535
+
536
+ // Holds the world point under a canvas pixel still across a zoom
537
+ // change: the same inverse the vertex shader applies, solved for the
538
+ // pan that puts that point back under the same pixel.
539
+ function zoomAbout(camera, newZoom, pixelX, pixelY) {
540
+ const dx = pixelX - glCanvas.clientWidth / 2;
541
+ const dy = glCanvas.clientHeight / 2 - pixelY;
542
+ const worldX = camera.panX + dx / camera.zoom;
543
+ const worldY = camera.panY + dy / camera.zoom;
544
+ return { zoom: newZoom, panX: worldX - dx / newZoom, panY: worldY - dy / newZoom };
545
+ }
546
+
547
+ // Measure mode places its ruler by tapping, which arrives as the
548
+ // compatibility mouse events renderer.cpp already handles, so the
549
+ // gesture is left alone while it is on.
550
+ const touchIdle = () => currentMode === "measure" || !resolvedModule;
551
+
552
+ hostElement.addEventListener("touchstart", (event) => {
553
+ if (touchIdle() || !onCanvas(event)) return;
554
+ const touches = event.touches;
555
+ if (touches.length >= 2) {
556
+ // A third finger is ignored rather than reseating the pinch,
557
+ // which would jump the view mid-zoom.
558
+ if (!pinch) {
559
+ const [a, b] = touches;
560
+ pinch = { a: a.identifier, b: b.identifier, dist: spread(a, b) };
561
+ panId = null;
562
+ lastX = (a.clientX + b.clientX) / 2;
563
+ lastY = (a.clientY + b.clientY) / 2;
564
+ }
565
+ } else if (touches.length === 1) {
566
+ panId = touches[0].identifier;
567
+ lastX = touches[0].clientX;
568
+ lastY = touches[0].clientY;
569
+ }
570
+ // Without this the browser takes the gesture for page scrolling or
571
+ // its own pinch zoom, whatever touch-action says.
572
+ event.preventDefault();
573
+ }, { passive: false });
574
+
575
+ hostElement.addEventListener("touchmove", (event) => {
576
+ if (touchIdle() || !onCanvas(event)) return;
577
+ let camera = resolvedModule.getCamera();
578
+
579
+ if (pinch) {
580
+ const a = touchById(event.touches, pinch.a);
581
+ const b = touchById(event.touches, pinch.b);
582
+ if (a && b) {
583
+ // Two things at once, and both are wanted: the midpoint
584
+ // travelling across the glass drags the layout exactly as
585
+ // one finger would, and the fingers' separation changing
586
+ // zooms about that midpoint. Panning first, at the
587
+ // pre-zoom scale, keeps the two independent.
588
+ const midX = (a.clientX + b.clientX) / 2;
589
+ const midY = (a.clientY + b.clientY) / 2;
590
+ camera = {
591
+ zoom: camera.zoom,
592
+ panX: camera.panX - (midX - lastX) / camera.zoom,
593
+ panY: camera.panY + (midY - lastY) / camera.zoom
594
+ };
595
+ lastX = midX;
596
+ lastY = midY;
597
+
598
+ const dist = spread(a, b);
599
+ if (dist >= MIN_PINCH_PX && pinch.dist >= MIN_PINCH_PX) {
600
+ const rect = glCanvas.getBoundingClientRect();
601
+ camera = zoomAbout(camera, camera.zoom * dist / pinch.dist,
602
+ midX - rect.left, midY - rect.top);
603
+ pinch.dist = dist;
604
+ }
605
+ // setCamera clamps both zoom and pan and asks for a redraw,
606
+ // so the next getCamera reads back what was allowed rather
607
+ // than what was asked for, and nothing drifts.
608
+ resolvedModule.setCamera(camera.zoom, camera.panX, camera.panY);
609
+ event.preventDefault();
610
+ }
611
+ return;
612
+ }
613
+
614
+ const finger = panId === null ? null : touchById(event.touches, panId);
615
+ if (!finger) return;
616
+ resolvedModule.setCamera(camera.zoom,
617
+ camera.panX - (finger.clientX - lastX) / camera.zoom,
618
+ camera.panY + (finger.clientY - lastY) / camera.zoom);
619
+ lastX = finger.clientX;
620
+ lastY = finger.clientY;
621
+ event.preventDefault();
622
+ }, { passive: false });
623
+
624
+ // touchend and touchcancel want the same thing: forget the fingers that
625
+ // are gone, and keep whatever gesture the ones still down can support.
626
+ // event.touches is already only the fingers still on the glass.
627
+ const endTouch = (event) => {
628
+ if (pinch) {
629
+ const a = touchById(event.touches, pinch.a);
630
+ const b = touchById(event.touches, pinch.b);
631
+ if (a && b) return;
632
+ // Lifting one finger of a pinch hands the gesture to the other
633
+ // rather than ending it. Otherwise the survivor is picked up as
634
+ // a fresh pan only at its next move, and the view jumps by
635
+ // however far it travelled since the lift.
636
+ const survivor = a || b;
637
+ pinch = null;
638
+ panId = survivor ? survivor.identifier : null;
639
+ if (survivor) {
640
+ lastX = survivor.clientX;
641
+ lastY = survivor.clientY;
642
+ }
643
+ return;
644
+ }
645
+ if (panId !== null && !touchById(event.touches, panId)) panId = null;
646
+ };
647
+ hostElement.addEventListener("touchend", endTouch);
648
+ hostElement.addEventListener("touchcancel", endTouch);
649
+
650
+ // iOS Safari answers a two-finger pinch with its own page zoom,
651
+ // delivered as a proprietary GestureEvent rather than through the touch
652
+ // events, and it can do that over an element that has already claimed
653
+ // the gesture with touch-action: none. Refused here so the pinch
654
+ // reaches the touch handlers in renderer.cpp instead -- and refused on
655
+ // the canvas alone, never on the page, so a pinch anywhere else in an
656
+ // embedding page still zooms it. A no-op in every other browser:
657
+ // nothing else fires these.
658
+ for (const type of ["gesturestart", "gesturechange", "gestureend"]) {
659
+ glCanvas.addEventListener(type, (event) => event.preventDefault());
660
+ }
473
661
  }
474
662
 
475
663
  // Reflects a loaded-file state in a lil-gui button row (used by both the