diff hg-web/src/components/graph.tsx @ 195:f8f5004a920a

Merging back hg-web-tip
author MrJuneJune <me@mrjunejune.com>
date Tue, 27 Jan 2026 06:51:44 -0800
parents fb28063dc490
children
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/src/components/graph.tsx	Tue Jan 27 06:51:44 2026 -0800
@@ -0,0 +1,313 @@
+import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
+
+// Configuration constants for the layout
+const rowHeight = 40;
+const colWidth = 20;
+const nodeRadius = 4.5;
+
+// --- Interfaces ---
+
+interface Changeset {
+  node: string;
+  date: [number, number];
+  desc: string;
+  branch: string;
+  bookmarks: string[];
+  tags: string[];
+  user: string;
+  phase: string;
+  col: number;
+  row: number;
+  color: number;
+  edges: Array<{
+    bcolor: string;
+    col: number;
+    color: number;
+    nextcol: number;
+    width: number;
+  }>;
+  parents: string[];
+}
+
+interface GraphData {
+  node: string;
+  changeset_count: number;
+  changesets: Changeset[];
+}
+
+interface UseGraphDataOptions {
+  initialCommit?: string | null;
+  graphTop?: string | null;
+}
+
+interface UseGraphDataResult {
+  data: GraphData | null;
+  loading: boolean;
+  error: string | null;
+  loadMore: () => void;
+  hasMore: boolean;
+  tip: string | null;
+  currentCommit: string | null;
+}
+
+// --- Hook Logic ---
+
+function useGraphData({ initialCommit = null, graphTop = null }: UseGraphDataOptions = {}): UseGraphDataResult {
+  const [data, setData] = useState<GraphData | null>(null);
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+  const [tip, setTip] = useState<string | null>(graphTop);
+  const [currentCommit, setCurrentCommit] = useState<string | null>(initialCommit);
+  const [hasMore, setHasMore] = useState(true);
+
+  const fetchData = useCallback(async (commit: string | null, tipNode: string | null, append: boolean = false) => {
+    if (loading) return;
+    setLoading(true);
+    setError(null);
+
+    try {
+      const url = !commit 
+        ? `/api/graph/tip?style=json` 
+        : `/api/graph/${commit}?graphtop=${tipNode}&style=json`;
+
+      const response = await fetch(url);
+      if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
+      
+      const result: GraphData = await response.json();
+
+      setData(prev => {
+        if (append && prev) {
+          const existingNodes = new Set(prev.changesets.map(cs => cs.node));
+          const newChangesets = result.changesets.filter(cs => !existingNodes.has(cs.node));
+          
+          // Re-index rows to ensure they increment correctly for the canvas height
+          const startRow = prev.changesets.length;
+          const reindexed = newChangesets.map((cs, idx) => ({
+            ...cs,
+            row: startRow + idx
+          }));
+
+          return {
+            ...result,
+            changesets: [...prev.changesets, ...reindexed]
+          };
+        }
+        return result;
+      });
+
+      if (!tip && !append) setTip(result.node);
+
+      if (result.changesets.length > 0) {
+        const lastNode = result.changesets[result.changesets.length - 1].node;
+        setCurrentCommit(lastNode);
+        setHasMore(result.changesets.length >= 30);
+      } else {
+        setHasMore(false);
+      }
+    } catch (err: any) {
+      setError(err.message);
+    } finally {
+      setLoading(false);
+    }
+  }, [tip, loading]);
+
+  useEffect(() => {
+    fetchData(initialCommit, graphTop, false);
+  }, [initialCommit, graphTop]);
+
+  const loadMore = useCallback(() => {
+    if (!loading && hasMore && currentCommit && tip) {
+      fetchData(currentCommit, tip, true);
+    }
+  }, [loading, hasMore, currentCommit, tip, fetchData]);
+
+  return { data, loading, error, loadMore, hasMore, tip, currentCommit };
+}
+
+// --- Pencil Rendering Logic ---
+
+const drawPencilLine = (
+  ctx: CanvasRenderingContext2D,
+  x1: number, y1: number,
+  x2: number, y2: number,
+  texture: CanvasPattern | null, // Ensure type safety
+  isCurve: boolean = false
+) => {
+  const strokes = 3;
+  ctx.save();
+
+  for (let s = 0; s < strokes; s++) {
+    ctx.beginPath();
+    ctx.strokeStyle = texture;
+    ctx.globalAlpha = 0.2 + (s * 0.2); 
+    ctx.lineWidth = 1.5 - (s * 0.2); // Pencil lines are usually thinner
+
+    // 2. Realistic Jitter: Actually return a random small number
+    const jitter = () => (Math.random() - 0.5) * 1.5;
+
+    ctx.moveTo(x1 + jitter(), y1 + jitter());
+
+    if (isCurve) {
+      const cpY = y1 + (y2 - y1) / 2;
+      ctx.bezierCurveTo(
+        x1 + jitter(), cpY + jitter(), 
+        x2 + jitter(), cpY + jitter(), 
+        x2 + jitter(), y2 + jitter()
+      );
+    } else {
+      ctx.lineTo(x2 + jitter(), y2 + jitter());
+    }
+    
+    ctx.stroke();
+  }
+  ctx.restore();
+}
+
+// --- Main Component ---
+
+interface GraphProps {
+  data: GraphData | null;
+  loading?: boolean;
+  hasMore?: boolean;
+  onLoadMore?: () => void;
+  onCommitClick?: (node: string) => void;
+  maxRows?: number;
+}
+
+const Graph = ({ data, loading, hasMore, onLoadMore, onCommitClick, maxRows }: GraphProps) => {
+  const canvasRef = useRef<HTMLCanvasElement>(null);
+  const containerRef = useRef<HTMLDivElement>(null);
+
+  const changesets = useMemo(() => 
+    maxRows && data?.changesets ? data.changesets.slice(0, maxRows) : data?.changesets || [], [data, maxRows]);
+
+  let pencilPattern;
+  const img = new Image();
+  img.src = "http://localhost:6970/pencil_lines.png";
+
+  const pandaImg = new Image();
+  pandaImg.src = "http://localhost:6970/panda.png";
+
+  useEffect(() => {
+    const canvas = canvasRef.current;
+    if (!canvas || !changesets.length) return;
+
+    const ctx = canvas.getContext('2d');
+    if (!ctx) return;
+
+    // Grab colors from CSS variables or defaults
+    const getColors = () => {
+      const s = getComputedStyle(document.documentElement);
+      return [
+        s.getPropertyValue('--graph-1').trim() || '#4dabf7',
+        s.getPropertyValue('--graph-2').trim() || '#63e6be',
+        s.getPropertyValue('--graph-3').trim() || '#ffbc42',
+        s.getPropertyValue('--graph-4').trim() || '#b197fc',
+        s.getPropertyValue('--graph-5').trim() || '#ff8787',
+        s.getPropertyValue('--graph-6').trim() || '#f06595',
+      ];
+    };
+
+    const colors = getColors();
+    const dpr = window.devicePixelRatio || 1;
+    const maxCol = Math.max(...changesets.map(cs => cs.col), 0);
+    const canvasWidth = (maxCol + 2) * colWidth;
+    
+    // Scale for high-DPI screens
+    canvas.width = canvasWidth * dpr;
+    canvas.height = changesets.length * rowHeight * dpr;
+    canvas.style.width = `${canvasWidth}px`;
+    canvas.style.height = `${changesets.length * rowHeight}px`;
+    ctx.scale(dpr, dpr);
+    ctx.clearRect(0, 0, canvasWidth, changesets.length * rowHeight);
+
+    const getX = (col: number) => (col + 1) * colWidth;
+    const getY = (row: number) => (row * rowHeight) + (rowHeight / 2);
+
+    const renderCanvas = () => {
+      if (!pencilPattern) return; // Don't draw if the pattern isn't ready
+    
+      // Pass 1: Draw Connecting Edges
+      changesets.forEach((cs, i) => {
+        if (!cs.edges) return;
+        cs.edges.forEach(edge => {
+          const sX = getX(edge.col), sY = getY(i);
+          const eX = getX(edge.nextcol), eY = getY(i + 1);
+          
+          drawPencilLine(ctx, sX, sY, eX, eY, pencilPattern, edge.col !== edge.nextcol);
+        });
+      });
+
+      // Pass 2: Draw Commit Nodes
+      changesets.forEach((cs, i) => {
+        const x = getX(cs.col), y = getY(i);
+        ctx.drawImage(pandaImg, x-10, y-10, 20, 20);
+      });
+    };
+
+    img.onload = () => {
+      pencilPattern = ctx.createPattern(img, "repeat")!;
+      renderCanvas(); 
+    };
+  }, [changesets]);
+
+  // Handle Infinite Scroll via Intersection Observer
+  useEffect(() => {
+    if (!onLoadMore || !hasMore) return;
+    const observer = new IntersectionObserver((entries) => {
+      if (entries[0].isIntersecting && !loading) {
+        onLoadMore();
+      }
+    }, { threshold: 0.1 });
+
+    const sentinel = document.getElementById('infinite-scroll-sentinel');
+    if (sentinel) observer.observe(sentinel);
+    return () => observer.disconnect();
+  }, [onLoadMore, hasMore, loading]);
+
+  return (
+    <div style={{ display: 'flex', flexDirection: 'column', height: '100%', backgroundImage: 'url("/hg-web-background.jpg")', fontFamily: 'monospace' }}>
+      <div 
+        ref={containerRef} 
+        style={{ display: 'flex', flex: 1, overflowY: 'auto', position: 'relative' }}
+      >
+        {/* Graph Column - Sticky to keep lines aligned with text during scroll */}
+        <div style={{ position: 'sticky', top: 0, height: 'fit-content', zIndex: 10, borderRight: '1px solid #333' }}>
+          <canvas ref={canvasRef} style={{ display: 'block' }} />
+        </div>
+
+        {/* Details Column */}
+        <div style={{ flex: 1 }}>
+          {changesets.map((cs) => (
+            <div 
+              key={cs.node} 
+              style={{ 
+                height: rowHeight, 
+                display: 'flex', 
+                alignItems: 'center', 
+                padding: '0 15px', 
+                borderBottom: '1px solid #252525', 
+                cursor: 'pointer',
+                fontSize: '13px',
+                whiteSpace: 'nowrap'
+              }}
+              onClick={() => onCommitClick?.(cs.node)}
+              onMouseEnter={(e) => (e.currentTarget.style.background = '#222')}
+              onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
+            >
+              <span style={{ color: '#4dabf7', width: '90px', flexShrink: 0 }}>{cs.node.substring(0, 12)}</span>
+              <span style={{ color: '#eee', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', paddingRight: '20px' }}>{cs.desc}</span>
+              <span style={{ color: '#888', width: '150px', textAlign: 'right' }}>{cs.user.split(' <')[0]}</span>
+            </div>
+          ))}
+          <div id="infinite-scroll-sentinel" style={{ height: '50px' }} />
+        </div>
+      </div>
+      
+      {loading && <div style={{ padding: '10px', textAlign: 'center', color: '#888', fontSize: '12px', background: '#111' }}>Loading repository history...</div>}
+    </div>
+  );
+};
+
+export { Graph, useGraphData };
+export type { GraphData, Changeset, UseGraphDataResult };