Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions features/cartesian-coordinates.feature
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,9 @@ Feature: Cartesian coordinates
And a set of cartesian axes
And an unreachable cartesian text extent case
Then cartesian finalize should stop at max iterations with remaining overflow

Scenario: Scatterplot markers with empty and label-only values do not break cartesian finalize
Given a default canvas
And a set of cartesian axes
And a scatterplot marker extent edge case
Then cartesian finalize should render without point extent errors
33 changes: 33 additions & 0 deletions features/steps/cartesian-coordinates.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,14 @@

from behave import *

import xml.etree.ElementTree as xml

import numpy
import toyplot
import toyplot.coordinates
import toyplot.data
import toyplot.html
import toyplot.marker

import test
import testing
Expand Down Expand Up @@ -429,3 +435,30 @@ def step_impl(context):
context._max_overflow > toyplot.coordinates._CARTESIAN_FINALIZE_PX_TOL,
msg=f"Expected remaining overflow > tolerance, got {context._max_overflow}",
)


@given(u'a scatterplot marker extent edge case')
def step_impl(context):
marker_style = {"stroke": toyplot.color.black, "fill": "cornsilk"}
label_style = {"stroke": "none", "fill": toyplot.color.black}
markers = [
None,
"",
toyplot.marker.create(shape="", label="A"),
toyplot.marker.create(shape="o", label="1"),
toyplot.marker.create(shape="s", label="B"),
]
context.axes.scatterplot(
numpy.arange(len(markers)),
color="steelblue",
marker=markers,
size=10,
mstyle=marker_style,
mlstyle=label_style,
)


@then(u'cartesian finalize should render without point extent errors')
def step_impl(context):
html = toyplot.html.render(context.canvas)
test.assert_is_instance(html, xml.Element)
151 changes: 124 additions & 27 deletions toyplot/mark.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
import toyplot.color
import toyplot.marker
import toyplot.require
import toyplot.style
import toyplot.text


log = logging.getLogger(__name__)
Expand Down Expand Up @@ -844,38 +846,133 @@ def extents(self, axes):
# iterate over entered axes (e.g., ['x', 'y']) if exists for Mark
assert all(i in self._coordinate_axes for i in axes)

# get coordinates of 'x' 'y' from potentially ['x', 'y0', 'x', 'y1', ... etc]
axis_map = {key: index for index, key in enumerate(self._coordinate_axes)}
coords = tuple([self._table[self._coordinates[axis_map[axis]]] for axis in axes])

# get empty extents arrays to be filled below.
xext = numpy.zeros(coords[0].size)
yext = numpy.zeros(coords[0].size)

# extents requires marker shape (rect or not), size, and stroke-width
_mstyle = {} if self._mstyle is None else self._mstyle
stroke_width = _mstyle.get("stroke-width", 0)
iterdata = zip(
range(coords[0].size),
self._table[self._marker[0]],
self._table[self._msize[0]],
)
dimensions = len(self._coordinate_axes)
series_count = len(self._coordinates) // dimensions

def datum_marker_extents(marker):
size = 0.0 if marker.size is None else toyplot.require.as_float(marker.size)
left = right = top = bottom = 0.0

if marker.shape:
width, height = 1.0, 1.0
if marker.shape[0] == "r":
width, height = (
toyplot.require.as_float(value)
for value in marker.shape[1:].split("x")
)
stroke_width = 0.0
if marker.mstyle is not None:
stroke_width = toyplot.require.as_float(
marker.mstyle.get("stroke-width", 0)
)
xextent = (width * size) / 2 + stroke_width
yextent = (height * size) / 2 + stroke_width
left = -xextent
right = xextent
top = -yextent
bottom = yextent

if marker.label:
label_style = toyplot.style.combine(
{
"-toyplot-vertical-align": "middle",
"fill": toyplot.color.black,
"font-size": "%spx" % (size * 0.75),
"stroke": "none",
"text-anchor": "middle",
},
marker.lstyle,
)
label_left, label_right, label_top, label_bottom = toyplot.text.extents(
[str(marker.label)],
[0 if marker.angle is None else marker.angle],
label_style,
)
label_left = float(label_left[0])
label_right = float(label_right[0])
label_top = float(label_top[0])
label_bottom = float(label_bottom[0])

if marker.shape:
left = min(left, label_left)
right = max(right, label_right)
top = min(top, label_top)
bottom = max(bottom, label_bottom)
else:
left = label_left
right = label_right
top = label_top
bottom = label_bottom

return left, right, top, bottom

coord_parts = [[] for axis in axes]
left_parts = []
right_parts = []
top_parts = []
bottom_parts = []

for series_index in range(series_count):
series_coordinates = self._coordinates[
series_index * dimensions:(series_index + 1) * dimensions
]
series_coords = [
self._table[series_coordinates[axis_map[axis]]] for axis in axes
]
count = series_coords[0].size

for index, values in enumerate(series_coords):
coord_parts[index].append(values)

left = numpy.zeros(count)
right = numpy.zeros(count)
top = numpy.zeros(count)
bottom = numpy.zeros(count)

iterdata = zip(
range(count),
self._table[self._marker[series_index]],
self._table[self._msize[series_index]],
self._table[self._mfill[series_index]],
self._table[self._mstroke[series_index]],
self._table[self._mopacity[series_index]],
)

# fill extents (left, right, top, bottom) for each marker
for idx, shape, size in iterdata:
# Mirror _draw_marker(): falsey markers render nothing, while
# label-only Marker objects contribute text extents only.
for idx, dmarker, dsize, dfill, dstroke, dopacity in iterdata:
if not dmarker:
continue

# if 'r2x1' then width is 2X height
if shape[0] == "r":
width, height = (int(i) for i in shape[1:].split("x"))
else:
width, height = 1, 1
dstyle = toyplot.style.combine(
{
"fill": toyplot.color.to_css(dfill),
"stroke": toyplot.color.to_css(dstroke),
"opacity": dopacity,
},
self._mstyle,
)
marker = (
toyplot.marker.create(size=dsize, mstyle=dstyle, lstyle=self._mlstyle)
+ toyplot.marker.convert(dmarker)
)
left[idx], right[idx], top[idx], bottom[idx] = datum_marker_extents(marker)

# extent is half of size diameter, stroke is already half
xext[idx] = (width * size) / 2 + stroke_width
yext[idx] = (height * size) / 2 + stroke_width
left_parts.append(left)
right_parts.append(right)
top_parts.append(top)
bottom_parts.append(bottom)

# return is usually parsed: (x, y), (left, right, bottom, top) = ...
return coords, (-xext, xext, -yext, yext)
coords = tuple(
numpy.concatenate(parts) if parts else numpy.array([])
for parts in coord_parts
)
extents = tuple(
numpy.concatenate(parts) if parts else numpy.array([])
for parts in [left_parts, right_parts, top_parts, bottom_parts]
)
return coords, extents

@property
def markers(self):
Expand Down
Loading