use crate::prelude::*;
#[cfg(feature = "dashboard")]
use headless_chrome::{Browser, LaunchOptions};
#[cfg(feature = "dashboard")]
use uuid::Uuid;
#[cfg(feature = "dashboard")]
fn convert_to_f64_vec(array: &dyn Array) -> ElusionResult<Vec<f64>> {
match array.data_type() {
ArrowDataType::Float64 => {
let float_array = array.as_any()
.downcast_ref::<Float64Array>()
.ok_or_else(|| ElusionError::Custom("Failed to downcast to Float64Array".to_string()))?;
Ok(float_array.values().to_vec())
},
ArrowDataType::Int64 => {
let int_array = array.as_any()
.downcast_ref::<Int64Array>()
.ok_or_else(|| ElusionError::Custom("Failed to downcast to Int64Array".to_string()))?;
Ok(int_array.values().iter().map(|&x| x as f64).collect())
},
ArrowDataType::Date32 => {
let date_array = array.as_any()
.downcast_ref::<Date32Array>()
.ok_or_else(|| ElusionError::Custom("Failed to downcast to Date32Array".to_string()))?;
Ok(convert_date32_to_timestamps(date_array))
},
ArrowDataType::Utf8 => {
let string_array = array.as_any()
.downcast_ref::<StringArray>()
.ok_or_else(|| ElusionError::Custom("Failed to downcast to StringArray".to_string()))?;
let mut values = Vec::with_capacity(array.len());
for i in 0..array.len() {
let value = string_array.value(i).parse::<f64>().unwrap_or(0.0);
values.push(value);
}
Ok(values)
},
other_type => {
Err(ElusionError::Custom(format!("Unsupported data type for plotting: {:?}", other_type)))
}
}
}
#[cfg(feature = "dashboard")]
fn convert_to_string_vec(array: &dyn Array) -> ElusionResult<Vec<String>> {
match array.data_type() {
ArrowDataType::Utf8 => {
let string_array = array.as_any()
.downcast_ref::<StringArray>()
.ok_or_else(|| ElusionError::Custom("Failed to downcast to StringArray".to_string()))?;
let mut values = Vec::with_capacity(array.len());
for i in 0..array.len() {
values.push(string_array.value(i).to_string());
}
Ok(values)
},
other_type => {
Err(ElusionError::Custom(format!("Expected string type but got: {:?}", other_type)))
}
}
}
#[cfg(feature = "dashboard")]
fn convert_date32_to_timestamps(array: &Date32Array) -> Vec<f64> {
array.values()
.iter()
.map(|&days| {
let date = NaiveDate::from_num_days_from_ce_opt(days + 719163)
.unwrap_or(NaiveDate::from_ymd_opt(1970, 1, 1).unwrap());
let datetime = date.and_hms_opt(0, 0, 0).unwrap();
datetime.and_utc().timestamp() as f64 * 1000.0 })
.collect()
}
#[cfg(feature = "dashboard")]
fn sort_by_date(x_values: &[f64], y_values: &[f64]) -> (Vec<f64>, Vec<f64>) {
let mut pairs: Vec<(f64, f64)> = x_values.iter()
.cloned()
.zip(y_values.iter().cloned())
.collect();
pairs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(Ordering::Equal));
pairs.into_iter().unzip()
}
#[cfg(feature = "dashboard")]
fn parse_date_string(date_str: &str) -> Option<chrono::NaiveDateTime> {
let formats = [
"%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y", "%Y/%m/%d",
"%d %b %Y", "%d %B %Y", "%b %d %Y", "%B %d %Y",
"%Y-%m-%d %H:%M:%S", "%d.%m.%Y %H:%M:%S",
"%m/%d/%Y", "%Y.%m.%d", ];
for format in formats {
if let Ok(date) = chrono::NaiveDateTime::parse_from_str(date_str, format) {
return Some(date);
} else if let Ok(date) = chrono::NaiveDate::parse_from_str(date_str, format) {
return Some(date.and_hms_opt(0, 0, 0).unwrap_or_default());
}
}
None
}
#[cfg(feature = "dashboard")]
pub async fn plot_linee(
df: &CustomDataFrame,
x_col: &str,
y_col: &str,
show_markers: bool,
title: Option<&str>
) -> ElusionResult<PlotlyPlot> {
let batches = df.df.clone().collect().await.map_err(ElusionError::DataFusion)?;
let batch = &batches[0];
let x_idx = batch.schema().index_of(x_col)
.map_err(|e| ElusionError::Custom(format!("Column {} not found: {}", x_col, e)))?;
let y_idx = batch.schema().index_of(y_col)
.map_err(|e| ElusionError::Custom(format!("Column {} not found: {}", y_col, e)))?;
let x_values: Vec<f64> = convert_to_f64_vec(batch.column(x_idx))?;
let y_values: Vec<f64> = convert_to_f64_vec(batch.column(y_idx))?;
let (sorted_x, sorted_y) = sort_by_date(&x_values, &y_values);
let trace = if show_markers {
Scatter::new(sorted_x, sorted_y)
.mode(Mode::LinesMarkers)
.name(&format!("{} vs {}", y_col, x_col))
.line(Line::new()
.color(Rgb::new(0, 102, 51))
.width(3.0)
.shape(LineShape::Spline)) .marker(Marker::new()
.color(Rgb::new(239, 85, 59)) .size(10)
.line(plotly::common::Line::new()
.color(NamedColor::White)
.width(2.0)))
} else {
Scatter::new(sorted_x, sorted_y)
.mode(Mode::Lines)
.name(&format!("{} vs {}", y_col, x_col))
.line(Line::new()
.color(Rgb::new(0, 102, 51))
.width(3.0)
.shape(LineShape::Spline))
};
let mut plot = PlotlyPlot::new();
plot.add_trace(trace);
let x_axis = if matches!(batch.column(x_idx).data_type(), ArrowDataType::Date32) {
Axis::new()
.title(x_col.to_string())
.grid_color(Rgb::new(229, 229, 229))
.show_grid(true)
.type_(plotly::layout::AxisType::Date)
} else {
Axis::new()
.title(x_col.to_string())
.grid_color(Rgb::new(229, 229, 229))
.show_grid(true)
};
let layout = Layout::new()
.title(title.unwrap_or(&format!("{} vs {}", y_col, x_col)))
.x_axis(x_axis)
.y_axis(Axis::new()
.title(y_col.to_string())
.grid_color(Rgb::new(229, 229, 229))
.show_grid(true))
.plot_background_color(Rgba::new(248, 249, 250, 1.0));
plot.set_layout(layout);
Ok(plot)
}
#[cfg(feature = "dashboard")]
pub async fn plot_time_seriess(
df: &CustomDataFrame,
date_col: &str,
value_col: &str,
show_markers: bool,
title: Option<&str>,
) -> ElusionResult<PlotlyPlot> {
let batches = df.df.clone().collect().await.map_err(ElusionError::DataFusion)?;
let batch = &batches[0];
let x_idx = batch.schema().index_of(date_col)
.map_err(|e| ElusionError::Custom(format!("Column {} not found: {}", date_col, e)))?;
let y_idx = batch.schema().index_of(value_col)
.map_err(|e| ElusionError::Custom(format!("Column {} not found: {}", value_col, e)))?;
if !matches!(batch.column(x_idx).data_type(), ArrowDataType::Date32) {
return Err(ElusionError::Custom(
format!("Column {} must be a Date32 type for time series plot", date_col)
));
}
let x_values = convert_to_f64_vec(batch.column(x_idx))?;
let y_values = convert_to_f64_vec(batch.column(y_idx))?;
let (sorted_x, sorted_y) = sort_by_date(&x_values, &y_values);
let trace = if show_markers {
Scatter::new(sorted_x, sorted_y)
.mode(Mode::LinesMarkers)
.name(value_col)
.line(Line::new()
.color(Rgb::new(0, 102, 51))
.width(3.0)
.shape(LineShape::Spline)) .marker(Marker::new()
.color(Rgb::new(220, 38, 38)) .size(10)
.line(plotly::common::Line::new()
.color(NamedColor::White)
.width(2.0)))
} else {
Scatter::new(sorted_x, sorted_y)
.mode(Mode::Lines)
.name(value_col)
.line(Line::new()
.color(Rgb::new(0, 102, 51))
.width(3.0)
.shape(LineShape::Spline)) };
let mut plot = PlotlyPlot::new();
plot.add_trace(trace);
let layout = Layout::new()
.title(title.unwrap_or(&format!("{} over Time", value_col)))
.x_axis(Axis::new()
.title(date_col.to_string())
.grid_color(Rgb::new(229, 229, 229))
.show_grid(true)
.type_(plotly::layout::AxisType::Date))
.y_axis(Axis::new()
.title(value_col.to_string())
.grid_color(Rgb::new(229, 229, 229))
.show_grid(true))
.plot_background_color(Rgba::new(248, 249, 250, 1.0));
plot.set_layout(layout);
Ok(plot)
}
#[cfg(feature = "dashboard")]
pub async fn plot_scatterr(
df: &CustomDataFrame,
x_col: &str,
y_col: &str,
marker_size: Option<usize>,
) -> ElusionResult<PlotlyPlot> {
let batches = df.df.clone().collect().await.map_err(ElusionError::DataFusion)?;
let batch = &batches[0];
let x_idx = batch.schema().index_of(x_col)
.map_err(|e| ElusionError::Custom(format!("Column {} not found: {}", x_col, e)))?;
let y_idx = batch.schema().index_of(y_col)
.map_err(|e| ElusionError::Custom(format!("Column {} not found: {}", y_col, e)))?;
let x_values: Vec<f64> = convert_to_f64_vec(batch.column(x_idx))?;
let y_values: Vec<f64> = convert_to_f64_vec(batch.column(y_idx))?;
let trace = Scatter::new(x_values, y_values.clone())
.mode(Mode::Markers)
.name(&format!("{} vs {}", y_col, x_col))
.marker(Marker::new()
.size(marker_size.unwrap_or(12))
.color_array(y_values) .color_scale(ColorScale::Palette(ColorScalePalette::Viridis))
.show_scale(true)
.line(plotly::common::Line::new()
.color(NamedColor::White)
.width(1.5)));
let mut plot = PlotlyPlot::new();
plot.add_trace(trace);
let layout = Layout::new()
.title(format!("Scatter Plot: {} vs {}", y_col, x_col))
.x_axis(Axis::new().title(x_col.to_string()))
.y_axis(Axis::new().title(y_col.to_string()))
.plot_background_color(Rgba::new(248, 249, 250, 1.0));
plot.set_layout(layout);
Ok(plot)
}
#[cfg(feature = "dashboard")]
pub async fn plot_barr(
df: &CustomDataFrame,
x_col: &str,
y_col: &str,
orientation: Option<&str>,
title: Option<&str>,
) -> ElusionResult<PlotlyPlot> {
let batches = df.df.clone().collect().await.map_err(ElusionError::DataFusion)?;
let batch = &batches[0];
let x_idx = batch.schema().index_of(x_col)
.map_err(|e| ElusionError::Custom(format!("Column {} not found: {}", x_col, e)))?;
let y_idx = batch.schema().index_of(y_col)
.map_err(|e| ElusionError::Custom(format!("Column {} not found: {}", y_col, e)))?;
let (x_values, y_values) = if batch.column(x_idx).data_type() == &ArrowDataType::Utf8 {
(convert_to_string_vec(batch.column(x_idx))?, convert_to_f64_vec(batch.column(y_idx))?)
} else {
(convert_to_string_vec(batch.column(y_idx))?, convert_to_f64_vec(batch.column(x_idx))?)
};
let colors = vec![
"rgb(60, 87, 214)", "rgb(220, 38, 38)", "rgb(5, 135, 95)", "rgb(234, 88, 12)", "rgb(13, 149, 210)", "rgb(197, 35, 107)", "rgb(31, 177, 85)", "rgb(151, 77, 222)", "rgb(221, 142, 10)", "rgb(112, 52, 213)", ];
let bar_colors: Vec<String> = (0..x_values.len())
.map(|i| colors[i % colors.len()].to_string())
.collect();
let trace = match orientation.unwrap_or("v") {
"h" => {
Bar::new(x_values.clone(), y_values.clone())
.orientation(Orientation::Horizontal)
.name(&format!("{} by {}", y_col, x_col))
.marker(Marker::new()
.color_array(bar_colors)
.line(plotly::common::Line::new()
.color(NamedColor::White)
.width(1.5)))
},
_ => {
Bar::new(x_values, y_values)
.orientation(Orientation::Vertical)
.name(&format!("{} by {}", y_col, x_col))
.marker(Marker::new()
.color_array(bar_colors)
.line(plotly::common::Line::new()
.color(NamedColor::White)
.width(1.5)))
}
};
let mut plot = PlotlyPlot::new();
plot.add_trace(trace);
let layout = Layout::new()
.title(title.unwrap_or(&format!("Bar Chart: {} by {}", y_col, x_col)))
.x_axis(Axis::new().title(x_col.to_string()))
.y_axis(Axis::new().title(y_col.to_string()))
.plot_background_color(Rgba::new(248, 249, 250, 1.0));
plot.set_layout(layout);
Ok(plot)
}
#[cfg(feature = "dashboard")]
pub async fn plot_histogramm(
df: &CustomDataFrame,
col: &str,
bins: Option<usize>,
title: Option<&str>
) -> ElusionResult<PlotlyPlot> {
let batches = df.df.clone().collect().await.map_err(ElusionError::DataFusion)?;
let batch = &batches[0];
let idx = batch.schema().index_of(col)
.map_err(|e| ElusionError::Custom(format!("Column {} not found: {}", col, e)))?;
let values = convert_to_f64_vec(batch.column(idx))?;
let trace = Histogram::new(values)
.name(col)
.n_bins_x(bins.unwrap_or(30))
.marker(Marker::new()
.color(Rgba::new(0, 204, 102, 0.7))
.line(plotly::common::Line::new()
.color(Rgb::new(0, 102, 51)) .width(1.5)));
let mut plot = PlotlyPlot::new();
plot.add_trace(trace);
let layout = Layout::new()
.title(title.unwrap_or(&format!("Histogram of {}", col)))
.x_axis(Axis::new()
.title(col.to_string())
.grid_color(Rgb::new(229, 229, 229))
.show_grid(true))
.y_axis(Axis::new()
.title("Count".to_string())
.grid_color(Rgb::new(229, 229, 229))
.show_grid(true))
.plot_background_color(Rgba::new(248, 249, 250, 1.0))
.bar_mode(BarMode::Overlay);
plot.set_layout(layout);
Ok(plot)
}
#[cfg(feature = "dashboard")]
pub async fn plot_boxx(
df: &CustomDataFrame,
value_col: &str,
group_by_col: Option<&str>,
title: Option<&str>,
) -> ElusionResult<PlotlyPlot> {
let batches = df.df.clone().collect().await.map_err(ElusionError::DataFusion)?;
let batch = &batches[0];
let value_idx = batch.schema().index_of(value_col)
.map_err(|e| ElusionError::Custom(format!("Column {} not found: {}", value_col, e)))?;
let values = convert_to_f64_vec(batch.column(value_idx))?;
let trace = if let Some(group_col) = group_by_col {
let group_idx = batch.schema().index_of(group_col)
.map_err(|e| ElusionError::Custom(format!("Column {} not found: {}", group_col, e)))?;
let groups = convert_to_f64_vec(batch.column(group_idx))?;
BoxPlot::new(values)
.x(groups) .name(value_col)
.marker(Marker::new()
.color(Rgb::new(0, 204, 102))
.size(6)
.line(plotly::common::Line::new()
.color(Rgb::new(0, 204, 102))
.width(1.0)))
.line(plotly::common::Line::new()
.color(Rgb::new(0, 204, 102))
.width(2.0))
.fill_color(Rgba::new(0, 204, 102, 0.7))
} else {
BoxPlot::new(values)
.name(value_col)
.marker(Marker::new()
.color(Rgb::new(0, 204, 102))
.size(6)
.line(plotly::common::Line::new()
.color(Rgb::new(0, 102, 51))
.width(1.0)))
.line(plotly::common::Line::new()
.color(Rgb::new(0, 204, 102))
.width(2.0))
.fill_color(Rgba::new(0, 204, 102, 0.7))
};
let mut plot = PlotlyPlot::new();
plot.add_trace(trace);
let layout = Layout::new()
.title(title.unwrap_or(&format!("Distribution of {}", value_col)))
.y_axis(Axis::new()
.title(value_col.to_string())
.grid_color(Rgb::new(229, 229, 229))
.show_grid(true)
.zero_line(true)
.zero_line_color(Rgb::new(200, 200, 200)))
.x_axis(Axis::new()
.title(group_by_col.unwrap_or("").to_string())
.grid_color(Rgb::new(229, 229, 229))
.show_grid(true))
.plot_background_color(Rgba::new(248, 249, 250, 1.0));
plot.set_layout(layout);
Ok(plot)
}
#[cfg(feature = "dashboard")]
pub async fn plot_piee(
df: &CustomDataFrame,
label_col: &str,
value_col: &str,
title: Option<&str>,
) -> ElusionResult<PlotlyPlot> {
let batches = df.df.clone().collect().await.map_err(ElusionError::DataFusion)?;
let batch = &batches[0];
let label_idx = batch.schema().index_of(label_col)
.map_err(|e| ElusionError::Custom(format!("Column {} not found: {}", label_col, e)))?;
let value_idx = batch.schema().index_of(value_col)
.map_err(|e| ElusionError::Custom(format!("Column {} not found: {}", value_col, e)))?;
let labels = convert_to_string_vec(batch.column(label_idx))?;
let values = convert_to_f64_vec(batch.column(value_idx))?;
let colors = vec![
"rgb(99, 110, 250)", "rgb(239, 85, 59)", "rgb(0, 204, 150)", "rgb(255, 161, 90)", "rgb(25, 211, 243)", "rgb(255, 102, 146)", "rgb(182, 232, 128)", "rgb(255, 151, 255)", "rgb(254, 203, 82)", "rgb(155, 135, 245)", ];
let slice_colors: Vec<String> = (0..labels.len())
.map(|i| colors[i % colors.len()].to_string())
.collect();
let trace = Pie::new(values)
.labels(labels)
.name(value_col)
.hole(0.0)
.marker(Marker::new()
.color_array(slice_colors) .line(plotly::common::Line::new()
.color(NamedColor::White)
.width(2.0)));
let mut plot = PlotlyPlot::new();
plot.add_trace(trace);
let layout = Layout::new()
.title(title.unwrap_or(&format!("Distribution of {}", value_col)))
.show_legend(true)
.plot_background_color(Rgba::new(248, 249, 250, 1.0));
plot.set_layout(layout);
Ok(plot)
}
#[cfg(feature = "dashboard")]
pub async fn plot_donutt(
df: &CustomDataFrame,
label_col: &str,
value_col: &str,
title: Option<&str>,
hole_size: Option<f64>,
) -> ElusionResult<PlotlyPlot> {
let batches = df.df.clone().collect().await.map_err(ElusionError::DataFusion)?;
let batch = &batches[0];
let label_idx = batch.schema().index_of(label_col)
.map_err(|e| ElusionError::Custom(format!("Column {} not found: {}", label_col, e)))?;
let value_idx = batch.schema().index_of(value_col)
.map_err(|e| ElusionError::Custom(format!("Column {} not found: {}", value_col, e)))?;
let labels = convert_to_string_vec(batch.column(label_idx))?;
let values = convert_to_f64_vec(batch.column(value_idx))?;
let hole_size = hole_size.unwrap_or(0.5).max(0.0).min(1.0);
let colors = vec![
"rgb(99, 110, 250)", "rgb(239, 85, 59)", "rgb(0, 204, 150)", "rgb(255, 161, 90)", "rgb(25, 211, 243)", "rgb(255, 102, 146)", "rgb(182, 232, 128)", "rgb(255, 151, 255)", "rgb(254, 203, 82)", "rgb(155, 135, 245)", ];
let slice_colors: Vec<String> = (0..labels.len())
.map(|i| colors[i % colors.len()].to_string())
.collect();
let trace = Pie::new(values)
.labels(labels)
.name(value_col)
.hole(hole_size)
.marker(Marker::new()
.color_array(slice_colors) .line(plotly::common::Line::new()
.color(NamedColor::White)
.width(2.0)));
let mut plot = PlotlyPlot::new();
plot.add_trace(trace);
let layout = Layout::new()
.title(title.unwrap_or(&format!("Distribution of {}", value_col)))
.show_legend(true)
.plot_background_color(Rgba::new(248, 249, 250, 1.0));
plot.set_layout(layout);
Ok(plot)
}
#[cfg(feature = "dashboard")]
pub async fn plot_waterfall_impl(
df: &CustomDataFrame,
x_col: &str,
y_col: &str,
title: Option<&str>,
) -> ElusionResult<PlotlyPlot> {
let batches = df.df.clone().collect().await.map_err(ElusionError::DataFusion)?;
let batch = &batches[0];
let x_idx = batch.schema().index_of(x_col)
.map_err(|e| ElusionError::Custom(format!("Column {} not found: {}", x_col, e)))?;
let y_idx = batch.schema().index_of(y_col)
.map_err(|e| ElusionError::Custom(format!("Column {} not found: {}", y_col, e)))?;
let x_values = convert_to_string_vec(batch.column(x_idx))?;
let y_values = convert_to_f64_vec(batch.column(y_idx))?;
let mut cumulative: Vec<f64> = Vec::new();
let mut running_total = 0.0;
for &val in &y_values {
cumulative.push(running_total);
running_total += val;
}
let colors: Vec<String> = y_values.iter()
.map(|&v| {
if v >= 0.0 {
"rgb(0, 204, 150)".to_string() } else {
"rgb(239, 85, 59)".to_string() }
})
.collect();
let base_trace = Bar::new(x_values.clone(), cumulative.clone())
.name("Base")
.marker(Marker::new()
.color(Rgba::new(0, 0, 0, 0.0))) .show_legend(false);
let change_trace = Bar::new(x_values, y_values)
.name("Change")
.marker(Marker::new()
.color_array(colors)
.line(plotly::common::Line::new()
.color(NamedColor::White)
.width(2.0)));
let mut plot = PlotlyPlot::new();
plot.add_trace(base_trace);
plot.add_trace(change_trace);
let layout = Layout::new()
.title(title.unwrap_or("Waterfall Chart"))
.bar_mode(BarMode::Stack)
.x_axis(Axis::new().title(x_col.to_string()))
.y_axis(Axis::new().title(y_col.to_string()))
.plot_background_color(Rgba::new(248, 249, 250, 1.0));
plot.set_layout(layout);
Ok(plot)
}
#[cfg(feature = "dashboard")]
pub async fn plot_line_impl(
df: &CustomDataFrame,
date_col: &str,
value_col: &str,
show_markers: bool,
title: Option<&str>,
) -> ElusionResult<PlotlyPlot> {
let mut plot = plot_linee(df, date_col, value_col, show_markers, title).await?;
let buttons = vec![
Button::new()
.name("1m")
.args(json!({
"xaxis.range": ["now-1month", "now"]
}))
.label("1m"),
Button::new()
.name("6m")
.args(json!({
"xaxis.range": ["now-6months", "now"]
}))
.label("6m"),
Button::new()
.name("1y")
.args(json!({
"xaxis.range": ["now-1year", "now"]
}))
.label("1y"),
Button::new()
.name("YTD")
.args(json!({
"xaxis.range": ["now-ytd", "now"]
}))
.label("YTD"),
Button::new()
.name("all")
.args(json!({
"xaxis.autorange": true
}))
.label("All")
];
let layout = plot.layout().clone()
.x_axis(Axis::new()
.title(date_col.to_string())
.grid_color(Rgb::new(229, 229, 229))
.show_grid(true)
.type_(plotly::layout::AxisType::Date)
.range_slider(RangeSlider::new().visible(true)))
.update_menus(vec![
UpdateMenu::new()
.buttons(buttons)
.direction(UpdateMenuDirection::Down)
.show_active(true)
])
.drag_mode(DragMode::Zoom);
plot.set_layout(layout);
Ok(plot)
}
#[cfg(feature = "dashboard")]
pub async fn plot_time_series_impl(
df: &CustomDataFrame,
date_col: &str,
value_col: &str,
show_markers: bool,
title: Option<&str>,
) -> ElusionResult<PlotlyPlot> {
let mut plot = plot_time_seriess(df, date_col, value_col, show_markers, title).await?;
let buttons = vec![
Button::new()
.name("1m")
.args(json!({
"xaxis.range": ["now-1month", "now"]
}))
.label("1m"),
Button::new()
.name("6m")
.args(json!({
"xaxis.range": ["now-6months", "now"]
}))
.label("6m"),
Button::new()
.name("1y")
.args(json!({
"xaxis.range": ["now-1year", "now"]
}))
.label("1y"),
Button::new()
.name("YTD")
.args(json!({
"xaxis.range": ["now-ytd", "now"]
}))
.label("YTD"),
Button::new()
.name("all")
.args(json!({
"xaxis.autorange": true
}))
.label("All")
];
let layout = plot.layout().clone()
.x_axis(Axis::new()
.title(date_col.to_string())
.grid_color(Rgb::new(229, 229, 229))
.show_grid(true)
.type_(plotly::layout::AxisType::Date)
.range_slider(RangeSlider::new().visible(true)))
.update_menus(vec![
UpdateMenu::new()
.buttons(buttons)
.direction(UpdateMenuDirection::Down)
.show_active(true)
])
.drag_mode(DragMode::Zoom);
plot.set_layout(layout);
Ok(plot)
}
#[cfg(feature = "dashboard")]
pub async fn plot_bar_impl(
df: &CustomDataFrame,
x_col: &str,
y_col: &str,
title: Option<&str>,
) -> ElusionResult<PlotlyPlot> {
let mut plot = plot_barr(df, x_col, y_col, None, title).await?;
let update_menu_buttons = vec![
Button::new()
.name("reset")
.args(json!({
"xaxis.type": "category",
"xaxis.categoryorder": "trace"
}))
.label("Reset"),
Button::new()
.name("ascending")
.args(json!({
"xaxis.type": "category",
"xaxis.categoryorder": "total ascending"
}))
.label("Sort Ascending"),
Button::new()
.name("descending")
.args(json!({
"xaxis.type": "category",
"xaxis.categoryorder": "total descending"
}))
.label("Sort Descending")
];
let layout = plot.layout().clone()
.show_legend(true)
.update_menus(vec![
UpdateMenu::new()
.buttons(update_menu_buttons)
.direction(UpdateMenuDirection::Down)
.show_active(true)
]);
plot.set_layout(layout);
Ok(plot)
}
#[cfg(feature = "dashboard")]
pub async fn plot_scatter_impl(
df: &CustomDataFrame,
x_col: &str,
y_col: &str,
marker_size: Option<usize>,
) -> ElusionResult<PlotlyPlot> {
let mut plot = plot_scatterr(df, x_col, y_col, marker_size).await?;
let mode_buttons = vec![
Button::new()
.name("zoom")
.args(json!({
"dragmode": "zoom"
}))
.label("Zoom"),
Button::new()
.name("select")
.args(json!({
"dragmode": "select"
}))
.label("Select"),
Button::new()
.name("pan")
.args(json!({
"dragmode": "pan"
}))
.label("Pan")
];
let layout = plot.layout().clone()
.show_legend(true)
.drag_mode(DragMode::Zoom)
.update_menus(vec![
UpdateMenu::new()
.buttons(mode_buttons)
.direction(UpdateMenuDirection::Down)
.show_active(true)
]);
plot.set_layout(layout);
Ok(plot)
}
#[cfg(feature = "dashboard")]
pub async fn plot_histogram_impl(
df: &CustomDataFrame,
col: &str,
title: Option<&str>,
) -> ElusionResult<PlotlyPlot> {
let mut plot = plot_histogramm(df, col, None, title).await?;
let bin_buttons = vec![
Button::new()
.name("bins10")
.args(json!({
"xbins.size": 10
}))
.label("10 Bins"),
Button::new()
.name("bins20")
.args(json!({
"xbins.size": 20
}))
.label("20 Bins"),
Button::new()
.name("bins30")
.args(json!({
"xbins.size": 30
}))
.label("30 Bins")
];
let layout = plot.layout().clone()
.show_legend(true)
.update_menus(vec![
UpdateMenu::new()
.buttons(bin_buttons)
.direction(UpdateMenuDirection::Down)
.show_active(true)
]);
plot.set_layout(layout);
Ok(plot)
}
#[cfg(feature = "dashboard")]
pub async fn plot_box_impl(
df: &CustomDataFrame,
value_col: &str,
group_by_col: Option<&str>,
title: Option<&str>,
) -> ElusionResult<PlotlyPlot> {
let mut plot = plot_boxx(df, value_col, group_by_col, title).await?;
let outlier_buttons = vec![
Button::new()
.name("show_outliers")
.args(json!({
"boxpoints": "outliers"
}))
.label("Show Outliers"),
Button::new()
.name("hide_outliers")
.args(json!({
"boxpoints": false
}))
.label("Hide Outliers")
];
let layout = plot.layout().clone()
.show_legend(true)
.update_menus(vec![
UpdateMenu::new()
.buttons(outlier_buttons)
.direction(UpdateMenuDirection::Down)
.show_active(true)
]);
plot.set_layout(layout);
Ok(plot)
}
#[cfg(feature = "dashboard")]
pub async fn plot_pie_impl(
df: &CustomDataFrame,
label_col: &str,
value_col: &str,
title: Option<&str>,
) -> ElusionResult<PlotlyPlot> {
let mut plot = plot_piee(df, label_col, value_col, title).await?;
let display_buttons = vec![
Button::new()
.name("percentage")
.args(json!({
"textinfo": "percent"
}))
.label("Show Percentages"),
Button::new()
.name("values")
.args(json!({
"textinfo": "value"
}))
.label("Show Values"),
Button::new()
.name("both")
.args(json!({
"textinfo": "value+percent"
}))
.label("Show Both")
];
let layout = plot.layout().clone()
.show_legend(true)
.update_menus(vec![
UpdateMenu::new()
.buttons(display_buttons)
.direction(UpdateMenuDirection::Down)
.show_active(true)
]);
plot.set_layout(layout);
Ok(plot)
}
#[cfg(feature = "dashboard")]
pub async fn plot_donut_impl(
df: &CustomDataFrame,
label_col: &str,
value_col: &str,
title: Option<&str>,
) -> ElusionResult<PlotlyPlot> {
let mut plot = plot_donutt(df, label_col, value_col, title, Some(0.5)).await?;
let hole_buttons = vec![
Button::new()
.name("small")
.args(json!({
"hole": 0.3
}))
.label("Small Hole"),
Button::new()
.name("medium")
.args(json!({
"hole": 0.5
}))
.label("Medium Hole"),
Button::new()
.name("large")
.args(json!({
"hole": 0.7
}))
.label("Large Hole")
];
let layout = plot.layout().clone()
.show_legend(true)
.update_menus(vec![
UpdateMenu::new()
.buttons(hole_buttons)
.direction(UpdateMenuDirection::Down)
.show_active(true)
]);
plot.set_layout(layout);
Ok(plot)
}
#[cfg(feature = "dashboard")]
pub async fn create_report_impl(
plots: Option<&[(&PlotlyPlot, &str)]>,
tables: Option<&[(&CustomDataFrame, &str)]>,
report_title: &str,
filename: &str, layout_config: Option<ReportLayout>,
table_options: Option<TableOptions>,
) -> ElusionResult<()> {
if let Some(parent) = LocalPath::new(filename).parent() {
if !parent.exists() {
fs::create_dir_all(parent)?;
}
}
let file_path_str = LocalPath::new(filename).to_str()
.ok_or_else(|| ElusionError::Custom("Invalid path".to_string()))?;
let layout = layout_config.unwrap_or_default();
let plot_containers = plots.map(|plots| {
plots.iter().enumerate()
.map(|(i, (plot, title))| {
let plot_data = serde_json::to_string(plot.data())
.unwrap_or_else(|_| "[]".to_string());
let plot_layout = serde_json::to_string(plot.layout())
.unwrap_or_else(|_| "{}".to_string());
format!(
r#"<div class="plot-container"
data-plot-data='{}'
data-plot-layout='{}'>
<div class="plot-title">{}</div>
<div id="plot_{}" style="width:100%;height:{}px;"></div>
</div>"#,
plot_data,
plot_layout,
title,
i,
layout.plot_height
)
})
.collect::<Vec<_>>()
.join("\n")
}).unwrap_or_default();
let table_containers = if let Some(tables) = tables {
let table_op = TableOptions::default();
let table_opts = table_options.as_ref().unwrap_or(&table_op);
let mut containers = Vec::new();
for (i, (df, title)) in tables.iter().enumerate() {
let batches = df.df.clone().collect().await?;
let schema = df.df.schema();
let columns = schema.fields().iter()
.map(|f| {
let base_def = format!(
r#"{{
field: "{}",
headerName: "{}",
sortable: true,
filter: true,
resizable: true"#,
f.name(),
f.name()
);
let column_def = match f.data_type() {
ArrowDataType::Date32 | ArrowDataType::Date64 | ArrowDataType::Timestamp(_, _) => {
format!(
r#"{},
filter: 'agDateColumnFilter',
filterParams: {{
browserDatePicker: true,
minValidYear: 1000,
maxValidYear: 9999
}}"#,
base_def
)
},
ArrowDataType::Utf8 if f.name().to_lowercase().contains("date") ||
f.name().to_lowercase().contains("time") => {
format!(
r#"{},
filter: 'agDateColumnFilter',
filterParams: {{
browserDatePicker: true,
minValidYear: 1000,
maxValidYear: 9999,
comparator: (filterValue, cellValue) => {{
try {{
const filterDate = new Date(filterValue);
const cellDate = new Date(cellValue);
if (!isNaN(filterDate) && !isNaN(cellDate)) {{
return cellDate - filterDate;
}}
}} catch (e) {{}}
return 0;
}}
}}"#,
base_def
)
},
_ => base_def,
};
format!("{}}}", column_def)
})
.collect::<Vec<_>>()
.join(",");
let mut rows = Vec::new();
for batch in &batches {
for row_idx in 0..batch.num_rows() {
let mut row = serde_json::Map::new();
for (col_idx, field) in batch.schema().fields().iter().enumerate() {
let col = batch.column(col_idx);
let value = match col.data_type() {
ArrowDataType::Int32 => {
let array = col.as_any().downcast_ref::<Int32Array>().unwrap();
if array.is_null(row_idx) {
serde_json::Value::Null
} else {
serde_json::Value::Number(array.value(row_idx).into())
}
},
ArrowDataType::Int64 => {
let array = col.as_any().downcast_ref::<Int64Array>().unwrap();
if array.is_null(row_idx) {
serde_json::Value::Null
} else {
serde_json::Value::Number(array.value(row_idx).into())
}
},
ArrowDataType::Float64 => {
let array = col.as_any().downcast_ref::<Float64Array>().unwrap();
if array.is_null(row_idx) {
serde_json::Value::Null
} else {
let num = array.value(row_idx);
if num.is_finite() {
serde_json::Number::from_f64(num)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null)
} else {
serde_json::Value::Null
}
}
},
ArrowDataType::Date32 => {
let array = col.as_any().downcast_ref::<Date32Array>().unwrap();
if array.is_null(row_idx) {
serde_json::Value::Null
} else {
let days = array.value(row_idx);
let date = chrono::NaiveDate::from_num_days_from_ce_opt(days + 719163)
.unwrap_or(chrono::NaiveDate::from_ymd_opt(1970, 1, 1).unwrap());
let datetime = date.and_hms_opt(0, 0, 0).unwrap();
serde_json::Value::String(datetime.format("%Y-%m-%d").to_string())
}
},
ArrowDataType::Date64 => {
let array = col.as_any().downcast_ref::<Date64Array>().unwrap();
if array.is_null(row_idx) {
serde_json::Value::Null
} else {
let ms = array.value(row_idx);
let datetime = chrono::DateTime::from_timestamp_millis(ms)
.unwrap_or_default()
.naive_utc();
serde_json::Value::String(datetime.format("%Y-%m-%d %H:%M:%S").to_string())
}
},
ArrowDataType::Timestamp(time_unit, None) => {
let array = col.as_any().downcast_ref::<TimestampNanosecondArray>().unwrap();
if array.is_null(row_idx) {
serde_json::Value::Null
} else {
let ts = array.value(row_idx);
let datetime = match time_unit {
TimeUnit::Second => chrono::DateTime::from_timestamp(ts, 0),
TimeUnit::Millisecond => chrono::DateTime::from_timestamp_millis(ts),
TimeUnit::Microsecond => chrono::DateTime::from_timestamp_micros(ts),
TimeUnit::Nanosecond => chrono::DateTime::from_timestamp(
ts / 1_000_000_000,
(ts % 1_000_000_000) as u32
),
}.unwrap_or_default().naive_utc();
serde_json::Value::String(datetime.format("%Y-%m-%d %H:%M:%S").to_string())
}
},
ArrowDataType::Utf8 => {
let array = col.as_any().downcast_ref::<StringArray>().unwrap();
if array.is_null(row_idx) {
serde_json::Value::Null
} else {
let value = array.value(row_idx);
if field.name().to_lowercase().contains("date") ||
field.name().to_lowercase().contains("time") {
if let Some(datetime) = parse_date_string(value) {
serde_json::Value::String(
datetime.format("%Y-%m-%d %H:%M:%S").to_string()
)
} else {
serde_json::Value::String(value.to_string())
}
} else {
serde_json::Value::String(value.to_string())
}
}
},
_ => serde_json::Value::Null,
};
row.insert(field.name().clone(), value);
}
rows.push(serde_json::Value::Object(row));
}
}
let container = format!(
r#"<div class="table-container">
<div class="table-title">{0}</div>
<div id="grid_{1}"
class="{2}"
style="width:100%;height:{3}px;"
data-table-title="{0}">
<!-- AG Grid will be rendered here -->
</div>
<script>
(function() {{
console.log('Initializing grid_{1}');
// Column definitions with more detailed configuration
const columnDefs = [{4}];
console.log('Column definitions:', columnDefs);
const rowData = {5};
console.log('Row data:', rowData);
// Grid options with more features
const gridOptions = {{
columnDefs: columnDefs,
rowData: rowData,
pagination: {6},
paginationPageSize: {7},
defaultColDef: {{
flex: 1,
minWidth: 100,
sortable: {8},
filter: {9},
floatingFilter: true,
resizable: true,
cellClass: 'ag-cell-font-size'
}},
onGridReady: function(params) {{
console.log('Grid Ready event fired for grid_{1}');
params.api.sizeColumnsToFit();
const event = new CustomEvent('gridReady');
gridDiv.dispatchEvent(event);
}},
enableRangeSelection: true,
enableCharts: true,
popupParent: document.body,
// Add styling options
headerClass: "ag-header-cell",
rowClass: "ag-row-font-size",
sideBar: {{
toolPanels: ['columns', 'filters'],
defaultToolPanel: '',
hiddenByDefault: {10}
}}
}};
// Initialize AG Grid
const gridDiv = document.querySelector('#grid_{1}');
console.log('Grid container:', gridDiv);
console.log('AG Grid loaded:', typeof agGrid !== 'undefined');
if (!gridDiv) {{
console.error('Grid container not found for grid_{1}');
return;
}}
try {{
new agGrid.Grid(gridDiv, gridOptions);
gridDiv.gridOptions = gridOptions;
}} catch (error) {{
console.error('Error initializing AG Grid:', error);
}}
}})();
</script>
</div>"#,
title, i, table_opts.theme, layout.table_height, columns, serde_json::to_string(&rows).unwrap_or_default(), table_opts.pagination, table_opts.page_size, table_opts.enable_sorting, table_opts.enable_filtering, !table_opts.enable_column_menu );
containers.push(container);
}
containers.join("\n")
} else {
String::new()
};
let html_content = format!(
r#"<!DOCTYPE html>
<html>
<head>
<title>{0}</title>
{1}
{2}
<style>
body {{
font-family: Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}}
.container {{
max-width: {3}px;
margin: 0 auto;
background-color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}}
.plot-container.has-selection {{
border: 2px solid #007bff;
box-shadow: 0 0 10px rgba(0,123,255,0.3);
}}
.category-selected {{
opacity: 1 !important;
}}
.category-dimmed {{
opacity: 0.3 !important;
}}
h1 {{
color: #333;
text-align: center;
margin-bottom: 30px;
}}
.controls {{
margin-bottom: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
display: flex;
gap: 10px;
justify-content: center;
}}
.controls button {{
padding: 8px 16px;
border: none;
border-radius: 4px;
background: #007bff;
color: white;
cursor: pointer;
transition: background 0.2s;
}}
.controls button:hover {{
background: #0056b3;
}}
.controls button {{
padding: 8px 16px;
border: none;
border-radius: 4px;
background: #007bff;
color: white;
cursor: pointer;
transition: background 0.2s;
}}
.controls button:hover {{
background: #0056b3;
}}
.controls button.export-button {{
background: #28a745;
}}
.controls button.export-button:hover {{
background: #218838;
}}
.grid {{
display: grid;
grid-template-columns: repeat({4}, 1fr);
gap: {5}px;
}}
.plot-container, .table-container {{
background: white;
padding: 15px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}}
.plot-title, .table-title {{
font-size: 18px;
font-weight: bold;
margin-bottom: 10px;
color: #444;
}}
@media (max-width: 768px) {{
.grid {{
grid-template-columns: 1fr;
}}
}}
.loading {{
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(255,255,255,0.9);
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
z-index: 9999;
}}
.ag-cell-font-size {{
font-size: 17px;
}}
.ag-cell-bold {{
font-weight: bold;
}}
.ag-header-cell {{
font-weight: bold;
border-bottom: 1px solid #3fdb59;
}}
.align-right {{
text-align: left;
}}
.ag-theme-alpine {{
--ag-font-size: 17px;
--ag-header-height: 40px;
}}
/* NEW: Print-specific styles for PDF to prevent splits */
@media print {{
@page {{
size: A4 portrait; /* Or 'letter' if preferred; matches your 8.5x11 */
margin: 1cm; /* Override code margins for consistency */
}}
body {{
margin: 0;
}}
.container {{
box-shadow: none;
border-radius: 0;
}}
.controls, .loading {{
display: none; /* Hide non-essential elements in PDF */
}}
.plot-container, .table-container {{
page-break-inside: avoid;
break-inside: avoid;
break-before: auto;
break-after: auto;
margin-bottom: 20px; /* Add space between elements */
}}
.grid {{
display: block; /* Switch to block for linear PDF flow */
}}
}}
</style>
</head>
<body>
<div class="container">
<h1>{0}</h1>
<div class="controls">
{6}
</div>
<div id="loading" class="loading">Processing...</div>
{7}
{8}
</div>
<script>
{9}
</script>
</body>
</html>"#,
report_title, if plots.is_some() { r#"<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>"#
} else { "" },
if tables.is_some() { r#"
<script>
// Check if AG Grid is already loaded
console.log('AG Grid script loading status:', typeof agGrid !== 'undefined');
</script>
<script src="https://cdn.jsdelivr.net/npm/ag-grid-community@31.0.1/dist/ag-grid-community.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ag-grid-community@31.0.1/styles/ag-grid.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ag-grid-community@31.0.1/styles/ag-theme-alpine.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ag-grid-community@31.0.1/styles/ag-theme-quartz.css">
<script>
// Verify AG Grid loaded correctly
document.addEventListener('DOMContentLoaded', function() {
console.log('AG Grid loaded check:', typeof agGrid !== 'undefined');
console.log('XLSX loaded check:', typeof XLSX !== 'undefined');
});
</script>
"#
} else { "" },
layout.max_width, layout.grid_columns, layout.grid_gap, generate_controls(plots.is_some(), tables.is_some()), if !plot_containers.is_empty() { format!(r#"<div class="grid">{}</div>"#, plot_containers)
} else { String::new() },
if !table_containers.is_empty() { format!(r#"<div class="tables">{}</div>"#, table_containers)
} else { String::new() },
generate_javascript(plots.is_some(), tables.is_some(), layout.grid_columns) );
std::fs::write(file_path_str, html_content)?;
println!("✅ Interactive Dashboard created at {}", file_path_str);
Ok(())
}
#[cfg(feature = "dashboard")]
#[derive(Debug, Clone)]
pub struct ReportLayout {
pub grid_columns: usize, pub grid_gap: usize, pub max_width: usize, pub plot_height: usize, pub table_height: usize, }
#[cfg(feature = "dashboard")]
impl Default for ReportLayout {
fn default() -> Self {
Self {
grid_columns: 2,
grid_gap: 20,
max_width: 1200,
plot_height: 400,
table_height: 400,
}
}
}
#[cfg(not(feature = "dashboard"))]
#[derive(Debug, Clone)]
pub struct ReportLayout {
pub grid_columns: usize,
pub grid_gap: usize,
pub max_width: usize,
pub plot_height: usize,
pub table_height: usize,
}
#[cfg(feature = "dashboard")]
#[derive(Debug, Clone)]
pub struct TableOptions {
pub pagination: bool,
pub page_size: usize,
pub enable_sorting: bool,
pub enable_filtering: bool,
pub enable_column_menu: bool,
pub theme: String, }
#[cfg(feature = "dashboard")]
impl Default for TableOptions {
fn default() -> Self {
Self {
pagination: true,
page_size: 10,
enable_sorting: true,
enable_filtering: true,
enable_column_menu: true,
theme: "ag-theme-alpine".to_string(),
}
}
}
#[cfg(not(feature = "dashboard"))]
#[derive(Debug, Clone)]
pub struct TableOptions {
pub pagination: bool,
pub page_size: usize,
pub enable_sorting: bool,
pub enable_filtering: bool,
pub enable_column_menu: bool,
pub theme: String,
}
#[cfg(feature = "dashboard")]
fn generate_controls(has_plots: bool, has_tables: bool) -> String {
let mut controls = Vec::new();
if has_plots {
controls.extend_from_slice(&[
r#"<button onclick="toggleGrid()">Toggle Layout</button>"#,
r#"<button onclick="resetAllFilters()">Reset All Filters</button>"#,
]);
}
if has_tables {
controls.extend_from_slice(&[
r#"<button onclick="exportToExcel()" class="export-button">Export tables to Excel</button>"#
]);
}
controls.join("\n")
}
#[cfg(feature = "dashboard")]
fn generate_javascript(has_plots: bool, has_tables: bool, grid_columns: usize) -> String {
let mut js = String::new();
js.push_str(r#"
document.addEventListener('DOMContentLoaded', function() {
console.log('DOMContentLoaded event fired');
showLoading();
const promises = [];
// Wait for plots if they exist
const plotContainers = document.querySelectorAll('.plot-container');
console.log('Found plot containers:', plotContainers.length);
if (plotContainers.length > 0) {
promises.push(...Array.from(plotContainers).map(container =>
new Promise(resolve => {
const observer = new MutationObserver((mutations, obs) => {
if (container.querySelector('.js-plotly-plot')) {
obs.disconnect();
resolve();
}
});
observer.observe(container, { childList: true, subtree: true });
})
));
}
// Wait for grids if they exist
const gridContainers = document.querySelectorAll('[id^="grid_"]');
console.log('Found grid containers:', gridContainers.length);
if (gridContainers.length > 0) {
promises.push(...Array.from(gridContainers).map(container =>
new Promise(resolve => {
container.addEventListener('gridReady', () => {
console.log('Grid ready event received for:', container.id);
resolve();
}, { once: true });
// Add a timeout to prevent infinite waiting
setTimeout(() => {
console.log('Grid timeout for:', container.id);
resolve();
}, 5000);
})
));
}
// If no async content to wait for, hide loading immediately
if (promises.length === 0) {
console.log('No async content to wait for');
hideLoading();
return;
}
// Wait for all content to load or timeout
Promise.all(promises)
.then(() => {
console.log('All content loaded successfully');
hideLoading();
showNotification('Report loaded successfully', 'info');
})
.catch(error => {
console.error('Error loading report:', error);
hideLoading();
showNotification('Error loading some components', 'error');
});
});
"#);
js.push_str(r#"
// Global filter state
let globalFilters = {
dateRange: { start: null, end: null },
selectedCategories: new Set(),
selectedPoints: new Set()
};
// Initialize cross-filtering
function initializeCrossFiltering() {
console.log('Initializing cross-filtering...');
// Check if ANY plot has a date axis
let hasDateAxis = false;
document.querySelectorAll('.plot-container').forEach(container => {
try {
const layoutData = container.dataset.plotLayout;
console.log('Raw layout data:', layoutData);
if (!layoutData || layoutData === 'undefined' || layoutData === '{}') {
console.warn('No valid layout data for container:', container);
return;
}
const layout = JSON.parse(layoutData);
console.log('Parsed layout:', layout);
if (layout.xaxis && layout.xaxis.type === 'date') {
hasDateAxis = true;
console.log('Found plot with date axis!');
}
} catch (e) {
console.error('Error parsing layout:', e, 'for container:', container);
}
});
console.log('Has date axis:', hasDateAxis);
if (hasDateAxis) {
addDateRangeFilter();
} else {
console.log('No date axis plots found, skipping date filter');
}
// Add category filter UI (always add, will show when categories selected)
addCategoryFilterUI();
// Setup plot selection events
setupPlotSelectionEvents();
// Setup table filtering
setupTableFiltering();
}
// Add date range filter control
function addDateRangeFilter() {
// Count how many time-series plots exist
let timeSeriesCount = 0;
document.querySelectorAll('.plot-container').forEach(container => {
try {
const layoutData = container.dataset.plotLayout;
// Skip if no valid layout data
if (!layoutData || layoutData === 'undefined' || layoutData === '{}') {
return;
}
const layout = JSON.parse(layoutData);
if (layout.xaxis && layout.xaxis.type === 'date') {
timeSeriesCount++;
}
} catch (e) {
console.error('Error parsing layout in addDateRangeFilter:', e);
}
});
if (timeSeriesCount === 0) {
console.log('No time-series plots found, not adding date filter');
return;
}
const filterContainer = document.createElement('div');
filterContainer.className = 'date-range-filter';
filterContainer.innerHTML = `
<div style="padding: 15px; background: #f8f9fa; border-radius: 8px; margin-bottom: 20px;">
<h3 style="margin-top: 0;">Date Range Filter</h3>
<p style="font-size: 14px; color: #666; margin: 5px 0;">
Applies to ${timeSeriesCount} time-series plot(s)
</p>
<div style="display: flex; gap: 10px; align-items: center;">
<input type="date" id="startDate" style="padding: 5px; border-radius: 4px; border: 1px solid #ddd;">
<span>to</span>
<input type="date" id="endDate" style="padding: 5px; border-radius: 4px; border: 1px solid #ddd;">
<button onclick="applyDateFilter()" style="padding: 6px 12px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer;">Apply Filter</button>
<button onclick="clearDateFilter()" style="padding: 6px 12px; background: #6c757d; color: white; border: none; border-radius: 4px; cursor: pointer;">Clear</button>
</div>
</div>
`;
const container = document.querySelector('.container');
const controls = document.querySelector('.controls');
if (container && controls) {
container.insertBefore(filterContainer, controls.nextSibling);
}
}
function applyDateFilter() {
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
if (startDate && endDate) {
globalFilters.dateRange.start = new Date(startDate);
globalFilters.dateRange.end = new Date(endDate);
// Apply to ONLY time-series plots (plots with date axes)
document.querySelectorAll('.plot-container').forEach((container, index) => {
const plotDiv = container.querySelector(`[id^="plot_"]`);
if (plotDiv && plotDiv.data) {
const layout = JSON.parse(container.dataset.plotLayout);
// Only apply filter if this plot has a date axis
if (layout.xaxis && layout.xaxis.type === 'date') {
const filteredData = filterPlotData(plotDiv.data, globalFilters.dateRange);
Plotly.react(plotDiv, filteredData, plotDiv.layout);
}
// Skip non-date plots - they remain unchanged
}
});
// Apply to tables (only if they have date columns)
applyTableDateFilter();
showNotification('Date filter applied to time-series plots', 'info');
}
}
function clearDateFilter() {
globalFilters.dateRange.start = null;
globalFilters.dateRange.end = null;
if (document.getElementById('startDate')) {
document.getElementById('startDate').value = '';
}
if (document.getElementById('endDate')) {
document.getElementById('endDate').value = '';
}
// Reset ONLY plots that have date axes
const containers = document.querySelectorAll('.plot-container[data-plot-layout]');
console.log('Clearing filters for', containers.length, 'containers');
containers.forEach((container, index) => {
try {
const layoutData = container.dataset.plotLayout;
// Skip if no valid layout data
if (!layoutData || layoutData === 'undefined' || layoutData === '{}') {
return;
}
const layout = JSON.parse(layoutData);
// Only reset if this plot has a date axis
if (layout.xaxis && layout.xaxis.type === 'date') {
const plotDiv = container.querySelector(`[id^="plot_"]`);
if (plotDiv) {
const originalData = JSON.parse(container.dataset.plotData);
console.log('Resetting plot:', plotDiv.id);
Plotly.react(plotDiv, originalData, layout);
}
}
} catch (e) {
console.error('Error clearing filter for container:', container, e);
}
});
// Clear table filters
clearTableFilters();
showNotification('Date filters cleared', 'info');
}
function filterPlotData(data, dateRange) {
if (!dateRange.start || !dateRange.end) return data;
return data.map(trace => {
if (trace.x && Array.isArray(trace.x)) {
const filteredIndices = [];
trace.x.forEach((x, i) => {
const date = new Date(x);
if (date >= dateRange.start && date <= dateRange.end) {
filteredIndices.push(i);
}
});
return {
...trace,
x: filteredIndices.map(i => trace.x[i]),
y: filteredIndices.map(i => trace.y[i])
};
}
return trace;
});
}
function addCategoryFilterUI() {
const filterContainer = document.createElement('div');
filterContainer.id = 'category-filter-container';
filterContainer.className = 'category-filter';
filterContainer.style.display = 'none'; // Hidden until categories are selected
filterContainer.innerHTML = `
<div style="padding: 15px; background: #fff3cd; border-radius: 8px; margin-bottom: 20px; border: 1px solid #ffc107;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h3 style="margin: 0 0 5px 0;">Category Filter Active</h3>
<p id="selected-categories-text" style="font-size: 14px; color: #666; margin: 0;">
No categories selected
</p>
</div>
<button onclick="clearCategoryFilter()"
style="padding: 6px 12px; background: #dc3545; color: white; border: none; border-radius: 4px; cursor: pointer;">
Clear Category Filter
</button>
</div>
<div id="selected-categories-list" style="margin-top: 10px; display: flex; flex-wrap: wrap; gap: 8px;">
<!-- Category badges will be added here -->
</div>
</div>
`;
const container = document.querySelector('.container');
const dateFilter = document.querySelector('.date-range-filter');
if (container) {
if (dateFilter) {
// Insert after date filter
dateFilter.parentNode.insertBefore(filterContainer, dateFilter.nextSibling);
} else {
// Insert after controls
const controls = document.querySelector('.controls');
container.insertBefore(filterContainer, controls.nextSibling);
}
}
}
function setupPlotSelectionEvents() {
document.querySelectorAll('.plot-container').forEach((container, index) => {
const plotDiv = container.querySelector(`[id^="plot_"]`);
if (plotDiv) {
// Selection event for scatter/line plots
plotDiv.on('plotly_selected', function(eventData) {
if (!eventData || !eventData.points) return;
// Add visual indicator
container.classList.add('has-selection');
// Store selected points
globalFilters.selectedPoints.clear();
eventData.points.forEach(point => {
globalFilters.selectedPoints.add({
x: point.x,
y: point.y,
curveNumber: point.curveNumber
});
});
// Highlight corresponding points in other plots
highlightConnectedPoints(index);
// Filter tables based on selection
filterTablesBySelection();
showNotification(`Selected ${eventData.points.length} points`, 'info');
});
// Add deselect handler
plotDiv.on('plotly_deselect', function() {
container.classList.remove('has-selection');
globalFilters.selectedPoints.clear();
resetPlotHighlights();
clearTableFilters();
});
// Click event for bar/pie charts
plotDiv.on('plotly_click', function(data) {
if (!data || !data.points || data.points.length === 0) return;
const point = data.points[0];
const category = point.x || point.label;
if (category) {
if (globalFilters.selectedCategories.has(category)) {
globalFilters.selectedCategories.delete(category);
} else {
globalFilters.selectedCategories.add(category);
}
updateCategoryHighlights();
filterTablesByCategories();
}
});
}
});
}
function highlightConnectedPoints(sourceIndex) {
document.querySelectorAll('.plot-container').forEach((container, index) => {
if (index === sourceIndex) return;
const plotDiv = container.querySelector(`[id^="plot_"]`);
if (plotDiv && plotDiv.data) {
// Create highlight effect
const update = {
'marker.color': [],
'marker.size': []
};
plotDiv.data[0].x.forEach((x, i) => {
const isSelected = Array.from(globalFilters.selectedPoints).some(
p => p.x === x && p.y === plotDiv.data[0].y[i]
);
update['marker.color'].push(isSelected ? 'red' : 'blue');
update['marker.size'].push(isSelected ? 12 : 8);
});
Plotly.restyle(plotDiv, update, [0]);
}
});
}
function resetPlotHighlights() {
document.querySelectorAll('.plot-container').forEach((container) => {
const plotDiv = container.querySelector(`[id^="plot_"]`);
if (plotDiv && plotDiv.data) {
const originalData = JSON.parse(container.dataset.plotData);
Plotly.react(plotDiv, originalData, plotDiv.layout);
}
});
}
function updateCategoryHighlights() {
// Update filter UI
updateCategoryFilterUI();
document.querySelectorAll('.plot-container').forEach((container) => {
const plotDiv = container.querySelector(`[id^="plot_"]`);
if (!plotDiv || !plotDiv.data) return;
const layout = JSON.parse(container.dataset.plotLayout);
// Only apply to bar and pie charts
const isBarChart = plotDiv.data.some(trace => trace.type === 'bar');
const isPieChart = plotDiv.data.some(trace => trace.type === 'pie');
if (!isBarChart && !isPieChart) return;
if (globalFilters.selectedCategories.size === 0) {
// No filter - reset to original
const originalData = JSON.parse(container.dataset.plotData);
Plotly.react(plotDiv, originalData, layout);
container.classList.remove('has-selection');
return;
}
container.classList.add('has-selection');
// Highlight selected categories
if (isBarChart) {
const trace = plotDiv.data[0];
const colors = trace.x.map((category) => {
return globalFilters.selectedCategories.has(category)
? 'rgb(55, 128, 191)' // Selected - blue
: 'rgba(55, 128, 191, 0.3)'; // Not selected - faded
});
Plotly.restyle(plotDiv, {'marker.color': [colors]}, [0]);
} else if (isPieChart) {
const trace = plotDiv.data[0];
const colors = trace.labels.map((label) => {
return globalFilters.selectedCategories.has(label)
? undefined // Use default color
: 'rgba(200, 200, 200, 0.5)'; // Faded gray
});
Plotly.restyle(plotDiv, {'marker.colors': [colors]}, [0]);
}
});
if (globalFilters.selectedCategories.size > 0) {
showNotification(`Filtered by ${globalFilters.selectedCategories.size} categor${globalFilters.selectedCategories.size === 1 ? 'y' : 'ies'}`, 'info');
}
}
function updateCategoryFilterUI() {
const filterContainer = document.getElementById('category-filter-container');
if (!filterContainer) return;
const categoriesText = document.getElementById('selected-categories-text');
const categoriesList = document.getElementById('selected-categories-list');
if (globalFilters.selectedCategories.size === 0) {
filterContainer.style.display = 'none';
} else {
filterContainer.style.display = 'block';
// Update text
const count = globalFilters.selectedCategories.size;
categoriesText.textContent = `${count} categor${count === 1 ? 'y' : 'ies'} selected`;
// Update badges
categoriesList.innerHTML = '';
globalFilters.selectedCategories.forEach(category => {
const badge = document.createElement('span');
badge.style.cssText = `
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 8px;
background: #007bff;
color: white;
border-radius: 12px;
font-size: 13px;
`;
badge.innerHTML = `
${category}
<button onclick="removeCategoryFilter('${category.replace(/'/g, "\\'")}')"
style="background: none; border: none; color: white; cursor: pointer; font-size: 16px; padding: 0; line-height: 1;">
\u00D7
</button>
`;
categoriesList.appendChild(badge);
});
}
}
function removeCategoryFilter(category) {
globalFilters.selectedCategories.delete(category);
updateCategoryHighlights();
filterTablesByCategories();
}
function filterTablesBySelection() {
// Placeholder for table filtering by selection
console.log('Tables filtered by selection');
}
function filterTablesByCategories() {
if (globalFilters.selectedCategories.size === 0) {
clearTableFilters();
return;
}
document.querySelectorAll('[id^="grid_"]').forEach(gridElement => {
if (!gridElement.gridOptions || !gridElement.gridOptions.api) return;
const api = gridElement.gridOptions.api;
// Detect categorical columns by analyzing the data
const categoryColumns = detectCategoricalColumns(api);
if (categoryColumns.length === 0) return;
// Apply filter to categorical columns
api.setFilterModel(
categoryColumns.reduce((filters, colField) => {
filters[colField] = {
filterType: 'set',
values: Array.from(globalFilters.selectedCategories)
};
return filters;
}, {})
);
api.onFilterChanged();
});
}
function detectCategoricalColumns(gridApi) {
const categoricalColumns = [];
const columnDefs = gridApi.getColumnDefs();
// Get a sample of data to analyze
const sampleSize = Math.min(100, gridApi.getDisplayedRowCount());
const sampleData = [];
for (let i = 0; i < sampleSize; i++) {
const rowNode = gridApi.getDisplayedRowAtIndex(i);
if (rowNode) {
sampleData.push(rowNode.data);
}
}
if (sampleData.length === 0) return categoricalColumns;
// Analyze each column
columnDefs.forEach(colDef => {
const field = colDef.field;
if (!field) return;
// Collect values for this column
const values = sampleData.map(row => row[field]).filter(v => v != null);
if (values.length === 0) return;
// Check if it's categorical
const isCategorical = isColumnCategorical(values);
if (isCategorical) {
categoricalColumns.push(field);
console.log(`Detected categorical column: ${field}`);
}
});
return categoricalColumns;
}
function isColumnCategorical(values) {
// Rule 1: If all values are strings, likely categorical
const allStrings = values.every(v => typeof v === 'string');
if (!allStrings) return false;
// Rule 2: Check uniqueness ratio
const uniqueValues = new Set(values);
const uniqueRatio = uniqueValues.size / values.length;
// If less than 50% unique values, it's likely categorical
// (e.g., 10 unique customer names out of 100 rows)
if (uniqueRatio < 0.5) return true;
// Rule 3: If there are relatively few unique values (< 50), consider it categorical
if (uniqueValues.size < 50) return true;
// Rule 4: Check if values look like categories (short strings, repeated patterns)
const avgLength = values.reduce((sum, v) => sum + v.length, 0) / values.length;
if (avgLength < 30 && uniqueValues.size < values.length * 0.8) return true;
return false;
}
function setupTableFiltering() {
// Add filter input for each table
document.querySelectorAll('.table-container').forEach((container) => {
const filterDiv = document.createElement('div');
filterDiv.innerHTML = `
<input type="text"
placeholder="Type to filter table..."
class="table-filter-input"
style="width: 100%; padding: 8px; margin: 10px 0; border: 1px solid #ddd; border-radius: 4px;">
`;
container.insertBefore(filterDiv, container.querySelector('[id^="grid_"]'));
filterDiv.querySelector('input').addEventListener('input', function(e) {
const gridElement = container.querySelector('[id^="grid_"]');
if (gridElement && gridElement.gridOptions) {
gridElement.gridOptions.api.setQuickFilter(e.target.value);
}
});
});
}
function applyTableDateFilter() {
document.querySelectorAll('[id^="grid_"]').forEach(gridElement => {
if (gridElement.gridOptions && globalFilters.dateRange.start) {
const api = gridElement.gridOptions.api;
api.setFilterModel({
// Apply date filter to date columns
date: {
type: 'inRange',
dateFrom: globalFilters.dateRange.start.toISOString().split('T')[0],
dateTo: globalFilters.dateRange.end.toISOString().split('T')[0]
}
});
api.onFilterChanged();
}
});
}
function clearTableFilters() {
document.querySelectorAll('[id^="grid_"]').forEach(gridElement => {
if (gridElement.gridOptions) {
gridElement.gridOptions.api.setFilterModel(null);
}
});
}
// Initialize everything when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
setTimeout(() => {
initializeCrossFiltering();
}, 1000);
});
// Helper functions
function showNotification(message, type = 'info') {
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.textContent = message;
notification.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
padding: 10px 20px;
border-radius: 4px;
color: white;
background: ${type === 'error' ? '#dc3545' : '#007bff'};
z-index: 1000;
animation: slideIn 0.5s ease-out;
`;
document.body.appendChild(notification);
setTimeout(() => notification.remove(), 3000);
}
function showLoading() {
console.log('showLoading called');
const loading = document.getElementById('loading');
if (loading) {
loading.style.display = 'block';
console.log('Loading indicator shown');
} else {
console.error('Loading element not found');
}
}
function hideLoading() {
console.log('hideLoading called');
const loading = document.getElementById('loading');
if (loading) {
loading.style.display = 'none';
console.log('Loading indicator hidden');
} else {
console.error('Loading element not found');
}
}
"#);
if has_plots {
js.push_str(&format!(r#"
const plots = [];
let currentGridColumns = {};
document.querySelectorAll('.plot-container').forEach((container, index) => {{
const plotDiv = container.querySelector(`#plot_${{index}}`);
const data = JSON.parse(container.dataset.plotData);
const layout = JSON.parse(container.dataset.plotLayout);
layout.autosize = true;
Plotly.newPlot(plotDiv, data, layout, {{
responsive: true,
scrollZoom: true,
modeBarButtonsToAdd: [
'hoverClosestCartesian',
'hoverCompareCartesian'
],
displaylogo: false
}}).then(gd => {{
plots.push(gd);
gd.on('plotly_click', function(data) {{
highlightPoint(data, index);
}});
}}).catch(error => {{
console.error('Error creating plot:', error);
showNotification('Error creating plot', 'error');
}});
}});
function toggleGrid() {{
const grid = document.querySelector('.grid');
currentGridColumns = currentGridColumns === {0} ? 1 : {0};
grid.style.gridTemplateColumns = `repeat(${{currentGridColumns}}, 1fr)`;
showNotification(`Layout changed to ${{currentGridColumns}} column(s)`);
}}
function highlightPoint(data, plotIndex) {{
if (!data.points || !data.points[0]) return;
const point = data.points[0];
const pointColor = 'red';
plots.forEach((plot, idx) => {{
if (idx !== plotIndex) {{
const trace = plot.data[0];
if (trace.x && trace.y) {{
const matchingPoints = trace.x.map((x, i) => {{
return {{x, y: trace.y[i]}};
}}).filter(p => p.x === point.x && p.y === point.y);
if (matchingPoints.length > 0) {{
Plotly.restyle(plot, {{'marker.color': pointColor}}, [0]);
}}
}}
}}
}});
}}
"#, grid_columns));
}
if has_tables {
js.push_str(r#"
// Table utility functions
function exportAllTables() {
try {
const exportedTables = new Set(); // Track exported tables to avoid duplicates
document.querySelectorAll('[id^="grid_"]').forEach((container) => {
// Skip if already exported
if (exportedTables.has(container.id)) {
return;
}
exportedTables.add(container.id);
if (!container.gridOptions || !container.gridOptions.api) {
console.warn('No grid API found for:', container.id);
return;
}
const gridApi = container.gridOptions.api;
const csvContent = gridApi.getDataAsCsv({
skipHeader: false,
skipFooters: true,
skipGroups: true,
suppressQuotes: false,
columnSeparator: ','
});
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `${container.id}.csv`;
link.click();
// Cleanup
setTimeout(() => {
URL.revokeObjectURL(link.href);
}, 100);
});
showNotification(`Exported ${exportedTables.size} table(s)`, 'info');
} catch (error) {
console.error('Error exporting tables:', error);
showNotification('Error exporting tables', 'error');
}
}
function exportToExcel() {
try {
const exportedTables = new Set();
document.querySelectorAll('[id^="grid_"]').forEach((container) => {
if (exportedTables.has(container.id)) {
return;
}
exportedTables.add(container.id);
if (!container.gridOptions || !container.gridOptions.api) {
console.warn('No grid API found for:', container.id);
return;
}
// Get the table title from data attribute
const tableTitle = container.dataset.tableTitle || container.id;
// Sanitize filename (remove invalid characters)
const filename = tableTitle.replace(/[^a-z0-9_\-\s]/gi, '_').replace(/\s+/g, '_');
const gridApi = container.gridOptions.api;
const columnApi = container.gridOptions.columnApi;
const columns = columnApi.getAllDisplayedColumns();
const columnDefs = columns.map(col => ({
header: col.colDef.headerName || col.colDef.field,
field: col.colDef.field
}));
const rowData = [];
gridApi.forEachNode(node => {
const row = {};
columnDefs.forEach(col => {
let value = node.data[col.field];
// Handle numbers - ensure proper parsing
if (typeof value === 'number') {
row[col.header] = value;
}
else if (typeof value === 'string') {
// Remove all thousand separators (both comma and period)
// Then convert last comma or period to decimal point
const cleanValue = value.trim();
// Check if it looks like a number with formatting
if (cleanValue.match(/^[\d.,]+$/)) {
// Count commas and periods
const commaCount = (cleanValue.match(/,/g) || []).length;
const periodCount = (cleanValue.match(/\./g) || []).length;
let parsed;
// Determine format and parse accordingly
if (periodCount > 1 || (periodCount === 1 && commaCount === 0 && cleanValue.indexOf('.') < cleanValue.length - 3)) {
// European format: 1.234.567,89 or 1.234
parsed = parseFloat(cleanValue.replace(/\./g, '').replace(',', '.'));
} else if (commaCount > 1 || (commaCount === 1 && periodCount === 0 && cleanValue.indexOf(',') < cleanValue.length - 3)) {
// US format with commas: 1,234,567.89 or 1,234
parsed = parseFloat(cleanValue.replace(/,/g, ''));
} else if (periodCount === 1 && commaCount === 0) {
// Simple decimal with period: 123.45
parsed = parseFloat(cleanValue);
} else if (commaCount === 1 && periodCount === 0) {
// Could be European decimal: 123,45
parsed = parseFloat(cleanValue.replace(',', '.'));
} else {
// Just try parsing as-is
parsed = parseFloat(cleanValue.replace(/,/g, ''));
}
if (!isNaN(parsed)) {
row[col.header] = parsed;
} else {
row[col.header] = value;
}
} else {
row[col.header] = value;
}
}
else {
row[col.header] = value;
}
});
rowData.push(row);
});
const worksheet = XLSX.utils.json_to_sheet(rowData);
// Apply number formatting to all numeric cells
const range = XLSX.utils.decode_range(worksheet['!ref']);
for (let C = range.s.c; C <= range.e.c; ++C) {
const headerCell = worksheet[XLSX.utils.encode_col(C) + '1'];
const headerText = headerCell ? headerCell.v : '';
for (let R = range.s.r + 1; R <= range.e.r; ++R) {
const cellAddress = XLSX.utils.encode_col(C) + (R + 1);
if (!worksheet[cellAddress]) continue;
const cell = worksheet[cellAddress];
if (typeof cell.v === 'number') {
cell.t = 'n';
// Check if this is a date column
if (headerText && (headerText.toLowerCase().includes('date') || headerText.toLowerCase().includes('time'))) {
cell.z = 'yyyy-mm-dd';
} else {
// US number format: comma for thousands, period for decimal
cell.z = '#,##0.00';
}
}
}
}
// Set column widths for better readability
const colWidths = columns.map(() => ({ wch: 20 }));
worksheet['!cols'] = colWidths;
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Data');
const excelBuffer = XLSX.write(workbook, {
bookType: 'xlsx',
type: 'array',
cellStyles: true
});
const blob = new Blob([excelBuffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${filename}.xlsx`;
link.click();
setTimeout(() => {
window.URL.revokeObjectURL(url);
}, 100);
});
showNotification(`Exported ${exportedTables.size} table(s) to Excel`, 'info');
} catch (error) {
console.error('Error exporting to Excel:', error);
showNotification('Error exporting to Excel', 'error');
}
}
// Initialize AG Grid Quick Filter after DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.ag-theme-alpine').forEach(container => {
const gridOptions = container.gridOptions;
if (gridOptions) {
console.log('Initializing quick filter for container:', container.id);
const quickFilterInput = document.createElement('input');
quickFilterInput.type = 'text';
quickFilterInput.placeholder = 'Quick Filter...';
quickFilterInput.className = 'quick-filter';
quickFilterInput.style.cssText = 'margin: 10px 0; padding: 5px; width: 200px;';
quickFilterInput.addEventListener('input', function(e) {
gridOptions.api.setQuickFilter(e.target.value);
});
container.parentNode.insertBefore(quickFilterInput, container);
}
});
});
"#);
}
js.push_str(r#"
const style = document.createElement('style');
style.textContent = `
.notification {
position: fixed;
bottom: 20px;
right: 20px;
padding: 10px 20px;
border-radius: 4px;
color: white;
font-weight: bold;
z-index: 1000;
animation: slideIn 0.5s ease-out;
}
.notification.info {
background-color: #007bff;
}
.notification.error {
background-color: #dc3545;
}
@keyframes slideIn {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
`;
document.head.appendChild(style);
"#);
js.push_str(r#"
function resetAllFilters() {
// Clear date filter
if (document.getElementById('startDate')) {
clearDateFilter();
}
// Clear selected categories
globalFilters.selectedCategories.clear();
globalFilters.selectedPoints.clear();
// Reset all plots to original styling
document.querySelectorAll('.plot-container').forEach((container) => {
container.classList.remove('has-selection');
const plotDiv = container.querySelector(`[id^="plot_"]`);
if (plotDiv && plotDiv.data) {
const originalData = JSON.parse(container.dataset.plotData);
const originalLayout = JSON.parse(container.dataset.plotLayout);
Plotly.react(plotDiv, originalData, originalLayout);
}
});
// Clear table filters
clearTableFilters();
showNotification('All filters reset', 'info');
}
"#);
js.push_str(r#"
function clearCategoryFilter() {
globalFilters.selectedCategories.clear();
updateCategoryHighlights();
clearTableFilters();
const filterContainer = document.getElementById('category-filter-container');
if (filterContainer) {
filterContainer.style.display = 'none';
}
showNotification('Category filter cleared', 'info');
}
"#);
js.push_str(r#"
// Force hide loading immediately and after everything loads
(function() {
const loading = document.getElementById('loading');
if (loading) {
loading.style.display = 'none';
}
})();
// Also hide on window load as backup
window.addEventListener('load', function() {
const loading = document.getElementById('loading');
if (loading) {
loading.style.display = 'none';
}
});
"#);
js
}
#[cfg(feature = "dashboard")]
pub async fn export_plot_to_png_impl(
plot: &PlotlyPlot,
filename: &str,
width: u32,
height: u32,
) -> ElusionResult<()> {
let html_content = format!(
r#"<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
<style>
body {{ margin: 0; padding: 0; }}
#plot {{ width: {}px; height: {}px; }}
</style>
</head>
<body>
<div id="plot"></div>
<script>
const data = {};
const layout = {};
layout.width = {};
layout.height = {};
Plotly.newPlot('plot', data, layout, {{
staticPlot: true,
displayModeBar: false
}}).then(function(gd) {{
return Plotly.toImage(gd, {{
format: 'png',
width: {},
height: {}
}});
}}).then(function(dataUrl) {{
// Store the image data for retrieval
window.plotlyImageData = dataUrl;
}});
</script>
</body>
</html>"#,
width, height,
serde_json::to_string(plot.data()).unwrap(),
serde_json::to_string(plot.layout()).unwrap(),
width, height,
width, height
);
let browser = Browser::new(LaunchOptions {
headless: true,
window_size: Some((width, height)),
..Default::default()
}).map_err(|e| ElusionError::Custom(format!("Failed to launch browser: {}", e)))?;
let tab = browser.new_tab()
.map_err(|e| ElusionError::Custom(format!("Failed to create tab: {}", e)))?;
let temp_html = format!("{}.html", Uuid::new_v4());
std::fs::write(&temp_html, html_content)?;
let absolute_path = std::fs::canonicalize(&temp_html)
.map_err(|e| ElusionError::Custom(format!("Failed to canonicalize path: {}", e)))?;
let file_url = {
let mut path_str = absolute_path.to_str()
.ok_or_else(|| ElusionError::Custom("Invalid UTF-8 in path".to_string()))?
.to_string();
#[cfg(target_os = "windows")]
{
if path_str.starts_with(r"\\?\") {
path_str = path_str[4..].to_string();
}
path_str = path_str.replace('\\', "/");
format!("file:///{}", path_str)
}
#[cfg(target_os = "macos")]
{
format!("file://{}", path_str)
}
#[cfg(target_os = "linux")]
{
format!("file://{}", path_str)
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
{
format!("file://{}", path_str)
}
};
println!("📄 Navigating to: {}", file_url);
tab.navigate_to(&file_url)
.map_err(|e| ElusionError::Custom(format!("Failed to navigate to {}: {}", file_url, e)))?;
tab.wait_until_navigated()
.map_err(|e| ElusionError::Custom(format!("Navigation timeout: {}", e)))?;
std::thread::sleep(std::time::Duration::from_secs(2));
let result = tab.evaluate(
"window.plotlyImageData",
false
).map_err(|e| ElusionError::Custom(format!("Failed to get image data: {}", e)))?;
if let Some(data_url) = result.value {
if let Some(url_str) = data_url.as_str() {
let base64_data = url_str.replace("data:image/png;base64,", "");
let image_data = STANDARD.decode(base64_data)
.map_err(|e| ElusionError::Custom(format!("Failed to decode image: {}", e)))?;
std::fs::write(filename, image_data)?;
println!("✅ Plot exported to PNG: {}", filename);
}
}
if let Err(e) = std::fs::remove_file(temp_html) {
eprintln!("⚠️ Warning: Failed to remove temp HTML file: {}", e);
}
Ok(())
}
#[cfg(feature = "dashboard")]
pub async fn export_report_to_pdf_impl(
plots: Option<&[(&PlotlyPlot, &str)]>,
tables: Option<&[(&CustomDataFrame, &str)]>,
report_title: &str,
pdf_filename: &str,
layout_config: Option<ReportLayout>,
table_options: Option<TableOptions>,
) -> ElusionResult<()> {
use headless_chrome::types::PrintToPdfOptions;
let pdf_path = LocalPath::new(pdf_filename);
let temp_html_path = if let Some(parent) = pdf_path.parent() {
parent.join(format!("temp_{}.html", Uuid::new_v4()))
} else {
LocalPath::new(&format!("temp_{}.html", Uuid::new_v4())).to_path_buf()
};
let temp_html = temp_html_path.to_str()
.ok_or_else(|| ElusionError::Custom("Invalid temp HTML path".to_string()))?;
create_report_impl(
plots,
tables,
report_title,
temp_html,
layout_config,
table_options
).await?;
let browser = Browser::new(LaunchOptions {
headless: true,
window_size: Some((1920, 1080)),
..Default::default()
}).map_err(|e| ElusionError::Custom(format!("Failed to launch browser: {}", e)))?;
let tab = browser.new_tab()
.map_err(|e| ElusionError::Custom(format!("Failed to create tab: {}", e)))?;
let absolute_path = std::fs::canonicalize(temp_html)
.map_err(|e| ElusionError::Custom(format!("Failed to canonicalize path: {}", e)))?;
let file_url = {
let mut path_str = absolute_path.to_str()
.ok_or_else(|| ElusionError::Custom("Invalid UTF-8 in path".to_string()))?
.to_string();
#[cfg(target_os = "windows")]
{
if path_str.starts_with(r"\\?\") {
path_str = path_str[4..].to_string();
}
path_str = path_str.replace('\\', "/");
format!("file:///{}", path_str)
}
#[cfg(target_os = "macos")]
{
format!("file://{}", path_str)
}
#[cfg(target_os = "linux")]
{
format!("file://{}", path_str)
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
{
format!("file://{}", path_str)
}
};
println!("📄 Navigating to: {}", file_url);
tab.navigate_to(&file_url)
.map_err(|e| ElusionError::Custom(format!("Failed to navigate to {}: {}", file_url, e)))?;
tab.wait_until_navigated()
.map_err(|e| ElusionError::Custom(format!("Navigation timeout: {}", e)))?;
std::thread::sleep(std::time::Duration::from_secs(7));
tab.wait_for_element("body")
.map_err(|e| ElusionError::Custom(format!("Failed to find body element: {}", e)))?;
let pdf_options = PrintToPdfOptions {
landscape: Some(false),
display_header_footer: Some(true),
print_background: Some(true),
scale: Some(1.0),
paper_width: Some(8.5),
paper_height: Some(11.0),
margin_top: Some(0.4),
margin_bottom: Some(0.4),
margin_left: Some(0.4),
margin_right: Some(0.4),
page_ranges: None,
ignore_invalid_page_ranges: None,
header_template: Some(String::new()),
footer_template: Some(String::new()),
prefer_css_page_size: Some(true),
transfer_mode: None,
generate_document_outline: Some(false),
generate_tagged_pdf: Some(false),
};
let pdf_data = tab.print_to_pdf(Some(pdf_options))
.map_err(|e| ElusionError::Custom(format!("Failed to generate PDF: {}", e)))?;
if let Some(parent) = LocalPath::new(pdf_filename).parent() {
if !parent.exists() {
fs::create_dir_all(parent)?;
}
}
std::fs::write(pdf_filename, pdf_data)?;
println!("✅ Report exported to PDF: {}", pdf_filename);
if let Err(e) = std::fs::remove_file(temp_html) {
eprintln!("⚠️ Warning: Failed to remove temp HTML file: {}", e);
}
Ok(())
}