Skip to content

Commit 26b628b

Browse files
timtreisclaude
andcommitted
Compact responsive UX + drop UTC-timestamp collision rename
UX: - Responsive canvas via `width: 100%` + `max-width: Npx` + CSS `aspect-ratio` on the wrap/container divs. Removes the fixed `DISP_MAX = 760` pixel box. Below `max_width` the canvas scales with the surrounding column; above it the canvas caps and centers. - New `max_width: int = 880` kwarg on `sdata.pl.annotate(...)` plumbed through `_InteractiveSession.__init__` and a new `max_display_width` `Int` traitlet on `DrawCanvas`. Pure display hint; underlying PNG render is unchanged (840 × 840). - Toolbar reflow: replace `HBox` with `Box(layout=Layout(flex_flow= "row wrap", ...))` so the tool toggle + icon buttons wrap onto a second row under narrow notebook widths instead of overflowing. - Icon-only auxiliary buttons (Close polygon / Undo / Clear / Fit / Write to disk) — `description=""`, 36px square, tooltip carries the affordance. Save button keeps its text label. - Drop the standalone "0 shape(s) on canvas" row and the `description= "Tool:"` / `description="Name:"` widget-side labels — none of them added information, all cost vertical space. Behaviour: - Remove the UTC-timestamp collision rename in `commit_to_memory`. Same name now overwrites in-memory (and on-disk via `write_element`, which we already pass `overwrite=True`). Drops the `datetime` import and the rename-on-collision banner branch. Reviewer flagged the prior rename as off-convention vs upstream spatialdata. - Update `test_commit_to_memory_renames_on_collision` → `test_commit_to_memory_overwrites_on_collision`. Other tests unchanged. Lint: - Pre-commit pass: `_dt.timezone.utc` → `_dt.UTC` (UP017) before the whole datetime block was dropped; ruff PT018 split assertion into two lines in `_collect_polygons`; biome + ruff-format normalised the changed Python and JS. All 18 interactive tests pass in dev-interactive-py313. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3b77059 commit 26b628b

13 files changed

Lines changed: 311 additions & 184 deletions

File tree

src/spatialdata_plot/pl/basic.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ def annotate(
177177
element: str,
178178
*,
179179
persist: bool = True,
180+
max_width: int = 880,
180181
) -> None:
181182
"""Draw and save regions interactively on an image element.
182183
@@ -189,9 +190,8 @@ def annotate(
189190
cleared on every Save so the next set of shapes can be drawn
190191
independently.
191192
192-
In-memory name collisions are renamed to ``{name}_{UTC-ISO}``. The
193-
on-disk *Write to disk* button calls ``SpatialData.write_element``,
194-
which overwrites an existing on-disk element of the same name.
193+
Same-name commits overwrite both in memory and (via
194+
``SpatialData.write_element``) on disk.
195195
196196
Requires the ``interactive`` extra: ``pip install 'spatialdata-plot[interactive]'``.
197197
@@ -207,6 +207,10 @@ def annotate(
207207
If ``True`` (default), show a *Write to disk* button that calls
208208
:meth:`SpatialData.write_element` for the most recent save.
209209
Set to ``False`` to limit the session to in-memory commits.
210+
max_width :
211+
Maximum display width in CSS pixels. The widget fills its
212+
container width but never exceeds this. Display hint only;
213+
the underlying render is always 840 × 840 px.
210214
211215
Returns
212216
-------
@@ -242,6 +246,7 @@ def annotate(
242246
coordinate_system=coordinate_system,
243247
element=element,
244248
persist=persist,
249+
max_width=max_width,
245250
)
246251
session.show()
247252

src/spatialdata_plot/pl/interactive/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
>>> import spatialdata_plot # noqa: F401 registers .pl
66
>>> sdata.pl.annotate("global", "he_image")
77
"""
8+
89
from __future__ import annotations
910

1011
__all__: list[str] = []

src/spatialdata_plot/pl/interactive/_canvas.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ class DrawCanvas(anywidget.AnyWidget):
4242
image_url = traitlets.Unicode("").tag(sync=True)
4343
image_width = traitlets.Int(720).tag(sync=True)
4444
image_height = traitlets.Int(720).tag(sync=True)
45+
max_display_width = traitlets.Int(880).tag(sync=True)
4546
tool = traitlets.Enum(TOOLS, default_value="rectangle").tag(sync=True)
4647
shapes = traitlets.List([]).tag(sync=True)
4748
clear_trigger = traitlets.Int(0).tag(sync=True)

