Feature Example: Icon fonts, per-entity colour, and per-resource icons

Warning

This example has not yet been updated to reflect all of the new features and recommendations from vidigi 2.0.0, which have further simplified the process of adding vidigi to your model and accessing and modifying your animation.

For now, all of the code below will still work, but check out the getting started page for a full guide to the recommended way to use vidigi 2.0.0.

Every entity icon in vidigi has, until now, been an emoji. That’s a fine default, but it has two real limits: the vocabulary is capped at what emoji offer, and emoji are colour fonts - textfont.color has no effect on them at all, so an entity can never be coloured by anything.

entity_icon_font switches the icon to an icon font instead - Font Awesome, Bootstrap Icons, Material Symbols, or any font already on the page. Icon fonts are monochrome, which is what makes entity_colour_by visible on the icon itself. Once flipping or an icon font is in play, annotating an icon with extra text (a length-of-stay figure, say) needs its own feature too - entity_annotation_by - since Plotly gives a single icon’s text node one mirror and one font for the whole node, appended text included. A fourth, independent feature - resource_icon - sets a custom icon on a resource (not an entity), one per event position, so each resource stage can look different: a plain glyph, or an image. resource_icon_font then renders a glyph one in an icon font, chosen independently of entity_icon_font.

Model setup

The same single-step clinic model used in feat_flip_entity_icons - patients arrive, queue for a treatment cubicle, are treated, and leave. Here each patient also carries a priority (‘high’ or ‘low’, assigned at random, purely to demonstrate colouring by an event-log column - it has no effect on the pathway itself).

import random

import plotly.io as pio
from custom_icons_model import Model, g

from vidigi.animation import animate_activity_log
from vidigi.utils import EventPosition, create_event_position_df

pio.renderers.default = "notebook"

random.seed(42)
model = Model(run_number=1)
event_log = model.run()["event_log"]
event_log[["patient", "event", "priority"]].head()
patient event priority
0 1 arrival high
1 1 treatment_wait_begins high
2 1 treatment_begins high
3 2 arrival high
4 2 treatment_wait_begins high

The default: emoji

For comparison, the animation exactly as it’s always looked - one emoji per patient, cycled from vidigi’s built-in list.

event_position_df = create_event_position_df(
    [
        EventPosition(event="arrival", x=50, y=300, label="Arrival"),
        EventPosition(
            event="treatment_wait_begins", x=450, y=275, label="Waiting for Treatment"
        ),
        EventPosition(
            event="treatment_begins",
            x=250,
            y=175,
            resource="n_cubicles",
            label="Being Treated",
        ),
        EventPosition(event="depart", x=170, y=70, label="Exit"),
    ]
)

animate_activity_log(
    event_log=event_log,
    event_position_df=event_position_df,
    entity_col_name="patient",
    scenario=g(),
    every_x_time_units=10,
    limit_duration=300,
    wrap_queues_at=10,
    frame_duration=1000,
    plotly_height=500,
    plotly_width=1200,
    step_snapshot_max=10,
    gap_between_entities=20,
    gap_between_resources=20,
)

An icon font instead of emoji: entity_icon_font

One argument, plus a codepoint instead of an emoji in custom_entity_icon_list - "" is Font Awesome’s fa-walking glyph. The CSS this needs (a @font-face under the hood, and a small hidden element that reliably forces the browser to load it - see the notes below) is injected automatically. - We can use this in conjunction with the flip_icon parameter in our EventPosition objects.

event_position_df = create_event_position_df(
    [
        EventPosition(event="arrival", x=50, y=300, label="Arrival"),
        EventPosition(
            event="treatment_wait_begins", x=450, y=275, label="Waiting for Treatment"
        ),
        EventPosition(
            event="treatment_begins",
            x=250,
            y=175,
            resource="n_cubicles",
            label="Being Treated",
            flip_icons=True,
        ),
        EventPosition(event="depart", x=170, y=70, label="Exit", flip_icons=True),
    ]
)

animate_activity_log(
    event_log=event_log,
    event_position_df=event_position_df,
    entity_col_name="patient",
    scenario=g(),
    every_x_time_units=10,
    limit_duration=200,
    wrap_queues_at=10,
    frame_duration=1000,
    gap_between_entities=20,
    gap_between_resources=20,
    step_snapshot_max=10,
    plotly_height=500,
    plotly_width=1200,
    entity_icon_font="font-awesome",
    custom_entity_icon_list=["\uf554"],  # fa-walking
)
x

