]> Repositorios git - classgraph.git/commitdiff
Side panel: subclass index, pin/mute buttons; search now locates rather than drills in
authorJavier Sagredo <[email protected]>
Sun, 3 May 2026 22:00:13 +0000 (00:00 +0200)
committerJavier Sagredo <[email protected]>
Mon, 4 May 2026 00:02:04 +0000 (02:02 +0200)
data/viewer.css
data/viewer.html
data/viewer.js

index 1bfaf410858d653cd5f8bd4e87c983a5fc9bca5d..005ce9ba779a20861c7525ab092ff411ff7d0787 100644 (file)
@@ -7,8 +7,161 @@ body { display: flex; }
   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;
@@ -285,6 +438,45 @@ body { 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; }
index 289ea4c2c9b53e7f63527205885a55fb54feac30..df2ead4064bb80157de4a3f1051a792f51e8a012 100644 (file)
       </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) =&gt; …)</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>
index 1cf14a86016841c978bf1ae681fe7b1609671d91..514f6c2957ab9e5d88dbfb59983827200311d9fb 100644 (file)
     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