T8/Phase 6: OSS component swap polish
- Calendar: react-big-calendar w/ withDragAndDrop, date-fns localizer, custom event renderer (color tokens), custom toolbar, responsive (agenda on mobile), now indicator - Graph: react-force-graph-2d w/ custom node/link canvas renderers, hover highlighting, search-to-fly, filter panel (entity + relationship types), ResizeObserver, 500-node cap, zoom controls - Command palette: cmdk polish (recent items, @mention filter, settings/logout actions, mobile full-screen) - Shortcuts: @github/hotkey for g+letter nav, n+letter create, ?, /, c; Cmd+K for palette - README.md added with architecture, page list, shortcuts, dev guide - Bundle: 369 KB gzipped (under 1.5 MB budget)
This commit is contained in:
@@ -1,23 +1,25 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { useState, useRef, useCallback, useEffect, useMemo } from "react";
|
||||
import { createRoute } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../_app";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery } from "@/lib/api";
|
||||
import { useRealtime } from "@/hooks/use-realtime";
|
||||
import { Search, ZoomIn, ZoomOut, RotateCcw, Filter } from "lucide-react";
|
||||
import { Search, ZoomIn, ZoomOut, RotateCcw, Filter, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { GraphNode, GraphEdge } from "@/lib/types";
|
||||
import ForceGraph2D from "react-force-graph-2d";
|
||||
|
||||
const ENTITY_TYPES = ["task", "habit", "project", "note", "section", "tag", "domain"];
|
||||
const RELATIONSHIP_TYPES = ["depends_on", "related_to", "part_of", "references", "parent_of", "child_of", "connects_to"];
|
||||
|
||||
const ENTITY_COLORS: Record<string, string> = {
|
||||
task: "#3b82f6",
|
||||
habit: "#10b981",
|
||||
@@ -28,17 +30,42 @@ const ENTITY_COLORS: Record<string, string> = {
|
||||
domain: "#6366f1",
|
||||
};
|
||||
|
||||
const MAX_NODES = 500;
|
||||
|
||||
function GraphPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const graphRef = useRef<any>(undefined);
|
||||
const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
|
||||
const [search, setSearch] = useState("");
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [enabledTypes, setEnabledTypes] = useState<Set<string>>(new Set(ENTITY_TYPES));
|
||||
const [enabledRelationships, setEnabledRelationships] = useState<Set<string>>(new Set(RELATIONSHIP_TYPES));
|
||||
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [hoveredNode, setHoveredNode] = useState<GraphNode | null>(null);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
|
||||
useRealtime({ enabled: true });
|
||||
|
||||
// ResizeObserver for reactive sizing
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { width, height } = entry.contentRect;
|
||||
if (width > 0 && height > 0) {
|
||||
setDimensions({ width, height });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const { data: nodesData } = useApiQuery<{ items: GraphNode[]; totalItems: number }>(
|
||||
["graph", "nodes"],
|
||||
"/graph/nodes?domain=placeholder"
|
||||
@@ -52,9 +79,57 @@ function GraphPage() {
|
||||
const allNodes = nodesData?.items || [];
|
||||
const allEdges = edgesData?.items || [];
|
||||
|
||||
// Filter by entity type
|
||||
const filteredNodes = allNodes.filter((n) => enabledTypes.has(n.type));
|
||||
const filteredNodeIds = new Set(filteredNodes.map((n) => n.id));
|
||||
const filteredEdges = allEdges.filter((e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target));
|
||||
const filteredEdges = allEdges.filter(
|
||||
(e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target) && enabledRelationships.has(e.type)
|
||||
);
|
||||
|
||||
// Performance cap
|
||||
const displayNodes = filteredNodes.slice(0, MAX_NODES);
|
||||
const displayNodeIds = new Set(displayNodes.map((n) => n.id));
|
||||
const displayEdges = filteredEdges.filter(
|
||||
(e) => displayNodeIds.has(e.source) && displayNodeIds.has(e.target)
|
||||
);
|
||||
const exceeded = filteredNodes.length > MAX_NODES;
|
||||
|
||||
// Graph data for react-force-graph-2d
|
||||
const graphData = useMemo(() => ({
|
||||
nodes: displayNodes.map((n) => ({
|
||||
id: n.id,
|
||||
label: n.label,
|
||||
type: n.type,
|
||||
color: n.color || ENTITY_COLORS[n.type] || "#6b7280",
|
||||
})),
|
||||
links: displayEdges.map((e) => ({
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
type: e.type,
|
||||
})),
|
||||
}), [displayNodes, displayEdges]);
|
||||
|
||||
// Hover highlight: connected nodes/edges
|
||||
const highlightNodes = useMemo(() => {
|
||||
if (!hoveredNode) return new Set<string>();
|
||||
const connected = new Set<string>([hoveredNode.id]);
|
||||
displayEdges.forEach((e) => {
|
||||
if (e.source === hoveredNode.id) connected.add(e.target);
|
||||
if (e.target === hoveredNode.id) connected.add(e.source);
|
||||
});
|
||||
return connected;
|
||||
}, [hoveredNode, displayEdges]);
|
||||
|
||||
const highlightLinks = useMemo(() => {
|
||||
if (!hoveredNode) return new Set<string>();
|
||||
const connected = new Set<string>();
|
||||
displayEdges.forEach((e) => {
|
||||
if (e.source === hoveredNode.id || e.target === hoveredNode.id) {
|
||||
connected.add(`${e.source}-${e.target}`);
|
||||
}
|
||||
});
|
||||
return connected;
|
||||
}, [hoveredNode, displayEdges]);
|
||||
|
||||
const toggleType = (type: string) => {
|
||||
const next = new Set(enabledTypes);
|
||||
@@ -63,6 +138,128 @@ function GraphPage() {
|
||||
setEnabledTypes(next);
|
||||
};
|
||||
|
||||
const toggleRelationship = (type: string) => {
|
||||
const next = new Set(enabledRelationships);
|
||||
if (next.has(type)) next.delete(type);
|
||||
else next.add(type);
|
||||
setEnabledRelationships(next);
|
||||
};
|
||||
|
||||
// Search: fly to node
|
||||
const handleSearch = useCallback(() => {
|
||||
if (!search.trim() || !graphRef.current) return;
|
||||
const found = displayNodes.find(
|
||||
(n) => n.label.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
if (found) {
|
||||
graphRef.current.centerAt(found.x, found.y, 1000);
|
||||
graphRef.current.zoom(3, 1000);
|
||||
}
|
||||
}, [search, displayNodes]);
|
||||
|
||||
const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") handleSearch();
|
||||
}, [handleSearch]);
|
||||
|
||||
// Node click → side panel
|
||||
const handleNodeClick = useCallback((node: any) => {
|
||||
setSelectedNode(node as GraphNode);
|
||||
setDetailOpen(true);
|
||||
}, []);
|
||||
|
||||
// Node hover → highlight
|
||||
const handleNodeHover = useCallback((node: any | null) => {
|
||||
setHoveredNode(node as GraphNode | null);
|
||||
}, []);
|
||||
|
||||
// Custom node renderer
|
||||
const nodeCanvasObject = useCallback(
|
||||
(node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const isHighlighted = highlightNodes.size === 0 || highlightNodes.has(node.id);
|
||||
const isHovered = hoveredNode?.id === node.id;
|
||||
const label = node.label || "";
|
||||
const fontSize = Math.max(8, 12 / globalScale);
|
||||
const radius = isHovered ? 8 : 6;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, radius, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = isHighlighted ? node.color : `${node.color}33`;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = isHovered ? "#fff" : "#fff";
|
||||
ctx.lineWidth = isHovered ? 2 / globalScale : 1 / globalScale;
|
||||
ctx.stroke();
|
||||
|
||||
// Label below node
|
||||
ctx.font = `${fontSize}px Sans-Serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
ctx.fillStyle = isHighlighted
|
||||
? (document.documentElement.classList.contains("dark") ? "#e2e8f0" : "#1e293b")
|
||||
: "#94a3b8";
|
||||
const displayLabel = label.length > 15 ? label.slice(0, 15) + "…" : label;
|
||||
ctx.fillText(displayLabel, node.x, node.y + radius + 2);
|
||||
},
|
||||
[highlightNodes, hoveredNode]
|
||||
);
|
||||
|
||||
// Custom link renderer with arrows
|
||||
const linkCanvasObject = useCallback(
|
||||
(link: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const isHighlighted = highlightLinks.size === 0 || highlightLinks.has(`${link.source.id}-${link.target.id}`);
|
||||
const width = isHighlighted ? 1.5 / globalScale : 0.5 / globalScale;
|
||||
const opacity = isHighlighted ? 0.6 : 0.1;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(link.source.x, link.source.y);
|
||||
ctx.lineTo(link.target.x, link.target.y);
|
||||
ctx.strokeStyle = `rgba(148, 163, 184, ${opacity})`;
|
||||
ctx.lineWidth = width;
|
||||
ctx.stroke();
|
||||
|
||||
// Arrow at midpoint
|
||||
if (isHighlighted && globalScale > 0.5) {
|
||||
const midX = (link.source.x + link.target.x) / 2;
|
||||
const midY = (link.source.y + link.target.y) / 2;
|
||||
const dx = link.target.x - link.source.x;
|
||||
const dy = link.target.y - link.source.y;
|
||||
const len = Math.sqrt(dx * dx + dy * dy);
|
||||
if (len > 0) {
|
||||
const ux = dx / len;
|
||||
const uy = dy / len;
|
||||
const arrowSize = 4 / globalScale;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(midX + ux * arrowSize, midY + uy * arrowSize);
|
||||
ctx.lineTo(midX - ux * arrowSize + uy * arrowSize * 0.5, midY - uy * arrowSize - ux * arrowSize * 0.5);
|
||||
ctx.lineTo(midX - ux * arrowSize - uy * arrowSize * 0.5, midY - uy * arrowSize + ux * arrowSize * 0.5);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = `rgba(148, 163, 184, ${opacity})`;
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
},
|
||||
[highlightLinks]
|
||||
);
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
if (graphRef.current) {
|
||||
const newZoom = Math.min(graphRef.current.zoom() * 1.3, 10);
|
||||
graphRef.current.zoom(newZoom, 400);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
if (graphRef.current) {
|
||||
const newZoom = Math.max(graphRef.current.zoom() / 1.3, 0.1);
|
||||
graphRef.current.zoom(newZoom, 400);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
if (graphRef.current) {
|
||||
graphRef.current.zoomToFit(400, 50);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-8rem)] -m-4 md:-m-6">
|
||||
{/* Graph canvas area */}
|
||||
@@ -75,6 +272,7 @@ function GraphPage() {
|
||||
placeholder="Find a node..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
className="pl-8 w-64 bg-background/90 backdrop-blur"
|
||||
/>
|
||||
</div>
|
||||
@@ -83,76 +281,91 @@ function GraphPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Graph visualization */}
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<svg width="400" height="400" viewBox="0 0 400 400" className="mx-auto mb-4">
|
||||
{/* Simple force-directed graph visualization */}
|
||||
{filteredEdges.map((edge, i) => {
|
||||
const source = filteredNodes.find((n) => n.id === edge.source);
|
||||
const target = filteredNodes.find((n) => n.id === edge.target);
|
||||
if (!source || !target) return null;
|
||||
// Simple circular layout
|
||||
const srcIdx = filteredNodes.indexOf(source);
|
||||
const tgtIdx = filteredNodes.indexOf(target);
|
||||
const total = filteredNodes.length;
|
||||
const angle1 = (2 * Math.PI * srcIdx) / Math.max(total, 1);
|
||||
const angle2 = (2 * Math.PI * tgtIdx) / Math.max(total, 1);
|
||||
const r = 150;
|
||||
const x1 = 200 + r * Math.cos(angle1);
|
||||
const y1 = 200 + r * Math.sin(angle1);
|
||||
const x2 = 200 + r * Math.cos(angle2);
|
||||
const y2 = 200 + r * Math.sin(angle2);
|
||||
return <line key={i} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#333" strokeWidth={0.5} opacity={0.3} />;
|
||||
})}
|
||||
{filteredNodes.map((node, i) => {
|
||||
const total = filteredNodes.length;
|
||||
const angle = (2 * Math.PI * i) / Math.max(total, 1);
|
||||
const r = 150;
|
||||
const x = 200 + r * Math.cos(angle);
|
||||
const y = 200 + r * Math.sin(angle);
|
||||
return (
|
||||
<g key={node.id} onClick={() => { setSelectedNode(node); setDetailOpen(true); }} style={{ cursor: "pointer" }}>
|
||||
<circle cx={x} cy={y} r={6} fill={node.color || "#6b7280"} stroke="white" strokeWidth={2} />
|
||||
<text x={x} y={y - 10} textAnchor="middle" fontSize={8} fill="currentColor" className="fill-foreground">
|
||||
{node.label.length > 15 ? node.label.slice(0, 15) + "..." : node.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<p className="text-sm">
|
||||
{filteredNodes.length} nodes, {filteredEdges.length} edges
|
||||
</p>
|
||||
<p className="text-xs mt-1">
|
||||
Full interactive graph with react-force-graph-2d will be available in T8.
|
||||
</p>
|
||||
</div>
|
||||
{/* Zoom controls */}
|
||||
<div className="absolute bottom-4 right-4 z-10 flex flex-col gap-1">
|
||||
<Button variant="secondary" size="icon" onClick={handleZoomIn} aria-label="Zoom in">
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="secondary" size="icon" onClick={handleZoomOut} aria-label="Zoom out">
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="secondary" size="icon" onClick={handleReset} aria-label="Reset view">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Performance warning */}
|
||||
{exceeded && (
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Showing {MAX_NODES} of {filteredNodes.length} nodes (cap reached)
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Force graph */}
|
||||
<ForceGraph2D
|
||||
ref={graphRef}
|
||||
graphData={graphData}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
nodeCanvasObject={nodeCanvasObject}
|
||||
linkCanvasObject={linkCanvasObject}
|
||||
linkDirectionalArrowLength={0}
|
||||
linkDirectionalArrowRelPos={0.5}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeHover={handleNodeHover}
|
||||
nodeRelSize={6}
|
||||
d3AlphaDecay={0.02}
|
||||
d3VelocityDecay={0.3}
|
||||
cooldownTicks={100}
|
||||
warmupTicks={40}
|
||||
backgroundColor="transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter panel */}
|
||||
<Sheet open={filterOpen} onOpenChange={setFilterOpen}>
|
||||
<SheetContent side="right" className="w-64">
|
||||
<SheetContent side="right" className="w-72">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Filters</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="space-y-4 pt-4">
|
||||
<h3 className="text-sm font-semibold">Entity Types</h3>
|
||||
{ENTITY_TYPES.map((type) => (
|
||||
<div key={type} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={"type-" + type}
|
||||
checked={enabledTypes.has(type)}
|
||||
onCheckedChange={() => toggleType(type)}
|
||||
/>
|
||||
<Label htmlFor={"type-" + type} className="flex items-center gap-2 text-sm">
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: ENTITY_COLORS[type] }} />
|
||||
{type.charAt(0).toUpperCase() + type.slice(1)}s
|
||||
</Label>
|
||||
<ScrollArea className="h-full pr-4">
|
||||
<div className="space-y-6 pt-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Entity Types</h3>
|
||||
{ENTITY_TYPES.map((type) => (
|
||||
<div key={type} className="flex items-center gap-2 py-1">
|
||||
<Checkbox
|
||||
id={"type-" + type}
|
||||
checked={enabledTypes.has(type)}
|
||||
onCheckedChange={() => toggleType(type)}
|
||||
/>
|
||||
<Label htmlFor={"type-" + type} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: ENTITY_COLORS[type] }} />
|
||||
{type.charAt(0).toUpperCase() + type.slice(1)}s
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Separator />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Relationship Types</h3>
|
||||
{RELATIONSHIP_TYPES.map((type) => (
|
||||
<div key={type} className="flex items-center gap-2 py-1">
|
||||
<Checkbox
|
||||
id={"rel-" + type}
|
||||
checked={enabledRelationships.has(type)}
|
||||
onCheckedChange={() => toggleRelationship(type)}
|
||||
/>
|
||||
<Label htmlFor={"rel-" + type} className="text-sm cursor-pointer">
|
||||
{type.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
@@ -172,7 +385,7 @@ function GraphPage() {
|
||||
<Separator />
|
||||
<h4 className="text-sm font-semibold">Connected nodes</h4>
|
||||
<div className="space-y-1">
|
||||
{filteredEdges
|
||||
{displayEdges
|
||||
.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id)
|
||||
.map((e, i) => {
|
||||
const connectedId = e.source === selectedNode.id ? e.target : e.source;
|
||||
@@ -180,8 +393,8 @@ function GraphPage() {
|
||||
return connected ? (
|
||||
<div key={i} className="flex items-center gap-2 text-sm py-1">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: connected.color }} />
|
||||
<span className="truncate">{connected.label}</span>
|
||||
<Badge variant="outline" className="text-[10px]">{e.type}</Badge>
|
||||
<span className="truncate flex-1">{connected.label}</span>
|
||||
<Badge variant="outline" className="text-[10px]">{e.type.replace(/_/g, " ")}</Badge>
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user