Mercurial
comparison hg-web/src/components/graph.tsx @ 193:9f4429c49733 hg-web
[HgWeb] Making progress....
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Sun, 25 Jan 2026 20:04:55 -0800 |
| parents | |
| children | fb28063dc490 |
comparison
equal
deleted
inserted
replaced
| 192:b818a4561a3c | 193:9f4429c49733 |
|---|---|
| 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_texture.png"; | |
| 187 | |
| 188 useEffect(() => { | |
| 189 const canvas = canvasRef.current; | |
| 190 if (!canvas || !changesets.length) return; | |
| 191 | |
| 192 const ctx = canvas.getContext('2d'); | |
| 193 if (!ctx) return; | |
| 194 | |
| 195 // Grab colors from CSS variables or defaults | |
| 196 const getColors = () => { | |
| 197 const s = getComputedStyle(document.documentElement); | |
| 198 return [ | |
| 199 s.getPropertyValue('--graph-1').trim() || '#4dabf7', | |
| 200 s.getPropertyValue('--graph-2').trim() || '#63e6be', | |
| 201 s.getPropertyValue('--graph-3').trim() || '#ffbc42', | |
| 202 s.getPropertyValue('--graph-4').trim() || '#b197fc', | |
| 203 s.getPropertyValue('--graph-5').trim() || '#ff8787', | |
| 204 s.getPropertyValue('--graph-6').trim() || '#f06595', | |
| 205 ]; | |
| 206 }; | |
| 207 | |
| 208 const colors = getColors(); | |
| 209 const dpr = window.devicePixelRatio || 1; | |
| 210 const maxCol = Math.max(...changesets.map(cs => cs.col), 0); | |
| 211 const canvasWidth = (maxCol + 2) * colWidth; | |
| 212 | |
| 213 // Scale for high-DPI screens | |
| 214 canvas.width = canvasWidth * dpr; | |
| 215 canvas.height = changesets.length * rowHeight * dpr; | |
| 216 canvas.style.width = `${canvasWidth}px`; | |
| 217 canvas.style.height = `${changesets.length * rowHeight}px`; | |
| 218 ctx.scale(dpr, dpr); | |
| 219 ctx.clearRect(0, 0, canvasWidth, changesets.length * rowHeight); | |
| 220 | |
| 221 const getX = (col: number) => (col + 1) * colWidth; | |
| 222 const getY = (row: number) => (row * rowHeight) + (rowHeight / 2); | |
| 223 | |
| 224 const renderCanvas = () => { | |
| 225 if (!pencilPattern) return; // Don't draw if the pattern isn't ready | |
| 226 | |
| 227 // Pass 1: Draw Connecting Edges | |
| 228 changesets.forEach((cs, i) => { | |
| 229 if (!cs.edges) return; | |
| 230 cs.edges.forEach(edge => { | |
| 231 const sX = getX(edge.col), sY = getY(i); | |
| 232 const eX = getX(edge.nextcol), eY = getY(i + 1); | |
| 233 | |
| 234 drawPencilLine(ctx, sX, sY, eX, eY, pencilPattern, edge.col !== edge.nextcol); | |
| 235 }); | |
| 236 }); | |
| 237 | |
| 238 // Pass 2: Draw Commit Nodes | |
| 239 changesets.forEach((cs, i) => { | |
| 240 const x = getX(cs.col), y = getY(i); | |
| 241 const color = colors[cs.color % colors.length]; | |
| 242 | |
| 243 // Sketchy outer glow | |
| 244 ctx.beginPath(); | |
| 245 ctx.arc(x, y, nodeRadius + 2, 0, Math.PI * 2); | |
| 246 ctx.fillStyle = `${color}33`; | |
| 247 ctx.fill(); | |
| 248 | |
| 249 // Core Node | |
| 250 ctx.beginPath(); | |
| 251 ctx.arc(x, y, nodeRadius, 0, Math.PI * 2); | |
| 252 ctx.fillStyle = color; | |
| 253 ctx.fill(); | |
| 254 | |
| 255 // Sketchy border rings | |
| 256 for (let s = 0; s < 2; s++) { | |
| 257 ctx.beginPath(); | |
| 258 ctx.arc(x + (Math.random() - 0.5), y + (Math.random() - 0.5), nodeRadius + 0.5, 0, Math.PI * 2); | |
| 259 ctx.strokeStyle = '#000000'; | |
| 260 ctx.globalAlpha = 0.3; | |
| 261 ctx.lineWidth = 0.8; | |
| 262 ctx.stroke(); | |
| 263 } | |
| 264 ctx.globalAlpha = 1; | |
| 265 }); | |
| 266 }; | |
| 267 | |
| 268 img.onload = () => { | |
| 269 console.log("WTF"); | |
| 270 pencilPattern = ctx.createPattern(img, "repeat")!; | |
| 271 renderCanvas(); | |
| 272 }; | |
| 273 }, [changesets]); | |
| 274 | |
| 275 // Handle Infinite Scroll via Intersection Observer | |
| 276 useEffect(() => { | |
| 277 if (!onLoadMore || !hasMore) return; | |
| 278 const observer = new IntersectionObserver((entries) => { | |
| 279 if (entries[0].isIntersecting && !loading) { | |
| 280 onLoadMore(); | |
| 281 } | |
| 282 }, { threshold: 0.1 }); | |
| 283 | |
| 284 const sentinel = document.getElementById('infinite-scroll-sentinel'); | |
| 285 if (sentinel) observer.observe(sentinel); | |
| 286 return () => observer.disconnect(); | |
| 287 }, [onLoadMore, hasMore, loading]); | |
| 288 | |
| 289 return ( | |
| 290 <div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#1a1a1a', fontFamily: 'monospace' }}> | |
| 291 <div | |
| 292 ref={containerRef} | |
| 293 style={{ display: 'flex', flex: 1, overflowY: 'auto', position: 'relative' }} | |
| 294 > | |
| 295 {/* Graph Column - Sticky to keep lines aligned with text during scroll */} | |
| 296 <div style={{ position: 'sticky', top: 0, height: 'fit-content', zIndex: 10, borderRight: '1px solid #333' }}> | |
| 297 <canvas ref={canvasRef} style={{ display: 'block' }} /> | |
| 298 </div> | |
| 299 | |
| 300 {/* Details Column */} | |
| 301 <div style={{ flex: 1 }}> | |
| 302 {changesets.map((cs) => ( | |
| 303 <div | |
| 304 key={cs.node} | |
| 305 style={{ | |
| 306 height: rowHeight, | |
| 307 display: 'flex', | |
| 308 alignItems: 'center', | |
| 309 padding: '0 15px', | |
| 310 borderBottom: '1px solid #252525', | |
| 311 cursor: 'pointer', | |
| 312 fontSize: '13px', | |
| 313 whiteSpace: 'nowrap' | |
| 314 }} | |
| 315 onClick={() => onCommitClick?.(cs.node)} | |
| 316 onMouseEnter={(e) => (e.currentTarget.style.background = '#222')} | |
| 317 onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')} | |
| 318 > | |
| 319 <span style={{ color: '#4dabf7', width: '90px', flexShrink: 0 }}>{cs.node.substring(0, 12)}</span> | |
| 320 <span style={{ color: '#eee', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', paddingRight: '20px' }}>{cs.desc}</span> | |
| 321 <span style={{ color: '#888', width: '150px', textAlign: 'right' }}>{cs.user.split(' <')[0]}</span> | |
| 322 </div> | |
| 323 ))} | |
| 324 <div id="infinite-scroll-sentinel" style={{ height: '50px' }} /> | |
| 325 </div> | |
| 326 </div> | |
| 327 | |
| 328 {loading && <div style={{ padding: '10px', textAlign: 'center', color: '#888', fontSize: '12px', background: '#111' }}>Loading repository history...</div>} | |
| 329 </div> | |
| 330 ); | |
| 331 }; | |
| 332 | |
| 333 export { Graph, useGraphData }; | |
| 334 export type { GraphData, Changeset, UseGraphDataResult }; |