Skip to main content

Frontend Architecture

The Ganimede frontend is built with Svelte and uses Yjs for real-time state synchronization. The UI consists of a 2D canvas where cells can be positioned, dragged, and organized into tissue groups.

Component Hierarchy

App.svelte
  └── Canvas.svelte
        ├── Notebook.svelte
        │     ├── Cell.svelte (for standalone cells)
        │     │     ├── CellToolbar.svelte
        │     │     ├── CodeCell.svelte / MarkdownCell.svelte
        │     │     │     └── CodeEditor.svelte (Monaco)
        │     │     │     └── Outputs.svelte
        │     │     └── NewCellToolbar.svelte
        │     │
        │     ├── Tissue.svelte (for grouped cells)
        │     │     ├── TissueToolbar.svelte
        │     │     ├── CodeCell.svelte / MarkdownCell.svelte (header)
        │     │     └── NewCellToolbar.svelte
        │     │
        │     ├── Edges.svelte (connection lines)
        │     └── Cursor.svelte (collaboration cursors)

        ├── ToolbarCanvas.svelte
        └── Toast.svelte (notifications)

Core Components

App.svelte

Location: ui/src/App.svelte Responsibilities:
  • Application entry point
  • Load configuration
  • Render Canvas
<script>
  import { fetchConfig } from "./stores/config.js";
  import Canvas from "./components/Canvas.svelte";
  
  async function loadApp() {
    await fetchConfig();
  }
</script>

