Skip to content

Commit 50a6606

Browse files
authored
refactor: decompose _type_check_params, _convert_shapes, _set_color_source_vec (#741)
1 parent e454700 commit 50a6606

4 files changed

Lines changed: 217 additions & 123 deletions

File tree

src/spatialdata_plot/pl/_color.py

Lines changed: 99 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,92 @@ def _extract_color_column(
458458
return values.reindex(element.index)
459459

460460

461+
def _resolve_color_origins(
462+
value_to_plot: str | None,
463+
sdata: sd.SpatialData,
464+
element_name: list[str] | str | None,
465+
table_name: str | None,
466+
) -> tuple[list[Any], bool]:
467+
"""Locate the color column and resolve df-vs-table shadowing; raise if it lives in >1 place."""
468+
origins = _locate_value(value_key=value_to_plot, sdata=sdata, element_name=element_name, table_name=table_name)
469+
# An explicit `table_name=` disambiguates a column present in both the element df and the table.
470+
explicit_table_shadows_df = table_name is not None and any(o.origin == "df" for o in origins)
471+
if explicit_table_shadows_df:
472+
origins = [o for o in origins if o.origin != "df"]
473+
if len(origins) > 1:
474+
raise ValueError(
475+
f"Color key '{value_to_plot}' for element '{element_name}' was found in multiple locations: {origins}. "
476+
"Please keep it in exactly one place (preferably on the points parquet for speed) to avoid ambiguity."
477+
)
478+
return origins, explicit_table_shadows_df
479+
480+
481+
def _fetch_color_source_vector(
482+
sdata: sd.SpatialData,
483+
element: SpatialElement | None,
484+
element_name: list[str] | str | None,
485+
value_to_plot: str | None,
486+
table_name: str | None,
487+
table_layer: str | None,
488+
origins: list[Any],
489+
explicit_table_shadows_df: bool,
490+
preloaded_color_data: pd.Series | None,
491+
) -> ArrayLike | pd.Series:
492+
"""Read the raw color column, preferring a direct aligned read over a whole-table join."""
493+
if preloaded_color_data is not None:
494+
return preloaded_color_data
495+
if (
496+
isinstance(element, GeoDataFrame)
497+
and isinstance(element_name, str)
498+
and table_name is not None
499+
and table_name in sdata.tables
500+
and origins[0].origin in ("obs", "var")
501+
):
502+
# Fast path: read the single aligned column directly instead of joining/copying the
503+
# whole annotating table (the join's out-of-order sparse row-gather dominates large renders).
504+
return _extract_color_column(
505+
sdata[table_name],
506+
value_to_plot,
507+
origin=origins[0].origin,
508+
element=element,
509+
element_name=element_name,
510+
table_layer=table_layer,
511+
)
512+
if explicit_table_shadows_df:
513+
# Pass the table as `element` so upstream `get_values` skips the
514+
# element-column lookup and avoids the multi-origin error.
515+
return get_values(
516+
value_key=value_to_plot,
517+
element=sdata[table_name],
518+
element_name=element_name,
519+
table_layer=table_layer,
520+
)[value_to_plot]
521+
return get_values(
522+
value_key=value_to_plot,
523+
sdata=sdata,
524+
element_name=element_name,
525+
table_name=table_name,
526+
table_layer=table_layer,
527+
)[value_to_plot]
528+
529+
530+
def _resolve_color_table(value_from_element: bool, table_name: str | None, sdata: sd.SpatialData) -> str | None:
531+
"""Pick which table supplies .uns colors: none if the value is element-local, else the named or sole table."""
532+
if value_from_element:
533+
return None
534+
if table_name is not None:
535+
if table_name in sdata.tables:
536+
return table_name
537+
logger.warning(f"Table '{table_name}' not found in `sdata.tables`. Falling back to default behavior.")
538+
return None
539+
table_keys = list(sdata.tables.keys())
540+
if not table_keys:
541+
return None
542+
if len(table_keys) > 1:
543+
logger.warning(f"No table name provided, using '{table_keys[0]}' as fallback for color mapping.")
544+
return table_keys[0]
545+
546+
461547
def _set_color_source_vec(
462548
sdata: sd.SpatialData,
463549
element: SpatialElement | None,
@@ -478,26 +564,7 @@ def _set_color_source_vec(
478564
color = np.full(len(element), na_color.get_hex_with_alpha())
479565
return color, color, False
480566

481-
# Figure out where to get the color from
482-
origins = _locate_value(
483-
value_key=value_to_plot,
484-
sdata=sdata,
485-
element_name=element_name,
486-
table_name=table_name,
487-
)
488-
489-
# When both the element's own dataframe and the chosen table contain a
490-
# column with this name, an explicit `table_name=` resolves the ambiguity —
491-
# keep only the table origin and skip the multi-origin error below.
492-
explicit_table_shadows_df = table_name is not None and any(o.origin == "df" for o in origins)
493-
if explicit_table_shadows_df:
494-
origins = [o for o in origins if o.origin != "df"]
495-
496-
if len(origins) > 1:
497-
raise ValueError(
498-
f"Color key '{value_to_plot}' for element '{element_name}' was found in multiple locations: {origins}. "
499-
"Please keep it in exactly one place (preferably on the points parquet for speed) to avoid ambiguity."
500-
)
567+
origins, explicit_table_shadows_df = _resolve_color_origins(value_to_plot, sdata, element_name, table_name)
501568

502569
if len(origins) == 1 and value_to_plot is not None:
503570
if table_name is not None:
@@ -507,42 +574,17 @@ def _set_color_source_vec(
507574
element_name=element_name,
508575
table_name=table_name,
509576
)
510-
if preloaded_color_data is not None:
511-
color_source_vector = preloaded_color_data
512-
elif (
513-
isinstance(element, GeoDataFrame)
514-
and isinstance(element_name, str)
515-
and table_name is not None
516-
and table_name in sdata.tables
517-
and origins[0].origin in ("obs", "var")
518-
):
519-
# Fast path: read the single aligned column directly instead of joining/copying the
520-
# whole annotating table (the join's out-of-order sparse row-gather dominates large renders).
521-
color_source_vector = _extract_color_column(
522-
sdata[table_name],
523-
value_to_plot,
524-
origin=origins[0].origin,
525-
element=element,
526-
element_name=element_name,
527-
table_layer=table_layer,
528-
)
529-
elif explicit_table_shadows_df:
530-
# Pass the table as `element` so upstream `get_values` skips the
531-
# element-column lookup and avoids the multi-origin error.
532-
color_source_vector = get_values(
533-
value_key=value_to_plot,
534-
element=sdata[table_name],
535-
element_name=element_name,
536-
table_layer=table_layer,
537-
)[value_to_plot]
538-
else:
539-
color_source_vector = get_values(
540-
value_key=value_to_plot,
541-
sdata=sdata,
542-
element_name=element_name,
543-
table_name=table_name,
544-
table_layer=table_layer,
545-
)[value_to_plot]
577+
color_source_vector = _fetch_color_source_vector(
578+
sdata=sdata,
579+
element=element,
580+
element_name=element_name,
581+
value_to_plot=value_to_plot,
582+
table_name=table_name,
583+
table_layer=table_layer,
584+
origins=origins,
585+
explicit_table_shadows_df=explicit_table_shadows_df,
586+
preloaded_color_data=preloaded_color_data,
587+
)
546588

547589
color_series = (
548590
color_source_vector if isinstance(color_source_vector, pd.Series) else pd.Series(color_source_vector)
@@ -588,29 +630,8 @@ def _set_color_source_vec(
588630
processed = processed.reorder_categories(sorted(processed.categories))
589631
color_source_vector = processed # convert, e.g., `pd.Series`
590632

591-
# When the value lives on the element's own DataFrame (origin="df"),
592-
# there is no reason to look up a table for .uns colors.
593633
value_from_element = origins[0].origin == "df"
594-
595-
# Use the provided table_name parameter, fall back to only one present
596-
table_to_use: str | None
597-
if value_from_element:
598-
table_to_use = None
599-
elif table_name is not None and table_name in sdata.tables:
600-
table_to_use = table_name
601-
elif table_name is not None and table_name not in sdata.tables:
602-
logger.warning(f"Table '{table_name}' not found in `sdata.tables`. Falling back to default behavior.")
603-
table_to_use = None
604-
else:
605-
table_keys = list(sdata.tables.keys())
606-
if len(table_keys) == 1:
607-
table_to_use = table_keys[0]
608-
elif len(table_keys) > 1:
609-
table_to_use = table_keys[0]
610-
logger.warning(f"No table name provided, using '{table_to_use}' as fallback for color mapping.")
611-
else:
612-
table_to_use = None
613-
634+
table_to_use = _resolve_color_table(value_from_element, table_name, sdata)
614635
adata_for_mapping = sdata[table_to_use] if table_to_use is not None else None
615636

616637
# Check if custom colors exist in the resolved table's .uns slot

src/spatialdata_plot/pl/_geometry.py

Lines changed: 43 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,40 @@ def _validate_polygons(shapes: GeoDataFrame) -> GeoDataFrame:
268268
return shapes
269269

270270

271+
def _circle_to_hexagon(center: shapely.Point, radius: float) -> tuple[shapely.Polygon, None]:
272+
verts = [
273+
(
274+
center.x + radius * math.cos(math.radians(a)),
275+
center.y + radius * math.sin(math.radians(a)),
276+
)
277+
for a in range(30, 390, 60)
278+
]
279+
return shapely.Polygon(verts), None
280+
281+
282+
def _circle_to_square(center: shapely.Point, radius: float) -> tuple[shapely.Polygon, None]:
283+
verts = [
284+
(
285+
center.x + radius * math.cos(math.radians(a)),
286+
center.y + radius * math.sin(math.radians(a)),
287+
)
288+
for a in range(45, 360, 90)
289+
]
290+
return shapely.Polygon(verts), None
291+
292+
293+
def _circle_to_circle(center: shapely.Point, radius: float) -> tuple[shapely.Point, float]:
294+
return center, radius
295+
296+
297+
def _enclosing_circle(coords: np.ndarray) -> tuple[shapely.Point, float]:
298+
"""Enclosing circle from a point cloud: centroid of the convex hull and the max vertex distance."""
299+
hull_pts = coords[ConvexHull(coords).vertices]
300+
center = np.mean(hull_pts, axis=0)
301+
radius = float(np.max(np.linalg.norm(hull_pts - center, axis=1)))
302+
return shapely.Point(center), radius
303+
304+
271305
def _convert_shapes(
272306
shapes: GeoDataFrame,
273307
target_shape: str,
@@ -282,67 +316,32 @@ def _convert_shapes(
282316
# work on a copy with a clean positional index
283317
shapes = shapes.reset_index(drop=True).copy()
284318

285-
def _circle_to_hexagon(center: shapely.Point, radius: float) -> tuple[shapely.Polygon, None]:
286-
verts = [
287-
(
288-
center.x + radius * math.cos(math.radians(a)),
289-
center.y + radius * math.sin(math.radians(a)),
290-
)
291-
for a in range(30, 390, 60)
292-
]
293-
return shapely.Polygon(verts), None
294-
295-
def _circle_to_square(center: shapely.Point, radius: float) -> tuple[shapely.Polygon, None]:
296-
verts = [
297-
(
298-
center.x + radius * math.cos(math.radians(a)),
299-
center.y + radius * math.sin(math.radians(a)),
300-
)
301-
for a in range(45, 360, 90)
302-
]
303-
return shapely.Polygon(verts), None
304-
305-
def _circle_to_circle(center: shapely.Point, radius: float) -> tuple[shapely.Point, float]:
306-
return center, radius
307-
308319
def _polygon_to_circle(polygon: shapely.Polygon) -> tuple[shapely.Point, float]:
309-
coords = np.array(polygon.exterior.coords)
310-
hull_pts = coords[ConvexHull(coords).vertices]
311-
center = np.mean(hull_pts, axis=0)
312-
radius = float(np.max(np.linalg.norm(hull_pts - center, axis=1)))
320+
center, radius = _enclosing_circle(np.array(polygon.exterior.coords))
313321
nonlocal warn_shape_size
314322
if 2 * radius > max_extent * warn_above_extent_fraction:
315323
warn_shape_size = True
316-
return shapely.Point(center), radius
324+
return center, radius
317325

318326
def _polygon_to_hexagon(polygon: shapely.Polygon) -> tuple[shapely.Polygon, None]:
319-
c, r = _polygon_to_circle(polygon)
320-
return _circle_to_hexagon(c, r)
327+
return _circle_to_hexagon(*_polygon_to_circle(polygon))
321328

322329
def _polygon_to_square(polygon: shapely.Polygon) -> tuple[shapely.Polygon, None]:
323-
c, r = _polygon_to_circle(polygon)
324-
return _circle_to_square(c, r)
330+
return _circle_to_square(*_polygon_to_circle(polygon))
325331

326332
def _multipolygon_to_circle(multipolygon: shapely.MultiPolygon) -> tuple[shapely.Point, float]:
327-
pts = []
328-
for poly in multipolygon.geoms:
329-
pts.extend(poly.exterior.coords)
330-
pts_array = np.array(pts)
331-
hull_pts = pts_array[ConvexHull(pts_array).vertices]
332-
center = np.mean(hull_pts, axis=0)
333-
radius = float(np.max(np.linalg.norm(hull_pts - center, axis=1)))
333+
coords = np.array([pt for poly in multipolygon.geoms for pt in poly.exterior.coords])
334+
center, radius = _enclosing_circle(coords)
334335
nonlocal warn_shape_size
335336
if 2 * radius > max_extent * warn_above_extent_fraction:
336337
warn_shape_size = True
337-
return shapely.Point(center), radius
338+
return center, radius
338339

339340
def _multipolygon_to_hexagon(multipolygon: shapely.MultiPolygon) -> tuple[shapely.Polygon, None]:
340-
c, r = _multipolygon_to_circle(multipolygon)
341-
return _circle_to_hexagon(c, r)
341+
return _circle_to_hexagon(*_multipolygon_to_circle(multipolygon))
342342

343343
def _multipolygon_to_square(multipolygon: shapely.MultiPolygon) -> tuple[shapely.Polygon, None]:
344-
c, r = _multipolygon_to_circle(multipolygon)
345-
return _circle_to_square(c, r)
344+
return _circle_to_square(*_multipolygon_to_circle(multipolygon))
346345

347346
# choose conversion methods
348347
conversion_methods: dict[str, Any]

0 commit comments

Comments
 (0)