Colouring entities by a column: entity_colour_by

Now that the icon is monochrome, colour becomes meaningful. entity_colour_by="priority" colours each patient by that column from the event log, entity_colour_map picks the exact colours, and a legend appears automatically.

animate_activity_log(
    event_log=event_log,
    event_position_df=event_position_df,
    entity_col_name="patient",
    scenario=g(),
    every_x_time_units=10,
    limit_duration=200,
    wrap_queues_at=10,
    step_snapshot_max=10,
    gap_between_entities=20,
    gap_between_resources=20,
    frame_duration=1000,
    plotly_height=500,
    plotly_width=1200,
    entity_icon_font="font-awesome",
    custom_entity_icon_list=["\uf554"],
    entity_colour_by="priority",
    entity_colour_map={"high": "crimson", "low": "steelblue"},
)
x

Combining flip and font with extra text: entity_annotation_by

The animation above already combines a per-event flip_icons, entity_icon_font and entity_colour_by. Appending extra text onto icon itself - a running length-of-stay figure, say - would share the icon’s single SVG <text> node, so it would get mirrored and re-fonted right along with the icon (see the “Annotating an icon with extra text” section of vidigi_docs/customising_animations.qmd for why). entity_annotation_by draws it as a second, independently-styled trace instead - genuinely immune to both - at the cost of roughly doubling the per-frame text payload for every entity.

animate_activity_log(
    event_log=event_log,
    event_position_df=event_position_df,
    entity_col_name="patient",
    scenario=g(),
    every_x_time_units=10,
    limit_duration=200,
    wrap_queues_at=10,
    step_snapshot_max=10,
    gap_between_entities=25,
    gap_between_resources=25,
    frame_duration=1000,
    plotly_height=500,
    plotly_width=1200,
    entity_icon_font="font-awesome",
    custom_entity_icon_list=[""],
    entity_colour_by="priority",
    entity_colour_map={"high": "crimson", "low": "steelblue"},
    entity_annotation_by="priority",
    entity_annotation_offset_y=-30,
)
x
Note

If you are not using flip and/or custom icon fonts, you should prefer appending additional information to the main icon trace as this minimizes the number of points that have to be rendered and stored, reducing the animation size on disk and minimizing the risk of more complex animations slowing down

Confirming the overflow fallback: + n more and gauges

