Skip to main content

Mobile is a shared library capability

Unreleased

The shared pinch and visible-frame APIs are repository implementations for the next release. Current 0.1.4 npm packages do not include them; publication remains maintainer-gated.

A graph has the same identity, selection and relationships on a phone and a large screen. Mobile support belongs in Dagr's input, camera and presentation contracts, with adapters for the surface being drawn. It does not require a phone-only renderer or another copy of the graph.

The first shared primitive is createPinchGesture, exported by @prnt/dagr-render/core, the full renderer entry and @prnt/dagr/render/core. Both DagrCanvas and Explorer use it for their two-contact geometry and lifecycle. It is DOM-free and three.js-free, so it also works in an imperative host or a server-side test. This is a core foundation, not a claim that every mobile reading flow or real device has been validated.

Where each responsibility lives​

LayerOwnsDoes not own
Graph and layoutStable IDs, relationships, coordinates and layout deltasA phone-specific copy of the dataset
Renderer coreCamera arithmetic, shared touch-contact geometry and the visible frameReact state, DOM event capture or a device-name detector
Surface adapterCSS coordinates, event capture/cancellation, scroll ownership and camera updatesDuplicated pinch arithmetic or graph semantics
Explorer or host UIReadable controls/details, focus, search and reading modesA separate mobile selection/search store
Rendering backendGPU or SVG drawing and its measured resource budgetChoosing a graph's meaning or changing input semantics

The native Explorer base remains experimental. A backend may change for measured rendering capacity or capabilities; a narrow container alone is not a backend-selection rule. Shared contact handling reduces behavioral drift, but does not replace visual/backend parity checks.

Shared pinch contract​

Feed accepted touch contacts into one gesture object per surface. Positions and deltas are in viewport CSS pixels, before camera conversion. Do not multiply them by devicePixelRatio. On each change, pan first, then zoom at the new midpoint:

import { Camera2D, createPinchGesture } from '@prnt/dagr-render/core';

const camera = new Camera2D({
viewport: { width: 320, height: 480, devicePixelRatio: 1 },
});
const gesture = createPinchGesture();
gesture.down(1, { x: 100, y: 100 });
gesture.down(2, { x: 200, y: 100 });
const change = gesture.move(2, { x: 300, y: 100 });
// change: { pan: { x: 50, y: 0 }, anchor: { x: 200, y: 100 }, factor: 2 }
if (change !== null) {
camera.panByScreen(change.pan.x, change.pan.y);
camera.zoomAtScreen(change.anchor, change.factor);
}
gesture.up(2); // Also on pointercancel, and on lostpointercapture targeting the surface itself.
gesture.reset(); // On surface teardown or an explicitly abandoned sequence.

Here camera is a host-owned Camera2D; it is not created by the gesture. Explorer applies the same deltas through its y-down camera adapter.

The first two contacts stay the active pair. A third contact and duplicate down are ignored; an ignored contact is not automatically promoted when a slot opens. A new down can fill that slot. Unknown moves return null. Releasing a contact ends two-finger motion; the remaining finger alone cannot produce another pinch delta. Coincident fingers pan without zoom, then establish a fresh distance baseline as they separate. Non-finite input is ignored. Input points and returned pointer-ID snapshots do not expose mutable internal state.

The object does not attach events, capture pointers, call preventDefault, change focus, suppress a synthesized click, or distinguish mouse from touch. Adapters own these decisions. Forward cancellation as well as release, suppress post-pinch clicks and avoid resuming an old single-finger drag. Dropped events or a disappearing surface require an explicit reset. Both built-in adapters reset stale contacts when a fresh primary touch sequence begins.

Visible frame contract​

