Custom Progress Indicator using ReactiveHTML

Here is an example of ReactiveHTML use to create custom progress indicator

class ArcProgressIndicator(pn.reactive.ReactiveHTML):
        
    progress = param.Number(default=0, bounds=(0, 100))
    transition_duration = param.Number(default=0.5, bounds=(0, None))
    format_options = param.Dict(default={"locale":"en-US", 
                                         "minimumIntegerDigits":"1",
                                         "maximumIntegerDigits":"3",
                                         "minimumFractionDigits":"1",
                                         "maximumFractionDigits":"1"})
    text_style = param.Dict(default={
        "font-size"  : 5,
        "text-anchor": "middle",
        "letter-spacing": -0.5,
    })
    
    empty_color = param.Color(default="#e8f6fd")
    
    fill_color = param.Color(default="#2a87d8")
    
    _template = """
    <div id="arcprog"></div>
    """
    
    _scripts = {
        "render": """
            const container = document.getElementById(`arcprog-${data.id}`)
            const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg")
            svg.setAttribute("viewBox", "0 0 20 9")
            svg.setAttribute("style", "width:100%;height:100%;")
            
            const empty_path = document.createElementNS("http://www.w3.org/2000/svg", "path")
            empty_path.setAttribute("d", "M1 9 A 8 7 0 1 1 19 9")
            empty_path.setAttribute("fill", "none")
            empty_path.setAttribute("stroke-width", "1.5")
            state.empty_path = empty_path
            
            state.path_len = empty_path.getTotalLength()
            
            const fill_path = empty_path.cloneNode()
            fill_path.setAttribute("stroke-dasharray", `${state.path_len * data.progress/100} ${state.path_len}`)
            fill_path.setAttribute("style", `transition: stroke-dasharray ${data.transition_duration}s`)
            state.fill_path = fill_path
            
            text = document.createElementNS("http://www.w3.org/2000/svg", "text")
            text.setAttribute("y","8.8")
            text.setAttribute("x","10")
            self.text_style()
            state.text = text
            svg.appendChild(empty_path)
            svg.appendChild(fill_path)
            svg.appendChild(text)
            container.appendChild(svg)
            self.empty_color()
            self.fill_color()
            self.format_options()
            self.progress()
            
        """,
        "progress": """
                const textNode = document.createTextNode(`${state.formatter.format(data.progress)}%`)
                if(state.text.firstChild)
                    state.text.firstChild.replaceWith(textNode)
                else
                    text.appendChild(textNode)
                state.fill_path.setAttribute("stroke-dasharray", `${state.path_len * data.progress/100} ${state.path_len}`)
            """,
        "transition_duration": """
                state.fill_path.setAttribute("style", `transition: stroke-dasharray ${data.transition_duration}s`)
            """,
        "format_options":
            """
                state.formatter = new Intl.NumberFormat(data.format_options.locale, data.format_options)
            """,
        "text_style": """
                text.setAttribute("style", Object.entries(data.text_style).map(([k, v]) => `${k}:${v}`).join(';'))
            """,
        "empty_color": """
            state.empty_path.setAttribute("stroke", data.empty_color)
        """,
        "fill_color": """
            state.fill_path.setAttribute("stroke", data.fill_color)
            state.text.setAttribute("fill", data.fill_color)
        """,
    }

indicator = ArcProgressIndicator(progress=10, background="#efebeb")
pn.Row(
    indicator.controls()[0],
    indicator
)

progress_indicator

4 Likes

I made some improvement, now we can define a gradient color and some anotations

