Kung-Hsun commited on
Commit
0f3ee51
·
verified ·
1 Parent(s): 53578f1

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +112 -130
app.py CHANGED
@@ -6,19 +6,17 @@ import os
6
  from datetime import datetime, time
7
  from typing import Union, Optional, Tuple, Dict, List
8
  import plotly.graph_objects as go
9
- from functools import partial
10
 
 
11
  EXCEL_LETTERS = ["A", "B", "K", "L", "M", "V", "W", "X", "Y"]
12
  TARGET_NAMES = ["data", "time", "⊿Ptop", "⊿Pmid", "⊿Pbot", "H2%", "CO%", "CO2%", "CH4%"]
13
 
14
- # 預設顏色/線型
15
  DEFAULT_COLORS = [
16
  "#1f77b4","#ff7f0e","#2ca02c","#d62728","#9467bd",
17
  "#8c564b","#e377c2","#7f7f7f","#bcbd22","#17becf"
18
  ]
19
  DASH_OPTIONS = ["solid","dot","dash","longdash","dashdot","longdashdot"]
20
 
21
- # 色盤(含色弱友善方案)
22
  PALETTES = {
23
  "Plotly10": DEFAULT_COLORS,
24
  "Tableau10": ["#4E79A7","#F28E2B","#E15759","#76B7B2","#59A14F","#EDC948","#B07AA1","#FF9DA7","#9C755F","#BAB0AC"],
@@ -27,8 +25,9 @@ PALETTES = {
27
  "Okabe-Ito (CB-safe)": ["#0072B2","#E69F00","#009E73","#D55E00","#CC79A7","#F0E442","#56B4E9","#000000"]
28
  }
29
 
30
- MAX_SERIES_FOR_PICKER = 10 # 最多顯示 10 顏色挑選器
31
 
 
32
  def letters_to_index_zero_based(letter: str) -> int:
33
  idx = 0
34
  for ch in letter.upper():
@@ -47,7 +46,6 @@ def get_lower_name(file_input: Union[str, os.PathLike, io.BytesIO, bytes, object
47
 
48
  def load_dataframe(file_input) -> pd.DataFrame:
49
  lower_name = get_lower_name(file_input)
50
-
51
  if isinstance(file_input, (str, os.PathLike)):
52
  path = str(file_input)
53
  if lower_name.endswith((".xlsx", ".xls")):
@@ -138,10 +136,8 @@ def _hhmmss_int_to_seconds(n: int):
138
 
139
  def series_time_to_seconds_of_day(series: pd.Series) -> pd.Series:
140
  s = series.copy()
141
-
142
  if pd.api.types.is_datetime64_any_dtype(s):
143
  return (s.dt.hour*3600 + s.dt.minute*60 + s.dt.second).astype("float")
144
-
145
  if pd.api.types.is_timedelta64_dtype(s):
146
  total_sec = s.dt.total_seconds()
147
  return (total_sec % 86400).astype("float")
@@ -188,11 +184,9 @@ def parse_hhmmss_or_number(s: Optional[str]) -> Optional[float]:
188
  parts = text.split(":")
189
  try:
190
  if len(parts) == 2:
191
- mm, ss = int(parts[0]), int(parts[1])
192
- return mm*60 + ss
193
  elif len(parts) == 3:
194
- hh, mm, ss = int(parts[0]), int(parts[1]), int(parts[2])
195
- return hh*3600 + mm*60 + ss
196
  except Exception:
197
  return None
198
  try:
@@ -210,42 +204,30 @@ def prepare_x_series(df: pd.DataFrame, x_col: str) -> Tuple[pd.Series, bool]:
210
  num = pd.to_numeric(x, errors="coerce")
211
  return num, False
212
 
213
- def build_default_style_rows(y_cols: List[str], existing: Optional[pd.DataFrame]=None) -> pd.DataFrame:
214
- rows = []
215
- for i, y in enumerate(y_cols):
216
- default = {
217
- "series": y,
218
- "color": (existing.loc[existing["series"]==y, "color"].iloc[0]
219
- if isinstance(existing, pd.DataFrame) and (existing["series"]==y).any()
220
- else DEFAULT_COLORS[i % len(DEFAULT_COLORS)]),
221
- "width": float(existing.loc[existing["series"]==y, "width"].iloc[0])
222
- if isinstance(existing, pd.DataFrame) and (existing["series"]==y).any()
223
- else 2.0,
224
- "dash": (existing.loc[existing["series"]==y, "dash"].iloc[0]
225
- if isinstance(existing, pd.DataFrame) and (existing["series"]==y).any()
226
- else "solid"),
227
- }
228
- rows.append(default)
229
- return pd.DataFrame(rows, columns=["series","color","width","dash"])
230
-
231
- def styles_to_map(style_df: Optional[pd.DataFrame]) -> Dict[str, Dict]:
232
  m: Dict[str, Dict] = {}
233
- if not isinstance(style_df, pd.DataFrame) or style_df.empty:
234
- return m
235
- for _, r in style_df.iterrows():
236
  name = str(r.get("series","")).strip()
237
- if not name:
238
- continue
239
- col = str(r.get("color","")).strip() or None
240
- w = r.get("width", None)
241
  try:
242
- width = float(w) if w is not None and str(w).strip() != "" else 2.0
243
  except Exception:
244
- width = 2.0
245
- dash = str(r.get("dash","solid")).strip().lower()
246
- if dash not in DASH_OPTIONS:
247
- dash = "solid"
248
- m[name] = {"color": col, "width": width, "dash": dash}
 
 
249
  return m
250
 
251
  def make_line_figure(df: pd.DataFrame, x_col: str, y_cols: list,
@@ -271,20 +253,15 @@ def make_line_figure(df: pd.DataFrame, x_col: str, y_cols: list,
271
  mask = x_series.notna() & y.notna()
272
  if mask.sum() < 2:
273
  continue
274
-
275
- style = style_map.get(y_col, {})
276
- line_kwargs = {
277
- "color": style.get("color") or DEFAULT_COLORS[idx % len(DEFAULT_COLORS)],
278
- "width": style.get("width", 2.0),
279
- "dash": style.get("dash", "solid")
280
- }
281
  fig.add_trace(
282
  go.Scatter(
283
  x=x_series[mask],
284
  y=y[mask],
285
  mode="lines",
286
  name=y_col,
287
- line=line_kwargs
288
  )
289
  )
290
 
@@ -338,12 +315,11 @@ def make_line_figure(df: pd.DataFrame, x_col: str, y_cols: list,
338
  fig.update_xaxes(showgrid=True)
339
  return fig
340
 
341
- # ===== Gradio UI =====
342
- with gr.Blocks(title="Excel/CSV 指定欄位擷取器(時間過濾+互動線圖+樣式)") as demo:
343
  gr.Markdown("### 指定欄位擷取(A,B,K,L,M,V,W,X,Y)→ data,time,⊿Ptop,⊿Pmid,⊿Pbot,H2%,CO%,CO2%,CH4% ;支援 **時間區段 (hh:mm:ss)** 過濾與 **互動線圖**(Y 可複選)。")
344
 
345
  df_state = gr.State(value=None)
346
- style_state = gr.State(value=None) # 保存目前樣式表(DataFrame)
347
 
348
  inp = gr.File(label="上傳 .xlsx 或 .csv 檔案", file_types=[".xlsx", ".csv"], type="filepath")
349
 
@@ -387,13 +363,14 @@ with gr.Blocks(title="Excel/CSV 指定欄位擷取器(時間過濾+互動線
387
  y_max = gr.Textbox(label="Y 最大(數值)", value="")
388
  y_dtick = gr.Textbox(label="Y 刻度間距 dtick(數值)", value="")
389
 
390
- gr.Markdown("### 樣式(線與線型在格中編輯;顏色用 ColorPicker 或色盤)")
 
391
  style_df = gr.Dataframe(
392
- label="系列樣式表",
393
- headers=["series","color","width","dash"],
394
- datatype=["str","str","number","str"],
395
  row_count=(0, "dynamic"),
396
- col_count=(4, "fixed"),
397
  wrap=True
398
  )
399
 
@@ -401,10 +378,22 @@ with gr.Blocks(title="Excel/CSV 指定欄位擷取器(時間過濾+互動線
401
  palette_dd = gr.Dropdown(label="色盤", choices=list(PALETTES.keys()), value="Okabe-Ito (CB-safe)")
402
  apply_palette_btn = gr.Button("套用色盤到 Y(依序)")
403
 
404
- gr.Markdown("#### 顏色挑選(最多顯示前 10 條)")
 
405
  color_pickers = []
406
- for i in range(MAX_SERIES_FOR_PICKER):
407
- color_pickers.append(gr.ColorPicker(label=f"系列 {i+1}", value="#000000", visible=False))
 
 
 
 
 
 
 
 
 
 
 
408
 
409
  plot_btn = gr.Button("繪製線圖(互動)")
410
  plot_out = gr.Plot(label="互動線圖")
@@ -412,20 +401,22 @@ with gr.Blocks(title="Excel/CSV 指定欄位擷取器(時間過濾+互動線
412
 
413
  # --------- 回呼邏輯 ---------
414
  def run_pipeline(file_path_str, sh_, sm_, ss_, eh_, em_, es_):
 
 
 
 
415
  if not file_path_str:
416
- empty_updates = [gr.update(visible=False) for _ in range(MAX_SERIES_FOR_PICKER)]
417
  return (gr.update(visible=False), "請先上傳檔��。", pd.DataFrame(),
418
  None, gr.update(choices=[], value=None), gr.update(choices=[], value=None),
419
- pd.DataFrame(), None, *empty_updates)
420
 
421
  try:
422
  df = load_dataframe(file_path_str)
423
  out = extract_and_rename(df)
424
  except Exception as e:
425
- empty_updates = [gr.update(visible=False) for _ in range(MAX_SERIES_FOR_PICKER)]
426
  return (gr.update(visible=False), f"處理失敗:{e}", pd.DataFrame(),
427
  None, gr.update(choices=[], value=None), gr.update(choices=[], value=None),
428
- pd.DataFrame(), None, *empty_updates)
429
 
430
  original_rows = len(out)
431
 
@@ -433,18 +424,16 @@ with gr.Blocks(title="Excel/CSV 指定欄位擷取器(時間過濾+互動線
433
  start_sec = parse_time_to_seconds(sh_, sm_, ss_)
434
  end_sec = parse_time_to_seconds(eh_, em_, es_)
435
  except Exception as e:
436
- empty_updates = [gr.update(visible=False) for _ in range(MAX_SERIES_FOR_PICKER)]
437
  return (gr.update(visible=False), f"時間輸入錯誤:{e}", pd.DataFrame(),
438
  None, gr.update(choices=[], value=None), gr.update(choices=[], value=None),
439
- pd.DataFrame(), None, *empty_updates)
440
 
441
  parsed_ok = None
442
  if (start_sec is not None) and (end_sec is not None):
443
  if "time" not in out.columns:
444
- empty_updates = [gr.update(visible=False) for _ in range(MAX_SERIES_FOR_PICKER)]
445
  return (gr.update(visible=False), "找不到 'time' 欄,無法做時間過濾。", pd.DataFrame(),
446
  None, gr.update(choices=[], value=None), gr.update(choices=[], value=None),
447
- pd.DataFrame(), None, *empty_updates)
448
  secs = series_time_to_seconds_of_day(out["time"])
449
  parsed_ok = int(secs.notna().sum())
450
  valid_mask = secs.notna()
@@ -460,16 +449,16 @@ with gr.Blocks(title="Excel/CSV 指定欄位擷取器(時間過濾+互動線
460
  try:
461
  out.to_excel(out_path, index=False, engine="openpyxl")
462
  except Exception as e:
463
- empty_updates = [gr.update(visible=False) for _ in range(MAX_SERIES_FOR_PICKER)]
464
  return (gr.update(visible=False), f"輸出 Excel 失敗:{e}", pd.DataFrame(),
465
  None, gr.update(choices=[], value=None), gr.update(choices=[], value=None),
466
- pd.DataFrame(), None, *empty_updates)
467
 
468
  cols = out.columns.tolist()
469
  default_x = "time" if "time" in cols else (cols[0] if cols else None)
470
  default_y = [c for c in ["H2%", "CO%", "CO2%", "CH4%"] if c in cols] or ([cols[1]] if len(cols) > 1 else cols)
471
 
472
- init_style_df = build_default_style_rows(default_y)
 
473
 
474
  note_lines = [f"完成!原始列數:**{original_rows}**",
475
  f"輸出列數:**{len(out)}**"]
@@ -479,15 +468,16 @@ with gr.Blocks(title="Excel/CSV 指定欄位擷取器(時間過濾+互動線
479
  note_lines.append("下方預覽、右側可下載;可選欄位與樣式後繪圖。")
480
  note = "|".join(note_lines)
481
 
482
- # 初始顏色挑選器狀態
483
- picker_updates = []
484
- for i in range(MAX_SERIES_FOR_PICKER):
485
  if i < len(default_y):
486
  series = default_y[i]
487
- c = init_style_df.loc[init_style_df["series"]==series, "color"].iloc[0]
488
- picker_updates.append(gr.update(visible=True, value=c, label=f"{series} 顏色"))
489
  else:
490
  picker_updates.append(gr.update(visible=False))
 
491
 
492
  return (
493
  gr.update(value=out_path, visible=True),
@@ -497,52 +487,58 @@ with gr.Blocks(title="Excel/CSV 指定欄位擷取器(時間過濾+互動線
497
  gr.update(choices=cols, value=default_x),
498
  gr.update(choices=cols, value=default_y),
499
  init_style_df,
500
- init_style_df, # style_state
501
- *picker_updates
502
  )
503
 
504
- def sync_styles(style_df_current, y_cols):
505
  y_cols = y_cols or []
506
- existing = style_df_current if isinstance(style_df_current, pd.DataFrame) else None
507
- new_df = build_default_style_rows(y_cols, existing=existing)
 
 
508
 
509
- picker_updates = []
510
- for i in range(MAX_SERIES_FOR_PICKER):
511
  if i < len(y_cols):
512
  series = y_cols[i]
513
- c = new_df.loc[new_df["series"]==series, "color"].iloc[0]
514
- picker_updates.append(gr.update(visible=True, value=c, label=f"{series} 顏色"))
515
  else:
516
  picker_updates.append(gr.update(visible=False))
517
- return new_df, new_df, *picker_updates
518
-
519
- def on_y_change(style_df_current, y_cols):
520
- # 自動同步(保留既有設定)
521
- return sync_styles(style_df_current, y_cols)
522
 
523
  def apply_palette(style_df_current, y_cols, palette_name):
524
  y_cols = y_cols or []
525
- df = build_default_style_rows(y_cols, existing=style_df_current)
526
  pal = PALETTES.get(palette_name, DEFAULT_COLORS)
527
- # 按 y_cols 順序覆蓋 color
528
- for i, s in enumerate(y_cols):
529
- df.loc[df["series"]==s, "color"] = pal[i % len(pal)]
530
- # 更新挑選器
531
  picker_updates = []
532
- for i in range(MAX_SERIES_FOR_PICKER):
533
  if i < len(y_cols):
534
- series = y_cols[i]
535
- c = df.loc[df["series"]==series, "color"].iloc[0]
536
- picker_updates.append(gr.update(visible=True, value=c, label=f"{series} 顏色"))
537
  else:
538
  picker_updates.append(gr.update(visible=False))
539
- return df, df, *picker_updates
 
 
 
 
 
 
 
 
540
 
541
- def plot_handler(df, x_col, y_cols, fig_w_, fig_h_, auto_x_, x_min_, x_max_, x_dtick_, auto_y_, y_min_, y_max_, y_dtick_, style_df_current):
 
542
  if df is None:
543
  return None, "尚未有可用資料,請先完成上方處理。"
 
 
 
 
 
544
  try:
545
- s_map = styles_to_map(style_df_current)
546
  fig = make_line_figure(
547
  df, x_col, y_cols or [],
548
  fig_w=int(fig_w_ or 900), fig_h=int(fig_h_ or 500),
@@ -554,49 +550,35 @@ with gr.Blocks(title="Excel/CSV 指定欄位擷取器(時間過濾+互動線
554
  except Exception as e:
555
  return None, f"繪圖失敗:{e}"
556
 
557
- def make_color_change_handler(idx):
558
- def handler(style_df_current, y_cols, new_color):
559
- y_cols = y_cols or []
560
- if idx >= len(y_cols):
561
- return style_df_current, style_df_current # 無變更
562
- series = y_cols[idx]
563
- df = build_default_style_rows(y_cols, existing=style_df_current)
564
- df.loc[df["series"]==series, "color"] = new_color
565
- return df, df
566
- return handler
567
-
568
- # 事件綁定
569
- outputs_base = [file_out, msg, preview, df_state, x_sel, y_sel, style_df, style_state]
570
- outputs_with_pickers = outputs_base + color_pickers
571
 
572
  run_btn.click(
573
  run_pipeline,
574
  inputs=[inp, sh, sm, ss, eh, em, es],
575
- outputs=outputs_with_pickers
576
  )
577
 
 
578
  y_sel.change(
579
- on_y_change,
580
  inputs=[style_df, y_sel],
581
- outputs=[style_df, style_state, *color_pickers]
582
  )
583
 
 
584
  apply_palette_btn.click(
585
  apply_palette,
586
  inputs=[style_df, y_sel, palette_dd],
587
- outputs=[style_df, style_state, *color_pickers]
588
  )
589
 
590
- for i, picker in enumerate(color_pickers):
591
- picker.change(
592
- make_color_change_handler(i),
593
- inputs=[style_df, y_sel, picker],
594
- outputs=[style_df, style_state]
595
- )
596
-
597
  plot_btn.click(
598
  plot_handler,
599
- inputs=[df_state, x_sel, y_sel, fig_w, fig_h, auto_x, x_min, x_max, x_dtick, auto_y, y_min, y_max, y_dtick, style_state],
600
  outputs=[plot_out, plot_msg]
601
  )
602
 
 
6
  from datetime import datetime, time
7
  from typing import Union, Optional, Tuple, Dict, List
8
  import plotly.graph_objects as go
 
9
 
10
+ # ====== 常數與預設 ======
11
  EXCEL_LETTERS = ["A", "B", "K", "L", "M", "V", "W", "X", "Y"]
12
  TARGET_NAMES = ["data", "time", "⊿Ptop", "⊿Pmid", "⊿Pbot", "H2%", "CO%", "CO2%", "CH4%"]
13
 
 
14
  DEFAULT_COLORS = [
15
  "#1f77b4","#ff7f0e","#2ca02c","#d62728","#9467bd",
16
  "#8c564b","#e377c2","#7f7f7f","#bcbd22","#17becf"
17
  ]
18
  DASH_OPTIONS = ["solid","dot","dash","longdash","dashdot","longdashdot"]
19
 
 
20
  PALETTES = {
21
  "Plotly10": DEFAULT_COLORS,
22
  "Tableau10": ["#4E79A7","#F28E2B","#E15759","#76B7B2","#59A14F","#EDC948","#B07AA1","#FF9DA7","#9C755F","#BAB0AC"],
 
25
  "Okabe-Ito (CB-safe)": ["#0072B2","#E69F00","#009E73","#D55E00","#CC79A7","#F0E442","#56B4E9","#000000"]
26
  }
27
 
28
+ MAX_SERIES = 10 # 最多顯示 10 組(顏色 + 線型)
29
 
30
+ # ====== 工具函式 ======
31
  def letters_to_index_zero_based(letter: str) -> int:
32
  idx = 0
33
  for ch in letter.upper():
 
46
 
47
  def load_dataframe(file_input) -> pd.DataFrame:
48
  lower_name = get_lower_name(file_input)
 
49
  if isinstance(file_input, (str, os.PathLike)):
50
  path = str(file_input)
51
  if lower_name.endswith((".xlsx", ".xls")):
 
136
 
137
  def series_time_to_seconds_of_day(series: pd.Series) -> pd.Series:
138
  s = series.copy()
 
139
  if pd.api.types.is_datetime64_any_dtype(s):
140
  return (s.dt.hour*3600 + s.dt.minute*60 + s.dt.second).astype("float")
 
141
  if pd.api.types.is_timedelta64_dtype(s):
142
  total_sec = s.dt.total_seconds()
143
  return (total_sec % 86400).astype("float")
 
184
  parts = text.split(":")
185
  try:
186
  if len(parts) == 2:
187
+ mm, ss = int(parts[0]), int(parts[1]); return mm*60 + ss
 
188
  elif len(parts) == 3:
189
+ hh, mm, ss = int(parts[0]), int(parts[1]), int(parts[2]); return hh*3600 + mm*60 + ss
 
190
  except Exception:
191
  return None
192
  try:
 
204
  num = pd.to_numeric(x, errors="coerce")
205
  return num, False
206
 
207
+ def styles_to_map(y_cols: List[str], style_df: Optional[pd.DataFrame],
208
+ colors: List[Optional[str]], dashes: List[Optional[str]]) -> Dict[str, Dict]:
209
+ """
210
+ 最終用於繪圖的樣式:{series: {color, width, dash}}
211
+ - width 來自 style_df(使用者可編輯)
212
+ - color 來自 ColorPicker
213
+ - dash 來自 Radio
214
+ """
 
 
 
 
 
 
 
 
 
 
 
215
  m: Dict[str, Dict] = {}
216
+ df = style_df if isinstance(style_df, pd.DataFrame) else pd.DataFrame(columns=["series","width"])
217
+ width_map = {}
218
+ for _, r in df.iterrows():
219
  name = str(r.get("series","")).strip()
220
+ if not name: continue
 
 
 
221
  try:
222
+ width_map[name] = float(r.get("width", 2.0))
223
  except Exception:
224
+ width_map[name] = 2.0
225
+
226
+ for i, s in enumerate(y_cols):
227
+ col = colors[i] if i < len(colors) and colors[i] else DEFAULT_COLORS[i % len(DEFAULT_COLORS)]
228
+ dash = (dashes[i] if i < len(dashes) and (dashes[i] in DASH_OPTIONS) else "solid")
229
+ w = width_map.get(s, 2.0)
230
+ m[s] = {"color": col, "width": w, "dash": dash}
231
  return m
232
 
233
  def make_line_figure(df: pd.DataFrame, x_col: str, y_cols: list,
 
253
  mask = x_series.notna() & y.notna()
254
  if mask.sum() < 2:
255
  continue
256
+ st = style_map.get(y_col, {"color": DEFAULT_COLORS[idx % len(DEFAULT_COLORS)],
257
+ "width": 2.0, "dash": "solid"})
 
 
 
 
 
258
  fig.add_trace(
259
  go.Scatter(
260
  x=x_series[mask],
261
  y=y[mask],
262
  mode="lines",
263
  name=y_col,
264
+ line=dict(color=st["color"], width=st["width"], dash=st["dash"])
265
  )
266
  )
267
 
 
315
  fig.update_xaxes(showgrid=True)
316
  return fig
317
 
318
+ # ====== 介面 ======
319
+ with gr.Blocks(title="Excel/CSV 指定欄位擷取器(時間過濾+互動線圖+色彩樣式)") as demo:
320
  gr.Markdown("### 指定欄位擷取(A,B,K,L,M,V,W,X,Y)→ data,time,⊿Ptop,⊿Pmid,⊿Pbot,H2%,CO%,CO2%,CH4% ;支援 **時間區段 (hh:mm:ss)** 過濾與 **互動線圖**(Y 可複選)。")
321
 
322
  df_state = gr.State(value=None)
 
323
 
324
  inp = gr.File(label="上傳 .xlsx 或 .csv 檔案", file_types=[".xlsx", ".csv"], type="filepath")
325
 
 
363
  y_max = gr.Textbox(label="Y 最大(數值)", value="")
364
  y_dtick = gr.Textbox(label="Y 刻度間距 dtick(數值)", value="")
365
 
366
+ # ---- 樣式(已移除 color 欄),顏色用 ColorPicker,線型用 Radio ----
367
+ gr.Markdown("### 樣式設定(寬度表 + ColorPicker + 線型 Radio)")
368
  style_df = gr.Dataframe(
369
+ label="系列寬度(每條線一列)",
370
+ headers=["series","width"],
371
+ datatype=["str","number"],
372
  row_count=(0, "dynamic"),
373
+ col_count=(2, "fixed"),
374
  wrap=True
375
  )
376
 
 
378
  palette_dd = gr.Dropdown(label="色盤", choices=list(PALETTES.keys()), value="Okabe-Ito (CB-safe)")
379
  apply_palette_btn = gr.Button("套用色盤到 Y(依序)")
380
 
381
+ gr.Markdown("#### 顏色挑選(前 10 條;縮短標籤以節省寬度)")
382
+ # 兩排 × 5 組 ColorPicker,縮短標籤(僅顯示系列名)
383
  color_pickers = []
384
+ for row in range(2):
385
+ with gr.Row():
386
+ for i in range(5):
387
+ idx = row*5 + i
388
+ color_pickers.append(gr.ColorPicker(label=f"系列 {idx+1}", value="#000000", visible=False))
389
+
390
+ gr.Markdown("#### 線型(Radio 勾選;前 10 條)")
391
+ dash_radios = []
392
+ for row in range(2):
393
+ with gr.Row():
394
+ for i in range(5):
395
+ idx = row*5 + i
396
+ dash_radios.append(gr.Radio(label=f"系列 {idx+1}", choices=DASH_OPTIONS, value="solid", visible=False))
397
 
398
  plot_btn = gr.Button("繪製線圖(互動)")
399
  plot_out = gr.Plot(label="互動線圖")
 
401
 
402
  # --------- 回呼邏輯 ---------
403
  def run_pipeline(file_path_str, sh_, sm_, ss_, eh_, em_, es_):
404
+ # 基本輸出佔位
405
+ empty_color = [gr.update(visible=False) for _ in range(MAX_SERIES)]
406
+ empty_dash = [gr.update(visible=False) for _ in range(MAX_SERIES)]
407
+
408
  if not file_path_str:
 
409
  return (gr.update(visible=False), "請先上傳檔��。", pd.DataFrame(),
410
  None, gr.update(choices=[], value=None), gr.update(choices=[], value=None),
411
+ pd.DataFrame(), *empty_color, *empty_dash)
412
 
413
  try:
414
  df = load_dataframe(file_path_str)
415
  out = extract_and_rename(df)
416
  except Exception as e:
 
417
  return (gr.update(visible=False), f"處理失敗:{e}", pd.DataFrame(),
418
  None, gr.update(choices=[], value=None), gr.update(choices=[], value=None),
419
+ pd.DataFrame(), *empty_color, *empty_dash)
420
 
421
  original_rows = len(out)
422
 
 
424
  start_sec = parse_time_to_seconds(sh_, sm_, ss_)
425
  end_sec = parse_time_to_seconds(eh_, em_, es_)
426
  except Exception as e:
 
427
  return (gr.update(visible=False), f"時間輸入錯誤:{e}", pd.DataFrame(),
428
  None, gr.update(choices=[], value=None), gr.update(choices=[], value=None),
429
+ pd.DataFrame(), *empty_color, *empty_dash)
430
 
431
  parsed_ok = None
432
  if (start_sec is not None) and (end_sec is not None):
433
  if "time" not in out.columns:
 
434
  return (gr.update(visible=False), "找不到 'time' 欄,無法做時間過濾。", pd.DataFrame(),
435
  None, gr.update(choices=[], value=None), gr.update(choices=[], value=None),
436
+ pd.DataFrame(), *empty_color, *empty_dash)
437
  secs = series_time_to_seconds_of_day(out["time"])
438
  parsed_ok = int(secs.notna().sum())
439
  valid_mask = secs.notna()
 
449
  try:
450
  out.to_excel(out_path, index=False, engine="openpyxl")
451
  except Exception as e:
 
452
  return (gr.update(visible=False), f"輸出 Excel 失敗:{e}", pd.DataFrame(),
453
  None, gr.update(choices=[], value=None), gr.update(choices=[], value=None),
454
+ pd.DataFrame(), *empty_color, *empty_dash)
455
 
456
  cols = out.columns.tolist()
457
  default_x = "time" if "time" in cols else (cols[0] if cols else None)
458
  default_y = [c for c in ["H2%", "CO%", "CO2%", "CH4%"] if c in cols] or ([cols[1]] if len(cols) > 1 else cols)
459
 
460
+ # 初始化寬度表(無 color 欄)
461
+ init_style_df = pd.DataFrame({"series": default_y, "width": [2.0]*len(default_y)})
462
 
463
  note_lines = [f"完成!原始列數:**{original_rows}**",
464
  f"輸出列數:**{len(out)}**"]
 
468
  note_lines.append("下方預覽、右側可下載;可選欄位與樣式後繪圖。")
469
  note = "|".join(note_lines)
470
 
471
+ # 初始 ColorPicker 與 Radio 顯示(兩排 × 5)
472
+ picker_updates, radio_updates = [], []
473
+ for i in range(MAX_SERIES):
474
  if i < len(default_y):
475
  series = default_y[i]
476
+ picker_updates.append(gr.update(visible=True, value=DEFAULT_COLORS[i % len(DEFAULT_COLORS)], label=series))
477
+ radio_updates.append(gr.update(visible=True, value="solid", label=series))
478
  else:
479
  picker_updates.append(gr.update(visible=False))
480
+ radio_updates.append(gr.update(visible=False))
481
 
482
  return (
483
  gr.update(value=out_path, visible=True),
 
487
  gr.update(choices=cols, value=default_x),
488
  gr.update(choices=cols, value=default_y),
489
  init_style_df,
490
+ *picker_updates,
491
+ *radio_updates
492
  )
493
 
494
+ def sync_on_y_change(style_df_current, y_cols):
495
  y_cols = y_cols or []
496
+ # 寬度表刷新(保留既有 width)
497
+ exist = style_df_current if isinstance(style_df_current, pd.DataFrame) else pd.DataFrame(columns=["series","width"])
498
+ width_map = {str(r["series"]): r["width"] for _, r in exist.iterrows()} if not exist.empty else {}
499
+ new_df = pd.DataFrame({"series": y_cols, "width": [width_map.get(s, 2.0) for s in y_cols]})
500
 
501
+ picker_updates, radio_updates = [], []
502
+ for i in range(MAX_SERIES):
503
  if i < len(y_cols):
504
  series = y_cols[i]
505
+ picker_updates.append(gr.update(visible=True, value=DEFAULT_COLORS[i % len(DEFAULT_COLORS)], label=series))
506
+ radio_updates.append(gr.update(visible=True, value="solid", label=series))
507
  else:
508
  picker_updates.append(gr.update(visible=False))
509
+ radio_updates.append(gr.update(visible=False))
510
+ return new_df, *picker_updates, *radio_updates
 
 
 
511
 
512
  def apply_palette(style_df_current, y_cols, palette_name):
513
  y_cols = y_cols or []
 
514
  pal = PALETTES.get(palette_name, DEFAULT_COLORS)
 
 
 
 
515
  picker_updates = []
516
+ for i in range(MAX_SERIES):
517
  if i < len(y_cols):
518
+ picker_updates.append(gr.update(visible=True, value=pal[i % len(pal)], label=y_cols[i]))
 
 
519
  else:
520
  picker_updates.append(gr.update(visible=False))
521
+ # Radio 不變,只更新顏色
522
+ radio_updates = []
523
+ for i in range(MAX_SERIES):
524
+ if i < len(y_cols):
525
+ radio_updates.append(gr.update(visible=True, value="solid", label=y_cols[i]))
526
+ else:
527
+ radio_updates.append(gr.update(visible=False))
528
+ # 寬度表保持不變
529
+ return style_df_current, *picker_updates, *radio_updates
530
 
531
+ def plot_handler(df, x_col, y_cols, fig_w_, fig_h_, auto_x_, x_min_, x_max_, x_dtick_, auto_y_, y_min_, y_max_, y_dtick_,
532
+ style_df_current, *color_and_dash):
533
  if df is None:
534
  return None, "尚未有可用資料,請先完成上方處理。"
535
+
536
+ # 拆分 color 與 dash(各 MAX_SERIES 個)
537
+ colors = list(color_and_dash[:MAX_SERIES])
538
+ dashes = list(color_and_dash[MAX_SERIES:MAX_SERIES*2])
539
+
540
  try:
541
+ s_map = styles_to_map(y_cols or [], style_df_current, colors, dashes)
542
  fig = make_line_figure(
543
  df, x_col, y_cols or [],
544
  fig_w=int(fig_w_ or 900), fig_h=int(fig_h_ or 500),
 
550
  except Exception as e:
551
  return None, f"繪圖失敗:{e}"
552
 
553
+ # 綁定事件
554
+ base_outputs = [file_out, msg, preview, df_state, x_sel, y_sel, style_df]
555
+ color_outputs = color_pickers
556
+ dash_outputs = dash_radios
 
 
 
 
 
 
 
 
 
 
557
 
558
  run_btn.click(
559
  run_pipeline,
560
  inputs=[inp, sh, sm, ss, eh, em, es],
561
+ outputs=base_outputs + color_outputs + dash_outputs
562
  )
563
 
564
+ # Y 變更時同��(更新寬度表、ColorPicker 與 Radio)
565
  y_sel.change(
566
+ sync_on_y_change,
567
  inputs=[style_df, y_sel],
568
+ outputs=[style_df] + color_outputs + dash_outputs
569
  )
570
 
571
+ # 套用色盤(只變更顏色挑選器;Radio 與寬度表不動)
572
  apply_palette_btn.click(
573
  apply_palette,
574
  inputs=[style_df, y_sel, palette_dd],
575
+ outputs=[style_df] + color_outputs + dash_outputs
576
  )
577
 
578
+ # 繪圖(把所有 ColorPicker Radio 的值都丟進 handler)
 
 
 
 
 
 
579
  plot_btn.click(
580
  plot_handler,
581
+ inputs=[df_state, x_sel, y_sel, fig_w, fig_h, auto_x, x_min, x_max, x_dtick, auto_y, y_min, y_max, y_dtick, style_df] + color_pickers + dash_radios,
582
  outputs=[plot_out, plot_msg]
583
  )
584