Skip to content

Card graph: an Obsidian-style view of how cards connect - #104

Merged
rayzhou-bit merged 10 commits into
masterfrom
feature/card-graph
Oct 6, 2026
Merged

rayzhou-bit merged 10 commits into
masterfrom
feature/card-graph

Conversation

@rayzhou-bit

Copy link
Copy Markdown
Owner

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.json is unchanged.

The model

buildCardGraph derives everything fresh from state.project.present, so it can't drift from the cards it describes. Run against the intro project:

nodes 17  edges 6
groups: Start Here(3), Sunken Barrow(4), Bestiary(6), Party(4)

  Cards               → The Sunken Barrow
  1 - The Barrow Door → Goblin
  2 - The Warren      → Goblin
  2 - The Warren      → Goblin Boss
  3 - The Sunken Tomb → Skeleton
  Goblin Boss         → Goblin

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.

collectRefText is 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

  • The panel sliced the tool menu. A 24px inset cut the 80px ToolMenu down its middle, rendering fr and G slivers against the panel edge. Left inset now clears it, with a backdrop so what's behind reads as behind.
  • colors.scss gained fill alongside background-color on each colour key, so one value drives both a DOM swatch and an SVG node. Checked for leakage: fill is inherited, but the Graph's own is the only inline SVG in the codebase, so nothing else is affected.
  • Node hit areas and the toggle's z-index were both fixed during the build — a click could otherwise fall through the gap between a node's dot and its label.

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

rayzhou-bit and others added 3 commits October 6, 2026 01:11
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>
@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

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

rayzhou-bit and others added 7 commits October 6, 2026 11:31
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>
@rayzhou-bit
rayzhou-bit merged commit d5fdaaa into master Oct 6, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant