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,133 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import field, make_dataclass
4
+ from typing import Any
5
+
6
+ import plotly.graph_objects as go
7
+
8
+ from .configurator import Configurator
9
+
10
+
11
+ def SupportsColoraxis(prefix: str) -> type[Configurator]: # noqa: N802 factory returns a class
12
+ """Factory that creates coloraxis configurator mixins."""
13
+ fields = _coloraxis_fields(prefix)
14
+
15
+ def configure(self: Any, fig: go.Figure) -> None:
16
+ _configure_coloraxis(self, fig, prefix)
17
+
18
+ def resolve_coloraxis(self: Any, fig: go.Figure) -> str:
19
+ return _resolve_coloraxis(self, fig, prefix)
20
+
21
+ return make_dataclass(
22
+ f"SupportsColoraxis_{prefix}",
23
+ fields=fields,
24
+ bases=(Configurator,),
25
+ namespace={
26
+ "prefix": prefix,
27
+ "configure": configure,
28
+ "resolve_coloraxis": resolve_coloraxis,
29
+ },
30
+ kw_only=True,
31
+ )
32
+
33
+
34
+ # --- Field definitions ---
35
+
36
+
37
+ def _coloraxis_fields(prefix: str) -> list[tuple[str, type, Any]]:
38
+ return [
39
+ (f"{prefix}_coloraxis_id", int | None, field(default=None)),
40
+ (f"{prefix}_colorscale", str, field(default="Viridis")),
41
+ (f"{prefix}_showscale", bool, field(default=True)),
42
+ (f"{prefix}_cmin", float | None, field(default=None)),
43
+ (f"{prefix}_cmax", float | None, field(default=None)),
44
+ (f"{prefix}_colorbar_title", str | None, field(default=None)),
45
+ (f"{prefix}_coloraxis_override", bool, field(default=False)),
46
+ ]
47
+
48
+
49
+ # --- Configure helper ---
50
+
51
+
52
+ def _configure_coloraxis(self: Any, fig: go.Figure, prefix: str) -> None:
53
+ def _get(suffix: str) -> Any:
54
+ return getattr(self, f"{prefix}_{suffix}")
55
+
56
+ coloraxis_name = _resolve_coloraxis(self, fig, prefix)
57
+
58
+ if _coloraxis_exists(fig, coloraxis_name) and not _get("coloraxis_override"):
59
+ return
60
+
61
+ config: dict[str, Any] = {
62
+ "colorscale": _get("colorscale"),
63
+ "showscale": _get("showscale"),
64
+ }
65
+
66
+ cmin = _get("cmin")
67
+ if cmin is not None:
68
+ config["cmin"] = cmin
69
+
70
+ cmax = _get("cmax")
71
+ if cmax is not None:
72
+ config["cmax"] = cmax
73
+
74
+ colorbar_title = _get("colorbar_title")
75
+ if colorbar_title is not None:
76
+ config["colorbar"] = {"title": colorbar_title}
77
+
78
+ fig.update_layout(**{coloraxis_name: config})
79
+
80
+
81
+ def _resolve_coloraxis(self: Any, fig: go.Figure, prefix: str) -> str:
82
+ """Resolve coloraxis id and return the coloraxis name (e.g. 'coloraxis2')."""
83
+ cache_attr = f"__{prefix}_resolved_coloraxis"
84
+
85
+ cached = getattr(self, cache_attr, None)
86
+ if cached is not None:
87
+ return cached
88
+
89
+ coloraxis_id = getattr(self, f"{prefix}_coloraxis_id")
90
+
91
+ if coloraxis_id is not None:
92
+ result = _coloraxis_name(coloraxis_id)
93
+ else:
94
+ result = _next_coloraxis_name(fig)
95
+
96
+ object.__setattr__(self, cache_attr, result)
97
+ return result
98
+
99
+
100
+ # --- Helpers ---
101
+
102
+
103
+ def _coloraxis_name(coloraxis_id: int) -> str:
104
+ """Convert coloraxis id to Plotly coloraxis name."""
105
+ if coloraxis_id == 1:
106
+ return "coloraxis"
107
+ return f"coloraxis{coloraxis_id}"
108
+
109
+
110
+ def _coloraxis_exists(fig: go.Figure, coloraxis_name: str) -> bool:
111
+ """Check if coloraxis already exists in figure layout."""
112
+ return getattr(fig.layout, coloraxis_name, None) is not None
113
+
114
+
115
+ def _next_coloraxis_name(fig: go.Figure) -> str:
116
+ """Find next available coloraxis name in figure."""
117
+ layout = fig.layout
118
+ existing_ids: list[int] = []
119
+
120
+ for key in dir(layout):
121
+ if key == "coloraxis":
122
+ if getattr(layout, key, None) is not None:
123
+ existing_ids.append(1)
124
+ elif (
125
+ key.startswith("coloraxis")
126
+ and key[9:].isdigit()
127
+ and getattr(layout, key, None) is not None
128
+ ):
129
+ existing_ids.append(int(key[9:]))
130
+
131
+ next_id = max(existing_ids, default=0) + 1
132
+
133
+ return _coloraxis_name(next_id)
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+ import plotly.graph_objects as go
6
+
7
+
8
+ class Configurator(ABC):
9
+ """A mixin that can configure a Plotly figure.
10
+
11
+ In `viz`, configurators are intended to be used as mixins on a plot
12
+ class and invoked in MRO order by `Plot._configure_by_mro(...)`.
13
+ """
14
+
15
+ @abstractmethod
16
+ def configure(self, fig: go.Figure) -> None:
17
+ """Apply configuration to the given figure."""
18
+ raise NotImplementedError
@@ -0,0 +1,368 @@
1
+ """Jitter overlay mixin for categorical plots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import numpy as np
9
+ import plotly.graph_objects as go
10
+
11
+ from diffract.viz.data import Entry
12
+ from diffract.viz.styling import (
13
+ ColorResolver,
14
+ ColorSource,
15
+ DefaultColorPalette,
16
+ ResolvedColor,
17
+ )
18
+
19
+ from .coloraxis import SupportsColoraxis
20
+ from .overlay import Overlay
21
+
22
+ if TYPE_CHECKING:
23
+ from diffract.viz.styling import Theme
24
+
25
+ _JITTER_TRACE_ID_SUFFIX = "_jitter"
26
+
27
+
28
+ def density_scaled_jitter(
29
+ y: np.ndarray,
30
+ jitter: np.ndarray,
31
+ *,
32
+ n_bins: int = 20,
33
+ ) -> np.ndarray:
34
+ """Scale jitter by local density to reduce overlap in dense regions.
35
+
36
+ Points in denser regions get larger jitter spread, while isolated points
37
+ stay closer to the center.
38
+
39
+ Args:
40
+ y: Y-values for the points.
41
+ jitter: Base jitter values (uniform random).
42
+ n_bins: Number of bins for density estimation.
43
+
44
+ Returns:
45
+ Scaled jitter values.
46
+ """
47
+ if y.size == 0:
48
+ return jitter
49
+
50
+ y_min, y_max = np.nanmin(y), np.nanmax(y)
51
+ if y_min == y_max:
52
+ return jitter
53
+
54
+ bins = np.linspace(y_min, y_max, n_bins + 1)
55
+ counts, _ = np.histogram(y, bins=bins)
56
+ max_count = counts.max() if counts.max() > 0 else 1
57
+
58
+ bin_indices = np.clip(
59
+ np.digitize(y, bins) - 1,
60
+ 0,
61
+ n_bins - 1,
62
+ )
63
+ scale = counts[bin_indices] / max_count
64
+
65
+ return jitter * scale
66
+
67
+
68
+ @dataclass(slots=False, kw_only=True)
69
+ class SupportsJitter(Overlay, SupportsColoraxis("jitter")):
70
+ """Mixin that adds jitter scatter overlay to categorical plots.
71
+
72
+ Jitter displays individual data points with horizontal spread,
73
+ useful for showing distribution density alongside box/violin plots.
74
+
75
+ Creates ``go.Scatter`` traces with their own ``trace_id``
76
+ (``{parent_trace_id}_jitter``). Visual properties are applied
77
+ in :meth:`configure`:
78
+
79
+ - **Color**: from ``jitter_color`` if set, otherwise falls back
80
+ to the parent trace's ``marker_color`` / ``marker_color_values``.
81
+ - **Size, opacity, symbol**: inherited from the parent trace's
82
+ ``marker_size``, ``marker_opacity``, ``marker_symbol``.
83
+
84
+ Requires ``_traces_data`` to contain for each trace:
85
+ - ``y_values``: list of y values for points
86
+ - ``category_idx`` *or* ``jitter_x_center``: numeric x center
87
+
88
+ Optional per-trace key:
89
+ - ``jitter_xaxis``: Plotly x-axis reference for overlay points
90
+ (for example ``"x2"`` for a hidden linear axis overlaid on a
91
+ categorical ``x`` axis).
92
+ """
93
+
94
+ jitter_enabled: bool = False
95
+ jitter_width: float = 0.12
96
+ jitter_offset: float = -0.35
97
+ jitter_seed: int = 42
98
+ jitter_density_scale: bool = True
99
+ jitter_color: ColorSource = None
100
+
101
+ # --- Overlay: add geometry ------------------------------------------------
102
+
103
+ def add_overlay_traces(self, fig: go.Figure) -> None:
104
+ """Add jitter scatter traces if enabled."""
105
+ if not self.jitter_enabled:
106
+ return
107
+
108
+ if self._traces_data is None:
109
+ return
110
+
111
+ self._configure_jitter_overlay_xaxes(fig)
112
+
113
+ for trace_id, trace_data in self._traces_data.items():
114
+ self._add_jitter_trace(fig, trace_id, trace_data)
115
+
116
+ def _add_jitter_trace(
117
+ self,
118
+ fig: go.Figure,
119
+ trace_id: str,
120
+ trace_data: dict[str, Any],
121
+ ) -> None:
122
+ """Add a single jitter scatter trace for a category."""
123
+ y_values = trace_data.get("y_values", [])
124
+ if len(y_values) == 0:
125
+ return
126
+
127
+ x_center = _coerce_jitter_center(trace_data)
128
+ if x_center is None:
129
+ return
130
+
131
+ y_arr = np.asarray(y_values, dtype=np.float64)
132
+ n_points = len(y_arr)
133
+
134
+ # Generate jittered x positions
135
+ rng = np.random.default_rng(self.jitter_seed)
136
+ jitter = rng.uniform(-self.jitter_width, self.jitter_width, size=n_points)
137
+
138
+ if self.jitter_density_scale:
139
+ jitter = density_scaled_jitter(y_arr, jitter)
140
+
141
+ x_jittered = (
142
+ np.full(n_points, x_center, dtype=np.float64) + self.jitter_offset + jitter
143
+ )
144
+
145
+ jitter_trace_id = f"{trace_id}{_JITTER_TRACE_ID_SUFFIX}"
146
+ scatter_kwargs: dict[str, Any] = {
147
+ "x": x_jittered,
148
+ "y": y_arr,
149
+ "mode": "markers",
150
+ "showlegend": False,
151
+ "meta": {"trace_id": jitter_trace_id},
152
+ }
153
+
154
+ jitter_xaxis = trace_data.get("jitter_xaxis")
155
+ if isinstance(jitter_xaxis, str) and jitter_xaxis:
156
+ scatter_kwargs["xaxis"] = jitter_xaxis
157
+
158
+ fig.add_trace(go.Scatter(**scatter_kwargs))
159
+
160
+ def _configure_jitter_overlay_xaxes(self, fig: go.Figure) -> None:
161
+ """Ensure overlay x-axes exist for jitter traces that need them."""
162
+ if self._traces_data is None:
163
+ return
164
+
165
+ centers_by_axis: dict[str, list[float]] = {}
166
+ for trace_data in self._traces_data.values():
167
+ jitter_xaxis = trace_data.get("jitter_xaxis")
168
+ if not isinstance(jitter_xaxis, str) or jitter_xaxis == "x":
169
+ continue
170
+
171
+ center = _coerce_jitter_center(trace_data)
172
+ if center is None:
173
+ continue
174
+
175
+ centers_by_axis.setdefault(jitter_xaxis, []).append(center)
176
+
177
+ for axis_ref, centers in centers_by_axis.items():
178
+ self._configure_single_overlay_xaxis(
179
+ fig, axis_ref=axis_ref, centers=centers
180
+ )
181
+
182
+ def _configure_single_overlay_xaxis(
183
+ self,
184
+ fig: go.Figure,
185
+ *,
186
+ axis_ref: str,
187
+ centers: list[float],
188
+ ) -> None:
189
+ """Configure one hidden linear axis overlaid on the primary x-axis."""
190
+ if not axis_ref.startswith("x") or not centers:
191
+ return
192
+
193
+ suffix = axis_ref[1:]
194
+ axis_layout_key = "xaxis" if not suffix else f"xaxis{suffix}"
195
+
196
+ # Keep jitter points near category centers while allowing configured offsets.
197
+ half_span = max(0.5, abs(self.jitter_offset) + self.jitter_width + 0.05)
198
+ axis_range = [min(centers) - half_span, max(centers) + 0.5 * half_span]
199
+
200
+ fig.update_layout(
201
+ **{
202
+ axis_layout_key: {
203
+ "overlaying": "x",
204
+ "showgrid": False,
205
+ "zeroline": False,
206
+ "showline": False,
207
+ "showticklabels": False,
208
+ "ticks": "",
209
+ "range": axis_range,
210
+ }
211
+ }
212
+ )
213
+
214
+ # --- Configurator: apply visuals ------------------------------------------
215
+
216
+ def configure(self, fig: go.Figure) -> None:
217
+ """Apply marker styling to jitter traces.
218
+
219
+ Color is taken from ``jitter_color`` / ``jitter_color_values``
220
+ in trace data if present, otherwise falls back to
221
+ ``marker_color`` / ``marker_color_values``. Size, opacity
222
+ and symbol are always inherited from the parent marker.
223
+ """
224
+ super().configure(fig) # SupportsColoraxis("jitter") layout
225
+
226
+ if not self.jitter_enabled or self._traces_data is None:
227
+ return
228
+
229
+ coloraxis = self.resolve_coloraxis(fig)
230
+
231
+ for trace_id, trace_data in self._traces_data.items():
232
+ jitter_trace_id = f"{trace_id}{_JITTER_TRACE_ID_SUFFIX}"
233
+ kwargs = _build_jitter_marker_kwargs(trace_data, coloraxis)
234
+
235
+ if kwargs:
236
+ fig.update_traces(
237
+ marker=kwargs,
238
+ selector=lambda t, jtid=jitter_trace_id: (
239
+ t.meta and t.meta.get("trace_id") == jtid
240
+ ),
241
+ )
242
+
243
+ # --- Resolution helpers (called by concrete plots in _build_traces_data) --
244
+
245
+ def _resolve_jitter_color(
246
+ self,
247
+ entries: dict[str, Entry] | None,
248
+ theme: Theme | None = None,
249
+ ) -> ResolvedColor | None:
250
+ """Resolve ``jitter_color`` source.
251
+
252
+ Returns ``None`` when ``jitter_color`` is not set, signalling
253
+ that :meth:`configure` should fall back to the parent's
254
+ ``marker_color``.
255
+ """
256
+ if self.jitter_color is None:
257
+ return None
258
+ color_palette = theme.palettes.color if theme else DefaultColorPalette()
259
+ resolver = ColorResolver(color_palette)
260
+ return resolver.resolve(self.jitter_color, entries)
261
+
262
+ def _add_jitter_color_to_trace(
263
+ self,
264
+ trace_data: dict[str, Any],
265
+ resolved_color: ResolvedColor | None,
266
+ ) -> None:
267
+ """Add resolved jitter color to trace data.
268
+
269
+ Vector values (numpy arrays) are flattened to match the
270
+ observation-level expansion performed by ``_group_by_category``.
271
+ """
272
+ if resolved_color is None:
273
+ return
274
+ if resolved_color.values is not None:
275
+ trace_data["jitter_color_values"] = _flatten_color_values(
276
+ resolved_color.values,
277
+ )
278
+ elif resolved_color.color is not None:
279
+ trace_data["jitter_color"] = resolved_color.color
280
+
281
+
282
+ # --- Private helpers ----------------------------------------------------------
283
+
284
+
285
+ def _build_jitter_marker_kwargs(
286
+ trace_data: dict[str, Any],
287
+ coloraxis: str | None,
288
+ ) -> dict[str, Any]:
289
+ """Build marker kwargs for a jitter trace.
290
+
291
+ Reads jitter-specific color keys first, falling back to the parent
292
+ trace's marker color when no jitter color is present.
293
+ """
294
+ kwargs: dict[str, Any] = {}
295
+
296
+ # Inherit size, opacity, symbol from parent marker
297
+ size = trace_data.get("marker_size")
298
+ if size is not None:
299
+ kwargs["size"] = size
300
+
301
+ opacity = trace_data.get("marker_opacity")
302
+ if opacity is not None:
303
+ kwargs["opacity"] = opacity
304
+
305
+ symbol = trace_data.get("marker_symbol")
306
+ if symbol is not None:
307
+ kwargs["symbol"] = symbol
308
+
309
+ # Color: jitter-specific keys take priority over marker fallback
310
+ jitter_color = trace_data.get("jitter_color")
311
+ jitter_color_values = trace_data.get("jitter_color_values")
312
+
313
+ if jitter_color_values is not None:
314
+ kwargs["color"] = jitter_color_values
315
+ if coloraxis is not None:
316
+ kwargs["coloraxis"] = coloraxis
317
+ elif jitter_color is not None:
318
+ kwargs["color"] = jitter_color
319
+ else:
320
+ # Fallback to parent marker color.
321
+ # Flatten potential arrays (vector fields stored by SupportsMarker).
322
+ marker_color = trace_data.get("marker_color")
323
+ marker_color_values = trace_data.get("marker_color_values")
324
+
325
+ if marker_color_values is not None:
326
+ kwargs["color"] = _flatten_color_values(marker_color_values)
327
+ if coloraxis is not None:
328
+ kwargs["coloraxis"] = coloraxis
329
+ elif marker_color is not None:
330
+ kwargs["color"] = marker_color
331
+
332
+ return kwargs
333
+
334
+
335
+ def _flatten_color_values(values: list[Any]) -> list[float]:
336
+ """Flatten per-entry color values, expanding numpy arrays.
337
+
338
+ Scalar values are converted to ``float``. Array values are
339
+ ``ravel()``-ed and their elements appended individually, matching
340
+ the observation-level expansion performed by ``_group_by_category``.
341
+
342
+ If all values are already scalar, the output is equivalent to a
343
+ plain ``[float(v) for v in values]``.
344
+ """
345
+ flat: list[float] = []
346
+ for v in values:
347
+ if isinstance(v, np.ndarray):
348
+ flat.extend(v.ravel().tolist())
349
+ elif v is None:
350
+ flat.append(float("nan"))
351
+ else:
352
+ flat.append(float(v))
353
+ return flat
354
+
355
+
356
+ def _coerce_jitter_center(trace_data: dict[str, Any]) -> float | None:
357
+ """Extract a numeric x-center for jitter from trace data."""
358
+ x_center = trace_data.get("jitter_x_center")
359
+ if x_center is None:
360
+ x_center = trace_data.get("category_idx")
361
+
362
+ if x_center is None:
363
+ return None
364
+
365
+ try:
366
+ return float(x_center)
367
+ except (TypeError, ValueError):
368
+ return None