holoviz/panel · #8796

fix: Improve Dial rendering and theming

philippjfr · merged Oct 3, 20263 files · 199 + / 28 −
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)