scatter3d-anywidget 0.1.4__py3-none-any.whl → 0.1.6__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
@@ -80,6 +80,7 @@ class Category:
80
80
  label_list=None,
81
81
  color_palette: dict[Any, tuple[float, float, float]] | None = None,
82
82
  missing_color: tuple[float, float, float] = MISSING_COLOR,
83
+ editable: bool = True,
83
84
  ):
84
85
  self._cb_id_gen = count(1)
85
86
  self._callbacks: dict[int, weakref.ReferenceType] = {}
@@ -102,6 +103,8 @@ class Category:
102
103
  _is_valid_color(missing_color)
103
104
  self._missing_color = missing_color
104
105
 
106
+ self._editable = bool(editable)
107
+
105
108
  def subscribe(self, cb: CategoryCallback) -> int:
106
109
  cb_id = next(self._cb_id_gen)
107
110
  try:
@@ -125,6 +128,10 @@ class Category:
125
128
  for cb_id in dead:
126
129
  self._callbacks.pop(cb_id, None)
127
130
 
131
+ @property
132
+ def editable(self) -> bool:
133
+ return self._editable
134
+
128
135
  @staticmethod
129
136
  def _get_unique_labels_in_values(values):
130
137
  return values.drop_nulls().unique().to_list()
@@ -495,6 +502,11 @@ class Scatter3dWidget(anywidget.AnyWidget):
495
502
  help="Desired widget height in CSS pixels. Frontend will clamp to notebook constraints.",
496
503
  ).tag(sync=True)
497
504
 
505
+ category_editable_t = traitlets.Bool(
506
+ default_value=True,
507
+ help="Whether the active category is editable (enables lasso UI).",
508
+ ).tag(sync=True)
509
+
498
510
  def __init__(
499
511
  self,
500
512
  xyz: numpy.ndarray,
@@ -507,6 +519,9 @@ class Scatter3dWidget(anywidget.AnyWidget):
507
519
  super().__init__()
508
520
  self._category_cb_id: int | None = None
509
521
 
522
+ # Guard to suppress trait observers during multi-traitlet sync bursts
523
+ self._syncing_category = False
524
+
510
525
  if category is not None and xyz.shape[0] != category.num_values:
511
526
  raise ValueError(
512
527
  f"The number of points ({xyz.shape[0]}) should match "
@@ -628,13 +643,19 @@ class Scatter3dWidget(anywidget.AnyWidget):
628
643
 
629
644
  @traitlets.observe("labels_t")
630
645
  def _on_labels_t(self, change) -> None:
646
+ # During category sync we temporarily allow intermediate inconsistent states.
647
+ if getattr(self, "_syncing_category", False):
648
+ return
649
+
631
650
  if self.interaction_mode_t == "lasso":
632
651
  self._ensure_active_category_invariants()
633
652
  elif self.active_category_t is not None and self.active_category_t not in (
634
653
  change.get("new") or []
635
654
  ):
636
655
  # in rotate mode, invalid active is a real error
637
- raise RuntimeError(...)
656
+ raise RuntimeError(
657
+ f"active_category_t={self.active_category_t!r} is not present in labels_t after labels update"
658
+ )
638
659
 
639
660
  @traitlets.observe("client_ready_t")
640
661
  def _on_client_ready_t(self, change) -> None:
@@ -730,6 +751,10 @@ class Scatter3dWidget(anywidget.AnyWidget):
730
751
  )
731
752
  return v
732
753
 
754
+ @property
755
+ def active_category(self):
756
+ return self.active_category_t
757
+
733
758
  @traitlets.observe("tooltip_request_t")
734
759
  def _on_tooltip_request(self, change) -> None:
735
760
  req = change["new"] or {}
@@ -839,34 +864,54 @@ class Scatter3dWidget(anywidget.AnyWidget):
839
864
  if self._category is None:
840
865
  raise RuntimeError("The category should be set")
841
866
 
842
- cat = self._category
867
+ self._syncing_category = True
868
+ try:
869
+ cat = self._category
843
870
 
844
- # labels_t must be JSON-friendly; enforce str
845
- labels = [str(lbl) for lbl in cat.label_list]
846
- self.labels_t = labels
871
+ # labels_t must be JSON-friendly; enforce str
872
+ labels = [str(lbl) for lbl in cat.label_list]
873
+ self.labels_t = labels
847
874
 
848
- # coded values: uint16 bytes, length N
849
- coded = cat.coded_values
850
- if coded.shape[0] != self.num_points:
851
- raise RuntimeError(
852
- f"Category has {coded.shape[0]} values but xyz has {self.num_points} points"
853
- )
854
- self.coded_values_t = self._pack_u16_c(coded)
875
+ self.category_editable_t = cat.editable
855
876
 
856
- # colors aligned with labels order
857
- # Category stores palette keyed by original labels; we reconstruct in label_list order.
858
- palette = cat.color_palette # label -> (r,g,b)
859
- self.colors_t = [list(map(float, palette[lbl])) for lbl in cat.label_list]
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")
860
881
 
861
- # missing color
862
- self.missing_color_t = list(map(float, cat.missing_color))
882
+ coded = cat.coded_values
883
+ if coded.shape[0] != self.num_points:
884
+ raise RuntimeError(
885
+ f"Category has {coded.shape[0]} values but xyz has {self.num_points} points"
886
+ )
887
+ self.coded_values_t = self._pack_u16_c(coded)
863
888
 
864
- if len(self.colors_t) != len(self.labels_t):
865
- raise RuntimeError(
866
- "Internal error: colors_t length must match labels_t length"
867
- )
889
+ palette = cat.color_palette # label -> (r,g,b)
890
+ self.colors_t = [list(map(float, palette[lbl])) for lbl in cat.label_list]
868
891
 
869
- self._ensure_active_category_invariants()
892
+ self.missing_color_t = list(map(float, cat.missing_color))
893
+
894
+ if len(self.colors_t) != len(self.labels_t):
895
+ raise RuntimeError(
896
+ "Internal error: colors_t length must match labels_t length"
897
+ )
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
+ finally:
914
+ self._syncing_category = False
870
915
 
871
916
  def _get_category(self):
872
917
  return self._category