diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index a066d55e..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,89 +0,0 @@ -name: Release - -# Build standalone `kuva` CLI binaries on every version tag and attach them to the -# matching GitHub Release, so users can download a pre-compiled binary instead of -# building from source. Resolves https://github.com/Psy-Fer/kuva/issues/17. - -on: - push: - tags: - # Matches the existing release-tag convention (v0.1.6, v0.2.0, ...). - - "v[0-9]+.[0-9]+.[0-9]+" - # Manual runs: pick/enter an existing tag to (re)build and attach binaries for. - workflow_dispatch: - inputs: - tag: - description: "Existing tag to build and release (e.g. v0.2.0)" - required: true - -# Needed to create the Release and upload assets. -permissions: - contents: write - -jobs: - # taiki-e/upload-rust-binary-action uploads to an *existing* Release; a tag - # push alone does not create one. Create it first (idempotently) so the - # matrix below has somewhere to attach the binaries. - create-release: - runs-on: ubuntu-latest - steps: - - name: Create GitHub Release if it does not exist - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} - run: | - gh release view "$TAG" >/dev/null 2>&1 \ - || gh release create "$TAG" --title "$TAG" --generate-notes - - upload-assets: - needs: create-release - name: ${{ matrix.target }} - runs-on: ${{ matrix.os }} - strategy: - # Don't let one failing target cancel the others — a partial set of - # binaries is still useful, and lets us see which target actually broke. - fail-fast: false - matrix: - include: - - target: x86_64-unknown-linux-gnu - os: ubuntu-latest - # Static musl build: runs on any Linux distro without a glibc match. - - target: x86_64-unknown-linux-musl - os: ubuntu-latest - - target: aarch64-unknown-linux-gnu - os: ubuntu-latest - # Both macOS targets build on the Apple-Silicon runner: it cross-compiles - # to x86_64 natively (no extra tooling). The Intel macos-13 runner is - # scarce/deprecated and can queue indefinitely on personal accounts. - - target: x86_64-apple-darwin - os: macos-latest - - target: aarch64-apple-darwin - os: macos-latest - - target: x86_64-pc-windows-msvc - os: windows-latest - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} - - # Cross-compiles where needed (musl tooling, `cross` for aarch64-linux), - # builds a release binary, packs it into an archive named below, writes a - # SHA-256 checksum, and uploads everything to the Release for this tag - # (creating the Release if it does not exist yet). - - name: Build and upload kuva binary - uses: taiki-e/upload-rust-binary-action@v1 - with: - bin: kuva - # Full-featured CLI: SVG + PNG + PDF backends (matches CI's cli,full). - # `doom` is intentionally excluded — it downloads assets at build time. - features: cli,full - target: ${{ matrix.target }} - # Asset name, e.g. kuva-v0.2.0-x86_64-unknown-linux-gnu.tar.gz - # (.zip on Windows). - archive: kuva-$tag-$target - checksum: sha256 - # On manual runs there is no tag ref; tell the action which tag to use. - # On tag pushes this is empty and the action falls back to github.ref. - ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || '' }} - token: ${{ secrets.GITHUB_TOKEN }} diff --git a/src/bin/kuva/layout_args.rs b/src/bin/kuva/layout_args.rs index 603b6b7b..3d6f6fd5 100644 --- a/src/bin/kuva/layout_args.rs +++ b/src/bin/kuva/layout_args.rs @@ -1,5 +1,5 @@ use clap::Args; -use kuva::render::layout::{AxisLabelOverlap, Layout, TickFormat}; +use kuva::render::layout::{AxisLabelOverlap, AxisLine, Layout, TickAlign, TickFormat, TickPos}; use kuva::render::palette::Palette; use kuva::render::theme::Theme; @@ -131,6 +131,18 @@ pub struct AxisArgs { #[arg(long)] pub no_grid: bool, + /// Axis line style: left or box. + #[arg(long, value_name = "FRAME")] + pub axis_line: Option, + + /// Tick alignment relative to the axis line: outside, inside, or center. + #[arg(long, value_name = "ALIGN")] + pub tick_align: Option, + + /// Tick position: primary (bottom/left) or both. + #[arg(long, value_name = "POS")] + pub tick_pos: Option, + /// Fix the X axis lower bound; overrides auto-range. #[arg(long)] pub x_min: Option, @@ -285,6 +297,21 @@ pub fn apply_axis_args(mut layout: Layout, args: &AxisArgs) -> Layout { if args.no_grid { layout = layout.with_show_grid(false); } + if let Some(ref line) = args.axis_line { + if let Some(line) = parse_axis_line(line) { + layout = layout.with_axis_line(line); + } + } + if let Some(ref align) = args.tick_align { + if let Some(align) = parse_tick_align(align) { + layout = layout.with_tick_align(align); + } + } + if let Some(ref pos) = args.tick_pos { + if let Some(pos) = parse_tick_pos(pos) { + layout = layout.with_tick_pos(pos); + } + } if let Some(v) = args.x_min { layout = layout.with_x_axis_min(v); } @@ -373,6 +400,31 @@ fn colourblind_palette(condition: &str) -> Option { } } +fn parse_axis_line(s: &str) -> Option { + match s.to_ascii_lowercase().replace('_', "-").as_str() { + "open" | "left" | "primary" => Some(AxisLine::Open), + "box" | "frame" | "enclosed" => Some(AxisLine::Box), + _ => None, + } +} + +fn parse_tick_align(s: &str) -> Option { + match s.to_ascii_lowercase().replace('_', "-").as_str() { + "inside" | "in" => Some(TickAlign::Inside), + "outside" | "out" => Some(TickAlign::Outside), + "center" | "centre" | "middle" => Some(TickAlign::Center), + _ => None, + } +} + +fn parse_tick_pos(s: &str) -> Option { + match s.to_ascii_lowercase().replace('_', "-").as_str() { + "primary" | "left" | "bottom" | "lower" => Some(TickPos::Primary), + "both" | "mirror" | "mirrored" => Some(TickPos::Both), + _ => None, + } +} + fn parse_label_overlap(s: &str) -> Option { match s { "allow" => Some(AxisLabelOverlap::Allow), diff --git a/src/lib.rs b/src/lib.rs index 0cea63c7..1e5c25eb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -83,8 +83,7 @@ pub use render::render_utils::silverman_bandwidth; /// [`simple_kde_reflect`] instead. pub use render::render_utils::simple_kde; -pub use render::layout::AxisLabelOverlap; -pub use render::layout::TickFormat; +pub use render::layout::{AxisLabelOverlap, AxisLine, TickAlign, TickFormat, TickPos}; pub use render::palette::Palette; pub use render::render::render_calendar; pub use render::render::render_phylo_tree; diff --git a/src/prelude.rs b/src/prelude.rs index 7f071975..fc1b69f7 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -168,7 +168,7 @@ pub use crate::plot::{ pub use crate::render::plots::Plot; // ── Layout & rendering ─────────────────────────────────────────────────────── -pub use crate::render::layout::{Layout, TickFormat}; +pub use crate::render::layout::{AxisLine, Layout, TickAlign, TickFormat, TickPos}; pub use crate::render::render::{ collect_legend_entries, render_bump, render_forest, render_funnel, render_gantt, render_jointplot, render_lollipop, render_mosaic, render_multiple, render_parallel, diff --git a/src/render/axis.rs b/src/render/axis.rs index 93aad8f7..f546f5e5 100644 --- a/src/render/axis.rs +++ b/src/render/axis.rs @@ -1,8 +1,104 @@ use crate::render::color::Color; -use crate::render::layout::{AxisLabelOverlap, ComputedLayout, Layout, TickFormat}; +use crate::render::layout::{ + AxisLabelOverlap, AxisLine, ComputedLayout, Layout, TickAlign, TickFormat, TickPos, +}; use crate::render::render::{Primitive, Scene, TextAnchor}; use crate::render::render_utils; +fn draw_x_tick( + scene: &mut Scene, + layout: &Layout, + computed: &ComputedLayout, + theme: &crate::render::theme::Theme, + x: f64, + is_minor: bool, +) { + let tick_len = if is_minor { + computed.tick_mark_minor + } else { + computed.tick_mark_major + }; + let y_base = computed.height - computed.margin_bottom; + let (y1, y2) = match layout.tick_align { + TickAlign::Inside => (y_base - tick_len, y_base), + TickAlign::Outside => (y_base, y_base + tick_len), + TickAlign::Center => (y_base - tick_len * 0.5, y_base + tick_len * 0.5), + }; + scene.add(Primitive::Line { + x1: x, + y1, + x2: x, + y2, + stroke: Color::from(&theme.tick_color), + stroke_width: computed.tick_stroke_width, + stroke_dasharray: None, + }); + if layout.tick_pos == TickPos::Both { + let y_top = computed.margin_top; + let (ty1, ty2) = match layout.tick_align { + TickAlign::Inside => (y_top, y_top + tick_len), + TickAlign::Outside => (y_top - tick_len, y_top), + TickAlign::Center => (y_top - tick_len * 0.5, y_top + tick_len * 0.5), + }; + scene.add(Primitive::Line { + x1: x, + y1: ty1, + x2: x, + y2: ty2, + stroke: Color::from(&theme.tick_color), + stroke_width: computed.tick_stroke_width, + stroke_dasharray: None, + }); + } +} + +fn draw_y_tick( + scene: &mut Scene, + layout: &Layout, + computed: &ComputedLayout, + theme: &crate::render::theme::Theme, + y: f64, + is_minor: bool, +) { + let tick_len = if is_minor { + computed.tick_mark_minor + } else { + computed.tick_mark_major + }; + let x_base = computed.margin_left; + let (x1, x2) = match layout.tick_align { + TickAlign::Inside => (x_base, x_base + tick_len), + TickAlign::Outside => (x_base - tick_len, x_base), + TickAlign::Center => (x_base - tick_len * 0.5, x_base + tick_len * 0.5), + }; + scene.add(Primitive::Line { + x1, + y1: y, + x2, + y2: y, + stroke: Color::from(&theme.tick_color), + stroke_width: computed.tick_stroke_width, + stroke_dasharray: None, + }); + if layout.tick_pos == TickPos::Both && layout.y2_range.is_none() { + let x_right = computed.width - computed.margin_right; + let (tx1, tx2) = match layout.tick_align { + TickAlign::Inside => (x_right - tick_len, x_right), + TickAlign::Outside => (x_right, x_right + tick_len), + TickAlign::Center => (x_right - tick_len * 0.5, x_right + tick_len * 0.5), + }; + scene.add(Primitive::Line { + x1: tx1, + y1: y, + x2: tx2, + y2: y, + stroke: Color::from(&theme.tick_color), + stroke_width: computed.tick_stroke_width, + stroke_dasharray: None, + }); + } +} + /// Tracks state for x-axis label overlap handling across a tick loop. pub(crate) struct XLabelPlacer { strategy: AxisLabelOverlap, @@ -233,15 +329,7 @@ pub fn add_axes_and_grid(scene: &mut Scene, computed: &ComputedLayout, layout: & color: None, }); - scene.add(Primitive::Line { - x1: computed.margin_left - computed.tick_mark_major, - y1: y_pos, - x2: computed.margin_left, - y2: y_pos, - stroke: Color::from(&theme.tick_color), - stroke_width: computed.tick_stroke_width, - stroke_dasharray: None, - }); + draw_y_tick(scene, layout, computed, theme, y_pos, false); } } if !layout.suppress_x_ticks { @@ -276,15 +364,7 @@ pub fn add_axes_and_grid(scene: &mut Scene, computed: &ComputedLayout, layout: & }); } - scene.add(Primitive::Line { - x1: x_pos, - y1: computed.height - computed.margin_bottom, - x2: x_pos, - y2: computed.height - computed.margin_bottom + computed.tick_mark_major, - stroke: Color::from(&theme.tick_color), - stroke_width: computed.tick_stroke_width, - stroke_dasharray: None, - }); + draw_x_tick(scene, layout, computed, theme, x_pos, false); } } else { let mut placer = XLabelPlacer::new( @@ -295,15 +375,7 @@ pub fn add_axes_and_grid(scene: &mut Scene, computed: &ComputedLayout, layout: & for tx in x_ticks.iter() { let x = map_x(*tx); - scene.add(Primitive::Line { - x1: x, - y1: computed.height - computed.margin_bottom, - x2: x, - y2: computed.height - computed.margin_bottom + computed.tick_mark_major, - stroke: Color::from(&theme.tick_color), - stroke_width: computed.tick_stroke_width, - stroke_dasharray: None, - }); + draw_x_tick(scene, layout, computed, theme, x, false); let label = if let Some(ref dt) = layout.x_datetime { dt.format_tick(*tx) @@ -365,30 +437,14 @@ pub fn add_axes_and_grid(scene: &mut Scene, computed: &ComputedLayout, layout: & }); } - scene.add(Primitive::Line { - x1: x_pos, - y1: computed.height - computed.margin_bottom, - x2: x_pos, - y2: computed.height - computed.margin_bottom + computed.tick_mark_major, - stroke: Color::from(&theme.tick_color), - stroke_width: computed.tick_stroke_width, - stroke_dasharray: None, - }); + draw_x_tick(scene, layout, computed, theme, x_pos, false); } } if !layout.suppress_y_ticks { for ty in y_ticks.iter() { let y = map_y(*ty); - scene.add(Primitive::Line { - x1: computed.margin_left - computed.tick_mark_major, - y1: y, - x2: computed.margin_left, - y2: y, - stroke: Color::from(&theme.tick_color), - stroke_width: computed.tick_stroke_width, - stroke_dasharray: None, - }); + draw_y_tick(scene, layout, computed, theme, y, false); let label = if let Some(ref dt) = layout.y_datetime { dt.format_tick(*ty) @@ -421,15 +477,7 @@ pub fn add_axes_and_grid(scene: &mut Scene, computed: &ComputedLayout, layout: & for tx in x_ticks.iter() { let x = map_x(*tx); - scene.add(Primitive::Line { - x1: x, - y1: computed.height - computed.margin_bottom, - x2: x, - y2: computed.height - computed.margin_bottom + computed.tick_mark_major, - stroke: Color::from(&theme.tick_color), - stroke_width: computed.tick_stroke_width, - stroke_dasharray: None, - }); + draw_x_tick(scene, layout, computed, theme, x, false); let label = if let Some(ref dt) = layout.x_datetime { dt.format_tick(*tx) @@ -465,15 +513,7 @@ pub fn add_axes_and_grid(scene: &mut Scene, computed: &ComputedLayout, layout: & for ty in y_ticks.iter() { let y = map_y(*ty); - scene.add(Primitive::Line { - x1: computed.margin_left - computed.tick_mark_major, - y1: y, - x2: computed.margin_left, - y2: y, - stroke: Color::from(&theme.tick_color), - stroke_width: computed.tick_stroke_width, - stroke_dasharray: None, - }); + draw_y_tick(scene, layout, computed, theme, y, false); let label = if let Some(ref dt) = layout.y_datetime { dt.format_tick(*ty) @@ -500,15 +540,7 @@ pub fn add_axes_and_grid(scene: &mut Scene, computed: &ComputedLayout, layout: & if let Some(ref mx) = x_minor { for tx in mx { let x = map_x(*tx); - scene.add(Primitive::Line { - x1: x, - y1: computed.height - computed.margin_bottom, - x2: x, - y2: computed.height - computed.margin_bottom + computed.tick_mark_minor, - stroke: Color::from(&theme.tick_color), - stroke_width: computed.tick_stroke_width, - stroke_dasharray: None, - }); + draw_x_tick(scene, layout, computed, theme, x, true); } } } @@ -516,19 +548,37 @@ pub fn add_axes_and_grid(scene: &mut Scene, computed: &ComputedLayout, layout: & if let Some(ref my) = y_minor { for ty in my { let y = map_y(*ty); - scene.add(Primitive::Line { - x1: computed.margin_left - computed.tick_mark_minor, - y1: y, - x2: computed.margin_left, - y2: y, - stroke: Color::from(&theme.tick_color), - stroke_width: computed.tick_stroke_width, - stroke_dasharray: None, - }); + draw_y_tick(scene, layout, computed, theme, y, true); } } } } + + if layout.axis_line == AxisLine::Box || layout.tick_pos == TickPos::Both { + // Top axis + scene.add(Primitive::Line { + x1: computed.margin_left, + y1: computed.margin_top, + x2: computed.width - computed.margin_right, + y2: computed.margin_top, + stroke: Color::from(&theme.axis_color), + stroke_width: computed.axis_line_width, + stroke_dasharray: None, + }); + + // Right axis (drawn here only if y2 axis is NOT present) + if layout.y2_range.is_none() { + scene.add(Primitive::Line { + x1: computed.width - computed.margin_right, + y1: computed.margin_top, + x2: computed.width - computed.margin_right, + y2: computed.height - computed.margin_bottom, + stroke: Color::from(&theme.axis_color), + stroke_width: computed.axis_line_width, + stroke_dasharray: None, + }); + } + } } pub fn add_y2_axis(scene: &mut Scene, computed: &ComputedLayout, layout: &Layout) { @@ -562,10 +612,19 @@ pub fn add_y2_axis(scene: &mut Scene, computed: &ComputedLayout, layout: &Layout for ty in y2_ticks.iter() { let y = computed.map_y2(*ty); + let (tx1, tx2) = match layout.tick_align { + TickAlign::Inside => (axis_x - computed.tick_mark_major, axis_x), + TickAlign::Outside => (axis_x, axis_x + computed.tick_mark_major), + TickAlign::Center => ( + axis_x - computed.tick_mark_major * 0.5, + axis_x + computed.tick_mark_major * 0.5, + ), + }; + scene.add(Primitive::Line { - x1: axis_x, + x1: tx1, y1: y, - x2: axis_x + computed.tick_mark_major, + x2: tx2, y2: y, stroke: Color::from(&theme.tick_color), stroke_width: computed.tick_stroke_width, diff --git a/src/render/figure.rs b/src/render/figure.rs index 240c0578..4be38cf1 100644 --- a/src/render/figure.rs +++ b/src/render/figure.rs @@ -896,6 +896,9 @@ fn clone_layout(l: &Layout) -> Layout { new.data_y_range = l.data_y_range; new.ticks = l.ticks; new.show_grid = l.show_grid; + new.axis_line = l.axis_line; + new.tick_align = l.tick_align; + new.tick_pos = l.tick_pos; new.x_label = l.x_label.clone(); new.y_label = l.y_label.clone(); new.title = l.title.clone(); diff --git a/src/render/layout.rs b/src/render/layout.rs index a7cdeb52..dfa5998c 100644 --- a/src/render/layout.rs +++ b/src/render/layout.rs @@ -135,6 +135,87 @@ fn tick_format_sci(v: f64) -> String { } } +/// Controls which axis border lines are drawn. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AxisLine { + /// Draw only the primary bottom and left axes (default). + Open, + /// Draw a full box around the plot area. + Box, +} + +impl From<&str> for AxisLine { + fn from(value: &str) -> Self { + match value.to_ascii_lowercase().replace('_', "-").as_str() { + "open" | "left" | "primary" => Self::Open, + "box" | "frame" | "enclosed" => Self::Box, + other => panic!("invalid axis line '{other}'; expected open or box"), + } + } +} + +impl From for AxisLine { + fn from(value: String) -> Self { + Self::from(value.as_str()) + } +} + +/// Controls whether tick marks point inside, outside, or across axis lines. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TickAlign { + /// Ticks extend inward into the plot area (publication / pgfplots style). + Inside, + /// Ticks extend outward from the plot area (default). + Outside, + /// Ticks straddle the axis line equally on both sides. + Center, +} + +impl From<&str> for TickAlign { + fn from(value: &str) -> Self { + match value.to_ascii_lowercase().replace('_', "-").as_str() { + "inside" | "in" => Self::Inside, + "outside" | "out" => Self::Outside, + "center" | "centre" | "middle" => Self::Center, + other => { + panic!("invalid tick alignment '{other}'; expected inside, outside, or center") + } + } + } +} + +impl From for TickAlign { + fn from(value: String) -> Self { + Self::from(value.as_str()) + } +} + +/// Controls whether tick marks appear only on the primary axes or on all four sides. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TickPos { + /// Ticks on the primary bottom and left axes only (default). + Primary, + /// Ticks mirrored onto the top and right axes as well. Automatically + /// promotes `axis_line` to [`AxisLine::Box`]. + Both, +} + +impl From<&str> for TickPos { + fn from(value: &str) -> Self { + match value.to_ascii_lowercase().replace('_', "-").as_str() { + "primary" | "left" | "bottom" | "lower" => Self::Primary, + "both" | "mirror" | "mirrored" => Self::Both, + other => panic!("invalid tick position '{other}'; expected primary or both"), + } + } +} + +impl From for TickPos { + fn from(value: String) -> Self { + Self::from(value.as_str()) + } +} + /// Defines the layout of the plot pub struct Layout { pub width: Option, @@ -146,6 +227,9 @@ pub struct Layout { pub data_y_range: Option<(f64, f64)>, pub ticks: usize, pub show_grid: bool, + pub axis_line: AxisLine, + pub tick_align: TickAlign, + pub tick_pos: TickPos, pub x_label: Option, pub y_label: Option, pub title: Option, @@ -318,6 +402,9 @@ impl Layout { data_y_range: None, ticks: 5, show_grid: true, + axis_line: AxisLine::Open, + tick_align: TickAlign::Outside, + tick_pos: TickPos::Primary, x_label: None, y_label: None, title: None, @@ -1442,6 +1529,51 @@ impl Layout { self } + /// Set which axis border lines are drawn around the plot area. + /// + /// - [`AxisLine::Open`] *(default)* — bottom and left axes only. + /// - [`AxisLine::Box`] — all four sides (publication / pgfplots style). + /// + /// See also [`with_box_axes`](Self::with_box_axes) as a shorthand for `AxisLine::Box`. + /// Accepts `AxisLine` or `&str` / `String` (`"open"`, `"box"`, `"frame"`, `"enclosed"`). + pub fn with_axis_line>(mut self, line: L) -> Self { + self.axis_line = line.into(); + self + } + + /// Shorthand for `.with_axis_line(AxisLine::Box)` — draws all four axis borders. + pub fn with_box_axes(self) -> Self { + self.with_axis_line(AxisLine::Box) + } + + /// Set the direction tick marks extend relative to the axis line. + /// + /// - [`TickAlign::Outside`] *(default)* — ticks extend outward from the plot area. + /// - [`TickAlign::Inside`] — ticks extend inward into the plot area (publication style). + /// - [`TickAlign::Center`] — ticks straddle the axis line equally on both sides. + /// + /// Accepts `TickAlign` or `&str` / `String` (`"inside"`, `"outside"`, `"center"`). + pub fn with_tick_align>(mut self, align: A) -> Self { + self.tick_align = align.into(); + self + } + + /// Set whether tick marks appear on the primary axes only or on all four sides. + /// + /// - [`TickPos::Primary`] *(default)* — ticks on bottom and left axes only. + /// - [`TickPos::Both`] — ticks mirrored onto the top and right axes as well. + /// Automatically promotes `axis_line` to [`AxisLine::Box`] so the border + /// lines appear alongside the mirrored ticks. + /// + /// Accepts `TickPos` or `&str` / `String` (`"primary"`, `"both"`, `"mirror"`). + pub fn with_tick_pos>(mut self, pos: P) -> Self { + self.tick_pos = pos.into(); + if self.tick_pos == TickPos::Both { + self.axis_line = AxisLine::Box; + } + self + } + fn with_show_legend(mut self) -> Self { self.show_legend = true; self diff --git a/src/render/render.rs b/src/render/render.rs index 6144541b..ab1e6efa 100644 --- a/src/render/render.rs +++ b/src/render/render.rs @@ -13347,6 +13347,9 @@ pub fn render_multiple(plots: Vec, layout: Layout) -> Scene { }); if !skip_axes { add_axes_and_grid(&mut scene, &computed, &layout); + if layout.y2_range.is_some() { + add_y2_axis(&mut scene, &computed, &layout); + } } // For DicePlot: precompute the actual grid extents so that axis labels and diff --git a/tests/enclosed_internal_svg.rs b/tests/enclosed_internal_svg.rs new file mode 100644 index 00000000..748c36f1 --- /dev/null +++ b/tests/enclosed_internal_svg.rs @@ -0,0 +1,48 @@ +mod common; +use kuva::backend::svg::SvgBackend; +use kuva::plot::scatter::ScatterPlot; +use kuva::render::layout::{AxisLine, Layout, TickAlign}; +use kuva::render::plots::Plot; +use kuva::render::render::render_multiple; + +fn make_scatter() -> Vec<(f64, f64)> { + vec![(1.0, 2.0), (2.0, 5.0), (3.0, 3.0)] +} + +#[test] +fn test_enclosed_internal_svg() { + let plot = ScatterPlot::new() + .with_data(make_scatter()) + .with_color("steelblue"); + let plots = vec![Plot::Scatter(plot)]; + let layout = Layout::auto_from_plots(&plots) + .with_title("Enclosed Internal") + .with_axis_line(AxisLine::Box) + .with_tick_align(TickAlign::Inside); + let svg = SvgBackend.render_scene(&render_multiple(plots, layout)); + common::write_test_output("test_outputs/enclosed_internal.svg", &svg).unwrap(); + + assert!(svg.contains(" elements in box vs + // the identical plot rendered with the default Open axis line. + let plot_open = ScatterPlot::new() + .with_data(make_scatter()) + .with_color("steelblue"); + let plots_open = vec![Plot::Scatter(plot_open)]; + let layout_open = Layout::auto_from_plots(&plots_open).with_title("Enclosed Internal"); + let svg_open = SvgBackend.render_scene(&render_multiple(plots_open, layout_open)); + + let box_lines = svg.matches(" open_lines, + "box mode should emit more elements than open mode ({box_lines} vs {open_lines})" + ); +} + +#[test] +fn test_axis_line_open_is_default() { + let layout = Layout::new((0.0, 1.0), (0.0, 1.0)); + assert_eq!(layout.axis_line, AxisLine::Open); +} diff --git a/tests/enclosed_mirrored_svg.rs b/tests/enclosed_mirrored_svg.rs new file mode 100644 index 00000000..4d6dc071 --- /dev/null +++ b/tests/enclosed_mirrored_svg.rs @@ -0,0 +1,68 @@ +mod common; +use kuva::backend::svg::SvgBackend; +use kuva::plot::scatter::ScatterPlot; +use kuva::render::layout::{AxisLine, Layout, TickAlign, TickPos}; +use kuva::render::plots::Plot; +use kuva::render::render::render_multiple; + +fn make_scatter() -> Vec<(f64, f64)> { + vec![(1.0, 2.0), (2.0, 5.0), (3.0, 3.0)] +} + +#[test] +fn test_enclosed_mirrored_svg() { + let plot = ScatterPlot::new() + .with_data(make_scatter()) + .with_color("seagreen"); + let plots = vec![Plot::Scatter(plot)]; + let layout = Layout::auto_from_plots(&plots) + .with_title("Enclosed Mirrored") + .with_tick_align(TickAlign::Inside) + .with_tick_pos(TickPos::Both); + let svg = SvgBackend.render_scene(&render_multiple(plots, layout)); + common::write_test_output("test_outputs/enclosed_mirrored.svg", &svg).unwrap(); + + assert!(svg.contains(" primary_lines, + "mirrored ticks should emit more elements than primary-only \ + ({mirrored_lines} vs {primary_lines})" + ); +} + +#[test] +fn test_tick_pos_both_auto_sets_box() { + let layout = Layout::new((0.0, 1.0), (0.0, 1.0)).with_tick_pos(TickPos::Both); + assert_eq!( + layout.axis_line, + AxisLine::Box, + "with_tick_pos(Both) must promote axis_line to Box" + ); +} + +#[test] +fn test_axis_line_tick_align_tick_pos_builders() { + let layout = Layout::new((0.0, 1.0), (0.0, 1.0)) + .with_axis_line("box") + .with_tick_align("center") + .with_tick_pos("both"); + + assert_eq!(layout.axis_line, AxisLine::Box); + assert_eq!(layout.tick_align, TickAlign::Center); + assert_eq!(layout.tick_pos, TickPos::Both); +} diff --git a/tests/y2_override_svg.rs b/tests/y2_override_svg.rs new file mode 100644 index 00000000..cf6808f5 --- /dev/null +++ b/tests/y2_override_svg.rs @@ -0,0 +1,66 @@ +mod common; +use kuva::backend::svg::SvgBackend; +use kuva::plot::scatter::ScatterPlot; +use kuva::render::layout::{Layout, TickAlign, TickPos}; +use kuva::render::plots::Plot; +use kuva::render::render::render_multiple; + +#[test] +fn test_y2_override_svg() { + let data1 = vec![(1.0, 2.0), (2.0, 4.0), (3.0, 6.0)]; + let data2 = vec![(1.0, 100.0), (2.0, 250.0), (3.0, 150.0)]; + + let plot1 = ScatterPlot::new() + .with_data(data1) + .with_color("steelblue") + .with_legend("Primary"); + let plot2 = ScatterPlot::new() + .with_data(data2) + .with_color("crimson") + .with_legend("Secondary"); + let plots = vec![Plot::Scatter(plot1), Plot::Scatter(plot2)]; + + let layout = Layout::new((0.5, 3.5), (0.0, 10.0)) + .with_title("Y2 Override") + .with_tick_align(TickAlign::Inside) + .with_tick_pos(TickPos::Both) + .with_y2_range(0.0, 500.0) + .with_y2_label("Secondary Axis"); + + let svg = SvgBackend.render_scene(&render_multiple(plots, layout)); + common::write_test_output("test_outputs/y2_override_internal.svg", &svg).unwrap(); + + assert!(svg.contains("500<") || svg.contains(">400<") || svg.contains(">250<"), + "y2 tick labels should appear in SVG" + ); + // With tick_pos=Both the plot is promoted to box mode; the top border + // line must be present. The right axis is owned by add_y2_axis. + // Both are checked structurally: the enclosed+y2 render should emit more + // elements than the same two-series plot with default (Open/Primary) axes. + let plot1b = ScatterPlot::new() + .with_data(vec![(1.0, 2.0), (2.0, 4.0), (3.0, 6.0)]) + .with_color("steelblue") + .with_legend("Primary"); + let plot2b = ScatterPlot::new() + .with_data(vec![(1.0, 100.0), (2.0, 250.0), (3.0, 150.0)]) + .with_color("crimson") + .with_legend("Secondary"); + let plots_plain = vec![Plot::Scatter(plot1b), Plot::Scatter(plot2b)]; + let layout_plain = Layout::new((0.5, 3.5), (0.0, 10.0)) + .with_title("Y2 Override") + .with_y2_range(0.0, 500.0) + .with_y2_label("Secondary Axis"); + let svg_plain = SvgBackend.render_scene(&render_multiple(plots_plain, layout_plain)); + + assert!( + svg.matches(" svg_plain.matches(" elements than default axes" + ); +}