scatter3d-anywidget 0.1.5__py3-none-any.whl → 0.1.7__py3-none-any.whl

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.
scatter3d/scatter3d.py CHANGED
@@ -519,6 +519,9 @@ class Scatter3dWidget(anywidget.AnyWidget):
519
519
  super().__init__()
520
520
  self._category_cb_id: int | None = None
521
521
 
522
+ # Guard to suppress trait observers during multi-traitlet sync bursts
523
+ self._syncing_category = False
524
+
522
525
  if category is not None and xyz.shape[0] != category.num_values:
523
526
  raise ValueError(
524
527
  f"The number of points ({xyz.shape[0]}) should match "
@@ -629,24 +632,30 @@ class Scatter3dWidget(anywidget.AnyWidget):
629
632
 
630
633
  @traitlets.observe("active_category_t")
631
634
  def _on_active_category_t(self, change) -> None:
632
- if self.interaction_mode_t == "lasso" and change.get("new") is None:
633
- # NOTE: traitlets bypasses @validate(...) when allow_none=True.
634
- # 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:
635
640
  old = change.get("old")
636
641
  if old is not None:
637
- # restore previous valid value to keep state consistent
638
642
  self.set_trait("active_category_t", old)
639
643
  raise traitlets.TraitError("active_category_t cannot be None in lasso mode")
640
644
 
641
645
  @traitlets.observe("labels_t")
642
646
  def _on_labels_t(self, change) -> None:
643
- if self.interaction_mode_t == "lasso":
644
- self._ensure_active_category_invariants()
645
- elif self.active_category_t is not None and self.active_category_t not in (
647
+ # During category sync we temporarily allow intermediate inconsistent states.
648
+ if getattr(self, "_syncing_category", False):
649
+ return
650
+
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 (
646
654
  change.get("new") or []
647
655
  ):
648
- # in rotate mode, invalid active is a real error
649
- raise RuntimeError(...)
656
+ self.active_category_t = None
657
+ if self.interactive_ready_t:
658
+ self.send_state("active_category_t")
650
659
 
651
660
  @traitlets.observe("client_ready_t")
652
661
  def _on_client_ready_t(self, change) -> None:
@@ -735,11 +744,9 @@ class Scatter3dWidget(anywidget.AnyWidget):
735
744
  )
736
745
  return v
737
746
 
738
- # rotate mode
747
+ # rotate mode: do not crash on stale frontend values
739
748
  if labels and v not in labels:
740
- raise traitlets.TraitError(
741
- f"active_category_t={v!r} is not present in labels_t"
742
- )
749
+ return None
743
750
  return v
744
751
 
745
752
  @property
@@ -855,46 +862,47 @@ class Scatter3dWidget(anywidget.AnyWidget):
855
862
  if self._category is None:
856
863
  raise RuntimeError("The category should be set")
857
864
 
858
- cat = self._category
859
-
860
- # labels_t must be JSON-friendly; enforce str
861
- labels = [str(lbl) for lbl in cat.label_list]
862
- self.labels_t = labels
865
+ self._syncing_category = True
866
+ try:
867
+ cat = self._category
863
868
 
864
- self.category_editable_t = cat.editable
869
+ # labels_t must be JSON-friendly; enforce str
870
+ labels = [str(lbl) for lbl in cat.label_list]
871
+ self.labels_t = labels
865
872
 
866
- if not self.category_editable_t and self.interaction_mode_t == "lasso":
867
- self.interaction_mode_t = "rotate"
868
- if self.interactive_ready_t:
869
- self.send_state("interaction_mode_t")
870
-
871
- # coded values: uint16 bytes, length N
872
- coded = cat.coded_values
873
- if coded.shape[0] != self.num_points:
874
- raise RuntimeError(
875
- f"Category has {coded.shape[0]} values but xyz has {self.num_points} points"
876
- )
877
- self.coded_values_t = self._pack_u16_c(coded)
873
+ self.category_editable_t = cat.editable
878
874
 
879
- # colors aligned with labels order
880
- # Category stores palette keyed by original labels; we reconstruct in label_list order.
881
- palette = cat.color_palette # label -> (r,g,b)
882
- self.colors_t = [list(map(float, palette[lbl])) for lbl in cat.label_list]
875
+ # coded values: uint16 bytes, length N
876
+ coded = cat.coded_values
877
+ if coded.shape[0] != self.num_points:
878
+ raise RuntimeError(
879
+ f"Category has {coded.shape[0]} values but xyz has {self.num_points} points"
880
+ )
881
+ self.coded_values_t = self._pack_u16_c(coded)
883
882
 
884
- # missing color
885
- self.missing_color_t = list(map(float, cat.missing_color))
883
+ # colors aligned with labels order
884
+ palette = cat.color_palette # label -> (r,g,b)
885
+ self.colors_t = [list(map(float, palette[lbl])) for lbl in cat.label_list]
886
886
 
887
- if len(self.colors_t) != len(self.labels_t):
888
- raise RuntimeError(
889
- "Internal error: colors_t length must match labels_t length"
890
- )
887
+ # missing color
888
+ self.missing_color_t = list(map(float, cat.missing_color))
891
889
 
892
- self._ensure_active_category_invariants()
890
+ if len(self.colors_t) != len(self.labels_t):
891
+ raise RuntimeError(
892
+ "Internal error: colors_t length must match labels_t length"
893
+ )
894
+ finally:
895
+ self._syncing_category = False
893
896
 
894
897
  def _get_category(self):
895
898
  return self._category
896
899
 
897
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
+
898
906
  if self._xyz is not None and category.num_values != self.num_points:
899
907
  raise ValueError(
900
908
  f"The number of values in the category ({category.num_values}) "
@@ -904,6 +912,19 @@ class Scatter3dWidget(anywidget.AnyWidget):
904
912
  self._category.unsubscribe(self._category_cb_id)
905
913
 
906
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
+
907
928
  # Subscribe to new category
908
929
  self._category_cb_id = category.subscribe(self._on_category_changed)
909
930
  self._sync_traitlets_from_category()