view hg-web/src/components/graph.tsx @ 225:70de0c80d093 hg-web

[hg-web] Add side-by-side changeset diffs
author MrJuneJune <me@mrjunejune.com>
date Sun, 02 Aug 2026 14:41:09 -0700
parents 3007ef5fc0ed
children
line wrap: on
line source

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 [assetError, setAssetError] = useState<string | null>(null);

  const changesets = useMemo(() => 
    maxRows && data?.changesets ? data.changesets.slice(0, maxRows) : data?.changesets || [], [data, maxRows]);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas || !changesets.length) return;

    const ctx = canvas.getContext('2d');
    if (!ctx) return;

    let cancelled = false;
    let pencilPattern: CanvasPattern | null = null;
    let loadedAssets = 0;
    const pencilImage = new Image();
    const pandaImage = new Image();
    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(pandaImage, x-10, y-10, 20, 20);
      });
    };

    const handleAssetLoad = () => {
      loadedAssets++;
      if (loadedAssets !== 2 || cancelled) return;
      pencilPattern = ctx.createPattern(pencilImage, "repeat");
      renderCanvas();
    };

    const handleAssetError = () => {
      if (!cancelled) setAssetError('Unable to load the graph artwork.');
    };

    setAssetError(null);
    pencilImage.onload = handleAssetLoad;
    pencilImage.onerror = handleAssetError;
    pandaImage.onload = handleAssetLoad;
    pandaImage.onerror = handleAssetError;
    pencilImage.src = "/pencil_lines.png";
    pandaImage.src = "/panda.png";

    return () => {
      cancelled = true;
      pencilImage.onload = null;
      pencilImage.onerror = null;
      pandaImage.onload = null;
      pandaImage.onerror = null;
    };
  }, [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 className="graph-container">
      {assetError && <div className="error-message">{assetError}</div>}
      <div 
        ref={containerRef} 
        className="graph-wrapper"
      >
        <div className="graph-canvas-column">
          <canvas ref={canvasRef} style={{ display: 'block' }} />
        </div>

        <div className="graph-details-column">
          {changesets.map((cs) => (
            <button
              type="button"
              key={cs.node}
              className="graph-row"
              onClick={() => onCommitClick?.(cs.node)}
              aria-label={`Open changeset ${cs.node.substring(0, 12)}: ${cs.desc}`}
            >
              <span className="graph-row-meta">
                <span className="graph-hash">{cs.node.substring(0, 12)}</span>
                <span className="graph-user">{cs.user.split(' <')[0]}</span>
                {cs.branch && <span className="graph-branch">{cs.branch}</span>}
              </span>
              <span className="graph-desc">{cs.desc}</span>
            </button>
          ))}
          <div id="infinite-scroll-sentinel" style={{ height: '50px' }} />
        </div>
      </div>
      
      {loading && <div className="graph-loading-row">Loading repository history...</div>}
    </div>
  );
};

export { Graph, useGraphData };
export type { GraphData, Changeset, UseGraphDataResult };