diff --git a/AGENTS.md b/AGENTS.md index bdbfbc2..3b1d099 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,12 @@ These instructions apply to the entire repository. - Stable resources and components use typed IDs. Collection indices are one-shot lookup positions only; they must not cross action, job, frame, or persistence boundaries. +- Encoding applicability is attached to field rendering capabilities, never to + a data-domain enum. New domains gain an encoding by exposing its capability; + do not add domain branches or domain names to encoding/property registries. +- A persisted default, an invocation input, and result provenance are lifecycle + copies of one value, not parallel sources of state. Resolve them in priority + order: explicit input, target provenance, then default. - Respect crate boundaries: parsing in `plotx-io`, scientific algorithms in `plotx-analysis`, spectral transforms in `plotx-processing`, presentation models in `plotx-figure`, rendering in `plotx-render`, application state in diff --git a/crates/analysis/src/lib.rs b/crates/analysis/src/lib.rs index 76931b0..2191fc4 100644 --- a/crates/analysis/src/lib.rs +++ b/crates/analysis/src/lib.rs @@ -14,6 +14,7 @@ pub mod multiplet; pub mod peaks; mod pseudo2d_impl; pub mod relaxation; +pub mod robust; pub mod series; mod series_reduce; pub mod stack; diff --git a/crates/analysis/src/robust.rs b/crates/analysis/src/robust.rs new file mode 100644 index 0000000..cb6ccee --- /dev/null +++ b/crates/analysis/src/robust.rs @@ -0,0 +1,202 @@ +//! Robust scale estimators shared by scientific field providers. + +/// Estimate white-noise scale from horizontal and vertical first differences. +/// Differences never cross a row boundary; combining both axes avoids giving a +/// preferred direction to a regular 2D spectrum. +pub fn robust_difference_mad(values: &[f32], rows: usize, cols: usize) -> f64 { + let mut deviations = Vec::new(); + for row in 0..rows { + let Some(offset) = row.checked_mul(cols) else { + return 0.0; + }; + for col in 0..cols.saturating_sub(1) { + let Some(left) = values.get(offset + col) else { + return 0.0; + }; + let Some(right) = values.get(offset + col + 1) else { + return 0.0; + }; + if left.is_finite() && right.is_finite() { + deviations.push(f64::from(*right - *left)); + } + } + } + for row in 0..rows.saturating_sub(1) { + let Some(next_row) = row.checked_add(1).and_then(|next| next.checked_mul(cols)) else { + return 0.0; + }; + let Some(offset) = row.checked_mul(cols) else { + return 0.0; + }; + for col in 0..cols { + let Some(top) = values.get(offset + col) else { + return 0.0; + }; + let Some(bottom) = values.get(next_row + col) else { + return 0.0; + }; + if top.is_finite() && bottom.is_finite() { + deviations.push(f64::from(*bottom - *top)); + } + } + } + mad_scale(&mut deviations).map_or(0.0, |scale| scale / std::f64::consts::SQRT_2) +} + +/// Estimate an AFM-style background after subtracting its least-squares plane. +/// The reported location is that plane at the grid centre while the spread is a +/// MAD of residuals, so a sample tilt cannot turn into contour noise. +pub fn deplaned_location_scale(values: &[f32], rows: usize, cols: usize) -> (f64, f64) { + let Some((offset, slope_x, slope_y)) = least_squares_plane(values, rows, cols) else { + let mut finite = values + .iter() + .copied() + .filter(|value| value.is_finite()) + .map(f64::from) + .collect::>(); + let location = median(&mut finite).unwrap_or(0.0); + let scale = mad_scale(&mut finite).unwrap_or(0.0); + return (location, scale); + }; + let mut residuals = Vec::new(); + for row in 0..rows { + for col in 0..cols { + let Some(index) = row + .checked_mul(cols) + .and_then(|offset| offset.checked_add(col)) + else { + return (0.0, 0.0); + }; + let Some(value) = values.get(index).filter(|value| value.is_finite()) else { + continue; + }; + let plane = offset + slope_x * col as f64 + slope_y * row as f64; + residuals.push(f64::from(*value) - plane); + } + } + let location = offset + + slope_x * cols.saturating_sub(1) as f64 / 2.0 + + slope_y * rows.saturating_sub(1) as f64 / 2.0; + (location, mad_scale(&mut residuals).unwrap_or(0.0)) +} + +fn least_squares_plane(values: &[f32], rows: usize, cols: usize) -> Option<(f64, f64, f64)> { + let mut normal = [[0.0; 3]; 3]; + let mut rhs = [0.0; 3]; + for row in 0..rows { + for col in 0..cols { + let index = row.checked_mul(cols)?.checked_add(col)?; + let value = f64::from(*values.get(index)?); + if !value.is_finite() { + continue; + } + let point = [1.0, col as f64, row as f64]; + for i in 0..3 { + rhs[i] += point[i] * value; + for j in 0..3 { + normal[i][j] += point[i] * point[j]; + } + } + } + } + for pivot in 0..3 { + let best = (pivot..3).max_by(|&left, &right| { + normal[left][pivot] + .abs() + .total_cmp(&normal[right][pivot].abs()) + })?; + if normal[best][pivot].abs() <= f64::EPSILON { + return None; + } + if best != pivot { + normal.swap(pivot, best); + rhs.swap(pivot, best); + } + let divisor = normal[pivot][pivot]; + for value in &mut normal[pivot][pivot..] { + *value /= divisor; + } + rhs[pivot] /= divisor; + let pivot_row = normal[pivot]; + for row in 0..3 { + if row == pivot { + continue; + } + let factor = normal[row][pivot]; + for (value, pivot_value) in normal[row][pivot..] + .iter_mut() + .zip(pivot_row[pivot..].iter()) + { + *value -= factor * *pivot_value; + } + rhs[row] -= factor * rhs[pivot]; + } + } + Some((rhs[0], rhs[1], rhs[2])) +} + +fn mad_scale(values: &mut [f64]) -> Option { + let center = median(values)?; + for value in &mut *values { + *value = (*value - center).abs(); + } + median(values).map(|mad| mad / 0.674_489_750_196_081_7) +} + +fn median(values: &mut [f64]) -> Option { + if values.is_empty() { + return None; + } + let middle = values.len() / 2; + let (_, upper, _) = values.select_nth_unstable_by(middle, f64::total_cmp); + let upper = *upper; + if values.len().is_multiple_of(2) { + let lower = values[..middle].iter().copied().max_by(f64::total_cmp)?; + Some((lower + upper) / 2.0) + } else { + Some(upper) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn white_noise_estimate_is_close_to_its_known_sigma() { + let mut state = 0x4d59_5df4_d0f3_3173_u64; + let mut uniform = || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + (state as f64 / u64::MAX as f64).max(f64::MIN_POSITIVE) + }; + let values = (0..128 * 128) + .map(|_| { + let radius = (-2.0 * uniform().ln()).sqrt(); + (radius * (std::f64::consts::TAU * uniform()).cos()) as f32 + }) + .collect::>(); + let sigma = robust_difference_mad(&values, 128, 128); + assert!((sigma - 1.0).abs() < 0.08, "estimated sigma was {sigma}"); + } + + #[test] + fn correlated_noise_has_a_smaller_difference_scale() { + let values = (0..32) + .flat_map(|row| (0..32).map(move |col| row as f32 + col as f32 * 0.01)) + .collect::>(); + let sigma = robust_difference_mad(&values, 32, 32); + assert!( + sigma < 0.6, + "correlation should reduce the difference scale" + ); + } + + #[test] + fn degenerate_grids_have_zero_scale() { + assert_eq!(robust_difference_mad(&[0.0], 1, 1), 0.0); + assert_eq!(robust_difference_mad(&[0.0, 0.0], 1, 2), 0.0); + assert_eq!(robust_difference_mad(&[0.0, 0.0], 2, 1), 0.0); + } +} diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index 305dce5..d7ef6d4 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -558,7 +558,7 @@ fn resize_cursor(handle: ResizeHandle) -> egui::CursorIcon { mod tests { use super::*; use plotx_core::state::{ - CanvasObject, CanvasObjectKind, CanvasViewport, DatasetId, PanelMeta, PlotObject, TextBox, + CanvasObject, CanvasObjectKind, CanvasViewport, PanelMeta, PlotObject, TextBox, }; use plotx_figure::{Axis, Figure}; @@ -592,7 +592,7 @@ mod tests { group: None, kind: CanvasObjectKind::Plot(Box::new(PlotObject { next_series_id: plotx_core::state::SeriesId::new(1), - binding: plotx_core::state::DataBinding::single(DatasetId::new()), + binding: plotx_core::state::DataBinding { series: Vec::new() }, chart: plotx_core::state::ChartSpec::default(), stack: plotx_core::state::StackSpec::default(), projections: plotx_core::state::AxisProjections::default(), @@ -625,7 +625,7 @@ mod tests { group: None, kind: CanvasObjectKind::Plot(Box::new(PlotObject { next_series_id: plotx_core::state::SeriesId::new(1), - binding: plotx_core::state::DataBinding::single(DatasetId::new()), + binding: plotx_core::state::DataBinding { series: Vec::new() }, chart: plotx_core::state::ChartSpec::default(), stack: plotx_core::state::StackSpec::default(), projections: plotx_core::state::AxisProjections::default(), diff --git a/crates/app/src/ui/object_inspector.rs b/crates/app/src/ui/object_inspector.rs index 481b8bd..461523b 100644 --- a/crates/app/src/ui/object_inspector.rs +++ b/crates/app/src/ui/object_inspector.rs @@ -169,12 +169,12 @@ fn data_section(app: &mut PlotxApp, ci: usize, object: ObjectId, ui: &mut Ui) { } } let color = sb - .color + .primary_color() .unwrap_or(OVERLAY_PALETTE[i % OVERLAY_PALETTE.len()]); swatch(ui, color); let name = app .doc - .dataset_index(sb.dataset) + .dataset_index(sb.source.resource) .and_then(|index| app.doc.datasets.get(index)) .map(Dataset::display_name) .unwrap_or_default(); @@ -222,15 +222,21 @@ fn data_section(app: &mut PlotxApp, ci: usize, object: ObjectId, ui: &mut Ui) { b.series.swap(i, i - 1); next_binding = Some(b); } - let mut scale = sb.scale; - if ui - .add(DragValue::new(&mut scale).speed(0.02).range(0.01..=100.0)) - .on_hover_text("Scale") - .changed() - { - let mut b = binding.clone(); - b.series[i].scale = scale; - next_binding = Some(b); + if matches!(sb.encoding, plotx_figure::SeriesEncoding::Line(_)) { + let mut scale = sb.line_scale(); + if ui + .add(DragValue::new(&mut scale).speed(0.02).range(0.01..=100.0)) + .on_hover_text("Scale") + .changed() + { + let mut b = binding.clone(); + if let plotx_figure::SeriesEncoding::Line(line) = + &mut b.series[i].encoding + { + line.scale = scale; + } + next_binding = Some(b); + } } } else if i != 0 && ui.small_button("Primary").clicked() { let mut b = binding.clone(); @@ -268,11 +274,13 @@ fn data_section(app: &mut PlotxApp, ci: usize, object: ObjectId, ui: &mut Ui) { else { continue; }; - let mut series = - SeriesBinding::new(app.doc.datasets[*di].resource_id()); - series.id = series_id; - b.series.push(series); - next_binding = Some(b); + if let Some(mut series) = + SeriesBinding::from_dataset(&app.doc.datasets[*di]) + { + series.id = series_id; + b.series.push(series); + next_binding = Some(b); + } } } }); diff --git a/crates/app/src/ui/object_inspector/chart_gallery.rs b/crates/app/src/ui/object_inspector/chart_gallery.rs index 284a6c2..02f36d1 100644 --- a/crates/app/src/ui/object_inspector/chart_gallery.rs +++ b/crates/app/src/ui/object_inspector/chart_gallery.rs @@ -4,7 +4,8 @@ use egui::{DragValue, Ui}; use plotx_core::actions::Action; use plotx_core::state::{ - ChartSpec, Dataset, ObjectId, PlotxApp, chart_type, chart_types_for, default_chart_type, + ChartSpec, Dataset, ObjectId, PlotxApp, PresentationProfile, RequestedChart, chart_type, + chart_types_for_capabilities, default_chart_type, default_encoding, encoding_descriptors_for, }; pub(super) fn chart_gallery(app: &mut PlotxApp, ci: usize, object: ObjectId, ui: &mut Ui) { @@ -12,6 +13,7 @@ pub(super) fn chart_gallery(app: &mut PlotxApp, ci: usize, object: ObjectId, ui: return; }; let current = plot.chart.clone(); + let binding = plot.binding.clone(); let Some(primary) = plot .binding .primary_dataset() @@ -20,8 +22,17 @@ pub(super) fn chart_gallery(app: &mut PlotxApp, ci: usize, object: ObjectId, ui: return; }; let domain = app.doc.datasets[primary].domain(); - let types = chart_types_for(domain); - let current_id = if chart_type(¤t.type_id).is_some_and(|c| c.domains.contains(&domain)) { + let Some(primary_series) = binding.series.first() else { + return; + }; + let Some(field) = app.doc.datasets[primary].field_descriptor(primary_series.source.field) + else { + return; + }; + let types = chart_types_for_capabilities(&field.capabilities, domain); + let current_id = if chart_type(¤t.type_id) + .is_some_and(|chart| chart.is_applicable_to(&field.capabilities)) + { current.type_id.clone() } else { default_chart_type(domain).id.to_owned() @@ -108,6 +119,54 @@ pub(super) fn chart_gallery(app: &mut PlotxApp, ci: usize, object: ObjectId, ui: app.execute_action(Action::set_chart_type(ci, object, current, after)); app.session.status = "Changed chart type.".to_owned(); } + + let encodings = visual_encodings(&field.capabilities); + if encodings.len() > 1 { + ui.separator(); + ui.strong("Visual encoding"); + ui.horizontal_wrapped(|ui| { + for descriptor in encodings { + let selected = matches!( + (descriptor.id, &primary_series.encoding), + ("line", plotx_figure::SeriesEncoding::Line(_)) + | ("contour", plotx_figure::SeriesEncoding::Contour(_)) + | ("heatmap", plotx_figure::SeriesEncoding::Heatmap(_)) + | ("image", plotx_figure::SeriesEncoding::Image(_)) + ); + if ui.selectable_label(selected, descriptor.id).clicked() { + let requested = match descriptor.id { + "line" => RequestedChart::Line, + "contour" => RequestedChart::Contour, + "heatmap" => RequestedChart::Heatmap, + "image" => RequestedChart::Image, + _ => continue, + }; + let mut after = binding.clone(); + if let Some(series) = after.series.first_mut() { + series.encoding = default_encoding( + &field.capabilities, + &field.metadata, + requested, + &PresentationProfile::default(), + ); + } + app.execute_action(Action::set_data_binding( + ci, + object, + binding.clone(), + after, + )); + app.session.status = "Changed visual encoding.".to_owned(); + } + } + }); + } +} + +fn visual_encodings( + capabilities: &plotx_core::state::FieldCapabilities, +) -> Vec<&'static plotx_core::state::EncodingDescriptor> { + encoding_descriptors_for(capabilities) } /// Per-chart-type options below the gallery. Drag edits only commit on @@ -216,3 +275,73 @@ fn deferred_drag( } None } + +#[cfg(test)] +mod tests { + use super::*; + use plotx_core::state::{AfmDataset, Dataset, Nmr2DDataset}; + use std::sync::Arc; + + #[test] + fn gallery_offers_contour_for_afm_height_and_heatmap_for_nmr_plane() { + let afm = Dataset::Afm(Box::new(AfmDataset::load(plotx_io::AfmData { + images: vec![plotx_io::AfmImageChannel { + name: "Height".to_owned(), + width: 2, + height: 2, + scan_size_x: 1.0, + scan_size_y: 1.0, + lateral_unit: "nm".to_owned(), + scale: plotx_io::AfmScale { + multiplier: 1.0, + offset: 0.0, + unit: "nm".to_owned(), + }, + raw: Arc::from(vec![0, 1, 2, 3]), + frame_direction: plotx_io::AfmFrameDirection::Trace, + }], + forces: None, + source: "gallery AFM".to_owned(), + import_warnings: Vec::new(), + }))); + let afm_height = afm.field_descriptors().into_iter().next().unwrap(); + assert!( + visual_encodings(&afm_height.capabilities) + .iter() + .any(|encoding| encoding.id == "contour") + ); + + 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, + }; + let nmr = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(plotx_io::NmrData2D { + data: vec![num_complex::Complex64::new(1.0, 0.0); 4], + rows: 2, + cols: 2, + 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: "gallery NMR".to_owned(), + }))); + let nmr_plane = nmr + .field_descriptors() + .into_iter() + .find(|field| field.local_id == "nmr.real") + .unwrap(); + assert!( + visual_encodings(&nmr_plane.capabilities) + .iter() + .any(|encoding| encoding.id == "heatmap") + ); + } +} diff --git a/crates/core/src/actions/tests/integral_curve.rs b/crates/core/src/actions/tests/integral_curve.rs index bb07e21..eef68c0 100644 --- a/crates/core/src/actions/tests/integral_curve.rs +++ b/crates/core/src/actions/tests/integral_curve.rs @@ -154,8 +154,8 @@ fn overlay_only_dataset_does_not_contribute_integrals() { app.doc.datasets.push(secondary); let binding = DataBinding { series: vec![ - SeriesBinding::new(app.doc.datasets[0].resource_id()), - SeriesBinding::new(app.doc.datasets[1].resource_id()), + SeriesBinding::from_dataset(&app.doc.datasets[0]).unwrap(), + SeriesBinding::from_dataset(&app.doc.datasets[1]).unwrap(), ], }; let fig = app.build_stacked_figure(&binding, &StackSpec::default(), [120.0, 80.0]); diff --git a/crates/core/src/actions/tests/linefit.rs b/crates/core/src/actions/tests/linefit.rs index c29054f..13e8570 100644 --- a/crates/core/src/actions/tests/linefit.rs +++ b/crates/core/src/actions/tests/linefit.rs @@ -376,7 +376,7 @@ fn single_table_figure_gains_line_fit_overlays() { assert_eq!(app.doc.datasets[1].line_fits().len(), 1); let fig = app.build_binding_figure( - &DataBinding::single(app.doc.datasets[1].resource_id()), + &DataBinding::single(&app.doc.datasets[1]), &ChartSpec::default_for(DataDomain::Table), &StackSpec::default(), [120.0, 80.0], @@ -423,7 +423,7 @@ fn non_line_table_charts_skip_line_fit_overlays() { "table_surface", ] { let fig = app.build_binding_figure( - &DataBinding::single(app.doc.datasets[1].resource_id()), + &DataBinding::single(&app.doc.datasets[1]), &ChartSpec { type_id: id.to_owned(), ..ChartSpec::default() @@ -450,8 +450,8 @@ fn stacked_figures_exclude_line_fit_overlays() { let binding = DataBinding { series: vec![ - SeriesBinding::new(app.doc.datasets[0].resource_id()), - SeriesBinding::new(app.doc.datasets[1].resource_id()), + SeriesBinding::from_dataset(&app.doc.datasets[0]).unwrap(), + SeriesBinding::from_dataset(&app.doc.datasets[1]).unwrap(), ], }; let stack = StackSpec { @@ -473,8 +473,8 @@ fn single_plot_color_override_leaves_overlay_colors_alone() { )); let override_color = Color::rgb(0x11, 0x22, 0x33); - let mut binding = DataBinding::single(app.doc.datasets[0].resource_id()); - binding.series[0].color = Some(override_color); + let mut binding = DataBinding::single(&app.doc.datasets[0]); + binding.series[0].set_primary_color(override_color); let fig = app.build_binding_figure( &binding, &ChartSpec::default_for(DataDomain::Nmr1d), diff --git a/crates/core/src/actions/tests/more.rs b/crates/core/src/actions/tests/more.rs index 13f1e07..04941b2 100644 --- a/crates/core/src/actions/tests/more.rs +++ b/crates/core/src/actions/tests/more.rs @@ -9,17 +9,19 @@ fn stacked_binding_builds_distinctly_coloured_series_with_legend() { .datasets .push(Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d())))); let object = app.doc.canvases[0].objects[0].id; + let mut second = crate::state::SeriesBinding::from_dataset(&app.doc.datasets[1]).unwrap(); + second.set_primary_color(plotx_figure::Color::rgb(0x8a, 0x1c, 0x1c)); let binding = crate::state::DataBinding { series: vec![ - crate::state::SeriesBinding::new(app.doc.datasets[0].resource_id()), - crate::state::SeriesBinding::new(app.doc.datasets[1].resource_id()), + crate::state::SeriesBinding::from_dataset(&app.doc.datasets[0]).unwrap(), + second, ], }; app.execute_action(Action::set_data_binding( 0, object, - crate::state::DataBinding::single(app.doc.datasets[0].resource_id()), + crate::state::DataBinding::single(&app.doc.datasets[0]), binding, )); @@ -41,8 +43,8 @@ fn single_table_color_override_recolors_points_and_error_bars() { use crate::state::{ChartSpec, DataBinding, DataDomain, SeriesBinding, StackSpec}; let (app, _) = table_app_with_sigma(vec![0.1, 0.1, 0.1]); let color = plotx_figure::Color::rgb(0xaa, 0x22, 0x44); - let mut series = SeriesBinding::new(app.doc.datasets[0].resource_id()); - series.color = Some(color); + let mut series = SeriesBinding::from_dataset(&app.doc.datasets[0]).unwrap(); + series.set_primary_color(color); let figure = app.build_binding_figure( &DataBinding { series: vec![series], @@ -65,8 +67,8 @@ fn single_table_color_override_recolors_bar_polygons() { use crate::state::{ChartSpec, DataBinding, SeriesBinding, StackSpec}; let (app, _) = table_app_with_sigma(vec![0.1, 0.1, 0.1]); let color = plotx_figure::Color::rgb(0xaa, 0x22, 0x44); - let mut series = SeriesBinding::new(app.doc.datasets[0].resource_id()); - series.color = Some(color); + let mut series = SeriesBinding::from_dataset(&app.doc.datasets[0]).unwrap(); + series.set_primary_color(color); let figure = app.build_binding_figure( &DataBinding { series: vec![series], @@ -139,7 +141,7 @@ fn stack_candidates_reject_incompatible_datasets() { .push(Dataset::Nmr2D(Box::new(crate::state::Nmr2DDataset::load( synthetic_2d(), )))); - let binding = crate::state::DataBinding::single(app.doc.datasets[0].resource_id()); + let binding = crate::state::DataBinding::single(&app.doc.datasets[0]); let candidates = app.stack_candidates(&binding); assert!(candidates.contains(&1), "the other 1D spectrum is eligible"); @@ -150,7 +152,7 @@ fn stack_candidates_reject_incompatible_datasets() { // The 2D primary is a Field-stackable domain, but with no other 2D dataset // loaded there is nothing to overlay onto it. - let two_d = crate::state::DataBinding::single(app.doc.datasets[2].resource_id()); + let two_d = crate::state::DataBinding::single(&app.doc.datasets[2]); assert!(app.stack_candidates(&two_d).is_empty()); } diff --git a/crates/core/src/actions/tests/stable_identity.rs b/crates/core/src/actions/tests/stable_identity.rs index 1953f8c..ccb7df6 100644 --- a/crates/core/src/actions/tests/stable_identity.rs +++ b/crates/core/src/actions/tests/stable_identity.rs @@ -2,7 +2,7 @@ use super::{dataset_id, sample_app, synthetic_1d}; use crate::actions::Action; use crate::state::{ AxisProjection, DEFAULT_CANVAS_SIZE_MM, Dataset, DatasetId, DatasetLineage, DerivationKind, - NmrDataset, ObjectFrame, ProjectionSource, SeriesBinding, + FieldId, NmrDataset, ObjectFrame, ProjectionSource, SeriesBinding, SeriesSource, }; use plotx_processing::{ProcessingStep, StepKind, StepSource}; @@ -20,7 +20,12 @@ fn dataset_delete_undo_restores_identity_and_persistent_references() { ); let plot = app.doc.canvases[0].objects[0].plot_mut().unwrap(); - plot.binding.series.push(SeriesBinding::new(inserted_id)); + plot.binding + .series + .push(SeriesBinding::with_source(SeriesSource { + resource: inserted_id, + field: FieldId::default(), + })); plot.projections.top = AxisProjection { source: ProjectionSource::Attached(inserted_id), visible: true, @@ -72,9 +77,18 @@ fn canvas_dataset_ids_follow_first_appearance_and_page_indices_follow_document_o let canvas = &mut app.doc.canvases[0]; canvas.objects[0].plot_mut().unwrap().binding.series = vec![ - SeriesBinding::new(ids[2]), - SeriesBinding::new(ids[0]), - SeriesBinding::new(ids[2]), + SeriesBinding::with_source(SeriesSource { + resource: ids[2], + field: FieldId::default(), + }), + SeriesBinding::with_source(SeriesSource { + resource: ids[0], + field: FieldId::default(), + }), + SeriesBinding::with_source(SeriesSource { + resource: ids[2], + field: FieldId::default(), + }), ]; let object_id = canvas.allocate_object_id(); let second_plot = app.build_plot_object( @@ -108,7 +122,10 @@ fn series_reorder_preserves_ids_and_only_changes_order() { app.doc.datasets.push(second); let plot = app.doc.canvases[0].objects[0].plot_mut().unwrap(); let id = plot.allocate_series_id(); - let mut series = SeriesBinding::new(second_id); + let mut series = SeriesBinding::with_source(SeriesSource { + resource: second_id, + field: FieldId::default(), + }); series.id = id; plot.binding.series.push(series); let before: Vec<_> = plot.binding.series.iter().map(|series| series.id).collect(); @@ -154,7 +171,10 @@ fn step_and_series_allocators_do_not_rollback_with_undo() { let series_id = plot.allocate_series_id(); let high_water = plot.next_series_id; let mut after = before.clone(); - let mut series = SeriesBinding::new(second_id); + let mut series = SeriesBinding::with_source(SeriesSource { + resource: second_id, + field: FieldId::default(), + }); series.id = series_id; after.series.push(series); (series_id, high_water, before, after, object_id) diff --git a/crates/core/src/actions/tests/stack.rs b/crates/core/src/actions/tests/stack.rs index 48a52e9..c007f6c 100644 --- a/crates/core/src/actions/tests/stack.rs +++ b/crates/core/src/actions/tests/stack.rs @@ -18,8 +18,8 @@ fn stacked_figure_is_domain_generic_with_offset_scale_and_hide() { let chart = ChartSpec::default_for(domain); let binding = DataBinding { series: vec![ - SeriesBinding::new(app.doc.datasets[0].resource_id()), - SeriesBinding::new(app.doc.datasets[1].resource_id()), + SeriesBinding::from_dataset(&app.doc.datasets[0]).unwrap(), + SeriesBinding::from_dataset(&app.doc.datasets[1]).unwrap(), ], }; let sup = app.build_binding_figure(&binding, &chart, &StackSpec::default(), size); @@ -53,7 +53,9 @@ fn stacked_figure_is_domain_generic_with_offset_scale_and_hide() { } let mut scaled = binding.clone(); - scaled.series[0].scale = 3.0; + if let plotx_figure::SeriesEncoding::Line(line) = &mut scaled.series[0].encoding { + line.scale = 3.0; + } let fig_s = app.build_binding_figure(&scaled, &chart, &StackSpec::default(), size); assert!(fig_s.y.max > sup.y.max, "scaling up raises the peak"); if domain == DataDomain::Table { @@ -66,21 +68,27 @@ fn stacked_figure_is_domain_generic_with_offset_scale_and_hide() { fn field_overlay_stacks_two_2d_contours_in_distinct_colors() { use crate::state::{ChartSpec, DataBinding, DataDomain, SeriesBinding, StackMode, StackSpec}; let mut app = sample_app(); + let mut signed_grid = synthetic_2d(); + signed_grid.domain = plotx_io::Domain::Frequency; + for (index, value) in signed_grid.data.iter_mut().enumerate() { + let column = index % signed_grid.cols; + *value = num_complex::Complex64::new(column as f64 - 15.5, 0.0); + } app.doc .datasets .push(Dataset::Nmr2D(Box::new(crate::state::Nmr2DDataset::load( - synthetic_2d(), + signed_grid.clone(), )))); app.doc .datasets .push(Dataset::Nmr2D(Box::new(crate::state::Nmr2DDataset::load( - synthetic_2d(), + signed_grid, )))); let (a, b) = (app.doc.datasets.len() - 2, app.doc.datasets.len() - 1); let binding = DataBinding { series: vec![ - SeriesBinding::new(app.doc.datasets[a].resource_id()), - SeriesBinding::new(app.doc.datasets[b].resource_id()), + SeriesBinding::from_dataset(&app.doc.datasets[a]).unwrap(), + SeriesBinding::from_dataset(&app.doc.datasets[b]).unwrap(), ], }; let chart = ChartSpec::default_for(DataDomain::Nmr2d); @@ -91,13 +99,14 @@ fn field_overlay_stacks_two_2d_contours_in_distinct_colors() { let fig = app.build_binding_figure(&binding, &chart, &stack, [120.0, 80.0]); assert!(fig.show_legend, "a Field overlay shows a legend"); - assert!( - fig.contours.len() >= 2, - "each 2D dataset contributes its own contour" + assert_eq!( + fig.contours.len(), + 4, + "each signed dataset contributes both signs" ); assert_ne!( fig.contours[0].color, fig.contours[1].color, - "overlaid datasets get distinct palette colours" + "a signed field keeps its positive and negative contours distinct" ); } @@ -148,14 +157,14 @@ fn selecting_canvas_populates_data_selection_with_its_datasets() { let object = app.doc.canvases[0].objects[0].id; let binding = crate::state::DataBinding { series: vec![ - crate::state::SeriesBinding::new(app.doc.datasets[0].resource_id()), - crate::state::SeriesBinding::new(app.doc.datasets[1].resource_id()), + crate::state::SeriesBinding::from_dataset(&app.doc.datasets[0]).unwrap(), + crate::state::SeriesBinding::from_dataset(&app.doc.datasets[1]).unwrap(), ], }; app.execute_action(Action::set_data_binding( 0, object, - crate::state::DataBinding::single(app.doc.datasets[0].resource_id()), + crate::state::DataBinding::single(&app.doc.datasets[0]), binding, )); @@ -183,14 +192,14 @@ fn plot_object_reports_every_bound_dataset_for_selection_mirroring() { let object = app.doc.canvases[0].objects[0].id; let binding = DataBinding { series: vec![ - SeriesBinding::new(app.doc.datasets[0].resource_id()), - SeriesBinding::new(app.doc.datasets[1].resource_id()), + SeriesBinding::from_dataset(&app.doc.datasets[0]).unwrap(), + SeriesBinding::from_dataset(&app.doc.datasets[1]).unwrap(), ], }; app.execute_action(Action::set_data_binding( 0, object, - DataBinding::single(app.doc.datasets[0].resource_id()), + DataBinding::single(&app.doc.datasets[0]), binding, )); @@ -218,8 +227,8 @@ fn shear_sign_flips_the_pseudo_3d_lean_direction() { let chart = ChartSpec::default_for(DataDomain::Table); let binding = DataBinding { series: vec![ - SeriesBinding::new(table.doc.datasets[0].resource_id()), - SeriesBinding::new(table.doc.datasets[1].resource_id()), + SeriesBinding::from_dataset(&table.doc.datasets[0]).unwrap(), + SeriesBinding::from_dataset(&table.doc.datasets[1]).unwrap(), ], }; let size = [120.0, 80.0]; diff --git a/crates/core/src/automation/resources.rs b/crates/core/src/automation/resources.rs index 57545a0..fe317ee 100644 --- a/crates/core/src/automation/resources.rs +++ b/crates/core/src/automation/resources.rs @@ -10,6 +10,7 @@ pub const KIND_DATASET: &str = "plotx.dataset"; pub const KIND_CANVAS: &str = "plotx.canvas"; pub const KIND_TABLE_ROW: &str = "plotx.table.row"; pub const KIND_TABLE_COLUMN: &str = "plotx.table.column"; +pub const KIND_FIELD: &str = "plotx.field"; pub const KIND_CANVAS_OBJECT: &str = "plotx.canvas.object"; pub const KIND_EXTERNAL_INPUT: &str = "plotx.external_input"; @@ -20,6 +21,22 @@ pub const CAP_EXPORT: &str = "figure.export"; pub const CAP_PREVIEW: &str = "data.preview"; pub const CAP_TRANSFORM: &str = "data.transform"; pub const CAP_PROCESSING_SCHEME: &str = "processing.scheme"; +pub const CAP_FIELD_SCALAR_GRID_2D_REGULAR: &str = "field.scalar_grid_2d.regular"; +pub const CAP_FIELD_CURVE_1D: &str = "field.curve_1d"; +pub const CAP_FIELD_COLORED_RASTER_2D: &str = "field.colored_raster_2d"; +pub const CAP_FIELD_SIGNED: &str = "field.signed"; +pub const CAP_FIELD_NOISE_SCALE: &str = "field.noise_scale"; +pub const CAP_FIELD_LOCATION_SCALE: &str = "field.location_scale"; +pub const CAP_FIELD_BOUNDED: &str = "field.bounded"; +/// Provider semantics consumed only by legacy chart builders. They keep those +/// builders from masquerading as universally applicable curve/grid encodings. +pub const CAP_FIELD_TABLE: &str = "field.tabular"; +pub const CAP_FIELD_NMR_SPECTRUM: &str = "field.nmr.spectrum"; +pub const CAP_FIELD_NMR_CONTOUR: &str = "field.nmr.contour"; +pub const CAP_FIELD_NMR_STACK: &str = "field.nmr.stack"; +pub const CAP_FIELD_SWEEP_COLLECTION: &str = "field.sweep_collection"; +pub const CAP_FIELD_FORCE_CURVE: &str = "field.force_curve"; +pub const CAP_FIELD_AFM_MAP: &str = "field.afm.map"; /// Capability-oriented resource access. New resource types can participate by /// implementing this trait; query and tool orchestration do not dispatch on a @@ -67,7 +84,7 @@ impl<'a> ProjectResourceProvider<'a> { let mut metadata = BTreeMap::new(); metadata.insert("domain".to_owned(), format!("{:?}", dataset.domain())); metadata.insert("index_hint".to_owned(), index.to_string()); - let (dimensions, units, children) = match dataset { + let (dimensions, units, mut children) = match dataset { Dataset::Table(table) => { capabilities.push(cap(CAP_TRANSFORM)); let snapshot = &table.typed_state.envelope.revision.snapshot; @@ -137,6 +154,12 @@ impl<'a> ProjectResourceProvider<'a> { (dimensions, units, Vec::new()) } }; + children.extend( + dataset + .field_descriptors() + .into_iter() + .map(|field| child_ref(id, &field.local_id, KIND_FIELD)), + ); ResourceDescriptor { resource: top_ref(id, KIND_DATASET), name: dataset.display_name(), @@ -253,6 +276,19 @@ impl ResourceProvider for ProjectResourceProvider<'_> { }); } } + for field in dataset.field_descriptors() { + descriptors.push(ResourceDescriptor { + resource: child_ref(dataset.resource_id(), &field.local_id, KIND_FIELD), + name: field.name, + capabilities: field.capabilities.iter().cloned().collect(), + children: Vec::new(), + dimensions: field.dimensions, + units: field.units, + metadata: field.metadata.0, + lineage: Vec::new(), + revision: self.revision(), + }); + } descriptors.push(parent); } for index in 0..self.app.doc.canvases.len() { diff --git a/crates/core/src/automation/types.rs b/crates/core/src/automation/types.rs index 44595df..9125bd5 100644 --- a/crates/core/src/automation/types.rs +++ b/crates/core/src/automation/types.rs @@ -20,6 +20,12 @@ impl ResourceKindId { #[serde(transparent)] pub struct CapabilityId(pub String); +impl std::borrow::Borrow for CapabilityId { + fn borrow(&self) -> &str { + &self.0 + } +} + impl CapabilityId { pub fn new(value: impl Into) -> Self { Self(value.into()) diff --git a/crates/core/src/export/precheck.rs b/crates/core/src/export/precheck.rs index 25d65fd..5e44a93 100644 --- a/crates/core/src/export/precheck.rs +++ b/crates/core/src/export/precheck.rs @@ -275,7 +275,7 @@ mod tests { group: None, kind: CanvasObjectKind::Plot(Box::new(PlotObject { next_series_id: crate::state::SeriesId::new(1), - binding: DataBinding::single(crate::state::DatasetId::new()), + binding: DataBinding { series: Vec::new() }, chart: ChartSpec::default(), stack: StackSpec::default(), projections: AxisProjections::default(), diff --git a/crates/core/src/figures.rs b/crates/core/src/figures.rs index 0f4fbf8..80f123f 100644 --- a/crates/core/src/figures.rs +++ b/crates/core/src/figures.rs @@ -2,11 +2,15 @@ use plotx_analysis::diffusion::DiffusionMap; use plotx_analysis::ilt::IltResult; -use plotx_figure::{Annotation, Axis, AxisFrame, Color, Contour, Figure, Series}; +use plotx_analysis::robust::{deplaned_location_scale, robust_difference_mad}; +use plotx_figure::{ + Annotation, Axis, AxisFrame, Color, Contour, ContourBasePolicy, ContourLevelSpec, ContourSpec, + Figure, Series, +}; use plotx_io::NmrData; use plotx_processing::{Preset2D, Spectrum, Spectrum2D, StackSpectrum}; -use crate::state::ResolvedPeak; +use crate::state::{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(); @@ -35,22 +39,18 @@ pub fn apply_peak_labels(mut fig: Figure, peaks: &[ResolvedPeak]) -> Figure { fig } -/// Contour lowest level as a fraction of the peak, and the number/ratio of -/// geometric levels drawn above it. -pub const CONTOUR_BASE_FRAC: f64 = 0.04; -pub const CONTOUR_LEVELS: usize = 14; -pub const CONTOUR_RATIO: f64 = 1.35; - /// Build a contour figure from a processed true-2D spectrum. F2 (direct) is the /// x-axis with high ppm on the left; F1 (indirect) is the y-axis with high ppm /// at the bottom (low ppm at the top) — the standard 2D NMR orientation. -pub fn build_figure_2d(spec: &Spectrum2D, preset: Preset2D) -> Figure { - build_figure_2d_cancellable(spec, preset, &|| false).expect("non-cancelling contour figure") +pub fn build_figure_2d(spec: &Spectrum2D, preset: Preset2D, contour: &ContourSpec) -> Figure { + build_figure_2d_cancellable(spec, preset, contour, &|| false) + .expect("non-cancelling contour figure") } pub fn build_figure_2d_cancellable( spec: &Spectrum2D, preset: Preset2D, + contour: &ContourSpec, cancelled: &impl Fn() -> bool, ) -> Option
{ if cancelled() { @@ -68,42 +68,189 @@ pub fn build_figure_2d_cancellable( if spec.f2_size >= 2 && spec.f1_size >= 2 { let z = spec.real(); - // The real (absorption) plane carries signed lobes, so mirror the positive - // geometric levels to negative. - let peak = z.iter().fold(0.0f32, |m, &v| m.max(v.abs())) as f64; - let positive = plotx_render::contour::geometric_levels( - peak * CONTOUR_BASE_FRAC, - peak, - CONTOUR_LEVELS, - CONTOUR_RATIO, - ); - let levels: Vec = positive - .iter() - .rev() - .map(|l| -l) - .chain(positive.iter().copied()) - .collect(); // Grid columns run low→high ppm (index 0 = f2_ppm[0]); rows likewise. - let segments = plotx_render::contour::segments_cancellable( - &z, - spec.f1_size, - spec.f2_size, + let bounds = [ spec.f2_ppm[0], spec.f2_ppm[spec.f2_size - 1], spec.f1_ppm[0], spec.f1_ppm[spec.f1_size - 1], - &levels, - cancelled, - )?; - fig = fig.with_contour(Contour { - segments, - color: Color::TRACE, - width: 0.7, - }); + ]; + let positive = contour_levels(&z, spec.f1_size, spec.f2_size, &contour.positive, false); + let segments = if positive.is_empty() { + Vec::new() + } else { + contour_segments(&z, spec.f1_size, spec.f2_size, bounds, &positive, cancelled)? + }; + if !segments.is_empty() { + fig = fig.with_contour(Contour { + segments, + color: contour.style.positive_color.resolve(), + width: contour.style.width.get(), + }); + } + if let Some(negative) = contour.negative.as_ref() { + let levels = contour_levels(&z, spec.f1_size, spec.f2_size, negative, true); + let segments = if levels.is_empty() { + Vec::new() + } else { + contour_segments(&z, spec.f1_size, spec.f2_size, bounds, &levels, cancelled)? + }; + if !segments.is_empty() { + fig = fig.with_contour(Contour { + segments, + color: contour.style.negative_color.resolve(), + width: contour.style.width.get(), + }); + } + } } Some(fig) } +fn contour_segments( + values: &[f32], + rows: usize, + cols: usize, + bounds: [f64; 4], + levels: &[f64], + cancelled: &impl Fn() -> bool, +) -> Option> { + plotx_render::contour::segments_cancellable( + values, rows, cols, bounds[0], bounds[1], bounds[2], bounds[3], levels, cancelled, + ) +} + +pub(crate) fn scalar_contour_overlays( + values: &[f32], + rows: usize, + cols: usize, + bounds: [f64; 4], + contour: &ContourSpec, +) -> Vec { + scalar_contour_overlays_cancellable(values, rows, cols, bounds, contour, &|| false) + .unwrap_or_default() +} + +pub(crate) fn scalar_contour_overlays_cancellable( + values: &[f32], + rows: usize, + cols: usize, + bounds: [f64; 4], + contour: &ContourSpec, + cancelled: &impl Fn() -> bool, +) -> Option> { + if cancelled() { + return None; + } + let mut overlays = Vec::new(); + let positive = contour_levels(values, rows, cols, &contour.positive, false); + if !positive.is_empty() { + let segments = contour_segments(values, rows, cols, bounds, &positive, cancelled)?; + if !segments.is_empty() { + overlays.push(Contour { + segments, + color: contour.style.positive_color.resolve(), + width: contour.style.width.get(), + }); + } + } + if let Some(negative) = contour.negative.as_ref() { + let levels = contour_levels(values, rows, cols, negative, true); + if !levels.is_empty() { + let segments = contour_segments(values, rows, cols, bounds, &levels, cancelled)?; + if !segments.is_empty() { + overlays.push(Contour { + segments, + color: contour.style.negative_color.resolve(), + width: contour.style.width.get(), + }); + } + } + } + Some(overlays) +} + +/// Resolve a level policy directly for the current model-layer renderer. The +/// phase-4 cache/job split will memoize estimates; this pure calculation keeps +/// policy state out of marching squares in the meantime. +fn contour_levels( + values: &[f32], + rows: usize, + cols: usize, + level: &ContourLevelSpec, + negative: bool, +) -> Vec { + let Some((minimum, maximum)) = finite_range(values) else { + return Vec::new(); + }; + let peak = if negative { + -minimum.min(0.0) + } else { + maximum.max(0.0) + }; + if peak <= 0.0 { + return Vec::new(); + } + let mut base = match &level.base { + ContourBasePolicy::Absolute(value) => value.get(), + ContourBasePolicy::NoiseSigma { multiplier, .. } => { + robust_difference_mad(values, rows, cols) * multiplier.get() + } + ContourBasePolicy::BackgroundScale { multiplier, .. } => { + let (location, scale) = deplaned_location_scale(values, rows, cols); + (location + multiplier.get() * scale).abs() + } + ContourBasePolicy::FractionOfRange(fraction) => { + minimum + fraction.get() * (maximum - minimum) + } + }; + if level.count == 1 { + // One level always means one visible, interior contour. A policy at the + // exact peak has no crossing, so degenerate estimates resolve halfway + // to the signed peak rather than silently yielding an empty figure. + if !base.is_finite() || base <= 0.0 || base >= peak { + base = peak / 2.0; + } + return (base > 0.0 && base < peak) + .then_some(if negative { -base } else { base }) + .into_iter() + .collect(); + } + if !base.is_finite() || base <= 0.0 || base >= peak { + // A perfectly synthetic/noiseless grid has a zero MAD estimate. Use + // the user-selected ladder span rather than restoring a hidden peak + // fraction, so the concrete `ContourLevelSpec` still controls output. + base = peak + / level + .ratio + .get() + .powi(i32::from(level.count.saturating_sub(1))); + } + if !base.is_finite() || base <= 0.0 || base >= peak { + return Vec::new(); + } + let levels = plotx_render::contour::geometric_levels( + base, + peak, + usize::from(level.count), + level.ratio.get(), + ); + if negative { + levels.into_iter().map(|value| -value).collect() + } else { + levels + } +} + +fn finite_range(values: &[f32]) -> Option<(f64, f64)> { + let mut finite = values.iter().copied().filter(|value| value.is_finite()); + let first = f64::from(finite.next()?); + Some(finite.fold((first, first), |(minimum, maximum), value| { + let value = f64::from(value); + (minimum.min(value), maximum.max(value)) + })) +} + /// Build a waterfall figure from a pseudo-2D stack: the direct-dimension /// spectrum of each increment, offset vertically. Increments are strided so at /// most `MAX_STACK_TRACES` are drawn. @@ -235,23 +382,18 @@ pub fn build_dosy_figure_cancellable( } } } - let peak = grid.iter().cloned().fold(0.0f32, f32::max) as f64; let mut fig = Figure::new(format!("DOSY — {source}"), x, y).with_axis_frame(AxisFrame::Box); - if peak > 0.0 { - let levels = plotx_render::contour::geometric_levels( - peak * CONTOUR_BASE_FRAC, - peak, - CONTOUR_LEVELS, - CONTOUR_RATIO, - ); + let contour = bounded_scalar_contour_spec(); + let levels = contour_levels(&grid, NY, NX, &contour.positive, false); + if !levels.is_empty() { // Grid rows map onto [logd_lo, logd_hi], cols onto [ppm_lo, ppm_hi]. let segments = plotx_render::contour::segments_cancellable( &grid, NY, NX, ppm_lo, ppm_hi, logd_lo, logd_hi, &levels, cancelled, )?; fig = fig.with_contour(Contour { segments, - color: Color::TRACE, - width: 0.7, + color: contour.style.positive_color.resolve(), + width: contour.style.width.get(), }); } Some(fig) @@ -311,16 +453,11 @@ pub fn build_ilt_figure_cancellable( grid[r * nx + c] = a as f32; } } - let peak = grid.iter().cloned().fold(0.0f32, f32::max) as f64; let mut fig = Figure::new(format!("DOSY (ILT) — {source}"), x, y).with_axis_frame(AxisFrame::Box); - if peak > 0.0 { - let levels = plotx_render::contour::geometric_levels( - peak * CONTOUR_BASE_FRAC, - peak, - CONTOUR_LEVELS, - CONTOUR_RATIO, - ); + let contour = bounded_scalar_contour_spec(); + let levels = contour_levels(&grid, ny, nx, &contour.positive, false); + if !levels.is_empty() { let segments = plotx_render::contour::segments_cancellable( &grid, ny, @@ -334,13 +471,18 @@ pub fn build_ilt_figure_cancellable( )?; fig = fig.with_contour(Contour { segments, - color: Color::TRACE, - width: 0.7, + color: contour.style.positive_color.resolve(), + width: contour.style.width.get(), }); } Some(fig) } +fn bounded_scalar_contour_spec() -> ContourSpec { + let capabilities = scalar_grid_capabilities(true, &[crate::automation::CAP_FIELD_BOUNDED]); + default_contour_spec(&capabilities) +} + #[cfg(test)] mod tests { use super::*; @@ -376,7 +518,11 @@ mod tests { // share the projector, so a wrong flip is invisible in preview). #[test] fn contour_places_low_f1_ppm_near_the_top() { - let fig = build_figure_2d(&spectrum_2d(), Preset2D::Hsqc); + let fig = build_figure_2d( + &spectrum_2d(), + Preset2D::Hsqc, + &bounded_scalar_contour_spec(), + ); assert_eq!(fig.axis_frame, AxisFrame::Box); let proj = Projector::new(&fig, Rect::new(0.0, 0.0, 400.0, 300.0), &Margins::default()); let (_, py_low_ppm) = proj.project([1.5, 0.0]); @@ -394,4 +540,61 @@ mod tests { assert_eq!(axis_label("1H"), "¹H chemical shift (ppm)"); assert_eq!(axis_label("F"), "F chemical shift (ppm)"); } + + #[test] + fn background_scale_removes_a_planar_afm_tilt_before_measuring_mad() { + let values = (0..4) + .flat_map(|row| (0..4).map(move |col| 10.0 + 3.0 * col as f32 + 2.0 * row as f32)) + .collect::>(); + let (location, scale) = deplaned_location_scale(&values, 4, 4); + + assert!((location - 17.5).abs() < 1e-10); + assert!(scale < 1e-10, "a plane is not background roughness"); + } + + #[test] + fn finite_range_preserves_signed_and_baselined_fields() { + assert_eq!(finite_range(&[-40.0, -5.0]), Some((-40.0, -5.0))); + assert_eq!(finite_range(&[500.0, 600.0]), Some((500.0, 600.0))); + assert_eq!(finite_range(&[f32::NAN, f32::INFINITY]), None); + } + + #[test] + fn fraction_of_range_starts_from_the_field_minimum() { + let level = ContourLevelSpec { + base: ContourBasePolicy::FractionOfRange( + plotx_figure::UnitInterval::new(0.04).unwrap(), + ), + count: 1, + ratio: plotx_figure::PositiveFiniteF64::new(1.35).unwrap(), + }; + assert_eq!( + contour_levels(&[500.0, 600.0], 1, 2, &level, false), + [504.0] + ); + } + + #[test] + fn all_negative_fields_get_negative_levels() { + let level = ContourLevelSpec { + base: ContourBasePolicy::Absolute(plotx_figure::PositiveFiniteF64::new(10.0).unwrap()), + count: 1, + ratio: plotx_figure::PositiveFiniteF64::new(1.35).unwrap(), + }; + assert_eq!(contour_levels(&[-40.0, -5.0], 1, 2, &level, true), [-10.0]); + assert!(contour_levels(&[-40.0, -5.0], 1, 2, &level, false).is_empty()); + } + + #[test] + fn one_level_contour_uses_an_interior_fallback_for_degenerate_mad() { + let level = ContourLevelSpec { + base: ContourBasePolicy::NoiseSigma { + multiplier: plotx_figure::PositiveFiniteF64::new(5.0).unwrap(), + estimator: plotx_figure::EstimatorSelection::FollowLatest, + }, + count: 1, + ratio: plotx_figure::PositiveFiniteF64::new(1.35).unwrap(), + }; + assert_eq!(contour_levels(&[0.0, 2.0], 1, 2, &level, false), [1.0]); + } } diff --git a/crates/core/src/project/convert.rs b/crates/core/src/project/convert.rs index 22e1105..0aeb841 100644 --- a/crates/core/src/project/convert.rs +++ b/crates/core/src/project/convert.rs @@ -3,6 +3,7 @@ use super::convert_recipes::{nmr2d_recipe_extensions, read_regions}; use super::electrophysiology_convert::{ electrophysiology_from_object, electrophysiology_to_objects, }; +use super::field_catalog::{read as read_field_catalog, validate_series}; use super::*; use crate::state::SeriesId; pub fn dataset_to_objects( @@ -28,7 +29,8 @@ pub fn dataset_to_objects( extensions: serde_json::json!({ "plotx.nmr": { "source": &n.data.source - } + }, + "plotx.fields": &n.field_catalog }), }; let recipe = RecipeObject { @@ -81,7 +83,8 @@ pub fn dataset_to_objects( "experiment_hint": &n.data.experiment, "pseudo_axis": n.data.pseudo_axis.as_ref().map(pseudo_axis_to_dto), "diffusion": n.data.diffusion.as_ref().map(diffusion_to_dto), - } + }, + "plotx.fields": &n.field_catalog }), }; let recipe = RecipeObject { @@ -135,9 +138,9 @@ pub fn dataset_to_objects( }, extensions: serde_json::json!({ "plotx.afm": { - "selected_channel": afm.selected_channel, "selected_pixel": afm.selected_pixel - } + }, + "plotx.fields": &afm.field_catalog }), }; let recipe = RecipeObject { @@ -165,24 +168,24 @@ pub fn object_to_dataset( let raw = read_bytes(zip, &data.payload.blob)?; let decoded = super::afm_convert::decode_afm(&raw)?; let mut dataset = crate::state::AfmDataset::load(decoded); + dataset.field_catalog = read_field_catalog(data)?; dataset.name = data.label.clone(); - if let Some(state) = data.extensions.get("plotx.afm") { - dataset.selected_channel = state - .get("selected_channel") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0) as usize; - if let Some(pixel) = state + if let Some(state) = data.extensions.get("plotx.afm") + && let Some(pixel) = state .get("selected_pixel") .and_then(serde_json::Value::as_array) - && let (Some(x), Some(y)) = ( - pixel.first().and_then(serde_json::Value::as_u64), - pixel.get(1).and_then(serde_json::Value::as_u64), - ) - { - dataset.selected_pixel = [x as usize, y as usize]; - } + && let (Some(x), Some(y)) = ( + pixel.first().and_then(serde_json::Value::as_u64), + pixel.get(1).and_then(serde_json::Value::as_u64), + ) + { + dataset.selected_pixel = [x as usize, y as usize]; } - return Ok(Dataset::Afm(Box::new(dataset))); + let dataset = Dataset::Afm(Box::new(dataset)); + dataset + .validate_field_catalog() + .map_err(ProjectError::Invalid)?; + return Ok(dataset); } if data.classification.domain == "electrophysiology" && data.classification.object == "recording" @@ -234,10 +237,15 @@ pub fn object_to_dataset( source: nmr_source(data), group_delay: dim.group_delay.unwrap_or(0.0), }); + dataset.field_catalog = read_field_catalog(data)?; apply_1d_recipe(&mut dataset, recipe)?; dataset.name = data.label.clone(); dataset.retransform(); - Ok(Dataset::Nmr(Box::new(dataset))) + let dataset = Dataset::Nmr(Box::new(dataset)); + dataset + .validate_field_catalog() + .map_err(ProjectError::Invalid)?; + Ok(dataset) } 2 => { let rows = *data @@ -289,6 +297,7 @@ pub fn object_to_dataset( nus: None, source: nmr_source(data), }); + dataset.field_catalog = read_field_catalog(data)?; apply_2d_recipe(&mut dataset, recipe); read_regions(&mut dataset, recipe); read_integrals_2d(&mut dataset, recipe)?; @@ -297,7 +306,11 @@ pub fn object_to_dataset( if let Err(error) = dataset.recompute_integrals() { dataset.integral_error = Some(error.to_string()); } - Ok(Dataset::Nmr2D(Box::new(dataset))) + let dataset = Dataset::Nmr2D(Box::new(dataset)); + dataset + .validate_field_catalog() + .map_err(ProjectError::Invalid)?; + Ok(dataset) } n => Err(ProjectError::Unsupported(format!( "NMR acquisitions with {n} dimensions" @@ -454,19 +467,25 @@ pub fn canvas_to_view( .map(|sb| { let dataset = datasets .iter() - .find(|dataset| dataset.resource_id() == sb.dataset) + .find(|dataset| dataset.resource_id() == sb.source.resource) .ok_or_else(|| { ProjectError::Invalid(format!( "view {view_id} plot {} references missing series dataset {}", - object.id, sb.dataset + object.id, sb.source.resource )) })?; + validate_series( + dataset, + sb.source.field, + &sb.encoding, + &format!("view {view_id} plot {} series {}", object.id, sb.id), + )?; Ok(SeriesBindingDto { - id: Some(sb.id.get()), + id: sb.id.get(), input: format!("recipe_{}", dataset.resource_id()), - color: sb.color.map(|c| [c.r, c.g, c.b]), + field: sb.source.field.get(), label: sb.label.clone(), - scale: sb.scale, + encoding: sb.encoding.clone(), visible: sb.visible, }) }) @@ -600,26 +619,39 @@ pub fn view_to_canvas( )) }) }; - let binding = if view_object.series.is_empty() { - let index = resolve(&view_object.input)?; - DataBinding::single(app.doc.datasets[index].resource_id()) - } else { - let mut series = Vec::with_capacity(view_object.series.len()); - for (position, sb) in view_object.series.iter().enumerate() { - series.push(SeriesBinding { - id: SeriesId::new(sb.id.unwrap_or(position as u64)), - dataset: { - let index = resolve(&sb.input)?; - app.doc.datasets[index].resource_id() - }, - color: sb.color.map(|c| plotx_figure::Color::rgb(c[0], c[1], c[2])), - label: sb.label.clone(), - scale: sb.scale, - visible: sb.visible, - }); - } - DataBinding { series } - }; + if view_object.series.is_empty() { + return Err(ProjectError::Invalid(format!( + "view {view_id} plot {} has no series bindings", + view_object.id + ))); + } + let mut series = Vec::with_capacity(view_object.series.len()); + for sb in &view_object.series { + let index = resolve(&sb.input)?; + let dataset = app.doc.datasets.get(index).ok_or_else(|| { + ProjectError::Invalid(format!( + "view {view_id} references unavailable dataset index {index}" + )) + })?; + let field = crate::state::FieldId::new(sb.field); + validate_series( + dataset, + field, + &sb.encoding, + &format!("view {view_id} series {}", sb.id), + )?; + series.push(SeriesBinding { + id: SeriesId::new(sb.id), + source: crate::state::SeriesSource { + resource: dataset.resource_id(), + field, + }, + label: sb.label.clone(), + encoding: sb.encoding.clone(), + visible: sb.visible, + }); + } + let binding = DataBinding { series }; let stack = view_object .stack .clone() @@ -634,12 +666,8 @@ pub fn view_to_canvas( view.id )) })?; - let domain = app - .doc - .datasets - .get(di) - .map(Dataset::domain) - .unwrap_or(crate::state::DataDomain::Nmr1d); + // `dataset_index` above searched this same immutable vector. + let domain = app.doc.datasets[di].domain(); let chart = crate::state::ChartSpec { type_id: view_object .chart_type diff --git a/crates/core/src/project/dto.rs b/crates/core/src/project/dto.rs index 21c6ffb..c716f70 100644 --- a/crates/core/src/project/dto.rs +++ b/crates/core/src/project/dto.rs @@ -353,7 +353,6 @@ pub struct ViewCanvasObject { /// it when zero keeps it out of text/shape/label objects, where it is noise. #[serde(default, skip_serializing_if = "is_zero_u64")] pub next_series_id: u64, - #[serde(default, skip_serializing_if = "Vec::is_empty")] pub series: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub chart_type: Option, @@ -416,31 +415,21 @@ fn caption_visible_default() -> bool { #[derive(Serialize, Deserialize, Clone)] pub struct SeriesBindingDto { - /// Owner-local series identity. Optional on read: a series list written - /// without ids falls back to positional numbering, which keeps the entries - /// distinct — a plain zero default would collapse every series onto id 0. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub id: Option, + /// Owner-local series identity. It is mandatory because a persisted series + /// is an addressable component, not a position in an overlay list. + pub id: u64, pub input: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub color: Option<[u8; 3]>, + /// Dataset-local stable field identity. + pub field: u64, #[serde(default, skip_serializing_if = "Option::is_none")] pub label: Option, - /// Multiplier applied before stacking; defaults to 1.0. - #[serde(default = "one_f64", skip_serializing_if = "is_one_f64")] - pub scale: f64, + /// The complete, concrete document encoding. Auto is creation-only and + /// intentionally cannot be represented here. + pub encoding: plotx_figure::SeriesEncoding, #[serde(default = "bool_true", skip_serializing_if = "is_true")] pub visible: bool, } -fn one_f64() -> f64 { - 1.0 -} - -fn is_one_f64(v: &f64) -> bool { - (*v - 1.0).abs() < f64::EPSILON -} - fn bool_true() -> bool { true } diff --git a/crates/core/src/project/electrophysiology_convert.rs b/crates/core/src/project/electrophysiology_convert.rs index 8e04af3..65d54f4 100644 --- a/crates/core/src/project/electrophysiology_convert.rs +++ b/crates/core/src/project/electrophysiology_convert.rs @@ -126,7 +126,11 @@ pub(super) fn electrophysiology_from_object( ))); } }; - Ok(Dataset::Electrophysiology(Box::new(recording))) + let dataset = Dataset::Electrophysiology(Box::new(recording)); + dataset + .validate_field_catalog() + .map_err(ProjectError::Invalid)?; + Ok(dataset) } /// Traversal order shared by save and load so the length-prefixed blob is filled diff --git a/crates/core/src/project/field_catalog.rs b/crates/core/src/project/field_catalog.rs new file mode 100644 index 0000000..525c25c --- /dev/null +++ b/crates/core/src/project/field_catalog.rs @@ -0,0 +1,77 @@ +use super::*; + +pub(super) fn read(data: &DataObject) -> Result { + let value = data + .extensions + .get("plotx.fields") + .cloned() + .ok_or_else(|| { + ProjectError::Invalid(format!( + "dataset {} is missing its mandatory plotx.fields identity catalog", + data.id + )) + })?; + serde_json::from_value(value).map_err(|error| { + ProjectError::Invalid(format!( + "dataset {} has an invalid plotx.fields identity catalog: {error}", + data.id + )) + }) +} + +pub(super) fn validate_series( + dataset: &Dataset, + field: crate::state::FieldId, + encoding: &plotx_figure::SeriesEncoding, + context: &str, +) -> Result<()> { + if !dataset.has_field(field) { + return Err(ProjectError::Invalid(format!( + "{context} references missing field {field}" + ))); + } + if !dataset.supports_encoding(field, encoding) { + return Err(ProjectError::Invalid(format!( + "{context} uses an encoding not supported by field {field}" + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stale_field_or_inapplicable_encoding_is_rejected_before_serialization() { + let source = plotx_io::NmrData { + points: vec![num_complex::Complex64::new(1.0, 0.0); 4], + domain: plotx_io::Domain::Frequency, + spectral_width_hz: 4_000.0, + observe_freq_mhz: 400.0, + carrier_ppm: 0.0, + nucleus: "1H".to_owned(), + source: "field validation".to_owned(), + group_delay: 0.0, + }; + let dataset = Dataset::Nmr(Box::new(crate::state::NmrDataset::load(source))); + let field = dataset.default_field_id().unwrap(); + let error = validate_series( + &dataset, + field, + &plotx_figure::SeriesEncoding::Heatmap(plotx_figure::HeatmapSpec::default()), + "test series", + ) + .unwrap_err(); + assert!(error.to_string().contains("encoding not supported")); + + let error = validate_series( + &dataset, + crate::state::FieldId::new(field.get() + 1), + &plotx_figure::SeriesEncoding::default(), + "test series", + ) + .unwrap_err(); + assert!(error.to_string().contains("missing field")); + } +} diff --git a/crates/core/src/project/field_encoding_tests.rs b/crates/core/src/project/field_encoding_tests.rs new file mode 100644 index 0000000..c62127f --- /dev/null +++ b/crates/core/src/project/field_encoding_tests.rs @@ -0,0 +1,155 @@ +use super::tests::{first_plot, synthetic_true_2d, temp_project}; +use super::*; +use crate::state::{AfmDataset, CanvasDocument, Dataset, Nmr2DDataset, ObjectFrame, PlotxApp}; +use plotx_figure::{HeatmapSpec, SeriesEncoding}; +use std::collections::BTreeSet; +use std::sync::Arc; + +#[test] +fn project_roundtrip_preserves_concrete_contour_series_encoding() { + let mut app = PlotxApp::new(); + app.doc + .datasets + .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load( + synthetic_true_2d(), + )))); + let mut canvas = CanvasDocument::new("contour".to_owned(), [120.0, 80.0]); + let [width, height] = canvas.size_pt(); + let object = app.build_plot_object( + 0, + ObjectFrame::new(0.0, 0.0, width, height), + canvas.allocate_object_id(), + "Contour".to_owned(), + ); + canvas.objects.push(object); + app.doc.canvases.push(canvas); + let before = first_plot(&app).binding.series[0].clone(); + assert!(matches!( + before.encoding, + plotx_figure::SeriesEncoding::Contour(_) + )); + + let path = temp_project("contour-series-binding"); + let _ = std::fs::remove_file(&path); + save_project(&app, &path, false).unwrap(); + let loaded = load_project(&path).unwrap(); + let _ = std::fs::remove_file(&path); + + assert_eq!(first_plot(&loaded).binding.series, vec![before]); +} + +#[test] +fn nmr_two_dimensional_fields_expose_real_and_magnitude_capabilities() { + let dataset = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(synthetic_true_2d()))); + let fields = dataset.field_descriptors(); + assert_eq!(fields.len(), 2); + assert_eq!(fields[0].local_id, "nmr.real"); + assert!( + fields[0] + .capabilities + .contains(crate::automation::CAP_FIELD_SCALAR_GRID_2D_REGULAR) + ); + assert!( + fields[0] + .capabilities + .contains(crate::automation::CAP_FIELD_SIGNED) + ); + assert!( + fields[0] + .capabilities + .contains(crate::automation::CAP_FIELD_NOISE_SCALE) + ); + assert_eq!(fields[1].local_id, "nmr.magnitude"); + assert!( + fields[1] + .capabilities + .contains(crate::automation::CAP_FIELD_SCALAR_GRID_2D_REGULAR) + ); + assert!( + fields[1] + .capabilities + .contains(crate::automation::CAP_FIELD_BOUNDED) + ); + assert!( + !fields[1] + .capabilities + .contains(crate::automation::CAP_FIELD_SIGNED) + ); +} + +#[test] +fn afm_calibration_and_scan_metadata_keep_field_ids_distinct_after_roundtrip() { + let base = afm_channel(1.0, 0.0, 2.0, 3.0); + let variants = [ + base.clone(), + afm_channel(2.0, 0.0, 2.0, 3.0), + afm_channel(1.0, 1.0, 2.0, 3.0), + afm_channel(1.0, 0.0, 4.0, 3.0), + afm_channel(1.0, 0.0, 2.0, 5.0), + ]; + let keys: Vec<_> = variants.iter().map(crate::state::afm_channel_key).collect(); + assert_eq!(keys.iter().collect::>().len(), variants.len()); + + let mut app = PlotxApp::new(); + app.doc + .datasets + .push(Dataset::Afm(Box::new(AfmDataset::load( + plotx_io::AfmData { + images: variants.to_vec(), + forces: None, + source: "calibration identity test".to_owned(), + import_warnings: Vec::new(), + }, + )))); + let before = app.doc.datasets[0].field_descriptors(); + assert_eq!(before.len(), variants.len()); + assert_eq!( + before + .iter() + .map(|field| field.id) + .collect::>() + .len(), + variants.len() + ); + for field in &before { + assert!( + app.doc.datasets[0] + .encoded_field_figure(field.id, &SeriesEncoding::Heatmap(HeatmapSpec::default())) + .is_some() + ); + } + + let path = temp_project("afm-calibration-field-ids"); + let _ = std::fs::remove_file(&path); + save_project(&app, &path, false).unwrap(); + let loaded = load_project(&path).unwrap(); + let _ = std::fs::remove_file(&path); + let after = loaded.doc.datasets[0].field_descriptors(); + assert_eq!( + after.iter().map(|field| field.id).collect::>(), + before.iter().map(|field| field.id).collect::>() + ); +} + +fn afm_channel( + multiplier: f64, + offset: f64, + scan_size_x: f64, + scan_size_y: f64, +) -> plotx_io::AfmImageChannel { + plotx_io::AfmImageChannel { + name: "Height".to_owned(), + width: 2, + height: 2, + scan_size_x, + scan_size_y, + lateral_unit: "nm".to_owned(), + scale: plotx_io::AfmScale { + multiplier, + offset, + unit: "nm".to_owned(), + }, + raw: Arc::from(vec![1, 2, 3, 4]), + frame_direction: plotx_io::AfmFrameDirection::Trace, + } +} diff --git a/crates/core/src/project/mod.rs b/crates/core/src/project/mod.rs index 180a8d6..5b27eba 100644 --- a/crates/core/src/project/mod.rs +++ b/crates/core/src/project/mod.rs @@ -30,6 +30,7 @@ mod convert_dimensions; mod convert_recipes; mod dto; mod electrophysiology_convert; +mod field_catalog; mod integrals2d; mod persistence; mod pipeline_conv; @@ -670,6 +671,8 @@ mod cleanup_tests; #[cfg(test)] mod electrophysiology_tests; #[cfg(test)] +mod field_encoding_tests; +#[cfg(test)] mod lineage_tests; #[cfg(test)] mod linefit_tests; diff --git a/crates/core/src/project/reference_tests.rs b/crates/core/src/project/reference_tests.rs index b380222..90cddce 100644 --- a/crates/core/src/project/reference_tests.rs +++ b/crates/core/src/project/reference_tests.rs @@ -30,7 +30,9 @@ fn save_rejects_missing_lineage_source() { fn save_rejects_missing_primary_and_series_datasets() { let mut missing_primary = sample_app(); let missing = DatasetId::new(); - first_plot_mut(&mut missing_primary).binding.series[0].dataset = missing; + first_plot_mut(&mut missing_primary).binding.series[0] + .source + .resource = missing; let error = save_error(&missing_primary, "missing-primary-dataset"); assert!( error.contains(&format!("missing primary dataset {missing}")), @@ -42,7 +44,10 @@ fn save_rejects_missing_primary_and_series_datasets() { first_plot_mut(&mut missing_series) .binding .series - .push(SeriesBinding::new(missing)); + .push(SeriesBinding::with_source(crate::state::SeriesSource { + resource: missing, + field: crate::state::FieldId::default(), + })); let error = save_error(&missing_series, "missing-series-dataset"); assert!( error.contains(&format!("missing series dataset {missing}")), @@ -105,6 +110,7 @@ fn loading_a_maximum_object_id_reports_exhaustion() { "name": "Label", "kind": "text", "next_series_id": 0, + "series": [], "frame": { "x": 0.0, "y": 0.0, "width": 10.0, "height": 10.0 }, "locked": false, "visible": true @@ -159,7 +165,12 @@ fn loading_a_maximum_series_id_reports_exhaustion() { "name": "Plot", "kind": "line_plot", "input": recipe, - "series": [{ "id": u64::MAX, "input": recipe }], + "series": [{ + "id": u64::MAX, + "input": recipe, + "field": 0, + "encoding": {"kind":"line","spec":{"color":{"explicit":{"r":15,"g":77,"b":146}},"scale":1.0,"width":1.0}} + }], "frame": { "x": 0.0, "y": 0.0, "width": 100.0, "height": 80.0 }, "locked": false, "visible": true diff --git a/crates/core/src/project/tests.rs b/crates/core/src/project/tests.rs index 4cafd69..fd60359 100644 --- a/crates/core/src/project/tests.rs +++ b/crates/core/src/project/tests.rs @@ -378,7 +378,7 @@ fn project_roundtrip_preserves_data_recipe_and_view() { assert!((phase.pivot_frac - 0.4).abs() < f64::EPSILON); } -fn synthetic_true_2d() -> plotx_io::NmrData2D { +pub(super) fn synthetic_true_2d() -> plotx_io::NmrData2D { use plotx_io::{Dim, Domain, NmrData2D, QuadMode}; let (cols, rows) = (32usize, 4usize); let dim = |nucleus: &str| Dim { @@ -582,14 +582,14 @@ fn project_roundtrip_preserves_overlay_binding() { app.build_plot_object(0, ObjectFrame::new(0.0, 0.0, w, h), id, "Plot 1".to_owned()); object.plot_mut().unwrap().binding = crate::state::DataBinding { series: vec![ - crate::state::SeriesBinding::new(app.doc.datasets[0].resource_id()), - crate::state::SeriesBinding { - id: crate::state::SeriesId::new(1), - dataset: app.doc.datasets[1].resource_id(), - color: Some(Color::rgb(10, 20, 30)), - label: Some("treated".to_owned()), - scale: 1.0, - visible: true, + crate::state::SeriesBinding::from_dataset(&app.doc.datasets[0]).unwrap(), + { + let mut series = + crate::state::SeriesBinding::from_dataset(&app.doc.datasets[1]).unwrap(); + series.id = crate::state::SeriesId::new(1); + series.set_primary_color(Color::rgb(10, 20, 30)); + series.label = Some("treated".to_owned()); + series }, ], }; @@ -607,14 +607,17 @@ fn project_roundtrip_preserves_overlay_binding() { let binding = &first_plot(&loaded).binding; assert_eq!(binding.series.len(), 2); assert_eq!( - binding.series[0].dataset, + binding.series[0].source.resource, loaded.doc.datasets[0].resource_id() ); assert_eq!( - binding.series[1].dataset, + binding.series[1].source.resource, loaded.doc.datasets[1].resource_id() ); - assert_eq!(binding.series[1].color, Some(Color::rgb(10, 20, 30))); + assert_eq!( + binding.series[1].primary_color(), + Some(Color::rgb(10, 20, 30)) + ); assert_eq!(binding.series[1].label.as_deref(), Some("treated")); assert!(first_plot(&loaded).figure.show_legend); } @@ -636,14 +639,15 @@ fn project_roundtrip_preserves_stack_spec_and_series_fields() { let plot = object.plot_mut().unwrap(); plot.binding = DataBinding { series: vec![ - SeriesBinding::new(app.doc.datasets[0].resource_id()), - SeriesBinding { - id: crate::state::SeriesId::new(1), - dataset: app.doc.datasets[1].resource_id(), - color: None, - label: None, - scale: 2.5, - visible: false, + SeriesBinding::from_dataset(&app.doc.datasets[0]).unwrap(), + { + let mut series = SeriesBinding::from_dataset(&app.doc.datasets[1]).unwrap(); + series.id = crate::state::SeriesId::new(1); + if let plotx_figure::SeriesEncoding::Line(line) = &mut series.encoding { + line.scale = 2.5; + } + series.visible = false; + series }, ], }; @@ -672,20 +676,18 @@ fn project_roundtrip_preserves_stack_spec_and_series_fields() { assert_eq!(plot.stack.shear_x, 0.1); assert!(plot.stack.normalize); assert_eq!(plot.stack.active, Some(1)); - assert_eq!(plot.binding.series[1].scale, 2.5); + assert_eq!(plot.binding.series[1].line_scale(), 2.5); assert!(!plot.binding.series[1].visible); } #[test] -fn single_input_without_explicit_series_is_still_parseable() { - let view: ViewCanvasObject = serde_json::from_str( +fn plot_without_explicit_series_is_rejected_by_the_project_schema() { + let result = serde_json::from_str::( r#"{"id":"1","name":"Plot","kind":"line_plot","input":"recipe_000000","next_series_id":0, "frame":{"x":0.0,"y":0.0,"width":100.0,"height":80.0}, "title":null,"snapshot":null,"locked":false,"visible":true}"#, - ) - .unwrap(); - assert!(view.series.is_empty()); - assert!(view.axis_overrides.is_none()); + ); + assert!(result.is_err()); } #[test] fn scheme_save_load_apply_roundtrips() { diff --git a/crates/core/src/project/typed_table.rs b/crates/core/src/project/typed_table.rs index 3debb59..de1fbcb 100644 --- a/crates/core/src/project/typed_table.rs +++ b/crates/core/src/project/typed_table.rs @@ -101,6 +101,9 @@ fn block_path(hash: ContentHash) -> String { #[derive(Serialize, Deserialize)] struct TableSidecarV1 { + /// Dataset-owned child-field identity mapping. Mandatory: projects written + /// before FieldId stabilization are intentionally not accepted. + field_catalog: crate::state::FieldCatalog, #[serde(default)] x_column: Option, series: Vec, @@ -161,6 +164,7 @@ pub(crate) fn table_dataset_to_v1( } } let sidecar = TableSidecarV1 { + field_catalog: table.field_catalog.clone(), x_column: table.x_binding, series: table.series_bindings.clone(), provenance: table.provenance.clone(), @@ -231,6 +235,7 @@ pub(crate) fn table_dataset_from_v1( resource_id: data.id.parse().map_err(|_| { ProjectError::Invalid(format!("table has invalid stable id {}", data.id)) })?, + field_catalog: sidecar.field_catalog, provenance: sidecar.provenance, meta: sidecar.meta, curve_fit_analyses: sidecar.curve_fit_analyses, @@ -262,6 +267,9 @@ pub(crate) fn table_dataset_from_v1( .map(|analysis| analysis.id.saturating_add(1)) .max() .unwrap_or(0); + crate::state::Dataset::Table(Box::new(dataset.clone())) + .validate_field_catalog() + .map_err(ProjectError::Invalid)?; Ok(dataset) } diff --git a/crates/core/src/state/afm.rs b/crates/core/src/state/afm.rs index b08af40..645cfde 100644 --- a/crates/core/src/state/afm.rs +++ b/crates/core/src/state/afm.rs @@ -1,13 +1,16 @@ use super::*; -use plotx_figure::{AxisFrame, ColormapId, HeatmapGrid, Series}; +use plotx_figure::{AxisFrame, ColormapId, ContourSpec, HeatmapGrid, Series}; use std::sync::Arc; #[derive(Clone)] pub struct AfmDataset { pub resource_id: DatasetId, + /// Persisted mapping from stable channel keys to dataset-local field ids. + pub field_catalog: FieldCatalog, pub data: Arc, + /// Immutable keys calculated with the loaded raster data, never serialized. + pub(crate) image_field_keys: Arc<[String]>, pub name: Option, - pub selected_channel: usize, pub selected_pixel: [usize; 2], pub lineage: Option, } @@ -17,18 +20,20 @@ impl AfmDataset { let selected_pixel = data.forces.as_ref().map_or([0, 0], |forces| { [forces.grid_width / 2, forces.grid_height / 2] }); + let image_field_keys = crate::state::afm_channel_keys(&data); Self { resource_id: DatasetId::new(), + field_catalog: crate::state::afm_field_catalog_for_keys(&data, &image_field_keys), data: Arc::new(data), + image_field_keys, name: None, - selected_channel: 0, selected_pixel, lineage: None, } } - pub fn map_figure(&self, colormap: ColormapId) -> Option
{ - let channel = self.data.images.get(self.selected_channel)?; + pub fn map_figure(&self, field: FieldId, colormap: ColormapId) -> Option
{ + let channel = self.image_for_field(field)?; let values: Vec = channel .raw .iter() @@ -60,7 +65,32 @@ impl AfmDataset { Some(figure) } - pub fn force_figure(&self) -> Option
{ + pub fn contour_figure(&self, field: FieldId, contour: &ContourSpec) -> Option
{ + let channel = self.image_for_field(field)?; + let values: Vec = channel + .raw + .iter() + .map(|value| channel.scale.apply(*value) as f32) + .collect(); + let mut figure = Figure::new( + &channel.name, + Axis::new(&channel.lateral_unit, 0.0, channel.scan_size_x), + Axis::new(&channel.lateral_unit, 0.0, channel.scan_size_y), + ); + figure.contours = crate::figures::scalar_contour_overlays( + &values, + channel.height, + channel.width, + [0.0, channel.scan_size_x, 0.0, channel.scan_size_y], + contour, + ); + figure.lock_aspect = true; + figure.axis_frame = AxisFrame::Box; + Some(figure) + } + + pub fn force_figure(&self, field: FieldId) -> Option
{ + (self.field_catalog.id_for_key("afm.force_curve") == Some(field)).then_some(())?; let forces = self.data.forces.as_ref()?; let curve = forces.curve_raw(self.selected_pixel[0], self.selected_pixel[1])?; let x_value = |display_index: usize| { @@ -147,6 +177,15 @@ impl AfmDataset { figure.show_legend = true; Some(figure) } + + fn image_for_field(&self, field: FieldId) -> Option<&plotx_io::AfmImageChannel> { + self.data + .images + .iter() + .zip(self.image_field_keys.iter()) + .find(|(_, key)| self.field_catalog.id_for_key(key) == Some(field)) + .map(|(channel, _)| channel) + } } fn physical_length_factor(unit: &str) -> Option { diff --git a/crates/core/src/state/app_impl.rs b/crates/core/src/state/app_impl.rs index 454f266..cabecd6 100644 --- a/crates/core/src/state/app_impl.rs +++ b/crates/core/src/state/app_impl.rs @@ -94,95 +94,6 @@ impl PlotxApp { .unwrap_or_else(|| source.to_string()) } - /// Build a dataset's figure through the chart registry: resolve `chart`'s - /// type for the dataset's domain (falling back to the domain default when the - /// recorded id doesn't apply), then dispatch to its builder. The default chart - /// of each domain calls the same builder as before, so figures are unchanged. - pub fn build_full_canvas_figure( - &self, - dataset: usize, - chart: &ChartSpec, - size_mm: [f32; 2], - ) -> Figure { - let mut figure = - crate::workflow::build_dataset_figure(&self.doc.datasets[dataset], chart, size_mm); - if let Some(nmr) = self.doc.datasets[dataset].as_nmr() { - figure.integral_curves = nmr.integral_curves(); - } - // Every figure build stamps the document's typography, so a doc-level - // edit reaches each plot on its next rebuild without per-plot state. - figure.typography = self.doc.style_library.figure_typography; - figure - } - - /// Build the figure for a plot's data binding. A multi-series binding whose - /// datasets share one stackable (line-series) domain is combined into one - /// figure honouring `stack`; any other binding renders the primary alone. - pub fn build_binding_figure( - &self, - binding: &DataBinding, - chart: &ChartSpec, - stack: &StackSpec, - size_mm: [f32; 2], - ) -> Figure { - if binding.series.len() > 1 && self.series_stackable(binding) { - self.build_stacked_figure(binding, stack, size_mm) - } else { - let Some(primary_id) = binding.primary_dataset() else { - return Figure::new( - "", - plotx_figure::Axis::new("x", 0.0, 1.0), - plotx_figure::Axis::new("y", 0.0, 1.0), - ); - }; - let Some(primary) = self.doc.dataset_index(primary_id) else { - return Figure::new( - "", - plotx_figure::Axis::new("x", 0.0, 1.0), - plotx_figure::Axis::new("y", 0.0, 1.0), - ); - }; - let domain = self.doc.datasets[primary].domain(); - let mut fig = self.build_full_canvas_figure(primary, chart, size_mm); - // A single-series colour override (e.g. a theme's primary trace colour) - // recolours the built traces, so it survives figure rebuilds and export. - // Applied before the line-fit overlays so those keep their own colours; - // stacked figures never get overlays (each trace stays a single series). - if let Some(color) = binding.series.first().and_then(|s| s.color) { - for series in &mut fig.series { - series.color = color; - } - for error_bar in &mut fig.error_bars { - error_bar.color = color; - } - // Bar/box bodies live in `polygons` and must follow the traces. - // Value-mapped figures (heatmap cells, colormap surfaces, pie - // wedges) keep their own colours — one override would erase the - // encoding they carry. - if fig.heatmap.is_none() && fig.axis_frame != plotx_figure::AxisFrame::Hidden { - let background = fig.background; - for polygon in &mut fig.polygons { - polygon.fill = color; - if let Some((stroke, _)) = &mut polygon.stroke - && *stroke != background - { - *stroke = color; - } - } - } - } - // Stored fits are curves in the table's native x/y space; every - // other table chart (histogram, box, heatmap, …) draws in different - // coordinates where those curves would be unrelated ink. - let fits_apply = domain != DataDomain::Table - || resolved_chart_type(domain, &chart.type_id).id == "table_line"; - if fits_apply { - fig = apply_line_fit_overlays(fig, self.doc.datasets[primary].line_fits()); - } - fig - } - } - /// Build a plot object's full figure: its binding figure, then any marginal /// axis projections layered on. The single entry point every object rebuild /// (resize, binding/chart/stack/projection edit, load) routes through, so the diff --git a/crates/core/src/state/app_impl_figures.rs b/crates/core/src/state/app_impl_figures.rs new file mode 100644 index 0000000..2876d53 --- /dev/null +++ b/crates/core/src/state/app_impl_figures.rs @@ -0,0 +1,172 @@ +use super::*; +use plotx_figure::Figure; + +impl PlotxApp { + /// Build a dataset's figure through the chart registry: resolve `chart`'s + /// type for the dataset's domain (falling back to the domain default when the + /// recorded id doesn't apply), then dispatch to its builder. The default chart + /// of each domain calls the same builder as before, so figures are unchanged. + pub fn build_full_canvas_figure( + &self, + dataset: usize, + chart: &ChartSpec, + size_mm: [f32; 2], + ) -> Figure { + let mut figure = + crate::workflow::build_dataset_figure(&self.doc.datasets[dataset], chart, size_mm); + if let Some(nmr) = self.doc.datasets[dataset].as_nmr() { + figure.integral_curves = nmr.integral_curves(); + } + // Every figure build stamps the document's typography, so a doc-level + // edit reaches each plot on its next rebuild without per-plot state. + figure.typography = self.doc.style_library.figure_typography; + figure + } + + /// Build the figure for a plot's data binding. A multi-series binding whose + /// datasets share one stackable (line-series) domain is combined into one + /// figure honouring `stack`; any other binding renders the primary alone. + pub fn build_binding_figure( + &self, + binding: &DataBinding, + chart: &ChartSpec, + stack: &StackSpec, + size_mm: [f32; 2], + ) -> Figure { + if binding.series.len() > 1 && self.series_stackable(binding) { + self.build_stacked_figure(binding, stack, size_mm) + } else { + if let Some(series) = binding.series.first() + && !matches!(series.encoding, plotx_figure::SeriesEncoding::Line(_)) + { + let figure = self + .build_encoded_series_figure(series) + .unwrap_or_else(|| unsupported_series_figure(series)); + return self.normalize_binding_figure(figure, size_mm); + } + let Some(primary_id) = binding.primary_dataset() else { + return Figure::new( + "", + plotx_figure::Axis::new("x", 0.0, 1.0), + plotx_figure::Axis::new("y", 0.0, 1.0), + ); + }; + let Some(primary) = self.doc.dataset_index(primary_id) else { + return Figure::new( + "", + plotx_figure::Axis::new("x", 0.0, 1.0), + plotx_figure::Axis::new("y", 0.0, 1.0), + ); + }; + let domain = self.doc.datasets[primary].domain(); + let mut fig = self.build_full_canvas_figure(primary, chart, size_mm); + // A single-series colour override (e.g. a theme's primary trace colour) + // recolours the built traces, so it survives figure rebuilds and export. + // Applied before the line-fit overlays so those keep their own colours; + // stacked figures never get overlays (each trace stays a single series). + if let Some(line) = binding + .series + .first() + .and_then(|series| match &series.encoding { + plotx_figure::SeriesEncoding::Line(line) => Some(line), + plotx_figure::SeriesEncoding::Contour(_) + | plotx_figure::SeriesEncoding::Heatmap(_) + | plotx_figure::SeriesEncoding::Image(_) => None, + }) + { + let color = line.color.resolve(); + for series in &mut fig.series { + series.color = color; + series.width = line.width.get(); + for point in &mut series.points { + point[1] *= line.scale; + } + } + for error_bar in &mut fig.error_bars { + error_bar.color = color; + error_bar.center[1] *= line.scale; + error_bar.negative *= line.scale.abs(); + error_bar.positive *= line.scale.abs(); + } + // Bar/box bodies live in `polygons` and must follow the traces. + // Value-mapped figures (heatmap cells, colormap surfaces, pie + // wedges) keep their own colours — one override would erase the + // encoding they carry. + if fig.heatmap.is_none() && fig.axis_frame != plotx_figure::AxisFrame::Hidden { + let background = fig.background; + for polygon in &mut fig.polygons { + polygon.fill = color; + if let Some((stroke, _)) = &mut polygon.stroke + && *stroke != background + { + *stroke = color; + } + } + } + } + // Stored fits are curves in the table's native x/y space; every + // other table chart (histogram, box, heatmap, …) draws in different + // coordinates where those curves would be unrelated ink. + let selected_chart = binding + .series + .first() + .and_then(|series| self.doc.dataset_by_id(series.source.resource)) + .and_then(|dataset| { + dataset + .field_descriptor(binding.series[0].source.field) + .map(|field| { + resolved_chart_type_for_field( + &field.capabilities, + domain, + &chart.type_id, + ) + }) + }) + .unwrap_or_else(|| default_chart_type(domain)); + let fits_apply = domain != DataDomain::Table || selected_chart.id == "table_line"; + if fits_apply { + fig = apply_line_fit_overlays(fig, self.doc.datasets[primary].line_fits()); + } + fig + } + } + + pub(super) fn normalize_binding_figure(&self, mut figure: Figure, size_mm: [f32; 2]) -> Figure { + figure.title.clear(); + figure.width = size_mm[0] * MM_TO_PT; + figure.height = size_mm[1] * MM_TO_PT; + figure.typography = self.doc.style_library.figure_typography; + figure + } + + pub(super) fn build_encoded_series_figure(&self, series: &SeriesBinding) -> Option
{ + let dataset = self.doc.dataset_by_id(series.source.resource)?; + if !dataset.supports_encoding(series.source.field, &series.encoding) { + return None; + } + dataset.encoded_field_figure(series.source.field, &series.encoding) + } +} + +fn unsupported_series_figure(series: &SeriesBinding) -> Figure { + let mut figure = Figure::new( + "Unavailable field encoding", + plotx_figure::Axis::new("x", 0.0, 1.0), + plotx_figure::Axis::new("y", 0.0, 1.0), + ); + figure.annotations.push(plotx_figure::Annotation { + text: format!( + "The selected field cannot be rendered with {}.", + match series.encoding { + plotx_figure::SeriesEncoding::Line(_) => "line", + plotx_figure::SeriesEncoding::Contour(_) => "contour", + plotx_figure::SeriesEncoding::Heatmap(_) => "heatmap", + plotx_figure::SeriesEncoding::Image(_) => "image", + } + ), + at: [0.5, 0.5], + color: plotx_figure::Color::rgb(0xd1, 0x24, 0x2a), + size: 12.0, + }); + figure +} diff --git a/crates/core/src/state/app_impl_slice.rs b/crates/core/src/state/app_impl_slice.rs index 6449454..2073476 100644 --- a/crates/core/src/state/app_impl_slice.rs +++ b/crates/core/src/state/app_impl_slice.rs @@ -47,6 +47,7 @@ impl NmrDataset { }; Self { resource_id: DatasetId::new(), + field_catalog: nmr_field_catalog(), data, base: spectrum.clone(), pipeline, diff --git a/crates/core/src/state/charts.rs b/crates/core/src/state/charts.rs index fb2eb97..24f441c 100644 --- a/crates/core/src/state/charts.rs +++ b/crates/core/src/state/charts.rs @@ -1,4 +1,5 @@ -//! The chart registry: the catalog of chart types and the data domains they apply to. +//! The chart registry. Data domains provide recommended defaults only; concrete +//! encoding applicability is determined by field capabilities. use super::*; @@ -52,154 +53,261 @@ pub struct ChartContext { pub view_angles: [f32; 2], } -/// `id` is persisted in `.plotx`. -pub struct ChartType { +/// A chart registration. `recommended_domains` preserves sensible defaults for +/// existing entry points, but it is never an applicability gate. +pub struct ChartDescriptor { pub id: &'static str, pub name: &'static str, - pub domains: &'static [DataDomain], + pub recommended_domains: &'static [DataDomain], + /// Stable `CapabilityId` string identities required by this chart. + pub required_capabilities: &'static [&'static str], pub needs_column: bool, pub build: fn(&Dataset, &ChartContext) -> Option
, } +/// Legacy spelling retained for the broad table-chart surface. New code should +/// use `ChartDescriptor` and field capabilities. +pub type ChartType = ChartDescriptor; + +impl ChartDescriptor { + pub fn is_applicable_to(&self, capabilities: &FieldCapabilities) -> bool { + capabilities.supports(self.required_capabilities) + } +} + /// The catalog. The first entry for a domain is that domain's default chart, so /// old `.plotx` files (no recorded chart type) map to it. -static CHART_TYPES: &[ChartType] = &[ - ChartType { +static CHART_TYPES: &[ChartDescriptor] = &[ + ChartDescriptor { id: "afm_map", name: "AFM Map", - domains: &[DataDomain::Afm], + recommended_domains: &[DataDomain::Afm], + required_capabilities: &[ + crate::automation::CAP_FIELD_SCALAR_GRID_2D_REGULAR, + crate::automation::CAP_FIELD_AFM_MAP, + ], needs_column: false, build: build_afm_map, }, - ChartType { + ChartDescriptor { id: "afm_force_curve", name: "Force Curve", - domains: &[DataDomain::Afm], + recommended_domains: &[DataDomain::Afm], + required_capabilities: &[ + crate::automation::CAP_FIELD_CURVE_1D, + crate::automation::CAP_FIELD_FORCE_CURVE, + ], needs_column: false, build: build_afm_force, }, - ChartType { + ChartDescriptor { id: "electrophysiology_sweeps", name: "Sweeps", - domains: &[DataDomain::Electrophysiology], + recommended_domains: &[DataDomain::Electrophysiology], + required_capabilities: &[ + crate::automation::CAP_FIELD_CURVE_1D, + crate::automation::CAP_FIELD_SWEEP_COLLECTION, + ], needs_column: false, build: build_electrophysiology, }, - ChartType { + ChartDescriptor { id: "nmr_spectrum", name: "Spectrum", - domains: &[DataDomain::Nmr1d], + recommended_domains: &[DataDomain::Nmr1d], + required_capabilities: &[ + crate::automation::CAP_FIELD_CURVE_1D, + crate::automation::CAP_FIELD_NMR_SPECTRUM, + ], needs_column: false, build: build_nmr_spectrum, }, - ChartType { + ChartDescriptor { id: "nmr_contour", name: "Contour", - domains: &[DataDomain::Nmr2d], + recommended_domains: &[DataDomain::Nmr2d], + required_capabilities: &[ + crate::automation::CAP_FIELD_SCALAR_GRID_2D_REGULAR, + crate::automation::CAP_FIELD_NMR_CONTOUR, + ], needs_column: false, build: build_nmr_2d, }, - ChartType { + ChartDescriptor { id: "nmr_pseudo", name: "Stack / analysis", - domains: &[DataDomain::PseudoNmr], + recommended_domains: &[DataDomain::PseudoNmr], + required_capabilities: &[ + crate::automation::CAP_FIELD_CURVE_1D, + crate::automation::CAP_FIELD_NMR_STACK, + ], needs_column: false, build: build_nmr_2d, }, - ChartType { + ChartDescriptor { id: "table_line", name: "Line", - domains: &[DataDomain::Table], + recommended_domains: &[DataDomain::Table], + required_capabilities: &[ + crate::automation::CAP_FIELD_CURVE_1D, + crate::automation::CAP_FIELD_TABLE, + ], needs_column: false, build: build_table_line, }, - ChartType { + ChartDescriptor { id: "table_bar", name: "Bar", - domains: &[DataDomain::Table], + recommended_domains: &[DataDomain::Table], + required_capabilities: &[crate::automation::CAP_FIELD_TABLE], needs_column: true, build: build_table_bar, }, - ChartType { + ChartDescriptor { id: "table_bar_grouped", name: "Grouped bars", - domains: &[DataDomain::Table], + recommended_domains: &[DataDomain::Table], + required_capabilities: &[crate::automation::CAP_FIELD_TABLE], needs_column: false, build: build_table_bar_grouped, }, - ChartType { + ChartDescriptor { id: "table_histogram", name: "Histogram", - domains: &[DataDomain::Table], + recommended_domains: &[DataDomain::Table], + required_capabilities: &[crate::automation::CAP_FIELD_TABLE], needs_column: true, build: build_table_histogram, }, - ChartType { + ChartDescriptor { id: "table_box", name: "Box", - domains: &[DataDomain::Table], + recommended_domains: &[DataDomain::Table], + required_capabilities: &[crate::automation::CAP_FIELD_TABLE], needs_column: false, build: build_table_box, }, - ChartType { + ChartDescriptor { id: "table_violin", name: "Violin", - domains: &[DataDomain::Table], + recommended_domains: &[DataDomain::Table], + required_capabilities: &[crate::automation::CAP_FIELD_TABLE], needs_column: false, build: build_table_violin, }, - ChartType { + ChartDescriptor { id: "table_heatmap", name: "Heatmap", - domains: &[DataDomain::Table], + recommended_domains: &[DataDomain::Table], + required_capabilities: &[crate::automation::CAP_FIELD_TABLE], needs_column: false, build: build_table_heatmap, }, - ChartType { + ChartDescriptor { id: "table_pie", name: "Pie", - domains: &[DataDomain::Table], + recommended_domains: &[DataDomain::Table], + required_capabilities: &[crate::automation::CAP_FIELD_TABLE], needs_column: true, build: build_table_pie, }, - ChartType { + ChartDescriptor { id: "table_surface", name: "Surface 3D", - domains: &[DataDomain::Table], + recommended_domains: &[DataDomain::Table], + required_capabilities: &[crate::automation::CAP_FIELD_TABLE], needs_column: false, build: build_table_surface, }, ]; -pub fn chart_type(id: &str) -> Option<&'static ChartType> { +pub fn chart_type(id: &str) -> Option<&'static ChartDescriptor> { CHART_TYPES.iter().find(|c| c.id == id) } -pub fn chart_types_for(domain: DataDomain) -> Vec<&'static ChartType> { - CHART_TYPES +pub fn chart_types_for_capabilities( + capabilities: &FieldCapabilities, + preferred_domain: DataDomain, +) -> Vec<&'static ChartDescriptor> { + let mut charts = CHART_TYPES .iter() - .filter(|c| c.domains.contains(&domain)) - .collect() + .filter(|chart| chart.is_applicable_to(capabilities)) + .collect::>(); + // Domains express presentation preference only. Applicability was decided + // above from the field's capabilities. + charts.sort_by_key(|chart| !chart.recommended_domains.contains(&preferred_domain)); + charts +} + +/// Resolve an unknown stored id to the domain's presentation default. Callers +/// that materialize a binding must additionally check field capabilities. +pub fn resolved_chart_type(domain: DataDomain, id: &str) -> &'static ChartDescriptor { + chart_type(id).unwrap_or_else(|| default_chart_type(domain)) } -/// The chart type a stored id resolves to for a domain: the id itself when it -/// is valid there, otherwise the domain default (old files, ids from newer -/// builds, or a binding switched to another domain). -pub fn resolved_chart_type(domain: DataDomain, id: &str) -> &'static ChartType { +/// Resolve a stored chart through the same field-capability gate used by the +/// gallery. This is the legacy-chart counterpart of encoding materialization. +pub fn resolved_chart_type_for_field( + capabilities: &FieldCapabilities, + domain: DataDomain, + id: &str, +) -> &'static ChartDescriptor { chart_type(id) - .filter(|candidate| candidate.domains.contains(&domain)) + .filter(|chart| chart.is_applicable_to(capabilities)) .unwrap_or_else(|| default_chart_type(domain)) } /// A domain's default chart type (its first registered entry). Every domain has /// at least one, so this never fails for a domain produced by `Dataset::domain`. -pub fn default_chart_type(domain: DataDomain) -> &'static ChartType { +pub fn default_chart_type(domain: DataDomain) -> &'static ChartDescriptor { CHART_TYPES .iter() - .find(|c| c.domains.contains(&domain)) + .find(|c| c.recommended_domains.contains(&domain)) .expect("every data domain registers at least one chart type") } +/// The domain-neutral visual encoding catalog. Adding a provider that exposes +/// a capability automatically makes the matching descriptor available; no +/// chart registry domain branch is needed. +pub struct EncodingDescriptor { + pub id: &'static str, + pub required_capabilities: &'static [&'static str], +} + +impl EncodingDescriptor { + pub fn is_applicable_to(&self, capabilities: &FieldCapabilities) -> bool { + capabilities.supports(self.required_capabilities) + } +} + +pub static ENCODING_DESCRIPTORS: &[EncodingDescriptor] = &[ + EncodingDescriptor { + id: "line", + required_capabilities: &[crate::automation::CAP_FIELD_CURVE_1D], + }, + EncodingDescriptor { + id: "contour", + required_capabilities: &[crate::automation::CAP_FIELD_SCALAR_GRID_2D_REGULAR], + }, + EncodingDescriptor { + id: "heatmap", + required_capabilities: &[crate::automation::CAP_FIELD_SCALAR_GRID_2D_REGULAR], + }, + EncodingDescriptor { + id: "image", + required_capabilities: &[crate::automation::CAP_FIELD_COLORED_RASTER_2D], + }, +]; + +pub fn encoding_descriptors_for( + capabilities: &FieldCapabilities, +) -> Vec<&'static EncodingDescriptor> { + ENCODING_DESCRIPTORS + .iter() + .filter(|descriptor| descriptor.is_applicable_to(capabilities)) + .collect() +} + /// Default 3D surface view: slightly rotated and elevated so all three faces read. pub const SURFACE_DEFAULT_VIEW: [f32; 2] = [-50.0, 30.0]; @@ -261,11 +369,23 @@ fn build_electrophysiology(dataset: &Dataset, _ctx: &ChartContext) -> Option Option
{ - dataset.as_afm()?.map_figure(ctx.colormap) + let afm = dataset.as_afm()?; + let field = dataset + .field_descriptors() + .into_iter() + .find(|field| field.local_id.starts_with("afm.channel."))? + .id; + afm.map_figure(field, ctx.colormap) } fn build_afm_force(dataset: &Dataset, _ctx: &ChartContext) -> Option
{ - dataset.as_afm()?.force_figure() + let afm = dataset.as_afm()?; + let field = dataset + .field_descriptors() + .into_iter() + .find(|field| field.local_id == "afm.force_curve")? + .id; + afm.force_figure(field) } fn build_nmr_2d(dataset: &Dataset, _ctx: &ChartContext) -> Option
{ @@ -316,7 +436,11 @@ mod tests { #[test] fn table_domain_lists_all_generic_charts_with_line_default() { - let ids: Vec<&str> = chart_types_for(DataDomain::Table) + let capabilities = FieldCapabilities::new([ + crate::automation::CapabilityId::new(crate::automation::CAP_FIELD_CURVE_1D), + crate::automation::CapabilityId::new(crate::automation::CAP_FIELD_TABLE), + ]); + let ids: Vec<&str> = chart_types_for_capabilities(&capabilities, DataDomain::Table) .iter() .map(|c| c.id) .collect(); @@ -364,4 +488,43 @@ mod tests { assert!(chart_type(default_chart_type(domain).id).is_some()); } } + + #[test] + fn scalar_field_capability_unlocks_contour_and_heatmap_without_domain_registration() { + let capabilities = FieldCapabilities::new([crate::automation::CapabilityId::new( + crate::automation::CAP_FIELD_SCALAR_GRID_2D_REGULAR, + )]); + let ids = encoding_descriptors_for(&capabilities) + .into_iter() + .map(|descriptor| descriptor.id) + .collect::>(); + assert_eq!(ids, ["contour", "heatmap"]); + } + + #[test] + fn colored_raster_unlocks_only_image_not_scalar_encodings() { + let capabilities = FieldCapabilities::new([crate::automation::CapabilityId::new( + crate::automation::CAP_FIELD_COLORED_RASTER_2D, + )]); + let ids = encoding_descriptors_for(&capabilities) + .into_iter() + .map(|descriptor| descriptor.id) + .collect::>(); + assert_eq!(ids, ["image"]); + } + + #[test] + fn malformed_colored_scalar_field_is_excluded_from_contour_and_heatmap() { + let capabilities = FieldCapabilities::new([ + crate::automation::CapabilityId::new( + crate::automation::CAP_FIELD_SCALAR_GRID_2D_REGULAR, + ), + crate::automation::CapabilityId::new(crate::automation::CAP_FIELD_COLORED_RASTER_2D), + ]); + let ids = encoding_descriptors_for(&capabilities) + .into_iter() + .map(|descriptor| descriptor.id) + .collect::>(); + assert_eq!(ids, ["image"]); + } } diff --git a/crates/core/src/state/datasets.rs b/crates/core/src/state/datasets.rs index 84261f1..7713157 100644 --- a/crates/core/src/state/datasets.rs +++ b/crates/core/src/state/datasets.rs @@ -27,6 +27,8 @@ pub struct NmrDataset { /// Stable automation and persistence identity. Array positions remain a UI /// implementation detail and must never escape into saved references. pub resource_id: DatasetId, + /// Persisted child-field identity allocator and key mapping. + pub field_catalog: FieldCatalog, pub data: NmrData, pub base: Spectrum, pub pipeline: AxisPipeline, @@ -65,6 +67,7 @@ impl NmrDataset { let spectrum = reapply(&base, &pipeline); let mut result = Self { resource_id: DatasetId::new(), + field_catalog: nmr_field_catalog(), data, base, pipeline, @@ -140,6 +143,8 @@ impl NmrDataset { pub struct Nmr2DDataset { /// Stable automation and persistence identity. pub resource_id: DatasetId, + /// Persisted child-field identity allocator and key mapping. + pub field_catalog: FieldCatalog, pub data: Arc, pub params: Params2D, /// Persistent owner-local allocator shared by both axes. @@ -205,6 +210,7 @@ impl Nmr2DDataset { let processed_figure = Arc::new(build_processed_figure(&processed, preset)); let mut result = Self { resource_id: DatasetId::new(), + field_catalog: nmr2d_field_catalog(), data: Arc::new(data), base_params: params.clone(), base_stale: false, diff --git a/crates/core/src/state/datasets_2d_figure.rs b/crates/core/src/state/datasets_2d_figure.rs index 5bfb0b5..3ddba7d 100644 --- a/crates/core/src/state/datasets_2d_figure.rs +++ b/crates/core/src/state/datasets_2d_figure.rs @@ -1,4 +1,20 @@ use super::*; +use plotx_figure::{Axis, AxisFrame, HeatmapGrid, HeatmapSpec, SeriesEncoding}; + +#[cfg(test)] +thread_local! { + static SYNCHRONOUS_CONTOUR_BUILDS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[cfg(test)] +pub(crate) fn reset_synchronous_contour_builds() { + SYNCHRONOUS_CONTOUR_BUILDS.with(|builds| builds.set(0)); +} + +#[cfg(test)] +pub(crate) fn synchronous_contour_builds() -> usize { + SYNCHRONOUS_CONTOUR_BUILDS.with(std::cell::Cell::get) +} impl Nmr2DDataset { pub fn figure(&self) -> Figure { @@ -36,6 +52,127 @@ impl Nmr2DDataset { self.preset.label(), ) } + + pub(crate) fn encoded_field_figure( + &self, + field: &FieldDescriptor, + encoding: &SeriesEncoding, + ) -> Option
{ + let Processed2D::Ft(spectrum) = &self.processed else { + return None; + }; + match (field.local_id.as_str(), encoding) { + ("nmr.real", SeriesEncoding::Contour(contour)) => { + let cached = default_contour_spec(&field.capabilities); + if *contour == cached { + Some((*self.processed_figure).clone()) + } else { + #[cfg(test)] + SYNCHRONOUS_CONTOUR_BUILDS.with(|builds| builds.set(builds.get() + 1)); + Some(crate::build_figure_2d(spectrum, self.preset, contour)) + } + } + ("nmr.real", SeriesEncoding::Heatmap(heatmap)) => Some(nmr_scalar_heatmap( + spectrum, + spectrum.real(), + "Real", + heatmap, + )), + ("nmr.magnitude", SeriesEncoding::Heatmap(heatmap)) => Some(nmr_scalar_heatmap( + spectrum, + spectrum.magnitude(), + "Magnitude", + heatmap, + )), + ("nmr.magnitude", SeriesEncoding::Contour(contour)) => { + Some(nmr_magnitude_contour(spectrum, contour)) + } + _ => None, + } + } +} + +fn nmr_axes(spectrum: &plotx_processing::Spectrum2D) -> (Axis, Axis) { + let (f2_lo, f2_hi) = spectrum.f2_bounds(); + let (f1_lo, f1_hi) = spectrum.f1_bounds(); + ( + Axis::new( + format!("{} chemical shift (ppm)", spectrum.direct.nucleus), + f2_lo, + f2_hi, + ) + .reversed(true), + Axis::new( + format!("{} chemical shift (ppm)", spectrum.indirect.nucleus), + f1_lo, + f1_hi, + ) + .reversed(true), + ) +} + +fn nmr_scalar_heatmap( + spectrum: &plotx_processing::Spectrum2D, + values: Vec, + field_name: &str, + heatmap: &HeatmapSpec, +) -> Figure { + let (x, y) = nmr_axes(spectrum); + let mut finite = values.iter().copied().filter(|value| value.is_finite()); + let Some(first) = finite.next() else { + return Figure::new(field_name, x, y).with_axis_frame(AxisFrame::Box); + }; + let (minimum, maximum) = finite.fold((first, first), |(minimum, maximum), value| { + (minimum.min(value), maximum.max(value)) + }); + let mut figure = Figure::new(format!("{field_name} — {}", spectrum.source), x, y) + .with_axis_frame(AxisFrame::Box); + figure.lock_aspect = spectrum.direct.nucleus == spectrum.indirect.nucleus; + figure.heatmap = Some(HeatmapGrid { + rows: spectrum.f1_size, + cols: spectrum.f2_size, + values, + x_bounds: [ + spectrum.f2_ppm.first().copied().unwrap_or(0.0), + spectrum.f2_ppm.last().copied().unwrap_or(0.0), + ], + y_bounds: [ + spectrum.f1_ppm.first().copied().unwrap_or(0.0), + spectrum.f1_ppm.last().copied().unwrap_or(0.0), + ], + colormap: heatmap.colormap, + value_range: heatmap + .value_range + .map(|range| [range[0], range[1]]) + .unwrap_or([minimum, maximum]), + }); + figure +} + +fn nmr_magnitude_contour( + spectrum: &plotx_processing::Spectrum2D, + contour: &plotx_figure::ContourSpec, +) -> Figure { + let mut figure = nmr_scalar_heatmap( + spectrum, + spectrum.magnitude(), + "Magnitude", + &HeatmapSpec::default(), + ); + figure.heatmap = None; + figure.contours = crate::figures::scalar_contour_overlays( + &spectrum.magnitude(), + spectrum.f1_size, + spectrum.f2_size, + [ + spectrum.f2_ppm.first().copied().unwrap_or(0.0), + spectrum.f2_ppm.last().copied().unwrap_or(0.0), + spectrum.f1_ppm.first().copied().unwrap_or(0.0), + spectrum.f1_ppm.last().copied().unwrap_or(0.0), + ], + contour, + ); + figure } pub(crate) fn build_processed_figure(processed: &Processed2D, preset: Preset2D) -> Figure { @@ -52,7 +189,17 @@ pub(crate) fn build_processed_figure_cancellable( return None; } match processed { - Processed2D::Ft(spectrum) => build_figure_2d_cancellable(spectrum, preset, cancelled), + Processed2D::Ft(spectrum) => { + let capabilities = scalar_grid_capabilities( + axis_is_linear(&spectrum.f1_ppm) && axis_is_linear(&spectrum.f2_ppm), + &[ + crate::automation::CAP_FIELD_SIGNED, + crate::automation::CAP_FIELD_NOISE_SCALE, + ], + ); + let contour = default_contour_spec(&capabilities); + build_figure_2d_cancellable(spectrum, preset, &contour, cancelled) + } Processed2D::Stack(stack) => { let figure = build_stack_figure(stack); (!cancelled()).then_some(figure) diff --git a/crates/core/src/state/document.rs b/crates/core/src/state/document.rs index e30a5fc..7a3562c 100644 --- a/crates/core/src/state/document.rs +++ b/crates/core/src/state/document.rs @@ -162,31 +162,6 @@ pub struct NamedView { pub pan: [f32; 2], } -/// One overlaid trace's data source with optional per-series style overrides. -/// Only `dataset` is required. -#[derive(Clone, Debug, PartialEq)] -pub struct SeriesBinding { - pub id: SeriesId, - pub dataset: DatasetId, - pub color: Option, - pub label: Option, - pub scale: f64, - pub visible: bool, -} - -impl SeriesBinding { - pub fn new(dataset: impl Into) -> Self { - Self { - id: SeriesId::default(), - dataset: dataset.into(), - color: None, - label: None, - scale: 1.0, - visible: true, - } - } -} - /// How a multi-dataset plot combines its members. Line kinds: `Superimposed` /// overlays every trace on a shared axis; `Offset` steps each successive trace /// vertically (and optionally horizontally, for a pseudo-3D look). Field kind: @@ -234,14 +209,14 @@ pub struct DataBinding { } impl DataBinding { - pub fn single(dataset: impl Into) -> Self { + pub fn single(dataset: &Dataset) -> Self { Self { - series: vec![SeriesBinding::new(dataset)], + series: SeriesBinding::from_dataset(dataset).into_iter().collect(), } } pub fn primary_dataset(&self) -> Option { - self.series.first().map(|s| s.dataset) + self.series.first().map(|s| s.source.resource) } /// Result overlays belonging to the primary dataset follow the visibility @@ -251,11 +226,13 @@ impl DataBinding { } pub fn dataset_ids(&self) -> Vec { - self.series.iter().map(|s| s.dataset).collect() + self.series.iter().map(|s| s.source.resource).collect() } pub fn contains_dataset(&self, dataset: DatasetId) -> bool { - self.series.iter().any(|s| s.dataset == dataset) + self.series + .iter() + .any(|series| series.source.resource == dataset) } } diff --git a/crates/core/src/state/electrophysiology.rs b/crates/core/src/state/electrophysiology.rs index 7623d55..d60f1c9 100644 --- a/crates/core/src/state/electrophysiology.rs +++ b/crates/core/src/state/electrophysiology.rs @@ -1,5 +1,6 @@ use super::*; use plotx_analysis::electrophysiology::{self, PeakMode, TimeWindow}; +use std::sync::{Arc, OnceLock}; fn new_resource_id() -> DatasetId { DatasetId::new() @@ -75,7 +76,12 @@ impl Default for ElectrophysiologyProcessing { pub struct ElectrophysiologyDataset { #[serde(default = "new_resource_id")] pub resource_id: DatasetId, + /// Persisted mapping from stable channel keys to dataset-local field ids. + pub field_catalog: FieldCatalog, pub data: ElectrophysiologyData, + /// Calculated from loaded samples and omitted from project metadata. + #[serde(skip, default)] + field_keys: OnceLock]>>, pub name: Option, pub metadata: RecordingMetadata, pub processing: ElectrophysiologyProcessing, @@ -115,9 +121,12 @@ impl ElectrophysiologyDataset { .map(|v| v.len() as f64 / data.sample_rate_hz) .fold(0.0, f64::max); let stimulus = stimulus.or_else(|| data.protocol.as_deref().and_then(suggested_stimulus)); + let field_keys = crate::state::electrophysiology_channel_keys(&data); Self { resource_id: new_resource_id(), + field_catalog: crate::state::electrophysiology_field_catalog_for_keys(&field_keys), data, + field_keys: OnceLock::from(field_keys), name: None, metadata, processing: ElectrophysiologyProcessing::default(), @@ -133,6 +142,15 @@ impl ElectrophysiologyDataset { } } + pub(crate) fn field_key(&self, channel: usize) -> Option<&str> { + self.field_keys().get(channel).and_then(Option::as_deref) + } + + pub(crate) fn field_keys(&self) -> &[Option] { + self.field_keys + .get_or_init(|| crate::state::electrophysiology_channel_keys(&self.data)) + } + pub fn processed_trace( &self, sweep: usize, @@ -222,6 +240,17 @@ impl ElectrophysiologyDataset { figure } + pub fn figure_for_field(&self, field: FieldId) -> Option
{ + let channel = (0..self.data.channels.len()).find(|&index| { + self.field_key(index) + .and_then(|key| self.field_catalog.id_for_key(key)) + == Some(field) + })?; + let mut copy = self.clone(); + copy.selected_channel = channel; + Some(copy.figure()) + } + pub fn stimulus_values( &self, ) -> Result<(Vec, ElectricalQuantity), ElectrophysiologyAnalysisError> { diff --git a/crates/core/src/state/field.rs b/crates/core/src/state/field.rs new file mode 100644 index 0000000..99f5529 --- /dev/null +++ b/crates/core/src/state/field.rs @@ -0,0 +1,675 @@ +use super::{DatasetId, FieldCatalog, FieldId, electrophysiology_channel_key}; +use crate::automation::{ + CAP_FIELD_AFM_MAP, CAP_FIELD_BOUNDED, CAP_FIELD_COLORED_RASTER_2D, CAP_FIELD_CURVE_1D, + CAP_FIELD_FORCE_CURVE, CAP_FIELD_LOCATION_SCALE, CAP_FIELD_NMR_CONTOUR, CAP_FIELD_NMR_SPECTRUM, + CAP_FIELD_NMR_STACK, CAP_FIELD_NOISE_SCALE, CAP_FIELD_SCALAR_GRID_2D_REGULAR, CAP_FIELD_SIGNED, + CAP_FIELD_SWEEP_COLLECTION, CAP_FIELD_TABLE, CapabilityId, +}; +use plotx_figure::{ + ColorSource, ContourBasePolicy, ContourLevelSpec, ContourSpec, ContourStyle, + EstimatorSelection, HeatmapSpec, ImageSpec, LineEncoding, PositiveFiniteF64, SeriesEncoding, + UnitInterval, +}; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; + +impl super::Dataset { + /// Describes the stable child fields a dataset currently exposes. This is a + /// data adapter, not an encoding registry: callers decide applicability + /// solely from the returned capabilities. + pub fn field_descriptors(&self) -> Vec { + let curve = |extra: &[&str]| { + FieldCapabilities::new( + std::iter::once(CapabilityId::new(CAP_FIELD_CURVE_1D)).chain( + extra + .iter() + .map(|capability| CapabilityId::new(*capability)), + ), + ) + }; + let scalar = |regular, extra: &[&str]| scalar_grid_capabilities(regular, extra); + let descriptor = + |id, local_id: &str, name: &str, capabilities, dimensions, units, recommended: &str| { + FieldDescriptor { + id, + local_id: local_id.to_owned(), + name: name.to_owned(), + capabilities, + dimensions, + units, + metadata: FieldMetadata(BTreeMap::from([( + "recommended_encoding".to_owned(), + recommended.to_owned(), + )])), + } + }; + match self { + Self::Nmr(nmr) => nmr + .field_catalog + .id_for_key("nmr.real") + .into_iter() + .map(|id| { + descriptor( + id, + "nmr.real", + "Real", + curve(&[CAP_FIELD_NMR_SPECTRUM]), + vec![nmr.spectrum.values.len()], + vec!["ppm".to_owned()], + "line", + ) + }) + .collect(), + Self::Nmr2D(nmr) if nmr.is_true_2d() => [ + nmr.field_catalog.id_for_key("nmr.real").map(|id| { + descriptor( + id, + "nmr.real", + "Real", + scalar( + nmr_grid_is_regular(nmr), + &[ + CAP_FIELD_SIGNED, + CAP_FIELD_NOISE_SCALE, + CAP_FIELD_NMR_CONTOUR, + ], + ), + vec![nmr.data.rows, nmr.data.cols], + vec!["ppm".to_owned(), "ppm".to_owned()], + "contour", + ) + }), + nmr.field_catalog.id_for_key("nmr.magnitude").map(|id| { + descriptor( + id, + "nmr.magnitude", + "Magnitude", + scalar(nmr_grid_is_regular(nmr), &[CAP_FIELD_BOUNDED]), + vec![nmr.data.rows, nmr.data.cols], + vec!["ppm".to_owned(), "ppm".to_owned()], + "heatmap", + ) + }), + ] + .into_iter() + .flatten() + .collect(), + Self::Nmr2D(nmr) => nmr + .field_catalog + .id_for_key("nmr.stack") + .into_iter() + .map(|id| { + descriptor( + id, + "nmr.stack", + "Stack", + curve(&[CAP_FIELD_NMR_STACK]), + vec![nmr.data.cols], + vec!["ppm".to_owned()], + "line", + ) + }) + .collect(), + Self::Table(table) => { + let Ok(row_count) = + usize::try_from(table.typed_state.envelope.revision.snapshot.row_count) + else { + return Vec::new(); + }; + table + .field_catalog + .id_for_key("table.default_series") + .into_iter() + .map(|id| { + descriptor( + id, + "table.default_series", + "Default series", + curve(&[CAP_FIELD_TABLE]), + vec![row_count], + Vec::new(), + "line", + ) + }) + .collect() + } + Self::Electrophysiology(recording) => recording + .data + .channels + .iter() + .enumerate() + .filter_map(|(index, channel)| { + let key = electrophysiology_channel_key(recording, index)?; + let id = recording.field_catalog.id_for_key(&key)?; + Some(descriptor( + id, + &key, + &channel.name, + curve(&[CAP_FIELD_SWEEP_COLLECTION]), + vec![recording.data.sweeps.len()], + vec![channel.unit.symbol.clone()], + "line", + )) + }) + .collect(), + Self::Afm(afm) => { + let mut fields = afm + .data + .images + .iter() + .zip(afm.image_field_keys.iter()) + .filter_map(|(channel, key)| { + let id = afm.field_catalog.id_for_key(key)?; + Some(descriptor( + id, + key, + &channel.name, + scalar( + true, + &[ + CAP_FIELD_LOCATION_SCALE, + CAP_FIELD_BOUNDED, + CAP_FIELD_AFM_MAP, + ], + ), + vec![channel.height, channel.width], + vec![channel.lateral_unit.clone(), channel.scale.unit.clone()], + "heatmap", + )) + }) + .collect::>(); + if let Some(forces) = &afm.data.forces + && let Some(id) = afm.field_catalog.id_for_key("afm.force_curve") + { + fields.push(descriptor( + id, + "afm.force_curve", + "Force curve", + curve(&[CAP_FIELD_FORCE_CURVE]), + vec![forces.samples_per_curve], + vec![forces.signal_scale.unit.clone()], + "line", + )); + } + fields + } + } + } + + pub fn default_field_id(&self) -> Option { + self.field_descriptors().first().map(|field| field.id) + } + + pub fn has_field(&self, id: FieldId) -> bool { + self.field_descriptors().iter().any(|field| field.id == id) + } + + pub fn field_descriptor(&self, id: FieldId) -> Option { + self.field_descriptors() + .into_iter() + .find(|field| field.id == id) + } + + /// A persisted encoding is valid only when its source field exposes the + /// matching rendering capability. This is used at every persistence + /// boundary as well as by UI discovery. + pub fn supports_encoding(&self, id: FieldId, encoding: &SeriesEncoding) -> bool { + let Some(descriptor) = self.field_descriptor(id) else { + return false; + }; + let required = match encoding { + SeriesEncoding::Line(_) => CAP_FIELD_CURVE_1D, + SeriesEncoding::Contour(_) | SeriesEncoding::Heatmap(_) => { + CAP_FIELD_SCALAR_GRID_2D_REGULAR + } + SeriesEncoding::Image(_) => CAP_FIELD_COLORED_RASTER_2D, + }; + descriptor.capabilities.supports(&[required]) + } + + /// Provider adapter for one concrete field. The dispatch belongs to the + /// provider boundary, not the chart/encoding registry: each arm receives a + /// field id plus an already capability-validated encoding. + pub(crate) fn encoded_field_figure( + &self, + id: FieldId, + encoding: &SeriesEncoding, + ) -> Option { + let descriptor = self.field_descriptor(id)?; + if !self.supports_encoding(id, encoding) { + return None; + } + match self { + Self::Nmr(nmr) => match encoding { + SeriesEncoding::Line(_) => Some(crate::figures::build_figure( + &nmr.data, + &nmr.spectrum, + &nmr.peaks.resolve(), + )), + SeriesEncoding::Contour(_) + | SeriesEncoding::Heatmap(_) + | SeriesEncoding::Image(_) => None, + }, + Self::Nmr2D(nmr) => nmr.encoded_field_figure(&descriptor, encoding), + Self::Table(table) => match encoding { + SeriesEncoding::Line(_) => Some(crate::figures::apply_peak_labels( + table.figure(), + &table.peaks.resolve(), + )), + SeriesEncoding::Contour(_) + | SeriesEncoding::Heatmap(_) + | SeriesEncoding::Image(_) => None, + }, + Self::Electrophysiology(recording) => match encoding { + SeriesEncoding::Line(_) => recording.figure_for_field(id), + SeriesEncoding::Contour(_) + | SeriesEncoding::Heatmap(_) + | SeriesEncoding::Image(_) => None, + }, + Self::Afm(afm) => match encoding { + SeriesEncoding::Line(_) => afm.force_figure(id), + SeriesEncoding::Contour(contour) => afm.contour_figure(id, contour), + SeriesEncoding::Heatmap(heatmap) => afm.map_figure(id, heatmap.colormap), + SeriesEncoding::Image(_) => None, + }, + } + } + + /// Validate that every key produced by the concrete provider has a unique, + /// persisted field identity. Project decoding calls this before bindings are + /// accepted, so a decoder change cannot silently retarget a series. + pub fn validate_field_catalog(&self) -> Result<(), String> { + let catalog = self.field_catalog(); + catalog.validate_for_keys(self.all_field_keys()) + } + + fn field_catalog(&self) -> &FieldCatalog { + match self { + Self::Nmr(dataset) => &dataset.field_catalog, + Self::Nmr2D(dataset) => &dataset.field_catalog, + Self::Table(dataset) => &dataset.field_catalog, + Self::Electrophysiology(dataset) => &dataset.field_catalog, + Self::Afm(dataset) => &dataset.field_catalog, + } + } + + fn all_field_keys(&self) -> Vec { + match self { + Self::Nmr(_) => vec!["nmr.real".to_owned()], + // These keys stay allocated while the processing state is pseudo-2D. + // A binding to an inactive field is rejected on save/load instead of + // being reassigned to the stack field. + Self::Nmr2D(_) => vec![ + "nmr.real".to_owned(), + "nmr.magnitude".to_owned(), + "nmr.stack".to_owned(), + ], + Self::Table(_) => vec!["table.default_series".to_owned()], + Self::Electrophysiology(dataset) => (0..dataset.data.channels.len()) + .filter_map(|index| electrophysiology_channel_key(dataset, index)) + .collect(), + Self::Afm(dataset) => dataset + .image_field_keys + .iter() + .cloned() + .chain( + dataset + .data + .forces + .as_ref() + .map(|_| "afm.force_curve".to_owned()), + ) + .collect(), + } + } +} + +/// Central capability gate for scalar fields. A provider must derive +/// `regular` from its actual coordinate representation, not from its domain. +pub fn scalar_grid_capabilities(regular: bool, extra: &[&str]) -> FieldCapabilities { + FieldCapabilities::new( + regular + .then_some(CapabilityId::new(CAP_FIELD_SCALAR_GRID_2D_REGULAR)) + .into_iter() + .chain( + extra + .iter() + .map(|capability| CapabilityId::new(*capability)), + ), + ) +} + +fn nmr_grid_is_regular(dataset: &super::Nmr2DDataset) -> bool { + let plotx_processing::Processed2D::Ft(spectrum) = &dataset.processed else { + return false; + }; + axis_is_linear(&spectrum.f1_ppm) && axis_is_linear(&spectrum.f2_ppm) +} + +pub(crate) fn axis_is_linear(values: &[f64]) -> bool { + let Some((&first, rest)) = values.split_first() else { + return false; + }; + if rest.is_empty() { + return true; + } + let last = *rest.last().unwrap_or(&first); + let step = (last - first) / (values.len() - 1) as f64; + values.iter().enumerate().all(|(index, value)| { + let expected = first + step * index as f64; + (*value - expected).abs() <= 1e-9 * expected.abs().max(1.0) + }) +} + +/// A reference to a field child resource. It is a data source, never a plot +/// component: contour properties remain addressed by the owning `SeriesId`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct FieldRef { + pub resource: DatasetId, + pub field: FieldId, +} + +/// Runtime-only revision of immutable field data. It is purposefully separate +/// from persisted field identity and is not part of the project format yet. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct FieldVersion(pub u64); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct VersionedFieldRef { + pub field: FieldRef, + pub version: FieldVersion, +} + +#[derive(Clone, Debug)] +pub struct FieldSnapshot { + pub source: VersionedFieldRef, + pub payload: FieldPayload, + pub provenance: FieldProvenance, +} + +/// Field payloads stay arity- and representation-specific. In particular, a +/// colored raster has no scalar statistics and cannot reach contour resolution. +#[derive(Clone, Debug)] +pub enum FieldPayload { + ScalarGrid2D(ScalarGrid2D), + Curve1D(Curve1D), + ColoredRaster2D(ColoredRaster2D), +} + +impl FieldPayload { + pub fn scalar_grid(&self) -> Option<&ScalarGrid2D> { + match self { + Self::ScalarGrid2D(grid) => Some(grid), + Self::Curve1D(_) | Self::ColoredRaster2D(_) => None, + } + } + + pub fn summary(&self) -> Option { + self.scalar_grid().and_then(ScalarGrid2D::summary) + } + + /// Capabilities implied by the concrete payload representation. Providers + /// may add semantic capabilities (signed, noise scale, units), but must not + /// claim a regular scalar grid for an explicitly sampled one. + pub fn intrinsic_capabilities(&self) -> FieldCapabilities { + match self { + Self::ScalarGrid2D(grid) if grid.is_regular() => scalar_grid_capabilities(true, &[]), + Self::ScalarGrid2D(_) => scalar_grid_capabilities(false, &[]), + Self::Curve1D(_) => FieldCapabilities::new([CapabilityId::new(CAP_FIELD_CURVE_1D)]), + Self::ColoredRaster2D(_) => { + FieldCapabilities::new([CapabilityId::new(CAP_FIELD_COLORED_RASTER_2D)]) + } + } + } +} + +#[derive(Clone, Debug)] +pub struct ScalarGrid2D { + pub values: Arc<[f32]>, + pub rows: usize, + pub cols: usize, + pub x: AxisSampling, + pub y: AxisSampling, +} + +impl ScalarGrid2D { + pub fn is_regular(&self) -> bool { + matches!(self.x, AxisSampling::Linear { .. }) + && matches!(self.y, AxisSampling::Linear { .. }) + } + + pub fn summary(&self) -> Option { + let mut values = self + .values + .iter() + .copied() + .filter(|value| value.is_finite()); + let first = values.next()? as f64; + let (min, max) = values.fold((first, first), |(min, max), value| { + let value = value as f64; + (min.min(value), max.max(value)) + }); + Some(FieldSummary { min, max }) + } +} + +#[derive(Clone, Debug)] +pub enum AxisSampling { + Linear { start: f64, end: f64 }, + Explicit(Arc<[f64]>), +} + +#[derive(Clone, Debug)] +pub struct Curve1D { + pub x: Arc<[f64]>, + pub values: Arc<[f32]>, +} + +#[derive(Clone, Debug)] +pub struct ColoredRaster2D { + pub pixels: Arc<[u8]>, + pub rows: usize, + pub cols: usize, + pub format: RasterFormat, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RasterFormat { + Rgb8, + Rgba8, +} + +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub struct FieldProvenance { + pub source_fingerprint: Option, + pub metadata: BTreeMap, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FieldSummary { + pub min: f64, + pub max: f64, +} + +/// Stable child-resource metadata, including the capabilities used by encoding +/// and chart applicability checks. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FieldDescriptor { + pub id: FieldId, + pub local_id: String, + pub name: String, + pub capabilities: FieldCapabilities, + pub dimensions: Vec, + pub units: Vec, + pub metadata: FieldMetadata, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FieldCapabilities(BTreeSet); + +impl FieldCapabilities { + pub fn new(values: impl IntoIterator) -> Self { + Self(values.into_iter().collect()) + } + + pub fn contains(&self, capability: &str) -> bool { + self.0.contains(capability) + } + + /// Reject scalar-grid renderers for a colored raster even when a malformed + /// provider advertises both mutually exclusive capabilities. + pub fn supports(&self, required: &[&str]) -> bool { + required.iter().all(|capability| self.contains(capability)) + && !(required.contains(&CAP_FIELD_SCALAR_GRID_2D_REGULAR) + && self.contains(CAP_FIELD_COLORED_RASTER_2D)) + } + + pub fn iter(&self) -> impl Iterator { + self.0.iter() + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FieldMetadata(pub BTreeMap); + +impl FieldMetadata { + pub fn recommended_encoding(&self) -> Option<&str> { + self.0.get("recommended_encoding").map(String::as_str) + } +} + +/// A creation-time request. It is resolved to a concrete `SeriesEncoding` +/// before a `SeriesBinding` enters document state. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum RequestedChart { + #[default] + Auto, + Line, + Contour, + Heatmap, + Image, +} + +/// Optional domain adapters may choose this profile, but the encoding factory +/// itself only considers capability and metadata inputs. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PresentationProfile { + pub preferred_encoding: Option, +} + +/// 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( + source_capabilities: &FieldCapabilities, + semantic_metadata: &FieldMetadata, + requested_chart: RequestedChart, + presentation_profile: &PresentationProfile, +) -> SeriesEncoding { + let requested_chart = match requested_chart { + RequestedChart::Auto => presentation_profile + .preferred_encoding + .or_else(|| match semantic_metadata.recommended_encoding() { + Some("line") => Some(RequestedChart::Line), + Some("contour") => Some(RequestedChart::Contour), + Some("heatmap") => Some(RequestedChart::Heatmap), + Some("image") => Some(RequestedChart::Image), + _ => None, + }) + .unwrap_or_else(|| { + if source_capabilities.supports(&[CAP_FIELD_COLORED_RASTER_2D]) { + RequestedChart::Image + } else if source_capabilities.supports(&[CAP_FIELD_SCALAR_GRID_2D_REGULAR]) { + RequestedChart::Heatmap + } else { + RequestedChart::Line + } + }), + concrete => concrete, + }; + + match requested_chart { + RequestedChart::Contour + if source_capabilities.supports(&[CAP_FIELD_SCALAR_GRID_2D_REGULAR]) => + { + SeriesEncoding::Contour(default_contour_spec(source_capabilities)) + } + RequestedChart::Heatmap + if source_capabilities.supports(&[CAP_FIELD_SCALAR_GRID_2D_REGULAR]) => + { + SeriesEncoding::Heatmap(HeatmapSpec::default()) + } + RequestedChart::Image if source_capabilities.supports(&[CAP_FIELD_COLORED_RASTER_2D]) => { + SeriesEncoding::Image(ImageSpec::default()) + } + RequestedChart::Line if source_capabilities.contains(CAP_FIELD_CURVE_1D) => { + SeriesEncoding::Line(LineEncoding::default()) + } + // A stale explicit request must still materialize to a complete, + // applicable document encoding rather than carrying Auto forward. + RequestedChart::Auto + | RequestedChart::Line + | RequestedChart::Contour + | RequestedChart::Heatmap + | RequestedChart::Image + if source_capabilities.supports(&[CAP_FIELD_COLORED_RASTER_2D]) => + { + SeriesEncoding::Image(ImageSpec::default()) + } + RequestedChart::Auto + | RequestedChart::Line + | RequestedChart::Contour + | RequestedChart::Heatmap + | RequestedChart::Image + if source_capabilities.supports(&[CAP_FIELD_SCALAR_GRID_2D_REGULAR]) => + { + SeriesEncoding::Heatmap(HeatmapSpec::default()) + } + RequestedChart::Auto + | RequestedChart::Line + | RequestedChart::Contour + | RequestedChart::Heatmap + | RequestedChart::Image => SeriesEncoding::Line(LineEncoding::default()), + } +} + +pub fn default_contour_spec(capabilities: &FieldCapabilities) -> ContourSpec { + let estimator = EstimatorSelection::Frozen { + estimator: "robust_difference_mad".to_owned(), + version: 1, + }; + let base = if capabilities.contains(CAP_FIELD_NOISE_SCALE) { + ContourBasePolicy::NoiseSigma { + multiplier: PositiveFiniteF64::new(5.0).expect("literal multiplier is valid"), + estimator, + } + } else if capabilities.contains(CAP_FIELD_LOCATION_SCALE) { + ContourBasePolicy::BackgroundScale { + multiplier: PositiveFiniteF64::new(5.0).expect("literal multiplier is valid"), + estimator, + } + } else if capabilities.contains(CAP_FIELD_BOUNDED) { + ContourBasePolicy::FractionOfRange( + UnitInterval::new(0.04).expect("literal fraction is valid"), + ) + } else { + ContourBasePolicy::Absolute(PositiveFiniteF64::new(1.0).expect("literal base is valid")) + }; + let level = ContourLevelSpec { + base, + count: 14, + ratio: PositiveFiniteF64::new(1.35).expect("literal ratio is valid"), + }; + ContourSpec { + positive: level.clone(), + negative: capabilities.contains(CAP_FIELD_SIGNED).then_some(level), + style: ContourStyle { + positive_color: ColorSource::Explicit(plotx_figure::Color::TRACE), + negative_color: ColorSource::Explicit(plotx_figure::Color::rgb(0xd1, 0x24, 0x2a)), + ..ContourStyle::default() + }, + } +} + +#[cfg(test)] +#[path = "field_tests.rs"] +mod tests; diff --git a/crates/core/src/state/field_catalog.rs b/crates/core/src/state/field_catalog.rs new file mode 100644 index 0000000..89241c4 --- /dev/null +++ b/crates/core/src/state/field_catalog.rs @@ -0,0 +1,232 @@ +use super::FieldId; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; + +/// Dataset-owned allocator and persisted lookup table for field child resources. +/// The key is supplied by the provider and identifies the actual channel/plane; +/// the numeric `FieldId` is only an owner-local reference, never an array index. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FieldCatalog { + next_id: u64, + key_to_id: BTreeMap, +} + +impl FieldCatalog { + pub fn for_keys(keys: impl IntoIterator) -> Self { + let mut catalog = Self { + next_id: 0, + key_to_id: BTreeMap::new(), + }; + for key in keys { + catalog.allocate(key); + } + catalog + } + + pub fn id_for_key(&self, key: &str) -> Option { + self.key_to_id.get(key).copied() + } + + fn allocate(&mut self, key: String) { + if self.key_to_id.contains_key(&key) { + return; + } + let id = FieldId::new(self.next_id); + self.next_id = self + .next_id + .checked_add(1) + .expect("dataset field identity allocator overflow"); + self.key_to_id.insert(key, id); + } + + pub(crate) fn validate_for_keys(&self, keys: Vec) -> Result<(), String> { + let supplied_len = keys.len(); + let expected = keys.into_iter().collect::>(); + if expected.len() != supplied_len + || expected.len() != self.key_to_id.len() + || !expected.iter().all(|key| self.key_to_id.contains_key(key)) + { + return Err("field catalog does not match this dataset's stable field keys".to_owned()); + } + let ids = self.key_to_id.values().copied().collect::>(); + if ids.len() != self.key_to_id.len() { + return Err("field catalog contains duplicate field identities".to_owned()); + } + let minimum_next = ids + .last() + .map_or(Some(0), |id| id.get().checked_add(1)) + .ok_or_else(|| "field catalog identity allocator overflow".to_owned())?; + if self.next_id < minimum_next { + return Err("field catalog allocator would reuse an existing identity".to_owned()); + } + Ok(()) + } +} + +pub(crate) fn nmr_field_catalog() -> FieldCatalog { + FieldCatalog::for_keys(["nmr.real".to_owned()]) +} + +pub(crate) fn nmr2d_field_catalog() -> FieldCatalog { + FieldCatalog::for_keys([ + "nmr.real".to_owned(), + "nmr.magnitude".to_owned(), + "nmr.stack".to_owned(), + ]) +} + +pub(crate) fn table_field_catalog() -> FieldCatalog { + FieldCatalog::for_keys(["table.default_series".to_owned()]) +} + +#[cfg(test)] +pub(crate) fn afm_field_catalog(data: &plotx_io::AfmData) -> FieldCatalog { + let image_keys = afm_channel_keys(data); + afm_field_catalog_for_keys(data, &image_keys) +} + +pub(crate) fn afm_channel_keys(data: &plotx_io::AfmData) -> Arc<[String]> { + data.images.iter().map(afm_channel_key).collect() +} + +pub(crate) fn afm_field_catalog_for_keys( + data: &plotx_io::AfmData, + image_keys: &[String], +) -> FieldCatalog { + FieldCatalog::for_keys( + data.forces + .as_ref() + .map(|_| "afm.force_curve".to_owned()) + .into_iter() + .chain(image_keys.iter().cloned()), + ) +} + +pub(crate) fn electrophysiology_channel_keys( + data: &plotx_io::ElectrophysiologyData, +) -> Arc<[Option]> { + (0..data.channels.len()) + .map(|index| electrophysiology_data_channel_key(data, index)) + .collect() +} + +pub(crate) fn electrophysiology_field_catalog_for_keys( + channel_keys: &[Option], +) -> FieldCatalog { + FieldCatalog::for_keys(channel_keys.iter().filter_map(|key| key.clone())) +} + +pub(crate) fn afm_channel_key(channel: &plotx_io::AfmImageChannel) -> String { + #[cfg(test)] + count_afm_channel_key_computation(); + + let mut hash = StableFieldHasher::new(); + hash.write_str(&channel.name); + hash.write_usize(channel.width); + hash.write_usize(channel.height); + hash.write(&channel.scan_size_x.to_le_bytes()); + hash.write(&channel.scan_size_y.to_le_bytes()); + hash.write_str(&channel.lateral_unit); + hash.write(&channel.scale.multiplier.to_le_bytes()); + hash.write(&channel.scale.offset.to_le_bytes()); + hash.write_str(&channel.scale.unit); + hash.write_afm_frame_direction(channel.frame_direction); + for value in channel.raw.iter() { + hash.write(&value.to_le_bytes()); + } + format!("afm.channel.{:016x}", hash.finish()) +} + +pub(crate) fn electrophysiology_channel_key( + recording: &super::ElectrophysiologyDataset, + channel: usize, +) -> Option { + recording.field_key(channel).map(str::to_owned) +} + +fn electrophysiology_data_channel_key( + data: &plotx_io::ElectrophysiologyData, + channel: usize, +) -> Option { + let metadata = data.channels.get(channel)?; + let mut hash = StableFieldHasher::new(); + hash.write_str(&metadata.name); + hash.write_str(&metadata.unit.symbol); + hash.write_electrical_quantity(metadata.unit.quantity); + for sweep in &data.sweeps { + for value in sweep.channels.get(channel)? { + hash.write(&value.to_le_bytes()); + } + } + Some(format!("electrophysiology.channel.{:016x}", hash.finish())) +} + +struct StableFieldHasher(u64); + +impl StableFieldHasher { + const OFFSET: u64 = 0xcbf2_9ce4_8422_2325; + const PRIME: u64 = 0x0000_0100_0000_01b3; + + fn new() -> Self { + Self(Self::OFFSET) + } + + fn write(&mut self, bytes: &[u8]) { + for byte in bytes { + self.0 ^= u64::from(*byte); + self.0 = self.0.wrapping_mul(Self::PRIME); + } + } + + fn write_str(&mut self, value: &str) { + self.write(value.as_bytes()); + self.write(&[0]); + } + + fn write_usize(&mut self, value: usize) { + self.write(&(value as u64).to_le_bytes()); + } + + fn write_afm_frame_direction(&mut self, value: plotx_io::AfmFrameDirection) { + let value = match value { + plotx_io::AfmFrameDirection::Trace => 0, + plotx_io::AfmFrameDirection::Retrace => 1, + plotx_io::AfmFrameDirection::Unknown => 2, + }; + self.write(&[value]); + } + + fn write_electrical_quantity(&mut self, value: plotx_io::ElectricalQuantity) { + let value = match value { + plotx_io::ElectricalQuantity::Voltage => 0, + plotx_io::ElectricalQuantity::Current => 1, + plotx_io::ElectricalQuantity::Unknown => 2, + }; + self.write(&[value]); + } + + fn finish(self) -> u64 { + self.0 + } +} + +#[cfg(test)] +thread_local! { + static AFM_CHANNEL_KEY_COMPUTATIONS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[cfg(test)] +fn count_afm_channel_key_computation() { + AFM_CHANNEL_KEY_COMPUTATIONS.with(|count| count.set(count.get() + 1)); +} + +#[cfg(test)] +pub(crate) fn reset_afm_channel_key_computations() { + AFM_CHANNEL_KEY_COMPUTATIONS.with(|count| count.set(0)); +} + +#[cfg(test)] +pub(crate) fn afm_channel_key_computations() -> usize { + AFM_CHANNEL_KEY_COMPUTATIONS.with(std::cell::Cell::get) +} diff --git a/crates/core/src/state/field_tests.rs b/crates/core/src/state/field_tests.rs new file mode 100644 index 0000000..c0e0110 --- /dev/null +++ b/crates/core/src/state/field_tests.rs @@ -0,0 +1,404 @@ +use super::*; +use crate::state::{AfmDataset, Dataset, ElectrophysiologyDataset, Nmr2DDataset}; + +#[test] +fn colored_raster_cannot_supply_scalar_statistics() { + let payload = FieldPayload::ColoredRaster2D(ColoredRaster2D { + pixels: Arc::from(vec![255, 0, 0]), + rows: 1, + cols: 1, + format: RasterFormat::Rgb8, + }); + assert!(payload.scalar_grid().is_none()); + assert!(payload.summary().is_none()); +} + +#[test] +fn regular_capability_selects_contour_without_domain_knowledge() { + let capabilities = + FieldCapabilities::new([CapabilityId::new(CAP_FIELD_SCALAR_GRID_2D_REGULAR)]); + let encoding = default_encoding( + &capabilities, + &FieldMetadata::default(), + RequestedChart::Contour, + &PresentationProfile::default(), + ); + assert!(matches!(encoding, SeriesEncoding::Contour(_))); +} + +#[test] +fn unregistered_regular_grid_provider_gets_scalar_encodings() { + let provider_grid = ScalarGrid2D { + values: Arc::from(vec![0.0, 1.0, 2.0, 3.0]), + rows: 2, + cols: 2, + x: AxisSampling::Linear { + start: 0.0, + end: 1.0, + }, + y: AxisSampling::Linear { + start: 0.0, + end: 1.0, + }, + }; + let capabilities = FieldPayload::ScalarGrid2D(provider_grid).intrinsic_capabilities(); + let ids = crate::state::encoding_descriptors_for(&capabilities) + .into_iter() + .map(|descriptor| descriptor.id) + .collect::>(); + assert_eq!(ids, ["contour", "heatmap"]); +} + +#[test] +fn colored_raster_auto_materializes_an_image_encoding() { + let capabilities = FieldCapabilities::new([CapabilityId::new(CAP_FIELD_COLORED_RASTER_2D)]); + let encoding = default_encoding( + &capabilities, + &FieldMetadata::default(), + RequestedChart::Auto, + &PresentationProfile::default(), + ); + assert!(matches!(encoding, SeriesEncoding::Image(_))); +} + +#[test] +fn explicitly_sampled_grid_does_not_claim_the_regular_grid_capability() { + let payload = FieldPayload::ScalarGrid2D(ScalarGrid2D { + values: Arc::from(vec![0.0, 1.0, 2.0, 3.0]), + rows: 2, + cols: 2, + x: AxisSampling::Explicit(Arc::from(vec![0.0, 0.5])), + y: AxisSampling::Linear { + start: 0.0, + end: 1.0, + }, + }); + assert!( + !payload + .intrinsic_capabilities() + .contains(CAP_FIELD_SCALAR_GRID_2D_REGULAR) + ); + let ids = crate::state::encoding_descriptors_for(&payload.intrinsic_capabilities()) + .into_iter() + .map(|descriptor| descriptor.id) + .collect::>(); + assert!( + !ids.contains(&"contour"), + "a new provider with explicit sampling must not need a registry edit to be excluded" + ); +} + +#[test] +fn raster_falls_back_to_image_instead_of_a_scalar_or_line_encoding() { + let capabilities = FieldCapabilities::new([CapabilityId::new(CAP_FIELD_COLORED_RASTER_2D)]); + let encoding = default_encoding( + &capabilities, + &FieldMetadata::default(), + RequestedChart::Contour, + &PresentationProfile::default(), + ); + assert!(matches!(encoding, SeriesEncoding::Image(_))); +} + +#[test] +fn afm_channel_ids_follow_persisted_keys_after_channel_reordering() { + let channel = |name: &str, raw: Vec| plotx_io::AfmImageChannel { + name: name.to_owned(), + width: 2, + height: 2, + scan_size_x: 1.0, + scan_size_y: 1.0, + lateral_unit: "nm".to_owned(), + scale: plotx_io::AfmScale { + multiplier: 1.0, + offset: 0.0, + unit: "nm".to_owned(), + }, + raw: Arc::from(raw), + frame_direction: plotx_io::AfmFrameDirection::Trace, + }; + let first = channel("Height", vec![1, 2, 3, 4]); + let second = channel("Phase", vec![5, 6, 7, 8]); + let data = plotx_io::AfmData { + images: vec![first.clone(), second.clone()], + forces: None, + source: "test".to_owned(), + import_warnings: Vec::new(), + }; + let catalog = crate::state::afm_field_catalog(&data); + let height = catalog + .id_for_key(&crate::state::afm_channel_key(&first)) + .unwrap(); + let phase = catalog + .id_for_key(&crate::state::afm_channel_key(&second)) + .unwrap(); + + let mut reordered = AfmDataset::load(plotx_io::AfmData { + images: vec![second, first], + ..data + }); + reordered.field_catalog = catalog; + let dataset = Dataset::Afm(Box::new(reordered)); + dataset.validate_field_catalog().unwrap(); + let fields = dataset.field_descriptors(); + assert_eq!( + fields + .iter() + .find(|field| field.name == "Height") + .unwrap() + .id, + height + ); + assert_eq!( + fields + .iter() + .find(|field| field.name == "Phase") + .unwrap() + .id, + phase + ); +} + +#[test] +fn force_curve_id_is_not_derived_from_the_image_count() { + let data = plotx_io::AfmData { + images: Vec::new(), + forces: Some(plotx_io::AfmForceSet { + grid_width: 1, + grid_height: 1, + samples_per_curve: 1, + raw: Arc::from(vec![0]), + signal_scale: plotx_io::AfmScale { + multiplier: 1.0, + offset: 0.0, + unit: "V".to_owned(), + }, + sample_period_s: None, + z_positions: None, + display_order: Arc::from(vec![0]), + approach_samples: 1, + deflection_sensitivity_m_per_v: None, + spring_constant_n_per_m: None, + }), + source: "test".to_owned(), + import_warnings: Vec::new(), + }; + let catalog = crate::state::afm_field_catalog(&data); + assert_eq!(catalog.id_for_key("afm.force_curve"), Some(FieldId::new(0))); + let with_image = plotx_io::AfmData { + images: vec![plotx_io::AfmImageChannel { + name: "Height".to_owned(), + width: 1, + height: 1, + scan_size_x: 1.0, + scan_size_y: 1.0, + lateral_unit: "nm".to_owned(), + scale: plotx_io::AfmScale { + multiplier: 1.0, + offset: 0.0, + unit: "nm".to_owned(), + }, + raw: Arc::from(vec![1]), + frame_direction: plotx_io::AfmFrameDirection::Trace, + }], + ..data + }; + let catalog = crate::state::afm_field_catalog(&with_image); + assert_eq!(catalog.id_for_key("afm.force_curve"), Some(FieldId::new(0))); +} + +#[test] +fn afm_channel_key_is_stable_and_matches_the_loaded_key_cache() { + let channel = afm_channel("Height", 1.0, 0.0, 2.0, 3.0); + let first = crate::state::afm_channel_key(&channel); + let second = crate::state::afm_channel_key(&channel); + assert_eq!(first, second); + + let dataset = Dataset::Afm(Box::new(AfmDataset::load(plotx_io::AfmData { + images: vec![channel], + forces: None, + source: "key cache test".to_owned(), + import_warnings: Vec::new(), + }))); + assert_eq!(dataset.field_descriptors()[0].local_id, first); +} + +#[test] +fn afm_field_keys_are_hashed_once_then_reused_during_rendering() { + crate::state::reset_afm_channel_key_computations(); + let dataset = Dataset::Afm(Box::new(AfmDataset::load(plotx_io::AfmData { + images: vec![afm_channel("Height", 1.0, 0.0, 2.0, 3.0)], + forces: None, + source: "key cache render test".to_owned(), + import_warnings: Vec::new(), + }))); + assert_eq!(crate::state::afm_channel_key_computations(), 1); + + let field = dataset.default_field_id().unwrap(); + assert!(dataset.has_field(field)); + assert!(dataset.supports_encoding(field, &SeriesEncoding::Heatmap(HeatmapSpec::default()))); + assert!( + dataset + .encoded_field_figure(field, &SeriesEncoding::Heatmap(HeatmapSpec::default())) + .is_some() + ); + assert_eq!( + crate::state::afm_channel_key_computations(), + 1, + "descriptor lookup and rendering must reuse the key calculated while loading" + ); +} + +#[test] +fn electrophysiology_keys_include_the_channel_quantity() { + let data = plotx_io::ElectrophysiologyData { + abf_version: "2.0".to_owned(), + sample_rate_hz: 10_000.0, + channels: vec![ + plotx_io::RecordedChannel { + name: "Response".to_owned(), + unit: plotx_io::ElectricalUnit { + symbol: "mV".to_owned(), + quantity: plotx_io::ElectricalQuantity::Voltage, + }, + }, + plotx_io::RecordedChannel { + name: "Response".to_owned(), + unit: plotx_io::ElectricalUnit { + symbol: "mV".to_owned(), + quantity: plotx_io::ElectricalQuantity::Current, + }, + }, + ], + sweeps: vec![plotx_io::Sweep { + start_time_s: 0.0, + channels: vec![vec![1.0, 2.0], vec![1.0, 2.0]], + commands: Vec::new(), + }], + protocol: None, + source: "quantity test".to_owned(), + import_warnings: Vec::new(), + }; + let dataset = Dataset::Electrophysiology(Box::new(ElectrophysiologyDataset::load(data))); + let fields = dataset.field_descriptors(); + assert_eq!(fields.len(), 2); + assert_ne!(fields[0].local_id, fields[1].local_id); + dataset.validate_field_catalog().unwrap(); +} + +fn afm_channel( + name: &str, + multiplier: f64, + offset: f64, + scan_size_x: f64, + scan_size_y: f64, +) -> plotx_io::AfmImageChannel { + plotx_io::AfmImageChannel { + name: name.to_owned(), + width: 2, + height: 2, + scan_size_x, + scan_size_y, + lateral_unit: "nm".to_owned(), + scale: plotx_io::AfmScale { + multiplier, + offset, + unit: "nm".to_owned(), + }, + raw: Arc::from(vec![1, 2, 3, 4]), + frame_direction: plotx_io::AfmFrameDirection::Trace, + } +} + +#[test] +fn magnitude_field_renders_magnitude_instead_of_falling_back_to_real() { + 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, + }; + let dataset = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(plotx_io::NmrData2D { + data: vec![ + num_complex::Complex64::new(-3.0, 4.0), + num_complex::Complex64::new(5.0, 12.0), + num_complex::Complex64::new(8.0, 15.0), + num_complex::Complex64::new(-7.0, 24.0), + ], + rows: 2, + cols: 2, + 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: "magnitude test".to_owned(), + }))); + let magnitude = dataset + .field_descriptors() + .into_iter() + .find(|field| field.local_id == "nmr.magnitude") + .unwrap(); + let figure = dataset + .encoded_field_figure( + magnitude.id, + &SeriesEncoding::Heatmap(HeatmapSpec::default()), + ) + .unwrap(); + let heatmap = figure.heatmap.unwrap(); + assert_eq!(heatmap.values, vec![5.0, 13.0, 17.0, 25.0]); + assert_ne!(heatmap.values, vec![-3.0, 5.0, 8.0, -7.0]); +} + +#[test] +fn default_nmr_contour_uses_the_processed_figure_cache() { + 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, + }; + let dataset = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(plotx_io::NmrData2D { + data: vec![num_complex::Complex64::new(1.0, 0.0); 16], + 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: "cache test".to_owned(), + }))); + let real = dataset + .field_descriptors() + .into_iter() + .find(|field| field.local_id == "nmr.real") + .unwrap(); + crate::state::datasets_2d_figure::reset_synchronous_contour_builds(); + dataset + .encoded_field_figure( + real.id, + &default_encoding( + &real.capabilities, + &real.metadata, + RequestedChart::Contour, + &PresentationProfile::default(), + ), + ) + .unwrap(); + assert_eq!( + crate::state::datasets_2d_figure::synchronous_contour_builds(), + 0, + "the default contour must clone the background-built processed figure" + ); +} diff --git a/crates/core/src/state/identity.rs b/crates/core/src/state/identity.rs index f07030c..b7ac6c8 100644 --- a/crates/core/src/state/identity.rs +++ b/crates/core/src/state/identity.rs @@ -118,6 +118,7 @@ macro_rules! local_id { } local_id!(SeriesId); +local_id!(FieldId); local_id!(ObjectId); #[cfg(test)] diff --git a/crates/core/src/state/mod.rs b/crates/core/src/state/mod.rs index 8a75629..92ae75b 100644 --- a/crates/core/src/state/mod.rs +++ b/crates/core/src/state/mod.rs @@ -29,6 +29,7 @@ mod app_impl_arithmetic; mod app_impl_compute; #[cfg(test)] mod app_impl_compute_tests; +mod app_impl_figures; mod app_impl_io; mod app_impl_linefit; mod app_impl_multiplet; @@ -50,6 +51,8 @@ mod datasets_2d_maps; mod document; mod document_identity; mod electrophysiology; +mod field; +mod field_catalog; mod fit_selection; mod identity; mod interaction; @@ -64,6 +67,7 @@ mod panel_label; mod peaks; mod plot_object; mod region; +mod series_binding; mod size_presets; mod stack; mod statistics; @@ -95,6 +99,18 @@ pub use datasets::*; pub(crate) use datasets_2d_figure::{build_processed_figure, build_processed_figure_cancellable}; pub use document::*; pub use electrophysiology::*; +pub use field::*; +pub use field_catalog::FieldCatalog; +#[cfg(test)] +pub(crate) use field_catalog::{ + afm_channel_key, afm_channel_key_computations, afm_field_catalog, + reset_afm_channel_key_computations, +}; +pub(crate) use field_catalog::{ + afm_channel_keys, afm_field_catalog_for_keys, electrophysiology_channel_key, + electrophysiology_channel_keys, electrophysiology_field_catalog_for_keys, nmr_field_catalog, + nmr2d_field_catalog, table_field_catalog, +}; pub use identity::*; pub use interaction::*; pub use lineage::*; @@ -104,6 +120,7 @@ pub use page_fit::*; pub use panel_label::*; pub use peaks::*; pub use region::*; +pub use series_binding::*; pub use size_presets::*; pub use statistics::*; pub use statistics_report::{ diff --git a/crates/core/src/state/series_binding.rs b/crates/core/src/state/series_binding.rs new file mode 100644 index 0000000..36fdb87 --- /dev/null +++ b/crates/core/src/state/series_binding.rs @@ -0,0 +1,91 @@ +use super::{ + Dataset, DatasetId, FieldId, PresentationProfile, RequestedChart, SeriesId, default_encoding, +}; +use plotx_figure::Color; + +/// One overlaid series' field source. A field is a child resource of its +/// dataset, not a component of the plot object. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SeriesSource { + pub resource: DatasetId, + pub field: FieldId, +} + +/// One overlaid series and its concrete visual encoding. Encoding-specific +/// values (colour, scaling, contour levels) live below `encoding`, so a contour +/// cannot accidentally inherit line-only semantics. +#[derive(Clone, Debug, PartialEq)] +pub struct SeriesBinding { + pub id: SeriesId, + pub source: SeriesSource, + pub visible: bool, + pub label: Option, + pub encoding: plotx_figure::SeriesEncoding, +} + +impl SeriesBinding { + /// Materialize a complete source and encoding from the dataset's actual + /// default field. This is the only production constructor for a new series. + pub fn from_dataset(dataset: &Dataset) -> Option { + let field = dataset.default_field_id()?; + let descriptor = dataset.field_descriptor(field)?; + Some(Self { + id: SeriesId::default(), + source: SeriesSource { + resource: dataset.resource_id(), + field, + }, + visible: true, + label: None, + encoding: default_encoding( + &descriptor.capabilities, + &descriptor.metadata, + RequestedChart::Auto, + &PresentationProfile::default(), + ), + }) + } + + pub fn with_source(source: SeriesSource) -> Self { + Self { + id: SeriesId::default(), + source, + visible: true, + label: None, + encoding: plotx_figure::SeriesEncoding::default(), + } + } + + pub fn line_scale(&self) -> f64 { + match &self.encoding { + plotx_figure::SeriesEncoding::Line(line) => line.scale, + plotx_figure::SeriesEncoding::Contour(_) + | plotx_figure::SeriesEncoding::Heatmap(_) + | plotx_figure::SeriesEncoding::Image(_) => 1.0, + } + } + + pub fn primary_color(&self) -> Option { + match &self.encoding { + plotx_figure::SeriesEncoding::Line(line) => Some(line.color.resolve()), + plotx_figure::SeriesEncoding::Contour(contour) => { + Some(contour.style.positive_color.resolve()) + } + plotx_figure::SeriesEncoding::Heatmap(_) | plotx_figure::SeriesEncoding::Image(_) => { + None + } + } + } + + pub fn set_primary_color(&mut self, color: Color) { + match &mut self.encoding { + plotx_figure::SeriesEncoding::Line(line) => { + line.color = plotx_figure::ColorSource::Explicit(color); + } + plotx_figure::SeriesEncoding::Contour(contour) => { + contour.style.positive_color = plotx_figure::ColorSource::Explicit(color); + } + plotx_figure::SeriesEncoding::Heatmap(_) | plotx_figure::SeriesEncoding::Image(_) => {} + } + } +} diff --git a/crates/core/src/state/stack.rs b/crates/core/src/state/stack.rs index c32cb76..4bb5098 100644 --- a/crates/core/src/state/stack.rs +++ b/crates/core/src/state/stack.rs @@ -8,7 +8,7 @@ impl PlotxApp { let Some(domain) = binding .series .first() - .and_then(|s| self.doc.dataset_index(s.dataset)) + .and_then(|s| self.doc.dataset_index(s.source.resource)) .and_then(|index| self.doc.datasets.get(index)) .map(Dataset::domain) else { @@ -17,7 +17,7 @@ impl PlotxApp { domain.stack_kind().is_some() && binding.series.iter().all(|s| { self.doc - .dataset_index(s.dataset) + .dataset_index(s.source.resource) .and_then(|index| self.doc.datasets.get(index)) .map(Dataset::domain) == Some(domain) @@ -70,7 +70,7 @@ impl PlotxApp { let mut prepared: Vec<(usize, Vec, Vec)> = Vec::new(); let mut global_peak = 0.0f64; for (i, sb) in binding.series.iter().enumerate() { - let Some(dataset) = self.doc.dataset_index(sb.dataset) else { + let Some(dataset) = self.doc.dataset_index(sb.source.resource) else { continue; }; if !sb.visible { @@ -83,7 +83,7 @@ impl PlotxApp { .iter() .flat_map(|s| s.points.iter()) .fold(0.0f64, |m, p| m.max(p[1].abs())); - let factor = sb.scale + let factor = sb.line_scale() * if stack.normalize && peak > 0.0 { 1.0 / peak } else { @@ -111,7 +111,7 @@ impl PlotxApp { for (i, mut series, mut error_bars) in prepared { let sb = &binding.series[i]; let color = sb - .color + .primary_color() .unwrap_or(OVERLAY_PALETTE[i % OVERLAY_PALETTE.len()]); let label = self.series_label(sb); let x_off = if stacked { @@ -176,24 +176,38 @@ impl PlotxApp { .primary_dataset() .and_then(|id| self.doc.dataset_index(id)) .expect("validated data binding has a primary dataset"); - let mut fig = self.build_full_canvas_figure(primary, &chart, size_mm); + let primary_part = binding + .series + .first() + .and_then(|series| self.build_encoded_series_figure(series)) + .unwrap_or_else(|| self.build_full_canvas_figure(primary, &chart, size_mm)); + let mut fig = primary_part.clone(); fig.contours.clear(); let (mut x_min, mut x_max) = (fig.x.min, fig.x.max); let (mut y_min, mut y_max) = (fig.y.min, fig.y.max); let mut merged = false; for (i, sb) in binding.series.iter().enumerate() { - let Some(dataset) = self.doc.dataset_index(sb.dataset) else { - continue; - }; if !sb.visible { continue; } - let part = self.build_full_canvas_figure(dataset, &chart, size_mm); + let encoded_contour = matches!(sb.encoding, plotx_figure::SeriesEncoding::Contour(_)); + let part = if i == 0 { + primary_part.clone() + } else if let Some(part) = self.build_encoded_series_figure(sb) { + part + } else { + continue; + }; let color = sb - .color + .primary_color() .unwrap_or(OVERLAY_PALETTE[i % OVERLAY_PALETTE.len()]); for mut contour in part.contours { - contour.color = color; + // A concrete ContourSpec owns independent positive and negative + // styling. The legacy chart fallback retains its palette override + // until the broad stack migration lands in phase 7. + if !encoded_contour { + contour.color = color; + } fig.contours.push(contour); } if merged { @@ -211,13 +225,13 @@ impl PlotxApp { fig.y.min = y_min; fig.y.max = y_max; fig.show_legend = true; - fig + self.normalize_binding_figure(fig, size_mm) } pub fn series_label(&self, sb: &SeriesBinding) -> String { sb.label.clone().unwrap_or_else(|| { self.doc - .dataset_by_id(sb.dataset) + .dataset_by_id(sb.source.resource) .map(Dataset::display_name) .unwrap_or_default() }) @@ -336,7 +350,7 @@ impl PlotxApp { series: sel .iter() .filter_map(|&d| self.doc.datasets.get(d)) - .map(|dataset| SeriesBinding::new(dataset.resource_id())) + .filter_map(SeriesBinding::from_dataset) .collect(), }; let mode = match domain.stack_kind() { diff --git a/crates/core/src/state/table.rs b/crates/core/src/state/table.rs index cf5a4e7..04705bc 100644 --- a/crates/core/src/state/table.rs +++ b/crates/core/src/state/table.rs @@ -182,6 +182,8 @@ pub const SHEET_MAX_ROWS: usize = 24; #[derive(Clone)] pub struct TableDataset { pub resource_id: crate::state::DatasetId, + /// Persisted identity for the table's default series field. + pub field_catalog: crate::state::FieldCatalog, /// Executable extraction recipe used to refresh this immutable table. pub provenance: Option, /// Domain constants consumed by analysis bindings. @@ -225,6 +227,7 @@ impl TableDataset { pub fn from_typed(typed_state: TypedTableState) -> Self { Self { resource_id: new_resource_id(), + field_catalog: crate::state::table_field_catalog(), provenance: None, meta: TableMeta::default(), curve_fit_analyses: Vec::new(), diff --git a/crates/core/src/state/table_charts/mod.rs b/crates/core/src/state/table_charts/mod.rs index 5e62efe..aba1a6e 100644 --- a/crates/core/src/state/table_charts/mod.rs +++ b/crates/core/src/state/table_charts/mod.rs @@ -66,8 +66,8 @@ fn darkened(c: Color, factor: f32) -> Color { #[cfg(test)] mod tests { use crate::state::{ - ChartSpec, DataDomain, Dataset, FloatSeries, chart_types_for, - materialized_float_series_table, + ChartSpec, DataDomain, Dataset, FieldCapabilities, FloatSeries, + chart_types_for_capabilities, materialized_float_series_table, }; /// End-to-end sweep: every registered table chart builds from a realistic @@ -103,7 +103,11 @@ mod tests { .unwrap(); let dataset = Dataset::Table(Box::new(table)); - for chart in chart_types_for(DataDomain::Table) { + let capabilities = FieldCapabilities::new([ + crate::automation::CapabilityId::new(crate::automation::CAP_FIELD_CURVE_1D), + crate::automation::CapabilityId::new(crate::automation::CAP_FIELD_TABLE), + ]); + for chart in chart_types_for_capabilities(&capabilities, DataDomain::Table) { for stacked in [false, true] { let spec = ChartSpec { type_id: chart.id.to_owned(), diff --git a/crates/core/src/theme.rs b/crates/core/src/theme.rs index e5d7612..9b41516 100644 --- a/crates/core/src/theme.rs +++ b/crates/core/src/theme.rs @@ -42,6 +42,15 @@ impl Theme { } } + fn negative_contour_color(&self, i: usize) -> Color { + let candidate = self.trace_color(i + 1); + if candidate == self.trace_color(i) { + Color::rgb(0xd1, 0x24, 0x2a) + } else { + candidate + } + } + fn style_library(&self) -> StyleLibrary { let mut text = TextBox::label(String::new()); text.color = self.text_color; @@ -137,21 +146,25 @@ pub struct ThemeSnapshot { pub background: Color, pub style_library: StyleLibrary, pub object_styles: Vec<(ObjectId, ObjectStyle)>, - pub series_colors: Vec<(ObjectId, Vec>)>, + pub series_encodings: Vec<(ObjectId, Vec)>, } impl PlotxApp { fn capture_theme_snapshot(&self, ci: usize) -> ThemeSnapshot { let canvas = &self.doc.canvases[ci]; let mut object_styles = Vec::new(); - let mut series_colors = Vec::new(); + let mut series_encodings = Vec::new(); for object in &canvas.objects { if let Some(style) = object.style() { object_styles.push((object.id, style)); } else if let Some(plot) = object.plot() { - series_colors.push(( + series_encodings.push(( object.id, - plot.binding.series.iter().map(|s| s.color).collect(), + plot.binding + .series + .iter() + .map(|series| series.encoding.clone()) + .collect(), )); } } @@ -159,7 +172,7 @@ impl PlotxApp { background: canvas.background, style_library: self.doc.style_library.clone(), object_styles, - series_colors, + series_encodings, } } @@ -170,9 +183,21 @@ impl PlotxApp { for (_, style) in &mut snap.object_styles { theme.restyle_object(style); } - for (_, colors) in &mut snap.series_colors { - for (i, color) in colors.iter_mut().enumerate() { - *color = Some(theme.trace_color(i)); + for (_, encodings) in &mut snap.series_encodings { + for (i, encoding) in encodings.iter_mut().enumerate() { + match encoding { + plotx_figure::SeriesEncoding::Line(line) => { + line.color = plotx_figure::ColorSource::Explicit(theme.trace_color(i)); + } + plotx_figure::SeriesEncoding::Contour(contour) => { + contour.style.positive_color = + plotx_figure::ColorSource::Explicit(theme.trace_color(i)); + contour.style.negative_color = + plotx_figure::ColorSource::Explicit(theme.negative_contour_color(i)); + } + plotx_figure::SeriesEncoding::Heatmap(_) + | plotx_figure::SeriesEncoding::Image(_) => {} + } } } snap @@ -186,10 +211,10 @@ impl PlotxApp { o.set_style(style); } } - for (id, colors) in &snap.series_colors { + for (id, encodings) in &snap.series_encodings { if let Some(plot) = c.object_mut(*id).and_then(|o| o.plot_mut()) { - for (sb, &color) in plot.binding.series.iter_mut().zip(colors) { - sb.color = color; + for (series, encoding) in plot.binding.series.iter_mut().zip(encodings) { + series.encoding = encoding.clone(); } } } diff --git a/crates/core/src/workflow.rs b/crates/core/src/workflow.rs index 7400fbd..1819835 100644 --- a/crates/core/src/workflow.rs +++ b/crates/core/src/workflow.rs @@ -234,6 +234,10 @@ pub fn build_dataset_figure(dataset: &Dataset, chart: &ChartSpec, size_mm: [f32; figure } +fn default_binding(dataset: &Dataset) -> DataBinding { + DataBinding::single(dataset) +} + pub fn build_plot_object( dataset: &Dataset, _dataset_index: usize, @@ -259,7 +263,7 @@ pub fn build_plot_object( group: None, kind: CanvasObjectKind::Plot(Box::new(PlotObject { next_series_id: crate::state::SeriesId::new(1), - binding: DataBinding::single(dataset.resource_id()), + binding: default_binding(dataset), chart, stack: StackSpec::default(), projections: AxisProjections::default(), @@ -326,6 +330,15 @@ pub fn build_default_canvas_for_dataset( ); if let CanvasObjectKind::Plot(plot) = &mut second.kind { plot.chart.type_id = "afm_force_curve".to_owned(); + if let Some(series) = plot.binding.series.first_mut() + && let Some(field) = dataset + .field_descriptors() + .into_iter() + .find(|field| field.local_id == "afm.force_curve") + { + series.source.field = field.id; + series.encoding = plotx_figure::SeriesEncoding::default(); + } plot.figure = build_dataset_figure( dataset, &plot.chart, diff --git a/crates/core/tests/afm_canvas.rs b/crates/core/tests/afm_canvas.rs index 34b19a9..a0080f0 100644 --- a/crates/core/tests/afm_canvas.rs +++ b/crates/core/tests/afm_canvas.rs @@ -1,7 +1,9 @@ use plotx_core::actions::Action; +use plotx_core::automation::{KIND_FIELD, ProjectResourceProvider, ResourceProvider}; use plotx_core::state::{ - CanvasObjectKind, DEFAULT_CANVAS_SIZE_MM, Dataset, NATURE_DOUBLE_COLUMN, PlotxApp, + CanvasObjectKind, DEFAULT_CANVAS_SIZE_MM, Dataset, NATURE_DOUBLE_COLUMN, PlotxApp, StackSpec, }; +use plotx_figure::{ContourSpec, SeriesEncoding}; use plotx_io::{AfmData, AfmForceSet, AfmFrameDirection, AfmImageChannel, AfmScale}; use std::sync::Arc; @@ -96,4 +98,117 @@ fn map_and_force_gui_insertion_builds_side_by_side_plots() { }) .collect(); assert_eq!(chart_ids, ["afm_map", "afm_force_curve"]); + let CanvasObjectKind::Plot(map) = &objects[0].kind else { + panic!("expected AFM map plot"); + }; + assert_eq!( + map.binding.series[0].source.field, + app.doc.datasets[0] + .field_descriptors() + .into_iter() + .find(|field| field.local_id.starts_with("afm.channel.")) + .unwrap() + .id + ); + assert!(matches!( + map.binding.series[0].encoding, + SeriesEncoding::Heatmap(_) + )); + let CanvasObjectKind::Plot(force) = &objects[1].kind else { + panic!("expected AFM force plot"); + }; + assert_eq!( + force.binding.series[0].source.field, + app.doc.datasets[0] + .field_descriptors() + .into_iter() + .find(|field| field.local_id == "afm.force_curve") + .unwrap() + .id + ); + assert!(matches!( + force.binding.series[0].encoding, + SeriesEncoding::Line(_) + )); +} + +#[test] +fn afm_scalar_field_can_render_a_contour_without_a_domain_chart_branch() { + let app = insert(afm_dataset(true)); + let CanvasObjectKind::Plot(map) = &app.doc.canvases[0].objects[0].kind else { + panic!("expected AFM map plot"); + }; + let mut binding = map.binding.clone(); + binding.series[0].encoding = SeriesEncoding::Contour( + ContourSpec::absolute(1.5, false).expect("positive literal contour base"), + ); + let figure = app.build_binding_figure( + &binding, + &map.chart, + &StackSpec::default(), + app.doc.canvases[0].size_mm, + ); + assert!(!figure.contours.is_empty()); + assert!(!figure.contours[0].segments.is_empty()); +} + +#[test] +fn afm_fields_expose_independent_image_and_force_capabilities() { + let dataset = afm_dataset(true); + let fields = dataset.field_descriptors(); + assert_eq!(fields.len(), 2); + assert!( + fields[0] + .capabilities + .contains(plotx_core::automation::CAP_FIELD_SCALAR_GRID_2D_REGULAR) + ); + assert!( + fields[0] + .capabilities + .contains(plotx_core::automation::CAP_FIELD_LOCATION_SCALE) + ); + assert!( + !fields[0] + .capabilities + .contains(plotx_core::automation::CAP_FIELD_CURVE_1D) + ); + assert!( + fields[1] + .capabilities + .contains(plotx_core::automation::CAP_FIELD_CURVE_1D) + ); + assert!( + !fields[1] + .capabilities + .contains(plotx_core::automation::CAP_FIELD_SCALAR_GRID_2D_REGULAR) + ); +} + +#[test] +fn field_descriptors_are_dataset_child_resources() { + let app = insert(afm_dataset(true)); + let dataset = &app.doc.datasets[0]; + let descriptors = ProjectResourceProvider::new(&app).descriptors(); + let fields = descriptors + .iter() + .filter(|descriptor| descriptor.resource.kind.0 == KIND_FIELD) + .collect::>(); + assert_eq!(fields.len(), 2); + assert!(fields.iter().all(|field| { + field.resource.parent_id.as_deref() == Some(&dataset.resource_id().to_string()) + })); + let map_key = dataset + .field_descriptors() + .into_iter() + .find(|field| field.local_id.starts_with("afm.channel.")) + .unwrap() + .local_id; + assert!(fields.iter().any(|field| { + field.resource.local_id.as_deref() == Some(map_key.as_str()) && map_key != "afm.channel.0" + })); + assert!( + fields + .iter() + .any(|field| field.resource.local_id.as_deref() == Some("afm.force_curve")) + ); } diff --git a/crates/core/tests/slice2d.rs b/crates/core/tests/slice2d.rs index fdf34a5..e70f3e4 100644 --- a/crates/core/tests/slice2d.rs +++ b/crates/core/tests/slice2d.rs @@ -90,7 +90,15 @@ fn contour_slice_places_peak_and_exports_svg() { spec.f1_ppm[br] ); - let fig = build_figure_2d(&spec, preset); + let capabilities = plotx_core::state::FieldCapabilities::new([ + plotx_core::automation::CapabilityId::new( + plotx_core::automation::CAP_FIELD_SCALAR_GRID_2D_REGULAR, + ), + plotx_core::automation::CapabilityId::new(plotx_core::automation::CAP_FIELD_SIGNED), + plotx_core::automation::CapabilityId::new(plotx_core::automation::CAP_FIELD_NOISE_SCALE), + ]); + let contour = plotx_core::state::default_contour_spec(&capabilities); + let fig = build_figure_2d(&spec, preset, &contour); assert!(!fig.contours.is_empty()); assert!(!fig.contours[0].segments.is_empty()); diff --git a/crates/figure/src/encoding.rs b/crates/figure/src/encoding.rs new file mode 100644 index 0000000..6e23072 --- /dev/null +++ b/crates/figure/src/encoding.rs @@ -0,0 +1,311 @@ +use crate::{Color, ColormapId}; +use serde::{Deserialize, Deserializer, Serialize, de}; + +/// A finite, strictly positive scalar used by persisted presentation settings. +/// Constructors reject non-finite values so encodings cannot poison renderer keys. +#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct PositiveFiniteF64(f64); + +impl PositiveFiniteF64 { + pub fn new(value: f64) -> Option { + (value.is_finite() && value > 0.0).then_some(Self(value)) + } + + pub const fn get(self) -> f64 { + self.0 + } +} + +impl<'de> Deserialize<'de> for PositiveFiniteF64 { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = f64::deserialize(deserializer)?; + Self::new(value) + .ok_or_else(|| de::Error::custom("expected a finite value greater than zero")) + } +} + +/// A finite, strictly positive width in output-space logical units. +#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct PositiveFiniteF32(f32); + +impl PositiveFiniteF32 { + pub fn new(value: f32) -> Option { + (value.is_finite() && value > 0.0).then_some(Self(value)) + } + + pub const fn get(self) -> f32 { + self.0 + } +} + +impl<'de> Deserialize<'de> for PositiveFiniteF32 { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = f32::deserialize(deserializer)?; + Self::new(value) + .ok_or_else(|| de::Error::custom("expected a finite value greater than zero")) + } +} + +/// A finite fraction in the closed unit interval. +#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct UnitInterval(f64); + +impl UnitInterval { + pub fn new(value: f64) -> Option { + (value.is_finite() && (0.0..=1.0).contains(&value)).then_some(Self(value)) + } + + pub const fn get(self) -> f64 { + self.0 + } +} + +impl<'de> Deserialize<'de> for UnitInterval { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = f64::deserialize(deserializer)?; + Self::new(value).ok_or_else(|| de::Error::custom("expected a finite value in [0, 1]")) + } +} + +/// A concrete display color. Theme application rewrites these values as one +/// undoable style operation; document encodings never carry unresolved tokens. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ColorSource { + Explicit(Color), +} + +impl ColorSource { + pub const fn resolve(self) -> Color { + match self { + Self::Explicit(color) => color, + } + } +} + +/// An estimator identity selected by an encoding. The concrete estimate and its +/// provenance are deliberately owned by the field provider rather than here. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "mode")] +pub enum EstimatorSelection { + FollowLatest, + Frozen { estimator: String, version: u32 }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct LineEncoding { + pub color: ColorSource, + pub scale: f64, + pub width: PositiveFiniteF32, +} + +impl Default for LineEncoding { + fn default() -> Self { + Self { + color: ColorSource::Explicit(Color::TRACE), + scale: 1.0, + width: PositiveFiniteF32::new(1.0).expect("literal width is valid"), + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ContourSpec { + pub positive: ContourLevelSpec, + pub negative: Option, + pub style: ContourStyle, +} + +impl ContourSpec { + pub fn absolute(base: f64, negative: bool) -> Option { + let level = ContourLevelSpec { + base: ContourBasePolicy::Absolute(PositiveFiniteF64::new(base)?), + count: 14, + ratio: PositiveFiniteF64::new(1.35)?, + }; + Some(Self { + positive: level.clone(), + negative: negative.then_some(level), + style: ContourStyle::default(), + }) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct ContourLevelSpec { + pub base: ContourBasePolicy, + pub count: u16, + pub ratio: PositiveFiniteF64, +} + +impl ContourLevelSpec { + pub const MAX_COUNT: u16 = 256; + + fn validate(&self) -> bool { + self.count > 0 && self.count <= Self::MAX_COUNT && self.ratio.get() > 1.0 + } +} + +impl<'de> Deserialize<'de> for ContourLevelSpec { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct RawContourLevelSpec { + base: ContourBasePolicy, + count: u16, + ratio: PositiveFiniteF64, + } + + let raw = RawContourLevelSpec::deserialize(deserializer)?; + let level = Self { + base: raw.base, + count: raw.count, + ratio: raw.ratio, + }; + level.validate().then_some(level).ok_or_else(|| { + de::Error::custom("contour count must be in 1..=256 and ratio must exceed one") + }) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "policy", content = "value")] +pub enum ContourBasePolicy { + Absolute(PositiveFiniteF64), + NoiseSigma { + multiplier: PositiveFiniteF64, + estimator: EstimatorSelection, + }, + BackgroundScale { + multiplier: PositiveFiniteF64, + estimator: EstimatorSelection, + }, + FractionOfRange(UnitInterval), +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ContourStyle { + pub positive_color: ColorSource, + pub negative_color: ColorSource, + pub width: PositiveFiniteF32, +} + +impl Default for ContourStyle { + fn default() -> Self { + Self { + positive_color: ColorSource::Explicit(Color::TRACE), + negative_color: ColorSource::Explicit(Color::rgb(0xd1, 0x24, 0x2a)), + width: PositiveFiniteF32::new(0.7).expect("literal width is valid"), + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct HeatmapSpec { + pub colormap: ColormapId, + /// `None` uses the scalar field's finite min/max summary. + pub value_range: Option<[f32; 2]>, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ImageSpec { + pub opacity: UnitInterval, + pub interpolation: ImageInterpolation, +} + +impl Default for ImageSpec { + fn default() -> Self { + Self { + opacity: UnitInterval::new(1.0).expect("literal opacity is valid"), + interpolation: ImageInterpolation::Linear, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ImageInterpolation { + Nearest, + #[default] + Linear, +} + +/// The concrete, persisted visual encoding of one series. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "kind", content = "spec")] +pub enum SeriesEncoding { + Line(LineEncoding), + Contour(ContourSpec), + Heatmap(HeatmapSpec), + Image(ImageSpec), +} + +impl Default for SeriesEncoding { + fn default() -> Self { + Self::Line(LineEncoding::default()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn contour_style_keeps_positive_and_negative_colors_distinct() { + let style = ContourStyle::default(); + assert_ne!( + style.positive_color.resolve(), + style.negative_color.resolve() + ); + } + + #[test] + fn persisted_encoding_has_no_auto_variant() { + let value = serde_json::to_value(SeriesEncoding::default()).unwrap(); + assert_ne!(value["kind"], "auto"); + } + + #[test] + fn persisted_numeric_settings_reject_invalid_values() { + assert!(serde_json::from_str::("-1.0").is_err()); + assert!(serde_json::from_str::("0.0").is_err()); + assert!(serde_json::from_str::("1.1").is_err()); + assert!( + serde_json::from_str::( + r#"{"base":{"policy":"absolute","value":1.0},"count":0,"ratio":1.35}"#, + ) + .is_err() + ); + assert!( + serde_json::from_str::( + r#"{"base":{"policy":"absolute","value":1.0},"count":3,"ratio":1.0}"#, + ) + .is_err() + ); + let error = serde_json::from_str::( + r#"{"base":{"policy":"absolute","value":1.0},"count":257,"ratio":1.35}"#, + ) + .unwrap_err(); + assert!(error.to_string().contains("1..=256")); + assert!( + serde_json::from_str::( + r#"{"base":{"policy":"absolute","value":1.0},"count":3,"ratio":1.35}"#, + ) + .is_ok() + ); + } +} diff --git a/crates/figure/src/lib.rs b/crates/figure/src/lib.rs index 74ab96e..487306e 100644 --- a/crates/figure/src/lib.rs +++ b/crates/figure/src/lib.rs @@ -2,8 +2,14 @@ //! coordinates. Both renderers (egui screen, SVG export) consume this model. mod colormap; +mod encoding; pub use colormap::ColormapId; +pub use encoding::{ + ColorSource, ContourBasePolicy, ContourLevelSpec, ContourSpec, ContourStyle, + EstimatorSelection, HeatmapSpec, ImageInterpolation, ImageSpec, LineEncoding, + PositiveFiniteF32, PositiveFiniteF64, SeriesEncoding, UnitInterval, +}; /// An RGB color, 0–255 per channel. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]