diff --git a/crates/app/src/ui/canvas/interactions.rs b/crates/app/src/ui/canvas/interactions.rs index 9bbbaae..65ddb94 100644 --- a/crates/app/src/ui/canvas/interactions.rs +++ b/crates/app/src/ui/canvas/interactions.rs @@ -256,7 +256,7 @@ pub(crate) fn handle_data_tool_target( .and_then(|o| o.plot()) .is_some() { - select_object_datasets(app, ci, id); + app.focus_object_datasets(ci, id); } } @@ -309,7 +309,7 @@ pub(crate) fn handle_object_interactions( if matches!(app.interaction(), Interaction::PanelLabel(_)) { app.reset_interaction(); } - select_object_datasets(app, ci, id); + app.focus_object_datasets(ci, id); if let Some(object) = app.doc.canvases[ci].object(id).filter(|o| !o.locked) { let before = object.frame; let start = page_pos.map(|p| [p.x, p.y]).unwrap_or([before.x, before.y]); @@ -477,23 +477,6 @@ fn finish_marquee(app: &mut PlotxApp, ci: usize, marq: MarqueeDrag) { ); } -fn select_object_datasets(app: &mut PlotxApp, ci: usize, id: ObjectId) { - let Some(object) = app.doc.canvases[ci].object(id) else { - return; - }; - let active = object.dataset().and_then(|id| app.doc.dataset_index(id)); - let datasets = object - .dataset_ids() - .into_iter() - .filter_map(|id| app.doc.dataset_index(id)) - .collect::>(); - if !datasets.is_empty() { - app.focus_datasets(&datasets, active); - } else { - app.set_active_dataset(active); - } -} - pub(crate) fn arrange_context_menu(app: &mut PlotxApp, ci: usize, ui: &mut Ui) { if ui.button("Copy figure").clicked() { let ctx = ui.ctx().clone(); @@ -578,6 +561,10 @@ pub(crate) fn arrange_context_menu(app: &mut PlotxApp, ci: usize, ui: &mut Ui) { if ui.checkbox(&mut snap, "Snap to grid & objects").clicked() { app.set_snap_enabled(snap); } + // Channel 4: whatever the selection draws, its settings are one click from + // here. Navigation only — the entries jump to the panel section that owns + // the controls, and are derived from the catalog rather than listed again. + crate::ui::properties::discovery::context_menu(app, ui); ui.separator(); if ui.button("Canvas settings…").clicked() { app.session.ui.canvas_settings = Some(ci); diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index d7ef6d4..1512ad3 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -39,6 +39,7 @@ mod painting; mod panel_notes; mod peaks; mod phase; +mod readout; mod regions; mod slices; mod snap; @@ -57,6 +58,7 @@ pub(crate) use painting::*; pub(crate) use panel_notes::*; pub(crate) use peaks::*; pub(crate) use phase::*; +pub(crate) use readout::*; pub(crate) use regions::*; pub(crate) use slices::*; pub(crate) use snap::*; @@ -229,6 +231,7 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { paint_marquee(app, ci, rect, &painter, chrome); paint_panel_label_selection(app, ci, rect, &painter, chrome); paint_object_selection(app, ci, rect, page, &painter, chrome); + paint_property_readouts(app, ci, rect, &painter, chrome, ui.visuals().dark_mode); paint_tile_ghost(app, &painter, chrome); paint_tile_preview(app, rect, &painter, chrome); super::canvas_size::page_size_chrome(app, ci, page, rect, ui); diff --git a/crates/app/src/ui/canvas/readout.rs b/crates/app/src/ui/canvas/readout.rs new file mode 100644 index 0000000..5de4750 --- /dev/null +++ b/crates/app/src/ui/canvas/readout.rs @@ -0,0 +1,77 @@ +//! The in-place readout for canvas-steppable settings (§8.5 channel 3). +//! +//! "The best parameter is the one you never have to look for" only holds if the +//! plot says what its threshold currently is. This paints that value in the +//! corner of every plot the `+` / `-` gesture would act on, so the number the +//! keys move is visible where the keys are pressed. +//! +//! It is a label and nothing more: it reads a cached value through +//! [`PlotxApp::contour_base_readout`] and never resolves, measures or queues +//! anything. A plot whose estimate has not arrived says so. + +use super::{object_screen_rect, plot_rect}; +use crate::ui::properties; +use egui::{Align2, Color32, FontId}; +use plotx_core::state::PlotxApp; + +const READOUT_FONT_PT: f32 = 11.0; +const READOUT_INSET_PX: f32 = 6.0; + +/// Paint the corner readout for each plot the canvas gesture currently targets. +pub(crate) fn paint_property_readouts( + app: &PlotxApp, + ci: usize, + rect: egui::Rect, + painter: &egui::Painter, + chrome: super::ChromeStyle, + dark_mode: bool, +) { + // Nothing to read out unless a steppable property applies right now — the + // same condition that enables the gesture, asked once, so the label cannot + // appear on a plot the keys would not move. + let Some((property, _)) = properties::discovery::step_target(app) else { + return; + }; + let Some(canvas) = app.doc.canvases.get(ci) else { + return; + }; + for object in properties::discovery::selection_objects(app) { + // The series the gesture edits, not the first one the plot happens to + // hold. A contour stacked over a heatmap is drawn second, and reading + // the first series would caption the plot with a heatmap that has no + // level while the keys moved the contour that does — or, with several + // contours, present one of them as the whole plot's threshold. + let targets = app.series_targets(ci, object); + let readouts: Vec<_> = app + .resolve_property_set(property, &targets) + .applicable_targets + .iter() + .filter_map(|address| app.contour_base_readout(&address.target)) + .collect(); + let Some(text) = properties::readout::aggregate_summary(&readouts) else { + continue; + }; + let Some(frame) = object_screen_rect(app.session.board, canvas, object, rect) else { + continue; + }; + let frame = plot_rect(frame); + if !frame.is_positive() { + continue; + } + let anchor = frame.right_top() + egui::vec2(-READOUT_INSET_PX, READOUT_INSET_PX); + let galley = painter.layout_no_wrap( + text, + FontId::proportional(READOUT_FONT_PT), + chrome.selection_stroke, + ); + let text_rect = Align2::RIGHT_TOP.anchor_size(anchor, galley.size()); + // A plot's own ink runs right up to its frame, so the label needs a + // backing plate to stay legible over dense contours. + painter.rect_filled( + text_rect.expand(3.0), + 3.0, + Color32::from_black_alpha(if dark_mode { 150 } else { 20 }), + ); + painter.galley(text_rect.min, galley, chrome.selection_stroke); + } +} diff --git a/crates/app/src/ui/command_exec.rs b/crates/app/src/ui/command_exec.rs index c97ca0d..97ccb4a 100644 --- a/crates/app/src/ui/command_exec.rs +++ b/crates/app/src/ui/command_exec.rs @@ -144,6 +144,21 @@ pub fn execute( app.apply_theme(&theme); } } + // Channels 2 and 4 navigate; they never edit. `reveal_property` opens + // the home the presentation names and asks the panel to scroll and + // highlight — the same route a palette hit takes. + CommandId::PropertyGroup(section) => { + let now = ctx.input(|input| input.time); + if let Some(property) = super::properties::discovery::entry_property( + section, + super::properties::PRESENTATIONS, + ) { + super::command_palette::reveal_property(app, property, now); + ctx.request_repaint(); + } + } + // Channel 3 edits, and does so through the property planner. + CommandId::StepProperty(step) => super::properties::discovery::step_selection(app, step), CommandId::Tool(tool) => app.toggle_tool(tool), } } @@ -203,8 +218,7 @@ fn open_chart_type(app: &mut PlotxApp) { let Some((ci, object)) = commands::chart_plot_target(app, dataset) else { return; }; - app.session.active_canvas = Some(ci); - app.select_object(ci, object); + app.reveal_object(ci, object); app.session.secondary_sidebar_visible = true; } diff --git a/crates/app/src/ui/command_palette.rs b/crates/app/src/ui/command_palette.rs index 60019cd..be54d0e 100644 --- a/crates/app/src/ui/command_palette.rs +++ b/crates/app/src/ui/command_palette.rs @@ -1,11 +1,72 @@ -use super::commands::{CommandDescriptor, CommandExecutionClass}; +//! The unified search (`Cmd+K`). +//! +//! Its search set is commands ∪ properties ∪ resources. Matching a command by +//! its label alone was the visible half of the gap the property catalog closes: +//! a setting the user could see on screen was not findable by name, because +//! only verbs were indexed. Every entry now contributes a set of terms — for a +//! property, its id tokens, canonical label and aliases, and the active +//! locale's label and aliases — and a query term has to appear in one of them. + +use super::commands::{CommandDescriptor, CommandExecutionClass, CommandId}; +use super::properties::{self, PanelRoute}; use super::*; use egui::{Align2, FontId, Key, TextEdit, vec2}; +use plotx_core::properties::PropertyId; +use plotx_core::state::{ObjectId, PropertyFocus}; const PANEL_WIDTH: f32 = 540.0; const LIST_HEIGHT: f32 = 320.0; const ROW_HEIGHT: f32 = 26.0; +/// What activating a search hit does. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(super) enum PaletteAction { + Command(CommandId), + /// Reveal a property at its canonical home. + Property(PropertyId), + Dataset(usize), + Canvas(usize), + Object(usize, ObjectId), +} + +/// One row of the unified search. +pub(super) struct PaletteItem { + label: String, + /// Right-hand hint: a shortcut for a command, the home panel for a + /// property, the owning page for a plot. + detail: String, + /// Lower-cased text a query is matched against. + haystack: String, + prefix: &'static str, + enabled: bool, + disabled_reason: Option, + pub(super) action: PaletteAction, +} + +impl PaletteItem { + fn from_command(command: CommandDescriptor) -> Self { + let prefix = if command.checked == Some(true) { + egui_phosphor::regular::CHECK + } else if matches!( + command.execution_class, + CommandExecutionClass::ToolEditor | CommandExecutionClass::ToolBacked + ) { + egui_phosphor::regular::WRENCH + } else { + "" + }; + Self { + haystack: command.label.to_lowercase(), + label: command.label, + detail: command.shortcut.unwrap_or_default(), + prefix, + enabled: command.enabled, + disabled_reason: command.disabled_reason.map(str::to_owned), + action: PaletteAction::Command(command.id), + } + } +} + pub(super) fn command_palette_window( app: &mut PlotxApp, clipboard: &mut clipboard_table::ClipboardTablePaste, @@ -16,7 +77,7 @@ pub(super) fn command_palette_window( }; let (mut query, mut selected) = (state.query.clone(), state.selected); - let commands = commands::catalog(app); + let items = search_set(app); let (up, down, enter) = ctx.input(|input| { ( input.key_pressed(Key::ArrowUp), @@ -30,7 +91,7 @@ pub(super) fn command_palette_window( ui.set_width(PANEL_WIDTH); let response = ui.add( TextEdit::singleline(&mut query) - .hint_text("Type a command…") + .hint_text("Search commands, settings and data…") .desired_width(f32::INFINITY), ); if !response.has_focus() { @@ -40,26 +101,26 @@ pub(super) fn command_palette_window( selected = 0; } - let filtered = filter(&commands, &query); + let filtered = filter(&items, &query); if filtered .get(selected) - .is_none_or(|&index| !commands[index].enabled) + .is_none_or(|&index| !items[index].enabled) { selected = filtered .iter() - .position(|&index| commands[index].enabled) + .position(|&index| items[index].enabled) .unwrap_or(0); } let moved = up || down; if down { - selected = step(&commands, &filtered, selected, 1); + selected = step(&items, &filtered, selected, 1); } else if up { - selected = step(&commands, &filtered, selected, -1); + selected = step(&items, &filtered, selected, -1); } if enter && let Some(&index) = filtered .get(selected) - .filter(|&&index| commands[index].enabled) + .filter(|&&index| items[index].enabled) { run = Some(index); } @@ -71,23 +132,23 @@ pub(super) fn command_palette_window( .show(ui, |ui| { if filtered.is_empty() { ui.add_space(12.0); - ui.vertical_centered(|ui| ui.weak("No matching command")); + ui.vertical_centered(|ui| ui.weak("No matching command, setting or data")); ui.add_space(12.0); return; } for (position, &index) in filtered.iter().enumerate() { - let command = &commands[index]; - let response = row(ui, command, position == selected && command.enabled); + let item = &items[index]; + let response = row(ui, item, position == selected && item.enabled); let clicked = response.clicked(); if position == selected && moved { response.scroll_to_me(None); } - if !command.enabled - && let Some(reason) = command.disabled_reason + if !item.enabled + && let Some(reason) = item.disabled_reason.as_deref() { response.on_hover_text(reason); } - if command.enabled && clicked { + if item.enabled && clicked { run = Some(index); } } @@ -102,29 +163,166 @@ pub(super) fn command_palette_window( app.session.ui.command_palette = None; } if let Some(index) = run { - commands::execute(commands[index].id, app, clipboard, ctx); + activate(items[index].action, app, clipboard, ctx); + } +} + +/// Commands ∪ properties ∪ resources, in that order: verbs first, then the +/// settings that describe how things look, then the things themselves. +pub(super) fn search_set(app: &PlotxApp) -> Vec { + let mut items: Vec = commands::catalog(app) + .into_iter() + .map(PaletteItem::from_command) + .collect(); + + // Resolved once for the whole property half of the set: applicability is a + // question about the current selection, and every hit asks it of the same + // selection the panel and the other discovery channels read. + let targets = properties::discovery::selection_targets(app); + for hit in properties::property_hits() { + let unavailable = property_unavailable_reason(app, hit.id, &targets); + items.push(PaletteItem { + haystack: hit.terms.join(" "), + label: hit.label, + detail: hit.home.to_owned(), + prefix: egui_phosphor::regular::SLIDERS_HORIZONTAL, + enabled: unavailable.is_none(), + disabled_reason: unavailable, + action: PaletteAction::Property(hit.id), + }); + } + + for (index, dataset) in app.doc.datasets.iter().enumerate() { + let label = dataset.display_name(); + let kind = dataset.kind_label(); + items.push(PaletteItem { + haystack: format!("{} {}", label.to_lowercase(), kind.to_lowercase()), + label, + detail: kind.to_owned(), + prefix: egui_phosphor::regular::DATABASE, + enabled: true, + disabled_reason: None, + action: PaletteAction::Dataset(index), + }); + } + + for (index, canvas) in app.doc.canvases.iter().enumerate() { + items.push(PaletteItem { + haystack: canvas.name.to_lowercase(), + label: canvas.name.clone(), + detail: "Page".to_owned(), + prefix: egui_phosphor::regular::FILE, + enabled: true, + disabled_reason: None, + action: PaletteAction::Canvas(index), + }); + for object in &canvas.objects { + items.push(PaletteItem { + haystack: format!( + "{} {}", + object.name.to_lowercase(), + canvas.name.to_lowercase() + ), + label: object.name.clone(), + detail: canvas.name.clone(), + prefix: egui_phosphor::regular::SELECTION, + enabled: true, + disabled_reason: None, + action: PaletteAction::Object(index, object.id), + }); + } + } + items +} + +/// Why a property hit cannot be revealed right now, or `None` when it can. +/// +/// Activating a hit only asks the panel to reveal a row, and the panel resolves +/// its rows against the selection. A hit that applies to nothing therefore has +/// no row to scroll to: the focus would be requested and then hang there, having +/// moved nothing on screen. The gate is the catalog's own applicability answer — +/// capability and encoding gates included — resolved against the same targets, +/// so the palette reports applicability rather than deciding it a second time. +/// +/// The entry stays visible and disabled rather than disappearing, per the +/// crate's hide-vs-disable rule: a setting the user is searching for by name +/// must be findable even when the current selection cannot receive it. +fn property_unavailable_reason( + app: &PlotxApp, + property: PropertyId, + targets: &[plotx_core::automation::TargetRef], +) -> Option { + let resolved = app.resolve_property_set(property, targets); + if !resolved.applicable_targets.is_empty() { + return None; } + // Nothing was even a candidate: the group already declares the sentence + // that names the fix, and the catalog has nothing more specific to add. + let Some((_, reason)) = resolved.skipped_targets.first() else { + return Some( + properties::presentation(property) + .and_then(|entry| properties::discovery::group(entry.home_route.section)) + .map(|group| group.unavailable_reason) + .unwrap_or("Select an object that has this setting.") + .to_owned(), + ); + }; + Some(format!("Select a series this setting applies to: {reason}")) } -fn filter(commands: &[CommandDescriptor], query: &str) -> Vec { +fn activate( + action: PaletteAction, + app: &mut PlotxApp, + clipboard: &mut clipboard_table::ClipboardTablePaste, + ctx: &egui::Context, +) { + match action { + PaletteAction::Command(id) => commands::execute(id, app, clipboard, ctx), + PaletteAction::Property(id) => { + reveal_property(app, id, ctx.input(|input| input.time)); + ctx.request_repaint(); + } + PaletteAction::Dataset(index) => app.focus_single(index), + // Navigation goes through the one path that also brings the selection + // and the data focus with it. A page switch that only moved + // `active_canvas` would leave the previous page's selection in place, + // and object ids restart at one on every page, so it would resolve to an + // unrelated object here. + PaletteAction::Canvas(index) => app.activate_canvas(index), + PaletteAction::Object(canvas, object) => app.reveal_object(canvas, object), + } +} + +/// Open the property's home panel and ask it to expand, scroll and highlight. +/// The route is data: this reads it rather than knowing where anything lives. +pub(super) fn reveal_property(app: &mut PlotxApp, property: PropertyId, now: f64) { + let Some(presentation) = properties::presentation(property) else { + return; + }; + let route = presentation.home_route; + if !route.panel.sections().contains(&route.section) { + // Opening a panel that will never scroll anywhere is worse than saying + // nothing happened, so report it instead of pretending to navigate. + app.session.status = format!("No panel currently hosts {property}."); + return; + } + match route.panel { + PanelRoute::SecondarySidebar => app.session.secondary_sidebar_visible = true, + } + app.session.ui.property_focus = Some(PropertyFocus::request(property, now)); +} + +fn filter(items: &[PaletteItem], query: &str) -> Vec { let terms: Vec = query.split_whitespace().map(str::to_lowercase).collect(); - commands + items .iter() .enumerate() - .filter(|(_, command)| { - let name = command.label.to_lowercase(); - terms.iter().all(|term| name.contains(term)) - }) + .filter(|(_, item)| terms.iter().all(|term| item.haystack.contains(term))) .map(|(index, _)| index) .collect() } -fn step( - commands: &[CommandDescriptor], - filtered: &[usize], - from: usize, - direction: isize, -) -> usize { +fn step(items: &[PaletteItem], filtered: &[usize], from: usize, direction: isize) -> usize { let count = filtered.len() as isize; if count == 0 { return 0; @@ -132,16 +330,16 @@ fn step( let mut index = from as isize; for _ in 0..count { index = (index + direction).rem_euclid(count); - if commands[filtered[index as usize]].enabled { + if items[filtered[index as usize]].enabled { return index as usize; } } from } -fn row(ui: &mut Ui, command: &CommandDescriptor, selected: bool) -> Response { +fn row(ui: &mut Ui, item: &PaletteItem, selected: bool) -> Response { let width = ui.available_width(); - let sense = if command.enabled { + let sense = if item.enabled { Sense::click() } else { Sense::hover() @@ -154,39 +352,29 @@ fn row(ui: &mut Ui, command: &CommandDescriptor, selected: bool) -> Response { if selected { ui.painter() .rect_filled(rect, 4.0, visuals.selection.bg_fill); - } else if command.enabled && response.hovered() { + } else if item.enabled && response.hovered() { ui.painter() .rect_filled(rect, 4.0, visuals.widgets.hovered.bg_fill); } - let color = if !command.enabled { + let color = if !item.enabled { visuals.weak_text_color() } else if selected { visuals.strong_text_color() } else { visuals.text_color() }; - let prefix = if command.checked == Some(true) { - egui_phosphor::regular::CHECK - } else if matches!( - command.execution_class, - CommandExecutionClass::ToolEditor | CommandExecutionClass::ToolBacked - ) { - egui_phosphor::regular::WRENCH - } else { - "" - }; ui.painter().text( egui::pos2(rect.left() + 10.0, rect.center().y), Align2::LEFT_CENTER, - format!("{prefix} {}", command.label), + format!("{} {}", item.prefix, item.label), FontId::proportional(14.0), color, ); - if let Some(shortcut) = &command.shortcut { + if !item.detail.is_empty() { ui.painter().text( egui::pos2(rect.right() - 10.0, rect.center().y), Align2::RIGHT_CENTER, - shortcut, + &item.detail, FontId::proportional(12.0), visuals.weak_text_color(), ); @@ -195,24 +383,5 @@ fn row(ui: &mut Ui, command: &CommandDescriptor, selected: bool) -> Response { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn filter_matches_all_terms() { - let app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); - let commands = commands::catalog(&app); - let matches = filter(&commands, "toggle snapping"); - assert!( - matches - .iter() - .any(|&index| commands[index].id == commands::CommandId::ToggleSnap) - ); - } - - #[test] - fn empty_state_is_constructible() { - let state = plotx_core::state::CommandPaletteState::default(); - assert!(state.query.is_empty()); - } -} +#[path = "command_palette_tests.rs"] +mod tests; diff --git a/crates/app/src/ui/command_palette_tests.rs b/crates/app/src/ui/command_palette_tests.rs new file mode 100644 index 0000000..9836b24 --- /dev/null +++ b/crates/app/src/ui/command_palette_tests.rs @@ -0,0 +1,219 @@ +use super::*; +use plotx_core::properties::contour; + +fn indices(items: &[PaletteItem], query: &str) -> Vec { + filter(items, query) + .into_iter() + .map(|index| items[index].action) + .collect() +} + +#[test] +fn filter_matches_all_terms() { + let app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); + let items = search_set(&app); + assert!( + indices(&items, "toggle snapping") + .contains(&PaletteAction::Command(commands::CommandId::ToggleSnap)) + ); +} + +/// The gap this stage closes: before, the search set was verbs only, so a +/// setting the user could see on screen could not be found by name. +#[test] +fn a_setting_is_findable_by_its_label() { + let app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); + let items = search_set(&app); + assert!( + indices(&items, "lowest level").contains(&PaletteAction::Property(contour::BASE_MAGNITUDE)), + "the essential contour control must be searchable by its label" + ); +} + +/// Every indexed term reaches the filter, not just the label: the canonical +/// alias, the localized alias, and the id read as separate words. +#[test] +fn canonical_aliases_and_id_tokens_are_searchable() { + let app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); + let items = search_set(&app); + for query in [ + "contour threshold", + "sigma", + "contour count", + "series.contour.ratio", + ] { + assert!( + indices(&items, query) + .iter() + .any(|action| matches!(action, PaletteAction::Property(_))), + "'{query}' must reach a property" + ); + } + assert!( + indices(&items, "contour count").contains(&PaletteAction::Property(contour::COUNT)), + "id tokens are matched word by word" + ); +} + +/// Resources join the same set, so the search finds the thing as well as the +/// verb and the setting. +#[test] +fn resources_are_part_of_the_search_set() { + let mut app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); + app.doc + .canvases + .push(plotx_core::state::CanvasDocument::new( + "Figure 3".to_owned(), + [120.0, 80.0], + )); + let items = search_set(&app); + assert!(indices(&items, "figure 3").contains(&PaletteAction::Canvas(0))); +} + +/// Activating a property hit routes to its declared home and starts the +/// reveal — expand, scroll, highlight — rather than executing anything. +#[test] +fn activating_a_property_requests_its_home_route() { + let mut app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); + app.session.secondary_sidebar_visible = false; + reveal_property(&mut app, contour::BASE_MAGNITUDE, 10.0); + assert!(app.session.secondary_sidebar_visible); + let focus = app.session.ui.property_focus.expect("focus is requested"); + assert_eq!(focus.property, contour::BASE_MAGNITUDE); + assert!(focus.pending); + assert!((focus.highlight_until - 10.8).abs() < 1e-9); +} + +fn property_item(items: &[PaletteItem], property: PropertyId) -> &PaletteItem { + items + .iter() + .find(|item| item.action == PaletteAction::Property(property)) + .expect("every presented property is in the search set") +} + +/// §8.5 channel 1 gates on applicability, because activating a hit only asks the +/// panel to reveal a row. A hit that applies to nothing had nowhere to scroll +/// to: the focus was requested and then hung there, having moved nothing. +/// +/// The entry stays in the list, disabled with a reason, rather than +/// disappearing — the crate's hide-vs-disable rule, and the reason a user +/// searching by name still finds the setting they were looking for. +#[test] +fn a_setting_that_applies_to_nothing_is_disabled_with_a_reason() { + let app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); + let items = search_set(&app); + let hit = property_item(&items, contour::BASE_MAGNITUDE); + assert!( + !hit.enabled, + "an empty document has no series to change the lowest level of" + ); + let reason = hit + .disabled_reason + .as_deref() + .expect("a gated entry explains itself"); + assert!( + reason.starts_with("Select"), + "the reason names the fix: {reason}" + ); + // Still findable: the gate is on activation, not on discovery. + assert!( + indices(&items, "lowest level").contains(&PaletteAction::Property(contour::BASE_MAGNITUDE)) + ); +} + +/// The gate is the catalog's own applicability answer, so a target that exists +/// but does not carry the setting is refused with the catalog's own reason +/// rather than a second rule written in the palette. +#[test] +fn a_setting_the_selected_series_cannot_carry_is_disabled_with_the_catalogs_reason() { + let (mut app, ids) = properties::fixture::contour_page(1); + properties::fixture::draw_as_heatmap(&mut app, ids[0]); + let items = search_set(&app); + let hit = property_item(&items, contour::BASE_MAGNITUDE); + assert!(!hit.enabled); + let reason = hit + .disabled_reason + .as_deref() + .expect("a gated entry explains itself"); + assert!( + reason.contains("heatmap"), + "the reason states what the series actually draws: {reason}" + ); +} + +#[test] +fn a_setting_the_selection_can_receive_stays_active() { + let (app, _) = properties::fixture::contour_page(1); + let items = search_set(&app); + let hit = property_item(&items, contour::BASE_MAGNITUDE); + assert!(hit.enabled, "a selected contour series can receive it"); + assert!(hit.disabled_reason.is_none()); +} + +/// Navigating to a page is not only a change of what is drawn. +/// +/// `ObjectId`s are allocated per page and start again at one, so a selection +/// left over from the page being left resolves here to an unrelated object — +/// and the inspector, the property panel and every tool would then act on it. +/// The palette therefore navigates through the same path every other page +/// switch uses instead of assigning `active_canvas` on its own. +#[test] +fn activating_a_page_does_not_carry_a_stale_selection_onto_it() { + let (mut app, ids) = properties::fixture::contour_page(2); + let (second, elsewhere) = properties::fixture::add_page(&mut app, "Figure 2", 0, 1); + assert_eq!( + elsewhere[0], ids[0], + "per-page allocation makes the ids collide, which is the whole problem" + ); + app.select_object(0, ids[1]); + + let ctx = egui::Context::default(); + let mut clipboard = clipboard_table::ClipboardTablePaste::default(); + activate( + PaletteAction::Canvas(second), + &mut app, + &mut clipboard, + &ctx, + ); + + assert_eq!(app.session.active_canvas, Some(second)); + assert_eq!( + app.session.ui.selection.objects(), + &elsewhere[..], + "the selection is re-derived from the page entered, never inherited" + ); + assert!(!app.session.ui.selection.objects().contains(&ids[1])); +} + +/// Landing on an object points the data focus at what that object draws, so the +/// two halves of the session do not end up describing different things. +#[test] +fn activating_an_object_brings_the_data_focus_with_it() { + let (mut app, _) = properties::fixture::contour_page(1); + let second_dataset = properties::fixture::add_dataset(&mut app); + let (page, objects) = properties::fixture::add_page(&mut app, "Figure 2", second_dataset, 1); + app.focus_single(0); + + let ctx = egui::Context::default(); + let mut clipboard = clipboard_table::ClipboardTablePaste::default(); + activate( + PaletteAction::Object(page, objects[0]), + &mut app, + &mut clipboard, + &ctx, + ); + + assert_eq!(app.session.active_canvas, Some(page)); + assert_eq!(app.session.ui.selection.objects(), &objects[..]); + assert_eq!( + app.active_dataset(), + Some(second_dataset), + "the data focus follows the object the search landed on" + ); +} + +#[test] +fn empty_state_is_constructible() { + let state = plotx_core::state::CommandPaletteState::default(); + assert!(state.query.is_empty()); +} diff --git a/crates/app/src/ui/commands.rs b/crates/app/src/ui/commands.rs index 67835ca..5407e09 100644 --- a/crates/app/src/ui/commands.rs +++ b/crates/app/src/ui/commands.rs @@ -5,6 +5,7 @@ use plotx_core::actions::ZOrder; use plotx_core::export::ExportFormat; use plotx_core::layout::{Align, Distribute, GutterPreset, SpacingMode}; +use plotx_core::properties::PropertyStep; use plotx_core::state::{Dataset, ObjectId, PlotxApp, Tool, WorkflowTab}; pub use super::command_exec::execute; @@ -108,6 +109,14 @@ pub enum CommandId { Distribute(Distribute), ZOrder(ZOrder), ApplyTheme(&'static str), + /// Reveal a whole group of catalog properties at its canonical home (§8.5 + /// channel 2). Registered per declared group, so a group gains a Ribbon + /// button, a menu entry and a palette hit by being declared once. It + /// navigates and never edits: the panel it opens owns the controls. + PropertyGroup(&'static str), + /// Move the canvas-steppable property one rung (§8.5 channel 3). The + /// property is derived from the catalog, so the binding does not name one. + StepProperty(PropertyStep), Tool(Tool), } @@ -272,6 +281,16 @@ pub fn catalog(app: &PlotxApp) -> Vec { .into_iter() .map(|theme| CommandId::ApplyTheme(theme.id)), ); + // Every declared property group, and the step gesture. Both are derived + // from the property catalog: a group declared once appears here, and a + // property that declares itself steppable is driven by the existing + // binding without any new command. + ids.extend( + super::properties::GROUPS + .iter() + .map(|group| CommandId::PropertyGroup(group.section)), + ); + ids.extend([PropertyStep::Lower, PropertyStep::Raise].map(CommandId::StepProperty)); ids.extend(tool_commands().into_iter().map(CommandId::Tool)); ids.into_iter() .map(|id| { @@ -516,6 +535,16 @@ pub fn describe(app: &PlotxApp, id: CommandId) -> CommandDescriptor { selected >= 1, "Select an object before changing its stacking order.", ), + CommandId::PropertyGroup(section) => requires( + super::properties::discovery::group_applies(app, section), + super::properties::discovery::group(section) + .map(|group| group.unavailable_reason) + .unwrap_or("Select an object that has these settings."), + ), + CommandId::StepProperty(_) => requires( + super::properties::discovery::step_target(app).is_some(), + "Select a plot whose series draws contours before stepping its lowest level.", + ), CommandId::Tool(tool) if tool.is_data_tool() => requires( dataset().is_some(), "Select a dataset before using this data tool.", @@ -618,6 +647,12 @@ fn ribbon_placement(id: CommandId) -> Option { CommandId::Tool( Tool::Text | Tool::PanelLabel | Tool::Rect | Tool::Ellipse | Tool::Line | Tool::Arrow, ) => (Arrange, "Annotate", 3, Always), + // The group's own declaration decides where it lands, so a new group + // needs no arm here. + CommandId::PropertyGroup(section) => { + let spot = super::properties::discovery::group(section)?.ribbon; + (spot.tab, spot.group, spot.priority, Always) + } _ => return None, }; Some(RibbonPlacement { diff --git a/crates/app/src/ui/commands/identity.rs b/crates/app/src/ui/commands/identity.rs index bd0257e..be7324b 100644 --- a/crates/app/src/ui/commands/identity.rs +++ b/crates/app/src/ui/commands/identity.rs @@ -5,6 +5,7 @@ use egui_phosphor::regular as icon; use plotx_core::actions::ZOrder; use plotx_core::layout::{Align, Distribute, GutterPreset, SpacingMode}; +use plotx_core::properties::PropertyStep; use plotx_core::state::{PlotxApp, Tool}; use super::CommandId; @@ -24,6 +25,8 @@ impl CommandId { Self::ZOrder(mode) => format!("arrange.order.{}", zorder_slug(mode)), Self::ApplyTheme(id) => format!("view.theme.{id}"), Self::SetCanvasSizePreset(id) => format!("figure.canvas_size.{id}"), + Self::PropertyGroup(section) => format!("properties.group.{section}"), + Self::StepProperty(step) => format!("properties.step.{}", step.as_str()), Self::Tool(tool) => format!("tool.{}", tool_slug(tool)), _ => simple_stable_id(self).to_owned(), } @@ -225,6 +228,31 @@ pub(super) fn command_identity( .unwrap_or_else(|| "Apply Theme".into()); (label, Some(icon::PALETTE), None) } + // Derived from the property catalog: the group supplies its own name + // and icon, so declaring a group is all a new one needs. + CommandId::PropertyGroup(section) => { + let group = crate::ui::properties::discovery::group(section); + ( + group + .map(|group| format!("{} settings", group.label.get())) + .unwrap_or_else(|| "Settings".to_owned()), + group.map(|group| group.icon), + None, + ) + } + // Named after whichever property declared itself steppable, so the + // gesture and its command can never describe different settings. + CommandId::StepProperty(step) => { + let setting = crate::ui::properties::discovery::steppable_in( + crate::ui::properties::PRESENTATIONS, + ) + .map(|entry| entry.localized_label.get().to_lowercase()) + .unwrap_or_else(|| "setting".to_owned()); + match step { + PropertyStep::Raise => (format!("Raise {setting}"), Some(icon::PLUS), None), + PropertyStep::Lower => (format!("Lower {setting}"), Some(icon::MINUS), None), + } + } CommandId::Tool(tool) => ( format!("Tool: {}", tool.label()), tool_icon(tool), diff --git a/crates/app/src/ui/mod.rs b/crates/app/src/ui/mod.rs index 4d6efcf..493c9bb 100644 --- a/crates/app/src/ui/mod.rs +++ b/crates/app/src/ui/mod.rs @@ -23,6 +23,7 @@ mod object_inspector; mod present; mod primary_sidebar; pub(crate) mod processing_templates; +pub(crate) mod properties; mod ribbon; mod secondary_sidebar; mod settings_dialog; diff --git a/crates/app/src/ui/object_inspector.rs b/crates/app/src/ui/object_inspector.rs index 461523b..0fe8f84 100644 --- a/crates/app/src/ui/object_inspector.rs +++ b/crates/app/src/ui/object_inspector.rs @@ -38,6 +38,7 @@ pub(crate) fn render(app: &mut PlotxApp, ui: &mut Ui) { } if ids.is_empty() { commit_panel_note_edit(app); + property_sections(app, ci, ui); return; } @@ -66,6 +67,7 @@ pub(crate) fn render(app: &mut PlotxApp, ui: &mut Ui) { note_focused = panel_note_section(app, ci, ids[0], ui); data_section(app, ci, ids[0], ui); } + property_sections(app, ci, ui); let text_ids = kind_targets(app, ci, &ids, |o| o.text().is_some()); let shape_ids = kind_targets(app, ci, &ids, |o| o.shape().is_some()); @@ -95,6 +97,21 @@ pub(crate) fn render(app: &mut PlotxApp, ui: &mut Ui) { ui.add_space(2.0); } +/// Catalog-driven rows for whatever the resolved plot selection draws. +/// +/// The objects come from the same resolution the Ribbon button, the context +/// menu and the canvas gesture use — every selected plot, or the page's active +/// plot when nothing is selected — rather than from this panel's own +/// single-selection guard. Reading the selection differently is what let those +/// channels enable a jump to a section that then drew nothing, and it put the +/// cross-target `Mixed` aggregate out of reach of the interface entirely. The +/// section renders nothing at all when no resolved series has an applicable +/// encoding. +fn property_sections(app: &mut PlotxApp, ci: usize, ui: &mut Ui) { + let objects = crate::ui::properties::discovery::selection_objects(app); + crate::ui::properties::panel::contour_section(app, ci, &objects, ui); +} + fn geometry_section(app: &mut PlotxApp, ci: usize, ids: &[ObjectId], ui: &mut Ui) { let primary = ids[0]; let Some(o) = app.doc.canvases[ci].object(primary) else { diff --git a/crates/app/src/ui/object_inspector/chart_gallery.rs b/crates/app/src/ui/object_inspector/chart_gallery.rs index 02f36d1..2f33770 100644 --- a/crates/app/src/ui/object_inspector/chart_gallery.rs +++ b/crates/app/src/ui/object_inspector/chart_gallery.rs @@ -6,6 +6,7 @@ use plotx_core::actions::Action; use plotx_core::state::{ ChartSpec, Dataset, ObjectId, PlotxApp, PresentationProfile, RequestedChart, chart_type, chart_types_for_capabilities, default_chart_type, default_encoding, encoding_descriptors_for, + field_peak_magnitude, }; pub(super) fn chart_gallery(app: &mut PlotxApp, ci: usize, object: ObjectId, ui: &mut Ui) { @@ -143,11 +144,14 @@ pub(super) fn chart_gallery(app: &mut PlotxApp, ci: usize, object: ObjectId, ui: }; let mut after = binding.clone(); if let Some(series) = after.series.first_mut() { + let source_field = series.source.field; + let dataset = &app.doc.datasets[primary]; series.encoding = default_encoding( &field.capabilities, &field.metadata, requested, &PresentationProfile::default(), + &|| field_peak_magnitude(dataset, source_field), ); } app.execute_action(Action::set_data_binding( diff --git a/crates/app/src/ui/primary_sidebar.rs b/crates/app/src/ui/primary_sidebar.rs index b521731..5072f7c 100644 --- a/crates/app/src/ui/primary_sidebar.rs +++ b/crates/app/src/ui/primary_sidebar.rs @@ -168,14 +168,7 @@ fn canvas_list(app: &mut PlotxApp, ui: &mut Ui) { if extend { plotx_core::state::toggle_frame_selection_synced(app, FrameRef::Page(ci)); } else { - app.session.active_canvas = Some(ci); - let lead = app.doc.canvases[ci] - .active_dataset() - .and_then(|id| app.doc.dataset_index(id)); - let datasets = app.doc.page_dataset_indices(ci); - app.focus_datasets(&datasets, lead); - app.sync_selection_to_active_canvas(); - app.reset_interaction(); + app.activate_canvas(ci); app.session.ui.panel_note_inline_edit = None; app.session.ui.panel_note_edit = None; app.session.ui.frame_selection = vec![FrameRef::Page(ci)]; diff --git a/crates/app/src/ui/properties/discovery.rs b/crates/app/src/ui/properties/discovery.rs new file mode 100644 index 0000000..d44ea1b --- /dev/null +++ b/crates/app/src/ui/properties/discovery.rs @@ -0,0 +1,197 @@ +//! The remaining discovery channels of §8.5, all derived from one registration. +//! +//! Search (channel 1) indexes properties individually. The Ribbon (2) and the +//! context menu (4) address *groups*: the Ribbon is an entry map, not a second +//! control surface, so a group gets one button that jumps to the section where +//! its members already live. The canvas gesture (3) addresses the one property +//! that declared itself steppable. +//! +//! None of these channels carries editing logic. Navigation opens the home the +//! presentation already names, and the gesture calls +//! [`PlotxApp::plan_property_step`], the same planner, validation and typed +//! action the panel control uses. There is exactly one source of state for a +//! property and exactly one path that writes it; what varies is only where the +//! user reaches for it. + +use super::{PRESENTATIONS, PropertyGroup, PropertyPresentation}; +use egui::Ui; +use plotx_core::automation::TargetRef; +use plotx_core::properties::{PropertyId, PropertyStep, Tier}; +use plotx_core::state::{ObjectId, PlotxApp}; + +/// The declared groups, in Ribbon order. +pub(crate) fn groups() -> &'static [PropertyGroup] { + super::GROUPS +} + +pub(crate) fn group(section: &str) -> Option<&'static PropertyGroup> { + groups().iter().find(|group| group.section == section) +} + +/// Every presentation whose home is this section. Membership is read off the +/// home route rather than listed again on the group, so registering a property +/// with an existing home is all it takes to appear in that group's Ribbon +/// button and context-menu entry. +pub(crate) fn members_of<'a>( + section: &str, + presentations: &'a [PropertyPresentation], +) -> Vec<&'a PropertyPresentation> { + presentations + .iter() + .filter(|entry| entry.home_route.section == section) + .collect() +} + +/// Where a jump to a group lands: its first Essential member, or its first +/// member when the group is entirely advanced. Landing on the row the user is +/// most likely to have come for is the difference between navigation and +/// merely opening a panel. +pub(crate) fn entry_property( + section: &str, + presentations: &[PropertyPresentation], +) -> Option { + let members = members_of(section, presentations); + members + .iter() + .find(|entry| entry.tier() == Some(Tier::Essential)) + .or_else(|| members.first()) + .map(|entry| entry.id) +} + +/// The property the canvas `+` / `-` gesture drives, if any. +/// +/// The opt-in lives on the presentation entry, so it is part of the property's +/// single registration rather than a second table a new property would have to +/// be added to. The gesture is not a channel every property can carry — a +/// colour has no direction — which is exactly why the entry declares it. +pub(crate) fn steppable_in( + presentations: &[PropertyPresentation], +) -> Option<&PropertyPresentation> { + presentations.iter().find(|entry| entry.canvas_step) +} + +/// The plot objects the discovery channels act on: the current selection, or +/// the page's active plot when nothing is selected. +/// +/// Every channel shares this, so the Ribbon button, the context menu, the +/// gesture and the on-canvas readout can never disagree about what they refer +/// to. +pub(crate) fn selection_objects(app: &PlotxApp) -> Vec { + let Some(canvas) = app + .session + .active_canvas + .and_then(|index| app.doc.canvases.get(index)) + else { + return Vec::new(); + }; + let selected: Vec = app + .session + .ui + .selection + .objects() + .iter() + .copied() + .filter(|&id| { + canvas + .object(id) + .is_some_and(|object| object.plot().is_some()) + }) + .collect(); + if !selected.is_empty() { + return selected; + } + canvas.active_plot_object_id().into_iter().collect() +} + +/// Every series target of [`selection_objects`], in binding order. +pub(crate) fn selection_targets(app: &PlotxApp) -> Vec { + let Some(canvas) = app.session.active_canvas else { + return Vec::new(); + }; + selection_objects(app) + .into_iter() + .flat_map(|object| app.series_targets(canvas, object)) + .collect() +} + +/// Whether any member of a group currently applies to the selection. This is +/// the group's own applicability, derived from its members' definitions — +/// capability and encoding gates included — not a second rule written here. +pub(crate) fn group_applies(app: &PlotxApp, section: &str) -> bool { + let targets = selection_targets(app); + if targets.is_empty() { + return false; + } + members_of(section, PRESENTATIONS).into_iter().any(|entry| { + !app.resolve_property_set(entry.id, &targets) + .applicable_targets + .is_empty() + }) +} + +/// The property and targets the `+` / `-` gesture would act on right now. +pub(crate) fn step_target(app: &PlotxApp) -> Option<(PropertyId, Vec)> { + let property = steppable_in(PRESENTATIONS)?.id; + let targets = selection_targets(app); + let applicable = app + .resolve_property_set(property, &targets) + .applicable_targets; + (!applicable.is_empty()).then_some((property, targets)) +} + +/// Channel 3: take one step on the selection, through the planner. +/// +/// The gesture computes nothing itself. It names a property and a direction; +/// the catalog decides what a step is, validates it and compiles it into the +/// same atomic action the panel produces, so the two entry points cannot drift. +pub(crate) fn step_selection(app: &mut PlotxApp, step: PropertyStep) { + let Some((property, targets)) = step_target(app) else { + return; + }; + match app.plan_property_step(property, &targets, step) { + Ok(commit) => { + let skipped = commit.skipped.len(); + let applied = app.commit_property(commit); + let label = super::presentation(property) + .map(|entry| entry.localized_label.get()) + .unwrap_or("setting"); + app.session.status = match skipped { + 0 => format!("Stepped {label} on {applied} series."), + skipped => format!("Stepped {label} on {applied} series; skipped {skipped}."), + }; + } + // A step that lands outside the property's own range is refused by the + // same validation a typed value meets, and the reason reaches the user + // rather than the gesture silently doing nothing. + Err(error) => app.session.status = format!("Could not step this setting: {error}"), + } +} + +/// Channel 4: the context-menu entries for whatever the selection draws. +/// +/// Navigation only — every entry reveals a group at its canonical home. +pub(crate) fn context_menu(app: &mut PlotxApp, ui: &mut Ui) { + let now = ui.input(|input| input.time); + let reachable: Vec<(&'static str, PropertyId)> = groups() + .iter() + .filter(|group| group_applies(app, group.section)) + .filter_map(|group| { + entry_property(group.section, PRESENTATIONS) + .map(|property| (group.label.get(), property)) + }) + .collect(); + if reachable.is_empty() { + return; + } + ui.separator(); + for (label, property) in reachable { + if ui.button(format!("{label} settings…")).clicked() { + super::super::command_palette::reveal_property(app, property, now); + ui.close(); + } + } +} + +#[cfg(test)] +#[path = "discovery_tests.rs"] +mod tests; diff --git a/crates/app/src/ui/properties/discovery_tests.rs b/crates/app/src/ui/properties/discovery_tests.rs new file mode 100644 index 0000000..0a19b3e --- /dev/null +++ b/crates/app/src/ui/properties/discovery_tests.rs @@ -0,0 +1,199 @@ +//! §8.5's central claim: **register once, and every applicable channel follows.** +//! +//! The four channels are not four registries. A property is declared exactly +//! once — a `PropertyDefinition` in the core catalog plus one +//! [`PropertyPresentation`] here — and search, the Ribbon group, the context +//! menu and the canvas gesture are all *derived* from that pair. These tests +//! pin that down by registering a property that exists nowhere else and +//! checking every channel picks it up with no further edit. +//! +//! The derivations are written against slices for exactly this reason: a test +//! can hand them a table with one extra entry, which is what "no second +//! registration" has to mean if it is to mean anything. + +use crate::ui::commands::{self, CommandId}; +use crate::ui::properties::{ + CONTOUR_HOME, GROUPS, LocalizedText, PRESENTATIONS, PropertyPresentation, discovery, panel, + presentation, search, +}; +use plotx_core::properties::{ + Applicability, ComponentKind, DefaultPolicy, EncodingKind, PropertyAccess, PropertyDefinition, + PropertyId, PropertyStep, ScopeKind, Tier, ValueCopies, ValueSchema, +}; +use plotx_core::state::PlotxApp; + +/// A property registered nowhere but here. It shares the contour section's +/// home, which is the ordinary case: a new setting joins a group that already +/// exists. +const NEWCOMER: PropertyDefinition = PropertyDefinition { + id: PropertyId("series.contour.newcomer"), + scope_kind: ScopeKind::Object, + value_schema: ValueSchema::Bool, + access: PropertyAccess::ReadWrite, + applicability: Applicability::encoding(ComponentKind::Series, EncodingKind::Contour), + default_policy: DefaultPolicy::None, + tier: Tier::Advanced, + copies: ValueCopies::PerTarget, + canonical_label: "Newcomer setting", + canonical_aliases: &["freshly registered"], +}; + +const NEWCOMER_PRESENTATION: PropertyPresentation = PropertyPresentation { + id: NEWCOMER.id, + localized_label: LocalizedText("Newcomer"), + localized_aliases: &[LocalizedText("brand new")], + home_route: CONTOUR_HOME, + canvas_step: true, +}; + +fn with_newcomer() -> Vec { + let mut table = vec![NEWCOMER_PRESENTATION]; + table.extend_from_slice(PRESENTATIONS); + table +} + +/// Channel 1: a single registration is enough to be searchable, by its id +/// tokens, its canonical terms and its localized ones. +#[test] +fn one_registration_is_searchable() { + let hits = search::hits_from(&[(&NEWCOMER, &NEWCOMER_PRESENTATION)]); + let hit = hits.first().expect("a registered property is indexed"); + assert_eq!(hit.id, NEWCOMER.id); + for term in [ + "newcomer", + "series", + "contour", + "freshly registered", + "brand new", + ] { + assert!( + hit.terms.contains(&term.to_owned()), + "'{term}' is not a search term of {}", + NEWCOMER.id + ); + } +} + +/// Channel 2 and 4: both address groups, and group membership is read off the +/// home route. A property that joins an existing group therefore appears in +/// that group's Ribbon button and context-menu entry without touching `GROUPS`. +#[test] +fn one_registration_joins_its_group_without_a_second_entry() { + let table = with_newcomer(); + let members = discovery::members_of(panel::CONTOUR_SECTION, &table); + assert!( + members.iter().any(|entry| entry.id == NEWCOMER.id), + "the newcomer must be a member of the group whose home it declared" + ); + assert_eq!( + members.len(), + PRESENTATIONS.len() + 1, + "membership is derived, so it grows with the table and nothing else" + ); + // The group table itself is untouched: the newcomer contributed no entry. + assert_eq!(GROUPS.len(), 1); +} + +/// Channel 3: the gesture picks up whichever property declared itself +/// steppable. The declaration lives on the same entry as everything else. +#[test] +fn one_registration_claims_the_canvas_gesture() { + let table = with_newcomer(); + assert_eq!( + discovery::steppable_in(&table).map(|entry| entry.id), + Some(NEWCOMER.id), + "a property that declares the gesture drives it with no further wiring" + ); +} + +/// Every declared group has a Ribbon button, a menu entry and a palette hit — +/// derived from `GROUPS`, so declaring a group is the whole registration. +#[test] +fn every_declared_group_has_a_ribbon_entry() { + let app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); + let catalog = commands::catalog(&app); + for group in GROUPS { + let descriptor = catalog + .iter() + .find(|command| command.id == CommandId::PropertyGroup(group.section)) + .unwrap_or_else(|| panic!("group '{}' has no command", group.section)); + let placement = commands::describe(&app, descriptor.id) + .ribbon + .unwrap_or_else(|| panic!("group '{}' has no Ribbon placement", group.section)); + assert_eq!(placement.tab, group.ribbon.tab); + assert_eq!(placement.group, group.ribbon.group); + assert!(descriptor.label.contains(group.label.get())); + } +} + +/// The Ribbon and the context menu are entry maps. Every group must therefore +/// name a property to land on, and that property must have a resolvable home — +/// a button that opens nothing is worse than no button. +#[test] +fn every_group_lands_on_a_property_with_a_home() { + for group in GROUPS { + let property = discovery::entry_property(group.section, PRESENTATIONS) + .unwrap_or_else(|| panic!("group '{}' has no member to land on", group.section)); + let entry = presentation(property).expect("the landing property is presented"); + assert_eq!(entry.home_route.section, group.section); + assert!(entry.home_route.panel.sections().contains(&group.section)); + } +} + +/// A group with no section behind it could never be navigated to, and a +/// section with no group would silently lose channels 2 and 4. Both directions +/// are checked so the omission fails the build instead of the interface. +#[test] +fn groups_and_home_sections_correspond() { + for group in GROUPS { + assert!( + !discovery::members_of(group.section, PRESENTATIONS).is_empty(), + "group '{}' has no members", + group.section + ); + } + for entry in PRESENTATIONS { + assert!( + discovery::group(entry.home_route.section).is_some(), + "{} lives in section '{}', which declares no group, so it is \ + unreachable from the Ribbon and the context menu", + entry.id, + entry.home_route.section + ); + } +} + +/// The gesture drives one setting at a time. Two steppable properties would +/// make `+` mean different things depending on table order, which is precisely +/// the kind of hidden ambiguity a derived channel must not introduce. +#[test] +fn at_most_one_property_claims_the_canvas_gesture() { + let claiming: Vec<&str> = PRESENTATIONS + .iter() + .filter(|entry| entry.canvas_step) + .map(|entry| entry.id.as_str()) + .collect(); + assert!( + claiming.len() <= 1, + "these properties all claim the `+`/`-` gesture: {claiming:?}" + ); +} + +/// The gesture is registered as a command, so it is searchable, appears in +/// menus, and is gated with a reason like every other action. +#[test] +fn the_gesture_is_a_catalog_command_with_a_reason_when_it_cannot_run() { + let app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); + for step in [PropertyStep::Raise, PropertyStep::Lower] { + let descriptor = commands::describe(&app, CommandId::StepProperty(step)); + assert!(!descriptor.enabled, "an empty document has nothing to step"); + let reason = descriptor + .disabled_reason + .expect("a gated command explains"); + assert!( + reason.starts_with("Select"), + "reason must name the fix: {reason}" + ); + assert!(!descriptor.label.is_empty()); + } +} diff --git a/crates/app/src/ui/properties/fixture.rs b/crates/app/src/ui/properties/fixture.rs new file mode 100644 index 0000000..b8032bb --- /dev/null +++ b/crates/app/src/ui/properties/fixture.rs @@ -0,0 +1,131 @@ +//! Documents the property interface tests share. +//! +//! The interface half of the catalog is only testable against a document that +//! actually draws something, and more than one test needs the same one: a page +//! of contour plots whose settings can be made to agree or to differ. + +use plotx_core::automation::TargetRef; +use plotx_core::properties::{PropertyValue, contour}; +use plotx_core::state::{ + CanvasDocument, Dataset, Nmr2DDataset, ObjectFrame, ObjectId, PlotxApp, Selection, +}; + +fn nmr2d(source: &str) -> plotx_io::NmrData2D { + let dimension = |nucleus: &str| plotx_io::Dim { + spectral_width_hz: 4_000.0, + observe_freq_mhz: 400.0, + carrier_ppm: 0.0, + nucleus: nucleus.to_owned(), + group_delay: 0.0, + }; + plotx_io::NmrData2D { + data: (0..16) + .map(|value| num_complex::Complex64::new(f64::from(value) - 7.0, 0.5)) + .collect(), + rows: 4, + cols: 4, + domain: plotx_io::Domain::Frequency, + direct: dimension("1H"), + indirect: dimension("13C"), + quad: plotx_io::QuadMode::Complex, + indirect_conjugate: false, + experiment: None, + pseudo_axis: None, + diffusion: None, + nus: None, + source: source.to_owned(), + } +} + +/// One page holding `plots` contour plots of one 2D spectrum, all selected. +pub(crate) fn contour_page(plots: usize) -> (PlotxApp, Vec) { + let mut app = PlotxApp::new(); + app.doc + .datasets + .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load(nmr2d("panel"))))); + let mut canvas = CanvasDocument::new("page".to_owned(), [200.0, 200.0]); + let mut ids = Vec::new(); + for index in 0..plots { + let id = canvas.allocate_object_id(); + let object = app.build_plot_object( + 0, + ObjectFrame::new(0.0, 60.0 * index as f32, 100.0, 50.0), + id, + format!("Plot {index}"), + ); + canvas.objects.push(object); + ids.push(id); + } + app.doc.canvases.push(canvas); + app.session.active_canvas = Some(0); + app.set_selection(Selection::Objects(ids.clone())); + (app, ids) +} + +/// A second dataset, so a navigation test can tell whether the data focus +/// followed the object it landed on. +pub(crate) fn add_dataset(app: &mut PlotxApp) -> usize { + app.doc + .datasets + .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load(nmr2d( + "second", + ))))); + app.doc.datasets.len() - 1 +} + +/// Another page carrying `plots` plots of `dataset`. Object ids are allocated +/// per page, so the ids here start again at one — which is exactly why a +/// selection may not survive a page switch. +pub(crate) fn add_page( + app: &mut PlotxApp, + name: &str, + dataset: usize, + plots: usize, +) -> (usize, Vec) { + let mut canvas = CanvasDocument::new(name.to_owned(), [200.0, 200.0]); + let mut ids = Vec::new(); + for index in 0..plots { + let id = canvas.allocate_object_id(); + let object = app.build_plot_object( + dataset, + ObjectFrame::new(0.0, 60.0 * index as f32, 100.0, 50.0), + id, + format!("{name} {index}"), + ); + canvas.objects.push(object); + ids.push(id); + } + app.doc.canvases.push(canvas); + (app.doc.canvases.len() - 1, ids) +} + +/// Redraw one plot's series as a heatmap: a target that exists, resolves, and +/// carries no contour setting. +pub(crate) fn draw_as_heatmap(app: &mut PlotxApp, object: ObjectId) { + if let Some(series) = app.doc.canvases[0] + .object_mut(object) + .and_then(|object| object.plot_mut()) + .and_then(|plot| plot.binding.series.first_mut()) + { + series.encoding = + plotx_figure::SeriesEncoding::Heatmap(plotx_figure::HeatmapSpec::default()); + } +} + +pub(crate) fn targets_of(app: &PlotxApp, object: ObjectId) -> Vec { + app.series_targets(0, object) +} + +/// Move one plot's lowest level through the catalog, so the disagreement the +/// panel then reports was produced the way a user produces it. +pub(crate) fn set_lowest_level(app: &mut PlotxApp, object: ObjectId, multiplier: f64) { + let targets = targets_of(app, object); + let commit = app + .plan_property_write( + contour::BASE_MAGNITUDE, + &targets, + &PropertyValue::Float(multiplier), + ) + .expect("the fixture writes a valid multiplier"); + app.commit_property(commit); +} diff --git a/crates/app/src/ui/properties/mod.rs b/crates/app/src/ui/properties/mod.rs new file mode 100644 index 0000000..d6d860a --- /dev/null +++ b/crates/app/src/ui/properties/mod.rs @@ -0,0 +1,226 @@ +//! Presentation for the property catalog: what a property is called in the +//! interface and where it lives. +//! +//! The semantic half — identity, schema, applicability, tier, default policy — +//! stays in `plotx_core::properties`. This table adds only what the interface +//! needs, and may never introduce an entry that has no definition behind it. In +//! particular the tier is *read* from the definition rather than repeated here, +//! so a property cannot be Essential in the panel and Advanced in the catalog. + +pub(crate) mod discovery; +pub(crate) mod panel; +pub(crate) mod readout; +mod search; + +#[cfg(test)] +pub(crate) mod fixture; + +pub(crate) use search::property_hits; + +use plotx_core::properties::{PropertyDefinition, PropertyId, Tier, contour, definition}; +use plotx_core::state::WorkflowTab; + +/// A user-facing string in the active locale. PlotX ships one locale today; the +/// type marks which strings are translatable so adding another is a table edit +/// rather than a rework of the search index. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LocalizedText(pub &'static str); + +impl LocalizedText { + pub const fn get(self) -> &'static str { + self.0 + } +} + +/// Which panel owns a property's canonical home. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PanelRoute { + SecondarySidebar, +} + +impl PanelRoute { + /// The section ids this panel actually renders. A home route naming + /// anything else could not be navigated to, which is what the consistency + /// test checks. + pub const fn sections(self) -> &'static [&'static str] { + match self { + Self::SecondarySidebar => &[panel::CONTOUR_SECTION], + } + } + + pub const fn title(self) -> &'static str { + match self { + Self::SecondarySidebar => "Object inspector", + } + } +} + +/// Where a property is edited. This is data, not code: navigation opens the +/// panel, expands the section and scrolls to the row named by the property id. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct HomeRoute { + pub panel: PanelRoute, + pub section: &'static str, +} + +/// The interface half of one catalog entry. +#[derive(Clone, Copy, Debug)] +pub struct PropertyPresentation { + pub id: PropertyId, + pub localized_label: LocalizedText, + pub localized_aliases: &'static [LocalizedText], + pub home_route: HomeRoute, + /// Whether the canvas `+` / `-` gesture drives this property (§8.5 + /// channel 3). Declared here, on the property's single registration, so the + /// gesture is derived rather than listed in a table of its own. Most + /// properties have no natural direction, which is why this is an opt-in and + /// not an inference. + pub canvas_step: bool, +} + +/// Where a group of properties appears in the Ribbon. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RibbonSpot { + pub tab: WorkflowTab, + pub group: &'static str, + /// Lower values survive longer as the Ribbon's width budget tightens. + pub priority: u8, +} + +/// One group of properties with a shared home (§8.5 channel 2 and 4). +/// +/// The Ribbon and the context menu address groups, never single parameters: +/// they are entry maps that jump to the panel section where the controls +/// already live. Membership is not listed here — it is read off the members' +/// home routes — so adding a property to an existing group requires no edit to +/// this table. +#[derive(Clone, Copy, Debug)] +pub struct PropertyGroup { + /// The home-route section its members share. + pub section: &'static str, + pub label: LocalizedText, + pub icon: &'static str, + pub ribbon: RibbonSpot, + /// Why the entry is disabled when nothing in the selection has a member of + /// this group. Starts with a verb and says how to unblock it. + pub unavailable_reason: &'static str, +} + +pub const GROUPS: &[PropertyGroup] = &[PropertyGroup { + section: panel::CONTOUR_SECTION, + label: LocalizedText("Contour"), + icon: egui_phosphor::regular::CHART_POLAR, + ribbon: RibbonSpot { + tab: WorkflowTab::Figure, + group: "Style", + priority: 2, + }, + unavailable_reason: "Select a plot whose series draws contours before changing contour levels.", +}]; + +impl PropertyPresentation { + /// The tier lives on the definition; presentation reads it so the panel + /// budget and the catalog can never disagree. + pub fn tier(&self) -> Option { + definition(self.id).map(|definition| definition.tier) + } + + pub fn definition(&self) -> Option<&'static PropertyDefinition> { + definition(self.id) + } +} + +const CONTOUR_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::SecondarySidebar, + section: panel::CONTOUR_SECTION, +}; + +pub const PRESENTATIONS: &[PropertyPresentation] = &[ + PropertyPresentation { + id: contour::BASE_MAGNITUDE, + localized_label: LocalizedText("Lowest level"), + localized_aliases: &[ + LocalizedText("threshold"), + LocalizedText("contour threshold"), + LocalizedText("noise multiple"), + ], + home_route: CONTOUR_HOME, + // The one contour setting worth reaching without leaving the plot: + // §1 principle 4(c) — the best parameter is the one you never look for. + canvas_step: true, + }, + PropertyPresentation { + id: contour::BASE_POLICY, + localized_label: LocalizedText("Anchor"), + localized_aliases: &[LocalizedText("level anchor"), LocalizedText("base policy")], + home_route: CONTOUR_HOME, + canvas_step: false, + }, + PropertyPresentation { + id: contour::COUNT, + localized_label: LocalizedText("Levels"), + localized_aliases: &[ + LocalizedText("contour levels"), + LocalizedText("number of contours"), + ], + home_route: CONTOUR_HOME, + canvas_step: false, + }, + PropertyPresentation { + id: contour::RATIO, + localized_label: LocalizedText("Level ratio"), + localized_aliases: &[LocalizedText("contour spacing")], + home_route: CONTOUR_HOME, + canvas_step: false, + }, + PropertyPresentation { + id: contour::NEGATIVE_ENABLED, + localized_label: LocalizedText("Negative contours"), + localized_aliases: &[LocalizedText("negative peaks")], + home_route: CONTOUR_HOME, + canvas_step: false, + }, + PropertyPresentation { + id: contour::POSITIVE_COLOR, + localized_label: LocalizedText("Positive colour"), + localized_aliases: &[LocalizedText("contour colour")], + home_route: CONTOUR_HOME, + canvas_step: false, + }, + PropertyPresentation { + id: contour::NEGATIVE_COLOR, + localized_label: LocalizedText("Negative colour"), + localized_aliases: &[], + home_route: CONTOUR_HOME, + canvas_step: false, + }, + PropertyPresentation { + id: contour::LINE_WIDTH, + localized_label: LocalizedText("Line width"), + localized_aliases: &[LocalizedText("contour width")], + home_route: CONTOUR_HOME, + canvas_step: false, + }, +]; + +pub fn presentation(id: PropertyId) -> Option<&'static PropertyPresentation> { + PRESENTATIONS + .iter() + .find(|presentation| presentation.id == id) +} + +/// Essential entries a single panel section renders by default. The budget +/// check counts these. +pub fn essential_in(section: &str) -> Vec<&'static PropertyPresentation> { + PRESENTATIONS + .iter() + .filter(|presentation| { + presentation.home_route.section == section + && presentation.tier() == Some(Tier::Essential) + }) + .collect() +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/crates/app/src/ui/properties/panel.rs b/crates/app/src/ui/properties/panel.rs new file mode 100644 index 0000000..5077636 --- /dev/null +++ b/crates/app/src/ui/properties/panel.rs @@ -0,0 +1,490 @@ +//! The property panel: catalog-driven controls for the current selection. +//! +//! Every row here is generated from a [`PropertyDefinition`] plus its +//! presentation entry, so a property gains a control, a search entry, a +//! "modified" marker and a reset by being registered once. Only `Essential` +//! rows are rendered by default; everything else is folded away, which is what +//! keeps the panel from growing a row per feature. + +use super::{PRESENTATIONS, PropertyPresentation}; +use egui::{DragValue, Ui}; +use egui_phosphor::regular as icon; +use plotx_core::automation::TargetRef; +use plotx_core::properties::{ + AggregateValue, ContourBaseReadout, EncodingKind, PropertyDefinition, PropertyId, + PropertyValue, ResolvedProperty, ResolvedPropertySet, ResolvedSchema, ValueCopies, ValueSchema, + contour, +}; +use plotx_core::state::{ObjectId, PlotxApp, PropertyFocus}; + +/// The home-route section id of the contour rows. The route table and the +/// collapsing header below must agree on it, so both read this constant. +pub(crate) const CONTOUR_SECTION: &str = "object.contour"; + +/// What a control shows in place of a number or a choice when there is none: +/// the sources behind the row do not agree, so no value may be presented as the +/// current one. +const NO_SINGLE_VALUE: &str = "—"; + +/// Why a row has no single value, and what setting one now will do. +/// +/// Which sources disagree is derived from two facts the row already knows: how +/// many targets it read, and whether the definition says one target holds one +/// copy of the setting or one per mirrored half. Stating both possibilities at +/// once was true but blunt — a single selected series with an asymmetric ladder +/// and two series that merely differ are different problems with different +/// fixes, and the row can tell them apart. +fn no_single_value_hint(targets: usize, copies: ValueCopies) -> String { + let halves = copies == ValueCopies::PerMirroredHalf; + let sources = match (targets, halves) { + (0..=1, true) => "the positive and negative halves of this ladder hold different values", + (0..=1, false) => "this series holds more than one value for it", + (_, true) => { + "the selected series — and the two halves of each ladder — do not all hold the \ + same value" + } + (_, false) => "the selected series do not all hold the same value", + }; + format!("No single value: {sources}. Setting it now applies to all of them.") +} + +/// One catalog row, already resolved against the selection. +struct Row { + presentation: &'static PropertyPresentation, + definition: &'static PropertyDefinition, + set: ResolvedPropertySet, + representative: ResolvedProperty, + /// What the number in this row currently *means* (§4.3). Present only on + /// the anchored-level row, only when the row has a single value to explain, + /// and only from what the derived caches already hold — reading it never + /// starts a measurement. + readout: Option, +} + +impl Row { + /// The current value, or `None` when the sources behind the row disagree — + /// several selected series, or the two halves of one contour ladder. + fn value(&self) -> Option { + self.set.value.uniform().copied() + } + + /// The value a control edits *from* when there is none to show. It is the + /// factory default, never one source's value: the numeric and choice + /// controls hide it outright, and the colour swatch — which cannot render + /// blank — then shows a colour that belongs to nobody in the selection + /// rather than passing one target's off as the answer. + fn editing_value(&self) -> Option { + self.value().or(self.representative.default_value) + } + + fn mixed(&self) -> bool { + matches!(self.set.value, AggregateValue::Mixed) + } + + fn modified(&self) -> bool { + self.representative.is_modified() || self.mixed() + } +} + +/// A control edit waiting to be committed once the immutable borrows are done. +enum Pending { + Write(PropertyId, PropertyValue), + Reset(PropertyId), + ResetEncoding, +} + +/// Render the contour section for the current selection. Returns `false` when +/// nothing in the selection draws a contour, in which case the caller draws no +/// heading either. +pub(crate) fn contour_section( + app: &mut PlotxApp, + canvas: usize, + objects: &[ObjectId], + ui: &mut Ui, +) -> bool { + let targets: Vec = objects + .iter() + .flat_map(|&object| app.series_targets(canvas, object)) + .collect(); + if targets.is_empty() { + return false; + } + let rows = resolve_rows(app, &targets); + if rows.is_empty() { + return false; + } + + let now = ui.input(|input| input.time); + let focus = app.session.ui.property_focus; + let focused_here = + focus.is_some_and(|focus| rows.iter().any(|row| row.presentation.id == focus.property)); + + ui.separator(); + ui.horizontal(|ui| { + ui.strong("Contour"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.weak(match targets.len() { + 1 => "1 series".to_owned(), + count => format!("{count} series"), + }); + }); + }); + + // The rows rendered without expanding anything are exactly the list the + // budget check counts, so the check cannot pass while the panel shows more. + let essential: Vec = super::essential_in(CONTOUR_SECTION) + .into_iter() + .map(|entry| entry.id) + .collect(); + let mut pending: Option = None; + for row in rows + .iter() + .filter(|row| essential.contains(&row.presentation.id)) + { + property_row(row, focus, now, &mut pending, ui); + } + + let advanced: Vec<&Row> = rows + .iter() + .filter(|row| !essential.contains(&row.presentation.id)) + .collect(); + if !advanced.is_empty() { + let id = ui.make_persistent_id(("property_section", CONTOUR_SECTION)); + let mut state = + egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, false); + if focused_here + && focus.is_some_and(|focus| { + advanced + .iter() + .any(|row| row.presentation.id == focus.property) + }) + { + state.set_open(true); + state.store(ui.ctx()); + } + egui::CollapsingHeader::new("Advanced") + .id_salt(("property_section", CONTOUR_SECTION)) + .show(ui, |ui| { + for row in advanced { + property_row(row, focus, now, &mut pending, ui); + } + }); + } + + if ui + .small_button("Reset contour") + .on_hover_text("Rebuild this series' encoding from its defaults") + .clicked() + { + pending = Some(Pending::ResetEncoding); + } + + // The reveal is one-shot: once the section has been drawn with the row in + // it, only the fading highlight remains. + if let Some(focus) = app.session.ui.property_focus.as_mut() + && focused_here + { + focus.pending = false; + } + if focus.is_some_and(|focus| now >= focus.highlight_until) { + app.session.ui.property_focus = None; + } + + if let Some(pending) = pending { + apply(app, &targets, pending); + } + true +} + +fn resolve_rows(app: &PlotxApp, targets: &[TargetRef]) -> Vec { + let mut rows = Vec::new(); + for presentation in PRESENTATIONS { + let Some(definition) = presentation.definition() else { + continue; + }; + if definition.applicability.encoding != Some(plotx_core::properties::EncodingKind::Contour) + { + continue; + } + let set = app.resolve_property_set(presentation.id, targets); + let Some(first) = set.applicable_targets.first() else { + continue; + }; + let Ok(representative) = app.resolve_property(first) else { + continue; + }; + // A readout is a statement about *the* current value, and it is read + // from one target. When the sources disagree there is no such value, so + // the row is given none: resolving one target's level and captioning it + // as the row's would pass one series' threshold off as the selection's, + // which is the same misrepresentation the control itself refuses when it + // blanks its number. + let readout = (presentation.id == contour::BASE_MAGNITUDE && set.value.uniform().is_some()) + .then(|| app.contour_base_readout(&first.target)) + .flatten(); + rows.push(Row { + presentation, + definition, + set, + representative, + readout, + }); + } + rows +} + +fn property_row( + row: &Row, + focus: Option, + now: f64, + pending: &mut Option, + ui: &mut Ui, +) { + let highlighted = focus + .is_some_and(|focus| focus.property == row.presentation.id && now < focus.highlight_until); + let response = ui + .scope(|ui| { + ui.horizontal(|ui| { + ui.label(row.presentation.localized_label.get()) + .on_hover_text(row.definition.canonical_label); + control(row, pending, ui); + if row.modified() { + modified_marker(row, pending, ui); + } + }); + }) + .response; + if highlighted { + ui.painter().rect_stroke( + response.rect.expand(2.0), + 4.0, + egui::Stroke::new(1.5_f32, ui.visuals().selection.bg_fill), + egui::StrokeKind::Outside, + ); + ui.ctx().request_repaint(); + } + if focus.is_some_and(|focus| focus.property == row.presentation.id && focus.pending) { + response.scroll_to_me(None); + } +} + +/// The "modified" affordance: a marker whose tooltip names the default, and a +/// one-click reset back to it. +fn modified_marker(row: &Row, pending: &mut Option, ui: &mut Ui) { + let default = row + .representative + .default_value + .map(|value| describe(row, value)) + .unwrap_or_else(|| "no default".to_owned()); + let hint = if row.mixed() { + format!( + "{} Default: {default}", + no_single_value_hint(row.set.applicable_targets.len(), row.definition.copies) + ) + } else { + format!("Changed from the default: {default}") + }; + ui.label(icon::DOT_OUTLINE).on_hover_text(&hint); + if ui + .small_button(icon::ARROW_COUNTER_CLOCKWISE) + .on_hover_text(format!("Reset to {default}")) + .clicked() + { + *pending = Some(Pending::Reset(row.presentation.id)); + } +} + +/// Draw the control for one row. +/// +/// When the row has no single value the widget still edits — one gesture must +/// be enough to make the whole selection agree — but it displays nothing that +/// could be read as the current setting: an em dash instead of a number or a +/// choice, and no checkbox state at all. +fn control(row: &Row, pending: &mut Option, ui: &mut Ui) { + let mixed = row.mixed(); + let Some(value) = row.editing_value() else { + ui.weak("unavailable"); + return; + }; + match (&row.representative.schema, value) { + (ResolvedSchema::Bool, PropertyValue::Bool(current)) => { + if mixed { + // A checkbox has no third state, and an unticked box would be a + // claim about the selection. Two unselected choices are not. + for (label, next) in [("On", true), ("Off", false)] { + if ui.selectable_label(false, label).clicked() { + *pending = Some(Pending::Write( + row.presentation.id, + PropertyValue::Bool(next), + )); + } + } + } else { + let mut current = current; + if ui.checkbox(&mut current, "").changed() { + *pending = Some(Pending::Write( + row.presentation.id, + PropertyValue::Bool(current), + )); + } + } + } + (ResolvedSchema::Int { min, max }, PropertyValue::Int(current)) => { + let mut current = current; + let drag = DragValue::new(&mut current).speed(0.25).range(*min..=*max); + if ui.add(hide_value(drag, mixed)).changed() { + *pending = Some(Pending::Write( + row.presentation.id, + PropertyValue::Int(current), + )); + } + } + (ResolvedSchema::Float { bounds, log, unit }, PropertyValue::Float(current)) => { + let mut next = current; + // A logarithmic quantity spans many decades, so the drag step has + // to follow the value rather than the (unbounded) range. + let speed = if *log { + (current.abs() * 0.02).max(f64::MIN_POSITIVE) + } else { + ((bounds.max - bounds.min) / 200.0).max(1.0e-3) + }; + // An egui range is inclusive, so an open bound is entered here by + // asking the schema for the smallest value it admits rather than by + // nudging the literal — the nudge would be a second copy of the rule. + let drag = DragValue::new(&mut next) + .speed(speed) + .range(bounds.lowest()..=bounds.max); + if ui.add(hide_value(drag, mixed)).changed() { + *pending = Some(Pending::Write( + row.presentation.id, + PropertyValue::Float(next), + )); + } + if !unit.is_empty() { + ui.weak(*unit); + } + // §4.3: the multiple alone does not say whether a cross peak + // survives; the resolved level does. + if let Some(readout) = &row.readout + && let Some(suffix) = super::readout::resolution_suffix(readout) + { + ui.weak(suffix) + .on_hover_text(super::readout::explanation(readout)); + } + } + (ResolvedSchema::Enum { variants }, PropertyValue::Enum(current)) => { + // Nothing is current when the sources disagree, so no variant is + // marked selected and the box names none of them. + let current = (!mixed).then_some(current); + let selected = current + .map(|current| { + variants + .iter() + .find(|variant| variant.id == current) + .map(|variant| variant.canonical_label) + .unwrap_or(current) + }) + .unwrap_or(NO_SINGLE_VALUE); + egui::ComboBox::from_id_salt(row.presentation.id.as_str()) + .selected_text(selected) + .show_ui(ui, |ui| { + for variant in variants { + if ui + .selectable_label(current == Some(variant.id), variant.canonical_label) + .clicked() + { + *pending = Some(Pending::Write( + row.presentation.id, + PropertyValue::Enum(variant.id), + )); + } + } + }); + } + (ResolvedSchema::Color, PropertyValue::Color(current)) => { + let mut rgb = [current.r, current.g, current.b]; + if ui.color_edit_button_srgb(&mut rgb).changed() { + *pending = Some(Pending::Write( + row.presentation.id, + PropertyValue::Color(plotx_figure::Color::rgb(rgb[0], rgb[1], rgb[2])), + )); + } + } + // A control and a value of different shapes means the schema and the + // domain model disagree; say so rather than drawing a wrong widget. + _ => { + ui.weak("unavailable"); + } + } + if mixed { + ui.weak("mixed").on_hover_text(no_single_value_hint( + row.set.applicable_targets.len(), + row.definition.copies, + )); + } +} + +/// Blank a drag control's readout while leaving it draggable and typable. The +/// number it still carries only decides where a drag starts; it is never shown. +fn hide_value(drag: DragValue<'_>, hidden: bool) -> DragValue<'_> { + if hidden { + drag.custom_formatter(|_, _| NO_SINGLE_VALUE.to_owned()) + } else { + drag + } +} + +fn describe(row: &Row, value: PropertyValue) -> String { + match value { + PropertyValue::Bool(value) => if value { "on" } else { "off" }.to_owned(), + PropertyValue::Int(value) => value.to_string(), + PropertyValue::Float(value) => format!("{value:.4}"), + // Read the label from the full static variant list, not the ones this + // field permits: a default may name a choice the user cannot switch + // back to by hand, and it still has to be nameable in the tooltip. + PropertyValue::Enum(value) => match row.definition.value_schema { + ValueSchema::Enum { variants } => variants + .iter() + .find(|variant| variant.id == value) + .map(|variant| variant.canonical_label.to_owned()) + .unwrap_or_else(|| value.to_owned()), + _ => value.to_owned(), + }, + PropertyValue::Color(color) => format!("#{:02x}{:02x}{:02x}", color.r, color.g, color.b), + } +} + +fn apply(app: &mut PlotxApp, targets: &[TargetRef], pending: Pending) { + let planned = match pending { + Pending::Write(property, value) => app.plan_property_write(property, targets, &value), + Pending::Reset(property) => app.plan_property_reset(property, targets), + // Scoped to the encoding this section is about: a plot that stacks a + // contour over a heatmap must not have the heatmap rebuilt by a button + // that names the contour. + Pending::ResetEncoding => app.plan_encoding_reset(EncodingKind::Contour, targets), + }; + match planned { + Ok(commit) => { + let skipped = commit.skipped.clone(); + let applied = app.commit_property(commit); + // A skipped target is reported, never silently dropped: the user + // asked for the whole selection and must learn what it did not do. + app.session.status = if skipped.is_empty() { + format!("Updated {applied} contour series.") + } else { + format!( + "Updated {applied} contour series; skipped {}: {}", + skipped.len(), + skipped[0].1 + ) + }; + } + Err(error) => { + app.session.status = format!("Could not change the contour: {error}"); + } + } +} + +#[cfg(test)] +#[path = "panel_tests.rs"] +mod tests; diff --git a/crates/app/src/ui/properties/panel_tests.rs b/crates/app/src/ui/properties/panel_tests.rs new file mode 100644 index 0000000..c4f87ce --- /dev/null +++ b/crates/app/src/ui/properties/panel_tests.rs @@ -0,0 +1,143 @@ +//! What the panel says when its sources do not agree, and which sources it has. +//! +//! Both are questions about representation rather than about editing. A row +//! whose targets disagree may show no value at all — not a number, and not a +//! caption derived from one of them — and a panel that reads a narrower +//! selection than the channels which navigate to it cannot show a disagreement +//! at all, because it never sees two targets. + +use super::*; +use crate::ui::properties::{discovery, fixture}; + +/// Resolve the section's rows the way the panel does: over the plot selection +/// discovery resolves, not over a hand-picked target list. +fn rows_for_selection(app: &PlotxApp) -> Vec { + let targets: Vec = discovery::selection_objects(app) + .into_iter() + .flat_map(|object| app.series_targets(0, object)) + .collect(); + resolve_rows(app, &targets) +} + +fn lowest_level_row(rows: &[Row]) -> &Row { + rows.iter() + .find(|row| row.presentation.id == contour::BASE_MAGNITUDE) + .expect("the lowest level is a row of the contour section") +} + +/// The debt phase 5a recorded: one hint that stated both possibilities at +/// once was true but blunt. With the definition declaring how many copies a +/// target holds, the row can name the sources that actually disagree — and +/// it does so without knowing what a `ContourSpec` is. +#[test] +fn the_hint_names_the_sources_that_actually_disagree() { + let one_ladder = no_single_value_hint(1, ValueCopies::PerMirroredHalf); + assert!( + one_ladder.contains("positive and negative halves"), + "a single series with an asymmetric ladder is a halves problem: {one_ladder}" + ); + assert!(!one_ladder.contains("selected series")); + + let many_plain = no_single_value_hint(3, ValueCopies::PerTarget); + assert!( + many_plain.contains("selected series"), + "several targets of a one-copy setting is a selection problem: {many_plain}" + ); + assert!(!many_plain.contains("halves")); + + // Only when both are possible may the hint state both. + let many_ladders = no_single_value_hint(3, ValueCopies::PerMirroredHalf); + assert!(many_ladders.contains("selected series")); + assert!(many_ladders.contains("halves")); +} + +/// A single selected plot is the ordinary case, and it is uniform. +#[test] +fn one_selected_plot_reads_as_a_single_value() { + let (app, _) = fixture::contour_page(1); + let row = rows_for_selection(&app).len(); + assert!(row > 0, "a selected contour plot renders catalog rows"); + let rows = rows_for_selection(&app); + assert!(!lowest_level_row(&rows).mixed()); +} + +/// The cross-target `Mixed` aggregate has to be *reachable from the interface*. +/// +/// Everything below the panel could already report it, but the section was +/// rendered inside a single-selection guard, so no interface path ever handed +/// it two targets. A read model that cannot be exercised is not a feature; this +/// selects two contour plots that disagree and checks the row the panel would +/// draw says so. +#[test] +fn two_selected_plots_that_disagree_reach_the_panel_as_mixed() { + let (mut app, ids) = fixture::contour_page(2); + fixture::set_lowest_level(&mut app, ids[1], 9.0); + assert_eq!( + discovery::selection_objects(&app).len(), + 2, + "both plots are in the resolved selection" + ); + + let rows = rows_for_selection(&app); + let row = lowest_level_row(&rows); + assert!( + row.mixed(), + "two series holding different lowest levels have no single value" + ); + assert_eq!(row.set.applicable_targets.len(), 2); + assert!(row.value().is_none(), "a mixed row shows no number"); + assert!(row.modified(), "a mixed row is not the factory default"); +} + +/// Selecting nothing falls back to the page's active plot, which is what the +/// Ribbon button and the context menu already gate on. The panel has to agree, +/// or those channels enable a jump to a section that draws nothing. +#[test] +fn an_empty_selection_still_resolves_the_pages_active_plot() { + let (mut app, _) = fixture::contour_page(1); + app.set_selection(plotx_core::state::Selection::None); + assert_eq!(discovery::selection_objects(&app).len(), 1); + assert!( + !rows_for_selection(&app).is_empty(), + "the section renders wherever the discovery channels say it applies" + ); +} + +/// §8.3 and §4.3 together: a row with no single value may not caption itself +/// with one target's resolved level. +/// +/// The control already blanks its number when the sources disagree, but the +/// readout beside it was resolved from the first applicable target and printed +/// regardless — `5 × σ = 1.2e4` next to an em dash, presenting one series' +/// threshold as the selection's. The row is given no readout at all rather than +/// being trusted to hide one, so there is no level text for a control to print. +#[test] +fn a_mixed_row_carries_no_resolved_level() { + let (mut app, ids) = fixture::contour_page(2); + fixture::set_lowest_level(&mut app, ids[1], 9.0); + + let rows = rows_for_selection(&app); + let row = lowest_level_row(&rows); + assert!(row.mixed()); + assert!( + row.readout.is_none(), + "a mixed row has no single lowest level to resolve, so it states none: {:?}", + row.readout + ); +} + +/// The complement, so the suppression cannot be satisfied by never producing a +/// readout at all: one plot, agreeing with itself, still gets its anchor +/// sentence. +#[test] +fn a_uniform_row_still_carries_its_resolved_level() { + let (app, _) = fixture::contour_page(1); + let rows = rows_for_selection(&app); + let row = lowest_level_row(&rows); + assert!(!row.mixed()); + let readout = row + .readout + .as_ref() + .expect("a single agreeing series states what its multiple means"); + assert_eq!(readout.magnitude, 5.0); +} diff --git a/crates/app/src/ui/properties/readout.rs b/crates/app/src/ui/properties/readout.rs new file mode 100644 index 0000000..705a530 --- /dev/null +++ b/crates/app/src/ui/properties/readout.rs @@ -0,0 +1,368 @@ +//! Wording for the live contour readout (§4.3). +//! +//! The control edits a number whose meaning depends on the anchor, so the +//! interface states the whole sentence: `5 × σ = 1.2e4`, not `5`. The resolved +//! half comes from an asynchronous estimate, so this also has to say what an +//! unmeasured or degenerate anchor looks like — honestly, and without ever +//! asking for the measurement. + +use plotx_core::properties::{ContourAnchor, ContourBaseReadout}; +use plotx_core::state::{ + CONTOUR_BASE_ABSOLUTE, CONTOUR_BASE_BACKGROUND_SCALE, CONTOUR_BASE_FRACTION_OF_RANGE, + CONTOUR_BASE_NOISE_FLOOR, +}; + +/// The multiple in the terms the user set it in, with no resolved level: `5 × σ`, +/// `background + 5 × spread`, `4% of range`, or a bare level. +/// +/// A floored noise anchor has two terms and names the one actually in force. It +/// has to: `5 × σ` over a level the estimate did not produce would describe a +/// picture the data never made, and the whole point of showing the anchor rather +/// than the bare number is that the sentence is true. +pub(crate) fn anchor_expression(readout: &ContourBaseReadout) -> String { + let magnitude = number(readout.magnitude); + match readout.kind { + CONTOUR_BASE_NOISE_FLOOR if readout.anchor == ContourAnchor::Floored => { + format!("{magnitude} × {}", peak_floor(readout)) + } + CONTOUR_BASE_NOISE_FLOOR => format!("{magnitude} × σ"), + CONTOUR_BASE_BACKGROUND_SCALE => format!("background + {magnitude} × spread"), + CONTOUR_BASE_FRACTION_OF_RANGE => format!("{magnitude} of range"), + // An absolute level, and anything a future policy adds: the number is + // already in the field's own units. + _ => magnitude, + } +} + +/// The anchor's floor, named as the quantity it is: a fraction of the field's +/// own peak. Falls back to the generic words when no fraction is known, so a +/// missing number never turns into a missing explanation. +fn peak_floor(readout: &ContourBaseReadout) -> String { + match readout.peak_fraction { + Some(fraction) => format!("{}% of peak", number(fraction * 100.0)), + None => "the noise floor".to_owned(), + } +} + +/// The full sentence for a compact space — the plot corner, or the row next to +/// the control. +/// +/// An absolute base is already a level, so restating it as `1.2e4 = 1.2e4` +/// would be noise; every other anchor resolves to something the number alone +/// does not say. +pub(crate) fn summary(readout: &ContourBaseReadout) -> String { + let expression = anchor_expression(readout); + match readout.anchor { + // The estimator measured no spread at all. Reporting `5 × σ = 0` would + // describe a blank plot, and the plot is not blank: the ladder falls + // back to one derived from the field's own peak. Say that instead. + ContourAnchor::Degenerate => format!("{expression} — no spread measured"), + ContourAnchor::Measuring => format!("{expression} — measuring…"), + // The expression already names the floor; this says why it, and not the + // estimate, is what the level came from. Without the clause the reader + // has no way to tell a floor that is merely configured from one that is + // currently deciding the picture. + ContourAnchor::Floored => match readout.lowest_level { + Some(level) => format!("{expression} = {} — σ is below this floor", number(level)), + None => format!("{expression} — σ is below this floor"), + }, + ContourAnchor::Direct | ContourAnchor::Measured => match readout.lowest_level { + Some(level) if readout.kind != CONTOUR_BASE_ABSOLUTE => { + format!("{expression} = {}", number(level)) + } + Some(level) => number(level), + None => expression, + }, + } +} + +/// The corner label for one plot, over every series the gesture would move. +/// +/// `None` when the plot has no such series. When it has several that agree the +/// label is their shared sentence; when they disagree it says so rather than +/// promoting one series' threshold to the plot's, which would put a number on +/// screen that the keys are about to move away from on some other series. +pub(crate) fn aggregate_summary(readouts: &[ContourBaseReadout]) -> Option { + let first = readouts.first()?; + if readouts.iter().all(|readout| readout == first) { + return Some(summary(first)); + } + Some(format!( + "{} contour series — no single lowest level", + readouts.len() + )) +} + +/// Just the resolved half, for a row that already shows the number the control +/// edits. `None` when the number is the level and there is nothing to add. +pub(crate) fn resolution_suffix(readout: &ContourBaseReadout) -> Option { + match readout.anchor { + ContourAnchor::Degenerate => Some("no spread measured".to_owned()), + ContourAnchor::Measuring => Some("measuring…".to_owned()), + // The row's own unit reads "× noise floor", which is true of both terms. + // Naming the floor here is what makes the row say which one is in force. + ContourAnchor::Floored => Some(match readout.lowest_level { + Some(level) => format!("= {} ({})", number(level), peak_floor(readout)), + None => format!("= {}", peak_floor(readout)), + }), + ContourAnchor::Direct | ContourAnchor::Measured => readout + .lowest_level + .filter(|_| readout.kind != CONTOUR_BASE_ABSOLUTE) + .map(|level| format!("= {}", number(level))), + } +} + +/// The longer form, for a tooltip that has room to explain rather than label. +pub(crate) fn explanation(readout: &ContourBaseReadout) -> String { + let level = readout + .lowest_level + .map(|level| format!("The lowest level drawn is {}. ", number(level))) + .unwrap_or_default(); + if readout.anchor == ContourAnchor::Floored { + return format!( + "{level}This field's estimated noise σ is below the anchor's floor of {floor}, \ + so the multiple is measured against the floor instead. A field with this much \ + dynamic range carries the sampling artefacts of its own strongest feature well \ + above its thermal noise, and a level under the floor traces those rather than \ + peaks. Choose the absolute anchor to set a level below it anyway.", + floor = peak_floor(readout), + ); + } + let anchor = match readout.anchor { + ContourAnchor::Direct => "This level is set directly, so it needs no measurement.", + // Only an anchor that *has* a floor may mention one; a background anchor + // has none, and inventing one here would describe a rule it does not + // follow. + ContourAnchor::Measured if readout.peak_fraction.is_some() => { + return format!( + "{level}The multiple is measured against this field's own estimated noise σ, \ + so it follows the data. σ is at or above the anchor's floor of {floor}, so \ + the floor is not in force.", + floor = peak_floor(readout), + ); + } + ContourAnchor::Measured => { + "The multiple is measured against this field's own estimated scale, \ + so it follows the data." + } + ContourAnchor::Floored => unreachable!("returned above"), + ContourAnchor::Degenerate => { + "This field has no measurable spread — a flat or perfectly regular \ + grid — so the multiple anchors nothing and the levels fall back to \ + a ladder derived from the field's peak." + } + ContourAnchor::Measuring => { + "The scale this multiple is measured against is still being \ + estimated in the background; the level appears when it arrives." + } + }; + format!("{level}{anchor}") +} + +/// Print a level or a multiple the way a user reads it: plain decimals across +/// the range typed by hand, scientific notation once the digits stop being +/// legible. +fn number(value: f64) -> String { + if value != 0.0 && (value.abs() >= 1.0e5 || value.abs() < 1.0e-3) { + format!("{value:.3e}") + } else { + let rounded = (value * 1.0e4).round() / 1.0e4; + format!("{rounded}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn readout( + kind: &'static str, + anchor: ContourAnchor, + level: Option, + ) -> ContourBaseReadout { + ContourBaseReadout { + kind, + magnitude: 5.0, + lowest_level: level, + peak_fraction: (kind == CONTOUR_BASE_NOISE_FLOOR).then_some(1.0e-4), + anchor, + } + } + + /// §4.3: the interface states the semantics the estimate gives the number. + #[test] + fn a_measured_multiple_reads_as_the_level_it_resolves_to() { + assert_eq!( + summary(&readout( + CONTOUR_BASE_NOISE_FLOOR, + ContourAnchor::Measured, + Some(12_000.0) + )), + "5 × σ = 12000" + ); + // Past the point where digits stop being countable, the level switches + // to scientific notation rather than becoming an unreadable run. + assert_eq!( + summary(&readout( + CONTOUR_BASE_NOISE_FLOOR, + ContourAnchor::Measured, + Some(1.234e8) + )), + "5 × σ = 1.234e8" + ); + assert_eq!( + summary(&readout( + CONTOUR_BASE_BACKGROUND_SCALE, + ContourAnchor::Measured, + Some(3.5) + )), + "background + 5 × spread = 3.5" + ); + } + + /// A degenerate estimate must never be presented as `5 × σ = 0`: that reads + /// as a blank plot, and the plot is drawing a fallback ladder. + #[test] + fn a_degenerate_estimate_is_not_reported_as_a_zero_level() { + let text = summary(&readout( + CONTOUR_BASE_NOISE_FLOOR, + ContourAnchor::Degenerate, + Some(0.0), + )); + assert_eq!(text, "5 × σ — no spread measured"); + assert!(!text.contains("= 0")); + assert!( + explanation(&readout( + CONTOUR_BASE_NOISE_FLOOR, + ContourAnchor::Degenerate, + None + )) + .contains("no measurable spread") + ); + } + + /// A pending estimate shows the multiple and admits the rest is unknown. + #[test] + fn a_pending_estimate_shows_the_multiple_and_says_it_is_measuring() { + assert_eq!( + summary(&readout( + CONTOUR_BASE_NOISE_FLOOR, + ContourAnchor::Measuring, + None + )), + "5 × σ — measuring…" + ); + } + + /// The corner label speaks for every series the keys would move. + /// + /// It used to read the plot's *first* series, which is neither necessarily + /// the one the gesture edits — a contour stacked over a heatmap is drawn + /// second — nor necessarily the only one. With several contours it printed + /// one of their thresholds while `+` moved them all. + #[test] + fn a_corner_label_over_several_series_states_a_level_only_when_they_agree() { + let one = readout( + CONTOUR_BASE_NOISE_FLOOR, + ContourAnchor::Measured, + Some(12_000.0), + ); + assert_eq!(aggregate_summary(&[]), None, "no series, no label"); + assert_eq!(aggregate_summary(&[one]), Some(summary(&one))); + assert_eq!( + aggregate_summary(&[one, one]), + Some(summary(&one)), + "series that agree read as the value they agree on" + ); + + let other = ContourBaseReadout { + magnitude: 9.0, + lowest_level: Some(21_600.0), + ..one + }; + let mixed = aggregate_summary(&[one, other]).expect("two series still get a label"); + assert!( + !mixed.contains("12000") && !mixed.contains("21600"), + "no single series' level may stand for the plot: {mixed}" + ); + assert!(mixed.contains('2'), "it says how many disagree: {mixed}"); + } + + /// The sentence that made the floor worth spelling into the policy. + /// + /// When the peak floor is what the level came from, saying `5 × σ` would + /// name a quantity the plot was not drawn from — the exact substitution this + /// readout exists to prevent. Every surface has to carry it: the compact + /// corner label, the row beside the control, and the tooltip. + #[test] + fn a_floored_anchor_names_the_floor_and_never_the_estimate() { + let floored = readout( + CONTOUR_BASE_NOISE_FLOOR, + ContourAnchor::Floored, + Some(1.6521e5), + ); + + assert_eq!( + summary(&floored), + "5 × 0.01% of peak = 1.652e5 — σ is below this floor" + ); + assert_eq!(anchor_expression(&floored), "5 × 0.01% of peak"); + assert_eq!( + resolution_suffix(&floored).as_deref(), + Some("= 1.652e5 (0.01% of peak)") + ); + for text in [ + summary(&floored), + anchor_expression(&floored), + resolution_suffix(&floored).unwrap_or_default(), + ] { + assert!( + !text.contains("× σ"), + "a floored level must never be presented as a multiple of σ: {text}" + ); + } + + let explained = explanation(&floored); + assert!(explained.contains("below the anchor's floor of 0.01% of peak")); + assert!( + explained.contains("absolute anchor"), + "the tooltip ends on the way past the floor: {explained}" + ); + } + + /// The other half of the same property: an anchor whose estimate clears the + /// floor reads exactly as it did before the floor existed, and says the + /// floor is not in force. + #[test] + fn a_measured_anchor_is_unchanged_and_says_the_floor_is_not_in_force() { + let measured = readout( + CONTOUR_BASE_NOISE_FLOOR, + ContourAnchor::Measured, + Some(12_000.0), + ); + assert_eq!(summary(&measured), "5 × σ = 12000"); + assert_eq!(resolution_suffix(&measured).as_deref(), Some("= 12000")); + assert!(explanation(&measured).contains("at or above the anchor's floor")); + + // A background anchor has no floor, so nothing may claim one for it. + let background = readout( + CONTOUR_BASE_BACKGROUND_SCALE, + ContourAnchor::Measured, + Some(3.5), + ); + assert!(!explanation(&background).contains("floor")); + } + + /// An absolute base is already the level; restating it twice is noise. + #[test] + fn an_absolute_base_is_stated_once() { + assert_eq!( + summary(&readout( + CONTOUR_BASE_ABSOLUTE, + ContourAnchor::Direct, + Some(1_200.0) + )), + "1200" + ); + } +} diff --git a/crates/app/src/ui/properties/search.rs b/crates/app/src/ui/properties/search.rs new file mode 100644 index 0000000..3f89a7d --- /dev/null +++ b/crates/app/src/ui/properties/search.rs @@ -0,0 +1,84 @@ +//! The property half of the unified search index. +//! +//! Registering a definition and its presentation is what makes a property +//! findable; nothing here is written per property. The matched terms are the +//! union the design calls for: the id's own tokens, the canonical label and +//! aliases (stable English, so a headless or scripted caller finds the same +//! entry), and the active locale's label and aliases. + +use super::{PropertyPresentation, presentation}; +use plotx_core::properties::{PropertyDefinition, PropertyId, catalog}; + +/// One searchable property entry. +#[derive(Clone, Debug)] +pub(crate) struct PropertyHit { + pub id: PropertyId, + /// What the palette row shows. + pub label: String, + /// Where activating it navigates to. + pub home: &'static str, + /// Lower-cased terms a query is matched against. + pub terms: Vec, +} + +/// Every user-visible property, in catalog order. +pub(crate) fn property_hits() -> Vec { + let pairs: Vec<(&PropertyDefinition, &PropertyPresentation)> = catalog() + .iter() + .filter_map(|definition| Some((*definition, presentation(definition.id)?))) + .collect(); + hits_from(&pairs) +} + +/// Build the index from an explicit table. +/// +/// Nothing here is written per property: a definition and its presentation are +/// enough to produce a searchable entry, which is what makes registration alone +/// sufficient. Taking the table as an argument is what lets a test prove that +/// with an entry that exists nowhere else. +pub(crate) fn hits_from( + pairs: &[(&PropertyDefinition, &PropertyPresentation)], +) -> Vec { + pairs + .iter() + .map(|(definition, presentation)| { + let mut terms: Vec = Vec::new(); + terms.push(definition.id.as_str().to_lowercase()); + terms.extend(definition.id.tokens().map(str::to_lowercase)); + terms.push(definition.canonical_label.to_lowercase()); + terms.extend( + definition + .canonical_aliases + .iter() + .map(|alias| alias.to_lowercase()), + ); + terms.push(presentation.localized_label.get().to_lowercase()); + terms.extend( + presentation + .localized_aliases + .iter() + .map(|alias| alias.get().to_lowercase()), + ); + terms.sort_unstable(); + terms.dedup(); + PropertyHit { + id: definition.id, + label: presentation.localized_label.get().to_owned(), + home: presentation.home_route.panel.title(), + terms, + } + }) + .collect() +} + +/// Presentations with no definition behind them. The index above simply skips +/// such an entry, so a mistake degrades rather than panics at runtime; this +/// exists so the consistency test can fail the build instead. +#[cfg(test)] +pub(crate) fn orphan_presentations() -> Vec { + super::PRESENTATIONS + .iter() + .filter(|presentation| presentation.definition().is_none()) + .map(|presentation| presentation.id) + .collect() +} diff --git a/crates/app/src/ui/properties/tests.rs b/crates/app/src/ui/properties/tests.rs new file mode 100644 index 0000000..6b23017 --- /dev/null +++ b/crates/app/src/ui/properties/tests.rs @@ -0,0 +1,150 @@ +use super::*; +use plotx_core::properties::{PropertyAccess, catalog}; + +/// Rows a single panel section renders without being expanded. Beyond this the +/// section is no longer a panel but a settings dump, and the fix is to re-tier +/// properties rather than to raise the number. +const MAX_ESSENTIAL_PER_SECTION: usize = 6; + +/// §8.1: every user-visible property must have a presentation. A definition +/// without one is addressable by automation yet invisible and unsearchable in +/// the interface — exactly the asymmetry the catalog exists to remove. +#[test] +fn every_user_visible_property_has_a_presentation() { + let missing: Vec<&str> = catalog() + .iter() + .filter(|definition| definition.access != PropertyAccess::ReadOnly) + .filter(|definition| presentation(definition.id).is_none()) + .map(|definition| definition.id.as_str()) + .collect(); + assert!( + missing.is_empty(), + "these properties have no presentation entry: {missing:?}" + ); +} + +/// §8.1: the reverse direction. The frontend may add localized search terms but +/// may never invent an entry with no semantic definition behind it. +#[test] +fn every_presentation_points_at_a_valid_definition() { + assert!( + search::orphan_presentations().is_empty(), + "these presentations name no definition: {:?}", + search::orphan_presentations() + ); + for entry in PRESENTATIONS { + let definition = entry.definition().expect("checked above"); + assert_eq!(definition.id, entry.id); + assert_eq!( + entry.tier(), + Some(definition.tier), + "{} must read its tier from the definition", + entry.id + ); + } +} + +/// §8.1: stable ids are unique on the presentation side too, so navigation and +/// search can never land on two different rows for one id. +#[test] +fn presentation_ids_are_unique() { + let mut ids: Vec<&str> = PRESENTATIONS + .iter() + .map(|entry| entry.id.as_str()) + .collect(); + let total = ids.len(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), total, "presentation ids must be unique"); +} + +/// §8.1: a home route must be resolvable — its panel has to declare the section +/// it names, otherwise a search hit opens nothing. +#[test] +fn every_home_route_resolves_to_a_declared_section() { + for entry in PRESENTATIONS { + let route = entry.home_route; + assert!( + route.panel.sections().contains(&route.section), + "{} routes to '{}', which {} does not render", + entry.id, + route.section, + route.panel.title() + ); + } +} + +/// §8.1: aliases must actually reach the unified search index. A canonical +/// alias nobody indexes is a comment, not a search term. +#[test] +fn aliases_are_indexed_by_the_unified_search() { + let hits = property_hits(); + assert_eq!(hits.len(), PRESENTATIONS.len()); + for definition in catalog() { + let Some(entry) = presentation(definition.id) else { + continue; + }; + let hit = hits + .iter() + .find(|hit| hit.id == definition.id) + .expect("every presented property is indexed"); + for alias in definition.canonical_aliases { + assert!( + hit.terms.contains(&alias.to_lowercase()), + "canonical alias '{alias}' of {} is not indexed", + definition.id + ); + } + for alias in entry.localized_aliases { + assert!( + hit.terms.contains(&alias.get().to_lowercase()), + "localized alias '{}' of {} is not indexed", + alias.get(), + definition.id + ); + } + assert!( + hit.terms + .contains(&entry.localized_label.get().to_lowercase()) + ); + // The dotted id is searchable term by term, so "contour count" finds + // `series.contour.count` without the user knowing the id. + for token in definition.id.tokens() { + assert!(hit.terms.contains(&token.to_lowercase())); + } + } +} + +/// §8.6: the panel budget. Exceeding it means the section has stopped being a +/// panel, and the remedy is to move rows to `Advanced`, not to raise the limit. +#[test] +fn no_panel_section_exceeds_its_essential_budget() { + for panel in [PanelRoute::SecondarySidebar] { + for section in panel.sections() { + let essential = essential_in(section); + assert!( + essential.len() <= MAX_ESSENTIAL_PER_SECTION, + "section '{section}' of the {} renders {} Essential properties, \ + over the budget of {MAX_ESSENTIAL_PER_SECTION}. Re-tier some of \ + {:?} to Advanced or Expert instead of raising the budget.", + panel.title(), + essential.len(), + essential + .iter() + .map(|entry| entry.id.as_str()) + .collect::>() + ); + } + } +} + +/// §12: only the lowest level is Essential on a contour; the ladder shape and +/// the negative-half colour are for users who went looking for them. +#[test] +fn only_the_lowest_contour_level_is_essential() { + let essential: Vec<&str> = essential_in(panel::CONTOUR_SECTION) + .iter() + .map(|entry| entry.id.as_str()) + .collect(); + assert_eq!(essential, [contour::BASE_MAGNITUDE.as_str()]); +} diff --git a/crates/app/src/ui/ribbon.rs b/crates/app/src/ui/ribbon.rs index 48ec17b..d7012c2 100644 --- a/crates/app/src/ui/ribbon.rs +++ b/crates/app/src/ui/ribbon.rs @@ -341,6 +341,11 @@ fn short_label(command: &CommandDescriptor) -> String { .to_owned(), CommandId::Align(_) => command.label.trim_start_matches("Align ").to_owned(), CommandId::Distribute(_) => command.label.trim_start_matches("Distribute ").to_owned(), + // A Ribbon tile shows the group's own short name; the full "… settings" + // wording stays in the tooltip, the menus and the palette. + CommandId::PropertyGroup(section) => super::properties::discovery::group(section) + .map(|group| group.label.get().to_owned()) + .unwrap_or_else(|| "Settings".to_owned()), CommandId::Tool(Tool::BrowseZoom) => "Zoom".to_owned(), CommandId::Tool(_) => command.label.trim_start_matches("Tool: ").to_owned(), _ => command.label.clone(), diff --git a/crates/app/src/ui/shortcuts.rs b/crates/app/src/ui/shortcuts.rs index fc2fab9..0d4719d 100644 --- a/crates/app/src/ui/shortcuts.rs +++ b/crates/app/src/ui/shortcuts.rs @@ -1,4 +1,5 @@ use super::*; +use plotx_core::properties::PropertyStep; use plotx_core::state::Tool; /// A key chord expressed with egui types. `command` means Cmd on macOS and @@ -144,6 +145,27 @@ static BINDINGS: &[CommandBinding] = &[ dispatch: false, menu_accelerator: false, }, + // §8.5 channel 3 / §12: bare `+` and `-` step the canvas-steppable setting. + // The bare keys are free because every existing zoom binding takes a + // modifier: Cmd/Ctrl for the UI scale above, and Alt+wheel for the y-axis + // zoom in `canvas::navigation`. The chord names a direction, not a + // property — which setting it moves is derived from the catalog. + CommandBinding { + id: commands::CommandId::StepProperty(PropertyStep::Raise), + primary: plain(egui::Key::Plus), + // "+" usually needs Shift, and some layouts report the key as Equals; + // the plain matcher ignores Shift, so both spellings are accepted. + aliases: &[plain(egui::Key::Equals)], + dispatch: true, + menu_accelerator: false, + }, + CommandBinding { + id: commands::CommandId::StepProperty(PropertyStep::Lower), + primary: plain(egui::Key::Minus), + aliases: &[], + dispatch: true, + menu_accelerator: false, + }, tool_key(Tool::Select, egui::Key::V), tool_key(Tool::BrowseZoom, egui::Key::Z), tool_key(Tool::Text, egui::Key::T), diff --git a/crates/core/src/actions/tests/stack.rs b/crates/core/src/actions/tests/stack.rs index dca9fe7..4e45fa3 100644 --- a/crates/core/src/actions/tests/stack.rs +++ b/crates/core/src/actions/tests/stack.rs @@ -77,7 +77,7 @@ fn field_overlay_stacks_two_2d_contours_in_distinct_colors() { let column = index % signed_grid.cols; let row = index / signed_grid.cols; // A non-zero robust noise scale keeps this signed test field on the - // concrete NoiseSigma path rather than exercising a degenerate plane. + // concrete NoiseFloor path rather than exercising a degenerate plane. *value = num_complex::Complex64::new( column as f64 - 15.5 + ((row * 17 + column * 13) % 11) as f64 * 0.037, 0.0, diff --git a/crates/core/src/automation/resources.rs b/crates/core/src/automation/resources.rs index fe317ee..a5eb9e7 100644 --- a/crates/core/src/automation/resources.rs +++ b/crates/core/src/automation/resources.rs @@ -731,6 +731,15 @@ fn child_ref(parent: impl ToString, local: &str, kind: &str) -> ResourceRef { } } +/// The child reference that addresses one plot object of a page. Property +/// targets and descriptors must agree on this shape, so both build it here. +pub fn canvas_object_ref( + canvas: crate::state::CanvasId, + object: crate::state::ObjectId, +) -> ResourceRef { + child_ref(canvas, &object.to_string(), KIND_CANVAS_OBJECT) +} + fn cap(id: &str) -> CapabilityId { CapabilityId::new(id) } diff --git a/crates/core/src/figures.rs b/crates/core/src/figures.rs index 6fda64c..abea059 100644 --- a/crates/core/src/figures.rs +++ b/crates/core/src/figures.rs @@ -10,7 +10,10 @@ use plotx_figure::{ use plotx_io::NmrData; use plotx_processing::{Preset2D, Spectrum, Spectrum2D, StackSpectrum}; -use crate::state::{ResolvedPeak, default_contour_spec, scalar_grid_capabilities}; +use crate::state::{ + EstimatedScale, FieldSummary, FiniteF64, ResolvedPeak, default_contour_spec, + scalar_grid_capabilities, +}; pub fn build_figure(data: &NmrData, spec: &Spectrum, peaks: &[ResolvedPeak]) -> Figure { let (ppm_lo, ppm_hi) = spec.ppm_bounds(); @@ -78,8 +81,27 @@ fn contour_levels( } let base = match &level.base { ContourBasePolicy::Absolute(value) => value.get(), - ContourBasePolicy::NoiseSigma { multiplier, .. } => { - robust_difference_mad(values, rows, cols) * multiplier.get() + ContourBasePolicy::NoiseFloor { + multiplier, + peak_fraction, + .. + } => { + // The floor decision is shared with the versioned resolver for the + // same reason the ladder is: two copies of it would drift, and this + // path and that one must agree on what a field's noise scale is. + let Some(scale) = EstimatedScale::new(robust_difference_mad(values, rows, cols)) else { + return Vec::new(); + }; + let (Some(min), Some(max)) = (FiniteF64::new(minimum), FiniteF64::new(maximum)) else { + return Vec::new(); + }; + multiplier.get() + * crate::state::resolved_noise_scale( + scale, + *peak_fraction, + FieldSummary { min, max }, + ) + .0 } ContourBasePolicy::BackgroundScale { multiplier, .. } => { let (location, scale) = deplaned_location_scale(values, rows, cols); @@ -344,7 +366,8 @@ pub fn build_ilt_figure_cancellable( fn bounded_scalar_contour_spec() -> ContourSpec { let capabilities = scalar_grid_capabilities(true, &[crate::automation::CAP_FIELD_BOUNDED]); - default_contour_spec(&capabilities) + // `Bounded` anchors the base to the value range, so no peak is consulted. + default_contour_spec(&capabilities, crate::state::NO_PEAK) } #[cfg(test)] @@ -413,8 +436,9 @@ mod tests { fn noise_sigma_level(count: u16) -> ContourLevelSpec { ContourLevelSpec { - base: ContourBasePolicy::NoiseSigma { + base: ContourBasePolicy::NoiseFloor { multiplier: plotx_figure::PositiveFiniteF64::new(5.0).unwrap(), + peak_fraction: plotx_figure::UnitInterval::new(0.0).expect("a zero floor is valid"), estimator: plotx_figure::EstimatorSelection::FollowLatest, }, count, @@ -638,8 +662,9 @@ mod tests { #[test] fn one_level_contour_uses_an_interior_fallback_for_degenerate_mad() { let level = ContourLevelSpec { - base: ContourBasePolicy::NoiseSigma { + base: ContourBasePolicy::NoiseFloor { multiplier: plotx_figure::PositiveFiniteF64::new(5.0).unwrap(), + peak_fraction: plotx_figure::UnitInterval::new(0.0).expect("a zero floor is valid"), estimator: plotx_figure::EstimatorSelection::FollowLatest, }, count: 1, diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index e35f0a7..8a95535 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -9,6 +9,7 @@ pub mod fit_model_library; pub mod layout; pub mod operation; pub mod project; +pub mod properties; pub mod settings; pub mod state; pub mod templates; diff --git a/crates/core/src/properties/contour.rs b/crates/core/src/properties/contour.rs new file mode 100644 index 0000000..63c6391 --- /dev/null +++ b/crates/core/src/properties/contour.rs @@ -0,0 +1,603 @@ +//! Contour properties on a plot object's series. +//! +//! These are the catalog's first entries and its driving case: the levels a 2D +//! scalar field is drawn at. Their address is deliberately +//! `target.resource = plot object`, `component = Series(SeriesId)` — the field a +//! series reads from is a *data source*, and where geometry happens to be cached +//! is an implementation accident. Neither determines who owns the setting. +//! +//! The ladder is symmetric: base, count and ratio describe one ladder that the +//! negative half mirrors, and each half applies its own sign during resolution. +//! Writes therefore update every half that exists. A [`ContourSpec`] can still +//! hold two halves that differ, so reads of a shared rung report a value only +//! when both halves agree and report [`AggregateValue::Mixed`] when they do not: +//! passing the positive half off as the whole setting is what would let an +//! asymmetric ladder be overwritten without the user ever seeing it. + +use super::model::*; +use crate::automation::{ + CAP_FIELD_BOUNDED, CAP_FIELD_LOCATION_SCALE, CAP_FIELD_NOISE_SCALE, + CAP_FIELD_SCALAR_GRID_2D_REGULAR, CAP_FIELD_SIGNED, +}; +use crate::state::{ + CONTOUR_BASE_ABSOLUTE, CONTOUR_BASE_BACKGROUND_SCALE, CONTOUR_BASE_FRACTION_OF_RANGE, + CONTOUR_BASE_NOISE_FLOOR, PeakMagnitude, contour_base_kind, contour_base_policy, +}; +use plotx_figure::{ + ColorSource, ContourBasePolicy, ContourLevelSpec, ContourSpec, PositiveFiniteF32, + PositiveFiniteF64, UnitInterval, +}; + +pub const BASE_POLICY: PropertyId = PropertyId("series.contour.base.policy"); +pub const BASE_MAGNITUDE: PropertyId = PropertyId("series.contour.base.magnitude"); +pub const COUNT: PropertyId = PropertyId("series.contour.count"); +pub const RATIO: PropertyId = PropertyId("series.contour.ratio"); +pub const NEGATIVE_ENABLED: PropertyId = PropertyId("series.contour.negative.enabled"); +pub const POSITIVE_COLOR: PropertyId = PropertyId("series.contour.positive_color"); +pub const NEGATIVE_COLOR: PropertyId = PropertyId("series.contour.negative_color"); +pub const LINE_WIDTH: PropertyId = PropertyId("series.contour.line_width"); + +/// The largest multiplier a σ- or background-anchored base accepts. Well above +/// any real ladder, low enough that a slipped decimal point is rejected instead +/// of silently blanking the plot. +const MAX_MULTIPLIER: f64 = 1.0e4; +/// Ratios beyond this compress a ladder into one visible level. +const MAX_RATIO: f64 = 10.0; + +/// The level ratio's range. Open at one because a ladder whose rungs sit at the +/// same height is not a ladder; §4.3 states the invariant as `ratio > 1.0`. +const RATIO_BOUNDS: FloatBounds = FloatBounds::above(1.0, MAX_RATIO); +/// Below this a stroke is invisible on screen and hairline in print. +const LINE_WIDTH_BOUNDS: FloatBounds = FloatBounds::inclusive(0.05, 10.0); + +/// The base-policy choices and the capability each one needs. `FractionOfRange` +/// requires a bounded field and is withheld from signed ones: "four percent of +/// the value range" is not a threshold a user can reason about when the range +/// straddles zero. +const BASE_POLICIES: &[EnumVariant] = &[ + EnumVariant::new(CONTOUR_BASE_ABSOLUTE, "Absolute level"), + EnumVariant::new(CONTOUR_BASE_NOISE_FLOOR, "Multiple of the noise floor") + .requiring(&[CAP_FIELD_NOISE_SCALE]), + EnumVariant::new( + CONTOUR_BASE_BACKGROUND_SCALE, + "Background + multiple of spread", + ) + .requiring(&[CAP_FIELD_LOCATION_SCALE]), + EnumVariant::new(CONTOUR_BASE_FRACTION_OF_RANGE, "Fraction of value range") + .requiring(&[CAP_FIELD_BOUNDED]) + .forbidding(&[CAP_FIELD_SIGNED]), +]; + +const CONTOUR: Applicability = + Applicability::encoding(ComponentKind::Series, EncodingKind::Contour) + .requiring(&[CAP_FIELD_SCALAR_GRID_2D_REGULAR]); + +const SIGNED_CONTOUR: Applicability = + Applicability::encoding(ComponentKind::Series, EncodingKind::Contour) + .requiring(&[CAP_FIELD_SCALAR_GRID_2D_REGULAR, CAP_FIELD_SIGNED]); + +pub(super) const DEFINITIONS: &[PropertyDefinition] = &[ + PropertyDefinition { + id: BASE_MAGNITUDE, + scope_kind: ScopeKind::Object, + value_schema: ValueSchema::Float { + bounds: FloatBounds::above(0.0, f64::MAX), + log: true, + }, + access: PropertyAccess::ReadWrite, + applicability: CONTOUR, + default_policy: DefaultPolicy::EncodingFactory, + tier: Tier::Essential, + copies: ValueCopies::PerMirroredHalf, + canonical_label: "Lowest contour level", + canonical_aliases: &[ + "contour threshold", + "contour base", + "lowest level", + "cutoff", + "noise multiple", + "sigma", + ], + }, + PropertyDefinition { + id: BASE_POLICY, + scope_kind: ScopeKind::Object, + value_schema: ValueSchema::Enum { + variants: BASE_POLICIES, + }, + access: PropertyAccess::ReadWrite, + applicability: CONTOUR, + default_policy: DefaultPolicy::EncodingFactory, + tier: Tier::Advanced, + copies: ValueCopies::PerMirroredHalf, + canonical_label: "Level anchor", + canonical_aliases: &["contour base policy", "threshold anchor", "level policy"], + }, + PropertyDefinition { + id: COUNT, + scope_kind: ScopeKind::Object, + value_schema: ValueSchema::Int { + min: 1, + max: ContourLevelSpec::MAX_COUNT as i64, + }, + access: PropertyAccess::ReadWrite, + applicability: CONTOUR, + default_policy: DefaultPolicy::EncodingFactory, + tier: Tier::Advanced, + copies: ValueCopies::PerMirroredHalf, + canonical_label: "Contour levels", + canonical_aliases: &["contour count", "number of levels", "level count"], + }, + PropertyDefinition { + id: RATIO, + scope_kind: ScopeKind::Object, + // Open at one: a ratio of exactly one draws every level at the same + // height. The bound is declared once, so the control cannot offer a + // value the write path refuses. + value_schema: ValueSchema::Float { + bounds: RATIO_BOUNDS, + log: false, + }, + access: PropertyAccess::ReadWrite, + applicability: CONTOUR, + default_policy: DefaultPolicy::EncodingFactory, + tier: Tier::Advanced, + copies: ValueCopies::PerMirroredHalf, + canonical_label: "Level ratio", + canonical_aliases: &["contour ratio", "level spacing", "geometric ratio"], + }, + PropertyDefinition { + id: NEGATIVE_ENABLED, + scope_kind: ScopeKind::Object, + value_schema: ValueSchema::Bool, + access: PropertyAccess::ReadWrite, + applicability: SIGNED_CONTOUR, + default_policy: DefaultPolicy::EncodingFactory, + tier: Tier::Advanced, + copies: ValueCopies::PerTarget, + canonical_label: "Draw negative contours", + canonical_aliases: &["negative levels", "negative peaks", "show negative"], + }, + PropertyDefinition { + id: POSITIVE_COLOR, + scope_kind: ScopeKind::Object, + value_schema: ValueSchema::Color, + access: PropertyAccess::ReadWrite, + applicability: CONTOUR, + default_policy: DefaultPolicy::EncodingFactory, + tier: Tier::Advanced, + copies: ValueCopies::PerTarget, + canonical_label: "Positive contour colour", + canonical_aliases: &["contour colour", "contour color", "positive colour"], + }, + PropertyDefinition { + id: NEGATIVE_COLOR, + scope_kind: ScopeKind::Object, + value_schema: ValueSchema::Color, + access: PropertyAccess::ReadWrite, + applicability: SIGNED_CONTOUR, + default_policy: DefaultPolicy::EncodingFactory, + tier: Tier::Advanced, + copies: ValueCopies::PerTarget, + canonical_label: "Negative contour colour", + canonical_aliases: &["negative colour", "negative color"], + }, + PropertyDefinition { + id: LINE_WIDTH, + scope_kind: ScopeKind::Object, + value_schema: ValueSchema::Float { + bounds: LINE_WIDTH_BOUNDS, + log: false, + }, + access: PropertyAccess::ReadWrite, + applicability: CONTOUR, + default_policy: DefaultPolicy::EncodingFactory, + tier: Tier::Advanced, + copies: ValueCopies::PerTarget, + canonical_label: "Contour line width", + canonical_aliases: &["contour width", "line width", "stroke width"], + }, +]; + +/// Read one property out of a spec. `None` means the id is not a contour +/// property; applicability has already been decided by the caller. +/// +/// The four rungs of the shared ladder aggregate over both halves. Everything +/// else — the colours, the line width, whether a negative half exists at all — +/// has exactly one copy and is always `Uniform`. +pub(super) fn read(id: PropertyId, spec: &ContourSpec) -> Option> { + let value = match id { + // The base has two faces on one value. A half whose policy is a + // different *kind* disagrees whatever its number says, and a magnitude + // read under a different kind would mean something else entirely — "5" + // is five σ or five intensity units depending on the anchor — so the + // magnitude agrees only when the kind does too. + BASE_POLICY => shared(spec, base_kind, |half| { + PropertyValue::Enum(contour_base_kind(&half.base)) + }), + BASE_MAGNITUDE => shared( + spec, + |half| (base_kind(half), base_magnitude(&half.base)), + |half| PropertyValue::Float(base_magnitude(&half.base)), + ), + COUNT => shared( + spec, + |half| half.count, + |half| PropertyValue::Int(i64::from(half.count)), + ), + RATIO => shared( + spec, + |half| half.ratio.get(), + |half| PropertyValue::Float(half.ratio.get()), + ), + NEGATIVE_ENABLED => AggregateValue::Uniform(PropertyValue::Bool(spec.negative.is_some())), + POSITIVE_COLOR => { + AggregateValue::Uniform(PropertyValue::Color(spec.style.positive_color.resolve())) + } + NEGATIVE_COLOR => { + AggregateValue::Uniform(PropertyValue::Color(spec.style.negative_color.resolve())) + } + LINE_WIDTH => { + AggregateValue::Uniform(PropertyValue::Float(f64::from(spec.style.width.get()))) + } + _ => return None, + }; + Some(value) +} + +/// Read one rung of the shared ladder across the halves that exist. +/// +/// `witness` is what "the halves agree on this rung" means; `value` is what is +/// reported when they do. A spec with no negative half has one source, not a +/// disagreeing one: the user switched the negative contours off, which is a +/// setting of its own and not an asymmetric ladder. +fn shared( + spec: &ContourSpec, + witness: impl Fn(&ContourLevelSpec) -> W, + value: impl Fn(&ContourLevelSpec) -> PropertyValue, +) -> AggregateValue { + match &spec.negative { + Some(negative) if witness(negative) != witness(&spec.positive) => AggregateValue::Mixed, + _ => AggregateValue::Uniform(value(&spec.positive)), + } +} + +fn base_kind(half: &ContourLevelSpec) -> &'static str { + contour_base_kind(&half.base) +} + +/// What the default policy resolves to for one property *in the context the +/// target is actually in*. +/// +/// Most properties read straight out of the spec the encoding factory would +/// produce. The base magnitude cannot, because a magnitude has no meaning apart +/// from its anchor: "5" is five noise σ under one anchor and five times the +/// value range under another. Handing the factory's number — measured against +/// the anchor the factory chose — to a target the user has since re-anchored +/// produces a value the writer must reject, which is a reset that can never +/// succeed. §8.1 asks for the default to be *derived* from the current context, +/// and the current anchor is part of that context, so the magnitude's default is +/// the one a freshly built base of the target's own kind carries. That is the +/// same construction switching the anchor performs, so "switch to this anchor" +/// and "reset the level under this anchor" agree by construction rather than by +/// two lists of literals staying in step. +pub(super) fn default_for( + property: PropertyId, + defaults: &ContourSpec, + current: &ContourSpec, + peak: PeakMagnitude<'_>, +) -> Option> { + if property != BASE_MAGNITUDE { + return read(property, defaults); + } + let base = contour_base_policy(contour_base_kind(¤t.positive.base), peak)?; + Some(AggregateValue::Uniform(PropertyValue::Float( + base_magnitude(&base), + ))) +} + +/// The bounds and caption that this target's *current* base policy implies. +/// The static schema stays context-free; this is what a control is built from. +pub(super) fn resolved_schema(id: PropertyId, spec: &ContourSpec) -> Option { + if id != BASE_MAGNITUDE { + return None; + } + let schema = match &spec.positive.base { + ContourBasePolicy::Absolute(_) => ResolvedSchema::Float { + bounds: FloatBounds::above(0.0, f64::MAX), + log: true, + unit: "intensity", + }, + ContourBasePolicy::NoiseFloor { .. } => ResolvedSchema::Float { + bounds: FloatBounds::above(0.0, MAX_MULTIPLIER), + log: false, + unit: "× noise floor", + }, + ContourBasePolicy::BackgroundScale { .. } => ResolvedSchema::Float { + bounds: FloatBounds::above(0.0, MAX_MULTIPLIER), + log: false, + unit: "× spread", + }, + ContourBasePolicy::FractionOfRange(_) => ResolvedSchema::Float { + bounds: FloatBounds::above(0.0, 1.0), + log: false, + unit: "of range", + }, + }; + Some(schema) +} + +/// The numeric bounds a definition declares, so the write path validates against +/// the very value the control was built from rather than a second copy of it. +fn declared_bounds(id: PropertyId) -> Result { + DEFINITIONS + .iter() + .find(|definition| definition.id == id) + .and_then(|definition| definition.value_schema.float_bounds()) + .ok_or_else(|| PropertyError::UnknownProperty(id.as_str().to_owned())) +} + +/// Apply one property to a spec. +/// +/// Whether the *choice* is permitted at all is a capability question the +/// planner has already answered from the definition's schema; this validates +/// the value's own range. Base, count and ratio describe the shared ladder and +/// are written to both halves; a half that does not exist is simply absent. +/// Every failure is returned, never swallowed, so a rejected value cannot land +/// partially. +pub(super) fn write( + id: PropertyId, + spec: &mut ContourSpec, + value: &PropertyValue, + peak: PeakMagnitude<'_>, +) -> Result<(), PropertyError> { + match id { + BASE_POLICY => { + let kind = expect_enum(id, value)?; + let policy = + contour_base_policy(kind, peak).ok_or_else(|| PropertyError::InvalidValue { + property: id, + message: format!("'{kind}' is not a contour base policy"), + })?; + for half in halves(spec) { + half.base = policy.clone(); + } + } + BASE_MAGNITUDE => { + let magnitude = expect_float(id, value)?; + // Validate against the current policy before touching either half, + // so a rejected magnitude leaves the spec exactly as it was. + let updated = with_magnitude(&spec.positive.base, magnitude).ok_or({ + PropertyError::InvalidValue { + property: id, + message: magnitude_message(&spec.positive.base), + } + })?; + for half in halves(spec) { + half.base = updated.clone(); + } + } + COUNT => { + let count = expect_int(id, value)?; + let count = u16::try_from(count) + .ok() + .filter(|count| *count >= 1 && *count <= ContourLevelSpec::MAX_COUNT) + .ok_or(PropertyError::InvalidValue { + property: id, + message: format!( + "level count must be between 1 and {}", + ContourLevelSpec::MAX_COUNT + ), + })?; + for half in halves(spec) { + half.count = count; + } + } + RATIO => { + let ratio = declared_bounds(id)?.check(id, "level ratio", expect_float(id, value)?)?; + let ratio = PositiveFiniteF64::new(ratio).ok_or(PropertyError::InvalidValue { + property: id, + message: "level ratio must be a finite positive number".to_owned(), + })?; + for half in halves(spec) { + half.ratio = ratio; + } + } + NEGATIVE_ENABLED => { + let enabled = expect_bool(id, value)?; + if !enabled { + spec.negative = None; + } else if spec.negative.is_none() { + // The negative half mirrors the ladder the positive half is + // already drawing; only its sign differs. + spec.negative = Some(spec.positive.clone()); + } + } + POSITIVE_COLOR => { + spec.style.positive_color = ColorSource::Explicit(expect_color(id, value)?); + } + NEGATIVE_COLOR => { + spec.style.negative_color = ColorSource::Explicit(expect_color(id, value)?); + } + LINE_WIDTH => { + let width = declared_bounds(id)?.check(id, "line width", expect_float(id, value)?)?; + spec.style.width = + PositiveFiniteF32::new(width as f32).ok_or(PropertyError::InvalidValue { + property: id, + message: "line width must be a finite positive number".to_owned(), + })?; + } + _ => { + return Err(PropertyError::UnknownProperty(id.as_str().to_owned())); + } + } + Ok(()) +} + +/// Move the lowest level one rung along the ladder this series actually draws. +/// +/// The step is *geometric* — the ladder's own `ratio` — rather than additive. +/// Contour levels are `base·ratio^k`, so one press moves the floor by exactly +/// one drawn rung whatever decade the base sits in, and the gesture means the +/// same thing on a spectrum whose peak is 1e2 and one whose peak is 1e9. An +/// additive step would have to be retuned per dataset, which is the reason the +/// ladder is geometric to begin with. A σ- or fraction-anchored base is stepped +/// the same way: its multiple is a position on the same ladder. +/// +/// The new value is then written through [`write`], so the gesture is validated +/// by exactly the rules a typed value from the panel is. +pub(super) fn step( + id: PropertyId, + spec: &mut ContourSpec, + step: PropertyStep, +) -> Result<(), PropertyError> { + if id != BASE_MAGNITUDE { + return Err(PropertyError::InvalidValue { + property: id, + message: "this setting has no step gesture".to_owned(), + }); + } + let ratio = spec.positive.ratio.get(); + let current = base_magnitude(&spec.positive.base); + let stepped = match step { + PropertyStep::Raise => current * ratio, + PropertyStep::Lower => current / ratio, + }; + let next = ceiling(&spec.positive.base).map_or(stepped, |ceiling| stepped.min(ceiling)); + if next == current { + return Err(PropertyError::InvalidValue { + property: id, + message: format!( + "the lowest contour level is already at the highest value this anchor allows ({current})" + ), + }); + } + write(id, spec, &PropertyValue::Float(next), crate::state::NO_PEAK) +} + +/// The largest magnitude a base policy accepts, where it has one. An absolute +/// level is bounded by the field, not by the catalog, so it has none. +fn ceiling(base: &ContourBasePolicy) -> Option { + match base { + ContourBasePolicy::Absolute(_) => None, + ContourBasePolicy::NoiseFloor { .. } | ContourBasePolicy::BackgroundScale { .. } => { + Some(MAX_MULTIPLIER) + } + ContourBasePolicy::FractionOfRange(_) => Some(1.0), + } +} + +/// Every level ladder the spec currently carries, positive half first. +fn halves(spec: &mut ContourSpec) -> impl Iterator { + std::iter::once(&mut spec.positive).chain(spec.negative.iter_mut()) +} + +pub(super) fn base_magnitude(base: &ContourBasePolicy) -> f64 { + match base { + ContourBasePolicy::Absolute(value) => value.get(), + ContourBasePolicy::NoiseFloor { multiplier, .. } + | ContourBasePolicy::BackgroundScale { multiplier, .. } => multiplier.get(), + ContourBasePolicy::FractionOfRange(fraction) => fraction.get(), + } +} + +/// The peak fraction a base policy will not resolve below, where it has one. +/// +/// It is not the number the control edits, so it is deliberately not part of +/// [`base_magnitude`]; it is what a readout needs in order to name the floor it +/// is reporting. +pub(super) fn base_peak_fraction(base: &ContourBasePolicy) -> Option { + match base { + ContourBasePolicy::NoiseFloor { peak_fraction, .. } => Some(peak_fraction.get()), + ContourBasePolicy::Absolute(_) + | ContourBasePolicy::BackgroundScale { .. } + | ContourBasePolicy::FractionOfRange(_) => None, + } +} + +fn with_magnitude(base: &ContourBasePolicy, magnitude: f64) -> Option { + let policy = match base { + ContourBasePolicy::Absolute(_) => { + ContourBasePolicy::Absolute(PositiveFiniteF64::new(magnitude)?) + } + // The floor travels with the policy: it is a persisted calibration, not + // a constant resolution reaches for, so editing the multiple must carry + // the field's own floor forward rather than re-deriving it from whatever + // the current build happens to think it is. + ContourBasePolicy::NoiseFloor { + peak_fraction, + estimator, + .. + } => ContourBasePolicy::NoiseFloor { + multiplier: bounded_multiplier(magnitude)?, + peak_fraction: *peak_fraction, + estimator: estimator.clone(), + }, + ContourBasePolicy::BackgroundScale { estimator, .. } => { + ContourBasePolicy::BackgroundScale { + multiplier: bounded_multiplier(magnitude)?, + estimator: estimator.clone(), + } + } + ContourBasePolicy::FractionOfRange(_) => ContourBasePolicy::FractionOfRange( + UnitInterval::new(magnitude).filter(|fraction| fraction.get() > 0.0)?, + ), + }; + Some(policy) +} + +fn bounded_multiplier(value: f64) -> Option { + PositiveFiniteF64::new(value).filter(|value| value.get() <= MAX_MULTIPLIER) +} + +fn magnitude_message(base: &ContourBasePolicy) -> String { + match base { + ContourBasePolicy::Absolute(_) => { + "absolute level must be a finite value greater than zero".to_owned() + } + ContourBasePolicy::NoiseFloor { .. } | ContourBasePolicy::BackgroundScale { .. } => { + format!("multiplier must be greater than 0 and at most {MAX_MULTIPLIER}") + } + ContourBasePolicy::FractionOfRange(_) => { + "fraction must be greater than 0 and at most 1".to_owned() + } + } +} + +fn expect_bool(property: PropertyId, value: &PropertyValue) -> Result { + value.as_bool().ok_or(PropertyError::InvalidValue { + property, + message: format!("expected a boolean, got {}", value.kind()), + }) +} + +fn expect_int(property: PropertyId, value: &PropertyValue) -> Result { + value.as_int().ok_or(PropertyError::InvalidValue { + property, + message: format!("expected an integer, got {}", value.kind()), + }) +} + +fn expect_float(property: PropertyId, value: &PropertyValue) -> Result { + value.as_float().ok_or(PropertyError::InvalidValue { + property, + message: format!("expected a number, got {}", value.kind()), + }) +} + +fn expect_enum(property: PropertyId, value: &PropertyValue) -> Result<&'static str, PropertyError> { + value.as_enum().ok_or(PropertyError::InvalidValue { + property, + message: format!("expected a choice, got {}", value.kind()), + }) +} + +fn expect_color( + property: PropertyId, + value: &PropertyValue, +) -> Result { + value.as_color().ok_or(PropertyError::InvalidValue { + property, + message: format!("expected a colour, got {}", value.kind()), + }) +} diff --git a/crates/core/src/properties/ladder_tests.rs b/crates/core/src/properties/ladder_tests.rs new file mode 100644 index 0000000..3247a5c --- /dev/null +++ b/crates/core/src/properties/ladder_tests.rs @@ -0,0 +1,299 @@ +//! The shared contour ladder read across the halves that carry it. +//! +//! `ContourSpec` keeps the positive and negative halves as two independent +//! `ContourLevelSpec`s, so a document can hold a ladder whose halves differ. +//! Writes deliberately keep the two in step, which is only safe as long as a +//! read never presents one half as the whole setting: otherwise an asymmetric +//! ladder would be flattened by the next edit without ever being shown. + +use super::tests::{contour_app, contour_spec}; +use super::*; +use crate::automation::{ComponentRef, TargetRef}; +use crate::state::{CONTOUR_BASE_NOISE_FLOOR, ObjectId, PlotxApp, contour_base_kind}; +use plotx_figure::{ + ContourBasePolicy, ContourSpec, PositiveFiniteF64, SeriesEncoding, UnitInterval, +}; + +/// Edit the contour spec of the series a target names, in place, without going +/// through the catalog — the point of these tests is what the catalog does with +/// a spec it did not write. +fn with_spec(app: &mut PlotxApp, target: &TargetRef, edit: impl FnOnce(&mut ContourSpec)) { + let Some(ComponentRef::Series(series)) = target.component else { + panic!("the fixture addresses a series"); + }; + let object: ObjectId = target + .resource + .local_id + .as_deref() + .expect("the fixture names an object") + .parse() + .expect("the object id parses"); + let plot = app.doc.canvases[0] + .object_mut(object) + .and_then(|object| object.plot_mut()) + .expect("plot"); + let binding = plot + .binding + .series + .iter_mut() + .find(|candidate| candidate.id == series) + .expect("series"); + let SeriesEncoding::Contour(spec) = &mut binding.encoding else { + panic!("the fixture draws a contour"); + }; + edit(spec); +} + +/// A second contour series on the same object, cloned from the first and then +/// edited, so a selection can hold two targets. +fn add_series( + app: &mut PlotxApp, + target: &TargetRef, + edit: impl FnOnce(&mut ContourSpec), +) -> TargetRef { + let object: ObjectId = target + .resource + .local_id + .as_deref() + .expect("the fixture names an object") + .parse() + .expect("the object id parses"); + let id = { + let plot = app.doc.canvases[0] + .object_mut(object) + .and_then(|object| object.plot_mut()) + .expect("plot"); + let id = plot.allocate_series_id(); + let mut extra = plot.binding.series[0].clone(); + extra.id = id; + if let SeriesEncoding::Contour(spec) = &mut extra.encoding { + edit(spec); + } + plot.binding.series.push(extra); + id + }; + app.series_target(0, object, id).expect("target resolves") +} + +fn read(app: &PlotxApp, target: &TargetRef, property: PropertyId) -> AggregateValue { + app.resolve_property(&PropertyAddress::new(target.clone(), property)) + .expect("the fixture resolves") + .value +} + +/// Change a base's number while keeping its policy kind, whatever kind that is. +fn scale_magnitude(base: &mut ContourBasePolicy) { + match base { + ContourBasePolicy::Absolute(value) => { + *value = PositiveFiniteF64::new(value.get() * 2.0).expect("a doubled base is positive"); + } + ContourBasePolicy::NoiseFloor { multiplier, .. } + | ContourBasePolicy::BackgroundScale { multiplier, .. } => { + *multiplier = PositiveFiniteF64::new(multiplier.get() * 2.0) + .expect("a doubled multiplier is fine") + } + ContourBasePolicy::FractionOfRange(fraction) => { + *fraction = UnitInterval::new(fraction.get() / 2.0).expect("a halved fraction is unit"); + } + } +} + +/// Halves that disagree on the count must not be summarized by the positive +/// one. Writing the shared rung is what converges them, and it is the user's own +/// gesture rather than something the read did behind their back. +#[test] +fn an_asymmetric_count_reads_as_mixed_until_a_write_converges_it() { + let (mut app, target) = contour_app(); + with_spec(&mut app, &target, |spec| { + spec.negative + .as_mut() + .expect("a signed field has one") + .count = 3; + }); + + let resolved = app + .resolve_property(&PropertyAddress::new(target.clone(), contour::COUNT)) + .expect("resolves"); + assert_eq!( + resolved.value, + AggregateValue::Mixed, + "the positive half's 14 does not speak for a ladder whose other half is 3" + ); + assert!( + resolved.is_modified(), + "a ladder no factory could have produced is a modified one" + ); + // A single-target selection carries the same fact through to the panel. + let set = app.resolve_property_set(contour::COUNT, std::slice::from_ref(&target)); + assert_eq!(set.applicable_targets.len(), 1); + assert_eq!(set.value, AggregateValue::Mixed); + + let commit = app + .plan_property_write( + contour::COUNT, + std::slice::from_ref(&target), + &PropertyValue::Int(7), + ) + .expect("count is writable"); + app.commit_property(commit); + + let spec = contour_spec(&app, &target); + assert_eq!(spec.positive.count, 7); + assert_eq!(spec.negative.as_ref().map(|half| half.count), Some(7)); + assert_eq!( + read(&app, &target, contour::COUNT), + AggregateValue::Uniform(PropertyValue::Int(7)), + "the halves agree again, so there is a value to report" + ); +} + +/// Halves anchored to different *kinds* of base disagree even when their numbers +/// coincide: five times the noise σ and an absolute level of five are not the +/// same threshold, and the magnitude read under one says nothing about the other. +#[test] +fn halves_anchored_to_different_policies_read_as_mixed() { + let (mut app, target) = contour_app(); + with_spec(&mut app, &target, |spec| { + assert_eq!( + contour_base_kind(&spec.positive.base), + CONTOUR_BASE_NOISE_FLOOR, + "the signed NMR fixture anchors to noise σ" + ); + let magnitude = match &spec.positive.base { + ContourBasePolicy::NoiseFloor { multiplier, .. } => *multiplier, + other => panic!("expected a σ anchor, got {other:?}"), + }; + // Same number, different kind: only the kind may make this Mixed. + spec.negative.as_mut().expect("a signed field has one").base = + ContourBasePolicy::Absolute(magnitude); + }); + + assert_eq!( + read(&app, &target, contour::BASE_POLICY), + AggregateValue::Mixed + ); + assert_eq!( + read(&app, &target, contour::BASE_MAGNITUDE), + AggregateValue::Mixed, + "a number whose meaning differs between the halves is not a shared value" + ); + // The rungs that do still agree keep reporting their value. + assert_eq!( + read(&app, &target, contour::COUNT), + AggregateValue::Uniform(PropertyValue::Int(14)) + ); +} + +/// The other face of the same base: the halves agree on the kind of anchor and +/// disagree only on how far from it the ladder starts. +#[test] +fn halves_with_one_anchor_but_different_magnitudes_read_as_mixed() { + let (mut app, target) = contour_app(); + with_spec(&mut app, &target, |spec| { + scale_magnitude(&mut spec.negative.as_mut().expect("a signed field has one").base); + }); + + assert_eq!( + read(&app, &target, contour::BASE_MAGNITUDE), + AggregateValue::Mixed + ); + assert_eq!( + read(&app, &target, contour::BASE_POLICY), + AggregateValue::Uniform(PropertyValue::Enum(CONTOUR_BASE_NOISE_FLOOR)), + "the anchor itself is still the same choice in both halves" + ); +} + +/// Switching the negative contours off is a setting, not a disagreement. One +/// half is one source, and one source always agrees with itself. +#[test] +fn a_ladder_with_no_negative_half_reads_as_uniform() { + let (mut app, target) = contour_app(); + let commit = app + .plan_property_write( + contour::NEGATIVE_ENABLED, + std::slice::from_ref(&target), + &PropertyValue::Bool(false), + ) + .expect("the negative half is writable on a signed field"); + app.commit_property(commit); + assert!(contour_spec(&app, &target).negative.is_none()); + + for (property, expected) in [ + (contour::COUNT, PropertyValue::Int(14)), + (contour::BASE_MAGNITUDE, PropertyValue::Float(5.0)), + ( + contour::BASE_POLICY, + PropertyValue::Enum(CONTOUR_BASE_NOISE_FLOOR), + ), + ] { + assert_eq!( + read(&app, &target, property), + AggregateValue::Uniform(expected), + "{property} has one half to read, so it has a value" + ); + } + assert_eq!( + app.resolve_property_set(contour::COUNT, std::slice::from_ref(&target)) + .value, + AggregateValue::Uniform(PropertyValue::Int(14)) + ); +} + +/// Two targets that each agree with themselves, and disagree with each other. +#[test] +fn targets_that_are_each_uniform_but_differ_read_as_mixed() { + let (mut app, first) = contour_app(); + let second = add_series(&mut app, &first, |spec| { + spec.positive.count = 5; + spec.negative + .as_mut() + .expect("a signed field has one") + .count = 5; + }); + for target in [&first, &second] { + assert!( + matches!( + read(&app, target, contour::COUNT), + AggregateValue::Uniform(_) + ), + "each target must be uniform on its own for this to prove anything" + ); + } + + let set = app.resolve_property_set(contour::COUNT, &[first, second]); + assert_eq!(set.applicable_targets.len(), 2); + assert_eq!(set.value, AggregateValue::Mixed); +} + +/// The superposition that must not collapse: two targets whose positive halves +/// agree, one of which is internally asymmetric. Aggregating on the positive +/// half alone would report a confident `Uniform` and hide the odd ladder. +#[test] +fn one_targets_asymmetric_ladder_makes_the_whole_selection_mixed() { + let (mut app, first) = contour_app(); + let second = add_series(&mut app, &first, |spec| { + spec.negative + .as_mut() + .expect("a signed field has one") + .count = 2; + }); + assert_eq!( + contour_spec(&app, &first).positive.count, + contour_spec(&app, &second).positive.count, + "the positive halves must agree for this to prove anything" + ); + assert_eq!( + read(&app, &first, contour::COUNT), + AggregateValue::Uniform(PropertyValue::Int(14)) + ); + assert_eq!(read(&app, &second, contour::COUNT), AggregateValue::Mixed); + + let set = app.resolve_property_set(contour::COUNT, &[first, second]); + assert_eq!(set.applicable_targets.len(), 2); + assert_eq!( + set.value, + AggregateValue::Mixed, + "agreement between the targets must not swallow disagreement inside one" + ); +} diff --git a/crates/core/src/properties/mod.rs b/crates/core/src/properties/mod.rs new file mode 100644 index 0000000..5d8de95 --- /dev/null +++ b/crates/core/src/properties/mod.rs @@ -0,0 +1,87 @@ +//! The property catalog: one registration point for every addressable, +//! persistent setting, the counterpart of the existing command catalog. +//! +//! The catalog owns descriptions, addressing, reading, validation and the +//! compilation of an edit into a typed [`crate::actions::Action`]. It does not +//! own values: there is no `HashMap` anywhere in this +//! module, because every value already has exactly one home in a typed domain +//! model. Presentation — localized labels and panel routing — belongs to the +//! application crate and is keyed by the same [`PropertyId`]. + +pub mod contour; +mod model; +mod readout; +mod service; + +pub use model::*; +pub use readout::{ContourAnchor, ContourBaseReadout}; + +use crate::state::FieldCapabilities; +use std::sync::LazyLock; + +/// The single aggregation point. Each area exports its own slice of +/// definitions; adding one here is what makes it addressable, searchable and +/// resettable everywhere at once. +static CATALOG: LazyLock> = + LazyLock::new(|| contour::DEFINITIONS.iter().collect()); + +pub fn catalog() -> &'static [&'static PropertyDefinition] { + &CATALOG +} + +pub fn definition(id: PropertyId) -> Option<&'static PropertyDefinition> { + catalog() + .iter() + .copied() + .find(|definition| definition.id == id) +} + +/// Look a definition up by its stable string id, the form an automation or +/// search caller carries. +pub fn definition_by_key(key: &str) -> Option<&'static PropertyDefinition> { + catalog() + .iter() + .copied() + .find(|definition| definition.id.as_str() == key) +} + +/// The enum choices a field's capabilities actually permit. Capability decides +/// whether a choice can exist at all; the user's current value decides which one +/// is in force. +pub fn permitted_variants( + schema: &ValueSchema, + capabilities: &FieldCapabilities, +) -> Vec<&'static EnumVariant> { + let ValueSchema::Enum { variants } = schema else { + return Vec::new(); + }; + variants + .iter() + .filter(|variant| { + variant + .required_capabilities + .iter() + .all(|capability| capabilities.contains(capability)) + && !variant + .forbidden_capabilities + .iter() + .any(|capability| capabilities.contains(capability)) + }) + .collect() +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; + +#[cfg(test)] +#[path = "ladder_tests.rs"] +mod ladder_tests; + +#[cfg(test)] +#[path = "step_tests.rs"] +mod step_tests; + +#[cfg(test)] +#[path = "scope_tests.rs"] +mod scope_tests; diff --git a/crates/core/src/properties/model.rs b/crates/core/src/properties/model.rs new file mode 100644 index 0000000..90ce23e --- /dev/null +++ b/crates/core/src/properties/model.rs @@ -0,0 +1,616 @@ +//! The language-neutral half of the property catalog. +//! +//! A [`PropertyDefinition`] describes *what a property is* — its identity, +//! schema, ownership scope, applicability and default policy. It deliberately +//! carries no target, no current value and no stored "is default" flag: the +//! current value and the default are derived from the document on demand, so +//! there is never a second copy of a value that already lives in a typed domain +//! model. There is, for the same reason, no generic value store here; writes are +//! compiled into the existing typed `Action`s. + +use crate::automation::{ComponentRef, TargetRef}; +use plotx_figure::Color; +use std::fmt; + +/// Stable, language-neutral identity of one catalog entry. Definitions are +/// static, so the identity is a `&'static str`: it cannot be minted at runtime +/// and cannot drift between releases without a visible source change. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct PropertyId(pub &'static str); + +impl PropertyId { + pub const fn as_str(self) -> &'static str { + self.0 + } + + /// The dotted segments of the id, used as search tokens so a headless + /// caller can find `series.contour.count` by typing "contour count". + pub fn tokens(self) -> impl Iterator { + self.0.split('.') + } +} + +impl fmt::Display for PropertyId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.0) + } +} + +/// Who owns the value and how many instances of it exist. Ownership decides the +/// scope, never "does editing it trigger a recomputation": a contour threshold +/// is owned by one series in one plot even though changing it rebuilds geometry. +/// +/// There is no `Session` scope on purpose — the current slice, panel expansion +/// and board zoom are navigation state and stay out of the catalog (§8.4). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ScopeKind { + App, + Document, + Canvas, + Dataset, + Object, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PropertyAccess { + ReadOnly, + ReadWrite, +} + +/// Panel budget tier. This is the single definition of a property's tier; +/// the presentation layer reads it rather than storing its own copy, so the two +/// cannot drift apart. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Tier { + Essential, + Advanced, + Expert, +} + +impl Tier { + pub const fn as_str(self) -> &'static str { + match self { + Self::Essential => "essential", + Self::Advanced => "advanced", + Self::Expert => "expert", + } + } +} + +/// One selectable choice of an enumerated property, together with the field +/// capabilities that make it selectable at all. The gate is declared here rather +/// than in a `match` on a data domain, so a new provider gains or loses a choice +/// purely by exposing or withholding a capability. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct EnumVariant { + pub id: &'static str, + pub canonical_label: &'static str, + /// Every capability the target's field must expose. + pub required_capabilities: &'static [&'static str], + /// Capabilities that make this choice meaningless even when the required + /// ones are present — a fraction of the value range says nothing useful on a + /// field with both signs. + pub forbidden_capabilities: &'static [&'static str], +} + +impl EnumVariant { + pub const fn new(id: &'static str, canonical_label: &'static str) -> Self { + Self { + id, + canonical_label, + required_capabilities: &[], + forbidden_capabilities: &[], + } + } + + pub const fn requiring(mut self, capabilities: &'static [&'static str]) -> Self { + self.required_capabilities = capabilities; + self + } + + pub const fn forbidding(mut self, capabilities: &'static [&'static str]) -> Self { + self.forbidden_capabilities = capabilities; + self + } +} + +/// The numeric range of a float property. +/// +/// A bound can be *open*: a level ratio must be strictly greater than one, or +/// the ladder it describes stops rising. Openness is part of the rule and +/// therefore belongs to the schema, not to whichever control or writer happens +/// to re-state it. The alternative — a control that stops at a rounded literal +/// while the writer tests the real bound — is two copies of one rule, and the +/// copies drift. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FloatBounds { + pub min: f64, + pub max: f64, + /// Whether `min` itself is admitted. + pub exclusive_min: bool, +} + +impl FloatBounds { + /// `min ..= max`. + pub const fn inclusive(min: f64, max: f64) -> Self { + Self { + min, + max, + exclusive_min: false, + } + } + + /// Strictly above `min`, up to and including `max`. + pub const fn above(min: f64, max: f64) -> Self { + Self { + min, + max, + exclusive_min: true, + } + } + + pub fn admits(self, value: f64) -> bool { + value.is_finite() + && value <= self.max + && if self.exclusive_min { + value > self.min + } else { + value >= self.min + } + } + + /// The smallest value the bound admits. + /// + /// A control whose range is inclusive has to start here rather than at + /// `min`, so it cannot offer a value the write path will reject. Deriving it + /// keeps the offset out of the hands of whoever writes the next control. + pub fn lowest(self) -> f64 { + if self.exclusive_min { + self.min.next_up() + } else { + self.min + } + } + + /// The rule in words, for an error a user reads. + pub fn describe(self) -> String { + let low = if self.exclusive_min { + format!("greater than {}", self.min) + } else { + format!("at least {}", self.min) + }; + format!("{low} and at most {}", self.max) + } + + /// Validate a value against the bound, naming the property in the failure. + pub fn check( + self, + property: PropertyId, + subject: &str, + value: f64, + ) -> Result { + if self.admits(value) { + return Ok(value); + } + Err(PropertyError::InvalidValue { + property, + message: format!("{subject} must be {}", self.describe()), + }) + } +} + +/// The static value schema. Bounds that depend on the target's current state are +/// reported by [`ResolvedProperty::schema`] instead, keeping the definition +/// context-free. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ValueSchema { + Bool, + Int { min: i64, max: i64 }, + Float { bounds: FloatBounds, log: bool }, + Enum { variants: &'static [EnumVariant] }, + Color, +} + +impl ValueSchema { + /// The declared numeric range, when this is a float schema. Both the control + /// and the write path ask for it here rather than restating it. + pub const fn float_bounds(&self) -> Option { + match self { + Self::Float { bounds, .. } => Some(*bounds), + _ => None, + } + } +} + +/// A property value in transit between the catalog and a control. It is never +/// stored: the authoritative value stays in the typed domain model. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum PropertyValue { + Bool(bool), + Int(i64), + Float(f64), + /// One of the owning schema's static variant ids. + Enum(&'static str), + Color(Color), +} + +impl PropertyValue { + pub const fn kind(&self) -> &'static str { + match self { + Self::Bool(_) => "bool", + Self::Int(_) => "int", + Self::Float(_) => "float", + Self::Enum(_) => "enum", + Self::Color(_) => "color", + } + } + + pub const fn as_bool(&self) -> Option { + match self { + Self::Bool(value) => Some(*value), + _ => None, + } + } + + pub const fn as_int(&self) -> Option { + match self { + Self::Int(value) => Some(*value), + _ => None, + } + } + + pub const fn as_float(&self) -> Option { + match self { + Self::Float(value) => Some(*value), + _ => None, + } + } + + pub const fn as_enum(&self) -> Option<&'static str> { + match self { + Self::Enum(value) => Some(*value), + _ => None, + } + } + + pub const fn as_color(&self) -> Option { + match self { + Self::Color(value) => Some(*value), + _ => None, + } + } +} + +/// How the default of a property is obtained. Defaults are *derived*, never +/// stored next to the current value. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum DefaultPolicy { + /// Re-run the same factory that materializes a new encoding, in the + /// target's current context, and read this property out of the result. + EncodingFactory, + /// A literal that does not depend on the target. + Fixed(PropertyValue), + /// Read-only values have no default to reset to. + None, +} + +/// How many copies of one setting a single target holds. +/// +/// Most settings have exactly one copy per target, so a single-target read can +/// only ever be uniform. A few describe a shape the target mirrors — a contour +/// ladder keeps a positive and a negative half that share base, count and +/// ratio — and those have one copy per half, which is why even a single-target +/// read is an aggregate. +/// +/// The distinction is declared here rather than inferred by a control, so a +/// frontend can say *which* sources disagree without knowing what the target's +/// domain model looks like. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ValueCopies { + /// Exactly one copy per target. + PerTarget, + /// One copy per mirrored half of a symmetric pair the target holds. + PerMirroredHalf, +} + +/// One step of a direct-manipulation gesture along a property's own scale. +/// +/// The gesture names a direction, never a value: what one step *is* belongs to +/// the property, so a canvas key and a panel control cannot disagree about it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PropertyStep { + Raise, + Lower, +} + +impl PropertyStep { + pub const fn as_str(self) -> &'static str { + match self { + Self::Raise => "raise", + Self::Lower => "lower", + } + } +} + +/// Which owner-local component the address must name. Field and column +/// properties are addressed through their own child `ResourceRef` with no +/// component at all, so they are absent here (§3.1). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ComponentKind { + None, + Series, + ProcessingStep, +} + +impl ComponentKind { + pub const fn as_str(self) -> &'static str { + match self { + Self::None => "none", + Self::Series => "series", + Self::ProcessingStep => "processing_step", + } + } + + pub fn of(component: Option<&ComponentRef>) -> Self { + match component { + None => Self::None, + Some(ComponentRef::Series(_)) => Self::Series, + Some(ComponentRef::ProcessingStep(_)) => Self::ProcessingStep, + } + } +} + +/// Which concrete visual encoding a property belongs to. This is a fact about +/// the rendering model, not about a data domain: any field that can be drawn as +/// a contour exposes the same contour properties, whatever it measures. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EncodingKind { + Line, + Contour, + Heatmap, + Image, +} + +impl EncodingKind { + pub const fn as_str(self) -> &'static str { + match self { + Self::Line => "line", + Self::Contour => "contour", + Self::Heatmap => "heatmap", + Self::Image => "image", + } + } + + pub const fn of(encoding: &plotx_figure::SeriesEncoding) -> Self { + match encoding { + plotx_figure::SeriesEncoding::Line(_) => Self::Line, + plotx_figure::SeriesEncoding::Contour(_) => Self::Contour, + plotx_figure::SeriesEncoding::Heatmap(_) => Self::Heatmap, + plotx_figure::SeriesEncoding::Image(_) => Self::Image, + } + } +} + +/// When a property applies to a target. Everything here is expressed as a +/// component shape plus rendering capabilities; no branch of the catalog may +/// name a data domain. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Applicability { + pub component: ComponentKind, + pub encoding: Option, + pub required_capabilities: &'static [&'static str], +} + +impl Applicability { + pub const fn component(component: ComponentKind) -> Self { + Self { + component, + encoding: None, + required_capabilities: &[], + } + } + + pub const fn encoding(component: ComponentKind, encoding: EncodingKind) -> Self { + Self { + component, + encoding: Some(encoding), + required_capabilities: &[], + } + } + + pub const fn requiring(mut self, capabilities: &'static [&'static str]) -> Self { + self.required_capabilities = capabilities; + self + } +} + +/// The static, language-neutral description of one property. +#[derive(Clone, Copy, Debug)] +pub struct PropertyDefinition { + pub id: PropertyId, + pub scope_kind: ScopeKind, + pub value_schema: ValueSchema, + pub access: PropertyAccess, + pub applicability: Applicability, + pub default_policy: DefaultPolicy, + pub tier: Tier, + /// How many copies of this setting one target holds, so a control can word + /// a disagreement precisely instead of listing every way one could arise. + pub copies: ValueCopies, + pub canonical_label: &'static str, + /// Stable English search terms. The presentation layer adds localized ones; + /// it may never introduce an entry that has no definition here. + pub canonical_aliases: &'static [&'static str], +} + +/// Where a property lives: a target (resource plus at most one owner-local +/// component) and the definition being addressed. +#[derive(Clone, Debug, PartialEq)] +pub struct PropertyAddress { + pub target: TargetRef, + pub definition: PropertyId, +} + +impl PropertyAddress { + pub fn new(target: TargetRef, definition: PropertyId) -> Self { + Self { target, definition } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Availability { + Editable, + ReadOnly, +} + +/// The schema narrowed to one concrete target: the enum choices its field's +/// capabilities permit, and the bounds and unit that its current state implies. +#[derive(Clone, Debug, PartialEq)] +pub enum ResolvedSchema { + Bool, + Int { + min: i64, + max: i64, + }, + Float { + bounds: FloatBounds, + log: bool, + /// A short unit or multiplier caption ("× σ", "fraction"), empty when + /// the number speaks for itself. + unit: &'static str, + }, + Enum { + variants: Vec<&'static EnumVariant>, + }, + Color, +} + +/// A property read against one target. The value and its default are derived on +/// the spot; neither is stored anywhere in the catalog. +#[derive(Clone, Debug, PartialEq)] +pub struct ResolvedProperty { + pub address: PropertyAddress, + /// One target can already hold the setting more than once — a contour + /// ladder keeps a positive and a negative half that share base, count and + /// ratio — so even a single-target read is an aggregate. `Mixed` here means + /// the target's own copies disagree, and the read refuses to pass one of + /// them off as the whole setting. + pub value: AggregateValue, + /// `None` for read-only properties, which have nothing to reset to. + pub default_value: Option, + pub availability: Availability, + pub schema: ResolvedSchema, +} + +impl ResolvedProperty { + /// Whether the target currently differs from what the default policy would + /// produce for it right now. A target whose own copies disagree cannot be + /// what the factory produced, which always writes one value to every copy. + pub fn is_modified(&self) -> bool { + match &self.value { + AggregateValue::Uniform(value) => { + self.default_value.is_some_and(|default| default != *value) + } + AggregateValue::Mixed => true, + AggregateValue::Unavailable => false, + } + } +} + +/// The read side of an aggregate: several sources of one setting, folded into +/// the single answer a control can show. +/// +/// The sources are not necessarily targets. Both the copies one target holds of +/// a shared setting and the targets of a multi-selection aggregate through this +/// same type, so "the two halves of this ladder disagree" and "these two series +/// disagree" are one fact with one representation rather than two parallel ones. +#[derive(Clone, Debug, PartialEq)] +pub enum AggregateValue { + Uniform(T), + Mixed, + Unavailable, +} + +impl AggregateValue { + pub const fn uniform(&self) -> Option<&T> { + match self { + Self::Uniform(value) => Some(value), + Self::Mixed | Self::Unavailable => None, + } + } +} + +impl AggregateValue { + /// Fold one more source in. + /// + /// `Unavailable` is the empty read and therefore the identity, so folding + /// over no sources at all yields it. Once any source is `Mixed`, or two + /// sources carry different values, the result stays `Mixed`: disagreement + /// inside a source and disagreement between sources compose instead of one + /// hiding the other. + #[must_use] + pub fn merge(self, other: Self) -> Self { + match (self, other) { + (Self::Unavailable, other) => other, + (current, Self::Unavailable) => current, + (Self::Uniform(current), Self::Uniform(next)) if current == next => { + Self::Uniform(current) + } + _ => Self::Mixed, + } + } +} + +/// One property read across a selection. Targets the property does not apply to +/// are reported with a reason rather than silently dropped. +#[derive(Clone, Debug, PartialEq)] +pub struct ResolvedPropertySet { + pub applicable_targets: Vec, + pub skipped_targets: Vec<(TargetRef, String)>, + pub value: AggregateValue, +} + +/// A validated, not-yet-executed write. Every applicable target is already +/// folded into one atomic action, so a commit either lands everywhere or +/// nowhere. +#[derive(Clone)] +pub struct PropertyCommit { + pub action: crate::actions::Action, + pub applied: Vec, + pub skipped: Vec<(TargetRef, String)>, +} + +impl fmt::Debug for PropertyCommit { + /// `Action` is deliberately not `Debug` — it carries whole document + /// snapshots — so a commit reports what it would do, not the payload. + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PropertyCommit") + .field("applied", &self.applied) + .field("skipped", &self.skipped) + .finish_non_exhaustive() + } +} + +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum PropertyError { + #[error("unknown property '{0}'")] + UnknownProperty(String), + #[error("property {property} addresses a {expected} component, not {actual}")] + ComponentKind { + property: PropertyId, + expected: &'static str, + actual: &'static str, + }, + #[error("no such target: {0}")] + UnknownTarget(String), + #[error("{0}")] + NotApplicable(String), + #[error("property {0} is read-only")] + ReadOnly(PropertyId), + #[error("invalid value for {property}: {message}")] + InvalidValue { + property: PropertyId, + message: String, + }, +} diff --git a/crates/core/src/properties/readout.rs b/crates/core/src/properties/readout.rs new file mode 100644 index 0000000..2da3161 --- /dev/null +++ b/crates/core/src/properties/readout.rs @@ -0,0 +1,193 @@ +//! What a contour's lowest level *currently means*, for display next to the +//! control and in the corner of the plot. +//! +//! A multiple is not a threshold. "5" means five noise σ, five units of +//! intensity, or five percent of a range depending on the anchor, and only the +//! first of those tells the user whether a cross peak will survive. §4.3 +//! therefore asks the interface to show the resolved semantics — `5 × σ = +//! 1.2e4` — rather than the bare number the control edits. +//! +//! The resolved half of that sentence comes from an estimate, and estimates are +//! asynchronous, content-addressed and computed on demand (§3.4, §6). This +//! module is consequently **read-only in the strongest sense**: it takes +//! `&self`, reads only what the derived caches already hold, and has no path to +//! minting a field version, queueing a job, or materializing a payload. A miss +//! is reported as a miss. Computing an estimate so that a label could be +//! populated would make drawing the interface schedule scientific work, which +//! is exactly the coupling the field runtime exists to prevent. + +use super::contour; +use crate::automation::TargetRef; +use crate::state::{ + ContourResolution, EstimateKey, EstimateKind, EstimateResult, EstimatedScale, FieldRef, + FieldSummary, NoiseScaleTerm, PlotxApp, VersionedFieldRef, contour_base_kind, + resolve_contour_levels, resolved_noise_scale, +}; +use plotx_figure::{ContourBasePolicy, SeriesEncoding, UnitInterval}; + +/// Whether the number a user set can currently be turned into a level, and why +/// not when it cannot. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ContourAnchor { + /// The magnitude needs no measurement: it is already a level, or a fraction + /// of a range the field summary knows. + Direct, + /// The estimate this multiple is measured against is cached, and it is at + /// or above the anchor's floor, so the estimate is what the number means. + Measured, + /// The cached estimate is *below* the anchor's peak floor, so the multiple + /// is measured against the floor instead. + /// + /// This is a different sentence from [`Self::Measured`] rather than a detail + /// of it. The level on screen no longer follows the estimator, and an + /// interface that still read `5 × σ` here would be naming a quantity the + /// plot is not drawn from — the exact substitution this readout exists to + /// make impossible. + Floored, + /// The estimator ran and measured no spread at all. The multiple anchors + /// nothing — five times zero is zero — so the ladder falls back to one + /// derived from the field's own peak, and saying "5σ = 0" would be a + /// misleading way to describe a plot that is visibly drawing contours. + Degenerate, + /// Nothing is cached yet. The multiple is known and the level is not; + /// inventing one here would mean measuring on the interface thread. + Measuring, +} + +/// The lowest contour level of one series, in the terms the user set it in and +/// in the field's own units. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ContourBaseReadout { + /// The anchor kind, one of the `CONTOUR_BASE_*` ids, so a caller can word + /// the multiple without matching on a policy type it should not know. + pub kind: &'static str, + /// The number the control edits: a multiplier, a fraction, or a level. + pub magnitude: f64, + /// The lowest level actually drawn right now, when it is known. This is the + /// resolved ladder's first rung, not `base` — a ladder truncated at the + /// peak, or rebuilt from the peak after a degenerate estimate, draws + /// something other than the number the anchor implies, and the readout + /// reports what is on screen. + pub lowest_level: Option, + /// The fraction of the field's peak magnitude this anchor will not resolve + /// below, for anchors that carry such a floor; `None` for the rest. It is + /// reported whether or not the floor is currently the term in force, so a + /// caller can name the floor in both sentences. + pub peak_fraction: Option, + pub anchor: ContourAnchor, +} + +impl PlotxApp { + /// Read what one contour series' lowest level currently means. + /// + /// Returns `None` when the target is not a contour series at all. A target + /// that is one but whose estimate has not arrived returns a readout with + /// [`ContourAnchor::Measuring`]: the difference between "no such setting" + /// and "not measured yet" is one the interface has to be able to state. + pub fn contour_base_readout(&self, target: &TargetRef) -> Option { + let context = self.series_context_unchecked(target).ok()?; + let SeriesEncoding::Contour(spec) = context.encoding else { + return None; + }; + let source = VersionedFieldRef { + field: FieldRef { + resource: context.dataset.resource_id(), + field: context.field, + }, + // A field with no runtime version has never been drawn, so nothing + // about it is cached. Minting one here would be this module + // allocating session state on behalf of a label. + version: self.session.compute.current_field_version(FieldRef { + resource: context.dataset.resource_id(), + field: context.field, + })?, + }; + let summary = self.session.compute.peek_field_summary(source); + let anchor = match &spec.positive.base { + ContourBasePolicy::Absolute(_) | ContourBasePolicy::FractionOfRange(_) => { + ContourAnchor::Direct + } + ContourBasePolicy::NoiseFloor { + peak_fraction, + estimator, + .. + } => self.floored_anchor_of( + &EstimateKey { + source, + kind: EstimateKind::Noise, + estimator: estimator.clone(), + }, + *peak_fraction, + summary, + ), + ContourBasePolicy::BackgroundScale { estimator, .. } => self.anchor_of(&EstimateKey { + source, + kind: EstimateKind::Background, + estimator: estimator.clone(), + }), + }; + // Resolution is pure arithmetic over a cached summary and cached + // estimates; the payload is never touched. A miss simply yields + // `Pending`, which is reported rather than acted on. + let lowest_level = summary.and_then(|summary| { + match resolve_contour_levels(source, spec, summary, |key| { + self.session.compute.peek_estimate(key).cloned() + }) { + ContourResolution::Ready { levels, .. } => { + levels.positive.first().map(|level| level.get()) + } + ContourResolution::Pending(_) | ContourResolution::Unavailable => None, + } + }); + Some(ContourBaseReadout { + kind: contour_base_kind(&spec.positive.base), + magnitude: contour::base_magnitude(&spec.positive.base), + lowest_level, + peak_fraction: contour::base_peak_fraction(&spec.positive.base), + anchor, + }) + } + + /// Classify a floored noise anchor: whether the estimate or the floor is the + /// scale in force right now. + /// + /// Without a cached summary the floor cannot be compared against anything, + /// and answering `Measured` would assert the estimate is in force when it + /// may not be. A field with no cached summary has never been drawn, so + /// reporting the measurement as still outstanding is both true and the only + /// thing this read-only module may do about it. + fn floored_anchor_of( + &self, + key: &EstimateKey, + peak_fraction: UnitInterval, + summary: Option, + ) -> ContourAnchor { + let (Some(EstimateResult::Scale(estimate)), Some(summary)) = + (self.session.compute.peek_estimate(key), summary) + else { + return ContourAnchor::Measuring; + }; + match resolved_noise_scale(estimate.scale, peak_fraction, summary) { + // Neither term measured anything: a flat field with no peak either. + (scale, _) if scale <= 0.0 => ContourAnchor::Degenerate, + (_, NoiseScaleTerm::PeakFloor) => ContourAnchor::Floored, + (_, NoiseScaleTerm::Estimated) => ContourAnchor::Measured, + } + } + + fn anchor_of(&self, key: &EstimateKey) -> ContourAnchor { + let scale = match self.session.compute.peek_estimate(key) { + None => return ContourAnchor::Measuring, + Some(EstimateResult::Scale(estimate)) => estimate.scale, + Some(EstimateResult::LocationScale(estimate)) => estimate.scale, + }; + match scale { + EstimatedScale::Positive(_) => ContourAnchor::Measured, + EstimatedScale::Degenerate => ContourAnchor::Degenerate, + } + } +} + +#[cfg(test)] +#[path = "readout_tests.rs"] +mod tests; diff --git a/crates/core/src/properties/readout_tests.rs b/crates/core/src/properties/readout_tests.rs new file mode 100644 index 0000000..64c4bc1 --- /dev/null +++ b/crates/core/src/properties/readout_tests.rs @@ -0,0 +1,176 @@ +//! The live readout may only ever *read*. +//! +//! These tests are the guard on the seam: a readout must report a cache miss +//! rather than filling it, so drawing the interface can never queue an estimate, +//! run marching squares on the calling thread, or materialize a field payload. + +use crate::contour_probe; +use crate::properties::tests::contour_app; +use crate::properties::{ContourAnchor, PropertyValue, contour}; +use crate::state::{ + CONTOUR_BASE_ABSOLUTE, CONTOUR_BASE_NOISE_FLOOR, ChartSpec, DataBinding, DataDomain, PlotxApp, + StackSpec, +}; +use std::time::{Duration, Instant}; + +fn binding(app: &PlotxApp) -> DataBinding { + app.doc.canvases[0] + .objects + .first() + .and_then(|object| object.plot()) + .expect("the fixture holds one plot") + .binding + .clone() +} + +/// Draw the fixture's plot once, the way a frame does. +fn rebuild(app: &mut PlotxApp) { + let binding = binding(app); + app.build_binding_figure( + &binding, + &ChartSpec::default_for(DataDomain::Nmr2d), + &StackSpec::default(), + [120.0, 80.0], + ); +} + +fn settle(app: &mut PlotxApp) { + let deadline = Instant::now() + Duration::from_secs(2); + while app.compute_busy() && Instant::now() < deadline { + app.poll_compute(); + std::thread::sleep(Duration::from_millis(5)); + } + app.poll_compute(); + assert!(!app.compute_busy(), "field work did not settle in time"); +} + +/// Rebuild until both the estimate and the geometry have landed. +fn warm(app: &mut PlotxApp) { + for _ in 0..4 { + rebuild(app); + settle(app); + } +} + +/// §4.3: before anything is measured, the readout says so instead of showing a +/// number nobody computed — and asking does not start the measurement. +#[test] +fn an_unmeasured_anchor_reads_as_measuring_without_queueing_anything() { + let (app, target) = contour_app(); + + contour_probe::reset(); + let readout = app + .contour_base_readout(&target) + .expect("the fixture draws a contour"); + + assert_eq!(readout.kind, CONTOUR_BASE_NOISE_FLOOR); + assert_eq!(readout.anchor, ContourAnchor::Measuring); + assert_eq!(readout.lowest_level, None); + assert_eq!(contour_probe::queued_estimates(), 0); + assert_eq!(contour_probe::queued_contour_builds(), 0); + assert_eq!(contour_probe::field_payload_materializations(), 0); + assert_eq!(contour_probe::marching_squares_on_this_thread(), 0); +} + +/// The warm path: a cached noise estimate resolves the multiple into a level, +/// and reading it repeatedly costs nothing at all — no payload, no job, no +/// marching squares on this thread. +#[test] +fn a_cached_estimate_resolves_the_level_and_reading_it_stays_free() { + let (mut app, target) = contour_app(); + warm(&mut app); + + contour_probe::reset(); + let readout = app + .contour_base_readout(&target) + .expect("the fixture draws a contour"); + + assert_eq!(readout.anchor, ContourAnchor::Measured); + assert_eq!(readout.kind, CONTOUR_BASE_NOISE_FLOOR); + let level = readout.lowest_level.expect("a measured anchor has a level"); + assert!(level > 0.0, "a positive half's lowest level is positive"); + assert!( + (level - readout.magnitude).abs() > f64::EPSILON, + "the readout must resolve the multiple, not repeat it: {readout:?}" + ); + + for _ in 0..8 { + assert_eq!(app.contour_base_readout(&target), Some(readout)); + } + assert_eq!( + contour_probe::queued_estimates(), + 0, + "a readout must never queue an estimate" + ); + assert_eq!(contour_probe::queued_contour_builds(), 0); + assert_eq!( + contour_probe::field_payload_materializations(), + 0, + "a readout must never materialize a field payload" + ); + assert_eq!( + contour_probe::marching_squares_on_this_thread(), + 0, + "a readout must never run marching squares on the calling thread" + ); +} + +/// §4.3, and the whole point of spelling the floor into the policy: when the +/// floor is what the level came from, the readout says so rather than passing +/// the level off as a multiple of an estimate that did not produce it. +/// +/// The plane is flat noise around zero with one feature a million times larger — +/// the shape of every spectrum with a dominant diagonal or solvent peak — so its +/// robust σ is far below the anchor's floor. +#[test] +fn a_field_whose_sigma_falls_under_the_floor_reads_as_floored() { + let mut plane = vec![0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0]; + plane.extend([0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0]); + plane.push(1.0e6); + let (mut app, target) = crate::properties::tests::contour_app_with_plane(&plane); + warm(&mut app); + + contour_probe::reset(); + let readout = app + .contour_base_readout(&target) + .expect("the fixture draws a contour"); + + assert_eq!(readout.kind, CONTOUR_BASE_NOISE_FLOOR); + assert_eq!( + readout.anchor, + ContourAnchor::Floored, + "a field this far past the floor cannot be described as `5 × σ`: {readout:?}" + ); + assert_eq!(readout.peak_fraction, Some(1.0e-4)); + let level = readout.lowest_level.expect("a floored anchor has a level"); + assert!( + (level - readout.magnitude * 1.0e-4 * 1.0e6).abs() < 1.0e-6, + "the level comes from the floor, not the estimate: {readout:?}" + ); + assert_eq!(contour_probe::queued_estimates(), 0); + assert_eq!(contour_probe::field_payload_materializations(), 0); +} + +/// §4.3 on a policy that needs no measurement: an absolute level is its own +/// level, and the readout says so directly rather than reporting `Measuring`. +#[test] +fn a_directly_anchored_base_needs_no_estimate() { + let (mut app, target) = contour_app(); + warm(&mut app); + let commit = app + .plan_property_write( + contour::BASE_POLICY, + std::slice::from_ref(&target), + &PropertyValue::Enum(CONTOUR_BASE_ABSOLUTE), + ) + .expect("an absolute base needs no capability"); + app.commit_property(commit); + warm(&mut app); + + contour_probe::reset(); + let readout = app.contour_base_readout(&target).expect("still a contour"); + + assert_eq!(readout.anchor, ContourAnchor::Direct); + assert_eq!(contour_probe::field_payload_materializations(), 0); + assert_eq!(contour_probe::queued_estimates(), 0); +} diff --git a/crates/core/src/properties/scope_tests.rs b/crates/core/src/properties/scope_tests.rs new file mode 100644 index 0000000..8e1da20 --- /dev/null +++ b/crates/core/src/properties/scope_tests.rs @@ -0,0 +1,381 @@ +//! What a catalog write is *allowed to reach*, and what context it resolves in. +//! +//! Two failures live here, and both are silent by nature. A reset that rebuilds +//! more than the surface offering it names changes settings the user never +//! looked at, and reports success. A default resolved against a context the +//! target has since left hands the writer a value from a different frame of +//! reference, and either lands a wrong number or refuses forever. Neither is +//! visible without a test that states the boundary. + +use super::tests::{contour_app, contour_spec}; +use super::*; +use crate::automation::{ComponentRef, TargetRef}; +use crate::state::{ + CONTOUR_BASE_ABSOLUTE, CONTOUR_BASE_BACKGROUND_SCALE, CONTOUR_BASE_FRACTION_OF_RANGE, + CONTOUR_BASE_NOISE_FLOOR, CanvasDocument, Dataset, ObjectFrame, PlotxApp, contour_base_kind, + default_contour_spec, field_peak_magnitude, +}; +use plotx_figure::{HeatmapSpec, SeriesEncoding}; + +fn object_of(target: &TargetRef) -> crate::state::ObjectId { + target + .resource + .local_id + .as_deref() + .expect("a canvas object target carries a local id") + .parse() + .expect("the local id is an object id") +} + +fn encoding_of(app: &PlotxApp, target: &TargetRef) -> SeriesEncoding { + let Some(ComponentRef::Series(series)) = target.component else { + panic!("the fixture addresses a series"); + }; + app.doc.canvases[0] + .object(object_of(target)) + .and_then(|object| object.plot()) + .expect("plot") + .binding + .series + .iter() + .find(|candidate| candidate.id == series) + .expect("series") + .encoding + .clone() +} + +/// One plot drawing a contour *and* a heatmap from the same field — the case a +/// section-scoped action has to survive. The heatmap is deliberately set away +/// from its factory value, so a reset that reaches it is visible. +fn stacked_app() -> (PlotxApp, TargetRef, TargetRef) { + let (mut app, contour) = contour_app(); + let object = object_of(&contour); + let heatmap_id = { + let plot = app.doc.canvases[0] + .object_mut(object) + .and_then(|object| object.plot_mut()) + .expect("plot"); + let id = plot.allocate_series_id(); + let mut extra = plot.binding.series[0].clone(); + extra.id = id; + extra.encoding = SeriesEncoding::Heatmap(HeatmapSpec { + value_range: Some([1.0, 9.0]), + ..HeatmapSpec::default() + }); + plot.binding.series.push(extra); + id + }; + let heatmap = app + .series_target(0, object, heatmap_id) + .expect("the heatmap series is addressable"); + (app, contour, heatmap) +} + +/// §4.2: resetting *one encoding* is scoped to that encoding. +/// +/// The contour section offers "Reset contour" over every series of the objects +/// it is showing, because a series is where the setting lives. Without a scope, +/// that reset walked into the heatmap stacked under the contour, replaced it +/// from the factory, and counted it as a contour in the status line — a +/// destructive edit to a setting whose control was never on screen, reported as +/// a success. The scope belongs to the request rather than to the caller's +/// target list: a property write already gets it from the definition it belongs +/// to, and an encoding reset has no property to get it from. +#[test] +fn resetting_one_encoding_leaves_a_stacked_encoding_untouched() { + let (mut app, contour, heatmap) = stacked_app(); + let before = encoding_of(&app, &heatmap); + + let commit = app + .plan_encoding_reset(EncodingKind::Contour, &[contour.clone(), heatmap.clone()]) + .expect("the reset plans"); + assert_eq!( + commit.applied.len(), + 1, + "only the contour is in this reset's scope: {:?}", + commit.applied + ); + assert_eq!( + commit.skipped.len(), + 1, + "the series outside the scope is reported, never silently included" + ); + assert!( + commit.skipped[0].1.contains("heatmap"), + "the reason names what the series actually draws: {}", + commit.skipped[0].1 + ); + app.commit_property(commit); + + assert_eq!( + encoding_of(&app, &heatmap), + before, + "a reset named for the contour must not rebuild the heatmap beneath it" + ); + assert!(matches!( + encoding_of(&app, &contour), + SeriesEncoding::Contour(_) + )); +} + +/// The complement: the scope is not a way to skip work the caller did ask for. +#[test] +fn an_encoding_reset_still_rebuilds_every_target_in_its_scope() { + let (mut app, contour, _) = stacked_app(); + let object = object_of(&contour); + if let Some(SeriesEncoding::Contour(spec)) = app.doc.canvases[0] + .object_mut(object) + .and_then(|object| object.plot_mut()) + .and_then(|plot| plot.binding.series.first_mut()) + .map(|series| &mut series.encoding) + { + spec.positive.count = 2; + } + let commit = app + .plan_encoding_reset(EncodingKind::Contour, std::slice::from_ref(&contour)) + .expect("the reset plans"); + app.commit_property(commit); + assert_eq!(contour_spec(&app, &contour).positive.count, 14); +} + +/// A bounded, unsigned 2D field: the AFM height map the design names as the +/// case whose background is not centred on zero. Its capabilities admit +/// `BackgroundScale` (the factory's choice) *and* `FractionOfRange`, which is +/// what makes it the field where re-anchoring and resetting can disagree. +fn afm_contour_app() -> (PlotxApp, TargetRef) { + let channel = plotx_io::AfmImageChannel { + name: "Height".to_owned(), + width: 4, + height: 4, + scan_size_x: 3.0, + scan_size_y: 3.0, + lateral_unit: "nm".to_owned(), + scale: plotx_io::AfmScale { + multiplier: 1.0, + offset: 0.0, + unit: "nm".to_owned(), + }, + raw: std::sync::Arc::from((0..16).collect::>()), + frame_direction: plotx_io::AfmFrameDirection::Trace, + }; + let mut app = PlotxApp::new(); + app.doc + .datasets + .push(Dataset::Afm(Box::new(crate::state::AfmDataset::load( + plotx_io::AfmData { + images: vec![channel], + forces: None, + source: "anchor scope".to_owned(), + import_warnings: Vec::new(), + }, + )))); + let mut canvas = CanvasDocument::new("page".to_owned(), [120.0, 80.0]); + let id = canvas.allocate_object_id(); + let object = + app.build_plot_object(0, ObjectFrame::new(0.0, 0.0, 100.0, 80.0), id, "Map".into()); + canvas.objects.push(object); + app.doc.canvases.push(canvas); + app.session.active_canvas = Some(0); + + // A height map defaults to a heatmap; the encoding a user picks from the + // chart gallery is what puts contour properties on it. §4.2: any field with + // a regular scalar grid can carry either. + let field = app.doc.canvases[0] + .object(id) + .and_then(|object| object.plot()) + .and_then(|plot| plot.binding.series.first()) + .map(|series| series.source.field) + .expect("one series"); + let capabilities = app.doc.datasets[0] + .field_descriptor(field) + .map(|descriptor| descriptor.capabilities) + .expect("the height map is a field"); + let encoding = SeriesEncoding::Contour(default_contour_spec(&capabilities, &|| { + field_peak_magnitude(&app.doc.datasets[0], field) + })); + let series = { + let series = app.doc.canvases[0] + .object_mut(id) + .and_then(|object| object.plot_mut()) + .and_then(|plot| plot.binding.series.first_mut()) + .expect("one series"); + series.encoding = encoding; + series.id + }; + let target = app + .series_target(0, id, series) + .expect("the series is addressable"); + (app, target) +} + +#[test] +fn the_afm_fixture_anchors_on_the_background_and_admits_a_fraction() { + let (app, target) = afm_contour_app(); + assert_eq!( + contour_base_kind(&contour_spec(&app, &target).positive.base), + CONTOUR_BASE_BACKGROUND_SCALE + ); + let resolved = app + .resolve_property(&PropertyAddress::new(target.clone(), contour::BASE_POLICY)) + .expect("the anchor resolves"); + let ResolvedSchema::Enum { variants } = resolved.schema else { + panic!("the anchor is a choice"); + }; + assert!( + variants + .iter() + .any(|variant| variant.id == CONTOUR_BASE_FRACTION_OF_RANGE), + "an unsigned bounded field admits a fraction of its range" + ); +} + +/// §8.1: the default is *derived in the target's current context*, and the +/// anchor is part of that context. +/// +/// A magnitude means nothing on its own — "5" is five times a measured spread +/// under one anchor and five times the whole value range under another. Reading +/// the default off the factory's spec therefore answered with a number from a +/// frame of reference the target had left. On this field the factory anchors on +/// the background with a multiplier of 5, so a user who switches to a fraction +/// of the range and resets got 5 handed to a policy whose ceiling is 1: the +/// reset failed, every time, with no way to make it succeed. +#[test] +fn resetting_the_lowest_level_re_derives_it_under_the_anchor_in_force() { + let (mut app, target) = afm_contour_app(); + let targets = std::slice::from_ref(&target); + let commit = app + .plan_property_write( + contour::BASE_POLICY, + targets, + &PropertyValue::Enum(CONTOUR_BASE_FRACTION_OF_RANGE), + ) + .expect("a bounded unsigned field admits a fraction anchor"); + app.commit_property(commit); + + let address = PropertyAddress::new(target.clone(), contour::BASE_MAGNITUDE); + let resolved = app.resolve_property(&address).expect("the level resolves"); + assert_eq!( + resolved.default_value, + Some(PropertyValue::Float(0.04)), + "the default is the magnitude a fresh base of the anchor in force carries, \ + not the multiplier the factory's own anchor happened to use" + ); + + let commit = app + .plan_property_reset(contour::BASE_MAGNITUDE, targets) + .expect("a reset under the anchor in force is a value that anchor accepts"); + app.commit_property(commit); + let spec = contour_spec(&app, &target); + assert_eq!( + contour_base_kind(&spec.positive.base), + CONTOUR_BASE_FRACTION_OF_RANGE, + "resetting one property re-derives that property, and leaves the anchor alone" + ); + assert_eq!( + app.resolve_property(&address).expect("resolves").value, + AggregateValue::Uniform(PropertyValue::Float(0.04)) + ); +} + +/// The same rule on a signed field, where the two anchors differ by orders of +/// magnitude rather than by a ceiling: an absolute level reset to the factory's +/// `5` would be five raw intensity units, which on this spectrum is a threshold +/// nobody chose. +#[test] +fn an_absolute_anchor_resets_to_a_level_and_not_to_a_multiplier() { + let (mut app, target) = contour_app(); + let targets = std::slice::from_ref(&target); + assert_eq!( + contour_base_kind(&contour_spec(&app, &target).positive.base), + CONTOUR_BASE_NOISE_FLOOR + ); + let commit = app + .plan_property_write( + contour::BASE_POLICY, + targets, + &PropertyValue::Enum(CONTOUR_BASE_ABSOLUTE), + ) + .expect("an absolute level needs no capability"); + app.commit_property(commit); + + let anchored = super::contour::base_magnitude(&contour_spec(&app, &target).positive.base); + let commit = app + .plan_property_reset(contour::BASE_MAGNITUDE, targets) + .expect("the reset plans"); + app.commit_property(commit); + let after = super::contour::base_magnitude(&contour_spec(&app, &target).positive.base); + assert!( + (after - anchored).abs() < 1.0e-12, + "an absolute base freshly anchored to this field is what the reset must \ + produce; got {after} where re-anchoring produces {anchored}" + ); + assert_ne!( + after, 5.0, + "the noise multiplier belongs to the anchor the target no longer holds" + ); +} + +/// §4.3 declares `ratio > 1.0`. A schema that admits exactly one while the +/// writer refuses it is one rule written twice, and the control was built from +/// the half that was wrong: the value could be entered and never stored. +#[test] +fn the_level_ratio_is_open_at_one_in_the_schema_the_control_is_built_from() { + let bounds = definition(contour::RATIO) + .expect("the ratio is registered") + .value_schema + .float_bounds() + .expect("the ratio is a float"); + assert!(bounds.exclusive_min, "a ratio of one draws one level twice"); + assert!(!bounds.admits(1.0)); + assert!(bounds.lowest() > 1.0); + + let (mut app, target) = contour_app(); + let targets = std::slice::from_ref(&target); + let refused = app + .plan_property_write(contour::RATIO, targets, &PropertyValue::Float(1.0)) + .expect_err("the writer refuses the bound itself"); + assert!(matches!(refused, PropertyError::InvalidValue { .. })); + + // The floor a control offers has to be a value the writer takes. This is + // the pairing that drifted: the two ends now read the same declaration. + let commit = app + .plan_property_write( + contour::RATIO, + targets, + &PropertyValue::Float(bounds.lowest()), + ) + .expect("the smallest value the schema admits is storable"); + app.commit_property(commit); + assert!(contour_spec(&app, &target).positive.ratio.get() > 1.0); +} + +/// The readout of a stacked plot follows applicability, so this pins what +/// "applicable" answers there: the contour, not whichever series is first. +#[test] +fn applicability_names_the_contour_under_a_heatmap_drawn_first() { + let (mut app, contour, heatmap) = stacked_app(); + // Put the heatmap first, which is what a heatmap with a contour drawn over + // it looks like. + { + let plot = app.doc.canvases[0] + .object_mut(object_of(&contour)) + .and_then(|object| object.plot_mut()) + .expect("plot"); + plot.binding.series.reverse(); + } + let targets = app.series_targets(0, object_of(&contour)); + assert_eq!( + targets.first(), + Some(&heatmap), + "the heatmap is drawn first" + ); + let set = app.resolve_property_set(contour::BASE_MAGNITUDE, &targets); + assert_eq!( + set.applicable_targets + .iter() + .map(|address| address.target.clone()) + .collect::>(), + vec![contour], + "only the series that draws a contour has a lowest contour level" + ); +} diff --git a/crates/core/src/properties/service.rs b/crates/core/src/properties/service.rs new file mode 100644 index 0000000..c346580 --- /dev/null +++ b/crates/core/src/properties/service.rs @@ -0,0 +1,570 @@ +//! Reading and writing catalog properties against the live document. +//! +//! One planner serves every entry point. A UI control, and later an automation +//! tool, both hand a typed [`PropertyValue`] to [`PlotxApp::plan_property_write`] +//! and receive a single atomic [`crate::actions::Action`] plus an explicit list +//! of the targets that were skipped and why. + +use super::model::*; +use super::{contour, definition, permitted_variants}; +use crate::actions::Action; +use crate::automation::{ComponentRef, ResourceRef, TargetRef, canvas_object_ref}; +use crate::state::{ + CanvasId, DataBinding, Dataset, FieldCapabilities, FieldId, ObjectId, PlotxApp, + PresentationProfile, RequestedChart, SeriesId, default_contour_spec, default_encoding, + field_peak_magnitude, +}; +use plotx_figure::SeriesEncoding; + +/// A target resolved down to the one series it names, plus everything +/// applicability and defaults need. Indices here are one-shot lookup positions +/// and never leave this struct. +pub(super) struct SeriesContext<'a> { + canvas: usize, + object: ObjectId, + series: SeriesId, + pub(super) dataset: &'a Dataset, + pub(super) field: FieldId, + capabilities: FieldCapabilities, + pub(super) encoding: &'a SeriesEncoding, +} + +impl PlotxApp { + /// The address of one contour-style property on one series of a plot. + pub fn series_target( + &self, + canvas: usize, + object: ObjectId, + series: SeriesId, + ) -> Option { + let canvas_id = self.doc.canvases.get(canvas)?.resource_id; + Some(TargetRef { + resource: canvas_object_ref(canvas_id, object), + component: Some(ComponentRef::Series(series)), + }) + } + + /// Every series of one plot object, in binding order. + pub fn series_targets(&self, canvas: usize, object: ObjectId) -> Vec { + let Some(canvas_document) = self.doc.canvases.get(canvas) else { + return Vec::new(); + }; + let resource = canvas_object_ref(canvas_document.resource_id, object); + canvas_document + .object(object) + .and_then(|object| object.plot()) + .map(|plot| { + plot.binding + .series + .iter() + .map(|series| TargetRef { + resource: resource.clone(), + component: Some(ComponentRef::Series(series.id)), + }) + .collect() + }) + .unwrap_or_default() + } + + /// Read one property against one target. + pub fn resolve_property( + &self, + address: &PropertyAddress, + ) -> Result { + let definition = self.definition_for(address.definition)?; + let context = self.series_context(&address.target, definition)?; + let SeriesEncoding::Contour(spec) = context.encoding else { + return Err(not_applicable_encoding(definition, context.encoding)); + }; + let value = contour::read(definition.id, spec) + .ok_or_else(|| PropertyError::UnknownProperty(definition.id.as_str().to_owned()))?; + let default_value = match definition.default_policy { + DefaultPolicy::None => None, + DefaultPolicy::Fixed(value) => Some(value), + DefaultPolicy::EncodingFactory => self.factory_default(definition.id, &context), + }; + let availability = match definition.access { + PropertyAccess::ReadOnly => Availability::ReadOnly, + PropertyAccess::ReadWrite => Availability::Editable, + }; + Ok(ResolvedProperty { + address: address.clone(), + value, + default_value, + availability, + schema: contour::resolved_schema(definition.id, spec) + .unwrap_or_else(|| resolved_schema(definition, &context.capabilities)), + }) + } + + /// Read one property across a selection, reporting both the aggregate value + /// and every target the property does not apply to. + pub fn resolve_property_set( + &self, + property: PropertyId, + targets: &[TargetRef], + ) -> ResolvedPropertySet { + let mut applicable_targets = Vec::new(); + let mut skipped_targets = Vec::new(); + // Each target already answers with an aggregate over its own copies of + // the setting, so folding the selection in with the same operation makes + // an asymmetric ladder inside one target and a disagreement between two + // targets compose rather than mask each other. + let mut value = AggregateValue::Unavailable; + for target in targets { + let address = PropertyAddress::new(target.clone(), property); + match self.resolve_property(&address) { + Ok(resolved) => { + value = value.merge(resolved.value); + applicable_targets.push(address); + } + Err(error) => skipped_targets.push((target.clone(), error.to_string())), + } + } + ResolvedPropertySet { + applicable_targets, + skipped_targets, + value, + } + } + + /// Validate a write against every applicable target and fold it into one + /// atomic action. Nothing is executed here, and a single validation failure + /// aborts the whole commit rather than landing on part of the selection. + pub fn plan_property_write( + &self, + property: PropertyId, + targets: &[TargetRef], + value: &PropertyValue, + ) -> Result { + let definition = self.definition_for(property)?; + if definition.access == PropertyAccess::ReadOnly { + return Err(PropertyError::ReadOnly(property)); + } + self.plan(definition, targets, &mut |spec, context| { + let permitted = permitted_variants(&definition.value_schema, &context.capabilities); + if let PropertyValue::Enum(choice) = value + && !permitted.iter().any(|variant| variant.id == *choice) + { + return Err(PropertyError::InvalidValue { + property, + message: format!("'{choice}' needs a capability this field does not expose",), + }); + } + contour::write(property, spec, value, &|| { + field_peak_magnitude(context.dataset, context.field) + }) + }) + } + + /// Move one property one step along its own scale, from whatever each + /// target currently holds. + /// + /// This is the entry point for direct-manipulation gestures. It does not + /// compute a value somewhere else and hand it over: the gesture names a + /// direction, and the step is taken inside the same planner, against the + /// same working copy, and validated by the same rules as a value typed into + /// the panel. A gesture therefore cannot become a second source of state, + /// and cannot reach a value the panel would have rejected. + pub fn plan_property_step( + &self, + property: PropertyId, + targets: &[TargetRef], + step: PropertyStep, + ) -> Result { + let definition = self.definition_for(property)?; + if definition.access == PropertyAccess::ReadOnly { + return Err(PropertyError::ReadOnly(property)); + } + self.plan(definition, targets, &mut |spec, _context| { + contour::step(property, spec, step) + }) + } + + /// Reset one property by re-deriving it from the default policy in each + /// target's current context. + pub fn plan_property_reset( + &self, + property: PropertyId, + targets: &[TargetRef], + ) -> Result { + let definition = self.definition_for(property)?; + if definition.access == PropertyAccess::ReadOnly { + return Err(PropertyError::ReadOnly(property)); + } + self.plan(definition, targets, &mut |spec, context| { + let value = match definition.default_policy { + DefaultPolicy::Fixed(value) => value, + DefaultPolicy::None => return Err(PropertyError::ReadOnly(property)), + DefaultPolicy::EncodingFactory => { + self.factory_default(property, context) + .ok_or(PropertyError::InvalidValue { + property, + message: "the default factory has no single value for this setting" + .to_owned(), + })? + } + }; + contour::write(property, spec, &value, &|| { + field_peak_magnitude(context.dataset, context.field) + }) + }) + } + + /// Reset a whole encoding by calling the default factory again, replacing it + /// with a freshly materialized concrete encoding. + /// + /// The caller names *which* encoding it is resetting, and a target drawn as + /// anything else is skipped with a reason rather than rebuilt. A property + /// write gets that filter for free — a control belongs to a definition, and + /// the definition's applicability skips whatever it does not describe — but + /// an encoding reset has no property to compare against, so the scope has to + /// be part of the request. Without it, a reset offered inside one encoding's + /// section reaches every series of the object: the heatmap underneath a + /// contour would be silently rebuilt from defaults by a button that names + /// only the contour, and reported back to the user as a contour. + pub fn plan_encoding_reset( + &self, + encoding: EncodingKind, + targets: &[TargetRef], + ) -> Result { + let mut plan = BindingPlan::default(); + let mut skipped = Vec::new(); + let mut applied = Vec::new(); + for target in targets { + let context = match self.series_context_unchecked(target) { + Ok(context) => context, + Err(error) => { + skipped.push((target.clone(), error.to_string())); + continue; + } + }; + let actual = EncodingKind::of(context.encoding); + if actual != encoding { + skipped.push(( + target.clone(), + format!( + "this reset rebuilds a {} and this series is drawn as a {}", + encoding.as_str(), + actual.as_str() + ), + )); + continue; + } + let Some(descriptor) = context.dataset.field_descriptor(context.field) else { + skipped.push((target.clone(), "the source field is gone".to_owned())); + continue; + }; + let requested = match context.encoding { + SeriesEncoding::Line(_) => RequestedChart::Line, + SeriesEncoding::Contour(_) => RequestedChart::Contour, + SeriesEncoding::Heatmap(_) => RequestedChart::Heatmap, + SeriesEncoding::Image(_) => RequestedChart::Image, + }; + let encoding = default_encoding( + &descriptor.capabilities, + &descriptor.metadata, + requested, + &PresentationProfile::default(), + &|| field_peak_magnitude(context.dataset, context.field), + ); + let binding = plan.entry(self, context.canvas, context.object)?; + let Some(series) = binding + .series + .iter_mut() + .find(|series| series.id == context.series) + else { + skipped.push((target.clone(), "the series is gone".to_owned())); + continue; + }; + series.encoding = encoding; + applied.push(PropertyAddress::new(target.clone(), PropertyId("encoding"))); + } + Ok(PropertyCommit { + action: plan.into_action(), + applied, + skipped, + }) + } + + /// Execute a validated commit and report what was skipped. Returns the + /// number of targets actually changed. + pub fn commit_property(&mut self, commit: PropertyCommit) -> usize { + let applied = commit.applied.len(); + self.execute_action(commit.action); + applied + } + + fn definition_for( + &self, + property: PropertyId, + ) -> Result<&'static PropertyDefinition, PropertyError> { + definition(property) + .ok_or_else(|| PropertyError::UnknownProperty(property.as_str().to_owned())) + } + + /// Shared planning body: resolve, validate, mutate a working copy, and fold + /// every touched object into one action. + fn plan( + &self, + definition: &'static PropertyDefinition, + targets: &[TargetRef], + edit: &mut dyn FnMut( + &mut plotx_figure::ContourSpec, + &SeriesContext<'_>, + ) -> Result<(), PropertyError>, + ) -> Result { + let mut plan = BindingPlan::default(); + let mut skipped = Vec::new(); + let mut applied = Vec::new(); + for target in targets { + let context = match self.series_context(target, definition) { + Ok(context) => context, + Err(error) => { + skipped.push((target.clone(), error.to_string())); + continue; + } + }; + if !matches!(context.encoding, SeriesEncoding::Contour(_)) { + skipped.push(( + target.clone(), + not_applicable_encoding(definition, context.encoding).to_string(), + )); + continue; + } + let binding = plan.entry(self, context.canvas, context.object)?; + let Some(series) = binding + .series + .iter_mut() + .find(|series| series.id == context.series) + else { + skipped.push((target.clone(), "the series is gone".to_owned())); + continue; + }; + let SeriesEncoding::Contour(spec) = &mut series.encoding else { + skipped.push(( + target.clone(), + "the series is no longer a contour".to_owned(), + )); + continue; + }; + // A failure here aborts the whole commit: the working copies are + // dropped and no action is ever built, so nothing lands partially. + edit(spec, &context)?; + applied.push(PropertyAddress::new(target.clone(), definition.id)); + } + Ok(PropertyCommit { + action: plan.into_action(), + applied, + skipped, + }) + } + + /// What the default policy resolves to for this property in the target's + /// current context. + /// + /// The context is the target as it stands now, not only the field it draws: + /// a setting whose meaning depends on another setting the user has changed + /// is re-derived under that change (§8.1). The factory writes one ladder to + /// both halves, so its answer is always `Uniform`; `None` reports the ways + /// that can fail to hold — a target that is no longer a contour, an id the + /// contour reader does not know, or a factory that grew an asymmetric + /// default — instead of inventing a value to reset to. + fn factory_default( + &self, + property: PropertyId, + context: &SeriesContext<'_>, + ) -> Option { + let SeriesEncoding::Contour(current) = context.encoding else { + return None; + }; + let defaults = self.default_contour_spec_for(context); + contour::default_for(property, &defaults, current, &|| { + field_peak_magnitude(context.dataset, context.field) + })? + .uniform() + .copied() + } + + fn default_contour_spec_for(&self, context: &SeriesContext<'_>) -> plotx_figure::ContourSpec { + default_contour_spec(&context.capabilities, &|| { + field_peak_magnitude(context.dataset, context.field) + }) + } + + /// Resolve a target and enforce the definition's applicability. The + /// component shape is checked before any domain lookup happens, so a + /// misaddressed property is rejected rather than reaching plot code. + fn series_context( + &self, + target: &TargetRef, + definition: &'static PropertyDefinition, + ) -> Result, PropertyError> { + let actual = ComponentKind::of(target.component.as_ref()); + if actual != definition.applicability.component { + return Err(PropertyError::ComponentKind { + property: definition.id, + expected: definition.applicability.component.as_str(), + actual: actual.as_str(), + }); + } + let context = self.series_context_unchecked(target)?; + let missing = definition + .applicability + .required_capabilities + .iter() + .find(|capability| !context.capabilities.contains(capability)); + if let Some(missing) = missing { + return Err(PropertyError::NotApplicable(format!( + "{} needs a field that exposes {missing}", + definition.canonical_label + ))); + } + if let Some(expected) = definition.applicability.encoding + && EncodingKind::of(context.encoding) != expected + { + return Err(not_applicable_encoding(definition, context.encoding)); + } + Ok(context) + } + + /// Address resolution only: object, series, source field and capabilities. + pub(super) fn series_context_unchecked( + &self, + target: &TargetRef, + ) -> Result, PropertyError> { + let Some(ComponentRef::Series(series)) = target.component else { + return Err(PropertyError::ComponentKind { + property: PropertyId("series"), + expected: ComponentKind::Series.as_str(), + actual: ComponentKind::of(target.component.as_ref()).as_str(), + }); + }; + let (canvas, object) = self.canvas_object(&target.resource)?; + let plot = self.doc.canvases[canvas] + .object(object) + .and_then(|object| object.plot()) + .ok_or_else(|| PropertyError::UnknownTarget(target.resource.id.clone()))?; + let binding = plot + .binding + .series + .iter() + .find(|binding| binding.id == series) + .ok_or_else(|| { + PropertyError::UnknownTarget(format!("{}/series/{series}", target.resource.id)) + })?; + let dataset = self + .doc + .dataset_by_id(binding.source.resource) + .ok_or_else(|| PropertyError::UnknownTarget(binding.source.resource.to_string()))?; + let capabilities = dataset + .field_descriptor(binding.source.field) + .map(|descriptor| descriptor.capabilities) + .unwrap_or_default(); + Ok(SeriesContext { + canvas, + object, + series, + dataset, + field: binding.source.field, + capabilities, + encoding: &binding.encoding, + }) + } + + /// Turn a canvas-object resource reference into a one-shot lookup position. + fn canvas_object(&self, resource: &ResourceRef) -> Result<(usize, ObjectId), PropertyError> { + let unknown = || PropertyError::UnknownTarget(resource.id.clone()); + if resource.kind.0 != crate::automation::KIND_CANVAS_OBJECT { + return Err(PropertyError::NotApplicable(format!( + "expected a plot object, got {}", + resource.kind.0 + ))); + } + let parent = resource.parent_id.as_deref().ok_or_else(unknown)?; + let local = resource.local_id.as_deref().ok_or_else(unknown)?; + let canvas_id: CanvasId = parent.parse().map_err(|_| unknown())?; + let object: ObjectId = local.parse().map_err(|_| unknown())?; + let canvas = self.doc.canvas_index(canvas_id).ok_or_else(unknown)?; + Ok((canvas, object)) + } +} + +/// Working copies of every plot binding a commit touches, so two series of the +/// same object collapse into one action rather than two whose snapshots would +/// overwrite each other. +#[derive(Default)] +struct BindingPlan { + entries: Vec<(usize, ObjectId, DataBinding, DataBinding)>, +} + +impl BindingPlan { + fn entry( + &mut self, + app: &PlotxApp, + canvas: usize, + object: ObjectId, + ) -> Result<&mut DataBinding, PropertyError> { + if let Some(index) = self + .entries + .iter() + .position(|entry| entry.0 == canvas && entry.1 == object) + { + return Ok(&mut self.entries[index].3); + } + let binding = app + .doc + .canvases + .get(canvas) + .and_then(|canvas| canvas.object(object)) + .and_then(|object| object.plot()) + .map(|plot| plot.binding.clone()) + .ok_or_else(|| PropertyError::UnknownTarget(object.to_string()))?; + self.entries + .push((canvas, object, binding.clone(), binding)); + let last = self.entries.len() - 1; + Ok(&mut self.entries[last].3) + } + + fn into_action(self) -> Action { + Action::Composite( + self.entries + .into_iter() + .filter(|(_, _, before, after)| before != after) + .map(|(canvas, object, before, after)| { + Action::set_data_binding(canvas, object, before, after) + }) + .collect(), + ) + } +} + +fn not_applicable_encoding( + definition: &'static PropertyDefinition, + encoding: &SeriesEncoding, +) -> PropertyError { + PropertyError::NotApplicable(format!( + "{} applies to a contour, and this series is drawn as a {}", + definition.canonical_label, + EncodingKind::of(encoding).as_str() + )) +} + +fn resolved_schema( + definition: &'static PropertyDefinition, + capabilities: &FieldCapabilities, +) -> ResolvedSchema { + match definition.value_schema { + ValueSchema::Bool => ResolvedSchema::Bool, + ValueSchema::Int { min, max } => ResolvedSchema::Int { min, max }, + ValueSchema::Float { bounds, log } => ResolvedSchema::Float { + bounds, + log, + unit: "", + }, + ValueSchema::Enum { .. } => ResolvedSchema::Enum { + variants: permitted_variants(&definition.value_schema, capabilities), + }, + ValueSchema::Color => ResolvedSchema::Color, + } +} diff --git a/crates/core/src/properties/step_tests.rs b/crates/core/src/properties/step_tests.rs new file mode 100644 index 0000000..9fc58f9 --- /dev/null +++ b/crates/core/src/properties/step_tests.rs @@ -0,0 +1,286 @@ +//! The canvas gesture (§8.5 channel 3) and the shared-ladder marker. +//! +//! The gesture is the case §1 principle 1 exists for: a second *entry point* +//! is fine, a second *source of state* is not. These tests pin that the step +//! reaches the domain model only through the planner, obeys the same validation +//! a typed value meets, and produces exactly the action a typed value would. + +use super::tests::{contour_app, contour_spec}; +use super::*; +use crate::automation::{ComponentRef, TargetRef}; +use crate::state::{ + CONTOUR_BASE_ABSOLUTE, CONTOUR_BASE_NOISE_FLOOR, ObjectId, PlotxApp, contour_base_kind, +}; +use plotx_figure::{ContourBasePolicy, PositiveFiniteF64, SeriesEncoding}; + +fn object_of(target: &TargetRef) -> ObjectId { + target + .resource + .local_id + .as_deref() + .expect("a canvas-object target names its object") + .parse() + .expect("object ids round-trip through their string form") +} + +/// Reach into the document and give the negative half a ladder of its own, the +/// state a symmetric write can never produce but a project file can hold. +fn desynchronize_halves(app: &mut PlotxApp, target: &TargetRef) { + let Some(ComponentRef::Series(series)) = target.component else { + panic!("the fixture addresses a series"); + }; + let binding = &mut app.doc.canvases[0] + .object_mut(object_of(target)) + .and_then(|object| object.plot_mut()) + .expect("the fixture holds a plot") + .binding; + let encoding = &mut binding + .series + .iter_mut() + .find(|candidate| candidate.id == series) + .expect("the series exists") + .encoding; + let SeriesEncoding::Contour(spec) = encoding else { + panic!("the fixture draws a contour"); + }; + let negative = spec + .negative + .as_mut() + .expect("a signed field has both halves"); + negative.base = ContourBasePolicy::Absolute(PositiveFiniteF64::new(3.0).unwrap()); + negative.count = spec.positive.count + 1; + negative.ratio = PositiveFiniteF64::new(spec.positive.ratio.get() + 0.2).unwrap(); +} + +/// The step is geometric, by the ladder's own ratio, and it lands on both +/// halves because base, count and ratio describe one shared ladder. +#[test] +fn a_step_moves_the_base_by_the_ladders_own_ratio() { + let (mut app, target) = contour_app(); + let before = contour_spec(&app, &target); + let ratio = before.positive.ratio.get(); + let magnitude = match &before.positive.base { + ContourBasePolicy::NoiseFloor { multiplier, .. } => multiplier.get(), + other => panic!("the fixture defaults to a noise-anchored base, got {other:?}"), + }; + + let commit = app + .plan_property_step( + contour::BASE_MAGNITUDE, + std::slice::from_ref(&target), + PropertyStep::Raise, + ) + .expect("a contour series can be stepped"); + app.commit_property(commit); + + let raised = contour_spec(&app, &target); + let stepped = |base: &ContourBasePolicy| match base { + ContourBasePolicy::NoiseFloor { multiplier, .. } => multiplier.get(), + other => panic!("stepping must not change the anchor, got {other:?}"), + }; + assert!((stepped(&raised.positive.base) - magnitude * ratio).abs() < 1.0e-9); + assert!( + (stepped(&raised.negative.as_ref().expect("signed").base) - magnitude * ratio).abs() + < 1.0e-9, + "the shared ladder is written to every half that exists" + ); + + let commit = app + .plan_property_step( + contour::BASE_MAGNITUDE, + std::slice::from_ref(&target), + PropertyStep::Lower, + ) + .expect("a contour series can be stepped"); + app.commit_property(commit); + assert!((stepped(&contour_spec(&app, &target).positive.base) - magnitude).abs() < 1.0e-9); +} + +/// §1 principle 1. The gesture is an entry point, not a state source: stepping +/// leaves the document in exactly the state typing the same number would. +#[test] +fn a_step_lands_where_the_typed_value_would() { + let (mut gestured, target) = contour_app(); + let (mut typed, typed_target) = contour_app(); + let spec = contour_spec(&gestured, &target); + let ratio = spec.positive.ratio.get(); + let magnitude = match &spec.positive.base { + ContourBasePolicy::NoiseFloor { multiplier, .. } => multiplier.get(), + other => panic!("unexpected default base {other:?}"), + }; + + let commit = gestured + .plan_property_step( + contour::BASE_MAGNITUDE, + std::slice::from_ref(&target), + PropertyStep::Raise, + ) + .expect("the gesture plans"); + assert_eq!(gestured.commit_property(commit), 1); + + let commit = typed + .plan_property_write( + contour::BASE_MAGNITUDE, + std::slice::from_ref(&typed_target), + &PropertyValue::Float(magnitude * ratio), + ) + .expect("the control plans"); + assert_eq!(typed.commit_property(commit), 1); + + assert_eq!( + contour_spec(&gestured, &target), + contour_spec(&typed, &typed_target), + "one planner, one validation, one action — whichever entry point was used" + ); +} + +/// A gesture must not be able to reach a value the panel would reject. The +/// multiplier ceiling is the same one a typed value meets, and running into it +/// is reported rather than silently doing nothing. +#[test] +fn a_step_past_the_ceiling_is_refused_with_a_reason() { + let (mut app, target) = contour_app(); + let commit = app + .plan_property_write( + contour::BASE_MAGNITUDE, + std::slice::from_ref(&target), + &PropertyValue::Float(1.0e4), + ) + .expect("the ceiling itself is a legal multiplier"); + app.commit_property(commit); + + let error = app + .plan_property_step( + contour::BASE_MAGNITUDE, + std::slice::from_ref(&target), + PropertyStep::Raise, + ) + .expect_err("a step past the ceiling cannot land"); + assert!( + matches!(error, PropertyError::InvalidValue { .. }), + "got {error:?}" + ); + assert!(error.to_string().contains("highest value")); +} + +/// The gesture is declared per property. Asking for a step on one that has no +/// direction is refused, rather than being silently mapped onto some other +/// setting. +#[test] +fn a_property_with_no_step_gesture_refuses_one() { + let (app, target) = contour_app(); + for property in [contour::COUNT, contour::POSITIVE_COLOR] { + let error = app + .plan_property_step(property, std::slice::from_ref(&target), PropertyStep::Raise) + .expect_err("only a declared step gesture may be taken"); + assert!( + matches!(error, PropertyError::InvalidValue { .. }), + "{property} produced {error:?}" + ); + } +} + +/// A gesture is still an ordinary write: it steps from what the *positive* half +/// holds and then makes both halves agree, exactly as a typed value does, so an +/// asymmetric ladder is resolved rather than stepped twice over. +#[test] +fn a_step_on_an_asymmetric_ladder_makes_the_halves_agree() { + let (mut app, target) = contour_app(); + desynchronize_halves(&mut app, &target); + let before = contour_spec(&app, &target); + assert_ne!( + contour_base_kind(&before.positive.base), + contour_base_kind(&before.negative.as_ref().unwrap().base), + "the fixture is deliberately asymmetric" + ); + + let commit = app + .plan_property_step( + contour::BASE_MAGNITUDE, + std::slice::from_ref(&target), + PropertyStep::Lower, + ) + .expect("an asymmetric ladder can still be stepped"); + app.commit_property(commit); + + let after = contour_spec(&app, &target); + let negative = after.negative.as_ref().expect("signed"); + assert_eq!( + contour_base_kind(&after.positive.base), + CONTOUR_BASE_NOISE_FLOOR + ); + assert_eq!(contour_base_kind(&negative.base), CONTOUR_BASE_NOISE_FLOOR); + assert_eq!( + contour::read(contour::BASE_MAGNITUDE, &after), + Some(AggregateValue::Uniform(PropertyValue::Float( + match &after.positive.base { + ContourBasePolicy::NoiseFloor { multiplier, .. } => multiplier.get(), + other => panic!("unexpected base {other:?}"), + } + ))) + ); +} + +/// The marker phase 5a called for, checked against what the reader actually +/// does rather than trusted as a comment: a definition that says its value is +/// held once per mirrored half must be the one that can report `Mixed` from a +/// single target, and a definition that says otherwise must not. +#[test] +fn the_shared_marker_matches_what_a_single_target_can_disagree_about() { + let (mut app, target) = contour_app(); + desynchronize_halves(&mut app, &target); + + for definition in catalog() { + let address = PropertyAddress::new(target.clone(), definition.id); + let Ok(resolved) = app.resolve_property(&address) else { + continue; + }; + let mixed = matches!(resolved.value, AggregateValue::Mixed); + match definition.copies { + ValueCopies::PerMirroredHalf => assert!( + mixed, + "{} is declared as held once per half, so two different halves \ + must read as Mixed", + definition.id + ), + ValueCopies::PerTarget => assert!( + !mixed, + "{} is declared as held once per target, so it cannot disagree \ + with itself", + definition.id + ), + } + } +} + +/// A ceiling belongs to the anchor, not to the gesture. An absolute level is +/// bounded by the field rather than by the catalog, so it steps as far as the +/// user wants — the refusal above is a property of the multiplier, not of +/// stepping. +#[test] +fn an_absolute_anchor_has_no_catalog_ceiling_to_run_into() { + let (mut app, target) = contour_app(); + let commit = app + .plan_property_write( + contour::BASE_POLICY, + std::slice::from_ref(&target), + &PropertyValue::Enum(CONTOUR_BASE_ABSOLUTE), + ) + .expect("an absolute base needs no capability"); + app.commit_property(commit); + + for _ in 0..12 { + let commit = app + .plan_property_step( + contour::BASE_MAGNITUDE, + std::slice::from_ref(&target), + PropertyStep::Raise, + ) + .expect("an absolute level is bounded by the field, not by the catalog"); + app.commit_property(commit); + } + assert_eq!( + contour_base_kind(&contour_spec(&app, &target).positive.base), + CONTOUR_BASE_ABSOLUTE + ); +} diff --git a/crates/core/src/properties/tests.rs b/crates/core/src/properties/tests.rs new file mode 100644 index 0000000..0030297 --- /dev/null +++ b/crates/core/src/properties/tests.rs @@ -0,0 +1,635 @@ +use super::*; +use crate::actions::Action; +use crate::automation::{ + CAP_FIELD_NOISE_SCALE, CAP_FIELD_SIGNED, CapabilityId, ComponentRef, KIND_FIELD, ResourceRef, + TargetRef, +}; +use crate::state::{ + CONTOUR_BASE_FRACTION_OF_RANGE, CONTOUR_BASE_NOISE_FLOOR, CanvasDocument, Dataset, + Nmr2DDataset, ObjectFrame, PlotxApp, SeriesBinding, SeriesId, +}; + +/// The default plane: values running -7..8, so its noise estimate is an +/// ordinary fraction of its peak and no contour floor is ever reached. +fn default_plane() -> Vec { + (0..16).map(|value| f64::from(value) - 7.0).collect() +} + +fn nmr2d_with(source: &str, values: &[f64]) -> plotx_io::NmrData2D { + let dimension = |nucleus: &str| plotx_io::Dim { + spectral_width_hz: 4_000.0, + observe_freq_mhz: 400.0, + carrier_ppm: 0.0, + nucleus: nucleus.to_owned(), + group_delay: 0.0, + }; + plotx_io::NmrData2D { + data: values + .iter() + .map(|value| num_complex::Complex64::new(*value, 0.5)) + .collect(), + rows: 4, + cols: 4, + domain: plotx_io::Domain::Frequency, + direct: dimension("1H"), + indirect: dimension("13C"), + quad: plotx_io::QuadMode::Complex, + indirect_conjugate: false, + experiment: None, + pseudo_axis: None, + diffusion: None, + nus: None, + source: source.to_owned(), + } +} + +/// One page holding one plot bound to a true-2D spectrum, i.e. the exact shape +/// the driving case has: a contour drawn from a signed scalar grid. +pub(super) fn contour_app() -> (PlotxApp, TargetRef) { + contour_app_with_plane(&default_plane()) +} + +/// The same page over a plane the caller chooses, so a test can put a field of +/// a given dynamic range in front of the catalog. +pub(super) fn contour_app_with_plane(values: &[f64]) -> (PlotxApp, TargetRef) { + let mut app = PlotxApp::new(); + app.doc + .datasets + .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load(nmr2d_with( + "contour", values, + ))))); + let mut canvas = CanvasDocument::new("page".to_owned(), [120.0, 80.0]); + let id = canvas.allocate_object_id(); + let object = app.build_plot_object( + 0, + ObjectFrame::new(0.0, 0.0, 100.0, 80.0), + id, + "Plot".into(), + ); + canvas.objects.push(object); + app.doc.canvases.push(canvas); + app.session.active_canvas = Some(0); + let series = app.doc.canvases[0] + .object(id) + .and_then(|object| object.plot()) + .and_then(|plot| plot.binding.series.first()) + .map(|series| series.id) + .expect("the plot has a series"); + let target = app.series_target(0, id, series).expect("target resolves"); + (app, target) +} + +pub(super) fn contour_spec(app: &PlotxApp, target: &TargetRef) -> plotx_figure::ContourSpec { + let Some(ComponentRef::Series(series)) = target.component else { + panic!("the fixture addresses a series"); + }; + let binding = &app.doc.canvases[0] + .object( + target + .resource + .local_id + .as_deref() + .unwrap() + .parse() + .unwrap(), + ) + .and_then(|object| object.plot()) + .expect("plot") + .binding; + match &binding + .series + .iter() + .find(|candidate| candidate.id == series) + .expect("series") + .encoding + { + plotx_figure::SeriesEncoding::Contour(spec) => spec.clone(), + other => panic!("expected a contour, got {other:?}"), + } +} + +#[test] +fn the_fixture_draws_a_contour() { + let (app, target) = contour_app(); + let address = PropertyAddress::new(target.clone(), contour::BASE_MAGNITUDE); + let resolved = app.resolve_property(&address).expect("contour resolves"); + assert_eq!(resolved.availability, Availability::Editable); +} + +/// Two definitions sharing an id would make every lookup, every search hit and +/// every reset ambiguous. +#[test] +fn stable_property_ids_are_unique() { + let mut ids: Vec<&str> = catalog() + .iter() + .map(|definition| definition.id.as_str()) + .collect(); + let total = ids.len(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), total, "catalog ids must be unique"); +} + +/// Every entry must be reachable: a definition nothing can address is dead +/// weight that still costs a panel row and a search hit. +#[test] +fn every_definition_declares_an_addressable_shape() { + for definition in catalog() { + assert!( + !definition.canonical_label.is_empty(), + "{} has no canonical label", + definition.id + ); + if definition.access == PropertyAccess::ReadOnly { + assert!( + matches!(definition.default_policy, DefaultPolicy::None), + "{} is read-only and cannot have a default to reset to", + definition.id + ); + } + } +} + +/// The addressing rule the whole design turns on. A contour setting belongs to +/// the series that draws it, so its address is the plot object plus a +/// `Series(SeriesId)` component — never the dataset, never the field child +/// resource the series happens to read, and never a bare object. +#[test] +fn a_contour_property_is_addressed_by_series_and_nothing_else() { + let (app, target) = contour_app(); + assert!(matches!(target.component, Some(ComponentRef::Series(_)))); + assert_eq!( + target.resource.kind.0, + crate::automation::KIND_CANVAS_OBJECT + ); + + let resolved = app + .resolve_property(&PropertyAddress::new(target.clone(), contour::COUNT)) + .expect("a series component resolves"); + assert!(matches!( + resolved.value, + AggregateValue::Uniform(PropertyValue::Int(_)) + )); + + // The same object without a component names no series at all. + let bare = TargetRef::resource(target.resource.clone()); + let error = app + .resolve_property(&PropertyAddress::new(bare, contour::COUNT)) + .expect_err("a bare object is not a contour target"); + assert!(matches!(error, PropertyError::ComponentKind { .. })); + + // The dataset that owns the values is not the owner of the setting. + let dataset = TargetRef { + resource: ResourceRef::from(app.doc.datasets[0].resource_id()), + component: target.component, + }; + let error = app + .resolve_property(&PropertyAddress::new(dataset, contour::COUNT)) + .expect_err("a dataset is not a contour target"); + assert!(matches!(error, PropertyError::NotApplicable(_))); + + // Neither is the field child resource the series reads from: fields carry + // their own stats and provenance properties, addressed with no component. + let field = TargetRef { + resource: ResourceRef { + id: format!("{}/nmr.real", app.doc.datasets[0].resource_id()), + kind: crate::automation::ResourceKindId::new(KIND_FIELD), + parent_id: Some(app.doc.datasets[0].resource_id().to_string()), + local_id: Some("nmr.real".to_owned()), + }, + component: target.component, + }; + let error = app + .resolve_property(&PropertyAddress::new(field, contour::COUNT)) + .expect_err("a field child resource is not a contour target"); + assert!(matches!(error, PropertyError::NotApplicable(_))); +} + +/// Applicability is decided from the definition before the target is looked up +/// at all, so a misaddressed property never reaches plot code. Pinned with a +/// resource that does not exist: the component-kind rejection must still win +/// over "no such target", which is only true if it happens first. +#[test] +fn the_component_shape_is_rejected_before_any_document_lookup() { + let (app, target) = contour_app(); + let nowhere = TargetRef { + resource: ResourceRef { + id: "00000000-0000-0000-0000-000000000000/999".to_owned(), + kind: crate::automation::ResourceKindId::new(crate::automation::KIND_CANVAS_OBJECT), + parent_id: Some("00000000-0000-0000-0000-000000000000".to_owned()), + local_id: Some("999".to_owned()), + }, + component: Some(ComponentRef::ProcessingStep(plotx_processing::StepId::new( + 0, + ))), + }; + let error = app + .resolve_property(&PropertyAddress::new(nowhere, contour::COUNT)) + .expect_err("a processing step does not own contour levels"); + match error { + PropertyError::ComponentKind { + property, + expected, + actual, + } => { + assert_eq!(property, contour::COUNT, "the real property is named"); + assert_eq!(expected, "series"); + assert_eq!(actual, "processing_step"); + } + other => panic!("expected the definition's own component gate, got {other:?}"), + } + // The same address with the right component shape does get as far as the + // document, proving the rejection above was the component gate and not an + // accident of the bogus id. + assert!(matches!( + app.resolve_property(&PropertyAddress::new( + TargetRef { + resource: ResourceRef { + id: "00000000-0000-0000-0000-000000000000/999".to_owned(), + kind: crate::automation::ResourceKindId::new( + crate::automation::KIND_CANVAS_OBJECT + ), + parent_id: Some("00000000-0000-0000-0000-000000000000".to_owned()), + local_id: Some("999".to_owned()), + }, + component: target.component, + }, + contour::COUNT, + )), + Err(PropertyError::UnknownTarget(_)) + )); +} + +/// A panel edit must reach the document as the ordinary typed binding action, +/// so it undoes, redoes and rebuilds like every other binding change. +#[test] +fn an_edit_compiles_into_a_typed_binding_action() { + let (mut app, target) = contour_app(); + let before = contour_spec(&app, &target); + let commit = app + .plan_property_write( + contour::COUNT, + std::slice::from_ref(&target), + &PropertyValue::Int(7), + ) + .expect("count is writable"); + assert_eq!(commit.applied.len(), 1); + assert!(commit.skipped.is_empty()); + let Action::Composite(actions) = &commit.action else { + panic!("a commit is always one atomic composite"); + }; + assert_eq!(actions.len(), 1); + assert!(matches!(actions[0], Action::SetDataBinding { .. })); + + app.commit_property(commit); + let after = contour_spec(&app, &target); + assert_eq!(before.positive.count, 14); + assert_eq!(after.positive.count, 7); + assert_eq!( + after.negative.as_ref().map(|half| half.count), + Some(7), + "the mirrored half follows the shared ladder" + ); +} + +/// Two series of one object must fold into a single action. Two actions built +/// from the same pre-edit snapshot would make the second overwrite the first. +#[test] +fn several_series_of_one_object_fold_into_one_action() { + let (mut app, first) = contour_app(); + let object: crate::state::ObjectId = + first.resource.local_id.as_deref().unwrap().parse().unwrap(); + let second_id = { + let plot = app.doc.canvases[0] + .object_mut(object) + .and_then(|object| object.plot_mut()) + .expect("plot"); + let id = plot.allocate_series_id(); + let mut extra = plot.binding.series[0].clone(); + extra.id = id; + plot.binding.series.push(extra); + id + }; + let second = app.series_target(0, object, second_id).expect("target"); + + let commit = app + .plan_property_write( + contour::COUNT, + &[first.clone(), second.clone()], + &PropertyValue::Int(9), + ) + .expect("count is writable"); + assert_eq!(commit.applied.len(), 2); + let Action::Composite(actions) = &commit.action else { + panic!("expected a composite"); + }; + assert_eq!( + actions.len(), + 1, + "both series belong to one object and share one binding action" + ); + + app.commit_property(commit); + for target in [first, second] { + assert_eq!(contour_spec(&app, &target).positive.count, 9); + } +} + +/// A target the property does not apply to is reported with a reason. Silently +/// dropping it would leave the user believing the edit landed everywhere. +#[test] +fn an_inapplicable_target_is_reported_rather_than_ignored() { + let (mut app, target) = contour_app(); + let object: crate::state::ObjectId = target + .resource + .local_id + .as_deref() + .unwrap() + .parse() + .unwrap(); + let line_id = { + let plot = app.doc.canvases[0] + .object_mut(object) + .and_then(|object| object.plot_mut()) + .expect("plot"); + let id = plot.allocate_series_id(); + let mut extra = plot.binding.series[0].clone(); + extra.id = id; + extra.encoding = plotx_figure::SeriesEncoding::Line(plotx_figure::LineEncoding::default()); + plot.binding.series.push(extra); + id + }; + let line = app.series_target(0, object, line_id).expect("target"); + + let set = app.resolve_property_set(contour::COUNT, &[target.clone(), line.clone()]); + assert_eq!(set.applicable_targets.len(), 1); + assert_eq!(set.skipped_targets.len(), 1); + assert!( + set.skipped_targets[0].1.contains("contour"), + "the reason names the mismatch: {}", + set.skipped_targets[0].1 + ); + + let commit = app + .plan_property_write(contour::COUNT, &[target, line], &PropertyValue::Int(5)) + .expect("the compatible target still commits"); + assert_eq!(commit.applied.len(), 1); + assert_eq!(commit.skipped.len(), 1); +} + +/// A value one target rejects must abort the entire commit; a partially applied +/// multi-selection edit is exactly what the atomic composite exists to prevent. +#[test] +fn a_rejected_value_aborts_the_whole_commit() { + let (app, target) = contour_app(); + let before = contour_spec(&app, &target); + let error = app + .plan_property_write( + contour::COUNT, + std::slice::from_ref(&target), + &PropertyValue::Int(0), + ) + .expect_err("zero levels is not a ladder"); + assert!(matches!(error, PropertyError::InvalidValue { .. })); + assert_eq!( + contour_spec(&app, &target).positive.count, + before.positive.count, + "nothing may change when planning failed" + ); +} + +/// The base-policy gate is a capability gate, not a domain check: a signed field +/// is never offered a fraction of its value range, and a field with no noise +/// estimator is never offered a multiple of σ. +#[test] +fn base_policies_are_gated_by_field_capability() { + let signed = crate::state::FieldCapabilities::new([ + CapabilityId::new(crate::automation::CAP_FIELD_SCALAR_GRID_2D_REGULAR), + CapabilityId::new(CAP_FIELD_SIGNED), + CapabilityId::new(CAP_FIELD_NOISE_SCALE), + ]); + let schema = definition(contour::BASE_POLICY) + .expect("the policy property is registered") + .value_schema; + let offered: Vec<&str> = permitted_variants(&schema, &signed) + .into_iter() + .map(|variant| variant.id) + .collect(); + assert!(offered.contains(&CONTOUR_BASE_NOISE_FLOOR)); + assert!( + !offered.contains(&CONTOUR_BASE_FRACTION_OF_RANGE), + "a fraction of a range that straddles zero is not a threshold" + ); + + let bounded = crate::state::FieldCapabilities::new([ + CapabilityId::new(crate::automation::CAP_FIELD_SCALAR_GRID_2D_REGULAR), + CapabilityId::new(crate::automation::CAP_FIELD_BOUNDED), + ]); + let offered: Vec<&str> = permitted_variants(&schema, &bounded) + .into_iter() + .map(|variant| variant.id) + .collect(); + assert!(offered.contains(&CONTOUR_BASE_FRACTION_OF_RANGE)); + assert!(!offered.contains(&CONTOUR_BASE_NOISE_FLOOR)); +} + +/// The gate must also hold on the write path, not only in the control that +/// offers the choices. +#[test] +fn an_ungated_base_policy_is_refused_on_write() { + let (app, target) = contour_app(); + let error = app + .plan_property_write( + contour::BASE_POLICY, + std::slice::from_ref(&target), + &PropertyValue::Enum(CONTOUR_BASE_FRACTION_OF_RANGE), + ) + .expect_err("the signed NMR plane must not accept a range fraction"); + assert!(matches!(error, PropertyError::InvalidValue { .. })); +} + +/// Reset re-derives the value from the factory in the target's current context +/// rather than restoring a stored snapshot. +#[test] +fn reset_rederives_the_factory_default() { + let (mut app, target) = contour_app(); + let commit = app + .plan_property_write( + contour::BASE_MAGNITUDE, + std::slice::from_ref(&target), + &PropertyValue::Float(11.0), + ) + .expect("the multiplier is writable"); + app.commit_property(commit); + let address = PropertyAddress::new(target.clone(), contour::BASE_MAGNITUDE); + let resolved = app.resolve_property(&address).expect("resolves"); + assert!(resolved.is_modified()); + assert_eq!(resolved.default_value, Some(PropertyValue::Float(5.0))); + + let commit = app + .plan_property_reset(contour::BASE_MAGNITUDE, std::slice::from_ref(&target)) + .expect("reset plans"); + app.commit_property(commit); + let resolved = app.resolve_property(&address).expect("resolves"); + assert!(!resolved.is_modified()); + assert_eq!( + resolved.value, + AggregateValue::Uniform(PropertyValue::Float(5.0)) + ); +} + +/// Resetting a whole encoding goes back through the default factory, so it +/// yields a complete concrete encoding rather than a patched-up old one. +#[test] +fn resetting_an_encoding_calls_the_default_factory() { + let (mut app, target) = contour_app(); + let commit = app + .plan_property_write( + contour::NEGATIVE_ENABLED, + std::slice::from_ref(&target), + &PropertyValue::Bool(false), + ) + .expect("the negative half is writable on a signed field"); + app.commit_property(commit); + assert!(contour_spec(&app, &target).negative.is_none()); + + let commit = app + .plan_encoding_reset(EncodingKind::Contour, std::slice::from_ref(&target)) + .expect("encoding reset plans"); + app.commit_property(commit); + let spec = contour_spec(&app, &target); + assert!( + spec.negative.is_some(), + "the factory restores the negative half a signed field gets by default" + ); + assert_eq!(spec.positive.count, 14); +} + +/// Reading across a heterogeneous selection reports `Mixed` instead of picking +/// one target's value and pretending it speaks for all of them. +#[test] +fn a_heterogeneous_selection_reads_as_mixed() { + let (mut app, first) = contour_app(); + let object: crate::state::ObjectId = + first.resource.local_id.as_deref().unwrap().parse().unwrap(); + let second_id = { + let plot = app.doc.canvases[0] + .object_mut(object) + .and_then(|object| object.plot_mut()) + .expect("plot"); + let id = plot.allocate_series_id(); + let mut extra = plot.binding.series[0].clone(); + extra.id = id; + if let plotx_figure::SeriesEncoding::Contour(spec) = &mut extra.encoding { + spec.positive.count = 3; + } + plot.binding.series.push(extra); + id + }; + let second = app.series_target(0, object, second_id).expect("target"); + let set = app.resolve_property_set(contour::COUNT, &[first, second]); + assert_eq!(set.value, AggregateValue::Mixed); + + let (app, only) = contour_app(); + let set = app.resolve_property_set(contour::COUNT, std::slice::from_ref(&only)); + assert_eq!(set.value, AggregateValue::Uniform(PropertyValue::Int(14))); + let set = app.resolve_property_set(contour::COUNT, &[]); + assert_eq!(set.value, AggregateValue::Unavailable); +} + +/// A series binding whose id no longer exists must not resolve to a neighbour. +#[test] +fn an_unknown_series_does_not_resolve_to_another_one() { + let (app, target) = contour_app(); + let stale = TargetRef { + resource: target.resource.clone(), + component: Some(ComponentRef::Series(SeriesId::new(4_242))), + }; + let error = app + .resolve_property(&PropertyAddress::new(stale, contour::COUNT)) + .expect_err("a stale series id is not a target"); + assert!(matches!(error, PropertyError::UnknownTarget(_))); +} + +/// The catalog never grows a parallel value store: a definition describes, and +/// the value stays in the encoding. This pins the property that makes that true +/// — the resolved value always equals what the domain model holds. +#[test] +fn resolved_values_come_from_the_domain_model() { + let (mut app, target) = contour_app(); + let object: crate::state::ObjectId = target + .resource + .local_id + .as_deref() + .unwrap() + .parse() + .unwrap(); + if let Some(plotx_figure::SeriesEncoding::Contour(spec)) = app.doc.canvases[0] + .object_mut(object) + .and_then(|object| object.plot_mut()) + .and_then(|plot| plot.binding.series.first_mut()) + .map(|series| &mut series.encoding) + { + // Both halves, so this pins where the value comes from rather than + // re-testing what an asymmetric ladder reads as. + spec.positive.count = 21; + if let Some(negative) = spec.negative.as_mut() { + negative.count = 21; + } + } + let resolved = app + .resolve_property(&PropertyAddress::new(target, contour::COUNT)) + .expect("resolves"); + assert_eq!( + resolved.value, + AggregateValue::Uniform(PropertyValue::Int(21)) + ); +} + +/// `SeriesSource.field` says where the values come from; it is not the +/// component of a contour address. Two series of one object reading the very +/// same field must therefore still be told apart, and each keep its own levels. +#[test] +fn the_source_field_is_not_the_component() { + let (mut app, first) = contour_app(); + let object: crate::state::ObjectId = + first.resource.local_id.as_deref().unwrap().parse().unwrap(); + let second_id = { + let plot = app.doc.canvases[0] + .object_mut(object) + .and_then(|object| object.plot_mut()) + .expect("plot"); + let id = plot.allocate_series_id(); + let mut extra = plot.binding.series[0].clone(); + extra.id = id; + plot.binding.series.push(extra); + id + }; + let second = app.series_target(0, object, second_id).expect("target"); + let sources: Vec = app.doc.canvases[0] + .object(object) + .and_then(|object| object.plot()) + .map(|plot| { + plot.binding + .series + .iter() + .map(|series: &SeriesBinding| series.source.field) + .collect() + }) + .expect("plot"); + assert_eq!( + sources[0], sources[1], + "both series must read one field for this to prove anything" + ); + + let commit = app + .plan_property_write( + contour::COUNT, + std::slice::from_ref(&second), + &PropertyValue::Int(3), + ) + .expect("count is writable"); + app.commit_property(commit); + assert_eq!(contour_spec(&app, &first).positive.count, 14); + assert_eq!(contour_spec(&app, &second).positive.count, 3); +} diff --git a/crates/core/src/state/app_impl.rs b/crates/core/src/state/app_impl.rs index 3e54ccb..40f2561 100644 --- a/crates/core/src/state/app_impl.rs +++ b/crates/core/src/state/app_impl.rs @@ -394,6 +394,77 @@ impl PlotxApp { } } + /// Navigate to a page. + /// + /// Switching pages is never only a change of what is drawn. `ObjectId`s are + /// allocated per page and start again at one on each, so a selection left + /// over from the page being left resolves, on the page being entered, to an + /// unrelated object — and every inspector row, property write and tool that + /// follows would act on it. Activation therefore re-derives the selection + /// and the data focus from the page it enters, and every entry point that + /// switches pages comes through here rather than assigning `active_canvas` + /// and leaving the rest of the session pointing at the page it left. + pub fn activate_canvas(&mut self, ci: usize) { + let Some(canvas) = self.doc.canvases.get(ci) else { + return; + }; + let lead = canvas.active_dataset(); + self.session.active_canvas = Some(ci); + let lead = lead.and_then(|id| self.doc.dataset_index(id)); + let datasets = self.doc.page_dataset_indices(ci); + self.focus_datasets(&datasets, lead); + self.sync_selection_to_active_canvas(); + self.reset_interaction(); + } + + /// Navigate to one object: activate its page, select it, and follow the + /// datasets it draws. + /// + /// This is [`Self::activate_canvas`]'s counterpart for a destination that + /// names an object, and it exists for the same reason: selecting an object + /// without pointing the data focus at what it draws leaves the two halves of + /// the session describing different things. + pub fn reveal_object(&mut self, ci: usize, id: ObjectId) { + if self + .doc + .canvases + .get(ci) + .and_then(|canvas| canvas.object(id)) + .is_none() + { + return; + } + self.session.active_canvas = Some(ci); + self.reset_interaction(); + self.select_object(ci, id); + self.focus_object_datasets(ci, id); + } + + /// Point the data focus at the datasets one object draws, with the object's + /// own active dataset leading. + pub fn focus_object_datasets(&mut self, ci: usize, id: ObjectId) { + let Some(object) = self + .doc + .canvases + .get(ci) + .and_then(|canvas| canvas.object(id)) + else { + return; + }; + let lead = object.dataset(); + let sources = object.dataset_ids(); + let lead = lead.and_then(|id| self.doc.dataset_index(id)); + let datasets: Vec = sources + .into_iter() + .filter_map(|id| self.doc.dataset_index(id)) + .collect(); + if datasets.is_empty() { + self.set_active_dataset(lead); + } else { + self.focus_datasets(&datasets, lead); + } + } + /// The group members of `id` with `id` moved to the front (the primary). fn group_click_members(&self, ci: usize, id: ObjectId) -> Vec { let Some(c) = self.doc.canvases.get(ci) else { diff --git a/crates/core/src/state/app_impl_figures.rs b/crates/core/src/state/app_impl_figures.rs index aee4da5..27a0f47 100644 --- a/crates/core/src/state/app_impl_figures.rs +++ b/crates/core/src/state/app_impl_figures.rs @@ -188,29 +188,63 @@ impl PlotxApp { } let key = ContourGeometryCacheKey { source, levels }; if let Some(geometry) = self.session.compute.geometry_for(&key) { + // A capped build drew fewer levels than the panel lists. + // Saying so is the difference between a contour the user + // chose and one the renderer silently cut down. + if let Some(omitted) = geometry.omitted { + self.session.status = omitted_levels_status(&omitted); + } return dataset.contour_figure_from_geometry( series.source.field, &geometry, &contour.style, ); } - let grid = self.contour_grid(dataset, series.source.field, version, summary)?; - if let Err(error) = self.session.compute.enqueue_contour(key, grid) { - self.session.status = field_enqueue_error_status(error); + // Ask whether the build is already running *before* building + // its input. A miss is resolved on every frame for as long as + // the job runs, and materializing the grid first would read and + // clone the whole plane each time only for the enqueue to + // recognize the duplicate and drop it. + if !self.session.compute.geometry_in_flight(&key) { + let grid = self.contour_grid(dataset, series.source.field, version, summary)?; + if let Err(error) = self.session.compute.enqueue_contour(key, grid) { + self.session.status = field_enqueue_error_status(error); + return dataset.encoded_field_figure(series.source.field, &series.encoding); + } + } + // The plot is empty and will stay empty until the worker + // answers. An unexplained blank plot is indistinguishable from + // a broken one, so the wait is stated where every other slow + // operation states it — but never over an unreachable + // threshold, which explains a half that will still be blank + // once the wait is over and names the edit that fixes it. + if unreachable.is_empty() { + self.session.status = CONTOUR_GEOMETRY_PENDING_STATUS.to_owned(); } } ContourResolution::Pending(keys) => { - let grid = self.contour_grid(dataset, series.source.field, version, summary)?; - for key in keys { - if let Err(error) = self - .session - .compute - .enqueue_estimate(key, Arc::clone(&grid)) - { - self.session.status = field_enqueue_error_status(error); - break; + let pending_status = estimate_pending_status(&keys); + // Same order for the same reason: only the keys that are not + // already running need a grid, and when none of them do the + // payload is never touched at all. + if keys + .iter() + .any(|key| !self.session.compute.estimate_in_flight(key)) + { + let grid = self.contour_grid(dataset, series.source.field, version, summary)?; + for key in keys { + if let Err(error) = self + .session + .compute + .enqueue_estimate(key, Arc::clone(&grid)) + { + self.session.status = field_enqueue_error_status(error); + return dataset + .encoded_field_figure(series.source.field, &series.encoding); + } } } + self.session.status = pending_status; } ContourResolution::Unavailable => { self.session.status = "Contour levels are unavailable for this field.".into(); @@ -233,6 +267,33 @@ impl PlotxApp { } } +/// What the user is told while a field's derived work is still running. +/// +/// Contour geometry and the estimates it depends on are computed off the +/// interface thread, and until they land the plot is genuinely empty. Nothing +/// used to say so: the frame that would have explained the wait drew a blank +/// axis box, which reads exactly like a plot that failed. These are worded as +/// progress rather than as failure, and name the work rather than the object — +/// derived field work is content-addressed and shared between every plot +/// resolving the same key (§5–§6), so there is no per-object state to report and +/// none is introduced here. +const CONTOUR_GEOMETRY_PENDING_STATUS: &str = "Building contour geometry…"; + +/// Name the measurement being waited on rather than "an estimate": the two kinds +/// take visibly different times, and a user who knows which one is running knows +/// whether the anchor they just chose is the reason. +fn estimate_pending_status(keys: &[EstimateKey]) -> String { + let noise = keys.iter().any(|key| key.kind == EstimateKind::Noise); + let background = keys.iter().any(|key| key.kind == EstimateKind::Background); + match (noise, background) { + (true, true) => "Measuring this field's noise scale and background…".to_owned(), + (false, true) => "Measuring this field's background…".to_owned(), + // An empty list cannot reach here: resolution reports `Pending` only + // when it has a key to wait for. + (true, false) | (false, false) => "Measuring this field's noise scale…".to_owned(), + } +} + /// Word the thresholds a field never reaches. /// /// Both numbers appear side by side because that is what makes the common @@ -267,6 +328,42 @@ fn unreachable_threshold_status(unreachable: &[UnreachableContourThreshold]) -> .join(" ") } +/// Word the levels the renderer's segment budget left undrawn. +/// +/// The count says how much of the ladder is missing, the magnitude says where +/// the plot stops being what the panel describes, and the sentence ends on the +/// one edit that recovers a complete picture. Naming the lowest level that *was* +/// drawn makes the fix concrete: set the ladder's floor there and every level it +/// then lists is a level actually on screen. +fn omitted_levels_status(omitted: &OmittedContourLevels) -> String { + let count = usize::from(omitted.positive) + usize::from(omitted.negative); + match omitted.lowest_drawn { + Some(lowest) => format!( + "The lowest {count} contour levels were not drawn: at {highest} and below, this \ + field crosses more of the grid than one plot can render. Raise the lowest level \ + to {lowest} or above to see every level the panel lists.", + highest = format_magnitude(omitted.highest_omitted.get()), + lowest = format_magnitude(lowest.get()), + ), + None => format!( + "No contour levels were drawn: even the highest, {highest}, crosses more of the \ + field than one plot can render. Raise the lowest level well above {highest}, or \ + draw fewer levels.", + highest = format_magnitude(omitted.highest_omitted.get()), + ), + } +} + +/// Print a level a ladder computed, as opposed to one a user typed. +/// +/// `format_level` echoes a threshold back verbatim because the user chose the +/// digits. A rung of a geometric ladder has no chosen digits — its exact value +/// is `base·ratio^k` and prints as sixteen of them — so it is shown to four +/// significant figures, which is both readable and enough to type back in. +fn format_magnitude(value: f64) -> String { + format!("{value:.3e}") +} + /// Print a contour level so a mistyped magnitude stays legible: plain decimals /// across the range users type by hand, scientific notation once a digit count /// stops being readable. Only strictly positive magnitudes reach this. diff --git a/crates/core/src/state/compute.rs b/crates/core/src/state/compute.rs index 52cb9bd..24203b9 100644 --- a/crates/core/src/state/compute.rs +++ b/crates/core/src/state/compute.rs @@ -675,3 +675,7 @@ fn done_identity(done: &Done) -> Option<(DatasetId, ComputeKind, u64)> { #[cfg(test)] mod tests; + +#[cfg(test)] +#[path = "contour_budget_tests.rs"] +mod contour_budget_tests; diff --git a/crates/core/src/state/compute_field.rs b/crates/core/src/state/compute_field.rs index 0872bd2..e5f449e 100644 --- a/crates/core/src/state/compute_field.rs +++ b/crates/core/src/state/compute_field.rs @@ -2,9 +2,10 @@ use super::*; use crate::state::{ - Dataset, EstimateKind, EstimateProvenance, EstimatedScale, FieldPayload, FieldProvenance, - FieldRef, FieldSnapshot, FieldSummary, FieldVersion, FiniteF64, LocationScaleEstimate, - ScaleEstimate, VersionedFieldRef, + ContourSegment, Dataset, EstimateKind, EstimateProvenance, EstimatedScale, FieldPayload, + FieldProvenance, FieldRef, FieldSnapshot, FieldSummary, FieldVersion, FiniteF64, + LocationScaleEstimate, OmittedContourLevels, ResolvedContourLevels, ScaleEstimate, + VersionedFieldRef, }; use plotx_analysis::robust::{ DEPLANED_LOCATION_SCALE_ID, DEPLANED_LOCATION_SCALE_VERSION, ROBUST_DIFFERENCE_MAD_ID, @@ -127,6 +128,33 @@ impl ComputeService { self.field_runtime.geometry(key) } + /// Read-only access for on-screen readouts. Taking `&self` is the point: + /// nothing reachable from here can mint a field version, queue an + /// `EstimateField` job, or materialize a payload, so displaying a value can + /// never schedule the work that would produce one. + pub(crate) fn peek_estimate(&self, key: &EstimateKey) -> Option<&EstimateResult> { + self.field_runtime.peek_estimate(key) + } + + pub(crate) fn peek_field_summary(&self, source: VersionedFieldRef) -> Option { + self.field_runtime.peek_summary(source) + } + + /// Whether the work behind this key is already running. + /// + /// A caller asks this *before* materializing a grid. The enqueue paths + /// deduplicate too, but they can only do so once handed a payload, and a + /// rebuild happens on every frame while a job runs: a 2048 × 8192 plane is + /// 64 MiB of `f32` that would be read and cloned every one of them, for a + /// job that was already accepted on the first. + pub(crate) fn estimate_in_flight(&self, key: &EstimateKey) -> bool { + self.field_runtime.estimate_in_flight(key) + } + + pub(crate) fn geometry_in_flight(&self, key: &ContourGeometryCacheKey) -> bool { + self.field_runtime.geometry_in_flight(key) + } + pub(crate) fn enqueue_estimate( &mut self, key: EstimateKey, @@ -257,6 +285,24 @@ fn resolved_estimator(key: &EstimateKey) -> Result { } } +/// Build the geometry for one resolved ladder, inside the renderer's segment +/// budget. +/// +/// The budget is applied *here*, in the worker that produces the geometry, +/// rather than in the cache or at paint time. Geometry is content-addressed by +/// `(field version, resolved levels)`, and so is the decision this makes: the +/// same grid and the same ladder always yield the same kept levels. A capped +/// result is therefore a normal cache entry — computed once, shared by every +/// object resolving that key, and never recomputed — where a cache-side or +/// paint-side cap would either re-run marching squares on an ungrowable result +/// every frame or quietly hand the renderer more than it can draw. +/// +/// Levels are drawn outermost first, dropped whole, and grouped by magnitude: +/// a magnitude enters the geometry in every half that asked for it, or in +/// neither. Cutting mid-magnitude would leave a signed plot with more positive +/// lobes than negative ones, and would make the advice wrong — after grouping, +/// raising the ladder's floor to the lowest drawn magnitude reproduces exactly +/// the plot on screen. pub(super) fn run_build_contour( key: ContourGeometryCacheKey, grid: Arc, @@ -268,25 +314,104 @@ pub(super) fn run_build_contour( return Err("contour geometry requires finite linear axis bounds".to_owned()); }; let levels = &key.levels; + let mut positive: Vec = Vec::new(); + let mut negative: Vec = Vec::new(); + let mut scratch: Vec = Vec::new(); + let mut drawn_positive = 0usize; + let mut drawn_negative = 0usize; + let mut lowest_drawn = None; + let mut highest_omitted = None; + for magnitude in magnitudes_outermost_first(levels) { + // Extract this magnitude's halves into scratch before committing + // either: the budget question is whether the whole magnitude fits, and + // extraction stops the moment the answer is no. + scratch.clear(); + let remaining = plotx_render::contour::MAX_CONTOUR_SEGMENTS + .saturating_sub(positive.len() + negative.len()); + let bounds = [x0, x1, y0, y1]; + let mut positive_count = 0usize; + let mut negative_count = 0usize; + let mut positive_end = 0usize; + let mut fits = true; + for level in levels.positive.iter().map(|level| level.get()) { + if level != magnitude { + continue; + } + positive_count += 1; + fits = extract_level(&grid, bounds, level, remaining, &mut scratch); + if !fits { + break; + } + } + if fits { + positive_end = scratch.len(); + for level in levels.negative.iter().map(|level| level.get()) { + if -level != magnitude { + continue; + } + negative_count += 1; + fits = extract_level(&grid, bounds, level, remaining, &mut scratch); + if !fits { + break; + } + } + } + if !fits { + highest_omitted = FiniteF64::new(magnitude); + break; + } + positive.extend_from_slice(&scratch[..positive_end]); + negative.extend_from_slice(&scratch[positive_end..]); + drawn_positive += positive_count; + drawn_negative += negative_count; + lowest_drawn = FiniteF64::new(magnitude); + } + + let omitted = highest_omitted.map(|highest_omitted| OmittedContourLevels { + positive: saturating_u16(levels.positive.len().saturating_sub(drawn_positive)), + negative: saturating_u16(levels.negative.len().saturating_sub(drawn_negative)), + highest_omitted, + lowest_drawn, + }); + Ok(ContourGeometry { + positive: Arc::from(positive), + negative: Arc::from(negative), + positive_levels: saturating_u16(drawn_positive), + negative_levels: saturating_u16(drawn_negative), + omitted, + }) +} + +/// Every magnitude either half draws, outermost first and without repeats. A +/// negative half stores signed levels, so its magnitudes are negated here; the +/// two halves usually share a ladder and therefore collapse onto the same +/// magnitudes, which is what lets the budget cut them together. +fn magnitudes_outermost_first(levels: &ResolvedContourLevels) -> Vec { + let mut magnitudes: Vec = levels + .positive + .iter() + .map(|level| level.get()) + .chain(levels.negative.iter().map(|level| -level.get())) + .collect(); + magnitudes.sort_by(|left, right| right.total_cmp(left)); + magnitudes.dedup(); + magnitudes +} + +/// Extract one level into `out`, stopping if `out` would pass `limit`. Reports +/// whether the level fit; a level that did not is left partial in `out` for the +/// caller to discard along with the rest of its magnitude. +fn extract_level( + grid: &ScalarGrid2D, + bounds: [f64; 4], + level: f64, + limit: usize, + out: &mut Vec, +) -> bool { #[cfg(test)] crate::contour_probe::record_marching_squares(); - let positive = plotx_render::contour::segments( - &grid.values, - grid.rows, - grid.cols, - x0, - x1, - y0, - y1, - &levels - .positive - .iter() - .map(|value| value.get()) - .collect::>(), - ); - #[cfg(test)] - crate::contour_probe::record_marching_squares(); - let negative = plotx_render::contour::segments( + let [x0, x1, y0, y1] = bounds; + plotx_render::contour::level_segments_into( &grid.values, grid.rows, grid.cols, @@ -294,16 +419,16 @@ pub(super) fn run_build_contour( x1, y0, y1, - &levels - .negative - .iter() - .map(|value| value.get()) - .collect::>(), - ); - Ok(ContourGeometry { - positive: Arc::from(positive), - negative: Arc::from(negative), - positive_levels: u16::try_from(levels.positive.len()).unwrap_or(u16::MAX), - negative_levels: u16::try_from(levels.negative.len()).unwrap_or(u16::MAX), - }) + level, + out, + limit, + // The budget replaces cancellation here: this build always runs to a + // bounded result rather than being abandoned part-way. + &|| false, + ) + .expect("non-cancelling contour extraction") +} + +fn saturating_u16(value: usize) -> u16 { + u16::try_from(value).unwrap_or(u16::MAX) } diff --git a/crates/core/src/state/contour_budget_tests.rs b/crates/core/src/state/contour_budget_tests.rs new file mode 100644 index 0000000..fc43dc3 --- /dev/null +++ b/crates/core/src/state/contour_budget_tests.rs @@ -0,0 +1,245 @@ +//! The renderer's segment budget: what a field too dense to draw produces, and +//! what the user is told about it. +//! +//! A contour ladder is not bounded by its level count. A single level over a +//! large grid can cross millions of cells, and every crossing becomes a drawn +//! segment; a 2048×8192 spectrum whose lowest level sat in the noise produced +//! 8.4 million of them and asked the GPU for a 1.0 GB index buffer, which is a +//! device validation error rather than a slow frame. These tests fix the two +//! properties that make that impossible: the geometry a build hands back is +//! bounded, and a bounded-down build says so. + +use super::compute_field::run_build_contour; +use crate::state::{ + AxisSampling, ChartSpec, ContourGeometryCacheKey, DataBinding, DataDomain, Dataset, DatasetId, + FieldId, FieldRef, FieldVersion, FiniteF64, Nmr2DDataset, PlotxApp, ResolvedContourLevels, + ScalarGrid2D, StackSpec, VersionedFieldRef, +}; +use num_complex::Complex64; +use plotx_figure::{ + ContourBasePolicy, ContourLevelSpec, ContourSpec, ContourStyle, PositiveFiniteF64, + SeriesEncoding, +}; +use plotx_io::{Dim, Domain, NmrData2D, QuadMode}; +use plotx_render::contour::MAX_CONTOUR_SEGMENTS; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +const SIDE: usize = 256; + +/// A grid every contour level crosses in every cell: neighbouring samples +/// alternate between `amplitude` and zero, so any level strictly between them +/// has a crossing on all four edges of all `(SIDE-1)²` cells. It is the densest +/// field a regular grid can hold, and stands in for the real failure — a +/// spectrum whose lowest levels sit inside its noise — without needing 16 +/// million samples to reproduce it. +fn dense_values(amplitude: f32) -> Vec { + (0..SIDE) + .flat_map(|row| { + (0..SIDE).map(move |col| { + if (row + col).is_multiple_of(2) { + amplitude + } else { + 0.0 + } + }) + }) + .collect() +} + +/// The same field with both signs, for the half-symmetry check: alternating +/// samples run `+amplitude` / `-amplitude`, so a positive level and its negative +/// mirror each cross every cell. +fn dense_signed_values(amplitude: f32) -> Vec { + (0..SIDE) + .flat_map(|row| { + (0..SIDE).map(move |col| { + if (row + col).is_multiple_of(2) { + amplitude + } else { + -amplitude + } + }) + }) + .collect() +} + +fn grid(values: Vec) -> Arc { + Arc::new(ScalarGrid2D { + values: Arc::from(values), + rows: SIDE, + cols: SIDE, + x: AxisSampling::Linear { + start: 0.0, + end: 1.0, + }, + y: AxisSampling::Linear { + start: 0.0, + end: 1.0, + }, + }) +} + +fn key(levels: ResolvedContourLevels) -> ContourGeometryCacheKey { + ContourGeometryCacheKey { + source: VersionedFieldRef { + field: FieldRef { + resource: DatasetId::from_uuid(uuid::Uuid::from_u128(4242)), + field: FieldId::new(0), + }, + version: FieldVersion(1), + }, + levels, + } +} + +fn finite(value: f64) -> FiniteF64 { + FiniteF64::new(value).expect("test literals are finite") +} + +fn resolved(positive: &[f64], negative: &[f64]) -> ResolvedContourLevels { + ResolvedContourLevels { + positive: Arc::from(positive.iter().copied().map(finite).collect::>()), + negative: Arc::from(negative.iter().copied().map(finite).collect::>()), + } +} + +#[test] +fn a_field_too_dense_to_draw_yields_bounded_geometry_instead_of_an_unbounded_buffer() { + let geometry = run_build_contour( + key(resolved(&[0.25, 0.5, 1.0], &[])), + grid(dense_values(1.0)), + ) + .expect("a well-formed grid builds geometry"); + + let drawn = geometry.positive.len() + geometry.negative.len(); + assert!( + drawn <= MAX_CONTOUR_SEGMENTS, + "a build must never hand the renderer more segments than it can draw, \ + got {drawn}" + ); + let omitted = geometry + .omitted + .expect("this field cannot draw its whole ladder, so it must report what it dropped"); + assert_eq!( + usize::from(geometry.positive_levels) + usize::from(omitted.positive), + 3, + "every requested level is either drawn or reported as omitted" + ); + assert!(omitted.positive > 0); + let lowest = omitted + .lowest_drawn + .expect("the outermost level alone fits the budget"); + assert!( + omitted.highest_omitted.get() < lowest.get(), + "levels are dropped from the bottom up: {} is not below {}", + omitted.highest_omitted.get(), + lowest.get() + ); +} + +#[test] +fn the_budget_cuts_both_halves_at_the_same_magnitude() { + let geometry = run_build_contour( + key(resolved(&[0.25, 0.5, 1.0], &[-0.25, -0.5, -1.0])), + grid(dense_signed_values(1.0)), + ) + .expect("a well-formed grid builds geometry"); + + let omitted = geometry + .omitted + .expect("a signed field this dense cannot draw its whole ladder"); + assert_eq!( + geometry.positive_levels, geometry.negative_levels, + "a magnitude is drawn in both halves or in neither, so a signed plot \ + never loses its negative lobes to the budget alone" + ); + assert_eq!(omitted.positive, omitted.negative); + assert!( + geometry.positive.len() + geometry.negative.len() <= MAX_CONTOUR_SEGMENTS, + "the budget bounds the geometry, not one half of it" + ); +} + +fn settle(app: &mut PlotxApp) { + let deadline = Instant::now() + Duration::from_secs(30); + while app.compute_busy() && Instant::now() < deadline { + app.poll_compute(); + std::thread::sleep(Duration::from_millis(5)); + } + app.poll_compute(); + assert!(!app.compute_busy(), "contour build did not settle in time"); +} + +/// A 256×256 true-2D dataset whose real plane is the dense grid above. +fn dense_dataset(label: &str, values: &[f32]) -> Dataset { + let dimension = |nucleus: &str| Dim { + spectral_width_hz: 4_000.0, + observe_freq_mhz: 400.0, + carrier_ppm: 0.0, + nucleus: nucleus.to_owned(), + group_delay: 0.0, + }; + Dataset::Nmr2D(Box::new(Nmr2DDataset::load(NmrData2D { + data: values + .iter() + .map(|value| Complex64::new(f64::from(*value), 0.0)) + .collect(), + rows: SIDE, + cols: SIDE, + domain: Domain::Frequency, + direct: dimension("1H"), + indirect: dimension("13C"), + quad: QuadMode::Complex, + indirect_conjugate: false, + experiment: None, + pseudo_axis: None, + diffusion: None, + nus: None, + source: label.to_owned(), + }))) +} + +#[test] +fn a_capped_contour_build_tells_the_user_which_levels_were_not_drawn() { + let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); + app.doc + .datasets + .push(dense_dataset("dense", &dense_values(1.0))); + let mut binding = DataBinding::single(&app.doc.datasets[0]); + let level = ContourLevelSpec { + base: ContourBasePolicy::Absolute(PositiveFiniteF64::new(0.25).unwrap()), + count: 3, + ratio: PositiveFiniteF64::new(2.0).unwrap(), + }; + binding.series[0].encoding = SeriesEncoding::Contour(ContourSpec { + positive: level, + negative: None, + style: ContourStyle::default(), + }); + let chart = ChartSpec::default_for(DataDomain::Nmr2d); + + app.build_binding_figure(&binding, &chart, &StackSpec::default(), [120.0, 80.0]); + settle(&mut app); + let figure = app.build_binding_figure(&binding, &chart, &StackSpec::default(), [120.0, 80.0]); + + let drawn: usize = figure + .contours + .iter() + .map(|contour| contour.segments.len()) + .sum(); + assert!(drawn > 0, "the levels that do fit are still drawn"); + assert!( + drawn <= MAX_CONTOUR_SEGMENTS, + "the figure carries only what the renderer can draw, got {drawn}" + ); + let status = app.session.status.clone(); + assert!( + status.contains("were not drawn"), + "a plot that is not the ladder the panel lists must say so: {status:?}" + ); + assert!( + status.contains("Raise the lowest level"), + "the explanation must end on the edit that recovers a full ladder: {status:?}" + ); +} diff --git a/crates/core/src/state/field.rs b/crates/core/src/state/field.rs index 4351f1e..effe390 100644 --- a/crates/core/src/state/field.rs +++ b/crates/core/src/state/field.rs @@ -428,6 +428,124 @@ pub struct PresentationProfile { pub preferred_encoding: Option, } +/// Lazily supplies a field's peak magnitude, `max(|min|, |max|)`. +/// +/// It is consulted only where no capability anchors a contour base, so an +/// ordinary provider never materializes its values to obtain a default. Return +/// `None` when the summary is not (yet) known. +pub type PeakMagnitude<'a> = &'a dyn Fn() -> Option; + +/// A peak provider for contexts where the field's values are not at hand. +pub const NO_PEAK: PeakMagnitude<'static> = &|| None; + +/// The field's peak magnitude, `max(|min|, |max|)`, materialized on demand. +/// +/// This walks the field's values, so it belongs behind a [`PeakMagnitude`] +/// closure that only the unanchored base policy ever calls. +pub fn field_peak_magnitude(dataset: &super::Dataset, field: FieldId) -> Option { + let summary = dataset.field_payload(field)?.summary()?; + let peak = summary.max.get().abs().max(summary.min.get().abs()); + (peak > 0.0).then_some(peak) +} + +/// Stable ids of the contour base policies, shared by the default factory and +/// the property catalog so a base chosen either way is the same value. +pub const CONTOUR_BASE_ABSOLUTE: &str = "absolute"; +pub const CONTOUR_BASE_NOISE_FLOOR: &str = "noise_floor"; +pub const CONTOUR_BASE_BACKGROUND_SCALE: &str = "background_scale"; +pub const CONTOUR_BASE_FRACTION_OF_RANGE: &str = "fraction_of_range"; + +/// The conventional lowest level of a peak-anchored ladder, and the fraction the +/// bounded policy starts from. +const CONTOUR_BASE_FRACTION: f64 = 0.04; +/// The conventional distance from the noise or background floor. +const CONTOUR_BASE_MULTIPLIER: f64 = 5.0; +/// The smallest noise scale a σ-anchored base accepts, as a fraction of the +/// field's peak magnitude. +/// +/// This is a calibration, not a convention, and it is the one number in this +/// file that should be re-measured when new evidence arrives. +/// +/// A noise estimator measures thermal noise. A 2D plane with large dynamic +/// range also carries the sampling artefacts of its own strongest feature — +/// indirect-dimension (t₁) noise ridges and residual solvent ridges — whose +/// amplitude scales with that feature rather than with the thermal floor, and +/// which are conventionally quoted at 10⁻³ to 10⁻⁴ of the parent peak. A level +/// below that traces artefacts, not signal. +/// +/// Measured on a 2048 × 8192 ¹H–¹H NOESY (peak 3.304e8, robust σ 1.669e3, so +/// 197,900:1 dynamic range) by counting the grid crossings of a single level +/// swept geometrically: 2.81e6 crossings at 0.001 % of peak, 8.99e5 at 0.004 %, +/// then 7.56e4 at 0.008 % and a smooth halving per octave above that. The knee +/// at ≈ 0.008 % of peak — 16 σ — is where contours stop following the artefact +/// floor, and it agrees with the conventional t₁-noise magnitude. The floor is +/// set at that knee, and the ladder's own 5× multiplier then places the lowest +/// level five artefact-floor units above it, exactly as 5σ places it five +/// thermal-noise units above thermal noise. +/// +/// Re-calibrate if the noise estimator changes what it measures, if the +/// renderer's segment budget changes, or if fields are seen whose artefact floor +/// sits elsewhere. The floor binds only above a dynamic range of 1/this value; +/// below it the estimated scale wins and nothing about resolution changes. +const CONTOUR_NOISE_FLOOR_PEAK_FRACTION: f64 = 1.0e-4; + +pub fn contour_base_kind(policy: &ContourBasePolicy) -> &'static str { + match policy { + ContourBasePolicy::Absolute(_) => CONTOUR_BASE_ABSOLUTE, + ContourBasePolicy::NoiseFloor { .. } => CONTOUR_BASE_NOISE_FLOOR, + ContourBasePolicy::BackgroundScale { .. } => CONTOUR_BASE_BACKGROUND_SCALE, + ContourBasePolicy::FractionOfRange(_) => CONTOUR_BASE_FRACTION_OF_RANGE, + } +} + +/// The canonical parameters of one base policy. +/// +/// Whether a policy *may* be chosen is a capability question answered by the +/// caller; this only says what it looks like when it is. Returns `None` for an +/// unknown id rather than substituting a policy the caller did not ask for. +pub fn contour_base_policy(kind: &str, peak: PeakMagnitude<'_>) -> Option { + let policy = match kind { + CONTOUR_BASE_ABSOLUTE => ContourBasePolicy::Absolute(absolute_base(peak)), + CONTOUR_BASE_NOISE_FLOOR => ContourBasePolicy::NoiseFloor { + multiplier: PositiveFiniteF64::new(CONTOUR_BASE_MULTIPLIER) + .expect("literal multiplier is valid"), + peak_fraction: UnitInterval::new(CONTOUR_NOISE_FLOOR_PEAK_FRACTION) + .expect("literal fraction is valid"), + estimator: EstimatorSelection::Frozen { + estimator: plotx_analysis::robust::ROBUST_DIFFERENCE_MAD_ID.to_owned(), + version: plotx_analysis::robust::ROBUST_DIFFERENCE_MAD_VERSION, + }, + }, + CONTOUR_BASE_BACKGROUND_SCALE => ContourBasePolicy::BackgroundScale { + multiplier: PositiveFiniteF64::new(CONTOUR_BASE_MULTIPLIER) + .expect("literal multiplier is valid"), + estimator: EstimatorSelection::Frozen { + estimator: plotx_analysis::robust::DEPLANED_LOCATION_SCALE_ID.to_owned(), + version: plotx_analysis::robust::DEPLANED_LOCATION_SCALE_VERSION, + }, + }, + CONTOUR_BASE_FRACTION_OF_RANGE => ContourBasePolicy::FractionOfRange( + UnitInterval::new(CONTOUR_BASE_FRACTION).expect("literal fraction is valid"), + ), + _ => return None, + }; + Some(policy) +} + +/// An absolute base anchored to the field's own peak. +/// +/// A fixed literal cannot serve here: a base of one intensity unit draws nothing +/// at all on any field whose peak is below one, and does so silently, with no +/// control in the panel that explains the blank plot. The peak is the only +/// scale-free anchor available when no capability offers a better one; the +/// literal remains solely as the last resort when even that is unknown. +fn absolute_base(peak: PeakMagnitude<'_>) -> PositiveFiniteF64 { + peak() + .map(|peak| peak * CONTOUR_BASE_FRACTION) + .and_then(PositiveFiniteF64::new) + .unwrap_or_else(|| PositiveFiniteF64::new(1.0).expect("literal base is valid")) +} + /// Materialize the complete persisted encoding for a newly created series. /// This is the sole default-policy factory; it never dispatches on `DataDomain`. pub fn default_encoding( @@ -435,6 +553,7 @@ pub fn default_encoding( semantic_metadata: &FieldMetadata, requested_chart: RequestedChart, presentation_profile: &PresentationProfile, + peak: PeakMagnitude<'_>, ) -> SeriesEncoding { let requested_chart = match requested_chart { RequestedChart::Auto => presentation_profile @@ -462,7 +581,7 @@ pub fn default_encoding( RequestedChart::Contour if source_capabilities.supports(&[CAP_FIELD_SCALAR_GRID_2D_REGULAR]) => { - SeriesEncoding::Contour(default_contour_spec(source_capabilities)) + SeriesEncoding::Contour(default_contour_spec(source_capabilities, peak)) } RequestedChart::Heatmap if source_capabilities.supports(&[CAP_FIELD_SCALAR_GRID_2D_REGULAR]) => @@ -503,30 +622,26 @@ pub fn default_encoding( } } -pub fn default_contour_spec(capabilities: &FieldCapabilities) -> ContourSpec { - let base = if capabilities.contains(CAP_FIELD_NOISE_SCALE) { - ContourBasePolicy::NoiseSigma { - multiplier: PositiveFiniteF64::new(5.0).expect("literal multiplier is valid"), - estimator: EstimatorSelection::Frozen { - estimator: plotx_analysis::robust::ROBUST_DIFFERENCE_MAD_ID.to_owned(), - version: plotx_analysis::robust::ROBUST_DIFFERENCE_MAD_VERSION, - }, - } +/// Pick the base policy this field's capabilities anchor best, most specific +/// first, and fall back to a peak-anchored absolute level when none of them do. +pub fn default_contour_base_kind(capabilities: &FieldCapabilities) -> &'static str { + if capabilities.contains(CAP_FIELD_NOISE_SCALE) { + CONTOUR_BASE_NOISE_FLOOR } else if capabilities.contains(CAP_FIELD_LOCATION_SCALE) { - ContourBasePolicy::BackgroundScale { - multiplier: PositiveFiniteF64::new(5.0).expect("literal multiplier is valid"), - estimator: EstimatorSelection::Frozen { - estimator: plotx_analysis::robust::DEPLANED_LOCATION_SCALE_ID.to_owned(), - version: plotx_analysis::robust::DEPLANED_LOCATION_SCALE_VERSION, - }, - } + CONTOUR_BASE_BACKGROUND_SCALE } else if capabilities.contains(CAP_FIELD_BOUNDED) { - ContourBasePolicy::FractionOfRange( - UnitInterval::new(0.04).expect("literal fraction is valid"), - ) + CONTOUR_BASE_FRACTION_OF_RANGE } else { - ContourBasePolicy::Absolute(PositiveFiniteF64::new(1.0).expect("literal base is valid")) - }; + CONTOUR_BASE_ABSOLUTE + } +} + +pub fn default_contour_spec( + capabilities: &FieldCapabilities, + peak: PeakMagnitude<'_>, +) -> ContourSpec { + let base = contour_base_policy(default_contour_base_kind(capabilities), peak) + .expect("default base kind is a known policy"); let level = ContourLevelSpec { base, count: 14, diff --git a/crates/core/src/state/field_cache.rs b/crates/core/src/state/field_cache.rs index 1e16405..23293a4 100644 --- a/crates/core/src/state/field_cache.rs +++ b/crates/core/src/state/field_cache.rs @@ -66,6 +66,15 @@ impl LruMap { Some(&entry.value) } + /// Read an entry without counting it as a use. + /// + /// This is the whole reason the borrow can be shared: showing a cached + /// number on screen is not a reason to keep it alive ahead of an entry a + /// resolve actually needs, so recency is deliberately left untouched. + fn peek(&self, key: &K) -> Option<&V> { + self.entries.get(key).map(|entry| &entry.value) + } + /// Insert `key`, then evict least-recently-used entries until both budgets /// hold. `retain` protects keys that must never be dropped — in practice /// the in-flight sets, whose completion would otherwise write into a slot @@ -181,6 +190,18 @@ impl FieldRuntime { self.estimates.get(key) } + /// The narrow read-only window onto the derived caches, for on-screen + /// readouts. It can report only what is already cached: there is + /// deliberately no path from here to minting a version, queueing a job, or + /// materializing a payload, so a readout can never start work. + pub(crate) fn peek_estimate(&self, key: &EstimateKey) -> Option<&EstimateResult> { + self.estimates.peek(key) + } + + pub(crate) fn peek_summary(&self, source: VersionedFieldRef) -> Option { + self.summaries.peek(&source).copied() + } + pub(crate) fn geometry( &mut self, key: &ContourGeometryCacheKey, @@ -188,6 +209,20 @@ impl FieldRuntime { self.geometry.get(key).cloned() } + /// Whether a job for exactly this key is already with the workers. + /// + /// Deduplication also happens inside `begin_*`, but only once a caller has + /// a grid to hand over. These let a caller find out *before* building one, + /// which is the difference between one payload materialization per job and + /// one per frame for as long as the job runs. + pub(crate) fn estimate_in_flight(&self, key: &EstimateKey) -> bool { + self.estimates_in_flight.contains(key) + } + + pub(crate) fn geometry_in_flight(&self, key: &ContourGeometryCacheKey) -> bool { + self.geometry_in_flight.contains(key) + } + pub(crate) fn begin_estimate(&mut self, key: EstimateKey) -> bool { self.estimates_in_flight.insert(key) } diff --git a/crates/core/src/state/field_progress_tests.rs b/crates/core/src/state/field_progress_tests.rs new file mode 100644 index 0000000..826d6bf --- /dev/null +++ b/crates/core/src/state/field_progress_tests.rs @@ -0,0 +1,165 @@ +//! What happens on the frames between asking for derived field work and getting +//! it back. +//! +//! Two things used to happen, both wrong. The plot said nothing at all, so a +//! build that was merely slow looked exactly like one that had failed. And the +//! rebuild materialized the field's whole payload before asking whether the job +//! it was about to queue was already running, so a 2048 × 8192 plane was read +//! and cloned once per frame for the entire life of a job that had been accepted +//! on the first. + +use super::tests::{grid_dataset, wait_for_app_compute}; +use super::*; +use crate::contour_probe; +use crate::state::{ChartSpec, DataBinding, DataDomain, PlotxApp, StackSpec}; +use plotx_figure::{ + ContourLevelSpec, ContourSpec, ContourStyle, PositiveFiniteF64, SeriesEncoding, +}; + +/// A 4×4 signed field with enough structure for a robust noise estimate. +fn signed_values() -> Vec { + vec![ + -4.0, -1.0, 3.0, 1.0, 2.0, -3.0, 4.0, -2.0, 1.0, 5.0, -5.0, 3.0, -2.0, 4.0, 0.0, 6.0, + ] +} + +fn app_with_contour(encoding: Option) -> (PlotxApp, DataBinding) { + let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); + app.doc + .datasets + .push(grid_dataset("progress", &signed_values())); + let mut binding = DataBinding::single(&app.doc.datasets[0]); + if let Some(spec) = encoding { + binding.series[0].encoding = SeriesEncoding::Contour(spec); + } + assert!( + matches!(binding.series[0].encoding, SeriesEncoding::Contour(_)), + "a true-2D NMR field defaults to a contour encoding" + ); + (app, binding) +} + +fn absolute_spec() -> ContourSpec { + let level = ContourLevelSpec { + base: ContourBasePolicy::Absolute(PositiveFiniteF64::new(1.0).expect("literal is valid")), + count: 3, + ratio: PositiveFiniteF64::new(1.5).expect("literal is valid"), + }; + ContourSpec { + positive: level.clone(), + negative: Some(level), + style: ContourStyle::default(), + } +} + +fn rebuild(app: &mut PlotxApp, binding: &DataBinding) -> plotx_figure::Figure { + app.build_binding_figure( + binding, + &ChartSpec::default_for(DataDomain::Nmr2d), + &StackSpec::default(), + [120.0, 80.0], + ) +} + +/// While a contour build is with the workers, repeating the rebuild must not +/// touch the field's values again. +/// +/// Deduplication already happened inside the enqueue, but only after a payload +/// had been built to hand it — so the frame cost of an in-flight job was one +/// full copy of the grid. Results are installed by `poll_compute`, so declining +/// to call it is exactly "the job is still running". +#[test] +fn rebuilding_while_a_contour_build_is_in_flight_materializes_no_further_payload() { + let (mut app, binding) = app_with_contour(Some(absolute_spec())); + + contour_probe::reset(); + let first = rebuild(&mut app, &binding); + assert!(first.contours.is_empty(), "the geometry is still pending"); + assert_eq!(contour_probe::queued_contour_builds(), 1); + let materialized = contour_probe::field_payload_materializations(); + assert!(materialized > 0, "the queued job was handed a real grid"); + + for _ in 0..8 { + let pending = rebuild(&mut app, &binding); + assert!(pending.contours.is_empty()); + } + assert_eq!( + contour_probe::queued_contour_builds(), + 1, + "the in-flight set already deduplicates the job itself" + ); + assert_eq!( + contour_probe::field_payload_materializations(), + materialized, + "an in-flight build must be recognized before its input is built, not \ + after: otherwise every frame of the wait copies the whole field" + ); + + // The warm path is unchanged: the finished geometry draws, still without + // marching squares on this thread. + wait_for_app_compute(&mut app); + let drawn = rebuild(&mut app, &binding); + assert!(!drawn.contours.is_empty()); + assert_eq!(contour_probe::marching_squares_on_this_thread(), 0); +} + +/// The same, one stage earlier: a pending noise estimate. +#[test] +fn rebuilding_while_an_estimate_is_in_flight_materializes_no_further_payload() { + let (mut app, binding) = app_with_contour(None); + + contour_probe::reset(); + let first = rebuild(&mut app, &binding); + assert!(first.contours.is_empty()); + assert_eq!(contour_probe::queued_estimates(), 1); + let materialized = contour_probe::field_payload_materializations(); + assert!(materialized > 0); + + for _ in 0..8 { + rebuild(&mut app, &binding); + } + assert_eq!(contour_probe::queued_estimates(), 1); + assert_eq!( + contour_probe::field_payload_materializations(), + materialized, + "an in-flight estimate must be recognized before its input is built" + ); +} + +/// A plot that is empty because its geometry is still being built says so. +/// +/// An empty plot with no explanation is indistinguishable from a broken one, +/// which is half the reason the crash this work came from was undiagnosable. +#[test] +fn a_pending_contour_build_says_it_is_building() { + let (mut app, binding) = app_with_contour(Some(absolute_spec())); + app.session.status.clear(); + + let figure = rebuild(&mut app, &binding); + assert!(figure.contours.is_empty()); + assert_eq!(app.session.status, "Building contour geometry…"); + + // And stops saying it once there is something to look at. + wait_for_app_compute(&mut app); + app.session.status.clear(); + let drawn = rebuild(&mut app, &binding); + assert!(!drawn.contours.is_empty()); + assert!( + app.session.status.is_empty(), + "a finished plot has nothing to report: {}", + app.session.status + ); +} + +/// The wait is named, not generic: a noise estimate and a background fit take +/// visibly different times, and the anchor the user just chose decides which one +/// is running. +#[test] +fn a_pending_estimate_names_the_measurement_it_is_waiting_for() { + let (mut app, binding) = app_with_contour(None); + app.session.status.clear(); + + let figure = rebuild(&mut app, &binding); + assert!(figure.contours.is_empty()); + assert_eq!(app.session.status, "Measuring this field's noise scale…"); +} diff --git a/crates/core/src/state/field_runtime.rs b/crates/core/src/state/field_runtime.rs index 3c4a0a4..c30d2dd 100644 --- a/crates/core/src/state/field_runtime.rs +++ b/crates/core/src/state/field_runtime.rs @@ -8,6 +8,7 @@ use super::{DatasetId, FieldCapabilities, FieldId, scalar_grid_capabilities}; use crate::automation::{CAP_FIELD_COLORED_RASTER_2D, CAP_FIELD_CURVE_1D, CapabilityId}; use plotx_figure::{ ContourBasePolicy, ContourLevelSpec, ContourSpec, EstimatorSelection, PositiveFiniteF64, + UnitInterval, }; use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use std::cmp::Ordering; @@ -453,6 +454,31 @@ pub struct ContourGeometry { pub negative: Arc<[ContourSegment]>, pub positive_levels: u16, pub negative_levels: u16, + /// The levels the renderer's segment budget left undrawn, or `None` when + /// every resolved level was drawn. It travels with the geometry rather than + /// beside it because the two are one answer: the segments say what was + /// drawn, and this says what the same build refused to draw and why the + /// plot is therefore not the ladder the panel shows. + pub omitted: Option, +} + +/// Levels a contour build dropped because drawing them would have exceeded +/// [`plotx_render::contour::MAX_CONTOUR_SEGMENTS`]. +/// +/// Whole levels are dropped, outermost kept first, precisely so the omission +/// stays explainable: a plot that draws its top *n* levels is a plot with a +/// higher lowest level, which is a picture a user can reason about and fix. A +/// contour cut off part-way along its own path is not. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct OmittedContourLevels { + pub positive: u16, + pub negative: u16, + /// Magnitude of the outermost level that did not fit. Every level at or + /// below it was dropped, in both halves. + pub highest_omitted: FiniteF64, + /// Magnitude of the lowest level actually drawn, or `None` when the budget + /// could not fit even one level. + pub lowest_drawn: Option, } impl ContourGeometry { @@ -462,6 +488,7 @@ impl ContourGeometry { negative: Arc::from([]), positive_levels: 0, negative_levels: 0, + omitted: None, } } } @@ -499,6 +526,56 @@ pub enum ContourResolution { Unavailable, } +/// Which term of a floored noise anchor supplied the scale actually in force. +/// +/// A readout that named only the multiplier would be true of both cases and +/// informative about neither: `5 × σ` describes a level five thermal-noise units +/// up, and the same words over a floored anchor describe a level that has +/// nothing to do with the estimate. The resolver therefore reports which term +/// won alongside the value, and the interface says so. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NoiseScaleTerm { + /// The estimator's own measurement is at or above the policy's floor. + Estimated, + /// The estimate is below the floor, so the multiple is measured against a + /// fraction of the field's peak magnitude instead. + PeakFloor, +} + +/// The peak magnitude of a summary: the largest absolute value the field holds, +/// whatever its sign. +pub fn summary_peak_magnitude(summary: FieldSummary) -> f64 { + summary.max.get().abs().max(summary.min.get().abs()) +} + +/// The noise scale a floored anchor resolves against, and the term that supplied +/// it. Ties go to the estimate: with no floor configured, or with a floor the +/// estimate already clears, this is exactly the estimator's answer. +/// +/// A [`EstimatedScale::Degenerate`] result is deliberately *not* floored. The +/// floor stands for the sampling artefacts a real measurement carries around its +/// own strongest feature; a field that measures no spread whatsoever — a plane, +/// an ideal synthetic — has no such structure to be protected from, and +/// flooring it would trade a ladder that spans the field for fourteen rungs +/// crowded against zero. "Nothing was measurable" is a different answer from "a +/// very small scale was measured", which is why the estimate type spells the two +/// apart, and the degenerate ladder policy in `contour_ladder` stays in force. +pub fn resolved_noise_scale( + scale: EstimatedScale, + peak_fraction: UnitInterval, + summary: FieldSummary, +) -> (f64, NoiseScaleTerm) { + let EstimatedScale::Positive(estimated) = scale else { + return (scale.get(), NoiseScaleTerm::Estimated); + }; + let floor = peak_fraction.get() * summary_peak_magnitude(summary); + if floor.is_finite() && floor > estimated.get() { + (floor, NoiseScaleTerm::PeakFloor) + } else { + (estimated.get(), NoiseScaleTerm::Estimated) + } +} + pub fn resolve_contour_levels( source: VersionedFieldRef, spec: &ContourSpec, @@ -573,9 +650,26 @@ fn resolve_half( } let base = match &level.base { ContourBasePolicy::Absolute(value) => value.get(), - ContourBasePolicy::FractionOfRange(fraction) => min + fraction.get() * (max - min), - ContourBasePolicy::NoiseSigma { + ContourBasePolicy::FractionOfRange(fraction) => { + // A base policy never yields a signed magnitude (§4.3): this half + // owns the sign and applies it below. Working across the raw + // `min..max` span instead would hand a signed base to a field that + // has both signs — for a spectrum running -P..P the positive half's + // "four percent" came out at -0.92·P. Measuring from this half's own + // floor (the sample closest to zero on its side) up to its peak + // keeps the result an unsigned magnitude for every field, and is + // identical to the span form on the single-signed fields the + // `Bounded` capability admits. + let floor = if negative { + (-max).max(0.0) + } else { + min.max(0.0) + }; + floor + fraction.get() * (peak - floor) + } + ContourBasePolicy::NoiseFloor { multiplier, + peak_fraction, estimator, } => { let key = EstimateKey { @@ -587,7 +681,12 @@ fn resolve_half( pending.push(key); return None; }; - result.scale.get() * multiplier.get() + // The floor is measured against the *field's* peak, not this half's. + // Sampling artefacts are driven by the strongest feature whatever + // its sign, and a per-half floor would also split the two halves + // onto different ladders, which the geometry budget relies on them + // not doing. + multiplier.get() * resolved_noise_scale(result.scale, *peak_fraction, summary).0 } ContourBasePolicy::BackgroundScale { multiplier, @@ -645,3 +744,15 @@ mod degenerate_tests; #[cfg(test)] #[path = "field_runtime_threshold_tests.rs"] mod threshold_tests; + +#[cfg(test)] +#[path = "field_runtime_fraction_tests.rs"] +mod fraction_tests; + +#[cfg(test)] +#[path = "field_runtime_floor_tests.rs"] +mod floor_tests; + +#[cfg(test)] +#[path = "field_progress_tests.rs"] +mod progress_tests; diff --git a/crates/core/src/state/field_runtime_degenerate_tests.rs b/crates/core/src/state/field_runtime_degenerate_tests.rs index 225bcf0..1431255 100644 --- a/crates/core/src/state/field_runtime_degenerate_tests.rs +++ b/crates/core/src/state/field_runtime_degenerate_tests.rs @@ -28,8 +28,8 @@ fn contour_binding(app: &PlotxApp) -> DataBinding { panic!("a true-2D NMR field defaults to a contour encoding"); }; assert!( - matches!(contour.positive.base, ContourBasePolicy::NoiseSigma { .. }), - "a noise-scale field defaults to a NoiseSigma base" + matches!(contour.positive.base, ContourBasePolicy::NoiseFloor { .. }), + "a noise-scale field defaults to a NoiseFloor base" ); binding } @@ -97,8 +97,9 @@ fn an_unavailable_estimator_reports_an_error_and_is_not_cached_as_degenerate() { }; *contour = ContourSpec { positive: plotx_figure::ContourLevelSpec { - base: ContourBasePolicy::NoiseSigma { + base: ContourBasePolicy::NoiseFloor { multiplier: PositiveFiniteF64::new(5.0).unwrap(), + peak_fraction: plotx_figure::UnitInterval::new(0.0).expect("a zero floor is valid"), estimator: EstimatorSelection::Frozen { estimator: "retired_estimator".to_owned(), version: 99, diff --git a/crates/core/src/state/field_runtime_floor_tests.rs b/crates/core/src/state/field_runtime_floor_tests.rs new file mode 100644 index 0000000..cf46eaf --- /dev/null +++ b/crates/core/src/state/field_runtime_floor_tests.rs @@ -0,0 +1,228 @@ +//! The peak floor under a σ-anchored contour base. +//! +//! A noise estimator measures thermal noise. A plane with large dynamic range +//! also carries the sampling artefacts of its own strongest feature, well above +//! that floor, and a ladder anchored to thermal σ alone sinks into them. The +//! anchor therefore resolves against `max(σ, fraction × peak)` — and says which +//! of the two it used, because the two describe different pictures. +//! +//! These tests pin three things: that a field whose σ is ordinary next to its +//! peak resolves *exactly* as it did before the floor existed; that a field with +//! the measured dynamic range of the spectrum this was calibrated on is held at +//! the floor; and that the calibrated fraction itself is what the measurement +//! says it should be. + +use super::tests::{finite, source}; +use super::*; +use crate::state::{CONTOUR_BASE_NOISE_FLOOR, contour_base_policy}; +use plotx_figure::{ + ContourLevelSpec, ContourSpec, ContourStyle, EstimatorSelection, PositiveFiniteF64, + UnitInterval, +}; + +/// The ¹H–¹H NOESY the floor was calibrated on: a 2048 × 8192 real plane whose +/// peak is 3.304e8 against a robust noise estimate of 1.669e3, a dynamic range +/// of 197,900:1. Sweeping a single level over that grid, the crossing count +/// falls from 8.99e5 at 0.004 % of peak to 7.56e4 at 0.008 % and then halves +/// smoothly per octave: below ≈ 0.008 % of peak the contour is tracing the +/// field's artefact floor rather than its peaks. +const NOESY_PEAK: f64 = 3.3042e8; +const NOESY_SIGMA: f64 = 1.6690e3; +/// Where the measured crossing count stops falling steeply — the artefact floor. +const NOESY_ARTEFACT_KNEE_FRACTION: f64 = 8.0e-5; + +fn scale_estimate(scale: f64) -> EstimateResult { + EstimateResult::Scale(ScaleEstimate { + scale: EstimatedScale::new(scale).expect("test scales are finite and non-negative"), + provenance: EstimateProvenance { + estimator: plotx_analysis::robust::ROBUST_DIFFERENCE_MAD_ID.to_owned(), + version: plotx_analysis::robust::ROBUST_DIFFERENCE_MAD_VERSION, + }, + }) +} + +fn floored_spec(multiplier: f64, peak_fraction: f64, count: u16) -> ContourSpec { + let level = ContourLevelSpec { + base: ContourBasePolicy::NoiseFloor { + multiplier: PositiveFiniteF64::new(multiplier).expect("test multiplier is positive"), + peak_fraction: UnitInterval::new(peak_fraction).expect("test fraction is in [0, 1]"), + estimator: EstimatorSelection::FollowLatest, + }, + count, + ratio: PositiveFiniteF64::new(1.35).expect("literal ratio is valid"), + }; + ContourSpec { + positive: level.clone(), + negative: Some(level), + style: ContourStyle::default(), + } +} + +fn resolved(spec: &ContourSpec, summary: FieldSummary, sigma: f64) -> ResolvedContourLevels { + let ContourResolution::Ready { levels, .. } = + resolve_contour_levels(source(11, 0, 1), spec, summary, |_| { + Some(scale_estimate(sigma)) + }) + else { + panic!("the estimate is supplied, so resolution is not pending"); + }; + levels +} + +fn spanning(peak: f64) -> FieldSummary { + FieldSummary { + min: finite(-peak), + max: finite(peak), + } +} + +/// The property the whole change turns on: a floor is a floor, not a +/// substitute. On a field whose estimated σ is ordinary next to its peak the +/// floor is never reached, and resolution produces byte-for-byte the ladder an +/// unfloored σ anchor produces. Nothing about an ordinary spectrum moves. +#[test] +fn a_field_whose_sigma_clears_the_floor_resolves_exactly_as_an_unfloored_anchor() { + // Dynamic range 1,000:1 — a σ a hundred times the floor's fraction. + let summary = spanning(1.0e6); + let sigma = 1.0e3; + let floored = resolved(&floored_spec(5.0, 1.0e-4, 14), summary, sigma); + let unfloored = resolved(&floored_spec(5.0, 0.0, 14), summary, sigma); + + assert_eq!(floored, unfloored); + assert_eq!( + floored.positive.first().map(|level| level.get()), + Some(5.0e3) + ); + assert_eq!( + resolved_noise_scale( + EstimatedScale::new(sigma).expect("finite"), + UnitInterval::new(1.0e-4).expect("valid"), + summary, + ), + (sigma, NoiseScaleTerm::Estimated), + ); +} + +/// The dynamic range at which the two terms swap is exactly the reciprocal of +/// the fraction, and the swap is continuous: at the crossing point both terms +/// give the same scale, so no field's ladder jumps as its σ drifts across it. +#[test] +fn the_terms_swap_at_the_reciprocal_of_the_fraction_without_a_step() { + let summary = spanning(1.0e6); + let fraction = UnitInterval::new(1.0e-4).expect("valid"); + let exactly_at_the_floor = 1.0e-4 * 1.0e6; + + let (scale, term) = resolved_noise_scale( + EstimatedScale::new(exactly_at_the_floor).expect("finite"), + fraction, + summary, + ); + assert_eq!((scale, term), (100.0, NoiseScaleTerm::Estimated)); + + let (scale, term) = resolved_noise_scale( + EstimatedScale::new(exactly_at_the_floor * 0.5).expect("finite"), + fraction, + summary, + ); + assert_eq!((scale, term), (100.0, NoiseScaleTerm::PeakFloor)); +} + +/// The spectrum that motivated the floor. Its σ is 2.5e-5 of its peak, twenty +/// times below the floor, so the floor decides the ladder — and the resolved +/// base lands above the artefact knee the measurement found. +#[test] +fn the_calibrating_noesy_is_held_above_its_measured_artefact_floor() { + let summary = spanning(NOESY_PEAK); + let spec = floored_spec(5.0, 1.0e-4, 14); + let levels = resolved(&spec, summary, NOESY_SIGMA); + let base = levels + .positive + .first() + .map(|level| level.get()) + .expect("a ladder with signal has a lowest level"); + + let (scale, term) = resolved_noise_scale( + EstimatedScale::new(NOESY_SIGMA).expect("finite"), + UnitInterval::new(1.0e-4).expect("valid"), + summary, + ); + assert_eq!(term, NoiseScaleTerm::PeakFloor); + assert!((scale - 1.0e-4 * NOESY_PEAK).abs() < 1.0e-6); + + assert!( + base > NOESY_ARTEFACT_KNEE_FRACTION * NOESY_PEAK, + "the lowest level {base:e} must sit above the measured artefact knee \ + {:e}, or the ladder is drawing t1 noise", + NOESY_ARTEFACT_KNEE_FRACTION * NOESY_PEAK + ); + // Without the floor the same spectrum anchors at five thermal σ, three + // orders of magnitude further down and squarely inside that noise. + let unfloored = resolved(&floored_spec(5.0, 0.0, 14), summary, NOESY_SIGMA); + let unfloored_base = unfloored + .positive + .first() + .map(|level| level.get()) + .expect("the unfloored ladder also has a lowest level"); + assert!(unfloored_base < NOESY_ARTEFACT_KNEE_FRACTION * NOESY_PEAK); + assert!(base / unfloored_base > 15.0); +} + +/// The floor is measured against the field's peak magnitude, not each half's +/// own. Sampling artefacts are driven by the strongest feature whatever its +/// sign; a per-half floor would also give the two halves different ladders, +/// which the geometry budget's "drop a magnitude from both signs together" rule +/// depends on them not having. +#[test] +fn both_halves_share_one_floor_taken_from_the_fields_peak() { + let summary = FieldSummary { + min: finite(-1.0e8), + max: finite(4.0e8), + }; + let levels = resolved(&floored_spec(5.0, 1.0e-4, 14), summary, 1.0e3); + let positive = levels.positive.first().map(|level| level.get()); + let negative = levels.negative.first().map(|level| -level.get()); + + assert_eq!(positive, Some(5.0 * 1.0e-4 * 4.0e8)); + assert_eq!(negative, positive, "one field, one noise floor"); +} + +/// A degenerate estimate is not a very small scale; it is the absence of one. +/// Flooring it would replace the ladder that spans an ideal synthetic field +/// with fourteen rungs crowded against zero, so the degenerate policy in +/// `contour_ladder` keeps the case. +#[test] +fn a_degenerate_estimate_is_not_floored() { + let summary = spanning(1.0e6); + assert_eq!( + resolved_noise_scale( + EstimatedScale::Degenerate, + UnitInterval::new(1.0e-4).expect("valid"), + summary, + ), + (0.0, NoiseScaleTerm::Estimated), + ); +} + +/// The calibration itself, so a change to it is a deliberate edit with the +/// measurements in front of the editor rather than a silent drift. +#[test] +fn the_default_noise_anchor_carries_the_calibrated_floor() { + let policy = contour_base_policy(CONTOUR_BASE_NOISE_FLOOR, crate::state::NO_PEAK) + .expect("the noise floor is a known policy"); + let ContourBasePolicy::NoiseFloor { + multiplier, + peak_fraction, + .. + } = policy + else { + panic!("the noise-floor kind builds a noise-floor policy"); + }; + assert_eq!(multiplier.get(), 5.0); + assert_eq!(peak_fraction.get(), 1.0e-4); + // The default lowest level is therefore 0.05 % of a field's peak whenever + // the floor is in force: eighty times below the peak fraction §12 rejects + // for suppressing weak cross peaks, and above the artefact floor measured + // on the calibrating spectrum. + assert!(multiplier.get() * peak_fraction.get() < 0.04); + assert!(multiplier.get() * peak_fraction.get() > NOESY_ARTEFACT_KNEE_FRACTION); +} diff --git a/crates/core/src/state/field_runtime_fraction_tests.rs b/crates/core/src/state/field_runtime_fraction_tests.rs new file mode 100644 index 0000000..8b49cd9 --- /dev/null +++ b/crates/core/src/state/field_runtime_fraction_tests.rs @@ -0,0 +1,98 @@ +//! `FractionOfRange` resolution. +//! +//! The policy names a fraction of a field's dynamic range. Because a base policy +//! never produces a signed magnitude — each half owns its sign — the fraction is +//! measured across that half's own magnitude range. These tests pin both halves +//! of that rule: the value stays unsigned even on a field that straddles zero, +//! and it is unchanged on the single-signed fields the `Bounded` capability +//! admits, which are the only ones a user may select the policy on. + +use super::*; +use plotx_figure::{ContourBasePolicy, ContourLevelSpec, PositiveFiniteF64, UnitInterval}; + +fn finite(value: f64) -> FiniteF64 { + FiniteF64::new(value).expect("test literals are finite") +} + +fn source() -> VersionedFieldRef { + VersionedFieldRef { + field: FieldRef { + resource: DatasetId::from_uuid(uuid::Uuid::from_u128(7)), + field: FieldId::new(0), + }, + version: FieldVersion(1), + } +} + +fn fraction_spec(fraction: f64, negative: bool) -> ContourSpec { + let level = ContourLevelSpec { + base: ContourBasePolicy::FractionOfRange( + UnitInterval::new(fraction).expect("test literal is in range"), + ), + count: 1, + ratio: PositiveFiniteF64::new(2.0).expect("test literal is valid"), + }; + ContourSpec { + positive: level.clone(), + negative: negative.then_some(level), + style: plotx_figure::ContourStyle::default(), + } +} + +fn resolved(spec: &ContourSpec, min: f64, max: f64) -> ResolvedContourLevels { + let summary = FieldSummary { + min: finite(min), + max: finite(max), + }; + match resolve_contour_levels(source(), spec, summary, |_| None) { + ContourResolution::Ready { levels, .. } => levels, + other => panic!("a fraction base needs no estimate, got {other:?}"), + } +} + +/// The bug this rule closes: across a raw `-P..P` span the positive half's +/// "four percent" evaluated to `-0.92·P`, a *negative* base for the positive +/// half. Measuring from the half's own floor keeps it a magnitude. +#[test] +fn a_signed_field_never_yields_a_signed_base() { + let levels = resolved(&fraction_spec(0.04, true), -10.0, 10.0); + assert_eq!(levels.positive.len(), 1); + assert!( + levels.positive[0].get() > 0.0, + "the positive half draws a positive level, got {}", + levels.positive[0].get() + ); + assert!((levels.positive[0].get() - 0.4).abs() < 1e-12); + assert_eq!(levels.negative.len(), 1); + assert!((levels.negative[0].get() + 0.4).abs() < 1e-12); +} + +/// A single-signed field with an offset floor — an AFM height map running +/// 100..200 nm — still measures the fraction across its own span, so the lowest +/// level sits just above the surface rather than far below it. +#[test] +fn a_bounded_field_measures_across_its_own_span() { + let levels = resolved(&fraction_spec(0.04, false), 100.0, 200.0); + assert_eq!(levels.positive.len(), 1); + assert!((levels.positive[0].get() - 104.0).abs() < 1e-12); +} + +/// A field whose values start at zero — a magnitude plane — is the case the +/// legacy peak-fraction ladder was written for, and must be unchanged. +#[test] +fn a_magnitude_plane_keeps_the_conventional_peak_fraction() { + let levels = resolved(&fraction_spec(0.04, false), 0.0, 250.0); + assert_eq!(levels.positive.len(), 1); + assert!((levels.positive[0].get() - 10.0).abs() < 1e-12); +} + +/// An entirely negative field: its magnitudes run from `|max|` up to `|min|`, +/// and the negative half's base must land inside that band with the sign applied +/// afterwards. +#[test] +fn an_entirely_negative_field_resolves_inside_its_own_band() { + let levels = resolved(&fraction_spec(0.5, true), -200.0, -100.0); + assert!(levels.positive.is_empty(), "no positive samples exist"); + assert_eq!(levels.negative.len(), 1); + assert!((levels.negative[0].get() + 150.0).abs() < 1e-12); +} diff --git a/crates/core/src/state/field_runtime_tests.rs b/crates/core/src/state/field_runtime_tests.rs index b2381c8..dc7070a 100644 --- a/crates/core/src/state/field_runtime_tests.rs +++ b/crates/core/src/state/field_runtime_tests.rs @@ -286,8 +286,9 @@ fn equivalent_absolute_levels_share_the_same_geometry_key() { let field = source(2, 7, 11); let noise_spec = ContourSpec { positive: ContourLevelSpec { - base: ContourBasePolicy::NoiseSigma { + base: ContourBasePolicy::NoiseFloor { multiplier: PositiveFiniteF64::new(5.0).unwrap(), + peak_fraction: plotx_figure::UnitInterval::new(0.0).expect("a zero floor is valid"), estimator: EstimatorSelection::Frozen { estimator: plotx_analysis::robust::ROBUST_DIFFERENCE_MAD_ID.to_owned(), version: plotx_analysis::robust::ROBUST_DIFFERENCE_MAD_VERSION, @@ -505,8 +506,9 @@ fn estimates_are_demand_driven_and_coexist_per_estimator_selection() { }; let noise_only = ContourSpec { positive: ContourLevelSpec { - base: ContourBasePolicy::NoiseSigma { + base: ContourBasePolicy::NoiseFloor { multiplier: PositiveFiniteF64::new(5.0).unwrap(), + peak_fraction: plotx_figure::UnitInterval::new(0.0).expect("a zero floor is valid"), estimator: frozen_noise.estimator.clone(), }, count: 3, @@ -518,7 +520,7 @@ fn estimates_are_demand_driven_and_coexist_per_estimator_selection() { assert_eq!( resolve_contour_levels(source, &noise_only, summary(), |_| None), ContourResolution::Pending(vec![frozen_noise.clone()]), - "a NoiseSigma contour does not request an unrelated background fit" + "a NoiseFloor contour does not request an unrelated background fit" ); let background_only = ContourSpec { positive: ContourLevelSpec { @@ -555,7 +557,7 @@ fn estimates_are_demand_driven_and_coexist_per_estimator_selection() { ); assert!( service.estimate_for(&background).is_none(), - "a NoiseSigma contour never schedules an unrelated background fit" + "a NoiseFloor contour never schedules an unrelated background fit" ); let latest_noise = EstimateKey { diff --git a/crates/core/src/state/field_runtime_threshold_tests.rs b/crates/core/src/state/field_runtime_threshold_tests.rs index 841f4ee..a17a440 100644 --- a/crates/core/src/state/field_runtime_threshold_tests.rs +++ b/crates/core/src/state/field_runtime_threshold_tests.rs @@ -106,8 +106,9 @@ fn a_policy_base_above_the_peak_falls_back_to_the_selected_ladder_span() { // successful resolution, never a threshold report. let spec = ContourSpec { positive: ContourLevelSpec { - base: ContourBasePolicy::NoiseSigma { + base: ContourBasePolicy::NoiseFloor { multiplier: PositiveFiniteF64::new(5.0).unwrap(), + peak_fraction: plotx_figure::UnitInterval::new(0.0).expect("a zero floor is valid"), estimator: EstimatorSelection::FollowLatest, }, count: 3, diff --git a/crates/core/src/state/field_tests.rs b/crates/core/src/state/field_tests.rs index 9ee12f6..7992a00 100644 --- a/crates/core/src/state/field_tests.rs +++ b/crates/core/src/state/field_tests.rs @@ -240,10 +240,48 @@ fn regular_capability_selects_contour_without_domain_knowledge() { &FieldMetadata::default(), RequestedChart::Contour, &PresentationProfile::default(), + crate::state::NO_PEAK, ); assert!(matches!(encoding, SeriesEncoding::Contour(_))); } +/// A scalar grid with no capability to anchor a base — no noise estimator, no +/// background estimator, not bounded — still has to receive a base that draws. +/// A fixed literal of one intensity unit produced a silently blank plot for +/// every such field whose peak was below one, so the fallback is anchored to the +/// field's own peak instead. +#[test] +fn an_unanchored_contour_base_follows_the_field_peak() { + let capabilities = + FieldCapabilities::new([CapabilityId::new(CAP_FIELD_SCALAR_GRID_2D_REGULAR)]); + let faint = default_contour_spec(&capabilities, &|| Some(1.0e-3)); + let plotx_figure::ContourBasePolicy::Absolute(base) = faint.positive.base else { + panic!("no capability anchors this field, so the base is absolute"); + }; + assert!( + base.get() < 1.0e-3, + "the lowest level must sit below the peak, got {}", + base.get() + ); + assert!(base.get() > 0.0); + + let loud = default_contour_spec(&capabilities, &|| Some(1.0e6)); + let plotx_figure::ContourBasePolicy::Absolute(loud) = loud.positive.base else { + panic!("absolute base"); + }; + assert!( + loud.get() > base.get(), + "the base scales with the field rather than staying a literal" + ); + + // Without any peak the encoding must still be concrete and valid. + let unknown = default_contour_spec(&capabilities, crate::state::NO_PEAK); + assert!(matches!( + unknown.positive.base, + plotx_figure::ContourBasePolicy::Absolute(_) + )); +} + #[test] fn unregistered_regular_grid_provider_gets_scalar_encodings() { let provider_grid = ScalarGrid2D { @@ -275,6 +313,7 @@ fn colored_raster_auto_materializes_an_image_encoding() { &FieldMetadata::default(), RequestedChart::Auto, &PresentationProfile::default(), + crate::state::NO_PEAK, ); assert!(matches!(encoding, SeriesEncoding::Image(_))); } @@ -314,6 +353,7 @@ fn raster_falls_back_to_image_instead_of_a_scalar_or_line_encoding() { &FieldMetadata::default(), RequestedChart::Contour, &PresentationProfile::default(), + crate::state::NO_PEAK, ); assert!(matches!(encoding, SeriesEncoding::Image(_))); } @@ -611,6 +651,7 @@ fn default_nmr_contour_never_builds_geometry_inline() { &real.metadata, RequestedChart::Contour, &PresentationProfile::default(), + crate::state::NO_PEAK, ), ) .unwrap(); diff --git a/crates/core/src/state/mod.rs b/crates/core/src/state/mod.rs index 0672958..4cea14e 100644 --- a/crates/core/src/state/mod.rs +++ b/crates/core/src/state/mod.rs @@ -84,6 +84,7 @@ mod table_fit; mod table_native; mod table_numeric; mod tile_drop; +mod ui_drag; mod ui_state; mod units; mod workflow_tab; @@ -137,6 +138,7 @@ pub use table_execution::*; pub use table_execution_job::*; pub use table_native::*; pub use tile_drop::*; +pub use ui_drag::*; pub use ui_state::*; pub use units::*; pub use workflow_tab::WorkflowTab; diff --git a/crates/core/src/state/series_binding.rs b/crates/core/src/state/series_binding.rs index 36fdb87..8a31b88 100644 --- a/crates/core/src/state/series_binding.rs +++ b/crates/core/src/state/series_binding.rs @@ -1,5 +1,6 @@ use super::{ Dataset, DatasetId, FieldId, PresentationProfile, RequestedChart, SeriesId, default_encoding, + field_peak_magnitude, }; use plotx_figure::Color; @@ -42,6 +43,7 @@ impl SeriesBinding { &descriptor.metadata, RequestedChart::Auto, &PresentationProfile::default(), + &|| field_peak_magnitude(dataset, field), ), }) } diff --git a/crates/core/src/state/ui_drag.rs b/crates/core/src/state/ui_drag.rs new file mode 100644 index 0000000..e5e9022 --- /dev/null +++ b/crates/core/src/state/ui_drag.rs @@ -0,0 +1,84 @@ +//! In-progress measurement-band gestures. +//! +//! Each of these mirrors [`super::ObjectDrag`]: the live geometry is recomputed +//! absolutely from the grab state every frame so nothing accumulates drift, and +//! `before` snapshots the dataset so the gesture commits as one undoable step. + +use super::{ObjectId, Region}; +use crate::{Integral2D, IntegralResult}; + +/// An in-progress region-band edit on a series plot. `region_id` names the band +/// being resized or moved (`None` while drawing a new one). +#[derive(Clone, Debug)] +pub struct RegionDrag { + pub canvas: usize, + pub object: ObjectId, + pub dataset: usize, + pub kind: RegionDragKind, + pub region_id: Option, + pub before: Vec, + /// Pointer ppm at grab time (for `Move`) or the fixed anchor (for `NewBand`). + pub anchor_ppm: f64, + /// The dragged band's lo/hi at grab time (for `Move`). + pub grab_lo: f64, + pub grab_hi: f64, + /// Live pointer ppm, used to paint the `NewBand` preview. + pub current_ppm: f64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RegionDragKind { + NewBand, + EdgeLo, + EdgeHi, + Move, +} + +/// An in-progress integral-band edit on a 1D spectrum — the direct analogue of +/// [`RegionDrag`], reusing [`RegionDragKind`]. +#[derive(Clone, Debug)] +pub struct IntegralDrag { + pub canvas: usize, + pub object: ObjectId, + pub dataset: usize, + pub kind: RegionDragKind, + pub integral_id: Option, + pub before: Vec, + pub anchor_ppm: f64, + pub grab_lo: f64, + pub grab_hi: f64, + pub current_ppm: f64, +} + +/// An in-progress true-2D integral rectangle edit. Geometry is updated live, +/// while volume recomputation is deferred until the gesture commits. +#[derive(Clone, Debug)] +pub struct Integral2DDrag { + pub canvas: usize, + pub object: ObjectId, + pub dataset: usize, + pub kind: Integral2DDragKind, + pub integral_id: Option, + pub before: Vec, + /// Pointer coordinates at grab time, or the fixed corner for a new rectangle. + pub anchor: [f64; 2], + /// Rectangle bounds at grab time for moves and resizes. + pub grab_f2: (f64, f64), + pub grab_f1: (f64, f64), + /// Live pointer coordinates, used for the new-rectangle preview. + pub current: [f64; 2], +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Integral2DDragKind { + NewRect, + EdgeF2Lo, + EdgeF2Hi, + EdgeF1Lo, + EdgeF1Hi, + CornerF2LoF1Lo, + CornerF2LoF1Hi, + CornerF2HiF1Lo, + CornerF2HiF1Hi, + Move, +} diff --git a/crates/core/src/state/ui_state.rs b/crates/core/src/state/ui_state.rs index dc4bd98..9a642c5 100644 --- a/crates/core/src/state/ui_state.rs +++ b/crates/core/src/state/ui_state.rs @@ -4,87 +4,6 @@ use std::collections::HashSet; use std::ops::{Deref, DerefMut}; use std::sync::Arc; -/// An in-progress region-band edit on a series plot. Like [`ObjectDrag`], the -/// live band is recomputed absolutely from the grab state each frame so nothing -/// accumulates drift. `before` snapshots the dataset's regions for the undoable -/// commit; `region_id` names the band being resized/moved (`None` while drawing -/// a new one). -#[derive(Clone, Debug)] -pub struct RegionDrag { - pub canvas: usize, - pub object: ObjectId, - pub dataset: usize, - pub kind: RegionDragKind, - pub region_id: Option, - pub before: Vec, - /// Pointer ppm at grab time (for `Move`) or the fixed anchor (for `NewBand`). - pub anchor_ppm: f64, - /// The dragged band's lo/hi at grab time (for `Move`). - pub grab_lo: f64, - pub grab_hi: f64, - /// Live pointer ppm, used to paint the `NewBand` preview. - pub current_ppm: f64, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum RegionDragKind { - NewBand, - EdgeLo, - EdgeHi, - Move, -} - -/// An in-progress integral-band edit on a 1D spectrum — the direct analogue of -/// [`RegionDrag`], reusing [`RegionDragKind`]. `before` snapshots the dataset's -/// integrals for the undoable commit; `integral_id` names the band being -/// resized/moved (`None` while drawing a new one). -#[derive(Clone, Debug)] -pub struct IntegralDrag { - pub canvas: usize, - pub object: ObjectId, - pub dataset: usize, - pub kind: RegionDragKind, - pub integral_id: Option, - pub before: Vec, - pub anchor_ppm: f64, - pub grab_lo: f64, - pub grab_hi: f64, - pub current_ppm: f64, -} - -/// An in-progress true-2D integral rectangle edit. Geometry is updated live, -/// while volume recomputation is deferred until the gesture commits. -#[derive(Clone, Debug)] -pub struct Integral2DDrag { - pub canvas: usize, - pub object: ObjectId, - pub dataset: usize, - pub kind: Integral2DDragKind, - pub integral_id: Option, - pub before: Vec, - /// Pointer coordinates at grab time, or the fixed corner for a new rectangle. - pub anchor: [f64; 2], - /// Rectangle bounds at grab time for moves and resizes. - pub grab_f2: (f64, f64), - pub grab_f1: (f64, f64), - /// Live pointer coordinates, used for the new-rectangle preview. - pub current: [f64; 2], -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Integral2DDragKind { - NewRect, - EdgeF2Lo, - EdgeF2Hi, - EdgeF1Lo, - EdgeF1Hi, - CornerF2LoF1Lo, - CornerF2LoF1Hi, - CornerF2HiF1Lo, - CornerF2HiF1Hi, - Move, -} - /// The current slice position of the Slice tool: which 2D dataset/plot it /// targets, the cut orientation, and the snapped grid index (a row/column index /// for a true-2D spectrum, or an increment index for a pseudo-2D stack). Drives @@ -320,6 +239,31 @@ pub struct FitEditorValidation { pub result: Result<(plotx_analysis::fit_model::FitModelDefinition, Vec), String>, } +/// A search hit that has to be revealed: open the property's home panel, expand +/// its section, scroll the row into view and highlight it briefly. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PropertyFocus { + pub property: crate::properties::PropertyId, + /// Still waiting for its panel to expand and scroll to it. + pub pending: bool, + /// UI-clock time at which the highlight stops being painted. + pub highlight_until: f64, +} + +impl PropertyFocus { + /// Long enough to catch the eye after a scroll, short enough not to linger + /// as an apparent selection. + pub const HIGHLIGHT_SECONDS: f64 = 0.8; + + pub fn request(property: crate::properties::PropertyId, now: f64) -> Self { + Self { + property, + pending: true, + highlight_until: now + Self::HIGHLIGHT_SECONDS, + } + } +} + pub struct UiState { /// The single in-flight direct-manipulation gesture; see [`Interaction`]. pub interaction: Interaction, @@ -397,6 +341,9 @@ pub struct UiState { /// One-shot request from navigation (for example a double-click in the data /// browser) to reveal a contextual group in the Secondary Side Bar. pub requested_tool_group: Option, + /// A property the unified search asked the panels to reveal. Navigation + /// state, so it is deliberately absent from the property catalog itself. + pub property_focus: Option, /// Source dataset whose Regions workflow is shown in the canvas task card. pub region_task_dataset: Option, /// Whether the Regions task card is reduced to its one-line summary. @@ -541,6 +488,7 @@ impl Default for UiState { data_browser_selected_node: None, data_browser_last_active: None, requested_tool_group: None, + property_focus: None, region_task_dataset: None, region_task_collapsed: false, curve_fit_task_dataset: None, diff --git a/crates/figure/src/encoding.rs b/crates/figure/src/encoding.rs index 79834cc..a74bea0 100644 --- a/crates/figure/src/encoding.rs +++ b/crates/figure/src/encoding.rs @@ -186,8 +186,27 @@ impl<'de> Deserialize<'de> for ContourLevelSpec { #[serde(rename_all = "snake_case", tag = "policy", content = "value")] pub enum ContourBasePolicy { Absolute(PositiveFiniteF64), - NoiseSigma { + /// A multiple of the field's noise scale, where "noise scale" is the larger + /// of the estimator's answer and `peak_fraction` of the field's peak + /// magnitude. + /// + /// Both coefficients are carried here rather than one being applied inside + /// resolution, because which of the two is in force decides what the number + /// beside the control means. A policy that silently substituted a peak + /// fraction while the interface still read `5 × σ` would be describing a + /// picture the data never produced. + /// + /// The floor is not a preference: an estimator measures thermal noise, and + /// a field with enough dynamic range carries sampling artefacts of its own + /// strongest feature far above that. Below `peak_fraction` of the peak a + /// contour traces those artefacts rather than signal. A field whose + /// estimated scale is large next to its peak never reaches the floor and + /// resolves exactly as a plain multiple of σ would. + NoiseFloor { multiplier: PositiveFiniteF64, + /// The smallest noise scale this anchor will accept, as a fraction of + /// the field's peak magnitude. Zero disables the floor. + peak_fraction: UnitInterval, estimator: EstimatorSelection, }, BackgroundScale { diff --git a/crates/render/src/contour.rs b/crates/render/src/contour.rs index 50a8f51..d6df02e 100644 --- a/crates/render/src/contour.rs +++ b/crates/render/src/contour.rs @@ -1,6 +1,29 @@ //! Marching-squares contour extraction: turns a row-major intensity grid and a //! set of levels into data-space line segments for a contour plot. +/// One contour line segment in data space, `[[x0, y0], [x1, y1]]`. +pub type Segment = [[f64; 2]; 2]; + +/// The most contour line segments one figure's geometry may carry. +/// +/// This is a hard device limit, not a taste threshold. Every drawn segment is +/// tessellated into its own feathered quad strip, which costs 30 `u32` indices +/// in the frame's index buffer — a figure of 8.4 million segments, measured on +/// a 2048×8192 spectrum whose lowest level sat 5σ above a thermal-noise +/// estimate, asked for a 1.0 GB buffer against wgpu's 256 MiB default +/// `max_buffer_size`. Exceeding that maximum is a validation error that aborts +/// the process, so it must be impossible to reach rather than merely unlikely. +/// +/// 256 MiB is 67,108,864 indices, about 2.2 million segments for *everything* a +/// frame draws. Budgeting 250,000 per contour geometry keeps a page of eight +/// contour plots inside the limit alongside the rest of the interface, and +/// keeps one plot's tessellation tractable besides. +/// +/// The budget bounds a whole geometry rather than a single level: the levels of +/// one ladder are drawn together or not at all, so a per-level cap would still +/// let a ladder overrun the buffer. +pub const MAX_CONTOUR_SEGMENTS: usize = 250_000; + /// Contour line segments (each `[[x0,y0],[x1,y1]]`, in data space) for every /// `level` crossing of the `rows × cols` grid `z`. Columns map linearly onto /// `[x0, x1]` and rows onto `[y0, y1]`. @@ -14,7 +37,7 @@ pub fn segments( y0: f64, y1: f64, levels: &[f64], -) -> Vec<[[f64; 2]; 2]> { +) -> Vec { segments_cancellable(z, rows, cols, x0, x1, y0, y1, levels, &|| false) .expect("non-cancelling contour extraction") } @@ -30,66 +53,121 @@ pub fn segments_cancellable( y1: f64, levels: &[f64], cancelled: &impl Fn() -> bool, -) -> Option> { +) -> Option> { let mut out = Vec::new(); + for &level in levels { + // These callers draw everything they ask for, so the budget is opted + // out of rather than ignored: `usize::MAX` can never be reached, and + // the "fits" answer is constant. + level_segments_into( + z, + rows, + cols, + x0, + x1, + y0, + y1, + level, + &mut out, + usize::MAX, + cancelled, + )?; + } + Some(out) +} + +/// Append one `level`'s crossings of the `rows × cols` grid `z` to `out`, +/// stopping once `out` would grow past `limit` segments. +/// +/// Extracting a single level is what lets a caller decide *per level* whether +/// the result still fits a budget it owns: a caller that must stay under +/// [`MAX_CONTOUR_SEGMENTS`] can measure a level, then keep or discard the whole +/// level. Truncating mid-level would be indistinguishable from a contour that +/// genuinely stops there, which is why this is the smallest unit offered — and +/// why `limit` reports rather than returns a short level. A level that crosses +/// a whole large grid is tens of millions of segments and a gigabyte of +/// scratch, so a caller that is going to reject it must be able to stop paying +/// for it as soon as the verdict is settled. +/// +/// Returns `Some(true)` when the level is complete, `Some(false)` when `limit` +/// stopped it part-way — `out` then holds a partial level the caller must +/// discard — and `None` when `cancelled` fired, with the same obligation. +#[allow(clippy::too_many_arguments)] +pub fn level_segments_into( + z: &[f32], + rows: usize, + cols: usize, + x0: f64, + x1: f64, + y0: f64, + y1: f64, + level: f64, + out: &mut Vec, + limit: usize, + cancelled: &impl Fn() -> bool, +) -> Option { if rows < 2 || cols < 2 || z.len() < rows * cols { - return Some(out); + return Some(true); } let gx = |colf: f64| x0 + (x1 - x0) * colf / (cols - 1) as f64; let gy = |rowf: f64| y0 + (y1 - y0) * rowf / (rows - 1) as f64; let at = |r: usize, c: usize| z[r * cols + c] as f64; - for &level in levels { - for r in 0..rows - 1 { - if cancelled() { - return None; - } - for c in 0..cols - 1 { - let nw = at(r, c); - let ne = at(r, c + 1); - let sw = at(r + 1, c); - let se = at(r + 1, c + 1); + for r in 0..rows - 1 { + if cancelled() { + return None; + } + // Tested once per row rather than once per cell: the overshoot is at + // most one row of crossings, and the check costs nothing against the + // row it guards. + if out.len() > limit { + return Some(false); + } + for c in 0..cols - 1 { + let nw = at(r, c); + let ne = at(r, c + 1); + let sw = at(r + 1, c); + let se = at(r + 1, c + 1); - let case = (sw >= level) as u8 - | (((se >= level) as u8) << 1) - | (((ne >= level) as u8) << 2) - | (((nw >= level) as u8) << 3); - if case == 0 || case == 15 { - continue; - } + let case = (sw >= level) as u8 + | (((se >= level) as u8) << 1) + | (((ne >= level) as u8) << 2) + | (((nw >= level) as u8) << 3); + if case == 0 || case == 15 { + continue; + } - // Edge crossings as fractional (row, col) grid coordinates. - let interp = |a: f64, b: f64| (level - a) / (b - a); - let top = || [r as f64, c as f64 + interp(nw, ne)]; - let bottom = || [r as f64 + 1.0, c as f64 + interp(sw, se)]; - let left = || [r as f64 + interp(nw, sw), c as f64]; - let right = || [r as f64 + interp(ne, se), c as f64 + 1.0]; + // Edge crossings as fractional (row, col) grid coordinates. + let interp = |a: f64, b: f64| (level - a) / (b - a); + let top = || [r as f64, c as f64 + interp(nw, ne)]; + let bottom = || [r as f64 + 1.0, c as f64 + interp(sw, se)]; + let left = || [r as f64 + interp(nw, sw), c as f64]; + let right = || [r as f64 + interp(ne, se), c as f64 + 1.0]; - let mut push = |a: [f64; 2], b: [f64; 2]| { - out.push([[gx(a[1]), gy(a[0])], [gx(b[1]), gy(b[0])]]); - }; + let mut push = |a: [f64; 2], b: [f64; 2]| { + out.push([[gx(a[1]), gy(a[0])], [gx(b[1]), gy(b[0])]]); + }; - match case { - 1 | 14 => push(left(), bottom()), - 2 | 13 => push(bottom(), right()), - 3 | 12 => push(left(), right()), - 4 | 11 => push(top(), right()), - 6 | 9 => push(bottom(), top()), - 7 | 8 => push(left(), top()), - 5 => { - push(left(), top()); - push(bottom(), right()); - } - 10 => { - push(left(), bottom()); - push(top(), right()); - } - _ => {} + match case { + 1 | 14 => push(left(), bottom()), + 2 | 13 => push(bottom(), right()), + 3 | 12 => push(left(), right()), + 4 | 11 => push(top(), right()), + 6 | 9 => push(bottom(), top()), + 7 | 8 => push(left(), top()), + 5 => { + push(left(), top()); + push(bottom(), right()); + } + 10 => { + push(left(), bottom()); + push(top(), right()); } + _ => {} } } } - Some(out) + Some(out.len() <= limit) } /// A geometric ladder of `count` positive contour levels between `base` (the diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 181ee20..7e4c56b 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -64,6 +64,7 @@ export default defineConfig({ translations: { 'zh-CN': '图形与导出' }, items: [ { slug: 'guides/layout-and-export' }, + { slug: 'guides/contour-levels' }, { slug: 'guides/annotations' }, { slug: 'guides/exporting' }, { slug: 'guides/present-mode' }, diff --git a/docs/src/content/docs/guides/contour-levels.md b/docs/src/content/docs/guides/contour-levels.md new file mode 100644 index 0000000..3c28de2 --- /dev/null +++ b/docs/src/content/docs/guides/contour-levels.md @@ -0,0 +1,177 @@ +--- +title: Contour levels +description: Set the lowest contour level, the ladder above it, and the colours of a 2D contour plot. +--- + +A contour plot draws a ladder of levels: a lowest level, then each next level a +fixed ratio above the one before. PlotX edits that ladder for any 2D scalar +data — a phase-sensitive NMR plane, a magnitude plane, an AFM height map — from +one place. + +Select the plot on the page. The **Contour** section appears in the Object +inspector at the top of the Secondary Side Bar, and only when something in the +selection draws a contour. Select several plots and the section edits them +together; select nothing and it follows the page's active plot. + +## Lowest level + +**Lowest level** is the only setting shown until you open **Advanced**, because +it decides what you see: everything below it is not drawn at all. Raise it to +suppress noise, lower it to reveal weak cross-peaks. + +What the number means depends on the anchor (below). Under the default anchor +for a phase-sensitive NMR plane it is a multiple of the noise floor, so `5` +means "start at five times the noise". Because a multiple on its own does not +tell you where the level actually falls, PlotX shows the resolved value next to +the control: `5 × σ = 1.2e4`. + +Noise and background are estimated in the background, so until the estimate +arrives the row reads `measuring…` rather than a number. Data with no measurable +spread — a perfectly flat or perfectly regular grid — reads `no spread +measured`: the multiple anchors nothing, and the levels fall back to a ladder +derived from the data's own peak so the plot is not left blank. + +### The noise floor + +An estimator measures thermal noise. Data with a large dynamic range also +carries the sampling artefacts of its own strongest feature — in a 2D spectrum, +the t₁ noise ridges and residual solvent ridges that run through the tallest +peaks — and those are far above the thermal floor. A level set below them draws +the artefacts rather than the peaks: hundreds of thousands of contour crossings +that hide the spectrum and can exceed what one plot is able to draw at all. + +So the σ anchor never resolves below **0.01 % of the data's peak**. Whichever is +larger, the estimated σ or that floor, is what the multiple is measured against. +On ordinary data σ is much larger and nothing changes. On data whose peak is +more than 10,000 times its noise the floor takes over, and the readout says so +rather than continuing to name σ: + +- `5 × σ = 1.2e4` — the estimate is in force. +- `5 × 0.01% of peak = 1.652e5 — σ is below this floor` — the floor is. + +Hover the row for the longer explanation. The floor is a floor, not a ceiling on +what you can ask for: choose the **Absolute level** anchor to set any level you +like, including one inside the noise. + +If you set an **Absolute level** the data never reaches, that half draws nothing +and the status bar reports both numbers — for example, *The positive contour +threshold 20000 is above this field's positive peak 1800, so no positive +contours are drawn. Lower the threshold below 1800.* A slipped decimal point is +visible at a glance that way. + +### From the plot + +Press `+` and `-` to raise and lower the lowest level of the selected plot — or +of the page's active plot when nothing is selected — without leaving the canvas. +On keyboards where `+` needs Shift, `=` raises as well. Each press moves the +level by one rung of that plot's own **Level ratio**, so one press adds or +removes roughly one contour ring whatever the intensity scale: the same gesture +works on a spectrum whose peak is 100 and one whose peak is a billion. + +While the keys apply, the current lowest level is shown in the top-right corner +of the plot, in the same words as the panel; a plot whose contour series do not +all sit at the same level says so instead of showing one of them. Stepping is an +ordinary edit: it can be undone, and a step past the highest value the current +anchor allows is refused, with the reason in the status bar. + +## Anchor and ladder + +Open **Advanced** for the rest of the ladder. + +- **Anchor** — what the lowest level is measured against. The choices offered + depend on what the data can supply: + - *Multiple of the noise floor* — needs data whose noise level can be + estimated, such as a phase-sensitive NMR plane. This is the default there. + See [The noise floor](#the-noise-floor) for what the floor is. + - *Background + multiple of spread* — needs data with a measurable background + level and spread, such as an AFM height map, where the background is not + centred on zero. + - *Fraction of value range* — needs single-signed data, such as a magnitude + plane. It is not offered for data that has both positive and negative + values, where a fraction of the range is not a meaningful threshold. + - *Absolute level* — a raw intensity, always available. +- **Levels** — how many levels the ladder has, from 1 to 256. +- **Level ratio** — the factor between one level and the next. It must be + greater than 1, and at most 10. + +**Negative contours**, **Positive colour**, **Negative colour** and **Line +width** (0.05 to 10) are in the same section. + +The ladder stops early when the next level would be above the data's peak, so a +plot can show fewer levels than **Levels** asks for. That is not an error: the +remaining levels would draw nothing. + +A ladder can also stop early at the bottom. A level that falls inside the noise +crosses most of the grid, and there is a limit to how much line one plot can +draw. Past that limit PlotX drops the remaining levels whole — never cutting a +contour off part-way along its own path — and drops the same levels from both +signs, so what you see is a complete ladder with a higher floor. The status bar +says how many went and what to set instead, for example: *The lowest 14 contour +levels were not drawn: at 5.052e4 and below, this field crosses more of the grid +than one plot can render. Raise the lowest level to 6.820e4 or above to see +every level the panel lists.* + +Changing the anchor, the lowest level, the count or the ratio applies to both +signs — the negative half mirrors the positive ladder and applies its own sign. + +## While it is being drawn + +Contour geometry, and the noise or background estimate a ladder is anchored to, +are computed away from the interface so a large plane never freezes the +application. Until they land the plot is empty, and the status bar says which +step is running — *Measuring this field's noise scale…*, then *Building contour +geometry…* — so a plot that is merely slow is never mistaken for one that has +failed. Large planes take a moment; the plot fills in by itself. The work is +shared: two plots drawn from the same data at the same levels wait on one +computation, not two. + +## No single value + +The anchor, the lowest level, the count and the ratio are shared: by the +positive and negative halves of a ladder, and by every series you have selected. +When they do not all agree, the control shows an em dash and the word *mixed* +instead of a number or a choice, so no one value is presented as the setting. +The resolved level beside the control disappears with it: there is no single +lowest level to resolve. Hover *mixed* to see which case it is — one series +whose two halves differ, or several series that differ from each other. + +Setting the row applies your value to every selected series and to both halves +of each ladder, which is what makes them agree again. + +## Negative contours + +**Negative contours** is offered only for data that has both signs, such as a +phase-sensitive NMR plane. Single-signed data — a magnitude plane, a height map +— has no negative half to draw. + +Positive and negative contours have separate colours, so the sign information of +a phase-sensitive spectrum survives in the figure. + +## Changed values and reset + +A setting that differs from what PlotX would choose for this data is marked with +a dot. Hover the dot to see the default; use the reset button next to it to go +back. Reset re-derives the default from the current data rather than restoring +an older value, so it stays correct after reprocessing. A row with no single +value carries the same marker. + +Resetting the lowest level re-derives it under the anchor currently in force, not +under the one PlotX would have chosen: reset it while anchored to a fraction of +the range and you get a fraction, not a noise multiple. + +**Reset contour** rebuilds the whole contour — anchor, ladder and colours — from +the defaults for this data. It touches only the series drawn as contours; +anything else in the same plot, such as a heatmap underneath, is left as it is +and reported as skipped in the status bar. + +## Finding a setting + +Four routes reach a contour setting, and they all end at the same control: + +- Ctrl+K (Cmd on macOS) searches settings as + well as commands and data. Typing `contour threshold`, `sigma` or `levels` + finds the matching row, opens the panel it lives in, expands its section and + highlights it. See [Command palette](/reference/command-palette/). +- **Contour** on the **Figure** tab of the Ribbon jumps to the same place. +- Right-click the plot and choose **Contour settings…**. +- `+` and `-` change the lowest level directly on the plot. diff --git a/docs/src/content/docs/reference/command-palette.md b/docs/src/content/docs/reference/command-palette.md index a364a71..806a3a7 100644 --- a/docs/src/content/docs/reference/command-palette.md +++ b/docs/src/content/docs/reference/command-palette.md @@ -1,11 +1,11 @@ --- title: Command palette -description: Search and run any command from the keyboard. +description: Search commands, settings, and data from the keyboard. --- -The command palette gives keyboard access to the application's commands — -everything from opening files to switching tools — without hunting through -menus. +The command palette gives keyboard access to commands — everything from opening +files to switching tools — and to settings and data, without hunting through +menus and panels. ## Opening and closing @@ -19,13 +19,22 @@ You can also choose **Search commands** at the right of the Ribbon task tabs, or ## Searching and running Type to filter the list. Matching is case-insensitive; separate words with -spaces and a command matches only when every word hits. +spaces and a row matches only when every word hits. -- `↑` / `↓` move the selection, skipping unavailable commands. -- `Enter` or a mouse click runs the selected command and closes the palette. +- `↑` / `↓` move the selection, skipping unavailable rows. +- `Enter` or a mouse click activates the selected row and closes the palette. -Each row shows the command name on the left and, in gray on the right, the -keyboard shortcut bound to that command, if it has one. +Each row shows the name on the left and, in gray on the right, a hint: the +keyboard shortcut for a command, the panel for a setting, the kind or the page +for data. + +Settings are matched on more than their visible label: their identifier, read +whole or word by word, and their alternative names. `contour threshold`, `sigma` +and `series.contour.count` all reach the contour rows. + +Activating a setting does not change anything. It opens the panel the setting +lives in, expands its section, scrolls to the row and highlights it briefly, so +you can see the current value before editing it. ## Availability @@ -33,8 +42,17 @@ Commands that don't apply in the current context are grayed out — for example, export commands without an active canvas, or align and distribute without enough selected objects. +Settings are grayed out the same way when nothing in the current selection can +receive them — no plot selected, or a selected series that draws something other +than a contour. They stay in the list so you can still find them by name; hover +one to see why it is unavailable. + ## What's included +Three kinds of row share one search: commands, settings, and data. + +Commands: + - Open, import, and save; new canvas from a template. - Export (SVG, PDF, PNG, JPEG, TIFF) and copy image. - Undo, redo, select all, and grouping. @@ -43,7 +61,21 @@ without enough selected objects. - Arrange: grid, align, distribute, z-order, and *Tidy up frames*. - Applying themes and stacking data. - Switching to any tool. +- *Contour settings*, and raising or lowering the lowest contour level. + +Settings: + +- The contour rows of the Object inspector — lowest level, anchor, levels, level + ratio, negative contours, colours, and line width. See + [Contour levels](/guides/contour-levels/). + +Data: + +- Every dataset, page, and object on a page. Activating one opens it and + selects it. Parameterized operations that need a target picked on the canvas — such as a specific integral or phase adjustment — are not in the palette; switch to the -corresponding tool instead. +corresponding tool instead. One-off inputs to an operation, such as the export +resolution of a single export, are part of that operation's dialog rather than +searchable settings. diff --git a/docs/src/content/docs/reference/shortcuts.md b/docs/src/content/docs/reference/shortcuts.md index 017144a..a17b34c 100644 --- a/docs/src/content/docs/reference/shortcuts.md +++ b/docs/src/content/docs/reference/shortcuts.md @@ -57,6 +57,7 @@ the cursor, or on the board when the cursor is over empty space. | `Ctrl` + `G` | Group the selected objects | | `Ctrl` + `Shift` + `G` | Ungroup | | `Delete` or `Backspace` | Delete the selected annotation objects; in the Peaks and Integrate tools, delete the selected peak or region | +| `+` (or `=`) / `-` | Raise / lower the lowest contour level of the selected plot | | `F2` | Rename the selected dataset or canvas | | `Esc` | Cancel the active drag; further presses clear the Analysis Range and selections one at a time, then leave the active tool | | `Ctrl` + `C` | Copy the selected frame (or the active canvas) to the clipboard as bitmap + vector | @@ -64,6 +65,14 @@ the cursor, or on the board when the cursor is over empty space. | `Ctrl` + `,` | Open Preferences | | `Ctrl` + `K` or `Ctrl` + `Shift` + `P` | Open the [command palette](/reference/command-palette/) | +`+` and `-` act on the plot you have selected, or on the active plot when +nothing is selected, and only when it draws contours. Each press moves the +lowest level by one rung of that plot's own level ratio, so one press adds or +removes roughly one contour ring whatever the intensity scale. The current +lowest level is shown in the top-right corner of the plot while the keys apply. +See [Contour levels](/guides/contour-levels/). Holding `Ctrl` changes the UI +scale instead. + While editing a board note: `Enter` commits, `Shift` + `Enter` inserts a newline, `Esc` cancels. diff --git a/docs/src/content/docs/reference/ui-overview.md b/docs/src/content/docs/reference/ui-overview.md index ef1cbcd..eb99022 100644 --- a/docs/src/content/docs/reference/ui-overview.md +++ b/docs/src/content/docs/reference/ui-overview.md @@ -34,10 +34,12 @@ introduces the same regions in walkthrough form. through a multi-step task (Regions, Curve Fit, Statistics). Drag its lower-right handle to resize it. - **Object inspector** — the properties panel for the selected board object: - chart type, styling, and geometry. + chart type, styling, geometry, and the display settings of what it draws, such + as [contour levels](/guides/contour-levels/). Everyday settings are shown + directly; the rest are folded into **Advanced**. - **Data sheet** — the spreadsheet view of a data table, opened by double-clicking the table. -- **Command palette** — the searchable command list on +- **Command palette** — the searchable list of commands, settings, and data on Ctrl+K; see [Command palette](/reference/command-palette/). - **Size chip** — the label above a page's top-left corner showing its diff --git a/docs/src/content/docs/zh-cn/guides/contour-levels.md b/docs/src/content/docs/zh-cn/guides/contour-levels.md new file mode 100644 index 0000000..04f5a4b --- /dev/null +++ b/docs/src/content/docs/zh-cn/guides/contour-levels.md @@ -0,0 +1,149 @@ +--- +title: 等高线层级 +description: 设置 2D 等高线图的最低层、层级阶梯与颜色。 +--- + +等高线图绘制的是一组阶梯式层级:先有一个最低层,之后每一层都比前一层高出一个 +固定比值。PlotX 在同一处编辑任意 2D 标量数据的这组阶梯——相敏 NMR 平面、幅度模 +平面、AFM 高度图都一样。 + +在页面上选中该图,副侧栏顶部的对象检查器中会出现 **Contour** 分节;只有当前选择 +中确实有内容以等高线绘制时才会出现。选中多个图时,该分节会一起编辑它们;什么都 +不选时,它跟随该页的活动图。 + +## 最低层 + +**Lowest level**(最低层)是展开 **Advanced**(高级)之前唯一显示的设置,因为它 +决定了你能看到什么:低于它的一切都不会被绘制。调高它可以压掉噪声,调低它可以显出 +弱交叉峰。 + +这个数字的含义取决于所选的锚定方式(见下)。相敏 NMR 平面的默认锚定是噪声下限的 +倍数,因此 `5` 表示"从五倍噪声起画"。倍数本身并不能说明这一层究竟落在什么强度 +上,因此 PlotX 会在控件旁显示解析后的取值:`5 × σ = 1.2e4`。 + +噪声与背景是在后台估计的,估计结果到达之前该行显示 `measuring…`(测量中)而不是 +数字。完全没有可测离散度的数据——完全平坦或完全规则的网格——显示 `no spread +measured`(未测出离散度):此时倍数无从锚定,层级会退回到按数据自身峰值推导的 +阶梯,以免图上一片空白。 + +### 噪声下限 + +估计器测的是热噪声。动态范围很大的数据还带有自身最强特征的采样伪迹——在 2D 谱里 +就是穿过最高峰的 t₁ 噪声脊和残余水峰脊——它们远高于热噪声。把最低层设到它们之下, +画出来的就是伪迹而不是峰:数十万条等高线穿越会盖住整张谱,甚至超出一幅图能够 +绘制的上限。 + +因此 σ 锚定解析出的噪声尺度**不会低于数据峰值的 0.01 %**。估计出的 σ 与这个下限, +哪个大就用哪个来度量倍数。普通数据的 σ 远大于下限,行为完全不变;峰值超过噪声 +一万倍的数据则由下限接管,此时读数会如实改口,不再继续声称是 σ: + +- `5 × σ = 1.2e4` —— 由估计值决定。 +- `5 × 0.01% of peak = 1.652e5 — σ is below this floor` —— 由下限决定。 + +悬停该行可以看到更详细的说明。下限只是下限,并不限制你能提出的要求:选择 +**Absolute level**(绝对强度)锚定即可设成任意值,包括落在噪声里的值。 + +如果把 **Absolute level**(绝对强度)设成数据根本达不到的值,该半区将什么都不画, +状态栏会同时给出两个数字,例如:*The positive contour threshold 20000 is above +this field's positive peak 1800, so no positive contours are drawn. Lower the +threshold below 1800.*(正半区阈值 20000 高于该场的正向峰值 1800,因此不绘制正 +等高线;请把阈值降到 1800 以下。)小数点打错一位,一眼就能看出来。 + +### 直接在图上调整 + +按 `+` 和 `-` 即可提高或降低所选图的最低层,无需离开画布;未选中任何图时作用于该页 +的活动图。在需要按 Shift 才能打出 `+` 的键盘上,`=` 同样可以提高。每按一次,最低层 +按该图自身的 **Level ratio**(层间比值)移动一级,因此无论强度量级如何,一次按键 +大致增减一圈等高线——峰值是 100 的谱和峰值是十亿的谱,手感完全相同。 + +快捷键生效时,图的右上角会以与面板相同的措辞显示当前最低层;若图中多条等高线谱线 +的最低层并不一致,它会如实说明,而不是拿其中一条冒充整体。这种调整就是一次普通 +编辑:可以撤销;若一步会越过当前锚定允许的最大值,该步会被拒绝,原因显示在 +状态栏。 + +## 锚定与阶梯 + +展开 **Advanced**(高级)可以看到阶梯的其余设置。 + +- **Anchor**(锚定)——最低层相对于什么来度量。可选项取决于数据能提供什么: + - *Multiple of the noise floor*(噪声下限的倍数)——需要能估计噪声水平的数据, + 例如相敏 NMR 平面。这也是该类数据的默认值。下限的含义见[噪声下限](#噪声下限)。 + - *Background + multiple of spread*(背景 + 若干倍离散度)——需要能测出背景水平 + 和离散度的数据,例如背景不以零为中心的 AFM 高度图。 + - *Fraction of value range*(值域的一部分)——需要单符号数据,例如幅度模平面。 + 对同时存在正负值的数据不提供该选项,因为此时"值域的百分之几"并不是一个有意 + 义的阈值。 + - *Absolute level*(绝对强度)——直接给出强度值,始终可用。 +- **Levels**(层数)——阶梯共有多少层,取值 1 到 256。 +- **Level ratio**(层间比值)——相邻两层之间的倍数,必须大于 1,且不超过 10。 + +**Negative contours**(负等高线)、**Positive colour**(正等高线颜色)、 +**Negative colour**(负等高线颜色)和 **Line width**(线宽,0.05 到 10)也在同一 +分节中。 + +当下一层会超过数据峰值时,阶梯会提前终止,因此图上实际显示的层数可能少于 +**Levels** 所要求的。这不是错误:多出的层本来也画不出任何东西。 + +阶梯也可能从下端提前终止。落在噪声里的层会穿过网格的大部分区域,而一幅图能绘制 +的线量是有上限的。超过该上限后,PlotX 会整层丢弃剩余层级——绝不会把某条等高线 +从中途截断——并且正负两个半区同时丢弃同样的层,因此你看到的始终是一组完整的、 +只是下限更高的阶梯。状态栏会说明丢弃了多少层、应改设成什么,例如:*The lowest 14 +contour levels were not drawn: at 5.052e4 and below, this field crosses more of +the grid than one plot can render. Raise the lowest level to 6.820e4 or above to +see every level the panel lists.*(最低的 14 层未绘制:在 5.052e4 及以下,该场 +穿过的网格超出一幅图能绘制的范围;请把最低层提高到 6.820e4 或以上,以看到面板 +列出的每一层。) + +修改锚定、最低层、层数或比值会同时作用于正负两个半区——负半区镜像正半区的阶梯, +并施加自己的符号。 + +## 绘制过程中 + +等高线几何,以及阶梯所锚定的噪声或背景估计,都在界面之外计算,因此再大的平面也 +不会卡住整个程序。在结果到达之前图上是空的,状态栏会说明当前进行到哪一步—— +*Measuring this field's noise scale…*(正在测量该场的噪声尺度)、 +*Building contour geometry…*(正在构建等高线几何)——这样"只是慢"就不会被当成 +"坏掉了"。大平面需要片刻,图会自行填充。这些计算是共享的:同一份数据、同一组层级 +的两幅图只等待一次计算,而不是两次。 + +## 没有单一取值时 + +锚定、最低层、层数和比值都是共享的:既由一组阶梯的正负两个半区共享,也由你选中 +的每条谱线共享。当它们并不一致时,控件会显示一个破折号和 *mixed*(多个值),而 +不显示数字或选项,以免把某一个取值当作整体设置呈现。控件旁解析后的层级取值也会 +一并消失:此时并不存在唯一的最低层可供解析。悬停 *mixed* 可以看到究竟是哪一种 +情况:是同一条谱线的正负两半不一致,还是多条选中的谱线彼此不一致。 + +设置该行会把你的取值应用到每条选中的谱线以及每组阶梯的两个半区,它们也就重新 +一致了。 + +## 负等高线 + +**Negative contours**(负等高线)只对同时具有正负号的数据提供,例如相敏 NMR +平面。单符号数据——幅度模平面、高度图——没有负半区可画。 + +正、负等高线各有独立颜色,因此相敏谱的正负号信息在成图后依然保留。 + +## 已修改标记与重置 + +与 PlotX 针对该数据所选默认值不同的设置会带一个圆点标记。悬停圆点可以看到默认 +值,点击旁边的重置按钮即可恢复。重置是按当前数据重新推导默认值,而不是恢复某个 +旧值,因此在重新处理数据之后依然正确。没有单一取值的行同样会带这个标记。 + +重置最低层时,默认值是按**当前生效的锚定**重新推导的,而不是按 PlotX 原本会选择 +的锚定:在"值域的一部分"锚定下重置,得到的就是一个比例,而不是噪声倍数。 + +**Reset contour**(重置等高线)会按该数据的默认值重建整条等高线——锚定、阶梯和 +颜色。它只作用于以等高线绘制的谱线;同一张图里的其他内容(例如底下的热图)保持 +原样,并在状态栏中作为跳过项报告。 + +## 找到某个设置 + +到达某个等高线设置有四条途径,它们最终都通向同一个控件: + +- Ctrl+K(macOS 上为 Cmd)同时搜索设置、命令和 + 数据。输入 `contour threshold`、`sigma` 或 `levels` 即可找到对应的行,PlotX 会 + 打开它所在的面板、展开其分节并高亮该行。参见[命令面板](/zh-cn/reference/command-palette/)。 +- Ribbon 的 **Figure** 页签上的 **Contour** 按钮跳转到同一处。 +- 在图上点击右键,选择 **Contour settings…**。 +- `+` 与 `-` 直接在图上调整最低层。 diff --git a/docs/src/content/docs/zh-cn/reference/command-palette.md b/docs/src/content/docs/zh-cn/reference/command-palette.md index dc67063..3d1a868 100644 --- a/docs/src/content/docs/zh-cn/reference/command-palette.md +++ b/docs/src/content/docs/zh-cn/reference/command-palette.md @@ -1,10 +1,10 @@ --- title: 命令面板 -description: 用键盘搜索并执行任意命令。 +description: 用键盘搜索命令、设置与数据。 --- -命令面板提供应用各项命令的键盘入口——从打开文件到切换工具——无需在菜单中 -逐层查找。 +命令面板提供命令的键盘入口——从打开文件到切换工具——同时也涵盖设置与数据, +无需在菜单和面板中逐层查找。 ## 打开与关闭 @@ -18,18 +18,32 @@ description: 用键盘搜索并执行任意命令。 输入即过滤列表。匹配不区分大小写;多个词以空格分隔,全部命中才算匹配。 -- `↑` / `↓` 移动选中项,自动跳过不可用的命令。 -- `Enter` 或鼠标点击执行所选命令并关闭面板。 +- `↑` / `↓` 移动选中项,自动跳过不可用的行。 +- `Enter` 或鼠标点击激活所选行并关闭面板。 -列表每行左侧是命令名,右侧以灰色显示该命令已绑定的键盘快捷键(如有)。 +列表每行左侧是名称,右侧以灰色显示提示:命令显示其快捷键,设置显示其所在面板, +数据显示其类型或所属页面。 + +设置的匹配范围不止其可见标签:还包括它的标识符(整体或逐词拆开)及其别名。 +`contour threshold`、`sigma` 与 `series.contour.count` 都能命中等高线相关的行。 + +激活一个设置不会改变任何内容。它会打开该设置所在的面板、展开其分节、滚动到该行 +并短暂高亮,让你先看清当前值再编辑。 ## 可用性 在当前上下文中不适用的命令会置灰——例如没有活动画布时的导出命令,或所选 对象不足时的对齐与分布。 +当前选择无法承载的设置同样会置灰——没有选中任何图,或选中的谱线画的不是等高线。 +它们仍留在列表中,以便你按名称找到;悬停即可看到不可用的原因。 + ## 收录范围 +命令、设置与数据三类行共用同一个搜索。 + +命令: + - 打开、导入与保存;按模板新建画布。 - 导出(SVG、PDF、PNG、JPEG、TIFF)与复制图像。 - 撤销、重做、全选与编组。 @@ -38,6 +52,17 @@ description: 用键盘搜索并执行任意命令。 - 排列:网格、对齐、分布、层序与 *Tidy up frames*(一键整理)。 - 应用主题与堆叠数据。 - 切换到任意工具。 +- *Contour settings*(等高线设置),以及提高或降低等高线最低层。 + +设置: + +- 对象检查器中的等高线各行——最低层、锚定、层数、层间比值、负等高线、颜色与 + 线宽。参见[等高线层级](/zh-cn/guides/contour-levels/)。 + +数据: + +- 项目中的每个数据集、页面,以及页面上的每个对象。激活即打开并选中它。 需要在画布上点选目标的参数化操作——如某个具体的积分或相位调整——不在面板 -中;请改为切换到对应工具完成。 +中;请改为切换到对应工具完成。某次操作的一次性输入(例如单次导出的分辨率)属于 +该操作的对话框,而不是可搜索的设置。 diff --git a/docs/src/content/docs/zh-cn/reference/shortcuts.md b/docs/src/content/docs/zh-cn/reference/shortcuts.md index 8688e9f..f66cee0 100644 --- a/docs/src/content/docs/zh-cn/reference/shortcuts.md +++ b/docs/src/content/docs/zh-cn/reference/shortcuts.md @@ -56,6 +56,7 @@ description: 键盘与鼠标快捷操作。 | `Ctrl` + `G` | 编组所选对象 | | `Ctrl` + `Shift` + `G` | 取消编组 | | `Delete` 或 `Backspace` | 删除所选标注对象;在峰 / 积分工具中删除所选的峰或区域 | +| `+`(或 `=`)/ `-` | 提高 / 降低所选图的等高线最低层 | | `F2` | 重命名所选数据集或画布 | | `Esc` | 取消进行中的拖动;继续按则依次清除分析范围和各级选择,最后退出当前工具 | | `Ctrl` + `C` | 把选中的图框(未选中时为活动画布)以位图 + 矢量格式复制到剪贴板 | @@ -63,6 +64,11 @@ description: 键盘与鼠标快捷操作。 | `Ctrl` + `,` | 打开首选项 | | `Ctrl` + `K` 或 `Ctrl` + `Shift` + `P` | 打开[命令面板](/zh-cn/reference/command-palette/) | +`+` 与 `-` 作用于所选的图;未选中时作用于当前活动的图,且仅在该图绘制等高线时 +生效。每按一次,最低层按该图自身的层间比值移动一级,因此无论强度量级如何,一次 +按键大致增减一圈等高线。快捷键生效时,当前最低层显示在图的右上角。参见 +[等高线层级](/zh-cn/guides/contour-levels/)。按住 `Ctrl` 时调整的则是 UI 缩放。 + 编辑画板便签时:`Enter` 提交,`Shift` + `Enter` 换行,`Esc` 取消。 ## UI 缩放 diff --git a/docs/src/content/docs/zh-cn/reference/ui-overview.md b/docs/src/content/docs/zh-cn/reference/ui-overview.md index 504640d..2afc8ca 100644 --- a/docs/src/content/docs/zh-cn/reference/ui-overview.md +++ b/docs/src/content/docs/zh-cn/reference/ui-overview.md @@ -31,10 +31,13 @@ PlotX 的界面为英文;手册中加粗的英文词即界面上的原文标 - **任务卡片(task card)**——画布右上角的浮动卡片,引导多步任务 (区域、曲线拟合、统计)。拖动其右下角手柄可调整大小。 - **对象检查器(Object inspector)**——所选画板对象的属性面板:图表 - 类型、样式与几何。 + 类型、样式、几何,以及其绘制内容的显示设置,例如 + [等高线层级](/zh-cn/guides/contour-levels/)。常用设置直接显示,其余收在 + **Advanced**(高级)折叠区中。 - **数据表(Data sheet)**——数据表格的电子表格视图,双击表格打开。 - **命令面板(Command palette)**——Ctrl+K 打开 - 的可搜索命令列表;见[命令面板](/zh-cn/reference/command-palette/)。 + 的可搜索列表,涵盖命令、设置与数据;见 + [命令面板](/zh-cn/reference/command-palette/)。 - **尺寸标签(Size chip)**——页面左上角上方的标签,显示页面尺寸和匹 配到的期刊预设。