From 7ecb80914d7a6535aa11e73089846dad49821688 Mon Sep 17 00:00:00 2001 From: Andrew Hariri <31301117+hariria@users.noreply.github.com> Date: Sun, 8 Nov 2026 09:54:06 -0800 Subject: [PATCH 01/12] Add Proa page composition example --- .github/workflows/build.yml | 4 + CHANGELOG.md | 1 + examples/Cargo.toml | 1 + examples/README.md | 5 + examples/proa/.gitignore | 1 + examples/proa/Cargo.toml | 15 ++ examples/proa/README.md | 66 +++++++ examples/proa/src/main.rs | 274 ++++++++++++++++++++++++++++++ examples/proa/src/plotly_chart.rs | 101 +++++++++++ 9 files changed, 468 insertions(+) create mode 100644 examples/proa/.gitignore create mode 100644 examples/proa/Cargo.toml create mode 100644 examples/proa/README.md create mode 100644 examples/proa/src/main.rs create mode 100644 examples/proa/src/plotly_chart.rs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a9431a21..18ff8550 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -221,6 +221,7 @@ jobs: kaleido, maps, ndarray, + proa, scientific_charts, shapes, statistical_charts, @@ -240,6 +241,9 @@ jobs: - uses: dtolnay/rust-toolchain@stable - run: cd ${{ github.workspace }}/examples/${{ matrix.example }} && cargo build + - name: Test Proa example + if: matrix.example == 'proa' + run: cd ${{ github.workspace }}/examples/proa && cargo test build_wasm_examples: name: Build Wasm Examples diff --git a/CHANGELOG.md b/CHANGELOG.md index 6913daa1..fd176c73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ### Added +- Add a Proa example composing three Plotly figures with a reusable document layout, page metadata, and chart components. - [[#406](https://github.com/plotly/plotly.rs/issues/406)] Expose `plotly.js` 3.1–3.6 attributes - [[#410](https://github.com/plotly/plotly.rs/pull/410)] Add `Choropleth` (geo subplot) and `ChoroplethMap` (MapLibre `map` subplot) trace types, with a `LocationMode` enum and a dedicated `choropleth::Marker`; add the MapLibre `map` subplot via `LayoutMap`/`MapStyle`/`MapBounds` - [[#410](https://github.com/plotly/plotly.rs/pull/410)] Add `LayoutGeo` options: `fitbounds` (`GeoFitBounds`), `resolution` (`GeoResolution`, 1:110M/1:50M base-layer detail), and `bgcolor` diff --git a/examples/Cargo.toml b/examples/Cargo.toml index 17dcd263..74205c08 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -13,6 +13,7 @@ members = [ "maps", "ndarray", "plotly_utils", + "proa", "scientific_charts", "shapes", "statistical_charts", diff --git a/examples/README.md b/examples/README.md index 606f4e66..340f1c14 100644 --- a/examples/README.md +++ b/examples/README.md @@ -9,3 +9,8 @@ You can also configure the chosen example to open the resulting plots automatica For more complex examples, instructions on how to run them can be found in the README of each example's subdirectory. Pull requests with more examples of different behavior are always welcome. + +## Proa page composition + +See [`proa`](proa) for an example composing three Plotly figures into a reusable +Rust layout with page metadata and chart components. diff --git a/examples/proa/.gitignore b/examples/proa/.gitignore new file mode 100644 index 00000000..16be8f21 --- /dev/null +++ b/examples/proa/.gitignore @@ -0,0 +1 @@ +/output/ diff --git a/examples/proa/Cargo.toml b/examples/proa/Cargo.toml new file mode 100644 index 00000000..bbbc2059 --- /dev/null +++ b/examples/proa/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "proa_example" +version = "0.1.0" +edition = "2021" +rust-version = "1.93" +publish = false + +[dependencies] +ndarray = "0.17" +plotly = { path = "../../plotly" } +proa_core = "0.3.0" +proa_framework_core = { version = "0.3.0", default-features = false } +proa_macros = "0.3.0" +serde = "1" +serde_json = "1" diff --git a/examples/proa/README.md b/examples/proa/README.md new file mode 100644 index 00000000..cc01524e --- /dev/null +++ b/examples/proa/README.md @@ -0,0 +1,66 @@ +# Proa page composition + +This example adapts the three figures from +[`customization/multiple_plots_example`](../customization/multiple_plots_example) +into a page built with [Proa](https://proa.so). It demonstrates composing borrowed +`Plot` values as Rust render components, rather than inserting chart HTML strings +into a page builder. + +## Run + +Requires Rust 1.93 or newer. From this directory: + +```bash +cargo run +``` + +Open `output/multiple_plots.html` in your browser. The file loads Plotly.js 3.7.0 +from its CDN, so an internet connection is required. No application server, WASM +target, JavaScript build tool, or local Proa checkout is needed. + +To run the render and serialization tests: + +```bash +cargo test +``` + +## Components + +- `PlotLayout` owns the HTML document, styles, shared Plotly.js script, and + initialization of all charts. It accepts any synchronous renderable child. +- `LayoutMetadata` supplies default title and description values; `PageMetadata` + overrides them through Proa's metadata cascade. Other pages can inherit the + defaults or supply their own metadata. +- `MultiplePlotsPage` renders the page heading and a collection of `ChartCard` + components. +- `ChartCard` supplies the common section/heading structure around `PlotlyChart`. +- `PlotlyChart` is a small, example-local adapter over `&Plot`. It uses the + existing `Serialize` implementation and writes JSON directly into Proa's output + buffer. It does not call `to_html()` or `to_inline_html()` or clone the plot. + +The plot-building functions still use the normal Plotly.rs API. They return +`Plot`, leaving presentation to the components. Change the layout once to change +all pages that use it, or change `ChartCard` once to change every chart wrapper. + +This example uses the published Proa 0.3 metadata API (`Metadata` and +`resolve_metadata_stack`). It does not require the newer, unreleased +`render_head`/managed-head API. + +## Plotly.js boundary + +Each chart renders a container followed by an inert `").next().unwrap(); - let actual: serde_json::Value = serde_json::from_str(json).unwrap(); - let expected: serde_json::Value = serde_json::from_str(&chart.plot.to_json()).unwrap(); + let decoded = html_escape::decode_html_entities(json); + let actual: serde_json::Value = serde_json::from_str(&decoded).unwrap(); + let expected: serde_json::Value = serde_json::from_str(&plot.to_json()).unwrap(); assert_eq!(actual, expected); } } diff --git a/examples/proa/src/plotly_chart.rs b/examples/proa/src/plotly_chart.rs index 0e0da24e..6e8b5c33 100644 --- a/examples/proa/src/plotly_chart.rs +++ b/examples/proa/src/plotly_chart.rs @@ -1,101 +1,70 @@ -//! A local adapter: borrow a Plot and serialize it into the page's output. - -use std::io; +//! Prepare figure JSON once; compose the chart by borrowing this component. use plotly::Plot; -use proa_core::{DataLoader, RenderBuffer, WebContext, WebRenderSync, WriteBuf, WriteError}; +use proa_core::{DataLoader, WebContext, WebRenderSync, WriteError}; use proa_macros::html_sync; -use serde::Serialize; pub struct PlotlyChart<'a> { - pub id: &'a str, - pub plot: &'a Plot, + id: &'a str, + json: String, pub class: Option<&'static str>, } -impl WebRenderSync for PlotlyChart<'_> { - fn render(self, cx: &mut WebContext) -> Result<(), WriteError> { - html_sync! { -
- - } - .render(cx) +impl<'a> PlotlyChart<'a> { + pub fn new(id: &'a str, plot: &Plot) -> Result { + Ok(Self { + id, + json: serde_json::to_string(plot)?, + class: None, + }) } } -// Keep the template content as JSON, with no HTML elements or entities. Write -// directly into Proa's buffer, encoding HTML-sensitive bytes as JSON escapes. -struct HtmlJson<'a, T>(&'a T); - -impl WebRenderSync for HtmlJson<'_, T> { +impl WebRenderSync for &PlotlyChart<'_> { fn render(self, cx: &mut WebContext) -> Result<(), WriteError> { - serde_json::to_writer(HtmlJsonWriter(cx.output_mut()), self.0) - // proa-perf-ok: allocate a diagnostic only on serialization failure - .map_err(|error| WriteError::render(error.to_string())) - } -} - -struct HtmlJsonWriter<'a>(&'a mut RenderBuffer); - -impl io::Write for HtmlJsonWriter<'_> { - fn write(&mut self, bytes: &[u8]) -> io::Result { - let mut start = 0; - for (index, byte) in bytes.iter().enumerate() { - let escaped: &[u8] = match byte { - b'<' => b"\\u003c", - b'>' => b"\\u003e", - b'&' => b"\\u0026", - b'\'' => b"\\u0027", - _ => continue, - }; - self.0 - .extend(&bytes[start..index]) - .map_err(io::Error::other)?; - self.0.extend(escaped).map_err(io::Error::other)?; - start = index + 1; + html_sync! { +
+ } - self.0.extend(&bytes[start..]).map_err(io::Error::other)?; - Ok(bytes.len()) - } - - fn flush(&mut self) -> io::Result<()> { - Ok(()) + .render(cx) } } #[cfg(test)] mod tests { use super::*; + use plotly::Layout; #[test] - fn json_seed_preserves_data_without_html_boundaries() { - let value = "