{#await loadApp()}
  <div>Loading...</div>
{:then}
  <Canvas />
{/await}

Canvas.svelte

Location: ui/src/components/Canvas.svelte Responsibilities:
  • 2D scrollable workspace (20000x20000px)
  • Pan with mouse drag
  • Zoom with mouse wheel
  • Context menu for creating cells
  • Socket initialization
<script>
  import { zoom, set_zoom } from "../stores/zoom";
  import { open_socket } from "../stores/socket";
  import mouse_pos from "../stores/mouse.js";
  
  // Pan logic
  let moving = false;
  let clicked_x = 0, clicked_y = 0;
  
  function mouseDown(e) {
    if (e.button === 0 && e.target.id === "canvas") {
      moving = true;
      clicked_x = $mouse_pos.x;
      clicked_y = $mouse_pos.y;
    }
  }
  
  function mouseMove(e) {
    if (moving) {
      window.scrollBy({
        left: clicked_x - $mouse_pos.x,
        top: clicked_y - $mouse_pos.y,
        behavior: "instant"
      });
    }
  }
</script>

<div
  class="canvas"
  style="
    height: 20000px;
    width: 20000px;
    transform: scale({$zoom});
    transform-origin: 0 0;
  "
  on:mousemove={mouseMove}
  on:mousedown={mouseDown}
>
  {#await open_socket}
    <div>Waiting for socket</div>
  {:then}
    <Notebook />
  {/await}
</div>
Zoom implementation (Canvas.svelte:37):
window.addEventListener("wheel", (event) => {
  set_zoom(event);  // Updates $zoom store
}, { passive: false });

Notebook.svelte

Location: ui/src/components/Notebook.svelte Responsibilities:
  • Render all cells from YDoc
  • Render edges (connections) between cells
  • Manage collaboration cursors
  • Auto-align cells on first load
<script>
  import { ycells, ynp_graph, pc_graph, ydoc } from "../stores/_notebook";
  import Cell from "./Cell.svelte";
  import Tissue from "./Tissue.svelte";
  import Edges from "./utility_components/Edges.svelte";
  
  let cells = ycells.toJSON();
  
  // Reactively update when ycells changes
  ycells.observe((event) => {
    cells = ycells.toJSON();
  });
  
  let np_graph = ynp_graph.toJSON();
  ynp_graph.observeDeep((event) => {
    np_graph = ynp_graph.toJSON();
  });
</script>

{#each cells as cell_id (cell_id)}
  {#if !(cell_id in $pc_graph)}
    <Cell {cell_id} />
  {:else}
    <Tissue {cell_id} />
  {/if}
{/each}

{#each cells as cell_id (cell_id)}
  {#if np_graph[cell_id]}
    {#each np_graph[cell_id] as next_id}
      <Edges current_cell_id={cell_id} {next_id} />
    {/each}
  {/if}
{/each}
Cell vs Tissue rendering:
  • If cell_id is in pc_graph, it’s a parent (heading) → render as Tissue
  • Otherwise, render as standalone Cell

Cell.svelte

Location: ui/src/components/Cell.svelte Responsibilities:
  • Individual cell rendering
  • Drag and drop positioning
  • Cell toolbar and actions
  • Auto-positioning within tissue groups
  • Reactive YDoc binding
<script>
  import { ydoc, cp_graph, html_elements } from "../stores/_notebook";
  
  export let cell_id;
  
  // Reactive cell proxy
  let cell = {
    ycell: ydoc.getMap(cell_id),
    
    get top() { return this.ycell.get("top"); },
    set top(value) { this.ycell.set("top", value); },
    
    get source() { return this.ycell.get("source"); },
    // ... other properties
  };
  
  // Force reactivity on YDoc changes
  cell.ycell.observe((yevent) => {
    cell = cell;
  });
</script>

<div
  class="cell"
  style="
    top: {cell.top}px;
    left: {cell.left}px;
  "
  bind:clientHeight={cell.height}
  bind:clientWidth={cell.width}
>
  <CellToolbar {cell} />
  
  {#if cell.type === "code"}
    <CodeCell {cell} />
  {:else if cell.type === "markdown"}
    <MarkdownCell {cell} />
  {/if}
  
  <NewCellToolbar {cell} />
</div>
Drag and drop (Cell.svelte:123):
let dragging = false;
let drag_cell_pos = { x: null, y: null };

function drag_mousedown(e) {
  dragging = true;
  dh_clicked = {
    x: $mouse_pos.x - cell.left,
    y: $mouse_pos.y - cell.top
  };
}

function drag_mousemove(e) {
  if (dragging) {
    drag_cell_pos = {
      x: $mouse_pos.x - dh_clicked.x,
      y: $mouse_pos.y - dh_clicked.y
    };
    
    // Detect drop targets (cells, tissues)
    const elements_under = document.elementsFromPoint(e.clientX, e.clientY);
    // ... highlight drop zones
  }
}

function drag_mouseup(e) {
  if (dragging) {
    cell.top = drag_cell_pos.y;
    cell.left = drag_cell_pos.x;
    move_cell(cell_id, dragover_cell, selected_dragzone);
  }
  dragging = false;
}
Auto-positioning in tissues (Cell.svelte:328): Cells inside tissues auto-position relative to their parent:
$: if ($cp_graph[cell_id] && !dragging) {
  let cell_list_loc = ypc_graph.get($cp_graph[cell_id]).toJSON().indexOf(cell_id);
  
  if (cell_list_loc === 0) {
    // First child: position below parent header
    cell.top = parent_cell.top + header_height + 32;
    cell.left = parent_cell.left + 8;
  } else {
    // Subsequent children: position below previous sibling
    cell.top = prev_cell.top + prev_cell.height + 11;
    cell.left = prev_cell.left;
  }
}

Tissue.svelte

Location: ui/src/components/Tissue.svelte Responsibilities:
  • Render heading cells with child dropzone
  • Group multiple cells under a heading
  • Auto-calculate dropzone size from children
  • Drag and drop for entire group
<div class="tissue">
  <TissueToolbar {cell} on:mousedown={drag_mousedown} />
  
  <!-- Heading cell -->
  <div class="bg-oli" id="title">
    {#if cell.type === "code"}
      <CodeCell {cell} />
    {:else if cell.type === "markdown"}
      <MarkdownCell {cell} />
    {/if}
  </div>
  
  <!-- Dropzone for children -->
  <div class="dropzone" {cell_id}>
    <div
      style="width:{dropzone_width}px; height:{dropzone_height}px;"
    />
  </div>
  
  <NewCellToolbar {cell} />
</div>
Dropzone sizing (Tissue.svelte:429):
// Observe all children width/height
let children_w_h = {};

$: if ($pc_graph[cell_id]) {
  $pc_graph[cell_id]
    .map(child_id => ydoc.getMap(child_id))
    .map(ychild => {
      ychild.observe(() => {
        children_w_h[ychild.get("id")] = {
          width: ychild.get("width"),
          height: ychild.get("height")
        };
      });
    });
}

// Calculate total size
$: dropzone_height = Object.values(children_w_h)
  .reduce((acc, child) => acc + child.height + 11, 0) + 10;

$: dropzone_width = Object.values(children_w_h)
  .reduce((acc, child) => Math.max(acc, child.width + 20), 0);

CodeEditor.svelte (Monaco Integration)

Location: ui/src/components/cell_components/CodeEditor.svelte Responsibilities:
  • Monaco editor instance
  • Bind to YText for collaborative editing
  • Syntax highlighting
  • Keyboard shortcuts (Shift+Enter to run)
Monaco is integrated with Yjs using the Monaco binding:
import * as monaco from "monaco-editor";
import { MonacoBinding } from "y-monaco";

const editor = monaco.editor.create(element, {
  value: "",
  language: "python",
  // ... config
});

// Bind to YText
const binding = new MonacoBinding(
  cell.source,  // YText
  editor.getModel(),
  new Set([editor]),
  awareness
);

Stores

Ganimede uses Svelte stores for reactive state management.

Notebook Store (_notebook.js)

Location: ui/src/stores/_notebook.js Responsibilities:
  • Create and connect to YDoc
  • Expose Yjs shared types as stores
  • Provide cell operations (create, delete, move)
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
import { writable, derived } from "svelte/store";

// Create YDoc
export const ydoc = new Y.Doc();

// Connect to Ypy server
const websocket_provider = new WebsocketProvider(
  "ws://localhost:1234",
  "g-y-room",
  ydoc
);

// Shared types
export const ycells = ydoc.getArray("cells");
export const ynp_graph = ydoc.getMap("np_graph");
export const ypc_graph = ydoc.getMap("pc_graph");
export const yrun_queue = ydoc.getArray("run_queue");

// Derived stores
export const pc_graph = writable(new Map());
ypc_graph.observeDeep((event) => {
  pc_graph.set(ypc_graph.toJSON());
});

export const cp_graph = derived(pc_graph, $pc_graph => {
  // Reverse pc_graph: child → parent
  const cp_graph = {};
  for (const parent in $pc_graph) {
    for (const child of $pc_graph[parent]) {
      cp_graph[child] = parent;
    }
  }
  return cp_graph;
});
Cell operations:
export function create_cell(type, from_cell, left, top) {
  let cell_id = generateRandomCellId();
  let ycell = ydoc.getMap(cell_id);
  
  ycell.set("id", cell_id);
  ycell.set("type", type);
  ycell.set("source", new Y.Text());
  ycell.set("outputs", new Y.Array());
  ycell.set("state", "idle");
  ycell.set("top", top);
  ycell.set("left", left);
  
  ycells.push([cell_id]);
}

export function delete_cell(cell_id) {
  // Remove from parent's pc_graph
  let parent = get(cp_graph)[cell_id];
  if (parent) {
    let index = ypc_graph.get(parent).toJSON().indexOf(cell_id);
    ypc_graph.get(parent).delete(index, 1);
  }
  
  // Recursively delete children
  if (ypc_graph.get(cell_id)) {
    for (const child of ypc_graph.get(cell_id).toJSON()) {
      delete_cell(child);
    }
  }
  
  // Remove from ycells
  let index = ycells.toJSON().indexOf(cell_id);
  ycells.delete(index, 1);
}

export function move_cell(cell_id, dragover_cell, selected_dragzone) {
  // Remove from previous parent
  // Insert at new location (before/after dragover_cell or into tissue)
  // Update np_graph and pc_graph
}

Socket Store (socket.js)

Location: ui/src/stores/socket.js Responsibilities:
  • WebSocket connection to Comms (port 8000)
  • Send control messages to backend
export let socket = null;

export async function open_socket() {
  socket = new WebSocket("ws://localhost:8000/");
  
  socket.onopen = async function(event) {
    socket.send(JSON.stringify({
      "channel": "notebook",
      "method": "get"
    }));
  };
}

export async function send_message({ channel, method, message }) {
  socket.send(JSON.stringify({ channel, method, message }));
}

Other Stores

  • zoom.js: Zoom level (scale transform)
  • mouse.js: Global mouse position
  • config.js: Configuration fetched from /config
  • notifications.js: Toast notifications

Reactivity Patterns

YDoc to Svelte Reactivity

// 1. Observe YDoc changes
ycell.observe((yevent) => {
  cell = cell;  // Force Svelte reactivity
});

// 2. Reactive statements
$: if (cell.state === "running") {
  // Update UI
}

// 3. Derived stores
export const cp_graph = derived(pc_graph, $pc_graph => {
  // Compute child → parent mapping
});

Two-way Binding

<!-- Bind cell dimensions -->
<div
  bind:clientHeight={cell.height}
  bind:clientWidth={cell.width}
>
This updates YDoc whenever the DOM element resizes.

Collaboration Features

Awareness (Cursors)

Location: stores/_notebook.js and Notebook.svelte
export const awareness = websocket_provider.awareness;

// Set local user info
awareness.setLocalStateField("id", generateId());
awareness.setLocalStateField("color", generateColor());
awareness.setLocalStateField("cursor", { x: 0, y: 0 });

// Update cursor position
function mousemove(e) {
  awareness.setLocalStateField("cursor", {
    x: $mouse_pos.x,
    y: $mouse_pos.y
  });
}

// Render other users' cursors
let users = [];
awareness.on("change", () => {
  users = [];
  for (let [key, value] of awareness.getStates()) {
    if (key !== local_user_id) {
      users.push(value);
    }
  }
});
{#each users as user}
  <Cursor
    x={user.cursor.x}
    y={user.cursor.y}
    color={user.color}
  />
{/each}

Key Patterns

  1. YDoc as state: All persistent state lives in YDoc shared types
  2. Reactive proxies: Cell objects wrap YDoc maps with getters/setters
  3. Observer pattern: YDoc changes trigger Svelte reactivity
  4. Derived state: cp_graph derived from pc_graph, pn_graph from np_graph
  5. Canvas positioning: Absolute positioning with top/left in px
  6. Drag and drop: Element detection with elementsFromPoint
  7. Auto-layout: Reactive statements for tissue child positioning

Build docs developers (and LLMs) love