File size: 45,082 Bytes
3ad88a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c9c783c
288ab67
c9c783c
 
 
 
 
 
3ad88a4
3778745
 
 
 
 
3ad88a4
183f2e6
 
 
3ad88a4
183f2e6
 
 
 
3ad88a4
5fcd5c7
3ad88a4
 
 
 
 
 
8d0110a
 
 
 
 
 
 
5661eaf
8d0110a
5661eaf
 
8d0110a
 
 
 
 
5661eaf
 
 
 
 
 
 
 
ae949da
5661eaf
 
 
 
 
bcd9deb
5661eaf
 
3ad88a4
 
 
5661eaf
 
ae949da
 
3ad88a4
5661eaf
 
 
 
 
 
 
3ad88a4
 
 
 
5661eaf
 
 
 
3ad88a4
5661eaf
 
 
 
 
3ad88a4
5661eaf
3ad88a4
5661eaf
 
 
 
3ad88a4
 
 
8d0110a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3ad88a4
 
 
 
8d0110a
3ad88a4
 
8d0110a
3ad88a4
 
 
2191371
8d0110a
 
183f2e6
3ad88a4
 
2191371
8d0110a
3ad88a4
 
 
 
 
 
0687a87
3ad88a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8d0110a
 
 
 
 
 
 
 
 
 
3ad88a4
 
 
 
8d0110a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3ad88a4
 
 
 
 
 
 
 
 
 
0687a87
 
af2ddfc
 
 
 
 
 
0687a87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
af2ddfc
 
 
 
 
 
0687a87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
675dfb1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0687a87
 
 
 
 
 
 
 
 
 
3ad88a4
c9c783c
3ad88a4
 
c9c783c
0687a87
 
3ad88a4
 
8d0110a
 
 
 
288ab67
 
 
 
 
 
8d0110a
 
 
 
 
 
 
 
 
988ef7f
2191371
 
 
 
 
 
 
 
 
 
3ad88a4
 
c9c783c
3ad88a4
3778745
c9c783c
 
 
 
 
 
3778745
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3ad88a4
c9c783c
0687a87
 
3ad88a4
 
988ef7f
c9c783c
36dbedd
0687a87
 
 
 
 
c9c783c
3778745
 
c9c783c
 
 
 
 
 
 
3ad88a4
 
 
 
2191371
 
 
c9c783c
 
 
 
 
 
3778745
36dbedd
c9c783c
 
 
 
3ad88a4
 
 
 
 
 
a6aaf1e
ba23b72
 
 
 
 
 
 
 
3ad88a4
 
 
c5fea10
 
 
 
 
3ad88a4
9662976
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3ad88a4
c9c783c
 
 
 
 
 
 
 
 
 
2191371
 
 
 
 
 
 
 
 
 
 
c9c783c
 
 
 
 
 
 
 
2191371
c9c783c
 
2191371
c9c783c
 
 
 
 
 
 
2191371
c9c783c
3ad88a4
 
 
 
c9c783c
3ad88a4
 
2191371
 
 
 
 
 
 
 
 
 
3ad88a4
 
 
 
 
 
 
 
 
c9c783c
2bdaac4
 
 
3ad88a4
0b4051e
0687a87
 
 
 
 
 
 
 
 
 
c9c783c
0687a87
 
 
 
3ad88a4
0687a87
 
 
 
3ad88a4
 
 
 
 
 
 
 
c9c783c
3ad88a4
 
 
 
 
 
 
 
 
5661eaf
 
0687a87
 
5661eaf
0687a87
 
8d0110a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3ad88a4
 
 
 
 
 
 
2191371
 
 
8d0110a
 
3ad88a4
8d0110a
3ad88a4
 
 
8d0110a
 
 
3ad88a4
 
 
 
 
 
 
 
8d0110a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3ad88a4
 
 
 
 
07a9968
288ab67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3ad88a4
 
 
 
 
 
 
 
07a9968
3ad88a4
 
8887389
 
 
 
 
 
 
288ab67
8d0110a
 
 
 
 
 
 
 
 
 
 
288ab67
 
3ad88a4
 
 
291aab0
3ad88a4
291aab0
 
 
 
 
 
 
 
 
3ad88a4
 
 
 
 
 
 
 
 
 
 
0687a87
 
 
 
 
 
 
 
3ad88a4
 
 
 
 
 
 
 
0687a87
3ad88a4
 
5fcd5c7
3ad88a4
5661eaf
 
 
 
3ad88a4
 
5661eaf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3ad88a4
 
2191371
 
3ad88a4
 
6a9e16c
3ad88a4
0687a87
 
 
 
3ad88a4
 
0687a87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3ad88a4
 
 
 
 
 
183f2e6
2191371
183f2e6
0687a87
 
183f2e6
 
 
 
0687a87
183f2e6
 
 
 
8d0110a
 
183f2e6
 
2191371
 
183f2e6
 
 
3ad88a4
 
 
 
 
 
 
 
 
 
 
 
8d0110a
3ad88a4
 
288ab67
 
