Git Explorer
Grok 4.5 · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 76,743 bytes · SHA-256 ea3a94d78e97
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Git & Timeline Visualizer</title>
<script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"></script>
<style>
:root {
--bg: #0f1419;
--bg2: #1a2332;
--bg3: #243044;
--border: #2d3a4f;
--text: #e7ecf3;
--muted: #8b9bb4;
--accent: #3b82f6;
--accent2: #60a5fa;
--green: #34d399;
--amber: #fbbf24;
--rose: #f87171;
--violet: #a78bfa;
--cyan: #22d3ee;
--panel-w: 340px;
--header-h: 56px;
--radius: 10px;
--shadow: 0 8px 32px rgba(0,0,0,.45);
--font: "Segoe UI", system-ui, -apple-system, sans-serif;
--mono: "Cascadia Code", "Fira Code", "SF Mono", Consolas, monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: var(--bg);
color: var(--text);
font-family: var(--font);
overflow: hidden;
}
/* ——— Header ——— */
header {
height: var(--header-h);
background: linear-gradient(180deg, #1c2838 0%, var(--bg2) 100%);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 18px;
gap: 16px;
z-index: 50;
position: relative;
}
.logo {
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
font-size: 15px;
letter-spacing: .02em;
white-space: nowrap;
}
.logo svg { flex-shrink: 0; }
.logo span.grad {
background: linear-gradient(90deg, var(--accent2), var(--cyan));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.header-actions {
margin-left: auto;
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
button, .btn {
font-family: var(--font);
font-size: 12.5px;
font-weight: 600;
border: 1px solid var(--border);
background: var(--bg3);
color: var(--text);
padding: 7px 12px;
border-radius: 7px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
transition: background .15s, border-color .15s, transform .1s;
white-space: nowrap;
}
button:hover, .btn:hover {
background: #2c3d55;
border-color: #3d5270;
}
button:active { transform: scale(.97); }
button.primary {
background: linear-gradient(135deg, #2563eb, #3b82f6);
border-color: #3b82f6;
color: #fff;
}
button.primary:hover {
background: linear-gradient(135deg, #1d4ed8, #2563eb);
}
button.danger {
background: rgba(248,113,113,.12);
border-color: rgba(248,113,113,.35);
color: var(--rose);
}
button.danger:hover {
background: rgba(248,113,113,.22);
}
button.ghost {
background: transparent;
border-color: transparent;
color: var(--muted);
}
button.ghost:hover {
color: var(--text);
background: var(--bg3);
}
button:disabled {
opacity: .4;
cursor: not-allowed;
}
/* ——— Layout ——— */
.app {
display: grid;
grid-template-columns: var(--panel-w) 1fr var(--panel-w);
height: calc(100% - var(--header-h));
}
.panel {
background: var(--bg2);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel.right {
border-right: none;
border-left: 1px solid var(--border);
}
.panel-tabs {
display: flex;
border-bottom: 1px solid var(--border);
background: rgba(0,0,0,.2);
flex-shrink: 0;
}
.panel-tabs button {
flex: 1;
border: none;
border-radius: 0;
border-bottom: 2px solid transparent;
background: transparent;
padding: 11px 8px;
font-size: 12px;
color: var(--muted);
}
.panel-tabs button.active {
color: var(--accent2);
border-bottom-color: var(--accent);
background: rgba(59,130,246,.08);
}
.panel-body {
flex: 1;
overflow-y: auto;
padding: 14px;
}
.panel-body::-webkit-scrollbar { width: 6px; }
.panel-body::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
.section-title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--muted);
margin: 14px 0 8px;
font-weight: 700;
}
.section-title:first-child { margin-top: 0; }
/* Forms */
label {
display: block;
font-size: 11.5px;
color: var(--muted);
margin-bottom: 4px;
font-weight: 600;
}
input, select, textarea {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
border-radius: 7px;
padding: 8px 10px;
font-size: 13px;
font-family: var(--font);
margin-bottom: 10px;
outline: none;
transition: border-color .15s;
}
input:focus, select:focus, textarea:focus {
border-color: var(--accent);
}
textarea { resize: vertical; min-height: 64px; }
.row { display: flex; gap: 8px; }
.row > * { flex: 1; }
.form-actions {
display: flex;
gap: 8px;
margin-top: 4px;
flex-wrap: wrap;
}
/* Cards / lists */
.card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
margin-bottom: 8px;
cursor: pointer;
transition: border-color .15s, background .15s;
position: relative;
}
.card:hover { border-color: #3d5270; }
.card.selected {
border-color: var(--accent);
background: rgba(59,130,246,.08);
box-shadow: 0 0 0 1px var(--accent);
}
.card .card-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 3px;
display: flex;
align-items: center;
gap: 6px;
}
.card .card-meta {
font-size: 11px;
color: var(--muted);
font-family: var(--mono);
}
.card .card-actions {
display: none;
position: absolute;
top: 8px;
right: 8px;
gap: 4px;
}
.card:hover .card-actions { display: flex; }
.card .card-actions button {
padding: 3px 6px;
font-size: 11px;
}
.badge {
display: inline-block;
font-size: 10px;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
letter-spacing: .03em;
}
.badge.branch { background: rgba(52,211,153,.15); color: var(--green); }
.badge.tag { background: rgba(251,191,36,.15); color: var(--amber); }
.badge.event { background: rgba(167,139,250,.15); color: var(--violet); }
.badge.head { background: rgba(59,130,246,.2); color: var(--accent2); }
.badge.merge { background: rgba(34,211,238,.15); color: var(--cyan); }
.empty-hint {
text-align: center;
color: var(--muted);
font-size: 12.5px;
padding: 24px 12px;
line-height: 1.5;
}
/* ——— Main canvas area ——— */
.main {
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
background:
radial-gradient(ellipse at 30% 20%, rgba(59,130,246,.06) 0%, transparent 50%),
radial-gradient(ellipse at 70% 80%, rgba(167,139,250,.05) 0%, transparent 50%),
var(--bg);
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-bottom: 1px solid var(--border);
background: rgba(26,35,50,.85);
backdrop-filter: blur(8px);
flex-shrink: 0;
flex-wrap: wrap;
z-index: 10;
}
.toolbar .sep {
width: 1px;
height: 22px;
background: var(--border);
margin: 0 4px;
}
.toolbar label.inline {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--muted);
margin: 0;
cursor: pointer;
}
.toolbar input[type="checkbox"] {
width: auto;
margin: 0;
accent-color: var(--accent);
}
.view-toggle {
display: flex;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 7px;
overflow: hidden;
}
.view-toggle button {
border: none;
border-radius: 0;
background: transparent;
padding: 6px 12px;
font-size: 12px;
}
.view-toggle button.active {
background: var(--accent);
color: #fff;
}
#graph-container {
flex: 1;
position: relative;
overflow: hidden;
}
#graph-svg {
width: 100%;
height: 100%;
cursor: grab;
display: block;
}
#graph-svg:active { cursor: grabbing; }
/* Timeline strip at bottom of main */
.timeline-strip {
height: 130px;
border-top: 1px solid var(--border);
background: var(--bg2);
flex-shrink: 0;
position: relative;
overflow: hidden;
}
.timeline-strip.collapsed { height: 36px; }
.timeline-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 14px;
border-bottom: 1px solid var(--border);
height: 36px;
}
.timeline-header h3 {
font-size: 11px;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--muted);
font-weight: 700;
}
#timeline-svg {
width: 100%;
height: calc(100% - 36px);
display: block;
}
/* Graph styles via classes */
.link {
fill: none;
stroke-width: 2.2;
opacity: .75;
}
.link.merge { stroke-dasharray: 6 3; }
.commit-node circle {
stroke-width: 2.5;
cursor: pointer;
transition: r .15s, stroke-width .15s;
}
.commit-node:hover circle,
.commit-node.selected circle {
stroke-width: 3.5;
filter: drop-shadow(0 0 6px rgba(59,130,246,.6));
}
.commit-node.selected circle {
stroke: #fff !important;
}
.commit-node .msg {
font-size: 11px;
fill: var(--text);
pointer-events: none;
font-family: var(--font);
}
.commit-node .sha {
font-size: 9.5px;
fill: var(--muted);
font-family: var(--mono);
pointer-events: none;
}
.branch-label {
font-size: 10.5px;
font-weight: 700;
pointer-events: none;
}
.branch-label-bg {
rx: 4;
ry: 4;
}
.event-anchor {
cursor: pointer;
}
.event-anchor circle {
fill: var(--violet);
stroke: #fff;
stroke-width: 1.5;
}
.event-anchor:hover circle,
.event-anchor.highlight circle {
filter: drop-shadow(0 0 8px rgba(167,139,250,.9));
}
.event-label {
font-size: 10px;
fill: var(--violet);
font-weight: 600;
pointer-events: none;
}
.grid-line {
stroke: var(--border);
stroke-width: 1;
opacity: .35;
}
/* Tooltip */
.tooltip {
position: absolute;
pointer-events: none;
background: #1e2a3c;
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
font-size: 12px;
max-width: 280px;
box-shadow: var(--shadow);
z-index: 100;
opacity: 0;
transition: opacity .12s;
line-height: 1.45;
}
.tooltip.visible { opacity: 1; }
.tooltip .tt-title {
font-weight: 700;
margin-bottom: 4px;
color: var(--text);
}
.tooltip .tt-row {
color: var(--muted);
font-size: 11.5px;
}
.tooltip .tt-row strong {
color: var(--accent2);
font-family: var(--mono);
font-weight: 600;
}
/* Modal */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,.6);
backdrop-filter: blur(4px);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity .2s;
}
.modal-backdrop.open {
opacity: 1;
pointer-events: auto;
}
.modal {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 14px;
width: min(560px, 94vw);
max-height: 86vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: var(--shadow);
transform: translateY(12px);
transition: transform .2s;
}
.modal-backdrop.open .modal { transform: translateY(0); }
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px;
border-bottom: 1px solid var(--border);
}
.modal-header h2 {
font-size: 16px;
font-weight: 700;
}
.modal-body {
padding: 18px;
overflow-y: auto;
flex: 1;
}
.modal-footer {
padding: 12px 18px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* Toast */
.toast-wrap {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 300;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
}
.toast {
background: var(--bg3);
border: 1px solid var(--border);
color: var(--text);
padding: 10px 18px;
border-radius: 8px;
font-size: 13px;
box-shadow: var(--shadow);
animation: toastIn .25s ease;
}
.toast.ok { border-color: rgba(52,211,153,.4); }
.toast.err { border-color: rgba(248,113,113,.4); }
@keyframes toastIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* Detail panel chips */
.detail-block {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px;
margin-bottom: 12px;
}
.detail-block h4 {
font-size: 13px;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.kv {
display: grid;
grid-template-columns: 90px 1fr;
gap: 4px 8px;
font-size: 12px;
margin-bottom: 6px;
}
.kv .k { color: var(--muted); }
.kv .v { font-family: var(--mono); word-break: break-all; }
.parent-list, .event-list-mini {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.chip {
font-size: 11px;
font-family: var(--mono);
background: var(--bg3);
border: 1px solid var(--border);
padding: 2px 7px;
border-radius: 4px;
cursor: pointer;
}
.chip:hover { border-color: var(--accent); color: var(--accent2); }
.status-bar {
font-size: 11px;
color: var(--muted);
padding: 4px 14px;
border-top: 1px solid var(--border);
background: rgba(0,0,0,.25);
display: flex;
gap: 16px;
flex-shrink: 0;
}
/* Legend */
.legend {
display: flex;
gap: 12px;
align-items: center;
font-size: 11px;
color: var(--muted);
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 5px;
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
border: 2px solid;
}
/* Import textarea large */
#import-json {
font-family: var(--mono);
font-size: 11.5px;
min-height: 220px;
}
.help-text {
font-size: 12px;
color: var(--muted);
line-height: 1.55;
margin-bottom: 12px;
}
.branch-color-swatch {
width: 12px;
height: 12px;
border-radius: 3px;
display: inline-block;
vertical-align: middle;
}
</style>
</head>
<body>
<header>
<div class="logo">
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
<circle cx="16" cy="8" r="4" fill="#3b82f6"/>
<circle cx="8" cy="24" r="4" fill="#34d399"/>
<circle cx="24" cy="24" r="4" fill="#a78bfa"/>
<path d="M16 12v4M16 16L8 20M16 16l8 4" stroke="#60a5fa" stroke-width="2"/>
</svg>
<span><span class="grad">Git</span> & Timeline</span>
</div>
<div class="header-actions">
<button class="ghost" id="btn-sample" title="Load sample project">Load Sample</button>
<button id="btn-import">Import</button>
<button id="btn-export">Export</button>
<button class="danger ghost" id="btn-reset" title="Clear all data">Reset</button>
</div>
</header>
<div class="app">
<!-- LEFT PANEL -->
<aside class="panel left">
<div class="panel-tabs">
<button class="active" data-tab="commits">Commits</button>
<button data-tab="branches">Branches</button>
<button data-tab="tags">Tags</button>
</div>
<div class="panel-body" id="left-panel-body">
<!-- dynamically filled -->
</div>
</aside>
<!-- MAIN -->
<main class="main">
<div class="toolbar">
<div class="view-toggle" id="layout-toggle">
<button class="active" data-layout="time">Time Layout</button>
<button data-layout="topo">Topo Layout</button>
</div>
<div class="sep"></div>
<button id="btn-zoom-fit">Fit</button>
<button id="btn-zoom-in">+</button>
<button id="btn-zoom-out">−</button>
<div class="sep"></div>
<label class="inline"><input type="checkbox" id="chk-events" checked /> Show events</label>
<label class="inline"><input type="checkbox" id="chk-labels" checked /> Labels</label>
<label class="inline"><input type="checkbox" id="chk-timeline" checked /> Timeline strip</label>
<div style="flex:1"></div>
<div class="legend">
<span class="legend-item"><span class="legend-dot" style="border-color:#3b82f6;background:#1e3a5f"></span> Commit</span>
<span class="legend-item"><span class="legend-dot" style="border-color:#a78bfa;background:#a78bfa"></span> Event</span>
<span class="legend-item"><span class="legend-dot" style="border-color:#34d399;background:transparent"></span> HEAD</span>
</div>
</div>
<div id="graph-container">
<svg id="graph-svg"></svg>
<div class="tooltip" id="tooltip"></div>
</div>
<div class="timeline-strip" id="timeline-strip">
<div class="timeline-header">
<h3>Chronological Timeline</h3>
<button class="ghost" id="btn-toggle-timeline" style="padding:2px 8px;font-size:11px">Collapse</button>
</div>
<svg id="timeline-svg"></svg>
</div>
<div class="status-bar" id="status-bar">
<span id="stat-commits">0 commits</span>
<span id="stat-branches">0 branches</span>
<span id="stat-events">0 events</span>
<span id="stat-selection">No selection</span>
</div>
</main>
<!-- RIGHT PANEL -->
<aside class="panel right">
<div class="panel-tabs">
<button class="active" data-rtab="detail">Detail</button>
<button data-rtab="events">Events</button>
<button data-rtab="create">Create</button>
</div>
<div class="panel-body" id="right-panel-body">
<!-- dynamically filled -->
</div>
</aside>
</div>
<!-- Import Modal -->
<div class="modal-backdrop" id="import-modal">
<div class="modal">
<div class="modal-header">
<h2>Import Data</h2>
<button class="ghost" id="import-close">✕</button>
</div>
<div class="modal-body">
<p class="help-text">
Paste JSON matching the app schema. This will <strong>replace</strong> the current state.
You can also import partial data (commits only, events only, etc.).
</p>
<label for="import-json">JSON payload</label>
<textarea id="import-json" spellcheck="false" placeholder='{"commits":[],"branches":[],"tags":[],"events":[],"head":null}'></textarea>
</div>
<div class="modal-footer">
<button class="ghost" id="import-cancel">Cancel</button>
<button class="primary" id="import-apply">Import & Replace</button>
</div>
</div>
</div>
<div class="toast-wrap" id="toast-wrap"></div>
<script>
/* ============================================================
Git & Timeline Visualizer — Application
============================================================ */
const STORAGE_KEY = 'git-timeline-viz-v1';
const BRANCH_PALETTE = [
'#3b82f6', '#34d399', '#fbbf24', '#f87171', '#a78bfa',
'#22d3ee', '#fb923c', '#e879f9', '#4ade80', '#38bdf8',
'#c084fc', '#f472b6'
];
// ——— State ———
const state = {
commits: [], // {id, parents[], message, timestamp, author}
branches: [], // {name, commitId, color}
tags: [], // {name, commitId}
events: [], // {id, title, description, timestamp, commitId}
head: null, // branch name
selectedCommitId: null,
selectedEventId: null,
layout: 'time', // 'time' | 'topo'
showEvents: true,
showLabels: true,
showTimeline: true,
leftTab: 'commits',
rightTab: 'detail',
};
// ——— Utilities ———
function uid(prefix = 'c') {
return prefix + '_' + Math.random().toString(36).slice(2, 9) + Date.now().toString(36).slice(-4);
}
function shortSha(id) {
return (id || '').replace(/^(c_|e_|t_)/, '').slice(0, 7);
}
function fmtDate(ts) {
if (!ts) return '—';
const d = new Date(ts);
if (isNaN(d)) return String(ts);
return d.toLocaleString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit'
});
}
function fmtDateShort(ts) {
const d = new Date(ts);
if (isNaN(d)) return '';
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: '2-digit' });
}
function toLocalInputValue(ts) {
if (!ts) {
const n = new Date();
n.setMinutes(n.getMinutes() - n.getTimezoneOffset());
return n.toISOString().slice(0, 16);
}
const d = new Date(ts);
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
return d.toISOString().slice(0, 16);
}
function fromLocalInputValue(v) {
if (!v) return Date.now();
return new Date(v).getTime();
}
function toast(msg, type = 'ok') {
const wrap = document.getElementById('toast-wrap');
const el = document.createElement('div');
el.className = 'toast ' + type;
el.textContent = msg;
wrap.appendChild(el);
setTimeout(() => {
el.style.opacity = '0';
el.style.transition = 'opacity .3s';
setTimeout(() => el.remove(), 300);
}, 2400);
}
function cloneState() {
return {
commits: state.commits,
branches: state.branches,
tags: state.tags,
events: state.events,
head: state.head,
};
}
// ——— Persistence ———
function save() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(cloneState()));
} catch (e) {
console.warn('localStorage save failed', e);
}
}
function load() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return false;
const data = JSON.parse(raw);
applyData(data, false);
return true;
} catch (e) {
console.warn('load failed', e);
return false;
}
}
function applyData(data, doSave = true) {
if (data.commits) state.commits = data.commits;
if (data.branches) state.branches = data.branches;
if (data.tags) state.tags = data.tags;
if (data.events) state.events = data.events;
if ('head' in data) state.head = data.head;
state.selectedCommitId = null;
state.selectedEventId = null;
if (doSave) save();
fullRender();
}
// ——— Sample dataset ———
function loadSample() {
const base = Date.parse('2024-01-15T09:00:00');
const day = 86400000;
const hour = 3600000;
const c0 = uid('c');
const c1 = uid('c');
const c2 = uid('c');
const c3 = uid('c');
const c4 = uid('c');
const c5 = uid('c');
const c6 = uid('c');
const c7 = uid('c');
const c8 = uid('c');
const c9 = uid('c');
const c10 = uid('c');
const c11 = uid('c');
const commits = [
{ id: c0, parents: [], message: 'Initial commit: project scaffold', timestamp: base, author: 'Alex Chen' },
{ id: c1, parents: [c0], message: 'Add auth module skeleton', timestamp: base + 2 * day, author: 'Alex Chen' },
{ id: c2, parents: [c1], message: 'Implement JWT login flow', timestamp: base + 5 * day, author: 'Sam Rivera' },
{ id: c3, parents: [c1], message: 'Spike: OAuth provider research', timestamp: base + 4 * day + 3 * hour, author: 'Jordan Lee' },
{ id: c4, parents: [c3], message: 'OAuth: Google provider', timestamp: base + 8 * day, author: 'Jordan Lee' },
{ id: c5, parents: [c2], message: 'Session store + refresh tokens', timestamp: base + 9 * day, author: 'Sam Rivera' },
{ id: c6, parents: [c5, c4], message: 'Merge feature/oauth into develop', timestamp: base + 12 * day, author: 'Alex Chen' },
{ id: c7, parents: [c6], message: 'API rate limiting middleware', timestamp: base + 14 * day, author: 'Sam Rivera' },
{ id: c8, parents: [c6], message: 'Hotfix: token expiry edge case', timestamp: base + 13 * day + 5 * hour, author: 'Alex Chen' },
{ id: c9, parents: [c7, c8], message: 'Release prep: changelog + version bump', timestamp: base + 18 * day, author: 'Alex Chen' },
{ id: c10, parents: [c9], message: 'v1.0.0 production release', timestamp: base + 20 * day, author: 'Alex Chen' },
{ id: c11, parents: [c10], message: 'Post-release: monitoring dashboards', timestamp: base + 22 * day, author: 'Jordan Lee' },
];
const branches = [
{ name: 'main', commitId: c11, color: BRANCH_PALETTE[0] },
{ name: 'develop', commitId: c9, color: BRANCH_PALETTE[1] },
{ name: 'feature/oauth', commitId: c4, color: BRANCH_PALETTE[4] },
{ name: 'hotfix/token-expiry', commitId: c8, color: BRANCH_PALETTE[3] },
];
const tags = [
{ name: 'v0.1.0', commitId: c2 },
{ name: 'v1.0.0-rc1', commitId: c9 },
{ name: 'v1.0.0', commitId: c10 },
];
const events = [
{ id: uid('e'), title: 'Project Kickoff', description: 'Team charter signed; repo created; CI pipeline provisioned.', timestamp: base + hour, commitId: c0 },
{ id: uid('e'), title: 'Security Review Gate', description: 'Auth design reviewed by security team. JWT approach approved with MFA follow-up.', timestamp: base + 5 * day + 2 * hour, commitId: c2 },
{ id: uid('e'), title: 'OAuth Spike Complete', description: 'Research spike closed; Google chosen as first provider.', timestamp: base + 8 * day + hour, commitId: c4 },
{ id: uid('e'), title: 'Feature Freeze', description: 'No new features for the release train. Bug fixes only.', timestamp: base + 12 * day + 3 * hour, commitId: c6 },
{ id: uid('e'), title: 'Incident: Token Expiry', description: 'Production-adjacent staging incident with refresh tokens near midnight UTC.', timestamp: base + 13 * day + 6 * hour, commitId: c8 },
{ id: uid('e'), title: 'Code Freeze', description: 'RC tagged. Only show-stopper fixes allowed.', timestamp: base + 18 * day + 2 * hour, commitId: c9 },
{ id: uid('e'), title: 'GA Launch', description: 'Public v1.0.0 launch. Press release and customer emails sent.', timestamp: base + 20 * day + 4 * hour, commitId: c10 },
{ id: uid('e'), title: 'Postmortem Review', description: 'Launch retro + observability follow-ups assigned.', timestamp: base + 22 * day + hour, commitId: c11 },
];
applyData({ commits, branches, tags, events, head: 'main' }, true);
toast('Sample project loaded');
}
// ——— Lookups ———
function getCommit(id) {
return state.commits.find(c => c.id === id);
}
function getBranch(name) {
return state.branches.find(b => b.name === name);
}
function branchesAt(commitId) {
return state.branches.filter(b => b.commitId === commitId);
}
function tagsAt(commitId) {
return state.tags.filter(t => t.commitId === commitId);
}
function eventsAt(commitId) {
return state.events.filter(e => e.commitId === commitId);
}
function childrenOf(commitId) {
return state.commits.filter(c => c.parents && c.parents.includes(commitId));
}
function nextBranchColor() {
return BRANCH_PALETTE[state.branches.length % BRANCH_PALETTE.length];
}
// ——— CRUD: Commits ———
function createCommit({ message, parents, timestamp, author }) {
const id = uid('c');
const commit = {
id,
message: message || 'Empty commit',
parents: parents || [],
timestamp: timestamp || Date.now(),
author: author || 'Developer',
};
state.commits.push(commit);
// Advance HEAD branch pointer if parents include current HEAD tip
if (state.head) {
const hb = getBranch(state.head);
if (hb && commit.parents.includes(hb.commitId)) {
hb.commitId = id;
}
}
save();
state.selectedCommitId = id;
fullRender();
toast('Commit created · ' + shortSha(id));
return id;
}
function updateCommit(id, patch) {
const c = getCommit(id);
if (!c) return;
Object.assign(c, patch);
// Ensure parents array uniqueness & no self
if (c.parents) {
c.parents = [...new Set(c.parents.filter(p => p && p !== id))];
}
save();
fullRender();
toast('Commit updated');
}
function deleteCommit(id) {
// Remove as parent from others
state.commits.forEach(c => {
if (c.parents) c.parents = c.parents.filter(p => p !== id);
});
state.commits = state.commits.filter(c => c.id !== id);
// Fix branches/tags/events referencing it
state.branches.forEach(b => {
if (b.commitId === id) b.commitId = null;
});
state.branches = state.branches.filter(b => b.commitId);
state.tags = state.tags.filter(t => t.commitId !== id);
state.events = state.events.filter(e => e.commitId !== id);
if (state.selectedCommitId === id) state.selectedCommitId = null;
save();
fullRender();
toast('Commit deleted', 'err');
}
// ——— CRUD: Branches ———
function createBranch(name, commitId, color) {
name = (name || '').trim();
if (!name) { toast('Branch name required', 'err'); return; }
if (getBranch(name)) { toast('Branch already exists', 'err'); return; }
if (!getCommit(commitId)) { toast('Commit required', 'err'); return; }
state.branches.push({
name,
commitId,
color: color || nextBranchColor(),
});
if (!state.head) state.head = name;
save();
fullRender();
toast('Branch ' + name + ' created');
}
function updateBranch(name, patch) {
const b = getBranch(name);
if (!b) return;
const oldName = b.name;
Object.assign(b, patch);
if (patch.name && patch.name !== oldName) {
if (state.head === oldName) state.head = patch.name;
}
save();
fullRender();
toast('Branch updated');
}
function deleteBranch(name) {
state.branches = state.branches.filter(b => b.name !== name);
if (state.head === name) state.head = state.branches[0]?.name || null;
save();
fullRender();
toast('Branch deleted', 'err');
}
function checkoutBranch(name) {
if (!getBranch(name)) return;
state.head = name;
save();
fullRender();
toast('HEAD → ' + name);
}
// ——— CRUD: Tags ———
function createTag(name, commitId) {
name = (name || '').trim();
if (!name) { toast('Tag name required', 'err'); return; }
if (state.tags.some(t => t.name === name)) { toast('Tag exists', 'err'); return; }
if (!getCommit(commitId)) { toast('Commit required', 'err'); return; }
state.tags.push({ name, commitId });
save();
fullRender();
toast('Tag ' + name + ' created');
}
function deleteTag(name) {
state.tags = state.tags.filter(t => t.name !== name);
save();
fullRender();
toast('Tag deleted', 'err');
}
// ——— CRUD: Events ———
function createEvent({ title, description, timestamp, commitId }) {
if (!title) { toast('Event title required', 'err'); return; }
if (!getCommit(commitId)) { toast('Must associate with a commit', 'err'); return; }
const e = {
id: uid('e'),
title,
description: description || '',
timestamp: timestamp || getCommit(commitId).timestamp,
commitId,
};
state.events.push(e);
state.selectedEventId = e.id;
save();
fullRender();
toast('Event tagged to ' + shortSha(commitId));
return e.id;
}
function updateEvent(id, patch) {
const e = state.events.find(x => x.id === id);
if (!e) return;
Object.assign(e, patch);
save();
fullRender();
toast('Event updated');
}
function deleteEvent(id) {
state.events = state.events.filter(e => e.id !== id);
if (state.selectedEventId === id) state.selectedEventId = null;
save();
fullRender();
toast('Event deleted', 'err');
}
// ——— Graph layout ———
function computeLayout() {
const commits = state.commits.slice();
if (!commits.length) return { nodes: [], links: [], lanes: 0 };
const byId = new Map(commits.map(c => [c.id, c]));
// Build adjacency
const children = new Map();
commits.forEach(c => {
(c.parents || []).forEach(p => {
if (!children.has(p)) children.set(p, []);
children.get(p).push(c.id);
});
});
// Topological generations (longest path from roots)
const gen = new Map();
const indeg = new Map(commits.map(c => [c.id, (c.parents || []).filter(p => byId.has(p)).length]));
const queue = commits.filter(c => indeg.get(c.id) === 0).map(c => c.id);
queue.forEach(id => gen.set(id, 0));
const order = [];
const q = queue.slice();
while (q.length) {
const id = q.shift();
order.push(id);
const g = gen.get(id) || 0;
(children.get(id) || []).forEach(cid => {
gen.set(cid, Math.max(gen.get(cid) || 0, g + 1));
indeg.set(cid, indeg.get(cid) - 1);
if (indeg.get(cid) === 0) q.push(cid);
});
}
// orphans with cycles fallback
commits.forEach(c => {
if (!gen.has(c.id)) {
gen.set(c.id, 0);
order.push(c.id);
}
});
// Branch-aware lane assignment (like git log --graph)
// Prefer keeping branch tips on stable lanes
const laneOf = new Map();
let maxLane = 0;
const activeLanes = []; // commitId occupying lane index or null
// Sort processing order: by generation, then timestamp
const sorted = commits.slice().sort((a, b) => {
const dg = (gen.get(a.id) || 0) - (gen.get(b.id) || 0);
if (dg !== 0) return dg;
return (a.timestamp || 0) - (b.timestamp || 0);
});
function allocLane(prefer) {
if (prefer != null && (activeLanes[prefer] == null)) {
activeLanes[prefer] = true;
return prefer;
}
for (let i = 0; i < activeLanes.length; i++) {
if (activeLanes[i] == null) {
activeLanes[i] = true;
return i;
}
}
activeLanes.push(true);
return activeLanes.length - 1;
}
// First pass: children inherit primary parent lane
const primaryChild = new Map(); // parent -> first child by time
sorted.forEach(c => {
const parents = (c.parents || []).filter(p => byId.has(p));
if (parents.length) {
const p0 = parents[0];
if (!primaryChild.has(p0)) primaryChild.set(p0, c.id);
}
});
sorted.forEach(c => {
const parents = (c.parents || []).filter(p => byId.has(p));
let lane;
if (!parents.length) {
lane = allocLane(null);
} else {
const p0 = parents[0];
const pLane = laneOf.get(p0);
// If this is the first child of primary parent, reuse lane
if (primaryChild.get(p0) === c.id && pLane != null) {
lane = pLane;
} else {
lane = allocLane(null);
}
}
laneOf.set(c.id, lane);
maxLane = Math.max(maxLane, lane);
});
// Assign coordinates
const xGap = 90;
const yGap = 72;
const marginX = 80;
const marginY = 60;
// For time layout, x = time scale; for topo, x = generation
let nodes;
if (state.layout === 'time') {
const times = commits.map(c => c.timestamp || 0);
const tMin = Math.min(...times);
const tMax = Math.max(...times);
const span = Math.max(tMax - tMin, 1);
const width = Math.max(commits.length * xGap, 600);
nodes = commits.map(c => {
const t = c.timestamp || tMin;
const x = marginX + ((t - tMin) / span) * width;
const y = marginY + (laneOf.get(c.id) || 0) * yGap;
return {
...c,
x, y,
lane: laneOf.get(c.id) || 0,
gen: gen.get(c.id) || 0,
};
});
} else {
nodes = commits.map(c => {
const g = gen.get(c.id) || 0;
const x = marginX + g * xGap;
const y = marginY + (laneOf.get(c.id) || 0) * yGap;
return {
...c,
x, y,
lane: laneOf.get(c.id) || 0,
gen: g,
};
});
}
const nodeById = new Map(nodes.map(n => [n.id, n]));
// Links with bend for merges
const links = [];
nodes.forEach(n => {
(n.parents || []).forEach((pid, idx) => {
const p = nodeById.get(pid);
if (!p) return;
links.push({
source: p,
target: n,
isMerge: idx > 0 || (n.parents || []).length > 1,
branchColor: colorForEdge(p, n),
});
});
});
return { nodes, links, lanes: maxLane + 1, nodeById };
}
function colorForEdge(parent, child) {
// Prefer branch color of any branch pointing at child or parent
const atChild = branchesAt(child.id);
if (atChild.length) return atChild[0].color;
const atParent = branchesAt(parent.id);
if (atParent.length) return atParent[0].color;
// walk toward HEAD branch
if (state.head) {
const hb = getBranch(state.head);
if (hb) return hb.color;
}
return BRANCH_PALETTE[0];
}
function colorForCommit(commitId) {
const bs = branchesAt(commitId);
if (bs.length) return bs[0].color;
// inherit from HEAD if on its ancestry-ish: just use first parent chain branch
const kids = childrenOf(commitId);
for (const k of kids) {
const bs2 = branchesAt(k.id);
if (bs2.length) return bs2[0].color;
}
if (state.head) {
const hb = getBranch(state.head);
if (hb) return hb.color;
}
return BRANCH_PALETTE[0];
}
// ——— D3 rendering ———
let zoomBehavior = null;
let currentTransform = d3.zoomIdentity;
let layoutCache = null;
function initSvg() {
const svg = d3.select('#graph-svg');
svg.selectAll('*').remove();
const g = svg.append('g').attr('class', 'viewport');
g.append('g').attr('class', 'links');
g.append('g').attr('class', 'nodes');
g.append('g').attr('class', 'labels');
g.append('g').attr('class', 'event-anchors');
zoomBehavior = d3.zoom()
.scaleExtent([0.15, 4])
.on('zoom', (event) => {
currentTransform = event.transform;
g.attr('transform', event.transform);
});
svg.call(zoomBehavior);
// click empty to deselect
svg.on('click', (event) => {
if (event.target === svg.node()) {
state.selectedCommitId = null;
state.selectedEventId = null;
highlightSelection();
renderRightPanel();
updateStatus();
}
});
}
function pathForLink(d) {
const x1 = d.source.x, y1 = d.source.y;
const x2 = d.target.x, y2 = d.target.y;
if (Math.abs(y1 - y2) < 1) {
return `M${x1},${y1} L${x2},${y2}`;
}
// Smooth step
const mx = (x1 + x2) / 2;
return `M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}`;
}
function renderGraph() {
const svg = d3.select('#graph-svg');
if (svg.select('.viewport').empty()) initSvg();
const { nodes, links, nodeById } = computeLayout();
layoutCache = { nodes, links, nodeById };
const gLinks = svg.select('.links');
const gNodes = svg.select('.nodes');
const gLabels = svg.select('.labels');
const gEvents = svg.select('.event-anchors');
// Links
const linkSel = gLinks.selectAll('path.link').data(links, d => d.source.id + '->' + d.target.id);
linkSel.exit().remove();
linkSel.enter()
.append('path')
.attr('class', 'link')
.merge(linkSel)
.attr('class', d => 'link' + (d.isMerge ? ' merge' : ''))
.attr('stroke', d => d.branchColor)
.attr('d', pathForLink);
// Nodes
const nodeSel = gNodes.selectAll('g.commit-node').data(nodes, d => d.id);
nodeSel.exit().remove();
const nodeEnter = nodeSel.enter().append('g')
.attr('class', 'commit-node')
.attr('data-id', d => d.id);
nodeEnter.append('circle').attr('r', 11);
nodeEnter.append('text').attr('class', 'msg').attr('x', 16).attr('y', -4);
nodeEnter.append('text').attr('class', 'sha').attr('x', 16).attr('y', 10);
const nodeMerge = nodeEnter.merge(nodeSel);
nodeMerge
.attr('transform', d => `translate(${d.x},${d.y})`)
.classed('selected', d => d.id === state.selectedCommitId)
.on('click', (event, d) => {
event.stopPropagation();
state.selectedCommitId = d.id;
state.selectedEventId = null;
// pick first event if any
const evs = eventsAt(d.id);
if (evs.length) state.selectedEventId = evs[0].id;
highlightSelection();
renderRightPanel();
renderLeftPanel();
renderTimeline();
updateStatus();
})
.on('mouseenter', (event, d) => showCommitTooltip(event, d))
.on('mousemove', (event) => moveTooltip(event))
.on('mouseleave', hideTooltip);
nodeMerge.select('circle')
.attr('fill', d => {
const col = colorForCommit(d.id);
return d3.color(col).darker(1.5);
})
.attr('stroke', d => {
const isHead = state.branches.some(b => b.name === state.head && b.commitId === d.id);
if (isHead) return '#34d399';
if ((d.parents || []).length > 1) return '#22d3ee';
return colorForCommit(d.id);
})
.attr('r', d => (d.parents || []).length > 1 ? 13 : 11);
nodeMerge.select('text.msg')
.text(d => state.showLabels ? truncate(d.message, 36) : '')
.attr('opacity', state.showLabels ? 1 : 0);
nodeMerge.select('text.sha')
.text(d => state.showLabels ? shortSha(d.id) : '')
.attr('opacity', state.showLabels ? 1 : 0);
// Branch + tag labels
const labelData = [];
state.branches.forEach(b => {
const n = nodeById.get(b.commitId);
if (!n) return;
labelData.push({
key: 'b:' + b.name,
x: n.x,
y: n.y,
text: b.name + (state.head === b.name ? ' ★' : ''),
color: b.color,
kind: 'branch',
offset: 0,
});
});
state.tags.forEach(t => {
const n = nodeById.get(t.commitId);
if (!n) return;
labelData.push({
key: 't:' + t.name,
x: n.x,
y: n.y,
text: t.name,
color: '#fbbf24',
kind: 'tag',
offset: 0,
});
});
// stack labels per commit
const byCommit = d3.group(labelData, d => d.x + ',' + d.y);
byCommit.forEach(arr => {
arr.forEach((d, i) => { d.offset = i; });
});
const labSel = gLabels.selectAll('g.ref-label').data(labelData, d => d.key);
labSel.exit().remove();
const labEnter = labSel.enter().append('g').attr('class', 'ref-label');
labEnter.append('rect').attr('class', 'branch-label-bg');
labEnter.append('text').attr('class', 'branch-label');
const labMerge = labEnter.merge(labSel);
labMerge.attr('transform', d => {
const ox = -10;
const oy = -28 - d.offset * 20;
return `translate(${d.x + ox},${d.y + oy})`;
});
labMerge.select('text')
.text(d => d.text)
.attr('fill', d => d.color)
.attr('x', 6)
.attr('y', 12);
labMerge.select('rect')
.attr('fill', d => d3.color(d.color).copy({ opacity: 0.15 }))
.attr('stroke', d => d.color)
.attr('stroke-width', 1)
.attr('x', 0)
.attr('y', 0)
.each(function (d) {
const textEl = this.parentNode.querySelector('text');
const w = textEl.getComputedTextLength() + 12;
d3.select(this).attr('width', w).attr('height', 18);
});
// Event anchors
const evData = state.showEvents
? state.events.map(e => {
const n = nodeById.get(e.commitId);
if (!n) return null;
return { ...e, x: n.x, y: n.y };
}).filter(Boolean)
: [];
// Offset multiple events on same commit
const evGroups = d3.group(evData, d => d.commitId);
evGroups.forEach(arr => {
arr.forEach((d, i) => { d.eOffset = i; });
});
const evSel = gEvents.selectAll('g.event-anchor').data(evData, d => d.id);
evSel.exit().remove();
const evEnter = evSel.enter().append('g').attr('class', 'event-anchor');
evEnter.append('line').attr('class', 'ev-stem');
evEnter.append('circle').attr('r', 6);
evEnter.append('text').attr('class', 'event-label');
const evMerge = evEnter.merge(evSel);
evMerge
.attr('transform', d => `translate(${d.x + 18 + d.eOffset * 14},${d.y - 18 - d.eOffset * 4})`)
.classed('highlight', d => d.id === state.selectedEventId || d.commitId === state.selectedCommitId)
.on('click', (event, d) => {
event.stopPropagation();
state.selectedEventId = d.id;
state.selectedCommitId = d.commitId;
highlightSelection();
renderRightPanel();
renderLeftPanel();
renderTimeline();
updateStatus();
})
.on('mouseenter', (event, d) => showEventTooltip(event, d))
.on('mousemove', (event) => moveTooltip(event))
.on('mouseleave', hideTooltip);
evMerge.select('line.ev-stem')
.attr('x1', 0).attr('y1', 0)
.attr('x2', -14).attr('y2', 14)
.attr('stroke', '#a78bfa')
.attr('stroke-width', 1.5)
.attr('stroke-dasharray', '3 2');
evMerge.select('text.event-label')
.text(d => state.showLabels ? truncate(d.title, 22) : '')
.attr('x', 10)
.attr('y', 4)
.attr('opacity', state.showLabels ? 1 : 0);
highlightSelection();
}
function truncate(s, n) {
if (!s) return '';
return s.length > n ? s.slice(0, n - 1) + '…' : s;
}
function highlightSelection() {
d3.selectAll('.commit-node')
.classed('selected', function () {
return this.getAttribute('data-id') === state.selectedCommitId;
});
d3.selectAll('.event-anchor')
.classed('highlight', d =>
d.id === state.selectedEventId || d.commitId === state.selectedCommitId
);
// Dim unrelated
const hasSel = !!state.selectedCommitId;
d3.selectAll('.commit-node').attr('opacity', d => {
if (!hasSel) return 1;
if (d.id === state.selectedCommitId) return 1;
if ((d.parents || []).includes(state.selectedCommitId)) return 0.95;
// children
const sel = getCommit(state.selectedCommitId);
if (sel && (sel.parents || []).includes(d.id)) return 0.95;
return 0.35;
});
d3.selectAll('path.link').attr('opacity', d => {
if (!hasSel) return 0.75;
if (d.source.id === state.selectedCommitId || d.target.id === state.selectedCommitId) return 1;
return 0.15;
});
}
function fitGraph() {
if (!layoutCache || !layoutCache.nodes.length) return;
const svg = document.getElementById('graph-svg');
const bounds = svg.getBoundingClientRect();
const nodes = layoutCache.nodes;
const minX = d3.min(nodes, d => d.x) - 80;
const maxX = d3.max(nodes, d => d.x) + 200;
const minY = d3.min(nodes, d => d.y) - 60;
const maxY = d3.max(nodes, d => d.y) + 80;
const w = maxX - minX;
const h = maxY - minY;
const scale = Math.min(bounds.width / w, bounds.height / h, 1.5) * 0.9;
const tx = (bounds.width - w * scale) / 2 - minX * scale;
const ty = (bounds.height - h * scale) / 2 - minY * scale;
const t = d3.zoomIdentity.translate(tx, ty).scale(scale);
d3.select('#graph-svg').transition().duration(400).call(zoomBehavior.transform, t);
}
// ——— Timeline strip ———
function renderTimeline() {
const svg = d3.select('#timeline-svg');
svg.selectAll('*').remove();
const strip = document.getElementById('timeline-strip');
if (!state.showTimeline) {
strip.classList.add('collapsed');
return;
}
strip.classList.remove('collapsed');
const el = document.getElementById('timeline-svg');
const width = el.clientWidth || 800;
const height = el.clientHeight || 90;
if (height < 20) return;
const items = [
...state.commits.map(c => ({
kind: 'commit',
id: c.id,
title: c.message,
timestamp: c.timestamp,
color: colorForCommit(c.id),
})),
...state.events.map(e => ({
kind: 'event',
id: e.id,
title: e.title,
timestamp: e.timestamp,
commitId: e.commitId,
color: '#a78bfa',
})),
].filter(d => d.timestamp != null);
if (!items.length) {
svg.append('text')
.attr('x', width / 2)
.attr('y', height / 2)
.attr('text-anchor', 'middle')
.attr('fill', '#8b9bb4')
.attr('font-size', 12)
.text('No timeline data yet');
return;
}
const tMin = d3.min(items, d => d.timestamp);
const tMax = d3.max(items, d => d.timestamp);
const pad = 40;
const x = d3.scaleTime()
.domain([tMin - (tMax - tMin) * 0.02, tMax + (tMax - tMin) * 0.02 || tMin + 1])
.range([pad, width - pad]);
// axis line
svg.append('line')
.attr('x1', pad).attr('x2', width - pad)
.attr('y1', height * 0.55).attr('y2', height * 0.55)
.attr('stroke', '#2d3a4f')
.attr('stroke-width', 2);
// ticks
const axis = d3.axisBottom(x).ticks(Math.min(8, items.length)).tickFormat(d => fmtDateShort(+d));
svg.append('g')
.attr('transform', `translate(0,${height * 0.55})`)
.call(axis)
.call(g => g.selectAll('text').attr('fill', '#8b9bb4').attr('font-size', 10))
.call(g => g.selectAll('line').attr('stroke', '#2d3a4f'))
.call(g => g.select('.domain').attr('stroke', 'transparent'));
// Commit ticks (bottom)
const commits = items.filter(d => d.kind === 'commit');
svg.selectAll('circle.tl-commit')
.data(commits)
.enter()
.append('circle')
.attr('class', 'tl-commit')
.attr('cx', d => x(d.timestamp))
.attr('cy', height * 0.55)
.attr('r', d => d.id === state.selectedCommitId ? 6 : 4)
.attr('fill', d => d.color)
.attr('stroke', d => d.id === state.selectedCommitId ? '#fff' : 'transparent')
.attr('stroke-width', 2)
.style('cursor', 'pointer')
.on('click', (event, d) => {
state.selectedCommitId = d.id;
state.selectedEventId = null;
const evs = eventsAt(d.id);
if (evs.length) state.selectedEventId = evs[0].id;
fullRender(false);
})
.on('mouseenter', (event, d) => {
const c = getCommit(d.id);
if (c) showCommitTooltip(event, c);
})
.on('mousemove', moveTooltip)
.on('mouseleave', hideTooltip);
// Events (top diamonds)
const events = items.filter(d => d.kind === 'event');
const evG = svg.selectAll('g.tl-event')
.data(events)
.enter()
.append('g')
.attr('class', 'tl-event')
.attr('transform', d => `translate(${x(d.timestamp)},${height * 0.28})`)
.style('cursor', 'pointer')
.on('click', (event, d) => {
state.selectedEventId = d.id;
state.selectedCommitId = d.commitId;
fullRender(false);
})
.on('mouseenter', (event, d) => {
const e = state.events.find(x => x.id === d.id);
if (e) showEventTooltip(event, e);
})
.on('mousemove', moveTooltip)
.on('mouseleave', hideTooltip);
evG.append('line')
.attr('x1', 0).attr('x2', 0)
.attr('y1', 8).attr('y2', height * 0.55 - height * 0.28)
.attr('stroke', '#a78bfa')
.attr('stroke-width', 1)
.attr('stroke-dasharray', '2 2')
.attr('opacity', 0.6);
evG.append('polygon')
.attr('points', '0,-7 7,0 0,7 -7,0')
.attr('fill', d => d.id === state.selectedEventId ? '#c4b5fd' : '#a78bfa')
.attr('stroke', d =>
d.id === state.selectedEventId || d.commitId === state.selectedCommitId ? '#fff' : 'transparent'
)
.attr('stroke-width', 1.5);
evG.append('text')
.text(d => truncate(d.title, 18))
.attr('y', -12)
.attr('text-anchor', 'middle')
.attr('fill', '#c4b5fd')
.attr('font-size', 9.5)
.attr('font-weight', 600);
}
// ——— Tooltips ———
function showCommitTooltip(event, d) {
const tip = document.getElementById('tooltip');
const br = branchesAt(d.id).map(b => b.name).join(', ') || '—';
const tg = tagsAt(d.id).map(t => t.name).join(', ') || '—';
const ev = eventsAt(d.id);
tip.innerHTML = `
<div class="tt-title">${escapeHtml(d.message || '')}</div>
<div class="tt-row">SHA: <strong>${shortSha(d.id)}</strong></div>
<div class="tt-row">Date: ${fmtDate(d.timestamp)}</div>
<div class="tt-row">Author: ${escapeHtml(d.author || '—')}</div>
<div class="tt-row">Branches: ${escapeHtml(br)}</div>
<div class="tt-row">Tags: ${escapeHtml(tg)}</div>
${ev.length ? `<div class="tt-row">Events: ${ev.map(e => escapeHtml(e.title)).join('; ')}</div>` : ''}
`;
tip.classList.add('visible');
moveTooltip(event);
}
function showEventTooltip(event, d) {
const tip = document.getElementById('tooltip');
const c = getCommit(d.commitId);
tip.innerHTML = `
<div class="tt-title">◈ ${escapeHtml(d.title)}</div>
<div class="tt-row">${escapeHtml(d.description || 'No description')}</div>
<div class="tt-row">When: ${fmtDate(d.timestamp)}</div>
<div class="tt-row">Anchored commit: <strong>${c ? shortSha(c.id) : '—'}</strong></div>
<div class="tt-row">${c ? escapeHtml(c.message) : ''}</div>
`;
tip.classList.add('visible');
moveTooltip(event);
}
function moveTooltip(event) {
const tip = document.getElementById('tooltip');
const container = document.getElementById('graph-container');
const rect = container.getBoundingClientRect();
let x = event.clientX - rect.left + 14;
let y = event.clientY - rect.top + 14;
if (x + 290 > rect.width) x = event.clientX - rect.left - 290;
if (y + 140 > rect.height) y = event.clientY - rect.top - 120;
tip.style.left = Math.max(8, x) + 'px';
tip.style.top = Math.max(8, y) + 'px';
}
function hideTooltip() {
document.getElementById('tooltip').classList.remove('visible');
}
function escapeHtml(s) {
const d = document.createElement('div');
d.textContent = String(s);
return d.innerHTML.replace(/"/g, '&' + 'quot;');
}
// ——— Panels ———
function renderLeftPanel() {
const body = document.getElementById('left-panel-body');
const tab = state.leftTab;
if (tab === 'commits') {
const sorted = state.commits.slice().sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
if (!sorted.length) {
body.innerHTML = `<div class="empty-hint">No commits yet.<br/>Use the Create tab or Load Sample.</div>`;
return;
}
body.innerHTML = `<div class="section-title">All Commits (${sorted.length})</div>` +
sorted.map(c => {
const ev = eventsAt(c.id).length;
const isHead = state.branches.some(b => b.name === state.head && b.commitId === c.id);
return `
<div class="card ${c.id === state.selectedCommitId ? 'selected' : ''}" data-cid="${c.id}">
<div class="card-title">
${isHead ? '<span class="badge head">HEAD</span>' : ''}
${(c.parents || []).length > 1 ? '<span class="badge merge">MERGE</span>' : ''}
${ev ? `<span class="badge event">${ev} evt</span>` : ''}
${escapeHtml(truncate(c.message, 40))}
</div>
<div class="card-meta">${shortSha(c.id)} · ${fmtDateShort(c.timestamp)} · ${escapeHtml(c.author || '')}</div>
<div class="card-actions">
<button data-act="edit-commit" data-id="${c.id}">Edit</button>
<button class="danger" data-act="del-commit" data-id="${c.id}">Del</button>
</div>
</div>`;
}).join('');
body.querySelectorAll('.card').forEach(card => {
card.addEventListener('click', (e) => {
if (e.target.closest('button')) return;
state.selectedCommitId = card.dataset.cid;
state.selectedEventId = eventsAt(card.dataset.cid)[0]?.id || null;
state.rightTab = 'detail';
fullRender(false);
});
});
body.querySelectorAll('[data-act="del-commit"]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (confirm('Delete this commit?')) deleteCommit(btn.dataset.id);
});
});
body.querySelectorAll('[data-act="edit-commit"]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
state.selectedCommitId = btn.dataset.id;
state.rightTab = 'detail';
renderRightPanel();
// focus edit form
document.querySelectorAll('.panel.right .panel-tabs button').forEach(b => {
b.classList.toggle('active', b.dataset.rtab === 'detail');
});
});
});
}
if (tab === 'branches') {
body.innerHTML = `<div class="section-title">Branches (${state.branches.length})</div>` +
(state.branches.length ? state.branches.map(b => {
const c = getCommit(b.commitId);
const isHead = state.head === b.name;
return `
<div class="card ${isHead ? 'selected' : ''}">
<div class="card-title">
<span class="branch-color-swatch" style="background:${b.color}"></span>
${escapeHtml(b.name)}
${isHead ? '<span class="badge head">HEAD</span>' : ''}
</div>
<div class="card-meta">→ ${c ? shortSha(c.id) + ' · ' + escapeHtml(truncate(c.message, 28)) : 'orphaned'}</div>
<div class="card-actions">
${!isHead ? `<button data-act="checkout" data-name="${escapeHtml(b.name)}">Checkout</button>` : ''}
<button class="danger" data-act="del-branch" data-name="${escapeHtml(b.name)}">Del</button>
</div>
</div>`;
}).join('') : `<div class="empty-hint">No branches. Create one from the Create tab.</div>`);
body.querySelectorAll('[data-act="checkout"]').forEach(btn => {
btn.addEventListener('click', () => checkoutBranch(btn.dataset.name));
});
body.querySelectorAll('[data-act="del-branch"]').forEach(btn => {
btn.addEventListener('click', () => {
if (confirm('Delete branch ' + btn.dataset.name + '?')) deleteBranch(btn.dataset.name);
});
});
}
if (tab === 'tags') {
body.innerHTML = `<div class="section-title">Tags (${state.tags.length})</div>` +
(state.tags.length ? state.tags.map(t => {
const c = getCommit(t.commitId);
return `
<div class="card">
<div class="card-title"><span class="badge tag">TAG</span> ${escapeHtml(t.name)}</div>
<div class="card-meta">→ ${c ? shortSha(c.id) + ' · ' + escapeHtml(truncate(c.message, 28)) : '—'}</div>
<div class="card-actions">
<button data-act="goto-tag" data-cid="${t.commitId}">Go</button>
<button class="danger" data-act="del-tag" data-name="${escapeHtml(t.name)}">Del</button>
</div>
</div>`;
}).join('') : `<div class="empty-hint">No tags yet.</div>`);
body.querySelectorAll('[data-act="del-tag"]').forEach(btn => {
btn.addEventListener('click', () => {
if (confirm('Delete tag?')) deleteTag(btn.dataset.name);
});
});
body.querySelectorAll('[data-act="goto-tag"]').forEach(btn => {
btn.addEventListener('click', () => {
state.selectedCommitId = btn.dataset.cid;
state.rightTab = 'detail';
fullRender(false);
});
});
}
}
function renderRightPanel() {
const body = document.getElementById('right-panel-body');
const tab = state.rightTab;
// sync tab buttons
document.querySelectorAll('.panel.right .panel-tabs button').forEach(b => {
b.classList.toggle('active', b.dataset.rtab === tab);
});
if (tab === 'detail') {
const c = getCommit(state.selectedCommitId);
if (!c) {
body.innerHTML = `<div class="empty-hint">Select a commit on the graph or from the list to inspect and edit it. Associated timeline events appear here too.</div>`;
return;
}
const parents = (c.parents || []).map(pid => {
const p = getCommit(pid);
return `<span class="chip" data-goto="${pid}">${shortSha(pid)}${p ? ' · ' + escapeHtml(truncate(p.message, 20)) : ''}</span>`;
}).join('') || '<span style="color:var(--muted);font-size:12px">Root commit</span>';
const kids = childrenOf(c.id).map(k =>
`<span class="chip" data-goto="${k.id}">${shortSha(k.id)} · ${escapeHtml(truncate(k.message, 20))}</span>`
).join('') || '<span style="color:var(--muted);font-size:12px">None</span>';
const br = branchesAt(c.id).map(b =>
`<span class="badge branch">${escapeHtml(b.name)}</span>`
).join(' ') || '—';
const tg = tagsAt(c.id).map(t =>
`<span class="badge tag">${escapeHtml(t.name)}</span>`
).join(' ') || '—';
const evs = eventsAt(c.id);
body.innerHTML = `
<div class="detail-block">
<h4>Commit <span class="badge head">${shortSha(c.id)}</span></h4>
<div class="kv"><span class="k">Message</span><span class="v">${escapeHtml(c.message)}</span></div>
<div class="kv"><span class="k">Author</span><span class="v">${escapeHtml(c.author || '—')}</span></div>
<div class="kv"><span class="k">Date</span><span class="v">${fmtDate(c.timestamp)}</span></div>
<div class="kv"><span class="k">Branches</span><span class="v">${br}</span></div>
<div class="kv"><span class="k">Tags</span><span class="v">${tg}</span></div>
<div style="margin-top:8px"><label>Parents</label><div class="parent-list">${parents}</div></div>
<div style="margin-top:8px"><label>Children</label><div class="parent-list">${kids}</div></div>
</div>
<div class="section-title">Edit Commit</div>
<label>Message</label>
<input id="edit-msg" value="${escapeHtml(c.message)}" />
<label>Author</label>
<input id="edit-author" value="${escapeHtml(c.author || '')}" />
<label>Timestamp</label>
<input id="edit-ts" type="datetime-local" value="${toLocalInputValue(c.timestamp)}" />
<label>Parents (comma-separated IDs)</label>
<input id="edit-parents" value="${(c.parents || []).join(', ')}" />
<div class="form-actions">
<button class="primary" id="btn-save-commit">Save</button>
<button class="danger" id="btn-delete-commit">Delete</button>
</div>
<div class="section-title">Anchored Events (${evs.length})</div>
${evs.length ? evs.map(e => `
<div class="card ${e.id === state.selectedEventId ? 'selected' : ''}" data-eid="${e.id}">
<div class="card-title"><span class="badge event">EVENT</span> ${escapeHtml(e.title)}</div>
<div class="card-meta">${fmtDate(e.timestamp)}</div>
</div>
`).join('') : `<div class="empty-hint" style="padding:12px">No events on this commit. Create one in the Events or Create tab.</div>`}
`;
body.querySelectorAll('[data-goto]').forEach(el => {
el.addEventListener('click', () => {
state.selectedCommitId = el.dataset.goto;
fullRender(false);
});
});
body.querySelectorAll('[data-eid]').forEach(el => {
el.addEventListener('click', () => {
state.selectedEventId = el.dataset.eid;
state.rightTab = 'events';
fullRender(false);
});
});
body.querySelector('#btn-save-commit')?.addEventListener('click', () => {
const parentsRaw = body.querySelector('#edit-parents').value;
const parents = parentsRaw.split(/[,\s]+/).map(s => s.trim()).filter(Boolean);
updateCommit(c.id, {
message: body.querySelector('#edit-msg').value,
author: body.querySelector('#edit-author').value,
timestamp: fromLocalInputValue(body.querySelector('#edit-ts').value),
parents,
});
});
body.querySelector('#btn-delete-commit')?.addEventListener('click', () => {
if (confirm('Delete commit ' + shortSha(c.id) + '?')) deleteCommit(c.id);
});
}
if (tab === 'events') {
const sorted = state.events.slice().sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
const selected = state.events.find(e => e.id === state.selectedEventId);
let html = `<div class="section-title">Timeline Events (${sorted.length})</div>`;
if (!sorted.length) {
html += `<div class="empty-hint">No timeline events. Associate real-world milestones with commits.</div>`;
} else {
html += sorted.map(e => {
const c = getCommit(e.commitId);
return `
<div class="card ${e.id === state.selectedEventId ? 'selected' : ''}" data-eid="${e.id}">
<div class="card-title"><span class="badge event">EVENT</span> ${escapeHtml(e.title)}</div>
<div class="card-meta">${fmtDateShort(e.timestamp)} · → ${c ? shortSha(c.id) : '?'}</div>
</div>`;
}).join('');
}
if (selected) {
const c = getCommit(selected.commitId);
html += `
<div class="section-title">Edit Event</div>
<div class="detail-block">
<div class="kv"><span class="k">Linked</span><span class="v">${c ? shortSha(c.id) + ' — ' + escapeHtml(c.message) : 'missing'}</span></div>
</div>
<label>Title</label>
<input id="ev-title" value="${escapeHtml(selected.title)}" />
<label>Description</label>
<textarea id="ev-desc">${escapeHtml(selected.description || '')}</textarea>
<label>Timestamp</label>
<input id="ev-ts" type="datetime-local" value="${toLocalInputValue(selected.timestamp)}" />
<label>Associated Commit</label>
<select id="ev-commit">
${state.commits.slice().sort((a,b)=>(a.timestamp||0)-(b.timestamp||0)).map(c =>
`<option value="${c.id}" ${c.id === selected.commitId ? 'selected' : ''}>${shortSha(c.id)} — ${escapeHtml(truncate(c.message, 40))}</option>`
).join('')}
</select>
<div class="form-actions">
<button class="primary" id="btn-save-event">Save Event</button>
<button class="danger" id="btn-del-event">Delete</button>
<button id="btn-focus-commit">Focus Commit</button>
</div>
`;
}
body.innerHTML = html;
body.querySelectorAll('[data-eid]').forEach(el => {
el.addEventListener('click', () => {
const e = state.events.find(x => x.id === el.dataset.eid);
if (!e) return;
state.selectedEventId = e.id;
state.selectedCommitId = e.commitId;
fullRender(false);
});
});
body.querySelector('#btn-save-event')?.addEventListener('click', () => {
updateEvent(selected.id, {
title: body.querySelector('#ev-title').value,
description: body.querySelector('#ev-desc').value,
timestamp: fromLocalInputValue(body.querySelector('#ev-ts').value),
commitId: body.querySelector('#ev-commit').value,
});
state.selectedCommitId = body.querySelector('#ev-commit').value;
});
body.querySelector('#btn-del-event')?.addEventListener('click', () => {
if (confirm('Delete this event?')) deleteEvent(selected.id);
});
body.querySelector('#btn-focus-commit')?.addEventListener('click', () => {
state.selectedCommitId = selected.commitId;
state.rightTab = 'detail';
fullRender(false);
});
}
if (tab === 'create') {
const commitOpts = state.commits
.slice()
.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))
.map(c => `<option value="${c.id}">${shortSha(c.id)} — ${escapeHtml(truncate(c.message, 42))}</option>`)
.join('');
const headCommit = state.head && getBranch(state.head)
? getBranch(state.head).commitId
: (state.commits[state.commits.length - 1]?.id || '');
body.innerHTML = `
<div class="section-title">New Commit</div>
<label>Message</label>
<input id="new-msg" placeholder="What changed?" />
<label>Author</label>
<input id="new-author" placeholder="Developer" value="Developer" />
<label>Timestamp</label>
<input id="new-ts" type="datetime-local" value="${toLocalInputValue()}" />
<label>Parent commit(s)</label>
<select id="new-parent" multiple size="4" style="margin-bottom:10px">
${commitOpts}
</select>
<p class="help-text" style="margin-top:-6px">Hold Ctrl/Cmd to select multiple parents for a merge commit. Defaults to HEAD tip.</p>
<div class="form-actions">
<button class="primary" id="btn-add-commit">Create Commit</button>
</div>
<div class="section-title">New Branch</div>
<label>Name</label>
<input id="new-branch-name" placeholder="feature/…
" />
<label>Points to commit</label>
<select id="new-branch-commit">${commitOpts}</select>
<div class="form-actions">
<button class="primary" id="btn-add-branch">Create Branch</button>
</div>
<div class="section-title">New Tag</div>
<label>Name</label>
<input id="new-tag-name" placeholder="v1.2.0" />
<label>Commit</label>
<select id="new-tag-commit">${commitOpts}</select>
<div class="form-actions">
<button class="primary" id="btn-add-tag">Create Tag</button>
</div>
<div class="section-title">New Timeline Event</div>
<label>Title</label>
<input id="new-ev-title" placeholder="Code Freeze" />
<label>Description</label>
<textarea id="new-ev-desc" placeholder="What happened in the real world?"></textarea>
<label>Timestamp</label>
<input id="new-ev-ts" type="datetime-local" value="${toLocalInputValue()}" />
<label>Associate with commit</label>
<select id="new-ev-commit">${commitOpts}</select>
<div class="form-actions">
<button class="primary" id="btn-add-event">Create Event</button>
</div>
`;
// preselect head tip
const parentSel = body.querySelector('#new-parent');
if (headCommit && parentSel) {
[...parentSel.options].forEach(o => { o.selected = o.value === headCommit; });
}
['new-branch-commit', 'new-tag-commit', 'new-ev-commit'].forEach(id => {
const sel = body.querySelector('#' + id);
if (sel && (state.selectedCommitId || headCommit)) {
sel.value = state.selectedCommitId || headCommit;
}
});
body.querySelector('#btn-add-commit').addEventListener('click', () => {
const sel = body.querySelector('#new-parent');
let parents = [...sel.selectedOptions].map(o => o.value);
if (!parents.length && headCommit) parents = [headCommit];
if (!parents.length && state.commits.length) {
toast('Select at least one parent (or start with Load Sample)', 'err');
// allow root if empty repo
if (state.commits.length) return;
}
createCommit({
message: body.querySelector('#new-msg').value || 'Update',
author: body.querySelector('#new-author').value || 'Developer',
timestamp: fromLocalInputValue(body.querySelector('#new-ts').value),
parents,
});
body.querySelector('#new-msg').value = '';
});
body.querySelector('#btn-add-branch').addEventListener('click', () => {
createBranch(
body.querySelector('#new-branch-name').value,
body.querySelector('#new-branch-commit').value
);
body.querySelector('#new-branch-name').value = '';
});
body.querySelector('#btn-add-tag').addEventListener('click', () => {
createTag(
body.querySelector('#new-tag-name').value,
body.querySelector('#new-tag-commit').value
);
body.querySelector('#new-tag-name').value = '';
});
body.querySelector('#btn-add-event').addEventListener('click', () => {
createEvent({
title: body.querySelector('#new-ev-title').value,
description: body.querySelector('#new-ev-desc').value,
timestamp: fromLocalInputValue(body.querySelector('#new-ev-ts').value),
commitId: body.querySelector('#new-ev-commit').value,
});
body.querySelector('#new-ev-title').value = '';
body.querySelector('#new-ev-desc').value = '';
});
}
}
function updateStatus() {
document.getElementById('stat-commits').textContent = state.commits.length + ' commits';
document.getElementById('stat-branches').textContent = state.branches.length + ' branches';
document.getElementById('stat-events').textContent = state.events.length + ' events';
const sel = state.selectedCommitId
? 'Selected ' + shortSha(state.selectedCommitId) + (state.selectedEventId ? ' + event' : '')
: 'No selection';
document.getElementById('stat-selection').textContent = sel;
}
function fullRender(refit = false) {
renderGraph();
renderTimeline();
renderLeftPanel();
renderRightPanel();
updateStatus();
if (refit) requestAnimationFrame(() => fitGraph());
}
// ——— Import / Export ———
function exportData() {
const data = cloneState();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'git-timeline-data.json';
a.click();
URL.revokeObjectURL(a.href);
toast('Exported JSON');
}
function openImport() {
document.getElementById('import-modal').classList.add('open');
document.getElementById('import-json').value = '';
}
function closeImport() {
document.getElementById('import-modal').classList.remove('open');
}
function applyImport() {
const raw = document.getElementById('import-json').value.trim();
if (!raw) { toast('Paste JSON first', 'err'); return; }
try {
const data = JSON.parse(raw);
// basic validation
if (data.commits && !Array.isArray(data.commits)) throw new Error('commits must be array');
if (data.events && !Array.isArray(data.events)) throw new Error('events must be array');
applyData({
commits: data.commits || [],
branches: data.branches || [],
tags: data.tags || [],
events: data.events || [],
head: data.head ?? null,
}, true);
closeImport();
toast('Import successful');
requestAnimationFrame(() => fitGraph());
} catch (e) {
toast('Import failed: ' + e.message, 'err');
}
}
// ——— Wiring ———
function bindUI() {
// Left tabs
document.querySelectorAll('.panel.left .panel-tabs button').forEach(btn => {
btn.addEventListener('click', () => {
state.leftTab = btn.dataset.tab;
document.querySelectorAll('.panel.left .panel-tabs button').forEach(b => {
b.classList.toggle('active', b === btn);
});
renderLeftPanel();
});
});
// Right tabs
document.querySelectorAll('.panel.right .panel-tabs button').forEach(btn => {
btn.addEventListener('click', () => {
state.rightTab = btn.dataset.rtab;
renderRightPanel();
});
});
// Layout toggle
document.querySelectorAll('#layout-toggle button').forEach(btn => {
btn.addEventListener('click', () => {
state.layout = btn.dataset.layout;
document.querySelectorAll('#layout-toggle button').forEach(b => {
b.classList.toggle('active', b === btn);
});
renderGraph();
requestAnimationFrame(() => fitGraph());
});
});
document.getElementById('btn-zoom-fit').addEventListener('click', fitGraph);
document.getElementById('btn-zoom-in').addEventListener('click', () => {
d3.select('#graph-svg').transition().duration(200).call(zoomBehavior.scaleBy, 1.3);
});
document.getElementById('btn-zoom-out').addEventListener('click', () => {
d3.select('#graph-svg').transition().duration(200).call(zoomBehavior.scaleBy, 0.7);
});
document.getElementById('chk-events').addEventListener('change', (e) => {
state.showEvents = e.target.checked;
renderGraph();
});
document.getElementById('chk-labels').addEventListener('change', (e) => {
state.showLabels = e.target.checked;
renderGraph();
});
document.getElementById('chk-timeline').addEventListener('change', (e) => {
state.showTimeline = e.target.checked;
renderTimeline();
// reflow graph
setTimeout(() => { renderGraph(); }, 50);
});
document.getElementById('btn-toggle-timeline').addEventListener('click', () => {
state.showTimeline = !state.showTimeline;
document.getElementById('chk-timeline').checked = state.showTimeline;
document.getElementById('btn-toggle-timeline').textContent = state.showTimeline ? 'Collapse' : 'Expand';
renderTimeline();
});
document.getElementById('btn-sample').addEventListener('click', () => {
if (state.commits.length && !confirm('Replace current data with the sample project?')) return;
loadSample();
requestAnimationFrame(() => fitGraph());
});
document.getElementById('btn-export').addEventListener('click', exportData);
document.getElementById('btn-import').addEventListener('click', openImport);
document.getElementById('import-close').addEventListener('click', closeImport);
document.getElementById('import-cancel').addEventListener('click', closeImport);
document.getElementById('import-apply').addEventListener('click', applyImport);
document.getElementById('btn-reset').addEventListener('click', () => {
if (!confirm('Clear all data? This cannot be undone.')) return;
applyData({ commits: [], branches: [], tags: [], events: [], head: null }, true);
toast('Workspace cleared', 'err');
});
window.addEventListener('resize', () => {
renderTimeline();
});
// Keyboard
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeImport();
state.selectedCommitId = null;
state.selectedEventId = null;
fullRender(false);
}
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault();
save();
toast('Saved to localStorage');
}
});
}
// ——— Boot ———
function boot() {
bindUI();
initSvg();
const ok = load();
if (!ok || !state.commits.length) {
loadSample();
} else {
fullRender();
}
requestAnimationFrame(() => {
requestAnimationFrame(() => fitGraph());
});
}
boot();
</script>
</body>
</html>