Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ jobs:
kaleido,
maps,
ndarray,
proa,
scientific_charts,
shapes,
statistical_charts,
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a

### Added

- [[#445](https://github.com/plotly/plotly.rs/pull/445)] 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`
Expand Down
1 change: 1 addition & 0 deletions examples/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ members = [
"maps",
"ndarray",
"plotly_utils",
"proa",
"scientific_charts",
"shapes",
"statistical_charts",
Expand Down
2 changes: 2 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ 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.

See [`proa`](proa) for three Plotly figures composed into a reusable Rust layout with page metadata and chart components.
18 changes: 18 additions & 0 deletions examples/proa/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[package]
name = "proa_example"
version = "0.1.0"
edition = "2021"
# Proa's minimum supported Rust version.
rust-version = "1.93"
publish = false

[dependencies]
axum = "0.7.9"
ndarray = "0.17"
plotly = { path = "../../plotly" }
proa_core = { git = "https://github.com/proa-labs/proa", rev = "43c28ff2c94b3f148094300df761eac086e618a7" }
proa_framework_axum = { git = "https://github.com/proa-labs/proa", rev = "43c28ff2c94b3f148094300df761eac086e618a7" }
proa_macros = { git = "https://github.com/proa-labs/proa", rev = "43c28ff2c94b3f148094300df761eac086e618a7" }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tower = { version = "0.5", features = ["util"] }
69 changes: 69 additions & 0 deletions examples/proa/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# 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 Plotly
figures 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
```

The command writes `output/multiple_plots.html` and exits. Open that file in
your browser. Stdout prints the saved path; it does not contain the HTML.
The page loads Plotly.js 3.7.0 from its CDN, so an internet connection is required.
No listening server, WASM target, JavaScript build tool, or local Proa checkout
is needed.

## Components

- `PlotLayout<C>` in `src/layout.rs` owns the HTML document, styles, shared
Plotly.js script, and chart initialization. It accepts any synchronous child.
- `MultiplePlotsPage` in `src/page.rs` owns chart setup, page metadata, and
composition. Its `HeadMetadata` overrides layout defaults at `{cx.head()}`.
- `src/plots.rs` builds the three figures using Plotly.rs's normal API.
- `src/chart.rs` defines `Chart` and `ChartCard`, which supplies the common
section/heading structure.
- `Chart::new` borrows a `Plot` and serializes it during page construction.
Rendering borrows the prepared component and uses Proa's normal text escaping;
serialization stays outside the component render methods. 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.

The example pins a public Proa Git revision for the new managed metadata API,
which is not in the released 0.3 crates. Cargo fetches it automatically; no local
Proa checkout is required. The Git dependencies will be replaced with crates.io
versions after Proa 0.4 is released. The revision is from Proa's existing main
branch; no changes to Proa are required. `src/main.rs` configures managed metadata
once with `FrameworkBuilder::with_head`. The route returns
`RouteResponse::ssr(PlotLayout { ... })`, and an in-process request renders the
page through the framework's normal middleware. The response HTML is written to
disk; no listening server or custom response finalizer is required.

## Plotly.js boundary

Each chart renders a container followed by an inert `<template>` holding JSON.
Proa escapes the JSON as ordinary HTML text, and the browser decodes those
entities when reading the template's text content. This preserves the figure
without interpreting strings in its data as HTML. Chart IDs are escaped HTML
attributes, never interpolated into JavaScript. The layout's initialization
script passes each container DOM node and parsed seed to `Plotly.newPlot`.

The short JavaScript bridge is limited to Plotly's third-party API, which has no
RSJS binding here. Plotly.js owns the interactive charts and window resizing;
Proa renders the page on the Rust side. There are no application filters, live
data updates, or independent component mounting/unmounting in this
example.

The example does not replace Plotly.rs's internal Askama templates, modify its
public API, or change its default dependencies. Plotly.js and the figure data
still go to the browser. No rendering-speed or client-payload reduction is
claimed.
54 changes: 54 additions & 0 deletions examples/proa/src/chart.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//! Prepare figure JSON once and compose charts through a shared card component.

use plotly::Plot;
use proa_core::{DataLoader, WebContext, WebRenderSync, WriteError};
use proa_macros::html_sync;

pub struct Chart {
id: &'static str,
title: &'static str,
json: String,
pub class: Option<&'static str>,
}

impl Chart {
pub fn new(
id: &'static str,
title: &'static str,
plot: &Plot,
) -> Result<Self, serde_json::Error> {
Ok(Self {
id,
title,
json: serde_json::to_string(plot)?,
class: None,
})
}
}

impl<L: DataLoader> WebRenderSync<L> for &Chart {
fn render(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
html_sync! {
<div id={self.id} data-plotly-chart class={self.class}></div>
<template data-plotly-seed>{self.json.as_str()}</template>
}
.render(cx)
}
}

pub struct ChartCard<'a> {
pub chart: &'a Chart,
pub class: Option<&'static str>,
}

impl<L: DataLoader> WebRenderSync<L> for ChartCard<'_> {
fn render(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
html_sync! {
<section data-chart-card class={self.class}>
<h2>{self.chart.title}</h2>
{self.chart}
</section>
}
.render(cx)
}
}
66 changes: 66 additions & 0 deletions examples/proa/src/layout.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
use proa_core::{
text::StaticRaw, ComponentRole, DataLoader, HeadMetadata, SyncHeadOperation, WebContext,
WebRenderSync, WriteError,
};
use proa_macros::html_sync;

// Minimal vendor bridge: RSJS does not expose Plotly.newPlot or its DOM-node
// API. Only this developer-owned script bypasses HTML escaping. Figure data
// uses normal Proa text escaping; Plotly owns interaction and resizing.
const PLOTLY_INIT: &str = r#"
await Promise.all(Array.from(
document.querySelectorAll("template[data-plotly-seed]"),
seed => Plotly.newPlot(seed.previousElementSibling, JSON.parse(seed.content.textContent))
));
"#;

pub struct PlotLayout<C = ()> {
pub class: Option<&'static str>,
pub children: Option<C>,
}

impl<L: DataLoader, C: WebRenderSync<L>> WebRenderSync<L> for PlotLayout<C> {
const ROLE: ComponentRole = ComponentRole::LAYOUT;

fn render_head(&self, cx: &mut WebContext<L>) -> impl SyncHeadOperation {
cx.head_mut().metadata(HeadMetadata {
title: "Plotly.rs".into(),
description: "Interactive Plotly figures.".into(),
..Default::default()
})
}

fn render(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
html_sync! {
<!DOCTYPE html>
<html lang="en" data-plot-layout>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
{cx.head()}
<style>
r#"
body { margin: 0; color: #172033; background: #f5f7fb; font-family: system-ui, sans-serif; }
main { max-width: 1200px; margin: 0 auto; padding: 24px; }
h1 { font-size: 1.75rem; }
.charts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; }
[data-chart-card] { min-width: 0; padding: 16px; background: white; border: 1px solid #dce2eb; border-radius: 12px; }
[data-chart-card] h2 { margin: 0 0 12px; font-size: 1.125rem; }
[data-plotly-chart] { height: 420px; }
[data-chart-card]:last-child { grid-column: 1 / -1; }
[data-chart-card]:last-child [data-plotly-chart] { height: clamp(360px, 65vw, 800px); }
@media (max-width: 700px) { .charts { grid-template-columns: 1fr; } main { padding: 12px; } }
"#
</style>
<script src="https://cdn.plot.ly/plotly-3.7.0.min.js"></script>
</head>
<body class={self.class}>
{self.children}
<noscript>"Enable JavaScript to display the interactive charts."</noscript>
<script type="module">{StaticRaw(PLOTLY_INIT)}</script>
</body>
</html>
}
.render(cx)
}
}
50 changes: 50 additions & 0 deletions examples/proa/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
mod chart;
mod layout;
mod page;
mod plots;

use axum::{
body::{to_bytes, Body},
http::{Request, StatusCode},
routing::get,
Router,
};
use proa_core::HeadConfig;
use proa_framework_axum::{FrameworkBuilder, IslandManifest, RouteResponse};
use tower::ServiceExt;

use crate::{layout::PlotLayout, page::MultiplePlotsPage};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// No React islands are used by this example.
let manifest = IslandManifest {
islands: Default::default(),
assets: Default::default(),
import_map: Default::default(),
ssr_bundle: None,
};
let app = FrameworkBuilder::new(manifest)
.with_head(HeadConfig::default())
.with_dynamic_routes(Router::new().route("/", get(index)))
.build();

// Render one request in-process; no listening server is needed.
let response = app.oneshot(Request::get("/").body(Body::empty())?).await?;
if !response.status().is_success() {
return Err(format!("Page rendering failed: {}", response.status()).into());
}
let html = to_bytes(response.into_body(), usize::MAX).await?;
std::fs::create_dir_all("./output")?;
std::fs::write("./output/multiple_plots.html", html)?;
println!("Multiple plots HTML page saved to: ./output/multiple_plots.html");
Ok(())
}

async fn index() -> Result<RouteResponse, StatusCode> {
let page = MultiplePlotsPage::new().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(RouteResponse::ssr(PlotLayout {
class: None,
children: Some(page),
}))
}
61 changes: 61 additions & 0 deletions examples/proa/src/page.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
use proa_core::{
ComponentRole, DataLoader, HeadMetadata, SyncHeadOperation, WebContext, WebRenderSync,
WriteError,
};
use proa_macros::html_sync;

use crate::{
chart::{Chart, ChartCard},
plots::{first_plot, second_plot, third_plot},
};

const TITLE: &str = "Plotly-rs Multiple Plots";
const HEADING: &str = "Multiple Plotly plots on the same HTML page";
const DESCRIPTION: &str = "Three Plotly figures composed into a Proa page.";

pub struct MultiplePlotsPage {
charts: Vec<Chart>,
metadata: HeadMetadata,
class: Option<&'static str>,
}

impl MultiplePlotsPage {
pub fn new() -> Result<Self, serde_json::Error> {
let charts = vec![
Chart::new("scatter_1", "Sine wave", &first_plot())?,
Chart::new("scatter_2", "Colored markers", &second_plot())?,
Chart::new("scatter_3_3d", "3D scatter", &third_plot())?,
];
Ok(Self {
charts,
metadata: HeadMetadata {
title: TITLE.into(),
description: DESCRIPTION.into(),
..Default::default()
},
class: None,
})
}
}

impl<L: DataLoader> WebRenderSync<L> for MultiplePlotsPage {
const ROLE: ComponentRole = ComponentRole::PAGE;

fn render_head(&self, cx: &mut WebContext<L>) -> impl SyncHeadOperation {
cx.head_mut().metadata(self.metadata.clone())
}

fn render(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
html_sync! {
<main data-multiple-plots-page class={self.class}>
<h1>{HEADING}</h1>
<div class="charts">
{for chart in &self.charts {
ChartCard { chart, class: None }
}}
</div>
</main>
}
.render(cx)
}
}
Loading
Loading