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