diff --git a/core/plotting.py b/core/plotting.py index eec7e44..4b7690f 100644 --- a/core/plotting.py +++ b/core/plotting.py @@ -38,6 +38,7 @@ "legend_bg": "rgba(255,255,255,0.86)", "annotation_bg": "rgba(255,255,255,0.92)", "annotation_border": "rgba(102,100,94,0.22)", + "shape_line": "#1C1A1A", }, "dark": { "template": "plotly_dark", @@ -52,6 +53,7 @@ "legend_bg": "rgba(26,25,23,0.86)", "annotation_bg": "rgba(26,25,23,0.92)", "annotation_border": "rgba(158,154,147,0.24)", + "shape_line": "#F2F0EB", }, } @@ -240,6 +242,101 @@ def _scale_trace_styles(fig: go.Figure, *, line_width_scale: float, marker_size_ trace.marker.size = max(4.0, float(current_size) * marker_size_scale) +def primary_y_range(*series: list[Any] | tuple[Any, ...] | None, padding_ratio: float = 0.08) -> list[float] | None: + values: list[float] = [] + for entry in series: + if entry is None: + continue + try: + iterator = iter(entry) + except TypeError: + iterator = iter((entry,)) + for value in iterator: + try: + parsed = float(value) + except (TypeError, ValueError): + continue + if math.isfinite(parsed): + values.append(parsed) + if not values: + return None + y_min = min(values) + y_max = max(values) + if y_min == y_max: + pad = max(abs(y_max) * padding_ratio, 1.0) + else: + pad = (y_max - y_min) * padding_ratio + return [y_min - pad, y_max + pad] + + +def sparse_label_indices( + points: list[Mapping[str, Any]], + *, + position_key: str = "position", + intensity_key: str = "intensity", + max_labels: int = 4, + min_distance_ratio: float = 0.08, + min_distance_floor: float = 1.0, +) -> set[int]: + if max_labels <= 0 or not points: + return set() + parsed: list[tuple[int, float, float]] = [] + for idx, point in enumerate(points): + try: + position = float(point.get(position_key)) + intensity = float(point.get(intensity_key, 0.0)) + except (AttributeError, TypeError, ValueError): + continue + if math.isfinite(position) and math.isfinite(intensity): + parsed.append((idx, position, intensity)) + if not parsed: + return set() + positions = [position for _idx, position, _intensity in parsed] + span = max(positions) - min(positions) if len(positions) >= 2 else 0.0 + min_distance = max(span * min_distance_ratio, min_distance_floor) + chosen: list[tuple[int, float, float]] = [] + for candidate in sorted(parsed, key=lambda item: (-item[2], item[1])): + if all(abs(candidate[1] - item[1]) >= min_distance for item in chosen): + chosen.append(candidate) + if len(chosen) >= max_labels: + break + return {idx for idx, _position, _intensity in chosen} + + +def _shape_line_needs_theme_contrast(color: Any) -> bool: + if color in (None, ""): + return True + normalized = str(color).strip().lower().replace(" ", "") + return normalized in { + "black", + "#000", + "#000000", + "#1c1a1a", + "rgb(0,0,0)", + "rgba(0,0,0,1)", + "white", + "#fff", + "#ffffff", + "#f2f0eb", + "rgb(255,255,255)", + "rgba(255,255,255,1)", + } + + +def _apply_shape_contrast(fig: go.Figure, *, line_color: str) -> None: + shapes = list(getattr(fig.layout, "shapes", None) or []) + if not shapes: + return + for shape in shapes: + line = getattr(shape, "line", None) + color = getattr(line, "color", None) if line is not None else None + if _shape_line_needs_theme_contrast(color): + shape.line.color = line_color + width = getattr(shape.line, "width", None) + if width in (None, 0): + shape.line.width = 2.5 + + def apply_materialscope_plot_theme( fig: go.Figure, settings: Mapping[str, Any] | None = None, @@ -264,6 +361,12 @@ def apply_materialscope_plot_theme( top_margin += 20 if subtitle: top_margin += 18 + bottom_margin = 54 if compact else 68 + plot_height = 520 if compact else 620 + if for_export: + top_margin = 82 if subtitle else 68 + bottom_margin = 54 + plot_height = DEFAULT_EXPORT_HEIGHT fig.update_layout( template=tokens["template"], @@ -290,9 +393,16 @@ def apply_materialscope_plot_theme( hovermode="x unified", hoverdistance=80, spikedistance=1000, - margin={"l": 64 if compact else 76, "r": right_margin, "t": top_margin, "b": 54 if compact else 68}, - height=520 if compact else 620, + newshape={ + "line": { + "color": tokens["shape_line"], + "width": 2.5, + } + }, + margin={"l": 64 if compact else 76, "r": right_margin, "t": top_margin, "b": bottom_margin}, + height=plot_height, ) + _apply_shape_contrast(fig, line_color=tokens["shape_line"]) if for_export: fig.update_layout(width=DEFAULT_EXPORT_WIDTH, height=DEFAULT_EXPORT_HEIGHT) fig.update_xaxes( diff --git a/dash_app/assets/style.css b/dash_app/assets/style.css index 2819477..bcfa0c8 100644 --- a/dash_app/assets/style.css +++ b/dash_app/assets/style.css @@ -1770,16 +1770,20 @@ html[data-theme="dark"] .main-content .ms-results-surface .dta-debug-shell { line-height: 1.45; } -.main-content .ms-results-surface .xrd-literature-card .card-body { - padding: 0.65rem 0.75rem; -} +/* -------------------------------------------------------------------------- */ +/* XRD — Literature Compare: explicit card chrome (Bootstrap/builder wins) */ +/* Scoped to .xrd-page so Raman/FTIR/TGA/DSC surfaces stay unchanged. */ +/* -------------------------------------------------------------------------- */ -.main-content .ms-results-surface .xrd-literature-card .card-title { - font-size: 0.9rem; - margin-bottom: 0.35rem; - color: var(--ta-muted); +.main-content .xrd-page .ms-result-secondary > .xrd-literature-card.card { + border: 1px solid var(--ta-border) !important; + border-radius: 4px !important; + box-shadow: var(--ta-card-shadow) !important; + background: var(--ta-panel) !important; } -.main-content .ms-results-surface .xrd-literature-card .ta-ms-details .ta-details-summary { - font-size: 0.8125rem; +.main-content .xrd-page .ms-result-secondary > .xrd-literature-card.card > .card-body { + padding: 1rem !important; } + + diff --git a/dash_app/components/xrd_result_plot.py b/dash_app/components/xrd_result_plot.py index 87bcc4f..8e5a035 100644 --- a/dash_app/components/xrd_result_plot.py +++ b/dash_app/components/xrd_result_plot.py @@ -7,7 +7,7 @@ import plotly.graph_objects as go -from dash_app.theme import PLOT_THEME, apply_figure_theme, normalize_ui_theme +from core.plotting import apply_materialscope_plot_theme from utils.i18n import translate_ui _XRD_MATCH_STYLE = { @@ -20,7 +20,7 @@ _XRD_PLOT_FALLBACK = { "show_peak_labels": True, "label_density_mode": "smart", - "max_labels": 8, + "max_labels": 4, "min_label_intensity_ratio": 0.12, "marker_size": 8, "label_position_precision": 2, @@ -151,6 +151,25 @@ def normalize_xrd_plot_settings(payload: Mapping[str, Any] | None) -> dict[str, return settings +def _shared_display_settings_from_xrd(settings: Mapping[str, Any]) -> dict[str, Any]: + return { + "legend_mode": "auto", + "compact": False, + "show_grid": True, + "show_spikes": True, + "line_width_scale": 1.0, + "marker_size_scale": 1.0, + "export_scale": 2, + "reverse_x_axis": False, + "x_range_enabled": bool(settings.get("x_range_enabled")), + "x_min": settings.get("x_min"), + "x_max": settings.get("x_max"), + "y_range_enabled": bool(settings.get("y_range_enabled")) and not bool(settings.get("log_y")), + "y_min": settings.get("y_min"), + "y_max": settings.get("y_max"), + } + + def _reference_marker_y(value: Any, observed_max_intensity: float) -> float: try: parsed = float(value) @@ -172,12 +191,64 @@ def _xrd_match_marker_style(kind: str, settings: Mapping[str, Any]) -> dict[str, def _xrd_peak_label(position: float, intensity: float, *, settings: Mapping[str, Any], lang: str) -> str: + pos_precision = int(settings.get("label_position_precision", 2)) + angle_unit = "°" if lang == "tr" else " deg" + return f"{position:.{pos_precision}f}{angle_unit}" + + +def _xrd_peak_hover_label(position: float, intensity: float, *, settings: Mapping[str, Any], lang: str) -> str: pos_precision = int(settings.get("label_position_precision", 2)) intensity_precision = int(settings.get("label_intensity_precision", 0)) angle_unit = "°" if lang == "tr" else " deg" return f"{position:.{pos_precision}f}{angle_unit} | I={intensity:.{intensity_precision}f}" +def _interpolate_signal_at_positions(axis: list[float], signal: list[float], positions: list[float]) -> list[float]: + if not axis or not signal or len(axis) != len(signal): + return [] + pairs = sorted( + (float(x), float(y)) + for x, y in zip(axis, signal) + if math.isfinite(float(x)) and math.isfinite(float(y)) + ) + if not pairs: + return [] + xs = [x for x, _y in pairs] + ys = [y for _x, y in pairs] + interpolated: list[float] = [] + for position in positions: + if position <= xs[0]: + interpolated.append(ys[0]) + continue + if position >= xs[-1]: + interpolated.append(ys[-1]) + continue + for idx in range(1, len(xs)): + if xs[idx] >= position: + x0, y0 = xs[idx - 1], ys[idx - 1] + x1, y1 = xs[idx], ys[idx] + if x1 == x0: + interpolated.append(y1) + else: + ratio = (position - x0) / (x1 - x0) + interpolated.append(y0 + (y1 - y0) * ratio) + break + return interpolated + + +def _corrected_view_y_range(signal: list[float], peak_y: list[float]) -> list[float] | None: + values = [float(value) for value in [*(signal or []), *(peak_y or [])] if math.isfinite(float(value))] + if not values: + return None + y_min = min(values) + y_max = max(values) + if y_min == y_max: + pad = max(abs(y_max) * 0.08, 1.0) + else: + pad = (y_max - y_min) * 0.08 + return [y_min - pad, y_max + pad] + + def _pick_peak_label_indices(peaks: list[dict[str, float]], settings: Mapping[str, Any]) -> set[int]: if not peaks or not bool(settings.get("show_peak_labels", True)): return set() @@ -186,7 +257,7 @@ def _pick_peak_label_indices(peaks: list[dict[str, float]], settings: Mapping[st if label_mode == "selected": label_mode = "smart" - max_labels = int(settings.get("max_labels", 10)) + max_labels = int(settings.get("max_labels", 4)) if max_labels <= 0: return set() @@ -208,14 +279,25 @@ def _pick_peak_label_indices(peaks: list[dict[str, float]], settings: Mapping[st chosen.add(idx) return chosen + positions = [float(item.get("position", 0.0)) for item in peaks] + finite_positions = [value for value in positions if math.isfinite(value)] + axis_span = max(finite_positions) - min(finite_positions) if len(finite_positions) >= 2 else 0.0 + min_label_distance = max(axis_span * 0.08, 2.0) + for idx in ranked_indices: - if float(peaks[idx].get("intensity", 0.0)) >= threshold: + position = positions[idx] + far_enough = all(abs(position - positions[chosen_idx]) >= min_label_distance for chosen_idx in chosen) + if float(peaks[idx].get("intensity", 0.0)) >= threshold and far_enough: chosen.add(idx) if len(chosen) >= max_labels: break if not chosen: - for idx in ranked_indices[:max_labels]: - chosen.add(idx) + for idx in ranked_indices: + position = positions[idx] + if all(abs(position - positions[chosen_idx]) >= min_label_distance for chosen_idx in chosen): + chosen.add(idx) + if len(chosen) >= max_labels: + break return chosen @@ -233,13 +315,11 @@ def build_xrd_result_figure( loc: str, sample_name: str, axis_title: str, + drawn_shapes: list[dict[str, Any]] | None = None, ) -> go.Figure: settings = normalize_xrd_plot_settings(plot_settings) line_width = float(settings.get("line_width", 2.0)) - tone = normalize_ui_theme(ui_theme) - pt = PLOT_THEME[tone] - muted = "#66645E" if tone == "light" else "#9E9A93" - line_primary = pt["text"] + line_primary = "#1C1A1A" if ui_theme != "dark" else "#EEEDEA" has_corrected = bool(corrected and len(corrected) == len(axis)) has_smoothed = bool(smoothed and len(smoothed) == len(axis)) @@ -256,6 +336,7 @@ def build_xrd_result_figure( fig = go.Figure() show_intermediate = bool(settings.get("show_intermediate_traces")) if has_raw: + demote_raw_for_corrected_view = has_corrected and not show_intermediate fig.add_trace( go.Scatter( x=axis, @@ -264,7 +345,8 @@ def build_xrd_result_figure( name=legend_raw, line=dict(color="#94A3B8", width=max(0.8, line_width - 0.4)), opacity=0.35 if has_overlay else 0.95, - showlegend=bool(show_intermediate) or not has_overlay, + showlegend=bool(show_intermediate) or not has_overlay or demote_raw_for_corrected_view, + visible="legendonly" if demote_raw_for_corrected_view else True, ) ) if show_intermediate and has_smoothed: @@ -306,9 +388,11 @@ def build_xrd_result_figure( if peaks: label_indices = _pick_peak_label_indices(peaks, settings) peak_x = [float(item.get("position", 0.0)) for item in peaks] - peak_y = [float(item.get("intensity", 0.0)) for item in peaks] + peak_intensity = [float(item.get("intensity", 0.0)) for item in peaks] + visual_peak_y = _interpolate_signal_at_positions(axis, primary_signal, peak_x) if has_corrected else [] + peak_y = visual_peak_y if len(visual_peak_y) == len(peak_x) else peak_intensity peak_text = [ - _xrd_peak_label(peak_x[idx], peak_y[idx], settings=settings, lang=loc) if idx in label_indices else "" + _xrd_peak_label(peak_x[idx], peak_intensity[idx], settings=settings, lang=loc) if idx in label_indices else "" for idx in range(len(peaks)) ] fig.add_trace( @@ -318,6 +402,11 @@ def build_xrd_result_figure( mode="markers", name=translate_ui(loc, "dash.analysis.xrd.figure.peaks"), marker=dict(color="#D97706", size=int(settings.get("marker_size", 8)), symbol="diamond"), + text=[ + _xrd_peak_hover_label(peak_x[idx], peak_intensity[idx], settings=settings, lang=loc) + for idx in range(len(peaks)) + ], + hovertemplate="%{text}", ) ) @@ -439,35 +528,30 @@ def build_xrd_result_figure( mode="text", text=peak_text, textposition="top center", - textfont=dict(size=10.5, color="#475569"), + textfont=dict(size=9.5, color="#475569"), hoverinfo="skip", showlegend=False, ) ) + if drawn_shapes: + fig.update_layout(shapes=drawn_shapes) + title_main = translate_ui(loc, "dash.analysis.figure.title_xrd_main") - sub_html = f"
{sample_name}" + subtitle_parts = [sample_name] if subtitle: - sub_html += f"
{subtitle}" - fig.update_layout( - title=(f"{title_main}{sub_html}"), - paper_bgcolor=pt["paper_bg"], - plot_bgcolor=pt["plot_bg"], - hovermode="x unified", - xaxis_title=axis_title, - yaxis_title=translate_ui(loc, "dash.analysis.figure.axis_intensity_au"), - margin=dict(l=64, r=24, t=88, b=72), - height=500, - legend=dict( - orientation="h", - yanchor="top", - y=-0.14, - xanchor="left", - x=0, - font=dict(size=10), - traceorder="normal", - ), + subtitle_parts.append(subtitle) + apply_materialscope_plot_theme( + fig, + _shared_display_settings_from_xrd(settings), + theme=ui_theme, + title=title_main, + subtitle=" | ".join(part for part in subtitle_parts if part), + view_mode="result", + scale_traces=False, ) + fig.update_xaxes(title_text=axis_title) + fig.update_yaxes(title_text=translate_ui(loc, "dash.analysis.figure.axis_intensity_au")) x_min = settings.get("x_min") x_max = settings.get("x_max") @@ -488,6 +572,9 @@ def build_xrd_result_figure( fig.update_yaxes(type="linear") if settings.get("y_range_enabled") and y_min is not None and y_max is not None: fig.update_yaxes(range=[float(y_min), float(y_max)]) + elif has_corrected: + corrected_range = _corrected_view_y_range(corrected, peak_y) + if corrected_range is not None: + fig.update_yaxes(range=corrected_range) - apply_figure_theme(fig, ui_theme) return fig diff --git a/dash_app/pages/ftir.py b/dash_app/pages/ftir.py index f36ad3f..e143636 100644 --- a/dash_app/pages/ftir.py +++ b/dash_app/pages/ftir.py @@ -103,11 +103,10 @@ build_plotly_config as build_spectral_plotly_config, build_spectral_plot_settings_card, normalize_spectral_plot_settings, - spectral_legend_layout, spectral_plot_settings_chrome, spectral_plot_settings_from_controls, ) -from dash_app.theme import PLOT_THEME, normalize_ui_theme +from core.plotting import apply_materialscope_plot_theme, primary_y_range, sparse_label_indices from utils.i18n import normalize_ui_locale, translate_ui dash.register_page(__name__, path="/ftir", title="FTIR Analysis - MaterialScope") @@ -1918,8 +1917,9 @@ def run_ftir_analysis(n_clicks, project_id, dataset_key, template_id, processing Input("ui-locale", "data"), Input("ftir-plot-settings", "data"), State("project-id", "data"), + State("ftir-result-plot-graph", "relayoutData"), ) -def display_result(result_id, _refresh, ui_theme, locale_data, plot_settings, project_id): +def display_result(result_id, _refresh, ui_theme, locale_data, plot_settings, project_id, relayout_data=None): loc = _loc(locale_data) empty_msg = empty_result_msg(locale_data=locale_data) summary_empty = html.P(translate_ui(loc, "dash.analysis.ftir.summary.empty"), className="text-muted") @@ -2007,7 +2007,15 @@ def display_result(result_id, _refresh, ui_theme, locale_data, plot_settings, pr top_match_area = empty_msg peak_cards_area = empty_msg if dataset_key: - figure_area = _build_figure(project_id, dataset_key, summary, ui_theme, loc, plot_settings=plot_settings) + figure_area = _build_figure( + project_id, + dataset_key, + summary, + ui_theme, + loc, + plot_settings=plot_settings, + drawn_shapes=_spectral_shapes_from_relayout(relayout_data), + ) top_match_area = _build_top_match_panel(summary, rows, loc) peak_cards_area = _build_peak_cards_from_curves(project_id, dataset_key, summary, loc) @@ -2461,6 +2469,7 @@ def _build_figure( loc: str, *, plot_settings: dict | None = None, + drawn_shapes: list[dict[str, Any]] | None = None, ) -> html.Div: from dash_app.api_client import analysis_state_curves @@ -2503,13 +2512,6 @@ def _build_figure( return no_data_figure_msg(locale_data=loc) sample_name = resolve_sample_name(summary, {}, fallback_display_name=dataset_key, locale_data=loc) - tone = normalize_ui_theme(ui_theme) - pt = PLOT_THEME[tone] - muted = "#66645E" if tone == "light" else "#9E9A93" - legend_bg = "rgba(255,255,255,0.9)" if tone == "light" else "rgba(26,25,23,0.94)" - hover_bg = "rgba(255,255,255,0.96)" if tone == "light" else "rgba(34,33,30,0.96)" - hover_fg = "#1C1A1A" if tone == "light" else "#EEEDEA" - dominant_signal = corrected if show_corrected_trace else smoothed if show_intermediate_smoothed else raw_signal if show_raw_trace else [] legend_query = translate_ui(loc, "dash.analysis.figure.legend_query_spectrum") legend_smooth = translate_ui(loc, "dash.analysis.figure.legend_smoothed_spectrum") @@ -2523,16 +2525,10 @@ def _build_figure( legend_query += suffix legend_normalized += suffix - y_series_for_range = [dominant_signal] - if show_raw_trace: - y_series_for_range.append(raw_signal) - if show_baseline_trace: - y_series_for_range.append(baseline) - if show_intermediate_smoothed: - y_series_for_range.append(smoothed) + primary_series = [dominant_signal] if show_normalized_trace: - y_series_for_range.append(normalized) - y_range = _y_axis_range(*y_series_for_range) + primary_series.append(normalized) + y_range = primary_y_range(*primary_series) if settings["y_range_enabled"] and settings["y_min"] is not None and settings["y_max"] is not None: y_range = [settings["y_min"], settings["y_max"]] @@ -2549,6 +2545,7 @@ def _build_figure( name=legend_baseline, line=dict(color=_FTIR_FIGURE_COLORS["baseline"], width=1.3 * line_scale, dash="dash"), opacity=0.65, + visible="legendonly" if show_corrected_trace else True, ) ) @@ -2561,6 +2558,7 @@ def _build_figure( name=legend_imported, line=dict(color=_FTIR_FIGURE_COLORS["raw"], width=1.6 * line_scale), opacity=0.45 if has_overlay else 0.95, + visible="legendonly" if (show_corrected_trace or show_normalized_trace) else True, ) ) @@ -2608,18 +2606,21 @@ def _peak_display_y(index: int) -> float | None: return float(raw_signal[index]) return None - # Peak annotations (top 8 only to avoid clutter) - _ANNOTATION_MIN_SEP = 20.0 - annotated_positions: list[float] = [] peak_count = len(peaks) - for i, peak in enumerate(peaks[:_FTIR_MAX_PEAK_CARDS] if settings["show_peaks"] else []): + peak_candidates = peaks[:_FTIR_MAX_PEAK_CARDS] if settings["show_peaks"] else [] + label_indices = sparse_label_indices( + peak_candidates, + max_labels=4, + min_distance_ratio=0.08, + min_distance_floor=35.0, + ) + for i, peak in enumerate(peak_candidates): pos = peak.get("position") intensity = peak.get("intensity") if pos is None or not wavenumber: continue idx = min(range(len(wavenumber)), key=lambda i: abs(wavenumber[i] - pos)) - too_close = any(abs(pos - p) < _ANNOTATION_MIN_SEP for p in annotated_positions) - label = "" if too_close else f"{pos:.0f}" + label = f"{pos:.0f}" if i in label_indices else "" y_at = _peak_display_y(idx) if y_at is None: continue @@ -2634,58 +2635,53 @@ def _peak_display_y(index: int) -> float | None: textfont=dict(size=8, color="#DC2626"), name=f"Peak {pos:.0f}", showlegend=False, + hovertemplate=f"{pos:.1f} cm⁻¹ | I={float(intensity or 0):.3g}", ) ) - if label: - annotated_positions.append(pos) title_main = translate_ui(loc, "dash.analysis.figure.title_ftir_main") - show_legend, legend_layout = spectral_legend_layout(len(fig.data), settings, theme=pt, legend_bg=legend_bg) - x_axis: dict[str, Any] = { - "showgrid": settings["show_grid"], - "showspikes": settings["show_spikes"], - "gridcolor": pt["grid"], - "linecolor": pt["grid"], - "tickfont": dict(size=12, color=pt["text"]), - "title_font": dict(size=13, color=pt["text"]), - "zeroline": False, - } + apply_materialscope_plot_theme( + fig, + settings, + theme=ui_theme, + title=title_main, + subtitle=sample_name, + view_mode="result", + scale_traces=False, + ) + fig.update_xaxes(title_text=translate_ui(loc, "dash.analysis.figure.axis_wavenumber")) + fig.update_yaxes(title_text=translate_ui(loc, "dash.analysis.figure.axis_signal_au")) if settings["x_range_enabled"] and settings["x_min"] is not None and settings["x_max"] is not None: x_range = [settings["x_min"], settings["x_max"]] if settings["reverse_x_axis"]: x_range = list(reversed(x_range)) - x_axis["range"] = x_range + fig.update_xaxes(range=x_range) elif settings["reverse_x_axis"]: - x_axis["autorange"] = "reversed" - y_axis = { - "range": y_range, - "showgrid": settings["show_grid"], - "showspikes": settings["show_spikes"], - "gridcolor": pt["grid"], - "linecolor": pt["grid"], - "tickfont": dict(size=12, color=pt["text"]), - "title_font": dict(size=13, color=pt["text"]), - "zeroline": False, - } - fig.update_layout( - title=(f"{title_main}
{sample_name}"), - paper_bgcolor=pt["paper_bg"], - plot_bgcolor=pt["plot_bg"], - hovermode="x unified", - xaxis_title=translate_ui(loc, "dash.analysis.figure.axis_wavenumber"), - yaxis_title=translate_ui(loc, "dash.analysis.figure.axis_signal_au"), - xaxis=x_axis, - yaxis=y_axis, - margin=dict(l=64, r=112 if show_legend and legend_layout.get("x") == 1.02 else 28, t=82, b=56), - height=460 if settings["compact"] else 520, - title_font=dict(size=20, color=pt["text"]), - title_x=0.01, - showlegend=show_legend, - legend=legend_layout, - hoverlabel=dict(bgcolor=hover_bg, font=dict(color=hover_fg)), - ) - fig.update_layout(meta={"plot_display_settings": settings}) - fig.update_layout(template=pt["template"]) + fig.update_xaxes(autorange="reversed") + if y_range is not None: + fig.update_yaxes(range=y_range) + if drawn_shapes: + fig.update_layout(shapes=drawn_shapes) + apply_materialscope_plot_theme( + fig, + settings, + theme=ui_theme, + title=title_main, + subtitle=sample_name, + view_mode="result", + scale_traces=False, + ) + fig.update_xaxes(title_text=translate_ui(loc, "dash.analysis.figure.axis_wavenumber")) + fig.update_yaxes(title_text=translate_ui(loc, "dash.analysis.figure.axis_signal_au")) + if settings["x_range_enabled"] and settings["x_min"] is not None and settings["x_max"] is not None: + x_range = [settings["x_min"], settings["x_max"]] + if settings["reverse_x_axis"]: + x_range = list(reversed(x_range)) + fig.update_xaxes(range=x_range) + elif settings["reverse_x_axis"]: + fig.update_xaxes(autorange="reversed") + if y_range is not None: + fig.update_yaxes(range=y_range) peak_count_disp = summary.get("peak_count", peak_count) top_match_name = summary.get("top_match_name") @@ -2721,12 +2717,26 @@ def _peak_display_y(index: int) -> float | None: [ html.H5(translate_ui(loc, "dash.analysis.ftir.figure.section_title"), className="mb-2"), html.P(run_caption, className="small text-muted mb-2"), - dcc.Graph(figure=fig, config=build_spectral_plotly_config(settings, filename="materialscope_ftir_spectrum"), className="ta-plot"), + dcc.Graph( + id="ftir-result-plot-graph", + figure=fig, + config=build_spectral_plotly_config(settings, filename="materialscope_ftir_spectrum"), + className="ta-plot", + ), *diag_children, ] ) +def _spectral_shapes_from_relayout(relayout_data): + if not isinstance(relayout_data, dict): + return None + shapes = relayout_data.get("shapes") + if isinstance(shapes, list): + return [dict(shape) for shape in shapes if isinstance(shape, dict)] + return None + + def _build_top_match_panel(summary: dict, rows: list, loc: str) -> html.Div: if not rows: if str(summary.get("match_status") or "").lower() == "library_unavailable": diff --git a/dash_app/pages/raman.py b/dash_app/pages/raman.py index 5fc33b4..2a55623 100644 --- a/dash_app/pages/raman.py +++ b/dash_app/pages/raman.py @@ -103,11 +103,10 @@ build_plotly_config as build_spectral_plotly_config, build_spectral_plot_settings_card, normalize_spectral_plot_settings, - spectral_legend_layout, spectral_plot_settings_chrome, spectral_plot_settings_from_controls, ) -from dash_app.theme import PLOT_THEME, normalize_ui_theme +from core.plotting import apply_materialscope_plot_theme, primary_y_range, sparse_label_indices from utils.i18n import normalize_ui_locale, translate_ui dash.register_page(__name__, path="/raman", title="RAMAN Analysis - MaterialScope") @@ -1923,8 +1922,9 @@ def run_raman_analysis(n_clicks, project_id, dataset_key, template_id, processin Input("ui-locale", "data"), Input("raman-plot-settings", "data"), State("project-id", "data"), + State("raman-result-plot-graph", "relayoutData"), ) -def display_result(result_id, _refresh, ui_theme, locale_data, plot_settings, project_id): +def display_result(result_id, _refresh, ui_theme, locale_data, plot_settings, project_id, relayout_data=None): loc = _loc(locale_data) empty_msg = empty_result_msg(locale_data=locale_data) summary_empty = html.P(translate_ui(loc, "dash.analysis.raman.summary.empty"), className="text-muted") @@ -2012,7 +2012,15 @@ def display_result(result_id, _refresh, ui_theme, locale_data, plot_settings, pr top_match_area = empty_msg peak_cards_area = empty_msg if dataset_key: - figure_area = _build_figure(project_id, dataset_key, summary, ui_theme, loc, plot_settings=plot_settings) + figure_area = _build_figure( + project_id, + dataset_key, + summary, + ui_theme, + loc, + plot_settings=plot_settings, + drawn_shapes=_spectral_shapes_from_relayout(relayout_data), + ) top_match_area = _build_top_match_panel(summary, rows, loc) peak_cards_area = _build_peak_cards_from_curves(project_id, dataset_key, summary, loc) @@ -2466,6 +2474,7 @@ def _build_figure( loc: str, *, plot_settings: dict | None = None, + drawn_shapes: list[dict[str, Any]] | None = None, ) -> html.Div: from dash_app.api_client import analysis_state_curves @@ -2508,13 +2517,6 @@ def _build_figure( return no_data_figure_msg(locale_data=loc) sample_name = resolve_sample_name(summary, {}, fallback_display_name=dataset_key, locale_data=loc) - tone = normalize_ui_theme(ui_theme) - pt = PLOT_THEME[tone] - muted = "#66645E" if tone == "light" else "#9E9A93" - legend_bg = "rgba(255,255,255,0.9)" if tone == "light" else "rgba(26,25,23,0.94)" - hover_bg = "rgba(255,255,255,0.96)" if tone == "light" else "rgba(34,33,30,0.96)" - hover_fg = "#1C1A1A" if tone == "light" else "#EEEDEA" - dominant_signal = corrected if show_corrected_trace else smoothed if show_intermediate_smoothed else raw_signal if show_raw_trace else [] legend_query = translate_ui(loc, "dash.analysis.figure.legend_query_spectrum") legend_smooth = translate_ui(loc, "dash.analysis.figure.legend_smoothed_spectrum") @@ -2528,16 +2530,10 @@ def _build_figure( legend_query += suffix legend_normalized += suffix - y_series_for_range = [dominant_signal] - if show_raw_trace: - y_series_for_range.append(raw_signal) - if show_baseline_trace: - y_series_for_range.append(baseline) - if show_intermediate_smoothed: - y_series_for_range.append(smoothed) + primary_series = [dominant_signal] if show_normalized_trace: - y_series_for_range.append(normalized) - y_range = _y_axis_range(*y_series_for_range) + primary_series.append(normalized) + y_range = primary_y_range(*primary_series) if settings["y_range_enabled"] and settings["y_min"] is not None and settings["y_max"] is not None: y_range = [settings["y_min"], settings["y_max"]] @@ -2554,6 +2550,7 @@ def _build_figure( name=legend_baseline, line=dict(color=_RAMAN_FIGURE_COLORS["baseline"], width=1.3 * line_scale, dash="dash"), opacity=0.65, + visible="legendonly" if show_corrected_trace else True, ) ) @@ -2566,6 +2563,7 @@ def _build_figure( name=legend_imported, line=dict(color=_RAMAN_FIGURE_COLORS["raw"], width=1.6 * line_scale), opacity=0.45 if has_overlay else 0.95, + visible="legendonly" if (show_corrected_trace or show_normalized_trace) else True, ) ) @@ -2613,18 +2611,21 @@ def _peak_display_y(index: int) -> float | None: return float(raw_signal[index]) return None - # Peak annotations (top 8 only to avoid clutter) - _ANNOTATION_MIN_SEP = 20.0 - annotated_positions: list[float] = [] peak_count = len(peaks) - for i, peak in enumerate(peaks[:_RAMAN_MAX_PEAK_CARDS] if settings["show_peaks"] else []): + peak_candidates = peaks[:_RAMAN_MAX_PEAK_CARDS] if settings["show_peaks"] else [] + label_indices = sparse_label_indices( + peak_candidates, + max_labels=4, + min_distance_ratio=0.08, + min_distance_floor=35.0, + ) + for i, peak in enumerate(peak_candidates): pos = peak.get("position") intensity = peak.get("intensity") if pos is None or not wavenumber: continue idx = min(range(len(wavenumber)), key=lambda i: abs(wavenumber[i] - pos)) - too_close = any(abs(pos - p) < _ANNOTATION_MIN_SEP for p in annotated_positions) - label = "" if too_close else f"{pos:.0f}" + label = f"{pos:.0f}" if i in label_indices else "" y_at = _peak_display_y(idx) if y_at is None: continue @@ -2639,58 +2640,53 @@ def _peak_display_y(index: int) -> float | None: textfont=dict(size=8, color="#DC2626"), name=f"Peak {pos:.0f}", showlegend=False, + hovertemplate=f"{pos:.1f} cm⁻¹ | I={float(intensity or 0):.3g}", ) ) - if label: - annotated_positions.append(pos) title_main = translate_ui(loc, "dash.analysis.figure.title_raman_main") - show_legend, legend_layout = spectral_legend_layout(len(fig.data), settings, theme=pt, legend_bg=legend_bg) - x_axis: dict[str, Any] = { - "showgrid": settings["show_grid"], - "showspikes": settings["show_spikes"], - "gridcolor": pt["grid"], - "linecolor": pt["grid"], - "tickfont": dict(size=12, color=pt["text"]), - "title_font": dict(size=13, color=pt["text"]), - "zeroline": False, - } + apply_materialscope_plot_theme( + fig, + settings, + theme=ui_theme, + title=title_main, + subtitle=sample_name, + view_mode="result", + scale_traces=False, + ) + fig.update_xaxes(title_text=translate_ui(loc, "dash.analysis.figure.axis_raman_shift")) + fig.update_yaxes(title_text=translate_ui(loc, "dash.analysis.figure.axis_intensity_au")) if settings["x_range_enabled"] and settings["x_min"] is not None and settings["x_max"] is not None: x_range = [settings["x_min"], settings["x_max"]] if settings["reverse_x_axis"]: x_range = list(reversed(x_range)) - x_axis["range"] = x_range + fig.update_xaxes(range=x_range) elif settings["reverse_x_axis"]: - x_axis["autorange"] = "reversed" - y_axis = { - "range": y_range, - "showgrid": settings["show_grid"], - "showspikes": settings["show_spikes"], - "gridcolor": pt["grid"], - "linecolor": pt["grid"], - "tickfont": dict(size=12, color=pt["text"]), - "title_font": dict(size=13, color=pt["text"]), - "zeroline": False, - } - fig.update_layout( - title=(f"{title_main}
{sample_name}"), - paper_bgcolor=pt["paper_bg"], - plot_bgcolor=pt["plot_bg"], - hovermode="x unified", - xaxis_title=translate_ui(loc, "dash.analysis.figure.axis_raman_shift"), - yaxis_title=translate_ui(loc, "dash.analysis.figure.axis_intensity_au"), - xaxis=x_axis, - yaxis=y_axis, - margin=dict(l=64, r=112 if show_legend and legend_layout.get("x") == 1.02 else 28, t=82, b=56), - height=460 if settings["compact"] else 520, - title_font=dict(size=20, color=pt["text"]), - title_x=0.01, - showlegend=show_legend, - legend=legend_layout, - hoverlabel=dict(bgcolor=hover_bg, font=dict(color=hover_fg)), - ) - fig.update_layout(meta={"plot_display_settings": settings}) - fig.update_layout(template=pt["template"]) + fig.update_xaxes(autorange="reversed") + if y_range is not None: + fig.update_yaxes(range=y_range) + if drawn_shapes: + fig.update_layout(shapes=drawn_shapes) + apply_materialscope_plot_theme( + fig, + settings, + theme=ui_theme, + title=title_main, + subtitle=sample_name, + view_mode="result", + scale_traces=False, + ) + fig.update_xaxes(title_text=translate_ui(loc, "dash.analysis.figure.axis_raman_shift")) + fig.update_yaxes(title_text=translate_ui(loc, "dash.analysis.figure.axis_intensity_au")) + if settings["x_range_enabled"] and settings["x_min"] is not None and settings["x_max"] is not None: + x_range = [settings["x_min"], settings["x_max"]] + if settings["reverse_x_axis"]: + x_range = list(reversed(x_range)) + fig.update_xaxes(range=x_range) + elif settings["reverse_x_axis"]: + fig.update_xaxes(autorange="reversed") + if y_range is not None: + fig.update_yaxes(range=y_range) peak_count_disp = summary.get("peak_count", peak_count) top_match_name = summary.get("top_match_name") @@ -2726,12 +2722,26 @@ def _peak_display_y(index: int) -> float | None: [ html.H5(translate_ui(loc, "dash.analysis.raman.figure.section_title"), className="mb-2"), html.P(run_caption, className="small text-muted mb-2"), - dcc.Graph(figure=fig, config=build_spectral_plotly_config(settings, filename="materialscope_raman_spectrum"), className="ta-plot"), + dcc.Graph( + id="raman-result-plot-graph", + figure=fig, + config=build_spectral_plotly_config(settings, filename="materialscope_raman_spectrum"), + className="ta-plot", + ), *diag_children, ] ) +def _spectral_shapes_from_relayout(relayout_data): + if not isinstance(relayout_data, dict): + return None + shapes = relayout_data.get("shapes") + if isinstance(shapes, list): + return [dict(shape) for shape in shapes if isinstance(shape, dict)] + return None + + def _build_top_match_panel(summary: dict, rows: list, loc: str) -> html.Div: if not rows: if str(summary.get("match_status") or "").lower() == "library_unavailable": diff --git a/dash_app/pages/xrd.py b/dash_app/pages/xrd.py index b629369..9a742a1 100644 --- a/dash_app/pages/xrd.py +++ b/dash_app/pages/xrd.py @@ -657,7 +657,7 @@ def _left_tabs() -> dbc.Tabs: _xrd_result_section( build_literature_compare_card( id_prefix="xrd", - class_name="xrd-literature-card mb-0 border-0 shadow-none bg-transparent", + class_name="xrd-literature-card mb-0", compact_toolbar=True, ), role="secondary", @@ -2047,6 +2047,7 @@ def _build_figure(project_id, dataset_key, summary, processing, ui_theme): axis_title=axis_title, ) return dcc.Graph( + id="xrd-result-plot-graph", figure=fig, config=build_plotly_config(plot_settings, filename="materialscope_xrd_diffractogram"), className="ta-plot", @@ -2195,6 +2196,15 @@ def display_xrd_result(result_id, _refresh, locale_data, project_id): ) +def _xrd_shapes_from_relayout(relayout_data): + if not isinstance(relayout_data, dict): + return None + shapes = relayout_data.get("shapes") + if isinstance(shapes, list): + return [dict(shape) for shape in shapes if isinstance(shape, dict)] + return None + + @callback( Output("xrd-result-figure", "children"), Input("xrd-result-cache", "data"), @@ -2202,8 +2212,9 @@ def display_xrd_result(result_id, _refresh, locale_data, project_id): Input("ui-theme", "data"), Input("ui-locale", "data"), State("project-id", "data"), + State("xrd-result-plot-graph", "relayoutData"), ) -def render_xrd_result_figure_area(cache, overlay_idx, ui_theme, locale_data, project_id): +def render_xrd_result_figure_area(cache, overlay_idx, ui_theme, locale_data, project_id, relayout_data): loc = _loc(locale_data) empty_msg = empty_result_msg(locale_data=locale_data) if not cache or not project_id: @@ -2251,8 +2262,10 @@ def render_xrd_result_figure_area(cache, overlay_idx, ui_theme, locale_data, pro loc=loc, sample_name=sample_name, axis_title=axis_title, + drawn_shapes=_xrd_shapes_from_relayout(relayout_data), ) return dcc.Graph( + id="xrd-result-plot-graph", figure=fig, config=build_plotly_config(plot_settings, filename="materialscope_xrd_diffractogram"), className="ta-plot", diff --git a/tests/test_core_plotting.py b/tests/test_core_plotting.py index 561aa29..88c22a5 100644 --- a/tests/test_core_plotting.py +++ b/tests/test_core_plotting.py @@ -85,6 +85,88 @@ def test_extract_plot_display_settings_reads_figure_meta(): assert extracted["export_scale"] == 4 +def test_apply_materialscope_plot_theme_preserves_shapes_and_sets_contrast(): + fig = go.Figure(data=[go.Scatter(x=[1, 2], y=[3, 4], mode="lines")]) + fig.add_shape(type="line", x0=1, y0=3, x1=2, y1=4, line={"color": "#000000"}) + + plotting.apply_materialscope_plot_theme(fig, theme="dark") + + assert len(fig.layout.shapes) == 1 + assert fig.layout.shapes[0].line.color == plotting.PLOT_THEME["dark"]["shape_line"] + assert fig.layout.shapes[0].line.width >= 2 + assert fig.layout.newshape.line.color == plotting.PLOT_THEME["dark"]["shape_line"] + + +def test_apply_materialscope_plot_theme_recolors_materialscope_default_shape_colors(): + fig = go.Figure(data=[go.Scatter(x=[1, 2], y=[3, 4], mode="lines")]) + fig.add_shape(type="line", x0=1, y0=3, x1=2, y1=4, line={"color": "#1C1A1A"}) + + plotting.apply_materialscope_plot_theme(fig, theme="dark") + assert fig.layout.shapes[0].line.color == plotting.PLOT_THEME["dark"]["shape_line"] + + plotting.apply_materialscope_plot_theme(fig, theme="light") + assert fig.layout.shapes[0].line.color == plotting.PLOT_THEME["light"]["shape_line"] + + +def test_apply_materialscope_plot_theme_preserves_custom_shape_color(): + fig = go.Figure(data=[go.Scatter(x=[1, 2], y=[3, 4], mode="lines")]) + fig.add_shape(type="line", x0=1, y0=3, x1=2, y1=4, line={"color": "rgba(148, 163, 184, 0.55)"}) + + plotting.apply_materialscope_plot_theme(fig, theme="dark") + + assert len(fig.layout.shapes) == 1 + assert fig.layout.shapes[0].line.color == "rgba(148, 163, 184, 0.55)" + + +def test_apply_materialscope_plot_theme_uses_compact_export_layout(): + fig = go.Figure(data=[go.Scatter(x=[1, 2], y=[3, 4], mode="lines")]) + + plotting.apply_materialscope_plot_theme(fig, title="Export", subtitle="Sample", for_export=True) + + assert fig.layout.width == plotting.DEFAULT_EXPORT_WIDTH + assert fig.layout.height == plotting.DEFAULT_EXPORT_HEIGHT + assert fig.layout.margin.b == 54 + assert fig.layout.margin.t <= 82 + + +def test_primary_y_range_ignores_non_finite_values_and_pads(): + result = plotting.primary_y_range([1, 2, "bad"], [float("nan"), 3]) + + assert result is not None + assert result[0] < 1 + assert result[1] > 3 + + +def test_primary_y_range_handles_numpy_like_iterables_without_truthiness(): + class ArrayLike: + def __bool__(self): + raise ValueError("ambiguous truth value") + + def __iter__(self): + return iter([1.0, 2.0, 3.0]) + + result = plotting.primary_y_range(ArrayLike(), (4.0, 5.0)) + + assert result is not None + assert result[0] < 1.0 + assert result[1] > 5.0 + + +def test_sparse_label_indices_prefers_strongest_spaced_points(): + points = [ + {"position": 10.0, "intensity": 1.0}, + {"position": 10.2, "intensity": 0.95}, + {"position": 20.0, "intensity": 0.8}, + {"position": 30.0, "intensity": 0.7}, + ] + + chosen = plotting.sparse_label_indices(points, max_labels=3, min_distance_floor=1.0) + + assert 0 in chosen + assert 1 not in chosen + assert len(chosen) <= 3 + + def test_normalize_plot_display_settings_rejects_non_finite_ranges(): settings = plotting.normalize_plot_display_settings( { diff --git a/tests/test_ftir_dash_page.py b/tests/test_ftir_dash_page.py index ec1c168..0123f00 100644 --- a/tests/test_ftir_dash_page.py +++ b/tests/test_ftir_dash_page.py @@ -485,6 +485,46 @@ def test_build_figure_returns_graph_when_curves_present(monkeypatch): s = str(fig_div) assert "ta-plot" in s assert "Peaks:" in s or "Tepeler:" in s + graph = next(c for c in fig_div.children if isinstance(c, dcc.Graph)) + assert graph.id == "ftir-result-plot-graph" + assert graph.figure.layout.meta["plot_view_mode"] == "result" + assert "plot_display_settings" in graph.figure.layout.meta + assert graph.figure.layout.hovermode == "x unified" + assert graph.figure.layout.legend.y >= 0 + assert graph.config["toImageButtonOptions"]["filename"] == "materialscope_ftir_spectrum" + raw_trace = next(trace for trace in graph.figure.data if "Imported" in str(trace.name)) + baseline_trace = next(trace for trace in graph.figure.data if "Baseline" in str(trace.name)) + assert raw_trace.visible == "legendonly" + assert baseline_trace.visible == "legendonly" + assert graph.figure.layout.yaxis.range[1] < 1.0 + + +def test_build_figure_preserves_drawn_shapes_with_theme_contrast(monkeypatch): + mod = _import_ftir_page() + import dash_app.api_client as api_client + + monkeypatch.setattr( + api_client, + "analysis_state_curves", + lambda *_: { + "temperature": [4000.0, 3000.0, 2000.0, 1000.0], + "raw_signal": [0.1, 0.5, 0.3, 0.2], + "smoothed": [0.12, 0.48, 0.32, 0.18], + "baseline": [0.05, 0.05, 0.05, 0.05], + "corrected": [0.07, 0.43, 0.27, 0.13], + "normalized": [], + "peaks": [], + "diagnostics": {"plot_normalized_primary_axis": False}, + }, + ) + shapes = [{"type": "line", "x0": 1000, "y0": 0.1, "x1": 2000, "y1": 0.2, "line": {"color": "#1C1A1A"}}] + + fig_div = mod._build_figure("proj", "ds", {}, "dark", "en", drawn_shapes=shapes) + graph = next(c for c in fig_div.children if isinstance(c, dcc.Graph)) + + assert mod._spectral_shapes_from_relayout({"shapes": shapes}) == shapes + assert len(graph.figure.layout.shapes) == 1 + assert graph.figure.layout.shapes[0].line.color == "#F2F0EB" def test_build_figure_no_data_when_empty_curves(monkeypatch): @@ -812,6 +852,36 @@ def test_build_figure_honors_plot_settings_for_traces_and_ranges(monkeypatch): assert list(graph.figure.layout.yaxis.range) == [-1.0, 1.0] +def test_build_figure_sparse_peak_labels_for_dense_clusters(monkeypatch): + mod = _import_ftir_page() + import dash_app.api_client as api_client + + peaks = [ + {"position": 1000.0 + idx * 8.0, "intensity": 1.0 - idx * 0.03} + for idx in range(10) + ] + monkeypatch.setattr( + api_client, + "analysis_state_curves", + lambda *_: { + "temperature": [900.0, 1000.0, 1100.0, 1200.0], + "raw_signal": [10.0, 20.0, 15.0, 12.0], + "smoothed": [0.12, 0.48, 0.32, 0.18], + "baseline": [0.05, 0.05, 0.05, 0.05], + "corrected": [0.07, 0.43, 0.27, 0.13], + "normalized": [], + "peaks": peaks, + "diagnostics": {"plot_normalized_primary_axis": False}, + }, + ) + + fig_div = mod._build_figure("proj", "ds", {}, "light", "en") + graph = next(c for c in fig_div.children if isinstance(c, dcc.Graph)) + labels = [trace.text[0] for trace in graph.figure.data if str(trace.name).startswith("Peak") and trace.text[0]] + assert len(labels) <= 4 + assert graph.figure.layout.yaxis.range[1] < 1.0 + + def test_render_ftir_plot_settings_chrome_localizes_options_and_placeholders(): mod = _import_ftir_page() diff --git a/tests/test_raman_dash_page.py b/tests/test_raman_dash_page.py index ba9f469..c8727a6 100644 --- a/tests/test_raman_dash_page.py +++ b/tests/test_raman_dash_page.py @@ -493,6 +493,46 @@ def test_build_figure_returns_graph_when_curves_present(monkeypatch): s = str(fig_div) assert "ta-plot" in s assert "Peaks:" in s or "Tepeler:" in s + graph = next(c for c in fig_div.children if isinstance(c, dcc.Graph)) + assert graph.id == "raman-result-plot-graph" + assert graph.figure.layout.meta["plot_view_mode"] == "result" + assert "plot_display_settings" in graph.figure.layout.meta + assert graph.figure.layout.hovermode == "x unified" + assert graph.figure.layout.legend.y >= 0 + assert graph.config["toImageButtonOptions"]["filename"] == "materialscope_raman_spectrum" + raw_trace = next(trace for trace in graph.figure.data if "Imported" in str(trace.name)) + baseline_trace = next(trace for trace in graph.figure.data if "Baseline" in str(trace.name)) + assert raw_trace.visible == "legendonly" + assert baseline_trace.visible == "legendonly" + assert graph.figure.layout.yaxis.range[1] < 1.0 + + +def test_build_figure_preserves_drawn_shapes_with_theme_contrast(monkeypatch): + mod = _import_raman_page() + import dash_app.api_client as api_client + + monkeypatch.setattr( + api_client, + "analysis_state_curves", + lambda *_: { + "temperature": [4000.0, 3000.0, 2000.0, 1000.0], + "raw_signal": [0.1, 0.5, 0.3, 0.2], + "smoothed": [0.12, 0.48, 0.32, 0.18], + "baseline": [0.05, 0.05, 0.05, 0.05], + "corrected": [0.07, 0.43, 0.27, 0.13], + "normalized": [], + "peaks": [], + "diagnostics": {"plot_normalized_primary_axis": False}, + }, + ) + shapes = [{"type": "line", "x0": 1000, "y0": 0.1, "x1": 2000, "y1": 0.2, "line": {"color": "#1C1A1A"}}] + + fig_div = mod._build_figure("proj", "ds", {}, "dark", "en", drawn_shapes=shapes) + graph = next(c for c in fig_div.children if isinstance(c, dcc.Graph)) + + assert mod._spectral_shapes_from_relayout({"shapes": shapes}) == shapes + assert len(graph.figure.layout.shapes) == 1 + assert graph.figure.layout.shapes[0].line.color == "#F2F0EB" def test_build_figure_no_data_when_empty_curves(monkeypatch): @@ -821,6 +861,36 @@ def test_build_figure_honors_plot_settings_for_traces_and_ranges(monkeypatch): assert list(graph.figure.layout.yaxis.range) == [-1.0, 1.0] +def test_build_figure_sparse_peak_labels_for_dense_clusters(monkeypatch): + mod = _import_raman_page() + import dash_app.api_client as api_client + + peaks = [ + {"position": 1000.0 + idx * 8.0, "intensity": 1.0 - idx * 0.03} + for idx in range(10) + ] + monkeypatch.setattr( + api_client, + "analysis_state_curves", + lambda *_: { + "temperature": [900.0, 1000.0, 1100.0, 1200.0], + "raw_signal": [10.0, 20.0, 15.0, 12.0], + "smoothed": [0.12, 0.48, 0.32, 0.18], + "baseline": [0.05, 0.05, 0.05, 0.05], + "corrected": [0.07, 0.43, 0.27, 0.13], + "normalized": [], + "peaks": peaks, + "diagnostics": {"plot_normalized_primary_axis": False}, + }, + ) + + fig_div = mod._build_figure("proj", "ds", {}, "light", "en") + graph = next(c for c in fig_div.children if isinstance(c, dcc.Graph)) + labels = [trace.text[0] for trace in graph.figure.data if str(trace.name).startswith("Peak") and trace.text[0]] + assert len(labels) <= 4 + assert graph.figure.layout.yaxis.range[1] < 1.0 + + def test_render_raman_plot_settings_chrome_localizes_options_and_placeholders(): mod = _import_raman_page() diff --git a/tests/test_xrd_dash_page.py b/tests/test_xrd_dash_page.py index 42c3e67..61a1ebd 100644 --- a/tests/test_xrd_dash_page.py +++ b/tests/test_xrd_dash_page.py @@ -3,6 +3,7 @@ from __future__ import annotations import inspect +import math import sys from pathlib import Path from types import SimpleNamespace @@ -382,11 +383,153 @@ def test_build_figure_uses_corrected_as_primary_trace(monkeypatch): ) assert isinstance(graph, dcc.Graph) + assert graph.id == "xrd-result-plot-graph" corrected_trace = next(trace for trace in graph.figure.data if trace.name == "Corrected Diffractogram") raw_trace = next(trace for trace in graph.figure.data if trace.name == "Raw Diffractogram") assert corrected_trace.line.width == 3.0 + assert corrected_trace.visible in (None, True) + assert raw_trace.visible == "legendonly" assert raw_trace.opacity < 0.4 assert graph.figure.layout.xaxis.title.text == "2theta (deg)" + assert graph.figure.layout.meta["plot_view_mode"] == "result" + assert "plot_display_settings" in graph.figure.layout.meta + assert graph.figure.layout.hovermode == "x unified" + assert graph.figure.layout.legend.y >= 0 + assert graph.figure.layout.yaxis.range[1] < 60 + assert graph.config["displayModeBar"] is True + assert graph.config["displaylogo"] is False + assert graph.config["responsive"] is True + assert graph.config["toImageButtonOptions"]["filename"] == "materialscope_xrd_diffractogram" + assert graph.config["toImageButtonOptions"]["width"] == 1400 + + +def test_xrd_relayout_shape_payload_is_carried_for_theme_rebuild(): + mod = _import_xrd_page() + + shapes = [{"type": "line", "x0": 10, "y0": 1, "x1": 20, "y1": 2, "line": {"color": "#000000"}}] + assert mod._xrd_shapes_from_relayout({"shapes": shapes}) == shapes + assert mod._xrd_shapes_from_relayout({"xaxis.range[0]": 10}) is None + + +def test_xrd_default_corrected_view_demotes_raw_from_autorange(): + from dash_app.components.xrd_result_plot import build_xrd_result_figure + + fig = build_xrd_result_figure( + axis=[10.0, 20.0, 30.0, 40.0], + raw_signal=[2000.0, 5000.0, 3500.0, 1500.0], + smoothed=[21.0, 48.0, 34.0, 16.0], + baseline=[4.0, 4.0, 4.0, 4.0], + corrected=[17.0, 44.0, 30.0, 12.0], + peaks=[], + selected_match=None, + plot_settings={}, + ui_theme="light", + loc="en", + sample_name="XRD Run A", + axis_title="2theta (deg)", + ) + + raw_trace = next(trace for trace in fig.data if trace.name == "Raw Diffractogram") + corrected_trace = next(trace for trace in fig.data if trace.name == "Corrected Diffractogram") + assert raw_trace.visible == "legendonly" + assert raw_trace.showlegend is True + assert corrected_trace.visible in (None, True) + assert corrected_trace.line.width == 3.0 + assert fig.layout.yaxis.range[1] < 60 + + +def test_xrd_dense_peak_cluster_labels_are_sparse_and_readable(): + from dash_app.components.xrd_result_plot import build_xrd_result_figure + + peaks = [ + {"position": 10.0, "intensity": 100.0}, + {"position": 10.2, "intensity": 95.0}, + {"position": 10.4, "intensity": 90.0}, + {"position": 10.6, "intensity": 85.0}, + {"position": 10.8, "intensity": 80.0}, + {"position": 16.0, "intensity": 70.0}, + {"position": 16.3, "intensity": 68.0}, + {"position": 24.0, "intensity": 65.0}, + {"position": 32.0, "intensity": 60.0}, + {"position": 40.0, "intensity": 55.0}, + ] + fig = build_xrd_result_figure( + axis=[10.0, 20.0, 30.0, 40.0], + raw_signal=[20.0, 50.0, 35.0, 15.0], + smoothed=[21.0, 48.0, 34.0, 16.0], + baseline=[4.0, 4.0, 4.0, 4.0], + corrected=[17.0, 44.0, 30.0, 12.0], + peaks=peaks, + selected_match=None, + plot_settings={}, + ui_theme="light", + loc="en", + sample_name="XRD Dense Peaks", + axis_title="2theta (deg)", + ) + + label_trace = next(trace for trace in fig.data if trace.mode == "text") + visible_labels = [label for label in label_trace.text if label] + assert len(visible_labels) <= 4 + assert len(visible_labels) < len(peaks) + assert "10.00 deg" in visible_labels[0] + + +def test_build_xrd_result_figure_preserves_log_y_range_after_shared_theme(): + from dash_app.components.xrd_result_plot import build_xrd_result_figure + + fig = build_xrd_result_figure( + axis=[10.0, 20.0, 30.0, 40.0], + raw_signal=[20.0, 50.0, 35.0, 15.0], + smoothed=[21.0, 48.0, 34.0, 16.0], + baseline=[4.0, 4.0, 4.0, 4.0], + corrected=[17.0, 44.0, 30.0, 12.0], + peaks=[], + selected_match=None, + plot_settings={ + "log_y": True, + "y_range_enabled": True, + "y_min": 1.0, + "y_max": 100.0, + "x_range_enabled": True, + "x_min": 12.0, + "x_max": 38.0, + }, + ui_theme="light", + loc="en", + sample_name="XRD Run Log", + axis_title="2theta (deg)", + ) + + assert fig.layout.yaxis.type == "log" + assert list(fig.layout.yaxis.range) == [math.log10(1.0), math.log10(100.0)] + assert list(fig.layout.xaxis.range) == [12.0, 38.0] + assert fig.layout.xaxis.title.text == "2theta (deg)" + assert fig.layout.yaxis.title.text == "Intensity (a.u.)" + assert fig.layout.meta["plot_view_mode"] == "result" + + +def test_build_xrd_result_figure_preserves_drawn_shapes_with_theme_contrast(): + from dash_app.components.xrd_result_plot import build_xrd_result_figure + + fig = build_xrd_result_figure( + axis=[10.0, 20.0, 30.0, 40.0], + raw_signal=[20.0, 50.0, 35.0, 15.0], + smoothed=[21.0, 48.0, 34.0, 16.0], + baseline=[4.0, 4.0, 4.0, 4.0], + corrected=[17.0, 44.0, 30.0, 12.0], + peaks=[], + selected_match=None, + plot_settings={}, + ui_theme="dark", + loc="en", + sample_name="XRD Drawing", + axis_title="2theta (deg)", + drawn_shapes=[{"type": "line", "x0": 10, "y0": 1, "x1": 20, "y1": 2, "line": {"color": "#000000"}}], + ) + + assert len(fig.layout.shapes) == 1 + assert fig.layout.shapes[0].line.color != "#000000" def test_build_figure_handles_missing_primary_signal(monkeypatch):