scattyr
  • Home
  • Demos Gallery
    • Gallery Overview
    • R Scatter App
    • Python Scatter App

Python Scatter Plot App (Shinylive)

A serverless WebAssembly-powered Python Shiny app demonstrating plotnine and scatter_plotnine() in Python.
Author

Brian S. Yandell

← Back to Demos Gallery

This section displays a Python scatter plot application using scatter_plotnine(), powered directly in your browser via Shinylive (WebAssembly).

The live application runs completely serverless in your browser. Below the application, view the source files for both the application (scatter_plot_app.py) and the plotting module (scatter.py) in separate tabs.

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| viewerHeight: 750
#| components: [viewer]

## file: requirements.txt
pandas
numpy
matplotlib
plotnine

## file: scatter.py
import pandas as pd
import numpy as np
from plotnine import (
    ggplot, aes, geom_point, geom_smooth, facet_wrap, labs,
    theme_minimal, scale_shape_manual, scale_color_brewer, scale_color_hue
)

def scatter_plotnine(
    dat,
    color_by="none",
    shape_by="none",
    facet_by="none",
    add_line=True,
    point_size=3.0,
    point_alpha=0.7,
    x_label="x",
    y_label="y"
):
    if dat is None or "x" not in dat.columns or "y" not in dat.columns:
        return None

    df = dat.copy()
    for col in ["sex", "diet", "geno", "Genotype"]:
        if col in df.columns:
            df[col] = df[col].astype("category")

    if "sex" in df.columns and "diet" in df.columns:
        df["sex_diet"] = (df["sex"].astype(str) + "_" + df["diet"].astype(str)).astype("category")

    if "subject" not in df.columns:
        df["subject"] = df.index.astype(str)

    mapping = {"x": "x", "y": "y"}
    col_var = color_by if (color_by and color_by != "none" and color_by in df.columns) else None
    shape_var = shape_by if (shape_by and shape_by != "none" and shape_by in df.columns) else None
    facet_var = facet_by if (facet_by and facet_by != "none" and facet_by in df.columns) else None

    if col_var:
        mapping["color"] = col_var
        mapping["group"] = col_var
    if shape_var:
        mapping["shape"] = shape_var

    p = ggplot(df, aes(**mapping))

    if add_line:
        if col_var:
            p += geom_smooth(method="lm", se=False, linetype="solid", size=1)
        else:
            p += geom_smooth(aes(group=1), method="lm", se=False, color="black", linetype="solid", size=1)

    if not shape_var:
        p += geom_point(shape="o", fill="none", size=point_size, alpha=point_alpha, stroke=1.5)
    else:
        p += geom_point(fill="none", size=point_size, alpha=point_alpha, stroke=1.5)
        shapes = ["o", "s", "^", "D", "v", "p", "*", "+", "x", "h"]
        p += scale_shape_manual(values=shapes)

    if facet_var:
        p += facet_wrap(f"~ {facet_var}")

    p += theme_minimal(base_size=9)
    p += labs(x=x_label, y=y_label)

    if col_var:
        num_levels = df[col_var].nunique()
        if num_levels <= 8:
            p += scale_color_brewer(type="qual", palette="Dark2")
        else:
            p += scale_color_hue()

    return p

## file: app.py
from shiny import App, Inputs, Outputs, Session, module, reactive, render, ui
from scatter import scatter_plotnine
import pandas as pd
import numpy as np

def make_mock_df(n=200, seed=42):
    np.random.seed(seed)
    df = pd.DataFrame({
        "subject": [f"ind_{i+1}" for i in range(n)],
        "x": np.random.randn(n),
        "sex": np.random.choice(["Female", "Male"], n),
        "diet": np.random.choice(["Chow", "HF"], n),
        "geno": np.random.choice(["AA", "AB", "BB"], n),
    })
    y = (
        2 * df["x"]
        + np.where(df["sex"] == "Male", 1.5, 0.0)
        + np.where(df["diet"] == "HF", -1.0, 0.0)
        + np.where(df["geno"] == "AB", 0.5, np.where(df["geno"] == "BB", 1.0, 0.0))
        + np.random.randn(n) * 0.8
    )
    df["y"] = y
    df.index = df["subject"]
    return df

