Coverage for src/jointview/app.py: 100%
67 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-10-01 04:48 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-10-01 04:48 +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#
15# N803 is there for the same reason. A cell's parameters *are* the names it reads, so a
16# cell reading a module-level constant — `WINDOWS`, `WINDOW_ALL` — takes an upper-case
17# argument. That is marimo spelling a constant's own name back, not an argument named
18# against the convention here, and lower-casing it would mean aliasing the constant to
19# hide the shape of the generated code.
20# ruff: noqa: ANN001, ANN202, N803
21#
22# The `return` closing each cell is marimo's too, and it is why four of them below carry
23# a `# pragma: no cover`. Running the notebook never calls these functions: marimo keeps
24# each cell as its body alone — the stored source parses to Import, Expr, Assign or
25# FunctionDef, never Return — and executes that as two code objects, the body exec'd and
26# a final expression eval'd to become the cell's output. So no `return` here is ever
27# reached, by `app.run()` or by anything else.
28#
29# Which makes the interesting case the five that carry no pragma. They are not better
30# tested; they are shadowed. Where a cell ends in a statement rather than an expression,
31# marimo emits its synthetic end-of-cell expression onto the next line down, and for a
32# one-line last statement that line is the `return`. Coverage sees bytecode there and
33# marks it hit. Nothing to fix — but the four pragmas are the honest cells, not the
34# neglected ones, and removing them would report a reach that `tests/test_app.py` does
35# not have.
37import marimo
39__generated_with = "0.23.15"
40app = marimo.App(width="full")
43@app.cell
44def _():
45 """Import marimo — the one cell every other cell here depends on."""
46 import marimo as mo
48 return (mo,) # pragma: no cover
51@app.cell
52def _(mo) -> None:
53 """Give the plot back the margins marimo reserves for prose."""
54 # marimo pads a notebook for prose — 96px of side margin and 48px under the last
55 # cell, twice over. This is a single-screen instrument, so those margins go back to
56 # the plot. First cell so the trim is in the DOM before anything that sizes itself
57 # against the page — the chart follows its container's width — is laid out.
58 #
59 # Matched on class *substrings* because the classes are Tailwind and carry colons;
60 # if a future marimo renames them the rules stop applying and the app still lays
61 # out, only roomier.
62 mo.Html("""
63 <style>
64 #App [class*="xl:px-24"] { padding-inline: 0.75rem !important; }
65 #App [class*="pb-24"] { padding-bottom: 0 !important; }
66 #App .output-area { padding-inline: 0 !important; }
67 /* vega-embed keeps a further 38px on the right for its own "..." menu. That one
68 is out of reach — marimo renders the chart into a shadow root — and it buys
69 the save/view-source menu, so it stays. */
70 </style>
71 """)
72 return # pragma: no cover
75@app.cell
76def _(mo):
77 """Load the frame, and settle the two things the command line gets a say in."""
78 from jointview.columns import (
79 WINDOW_ALL,
80 WINDOWS,
81 aligned,
82 date_column,
83 default_pair,
84 series_columns,
85 windowed,
86 )
87 from jointview.data import load_frame
88 from jointview.plot import drawn_points, line_chart
89 from jointview.stats import summary_markdown
91 # jointview navs.parquet --height 900
92 frame = load_frame(mo.cli_args().get("data"))
93 names = series_columns(frame)
94 # Nothing on the page has a height for the plot to follow, so it is a number. 700
95 # fills a laptop window once the notebook margins are gone; a tall monitor wants
96 # more, and only the person looking at it knows which they have.
97 plot_height = int(mo.cli_args().get("height") or 700)
98 return (
99 WINDOWS,
100 WINDOW_ALL,
101 aligned,
102 date_column,
103 default_pair,
104 drawn_points,
105 frame,
106 line_chart,
107 names,
108 plot_height,
109 summary_markdown,
110 windowed,
111 )
114@app.cell
115def _(default_pair, frame, mo, names):
116 """Build the two series pickers, opened on a pair worth looking at."""
117 a_start, b_start = default_pair(frame)
119 # One dropdown is the whole selector: it holds its own value, so there is no
120 # mo.state and no two-way binding to keep in step. It also costs a fixed two lines
121 # of the panel whatever the frame is — a radio list grew with the column count,
122 # and a wide parquet would have turned the side panels into a scroll.
123 def _pick(start):
124 """One dropdown over every series, opened on the column at ``start``."""
125 return mo.ui.dropdown(
126 options=names,
127 value=names[start],
128 # Searchable from ten columns up, where scanning the list stops being
129 # quicker than typing. Below that the search box is only in the way.
130 searchable=len(names) >= 10,
131 # There is no such thing as a plot of no series: the "--" entry would only
132 # ever be a way to break the chart.
133 allow_select_none=False,
134 full_width=True,
135 )
137 a_pick, b_pick = _pick(a_start), _pick(b_start)
138 return a_pick, b_pick
141@app.cell
142def _(WINDOWS, WINDOW_ALL, date_column, frame, mo):
143 """The two controls over the plot: how much of the sample to draw, and whether to index."""
144 # Two NAVs on one axis only mean something on a shared scale; off, the raw levels
145 # are there for a pair that already shares one.
146 rebase = mo.ui.switch(value=True, label="index both to 100")
148 # A radio rather than a date-range picker, and one row rather than two boxes: the
149 # questions actually asked of a NAV curve are "this year" and "the last three years",
150 # and a chip answers them in a click. Exclusive by construction, which is what the
151 # sample being one stretch of calendar requires — the whole of it by default, because
152 # a file's own span is the only window nobody has to have chosen.
153 #
154 # Cut by date, so a frame numbered by row has nothing to cut against and is offered
155 # the whole sample alone. Three chips that quietly did nothing would be worse than
156 # their absence.
157 windows = list(WINDOWS) if date_column(frame) else [WINDOW_ALL]
158 window = mo.ui.radio(options=windows, value=WINDOW_ALL, inline=True)
159 return rebase, window
162@app.cell
163def _(a_pick, aligned, b_pick, frame, line_chart, plot_height, rebase, window, windowed):
164 """Read the controls, and derive everything the layout below draws."""
165 a_column = a_pick.value
166 b_column = b_pick.value
167 # The window is taken once, here, and both the chart and the tables are built from
168 # what it leaves — which is what keeps the caption's promise under a cut. Rebasing
169 # follows it too: 100 lands at the start of the window, which is the comparison
170 # choosing a window asks for.
171 shown = windowed(frame, window.value)
172 pair = aligned(shown, a_column, b_column)
173 chart = line_chart(shown, a_column, b_column, rebase=rebase.value, height=plot_height)
174 return a_column, b_column, chart, pair
177@app.cell
178def _(WINDOW_ALL, drawn_points, mo, summary_markdown):
179 """The furniture the page is assembled from: the panel pieces, and the caption."""
181 def summary_table(pair, side, title):
182 """A metric table for one side of ``pair``, or a note when the sample is too short.
184 The whole aligned frame goes in rather than the column alone: jQuantStats reads
185 the annualisation factor off the spacing of the period column, so the dates have
186 to travel with the levels.
187 """
188 try:
189 return mo.md(summary_markdown(pair, side, title=title))
190 except (ValueError, KeyError):
191 return mo.md(f"**{title}** — too few overlapping observations to summarise.")
193 # A dropdown and seventeen metrics: the panel is now the same height whatever the
194 # frame holds, so it no longer needs the scroll box that a per-column radio list
195 # did. The wide gap keeps the split legible — the dropdown is a control, the
196 # table under it is a result, and they should not read as one column of text.
197 def panel(label, picker, table):
198 """One side of the page: a heading, its picker, and the table underneath."""
199 return mo.vstack(
200 [
201 mo.md(f"**{label}**"),
202 mo.vstack([picker, table], gap=1.75),
203 ],
204 gap=0.5,
205 )
207 def caption(a_column, b_column, frame, pair, window=WINDOW_ALL):
208 """One line under the plot saying what it is of, and what the tables describe.
210 Two numbers, and past `plot.MAX_POINTS` they differ: the curve is thinned for the
211 browser's sake while the tables go on describing every date in the common sample.
212 Saying so is what keeps this a caption rather than a claim — a max drawdown in a
213 table can sit at a date the line no longer carries a point for.
215 The window is named for the same reason. `frame` is the whole file whatever is
216 selected, so under a cut the dates missing from the sample were not dates a series
217 was absent on — they were dates left outside the window, and "252 of 1,500 dates
218 where both series are present" would report a choice as a hole in the data.
219 """
220 dropped = frame.height - pair.height
221 note = f" of {frame.height:,}" if dropped else ""
222 inside = "" if window == WINDOW_ALL else f" inside `{window}`"
223 sample = f"{pair.height:,}{note} dates{inside} where both series are present"
224 drawn = drawn_points(pair.height)
225 body = (
226 f"{drawn:,} points drawn from {sample}; the tables summarise all {pair.height:,}"
227 if drawn < pair.height
228 else f"{sample}, which is also what the tables summarise"
229 )
230 # The multiplication sign is the character this means; a lowercase x beside two
231 # column names reads as part of one of them.
232 return mo.md(f"`{a_column}` × `{b_column}` — {body}.") # noqa: RUF001
234 return caption, panel, summary_table # pragma: no cover
237@app.cell
238def _(a_column, b_column, caption, chart, frame, mo, pair, rebase, window):
239 """The middle column: the controls, the chart, and a line saying what it is of."""
240 # The two controls share one row above the plot: both are about the picture rather
241 # than about either series, and neither is wide enough to spend a line of the page on.
242 # Pushed to the two ends of that row, which is the chart's own width — the windows
243 # under the y-axis on the left, the switch over the end of the lines on the right —
244 # so the row reads as the plot's own frame rather than as a caption above it. Nothing
245 # sits outside those limits: the hstack is the chart's container, so "space-between"
246 # can only reach the edges the picture already occupies.
247 controls = mo.hstack([window, rebase], justify="space-between", align="center", gap=1.5)
248 # No align= here: centring would shrink the stack to its content and hand the chart
249 # back the gutter that "container" is there to fill.
250 figure = mo.vstack(
251 [controls, chart, caption(a_column, b_column, frame, pair, window.value)],
252 gap=0.25,
253 )
254 return (figure,)
257@app.cell
258def _(a_column, a_pick, b_column, b_pick, figure, mo, pair, panel, summary_table) -> None:
259 """Lay out the page: a picker and its table either side of the figure."""
260 mo.hstack(
261 [
262 panel("left", a_pick, summary_table(pair, "a", a_column)),
263 figure,
264 panel("right", b_pick, summary_table(pair, "b", b_column)),
265 ],
266 # The side panels hold a dropdown and a two-column table; anything wider than
267 # that is width taken off the picture.
268 widths=[1, 6, 1],
269 align="start",
270 gap=0.75,
271 )
272 return # pragma: no cover
275if __name__ == "__main__":
276 app.run()