hussain2010/Line_Graphs_Customization
1
1import streamlit as st2import pandas as pd3import matplotlib.pyplot as plt4import numpy as np5from io import BytesIO6import matplotlib as mpl7from scipy.signal import savgol_filter, butter, filtfilt, medfilt8from scipy.ndimage import gaussian_filter1d9from statsmodels.nonparametric.smoothers_lowess import lowess10 11# =========================================================12# GLOBAL DEFAULT FONT (does NOT remove font selector)13# =========================================================14mpl.rcParams["font.family"] = "Times New Roman"15mpl.rcParams["axes.unicode_minus"] = False16 17SPEED_OF_LIGHT = 299_792_458.0 # m/s18 19FREQ_UNITS = {20 "Hz": 1.0,21 "kHz": 1e3,22 "MHz": 1e6,23 "GHz": 1e9,24 "THz": 1e12,25}26 27LENGTH_UNITS = {28 "m": 1.0,29 "cm": 1e-2,30 "mm": 1e-3,31 "um": 1e-6,32 "nm": 1e-9,33 "pm": 1e-12,34}35 36 37# =========================================================38# Download function39# =========================================================40def download_button(fig, file_name, file_format, dpi):41 buffer = BytesIO()42 fig.savefig(buffer, format=file_format.lower(), dpi=dpi, bbox_inches="tight")43 buffer.seek(0)44 45 mime_map = {46 "png": "image/png",47 "jpg": "image/jpeg",48 "jpeg": "image/jpeg",49 "svg": "image/svg+xml",50 "pdf": "application/pdf",51 "eps": "application/postscript",52 "tif": "image/tiff",53 "tiff": "image/tiff",54 }55 56 st.download_button(57 label=f"Download as {file_format.upper()} ({dpi} DPI)",58 data=buffer,59 file_name=f"{file_name}_{dpi}dpi.{file_format.lower()}",60 mime=mime_map.get(file_format.lower(), "application/octet-stream"),61 )62 63 64# =========================================================65# Smoothing function (preserved; only safer edge handling)66# =========================================================67def apply_smoothing(data, method, **params):68 data = np.asarray(data, dtype=float)69 70 if method == "None":71 return data72 73 elif method == "Moving Average":74 window = int(params.get("window_size", 5))75 return pd.Series(data).rolling(window, center=True, min_periods=1).mean().to_numpy()76 77 elif method == "Gaussian":78 sigma = float(params.get("sigma", 2.0))79 return gaussian_filter1d(data, sigma=sigma)80 81 elif method == "Savitzky-Golay":82 window = int(params.get("window_size", 21))83 poly = int(params.get("poly_order", 3))84 window = _make_valid_odd_window(window, len(data), poly + 2)85 return savgol_filter(data, window, poly)86 87 elif method == "Median":88 kernel = int(params.get("kernel_size", 5))89 kernel = _make_valid_odd_window(kernel, len(data), 1)90 return medfilt(data, kernel_size=kernel)91 92 elif method == "Combined":93 kernel = int(params.get("kernel_size", 5))94 window = int(params.get("window_size", 21))95 poly = int(params.get("poly_order", 3))96 kernel = _make_valid_odd_window(kernel, len(data), 1)97 window = _make_valid_odd_window(window, len(data), poly + 2)98 temp = medfilt(data, kernel_size=kernel)99 return savgol_filter(temp, window, poly)100 101 elif method == "Exponential Moving Average":102 span = int(params.get("span", 10))103 return pd.Series(data).ewm(span=span).mean().to_numpy()104 105 elif method == "LOWESS":106 frac = float(params.get("frac", 0.1))107 return lowess(data, np.arange(len(data)), frac=frac, return_sorted=False)108 109 elif method == "Butterworth":110 order = int(params.get("order", 3))111 cutoff = float(params.get("cutoff", 0.05))112 b, a = butter(order, cutoff, btype="low")113 return filtfilt(b, a, data)114 115 elif method == "Fourier Transform":116 keep = float(params.get("keep_fraction", 0.1))117 keep = np.clip(keep, 0.0, 1.0)118 fft_vals = np.fft.fft(data)119 n = len(fft_vals)120 fft_vals[int(n * keep): int(n * (1 - keep))] = 0121 return np.real(np.fft.ifft(fft_vals))122 123 return data124 125 126def _make_valid_odd_window(window, data_len, minimum):127 window = max(int(window), int(minimum))128 if window % 2 == 0:129 window += 1130 if window > data_len:131 window = data_len if data_len % 2 == 1 else max(1, data_len - 1)132 if window < minimum:133 window = minimum if minimum % 2 == 1 else minimum + 1134 if window > data_len:135 window = data_len if data_len % 2 == 1 else max(1, data_len - 1)136 return max(window, 1)137 138 139# =========================================================140# Numeric cleaning / parsing helpers141# =========================================================142def clean_column(column):143 if pd.api.types.is_numeric_dtype(column):144 return pd.to_numeric(column, errors="coerce")145 146 cleaned = (147 column.astype(str)148 .str.replace(",", "", regex=False)149 .str.extract(r"([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)", expand=False)150 )151 return pd.to_numeric(cleaned, errors="coerce")152 153 154def parse_optional_float(text):155 if text is None:156 return None157 if isinstance(text, (int, float, np.integer, np.floating)):158 return float(text)159 160 text = str(text).strip()161 if text == "":162 return None163 164 try:165 return float(text)166 except ValueError:167 return None168 169 170# =========================================================171# Unit conversion helpers172# =========================================================173def linear_to_db(values, mode="Power (10log10)"):174 values = np.asarray(values, dtype=float)175 out = np.full(values.shape, np.nan, dtype=float)176 valid = values > 0177 factor = 10.0 if mode == "Power (10log10)" else 20.0178 out[valid] = factor * np.log10(values[valid])179 return out, np.count_nonzero(~valid)180 181 182 183def db_to_linear(values, mode="Power (10log10)"):184 values = np.asarray(values, dtype=float)185 factor = 10.0 if mode == "Power (10log10)" else 20.0186 return np.power(10.0, values / factor), 0187 188 189 190def convert_series(values, scale_option, config=None, axis_name="Axis"):191 values = np.asarray(values, dtype=float)192 config = config or {}193 warnings = []194 195 if scale_option == "None":196 return values, warnings197 198 if scale_option == "Hz → MHz":199 return values / 1e6, warnings200 201 if scale_option == "Hz → GHz":202 return values / 1e9, warnings203 204 if scale_option == "mm → cm":205 return values / 10.0, warnings206 207 if scale_option == "mm → m":208 return values / 1000.0, warnings209 210 if scale_option == "cm → mm":211 return values * 10.0, warnings212 213 if scale_option == "m → mm":214 return values * 1000.0, warnings215 216 if scale_option == "Frequency → Wavelength":217 in_unit = config.get("freq_input_unit", "GHz")218 out_unit = config.get("wave_output_unit", "nm")219 freq_hz = values * FREQ_UNITS[in_unit]220 221 out = np.full(freq_hz.shape, np.nan, dtype=float)222 valid = freq_hz != 0223 out[valid] = (SPEED_OF_LIGHT / freq_hz[valid]) / LENGTH_UNITS[out_unit]224 invalid_count = np.count_nonzero(~valid)225 if invalid_count:226 warnings.append(227 f"{axis_name}: {invalid_count} zero-value points were skipped during frequency-to-wavelength conversion."228 )229 return out, warnings230 231 if scale_option == "Wavelength → Frequency":232 in_unit = config.get("wave_input_unit", "nm")233 out_unit = config.get("freq_output_unit", "GHz")234 wavelength_m = values * LENGTH_UNITS[in_unit]235 236 out = np.full(wavelength_m.shape, np.nan, dtype=float)237 valid = wavelength_m != 0238 out[valid] = (SPEED_OF_LIGHT / wavelength_m[valid]) / FREQ_UNITS[out_unit]239 invalid_count = np.count_nonzero(~valid)240 if invalid_count:241 warnings.append(242 f"{axis_name}: {invalid_count} zero-value points were skipped during wavelength-to-frequency conversion."243 )244 return out, warnings245 246 if scale_option == "Linear → dB":247 mode = config.get("db_mode", "Power (10log10)")248 out, invalid_count = linear_to_db(values, mode=mode)249 if invalid_count:250 warnings.append(251 f"{axis_name}: {invalid_count} non-positive points were skipped during linear-to-dB conversion."252 )253 return out, warnings254 255 if scale_option == "dB → Linear":256 mode = config.get("db_mode", "Power (10log10)")257 out, _ = db_to_linear(values, mode=mode)258 return out, warnings259 260 return values, warnings261 262 263# =========================================================264# App config265# =========================================================266st.set_page_config(layout="wide")267st.title("Advanced CSV Data Visualization App")268 269uploaded_file = st.file_uploader("Upload your CSV file", type="csv")270 271if uploaded_file is not None:272 try:273 df = pd.read_csv(uploaded_file)274 df = df.apply(clean_column)275 276 if df.empty or df.dropna(how="all").empty:277 raise ValueError("No usable numeric data was found in the uploaded CSV file.")278 279 # =================================================280 # Sidebar281 # =================================================282 with st.sidebar:283 st.subheader("Graph Settings")284 col1, col2 = st.columns(2)285 286 # ---------------- X AXIS ----------------287 with col1:288 x_column = st.selectbox("X-axis Column:", options=df.columns, index=0)289 x_label = st.text_input("X-axis Label:", value="Frequency")290 x_unit = st.text_input("X-axis Unit:", value="GHz")291 scale_option = st.selectbox(292 "X Scaling Option:",293 [294 "None",295 "Hz → MHz",296 "Hz → GHz",297 "mm → cm",298 "mm → m",299 "cm → mm",300 "m → mm",301 "Frequency → Wavelength",302 "Wavelength → Frequency",303 "Linear → dB",304 "dB → Linear",305 ],306 index=2,307 )308 309 x_conversion_config = {}310 if scale_option == "Frequency → Wavelength":311 x_conversion_config["freq_input_unit"] = st.selectbox(312 "X Input Frequency Unit:",313 list(FREQ_UNITS.keys()),314 index=3,315 key="x_freq_input_unit",316 )317 x_conversion_config["wave_output_unit"] = st.selectbox(318 "X Output Wavelength Unit:",319 list(LENGTH_UNITS.keys()),320 index=4,321 key="x_wave_output_unit",322 )323 elif scale_option == "Wavelength → Frequency":324 x_conversion_config["wave_input_unit"] = st.selectbox(325 "X Input Wavelength Unit:",326 list(LENGTH_UNITS.keys()),327 index=4,328 key="x_wave_input_unit",329 )330 x_conversion_config["freq_output_unit"] = st.selectbox(331 "X Output Frequency Unit:",332 list(FREQ_UNITS.keys()),333 index=3,334 key="x_freq_output_unit",335 )336 elif scale_option in ["Linear → dB", "dB → Linear"]:337 x_conversion_config["db_mode"] = st.selectbox(338 "X dB Conversion Mode:",339 ["Power (10log10)", "Amplitude (20log10)"],340 index=0,341 key="x_db_mode",342 )343 344 x_min_text = st.text_input("Lower X-axis Limit:", value="")345 x_max_text = st.text_input("Upper X-axis Limit:", value="")346 x_step = st.number_input("X-axis Step Size:", value=5.0, format="%f")347 x_tick_position = st.selectbox(348 "X-axis Tick Position:", ["out", "in", "inout"], index=2349 )350 show_x_tick_marks = st.checkbox(351 "Show X-axis division marks", value=True352 )353 show_x_tick_labels = st.checkbox(354 "Show X-axis labels", value=True355 )356 357 # ---------------- Y AXIS ----------------358 with col2:359 default_y_columns = [col for col in df.columns if col != x_column]360 361 if "y_columns_state" not in st.session_state:362 st.session_state.y_columns_state = default_y_columns363 364 st.session_state.y_columns_state = [365 col for col in st.session_state.y_columns_state if col != x_column366 ]367 if not st.session_state.y_columns_state:368 st.session_state.y_columns_state = default_y_columns369 370 y_columns = st.multiselect(371 "Y-axis Column(s):",372 options=[col for col in df.columns if col != x_column],373 default=st.session_state.y_columns_state,374 )375 st.session_state.y_columns_state = y_columns376 377 if st.button("➕ Add all remaining columns"):378 st.session_state.y_columns_state = [379 col for col in df.columns if col != x_column380 ]381 st.rerun()382 383 if st.button("❌ Clear Y-axis selection"):384 st.session_state.y_columns_state = []385 st.rerun()386 387 y_label = st.text_input("Y-axis Label:", value="S21")388 y_unit = st.text_input("Y-axis Unit:", value="dB")389 y_scale_option = st.selectbox(390 "Y Scaling Option:",391 [392 "None",393 "Hz → MHz",394 "Hz → GHz",395 "mm → cm",396 "mm → m",397 "cm → mm",398 "m → mm",399 "Frequency → Wavelength",400 "Wavelength → Frequency",401 "Linear → dB",402 "dB → Linear",403 ],404 index=0,405 )406 407 y_conversion_config = {}408 if y_scale_option == "Frequency → Wavelength":409 y_conversion_config["freq_input_unit"] = st.selectbox(410 "Y Input Frequency Unit:",411 list(FREQ_UNITS.keys()),412 index=3,413 key="y_freq_input_unit",414 )415 y_conversion_config["wave_output_unit"] = st.selectbox(416 "Y Output Wavelength Unit:",417 list(LENGTH_UNITS.keys()),418 index=4,419 key="y_wave_output_unit",420 )421 elif y_scale_option == "Wavelength → Frequency":422 y_conversion_config["wave_input_unit"] = st.selectbox(423 "Y Input Wavelength Unit:",424 list(LENGTH_UNITS.keys()),425 index=4,426 key="y_wave_input_unit",427 )428 y_conversion_config["freq_output_unit"] = st.selectbox(429 "Y Output Frequency Unit:",430 list(FREQ_UNITS.keys()),431 index=3,432 key="y_freq_output_unit",433 )434 elif y_scale_option in ["Linear → dB", "dB → Linear"]:435 y_conversion_config["db_mode"] = st.selectbox(436 "Y dB Conversion Mode:",437 ["Power (10log10)", "Amplitude (20log10)"],438 index=0,439 key="y_db_mode",440 )441 442 y_min_text = st.text_input("Lower Y-axis Limit:", value="")443 y_max_text = st.text_input("Upper Y-axis Limit:", value="")444 y_step = st.number_input("Y-axis Step Size:", value=10.0, format="%f")445 y_tick_position = st.selectbox(446 "Y-axis Tick Position:", ["out", "in", "inout"], index=2447 )448 show_y_tick_marks = st.checkbox(449 "Show Y-axis division marks", value=True450 )451 show_y_tick_labels = st.checkbox(452 "Show Y-axis labels", value=True453 )454 455 # ---------------- TITLE & FONT ----------------456 st.subheader("Graph Font Settings")457 title = st.text_input("Graph Title:", value="MWPF, 100Km DM, S21, Far-field")458 font_theme = st.selectbox(459 "Graph Font Family (applies to all graph text):",460 ["Times New Roman", "Arial", "Courier New", "Helvetica", "Verdana"],461 index=0,462 )463 font_style_choice = st.selectbox(464 "Title Font Style:", ["Normal", "Italic", "Bold"], index=0465 )466 use_global_font_size = st.checkbox(467 "Use one font size for the whole graph", value=False468 )469 470 if use_global_font_size:471 whole_graph_font_size = st.slider(472 "Whole Graph Font Size:", 6, 30, 14473 )474 title_font_size = whole_graph_font_size475 x_label_font_size = whole_graph_font_size476 y_label_font_size = whole_graph_font_size477 x_tick_font_size = whole_graph_font_size478 y_tick_font_size = whole_graph_font_size479 legend_font_size = whole_graph_font_size480 else:481 title_font_size = st.slider("Title Font Size:", 10, 30, 14)482 x_label_font_size = st.slider(483 "X-axis Label Font Size:", 8, 24, 14484 )485 y_label_font_size = st.slider(486 "Y-axis Label Font Size:", 8, 24, 14487 )488 x_tick_font_size = st.slider(489 "X-axis Tick Font Size:", 6, 22, 12490 )491 y_tick_font_size = st.slider(492 "Y-axis Tick Font Size:", 6, 22, 12493 )494 legend_font_size = st.slider("Legend Font Size:", 6, 20, 10)495 496 # ---------------- GRID ----------------497 st.subheader("Grid Settings")498 show_grid = st.checkbox("Show Grid", value=True)499 show_minor_grid = st.checkbox("Show Sub-grid (Minor Grid)", value=False)500 grid_direction = st.selectbox("Grid Direction:", ["x", "y", "both"], index=2)501 grid_line_style = st.selectbox(502 "Grid Line Style:", ["-", "--", "-.", ":", "None"], index=0503 )504 grid_color = st.color_picker("Grid Line Color:", "#DDDDDD")505 grid_line_width = st.slider("Grid Line Width:", 0.5, 2.5, 1.0)506 507 # ---------------- SMOOTHING ----------------508 st.subheader("Advanced Smoothing Settings")509 510 smoothing_method = st.selectbox(511 "Smoothing Method:",512 [513 "None",514 "Moving Average",515 "Gaussian",516 "Savitzky-Golay",517 "Median",518 "Combined",519 "Exponential Moving Average",520 "LOWESS",521 "Butterworth",522 "Fourier Transform",523 ],524 )525 526 smoothing_params = {}527 528 if smoothing_method == "Moving Average":529 smoothing_params["window_size"] = st.slider("Window Size", 3, 101, 5, 2)530 531 elif smoothing_method == "Gaussian":532 smoothing_params["sigma"] = st.slider("Sigma", 0.1, 10.0, 2.0, 0.1)533 534 elif smoothing_method == "Savitzky-Golay":535 smoothing_params["window_size"] = st.slider("Window Size", 5, 101, 21, 2)536 smoothing_params["poly_order"] = st.slider("Polynomial Order", 1, 5, 3)537 538 elif smoothing_method == "Median":539 smoothing_params["kernel_size"] = st.slider("Kernel Size", 3, 51, 5, 2)540 541 elif smoothing_method == "Combined":542 smoothing_params["kernel_size"] = st.slider("Median Kernel", 3, 51, 5, 2)543 smoothing_params["window_size"] = st.slider("SG Window", 5, 101, 21, 2)544 smoothing_params["poly_order"] = st.slider("Polynomial Order", 1, 5, 3)545 546 elif smoothing_method == "Exponential Moving Average":547 smoothing_params["span"] = st.slider("EMA Span", 1, 50, 10)548 549 elif smoothing_method == "LOWESS":550 smoothing_params["frac"] = st.slider(551 "LOWESS Fraction", 0.01, 0.5, 0.1, 0.01552 )553 554 elif smoothing_method == "Butterworth":555 smoothing_params["order"] = st.slider("Filter Order", 1, 10, 3)556 smoothing_params["cutoff"] = st.slider(557 "Cutoff Frequency", 0.01, 0.5, 0.05, 0.01558 )559 560 elif smoothing_method == "Fourier Transform":561 smoothing_params["keep_fraction"] = st.slider(562 "Keep Fraction", 0.01, 1.0, 0.1, 0.01563 )564 565 # ---------------- MARKERS ----------------566 marker_legend_locations = [567 "upper right",568 "upper center",569 "upper left",570 "center right",571 "center",572 "center left",573 "lower right",574 "lower center",575 "lower left",576 ]577 578 marker_line_styles = {579 "Solid": "-",580 "Dashed": "--",581 "Dash-Dot": "-.",582 "Dotted": ":",583 }584 585 st.subheader("Vertical Marker Settings")586 marker_x_values = st.text_input(587 "Enter X-axis Values for Vertical Markers (comma-separated):", value=""588 )589 vmarker_color = st.color_picker("Vertical Marker Color:", "#FF0000")590 vmarker_line_style_name = st.selectbox(591 "Vertical Marker Line Style:",592 list(marker_line_styles.keys()),593 index=1,594 )595 vmarker_line_style = marker_line_styles[vmarker_line_style_name]596 597 vmarker_candidates = []598 if marker_x_values.strip():599 try:600 vmarker_candidates = [601 float(v.strip())602 for v in marker_x_values.split(",")603 if v.strip() != ""604 ]605 except Exception:606 vmarker_candidates = []607 608 selected_vmarkers = []609 if vmarker_candidates:610 selected_vmarkers = st.multiselect(611 "Select Vertical Marker(s):",612 options=vmarker_candidates,613 default=vmarker_candidates,614 )615 616 show_vmarker_values = st.checkbox(617 "Show line values at selected vertical markers", value=True618 )619 620 vmarker_legend_enable = st.checkbox(621 "Show vertical marker legend (marker values)", value=True622 )623 vmarker_legend_location = st.selectbox(624 "Vertical Marker Legend Location:",625 marker_legend_locations,626 index=0,627 )628 629 vvalues_legend_enable = st.checkbox(630 "Show vertical marker intersection values in legend", value=True631 )632 vvalues_legend_location = st.selectbox(633 "Vertical Values Legend Location:",634 marker_legend_locations,635 index=2,636 )637 638 st.subheader("Horizontal Marker Settings")639 marker_y_values = st.text_input(640 "Enter Y-axis Values for Horizontal Markers (comma-separated):", value=""641 )642 hmarker_color = st.color_picker("Horizontal Marker Color:", "#0000FF")643 hmarker_line_style_name = st.selectbox(644 "Horizontal Marker Line Style:",645 list(marker_line_styles.keys()),646 index=1,647 )648 hmarker_line_style = marker_line_styles[hmarker_line_style_name]649 650 hmarker_candidates = []651 if marker_y_values.strip():652 try:653 hmarker_candidates = [654 float(v.strip())655 for v in marker_y_values.split(",")656 if v.strip() != ""657 ]658 except Exception:659 hmarker_candidates = []660 661 selected_hmarkers = []662 if hmarker_candidates:663 selected_hmarkers = st.multiselect(664 "Select Horizontal Marker(s):",665 options=hmarker_candidates,666 default=hmarker_candidates,667 )668 669 show_hmarker_values = st.checkbox(670 "Show line values at selected horizontal markers", value=True671 )672 673 hmarker_legend_enable = st.checkbox(674 "Show horizontal marker legend (marker values)", value=True675 )676 hmarker_legend_location = st.selectbox(677 "Horizontal Marker Legend Location:",678 marker_legend_locations,679 index=6,680 )681 682 hvalues_legend_enable = st.checkbox(683 "Show horizontal marker intersection values in legend", value=True684 )685 hvalues_legend_location = st.selectbox(686 "Horizontal Values Legend Location:",687 marker_legend_locations,688 index=8,689 )690 691 # ---------------- DOWNLOAD ----------------692 dpi = st.selectbox("Select DPI for Download:", [100, 200, 300, 600], index=2)693 file_format = st.selectbox(694 "Select File Format for Download:",695 ["PNG", "JPG", "SVG", "PDF", "EPS", "TIFF"],696 index=0,697 )698 699 # ---------------- LEGEND ----------------700 st.subheader("Legend Customization")701 if use_global_font_size:702 st.caption(f"Legend font size follows the whole graph font size: {legend_font_size}")703 legend_font_weight = st.selectbox("Font Weight:", ["Normal", "Bold"], index=0)704 legend_bg_color = st.color_picker("Background Color:", "#FFFFFF")705 legend_border_color = st.color_picker("Border Color:", "#000000")706 legend_border_width = st.slider("Border Width:", 0.5, 2.0, 1.0)707 legend_alpha = st.slider(708 "Frame Alpha (0 = transparent, 1 = opaque):", 0.0, 1.0, 0.5709 )710 legend_title = st.text_input("Legend Title:", value="")711 legend_location = st.selectbox(712 "Legend Location:",713 marker_legend_locations,714 index=1,715 )716 legend_columns = st.selectbox("Legend Columns:", [1, 2, 3, 4, 5], index=1)717 718 # =================================================719 # LINE STYLE SETTINGS720 # =================================================721 st.subheader("Line Style Settings")722 723 unite_lines = st.checkbox(724 "Unite line formatting for all line graphs", value=False725 )726 727 unify_ls = False728 unify_ms = False729 unify_color = False730 unify_lw = False731 732 line_styles = {733 "Solid": "-",734 "Dashed": "--",735 "Dash-Dot": "-.",736 "Dotted": ":",737 }738 739 marker_styles = {740 "None": "",741 "Circle": "o",742 "Square": "s",743 "Star": "*",744 "Diamond": "D",745 "Triangle": "^",746 "Pentagon": "p",747 "Hexagon": "H",748 "Plus": "+",749 "X": "x",750 }751 752 auto_colors = plt.rcParams["axes.prop_cycle"].by_key()["color"]753 auto_line_styles = list(line_styles.values())754 auto_marker_styles = [m for m in marker_styles.values() if m != ""]755 756 if "auto_format_seed" not in st.session_state:757 st.session_state.auto_format_seed = int(np.random.randint(0, 1_000_000))758 759 unified_line_width = 1.0760 761 if unite_lines:762 st.markdown("**Apply unified formatting for:**")763 unify_ls = st.checkbox("Line Style", value=True)764 unify_ms = st.checkbox("Marker Style", value=False)765 unify_color = st.checkbox("Line Color", value=False)766 unify_lw = st.checkbox("Line Width", value=True)767 768 st.caption(769 "Checked line color, style, and marker are auto-distributed across the selected graph lines. "770 "Checked line width uses one identical width for all selected graph lines."771 )772 773 if unify_lw:774 unified_line_width = st.slider(775 "Select unified line width for all graph lines", 0.5, 3.0, 1.0776 )777 st.caption(778 "This shared width overrides the individual line-width sliders below."779 )780 781 if unify_color or unify_ls or unify_ms:782 if st.button("Reshuffle auto formatting"):783 st.session_state.auto_format_seed = int(784 np.random.randint(0, 1_000_000)785 )786 787 def build_shuffled_pool(pool, seed_offset):788 if not pool:789 return []790 pool_array = np.array(pool, dtype=object)791 rng = np.random.default_rng(int(st.session_state.auto_format_seed) + seed_offset)792 return pool_array[rng.permutation(len(pool_array))].tolist()793 794 shuffled_colors = build_shuffled_pool(auto_colors, 11)795 shuffled_line_styles = build_shuffled_pool(auto_line_styles, 23)796 shuffled_marker_styles = build_shuffled_pool(auto_marker_styles, 37)797 798 style_settings = {}799 800 for i, y_column in enumerate(y_columns):801 with st.expander(f"Line Style for '{y_column}'", expanded=False):802 default_color = auto_colors[i % len(auto_colors)]803 804 color = st.color_picker(805 f"Color for '{y_column}'",806 default_color,807 key=f"color_{y_column}",808 )809 810 line_style_name = st.selectbox(811 "Line Style",812 list(line_styles.keys()),813 key=f"ls_{y_column}",814 )815 816 marker_style_name = st.selectbox(817 "Marker Style",818 list(marker_styles.keys()),819 key=f"ms_{y_column}",820 )821 822 line_width = st.slider(823 "Line Width", 0.5, 3.0, 1.0, key=f"lw_{y_column}"824 )825 826 applied_color = (827 shuffled_colors[i % len(shuffled_colors)]828 if unite_lines and unify_color and len(shuffled_colors) > 0829 else color830 )831 applied_line_style = (832 shuffled_line_styles[i % len(shuffled_line_styles)]833 if unite_lines and unify_ls and len(shuffled_line_styles) > 0834 else line_styles[line_style_name]835 )836 applied_marker_style = (837 shuffled_marker_styles[i % len(shuffled_marker_styles)]838 if unite_lines and unify_ms and len(shuffled_marker_styles) > 0839 else marker_styles[marker_style_name]840 )841 applied_line_width = (842 unified_line_width843 if unite_lines and unify_lw844 else line_width845 )846 847 style_settings[y_column] = {848 "color": applied_color,849 "line_style": applied_line_style,850 "marker_style": applied_marker_style,851 "line_width": applied_line_width,852 }853 854 855 # =================================================856 # Plot857 # =================================================858 st.subheader("Graph Output")859 860 if st.button("Generate Graph"):861 if not y_columns:862 st.warning("Please select at least one Y-axis column.")863 st.stop()864 865 fig, ax = plt.subplots()866 messages = []867 868 x_min = parse_optional_float(x_min_text)869 x_max = parse_optional_float(x_max_text)870 y_min = parse_optional_float(y_min_text)871 y_max = parse_optional_float(y_max_text)872 873 if x_min_text.strip() and x_min is None:874 messages.append("X-axis lower limit could not be parsed and was ignored.")875 if x_max_text.strip() and x_max is None:876 messages.append("X-axis upper limit could not be parsed and was ignored.")877 if y_min_text.strip() and y_min is None:878 messages.append("Y-axis lower limit could not be parsed and was ignored.")879 if y_max_text.strip() and y_max is None:880 messages.append("Y-axis upper limit could not be parsed and was ignored.")881 882 x_raw = pd.to_numeric(df[x_column], errors="coerce").to_numpy(dtype=float)883 x_converted, x_warnings = convert_series(884 x_raw,885 scale_option,886 config=x_conversion_config,887 axis_name="X-axis",888 )889 messages.extend(x_warnings)890 891 plotted_series = {}892 893 for col in y_columns:894 y_raw = pd.to_numeric(df[col], errors="coerce").to_numpy(dtype=float)895 y_converted, y_warnings = convert_series(896 y_raw,897 y_scale_option,898 config=y_conversion_config,899 axis_name=f"Y-axis ({col})",900 )901 messages.extend(y_warnings)902 903 mask = np.isfinite(x_converted) & np.isfinite(y_converted)904 if np.count_nonzero(mask) < 2:905 messages.append(906 f"'{col}' was skipped because fewer than two valid points remained after conversion."907 )908 continue909 910 x_plot = x_converted[mask]911 y_plot = y_converted[mask]912 913 order = np.argsort(x_plot)914 x_plot = x_plot[order]915 y_plot = y_plot[order]916 917 y_smoothed = apply_smoothing(y_plot, smoothing_method, **smoothing_params)918 919 plotted_series[col] = {920 "x": x_plot,921 "y": y_smoothed,922 }923 924 ax.plot(925 x_plot,926 y_smoothed,927 label=col,928 linestyle=style_settings[col]["line_style"],929 marker=style_settings[col]["marker_style"],930 color=style_settings[col]["color"],931 linewidth=style_settings[col]["line_width"],932 )933 934 if not plotted_series:935 st.warning("No plottable Y-series remained after conversion and cleaning.")936 st.stop()937 938 if x_min is not None and x_max is not None:939 ax.set_xlim(x_min, x_max)940 if x_step > 0:941 ax.set_xticks(np.arange(x_min, x_max + x_step, x_step))942 943 if y_min is not None and y_max is not None:944 ax.set_ylim(y_min, y_max)945 if y_step > 0:946 ax.set_yticks(np.arange(y_min, y_max + y_step, y_step))947 948 if font_style_choice == "Italic":949 title_fontstyle = "italic"950 title_fontweight = "normal"951 elif font_style_choice == "Bold":952 title_fontstyle = "normal"953 title_fontweight = "bold"954 else:955 title_fontstyle = "normal"956 title_fontweight = "normal"957 958 ax.set_xlabel(959 f"{x_label} ({x_unit})",960 fontsize=x_label_font_size,961 family=font_theme,962 )963 ax.set_ylabel(964 f"{y_label} ({y_unit})",965 fontsize=y_label_font_size,966 family=font_theme,967 )968 ax.set_title(969 title,970 fontsize=title_font_size,971 fontstyle=title_fontstyle,972 fontweight=title_fontweight,973 family=font_theme,974 )975 976 ax.tick_params(977 axis="x",978 direction=x_tick_position,979 labelsize=x_tick_font_size,980 bottom=show_x_tick_marks,981 labelbottom=show_x_tick_labels,982 )983 ax.tick_params(984 axis="y",985 direction=y_tick_position,986 labelsize=y_tick_font_size,987 left=show_y_tick_marks,988 labelleft=show_y_tick_labels,989 )990 991 for tick in ax.get_xticklabels() + ax.get_yticklabels():992 tick.set_fontfamily(font_theme)993 994 if show_grid and grid_line_style != "None":995 ax.grid(996 True,997 linestyle=grid_line_style,998 linewidth=grid_line_width,999 color=grid_color,1000 axis=grid_direction,1001 )1002 1003 if show_minor_grid and grid_line_style != "None":1004 ax.minorticks_on()1005 ax.grid(1006 which="minor",1007 linestyle=grid_line_style,1008 linewidth=grid_line_width,1009 color=grid_color,1010 )1011 1012 # ==========================1013 # MARKERS (Vertical + Horizontal) with legend-only values1014 # ==========================1015 vmarker_handles = []1016 hmarker_handles = []1017 vvalue_handles = []1018 hvalue_handles = []1019 1020 # --- Vertical Markers ---1021 if selected_vmarkers:1022 for j, xm in enumerate(selected_vmarkers, start=1):1023 ax.axvline(1024 xm,1025 color=vmarker_color,1026 linestyle=vmarker_line_style,1027 linewidth=1.0,1028 )1029 1030 if vmarker_legend_enable:1031 vmarker_handles.append(1032 mpl.lines.Line2D(1033 [],1034 [],1035 color=vmarker_color,1036 linestyle=vmarker_line_style,1037 linewidth=1.0,1038 label=f"V{j}: x = {xm:g}",1039 )1040 )1041 1042 if show_vmarker_values and vvalues_legend_enable:1043 for col, series in plotted_series.items():1044 x_m = series["x"]1045 y_m = series["y"]1046 1047 if xm < x_m[0] or xm > x_m[-1]:1048 continue1049 1050 ym = float(np.interp(xm, x_m, y_m))1051 ax.scatter([xm], [ym], s=18, color=style_settings[col]["color"], zorder=5)1052 1053 vvalue_handles.append(1054 mpl.lines.Line2D(1055 [],1056 [],1057 color=style_settings[col]["color"],1058 marker="o",1059 linestyle="None",1060 markersize=5,1061 label=f"V{j} ({col}) = {ym:.6g}",1062 )1063 )1064 1065 # --- Horizontal Markers ---1066 if selected_hmarkers:1067 for j, ym in enumerate(selected_hmarkers, start=1):1068 ax.axhline(1069 ym,1070 color=hmarker_color,1071 linestyle=hmarker_line_style,1072 linewidth=1.0,1073 )1074 1075 if hmarker_legend_enable:1076 hmarker_handles.append(1077 mpl.lines.Line2D(1078 [],1079 [],1080 color=hmarker_color,1081 linestyle=hmarker_line_style,1082 linewidth=1.0,1083 label=f"H{j}: y = {ym:g}",1084 )1085 )1086 1087 if show_hmarker_values and hvalues_legend_enable:1088 for col, series in plotted_series.items():1089 x_m = series["x"]1090 y_m = series["y"]1091 1092 diff = y_m - ym1093 sign = np.sign(diff)1094 idx = np.where(np.diff(sign) != 0)[0]1095 if len(idx) == 0:1096 continue1097 1098 i0 = int(idx[0])1099 x0, x1 = x_m[i0], x_m[i0 + 1]1100 y0, y1 = y_m[i0], y_m[i0 + 1]1101 if y1 == y0:1102 continue1103 1104 xm = float(x0 + (ym - y0) * (x1 - x0) / (y1 - y0))1105 1106 ax.scatter([xm], [ym], s=18, color=style_settings[col]["color"], zorder=5)1107 1108 hvalue_handles.append(1109 mpl.lines.Line2D(1110 [],1111 [],1112 color=style_settings[col]["color"],1113 marker="o",1114 linestyle="None",1115 markersize=5,1116 label=f"H{j} ({col}) x = {xm:.6g}",1117 )1118 )1119 1120 # ==========================1121 # LEGENDS1122 # ==========================1123 def style_legend(legend_obj):1124 if legend_obj is None:1125 return1126 legend_obj.get_frame().set_linewidth(legend_border_width)1127 if legend_obj.get_title() is not None:1128 legend_obj.get_title().set_fontfamily(font_theme)1129 legend_obj.get_title().set_fontweight(legend_font_weight.lower())1130 for text in legend_obj.get_texts():1131 text.set_fontfamily(font_theme)1132 text.set_fontweight(legend_font_weight.lower())1133 1134 marker_legend_font_size = (1135 legend_font_size if use_global_font_size else max(6, legend_font_size - 1)1136 )1137 marker_value_font_size = (1138 legend_font_size if use_global_font_size else max(6, legend_font_size - 2)1139 )1140 1141 main_leg = ax.legend(1142 title=legend_title,1143 fontsize=legend_font_size,1144 loc=legend_location,1145 frameon=True,1146 facecolor=legend_bg_color,1147 edgecolor=legend_border_color,1148 framealpha=legend_alpha,1149 ncol=legend_columns,1150 )1151 style_legend(main_leg)1152 ax.add_artist(main_leg)1153 1154 if vmarker_legend_enable and len(vmarker_handles) > 0:1155 vleg = ax.legend(1156 handles=vmarker_handles,1157 title="Vertical Markers",1158 fontsize=marker_legend_font_size,1159 loc=vmarker_legend_location,1160 frameon=True,1161 facecolor=legend_bg_color,1162 edgecolor=legend_border_color,1163 framealpha=legend_alpha,1164 )1165 style_legend(vleg)1166 ax.add_artist(vleg)1167 1168 if hmarker_legend_enable and len(hmarker_handles) > 0:1169 hleg = ax.legend(1170 handles=hmarker_handles,1171 title="Horizontal Markers",1172 fontsize=marker_legend_font_size,1173 loc=hmarker_legend_location,1174 frameon=True,1175 facecolor=legend_bg_color,1176 edgecolor=legend_border_color,1177 framealpha=legend_alpha,1178 )1179 style_legend(hleg)1180 ax.add_artist(hleg)1181 1182 if show_vmarker_values and vvalues_legend_enable and len(vvalue_handles) > 0:1183 vvleg = ax.legend(1184 handles=vvalue_handles,1185 title="Vertical Marker Values",1186 fontsize=marker_value_font_size,1187 loc=vvalues_legend_location,1188 frameon=True,1189 facecolor=legend_bg_color,1190 edgecolor=legend_border_color,1191 framealpha=legend_alpha,1192 )1193 style_legend(vvleg)1194 ax.add_artist(vvleg)1195 1196 if show_hmarker_values and hvalues_legend_enable and len(hvalue_handles) > 0:1197 hvleg = ax.legend(1198 handles=hvalue_handles,1199 title="Horizontal Marker Values",1200 fontsize=marker_value_font_size,