@@ -573,6 +573,33 @@ def _check_instance_ids_overlap(
573573 )
574574
575575
576+ # Above this many circles, a uniform-radius outline-free element is a dot-field where buffering every
577+ # circle to a polygon dominates the render; rasterizing centroids as spread discs is far cheaper and
578+ # visually equivalent at that scale.
579+ _CIRCLE_FAST_PATH_MIN = 50_000
580+
581+
582+ def _circles_render_as_points (shapes : gpd .GeoDataFrame , is_point : Any , render_params : ShapesRenderParams ) -> bool :
583+ """Gate for the circle fast-path: a large, uniform-radius, outline-free, default-shape circle element.
584+
585+ Restricted so the point approximation never silently distorts the result: per-circle varying radii
586+ and outlines can't be reproduced by a single uniform spread, and a custom ``shape`` is meaningless
587+ for points.
588+ """
589+ if (
590+ render_params .as_points != "auto" # explicit True handled upstream; False opts out (accurate geometry)
591+ or render_params .shape is not None
592+ or "radius" not in shapes .columns
593+ or len (shapes ) <= _CIRCLE_FAST_PATH_MIN
594+ or render_params .outline_alpha [0 ] > 0
595+ or render_params .outline_alpha [1 ] > 0
596+ or not bool (is_point .all ())
597+ ):
598+ return False
599+ radius = pd .to_numeric (shapes ["radius" ], errors = "coerce" ).to_numpy ()
600+ return bool (np .isfinite (radius ).all () and np .ptp (radius ) == 0 )
601+
602+
576603def _render_shapes (
577604 sdata : sd .SpatialData ,
578605 render_params : ShapesRenderParams ,
@@ -718,12 +745,8 @@ def _render_shapes(
718745
719746 shapes = gpd .GeoDataFrame (shapes , geometry = "geometry" )
720747
721- if render_params .as_points :
722- # Fast mode: draw one dot per shape at its centroid instead of its geometry.
723- logger .info ("`as_points=True`: rendering shape centroids; `outline_*` and `shape` are ignored." )
724- centroids = shapes .geometry .centroid # intrinsic coords, positionally aligned to color_vector
725- # transform to coordinate-system coords so dots land correctly under non-identity transforms
726- xy = trans .transform (np .column_stack ([centroids .x .to_numpy (), centroids .y .to_numpy ()]))
748+ def _draw_centroids (xy : np .ndarray , radius : float | None = None ) -> None :
749+ """Render the element's centroids (coordinate-system coords) as dots; ``radius`` sizes the disc."""
727750 _render_centroids_as_points (
728751 ax ,
729752 render_params ,
@@ -739,7 +762,14 @@ def _render_shapes(
739762 legend_params = legend_params ,
740763 colorbar_requests = colorbar_requests ,
741764 axes_extent = _fast_extent (sdata_filt .shapes [element ], coordinate_system ),
765+ radius = radius ,
742766 )
767+
768+ if render_params .as_points is True :
769+ # Fast mode: draw one dot per shape at its centroid instead of its geometry.
770+ logger .info ("`as_points=True`: rendering shape centroids; `outline_*` and `shape` are ignored." )
771+ centroids = shapes .geometry .centroid # intrinsic; transform so dots land under non-identity transforms
772+ _draw_centroids (trans .transform (np .column_stack ([centroids .x .to_numpy (), centroids .y .to_numpy ()])))
743773 return
744774
745775 # convert shapes if necessary
@@ -773,6 +803,16 @@ def _render_shapes(
773803 is_point = _geometry .type == "Point"
774804 tm = trans .get_matrix () # coordinate-system affine; reused for circle sizing and the transform below
775805
806+ # Fast path: a large uniform-radius circle element with no outline rasterizes (to within a
807+ # pixel) the same as spread points, skipping the per-circle buffer/polygon-aggregation cost.
808+ if _circles_render_as_points (shapes , is_point , render_params ):
809+ logger .info (f"Rendering { len (shapes )} uniform circles as datashader points (fast path)." )
810+ stretch = float (np .linalg .svd (tm [:2 , :2 ], compute_uv = False ).max ()) # circle radius in CS units
811+ radius_cs = float (pd .to_numeric (shapes ["radius" ], errors = "coerce" ).iloc [0 ]) * render_params .scale * stretch
812+ xy = trans .transform (np .column_stack ([_geometry .x .to_numpy (), _geometry .y .to_numpy ()]))
813+ _draw_centroids (xy , radius = radius_cs )
814+ return
815+
776816 # Handle circles encoded as points with radius
777817 if is_point .any ():
778818 # Convert to numeric, replacing non-numeric values with NaN
@@ -1078,12 +1118,14 @@ def _render_centroids_as_points(
10781118 colorbar_requests : list [ColorbarSpec ] | None ,
10791119 axes_extent : dict [str , tuple [float , float ]],
10801120 allow_datashader : bool = True ,
1121+ radius : float | None = None ,
10811122) -> None :
10821123 """Render one dot per cell at ``(x, y)`` (coordinate-system coords), colored like the fill.
10831124
10841125 Shared "fast mode" for shapes/labels; backend chosen by ``_resolve_as_points_method``. ``axes_extent``
10851126 (the element's extent, i.e. the frame the axes will use) is what the datashader backend rasterizes over
1086- so its dots match the matplotlib markers.
1127+ so its dots match the matplotlib markers. ``radius`` (coordinate-system units), when set, sizes the
1128+ datashader spread to a faithful disc of that radius instead of the marker ``size``.
10871129 """
10881130 method = _resolve_as_points_method (render_params , n = len (x ), allow_datashader = allow_datashader )
10891131 if method == "datashader" :
@@ -1108,6 +1150,7 @@ def _render_centroids_as_points(
11081150 fig_params = fig_params ,
11091151 as_markers = True ,
11101152 axes_extent = axes_extent ,
1153+ radius = radius ,
11111154 )
11121155 color_spec = color_spec .evolve (source_vector = csv , color_vector = cv )
11131156 else :
@@ -1159,23 +1202,20 @@ def _datashader_points(
11591202 default_reduction : _DsReduction = "sum" ,
11601203 as_markers : bool = False ,
11611204 axes_extent : dict [str , tuple [float , float ]] | None = None ,
1205+ radius : float | None = None ,
11621206) -> tuple [Any , Any , Any ]:
11631207 """Datashade an x/y(+color) point frame onto ``ax``; return ``(cax, color_vector, color_source_vector)``.
11641208
11651209 Shared by ``render_points`` and the centroid "fast mode" of shapes/labels; ``df`` holds ``x``/``y`` in
11661210 coordinate-system coords. The (possibly recomputed) color vectors are returned so the caller's legend
11671211 matches. ``as_markers`` mimics matplotlib markers: it rasterizes over ``axes_extent`` (the plot frame),
1168- sizes the spread to the marker radius, and uses a uniform alpha.
1212+ sizes the spread to the marker radius, and uses a uniform alpha. ``radius`` (coordinate-system units)
1213+ overrides ``size`` to spread each dot to a faithful disc of that radius (circle fast-path).
11691214 """
1170- # Spread radius = matplotlib marker radius: an 'o' marker has diameter sqrt(s)*dpi/72 px, so radius
1171- # sqrt(s)*dpi/144. render_points keeps the looser sqrt(s)*dpi/100 it was calibrated with.
1172- px_div = 144 if as_markers else 100
1173- px : int | None = None if density else int (np .round (np .sqrt (size ) * (fig_params .fig .dpi / px_div )))
1174-
11751215 if as_markers and axes_extent is not None :
11761216 # Size the canvas to the AXES display box, not the figure: the datashader output is a
11771217 # data-coordinate image that scales with the (smaller) axes, so a figure-sized canvas shrinks the
1178- # dots. With 1 canvas px == 1 axes-display px, the spread radius above matches the marker.
1218+ # dots. With 1 canvas px == 1 axes-display px, the spread radius below matches the marker.
11791219 x_ext = [float (axes_extent ["x" ][0 ]), float (axes_extent ["x" ][1 ])]
11801220 y_ext = [float (axes_extent ["y" ][0 ]), float (axes_extent ["y" ][1 ])]
11811221 bb = ax .get_window_extent ()
@@ -1184,6 +1224,17 @@ def _datashader_points(
11841224 plot_width , plot_height = int (round (rx / factor )), int (round (ry / factor ))
11851225 else :
11861226 plot_width , plot_height , x_ext , y_ext , factor = _datashader_canvas_from_dataframe (df , fig_params )
1227+
1228+ if density :
1229+ px : int | None = None
1230+ elif radius is not None :
1231+ # Faithful disc: spread to the circle's on-screen pixel radius (factor = CS units per axes px).
1232+ # ds.tf.spread's footprint radius is ~px+0.5, so subtract 0.5 to match a filled disc of radius r.
1233+ px = max (int (round (radius / factor - 0.5 )), 0 )
1234+ else :
1235+ # Spread radius = matplotlib marker radius: an 'o' marker has diameter sqrt(s)*dpi/72 px, so
1236+ # radius sqrt(s)*dpi/144. render_points keeps the looser sqrt(s)*dpi/100 it was calibrated with.
1237+ px = int (np .round (np .sqrt (size ) * (fig_params .fig .dpi / (144 if as_markers else 100 ))))
11871238 cvs = ds .Canvas (plot_width = plot_width , plot_height = plot_height , x_range = x_ext , y_range = y_ext )
11881239
11891240 # ensure color column exists on the frame with positional alignment
0 commit comments