src/spatialdata_plot/pl/interactive/_commit.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Convert canvas pixel-coord shapes into a CS-coord ShapesModel."""
2+
23
from __future__ import annotations
34

45
from typing import Any
@@ -28,10 +29,7 @@ def pixel_shape_to_polygon(shape: dict[str, Any], extent: RenderExtent) -> Polyg
2829
y_lo, y_hi = sorted((float(extent.ylim[0]), float(extent.ylim[1])))
2930
w, h = extent.image_w, extent.image_h
3031

31-
cs_verts = [
32-
(xmin + (v[0] / w) * (xmax - xmin), y_lo + (v[1] / h) * (y_hi - y_lo))
33-
for v in verts
34-
]
32+
cs_verts = [(xmin + (v[0] / w) * (xmax - xmin), y_lo + (v[1] / h) * (y_hi - y_lo)) for v in verts]
3533
if len(cs_verts) < 3:
3634
return None
3735
poly = Polygon(cs_verts)
Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,13 @@
1-
"""Commit a ShapesModel into sdata.shapes under a collision-safe name."""
1+
"""Commit a ShapesModel into sdata.shapes."""
2+
23
from __future__ import annotations
34

4-
import datetime as _dt
55
from typing import Any
66

77
import spatialdata as sd
88

99

1010
def commit_to_memory(sdata: sd.SpatialData, shapes_model: Any, name: str) -> str:
11-
"""Add ``shapes_model`` to ``sdata.shapes`` under ``name``.
12-
13-
On collision, the existing element is preserved and the new one is
14-
renamed to ``{name}_{utc-iso}``. Returns the final committed name.
15-
"""
16-
target = name
17-
if target in sdata.shapes:
18-
ts = _dt.datetime.now(_dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
19-
target = f"{name}_{ts}"
20-
sdata.shapes[target] = shapes_model
21-
return target
11+
"""Add ``shapes_model`` to ``sdata.shapes`` under ``name``, overwriting on collision."""
12+
sdata.shapes[name] = shapes_model
13+
return name

src/spatialdata_plot/pl/interactive/_render.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Render an image element to a PNG suitable for the DrawCanvas background."""
2+
23
from __future__ import annotations
34

45
from dataclasses import dataclass

src/spatialdata_plot/pl/interactive/_session.py