mock_df = make_mock_df()

app_ui = ui.page_sidebar(
    ui.sidebar(
        ui.input_select("color_by", "Color by:", choices={"none": "None", "sex": "sex", "diet": "diet", "geno": "geno", "sex_diet": "sex_diet"}, selected="none"),
        ui.input_select("shape_by", "Shape by:", choices={"none": "None", "sex": "sex", "diet": "diet", "geno": "geno"}, selected="none"),
        ui.input_select("facet_by", "Facet by:", choices={"none": "None", "sex": "sex", "diet": "diet", "geno": "geno", "sex_diet": "sex_diet"}, selected="none"),
        ui.input_checkbox("add_line", "Add regression line?", value=True),
        ui.input_slider("point_size", "Point Size:", min=1, max=10, value=3, step=0.5),
        ui.input_slider("point_alpha", "Transparency (Alpha):", min=0.1, max=1, value=0.7, step=0.1),
    ),
    ui.card(
        ui.output_plot("plot_static")
    ),
    title="Test Generic Scatter Plot (Python / plotnine)"
)

def server(input: Inputs, output: Outputs, session: Session):
    @output
    @render.plot
    def plot_static():
        col_by = input.color_by()
        shp_by = input.shape_by()
        fct_by = input.facet_by()
        add_l = input.add_line()
        pt_sz = input.point_size()
        pt_al = input.point_alpha()
        return scatter_plotnine(
            dat=mock_df,
            color_by=col_by,
            shape_by=shp_by,
            facet_by=fct_by,
            add_line=add_l,
            point_size=pt_sz,
            point_alpha=pt_al
        )

app = App(app_ui, server)

Source Code

  • scatter_plot_app.py
  • scatter.py
"""
Shiny Scatter Plot Application in Python using plotnine.
"""

import pandas as pd
import numpy as np
from shiny import App, Inputs, Outputs, Session, module, reactive, render, ui
from shinywidgets import output_widget, render_widget
import plotly.express as px

from scatter import scatter_plotnine


def make_mock_df(n: int = 200, seed: int = 42) -> pd.DataFrame:
    """Generate mock dataset for generic scatter plot app."""
    np.random.seed(seed)
    df = pd.DataFrame({
        "subject": [f"ind_{i+1}" for i in range(n)],
        "x": np.random.randn(n),
        "sex": np.random.choice(["Female", "Male"], n),
        "diet": np.random.choice(["Chow", "HF"], n),
        "geno": np.random.choice(["AA", "AB", "BB"], n),
    })
    y = (
        2 * df["x"]
        + np.where(df["sex"] == "Male", 1.5, 0.0)
        + np.where(df["diet"] == "HF", -1.0, 0.0)
        + np.where(df["geno"] == "AB", 0.5, np.where(df["geno"] == "BB", 1.0, 0.0))
        + np.random.randn(n) * 0.8
    )
    df["y"] = y
    df.index = df["subject"]
    return df


@module.ui
def scatter_plot_input():
    return ui.output_ui("scatter_inputs")


@module.ui
def scatter_plot_output():
    return ui.output_ui("plot_ui")


