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:
committed by
safishamsi
co-authored by
Claude Opus 4.8
parent
5d63aad596
commit
7278e24d8a
@@ -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
@@ -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)))
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user