flex-direction: column;
height: 100vh;
min-width: 0;
+ position: relative; /* anchor for #canvas-controls */
}
+#canvas-controls {
+ position: absolute;
+ bottom: 12px;
+ right: 12px;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 6px;
+ z-index: 10;
+}
+
+#fit-btn {
+ appearance: none;
+ background: #fff;
+ border: 1px solid #cbd5e1;
+ border-radius: 4px;
+ padding: 5px 12px;
+ font-size: 12px;
+ cursor: pointer;
+ color: #1f2937;
+ box-shadow: 0 1px 3px rgba(15, 23, 42, 0.1);
+}
+#fit-btn:hover { background: #f1f5f9; }
+#fit-btn:active { background: #e2e8f0; }
+
+#help-panel {
+ background: #fff;
+ border: 1px solid #cbd5e1;
+ border-radius: 4px;
+ font-size: 11px;
+ position: relative;
+ box-shadow: 0 1px 3px rgba(15, 23, 42, 0.1);
+}
+#help-panel summary {
+ cursor: pointer;
+ padding: 5px 12px;
+ font-size: 12px;
+ font-weight: 600;
+ color: #1f2937;
+ user-select: none;
+ list-style: none;
+}
+#help-panel summary::-webkit-details-marker { display: none; }
+#help-panel summary::before {
+ content: '?';
+ display: inline-block;
+ width: 14px;
+ height: 14px;
+ background: #6366f1;
+ color: #fff;
+ border-radius: 50%;
+ text-align: center;
+ font-size: 10px;
+ font-weight: 700;
+ line-height: 14px;
+ margin-right: 6px;
+}
+#help-panel[open] summary::before { background: #1f2937; }
+
+/* Help content opens upward, away from the bottom edge of the canvas. */
+.help-content {
+ position: absolute;
+ bottom: calc(100% + 6px);
+ right: 0;
+ width: 360px;
+ max-height: 70vh;
+ overflow-y: auto;
+ background: #fff;
+ border: 1px solid #cbd5e1;
+ border-radius: 4px;
+ padding: 10px 14px;
+ box-shadow: 0 6px 16px rgba(15, 23, 42, 0.18);
+}
+.help-content h4 {
+ margin: 12px 0 4px;
+ font-size: 11px;
+ color: #475569;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ font-weight: 700;
+}
+.help-content h4:first-child { margin-top: 0; }
+.help-content code {
+ background: #f1f5f9;
+ padding: 0 4px;
+ border-radius: 3px;
+ font-size: 10.5px;
+}
+.help-content kbd {
+ background: #e5e7eb;
+ border: 1px solid #cbd5e1;
+ border-radius: 3px;
+ padding: 0 5px;
+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+ font-size: 10px;
+}
+.help-tip {
+ margin: 3px 0;
+ color: #475569;
+}
+
+.legend-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin: 4px 0;
+ line-height: 1.4;
+}
+.legend-row > .swatch {
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: flex-start;
+ width: 56px;
+}
+.swatch-edge-wrap { padding-left: 6px; }
+.swatch-node {
+ display: inline-block;
+ padding: 2px 6px;
+ font-size: 10px;
+ border-radius: 4px;
+ border: 1px solid;
+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+ white-space: nowrap;
+}
+.swatch-class { background: #3b82f6; color: #fff; border-color: #1d4ed8; }
+.swatch-family { background: #fbbf24; color: #3f2d05; border-color: #b45309; }
+.swatch-instance { background: #ecfdf5; color: #065f46; border-color: #10b981; }
+.swatch-faminst { background: #f5f3ff; color: #5b21b6; border-color: #a78bfa; font-style: italic; }
+.swatch-external { background: #e5e7eb; color: #374151; border-color: #9ca3af; border-style: dashed; }
+.swatch-ghost { background: #dbeafe; color: #1e3a8a; border-color: #1d4ed8; border-style: dashed; opacity: 0.85; }
+.swatch-orphan { background: #ecfdf5; color: #065f46; border: 1px dashed #dc2626; }
+
+.swatch-edge {
+ display: inline-block;
+ height: 0;
+ width: 36px;
+ border-top: 2px solid;
+ vertical-align: middle;
+}
+.swatch-edge.solid { border-top-style: solid; }
+.swatch-edge.dashed { border-top-style: dashed; }
+.swatch-edge.dotted { border-top-style: dotted; border-top-width: 3px; }
+.swatch-edge.gray { border-top-color: #94a3b8; }
+.swatch-edge.amber { border-top-color: #f59e0b; }
+.swatch-edge.light-gray { border-top-color: #cbd5e1; }
+.swatch-edge.green { border-top-color: #10b981; }
+.swatch-edge.purple { border-top-color: #6366f1; }
+.swatch-edge.violet-light { border-top-color: #c084fc; }
+.swatch-edge.violet { border-top-color: #a78bfa; }
+.swatch-edge.teal { border-top-color: #0d9488; }
+
#topbar {
flex: 0 0 auto;
display: flex;
#panel h1 { margin: 0 0 4px; font-size: 16px; letter-spacing: 0.04em; text-transform: uppercase; color: #444; }
#panel .hint { margin: 0 0 16px; font-size: 12px; color: #888; line-height: 1.5; }
+.panel-actions {
+ display: flex;
+ gap: 8px;
+ margin: 8px 0 16px;
+}
+.panel-btn {
+ appearance: none;
+ border: 1px solid #d1d5db;
+ background: #fff;
+ font-size: 12px;
+ padding: 4px 10px;
+ border-radius: 4px;
+ cursor: pointer;
+ color: #374151;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+}
+.panel-btn:hover { background: #f3f4f6; }
+.panel-btn.pin.active {
+ background: #dbeafe;
+ color: #1e40af;
+ border-color: #93c5fd;
+}
+.panel-btn.mute.active {
+ background: #fed7aa;
+ color: #7c2d12;
+ border-color: #fdba74;
+}
+
+#selected dd a[data-action="navigate-class"] {
+ color: #1d4ed8;
+ text-decoration: none;
+ cursor: pointer;
+}
+#selected dd a[data-action="navigate-class"]:hover {
+ text-decoration: underline;
+}
+
#selected h2 { margin: 0 0 4px; font-size: 18px; }
#selected .pkgmod { margin: 0 0 12px; color: #888; font-size: 12px; word-break: break-all; }
#selected .empty { color: #aaa; font-style: italic; }
</div>
</div>
<div id="cy"></div>
+ <div id="canvas-controls">
+ <button id="fit-btn" type="button" title="Fit graph to viewport (F)">⊡ Fit</button>
+ <details id="help-panel">
+ <summary>Help / legend</summary>
+ <div class="help-content">
+ <h4>Nodes</h4>
+ <div class="legend-row"><span class="swatch"><span class="swatch-node swatch-class">Cls</span></span><span>Class defined in this program</span></div>
+ <div class="legend-row"><span class="swatch"><span class="swatch-node swatch-family">Fam</span></span><span>Type family (open / closed / associated)</span></div>
+ <div class="legend-row"><span class="swatch"><span class="swatch-node swatch-instance">Inst</span></span><span>Class instance</span></div>
+ <div class="legend-row"><span class="swatch"><span class="swatch-node swatch-faminst">f a=b</span></span><span><code>type instance F … = …</code> row</span></div>
+ <div class="legend-row"><span class="swatch"><span class="swatch-node swatch-external">Ext</span></span><span>External class (referenced but not defined here)</span></div>
+ <div class="legend-row"><span class="swatch"><span class="swatch-node swatch-ghost">Cls</span></span><span>Ghost — one-hop neighbour in focus mode (click to pin)</span></div>
+ <div class="legend-row"><span class="swatch"><span class="swatch-node swatch-orphan">Inst</span></span><span>Orphan instance (red dashed border)</span></div>
+
+ <h4>Classes-view edges</h4>
+ <div class="legend-row"><span class="swatch swatch-edge-wrap"><span class="swatch-edge solid gray"></span></span><span>Plain superclass</span></div>
+ <div class="legend-row"><span class="swatch swatch-edge-wrap"><span class="swatch-edge dashed amber"></span></span><span>Superclass mediated by a type family <code>(Foo (F a) => …)</code></span></div>
+ <div class="legend-row"><span class="swatch swatch-edge-wrap"><span class="swatch-edge dashed light-gray"></span></span><span>Class → associated type family</span></div>
+
+ <h4>Instance-view edges</h4>
+ <div class="legend-row"><span class="swatch swatch-edge-wrap"><span class="swatch-edge solid green"></span></span><span>Class defines this instance</span></div>
+ <div class="legend-row"><span class="swatch swatch-edge-wrap"><span class="swatch-edge dotted purple"></span></span><span>Context constraint required by this instance</span></div>
+ <div class="legend-row"><span class="swatch swatch-edge-wrap"><span class="swatch-edge solid amber"></span></span><span>Instance needs a superclass instance (matched locally)</span></div>
+ <div class="legend-row"><span class="swatch swatch-edge-wrap"><span class="swatch-edge dashed gray"></span></span><span>Needs a superclass instance (no local match)</span></div>
+ <div class="legend-row"><span class="swatch swatch-edge-wrap"><span class="swatch-edge dashed violet-light"></span></span><span>Instance references this type family</span></div>
+ <div class="legend-row"><span class="swatch swatch-edge-wrap"><span class="swatch-edge solid violet"></span></span><span>Family → its type-instance row</span></div>
+ <div class="legend-row"><span class="swatch swatch-edge-wrap"><span class="swatch-edge dotted violet"></span></span><span>Instance's associated <code>type instance</code> RHS</span></div>
+ <div class="legend-row"><span class="swatch swatch-edge-wrap"><span class="swatch-edge dotted teal"></span></span><span>Type-instance row resolves to a class instance (chain)</span></div>
+
+ <h4>Controls</h4>
+ <div class="help-tip">Click → highlight + side panel</div>
+ <div class="help-tip">Double-click a class → instances; a family → type instances</div>
+ <div class="help-tip"><kbd>/</kbd> focuses search · <kbd>F</kbd> fits the graph</div>
+ <div class="help-tip">Pin / mute via the right-side buttons (chips appear in the topbar)</div>
+ </div>
+ </details>
+ </div>
</div>
<div id="splitter" title="Drag to resize panel"></div>
<aside id="panel">
<header>
<h1>classgraph</h1>
- <p class="hint" id="hint-classes">Click to highlight a node and its edges. <strong>Double-click</strong> a class to drill into its instances, or a family (diamond) to see its type instances. Scroll to zoom; drag to pan.</p>
+ <p class="hint" id="hint-classes">Search to locate a class. Click any node to inspect it on the right (with <em>Pin</em>/<em>Mute</em> buttons). <strong>Double-click</strong> a class to drill into its instances, or a family (diamond) to see its type instances. Scroll to zoom; drag to pan.</p>
<p class="hint" id="hint-instance" hidden>Click to highlight; <strong>double-click</strong> a class or family to drill in. The back arrow returns to the hierarchy.</p>
<p class="hint" id="hint-family" hidden>Each row is a <code>type instance</code> declaration. <strong>Double-click</strong> the parent class to see its instances.</p>
</header>
instancesByClass.get(k).push(i);
});
+ // Reverse index: for each class C, the classes that *have C as a
+ // direct superclass*. This is the missing direction of traversal —
+ // ciSuperclasses gives us "what does this class need", subclassesByClass
+ // gives us "what builds on top of this class".
+ const subclassesByClass = new Map();
+ for (const c of graph.meta.pdClasses) {
+ for (const sc of c.ciSuperclasses) {
+ const k = qid(sc.seSuperclass);
+ if (!subclassesByClass.has(k)) subclassesByClass.set(k, []);
+ subclassesByClass.get(k).push({ subclass: c, edge: sc });
+ }
+ }
+
// Family instances grouped by family id (qid). Each fam-instance gets a
// stable index so we can mint deterministic Cytoscape node ids. Closed
// type families' equations live on TypeFamilyInfo.tfEquations rather
}
}
+ function panelButtons(cid, opts) {
+ // opts.canPin: whether this entity can be added to the focus set.
+ // opts.canMute: whether this entity can be muted.
+ const isPinned = opts.canPin && focusSet.has(cid);
+ const isMuted = opts.canMute && mutedSet.has(cid);
+ const parts = [];
+ if (opts.canPin) {
+ parts.push(
+ `<button class="panel-btn pin${isPinned ? ' active' : ''}" ` +
+ `data-action="toggle-pin" data-id="${escapeAttr(cid)}" ` +
+ `title="${isPinned ? 'Remove from focus' : 'Add to focus subgraph'}">` +
+ `📌 ${isPinned ? 'Unpin' : 'Pin'}</button>`
+ );
+ }
+ if (opts.canMute) {
+ parts.push(
+ `<button class="panel-btn mute${isMuted ? ' active' : ''}" ` +
+ `data-action="toggle-mute" data-id="${escapeAttr(cid)}" ` +
+ `title="${isMuted ? 'Show again' : 'Hide everywhere'}">` +
+ `🙈 ${isMuted ? 'Unmute' : 'Mute'}</button>`
+ );
+ }
+ return parts.length === 0 ? '' : `<div class="panel-actions">${parts.join('')}</div>`;
+ }
+
function renderClassPanel(c) {
+ const cid = qid(c.ciName);
const tvs = c.ciTyVars
.map(v => `<li>${escape(v.tvName)}<span style="color:#888"> :: ${escape(v.tvKind)}</span></li>`)
.join('');
const supers = c.ciSuperclasses.length === 0 ? '<dd><em>none</em></dd>' :
c.ciSuperclasses
- .map(s => `<dd>${escape(s.seSuperclass.qnName)}${renderArgsParens(c.ciTyVars, s.seArgs)}</dd>`)
+ .map(s => {
+ const sid = qid(s.seSuperclass);
+ const args = renderArgsParens(c.ciTyVars, s.seArgs);
+ // Make the link navigable only when the superclass is in our data
+ // (or at least appears as an external stub in the classes view).
+ return `<dd><a href="#" data-action="navigate-class" data-id="${escapeAttr(sid)}">` +
+ `${escape(s.seSuperclass.qnName)}</a>${escape(args)}</dd>`;
+ })
+ .join('');
+ const subEntries = subclassesByClass.get(cid) || [];
+ const subs = subEntries.length === 0 ? '<dd><em>none</em></dd>' :
+ subEntries
+ .map(({subclass}) => {
+ const sid = qid(subclass.ciName);
+ return `<dd><a href="#" data-action="navigate-class" data-id="${escapeAttr(sid)}">` +
+ `${escape(subclass.ciName.qnName)}</a></dd>`;
+ })
.join('');
const assocs = c.ciAssocTypes.length === 0 ? '<dd><em>none</em></dd>' :
c.ciAssocTypes.map(a => `<dd>${escape(a.qnName)}</dd>`).join('');
const src = c.ciSrc
? `<dd>${escape(c.ciSrc.ssFile)}:${c.ciSrc.ssStartLine}:${c.ciSrc.ssStartCol}</dd>`
: '<dd><em>unknown</em></dd>';
- const numInsts = (instancesByClass.get(qid(c.ciName)) || []).length;
+ const numInsts = (instancesByClass.get(cid) || []).length;
return `
<h2>${escape(c.ciName.qnName)}</h2>
<p class="pkgmod">${escape(c.ciName.qnPackage)} · ${escape(c.ciName.qnModule)}</p>
+ ${panelButtons(cid, { canPin: true, canMute: true })}
<dl>
<dt>Type variables</dt><dd><ul>${tvs}</ul></dd>
<dt>Superclasses</dt>${supers}
+ <dt>Subclasses (in this program)</dt>${subs}
<dt>Associated type families</dt>${assocs}
<dt>Methods</dt>${meths}
<dt>Instances in this program</dt><dd>${numInsts}</dd>
return `
<h2>${escape(d.label)}</h2>
<p class="pkgmod">${escape(d.package || '')} · ${escape(d.module || '')}</p>
+ ${panelButtons(d.id, { canPin: false, canMute: true })}
<dl>
<dt>Status</dt><dd><em>External (not defined in this program)</em></dd>
</dl>`;
switchToClasses();
});
+ // -------------------------------------------------------------------------
+ // Side-panel event delegation: toggle Pin/Mute buttons, follow class links.
+
+ document.getElementById('selected').addEventListener('click', evt => {
+ const target = evt.target.closest('[data-action]');
+ if (!target) return;
+ evt.preventDefault();
+ const action = target.dataset.action;
+ const id = target.dataset.id;
+ if (action === 'toggle-pin') {
+ if (focusSet.has(id)) unpinClass(id); else pinClass(id);
+ rerenderSelectedFor(id);
+ } else if (action === 'toggle-mute') {
+ if (mutedSet.has(id)) unmuteClass(id); else muteClass(id);
+ rerenderSelectedFor(id);
+ } else if (action === 'navigate-class') {
+ navigateToClass(id);
+ }
+ });
+
+ function rerenderSelectedFor(id) {
+ const cls = classById.get(id);
+ if (cls) {
+ document.getElementById('selected').innerHTML = renderClassPanel(cls);
+ return;
+ }
+ const node = cy.getElementById(id);
+ if (node && node.length) {
+ document.getElementById('selected').innerHTML = renderExternalClassPanel(node.data());
+ }
+ }
+
+ // Centre, zoom and highlight a class node (or external stub) in the
+ // classes view, populating the side panel along the way. Used by the
+ // search bar and by clicking class names inside the side panel.
+ function navigateToClass(id) {
+ if (state.view !== 'classes') switchToClasses();
+ const n = cy.getElementById(id);
+ if (n && n.length > 0) {
+ highlightOnly(n);
+ cy.animate({
+ center: { eles: n },
+ zoom: Math.max(cy.zoom(), 1.3),
+ }, { duration: 250 });
+ } else {
+ // Outside the visible subgraph (focus filter excludes it). Update the
+ // side panel anyway so the user can pin it or follow links from there.
+ const cls = classById.get(id);
+ if (cls) {
+ document.getElementById('selected').innerHTML = renderClassPanel(cls);
+ }
+ }
+ }
+
document.getElementById('pin-clear').addEventListener('click', () => {
clearFocus();
});
const badge = e.kind === 'family'
? '<span class="badge family">family</span>'
: (e.external ? '<span class="badge external">external</span>' : '');
- const canPin = (e.kind === 'class' && !e.external);
- const canMute = (e.kind === 'class');
- const pinned = canPin && focusSet.has(e.id);
- const muted = canMute && mutedSet.has(e.id);
- const actions = [];
- if (canMute) {
- actions.push(`<button class="mute-add${muted ? ' muted' : ''}" title="${muted ? 'Unmute' : 'Mute (hide everywhere)'}">🙈</button>`);
- }
- if (canPin) {
- actions.push(`<button class="pin-add${pinned ? ' pinned' : ''}" title="${pinned ? 'Unpin' : 'Add to focus'}">📌</button>`);
- }
return `<li data-index="${i}">
<span class="row-content">
<span class="name">${escape(e.name)}${badge}</span>
<span class="qual">${escape(e.package)} · ${escape(e.module)}</span>
</span>
- <span class="row-actions">${actions.join('')}</span>
</li>`;
}).join('');
}
function selectMatch(i) {
const m = currentMatches[i];
if (!m) return;
- if (m.kind === 'family' && familyById.has(m.id)) {
- switchToFamily(m.id);
- } else if (classById.has(m.id) || true) {
- // External class entries don't drill into an instance view (we have no
- // instances for them); for those, switch to the classes view and
- // highlight the node so the user can see where it lives.
- if (classById.has(m.id)) {
- switchToInstance(m.id);
- } else {
- switchToClasses();
- const n = cy.getElementById(m.id);
- if (n && n.length) {
- cy.elements().addClass('dim').removeClass('highlight');
- n.removeClass('dim').addClass('highlight');
- n.connectedEdges().removeClass('dim').addClass('highlight')
- .connectedNodes().removeClass('dim');
- cy.animate({ center: { eles: n }, zoom: 1.3 }, { duration: 250 });
- }
- }
- }
searchInput.value = '';
hideSearchResults();
searchInput.blur();
+ // Search now always /locates/ — it never drills in. The user
+ // double-clicks a node when they actually want to descend.
+ if (state.view !== 'classes') switchToClasses();
+ const n = cy.getElementById(m.id);
+ if (n && n.length) {
+ highlightOnly(n);
+ cy.animate({
+ center: { eles: n },
+ zoom: Math.max(cy.zoom(), 1.3),
+ }, { duration: 250 });
+ } else {
+ // The node isn't in the visible subgraph (focus filter). Populate
+ // the side panel anyway so the user can pin it from there.
+ const cls = classById.get(m.id);
+ const fam = familyById.get(m.id);
+ if (cls) document.getElementById('selected').innerHTML = renderClassPanel(cls);
+ else if (fam) document.getElementById('selected').innerHTML = renderFamilyPanel(fam);
+ }
}
searchInput.addEventListener('input', () => {
const li = evt.target.closest('li[data-index]');
if (!li) return;
evt.preventDefault();
- const idx = parseInt(li.dataset.index, 10);
- const m = currentMatches[idx];
- if (!m) return;
- const pinBtn = evt.target.closest('.pin-add');
- if (pinBtn) {
- if (focusSet.has(m.id)) unpinClass(m.id); else pinClass(m.id);
- renderResults(currentMatches);
- updateActive();
- searchInput.focus();
- return;
- }
- const muteBtn = evt.target.closest('.mute-add');
- if (muteBtn) {
- if (mutedSet.has(m.id)) unmuteClass(m.id); else muteClass(m.id);
- renderResults(currentMatches);
- updateActive();
- searchInput.focus();
- return;
- }
- selectMatch(idx);
+ selectMatch(parseInt(li.dataset.index, 10));
});
document.addEventListener('mousedown', evt => {
if (!evt.target.closest('#search-wrap')) hideSearchResults();
});
- // Quick keyboard shortcut: "/" focuses the search bar.
+ // Quick keyboard shortcuts:
+ // / → focus the search bar
+ // F → fit the graph to the viewport
document.addEventListener('keydown', evt => {
+ const target = evt.target;
+ if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
if (evt.key === '/' && document.activeElement !== searchInput) {
- const target = evt.target;
- if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
evt.preventDefault();
searchInput.focus();
searchInput.select();
+ } else if (evt.key === 'f' || evt.key === 'F') {
+ evt.preventDefault();
+ fitGraph();
}
});
+ function fitGraph() {
+ cy.animate({ fit: { padding: 30 } }, { duration: 250 });
+ }
+
+ document.getElementById('fit-btn').addEventListener('click', fitGraph);
+
// ---------------------------------------------------------------------------
// Helpers