@@ -573,6 +573,32 @@ 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 datashader circle fast-path: a large, uniform-radius, outline-free, default-shape element.
584+
585+ A datashader speed optimization (use ``method="matplotlib"`` for exact circles), restricted so the
586+ point approximation never silently distorts: per-circle varying radii and outlines can't be
587+ reproduced by a single uniform spread, and a custom ``shape`` is meaningless for points.
588+ """
589+ if (
590+ render_params .shape is not None
591+ or "radius" not in shapes .columns
592+ or len (shapes ) <= _CIRCLE_FAST_PATH_MIN
593+ or render_params .outline_alpha [0 ] > 0
594+ or render_params .outline_alpha [1 ] > 0
595+ or not bool (is_point .all ())
596+ ):
597+ return False
598+ radius = pd .to_numeric (shapes ["radius" ], errors = "coerce" ).to_numpy ()
599+ return bool (np .isfinite (radius ).all () and np .ptp (radius ) == 0 )
600+
601+
576602def _render_shapes (
577603 sdata : sd .SpatialData ,
578604 render_params : ShapesRenderParams ,
@@ -718,12 +744,8 @@ def _render_shapes(
718744
719745 shapes = gpd .GeoDataFrame (shapes , geometry = "geometry" )
720746
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 ()]))
747+ def _draw_centroids (xy : np .ndarray , radius : float | None = None ) -> None :
748+ """Render the element's centroids (coordinate-system coords) as dots; ``radius`` sizes the disc."""
727749 _render_centroids_as_points (
728750 ax ,
729751 render_params ,
@@ -739,7 +761,14 @@ def _render_shapes(
739761 legend_params = legend_params ,
740762 colorbar_requests = colorbar_requests ,
741763 axes_extent = _fast_extent (sdata_filt .shapes [element ], coordinate_system ),
764+ radius = radius ,
742765 )
766+
767+ if render_params .as_points :
768+ # Fast mode: draw one dot per shape at its centroid instead of its geometry.
769+ logger .info ("`as_points=True`: rendering shape centroids; `outline_*` and `shape` are ignored." )
770+ centroids = shapes .geometry .centroid # intrinsic; transform so dots land under non-identity transforms
771+ _draw_centroids (trans .transform (np .column_stack ([centroids .x .to_numpy (), centroids .y .to_numpy ()])))
743772 return
744773
745774 # convert shapes if necessary
@@ -773,6 +802,16 @@ def _render_shapes(
773802 is_point = _geometry .type == "Point"
774803 tm = trans .get_matrix () # coordinate-system affine; reused for circle sizing and the transform below
775804
805+ # Fast path: a large uniform-radius circle element with no outline rasterizes (to within a
806+ # pixel) the same as spread points, skipping the per-circle buffer/polygon-aggregation cost.
807+ if _circles_render_as_points (shapes , is_point , render_params ):
808+ logger .info (f"Rendering { len (shapes )} uniform circles as datashader points (fast path)." )
809+ stretch = float (np .linalg .svd (tm [:2 , :2 ], compute_uv = False ).max ()) # circle radius in CS units
810+ radius_cs = float (pd .to_numeric (shapes ["radius" ], errors = "coerce" ).iloc [0 ]) * render_params .scale * stretch
811+ xy = trans .transform (np .column_stack ([_geometry .x .to_numpy (), _geometry .y .to_numpy ()]))
812+ _draw_centroids (xy , radius = radius_cs )
813+ return
814+
776815 # Handle circles encoded as points with radius
777816 if is_point .any ():
778817 # Convert to numeric, replacing non-numeric values with NaN
@@ -1078,12 +1117,14 @@ def _render_centroids_as_points(
10781117 colorbar_requests : list [ColorbarSpec ] | None ,
10791118 axes_extent : dict [str , tuple [float , float ]],
10801119 allow_datashader : bool = True ,
1120+ radius : float | None = None ,
10811121) -> None :
10821122 """Render one dot per cell at ``(x, y)`` (coordinate-system coords), colored like the fill.
10831123
10841124 Shared "fast mode" for shapes/labels; backend chosen by ``_resolve_as_points_method``. ``axes_extent``
10851125 (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.
1126+ so its dots match the matplotlib markers. ``radius`` (coordinate-system units), when set, sizes the
1127+ datashader spread to a faithful disc of that radius instead of the marker ``size``.
10871128 """
10881129 method = _resolve_as_points_method (render_params , n = len (x ), allow_datashader = allow_datashader )
10891130 if method == "datashader" :
@@ -1108,6 +1149,7 @@ def _render_centroids_as_points(
11081149 fig_params = fig_params ,
11091150 as_markers = True ,
11101151 axes_extent = axes_extent ,
1152+ radius = radius ,
11111153 )
11121154 color_spec = color_spec .evolve (source_vector = csv , color_vector = cv )
11131155 else :
@@ -1159,23 +1201,20 @@ def _datashader_points(
11591201 default_reduction : _DsReduction = "sum" ,
11601202 as_markers : bool = False ,
11611203 axes_extent : dict [str , tuple [float , float ]] | None = None ,
1204+ radius : float | None = None ,
11621205) -> tuple [Any , Any , Any ]:
11631206 """Datashade an x/y(+color) point frame onto ``ax``; return ``(cax, color_vector, color_source_vector)``.
11641207
11651208 Shared by ``render_points`` and the centroid "fast mode" of shapes/labels; ``df`` holds ``x``/``y`` in
11661209 coordinate-system coords. The (possibly recomputed) color vectors are returned so the caller's legend
11671210 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.
1211+ sizes the spread to the marker radius, and uses a uniform alpha. ``radius`` (coordinate-system units)
1212+ overrides ``size`` to spread each dot to a faithful disc of that radius (circle fast-path).
11691213 """
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-
11751214 if as_markers and axes_extent is not None :
11761215 # Size the canvas to the AXES display box, not the figure: the datashader output is a
11771216 # 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.
1217+ # dots. With 1 canvas px == 1 axes-display px, the spread radius below matches the marker.
11791218 x_ext = [float (axes_extent ["x" ][0 ]), float (axes_extent ["x" ][1 ])]
11801219 y_ext = [float (axes_extent ["y" ][0 ]), float (axes_extent ["y" ][1 ])]
11811220 bb = ax .get_window_extent ()
@@ -1184,6 +1223,17 @@ def _datashader_points(
11841223 plot_width , plot_height = int (round (rx / factor )), int (round (ry / factor ))
11851224 else :
11861225 plot_width , plot_height , x_ext , y_ext , factor = _datashader_canvas_from_dataframe (df , fig_params )
1226+
1227+ if density :
1228+ px : int | None = None
1229+ elif radius is not None :
1230+ # Faithful disc: spread to the circle's on-screen pixel radius (factor = CS units per axes px).
1231+ # ds.tf.spread's footprint radius is ~px+0.5, so subtract 0.5 to match a filled disc of radius r.
1232+ px = max (int (round (radius / factor - 0.5 )), 0 )
1233+ else :
1234+ # Spread radius = matplotlib marker radius: an 'o' marker has diameter sqrt(s)*dpi/72 px, so
1235+ # radius sqrt(s)*dpi/144. render_points keeps the looser sqrt(s)*dpi/100 it was calibrated with.
1236+ px = int (np .round (np .sqrt (size ) * (fig_params .fig .dpi / (144 if as_markers else 100 ))))
11871237 cvs = ds .Canvas (plot_width = plot_width , plot_height = plot_height , x_range = x_ext , y_range = y_ext )
11881238
11891239 # ensure color column exists on the frame with positional alignment
0 commit comments