2053 lines
75 KiB
Python
2053 lines
75 KiB
Python
import json
|
|
from html import escape
|
|
from pathlib import Path
|
|
from typing import Dict, List
|
|
|
|
|
|
def _safe(value: str) -> str:
|
|
return escape(str(value or ""))
|
|
|
|
|
|
def _folder_tree(rows: List[dict]) -> dict:
|
|
root = {"name": "ROOT", "path": "", "count": 0, "children": {}}
|
|
|
|
for row in rows:
|
|
p = row.get("path") or ""
|
|
parts = [x for x in p.split("/") if x]
|
|
if not parts:
|
|
continue
|
|
|
|
# Folder-only tree: for file items exclude last segment.
|
|
folder_depth = len(parts) if row.get("item_kind") == "FOLDER" else len(parts) - 1
|
|
if folder_depth <= 0:
|
|
continue
|
|
|
|
node = root
|
|
node["count"] += 1
|
|
current = []
|
|
for i in range(folder_depth):
|
|
part = parts[i]
|
|
current.append(part)
|
|
key = "/".join(current)
|
|
if part not in node["children"]:
|
|
node["children"][part] = {
|
|
"name": part,
|
|
"path": key,
|
|
"count": 0,
|
|
"children": {},
|
|
}
|
|
node = node["children"][part]
|
|
node["count"] += 1
|
|
|
|
return root
|
|
|
|
|
|
def _render_tree(node: dict) -> str:
|
|
if not node["children"]:
|
|
return ""
|
|
|
|
out = ["<ul class='tree-ul'>"]
|
|
for key in sorted(node["children"].keys()):
|
|
child = node["children"][key]
|
|
nested = _render_tree(child)
|
|
label = f"{_safe(child['name'])} <span class='cnt'>({_safe(child['count'])})</span>"
|
|
if nested:
|
|
out.append(
|
|
f"<li><details><summary class='tree-node' data-path='{_safe(child['path'])}'>{label}</summary>{nested}</details></li>"
|
|
)
|
|
else:
|
|
out.append(
|
|
f"<li><button class='tree-leaf tree-node' data-path='{_safe(child['path'])}'>{label}</button></li>"
|
|
)
|
|
out.append("</ul>")
|
|
return "".join(out)
|
|
|
|
|
|
def render_explorer(curated_rows: List[dict], output_html: Path, build_id: str = "") -> None:
|
|
rows = sorted(curated_rows, key=lambda x: (x.get("path") or "", x.get("name") or ""))
|
|
tree_html = _render_tree(_folder_tree(rows))
|
|
|
|
payload_rows = []
|
|
for r in rows:
|
|
payload_rows.append(
|
|
{
|
|
"item_id": r.get("item_id", ""),
|
|
"name": r.get("name", ""),
|
|
"path": r.get("path", ""),
|
|
"root_path": r.get("root_path", ""),
|
|
"item_kind": r.get("item_kind", ""),
|
|
"file_category": r.get("file_category", ""),
|
|
"original_owner_email": r.get("original_owner_email", ""),
|
|
"shared_by_email": r.get("shared_by_email", ""),
|
|
"access_scope": r.get("access_scope", ""),
|
|
"shared_with_count": int(r.get("shared_with_count", 0) or 0),
|
|
"access_targets": r.get("access_targets", ""),
|
|
"permission_entries": r.get("permission_entries", ""),
|
|
"risk_level": r.get("risk_level", "LOW"),
|
|
"risk_reason": r.get("risk_reason", ""),
|
|
"risk_score": int(r.get("risk_score", 0) or 0),
|
|
"risk_flags": r.get("risk_flags", ""),
|
|
"external_target_count": int(r.get("external_target_count", 0) or 0),
|
|
"writer_target_count": int(r.get("writer_target_count", 0) or 0),
|
|
}
|
|
)
|
|
|
|
rows_json = json.dumps(payload_rows, ensure_ascii=True)
|
|
|
|
html = f"""<!doctype html>
|
|
<html lang=\"en\">
|
|
<head>
|
|
<meta charset=\"utf-8\" />
|
|
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
|
|
<title>GDrive Explorer + Diagram</title>
|
|
<style>
|
|
:root {{
|
|
--bg: #edf2f7;
|
|
--panel: #ffffff;
|
|
--line: #d7e0ea;
|
|
--ink: #111827;
|
|
--muted: #4b5563;
|
|
--path: #facc15;
|
|
--low: #475569;
|
|
--med: #d97706;
|
|
--high: #dc2626;
|
|
--col1: 16fr;
|
|
--col2: 34fr;
|
|
--col3: 50fr;
|
|
}}
|
|
* {{ box-sizing: border-box; }}
|
|
body {{ margin: 0; font-family: "Segoe UI", Arial, sans-serif; background: var(--bg); color: var(--ink); }}
|
|
.wrap {{ width: 100%; max-width: none; margin: 0; padding: 10px; }}
|
|
|
|
.toolbar {{
|
|
display: grid;
|
|
grid-template-columns: minmax(220px, 1fr) auto auto auto auto;
|
|
gap: 8px;
|
|
align-items: center;
|
|
background: var(--panel);
|
|
border: 1px solid var(--line);
|
|
border-radius: 12px;
|
|
padding: 8px;
|
|
margin-bottom: 8px;
|
|
}}
|
|
.toolbar input {{ width: 100%; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; }}
|
|
.grp {{ display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }}
|
|
.grp button {{ border: 0; padding: 8px 10px; background: #fff; cursor: pointer; font-weight: 700; color: #1f2937; }}
|
|
.grp button.active {{ background: #0f766e; color: #fff; }}
|
|
.risk-summary {{
|
|
background: var(--panel);
|
|
border: 1px solid var(--line);
|
|
border-radius: 12px;
|
|
padding: 8px 10px;
|
|
margin-bottom: 8px;
|
|
}}
|
|
.risk-top {{ display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom: 6px; flex-wrap: wrap; }}
|
|
.risk-bar-wrap {{
|
|
width: 100%;
|
|
height: 14px;
|
|
border-radius: 999px;
|
|
overflow: hidden;
|
|
background: #dbe7f2;
|
|
border: 1px solid #c7d6e5;
|
|
}}
|
|
.risk-bar {{
|
|
height: 100%;
|
|
width: 0%;
|
|
background: linear-gradient(90deg,#16a34a,#f59e0b,#dc2626);
|
|
transition: width .22s ease;
|
|
}}
|
|
.risk-stats {{ display:flex; flex-wrap: wrap; gap: 6px; margin-top: 7px; }}
|
|
.risk-pill {{
|
|
display:inline-flex; align-items:center; gap:5px;
|
|
border:1px solid var(--line);
|
|
border-radius: 999px;
|
|
background:#fff;
|
|
padding: 3px 8px;
|
|
font-size: .74rem;
|
|
font-weight: 700;
|
|
color: #334155;
|
|
}}
|
|
.risk-rules {{ margin-top: 6px; font-size: .75rem; color:#334155; line-height:1.3; }}
|
|
.breadcrumbs {{
|
|
margin: 8px 0 10px;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
align-items: center;
|
|
background: var(--panel);
|
|
border: 1px solid var(--line);
|
|
border-radius: 10px;
|
|
padding: 7px 10px;
|
|
font-size: .78rem;
|
|
color: #334155;
|
|
}}
|
|
.crumb-btn {{
|
|
border: 1px solid #cfdbe8;
|
|
background: #fff;
|
|
border-radius: 999px;
|
|
padding: 2px 8px;
|
|
font-size: .74rem;
|
|
color: #1f2937;
|
|
cursor: pointer;
|
|
max-width: 260px;
|
|
overflow: hidden;
|
|
white-space: nowrap;
|
|
text-overflow: ellipsis;
|
|
}}
|
|
.crumb-sep {{ color:#94a3b8; font-weight: 700; }}
|
|
|
|
.layout {{
|
|
display: grid;
|
|
grid-template-columns: minmax(220px, var(--col1)) 8px minmax(430px, var(--col2)) 8px minmax(560px, var(--col3));
|
|
gap: 10px;
|
|
align-items: start;
|
|
min-width: 0;
|
|
}}
|
|
.top-layout {{
|
|
display: grid;
|
|
grid-template-columns: minmax(280px, 28fr) minmax(620px, 72fr);
|
|
gap: 10px;
|
|
align-items: stretch;
|
|
}}
|
|
.bottom-layout {{
|
|
display: grid;
|
|
grid-template-columns: minmax(620px, 72fr) minmax(260px, 28fr);
|
|
gap: 10px;
|
|
margin-top: 10px;
|
|
align-items: stretch;
|
|
}}
|
|
.splitter {{
|
|
border-radius: 8px;
|
|
background: linear-gradient(180deg,#d7e0ea,#c8d5e3);
|
|
cursor: col-resize;
|
|
width: 8px;
|
|
min-height: 78vh;
|
|
border: 1px solid #c4d0de;
|
|
}}
|
|
|
|
.panel {{ background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 8px; min-height: 78vh; overflow: hidden; min-width: 0; }}
|
|
.title {{ font-size: .95rem; font-weight: 800; margin: 2px 0 6px; }}
|
|
|
|
.tree-tools {{ display: flex; gap: 6px; margin-bottom: 6px; }}
|
|
.tree-tools button {{ border:1px solid var(--line); background:#fff; border-radius: 7px; padding: 6px 8px; cursor:pointer; font-size:.78rem; font-weight:700; }}
|
|
|
|
.tree-wrap {{ max-height: 74vh; overflow: auto; border: 1px solid var(--line); border-radius: 10px; padding: 6px; overscroll-behavior: contain; scroll-behavior: smooth; }}
|
|
.tree-ul {{ list-style: none; margin: 0; padding-left: 12px; }}
|
|
details > summary {{ cursor: pointer; font-weight: 600; color: #1f2937; padding: 2px 0; }}
|
|
.tree-leaf {{ border: 0; background: transparent; cursor: pointer; color: #374151; padding: 2px 0; text-align: left; width: 100%; }}
|
|
.tree-node {{ transition: color .18s ease, background-color .18s ease; }}
|
|
.tree-node.active {{ color: #0f766e !important; font-weight: 800 !important; background: rgba(15,118,110,.10); border-radius: 6px; padding-inline: 4px; }}
|
|
|
|
.tbl-wrap {{ max-height: 74vh; overflow: auto; border: 1px solid var(--line); border-radius: 10px; position: relative; overscroll-behavior: contain; scroll-behavior: smooth; }}
|
|
.tbl-tools {{ display:flex; gap: 6px; align-items:center; margin-bottom: 6px; }}
|
|
.tbl-tools button {{ border:1px solid var(--line); background:#fff; border-radius:7px; padding:5px 8px; cursor:pointer; font-size:.75rem; font-weight:700; color:#1f2937; }}
|
|
.tbl-tools .hint {{ color: var(--muted); font-size: .74rem; }}
|
|
.copy-row-path {{ border:1px solid var(--line); background:#fff; border-radius:7px; padding:2px 6px; cursor:pointer; font-size:.72rem; font-weight:700; color:#1f2937; }}
|
|
table {{ width: 100%; border-collapse: collapse; table-layout: fixed; }}
|
|
th, td {{ padding: 6px 7px; border-bottom: 1px solid var(--line); font-size: .81rem; text-align: left; vertical-align: top; }}
|
|
th {{ position: sticky; top: 0; background: #f8fafc; z-index: 1; }}
|
|
td code {{ font-size: .78rem; display: inline-block; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }}
|
|
|
|
.badge {{ font-size: .74rem; border-radius: 999px; padding: 1px 8px; color: #fff; font-weight: 800; }}
|
|
.risk-LOW {{ background: var(--low); }}
|
|
.risk-MEDIUM {{ background: var(--med); }}
|
|
.risk-HIGH {{ background: var(--high); }}
|
|
tr td {{ transition: background-color .16s ease; }}
|
|
tr.row-focus td {{ background: #fff7ed; }}
|
|
|
|
.diagram-wrap {{ max-height: 74vh; overflow: auto; border: 1px solid var(--line); border-radius: 10px; padding: 10px; background: linear-gradient(145deg,#f8fafc,#f4f8fb); overscroll-behavior: contain; scroll-behavior: smooth; }}
|
|
.d-tree {{ min-width: 900px; }}
|
|
.d-legend {{
|
|
display:flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px;
|
|
padding: 7px; border:1px solid var(--line); border-radius: 9px; background:#fff;
|
|
}}
|
|
.d-legend .chip {{
|
|
display:inline-flex; align-items:center; gap:6px;
|
|
border:1px solid #d5dfeb; border-radius: 999px; padding: 3px 8px;
|
|
font-size: .74rem; font-weight: 700; color:#1f2937;
|
|
}}
|
|
.sw {{ width: 10px; height: 10px; border-radius: 2px; display:inline-block; }}
|
|
.d-tree ul {{ list-style: none; margin: 0; padding-left: 28px; position: relative; }}
|
|
.d-tree ul::before {{ content: ""; position: absolute; top: 0; left: 11px; bottom: 0; border-left: 2px solid #d4dee9; }}
|
|
.d-tree li {{ margin: 8px 0; position: relative; }}
|
|
.d-tree li::before {{ content: ""; position: absolute; top: 18px; left: -17px; width: 17px; border-top: 2px solid #d4dee9; }}
|
|
.dbranch > summary {{ list-style: none; cursor: pointer; }}
|
|
.dbranch > summary::-webkit-details-marker {{ display: none; }}
|
|
.dbranch > summary::before {{ content: "▸"; color:#334155; font-size: .8rem; margin-right: 6px; }}
|
|
.dbranch[open] > summary::before {{ content: "▾"; }}
|
|
|
|
.dnode {{ border-radius: 10px; min-height: 76px; padding: 8px; border: 1px solid rgba(15,23,42,.25); color: #fff; }}
|
|
.dnode.folder {{ border-color: rgba(76,29,149,.55); }}
|
|
.dnode.depth-1 {{ background: #7c3aed; }}
|
|
.dnode.depth-2 {{ background: #6d28d9; }}
|
|
.dnode.depth-3 {{ background: #5b21b6; }}
|
|
.dnode.depth-4 {{ background: #4c1d95; }}
|
|
.dnode.depth-5 {{ background: #3b126f; }}
|
|
.dnode.file {{ background: #1d4ed8; border-color: #1e40af; cursor: default; }}
|
|
.dnode .n1 {{ font-weight: 800; font-size: .83rem; }}
|
|
.dnode .n2 {{ font-size: .78rem; margin-top: 2px; opacity: .95; }}
|
|
.dnode .n3 {{ font-size: .74rem; margin-top: 3px; opacity: .9; }}
|
|
.dnode.focus {{ outline: 2px solid #f59e0b; box-shadow: 0 0 0 2px rgba(245,158,11,.3) inset; }}
|
|
.dnode.selected {{ outline: 2px solid #f59e0b; box-shadow: 0 0 0 2px rgba(245,158,11,.28) inset; transform: translateY(-1px); transition: box-shadow .16s ease, transform .16s ease; }}
|
|
|
|
.hover-card {{
|
|
position: fixed;
|
|
left: 24px;
|
|
top: 24px;
|
|
z-index: 30;
|
|
width: 390px;
|
|
max-height: 68vh;
|
|
overflow: auto;
|
|
background: rgba(10, 18, 32, 0.38);
|
|
backdrop-filter: blur(18px) saturate(130%);
|
|
-webkit-backdrop-filter: blur(18px) saturate(130%);
|
|
color: #fff;
|
|
border: 1px solid rgba(191, 219, 254, 0.42);
|
|
border-radius: 10px;
|
|
padding: 10px;
|
|
pointer-events: auto;
|
|
user-select: text;
|
|
opacity: 0;
|
|
transform: translateY(4px);
|
|
transition: opacity .16s ease, transform .18s ease;
|
|
box-shadow: 0 10px 28px rgba(2,6,23,.35);
|
|
}}
|
|
.hover-card.show {{ opacity: 1; transform: translateY(0); }}
|
|
.hover-card .hline {{ font-size: .8rem; margin: 4px 0; line-height: 1.25; word-break: break-word; }}
|
|
|
|
.muted {{ color: var(--muted); font-size: .78rem; }}
|
|
|
|
.master-panel {{ margin-top: 10px; background: var(--panel); border:1px solid var(--line); border-radius:12px; padding:8px; }}
|
|
.master-title {{ font-size: .95rem; font-weight: 800; margin: 2px 0 6px; }}
|
|
.master-wrap {{ height: 76vh; border:1px solid var(--line); border-radius:10px; overflow:hidden; background: linear-gradient(145deg,#f7fafc,#eff4f8); position: relative; overscroll-behavior: contain; }}
|
|
.sunburst-wrap {{ height: 76vh; }}
|
|
.network-wrap {{ height: 52vh; border:1px solid var(--line); border-radius:10px; overflow:hidden; background: linear-gradient(145deg,#f7fafc,#eff4f8); position: relative; }}
|
|
.network-host {{ width: 100%; height: 100%; }}
|
|
.context-panel {{ min-height: 52vh; border:1px solid var(--line); border-radius:10px; padding: 10px; background: #f8fbff; }}
|
|
.context-live {{ font-size: .86rem; line-height: 1.45; color:#1f2937; margin-bottom: 10px; }}
|
|
.context-box {{ border:1px solid #d4deea; border-radius: 9px; background:#fff; padding: 8px 10px; font-size: .8rem; line-height: 1.4; color:#374151; margin-top: 8px; }}
|
|
.legacy-hidden {{ display: none !important; }}
|
|
.master-host {{ width: 100%; height: 100%; }}
|
|
.master-toolbar {{
|
|
position:absolute; right:10px; top:10px; z-index: 2;
|
|
display:flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end;
|
|
}}
|
|
.master-toolbar button {{
|
|
border:1px solid var(--line); background: rgba(255,255,255,.92);
|
|
border-radius:8px; padding: 5px 8px; font-size:.74rem; font-weight:700; cursor:pointer; color:#334155;
|
|
}}
|
|
.master-toolbar button.active {{ background:#0f766e; color:#fff; border-color:#0f766e; }}
|
|
.master-mode {{ position:absolute; left:10px; top:10px; background: rgba(255,255,255,.86); border:1px solid var(--line); border-radius:8px; padding:5px 8px; font-size:.74rem; color:#334155; font-weight:700; z-index:2; }}
|
|
.master-help {{ position:absolute; left:10px; bottom:10px; background: rgba(255,255,255,.8); border:1px solid var(--line); border-radius:8px; padding:6px 8px; font-size:.76rem; color:#334155; }}
|
|
.live-context {{
|
|
position: absolute;
|
|
right: 10px;
|
|
top: 46px;
|
|
z-index: 3;
|
|
width: min(330px, 34vw);
|
|
background: rgba(255,255,255,.9);
|
|
border: 1px solid var(--line);
|
|
border-radius: 10px;
|
|
padding: 8px 10px;
|
|
font-size: .76rem;
|
|
color: #1f2937;
|
|
line-height: 1.35;
|
|
box-shadow: 0 10px 22px rgba(15,23,42,.1);
|
|
}}
|
|
.build-badge {{
|
|
position: absolute;
|
|
right: 10px;
|
|
bottom: 10px;
|
|
z-index: 3;
|
|
font-size: .68rem;
|
|
color: #64748b;
|
|
background: rgba(255,255,255,.75);
|
|
border: 1px solid var(--line);
|
|
border-radius: 999px;
|
|
padding: 4px 8px;
|
|
}}
|
|
.selection-strip {{
|
|
position: absolute;
|
|
left: 10px;
|
|
top: 40px;
|
|
z-index: 3;
|
|
max-width: min(58vw, 820px);
|
|
background: rgba(255,255,255,.92);
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
padding: 5px 8px;
|
|
font-size: .74rem;
|
|
color: #334155;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}}
|
|
.network-selection-strip {{
|
|
position: absolute;
|
|
left: 10px;
|
|
top: 10px;
|
|
z-index: 2;
|
|
max-width: min(58vw, 820px);
|
|
background: rgba(255,255,255,.92);
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
padding: 5px 8px;
|
|
font-size: .74rem;
|
|
color: #334155;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}}
|
|
.master-info {{
|
|
position:absolute; right:10px; bottom:38px; z-index:2;
|
|
width: min(460px, 42vw);
|
|
max-height: 30vh;
|
|
overflow:auto;
|
|
background: rgba(10,18,32,.52);
|
|
backdrop-filter: blur(12px) saturate(120%);
|
|
-webkit-backdrop-filter: blur(12px) saturate(120%);
|
|
border:1px solid rgba(191,219,254,.45);
|
|
border-radius:10px;
|
|
color:#fff;
|
|
padding:8px 10px;
|
|
font-size:.78rem;
|
|
line-height: 1.35;
|
|
}}
|
|
|
|
@media (max-width: 1500px) {{
|
|
.layout {{ grid-template-columns: minmax(220px, var(--col1)) 8px minmax(380px, var(--col2)) 8px minmax(420px, var(--col3)); }}
|
|
}}
|
|
@media (max-width: 1100px) {{
|
|
.toolbar {{ grid-template-columns: 1fr; }}
|
|
.layout, .top-layout, .bottom-layout {{ grid-template-columns: 1fr; }}
|
|
.splitter {{ display:none; }}
|
|
.panel {{ min-height: auto; }}
|
|
.tree-wrap, .tbl-wrap, .diagram-wrap {{ max-height: 60vh; }}
|
|
.hover-card {{ width: calc(100vw - 24px); right: 12px; }}
|
|
.network-wrap, .sunburst-wrap {{ height: 60vh; }}
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class=\"wrap\">
|
|
<div class=\"toolbar\">
|
|
<input id=\"q\" placeholder=\"Filter: path, owner, access, person...\" />
|
|
<div class=\"grp\" id=\"scopeGrp\">
|
|
<button data-scope=\"both\" class=\"active\">All Files</button>
|
|
<button data-scope=\"my\">My Drive</button>
|
|
<button data-scope=\"shared\">Shared With Me</button>
|
|
</div>
|
|
<div class=\"grp\" id=\"riskGrp\">
|
|
<button data-risk=\"all\" class=\"active\">All Risks</button>
|
|
<button data-risk=\"high\">High Risk</button>
|
|
<button data-risk=\"medium_plus\">Medium+</button>
|
|
<button data-risk=\"public_domain\">Public/Domain</button>
|
|
<button data-risk=\"external\">External</button>
|
|
</div>
|
|
<button id=\"clearSel\" style=\"border:1px solid var(--line);background:#fff;border-radius:8px;padding:8px 10px;cursor:pointer;font-weight:700;\">Clear Selection</button>
|
|
<div class=\"muted\" id=\"meta\">Sort: path/name | click selects + highlights</div>
|
|
</div>
|
|
<section class=\"risk-summary\">
|
|
<div class=\"risk-top\">
|
|
<div><b>Global Security Score</b> <span id=\"riskPct\">-</span></div>
|
|
<div class=\"muted\" id=\"riskLabel\">Rules: public/domain/external/writer/mass-sharing</div>
|
|
</div>
|
|
<div class=\"risk-bar-wrap\"><div id=\"riskBar\" class=\"risk-bar\"></div></div>
|
|
<div class=\"risk-stats\">
|
|
<span class=\"risk-pill\" id=\"riskStatTotal\">Total: -</span>
|
|
<span class=\"risk-pill\" id=\"riskStatHigh\">High: -</span>
|
|
<span class=\"risk-pill\" id=\"riskStatMed\">Medium: -</span>
|
|
<span class=\"risk-pill\" id=\"riskStatLow\">Low: -</span>
|
|
<span class=\"risk-pill\" id=\"riskStatPublic\">Public/Domain: -</span>
|
|
<span class=\"risk-pill\" id=\"riskStatExternal\">External share: -</span>
|
|
</div>
|
|
<div class=\"risk-rules\">
|
|
HIGH: score 70+ | MEDIUM: 35-69 | LOW: 0-34
|
|
</div>
|
|
</section>
|
|
<div id=\"breadcrumbsBar\" class=\"breadcrumbs\">Path: All</div>
|
|
|
|
<div class=\"top-layout\">
|
|
<section class=\"panel\">
|
|
<div class=\"title\">Path Tree (Folders)</div>
|
|
<div class=\"tree-tools\">
|
|
<button id=\"expandAll\">Expand All</button>
|
|
<button id=\"collapseAll\">Collapse All</button>
|
|
</div>
|
|
<div class=\"tree-wrap\" id=\"tree\">{tree_html}</div>
|
|
</section>
|
|
<section class=\"panel\">
|
|
<div class=\"title\">Sunburst (Drive Structure)</div>
|
|
<div class=\"master-wrap sunburst-wrap\">
|
|
<div id=\"masterHost\" class=\"master-host\"></div>
|
|
<div id=\"sunburstSelection\" class=\"selection-strip\">Selected item: none</div>
|
|
<div class=\"master-toolbar\">
|
|
<button id=\"zoomOutBtn\">-</button>
|
|
<button id=\"zoomInBtn\">+</button>
|
|
<button id=\"zoomResetBtn\">Reset Zoom</button>
|
|
</div>
|
|
<div id=\"masterMode\" class=\"master-mode\">View: Folder map</div>
|
|
<div id=\"masterHelp\" class=\"master-help\">Scroll: zoom | Click: select + highlight</div>
|
|
<div id=\"masterInfo\" class=\"master-info\">Hover any segment for details.</div>
|
|
<div id=\"buildBadge\" class=\"build-badge\"></div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<div class=\"bottom-layout\">
|
|
<section class=\"panel\">
|
|
<div class=\"title\">Share Network</div>
|
|
<div class=\"network-wrap\">
|
|
<div id=\"networkSelection\" class=\"network-selection-strip\">Selected item: none</div>
|
|
<div id=\"networkHost\" class=\"network-host\"></div>
|
|
</div>
|
|
</section>
|
|
<section class=\"panel\">
|
|
<div class=\"title\">Live Context</div>
|
|
<div class=\"context-panel\">
|
|
<div id=\"liveContext\" class=\"context-live\">Loading context...</div>
|
|
<div class=\"context-box\">Selection details appear here in plain English. Use this panel to understand what you selected and where access risk is concentrated.</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<section class=\"legacy-hidden\">
|
|
<div class=\"layout\">
|
|
<section class=\"panel\">
|
|
<div class=\"title\">Item Access Matrix (hidden helper)</div>
|
|
<div class=\"tbl-tools\">
|
|
<button id=\"copyVisiblePaths\">Copy Visible Paths</button>
|
|
<button id=\"loadMoreRows\">Load More</button>
|
|
<div class=\"hint\" id=\"matrixHint\">Lazy matrix loading</div>
|
|
</div>
|
|
<div class=\"tbl-wrap\">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th style=\"width:40%\">Path/Name</th>
|
|
<th style=\"width:13%\">Type</th>
|
|
<th style=\"width:18%\">Owner</th>
|
|
<th style=\"width:9%\">Access</th>
|
|
<th style=\"width:6%\">#</th>
|
|
<th style=\"width:8%\">Risk</th>
|
|
<th style=\"width:8%\">Copy</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id=\"matrixBody\"></tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
<section class=\"panel\">
|
|
<div id=\"diagram\" class=\"diagram-wrap\"></div>
|
|
</section>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<div class=\"hover-card\" id=\"hoverCard\"></div>
|
|
|
|
<script src=\"https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js\"></script>
|
|
<script>
|
|
const rows = {rows_json};
|
|
const BUILD_ID = "{_safe(build_id)}";
|
|
let selectedPath = "";
|
|
let selectedItemId = "";
|
|
let selectedItemPath = "";
|
|
let selectedScope = "both";
|
|
let selectedRisk = "all";
|
|
let textFilter = "";
|
|
let hoverTimer = null;
|
|
let hideTimer = null;
|
|
let hoverCardPinned = false;
|
|
let activeHoverEl = null;
|
|
let lastMouse = {{ x: 24, y: 24 }};
|
|
let hoverPos = {{ x: 24, y: 24 }};
|
|
let followTimer = null;
|
|
let masterChart = null;
|
|
let networkChart = null;
|
|
let masterTree = null;
|
|
let masterView = 'sunburst';
|
|
let sunZoom = 1;
|
|
const masterHost = document.getElementById('masterHost');
|
|
const networkHost = document.getElementById('networkHost');
|
|
const masterMode = document.getElementById('masterMode');
|
|
const masterHelp = document.getElementById('masterHelp');
|
|
const masterInfo = document.getElementById('masterInfo');
|
|
const liveContext = document.getElementById('liveContext');
|
|
const buildBadge = document.getElementById('buildBadge');
|
|
const sunburstSelection = document.getElementById('sunburstSelection');
|
|
const networkSelection = document.getElementById('networkSelection');
|
|
const modeSunburstBtn = document.getElementById('modeSunburst');
|
|
const modeNetworkBtn = document.getElementById('modeNetwork');
|
|
const zoomInBtn = document.getElementById('zoomInBtn');
|
|
const zoomOutBtn = document.getElementById('zoomOutBtn');
|
|
const zoomResetBtn = document.getElementById('zoomResetBtn');
|
|
|
|
const q = document.getElementById('q');
|
|
const meta = document.getElementById('meta');
|
|
const treeWrap = document.getElementById('tree');
|
|
const breadcrumbsBar = document.getElementById('breadcrumbsBar');
|
|
const matrixBody = document.getElementById('matrixBody');
|
|
const diagram = document.getElementById('diagram');
|
|
const hoverCard = document.getElementById('hoverCard');
|
|
const matrixHint = document.getElementById('matrixHint');
|
|
const loadMoreRowsBtn = document.getElementById('loadMoreRows');
|
|
const copyVisiblePathsBtn = document.getElementById('copyVisiblePaths');
|
|
const riskBar = document.getElementById('riskBar');
|
|
const riskPct = document.getElementById('riskPct');
|
|
const riskLabel = document.getElementById('riskLabel');
|
|
const riskStatTotal = document.getElementById('riskStatTotal');
|
|
const riskStatHigh = document.getElementById('riskStatHigh');
|
|
const riskStatMed = document.getElementById('riskStatMed');
|
|
const riskStatLow = document.getElementById('riskStatLow');
|
|
const riskStatPublic = document.getElementById('riskStatPublic');
|
|
const riskStatExternal = document.getElementById('riskStatExternal');
|
|
|
|
let matrixAllRows = [];
|
|
let matrixRenderedRows = 0;
|
|
const MATRIX_BATCH = 260;
|
|
let centerTreeOnNextRender = false;
|
|
let preserveTreeToggleOnce = false;
|
|
|
|
function scopeOk(r) {{
|
|
if (selectedScope === 'both') return true;
|
|
if (selectedScope === 'my') return r.root_path === 'My Drive';
|
|
if (selectedScope === 'shared') return r.root_path === 'SharedWithMe';
|
|
return true;
|
|
}}
|
|
|
|
function riskOk(r) {{
|
|
if (selectedRisk === 'all') return true;
|
|
if (selectedRisk === 'high') return r.risk_level === 'HIGH';
|
|
if (selectedRisk === 'medium_plus') return r.risk_level === 'HIGH' || r.risk_level === 'MEDIUM';
|
|
if (selectedRisk === 'public_domain') return r.access_scope === 'PUBLIC' || r.access_scope === 'DOMAIN';
|
|
if (selectedRisk === 'external') return (r.external_target_count || 0) > 0 || (r.risk_flags || '').includes('EXTERNAL_');
|
|
return true;
|
|
}}
|
|
|
|
function pathOk(r) {{
|
|
return true;
|
|
}}
|
|
|
|
function textOk(r) {{
|
|
if (!textFilter) return true;
|
|
const hay = [r.path, r.original_owner_email, r.shared_by_email, r.access_scope, r.access_targets, r.permission_entries, r.file_category, r.risk_level].join(' ').toLowerCase();
|
|
return hay.includes(textFilter);
|
|
}}
|
|
|
|
function filteredRows() {{
|
|
return rows
|
|
.filter(r => scopeOk(r) && riskOk(r) && pathOk(r) && textOk(r))
|
|
.sort((a,b) => (a.path + '|' + a.name).localeCompare(b.path + '|' + b.name));
|
|
}}
|
|
|
|
function masterFocusRows() {{
|
|
const base = rows.filter(r => scopeOk(r) && riskOk(r) && textOk(r));
|
|
if (selectedItemId) {{
|
|
return base.filter(r => r.item_id === selectedItemId);
|
|
}}
|
|
if (selectedItemPath) {{
|
|
const exact = base.filter(r => r.path === selectedItemPath);
|
|
if (exact.length && exact.some(r => r.item_kind === 'FILE')) return exact;
|
|
return base.filter(r => r.path === selectedItemPath || r.path.startsWith(selectedItemPath + '/'));
|
|
}}
|
|
if (selectedPath) {{
|
|
return base.filter(r => r.path === selectedPath || r.path.startsWith(selectedPath + '/'));
|
|
}}
|
|
return base.sort((a,b) => (a.path + '|' + a.name).localeCompare(b.path + '|' + b.name));
|
|
}}
|
|
|
|
function selectedItemRow() {{
|
|
if (selectedItemId) return rows.find(r => r.item_id === selectedItemId) || null;
|
|
if (selectedItemPath) return rows.find(r => r.path === selectedItemPath) || null;
|
|
return null;
|
|
}}
|
|
|
|
function riskBadge(level) {{
|
|
return `<span class=\"badge risk-${{level}}\">${{level}}</span>`;
|
|
}}
|
|
|
|
function short(v, n=48) {{
|
|
return v && v.length > n ? v.slice(0,n-1) + '...' : (v || '');
|
|
}}
|
|
|
|
function setMasterInfo(lines) {{
|
|
masterInfo.innerHTML = lines.map(x => `<div>${{x}}</div>`).join('');
|
|
}}
|
|
|
|
function selectedLabel() {{
|
|
const row = selectedItemRow();
|
|
if (row) return `${{row.name || 'item'}} | ${{row.path}}`;
|
|
if (selectedItemPath) return selectedItemPath;
|
|
return 'None';
|
|
}}
|
|
|
|
function selectedItemText() {{
|
|
const row = selectedItemRow();
|
|
if (row) return `Selected item: ${{row.name || '-'}} | Path: ${{row.path || '-'}}`;
|
|
if (selectedItemPath) return `Selected item path: ${{selectedItemPath}}`;
|
|
return 'Selected item: none';
|
|
}}
|
|
|
|
function renderSelectionStrips() {{
|
|
const txt = selectedItemText();
|
|
if (sunburstSelection) sunburstSelection.textContent = txt;
|
|
if (networkSelection) networkSelection.textContent = txt;
|
|
}}
|
|
|
|
function currentSelectedPath() {{
|
|
const row = selectedItemRow();
|
|
return row?.path || selectedItemPath || selectedPath || '';
|
|
}}
|
|
|
|
function renderBreadcrumbs() {{
|
|
if (!breadcrumbsBar) return;
|
|
const p = currentSelectedPath();
|
|
if (!p) {{
|
|
breadcrumbsBar.innerHTML = 'Path: <b>All</b>';
|
|
return;
|
|
}}
|
|
const parts = p.split('/').filter(Boolean);
|
|
const chunks = [];
|
|
chunks.push(`<button class="crumb-btn" data-crumb-path="">All</button>`);
|
|
for (let i = 0; i < parts.length; i++) {{
|
|
const sub = parts.slice(0, i + 1).join('/');
|
|
chunks.push(`<span class="crumb-sep">/</span>`);
|
|
chunks.push(`<button class="crumb-btn" data-crumb-path="${{sub}}">${{parts[i]}}</button>`);
|
|
}}
|
|
breadcrumbsBar.innerHTML = chunks.join('');
|
|
}}
|
|
|
|
function renderLiveContext(visibleData) {{
|
|
const viewLabel = selectedScope === 'my'
|
|
? 'My Drive'
|
|
: (selectedScope === 'shared' ? 'Shared With Me' : 'All Files');
|
|
const data = visibleData && visibleData.length ? visibleData : filteredRows();
|
|
const high = data.filter(r => r.risk_level === 'HIGH').length;
|
|
const external = data.filter(r => (r.external_target_count || 0) > 0).length;
|
|
const selRow = selectedItemRow();
|
|
const selName = selRow ? (selRow.name || '-') : '-';
|
|
const selPath = selRow ? (selRow.path || '-') : (selectedItemPath || '-');
|
|
const sel = selectedLabel();
|
|
const branch = selectedPath || 'All paths';
|
|
liveContext.innerHTML = [
|
|
'<b>Live Context</b>',
|
|
`You are viewing: ${{viewLabel}}`,
|
|
`Selected path: ${{branch}}`,
|
|
`Items: ${{data.length}}`,
|
|
`High risk: ${{high}}`,
|
|
`External shares: ${{external}}`,
|
|
`Selection: ${{selName}}`,
|
|
`Selected full path: ${{selPath}}`,
|
|
`Master mode: ${{masterView === 'network' ? 'Share Network' : 'Sunburst'}}`,
|
|
].map(x => `<div>${{x}}</div>`).join('');
|
|
}}
|
|
|
|
const SUN_LABEL_MIN_ANGLE = 7.0;
|
|
const SUN_LABEL_MIN_RING_PX = 14.0;
|
|
const SUN_LABEL_AVG_CHAR_PX = 6.8;
|
|
const SUN_LABEL_MIN_CHARS = 4;
|
|
|
|
function matrixRowHtml(r) {{
|
|
return `
|
|
<tr data-item-id="${{r.item_id}}" data-path="${{r.path}}">
|
|
<td title="${{r.path}}"><code>${{short(r.path, 86)}}</code></td>
|
|
<td>${{r.item_kind}} / ${{r.file_category}}</td>
|
|
<td title="${{r.original_owner_email}}">${{short(r.original_owner_email, 28)}}</td>
|
|
<td>${{r.access_scope}}</td>
|
|
<td>${{r.shared_with_count}}</td>
|
|
<td>${{riskBadge(r.risk_level)}} <span class="muted">(${{r.risk_score || 0}})</span></td>
|
|
<td><button class="copy-row-path" data-copy-path="${{r.path}}">Copy</button></td>
|
|
</tr>
|
|
`;
|
|
}}
|
|
|
|
function renderMatrix(data) {{
|
|
matrixAllRows = data.slice();
|
|
matrixRenderedRows = 0;
|
|
matrixBody.innerHTML = '';
|
|
appendMatrixRows();
|
|
}}
|
|
|
|
function appendMatrixRows() {{
|
|
const next = matrixAllRows.slice(matrixRenderedRows, matrixRenderedRows + MATRIX_BATCH);
|
|
if (!next.length) return;
|
|
matrixBody.insertAdjacentHTML('beforeend', next.map(matrixRowHtml).join(''));
|
|
matrixRenderedRows += next.length;
|
|
matrixHint.textContent = `Loaded ${{matrixRenderedRows}} / ${{matrixAllRows.length}} rows`;
|
|
loadMoreRowsBtn.disabled = matrixRenderedRows >= matrixAllRows.length;
|
|
}}
|
|
|
|
function renderRiskSummary(sourceRows) {{
|
|
const set = (sourceRows && sourceRows.length ? sourceRows : rows);
|
|
const total = set.length || 1;
|
|
const high = set.filter(r => r.risk_level === 'HIGH').length;
|
|
const med = set.filter(r => r.risk_level === 'MEDIUM').length;
|
|
const low = set.filter(r => r.risk_level === 'LOW').length;
|
|
const publicDomain = set.filter(r => r.access_scope === 'PUBLIC' || r.access_scope === 'DOMAIN').length;
|
|
const external = set.filter(r => (r.external_target_count || 0) > 0).length;
|
|
const avgRisk = set.reduce((s, r) => s + (r.risk_score || 0), 0) / total;
|
|
const securityPct = Math.max(0, Math.min(100, 100 - avgRisk));
|
|
|
|
riskBar.style.width = `${{securityPct.toFixed(1)}}%`;
|
|
riskPct.textContent = `${{securityPct.toFixed(1)}}%`;
|
|
riskLabel.textContent = securityPct >= 80
|
|
? 'Status: stable (lower average risk).'
|
|
: (securityPct >= 60 ? 'Status: caution (visible risk hotspots).' : 'Status: critical (cleanup needed now).');
|
|
riskStatTotal.textContent = `Total: ${{set.length}}`;
|
|
riskStatHigh.textContent = `High: ${{high}}`;
|
|
riskStatMed.textContent = `Medium: ${{med}}`;
|
|
riskStatLow.textContent = `Low: ${{low}}`;
|
|
riskStatPublic.textContent = `Public/Domain: ${{publicDomain}}`;
|
|
riskStatExternal.textContent = `External share: ${{external}}`;
|
|
}}
|
|
|
|
function focusLabel() {{
|
|
const srow = selectedItemRow();
|
|
if (srow) {{
|
|
return `Selection: ${{srow.name || '(item)'}} | ${{srow.path}}`;
|
|
}}
|
|
if (selectedItemPath) return `Selection: ${{selectedItemPath}}`;
|
|
if (selectedPath) return `Selected path: ${{selectedPath}}`;
|
|
return 'Selection: none';
|
|
}}
|
|
|
|
function attachHover(targetEl, payload) {{
|
|
const targetPos = (x, y) => {{
|
|
const w = 390;
|
|
const h = Math.min(window.innerHeight * 0.68, 420);
|
|
const left = Math.max(10, Math.min(window.innerWidth - w - 12, x + 20));
|
|
const top = Math.max(10, Math.min(window.innerHeight - h - 12, y + 18));
|
|
return {{ left, top }};
|
|
}};
|
|
|
|
const positionCard = (x, y, soft = false) => {{
|
|
const pos = targetPos(x, y);
|
|
if (!soft || !hoverCard.classList.contains('show')) {{
|
|
hoverPos = {{ x: pos.left, y: pos.top }};
|
|
}} else {{
|
|
hoverPos.x = hoverPos.x + (pos.left - hoverPos.x) * 0.28;
|
|
hoverPos.y = hoverPos.y + (pos.top - hoverPos.y) * 0.28;
|
|
}}
|
|
const left = hoverPos.x;
|
|
const top = hoverPos.y;
|
|
hoverCard.style.left = left + 'px';
|
|
hoverCard.style.top = top + 'px';
|
|
}};
|
|
|
|
const show = () => {{
|
|
hoverCard.innerHTML = `
|
|
<div class='hline'><b>Path:</b> ${{payload.path || '-'}} </div>
|
|
<div class='hline'><b>Name:</b> ${{payload.name || payload.label || '-'}} </div>
|
|
<div class='hline'><b>Type:</b> ${{payload.item_kind || 'FOLDER'}} / ${{payload.file_category || '-'}} </div>
|
|
<div class='hline'><b>Owner:</b> ${{payload.original_owner_email || '-'}} </div>
|
|
<div class='hline'><b>Shared by:</b> ${{payload.shared_by_email || '-'}} </div>
|
|
<div class='hline'><b>Access:</b> ${{payload.access_scope || '-'}} </div>
|
|
<div class='hline'><b>Shared with:</b> ${{payload.access_targets || '-'}} </div>
|
|
<div class='hline'><b>Risk:</b> ${{payload.risk_level || '-'}} (score: ${{payload.risk_score || 0}}) ${{payload.risk_reason ? '(' + payload.risk_reason + ')' : ''}}</div>
|
|
<div class='hline'><b>Risk flags:</b> ${{payload.risk_flags || '-'}}</div>
|
|
<div class='hline'><b>Items in branch:</b> ${{payload.items || '-'}} </div>
|
|
`;
|
|
positionCard(lastMouse.x, lastMouse.y, false);
|
|
hoverCard.classList.add('show');
|
|
}};
|
|
|
|
targetEl.addEventListener('mouseenter', (e) => {{
|
|
activeHoverEl = targetEl;
|
|
targetEl.classList.add('focus');
|
|
lastMouse = {{ x: e.clientX, y: e.clientY }};
|
|
clearTimeout(hoverTimer);
|
|
clearTimeout(hideTimer);
|
|
hoverTimer = setTimeout(show, 550);
|
|
highlightMatrix(payload);
|
|
}});
|
|
|
|
targetEl.addEventListener('mousemove', (e) => {{
|
|
lastMouse = {{ x: e.clientX, y: e.clientY }};
|
|
if (hoverCard.classList.contains('show') && !hoverCardPinned) {{
|
|
if (!followTimer) {{
|
|
followTimer = setTimeout(() => {{
|
|
positionCard(lastMouse.x, lastMouse.y, true);
|
|
followTimer = null;
|
|
}}, 90);
|
|
}}
|
|
}}
|
|
}});
|
|
|
|
targetEl.addEventListener('mouseleave', () => {{
|
|
if (activeHoverEl === targetEl) {{
|
|
activeHoverEl = null;
|
|
}}
|
|
if (followTimer) {{
|
|
clearTimeout(followTimer);
|
|
followTimer = null;
|
|
}}
|
|
targetEl.classList.remove('focus');
|
|
clearTimeout(hoverTimer);
|
|
hideTimer = setTimeout(() => {{
|
|
if (!hoverCardPinned && !activeHoverEl) {{
|
|
hoverCard.classList.remove('show');
|
|
}}
|
|
}}, 900);
|
|
clearMatrixHighlight();
|
|
}});
|
|
|
|
targetEl.addEventListener('click', () => {{
|
|
const p = payload.path || '';
|
|
if (!p) return;
|
|
selectedItemId = payload.item_id || '';
|
|
selectedItemPath = p;
|
|
centerTreeOnNextRender = true;
|
|
rerender();
|
|
}});
|
|
}}
|
|
|
|
hoverCard.addEventListener('mouseenter', () => {{
|
|
hoverCardPinned = true;
|
|
if (followTimer) {{
|
|
clearTimeout(followTimer);
|
|
followTimer = null;
|
|
}}
|
|
clearTimeout(hideTimer);
|
|
}});
|
|
hoverCard.addEventListener('mouseleave', () => {{
|
|
hoverCardPinned = false;
|
|
hideTimer = setTimeout(() => {{
|
|
if (!activeHoverEl) hoverCard.classList.remove('show');
|
|
}}, 420);
|
|
}});
|
|
|
|
function getDepth(path) {{
|
|
return (path || '').split('/').filter(Boolean).length;
|
|
}}
|
|
|
|
function depthClass(path, kind) {{
|
|
if (kind !== 'FOLDER') return '';
|
|
const d = getDepth(path);
|
|
if (d <= 1) return 'depth-1';
|
|
if (d === 2) return 'depth-2';
|
|
if (d === 3) return 'depth-3';
|
|
if (d === 4) return 'depth-4';
|
|
return 'depth-5';
|
|
}}
|
|
|
|
function buildDiagramTree(data) {{
|
|
const root = {{ name: 'ROOT', path: '', kind: 'ROOT', items: 0, high: 0, children: new Map(), row: null }};
|
|
|
|
for (const r of data) {{
|
|
const parts = (r.path || '').split('/').filter(Boolean);
|
|
if (!parts.length) continue;
|
|
|
|
let node = root;
|
|
node.items += 1;
|
|
if (r.risk_level === 'HIGH') node.high += 1;
|
|
|
|
for (let i = 0; i < parts.length; i++) {{
|
|
const seg = parts[i];
|
|
const full = parts.slice(0, i + 1).join('/');
|
|
const isLast = i === parts.length - 1;
|
|
const kind = isLast ? r.item_kind : 'FOLDER';
|
|
|
|
if (!node.children.has(seg)) {{
|
|
node.children.set(seg, {{ name: seg, path: full, kind, items: 0, high: 0, children: new Map(), row: null }});
|
|
}}
|
|
|
|
node = node.children.get(seg);
|
|
node.items += 1;
|
|
if (r.risk_level === 'HIGH') node.high += 1;
|
|
if (isLast) node.row = r;
|
|
}}
|
|
}}
|
|
|
|
return root;
|
|
}}
|
|
|
|
function renderNode(node) {{
|
|
const children = Array.from(node.children.values()).sort((a,b) => {{
|
|
if (a.kind !== b.kind) return a.kind === 'FOLDER' ? -1 : 1;
|
|
return a.name.localeCompare(b.name);
|
|
}});
|
|
|
|
if (!children.length) return '';
|
|
|
|
return `<ul>${{children.map(ch => {{
|
|
const isFolder = ch.kind === 'FOLDER';
|
|
const klass = isFolder ? `folder ${{depthClass(ch.path, ch.kind)}}` : 'file';
|
|
const depth = getDepth(ch.path);
|
|
const parentPath = ch.path.includes('/') ? ch.path.split('/').slice(0, -1).join('/') : 'ROOT';
|
|
const row = ch.row || {{
|
|
label: ch.name,
|
|
path: ch.path,
|
|
items: ch.items,
|
|
risk_level: ch.high > 0 ? 'HIGH' : 'LOW',
|
|
risk_reason: ch.high > 0 ? 'Contains higher-risk items' : 'No high-risk items'
|
|
}};
|
|
|
|
const card = `
|
|
<div class=\"dnode ${{klass}}\" data-node-path=\"${{ch.path}}\" data-item-id=\"${{ch.row && ch.row.item_id ? ch.row.item_id : ''}}\">
|
|
<div class=\"n1\">${{isFolder ? 'Folder' : 'File'}} | Depth ${{depth}} | ${{isFolder ? 'Structure' : (ch.row.file_category || 'Item')}}</div>
|
|
<div class=\"n2\">${{ch.name}}</div>
|
|
<div class=\"n3\">Parent: ${{parentPath}} | Path: ${{ch.path}} | Items: ${{ch.items}} | High risk: ${{ch.high}}</div>
|
|
</div>
|
|
`;
|
|
|
|
if (isFolder) {{
|
|
return `<li><details class=\"dbranch\"><summary>${{card}}</summary>${{renderNode(ch)}}</details></li>`;
|
|
}}
|
|
return `<li>${{card}}</li>`;
|
|
}}).join('')}}</ul>`;
|
|
}}
|
|
|
|
function renderDiagram(data) {{
|
|
const previewOnly = data.length > 900;
|
|
const sourceData = previewOnly ? data.filter(r => getDepth(r.path) <= 2) : data;
|
|
|
|
let top = `<div class=\"dnode folder depth-1\" style=\"max-width:520px\">
|
|
<div class=\"n1\">Drive Overview | Depth 0</div>
|
|
<div class=\"n2\">Selected path: ${{selectedPath || 'All paths'}} </div>
|
|
<div class=\"n3\">Visible items: ${{data.length}}</div>
|
|
</div>`;
|
|
if (previewOnly) {{
|
|
top += `<div class="muted" style="margin:8px 0 4px;">Large tree mode: top levels only. Click a path on the left for deeper drill-down.</div>`;
|
|
}}
|
|
|
|
const previewTree = buildDiagramTree(sourceData);
|
|
const previewRoots = Array.from(previewTree.children.values()).sort((a,b)=>a.name.localeCompare(b.name));
|
|
const content = `<div class=\"d-tree\">${{top}}<ul>${{previewRoots.map(r => `<li><div class=\"dnode folder ${{depthClass(r.path, 'FOLDER')}}\" data-node-path=\"${{r.path}}\" data-item-id=\"\"><div class=\"n1\">Root branch | Depth ${{getDepth(r.path)}}</div><div class=\"n2\">${{r.name}}</div><div class=\"n3\">Path: ${{r.path}} | Items: ${{r.items}} | High risk: ${{r.high}}</div></div>${{renderNode(r)}}</li>`).join('')}}</ul></div>`;
|
|
diagram.innerHTML = content;
|
|
|
|
const idToRow = new Map(sourceData.map(r => [r.path, r]));
|
|
for (const card of diagram.querySelectorAll('.dnode[data-node-path]')) {{
|
|
const p = card.dataset.nodePath;
|
|
const exact = idToRow.get(p);
|
|
const payload = exact || {{ label: p.split('/').pop(), path: p, items: '-', risk_level: '-', risk_reason: '' }};
|
|
if (card.dataset.itemId) payload.item_id = card.dataset.itemId;
|
|
if ((selectedItemId && payload.item_id && payload.item_id === selectedItemId) || (!selectedItemId && selectedItemPath && payload.path === selectedItemPath)) {{
|
|
card.classList.add('selected');
|
|
}}
|
|
attachHover(card, payload);
|
|
}}
|
|
}}
|
|
|
|
function accessColor(scope) {{
|
|
if (scope === 'PUBLIC') return '#dc2626';
|
|
if (scope === 'DOMAIN') return '#ea580c';
|
|
if (scope === 'SHARED_USERS') return '#d97706';
|
|
return '#64748b';
|
|
}}
|
|
|
|
function hexToRgb(hex) {{
|
|
const h = String(hex || '').replace('#', '');
|
|
if (h.length !== 6) return [111, 131, 149];
|
|
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
|
|
}}
|
|
|
|
function rgbToHex(r, g, b) {{
|
|
const to2 = (x) => Math.max(0, Math.min(255, Math.round(x))).toString(16).padStart(2, '0');
|
|
return `#${{to2(r)}}${{to2(g)}}${{to2(b)}}`;
|
|
}}
|
|
|
|
function mixHex(base, target, t) {{
|
|
const [r1, g1, b1] = hexToRgb(base);
|
|
const [r2, g2, b2] = hexToRgb(target);
|
|
return rgbToHex(
|
|
r1 + (r2 - r1) * t,
|
|
g1 + (g2 - g1) * t,
|
|
b1 + (b2 - b1) * t
|
|
);
|
|
}}
|
|
|
|
function principalLabel(raw) {{
|
|
const v = String(raw || '');
|
|
if (!v) return '-';
|
|
if (v.startsWith('group:')) return `group:${{v.slice(6)}}`;
|
|
if (v.startsWith('domain:')) return `domain:${{v.slice(7)}}`;
|
|
if (v === 'anyone(public)') return 'public-link';
|
|
return v;
|
|
}}
|
|
|
|
function shortPrincipal(raw, n=26) {{
|
|
const s = principalLabel(raw);
|
|
return s.length > n ? s.slice(0, n - 3) + '...' : s;
|
|
}}
|
|
|
|
function activateMasterModeButtons() {{
|
|
if (modeSunburstBtn) modeSunburstBtn.classList.toggle('active', masterView === 'sunburst');
|
|
if (modeNetworkBtn) modeNetworkBtn.classList.toggle('active', masterView === 'network');
|
|
}}
|
|
|
|
function buildMasterSunburst(allRows) {{
|
|
const root = {{
|
|
name: 'DRIVE',
|
|
path: '',
|
|
value: 0,
|
|
itemCount: 0,
|
|
highCount: 0,
|
|
childrenMap: new Map(),
|
|
ownerSet: new Set(),
|
|
accessMix: new Map(),
|
|
}};
|
|
|
|
function ensureChild(parent, segPath, segName) {{
|
|
if (!parent.childrenMap.has(segName)) {{
|
|
parent.childrenMap.set(segName, {{
|
|
name: segName,
|
|
path: segPath,
|
|
value: 0,
|
|
itemCount: 0,
|
|
highCount: 0,
|
|
kind: 'FOLDER',
|
|
childrenMap: new Map(),
|
|
ownerSet: new Set(),
|
|
accessMix: new Map(),
|
|
}});
|
|
}}
|
|
return parent.childrenMap.get(segName);
|
|
}}
|
|
|
|
for (const r of allRows) {{
|
|
const parts = (r.path || '').split('/').filter(Boolean);
|
|
if (!parts.length) continue;
|
|
|
|
let node = root;
|
|
node.value += 1;
|
|
node.itemCount += 1;
|
|
if (r.risk_level === 'HIGH') node.highCount += 1;
|
|
if (r.original_owner_email) node.ownerSet.add(r.original_owner_email);
|
|
node.accessMix.set(r.access_scope, (node.accessMix.get(r.access_scope) || 0) + 1);
|
|
|
|
for (let i = 0; i < parts.length; i++) {{
|
|
const seg = parts[i];
|
|
const p = parts.slice(0, i + 1).join('/');
|
|
const child = ensureChild(node, p, seg);
|
|
child.value += 1;
|
|
child.itemCount += 1;
|
|
if (r.risk_level === 'HIGH') child.highCount += 1;
|
|
if (r.original_owner_email) child.ownerSet.add(r.original_owner_email);
|
|
child.accessMix.set(r.access_scope, (child.accessMix.get(r.access_scope) || 0) + 1);
|
|
if (i === parts.length - 1 && r.item_kind === 'FILE') child.kind = 'FILE';
|
|
node = child;
|
|
}}
|
|
}}
|
|
|
|
function dominantAccess(accessMix) {{
|
|
let best = 'PRIVATE';
|
|
let max = -1;
|
|
for (const [k, v] of accessMix.entries()) {{
|
|
if (v > max) {{ max = v; best = k; }}
|
|
}}
|
|
return best;
|
|
}}
|
|
|
|
const familyPalette = ['#6f8395', '#9a5b86', '#6a7e8f', '#8d5f82'];
|
|
|
|
function toNode(node, depth = 0, familyColor = '#6f8395', familyIdx = 0) {{
|
|
const children = Array.from(node.childrenMap.values())
|
|
.sort((a, b) => b.value - a.value)
|
|
.map((ch, idx) => {{
|
|
const isRootChild = depth === 0;
|
|
const nextFamilyIdx = isRootChild ? (familyIdx + idx) : familyIdx;
|
|
const nextFamilyColor = isRootChild
|
|
? familyPalette[nextFamilyIdx % familyPalette.length]
|
|
: familyColor;
|
|
return toNode(ch, depth + 1, nextFamilyColor, nextFamilyIdx);
|
|
}});
|
|
|
|
const highRatio = node.itemCount ? (node.highCount / node.itemCount) : 0;
|
|
const access = dominantAccess(node.accessMix);
|
|
const shade = Math.min(0.36, Math.max(0.02, depth * 0.07));
|
|
const color = mixHex(familyColor, '#f8fafc', shade);
|
|
const borderColor = mixHex(color, '#ffffff', 0.55);
|
|
|
|
return {{
|
|
name: node.name,
|
|
value: Math.max(node.value, 1),
|
|
path: node.path,
|
|
kind: node.kind || 'FOLDER',
|
|
itemCount: node.itemCount,
|
|
highCount: node.highCount,
|
|
owners: node.ownerSet.size,
|
|
access,
|
|
itemStyle: {{ color, borderColor, borderWidth: 1.4 }},
|
|
children,
|
|
riskRatio: highRatio,
|
|
}};
|
|
}}
|
|
|
|
return [toNode(root, 0, '#6f8395', 0)];
|
|
}}
|
|
|
|
function _pct(v) {{
|
|
if (typeof v === 'number') return v;
|
|
return parseFloat(String(v || '0').replace('%', '')) || 0;
|
|
}}
|
|
|
|
function _truncateLabel(text, maxChars) {{
|
|
const src = String(text || '');
|
|
if (src.length <= maxChars) return src;
|
|
if (maxChars <= 3) return '';
|
|
return src.slice(0, maxChars - 3) + '...';
|
|
}}
|
|
|
|
function applySunLabelRules(tree, innerPct, outerPct) {{
|
|
const hostRect = masterHost.getBoundingClientRect();
|
|
const radiusPx = Math.max(120, Math.min(hostRect.width, hostRect.height) * 0.5);
|
|
const total = (tree?.[0]?.value || 1);
|
|
|
|
function walk(node, parentValue, depth, r0Pct, r1Pct) {{
|
|
const nodeValue = Math.max(1, Number(node.value || 1));
|
|
const angleRad = (Math.PI * 2 * nodeValue) / Math.max(1, parentValue);
|
|
const angleDeg = angleRad * (180 / Math.PI);
|
|
const r0Px = (_pct(r0Pct) / 100) * radiusPx;
|
|
const r1Px = (_pct(r1Pct) / 100) * radiusPx;
|
|
const ringPx = Math.max(1, r1Px - r0Px);
|
|
const midRadius = (r0Px + r1Px) * 0.5;
|
|
const arcPx = Math.max(1, angleRad * midRadius);
|
|
const maxCharsArc = Math.floor((arcPx - 8) / SUN_LABEL_AVG_CHAR_PX);
|
|
const maxCharsRing = Math.floor((ringPx - 4) / 1.9);
|
|
const maxChars = Math.max(0, Math.min(maxCharsArc, maxCharsRing, 42));
|
|
const canShow = angleDeg >= SUN_LABEL_MIN_ANGLE && ringPx >= SUN_LABEL_MIN_RING_PX && maxChars >= SUN_LABEL_MIN_CHARS;
|
|
|
|
node.labelText = canShow ? _truncateLabel(node.name, maxChars) : '';
|
|
node.__labelAllowed = canShow;
|
|
|
|
const children = node.children || [];
|
|
if (!children.length) return;
|
|
const nextR0 = Math.min(r1Pct, innerPct + depth * ((outerPct - innerPct) / 4));
|
|
const nextR1 = Math.min(outerPct, innerPct + (depth + 1) * ((outerPct - innerPct) / 4));
|
|
for (const ch of children) {{
|
|
walk(ch, nodeValue, depth + 1, nextR0, nextR1);
|
|
}}
|
|
}}
|
|
|
|
for (const n of tree || []) {{
|
|
walk(n, total, 1, innerPct, innerPct + ((outerPct - innerPct) / 4));
|
|
}}
|
|
return tree;
|
|
}}
|
|
|
|
function setMasterModeLabel() {{
|
|
const flabel = focusLabel();
|
|
if (masterView === 'network') {{
|
|
masterMode.textContent = `View: Share Network | ${{flabel}}`;
|
|
return;
|
|
}}
|
|
const modeText = sunZoom > 1.18 ? 'View: Detailed (zoomed)' : 'View: Folder map';
|
|
masterMode.textContent = `${{modeText}} | ${{flabel}}`;
|
|
}}
|
|
|
|
function initMasterChart() {{
|
|
if (!window.echarts) {{
|
|
masterHelp.textContent = 'Master chart not loaded (ECharts CDN unavailable).';
|
|
return;
|
|
}}
|
|
masterChart = window.echarts.init(masterHost, null, {{ renderer: 'canvas' }});
|
|
window.addEventListener('resize', () => masterChart && masterChart.resize());
|
|
|
|
masterHost.addEventListener('wheel', (e) => {{
|
|
if (masterView !== 'sunburst') return;
|
|
e.preventDefault();
|
|
const factor = e.deltaY < 0 ? 1.08 : 0.92;
|
|
sunZoom = Math.max(0.75, Math.min(2.4, sunZoom * factor));
|
|
renderMasterSunburst();
|
|
highlightMaster(masterFocusRows());
|
|
}}, {{ passive: false }});
|
|
}}
|
|
|
|
function initNetworkChart() {{
|
|
if (!window.echarts || !networkHost) return;
|
|
networkChart = window.echarts.init(networkHost, null, {{ renderer: 'canvas' }});
|
|
window.addEventListener('resize', () => networkChart && networkChart.resize());
|
|
}}
|
|
|
|
function renderMasterSunburst() {{
|
|
if (!masterChart) return;
|
|
masterView = 'sunburst';
|
|
activateMasterModeButtons();
|
|
const focusRows = masterFocusRows();
|
|
const sourceRows = focusRows.length ? focusRows : rows;
|
|
let sun = buildMasterSunburst(sourceRows);
|
|
masterTree = sun;
|
|
|
|
const inner = Math.max(2, Math.min(24, 10 + (1 - sunZoom) * 10));
|
|
const outer = Math.max(82, Math.min(98, 91 + sunZoom * 2.0));
|
|
const f1 = 12;
|
|
const f2 = 11;
|
|
const f3 = 10;
|
|
const f4 = 9;
|
|
|
|
const r1 = Math.min(inner + 16, outer - 50);
|
|
const r2 = Math.min(inner + 34, outer - 30);
|
|
const r3 = Math.min(inner + 52, outer - 14);
|
|
sun = applySunLabelRules(sun, inner, outer);
|
|
|
|
masterChart.setOption({{
|
|
animationDuration: 320,
|
|
animationDurationUpdate: 280,
|
|
tooltip: {{
|
|
trigger: 'item',
|
|
backgroundColor: 'rgba(10,18,32,0.84)',
|
|
borderColor: 'rgba(191,219,254,0.42)',
|
|
textStyle: {{ color: '#fff', fontSize: 12 }},
|
|
formatter: (p) => {{
|
|
const d = p.data || {{}};
|
|
const depth = (d.path || '').split('/').filter(Boolean).length;
|
|
return [
|
|
`<b>${{d.name || 'DRIVE'}}</b>`,
|
|
`Path: ${{d.path || 'ROOT'}}`,
|
|
`Depth: ${{depth}}`,
|
|
`Type: ${{d.kind || 'FOLDER'}}`,
|
|
`Items: ${{d.itemCount || 0}}`,
|
|
`High risk: ${{d.highCount || 0}}`,
|
|
`Dominant access: ${{d.access || 'PRIVATE'}}`,
|
|
`Owners: ${{d.owners || 0}}`,
|
|
].join('<br/>');
|
|
}},
|
|
}},
|
|
series: [{{
|
|
type: 'sunburst',
|
|
nodeClick: 'rootToNode',
|
|
center: ['50%', '50%'],
|
|
radius: [`${{inner}}%`, `${{outer}}%`],
|
|
sort: (a, b) => b.getValue() - a.getValue(),
|
|
emphasis: {{ focus: 'ancestor' }},
|
|
blur: {{ itemStyle: {{ opacity: 0.62 }} }},
|
|
label: {{
|
|
rotate: 0,
|
|
minAngle: 0,
|
|
fontSize: f2,
|
|
overflow: 'none',
|
|
width: 180,
|
|
color: '#0f172a',
|
|
formatter: (p) => (p?.data?.labelText || ''),
|
|
}},
|
|
labelLayout: (params) => {{
|
|
const lr = params?.labelRect;
|
|
const rr = params?.rect;
|
|
if (!lr || !rr) return {{ hide: true }};
|
|
const outside = lr.x < rr.x || lr.y < rr.y || (lr.x + lr.width) > (rr.x + rr.width) || (lr.y + lr.height) > (rr.y + rr.height);
|
|
return {{
|
|
hideOverlap: true,
|
|
hide: outside || !params?.data?.__labelAllowed,
|
|
}};
|
|
}},
|
|
itemStyle: {{ borderColor: 'rgba(255,255,255,0.78)', borderWidth: 1.2 }},
|
|
levels: [
|
|
{{}},
|
|
{{ r0: `${{inner}}%`, r: `${{r1}}%`, itemStyle: {{ borderWidth: 1.5 }}, label: {{ rotate: 0, fontWeight: 800, fontSize: f1, color:'#111827' }} }},
|
|
{{ r0: `${{r1}}%`, r: `${{r2}}%`, itemStyle: {{ borderWidth: 1.4 }}, label: {{ fontSize: f2, color:'#0f172a' }} }},
|
|
{{ r0: `${{r2}}%`, r: `${{r3}}%`, itemStyle: {{ borderWidth: 1.2 }}, label: {{ fontSize: f3, color:'#0f172a' }} }},
|
|
{{ r0: `${{r3}}%`, r: `${{outer}}%`, itemStyle: {{ borderWidth: 1.1 }}, label: {{ fontSize: f4, color:'#0f172a' }} }},
|
|
],
|
|
data: sun,
|
|
}}],
|
|
}}, true);
|
|
|
|
setMasterModeLabel();
|
|
masterHelp.textContent = 'Sunburst: scroll or +/- to zoom, click segment to select.';
|
|
setMasterInfo([
|
|
'<b>Master info</b>',
|
|
'Mode: Sunburst',
|
|
focusLabel(),
|
|
'Click segment: selects and highlights',
|
|
`Items rendered: ${{sourceRows.length}}`,
|
|
`Zoom: x${{sunZoom.toFixed(2)}}`,
|
|
`Build: ${{BUILD_ID || '-'}}`,
|
|
]);
|
|
|
|
masterChart.off('click');
|
|
masterChart.on('click', (params) => {{
|
|
const d = params?.data || {{}};
|
|
const p = params?.data?.path || '';
|
|
if (!p) return;
|
|
setMasterInfo([
|
|
`<b>${{d.name || 'Segment'}}</b>`,
|
|
`Path: ${{p}}`,
|
|
`Items: ${{d.itemCount || 0}}`,
|
|
`High risk: ${{d.highCount || 0}}`,
|
|
`Dominant access: ${{d.access || '-'}}`,
|
|
]);
|
|
const exact = rows.find(r => r.path === p);
|
|
selectedItemId = exact ? (exact.item_id || '') : '';
|
|
selectedItemPath = p;
|
|
centerTreeOnNextRender = true;
|
|
rerender();
|
|
}});
|
|
|
|
masterChart.off('mouseover');
|
|
masterChart.on('mouseover', (params) => {{
|
|
const d = params?.data || {{}};
|
|
const p = params?.data?.path || '';
|
|
if (!p) return;
|
|
setMasterInfo([
|
|
`<b>${{d.name || 'Segment'}}</b>`,
|
|
`Path: ${{p}}`,
|
|
`Type: ${{d.kind || 'FOLDER'}}`,
|
|
`Items: ${{d.itemCount || 0}}`,
|
|
`Owners: ${{d.owners || 0}}`,
|
|
`Access: ${{d.access || '-'}}`,
|
|
]);
|
|
highlightMatrix({{ path: p }}, false);
|
|
}});
|
|
masterChart.off('mouseout');
|
|
masterChart.on('mouseout', () => clearMatrixHighlight());
|
|
|
|
masterChart.off('sunburstrootchanged');
|
|
masterChart.on('sunburstrootchanged', () => setMasterModeLabel());
|
|
}}
|
|
|
|
function buildMasterNetwork(allRows) {{
|
|
const nodeMap = new Map();
|
|
const edgeMap = new Map();
|
|
|
|
function ensureNode(id, role='user') {{
|
|
if (!id) return;
|
|
if (!nodeMap.has(id)) nodeMap.set(id, {{ id, name: id, role, hits: 0 }});
|
|
nodeMap.get(id).hits += 1;
|
|
}}
|
|
|
|
for (const r of allRows) {{
|
|
const src = r.shared_by_email || r.original_owner_email || '';
|
|
if (!src) continue;
|
|
ensureNode(src, 'sharer');
|
|
|
|
const targets = (r.access_targets || '').split('|').map(x => x.trim()).filter(Boolean);
|
|
for (const t of targets) {{
|
|
if (t === '-' || t === src) continue;
|
|
ensureNode(t, t.includes('@') ? 'user' : 'group');
|
|
const key = `${{src}}=>${{t}}`;
|
|
if (!edgeMap.has(key)) edgeMap.set(key, {{ source: src, target: t, value: 0, samplePath: r.path || '' }});
|
|
edgeMap.get(key).value += 1;
|
|
}}
|
|
}}
|
|
|
|
const nodes = Array.from(nodeMap.values()).map(n => {{
|
|
const size = Math.max(14, Math.min(52, 10 + Math.sqrt(n.hits) * 2.2));
|
|
const color = n.role === 'sharer' ? '#1d4ed8' : (n.role === 'group' ? '#7c3aed' : '#0f766e');
|
|
return {{
|
|
id: n.id,
|
|
name: n.name,
|
|
shortName: shortPrincipal(n.name),
|
|
value: n.hits,
|
|
symbolSize: size,
|
|
itemStyle: {{ color }},
|
|
label: {{
|
|
show: true,
|
|
color: '#0f172a',
|
|
fontSize: 10,
|
|
width: 120,
|
|
overflow: 'truncate',
|
|
formatter: (p) => p?.data?.shortName || '',
|
|
}},
|
|
}};
|
|
}});
|
|
|
|
const links = Array.from(edgeMap.values()).map(e => {{
|
|
return {{
|
|
source: e.source,
|
|
target: e.target,
|
|
value: e.value,
|
|
samplePath: e.samplePath,
|
|
lineStyle: {{ width: Math.max(1, Math.min(5, Math.log2(e.value + 1))), color: 'rgba(30,64,175,0.28)' }},
|
|
}};
|
|
}});
|
|
|
|
return {{ nodes, links }};
|
|
}}
|
|
|
|
function renderMasterNetwork() {{
|
|
if (!masterChart) return;
|
|
masterView = 'network';
|
|
activateMasterModeButtons();
|
|
const focusRows = masterFocusRows();
|
|
const sourceRows = focusRows.length ? focusRows : rows;
|
|
const net = buildMasterNetwork(sourceRows);
|
|
|
|
masterChart.setOption({{
|
|
animationDuration: 300,
|
|
tooltip: {{
|
|
trigger: 'item',
|
|
backgroundColor: 'rgba(10,18,32,0.84)',
|
|
textStyle: {{ color: '#fff' }},
|
|
formatter: (p) => {{
|
|
if (p.dataType === 'node') return `<b>${{principalLabel(p.data.name)}}</b><br/>Interactions: ${{p.data.value || 0}}`;
|
|
const d = p.data || {{}};
|
|
return `<b>${{principalLabel(d.source || '')}} ➜ ${{principalLabel(d.target || '')}}</b><br/>Shares: ${{d.value || 0}}<br/>Example path: ${{d.samplePath || '-'}}`;
|
|
}},
|
|
}},
|
|
series: [{{
|
|
type: 'graph',
|
|
layout: 'circular',
|
|
roam: true,
|
|
draggable: false,
|
|
data: net.nodes,
|
|
links: net.links,
|
|
edgeSymbol: ['none', 'arrow'],
|
|
edgeSymbolSize: 6,
|
|
circular: {{ rotateLabel: false }},
|
|
lineStyle: {{ curveness: 0.22, opacity: 0.64 }},
|
|
emphasis: {{ focus: 'adjacency' }},
|
|
labelLayout: {{ hideOverlap: false }},
|
|
label: {{ position: 'right' }},
|
|
}}],
|
|
}}, true);
|
|
|
|
setMasterModeLabel();
|
|
masterHelp.textContent = 'Network: who shares with whom. Scroll to zoom, drag to move.';
|
|
setMasterInfo([
|
|
'<b>Master info</b>',
|
|
'Mode: Share Network',
|
|
focusLabel(),
|
|
`Items rendered: ${{sourceRows.length}}`,
|
|
`Nodes: ${{net.nodes.length}}`,
|
|
`Links: ${{net.links.length}}`,
|
|
'Click edge: select related file/folder',
|
|
`Build: ${{BUILD_ID || '-'}}`,
|
|
]);
|
|
|
|
masterChart.off('click');
|
|
masterChart.on('click', (params) => {{
|
|
if (params.dataType === 'edge') {{
|
|
const p = params.data.samplePath || '';
|
|
if (!p) return;
|
|
setMasterInfo([
|
|
`<b>Edge: ${{principalLabel(params.data.source)}} ➜ ${{principalLabel(params.data.target)}}</b>`,
|
|
`Shares: ${{params.data.value || 0}}`,
|
|
`Example path: ${{p}}`,
|
|
]);
|
|
const exact = rows.find(r => r.path === p);
|
|
selectedItemId = exact ? (exact.item_id || '') : '';
|
|
selectedItemPath = p;
|
|
centerTreeOnNextRender = true;
|
|
rerender();
|
|
return;
|
|
}}
|
|
if (params.dataType === 'node') {{
|
|
const id = params.data.id || params.data.name || '';
|
|
if (!id) return;
|
|
setMasterInfo([
|
|
`<b>Node: ${{principalLabel(id)}}</b>`,
|
|
`Interactions: ${{params.data.value || 0}}`,
|
|
'Matrix highlights matching owner/shared/access rows.',
|
|
]);
|
|
const filtered = rows.filter(r =>
|
|
(r.original_owner_email || '').includes(id) ||
|
|
(r.shared_by_email || '').includes(id) ||
|
|
(r.access_targets || '').includes(id)
|
|
);
|
|
highlightMaster(filtered);
|
|
}}
|
|
}});
|
|
|
|
masterChart.off('mouseover');
|
|
masterChart.on('mouseover', (params) => {{
|
|
if (params.dataType === 'edge') {{
|
|
const p = params.data.samplePath || '';
|
|
if (p) highlightMatrix({{ path: p }}, false);
|
|
return;
|
|
}}
|
|
if (params.dataType === 'node') {{
|
|
const id = params.data.id || params.data.name || '';
|
|
clearMatrixHighlight();
|
|
if (!id) return;
|
|
for (const tr of matrixBody.querySelectorAll('tr')) {{
|
|
const p = tr.dataset.path || '';
|
|
const row = rows.find(x => x.path === p);
|
|
if (!row) continue;
|
|
if ((row.original_owner_email || '').includes(id) || (row.shared_by_email || '').includes(id) || (row.access_targets || '').includes(id)) {{
|
|
tr.classList.add('row-focus');
|
|
}}
|
|
}}
|
|
}}
|
|
}});
|
|
masterChart.off('mouseout');
|
|
masterChart.on('mouseout', () => clearMatrixHighlight());
|
|
}}
|
|
|
|
function renderNetworkPanel() {{
|
|
if (!networkChart) return;
|
|
const sourceRows = masterFocusRows();
|
|
const dataRows = sourceRows.length ? sourceRows : rows;
|
|
const net = buildMasterNetwork(dataRows);
|
|
|
|
networkChart.setOption({{
|
|
animationDuration: 260,
|
|
tooltip: {{
|
|
trigger: 'item',
|
|
backgroundColor: 'rgba(10,18,32,0.84)',
|
|
textStyle: {{ color: '#fff' }},
|
|
formatter: (p) => {{
|
|
if (p.dataType === 'node') return `<b>${{principalLabel(p.data.name)}}</b><br/>Interactions: ${{p.data.value || 0}}`;
|
|
const d = p.data || {{}};
|
|
return `<b>${{principalLabel(d.source || '')}} ➜ ${{principalLabel(d.target || '')}}</b><br/>Shares: ${{d.value || 0}}<br/>Example path: ${{d.samplePath || '-'}}`;
|
|
}},
|
|
}},
|
|
series: [{{
|
|
type: 'graph',
|
|
layout: 'circular',
|
|
roam: true,
|
|
draggable: false,
|
|
data: net.nodes,
|
|
links: net.links,
|
|
edgeSymbol: ['none', 'arrow'],
|
|
edgeSymbolSize: 6,
|
|
circular: {{ rotateLabel: false }},
|
|
lineStyle: {{ curveness: 0.22, opacity: 0.62 }},
|
|
emphasis: {{ focus: 'adjacency' }},
|
|
labelLayout: {{ hideOverlap: false }},
|
|
label: {{ position: 'right' }},
|
|
}}],
|
|
}}, true);
|
|
|
|
networkChart.off('click');
|
|
networkChart.on('click', (params) => {{
|
|
if (params.dataType === 'edge') {{
|
|
const p = params.data.samplePath || '';
|
|
if (!p) return;
|
|
const exact = rows.find(r => r.path === p);
|
|
selectedItemId = exact ? (exact.item_id || '') : '';
|
|
selectedItemPath = p;
|
|
centerTreeOnNextRender = true;
|
|
setMasterInfo([
|
|
`<b>Edge: ${{principalLabel(params.data.source)}} ➜ ${{principalLabel(params.data.target)}}</b>`,
|
|
`Shares: ${{params.data.value || 0}}`,
|
|
`Example path: ${{p}}`,
|
|
]);
|
|
rerender();
|
|
return;
|
|
}}
|
|
if (params.dataType === 'node') {{
|
|
const id = params.data.id || params.data.name || '';
|
|
if (!id) return;
|
|
clearMatrixHighlight();
|
|
for (const tr of matrixBody.querySelectorAll('tr')) {{
|
|
const p = tr.dataset.path || '';
|
|
const row = rows.find(x => x.path === p);
|
|
if (!row) continue;
|
|
if ((row.original_owner_email || '').includes(id) || (row.shared_by_email || '').includes(id) || (row.access_targets || '').includes(id)) tr.classList.add('row-focus');
|
|
}}
|
|
}}
|
|
}});
|
|
}}
|
|
|
|
function applyHighlightToSunNode(node, pathSet) {{
|
|
const on = !node.path || pathSet.has(node.path);
|
|
const next = {{
|
|
...node,
|
|
itemStyle: {{
|
|
...(node.itemStyle || {{}}),
|
|
opacity: on ? 1 : 0.62,
|
|
borderWidth: on ? 1.2 : 0.6,
|
|
}},
|
|
label: {{ show: true, opacity: on ? 1 : 0.55 }},
|
|
}};
|
|
if (node.children && node.children.length) next.children = node.children.map(ch => applyHighlightToSunNode(ch, pathSet));
|
|
return next;
|
|
}}
|
|
|
|
function highlightMaster(data) {{
|
|
if (!masterChart) return;
|
|
|
|
if (masterView === 'network') {{
|
|
const entitySet = new Set();
|
|
for (const r of data) {{
|
|
if (r.shared_by_email) entitySet.add(r.shared_by_email);
|
|
if (r.original_owner_email) entitySet.add(r.original_owner_email);
|
|
for (const t of (r.access_targets || '').split('|').map(x => x.trim()).filter(Boolean)) entitySet.add(t);
|
|
}}
|
|
|
|
masterChart.dispatchAction({{ type: 'downplay', seriesIndex: 0 }});
|
|
const opt = masterChart.getOption();
|
|
const nodes = opt?.series?.[0]?.data || [];
|
|
nodes.forEach((n, i) => {{
|
|
if (entitySet.has(n.id || n.name)) masterChart.dispatchAction({{ type: 'highlight', seriesIndex: 0, dataIndex: i }});
|
|
}});
|
|
masterHelp.textContent = `Network highlight: ${{entitySet.size}} entities`;
|
|
return;
|
|
}}
|
|
|
|
const pathSet = new Set();
|
|
for (const r of data) {{
|
|
const parts = (r.path || '').split('/').filter(Boolean);
|
|
for (let i = 1; i <= parts.length; i++) pathSet.add(parts.slice(0, i).join('/'));
|
|
}}
|
|
|
|
const focusRows = masterFocusRows();
|
|
const sourceRows = focusRows.length ? focusRows : rows;
|
|
const sun = buildMasterSunburst(sourceRows);
|
|
const inner = Math.max(2, Math.min(24, 10 + (1 - sunZoom) * 10));
|
|
const outer = Math.max(82, Math.min(98, 92 + sunZoom * 2.2));
|
|
const prepared = applySunLabelRules(sun, inner, outer);
|
|
const seriesData = (prepared || []).map((node) => applyHighlightToSunNode(node, pathSet));
|
|
|
|
masterChart.setOption({{
|
|
series: [{{
|
|
data: seriesData,
|
|
radius: [`${{inner}}%`, `${{outer}}%`],
|
|
}}],
|
|
}});
|
|
|
|
const highlighted = sourceRows.length ? (pathSet.size / sourceRows.length) < 0.95 : false;
|
|
masterHelp.textContent = highlighted
|
|
? `Sunburst highlight: ${{pathSet.size}} paths`
|
|
: 'Sunburst: full map view';
|
|
setMasterModeLabel();
|
|
}}
|
|
function clearMatrixHighlight() {{
|
|
for (const tr of matrixBody.querySelectorAll('tr')) tr.classList.remove('row-focus');
|
|
}}
|
|
|
|
function highlightMatrix(payload, doScroll=false) {{
|
|
clearMatrixHighlight();
|
|
const rowsAll = Array.from(matrixBody.querySelectorAll('tr'));
|
|
let matched = [];
|
|
|
|
if (payload.item_id) {{
|
|
matched = rowsAll.filter(tr => tr.dataset.itemId === payload.item_id);
|
|
}} else if (payload.path) {{
|
|
matched = rowsAll.filter(tr => tr.dataset.path === payload.path || tr.dataset.path.startsWith(payload.path + '/'));
|
|
}}
|
|
|
|
for (const tr of matched) tr.classList.add('row-focus');
|
|
}}
|
|
|
|
function _treePathFromSelection() {{
|
|
const sr = selectedItemRow();
|
|
let raw = selectedItemPath || selectedPath || '';
|
|
if (!raw && sr?.path) raw = sr.path;
|
|
if (!raw) return '';
|
|
|
|
const isFile = sr?.item_kind === 'FILE';
|
|
let candidate = raw;
|
|
if (isFile && candidate.includes('/')) {{
|
|
candidate = candidate.split('/').slice(0, -1).join('/');
|
|
}}
|
|
|
|
let seek = candidate;
|
|
while (seek) {{
|
|
const hit = document.querySelector(`.tree-node[data-path="${{CSS.escape(seek)}}"]`);
|
|
if (hit) return seek;
|
|
const idx = seek.lastIndexOf('/');
|
|
if (idx < 0) break;
|
|
seek = seek.slice(0, idx);
|
|
}}
|
|
return '';
|
|
}}
|
|
|
|
function revealPathInTree(path, center=false) {{
|
|
if (!path) return;
|
|
const parts = path.split('/').filter(Boolean);
|
|
for (let i = 1; i <= parts.length; i++) {{
|
|
const sub = parts.slice(0, i).join('/');
|
|
const summary = document.querySelector(`summary.tree-node[data-path="${{CSS.escape(sub)}}"]`);
|
|
if (summary) {{
|
|
const details = summary.closest('details');
|
|
if (details) details.open = true;
|
|
}}
|
|
}}
|
|
if (!center || !treeWrap) return;
|
|
const node =
|
|
document.querySelector(`.tree-node[data-path="${{CSS.escape(path)}}"]`) ||
|
|
document.querySelector(`summary.tree-node[data-path="${{CSS.escape(path)}}"]`) ||
|
|
document.querySelector(`button.tree-node[data-path="${{CSS.escape(path)}}"]`);
|
|
if (!node) return;
|
|
const wrapRect = treeWrap.getBoundingClientRect();
|
|
const nodeRect = node.getBoundingClientRect();
|
|
const delta = (nodeRect.top - wrapRect.top) - (wrapRect.height / 2 - nodeRect.height / 2);
|
|
treeWrap.scrollBy({{ top: delta, behavior: 'smooth' }});
|
|
}}
|
|
|
|
function syncTreeSelection(center=false, preserveToggle=false) {{
|
|
for (const n of document.querySelectorAll('.tree-node')) n.classList.remove('active');
|
|
const p = _treePathFromSelection();
|
|
if (!p) return;
|
|
if (!preserveToggle) revealPathInTree(p, center);
|
|
for (const n of document.querySelectorAll(`.tree-node[data-path="${{CSS.escape(p)}}"]`)) {{
|
|
n.classList.add('active');
|
|
}}
|
|
}}
|
|
|
|
function rerender() {{
|
|
const data = filteredRows();
|
|
renderMatrix(data);
|
|
renderDiagram(data);
|
|
if (masterView === 'sunburst') renderMasterSunburst();
|
|
renderNetworkPanel();
|
|
highlightMaster(masterFocusRows());
|
|
syncTreeSelection(centerTreeOnNextRender, preserveTreeToggleOnce);
|
|
centerTreeOnNextRender = false;
|
|
preserveTreeToggleOnce = false;
|
|
if (selectedItemId || selectedItemPath) {{
|
|
const sr = selectedItemRow();
|
|
if (sr) highlightMatrix(sr, false);
|
|
else if (selectedItemPath) highlightMatrix({{ path: selectedItemPath }}, false);
|
|
}} else if (selectedPath) {{
|
|
highlightMatrix({{ path: selectedPath }}, false);
|
|
}}
|
|
const focusText = (selectedItemId || selectedItemPath)
|
|
? (selectedItemRow() ? `${{selectedItemRow().name}} | ${{selectedItemRow().path}}` : selectedItemPath || '-')
|
|
: (selectedPath || 'All paths');
|
|
const visibleAvg = data.length ? (data.reduce((s, r) => s + (r.risk_score || 0), 0) / data.length) : 0;
|
|
riskLabel.textContent = `Rules: public/domain/external/writer/mass-sharing | Visible avg risk: ${{visibleAvg.toFixed(1)}}`;
|
|
meta.textContent = `Sort: path/name | Selection: ${{focusText}} | Visible rows: ${{data.length}}`;
|
|
renderRiskSummary(data);
|
|
renderLiveContext(data);
|
|
renderBreadcrumbs();
|
|
renderSelectionStrips();
|
|
buildBadge.textContent = `Build: ${{BUILD_ID || '-'}} | Rows: ${{rows.length}}`;
|
|
}}
|
|
|
|
q.addEventListener('input', () => {{
|
|
textFilter = q.value.toLowerCase().trim();
|
|
rerender();
|
|
}});
|
|
|
|
document.getElementById('scopeGrp').addEventListener('click', (e) => {{
|
|
const b = e.target.closest('button[data-scope]');
|
|
if (!b) return;
|
|
selectedScope = b.dataset.scope;
|
|
for (const x of e.currentTarget.querySelectorAll('button')) x.classList.remove('active');
|
|
b.classList.add('active');
|
|
rerender();
|
|
}});
|
|
|
|
document.getElementById('riskGrp').addEventListener('click', (e) => {{
|
|
const b = e.target.closest('button[data-risk]');
|
|
if (!b) return;
|
|
selectedRisk = b.dataset.risk;
|
|
for (const x of e.currentTarget.querySelectorAll('button')) x.classList.remove('active');
|
|
b.classList.add('active');
|
|
rerender();
|
|
}});
|
|
|
|
document.getElementById('tree').addEventListener('click', (e) => {{
|
|
const summary = e.target.closest('summary.tree-node');
|
|
if (summary) {{
|
|
e.preventDefault();
|
|
const details = summary.parentElement;
|
|
if (details && details.tagName === 'DETAILS') details.open = !details.open;
|
|
const p = summary.dataset.path || '';
|
|
selectedItemId = '';
|
|
selectedItemPath = p;
|
|
selectedPath = p;
|
|
centerTreeOnNextRender = false;
|
|
preserveTreeToggleOnce = true;
|
|
rerender();
|
|
return;
|
|
}}
|
|
|
|
const leaf = e.target.closest('button.tree-node');
|
|
if (!leaf) return;
|
|
const p = leaf.dataset.path || '';
|
|
selectedItemId = '';
|
|
selectedItemPath = p;
|
|
selectedPath = p;
|
|
centerTreeOnNextRender = false;
|
|
rerender();
|
|
}});
|
|
|
|
breadcrumbsBar.addEventListener('click', (e) => {{
|
|
const b = e.target.closest('button[data-crumb-path]');
|
|
if (!b) return;
|
|
const p = b.dataset.crumbPath || '';
|
|
if (!p) {{
|
|
selectedPath = '';
|
|
selectedItemId = '';
|
|
selectedItemPath = '';
|
|
centerTreeOnNextRender = false;
|
|
rerender();
|
|
return;
|
|
}}
|
|
const exact = rows.find(r => r.path === p);
|
|
selectedItemId = exact ? (exact.item_id || '') : '';
|
|
selectedItemPath = p;
|
|
selectedPath = p;
|
|
centerTreeOnNextRender = true;
|
|
rerender();
|
|
}});
|
|
|
|
document.getElementById('clearSel').addEventListener('click', () => {{
|
|
selectedPath = '';
|
|
selectedItemId = '';
|
|
selectedItemPath = '';
|
|
centerTreeOnNextRender = false;
|
|
rerender();
|
|
}});
|
|
|
|
document.getElementById('expandAll').addEventListener('click', () => {{
|
|
for (const d of document.querySelectorAll('#tree details')) d.open = true;
|
|
}});
|
|
|
|
document.getElementById('collapseAll').addEventListener('click', () => {{
|
|
for (const d of document.querySelectorAll('#tree details')) d.open = false;
|
|
}});
|
|
|
|
document.querySelector('.tbl-wrap').addEventListener('scroll', (e) => {{
|
|
const el = e.currentTarget;
|
|
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 110) {{
|
|
appendMatrixRows();
|
|
}}
|
|
}});
|
|
|
|
loadMoreRowsBtn.addEventListener('click', () => appendMatrixRows());
|
|
|
|
async function copyText(text) {{
|
|
try {{
|
|
await navigator.clipboard.writeText(text);
|
|
return true;
|
|
}} catch (_e) {{
|
|
const ta = document.createElement('textarea');
|
|
ta.value = text;
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
const ok = document.execCommand('copy');
|
|
document.body.removeChild(ta);
|
|
return ok;
|
|
}}
|
|
}}
|
|
|
|
matrixBody.addEventListener('click', async (e) => {{
|
|
const btn = e.target.closest('.copy-row-path');
|
|
if (btn) {{
|
|
const p = btn.dataset.copyPath || '';
|
|
if (!p) return;
|
|
const ok = await copyText(p);
|
|
btn.textContent = ok ? 'Copied' : 'Fail';
|
|
setTimeout(() => (btn.textContent = 'Copy'), 900);
|
|
return;
|
|
}}
|
|
|
|
const tr = e.target.closest('tr[data-item-id]');
|
|
if (!tr) return;
|
|
selectedItemId = tr.dataset.itemId || '';
|
|
selectedItemPath = tr.dataset.path || '';
|
|
centerTreeOnNextRender = true;
|
|
rerender();
|
|
}});
|
|
|
|
copyVisiblePathsBtn.addEventListener('click', async () => {{
|
|
const paths = matrixAllRows.map(r => r.path).join('\\n');
|
|
if (!paths) return;
|
|
const ok = await copyText(paths);
|
|
copyVisiblePathsBtn.textContent = ok ? 'Copied visible' : 'Copy failed';
|
|
setTimeout(() => (copyVisiblePathsBtn.textContent = 'Copy Visible Paths'), 1200);
|
|
}});
|
|
|
|
function applyCols(c1, c2, c3) {{
|
|
document.documentElement.style.setProperty('--col1', `${{c1}}fr`);
|
|
document.documentElement.style.setProperty('--col2', `${{c2}}fr`);
|
|
document.documentElement.style.setProperty('--col3', `${{c3}}fr`);
|
|
localStorage.setItem('gdrive_cols', JSON.stringify([c1, c2, c3]));
|
|
}}
|
|
|
|
function initResizableLayout() {{
|
|
const saved = localStorage.getItem('gdrive_cols');
|
|
if (saved) {{
|
|
try {{
|
|
const [a, b, c] = JSON.parse(saved);
|
|
if (a && b && c) applyCols(a, b, c);
|
|
}} catch (_e) {{}}
|
|
}}
|
|
|
|
const split1 = document.getElementById('splitter1');
|
|
const split2 = document.getElementById('splitter2');
|
|
const layout = document.querySelector('.layout');
|
|
|
|
function wire(splitter, leftIdx, rightIdx) {{
|
|
splitter.addEventListener('mousedown', (ev) => {{
|
|
if (window.innerWidth <= 1100) return;
|
|
ev.preventDefault();
|
|
const rect = layout.getBoundingClientRect();
|
|
const total = rect.width;
|
|
const onMove = (e) => {{
|
|
const x = e.clientX - rect.left;
|
|
let p1 = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--col1')) || 16;
|
|
let p2 = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--col2')) || 34;
|
|
let p3 = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--col3')) || 50;
|
|
const sum = p1 + p2 + p3;
|
|
const leftPx = total * ([p1, p1 + p2][leftIdx] / sum);
|
|
const deltaPx = x - leftPx;
|
|
const deltaFr = (deltaPx / total) * sum;
|
|
|
|
if (splitter.id === 'splitter1') {{
|
|
p1 = Math.max(12, Math.min(45, p1 + deltaFr));
|
|
p2 = Math.max(20, Math.min(60, p2 - deltaFr));
|
|
}} else {{
|
|
p2 = Math.max(20, Math.min(60, p2 + deltaFr));
|
|
p3 = Math.max(24, Math.min(70, p3 - deltaFr));
|
|
}}
|
|
applyCols(Number(p1.toFixed(2)), Number(p2.toFixed(2)), Number(p3.toFixed(2)));
|
|
}};
|
|
const onUp = () => {{
|
|
window.removeEventListener('mousemove', onMove);
|
|
window.removeEventListener('mouseup', onUp);
|
|
}};
|
|
window.addEventListener('mousemove', onMove);
|
|
window.addEventListener('mouseup', onUp);
|
|
}});
|
|
}}
|
|
|
|
wire(split1, 0, 1);
|
|
wire(split2, 1, 2);
|
|
}}
|
|
|
|
function initWheelContainment() {{
|
|
const selectors = ['#tree', '.tbl-wrap', '#diagram', '.master-wrap', '.network-wrap'];
|
|
for (const sel of selectors) {{
|
|
const el = document.querySelector(sel);
|
|
if (!el) continue;
|
|
el.addEventListener('wheel', (e) => e.stopPropagation(), {{ passive: true }});
|
|
}}
|
|
}}
|
|
|
|
// default collapsed for clarity on large trees
|
|
for (const d of document.querySelectorAll('#tree details')) d.open = false;
|
|
|
|
if (modeSunburstBtn) {{
|
|
modeSunburstBtn.addEventListener('click', () => {{
|
|
masterView = 'sunburst';
|
|
renderMasterSunburst();
|
|
highlightMaster(masterFocusRows());
|
|
}});
|
|
}}
|
|
if (modeNetworkBtn) {{
|
|
modeNetworkBtn.addEventListener('click', () => {{
|
|
masterView = 'network';
|
|
renderMasterNetwork();
|
|
highlightMaster(masterFocusRows());
|
|
}});
|
|
}}
|
|
zoomInBtn.addEventListener('click', () => {{
|
|
if (masterView !== 'sunburst') return;
|
|
sunZoom = Math.max(0.75, Math.min(2.4, sunZoom * 1.12));
|
|
renderMasterSunburst();
|
|
highlightMaster(masterFocusRows());
|
|
}});
|
|
zoomOutBtn.addEventListener('click', () => {{
|
|
if (masterView !== 'sunburst') return;
|
|
sunZoom = Math.max(0.75, Math.min(2.4, sunZoom * 0.88));
|
|
renderMasterSunburst();
|
|
highlightMaster(masterFocusRows());
|
|
}});
|
|
zoomResetBtn.addEventListener('click', () => {{
|
|
if (masterView !== 'sunburst') return;
|
|
sunZoom = 1;
|
|
renderMasterSunburst();
|
|
highlightMaster(masterFocusRows());
|
|
}});
|
|
|
|
initMasterChart();
|
|
initNetworkChart();
|
|
renderMasterSunburst();
|
|
initResizableLayout();
|
|
initWheelContainment();
|
|
renderRiskSummary();
|
|
|
|
rerender();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
output_html.write_text(html, encoding="utf-8")
|