From 67b59c16b10f4d65c0b4929e94854a510bfe33dc Mon Sep 17 00:00:00 2001 From: Benjamin CHARMES Date: Fri, 7 Nov 2025 14:49:05 +0100 Subject: [PATCH 1/6] Use MultiFileSelectDropdown on XRD and NMR Block --- pydatalab/src/pydatalab/apps/nmr/blocks.py | 62 ++++- pydatalab/src/pydatalab/apps/xrd/blocks.py | 41 +++- webapp/src/components/MetadataViewer.vue | 89 +++++++ .../src/components/datablocks/BokehBlock.vue | 24 +- .../src/components/datablocks/CycleBlock.vue | 226 +++++++----------- .../components/datablocks/DataBlockBase.vue | 34 +++ webapp/src/components/datablocks/NMRBlock.vue | 218 ++++++++++------- .../components/datablocks/NMRInsituBlock.vue | 173 +++++++------- .../src/components/datablocks/UVVisBlock.vue | 29 +-- .../datablocks/UVVisInsituBlock.vue | 147 ++++++------ webapp/src/components/datablocks/XRDBlock.vue | 122 ++++++++-- .../components/datablocks/XRDInsituBlock.vue | 154 ++++++------ 12 files changed, 790 insertions(+), 529 deletions(-) create mode 100644 webapp/src/components/MetadataViewer.vue diff --git a/pydatalab/src/pydatalab/apps/nmr/blocks.py b/pydatalab/src/pydatalab/apps/nmr/blocks.py index 7d5d8956e..e95213051 100644 --- a/pydatalab/src/pydatalab/apps/nmr/blocks.py +++ b/pydatalab/src/pydatalab/apps/nmr/blocks.py @@ -228,7 +228,36 @@ def generate_nmr_plot(self, parse: bool = True): """ if parse: - if not self.data.get("file_id"): + if self.data.get("file_ids") and len(self.data.get("file_ids")) > 0: + all_dfs = [] + + for file_id in self.data["file_ids"]: + try: + file_info = get_file_info_by_id(file_id, update_if_live=True) + df = self.load_nmr_data(file_info) + + if df: + df_converted = pd.DataFrame(df) + df_converted["normalized intensity"] = ( + df_converted.intensity / df_converted.intensity.max() + ) + df_converted.index.name = file_info.get("name", "unknown") + all_dfs.append(df_converted) + except Exception as exc: + warnings.warn(f"Could not load NMR file {file_id}: {exc}") + continue + + if not all_dfs: + warnings.warn("No compatible NMR data could be loaded from file_ids") + return + + metadata = self.data.get("metadata", {}) + self.data["bokeh_plot_data"] = self.make_nmr_plot( + all_dfs[0], metadata, extra_dfs=all_dfs[1:] + ) + return + + elif not self.data.get("file_id"): return None file_info = get_file_info_by_id(self.data["file_id"], update_if_live=True) @@ -256,11 +285,13 @@ def generate_nmr_plot(self, parse: bool = True): self.data["bokeh_plot_data"] = self.make_nmr_plot(df, self.data["metadata"]) @classmethod - def make_nmr_plot(cls, df: pd.DataFrame, metadata: dict[str, Any]) -> str: + def make_nmr_plot( + cls, df: pd.DataFrame, metadata: dict[str, Any], extra_dfs: list[pd.DataFrame] | None = None + ) -> str: """Create a Bokeh plot for the NMR data stored in the dataframe and metadata.""" nucleus_label = metadata.get("nucleus") or "" - # replace numbers with superscripts nucleus_label = nucleus_label.translate(str.maketrans("0123456789", "⁰¹²³⁴⁵⁶⁷⁸⁹")) + df.rename( { "ppm": f"{nucleus_label} chemical shift (ppm)", @@ -273,8 +304,24 @@ def make_nmr_plot(cls, df: pd.DataFrame, metadata: dict[str, Any]) -> str: inplace=True, ) + dfs_to_plot = [df] + if extra_dfs: + for extra_df in extra_dfs: + extra_df.rename( + { + "ppm": f"{nucleus_label} chemical shift (ppm)", + "hz": f"{nucleus_label} chemical shift (Hz)", + "intensity": "Intensity", + "intensity_per_scan": "Intensity per scan", + "normalized intensity": "Normalized intensity", + }, + axis=1, + inplace=True, + ) + dfs_to_plot.append(extra_df) + bokeh_layout = selectable_axes_plot( - df, + dfs_to_plot, x_options=[ f"{nucleus_label} chemical shift (ppm)", f"{nucleus_label} chemical shift (Hz)", @@ -287,8 +334,9 @@ def make_nmr_plot(cls, df: pd.DataFrame, metadata: dict[str, Any]) -> str: plot_line=True, point_size=3, ) - # flip x axis, per NMR convention. Note that the figure is the second element - # of the layout in the current implementation, but this could be fragile. - bokeh_layout.children[1].x_range.flipped = True + for child in bokeh_layout.children: + if hasattr(child, "x_range"): + child.x_range.flipped = True + break return bokeh.embed.json_item(bokeh_layout, theme=DATALAB_BOKEH_THEME) diff --git a/pydatalab/src/pydatalab/apps/xrd/blocks.py b/pydatalab/src/pydatalab/apps/xrd/blocks.py index 109618f6a..6ff8e6010 100644 --- a/pydatalab/src/pydatalab/apps/xrd/blocks.py +++ b/pydatalab/src/pydatalab/apps/xrd/blocks.py @@ -209,7 +209,10 @@ def _calc_baselines_and_normalize( def generate_xrd_plot(self, filenames: list[str | Path] | None = None) -> None: """Generate a Bokeh plot potentially containing multiple XRD patterns. - This function will first check whether a `file_id` is set in the block data. + This function will first check whether `file_ids` is set in the block data. + If so, it will load those specific files. + + Otherwise, it will check whether a `file_id` is set in the block data. If not, it will interpret this as the "all compatible files" option, and will look into the item data to find all attached files, and attempt to read them as XRD patterns. @@ -220,9 +223,37 @@ def generate_xrd_plot(self, filenames: list[str | Path] | None = None) -> None: file_info = None all_files = None pattern_dfs = None + y_options: list[str] = [] + peak_data: dict = {} + + if self.data.get("file_ids") and len(self.data.get("file_ids")) > 0 and filenames is None: + pattern_dfs = [] + peak_information = {} + + for ind, file_id in enumerate(self.data["file_ids"]): + try: + file_info = get_file_info_by_id(file_id, update_if_live=True) + peak_data = {} + pattern_df, y_options, peak_data = self.load_pattern( + file_info["location"], + wavelength=float(self.data.get("wavelength", self.defaults["wavelength"])), + ) + pattern_df.attrs["item_id"] = self.data.get("item_id", "unknown") + pattern_df.attrs["original_filename"] = file_info.get("name", "unknown") + pattern_df.attrs["wavelength"] = ( + f"{self.data.get('wavelength', self.defaults['wavelength'])} Å" + ) + peak_information[str(file_id)] = PeakInformation(**peak_data).dict() + pattern_df["normalized intensity (staggered)"] += ind + pattern_dfs.append(pattern_df) + except Exception as exc: + warnings.warn(f"Could not parse file {file_id} as XRD data. Error: {exc}") + continue + + self.data["computed"] = {} + self.data["computed"]["peak_data"] = peak_information - if self.data.get("file_id") is None and filenames is None: - # If no file set, try to plot them all + elif self.data.get("file_id") is None and filenames is None: item_info = flask_mongo.db.items.find_one( {"item_id": self.data["item_id"]}, projection={"file_ObjectIds": 1}, @@ -247,10 +278,9 @@ def generate_xrd_plot(self, filenames: list[str | Path] | None = None) -> None: pattern_dfs = [] peak_information = {} - y_options: list[str] = [] for ind, f in enumerate(all_files): try: - peak_data: dict = {} + peak_data = {} pattern_df, y_options, peak_data = self.load_pattern( f["location"], wavelength=float(self.data.get("wavelength", self.defaults["wavelength"])), @@ -312,7 +342,6 @@ def generate_xrd_plot(self, filenames: list[str | Path] | None = None) -> None: if "computed" not in self.data: self.data["computed"] = {"peak_data": {}} self.data["computed"]["peak_data"][f] = peak_model.dict() - pattern_dfs = [pattern_df] if pattern_dfs: p = self._make_plots(pattern_dfs, y_options) diff --git a/webapp/src/components/MetadataViewer.vue b/webapp/src/components/MetadataViewer.vue new file mode 100644 index 000000000..8ab0551f1 --- /dev/null +++ b/webapp/src/components/MetadataViewer.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/webapp/src/components/datablocks/BokehBlock.vue b/webapp/src/components/datablocks/BokehBlock.vue index c086fa6f2..2796934ed 100644 --- a/webapp/src/components/datablocks/BokehBlock.vue +++ b/webapp/src/components/datablocks/BokehBlock.vue @@ -2,19 +2,19 @@ - + -
-
- -
-
+
diff --git a/webapp/src/components/datablocks/CycleBlock.vue b/webapp/src/components/datablocks/CycleBlock.vue index eaf2e3c5a..127992364 100644 --- a/webapp/src/components/datablocks/CycleBlock.vue +++ b/webapp/src/components/datablocks/CycleBlock.vue @@ -1,155 +1,108 @@ @@ -226,6 +179,12 @@ export default { } }, }, + hasFileChanges() { + if (!this.isMultiSelect) return false; + const currentIds = this.file_ids || []; + if (this.pending_file_ids.length !== currentIds.length) return true; + return !this.pending_file_ids.every((id, index) => id === currentIds[index]); + }, // normalizingMass() { // return this.$store.all_item_data[this.item_id]["characteristic_mass"] || null; @@ -239,6 +198,7 @@ export default { win_size_1: createComputedSetterForBlockField("win_size_1"), derivative_mode: createComputedSetterForBlockField("derivative_mode"), characteristic_mass: createComputedSetterForBlockField("characteristic_mass"), + smoothing_factor: createComputedSetterForBlockField("s_spline"), }, mounted() { // Ensure file_ids is always an array diff --git a/webapp/src/components/datablocks/DataBlockBase.vue b/webapp/src/components/datablocks/DataBlockBase.vue index c15b89616..c649455f9 100644 --- a/webapp/src/components/datablocks/DataBlockBase.vue +++ b/webapp/src/components/datablocks/DataBlockBase.vue @@ -126,7 +126,32 @@ + + +
+ +
+ +
+
+ +
+ +
+ +
+
+ + @@ -146,6 +171,7 @@ import { createComputedSetterForBlockField } from "@/field_utils.js"; import TinyMceInline from "@/components/TinyMceInline"; import StyledBlockInfo from "@/components/StyledBlockInfo"; import tinymce from "tinymce/tinymce"; +import MetadataViewer from "@/components/MetadataViewer"; import { deleteBlock, updateBlockFromServer } from "@/server_fetch_utils"; @@ -153,6 +179,7 @@ export default { components: { TinyMceInline, StyledBlockInfo, + MetadataViewer, }, props: { item_id: { @@ -171,6 +198,7 @@ export default { padding_height: 18, isErrorsExpanded: true, isWarningsExpanded: true, + metadataShown: false, }; }, computed: { @@ -202,6 +230,12 @@ export default { }, BlockTitle: createComputedSetterForBlockField("title"), BlockDescription: createComputedSetterForBlockField("freeform_comment"), + hasMetadata() { + console.log("#$#$#$#$#%$#%$#$%#%$#%$#%$#%$"); + console.log(this.block); + console.log("#$#$#$#$#%$#%$#$%#%$#%$#%$#%$"); + return this.block?.metadata && Object.keys(this.block.metadata).length > 0; + }, }, mounted() { // this is to help toggleExpandBlock() work properly. Resets contentMaxHeight to "none" diff --git a/webapp/src/components/datablocks/NMRBlock.vue b/webapp/src/components/datablocks/NMRBlock.vue index f5349020a..4d464dccf 100644 --- a/webapp/src/components/datablocks/NMRBlock.vue +++ b/webapp/src/components/datablocks/NMRBlock.vue @@ -1,108 +1,79 @@ diff --git a/webapp/src/components/datablocks/NMRInsituBlock.vue b/webapp/src/components/datablocks/NMRInsituBlock.vue index 75267302f..f69f77e09 100644 --- a/webapp/src/components/datablocks/NMRInsituBlock.vue +++ b/webapp/src/components/datablocks/NMRInsituBlock.vue @@ -1,104 +1,99 @@ diff --git a/webapp/src/components/datablocks/UVVisBlock.vue b/webapp/src/components/datablocks/UVVisBlock.vue index 75e7a5519..86e6c9d61 100644 --- a/webapp/src/components/datablocks/UVVisBlock.vue +++ b/webapp/src/components/datablocks/UVVisBlock.vue @@ -1,20 +1,21 @@ diff --git a/webapp/src/components/datablocks/UVVisInsituBlock.vue b/webapp/src/components/datablocks/UVVisInsituBlock.vue index d03294433..679454d24 100644 --- a/webapp/src/components/datablocks/UVVisInsituBlock.vue +++ b/webapp/src/components/datablocks/UVVisInsituBlock.vue @@ -1,86 +1,81 @@ diff --git a/webapp/src/components/datablocks/XRDBlock.vue b/webapp/src/components/datablocks/XRDBlock.vue index 6170141da..c754bec91 100644 --- a/webapp/src/components/datablocks/XRDBlock.vue +++ b/webapp/src/components/datablocks/XRDBlock.vue @@ -1,25 +1,28 @@ @@ -27,14 +30,17 @@ DataBlockBase as a prop, and save from within DataBlockBase --> import DataBlockBase from "@/components/datablocks/DataBlockBase"; import FileSelectDropdown from "@/components/FileSelectDropdown"; import BokehPlot from "@/components/BokehPlot"; +import FileMultiSelectDropdown from "@/components/FileMultiSelectDropdown"; import { createComputedSetterForBlockField } from "@/field_utils.js"; +import { updateBlockFromServer } from "@/server_fetch_utils.js"; export default { components: { DataBlockBase, FileSelectDropdown, BokehPlot, + FileMultiSelectDropdown, }, props: { item_id: { @@ -46,6 +52,13 @@ export default { required: true, }, }, + + data() { + return { + pending_file_ids: [], + }; + }, + computed: { bokehPlotData() { return this.block.bokeh_plot_data; @@ -58,6 +71,77 @@ export default { }, wavelength: createComputedSetterForBlockField("wavelength"), file_id: createComputedSetterForBlockField("file_id"), + file_ids: createComputedSetterForBlockField("file_ids"), + isMultiSelect: createComputedSetterForBlockField("isMultiSelect"), + prev_file_ids: createComputedSetterForBlockField("prev_file_ids"), + prev_single_file_id: createComputedSetterForBlockField("prev_single_file_id"), + fileModel: { + get() { + const ids = this.file_ids || []; + if (this.isMultiSelect) { + return this.pending_file_ids; + } else { + return ids[0] || this.file_id || null; + } + }, + set(val) { + if (this.isMultiSelect) { + this.pending_file_ids = Array.isArray(val) ? val : [val]; + } else { + this.file_ids = val ? [val] : []; + this.file_id = val || null; + this.updateBlock(); + } + }, + }, + }, + + mounted() { + if (!Array.isArray(this.file_ids)) { + this.file_ids = []; + } + if (this.isMultiSelect) { + this.pending_file_ids = this.file_ids.slice(); + } + }, + + methods: { + updateBlock() { + updateBlockFromServer( + this.item_id, + this.block_id, + this.$store.state.all_item_data[this.item_id]["blocks_obj"][this.block_id], + ); + }, + toggleMultiSelect() { + if (this.isMultiSelect) { + this.prev_file_ids = this.file_ids.slice(); + if (this.prev_single_file_id) { + this.file_ids = [this.prev_single_file_id]; + this.file_id = this.prev_single_file_id; + } else if (this.prev_file_ids.length > 0) { + this.file_ids = [this.prev_file_ids[0]]; + this.file_id = this.prev_file_ids[0]; + } else { + this.file_ids = []; + this.file_id = null; + } + } else { + this.prev_single_file_id = this.file_ids[0] || this.file_id || null; + this.file_ids = + this.prev_file_ids && this.prev_file_ids.length > 0 ? this.prev_file_ids.slice() : []; + this.pending_file_ids = this.file_ids.slice(); + this.file_id = null; + } + this.isMultiSelect = !this.isMultiSelect; + this.updateBlock(); + }, + applyMultiSelect() { + if (!this.isMultiSelect) return; + this.file_ids = this.pending_file_ids.slice(); + this.file_id = null; + this.updateBlock(); + }, }, }; diff --git a/webapp/src/components/datablocks/XRDInsituBlock.vue b/webapp/src/components/datablocks/XRDInsituBlock.vue index 417f33201..6f7afaddd 100644 --- a/webapp/src/components/datablocks/XRDInsituBlock.vue +++ b/webapp/src/components/datablocks/XRDInsituBlock.vue @@ -1,93 +1,81 @@ From 9e775625e55175819e35a9d834bd02d4dbeec427 Mon Sep 17 00:00:00 2001 From: Benjamin CHARMES Date: Mon, 17 Nov 2025 15:26:17 +0100 Subject: [PATCH 2/6] remove debug console.log() --- webapp/src/components/datablocks/DataBlockBase.vue | 3 --- 1 file changed, 3 deletions(-) diff --git a/webapp/src/components/datablocks/DataBlockBase.vue b/webapp/src/components/datablocks/DataBlockBase.vue index c649455f9..94c8815f2 100644 --- a/webapp/src/components/datablocks/DataBlockBase.vue +++ b/webapp/src/components/datablocks/DataBlockBase.vue @@ -231,9 +231,6 @@ export default { BlockTitle: createComputedSetterForBlockField("title"), BlockDescription: createComputedSetterForBlockField("freeform_comment"), hasMetadata() { - console.log("#$#$#$#$#%$#%$#$%#%$#%$#%$#%$"); - console.log(this.block); - console.log("#$#$#$#$#%$#%$#$%#%$#%$#%$#%$"); return this.block?.metadata && Object.keys(this.block.metadata).length > 0; }, }, From dcb208685fab60098844e905fbd527d3a1ad357c Mon Sep 17 00:00:00 2001 From: Benjamin CHARMES Date: Wed, 3 Dec 2025 13:35:19 +0100 Subject: [PATCH 3/6] manual fix pre-commit --- webapp/src/components/datablocks/DataBlockBase.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/src/components/datablocks/DataBlockBase.vue b/webapp/src/components/datablocks/DataBlockBase.vue index 94860315e..269a95b8c 100644 --- a/webapp/src/components/datablocks/DataBlockBase.vue +++ b/webapp/src/components/datablocks/DataBlockBase.vue @@ -167,7 +167,7 @@ import { DialogService } from "@/services/DialogService"; import { createComputedSetterForBlockField } from "@/field_utils.js"; - + import MetadataViewer from "@/components/MetadataViewer"; import TiptapInline from "@/components/TiptapInline"; import BlockTooltip from "@/components/BlockTooltip"; From 23764726076f9f7d67e312c9d074576981697747 Mon Sep 17 00:00:00 2001 From: Benjamin CHARMES Date: Wed, 3 Dec 2025 13:45:23 +0100 Subject: [PATCH 4/6] manual fix pre-commit --- pydatalab/src/pydatalab/apps/nmr/blocks.py | 3 ++- pydatalab/src/pydatalab/apps/xrd/blocks.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pydatalab/src/pydatalab/apps/nmr/blocks.py b/pydatalab/src/pydatalab/apps/nmr/blocks.py index e95213051..0b4115e74 100644 --- a/pydatalab/src/pydatalab/apps/nmr/blocks.py +++ b/pydatalab/src/pydatalab/apps/nmr/blocks.py @@ -228,7 +228,8 @@ def generate_nmr_plot(self, parse: bool = True): """ if parse: - if self.data.get("file_ids") and len(self.data.get("file_ids")) > 0: + file_ids = self.data.get("file_ids") + if file_ids and len(file_ids) > 0: all_dfs = [] for file_id in self.data["file_ids"]: diff --git a/pydatalab/src/pydatalab/apps/xrd/blocks.py b/pydatalab/src/pydatalab/apps/xrd/blocks.py index b779b8324..9f5fda3af 100644 --- a/pydatalab/src/pydatalab/apps/xrd/blocks.py +++ b/pydatalab/src/pydatalab/apps/xrd/blocks.py @@ -226,7 +226,8 @@ def generate_xrd_plot(self, filenames: list[str | Path] | None = None) -> None: y_options: list[str] = [] peak_data: dict = {} - if self.data.get("file_ids") and len(self.data.get("file_ids")) > 0 and filenames is None: + file_ids = self.data.get("file_ids") + if file_ids and len(file_ids) > 0 and filenames is None: pattern_dfs = [] peak_information = {} From 01799b3629b2c6f0fa7a5a2d43ac81dd7d1fd100 Mon Sep 17 00:00:00 2001 From: Benjamin CHARMES Date: Wed, 3 Dec 2025 13:57:07 +0100 Subject: [PATCH 5/6] use same button in every block to switch single/multiple files --- webapp/src/components/datablocks/XRDBlock.vue | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/webapp/src/components/datablocks/XRDBlock.vue b/webapp/src/components/datablocks/XRDBlock.vue index c754bec91..c9876cf73 100644 --- a/webapp/src/components/datablocks/XRDBlock.vue +++ b/webapp/src/components/datablocks/XRDBlock.vue @@ -1,22 +1,40 @@