diffract-core 0.2.1__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.
Files changed (193) hide show
  1. diffract/__init__.py +52 -0
  2. diffract/configs/fast_speed_without_disk.ini +48 -0
  3. diffract/configs/hybrid.ini +74 -0
  4. diffract/configs/sqlite.ini +62 -0
  5. diffract/containers.py +405 -0
  6. diffract/core/__init__.py +23 -0
  7. diffract/core/cache/__init__.py +24 -0
  8. diffract/core/cache/containers.py +93 -0
  9. diffract/core/cache/interface.py +117 -0
  10. diffract/core/cache/redis_manager.py +464 -0
  11. diffract/core/cache/simple_manager.py +478 -0
  12. diffract/core/compute/__init__.py +46 -0
  13. diffract/core/compute/config.py +48 -0
  14. diffract/core/compute/containers.py +81 -0
  15. diffract/core/compute/decorator.py +125 -0
  16. diffract/core/compute/exceptions.py +31 -0
  17. diffract/core/compute/execution/__init__.py +14 -0
  18. diffract/core/compute/execution/_types.py +70 -0
  19. diffract/core/compute/execution/aggregation.py +74 -0
  20. diffract/core/compute/execution/aggregation_runner.py +362 -0
  21. diffract/core/compute/execution/enums.py +38 -0
  22. diffract/core/compute/execution/executor.py +125 -0
  23. diffract/core/compute/execution/parameter_runner.py +125 -0
  24. diffract/core/compute/execution/restrictions.py +61 -0
  25. diffract/core/compute/execution/strategy.py +118 -0
  26. diffract/core/compute/execution/utils.py +112 -0
  27. diffract/core/compute/extensions/__init__.py +0 -0
  28. diffract/core/compute/extensions/power_law.py +1051 -0
  29. diffract/core/compute/extensions/rmt.py +150 -0
  30. diffract/core/compute/extensions/utils.py +36 -0
  31. diffract/core/compute/field_signature.py +183 -0
  32. diffract/core/compute/kernels/__init__.py +48 -0
  33. diffract/core/compute/kernels/alignment.py +106 -0
  34. diffract/core/compute/kernels/heavy_tailed.py +394 -0
  35. diffract/core/compute/kernels/marchenko_pastur.py +99 -0
  36. diffract/core/compute/kernels/mat_decomposition.py +101 -0
  37. diffract/core/compute/kernels/mat_properties.py +53 -0
  38. diffract/core/compute/kernels/model_quality.py +27 -0
  39. diffract/core/compute/kernels/norms.py +88 -0
  40. diffract/core/compute/kernels/ranks.py +35 -0
  41. diffract/core/compute/kernels/tracy_widom.py +36 -0
  42. diffract/core/compute/metadata.py +67 -0
  43. diffract/core/compute/registry.py +485 -0
  44. diffract/core/constants.py +147 -0
  45. diffract/core/data/__init__.py +32 -0
  46. diffract/core/data/interface.py +304 -0
  47. diffract/core/data/metadata/__init__.py +15 -0
  48. diffract/core/data/metadata/containers.py +60 -0
  49. diffract/core/data/metadata/interface.py +208 -0
  50. diffract/core/data/metadata/sqlite_index.py +604 -0
  51. diffract/core/data/nn/__init__.py +43 -0
  52. diffract/core/data/nn/aggregates/__init__.py +35 -0
  53. diffract/core/data/nn/aggregates/metadata.py +96 -0
  54. diffract/core/data/nn/aggregates/proxy.py +57 -0
  55. diffract/core/data/nn/aggregates/repository.py +87 -0
  56. diffract/core/data/nn/aggregates/view.py +307 -0
  57. diffract/core/data/nn/containers.py +86 -0
  58. diffract/core/data/nn/extractors/__init__.py +49 -0
  59. diffract/core/data/nn/extractors/base.py +300 -0
  60. diffract/core/data/nn/extractors/factory.py +234 -0
  61. diffract/core/data/nn/extractors/flax.py +112 -0
  62. diffract/core/data/nn/extractors/handlers/__init__.py +39 -0
  63. diffract/core/data/nn/extractors/handlers/base.py +110 -0
  64. diffract/core/data/nn/extractors/handlers/flax_handlers.py +71 -0
  65. diffract/core/data/nn/extractors/handlers/numpy_handlers.py +63 -0
  66. diffract/core/data/nn/extractors/handlers/onnx_handlers.py +67 -0
  67. diffract/core/data/nn/extractors/handlers/tensorflow_handlers.py +82 -0
  68. diffract/core/data/nn/extractors/handlers/torch_handlers.py +124 -0
  69. diffract/core/data/nn/extractors/interface.py +56 -0
  70. diffract/core/data/nn/extractors/numpy.py +94 -0
  71. diffract/core/data/nn/extractors/onnx.py +108 -0
  72. diffract/core/data/nn/extractors/tensorflow.py +99 -0
  73. diffract/core/data/nn/extractors/torch.py +204 -0
  74. diffract/core/data/nn/params/__init__.py +27 -0
  75. diffract/core/data/nn/params/interface.py +402 -0
  76. diffract/core/data/nn/params/metadata.py +110 -0
  77. diffract/core/data/nn/params/proxy.py +93 -0
  78. diffract/core/data/nn/params/repository.py +45 -0
  79. diffract/core/data/nn/params/schema.py +82 -0
  80. diffract/core/data/nn/params/view.py +393 -0
  81. diffract/core/data/proxy.py +246 -0
  82. diffract/core/data/repository.py +227 -0
  83. diffract/core/data/utils.py +33 -0
  84. diffract/core/data/view.py +389 -0
  85. diffract/core/export/__init__.py +27 -0
  86. diffract/core/export/containers.py +47 -0
  87. diffract/core/export/exporters.py +191 -0
  88. diffract/core/export/formatters/__init__.py +23 -0
  89. diffract/core/export/formatters/base.py +68 -0
  90. diffract/core/export/formatters/dict_formatter.py +38 -0
  91. diffract/core/export/formatters/json_formatter.py +58 -0
  92. diffract/core/export/formatters/list_formatter.py +41 -0
  93. diffract/core/export/formatters/pandas_formatter.py +86 -0
  94. diffract/core/export/formatters/polars_formatter.py +86 -0
  95. diffract/core/export/formatters/registry.py +84 -0
  96. diffract/core/export/interface.py +105 -0
  97. diffract/core/parallel/__init__.py +21 -0
  98. diffract/core/parallel/containers.py +57 -0
  99. diffract/core/parallel/execution.py +91 -0
  100. diffract/core/parallel/runtime.py +91 -0
  101. diffract/core/storage/__init__.py +35 -0
  102. diffract/core/storage/base_manager.py +330 -0
  103. diffract/core/storage/containers.py +122 -0
  104. diffract/core/storage/hdf5_manager.py +856 -0
  105. diffract/core/storage/hybrid_manager.py +218 -0
  106. diffract/core/storage/interface.py +222 -0
  107. diffract/core/storage/metadata.py +89 -0
  108. diffract/core/storage/ram_manager.py +159 -0
  109. diffract/core/storage/sqlite_manager.py +1062 -0
  110. diffract/core/storage/zarr_manager.py +992 -0
  111. diffract/core/utils/__init__.py +45 -0
  112. diffract/core/utils/build.py +46 -0
  113. diffract/core/utils/exceptions.py +11 -0
  114. diffract/core/utils/hashing.py +90 -0
  115. diffract/core/utils/imports.py +292 -0
  116. diffract/core/utils/math.py +15 -0
  117. diffract/py.typed +0 -0
  118. diffract/session/__init__.py +24 -0
  119. diffract/session/errors.py +17 -0
  120. diffract/session/field_cache.py +216 -0
  121. diffract/session/namespaces/__init__.py +15 -0
  122. diffract/session/namespaces/compute/__init__.py +219 -0
  123. diffract/session/namespaces/models/__init__.py +282 -0
  124. diffract/session/namespaces/models/parameters/__init__.py +133 -0
  125. diffract/session/namespaces/models/parameters/meta_patcher.py +167 -0
  126. diffract/session/namespaces/results/__init__.py +378 -0
  127. diffract/session/namespaces/results/eraser.py +129 -0
  128. diffract/session/namespaces/results/injester.py +249 -0
  129. diffract/session/namespaces/utils/__init__.py +141 -0
  130. diffract/session/namespaces/utils/merger.py +295 -0
  131. diffract/session/namespaces/viz/__init__.py +91 -0
  132. diffract/session/namespaces/viz/_utils.py +14 -0
  133. diffract/session/namespaces/viz/box.py +207 -0
  134. diffract/session/namespaces/viz/grid.py +160 -0
  135. diffract/session/namespaces/viz/heatmap.py +164 -0
  136. diffract/session/namespaces/viz/scatter.py +202 -0
  137. diffract/session/namespaces/viz/sparkline.py +270 -0
  138. diffract/session/namespaces/viz/violin.py +212 -0
  139. diffract/session/session.py +260 -0
  140. diffract/session/utils.py +81 -0
  141. diffract/viz/__init__.py +42 -0
  142. diffract/viz/data/__init__.py +34 -0
  143. diffract/viz/data/detection.py +57 -0
  144. diffract/viz/data/extraction.py +117 -0
  145. diffract/viz/data/filtering.py +57 -0
  146. diffract/viz/data/ordering.py +129 -0
  147. diffract/viz/data/provider.py +99 -0
  148. diffract/viz/data/types.py +98 -0
  149. diffract/viz/plots/__init__.py +37 -0
  150. diffract/viz/plots/base/__init__.py +20 -0
  151. diffract/viz/plots/base/axis.py +219 -0
  152. diffract/viz/plots/base/coloraxis.py +133 -0
  153. diffract/viz/plots/base/configurator.py +18 -0
  154. diffract/viz/plots/base/jitter.py +368 -0
  155. diffract/viz/plots/base/line.py +197 -0
  156. diffract/viz/plots/base/marker.py +278 -0
  157. diffract/viz/plots/base/overlay.py +14 -0
  158. diffract/viz/plots/base/plot.py +110 -0
  159. diffract/viz/plots/base/update.py +50 -0
  160. diffract/viz/plots/boxplot.py +283 -0
  161. diffract/viz/plots/cluster.py +374 -0
  162. diffract/viz/plots/heatmap.py +168 -0
  163. diffract/viz/plots/scatter.py +233 -0
  164. diffract/viz/plots/sparkline.py +405 -0
  165. diffract/viz/plots/subplots/__init__.py +32 -0
  166. diffract/viz/plots/subplots/coloraxis.py +339 -0
  167. diffract/viz/plots/subplots/factory.py +713 -0
  168. diffract/viz/plots/subplots/grid.py +214 -0
  169. diffract/viz/plots/subplots/layout.py +370 -0
  170. diffract/viz/plots/subplots/spec.py +39 -0
  171. diffract/viz/plots/violin.py +298 -0
  172. diffract/viz/renderer.py +513 -0
  173. diffract/viz/styling/__init__.py +78 -0
  174. diffract/viz/styling/palettes/__init__.py +20 -0
  175. diffract/viz/styling/palettes/bundle.py +16 -0
  176. diffract/viz/styling/palettes/color.py +83 -0
  177. diffract/viz/styling/palettes/dashes.py +27 -0
  178. diffract/viz/styling/palettes/symbols.py +42 -0
  179. diffract/viz/styling/resolvers/__init__.py +11 -0
  180. diffract/viz/styling/resolvers/categorical.py +68 -0
  181. diffract/viz/styling/resolvers/color.py +77 -0
  182. diffract/viz/styling/resolvers/numeric.py +108 -0
  183. diffract/viz/styling/sources.py +27 -0
  184. diffract/viz/styling/theme/__init__.py +29 -0
  185. diffract/viz/styling/theme/applier.py +114 -0
  186. diffract/viz/styling/theme/components.py +66 -0
  187. diffract/viz/styling/theme/presets.py +58 -0
  188. diffract/viz/styling/theme/theme.py +36 -0
  189. diffract_core-0.2.1.dist-info/METADATA +530 -0
  190. diffract_core-0.2.1.dist-info/RECORD +193 -0
  191. diffract_core-0.2.1.dist-info/WHEEL +4 -0
  192. diffract_core-0.2.1.dist-info/licenses/LICENSE +201 -0
  193. diffract_core-0.2.1.dist-info/licenses/NOTICE +2 -0
