doppelgangers 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/build.js ADDED
@@ -0,0 +1,951 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { UMAP } from "umap-js";
4
+ export function build(options) {
5
+ const inputPath = path.resolve(options.input);
6
+ const outputPath = path.resolve(options.output);
7
+ const lines = fs.readFileSync(inputPath, "utf8").split("\n").filter(Boolean);
8
+ const entries = [];
9
+ for (const line of lines) {
10
+ try {
11
+ entries.push(JSON.parse(line));
12
+ }
13
+ catch {
14
+ // skip invalid lines
15
+ }
16
+ }
17
+ const embeddings = entries.map((entry) => entry.embedding);
18
+ const projectionsPath = path.resolve(options.projections);
19
+ let coords2d;
20
+ let coords3d;
21
+ if (fs.existsSync(projectionsPath)) {
22
+ console.log(`Loading cached projections from ${projectionsPath}`);
23
+ const cached = JSON.parse(fs.readFileSync(projectionsPath, "utf8"));
24
+ coords2d = cached.coords2d;
25
+ coords3d = cached.coords3d;
26
+ }
27
+ else {
28
+ const umap2d = new UMAP({
29
+ nComponents: 2,
30
+ nNeighbors: options.neighbors,
31
+ minDist: options.minDist,
32
+ random: Math.random,
33
+ });
34
+ const umap3d = new UMAP({
35
+ nComponents: 3,
36
+ nNeighbors: options.neighbors,
37
+ minDist: options.minDist,
38
+ random: Math.random,
39
+ });
40
+ console.log(`Running UMAP 2D on ${embeddings.length} embeddings`);
41
+ coords2d = umap2d.fit(embeddings);
42
+ console.log(`Running UMAP 3D on ${embeddings.length} embeddings`);
43
+ coords3d = umap3d.fit(embeddings);
44
+ fs.writeFileSync(projectionsPath, JSON.stringify({ coords2d, coords3d }));
45
+ console.log(`Saved projections to ${projectionsPath}`);
46
+ }
47
+ const xs = coords2d.map((c) => c[0]);
48
+ const ys = coords2d.map((c) => c[1]);
49
+ const minX = Math.min(...xs);
50
+ const maxX = Math.max(...xs);
51
+ const minY = Math.min(...ys);
52
+ const maxY = Math.max(...ys);
53
+ const rangeX = maxX - minX || 1;
54
+ const rangeY = maxY - minY || 1;
55
+ const xs3 = coords3d.map((c) => c[0]);
56
+ const ys3 = coords3d.map((c) => c[1]);
57
+ const zs3 = coords3d.map((c) => c[2]);
58
+ const minX3 = Math.min(...xs3);
59
+ const maxX3 = Math.max(...xs3);
60
+ const minY3 = Math.min(...ys3);
61
+ const maxY3 = Math.max(...ys3);
62
+ const minZ3 = Math.min(...zs3);
63
+ const maxZ3 = Math.max(...zs3);
64
+ const rangeX3 = maxX3 - minX3 || 1;
65
+ const rangeY3 = maxY3 - minY3 || 1;
66
+ const rangeZ3 = maxZ3 - minZ3 || 1;
67
+ const points = coords2d.map((coord, index) => {
68
+ const entry = entries[index];
69
+ const coord3d = coords3d[index] || [0, 0, 0];
70
+ return {
71
+ x: (coord[0] - minX) / rangeX,
72
+ y: (coord[1] - minY) / rangeY,
73
+ x3d: (coord3d[0] - minX3) / rangeX3,
74
+ y3d: (coord3d[1] - minY3) / rangeY3,
75
+ z3d: (coord3d[2] - minZ3) / rangeZ3,
76
+ title: entry.title || "",
77
+ number: entry.number,
78
+ url: entry.url || "",
79
+ body: entry.body || "",
80
+ state: entry.state,
81
+ type: entry.type,
82
+ embedding: options.includeEmbeddings ? entry.embedding : undefined,
83
+ };
84
+ });
85
+ const dataJson = JSON.stringify(points).replace(/</g, "\\u003c");
86
+ const html = generateHtml(dataJson);
87
+ const outputDir = path.dirname(outputPath);
88
+ fs.mkdirSync(outputDir, { recursive: true });
89
+ fs.writeFileSync(outputPath, html);
90
+ console.log(`Wrote ${outputPath}`);
91
+ }
92
+ function generateHtml(dataJson) {
93
+ return `<!doctype html>
94
+ <html lang="en">
95
+ <head>
96
+ <meta charset="utf-8" />
97
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
98
+ <title>Doppelgangers - Issue & PR Triage</title>
99
+ <style>
100
+ :root {
101
+ color-scheme: light dark;
102
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
103
+ --bg: #0f172a;
104
+ --panel: #111827;
105
+ --text: #f8fafc;
106
+ --muted: #94a3b8;
107
+ --accent: #38bdf8;
108
+ --point: #e2e8f0;
109
+ --point-open: #6ee7b7;
110
+ --point-closed: #a78bfa;
111
+ --selected: #f59e0b;
112
+ }
113
+ body {
114
+ margin: 0;
115
+ background: var(--bg);
116
+ color: var(--text);
117
+ height: 100vh;
118
+ overflow: hidden;
119
+ }
120
+ #app {
121
+ display: flex;
122
+ height: 100vh;
123
+ }
124
+ #plot-wrap {
125
+ flex: 1;
126
+ position: relative;
127
+ }
128
+ #plot {
129
+ display: block;
130
+ width: 100%;
131
+ height: 100%;
132
+ background: var(--bg);
133
+ cursor: grab;
134
+ }
135
+ #plot.dragging {
136
+ cursor: grabbing;
137
+ }
138
+ #hud {
139
+ position: absolute;
140
+ top: 12px;
141
+ left: 12px;
142
+ background: rgba(17, 24, 39, 0.9);
143
+ padding: 10px 14px;
144
+ border-radius: 8px;
145
+ font-size: 12px;
146
+ line-height: 1.5;
147
+ color: var(--muted);
148
+ }
149
+ #hud-row {
150
+ display: flex;
151
+ align-items: center;
152
+ gap: 8px;
153
+ margin-bottom: 8px;
154
+ }
155
+ #hud-mode {
156
+ font-weight: 600;
157
+ color: var(--text);
158
+ }
159
+ .hud-btn {
160
+ background: transparent;
161
+ color: var(--text);
162
+ border: 1px solid rgba(148, 163, 184, 0.4);
163
+ border-radius: 6px;
164
+ padding: 2px 8px;
165
+ font-size: 11px;
166
+ cursor: pointer;
167
+ }
168
+ .hud-btn:hover {
169
+ border-color: var(--accent);
170
+ color: var(--accent);
171
+ }
172
+ #filters {
173
+ display: flex;
174
+ flex-wrap: wrap;
175
+ gap: 6px 12px;
176
+ margin-bottom: 8px;
177
+ padding-bottom: 8px;
178
+ border-bottom: 1px solid rgba(148, 163, 184, 0.2);
179
+ }
180
+ #filters label {
181
+ display: flex;
182
+ align-items: center;
183
+ gap: 4px;
184
+ cursor: pointer;
185
+ font-size: 11px;
186
+ }
187
+ #filters input[type="checkbox"] {
188
+ margin: 0;
189
+ }
190
+ #search-wrap {
191
+ display: flex;
192
+ gap: 6px;
193
+ margin-bottom: 8px;
194
+ padding-bottom: 8px;
195
+ border-bottom: 1px solid rgba(148, 163, 184, 0.2);
196
+ }
197
+ #search-input {
198
+ flex: 1;
199
+ background: rgba(15, 23, 42, 0.6);
200
+ border: 1px solid rgba(148, 163, 184, 0.3);
201
+ border-radius: 4px;
202
+ padding: 4px 8px;
203
+ color: var(--text);
204
+ font-size: 11px;
205
+ }
206
+ #search-input::placeholder {
207
+ color: var(--muted);
208
+ }
209
+ #search-btn {
210
+ background: var(--accent);
211
+ color: var(--bg);
212
+ border: none;
213
+ border-radius: 4px;
214
+ padding: 4px 10px;
215
+ font-size: 11px;
216
+ font-weight: 600;
217
+ cursor: pointer;
218
+ }
219
+ #search-btn:disabled {
220
+ opacity: 0.5;
221
+ cursor: not-allowed;
222
+ }
223
+ #sidebar {
224
+ width: 480px;
225
+ background: var(--panel);
226
+ border-left: 1px solid rgba(148, 163, 184, 0.2);
227
+ padding: 16px;
228
+ overflow: auto;
229
+ display: flex;
230
+ flex-direction: column;
231
+ }
232
+ #sidebar-header {
233
+ display: flex;
234
+ align-items: center;
235
+ justify-content: space-between;
236
+ margin-bottom: 8px;
237
+ }
238
+ #sidebar-header h2 {
239
+ margin: 0;
240
+ font-size: 18px;
241
+ }
242
+ #sidebar-actions {
243
+ display: flex;
244
+ gap: 8px;
245
+ }
246
+ .sidebar-btn {
247
+ background: transparent;
248
+ color: var(--muted);
249
+ border: 1px solid rgba(148, 163, 184, 0.3);
250
+ border-radius: 6px;
251
+ padding: 4px 10px;
252
+ font-size: 11px;
253
+ cursor: pointer;
254
+ }
255
+ .sidebar-btn:hover {
256
+ border-color: var(--accent);
257
+ color: var(--accent);
258
+ }
259
+ #selection-count {
260
+ color: var(--muted);
261
+ margin-bottom: 12px;
262
+ font-size: 13px;
263
+ }
264
+ #selected-list {
265
+ list-style: none;
266
+ padding: 0;
267
+ margin: 0;
268
+ display: grid;
269
+ gap: 12px;
270
+ flex: 1;
271
+ overflow: auto;
272
+ }
273
+ #selected-list li {
274
+ padding: 10px;
275
+ background: rgba(15, 23, 42, 0.6);
276
+ border-radius: 8px;
277
+ border: 1px solid rgba(148, 163, 184, 0.2);
278
+ }
279
+ .item-header {
280
+ display: flex;
281
+ align-items: flex-start;
282
+ gap: 6px;
283
+ }
284
+ #selected-list a {
285
+ color: var(--accent);
286
+ text-decoration: none;
287
+ font-weight: 600;
288
+ flex: 1;
289
+ word-break: break-word;
290
+ }
291
+ #selected-list p {
292
+ margin: 6px 0 0 0;
293
+ color: var(--muted);
294
+ font-size: 12px;
295
+ word-break: break-word;
296
+ display: -webkit-box;
297
+ -webkit-line-clamp: 3;
298
+ -webkit-box-orient: vertical;
299
+ overflow: hidden;
300
+ }
301
+ .badge {
302
+ display: inline-block;
303
+ padding: 2px 6px;
304
+ border-radius: 4px;
305
+ font-size: 9px;
306
+ font-weight: 600;
307
+ text-transform: uppercase;
308
+ flex-shrink: 0;
309
+ }
310
+ .badge-open {
311
+ background: rgba(110, 231, 183, 0.2);
312
+ color: var(--point-open);
313
+ }
314
+ .badge-closed {
315
+ background: rgba(167, 139, 250, 0.2);
316
+ color: var(--point-closed);
317
+ }
318
+ .badge-pr {
319
+ background: rgba(56, 189, 248, 0.2);
320
+ color: var(--accent);
321
+ }
322
+ .badge-issue {
323
+ background: rgba(251, 191, 36, 0.2);
324
+ color: #fbbf24;
325
+ }
326
+ .legend {
327
+ display: flex;
328
+ gap: 12px;
329
+ font-size: 10px;
330
+ margin-top: 4px;
331
+ }
332
+ .legend-item {
333
+ display: flex;
334
+ align-items: center;
335
+ gap: 4px;
336
+ }
337
+ .legend-shape {
338
+ width: 8px;
339
+ height: 8px;
340
+ }
341
+ .legend-circle {
342
+ border-radius: 50%;
343
+ background: var(--muted);
344
+ }
345
+ .legend-ring {
346
+ border: 2px solid var(--muted);
347
+ border-radius: 50%;
348
+ background: transparent;
349
+ }
350
+ </style>
351
+ </head>
352
+ <body>
353
+ <div id="app">
354
+ <div id="plot-wrap">
355
+ <canvas id="plot"></canvas>
356
+ <div id="hud">
357
+ <div id="hud-row">
358
+ <div id="hud-mode">Mode: 2D</div>
359
+ <button id="toggle-mode" class="hud-btn" type="button">Toggle 3D</button>
360
+ </div>
361
+ <div id="filters">
362
+ <label><input type="checkbox" id="filter-pr" checked> PRs</label>
363
+ <label><input type="checkbox" id="filter-issue" checked> Issues</label>
364
+ <label><input type="checkbox" id="filter-open" checked> Open</label>
365
+ <label><input type="checkbox" id="filter-closed" checked> Closed</label>
366
+ </div>
367
+ <div id="search-wrap">
368
+ <input type="text" id="search-input" placeholder="Semantic search..." />
369
+ <button id="search-btn" type="button">Search</button>
370
+ </div>
371
+ <div class="legend">
372
+ <span class="legend-item"><span class="legend-shape legend-circle"></span> PR</span>
373
+ <span class="legend-item"><span class="legend-shape legend-ring"></span> Issue</span>
374
+ </div>
375
+ <div id="hud-instructions" style="margin-top: 8px;">
376
+ <div id="hud-line-2d">2D: drag to pan, scroll to zoom</div>
377
+ <div id="hud-line-3d">3D: drag to rotate, ctrl+drag to pan</div>
378
+ <div>Shift+drag to select, ctrl+shift to add</div>
379
+ </div>
380
+ <div id="hud-count"></div>
381
+ </div>
382
+ </div>
383
+ <aside id="sidebar">
384
+ <div id="sidebar-header">
385
+ <h2>Selection</h2>
386
+ <div id="sidebar-actions">
387
+ <button id="open-all-btn" class="sidebar-btn" type="button">Open All</button>
388
+ <button id="copy-btn" class="sidebar-btn" type="button">Copy</button>
389
+ </div>
390
+ </div>
391
+ <div id="selection-count">0 selected</div>
392
+ <ul id="selected-list"></ul>
393
+ </aside>
394
+ </div>
395
+ <script>
396
+ const data = ${dataJson};
397
+ const canvas = document.getElementById("plot");
398
+ const ctx = canvas.getContext("2d");
399
+ const selectionCount = document.getElementById("selection-count");
400
+ const selectedList = document.getElementById("selected-list");
401
+ const hudCount = document.getElementById("hud-count");
402
+ const hudMode = document.getElementById("hud-mode");
403
+ const toggleMode = document.getElementById("toggle-mode");
404
+ const hudLine2d = document.getElementById("hud-line-2d");
405
+ const hudLine3d = document.getElementById("hud-line-3d");
406
+ const openAllBtn = document.getElementById("open-all-btn");
407
+ const copyBtn = document.getElementById("copy-btn");
408
+ const searchInput = document.getElementById("search-input");
409
+ const searchBtn = document.getElementById("search-btn");
410
+ const filterPr = document.getElementById("filter-pr");
411
+ const filterIssue = document.getElementById("filter-issue");
412
+ const filterOpen = document.getElementById("filter-open");
413
+ const filterClosed = document.getElementById("filter-closed");
414
+
415
+ const styles = getComputedStyle(document.documentElement);
416
+ const colors = {
417
+ point: styles.getPropertyValue("--point").trim() || "#e2e8f0",
418
+ pointOpen: styles.getPropertyValue("--point-open").trim() || "#6ee7b7",
419
+ pointClosed: styles.getPropertyValue("--point-closed").trim() || "#a78bfa",
420
+ selected: styles.getPropertyValue("--selected").trim() || "#f59e0b",
421
+ accent: styles.getPropertyValue("--accent").trim() || "#38bdf8",
422
+ muted: styles.getPropertyValue("--muted").trim() || "#94a3b8"
423
+ };
424
+
425
+ let apiKey = null;
426
+
427
+ const hasStates = data.some(p => p.state);
428
+ const hasTypes = data.some(p => p.type);
429
+ const hasEmbeddings = data.some(p => p.embedding);
430
+
431
+ if (!hasEmbeddings) {
432
+ document.getElementById("search-wrap").style.display = "none";
433
+ }
434
+
435
+ const getPointColor = (point, isSelected) => {
436
+ if (isSelected) return colors.selected;
437
+ if (!hasStates) return colors.point;
438
+ if (point.state === "open") return colors.pointOpen;
439
+ if (point.state === "closed") return colors.pointClosed;
440
+ return colors.point;
441
+ };
442
+
443
+ const isVisible = (point) => {
444
+ const typeOk = !hasTypes ||
445
+ (point.type === "pr" && filterPr.checked) ||
446
+ (point.type === "issue" && filterIssue.checked) ||
447
+ (!point.type);
448
+ const stateOk = !hasStates ||
449
+ (point.state === "open" && filterOpen.checked) ||
450
+ (point.state === "closed" && filterClosed.checked) ||
451
+ (!point.state);
452
+ return typeOk && stateOk;
453
+ };
454
+
455
+ const view2d = {
456
+ scale: 1,
457
+ offsetX: 0,
458
+ offsetY: 0
459
+ };
460
+
461
+ const view3d = {
462
+ rotateX: 0.35,
463
+ rotateY: -0.6,
464
+ zoom: 1.2,
465
+ offsetX: 0,
466
+ offsetY: 0
467
+ };
468
+
469
+ const state = {
470
+ mode: "2d",
471
+ dragging: false,
472
+ selecting: false,
473
+ dragMode: null,
474
+ dragStart: { x: 0, y: 0 },
475
+ mouseDownPos: { x: 0, y: 0 },
476
+ selectRect: null,
477
+ selected: new Set(),
478
+ addToSelection: false
479
+ };
480
+
481
+ const resize = () => {
482
+ canvas.width = canvas.clientWidth * devicePixelRatio;
483
+ canvas.height = canvas.clientHeight * devicePixelRatio;
484
+ ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
485
+ scheduleRender();
486
+ };
487
+
488
+ const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
489
+
490
+ const setMode = (mode) => {
491
+ state.mode = mode;
492
+ hudMode.textContent = mode === "3d" ? "Mode: 3D" : "Mode: 2D";
493
+ toggleMode.textContent = mode === "3d" ? "Toggle 2D" : "Toggle 3D";
494
+ hudLine2d.style.opacity = mode === "2d" ? "1" : "0.5";
495
+ hudLine3d.style.opacity = mode === "3d" ? "1" : "0.5";
496
+ scheduleRender();
497
+ };
498
+
499
+ const getCanvasPoint = (event) => {
500
+ const rect = canvas.getBoundingClientRect();
501
+ return {
502
+ x: event.clientX - rect.left,
503
+ y: event.clientY - rect.top
504
+ };
505
+ };
506
+
507
+ const worldToScreen = (point) => {
508
+ return {
509
+ x: point.x * canvas.clientWidth * view2d.scale + view2d.offsetX,
510
+ y: point.y * canvas.clientHeight * view2d.scale + view2d.offsetY
511
+ };
512
+ };
513
+
514
+ const project3d = (point) => {
515
+ const x = (point.x3d - 0.5) * view3d.zoom;
516
+ const y = (point.y3d - 0.5) * view3d.zoom;
517
+ const z = (point.z3d - 0.5) * view3d.zoom;
518
+ const cosY = Math.cos(view3d.rotateY);
519
+ const sinY = Math.sin(view3d.rotateY);
520
+ const cosX = Math.cos(view3d.rotateX);
521
+ const sinX = Math.sin(view3d.rotateX);
522
+ const x1 = x * cosY + z * sinY;
523
+ const z1 = -x * sinY + z * cosY;
524
+ const y1 = y * cosX - z1 * sinX;
525
+ const z2 = y * sinX + z1 * cosX;
526
+ // Cull points behind camera
527
+ if (z2 < -0.9) {
528
+ return { x: -1000, y: -1000, depth: z2, culled: true };
529
+ }
530
+ const perspective = 1 / (1 + z2 * 0.9);
531
+ const screenX = x1 * perspective * canvas.clientWidth * 0.7 + canvas.clientWidth * 0.5 + view3d.offsetX;
532
+ const screenY = y1 * perspective * canvas.clientHeight * 0.7 + canvas.clientHeight * 0.5 + view3d.offsetY;
533
+ return { x: screenX, y: screenY, depth: z2, culled: false };
534
+ };
535
+
536
+ const getScreenPoint = (point) => {
537
+ if (state.mode === "3d") return project3d(point);
538
+ return worldToScreen(point);
539
+ };
540
+
541
+ const updateSidebar = () => {
542
+ const selected = Array.from(state.selected);
543
+ selectionCount.textContent = selected.length + " selected";
544
+ const maxDisplay = 200;
545
+ const list = selected.slice(0, maxDisplay);
546
+ selectedList.innerHTML = "";
547
+ for (const index of list) {
548
+ const item = data[index];
549
+ const li = document.createElement("li");
550
+ const header = document.createElement("div");
551
+ header.className = "item-header";
552
+ const link = document.createElement("a");
553
+ link.href = item.url;
554
+ link.target = "_blank";
555
+ link.rel = "noreferrer";
556
+ const num = item.number ? "#" + item.number + " " : "";
557
+ link.textContent = num + (item.title || item.url);
558
+ header.appendChild(link);
559
+ if (item.type) {
560
+ const typeBadge = document.createElement("span");
561
+ typeBadge.className = "badge badge-" + item.type;
562
+ typeBadge.textContent = item.type;
563
+ header.appendChild(typeBadge);
564
+ }
565
+ if (item.state) {
566
+ const stateBadge = document.createElement("span");
567
+ stateBadge.className = "badge badge-" + item.state;
568
+ stateBadge.textContent = item.state;
569
+ header.appendChild(stateBadge);
570
+ }
571
+ li.appendChild(header);
572
+ if (item.body) {
573
+ const snippet = document.createElement("p");
574
+ snippet.textContent = item.body;
575
+ li.appendChild(snippet);
576
+ }
577
+ selectedList.appendChild(li);
578
+ }
579
+ if (selected.length > maxDisplay) {
580
+ const more = document.createElement("p");
581
+ more.textContent = "Showing " + maxDisplay + " of " + selected.length;
582
+ more.style.color = colors.muted;
583
+ selectedList.appendChild(more);
584
+ }
585
+ };
586
+
587
+ const selectPoints = () => {
588
+ if (!state.selectRect) return;
589
+ const rect = state.selectRect;
590
+ const left = Math.min(rect.x0, rect.x1);
591
+ const right = Math.max(rect.x0, rect.x1);
592
+ const top = Math.min(rect.y0, rect.y1);
593
+ const bottom = Math.max(rect.y0, rect.y1);
594
+ if (!state.addToSelection) {
595
+ state.selected.clear();
596
+ }
597
+ for (let i = 0; i < data.length; i += 1) {
598
+ if (!isVisible(data[i])) continue;
599
+ const screen = getScreenPoint(data[i]);
600
+ if (screen.x >= left && screen.x <= right && screen.y >= top && screen.y <= bottom) {
601
+ state.selected.add(i);
602
+ }
603
+ }
604
+ updateSidebar();
605
+ };
606
+
607
+ const hitTestPoint = (clickX, clickY, radius) => {
608
+ for (let i = 0; i < data.length; i += 1) {
609
+ if (!isVisible(data[i])) continue;
610
+ const screen = getScreenPoint(data[i]);
611
+ const dx = screen.x - clickX;
612
+ const dy = screen.y - clickY;
613
+ if (dx * dx + dy * dy <= radius * radius) {
614
+ return i;
615
+ }
616
+ }
617
+ return -1;
618
+ };
619
+
620
+ let scheduled = false;
621
+ const scheduleRender = () => {
622
+ if (scheduled) return;
623
+ scheduled = true;
624
+ requestAnimationFrame(() => {
625
+ scheduled = false;
626
+ render();
627
+ });
628
+ };
629
+
630
+ const drawRing = (x, y, size) => {
631
+ ctx.beginPath();
632
+ ctx.arc(x, y, size, 0, Math.PI * 2);
633
+ ctx.lineWidth = 1.5;
634
+ ctx.stroke();
635
+ };
636
+
637
+ const render = () => {
638
+ ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
639
+ const projected = [];
640
+ let visibleCount = 0;
641
+ for (let i = 0; i < data.length; i += 1) {
642
+ if (!isVisible(data[i])) continue;
643
+ visibleCount++;
644
+ const screen = getScreenPoint(data[i]);
645
+ projected.push({ index: i, screen });
646
+ }
647
+ if (state.mode === "3d") {
648
+ projected.sort((a, b) => a.screen.depth - b.screen.depth);
649
+ }
650
+ for (const item of projected) {
651
+ if (item.screen.culled) continue;
652
+ const point = data[item.index];
653
+ const isSelected = state.selected.has(item.index);
654
+ const size = isSelected ? 4 : 2.5;
655
+ ctx.fillStyle = getPointColor(point, isSelected);
656
+ if (point.type === "issue") {
657
+ ctx.strokeStyle = getPointColor(point, isSelected);
658
+ drawRing(item.screen.x, item.screen.y, size + 1);
659
+ } else {
660
+ ctx.beginPath();
661
+ ctx.arc(item.screen.x, item.screen.y, size, 0, Math.PI * 2);
662
+ ctx.fill();
663
+ }
664
+ }
665
+ if (state.selectRect) {
666
+ const rect = state.selectRect;
667
+ const left = Math.min(rect.x0, rect.x1);
668
+ const right = Math.max(rect.x0, rect.x1);
669
+ const top = Math.min(rect.y0, rect.y1);
670
+ const bottom = Math.max(rect.y0, rect.y1);
671
+ ctx.strokeStyle = colors.accent;
672
+ ctx.lineWidth = 1;
673
+ ctx.setLineDash([4, 4]);
674
+ ctx.strokeRect(left, top, right - left, bottom - top);
675
+ ctx.setLineDash([]);
676
+ }
677
+ hudCount.textContent = visibleCount + " / " + data.length + " items";
678
+ };
679
+
680
+ canvas.addEventListener("mousedown", (event) => {
681
+ if (event.button !== 0) return;
682
+ const point = getCanvasPoint(event);
683
+ const ctrlOrMeta = event.ctrlKey || event.metaKey;
684
+ state.mouseDownPos = { x: point.x, y: point.y };
685
+
686
+ if (event.shiftKey) {
687
+ state.selecting = true;
688
+ state.addToSelection = ctrlOrMeta;
689
+ state.selectRect = {
690
+ x0: point.x,
691
+ y0: point.y,
692
+ x1: point.x,
693
+ y1: point.y
694
+ };
695
+ scheduleRender();
696
+ return;
697
+ }
698
+
699
+ if (ctrlOrMeta) {
700
+ if (state.mode === "2d") {
701
+ state.selecting = true;
702
+ state.addToSelection = true;
703
+ state.selectRect = {
704
+ x0: point.x,
705
+ y0: point.y,
706
+ x1: point.x,
707
+ y1: point.y
708
+ };
709
+ scheduleRender();
710
+ return;
711
+ } else {
712
+ state.dragging = true;
713
+ state.dragMode = "pan3d";
714
+ state.dragStart = { x: point.x, y: point.y };
715
+ canvas.classList.add("dragging");
716
+ return;
717
+ }
718
+ }
719
+
720
+ state.dragging = true;
721
+ state.dragMode = state.mode === "3d" ? "rotate" : "pan";
722
+ state.dragStart = { x: point.x, y: point.y };
723
+ canvas.classList.add("dragging");
724
+ });
725
+
726
+ const handleMove = (event) => {
727
+ const point = getCanvasPoint(event);
728
+ if (state.dragging) {
729
+ const dx = point.x - state.dragStart.x;
730
+ const dy = point.y - state.dragStart.y;
731
+ if (state.dragMode === "pan") {
732
+ view2d.offsetX += dx;
733
+ view2d.offsetY += dy;
734
+ } else if (state.dragMode === "rotate") {
735
+ view3d.rotateY += dx * 0.005;
736
+ view3d.rotateX = clamp(view3d.rotateX + dy * 0.005, -1.5, 1.5);
737
+ } else if (state.dragMode === "pan3d") {
738
+ view3d.offsetX += dx;
739
+ view3d.offsetY += dy;
740
+ }
741
+ state.dragStart = { x: point.x, y: point.y };
742
+ scheduleRender();
743
+ return;
744
+ }
745
+ if (state.selecting && state.selectRect) {
746
+ state.selectRect.x1 = point.x;
747
+ state.selectRect.y1 = point.y;
748
+ scheduleRender();
749
+ }
750
+ };
751
+
752
+ const endDrag = (event) => {
753
+ const point = getCanvasPoint(event);
754
+ const wasSelecting = state.selecting;
755
+ const totalDist = Math.hypot(point.x - state.mouseDownPos.x, point.y - state.mouseDownPos.y);
756
+
757
+ if (state.dragging) {
758
+ state.dragging = false;
759
+ state.dragMode = null;
760
+ canvas.classList.remove("dragging");
761
+ }
762
+ if (state.selecting) {
763
+ state.selecting = false;
764
+ selectPoints();
765
+ state.selectRect = null;
766
+ state.addToSelection = false;
767
+ scheduleRender();
768
+ }
769
+
770
+ // Only deselect on actual click (no movement), not after drag
771
+ if (event.target !== canvas) return;
772
+ if (wasSelecting) return;
773
+
774
+ if (totalDist < 3) {
775
+ const hitIndex = hitTestPoint(point.x, point.y, 8);
776
+ if (hitIndex === -1) {
777
+ state.selected.clear();
778
+ updateSidebar();
779
+ scheduleRender();
780
+ }
781
+ }
782
+ };
783
+
784
+ canvas.addEventListener("mousemove", handleMove);
785
+ window.addEventListener("mousemove", handleMove);
786
+ window.addEventListener("mouseup", endDrag);
787
+
788
+ toggleMode.addEventListener("click", () => {
789
+ setMode(state.mode === "2d" ? "3d" : "2d");
790
+ });
791
+
792
+ [filterPr, filterIssue, filterOpen, filterClosed].forEach(el => {
793
+ el.addEventListener("change", scheduleRender);
794
+ });
795
+
796
+ openAllBtn.addEventListener("click", (e) => {
797
+ e.stopPropagation();
798
+ const selected = Array.from(state.selected);
799
+ for (const index of selected) {
800
+ window.open(data[index].url, "_blank");
801
+ }
802
+ });
803
+
804
+ copyBtn.addEventListener("click", (e) => {
805
+ e.stopPropagation();
806
+ const selected = Array.from(state.selected);
807
+ const lines = selected.map(index => {
808
+ const item = data[index];
809
+ const num = item.number ? "#" + item.number : "";
810
+ const type = item.type ? "[" + item.type.toUpperCase() + "]" : "";
811
+ return type + " " + num + " " + item.title + "\\n" + item.url;
812
+ });
813
+ navigator.clipboard.writeText(lines.join("\\n\\n"));
814
+ });
815
+
816
+ const doSearch = async () => {
817
+ const query = searchInput.value.trim();
818
+ if (!query) return;
819
+
820
+ if (!apiKey) {
821
+ apiKey = prompt("Enter your OpenAI API key (session only, not stored):");
822
+ if (!apiKey) return;
823
+ }
824
+
825
+ searchBtn.disabled = true;
826
+ searchBtn.textContent = "...";
827
+
828
+ try {
829
+ const response = await fetch("https://api.openai.com/v1/embeddings", {
830
+ method: "POST",
831
+ headers: {
832
+ "Content-Type": "application/json",
833
+ "Authorization": "Bearer " + apiKey
834
+ },
835
+ body: JSON.stringify({
836
+ model: "text-embedding-3-small",
837
+ input: query
838
+ })
839
+ });
840
+
841
+ if (!response.ok) {
842
+ const err = await response.text();
843
+ throw new Error(err);
844
+ }
845
+
846
+ const result = await response.json();
847
+ const queryEmb = result.data[0].embedding;
848
+
849
+ // Compute cosine similarity with all points
850
+ const similarities = data.map((point, index) => {
851
+ if (!point.embedding) return { index, sim: -1 };
852
+ let dot = 0, normA = 0, normB = 0;
853
+ for (let i = 0; i < queryEmb.length; i++) {
854
+ dot += queryEmb[i] * point.embedding[i];
855
+ normA += queryEmb[i] * queryEmb[i];
856
+ normB += point.embedding[i] * point.embedding[i];
857
+ }
858
+ const sim = dot / (Math.sqrt(normA) * Math.sqrt(normB));
859
+ return { index, sim };
860
+ });
861
+
862
+ similarities.sort((a, b) => b.sim - a.sim);
863
+
864
+ state.selected.clear();
865
+ const topN = Math.min(20, similarities.length);
866
+ for (let i = 0; i < topN; i++) {
867
+ if (similarities[i].sim > 0) {
868
+ state.selected.add(similarities[i].index);
869
+ }
870
+ }
871
+
872
+ updateSidebar();
873
+ scheduleRender();
874
+ } catch (err) {
875
+ alert("Search failed: " + err.message);
876
+ apiKey = null;
877
+ } finally {
878
+ searchBtn.disabled = false;
879
+ searchBtn.textContent = "Search";
880
+ }
881
+ };
882
+
883
+ searchBtn.addEventListener("click", doSearch);
884
+ searchInput.addEventListener("keydown", (e) => {
885
+ if (e.key === "Enter") doSearch();
886
+ });
887
+
888
+ canvas.addEventListener("contextmenu", (event) => {
889
+ event.preventDefault();
890
+ });
891
+
892
+ canvas.addEventListener("wheel", (event) => {
893
+ event.preventDefault();
894
+ const point = getCanvasPoint(event);
895
+ const zoomFactor = Math.exp(-event.deltaY * 0.001);
896
+ if (state.mode === "3d") {
897
+ view3d.zoom = clamp(view3d.zoom * zoomFactor, 0.4, 5);
898
+ scheduleRender();
899
+ return;
900
+ }
901
+ const prevScale = view2d.scale;
902
+ view2d.scale = clamp(view2d.scale * zoomFactor, 0.2, 12);
903
+ const scaleChange = view2d.scale / prevScale;
904
+ view2d.offsetX = point.x - (point.x - view2d.offsetX) * scaleChange;
905
+ view2d.offsetY = point.y - (point.y - view2d.offsetY) * scaleChange;
906
+ scheduleRender();
907
+ }, { passive: false });
908
+
909
+ window.addEventListener("resize", resize);
910
+ setMode("2d");
911
+ resize();
912
+ updateSidebar();
913
+ </script>
914
+ </body>
915
+ </html>`;
916
+ }
917
+ // CLI entry point
918
+ if (process.argv[1]?.endsWith("build.js") || process.argv[1]?.endsWith("build.ts")) {
919
+ const args = process.argv.slice(2);
920
+ const options = {
921
+ input: "embeddings.jsonl",
922
+ output: "triage.html",
923
+ projections: "projections.json",
924
+ neighbors: 15,
925
+ minDist: 0.1,
926
+ includeEmbeddings: false,
927
+ };
928
+ for (let i = 0; i < args.length; i += 1) {
929
+ const arg = args[i];
930
+ if (arg === "--input") {
931
+ options.input = args[++i];
932
+ }
933
+ else if (arg === "--output") {
934
+ options.output = args[++i];
935
+ }
936
+ else if (arg === "--projections") {
937
+ options.projections = args[++i];
938
+ }
939
+ else if (arg === "--neighbors") {
940
+ options.neighbors = Number(args[++i]);
941
+ }
942
+ else if (arg === "--min-dist") {
943
+ options.minDist = Number(args[++i]);
944
+ }
945
+ else if (arg === "--search") {
946
+ options.includeEmbeddings = true;
947
+ }
948
+ }
949
+ build(options);
950
+ }
951
+ //# sourceMappingURL=build.js.map