Files
gdrive-visual-app/app/explorer.py
T
2026-05-19 14:53:39 +02:00

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")