@@ -0,0 +1,168 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, replace
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+ import plotly.graph_objects as go
8
+
9
+ from diffract.viz.data import (
10
+ DataType,
11
+ Entry,
12
+ FieldRef,
13
+ )
14
+ from diffract.viz.plots.base.axis import AxisType, SupportsAxis
15
+ from diffract.viz.plots.base.coloraxis import SupportsColoraxis
16
+ from diffract.viz.plots.base.plot import Plot
17
+ from diffract.viz.styling import (
18
+ CategoricalPropertyResolver,
19
+ NumericPropertyResolver,
20
+ )
21
+
22
+
23
+ @dataclass(kw_only=True)
24
+ class HeatmapPlot(
25
+ Plot,
26
+ SupportsAxis("x", AxisType.CATEGORICAL),
27
+ SupportsAxis("y", AxisType.CATEGORICAL),
28
+ SupportsColoraxis("heatmap"),
29
+ ):
30
+ """Heatmap plot that pivots a scalar field by two categorical dimensions.
31
+
32
+ Each cell in the heatmap represents a value of ``z`` at the intersection
33
+ of a ``y`` row and an ``x`` column. When multiple entries map to the
34
+ same (x, y) pair the **last** value wins (no aggregation).
35
+
36
+ Coloraxis settings (colorscale, cmin/cmax, colorbar) are inherited from
37
+ the ``SupportsColoraxis("heatmap")`` mixin and can be controlled via
38
+ ``heatmap_colorscale``, ``heatmap_cmin``, ``heatmap_cmax``, etc.
39
+ """
40
+
41
+ z: FieldRef
42
+ z_rescale_range: tuple[float, float] | None = None
43
+
44
+ y: FieldRef
45
+ x: FieldRef
46
+
47
+ fill_value: float = float("nan")
48
+
49
+ show_text: bool = False
50
+ text_format: str = ".2f"
51
+ text_font_size: int = 10
52
+
53
+ reverse_y: bool = True
54
+
55
+ def _build_traces_data(
56
+ self, entries: dict[str, Entry]
57
+ ) -> dict[str, dict[str, Any]]:
58
+ """Build a single trace-data dict containing the full z-matrix."""
59
+ x_resolver = y_resolver = CategoricalPropertyResolver()
60
+ z_resolver = NumericPropertyResolver(self.z_rescale_range)
61
+
62
+ x_ref = replace(self.x, data_type=self.x.data_type or DataType.CATEGORICAL)
63
+ y_ref = replace(self.y, data_type=self.y.data_type or DataType.CATEGORICAL)
64
+ x_values = x_resolver.resolve(x_ref, entries)
65
+ y_values = y_resolver.resolve(y_ref, entries)
66
+ z_values = z_resolver.resolve(self.z, entries)
67
+
68
+ sorted_x = self._get_sorted_unique(x_values, self.x)
69
+ sorted_y = self._get_sorted_unique(y_values, self.y)
70
+
71
+ cell_values = self._collect_cell_values(x_values, y_values, z_values)
72
+ z_matrix = self._build_matrix(sorted_x, sorted_y, cell_values)
73
+
74
+ traces_data: dict[str, dict[str, Any]] = {
75
+ "heatmap": {
76
+ "x_labels": sorted_x,
77
+ "y_labels": sorted_y,
78
+ "z_matrix": z_matrix,
79
+ },
80
+ }
81
+ return traces_data
82
+
83
+ @staticmethod
84
+ def _get_sorted_unique(values: list[Any], ref: FieldRef) -> list[str]:
85
+ """Return unique values sorted according to the field's ordering."""
86
+ unique = list(dict.fromkeys(values))
87
+ order_indices = ref.ordering.argsort(unique)
88
+ return [str(unique[i]) for i in order_indices]
89
+
90
+ @staticmethod
91
+ def _collect_cell_values(
92
+ x_values: list[Any],
93
+ y_values: list[Any],
94
+ z_values: list[Any],
95
+ ) -> dict[tuple[str, str], float]:
96
+ """Map (x, y) string pairs to scalar z-values."""
97
+ cell_values: dict[tuple[str, str], float] = {}
98
+ for x_val, y_val, z_val in zip(x_values, y_values, z_values, strict=False):
99
+ if z_val is None:
100
+ continue
101
+ cell_values[(str(x_val), str(y_val))] = float(z_val)
102
+ return cell_values
103
+
104
+ def _build_matrix(
105
+ self,
106
+ sorted_x: list[str],
107
+ sorted_y: list[str],
108
+ cell_values: dict[tuple[str, str], float],
109
+ ) -> np.ndarray:
110
+ """Build a 2-D z-matrix (rows=y, cols=x) from cell values."""
111
+ z_matrix = np.full(
112
+ (len(sorted_y), len(sorted_x)),
113
+ self.fill_value,
114
+ dtype=np.float64,
115
+ )
116
+ x_idx = {label: j for j, label in enumerate(sorted_x)}
117
+ y_idx = {label: i for i, label in enumerate(sorted_y)}
118
+
119
+ for (x_label, y_label), value in cell_values.items():
120
+ j = x_idx.get(x_label)
121
+ i = y_idx.get(y_label)
122
+ if i is not None and j is not None:
123
+ z_matrix[i, j] = value
124
+
125
+ return z_matrix
126
+
127
+ def _build_figure(self) -> go.Figure:
128
+ """Build the Plotly figure with a single Heatmap trace."""
129
+ fig = go.Figure()
130
+
131
+ if self._traces_data is None:
132
+ return fig
133
+
134
+ trace_data = self._traces_data["heatmap"]
135
+ x_labels: list[str] = trace_data["x_labels"]
136
+ y_labels: list[str] = trace_data["y_labels"]
137
+ z_matrix: np.ndarray = trace_data["z_matrix"]
138
+
139
+ heatmap_kwargs: dict[str, Any] = {
140
+ "z": z_matrix,
141
+ "x": x_labels,
142
+ "y": y_labels,
143
+ "coloraxis": self.resolve_coloraxis(fig),
144
+ }
145
+
146
+ if self.show_text:
147
+ text = np.vectorize(
148
+ lambda val: "" if np.isnan(val) else format(val, self.text_format),
149
+ )(z_matrix)
150
+ heatmap_kwargs["text"] = text
151
+ heatmap_kwargs["texttemplate"] = "%{text}"
152
+ heatmap_kwargs["textfont"] = {"size": self.text_font_size}
153
+
154
+ fig.add_trace(go.Heatmap(**heatmap_kwargs))
155
+
156
+ # Title
157
+ if self.title:
158
+ fig.update_layout(title=self.title)
159
+ else:
160
+ fig.update_layout(
161
+ title=f"{self.z.field} by {self.y.field} x {self.x.field}"
162
+ )
163
+
164
+ # Reverse y-axis so the first row is at the top (matrix convention)
165
+ if self.reverse_y:
166
+ fig.update_yaxes(autorange="reversed")
167
+
168
+ return fig
@@ -0,0 +1,233 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ import plotly.graph_objects as go
7
+
8
+ from diffract.viz.data import (
9
+ Entry,
10
+ FieldRef,
11
+ get_field_values,
12
+ )
13
+ from diffract.viz.plots.base.axis import AxisType, SupportsAxis
14
+ from diffract.viz.plots.base.marker import SupportsMarker
15
+ from diffract.viz.plots.base.plot import Plot
16
+ from diffract.viz.styling import (
17
+ NumericPropertyResolver,
18
+ ResolvedColor,
19
+ )
20
+
21
+
22
+ @dataclass(kw_only=True)
23
+ class ScatterPlot(
24
+ Plot,
25
+ SupportsAxis("x", AxisType.NUMERIC),
26
+ SupportsAxis("y", AxisType.NUMERIC),
27
+ SupportsMarker,
28
+ ):
29
+ """Scatter plot for two numeric fields."""
30
+
31
+ y: FieldRef
32
+ y_rescale_range: tuple[float, float] | None = None
33
+ y_rescale_traces_separately: bool = False
34
+
35
+ x: FieldRef
36
+ x_rescale_range: tuple[float, float] | None = None
37
+ x_rescale_traces_separately: bool = False
38
+
39
+ group_by: FieldRef | None = None
40
+
41
+ def _build_traces_data(
42
+ self, entries: dict[str, Entry]
43
+ ) -> dict[str, dict[str, Any]]:
44
+ """Build per-group trace data dicts."""
45
+ x_resolver = NumericPropertyResolver(self.x_rescale_range)
46
+ y_resolver = NumericPropertyResolver(self.y_rescale_range)
47
+
48
+ x_values = x_resolver.resolve(self.x, entries)
49
+ y_values = y_resolver.resolve(self.y, entries)
50
+
51
+ groups, group_indices = self._group_entries(entries, x_values, y_values)
52
+
53
+ # 1) Resolve marker properties globally for all entries
54
+ resolved_color = self._resolve_marker_color(entries, self._theme)
55
+ resolved_symbol = self._resolve_marker_symbol(entries, self._theme)
56
+ resolved_size = self._resolve_marker_size(entries, self._theme)
57
+ resolved_opacity = self._resolve_marker_opacity(entries, self._theme)
58
+
59
+ group_keys = self._get_sorted_group_keys(groups)
60
+
61
+ # 2) Build per-group traces
62
+ traces_data: dict[str, dict[str, Any]] = {}
63
+
64
+ for idx, group in enumerate(group_keys):
65
+ points = groups[group]
66
+ indices = group_indices[group]
67
+
68
+ trace_x = [p[0] for p in points]
69
+ if self.x_rescale_traces_separately:
70
+ trace_x = x_resolver._normalize(trace_x)
71
+
72
+ trace_y = [p[1] for p in points]
73
+ if self.y_rescale_traces_separately:
74
+ trace_y = y_resolver._normalize(trace_y)
75
+
76
+ trace_id = f"scatter_{group}"
77
+ trace_data: dict[str, Any] = {
78
+ "group": group,
79
+ "group_idx": idx,
80
+ "x_values": trace_x,
81
+ "y_values": trace_y,
82
+ }
83
+
84
+ # 3) Pick per-group values and add to trace via mixin methods
85
+ self._add_marker_color_to_trace(
86
+ trace_data,
87
+ self._pick_group_color(indices, resolved_color),
88
+ )
89
+ self._add_marker_symbol_to_trace(
90
+ trace_data,
91
+ self._pick_group_scalar(indices, resolved_symbol),
92
+ )
93
+ self._add_marker_size_to_trace(
94
+ trace_data,
95
+ self._pick_group_values(indices, resolved_size),
96
+ )
97
+ self._add_marker_opacity_to_trace(
98
+ trace_data,
99
+ self._pick_group_values(indices, resolved_opacity),
100
+ )
101
+
102
+ traces_data[trace_id] = trace_data
103
+
104
+ return traces_data
105
+
106
+ def _group_entries(
107
+ self,
108
+ entries: dict[str, Entry],
109
+ x_values: list[Any],
110
+ y_values: list[Any],
111
+ ) -> tuple[dict[str, list[tuple[float, float]]], dict[str, list[int]]]:
112
+ """Group (x, y) pairs by the ``group_by`` field.
113
+
114
+ Returns:
115
+ groups: mapping from group key to list of (x, y) pairs.
116
+ group_indices: mapping from group key to original entry indices
117
+ (only entries with non-None x *and* y).
118
+ """
119
+ if self.group_by is not None:
120
+ group_values = get_field_values(entries, self.group_by.field)
121
+ else:
122
+ group_values = ["all"] * len(x_values)
123
+
124
+ groups: dict[str, list[tuple[float, float]]] = {}
125
+ group_indices: dict[str, list[int]] = {}
126
+
127
+ for i, (g_val, x_val, y_val) in enumerate(
128
+ zip(group_values, x_values, y_values, strict=False)
129
+ ):
130
+ if x_val is None or y_val is None:
131
+ continue
132
+ key = str(g_val) if g_val is not None else "null"
133
+ groups.setdefault(key, []).append((float(x_val), float(y_val)))
134
+ group_indices.setdefault(key, []).append(i)
135
+
136
+ return groups, group_indices
137
+
138
+ def _get_sorted_group_keys(self, groups: dict[str, list[Any]]) -> list[str]:
139
+ """Return group keys sorted according to ``group_by`` ordering."""
140
+ group_keys = list(groups.keys())
141
+ if self.group_by is None:
142
+ return group_keys
143
+ order_indices = self.group_by.ordering.argsort(group_keys)
144
+ return [str(group_keys[i]) for i in order_indices]
145
+
146
+ @staticmethod
147
+ def _pick_group_color(
148
+ indices: list[int],
149
+ resolved_color: ResolvedColor,
150
+ ) -> ResolvedColor:
151
+ """Build a per-group ResolvedColor from a global ResolvedColor.
152
+
153
+ For continuous ``values``, slices to entries belonging to this group.
154
+ For categorical per-entry colors, picks the first entry's color
155
+ (within a group all entries share the same categorical color).
156
+ For a single color string, passes through as-is.
157
+ """
158
+ if resolved_color.values is not None:
159
+ filtered = [resolved_color.values[i] for i in indices]
160
+ return ResolvedColor(values=filtered)
161
+
162
+ if resolved_color.color is not None:
163
+ if isinstance(resolved_color.color, list):
164
+ if not resolved_color.color or not indices:
165
+ return ResolvedColor()
166
+ return ResolvedColor(color=resolved_color.color[indices[0]])
167
+ return resolved_color
168
+
169
+ return resolved_color
170
+
171
+ @staticmethod
172
+ def _pick_group_scalar(
173
+ indices: list[int],
174
+ resolved: str | list[str] | None,
175
+ ) -> str | None:
176
+ """Pick a single categorical value for a group (first match).
177
+
178
+ If resolved is scalar or None — return as-is.
179
+ If resolved is a per-entry list — return the first entry's value.
180
+ """
181
+ if resolved is None or not isinstance(resolved, list):
182
+ return resolved
183
+ if not resolved or not indices:
184
+ return None
185
+ return resolved[indices[0]]
186
+
187
+ @staticmethod
188
+ def _pick_group_values(
189
+ indices: list[int],
190
+ resolved: float | list[float] | None,
191
+ ) -> float | list[float] | None:
192
+ """Slice a resolved numeric property for a group.
193
+
194
+ If resolved is scalar or None — return as-is.
195
+ If resolved is a per-entry list — filter to entries in this group.
196
+ """
197
+ if resolved is None or not isinstance(resolved, list):
198
+ return resolved
199
+ return [resolved[i] for i in indices]
200
+
201
+ def _build_figure(self) -> go.Figure:
202
+ """Build the Plotly figure with scatter traces."""
203
+ fig = go.Figure()
204
+
205
+ if self._traces_data is None:
206
+ return fig
207
+
208
+ for trace_id, trace_data in self._traces_data.items():
209
+ group = trace_data["group"]
210
+ x_vals = trace_data["x_values"]
211
+ y_vals = trace_data["y_values"]
212
+
213
+ if not x_vals:
214
+ continue
215
+
216
+ fig.add_trace(
217
+ go.Scatter(
218
+ x=x_vals,
219
+ y=y_vals,
220
+ name=group,
221
+ mode="markers",
222
+ legendgroup=group,
223
+ meta={"trace_id": trace_id},
224
+ )
225
+ )
226
+
227
+ # Title
228
+ if self.title:
229
+ fig.update_layout(title=self.title)
230
+ else:
231
+ fig.update_layout(title=f"{self.y.field} vs {self.x.field}")
232
+
233
+ return fig