Enables the previously-disabled "הלכות" toggle. Each approved/published halacha of a displayed precedent becomes a hal:<id> node linked to its parent precedent (extracted_from); two cross-rule edges when both endpoints are in view: corroborates (a later ruling cites the rule — halacha_citation_corroboration) and equivalent (same principle from another committee — equivalent_halachot). Node size = corroboration in-degree. Backend (web/graph_api.py — read-only, G2): - _halacha_nodes_and_edges(): halachot WHERE case_law_id in view AND review_status IN (approved, published), LIMIT 600; rule_type carried in the source_kind slot, rule_statement in note. Wired into both build functions (gated via node_types). Metrics still exclude halacha edges (only cites/ precedent-typed feed PageRank). Validated: 185 halachot on the top-30 precedents; 20 corroboration + 5 equivalent edges in the corpus. Frontend: - graph.ts: GraphEdgeType += extracted_from. - graph-filter-panel: "הלכות" toggle enabled (was disabled "שלב ב׳"). - graph-canvas: amber halacha nodes; edge colours — extracted_from (faint amber), corroborates (amber), equivalent (violet). - graph-node-panel: halacha branch — אזכורים + סוג כלל + rule text; "open in library" deep-links to the parent precedent. - graph-view: halacha added to node + edge legends. web-ui build + lint pass. Invariants: G2 (SELECT-only), UI2 (no model change — reuses note/source_kind/case_law_id slots). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
207 lines
7.2 KiB
TypeScript
207 lines
7.2 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* Side panel shown when a node is selected. For precedent/halacha nodes it
|
||
* deep-links into the existing precedent library (/precedents/[id]) so the
|
||
* graph is a navigation surface, not a dead-end visualization.
|
||
*/
|
||
|
||
import Link from "next/link";
|
||
import { ExternalLink, X } from "lucide-react";
|
||
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent } from "@/components/ui/card";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import type { GraphNode } from "@/lib/api/graph";
|
||
import { usePrecedent } from "@/lib/api/precedent-library";
|
||
|
||
const TYPE_LABELS: Record<string, string> = {
|
||
precedent: "פסיקה",
|
||
halacha: "הלכה",
|
||
topic: "נושא",
|
||
practice_area: "תחום",
|
||
gap: "פסיקה חסרה",
|
||
digest: "יומון",
|
||
};
|
||
|
||
const RULE_TYPE_LABELS: Record<string, string> = {
|
||
binding: "מחייב",
|
||
interpretive: "פרשני",
|
||
procedural: "דיוני",
|
||
obiter: "אמרת אגב",
|
||
application: "יישום",
|
||
};
|
||
|
||
const GAP_STATUS_LABELS: Record<string, string> = {
|
||
open: "ממתינה לקליטה",
|
||
uploaded: "הועלתה",
|
||
closed: "טופלה",
|
||
irrelevant: "לא רלוונטית",
|
||
};
|
||
|
||
const PA_LABELS: Record<string, string> = {
|
||
rishuy_uvniya: "רישוי ובנייה",
|
||
betterment_levy: "היטל השבחה",
|
||
compensation_197: "פיצויים (ס׳ 197)",
|
||
appeals_committee: "ועדת ערר",
|
||
};
|
||
|
||
const SOURCE_LABELS: Record<string, string> = {
|
||
external_upload: "פסיקה חיצונית",
|
||
internal_committee: "החלטת ועדה",
|
||
cited_only: "מוזכר בלבד",
|
||
nevo_seed: "נבו",
|
||
};
|
||
|
||
export function GraphNodePanel({
|
||
node,
|
||
onClose,
|
||
}: {
|
||
node: GraphNode;
|
||
onClose: () => void;
|
||
}) {
|
||
const isPrecedentLike = node.type === "precedent" || node.type === "halacha";
|
||
const isPrecedent = node.type === "precedent";
|
||
const isHalacha = node.type === "halacha";
|
||
const isGap = node.type === "gap";
|
||
const isDigest = node.type === "digest";
|
||
// Rich detail (summary/headnote) for precedents — reuses the library hook.
|
||
const detailId = isPrecedent ? node.case_law_id : null;
|
||
const detail = usePrecedent(detailId);
|
||
return (
|
||
<Card className="bg-surface border-rule shadow-sm w-80 shrink-0 overflow-y-auto">
|
||
<CardContent className="space-y-4 p-4">
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div className="space-y-1">
|
||
<Badge variant="outline" className="text-[0.65rem]">
|
||
{TYPE_LABELS[node.type] ?? node.type}
|
||
</Badge>
|
||
<h2 className="text-navy text-base leading-snug m-0">{node.label}</h2>
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={onClose}
|
||
aria-label="סגור"
|
||
className="shrink-0"
|
||
>
|
||
<X className="size-4" />
|
||
</Button>
|
||
</div>
|
||
|
||
<dl className="space-y-2 text-sm">
|
||
{isPrecedent && <Row label="ציטוטים נכנסים" value={String(node.size)} />}
|
||
{isHalacha && (
|
||
<>
|
||
<Row label="אזכורים" value={String(node.size)} />
|
||
{node.source_kind && (
|
||
<Row
|
||
label="סוג כלל"
|
||
value={RULE_TYPE_LABELS[node.source_kind] ?? node.source_kind}
|
||
/>
|
||
)}
|
||
{node.note && (
|
||
<p className="text-ink text-sm leading-relaxed m-0">{node.note}</p>
|
||
)}
|
||
</>
|
||
)}
|
||
{node.practice_area && (
|
||
<Row label="תחום" value={PA_LABELS[node.practice_area] ?? node.practice_area} />
|
||
)}
|
||
{isPrecedent && node.source_kind && (
|
||
<Row label="מקור" value={SOURCE_LABELS[node.source_kind] ?? node.source_kind} />
|
||
)}
|
||
{node.precedent_level && <Row label="דרגה" value={node.precedent_level} />}
|
||
{isGap && (
|
||
<>
|
||
<Row label="מצוטטת ע״י" value={`${node.size} פסיקות בקורפוס`} />
|
||
{node.gap_status && (
|
||
<Row
|
||
label="סטטוס"
|
||
value={GAP_STATUS_LABELS[node.gap_status] ?? node.gap_status}
|
||
/>
|
||
)}
|
||
<p className="text-ink-muted text-xs leading-relaxed m-0">
|
||
פסיקה זו מצוטטת בקורפוס אך אינה קיימת בו — מועמדת לקליטה.
|
||
</p>
|
||
</>
|
||
)}
|
||
{isDigest && (
|
||
<>
|
||
{node.note && (
|
||
<p className="text-ink text-sm leading-relaxed m-0">{node.note}</p>
|
||
)}
|
||
{node.court && <Row label="ערכאה" value={node.court} />}
|
||
{node.date && <Row label="תאריך" value={node.date.slice(0, 10)} />}
|
||
<p className="text-ink-muted text-xs leading-relaxed m-0">
|
||
סיכום יומי מ״כל יום״ — מצביע על הפסיקה שהוא מנתח.
|
||
</p>
|
||
</>
|
||
)}
|
||
{!isPrecedentLike && !isGap && !isDigest && (
|
||
<p className="text-ink-muted text-xs leading-relaxed m-0">
|
||
לחיצה על נקודה זו מתמקדת בשכניה — כל הפסיקות המשויכות אליה.
|
||
</p>
|
||
)}
|
||
</dl>
|
||
|
||
{detailId && (
|
||
<div className="space-y-1.5 border-t border-rule pt-3">
|
||
{detail.isPending ? (
|
||
<>
|
||
<Skeleton className="h-3 w-full" />
|
||
<Skeleton className="h-3 w-4/5" />
|
||
</>
|
||
) : detail.error ? (
|
||
<p className="text-ink-muted text-xs m-0">לא ניתן לטעון תקציר.</p>
|
||
) : detail.data?.headnote || detail.data?.summary ? (
|
||
<p className="text-ink text-sm leading-relaxed m-0">
|
||
{detail.data.headnote || detail.data.summary}
|
||
</p>
|
||
) : (
|
||
<p className="text-ink-muted text-xs m-0">אין תקציר.</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{isPrecedentLike && node.case_law_id && (
|
||
<Button asChild variant="outline" className="w-full">
|
||
<Link href={`/precedents/${node.case_law_id}`}>
|
||
<ExternalLink className="size-4 me-2" />
|
||
פתח בספריית הפסיקה
|
||
</Link>
|
||
</Button>
|
||
)}
|
||
|
||
{isGap && (
|
||
<Button asChild variant="outline" className="w-full">
|
||
<Link href="/missing-precedents">
|
||
<ExternalLink className="size-4 me-2" />
|
||
לרשימת הפסיקה החסרה
|
||
</Link>
|
||
</Button>
|
||
)}
|
||
|
||
{isDigest && (
|
||
<Button asChild variant="outline" className="w-full">
|
||
<Link href="/digests">
|
||
<ExternalLink className="size-4 me-2" />
|
||
לעמוד היומונים
|
||
</Link>
|
||
</Button>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function Row({ label, value }: { label: string; value: string }) {
|
||
return (
|
||
<div className="flex items-baseline justify-between gap-3">
|
||
<dt className="text-ink-muted text-xs shrink-0">{label}</dt>
|
||
<dd className="text-ink text-end m-0">{value}</dd>
|
||
</div>
|
||
);
|
||
}
|