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:
@@ -3,12 +3,20 @@
|
||||
/**
|
||||
* Corpus graph orchestrator. Owns filter + selection state, decides whether to
|
||||
* render the full graph or a focused node neighborhood (the Obsidian "local
|
||||
* graph"), and wires the filter sidebar, canvas, and node detail panel.
|
||||
* graph"), and wires the filter sidebar, canvas, ranking panel, and node panel.
|
||||
*
|
||||
* Analytics (PR B): when color-by=community or size-by=pagerank/betweenness,
|
||||
* `metrics=true` is requested on the FULL graph (global importance). The
|
||||
* neighborhood endpoint does not compute metrics — instead we overlay the
|
||||
* cached full-graph metrics onto neighborhood nodes by id, so a node's
|
||||
* PageRank doesn't change just because you zoomed into it.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
type CorpusGraph,
|
||||
type GraphNode,
|
||||
@@ -20,7 +28,13 @@ import {
|
||||
type GraphControls,
|
||||
GraphFilterPanel,
|
||||
} from "@/components/graph/graph-filter-panel";
|
||||
import { GraphCanvas } from "@/components/graph/graph-canvas";
|
||||
import {
|
||||
type ColorBy,
|
||||
COMMUNITY_PALETTE,
|
||||
GraphCanvas,
|
||||
LEVEL_COLORS,
|
||||
PA_COLORS,
|
||||
} from "@/components/graph/graph-canvas";
|
||||
import { GraphNodePanel } from "@/components/graph/graph-node-panel";
|
||||
|
||||
const NODE_LIMIT = 400;
|
||||
@@ -46,6 +60,8 @@ export function GraphView() {
|
||||
district: "",
|
||||
yearFrom: 0,
|
||||
yearTo: 0,
|
||||
colorBy: "type",
|
||||
sizeBy: "indegree",
|
||||
showTopics: true,
|
||||
showPracticeAreas: true,
|
||||
showHalachot: false,
|
||||
@@ -65,6 +81,10 @@ export function GraphView() {
|
||||
return t.join(",");
|
||||
}, [controls.showTopics, controls.showPracticeAreas, controls.showHalachot]);
|
||||
|
||||
// Metrics are needed when colouring by cluster or sizing by a centrality.
|
||||
const metricsOn =
|
||||
controls.sizeBy !== "indegree" || controls.colorBy === "community";
|
||||
|
||||
const debouncedQ = useDebouncedValue(controls.q, 350);
|
||||
|
||||
const filters = useMemo(
|
||||
@@ -81,6 +101,7 @@ export function GraphView() {
|
||||
district: controls.district,
|
||||
year_from: controls.yearFrom,
|
||||
year_to: controls.yearTo,
|
||||
metrics: metricsOn,
|
||||
}),
|
||||
[
|
||||
controls.practiceArea,
|
||||
@@ -92,19 +113,51 @@ export function GraphView() {
|
||||
controls.district,
|
||||
controls.yearFrom,
|
||||
controls.yearTo,
|
||||
metricsOn,
|
||||
nodeTypes,
|
||||
debouncedQ,
|
||||
],
|
||||
);
|
||||
|
||||
const isFocused = !!focusNodeId;
|
||||
const full = useCorpusGraph(filters, !isFocused);
|
||||
// Keep the full query alive when metrics are on so the overlay map stays warm.
|
||||
const full = useCorpusGraph(filters, !isFocused || metricsOn);
|
||||
const neighborhood = useNodeNeighborhood(focusNodeId, 1, nodeTypes);
|
||||
|
||||
const active = isFocused ? neighborhood : full;
|
||||
const data: CorpusGraph | undefined = active.data;
|
||||
const error = active.error as Error | undefined;
|
||||
|
||||
// Cache of global metrics by node id, overlaid onto neighborhood nodes.
|
||||
const metricsMap = useMemo(() => {
|
||||
const m = new Map<
|
||||
string,
|
||||
{ pagerank: number | null; betweenness: number | null; community: number | null }
|
||||
>();
|
||||
for (const n of full.data?.nodes ?? []) {
|
||||
if (n.pagerank != null || n.community != null) {
|
||||
m.set(n.id, {
|
||||
pagerank: n.pagerank,
|
||||
betweenness: n.betweenness,
|
||||
community: n.community,
|
||||
});
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}, [full.data]);
|
||||
|
||||
const data: CorpusGraph | undefined = useMemo(() => {
|
||||
if (!isFocused) return full.data;
|
||||
if (!neighborhood.data) return undefined;
|
||||
if (metricsMap.size === 0) return neighborhood.data;
|
||||
return {
|
||||
...neighborhood.data,
|
||||
nodes: neighborhood.data.nodes.map((n) => {
|
||||
const mv = metricsMap.get(n.id);
|
||||
return mv ? { ...n, ...mv } : n;
|
||||
}),
|
||||
};
|
||||
}, [isFocused, full.data, neighborhood.data, metricsMap]);
|
||||
|
||||
const handleNodeClick = (node: GraphNode) => {
|
||||
setSelectedNode(node);
|
||||
setFocusNodeId(node.id);
|
||||
@@ -115,13 +168,13 @@ export function GraphView() {
|
||||
setSelectedNode(null);
|
||||
};
|
||||
|
||||
const showRanking = metricsOn && !selectedNode && (full.data?.nodes.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3 text-xs text-ink-muted">
|
||||
<span>
|
||||
{data
|
||||
? `${data.nodes.length} נקודות · ${data.edges.length} קשרים`
|
||||
: "—"}
|
||||
{data ? `${data.nodes.length} נקודות · ${data.edges.length} קשרים` : "—"}
|
||||
</span>
|
||||
{!isFocused && full.data?.truncated && (
|
||||
<span className="text-gold-deep">
|
||||
@@ -158,6 +211,8 @@ export function GraphView() {
|
||||
data={data}
|
||||
selectedId={selectedNode?.id ?? null}
|
||||
onNodeClick={handleNodeClick}
|
||||
colorBy={controls.colorBy}
|
||||
sizeBy={controls.sizeBy}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -172,23 +227,123 @@ export function GraphView() {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Legend />
|
||||
<Legend colorBy={controls.colorBy} />
|
||||
</div>
|
||||
|
||||
{selectedNode && (
|
||||
{selectedNode ? (
|
||||
<GraphNodePanel node={selectedNode} onClose={() => setSelectedNode(null)} />
|
||||
)}
|
||||
) : showRanking ? (
|
||||
<RankingPanel nodes={full.data!.nodes} onPick={handleNodeClick} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend() {
|
||||
const items = [
|
||||
function RankingPanel({
|
||||
nodes,
|
||||
onPick,
|
||||
}: {
|
||||
nodes: GraphNode[];
|
||||
onPick: (n: GraphNode) => void;
|
||||
}) {
|
||||
const precedents = nodes.filter((n) => n.type === "precedent");
|
||||
const byPagerank = [...precedents]
|
||||
.filter((n) => n.pagerank != null)
|
||||
.sort((a, b) => (b.pagerank ?? 0) - (a.pagerank ?? 0))
|
||||
.slice(0, 12);
|
||||
const byBetweenness = [...precedents]
|
||||
.filter((n) => n.betweenness != null)
|
||||
.sort((a, b) => (b.betweenness ?? 0) - (a.betweenness ?? 0))
|
||||
.slice(0, 12);
|
||||
|
||||
return (
|
||||
<Card className="bg-surface border-rule shadow-sm w-72 shrink-0 overflow-y-auto">
|
||||
<CardContent className="p-4">
|
||||
<Tabs defaultValue="pagerank">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="pagerank" className="flex-1">
|
||||
המשפיעות
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="betweenness" className="flex-1">
|
||||
גשרים
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="pagerank">
|
||||
<RankList items={byPagerank} metric="pagerank" onPick={onPick} />
|
||||
</TabsContent>
|
||||
<TabsContent value="betweenness">
|
||||
<RankList items={byBetweenness} metric="betweenness" onPick={onPick} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function RankList({
|
||||
items,
|
||||
metric,
|
||||
onPick,
|
||||
}: {
|
||||
items: GraphNode[];
|
||||
metric: "pagerank" | "betweenness";
|
||||
onPick: (n: GraphNode) => void;
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return <p className="text-ink-muted text-xs mt-3">אין נתונים.</p>;
|
||||
}
|
||||
return (
|
||||
<ol className="mt-2 space-y-1">
|
||||
{items.map((n, i) => (
|
||||
<li key={n.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPick(n)}
|
||||
className="flex w-full items-baseline justify-between gap-2 rounded px-2 py-1 text-start text-sm hover:bg-gold-wash"
|
||||
>
|
||||
<span className="truncate">
|
||||
<span className="text-ink-muted text-xs">{i + 1}.</span> {n.label}
|
||||
</span>
|
||||
<span className="text-ink-muted text-xs tabular-nums shrink-0">
|
||||
{((n[metric] ?? 0) * 100).toFixed(0)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
const LEGENDS: Record<ColorBy, { color: string; label: string }[]> = {
|
||||
type: [
|
||||
{ color: "#1e3a5f", label: "פסיקה" },
|
||||
{ color: "#a97d3a", label: "נושא" },
|
||||
{ color: "#475569", label: "תחום" },
|
||||
];
|
||||
],
|
||||
practice_area: [
|
||||
{ color: PA_COLORS.rishuy_uvniya, label: "רישוי ובנייה" },
|
||||
{ color: PA_COLORS.betterment_levy, label: "היטל השבחה" },
|
||||
{ color: PA_COLORS.compensation_197, label: "פיצויים" },
|
||||
],
|
||||
precedent_level: [
|
||||
{ color: LEVEL_COLORS["עליון"], label: "עליון" },
|
||||
{ color: LEVEL_COLORS["מנהלי"], label: "מנהלי" },
|
||||
{ color: LEVEL_COLORS["ועדת_ערר_מחוזית"], label: "ועדת ערר" },
|
||||
],
|
||||
community: [
|
||||
{ color: COMMUNITY_PALETTE[0], label: "אשכול עיקרי" },
|
||||
{ color: COMMUNITY_PALETTE[1], label: "אשכול נוסף" },
|
||||
{ color: COMMUNITY_PALETTE[2], label: "אשכול נוסף" },
|
||||
],
|
||||
recency: [
|
||||
{ color: "rgb(100,116,139)", label: "ישן (1994)" },
|
||||
{ color: "rgb(169,125,58)", label: "עדכני (2026)" },
|
||||
],
|
||||
};
|
||||
|
||||
function Legend({ colorBy }: { colorBy: ColorBy }) {
|
||||
const items = LEGENDS[colorBy] ?? LEGENDS.type;
|
||||
return (
|
||||
<div className="absolute bottom-3 end-3 flex flex-col gap-1 rounded-md bg-surface/85 backdrop-blur px-3 py-2 text-xs text-ink-muted">
|
||||
{items.map((i) => (
|
||||
|
||||
Reference in New Issue
Block a user