A surface's visible frame is the part of it left to frame content in: what a fit, a focus or a reveal should aim at. Three different things take area away, and each has its own function in @prnt/dagr-render/core. All work in the same CSS-pixel coordinates as getBoundingClientRect:

  • coverInset(surface, cover) is an overlay drawn over the surface, such as a details drawer. It takes one side, and only when it touches that side and spans at least half of it. An overlay in the middle takes nothing.
  • clipInset(surface, visible) is the part of the surface outside a box: a scrolling ancestor's client box (inside its border and scrollbar), or the visual viewport's, which can shrink while the layout viewport does not, for example when a software keyboard opens or the page is zoomed. It clips every side that falls outside.
  • visualViewportInset(surface, layout, visualViewport) is what the visual viewport hides of the surface beyond what the layout viewport (layout, the root element's client size) already hides: clipInset against the visual viewport, less clipInset against the layout viewport. A surface the page has scrolled partly away keeps that part of its frame. While the page is pinch-zoomed (a visual viewport scale 0.01 or more from 1) it takes nothing off, so magnifying the page does not shrink the graph.
  • visibleFrame(size, ...insets) combines them, taking the largest inset on each side, and returns { x, y, width, height } from the surface's top left.

Given a surface element and a drawer element over it:

import { coverInset, visibleFrame, visualViewportInset } from '@prnt/dagr-render/core';

const box = surface.getBoundingClientRect();
const root = document.documentElement;
const vv = window.visualViewport;
const frame = visibleFrame(
{ width: box.width, height: box.height },
coverInset(box, drawer.getBoundingClientRect()),
vv ? visualViewportInset(box, { width: root.clientWidth, height: root.clientHeight }, vv) : {},
);

An axis the insets would squeeze below max(160 or 120 CSS pixels, a quarter of the axis) keeps its whole extent, so a full-width drawer or a nearly hidden surface does not shrink the graph to nothing. The functions do not observe anything: when to measure, and whether a resize should move the camera, are adapter decisions. A smaller visual viewport is not proof that a keyboard opened, and nothing here should be used to disable browser zoom.

Explorer frames around its registered obstructions with coverInset and visibleFrame, and observes the visual viewport's resize and scroll events, taking off its visualViewportInset. A visual viewport that shrinks does not refit the camera: it keeps its scale, unless the smaller frame lowers the most the camera may zoom, and reveals the selected node if the change covered it. A camera fitted while the visual viewport was short fills the frame again when it grows back, because the least the camera may zoom is the fit.

DagrCanvas takes a measured inset as its inset prop and frames its fit, fit(), focusNode and camera limits in visibleFrame(size, inset) through Camera2D.setInset. It observes the visual viewport the same way, framing in the larger of its inset and its visualViewportInset on each side. Either changing keeps a camera at rest and only re-clamps it to the new limits; a fit() or focusNode flight under way works its target out again in the new frame and lands when it would have. Explorer keeps its own policy above: a camera at or flying to the fit refits, except on a change of the visual viewport alone, and the selection is revealed if the change covered it. Neither adapter observes the page's own scroll.

The visual-viewport coordinates are covered by jsdom tests and by bench/browser/visible-frame-check.mjs in headless Chromium, which scrolls the page past the canvas's top, pinch-zooms it and pans the zoomed view, shortens the layout viewport and emulates 200% zoom, and finds both cameras and the selection where the policies above put them. Chromium's DevTools protocol could not shrink the visual viewport at scale 1, the software-keyboard case, so that remains covered by jsdom only, and nothing here is verified on a real iOS or Android device. Current engines report getBoundingClientRect relative to the layout viewport, which is what visualViewportInset's use of offsetLeft and offsetTop relies on.

Page scrolling remains an explicit policy​

DagrCanvas navigation keeps its existing modes:

  • touchNavigation="drag" gives the surface one-finger pan and pinch, using touch-action: none.
  • touchNavigation="two-finger" leaves single-finger page panning enabled. A browser can cancel the pointer stream when it takes that scroll; a later second finger does not reliably turn it into an application pinch.

Explorer keeps its focus-gated policy: the first touch on an unfocused graph focuses it without activating a node. An unfocused graph allows page scroll. These policies intentionally differ; sharing geometry does not silently change who owns a browser gesture. Browser touch-action is determined when the sequence begins, so switching it inside pointerdown cannot reclaim a scroll already owned by the browser. See the Pointer Events specification.

Rendering and responsive reading​

Keep host containers explicitly sized. DagrCanvas measures its container and updates its drawing buffer separately from CSS-coordinate input. Its existing maxPixelRatio option can bound drawing-buffer density; choose a budget from measurements rather than assuming a phone is always slower or has fewer pixels. Explorer's rich node content remains DOM, backed by the same data and commands as search and details.

Adaptive controls and details shipped separately in M5.7b: Explorer docks details below the map in containers narrower than 720 CSS pixels. This input foundation does not add responsive panels or a new backend. The M5.7c outline reader (ExplorerOutline, and DagrExplorer's map or outline choice at any width) uses the same ExplorerRoot state. The visible frame contract above is the shared geometry for keyboard occlusion and camera-visible areas; a software keyboard can shrink the visual viewport without shrinking the layout viewport. Explorer and DagrCanvas observe it; safe areas are still to come.

See the roadmap for the viewport/occlusion and conformance work. Tests of a gesture state machine or Chromium touch emulation are not real iOS/Android device validation.