Lines changed: 94 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""ipywidgets-based session orchestrating the DrawCanvas. Internal."""
2+
23
from __future__ import annotations
34

45
import base64
@@ -56,8 +57,7 @@ def _fmt_banner(msg: str, kind: BannerKind = "info") -> str:
5657
def _validate(sdata: sd.SpatialData, coordinate_system: str, element: str) -> None:
5758
if coordinate_system not in sdata.coordinate_systems:
5859
raise ValueError(
59-
f"Unknown coordinate system {coordinate_system!r}. "
60-
f"Available: {list(sdata.coordinate_systems)}"
60+
f"Unknown coordinate system {coordinate_system!r}. Available: {list(sdata.coordinate_systems)}"
6161
)
6262
if element not in sdata.images:
6363
raise ValueError(f"Unknown image element {element!r}. Available: {list(sdata.images)}")
@@ -79,79 +79,129 @@ def __init__(
7979
element: str,
8080
*,
8181
persist: bool = True,
82+
max_width: int = 880,
8283
) -> None:
8384
_validate(sdata, coordinate_system, element)
8485

8586
self._sdata = sdata
8687
self._cs = coordinate_system
8788
self._element = element
8889
self._persist_enabled = persist
90+
self._max_width = max_width
8991
self.canvas: DrawCanvas | None = None
9092
self._extent: RenderExtent | None = None
9193
self._commits: list[str] = []
9294

9395
self._style = W.HTML(value=_CSS)
9496

97+
icon_btn_layout = W.Layout(width="36px")
9598
self.tool_tb = W.ToggleButtons(
9699
options=[("Rect", "rectangle"), ("Polygon", "polygon"), ("Lasso", "lasso")],
97100
value="rectangle",
98-
description="Tool:",
101+
description="",
102+
tooltips=["R", "P", "L"],
99103
)
100104
self.tool_tb.observe(self._on_tool_change, names="value")
101105
self.close_poly_btn = self._trigger_btn(
102-
"Close polygon", "check", "close_poly_trigger", tooltip="Enter", disabled=True,
106+
"",
107+
"check",
108+
"close_poly_trigger",
109+
tooltip="Close polygon (Enter)",
110+
disabled=True,
111+
layout=icon_btn_layout,
103112
)
104113
self.undo_btn = self._trigger_btn(
105-
"Undo", "rotate-left", "undo_trigger", tooltip="Ctrl+Z", disabled=True,
114+
"",
115+
"rotate-left",
116+
"undo_trigger",
117+
tooltip="Undo (Ctrl+Z)",
118+
disabled=True,
119+
layout=icon_btn_layout,
106120
)
107121
self.clear_btn = self._trigger_btn(
108-
"Clear", "trash", "clear_trigger", tooltip="Delete",
122+
"",
123+
"trash",
124+
"clear_trigger",
125+
tooltip="Clear canvas",
109126
after=lambda: self._set_banner("Canvas cleared.", "info"),
127+
layout=icon_btn_layout,
128+
)
129+
self.fit_btn = self._trigger_btn(
130+
"",
131+
"compress",
132+
"fit_trigger",
133+
tooltip="Fit view (F)",
134+
layout=icon_btn_layout,
110135
)
111-
self.fit_btn = self._trigger_btn("Fit view", "compress", "fit_trigger", tooltip="F")
112-
self.shape_count_lbl = W.Label(value="0 shape(s) on canvas")
113136

114-
self.name_tx = W.Text(value="", placeholder="name…", description="Name:")
115-
self.save_btn = W.Button(description="Save", button_style="success", icon="save")
137+
self.name_tx = W.Text(
138+
value="",
139+
placeholder="name…",
140+
layout=W.Layout(flex="1 1 140px", min_width="100px"),
141+
)
142+
self.save_btn = W.Button(
143+
description="Save",
144+
button_style="success",
145+
icon="save",
146+
tooltip="Save shapes to sdata.shapes[name]",
147+
)
116148
self.save_btn.on_click(self._on_save)
117149

118150
save_row_widgets: list[W.Widget] = [self.name_tx, self.save_btn]
119151
self.persist_btn: W.Button | None = None
120152
if persist:
121153
self.persist_btn = W.Button(
122-
description="Write to disk", button_style="warning", icon="hdd-o",
154+
description="",
155+
icon="hdd-o",
156+
button_style="warning",
157+
tooltip="Write last save to disk",
158+
layout=icon_btn_layout,
123159
)
124160
self.persist_btn.on_click(self._on_persist)
125161
self.persist_btn.disabled = True
126162
save_row_widgets.append(self.persist_btn)
127163

128-
self.banner = W.HTML(value=_fmt_banner(
129-
f"Annotating <b>{element!r}</b> in coordinate system <b>{coordinate_system!r}</b>. "
130-
"Pick a tool and draw. Click canvas first so keyboard shortcuts work. "
131-
"<b>R/P/L</b> tools · <b>Wheel</b> zoom · <b>Shift+drag</b> pan · "
132-
"<b>Alt+click</b> shape to delete · <b>Ctrl+Z</b> undo · <b>F</b> fit",
133-
"hint",
134-
))
164+
self.banner = W.HTML(
165+
value=_fmt_banner(
166+
f"Annotating <b>{element!r}</b> in coordinate system <b>{coordinate_system!r}</b>. "
167+
"Pick a tool and draw. Click canvas first so keyboard shortcuts work. "
168+
"<b>R/P/L</b> tools · <b>Wheel</b> zoom · <b>Shift+drag</b> pan · "
169+
"<b>Alt+click</b> shape to delete · <b>Ctrl+Z</b> undo · <b>F</b> fit",
170+
"hint",
171+
)
172+
)
135173
self.plot_box = W.VBox([])
136174

137-
def section(label: str) -> W.HTML:
138-
return W.HTML(value=f"<div class='sdp-section-title'>{label}</div>")
139-
140-
controls_card = W.VBox([
141-
W.HTML(value=(
142-
f"<div class='sdp-title'>Annotate</div>"
143-
f"<div class='sdp-context'>{element!r} · {coordinate_system!r}</div>"
144-
)),
145-
section("Draw"),
146-
W.HBox([self.tool_tb, self.close_poly_btn, self.undo_btn, self.clear_btn, self.fit_btn]),
147-
W.HBox([self.shape_count_lbl]),
148-
section("Save"),
149-
W.HBox(save_row_widgets),
150-
self.banner,
151-
])
175+
row_layout = W.Layout(
176+
display="flex",
177+
flex_flow="row wrap",
178+
align_items="center",
179+
gap="6px",
180+
)
181+
card_layout = W.Layout(max_width=f"{max_width}px", width="100%")
182+
toolbar = W.Box(
183+
children=[self.tool_tb, self.close_poly_btn, self.undo_btn, self.clear_btn, self.fit_btn],
184+
layout=row_layout,
185+
)
186+
save_row = W.Box(children=save_row_widgets, layout=row_layout)
187+
188+
controls_card = W.VBox(
189+
[
190+
W.HTML(
191+
value=(
192+
f"<div class='sdp-title'>Annotate</div>"
193+
f"<div class='sdp-context'>{element!r} · {coordinate_system!r}</div>"
194+
)
195+
),
196+
toolbar,
197+
save_row,
198+
self.banner,
199+
],
200+
layout=card_layout,
201+
)
152202
controls_card.add_class("sdp-card")
153203

154-
canvas_card = W.VBox([self.plot_box])
204+
canvas_card = W.VBox([self.plot_box], layout=card_layout)
155205
canvas_card.add_class("sdp-card")
156206

157207
self.controls = W.VBox([self._style, controls_card, canvas_card])
@@ -172,9 +222,12 @@ def _trigger_btn(
172222
tooltip: str = "",
173223
disabled: bool = False,
174224
after: Any = None,
225+
layout: W.Layout | None = None,
175226
) -> W.Button:
176227
btn = W.Button(description=description, icon=icon, tooltip=tooltip)
177228
btn.disabled = disabled
229+
if layout is not None:
230+
btn.layout = layout
178231

179232
def _on_click(_b: W.Button) -> None:
180233
if self.canvas is None:
@@ -195,15 +248,14 @@ def _render(self) -> None:
195248
image_url=data_url,
196249
image_width=extent.image_w,
197250
image_height=extent.image_h,
251+
max_display_width=self._max_width,
198252
tool=self.tool_tb.value,
199253
)
200254
self.canvas.observe(self._on_shapes_change, names="shapes")
201255
self.plot_box.children = (self.canvas,)
202-
self.shape_count_lbl.value = "0 shape(s) on canvas"
203256

204257
def _on_shapes_change(self, change: dict[str, Any]) -> None:
205258
shapes = change["new"] or []
206-
self.shape_count_lbl.value = f"{len(shapes)} shape(s) on canvas"
207259
self.undo_btn.disabled = len(shapes) == 0
208260

209261
def _on_tool_change(self, change: dict[str, Any]) -> None:
@@ -214,24 +266,24 @@ def _on_tool_change(self, change: dict[str, Any]) -> None:
214266
self._set_banner(f"Tool: <b>{change['new']}</b>", "info")
215267

216268
def _collect_polygons(self) -> list[Polygon]:
217-
assert self.canvas is not None and self._extent is not None
269+
assert self.canvas is not None
270+
assert self._extent is not None
218271
polys: list[Polygon] = []
219272
for sh in self.canvas.shapes:
220273
p = pixel_shape_to_polygon(sh, self._extent)
221274
if p is not None:
222275
polys.append(p)
223276
return polys
224277

225-
def _commit_polygons(self, polys: list[Polygon], name: str) -> tuple[str, bool]:
278+
def _commit_polygons(self, polys: list[Polygon], name: str) -> str:
226279
shapes_model = build_shapes_model(polys, self._cs)
227280
target = commit_to_memory(self._sdata, shapes_model, name)
228281
self._commits.append(target)
229-
return target, target != name
282+
return target
230283

231284
def _reset_canvas_state(self) -> None:
232285
assert self.canvas is not None
233286
self.canvas.clear_trigger += 1
234-
self.shape_count_lbl.value = "0 shape(s) on canvas"
235287

236288
def _on_save(self, _btn: W.Button) -> None:
237289
name = self.name_tx.value.strip()
@@ -250,13 +302,10 @@ def _on_save(self, _btn: W.Button) -> None:
250302
)
251303
return
252304

253-
target, renamed = self._commit_polygons(polys, name)
305+
target = self._commit_polygons(polys, name)
254306
self._reset_canvas_state()
255307

256-
msg = f"Saved <b>{target!r}</b> with {len(polys)} polygon(s)."
257-
if renamed:
258-
msg += " (name collided; renamed)"
259-
self._set_banner(msg, "success")
308+
self._set_banner(f"Saved <b>{target!r}</b> with {len(polys)} polygon(s).", "success")
260309
if self.persist_btn is not None:
261310
self.persist_btn.disabled = self._sdata.path is None
262311

0 commit comments

Comments
 (0)