-
Notifications
You must be signed in to change notification settings - Fork 3
Description
🧩 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()orcosmograph.camera.position→ returns{x, y}centercanvas.widthandcanvas.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
- ipywidgets Custom Messages Guide
- Cosmo.gl API Reference – getZoomLevel
- Cosmograph Callbacks – onZoomStart / onZoomEnd
Labels:
feature-request widget frontend-bridge camera state-sync view-management