Mercurial
diff hg-web/src/components/graph.tsx @ 231:09a96dcb2b4c hg-web
[merge] Join existing hg-web branch head
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Sun, 02 Aug 2026 16:50:48 -0700 |
| parents | 3007ef5fc0ed |
| children |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/src/components/graph.tsx Sun Aug 02 16:50:48 2026 -0700 @@ -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 [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 };