Mercurial
comparison 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 |
comparison
equal
deleted
inserted
replaced
| 189:14cc84ba35a0 | 195:f8f5004a920a |
|---|---|
| 1 import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'; | |
| 2 | |
| 3 // Configuration constants for the layout | |
| 4 const rowHeight = 40; | |
| 5 const colWidth = 20; | |
| 6 const nodeRadius = 4.5; | |
| 7 | |
| 8 // --- Interfaces --- | |
| 9 | |
| 10 interface Changeset { | |
| 11 node: string; | |
| 12 date: [number, number]; | |
| 13 desc: string; | |
| 14 branch: string; | |
| 15 bookmarks: string[]; | |
| 16 tags: string[]; | |
| 17 user: string; | |
| 18 phase: string; | |
| 19 col: number; | |
| 20 row: number; | |
| 21 color: number; | |
| 22 edges: Array<{ | |
| 23 bcolor: string; | |
| 24 col: number; | |
| 25 color: number; | |
| 26 nextcol: number; | |
| 27 width: number; | |
| 28 }>; | |
| 29 parents: string[]; | |
| 30 } | |
| 31 | |
| 32 interface GraphData { | |
| 33 node: string; | |
| 34 changeset_count: number; | |
| 35 changesets: Changeset[]; | |
| 36 } | |
| 37 | |
| 38 interface UseGraphDataOptions { | |
| 39 initialCommit?: string | null; | |
| 40 graphTop?: string | null; | |
| 41 } | |
| 42 | |
| 43 interface UseGraphDataResult { | |
| 44 data: GraphData | null; | |
| 45 loading: boolean; | |
| 46 error: string | null; | |
| 47 loadMore: () => void; | |
| 48 hasMore: boolean; | |
| 49 tip: string | null; | |
| 50 currentCommit: string | null; | |
| 51 } | |
| 52 | |
| 53 // --- Hook Logic --- | |
| 54 | |
| 55 function useGraphData({ initialCommit = null, graphTop = null }: UseGraphDataOptions = {}): UseGraphDataResult { | |
| 56 const [data, setData] = useState<GraphData | null>(null); | |
| 57 const [loading, setLoading] = useState(false); | |
| 58 const [error, setError] = useState<string | null>(null); | |
| 59 const [tip, setTip] = useState<string | null>(graphTop); | |
| 60 const [currentCommit, setCurrentCommit] = useState<string | null>(initialCommit); | |
| 61 const [hasMore, setHasMore] = useState(true); | |
| 62 | |
| 63 const fetchData = useCallback(async (commit: string | null, tipNode: string | null, append: boolean = false) => { | |
| 64 if (loading) return; | |
| 65 setLoading(true); | |
| 66 setError(null); | |
| 67 | |
| 68 try { | |
| 69 const url = !commit | |
| 70 ? `/api/graph/tip?style=json` | |
| 71 : `/api/graph/${commit}?graphtop=${tipNode}&style=json`; | |
| 72 | |
| 73 const response = await fetch(url); | |
| 74 if (!response.ok) throw new Error(`Fetch failed: ${response.status}`); | |
| 75 | |
| 76 const result: GraphData = await response.json(); | |
| 77 | |
| 78 setData(prev => { | |
| 79 if (append && prev) { | |
| 80 const existingNodes = new Set(prev.changesets.map(cs => cs.node)); | |
| 81 const newChangesets = result.changesets.filter(cs => !existingNodes.has(cs.node)); | |
| 82 | |
| 83 // Re-index rows to ensure they increment correctly for the canvas height | |
| 84 const startRow = prev.changesets.length; | |
| 85 const reindexed = newChangesets.map((cs, idx) => ({ | |
| 86 ...cs, | |
| 87 row: startRow + idx | |
| 88 })); | |
| 89 | |
| 90 return { | |
| 91 ...result, | |
| 92 changesets: [...prev.changesets, ...reindexed] | |
| 93 }; | |
| 94 } | |
| 95 return result; | |
| 96 }); | |
| 97 | |
| 98 if (!tip && !append) setTip(result.node); | |
| 99 | |
| 100 if (result.changesets.length > 0) { | |
| 101 const lastNode = result.changesets[result.changesets.length - 1].node; | |
| 102 setCurrentCommit(lastNode); | |
| 103 setHasMore(result.changesets.length >= 30); | |
| 104 } else { | |
| 105 setHasMore(false); | |
| 106 } | |
| 107 } catch (err: any) { | |
| 108 setError(err.message); | |
| 109 } finally { | |
| 110 setLoading(false); | |
| 111 } | |
| 112 }, [tip, loading]); | |
| 113 | |
| 114 useEffect(() => { | |
| 115 fetchData(initialCommit, graphTop, false); | |
| 116 }, [initialCommit, graphTop]); | |
| 117 | |
| 118 const loadMore = useCallback(() => { | |
| 119 if (!loading && hasMore && currentCommit && tip) { | |
| 120 fetchData(currentCommit, tip, true); | |
| 121 } | |
| 122 }, [loading, hasMore, currentCommit, tip, fetchData]); | |
| 123 | |
| 124 return { data, loading, error, loadMore, hasMore, tip, currentCommit }; | |
| 125 } | |
| 126 | |
| 127 // --- Pencil Rendering Logic --- | |
| 128 | |
| 129 const drawPencilLine = ( | |
| 130 ctx: CanvasRenderingContext2D, | |
| 131 x1: number, y1: number, | |
| 132 x2: number, y2: number, | |
| 133 texture: CanvasPattern | null, // Ensure type safety | |
| 134 isCurve: boolean = false | |
| 135 ) => { | |
| 136 const strokes = 3; | |
| 137 ctx.save(); | |
| 138 | |
| 139 for (let s = 0; s < strokes; s++) { | |
| 140 ctx.beginPath(); | |
| 141 ctx.strokeStyle = texture; | |
| 142 ctx.globalAlpha = 0.2 + (s * 0.2); | |
| 143 ctx.lineWidth = 1.5 - (s * 0.2); // Pencil lines are usually thinner | |
| 144 | |
| 145 // 2. Realistic Jitter: Actually return a random small number | |
| 146 const jitter = () => (Math.random() - 0.5) * 1.5; | |
| 147 | |
| 148 ctx.moveTo(x1 + jitter(), y1 + jitter()); | |
| 149 | |
| 150 if (isCurve) { | |
| 151 const cpY = y1 + (y2 - y1) / 2; | |
| 152 ctx.bezierCurveTo( | |
| 153 x1 + jitter(), cpY + jitter(), | |
| 154 x2 + jitter(), cpY + jitter(), | |
| 155 x2 + jitter(), y2 + jitter() | |
| 156 ); | |
| 157 } else { | |
| 158 ctx.lineTo(x2 + jitter(), y2 + jitter()); | |
| 159 } | |
| 160 | |
| 161 ctx.stroke(); | |
| 162 } | |
| 163 ctx.restore(); | |
| 164 } | |
| 165 | |
| 166 // --- Main Component --- | |
| 167 | |
| 168 interface GraphProps { | |
| 169 data: GraphData | null; | |
| 170 loading?: boolean; | |
| 171 hasMore?: boolean; | |
| 172 onLoadMore?: () => void; | |
| 173 onCommitClick?: (node: string) => void; | |
| 174 maxRows?: number; | |
| 175 } | |
| 176 | |
| 177 const Graph = ({ data, loading, hasMore, onLoadMore, onCommitClick, maxRows }: GraphProps) => { | |
| 178 const canvasRef = useRef<HTMLCanvasElement>(null); | |
| 179 const containerRef = useRef<HTMLDivElement>(null); | |
| 180 | |
| 181 const changesets = useMemo(() => | |
| 182 maxRows && data?.changesets ? data.changesets.slice(0, maxRows) : data?.changesets || [], [data, maxRows]); | |
| 183 | |
| 184 let pencilPattern; | |
| 185 const img = new Image(); | |
| 186 img.src = "http://localhost:6970/pencil_lines.png"; | |
| 187 | |
| 188 const pandaImg = new Image(); | |
| 189 pandaImg.src = "http://localhost:6970/panda.png"; | |
| 190 | |
| 191 useEffect(() => { | |
| 192 const canvas = canvasRef.current; | |
| 193 if (!canvas || !changesets.length) return; | |
| 194 | |
| 195 const ctx = canvas.getContext('2d'); | |
| 196 if (!ctx) return; | |
| 197 | |
| 198 // Grab colors from CSS variables or defaults | |
| 199 const getColors = () => { | |
| 200 const s = getComputedStyle(document.documentElement); | |
| 201 return [ | |
| 202 s.getPropertyValue('--graph-1').trim() || '#4dabf7', | |
| 203 s.getPropertyValue('--graph-2').trim() || '#63e6be', | |
| 204 s.getPropertyValue('--graph-3').trim() || '#ffbc42', | |
| 205 s.getPropertyValue('--graph-4').trim() || '#b197fc', | |
| 206 s.getPropertyValue('--graph-5').trim() || '#ff8787', | |
| 207 s.getPropertyValue('--graph-6').trim() || '#f06595', | |
| 208 ]; | |
| 209 }; | |
| 210 | |
| 211 const colors = getColors(); | |
| 212 const dpr = window.devicePixelRatio || 1; | |
| 213 const maxCol = Math.max(...changesets.map(cs => cs.col), 0); | |
| 214 const canvasWidth = (maxCol + 2) * colWidth; | |
| 215 | |
| 216 // Scale for high-DPI screens | |
| 217 canvas.width = canvasWidth * dpr; | |
| 218 canvas.height = changesets.length * rowHeight * dpr; | |
| 219 canvas.style.width = `${canvasWidth}px`; | |
| 220 canvas.style.height = `${changesets.length * rowHeight}px`; | |
| 221 ctx.scale(dpr, dpr); | |
| 222 ctx.clearRect(0, 0, canvasWidth, changesets.length * rowHeight); | |
| 223 | |
| 224 const getX = (col: number) => (col + 1) * colWidth; | |
| 225 const getY = (row: number) => (row * rowHeight) + (rowHeight / 2); | |
| 226 | |
| 227 const renderCanvas = () => { | |
| 228 if (!pencilPattern) return; // Don't draw if the pattern isn't ready | |
| 229 | |
| 230 // Pass 1: Draw Connecting Edges | |
| 231 changesets.forEach((cs, i) => { | |
| 232 if (!cs.edges) return; | |
| 233 cs.edges.forEach(edge => { | |
| 234 const sX = getX(edge.col), sY = getY(i); | |
| 235 const eX = getX(edge.nextcol), eY = getY(i + 1); | |
| 236 | |
| 237 drawPencilLine(ctx, sX, sY, eX, eY, pencilPattern, edge.col !== edge.nextcol); | |
| 238 }); | |
| 239 }); | |
| 240 | |
| 241 // Pass 2: Draw Commit Nodes | |
| 242 changesets.forEach((cs, i) => { | |
| 243 const x = getX(cs.col), y = getY(i); | |
| 244 ctx.drawImage(pandaImg, x-10, y-10, 20, 20); | |
| 245 }); | |
| 246 }; | |
| 247 | |
| 248 img.onload = () => { | |
| 249 pencilPattern = ctx.createPattern(img, "repeat")!; | |
| 250 renderCanvas(); | |
| 251 }; | |
| 252 }, [changesets]); | |
| 253 | |
| 254 // Handle Infinite Scroll via Intersection Observer | |
| 255 useEffect(() => { | |
| 256 if (!onLoadMore || !hasMore) return; | |
| 257 const observer = new IntersectionObserver((entries) => { | |
| 258 if (entries[0].isIntersecting && !loading) { | |
| 259 onLoadMore(); | |
| 260 } | |
| 261 }, { threshold: 0.1 }); | |
| 262 | |
| 263 const sentinel = document.getElementById('infinite-scroll-sentinel'); | |
| 264 if (sentinel) observer.observe(sentinel); | |
| 265 return () => observer.disconnect(); | |
| 266 }, [onLoadMore, hasMore, loading]); | |
| 267 | |
| 268 return ( | |
| 269 <div style={{ display: 'flex', flexDirection: 'column', height: '100%', backgroundImage: 'url("/hg-web-background.jpg")', fontFamily: 'monospace' }}> | |
| 270 <div | |
| 271 ref={containerRef} | |
| 272 style={{ display: 'flex', flex: 1, overflowY: 'auto', position: 'relative' }} | |
| 273 > | |
| 274 {/* Graph Column - Sticky to keep lines aligned with text during scroll */} | |
| 275 <div style={{ position: 'sticky', top: 0, height: 'fit-content', zIndex: 10, borderRight: '1px solid #333' }}> | |
| 276 <canvas ref={canvasRef} style={{ display: 'block' }} /> | |
| 277 </div> | |
| 278 | |
| 279 {/* Details Column */} | |
| 280 <div style={{ flex: 1 }}> | |
| 281 {changesets.map((cs) => ( | |
| 282 <div | |
| 283 key={cs.node} | |
| 284 style={{ | |
| 285 height: rowHeight, | |
| 286 display: 'flex', | |
| 287 alignItems: 'center', | |
| 288 padding: '0 15px', | |
| 289 borderBottom: '1px solid #252525', | |
| 290 cursor: 'pointer', | |
| 291 fontSize: '13px', | |
| 292 whiteSpace: 'nowrap' | |
| 293 }} | |
| 294 onClick={() => onCommitClick?.(cs.node)} | |
| 295 onMouseEnter={(e) => (e.currentTarget.style.background = '#222')} | |
| 296 onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')} | |
| 297 > | |
| 298 <span style={{ color: '#4dabf7', width: '90px', flexShrink: 0 }}>{cs.node.substring(0, 12)}</span> | |
| 299 <span style={{ color: '#eee', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', paddingRight: '20px' }}>{cs.desc}</span> | |
| 300 <span style={{ color: '#888', width: '150px', textAlign: 'right' }}>{cs.user.split(' <')[0]}</span> | |
| 301 </div> | |
| 302 ))} | |
| 303 <div id="infinite-scroll-sentinel" style={{ height: '50px' }} /> | |
| 304 </div> | |
| 305 </div> | |
| 306 | |
| 307 {loading && <div style={{ padding: '10px', textAlign: 'center', color: '#888', fontSize: '12px', background: '#111' }}>Loading repository history...</div>} | |
| 308 </div> | |
| 309 ); | |
| 310 }; | |
| 311 | |
| 312 export { Graph, useGraphData }; | |
| 313 export type { GraphData, Changeset, UseGraphDataResult }; |