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:
@@ -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}
|
||||
{/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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user