8887389
3ad88a4
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
/**
 * ExploreView.jsx β€” Interactive Codebase Tour.
 *
 * ═══════════════════════════════════════════════════════════════
 * WHAT THIS SHOWS
 * ═══════════════════════════════════════════════════════════════
 *
 * Instead of a raw scatter plot of files, this view teaches a student
 * HOW to approach a new codebase. The LLM generates 6-8 key concepts β€”
 * the ideas a student must understand β€” and their dependencies, then
 * renders them as an interactive node diagram:
 *
 *   [Value class] β†’ [Forward Pass] β†’ [Backward Pass] β†’ [Loss + SGD]
 *                β†˜ [MLP layer]   β†—
 *
 * Each node = a card you can click to expand. Arrows mean "you need
 * to understand X before Y". Reading order is encoded as numbered badges.
 *
 * ═══════════════════════════════════════════════════════════════
 * LAYOUT ALGORITHM
 * ═══════════════════════════════════════════════════════════════
 *
 * 1. Topological sort: assign each concept a column depth
 *    (longest dependency chain from a root node).
 * 2. Within each column, sort by reading_order.
 * 3. Center columns vertically relative to the tallest column.
 * 4. Draw bezier arrows between connected cards.
 *
 * ═══════════════════════════════════════════════════════════════
 * INTERACTIONS
 * ═══════════════════════════════════════════════════════════════
 *
 *   Click card  β†’ expand description + key methods
 *   Hover card  β†’ highlight its edges + connected nodes, dim others
 *   Ask button  β†’ pre-fills chat with a targeted question
 *   Scroll      β†’ zoom (non-passive wheel so preventDefault works)
 *   Drag        β†’ pan the canvas
 */

import { useEffect, useRef, useState, useCallback } from "react";
import { streamTour } from "../api";
import TourStory from "./TourStory";

// Module-level cache β€” survives tab switches because ExploreView is unmounted
// when the user navigates to Architecture/Class tabs and remounted on return.
// Without this, switching to Explore re-fetches (and re-generates) every time.
// Key: repo slug β†’ tour data object
const tourCache = {};

// localStorage key for a given repo's tour.
// We persist tour data across page refreshes so the backend (and LLM quota)
// is only hit once per repo, not on every refresh.
function tourLsKey(repo) { return `ghrc_tour_${repo.replace(/\//g, "_")}`; }

// ── Type β†’ visual token ───────────────────────────────────────────────────────
// Each concept type maps to a hue from a different part of the spectrum so
// they're immediately legible even at small sizes. Four clearly-distinct hues:
// blue (class), amber (function), violet (module), emerald (algorithm).
const TYPE_STYLE = {
  class:     { border: "#5B8FF9", glow: "rgba(91,143,249,0.38)",  dot: "#7DABFF", tag: "class"  }, // blue   240Β°
  function:  { border: "#FBBF24", glow: "rgba(251,191,36,0.32)",  dot: "#FCD34D", tag: "fn"     }, // amber   45Β°
  module:    { border: "#A78BFA", glow: "rgba(167,139,250,0.32)", dot: "#C4B5FD", tag: "module" }, // violet 270Β°
  algorithm: { border: "#34D399", glow: "rgba(52,211,153,0.32)",  dot: "#6EE7B7", tag: "algo"   }, // emerald 160Β°
};
const FALLBACK_STYLE = { border: "#4E5E80", glow: "rgba(78,94,128,0.30)", dot: "#8896B8", tag: "?" };

function styleFor(type) {
  return TYPE_STYLE[type] || FALLBACK_STYLE;
}

// ── Card geometry ─────────────────────────────────────────────────────────────
// Cards no longer grow on click (description moved to Story mode). The
// at-rest size covers name + subtitle + file + ask button only β€” key
// items and the "Builds on" row are revealed on hover via CSS max-height
// transitions. Hovering a card expands it AND its dependency neighbours
// (so context shows up alongside the focal concept). The lower rows are
// pushed down by EXPANSION_H below to keep the expansion from clipping
// into them.
const CARD_W      = 220;  // card width in canvas px
const CARD_H      = 142;  // at-rest card height
const COL_GAP     = 100;  // horizontal gap between cards in the same row
const ROW_GAP     = 72;   // vertical gap between rows
// Approximate height the card grows when hovered (key items row + builds-on
// row + paddings). Used to offset rows below a hovered card so the expansion
// has room to land. Slightly conservative β€” under-shooting causes overlap,
// over-shooting wastes a bit of vertical canvas during the hover.
const EXPANSION_H = 180;

// How many concepts appear in each horizontal row.
// With 12 concepts and PER_ROW=4: 3 rows of 4, reads like a book.
const PER_ROW = 4;

// ── Layout: row-major reading order ───────────────────────────────────────────
// Concepts are placed left-to-right by reading_order, wrapping to the next row
// after PER_ROW concepts β€” exactly like reading text.
//
//   1 β†’ 2 β†’ 3 β†’ 4
//   ↓
//   5 β†’ 6 β†’ 7 β†’ 8
//   ↓
//   9 β†’ 10 β†’ 11 β†’ 12
//
// This avoids the "spreadsheet" feel of column-major layouts where the eye
// must scan down a column then jump back to the top of the next column.
function computeLayout(concepts) {
  if (!concepts.length) return {};

  const sorted = [...concepts].sort((a, b) =>
    (a.reading_order ?? 999) - (b.reading_order ?? 999)
  );

  const positions = {};
  sorted.forEach((c, i) => {
    const row = Math.floor(i / PER_ROW);
    const col = i % PER_ROW;
    positions[c.id] = {
      x: col * (CARD_W + COL_GAP) + 48,
      y: row * (CARD_H + ROW_GAP) + 48,
    };
  });
  return positions;
}

// ── Arrow: cubic bezier between source and target ─────────────────────────────
// Normally left-to-right (right edge β†’ left edge). If the dependency arrow
// goes backwards (prerequisite placed to the right due to reading_order layout),
// flip to exit from the left edge and enter the right edge instead.
function bezierPath(fromPos, toPos) {
  const fromCenterX = fromPos.x + CARD_W / 2;
  const toCenterX   = toPos.x   + CARD_W / 2;
  const leftToRight = toCenterX >= fromCenterX;

  const x1 = leftToRight ? fromPos.x + CARD_W : fromPos.x;
  const y1 = fromPos.y + CARD_H / 2;
  const x2 = leftToRight ? toPos.x             : toPos.x + CARD_W;
  const y2 = toPos.y + CARD_H / 2;

  const tension = Math.max(Math.abs(x2 - x1) * 0.55, 60);
  const dir = leftToRight ? 1 : -1;
  return `M ${x1} ${y1} C ${x1 + dir * tension} ${y1}, ${x2 - dir * tension} ${y2}, ${x2} ${y2}`;
}