entity_icon_font and entity_colour_by only ever touch a real entity’s icon. The + n more label that appears once a step is too crowded to draw individually, and the segmented gauge step_snapshot_limit_gauges=True swaps it for, are exempt on purpose - the overflow text is ASCII art ([###.....]) or a plain count, and forcing it through an icon font would either render as tofu or, worse, silently substitute a glyph for a digit or letter in a ligature font like Material Symbols.

step_snapshot_max=0 forces every occupied step into overflow, which is an easy way to demonstrate this without needing a busier model. Both the + n more label and the gauge segments below stay in the default font and colour, even with entity_icon_font="font-awesome" and entity_colour_by="priority" both set.

animate_activity_log(
    event_log=event_log,
    event_position_df=event_position_df,
    entity_col_name="patient",
    scenario=g(),
    every_x_time_units=10,
    limit_duration=200,
    wrap_queues_at=15,
    frame_duration=1000,
    plotly_height=500,
    plotly_width=1200,
    entity_icon_font="font-awesome",
    custom_entity_icon_list=[""],
    entity_colour_by="priority",
    entity_colour_map={"high": "crimson", "low": "steelblue"},
    step_snapshot_max=0,  # force every occupied step into '+ n more'
    gap_between_entities=20,
    gap_between_resources=20,
)
x
animate_activity_log(
    event_log=event_log,
    event_position_df=event_position_df,
    entity_col_name="patient",
    scenario=g(),
    every_x_time_units=10,
    limit_duration=200,
    wrap_queues_at=15,
    gap_between_entities=20,
    gap_between_resources=20,
    frame_duration=1000,
    plotly_height=500,
    plotly_width=1200,
    entity_icon_font="font-awesome",
    custom_entity_icon_list=[""],
    entity_colour_by="priority",
    entity_colour_map={"high": "crimson", "low": "steelblue"},
    step_snapshot_max=0,  # force every occupied queue step into overflow
    step_snapshot_limit_gauges=True,  # ...as a gauge instead of '+ n more'
)
x

A custom icon per resource: resource_icon

custom_resource_icon sets one icon for every resource in the animation. resource_icon overrides it for a single event - it’s a field on EventPosition (or a column on a hand-built / CSV event_position_df), so each resource stage can carry its own icon:

# not run here - this model has only one resource stage
create_event_position_df([
    EventPosition(event="registration", x=120, y=175, resource="n_clerks",
                  label="Registration", resource_icon="🖥️"),
    EventPosition(event="treatment_begins", x=250, y=175, resource="n_cubicles",
                  label="Being Treated", resource_icon="bed_icon.svg"),
])

The value is either a text glyph (an emoji or short string) or an image - a URL, a local file path, or a data: URI, recognised by an image file extension or URL scheme. A glyph is drawn as scatter text, exactly like custom_resource_icon, and follows flip_entity_icons / per-event flip_icons. It renders in the page default font unless you set resource_icon_font - the resource-side counterpart to entity_icon_font, taking the same presets or CSS families and chosen independently of it. An image is drawn via Plotly’s layout.images at the resource’s position instead, sized by resource_image_size (falling back to resource_icon_size); it’s static across frames, so it costs nothing extra per frame the way an animated per-entity image would, but it can’t be mirrored - supply it pre-flipped if the layout needs it.

Here the single treatment stage gets a Font Awesome bed glyph (\uf236), with resource_icon_font set so it renders in the icon font - the entities are in Font Awesome too here, but that is a separate choice:

glyph_position_df = create_event_position_df(
    [
        EventPosition(event="arrival", x=50, y=300, label="Arrival"),
        EventPosition(
            event="treatment_wait_begins", x=450, y=275, label="Waiting for Treatment"
        ),
        EventPosition(
            event="treatment_begins",
            x=250,
            y=175,
            resource="n_cubicles",
            label="Being Treated",
            resource_icon="",  # fa-bed
        ),
        EventPosition(event="depart", x=170, y=70, label="Exit"),
    ]
)

animate_activity_log(
    event_log=event_log,
    event_position_df=glyph_position_df,
    entity_col_name="patient",
    scenario=g(),
    every_x_time_units=10,
    limit_duration=200,
    wrap_queues_at=15,
    frame_duration=1000,
    plotly_height=500,
    plotly_width=1200,
    entity_icon_font="font-awesome",
    resource_icon_font="font-awesome",
    custom_entity_icon_list=[""],  # fa-walking
    gap_between_resources=30,
    entity_colour_by="priority",
    entity_colour_map={"high": "crimson", "low": "steelblue"},
)
x

…and now as an image - bed_icon.svg, sitting next to this notebook. The file extension is what routes it to layout.images; resource_image_size sizes it in data units, independent of gap_between_resources.

image_position_df = create_event_position_df(
    [
        EventPosition(event="arrival", x=50, y=300, label="Arrival"),
        EventPosition(
            event="treatment_wait_begins", x=450, y=275, label="Waiting for Treatment"
        ),
        EventPosition(
            event="treatment_begins",
            x=250,
            y=175,
            resource="n_cubicles",
            label="Being Treated",
            resource_icon="bed_icon.svg",
        ),
        EventPosition(event="depart", x=170, y=70, label="Exit"),
    ]
)

animate_activity_log(
    event_log=event_log,
    event_position_df=image_position_df,
    entity_col_name="patient",
    scenario=g(),
    every_x_time_units=10,
    limit_duration=200,
    wrap_queues_at=15,
    frame_duration=1000,
    plotly_height=500,
    plotly_width=1200,
    entity_icon_font="font-awesome",
    custom_entity_icon_list=[""],
    gap_between_resources=50,
    resource_image_size=40,
    entity_colour_by="priority",
    entity_colour_map={"high": "crimson", "low": "steelblue"},
)
x

Different icons per stage, in an icon font: a two-stage model

The model so far has a single resource stage. Here is a two-stage one - patients see a nurse, then rest in a bed - built in two_stage_model.py next to this notebook. Each stage gets its own resource_icon: \uf82f (Font Awesome’s fa-user-nurse) for the nurse, \uf236 (fa-bed) for the bed.

resource_icon_font="font-awesome" renders both in the icon font. It is animation-wide - one font for every glyph resource stage, since the resource icons are drawn as a single trace - so both stages share Font Awesome here, and it is the codepoint in each stage’s resource_icon that tells them apart. The entities stay as plain emoji: resource_icon_font is independent of entity_icon_font, so fonting the resource icons leaves the entity icons alone.

from two_stage_model import Model as TwoStageModel
from two_stage_model import g as two_stage_g

two_stage_log = TwoStageModel(run_number=1).run()["event_log"]

two_stage_positions = create_event_position_df(
    [
        EventPosition(event="arrival", x=50, y=340, label="Arrival"),
        EventPosition(
            event="nurse_wait_begins", x=430, y=320, label="Waiting for Nurse"
        ),
        EventPosition(
            event="nurse_begins",
            x=430,
            y=220,
            resource="n_nurses",
            label="With Nurse",
            resource_icon="",
        ),  # fa-user-nurse
        EventPosition(event="bed_wait_begins", x=430, y=140, label="Waiting for Bed"),
        EventPosition(
            event="bed_begins",
            x=430,
            y=60,
            resource="n_beds",
            label="Resting",
            resource_icon="",
        ),  # fa-bed
        EventPosition(event="depart", x=50, y=40, label="Exit"),
    ]
)

animate_activity_log(
    event_log=two_stage_log,
    event_position_df=two_stage_positions,
    entity_col_name="patient",
    scenario=two_stage_g(),
    every_x_time_units=10,
    limit_duration=200,
    wrap_queues_at=10,
    step_snapshot_max=10,
    frame_duration=1000,
    plotly_height=600,
    plotly_width=1000,
    gap_between_entities=20,
    gap_between_resources=30,
    resource_icon_font="font-awesome",
)
x

Notes

  • entity_icon_font and resource_icon_font need plotly >= 5.23.0. A preset always resolves a numeric font weight (Font Awesome Solid is 900), and numeric textfont.weight on scatter traces only arrived in plotly.js 2.33.0, bundled from plotly.py 5.23.0. Older plotly raises ValueError: Invalid value ... received for the 'weight' property; pip install -U plotly if you hit it. Emoji animations are unaffected.
  • Presets ("font-awesome", "bootstrap-icons", "material-symbols") load their CSS from a CDN - nothing is bundled with vidigi, so this needs network access at view time, and the flip does not show up in a static fig.write_image() export, which renders in its own page.
  • "material-symbols" is a ligature font: custom_entity_icon_list can use names like "directions_walk" instead of a codepoint, and the text renders as the glyph directly.
  • Two Plotly quirks entity_icon_font_css() works around, confirmed on plotly.js 6.7 and 5.12: a textfont.family value containing a standalone number - exactly the shape of “Font Awesome 6 Free”, the vendor’s own name - is silently dropped (the presets are pre-aliased under a digit-free name; a custom family shaped the same way raises a clear error instead); and a browser’s automatic “does this page need this webfont” detection does not reliably notice Plotly’s SVG icons, which is why the injected CSS also includes a small hidden element that forces the font to load regardless.
  • A category with zero entities in whichever frame first needs an empty placeholder trace for it (nobody of a given priority has arrived yet, or - for entity_icon_font alone - nobody at all) used to stay invisible for the rest of the animation once it did have entities: a real fourth Plotly bug, where a placeholder’s trace-level opacity is never reset by a later frame that never needs to set one itself. Fixed by not setting one on the placeholder in the first place.
  • The + n more / ASCII-gauge overflow icon always stays on the default font and out of entity_colour_by’s legend, whatever category it would otherwise fall into - a substituted glyph in place of the ASCII art would be worse than plain text.
  • entity_annotation_by is a second, fully-animated trace - roughly double the per-frame text/point payload of the far cheaper default (appending extra text onto icon yourself). Reach for it specifically when combining annotated icons with flip_entity_icons/entity_icon_font, as shown above
    • not as a default replacement for appending. See the “Annotating an icon with extra text” section of vidigi_docs/customising_animations.qmd for the full trade-off, and example_13 for the appending technique in a real model.
  • resource_icon is set per event, so different resource stages can each have their own - a resource_icon on one EventPosition only overrides custom_resource_icon for that stage. A glyph resource_icon follows flip_entity_icons / a per-event flip_icons, exactly like custom_resource_icon; an image one cannot be mirrored - Plotly has no per-image transform - so supply it pre-flipped if the layout needs it facing the other way.
  • A glyph resource_icon (and custom_resource_icon) renders in the page default font unless resource_icon_font is set - the resource-side counterpart to entity_icon_font, chosen independently, so entities and resources can be in different icon fonts or one left on emoji. The codepoint goes straight into custom_resource_icon / resource_icon; it is animation-wide and does nothing to an image resource_icon.
  • feat_flip_entity_icons covers mirroring an icon in place.
Back to top