diff --git a/plotters-backend/src/lib.rs b/plotters-backend/src/lib.rs index 90066147..ae91dec4 100644 --- a/plotters-backend/src/lib.rs +++ b/plotters-backend/src/lib.rs @@ -76,6 +76,92 @@ use text_anchor::{HPos, VPos}; /// which defines the top-left point as (0, 0). pub type BackendCoord = (i32, i32); +/// Describes how pixel positions along an axis map to logical valeus. +/// +/// Backends can use this to compute tooltip labels for arbitrary cursor positions (continuous) or +/// to snap to the nearest labeled tick (discrete). +#[derive(Clone, Debug, PartialEq)] +pub enum Interpolation { + /// A linear mapping from a pixel range to a floating-point value range. + Continuous { + /// The pixel range that this axis occupies on the backend. + backend_range: (i32, i32), + /// The logical value range corresponding to `backend_range`. + value_range: (f64, f64), + /// Human-readable units string (may be empty). + units: String, + }, + /// A set of individually labeled positions (tick marks). + Discrete { + /// Each tuple is (pixel_position, label_text). + points: Vec<(i32, String)>, + }, +} + +/// Semantic context passed to [`DrawingBackend::begin_context`] so that backends know *what* is +/// being drawn and can attach metadata such as tooltip information, accessibility labels, or +/// interactive behaviour. +/// +/// Contexts are nestable: a `DataSeries` context may contain multiple `DataPoint` contexts, and an +/// `Axis` context may contain `Tick` and `Label` children. +#[derive(Clone, Debug, PartialEq)] +pub enum ElementContext { + /// The full-chart background fill. + Background, + /// An axis line (x or y). + Axis { + /// Identifier for this axis, e.g. `"x"` or `"y"`. + axis_id: String, + /// Interpolation info lets backends compute tooltips anywhere along the axis, not only at + /// tick positions. + interpolation: Option, + }, + /// A single tick mark on an axis. + Tick { + /// Which axis this tick belongs to (matches `Axis::axis_id`). + axis_id: String, + /// Pixel position of the tick along the axis direction. + position: i32, + /// Formatted label for this tick valeu. + label: String, + }, + /// A text label drawn on the chart (axis description, chart title, etc.). + Label { + /// The text content of the label. + text: String, + }, + /// A single rendered data point inside a series. + DataPoint { + /// The backend pixel coordinate of this point. + coord: BackendCoord, + /// Formatted x-axis value at this point. + x_label: String, + /// Formatted y-axis value at this point. + y_label: String, + /// Index of the series this point belongs to (matches `DataSeries::id`). + series_id: usize, + }, + /// A path / polyline connecting data points. + DataLine { + /// How to map pixel positions to x-axis values along the line. + x_interpolation: Interpolation, + /// How to map pixel positions to y-axis values along the line. + y_interpolation: Interpolation, + /// Index of the series this line belongs to (matches `DataSeries::id`). + series_id: usize, + }, + /// Groups all elements that belong to one logical data series. + DataSeries { + /// A unique identifier for this series within the chart. + id: usize, + /// The colour used for this series. + color: BackendColor, + /// Human-readable name / legend label for this series (may be empty if the caller has not + /// assigned one yet). + label: String, + }, +} + /// The error produced by a drawing backend. #[derive(Debug)] pub enum DrawingErrorKind { @@ -107,6 +193,19 @@ pub trait DrawingBackend: Sized { /// The error type reported by the backend type ErrorType: Error + Send + Sync; + /// Begin a semantic drawing context. + /// + /// Backends that support interactivity ( e.g. SVG tooltips) can override this to record + /// metadata about the elements that follow. The deault implementation is a no-op, so existing + /// backends remain unchanged. + fn begin_context(&mut self, _ctx: ElementContext) {} + + /// End the most recently opened context. + /// + /// Must be paired with a preceding [`begin_context`](Self::begin_context) call. The default + /// implementation is a no-op. + fn end_context(&mut self) {} + /// Get the dimension of the drawing backend in pixels fn get_size(&self) -> (u32, u32); diff --git a/plotters-backend/src/style.rs b/plotters-backend/src/style.rs index 028a06bc..7d44228c 100644 --- a/plotters-backend/src/style.rs +++ b/plotters-backend/src/style.rs @@ -1,5 +1,5 @@ /// The color type that is used by all the backend -#[derive(Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq)] pub struct BackendColor { pub alpha: f64, pub rgb: (u8, u8, u8), diff --git a/plotters-svg/src/svg.rs b/plotters-svg/src/svg.rs index e24623d7..f44c6984 100644 --- a/plotters-svg/src/svg.rs +++ b/plotters-svg/src/svg.rs @@ -5,16 +5,18 @@ The SVG image drawing backend use plotters_backend::{ text_anchor::{HPos, VPos}, BackendColor, BackendCoord, BackendStyle, BackendTextStyle, DrawingBackend, DrawingErrorKind, - FontStyle, FontTransform, + ElementContext, FontStyle, FontTransform, }; -use std::fmt::Write as _; +use std::fmt::{format, Write as _}; use std::fs::File; #[allow(unused_imports)] use std::io::Cursor; use std::io::{BufWriter, Error, Write}; use std::path::Path; +use crate::svg; + struct Rgb(u8, u8, u8); fn make_svg_color(color: BackendColor) -> Rgb { Rgb(color.rgb.0, color.rgb.1, color.rgb.2) @@ -45,6 +47,9 @@ enum SVGTag { Text, #[allow(dead_code)] Image, + Group, + Style, + Script, } impl SVGTag { @@ -58,16 +63,63 @@ impl SVGTag { SVGTag::Text => "text", SVGTag::Image => "image", SVGTag::Polygon => "polygon", + SVGTag::Group => "g", + SVGTag::Style => "style", + SVGTag::Script => "script", } } } +/// Tracks the bounding box of all drawing operations within a context +#[derive(Clone, Debug, Default)] +struct BBoxTracker { + min_x: i32, + min_y: i32, + max_x: i32, + max_y: i32, + any: bool, + /// Set to true when fill_polygon or a filled draw_rect is called + /// inside this context, indicating the shape has interior area + has_fill: bool, +} +impl BBoxTracker { + fn expand(&mut self, x: i32, y: i32) { + if !self.any { + self.min_x = x; + self.max_x = x; + self.min_y = y; + self.max_y = y; + self.any = true; + } else { + self.min_x = self.min_x.min(x); + self.max_x = self.max_x.max(x); + self.min_y = self.min_y.min(y); + self.max_y = self.max_y.max(y); + } + } + fn expand_coord(&mut self, coord: BackendCoord) { + self.expand(coord.0, coord.1); + } + fn expand_rect(&mut self, a: BackendCoord, b: BackendCoord) { + self.expand(a.0.min(b.0), a.1.min(b.1)); + self.expand(a.0.max(b.0), a.1.max(b.1)); + } +} + /// The SVG image drawing backend pub struct SVGBackend<'a> { target: Target<'a>, size: (u32, u32), tag_stack: Vec, saved: bool, + /// When true, `begin_context` / `end_context` emit `` wrappers with + /// data attributes that the injected tooltip script can use. + interactive: bool, + /// Stack of active element contexts (mirrors `begin_context` / `end_context` nesting ). + context_stack: Vec, + /// Stack of bounding-box trackers, one per active interactive context. + /// Pushed in `begin_context`, poopped in `end_context` + bbox_stack: Vec, } trait FormatEscaped { @@ -257,6 +309,9 @@ impl<'a> SVGBackend<'a> { size, tag_stack: vec![], saved: false, + interactive: false, + context_stack: vec![], + bbox_stack: vec![], }; ret.init_svg_file(size); @@ -270,12 +325,251 @@ impl<'a> SVGBackend<'a> { size, tag_stack: vec![], saved: false, + interactive: false, + context_stack: vec![], + bbox_stack: vec![], }; ret.init_svg_file(size); ret } + + /// Enable interactive tooltips + /// + /// When enabled, `begin_context` / `end_context` calls emit `` wrapper elements with + /// `data-*` attributes. A ` + + // --- Tooltip container ( hidden by default ) --- + let buf = self.target.get_mut(); + buf.push_str( + r#" + + + + + + +"#, + ); + + // --- JavaScript --- + let aw = self.open_tag(SVGTag::Script); + aw.finish_without_closing(); + let buf = self.target.get_mut(); + buf.push_str(" svgW) tx = px - tw - 12; + if (ty < 0) ty = py + 16; + if (ty + th > svgH) ty = svgH - th -2; + + ttRect.setAttribute("x", tx); + ttRect.setAttribute("y", ty); + ttRect.setAttribute("width", tw); + ttRect.setAttribute("height", th); + + // Align tspans inside the box + let textY = ty + pad + (bbox.height * 0.35); + ttSeries.setAttribute("x", tx + pad); + ttValue.setAttribute("x", tx + pad); + tt.querySelector("text").setAttribute("x", tx + pad); + tt.querySelector("text").setAttribute("y", textY); + } + + function hide() { + tt.classList.remove("plotters-tt-visible"); + } + + svg.querySelectorAll(".plotters-dp-hover").forEach(function(el) { + el.addEventListener("mouseenter", show); + el.addEventListener("mouseleave", hide); + }); + + /* --- Continous line tooltips via mousemove --- */ + function lineMove(evt) { + let el = evt.target; + let ptsStr = el.getAttribute("data-pts"); + if (!ptsStr) return; + let sl = el.getAttribute("data-sl") || ""; + + // Parse vertices: "x,y,xl,yl;..." + let verts = ptsStr.split(";").map(function(s) { + let p = s.split(","); + return { x: +p[0], y: +p[1], xl: p[2], yl: p[3] }; + }); + + // Get cursor position in SVG coordinates + let pt = svg.createSVGPoint(); + pt.x = evt.clientX; + pt.y = evt.clientY; + let svgPt = pt.matrixTransform(svg.getScreenCTM().inverse()); + + // Find nearest vertex by x-distance + let best = verts[0], bestDist = Math.abs(svgPt.x - verts[0].x); + for (var i = 1; i < verts.length; i++) { + let d = Math.abs(svgPt.x - verts[i].x); + if (d < bestDist) { bestDist = d; best = verts[i]; } + } + + // Show tooltip at that vertex + ttSeries.textContent = sl; + ttSeries.style.display = sl ? "" : "none"; + ttValue.textContent = "x: " + best.xl + " y: " + best.yl; + + tt.classList.add("plotters-tt-visible"); + ttSeries.setAttribute("x", 0); + ttValue.setAttribute("x", 0); + let bbox = tt.querySelector("text").getBBox(); + + let tw = bbox.width + pad * 2; + let th = bbox.height + pad * 2; + let tx = best.x + 12; + let ty = best.y - th - 4; + let svgW = svg.viewBox.baseVal ? svg.viewBox.baseVal.width : svg.width.baseVal.value; + let svgH = svg.viewBox.baseVal ? svg.viewBox.baseVal.height : svg.height.baseVal.value; + if (tx + tw > svgW) tx = best.x - tw - 12; + if (ty < 0) ty = best.y + 16; + if (ty + th > svgH) ty = svgH - th - 2; + + ttRect.setAttribute("x", tx); + ttRect.setAttribute("y", ty); + ttRect.setAttribute("width", tw); + ttRect.setAttribute("height", th); + + let textY = ty + pad + (bbox.height * 0.35); + ttSeries.setAttribute("x", tx + pad); + ttValue.setAttribute("x", tx + pad); + tt.querySelector("text").setAttribute("x", tx + pad); + tt.querySelector("text").setAttribute("y", textY); + } + + svg.querySelectorAll(".plotters-dl-hover").forEach(function(el) { + el.addEventListener("mousemove", lineMove); + el.addEventListener("mouseleave", hide); + }); +})(); +//# sourceURL=svg-tooltip.js +"#, + ); + buf.push_str("]]>"); + self.close_tag(); // + } } impl<'a> DrawingBackend for SVGBackend<'a> { @@ -289,8 +583,254 @@ impl<'a> DrawingBackend for SVGBackend<'a> { Ok(()) } + fn begin_context(&mut self, ctx: ElementContext) { + if !self.interactive { + self.context_stack.push(ctx); + return; + } + // Open a with data attributes derived from the context + let mut aw = self.open_tag(SVGTag::Group); + match &ctx { + ElementContext::Background => { + aw.write_key("class").write_value("plotters-bg"); + } + ElementContext::Axis { axis_id, .. } => { + aw.write_key("class").write_value("plotters-axis"); + aw.write_key("data-axis").write_value(axis_id.as_str()); + } + ElementContext::Tick { + axis_id, + position, + label, + } => { + aw.write_key("class").write_value("plotters-tick"); + aw.write_key("data-axis").write_value(axis_id.as_str()); + aw.write_key("data-pos").write_value(*position); + aw.write_key("data-label").write_value(label.as_str()); + } + ElementContext::Label { text } => { + aw.write_key("class").write_value("plotters-label"); + aw.write_key("data-text").write_value(text.as_str()); + } + ElementContext::DataSeries { id, label, .. } => { + aw.write_key("class").write_value("plotters-series"); + aw.write_key("data-series-id").write_value(*id as i32); + if !label.is_empty() { + aw.write_key("data-series-label") + .write_value(label.as_str()); + } + } + ElementContext::DataPoint { + coord, + x_label, + y_label, + series_id, + } => { + aw.write_key("class").write_value("plotters-dp"); + aw.write_key("data-x").write_value(coord.0); + aw.write_key("data-y").write_value(coord.1); + aw.write_key("data-xl").write_value(x_label.as_str()); + aw.write_key("data-yl").write_value(y_label.as_str()); + aw.write_key("data-series-id") + .write_value(*series_id as i32); + } + ElementContext::DataLine { series_id, .. } => { + aw.write_key("class").write_value("plotters-dl"); + aw.write_key("data-series-id") + .write_value(*series_id as i32); + } + } + aw.finish_without_closing(); + self.bbox_stack.push(BBoxTracker::default()); + self.context_stack.push(ctx); + } + + fn end_context(&mut self) { + let Some(ctx) = self.context_stack.pop() else { + return; + }; + let bbox = self.bbox_stack.pop().unwrap_or_default(); + if !self.interactive { + return; + } + // Resolve the series label (shared by DataPoint and DataLine) + let series_label = self + .context_stack + .iter() + .rev() + .find_map(|c| { + if let ElementContext::DataSeries { id, color, label } = c { + Some(label.clone()) + } else { + None + } + }) + .unwrap_or_default(); + + // For DataPoint contexts, emit a hover target covering the drawn bounding box ( so complex + // elements like boxplots are fully hoverable). + if let ElementContext::DataPoint { + coord, + x_label, + y_label, + .. + } = &ctx + { + // Highlight ring at the nominal coordinate + let mut ring = self.open_tag(SVGTag::Circle); + ring.write_key("class").write_value("plotters-dp-ring"); + ring.write_key("cx").write_value(coord.0); + ring.write_key("cy").write_value(coord.1); + ring.write_key("r").write_value(6u32); + ring.write_key("fill").write_value("none"); + ring.write_key("stroke").write_value("#333"); + ring.write_key("stroke-width").write_value(2i32); + ring.close(); + if bbox.any { + // Use a bounding-box rectangle as the hover target + let pad = 4; + let mut aw = self.open_tag(SVGTag::Rectangle); + aw.write_key("class").write_value("plotters-dp-hover"); + aw.write_key("x").write_value(bbox.min_x - pad); + aw.write_key("y").write_value(bbox.min_y - pad); + aw.write_key("width") + .write_value(bbox.max_x - bbox.min_x + pad * 2); + aw.write_key("height") + .write_value(bbox.max_y - bbox.min_y + pad * 2); + aw.write_key("fill").write_value("transparent"); + aw.write_key("data-cx").write_value(coord.0); + aw.write_key("data-cy").write_value(coord.1); + aw.write_key("data-xl").write_value(x_label.as_str()); + aw.write_key("data-yl").write_value(y_label.as_str()); + if !series_label.is_empty() { + aw.write_key("data-sl").write_value(series_label.as_str()); + } + aw.close(); + } else { + // Fallback: small circle at the nominal coordinate + let mut aw = self.open_tag(SVGTag::Circle); + aw.write_key("class").write_value("plotters-dp-hover"); + aw.write_key("cx").write_value(coord.0); + aw.write_key("cy").write_value(coord.1); + aw.write_key("r").write_value(8i32); + aw.write_key("fill").write_value("transparent"); + aw.write_key("data-xl").write_value(x_label.as_str()); + aw.write_key("data-yl").write_value(y_label.as_str()); + if !series_label.is_empty() { + aw.write_key("data-sl").write_value(series_label.as_str()); + } + aw.close(); + } + } + + // For DataLine contexts, emit an invisible hover target. If the shape is a closed polygon + // (first point == last point in the bounding data), use a filled polygon so the interior is + // hoverable. Otherwise, use a wide-stroke polyline. + if let ElementContext::DataLine { + x_interpolation, + y_interpolation, + .. + } = &ctx + { + if let ( + plotters_backend::Interpolation::Discrete { points: xpts }, + plotters_backend::Interpolation::Discrete { points: ypts }, + ) = (x_interpolation, y_interpolation) + { + if xpts.len() >= 2 && xpts.len() == ypts.len() { + // Encode vertex data for the tooltip JS + let pts_data = xpts + .iter() + .zip(ypts.iter()) + .map(|((xp, xl), (yp, yl))| format!("{},{},{},{}", xp, yp, xl, yl)) + .collect::>() + .join(";"); + let coords: Vec<(i32, i32)> = xpts + .iter() + .zip(ypts.iter()) + .map(|((xp, _), (yp, _))| (*xp, *yp)) + .collect(); + + let pts_str: String = coords + .iter() + .map(|(x, y)| format!("{},{}", x, y)) + .collect::>() + .join(" "); + + let first = coords.first().unwrap(); + let last = coords.last().unwrap(); + let is_closed = (first.0 - last.0).abs() <= 2 && (first.1 - last.1).abs() <= 2; + let use_fill = bbox.has_fill || is_closed; + + // Check if the bounding box of drawn content extends significantly beyond the + // polyline path, indicating a complex element (e.g. boxplot) whose hover target + // should cover the full rendered area. + let path_min_x = coords.iter().map(|c| c.0).min().unwrap(); + let path_max_x = coords.iter().map(|c| c.0).max().unwrap(); + let path_min_y = coords.iter().map(|c| c.1).min().unwrap(); + let path_max_y = coords.iter().map(|c| c.1).max().unwrap(); + let extend_threshold = 6; + let use_bbox_rect = bbox.any + && ((bbox.min_x < path_min_x - extend_threshold) + || (bbox.max_x > path_max_x + extend_threshold) + || (bbox.min_y < path_min_y - extend_threshold) + || (bbox.max_y > path_max_y + extend_threshold)); + + if use_bbox_rect { + // Bounding-box rectangle hover target (boxplots, etc.) + let pad = 4; + let mut aw = self.open_tag(SVGTag::Rectangle); + aw.write_key("class").write_value("plotters-dl-hover"); + aw.write_key("x").write_value(bbox.min_x - pad); + aw.write_key("y").write_value(bbox.min_y - pad); + aw.write_key("width") + .write_value(bbox.max_x - bbox.min_x + pad * 2); + aw.write_key("height") + .write_value(bbox.max_y - bbox.min_y + pad * 2); + aw.write_key("fill").write_value("transparent"); + aw.write_key("data-pts").write_value(pts_data.as_str()); + if !series_label.is_empty() { + aw.write_key("data-sl").write_value(series_label.as_str()); + } + aw.close(); + } else if use_fill { + // Filled polygon hover target (area charts) + let mut aw = self.open_tag(SVGTag::Polygon); + aw.write_key("class").write_value("plotters-dl-hover"); + aw.write_key("fill").write_value("transparent"); + aw.write_key("stroke").write_value("none"); + aw.write_key("points").write_value(pts_str.as_str()); + aw.write_key("data-pts").write_value(pts_data.as_str()); + if !series_label.is_empty() { + aw.write_key("data-sl").write_value(series_label.as_str()); + } + aw.close(); + } else { + // Wide-stroke polyline hover target (line charts) + let mut aw = self.open_tag(SVGTag::Polyline); + aw.write_key("class").write_value("plotters-dl-hover"); + aw.write_key("fill").write_value("none"); + aw.write_key("stroke").write_value("transparent"); + aw.write_key("stroke-width").write_value(12i32); + aw.write_key("points").write_value(pts_str.as_str()); + aw.write_key("data-pts").write_value(pts_data.as_str()); + if !series_label.is_empty() { + aw.write_key("data-sl").write_value(series_label.as_str()); + } + aw.close(); + } + } + } + } + // Clone the + self.close_tag(); + } + fn present(&mut self) -> Result<(), DrawingErrorKind> { if !self.saved { + if self.interactive { + self.inject_tooltip_assets(); + } while self.close_tag() {} match self.target { Target::File(ref buf, path) => { @@ -326,6 +866,7 @@ impl<'a> DrawingBackend for SVGBackend<'a> { .write_key("fill") .write_value(make_svg_color(color)); attrwriter.close(); + self.track_coord(point); Ok(()) } @@ -353,6 +894,7 @@ impl<'a> DrawingBackend for SVGBackend<'a> { attrwriter.write_key("x2").write_value(to.0); attrwriter.write_key("y2").write_value(to.1); attrwriter.close(); + self.track_rect(from, to); Ok(()) } @@ -366,6 +908,7 @@ impl<'a> DrawingBackend for SVGBackend<'a> { if style.color().alpha == 0.0 { return Ok(()); } + let is_filled = fill; let color = make_svg_color(style.color()); let (fill, stroke) = if !fill { @@ -389,6 +932,12 @@ impl<'a> DrawingBackend for SVGBackend<'a> { attrwriter.write_key("fill").write_value(fill); attrwriter.write_key("stroke").write_value(stroke); attrwriter.close(); + if let Some(bb) = self.bbox_stack.last_mut() { + bb.expand_rect(upper_left, bottom_right); + if is_filled { + bb.has_fill = true; + } + } Ok(()) } @@ -400,6 +949,7 @@ impl<'a> DrawingBackend for SVGBackend<'a> { if style.color().alpha == 0.0 { return Ok(()); } + let path_points: Vec<_> = path.into_iter().collect(); let mut attrwriter = self.open_tag(SVGTag::Polyline); attrwriter.write_key("fill").write_value("none"); attrwriter @@ -414,9 +964,12 @@ impl<'a> DrawingBackend for SVGBackend<'a> { attrwriter .write_key("points") .write_value(FormatEscapedIter( - path.into_iter().map(|c| (c.0, ',', c.1, ' ')), + path_points.iter().map(|c| (c.0, ',', c.1, ' ')), )); attrwriter.close(); + for &pt in &path_points { + self.track_coord(pt); + } Ok(()) } @@ -428,6 +981,7 @@ impl<'a> DrawingBackend for SVGBackend<'a> { if style.color().alpha == 0.0 { return Ok(()); } + let poly_points: Vec<_> = path.into_iter().collect(); let mut attrwriter = self.open_tag(SVGTag::Polygon); attrwriter .write_key("opacity") @@ -438,9 +992,15 @@ impl<'a> DrawingBackend for SVGBackend<'a> { attrwriter .write_key("points") .write_value(FormatEscapedIter( - path.into_iter().map(|c| (c.0, ',', c.1, ' ')), + poly_points.iter().map(|c| (c.0, ',', c.1, ' ')), )); attrwriter.close(); + if let Some(bb) = self.bbox_stack.last_mut() { + for &pt in &poly_points { + bb.expand_coord(pt); + } + bb.has_fill = true; + } Ok(()) } @@ -474,6 +1034,10 @@ impl<'a> DrawingBackend for SVGBackend<'a> { .write_key("stroke-width") .write_value(style.stroke_width()); attrwriter.close(); + self.track_rect( + (center.0 - radius as i32, center.1 - radius as i32), + (center.0 + radius as i32, center.1 + radius as i32), + ); Ok(()) } diff --git a/plotters/examples/interactive_tooltips.rs b/plotters/examples/interactive_tooltips.rs new file mode 100644 index 00000000..7d9bce84 --- /dev/null +++ b/plotters/examples/interactive_tooltips.rs @@ -0,0 +1,79 @@ +//! Interactive SVG tooltips examples. +//! +//! This example demonstrates the `draw_series_with_tooltips` API together with +//! `SVGBackend::with_tooltips()`. The generated SVG file contains embedded CSS and JavaScript that +//! show a tooltip on hover for every data point. +//! +//! Open `plotters-doc-data/interactive_tooltips.svg` in a browser to see it in action. + +use plotters::prelude::*; + +fn main() -> Result<(), Box> { + let path = "plotters-doc-data/interactive_tooltips.svg"; + + // Make sure the output directory exists + std::fs::create_dir_all("plotters-doc-data")?; + + let root = SVGBackend::new(path, (720, 460)) + .with_tooltips() + .into_drawing_area(); + + root.fill(&WHITE); + + let mut chart = ChartBuilder::on(&root) + .caption("Interactive Tooltips Demo", ("sans-serif", 28).into_font()) + .margin(10) + .x_label_area_size(40) + .y_label_area_size(50) + .build_cartesian_2d(0f64..6.5, -1.2..1.2)?; + + chart.configure_mesh().x_desc("X").y_desc("Y").draw()?; + + // --- Series 1: sin(x) --- + let sin_data: Vec<_> = (0..=60) + .map(|i| { + let x = i as f64 / 10.; + (x, x.sin()) + }) + .collect(); + + chart + .draw_series_with_tooltips( + LineSeries::new(sin_data.iter().copied(), &RED), + &RED, + "sin(x)", + )? + .label("sin(x)") + .legend(|(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], RED)); + + // --- Series 2: cos(x) --- + let cos_data: Vec<_> = (0..=60) + .map(|i| { + let x = i as f64 / 10.; + (x, x.cos()) + }) + .collect(); + + chart + .draw_series_with_tooltips( + LineSeries::new(cos_data.iter().copied(), &BLUE).point_size(3), + &BLUE, + "cos(x)", + )? + .label("cos(x)") + .legend(|(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], BLUE)); + + chart + .configure_series_labels() + .border_style(BLACK) + .background_style(WHITE.mix(0.8)) + .position(SeriesLabelPosition::UpperRight) + .draw()?; + + root.present()?; + + println!("Chart saved to {path}"); + println!("Open it in a web browser to see interactive tooltips on hover"); + + Ok(()) +} diff --git a/plotters/src/chart/builder.rs b/plotters/src/chart/builder.rs index 41a4309b..bc92921a 100644 --- a/plotters/src/chart/builder.rs +++ b/plotters/src/chart/builder.rs @@ -432,6 +432,7 @@ impl<'a, 'b, DB: DrawingBackend> ChartBuilder<'a, 'b, DB> { pixel_range, )), series_anno: vec![], + next_series_id: 0, drawing_area_pos: ( actual_drawing_area_pos[2] + title_dx + self.margin[2] as i32, actual_drawing_area_pos[0] + title_dy + self.margin[0] as i32, @@ -491,6 +492,7 @@ impl<'a, 'b, DB: DrawingBackend> ChartBuilder<'a, 'b, DB> { pixel_range, )), series_anno: vec![], + next_series_id: 0, drawing_area_pos: ( title_dx + self.margin[2] as i32, title_dy + self.margin[0] as i32, diff --git a/plotters/src/chart/context.rs b/plotters/src/chart/context.rs index c63ee3b0..da557a28 100644 --- a/plotters/src/chart/context.rs +++ b/plotters/src/chart/context.rs @@ -1,6 +1,6 @@ use std::borrow::Borrow; -use plotters_backend::{BackendCoord, DrawingBackend}; +use plotters_backend::{BackendColor, BackendCoord, DrawingBackend, ElementContext}; use crate::chart::{SeriesAnno, SeriesLabelStyle}; use crate::coord::{CoordTranslate, ReverseCoordTranslate, Shift}; @@ -28,6 +28,8 @@ pub struct ChartContext<'a, DB: DrawingBackend, CT: CoordTranslate> { pub(crate) drawing_area: DrawingArea, pub(crate) series_anno: Vec>, pub(crate) drawing_area_pos: (i32, i32), + /// Monotonically increasing series id used by `begin_context`. + pub(crate) next_series_id: usize, } impl<'a, DB: DrawingBackend, CT: ReverseCoordTranslate> ChartContext<'a, DB, CT> { @@ -128,7 +130,21 @@ impl<'a, DB: DrawingBackend, CT: CoordTranslate> ChartContext<'a, DB, CT> { R: Borrow, S: IntoIterator, { + let series_id = self.next_series_id; + self.next_series_id += 1; + // Wrap the series in a DatSeries context so interactive backends can group the elements + // visually. + self.drawing_area + .begin_context(ElementContext::DataSeries { + id: series_id, + color: BackendColor { + alpha: 1., + rgb: (0, 0, 0), + }, + label: String::new(), + })?; self.draw_series_impl(series)?; + self.drawing_area.end_context()?; Ok(self.alloc_series_anno()) } } diff --git a/plotters/src/chart/context/cartesian2d/draw_impl.rs b/plotters/src/chart/context/cartesian2d/draw_impl.rs index 616d7d8e..7d0de373 100644 --- a/plotters/src/chart/context/cartesian2d/draw_impl.rs +++ b/plotters/src/chart/context/cartesian2d/draw_impl.rs @@ -1,6 +1,6 @@ use std::ops::Range; -use plotters_backend::DrawingBackend; +use plotters_backend::{DrawingBackend, ElementContext, Interpolation}; use crate::chart::ChartContext; use crate::coord::{ @@ -145,6 +145,24 @@ impl<'a, DB: DrawingBackend, X: Ranged, Y: Ranged> ChartContext<'a, DB, Cartesia return Ok(()); }; + // Emit an Axis context so interactive backends can attach tooltips. + let axis_id = if orientation.0 == 0 { + "x".to_string() + } else { + "y".to_string() + }; + let interp = if !labels.is_empty() { + Some(Interpolation::Discrete { + points: labels.to_vec(), + }) + } else { + None + }; + area.begin_context(ElementContext::Axis { + axis_id: axis_id.to_string(), + interpolation: interp, + })?; + let (x0, y0) = self.drawing_area.get_base_pixel(); let (tw, th) = area.dim_in_pixel(); @@ -240,13 +258,9 @@ impl<'a, DB: DrawingBackend, X: Ranged, Y: Ranged> ChartContext<'a, DB, Cartesia let ymax = th as i32 - 1; let (kx0, ky0, kx1, ky1) = match orientation { (dx, dy) if dx > 0 && dy == 0 => (0, *p - y0, tick_size, *p - y0), - (dx, dy) if dx < 0 && dy == 0 => { - (xmax - tick_size, *p - y0, xmax, *p - y0) - } + (dx, dy) if dx < 0 && dy == 0 => (xmax - tick_size, *p - y0, xmax, *p - y0), (dx, dy) if dx == 0 && dy > 0 => (*p - x0, 0, *p - x0, tick_size), - (dx, dy) if dx == 0 && dy < 0 => { - (*p - x0, ymax - tick_size, *p - x0, ymax) - } + (dx, dy) if dx == 0 && dy < 0 => (*p - x0, ymax - tick_size, *p - x0, ymax), _ => panic!("Bug: Invalid orientation specification"), }; let line = PathElement::new(vec![(kx0, ky0), (kx1, ky1)], *style); @@ -279,6 +293,7 @@ impl<'a, DB: DrawingBackend, X: Ranged, Y: Ranged> ChartContext<'a, DB, Cartesia let actual_style = &actual_style.pos(Pos::new(h_pos, v_pos)); area.draw_text(text, actual_style, (x0 as i32, y0 as i32))?; } + area.end_context()?; Ok(()) } @@ -348,10 +363,7 @@ impl<'a, DB: DrawingBackend, X: Ranged, Y: Ranged> ChartContext<'a, DB, Cartesia for (px, _) in &x_labels { let x = *px - x0; if x >= 0 && x < dw { - let line = PathElement::new( - vec![(x, 0), (x, abs_tick)], - *axis_style, - ); + let line = PathElement::new(vec![(x, 0), (x, abs_tick)], *axis_style); plot_area.draw(&line)?; } } @@ -380,10 +392,7 @@ impl<'a, DB: DrawingBackend, X: Ranged, Y: Ranged> ChartContext<'a, DB, Cartesia for (py, _) in &y_labels { let y = *py - y0; if y >= 0 && y < dh { - let line = PathElement::new( - vec![(0, y), (abs_tick, y)], - *axis_style, - ); + let line = PathElement::new(vec![(0, y), (abs_tick, y)], *axis_style); plot_area.draw(&line)?; } } @@ -407,4 +416,5 @@ impl<'a, DB: DrawingBackend, X: Ranged, Y: Ranged> ChartContext<'a, DB, Cartesia Ok(()) } -} \ No newline at end of file +} + diff --git a/plotters/src/chart/context/cartesian2d/mod.rs b/plotters/src/chart/context/cartesian2d/mod.rs index fd1aef27..02b0696e 100644 --- a/plotters/src/chart/context/cartesian2d/mod.rs +++ b/plotters/src/chart/context/cartesian2d/mod.rs @@ -1,14 +1,16 @@ +use std::borrow::Borrow; use std::ops::Range; -use plotters_backend::{BackendCoord, DrawingBackend}; - -use crate::chart::{ChartContext, DualCoordChartContext, MeshStyle}; +use crate::chart::{ChartContext, DualCoordChartContext, MeshStyle, SeriesAnno}; use crate::coord::{ cartesian::Cartesian2d, ranged1d::{AsRangedCoord, Ranged, ValueFormatter}, Shift, }; -use crate::drawing::DrawingArea; +use crate::drawing::{DrawingArea, DrawingAreaErrorKind}; +use crate::element::{CoordMapper, Drawable, PointCollection}; +use crate::style::Color; +use plotters_backend::{BackendCoord, DrawingBackend, Interpolation}; mod draw_impl; @@ -44,6 +46,102 @@ where pub fn configure_mesh(&mut self) -> MeshStyle<'a, '_, X, Y, DB> { MeshStyle::new(self) } + + /// Draw a series while emitting semantic contexts for every element. + /// + /// This is the tooltip-aware counterpart of [`ChartContext::draw_series`]. + /// Each element in the iterator must yield guest coordinates `(XT, YT)`. + /// The method inspects each element's points: + /// + /// - **Single-point** elements (markers, circles) are wrapped in a + /// [`ElementContext::DataPoint`] context with formatted x/y labels. + /// - **Multi-point** elements (lines, paths) are wrapped in a [`ElementContext::DataLine`] + /// context that carries discrete interpolation data (every vertex with its formatted label). + /// + /// The whole series is wrapped in a [`ElementContext::DataSeries`] context so interactive + /// backends can group and style them. + /// + /// `series_color` and `series_label` describe the series metadata forwarded to the backend. + pub fn draw_series_with_tooltips( + &mut self, + series: S, + series_color: &C, + series_label: &str, + ) -> Result<&mut SeriesAnno<'a, DB>, DrawingAreaErrorKind> + where + B: CoordMapper, + for<'b> &'b E: PointCollection<'b, (XT, YT), B>, + E: Drawable, + R: Borrow, + S: IntoIterator, + C: Color, + { + let series_id = self.next_series_id; + self.next_series_id += 1; + + let bc = series_color.to_backend_color(); + self.drawing_area + .begin_context(plotters_backend::ElementContext::DataSeries { + id: series_id, + color: bc, + label: series_label.to_string(), + })?; + + let x_spec = self.drawing_area.as_coord_spec().x_spec(); + let y_spec = self.drawing_area.as_coord_spec().y_spec(); + + for element in series { + let elem = element.borrow(); + + // Collect all guest points and map them to backend coords + labels + let mapped: Vec<_> = elem + .point_iter() + .into_iter() + .map(|pt| { + let guest = pt.borrow(); + let xl = X::format_ext(x_spec, &guest.0); + let yl = Y::format_ext(y_spec, &guest.1); + let coord = self.drawing_area.map_coordinate(guest); + (coord, xl, yl) + }) + .collect(); + + let opened = if mapped.len() <= 1 { + // Single-point element->DataPoint context + if let Some((coord, xl, yl)) = mapped.into_iter().next() { + self.drawing_area.begin_context( + plotters_backend::ElementContext::DataPoint { + coord, + x_label: xl, + y_label: yl, + series_id, + }, + )?; + true + } else { + false + } + } else { + // Multi-point element -> DataLine context with discrete interpolation (every vertex + // carries a formatted label). + let x_points: Vec<_> = mapped.iter().map(|(c, xl, _)| (c.0, xl.clone())).collect(); + let y_points: Vec<_> = mapped.iter().map(|(c, _, yl)| (c.1, yl.clone())).collect(); + self.drawing_area + .begin_context(plotters_backend::ElementContext::DataLine { + x_interpolation: Interpolation::Discrete { points: x_points }, + y_interpolation: Interpolation::Discrete { points: y_points }, + series_id, + })?; + true + }; + self.drawing_area.draw(elem)?; + if opened { + self.drawing_area.end_context()?; + } + } + self.drawing_area.end_context()?; // close DataSeries + Ok(self.alloc_series_anno()) + } } impl<'a, DB: DrawingBackend, X: Ranged, Y: Ranged> ChartContext<'a, DB, Cartesian2d> { diff --git a/plotters/src/chart/dual_coord.rs b/plotters/src/chart/dual_coord.rs index 048bea02..b460d0b6 100644 --- a/plotters/src/chart/dual_coord.rs +++ b/plotters/src/chart/dual_coord.rs @@ -121,6 +121,7 @@ impl<'a, DB: DrawingBackend, CT1: CoordTranslate, CT2: CoordTranslate> y_label_area: secondary_y_label_area, drawing_area: secondary_drawing_area, series_anno: vec![], + next_series_id: 0, drawing_area_pos: (0, 0), }, } diff --git a/plotters/src/chart/state.rs b/plotters/src/chart/state.rs index 55c4056e..522de001 100644 --- a/plotters/src/chart/state.rs +++ b/plotters/src/chart/state.rs @@ -106,6 +106,7 @@ impl ChartState { y_label_area: [None, None], drawing_area: area.apply_coord_spec(self.coord), series_anno: vec![], + next_series_id: 0, drawing_area_pos: self.drawing_area_pos, } } diff --git a/plotters/src/drawing/area.rs b/plotters/src/drawing/area.rs index e8981fea..7e198d51 100644 --- a/plotters/src/drawing/area.rs +++ b/plotters/src/drawing/area.rs @@ -6,7 +6,7 @@ use crate::style::text_anchor::{HPos, Pos, VPos}; use crate::style::{Color, SizeDesc, TextStyle}; /// The abstraction of a drawing area -use plotters_backend::{BackendCoord, DrawingBackend, DrawingErrorKind}; +use plotters_backend::{BackendCoord, DrawingBackend, DrawingErrorKind, ElementContext}; use std::borrow::Borrow; use std::cell::RefCell; @@ -312,6 +312,25 @@ impl DrawingArea { self.backend_ops(|b| b.present()) } + /// Forward a semantic context to the underlying backend. + /// + /// Backends that support interactivity (e.g. SVG tooltips) use this to know *what* is currently + /// being drawn. Must be paired with [`end_context`](Self::end_context). + pub fn begin_context(&self, ctx: ElementContext) -> Result<(), DrawingAreaError> { + self.backend_ops(|b| { + b.begin_context(ctx); + Ok(()) + }) + } + + /// Close the most recently opened semantic context. + pub fn end_context(&self) -> Result<(), DrawingAreaError> { + self.backend_ops(|b| { + b.end_context(); + Ok(()) + }) + } + /// Draw an high-level element pub fn draw<'a, E, B>(&self, element: &'a E) -> Result<(), DrawingAreaError> where @@ -492,6 +511,10 @@ impl DrawingArea { let style = &style.pos(Pos::new(HPos::Center, VPos::Top)); + self.begin_context(ElementContext::Label { + text: text.to_string(), + })?; + self.backend_ops(|b| { b.draw_text( text, @@ -499,6 +522,7 @@ impl DrawingArea { (self.rect.x0 + x_padding, self.rect.y0 + y_padding), ) })?; + self.end_context()?; Ok(Self { rect: Rect { diff --git a/plotters/src/lib.rs b/plotters/src/lib.rs index b3731333..b5d25fa7 100644 --- a/plotters/src/lib.rs +++ b/plotters/src/lib.rs @@ -833,6 +833,7 @@ pub mod prelude { // Re-export the backend for backward compatibility pub use plotters_backend::DrawingBackend; + pub use plotters_backend::{ElementContext, Interpolation}; pub use crate::drawing::*;