@module.server
def scatter_plot_server(
    input: Inputs,
    output: Outputs,
    session: Session,
    plot_df: reactive.Calc,
    x_label: callable = lambda: "x",
    y_label: callable = lambda: "y"
):
    @output
    @render.ui
    def scatter_inputs():
        dat = plot_df()
        if dat is None or dat.empty:
            return ui.TagList()

        cols = list(dat.columns)
        candidate_vars = [c for c in cols if c not in ["x", "y", "subject"]]

        if "sex" in cols and "diet" in cols:
            candidate_vars.append("sex_diet")

        color_choices = {"none": "None"}
        for v in candidate_vars:
            color_choices[v] = v

        shape_choices = {"none": "None"}
        for v in candidate_vars:
            if v != "sex_diet":
                shape_choices[v] = v

        facet_choices = {"none": "None"}
        for v in candidate_vars:
            facet_choices[v] = v

        return ui.TagList(
            ui.input_radio_buttons("static", "Plot Type:", ["Static", "Interactive"], selected="Static"),
            ui.input_select("color_by", "Color by:", choices=color_choices, selected="none"),
            ui.input_select("shape_by", "Shape by:", choices=shape_choices, selected="none"),
            ui.input_select("facet_by", "Facet by:", choices=facet_choices, selected="none"),
            ui.input_checkbox("add_line", "Add regression line?", value=True),
            ui.input_slider("point_size", "Point Size:", min=1, max=10, value=3, step=0.5),
            ui.input_slider("point_alpha", "Transparency (Alpha):", min=0.1, max=1, value=0.7, step=0.1),
        )

    @reactive.calc
    def plot_obj():
        dat = plot_df()
        if dat is None or "x" not in dat.columns or "y" not in dat.columns:
            return None

        col_by = input.color_by() if "color_by" in input else "none"
        shp_by = input.shape_by() if "shape_by" in input else "none"
        fct_by = input.facet_by() if "facet_by" in input else "none"
        add_l = input.add_line() if "add_line" in input else True
        pt_sz = input.point_size() if "point_size" in input else 3
        pt_al = input.point_alpha() if "point_alpha" in input else 0.7

        x_lbl = x_label() if callable(x_label) else x_label
        y_lbl = y_label() if callable(y_label) else y_label

        return scatter_plotnine(
            dat=dat,
            color_by=col_by,
            shape_by=shp_by,
            facet_by=fct_by,
            add_line=add_l,
            point_size=pt_sz,
            point_alpha=pt_al,
            x_label=x_lbl,
            y_label=y_lbl
        )

    @output
    @render.plot
    def plot_static():
        p = plot_obj()
        return p

    @output
    @render_widget
    def plot_interactive():
        dat = plot_df()
        if dat is None or dat.empty:
            return None
        df = dat.copy()
        if "sex" in df.columns and "diet" in df.columns:
            df["sex_diet"] = df["sex"].astype(str) + "_" + df["diet"].astype(str)

        col_by = input.color_by() if ("color_by" in input and input.color_by() != "none") else None
        shp_by = input.shape_by() if ("shape_by" in input and input.shape_by() != "none") else None
        fct_by = input.facet_by() if ("facet_by" in input and input.facet_by() != "none") else None

        x_lbl = x_label() if callable(x_label) else x_label
        y_lbl = y_label() if callable(y_label) else y_label

        add_l = input.add_line() if "add_line" in input else True

        open_symbols = ["circle-open", "square-open", "diamond-open", "triangle-up-open", "triangle-down-open", "star-open", "cross-open"]
        fig = px.scatter(
            df,
            x="x",
            y="y",
            color=col_by,
            symbol=shp_by,
            symbol_sequence=open_symbols,
            facet_col=fct_by,
            hover_name="subject" if "subject" in df.columns else None,
            labels={"x": x_lbl, "y": y_lbl},
            trendline="ols" if add_l else None
        )
        if not shp_by:
            fig.update_traces(marker_symbol="circle-open")
        fig.update_layout(template="plotly_white")
        return fig

    @output
    @render.ui
    def plot_ui():
        static_val = input.static() if "static" in input else "Static"
        if static_val == "Interactive":
            return output_widget("plot_interactive")
        else:
            return ui.output_plot("plot_static")


def scatter_plot_app():
    """Create and return the Shiny App for scatter plot."""
    mock_df = make_mock_df()

    app_ui = ui.page_sidebar(
        ui.sidebar(
            scatter_plot_input("scatter")
        ),
        ui.card(
            scatter_plot_output("scatter")
        ),
        title="Test Generic Scatter Plot (Python / plotnine)"
    )

    def server(input: Inputs, output: Outputs, session: Session):
        scatter_plot_server("scatter", reactive.calc(lambda: mock_df))

    return App(app_ui, server)


