From e8db6b9f10d2cf09a40addd31c61cc04a32af4dc Mon Sep 17 00:00:00 2001 From: Dongcheng Lin Date: Sat, 24 Oct 2026 18:00:10 +0900 Subject: [PATCH] feat(plot): thin markers, wrap annotation text and set major tick steps --- .../agent/src/session/property_allowlist.rs | 4 + crates/app/src/ui/properties/presentations.rs | 12 ++ crates/app/src/ui/windows/plot_text_edit.rs | 20 +++- crates/core/src/automation/figure_edit.rs | 5 +- crates/core/src/project/axis_overrides.rs | 27 +++++ crates/core/src/project/field_catalog.rs | 10 ++ crates/core/src/properties/axis.rs | 103 +++++++++++++++++- .../core/src/properties/axis_scale_tests.rs | 26 +++++ crates/core/src/properties/axis_tests.rs | 49 +++++++++ crates/core/src/properties/line_display.rs | 31 ++++++ crates/core/src/properties/plot_display.rs | 12 +- crates/core/src/properties/right_axis.rs | 32 +++++- .../src/properties/transaction_shared_x.rs | 15 +++ crates/core/src/state/axis_overrides.rs | 7 ++ crates/core/src/state/display_marker_tests.rs | 48 ++++++++ crates/core/src/state/mod.rs | 2 +- crates/core/src/state/right_axis.rs | 8 +- crates/core/src/state/right_axis_tests.rs | 30 +++++ crates/core/src/state/shared_x_stack.rs | 42 ++++++- .../src/state/shared_x_stack_review_tests.rs | 88 +++++++++++++++ crates/figure/src/display.rs | 7 ++ crates/figure/src/lib.rs | 6 + crates/render/src/diagnostics/curves.rs | 2 +- crates/render/src/diagnostics/text.rs | 17 ++- crates/render/src/display_tests.rs | 67 ++++++++++++ crates/render/src/emf.rs | 13 ++- crates/render/src/emf/curves.rs | 6 +- crates/render/src/label_placement.rs | 14 ++- crates/render/src/label_placement_tests.rs | 40 +++++++ crates/render/src/rich_text.rs | 36 ++++++ crates/render/src/screen.rs | 34 ++++-- crates/render/src/screen/curves.rs | 6 +- crates/render/src/svg.rs | 26 +++-- crates/render/src/svg/curves.rs | 1 + crates/render/src/text_place.rs | 53 +++++++-- crates/render/src/ticks.rs | 58 ++++++++++ crates/render/src/ticks/margins.rs | 7 +- crates/render/src/y_title.rs | 18 ++- crates/render/src/y_title_tests.rs | 27 +++++ docs/src/content/docs/guides/annotations.md | 5 +- .../content/docs/guides/layout-and-export.md | 28 ++++- docs/src/content/docs/reference/mcp.md | 18 ++- .../content/docs/zh-cn/guides/annotations.md | 3 +- .../docs/zh-cn/guides/layout-and-export.md | 18 ++- docs/src/content/docs/zh-cn/reference/mcp.md | 14 ++- 45 files changed, 992 insertions(+), 103 deletions(-) diff --git a/crates/agent/src/session/property_allowlist.rs b/crates/agent/src/session/property_allowlist.rs index be7d699a..a8fd3725 100644 --- a/crates/agent/src/session/property_allowlist.rs +++ b/crates/agent/src/session/property_allowlist.rs @@ -22,6 +22,7 @@ pub const PROPERTIES: &[PropertyId] = &[ properties::line_display::MARKER_EDGE_COLOR, properties::line_display::MARKER_EDGE_WIDTH, properties::line_display::MARKER_HOLLOW, + properties::line_display::MARKER_EVERY, properties::line_display::BAR_WIDTH, properties::line_display::BAR_EDGE_COLOR, properties::line_display::BAR_EDGE_WIDTH, @@ -46,6 +47,8 @@ pub const PROPERTIES: &[PropertyId] = &[ properties::axis::EQUAL_SCALE, properties::axis::X_SCALE, properties::axis::Y_SCALE, + properties::axis::X_MAJOR_STEP, + properties::axis::Y_MAJOR_STEP, properties::plot_display::X_UNIT, properties::plot_display::Y_UNIT, properties::plot_display::X_RANGE, @@ -56,6 +59,7 @@ pub const PROPERTIES: &[PropertyId] = &[ properties::right_axis::Y2_SHOW_TICK_LABELS, properties::right_axis::Y2_SHOW_LABEL, properties::right_axis::Y2_UNIT, + properties::right_axis::Y2_MAJOR_STEP, properties::plot_display::X_BREAK, properties::plot_display::Y_BREAK, properties::plot_display::X_BREAK_FRACTION, diff --git a/crates/app/src/ui/properties/presentations.rs b/crates/app/src/ui/properties/presentations.rs index ec76c212..59610490 100644 --- a/crates/app/src/ui/properties/presentations.rs +++ b/crates/app/src/ui/properties/presentations.rs @@ -30,9 +30,19 @@ pub const PRESENTATIONS: &[PropertyPresentation] = &[ RIGHT_AXIS_HOME, ), object_entry(right_axis::Y2_UNIT, "Right Y display unit", RIGHT_AXIS_HOME), + object_entry( + right_axis::Y2_MAJOR_STEP, + "Right Y major tick step", + RIGHT_AXIS_HOME, + ), object_entry(line_display::MARKER_SIZE, "Marker diameter", LINE_HOME), object_entry(line_display::MARKER_FILL, "Marker fill", LINE_HOME), object_entry(line_display::MARKER_HOLLOW, "Hollow marker", LINE_HOME), + object_entry( + line_display::MARKER_EVERY, + "Marker every N points", + LINE_HOME, + ), object_entry(line_display::BAR_WIDTH, "Bar width", LINE_HOME), object_entry(line_display::BAR_EDGE_COLOR, "Bar outline color", LINE_HOME), object_entry(line_display::BAR_EDGE_WIDTH, "Bar outline width", LINE_HOME), @@ -159,6 +169,8 @@ pub const PRESENTATIONS: &[PropertyPresentation] = &[ "Y thousands separator", AXIS_HOME, ), + object_entry(axis::X_MAJOR_STEP, "X major tick step", AXIS_HOME), + object_entry(axis::Y_MAJOR_STEP, "Y major tick step", AXIS_HOME), PropertyPresentation { id: axis::FRAME, localized_label: LocalizedText("Frame"), diff --git a/crates/app/src/ui/windows/plot_text_edit.rs b/crates/app/src/ui/windows/plot_text_edit.rs index 6878c065..2ab5eb9a 100644 --- a/crates/app/src/ui/windows/plot_text_edit.rs +++ b/crates/app/src/ui/windows/plot_text_edit.rs @@ -40,14 +40,30 @@ pub(in crate::ui) fn plot_text_edit_window(app: &mut PlotiumApp, ctx: &egui::Con let Some(edit) = app.session.ui.plot_text_edit.as_mut() else { return; }; - let response = ui.add(egui::TextEdit::singleline(&mut edit.buffer).desired_width(320.0)); + // Text annotations take several lines: Enter starts a new line and + // Command-Enter saves. Line labels stay on one line. + let response = if is_text { + ui.add( + egui::TextEdit::multiline(&mut edit.buffer) + .desired_rows(2) + .desired_width(320.0), + ) + } else { + ui.add(egui::TextEdit::singleline(&mut edit.buffer).desired_width(320.0)) + }; if edit.focus { response.request_focus(); edit.focus = false; } // A text annotation needs some text; an empty line label removes it. let valid = !is_text || !edit.buffer.trim().is_empty(); - if valid && response.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter)) { + let submitted = if is_text { + response.has_focus() + && ui.input(|input| input.modifiers.command && input.key_pressed(egui::Key::Enter)) + } else { + response.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter)) + }; + if valid && submitted { save = true; } if ui.input(|input| input.key_pressed(egui::Key::Escape)) { diff --git a/crates/core/src/automation/figure_edit.rs b/crates/core/src/automation/figure_edit.rs index 8fc037ce..d367306d 100644 --- a/crates/core/src/automation/figure_edit.rs +++ b/crates/core/src/automation/figure_edit.rs @@ -18,8 +18,7 @@ pub fn figure_edit_schema() -> Value { let name = json!({"type":"string","minLength":1,"maxLength":1024}); let text = json!({"type":"string","maxLength":16384}); let font = json!({"type":"number","minimum":1,"maximum":72}); - let annotation_text = - json!({"type":"string","minLength":1,"maxLength":crate::state::MAX_ANNOTATION_TEXT_BYTES}); + let annotation_text = json!({"type":"string","minLength":1,"maxLength":crate::state::MAX_ANNOTATION_TEXT_BYTES,"description":"\\n starts a new line."}); let annotation_id = json!({"type":"integer","minimum":0,"description":"Plot-local annotation id from document inspection or an earlier annotation_edits result."}); let data_point = json!({"type":"array","minItems":2,"maxItems":2,"items":number,"description":"Finite [x,y] anchor in the plot's source data coordinates (the units in its coordinate_units, before any display-unit conversion). The label is centred above the anchor, or moved up to three text heights to the nearest spot clear of data, and follows axis range, zoom and pan; anchors outside the visible range are clipped."}); let orientation = json!({"enum":["vertical","horizontal"],"description":"vertical: constant x, spans the plot's height; horizontal: constant y, spans its width."}); @@ -161,7 +160,7 @@ pub fn figure_edit_schema() -> Value { ), ]; variants[0]["not"] = json!({"required":["size_mm","size_preset"]}); - json!({"type":"object","additionalProperties":false,"required":["canvas","edits"],"properties":{"canvas":target,"edits":{"type":"array","minItems":1,"maxItems":200,"items":{"oneOf":variants}}},"description":"Atomic native authoring on one exact canvas. Panel frames [x,y,w,h] are page pt, resize scales child-local frames. New content frames are parent-local pt. Page size/margins/gutter use mm; margins [top,right,bottom,left]. Grid uses native Frame-basis cells in supplied Panel order, existing page margins/gutter; no page-layout preference change or non-target reflow. Align uses bounding edges/centres, distribution equal centre spacing with endpoints fixed. Panel layout ops require unlocked Panels with free or shared-x stack layout; new text/images need a free-layout parent. panel_stack needs at least two plots in the Panel and its merge Panels: members fill its inner width, heights keep their frame proportions, only the bottom plot draws x tick labels and x title and only the top plot a title, inner boundaries touch with inward x ticks, members get one x range covering all their data (x scales must match) and draw the bottom member's x direction, and x range, scale, display unit and thousands separator edits apply to every member or are rejected. content_frame requires the item and parent unlocked, a free-layout or shared-x stack parent (where only the height counts, as the member's share) and Auto height off; image_replace requires the item and parent unlocked, not a layout mode change. Deletion-only batches accept distinct content_delete, originally empty panel_delete and explicit-column series_delete targets on this canvas. Reject overlaps, last-Series removal, defaults/dynamic displays and mixing deletion with other ops. Preserve empty parents, datasets and assets; preflight lists exact deletions and relationship changes. Hidden exact targets remain addressable. Annotation ops target an exact unlocked plot in an unlocked parent, anchor text, reference lines (kind reference_line) or arrows (kind arrow: tail `at`, head `to`) in source data coordinates, allow at most 256 of any kind per plot and are allowed with Auto height. No top-level ids/targets."}) + json!({"type":"object","additionalProperties":false,"required":["canvas","edits"],"properties":{"canvas":target,"edits":{"type":"array","minItems":1,"maxItems":200,"items":{"oneOf":variants}}},"description":"Atomic native authoring on one exact canvas. Panel frames [x,y,w,h] are page pt, resize scales child-local frames. New content frames are parent-local pt. Page size/margins/gutter use mm; margins [top,right,bottom,left]. Grid uses native Frame-basis cells in supplied Panel order, existing page margins/gutter; no page-layout preference change or non-target reflow. Align uses bounding edges/centres, distribution equal centre spacing with endpoints fixed. Panel layout ops require unlocked Panels with free or shared-x stack layout; new text/images need a free-layout parent. panel_stack needs at least two plots in the Panel and its merge Panels: members fill its inner width, heights keep their frame proportions, only the bottom plot draws x tick labels and x title and only the top plot a title, inner boundaries touch with inward x ticks, members get one x range covering all their data and the top member's x major tick step (x scales must match) and draw the bottom member's x direction, and x range, scale, display unit, thousands separator and major tick step edits apply to every member or are rejected. content_frame requires the item and parent unlocked, a free-layout or shared-x stack parent (where only the height counts, as the member's share) and Auto height off; image_replace requires the item and parent unlocked, not a layout mode change. Deletion-only batches accept distinct content_delete, originally empty panel_delete and explicit-column series_delete targets on this canvas. Reject overlaps, last-Series removal, defaults/dynamic displays and mixing deletion with other ops. Preserve empty parents, datasets and assets; preflight lists exact deletions and relationship changes. Hidden exact targets remain addressable. Annotation ops target an exact unlocked plot in an unlocked parent, anchor text, reference lines (kind reference_line) or arrows (kind arrow: tail `at`, head `to`) in source data coordinates, allow at most 256 of any kind per plot and are allowed with Auto height. No top-level ids/targets."}) } /// Built-in page size presets by id, shared by page edits and new canvases. pub(super) fn size_preset_schema() -> Value { diff --git a/crates/core/src/project/axis_overrides.rs b/crates/core/src/project/axis_overrides.rs index 76d6bd40..6564b2a2 100644 --- a/crates/core/src/project/axis_overrides.rs +++ b/crates/core/src/project/axis_overrides.rs @@ -50,6 +50,10 @@ pub struct AxisOverridesDto { #[serde(default, skip_serializing_if = "Option::is_none")] y_thousands_separator: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + x_major_step: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + y_major_step: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] guide_visibility: Option, #[serde(default, skip_serializing_if = "Option::is_none")] guide_placement: Option, @@ -73,6 +77,8 @@ pub struct AxisOverridesDto { y2_show_tick_labels: Option, #[serde(default, skip_serializing_if = "Option::is_none")] y2_show_label: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + y2_major_step: Option, } #[derive(Clone, Serialize, Deserialize)] @@ -281,6 +287,8 @@ impl AxisOverridesDto { y_show_label: overrides.y_show_label, x_thousands_separator: overrides.x_thousands_separator, y_thousands_separator: overrides.y_thousands_separator, + x_major_step: overrides.x_major_step, + y_major_step: overrides.y_major_step, guide_visibility: overrides.guide_visibility, guide_placement: overrides.guide_placement, guide_layout: overrides.guide_layout, @@ -293,6 +301,7 @@ impl AxisOverridesDto { y2_scale: overrides.y2_scale, y2_show_tick_labels: overrides.y2_show_tick_labels, y2_show_label: overrides.y2_show_label, + y2_major_step: overrides.y2_major_step, }) } @@ -317,6 +326,8 @@ impl AxisOverridesDto { y_show_label: self.y_show_label, x_thousands_separator: self.x_thousands_separator, y_thousands_separator: self.y_thousands_separator, + x_major_step: self.x_major_step, + y_major_step: self.y_major_step, guide_visibility: self.guide_visibility, guide_placement: self.guide_placement, guide_layout: self.guide_layout, @@ -333,6 +344,7 @@ impl AxisOverridesDto { y2_scale: self.y2_scale, y2_show_tick_labels: self.y2_show_tick_labels, y2_show_label: self.y2_show_label, + y2_major_step: self.y2_major_step, } .normalized() } @@ -381,6 +393,21 @@ mod tests { assert_eq!(restored.to_overrides(), overrides); } + #[test] + fn major_step_overrides_round_trip_through_the_project_format() { + let overrides = AxisOverrides { + x_major_step: Some(100.0), + y_major_step: Some(20.0), + y2_major_step: Some(0.5), + ..AxisOverrides::default() + }; + let dto = AxisOverridesDto::from_overrides(&overrides).expect("steps are stored"); + let json = serde_json::to_string(&dto).expect("serializes"); + assert!(json.contains("\"y_major_step\":20.0")); + let restored: AxisOverridesDto = serde_json::from_str(&json).expect("deserializes"); + assert_eq!(restored.to_overrides(), overrides); + } + #[test] fn right_axis_overrides_round_trip_through_the_project_format() { let overrides = AxisOverrides { diff --git a/crates/core/src/project/field_catalog.rs b/crates/core/src/project/field_catalog.rs index e3bd8712..0f2a7dd2 100644 --- a/crates/core/src/project/field_catalog.rs +++ b/crates/core/src/project/field_catalog.rs @@ -45,6 +45,16 @@ pub(super) fn validate_series( "{context} marker diameter must be between 0.1 and 20 pt" ))); } + if let plotium_figure::SeriesEncoding::Line(line) = encoding + && line + .appearance + .marker_every + .is_some_and(|n| !(2..=1000).contains(&n.get())) + { + return Err(ProjectError::Invalid(format!( + "{context} marker spacing must be between 2 and 1000 points" + ))); + } if let plotium_figure::SeriesEncoding::Line(line) = encoding && line .appearance diff --git a/crates/core/src/properties/axis.rs b/crates/core/src/properties/axis.rs index 88e27e4f..69e04a03 100644 --- a/crates/core/src/properties/axis.rs +++ b/crates/core/src/properties/axis.rs @@ -4,8 +4,8 @@ use super::provider::PropertyProvider; use super::target::{require_plot_object_target, resolved_schema}; use super::{ AggregateValue, Applicability, Availability, ComponentKind, DefaultPolicy, EditOp, EnumVariant, - PropertyAccess, PropertyAddress, PropertyDefinition, PropertyError, PropertyId, - PropertyTransaction, PropertyValue, ResolvedProperty, ScopeKind, Tier, ValueCopies, + FloatBounds, FloatDisplay, PropertyAccess, PropertyAddress, PropertyDefinition, PropertyError, + PropertyId, PropertyTransaction, PropertyValue, ResolvedProperty, ScopeKind, Tier, ValueCopies, ValueSchema, }; use crate::state::{FieldCapabilities, ObjectId, PlotObject, PlotiumApp}; @@ -23,6 +23,8 @@ pub const Y_THOUSANDS_SEPARATOR: PropertyId = PropertyId("object.axes.y_thousand pub const FRAME: PropertyId = PropertyId("object.axes.frame"); pub const X_SCALE: PropertyId = PropertyId("object.axes.x_scale"); pub const Y_SCALE: PropertyId = PropertyId("object.axes.y_scale"); +pub const X_MAJOR_STEP: PropertyId = PropertyId("object.axes.x_major_step"); +pub const Y_MAJOR_STEP: PropertyId = PropertyId("object.axes.y_major_step"); pub const OPEN: &str = "open"; pub const BOX: &str = "box"; @@ -42,6 +44,14 @@ const STACKED_EQUAL_SCALE: &str = const OBJECT: Applicability = Applicability::component(ComponentKind::None); +const MAJOR_STEP_BOUNDS: FloatBounds = FloatBounds::inclusive(0., 1e15); +/// Major tick step in the axis's display unit; 0 lets the axis choose. +pub(crate) const MAJOR_STEP_SCHEMA: ValueSchema = ValueSchema::Float { + bounds: MAJOR_STEP_BOUNDS, + display: FloatDisplay::Linear(""), + drag_step: None, +}; + const fn axis_definition( id: PropertyId, value_schema: ValueSchema, @@ -167,6 +177,20 @@ pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[ &["log y", "logarithmic y axis", "y log scale", "semilog"], Tier::Advanced, ), + axis_definition( + X_MAJOR_STEP, + MAJOR_STEP_SCHEMA, + "X major tick step", + &["x tick spacing", "x tick interval", "x major tick interval"], + Tier::Advanced, + ), + axis_definition( + Y_MAJOR_STEP, + MAJOR_STEP_SCHEMA, + "Y major tick step", + &["y tick spacing", "y tick interval", "y major tick interval"], + Tier::Advanced, + ), ]; pub(crate) struct AxisProvider; @@ -195,6 +219,10 @@ impl PropertyProvider for AxisProvider { Availability::Disabled(NO_AXES) } else if let Some(reason) = scale_unavailable(app, definition.id, plot) { Availability::Disabled(reason) + } else if let Some(reason) = major_step_axis(definition.id, plot.figure()) + .and_then(|axis| major_step_unavailable(plot.figure(), axis)) + { + Availability::Disabled(reason) } else if definition.id == EQUAL_SCALE && let Some(reason) = equal_scale_blocked(app, canvas, object, plot, !plot.figure().lock_aspect) @@ -250,6 +278,17 @@ impl PropertyProvider for AxisProvider { { return Err(PropertyError::NotApplicable(reason.to_owned())); } + if !matches!(operation, EditOp::Reset) + && let Some(reason) = app.doc.canvases[canvas] + .object(object) + .and_then(|object| object.plot()) + .and_then(|plot| { + major_step_axis(definition.id, plot.figure()) + .and_then(|axis| major_step_unavailable(plot.figure(), axis)) + }) + { + return Err(PropertyError::NotApplicable(reason.to_owned())); + } let overrides = transaction.axis_overrides(app, canvas, object)?; match (definition.id, operation) { (EQUAL_SCALE, EditOp::Set(PropertyValue::Bool(value))) => { @@ -303,6 +342,13 @@ impl PropertyProvider for AxisProvider { *scale_slot(overrides, definition.id) = Some(AxisScale::Log10) } (X_SCALE | Y_SCALE, EditOp::Reset) => *scale_slot(overrides, definition.id) = None, + (X_MAJOR_STEP | Y_MAJOR_STEP, EditOp::Set(PropertyValue::Float(value))) => { + checked_major_step(definition.id, *value)?; + *major_step_slot(overrides, definition.id) = (*value > 0.0).then_some(*value); + } + (X_MAJOR_STEP | Y_MAJOR_STEP, EditOp::Reset) => { + *major_step_slot(overrides, definition.id) = None + } (EQUAL_SCALE, EditOp::Reset) => overrides.lock_aspect = None, (X_LABEL, EditOp::Reset) => overrides.x_label = None, (Y_LABEL, EditOp::Reset) => overrides.y_label = None, @@ -357,6 +403,8 @@ fn value_of(id: PropertyId, plot: &PlotObject) -> Result Ok(frame_value(plot.figure().axis_frame)), X_SCALE => Ok(scale_value(plot.figure().x.scale)), Y_SCALE => Ok(scale_value(plot.figure().y.scale)), + X_MAJOR_STEP => Ok(major_step_value(plot.figure().x.major_step)), + Y_MAJOR_STEP => Ok(major_step_value(plot.figure().y.major_step)), _ => Err(PropertyError::UnknownProperty(id.as_str().to_owned())), } } @@ -398,6 +446,7 @@ fn default_value(id: PropertyId, plot: &PlotObject) -> Result Ok(PropertyValue::Bool(plot.derived_axes().lock_aspect)), X_SCALE => Ok(scale_value(plot.derived_axes().x_scale)), Y_SCALE => Ok(scale_value(plot.derived_axes().y_scale)), + X_MAJOR_STEP | Y_MAJOR_STEP => Ok(major_step_value(None)), _ => Err(PropertyError::UnknownProperty(id.as_str().to_owned())), } } @@ -421,6 +470,8 @@ fn has_override(id: PropertyId, plot: &PlotObject) -> Result Ok(plot.axis_overrides.x_scale.is_some()), Y_SCALE => Ok(plot.axis_overrides.y_scale.is_some()), + X_MAJOR_STEP => Ok(plot.axis_overrides.x_major_step.is_some()), + Y_MAJOR_STEP => Ok(plot.axis_overrides.y_major_step.is_some()), _ => Err(PropertyError::UnknownProperty(id.as_str().to_owned())), } } @@ -487,3 +538,51 @@ pub(crate) fn is_nmr_spectrum(app: &PlotiumApp, plot: &PlotObject) -> bool { .contains(crate::automation::CAP_FIELD_NMR_SIGNAL) }) } + +fn major_step_slot( + overrides: &mut crate::state::AxisOverrides, + id: PropertyId, +) -> &mut Option { + if id == X_MAJOR_STEP { + &mut overrides.x_major_step + } else { + &mut overrides.y_major_step + } +} + +fn major_step_axis( + id: PropertyId, + figure: &plotium_figure::Figure, +) -> Option<&plotium_figure::Axis> { + match id { + X_MAJOR_STEP => Some(&figure.x), + Y_MAJOR_STEP => Some(&figure.y), + _ => None, + } +} + +pub(crate) fn major_step_value(step: Option) -> PropertyValue { + PropertyValue::Float(step.unwrap_or(0.0)) +} + +pub(crate) fn checked_major_step(id: PropertyId, value: f64) -> Result { + MAJOR_STEP_BOUNDS.check(id, "major tick step", value) +} + +/// Why `axis` cannot take a set major tick step. +pub(crate) fn major_step_unavailable( + figure: &plotium_figure::Figure, + axis: &plotium_figure::Axis, +) -> Option<&'static str> { + if figure.axis_frame == AxisFrame::Hidden { + Some(NO_AXES) + } else if axis.categories.is_some() { + Some("A category axis places one tick per category.") + } else if !axis.effective_scale().is_linear() { + Some("A logarithmic axis places its major ticks on powers of ten.") + } else if axis.axis_break.is_some() { + Some("A broken axis chooses its own ticks; remove the break to set a tick step.") + } else { + None + } +} diff --git a/crates/core/src/properties/axis_scale_tests.rs b/crates/core/src/properties/axis_scale_tests.rs index 29c9970f..ee0b5766 100644 --- a/crates/core/src/properties/axis_scale_tests.rs +++ b/crates/core/src/properties/axis_scale_tests.rs @@ -198,3 +198,29 @@ fn log_scales_are_disabled_on_2d_maps_and_nmr_spectra() { .expect("write plans with a skip"); assert!(commit.applied.is_empty()); } + +#[test] +fn major_step_is_unavailable_on_a_logarithmic_axis() { + let (mut app, target, id) = line_app(); + let commit = app + .plan_property_write( + axis::X_SCALE, + std::slice::from_ref(&target), + &PropertyValue::Enum(axis::LOG), + ) + .expect("log x plans"); + app.commit_property(commit); + assert_eq!(figure(&app, id).x.effective_scale(), AxisScale::Log10); + let resolved = app + .resolve_property(&PropertyAddress::new(target.clone(), axis::X_MAJOR_STEP)) + .expect("step reads"); + assert!(matches!(resolved.availability, Availability::Disabled(_))); + let commit = app + .plan_property_write( + axis::X_MAJOR_STEP, + std::slice::from_ref(&target), + &PropertyValue::Float(10.0), + ) + .expect("write plans with a skip"); + assert!(commit.applied.is_empty()); +} diff --git a/crates/core/src/properties/axis_tests.rs b/crates/core/src/properties/axis_tests.rs index 09f5828b..26f86034 100644 --- a/crates/core/src/properties/axis_tests.rs +++ b/crates/core/src/properties/axis_tests.rs @@ -41,6 +41,8 @@ fn every_axis_and_guide_property_reset_clears_its_stored_override() { y_show_label: Some(false), x_thousands_separator: Some(true), y_thousands_separator: Some(true), + x_major_step: Some(2.0), + y_major_step: Some(2.0), axis_frame: Some(plotium_figure::AxisFrame::Open), x_scale: Some(plotium_figure::AxisScale::Linear), y_scale: Some(plotium_figure::AxisScale::Linear), @@ -80,6 +82,12 @@ fn every_axis_and_guide_property_reset_clears_its_stored_override() { (axis::Y_THOUSANDS_SEPARATOR, |value: &AxisOverrides| { value.y_thousands_separator.is_none() }), + (axis::X_MAJOR_STEP, |value: &AxisOverrides| { + value.x_major_step.is_none() + }), + (axis::Y_MAJOR_STEP, |value: &AxisOverrides| { + value.y_major_step.is_none() + }), (axis::FRAME, |value: &AxisOverrides| { value.axis_frame.is_none() }), @@ -489,3 +497,44 @@ fn thousands_separator_write_reaches_the_figure_and_undo_restores_plain_labels() assert_eq!(overrides(&app, object).y_thousands_separator, None); assert!(!figure_groups(&app)); } + +#[test] +fn major_step_write_reaches_the_figure_and_zero_returns_to_automatic() { + let (mut app, target, object) = axis_app(); + let figure_step = |app: &PlotiumApp| { + app.doc.canvases[0] + .object(object) + .and_then(|object| object.plot()) + .expect("plot") + .figure() + .y + .major_step + }; + let address = PropertyAddress::new(target.clone(), axis::Y_MAJOR_STEP); + let before = app.resolve_property(&address).expect("step reads"); + assert_eq!( + before.value, + AggregateValue::Uniform(PropertyValue::Float(0.0)) + ); + assert_eq!(before.modified, Some(false)); + assert_eq!(before.availability, Availability::Editable); + + let write = |app: &mut PlotiumApp, value: f64| { + app.plan_property_write( + axis::Y_MAJOR_STEP, + std::slice::from_ref(&target), + &PropertyValue::Float(value), + ) + }; + assert!(write(&mut app, -1.0).is_err()); + let commit = write(&mut app, 2.5).expect("step write plans"); + app.commit_property(commit); + assert_eq!(overrides(&app, object).y_major_step, Some(2.5)); + assert_eq!(figure_step(&app), Some(2.5)); + let commit = write(&mut app, 0.0).expect("zero plans"); + app.commit_property(commit); + assert_eq!(overrides(&app, object).y_major_step, None); + assert_eq!(figure_step(&app), None); + app.undo(); + assert_eq!(figure_step(&app), Some(2.5)); +} diff --git a/crates/core/src/properties/line_display.rs b/crates/core/src/properties/line_display.rs index fd179a98..0f2bf6af 100644 --- a/crates/core/src/properties/line_display.rs +++ b/crates/core/src/properties/line_display.rs @@ -14,6 +14,7 @@ pub const MARKER_FILL: PropertyId = PropertyId("series.line.marker_fill"); pub const MARKER_EDGE_COLOR: PropertyId = PropertyId("series.line.marker_edge_color"); pub const MARKER_EDGE_WIDTH: PropertyId = PropertyId("series.line.marker_edge_width"); pub const MARKER_HOLLOW: PropertyId = PropertyId("series.line.marker_hollow"); +pub const MARKER_EVERY: PropertyId = PropertyId("series.line.marker_every"); pub const BAR_WIDTH: PropertyId = PropertyId("series.bar.width"); pub const BAR_EDGE_COLOR: PropertyId = PropertyId("series.bar.edge_color"); pub const BAR_EDGE_WIDTH: PropertyId = PropertyId("series.bar.edge_width"); @@ -24,6 +25,7 @@ const Y_AXES: &[EnumVariant] = &[ EnumVariant::new(LEFT, "Left"), EnumVariant::new(RIGHT, "Right"), ]; +const MAX_MARKER_EVERY: i64 = 1000; const MARKER_EDGE_BOUNDS: FloatBounds = FloatBounds::inclusive(0., 5.); const BAR_WIDTH_BOUNDS: FloatBounds = FloatBounds::inclusive(0.05, 1.); const BAR_EDGE_BOUNDS: FloatBounds = FloatBounds::inclusive(0., 5.); @@ -91,6 +93,14 @@ const DEFINITIONS: &[PropertyDefinition] = &[ "Marker edge width", ), advanced(MARKER_HOLLOW, ValueSchema::Bool, "Hollow marker"), + advanced( + MARKER_EVERY, + ValueSchema::Int { + min: 1, + max: MAX_MARKER_EVERY, + }, + "Marker every N points", + ), advanced(Y_AXIS, ValueSchema::Enum { variants: Y_AXES }, "Y axis"), advanced( BAR_WIDTH, @@ -218,6 +228,11 @@ impl PropertyProvider for DisplayProvider { PropertyValue::Bool(false), None, ), + MARKER_EVERY => ( + PropertyValue::Int(line.appearance.marker_stride() as i64), + PropertyValue::Int(1), + Some(line.appearance.marker_every.is_some()), + ), Y_AXIS => ( PropertyValue::Enum(if line.appearance.y_axis.is_right() { RIGHT @@ -264,6 +279,7 @@ impl PropertyProvider for DisplayProvider { | MARKER_EDGE_COLOR | MARKER_EDGE_WIDTH | MARKER_HOLLOW + | MARKER_EVERY ); Ok(ResolvedProperty { address: address.clone(), @@ -472,6 +488,21 @@ impl PropertyProvider for DisplayProvider { }; } (MARKER_HOLLOW, PropertyValue::Bool(hollow)) => line.appearance.marker_hollow = hollow, + (MARKER_EVERY, PropertyValue::Int(n)) => { + if !(1..=MAX_MARKER_EVERY).contains(&n) { + return Err(PropertyError::InvalidValue { + property: d.id, + message: format!( + "marker spacing {n} is out of range: it must be between 1 and {MAX_MARKER_EVERY}" + ), + }); + } + // One is "every point", stored as no override. + line.appearance.marker_every = u32::try_from(n) + .ok() + .and_then(std::num::NonZeroU32::new) + .filter(|n| n.get() > 1); + } (Y_AXIS, PropertyValue::Enum(v)) => { line.appearance.y_axis = match v { LEFT => plotium_figure::YAxisSide::Left, diff --git a/crates/core/src/properties/plot_display.rs b/crates/core/src/properties/plot_display.rs index a68cdeb0..829608fc 100644 --- a/crates/core/src/properties/plot_display.rs +++ b/crates/core/src/properties/plot_display.rs @@ -376,11 +376,17 @@ impl PropertyProvider for PlotDisplayProvider { } Some(target) }; - if x { - overrides.x_display_unit = selected.map(Box::new); + let (unit, step) = if x { + (&mut overrides.x_display_unit, &mut overrides.x_major_step) } else { - overrides.y_display_unit = selected.map(Box::new); + (&mut overrides.y_display_unit, &mut overrides.y_major_step) + }; + let selected = selected.map(Box::new); + // A major step is written in the display unit it replaces. + if *unit != selected { + *step = None; } + *unit = selected; } (X_RANGE | Y_RANGE, PropertyValue::AxisRange(value)) => { let range = match value { diff --git a/crates/core/src/properties/right_axis.rs b/crates/core/src/properties/right_axis.rs index f1791dd0..dcbe3544 100644 --- a/crates/core/src/properties/right_axis.rs +++ b/crates/core/src/properties/right_axis.rs @@ -1,5 +1,6 @@ -//! Right y axis title, visibility, scale, range and display unit. Every -//! property applies only while some curve of the plot is on the right axis. +//! Right y axis title, visibility, scale, range, display unit and major tick +//! step. Every property applies only while some curve of the plot is on the +//! right axis. use super::provider::PropertyProvider; use super::target::require_plot_object_target; use super::*; @@ -13,6 +14,7 @@ pub const Y2_SHOW_LABEL: PropertyId = PropertyId("object.axes.y2_show_label"); pub const Y2_SCALE: PropertyId = PropertyId("object.axes.y2_scale"); pub const Y2_RANGE: PropertyId = PropertyId("object.axes.y2_range"); pub const Y2_UNIT: PropertyId = PropertyId("object.axes.y2_display_unit"); +pub const Y2_MAJOR_STEP: PropertyId = PropertyId("object.axes.y2_major_step"); const SCALES: &[EnumVariant] = &[ EnumVariant::new(super::axis::LINEAR, "Linear"), @@ -83,6 +85,13 @@ const DEFINITIONS: &[PropertyDefinition] = &[ &["y2 unit"], Tier::Advanced, ), + def( + Y2_MAJOR_STEP, + super::axis::MAJOR_STEP_SCHEMA, + "Right y major tick step", + &["y2 tick spacing", "right tick interval"], + Tier::Advanced, + ), ]; pub(crate) struct RightAxisProvider; @@ -240,6 +249,16 @@ impl PropertyProvider for RightAxisProvider { overrides.y2_display_unit.is_some(), ) } + Y2_MAJOR_STEP => { + if let Some(reason) = super::axis::major_step_unavailable(plot.figure(), axis) { + availability = Availability::Disabled(reason); + } + ( + super::axis::major_step_value(axis.major_step), + super::axis::major_step_value(None), + overrides.y2_major_step.is_some(), + ) + } _ => return Err(PropertyError::UnknownProperty(d.id.to_string())), }; Ok(ResolvedProperty { @@ -302,7 +321,12 @@ impl PropertyProvider for RightAxisProvider { (Y2_RANGE, PropertyValue::AxisRange(value)) => { overrides.y2_range = manual_range(d.id, value, &coordinate, &shown, overrides)?; } + (Y2_MAJOR_STEP, PropertyValue::Float(step)) => { + super::axis::checked_major_step(d.id, step)?; + overrides.y2_major_step = (!reset && step > 0.0).then_some(step); + } (Y2_UNIT, PropertyValue::Text(code)) => { + let before = overrides.y2_display_unit.clone(); overrides.y2_display_unit = if reset { None } else { @@ -319,6 +343,10 @@ impl PropertyProvider for RightAxisProvider { } Some(Box::new(target)) }; + // A major step is written in the display unit it replaces. + if overrides.y2_display_unit != before { + overrides.y2_major_step = None; + } } _ => return Err(error(d.id, "Value does not match the property schema.")), } diff --git a/crates/core/src/properties/transaction_shared_x.rs b/crates/core/src/properties/transaction_shared_x.rs index 027f99a1..33c2dd79 100644 --- a/crates/core/src/properties/transaction_shared_x.rs +++ b/crates/core/src/properties/transaction_shared_x.rs @@ -192,5 +192,20 @@ fn copy_into( target.x_thousands_separator = after.x_thousands_separator; changed.push(axis::X_THOUSANDS_SEPARATOR); } + if before.x_major_step != after.x_major_step { + let shown = |display: &Option>, unit| { + display.as_deref().cloned().or_else(|| Option::clone(unit)) + }; + target.x_major_step = crate::state::shared_x_step( + after.x_major_step, + &shown(&after.x_display_unit, source_unit), + &shown(&target.x_display_unit, &unit), + ) + .map_err(|message| PropertyError::InvalidValue { + property: axis::X_MAJOR_STEP, + message, + })?; + changed.push(axis::X_MAJOR_STEP); + } Ok(changed) } diff --git a/crates/core/src/state/axis_overrides.rs b/crates/core/src/state/axis_overrides.rs index 24c3fb37..22c15def 100644 --- a/crates/core/src/state/axis_overrides.rs +++ b/crates/core/src/state/axis_overrides.rs @@ -240,6 +240,9 @@ pub struct AxisOverrides { pub y_show_label: Option, pub x_thousands_separator: Option, pub y_thousands_separator: Option, + /// Major tick steps in each axis's display unit. + pub x_major_step: Option, + pub y_major_step: Option, pub guide_visibility: Option, pub guide_placement: Option, pub guide_layout: Option, @@ -255,6 +258,7 @@ pub struct AxisOverrides { pub y2_scale: Option, pub y2_show_tick_labels: Option, pub y2_show_label: Option, + pub y2_major_step: Option, } impl AxisOverrides { @@ -272,6 +276,7 @@ impl AxisOverrides { &mut self.x_thousands_separator, &mut self.y_thousands_separator, ); + std::mem::swap(&mut self.x_major_step, &mut self.y_major_step); let annotations = &mut self.annotations; for item in &mut annotations.items { item.at.swap(0, 1); @@ -351,6 +356,8 @@ impl AxisOverrides { if let Some(group) = self.y_thousands_separator { figure.y.thousands_separator = group; } + figure.x.major_step = self.x_major_step; + figure.y.major_step = self.y_major_step; if let Some(visibility) = self.guide_visibility { figure.guide_visibility = visibility; } diff --git a/crates/core/src/state/display_marker_tests.rs b/crates/core/src/state/display_marker_tests.rs index 8ea74dcf..cd05f4be 100644 --- a/crates/core/src/state/display_marker_tests.rs +++ b/crates/core/src/state/display_marker_tests.rs @@ -271,3 +271,51 @@ fn hand_chosen_colours_lock_the_curve_against_rethemes() { ); assert!(line(&app, 0).color_locked); } + +#[test] +fn marker_every_is_set_per_curve_and_one_marks_every_point() { + let mut app = fixture(); + let a = target(&app, 0, 0); + assert_eq!( + value(&app, &a, line_display::MARKER_EVERY), + PropertyValue::Int(1) + ); + let p = plan_tool( + &app, + request(&app, "series.line.marker_every", json!(5), vec![a.clone()]), + ) + .unwrap(); + execute_tool(&mut app, p, ExecutionAuthority::ReversibleModify).unwrap(); + assert_eq!(appearance(&app, 0).marker_stride(), 5); + assert_eq!(appearance(&app, 1).marker_every, None); + assert_eq!( + plot(&app, 0).figure().series[0].appearance.marker_stride(), + 5 + ); + + let path = std::env::temp_dir().join(format!( + "plotium-marker-every-{}.plotium", + uuid::Uuid::new_v4() + )); + crate::project::save_project(&app, &path, false).unwrap(); + let loaded = crate::project::load_project(&path).unwrap(); + std::fs::remove_file(path).unwrap(); + assert_eq!(appearance(&loaded, 0).marker_stride(), 5); + + for rejected in [json!(0), json!(1001)] { + assert!(matches!( + plan_tool( + &app, + request(&app, "series.line.marker_every", rejected, vec![a.clone()]) + ), + Err(AutomationError::InvalidParameters { .. }) + )); + } + write( + &mut app, + line_display::MARKER_EVERY, + PropertyValue::Int(1), + std::slice::from_ref(&a), + ); + assert_eq!(appearance(&app, 0).marker_every, None); +} diff --git a/crates/core/src/state/mod.rs b/crates/core/src/state/mod.rs index 17aadb72..38049394 100644 --- a/crates/core/src/state/mod.rs +++ b/crates/core/src/state/mod.rs @@ -153,7 +153,7 @@ mod shared_x_stack; pub use curve_binding::*; pub use curve_extract::*; pub use curve_service::*; -pub(crate) use shared_x_stack::shared_x_value; +pub(crate) use shared_x_stack::{shared_x_step, shared_x_value}; mod size_presets; mod stack; mod statistics; diff --git a/crates/core/src/state/right_axis.rs b/crates/core/src/state/right_axis.rs index 639f87ae..ad9a8337 100644 --- a/crates/core/src/state/right_axis.rs +++ b/crates/core/src/state/right_axis.rs @@ -61,17 +61,20 @@ impl AxisOverrides { if let Some(show) = self.y2_show_label { axis.show_label = show; } + axis.major_step = self.y2_major_step; } - /// Drop the range and display unit of the y axis on `side`, which were - /// written in a coordinate unit that its first curve has just replaced. + /// Drop the range, display unit and major step of the y axis on `side`, + /// which were written in a unit that its first curve has just replaced. pub(crate) fn forget_axis_units(&mut self, side: YAxisSide) { if side.is_right() { self.y2_range = None; self.y2_display_unit = None; + self.y2_major_step = None; } else { self.y_range = None; self.y_display_unit = None; + self.y_major_step = None; } } @@ -83,6 +86,7 @@ impl AxisOverrides { || self.y2_scale != other.y2_scale || self.y2_show_tick_labels != other.y2_show_tick_labels || self.y2_show_label != other.y2_show_label + || self.y2_major_step != other.y2_major_step } } diff --git a/crates/core/src/state/right_axis_tests.rs b/crates/core/src/state/right_axis_tests.rs index 8cadfd28..b573394b 100644 --- a/crates/core/src/state/right_axis_tests.rs +++ b/crates/core/src/state/right_axis_tests.rs @@ -466,6 +466,7 @@ fn every_right_axis_property_reports_its_default_on_a_two_axis_plot() { right_axis::Y2_SCALE, right_axis::Y2_RANGE, right_axis::Y2_UNIT, + right_axis::Y2_MAJOR_STEP, ] { let resolved = app .resolve_property(&PropertyAddress::new(object.clone(), id)) @@ -573,3 +574,32 @@ fn a_dormant_right_display_unit_passes_display_validation() { Ok(()) ); } + +#[test] +fn a_right_major_step_is_cleared_by_a_new_display_unit() { + let mut app = table_app(); + let curves = vec![ + curve(&app, CurveKind::Line, 1, YAxisSide::Left), + curve(&app, CurveKind::Line, 2, YAxisSide::Right), + ]; + create(&mut app, curves).unwrap(); + let object = app + .object_target(0, app.doc.canvases[0].objects[0].id) + .unwrap(); + let step = |app: &PlotiumApp| plot(app).figure().y2.as_ref().unwrap().major_step; + write( + &mut app, + object.clone(), + right_axis::Y2_MAJOR_STEP, + PropertyValue::Float(2.0), + ); + assert_eq!(step(&app), Some(2.0)); + write( + &mut app, + object, + right_axis::Y2_UNIT, + PropertyValue::Text("kHz".into()), + ); + assert_eq!(step(&app), None); + assert_eq!(plot(&app).axis_overrides.y2_major_step, None); +} diff --git a/crates/core/src/state/shared_x_stack.rs b/crates/core/src/state/shared_x_stack.rs index 5ed94587..a1db8c1d 100644 --- a/crates/core/src/state/shared_x_stack.rs +++ b/crates/core/src/state/shared_x_stack.rs @@ -234,6 +234,20 @@ pub(crate) fn shared_x_value( super::convert_curve_value(value, from, to, false) } +/// A member's x major step, written in the unit `from` it displays, as a +/// step in the unit `to` another member displays; it converts as a length. +pub(crate) fn shared_x_step( + step: Option, + from: &Option, + to: &Option, +) -> Result, String> { + let Some(step) = step else { + return Ok(None); + }; + let converted = shared_x_value(step, from, to)? - shared_x_value(0.0, from, to)?; + Ok((converted.is_finite() && converted > 0.0).then_some(converted)) +} + impl CanvasDocument { /// Give the numeric-x plots of a stack one x window: the union of their /// current windows as a manual x range, written in each plot's own @@ -259,18 +273,30 @@ impl CanvasDocument { .coordinates .as_ref() .and_then(|c| c.horizontal().cloned()); + let shown = plot + .axis_overrides + .x_display_unit + .as_deref() + .cloned() + .or_else(|| unit.clone()); ( id, unit, plot.viewport.full_x, plot.axis_overrides.x_scale, plot.axis_overrides.x_break, + plot.axis_overrides.x_major_step, + shown, ) }, ) }) .collect(); - let [(_, reference, _, scale, reference_break), ..] = members.as_slice() else { + let [ + (_, reference, _, scale, reference_break, reference_step, reference_shown), + .., + ] = members.as_slice() + else { return Ok(()); }; if members.len() < 2 { @@ -281,7 +307,7 @@ impl CanvasDocument { }; if members .iter() - .any(|(_, _, _, other, _)| linear(other) != linear(scale)) + .any(|(_, _, _, other, ..)| linear(other) != linear(scale)) { return Err( "Give the plots the same x scale (linear or logarithmic) before stacking them on a shared x axis." @@ -291,7 +317,7 @@ impl CanvasDocument { let incompatible = |error: String| format!("Plots in a shared-x stack need compatible x units: {error}"); let mut union: Option = None; - for (_, unit, full, _, _) in &members { + for (_, unit, full, ..) in &members { let min = shared_x_value(full.min, unit, reference).map_err(incompatible)?; let max = shared_x_value(full.max, unit, reference).map_err(incompatible)?; let range = AxisRange::new(min, max); @@ -310,7 +336,8 @@ impl CanvasDocument { } let reference = reference.clone(); let reference_break = *reference_break; - for (id, unit, _, _, _) in members { + let (reference_step, reference_shown) = (*reference_step, reference_shown.clone()); + for (id, unit, _, _, _, _, shown) in members { let min = shared_x_value(union.min, &reference, &unit).map_err(incompatible)?; let max = shared_x_value(union.max, &reference, &unit).map_err(incompatible)?; if let Some(plot) = self.object_mut(id).and_then(|item| item.plot_mut()) { @@ -321,7 +348,8 @@ impl CanvasDocument { plot.viewport.full_x = range; plot.viewport.view_x = range; } - // Members share one x mapping, so they take the first plot's break. + // Members share one x mapping, so they take the first plot's break + // and major step. let x_break = reference_break .map(|brk| { Ok::<_, String>(plotium_figure::AxisBreak { @@ -331,7 +359,10 @@ impl CanvasDocument { }) }) .transpose()?; + let step = + shared_x_step(reference_step, &reference_shown, &shown).map_err(incompatible)?; if let Some(plot) = self.object_mut(id).and_then(|item| item.plot_mut()) { + plot.axis_overrides.x_major_step = step; plot.axis_overrides.x_break = x_break; } } @@ -347,6 +378,7 @@ impl AxisOverrides { || self.x_break != other.x_break || self.x_display_unit != other.x_display_unit || self.x_thousands_separator != other.x_thousands_separator + || self.x_major_step != other.x_major_step } } diff --git a/crates/core/src/state/shared_x_stack_review_tests.rs b/crates/core/src/state/shared_x_stack_review_tests.rs index 6e8bd964..d8f7e8ff 100644 --- a/crates/core/src/state/shared_x_stack_review_tests.rs +++ b/crates/core/src/state/shared_x_stack_review_tests.rs @@ -247,3 +247,91 @@ fn outside_legends_line_up_beside_the_widest_right_axis() { assert!(legend.right() <= width, "{legend:?} overflows {width}"); } } + +#[test] +fn a_major_step_reaches_every_member_in_its_own_x_unit() { + let (mut app, _, ids) = stacked(vec![ + table(0, 1, "plotium.test.stack-step-a.v1"), + table(0, 500, "plotium.test.stack-step-b.v1"), + ]); + let step = |app: &PlotiumApp, id: ObjectId| { + app.doc.canvases[0] + .object(id) + .and_then(|item| item.plot()) + .expect("plot") + .figure() + .x + .major_step + }; + // Members whose curves carry different but compatible x units. + let registry = plotium_data::UnitRegistry::plotium_v1(); + for (id, unit) in ids.iter().zip(["A", "mA"]) { + let plot = app.doc.canvases[0] + .object_mut(*id) + .and_then(|item| item.plot_mut()) + .expect("plot"); + plot.binding.coordinates = Some(crate::state::CurveCoordinates { + x: Some(registry.resolve(unit).expect("unit")), + y: None, + y2: None, + transposed: false, + }); + } + let target = app.object_target(0, ids[1]).expect("bottom plot"); + let commit = app + .plan_property_write( + axis::X_MAJOR_STEP, + std::slice::from_ref(&target), + &PropertyValue::Float(50.0), + ) + .expect("shared step"); + assert_eq!(commit.applied.len(), 2, "the peer edit is reported"); + app.commit_property(commit); + assert_eq!(step(&app, ids[1]), Some(50.0)); + let upper = step(&app, ids[0]).expect("peer step"); + assert!((upper - 0.05).abs() < 1e-12, "{upper}"); + app.undo(); + assert_eq!((step(&app, ids[0]), step(&app, ids[1])), (None, None)); +} + +#[test] +fn stacking_converts_the_top_major_step_from_its_display_unit() { + let (mut app, panel, ids) = stacked(vec![ + table(0, 1, "plotium.test.stack-step-c.v1"), + table(0, 1, "plotium.test.stack-step-d.v1"), + ]); + let registry = plotium_data::UnitRegistry::plotium_v1(); + let amp = registry.resolve("A").expect("unit"); + for (index, id) in ids.iter().enumerate() { + let plot = app.doc.canvases[0] + .object_mut(*id) + .and_then(|item| item.plot_mut()) + .expect("plot"); + plot.binding.coordinates = Some(crate::state::CurveCoordinates { + x: Some(amp.clone()), + y: None, + y2: None, + transposed: false, + }); + // The top plot shows its x in mA and steps 50 mA. + if index == 0 { + plot.axis_overrides.x_display_unit = + Some(Box::new(registry.resolve("mA").expect("unit"))); + plot.axis_overrides.x_major_step = Some(50.0); + } + } + app.doc.canvases[0] + .unify_shared_x_window(panel) + .expect("unify"); + let step = |id: ObjectId| { + app.doc.canvases[0] + .object(id) + .and_then(|item| item.plot()) + .expect("plot") + .axis_overrides + .x_major_step + }; + assert_eq!(step(ids[0]), Some(50.0)); + let lower = step(ids[1]).expect("lower step"); + assert!((lower - 0.05).abs() < 1e-12, "{lower}"); +} diff --git a/crates/figure/src/display.rs b/crates/figure/src/display.rs index cc5089d9..f4fa9431 100644 --- a/crates/figure/src/display.rs +++ b/crates/figure/src/display.rs @@ -62,6 +62,9 @@ pub struct CurveAppearance { pub marker_edge_width: Option, /// Closed markers draw only their outline. pub marker_hollow: bool, + /// Markers sit on every n-th source point, starting with the first. No + /// override marks every point. + pub marker_every: Option, /// Only line and scatter curves may be measured on the right axis. pub y_axis: YAxisSide, } @@ -73,6 +76,10 @@ impl CurveAppearance { Marker::None }) } + /// Source-point stride between drawn markers. + pub fn marker_stride(self) -> usize { + self.marker_every.map_or(1, |n| n.get() as usize) + } pub fn marker_radius(self, width: f32) -> f32 { self.marker_size.map_or(width, |size| size.get() * 0.5) } diff --git a/crates/figure/src/lib.rs b/crates/figure/src/lib.rs index 896ef5db..7e9e49af 100644 --- a/crates/figure/src/lib.rs +++ b/crates/figure/src/lib.rs @@ -206,6 +206,10 @@ pub struct Axis { /// values are then written in full instead of with a ×10ⁿ multiplier. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub thousands_separator: bool, + /// Distance between major ticks in the display unit; `None` chooses one. + /// Only linear numeric axes without a break honour it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub major_step: Option, pub min: f64, pub max: f64, /// If true, larger values draw toward the lower screen coordinate (left for @@ -233,6 +237,7 @@ impl Axis { show_tick_labels: true, show_label: true, thousands_separator: false, + major_step: None, min, max, reversed: false, @@ -252,6 +257,7 @@ impl Axis { show_tick_labels: true, show_label: true, thousands_separator: false, + major_step: None, min: -0.5, max: n - 0.5, reversed: false, diff --git a/crates/render/src/diagnostics/curves.rs b/crates/render/src/diagnostics/curves.rs index b0a60ace..d2bece4f 100644 --- a/crates/render/src/diagnostics/curves.rs +++ b/crates/render/src/diagnostics/curves.rs @@ -63,7 +63,7 @@ pub fn scan_curve( continue; } let marker = series.appearance.marker(series.kind); - if marker != Marker::None { + if marker != Marker::None && i.is_multiple_of(series.appearance.marker_stride()) { let radius = series.appearance.marker_radius(series.width); if matches!(marker, Marker::Plus | Marker::Cross) && series.width <= 0. { continue; diff --git a/crates/render/src/diagnostics/text.rs b/crates/render/src/diagnostics/text.rs index 6a490f67..c0b85bf1 100644 --- a/crates/render/src/diagnostics/text.rs +++ b/crates/render/src/diagnostics/text.rs @@ -75,7 +75,7 @@ pub fn figure_text_boxes_placed( "title", if xf.show_title { &fig.title } else { "" }, size[0] * 0.5, - 0.0, + crate::rich_text::script_overshoot(&fig.title, ty.title_pt).0, ty.title_pt, 0.5, false, @@ -158,7 +158,10 @@ pub fn figure_text_boxes_placed( "axis.x.label", &fig.x.label, (plot.left + plot.right()) * 0.5, - x_lane_bottom - clearance - ty.label_pt, + x_lane_bottom + - clearance + - ty.label_pt + - crate::rich_text::script_overshoot(&fig.x.label, ty.label_pt).1, ty.label_pt, 0.5, false, @@ -169,7 +172,7 @@ pub fn figure_text_boxes_placed( add( "axis.y.label", &fig.y.label, - y_title_left + ty.label_pt * 0.5, + y_title_left + crate::y_title::rise(fig) + ty.label_pt * 0.5, y_title_cy, ty.label_pt, 0.5, @@ -301,14 +304,10 @@ pub fn annotation_text_boxes( crate::label_placement::drawn_texts(fig, plot, &labels, 1.) .filter(|(a, _)| !a.text.trim().is_empty()) .map(|(a, at)| { - let w = a - .text - .lines() - .map(|line| estimated_text_width(line, a.size)) - .fold(0., f32::max); + let (w, h) = crate::label_placement::text_extent(&a.text, a.size); TextBox { component: "annotation".into(), - bounds: crate::text_place::bounds(at, w, a.size), + bounds: crate::text_place::bounds(at, w, h), container: None, } }) diff --git a/crates/render/src/display_tests.rs b/crates/render/src/display_tests.rs index 9b800204..76b8caa1 100644 --- a/crates/render/src/display_tests.rs +++ b/crates/render/src/display_tests.rs @@ -204,3 +204,70 @@ fn star_pentagon_and_hexagon_point_up_inside_the_shared_envelope() { .expect("a star marker exports as an SVG polygon"); assert_eq!(star.split(' ').count(), 10, "{svg}"); } +#[test] +fn marker_every_n_draws_every_nth_point_from_the_first() { + let mut figure = Figure::new("", Axis::new("x", 0., 10.), Axis::new("y", 0., 10.)); + figure.series.push(Series::points( + "A", + (0..10).map(|i| [f64::from(i), 1.]).collect(), + )); + let centers = |svg: &str| -> Vec { + svg.split(", series: &Series) { if marker != Marker::None { let paint = MarkerPaint::new(series.appearance, series.color, series.width); let r = series.appearance.marker_radius(series.width); - for p in points.into_iter().filter(|p| crate::is_finite_point(*p)) { + for p in points + .into_iter() + .step_by(series.appearance.marker_stride()) + .filter(|p| crate::is_finite_point(*p)) + { marker_at(dc, p, marker, r, &paint); } } diff --git a/crates/render/src/label_placement.rs b/crates/render/src/label_placement.rs index 403c7955..f33b6ff5 100644 --- a/crates/render/src/label_placement.rs +++ b/crates/render/src/label_placement.rs @@ -202,7 +202,8 @@ pub(crate) fn drawn_texts<'a>( ) -> impl Iterator + 'a { let builder = fig.annotations.iter().filter_map(move |a| { let p = project(fig, plot, a.at, YAxisSide::Left); - crate::is_finite_point(p).then(|| (a, crate::text_place::place(a.anchor, p, a.size, scale))) + let (_, height) = text_extent(&a.text, a.size); + crate::is_finite_point(p).then(|| (a, crate::text_place::place(a.anchor, p, height, scale))) }); let author = fig .author_annotations @@ -229,7 +230,10 @@ pub(crate) fn text_extent(text: &str, size: f32) -> (f32, f32) { .lines() .map(|line| crate::ticks::estimated_text_width(line, size)) .fold(0.0, f32::max); - (width, size * text.lines().count().max(1) as f32) + ( + width, + crate::text_place::block_height(text.lines().count(), size), + ) } fn reference_candidates( @@ -461,9 +465,9 @@ impl Obstacles { .filter(|a| !a.text.trim().is_empty()) .filter_map(|a| { let p = placed(a.at)?; - let (width, _) = text_extent(&a.text, a.size); - let at = crate::text_place::place(a.anchor, p, a.size, 1.0); - Some(crate::text_place::bounds(at, width, a.size)) + let (width, height) = text_extent(&a.text, a.size); + let at = crate::text_place::place(a.anchor, p, height, 1.0); + Some(crate::text_place::bounds(at, width, height)) }), ); Self { diff --git a/crates/render/src/label_placement_tests.rs b/crates/render/src/label_placement_tests.rs index a1a4d9a5..64608481 100644 --- a/crates/render/src/label_placement_tests.rs +++ b/crates/render/src/label_placement_tests.rs @@ -292,3 +292,43 @@ fn arrows_push_reference_labels_along_their_line() { let label = label_layout(&f, plot, 1.0).reference[0].expect("label"); assert_ne!(label.position, clear.position); } + +#[test] +fn multi_line_texts_stack_upward_from_the_placed_baseline() { + let mut f = figure(vec![]); + f.author_annotations + .push(text([5.0, 5.0], "3.2 mAh g^{-1}\nICE = 97.9%")); + let label = text_label(&f, 0); + assert!((label.bounds.height - 8.0 * 2.25).abs() < 1e-4); + assert!((label.bounds.bottom() - label.y).abs() < 1e-4); + let svg = crate::svg::export(&f); + let ys: Vec = svg + .split(" Vec { out } +/// How far the scripts of `text` at `font_size` reach past its line box: a +/// raised run above the top and a lowered run below the bottom. +pub fn script_overshoot(text: &str, font_size: f32) -> (f32, f32) { + if !has_scripts(text) { + return (0.0, 0.0); + } + let runs = runs(text); + let has = |script| runs.iter().any(|run| run.script == script); + let above = SUPER_RISE + SCRIPT_SCALE * TOP_TO_BASELINE - TOP_TO_BASELINE; + let below = SUB_DROP + SCRIPT_SCALE * BASELINE_TO_BOTTOM - BASELINE_TO_BOTTOM; + ( + if has(Script::Super) { + above.max(0.0) * font_size + } else { + 0.0 + }, + if has(Script::Sub) { + below.max(0.0) * font_size + } else { + 0.0 + }, + ) +} + +/// Distance from a top edge down to the baseline of `text` set against it, +/// with its superscripts inside the edge. +pub(crate) fn below_top(text: &str, font_size: f32) -> f32 { + font_size * TOP_TO_BASELINE + script_overshoot(text, font_size).0 +} + +/// Distance from a bottom edge up to the baseline of `text` set against it, +/// with its subscripts inside the edge. +pub(crate) fn above_bottom(text: &str, font_size: f32) -> f32 { + font_size * BASELINE_TO_BOTTOM + script_overshoot(text, font_size).1 +} + /// `text` with markup removed and scripts flattened, for plain-text sinks. pub fn plain(text: &str) -> String { runs(text).into_iter().map(|run| run.text).collect() diff --git a/crates/render/src/screen.rs b/crates/render/src/screen.rs index 1b05eb75..87b9c8ec 100644 --- a/crates/render/src/screen.rs +++ b/crates/render/src/screen.rs @@ -139,7 +139,10 @@ fn paint_with_detail_and_stats_impl( painter, Pos2::new( frame.left + frame.width / 2.0, - frame.top + ty.title_pt * 0.5 * scale, + frame.top + + (ty.title_pt * 0.5 + + crate::rich_text::script_overshoot(&fig.title, ty.title_pt).0) + * scale, ), Align2::CENTER_CENTER, &fig.title, @@ -281,7 +284,11 @@ fn paint_with_detail_and_stats_impl( painter, Pos2::new( (plot.left + plot.right()) / 2.0, - x_lane_bottom - (multiplier_clearance + ty.label_pt * 0.5) * scale, + x_lane_bottom + - (multiplier_clearance + + ty.label_pt * 0.5 + + crate::rich_text::script_overshoot(&fig.x.label, ty.label_pt).1) + * scale, ), Align2::CENTER_CENTER, &fig.x.label, @@ -303,7 +310,10 @@ fn paint_with_detail_and_stats_impl( col(Color::AXIS), ) .with_angle_and_anchor(-std::f32::consts::FRAC_PI_2, Align2::CENTER_CENTER); - y_label.pos += Vec2::new(y_title_left + ty.label_pt * 0.5 * scale, y_title_cy); + y_label.pos += Vec2::new( + y_title_left + (crate::y_title::rise(fig) + ty.label_pt * 0.5) * scale, + y_title_cy, + ); painter.add(y_label); } @@ -404,14 +414,16 @@ fn paint_with_detail_and_stats_impl( (crate::text_place::TextAlign::Start, _) => Align2::LEFT_CENTER, (crate::text_place::TextAlign::End, _) => Align2::RIGHT_CENTER, }; - crate::rich_text::paint( - &clipped, - Pos2::new(at.x, at.y), - align, - &a.text, - crate::rich_text::plot_font(a.size * scale, &ty), - col(a.color), - ); + for (line, at) in crate::text_place::lines(at, &a.text, a.size * scale) { + crate::rich_text::paint( + &clipped, + Pos2::new(at.x, at.y), + align, + line, + crate::rich_text::plot_font(a.size * scale, &ty), + col(a.color), + ); + } } // Spines go over the clipped data, so a bar or trace resting on an axis diff --git a/crates/render/src/screen/curves.rs b/crates/render/src/screen/curves.rs index 839a58c9..547b3809 100644 --- a/crates/render/src/screen/curves.rs +++ b/crates/render/src/screen/curves.rs @@ -43,7 +43,11 @@ pub(super) fn paint( if marker != Marker::None { let r = series.appearance.marker_radius(series.width) * scale; let paint = MarkerPaint::new(series.appearance, series.color, series.width).scaled(scale); - for p in &series.points { + for p in series + .points + .iter() + .step_by(series.appearance.marker_stride()) + { let p = proj.project(*p); if crate::is_finite_point(p) { marker_at(painter, p, marker, r, &paint); diff --git a/crates/render/src/svg.rs b/crates/render/src/svg.rs index e7faef38..5ff631ba 100644 --- a/crates/render/src/svg.rs +++ b/crates/render/src/svg.rs @@ -126,7 +126,7 @@ fn write_figure( s, r#"{title}"#, cx = frame.left + w / 2.0, - y = frame.top + ty.title_pt * crate::rich_text::TOP_TO_BASELINE, + y = frame.top + crate::rich_text::below_top(&fig.title, ty.title_pt), font = ty.title_pt, col = plotium_figure::Color::BLACK.to_hex(), title = svg_content(&fig.title, ty.title_pt), @@ -272,7 +272,7 @@ fn write_figure( cx = (plot.left + plot.right()) / 2.0, y = x_lane_bottom - multiplier_clearance - - ty.label_pt * crate::rich_text::BASELINE_TO_BOTTOM, + - crate::rich_text::above_bottom(&fig.x.label, ty.label_pt), font = ty.label_pt, lab = svg_content(&fig.x.label, ty.label_pt), ); @@ -283,7 +283,7 @@ fn write_figure( let _ = write!( s, r#"{lab}"#, - x = y_title_left + ty.label_pt * crate::rich_text::TOP_TO_BASELINE, + x = y_title_left + crate::rich_text::below_top(&fig.y.label, ty.label_pt), cy = y_title_cy, font = ty.label_pt, lab = svg_content(&fig.y.label, ty.label_pt), @@ -439,15 +439,17 @@ fn write_figure( } else { "" }; - let _ = write!( - s, - r#"{txt}"#, - x = at.x, - y = at.y, - sz = a.size, - col = a.color.to_hex(), - txt = svg_content(&a.text, a.size), - ); + for (line, at) in crate::text_place::lines(at, &a.text, a.size) { + let _ = write!( + s, + r#"{txt}"#, + x = at.x, + y = at.y, + sz = a.size, + col = a.color.to_hex(), + txt = svg_content(line, a.size), + ); + } } let _ = write!(s, r#""#); // Spines go over the clipped data, so a bar or trace resting on an axis diff --git a/crates/render/src/svg/curves.rs b/crates/render/src/svg/curves.rs index aa616522..ea53afbb 100644 --- a/crates/render/src/svg/curves.rs +++ b/crates/render/src/svg/curves.rs @@ -40,6 +40,7 @@ pub(super) fn write( let mut points: Vec<_> = series .points .iter() + .step_by(series.appearance.marker_stride()) .map(|p| proj.project(*p)) .filter(|p| crate::is_finite_point(*p)) .collect(); diff --git a/crates/render/src/text_place.rs b/crates/render/src/text_place.rs index d76bbdaf..0887ce34 100644 --- a/crates/render/src/text_place.rs +++ b/crates/render/src/text_place.rs @@ -19,14 +19,22 @@ pub(crate) struct PlacedText { pub middle: bool, } -/// Where to draw an annotation of `size` points anchored at the projected -/// point. `scale` converts points to output units. -pub(crate) fn place(anchor: TextAnchor, (x, y): (f32, f32), size: f32, scale: f32) -> PlacedText { +/// Distance between the baselines of a multi-line text, in ems. +pub(crate) const LINE_PITCH: f32 = 1.25; + +/// Height of a text block of `lines` lines at `size`. +pub(crate) fn block_height(lines: usize, size: f32) -> f32 { + size * (1.0 + LINE_PITCH * lines.saturating_sub(1) as f32) +} + +/// Where to draw an annotation of `size` points and `height` points tall, +/// anchored at the projected point. `scale` converts points to output units. +pub(crate) fn place(anchor: TextAnchor, (x, y): (f32, f32), height: f32, scale: f32) -> PlacedText { let gap = TextAnchor::GAP_PT * scale; let (x, y, align, middle) = match anchor { TextAnchor::Point => (x, y, TextAlign::Center, false), TextAnchor::Above => (x, y - gap, TextAlign::Center, false), - TextAnchor::Below => (x, y + gap + 0.5 * size * scale, TextAlign::Center, true), + TextAnchor::Below => (x, y + gap + 0.5 * height * scale, TextAlign::Center, true), TextAnchor::Left => (x - gap, y, TextAlign::End, true), TextAnchor::Right => (x + gap, y, TextAlign::Start, true), }; @@ -38,20 +46,45 @@ pub(crate) fn place(anchor: TextAnchor, (x, y): (f32, f32), size: f32, scale: f3 } } -/// Box of a placed text `width` wide and one `size` high. -pub(crate) fn bounds(at: PlacedText, width: f32, size: f32) -> crate::Rect { +/// Each line of `text` at `size` output units placed at `at`: a block sits +/// on its last baseline, or a `middle` block is centred on `at.y`. +pub(crate) fn lines( + at: PlacedText, + text: &str, + size: f32, +) -> impl Iterator { + let count = text.lines().count(); + let pitch = LINE_PITCH * size; + let first = if at.middle { + at.y - 0.5 * pitch * count.saturating_sub(1) as f32 + } else { + at.y - pitch * count.saturating_sub(1) as f32 + }; + text.lines().enumerate().map(move |(index, line)| { + ( + line, + PlacedText { + y: first + pitch * index as f32, + ..at + }, + ) + }) +} + +/// Box of a placed text `width` wide and `height` high. +pub(crate) fn bounds(at: PlacedText, width: f32, height: f32) -> crate::Rect { let left = match at.align { TextAlign::Center => at.x - 0.5 * width, TextAlign::Start => at.x, TextAlign::End => at.x - width, }; - // A baseline-placed text rises one font size above its baseline. + // A baseline-placed block rises its whole height above its last baseline. let top = if at.middle { - at.y - 0.5 * size + at.y - 0.5 * height } else { - at.y - size + at.y - height }; - crate::Rect::new(left, top, width, size) + crate::Rect::new(left, top, width, height) } #[cfg(test)] diff --git a/crates/render/src/ticks.rs b/crates/render/src/ticks.rs index e5c76a7a..9e33e890 100644 --- a/crates/render/src/ticks.rs +++ b/crates/render/src/ticks.rs @@ -13,6 +13,9 @@ const Y_TICK_SPACING_FACTOR: f32 = 1.8; /// Closest label centres a short panel accepts: one label height plus a /// small gap, so the labels never touch. const Y_TICK_MIN_SPACING_FACTOR: f32 = 1.2; +/// Most ticks a set major step may draw; a denser step falls back to +/// automatic ticks. +const MAX_FIXED_TICKS: usize = 100; /// f64 represents every integer below 2⁵³ ≈ 9×10¹⁵ exactly. const GROUPED_FULL_EXPONENT_LIMIT: i32 = 15; @@ -148,6 +151,9 @@ fn adaptive_x_ticks(fig: &Figure, plot_width: f32, gap: f32, widest: f32) -> Axi return AxisTicks::empty(); } + if let Some(ticks) = fixed_step_ticks(&fig.x) { + return ticks; + } let target = ((plot_width / slot_width).floor() as usize).clamp(2, MAX_X_TARGET); for candidate in (2..=target).rev() { let ticks = axis_ticks_for(&fig.x, candidate); @@ -166,6 +172,9 @@ pub(crate) fn adaptive_y_ticks(axis: &Axis, tick_pt: f32, plot_height: f32) -> A if plot_height < 2.0 * spacing { return sparse_y_ticks(axis, tick_pt, plot_height); } + if let Some(ticks) = fixed_step_ticks(axis) { + return ticks; + } // A category axis names every slot that fits; the numeric cap only keeps // value ticks sparse. @@ -407,6 +416,9 @@ pub fn axis_ticks_for(axis: &Axis, target: usize) -> AxisTicks { { return crate::axis_break::ticks(axis, pieces, target); } + if let Some(ticks) = fixed_step_ticks(axis) { + return ticks; + } if axis.categories.is_none() { if let Some(display) = &axis.display { let mut result = format_axis_ticks( @@ -438,6 +450,52 @@ pub fn axis_ticks_for(axis: &Axis, target: usize) -> AxisTicks { } } +/// Ticks on the multiples of the axis's set major step, in its display unit. +/// `None` where the axis has no step, cannot honour one, or the step would +/// draw fewer than two ticks or more than [`MAX_FIXED_TICKS`]. +fn fixed_step_ticks(axis: &Axis) -> Option { + let step = axis + .major_step + .filter(|step| step.is_finite() && *step > 0.0)?; + if axis.categories.is_some() + || !axis.effective_scale().is_linear() + || axis.break_pieces().is_some() + { + return None; + } + let to_display = |value: f64| axis.display.as_ref().map_or(value, |d| d.to_display(value)); + let (a, b) = (to_display(axis.min), to_display(axis.max)); + let (lo, hi) = (a.min(b), a.max(b)); + let eps = step * 1e-9; + let first = ((lo - eps) / step).ceil(); + let last = ((hi + eps) / step).floor(); + if !first.is_finite() + || !last.is_finite() + || last - first < 1.0 + || last - first >= MAX_FIXED_TICKS as f64 + { + return None; + } + let values = (0..=(last - first) as usize) + .map(|k| { + let value = (first + k as f64) * step; + if value.abs() < eps { 0.0 } else { value } + }) + .collect(); + let mut ticks = label_tick_values( + values, + step, + lo.abs().max(hi.abs()), + axis.thousands_separator, + ); + if let Some(display) = &axis.display { + for value in &mut ticks.values { + *value = display.to_coordinate(*value); + } + } + Some(ticks) +} + /// Minor tick positions: `count` evenly spaced values in every major interval, /// including the partial intervals before the first and after the last major /// tick. Categorical axes and axes with fewer than two major ticks have none. diff --git a/crates/render/src/ticks/margins.rs b/crates/render/src/ticks/margins.rs index ea423d6f..399d4668 100644 --- a/crates/render/src/ticks/margins.rs +++ b/crates/render/src/ticks/margins.rs @@ -1,6 +1,7 @@ //! Plot margins: the room the axis furniture needs around the plot area. use super::{AxisTicks, estimated_text_width, widest_tick_label}; +use crate::rich_text::script_overshoot; use crate::{AXIS_LABEL_GAP, Margins, TICK_LABEL_PAD}; use plotium_figure::{AxisFrame, Figure}; @@ -139,7 +140,7 @@ pub(super) fn margins_for_ticks_with_widths( let title_clearance = if fig.title.trim().is_empty() { 0.0 } else { - ty.title_pt + AXIS_LABEL_GAP + ty.title_pt + script_overshoot(&fig.title, ty.title_pt).0 + AXIS_LABEL_GAP }; if fig.axis_frame == AxisFrame::Hidden { return Margins { @@ -159,7 +160,7 @@ pub(super) fn margins_for_ticks_with_widths( ty.tick_outset() }; let y_title_clearance = if fig.y.show_label { - ty.label_pt + AXIS_LABEL_GAP + crate::y_title::lane_width(fig) + AXIS_LABEL_GAP } else { 0.0 }; @@ -175,7 +176,7 @@ pub(super) fn margins_for_ticks_with_widths( 0.0 }; let x_title_clearance = if fig.x.show_label { - ty.label_pt + AXIS_LABEL_GAP + ty.label_pt + script_overshoot(&fig.x.label, ty.label_pt).1 + AXIS_LABEL_GAP } else { 0.0 }; diff --git a/crates/render/src/y_title.rs b/crates/render/src/y_title.rs index 147161a8..127d5f59 100644 --- a/crates/render/src/y_title.rs +++ b/crates/render/src/y_title.rs @@ -4,7 +4,19 @@ use crate::ticks::{self, AxisLayout}; use crate::{AXIS_LABEL_GAP, Projector, Rect, StackPlacement, XFurniture}; use plotium_figure::{AxisFrame, Figure}; -/// Left edge of the rotated y-axis title's `label_pt`-wide lane, in output +/// How far the rotated y-axis title's superscripts reach past its line box +/// toward the outer edge, in points. +pub(crate) fn rise(fig: &Figure) -> f32 { + crate::rich_text::script_overshoot(&fig.y.label, fig.typography.label_pt).0 +} + +/// Width of the rotated y-axis title's lane: one line plus the [`rise`] of +/// its superscripts, which the title's line box sits inside of. +pub(crate) fn lane_width(fig: &Figure) -> f32 { + fig.typography.label_pt + rise(fig) +} + +/// Left edge of the rotated y-axis title's [`lane_width`]-wide lane, in output /// units. The lane sits [`AXIS_LABEL_GAP`] left of the widest y tick label, /// the same gap the x title keeps below the x tick labels, so a plot pushed /// right by a wide left-end x label keeps its title beside the y ticks. @@ -31,7 +43,7 @@ pub(crate) fn y_title_left( Some(placement) => placement.y_tick_clearance, None => layout.y_tick_clearance(fig), }; - let beside_ticks = y_axis_x - (tick_clearance + AXIS_LABEL_GAP + ty.label_pt) * scale; + let beside_ticks = y_axis_x - (tick_clearance + AXIS_LABEL_GAP + lane_width(fig)) * scale; if beside_ticks <= edge { return edge; } @@ -51,7 +63,7 @@ pub(crate) fn y_title_left( } else { f32::INFINITY }; - let title_right = beside_ticks + (ty.label_pt + AXIS_LABEL_GAP) * scale; + let title_right = beside_ticks + (lane_width(fig) + AXIS_LABEL_GAP) * scale; if reaches_x_labels && x_labels_left < title_right { edge } else { diff --git a/crates/render/src/y_title_tests.rs b/crates/render/src/y_title_tests.rs index 200d0b89..798a7b77 100644 --- a/crates/render/src/y_title_tests.rs +++ b/crates/render/src/y_title_tests.rs @@ -133,3 +133,30 @@ fn stacked_titles_align_beside_the_stacks_widest_tick_labels() { assert!((title.right() - expected).abs() < 1e-3, "{title:?}"); } } + +#[test] +fn script_overshoot_of_edge_titles_stays_inside_the_frame() { + let mut fig = bars(&["A", "B"]); + fig.title = "Cycling at 1 A g^{-1}".to_owned(); + fig.x.label = "Li_{x}".to_owned(); + let ty = fig.typography; + let (rise, _) = crate::rich_text::script_overshoot(&fig.y.label, ty.label_pt); + let (title_rise, _) = crate::rich_text::script_overshoot(&fig.title, ty.title_pt); + let (_, drop) = crate::rich_text::script_overshoot(&fig.x.label, ty.label_pt); + assert!(rise > 0.0 && title_rise > 0.0 && drop > 0.0); + + let all = figure_text_boxes(&fig, [fig.width, fig.height]); + let y_title = boxes(&all, "axis.y.label").next().expect("y title"); + assert!((y_title.left - rise).abs() < 1e-3, "{y_title:?}"); + let title = boxes(&all, "title").next().expect("title"); + assert!((title.top - title_rise).abs() < 1e-3, "{title:?}"); + let x_title = boxes(&all, "axis.x.label").next().expect("x title"); + assert!( + (fig.height - x_title.bottom() - drop).abs() < 1e-3, + "{x_title:?}" + ); + + let (y_gap, x_gap) = title_gaps(&all); + assert!((y_gap - AXIS_LABEL_GAP).abs() < 1e-3, "y gap {y_gap}"); + assert!((x_gap - AXIS_LABEL_GAP).abs() < 1e-3, "x gap {x_gap}"); +} diff --git a/docs/src/content/docs/guides/annotations.md b/docs/src/content/docs/guides/annotations.md index 729f5495..c4087d6e 100644 --- a/docs/src/content/docs/guides/annotations.md +++ b/docs/src/content/docs/guides/annotations.md @@ -57,8 +57,9 @@ automatic placement. line here**. The new annotation sits at the clicked data point; an arrow points at it. - **Edit text**: double-click a text annotation or reference-line label, or - choose **Edit text…** from its right-click menu. `Enter` saves and `Esc` - cancels. + choose **Edit text…** from its right-click menu. In a text annotation + `Enter` starts a new line and `Ctrl`/`Cmd` + `Enter` saves; a reference-line + label is one line, saved with `Enter`. `Esc` cancels. - **Move**: drag an arrow to move it, or drag one of its ends to move only that end; drag a reference line across itself to change its value. Arrow keys nudge the selected annotation. Agents keep an arrow or line you moved where diff --git a/docs/src/content/docs/guides/layout-and-export.md b/docs/src/content/docs/guides/layout-and-export.md index 71e7c8f2..4598debd 100644 --- a/docs/src/content/docs/guides/layout-and-export.md +++ b/docs/src/content/docs/guides/layout-and-export.md @@ -249,6 +249,17 @@ the multiplier. Agents can set the same option through the `object.axes.x_thousands_separator` and `object.axes.y_thousands_separator` properties. +To choose where major ticks fall, set **X major tick step** or **Y major tick +step** under **Advanced** in the same section, or **Right Y major tick step** +in the **Right Y axis** section. Ticks then sit on the multiples of the step in +the axis's display unit — a step of 20 gives 80, 100, 120 — and minor ticks +divide those intervals. 0 returns to automatic ticks. The step does not apply +to logarithmic, category or broken axes, and a step that would draw fewer than +two or more than 100 ticks falls back to automatic ticks. Changing the display +unit clears the step. Agents use +`object.axes.x_major_step`, `object.axes.y_major_step` and +`object.axes.y2_major_step`. + ## Stack plots on a shared x axis A Shared X Stack puts two or more plots in one Panel, one above the other, on a @@ -280,12 +291,13 @@ In the stack: of a lower plot's y axis moves just inside its top-left corner, and a y tick number that would sit on the shared edge is left out. - The plots share one x window. Entering the stack sets an x range covering all - of their data on every plot, so the plots must use the same x scale (linear - or logarithmic) first. Every plot follows the bottom plot's x direction: an + of their data and the top plot's x major tick step on every plot, so the + plots must use the same x scale (linear or logarithmic) first. Every plot follows the bottom plot's x direction: an NMR spectrum, whose ppm axis runs right to left, stacked above an ascending plot is drawn left to right. - Zooming or panning x on one plot moves all of them. Changing the x range, - logarithmic scale, x display unit or thousands separator on one plot changes + logarithmic scale, x display unit, thousands separator or major tick step on + one plot changes every plot in the stack in one undoable step; a change that one of the plots cannot take, such as a logarithmic axis on an NMR spectrum, is refused. - The top-to-bottom order is the Panel's content order. Each plot's height sets @@ -374,7 +386,8 @@ What you control directly: title; the title reads bottom to top like the left one and starts as the first right curve's column name and unit. The **Right Y axis** section of the Object inspector, shown only while the plot has a right axis, sets its - title, range, logarithmic scale, tick labels and display unit. Scrolling, + title, range, logarithmic scale, tick labels, display unit and major tick + step. Scrolling, dragging and box zoom move both y axes by the same fraction of their range, a double-click returns both to their full range, and the **Inspect** cursor reads the pointer on x and on both y axes. One legend lists every curve. @@ -481,8 +494,11 @@ What you control directly: colour until set. **Marker edge width** draws that outline, from 0 (no outline, the default) to 5 pt. **Hollow marker** leaves closed markers unfilled and keeps their outline, 0.5 pt when the edge width is 0; the - fill colour returns when it is turned off. Plus and cross markers stay in the curve - colour. The legend swatch shows the same marker. Charts whose colours carry + fill colour returns when it is turned off. **Marker every N points** draws + a marker on the first point and every N-th point after it, for dense data + such as hundreds of cycles; the line still runs through every point. Plus + and cross markers stay in the curve colour. The legend swatch shows the same + marker. Charts whose colours carry meaning keep their marker colours. - **Canvas themes** carry matching sizes — the Presentation Dark theme, for example, enlarges the axis text for slides. The **Nature** theme follows the diff --git a/docs/src/content/docs/reference/mcp.md b/docs/src/content/docs/reference/mcp.md index bb8e8005..9bf776dc 100644 --- a/docs/src/content/docs/reference/mcp.md +++ b/docs/src/content/docs/reference/mcp.md @@ -84,6 +84,7 @@ canvases and canvas objects, applying built-in themes or saved canvas styles to | `series.line.marker_fill / series.line.marker_edge_color` | Object + Series | `#RRGGBB` | | `series.line.marker_edge_width` | Object + Series | 0–5 pt; 0 draws no outline (0.5 pt while hollow) | | `series.line.marker_hollow` | Object + Series | boolean | +| `series.line.marker_every` | Object + Series | 1–1000; markers on every n-th point from the first (default 1) | | `series.bar.width` | Object + Series | 0.05–1, fraction of the bar's slot (bar curves; default 0.8) | | `series.bar.edge_color` | Object + Series | `#RRGGBB` bar outline (bar curves; unset, a near-black shade of the fill's hue that follows color edits) | | `series.bar.edge_width` | Object + Series | 0–5 pt bar outline; 0 draws none (bar curves; default 0.5 pt, or the theme's supporting-line width) | @@ -101,6 +102,7 @@ canvases and canvas objects, applying built-in themes or saved canvas styles to | `object.axes.x_show_label / object.axes.y_show_label` | Object | boolean; shows the axis title | | `object.axes.x_show_tick_labels / object.axes.y_show_tick_labels` | Object | boolean | | `object.axes.x_thousands_separator / object.axes.y_thousands_separator` | Object | boolean; groups digits in tick labels | +| `object.axes.x_major_step / object.axes.y_major_step` | Object | ≥ 0, distance between major ticks in the display unit; 0 chooses it automatically (linear axes without a break) | | `object.axes.frame` | Object | `open` (left and bottom), `box` (all four sides) | | `object.axes.equal_scale` | Object | boolean; same data units per length on x and y (linear, non-categorical axes without a right y axis) | | `object.axes.x_scale / object.axes.y_scale` | Object | `linear`, `log` (line, scatter and stick plots; a manual range must stay above zero) | @@ -111,6 +113,7 @@ canvases and canvas objects, applying built-in themes or saved canvas styles to | `object.axes.y2_scale` | Object | `linear`, `log` | | `object.axes.y2_display_unit` | Object | compatible code from property inspection | | `object.axes.y2_range` | Object | auto or manual range object | +| `object.axes.y2_major_step` | Object | as `y_major_step`, for the right y axis | | `object.figure.typography.tick_pt / object.figure.typography.label_pt / object.figure.typography.legend_pt` | Object | 1–72 pt | | `object.guides.visibility` | Object | `auto`, `show`, `hide` | | `object.guides.placement` | Object | `auto`, `inside`, `outside_right`, `outside_bottom`, `custom` | @@ -1114,10 +1117,12 @@ their heights keep the proportions of their current frames, so change one with stack is turned on. Only the bottom plot draws x tick labels and the x title, only the top plot draws a figure title, inner boundaries touch with inward x ticks, and the plot areas share left and right edges. Entering the stack writes -one x range covering every member's data to each member (in its own coordinate -unit), so members must already share an x scale; every member draws the bottom +one x range covering every member's data (in its own coordinate unit) and the +top member's x major step (in the unit it displays) to each member, so members +must already share an x scale; every member draws the bottom member's x direction without storing it. The plots share one x viewport, and -later x range, x scale, x display unit and x thousands-separator edits on any +later x range, x scale, x display unit, x thousands-separator and x major step +edits on any member apply to all of them in one Undo step; the write is rejected when any member cannot take the value, and `applied` lists the member edits. `shared_x: false` returns the Panel to free layout and keeps the frames. The result lists each operation in `panel_edits` with `layout`, @@ -1144,7 +1149,8 @@ clipped with the plot. `annotation_id` is local to the plot and never reused after deletion; read it from `annotation_edits` in the result or from the plot's `annotations` list in `plotium_document_inspect`. `at` uses the plot's source units reported in `coordinate_units`, not a display unit chosen for the tick -labels. Annotation text permits 1024 UTF-8 bytes (nonblank), and a plot holds at +labels. `\n` in `text` starts a new line. +Annotation text permits 1024 UTF-8 bytes (nonblank), and a plot holds at most 256 annotations. The plot and its parent panel must be unlocked; Auto height may stay on. @@ -1815,7 +1821,9 @@ width and outline width apply to bar, box and violin curves; the outline color t bar curves. Marker fill and edge color apply to closed markers and follow the curve color until set; reset returns them to it. A hollow marker draws no fill and keeps an outline, 0.5 pt when -no edge width is set. A chart that owns semantic colors rejects a single series +no edge width is set. `marker_every` thins markers to every n-th source point, +starting with the first; the line still passes through every point. A chart +that owns semantic colors rejects a single series color, marker fill or marker edge color override as inapplicable. Plot-local tick, axis-title and legend sizes inherit document typography until diff --git a/docs/src/content/docs/zh-cn/guides/annotations.md b/docs/src/content/docs/zh-cn/guides/annotations.md index 36653c3e..ba6737ea 100644 --- a/docs/src/content/docs/zh-cn/guides/annotations.md +++ b/docs/src/content/docs/zh-cn/guides/annotations.md @@ -46,7 +46,8 @@ description: 画板上的文本、面板序号、形状、箭头、图内标签 **Add vertical line here** 或 **Add horizontal line here**。新标注放在点击处的 数据坐标上;箭头指向该点。 - **改字**:双击文字标注或参考线标签,或在右键菜单中选择 **Edit text…**。 - `Enter` 保存,`Esc` 取消。 + 文字标注中 `Enter` 换行,`Ctrl`/`Cmd` + `Enter` 保存;参考线标签只有一行, + `Enter` 保存。`Esc` 取消。 - **移动**:拖动箭头可整体移动,拖动箭头的一端只移动这一端;沿垂直于参考线的方向 拖动可改变它的位置。方向键微调所选标注。你移动过的箭头和参考线,agent 只有在你 要求时才会挪动。 diff --git a/docs/src/content/docs/zh-cn/guides/layout-and-export.md b/docs/src/content/docs/zh-cn/guides/layout-and-export.md index bfb79bf2..932d6396 100644 --- a/docs/src/content/docs/zh-cn/guides/layout-and-export.md +++ b/docs/src/content/docs/zh-cn/guides/layout-and-export.md @@ -200,6 +200,14 @@ object**——此后按住 `Alt` 就只为这一次拖放删除空页面。 `object.axes.x_thousands_separator` 与 `object.axes.y_thousands_separator` 属性设置同一选项。 +若要指定主刻度的位置,在同一区域的 **Advanced** 中设置 **X major tick step** 或 +**Y major tick step**,右 y 轴则在 **Right Y axis** 区域设置 +**Right Y major tick step**。主刻度随后落在该轴显示单位下间隔的整数倍上—— +间隔 20 给出 80、100、120——次刻度在其间均分。设为 0 恢复自动刻度。对数轴、 +分类轴和断轴不适用此设置;间隔会画出少于 2 个或超过 100 个刻度时退回自动刻度。更换显示单位会清除间隔。智能体使用 +`object.axes.x_major_step`、`object.axes.y_major_step` 与 +`object.axes.y2_major_step`。 + ## 共享 x 轴堆叠 Shared X Stack(共享 x 轴堆叠)把一个 Panel 里的两张及以上的图上下排列、共用 @@ -223,10 +231,10 @@ Shared X Stack(共享 x 轴堆叠)把一个 Panel 里的两张及以上的 相接处的 x 刻度朝内;最下面的 x 轴保持你设定的刻度方向。下方图 y 轴的 ×10ⁿ 倍数移到其绘图区左上角内侧,落在共用边界上的 y 刻度数字不再绘制。 - 各图共用一个 x 视窗。进入堆叠时,会给每张图设置一个覆盖全部数据的 x 范围, - 因此各图须先使用相同的 x 刻度类型(线性或对数)。所有图都跟随最下面那张图的 + 并统一使用最上面那张图的 x 主刻度间隔,因此各图须先使用相同的 x 刻度类型(线性或对数)。所有图都跟随最下面那张图的 x 方向:ppm 轴从右到左的 NMR 谱堆叠在升序图上方时,会改为从左到右绘制。 - 在任一张图上缩放或平移 x,其他图会一起移动。在任一张图上修改 x 范围、对数刻度、 - x 显示单位或千位分隔符,会同时改到堆叠中的所有图,并合为一次撤销;若其中某张图 + x 显示单位、千位分隔符或主刻度间隔,会同时改到堆叠中的所有图,并合为一次撤销;若其中某张图 不接受该修改(例如 NMR 谱不能使用对数轴),整个修改会被拒绝。 - 从上到下的顺序就是 Panel 内容顺序。每张图的高度决定它在堆叠中所占的比例:在 画布上拖动某张图的下边框,即可让它比其他图更高或更矮。 @@ -293,7 +301,7 @@ NMR 核素质量数。新数据集默认使用 89 × 60 mm 单栏画布:单个 所有曲线都回到左轴后,右轴随之消失。右轴按自己的曲线自动确定范围,并绘制 自己的刻度、刻度标签和标题;标题与左轴标题同样从下往上读,初始为第一条右轴 曲线的列名和单位。对象检查器中的 **Right Y axis** 区域只在图有右轴时出现, - 可设置右轴的标题、范围、对数刻度、刻度标签和显示单位。滚轮、拖动和框选缩放 + 可设置右轴的标题、范围、对数刻度、刻度标签、显示单位和主刻度间隔。滚轮、拖动和框选缩放 会让两条 y 轴按各自范围的相同比例一起移动,双击会让两条轴都回到完整范围, **Inspect** 光标同时读出指针处的 x 和左右两个 y 值。所有曲线共用一个图例。 柱状曲线始终在左轴上,参考线、标注和箭头按左轴坐标放置。表格曲线图中,每条 @@ -370,7 +378,9 @@ NMR 核素质量数。新数据集默认使用 89 × 60 mm 单栏画布:单个 设定闭合符号(圆、方、菱形、三角、星形、五边形、六边形)的填充色,**Marker edge color** 设定其边框色; 未设置时两者都跟随曲线颜色。**Marker edge width** 绘制该边框,范围 0(无边框, 默认)到 5 pt。**Hollow marker** 使闭合符号不填充、只保留边框,边框宽度为 0 时 - 为 0.5 pt;关闭后恢复原填充色。加号和叉号符号仍使用曲线颜色。图例中的符号与之一致。颜色带有 + 为 0.5 pt;关闭后恢复原填充色。**Marker every N points** 从第一个点起每 N 个 + 点画一个符号,适合几百圈循环这样的密集数据;折线仍经过每个点。加号和叉号符号 + 仍使用曲线颜色。图例中的符号与之一致。颜色带有 语义的图型保持其符号颜色。 - **画布主题**携带配套字号——例如 Presentation Dark 主题会放大坐标轴 文字以适合投影演示。**Nature** 主题遵循 Nature Portfolio 终稿作图规范: diff --git a/docs/src/content/docs/zh-cn/reference/mcp.md b/docs/src/content/docs/zh-cn/reference/mcp.md index 085934bf..f5792f58 100644 --- a/docs/src/content/docs/zh-cn/reference/mcp.md +++ b/docs/src/content/docs/zh-cn/reference/mcp.md @@ -72,6 +72,7 @@ dirty、edit generation 或 automation revision。纯查询工作流同样不改 | `series.line.marker_fill / series.line.marker_edge_color` | 图对象+Series | `#RRGGBB` | | `series.line.marker_edge_width` | 图对象+Series | 0–5 pt;0 表示无边框(空心时为 0.5 pt) | | `series.line.marker_hollow` | 图对象+Series | 布尔值 | +| `series.line.marker_every` | 图对象+Series | 1–1000;从第一个点起每 n 个点画一个符号(默认 1) | | `series.bar.width` | 图对象+Series | 0.05–1,占所在槽位宽度的比例(仅柱状曲线;默认 0.8) | | `series.bar.edge_color` | 图对象+Series | `#RRGGBB`,柱描边颜色(仅柱状曲线;未设置时为带填充色色相的近黑色,并随曲线颜色变化) | | `series.bar.edge_width` | 图对象+Series | 0–5 pt,柱描边宽度;0 表示无描边(仅柱状曲线;默认 0.5 pt,主题规定了辅助线宽时用它) | @@ -89,6 +90,7 @@ dirty、edit generation 或 automation revision。纯查询工作流同样不改 | `object.axes.x_show_label / object.axes.y_show_label` | 图对象 | 布尔值;显示轴标题 | | `object.axes.x_show_tick_labels / object.axes.y_show_tick_labels` | 图对象 | 布尔值 | | `object.axes.x_thousands_separator / object.axes.y_thousands_separator` | 图对象 | 布尔值;刻度数字按千分位分组 | +| `object.axes.x_major_step / object.axes.y_major_step` | 图对象 | ≥ 0,按显示单位计的主刻度间隔;0 表示自动(无断轴的线性轴) | | `object.axes.frame` | 图对象 | `open`(左、下两条轴)、`box`(四边框) | | `object.axes.equal_scale` | 图对象 | 布尔值;x、y 每单位长度对应相同的数据量(线性、非分类轴,且没有右 y 轴) | | `object.axes.x_scale / object.axes.y_scale` | 图对象 | `linear`、`log`(仅折线、散点和棒状图;手动范围须大于零) | @@ -99,6 +101,7 @@ dirty、edit generation 或 automation revision。纯查询工作流同样不改 | `object.axes.y2_scale` | 图对象 | `linear`、`log` | | `object.axes.y2_display_unit` | 图对象 | 属性查询返回的兼容单位代码 | | `object.axes.y2_range` | 图对象 | 自动或手动范围对象 | +| `object.axes.y2_major_step` | 图对象 | 同 `y_major_step`,用于右 y 轴 | | `object.figure.typography.tick_pt / object.figure.typography.label_pt / object.figure.typography.legend_pt` | 图对象 | 1–72 pt | | `object.guides.visibility` | 图对象 | `auto`, `show`, `hide` | | `object.guides.placement` | 图对象 | `auto`, `inside`, `outside_right`, `outside_bottom`, `custom` | @@ -918,9 +921,11 @@ Series 身份和来源绑定。已知目标可复用同步提交结果的 contex 保持当前 frame 的比例,用 `content_frame` 修改某张图的高度即可调整其所占比例。 开启堆叠时间距默认为 0 pt。只有最下面的图绘制 x 刻度数字和 x 轴标题,只有 最上面的图绘制图标题,内部边界紧贴且 x 刻度朝内,各绘图区共用左右边缘。进入堆叠 -时,会把覆盖所有成员数据的 x 范围(按各自坐标单位)写入每个成员,因此各成员须先 +时,会把覆盖所有成员数据的 x 范围(按各自坐标单位)和最上面成员的 x 主刻度间隔 +(按各自显示的单位)写入每个成员,因此各成员须先 使用相同的 x 刻度类型;所有成员绘制最下面成员的 x 方向,但不保存该方向。各图共享 -x 视窗;之后在任一成员上修改 x 范围、x 刻度类型、x 显示单位或 x 千位分隔符,都会 +x 视窗;之后在任一成员上修改 x 范围、x 刻度类型、x 显示单位、x 千位分隔符或 +x 主刻度间隔,都会 写到所有成员,并合为一次 Undo;若任一成员不能接受该值则拒绝写入,`applied` 会 列出各成员的修改。`shared_x: false` 恢复为自由布局并保留各 frame。结果在 `panel_edits` 中列出 每个操作的 `layout`、`gap_pt`、`members_top_to_bottom` 和 `merged_panels`。 @@ -941,7 +946,8 @@ x 视窗;之后在任一成员上修改 x 范围、x 刻度类型、x 显示 `plotium_get_state` 在 `hand_edits` 中以键 `annotation.label_position` 列出拖过的标签。 `annotation_id` 只在所属图内有效,删除后不会复用;可从结果的 `annotation_edits` 或 `plotium_document_inspect` 返回的图 `annotations` 列表读取。`at` 使用 -`coordinate_units` 给出的源单位,而不是刻度标签所选的显示单位。标注文字最多 1024 +`coordinate_units` 给出的源单位,而不是刻度标签所选的显示单位。`text` 中的 `\n` +另起一行。标注文字最多 1024 UTF-8 字节且不得空白,每张图最多 256 个标注。图及其父面板须未锁定;不要求关闭 Auto height。 @@ -1462,7 +1468,7 @@ edit generation、automation revision 或 Undo 历史。能力发现见 `plotium 现有基于 width 的半径,原线宽接口含义不变;reset 恢复原生符号/大小。 显式空图例文字使该图例项不显示。线型仅适用于折线,散点目标会按规则跳过。 符号适用于折线和散点,不适用于 stick、柱状、箱线和小提琴曲线;柱宽和描边宽度适用于柱状、箱线和小提琴曲线,描边颜色仅适用于柱状曲线。符号填充色和边框色作用于闭合符号,未设置时跟随 -曲线颜色,reset 恢复跟随。空心符号不填充并保留边框,未设置边框宽度时为 0.5 pt。颜色由图型语义决定时,单曲线改色、改符号填充色或边框色按不适用跳过。 +曲线颜色,reset 恢复跟随。空心符号不填充并保留边框,未设置边框宽度时为 0.5 pt。`marker_every` 从第一个源数据点起每 n 个点画一个符号,折线仍经过每个点。颜色由图型语义决定时,单曲线改色、改符号填充色或边框色按不适用跳过。 单图刻度、轴标题和图例字号在未覆盖时继承文档设置,reset 只移除相应覆盖; 改单图字体不影响其他图。字体/标题测量沿用原生渲染,不重排页面,不改变面板及