comparison 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
comparison
equal deleted inserted replaced
217:7ef4c9d2a72d 231:09a96dcb2b4c
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 const [assetError, setAssetError] = useState<string | null>(null);
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
192 let cancelled = false;
193 let pencilPattern: CanvasPattern | null = null;
194 let loadedAssets = 0;
195 const pencilImage = new Image();
196 const pandaImage = new Image();
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);
229 ctx.drawImage(pandaImage, x-10, y-10, 20, 20);
230 });
231 };
232
233 const handleAssetLoad = () => {
234 loadedAssets++;
235 if (loadedAssets !== 2 || cancelled) return;
236 pencilPattern = ctx.createPattern(pencilImage, "repeat");
237 renderCanvas();
238 };
239
240 const handleAssetError = () => {
241 if (!cancelled) setAssetError('Unable to load the graph artwork.');
242 };
243
244 setAssetError(null);
245 pencilImage.onload = handleAssetLoad;
246 pencilImage.onerror = handleAssetError;
247 pandaImage.onload = handleAssetLoad;
248 pandaImage.onerror = handleAssetError;
249 pencilImage.src = "/pencil_lines.png";
250 pandaImage.src = "/panda.png";
251
252 return () => {
253 cancelled = true;
254 pencilImage.onload = null;
255 pencilImage.onerror = null;
256 pandaImage.onload = null;
257 pandaImage.onerror = null;
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 (
276 <div className="graph-container">
277 {assetError && <div className="error-message">{assetError}</div>}
278 <div
279 ref={containerRef}
280 className="graph-wrapper"
281 >
282 <div className="graph-canvas-column">
283 <canvas ref={canvasRef} style={{ display: 'block' }} />
284 </div>
285
286 <div className="graph-details-column">
287 {changesets.map((cs) => (
288 <button
289 type="button"
290 key={cs.node}
291 className="graph-row"
292 onClick={() => onCommitClick?.(cs.node)}
293 aria-label={`Open changeset ${cs.node.substring(0, 12)}: ${cs.desc}`}
294 >
295 <span className="graph-row-meta">
296 <span className="graph-hash">{cs.node.substring(0, 12)}</span>
297 <span className="graph-user">{cs.user.split(' <')[0]}</span>
298 {cs.branch && <span className="graph-branch">{cs.branch}</span>}
299 </span>
300 <span className="graph-desc">{cs.desc}</span>
301 </button>
302 ))}
303 <div id="infinite-scroll-sentinel" style={{ height: '50px' }} />
304 </div>
305 </div>
306
307 {loading && <div className="graph-loading-row">Loading repository history...</div>}
308 </div>
309 );
310 };
311
312 export { Graph, useGraphData };
313 export type { GraphData, Changeset, UseGraphDataResult };