Mercurial
view 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 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 };