// ── ConceptCard ────────────────────────────────────────────────────────────────
//
// Canvas cards used to embed the full description here when selected, which
// duplicated content with Story mode. The split now is:
//   β€’ Canvas cards = relational view: name, file, key items, what this
//     depends on. A clicked card jumps the viewer to Story mode for the
//     deeper read.
//   β€’ Story mode = the reader: long-form description, code link, depends-on
//     pills with cross-references.
// Each view does one job well; we no longer maintain the same prose in two
// places.
function ConceptCard({
  concept, visualNum, isEntry, isHovered, isDimmed, pos,
  onOpenStory, onHover, onAsk, onDragStart, wasDragged,
  dependsOnNames,  // [{id, name}] β€” resolved neighbours for the "Connects to" row
}) {
  const s = styleFor(concept.type);

  return (
    <div
      className={`ec-card${isHovered ? " ec-hover" : ""}${isDimmed ? " ec-dimmed" : ""}`}
      style={{
        position: "absolute",
        zIndex: isHovered ? 10 : 1,
        left: pos.x,
        top: pos.y,
        width: CARD_W,
        cursor: "grab",
        borderColor: isHovered ? s.border : undefined,
        boxShadow: isHovered
          ? `0 0 0 2px ${s.border}, 0 0 20px ${s.glow.replace(/[\d.]+\)$/, '0.60)')}, 0 20px 60px ${s.glow.replace(/[\d.]+\)$/, '0.45)')}`
          : undefined,
      }}
      onMouseDown={(e) => onDragStart?.(e, concept, pos)}
      onClick={() => { if (!wasDragged?.current) onOpenStory(concept.id); }}
      onMouseEnter={() => onHover(concept.id)}
      onMouseLeave={() => onHover(null)}
    >
      {/* Top row: reading order badge + type tag */}
      <div className="ec-card-top">
        <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
          <span className="ec-order">{visualNum ?? concept.reading_order}</span>
          {isEntry && <span className="ec-entry-tag">Start here</span>}
        </div>
        <span className="ec-type-tag" style={{ color: s.dot, borderColor: `${s.dot}44` }}>
          {s.tag}
        </span>
      </div>

      {/* Name + subtitle */}
      <div className="ec-name">{concept.name}</div>
      <div className="ec-subtitle">{concept.subtitle}</div>

      {/* File pill */}
      <div className="ec-file">
        <svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" style={{ opacity: 0.5, flexShrink: 0 }}>
          <path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 8.75 4.25V1.5Zm6.75.56v2.19c0 .138.112.25.25.25h2.19Z"/>
        </svg>
        {concept.file}
      </div>

      {/* Key items β€” always visible. These are the named methods/functions
          inside the concept, the relational hook readers care about most
          when scanning the canvas ("what's IN this thing?"). */}
      {concept.key_items?.length > 0 && (
        <div className="ec-items">
          {concept.key_items.slice(0, 4).map(item => (
            <code key={item} className="ec-item">{item}</code>
          ))}
          {concept.key_items.length > 4 && (
            <span className="ec-item-more">+{concept.key_items.length - 4}</span>
          )}
        </div>
      )}

      {/* Connects to β€” surfaces the dependency edges as readable text so
          users can scan a card and see where exploration leads next without
          tracing arrows visually. Mirrors the depends_on data already used
          to draw the blue connection arrows on the canvas. */}
      {dependsOnNames?.length > 0 && (
        <div className="ec-connects">
          <span className="ec-connects-label">Builds on</span>
          <div className="ec-connects-list">
            {dependsOnNames.slice(0, 3).map(d => (
              <span key={d.id} className="ec-connects-item">{d.name}</span>
            ))}
            {dependsOnNames.length > 3 && (
              <span className="ec-connects-more">+{dependsOnNames.length - 3}</span>
            )}
          </div>
        </div>
      )}

      {/* Ask button β€” separate path from "Read in Story" (card body click).
          Story = read; Ask = converse. Two intents, two affordances. */}
      <button
        className="ec-ask"
        onClick={e => { e.stopPropagation(); onAsk(concept); }}
      >
        Ask about this β†’
      </button>
    </div>
  );
}

