cellestial 0.58.0__tar.gz → 0.58.2__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.
Files changed (66) hide show
  1. {cellestial-0.58.0 → cellestial-0.58.2}/PKG-INFO +18 -1
  2. {cellestial-0.58.0 → cellestial-0.58.2}/README.md +17 -0
  3. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/datasets/datasets.py +2 -1
  4. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/layers/arrow.py +14 -4
  5. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/layers/ondata_legend.py +2 -2
  6. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/layers/outline.py +3 -2
  7. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/layers/stream.py +3 -1
  8. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/single/common/xyplot.py +2 -0
  9. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/single/core/dimensional.py +2 -0
  10. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/single/core/utilities.py +2 -1
  11. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/single/differential/utilities.py +8 -0
  12. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/single/differential/volcano.py +0 -4
  13. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/single/heatmap/dotplot.py +1 -0
  14. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/single/heatmap/heatmap.py +19 -13
  15. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/single/heatmap/stacked_violin.py +2 -1
  16. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/single/heatmap/utilities.py +9 -3
  17. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/single/quick/highest.py +3 -2
  18. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/single/quick/ridge.py +3 -2
  19. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/spatial/spatial.py +12 -0
  20. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/util/__init__.py +2 -0
  21. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/util/operations.py +19 -12
  22. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/util/utilities.py +16 -0
  23. {cellestial-0.58.0 → cellestial-0.58.2}/pyproject.toml +7 -2
  24. {cellestial-0.58.0 → cellestial-0.58.2}/LICENSE +0 -0
  25. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/__init__.py +0 -0
  26. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/_version.py +0 -0
  27. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/complex/__init__.py +0 -0
  28. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/complex/annotated_heatmap.py +0 -0
  29. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/datasets/__init__.py +0 -0
  30. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/frames/__init__.py +0 -0
  31. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/frames/build.py +0 -0
  32. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/frames/operations.py +0 -0
  33. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/layers/__init__.py +0 -0
  34. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/layers/_deferred.py +0 -0
  35. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/layers/bracket.py +0 -0
  36. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/layers/utilities.py +0 -0
  37. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/single/__init__.py +0 -0
  38. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/single/base/__init__.py +0 -0
  39. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/single/base/base.py +0 -0
  40. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/single/basic/__init__.py +0 -0
  41. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/single/basic/bar.py +0 -0
  42. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/single/basic/scatter.py +0 -0
  43. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/single/common/__init__.py +0 -0
  44. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/single/common/xyplots.py +0 -0
  45. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/single/core/__init__.py +0 -0
  46. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/single/core/distribution.py +0 -0
  47. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/single/core/distributions.py +0 -0
  48. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/single/core/subdimensional.py +0 -0
  49. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/single/core/subdimensionals.py +0 -0
  50. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/single/differential/__init__.py +0 -0
  51. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/single/differential/markers.py +0 -0
  52. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/single/heatmap/__init__.py +0 -0
  53. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/single/quick/__init__.py +0 -0
  54. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/single/quick/elbow.py +0 -0
  55. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/spatial/__init__.py +0 -0
  56. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/spatial/spatials.py +0 -0
  57. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/spatial/utilities.py +0 -0
  58. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/themes/__init__.py +0 -0
  59. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/themes/_distributions.py +0 -0
  60. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/themes/_heatmaps.py +0 -0
  61. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/themes/_scatters.py +0 -0
  62. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/util/colors.py +0 -0
  63. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/util/dendrogram.py +0 -0
  64. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/util/errors.py +0 -0
  65. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/util/markers.py +0 -0
  66. {cellestial-0.58.0 → cellestial-0.58.2}/cellestial/util/save.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cellestial
