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:
@@ -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 1994–2026. */
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user