Coverage for src/jointview/app.py: 100%
58 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-17 06:44 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-17 06:44 +0000
1"""The marimo notebook itself: two pickers, one chart, a summary table either side.
3Run it with the ``jointview`` command rather than opening this file — see
4:mod:`jointview.cli`, which starts marimo on this notebook and passes it ``--data``
5and ``--height``.
6"""
8# The cell signatures below are marimo's, not ours: it names every cell ``_``, derives
9# the parameters from what the cell reads, and rewrites both on save. Annotating them
10# would be annotating generated code that the next edit in the marimo editor throws
11# away — and the types are marimo's UI plumbing, so the annotations would be `Any`
12# anyway. Told to ruff here rather than in a config file, so the reason sits next to
13# the code it excuses. (ty asks for none of this; it does not require annotations.)
14# ruff: noqa: ANN001, ANN202
15#
16# The `return` closing each cell is marimo's too, and it is why four of them below carry
17# a `# pragma: no cover`. Running the notebook never calls these functions: marimo keeps
18# each cell as its body alone — the stored source parses to Import, Expr, Assign or
19# FunctionDef, never Return — and executes that as two code objects, the body exec'd and
20# a final expression eval'd to become the cell's output. So no `return` here is ever
21# reached, by `app.run()` or by anything else.
22#
23# Which makes the interesting case the five that carry no pragma. They are not better
24# tested; they are shadowed. Where a cell ends in a statement rather than an expression,
25# marimo emits its synthetic end-of-cell expression onto the next line down, and for a
26# one-line last statement that line is the `return`. Coverage sees bytecode there and
27# marks it hit. Nothing to fix — but the four pragmas are the honest cells, not the
28# neglected ones, and removing them would report a reach that `tests/test_app.py` does
29# not have.
31import marimo
33__generated_with = "0.23.15"
34app = marimo.App(width="full")
37@app.cell
38def _():
39 """Import marimo — the one cell every other cell here depends on."""
40 import marimo as mo
42 return (mo,) # pragma: no cover
45@app.cell
46def _(mo) -> None:
47 """Give the plot back the margins marimo reserves for prose."""
48 # marimo pads a notebook for prose — 96px of side margin and 48px under the last
49 # cell, twice over. This is a single-screen instrument, so those margins go back to
50 # the plot. First cell so the trim is in the DOM before anything that sizes itself
51 # against the page — the chart follows its container's width — is laid out.
52 #
53 # Matched on class *substrings* because the classes are Tailwind and carry colons;
54 # if a future marimo renames them the rules stop applying and the app still lays
55 # out, only roomier.
56 mo.Html("""
57 <style>
58 #App [class*="xl:px-24"] { padding-inline: 0.75rem !important; }
59 #App [class*="pb-24"] { padding-bottom: 0 !important; }
60 #App .output-area { padding-inline: 0 !important; }
61 /* vega-embed keeps a further 38px on the right for its own "..." menu. That one
62 is out of reach — marimo renders the chart into a shadow root — and it buys
63 the save/view-source menu, so it stays. */
64 </style>
65 """)
66 return # pragma: no cover
69@app.cell
70def _(mo):
71 """Load the frame, and settle the two things the command line gets a say in."""
72 from jointview.columns import aligned, default_pair, series_columns
73 from jointview.data import load_frame
74 from jointview.plot import line_chart
75 from jointview.stats import summary_markdown
77 # jointview navs.parquet --height 900
78 frame = load_frame(mo.cli_args().get("data"))
79 names = series_columns(frame)
80 # Nothing on the page has a height for the plot to follow, so it is a number. 700
81 # fills a laptop window once the notebook margins are gone; a tall monitor wants
82 # more, and only the person looking at it knows which they have.
83 plot_height = int(mo.cli_args().get("height") or 700)
84 return (
85 aligned,
86 default_pair,
87 frame,
88 line_chart,
89 names,
90 plot_height,
91 summary_markdown,
92 )
95@app.cell
96def _(default_pair, frame, mo, names):
97 """Build the two series pickers, opened on a pair worth looking at."""
98 a_start, b_start = default_pair(frame)
100 # One dropdown is the whole selector: it holds its own value, so there is no
101 # mo.state and no two-way binding to keep in step. It also costs a fixed two lines
102 # of the panel whatever the frame is — a radio list grew with the column count,
103 # and a wide parquet would have turned the side panels into a scroll.
104 def _pick(start):
105 """One dropdown over every series, opened on the column at ``start``."""
106 return mo.ui.dropdown(
107 options=names,
108 value=names[start],
109 # Searchable from ten columns up, where scanning the list stops being
110 # quicker than typing. Below that the search box is only in the way.
111 searchable=len(names) >= 10,
112 # There is no such thing as a plot of no series: the "--" entry would only
113 # ever be a way to break the chart.
114 allow_select_none=False,
115 full_width=True,
116 )
118 a_pick, b_pick = _pick(a_start), _pick(b_start)
119 return a_pick, b_pick
122@app.cell
123def _(mo):
124 """The only control over the plot itself: whether to index both series to 100."""
125 # Two NAVs on one axis only mean something on a shared scale; off, the raw levels
126 # are there for a pair that already shares one.
127 rebase = mo.ui.switch(value=True, label="index both to 100")
128 return (rebase,)
131@app.cell
132def _(a_pick, aligned, b_pick, frame, line_chart, plot_height, rebase):
133 """Read the pickers, and derive everything the layout below draws."""
134 a_column = a_pick.value
135 b_column = b_pick.value
136 pair = aligned(frame, a_column, b_column)
137 chart = line_chart(frame, a_column, b_column, rebase=rebase.value, height=plot_height)
138 return a_column, b_column, chart, pair
141@app.cell
142def _(mo, summary_markdown):
143 """The two pieces of furniture a side panel is made of."""
145 def summary_table(pair, side, title):
146 """A metric table for one side of ``pair``, or a note when the sample is too short.
148 The whole aligned frame goes in rather than the column alone: jQuantStats reads
149 the annualisation factor off the spacing of the period column, so the dates have
150 to travel with the levels.
151 """
152 try:
153 return mo.md(summary_markdown(pair, side, title=title))
154 except (ValueError, KeyError):
155 return mo.md(f"**{title}** — too few overlapping observations to summarise.")
157 # A dropdown and seventeen metrics: the panel is now the same height whatever the
158 # frame holds, so it no longer needs the scroll box that a per-column radio list
159 # did. The wide gap keeps the split legible — the dropdown is a control, the
160 # table under it is a result, and they should not read as one column of text.
161 def panel(label, picker, table):
162 """One side of the page: a heading, its picker, and the table underneath."""
163 return mo.vstack(
164 [
165 mo.md(f"**{label}**"),
166 mo.vstack([picker, table], gap=1.75),
167 ],
168 gap=0.5,
169 )
171 return panel, summary_table # pragma: no cover
174@app.cell
175def _(a_column, b_column, chart, frame, mo, pair, rebase):
176 """The middle column: the switch, the chart, and a line saying what it is of."""
177 _dropped = frame.height - pair.height
178 _note = f" of {frame.height:,}" if _dropped else ""
179 caption = mo.md(
180 # The multiplication sign is the character this means; a lowercase x beside two
181 # column names reads as part of one of them.
182 f"`{a_column}` × `{b_column}` — {pair.height:,}{_note} dates where both series " # noqa: RUF001
183 "are present, which is also what the tables summarise."
184 )
185 # No align= here: centring would shrink the stack to its content and hand the chart
186 # back the gutter that "container" is there to fill.
187 figure = mo.vstack([rebase, chart, caption], gap=0.25)
188 return (figure,)
191@app.cell
192def _(a_column, a_pick, b_column, b_pick, figure, mo, pair, panel, summary_table) -> None:
193 """Lay out the page: a picker and its table either side of the figure."""
194 mo.hstack(
195 [
196 panel("left", a_pick, summary_table(pair, "a", a_column)),
197 figure,
198 panel("right", b_pick, summary_table(pair, "b", b_column)),
199 ],
200 # The side panels hold a dropdown and a two-column table; anything wider than
201 # that is width taken off the picture.
202 widths=[1, 6, 1],
203 align="start",
204 gap=0.75,
205 )
206 return # pragma: no cover
209if __name__ == "__main__":
210 app.run()