holoviz/panel · #8796
fix: Improve Dial rendering and theming
panel/tests/ui/widgets/test_indicators.py20 + / 1 −
@@ -1,16 +1,19 @@+import io+ import pytest pytest.importorskip("playwright") from bokeh.models import Tooltip+from PIL import Image from playwright.sync_api import expect import panel as pn from panel.tests.util import serve_component, wait_until from panel.theme import Fast from panel.widgets import TooltipIcon-from panel.widgets.indicators import Gauge, Progress+from panel.widgets.indicators import Dial, Gauge, Progress pytestmark = pytest.mark.ui @@ -154,3 +157,19 @@ def test_fast_progress_indicator_explicit_height(page): progress_el = page.locator("progress") expect(progress_el).to_have_count(1) expect(progress_el).to_have_css("height", "32px")+++def test_dial_frame_is_transparent_on_dark_background(page):+ dial = Dial(value=25, width=200, height=200)+ row = pn.Row(dial, styles={'background': 'rgb(0, 0, 0)'})++ serve_component(page, row)++ figure = page.locator('.bk-Figure')+ expect(figure).to_have_count(1)+ page.wait_for_timeout(500)+ image = Image.open(io.BytesIO(figure.screenshot())).convert('RGB')+ # The corners sit outside the arc, so anything but the page+ # background there means the figure border is painted.+ for xy in [(1, 1), (image.width-2, 1), (1, image.height-2), (image.width-2, image.height-2)]:+ assert image.getpixel(xy) == (0, 0, 0)panel/tests/widgets/test_indicators.py68 + / 7 −
@@ -41,15 +41,15 @@ def test_dial_thresholds(document, comm): cds = model.select(name='annulus_source') - assert ['green', 'whitesmoke'] == cds.data['color']+ assert ['green', '#e8e8e8'] == cds.data['color'] dial.value = 50 - assert ['yellow', 'whitesmoke'] == cds.data['color']+ assert ['yellow', '#e8e8e8'] == cds.data['color'] dial.value = 72 - assert ['red', 'whitesmoke'] == cds.data['color']+ assert ['red', '#e8e8e8'] == cds.data['color'] def test_dial_none(document, comm):@@ -79,15 +79,15 @@ def test_dial_thresholds_with_bounds(document, comm): cds = model.select(name='annulus_source') - assert ['green', 'whitesmoke'] == cds.data['color']+ assert ['green', '#e8e8e8'] == cds.data['color'] dial.value = 50 - assert ['yellow', 'whitesmoke'] == cds.data['color']+ assert ['yellow', '#e8e8e8'] == cds.data['color'] dial.value = 75 - assert ['red', 'whitesmoke'] == cds.data['color']+ assert ['red', '#e8e8e8'] == cds.data['color'] def test_dial_bounds():@@ -174,7 +174,7 @@ def test_tqdm_color(): @pytest.mark.parametrize(('theme', 'text', 'unfilled'), [- ('default', 'black', 'whitesmoke'), ('dark', 'white', '#424242')+ ('default', 'black', '#e8e8e8'), ('dark', 'white', '#424242') ]) def test_dial_colors_follow_theme(document, comm, theme, text, unfilled): with config.set(theme=theme):@@ -197,6 +197,67 @@ def test_dial_explicit_colors_override_theme(document, comm): assert model.select_one({'name': 'needle_renderer'}).glyph.fill_color == 'blue' +def test_dial_needle_color_update(document, comm):+ dial = Dial(value=25)+ model = dial.get_root(document, comm)++ dial.needle_color = 'blue'++ assert model.select_one({'name': 'needle_renderer'}).glyph.fill_color == 'blue'+ assert model.select_one({'name': 'needle_hub_renderer'}).glyph.fill_color == 'blue'+++def test_dial_is_transparent(document, comm):+ model = Dial(value=25).get_root(document, comm)++ assert model.background_fill_alpha == 0+ assert model.border_fill_alpha == 0+++def test_dial_hides_needle_for_none(document, comm):+ dial = Dial(value=None)+ model = dial.get_root(document, comm)+ needle = model.select_one({'name': 'needle_source'})++ assert list(needle.data['radius']) == [0]++ dial.value = 50++ assert list(needle.data['radius']) == [0.9]+++@pytest.mark.parametrize(('width', 'height'), [(250, 250), (400, 200), (200, 400)])+def test_dial_ranges_preserve_aspect(document, comm, width, height):+ model = Dial(value=25, width=width, height=height).get_root(document, comm)++ x_span = model.x_range.end - model.x_range.start+ y_span = model.y_range.end - model.y_range.start+ assert x_span/width == pytest.approx(y_span/height)+ assert model.x_range.start < -1 and model.x_range.end > 1+ assert model.y_range.end > 1+++def test_dial_ranges_follow_size(document, comm):+ dial = Dial(value=25)+ model = dial.get_root(document, comm)++ dial.width = 500++ x_span = model.x_range.end - model.x_range.start+ y_span = model.y_range.end - model.y_range.start+ assert x_span/500 == pytest.approx(y_span/250)+++def test_dial_tick_labels_align_inwards(document, comm):+ model = Dial(value=25).get_root(document, comm)+ labels = model.select_one({'name': 'label_source'}).data++ assert labels['align'] == ['center', 'center', 'left', 'right']+ assert list(labels['rot']) == [0, 0, 0, 0]+ # Value row sits below the min/max labels to avoid overlapping them.+ assert labels['y'][1] < labels['y'][2]++ def test_linear_gauge_is_transparent(document, comm): model = LinearGauge(value=25).get_root(document, comm) panel/widgets/indicators.py111 + / 20 −
@@ -705,17 +705,98 @@ def __init__(self, **params): def _update_value_bounds(self): self.param.value.bounds = self.bounds + @property+ def _unfilled_color(self) -> str:+ # Without an outline, whitesmoke is indistinguishable from a white page.+ if self.unfilled_color:+ return self.unfilled_color+ return '#424242' if config.theme == 'dark' else '#e8e8e8'++ def _angles(self) -> tuple[float, float, float]:+ start = (np.radians(360-self.start_angle) - pi % (2*pi)) + pi+ end = (np.radians(360-self.end_angle) - pi % (2*pi)) + pi+ distance = (abs(end-start) % (pi*2))+ if end>start:+ distance = (pi*2)-distance+ return start, end, distance++ def _size(self) -> tuple[int, int]:+ height = self.height or self.width or 250+ return self.width or height, height++ def _font_sizes(self) -> tuple[str, str, str]:+ scale = (self._size()[1]/400)+ title_size = self.title_size if self.title_size else '%spt' % (scale*32)+ value_size = self.value_size if self.value_size else '%spt' % (scale*48)+ tick_size = self.tick_size if self.tick_size else '%spt' % (scale*18)+ return title_size, value_size, tick_size++ @staticmethod+ def _font_px(size: str) -> float:+ # Only reserves layout space, so a rough guess for unknown units is fine.+ try:+ if size.endswith('pt'):+ return float(size[:-2])*4/3+ elif size.endswith('px'):+ return float(size[:-2])+ elif size.endswith('em'):+ return float(size[:-2])*16+ except ValueError:+ pass+ return 16++ def _tick_anchors(self, start: float, end: float) -> list[tuple[float, float, str]]:+ # Horizontal labels hung below each arc end, aligned so they grow+ # towards the center instead of out of the plot.+ inner_radius = 1-self.annulus_width+ anchors = []+ for angle in (start, end):+ cos, sin = np.cos(angle), np.sin(angle)+ y = min(sin, sin*inner_radius) - 0.04+ if cos < -0.1:+ anchors.append((cos, y, 'left'))+ elif cos > 0.1:+ anchors.append((cos, y, 'right'))+ else:+ anchors.append((cos*(1-self.annulus_width/2.), y, 'center'))+ return anchors++ def _text_height(self, size: str) -> float:+ # Font sizes are in screen units, so convert them with the+ # resolution the dial would have if it filled the plot.+ width, height = self._size()+ return self._font_px(size)*1.2*2/min(width, height)++ def _value_y(self, start: float, end: float) -> float:+ # Keep the value below the min/max labels so the two never overlap.+ tick_height = self._text_height(self._font_sizes()[2])+ tick_bottom = min(y for _, y, _ in self._tick_anchors(start, end)) - tick_height+ return min(-0.5, tick_bottom)++ def _get_ranges(self) -> tuple[tuple[float, float], tuple[float, float]]:+ start, end, distance = self._angles()+ angles = np.linspace(start, start-distance, 361)+ radii = np.array([[1], [1-self.annulus_width]])+ xs, ys = np.cos(angles)*radii, np.sin(angles)*radii+ xmin, xmax = xs.min(), xs.max()+ ymax = ys.max()+ value_bottom = self._value_y(start, end) - self._text_height(self._font_sizes()[1])+ ymin = min(ys.min(), value_bottom)++ width, height = self._size()+ pad = 0.03+ units = max((xmax-xmin+2*pad)/width, (ymax-ymin+2*pad)/height)+ cx, cy = (xmin+xmax)/2, (ymin+ymax)/2+ dx, dy = units*width/2, units*height/2+ return (cx-dx, cx+dx), (cy-dy, cy+dy)+ def _get_data(self, properties): vmin, vmax = self.bounds value = self.value if value is None: value = float('nan') fraction = (value-vmin)/(vmax-vmin)- start = (np.radians(360-self.start_angle) - pi % (2*pi)) + pi- end = (np.radians(360-self.end_angle) - pi % (2*pi)) + pi- distance = (abs(end-start) % (pi*2))- if end>start:- distance = (pi*2)-distance+ start, end, distance = self._angles() radial_fraction = distance*fraction angle = start if np.isnan(fraction) else (start-radial_fraction) inner_radius = 1-self.annulus_width@@ -754,31 +835,31 @@ def _get_data(self, properties): x, y = np.cos(angle)*center_radius, np.sin(angle)*center_radius needle_start = pi+angle-(self.needle_width/2.) needle_end = pi+angle+(self.needle_width/2.)+ # The wedge's blunt end sits at the origin, so the hub must be+ # wider than the needle base to cover it.+ needle_base = self.needle_width*center_radius needle_data = { 'x': np.array([x]), 'y': np.array([y]), 'start': np.array([needle_start]), 'end': np.array([needle_end]),- 'radius': np.array([center_radius])+ 'radius': np.array([0 if np.isnan(fraction) else center_radius]),+ 'hub_radius': np.array([max(0.05, needle_base*0.7)]) } value = self.format.format(value=value).replace('nan', self.nan_format) min_value = self.format.format(value=vmin) max_value = self.format.format(value=vmax)- tminx, tminy = np.cos(start)*center_radius, np.sin(start)*center_radius- tmaxx, tmaxy = np.cos(end)*center_radius, np.sin(end)*center_radius- tmin_angle, tmax_angle = start+pi, end+pi % pi- scale = (self.height/400)- title_size = self.title_size if self.title_size else '%spt' % (scale*32)- value_size = self.value_size if self.value_size else '%spt' % (scale*48)- tick_size = self.tick_size if self.tick_size else '%spt' % (scale*18)+ (tminx, tminy, tmin_align), (tmaxx, tmaxy, tmax_align) = self._tick_anchors(start, end)+ title_size, value_size, tick_size = self._font_sizes() label_color = self.label_color or _theme_text_color() text_data= { 'x': np.array([0, 0, tminx, tmaxx]),- 'y': np.array([-.2, -.5, tminy, tmaxy]),+ 'y': np.array([-.2, self._value_y(start, end), tminy, tmaxy]), 'text': [self.label, value, min_value, max_value],- 'rot': np.array([0, 0, tmin_angle, tmax_angle]),+ 'rot': np.array([0, 0, 0, 0]),+ 'align': ['center', 'center', tmin_align, tmax_align], 'size': [title_size, value_size, tick_size, tick_size], 'color': [label_color, color, label_color, label_color] }@@ -787,9 +868,11 @@ def _get_data(self, properties): def _get_model(self, doc, root=None, parent=None, comm=None): from bokeh.plotting import figure properties = self._get_properties(doc)+ x_range, y_range = self._get_ranges() model = figure(- x_range=(-1,1), y_range=(-1,1), tools=[],+ x_range=x_range, y_range=y_range, tools=[], outline_line_color=None, toolbar_location=None,+ border_fill_alpha=0, min_border=0, width=self.width, height=self.height, **properties ) model.xaxis.visible = False@@ -807,7 +890,7 @@ def _get_model(self, doc, root=None, parent=None, comm=None): annulus_source = ColumnDataSource(data=annulus, name='annulus_source') model.annular_wedge( x=0, y=0, inner_radius='radius', outer_radius=1, start_angle='starts',- end_angle='ends', line_color='gray', color='color', direction='clock',+ end_angle='ends', line_color=None, color='color', direction='clock', source=annulus_source ) @@ -818,6 +901,10 @@ def _get_model(self, doc, root=None, parent=None, comm=None): fill_color=self._needle_color, line_color=self._needle_color, source=needle_source, name='needle_renderer' )+ model.circle(+ x=0, y=0, radius='hub_radius', fill_color=self._needle_color,+ line_color=None, source=needle_source, name='needle_hub_renderer'+ ) # Draw thresholds threshold_source = ColumnDataSource(data=threshold, name='threshold_source')@@ -829,7 +916,7 @@ def _get_model(self, doc, root=None, parent=None, comm=None): # Draw labels text_source = ColumnDataSource(data=text, name='label_source') model.text(- x='x', y='y', text='text', font_size='size', text_align='center',+ x='x', y='y', text='text', font_size='size', text_align='align', text_color='color', source=text_source, text_baseline='top', angle='rot' )@@ -849,13 +936,17 @@ def _manual_update(self, events, model, doc, root, parent, comm): model.update(**{event.name: event.new}) if event.name in self._data_params: update_data = True- elif event.name == 'needle_color':- needle_r = model.select(name='needle_renderer')+ if event.name == 'needle_color':+ needle_r = model.select_one({'name': 'needle_renderer'}) needle_r.glyph.line_color = self._needle_color needle_r.glyph.fill_color = self._needle_color+ model.select_one({'name': 'needle_hub_renderer'}).glyph.fill_color = self._needle_color if not update_data: return properties = self._get_properties(doc)+ (x0, x1), (y0, y1) = self._get_ranges()+ model.x_range.update(start=x0, end=x1)+ model.y_range.update(start=y0, end=y1) annulus, needle, threshold, labels = self._get_data(properties) model.select(name='annulus_source').data.update(annulus) model.select(name='needle_source').data.update(needle)