Mercurial
view hg-web/src/components/graph.tsx @ 267:a78f5986011f
discover bundled production runtimes
Locate Bazel-packaged Copilot and Python binaries by stable suffix instead of hardcoding an external repository prefix.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Fri, 07 Aug 2026 12:58:55 -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 };