Repository navigation
Card graph: an Obsidian-style view of how cards connect - #104
Merged
Merged
Conversation
Two pure modules, no UI yet. cardGraph derives nodes, directed reference edges and tab groups straight from project state - nothing persisted, so it can't drift from the cards it describes. Edges are deduped (five mentions of a card is one relationship), self-references and edges to deleted cards dropped. collectRefText has to know every surface a token can live in: freeform blocks, monster notes, note descriptions and note entry descriptions. A field missing from that list is an edge the graph silently won't draw. graphLayout is deterministic rather than a force simulation. The same project always produces the same picture, so the graph is somewhere you can build spatial memory - which matters more here than organic clustering, because this app is already a spatial canvas and a layout that moved on its own would compete with the positions the user authored. A card is placed in one group, its first tab by viewOrder, not one per tab: positions are fixed, so duplicating a node would imply two cards. tabIds keeps the full membership for the view to mark shared cards. Cards in no tab get their own group rather than being dropped - they're the easiest to forget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Renders buildCardGraph/layoutCardGraph as SVG: one <g> per tab region, directed edges with arrowheads, node fill from the card's color class, a dashed ring for cards shared across tabs. Session gets isGraphOpen + setGraphOpen, mirroring isLibraryOpen's affordance (same reset-on- project-change effect, button always mounted). colors.scss grows a `fill` alongside each `background-color` rule so the existing color-key classes work on SVG shapes too, instead of hardcoding hex a second time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The panel was inset 24px on every side, which cut the 80px ToolMenu down its middle - the labels rendered as "fr" and "G" slivers against the panel edge, which reads as a rendering bug rather than something behind. Inset the left edge past the menu instead, and put a backdrop behind the panel so what it covers is clearly behind it rather than sliced. The backdrop closes the graph on click, same as the mobile create sheet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Visit the preview URL for this PR (updated for commit 39fbd2b): https://dmkit-e7e99--pr104-feature-card-graph-duu1nwxd.web.app (expires Tue, 13 Oct 2026 22:07:49 GMT) 🔥 via Firebase Hosting GitHub Action 🌎 Sign: 310a7f1d940a0f8f9346804f5fc60f8ba2a4f18d |
The graph button was fixed to the bottom-right corner, which sat on top of the Library panel's contents whenever it was open. It now lives directly under the library button inside .library, so it tracks the panel as it slides and never overlaps it. That also puts it behind the graph panel once open, so the panel carries its own close control. Tooltip moves to the left with the arrow on the right, matching the library button exactly - it now only overrides position and inherits the rest from App.scss's app-wide tooltip rule. The Barrow tab also houses the three monsters the one-shot uses, which is how a DM would actually run it: the stat block sits on the dungeon tab and in the Bestiary at once. This is the many-to-many the data model was built for, and it makes the graph's current "a card belongs to its first tab" rule visibly wrong - the Bestiary now renders with three monsters instead of six. A layout that handles membership properly is the next commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Regions couldn't represent the thing the graph exists to show. A card placed in three tabs has one position, so the old layout picked its first tab and dropped it from the others - with the monsters shared onto the Barrow, the Bestiary rendered with three of its six. Now each tab is a ring, drawn as arcs over the cards belonging to it, so a shared card is covered by two rings at once and the overlap IS the shared membership. The Bestiary's ring reaches round into the Barrow's stretch of the circle to cover Goblin, Goblin Boss and Skeleton. It also kills the complaint that started this: labels sit outside the card ring and every edge is a chord inside it, so an edge can never cross a label. That's structural rather than a routing trick - there is nowhere for a chord to go that a label occupies. buildCardGraph's groups[].cardIds is now full membership rather than first-tab ownership. What was groupId is primaryTabId, and it decides one thing only: where a card's single dot sits on the ring. Also removes a stale duplicate of .card-graph-edge further down the stylesheet that was quietly overriding the edge colour - the earlier rule looked correct in the file and did nothing in the browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- 10px between the library and graph buttons, up from 2. - Zoom and pan: wheel zooms about the pointer, drag pans, and a control cluster sits opposite the key. Zoom alone would have been useless here - the middle of a ring is the empty part, so magnifying it reaches nothing. Pointer-down on a node is ignored so a node's click still navigates. - The tab key keeps its bottom-left corner and gains a "Tabs" heading. It's HTML outside the SVG, so zooming never moves it. - Nodes carry their card-type icon, sized from a node radius that doubled to fit one, with the same dark-on-light icon pairing the card title bars use. - Rings twice as thick, and spaced further apart. The gap now shrinks once there are enough tabs that a fixed one would push the innermost ring past the centre, so every tab keeps a visible ring. - Focus/hover draws a halo round the dot instead of the browser's own focus ring, which on a <g> covers the whole bounding box - including the rotated label, so it rendered as a big rectangle skewed off at the label's angle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
React registers onWheel as a passive listener, so the preventDefault() in it
was silently ignored and a trackpad pinch zoomed the browser window instead
of the graph. Bound natively with { passive: false }, the same way Canvas
already does it, and wheel handling now follows Canvas's split: ctrl/meta
(a pinch) zooms about the pointer, a plain wheel pans. deltaMode
normalisation is shared with Canvas so a line- or page-mode wheel behaves the
same in both.
The listener also has to be keyed to the panel opening. The svg doesn't exist
until then, and a ref filling in doesn't re-run an effect by itself - bound
on mount alone it attached to nothing. Reopening the panel now also resets
the view instead of resuming wherever the last session left it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closing the panel no longer resets the view - reopening the graph puts you back where you were looking. It resets when the active project changes instead, since that's a different graph entirely. Stays in component state: worth keeping for the session, not worth persisting anywhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Typing a title another card already has commits as "Goblin 2"; copying a card does the same, so there's one naming rule rather than two. A title that already ends in a number counts up from there, so copying "Goblin 2" gives "Goblin 3" rather than "Goblin 2 2". Enforced in the createCard and updateCardTitle reducers rather than in a hook, so it holds for every path that can set a title - typing, copying one card, copying a selection - without each of them having to remember. Both copy thunks already route through createCard, so they needed no change. A card's own title is excluded when it's the one being renamed, or committing an unchanged title would bump it every time. References were never at risk: a #[Title](id) token resolves by id, so a duplicate title could not break a link. This is about the "#" picker, the Library and the graph, where two identical labels are indistinguishable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A relational view of the project, behind a toggle next to the canvas. Nodes are cards, edges are
#[Title](id)references, and tabs are labelled regions rather than nodes — so a tab doesn't become a hub wired to everything it holds.Deterministic, not force-directed
The layout is a fixed rule, not a physics simulation. Obsidian can use force layout because its notes have no position of their own; here position is already user-authored and meaningful, so a graph that rearranged itself each visit would compete with the canvas rather than complement it. Same project, same picture, every time — there's a test asserting byte-identical output across runs.
It also means no new dependency.
package.jsonis unchanged.The model
buildCardGraphderives everything fresh fromstate.project.present, so it can't drift from the cards it describes. Run against the intro project:Edges are deduped — two mentions of a card is one relationship, not two lines. Self-references and references to deleted cards are dropped; the latter still render as dangling chips on the card itself, but there's no node to draw a line to.
collectRefTextis the part that will rot. It has to know every surface a token can live in — freeform blocks, monster notes, note descriptions, note entry descriptions. A field that gains reference support later and isn't added there becomes an edge the graph silently fails to draw. Flagged in the code, with a test per surface.Cards in no tab get their own group rather than being dropped — they're the easiest to forget you have.
Clicking a node
Dispatches the existing
focusCard, so it inherits the whole path already built for reference chips: centre it on the canvas, or open the Library and blink it if it lives on another tab. No new navigation.Fixes made during review
frandGslivers against the panel edge. Left inset now clears it, with a backdrop so what's behind reads as behind.colors.scssgainedfillalongsidebackground-coloron each colour key, so one value drives both a DOM swatch and an SVG node. Checked for leakage:fillis inherited, but the Graph's own is the only inline SVG in the codebase, so nothing else is affected.Verification
784 tests pass (33 new), build clean. Checked in the browser at 1440×900: 4 groups / 17 nodes / 6 edges matching the model exactly, arrowheads pointing the right way, clicking Goblin closes the graph and lands on the card mid-blink. Both empty states verified visually by temporarily emptying the fixture — no cards, and cards with no references. Mobile is unaffected; the toggle doesn't render there.
🤖 Generated with Claude Code