Pages and Visualizations#
A page function takes in data and returns a website as a folder, all the
data's inside. The server hosts that folder. Usually the folder is a
cg.Page that a @cg.ui.PageBuilder() function built from cg.ui
components; it may also be any directory value holding an index.html, such
as a report a tool wrote. A page is declared beside the target it reads, at a
schema position, the way an interceptor is; the server calls the
root page function with the loaded target, memoized by the input's content
hash, so an unchanged target is instant, and hosts the folder it returned at
/pages/<target>/~/. The root page links to
/pages/<target>/sessions/<16-hex>/, a URL known ahead of time from the
position, and visiting it calls the session page function with that session,
cached the same way. Nothing in a sealed folder is ever modified. The
walkthrough adds two pages to a target step by
step.
Pages are where the thesis becomes visible. Because the schema is declared before capture begins, analysis can run during the study rather than after it: a page is that analysis, addressed at a schema position and recomputed as data lands. Participants and investigators see coverage — which conditions the dataset still lacks — while there is still time to change what gets captured, which is the same information the interceptors use to decide whom to interrupt.
Quick start#
uv run python -c "from PIL import Image; Image.new('RGB', (640, 480), (90, 120, 40)).save('$HOME/photo.jpg')"
KEY=$(printf '%016X' $(( $(date +%s) * 1000000 )))
curl -X PUT -H 'X-CG-File-Extension: jpeg' --data-binary @"$HOME/photo.jpg" \
"http://localhost:4433/api/v1/targets/PlantJournal/files/sessions/$KEY/photo"
Open http://localhost:4433/pages/PlantJournal/sessions/$KEY/, then
http://localhost:4433/pages/PlantJournal/, then http://localhost:4433/pages.
From a shell:
BASE=http://localhost:4433/pages/PlantJournal/sessions/$KEY
curl -s -o /dev/null "$BASE/"
until curl -s "$BASE/~status" | grep -q '"state": *"ready"'; do sleep 1; done
curl -s "$BASE/~/"
Declaring a page#
| Parameter | Meaning |
|---|---|
Schema |
The target's schema class, the one the module itself declares. |
select |
A selector from the schema root to the page's position, lambda s: s.sessions.for_each(); None is the root. |
version |
The page's version. The function is memoized under "<function name>/<version>"; bumping it invalidates every render and keeps every URL. |
A page is as public as the target's files: whatever access rule the server
applies to /api/v1/targets/<target>/files/… applies to its pages, and
nothing else gates them.
The function takes one parameter, named as you like and annotated with
the type at the selected position, and returns a directory value whose folder
holds an index.html. The usual form is a @cg.ui.PageBuilder() function:
its body is annotated -> None and builds components, and the wrapper the
decorator returns is annotated -> cg.Page, which is what @cgserver.page
sees. A plain function may instead return any directory scalar itself
(below). Anything else, two pages
at one pattern, or a selector the schema lacks is a DefinitionError when the
module loads, reported through the target's status.
@cgserver.page(PlantJournal, version=1)
@cg.ui.PageBuilder()
def journal(target: PlantJournal) -> None: ... # the root: ""
@cgserver.page(PlantJournal, lambda s: s.sessions, version=1)
@cg.ui.PageBuilder()
def sessions(sessions: cg.Map[cg.Date, Session]) -> None: ... # the map: sessions
@cgserver.page(PlantJournal, lambda s: s.sessions.for_each(), version=1)
@cg.ui.PageBuilder()
def session(session: Session) -> None: ... # each session: sessions/*
@cgserver.page(PlantJournal, lambda s: s.sessions.for_each().photo, version=1)
@cg.ui.PageBuilder()
def photo(photo: cg.Image) -> None: ... # each photo: sessions/*/photo
Under @cg.ui.PageBuilder() the body reads as the page, top to bottom:
every component it constructs attaches to the innermost open stack in
construction order, the way procedure nodes attach to the recording context
under @cg.procedure; with cg.ui.HStack(): or with cg.ui.VStack():
opens a nested stack for the block; cg.ui.Title(...) names the page and
renders nothing in the body. Ordinary Python does the rest: a for loop
builds one PageLink per session, an if decides whether to say something.
@cgserver.page(PlantJournal, version=1)
@cg.ui.PageBuilder()
def journal(target: PlantJournal) -> None:
cg.ui.Title("Plant Journal")
cg.ui.Markdown("## Sessions")
with cg.ui.HStack():
cg.ui.Stat("Sessions", len(target.sessions))
cg.ui.Stat("Days", 4)
cg.ui.Gallery.of(target.sessions, lambda s: session_thumbnail(s.photo))
for instant, session in target.sessions.to_dict().items():
cg.ui.PageLink(session, title=instant.date().isoformat())
A page function is a cg.pure_function, so the recipes guide
holds: it is an ordinary synchronous call, its result seals into the scratch,
and editing the body without bumping the version keeps serving the old
render. The server loads the position's value with cg.load and passes it.
Selectors#
A selector is a plain function of the schema, run once at decoration against
a stand-in that records the fields it walks. The grammar and its errors are
the interceptors': for_each() is the
only way past a Map or Array, a field of a container without it is a load
error naming the fix, and indexing is a load error. What differs is where a
page may stop.
- A page may stop anywhere.
lambda s: s.sessions.for_each()is one page per session (sessions/*, parameterSession);lambda s: s.sessions.for_each().photoone per photograph (sessions/*/photo, parametercg.Image), answering at…/photo/. - A page may be declared at the container itself.
lambda s: s.sessionsis the single page of the whole map (sessions, parametercg.Map[cg.Date, Session]), answering at…/sessions/; it exists as soon as the directory does. - No data, no page. A position whose file or directory is absent is
404, whatever its type.
What a page is on disk#
A @cg.ui.PageBuilder() function returns a cg.Page: the components its
body built, in a root VStack, and the title a cg.ui.Title set. Nothing
is written until the page is built, which the store does when the page
function returns. The built folder is:
| Entry | Contents |
|---|---|
index.html |
The rendered tree, with <title> when the page named one. |
files/ |
Every file a component shows, numbered in staging order (files/0.jpeg); one source is copied once. |
lib/<library>/ |
Each browser library a component used, copied whole with its license. |
The title is the page's name wherever it is listed: the browser tab, the
card at /pages, ~status. Derive it from the data rather than repeating
the function's name. A page with no file props has no files/; one using no
library has no lib/.
Outside a builder, a stack takes its children outright and
cg.Page.declare(root, title=None) makes the page from the tree:
cg.Page.declare(cg.ui.VStack(children=[cg.ui.Markdown("# Hi")]), title="Hi").
Inside a builder, children= is not needed and doubles a component that was
already attached to the open stack.
Returning a folder that already exists#
A page function need not use cg.ui at all. It may return any directory
scalar — a cg.DirScalar subclass, such as a directory value a tool wrote
its own HTML report into — as long as the folder holds an index.html. The
server hosts the folder as it stands at the position's ~/, serving every
file inside it, and reads the page's title from the <title> of that
index.html. cg.Page is one such value, and cg.ui is one way to make
one. A returned folder without index.html fails the render with an error
naming index.html.
class Report(cg.DirScalar, name="edu.example.report"): ...
@cg.pure_function("coverage_report/1")
def coverage_report(sessions: cg.Map[cg.Date, Session]) -> Report:
report = Report.new()
(report / "index.html").write_text(
f"<title>Coverage</title><p>{len(sessions)} sessions captured.</p>"
)
return report
@cgserver.page(PlantJournal, lambda s: s.sessions, version=1)
def sessions(sessions: cg.Map[cg.Date, Session]) -> Report:
return coverage_report(sessions)
Such a folder links however its author wrote it; PageLink and the of
constructors are how a cg.ui page links, and a hand-made folder holding an
index.html is a cg.Page as it stands: cg.Page.load(folder) takes it,
stores it byte for byte, and reads page.title from the <title>.
Links#
A page links to another value's page by the relative path between the two
values' on-disk positions, plus one ../ for the ~/ segment the server
hosts the folder under. The server sets which value a page is for (page.at)
after the page function returns, so authors never do. From the root page a
session links as ../sessions/<key>/; from a session page the root is
../../../, a sibling ../../<key>/, the page itself ../. A link is a
position URL, and only a position with a page declared exactly there
answers, so link to values whose pattern the target declares a page at.
Only PageLink and the of constructors link; every other component
shows its data and links nowhere.
cg.ui.PageLink(value, title=None)is a card linking tovalue's page, captionedtitleor the value's folder name.valuemust have a position in the target: the page function's argument and everything reached through it, or anything acg.loadreturns. A value built in memory, changed since it was loaded, or computed by a recipe fails the build with a message naming its type; so does aPageLinkin a page with noat.- Pictures and markers link through the
ofconstructors.Gallery.of(container, image),Timelapse.of(container, image)andMapViewer.of(container, location, label=None, anchor=None, marker="pin", outline=None)walk a loadedMaporArraywithpmap, so the per-entry recipes run in parallel, and link each result to its entry's page, not to wherever the result lives. A thumbnail computed per session has no position of its own;Gallery.of(target.sessions, thumbnail)links it to its session. A function returningNoneorMissingleaves that entry out, soGallery.of(target.sessions, lambda s: thumbnail(s.photo))skips a session whose photo never landed: a recipe called with aMissingargument returnsMissing. A function that raises on an entry, or returns aMissingcarrying an error, leaves that entry out the same way; the walk fails only when every entry fails. Over a container built in memory,ofdraws the items unlinked. - A
Scatterplots columns, not entries. Itscolumnsare one quantity per point —frames.iso,sun.altitude, a plain list — every column the same length, and the dict's keys are the axis menus and the axis titles. A value that was never recorded is absent (aMissingfrom a broadcast column reads as one), so that point sits off the axes showing it and a column with nothing recorded is left out of the menus altogether. Broadcast access is what builds a column:frames.exif.isoreads the field off every element, andframes["latitude", "iso"]builds rows of several at once. - Without
of, nothing links:ImageViewer(session.photo),Gallery(target.sessions.photo),Timelapse(...)andMapViewer(locations)show their pictures and markers as they are. A keyed collection still captions each item by its key.
URLs#
Every URL under /pages is a target name (which may nest, Plants/Tulip),
a target-relative path spelled as the file API spells it, and an optional
ask after a ~ segment. A trailing slash is optional.
| URL | Behavior |
|---|---|
/pages |
The index, under the server's configured name: every page position as a tree of links. Targets nest under their group directories; a target with a root page links to it, titled by the target's name or by its root page's <title> once that page has a render. Beneath a target, each field with a page is a link, and a Map or Array folds open into its entries on disk, captioned by key. |
/pages/<target>/<path…>/ |
The position. 302 to its ~/ when the folder is ready; the computing view otherwise. 404 unless a page is declared exactly at that path's pattern and data exists there: the map sessions/ on a target with pages only at sessions/*, a key nobody captured, or a target's root without a root page are all 404. |
…/~/ |
The hosted folder's index.html with a banner on top: a button up to the nearest ancestor with a page (the index when there is none) and the trail from the site through the target and each path component, linked wherever a page is declared. ETag = a hash of what is served, Cache-Control: no-cache; 304 when the caller holds it. A folder that no longer matches the value on disk sends the visitor back through the position URL. |
…/~/<file…> |
A file of the folder held right now, same ETag, so an open page keeps its files while a fresher render computes. 404 once evicted. |
…/~status |
state (ready, computing, failed, pending), hash, title, error, running ({id, parent, label, identity, key, depth, elapsed, done, total, note, fields, log_tail} per step in flight beneath the page's call). Every key always present. 404 where the position is. |
/pages/<target>/~pages |
{"patterns": [...]}: every position the target declares a page at, * per container crossed; [] for a target without pages. What the app asks. |
A component that could escape a directory is 400; a path that is not a
position of the schema, a key that is not canonical, an unknown ask, or an
unknown target is 404.
Components#
Every component is a class in cg.ui; its annotated fields are its props,
checked when it is built. A component given Missing for any prop renders
as nothing: cg.ui.Stat("Wind", session.weather.wind_speed_mps) leaves the
page when no weather was recorded, and so does
cg.ui.ImageViewer(web_jpeg(session.photo)), because the recipe returns
Missing for a missing photo. Page code therefore reads the value it wants
and lets absence fall out, rather than guarding with cg.is_missing; guard
only where absence changes what to say. A page is self-contained except for
the map tiles, which MapViewer fetches from OpenStreetMap.
cg.ui.PageBuilder() is the decorator that turns a component-building
function into a page function: under it, each component below attaches to
the innermost open stack as it is built, with on a stack nests, and
Title names the page. The builder's root is a VStack, so a page with no
stacks of its own is its components top to bottom.
| Component | Renders | Stages |
|---|---|---|
Title(text) |
Nothing in the body; sets the page's <title>. Raises outside a builder. |
|
VStack(gap=16) |
Children top to bottom; with opens it for the block. Raises outside a builder unless given children=. |
|
HStack(gap=16) |
Children side by side, wrapping when narrow; with likewise. |
|
Divider() |
A faint line across the page. | |
Padding(size=16) |
size pixels of empty space along the enclosing stack's axis. |
|
Markdown(text) |
CommonMark as prose; raw HTML is shown, not interpreted. | |
Stat(label, value, unit=None) |
One labelled figure. | |
ImageViewer(image, caption=None) |
One picture, linked to its own page when it has one. | |
Gallery(images) |
A grid of pictures, captioned by key when keyed; Gallery.of for derived pictures. |
|
Timelapse(images) |
A Map[Time, Image] scrubbed in time order with a slider; Timelapse.of likewise. |
|
MapViewer(locations, labels=None, marker="pin", outline=None, anchors=None) |
Markers on a map, popups from labels or the keys; "dot" plots hundreds close together, outline draws a boundary under them, an anchors point is joined to its marker by a dashed line; MapViewer.of links them. |
lib/leaflet |
Scatter(columns, default=None, groups=None, spread=3.0) |
Points over named columns, either axis picked from a menu; each axis spans spread deviations of its own column, clamped to it. |
lib/vega, lib/vega-lite, lib/vega-embed |
Chart(spec, data=None) |
A Vega-Lite spec in full; data is a staged CSV, TSV or JSON cg.Blob. |
lib/vega, lib/vega-lite, lib/vega-embed |
SplatViewer(model) |
A cg.Splat PLY in an orbit viewer. |
lib/gsplat |
PageLink(value, title=None) |
A link card to a value's page. |
A suite declares its own by subclassing cg.ui.Component, annotating the
props, and implementing html(self, render). cg.ui.element and
cg.ui.text write escaped HTML; the cg.ui.Render handed in stages files
(render.stage), copies a shipped library (render.lib), emits a script
once per page (render.once), and resolves links (render.href). Built
inside a builder function, a custom component attaches to the open stack
like a standard one, and a Missing prop makes it render nothing, with no
code of its own for either.
class Badge(cg.ui.Component):
text: str
def html(self, render: cg.ui.Render) -> str:
return cg.ui.element("span", {"class": "badge"}, cg.ui.text(self.text))
@cgserver.page(PlantJournal, lambda s: s.sessions.for_each(), version=1)
@cg.ui.PageBuilder()
def session(session: Session) -> None:
cg.ui.Title("Session")
Badge("photographed" if not cg.is_missing(session.photo) else "pending")
cg.ui.ImageViewer(session.photo)
Thin assembly over cached per-session calls#
A page's key is the content of the value at its position, so the root page is recomputed on the first visit after any of the target's files change. That is cheap as long as the root's body is assembly: it walks the loaded data and calls memoized per-session recipes, so a new upload costs one session's work plus the assembly, never the whole study again.
def _thumbnail(session: Session) -> cg.Thumbnail:
small = cg.Thumbnail.new() # rendered inside the root's call
session.photo.pil(max_axis=256).save(small)
return small
@cgserver.page(PlantJournal, version=1)
@cg.ui.PageBuilder()
def journal(target: PlantJournal) -> None:
cg.ui.Title("Plant Journal")
cg.ui.Gallery.of(target.sessions, _thumbnail)
Every upload re-decodes every photo: the work is keyed by the whole target.
@cg.pure_function("session_thumbnail/1")
def session_thumbnail(photo: cg.Image) -> cg.Thumbnail:
thumbnail = cg.Thumbnail.new()
photo.pil(max_axis=256).save(thumbnail)
return thumbnail
@cgserver.page(PlantJournal, version=1)
@cg.ui.PageBuilder()
def journal(target: PlantJournal) -> None:
cg.ui.Title("Plant Journal")
cg.ui.Gallery.of(target.sessions, lambda s: session_thumbnail(s.photo))
Each photo is decoded once, keyed by its own content. The same call serves the thumbnail interceptor from one cache entry.
Anything that scales with the number of sessions belongs in a per-session
cg.pure_function, or in a page declared at the session, whose key is
that session alone. Anything that scales with the whole dataset is either
cheap enough for the root page or its own memoized call over exactly the data
it needs.
What computing looks like#
The first visit to a position with no sealed folder for its input shows
Calculating…, polls ~status four times a second, and reloads once the
folder is ready. The panel draws the step tree from the scratch's steps
table (recipes):
the page's own call at the root, every memoized call, map, pmap and
cg.step block beneath it nested, each with its elapsed seconds, a bar and
count where it has a total, and its latest note; a line above sums the counts
into 17 of 40 steps done. A step that finishes stays listed as done, so the
tree grows as the page computes, and the last lines of the deepest running
call's exec.log follow it. A gallery of forty thumbnails reads as one
Gallery step filling up, with the thumbnail calls computing right now
beneath it. A recipe waiting for the GPU reads as a gpu lock step whose
note names what holds it (global locks),
so a page queued behind another target's training says so rather than
sitting silent. The state is shared across worker processes, because the runner
asks the scratch rather than its own memory. Each target has one worker
thread, so a slow page holds up only its own target, and two overlapping
first visits compute once.
A visit always calls the memoized page function. A page that raises
seals nothing; once the attempt ends the computing page shows the error text
and its notes, the exec.log tail among them, and the traceback is in the
server log under the page's URL. ~status reports failed with that error
until the next visit, which calls the function again, so reloading a failed
page tries again; pending means nothing is computed for these inputs and
nothing is in flight.
Eviction and recompute#
A render is an object in the recipe scratch
like any other result. The runner holds each position's current render under
the scratch name page:<target>/<path>, pinned while the server runs; the
rest is evictable history. A position computes only when someone visits it,
and then only when no sealed folder answers for the value at its position:
it has changed, its render was evicted, the version was bumped, or the last
attempt raised. An evicted render recomputes to the same hash, so old
links heal. Nothing computes
on a restart: an unchanged position serves from the scratch at once.
The app's button#
A hosted target in the app fetches ~pages once per open and shows a
Visualizations button in the explorer's actions toolbar whenever the
shown value's path matches a declared pattern, * standing for exactly one
component. It opens <server>/pages/<target>/<path…>/ in the browser, ready
or not. A target without pages answers {"patterns": []} and the button
never appears.
Troubleshooting#
404at a position URL. No page is declared at that path's pattern, or nothing has been captured there yet:~pageslists the patterns, and the file API says what is on disk.RuntimeError: no page is being built. Acg.ui.Titleor awithon a stack ran outside a@cg.ui.PageBuilder()function: a helper that builds part of a page must be called from inside one, or return the components for the caller to place. A stack built outside a builder takeschildren=instead ofwith.- The build fails naming a
PageLink. with no position in a target: the value was built in memory, computed by a recipe, or changed since it was loaded; link the loaded value instead. the page has noat: the page was built outside the server withoutat=. - The render fails naming
index.html. The page function returned a directory value whose folder holds noindex.html; write one there, or return acg.Page. - A component appears twice. It was built inside a builder function and
then also passed as
children=to a stack; inside a builder, build it under thewithblock instead. - A gallery's pictures do not link. The pictures are derived and the
gallery was built with
Gallery(...)rather thanGallery.of(...), or the container came from memory rather than the loaded target. 404under~/that used to work. The render was evicted or replaced by a newer one. Open the position URL: an evicted render recomputes to the same hash.- The root page recomputes after every upload. By design. If each
recompute is slow, move per-session work into
cg.pure_functions (above) or into a page at the session. - A target declares a page but its URL says it declares none. The module failed to load; the target's status in the management panel and the server log carry the error.
- The index shows the target's name, not the page's title. Its root page has not been rendered yet, or it declares no root page.