scatter3d-anywidget 0.1.6__tar.gz → 0.1.7__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: scatter3d-anywidget
3
- Version: 0.1.6
3
+ Version: 0.1.7
4
4
  Summary: 3D scatter widget with lasso selection
5
5
  License: MIT
6
6
  Classifier: Development Status :: 3 - Alpha
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "scatter3d-anywidget"
3
- version = "0.1.6"
3
+ version = "0.1.7"
4
4
  description = "3D scatter widget with lasso selection"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -632,12 +632,13 @@ class Scatter3dWidget(anywidget.AnyWidget):
632
632
 
633
633
  @traitlets.observe("active_category_t")
634
634
  def _on_active_category_t(self, change) -> None:
635
- if self.interaction_mode_t == "lasso" and change.get("new") is None:
636
- # NOTE: traitlets bypasses @validate(...) when allow_none=True.
637
- # In lasso mode, clearing is a hard error (no silent fallback).
635
+ old = change.get("old")
636
+ new = change.get("new")
637
+
638
+ # Keep your existing policy/logic:
639
+ if self.interaction_mode_t == "lasso" and new is None:
638
640
  old = change.get("old")
639
641
  if old is not None:
640
- # restore previous valid value to keep state consistent
641
642
  self.set_trait("active_category_t", old)
642
643
  raise traitlets.TraitError("active_category_t cannot be None in lasso mode")
643
644
 
@@ -647,15 +648,14 @@ class Scatter3dWidget(anywidget.AnyWidget):
647
648
  if getattr(self, "_syncing_category", False):
648
649
  return
649
650
 
650
- if self.interaction_mode_t == "lasso":
651
- self._ensure_active_category_invariants()
652
- elif self.active_category_t is not None and self.active_category_t not in (
651
+ # After our new policy, we should never be in lasso mode during a category switch,
652
+ # and active_category_t should be None. Still, be defensive: never crash.
653
+ if self.active_category_t is not None and self.active_category_t not in (
653
654
  change.get("new") or []
654
655
  ):
655
- # in rotate mode, invalid active is a real error
656
- raise RuntimeError(
657
- f"active_category_t={self.active_category_t!r} is not present in labels_t after labels update"
658
- )
656
+ self.active_category_t = None
657
+ if self.interactive_ready_t:
658
+ self.send_state("active_category_t")
659
659
 
660
660
  @traitlets.observe("client_ready_t")
661
661
  def _on_client_ready_t(self, change) -> None:
@@ -744,11 +744,9 @@ class Scatter3dWidget(anywidget.AnyWidget):
744
744
  )
745
745
  return v
746
746
 
747
- # rotate mode
747
+ # rotate mode: do not crash on stale frontend values
748
748
  if labels and v not in labels:
749
- raise traitlets.TraitError(
750
- f"active_category_t={v!r} is not present in labels_t"
751
- )
749
+ return None
752
750
  return v
753
751
 
754
752
  @property
@@ -874,11 +872,7 @@ class Scatter3dWidget(anywidget.AnyWidget):
874
872
 
875
873
  self.category_editable_t = cat.editable
876
874
 
877
- if not self.category_editable_t and self.interaction_mode_t == "lasso":
878
- self.interaction_mode_t = "rotate"
879
- if self.interactive_ready_t:
880
- self.send_state("interaction_mode_t")
881
-
875
+ # coded values: uint16 bytes, length N
882
876
  coded = cat.coded_values
883
877
  if coded.shape[0] != self.num_points:
884
878
  raise RuntimeError(
@@ -886,30 +880,17 @@ class Scatter3dWidget(anywidget.AnyWidget):
886
880
  )
887
881
  self.coded_values_t = self._pack_u16_c(coded)
888
882
 
883
+ # colors aligned with labels order
889
884
  palette = cat.color_palette # label -> (r,g,b)
890
885
  self.colors_t = [list(map(float, palette[lbl])) for lbl in cat.label_list]
891
886
 
887
+ # missing color
892
888
  self.missing_color_t = list(map(float, cat.missing_color))
893
889
 
894
890
  if len(self.colors_t) != len(self.labels_t):
895
891
  raise RuntimeError(
896
892
  "Internal error: colors_t length must match labels_t length"
897
893
  )
898
-
899
- # Now that labels/colors are consistent, enforce a stable policy for active category.
900
- if self.interaction_mode_t == "rotate":
901
- if (
902
- self.active_category_t is not None
903
- and self.active_category_t not in self.labels_t
904
- ):
905
- # Professional + predictable: clear invalid selection on category switch.
906
- self.active_category_t = None
907
- if self.interactive_ready_t:
908
- self.send_state("active_category_t")
909
- else:
910
- # lasso mode keeps existing behavior: ensure a valid active label exists.
911
- self._ensure_active_category_invariants()
912
-
913
894
  finally:
914
895
  self._syncing_category = False
915
896
 
@@ -917,6 +898,11 @@ class Scatter3dWidget(anywidget.AnyWidget):
917
898
  return self._category
918
899
 
919
900
  def _set_category(self, category: Category) -> None:
901
+ # Idempotence: marimo may re-run cells and re-assign the same Category.
902
+ # That must be a no-op (must not clear active category, mode, etc).
903
+ if category is self._category:
904
+ return
905
+
920
906
  if self._xyz is not None and category.num_values != self.num_points:
921
907
  raise ValueError(
922
908
  f"The number of values in the category ({category.num_values}) "
@@ -926,6 +912,19 @@ class Scatter3dWidget(anywidget.AnyWidget):
926
912
  self._category.unsubscribe(self._category_cb_id)
927
913
 
928
914
  self._category = category
915
+
916
+ # POLICY: changing the category object resets interaction to rotate and clears active.
917
+ # This must NOT run on Category mutations (coded values edits, palette tweaks, etc.).
918
+ if self.interaction_mode_t != "rotate":
919
+ self.interaction_mode_t = "rotate"
920
+ if self.interactive_ready_t:
921
+ self.send_state("interaction_mode_t")
922
+
923
+ if self.active_category_t is not None:
924
+ self.active_category_t = None
925
+ if self.interactive_ready_t:
926
+ self.send_state("active_category_t")
927
+
929
928
  # Subscribe to new category
930
929
  self._category_cb_id = category.subscribe(self._on_category_changed)
931
930
  self._sync_traitlets_from_category()
@@ -18,7 +18,7 @@ def _():
18
18
  points = np.random.randn(num_points, 3)
19
19
  species_list = ["species1", "species2", "species3"]
20
20
  species = random.choices(species_list, k=num_points)
21
- species = Category(pandas.Series(species, name="species"))
21
+ species = Category(pandas.Series(species, name="species"), editable=False)
22
22
  countries_list = ["country1", "country2", "country3"]
23
23
  countries = random.choices(countries_list, k=num_points)
24
24
  countries = Category(pandas.Series(countries, name="countries"))
@@ -29,12 +29,12 @@ def _():
29
29
  w = Scatter3dWidget(xyz=points, category=species, point_ids=point_ids)
30
30
  w.height = 800
31
31
  ui = marimo.ui.anywidget(w)
32
- return species, ui, w
32
+ return Scatter3dWidget, countries, ui, w
33
33
 
34
34
 
35
35
  @app.cell
36
- def _(species):
37
- category = species
36
+ def _(countries):
37
+ category = countries
38
38
  return (category,)
39
39
 
40
40
 
@@ -47,7 +47,28 @@ def _(category, ui, w):
47
47
 
48
48
  @app.cell
49
49
  def _(w):
50
- print(w.active_category)
50
+ widget = w
51
+
52
+ print("AFTER click:",
53
+ "mode=", widget.interaction_mode_t,
54
+ "editable=", widget.category_editable_t,
55
+ "active=", widget.active_category_t)
56
+
57
+ return
58
+
59
+
60
+ @app.cell
61
+ def _():
62
+ return
63
+
64
+
65
+ @app.cell
66
+ def _(Scatter3dWidget):
67
+ import scatter3d, inspect
68
+
69
+ print("scatter3d module:", scatter3d.__file__)
70
+ print("Scatter3dWidget source:", inspect.getsourcefile(Scatter3dWidget))
71
+ print("Scatter3dWidget._sync_traitlets_from_category line:", Scatter3dWidget._sync_traitlets_from_category.__code__.co_firstlineno)
51
72
  return
52
73
 
53
74