app = scatter_plot_app()
"""
Scatter plot rendering using plotnine.
"""

import pandas as pd
import numpy as np
from plotnine import (
    ggplot, aes, geom_point, geom_smooth, facet_wrap, labs,
    theme_minimal, scale_shape_manual, scale_color_brewer, scale_color_hue
)
from typing import Optional


def scatter_plotnine(
    dat: pd.DataFrame,
    color_by: str = "none",
    shape_by: str = "none",
    facet_by: str = "none",
    add_line: bool = True,
    point_size: float = 3.0,
    point_alpha: float = 0.7,
    x_label: str = "x",
    y_label: str = "y"
) -> Optional[ggplot]:
    """
    Create a plotnine scatter plot mirroring scatter_ggplot in R.

    Parameters
    ----------
    dat : pd.DataFrame
        DataFrame containing 'x' and 'y' columns, plus metadata variables.
    color_by : str
        Column name to color points by, or "none". Default is "none".
    shape_by : str
        Column name to set point shapes by, or "none". Default is "none".
    facet_by : str
        Column name to facet plot by, or "none". Default is "none".
    add_line : bool
        Whether to add linear regression line(s). Default is True.
    point_size : float
        Scatter point size. Default is 3.0.
    point_alpha : float
        Scatter point transparency (0.0 to 1.0). Default is 0.7.
    x_label : str
        X-axis label. Default is "x".
    y_label : str
        Y-axis label. Default is "y".

    Returns
    -------
    plotnine.ggplot.ggplot or None
        A plotnine ggplot object, or None if required columns are missing.
    """
    if dat is None or not isinstance(dat, pd.DataFrame):
        return None

    if "x" not in dat.columns or "y" not in dat.columns:
        return None

    df = dat.copy()

    # Convert candidates to category if present
    for col in ["sex", "diet", "geno", "Genotype"]:
        if col in df.columns:
            df[col] = df[col].astype("category")

    # Calculate sex_diet combination if both columns exist
    if "sex" in df.columns and "diet" in df.columns:
        df["sex_diet"] = (df["sex"].astype(str) + "_" + df["diet"].astype(str)).astype("category")

    # Ensure subject column exists for labeling
    if "subject" not in df.columns:
        df["subject"] = df.index.astype(str)

    mapping = {"x": "x", "y": "y"}

    col_var = color_by if (color_by and color_by != "none" and color_by in df.columns) else None
    shape_var = shape_by if (shape_by and shape_by != "none" and shape_by in df.columns) else None
    facet_var = facet_by if (facet_by and facet_by != "none" and facet_by in df.columns) else None

    if col_var:
        mapping["color"] = col_var
        mapping["group"] = col_var

    if shape_var:
        mapping["shape"] = shape_var

    p = ggplot(df, aes(**mapping))

    # Add regression lines first (so they are plotted behind/below symbols)
    if add_line:
        if col_var:
            p += geom_smooth(method="lm", se=False, linetype="solid", size=1)
        else:
            p += geom_smooth(aes(group=1), method="lm", se=False, color="black", linetype="solid", size=1)

    # Add scatter points (open symbols / transparent fill matching R ggplot shape=1)
    if not shape_var:
        p += geom_point(shape="o", fill="none", size=point_size, alpha=point_alpha, stroke=1.5)
    else:
        p += geom_point(fill="none", size=point_size, alpha=point_alpha, stroke=1.5)
        shapes = ["o", "s", "^", "D", "v", "p", "*", "+", "x", "h", "8", "H", "d", "|", "_"]
        p += scale_shape_manual(values=shapes)

    # Faceting
    if facet_var:
        p += facet_wrap(f"~ {facet_var}")

    # Theme and custom labels
    p += theme_minimal(base_size=9)
    p += labs(x=x_label, y=y_label)

    if col_var:
        num_levels = df[col_var].nunique()
        if num_levels <= 8:
            p += scale_color_brewer(type="qual", palette="Dark2")
        else:
            p += scale_color_hue()

    return p