# Obsidian Canvas JSON Specification

Canvas files are JSON with two top-level keys: `nodes` (array) and `edges` (array).
Obsidian reads and writes them as UTF-8 JSON files with `.canvas` extension.

This reference aligns with the [JSON Canvas 1.0 open specification](https://jsoncanvas.org/spec/1.0/). All structures support arbitrary additional fields (`[key: string]: any`) for forward compatibility. Obsidian will preserve unknown fields when reading and writing canvas files.

**ID format**: The JSON Canvas 1.0 spec recommends 16-character lowercase hexadecimal IDs (e.g., `"a1b2c3d4e5f67890"`). Obsidian itself generates IDs in this format. The descriptive ID examples in this reference (`"text-title-4821"`, `"img-cover-7823"`) are an alternative naming convention that this plugin uses for human readability. Both are valid JSON Canvas. Use whichever fits your workflow.

---

## Coordinate System

```
        x increases →
   ┌─────────────────────────────────
   │  (-920, -2400)      (0, -2400)
   │
y  │  (-920, 0)          (0, 0) ← origin
↓  │
   │  (-920, 540)        (500, 540)
```

- **Origin** (0, 0) is the center of the canvas viewport.
- **x increases rightward.** Negative x = left of center.
- **y increases downward.** Negative y = above center.
- Node `x` and `y` are the **top-left corner** of the node, not the center.
- Obsidian pans to fit all nodes on first open.

---

## Node Types

### Text node

Renders markdown content as a styled card.

```json
{
  "id": "text-title-4821",
  "type": "text",
  "text": "# Heading\n\nParagraph with **bold** and `code`.",
  "x": -400,
  "y": -300,
  "width": 400,
  "height": 120,
  "color": "6"
}
```

- `text`: markdown string. Use `\n` for newlines.
- Minimum readable size: width ≥ 200, height ≥ 60.
- `color` is optional. Omit for default (no color).

---

### File node

Renders an image, PDF, markdown note, or other vault file inline.

```json
{
  "id": "img-cover-7823",
  "type": "file",
  "file": "_attachments/images/example.png",
  "x": -900,
  "y": -100,
  "width": 420,
  "height": 236
}
```

- `file`: **vault-relative path** (not absolute, not `~/`).
- Supported: `.png` `.jpg` `.webp` `.gif` `.pdf` `.md` `.canvas`
- For `.md` files: renders as a preview card.
- For `.pdf` files: renders the first page as preview.
- No `color` field for file nodes: color is ignored.

---

### Group node (Zone)

A labeled rectangular region. Does not clip or contain nodes. It's a visual guide.
Nodes placed "inside" a group are just positioned within its bounding box.

```json
{
  "id": "zone-branding-3391",
  "type": "group",
  "label": "Brand Identity",
  "x": -920,
  "y": -880,
  "width": 1060,
  "height": 290,
  "color": "6",
  "background": "_attachments/images/grid-bg.png",
  "backgroundStyle": "cover"
}
```

- `label`: shown at the top of the group box.
- `color`: colors the group border and label.
- `background` *(optional)*: vault-relative path to a background image for the group.
- `backgroundStyle` *(optional)*: how the background is rendered.
  - `"cover"`: fills the group, cropping if needed (default-ish behavior)
  - `"ratio"`: preserves aspect ratio, fits inside the group
  - `"repeat"`: tiles the image
- Groups do not affect auto-layout: they are purely visual containers.

---

### Link node

Renders a web URL as an embedded preview card.

```json
{
  "id": "link-karpathy-2233",
  "type": "link",
  "url": "https://github.com/karpathy",
  "x": 200,
  "y": -300,
  "width": 400,
  "height": 120
}
```

- `url`: must be a valid `https://` URL.
- Obsidian fetches the Open Graph preview (title, description, thumbnail).

---

## Edges

Connections between nodes. Usually empty for mood boards.

```json
{
  "id": "e-hub-cidx",
  "fromNode": "hub",
  "fromSide": "right",
  "fromEnd": "none",
  "toNode": "c-idx",
  "toSide": "left",
  "toEnd": "arrow",
  "label": "concepts",
  "color": "5"
}
```

**Required fields**: `id`, `fromNode`, `toNode`. Everything else is optional.

- `fromNode` / `toNode`: IDs of the source and target nodes.
- `fromSide` / `toSide` *(optional)*: `"top"` `"bottom"` `"left"` `"right"`. If omitted, Obsidian auto-calculates the best side based on relative node positions.
- `fromEnd` *(optional)*: end-cap on the source side. Defaults to `"none"`. Values: `"none"` | `"arrow"`.
- `toEnd` *(optional)*: end-cap on the target side. **Defaults to `"arrow"`**: note the asymmetric default vs `fromEnd`. Values: `"none"` | `"arrow"`.
- `label` *(optional)*: text shown on the edge.
- `color` *(optional)*: same color palette as nodes (`"1"`–`"6"` or hex).

Most edges represent directed relationships, so the asymmetric defaults (`fromEnd: "none"`, `toEnd: "arrow"`) produce a single arrow pointing from source to target without specifying anything explicitly.

---

## Color Reference

| Code | Color | Hex (approx) | Use case |
|------|-------|-------------|----------|
| `"1"` | Red / Tomato | #e03e3e | Warnings, archive |
| `"2"` | Orange | #d09035 | Active work |
| `"3"` | Yellow / Gold | #d0a023 | WIP, notes |
| `"4"` | Green / Teal | #448361 | Content, sources |
| `"5"` | Blue / Cyan | #3ea7d3 | Navigation, info |
| `"6"` | Purple / Violet | #9063d2 | Title, identity |

Omit `color` entirely for the default (no border color, transparent label).

---

## Image Sizing Guidelines

Calculate from actual image dimensions using PIL or `identify`:

```bash
python3 -c "from PIL import Image; img=Image.open('path.png'); print(img.width, img.height)"
# or
identify -format '%w %h' path.png
```

| Aspect ratio | Condition | Canvas width | Canvas height |
|-------------|-----------|-------------|--------------|
| 16:9 (wide) | ratio 1.6–2.0 | 420 | 236 |
| 2:1 (ultra wide) | ratio > 2.0 | 440 | 220 |
| 4:3 | ratio 1.2–1.6 | 380 | 285 |
| 1:1 (square) | ratio 0.9–1.1 | 280 | 280 |
| 3:4 | ratio 0.6–0.9 | 240 | 320 |
| 9:16 (portrait) | ratio < 0.6 | 200 | 356 |
| PDF | any | 400 | 520 |
| Unknown | fallback | 320 | 240 |

---

## Auto-Positioning Pseudocode

```
function place_node(canvas, zone_label, new_w, new_h):
  zone = find group node where label == zone_label
  padding = 20

  if zone not found:
    max_y = max(n.y + n.height for n in canvas.nodes) + 60
    return (-400, max_y)

  # Nodes visually inside zone
  inside = [n for n in canvas.nodes
            if n.type != 'group'
            and zone.x <= n.x < zone.x + zone.width
            and zone.y <= n.y < zone.y + zone.height]

  if inside is empty:
    return (zone.x + padding, zone.y + padding)

  # Rightmost point in zone
  rightmost = max(n.x + n.width for n in inside)
  next_x = rightmost + 40

  if next_x + new_w > zone.x + zone.width - padding:
    # Overflow → new row
    bottom_of_row = max(n.y + n.height for n in inside)
    return (zone.x + padding, bottom_of_row + padding)

  # Same row
  row_y = min(n.y for n in inside)  # align to top of existing row
  return (next_x, row_y)
```

---

## Full Example: Two-Zone Canvas

```json
{
  "nodes": [
    {
      "id": "title-0001",
      "type": "text",
      "text": "# Brand Reference\n\n**AI Marketing Hub** visual assets",
      "x": -920, "y": -2440, "width": 560, "height": 180, "color": "6"
    },
    {
      "id": "zone-logos",
      "type": "group",
      "label": "Logos & Icons",
      "x": -920, "y": -2200, "width": 1800, "height": 320, "color": "6"
    },
    {
      "id": "img-logo-pro",
      "type": "file",
      "file": "_attachments/images/example.png",
      "x": -900, "y": -2180, "width": 420, "height": 236
    },
    {
      "id": "img-icon-free",
      "type": "file",
      "file": "_attachments/images/example-icon.png",
      "x": -440, "y": -2180, "width": 280, "height": 280
    },
    {
      "id": "zone-covers",
      "type": "group",
      "label": "Skill Covers",
      "x": -920, "y": -1820, "width": 1800, "height": 340, "color": "3"
    },
    {
      "id": "img-seo",
      "type": "file",
      "file": "_attachments/images/example-cover.png",
      "x": -900, "y": -1800, "width": 420, "height": 236
    }
  ],
  "edges": []
}
```

---

## Common Mistakes

- **Wrong path format**: use `_attachments/images/file.png` not `/home/user/...` or `~/...`
- **ID collision**: always read existing IDs before generating a new one
- **Negative y confusion**: `y: -2400` is ABOVE `y: -1000` (more negative = higher up)
- **Group does not clip**: placing a node "inside" a group is just positioning it within the group's bounding box: there is no parent-child relationship in the JSON
- **Missing height on text nodes**: Obsidian will render the text but may clip it if height is too small. Use height ≥ content-lines × 24.