class ArcProgressIndicator(pn.reactive.ReactiveHTML):
        
    progress = param.Number(default=0, bounds=(0, 100))
    
    transition_duration = param.Number(default=0.5, bounds=(0, None))
    
    format_options = param.Dict(default={"locale":"en-US",
                                         "style": "percent",
                                         "minimumIntegerDigits":"1",
                                         "maximumIntegerDigits":"3",
                                         "minimumFractionDigits":"1",
                                         "maximumFractionDigits":"1"})
    
    text_style = param.Dict(default={
        "font-size"  : 4.5,
        "text-anchor": "middle",
        "letter-spacing": -0.2,
    })
    
    empty_color = param.Color(default="#e8f6fd")
    
    fill_color = param.Color(default="#2a87d8")
    
    use_gradient = param.Boolean(default=False)
    
    gradient = param.Parameter(default=[{"stop": 0, "color": "green"}, {"stop": 1, "color": "red"}])
    
    annotations = param.Parameter(default=[])
    
    viewbox = param.List(default=[0, -1, 20, 10], constant=True)
    
    _template = """
    <div id="arcprog">
        <svg height="100%" width="100%" viewBox="0 -1 20 10" style="display: block;">
          <defs>
              <linearGradient id="grad">
                <stop offset="0" style="stop-color:black" />
                <stop offset="1" style="stop-color:magenta" />
              </linearGradient>
          </defs>
        </svg>
    </div>
    """
    
    _scripts = {
        "render": """
            state.initialized = false
            state.GradientReader = function(colorStops) {

                const canvas = document.createElement('canvas');   // create canvas element
                const ctx = canvas.getContext('2d');               // get context
                const gr = ctx.createLinearGradient(0, 0, 101, 0); // create a gradient

                canvas.width = 101;                                // 101 pixels incl.
                canvas.height = 1;                                 // as the gradient

                for (const { stop, color } of colorStops) {               // add color stops
                    gr.addColorStop(stop, color);
                }

                ctx.fillStyle = gr;                                // set as fill style
                ctx.fillRect(0, 0, 101, 1);                        // draw a single line

                // method to get color of gradient at % position [0, 100]
                return {
                    getColor: (pst) => {
                        const color_array = ctx.getImageData(pst|0, 0, 1, 1).data
                        return `rgb(${color_array[0]}, ${color_array[1]}, ${color_array[2]})`
                    }
                };
            }
            state.container = document.getElementById(`arcprog-${data.id}`)
            const svg = state.container.querySelector("svg")

            const empty_path = document.createElementNS("http://www.w3.org/2000/svg", "path")
            empty_path.setAttribute("d", "M1 9 A 8 8 0 1 1 19 9")
            empty_path.setAttribute("fill", "none")
            empty_path.setAttribute("stroke-width", "1.5")
            state.empty_path = empty_path
            
            const fill_path = empty_path.cloneNode()
            state.fill_path = fill_path
            
            text = document.createElementNS("http://www.w3.org/2000/svg", "text")
            text.setAttribute("y","8.9")
            text.setAttribute("x","10")
            self.text_style()
            state.text = text
            
            //path used to
            const external_path = document.createElementNS("http://www.w3.org/2000/svg", "path")
            external_path.setAttribute("d", "M0.25 9 A 8.75 8.75 0 1 1 19.75 9")
            state.external_path = external_path
            
            svg.appendChild(empty_path)
            svg.appendChild(fill_path)
            svg.appendChild(text)
            
            self.viewbox()
            self.transition_duration()
            self.empty_color()
            self.fill_color()
            self.format_options()
            self.gradient()
            self.progress()
            self.annotations()
            state.initialized = true
        """,
        "annotations": """
            const path_len = state.empty_path.getTotalLength()
            const tot_len = state.external_path.getTotalLength()
            const svg = state.container.querySelector("svg")
            svg.querySelectorAll(".ArcProgressIndicator_annotation").forEach((node) => node.remove())
            const annotations = data.annotations
            annotations.forEach((annotation) => {
                const {progress, text, tick_width, text_size} = annotation
                const annotation_position = state.external_path.getPointAtLength(tot_len * progress/100);
                
                const annot_tick = state.empty_path.cloneNode()
                annot_tick.setAttribute("class", "ArcProgressIndicator_annotation")
                annot_tick.setAttribute("stroke-dasharray", `${tick_width} ${path_len}`)
                annot_tick.setAttribute("stroke-dashoffset", `${-(path_len * progress/100 - tick_width/2)}`)
                annot_tick.setAttribute("stroke", "black")
                
                const annot_text = document.createElementNS("http://www.w3.org/2000/svg", "text")
                annot_text.setAttribute("class", "ArcProgressIndicator_annotation")
                annot_text.setAttribute("x",annotation_position.x)
                annot_text.setAttribute("y",annotation_position.y)
                annot_text.setAttribute("style",`font-size:${text_size};text-anchor:${progress>50 ? "start" : "end"}`)

                const textNode = document.createTextNode(text)
                annot_text.appendChild(textNode)
                
                svg.appendChild(annot_tick)
                svg.appendChild(annot_text)
            })
            
            
        """,
        "progress": """
            const textNode = document.createTextNode(`${state.formatter.format(data.progress / (state.formatter.resolvedOptions().style=="percent" ? 100 : 1))}`)

            if(state.text.firstChild)
                state.text.firstChild.replaceWith(textNode)
            else
                text.appendChild(textNode)
            const path_len = state.empty_path.getTotalLength()
            state.fill_path.setAttribute("stroke-dasharray", `${path_len * data.progress/100} ${path_len}`)
            const current_color = data.use_gradient ? state.gr.getColor(data.progress) : data.fill_color

            if(!state.text_style || !("fill" in state.text_style))
                state.text.setAttribute("fill", current_color)
        """,
        "transition_duration": """
            state.fill_path.setAttribute("style", `transition: stroke-dasharray ${data.transition_duration}s`)
        """,
        "format_options":"""
            state.formatter = new Intl.NumberFormat(data.format_options.locale, data.format_options)
            if (state.initialized)
                self.progress()
        """,
        "text_style": """
                text.setAttribute("style", Object.entries(data.text_style).map(([k, v]) => `${k}:${v}`).join(';'))
            """,
        "empty_color": """
            state.empty_path.setAttribute("stroke", data.empty_color)
        """,
        "fill_color": """
            if (data.use_gradient)
                state.fill_path.setAttribute("stroke", `url(#grad-${data.id}`)
            else
                state.fill_path.setAttribute("stroke", data.fill_color)
        """,
        "use_gradient":"""
            self.fill_color()
            if (state.initialized)
                self.progress()
        """,
        "gradient": """
            const gradientNode = state.container.querySelector("linearGradient")
            gradientNode.querySelectorAll("stop").forEach((stop) => gradientNode.removeChild(stop))
            const list_gradient_values = data.gradient
            list_gradient_values.forEach((elem) => {
                const stopNode = document.createElementNS("http://www.w3.org/2000/svg", "stop")
                stopNode.setAttribute("offset", `${elem.stop}`)
                stopNode.setAttribute("stop-color", `${elem.color}`)
                gradientNode.appendChild(stopNode)
            })
            state.gr = new state.GradientReader(data.gradient)
            if (state.initialized)
                self.progress()
        """,
        "viewbox": """
            const svg = state.container.querySelector("svg")
            svg.setAttribute("viewBox", data.viewbox.join(" "))
        """
    }
    
    def __init__(self, **params):
        if "text_style" in params:
            default_text_style = dict(self.param.text_style.default)
            default_text_style.update(params.get("text_style"))
            params["text_style"] = default_text_style
        if "format_options" in params:
            default_format_options = dict(self.param.format_options.default)
            default_format_options.update(params.get("format_options"))
            params["format_options"] = default_format_options
        
        super().__init__(**params)
        self._on_use_gradient_change()
    
    @pn.depends("use_gradient", watch=True)
    def _on_use_gradient_change(self):
        if self.use_gradient:
            self.param.fill_color.precedence = -1
            self.param.gradient.precedence = 1
        else:
            self.param.fill_color.precedence = 1
            self.param.gradient.precedence = -1
            

    

indicator = ArcProgressIndicator(progress=10, background="#efebeb", 
                                 use_gradient=True, text_style={"fill": "gray"},
                                 format_options={"style": "percent"},
                                 viewbox=[-2, -2, 24, 11],
                                 annotations=[{"progress": 0, "text": "0%", "tick_width": 0.2, "text_size": 0.8},
                                              {"progress": 10, "text": "10%", "tick_width": 0.1, "text_size": 1},
                                              {"progress": 100, "text": "100%", "tick_width": 0.2, "text_size": 0.8}
                                             ]
                                )
pn.Row(
    indicator.controls()[0],
    indicator
)
8 Likes

Great work ! If I understand well, all the css can be embeded with the indicator? no need to load the css in pn.extension. This will simplify my code a lot.

1 Like