3
- Version: 0.58.0
3
+ Version: 0.58.2
4
4
  Summary: Interactive single-cell and spatial omics visualization library
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -46,6 +46,7 @@ Description-Content-Type: text/markdown
46
46
  [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-ff0000)](https://opensource.org/licenses/Apache-2.0)
47
47
  [![Powered by Polars](https://img.shields.io/badge/Powered%20by-Polars-377eb8?logo=polars&logoColor=white)](https://www.pola.rs/)
48
48
  [![Graphics: Lets-Plot](https://img.shields.io/badge/Graphics-Lets--Plot-FF00CC?logo=jetbrains&logoColor=white)](https://lets-plot.org/)
49
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21710312.svg)](https://doi.org/10.5281/zenodo.21710312)
49
50
 
50
51
  <img align="left" src="https://raw.githubusercontent.com/datavil/cellestial/master/assets/cellestial.anim.svg" alt="Cellestial Logo" width="280">
51
52
 
@@ -270,6 +271,22 @@ See the [migration guide](https://cellestial.datavil.org/migrating_from_scanpy.h
270
271
  - [API reference](https://cellestial.datavil.org/API.html) — every function.
271
272
  - [Migrating from Scanpy](https://cellestial.datavil.org/migrating_from_scanpy.html).
272
273
 
274
+ ## Citation
275
+
276
+ If you use Cellestial, please cite:
277
+
278
+ ```bibtex
279
+ @misc{https://doi.org/10.5281/zenodo.21710312,
280
+ doi = {10.5281/ZENODO.21710312},
281
+ url = {https://zenodo.org/doi/10.5281/zenodo.21710312},
282
+ author = {Kosar, Zafer},
283
+ title = {Cellestial: A grammar of graphics for single-cell omics},
284
+ publisher = {Zenodo},
285
+ year = {2026},
286
+ copyright = {Apache License 2.0}
287
+ }
288
+ ```
289
+
273
290
  ## License
274
291
 
275
292
  Apache 2.0. See [LICENSE](LICENSE).
@@ -6,6 +6,7 @@
6
6
  [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-ff0000)](https://opensource.org/licenses/Apache-2.0)
7
7
  [![Powered by Polars](https://img.shields.io/badge/Powered%20by-Polars-377eb8?logo=polars&logoColor=white)](https://www.pola.rs/)
8
8
  [![Graphics: Lets-Plot](https://img.shields.io/badge/Graphics-Lets--Plot-FF00CC?logo=jetbrains&logoColor=white)](https://lets-plot.org/)
9
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21710312.svg)](https://doi.org/10.5281/zenodo.21710312)
9
10
 
10
11
  <img align="left" src="https://raw.githubusercontent.com/datavil/cellestial/master/assets/cellestial.anim.svg" alt="Cellestial Logo" width="280">
11
12
 
@@ -230,6 +231,22 @@ See the [migration guide](https://cellestial.datavil.org/migrating_from_scanpy.h
230
231
  - [API reference](https://cellestial.datavil.org/API.html) — every function.
231
232
  - [Migrating from Scanpy](https://cellestial.datavil.org/migrating_from_scanpy.html).
232
233
 
234
+ ## Citation
235
+
236
+ If you use Cellestial, please cite:
237
+
238
+ ```bibtex
239
+ @misc{https://doi.org/10.5281/zenodo.21710312,
240
+ doi = {10.5281/ZENODO.21710312},
241
+ url = {https://zenodo.org/doi/10.5281/zenodo.21710312},
242
+ author = {Kosar, Zafer},
243
+ title = {Cellestial: A grammar of graphics for single-cell omics},
244
+ publisher = {Zenodo},
245
+ year = {2026},
246
+ copyright = {Apache License 2.0}
247
+ }
248
+ ```
249
+
233
250
  ## License
234
251
 
235
252
  Apache 2.0. See [LICENSE](LICENSE).
@@ -13,6 +13,7 @@ _UUID_PATTERN = re.compile(
13
13
  )
14
14
  _DOWNLOAD_TIMEOUT_SECONDS = 300
15
15
 
16
+
16
17
  def _resolve_cache_file(
17
18
  cache_directory: str | Path | PathLike,
18
19
  filename: str,
@@ -198,7 +199,7 @@ def from_url(
198
199
  # download cannot poison the cache.
199
200
  partial_file = cache_file.with_name(cache_file.name + ".part")
200
201
 
201
- with urlopen(url, timeout = _DOWNLOAD_TIMEOUT_SECONDS) as response:
202
+ with urlopen(url, timeout=_DOWNLOAD_TIMEOUT_SECONDS) as response:
202
203
  total_size = int(response.headers.get("Content-Length", 0))
203
204
  progress_bar = tqdm(
204
205
  total=total_size, unit="iB", unit_scale=True, desc=f"Downloading {filename}"
@@ -72,10 +72,20 @@ def _axis_arrow_layers(
72
72
  """
73
73
  arrow_kwargs = arrow_kwargs or {}
74
74
 
75
- x_min = frame[x].min()
76
- x_max = frame[x].max()
77
- y_min = frame[y].min()
78
- y_max = frame[y].max()
75
+ bounds = frame.select(
76
+ pl.col(x).min().alias("x_min"),
77
+ pl.col(x).max().alias("x_max"),
78
+ pl.col(y).min().alias("y_min"),
79
+ pl.col(y).max().alias("y_max"),
80
+ ).row(0, named=True)
81
+ if any(value is None for value in bounds.values()):
82
+ msg = "Arrow axes require at least one valid coordinate pair."
83
+ raise ValueError(msg)
84
+
85
+ x_min = bounds["x_min"]
86
+ x_max = bounds["x_max"]
87
+ y_min = bounds["y_min"]
88
+ y_max = bounds["y_max"]
79
89
 
80
90
  # find total difference between the max and min for both axis
81
91
  x_diff = x_max - x_min
@@ -6,7 +6,7 @@ import polars as pl
6
6
  from lets_plot import aes, geom_label, geom_label_repel, geom_text, geom_text_repel, theme
7
7
 
8
8
  from cellestial.layers._deferred import DeferredLayer
9
- from cellestial.util import get_mapping, retrieve
9
+ from cellestial.util import _drop_nonfinite_rows, get_mapping, retrieve
10
10
  from cellestial.util.errors import MissingAestheticError
11
11
 
12
12
  if TYPE_CHECKING:
@@ -22,7 +22,7 @@ def _compute_label_positions(
22
22
  group_by: str,
23
23
  ) -> DataFrame:
24
24
  """Aggregate per-group median coordinates for `geom_text` placement."""
25
- frame = frame.filter(pl.col(group_by).is_not_null())
25
+ frame = _drop_nonfinite_rows(frame, [x, y]).filter(pl.col(group_by).is_not_null())
26
26
  return frame.group_by(group_by).agg(
27
27
  pl.col(x).median(),
28
28
  pl.col(y).median(),
@@ -8,7 +8,7 @@ from lets_plot import aes, geom_path
8
8
  from scipy.stats import gaussian_kde
9
9
 
10
10
  from cellestial.layers._deferred import DeferredLayer
11
- from cellestial.util import get_mapping
11
+ from cellestial.util import _drop_nonfinite_rows, get_mapping
12
12
  from cellestial.util.errors import MissingAestheticError
13
13
 
14
14
  if TYPE_CHECKING:
@@ -50,7 +50,8 @@ def _get_density_boundaries(
50
50
  boundaries = []
51
51
  for group in groups:
52
52
  group_label = "+".join(group) if len(group) > 1 else group[0]
53
- points = frame.filter(pl.col(group_by).is_in(group)).select([x, y]).to_numpy()
53
+ group_frame = frame.filter(pl.col(group_by).is_in(group))
54
+ points = _drop_nonfinite_rows(group_frame, [x, y]).select([x, y]).to_numpy()
54
55
  if len(points) < 5:
55
56
  continue
56
57
 
@@ -12,7 +12,7 @@ from lets_plot import (
12
12
  )
13
13
 
14
14
  from cellestial.layers._deferred import DeferredLayer
15
- from cellestial.util import get_mapping, retrieve
15
+ from cellestial.util import _drop_nonfinite_rows, get_mapping, retrieve
16
16
  from cellestial.util.errors import MissingAestheticError
17
17
 
18
18
  if TYPE_CHECKING:
@@ -227,6 +227,8 @@ def stream(
227
227
  msg = "`scvelo` must be installed to use the stream layer."
228
228
  raise ImportError(msg) from err
229
229
 
230
+ frame = _drop_nonfinite_rows(frame, [x, y, x_velocity, y_velocity])
231
+
230
232
  # extract coordinates and velocities as numpy arrays
231
233
  dimensions = frame.select(pl.col(x), pl.col(y)).to_numpy()
232
234
  velocities = frame.select(pl.col(x_velocity), pl.col(y_velocity)).to_numpy()
@@ -17,6 +17,7 @@ from cellestial.themes import _THEME_SCATTER
17
17
  from cellestial.util import (
18
18
  _collect_aes_columns,
19
19
  _determine_axis,
20
+ _drop_nonfinite_rows,
20
21
  _resolve_tooltips,
21
22
  _select_variable_keys,
22
23
  _validate_tooltips,
@@ -221,6 +222,7 @@ def xyplot(
221
222
  include_dimensions=include_dimensions,
222
223
  metadata_columns=metadata_columns,
223
224
  )
225
+ frame = _drop_nonfinite_rows(frame, [x, y])
224
226
  _validate_tooltips(tooltips, frame)
225
227
 
226
228
  # BUILD: the scatterplot
@@ -24,6 +24,7 @@ from cellestial.themes import _THEME_DIMENSION
24
24
  from cellestial.util import (
25
25
  _collect_aes_columns,
26
26
  _color_gradient,
27
+ _drop_nonfinite_rows,
27
28
  _reject_sequence_key,
28
29
  _require_feature_key,
29
30
  _resolve_embedding_key,
@@ -282,6 +283,7 @@ def dimensional(
282
283
  include_dimensions=max(xy),
283
284
  metadata_columns=metadata_columns,
284
285
  )
286
+ frame = _drop_nonfinite_rows(frame, [x, y])
285
287
  _validate_tooltips(tooltips, frame)
286
288
 
287
289
  # HANDLE: groups filter (categorical-only)
@@ -25,6 +25,7 @@ from cellestial.themes import _THEME_DIST
25
25
  from cellestial.util import (
26
26
  _collect_aes_columns,
27
27
  _determine_axis,
28
+ _drop_nonfinite_rows,
28
29
  _resolve_tooltips,
29
30
  _validate_tooltips,
30
31
  _warn,
@@ -178,7 +179,7 @@ def _distribution(
178
179
  frame = frame.unpivot(
179
180
  on=keys, index=index, value_name=value_column, variable_name=variable_column
180
181
  )
181
- frame = frame.drop_nulls(subset=[value_column])
182
+ frame = _drop_nonfinite_rows(frame, [value_column])
182
183
  if threshold is not None:
183
184
  frame = frame.filter(pl.col(value_column) >= threshold)
184
185
  if group_by is None:
@@ -134,6 +134,13 @@ def _build_volcano_frame(
134
134
  else:
135
135
  raise _unsupported_data_type(data, AnnData)
136
136
 
137
+ frame = frame.filter(
138
+ pl.col(logfoldchange_column).is_not_null()
139
+ & pl.col(logfoldchange_column).is_finite()
140
+ & pl.col(pvalue_column).is_not_null()
141
+ & pl.col(pvalue_column).is_finite()
142
+ )
143
+
137
144
  # CRITICAL PARTS: Dataframe Operations
138
145
  # 1. -log10(pvalue) transform
139
146
  frame = frame.with_columns(
@@ -299,6 +306,7 @@ def _build_markers_frame(
299
306
  # Round scores to keep the embedded JSON compact when the frame is
300
307
  # serialized into the plot output.
301
308
  frame = frame.with_columns(pl.col(score_column).round(4))
309
+ frame = frame.filter(pl.col(score_column).is_not_null() & pl.col(score_column).is_finite())
302
310
 
303
311
  return frame, list(selected), str(stored_group_by)
304
312
 
@@ -248,10 +248,6 @@ def volcano(
248
248
  .otherwise(cap)
249
249
  .alias(neg_log_pvalue_column)
250
250
  )
251
- # logFC infinities mean the gene is expressed in only one group; fold change
252
- # is undefined, drop those rows.
253
- frame = frame.filter(pl.col(logfoldchange_column).is_finite())
254
-
255
251
  # SUBSAMPLE: cap non-significant points to keep the embedded data small.
256
252
  # The non-significant cloud overplots itself heavily, so dropping most of
257
253
  # it has no visible effect but shrinks the rendered plot's on-disk size.
@@ -315,6 +315,7 @@ def dotplot(
315
315
  variable_name=variable_column,
316
316
  value_name=value_name,
317
317
  )
318
+ frame = frame.filter(pl.col(value_name).is_not_null() & pl.col(value_name).is_finite())
318
319
  # 2. Aggregate and compute stats
319
320
  frame = frame.group_by([group_by, variable_column]).agg(
320
321
  [
@@ -27,6 +27,7 @@ from cellestial.single.heatmap.utilities import (
27
27
  _get_group_lines_frame,
28
28
  _key_groups_bar_y,
29
29
  _key_groups_layers,
30
+ _nonaggregate_y_step,
30
31
  _resolve_key_groups,
31
32
  _resolve_padding,
32
33
  _resolve_rank_genes_groups_args,
@@ -286,7 +287,7 @@ def heatmap(
286
287
  group_lines_size=0.5,
287
288
  group_lines_color="white",
288
289
  dendrogram=True,
289
- dendrogram_size="0.7",
290
+ dendrogram_size=0.7,
290
291
  group_bars_labels=True,
291
292
  group_bars=True,
292
293
  ) + scale_fill_viridis()
@@ -362,7 +363,8 @@ def heatmap(
362
363
  include_dimensions=include_dimensions,
363
364
  metadata_columns=metadata_columns,
364
365
  )
365
- index_columns = [group_by] if aggregate else [observations_name, group_by]
366
+ row_identifier = variables_name if axis == 1 else observations_name
367
+ index_columns = [group_by] if aggregate else [row_identifier, group_by]
366
368
  frame = frame.unpivot(
367
369
  on=keys,
368
370
  index=index_columns,
@@ -370,13 +372,15 @@ def heatmap(
370
372
  value_name=value_column,
371
373
  )
372
374
  if aggregate:
373
- frame = frame.group_by(group_by, variable_column).agg(pl.col(value_column).mean())
375
+ frame = frame.group_by(group_by, variable_column).agg(
376
+ pl.col(value_column).filter(pl.col(value_column).is_finite()).mean()
377
+ )
374
378
  frame = frame.drop_nulls()
375
379
 
376
380
  # HANDLE: standard scaling
377
381
  if scale_axis is not None:
378
382
  partition_key = (
379
- variable_column if scale_axis == 0 else (group_by if aggregate else observations_name)
383
+ variable_column if scale_axis == 0 else (group_by if aggregate else row_identifier)
380
384
  )
381
385
  frame = _scale_values(frame, value_column=value_column, partition_key=partition_key)
382
386
 
@@ -393,7 +397,7 @@ def heatmap(
393
397
  if not aggregate and max_rows is not None:
394
398
  frame = _bin_within_groups(
395
399
  frame,
396
- observations_name=observations_name,
400
+ observations_name=row_identifier,
397
401
  group_by=group_by,
398
402
  variable_column=variable_column,
399
403
  value_column=value_column,
@@ -407,7 +411,7 @@ def heatmap(
407
411
  frame,
408
412
  aggregate=aggregate,
409
413
  group_by=group_by,
410
- observations_name=observations_name,
414
+ observations_name=row_identifier,
411
415
  variable_column=variable_column,
412
416
  x_keys=x_keys,
413
417
  y_order_groups=y_order_groups,
@@ -428,15 +432,17 @@ def heatmap(
428
432
  # Compute top edge of data and the y limit, extending the latter when
429
433
  # key-group brackets are drawn so rotated labels are not clipped.
430
434
  if aggregate:
435
+ data_bottom = -0.5
431
436
  data_top = n_y - 0.5
432
437
  else:
433
- # ``position_y`` ranges from 0 to ``n_x - 1``; cells extend half a row above.
434
- half_step = (n_x - 1) / max(n_y - 1, 1) / 2
435
- data_top = (n_x - 1) + half_step
438
+ y_step = _nonaggregate_y_step(n_x, n_y)
439
+ half_step = y_step / 2
440
+ data_bottom = -half_step
441
+ data_top = y_step * max(n_y - 1, 0) + half_step
436
442
  y_max_limit = data_top
437
443
  key_groups_total_span: float | None = None
438
444
  if key_labels and key_groups is not None:
439
- data_range = data_top + 0.5
445
+ data_range = data_top - data_bottom
440
446
  key_groups_padding = _resolve_padding(
441
447
  key_groups,
442
448
  padding=None,
@@ -452,19 +458,19 @@ def heatmap(
452
458
  htmp += scale_y_continuous(
453
459
  breaks=list(range(n_y)),
454
460
  labels=y_order_groups,
455
- limits=[-0.5, y_max_limit],
461
+ limits=[data_bottom, y_max_limit],
456
462
  expand=[0, 0],
457
463
  )
458
464
  elif group_bars and group_bars_labels:
459
465
  htmp += scale_y_continuous(
460
466
  breaks=group_centers,
461
467
  labels=y_order_groups,
462
- limits=[-0.5, y_max_limit],
468
+ limits=[data_bottom, y_max_limit],
463
469
  expand=[0, 0],
464
470
  )
465
471
  htmp += guides(color="none")
466
472
  else:
467
- htmp += scale_y_continuous(limits=[-0.5, y_max_limit], expand=[0, 0])
473
+ htmp += scale_y_continuous(limits=[data_bottom, y_max_limit], expand=[0, 0])
468
474
  htmp += theme(axis_text_y=element_blank(), axis_ticks_y=element_blank())
469
475
 
470
476
  # GROUP color bar on left for non-aggregate
@@ -29,6 +29,7 @@ from cellestial.single.heatmap.utilities import (
29
29
  )
30
30
  from cellestial.themes import _THEME_DOTPLOT
31
31
  from cellestial.util import (
32
+ _drop_nonfinite_rows,
32
33
  _fill_gradient,
33
34
  _get_dendrogram,
34
35
  _get_dendrogram_path_frame,
@@ -332,7 +333,7 @@ def stacked_violin(
332
333
  variable_name=variable_column,
333
334
  value_name=value_column,
334
335
  )
335
- frame = frame.drop_nulls(subset=[value_column])
336
+ frame = _drop_nonfinite_rows(frame, [value_column])
336
337
  # 2. Apply threshold filter
337
338
  if threshold is not None:
338
339
  frame = frame.filter(pl.col(value_column) >= threshold)
@@ -123,6 +123,12 @@ def _group_bar_gap(n_x: int) -> float:
123
123
  return max(_GROUP_BAR_GAP_MIN, n_x * _GROUP_BAR_GAP_RATIO)
124
124
 
125
125
 
126
+ def _nonaggregate_y_step(n_x: int, n_y: int) -> float:
127
+ """Return the distance between adjacent nonaggregated row centers."""
128
+ y_span = max(n_x - 1, 1)
129
+ return y_span / max(n_y - 1, 1)
130
+
131
+
126
132
  def _assign_positions(
127
133
  frame: pl.DataFrame,
128
134
  *,
@@ -164,7 +170,7 @@ def _assign_positions(
164
170
  )
165
171
  return frame, None, n_x, n_y, [float(i) for i in range(n_y)]
166
172
 
167
- # non-aggregate: rescale per-cell _y to span [0, n_x] for square-ish aspect
173
+ # Nonaggregate: use a width-comparable y span for a square-ish aspect.
168
174
  group_index = {g: i for i, g in enumerate(y_order_groups)}
169
175
  cell_frame = (
170
176
  frame.select(observations_name, group_by)
@@ -178,7 +184,7 @@ def _assign_positions(
178
184
  .sort(["_group_index", observations_name])
179
185
  )
180
186
  n_y = cell_frame.height
181
- y_step = (n_x - 1) / max(n_y - 1, 1)
187
+ y_step = _nonaggregate_y_step(n_x, n_y)
182
188
  cell_frame = cell_frame.with_columns(
183
189
  (pl.int_range(pl.len()).cast(pl.Float64) * y_step).alias("position_y")
184
190
  )
@@ -231,7 +237,7 @@ def _get_group_lines_frame(
231
237
  .head(n_groups - 1)["y_max"]
232
238
  .to_list()
233
239
  )
234
- half_step = (n_x - 1) / max(n_y - 1, 1) / 2
240
+ half_step = _nonaggregate_y_step(n_x, n_y) / 2
235
241
  line_ys = [y + half_step for y in boundaries]
236
242
 
237
243
  x_start = -0.5
@@ -12,6 +12,7 @@ from lets_plot import (
12
12
 
13
13
  from cellestial.frames import _highest_expressed_genes_frame
14
14
  from cellestial.themes import _THEME_HIGHEST
15
+ from cellestial.util import _drop_nonfinite_rows
15
16
  from cellestial.util.errors import _unsupported_data_type
16
17
 
17
18
  if TYPE_CHECKING:
@@ -134,8 +135,8 @@ def highest_expressed_genes(
134
135
  descending=True,
135
136
  )
136
137
 
137
- # FILTER: drop nulls and apply threshold if provided
138
- frame = frame.drop_nulls()
138
+ # FILTER: keep finite values and apply threshold if provided
139
+ frame = _drop_nonfinite_rows(frame, [value_column])
139
140
  frame = frame.filter(
140
141
  pl.col(value_column) >= threshold if threshold is not None else True,
141
142
  )
@@ -18,6 +18,7 @@ from cellestial.frames import build_frame
18
18
  from cellestial.util import (
19
19
  _collect_aes_columns,
20
20
  _determine_axis,
21
+ _drop_nonfinite_rows,
21
22
  _reject_sequence_key,
22
23
  _resolve_tooltips,
23
24
  _validate_tooltips,
@@ -201,8 +202,8 @@ def ridge(
201
202
  metadata_columns=metadata_columns,
202
203
  )
203
204
 
204
- # FILTER: drop nulls and apply threshold
205
- frame = frame.drop_nulls(subset=[key])
205
+ # FILTER: keep finite values and apply threshold
206
+ frame = _drop_nonfinite_rows(frame, [key]).filter(pl.col(group_by).is_not_null())
206
207
  frame = frame.filter(
207
208
  pl.col(key) >= threshold if threshold is not None else True,
208
209
  )
@@ -25,6 +25,7 @@ from cellestial.themes import _THEME_SPATIAL
25
25
  from cellestial.util import (
26
26
  _collect_aes_columns,
27
27
  _color_gradient,
28
+ _drop_nonfinite_rows,
28
29
  _fill_gradient,
29
30
  _reject_sequence_key,
30
31
  _resolve_tooltips,
@@ -358,6 +359,17 @@ def spatial(
358
359
  pl.Series("spatial_y", spot_coordinates[:, 1]),
359
360
  )
360
361
 
362
+ if is_polygon:
363
+ finite_vertex = pl.all_horizontal(
364
+ pl.col("polygon_x").is_not_null(),
365
+ pl.col("polygon_x").is_finite(),
366
+ pl.col("polygon_y").is_not_null(),
367
+ pl.col("polygon_y").is_finite(),
368
+ )
369
+ frame = frame.filter(finite_vertex.all().over("instance_id"))
370
+ else:
371
+ frame = _drop_nonfinite_rows(frame, ["spatial_x", "spatial_y"])
372
+
361
373
  _validate_tooltips(tooltips, frame)
362
374
 
363
375
  # HANDLE: groups filter (categorical-only)
@@ -9,6 +9,7 @@ from cellestial.util.utilities import (
9
9
  _collect_aes_columns,
10
10
  _color_gradient,
11
11
  _determine_axis,
12
+ _drop_nonfinite_rows,
12
13
  _fill_gradient,
13
14
  _is_observation_key,
14
15
  _is_variable_key,
@@ -30,6 +31,7 @@ __all__ = [
30
31
  "_collect_aes_columns",
31
32
  "_color_gradient",
32
33
  "_determine_axis",
34
+ "_drop_nonfinite_rows",
33
35
  "_fill_gradient",
34
36
  "_get_dendrogram",
35
37
  "_get_dendrogram_path_frame",
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from collections.abc import Sequence
4
- from typing import TYPE_CHECKING
4
+ from typing import TYPE_CHECKING, cast
5
5
 
6
6
  from lets_plot import gggrid
7
7
  from lets_plot.plot.core import PlotSpec
@@ -30,32 +30,39 @@ def _normalize_widths(
30
30
  msg = f"`widths` must be a flat or nested sequence, but received {type(widths)}"
31
31
  raise TypeError(msg)
32
32
 
33
- is_nested = any(isinstance(row_widths, (list, tuple)) for row_widths in widths)
34
- if is_nested:
35
- if not all(isinstance(row_widths, (list, tuple)) for row_widths in widths):
36
- msg = "`widths` mixes numbers and sub-lists; use a fully nested list, one per row."
33
+ nested_flags = [
34
+ isinstance(row_widths, Sequence) and not isinstance(row_widths, (str, bytes))
35
+ for row_widths in widths
36
+ ]
37
+ if any(nested_flags):
38
+ if not all(nested_flags):
39
+ msg = "`widths` mixes numbers and sequences; use one sequence per row."
37
40
  raise ValueError(msg)
38
- if len(widths) != len(plots):
39
- msg = f"nested `widths` has {len(widths)} rows but `plots` has {len(plots)} rows."
41
+ nested_widths = cast("Sequence[Sequence[float]]", widths)
42
+ if len(nested_widths) != len(plots):
43
+ msg = (
44
+ f"nested `widths` has {len(nested_widths)} rows but `plots` has {len(plots)} rows."
45
+ )
40
46
  raise ValueError(msg)
41
- for index, (row, row_widths) in enumerate(zip(plots, widths, strict=True)):
47
+ for index, (row, row_widths) in enumerate(zip(plots, nested_widths, strict=True)):
42
48
  if len(row_widths) != len(row):
43
49
  msg = (
44
50
  f"`widths[{index}]` has {len(row_widths)} values "
45
51
  f"but row {index} has {len(row)} plots."
46
52
  )
47
53
  raise ValueError(msg)
48
- return [list(row_widths) for row_widths in widths]
54
+ return [list(row_widths) for row_widths in nested_widths]
49
55
 
50
56
  # flat widths: broadcast to every row, valid only when all rows share that length.
51
- uneven = [index for index, row in enumerate(plots) if len(row) != len(widths)]
57
+ flat_widths = cast("Sequence[float]", widths)
58
+ uneven = [index for index, row in enumerate(plots) if len(row) != len(flat_widths)]
52
59
  if uneven:
53
60
  msg = (
54
- f"flat `widths` of length {len(widths)} cannot broadcast to rows {uneven} "
61
+ f"flat `widths` of length {len(flat_widths)} cannot broadcast to rows {uneven} "
55
62
  "with differing plot counts; pass a nested `widths` instead."
56
63
  )
57
64
  raise ValueError(msg)
58
- return [list(widths) for _ in plots]
65
+ return [list(flat_widths) for _ in plots]
59
66
 
60
67
 
61
68
  def layout(
@@ -65,6 +65,22 @@ def _warn(message: str) -> None:
65
65
  warnings.formatwarning = original_format
66
66
 
67
67
 
68
+ def _drop_nonfinite_rows(frame: pl.DataFrame, columns: Sequence[str]) -> pl.DataFrame:
69
+ """Keep rows whose selected numeric columns are present and finite."""
70
+ numeric_columns = [
71
+ column
72
+ for column in dict.fromkeys(columns)
73
+ if column in frame.columns and frame.schema[column].is_numeric()
74
+ ]
75
+ if not numeric_columns:
76
+ return frame
77
+ return frame.filter(
78
+ pl.all_horizontal(
79
+ pl.col(column).is_not_null() & pl.col(column).is_finite() for column in numeric_columns
80
+ )
81
+ )
82
+
83
+
68
84
  def _build_tooltips(
69
85
  *,
70
86
  tooltips: list[str] | str,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cellestial"
3
- version = "0.58.0"
3
+ version = "0.58.2"
4
4
  description = "Interactive single-cell and spatial omics visualization library"
5
5
  authors = [
6
6
  { name = "Zaf4", email = "zafermolbio@gmail.com" },
@@ -71,6 +71,8 @@ tqdm = "^4.67.3"
71
71
  pytest = "^9.0.3"
72
72
  pytest-cov = "^7.1.0"
73
73
  nbstripout = "^0.9.1"
74
+ pytest-xdist = "^3.8.0"
75
+ pytest-playwright = "^0.8.0"
74
76
 
75
77
  [tool.poetry.group.docs.dependencies]
76
78
  sphinx = ">=8.0.0"
@@ -84,7 +86,10 @@ squidpy = "^1.8.1"
84
86
 
85
87
  [tool.pytest.ini_options]
86
88
  testpaths = ["tests"]
87
- addopts = "--cov=cellestial --cov-report=xml:tests/coverage.xml --cov-report=term-missing"
89
+ addopts = "-n 4 --dist=loadscope -m 'not browser' --cov=cellestial --cov-report=xml:tests/coverage.xml --cov-report=term-missing"
90
+ markers = [
91
+ "browser: exercises Lets-Plot in a real browser",
92
+ ]
88
93
 
89
94
  [tool.coverage.run]
90
95
  data_file = "tests/.coverage"
File without changes