feat(ui): the type hierarchy — what a type is built on, and what dispatches through it (CG-58)

A vertical tree above the members outline for classes, interfaces, structs,
traits, protocols, enums, unions and type aliases: ancestors above (the whole
chain, not just the direct parent), the focus in accent, subtypes below indented
per level. `extends` draws solid, `implements` dashed; a synthesized edge — Go's
implicit interface satisfaction — draws dashed wider and carries the site it was
wired at, so a relation the resolver inferred never reads like one the source
wrote down. For an interface the fan below IS the set of runtime targets a call
can land on, and a type with eight or more implementers leads with that in a
sentence. Members that redeclare an ancestor's are marked in the outline.

The walk lives in `src/graph/type-hierarchy.ts`, following CG-50/CG-51: shared
computation in `src/graph/`, presentation in the caller. Its `countImplementers`
is now also what `ToolHandler.buildPolymorphicBoundaries` counts with, so "N
types implement X" is the same N whether an agent reads it or a person does.
`/api/node` carries the block as `hierarchy` rather than a second endpoint —
it is part of the Symbol view's first paint, and gated to types, so a function
costs one kind test.

Layout is arithmetic (24px rows, 22px indent, orthogonal connectors computed
from the two): no ResizeObserver, same payload → same picture. The header's
`extends X` / `implemented by …` chips are suppressed while the tree is on
screen — two renderings of one relation in one column is how a reader ends up
trusting neither.

