Mercurial
annotate hg-web/src/components/graph.tsx @ 224:3007ef5fc0ed hg-web
[hg-web] Simplify visuals and preview static files
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Sun, 02 Aug 2026 13:51:11 -0700 |
| parents | ce7f4400c2de |
| children |
| rev | line source |
|---|---|
| 193 | 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); | |
|
221
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
180 const [assetError, setAssetError] = useState<string | null>(null); |
| 193 | 181 |
| 182 const changesets = useMemo(() => | |
| 183 maxRows && data?.changesets ? data.changesets.slice(0, maxRows) : data?.changesets || [], [data, maxRows]); | |
| 184 | |
| 185 useEffect(() => { | |
| 186 const canvas = canvasRef.current; | |
| 187 if (!canvas || !changesets.length) return; | |
| 188 | |
| 189 const ctx = canvas.getContext('2d'); | |
| 190 if (!ctx) return; | |
| 191 | |
|
221
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
192 let cancelled = false; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
193 let pencilPattern: CanvasPattern | null = null; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
194 let loadedAssets = 0; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
195 const pencilImage = new Image(); |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
196 const pandaImage = new Image(); |
| 193 | 197 const dpr = window.devicePixelRatio || 1; |
| 198 const maxCol = Math.max(...changesets.map(cs => cs.col), 0); | |
| 199 const canvasWidth = (maxCol + 2) * colWidth; | |
| 200 | |
| 201 // Scale for high-DPI screens | |
| 202 canvas.width = canvasWidth * dpr; | |
| 203 canvas.height = changesets.length * rowHeight * dpr; | |
| 204 canvas.style.width = `${canvasWidth}px`; | |
| 205 canvas.style.height = `${changesets.length * rowHeight}px`; | |
| 206 ctx.scale(dpr, dpr); | |
| 207 ctx.clearRect(0, 0, canvasWidth, changesets.length * rowHeight); | |
| 208 | |
| 209 const getX = (col: number) => (col + 1) * colWidth; | |
| 210 const getY = (row: number) => (row * rowHeight) + (rowHeight / 2); | |
| 211 | |
| 212 const renderCanvas = () => { | |
| 213 if (!pencilPattern) return; // Don't draw if the pattern isn't ready | |
| 214 | |
| 215 // Pass 1: Draw Connecting Edges | |
| 216 changesets.forEach((cs, i) => { | |
| 217 if (!cs.edges) return; | |
| 218 cs.edges.forEach(edge => { | |
| 219 const sX = getX(edge.col), sY = getY(i); | |
| 220 const eX = getX(edge.nextcol), eY = getY(i + 1); | |
| 221 | |
| 222 drawPencilLine(ctx, sX, sY, eX, eY, pencilPattern, edge.col !== edge.nextcol); | |
| 223 }); | |
| 224 }); | |
| 225 | |
| 226 // Pass 2: Draw Commit Nodes | |
| 227 changesets.forEach((cs, i) => { | |
| 228 const x = getX(cs.col), y = getY(i); | |
|
221
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
229 ctx.drawImage(pandaImage, x-10, y-10, 20, 20); |
| 193 | 230 }); |
| 231 }; | |
| 232 | |
|
221
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
233 const handleAssetLoad = () => { |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
234 loadedAssets++; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
235 if (loadedAssets !== 2 || cancelled) return; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
236 pencilPattern = ctx.createPattern(pencilImage, "repeat"); |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
237 renderCanvas(); |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
238 }; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
239 |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
240 const handleAssetError = () => { |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
241 if (!cancelled) setAssetError('Unable to load the graph artwork.'); |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
242 }; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
243 |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
244 setAssetError(null); |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
245 pencilImage.onload = handleAssetLoad; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
246 pencilImage.onerror = handleAssetError; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
247 pandaImage.onload = handleAssetLoad; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
248 pandaImage.onerror = handleAssetError; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
249 pencilImage.src = "/pencil_lines.png"; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
250 pandaImage.src = "/panda.png"; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
251 |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
252 return () => { |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
253 cancelled = true; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
254 pencilImage.onload = null; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
255 pencilImage.onerror = null; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
256 pandaImage.onload = null; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
257 pandaImage.onerror = null; |
| 193 | 258 }; |
| 259 }, [changesets]); | |
| 260 | |
| 261 // Handle Infinite Scroll via Intersection Observer | |
| 262 useEffect(() => { | |
| 263 if (!onLoadMore || !hasMore) return; | |
| 264 const observer = new IntersectionObserver((entries) => { | |
| 265 if (entries[0].isIntersecting && !loading) { | |
| 266 onLoadMore(); | |
| 267 } | |
| 268 }, { threshold: 0.1 }); | |
| 269 | |
| 270 const sentinel = document.getElementById('infinite-scroll-sentinel'); | |
| 271 if (sentinel) observer.observe(sentinel); | |
| 272 return () => observer.disconnect(); | |
| 273 }, [onLoadMore, hasMore, loading]); | |
| 274 | |
| 275 return ( | |
|
224
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
221
diff
changeset
|
276 <div className="graph-container"> |
|
221
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
277 {assetError && <div className="error-message">{assetError}</div>} |
| 193 | 278 <div |
| 279 ref={containerRef} | |
|
221
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
280 className="graph-wrapper" |
| 193 | 281 > |
|
221
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
282 <div className="graph-canvas-column"> |
| 193 | 283 <canvas ref={canvasRef} style={{ display: 'block' }} /> |
| 284 </div> | |
| 285 | |
|
221
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
286 <div className="graph-details-column"> |
| 193 | 287 {changesets.map((cs) => ( |
|
221
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
288 <button |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
289 type="button" |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
290 key={cs.node} |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
291 className="graph-row" |
| 193 | 292 onClick={() => onCommitClick?.(cs.node)} |
|
221
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
293 aria-label={`Open changeset ${cs.node.substring(0, 12)}: ${cs.desc}`} |
| 193 | 294 > |
|
221
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
295 <span className="graph-row-meta"> |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
296 <span className="graph-hash">{cs.node.substring(0, 12)}</span> |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
297 <span className="graph-user">{cs.user.split(' <')[0]}</span> |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
298 {cs.branch && <span className="graph-branch">{cs.branch}</span>} |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
299 </span> |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
300 <span className="graph-desc">{cs.desc}</span> |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
301 </button> |
| 193 | 302 ))} |
| 303 <div id="infinite-scroll-sentinel" style={{ height: '50px' }} /> | |
| 304 </div> | |
| 305 </div> | |
| 306 | |
|
221
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
307 {loading && <div className="graph-loading-row">Loading repository history...</div>} |
| 193 | 308 </div> |
| 309 ); | |
| 310 }; | |
| 311 | |
| 312 export { Graph, useGraphData }; | |
| 313 export type { GraphData, Changeset, UseGraphDataResult }; |