view hg-web/src/components/graph.tsx @ 213:60918f88070e

Simple change regarding to accessibility.
author MrJuneJune <me@mrjunejune.com>
date Sun, 15 Feb 2026 21:39:43 -0800
parents fb28063dc490
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 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 };