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):