`TypeHierarchy` is exported from `@colbymchenry/codegraph-ui` and takes its data
as a prop, so a host holding a `WireSymbolPayload` renders it without a second
read.
This commit is contained in:
Colby McHenry
2026-08-27 07:14:55 -05:00
parent c15413f200
commit 2a0c6dc58f
21 changed files with 2100 additions and 28 deletions
+39 -5
View File
@@ -63,11 +63,17 @@ build so that mistake cannot land twice.
```
Exports: `SymbolView`, `FlowStrip`, `ArchitectureMap`, `FileView`,
`FileSourceView`, `EntryPointsView`, `TrailBar`, `SearchPalette`,
`PalettePanel`, `PaletteRows`, `DriftBanner`, `KindGlyph`, `ExportButtons`,
`CodegraphUi` — plus every pure model function the screens are built from
(`buildCalleeRail`, `buildFlowLayout`, `buildMapLayout`, `tokensByLine`, …) and
the `Wire*` types an adapter answers in.
`FileSourceView`, `EntryPointsView`, `TypeHierarchy`, `TrailBar`,
`SearchPalette`, `PalettePanel`, `PaletteRows`, `DriftBanner`, `KindGlyph`,
`ExportButtons`, `CodegraphUi` — plus every pure model function the screens are
built from (`buildCalleeRail`, `buildFlowLayout`, `buildMapLayout`,
`buildHierarchyModel`, `tokensByLine`, …) and the `Wire*` types an adapter
answers in.
`TypeHierarchy` is the one screen that takes its data as a prop rather than
asking the adapter: it is part of `SymbolView`'s payload (`/api/node`'s
`hierarchy`), so a host that already holds a `WireSymbolPayload` can render the
tree on its own without a second read.
### The adapter is the only way data arrives
@@ -254,6 +260,34 @@ The verdict itself is not computed here or in the server: it is
`findDynamicBoundaries` in `src/graph/dynamic-boundary-report.ts`, the same
detector `codegraph_explore` announces boundaries with.
## The type hierarchy
A class, interface, struct, trait or enum carries a `hierarchy` on its
`/api/node` payload: ancestors up, subtypes down, and the fan an interface call
dispatches into. `buildHierarchyModel` turns it into a tree whose geometry is
arithmetic — 24px rows, 22px of indent per descendant level, orthogonal 1px
connectors computed from those two numbers. Nothing is measured; the same
payload always draws the same picture.
The details worth knowing before changing it:
- **`extends` is solid, `implements` dashed `4 3`, a synthesized edge dashed
`6 3`** with a `via <mechanism>` pill. In Go, `System` satisfies `Clock`
without either file naming the other and the edge exists only because the
resolver made it — the block says so rather than drawing it like a parse.
- **Overrides on the members outline are a NAME match**, not an `overrides`
edge (nothing in the engine emits one). They are matched against the nearest
ancestor that declares the name and are blind to signatures, and the tooltip
says which claim was actually checked.
- **The fold trims the deepest end**, because the walk is breadth-first: a
reader looking at an interface gets every direct implementation before any
subclass of one appears at all.
The walk is not computed here or in the server: it is `buildTypeHierarchy` in
`src/graph/type-hierarchy.ts`, whose `countImplementers` is also the number
`codegraph_explore` prints when it announces an interface dispatch — so "N types
implement X" is the same N wherever you read it.
## Live updates
The viewer never polls. `lib/live.svelte.ts` holds one `EventSource` on
+29 -2
View File
@@ -10,7 +10,7 @@
-->
<script lang="ts">
import KindGlyph from '../KindGlyph.svelte';
import type { WireNodeRef } from '../../lib/api';
import type { WireNodeRef, WireOverride } from '../../lib/api';
import type { OutlineRow } from '../../lib/symbol-model';
interface Props {
@@ -21,6 +21,18 @@
}
let { rows, total, truncated, onopen }: Props = $props();
/**
* The override mark is a NAME match inside a chain the graph links, not an
* `overrides` edge — nothing in the engine emits one. The tooltip says so,
* because "overrides Base" and "declares the same name as Base" are different
* claims and only the second one was checked.
*/
function overrideTitle(o: WireOverride): string {
return o.relation === 'implements'
? `Declares a member ${o.baseTypeName} requires — matched by name.`
: `Redeclares a member of ${o.baseTypeName} — matched by name.`;
}
</script>
<div class="subh">
@@ -40,7 +52,13 @@
>
<KindGlyph kind={row.member.kind} />
<span class="nm">{row.member.name}</span>
<span class="sig">{row.member.signature ?? ''}</span>
<span class="sig">
{#if row.member.overrides}
<span class="ovr" title={overrideTitle(row.member.overrides)}>
{row.member.overrides.relation === 'implements' ? 'satisfies' : 'overrides'}
{row.member.overrides.baseTypeName}
</span>
{/if}{row.member.signature ?? ''}</span>
<span class="cnt">
{#if row.member.fanIn}{row.member.fanIn}{/if}{#if row.member.fanIn && row.member.fanOut}&nbsp;
{/if}{#if row.member.fanOut}{row.member.fanOut}{/if}
@@ -101,6 +119,15 @@
color: var(--ink-3);
}
.ovr {
margin-right: 6px;
padding: 0 4px;
border: 1px solid var(--rule-soft);
color: var(--ink-2);
font: 10.5px var(--mono);
white-space: nowrap;
}
.sig {
overflow: hidden;
color: var(--ink-3);
+20 -3
View File
@@ -22,19 +22,36 @@
interface Props {
payload: WireSymbolPayload;
onopen: (node: WireNodeRef) => void;
/**
* Draw the `extends X` / `implemented by …` chips.
*
* Off when the type-hierarchy tree is on screen: the tree answers the same
* question with more of the truth in it (depth, synthesized edges, the
* subtypes that are not direct), and two renderings of one relation in one
* column is how a reader ends up trusting neither.
*/
relationChips?: boolean;
}
let { payload, onopen }: Props = $props();
let { payload, onopen, relationChips = true }: Props = $props();
let node = $derived<WireNodeDetail>(payload.node);
let tests = $derived(payload.tests);
/** `extends`/`implements` this symbol declares, and the ones declared on it. */
let supertypes = $derived(
payload.outgoing.items.filter((r) => r.edgeKinds.some((k) => k === 'extends' || k === 'implements'))
relationChips
? payload.outgoing.items.filter((r) =>
r.edgeKinds.some((k) => k === 'extends' || k === 'implements')
)
: []
);
let subtypes = $derived(
payload.incoming.items.filter((r) => r.edgeKinds.some((k) => k === 'extends' || k === 'implements'))
relationChips
? payload.incoming.items.filter((r) =>
r.edgeKinds.some((k) => k === 'extends' || k === 'implements')
)
: []
);
const TYPE_CHIP_LIMIT = 12;
@@ -0,0 +1,271 @@
<!--
The type hierarchy: what this type is built on, and what is built on it
(design spec §3.10).
It sits above the members outline because it changes how the outline reads. A
method on a class that implements a twelve-member interface is not the same
object as a method on a class nothing extends: one is a contract you can break
for eleven other files, the other is a private detail. The tree says which
before the member list is on screen, and the outline's "overrides X" marks
come from the same walk.
Layout is arithmetic — fixed row height, fixed indent step — so the connectors
are drawn from two numbers rather than measured. `implements` is dashed and
`extends` solid; a synthesized edge (Go's implicit interface satisfaction) is
dashed wider and says where it was wired, exactly as the Flow strip draws a
synthesized hop.
-->
<script lang="ts">
import KindGlyph from '../KindGlyph.svelte';
import type { WireHierarchy, WireNodeDetail, WireNodeRef } from '../../lib/api';
import {
buildHierarchyModel,
connectorPath,
visibleHierarchy,
HIER_ROW_H,
} from '../../lib/hierarchy-model';
interface Props {
hierarchy: WireHierarchy;
focus: WireNodeDetail;
onopen: (node: WireNodeRef) => void;
}
let { hierarchy, focus, onopen }: Props = $props();
let expanded = $state(false);
let model = $derived(buildHierarchyModel(hierarchy, focus));
let view = $derived(visibleHierarchy(model, expanded));
// Reset the fold when the reader navigates to another type — an expanded fan
// left open across a navigation would silently apply to a different symbol.
$effect(() => {
focus.id;
expanded = false;
});
let counts = $derived(
[
hierarchy.ancestors.total > 0
? `${hierarchy.ancestors.total} above`
: '',
hierarchy.direct > 0 ? `${hierarchy.descendants.total} below` : '',
]
.filter(Boolean)
.join(' · ')
);
function title(row: (typeof view.rows)[number]): string {
const where = `${row.node.file}:${row.node.line}`;
if (!row.entry) return `${row.node.qualifiedName}${where}`;
const wiring = row.entry.synthesized
? ` — matched by ${row.entry.via ?? 'the resolver'}${row.entry.registeredAt ? ` at ${row.entry.registeredAt}` : ''}`
: '';
return `${row.node.qualifiedName}${where}${wiring}`;
}
</script>
<div class="subh">
<span>Type hierarchy</span>
<span class="n">{counts}</span>
<span class="hint">supertypes above · subtypes below</span>
</div>
{#if model.headline}
<p class="headline">{model.headline}</p>
{/if}
<div class="tree">
<div class="canvas" style:height={`${view.height}px`}>
<svg class="wires" width="100%" height={view.height} aria-hidden="true">
{#each view.connectors as c, i (i)}
<path
d={connectorPath(c)}
class:dashed={c.relation === 'implements'}
class:synth={c.synthesized}
/>
{/each}
</svg>
{#each view.rows as row (row.node.id + row.side)}
{#if row.side === 'focus'}
<div
class="row focus"
style:top={`${row.index * HIER_ROW_H}px`}
style:padding-left={`${row.indent + 18}px`}
>
<KindGlyph kind={row.node.kind} />
<span class="nm">{row.node.name}</span>
</div>
{:else}
<button
type="button"
class="row"
style:top={`${row.index * HIER_ROW_H}px`}
style:padding-left={`${row.indent + 18}px`}
onclick={() => onopen(row.node)}
title={title(row)}
>
<KindGlyph kind={row.node.kind} />
<span class="nm">{row.node.name}</span>
<span class="word">{row.word}</span>
{#if row.entry?.synthesized}
<span class="pill" title={row.entry.registeredAt ?? ''}>
via {row.entry.via ?? 'resolver'}
</span>
{/if}
{#if row.entry && row.entry.hiddenSubtypes > 0}
<span class="pill">+{row.entry.hiddenSubtypes} below</span>
{/if}
<span class="file">{row.node.file === focus.file ? 'same file' : row.node.file}</span>
</button>
{/if}
{/each}
</div>
</div>
{#if model.foldFrom !== null}
<button type="button" class="fold" onclick={() => (expanded = !expanded)}>
{expanded ? 'Fold' : `+${model.foldCount} more ${model.foldNoun}`}
</button>
{/if}
{#if model.note}
<div class="note">{model.note}</div>
{/if}
<style>
.subh {
display: flex;
align-items: baseline;
gap: 8px;
margin: 18px 0 4px;
font-weight: 600;
font-size: 13px;
}
.subh .n {
color: var(--ink-3);
font-weight: 400;
}
.subh .hint {
margin-left: auto;
color: var(--ink-3);
font-size: 11.5px;
font-weight: 400;
}
.headline {
margin: 0 0 6px;
color: var(--ink-2);
font-size: 12px;
}
.tree {
border-top: 1px solid var(--rule);
padding-top: 6px;
}
/* The one positioned box: rows and wires share its origin, so a row's y and
the y its connector lands on are the same arithmetic. */
.canvas {
position: relative;
}
.wires {
position: absolute;
top: 0;
left: 0;
overflow: visible;
pointer-events: none;
}
.wires path {
fill: none;
stroke: var(--ink-4);
stroke-width: 1;
}
.wires path.dashed {
stroke-dasharray: 4 3;
}
.wires path.synth {
stroke: var(--ink-3);
stroke-dasharray: 6 3;
}
.row {
position: absolute;
top: 0;
right: 0;
left: 0;
display: flex;
align-items: center;
gap: 8px;
height: 24px;
padding-right: 4px;
border: 1px solid transparent;
text-align: left;
}
button.row:hover {
background: var(--press);
}
.nm {
font: 12.5px var(--mono);
white-space: nowrap;
}
.row.focus {
color: var(--accent);
}
.row.focus .nm {
font-weight: 600;
}
.word {
color: var(--ink-3);
font-size: 11px;
white-space: nowrap;
}
.pill {
padding: 0 4px;
border: 1px solid var(--rule-soft);
color: var(--ink-3);
font: 10.5px var(--mono);
white-space: nowrap;
}
.file {
overflow: hidden;
margin-left: auto;
padding-left: 10px;
color: var(--ink-3);
font: 11px var(--mono);
text-overflow: ellipsis;
white-space: nowrap;
}
.fold {
margin-top: 6px;
padding: 3px 8px;
border: 1px solid var(--rule-soft);
color: var(--ink-2);
font-size: 11.5px;
}
.fold:hover {
background: var(--press);
}
.note {
padding: 8px 0;
color: var(--ink-3);
font-size: 11.5px;
}
</style>
+17
View File
@@ -111,6 +111,8 @@ export { default as DriftBanner } from './components/DriftBanner.svelte';
export { default as KindGlyph } from './components/KindGlyph.svelte';
/** Copy image / download SVG for a Flow strip or a Map layout. */
export { default as ExportButtons } from './components/ExportButtons.svelte';
/** Ancestors up, subtypes down, and the fan an interface call dispatches into. */
export { default as TypeHierarchy } from './components/symbol/TypeHierarchy.svelte';
/* ------------------------------------------------------------- the state -- */
@@ -174,6 +176,21 @@ export type {
FlowLinkLayout,
} from './lib/flow-model';
export {
buildHierarchyModel,
connectorPath,
visibleHierarchy,
HIER_FOLD_AT,
HIER_INDENT,
HIER_PORT_X,
HIER_ROW_H,
} from './lib/hierarchy-model';
export type {
HierarchyConnector,
HierarchyModel,
HierarchyRow,
} from './lib/hierarchy-model';
export { buildMapLayout, isEdgeVisible, moduleMetaLabel } from './lib/map-model';
export type {
MapEdgeLayout,
+271
View File
@@ -0,0 +1,271 @@
/**
* The type-hierarchy tree, laid out arithmetically (design spec §3.10).
*
* A tree of types has no natural coordinate that the code supplies — unlike the
* callee rail, which is anchored to the line that calls it, and unlike the Map,
* which is layered by dependency. So the one rule that keeps it honest is
* determinism: rows are a fixed height, indents are a fixed step, and the
* connectors are computed from those two numbers. Nothing is measured, nothing
* is simulated, and the same payload always draws the same picture.
*
* Direction is spatial, as everywhere else in the viewer: what this type is
* built ON sits above it, what is built on THIS sits below and to the right.
* The focus is the one accent row in between.
*/
import type { WireHierarchy, WireHierarchyNode, WireNodeDetail, WireNodeRef } from './wire';
/** Row height, in px. Fixed, so connector geometry is arithmetic. */
export const HIER_ROW_H = 24;
/** Indent per descendant level, in px (design spec §3.10). */
export const HIER_INDENT = 22;
/** Left edge of a row's kind glyph, measured from the row's own indent. */
export const HIER_GLYPH_X = 18;
/**
* Where a row's connector leaves it: the centre of its 16px kind glyph. Lines
* hang off the glyph rather than off the row, so the trunk of a fan reads as
* coming out of the type rather than out of the margin.
*/
export const HIER_PORT_X = HIER_GLYPH_X + 8;
/**
* Subtypes drawn before the rest fold away.
*
* The spec's rule is "≥ 12 descendants fold": twelve rows is about the point
* where a fan stops being a list you read and starts being a wall you scroll
* past, and the count in the fold's label is the part that actually matters
* once you are past it. Folding starts at the row AFTER this one — a fan of
* exactly twelve draws twelve rows rather than eleven and a "+0 more".
*/
export const HIER_FOLD_AT = 12;
/** One drawn row: a type, or the focus itself. */
export interface HierarchyRow {
/** `null` for the focus row, which is not a hierarchy entry. */
entry: WireHierarchyNode | null;
node: WireNodeRef;
/** Which half of the tree this row belongs to. */
side: 'ancestor' | 'focus' | 'descendant';
/** Horizontal offset in px. Ancestors and the focus sit at 0. */
indent: number;
/** Row index from the top of the block, before any fold is applied. */
index: number;
/** The relation word shown beside the row: "extends", "implements", "". */
word: string;
}
/** One orthogonal connector: down the vertical, then out along the horizontal. */
export interface HierarchyConnector {
/** Row index the segment starts at (the row nearer the top). */
fromIndex: number;
/** Row index it ends at. */
toIndex: number;
x: number;
/** Where the horizontal run ends. Equal to `x` when the two rows share an indent. */
toX: number;
relation: 'extends' | 'implements';
synthesized: boolean;
}
/** Everything the block draws, in one pass over the payload. */
export interface HierarchyModel {
rows: HierarchyRow[];
connectors: HierarchyConnector[];
/** Index of the focus row — the accent one. */
focusIndex: number;
/** Rows from this index on are behind the fold. `null` when nothing folds. */
foldFrom: number | null;
/** How many rows the fold hides. */
foldCount: number;
/** "implementations" / "subclasses" / "subtypes", chosen from what is folded. */
foldNoun: string;
/** The one-line claim above the tree, or `''` when there is nothing worth claiming. */
headline: string;
/** A note under the tree when the payload is not the whole truth. */
note: string;
}
/**
* Lay the tree out.
*
* Ancestors are emitted FARTHEST first so the focus's own parents end up
* adjacent to it — read top to bottom, the block goes from the most general
* type to the most specific. Descendants come out of the payload breadth-first
* already, and stay in that order: a fold that trims the tail then trims the
* deepest, least relevant end.
*/
export function buildHierarchyModel(
hierarchy: WireHierarchy,
focus: WireNodeDetail
): HierarchyModel {
const rows: HierarchyRow[] = [];
const ancestors = [...hierarchy.ancestors.items].sort(
(a, b) => b.depth - a.depth || a.name.localeCompare(b.name)
);
for (const entry of ancestors) {
rows.push({
entry,
node: entry,
side: 'ancestor',
indent: 0,
index: rows.length,
// The plain relation word, on both halves of the tree. It reads off the
// connector: this row is what the row below it extends or implements.
// The block's header says which half is which.
word: entry.relation,
});
}
const focusIndex = rows.length;
rows.push({ entry: null, node: focus, side: 'focus', indent: 0, index: focusIndex, word: '' });
for (const entry of hierarchy.descendants.items) {
rows.push({
entry,
node: entry,
side: 'descendant',
indent: entry.depth * HIER_INDENT,
index: rows.length,
word: entry.relation,
});
}
// Descendant elbows look their parent up here. Ancestors are deliberately
// excluded: a type that is somehow both above and below the focus (a cycle in
// generated code) must not make a subtype hang off a supertype row.
const byId = new Map(
rows.filter((r) => r.side !== 'ancestor').map((row) => [row.node.id, row] as const)
);
const connectors: HierarchyConnector[] = [];
// Ancestors: every row at indent 0, so each segment is a plain vertical to
// the row below it. The relation is carried by the row's own word as well —
// with two direct parents the line alone could not say which is which, and a
// connector is structure, not the claim.
for (let i = 0; i < focusIndex; i++) {
const row = rows[i];
const next = rows[i + 1];
if (!row?.entry || !next) continue;
connectors.push({
fromIndex: i,
toIndex: i + 1,
x: HIER_PORT_X,
toX: HIER_PORT_X,
relation: row.entry.relation,
synthesized: row.entry.synthesized,
});
}
// Descendants: an elbow from the parent row's vertical out to this row's glyph.
for (const row of rows) {
if (row.side !== 'descendant' || !row.entry) continue;
const parent = byId.get(row.entry.parentId) ?? rows[focusIndex];
if (!parent) continue;
connectors.push({
fromIndex: parent.index,
toIndex: row.index,
x: parent.indent + HIER_PORT_X,
// Stop two pixels short of the child's glyph, so the line meets the box
// instead of running under it.
toX: row.indent + HIER_GLYPH_X - 2,
relation: row.entry.relation,
synthesized: row.entry.synthesized,
});
}
const descendantCount = rows.length - focusIndex - 1;
const foldFrom = descendantCount > HIER_FOLD_AT ? focusIndex + 1 + HIER_FOLD_AT : null;
const folded = foldFrom === null ? [] : rows.slice(foldFrom);
return {
rows,
connectors,
focusIndex,
foldFrom,
foldCount: folded.length,
foldNoun: nounFor(folded.map((r) => r.entry).filter((e): e is WireHierarchyNode => !!e)),
headline: headlineFor(hierarchy, focus),
note: noteFor(hierarchy),
};
}
/**
* The word for a group of subtypes.
*
* "implementations" is what the spec asks for and what an interface's fan
* actually is; a fan of `extends` edges is a class family, and calling those
* implementations would be wrong in every language that has both.
*/
function nounFor(entries: readonly WireHierarchyNode[]): string {
if (entries.length === 0) return 'subtypes';
const implementsCount = entries.filter((e) => e.relation === 'implements').length;
if (implementsCount === entries.length) return 'implementations';
if (implementsCount === 0) return 'subclasses';
return 'subtypes';
}
/**
* The claim above the tree.
*
* The only claim worth making in a header is the one a reader cannot get by
* counting the rows: that a call through this type does not go anywhere in
* particular. Everything else the tree says for itself.
*/
function headlineFor(hierarchy: WireHierarchy, focus: WireNodeDetail): string {
if (hierarchy.polymorphic) {
return `A call through ${focus.name} dispatches to ${hierarchy.implementers} implementations — no single static target.`;
}
return '';
}
/** What the payload is NOT saying, when it is not saying all of it. */
function noteFor(hierarchy: WireHierarchy): string {
const parts: string[] = [];
if (hierarchy.descendants.truncated) {
parts.push(
`Showing ${hierarchy.descendants.shown} of ${hierarchy.descendants.total} subtypes`
);
} else if (hierarchy.bounded) {
parts.push('Deeper subtypes exist below the levels walked');
}
if (hierarchy.ancestors.truncated) {
parts.push(`${hierarchy.ancestors.total - hierarchy.ancestors.shown} more supertypes above`);
}
return parts.join(' · ');
}
/**
* What is on screen for a given fold state.
*
* Connectors are filtered to the rows that are actually drawn, so a folded fan
* never leaves a line running off into the fold's own label. The height is
* arithmetic — {@link HIER_ROW_H} per row — which is the whole reason this
* block needs no `ResizeObserver`.
*/
export function visibleHierarchy(
model: HierarchyModel,
expanded: boolean
): { rows: HierarchyRow[]; connectors: HierarchyConnector[]; height: number } {
const count = expanded || model.foldFrom === null ? model.rows.length : model.foldFrom;
return {
rows: model.rows.slice(0, count),
connectors: model.connectors.filter((c) => c.toIndex < count && c.fromIndex < count),
height: count * HIER_ROW_H,
};
}
/**
* The SVG path for one connector: down, then out. Two straight runs and a
* corner — never a curve, because a hierarchy is not a flow and a Bézier here
* would read as one.
*/
export function connectorPath(c: HierarchyConnector): string {
const y0 = c.fromIndex * HIER_ROW_H + HIER_ROW_H / 2;
const y1 = c.toIndex * HIER_ROW_H + HIER_ROW_H / 2;
if (c.toX <= c.x) return `M ${c.x} ${y0} L ${c.x} ${y1}`;
return `M ${c.x} ${y0} L ${c.x} ${y1} L ${c.toX} ${y1}`;
}
+44
View File
@@ -56,6 +56,48 @@ export interface WireMember extends WireNodeRef {
depth: number;
fanIn: number;
fanOut: number;
/** This member redeclares one an ancestor type declares. */
overrides?: WireOverride;
}
/** How a subtype is tied to the type above it. */
export type WireHierarchyRelation = 'extends' | 'implements';
/** A member that redeclares an ancestor's — a name match inside a linked chain. */
export interface WireOverride {
baseId: string;
baseTypeId: string;
baseTypeName: string;
relation: WireHierarchyRelation;
}
/** One type in the hierarchy tree, and the single edge that puts it there. */
export interface WireHierarchyNode extends WireNodeRef {
/** Steps from the focus, in whichever direction the row sits. 1 = direct. */
depth: number;
/** The row this one hangs off — the focus's id at depth 1. */
parentId: string;
relation: WireHierarchyRelation;
/** Synthesized rather than parsed (Go's implicit interface satisfaction). */
synthesized: boolean;
via?: string;
registeredAt?: string;
/** Direct subtypes of this row that are NOT in the payload. */
hiddenSubtypes: number;
}
/** Ancestors up, subtypes down, and the fan an interface call dispatches into. */
export interface WireHierarchy {
ancestors: WireList<WireHierarchyNode>;
descendants: WireList<WireHierarchyNode>;
/** True number of DIRECT subtypes, whatever `descendants` was capped to. */
direct: number;
/** Of `direct`, the ones tied by `implements`. */
implementers: number;
/** Subtypes exist below what the walk returned. */
bounded: boolean;
/** A call through this type dispatches at runtime rather than to one target. */
polymorphic: boolean;
}
export interface WireEdge {
@@ -124,6 +166,8 @@ export interface WireSymbolPayload {
/** Outermost first: file, then module/class, then the symbol's own parent. */
ancestors: WireNodeRef[];
members: WireList<WireMember>;
/** The type-hierarchy block. `null` for anything that is not a type, and for a type with none. */
hierarchy: WireHierarchy | null;
incoming: WireList<WireRelation>;
outgoing: WireList<WireRelation>;
typesUsed: WireRelation[];
+6 -1
View File
@@ -20,6 +20,7 @@
import Connectors from '../components/symbol/Connectors.svelte';
import BlastStrip from '../components/symbol/BlastStrip.svelte';
import MembersOutline from '../components/symbol/MembersOutline.svelte';
import TypeHierarchy from '../components/symbol/TypeHierarchy.svelte';
import SourceBlock from '../components/symbol/SourceBlock.svelte';
import SymbolHeader from '../components/symbol/SymbolHeader.svelte';
import DriftBanner from '../components/DriftBanner.svelte';
@@ -560,7 +561,7 @@
<Connectors {connectors} width={overlay.width} height={overlay.height} />
<section class="center" bind:this={centerEl}>
<SymbolHeader {payload} onopen={open} />
<SymbolHeader {payload} onopen={open} relationChips={!payload.hierarchy} />
{#if payload.drift}
<div class="banner">
@@ -578,6 +579,10 @@
</div>
{/if}
{#if payload.hierarchy}
<TypeHierarchy hierarchy={payload.hierarchy} focus={payload.node} onopen={open} />
{/if}
{#if codeBlock}
<SourceBlock
block={codeBlock}