cartographer / ui /src /components /ExploreView.jsx
umanggarg's picture
Canvas/Story product split + URL routing (Tier 1)
8d0110a
/**
* 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>
);
}