#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 研究儀表板 ========== 把 L4 分析層(案件歷程、法官網絡、穩定度分析、釋憲結果編碼)做成一個離線網頁, 四個分頁,雙擊即開,與主資料庫網頁互相連結。 python _pipeline\\研究儀表板.py 前置:先跑過 _pipeline\\串接案件歷程.py(產生 40_案件歷程/ 的 CSV)。 法官網絡的計算已內建於本檔,不必另外執行 法官網絡.py; 本檔會順便補寫出 法官統計.csv 與 合議庭共事.csv。 穩定度相關分頁若找不到對應 CSV 會自動略過,不會中斷。 產出: 研究儀表板.html 放在資料庫根目錄,與主網頁同層 40_案件歷程/法官統計.csv 40_案件歷程/合議庭共事.csv 口徑提醒(也寫在網頁上): * 法官網絡只涵蓋「串接成功之釋憲原因案件」的承審法官, 節點大小是在這批案件中的出現頻率,不是個人辦案量。 * 穩定度指標的分母才是法官 103–114 年度全部案件。兩者不可混用。 """ import csv, json, re, sys, collections, html, datetime from pathlib import Path ROOT = Path(__file__).resolve().parent.parent D = ROOT / "40_案件歷程" IDX = ROOT / "00_索引" OUT = ROOT / "研究儀表板.html" MAIN = "釋字及憲法法庭資料庫.html" csv.field_size_limit(10 ** 8) def rd(p): """讀 CSV,回傳 list[dict];檔案不存在回空 list。""" p = Path(p) if not p.exists(): return [] with open(p, encoding="utf-8-sig", newline="") as fh: return list(csv.DictReader(fh)) def rdraw(p): """讀 CSV,回傳 (表頭, 列),供通用表格渲染用。""" p = Path(p) if not p.exists(): return [], [] with open(p, encoding="utf-8-sig", newline="") as fh: r = list(csv.reader(fh)) return (r[0], r[1:]) if r else ([], []) TIER = [("最高行政法院", 5), ("最高法院", 5), ("高等行政法院", 4), ("高等法院", 4), ("智慧財產", 4), ("懲戒法院", 4), ("地方法院", 3)] def tier_of(court): for name, lv in TIER: if name in (court or ""): return name, lv return (court or "其他"), 0 def chain_summary(ch): """一條鏈(=一件聲請案的原因案件歷程)的摘要,全部由 CSV 欄位直接算出。""" deep = sorted(ch, key=lambda s: -s["d"]) # 由最下級往上 tiers, seen = [], set() for s in deep: nm, _ = tier_of(s["ct"]) if nm not in seen: seen.add(nm) tiers.append(nm) years = [] for s in ch: m = re.match(r"(\d{2,3})", s.get("ym") or "") if m: years.append(int(m.group(1))) judges = set() for s in ch: judges |= {j for j in (s.get("j") or "").split("、") if j.strip()} adv = [(s.get("adv") or "(無記載)") for s in deep] return {"n": len(ch), "path": " → ".join(tiers), "yr": (f"{min(years)}–{max(years)} 年" if years and min(years) != max(years) else (f"{years[0]} 年" if years else "")), "nj": len(judges), "adv": " → ".join(adv), "chg": "是" if len(set(adv)) > 1 else "否", "root": ch[0]["no"] if ch else ""} def court_kind(c): c = c or "" if c.startswith("最高行政"): return "最高行政法院" if c.startswith("最高"): return "最高法院" if "高等行政" in c: return "高等行政法院" if "高等法院" in c: return "高等法院" if "地方法院" in c: return "地方法院" return "其他" # ---------------------------------------------------------------- 主資料庫標題 def load_titles(): """從 _pipeline/資料庫.json 取出 k → {字號, 爭點},讓儀表板顯示得懂。""" p = ROOT / "_pipeline" / "資料庫.json" if not p.exists(): return {} try: recs = json.load(open(p, encoding="utf-8")) except Exception as e: print(" (資料庫.json 讀取失敗,標題留白)", e) return {} out = {} for r in recs: t = "" for lab, ps in r.get("s", []): if lab in ("解釋爭點", "案由"): t = " ".join(tx for _, tx in ps) break t = re.sub(r"\s+", " ", t).strip() out[r["k"]] = {"z": r.get("z") or r["k"], "t": t[:80], "u": r.get("u", ""), "roc": r.get("roc", ""), "ty": r.get("ty", "")} return out # ---------------------------------------------------------------- 案件歷程 def build_history(titles): ch = rd(D / "案件歷程.csv") dv = {r["釋憲案"]: r for r in rd(D / "辯護型態變化.csv")} fj = rd(D / "確定終局裁判.csv") if not ch: return None bycase = collections.OrderedDict() for r in ch: k = r["釋憲案"] bycase.setdefault(k, {"k": k, "z": r.get("釋憲字號", ""), "rows": []}) bycase[k]["rows"].append(r) ORD = {"確定終局裁判": 0, "前審": 1, "再前審": 2, "未命中": 9} def step(x, depth=0): return {"lv": x["審級"], "ct": x["法院"], "no": x["案號"], "d": depth, "cz": x.get("案由", ""), "fin": x.get("全案終結情形", ""), "ym": x.get("終結年月", ""), "j": x.get("法官", ""), "adv": x.get("辯護/代理型態", ""), "cnt": x.get("當事人筆數") or x.get("被告或罪名筆數", ""), "src": x.get("來源檔", ""), "pre": x.get("前審案號", "")} cases = [] for k, c in bycase.items(): rows = c["rows"] hit = [x for x in rows if x["審級"] != "未命中"] # 一則釋憲案可能合併多位聲請人,各有各的審級鏈; # 而一筆裁判也可能同時有多個前審(最高法院常同時列一審與二審), # 所以每條鏈其實是一棵樹,用「上層案號」還原父子關係。 byno = {} for x in rows: byno.setdefault(x["案號"], x) has_parent_col = any("上層案號" in x for x in rows) kids = collections.defaultdict(list) if has_parent_col: for x in rows: p = (x.get("上層案號") or "").strip() if p: kids[p].append(x) else: # 舊版 CSV 沒有「上層案號」,退回用「前審案號」的第一個指標 for x in rows: p = (x.get("前審案號") or "").split(";")[0].strip() if p and p in byno: kids[x["案號"]].append(byno[p]) used, chains = set(), [] def descend(x, depth, acc): if x["案號"] in used: return used.add(x["案號"]) acc.append(step(x, depth)) for ch2 in kids.get(x["案號"], []): descend(ch2, depth + 1, acc) for x in sorted(rows, key=lambda r: ORD.get(r["審級"], 5)): if x["審級"] != "確定終局裁判" or x["案號"] in used: continue if has_parent_col and (x.get("上層案號") or "").strip(): continue # 不是樹根,等父節點帶它 acc = [] descend(x, 0, acc) if acc: chains.append(acc) miss_no = [] for x in sorted(rows, key=lambda r: ORD.get(r["審級"], 5)): if x["案號"] in used: continue if x["審級"] == "未命中": used.add(x["案號"]) miss_no.append(x["案號"]) else: acc = [] descend(x, 0, acc) if acc: chains.append(acc) ti = titles.get(k, {}) cases.append({ "k": k, "z": c["z"], "t": ti.get("t", ""), "u": ti.get("u", ""), "n": len(hit), "nc": len(chains), "m": miss_no, "d": dv.get(k, {}).get("辯護型態序列", ""), "chg": dv.get(k, {}).get("是否變更", ""), "C": chains, "S": [chain_summary(ch) for ch in chains], }) cases.sort(key=lambda x: (-x["n"], x["k"])) miss = [r for r in fj if not r.get("法院")] det = {} dp = D / "來源明細.json" if dp.exists(): try: det = json.load(open(dp, encoding="utf-8")) except Exception as e: print(" (來源明細.json 讀取失敗)", e) return {"cases": cases, "det": det, "stat": {"裁判": len([r for r in fj if r.get("法院")]), "未取得": len(miss), "串接": sum(1 for c in cases if c["n"]), "未命中": sum(1 for c in cases if not c["n"])}} # ---------------------------------------------------------------- 法官網絡 def build_network(): rows = [r for r in rd(D / "法官參與.csv") if (r.get("法官") or "").strip()] if not rows: return None bycase = collections.defaultdict(lambda: {"法官": set(), "法院": "", "審級": "", "釋憲": set()}) for r in rows: c = bycase[r["案號"]] c["法官"].add(r["法官"].strip()) c["法院"] = r["法院"] c["審級"] = r["審級"] c["釋憲"].add(r["釋憲案"]) stat = collections.defaultdict(lambda: {"案號": set(), "釋憲": set(), "法院": collections.Counter(), "審級": collections.Counter(), "夥伴": collections.Counter()}) for case, c in bycase.items(): js = sorted(c["法官"]) for j in js: s = stat[j] s["案號"].add(case) s["釋憲"] |= c["釋憲"] s["法院"][c["法院"]] += 1 s["審級"][c["審級"]] += 1 for a in js: for b in js: if a != b: stat[a]["夥伴"][b] += 1 D.mkdir(exist_ok=True) with open(D / "法官統計.csv", "w", encoding="utf-8-sig", newline="") as fh: w = csv.writer(fh) w.writerow(["法官", "承審件數", "涉及釋憲案數", "涉及釋憲案", "主要法院", "所有法院", "審級分布", "共事法官人數", "最常共事者"]) for j, s in sorted(stat.items(), key=lambda x: (-len(x[1]["案號"]), x[0])): w.writerow([j, len(s["案號"]), len(s["釋憲"]), "、".join(sorted(s["釋憲"])), s["法院"].most_common(1)[0][0] if s["法院"] else "", "、".join(sorted(s["法院"])), "、".join(f"{k}:{v}" for k, v in s["審級"].most_common()), len(s["夥伴"]), "、".join(f"{k}({v})" for k, v in s["夥伴"].most_common(3))]) pair = collections.Counter() paircase = collections.defaultdict(set) for case, c in bycase.items(): js = sorted(c["法官"]) for i in range(len(js)): for k in range(i + 1, len(js)): pair[(js[i], js[k])] += 1 paircase[(js[i], js[k])].add(case) with open(D / "合議庭共事.csv", "w", encoding="utf-8-sig", newline="") as fh: w = csv.writer(fh) w.writerow(["法官A", "法官B", "共事案件數", "共同案件"]) for (a, b), n in sorted(pair.items(), key=lambda x: -x[1]): w.writerow([a, b, n, ";".join(sorted(paircase[(a, b)]))]) nodes, idx = [], {} for j, s in sorted(stat.items(), key=lambda x: (-len(x[1]["案號"]), x[0])): idx[j] = len(nodes) mc = s["法院"].most_common(1)[0][0] if s["法院"] else "" nodes.append({"n": j, "v": len(s["案號"]), "r": len(s["釋憲"]), "c": court_kind(mc), "ct": mc, "p": len(s["夥伴"]), "lv": "、".join(f"{k}:{v}" for k, v in s["審級"].most_common()), "cs": sorted(s["案號"]), "rk": sorted(s["釋憲"]), "pt": [[k, v] for k, v in s["夥伴"].most_common(12)]}) links = [{"s": idx[a], "t": idx[b], "w": n} for (a, b), n in pair.items()] return {"nodes": nodes, "links": links, "stat": {"法官": len(nodes), "共事關係": len(links), "案件": len(bycase)}} # ---------------------------------------------------------------- 釋憲結果編碼 def build_outcome(titles): rows = rd(D / "釋憲結果.csv") if not rows: return None out = [] for r in rows: k = r["釋憲案"] out.append({"k": k, "R": r.get("R碼", ""), "nm": r.get("結果", ""), "ev": r.get("證據句", ""), "warn": r.get("警告性", ""), "leg": r.get("課予立法義務", ""), "rel": r.get("明示救濟", ""), "dl": r.get("定期失效期限", ""), "cf": r.get("信心", ""), "nt": r.get("註記", ""), "t": titles.get(k, {}).get("t", "")}) out.sort(key=lambda x: x["k"]) book = "" p = IDX / "碼表_釋憲結果.md" if p.exists(): book = p.read_text(encoding="utf-8") return {"rows": out, "book": book, "dist": collections.Counter(x["R"] for x in out).most_common()} # ---------------------------------------------------------------- 穩定度 def build_stability(): rep = "" p = IDX / "穩定度分析報告.md" if p.exists(): rep = p.read_text(encoding="utf-8") tabs = [] for fn, cap in [("合議庭穩定度_對照.csv", "被聲請 vs 同格對照"), ("穩定度與釋憲結果.csv", "依釋憲結果分組"), ("穩定度診斷.csv", "分母偏誤診斷"), ("穩定度分層檢定.csv", "van Elteren 分層檢定")]: h, r = rdraw(D / fn) if h: tabs.append({"f": fn, "c": cap, "h": h, "r": r}) if not rep and not tabs: return None return {"rep": rep, "tabs": tabs} # ---------------------------------------------------------------- markdown def md2html(md): """夠用就好的 Markdown 渲染:標題、表格、清單、粗體、行內碼、引言。""" if not md: return "" out, i, lines = [], 0, md.split("\n") def inl(t): t = html.escape(t) t = re.sub(r"`([^`]+)`", r"\1", t) t = re.sub(r"\*\*([^*]+)\*\*", r"\1", t) return t while i < len(lines): ln = lines[i] if re.match(r"^\s*\|.*\|\s*$", ln) and i + 1 < len(lines) and re.match(r"^\s*\|[\s:\-|]+\|\s*$", lines[i + 1]): hd = [c.strip() for c in ln.strip().strip("|").split("|")] i += 2 body = [] while i < len(lines) and re.match(r"^\s*\|.*\|\s*$", lines[i]): body.append([c.strip() for c in lines[i].strip().strip("|").split("|")]) i += 1 out.append("" + "".join(f"" for c in hd) + "" + "".join("" + "".join(f"" for c in b) + "" for b in body) + "
{inl(c)}
{inl(c)}
") continue m = re.match(r"^(#{1,4})\s+(.*)$", ln) if m: lv = len(m.group(1)) out.append(f"{inl(m.group(2))}") i += 1 continue if re.match(r"^\s*[-*]\s+", ln): li = [] while i < len(lines) and re.match(r"^\s*[-*]\s+", lines[i]): li.append("
  • " + inl(re.sub(r"^\s*[-*]\s+", "", lines[i])) + "
  • ") i += 1 out.append("") continue if re.match(r"^\s*\d+[.、]\s+", ln): li = [] while i < len(lines) and re.match(r"^\s*\d+[.、]\s+", lines[i]): li.append("
  • " + inl(re.sub(r"^\s*\d+[.、]\s+", "", lines[i])) + "
  • ") i += 1 out.append("
      " + "".join(li) + "
    ") continue if ln.startswith(">"): out.append("
    " + inl(ln.lstrip("> ")) + "
    ") i += 1 continue if re.match(r"^\s*---+\s*$", ln): out.append("
    ") i += 1 continue if ln.strip(): buf = [] while i < len(lines) and lines[i].strip() and not re.match(r"^\s*[|#>\-*]|^\s*\d+[.、]\s", lines[i]): buf.append(lines[i].strip()) i += 1 if buf: out.append("

    " + inl(" ".join(buf)) + "

    ") else: i += 1 continue i += 1 return "".join(out) CSS = """ *{box-sizing:border-box} body{margin:0;font:15px/1.75 "Noto Sans TC","Microsoft JhengHei","PingFang TC",system-ui,sans-serif; color:#1b1b1b;background:#f4f2ee} header{background:#2c3e50;color:#fff;padding:14px 22px 0;position:sticky;top:0;z-index:30; box-shadow:0 2px 8px rgba(0,0,0,.18)} header h1{margin:0 0 4px;font-size:19px;font-weight:600;letter-spacing:.5px} header .sub{font-size:12px;color:#b9c6d2;margin-bottom:10px} header .sub a{color:#8fd3ff} header .stamp{margin-left:14px;padding:1px 8px;border:1px solid #4d6b86;border-radius:9px; color:#9fb4c6;font-size:11px;white-space:nowrap} nav{display:flex;gap:2px} nav button{background:#3b5168;color:#cfdae4;border:0;padding:9px 20px;font-size:14px;cursor:pointer; border-radius:6px 6px 0 0;font-family:inherit} nav button:hover{background:#48627d;color:#fff} nav button.on{background:#f4f2ee;color:#2c3e50;font-weight:600} main{padding:20px 22px 60px;max-width:1400px;margin:0 auto} .pane{display:none}.pane.on{display:block} .card{background:#fff;border:1px solid #e0dcd4;border-radius:8px;padding:16px 20px;margin-bottom:16px} .card h2{margin:0 0 10px;font-size:16px;color:#2c3e50;border-bottom:2px solid #eee7dd;padding-bottom:6px} .card h3{font-size:14px;color:#44607d;margin:16px 0 6px} .note{background:#fff8e6;border-left:4px solid #e0a800;padding:10px 14px;border-radius:0 6px 6px 0; font-size:13px;color:#6b5400;margin-bottom:14px} .note b{color:#4a3a00} .kpi{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:14px} .kpi div{background:#fff;border:1px solid #e0dcd4;border-radius:8px;padding:10px 18px;min-width:110px} .kpi span{display:block;font-size:12px;color:#7b7568} .kpi b{font-size:22px;color:#2c3e50;font-weight:600} input[type=text],select{font-family:inherit;font-size:14px;padding:6px 10px;border:1px solid #ccc5b9; border-radius:6px;background:#fff} input[type=text]:focus,select:focus{outline:2px solid #7aa7d0;border-color:#7aa7d0} button.btn{font-family:inherit;font-size:13px;padding:6px 14px;border:1px solid #ccc5b9;background:#fff; border-radius:6px;cursor:pointer} button.btn:hover{background:#eef3f8;border-color:#7aa7d0} table{border-collapse:collapse;width:100%;font-size:13px;margin:8px 0} th,td{border:1px solid #e4e0d8;padding:6px 9px;text-align:left;vertical-align:top} th{background:#f0ede6;font-weight:600;color:#3d3a33;white-space:nowrap} tbody tr:nth-child(even){background:#faf9f6} .split{display:grid;grid-template-columns:320px 1fr;gap:16px;align-items:start} .list{background:#fff;border:1px solid #e0dcd4;border-radius:8px;max-height:74vh;overflow:auto} .list .it{padding:8px 12px;border-bottom:1px solid #f0ede6;cursor:pointer;font-size:13px} .list .it:hover{background:#eef3f8} .list .it.on{background:#dbe9f6;border-left:3px solid #2c3e50} .list .it b{display:block;color:#2c3e50} .list .it span{color:#7b7568;font-size:12px} .chain{position:relative;padding-left:22px} .chain:before{content:'';position:absolute;left:6px;top:8px;bottom:8px;width:2px;background:#d8d2c6} .step{position:relative;margin-bottom:14px} .step:before{content:'';position:absolute;left:-21px;top:8px;width:11px;height:11px;border-radius:50%; background:#2c3e50;border:2px solid #fff;box-shadow:0 0 0 2px #d8d2c6} .step .lv{display:inline-block;background:#2c3e50;color:#fff;font-size:11px;padding:1px 8px; border-radius:10px;margin-right:8px} .step .lv.p{background:#6b8299}.step .lv.pp{background:#9aa8b5}.step .lv.x{background:#b06a6a} .step .no{font-weight:600;color:#1b1b1b} .step dl{display:grid;grid-template-columns:96px 1fr;gap:2px 10px;margin:6px 0 0;font-size:13px} .step dt{color:#7b7568}.step dd{margin:0} .tag{display:inline-block;background:#eef3f8;border:1px solid #cfdcea;color:#2c3e50;font-size:12px; padding:1px 8px;border-radius:10px;margin:1px 3px 1px 0;cursor:pointer} .tag:hover{background:#d6e6f5} .tag.r{background:#fdecec;border-color:#f0c4c4;color:#8e2f2f} .tag.g{background:#e9f5ec;border-color:#c2e0cb;color:#2c6b3f} .tag.y{background:#fff6e0;border-color:#eddcae;color:#7a5a00} .tag.on{background:#2c3e50;color:#fff;border-color:#2c3e50} #cv{width:100%;height:70vh;background:#fff;border:1px solid #e0dcd4;border-radius:8px;display:block; cursor:grab} #cv.drag{cursor:grabbing} .ctl{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:10px;font-size:13px} .ctl label{color:#5c574d} .side{background:#fff;border:1px solid #e0dcd4;border-radius:8px;padding:14px;max-height:70vh;overflow:auto} .side h3{margin:0 0 8px;font-size:16px;color:#2c3e50} .ev{font-size:13px;line-height:1.7;color:#33302a} .mono{font-family:ui-monospace,Consolas,monospace;font-size:12px} .muted{color:#7b7568;font-size:12px} a.lnk{color:#20548c;text-decoration:none;border-bottom:1px dotted #90b4d6} a.lnk:hover{background:#eef3f8} .pcard{border:1px solid #e0dcd4;border-radius:8px;margin:0 0 12px;background:#fdfcfa;overflow:hidden} .pcard.on{border-color:#c8d6e4;box-shadow:0 1px 4px rgba(44,62,80,.07)} .phd{display:flex;align-items:center;gap:10px;padding:9px 14px;cursor:pointer;background:#f2efe9} .pcard.on .phd{background:#e8eef5} .phd:hover{background:#e3ebf3} .phd .ar{color:#7b7568;font-size:12px;width:12px} .phd b{color:#2c3e50;font-size:14px;white-space:nowrap} .phd .pno{color:#5c574d;font-size:13px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .phd .cp{flex:none;font-size:12px;padding:3px 10px} .pmeta{display:flex;flex-wrap:wrap;gap:8px;padding:8px 14px 0;font-size:12.5px} .pmeta span{background:#eef3f8;border:1px solid #d9e4ee;border-radius:10px;padding:1px 9px;color:#3d5c7a} .padv{padding:8px 14px 0;font-size:13px;color:#44403a} .pbody{padding:10px 14px 12px} .rawbox{margin:8px 0 4px;padding:10px 14px;background:#f7f6f2;border:1px solid #e4e0d8;border-radius:6px} .rawbox h4{margin:12px 0 4px;font-size:13px;color:#44607d} .rawbox h4:first-of-type{margin-top:4px} .rawbox table{margin:0 0 6px} .rawbox th{width:230px;font-weight:500;background:#efece5;font-size:12px;white-space:normal} .rawbox td{font-size:12px} .legend span{display:inline-flex;align-items:center;gap:5px;margin-right:12px;font-size:12px;color:#5c574d} .legend i{width:11px;height:11px;border-radius:50%;display:inline-block} blockquote{margin:10px 0;padding:8px 14px;border-left:4px solid #cfdcea;background:#f7fafd; color:#3d5c7a;font-size:14px} code{background:#f0ede6;padding:1px 5px;border-radius:4px;font-family:ui-monospace,Consolas,monospace; font-size:.92em} hr{border:0;border-top:1px solid #e4e0d8;margin:16px 0} """ JS = r""" (function(){ var $=function(s){return document.querySelector(s)}; var esc=function(t){return (t==null?'':String(t)).replace(/[&<>"]/g,function(c){ return {'&':'&','<':'<','>':'>','"':'"'}[c];})}; var MAIN=DASH.main; function dbLink(k,txt){return '' +esc(txt||k)+'';} /* ---------- 分頁 ---------- */ var panes=[].slice.call(document.querySelectorAll('.pane')); var btns=[].slice.call(document.querySelectorAll('nav button')); function go(id,sub){ panes.forEach(function(p){p.classList.toggle('on',p.id==='p-'+id)}); btns.forEach(function(b){b.classList.toggle('on',b.dataset.t===id)}); if(id==='net'&&NET&&!NET.started)startNet(); history.replaceState(null,'','#'+id+(sub?'/'+encodeURIComponent(sub):'')); } btns.forEach(function(b){b.onclick=function(){go(b.dataset.t)}}); /* ---------- 分頁一:案件歷程 ---------- */ var HIS=DASH.his; if(HIS){ var cur=null; function drawList(q){ q=(q||'').trim(); var rs=HIS.cases.filter(function(c){ if(!q)return true; var flat=c.C.map(function(ch){return ch.map(function(x){return x.no+x.j+x.cz}).join('')}).join(''); return (c.k+c.z+c.t+flat).indexOf(q)>=0;}); $('#hCnt').textContent=rs.length+' 件'; $('#hList').innerHTML=rs.map(function(c){ return '
    '+esc(c.z||c.k)+'' +''+(c.n?c.nc+' 件原因案件/'+c.n+' 筆裁判':'未串接') +(c.t?' | '+esc(c.t.slice(0,22)):'')+'
    '; }).join('')||'
    沒有符合的案件
    '; } var LVC={'確定終局裁判':'','前審':'p','再前審':'pp','未命中':'x'}; function drawCase(k){ cur=k; var c=HIS.cases.filter(function(x){return x.k===k})[0]; if(!c){$('#hDet').innerHTML='';return;} var nm=esc(c.z||c.k); var h='

    ' +(c.u?''+nm+'':nm) +' ' +(c.u?'案名連官網原文':'')+' '+dbLink(c.k,'本機資料庫')+'

    '; if(c.t)h+='

    '+esc(c.t)+'

    '; if(c.d)h+='

    辯護/代理型態(由下而上):'+esc(c.d) +(c.chg?' '+esc(c.chg)+'':'')+'

    '; if(c.nc>1)h+='

    本案有 '+c.nc+' 件原因案件(合併多位聲請人)。' +'一件確定終局裁判=一件聲請案,以下每張卡片就是一位聲請人的完整案件鏈。' +'' +'

    '; c.C.forEach(function(ch,ci){ var S=(c.S||[])[ci]||{}; var open=c.nc<=1; h+='
    ' +'
    '+(open?'▾':'▸')+'' +'聲請案 '+(ci+1)+'' +''+esc(S.root||'')+'' +'
    ' +'
    ' +''+(S.n||1)+' 個審級' +(S.path?''+esc(S.path)+'':'') +(S.yr?''+esc(S.yr)+'':'') +''+(S.nj||0)+' 位法官' +(S.chg==='是'?'辯護型態有變更':'') +'
    ' +(S.adv?'
    辯護/代理型態(由下而上):'+esc(S.adv)+'
    ':'') +'
    '; }); if(c.m&&c.m.length)h+='

    未命中('+c.m.length+' 筆)

    ' +'

    這些確定終局裁判在 DisposedCase 資料集中查無對應紀錄,' +'不代表沒有前審。

    '+c.m.map(esc).join('
    ')+'
    '; h+='
    '; $('#hDet').innerHTML=h; drawList($('#hQ').value); } $('#hQ').addEventListener('input',function(){drawList(this.value)}); $('#hList').addEventListener('click',function(e){ var it=e.target.closest('.it'); if(it&&it.dataset.k)drawCase(it.dataset.k);}); // 來源檔展開:把 DisposedCase 該筆的原始欄位與當事人組成攤開 var GRP=[['案號與法院',/法院|案號|案由|字別|裁判書ID|來源檔名|案號鍵/], ['審級與前審',/原審|一審|二審|前審|簡易庭/], ['終結',/終結|得上訴|不得上訴|得抗告|不得抗告|跨年/], ['法官',/法官/], ['辯護與代理',/辯護|律師|代理/], ['當事人',/當事人|身分|人數|序號/], ['金額與賠償',/金額|賠償|罰金|沒收/], ['公約引用',/公約/]]; function rawTable(no){ var d=HIS.det&&HIS.det[no]; if(!d)return '

    此案號沒有留存原始明細。請重跑 ' +'python _pipeline\\串接案件歷程.py <DisposedCase路徑> 產生 來源明細.json。

    '; var raw=d['原始']||{},keys=Object.keys(raw).filter(function(k){return raw[k]!==''}); var used={},h='

    來源檔 '+esc(d['來源檔']) +',該案號在此檔共 '+d['筆數']+' 列(=當事人筆數),以下為第 1 列的全部非空欄位。

    '; GRP.forEach(function(g){ var ks=keys.filter(function(k){return !used[k]&&g[1].test(k)}); if(!ks.length)return; ks.forEach(function(k){used[k]=1}); h+='

    '+g[0]+'

    '+ks.map(function(k){ return '';}).join('')+'
    '+esc(k)+''+esc(raw[k])+'
    '; }); var rest=keys.filter(function(k){return !used[k]}); if(rest.length)h+='

    其他

    '+rest.map(function(k){ return '';}).join('')+'
    '+esc(k)+''+esc(raw[k])+'
    '; var ps=d['當事人']||[]; h+='

    當事人組成

    '; if(ps.length){ var cols=[];ps.forEach(function(p){Object.keys(p).forEach(function(k){ if(cols.indexOf(k)<0)cols.push(k)})}); h+='' +cols.map(function(c){return ''}).join('')+'' +ps.map(function(p){return ''+cols.map(function(c){ return ''}).join('')+''}).join('')+'
    '+esc(c)+'
    '+esc(p[c]||'')+'
    '; }else{ h+='

    此案類的來源檔沒有當事人層級欄位(`當事人序號`、`身分類別` ' +'只出現在 387 個檔中的 151 與 21 個)。本案在來源檔中占 '+d['筆數'] +' 列,該列數即當事人數。

    '; } h+='

    這份資料不含任何姓名。當事人只有序號與身分類別,' +'序號僅在案內有效,跨案沒有個人識別碼,因此無法列出被告姓名,' +'也無法以個人為單位追蹤其前案。一條審級鏈=一件確定終局裁判(一個案號),不等於一位被告。

    '; return h+'
    '; } function toggleCard(card,force){ var body=card.querySelector('.pbody'), ar=card.querySelector('.ar'); var open=force===undefined?body.style.display==='none':force; body.style.display=open?'block':'none'; ar.textContent=open?'▾':'▸'; card.classList.toggle('on',open); } function copyChain(ci){ var c=HIS.cases.filter(function(x){return x.k===cur})[0]; if(!c)return; var ch=c.C[ci]||[], S=(c.S||[])[ci]||{}; var md='### '+(c.z||c.k)+' 聲請案 '+(ci+1)+'\n\n' +'確定終局裁判:'+(S.root||'')+' | '+(S.n||1)+' 個審級 | '+(S.path||'') +(S.yr?' | '+S.yr:'')+'\n\n' +'| 審級 | 案號 | 案由 | 終結情形 | 終結年月 | 承審法官 | 辯護/代理型態 | 來源檔 |\n' +'|---|---|---|---|---|---|---|---|\n'; ch.forEach(function(s){ md+='| '+[s.lv,s.no,s.cz,s.fin,s.ym,s.j,s.adv,s.src] .map(function(x){return (x||'').replace(/\|/g,'\\|')}).join(' | ')+' |\n';}); md+='\n資料來源:司法院終結案件資料(DisposedCase 多年期),' +'以法院+年+字別+號精確比對串接;該資料集不含當事人姓名。\n'; navigator.clipboard.writeText(md); } $('#hDet').addEventListener('click',function(e){ var cp=e.target.closest('[data-cp]'); if(cp){ copyChain(+cp.dataset.cp); var o=cp.textContent; cp.textContent='已複製'; setTimeout(function(){cp.textContent=o},1500); e.stopPropagation(); return; } var hd=e.target.closest('.phd'); if(hd){ toggleCard(hd.parentNode); return; } if(e.target.id==='chAll'||e.target.id==='chNone'){ var op=e.target.id==='chAll'; [].slice.call($('#hDet').querySelectorAll('.pcard')).forEach(function(cd){toggleCard(cd,op)}); return; } var s=e.target.closest('[data-src]'); if(s){ var box=null; [].slice.call($('#hDet').querySelectorAll('.raw')).forEach(function(b){ if(b.dataset.raw===s.dataset.src)box=b;}); if(box){ if(box.style.display==='none'){ if(!box.innerHTML)box.innerHTML=rawTable(s.dataset.src); box.style.display='block'; s.innerHTML=s.innerHTML.replace('▾','▴'); } else { box.style.display='none'; s.innerHTML=s.innerHTML.replace('▴','▾'); } } return; } var t=e.target.closest('[data-j]'); if(t){go('net');setTimeout(function(){focusJudge(t.dataset.j)},80);}}); drawList(''); window.showCase=drawCase; window.openCase=function(k){go('his',k);drawCase(k); document.getElementById('hDet').scrollIntoView({behavior:'smooth',block:'start'});}; } /* ---------- 分頁二:法官網絡 ---------- */ var NET=DASH.net,sim=null; var COL={'最高法院':'#8e2f2f','最高行政法院':'#b06a2f','高等法院':'#2c3e50','高等行政法院':'#3d6b8e', '地方法院':'#4a7a55','其他':'#8a8578'}; function startNet(){ NET.started=1; var cv=$('#cv'),ctx=cv.getContext('2d'); var N=NET.nodes.map(function(n,i){return Object.assign({},n,{i:i})}); var L=NET.links.slice(); var W=0,H=0,tx=0,ty=0,sc=1,sel=null,hov=null,minW=1,filt='',kind=''; function size(){var r=cv.getBoundingClientRect();W=cv.width=r.width*devicePixelRatio; H=cv.height=r.height*devicePixelRatio;} size();window.addEventListener('resize',function(){size();draw()}); // 初始位置:依法院層級分環 var KS=Object.keys(COL); var RR=Math.max(1,Math.sqrt(N.length/40)); N.forEach(function(n,i){ var a=i/N.length*Math.PI*2, r=(120+Math.max(0,KS.indexOf(n.c))*70)*RR; n.x=Math.cos(a)*r;n.y=Math.sin(a)*r;n.vx=0;n.vy=0; n.rad=4+Math.sqrt(n.v)*3.2;}); sc=Math.min(1,1.6/RR); var adj={};L.forEach(function(l){(adj[l.s]=adj[l.s]||[]).push(l.t);(adj[l.t]=adj[l.t]||[]).push(l.s);}); var alpha=1; function tick(){ // 斥力(近似:對抽樣鄰域) for(var i=0;i90000)continue; var f=1400/d2,d=Math.sqrt(d2); a.vx-=dx/d*f;a.vy-=dy/d*f;b.vx+=dx/d*f;b.vy+=dy/d*f; } } L.forEach(function(l){ var a=N[l.s],b=N[l.t],dx=b.x-a.x,dy=b.y-a.y,d=Math.sqrt(dx*dx+dy*dy)||1; var tgt=42+ (a.rad+b.rad), f=(d-tgt)*0.012*Math.min(l.w,4); a.vx+=dx/d*f;a.vy+=dy/d*f;b.vx-=dx/d*f;b.vy-=dy/d*f;}); N.forEach(function(n){ n.vx-=n.x*0.0016;n.vy-=n.y*0.0016; n.x+=n.vx*alpha;n.y+=n.vy*alpha;n.vx*=0.72;n.vy*=0.72;}); alpha*=0.994; } function vis(n){ if(kind&&n.c!==kind)return false; if(n.p<0)return false; return true; } function draw(){ ctx.setTransform(1,0,0,1,0,0);ctx.clearRect(0,0,W,H); ctx.setTransform(sc*devicePixelRatio,0,0,sc*devicePixelRatio,W/2+tx*devicePixelRatio,H/2+ty*devicePixelRatio); var hi=sel!=null?{}:null; if(sel!=null){hi[sel]=1;(adj[sel]||[]).forEach(function(x){hi[x]=1})} ctx.lineWidth=0.8; L.forEach(function(l){ if(l.w0.9&&n.rad>7||n.i===sel||n.i===hov||(filt&&n.n.indexOf(filt)>=0))){ ctx.globalAlpha=1;ctx.fillStyle='#1b1b1b'; ctx.font=(n.i===sel?'bold ':'')+'11px "Noto Sans TC",sans-serif'; ctx.fillText(n.n,n.x+n.rad+3,n.y+4);} ctx.globalAlpha=1;}); } var run=0,RUNMAX=N.length>250?400:600; function loop(){ if(run3)dg.m=1; tx=dg.tx+dx;ty=dg.ty+dy;return;} var p=pick(e); if(p!==hov){hov=p;cv.style.cursor=p!=null?'pointer':'grab';}}); cv.addEventListener('wheel',function(e){e.preventDefault(); var k=e.deltaY<0?1.12:0.89; sc=Math.max(0.25,Math.min(4,sc*k));},{passive:false}); function select(i){ sel=i; if(i==null){$('#nSide').innerHTML='

    點選任一節點,看該法官的承審案件、共事對象與涉及的釋憲案。

    ';return;} var n=N[i]; var h='

    '+esc(n.n)+'

    '; h+='

    '+esc(n.ct||n.c)+' '+esc(n.lv)+'

    '; h+='' +'' +'
    承審件數'+n.v+'
    涉及釋憲案'+n.r+'
    共事法官'+n.p+' 位
    '; h+='

    涉及之釋憲案

    ' +n.rk.map(function(k){return ''+esc(k)+'';}).join('')+'
    '; h+='

    最常共事

    ' +(n.pt.length?n.pt.map(function(p){return ''+esc(p[0])+' ('+p[1]+')';}).join(''):'')+'
    '; h+='

    承審案號

    ' +n.cs.map(esc).join('
    ')+'
    '; $('#nSide').innerHTML=h; } select(null); $('#nSide').addEventListener('click',function(e){ var t=e.target.closest('[data-nm]'); if(t){focusJudge(t.dataset.nm);return;} var c=e.target.closest('[data-k]'); if(c&&window.openCase)window.openCase(c.dataset.k);}); $('#nQ').addEventListener('input',function(){ filt=this.value.trim(); if(filt){for(var i=0;i'+dbLink(r.k) +(HIS?' 歷程':'')+'' +''+esc(r.R)+'
    '+esc(r.nm)+'' +''+esc(r.ev)+(r.nt?'
    '+esc(r.nt)+'':'')+'' +''+esc(f.join('、'))+(r.cf?'
    信心:'+esc(r.cf):'')+''; }).join('')||'沒有符合的紀錄'; } $('#oChips').addEventListener('click',function(e){ var t=e.target.closest('[data-r]'); if(!t)return; var r=t.dataset.r; if(pick.has(r))pick.delete(r);else pick.add(r); t.classList.toggle('on');drawOc();}); $('#oQ').addEventListener('input',function(){q=this.value.trim();drawOc();}); $('#oTb').addEventListener('click',function(e){ var t=e.target.closest('[data-k]'); if(t&&window.openCase)window.openCase(t.dataset.k);}); $('#oBook').onclick=function(){var b=$('#oBookBox'); b.style.display=b.style.display==='none'?'block':'none';}; drawOc(); } // 深層連結:#his/釋字0812 直接開到該案 var route=(location.hash||'').slice(1).split('/'); var tab=['his','net','sta','oc'].indexOf(route[0])>=0?route[0]:'his'; var key=route[1]?decodeURIComponent(route[1]):''; go(tab); if(HIS&&HIS.cases.length){ var hit=key&&HIS.cases.some(function(c){return c.k===key}); window.showCase(hit?key:HIS.cases[0].k); } })(); """ # ---------------------------------------------------------------- HTML def write_html(his, net, sta, oc): dash = {"main": MAIN, "his": his, "net": net, "oc": oc} payload = json.dumps(dash, ensure_ascii=False).replace("
    口徑:一件確定終局裁判=一件聲請案,也就是一位聲請人(或一組共同聲請人)的原因案件, 往下回溯到各審級即為一張卡片。聲請人的姓名不在資料裡——司法院終結案件資料為無姓名版, 卡片以「聲請案 N」與確定終局裁判案號識別,不以人名識別。 資料只涵蓋 DisposedCase 103–114 年度,故「未命中」不等於沒有前審。點法官姓名可跳到網絡分頁。
    已取得確定終局裁判{s['裁判']}
    未取得{s['未取得']}
    至少串到一層{s['串接']}
    未命中{s['未命中']}
    """) else: panes.append('

    案件歷程追蹤

    ' '

    找不到 40_案件歷程/案件歷程.csv,請先執行 ' 'python _pipeline\\串接案件歷程.py

    ') # --- 分頁二 tabs.append(("net", "法官橫向網絡")) if net: s = net["stat"] leg = "".join(f'{k}' for k, c in [("最高法院", "#8e2f2f"), ("最高行政法院", "#b06a2f"), ("高等法院", "#2c3e50"), ("高等行政法院", "#3d6b8e"), ("地方法院", "#4a7a55"), ("其他", "#8a8578")]) opt = "".join(f'' for k in ["最高法院", "最高行政法院", "高等法院", "高等行政法院", "地方法院", "其他"]) panes.append(f"""
    這張圖不是辦案量排名。節點大小=該法官在 串接成功之釋憲原因案件中出現的案件數,樣本只有 {s['案件']} 件; 邊=同一案號同時列名(合議庭共事)。要看法官的完整辦案量,請用穩定度分頁的指標, 那組數字才是以 103–114 年度全部案件為分母。
    法官{s['法官']}
    共事關係{s['共事關係']}
    案件{s['案件']}
    滾輪縮放,拖曳平移,點節點看細節
    {leg}
    """) else: panes.append('

    法官橫向網絡

    ' '

    找不到 40_案件歷程/法官參與.csv。

    ') # --- 分頁三 tabs.append(("sta", "穩定度分析報告")) if sta: body = md2html(sta["rep"]) raw = "" for t in sta["tabs"]: raw += (f'

    {html.escape(t["c"])} {html.escape(t["f"])}

    ' "" + "".join(f"" for c in t["h"]) + "" + "".join("" + "".join(f"" for c in r) + "" for r in t["r"]) + "
    {html.escape(c)}
    {html.escape(c)}
    ") panes.append(f"""
    結論是虛無的,而且這是有意義的結論。 合議庭穩定度與釋憲結果無關聯,與是否被聲請釋憲亦無關聯(分層後三項指標皆不顯著)。 另外:對照表中的加權層內位移欄採分層大小加權,與 van Elteren 的 1/(N+1) 權重不一致, 兩者符號會相反,該欄不要引用
    {body}

    原始輸出表

    {raw}
    """) else: panes.append('

    穩定度分析報告

    ' '

    找不到 00_索引/穩定度分析報告.md 或 40_案件歷程/ 的穩定度 CSV。

    ') # --- 分頁四 tabs.append(("oc", "釋憲結果編碼")) if oc: NAME = {"R1": "違憲即時失效", "R2": "違憲定期失效", "R3": "違憲未宣告失效", "R4": "合憲", "R5": "合憲性解釋", "R6": "部分違憲部分合憲", "R7": "程序駁回", "R8": "其他或統一解釋"} RC = {"R1": "r", "R2": "r", "R3": "r", "R6": "y", "R4": "g", "R5": "g"} chips = "".join(f'{r} {NAME.get(r,"")} ' f'{n}' for r, n in sorted(oc["dist"])) panes.append(f"""
    這是人工編碼,不是原文。每一筆的證據句都是從解釋文/主文逐字節錄, 可回主資料庫核對;碼別本身是依碼表所作的判斷。目前只編了 {len(oc['rows'])} 則 (釋字1–58、697–813 及全部憲判),釋字59–696 尚未編碼。
    {chips}
    釋憲案結果碼 證據句(逐字節錄)附變項
    """) else: panes.append('

    釋憲結果編碼

    ' '

    找不到 40_案件歷程/釋憲結果.csv。

    ') nav = "".join(f'' for t, n in tabs) stamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M") ndet = len((his or {}).get("det") or {}) doc = f""" 研究儀表板|釋字及憲法法庭資料庫

    研究儀表板

    釋字及憲法法庭資料庫 L4 分析層 | 開啟主資料庫(原文與意見書) | 本頁僅含分析產出,原文一律以主資料庫與官網為準 建置 {stamp} 來源明細 {ndet} 件
    {''.join(panes)}
    """ OUT.write_text(doc, encoding="utf-8") return len(doc.encode("utf-8")) def main(): try: sys.stdout.reconfigure(encoding="utf-8") except Exception: pass if not D.exists(): print("找不到", D, "\n請先執行 python _pipeline\\串接案件歷程.py") sys.exit(1) print("[1/5] 讀取主資料庫標題 …") titles = load_titles() print(f" {len(titles)} 則") print("[2/5] 案件歷程 …") his = build_history(titles) print(" " + (f"{len(his['cases'])} 件釋憲案" if his else "略過(無 案件歷程.csv)")) print("[3/5] 法官網絡 …") net = build_network() print(" " + (f"{net['stat']['法官']} 位法官、{net['stat']['共事關係']} 組共事關係" "(已寫出 法官統計.csv/合議庭共事.csv)" if net else "略過(無 法官參與.csv)")) print("[4/5] 穩定度與釋憲結果 …") sta = build_stability() oc = build_outcome(titles) print(" 穩定度:" + (f"{len(sta['tabs'])} 張表" if sta else "略過") + "/結果編碼:" + (f"{len(oc['rows'])} 則" if oc else "略過")) print("[5/5] 產生網頁 …") n = write_html(his, net, sta, oc) print(f"\n完成:{OUT}") print(f" {n/1024/1024:.1f} MB,雙擊即開,無須網路") if __name__ == "__main__": main()