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