feat(graph): centrality + cluster analytics (corpus graph PR B)

The Obsidian "Graph Analysis" equivalent — surfaces influence and structure
beyond raw citation count.

Backend (new web/graph_metrics.py — pure, dependency-free, no DB → G2):
- PageRank (power-iteration), betweenness (Brandes), community (deterministic
  label-propagation + connected-components fallback), computed in-memory over
  the precedent citation subgraph that build_corpus_graph already fetched.
  Normalized 0–1; community ints dense-ranked by size (stable colours).
- GraphNode += pagerank/betweenness/community (None unless metrics=true).
- build_corpus_graph + /api/graph/corpus gain metrics=false (default path
  unchanged). Validated on the live corpus: 147 nodes in 13ms.

Frontend:
- graph.ts: GraphNode metrics fields + metrics param.
- graph-canvas: color-by (type | practice_area | precedent_level | community |
  recency) and size-by (in-degree | pagerank | betweenness) via colorForNode /
  radiusForNode; exported palettes.
- graph-view: colorBy/sizeBy controls; metrics requested only when needed;
  global metrics overlaid onto neighborhood nodes by id (a node's PageRank
  shouldn't change when focused); a ranking panel (Tabs: המשפיעות / גשרים,
  click → focus); dynamic legend per color-by.
- graph-filter-panel: "צביעה לפי" + "גודל נקודה לפי" Selects.

web-ui build + lint pass. Invariants: G2 (metrics pure, no DB writes),
UI2 (model grows on explicit Pydantic). api:types post-deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 21:04:47 +00:00
parent 106ab53231
commit 2fbc0cd3c2
7 changed files with 497 additions and 19 deletions

View File

@@ -37,6 +37,14 @@ type FGLink = {
treatment: string | null;
};
export type ColorBy =
| "type"
| "practice_area"
| "community"
| "precedent_level"
| "recency";
export type SizeBy = "indegree" | "pagerank" | "betweenness";
const NODE_COLORS: Record<string, string> = {
precedent: "#1e3a5f", // navy
halacha: "#b45309", // amber
@@ -51,8 +59,66 @@ const TREATMENT_COLORS: Record<string, string> = {
distinguished: "#d97706",
};
function nodeRadius(n: GraphNode): number {
// Distinct, cyclic palette for community (cluster) colouring.
export const COMMUNITY_PALETTE = [
"#1e3a5f", "#a97d3a", "#3b7a57", "#8c3b4a", "#5b4b8a", "#b06a2c",
"#2f6f7a", "#9a4f6a", "#46688a", "#7d6b3a", "#6b7280", "#4d7c5a",
];
// Authority hierarchy: עליון darkest → ועדת ערר lightest.
export const LEVEL_COLORS: Record<string, string> = {
עליון: "#13294b",
מנהלי: "#3b6ea5",
ועדת_ערר_מחוזית: "#8fb0cf",
};
export const PA_COLORS: Record<string, string> = {
rishuy_uvniya: "#1e3a5f",
betterment_levy: "#a97d3a",
compensation_197: "#3b7a57",
};
const FALLBACK_COLOR = "#94a3b8";
/** Old (slate) → recent (gold) gradient over 19942026. */
export function recencyColor(dateStr: string | null): string {
if (!dateStr) return FALLBACK_COLOR;
const y = Number(dateStr.slice(0, 4));
if (!y) return FALLBACK_COLOR;
const t = Math.max(0, Math.min(1, (y - 1994) / (2026 - 1994)));
const oldC = [100, 116, 139];
const newC = [169, 125, 58];
const c = oldC.map((o, i) => Math.round(o + (newC[i] - o) * t));
return `rgb(${c[0]},${c[1]},${c[2]})`;
}
export function colorForNode(n: GraphNode, colorBy: ColorBy): string {
// Hubs always keep their type colour — only precedents recolour.
if (n.type !== "precedent") return NODE_COLORS[n.type] ?? FALLBACK_COLOR;
switch (colorBy) {
case "practice_area":
return PA_COLORS[n.practice_area ?? ""] ?? FALLBACK_COLOR;
case "community":
return n.community != null
? COMMUNITY_PALETTE[n.community % COMMUNITY_PALETTE.length]
: FALLBACK_COLOR;
case "precedent_level":
return LEVEL_COLORS[n.precedent_level ?? ""] ?? FALLBACK_COLOR;
case "recency":
return recencyColor(n.date);
default:
return NODE_COLORS.precedent;
}
}
export function radiusForNode(n: GraphNode, sizeBy: SizeBy): number {
if (n.type === "topic" || n.type === "practice_area") return 5;
if (sizeBy === "pagerank" && n.pagerank != null) {
return 3 + Math.sqrt(n.pagerank) * 18;
}
if (sizeBy === "betweenness" && n.betweenness != null) {
return 3 + Math.sqrt(n.betweenness) * 18;
}
return Math.min(22, 3 + Math.sqrt(Math.max(0, n.size)) * 1.7);
}
@@ -76,10 +142,14 @@ export function GraphCanvas({
data,
selectedId,
onNodeClick,
colorBy = "type",
sizeBy = "indegree",
}: {
data: CorpusGraph | undefined;
selectedId: string | null;
onNodeClick: (node: GraphNode) => void;
colorBy?: ColorBy;
sizeBy?: SizeBy;
}) {
const { ref, size } = useElementSize<HTMLDivElement>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -143,9 +213,9 @@ export function GraphCanvas({
const drawNode = useCallback(
(node: FGNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
const r = nodeRadius(node);
const r = radiusForNode(node, sizeBy);
const dimmed = isDimmed(node.id);
const color = NODE_COLORS[node.type] ?? "#64748b";
const color = colorForNode(node, colorBy);
ctx.globalAlpha = dimmed ? 0.18 : 1;
ctx.beginPath();
@@ -194,17 +264,17 @@ export function GraphCanvas({
}
ctx.globalAlpha = 1;
},
[activeId, isDimmed],
[activeId, isDimmed, colorBy, sizeBy],
);
const drawPointerArea = useCallback(
(node: FGNode, color: string, ctx: CanvasRenderingContext2D) => {
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(node.x ?? 0, node.y ?? 0, nodeRadius(node) + 2, 0, 2 * Math.PI);
ctx.arc(node.x ?? 0, node.y ?? 0, radiusForNode(node, sizeBy) + 2, 0, 2 * Math.PI);
ctx.fill();
},
[],
[sizeBy],
);
const linkColor = useCallback(