5. Report documents

A forecast round, a model comparison, a set of estimation diagnostics: these end their life as a folder of figure windows or a static PDF. A single page a colleague can open, with no MATLAB and no toolbox, is a materially better artifact. They can read exact values on hover, zoom a series, switch a scenario off, and take the numbers away, without regenerating anything.

rise.report builds the report once and renders it twice.

r = rise.report.Doc("Quarterly forecast round");

r + rise.report.section("Output");
r + rise.report.text("Output returns to trend over the projection.");
r + rise.report.chart("Level of output", history, baseline, alternative, ...
                      'Highlight', projection);

rise.report.tohtml(r, "round.html");   % one self-contained page
rise.report.topdf(r,  "round.pdf");    % the same content, printed

5.1. One tree, two backends

The tree is the document. Both renderers walk it, and every difference between them is written down in rise.report.types rather than discovered by comparing the outputs:

Kind

On the page

In print

section, heading

heading and anchor

a numbered section

text

a paragraph

a paragraph

chart, bands, regime

inline graphics, interactive

a static figure

model

a highlighted code block

a highlighted listing

table, matrix

an HTML table

a LaTeX table

grid

a CSS grid

sequential, one after another

pagebreak

ignored; the page scrolls

a page break

Two tests police it. One fails if a kind has no rendering recorded for either backend. A second walks every kind the builders can produce, so an element cannot be added without declaring what both backends do with it.

5.2. The page carries nothing but itself

Charts are drawn as inline scalable graphics by RISE, not by a charting library. There is no script tag, no stylesheet link, and no content delivery network anywhere in the output, which is asserted by a test.

That is a deliberate choice with a cost and a return. The return is that the page works on a train, behind a firewall, and in five years; that there is no third-party licence to check; and that the palette and the missing-value handling are RISE’s rather than a library’s defaults bent into shape.

Four interactions come with it: a readout on hover, a legend that switches a series off, zoom and pan with a reset, and a button that hands over the chart’s numbers as a CSV. The zoom moves the drawing’s own coordinate box rather than redrawing, and the download is assembled from the points already in the page, so neither adds payload.

The page follows the reader’s light or dark setting, and prints with its interactive furniture hidden and its surfaces white.

5.3. What a chart accepts

Whatever you already have. A RISE series names itself and carries its own dates, so no wrapper is needed:

r + rise.report.chart("Output", y, yBaseline, yAlternative);
r + rise.report.chart("Everything", databank);   % a struct of series
r + rise.report.chart("Raw", 1:40);              % a bare vector

rise.report.series is still there for naming a line explicitly. Anything a chart cannot read is refused by argument position and class.

A series records whether its horizontal axis is a calendar, and both backends label it accordingly. Dates handed in explicitly count as dates only if they fall where a calendar does, so an impulse-response horizon stays a horizon rather than becoming a day in 1900.

A run of missing values lifts the pen rather than being bridged. A gap is information: it says the series does not cover that period.

5.4. The elements RISE has and the peer does not

Fan bands. A forecast is a distribution, and a fan chart is how that gets said:

r + rise.report.bands("Inflation", central, {lo50, lo90}, {hi50, hi90}, ...
                      Labels=["50 per cent", "90 per cent"]);

The bands are filled areas behind the central path, widest first, so the narrower ones read as darker. A date where an edge is missing narrows the ribbon rather than dropping it to the floor.

Regime probabilities. Regime-switching output is RISE’s distinctive content:

r + rise.report.regime("Filtered probabilities", pCalm, pStressed);

The probabilities are drawn as areas stacked to one, so reading the chart is reading a thickness rather than watching lines cross. A matrix with one column per regime is accepted, which is the shape the filter returns.

The model itself. What the model says belongs in the report that discusses it:

r + rise.report.model("Equations as written", "nk.rs", LineRange=12:40);

In print this is RISE’s own model listing, the one rnotes already draws. On the page it is the same text, coloured the same way. Pointing at the file is what keeps the report and the model in step.

5.5. Tables and matrices

The same shape of call: a title, an array, and names for its edges.

r + rise.report.table("Key series", values, Rows=names, Columns=dates);
r + rise.report.matrix("Transition", P, Rows=from, Columns=to);

Edges left out are numbered. Format takes a printf format or a count of decimals, and what a cell prints is decided in one place: a missing value reads as a gap rather than as the word for one, an infinity keeps its sign, and a value too small to show at the chosen precision is marked so a reader does not take it for an exact zero.

CondFormat takes rows of {test, style}. The test is a function of the value, the row index and the column index. The style is a name from rise.report.cellstyles, never markup, so a typo is refused at the call site rather than rendering as nothing. Rules apply in order and a later rule wins, in both backends.

5.6. Adding an element

An element class and a case in each renderer, and nothing else. The type table is the contract: an element that only one backend can draw must degrade explicitly there, and the tests will not let it be forgotten.

Worked example: rise-modern-tutorials/Reporting/report_document.