1174 lines
44 KiB
HTML
1174 lines
44 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>Non-Linear — Layered View</title>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<style>
|
||
:root {
|
||
--bg: #0c0d10;
|
||
--bg-1: #111216;
|
||
--bg-2: #16181d;
|
||
--bg-3: #1c1f26;
|
||
--bg-4: #232730;
|
||
--line: #23262d;
|
||
--line-2: #2c3038;
|
||
--text: #e6e7ea;
|
||
--text-dim: #9aa0aa;
|
||
--text-mute: #5f6571;
|
||
--accent: #7c8cff; /* indigo */
|
||
--accent-2: #9fb1ff;
|
||
--accent-soft: rgba(124,140,255,0.16);
|
||
--accent-line: rgba(124,140,255,0.55);
|
||
--ancestor: #f4b860; /* warm */
|
||
--ancestor-soft: rgba(244,184,96,0.14);
|
||
--ancestor-line: rgba(244,184,96,0.55);
|
||
--descendant: #6ed1a4; /* mint */
|
||
--descendant-soft: rgba(110,209,164,0.14);
|
||
--descendant-line: rgba(110,209,164,0.55);
|
||
--shadow: 0 6px 24px rgba(0,0,0,0.35);
|
||
|
||
--st-todo: #6c7280;
|
||
--st-prog: #f4b860;
|
||
--st-rev: #7c8cff;
|
||
--st-done: #6ed1a4;
|
||
--st-block: #e08197;
|
||
--st-back: #4d525c;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
html, body {
|
||
margin: 0; padding: 0;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||
font-size: 13px;
|
||
line-height: 1.45;
|
||
-webkit-font-smoothing: antialiased;
|
||
height: 100vh;
|
||
overflow: hidden;
|
||
}
|
||
.mono { font-family: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace; }
|
||
|
||
/* ====== APP SHELL ====== */
|
||
.app {
|
||
display: grid;
|
||
grid-template-columns: 56px 240px 1fr;
|
||
grid-template-rows: 44px 1fr;
|
||
grid-template-areas:
|
||
"rail topbar topbar"
|
||
"rail sidebar canvas";
|
||
height: 100vh;
|
||
}
|
||
|
||
/* Top bar */
|
||
.topbar {
|
||
grid-area: topbar;
|
||
border-bottom: 1px solid var(--line);
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 14px;
|
||
gap: 14px;
|
||
background: var(--bg);
|
||
position: relative;
|
||
z-index: 5;
|
||
}
|
||
.breadcrumb {
|
||
display: flex; align-items: center; gap: 6px;
|
||
color: var(--text-dim);
|
||
font-size: 12.5px;
|
||
}
|
||
.breadcrumb .sep { color: var(--text-mute); }
|
||
.breadcrumb .here { color: var(--text); font-weight: 500; }
|
||
.breadcrumb .proj-dot {
|
||
width: 14px; height: 14px; border-radius: 4px;
|
||
background: linear-gradient(135deg, #7c8cff, #6ed1a4);
|
||
display: inline-block;
|
||
}
|
||
.topbar .spacer { flex: 1; }
|
||
.topbar .layer-toggles {
|
||
display: flex; align-items: center; gap: 0;
|
||
border: 1px solid var(--line);
|
||
border-radius: 7px;
|
||
padding: 2px;
|
||
background: var(--bg-1);
|
||
}
|
||
.layer-toggle {
|
||
font-size: 11.5px;
|
||
padding: 3px 9px;
|
||
border-radius: 5px;
|
||
color: var(--text-dim);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
display: flex; align-items: center; gap: 5px;
|
||
}
|
||
.layer-toggle .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-mute); }
|
||
.layer-toggle.active { background: var(--bg-3); color: var(--text); }
|
||
.layer-toggle.active .dot { background: var(--accent); box-shadow: 0 0 0 2px rgba(124,140,255,0.18); }
|
||
.layer-toggle.disabled { opacity: 0.5; cursor: not-allowed; }
|
||
|
||
.pill-btn {
|
||
border: 1px solid var(--line);
|
||
background: var(--bg-1);
|
||
color: var(--text-dim);
|
||
border-radius: 6px;
|
||
padding: 4px 9px;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
display: flex; align-items: center; gap: 6px;
|
||
}
|
||
.pill-btn:hover { color: var(--text); border-color: var(--line-2); }
|
||
.kbd {
|
||
font-family: ui-monospace, monospace;
|
||
font-size: 10.5px;
|
||
color: var(--text-mute);
|
||
border: 1px solid var(--line);
|
||
background: var(--bg-2);
|
||
padding: 1px 5px;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
/* Left rail */
|
||
.rail {
|
||
grid-area: rail;
|
||
border-right: 1px solid var(--line);
|
||
background: var(--bg);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 10px 0;
|
||
gap: 4px;
|
||
}
|
||
.rail .logo {
|
||
width: 30px; height: 30px;
|
||
border-radius: 8px;
|
||
background: linear-gradient(135deg, #1f2330, #0c0d10);
|
||
border: 1px solid var(--line-2);
|
||
display: grid; place-items: center;
|
||
margin-bottom: 8px;
|
||
position: relative;
|
||
}
|
||
.rail .logo svg { width: 18px; height: 18px; }
|
||
.rail-btn {
|
||
width: 34px; height: 34px;
|
||
border-radius: 8px;
|
||
display: grid; place-items: center;
|
||
color: var(--text-mute);
|
||
cursor: pointer;
|
||
position: relative;
|
||
}
|
||
.rail-btn:hover { color: var(--text); background: var(--bg-2); }
|
||
.rail-btn.active { color: var(--text); background: var(--bg-3); }
|
||
.rail-btn.active::before {
|
||
content:""; position: absolute; left: -8px; top: 8px; bottom: 8px; width: 2px;
|
||
background: var(--accent); border-radius: 2px;
|
||
}
|
||
.rail-btn svg { width: 16px; height: 16px; }
|
||
.rail .grow { flex: 1; }
|
||
|
||
/* Sidebar */
|
||
.sidebar {
|
||
grid-area: sidebar;
|
||
border-right: 1px solid var(--line);
|
||
background: var(--bg);
|
||
overflow-y: auto;
|
||
padding: 10px 8px 16px;
|
||
}
|
||
.side-section { margin-top: 12px; }
|
||
.side-section:first-child { margin-top: 4px; }
|
||
.side-h {
|
||
font-size: 10.5px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
color: var(--text-mute);
|
||
padding: 6px 8px 4px;
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
}
|
||
.side-row {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 4px 8px;
|
||
border-radius: 6px;
|
||
color: var(--text-dim);
|
||
font-size: 12.5px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
.side-row:hover { background: var(--bg-2); color: var(--text); }
|
||
.side-row.active { background: var(--bg-3); color: var(--text); }
|
||
.side-row svg { width: 13px; height: 13px; opacity: 0.85; }
|
||
.side-row .badge {
|
||
margin-left: auto; font-size: 10.5px; color: var(--text-mute);
|
||
}
|
||
.side-tree { padding-left: 4px; }
|
||
.tree-row {
|
||
display: flex; align-items: center; gap: 6px;
|
||
padding: 3px 8px;
|
||
border-radius: 5px;
|
||
color: var(--text-dim);
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
line-height: 1.3;
|
||
}
|
||
.tree-row:hover { background: var(--bg-2); color: var(--text); }
|
||
.tree-row .chev { width: 10px; color: var(--text-mute); }
|
||
.tree-row .ico { width: 12px; height: 12px; flex: none; }
|
||
.tree-row .id { color: var(--text-mute); font-family: ui-monospace, monospace; font-size: 10.5px; margin-left: auto; }
|
||
.tree-row.active { background: var(--accent-soft); color: var(--text); }
|
||
|
||
/* ====== CANVAS ====== */
|
||
.canvas-wrap {
|
||
grid-area: canvas;
|
||
position: relative;
|
||
overflow: hidden;
|
||
background:
|
||
radial-gradient(1200px 700px at 30% -10%, rgba(124,140,255,0.06), transparent 60%),
|
||
var(--bg);
|
||
}
|
||
.canvas {
|
||
position: absolute; inset: 0;
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
padding: 18px 18px 56px;
|
||
}
|
||
.columns {
|
||
display: flex;
|
||
gap: 14px;
|
||
height: 100%;
|
||
align-items: stretch;
|
||
min-width: max-content;
|
||
}
|
||
.column {
|
||
display: flex;
|
||
flex-direction: column;
|
||
width: 320px;
|
||
min-width: 320px;
|
||
background: var(--bg-1);
|
||
border: 1px solid var(--line);
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
height: 100%;
|
||
flex: none;
|
||
}
|
||
.column.wide { width: 360px; min-width: 360px; }
|
||
.col-head {
|
||
padding: 10px 12px;
|
||
border-bottom: 1px solid var(--line);
|
||
display: flex; align-items: center; gap: 8px;
|
||
background: var(--bg-1);
|
||
}
|
||
.col-head .lvl {
|
||
font-size: 10px;
|
||
color: var(--text-mute);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
}
|
||
.col-head .title {
|
||
font-weight: 600;
|
||
font-size: 12.5px;
|
||
color: var(--text);
|
||
}
|
||
.col-head .meta {
|
||
margin-left: auto; font-size: 11px; color: var(--text-mute);
|
||
display: flex; align-items: center; gap: 6px;
|
||
}
|
||
.col-head .arrow {
|
||
width: 14px; height: 14px; color: var(--text-mute);
|
||
}
|
||
.col-body {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 8px;
|
||
display: flex; flex-direction: column; gap: 6px;
|
||
}
|
||
|
||
/* Group label inside a column */
|
||
.group-label {
|
||
font-size: 10.5px;
|
||
color: var(--text-mute);
|
||
padding: 8px 6px 2px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
display: flex; align-items: center; gap: 6px;
|
||
}
|
||
.group-label::after {
|
||
content:""; flex:1; height:1px; background: var(--line);
|
||
}
|
||
|
||
/* Node card */
|
||
.node {
|
||
--node-accent: var(--text-mute);
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
padding: 9px 10px;
|
||
background: var(--bg-2);
|
||
cursor: pointer;
|
||
transition: background 120ms, border-color 120ms, box-shadow 120ms, transform 120ms;
|
||
position: relative;
|
||
}
|
||
.node:hover { background: var(--bg-3); border-color: var(--line-2); }
|
||
.node .top {
|
||
display: flex; align-items: center; gap: 8px; margin-bottom: 4px;
|
||
}
|
||
.node .id {
|
||
font-family: ui-monospace, monospace;
|
||
font-size: 10.5px;
|
||
color: var(--text-mute);
|
||
}
|
||
.node .title-row {
|
||
display: flex; align-items: flex-start; gap: 8px;
|
||
}
|
||
.node .title-row .ico { flex: none; margin-top: 2px; }
|
||
.node .title {
|
||
font-size: 12.5px;
|
||
color: var(--text);
|
||
line-height: 1.35;
|
||
flex: 1;
|
||
}
|
||
.node .title b { font-weight: 600; }
|
||
.node .meta {
|
||
margin-top: 6px;
|
||
display: flex; align-items: center; gap: 8px;
|
||
color: var(--text-mute);
|
||
font-size: 11px;
|
||
}
|
||
.node .labels { display: flex; gap: 4px; flex-wrap: wrap; }
|
||
.label {
|
||
font-size: 10px;
|
||
padding: 1px 6px;
|
||
border-radius: 9px;
|
||
background: var(--bg-4);
|
||
color: var(--text-dim);
|
||
border: 1px solid var(--line-2);
|
||
line-height: 1.5;
|
||
}
|
||
.label.p0 { color: #f1a3b3; background: rgba(224,129,151,0.1); border-color: rgba(224,129,151,0.3); }
|
||
.label.bug { color: #f1a3b3; background: rgba(224,129,151,0.1); border-color: rgba(224,129,151,0.3); }
|
||
.label.feat { color: var(--accent-2); background: var(--accent-soft); border-color: var(--accent-line); }
|
||
.label.chore { color: var(--text-dim); }
|
||
.label.frontend { color: #d8b6ff; background: rgba(180,140,255,0.1); border-color: rgba(180,140,255,0.3); }
|
||
.label.backend { color: #98ddff; background: rgba(120,200,255,0.1); border-color: rgba(120,200,255,0.3); }
|
||
.label.infra { color: #f4cf86; background: rgba(244,184,96,0.1); border-color: rgba(244,184,96,0.3); }
|
||
|
||
.avatar {
|
||
width: 16px; height: 16px; border-radius: 50%;
|
||
background: var(--bg-4);
|
||
display: inline-grid; place-items: center;
|
||
font-size: 9px; color: var(--text);
|
||
border: 1px solid var(--line-2);
|
||
}
|
||
.avatar.a-jori { background: linear-gradient(135deg,#7c8cff,#5563d6); }
|
||
.avatar.a-mira { background: linear-gradient(135deg,#6ed1a4,#3aa178); }
|
||
.avatar.a-karri { background: linear-gradient(135deg,#f4b860,#c98933); }
|
||
.avatar.a-codex { background: linear-gradient(135deg,#1c1f26,#0c0d10); border-color: var(--line-2); position: relative; }
|
||
.avatar.a-codex::after { content: "★"; font-size: 8px; color: var(--accent-2); }
|
||
.avatar.a-triage { background: linear-gradient(135deg,#e08197,#9b3a52); }
|
||
.avatar.bot { color: var(--accent-2); }
|
||
|
||
/* Status dot */
|
||
.st {
|
||
width: 12px; height: 12px; border-radius: 50%;
|
||
border: 2px solid var(--st-todo);
|
||
background: transparent;
|
||
flex: none;
|
||
position: relative;
|
||
}
|
||
.st.todo { border-color: var(--st-todo); }
|
||
.st.prog { border-color: var(--st-prog); background: conic-gradient(var(--st-prog) 60%, transparent 0); }
|
||
.st.rev { border-color: var(--st-rev); background: conic-gradient(var(--st-rev) 75%, transparent 0); }
|
||
.st.done { border-color: var(--st-done); background: var(--st-done); }
|
||
.st.done::after { content:""; position:absolute; left:2px; top: 1px; width:5px; height:2.5px; border-left:1.5px solid var(--bg-1); border-bottom: 1.5px solid var(--bg-1); transform: rotate(-45deg); }
|
||
.st.back { border-color: var(--st-back); border-style: dashed; }
|
||
.st.cancel { border-color: var(--st-back); background: var(--st-back); position: relative; }
|
||
.st.cancel::after { content:"×"; position:absolute; inset:0; display:grid; place-items:center; font-size: 10px; color: var(--bg-1); line-height: 1; }
|
||
|
||
/* Component icon (cube) */
|
||
.cube { color: var(--text-dim); }
|
||
.node[data-kind="component"] .cube { color: var(--accent-2); }
|
||
|
||
/* Connector lines from each card to the column on the right (children expansion indicator) */
|
||
.node .child-handle {
|
||
position: absolute;
|
||
right: -8px; top: 50%; transform: translateY(-50%);
|
||
width: 14px; height: 14px;
|
||
border-radius: 50%;
|
||
background: var(--bg-1);
|
||
border: 1px solid var(--line-2);
|
||
display: grid; place-items: center;
|
||
color: var(--text-mute);
|
||
font-size: 10px;
|
||
opacity: 0;
|
||
transition: opacity 120ms;
|
||
pointer-events: none;
|
||
}
|
||
.node:hover .child-handle { opacity: 1; }
|
||
|
||
/* Highlight states */
|
||
.node.is-hover {
|
||
background: var(--bg-3);
|
||
border-color: var(--accent-line);
|
||
box-shadow: 0 0 0 1px var(--accent-line), 0 6px 16px rgba(0,0,0,0.4);
|
||
}
|
||
.node.is-ancestor {
|
||
border-color: var(--ancestor-line);
|
||
background: var(--ancestor-soft);
|
||
}
|
||
.node.is-ancestor .id, .node.is-ancestor .title { color: var(--text); }
|
||
.node.is-descendant {
|
||
border-color: var(--descendant-line);
|
||
background: var(--descendant-soft);
|
||
}
|
||
.node.is-dim {
|
||
opacity: 0.32;
|
||
}
|
||
|
||
/* Lineage edges layer */
|
||
.edges {
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
z-index: 3;
|
||
}
|
||
.edges path {
|
||
fill: none;
|
||
stroke-linecap: round;
|
||
}
|
||
|
||
/* Bottom mini overview / horizontal scroll indicator */
|
||
.minimap {
|
||
position: absolute;
|
||
left: 0; right: 0; bottom: 0;
|
||
height: 38px;
|
||
border-top: 1px solid var(--line);
|
||
background: linear-gradient(180deg, rgba(12,13,16,0) 0%, var(--bg) 30%);
|
||
display: flex; align-items: center;
|
||
padding: 0 14px;
|
||
gap: 12px;
|
||
z-index: 4;
|
||
}
|
||
.minimap .arrow-btn {
|
||
width: 22px; height: 22px;
|
||
border-radius: 6px;
|
||
border: 1px solid var(--line);
|
||
background: var(--bg-1);
|
||
display: grid; place-items: center;
|
||
color: var(--text-dim);
|
||
cursor: pointer;
|
||
}
|
||
.minimap .arrow-btn:hover { color: var(--text); }
|
||
.mini-track {
|
||
flex: 1;
|
||
height: 8px;
|
||
background: var(--bg-2);
|
||
border: 1px solid var(--line);
|
||
border-radius: 5px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
.mini-track .blocks {
|
||
position: absolute; inset: 0;
|
||
display: flex; padding: 1px; gap: 2px;
|
||
}
|
||
.mini-track .blocks > div {
|
||
flex: 1;
|
||
border-radius: 2px;
|
||
background: var(--bg-3);
|
||
}
|
||
.mini-track .blocks > div.viewing { background: var(--accent); opacity: 0.7; }
|
||
.mini-track .blocks > div.viewing.partial { background: var(--accent); opacity: 0.35; }
|
||
|
||
.mini-label { font-size: 11px; color: var(--text-mute); }
|
||
|
||
/* Direction indicator above columns */
|
||
.axis-strip {
|
||
position: absolute;
|
||
left: 18px; right: 18px; top: 6px;
|
||
display: flex; justify-content: space-between;
|
||
color: var(--text-mute);
|
||
font-size: 10.5px;
|
||
pointer-events: none;
|
||
text-transform: uppercase; letter-spacing: 0.1em;
|
||
z-index: 1;
|
||
}
|
||
|
||
/* Detail rail */
|
||
.detail {
|
||
position: absolute;
|
||
top: 18px; right: 18px; bottom: 56px;
|
||
width: 320px;
|
||
background: var(--bg-1);
|
||
border: 1px solid var(--line);
|
||
border-radius: 10px;
|
||
box-shadow: var(--shadow);
|
||
overflow: hidden;
|
||
display: flex; flex-direction: column;
|
||
z-index: 6;
|
||
}
|
||
.detail-head {
|
||
padding: 12px 14px 10px;
|
||
border-bottom: 1px solid var(--line);
|
||
display: flex; align-items: center; gap: 8px;
|
||
}
|
||
.detail-head .id { color: var(--text-mute); font-family: ui-monospace,monospace; font-size: 11px; }
|
||
.detail-head .x { margin-left:auto; cursor:pointer; color: var(--text-mute); }
|
||
.detail-body { padding: 14px; overflow-y: auto; }
|
||
.detail-body h3 { margin: 0 0 8px; font-size: 15px; font-weight: 600; }
|
||
.detail-body p { color: var(--text-dim); margin: 0 0 12px; font-size: 12.5px; }
|
||
.meta-grid {
|
||
display: grid;
|
||
grid-template-columns: 80px 1fr;
|
||
gap: 6px 10px;
|
||
font-size: 11.5px;
|
||
}
|
||
.meta-grid .k { color: var(--text-mute); }
|
||
.meta-grid .v { color: var(--text); display: flex; align-items: center; gap: 6px; }
|
||
|
||
/* Scrollbars */
|
||
.canvas::-webkit-scrollbar, .col-body::-webkit-scrollbar, .sidebar::-webkit-scrollbar, .detail-body::-webkit-scrollbar {
|
||
height: 10px; width: 10px;
|
||
}
|
||
.canvas::-webkit-scrollbar-thumb, .col-body::-webkit-scrollbar-thumb, .sidebar::-webkit-scrollbar-thumb, .detail-body::-webkit-scrollbar-thumb {
|
||
background: var(--bg-3); border-radius: 6px; border: 2px solid var(--bg);
|
||
}
|
||
.canvas::-webkit-scrollbar-track, .col-body::-webkit-scrollbar-track { background: transparent; }
|
||
|
||
/* Add child button */
|
||
.add-row {
|
||
margin-top: 4px;
|
||
display: flex; align-items: center; gap: 8px;
|
||
color: var(--text-mute);
|
||
font-size: 11.5px;
|
||
padding: 6px 8px;
|
||
border: 1px dashed var(--line-2);
|
||
border-radius: 7px;
|
||
cursor: pointer;
|
||
}
|
||
.add-row:hover { color: var(--text); border-color: var(--accent-line); background: var(--accent-soft); }
|
||
|
||
/* Edge legend (shown when hovering) */
|
||
.legend {
|
||
position: absolute;
|
||
bottom: 46px; right: 18px;
|
||
background: var(--bg-1);
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
padding: 8px 10px;
|
||
display: flex; gap: 14px;
|
||
font-size: 11px;
|
||
color: var(--text-dim);
|
||
box-shadow: var(--shadow);
|
||
z-index: 4;
|
||
opacity: 0;
|
||
transform: translateY(4px);
|
||
transition: opacity 140ms, transform 140ms;
|
||
pointer-events: none;
|
||
}
|
||
.legend.visible { opacity: 1; transform: translateY(0); }
|
||
.legend .swatch { display: inline-block; width: 18px; height: 2px; margin-right: 6px; vertical-align: middle; border-radius: 2px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- Icon defs -->
|
||
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
|
||
<defs>
|
||
<symbol id="i-cube" viewBox="0 0 16 16"><path d="M8 1.5l5.5 3v7L8 14.5 2.5 11.5v-7L8 1.5zM8 1.5v6m0 0L2.5 4.5M8 7.5l5.5-3M8 7.5v7" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></symbol>
|
||
<symbol id="i-folder" viewBox="0 0 16 16"><path d="M2 4.5A1.5 1.5 0 0 1 3.5 3h2.7l1.4 1.5h5A1.5 1.5 0 0 1 14 6v6.5A1.5 1.5 0 0 1 12.5 14h-9A1.5 1.5 0 0 1 2 12.5v-8z" fill="none" stroke="currentColor" stroke-width="1.2"/></symbol>
|
||
<symbol id="i-issue" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" stroke-width="1.2"/><circle cx="8" cy="8" r="2" fill="currentColor"/></symbol>
|
||
<symbol id="i-search" viewBox="0 0 16 16"><circle cx="7" cy="7" r="4.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M10.5 10.5L14 14" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></symbol>
|
||
<symbol id="i-plus" viewBox="0 0 16 16"><path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></symbol>
|
||
<symbol id="i-arrow-right" viewBox="0 0 16 16"><path d="M3 8h9m0 0L8.5 4.5M12 8L8.5 11.5" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
||
<symbol id="i-arrow-left" viewBox="0 0 16 16"><path d="M13 8H4m0 0L7.5 4.5M4 8l3.5 3.5" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
||
<symbol id="i-chev" viewBox="0 0 16 16"><path d="M5 4l5 4-5 4" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
||
<symbol id="i-inbox" viewBox="0 0 16 16"><path d="M2 8.5L4 3h8l2 5.5V13H2zM2 8.5h3.5l1 1.5h3l1-1.5H14" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></symbol>
|
||
<symbol id="i-graph" viewBox="0 0 16 16"><circle cx="3" cy="8" r="1.5" fill="currentColor"/><circle cx="13" cy="4" r="1.5" fill="currentColor"/><circle cx="13" cy="12" r="1.5" fill="currentColor"/><path d="M4.5 8L11.5 4M4.5 8l7 4" stroke="currentColor" stroke-width="1.2"/></symbol>
|
||
<symbol id="i-cycle" viewBox="0 0 16 16"><path d="M3 8a5 5 0 0 1 9-3M13 8a5 5 0 0 1-9 3" fill="none" stroke="currentColor" stroke-width="1.2"/><path d="M11 2v3h-3M5 14v-3h3" fill="none" stroke="currentColor" stroke-width="1.2"/></symbol>
|
||
<symbol id="i-list" viewBox="0 0 16 16"><path d="M3 4h10M3 8h10M3 12h10" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></symbol>
|
||
<symbol id="i-settings" viewBox="0 0 16 16"><circle cx="8" cy="8" r="2" fill="none" stroke="currentColor" stroke-width="1.2"/><path d="M8 1.5v2m0 9v2M1.5 8h2m9 0h2M3.3 3.3l1.4 1.4m6.6 6.6l1.4 1.4M3.3 12.7l1.4-1.4m6.6-6.6l1.4-1.4" stroke="currentColor" stroke-width="1.1"/></symbol>
|
||
<symbol id="i-palette" viewBox="0 0 16 16"><path d="M8 14a6 6 0 1 1 6-6 2 2 0 0 1-2 2H9.5a1 1 0 0 0-.7 1.7l.4.4A1.4 1.4 0 0 1 8 14z" fill="none" stroke="currentColor" stroke-width="1.2"/></symbol>
|
||
<symbol id="i-link" viewBox="0 0 16 16"><path d="M6.5 9.5a3 3 0 0 1 0-4l1.5-1.5a3 3 0 0 1 4 4M9.5 6.5a3 3 0 0 1 0 4L8 12a3 3 0 0 1-4-4" fill="none" stroke="currentColor" stroke-width="1.2"/></symbol>
|
||
<symbol id="i-x" viewBox="0 0 16 16"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></symbol>
|
||
<symbol id="i-bot" viewBox="0 0 16 16"><rect x="3" y="5" width="10" height="8" rx="2" fill="none" stroke="currentColor" stroke-width="1.2"/><circle cx="6.5" cy="9" r="1" fill="currentColor"/><circle cx="9.5" cy="9" r="1" fill="currentColor"/><path d="M8 5V3" stroke="currentColor" stroke-width="1.2"/></symbol>
|
||
</defs>
|
||
</svg>
|
||
|
||
<div class="app">
|
||
<!-- Rail -->
|
||
<aside class="rail">
|
||
<div class="logo" title="Non-Linear">
|
||
<svg viewBox="0 0 24 24" fill="none">
|
||
<path d="M4 18 L 10 12 L 14 16 L 20 6" stroke="url(#g)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||
<circle cx="4" cy="18" r="1.6" fill="#7c8cff"/>
|
||
<circle cx="20" cy="6" r="1.6" fill="#6ed1a4"/>
|
||
<defs><linearGradient id="g" x1="0" x2="24"><stop stop-color="#7c8cff"/><stop offset="1" stop-color="#6ed1a4"/></linearGradient></defs>
|
||
</svg>
|
||
</div>
|
||
<div class="rail-btn" title="Inbox"><svg><use href="#i-inbox"/></svg></div>
|
||
<div class="rail-btn active" title="Layered View"><svg><use href="#i-graph"/></svg></div>
|
||
<div class="rail-btn" title="List / Board"><svg><use href="#i-list"/></svg></div>
|
||
<div class="rail-btn" title="Cycles"><svg><use href="#i-cycle"/></svg></div>
|
||
<div class="grow"></div>
|
||
<div class="rail-btn" title="Settings"><svg><use href="#i-settings"/></svg></div>
|
||
</aside>
|
||
|
||
<!-- Topbar -->
|
||
<header class="topbar">
|
||
<div class="breadcrumb">
|
||
<span class="proj-dot"></span>
|
||
<span>Aurora</span>
|
||
<span class="sep">/</span>
|
||
<span>Backend</span>
|
||
<span class="sep">/</span>
|
||
<span class="here">Layered View</span>
|
||
</div>
|
||
<div class="spacer"></div>
|
||
|
||
<div class="layer-toggles" role="tablist" title="Layer visibility (Alt+1…4)">
|
||
<div class="layer-toggle active"><span class="dot"></span>Structure</div>
|
||
<div class="layer-toggle active"><span class="dot"></span>Work</div>
|
||
<div class="layer-toggle disabled"><span class="dot"></span>Connections</div>
|
||
<div class="layer-toggle disabled"><span class="dot"></span>Artifacts</div>
|
||
</div>
|
||
|
||
<button class="pill-btn" title="Filter"><svg width="12" height="12"><use href="#i-list"/></svg>Filter</button>
|
||
<button class="pill-btn" title="Search"><svg width="12" height="12"><use href="#i-search"/></svg>Search<span class="kbd">⌘K</span></button>
|
||
</header>
|
||
|
||
<!-- Sidebar -->
|
||
<aside class="sidebar">
|
||
<div class="side-section">
|
||
<div class="side-row"><svg><use href="#i-inbox"/></svg>Triage Inbox<span class="badge">12</span></div>
|
||
<div class="side-row"><svg><use href="#i-cycle"/></svg>Cycle 7<span class="badge">23</span></div>
|
||
<div class="side-row active"><svg><use href="#i-graph"/></svg>Layered View</div>
|
||
<div class="side-row"><svg><use href="#i-list"/></svg>All issues<span class="badge">145</span></div>
|
||
</div>
|
||
|
||
<div class="side-section">
|
||
<div class="side-h">Projects <span>+</span></div>
|
||
<div class="side-tree">
|
||
<div class="tree-row" style="font-weight:500;color:var(--text)">
|
||
<svg class="chev" viewBox="0 0 16 16"><path d="M5 4l5 4-5 4" fill="none" stroke="currentColor" stroke-width="1.2"/></svg>
|
||
<svg class="ico"><use href="#i-cube"/></svg>
|
||
Aurora SaaS
|
||
</div>
|
||
<div style="padding-left:14px">
|
||
<div class="tree-row">
|
||
<svg class="chev" viewBox="0 0 16 16" style="transform:rotate(90deg)"><path d="M5 4l5 4-5 4" fill="none" stroke="currentColor" stroke-width="1.2"/></svg>
|
||
<svg class="ico" style="color:var(--accent-2)"><use href="#i-cube"/></svg>
|
||
Backend <span class="id">C-04</span>
|
||
</div>
|
||
<div style="padding-left:14px">
|
||
<div class="tree-row active">
|
||
<span class="chev"></span>
|
||
<svg class="ico" style="color:var(--accent-2)"><use href="#i-cube"/></svg>
|
||
auth-service <span class="id">C-12</span>
|
||
</div>
|
||
<div class="tree-row">
|
||
<span class="chev"></span>
|
||
<svg class="ico" style="color:var(--accent-2)"><use href="#i-cube"/></svg>
|
||
billing-service <span class="id">C-18</span>
|
||
</div>
|
||
<div class="tree-row">
|
||
<span class="chev"></span>
|
||
<svg class="ico" style="color:var(--accent-2)"><use href="#i-cube"/></svg>
|
||
notifications <span class="id">C-21</span>
|
||
</div>
|
||
</div>
|
||
<div class="tree-row">
|
||
<svg class="chev" viewBox="0 0 16 16"><path d="M5 4l5 4-5 4" fill="none" stroke="currentColor" stroke-width="1.2"/></svg>
|
||
<svg class="ico" style="color:var(--accent-2)"><use href="#i-cube"/></svg>
|
||
Frontend <span class="id">C-05</span>
|
||
</div>
|
||
<div class="tree-row">
|
||
<svg class="chev" viewBox="0 0 16 16"><path d="M5 4l5 4-5 4" fill="none" stroke="currentColor" stroke-width="1.2"/></svg>
|
||
<svg class="ico" style="color:var(--accent-2)"><use href="#i-cube"/></svg>
|
||
Infra <span class="id">C-06</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="side-section">
|
||
<div class="side-h">Favorites</div>
|
||
<div class="side-row"><svg><use href="#i-issue"/></svg>NL-203 OAuth refresh</div>
|
||
<div class="side-row"><svg><use href="#i-cube"/></svg>auth-service</div>
|
||
<div class="side-row"><svg><use href="#i-bot"/></svg>Triage Agent</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- Canvas -->
|
||
<main class="canvas-wrap">
|
||
<div class="axis-strip">
|
||
<div>← lower (children)</div>
|
||
<div>higher (parents) →</div>
|
||
</div>
|
||
|
||
<div class="canvas" id="canvas">
|
||
<div class="columns" id="columns">
|
||
<!-- Columns are rendered by JS -->
|
||
</div>
|
||
|
||
<svg class="edges" id="edges" preserveAspectRatio="none"></svg>
|
||
</div>
|
||
|
||
<div class="legend" id="legend">
|
||
<div><span class="swatch" style="background:var(--ancestor)"></span>parents (toward right)</div>
|
||
<div><span class="swatch" style="background:var(--accent)"></span>focused</div>
|
||
<div><span class="swatch" style="background:var(--descendant)"></span>children (toward left)</div>
|
||
</div>
|
||
|
||
<div class="minimap">
|
||
<div class="arrow-btn" id="scroll-left" title="Scroll left"><svg width="12" height="12"><use href="#i-arrow-left"/></svg></div>
|
||
<div class="mini-track" id="minitrack">
|
||
<div class="blocks" id="miniblocks"></div>
|
||
</div>
|
||
<div class="arrow-btn" id="scroll-right" title="Scroll right"><svg width="12" height="12"><use href="#i-arrow-right"/></svg></div>
|
||
<div class="mini-label" id="mini-label">depth 0 → 5</div>
|
||
</div>
|
||
|
||
<!-- Detail panel -->
|
||
<aside class="detail" id="detail" style="display:none">
|
||
<div class="detail-head">
|
||
<span class="st prog"></span>
|
||
<span class="id">NL-203</span>
|
||
<span class="x" id="detail-x"><svg width="12" height="12"><use href="#i-x"/></svg></span>
|
||
</div>
|
||
<div class="detail-body">
|
||
<h3>Implement refresh-token rotation</h3>
|
||
<p>Rotate refresh tokens on every use. Detect reuse and revoke the entire family. Required to ship enterprise SSO.</p>
|
||
<div class="meta-grid">
|
||
<div class="k">Status</div> <div class="v"><span class="st prog"></span> In Progress</div>
|
||
<div class="k">Assignee</div> <div class="v"><span class="avatar a-jori">J</span> jori</div>
|
||
<div class="k">Parent</div> <div class="v"><svg width="12" height="12" style="color:var(--accent-2)"><use href="#i-cube"/></svg> oauth-module</div>
|
||
<div class="k">Cycle</div> <div class="v">Cycle 7 · ends Fri</div>
|
||
<div class="k">Labels</div> <div class="v"><span class="label feat">feature</span><span class="label backend">backend</span><span class="label p0">p0</span></div>
|
||
<div class="k">Blocked by</div> <div class="v">NL-198 (token store schema)</div>
|
||
<div class="k">Blocks</div> <div class="v">NL-241, NL-258</div>
|
||
<div class="k">Repo</div> <div class="v mono" style="font-size:11px">aurora/auth-service · /oauth</div>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
</main>
|
||
</div>
|
||
|
||
<!-- ====== DATA + LOGIC ====== -->
|
||
<script>
|
||
/* ---------- Graph data ---------- */
|
||
/*
|
||
Each node: { id, kind, title, parent, status?, labels?, assignee?, repo?, depth }
|
||
Columns are arranged by depth (lower-numbered depth = root, on the right; higher depth = leaves, on the left).
|
||
In the UI, leftmost column = lowest layer (children), rightmost = highest (root parent).
|
||
*/
|
||
const NODES = [
|
||
// Root
|
||
{ id:'C-00', kind:'component', title:'Aurora SaaS', depth:0, parent:null,
|
||
labels:[], owner:'jori', repo:'github.com/aurora' },
|
||
|
||
// Depth 1 — top-level components
|
||
{ id:'C-04', kind:'component', title:'Backend', depth:1, parent:'C-00', labels:['backend'], owner:'mira' },
|
||
{ id:'C-05', kind:'component', title:'Frontend', depth:1, parent:'C-00', labels:['frontend'], owner:'karri' },
|
||
{ id:'C-06', kind:'component', title:'Infra', depth:1, parent:'C-00', labels:['infra'], owner:'codex' },
|
||
|
||
// Depth 2 — services
|
||
{ id:'C-12', kind:'component', title:'auth-service', depth:2, parent:'C-04', labels:['backend'], owner:'jori', repo:'aurora/auth-service' },
|
||
{ id:'C-18', kind:'component', title:'billing-service', depth:2, parent:'C-04', labels:['backend'], owner:'mira', repo:'aurora/billing' },
|
||
{ id:'C-21', kind:'component', title:'notifications', depth:2, parent:'C-04', labels:['backend'], owner:'codex', repo:'aurora/notify' },
|
||
{ id:'C-31', kind:'component', title:'web-app', depth:2, parent:'C-05', labels:['frontend'], owner:'karri' },
|
||
{ id:'C-32', kind:'component', title:'design-system', depth:2, parent:'C-05', labels:['frontend'], owner:'karri' },
|
||
|
||
// Depth 3 — modules
|
||
{ id:'C-44', kind:'component', title:'oauth-module', depth:3, parent:'C-12', labels:['backend'], owner:'jori', repo:'aurora/auth-service /oauth' },
|
||
{ id:'C-45', kind:'component', title:'session-manager', depth:3, parent:'C-12', labels:['backend'], owner:'jori' },
|
||
{ id:'C-46', kind:'component', title:'mfa-module', depth:3, parent:'C-12', labels:['backend'], owner:'mira' },
|
||
|
||
// Depth 4 — issues attached to oauth-module
|
||
{ id:'NL-203', kind:'issue', title:'Implement refresh-token rotation', depth:4, parent:'C-44',
|
||
status:'prog', labels:['feature','backend','p0'], assignee:'jori' },
|
||
{ id:'NL-204', kind:'issue', title:'Reject reused refresh tokens', depth:4, parent:'C-44',
|
||
status:'todo', labels:['feature','backend'], assignee:'jori' },
|
||
{ id:'NL-205', kind:'issue', title:'OAuth callback fails on Safari 17', depth:4, parent:'C-44',
|
||
status:'rev', labels:['bug','frontend'], assignee:'karri' },
|
||
{ id:'NL-206', kind:'issue', title:'Document PKCE flow for SDK consumers', depth:4, parent:'C-44',
|
||
status:'back', labels:['chore'], assignee:'mira' },
|
||
|
||
// Issues under session-manager
|
||
{ id:'NL-241', kind:'issue', title:'Add Redis-backed session store', depth:4, parent:'C-45',
|
||
status:'prog', labels:['feature','backend'], assignee:'codex' },
|
||
{ id:'NL-242', kind:'issue', title:'Idle timeout config per workspace', depth:4, parent:'C-45',
|
||
status:'todo', labels:['feature'], assignee:'jori' },
|
||
|
||
// Issues under mfa-module
|
||
{ id:'NL-258', kind:'issue', title:'TOTP enrollment screen', depth:4, parent:'C-46',
|
||
status:'todo', labels:['feature','frontend'], assignee:'karri' },
|
||
|
||
// Depth 5 — sub-issues under NL-203
|
||
{ id:'NL-203a', kind:'issue', title:'Schema: refresh_token_family', depth:5, parent:'NL-203',
|
||
status:'done', labels:['chore','backend'], assignee:'jori' },
|
||
{ id:'NL-203b', kind:'issue', title:'Rotation handler + tests', depth:5, parent:'NL-203',
|
||
status:'prog', labels:['feature','backend'], assignee:'jori' },
|
||
{ id:'NL-203c', kind:'issue', title:'Audit log entry on reuse-detect', depth:5, parent:'NL-203',
|
||
status:'todo', labels:['feature'], assignee:'codex' },
|
||
];
|
||
|
||
/* Build lookup */
|
||
const byId = Object.fromEntries(NODES.map(n => [n.id, n]));
|
||
const childrenOf = {};
|
||
NODES.forEach(n => {
|
||
if (n.parent) (childrenOf[n.parent] = childrenOf[n.parent] || []).push(n.id);
|
||
});
|
||
|
||
/* Group by depth */
|
||
const maxDepth = Math.max(...NODES.map(n=>n.depth));
|
||
const byDepth = Array.from({length: maxDepth+1}, ()=>[]);
|
||
NODES.forEach(n => byDepth[n.depth].push(n));
|
||
|
||
/* ---------- Render ---------- */
|
||
|
||
const colsEl = document.getElementById('columns');
|
||
|
||
const ICON = {
|
||
component: '<svg class="ico cube" width="14" height="14"><use href="#i-cube"/></svg>',
|
||
issue: function(s){
|
||
return `<span class="st ${s||'todo'}"></span>`;
|
||
}
|
||
};
|
||
|
||
const AVATARS = {
|
||
jori:'J', mira:'M', karri:'K', codex:'★', triage:'T'
|
||
};
|
||
|
||
function avatar(who) {
|
||
if (!who) return '';
|
||
const klass = 'a-' + who;
|
||
const letter = AVATARS[who] || who[0].toUpperCase();
|
||
return `<span class="avatar ${klass}">${letter}</span>`;
|
||
}
|
||
|
||
function labelChips(labels){
|
||
if (!labels || !labels.length) return '';
|
||
return `<div class="labels">${labels.map(l=>`<span class="label ${l}">${l}</span>`).join('')}</div>`;
|
||
}
|
||
|
||
function nodeCard(n) {
|
||
const isComp = n.kind === 'component';
|
||
const icoLeft = isComp
|
||
? '<svg class="ico cube" width="14" height="14" style="color:var(--accent-2)"><use href="#i-cube"/></svg>'
|
||
: `<span class="st ${n.status||'todo'}"></span>`;
|
||
|
||
const idLabel = n.id;
|
||
|
||
const meta = [];
|
||
if (n.assignee) meta.push(avatar(n.assignee));
|
||
if (n.repo) meta.push(`<span class="mono" style="font-size:10.5px">${n.repo}</span>`);
|
||
if (isComp) {
|
||
const c = (childrenOf[n.id]||[]).length;
|
||
if (c) meta.push(`<span style="color:var(--text-mute)">${c} child${c===1?'':'ren'}</span>`);
|
||
}
|
||
|
||
return `
|
||
<div class="node" data-id="${n.id}" data-kind="${n.kind}">
|
||
<div class="title-row">
|
||
${icoLeft}
|
||
<div class="title">${n.title}</div>
|
||
<span class="id">${idLabel}</span>
|
||
</div>
|
||
<div class="meta">
|
||
${labelChips(n.labels)}
|
||
<span style="margin-left:auto;display:flex;gap:6px;align-items:center">${meta.join('')}</span>
|
||
</div>
|
||
<div class="child-handle">›</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/* Render columns RIGHT to LEFT order = root on right, leaves on left.
|
||
Display order in DOM = leftmost first (highest depth) ... */
|
||
function renderColumns() {
|
||
const frag = document.createDocumentFragment();
|
||
for (let d = maxDepth; d >= 0; d--) {
|
||
const col = document.createElement('section');
|
||
col.className = 'column';
|
||
col.dataset.depth = d;
|
||
|
||
// group nodes in this depth by parent component title (for higher signal)
|
||
const nodes = byDepth[d];
|
||
|
||
// Header label
|
||
let lvlLabel;
|
||
if (d === 0) lvlLabel = { lvl:'Layer 0 · root', title:'Project' };
|
||
else if (d === 1) lvlLabel = { lvl:`Layer ${d} · area`, title:'Top-level components' };
|
||
else if (d === 2) lvlLabel = { lvl:`Layer ${d} · service`, title:'Services' };
|
||
else if (d === 3) lvlLabel = { lvl:`Layer ${d} · module`, title:'Modules' };
|
||
else if (d === 4) lvlLabel = { lvl:`Layer ${d} · issues`, title:'Tasks' };
|
||
else lvlLabel = { lvl:`Layer ${d} · sub-issues`, title:'Sub-tasks' };
|
||
|
||
col.innerHTML = `
|
||
<div class="col-head">
|
||
<span class="lvl">${lvlLabel.lvl}</span>
|
||
<span class="title">${lvlLabel.title}</span>
|
||
<span class="meta">${nodes.length} ${nodes.length===1?'node':'nodes'}
|
||
<svg class="arrow" width="12" height="12">
|
||
<use href="#${d===0?'i-arrow-right':'i-arrow-right'}"></use>
|
||
</svg>
|
||
</span>
|
||
</div>
|
||
<div class="col-body" data-depth="${d}"></div>
|
||
`;
|
||
|
||
// Group children by parent so visual rhythm reflects parents above
|
||
const body = col.querySelector('.col-body');
|
||
if (d === 0) {
|
||
body.innerHTML = nodes.map(nodeCard).join('');
|
||
} else {
|
||
// Group by parent (descending order: parents in current visible col)
|
||
const groups = {};
|
||
nodes.forEach(n => { (groups[n.parent] = groups[n.parent] || []).push(n); });
|
||
// Order by parent's order in (d-1) column
|
||
const parentOrder = byDepth[d-1].map(p=>p.id);
|
||
parentOrder.forEach(pid => {
|
||
if (!groups[pid]) return;
|
||
const parent = byId[pid];
|
||
const groupHtml = `
|
||
<div class="group-label" data-parent-of="${pid}">
|
||
<span style="color:var(--text-dim)">under</span>
|
||
<span style="color:var(--text)">${parent.title}</span>
|
||
<span class="mono" style="color:var(--text-mute)">${pid}</span>
|
||
</div>
|
||
${groups[pid].map(nodeCard).join('')}
|
||
<div class="add-row" data-add-under="${pid}">
|
||
<svg width="12" height="12"><use href="#i-plus"/></svg>
|
||
Add ${d>=4 ? 'issue' : 'child'} under ${parent.title}
|
||
</div>
|
||
`;
|
||
body.insertAdjacentHTML('beforeend', groupHtml);
|
||
});
|
||
// Orphans (parents not in prev col, e.g. depth 0 only has 1 root)
|
||
Object.keys(groups).forEach(pid => {
|
||
if (parentOrder.includes(pid)) return;
|
||
body.insertAdjacentHTML('beforeend', groups[pid].map(nodeCard).join(''));
|
||
});
|
||
}
|
||
frag.appendChild(col);
|
||
}
|
||
colsEl.appendChild(frag);
|
||
}
|
||
renderColumns();
|
||
|
||
/* ---------- Hover highlighting ---------- */
|
||
const canvas = document.getElementById('canvas');
|
||
const edgesSvg = document.getElementById('edges');
|
||
const legend = document.getElementById('legend');
|
||
|
||
function ancestorsOf(id) {
|
||
const out = [];
|
||
let cur = byId[id]?.parent;
|
||
while (cur) { out.push(cur); cur = byId[cur].parent; }
|
||
return out;
|
||
}
|
||
function descendantsOf(id) {
|
||
const out = [];
|
||
const stack = [...(childrenOf[id]||[])];
|
||
while (stack.length) {
|
||
const x = stack.pop();
|
||
out.push(x);
|
||
(childrenOf[x]||[]).forEach(c => stack.push(c));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function nodeEl(id) {
|
||
return canvas.querySelector(`.node[data-id="${CSS.escape(id)}"]`);
|
||
}
|
||
|
||
function clearHighlights() {
|
||
canvas.querySelectorAll('.node').forEach(el => {
|
||
el.classList.remove('is-hover','is-ancestor','is-descendant','is-dim');
|
||
});
|
||
edgesSvg.innerHTML = '';
|
||
legend.classList.remove('visible');
|
||
}
|
||
|
||
function applyHighlight(id) {
|
||
const anc = ancestorsOf(id);
|
||
const desc = descendantsOf(id);
|
||
const set = new Set([id, ...anc, ...desc]);
|
||
|
||
canvas.querySelectorAll('.node').forEach(el => {
|
||
const nid = el.dataset.id;
|
||
if (nid === id) el.classList.add('is-hover');
|
||
else if (anc.includes(nid)) el.classList.add('is-ancestor');
|
||
else if (desc.includes(nid)) el.classList.add('is-descendant');
|
||
else el.classList.add('is-dim');
|
||
});
|
||
|
||
drawEdges(id, anc, desc);
|
||
legend.classList.add('visible');
|
||
}
|
||
|
||
function drawEdges(focusId, anc, desc) {
|
||
edgesSvg.innerHTML = '';
|
||
const canvasRect = canvas.getBoundingClientRect();
|
||
const scrollLeft = canvas.scrollLeft;
|
||
const scrollTop = canvas.scrollTop;
|
||
|
||
function center(el) {
|
||
const r = el.getBoundingClientRect();
|
||
return {
|
||
x: r.left - canvasRect.left + scrollLeft + r.width/2,
|
||
y: r.top - canvasRect.top + scrollTop + r.height/2,
|
||
r
|
||
};
|
||
}
|
||
function edgePoint(el, side) { // 'left' or 'right'
|
||
const r = el.getBoundingClientRect();
|
||
return {
|
||
x: (side === 'right' ? r.right : r.left) - canvasRect.left + scrollLeft,
|
||
y: r.top - canvasRect.top + scrollTop + r.height/2
|
||
};
|
||
}
|
||
|
||
// Set svg dims to canvas scroll content size
|
||
const w = canvas.scrollWidth;
|
||
const h = canvas.scrollHeight;
|
||
edgesSvg.setAttribute('width', w);
|
||
edgesSvg.setAttribute('height', h);
|
||
edgesSvg.setAttribute('viewBox', `0 0 ${w} ${h}`);
|
||
edgesSvg.style.width = w + 'px';
|
||
edgesSvg.style.height = h + 'px';
|
||
|
||
function drawCurve(a, b, color) {
|
||
// a is on the right of left node, b is on the left of right node
|
||
const dx = Math.max(40, Math.abs(b.x - a.x) * 0.5);
|
||
const d = `M ${a.x} ${a.y} C ${a.x + dx} ${a.y}, ${b.x - dx} ${b.y}, ${b.x} ${b.y}`;
|
||
const p = document.createElementNS('http://www.w3.org/2000/svg','path');
|
||
p.setAttribute('d', d);
|
||
p.setAttribute('stroke', color);
|
||
p.setAttribute('stroke-width', '1.6');
|
||
p.setAttribute('opacity', '0.85');
|
||
p.setAttribute('stroke-dasharray', '0');
|
||
edgesSvg.appendChild(p);
|
||
}
|
||
|
||
const focusEl = nodeEl(focusId);
|
||
if (!focusEl) return;
|
||
|
||
// ancestors: focus(left) -> parent(right) chain
|
||
let cur = focusId;
|
||
for (const aid of anc) {
|
||
const a = nodeEl(cur);
|
||
const b = nodeEl(aid);
|
||
if (a && b) {
|
||
drawCurve(edgePoint(a, 'right'), edgePoint(b, 'left'), 'rgba(244,184,96,0.7)');
|
||
}
|
||
cur = aid;
|
||
}
|
||
|
||
// descendants: child(left) -> focus(right). For each direct edge in tree
|
||
function drawDescendants(parentId) {
|
||
(childrenOf[parentId]||[]).forEach(cid => {
|
||
if (!desc.includes(cid)) return;
|
||
const a = nodeEl(cid);
|
||
const b = nodeEl(parentId);
|
||
if (a && b) {
|
||
drawCurve(edgePoint(a, 'right'), edgePoint(b, 'left'), 'rgba(110,209,164,0.7)');
|
||
}
|
||
drawDescendants(cid);
|
||
});
|
||
}
|
||
drawDescendants(focusId);
|
||
}
|
||
|
||
let hoverDebounce;
|
||
canvas.addEventListener('mouseover', (e) => {
|
||
const node = e.target.closest('.node');
|
||
if (!node) return;
|
||
clearTimeout(hoverDebounce);
|
||
clearHighlights();
|
||
applyHighlight(node.dataset.id);
|
||
});
|
||
canvas.addEventListener('mouseleave', () => {
|
||
hoverDebounce = setTimeout(clearHighlights, 80);
|
||
});
|
||
// also clear when leaving any column body's interactive region
|
||
document.addEventListener('mouseout', (e) => {
|
||
if (e.target === document.documentElement) clearHighlights();
|
||
});
|
||
|
||
/* Click → set as detail focus (cosmetic) */
|
||
canvas.addEventListener('click', (e) => {
|
||
const node = e.target.closest('.node');
|
||
if (!node) return;
|
||
const n = byId[node.dataset.id];
|
||
if (!n) return;
|
||
// Update detail panel header / body minimally
|
||
const det = document.getElementById('detail');
|
||
det.querySelector('.detail-head .id').textContent = n.id;
|
||
const stEl = det.querySelector('.detail-head .st');
|
||
stEl.className = 'st ' + (n.status || (n.kind==='component' ? 'done' : 'todo'));
|
||
det.querySelector('.detail-body h3').textContent = n.title;
|
||
det.style.display = 'flex';
|
||
});
|
||
|
||
document.getElementById('detail-x').addEventListener('click', () => {
|
||
document.getElementById('detail').style.display = 'none';
|
||
});
|
||
|
||
/* ---------- Minimap / scroll ---------- */
|
||
const blocksEl = document.getElementById('miniblocks');
|
||
const cols = colsEl.children.length;
|
||
for (let i=0;i<cols;i++) {
|
||
const b = document.createElement('div');
|
||
b.dataset.col = i;
|
||
blocksEl.appendChild(b);
|
||
}
|
||
function updateMini() {
|
||
const sl = canvas.scrollLeft, w = canvas.clientWidth, sw = canvas.scrollWidth;
|
||
// determine which columns are visible
|
||
const colsArr = [...colsEl.children];
|
||
const visible = new Set();
|
||
colsArr.forEach((c,i) => {
|
||
const r = c.getBoundingClientRect();
|
||
const cr = canvas.getBoundingClientRect();
|
||
const left = r.left - cr.left;
|
||
const right = r.right - cr.left;
|
||
if (right > 0 && left < cr.width) visible.add(i);
|
||
});
|
||
[...blocksEl.children].forEach((b,i) => {
|
||
b.classList.toggle('viewing', visible.has(i));
|
||
});
|
||
}
|
||
canvas.addEventListener('scroll', updateMini);
|
||
window.addEventListener('resize', updateMini);
|
||
setTimeout(updateMini, 50);
|
||
|
||
document.getElementById('scroll-left').onclick = () => canvas.scrollBy({left:-360, behavior:'smooth'});
|
||
document.getElementById('scroll-right').onclick = () => canvas.scrollBy({left: 360, behavior:'smooth'});
|
||
|
||
// Click minimap blocks to jump to a specific column
|
||
blocksEl.addEventListener('click', (e) => {
|
||
const b = e.target.closest('div[data-col]');
|
||
if (!b) return;
|
||
const i = parseInt(b.dataset.col);
|
||
const c = colsEl.children[i];
|
||
c.scrollIntoView({behavior:'smooth', inline:'start', block:'nearest'});
|
||
});
|
||
|
||
/* Keyboard shortcuts */
|
||
window.addEventListener('keydown', (e) => {
|
||
if (e.target.matches('input,textarea')) return;
|
||
if (e.key === 'ArrowLeft' && (e.altKey || e.metaKey === false && e.ctrlKey === false && !e.shiftKey)) {
|
||
canvas.scrollBy({left:-360, behavior:'smooth'});
|
||
} else if (e.key === 'ArrowRight' && (e.altKey)) {
|
||
canvas.scrollBy({left:360, behavior:'smooth'});
|
||
}
|
||
});
|
||
|
||
/* Initial scroll: center on the column with the focused issue (depth 4 → leftmost area) */
|
||
window.addEventListener('load', () => {
|
||
// Scroll so the depth-3 column (modules) is roughly central
|
||
const target = colsEl.querySelector('.column[data-depth="3"]');
|
||
if (target) target.scrollIntoView({behavior:'instant', inline:'center', block:'nearest'});
|
||
setTimeout(updateMini, 60);
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|