// ── TracePanel β€” live log of agent investigation steps ─────────────────────────
// Each entry in `log` is the "trace" payload from a TourAgent SSE event:
//   { type: "info"|"thinking"|"found"|"file"|"finding"|"react", text, name?, stages? }
//
// "react" entries come from the agentic Phase 1 ReAct loop β€” they show the
// THINK β†’ TOOL β†’ RESULT cycle that the agent uses to explore the codebase.
// Showing this live demonstrates how agentic AI works: the model reasons about
// what to read next, calls a tool, reads the result, and decides where to go.
//
// WHY SHOW THIS: transparency builds trust. When users see "Investigating:
// retrieval/hybrid_search.py" they understand WHY that concept appears in
// the tour β€” it was specifically investigated, not guessed from a keyword scan.
function TracePanel({ log, open, onToggle }) {
  const bodyRef = useRef(null);

  // Auto-scroll to bottom as new lines arrive
  useEffect(() => {
    if (open && bodyRef.current) {
      bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
    }
  }, [log, open]);

  const ICONS = {
    // ReAct loop step β€” tool icon (wrench) to distinguish from investigation steps
    react: (
      <svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
        <path d="M13.371 2.629a3.5 3.5 0 0 0-4.849 4.274L2.78 12.745a1.5 1.5 0 1 0 2.121 2.121l5.842-5.742a3.5 3.5 0 0 0 2.628-6.495zm-1.414 3.536a1.5 1.5 0 1 1-2.121-2.122 1.5 1.5 0 0 1 2.121 2.122z"/>
      </svg>
    ),
    thinking: (
      <svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
        <path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0zm.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533zM8 5.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/>
      </svg>
    ),
    found: (
      <svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
        <path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/>
      </svg>
    ),
    file: (
      <svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
        <path d="M4 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4.707A1 1 0 0 0 13.707 4L10 .293A1 1 0 0 0 9.293 0H4zm5.5 1.5v2a1 1 0 0 0 1 1h2l-3-3z"/>
      </svg>
    ),
    finding: (
      <svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
        <path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.099zm-5.242 1.656a5.5 5.5 0 1 1 0-11 5.5 5.5 0 0 1 0 11z"/>
      </svg>
    ),
    info: (
      <svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
        <path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
      </svg>
    ),
  };

  return (
    <div className="ec-trace-panel">
      <div className="ec-trace-header" onClick={onToggle}>
        <svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
          <path d="M5 3.5h6A1.5 1.5 0 0 1 12.5 5v5.034a.5.5 0 0 1-.276.447l-1.5.75-.448-.894.776-.388V5a.5.5 0 0 0-.5-.5H5a.5.5 0 0 0-.5.5v7a.5.5 0 0 0 .5.5h3.5v1H5A1.5 1.5 0 0 1 3.5 12V5A1.5 1.5 0 0 1 5 3.5z"/>
          <path d="M11.854 11.146a.5.5 0 0 0-.707.708L12.293 13H9.5a.5.5 0 0 0 0 1h2.793l-1.147 1.146a.5.5 0 0 0 .708.708l2-2a.5.5 0 0 0 0-.708l-2-2z"/>
        </svg>
        Agent trace β€” {log.length} steps
        <svg viewBox="0 0 16 16" fill="currentColor" width="10" height="10"
          style={{ marginLeft: "auto", transform: open ? "rotate(180deg)" : undefined, transition: "transform 0.2s" }}>
          <path d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z"/>
        </svg>
      </div>
      {open && (
        <div className="ec-trace-body" ref={bodyRef}>
          {log.map((entry, i) => (
            <div key={i} className="ec-trace-line">
              <span className={`ec-trace-icon ${entry.type}`}>
                {ICONS[entry.type] || ICONS.info}
              </span>
              <div className="ec-trace-text">
                {entry.type === "react" ? (
                  // ReAct entries: show tool call prominently, THINK text faint + truncated.
                  // entry.tool = "read_file("backend/services/agent.py")"
                  // entry.think = full reasoning sentence (can be 200+ chars)
                  <>
                    {entry.tool && <span className="ec-trace-react-tool">{entry.tool}</span>}
                    {entry.think && (
                      <span className="ec-trace-react-think">
                        {entry.think.length > 90 ? entry.think.slice(0, 90) + "…" : entry.think}
                      </span>
                    )}
                  </>
                ) : (
                  <>
                    {entry.name && <span className="ec-trace-name">{entry.name} </span>}
                    {entry.text && <span className="ec-trace-sub">{entry.text}</span>}
                    {entry.stages && (
                      <div className="ec-trace-stages">
                        {entry.stages.map((s, j) => (
                          <span key={j} className="ec-trace-stage-pill">{s}</span>
                        ))}
                      </div>
                    )}
                  </>
                )}
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

// ── ExploreView ────────────────────────────────────────────────────────────────
export default function ExploreView({ repo, onAskAbout, onRegenerateRef }) {
  const [data, setData]         = useState(null);
  const [loading, setLoading]   = useState(false);
  const [loadStage, setStage]   = useState(null);   // { stage, progress, message }
  const [traceLog, setTrace]    = useState([]);      // agent investigation steps
  const [traceOpen, setTrOpen]  = useState(true);   // trace panel expanded?
  const [error, setError]       = useState(null);
  const [hoveredId, setHovered]   = useState(null);
  // Tracks which concept Story mode should jump to when launched from a
  // Canvas card click. Bumped each click so TourStory remounts to that
  // concept; null means "open Story at the start" (e.g. via the mode tab).
  const [storyInitialId, setStoryInitialId] = useState(null);
  // "canvas" = scatter of cards + arrows; "story" = focused one-at-a-time reading.
  // Persist so the user's chosen mode survives page reloads.
  const [mode, setMode] = useState(
    () => localStorage.getItem("ghrc_tourMode") === "story" ? "story" : "canvas"
  );
  useEffect(() => { localStorage.setItem("ghrc_tourMode", mode); }, [mode]);

  // Open Story mode focused on a specific concept. Used by Canvas card
  // clicks: the card is the entry point, Story is the reader. Resetting
  // storyInitialId before bumping ensures the same-concept second click
  // still triggers a remount via the React key on TourStory below.
  function openStoryFor(conceptId) {
    setStoryInitialId(conceptId);
    setMode("story");
  }
  const [xform, setXform]       = useState({ x: 0, y: 0, scale: window.innerWidth < 768 ? 0.5 : 0.85 });
  const dragging   = useRef(false);
  const drag0      = useRef({});
  const wrapRef    = useRef(null);

  // Per-node drag β€” same pattern as GraphDiagram
  const [nodePos, setNodePos]  = useState({});  // id β†’ {x,y} overrides
  const dragNode   = useRef(null);              // active node drag state
  const wasDragged = useRef(false);            // suppress click-after-drag
  const scaleRef   = useRef(xform.scale);      // current scale for doc-level handlers
  useEffect(() => { scaleRef.current = xform.scale; }, [xform.scale]);

  // ── Fetch ─────────────────────────────────────────────────────────────────
  const load = useCallback((force = false) => {
    if (!repo) return;
    // 1. In-memory cache: survives tab switches within the same page session.
    if (!force && tourCache[repo]) {
      setData(tourCache[repo]);
      setLoading(false);
      setError(null);
      return;
    }
    // 2. localStorage cache: survives page refreshes. Avoids re-generating
    //    expensive LLM calls just because the user hit F5.
    if (!force) {
      try {
        const stored = localStorage.getItem(tourLsKey(repo));
        if (stored) {
          const parsed = JSON.parse(stored);
          tourCache[repo] = parsed;
          setData(parsed);
          setLoading(false);
          setError(null);
          return;
        }
      } catch { /* corrupt entry β€” fall through to fetch */ }
    }
    setLoading(true);
    setStage(null);
    setTrace([]);
    setTrOpen(true);
    setError(null);
    setData(null);
    setXform({ x: 0, y: 0, scale: window.innerWidth < 768 ? 0.5 : 0.85 });
    const cancel = streamTour(repo, {
      force,
      onProgress: (ev) => {
        setStage(ev);
        // Accumulate trace events for the live-log panel
        if (ev.trace) setTrace(prev => [...prev, ev.trace]);
      },
      onDone:     (d)  => {
        tourCache[repo] = d;
        try { localStorage.setItem(tourLsKey(repo), JSON.stringify(d)); } catch { /* quota full */ }
        setLoading(false);
        setStage(null);
        setData(d);
      },
      onError:    (e)  => { setLoading(false); setStage(null); setError(e); },
    });
    return cancel;
  }, [repo]);

  useEffect(() => { load(); }, [load]);

  // Reset dragged positions whenever a new tour loads
  useEffect(() => { setNodePos({}); }, [data]);

  // Expose a force-reload function to DiagramView via a ref so the header
  // "Regenerate" button can bust the cache without prop-drilling a callback.
  useEffect(() => {
    if (onRegenerateRef) {
      onRegenerateRef.current = () => {
        delete tourCache[repo];
        try { localStorage.removeItem(tourLsKey(repo)); } catch {}
        load(true);  // force=true β†’ api passes ?force=true β†’ backend busts disk cache
      };
    }
  }, [onRegenerateRef, repo, load]);

  // ── Non-passive wheel zoom ─────────────────────────────────────────────────
  useEffect(() => {
    const el = wrapRef.current;
    if (!el) return;
    function onWheel(e) {
      e.preventDefault();
      const f = Math.exp(-e.deltaY * 0.001);
      const rect = el.getBoundingClientRect();
      const mx = e.clientX - rect.left;
      const my = e.clientY - rect.top;
      setXform(t => {
        const newScale = Math.min(Math.max(t.scale * f, 0.3), 3);
        const ratio = newScale / t.scale;
        return { x: mx - (mx - t.x) * ratio, y: my - (my - t.y) * ratio, scale: newScale };
      });
    }
    el.addEventListener("wheel", onWheel, { passive: false });
    return () => el.removeEventListener("wheel", onWheel);
  // !!data as dep: ExploreView has early returns for loading/error/null states,
  // so wrapRef.current is null on first mount. Re-run once data arrives and the
  // canvas wrapper is actually in the DOM. Cleanup removes the old listener
  // before reattaching, so there's no double-registration risk.
  }, [!!data]);

  // Touch pan (1 finger) + pinch-to-zoom (2 fingers) β€” same logic as GraphDiagram
  useEffect(() => {
    const el = wrapRef.current;
    if (!el) return;
    let lastTouch = null;
    let lastPinch = null;
    function pinchDist(t) { return Math.hypot(t[1].clientX - t[0].clientX, t[1].clientY - t[0].clientY); }
    function pinchMid(t, rect) {
      return { x: (t[0].clientX + t[1].clientX) / 2 - rect.left,
               y: (t[0].clientY + t[1].clientY) / 2 - rect.top };
    }
    function onTouchStart(e) {
      if (e.touches.length === 1) {
        lastTouch = { x: e.touches[0].clientX, y: e.touches[0].clientY };
        lastPinch = null;
      } else if (e.touches.length === 2) {
        const t = [...e.touches];
        lastPinch = { dist: pinchDist(t), mid: pinchMid(t, el.getBoundingClientRect()) };
        lastTouch = null;
      }
    }
    function onTouchMove(e) {
      e.preventDefault();
      if (e.touches.length === 1 && lastTouch) {
        const dx = e.touches[0].clientX - lastTouch.x;
        const dy = e.touches[0].clientY - lastTouch.y;
        lastTouch = { x: e.touches[0].clientX, y: e.touches[0].clientY };
        setXform(t => ({ ...t, x: t.x + dx, y: t.y + dy }));
      } else if (e.touches.length === 2 && lastPinch) {
        const t   = [...e.touches];
        const rect = el.getBoundingClientRect();
        const d   = pinchDist(t);
        const mid = pinchMid(t, rect);
        const f   = d / lastPinch.dist;
        lastPinch = { dist: d, mid };
        setXform(s => {
          const newScale = Math.min(Math.max(s.scale * f, 0.3), 3);
          const ratio = newScale / s.scale;
          return { x: mid.x - (mid.x - s.x) * ratio, y: mid.y - (mid.y - s.y) * ratio, scale: newScale };
        });
      }
    }
    function onTouchEnd() { lastTouch = null; lastPinch = null; }
    el.addEventListener("touchstart", onTouchStart, { passive: true });
    el.addEventListener("touchmove",  onTouchMove,  { passive: false });
    el.addEventListener("touchend",   onTouchEnd);
    return () => {
      el.removeEventListener("touchstart", onTouchStart);
      el.removeEventListener("touchmove",  onTouchMove);
      el.removeEventListener("touchend",   onTouchEnd);
    };
  }, [!!data]);

  // ── Pan handlers ──────────────────────────────────────────────────────────
  // We attach mousemove/mouseup to the DOCUMENT rather than the wrapper div.
  //
  // Why: React synthetic events on the wrapper only fire when the pointer is
  // directly over the wrapper element. The moment it moves over a child card
  // the wrapper's onMouseMove stops firing, breaking the drag mid-gesture.
  //
  // Document-level listeners receive every mouse event regardless of which
  // element the cursor is currently over β€” the standard pattern for drag.
  useEffect(() => {
    function onDocMove(e) {
      // Node drag takes priority over canvas pan
      if (dragNode.current) {
        const dx = (e.clientX - dragNode.current.startMouse.x) / scaleRef.current;
        const dy = (e.clientY - dragNode.current.startMouse.y) / scaleRef.current;
        if (Math.abs(dx) > 4 || Math.abs(dy) > 4) wasDragged.current = true;
        const id   = dragNode.current.id;
        const newX = dragNode.current.startPos.x + dx;
        const newY = dragNode.current.startPos.y + dy;
        setNodePos(prev => ({ ...prev, [id]: { x: newX, y: newY } }));
        return;
      }
      if (!dragging.current) return;
      setXform(t => ({
        ...t,
        x: drag0.current.tx + (e.clientX - drag0.current.mx),
        y: drag0.current.ty + (e.clientY - drag0.current.my),
      }));
    }
    function onDocUp() {
      dragNode.current = null;
      dragging.current = false;
      if (wrapRef.current) wrapRef.current.style.cursor = "grab";
      setTimeout(() => { wasDragged.current = false; }, 0);
    }
    document.addEventListener("mousemove", onDocMove);
    document.addEventListener("mouseup",   onDocUp);
    return () => {
      document.removeEventListener("mousemove", onDocMove);
      document.removeEventListener("mouseup",   onDocUp);
    };
  }, []); // empty deps β€” only refs + stable setters used inside

  function onMouseDown(e) {
    if (e.button !== 0) return;
    dragging.current = true;
    drag0.current = { mx: e.clientX, my: e.clientY, tx: xform.x, ty: xform.y };
    if (wrapRef.current) wrapRef.current.style.cursor = "grabbing";
  }

  function onNodeDragStart(e, concept, currentPos) {
    if (e.button !== 0) return;
    e.stopPropagation(); // prevent canvas pan from activating
    dragNode.current = {
      id:         concept.id,
      startPos:   currentPos,
      startMouse: { x: e.clientX, y: e.clientY },
    };
  }

  function handleAsk(concept) {
    onAskAbout?.(
      concept.ask ||
      `Explain "${concept.name}" in ${repo} in detail β€” what does it do, how does it work, and what are the key methods or functions involved?`
    );
  }

  // ── Loading / error states ─────────────────────────────────────────────────
  if (loading) {
    const pct   = loadStage ? Math.round(loadStage.progress * 100) : 0;
    const rawLabel = loadStage?.message || "Building your guided tour…";
    // Cap the progress label β€” long THINK strings must not overflow this area
    const label = rawLabel.length > 72 ? rawLabel.slice(0, 72) + "…" : rawLabel;
    return (
      <div className="ec-loading" style={{ flexDirection: "column", alignItems: "stretch", gap: 16, maxWidth: 480, margin: "auto" }}>
        {/* Progress row */}
        <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
          <span className="spinner" />
          <div style={{ flex: 1 }}>
            <div style={{ fontWeight: 600, marginBottom: 6, fontSize: 13 }}>{label}</div>
            <div style={{ height: 3, background: "var(--border)", borderRadius: 2, overflow: "hidden" }}>
              <div style={{
                height: "100%", width: `${pct}%`,
                background: "var(--accent)", borderRadius: 2, transition: "width 0.5s ease",
              }} />
            </div>
            {pct > 0 && (
              <div style={{ fontSize: 11, color: "var(--muted)", marginTop: 4 }}>{pct}%</div>
            )}
          </div>
        </div>
        {/* Live agent trace log */}
        {traceLog.length > 0 && (
          <TracePanel log={traceLog} open={traceOpen} onToggle={() => setTrOpen(v => !v)} />
        )}
      </div>
    );
  }

  if (error) {
    return (
      <div className="ec-error">
        <div style={{ fontSize: 13, color: "var(--red)" }}>{error}</div>
        <button className="diagram-retry-btn" onClick={() => load(true)}>Retry</button>
      </div>
    );
  }

  if (!data) return null;

  const concepts      = data.concepts || [];
  const basePositions = computeLayout(concepts);

  // Visual sequence numbers: row-first then column β€” matches the left-to-right,
  // top-to-bottom reading order of the row-major layout.
  const visualNumber = {};
  Object.entries(basePositions)
    .sort(([, a], [, b]) => a.y !== b.y ? a.y - b.y : a.x - b.x)
    .forEach(([id], i) => { visualNumber[Number(id)] = i + 1; });

  // When a card is hovered, push every card in a row strictly BELOW the
  // hovered card's row down by EXPANSION_H so the expansion has room. The
  // lower rows reflow smoothly because positions feed into a CSS transform
  // with a transition on the `top` property β€” visually the lower rows
  // glide down rather than jumping.
  //
  // We use the HOVERED row, not the connected-set row: the connected
  // neighbours are usually in the same row as the hovered card (left/right
  // of it), so pushing only the hovered row's lower neighbours is correct.
  // If a connected neighbour is in a different row, the offset still
  // applies via the hovered card's row check below.
  const yOffsets = (() => {
    if (hoveredId === null) return {};
    const hoverPos = basePositions[hoveredId];
    if (!hoverPos) return {};
    const offsets = {};
    concepts.forEach(c => {
      const p = basePositions[c.id];
      if (p && p.y > hoverPos.y) offsets[c.id] = EXPANSION_H;
    });
    return offsets;
  })();
  const positions = Object.fromEntries(
    Object.entries(basePositions).map(([id, pos]) => [
      id,
      { x: pos.x, y: pos.y + (yOffsets[id] ?? 0) },
    ])
  );

  // Dragged position overrides static layout β€” falls back to positions[id]
  const getPosFor = (id) => nodePos[id] ?? positions[id];

  // Canvas bounding box β€” accounts for the maximum offset that any card
  // could pick up if a top-row card is hovered (push amount = EXPANSION_H).
  const allX = Object.values(positions).map(p => p.x + CARD_W + 80);
  const allY = Object.values(positions).map(p => p.y + CARD_H + EXPANSION_H + 80);
  const canvasW = Math.max(...allX, 700);
  const canvasH = Math.max(...allY, 500);

  // Connected set for hover dimming: hovered node + its direct neighbors
  // (concepts that depend on it AND concepts it depends on). Other cards
  // get the .ec-dimmed class so the relational structure pops on hover.
  const connectedIds = hoveredId !== null
    ? new Set([
        hoveredId,
        ...concepts.filter(c => c.depends_on?.includes(hoveredId)).map(c => c.id),
        ...(concepts.find(c => c.id === hoveredId)?.depends_on ?? []),
      ])
    : null;

  // Resolve depends_on ids β†’ concept names for the "Builds on" row on each
  // card. Done once per render so cards don't each re-walk the concepts
  // array. Missing ids are filtered out (the LLM occasionally references
  // concepts that didn't make the final cut).
  const conceptById = Object.fromEntries(concepts.map(c => [c.id, c]));
  const dependsOnByCard = Object.fromEntries(
    concepts.map(c => [
      c.id,
      (c.depends_on ?? [])
        .map(depId => conceptById[depId])
        .filter(Boolean)
        .map(dep => ({ id: dep.id, name: dep.name })),
    ])
  );

  return (
    <div className="ec-container">
      {/* ── Summary header ── */}
      <div className="ec-header">
        <div className="ec-summary">{data.summary}</div>
        <div className="ec-controls">
        <div className="ec-mode-toggle" role="tablist" aria-label="Tour view mode">
          <button
            role="tab"
            aria-selected={mode === "canvas"}
            className={`ec-mode-btn${mode === "canvas" ? " is-active" : ""}`}
            onClick={() => setMode("canvas")}
            title="See all concepts at once"
          >
            <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
              <rect x="1.5" y="1.5" width="5.5" height="5.5" rx="1"/>
              <rect x="9" y="1.5" width="5.5" height="5.5" rx="1"/>
              <rect x="1.5" y="9" width="5.5" height="5.5" rx="1"/>
              <rect x="9" y="9" width="5.5" height="5.5" rx="1"/>
            </svg>
            Canvas
          </button>
          <button
            role="tab"
            aria-selected={mode === "story"}
            className={`ec-mode-btn${mode === "story" ? " is-active" : ""}`}
            onClick={() => setMode("story")}
            title="Read one concept at a time"
          >
            <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
              <path d="M2 3.25C2 2.56 2.56 2 3.25 2h9.5c.69 0 1.25.56 1.25 1.25v9.5c0 .69-.56 1.25-1.25 1.25h-9.5C2.56 14 2 13.44 2 12.75v-9.5ZM3.5 3.5v9h9v-9h-9Z"/>
              <path d="M5 6h6v1.2H5V6Zm0 2.4h6v1.2H5V8.4Z"/>
            </svg>
            Story
          </button>
        </div>
        {data.entry_point && (
          <div className="ec-entry-hint">
            <svg width="11" height="11" viewBox="0 0 16 16" fill="currentColor" style={{ opacity: 0.6 }}>
              <path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm4.879-2.773 4.264 2.559a.25.25 0 0 1 0 .428l-4.264 2.559A.25.25 0 0 1 6 10.559V5.442a.25.25 0 0 1 .379-.215Z"/>
            </svg>
            Start reading: <code>{data.entry_point}</code>
          </div>
        )}
        </div>
      </div>

      {/* Keyed flex wrapper β€” remounts on mode change so .view-switch-in replays.
          display:flex + flex:1 so Canvas/Story still fill the container. */}
      <div
        key={mode}
        className="view-switch-in"
        style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}
      >
      {mode === "story" ? (
        // Key on storyInitialId so opening Story for a new concept remounts
        // and re-runs the initializer that picks the starting index. Without
        // the key, a second card click while already in Story mode wouldn't
        // jump to the new concept (useState initializer fires once).
        <TourStory
          key={`story-${storyInitialId ?? "start"}`}
          data={data}
          repo={repo}
          onAskAbout={onAskAbout}
          initialConceptId={storyInitialId}
        />
      ) : (
      <>
      {/* ── Canvas ── */}
      <div
        ref={wrapRef}
        className="ec-canvas-wrapper has-cursor-glow"
        onMouseDown={onMouseDown}
        // Feed --mx / --my for the cursor-glow primitive.
        // Pan drag uses onMouseDown + document mousemove, so this local
        // handler doesn't interfere with drag gesture state.
        onMouseMove={(e) => {
          const r = e.currentTarget.getBoundingClientRect();
          e.currentTarget.style.setProperty("--mx", `${e.clientX - r.left}px`);
          e.currentTarget.style.setProperty("--my", `${e.clientY - r.top}px`);
        }}
        style={{ "--glow-size": "520px", "--glow-intensity": "8%" }}
      >
        <div
          className="ec-canvas"
          style={{
            width: canvasW,
            height: canvasH,
            transform: `translate(${xform.x}px, ${xform.y}px) scale(${xform.scale})`,
            transformOrigin: "0 0",
          }}
        >
          {/* ── SVG arrow layer ── */}
          {/* Each arrow has two layers:
              1. Base path β€” solid thin line + arrowhead (always visible)
              2. Traveling dot β€” small glowing circle that animates from source to target
                 using <animateMotion> + <mpath>. This communicates DIRECTION: you can
                 instantly see which way concepts depend on each other. The dot fades in
                 after 10% of the journey and fades out before 90% so it never looks
                 abrupt at the endpoints. Highlighted arrows skip the dot β€” the glow
                 filter communicates selection state instead. */}
          <svg
            width={canvasW}
            height={canvasH}
            style={{ position: "absolute", inset: 0, pointerEvents: "none", overflow: "visible" }}
            aria-hidden="true"
          >
            <defs>
              <marker id="ec-arrow" markerWidth="7" markerHeight="5" refX="7" refY="2.5" orient="auto">
                <polygon points="0 0, 7 2.5, 0 5" fill="rgba(91,143,249,0.4)" />
              </marker>
              <marker id="ec-arrow-hi" markerWidth="7" markerHeight="5" refX="7" refY="2.5" orient="auto">
                <polygon points="0 0, 7 2.5, 0 5" fill="#7DABFF" />
              </marker>
              {/* Amber arrowhead for sequential reading-path arrows */}
              <marker id="ec-arrow-seq" markerWidth="7" markerHeight="5" refX="7" refY="2.5" orient="auto">
                <polygon points="0 0, 7 2.5, 0 5" fill="rgba(245,158,11,0.75)" />
              </marker>
            </defs>

            {/* ── Sequential reading-path arrows (amber) ──────────────────
                 Connect concept N β†’ N+1 in reading order so the learning
                 path is visually explicit. These are the primary navigation
                 guide; dependency arrows (blue) are supporting context. */}
            {(() => {
              const seq = [...concepts].sort((a, b) =>
                (a.reading_order ?? 999) - (b.reading_order ?? 999)
              );
              return seq.slice(0, -1).map((c, i) => {
                const next = seq[i + 1];
                const from = getPosFor(c.id);
                const to   = getPosFor(next.id);
                if (!from || !to) return null;
                const d = bezierPath(from, to);
                const isDim = connectedIds && !connectedIds.has(c.id) && !connectedIds.has(next.id);
                return (
                  <path
                    key={`seq-${c.id}β†’${next.id}`}
                    d={d}
                    stroke="rgba(245,158,11,0.50)"
                    strokeWidth="1.5"
                    fill="none"
                    markerEnd="url(#ec-arrow-seq)"
                    strokeDasharray="5 3"
                    style={{ opacity: isDim ? 0.06 : 1, transition: "opacity 0.15s" }}
                  />
                );
              });
            })()}

            {/* ── Dependency arrows (blue) β€” prerequisite relationships ── */}
            {concepts.map(c =>
              (c.depends_on ?? []).map(depId => {
                const from = getPosFor(depId);
                const to   = getPosFor(c.id);
                if (!from || !to) return null;

                const isHi  = connectedIds?.has(c.id) && connectedIds?.has(depId);
                const isDim = connectedIds && !isHi;
                const pathId = `ec-path-${depId}-${c.id}`;
                // Stagger dot travel per connection so all dots don't move in sync
                const stagger = `${((depId * 3 + c.id * 7) % 40) / 10}s`;
                const d = bezierPath(from, to);

                return (
                  <g key={`${depId}β†’${c.id}`} style={{ opacity: isDim ? 0.08 : 1, transition: "opacity 0.15s" }}>
                    {/* Base arrow path */}
                    <path
                      id={pathId}
                      d={d}
                      stroke={isHi ? "#7DABFF" : "rgba(91,143,249,0.35)"}
                      strokeWidth={isHi ? 2 : 1.2}
                      fill="none"
                      markerEnd={isHi ? "url(#ec-arrow-hi)" : "url(#ec-arrow)"}
                      style={{
                        filter: isHi ? "drop-shadow(0 0 3px rgba(125,171,255,0.6))" : undefined,
                        transition: "stroke 0.15s, stroke-width 0.15s, filter 0.15s",
                      }}
                    />
                    {/* Traveling dot β€” communicates flow direction.
                        Hidden on highlighted arrows (glow covers it) and dimmed arrows. */}
                    {!isHi && !isDim && (
                      <circle r="2.5" fill="#7DABFF">
                        {/* Fade in at 10%, full at 20%, full at 80%, fade out at 90% */}
                        <animate attributeName="opacity"
                          values="0;0;1;1;0;0" keyTimes="0;0.1;0.2;0.8;0.9;1"
                          dur="4s" begin={stagger} repeatCount="indefinite" />
                        <animateMotion dur="4s" begin={stagger} repeatCount="indefinite" rotate="auto">
                          <mpath href={`#${pathId}`} />
                        </animateMotion>
                      </circle>
                    )}
                  </g>
                );
              })
            )}
          </svg>

          {/* ── Concept cards ── */}
          {concepts.map(c => {
            const pos = getPosFor(c.id);
            if (!pos) return null;
            // isEntry = the leftmost card (visual number 1) β€” always the pipeline overview
            const isEntry = visualNumber[c.id] === 1;
            return (
              <ConceptCard
                key={c.id}
                concept={c}
                visualNum={visualNumber[c.id]}
                isEntry={isEntry}
                isHovered={hoveredId === c.id || (!!connectedIds && connectedIds.has(c.id))}
                isDimmed={!!connectedIds && !connectedIds.has(c.id)}
                pos={pos}
                dependsOnNames={dependsOnByCard[c.id]}
                onOpenStory={openStoryFor}
                onHover={setHovered}
                onAsk={handleAsk}
                onDragStart={onNodeDragStart}
                wasDragged={wasDragged}
              />
            );
          })}
        </div>
      </div>

      {/* ── Legend + hint ── */}
      <div className="ec-legend">
        {Object.entries(TYPE_STYLE).map(([type, s]) => (
          <span key={type} className="ec-legend-item">
            <span className="ec-legend-dot" style={{ background: s.dot }} />
            {type}
          </span>
        ))}
        <span className="ec-legend-hint">
          {concepts.length} concepts Β· scroll to zoom Β· drag to pan Β· hover for detail Β· click to read
        </span>
      </div>
      </>
      )}
      </div>
    </div>
  );
}