From 7278e24d8ad70fa1f91f2ce4bb7a45813950f30c Mon Sep 17 00:00:00 2001 From: Mohammed Ateeq <88542309+TPAteeq@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:26:10 +0100 Subject: [PATCH] fix(export): lay out canvas node cards in the box's sqrt(n)-column grid (#1452) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 1 + graphify/export.py | 20 ++++++++++++++------ tests/test_export.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index daa2bf4..51b849f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/graphify/export.py b/graphify/export.py index 084ba52..7e98f47 100644 --- a/graphify/export.py +++ b/graphify/export.py @@ -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))) diff --git a/tests/test_export.py b/tests/test_export.py index e5dbfa4..15c3c5b 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -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):