diff --git a/docs/book/src/SUMMARY.md b/docs/book/src/SUMMARY.md index 1c996f7f..23235a84 100644 --- a/docs/book/src/SUMMARY.md +++ b/docs/book/src/SUMMARY.md @@ -6,6 +6,7 @@ - [Jupyter Support](./fundamentals/jupyter_support.md) - [ndarray Support](./fundamentals/ndarray_support.md) - [Shapes](./fundamentals/shapes.md) + - [Themes](./fundamentals/themes.md) - [Recipes](./recipes.md) - [Basic Charts](./recipes/basic_charts.md) - [Scatter Plots](./recipes/basic_charts/scatter_plots.md) @@ -24,9 +25,9 @@ - [Time Series and Date Axes](./recipes/financial_charts/time_series_and_date_axes.md) - [Candlestick Charts](./recipes/financial_charts/candlestick_charts.md) - [OHLC Charts](./recipes/financial_charts/ohlc_charts.md) + - [Rangebreaks](./recipes/financial_charts/rangebreaks.md) - [3D Charts](./recipes/3dcharts.md) - [Scatter 3D](./recipes/3dcharts/3dcharts.md) - [Subplots](./recipes/subplots.md) - [Subplots](./recipes/subplots/subplots.md) - [Multiple Axes](./recipes/subplots/multiple_axes.md) - - [Themes](./recipes/themes.md) diff --git a/docs/book/src/recipes/themes.md b/docs/book/src/fundamentals/themes.md similarity index 100% rename from docs/book/src/recipes/themes.md rename to docs/book/src/fundamentals/themes.md diff --git a/docs/book/src/recipes/financial_charts.md b/docs/book/src/recipes/financial_charts.md index 6a811d9a..06156011 100644 --- a/docs/book/src/recipes/financial_charts.md +++ b/docs/book/src/recipes/financial_charts.md @@ -7,3 +7,4 @@ Kind | Link Time Series and Date Axes |[![Time Series and Date Axes](./img/time_series_and_date_axes.png)](./financial_charts/time_series_and_date_axes.md) Candlestick Charts | [![Candlestick Charts](./img/candlestick_chart.png)](./financial_charts/candlestick_charts.md) OHLC Charts | [![OHLC Charts](./img/ohlc_chart.png)](./financial_charts/ohlc_charts.md) +Rangebreaks | [![Rangebreaks](./img/rangebreaks.png)](./financial_charts/rangebreaks.md) diff --git a/docs/book/src/recipes/financial_charts/rangebreaks.md b/docs/book/src/recipes/financial_charts/rangebreaks.md new file mode 100644 index 00000000..a40e372e --- /dev/null +++ b/docs/book/src/recipes/financial_charts/rangebreaks.md @@ -0,0 +1,45 @@ +# Rangebreaks + +The following imports have been used to produce the plots below: + +```rust,no_run +use plotly::common::{Mode, Title}; +use plotly::layout::{Axis, RangeBreak}; +use plotly::{Layout, Plot, Scatter}; +use chrono::{DateTime, Duration}; +use rand::{Rng, SeedableRng}; +use rand_chacha::ChaCha8Rng; +``` + +The `to_inline_html` method is used to produce the html plot displayed in this page. + +## Series with Weekend and Holiday Gaps +```rust,no_run +{{#include ../../../../../examples/financial_charts/src/main.rs:series_with_gaps_for_weekends_and_holidays}} +``` + +{{#include ../../../../../examples/financial_charts/output/inline_series_with_gaps_for_weekends_and_holidays.html}} + + +## Hiding Weekend and Holiday Gaps with Rangebreaks +```rust,no_run +{{#include ../../../../../examples/financial_charts/src/main.rs:hiding_weekends_and_holidays_with_rangebreaks}} +``` + +{{#include ../../../../../examples/financial_charts/output/inline_hiding_weekends_and_holidays_with_rangebreaks.html}} + + +## Series with Non-Business Hours Gaps +```rust,no_run +{{#include ../../../../../examples/financial_charts/src/main.rs:series_with_non_business_hours_gaps}} +``` + +{{#include ../../../../../examples/financial_charts/output/inline_series_with_non_business_hours_gaps.html}} + + +## Hiding Non-Business Hours Gaps with Rangebreaks +```rust,no_run +{{#include ../../../../../examples/financial_charts/src/main.rs:hiding_non_business_hours_with_rangebreaks}} +``` + +{{#include ../../../../../examples/financial_charts/output/inline_hiding_non_business_hours_with_rangebreaks.html}} diff --git a/docs/book/src/recipes/img/rangebreaks.png b/docs/book/src/recipes/img/rangebreaks.png new file mode 100644 index 00000000..d471ad57 Binary files /dev/null and b/docs/book/src/recipes/img/rangebreaks.png differ diff --git a/examples/financial_charts/Cargo.toml b/examples/financial_charts/Cargo.toml index 57d628ac..0ceab75f 100644 --- a/examples/financial_charts/Cargo.toml +++ b/examples/financial_charts/Cargo.toml @@ -9,3 +9,6 @@ csv = "1.1" plotly = { path = "../../plotly" } plotly_utils = { path = "../plotly_utils" } serde = "1.0" +chrono = "0.4" +rand = "0.8" +rand_chacha = "0.3" diff --git a/examples/financial_charts/src/main.rs b/examples/financial_charts/src/main.rs index 51949880..758928fa 100644 --- a/examples/financial_charts/src/main.rs +++ b/examples/financial_charts/src/main.rs @@ -3,6 +3,7 @@ use std::env; use std::path::PathBuf; +use chrono::{DateTime, Duration}; use plotly::common::TickFormatStop; use plotly::layout::{Axis, RangeSelector, RangeSlider, SelectorButton, SelectorStep, StepMode}; use plotly::{Candlestick, Layout, Ohlc, Plot, Scatter}; @@ -320,6 +321,169 @@ fn simple_ohlc_chart(show: bool, file_name: &str) { } // ANCHOR_END: simple_ohlc_chart +// ANCHOR: series_with_gaps_for_weekends_and_holidays +fn series_with_gaps_for_weekends_and_holidays(show: bool, file_name: &str) { + let data = load_apple_data(); + + // Filter data for the specific date range as in the Python example + let filtered_data: Vec<&FinData> = data + .iter() + .filter(|d| d.date.as_str() >= "2015-12-01" && d.date.as_str() <= "2016-01-15") + .collect(); + + let date: Vec = filtered_data.iter().map(|d| d.date.clone()).collect(); + let high: Vec = filtered_data.iter().map(|d| d.high).collect(); + + let trace = Scatter::new(date, high).mode(plotly::common::Mode::Markers); + + let mut plot = Plot::new(); + plot.add_trace(trace); + + let layout = Layout::new() + .title("Series with Weekend and Holiday Gaps") + .x_axis( + Axis::new() + .range(vec!["2015-12-01", "2016-01-15"]) + .title("Date"), + ) + .y_axis(Axis::new().title("Price")); + plot.set_layout(layout); + + let path = write_example_to_html(&plot, file_name); + if show { + plot.show_html(path); + } +} +// ANCHOR_END: series_with_gaps_for_weekends_and_holidays + +// ANCHOR: hiding_weekends_and_holidays_with_rangebreaks +fn hiding_weekends_and_holidays_with_rangebreaks(show: bool, file_name: &str) { + let data = load_apple_data(); + + // Filter data for the specific date range as in the Python example + let filtered_data: Vec<&FinData> = data + .iter() + .filter(|d| d.date.as_str() >= "2015-12-01" && d.date.as_str() <= "2016-01-15") + .collect(); + + let date: Vec = filtered_data.iter().map(|d| d.date.clone()).collect(); + let high: Vec = filtered_data.iter().map(|d| d.high).collect(); + + let trace = Scatter::new(date, high).mode(plotly::common::Mode::Markers); + + let mut plot = Plot::new(); + plot.add_trace(trace); + + let layout = Layout::new() + .title("Hide Weekend and Holiday Gaps with rangebreaks") + .x_axis( + Axis::new() + .range(vec!["2015-12-01", "2016-01-15"]) + .title("Date") + .range_breaks(vec![ + plotly::layout::RangeBreak::new() + .bounds("sat", "mon"), // hide weekends + plotly::layout::RangeBreak::new() + .values(vec!["2015-12-25", "2016-01-01"]), // hide Christmas and New Year's + ]), + ) + .y_axis(Axis::new().title("Price")); + plot.set_layout(layout); + + let path = write_example_to_html(&plot, file_name); + if show { + plot.show_html(path); + } +} +// ANCHOR_END: hiding_weekends_and_holidays_with_rangebreaks + +// Helper to generate random walk data for all hours in a week +fn generate_business_hours_data() -> (Vec, Vec) { + use rand::Rng; + use rand::SeedableRng; + use rand_chacha::ChaCha8Rng; + + let mut dates = Vec::new(); + let mut values = Vec::new(); + let mut current_value = 0.0; + let mut rng = ChaCha8Rng::seed_from_u64(42); + let start_date = DateTime::parse_from_rfc3339("2020-03-02T00:00:00Z").unwrap(); + for day in 0..5 { + // Monday to Friday + for hour in 0..24 { + let current_date = start_date + Duration::days(day) + Duration::hours(hour); + dates.push(current_date.format("%Y-%m-%d %H:%M:%S").to_string()); + current_value += (rng.gen::() - 0.5) * 2.0; + values.push(current_value); + } + } + (dates, values) +} + +// ANCHOR: series_with_non_business_hours_gaps +fn series_with_non_business_hours_gaps(show: bool, file_name: &str) { + use chrono::NaiveDateTime; + use chrono::Timelike; + let (dates, all_values) = generate_business_hours_data(); + let mut values = Vec::with_capacity(all_values.len()); + + for (date_str, v) in dates.iter().zip(all_values.iter()) { + // Parse the date string to extract hour + // Format is "2020-03-02 09:00:00" + if let Ok(datetime) = NaiveDateTime::parse_from_str(date_str, "%Y-%m-%d %H:%M:%S") { + let hour = datetime.hour(); + if (9..17).contains(&hour) { + values.push(*v); + } else { + values.push(f64::NAN); + } + } else { + values.push(f64::NAN); + } + } + + let trace = Scatter::new(dates, values).mode(plotly::common::Mode::Markers); + let mut plot = Plot::new(); + plot.add_trace(trace); + let layout = Layout::new() + .title("Series with Non-Business Hour Gaps") + .x_axis(Axis::new().title("Time").tick_format("%b %d, %Y %H:%M")) + .y_axis(Axis::new().title("Value")); + plot.set_layout(layout); + let path = write_example_to_html(&plot, file_name); + if show { + plot.show_html(path); + } +} +// ANCHOR_END: series_with_non_business_hours_gaps + +// ANCHOR: hiding_non_business_hours_with_rangebreaks +fn hiding_non_business_hours_with_rangebreaks(show: bool, file_name: &str) { + let (dates, values) = generate_business_hours_data(); + let trace = Scatter::new(dates, values).mode(plotly::common::Mode::Markers); + let mut plot = Plot::new(); + plot.add_trace(trace); + let layout = Layout::new() + .title("Hide Non-Business Hour Gaps with rangebreaks") + .x_axis( + Axis::new() + .title("Time") + .tick_format("%b %d, %Y %H:%M") + .range_breaks(vec![ + plotly::layout::RangeBreak::new() + .bounds("17", "9") + .pattern("hour"), // hide hours outside of 9am-5pm + ]), + ) + .y_axis(Axis::new().title("Value")); + plot.set_layout(layout); + let path = write_example_to_html(&plot, file_name); + if show { + plot.show_html(path); + } +} +// ANCHOR_END: hiding_non_business_hours_with_rangebreaks + fn main() { // Change false to true on any of these lines to display the example. @@ -341,4 +505,13 @@ fn main() { // OHLC Charts simple_ohlc_chart(false, "simple_ohlc_chart"); + + // Rangebreaks usage + series_with_gaps_for_weekends_and_holidays(false, "series_with_gaps_for_weekends_and_holidays"); + hiding_weekends_and_holidays_with_rangebreaks( + false, + "hiding_weekends_and_holidays_with_rangebreaks", + ); + series_with_non_business_hours_gaps(false, "series_with_non_business_hours_gaps"); + hiding_non_business_hours_with_rangebreaks(false, "hiding_non_business_hours_with_rangebreaks"); } diff --git a/plotly/src/layout/axis.rs b/plotly/src/layout/axis.rs index 07da881e..5b59d074 100644 --- a/plotly/src/layout/axis.rs +++ b/plotly/src/layout/axis.rs @@ -6,6 +6,7 @@ use crate::common::{ Anchor, AxisSide, Calendar, ColorBar, ColorScale, DashType, ExponentFormat, Font, TickFormatStop, TickMode, Title, }; +use crate::layout::RangeBreak; use crate::private::NumOrStringCollection; #[derive(Serialize, Debug, Clone)] @@ -304,6 +305,8 @@ pub struct Axis { r#type: Option, #[serde(rename = "autorange")] auto_range: Option, + #[serde(rename = "rangebreaks")] + range_breaks: Option>, #[serde(rename = "rangemode")] range_mode: Option, range: Option, diff --git a/plotly/src/layout/mod.rs b/plotly/src/layout/mod.rs index 89299eb9..a2d73a2b 100644 --- a/plotly/src/layout/mod.rs +++ b/plotly/src/layout/mod.rs @@ -18,6 +18,7 @@ mod grid; mod legend; mod mapbox; mod modes; +mod rangebreaks; mod scene; mod shape; @@ -35,6 +36,7 @@ pub use self::mapbox::{Center, Mapbox, MapboxStyle}; pub use self::modes::{ AspectMode, BarMode, BarNorm, BoxMode, ClickMode, UniformTextMode, ViolinMode, WaterfallMode, }; +pub use self::rangebreaks::RangeBreak; pub use self::scene::{ Camera, CameraCenter, DragMode, DragMode3D, HoverMode, LayoutScene, Projection, ProjectionType, Rotation, diff --git a/plotly/src/layout/rangebreaks.rs b/plotly/src/layout/rangebreaks.rs new file mode 100644 index 00000000..d502a5a9 --- /dev/null +++ b/plotly/src/layout/rangebreaks.rs @@ -0,0 +1,72 @@ +use serde::{Deserialize, Serialize}; + +/// Struct representing a rangebreak for Plotly axes. +/// See: https://plotly.com/python/reference/layout/xaxis/#layout-xaxis-rangebreaks +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RangeBreak { + /// Sets the lower and upper bounds for this range break, e.g. ["sat", + /// "mon"] + #[serde(skip_serializing_if = "Option::is_none")] + pub bounds: Option<[String; 2]>, + + /// Sets the pattern by which this range break is generated, e.g. "day of + /// week" + #[serde(skip_serializing_if = "Option::is_none")] + pub pattern: Option, + + /// Sets the values at which this range break occurs. + /// See Plotly.js docs for details. + #[serde(skip_serializing_if = "Option::is_none")] + pub values: Option>, + + /// Sets the size of each range break in milliseconds (for time axes). + #[serde(skip_serializing_if = "Option::is_none")] + pub dvalue: Option, + + /// Sets whether this range break is enabled. + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, +} + +impl Default for RangeBreak { + fn default() -> Self { + Self::new() + } +} + +impl RangeBreak { + pub fn new() -> Self { + Self { + bounds: None, + pattern: None, + values: None, + dvalue: None, + enabled: None, + } + } + + pub fn bounds(mut self, lower: impl Into, upper: impl Into) -> Self { + self.bounds = Some([lower.into(), upper.into()]); + self + } + + pub fn pattern(mut self, pattern: impl Into) -> Self { + self.pattern = Some(pattern.into()); + self + } + + pub fn values(mut self, values: Vec>) -> Self { + self.values = Some(values.into_iter().map(|v| v.into()).collect()); + self + } + + pub fn dvalue(mut self, dvalue: u64) -> Self { + self.dvalue = Some(dvalue); + self + } + + pub fn enabled(mut self, enabled: bool) -> Self { + self.enabled = Some(enabled); + self + } +}