-
-
-
- {t("sources.title", "참고 소스 설정")}
-
- {project && (
-
- {project.name}{" "}
-
- · {project.domain}
-
-
- )}
+ const sources = project?.sources ?? [];
+
+ const columns: ColumnDef
[] = [
+ {
+ id: "name",
+ header: t("sources.name", "이름"),
+ accessorFn: (row) => row.name,
+ cell: ({ row }) => (
+
+
+
+
+ {row.original.name}
-
-
+ ),
+ size: 240,
+ enablePinning: true,
+ },
+ {
+ id: "type",
+ header: t("sources.type", "타입"),
+ accessorFn: (row) => row.type,
+ cell: ({ row }) => (
+
+ {row.original.type}
+
+ ),
+ size: 120,
+ },
+ {
+ id: "base_url",
+ header: "Base URL",
+ accessorFn: (row) => row.base_url ?? "",
+ enableSorting: false,
+ cell: ({ row }) =>
+ row.original.base_url ? (
+
e.stopPropagation()}
+ className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground hover:underline"
+ >
+
+
+ {row.original.base_url}
+
+
+ ) : (
+
—
+ ),
+ size: 280,
+ },
+ {
+ id: "trust",
+ header: t("sources.trust", "신뢰도"),
+ accessorFn: (row) => toNumber(row.trust_level) ?? 0,
+ cell: ({ row }) => {
+ const n = toNumber(row.original.trust_level);
+ return n == null ? (
+
—
+ ) : (
+
+ );
+ },
+ size: 140,
+ meta: { align: "right" },
+ },
+ {
+ id: "rate",
+ header: t("sources.rateLimit", "rate/분"),
+ accessorFn: (row) => row.rate_limit_per_minute ?? 0,
+ cell: ({ row }) => (
+
+ {row.original.rate_limit_per_minute ?? "—"}
+
+ ),
+ size: 110,
+ meta: { align: "right" },
+ },
+ {
+ id: "actions",
+ header: "",
+ enableSorting: false,
+ enableHiding: false,
+ enableResizing: false,
+ cell: ({ row }) => (
+
+
+
+ ),
+ size: 60,
+ meta: { align: "right" },
+ },
+ ];
- {isError && (
-
-
-
-
-
{(error as Error).message}
-
-
-
- )}
+
navigate(`/crawl/${projectName}`)}
+ disabled={!project || sources.length === 0}
+ >
+ {t("sources.next", "크롤 진행")}
+
+
+ >
+ }
+ />
-
-
-
-
- {t("sources.listTitle", "등록된 소스")}
-
- {t(
- "sources.listDesc",
- "프로젝트 온톨로지 구축에 사용할 참고 사이트 목록",
- )}
-
-
-
- {isLoading && (
-
- {Array.from({ length: 3 }).map((_, i) => (
-
- ))}
-
+
+ {isError && (
+
refetch()}
+ />
+ )}
+
+
+ tableId="sources"
+ columns={columns}
+ data={sources}
+ getRowId={(row) => row.id}
+ loading={isLoading}
+ loadingRows={4}
+ height={560}
+ empty={
+
- {t(
- "sources.empty",
- "아직 등록된 소스가 없습니다. 오른쪽 폼에서 추가하세요.",
- )}
-
- )}
-
- {project && project.sources.length > 0 && (
-
- {project.sources.map((s) => (
- -
-
-
- {s.name}
-
- {s.type}
-
- {typeof s.trust_level === "number" && (
-
- {t("sources.trust", "신뢰도")}{" "}
- {s.trust_level.toFixed(2)}
-
- )}
-
- {s.base_url && (
-
-
- {s.base_url}
-
- )}
-
- onDelete(s.name)}
- disabled={deleteSource.isPending}
- aria-label={t("sources.delete", "삭제")}
- >
-
-
-
- ))}
-
- )}
-
-
-
-
-
+ }
+ />
+ }
+ />
+
+
+ setAddOpen(false)}
+ >
+ {t("common.cancel", "취소")}
+
+
+ {createSource.isPending ? (
+
+ ) : (
+
+ )}
+ {t("sources.add", "소스 추가")}
+
+ >
+ }
+ >
+
+
+ >
+ );
+}
+
+function TrustBar({ value }: { value: number }) {
+ const pct = Math.round(value * 100);
+ const tone =
+ value >= 0.75 ? "bg-success" : value >= 0.4 ? "bg-warning" : "bg-danger";
+ return (
+
+
+
+ {value.toFixed(2)}
+
);
}
diff --git a/ontology_platform/web/frontend/src/pages/GraphViewPage.tsx b/ontology_platform/web/frontend/src/pages/GraphViewPage.tsx
index d6c0871..fd2322d 100644
--- a/ontology_platform/web/frontend/src/pages/GraphViewPage.tsx
+++ b/ontology_platform/web/frontend/src/pages/GraphViewPage.tsx
@@ -2,28 +2,45 @@ import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
AlertCircle,
- ArrowLeft,
CircleDot,
+ Eye,
+ EyeOff,
GitBranch,
+ Keyboard,
+ Layers,
+ Map as MapIcon,
Network,
RefreshCw,
- Search,
- SlidersHorizontal,
} from "lucide-react";
import {
Card,
CardContent,
- CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
-import { Select } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
+import { Drawer } from "@/components/ui/drawer";
+import { EmptyState } from "@/components/ui/empty-state";
+import { Tooltip } from "@/components/ui/tooltip";
+import {
+ FilterPanel,
+ FilterSearch,
+ FilterSelect,
+ FilterRange,
+ FilterToggle,
+ FilterChips,
+ type ActiveChip,
+} from "@/components/ui/filter-panel";
+import {
+ ShortcutsOverlay,
+ type ShortcutGroup,
+} from "@/components/ui/shortcuts-overlay";
+import { PageHeader } from "@/components/layout/PageHeader";
import { useGraphNeighborhood } from "@/hooks/usePlatform";
+import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";
import { useProject } from "@/hooks/useProjects";
import { GraphEdge, GraphNode } from "@/lib/api/platform";
import { formatDateTime, formatPercent, humanizeValue } from "@/lib/display";
@@ -39,6 +56,9 @@ const COLORS = [
"#dc2626",
];
+const VIEW_W = 860;
+const VIEW_H = 600;
+
interface VisualNode {
id: string;
name: string;
@@ -150,8 +170,8 @@ function buildGraph(
visualNodes = visualNodes.filter((node) => connectedIds.has(node.id));
}
- const centerX = 430;
- const centerY = 300;
+ const centerX = VIEW_W / 2;
+ const centerY = VIEW_H / 2;
const radius = Math.max(160, Math.min(260, visualNodes.length * 13));
visualNodes.forEach((node, index) => {
const angle = (Math.PI * 2 * index) / Math.max(visualNodes.length, 1);
@@ -179,6 +199,12 @@ export default function GraphViewPage() {
const [entityType, setEntityType] = useState("");
const [minConfidence, setMinConfidence] = useState(0);
const [selected, setSelected] = useState(null);
+ const [drawerOpen, setDrawerOpen] = useState(false);
+ const [hiddenTypes, setHiddenTypes] = useState>(new Set());
+ const [showLegend, setShowLegend] = useState(true);
+ const [showMinimap, setShowMinimap] = useState(true);
+ const [shortcutsOpen, setShortcutsOpen] = useState(false);
+
const graph = useGraphNeighborhood(projectName, {
includeCandidates: includeCandidates && statusScope === "validated_claim",
limit: 300,
@@ -212,297 +238,428 @@ export default function GraphViewPage() {
search,
],
);
- const averageConfidence = visual.edges.length
- ? visual.edges.reduce((sum, edge) => sum + edge.confidence, 0) /
- visual.edges.length
+
+ // Filter out types the user toggled off in the legend
+ const visibleNodes = useMemo(
+ () => visual.nodes.filter((n) => !hiddenTypes.has(n.type)),
+ [visual.nodes, hiddenTypes],
+ );
+ const visibleNodeIds = useMemo(
+ () => new Set(visibleNodes.map((n) => n.id)),
+ [visibleNodes],
+ );
+ const visibleEdges = useMemo(
+ () =>
+ visual.edges.filter(
+ (e) => visibleNodeIds.has(e.source) && visibleNodeIds.has(e.target),
+ ),
+ [visual.edges, visibleNodeIds],
+ );
+ const typeCounts = useMemo(() => {
+ const counts = new Map();
+ visual.nodes.forEach((n) =>
+ counts.set(n.type, (counts.get(n.type) ?? 0) + 1),
+ );
+ return counts;
+ }, [visual.nodes]);
+
+ const averageConfidence = visibleEdges.length
+ ? visibleEdges.reduce((sum, edge) => sum + edge.confidence, 0) /
+ visibleEdges.length
: 0;
- return (
-
-
-
navigate(`/quality/${projectName}`)}
- aria-label="Back"
- >
-
-
-
-
-
- Graph View
-
-
- {project?.name ?? projectName} relation graph, candidates, and edge evidence.
-
-
-
graph.refetch()}>
-
- Refresh
-
-
+ const selectNode = (node: VisualNode | VisualEdge) => {
+ setSelected(node);
+ setDrawerOpen(true);
+ };
- {graph.isError && (
-
-
+ const toggleType = (type: string) => {
+ setHiddenTypes((prev) => {
+ const next = new Set(prev);
+ if (next.has(type)) next.delete(type);
+ else next.add(type);
+ return next;
+ });
+ };
+
+ // Page-level shortcuts (disabled while drawer or overlay is open)
+ useKeyboardShortcuts(
+ {
+ l: () => setShowLegend((v) => !v),
+ m: () => setShowMinimap((v) => !v),
+ r: () => graph.refetch(),
+ "Shift+?": () => setShortcutsOpen(true),
+ Escape: () => {
+ if (drawerOpen) {
+ setDrawerOpen(false);
+ setSelected(null);
+ }
+ },
+ },
+ { disabled: shortcutsOpen },
+ );
+
+ const pageShortcutGroups: ShortcutGroup[] = [
+ {
+ title: "Graph View",
+ items: [
+ { keys: "l", label: "Toggle legend" },
+ { keys: "m", label: "Toggle minimap" },
+ { keys: "r", label: "Refresh graph" },
+ { keys: "Escape", label: "Close detail drawer" },
+ ],
+ },
+ {
+ title: "Global",
+ items: [
+ { keys: "Mod+K", label: "Command palette" },
+ { keys: "Shift+?", label: "Show this panel" },
+ { keys: "g g", label: "Jump to Graph View" },
+ ],
+ },
+ ];
+
+ return (
+ <>
+
+ graph.refetch()}>
+
+ Refresh
+
+
+ }
+ />
+
+
+ {graph.isError && (
+
{(graph.error as Error).message}
-
-
- )}
+
+ )}
-
-
-
- setSearch(event.target.value)}
- className="pl-9"
- placeholder="Search node or type"
- />
-
-
-
-
-
-
+ {/* Filter bar — unified FilterPanel */}
+ {(() => {
+ const statusLabels: Record
= {
+ validated_claim: "Validated",
+ candidate_claim: "Candidate",
+ rule_candidate: "Rule candidate",
+ active: "Active",
+ all: "All statuses",
+ };
+ const chips: ActiveChip[] = [];
+ if (search)
+ chips.push({
+ id: "search",
+ label: "Search",
+ value: `"${search}"`,
+ onClear: () => setSearch(""),
+ });
+ if (entityType)
+ chips.push({
+ id: "type",
+ label: "Type",
+ value: entityType,
+ onClear: () => setEntityType(""),
+ });
+ if (predicate)
+ chips.push({
+ id: "predicate",
+ label: "Predicate",
+ value: predicate,
+ onClear: () => setPredicate(""),
+ });
+ if (statusScope !== "validated_claim")
+ chips.push({
+ id: "status",
+ label: "Status",
+ value: statusLabels[statusScope] ?? statusScope,
+ onClear: () => setStatusScope("validated_claim"),
+ });
+ if (minConfidence > 0)
+ chips.push({
+ id: "conf",
+ label: "Min confidence",
+ value: formatPercent(minConfidence),
+ onClear: () => setMinConfidence(0),
+ });
+ if (!includeCandidates && statusScope === "validated_claim")
+ chips.push({
+ id: "blend",
+ label: "Blend candidates",
+ value: "off",
+ onClear: () => setIncludeCandidates(true),
+ });
-
-
-
-
- Minimum confidence
-
- {formatPercent(minConfidence)}
-
-
setMinConfidence(Number(event.target.value))}
- className="w-full"
- />
-
+ const clearAll = () => {
+ setSearch("");
+ setEntityType("");
+ setPredicate("");
+ setStatusScope("validated_claim");
+ setMinConfidence(0);
+ setIncludeCandidates(true);
+ };
-
-
-
-
-
- Ontology Network
-
- {visual.nodes.length} nodes / {visual.edges.length} edges shown
-
-
-
- {visual.types.slice(0, 8).map((type) => (
-
-
- {type}
-
- ))}
-
+ return (
+
0 ? : undefined}
+ onClearAll={chips.length > 0 ? clearAll : undefined}
+ >
+
+ ({
+ value: t,
+ label: t,
+ hint: typeCounts.get(t),
+ }))}
+ />
+ ({ value: p, label: p }))}
+ />
+ setStatusScope(v || "validated_claim")}
+ placeholder="Validated"
+ allowEmpty
+ options={Object.entries(statusLabels)
+ .filter(([k]) => k !== "validated_claim")
+ .map(([k, v]) => ({ value: k, label: v }))}
+ />
+ `${Math.round(v * 100)}%`}
+ />
+
+
+ );
+ })()}
+
+ {/* Canvas */}
+
+
+
+
+ Ontology Network
+
+
+ {visibleNodes.length} nodes / {visibleEdges.length} edges shown
+
-
-
+
+
+ setShowLegend((v) => !v)}
+ >
+
+
+
+
+ setShowMinimap((v) => !v)}
+ >
+
+
+
+
+ setShortcutsOpen(true)}
+ >
+
+
+
+
+
+
+
{graph.isLoading ? (
- ) : visual.nodes.length === 0 ? (
-
-
- No graph is available for the current filters.
-
+ ) : visibleNodes.length === 0 ? (
+
) : (
-
+
+ {/* Legend overlay (top-right) */}
+ {showLegend && (
+
+ )}
+
+ {/* Minimap overlay (bottom-right) */}
+ {showMinimap && visibleNodes.length > 0 && (
+
+ )}
+ >
)}
-
-
+
+
-
+
+ {/* Node / Edge detail drawer */}
+ {
+ setDrawerOpen(open);
+ if (!open) setSelected(null);
+ }}
+ size="md"
+ title={
+ selected
+ ? "predicate" in selected
+ ? `Edge · ${selected.predicate}`
+ : `Node · ${selected.name}`
+ : ""
+ }
+ description={
+ selected && "predicate" in selected
+ ? "Claim evidence and confidence"
+ : "Entity details and metadata"
+ }
+ headerExtra={
+ selected && (
+
+ {"predicate" in selected ? selected.predicate : selected.type}
+
+ )
+ }
+ footer={
+ {
+ setDrawerOpen(false);
+ setSelected(null);
+ }}
+ >
+ Close
+
+ }
+ >
+ {selected && "predicate" in selected && (
+
+
+
+
+
+
+
+
+ )}
+ {selected && !("predicate" in selected) && (
+
+
+
+
+
+ navigate(`/editor/${projectName}`)}
+ >
+ Open in Entity Manager
+
+
+ )}
+
+
+
+ >
+ );
+}
+
+/* ============================================================
+ * Legend Panel — overlay, toggle visibility per type
+ * ========================================================== */
+function LegendPanel({
+ types,
+ typeCounts,
+ hiddenTypes,
+ onToggle,
+}: {
+ types: string[];
+ typeCounts: Map;
+ hiddenTypes: Set;
+ onToggle: (type: string) => void;
+}) {
+ return (
+
+
+ Legend
+
+
+ {types.map((type) => {
+ const hidden = hiddenTypes.has(type);
+ const count = typeCounts.get(type) ?? 0;
+ return (
+ -
+ onToggle(type)}
+ className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors hover:bg-accent/60"
+ aria-pressed={!hidden}
+ >
+
+
+ {type}
+
+
+ {count}
+
+ {hidden ? (
+
+ ) : (
+
+ )}
+
+
+ );
+ })}
+
+
+ );
+}
+
+/* ============================================================
+ * Minimap — scaled overview of the whole graph
+ * ========================================================== */
+function Minimap({
+ nodes,
+ edges,
+}: {
+ nodes: VisualNode[];
+ edges: VisualEdge[];
+}) {
+ const W = 180;
+ const H = 130;
+ // Bounds of visible nodes (with padding so circles don't clip)
+ const pad = 24;
+ const xs = nodes.map((n) => n.x);
+ const ys = nodes.map((n) => n.y);
+ const minX = Math.min(...xs) - pad;
+ const maxX = Math.max(...xs) + pad;
+ const minY = Math.min(...ys) - pad;
+ const maxY = Math.max(...ys) + pad;
+ const w = Math.max(maxX - minX, 1);
+ const h = Math.max(maxY - minY, 1);
+ const scale = Math.min(W / w, H / h);
+ const offsetX = (W - w * scale) / 2;
+ const offsetY = (H - h * scale) / 2;
+ const tx = (x: number) => (x - minX) * scale + offsetX;
+ const ty = (y: number) => (y - minY) * scale + offsetY;
+
+ return (
+
+
+ Minimap
+
+ {nodes.length}·{edges.length}
+
+
+
+ {edges.map((e) => {
+ const s = nodes.find((n) => n.id === e.source);
+ const t = nodes.find((n) => n.id === e.target);
+ if (!s || !t) return null;
+ return (
+
+ );
+ })}
+ {nodes.map((n) => (
+
+ ))}
+
);
}
function Detail({ label, value }: { label: string; value: unknown }) {
return (
-
-
{label}
-
{humanizeValue(value)}
+
+
+ {label}
+
+
+ {humanizeValue(value)}
+
);
}
@@ -559,7 +949,7 @@ function HealthRow({ label, value }: { label: string; value: number | string })
return (
{label}
-
+
{typeof value === "number" ? value.toLocaleString() : value}
diff --git a/ontology_platform/web/frontend/src/pages/ReviewPage.tsx b/ontology_platform/web/frontend/src/pages/ReviewPage.tsx
index 46487fc..6c2a54c 100644
--- a/ontology_platform/web/frontend/src/pages/ReviewPage.tsx
+++ b/ontology_platform/web/frontend/src/pages/ReviewPage.tsx
@@ -4,6 +4,7 @@ import { toast } from "sonner";
import {
AlertCircle,
ArrowLeft,
+ ArrowRight,
Check,
Eye,
Filter,
@@ -11,6 +12,7 @@ import {
Search,
X,
} from "lucide-react";
+import { cn } from "@/lib/utils";
import {
Card,
CardContent,
@@ -168,23 +170,23 @@ export default function ReviewPage() {
-
-
+
+
-
-
+
+
Review Queue
신뢰도, 출처, 생성 방식, 검증 결과를 함께 확인합니다.
-
+
setSearch(event.target.value)}
- className="w-56 pl-9"
+ className="w-48 pl-9 sm:w-56"
placeholder="Search claims"
/>
@@ -193,7 +195,7 @@ export default function ReviewPage() {