Petri Dish

A pond petri dish as a cell-tracking lab — follow any specimen's radial travel path on a timeline you can play and scrub, while a classifier reads its motion and names how it moves.

Petri Dish

A pond petri dish as a cell-tracking lab — follow any specimen's radial travel path on a timeline you can play and scrub, while a classifier reads its motion and names how it moves.

---

.. llms_copy::Petri Dish

.. toc::

A pond petri dish you can track

Seventeen real pond microorganisms drift across the dish, each swimming the way its species actually moves. Follow any one of them from the *Track Console*: its journey lights up as a comet trail in the field and as a radial travel path, while a classifier reads the trajectory and predicts *how* it moves — gliding, run-and-tumble, helical, amoeboid, hopping, undulatory or sessile — then checks itself against the catalogue. Press Play to run the timeline, scrub the current-frame slider to step through it, and drag the analysis window to narrow the classifier onto a single stretch of behaviour.

.. admonition::Premium (preview) :color: yellow

RadialLineChart is MUI X Premium (preview) — set MUI_X_LICENSE_KEY to remove the watermark.

.. exec::docs.petri_dish.petri_dish :code: false

The specimens are real — the tracks are physics

There is no public, small, cleanly-licensed multi-cell trajectory CSV on GitHub (the good ones — CellTracksColab, VISEM sperm, Cell Tracking Challenge — live on Zenodo as multi-megabyte sets, and btrack's MIT sample is effectively a single track). So each path here is synthesized from real motility parameters: a characteristic speed in µm/s and a locomotion model per species — gliding diatoms trace smooth directed lines, ciliates run-and-tumble, *Euglena*/*Volvox* swim helices, copepods hop in jerky arcs, amoebae crawl, worms undulate, and sessile species jitter in place. The organism silhouettes are real, openly-licensed art from [PhyloPic](https://www.phylopic.org/) (mostly CC0; see assets/petri/CREDITS.md for the CC-BY attributions).

The classifier

For the followed specimen, over the current analysis window, three scale-invariant trajectory statistics are computed in the browser:

and combined with the species' real speed to predict a movement class. Over the full window the prediction matches the catalogue for every species; narrow the window onto a single run of a tumbling ciliate and watch the directionality climb — the classifier is reading motion, not labels.

How it works

Each microorganism lives at fixed slide coordinates that evolve over T frames. The trajectories are generated once in Python and shipped in a dcc.Store; everything else runs client-side (a dcc.Interval drives playback, and one clientside callback rebuilds the chart series, the travel path, the readout and the classifier each frame):

becomes a fading comet trail in the field and its distance-from-centre over the window becomes the connected line on the Radial Travel Path chart.

toggle the interval and rewind; a dmc.RangeSlider is the analysis window that bounds both playback and the classifier.

report the species under each mark.

.. admonition::Pairs with dash-mantine-components :color: blue

The entire instrument is DMC — Select, Slider, RangeSlider, ActionIcon transport, Badge classifier and the catalogue cards — driving two MUI X RadialLineCharts. It's the same "DMC steers an MUI component" pattern as the [Playground](/playground), scaled up to a time-series tool.

Source

```python

File: docs/petri_dish/petri_dish.py

import math import os import random

from dash import dcc, html, clientside_callback, Input, Output, State import dash_mantine_components as dmc from dash_iconify import DashIconify import dash_mui_scheduler as dms import dash_gauge as dg

/petri-dish — a pond petri dish reimagined as a CELL-TRACKING TIMELINE + a

movement CLASSIFIER (evolved from the /radar mechanics).

#

- 17 real pond microorganisms each swim a synthesized trajectory whose SHAPE

and SPEED come from real motility parameters (gliding diatoms, run-&-tumble

ciliates, helical Euglena/Volvox, jerky copepod hops, amoeboid crawl, …).

- A dmc.Select "follows" one specimen: its path lights up as a radial line and

a comet trail, and a classifier reads its trajectory statistics (speed,

tortuosity, directionality, turn jitter) to predict HOW it moves — then

checks the prediction against the catalogue.

- A dmc.Slider scrubs time; Play / Pause / Stop animate it; a dmc.RangeSlider

narrows the analysis window (the classifier re-runs over just those frames).

#

Trajectories are generated once in Python (deterministic seeds) and shipped in a

dcc.Store; every frame, selection and classification runs client-side.

T = 120 # frames on the timeline RB = 88.0 # world-bound radius (display units); maps to the chart's 0..100 radius

App accent (blue) — replaces the old teal (#0d9488) throughout this page.

ACCENT = "#3399ff" # rgb(51,153,255) — for text / SVG colours ACCENT_NAME = "brand" # Mantine color name (palette defined in appshell theme.colors)

Per-species native "forward" direction of the silhouette, in degrees measured

in image space (0 = the art points east/right). Used to rotate each specimen

image so its front leads its travel heading. Default 0; override to fine-tune.

FACE = {}

--- the specimens: catalogue facts + real motility parameters ----------------

movement_class / traj_type / speed_um_s / turn_jitter come from the sourced

locomotion taxonomy; img is a vendored PhyloPic silhouette (see assets/petri).

SPECIES = [ {"name": "Navicula", "group": "Pennate diatom", "kingdom": "Plant-like algae", "size_um": "20–100", "size_mid": 60, "color": "#b8893a", "shape": "diamond", "traj": "gliding", "speed_um_s": 12, "jit": 0.08, "move": "gliding_glider", "img": "navicula", "fact": "Glides along the slit (raphe) in its silica shell, leaving a mucilage trail."}, {"name": "Closterium", "group": "Desmid (green alga)", "kingdom": "Plant-like algae", "size_um": "100–400", "size_mid": 250, "color": "#6fb15a", "shape": "diamond", "traj": "gliding", "speed_um_s": 2, "jit": 0.10, "move": "gliding_glider", "img": "_generic", "fact": "Crescent-shaped, with gypsum crystals tumbling in vacuoles at each tip."}, {"name": "Spirogyra", "group": "Filamentous green alga", "kingdom": "Plant-like algae", "size_um": "10–150", "size_mid": 40, "color": "#4fa85a", "shape": "circle", "traj": "brownian", "speed_um_s": 0.5, "jit": 0.9, "move": "sessile_brownian", "img": "spirogyra", "fact": "Helical ribbon chloroplasts; forms the green mats called pond scum."}, {"name": "Volvox", "group": "Colonial green alga", "kingdom": "Plant-like algae", "size_um": "350–500", "size_mid": 425, "color": "#5cba6b", "shape": "circle", "traj": "helical", "speed_um_s": 600, "jit": 0.12, "move": "helical_swimmer", "img": "volvox", "fact": "A hollow sphere of flagellated cells rolling along a corkscrew path."}, {"name": "Scenedesmus", "group": "Green alga (coenobium)", "kingdom": "Plant-like algae", "size_um": "6–35", "size_mid": 20, "color": "#7cc24f", "shape": "star", "traj": "brownian", "speed_um_s": 1, "jit": 1.0, "move": "sessile_brownian", "img": "_generic", "fact": "Flat 4- or 8-cell colonies with spines that deter tiny grazers."}, {"name": "Euglena", "group": "Green flagellate", "kingdom": "Plant-like algae", "size_um": "35–70", "size_mid": 55, "color": "#69c24a", "shape": "wye", "traj": "helical", "speed_um_s": 70, "jit": 0.18, "move": "helical_swimmer", "img": "euglena", "fact": "Swims a helix toward light with a red eyespot; eats like an animal in the dark."}, {"name": "Amoeba proteus", "group": "Amoeba", "kingdom": "Protozoa", "size_um": "220–760", "size_mid": 490, "color": "#bfc9b0", "shape": "circle", "traj": "amoeboid", "speed_um_s": 3, "jit": 0.85, "move": "amoeboid_crawler", "img": "amoeba", "fact": "Crawls by flowing pseudopodia — a slow, wandering creep."}, {"name": "Paramecium", "group": "Ciliate", "kingdom": "Protozoa", "size_um": "50–300", "size_mid": 175, "color": "#cfdca8", "shape": "diamond", "traj": "run_and_tumble", "speed_um_s": 1000, "jit": 0.30, "move": "fast_ciliate_runner", "img": "paramecium", "fact": "Fast straight 'runs' punctuated by random 'tumble' reorientations."}, {"name": "Stentor", "group": "Ciliate", "kingdom": "Protozoa", "size_um": "500–2000", "size_mid": 1000, "color": "#7fae8a", "shape": "triangle", "traj": "brownian", "speed_um_s": 4, "jit": 0.80, "move": "sessile_brownian", "img": "stentor", "fact": "A giant trumpet that anchors and snaps shut in milliseconds."}, {"name": "Vorticella", "group": "Ciliate", "kingdom": "Protozoa", "size_um": "30–100", "size_mid": 65, "color": "#a9d0c4", "shape": "wye", "traj": "brownian", "speed_um_s": 2, "jit": 0.85, "move": "sessile_brownian", "img": "vorticella", "fact": "Anchored by a coiled stalk that yanks the bell down in <10 ms."}, {"name": "Actinosphaerium", "group": "Heliozoan", "kingdom": "Protozoa", "size_um": "200–1000", "size_mid": 400, "color": "#cbe0e6", "shape": "star", "traj": "amoeboid", "speed_um_s": 1.5, "jit": 0.80, "move": "amoeboid_crawler", "img": "actinosphaerium", "fact": "A 'sun animalcule' that rolls slowly on radiating axopodia."}, {"name": "Philodina", "group": "Rotifer", "kingdom": "Micro-animal", "size_um": "150–500", "size_mid": 300, "color": "#cdb89a", "shape": "wye", "traj": "ballistic", "speed_um_s": 150, "jit": 0.40, "move": "undulatory_crawler", "img": "philodina", "fact": "Creeps and swims with a whirling ciliated 'wheel organ'."}, {"name": "Tardigrade", "group": "Water bear", "kingdom": "Micro-animal", "size_um": "50–1200", "size_mid": 400, "color": "#c79a6b", "shape": "cross", "traj": "ballistic", "speed_um_s": 163, "jit": 0.45, "move": "undulatory_crawler", "img": "_generic", "fact": "Lumbers on eight clawed legs; survives drying, freezing, even vacuum."}, {"name": "Daphnia", "group": "Water flea (crustacean)", "kingdom": "Micro-animal", "size_um": "200–5000", "size_mid": 2000, "color": "#d6b98f", "shape": "circle", "traj": "hopping", "speed_um_s": 1000, "jit": 0.60, "move": "jerky_hopper", "img": "daphnia", "fact": "Hops in jerky arcs by rowing its branched antennae."}, {"name": "Cyclops", "group": "Copepod (crustacean)", "kingdom": "Micro-animal", "size_um": "500–5000", "size_mid": 1500, "color": "#a9c0a0", "shape": "triangle", "traj": "hopping", "speed_um_s": 3000, "jit": 0.50, "move": "jerky_hopper", "img": "cyclops", "fact": "Darts in fast bursts; the female carries paired egg sacs."}, {"name": "C. elegans", "group": "Nematode", "kingdom": "Micro-animal", "size_um": "250–1000", "size_mid": 1000, "color": "#cfcab4", "shape": "diamond", "traj": "ballistic", "speed_um_s": 300, "jit": 0.35, "move": "undulatory_crawler", "img": "celegans", "fact": "A transparent worm of exactly 959 cells, wriggling in S-waves."}, {"name": "Hydra", "group": "Cnidarian polyp", "kingdom": "Micro-animal", "size_um": "1000–20000", "size_mid": 10000, "color": "#8fb87a", "shape": "star", "traj": "brownian", "speed_um_s": 3, "jit": 0.90, "move": "sessile_brownian", "img": "_generic", "fact": "Mostly anchored; built from self-renewing stem cells, it barely ages."}, ]

--- trajectory generation (deterministic; see /tmp tuning -> 17/17 classify) --

def _disp_speed(v): return max(0.6, min(7.0, 0.6 + 2.3 * math.log10(1 + v)))

def _steer_inward(heading, x, y, strength): r = math.hypot(x, y) if r > 0.62 * RB: inward = math.atan2(-y, -x) d = min(1.0, (r - 0.62 * RB) / (0.38 * RB)) diff = math.atan2(math.sin(inward - heading), math.cos(inward - heading)) heading += diff * min(1.0, strength * d) return heading

def _clamp(x, y): r = math.hypot(x, y) if r > RB: x *= RB / r; y *= RB / r return x, y

def _gen_track(traj, v, jit, seed): r = random.Random(seed) ds = _disp_speed(v) directed = traj in ("gliding", "helical", "run_and_tumble", "ballistic") a0 = r.uniform(0, 2 * math.pi) if directed: rr = r.uniform(45, 72) x, y = rr * math.cos(a0), rr * math.sin(a0) heading = math.atan2(-y, -x) + r.gauss(0, 0.35) else: rr = r.uniform(8, 48) x, y = rr * math.cos(a0), rr * math.sin(a0) heading = r.uniform(0, 2 * math.pi) drift = r.uniform(0, 2 * math.pi) pts = [[round(x, 1), round(y, 1)]] run_left = 0 for f in range(1, T): if traj == "brownian": x += r.gauss(0, ds * 0.45); y += r.gauss(0, ds * 0.45) elif traj == "gliding": heading += r.gauss(0, jit * 0.18) heading = _steer_inward(heading, x, y, 0.25) s = min(ds, 1.0) x += math.cos(heading) * s; y += math.sin(heading) * s elif traj == "helical": heading += r.gauss(0, 0.02) heading = _steer_inward(heading, x, y, 0.25) s = min(ds, 1.3) lat = math.sin(f * 0.5) * s * 0.25 x += math.cos(heading) * s + math.cos(heading + math.pi / 2) * lat y += math.sin(heading) * s + math.sin(heading + math.pi / 2) * lat elif traj == "run_and_tumble": if run_left <= 0: heading = r.uniform(0, 2 * math.pi); run_left = r.randint(20, 34) run_left -= 1 heading = _steer_inward(heading, x, y, 0.5) s = min(ds, 1.9) x += math.cos(heading) * s; y += math.sin(heading) * s elif traj == "amoeboid": heading += r.gauss(0, 0.7) s = ds * 0.5 x += math.cos(heading) * s + math.cos(drift) * 0.40 y += math.sin(heading) * s + math.sin(drift) * 0.40 x, y = _clamp(x, y) elif traj == "hopping": if f % 3 == 0: heading = r.uniform(0, 2 * math.pi) x += math.cos(heading) * ds * 2.4; y += math.sin(heading) * ds * 2.4 else: x += r.gauss(0, ds * 0.05); y += r.gauss(0, ds * 0.05) elif traj == "ballistic": heading += r.gauss(0, jit * 0.5) heading = _steer_inward(heading, x, y, 0.3) s = min(ds, 1.4) lat = math.sin(f * 0.8) * s * 0.72 x += math.cos(heading) * s + math.cos(heading + math.pi / 2) * lat y += math.sin(heading) * s + math.sin(heading + math.pi / 2) * lat x, y = _clamp(x, y) pts.append([round(x, 1), round(y, 1)]) return pts

TRACKS = [ {"sp": i, "pts": _gen_track(sp["traj"], sp["speed_um_s"], sp["jit"], seed=1000 + i)} for i, sp in enumerate(SPECIES) ]

--- size classifier ----------------------------------------------------------

Specimens span ~20 µm (Scenedesmus) to ~10 000 µm (Hydra) — a 500x range that

can't be shown at literal linear scale (the small ones would vanish). So the

on-dish image size follows a PERCEPTUAL (log) scale of the real mid-size: it

grows monotonically with µm, stays faithful in ordering, and keeps everything

visible. disp = rendered edge in chart units; size_class = a coarse bin.

_SIZE_LO = math.log10(min(sp["size_mid"] for sp in SPECIES)) _SIZE_HI = math.log10(max(sp["size_mid"] for sp in SPECIES)) _DISP_MIN, _DISP_MAX = 16.0, 48.0 # smallest / largest rendered edge (chart units)

def _disp_px(mid): t = (math.log10(mid) - _SIZE_LO) / (_SIZE_HI - _SIZE_LO) return round(_DISP_MIN + (_DISP_MAX - _DISP_MIN) * t, 1)

def _size_class(mid): return ("giant" if mid >= 5000 else "large" if mid >= 1000 else "medium" if mid >= 200 else "small" if mid >= 50 else "tiny")

stash a resolvable asset url + native-orientation offset + display size on each

species, used by the portrait, the catalogue, and the on-dish specimen images.

for sp in SPECIES: sp["img"] = f"/assets/petri/{sp['img']}.svg" sp["face"] = FACE.get(sp["name"], 0) sp["disp"] = _disp_px(sp["size_mid"]) sp["size_class"] = _size_class(sp["size_mid"])

NA = 120 ANGLES = [i * 360 / NA for i in range(NA + 1)]

FIELD_SX = { "& .MuiChartsRadialGrid-line": {"stroke": "rgba(51, 153, 255, 0.16)"}, "& .MuiChartsRadialAxis-line": {"stroke": "rgba(51, 153, 255, 0.32)"}, "& .MuiChartsRadialAxis-tick": {"stroke": "rgba(51, 153, 255, 0.32)"}, "& text": {"fill": "light-dark(rgba(28, 74, 128, 0.7), rgba(150, 195, 255, 0.78))", "fontFamily": "monospace", "fontSize": "10px"}, }

_SHAPE_GLYPH = {"circle": "●", "diamond": "◆", "square": "■", "star": "★", "triangle": "▲", "cross": "✚", "wye": "⋎"} _KINGDOM_COLOR = {"Plant-like algae": "green", "Protozoa": "indigo", "Micro-animal": "orange"} _MOVE_LABEL = { "gliding_glider": "Gliding (substrate)", "helical_swimmer": "Helical swimmer", "fast_ciliate_runner": "Fast ciliate · run-&-tumble", "amoeboid_crawler": "Amoeboid crawl", "sessile_brownian": "Sessile · Brownian", "jerky_hopper": "Jerky hopper", "undulatory_crawler": "Undulatory crawler", } _MOVE_COLOR = { "gliding_glider": "indigo", "helical_swimmer": "grape", "fast_ciliate_runner": "red", "amoeboid_crawler": "orange", "sessile_brownian": "blue", "jerky_hopper": "pink", "undulatory_crawler": "lime", } _LICENSE = os.environ.get("MUI_X_LICENSE_KEY", "")

------------------------------------------------------------------ field ------

_field = dmc.Paper( [ dmc.Group( [ dmc.Text("PETRI DISH", fw=700, c=ACCENT, style={"letterSpacing": "3px", "fontFamily": "monospace"}), dmc.Badge("● FRAME 000 / 119 • 17 CELLS", id="petri-badge", color=ACCENT_NAME, variant="light", styles={"root": {"fontFamily": "monospace"}}), ], justify="space-between", mb="xs", ), # stage = circular dish (clipped) + a joystick overlay parked at the # bottom-right that pans the magnified viewport at zoom > 1x. html.Div( [ html.Div( html.Div( [ dms.RadialLineChart( id="petri-chart", height=420, licenseKey=_LICENSE, series=[], rotationAxis=[{"data": ANGLES, "min": 0, "max": 360, "tickInterval": [0, 45, 90, 135, 180, 225, 270, 315]}], radiusAxis=[{"min": 0, "max": 100, "position": "none"}], grid={"rotation": True, "radius": True}, hideLegend=True, skipAnimation=True, margin=14, sx=FIELD_SX, slotProps={"tooltip": {"trigger": "item"}}, ), # specimen-image layer: an SVG (same viewBox as the chart) drawn # each frame by a clientside callback; sits inside the viewport so # it zooms / pans / tracks together with the chart. html.Div(id="petri-overlay", style={"position": "absolute", "top": 0, "left": 0, "width": "100%", "height": "420px", "pointerEvents": "none", "zIndex": 2}), ], id="petri-viewport", # block (NOT flex) so the responsive RadialLineChart measures a # real parent width; origin = chart's true centre (half of height=420). style={"width": "100%", "height": "100%", "position": "relative", "transformOrigin": "50% 210px"}, ), className="petri-dish", style={ "borderRadius": "50%", "aspectRatio": "1 / 1", "width": "100%", "overflow": "hidden", }, ), html.Div( dg.DashRCJoystick( id="petri-joy", baseRadius=46, controllerRadius=20, directionCountMode="Nine", insideMode=True, throttle=50, ), id="petri-joy-wrap", style={"position": "absolute", "right": "6px", "bottom": "6px", "zIndex": 6, "opacity": 0.35, "pointerEvents": "none"}, ), ], style={"position": "relative", "maxWidth": 460, "margin": "0 auto"}, ), dcc.Interval(id="petri-play", interval=110, n_intervals=0, disabled=True), dcc.Interval(id="petri-pan", interval=70, n_intervals=0, disabled=True), dcc.Store(id="petri-tracks", data=TRACKS), dcc.Store(id="petri-species", data=SPECIES), dcc.Store(id="petri-view", data={"z": 1, "fx": 0, "fy": 0}), dcc.Store(id="petri-overlay-sink"), ], p="md", radius="md", withBorder=True, style={"borderColor": "rgb(51 142 234 / 24%)", "flex": "1 1 460px", "maxWidth": 560, "background": "linear-gradient(rgb(255 255 255 / 0%) 0%, rgb(243 247 239 / 30%) 100%)"}, )

----------------------------------------------------------------- console -----

def _zoom_label(icon, txt): return dmc.Center([DashIconify(icon=icon, width=15), html.Span(txt)], style={"gap": 6})

_console = dmc.Stack( [ dmc.Text("TRACK CONSOLE", fw=700, c=ACCENT, style={"letterSpacing": "2px", "fontFamily": "monospace"}), dmc.Stack( [ dmc.Group( [DashIconify(icon="mdi:magnify", width=15, color=ACCENT), dmc.Text("MAGNIFY", fw=700, size="xs", c="dimmed", style={"letterSpacing": "1px", "fontFamily": "monospace"})], gap=6, ), dmc.SegmentedControl( id="petri-zoom", value="1", fullWidth=True, color=ACCENT_NAME, radius="md", size="sm", data=[ {"value": "1", "label": _zoom_label("mdi:magnify-minus-outline", "1×")}, {"value": "2", "label": _zoom_label("mdi:magnify", "2×")}, {"value": "3", "label": _zoom_label("mdi:magnify-plus-outline", "3×")}, ], ), dmc.Text("Zoom in, then steer with the joystick — or flip Track to auto-follow.", size="xs", c="dimmed"), ], gap=4, ), dmc.Group( [ dmc.Select( id="petri-specimen", label="Follow specimen", value="all", allowDeselect=False, searchable=True, maxDropdownHeight=260, data=[{"value": "all", "label": "All specimens (17)"}] + [{"value": str(i), "label": sp["name"]} for i, sp in enumerate(SPECIES)], leftSection=DashIconify(icon="mdi:microscope"), style={"flex": "1 1 auto"}, ), dmc.Tooltip( label="Auto-follow the selected specimen (locks the joystick)", withArrow=True, position="top", multiline=True, w=200, children=dmc.Switch( id="petri-track", size="lg", radius="xl", color=ACCENT_NAME, mb=4, onLabel=DashIconify(icon="tabler:viewfinder", width=15), offLabel=DashIconify(icon="tabler:hand-finger", width=15), thumbIcon=DashIconify(icon="tabler:focus-2", width=12, color="var(--mantine-color-brand-6)"), ), ), ], align="flex-end", gap="sm", wrap="nowrap", ), dmc.Paper( dmc.Group( [ html.Img(id="petri-portrait", src="/assets/petri/_generic.svg", className="petri-silhouette", style={"width": 66, "height": 66, "objectFit": "contain", "flex": "0 0 auto"}), dmc.Stack( [ dmc.Text("All specimens", id="petri-portrait-name", fw=700, size="sm"), dmc.Text("17 tracked cells · pond water", id="petri-portrait-meta", size="xs", c="dimmed"), dmc.Text("Pick a specimen to follow its path and classify its movement.", id="petri-portrait-fact", size="xs"), ], gap=2, ), ], gap="sm", wrap="nowrap", align="flex-start", ), withBorder=True, radius="md", p="xs", ), dmc.Paper( dmc.Stack( [ dmc.Group( [ dmc.Text("CLASSIFIER", fw=700, size="xs", c="dimmed", style={"letterSpacing": "1px", "fontFamily": "monospace"}), dmc.Badge("— no specimen —", id="petri-class", color="gray", variant="filled"), ], justify="space-between", ), html.Pre(id="petri-readout", style={"fontFamily": "monospace", "fontSize": "11px", "color": "light-dark(#236bb3, #9ec9ff)", "margin": 0, "whiteSpace": "pre-wrap"}, children="Select a specimen to trace its\npath and classify how it moves."), ], gap="xs", ), withBorder=True, radius="md", p="sm", style={"background": "light-dark(#eef5ff, #16202e)"}, ), ], align="stretch", gap="sm", style={"flex": "1 1 250px", "maxWidth": 320}, )

---------------------------------------------------------------- timeline -----

def _ai(icon, _id, color=ACCENT_NAME): return dmc.ActionIcon(DashIconify(icon=icon, width=20), id=_id, color=color, variant="light", size="lg")

_marks = [{"value": v, "label": str(v)} for v in (0, 30, 60, 90, 119)] _timeline = dmc.Paper( [ dmc.Group( [ dmc.Text("TIMELINE", fw=700, c=ACCENT, style={"letterSpacing": "2px", "fontFamily": "monospace"}), dmc.Group( [_ai("mdi:play", "petri-play-btn"), _ai("mdi:pause", "petri-pause-btn", "yellow"), _ai("mdi:stop", "petri-stop-btn", "red")], gap="xs", ), ], justify="space-between", mb="lg", ), dmc.Text("Current frame", size="xs", c="dimmed", mb=2), dmc.Slider(id="petri-time", min=0, max=T - 1, value=0, step=1, updatemode="drag", marks=_marks, color=ACCENT_NAME, mb="xl"), dmc.Text("Analysis window (narrow to classify a single behaviour)", size="xs", c="dimmed", mb=2), dmc.RangeSlider(id="petri-window", min=0, max=T - 1, value=[0, T - 1], step=1, minRange=8, updatemode="drag", marks=_marks, color="indigo"), ], p="md", radius="md", withBorder=True, mt="md", )

------------------------------------------------------- radial travel path ----

_pathcard = dmc.Paper( [ dmc.Text("RADIAL TRAVEL PATH", fw=700, c=ACCENT, style={"letterSpacing": "2px", "fontFamily": "monospace"}), dmc.Text("The followed cell's distance from the dish centre over the analysis window, " "swept clockwise as time advances — the orange dot is the current frame.", size="xs", c="dimmed", mb="xs"), dms.RadialLineChart( id="petri-path", height=300, licenseKey=_LICENSE, series=[], rotationAxis=[{"data": [0, 360], "min": 0, "max": 360, "tickInterval": [0, 90, 180, 270]}], radiusAxis=[{"min": 0, "max": 100, "position": "none"}], grid={"rotation": True, "radius": True}, hideLegend=True, skipAnimation=True, margin=14, sx=FIELD_SX, ), ], p="md", radius="md", withBorder=True, mt="md", )

----------------------------------------------------------------- catalog -----

def _specimen_card(sp): return dmc.Paper( [ dmc.Group( [ html.Img(src=sp["img"], className="petri-silhouette", style={"width": 30, "height": 30, "objectFit": "contain"}), dmc.Text(sp["name"], fw=700, size="sm"), dmc.Text(_SHAPE_GLYPH.get(sp["shape"], "●"), c=sp["color"], style={"fontSize": "16px", "marginLeft": "auto"}), ], gap="xs", wrap="nowrap", ), dmc.Group( [ dmc.Badge(sp["kingdom"], size="xs", variant="light", color=_KINGDOM_COLOR.get(sp["kingdom"], "gray")), dmc.Badge(_MOVE_LABEL[sp["move"]], size="xs", variant="dot", color=_MOVE_COLOR[sp["move"]]), ], gap=6, mt=6, ), dmc.Text(f"~{sp['size_um']} µm ({sp['size_class']}) · {sp['speed_um_s']} µm/s", size="xs", c="dimmed", mt=4), dmc.Text(sp["fact"], size="xs", mt=4), ], withBorder=True, radius="md", p="sm", )

_catalog = dmc.Stack( [ dmc.Text("SPECIMEN CATALOG", fw=700, c=ACCENT, style={"letterSpacing": "2px", "fontFamily": "monospace"}), dmc.SimpleGrid(cols={"base": 1, "sm": 2, "md": 3}, spacing="sm", children=[_specimen_card(sp) for sp in SPECIES]), ], gap="xs", mt="lg", )

component = dmc.Stack( [ dmc.Group([_field, _console], align="flex-start", gap="xl", wrap="wrap"), _timeline, _pathcard, _catalog, ], gap="md", )

============================ client-side callbacks ===========================

1) Per-frame render: field marks + comet trail + radial travel path + classifier.

clientside_callback( """ function(t, sel, win, tracks, species) { const NA = 120, R = 100, RB = 88; const angles = []; for (let i = 0; i <= NA; i++) { angles.push(i * 360 / NA); } tracks = tracks || []; species = species || []; const T = tracks.length ? tracks[0].pts.length : 0; const noPath = [{data: [0, 360], min: 0, max: 360}]; if (!T) { return [[], [], noPath, '', '—', 'gray', '']; }

let w0 = 0, w1 = T - 1; if (win) { w0 = Math.max(0, Math.min(T - 1, win[0])); w1 = Math.max(0, Math.min(T - 1, win[1])); if (w1 < w0) { const z = w0; w0 = w1; w1 = z; } } const tc = Math.max(0, Math.min(T - 1, Math.round(t == null ? 0 : t))); const idx = (sel == null || sel === 'all') ? -1 : parseInt(sel);

const bucket = b => Math.round(b / 360 * NA) % (NA + 1); function polar(p) { const d = Math.hypot(p[0], p[1]); return {brg: (Math.atan2(p[0], p[1]) * 180 / Math.PI + 360) % 360, rad: Math.min(R, d / RB * R), dist: d}; }

const LABELS = {gliding_glider: 'Gliding (substrate)', helical_swimmer: 'Helical swimmer', fast_ciliate_runner: 'Fast ciliate · run-&-tumble', amoeboid_crawler: 'Amoeboid crawl', sessile_brownian: 'Sessile · Brownian', jerky_hopper: 'Jerky hopper', undulatory_crawler: 'Undulatory crawler'}; const COLORS = {gliding_glider: 'indigo', helical_swimmer: 'grape', fast_ciliate_runner: 'red', amoeboid_crawler: 'orange', sessile_brownian: 'blue', jerky_hopper: 'pink', undulatory_crawler: 'lime'}; const hexToRgb = h => { h = (h || '#3399ff').replace('#', ''); if (h.length === 3) { h = h.split('').map(c => c + c).join(''); } const n = parseInt(h, 16); return (n >> 16 & 255) + ',' + (n >> 8 & 255) + ',' + (n & 255); }; function classify(speed, tort, dir, jit) { if (speed < 50) { if (dir > 0.5 && tort < 1.8) { return 'gliding_glider'; } if (dir < 0.16 && tort > 4.0) { return 'sessile_brownian'; } return 'amoeboid_crawler'; } if (speed > 200 && dir < 0.3 && tort > 2.6) { return 'jerky_hopper'; } if (speed > 500 && tort >= 1.6) { return 'fast_ciliate_runner'; } if (dir > 0.5 && tort < 1.6 && jit < 0.2) { return 'helical_swimmer'; } if (speed >= 50 && speed <= 400) { return 'undulatory_crawler'; } return 'helical_swimmer'; } const pad = (v, k) => ('00000' + Math.round(v)).slice(-k); const fmtV = v => v >= 1000 ? (v / 1000).toFixed(1) + ' mm/s' : v + ' µm/s';

// --- every cell's current position at frame tc ------------------------ // Marks are HIDDEN here (showMark:false): the specimen *images* are drawn // by the overlay callback. Kept as series so the data/structure is intact. const series = []; tracks.forEach((tk, i) => { const sp = species[tk.sp] || {}; const p = tk.pts[tc]; if (!p) { return; } const pol = polar(p); const arr = angles.map(() => null); arr[bucket(pol.brg)] = pol.rad; series.push({data: arr, showMark: false, shape: sp.shape || 'circle', curve: 'linear', color: sp.color || '#3399ff', label: sp.name + ' · ' + (sp.group || '')}); });

let pathSeries = [], pathRot = noPath, readout, classLabel = '—', classColor = 'gray'; if (idx >= 0 && tracks[idx]) { const tk = tracks[idx], sp = species[idx] || {}; const trail = hexToRgb(sp.color); // comet trail tinted to the specimen colour // comet trail (spatial, last frames up to tc) const t0 = Math.max(w0, tc - 16); for (let f = t0; f <= tc; f++) { const p = tk.pts[f]; if (!p) { continue; } const pol = polar(p); const a = 0.12 + 0.78 * ((f - t0) / Math.max(1, tc - t0)); const arr = angles.map(() => null); arr[bucket(pol.brg)] = pol.rad; series.push({data: arr, showMark: true, shape: 'circle', curve: 'linear', color: 'rgba(' + trail + ',' + a.toFixed(2) + ')', label: sp.name + ' · t=' + f}); } // radial travel-path trace over the window: angle = time, radius = distance from centre const N = Math.max(1, w1 - w0), rot = [], rad = []; for (let f = w0; f <= w1; f++) { const p = tk.pts[f]; if (!p) { continue; } rot.push((f - w0) / N * 360); rad.push(polar(p).rad); } pathRot = [{data: rot, min: 0, max: 360, tickInterval: [0, 90, 180, 270]}]; const head = rad.map(() => null); const hi = Math.max(0, Math.min(rad.length - 1, tc - w0)); head[hi] = rad[hi]; pathSeries = [ {data: rad, showMark: false, curve: 'catmullRom', color: '#3399ff', label: 'distance from centre'}, {data: head, showMark: true, shape: 'circle', color: '#e8590c', label: 'now'}, ]; // classifier over the window const seg = []; for (let f = w0; f <= w1; f++) { if (tk.pts[f]) { seg.push(tk.pts[f]); } } let gross = 0; for (let k = 1; k < seg.length; k++) { gross += Math.hypot(seg[k][0] - seg[k - 1][0], seg[k][1] - seg[k - 1][1]); } const A = seg[0], B = seg[seg.length - 1]; const net = Math.hypot(B[0] - A[0], B[1] - A[1]); const tort = gross > 0 ? gross / Math.max(net, 1e-6) : 1; const dir = gross > 0 ? net / gross : 0; let ts = 0, tn = 0; for (let k = 1; k < seg.length - 1; k++) { const ax = seg[k][0] - seg[k - 1][0], ay = seg[k][1] - seg[k - 1][1]; const bx = seg[k + 1][0] - seg[k][0], by = seg[k + 1][1] - seg[k][1]; const m1 = Math.hypot(ax, ay), m2 = Math.hypot(bx, by); if (m1 < 1e-6 || m2 < 1e-6) { continue; } let ca = (ax * bx + ay * by) / (m1 * m2); ca = Math.max(-1, Math.min(1, ca)); ts += Math.acos(ca); tn++; } const jit = tn > 0 ? (ts / tn) / Math.PI : 0; const speed = sp.speed_um_s || 0; const cls = classify(speed, tort, dir, jit); classLabel = LABELS[cls] || cls; classColor = COLORS[cls] || 'gray'; const match = (cls === sp.move) ? '✓ matches catalog' : '≈ catalog: ' + (LABELS[sp.move] || sp.move); readout = 'SIZE ' + (sp.size_um || '?') + ' µm · ' + (sp.size_class || '') + '\\n' + 'SPEED ' + fmtV(speed) + '\\n' + 'TORTUOSITY ' + tort.toFixed(2) + '\\n' + 'DIRECTION ' + dir.toFixed(2) + '\\n' + 'TURN JITTER ' + jit.toFixed(2) + '\\n' + 'WINDOW ' + (w1 - w0 + 1) + ' frames\\n' + match; } else { readout = 'Select a specimen to trace its\\npath and classify how it moves.'; } const badge = '● FRAME ' + pad(tc, 3) + ' / ' + (T - 1) + ' • ' + tracks.length + ' CELLS'; return [series, pathSeries, pathRot, readout, classLabel, classColor, badge]; } """, Output("petri-chart", "series"), Output("petri-path", "series"), Output("petri-path", "rotationAxis"), Output("petri-readout", "children"), Output("petri-class", "children"), Output("petri-class", "color"), Output("petri-badge", "children"), Input("petri-time", "value"), Input("petri-specimen", "value"), Input("petri-window", "value"), State("petri-tracks", "data"), State("petri-species", "data"), )

2) Specimen portrait (image + name + facts) — only when the selection changes.

clientside_callback( """ function(sel, species) { species = species || []; if (sel == null || sel === 'all') { return ['/assets/petri/_generic.svg', 'All specimens', '17 tracked cells · pond water', 'Pick a specimen to follow its path and classify its movement.']; } const sp = species[parseInt(sel)]; if (!sp) { return window.dash_clientside.no_update; } return [sp.img || '/assets/petri/_generic.svg', sp.name, sp.group + ' · ' + sp.kingdom + ' · ~' + sp.size_um + ' µm', sp.fact]; } """, Output("petri-portrait", "src"), Output("petri-portrait-name", "children"), Output("petri-portrait-meta", "children"), Output("petri-portrait-fact", "children"), Input("petri-specimen", "value"), State("petri-species", "data"), )

3) Playback: advance the current frame each tick, looping within the window.

clientside_callback( """ function(n, t, win) { let w0 = 0, w1 = 119; if (win) { w0 = win[0]; w1 = win[1]; } let nt = Math.round(t == null ? 0 : t) + 1; if (nt > w1 || nt < w0) { nt = w0; } return nt; } """, Output("petri-time", "value", allow_duplicate=True), Input("petri-play", "n_intervals"), State("petri-time", "value"), State("petri-window", "value"), prevent_initial_call=True, )

4) Transport controls: Play / Pause / Stop toggle the interval (+ Stop rewinds).

clientside_callback( """ function(p, pa, st, win) { const ctx = window.dash_clientside.callback_context; const trig = (ctx.triggered[0] || {}).prop_id || ''; const nu = window.dash_clientside.no_update; if (trig.indexOf('petri-play-btn') >= 0) { return [false, nu]; } if (trig.indexOf('petri-pause-btn') >= 0) { return [true, nu]; } if (trig.indexOf('petri-stop-btn') >= 0) { return [true, win ? win[0] : 0]; } return [nu, nu]; } """, Output("petri-play", "disabled"), Output("petri-time", "value", allow_duplicate=True), Input("petri-play-btn", "n_clicks"), Input("petri-pause-btn", "n_clicks"), Input("petri-stop-btn", "n_clicks"), State("petri-window", "value"), prevent_initial_call=True, )

5) Magnify + pan: the SegmentedControl sets the zoom factor; the view offset

{fx, fy} in [-1, 1] is produced two ways:

* TRACK ON -> follow the selected specimen: each time the frame, specimen,

zoom or switch changes, recompute the pan that centres the cell. The CSS

transition in callback 6 turns these jumps into a smooth fly-to/follow.

* TRACK OFF -> the pan interval integrates the held joystick's (angle,

distance) into the offset (hold-to-pan; state persists while held).

clientside_callback( """ function(nTick, zoomVal, tFrame, trackOn, specSel, angle, distance, view, tracks) { const ZOOM = {'1': 1.0, '2': 2.0, '3': 3.2}; const BETA = 0.426; // plot radius / viewport width (measured from the chart) const RB = 88; // world-bound radius (matches the Python generator) const nu = window.dash_clientside.no_update; const ctx = window.dash_clientside.callback_context; const trig = (ctx.triggered[0] || {}).prop_id || ''; view = view || {z: 1, fx: 0, fy: 0}; tracks = tracks || []; const z = ZOOM[zoomVal] || 1; const idx = (specSel == null || specSel === 'all') ? -1 : parseInt(specSel); const T = tracks.length ? tracks[0].pts.length : 0;

// pan fractions that centre specimen idx at frame f (null if not possible) function centerOn(f) { if (idx < 0 || !tracks[idx] || z <= 1) { return null; } const p = tracks[idx].pts[f]; if (!p) { return null; } const d = Math.hypot(p[0], p[1]); const rad = Math.min(100, d / RB * 100); const brg = (Math.atan2(p[0], p[1]) * 180 / Math.PI + 360) % 360; const a = brg * Math.PI / 180; const g = (2 * z / (z - 1)) * BETA * (rad / 100); // brg is clockwise from north; translate(+x) reveals left, translate(+y) // reveals top, so to pull the cell to centre: fx ~ -sin, fy ~ +cos. let fx = -g * Math.sin(a); let fy = g * Math.cos(a); fx = Math.max(-1, Math.min(1, fx)); fy = Math.max(-1, Math.min(1, fy)); return {z: z, fx: fx, fy: fy}; }

// --- pan interval tick: joystick only, and only when NOT tracking -------- if (trig.indexOf('petri-pan') >= 0) { if (trackOn) { return nu; } // joystick locked while tracking if (z <= 1 || angle == null) { return nu; } const MAXD = 26; // joystick outerRadius (insideMode: baseRadius - controllerRadius) const STEP = 0.085; // pan fraction added per tick at full deflection const mag = Math.min(1, (distance || 0) / MAXD); if (mag < 0.04) { return nu; } const a = angle * Math.PI / 180; // 0=right, 90=up, 180=left, 270=down let fx = (view.fx || 0) - Math.cos(a) * mag * STEP; let fy = (view.fy || 0) + Math.sin(a) * mag * STEP; fx = Math.max(-1, Math.min(1, fx)); fy = Math.max(-1, Math.min(1, fy)); return {z: z, fx: fx, fy: fy}; }

// --- tracking on: follow the specimen on frame/zoom/specimen/switch ------ if (trackOn) { if (z <= 1) { return {z: 1, fx: 0, fy: 0}; } const fc = Math.max(0, Math.min(T ? T - 1 : 0, Math.round(tFrame == null ? 0 : tFrame))); const c = centerOn(fc); return c || nu; }

// --- free mode (track off): only the zoom control changes the view ------- if (trig.indexOf('petri-zoom') >= 0) { if (z <= 1) { return {z: 1, fx: 0, fy: 0}; } return {z: z, fx: view.fx || 0, fy: view.fy || 0}; } if (trig.indexOf('petri-track') >= 0) { // switch turned OFF -> stop following, keep wherever the view is now return {z: z, fx: view.fx || 0, fy: view.fy || 0}; } return nu; // frame/specimen changes don't pan while in free mode } """, Output("petri-view", "data"), Input("petri-pan", "n_intervals"), Input("petri-zoom", "value"), Input("petri-time", "value"), Input("petri-track", "checked"), Input("petri-specimen", "value"), State("petri-joy", "angle"), State("petri-joy", "distance"), State("petri-view", "data"), State("petri-tracks", "data"), prevent_initial_call=True, )

6) Apply the view state: scale + pan the viewport, and reconcile the joystick

with the Track switch. Track ON -> hide + lock the joystick, stop the pan

interval, and use a longer transition so the viewport glides (fly-to/follow).

Track OFF -> the joystick behaves as before (active when zoomed, dimmed at 1x).

clientside_callback( """ function(view, trackOn) { view = view || {z: 1, fx: 0, fy: 0}; const z = view.z || 1; // translate is in element-box %; net visible shift = z * translate, and the // half-overflow each side is (z-1)/2, so k = 50*(z-1)/z maps fx,fy in [-1,1] // to the exact pan range that keeps content edges within the dish. const k = z > 1 ? 50 * (z - 1) / z : 0; const tx = (view.fx || 0) * k; const ty = (view.fy || 0) * k; const vp = { width: '100%', height: '100%', transformOrigin: '50% 210px', willChange: 'transform', transition: trackOn ? 'transform 0.35s cubic-bezier(0.22, 1, 0.36, 1)' : 'transform 0.1s linear', transform: 'scale(' + z + ') translate(' + tx.toFixed(2) + '%, ' + ty.toFixed(2) + '%)' }; const zoomed = z > 1; const show = !trackOn; // hidden entirely while tracking const enabled = zoomed && !trackOn; // interactive only when zoomed & free const joy = { position: 'absolute', right: '6px', bottom: '6px', zIndex: 6, padding: '6px', borderRadius: '50%', background: 'rgba(255,255,255,0.55)', backdropFilter: 'blur(2px)', boxShadow: '0 2px 10px rgba(15,80,72,0.25)', transition: 'opacity 0.25s ease', opacity: show ? (zoomed ? 1 : 0.35) : 0, transform: show ? 'scale(1)' : 'scale(0.6)', pointerEvents: enabled ? 'auto' : 'none' }; // pan interval only needed for the joystick (free mode, zoomed in) return [vp, joy, !(zoomed && !trackOn)]; } """, Output("petri-viewport", "style"), Output("petri-joy-wrap", "style"), Output("petri-pan", "disabled"), Input("petri-view", "data"), Input("petri-track", "checked"), )

7) Specimen images on the dish. Each frame, draw an SVG (same viewBox as the

chart, so it lines up exactly) into #petri-overlay with one <image> per cell:

* positioned by the world->screen map (cx,cy) + (wx,-wy) * (196/88),

* tinted to the species colour via an feFlood + feComposite(SourceAlpha)

filter (works for ANY colour + ANY silhouette path — fill is ignored,

only the alpha/shape matters),

* rotated to its smoothed travel heading, with an upright front/back flip

so a cell swimming left stays right-side-up instead of upside-down.

The overlay lives inside #petri-viewport, so it zooms / pans / tracks for free.

clientside_callback( """ function(t, sel, tracks, species) { const el = document.getElementById('petri-overlay'); if (!el) { return window.dash_clientside.no_update; } tracks = tracks || []; species = species || []; const T = tracks.length ? tracks[0].pts.length : 0; if (!T) { el.innerHTML = ''; return 0; } const tc = Math.max(0, Math.min(T - 1, Math.round(t == null ? 0 : t))); const idx = (sel == null || sel === 'all') ? -1 : parseInt(sel); const SCALE = 196 / 88, CX = 230, CY = 210; // radius axis 100 -> 196px; centre (230,210)

let defs = '', body = ''; tracks.forEach((tk, i) => { const sp = species[i] || {}; const p = tk.pts[tc]; if (!p) { return; } const x = CX + p[0] * SCALE, y = CY - p[1] * SCALE; // screen y is down -> flip wy

// smoothed travel heading over the last few frames (screen space) const f0 = Math.max(0, tc - 3); const q = tk.pts[f0] || p; const vx = p[0] - q[0], vy = p[1] - q[1]; const vmag = Math.hypot(vx, vy); let deg = 0, flip = 1; if (vmag > 0.8) { deg = Math.atan2(-vy, vx) * 180 / Math.PI - (sp.face || 0); const d = ((deg % 360) + 360) % 360; if (d > 90 && d < 270) { flip = -1; } // facing left -> mirror so it stays upright }

const isSel = (i === idx); // size scaled to the specimen's real size (perceptual/log scale, set in // Python as sp.disp); the followed cell is enlarged 1.5x (capped). const base = sp.disp || 27; const S = isSel ? Math.min(58, base * 1.5) : base; const op = (idx >= 0 && !isSel) ? 0.45 : 1; const col = sp.color || '#3399ff'; const fid = 'ptint' + i; defs += '<filter id="' + fid + '" x="-30%" y="-30%" width="160%" height="160%">' + '<feFlood flood-color="' + col + '" result="f"/>' + '<feComposite in="f" in2="SourceAlpha" operator="in"/></filter>'; const ring = isSel ? '<circle r="' + (S * 0.66).toFixed(1) + '" fill="none" stroke="' + col + '" stroke-width="1.6" opacity="0.55"/>' : ''; body += '<g transform="translate(' + x.toFixed(2) + ',' + y.toFixed(2) + ') rotate(' + deg.toFixed(1) + ') scale(1,' + flip + ')" opacity="' + op + '">' + ring + '<image href="' + (sp.img || '') + '" x="' + (-S / 2) + '" y="' + (-S / 2) + '" width="' + S + '" height="' + S + '" preserveAspectRatio="xMidYMid meet"' + ' filter="url(#' + fid + ')"></image>' + '</g>'; }); el.innerHTML = '<svg width="100%" height="100%" viewBox="0 0 460 420" ' + 'preserveAspectRatio="xMidYMid meet" style="overflow:visible">' + '<defs>' + defs + '</defs>' + body + '</svg>'; return tc; } """, Output("petri-overlay-sink", "data"), Input("petri-time", "value"), Input("petri-specimen", "value"), State("petri-tracks", "data"), State("petri-species", "data"), ) ```

:defaultExpanded: false :withExpandedButton: true

---

*Source: /petri-dish*

Note for AI agents: This is the static, prerendered view of an interactive Dash application served because we detected a non-JS user agent. Full prose docs: