Skip to content

Add a get_view() API (with get_zoom_level, get_camera_position, etc.) for retrieving the current camera state #52

@thorwhalen

Description

@thorwhalen

🧩 Background

Cosmograph currently supports several “set view” methods (setView, fitView, setZoomLevel),
but there is no “get view” equivalent — particularly no way to retrieve the current zoom level and camera position from Python.

This becomes an issue for:

  • Narrative recording (logging what part of the graph corresponds to each story step)
  • Automated testing (verifying that a sequence of actions leads to a known view)
  • Reproducibility (saving and restoring the same view later)

🔍 Current Situation

On the JS side, Cosmograph exposes everything needed:

  • cosmograph.getZoomLevel() → returns a number (zoom scale)
  • cosmograph.getCameraPosition() or cosmograph.camera.position → returns {x, y} center
  • canvas.width and canvas.height → give viewport size in pixels

However, none of these are currently synced to the Python widget.
Only properties explicitly defined as .tag(sync=True) and updated via model.set() in JS will be visible to Python.

So, from Python you cannot directly query or read:

  • Zoom level
  • Camera position
  • Viewport dimensions

⚙️ Proposed Implementation

Implement a bidirectional communication pattern using custom ipywidgets messages.
We’ll define three new synced traitlets (zoom_level, camera_position, and optionally viewport_size)
and a new async API method get_view() that requests all view data from JS.

🐍 Python side (in the widget class)

from ipywidgets import DOMWidget
from traitlets import Float, Dict
import asyncio, uuid

class CosmographWidget(DOMWidget):
    zoom_level = Float(None, allow_none=True).tag(sync=True)
    camera_position = Dict(default_value=None, allow_none=True).tag(sync=True)
    viewport_size = Dict(default_value=None, allow_none=True).tag(sync=True)

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self._pending = {}
        self.on_msg(self._handle_frontend_msg)

    async def get_zoom_level(self, timeout=1.0):
        """Request only the zoom level."""
        req_id = str(uuid.uuid4())
        fut = asyncio.get_running_loop().create_future()
        self._pending[req_id] = fut
        self.send({"type": "get_zoom_level", "id": req_id})
        try:
            return await asyncio.wait_for(fut, timeout)
        finally:
            self._pending.pop(req_id, None)

    async def get_view(self, timeout=1.0):
        """Request full camera view: zoom, position, and viewport."""
        req_id = str(uuid.uuid4())
        fut = asyncio.get_running_loop().create_future()
        self._pending[req_id] = fut
        self.send({"type": "get_view", "id": req_id})
        try:
            return await asyncio.wait_for(fut, timeout)
        finally:
            self._pending.pop(req_id, None)

    def _handle_frontend_msg(self, _, content, buffers):
        msg_type = content.get("type")
        req_id = content.get("id")
        if msg_type == "zoom_level":
            value = content.get("value")
            self.zoom_level = value
            if req_id in self._pending:
                self._pending[req_id].set_result(value)
        elif msg_type == "view_state":
            result = {
                "zoom": content.get("zoom"),
                "center": content.get("center"),
                "viewport": content.get("viewport"),
            }
            self.zoom_level = result["zoom"]
            self.camera_position = result["center"]
            self.viewport_size = result["viewport"]
            if req_id in self._pending:
                self._pending[req_id].set_result(result)

💻 JavaScript side (in the View class)

this.model.on('msg:custom', (msg) => {
  if (msg.type === 'get_zoom_level') {
    const zoom = this.cosmograph?.getZoomLevel?.();
    this.model.set('zoom_level', zoom ?? null);
    this.model.save_changes();
    this.send({ type: 'zoom_level', id: msg.id, value: zoom });
  }

  if (msg.type === 'get_view') {
    const zoom = this.cosmograph?.getZoomLevel?.() ?? null;
    const center = this.cosmograph?.getCameraPosition?.?.() ?? null;
    const canvas = this.el?.querySelector('canvas');
    const viewport = canvas ? { width: canvas.width, height: canvas.height } : null;

    this.model.set({
      'zoom_level': zoom,
      'camera_position': center,
      'viewport_size': viewport
    });
    this.model.save_changes();

    this.send({
      type: 'view_state',
      id: msg.id,
      zoom: zoom,
      center: center,
      viewport: viewport
    });
  }
});

Optionally, to keep the zoom trait up-to-date automatically:

this.cosmograph.setConfig?.({
  onZoomEnd: () => {
    const zoom = this.cosmograph.getZoomLevel?.();
    this.model.set('zoom_level', zoom ?? null);
    this.model.save_changes();
  },
});

✅ Usage Example (Python)

# Query zoom only
zoom = await widget.get_zoom_level()
print("Zoom:", zoom)

# Query full view
view = await widget.get_view()
print("Full view:", view)

Would yield a dictionary like:

    {
      "zoom": 1.8,
      "center": {"x": 42.3, "y": -12.1},
      "viewport": {"width": 800, "height": 600}
    }

⚠️ Notes on Asynchrony

The JS/Python widget bridge is asynchronous.
If get_view() or get_zoom_level() were implemented as synchronous methods,
they would block the Jupyter event loop — preventing messages from being processed and potentially freezing the kernel.

Using an async request–response pattern avoids that issue cleanly.


📚 References


Labels:
feature-request widget frontend-bridge camera state-sync view-management

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions