一. 前言:沒有資料,為什麼圖上還有一條線? #
嗨,我是拍拍君。 今天先問一個很容易答錯的問題: 如果星期二整天沒有回報資料,Plotly 折線圖會自動斷開嗎?
不一定。只要那一天連資料列都不存在,線通常就直接接過去。
更麻煩的是,把日期改成字串類別,畫面還會變得很整齊。 星期五到星期一,和星期一到星期二,看起來竟然一樣遠。 乾淨是乾淨了,時間卻悄悄被改寫。
這篇接續 Plotly 基礎篇,不重做互動圖表大巡禮。 我們只處理一件事:線段和水平距離,到底對讀者承諾了什麼?
範例使用虛構的拍拍商店日報,不涉及真實營運資料。 最後會得到保留日曆與壓縮休息日的兩版圖,以及可自動檢查的契約。
二. 安裝:用小資料把語意看清楚 #
建立專案:
uv init plotly-time-axis
cd plotly-time-axis
uv add plotly pandas pytest
以下第三到第八節的 Python 程式,依序放進同一個 time_axis.py。
第九節另外存成 test_time_axis.py。
這裡使用 go.Scatter,明確選擇 SVG trace,避免 Rangebreaks 與 WebGL trace 的相容性問題。
本文實測環境為 Python 3.12.12、Plotly 7.1.0、pandas 3.1.0、pytest 9.2.0。
套件版本會前進,請保留專案的 uv.lock,升級後重跑驗證。
我們會刻意保留四種狀況:
| 日期 | 狀況 | 正確解讀 |
|---|---|---|
| 10/20 | 數值為 0 | 有觀測,而且確實是零 |
| 10/21 | 有列,數值為空 | 已回報缺測 |
| 10/27 | 整列不存在 | 預期回報,但沒有收到 |
| 10/23、週末 | 商店休息 | 本來就不在觀測日曆內 |
缺測、零與休息日,是三個不同的故事。
不要讓一個 fillna(0) 把它們全部寫成同一種結局。
三. 日期輸入:先解析,再排序,再拒絕重複 #
Plotly 折線依照輸入順序連接,不會先替你按日期排序。 日期軸只是決定座標位置,不是清理資料的魔法。
from pathlib import Path
import pandas as pd
import plotly.graph_objects as go
raw = pd.DataFrame({
"date": ["2026-10-28", "2026-10-19", "2026-10-21",
"2026-10-20", "2026-10-22", "2026-10-26",
"2026-10-29", "2026-10-30"],
"value": [150, 100, None, 0, 120, 130, 140, 160],
})
def prepare(source):
data = source.copy()
data["date"] = pd.to_datetime(
data["date"], format="%Y-%m-%d", errors="raise"
)
if data["date"].isna().any():
raise ValueError("日期不可為空")
if data["date"].duplicated().any():
raise ValueError("同一天有多列,請先定義聚合或版本規則")
return data.sort_values("date").reset_index(drop=True)
ordered = prepare(raw)
這個小函式刻意不幫你選「同一天最後一筆」。 因為兩筆可能是不同分店,也可能是同一份日報的修訂版。 直接去重或取平均,可能會丟掉真正有意義的資料。
多店資料應先以店名分組,各組各自驗證日期唯一性,再各畫一條 trace。 不要把兩間店交錯塞進同一條線。
這裡的日期只代表商店的日曆日,不是跨時區瞬間。 若來源是帶時區 timestamp,請先完成業務日期轉換;時區細節可看 zoneinfo 篇。
四. 缺列和空值:connectgaps=False 不是缺測偵測器
#
connectgaps=False 能保留輸入陣列裡的空值缺口。
但 10/27 根本沒出現在 ordered 裡,Plotly 不知道那天「應該」有一筆。
即使日期間隔拉長,它也不會替你判定這叫漏報。
因此要先拿出外部規格:這間店在哪些日子應該回報?
closed = pd.DatetimeIndex(["2026-10-23"])
expected = pd.bdate_range("2026-10-19", "2026-10-30")
expected = expected.difference(closed)
def complete_calendar(data, calendar):
if not data["date"].isin(calendar).all():
raise ValueError("資料包含觀測日曆以外的日期")
indexed = data.set_index("date")
result = indexed.reindex(calendar).rename_axis("date")
result["status"] = "observed"
result.loc[result["value"].isna(), "status"] = "reported_missing"
absent = ~result.index.isin(indexed.index)
result.loc[absent, "status"] = "absent_row"
return result.reset_index()
daily = complete_calendar(ordered, expected)
print(daily[["date", "value", "status"]].to_string(index=False))
bdate_range 在這裡只提供週一至週五的骨架。
它不是交易所日曆,也不會自動知道國定假日、補班或臨時店休。
10/23 是我們明確給定的虛構店休日。
執行後有 9 列;其中 10/21 是 reported_missing,10/27 是 absent_row。
兩者在畫線時都要斷開,但在資料品質追查時,責任來源不同。
不要從最早和最晚兩筆資料反推整份報表範圍。 如果開頭或結尾本身漏報,那種作法會把漏報日排除在檢查之外。 本例直接由報表規格固定起訖日。
五. 保留日曆軸:讓缺測真的出現 #
把時間骨架交給 trace,並明確把缺測值轉成 None:
def make_figure(data, title):
values = [None if pd.isna(v) else float(v) for v in data["value"]]
fig = go.Figure(go.Scatter(
x=data["date"].dt.strftime("%Y-%m-%d").tolist(),
y=values,
mode="lines+markers",
connectgaps=False,
name="拍拍商店",
line=dict(color="#31688e", width=3),
marker=dict(size=9),
))
fig.update_xaxes(type="date", tickformat="%m/%d", title="日曆日期")
fig.update_yaxes(title="日報值(示範單位)")
fig.update_layout(
title=title, template="plotly_white", height=440,
margin=dict(t=85, b=70), showlegend=False,
)
return fig
calendar_fig = make_figure(daily, "完整日曆距離;兩個缺測日不連線")
for day in daily.loc[daily["status"] != "observed", "date"]:
calendar_fig.add_vrect(
x0=(day - pd.Timedelta(hours=12)).strftime("%Y-%m-%d %H:%M"),
x1=(day + pd.Timedelta(hours=12)).strftime("%Y-%m-%d %H:%M"),
fillcolor="#efb366", opacity=0.2, line_width=0,
)
淡橘色帶代表缺測日,線則在 10/21 與 10/27 斷開。 有空值的點本身沒有 marker,不能只期待讀者 hover 到一個不存在的點。 色帶加上文字說明,才能讓缺口有可見的解釋。
注意:10/22 到 10/26 仍然會連線。 中間休息日沒有被補成缺測,因為商店本來就不需要回報。 這條線只是在連接相鄰的有效觀測,不代表中間每天都有量測。
如果領域規範要求「超過兩天就不連線」,應另外按間隔切成多條 trace,或插入斷點。
那是新的繪圖政策,不是 connectgaps 會自動做到的事。
六. 日期軸與類別軸:一樣的標籤,不一樣的尺 #
來做一個反例:只保留有效觀測,再把日期改成類別軸。
observed = ordered.dropna(subset=["value"])
date_fig = make_figure(observed, "只有有效觀測:保留日曆距離,但會跨過缺測")
category_fig = go.Figure(date_fig)
category_fig.update_xaxes(
type="category", categoryorder="array",
categoryarray=observed["date"].dt.strftime("%Y-%m-%d").tolist(),
title="觀測順序(每筆等距,不是經過時間)",
)
category_fig.update_layout(title="反例:類別軸把每筆觀測排成等距")
在日期軸上,10/22 到 10/26 的距離,是 10/19 到 10/20 的四倍。 在類別軸上,它們都只差一格。
因此不能拿類別軸線段的視覺斜率,直接比較每日變化速度。 類別軸並非不能用;如果問題是「第幾次觀測」,它可能很合適。 但請把軸名稱寫成觀測順序,不要假裝那是一把連續時間尺。
同樣重要的是:date_fig 雖然保留時間距離,仍會跨過被丟掉的缺測列。
選對軸型別,和保留缺測斷點,是兩個獨立任務。
七. Rangebreaks:休息日可以隱藏,漏報日不可以偷藏 #
另一種需求是想專心看營業日,不讓週末佔據太多空間。
這時可以用 rangebreaks,但必須揭露水平距離已被壓縮。
compressed_fig = go.Figure(calendar_fig)
compressed_fig.update_xaxes(
rangebreaks=[
dict(bounds=["sat", "mon"]),
dict(values=["2026-10-23"], dvalue=24 * 60 * 60 * 1000),
],
title="營業日日期(已隱藏週末與 10/23 店休)",
)
compressed_fig.update_layout(
title="壓縮休息日;10/21、10/27 缺測仍保留",
)
bounds=["sat", "mon"] 隱藏週六到週一之前的時段。
values 指定額外的起點;dvalue 的單位是毫秒,這裡隱藏一天。
它只改變顯示,不會補資料、改變原始日期,或把空值修好。
請特別避免這種寫法:找出所有沒數字的日期,通通塞進 values。
那會把真正的漏報也藏掉,讀者再也看不出品質問題。
本例的 closed 來自營業規格,而不是從資料的空值推測。
10/21 和 10/27 不在隱藏清單裡,所以斷線仍然可見。
官方文件也提醒,Rangebreaks 不支援 scattergl。
本例直接使用 go.Scatter;若換成會自動選 WebGL 的 Express 圖,需檢查 trace 型別,必要時指定 render_mode="svg"。
八. 匯出:把對照版一起交出去 #
不要只交一張「看起來更順」的壓縮版。 保留原始日曆版,讀者才知道那些空間是什麼。
figures = {
"calendar": calendar_fig,
"date-observed-only": date_fig,
"category-observed-only": category_fig,
"compressed": compressed_fig,
}
if __name__ == "__main__":
out = Path("reports")
out.mkdir(exist_ok=True)
for name, fig in figures.items():
fig.write_html(
out / f"{name}.html", include_plotlyjs=True,
full_html=True, div_id=f"time-axis-{name}",
)
daily.to_csv(out / "daily-audit.csv", index=False)
執行 uv run python time_axis.py,會產生四份可離線開啟的 HTML。
daily-audit.csv 則保留缺測分類,方便對照線為什麼中斷。
不要只檢查輸出檔存在;打開 HTML,看看實際線段、日期與陰影。
九. 自動檢查:先驗證契約,再看漂亮程度 #
測試另外放進 test_time_axis.py:
import pandas as pd
import pytest
from time_axis import (
raw, prepare, daily, calendar_fig, category_fig, compressed_fig,
)
def test_sort_and_duplicate_policy():
assert prepare(raw)["date"].is_monotonic_increasing
with pytest.raises(ValueError, match="同一天"):
prepare(pd.concat([raw, raw.iloc[[0]]]))
def test_missing_is_not_zero():
rows = daily.set_index("date")
assert rows.loc["2026-10-20", "value"] == 0
assert rows.loc["2026-10-21", "status"] == "reported_missing"
assert rows.loc["2026-10-27", "status"] == "absent_row"
assert len(daily) == 9
assert daily["value"].isna().sum() == 2
def test_figure_contract():
trace = calendar_fig.data[0]
assert trace.type == "scatter"
assert trace.connectgaps is False
assert list(trace.y).count(None) == 2
assert calendar_fig.layout.xaxis.type == "date"
assert category_fig.layout.xaxis.type == "category"
breaks = compressed_fig.layout.xaxis.rangebreaks
assert list(breaks[0].bounds) == ["sat", "mon"]
assert list(breaks[1].values) == ["2026-10-23"]
assert list(compressed_fig.data[0].y) == list(trace.y)
執行 uv run pytest -q。
這些測試確認資料和 Figure 設定,不能代替瀏覽器的實際渲染檢查。
升級套件後,也要重新打開 HTML,檢查缺口是否仍在、壓縮規則是否符合標題。
結語:時間軸也是一份資料契約 #
拍拍君最想提醒的不是某個參數,而是三條邊界:
- 空值能形成斷點,缺列得先有觀測日曆才認得出來。
- 日期軸保留經過時間,類別軸保留的是項目順序。
- Rangebreaks 可以壓縮已知休息日,不能拿來掩蓋漏報。
圖表畫得順,不等於資料真的連續。 先想清楚線段代表什麼,再決定要不要把它接起來。 拍拍君下次見。