diff --git a/EasyReflectometryApp/Backends/Mock/Plotting.qml b/EasyReflectometryApp/Backends/Mock/Plotting.qml index 537eb221..57e49717 100644 --- a/EasyReflectometryApp/Backends/Mock/Plotting.qml +++ b/EasyReflectometryApp/Backends/Mock/Plotting.qml @@ -23,7 +23,22 @@ QtObject { property int modelCount: 1 + // Plot mode properties + property bool plotRQ4: false + property string yMainAxisTitle: 'R(q)' + property bool xAxisLog: false + property string xAxisType: 'linear' + property bool sldXDataReversed: false + property bool scaleShown: false + property bool bkgShown: false + + // Signals for plot mode changes + signal plotModeChanged() + signal axisTypeChanged() + signal sldAxisReversedChanged() + signal referenceLineVisibilityChanged() signal samplePageDataChanged() + signal samplePageResetAxes() function setQtChartsSerieRef(value1, value2, value3) { console.debug(`setQtChartsSerieRef ${value1}, ${value2}, ${value3}`) @@ -52,4 +67,70 @@ QtObject { return '#0000FF' } + // Plot mode toggle functions + function togglePlotRQ4() { + plotRQ4 = !plotRQ4 + yMainAxisTitle = plotRQ4 ? 'R(q)×q⁴' : 'R(q)' + plotModeChanged() + } + + function toggleXAxisType() { + xAxisLog = !xAxisLog + xAxisType = xAxisLog ? 'log' : 'linear' + axisTypeChanged() + } + + function reverseSldXData() { + sldXDataReversed = !sldXDataReversed + sldAxisReversedChanged() + } + + function flipScaleShown() { + scaleShown = !scaleShown + referenceLineVisibilityChanged() + } + + function flipBkgShown() { + bkgShown = !bkgShown + referenceLineVisibilityChanged() + } + + // Reference line data accessors (mock implementation) + function getBackgroundData() { + if (!bkgShown) return [] + // Return mock horizontal line at background level + return [ + { 'x': 0.01, 'y': -7.0 }, + { 'x': 0.30, 'y': -7.0 } + ] + } + + function getScaleData() { + if (!scaleShown) return [] + // Return mock horizontal line at scale level (log10(1.0) = 0) + return [ + { 'x': 0.01, 'y': 0.0 }, + { 'x': 0.30, 'y': 0.0 } + ] + } + + // Analysis-specific reference line data accessors (use sample/calculated x-range) + function getBackgroundDataForAnalysis() { + if (!bkgShown) return [] + // Return mock horizontal line at background level using sample x-range + return [ + { 'x': sampleMinX, 'y': -7.0 }, + { 'x': sampleMaxX, 'y': -7.0 } + ] + } + + function getScaleDataForAnalysis() { + if (!scaleShown) return [] + // Return mock horizontal line at scale level using sample x-range + return [ + { 'x': sampleMinX, 'y': 0.0 }, + { 'x': sampleMaxX, 'y': 0.0 } + ] + } + } diff --git a/EasyReflectometryApp/Backends/Py/analysis.py b/EasyReflectometryApp/Backends/Py/analysis.py index b14d351d..2ad4fd81 100644 --- a/EasyReflectometryApp/Backends/Py/analysis.py +++ b/EasyReflectometryApp/Backends/Py/analysis.py @@ -11,6 +11,7 @@ from .logic.calculators import Calculators as CalculatorsLogic from .logic.experiments import Experiments as ExperimentLogic from .logic.fitting import Fitting as FittingLogic +from .logic.helpers import get_original_name from .logic.minimizers import Minimizers as MinimizersLogic from .logic.parameters import Parameters as ParametersLogic from .workers import FitterWorker @@ -59,6 +60,25 @@ def _initialize_selected_experiments(self) -> None: else: self._selected_experiment_indices = [] + def _ordered_experiments(self) -> list: + """Return experiments as an ordered list of experiment objects. + + Handles mapping-like storage without assuming contiguous integer keys. + """ + experiments = self._experiments_logic._project_lib._experiments + if not experiments: + return [] + + if hasattr(experiments, 'items'): + items = list(experiments.items()) + try: + items.sort(key=lambda item: item[0]) + except TypeError: + pass + return [experiment for _, experiment in items] + + return list(experiments) + ######################## ## Fitting @Property(str, notify=fittingChanged) @@ -278,7 +298,7 @@ def setExperimentNameAtIndex(self, index: int, new_name: str) -> None: def modelIndexForExperiment(self) -> int: # return the model index for the current experiment models = self._experiments_logic._project_lib._models - experiments = self._experiments_logic._project_lib._experiments + experiments = self._ordered_experiments() index = self.experimentCurrentIndex current_experiment = experiments[index] if 0 <= index < len(experiments) else None if current_experiment is not None: @@ -290,18 +310,19 @@ def modelIndexForExperiment(self) -> int: def modelNamesForExperiment(self) -> list: # return a list of model names for each experiment mapped_models = [] - experiments = self._experiments_logic._project_lib._experiments - for ind in experiments: - mapped_models.append(experiments[ind].model.name) + experiments = self._ordered_experiments() + for experiment in experiments: + name = get_original_name(experiment.model) + mapped_models.append(name) return mapped_models @Property('QVariantList', notify=experimentsChanged) def modelColorsForExperiment(self) -> list: # return a list of model colors for each experiment mapped_models = [] - experiments = self._experiments_logic._project_lib._experiments - for ind in experiments: - mapped_models.append(experiments[ind].model.color) + experiments = self._ordered_experiments() + for experiment in experiments: + mapped_models.append(experiment.model.color) return mapped_models @Slot(int) @@ -501,14 +522,10 @@ def enabledParameters(self) -> list[dict[str]]: if self._chached_enabled_parameters is not None: return self._chached_enabled_parameters enabled_parameters = [] - # import time - # t0 = time.time() for parameter in self._parameters_logic.parameters: if not parameter['enabled']: continue enabled_parameters.append(parameter) - # t1 = time.time() - # print(f"Enabled parameters computation time: {t1 - t0:.4f} seconds") self._chached_enabled_parameters = enabled_parameters return enabled_parameters diff --git a/EasyReflectometryApp/Backends/Py/logic/experiments.py b/EasyReflectometryApp/Backends/Py/logic/experiments.py index 3a2dbff9..f292d886 100644 --- a/EasyReflectometryApp/Backends/Py/logic/experiments.py +++ b/EasyReflectometryApp/Backends/Py/logic/experiments.py @@ -5,12 +5,41 @@ class Experiments: def __init__(self, project_lib: ProjectLib): self._project_lib = project_lib + def _ordered_experiment_items(self) -> list[tuple[object, object]]: + """Return experiments as ordered ``(key, experiment)`` pairs. + + Supports mapping-like storage without assuming contiguous integer keys. + """ + experiments = self._project_lib._experiments + if not experiments: + return [] + + if hasattr(experiments, 'items'): + items = list(experiments.items()) + try: + items.sort(key=lambda item: item[0]) + except TypeError: + pass + return items + + return list(enumerate(experiments)) + + def _experiment_at_index(self, index: int): + items = self._ordered_experiment_items() + if 0 <= index < len(items): + return items[index][1] + return None + + def _experiment_key_at_index(self, index: int): + items = self._ordered_experiment_items() + if 0 <= index < len(items): + return items[index][0] + return None + def available(self) -> list[str]: experiments_name = [] try: - # get .name from self._project_lib._experiments and append to experiments_name - for ind in self._project_lib._experiments.keys(): - exp = self._project_lib._experiments[ind] + for _, exp in self._ordered_experiment_items(): experiments_name.append(exp.name) except IndexError: pass @@ -26,19 +55,19 @@ def set_current_index(self, new_value: int) -> None: return False def set_experiment_name(self, new_name: str) -> None: - exp = self._project_lib._experiments.get(self._project_lib._current_experiment_index) + exp = self._experiment_at_index(self._project_lib._current_experiment_index) if exp: exp.name = new_name def set_experiment_name_at_index(self, index: int, new_name: str) -> None: - exp = self._project_lib._experiments.get(index) + exp = self._experiment_at_index(index) if exp: exp.name = new_name def model_on_experiment(self, experiment_index: int = -1) -> dict: if experiment_index == -1: experiment_index = self._project_lib._current_experiment_index - exp = self._project_lib._experiments.get(experiment_index) + exp = self._experiment_at_index(experiment_index) if exp: return exp.model return {} @@ -50,7 +79,7 @@ def model_index_on_experiment(self) -> int: return -1 def set_model_on_experiment(self, new_value: int) -> None: - exp = self._project_lib._experiments.get(self._project_lib._current_experiment_index) + exp = self._experiment_at_index(self._project_lib._current_experiment_index) models = self._project_lib._models if exp and models: try: @@ -66,12 +95,27 @@ def remove_experiment(self, index: int) -> None: """ Remove the experiment at the given index. """ - if 0 <= index < len(self.available()): - del self._project_lib._experiments[index] - # readjust the dictionary keys for continuity - temp_experiments = self._project_lib._experiments.copy() - self._project_lib._experiments = {i: exp for i, exp in enumerate(temp_experiments.values())} - if self._project_lib._current_experiment_index >= index: - self._project_lib._current_experiment_index = max(0, self._project_lib._current_experiment_index - 1) - else: + total = len(self.available()) + if not (0 <= index < total): + print(f'Experiment index {index} is out of range.') + return + + experiments = self._project_lib._experiments + exp_key = self._experiment_key_at_index(index) + if exp_key is None: print(f'Experiment index {index} is out of range.') + return + + if hasattr(experiments, 'items'): + del experiments[exp_key] + else: + experiments.pop(index) + + current = self._project_lib._current_experiment_index + new_total = max(0, total - 1) + if new_total == 0: + self._project_lib._current_experiment_index = 0 + elif current > index: + self._project_lib._current_experiment_index = current - 1 + elif current >= new_total: + self._project_lib._current_experiment_index = new_total - 1 diff --git a/EasyReflectometryApp/Backends/Py/logic/fitting.py b/EasyReflectometryApp/Backends/Py/logic/fitting.py index 1e71955f..18d683af 100644 --- a/EasyReflectometryApp/Backends/Py/logic/fitting.py +++ b/EasyReflectometryApp/Backends/Py/logic/fitting.py @@ -106,6 +106,25 @@ def prepare_for_threaded_fit(self) -> None: self._show_results_dialog = False self._fit_error_message = None + def _ordered_experiments(self) -> list: + """Return experiments as an ordered list of experiment objects. + + Handles mapping-like storage without assuming contiguous integer keys. + """ + experiments = self._project_lib._experiments + if not experiments: + return [] + + if hasattr(experiments, 'items'): + items = list(experiments.items()) + try: + items.sort(key=lambda item: item[0]) + except TypeError: + pass + return [experiment for _, experiment in items] + + return list(experiments) + def prepare_threaded_fit(self, minimizers_logic: 'Minimizers') -> tuple: """Prepare data for threaded fitting. @@ -115,7 +134,7 @@ def prepare_threaded_fit(self, minimizers_logic: 'Minimizers') -> tuple: try: from easyreflectometry.fitting import MultiFitter - experiments = self._project_lib._experiments + experiments = self._ordered_experiments() if not experiments: self._fit_error_message = 'No experiments to fit' self._running = False @@ -123,30 +142,44 @@ def prepare_threaded_fit(self, minimizers_logic: 'Minimizers') -> tuple: self._show_results_dialog = True return None, None, None, None, None - experiment_indices = range(len(experiments)) - # Create MultiFitter with all models - models = [experiments[idx].model for idx in experiment_indices] + models = [experiment.model for experiment in experiments] multi_fitter = MultiFitter(*models) + # Apply the user-selected minimizer to the new fitter + selected_minimizer = minimizers_logic.selected_minimizer_enum() + if selected_minimizer is not None: + multi_fitter.easy_science_multi_fitter.switch_minimizer(selected_minimizer) + logger.info( + 'Fitting: applied minimizer %s to MultiFitter (engine: %s, method: %s)', + selected_minimizer.name, + multi_fitter.easy_science_multi_fitter.minimizer.package, + multi_fitter.easy_science_multi_fitter.minimizer._method, + ) + if minimizers_logic.tolerance is not None: + multi_fitter.easy_science_multi_fitter.tolerance = minimizers_logic.tolerance + if minimizers_logic.max_iterations is not None: + multi_fitter.easy_science_multi_fitter.max_evaluations = minimizers_logic.max_iterations + # Prepare data arrays for all experiments - x_data = [experiments[idx].x for idx in experiment_indices] - y_data = [experiments[idx].y for idx in experiment_indices] + x_data = [experiment.x for experiment in experiments] + y_data = [experiment.y for experiment in experiments] # Validate error values before computing weights to avoid division by zero import numpy as np - for idx in experiment_indices: - ye = experiments[idx].ye + for idx, experiment in enumerate(experiments): + ye = experiment.ye if np.any(ye == 0): - exp_name = experiments[idx].name if hasattr(experiments[idx], 'name') else f'index {idx}' + exp_name = experiment.name if hasattr(experiment, 'name') else f'index {idx}' self._fit_error_message = f'Experiment {exp_name} has zero error values which would cause division by zero' self._running = False self._finished = True self._show_results_dialog = True return None, None, None, None, None - weights = [1.0 / experiments[idx].ye for idx in experiment_indices] + # ye contains variances (sigma²); weights = 1/sigma = 1/sqrt(variance) + weights = [1.0 / np.sqrt(experiment.ye) for experiment in experiments] # Method is optional in fit() - pass None to use minimizer's default method = None @@ -176,6 +209,8 @@ def on_fit_finished(self, results: List[FitResults]) -> None: # For multi-experiment fits, store the list; use first for single-result properties self._results = results self._result = results[0] + engine_name = getattr(results[0], 'minimizer_engine', 'unknown') + logger.info('Fit finished: engine=%s, chi2=%s, success=%s', engine_name, self.fit_chi2, results[0].success) else: self._result = results self._results = [results] if results else [] @@ -222,12 +257,12 @@ def start_stop(self) -> None: # Handle fit failure - create a failed result self._result = None self._fit_error_message = str(e) - print(f'Fit failed: {e}') + logger.warning('Fit failed: %s', e) except Exception as e: # Handle any other unexpected exceptions self._result = None self._fit_error_message = str(e) - print(f'Unexpected error during fit: {e}') + logger.warning('Unexpected error during fit: %s', e) finally: self._running = False self._finished = True diff --git a/EasyReflectometryApp/Backends/Py/logic/helpers.py b/EasyReflectometryApp/Backends/Py/logic/helpers.py index bb1a90d0..70465a5b 100644 --- a/EasyReflectometryApp/Backends/Py/logic/helpers.py +++ b/EasyReflectometryApp/Backends/Py/logic/helpers.py @@ -16,3 +16,14 @@ def formatMsg(type, *args): msg = ' ▌ '.join(msgs) msg = f'{mark} {msg}' return msg + + +def get_original_name(obj) -> str: + """Get original name from user_data, with defensive fallback to obj.name. + + Safely handles cases where user_data is None or not a dict. + """ + user_data = getattr(obj, 'user_data', None) + if isinstance(user_data, dict): + return user_data.get('original_name', obj.name) + return obj.name diff --git a/EasyReflectometryApp/Backends/Py/logic/minimizers.py b/EasyReflectometryApp/Backends/Py/logic/minimizers.py index b6352b56..0a190dfb 100644 --- a/EasyReflectometryApp/Backends/Py/logic/minimizers.py +++ b/EasyReflectometryApp/Backends/Py/logic/minimizers.py @@ -26,12 +26,17 @@ def minimizers_available(self) -> list[str]: def minimizer_current_index(self) -> int: return self._minimizer_current_index + def selected_minimizer_enum(self): + """Return the AvailableMinimizers enum for the currently selected minimizer.""" + if 0 <= self._minimizer_current_index < len(self._list_available_minimizers): + return self._list_available_minimizers[self._minimizer_current_index] + return None + def set_minimizer_current_index(self, new_value: int) -> bool: if new_value != self._minimizer_current_index: self._minimizer_current_index = new_value - if self._project_lib._fitter is not None: - enum_new_minimizer = self._list_available_minimizers[new_value] - self._project_lib._fitter.switch_minimizer(enum_new_minimizer) + enum_new_minimizer = self._list_available_minimizers[new_value] + self._project_lib.minimizer = enum_new_minimizer return True return False diff --git a/EasyReflectometryApp/Backends/Py/logic/models.py b/EasyReflectometryApp/Backends/Py/logic/models.py index cf2777d2..37bc1e81 100644 --- a/EasyReflectometryApp/Backends/Py/logic/models.py +++ b/EasyReflectometryApp/Backends/Py/logic/models.py @@ -5,6 +5,8 @@ from easyreflectometry.model import ModelCollection from easyreflectometry.model.resolution_functions import PercentageFwhm +from .helpers import get_original_name + class Models: def __init__(self, project_lib: ProjectLib): @@ -21,7 +23,7 @@ def index(self, new_value: Union[int, str]) -> None: @property def name_at_current_index(self) -> str: - return self._models[self.index].name + return get_original_name(self._models[self.index]) @property def scaling_at_current_index(self) -> float: @@ -128,7 +130,7 @@ def _from_models_collection_to_list_of_dicts(models_collection: ModelCollection) for model in models_collection: models_list.append( { - 'label': model.name, + 'label': get_original_name(model), 'color': str(model.color), } ) diff --git a/EasyReflectometryApp/Backends/Py/logic/parameters.py b/EasyReflectometryApp/Backends/Py/logic/parameters.py index bc36f539..1cb57160 100644 --- a/EasyReflectometryApp/Backends/Py/logic/parameters.py +++ b/EasyReflectometryApp/Backends/Py/logic/parameters.py @@ -9,6 +9,8 @@ from easyscience import global_object from easyscience.variable import Parameter +from .helpers import get_original_name + RESERVED_ALIAS_NAMES = {'np', 'numpy', 'math', 'pi', 'e'} @@ -226,7 +228,7 @@ def _is_layer_parameter(param: Parameter) -> bool: # Process parameters for each model for model_idx, model in enumerate(models): model_unique_name = model.unique_name - model_prefix = f'M{model_idx + 1}' + model_prefix = get_original_name(model) for parameter in parameters: # Skip parameters not in this model's path @@ -249,6 +251,7 @@ def _is_layer_parameter(param: Parameter) -> bool: prefixed_display_name = display_name alias = _make_alias(prefixed_display_name or parameter.name) + param_value = float(parameter.value) parameter_list.append( { 'name': prefixed_display_name, @@ -256,7 +259,7 @@ def _is_layer_parameter(param: Parameter) -> bool: 'group': group_name, 'alias': alias, 'unique_name': parameter.unique_name, - 'value': float(parameter.value), + 'value': param_value, 'error': float(parameter.variance), 'max': float(parameter.max), 'min': float(parameter.min), diff --git a/EasyReflectometryApp/Backends/Py/logic/project.py b/EasyReflectometryApp/Backends/Py/logic/project.py index adf8976c..47ee5be7 100644 --- a/EasyReflectometryApp/Backends/Py/logic/project.py +++ b/EasyReflectometryApp/Backends/Py/logic/project.py @@ -122,6 +122,11 @@ def add_sample_from_orso(self, sample) -> None: new_model_index = len(self._project_lib.models) - 1 self._update_enablement_of_fixed_layers_for_model(new_model_index) + def replace_models_from_orso(self, sample) -> None: + """Replace all existing models with a single model built from the loaded sample.""" + self._project_lib.replace_models_from_orso(sample) + self._update_enablement_of_fixed_layers_for_model(0) + def reset(self) -> None: self._project_lib.reset() self._project_lib.default_model() diff --git a/EasyReflectometryApp/Backends/Py/logic/status.py b/EasyReflectometryApp/Backends/Py/logic/status.py index 87ac11ce..89fc2fb7 100644 --- a/EasyReflectometryApp/Backends/Py/logic/status.py +++ b/EasyReflectometryApp/Backends/Py/logic/status.py @@ -4,6 +4,10 @@ class Status: def __init__(self, project_lib: ProjectLib): self._project_lib = project_lib + self._minimizers_logic = None + + def set_minimizers_logic(self, minimizers_logic): + self._minimizers_logic = minimizers_logic @property def project(self): @@ -11,6 +15,11 @@ def project(self): @property def minimizer(self): + if self._minimizers_logic is not None: + available = self._minimizers_logic.minimizers_available() + idx = self._minimizers_logic.minimizer_current_index() + if 0 <= idx < len(available): + return available[idx] return self._project_lib.minimizer.name @property diff --git a/EasyReflectometryApp/Backends/Py/plotting_1d.py b/EasyReflectometryApp/Backends/Py/plotting_1d.py index e06fdf6a..36ee9f88 100644 --- a/EasyReflectometryApp/Backends/Py/plotting_1d.py +++ b/EasyReflectometryApp/Backends/Py/plotting_1d.py @@ -19,6 +19,13 @@ class Plotting1d(QObject): experimentChartRangesChanged = Signal() experimentDataChanged = Signal() samplePageDataChanged = Signal() # Signal for QML to refresh sample page charts + samplePageResetAxes = Signal() # Signal for QML to reset chart axes after data load + + # New signals for plot mode properties + plotModeChanged = Signal() + axisTypeChanged = Signal() + sldAxisReversedChanged = Signal() + referenceLineVisibilityChanged = Signal() def __init__(self, project_lib: ProjectLib, parent=None): super().__init__(parent) @@ -26,7 +33,15 @@ def __init__(self, project_lib: ProjectLib, parent=None): self._proxy = parent self._currentLib1d = 'QtCharts' self._sample_data = {} + self._model_data = {} self._sld_data = {} + + # Plot mode state + self._plot_rq4 = False + self._x_axis_log = False + self._sld_x_reversed = False + self._scale_shown = False + self._bkg_shown = False self._chartRefs = { 'QtCharts': { 'samplePage': { @@ -48,9 +63,150 @@ def __init__(self, project_lib: ProjectLib, parent=None): def reset_data(self): self._sample_data = {} + self._model_data = {} self._sld_data = {} console.debug(IO.formatMsg('sub', 'Sample and SLD data cleared')) + def _apply_rq4(self, x, y): + """Apply R(q)×q⁴ transformation if enabled. + + Works with both numpy arrays and scalar values. + """ + if self._plot_rq4: + return y * (x**4) + return y + + # R(q)×q⁴ mode + @Property(bool, notify=plotModeChanged) + def plotRQ4(self) -> bool: + """Return whether R(q)×q⁴ mode is enabled.""" + return self._plot_rq4 + + @Slot() + def togglePlotRQ4(self) -> None: + """Toggle R(q)×q⁴ plotting mode.""" + self._plot_rq4 = not self._plot_rq4 + self.plotModeChanged.emit() + # Refresh all charts with new mode + self.sampleChartRangesChanged.emit() + self.experimentChartRangesChanged.emit() + self.samplePageDataChanged.emit() + + @Property(str, notify=plotModeChanged) + def yMainAxisTitle(self) -> str: + """Return Y-axis title based on current plot mode.""" + return 'R(q)×q⁴' if self._plot_rq4 else 'R(q)' + + # X-axis type (log/linear) + @Property(bool, notify=axisTypeChanged) + def xAxisLog(self) -> bool: + """Return whether X-axis is logarithmic.""" + return self._x_axis_log + + @Slot() + def toggleXAxisType(self) -> None: + """Toggle between linear and logarithmic X-axis.""" + self._x_axis_log = not self._x_axis_log + self.axisTypeChanged.emit() + + @Property(str, notify=axisTypeChanged) + def xAxisType(self) -> str: + """Return X-axis type as string for QML.""" + return 'log' if self._x_axis_log else 'linear' + + # SLD X-axis reversal + @Property(bool, notify=sldAxisReversedChanged) + def sldXDataReversed(self) -> bool: + """Return whether SLD X-axis is reversed.""" + return self._sld_x_reversed + + @Slot() + def reverseSldXData(self) -> None: + """Toggle SLD X-axis reversal.""" + self._sld_x_reversed = not self._sld_x_reversed + self.sldAxisReversedChanged.emit() + self.sldChartRangesChanged.emit() + + # Reference line visibility + @Property(bool, notify=referenceLineVisibilityChanged) + def scaleShown(self) -> bool: + """Return whether scale reference line is shown.""" + return self._scale_shown + + @Slot() + def flipScaleShown(self) -> None: + """Toggle scale line visibility.""" + self._scale_shown = not self._scale_shown + self.referenceLineVisibilityChanged.emit() + + @Property(bool, notify=referenceLineVisibilityChanged) + def bkgShown(self) -> bool: + """Return whether background reference line is shown.""" + return self._bkg_shown + + @Slot() + def flipBkgShown(self) -> None: + """Toggle background line visibility.""" + self._bkg_shown = not self._bkg_shown + self.referenceLineVisibilityChanged.emit() + + def _get_reference_line_data(self, param_attr: str, default_log: float, use_analysis_range: bool) -> list: + """Build a horizontal reference line for the given model parameter. + + :param param_attr: Model attribute name ('background' or 'scale') + :param default_log: Default log10 value if parameter <= 0 + :param use_analysis_range: If True, use sample/analysis x-range; if False, use experimental data x-range + """ + try: + model_idx = self._project_lib.current_model_index + model = self._project_lib.models[model_idx] + + if use_analysis_range: + x_min, x_max = self._get_all_models_sample_range()[0:2] + if x_min == float('inf') or x_max == float('-inf'): + return [] + else: + exp_idx = self._project_lib.current_experiment_index + exp_data = self._project_lib.experimental_data_for_model_at_index(exp_idx) + if exp_data.x is None or len(exp_data.x) == 0: + return [] + x_min, x_max = float(exp_data.x[0]), float(exp_data.x[-1]) + + param_value = getattr(model, param_attr).value + y_log = float(np.log10(param_value)) if param_value > 0 else default_log + return [{'x': float(x_min), 'y': y_log}, {'x': float(x_max), 'y': y_log}] + except (IndexError, AttributeError, TypeError) as e: + console.debug(f'Error getting {param_attr} reference line data: {e}') + return [] + + @Slot(result='QVariantList') + def getBackgroundData(self) -> list: + """Return background reference line data for the Experiment chart.""" + if not self._bkg_shown: + return [] + return self._get_reference_line_data('background', -10.0, use_analysis_range=False) + + @Slot(result='QVariantList') + def getScaleData(self) -> list: + """Return scale reference line data for the Experiment chart.""" + if not self._scale_shown: + return [] + return self._get_reference_line_data('scale', 0.0, use_analysis_range=False) + + @Slot(result='QVariantList') + def getBackgroundDataForAnalysis(self) -> list: + """Return background reference line data for the Analysis chart (sample x-range).""" + if not self._bkg_shown: + return [] + return self._get_reference_line_data('background', -10.0, use_analysis_range=True) + + @Slot(result='QVariantList') + def getScaleDataForAnalysis(self) -> list: + """Return scale reference line data for the Analysis chart (sample x-range).""" + if not self._scale_shown: + return [] + return self._get_reference_line_data('scale', 0.0, use_analysis_range=True) + @property def sample_data(self) -> DataSet1D: idx = self._project_lib.current_model_index @@ -67,6 +223,22 @@ def sample_data(self) -> DataSet1D: self._sample_data[idx] = data return data + @property + def model_data(self) -> DataSet1D: + idx = self._project_lib.current_model_index + if idx in self._model_data and self._model_data[idx] is not None: + return self._model_data[idx] + try: + data = self._project_lib.model_data_for_model_at_index(idx) + except IndexError: + data = DataSet1D( + name='Model Data empty', + x=np.empty(0), + y=np.empty(0), + ) + self._model_data[idx] = data + return data + @property def sld_data(self) -> DataSet1D: idx = self._project_lib.current_model_index @@ -154,8 +326,10 @@ def _get_all_models_sample_range(self): min_x = min(min_x, data.x.min()) max_x = max(max_x, data.x.max()) if data.y.size > 0: - valid_y = data.y[data.y > 0] + valid_mask = data.y > 0 + valid_y = data.y[valid_mask] if valid_y.size > 0: + valid_y = self._apply_rq4(data.x[valid_mask], valid_y) min_y = min(min_y, np.log10(valid_y.min())) max_y = max(max_y, np.log10(valid_y.max())) except (IndexError, ValueError): @@ -167,9 +341,11 @@ def _get_all_models_sample_range(self): if max_x == float('-inf'): max_x = self.sample_data.x.max() if self.sample_data.x.size > 0 else 1.0 if min_y == float('inf'): - min_y = np.log10(self.sample_data.y.min()) if self.sample_data.y.size > 0 else -10.0 + valid_y = self.sample_data.y[self.sample_data.y > 0] if self.sample_data.y.size > 0 else np.array([]) + min_y = np.log10(valid_y.min()) if valid_y.size > 0 else -10.0 if max_y == float('-inf'): - max_y = np.log10(self.sample_data.y.max()) if self.sample_data.y.size > 0 else 0.0 + valid_y = self.sample_data.y[self.sample_data.y > 0] if self.sample_data.y.size > 0 else np.array([]) + max_y = np.log10(valid_y.max()) if valid_y.size > 0 else 0.0 return (min_x, max_x, min_y, max_y) @@ -233,13 +409,24 @@ def experimentMinX(self): @Property(float, notify=experimentChartRangesChanged) def experimentMaxY(self): data = self.experiment_data - return np.log10(data.y.max()) if data.y.size > 0 else 1.0 + if data.y.size == 0: + return 1.0 + y_values = self._apply_rq4(data.x, data.y) + return np.log10(y_values.max()) @Property(float, notify=experimentChartRangesChanged) def experimentMinY(self): data = self.experiment_data valid_y = data.y[data.y > 0] if data.y.size > 0 else np.array([1e-10]) - return np.log10(valid_y.min()) if valid_y.size > 0 else -10.0 + if valid_y.size == 0: + return -10.0 + valid_x = data.x[data.y > 0] if data.y.size > 0 else np.array([1.0]) + valid_y = self._apply_rq4(valid_x, valid_y) + # Filter again after transformation to avoid log of zero/negative + valid_y = valid_y[valid_y > 0] + if valid_y.size == 0: + return -10.0 + return np.log10(valid_y.min()) @Property('QVariant', notify=chartRefsChanged) def chartRefs(self): @@ -284,7 +471,12 @@ def getSampleDataPointsForModel(self, model_index: int) -> list: data = self._project_lib.sample_data_for_model_at_index(model_index) points = [] for point in data.data_points(): - points.append({'x': float(point[0]), 'y': float(np.log10(point[1])) if point[1] > 0 else -10.0}) + x_val = float(point[0]) + y_val = float(point[1]) + if y_val > 0: + y_val = self._apply_rq4(x_val, y_val) + y_log = float(np.log10(y_val)) if y_val > 0 else -10.0 + points.append({'x': x_val, 'y': y_log}) return points except Exception as e: console.debug(f'Error getting sample data points for model {model_index}: {e}') @@ -324,12 +516,19 @@ def getExperimentDataPoints(self, experiment_index: int) -> list: points = [] for point in data.data_points(): if point[0] < self._project_lib.q_max and self._project_lib.q_min < point[0]: + q = point[0] + r = point[1] + error_var = point[2] + error_lower_linear = max(r - np.sqrt(error_var), 1e-10) + r_val = self._apply_rq4(q, r) + error_upper = self._apply_rq4(q, r + np.sqrt(error_var)) + error_lower = self._apply_rq4(q, error_lower_linear) points.append( { - 'x': float(point[0]), - 'y': float(np.log10(point[1])), - 'errorUpper': float(np.log10(point[1] + np.sqrt(point[2]))), - 'errorLower': float(np.log10(max(point[1] - np.sqrt(point[2]), 1e-10))), # Avoid log(0) + 'x': float(q), + 'y': float(np.log10(r_val)), + 'errorUpper': float(np.log10(error_upper)), + 'errorLower': float(np.log10(error_lower)), } ) return points @@ -347,12 +546,17 @@ def getAnalysisDataPoints(self, experiment_index: int) -> list: # Get the model index for this experiment - it may be different from experiment_index # When multiple experiments share the same model model_index = 0 + model_found = False if hasattr(exp_data, 'model') and exp_data.model is not None: # Find the model index in the models collection for idx, model in enumerate(self._project_lib.models): if model is exp_data.model: model_index = idx + model_found = True break + if not model_found: + console.debug(f'Warning: model for experiment {experiment_index} ' + f'not found in models collection, falling back to model 0') else: # Fallback: use experiment_index if it's within model range, else 0 model_index = experiment_index if experiment_index < len(self._project_lib.models) else 0 @@ -364,20 +568,29 @@ def getAnalysisDataPoints(self, experiment_index: int) -> list: q_filtered = q_values[mask] # Get calculated model data at the same q points using the correct model index - calc_data = self._project_lib.sample_data_for_model_at_index(model_index, q_filtered) + calc_data = self._project_lib.model_data_for_model_at_index(model_index, q_filtered) points = [] exp_points = list(exp_data.data_points()) calc_y = calc_data.y + if len(calc_y) != len(q_filtered): + console.debug(f'Warning: calculated data length ({len(calc_y)}) ' + f'differs from filtered experimental data ({len(q_filtered)}) ' + f'for experiment {experiment_index}') + calc_idx = 0 for point in exp_points: if point[0] < self._project_lib.q_max and self._project_lib.q_min < point[0]: - calc_y_val = calc_y[calc_idx] if calc_idx < len(calc_y) else point[1] + q = point[0] + r_meas = point[1] + calc_y_val = calc_y[calc_idx] if calc_idx < len(calc_y) else r_meas + r_meas = self._apply_rq4(q, r_meas) + calc_y_val = self._apply_rq4(q, calc_y_val) points.append( { - 'x': float(point[0]), - 'measured': float(np.log10(point[1])), + 'x': float(q), + 'measured': float(np.log10(r_meas)), 'calculated': float(np.log10(calc_y_val)), } ) @@ -390,6 +603,7 @@ def getAnalysisDataPoints(self, experiment_index: int) -> list: def refreshSamplePage(self): # Clear cached data so it gets recalculated self._sample_data = {} + self._model_data = {} self._sld_data = {} # Emit signals to update ranges and trigger QML refresh self.sampleChartRangesChanged.emit() @@ -400,6 +614,7 @@ def refreshExperimentPage(self): self.drawMeasuredOnExperimentChart() def refreshAnalysisPage(self): + self._model_data = {} self.drawCalculatedAndMeasuredOnAnalysisChart() def refreshExperimentRanges(self): @@ -446,6 +661,7 @@ def qtchartsReplaceCalculatedOnSldChartAndRedraw(self): nr_points = nr_points + 1 console.debug(IO.formatMsg('sub', 'Sld curve', f'{nr_points} points', 'on analysis page', 'replaced')) + @Slot() def drawMeasuredOnExperimentChart(self): if PLOT_BACKEND == 'QtCharts': if self.is_multi_experiment_mode: @@ -463,9 +679,16 @@ def qtchartsReplaceMeasuredOnExperimentChartAndRedraw(self): nr_points = 0 for point in self.experiment_data.data_points(): if point[0] < self._project_lib.q_max and self._project_lib.q_min < point[0]: - series_measured.append(point[0], np.log10(point[1])) - series_error_upper.append(point[0], np.log10(point[1] + np.sqrt(point[2]))) - series_error_lower.append(point[0], np.log10(max(point[1] - np.sqrt(point[2]), 1e-10))) + q = point[0] + r = point[1] + error_var = point[2] + error_lower_linear = max(r - np.sqrt(error_var), 1e-10) + r_val = self._apply_rq4(q, r) + error_upper = self._apply_rq4(q, r + np.sqrt(error_var)) + error_lower = self._apply_rq4(q, error_lower_linear) + series_measured.append(q, np.log10(r_val)) + series_error_upper.append(q, np.log10(error_upper)) + series_error_lower.append(q, np.log10(error_lower)) nr_points = nr_points + 1 console.debug(IO.formatMsg('sub', 'Measured curve', f'{nr_points} points', 'on experiment page', 'replaced')) @@ -486,6 +709,7 @@ def qtchartsReplaceMultiExperimentChartAndRedraw(self): # This method is called to trigger the refresh, actual drawing is handled by QML self.experimentDataChanged.emit() + @Slot() def drawCalculatedAndMeasuredOnAnalysisChart(self): if PLOT_BACKEND == 'QtCharts': if self.is_multi_experiment_mode: @@ -515,11 +739,15 @@ def qtchartsReplaceCalculatedAndMeasuredOnAnalysisChartAndRedraw(self): nr_points = 0 for point in self.experiment_data.data_points(): if point[0] < self._project_lib.q_max and self._project_lib.q_min < point[0]: - series_measured.append(point[0], np.log10(point[1])) + q = point[0] + r_meas = self._apply_rq4(q, point[1]) + series_measured.append(q, np.log10(r_meas)) nr_points = nr_points + 1 - console.debug(IO.formatMsg('sub', 'Measurede curve', f'{nr_points} points', 'on analysis page', 'replaced')) + console.debug(IO.formatMsg('sub', 'Measured curve', f'{nr_points} points', 'on analysis page', 'replaced')) - for point in self.sample_data.data_points(): - series_calculated.append(point[0], np.log10(point[1])) + for point in self.model_data.data_points(): + q = point[0] + r_calc = self._apply_rq4(q, point[1]) + series_calculated.append(q, np.log10(r_calc)) nr_points = nr_points + 1 console.debug(IO.formatMsg('sub', 'Calculated curve', f'{nr_points} points', 'on analysis page', 'replaced')) diff --git a/EasyReflectometryApp/Backends/Py/project.py b/EasyReflectometryApp/Backends/Py/project.py index 5217c0df..4fba7149 100644 --- a/EasyReflectometryApp/Backends/Py/project.py +++ b/EasyReflectometryApp/Backends/Py/project.py @@ -1,3 +1,5 @@ +import warnings + from EasyApp.Logic.Utils.Utils import generalizePath from easyreflectometry import Project as ProjectLib from easyreflectometry.orso_utils import load_orso_model @@ -20,6 +22,7 @@ class Project(QObject): externalNameChanged = Signal() externalProjectLoaded = Signal() externalProjectReset = Signal() + sampleLoadWarning = Signal(str) def __init__(self, project_lib: ProjectLib, parent=None): super().__init__(parent) @@ -104,13 +107,25 @@ def reset(self) -> None: self.externalNameChanged.emit() self.externalProjectReset.emit() - @Slot(str) - def sampleLoad(self, url: str) -> None: + @Slot(str, bool) + def sampleLoad(self, url: str, append: bool = True) -> None: # Load ORSO file content orso_data = orso.load_orso(generalizePath(url)) # Load the sample model - sample = load_orso_model(orso_data) - # Add the sample as a new model in the project - self._logic.add_sample_from_orso(sample) + with warnings.catch_warnings(record=True) as caught_warnings: + warnings.simplefilter('always') + sample = load_orso_model(orso_data) + if sample is None: + warning_msg = 'The ORSO file does not contain a valid sample model definition. No sample was loaded.' + for w in caught_warnings: + warning_msg = str(w.message) + self.sampleLoadWarning.emit(warning_msg) + return + if append: + # Add the sample as a new model in the project + self._logic.add_sample_from_orso(sample) + else: + # Replace all existing models with the loaded sample + self._logic.replace_models_from_orso(sample) # notify listeners self.externalProjectLoaded.emit() diff --git a/EasyReflectometryApp/Backends/Py/py_backend.py b/EasyReflectometryApp/Backends/Py/py_backend.py index ce924fe8..57ba5a8b 100644 --- a/EasyReflectometryApp/Backends/Py/py_backend.py +++ b/EasyReflectometryApp/Backends/Py/py_backend.py @@ -1,4 +1,5 @@ from EasyApp.Logic.Logging import LoggerLevelHandler +from EasyApp.Logic.Logging import console from easyreflectometry import Project as ProjectLib from PySide6.QtCore import Property from PySide6.QtCore import QObject @@ -38,6 +39,9 @@ def __init__(self, parent=None): self._logger = LoggerLevelHandler(self) + # Wire cross-cutting references before connecting signals + self._status._status_logic.set_minimizers_logic(self._analysis._minimizers_logic) + # Must be last to ensure all backend parts are created self._connect_backend_parts() @@ -94,18 +98,18 @@ def analysisSelectedExperimentIndices(self) -> list: @Slot('QVariantList') def analysisSetSelectedExperimentIndices(self, indices) -> None: """Set multiple selected experiment indices.""" - print(f'PyBackend.analysisSetSelectedExperimentIndices called with: {indices}') - print(f'Type of indices: {type(indices)}') + console.debug(f'PyBackend.analysisSetSelectedExperimentIndices called with: {indices}') + console.debug(f'Type of indices: {type(indices)}') # Convert QVariantList to Python list if needed python_indices = list(indices) if hasattr(indices, '__iter__') else [] - print(f'Converted to Python list: {python_indices}') + console.debug(f'Converted to Python list: {python_indices}') if hasattr(self._analysis, 'setSelectedExperimentIndices'): self._analysis.setSelectedExperimentIndices(python_indices) - print('Successfully called analysis.setSelectedExperimentIndices') + console.debug('Successfully called analysis.setSelectedExperimentIndices') else: - print('ERROR: analysis.setSelectedExperimentIndices method not found') + console.debug('ERROR: analysis.setSelectedExperimentIndices method not found') # Emit our local signal to notify QML properties self.multiExperimentSelectionChanged.emit() @@ -177,10 +181,15 @@ def _relay_project_page_created(self): self._summary.summaryChanged.emit() def _relay_project_page_project_changed(self): + # Clear layers cache first so that subsequent signal handlers + # (e.g. ComboBox onModelChanged / onCurrentAssemblyNameChanged in + # MultiLayer.qml) read up-to-date layer data. + self._sample._clearCacheAndEmitLayersChanged() self._sample.materialsTableChanged.emit() self._sample.modelsTableChanged.emit() + self._sample.modelsIndexChanged.emit() self._sample.assembliesTableChanged.emit() - self._sample._clearCacheAndEmitLayersChanged() + self._sample.assembliesIndexChanged.emit() self._experiment.experimentChanged.emit() self._analysis.experimentsChanged.emit() self._analysis._clearCacheAndEmitParametersChanged() @@ -188,12 +197,14 @@ def _relay_project_page_project_changed(self): self._summary.summaryChanged.emit() self._plotting_1d.reset_data() self._refresh_plots() + self._plotting_1d.samplePageResetAxes.emit() def _relay_sample_page_sample_changed(self): self._plotting_1d.reset_data() self._analysis._clearCacheAndEmitParametersChanged() self._status.statusChanged.emit() self._summary.summaryChanged.emit() + self._plotting_1d.samplePageResetAxes.emit() def _relay_experiment_page_experiment_changed(self): self._analysis.experimentsChanged.emit() @@ -206,6 +217,7 @@ def _relay_analysis_page(self): self._status.statusChanged.emit() self._experiment.experimentChanged.emit() self._summary.summaryChanged.emit() + self._plotting_1d.samplePageResetAxes.emit() def _refresh_plots(self): self._plotting_1d.sampleChartRangesChanged.emit() diff --git a/EasyReflectometryApp/Backends/Py/sample.py b/EasyReflectometryApp/Backends/Py/sample.py index 73c7647a..1c73c226 100644 --- a/EasyReflectometryApp/Backends/Py/sample.py +++ b/EasyReflectometryApp/Backends/Py/sample.py @@ -552,13 +552,29 @@ def _sanitize_relation(operator: str) -> str: def _format_numeric(value: float) -> str: return f'{value:.6g}' + def _get_independent_parameter_entries(self) -> list[dict]: + """Return the filtered list of independent+enabled parameter entries. + + This must match the same filtering as dependentParameterNames so that + the QML dropdown index maps to the correct parameter object. + """ + entries = [] + for parameter in self._parameters_logic.parameters: + if not parameter['independent']: + continue + if hasattr(parameter['object'], 'enabled') and not parameter['object'].enabled: + continue + entries.append(parameter) + return entries + def _prepare_constraint_instruction( self, dependent_index: int, relation_operator: str, expression: str, ) -> dict[str, Any]: - if dependent_index < 0 or dependent_index >= len(self._project_lib.parameters): + independent_entries = self._get_independent_parameter_entries() + if dependent_index < 0 or dependent_index >= len(independent_entries): raise ValueError('Select a dependent parameter before defining a constraint.') relation = self._sanitize_relation(relation_operator) @@ -868,7 +884,7 @@ def addConstraint(self, dependent_index: int, relation: str, expression: str): except Exception as error: # noqa: BLE001 return {'success': False, 'message': str(error)} - dependent = self._project_lib.parameters[dependent_index] + dependent = self._get_independent_parameter_entries()[dependent_index]['object'] previous_state = self._capture_parameter_state(dependent) self._ensure_parameter_independent(dependent) diff --git a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml index 15e23f18..df602135 100644 --- a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml +++ b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml @@ -78,7 +78,17 @@ QtObject { function projectReset() { activeBackend.project.reset() } function projectSave() { activeBackend.project.save() } function projectLoad(value) { activeBackend.project.load(value) } - function sampleFileLoad(value) { activeBackend.project.sampleLoad(value) } + function sampleFileLoad(value, append) { activeBackend.project.sampleLoad(value, append) } + + // Sample load warning signal - forwarded from backend + signal sampleLoadWarning(string message) + + property var _sampleLoadWarningConnection: { + if (activeBackend && activeBackend.project && activeBackend.project.sampleLoadWarning) { + activeBackend.project.sampleLoadWarning.connect(sampleLoadWarning) + } + return null + } /////////////// @@ -208,6 +218,7 @@ QtObject { try { return activeBackend.analysisExperimentsSelectedCount || 1 } catch (e) { + console.warn("analysisExperimentsSelectedCount failed:", e) return 1 } } @@ -215,6 +226,7 @@ QtObject { try { return activeBackend.analysisSelectedExperimentIndices || [] } catch (e) { + console.warn("analysisSelectedExperimentIndices failed:", e) return [] } } @@ -328,9 +340,84 @@ QtObject { readonly property var plottingAnalysisMaxY: activeBackend.plotting.sampleMaxY readonly property var calcSerieColor: activeBackend.plotting.calcSerieColor + // Plot mode properties + readonly property bool plottingPlotRQ4: activeBackend.plotting.plotRQ4 + readonly property string plottingYAxisTitle: activeBackend.plotting.yMainAxisTitle + readonly property bool plottingXAxisLog: activeBackend.plotting.xAxisLog + readonly property string plottingXAxisType: activeBackend.plotting.xAxisType + readonly property bool plottingSldXReversed: activeBackend.plotting.sldXDataReversed + + // Reference line visibility + readonly property bool plottingScaleShown: activeBackend.plotting.scaleShown + readonly property bool plottingBkgShown: activeBackend.plotting.bkgShown + + // Plot mode toggle functions + function plottingTogglePlotRQ4() { activeBackend.plotting.togglePlotRQ4() } + function plottingToggleXAxisType() { activeBackend.plotting.toggleXAxisType() } + function plottingReverseSldXData() { activeBackend.plotting.reverseSldXData() } + function plottingFlipScaleShown() { activeBackend.plotting.flipScaleShown() } + function plottingFlipBkgShown() { activeBackend.plotting.flipBkgShown() } + + // Reference line data accessors + function plottingGetBackgroundData() { + try { + return activeBackend.plotting.getBackgroundData() + } catch (e) { + console.warn("plottingGetBackgroundData failed:", e) + return [] + } + } + function plottingGetScaleData() { + try { + return activeBackend.plotting.getScaleData() + } catch (e) { + console.warn("plottingGetScaleData failed:", e) + return [] + } + } + + // Analysis-specific reference line data accessors (use sample/calculated x-range) + function plottingGetBackgroundDataForAnalysis() { + try { + return activeBackend.plotting.getBackgroundDataForAnalysis() + } catch (e) { + console.warn("plottingGetBackgroundDataForAnalysis failed:", e) + return [] + } + } + function plottingGetScaleDataForAnalysis() { + try { + return activeBackend.plotting.getScaleDataForAnalysis() + } catch (e) { + console.warn("plottingGetScaleDataForAnalysis failed:", e) + return [] + } + } + + // Helper to update background/scale reference line series on any chart view. + // useAnalysisRange: true for Analysis charts (sample x-range), false for Experiment charts. + function updateRefLines(bkgSeries, scaleSeries, useAnalysisRange) { + bkgSeries.clear() + if (plottingBkgShown) { + var bkgData = useAnalysisRange ? plottingGetBackgroundDataForAnalysis() : plottingGetBackgroundData() + for (var i = 0; i < bkgData.length; i++) { + bkgSeries.append(bkgData[i].x, bkgData[i].y) + } + } + scaleSeries.clear() + if (plottingScaleShown) { + var scaleData = useAnalysisRange ? plottingGetScaleDataForAnalysis() : plottingGetScaleData() + for (var j = 0; j < scaleData.length; j++) { + scaleSeries.append(scaleData[j].x, scaleData[j].y) + } + } + } + function plottingSetQtChartsSerieRef(value1, value2, value3) { activeBackend.plotting.setQtChartsSerieRef(value1, value2, value3) } function plottingRefreshSample() { activeBackend.plotting.drawCalculatedOnSampleChart() } function plottingRefreshSLD() { activeBackend.plotting.drawCalculatedOnSldChart() } + function plottingRefreshExperiment() { activeBackend.plotting.drawMeasuredOnExperimentChart() } + function plottingRefreshAnalysis() { activeBackend.plotting.drawCalculatedAndMeasuredOnAnalysisChart() } // Multi-model sample page plotting support readonly property int plottingModelCount: activeBackend.plotting.modelCount @@ -338,6 +425,7 @@ QtObject { try { return activeBackend.plotting.getSampleDataPointsForModel(index) } catch (e) { + console.warn("plottingGetSampleDataPointsForModel failed:", e) return [] } } @@ -345,6 +433,7 @@ QtObject { try { return activeBackend.plotting.getSldDataPointsForModel(index) } catch (e) { + console.warn("plottingGetSldDataPointsForModel failed:", e) return [] } } @@ -352,18 +441,34 @@ QtObject { try { return activeBackend.plotting.getModelColor(index) } catch (e) { + console.warn("plottingGetModelColor failed:", e) return '#000000' } } // Signal for sample page data changes - forward from backend signal samplePageDataChanged() + // Signal for resetting chart axes after data load + signal samplePageResetAxes() + // Signal for plot mode changes - forward from backend + signal plotModeChanged() + // Signal to request QML to reset chart axes (e.g., after model load) + signal chartAxesResetRequested() // Connect to backend signal (called from Component.onCompleted in QML items) function connectSamplePageDataChanged() { if (activeBackend && activeBackend.plotting && activeBackend.plotting.samplePageDataChanged) { activeBackend.plotting.samplePageDataChanged.connect(samplePageDataChanged) } + if (activeBackend && activeBackend.plotting && activeBackend.plotting.samplePageResetAxes) { + activeBackend.plotting.samplePageResetAxes.connect(samplePageResetAxes) + } + if (activeBackend && activeBackend.plotting && activeBackend.plotting.plotModeChanged) { + activeBackend.plotting.plotModeChanged.connect(plotModeChanged) + } + if (activeBackend && activeBackend.plotting && activeBackend.plotting.chartAxesResetRequested) { + activeBackend.plotting.chartAxesResetRequested.connect(chartAxesResetRequested) + } } Component.onCompleted: { @@ -375,6 +480,7 @@ QtObject { try { return activeBackend.plottingIsMultiExperimentMode || false } catch (e) { + console.warn("plottingIsMultiExperimentMode failed:", e) return false } } @@ -382,6 +488,7 @@ QtObject { try { return activeBackend.plottingIndividualExperimentDataList || [] } catch (e) { + console.warn("plottingIndividualExperimentDataList failed:", e) return [] } } @@ -389,6 +496,7 @@ QtObject { try { return activeBackend.plottingGetExperimentDataPoints(index) } catch (e) { + console.warn("plottingGetExperimentDataPoints failed:", e) return [] } } @@ -396,6 +504,7 @@ QtObject { try { return activeBackend.plottingGetAnalysisDataPoints(index) } catch (e) { + console.warn("plottingGetAnalysisDataPoints failed:", e) return [] } } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml index 0baf81cd..fec58f95 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml @@ -31,6 +31,43 @@ Rectangle { useOpenGL: EaGlobals.Vars.useOpenGL + // Background reference line series + LineSeries { + id: backgroundRefLine + axisX: chartView.axisX + axisY: chartView.axisY + useOpenGL: chartView.useOpenGL + color: "#888888" + width: 1 + style: Qt.DashLine + visible: Globals.BackendWrapper.plottingBkgShown + } + + // Scale reference line series + LineSeries { + id: scaleRefLine + axisX: chartView.axisX + axisY: chartView.axisY + useOpenGL: chartView.useOpenGL + color: "#666666" + width: 1 + style: Qt.DotLine + visible: Globals.BackendWrapper.plottingScaleShown + } + + // Update reference lines when visibility changes + Connections { + target: Globals.BackendWrapper.activeBackend?.plotting ?? null + enabled: target !== null + function onReferenceLineVisibilityChanged() { + chartView.updateReferenceLines() + } + } + + function updateReferenceLines() { + Globals.BackendWrapper.updateRefLines(backgroundRefLine, scaleRefLine, true) + } + // Multi-experiment support property var multiExperimentSeries: [] property bool isMultiExperimentMode: { @@ -49,12 +86,38 @@ Rectangle { // Watch for changes in multi-experiment selection Connections { - target: Globals.BackendWrapper.activeBackend + target: Globals.BackendWrapper.activeBackend ?? null + enabled: target !== null function onMultiExperimentSelectionChanged() { console.log("Analysis: Multi-experiment selection changed - updating series") chartView.updateMultiExperimentSeries() } } + + // Watch for plot mode changes (R(q)×q⁴ toggle) + Connections { + target: Globals.BackendWrapper + function onPlotModeChanged() { + console.debug("AnalysisView: Plot mode changed, refreshing chart") + Globals.BackendWrapper.plottingRefreshAnalysis() + // Delay resetAxes to allow axis range properties to update first + analysisResetAxesTimer.start() + } + function onChartAxesResetRequested() { + // Reset axes when model is loaded (e.g., from ORSO file) + analysisResetAxesTimer.start() + } + function onSamplePageResetAxes() { + analysisResetAxesTimer.start() + } + } + + Timer { + id: analysisResetAxesTimer + interval: 75 + repeat: false + onTriggered: chartView.resetAxes() + } property double xRange: Globals.BackendWrapper.plottingAnalysisMaxX - Globals.BackendWrapper.plottingAnalysisMinX axisX.title: "q (Å⁻¹)" @@ -64,7 +127,7 @@ Rectangle { axisX.maxAfterReset: Globals.BackendWrapper.plottingAnalysisMaxX + xRange * 0.01 property double yRange: Globals.BackendWrapper.plottingAnalysisMaxY - Globals.BackendWrapper.plottingAnalysisMinY - axisY.title: "Log10 R(q)" + axisY.title: "Log10 " + Globals.BackendWrapper.plottingYAxisTitle axisY.min: Globals.BackendWrapper.plottingAnalysisMinY - yRange * 0.01 axisY.max: Globals.BackendWrapper.plottingAnalysisMaxY + yRange * 0.01 axisY.minAfterReset: Globals.BackendWrapper.plottingAnalysisMinY - yRange * 0.01 @@ -364,6 +427,9 @@ Rectangle { // Initialize multi-experiment support updateMultiExperimentSeries() + + // Initialize reference lines + updateReferenceLines() } // Update series when chart becomes visible @@ -371,6 +437,9 @@ Rectangle { if (visible && isMultiExperimentMode) { updateMultiExperimentSeries() } + if (visible) { + updateReferenceLines() + } } } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml index 305fe01c..9eab767e 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml @@ -12,6 +12,7 @@ import EasyApp.Gui.Globals as EaGlobals import EasyApp.Gui.Elements as EaElements import EasyApp.Gui.Charts as EaCharts +import Gui as Gui import Gui.Globals as Globals @@ -63,12 +64,78 @@ Rectangle { // Watch for changes in multi-experiment selection Connections { - target: Globals.BackendWrapper.activeBackend + target: Globals.BackendWrapper.activeBackend ?? null + enabled: target !== null function onMultiExperimentSelectionChanged() { analysisChartView.updateMultiExperimentSeries() } } + // Watch for plot mode changes (R(q)×q⁴ toggle) + Connections { + target: Globals.BackendWrapper + function onPlotModeChanged() { + console.debug("CombinedView Analysis: Plot mode changed, refreshing chart") + Globals.BackendWrapper.plottingRefreshAnalysis() + // Delay resetAxes to allow axis range properties to update first + combinedAnalysisResetAxesTimer.start() + } + function onChartAxesResetRequested() { + // Reset axes when model is loaded (e.g., from ORSO file) + combinedAnalysisResetAxesTimer.start() + } + function onSamplePageResetAxes() { + combinedAnalysisResetAxesTimer.start() + } + } + + Timer { + id: combinedAnalysisResetAxesTimer + interval: 75 + repeat: false + onTriggered: { + analysisChartView.resetAxes() + sldChart.chartView.resetAxes() + } + } + + // Background reference line series + LineSeries { + id: backgroundRefLine + axisX: analysisChartView.axisX + axisY: analysisChartView.axisY + useOpenGL: analysisChartView.useOpenGL + color: "#888888" + width: 1 + style: Qt.DashLine + visible: Globals.BackendWrapper.plottingBkgShown + } + + // Scale reference line series + LineSeries { + id: scaleRefLine + axisX: analysisChartView.axisX + axisY: analysisChartView.axisY + useOpenGL: analysisChartView.useOpenGL + color: "#666666" + width: 1 + style: Qt.DotLine + visible: Globals.BackendWrapper.plottingScaleShown + } + + // Update reference lines when visibility changes + Connections { + target: Globals.BackendWrapper.activeBackend?.plotting ?? null + enabled: target !== null + function onReferenceLineVisibilityChanged() { + analysisChartView.updateReferenceLines() + } + } + + function updateReferenceLines() { + Globals.BackendWrapper.updateRefLines(backgroundRefLine, scaleRefLine, true) + } + // Multi-experiment series management function updateMultiExperimentSeries() { // Always get the latest value from backend @@ -183,7 +250,7 @@ Rectangle { axisX.maxAfterReset: Globals.BackendWrapper.plottingAnalysisMaxX + xRange * 0.01 property double yRange: Globals.BackendWrapper.plottingAnalysisMaxY - Globals.BackendWrapper.plottingAnalysisMinY - axisY.title: "Log10 R(q)" + axisY.title: "Log10 " + Globals.BackendWrapper.plottingYAxisTitle axisY.min: Globals.BackendWrapper.plottingAnalysisMinY - yRange * 0.01 axisY.max: Globals.BackendWrapper.plottingAnalysisMaxY + yRange * 0.01 axisY.minAfterReset: Globals.BackendWrapper.plottingAnalysisMinY - yRange * 0.01 @@ -246,7 +313,7 @@ Rectangle { ToolTip.text: qsTr("Enable pan") onClicked: { analysisChartView.allowZoom = !analysisChartView.allowZoom - sldChartView.allowZoom = analysisChartView.allowZoom + sldChart.chartView.allowZoom = analysisChartView.allowZoom } } @@ -260,7 +327,7 @@ Rectangle { ToolTip.text: qsTr("Enable box zoom") onClicked: { analysisChartView.allowZoom = !analysisChartView.allowZoom - sldChartView.allowZoom = analysisChartView.allowZoom + sldChart.chartView.allowZoom = analysisChartView.allowZoom } } @@ -273,7 +340,7 @@ Rectangle { ToolTip.text: qsTr("Reset axes") onClicked: { analysisChartView.resetAxes() - sldChartView.resetAxes() + sldChart.chartView.resetAxes() } } } @@ -381,92 +448,26 @@ Rectangle { // Initialize multi-experiment support updateMultiExperimentSeries() + + // Initialize reference lines + updateReferenceLines() } } } // SLD Chart (1/3 height) - Rectangle { - id: sldContainer + Gui.SldChart { + id: sldChart + SplitView.fillHeight: true SplitView.preferredHeight: parent.height * 0.33 SplitView.minimumHeight: 80 - color: EaStyle.Colors.chartBackground - - EaCharts.QtCharts1dMeasVsCalc { - id: sldChartView - - anchors.fill: parent - anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 - - useOpenGL: EaGlobals.Vars.useOpenGL - - property double xRange: Globals.BackendWrapper.plottingSldMaxX - Globals.BackendWrapper.plottingSldMinX - axisX.title: "z (Å)" - axisX.min: Globals.BackendWrapper.plottingSldMinX - xRange * 0.01 - axisX.max: Globals.BackendWrapper.plottingSldMaxX + xRange * 0.01 - axisX.minAfterReset: Globals.BackendWrapper.plottingSldMinX - xRange * 0.01 - axisX.maxAfterReset: Globals.BackendWrapper.plottingSldMaxX + xRange * 0.01 - - property double yRange: Globals.BackendWrapper.plottingSldMaxY - Globals.BackendWrapper.plottingSldMinY - axisY.title: "SLD (10⁻⁶Å⁻²)" - axisY.min: Globals.BackendWrapper.plottingSldMinY - yRange * 0.01 - axisY.max: Globals.BackendWrapper.plottingSldMaxY + yRange * 0.01 - axisY.minAfterReset: Globals.BackendWrapper.plottingSldMinY - yRange * 0.01 - axisY.maxAfterReset: Globals.BackendWrapper.plottingSldMaxY + yRange * 0.01 - - calcSerie.onHovered: (point, state) => showMainTooltip(sldChartView, sldDataToolTip, point, state) - calcSerie.color: { - const models = Globals.BackendWrapper.sampleModels - const idx = Globals.BackendWrapper.sampleCurrentModelIndex - - if (models && idx >= 0 && idx < models.length) { - return models[idx].color - } - - return undefined - } - - // Legend - Rectangle { - visible: Globals.Variables.showLegendOnAnalysisPage - x: sldChartView.plotArea.x + sldChartView.plotArea.width - width - EaStyle.Sizes.fontPixelSize - y: sldChartView.plotArea.y + EaStyle.Sizes.fontPixelSize - width: childrenRect.width - height: childrenRect.height - - color: EaStyle.Colors.mainContentBackgroundHalfTransparent - border.color: EaStyle.Colors.chartGridLine + showLegend: Globals.Variables.showLegendOnAnalysisPage + onShowLegendChanged: Globals.Variables.showLegendOnAnalysisPage = showLegend - Column { - leftPadding: EaStyle.Sizes.fontPixelSize - rightPadding: EaStyle.Sizes.fontPixelSize - topPadding: EaStyle.Sizes.fontPixelSize * 0.5 - bottomPadding: EaStyle.Sizes.fontPixelSize * 0.5 - - EaElements.Label { - text: '━ SLD' - color: sldChartView.calcSerie.color - } - } - } - - EaElements.ToolTip { - id: sldDataToolTip - - arrowLength: 0 - textFormat: Text.RichText - } - - // Data is set in python backend (plotting_1d.py) - Component.onCompleted: { - Globals.References.pages.analysis.mainContent.sldView = sldChartView - Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', - 'sldSerie', - sldChartView.calcSerie) - Globals.BackendWrapper.plottingRefreshSLD() - } + Component.onCompleted: { + Globals.References.pages.analysis.mainContent.sldView = sldChart.chartView } } } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/SldView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/SldView.qml index d55ca44a..31399d53 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/SldView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/SldView.qml @@ -3,181 +3,20 @@ // © 2025 Contributors to the EasyReflectometry project import QtQuick -import QtQuick.Controls -import QtCharts - -import EasyApp.Gui.Style as EaStyle -import EasyApp.Gui.Globals as EaGlobals -import EasyApp.Gui.Elements as EaElements -import EasyApp.Gui.Charts as EaCharts +import Gui as Gui import Gui.Globals as Globals -Rectangle { - id: container - - color: EaStyle.Colors.chartBackground - - EaCharts.QtCharts1dMeasVsCalc { - id: chartView - - anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 - - useOpenGL: EaGlobals.Vars.useOpenGL - - property double xRange: Globals.BackendWrapper.plottingSldMaxX - Globals.BackendWrapper.plottingSldMinX - axisX.title: "z (Å)" - axisX.min: Globals.BackendWrapper.plottingSldMinX - xRange * 0.01 - axisX.max: Globals.BackendWrapper.plottingSldMaxX + xRange * 0.01 - axisX.minAfterReset: Globals.BackendWrapper.plottingSldMinX - xRange * 0.01 - axisX.maxAfterReset: Globals.BackendWrapper.plottingSldMaxX + xRange * 0.01 - - property double yRange: Globals.BackendWrapper.plottingSldMaxY - Globals.BackendWrapper.plottingSldMinY - axisY.title: "SLD (10⁻⁶Å⁻²)" - axisY.min: Globals.BackendWrapper.plottingSldMinY - yRange * 0.01 - axisY.max: Globals.BackendWrapper.plottingSldMaxY + yRange * 0.01 - axisY.minAfterReset: Globals.BackendWrapper.plottingSldMinY - yRange * 0.01 - axisY.maxAfterReset: Globals.BackendWrapper.plottingSldMaxY + yRange * 0.01 - - calcSerie.onHovered: (point, state) => showMainTooltip(chartView, point, state) - calcSerie.color: { - const models = Globals.BackendWrapper.sampleModels - const idx = Globals.BackendWrapper.sampleCurrentModelIndex - - if (models && idx >= 0 && idx < models.length) { - return models[idx].color - } - - return undefined - } - - - // Tool buttons - Row { - id: toolButtons - - x: chartView.plotArea.x + chartView.plotArea.width - width - y: chartView.plotArea.y - height - EaStyle.Sizes.fontPixelSize - - spacing: 0.25 * EaStyle.Sizes.fontPixelSize - - EaElements.TabButton { - checked: Globals.Variables.showLegendOnSamplePage - autoExclusive: false - height: EaStyle.Sizes.toolButtonHeight - width: EaStyle.Sizes.toolButtonHeight - borderColor: EaStyle.Colors.chartAxis - fontIcon: "align-left" - ToolTip.text: Globals.Variables.showLegendOnSamplePage ? - qsTr("Hide legend") : - qsTr("Show legend") - onClicked: Globals.Variables.showLegendOnSamplePage = checked - } +Gui.SldChart { + id: sldChart - EaElements.TabButton { - checked: chartView.allowHover - autoExclusive: false - height: EaStyle.Sizes.toolButtonHeight - width: EaStyle.Sizes.toolButtonHeight - borderColor: EaStyle.Colors.chartAxis - fontIcon: "comment-alt" - ToolTip.text: qsTr("Show coordinates tooltip on hover") - onClicked: chartView.allowHover = !chartView.allowHover - } - - Item { height: 1; width: 0.5 * EaStyle.Sizes.fontPixelSize } // spacer - - EaElements.TabButton { - checked: !chartView.allowZoom - autoExclusive: false - height: EaStyle.Sizes.toolButtonHeight - width: EaStyle.Sizes.toolButtonHeight - borderColor: EaStyle.Colors.chartAxis - fontIcon: "arrows-alt" - ToolTip.text: qsTr("Enable pan") - onClicked: chartView.allowZoom = !chartView.allowZoom - } - - EaElements.TabButton { - checked: chartView.allowZoom - autoExclusive: false - height: EaStyle.Sizes.toolButtonHeight - width: EaStyle.Sizes.toolButtonHeight - borderColor: EaStyle.Colors.chartAxis - fontIcon: "expand" - ToolTip.text: qsTr("Enable box zoom") - onClicked: chartView.allowZoom = !chartView.allowZoom - } - - EaElements.TabButton { - checkable: false - height: EaStyle.Sizes.toolButtonHeight - width: EaStyle.Sizes.toolButtonHeight - borderColor: EaStyle.Colors.chartAxis - fontIcon: "backspace" - ToolTip.text: qsTr("Reset axes") - onClicked: chartView.resetAxes() - } - - } - // Tool buttons - - // Legend - Rectangle { - visible: Globals.Variables.showLegendOnExperimentPage - - x: chartView.plotArea.x + chartView.plotArea.width - width - EaStyle.Sizes.fontPixelSize - y: chartView.plotArea.y + EaStyle.Sizes.fontPixelSize - width: childrenRect.width - height: childrenRect.height - - color: EaStyle.Colors.mainContentBackgroundHalfTransparent - border.color: EaStyle.Colors.chartGridLine - - Column { - leftPadding: EaStyle.Sizes.fontPixelSize - rightPadding: EaStyle.Sizes.fontPixelSize - topPadding: EaStyle.Sizes.fontPixelSize * 0.5 - bottomPadding: EaStyle.Sizes.fontPixelSize * 0.5 - - EaElements.Label { - text: '━ SLD' - color: chartView.calcSerie.color - } - } - } - // Legend - - EaElements.ToolTip { - id: dataToolTip - - arrowLength: 0 - textFormat: Text.RichText - } - - // Data is set in python backend (plotting_1d.py) - Component.onCompleted: { - Globals.References.pages.analysis.mainContent.sldView = chartView - Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', - 'sldSerie', - chartView.calcSerie) - Globals.BackendWrapper.plottingRefreshSLD() - } - } + showLegend: Globals.Variables.showLegendOnAnalysisPage - // Logic + onShowLegendChanged: Globals.Variables.showLegendOnAnalysisPage = showLegend - function showMainTooltip(chart, point, state) { - if (!chartView.allowHover) { - return - } - const pos = chart.mapToPosition(Qt.point(point.x, point.y)) - dataToolTip.x = pos.x - dataToolTip.y = pos.y - dataToolTip.text = `

x: ${point.x.toFixed(3)}y: ${point.y.toFixed(3)}

` - dataToolTip.parent = chart - dataToolTip.visible = state + Component.onCompleted: { + Globals.References.pages.analysis.mainContent.sldView = sldChart.chartView } } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/PlotControl.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/PlotControl.qml new file mode 100644 index 00000000..34de6956 --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/PlotControl.qml @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2025 Contributors to the EasyReflectometry project + +import QtQuick + +import EasyApp.Gui.Style as EaStyle +import EasyApp.Gui.Elements as EaElements + +import Gui as Gui + +EaElements.GroupBox { + title: qsTr("Plot control") + collapsed: true + + Gui.PlotControlRefLines {} +} + diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Layout.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Layout.qml index 2571f4d9..deeee520 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Layout.qml @@ -17,4 +17,6 @@ EaComponents.SideBarColumn { Groups.Calculator {} Groups.Minimizer {} + + Groups.PlotControl {} } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml index 405e5291..10e39a35 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml @@ -261,7 +261,7 @@ EaElements.GroupBox { } EaComponents.TableViewLabel { - text: EaLogic.Utils.toDefaultPrecision(Globals.BackendWrapper.analysisFitableParameters[index].error) + text: formatError(Globals.BackendWrapper.analysisFitableParameters[index].error) color: (Globals.BackendWrapper.analysisFitableParameters[index].independent !== undefined ? Globals.BackendWrapper.analysisFitableParameters[index].independent : true) ? EaStyle.Colors.themeForeground : EaStyle.Colors.themeForegroundDisabled @@ -436,6 +436,13 @@ EaElements.GroupBox { fittables.parameterSlider.to = EaLogic.Utils.toDefaultPrecision(to) } + function formatError(value) { + if (value === undefined || value === 0 || isNaN(value)) return '' + var s = Number(value.toPrecision(2)).toString() + if (s.length <= 6) return s + return value.toExponential(1) + } + function allFittablesSelected() { const params = backend.analysisFitableParameters if (!params || !params.length) { diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Layout.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Layout.qml index f56ede8a..7c6f1c87 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Layout.qml @@ -22,13 +22,13 @@ EaComponents.ContentPage { sideBar: EaComponents.SideBar { tabs: [ - EaElements.TabButton { text: qsTr('Basic controls') } -// EaElements.TabButton { text: qsTr('Advanced controls') } + EaElements.TabButton { text: qsTr('Basic controls') }, + EaElements.TabButton { text: qsTr('Advanced controls') } ] items: [ - Loader { source: 'Sidebar/Basic/Layout.qml' } - // Loader { source: 'Sidebar/Advanced/Layout.qml' } + Loader { source: 'Sidebar/Basic/Layout.qml' }, + Loader { source: 'Sidebar/Advanced/Layout.qml' } ] continueButton.text: qsTr('Continue') diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml index 905dc7c9..070e809d 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml @@ -32,6 +32,43 @@ Rectangle { useOpenGL: EaGlobals.Vars.useOpenGL + // Background reference line series + LineSeries { + id: backgroundRefLine + axisX: chartView.axisX + axisY: chartView.axisY + useOpenGL: chartView.useOpenGL + color: "#888888" + width: 1 + style: Qt.DashLine + visible: Globals.BackendWrapper.plottingBkgShown + } + + // Scale reference line series + LineSeries { + id: scaleRefLine + axisX: chartView.axisX + axisY: chartView.axisY + useOpenGL: chartView.useOpenGL + color: "#666666" + width: 1 + style: Qt.DotLine + visible: Globals.BackendWrapper.plottingScaleShown + } + + // Update reference lines when visibility changes + Connections { + target: Globals.BackendWrapper.activeBackend?.plotting ?? null + enabled: target !== null + function onReferenceLineVisibilityChanged() { + chartView.updateReferenceLines() + } + } + + function updateReferenceLines() { + Globals.BackendWrapper.updateRefLines(backgroundRefLine, scaleRefLine, false) + } + // Multi-experiment support property var multiExperimentSeries: [] property bool isMultiExperimentMode: { @@ -137,7 +174,8 @@ Rectangle { // Watch for changes in multi-experiment selection Connections { - target: Globals.BackendWrapper.activeBackend + target: Globals.BackendWrapper.activeBackend ?? null + enabled: target !== null function onMultiExperimentSelectionChanged() { // Update series when selection changes // The function will handle showing/hiding appropriate series @@ -145,6 +183,28 @@ Rectangle { chartView.updateMultiExperimentSeries() } } + + // Watch for plot mode changes (R(q)×q⁴ toggle) + Connections { + target: Globals.BackendWrapper + function onPlotModeChanged() { + console.debug("ExperimentView: Plot mode changed, refreshing chart") + Globals.BackendWrapper.plottingRefreshExperiment() + // Delay resetAxes to allow axis range properties to update first + experimentResetAxesTimer.start() + } + function onChartAxesResetRequested() { + // Reset axes when model is loaded (e.g., from ORSO file) + experimentResetAxesTimer.start() + } + } + + Timer { + id: experimentResetAxesTimer + interval: 75 + repeat: false + onTriggered: chartView.resetAxes() + } property double xRange: Globals.BackendWrapper.plottingExperimentMaxX - Globals.BackendWrapper.plottingExperimentMinX axisX.title: "q (Å⁻¹)" @@ -154,7 +214,7 @@ Rectangle { axisX.maxAfterReset: Globals.BackendWrapper.plottingExperimentMaxX + xRange * 0.01 property double yRange: Globals.BackendWrapper.plottingExperimentMaxY - Globals.BackendWrapper.plottingExperimentMinY - axisY.title: "Log10 R(q)" + axisY.title: "Log10 " + Globals.BackendWrapper.plottingYAxisTitle axisY.min: Globals.BackendWrapper.plottingExperimentMinY - yRange * 0.01 axisY.max: Globals.BackendWrapper.plottingExperimentMaxY + yRange * 0.01 axisY.minAfterReset: Globals.BackendWrapper.plottingExperimentMinY - yRange * 0.01 @@ -491,6 +551,9 @@ Rectangle { // Initialize multi-experiment support // console.log("ExperimentView initialized - checking multi-experiment mode...") updateMultiExperimentSeries() + + // Initialize reference lines + updateReferenceLines() } // Update series when chart becomes visible @@ -498,6 +561,9 @@ Rectangle { if (visible && isMultiExperimentMode) { updateMultiExperimentSeries() } + if (visible) { + updateReferenceLines() + } } } diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Groups/PlotControl.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Groups/PlotControl.qml new file mode 100644 index 00000000..ccf27b4d --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Groups/PlotControl.qml @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2025 Contributors to the EasyReflectometry project + +import QtQuick +import QtQuick.Controls + +import EasyApp.Gui.Style as EaStyle +import EasyApp.Gui.Elements as EaElements + +import Gui.Globals as Globals + +EaElements.GroupBox { + title: qsTr("Plot control") + collapsed: true + + Column { + spacing: EaStyle.Sizes.fontPixelSize * 0.5 + + EaElements.CheckBox { + topPadding: 0 + checked: Globals.BackendWrapper.plottingPlotRQ4 + text: qsTr("Plot R(q)×q⁴") + ToolTip.text: qsTr("Checking this box will plot R(q) multiplied by q⁴") + onToggled: { + Globals.BackendWrapper.plottingTogglePlotRQ4() + } + } + + EaElements.CheckBox { + topPadding: 0 + checked: Globals.Variables.logarithmicQAxis + text: qsTr("Logarithmic q-axis") + ToolTip.text: qsTr("Checking this box will make the q-axis logarithmic") + onToggled: { + Globals.Variables.logarithmicQAxis = checked + } + } + + EaElements.CheckBox { + topPadding: 0 + checked: Globals.BackendWrapper.plottingScaleShown + text: qsTr("Show scale line") + ToolTip.text: qsTr("Checking this box will show the scale reference line on the plot") + onToggled: { + Globals.BackendWrapper.plottingFlipScaleShown() + } + } + + EaElements.CheckBox { + topPadding: 0 + checked: Globals.BackendWrapper.plottingBkgShown + text: qsTr("Show background line") + ToolTip.text: qsTr("Checking this box will show the background reference line on the plot") + onToggled: { + Globals.BackendWrapper.plottingFlipBkgShown() + } + } + } +} + diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Layout.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Layout.qml new file mode 100644 index 00000000..600ca675 --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Layout.qml @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2025 Contributors to the EasyReflectometry project + +import QtQuick + +import EasyApp.Gui.Elements as EaElements +import EasyApp.Gui.Components as EaComponents + +import "./Groups" as Groups + + +EaComponents.SideBarColumn { + + Groups.PlotControl {} + +} diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml index fb2f6d4e..3801d2e6 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml @@ -12,6 +12,7 @@ import EasyApp.Gui.Globals as EaGlobals import EasyApp.Gui.Elements as EaElements import EasyApp.Gui.Charts as EaCharts +import Gui as Gui import Gui.Globals as Globals @@ -25,7 +26,6 @@ Rectangle { // Store dynamically created series property var sampleSeries: [] - property var sldSeries: [] SplitView { anchors.fill: parent @@ -101,7 +101,7 @@ Rectangle { ValueAxis { id: sampleAxisY - titleText: "Log10 R(q)" + titleText: "Log10 " + Globals.BackendWrapper.plottingYAxisTitle // min/max set imperatively to avoid binding reset during zoom property double minAfterReset: Globals.BackendWrapper.plottingSampleMinY - sampleChartView.yRange * 0.01 property double maxAfterReset: Globals.BackendWrapper.plottingSampleMaxY + sampleChartView.yRange * 0.01 @@ -180,7 +180,7 @@ Rectangle { ToolTip.text: qsTr("Enable pan") onClicked: { sampleChartView.allowZoom = !checked - sldChartView.allowZoom = !checked + sldChart.chartView.allowZoom = !checked } } @@ -194,7 +194,7 @@ Rectangle { ToolTip.text: qsTr("Enable box zoom") onClicked: { sampleChartView.allowZoom = checked - sldChartView.allowZoom = checked + sldChart.chartView.allowZoom = checked } } @@ -207,7 +207,7 @@ Rectangle { ToolTip.text: qsTr("Reset axes") onClicked: { sampleChartView.resetAxes() - sldChartView.resetAxes() + sldChart.chartView.resetAxes() } } } @@ -360,222 +360,19 @@ Rectangle { } // SLD Chart (1/3 height) - Rectangle { - id: sldContainer + Gui.SldChart { + id: sldChart + SplitView.fillHeight: true SplitView.preferredHeight: parent.height * 0.33 SplitView.minimumHeight: 80 - color: EaStyle.Colors.chartBackground - - ChartView { - id: sldChartView - - anchors.fill: parent - anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 - anchors.margins: -12 - - antialiasing: true - legend.visible: false - backgroundRoundness: 0 - backgroundColor: EaStyle.Colors.chartBackground - plotAreaColor: EaStyle.Colors.chartPlotAreaBackground - - property bool allowZoom: true - property bool allowHover: true - - property double xRange: Globals.BackendWrapper.plottingSldMaxX - Globals.BackendWrapper.plottingSldMinX - - // Reverse axis logic - property bool reverseZAxis: Globals.Variables.reverseSldZAxis - - ValueAxis { - id: sldAxisX - titleText: "z (Å)" - // min/max set imperatively to avoid binding reset during zoom - property double minAfterReset: Globals.BackendWrapper.plottingSldMinX - sldChartView.xRange * 0.01 - property double maxAfterReset: Globals.BackendWrapper.plottingSldMaxX + sldChartView.xRange * 0.01 - reverse: sldChartView.reverseZAxis - color: EaStyle.Colors.chartAxis - gridLineColor: EaStyle.Colors.chartGridLine - minorGridLineColor: EaStyle.Colors.chartMinorGridLine - labelsColor: EaStyle.Colors.chartLabels - titleBrush: EaStyle.Colors.chartLabels - Component.onCompleted: { - min = minAfterReset - max = maxAfterReset - } - } - - property double yRange: Globals.BackendWrapper.plottingSldMaxY - Globals.BackendWrapper.plottingSldMinY - - ValueAxis { - id: sldAxisY - titleText: "SLD (10⁻⁶Å⁻²)" - // min/max set imperatively to avoid binding reset during zoom - property double minAfterReset: Globals.BackendWrapper.plottingSldMinY - sldChartView.yRange * 0.01 - property double maxAfterReset: Globals.BackendWrapper.plottingSldMaxY + sldChartView.yRange * 0.01 - color: EaStyle.Colors.chartAxis - gridLineColor: EaStyle.Colors.chartGridLine - minorGridLineColor: EaStyle.Colors.chartMinorGridLine - labelsColor: EaStyle.Colors.chartLabels - titleBrush: EaStyle.Colors.chartLabels - Component.onCompleted: { - min = minAfterReset - max = maxAfterReset - } - } - - function resetAxes() { - sldAxisX.min = sldAxisX.minAfterReset - sldAxisX.max = sldAxisX.maxAfterReset - sldAxisY.min = sldAxisY.minAfterReset - sldAxisY.max = sldAxisY.maxAfterReset - } - - // Legend showing all models - Rectangle { - visible: Globals.Variables.showLegendOnSamplePage - - x: sldChartView.plotArea.x + sldChartView.plotArea.width - width - EaStyle.Sizes.fontPixelSize - y: sldChartView.plotArea.y + EaStyle.Sizes.fontPixelSize - width: childrenRect.width - height: childrenRect.height - - color: EaStyle.Colors.mainContentBackgroundHalfTransparent - border.color: EaStyle.Colors.chartGridLine - - Column { - leftPadding: EaStyle.Sizes.fontPixelSize - rightPadding: EaStyle.Sizes.fontPixelSize - topPadding: EaStyle.Sizes.fontPixelSize * 0.5 - bottomPadding: EaStyle.Sizes.fontPixelSize * 0.5 - - Repeater { - model: container.modelCount - EaElements.Label { - text: '━ SLD ' + Globals.BackendWrapper.sampleModels[index].label - color: Globals.BackendWrapper.sampleModels[index].color - } - } - } - } - - EaElements.ToolTip { - id: sldDataToolTip - - arrowLength: 0 - textFormat: Text.RichText - } - - // Zoom rectangle - Rectangle { - id: sldRecZoom - - property int xScaleZoom: 0 - property int yScaleZoom: 0 - - visible: false - transform: Scale { - origin.x: 0 - origin.y: 0 - xScale: sldRecZoom.xScaleZoom - yScale: sldRecZoom.yScaleZoom - } - border.color: EaStyle.Colors.appBorder - border.width: 1 - opacity: 0.9 - color: "transparent" - - Rectangle { - anchors.fill: parent - opacity: 0.5 - color: sldRecZoom.border.color - } - } - - // Zoom with left mouse button - MouseArea { - id: sldZoomMouseArea - - enabled: sldChartView.allowZoom - anchors.fill: sldChartView - acceptedButtons: Qt.LeftButton - onPressed: { - sldRecZoom.x = mouseX - sldRecZoom.y = mouseY - sldRecZoom.visible = true - } - onMouseXChanged: { - if (mouseX > sldRecZoom.x) { - sldRecZoom.xScaleZoom = 1 - sldRecZoom.width = Math.min(mouseX, sldChartView.width) - sldRecZoom.x - } else { - sldRecZoom.xScaleZoom = -1 - sldRecZoom.width = sldRecZoom.x - Math.max(mouseX, 0) - } - } - onMouseYChanged: { - if (mouseY > sldRecZoom.y) { - sldRecZoom.yScaleZoom = 1 - sldRecZoom.height = Math.min(mouseY, sldChartView.height) - sldRecZoom.y - } else { - sldRecZoom.yScaleZoom = -1 - sldRecZoom.height = sldRecZoom.y - Math.max(mouseY, 0) - } - } - onReleased: { - const x = Math.min(sldRecZoom.x, mouseX) - sldChartView.anchors.leftMargin - const y = Math.min(sldRecZoom.y, mouseY) - sldChartView.anchors.topMargin - const width = sldRecZoom.width - const height = sldRecZoom.height - sldChartView.zoomIn(Qt.rect(x, y, width, height)) - sldRecZoom.visible = false - } - } - - // Pan with left mouse button - MouseArea { - property real pressedX - property real pressedY - property int threshold: 1 - - enabled: !sldZoomMouseArea.enabled - anchors.fill: sldChartView - acceptedButtons: Qt.LeftButton - onPressed: { - pressedX = mouseX - pressedY = mouseY - } - onMouseXChanged: Qt.callLater(update) - onMouseYChanged: Qt.callLater(update) - - function update() { - const dx = mouseX - pressedX - const dy = mouseY - pressedY - pressedX = mouseX - pressedY = mouseY - - if (dx > threshold) - sldChartView.scrollLeft(dx) - else if (dx < -threshold) - sldChartView.scrollRight(-dx) - if (dy > threshold) - sldChartView.scrollUp(dy) - else if (dy < -threshold) - sldChartView.scrollDown(-dy) - } - } - // Reset axes with right mouse button - MouseArea { - anchors.fill: sldChartView - acceptedButtons: Qt.RightButton - onClicked: sldChartView.resetAxes() - } + showLegend: Globals.Variables.showLegendOnSamplePage + reverseZAxis: Globals.Variables.reverseSldZAxis + onShowLegendChanged: Globals.Variables.showLegendOnSamplePage = showLegend - Component.onCompleted: { - Globals.References.pages.sample.mainContent.sldView = sldChartView - } + Component.onCompleted: { + Globals.References.pages.sample.mainContent.sldView = sldChart.chartView } } } @@ -591,6 +388,36 @@ Rectangle { function onSamplePageDataChanged() { refreshAllCharts() } + function onSamplePageResetAxes() { + sampleCombinedResetAxesTimer.start() + sldCombinedResetAxesTimer.start() + } + function onPlotModeChanged() { + refreshAllCharts() + // Delay resetAxes to allow axis range properties to update first + sampleCombinedResetAxesTimer.start() + } + function onChartAxesResetRequested() { + // Reset axes when model is loaded (e.g., from ORSO file) + sampleCombinedResetAxesTimer.start() + } + } + + Timer { + id: sampleCombinedResetAxesTimer + interval: 75 + repeat: false + onTriggered: { + sampleChartView.resetAxes() + sldChart.chartView.resetAxes() + } + } + + Timer { + id: sldCombinedResetAxesTimer + interval: 75 + repeat: false + onTriggered: sldChart.chartView.resetAxes() } Component.onCompleted: { @@ -607,14 +434,6 @@ Rectangle { } sampleSeries = [] - // Remove old SLD series - for (let j = 0; j < sldSeries.length; j++) { - if (sldSeries[j]) { - sldChartView.removeSeries(sldSeries[j]) - } - } - sldSeries = [] - // Determine which x-axis to use for sample chart based on log setting const sampleXAxisToUse = sampleChartView.useLogQAxis ? sampleAxisXLog : sampleAxisX @@ -629,15 +448,6 @@ Rectangle { // Connect hovered signal for tooltip sampleLine.hovered.connect((point, state) => showMainTooltip(sampleChartView, sampleDataToolTip, point, state)) sampleSeries.push(sampleLine) - - // Create SLD series - const sldLine = sldChartView.createSeries(ChartView.SeriesTypeLine, "SLD " + models[k].label, sldAxisX, sldAxisY) - sldLine.color = models[k].color - sldLine.width = 2 - sldLine.useOpenGL = EaGlobals.Vars.useOpenGL - // Connect hovered signal for tooltip - sldLine.hovered.connect((point, state) => showMainTooltip(sldChartView, sldDataToolTip, point, state)) - sldSeries.push(sldLine) } // Populate data @@ -659,18 +469,6 @@ Rectangle { } } } - - // Refresh SLD series - for (let j = 0; j < sldSeries.length && j < models.length; j++) { - const series = sldSeries[j] - if (series) { - series.clear() - const points = Globals.BackendWrapper.plottingGetSldDataPointsForModel(j) - for (let p = 0; p < points.length; p++) { - series.append(points[p].x, points[p].y) - } - } - } } // Logic diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml index 01645bfa..38c821cd 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml @@ -87,7 +87,7 @@ Rectangle { ValueAxis { id: axisY - titleText: "Log10 R(q)" + titleText: "Log10 " + Globals.BackendWrapper.plottingYAxisTitle // min/max set imperatively to avoid binding reset during zoom property double minAfterReset: Globals.BackendWrapper.plottingSampleMinY - chartView.yRange * 0.01 property double maxAfterReset: Globals.BackendWrapper.plottingSampleMaxY + chartView.yRange * 0.01 @@ -347,6 +347,25 @@ Rectangle { function onSamplePageDataChanged() { refreshAllCharts() } + function onSamplePageResetAxes() { + sampleResetAxesTimer.start() + } + function onPlotModeChanged() { + refreshAllCharts() + // Delay resetAxes to allow axis range properties to update first + sampleResetAxesTimer.start() + } + function onChartAxesResetRequested() { + // Reset axes when model is loaded (e.g., from ORSO file) + sampleResetAxesTimer.start() + } + } + + Timer { + id: sampleResetAxesTimer + interval: 75 + repeat: false + onTriggered: chartView.resetAxes() } Component.onCompleted: { diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml index e27d98f5..d8e0fe08 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml @@ -3,375 +3,21 @@ // © 2025 Contributors to the EasyReflectometry project import QtQuick -import QtQuick.Controls -import QtCharts - -import EasyApp.Gui.Style as EaStyle -import EasyApp.Gui.Globals as EaGlobals -import EasyApp.Gui.Elements as EaElements -import EasyApp.Gui.Charts as EaCharts +import Gui as Gui import Gui.Globals as Globals -Rectangle { - id: container - - color: EaStyle.Colors.chartBackground - - // Track model count changes to refresh charts - property int modelCount: Globals.BackendWrapper.sampleModels.length - - // Store dynamically created series - property var sldSeries: [] - - ChartView { - id: chartView - - anchors.fill: parent - anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 - anchors.margins: -12 - - antialiasing: true - legend.visible: false - backgroundRoundness: 0 - backgroundColor: EaStyle.Colors.chartBackground - plotAreaColor: EaStyle.Colors.chartPlotAreaBackground - - property bool allowZoom: true - property bool allowHover: true - - property double xRange: Globals.BackendWrapper.plottingSldMaxX - Globals.BackendWrapper.plottingSldMinX - - // Reverse axis logic - property bool reverseZAxis: Globals.Variables.reverseSldZAxis - - ValueAxis { - id: axisX - titleText: "z (Å)" - // min/max set imperatively to avoid binding reset during zoom - property double minAfterReset: Globals.BackendWrapper.plottingSldMinX - chartView.xRange * 0.01 - property double maxAfterReset: Globals.BackendWrapper.plottingSldMaxX + chartView.xRange * 0.01 - reverse: chartView.reverseZAxis - color: EaStyle.Colors.chartAxis - gridLineColor: EaStyle.Colors.chartGridLine - minorGridLineColor: EaStyle.Colors.chartMinorGridLine - labelsColor: EaStyle.Colors.chartLabels - titleBrush: EaStyle.Colors.chartLabels - Component.onCompleted: { - min = minAfterReset - max = maxAfterReset - } - } - - property double yRange: Globals.BackendWrapper.plottingSldMaxY - Globals.BackendWrapper.plottingSldMinY - - ValueAxis { - id: axisY - titleText: "SLD (10⁻⁶ Å⁻²)" - // min/max set imperatively to avoid binding reset during zoom - property double minAfterReset: Globals.BackendWrapper.plottingSldMinY - chartView.yRange * 0.01 - property double maxAfterReset: Globals.BackendWrapper.plottingSldMaxY + chartView.yRange * 0.01 - color: EaStyle.Colors.chartAxis - gridLineColor: EaStyle.Colors.chartGridLine - minorGridLineColor: EaStyle.Colors.chartMinorGridLine - labelsColor: EaStyle.Colors.chartLabels - titleBrush: EaStyle.Colors.chartLabels - Component.onCompleted: { - min = minAfterReset - max = maxAfterReset - } - } - - function resetAxes() { - axisX.min = axisX.minAfterReset - axisX.max = axisX.maxAfterReset - axisY.min = axisY.minAfterReset - axisY.max = axisY.maxAfterReset - } - - // Tool buttons - Row { - id: toolButtons - z: 1 // Keep buttons above MouseAreas - - x: chartView.plotArea.x + chartView.plotArea.width - width - y: chartView.plotArea.y - height - EaStyle.Sizes.fontPixelSize - - spacing: 0.25 * EaStyle.Sizes.fontPixelSize - - EaElements.TabButton { - checked: Globals.Variables.showLegendOnSamplePage - autoExclusive: false - height: EaStyle.Sizes.toolButtonHeight - width: EaStyle.Sizes.toolButtonHeight - borderColor: EaStyle.Colors.chartAxis - fontIcon: "align-left" - ToolTip.text: Globals.Variables.showLegendOnSamplePage ? - qsTr("Hide legend") : - qsTr("Show legend") - onClicked: Globals.Variables.showLegendOnSamplePage = checked - } - - EaElements.TabButton { - checked: chartView.allowHover - autoExclusive: false - height: EaStyle.Sizes.toolButtonHeight - width: EaStyle.Sizes.toolButtonHeight - borderColor: EaStyle.Colors.chartAxis - fontIcon: "comment-alt" - ToolTip.text: qsTr("Show coordinates tooltip on hover") - onClicked: chartView.allowHover = checked - } - - Item { height: 1; width: 0.5 * EaStyle.Sizes.fontPixelSize } // spacer - - EaElements.TabButton { - checked: !chartView.allowZoom - autoExclusive: false - height: EaStyle.Sizes.toolButtonHeight - width: EaStyle.Sizes.toolButtonHeight - borderColor: EaStyle.Colors.chartAxis - fontIcon: "arrows-alt" - ToolTip.text: qsTr("Enable pan") - onClicked: chartView.allowZoom = !checked - } - - EaElements.TabButton { - checked: chartView.allowZoom - autoExclusive: false - height: EaStyle.Sizes.toolButtonHeight - width: EaStyle.Sizes.toolButtonHeight - borderColor: EaStyle.Colors.chartAxis - fontIcon: "expand" - ToolTip.text: qsTr("Enable box zoom") - onClicked: chartView.allowZoom = checked - } - - EaElements.TabButton { - checkable: false - height: EaStyle.Sizes.toolButtonHeight - width: EaStyle.Sizes.toolButtonHeight - borderColor: EaStyle.Colors.chartAxis - fontIcon: "backspace" - ToolTip.text: qsTr("Reset axes") - onClicked: chartView.resetAxes() - } - - } - - // Legend showing all models - Rectangle { - visible: Globals.Variables.showLegendOnSamplePage - - x: chartView.plotArea.x + chartView.plotArea.width - width - EaStyle.Sizes.fontPixelSize - y: chartView.plotArea.y + EaStyle.Sizes.fontPixelSize - width: childrenRect.width - height: childrenRect.height - - color: EaStyle.Colors.mainContentBackgroundHalfTransparent - border.color: EaStyle.Colors.chartGridLine +Gui.SldChart { + id: sldChart - Column { - leftPadding: EaStyle.Sizes.fontPixelSize - rightPadding: EaStyle.Sizes.fontPixelSize - topPadding: EaStyle.Sizes.fontPixelSize * 0.5 - bottomPadding: EaStyle.Sizes.fontPixelSize * 0.5 + showLegend: Globals.Variables.showLegendOnSamplePage + reverseZAxis: Globals.Variables.reverseSldZAxis - Repeater { - model: container.modelCount - EaElements.Label { - text: '━ SLD ' + Globals.BackendWrapper.sampleModels[index].label - color: Globals.BackendWrapper.sampleModels[index].color - } - } - } - } - - EaElements.ToolTip { - id: dataToolTip - - arrowLength: 0 - textFormat: Text.RichText - } - - // Zoom rectangle - Rectangle { - id: recZoom - - property int xScaleZoom: 0 - property int yScaleZoom: 0 - - visible: false - transform: Scale { - origin.x: 0 - origin.y: 0 - xScale: recZoom.xScaleZoom - yScale: recZoom.yScaleZoom - } - border.color: EaStyle.Colors.appBorder - border.width: 1 - opacity: 0.9 - color: "transparent" - - Rectangle { - anchors.fill: parent - opacity: 0.5 - color: recZoom.border.color - } - } - - // Zoom with left mouse button - MouseArea { - id: zoomMouseArea - - enabled: chartView.allowZoom - anchors.fill: chartView - acceptedButtons: Qt.LeftButton - onPressed: { - recZoom.x = mouseX - recZoom.y = mouseY - recZoom.visible = true - } - onMouseXChanged: { - if (mouseX > recZoom.x) { - recZoom.xScaleZoom = 1 - recZoom.width = Math.min(mouseX, chartView.width) - recZoom.x - } else { - recZoom.xScaleZoom = -1 - recZoom.width = recZoom.x - Math.max(mouseX, 0) - } - } - onMouseYChanged: { - if (mouseY > recZoom.y) { - recZoom.yScaleZoom = 1 - recZoom.height = Math.min(mouseY, chartView.height) - recZoom.y - } else { - recZoom.yScaleZoom = -1 - recZoom.height = recZoom.y - Math.max(mouseY, 0) - } - } - onReleased: { - const x = Math.min(recZoom.x, mouseX) - chartView.anchors.leftMargin - const y = Math.min(recZoom.y, mouseY) - chartView.anchors.topMargin - const width = recZoom.width - const height = recZoom.height - chartView.zoomIn(Qt.rect(x, y, width, height)) - recZoom.visible = false - } - } - - // Pan with left mouse button - MouseArea { - property real pressedX - property real pressedY - property int threshold: 1 - - enabled: !zoomMouseArea.enabled - anchors.fill: chartView - acceptedButtons: Qt.LeftButton - onPressed: { - pressedX = mouseX - pressedY = mouseY - } - onMouseXChanged: Qt.callLater(update) - onMouseYChanged: Qt.callLater(update) - - function update() { - const dx = mouseX - pressedX - const dy = mouseY - pressedY - pressedX = mouseX - pressedY = mouseY - - if (dx > threshold) - chartView.scrollLeft(dx) - else if (dx < -threshold) - chartView.scrollRight(-dx) - if (dy > threshold) - chartView.scrollUp(dy) - else if (dy < -threshold) - chartView.scrollDown(-dy) - } - } - - // Reset axes with right mouse button - MouseArea { - anchors.fill: chartView - acceptedButtons: Qt.RightButton - onClicked: chartView.resetAxes() - } - - Component.onCompleted: { - Globals.References.pages.sample.mainContent.sldView = chartView - } - } - - // Create series dynamically when model count changes - onModelCountChanged: { - Qt.callLater(recreateAllSeries) - } - - // Refresh all chart series when data changes - Connections { - target: Globals.BackendWrapper - function onSamplePageDataChanged() { - refreshAllCharts() - } - } + onShowLegendChanged: Globals.Variables.showLegendOnSamplePage = showLegend Component.onCompleted: { - Qt.callLater(recreateAllSeries) - } - - function recreateAllSeries() { - // Remove old series - for (let i = 0; i < sldSeries.length; i++) { - if (sldSeries[i]) { - chartView.removeSeries(sldSeries[i]) - } - } - sldSeries = [] - - // Create new series for each model - const models = Globals.BackendWrapper.sampleModels - for (let k = 0; k < models.length; k++) { - const line = chartView.createSeries(ChartView.SeriesTypeLine, models[k].label, axisX, axisY) - line.color = models[k].color - line.width = 2 - line.useOpenGL = EaGlobals.Vars.useOpenGL - // Connect hovered signal for tooltip - line.hovered.connect((point, state) => showMainTooltip(chartView, point, state)) - sldSeries.push(line) - } - - refreshAllCharts() - } - - function refreshAllCharts() { - const models = Globals.BackendWrapper.sampleModels - for (let i = 0; i < sldSeries.length && i < models.length; i++) { - const series = sldSeries[i] - if (series) { - series.clear() - const points = Globals.BackendWrapper.plottingGetSldDataPointsForModel(i) - for (let p = 0; p < points.length; p++) { - series.append(points[p].x, points[p].y) - } - } - } - } - - // Logic - function showMainTooltip(chart, point, state) { - if (!chartView.allowHover) { - return - } - const pos = chart.mapToPosition(Qt.point(point.x, point.y)) - dataToolTip.x = pos.x - dataToolTip.y = pos.y - dataToolTip.text = `

x: ${point.x.toFixed(3)}y: ${point.y.toFixed(3)}

` - dataToolTip.parent = chart - dataToolTip.visible = state + Globals.References.pages.sample.mainContent.sldView = sldChart.chartView } } diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/PlotControl.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/PlotControl.qml index f7d77177..b448193c 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/PlotControl.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/PlotControl.qml @@ -17,6 +17,16 @@ EaElements.GroupBox { Column { spacing: EaStyle.Sizes.fontPixelSize * 0.5 + EaElements.CheckBox { + topPadding: 0 + checked: Globals.BackendWrapper.plottingPlotRQ4 + text: qsTr("Plot R(q)×q⁴") + ToolTip.text: qsTr("Checking this box will plot R(q) multiplied by q⁴") + onToggled: { + Globals.BackendWrapper.plottingTogglePlotRQ4() + } + } + EaElements.CheckBox { topPadding: 0 checked: Globals.Variables.reverseSldZAxis diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/MultiLayer.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/MultiLayer.qml index 213ab528..8697204f 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/MultiLayer.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/MultiLayer.qml @@ -78,6 +78,7 @@ EaElements.GroupColumn { horizontalAlignment: Text.AlignHCenter enabled: Globals.BackendWrapper.sampleLayers[index].thickness_enabled === "True" text: (isNaN(Globals.BackendWrapper.sampleLayers[index].thickness)) ? '--' : Number(Globals.BackendWrapper.sampleLayers[index].thickness).toFixed(2) + onActiveFocusChanged: if (activeFocus && Globals.BackendWrapper.sampleCurrentLayerIndex !== index) Globals.BackendWrapper.sampleSetCurrentLayerIndex(index) onEditingFinished: Globals.BackendWrapper.sampleSetCurrentLayerThickness(text) } @@ -85,6 +86,7 @@ EaElements.GroupColumn { horizontalAlignment: Text.AlignHCenter enabled: Globals.BackendWrapper.sampleLayers[index].roughness_enabled === "True" text: (isNaN(Globals.BackendWrapper.sampleLayers[index].roughness)) ? '--' : Number(Globals.BackendWrapper.sampleLayers[index].roughness).toFixed(2) + onActiveFocusChanged: if (activeFocus && Globals.BackendWrapper.sampleCurrentLayerIndex !== index) Globals.BackendWrapper.sampleSetCurrentLayerIndex(index) onEditingFinished: Globals.BackendWrapper.sampleSetCurrentLayerRoughness(text) } diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/SurfactantLayer.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/SurfactantLayer.qml index dd33ea8d..231e02f1 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/SurfactantLayer.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/SurfactantLayer.qml @@ -60,6 +60,7 @@ EaElements.GroupColumn { EaComponents.TableViewTextInput { horizontalAlignment: Text.AlignHCenter text: Globals.BackendWrapper.sampleLayers[index].formula + onActiveFocusChanged: if (activeFocus && Globals.BackendWrapper.sampleCurrentLayerIndex !== index) Globals.BackendWrapper.sampleSetCurrentLayerIndex(index) onEditingFinished: Globals.BackendWrapper.sampleSetCurrentLayerFormula(text) } @@ -67,6 +68,7 @@ EaElements.GroupColumn { horizontalAlignment: Text.AlignHCenter enabled: Globals.BackendWrapper.sampleLayers[index].thickness_enabled === "True" text: (isNaN(Globals.BackendWrapper.sampleLayers[index].thickness)) ? '--' : Number(Globals.BackendWrapper.sampleLayers[index].thickness).toFixed(2) + onActiveFocusChanged: if (activeFocus && Globals.BackendWrapper.sampleCurrentLayerIndex !== index) Globals.BackendWrapper.sampleSetCurrentLayerIndex(index) onEditingFinished: Globals.BackendWrapper.sampleSetCurrentLayerThickness(text) } @@ -74,12 +76,14 @@ EaElements.GroupColumn { horizontalAlignment: Text.AlignHCenter enabled: Globals.BackendWrapper.sampleLayers[index].roughness_enabled === "True" text: (isNaN(Globals.BackendWrapper.sampleLayers[index].roughness)) ? '--' : Number(Globals.BackendWrapper.sampleLayers[index].roughness).toFixed(2) + onActiveFocusChanged: if (activeFocus && Globals.BackendWrapper.sampleCurrentLayerIndex !== index) Globals.BackendWrapper.sampleSetCurrentLayerIndex(index) onEditingFinished: Globals.BackendWrapper.sampleSetCurrentLayerRoughness(text) } EaComponents.TableViewTextInput { horizontalAlignment: Text.AlignHCenter text: (isNaN(Globals.BackendWrapper.sampleLayers[index].solvation)) ? '--' : Number(Globals.BackendWrapper.sampleLayers[index].solvation).toFixed(2) + onActiveFocusChanged: if (activeFocus && Globals.BackendWrapper.sampleCurrentLayerIndex !== index) Globals.BackendWrapper.sampleSetCurrentLayerIndex(index) onEditingFinished: Globals.BackendWrapper.sampleSetCurrentLayerSolvation(text) } @@ -87,6 +91,7 @@ EaElements.GroupColumn { horizontalAlignment: Text.AlignHCenter enabled: Globals.BackendWrapper.sampleLayers[index].apm_enabled === "True" text: (isNaN(Globals.BackendWrapper.sampleLayers[index].apm)) ? '--' : Number(Globals.BackendWrapper.sampleLayers[index].apm).toFixed(2) + onActiveFocusChanged: if (activeFocus && Globals.BackendWrapper.sampleCurrentLayerIndex !== index) Globals.BackendWrapper.sampleSetCurrentLayerIndex(index) onEditingFinished: Globals.BackendWrapper.sampleSetCurrentLayerAPM(text) } @@ -108,7 +113,7 @@ EaElements.GroupColumn { } } mouseArea.onPressed: { - if (Globals.BackendWrapper.samplCurrentLayerIndex !== index) { + if (Globals.BackendWrapper.sampleCurrentLayerIndex !== index) { Globals.BackendWrapper.sampleSetCurrentLayerIndex(index) } } diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/LoadSample.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/LoadSample.qml index 14973e97..04235b1f 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/LoadSample.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/LoadSample.qml @@ -1,5 +1,5 @@ -import QtQuick 2.14 -import QtQuick.Controls 2.14 +import QtQuick +import QtQuick.Controls import QtQuick.Dialogs import EasyApp.Gui.Style as EaStyle @@ -13,6 +13,13 @@ EaElements.GroupBox { collapsed: false EaElements.GroupColumn { + EaElements.CheckBox { + id: appendCheckBox + text: qsTr("Append to existing models") + checked: true + width: EaStyle.Sizes.sideBarContentWidth + } + EaElements.SideBarButton { width: EaStyle.Sizes.sideBarContentWidth fontIcon: "folder-open" @@ -24,7 +31,31 @@ EaElements.GroupBox { id: fileDialog title: qsTr("Select a sample file") nameFilters: [ "ORT files (*.ort)", "ORSO files (*.orso)", "All files (*.*)" ] - onAccepted: Globals.BackendWrapper.sampleFileLoad(selectedFiles[0]) + onAccepted: Globals.BackendWrapper.sampleFileLoad(selectedFiles[0], appendCheckBox.checked) + } + } + + // Warning dialog for sample load issues + EaElements.Dialog { + id: sampleLoadWarningDialog + title: qsTr("Sample Load Warning") + standardButtons: Dialog.Ok + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + property string warningMessage: "" + + EaElements.Label { + text: sampleLoadWarningDialog.warningMessage + wrapMode: Text.WordWrap + width: Math.min(implicitWidth, EaStyle.Sizes.sideBarContentWidth * 1.5) + } + } + + Connections { + target: Globals.BackendWrapper + function onSampleLoadWarning(message) { + sampleLoadWarningDialog.warningMessage = message + sampleLoadWarningDialog.open() } } } \ No newline at end of file diff --git a/EasyReflectometryApp/Gui/PlotControlRefLines.qml b/EasyReflectometryApp/Gui/PlotControlRefLines.qml new file mode 100644 index 00000000..e1ee3eeb --- /dev/null +++ b/EasyReflectometryApp/Gui/PlotControlRefLines.qml @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2025 Contributors to the EasyReflectometry project + +import QtQuick +import QtQuick.Controls + +import EasyApp.Gui.Style as EaStyle +import EasyApp.Gui.Elements as EaElements + +import Gui.Globals as Globals + +Column { + spacing: EaStyle.Sizes.fontPixelSize * 0.5 + + EaElements.CheckBox { + topPadding: 0 + checked: Globals.BackendWrapper.plottingPlotRQ4 + text: qsTr("Plot R(q)×q⁴") + ToolTip.text: qsTr("Checking this box will plot R(q) multiplied by q⁴") + onToggled: { + Globals.BackendWrapper.plottingTogglePlotRQ4() + } + } + + EaElements.CheckBox { + topPadding: 0 + checked: Globals.Variables.logarithmicQAxis + text: qsTr("Logarithmic q-axis") + ToolTip.text: qsTr("Checking this box will make the q-axis logarithmic") + onToggled: { + Globals.Variables.logarithmicQAxis = checked + } + } + + EaElements.CheckBox { + topPadding: 0 + checked: Globals.BackendWrapper.plottingScaleShown + text: qsTr("Show scale line") + ToolTip.text: qsTr("Checking this box will show the scale reference line on the plot") + onToggled: { + Globals.BackendWrapper.plottingFlipScaleShown() + } + } + + EaElements.CheckBox { + topPadding: 0 + checked: Globals.BackendWrapper.plottingBkgShown + text: qsTr("Show background line") + ToolTip.text: qsTr("Checking this box will show the background reference line on the plot") + onToggled: { + Globals.BackendWrapper.plottingFlipBkgShown() + } + } +} diff --git a/EasyReflectometryApp/Gui/SldChart.qml b/EasyReflectometryApp/Gui/SldChart.qml new file mode 100644 index 00000000..908b28b3 --- /dev/null +++ b/EasyReflectometryApp/Gui/SldChart.qml @@ -0,0 +1,388 @@ +// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2025 Contributors to the EasyReflectometry project + +import QtQuick +import QtQuick.Controls +import QtCharts + +import EasyApp.Gui.Style as EaStyle +import EasyApp.Gui.Globals as EaGlobals +import EasyApp.Gui.Elements as EaElements + +import Gui.Globals as Globals + + +Rectangle { + id: root + + color: EaStyle.Colors.chartBackground + + // Whether to show the legend (caller binds to the right page variable) + property bool showLegend: false + + // Whether to show the z-axis reversed + property bool reverseZAxis: false + + // Expose the ChartView so callers can store a reference / call resetAxes + readonly property alias chartView: chartView + + // Track model count changes to refresh charts + property int modelCount: Globals.BackendWrapper.sampleModels.length + + // Store dynamically created series + property var sldSeries: [] + + ChartView { + id: chartView + + anchors.fill: parent + anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 + anchors.margins: -12 + + antialiasing: true + legend.visible: false + backgroundRoundness: 0 + backgroundColor: EaStyle.Colors.chartBackground + plotAreaColor: EaStyle.Colors.chartPlotAreaBackground + + property bool allowZoom: true + property bool allowHover: true + + property double xRange: Globals.BackendWrapper.plottingSldMaxX - Globals.BackendWrapper.plottingSldMinX + + ValueAxis { + id: axisX + titleText: "z (Å)" + property double minAfterReset: Globals.BackendWrapper.plottingSldMinX - chartView.xRange * 0.01 + property double maxAfterReset: Globals.BackendWrapper.plottingSldMaxX + chartView.xRange * 0.01 + reverse: root.reverseZAxis + color: EaStyle.Colors.chartAxis + gridLineColor: EaStyle.Colors.chartGridLine + minorGridLineColor: EaStyle.Colors.chartMinorGridLine + labelsColor: EaStyle.Colors.chartLabels + titleBrush: EaStyle.Colors.chartLabels + Component.onCompleted: { + min = minAfterReset + max = maxAfterReset + } + } + + property double yRange: Globals.BackendWrapper.plottingSldMaxY - Globals.BackendWrapper.plottingSldMinY + + ValueAxis { + id: axisY + titleText: "SLD (10⁻⁶Å⁻²)" + property double minAfterReset: Globals.BackendWrapper.plottingSldMinY - chartView.yRange * 0.01 + property double maxAfterReset: Globals.BackendWrapper.plottingSldMaxY + chartView.yRange * 0.01 + color: EaStyle.Colors.chartAxis + gridLineColor: EaStyle.Colors.chartGridLine + minorGridLineColor: EaStyle.Colors.chartMinorGridLine + labelsColor: EaStyle.Colors.chartLabels + titleBrush: EaStyle.Colors.chartLabels + Component.onCompleted: { + min = minAfterReset + max = maxAfterReset + } + } + + function resetAxes() { + axisX.min = axisX.minAfterReset + axisX.max = axisX.maxAfterReset + axisY.min = axisY.minAfterReset + axisY.max = axisY.maxAfterReset + } + + // Tool buttons + Row { + id: toolButtons + z: 1 + + x: chartView.plotArea.x + chartView.plotArea.width - width + y: chartView.plotArea.y - height - EaStyle.Sizes.fontPixelSize + + spacing: 0.25 * EaStyle.Sizes.fontPixelSize + + EaElements.TabButton { + checked: root.showLegend + autoExclusive: false + height: EaStyle.Sizes.toolButtonHeight + width: EaStyle.Sizes.toolButtonHeight + borderColor: EaStyle.Colors.chartAxis + fontIcon: "align-left" + ToolTip.text: root.showLegend ? + qsTr("Hide legend") : + qsTr("Show legend") + onClicked: root.showLegend = checked + } + + EaElements.TabButton { + checked: chartView.allowHover + autoExclusive: false + height: EaStyle.Sizes.toolButtonHeight + width: EaStyle.Sizes.toolButtonHeight + borderColor: EaStyle.Colors.chartAxis + fontIcon: "comment-alt" + ToolTip.text: qsTr("Show coordinates tooltip on hover") + onClicked: chartView.allowHover = checked + } + + Item { height: 1; width: 0.5 * EaStyle.Sizes.fontPixelSize } // spacer + + EaElements.TabButton { + checked: !chartView.allowZoom + autoExclusive: false + height: EaStyle.Sizes.toolButtonHeight + width: EaStyle.Sizes.toolButtonHeight + borderColor: EaStyle.Colors.chartAxis + fontIcon: "arrows-alt" + ToolTip.text: qsTr("Enable pan") + onClicked: chartView.allowZoom = !checked + } + + EaElements.TabButton { + checked: chartView.allowZoom + autoExclusive: false + height: EaStyle.Sizes.toolButtonHeight + width: EaStyle.Sizes.toolButtonHeight + borderColor: EaStyle.Colors.chartAxis + fontIcon: "expand" + ToolTip.text: qsTr("Enable box zoom") + onClicked: chartView.allowZoom = checked + } + + EaElements.TabButton { + checkable: false + height: EaStyle.Sizes.toolButtonHeight + width: EaStyle.Sizes.toolButtonHeight + borderColor: EaStyle.Colors.chartAxis + fontIcon: "backspace" + ToolTip.text: qsTr("Reset axes") + onClicked: chartView.resetAxes() + } + } + + // Legend showing all models + Rectangle { + visible: root.showLegend + + x: chartView.plotArea.x + chartView.plotArea.width - width - EaStyle.Sizes.fontPixelSize + y: chartView.plotArea.y + EaStyle.Sizes.fontPixelSize + width: childrenRect.width + height: childrenRect.height + + color: EaStyle.Colors.mainContentBackgroundHalfTransparent + border.color: EaStyle.Colors.chartGridLine + + Column { + leftPadding: EaStyle.Sizes.fontPixelSize + rightPadding: EaStyle.Sizes.fontPixelSize + topPadding: EaStyle.Sizes.fontPixelSize * 0.5 + bottomPadding: EaStyle.Sizes.fontPixelSize * 0.5 + + Repeater { + model: root.modelCount + EaElements.Label { + text: '━ SLD ' + Globals.BackendWrapper.sampleModels[index].label + color: Globals.BackendWrapper.sampleModels[index].color + } + } + } + } + + EaElements.ToolTip { + id: dataToolTip + arrowLength: 0 + textFormat: Text.RichText + } + + // Zoom rectangle + Rectangle { + id: recZoom + + property int xScaleZoom: 0 + property int yScaleZoom: 0 + + visible: false + transform: Scale { + origin.x: 0 + origin.y: 0 + xScale: recZoom.xScaleZoom + yScale: recZoom.yScaleZoom + } + border.color: EaStyle.Colors.appBorder + border.width: 1 + opacity: 0.9 + color: "transparent" + + Rectangle { + anchors.fill: parent + opacity: 0.5 + color: recZoom.border.color + } + } + + // Zoom with left mouse button + MouseArea { + id: zoomMouseArea + + enabled: chartView.allowZoom + anchors.fill: chartView + acceptedButtons: Qt.LeftButton + onPressed: { + recZoom.x = mouseX + recZoom.y = mouseY + recZoom.visible = true + } + onMouseXChanged: { + if (mouseX > recZoom.x) { + recZoom.xScaleZoom = 1 + recZoom.width = Math.min(mouseX, chartView.width) - recZoom.x + } else { + recZoom.xScaleZoom = -1 + recZoom.width = recZoom.x - Math.max(mouseX, 0) + } + } + onMouseYChanged: { + if (mouseY > recZoom.y) { + recZoom.yScaleZoom = 1 + recZoom.height = Math.min(mouseY, chartView.height) - recZoom.y + } else { + recZoom.yScaleZoom = -1 + recZoom.height = recZoom.y - Math.max(mouseY, 0) + } + } + onReleased: { + const x = Math.min(recZoom.x, mouseX) - chartView.anchors.leftMargin + const y = Math.min(recZoom.y, mouseY) - chartView.anchors.topMargin + const width = recZoom.width + const height = recZoom.height + chartView.zoomIn(Qt.rect(x, y, width, height)) + recZoom.visible = false + } + } + + // Pan with left mouse button + MouseArea { + property real pressedX + property real pressedY + property int threshold: 1 + + enabled: !zoomMouseArea.enabled + anchors.fill: chartView + acceptedButtons: Qt.LeftButton + onPressed: { + pressedX = mouseX + pressedY = mouseY + } + onMouseXChanged: Qt.callLater(update) + onMouseYChanged: Qt.callLater(update) + + function update() { + const dx = mouseX - pressedX + const dy = mouseY - pressedY + pressedX = mouseX + pressedY = mouseY + + if (dx > threshold) + chartView.scrollLeft(dx) + else if (dx < -threshold) + chartView.scrollRight(-dx) + if (dy > threshold) + chartView.scrollUp(dy) + else if (dy < -threshold) + chartView.scrollDown(-dy) + } + } + + // Reset axes with right mouse button + MouseArea { + anchors.fill: chartView + acceptedButtons: Qt.RightButton + onClicked: chartView.resetAxes() + } + } + + // Create series dynamically when model count changes + onModelCountChanged: { + Qt.callLater(recreateAllSeries) + } + + // Refresh all chart series when data changes + Connections { + target: Globals.BackendWrapper + function onSamplePageDataChanged() { + refreshAllCharts() + } + function onSamplePageResetAxes() { + resetAxesTimer.start() + } + function onPlotModeChanged() { + refreshAllCharts() + resetAxesTimer.start() + } + function onChartAxesResetRequested() { + resetAxesTimer.start() + } + } + + Timer { + id: resetAxesTimer + interval: 75 + repeat: false + onTriggered: chartView.resetAxes() + } + + Component.onCompleted: { + Qt.callLater(recreateAllSeries) + } + + function recreateAllSeries() { + // Remove old series + for (let i = 0; i < sldSeries.length; i++) { + if (sldSeries[i]) { + chartView.removeSeries(sldSeries[i]) + } + } + sldSeries = [] + + // Create new series for each model + const models = Globals.BackendWrapper.sampleModels + for (let k = 0; k < models.length; k++) { + const line = chartView.createSeries(ChartView.SeriesTypeLine, models[k].label, axisX, axisY) + line.color = models[k].color + line.width = 2 + line.useOpenGL = EaGlobals.Vars.useOpenGL + line.hovered.connect((point, state) => showMainTooltip(point, state)) + sldSeries.push(line) + } + + refreshAllCharts() + } + + function refreshAllCharts() { + const models = Globals.BackendWrapper.sampleModels + for (let i = 0; i < sldSeries.length && i < models.length; i++) { + const series = sldSeries[i] + if (series) { + series.clear() + const points = Globals.BackendWrapper.plottingGetSldDataPointsForModel(i) + for (let p = 0; p < points.length; p++) { + series.append(points[p].x, points[p].y) + } + } + } + } + + function showMainTooltip(point, state) { + if (!chartView.allowHover) { + return + } + const pos = chartView.mapToPosition(Qt.point(point.x, point.y)) + dataToolTip.x = pos.x + dataToolTip.y = pos.y + dataToolTip.text = `

x: ${point.x.toFixed(3)}y: ${point.y.toFixed(3)}

` + dataToolTip.parent = chartView + dataToolTip.visible = state + } +} diff --git a/EasyReflectometryApp/Gui/StatusBar.qml b/EasyReflectometryApp/Gui/StatusBar.qml index cf92b436..9afb2c96 100644 --- a/EasyReflectometryApp/Gui/StatusBar.qml +++ b/EasyReflectometryApp/Gui/StatusBar.qml @@ -53,4 +53,11 @@ EaElements.StatusBar { valueText: Globals.BackendWrapper.statusVariables ?? '' ToolTip.text: qsTr('Number of parameters: total, free and fixed') } + EaElements.StatusBarItem { + visible: Globals.BackendWrapper.analysisFitChi2 > 0 + keyIcon: 'chart-line' + keyText: qsTr('Chi²') + valueText: Globals.BackendWrapper.analysisFitChi2.toFixed(2) + ToolTip.text: qsTr('Goodness of fit (chi-squared)') + } } diff --git a/EasyReflectometryApp/Gui/qmldir b/EasyReflectometryApp/Gui/qmldir index d3151d58..fe68abae 100644 --- a/EasyReflectometryApp/Gui/qmldir +++ b/EasyReflectometryApp/Gui/qmldir @@ -1,4 +1,6 @@ module Gui ApplicationWindow ApplicationWindow.qml +PlotControlRefLines PlotControlRefLines.qml +SldChart SldChart.qml StatusBar StatusBar.qml diff --git a/pyproject.toml b/pyproject.toml index 58c688b2..22977614 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ classifiers = [ requires-python = '>=3.11' dependencies = [ 'EasyApp @ git+https://github.com/EasyScience/EasyApp.git@develop', - 'easyreflectometry @ git+https://github.com/EasyScience/EasyReflectometryLib.git@append_sample', + 'easyreflectometry @ git+https://github.com/EasyScience/EasyReflectometryLib.git@develop', 'asteval', 'PySide6', 'toml',