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 };