fix(export): lay out canvas node cards in the box's sqrt(n)-column grid (#1452)

to_canvas sized each community group box for a ceil(sqrt(n))-column grid but the
placement loop hardcoded 3 columns, so any community bigger than ~9 members
rendered as a cramped 3-wide strip in an over-wide, mostly-empty box (and the box
width/height didn't even agree — w used sqrt(n), h used /3). The column count is
now computed once per community (inner_cols) and reused for box width, box height,
and card placement, so the cards fill the box. Cosmetic, no data change.

Ported from PR #1459 by @TPAteeq onto current v8 (clean: only the grid math
changed, the #1457 dedup helper is untouched). Verified the geometry on a real
canvas: n=25 -> 5x5 grid with every card inside its box; n=10 -> 4 columns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mohammed Ateeq
2026-06-25 12:26:10 +01:00
committed by safishamsi
co-authored by Claude Opus 4.8
parent 5d63aad596
commit 7278e24d8a
3 changed files with 58 additions and 6 deletions
+1
View File
@@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## Unreleased
- Fix: `to_canvas` (Obsidian Canvas export) now lays out each community's node cards in the same `ceil(sqrt(n))`-column grid the group box is sized for. The box width assumed a roughly-square `sqrt(n)`-column layout, but the placement loop hardcoded 3 columns, so any community larger than ~9 members rendered as a cramped 3-wide strip in an over-wide, mostly-empty box. The column count is now computed once per community and reused for the box width, box height, and card placement, so the cards fill the box. Cosmetic, no data change (#1452, thanks @TPAteeq).
- Fix: `to_obsidian` / `to_canvas` / `to_wiki` no longer silently overwrite notes whose labels differ only by case (e.g. a class `References` and a prose heading `references`). The filename dedup was keyed on the exact-case name, so two such labels counted as non-colliding and the second write clobbered the first on case-insensitive filesystems (macOS/APFS, Windows/NTFS) — no suffix, no warning. Dedup now folds case (keyed on the lowercased name) while still emitting the original-case filename, so any pair that would collide on disk gets a numeric suffix. The obsidian/canvas dedup is shared in one helper so they can't drift, `wiki`'s slug dedup gets the matching fix, the `_COMMUNITY_*.md` overview notes (which had no dedup) are covered, and a generated `base_1` is itself re-checked so it can't overwrite a node literally labelled `base_1` (#1453, thanks @TPAteeq).
## 0.8.49 (2026-06-24)
+14 -6
View File
@@ -1178,15 +1178,21 @@ def to_canvas(
group_x_offsets: list[int] = []
group_y_offsets: list[int] = []
# Precompute group sizes so we can calculate offsets
# Precompute group sizes so we can calculate offsets.
# inner_cols is the per-community grid width; the box dimensions AND the node
# placement loop below both derive from it, so the cards always fill the box
# instead of wrapping into a narrow strip inside an oversized box.
sorted_cids = sorted(communities.keys())
group_sizes: dict[int, tuple[int, int]] = {}
group_cols: dict[int, int] = {}
for cid in sorted_cids:
members = communities[cid]
n = len(members)
w = max(600, 220 * math.ceil(math.sqrt(n)) if n > 0 else 600)
h = max(400, 100 * math.ceil(n / 3) + 120 if n > 0 else 400)
inner_cols = max(1, math.ceil(math.sqrt(n)))
w = max(600, 220 * inner_cols)
h = max(400, 100 * math.ceil(n / inner_cols) + 120)
group_sizes[cid] = (w, h)
group_cols[cid] = inner_cols
# Compute cumulative row heights and col widths for grid placement
# Each grid cell uses the max width/height in its col/row
@@ -1250,11 +1256,13 @@ def to_canvas(
"color": canvas_color,
})
# Node cards inside the group - rows of 3
# Node cards inside the group - laid out in the same ceil(sqrt(n))-column
# grid the box was sized for (group_cols[cid]), so cards fill the box.
inner_cols = group_cols[cid]
sorted_members = sorted(members, key=lambda n: G.nodes[n].get("label", n))
for m_idx, node_id in enumerate(sorted_members):
col = m_idx % 3
row = m_idx // 3
col = m_idx % inner_cols
row = m_idx // inner_cols
nx_x = gx + 20 + col * (180 + 20)
nx_y = gy + 80 + row * (60 + 20)
fname = node_filenames.get(node_id, safe_name(G.nodes[node_id].get("label", node_id)))
+43
View File
@@ -1,4 +1,5 @@
import json
import math
import re
import tempfile
from pathlib import Path
@@ -194,6 +195,48 @@ def test_to_canvas_no_communities_still_populates():
assert out.stat().st_size > 32
def test_to_canvas_node_grid_matches_box_columns():
"""#1452: a community's node cards are laid out in the same ceil(sqrt(n))-column
grid the group box is sized for. Previously the box width assumed sqrt(n)
columns while the placement loop hardcoded 3, so any community bigger than ~9
rendered as a cramped 3-wide strip filling only part of an over-wide box.
Covers a perfect square (25 -> 5x5) and a non-square count (10 -> 4 cols, a
partial last row) so both the column count and the row count are pinned."""
for n in (10, 25):
G = build_from_json({
"nodes": [
{"id": f"n{i}", "label": f"sym_{i:02d}", "file_type": "code", "source_file": "a.py"}
for i in range(n)
],
"edges": [],
})
communities = {0: [f"n{i}" for i in range(n)]}
with tempfile.TemporaryDirectory() as tmp:
out = Path(tmp) / "graph.canvas"
to_canvas(G, communities, str(out))
data = json.loads(out.read_text())
group = next(g for g in data["nodes"] if g.get("type") == "group")
cards = [c for c in data["nodes"] if c.get("type") == "file"]
assert len(cards) == n, f"n={n}"
# Cards occupy the ceil(sqrt(n))-column / ceil(n/cols)-row grid the box is
# sized for — not the old fixed 3 columns, which spread cards across far
# more rows (the load-bearing checks: distinct column/row positions).
expected_cols = math.ceil(math.sqrt(n))
expected_rows = math.ceil(n / expected_cols)
distinct_x = len({c["x"] for c in cards})
distinct_y = len({c["y"] for c in cards})
assert distinct_x == expected_cols, f"n={n}: expected {expected_cols} cols, got {distinct_x}"
assert distinct_y == expected_rows, f"n={n}: expected {expected_rows} rows, got {distinct_y}"
# And every card sits fully inside its group box on both axes.
gx, gy, gw, gh = group["x"], group["y"], group["width"], group["height"]
for c in cards:
assert gx <= c["x"] and c["x"] + c["width"] <= gx + gw, (n, c)
assert gy <= c["y"] and c["y"] + c["height"] <= gy + gh, (n, c)
# ── Issue #1409: punctuation-only Obsidian/Canvas filenames ───────────────────
def _punct_graph(label: str):