Build a Knowledge Graph Creator

Build an interactive knowledge graph app with vis-network and localStorage.

Introduction

30 Second Summary

Think about how you learn new things. Every concept connects to others. A programming language connects to the frameworks built on it. A historical event connects to the people involved. Visualizing these connections turns scattered knowledge into a map you can navigate.

In this project, you will build a knowledge graph creator that lets you add concepts as nodes, draw relationships between them as edges, and see the whole thing come alive as an interactive, physics-driven visualization in your browser.

What You'll Build

A dark-themed single-page web application that takes user input and renders a live, interactive knowledge graph using HTML, CSS, and JavaScript.

By the end of this project, you'll have:

  • A responsive dark-mode UI with a sidebar control panel and a full-width graph canvas, styled with CSS custom properties and Flexbox.
  • A real-time graph visualization powered by the vis-network library, complete with physics simulation, color-coded node groups, and directional edge arrows.
  • A persistent data layer using localStorage that auto-saves your graph and restores it every time you reopen the page.
  • Secret Mission: Add JSON export and import so users can save their graph as a downloadable file and reload it later.

Are there any prerequisites?

No prior experience is needed. This guide walks you through setting up VS Code and Google Chrome from scratch. You will write plain HTML, CSS, and JavaScript with no frameworks or build tools required.

Set Up Your Project Structure

Every web application starts with a foundation: the files that define its structure, appearance, and behavior. Your knowledge graph creator needs three files working together to produce an interactive visualization.

In this step, you will create the project folder, write the HTML skeleton that lays out the user interface, and load the vis-network library that powers the graph visualization.

In this step, get ready to:
  • Create the project folder and three starter files.
  • Build the full HTML skeleton with the vis-network CDN loaded.
  • Verify the library loaded correctly in Chrome DevTools.
Create the project folder and files
  • In VS Code, click Terminal in the top menu bar.
  • Click New Terminal.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and files by running these commands:
mkdir knowledge-graph-creator
cd knowledge-graph-creator
touch index.html style.css app.js
  • Confirm the files exist by running:
ls
  • You should see index.html, style.css, and app.js in the output.
  • Open the folder in VS Code by running:
code .

What does this command do?

The code . command tells VS Code to open the current folder as your workspace. You should see the three files appear in the Explorer sidebar on the left.

Command not found?

If code . does not work, open VS Code manually. Press Cmd+Space (macOS) or the Windows key (Windows) to open search, type VS Code, and press Enter. Then click File > Open Folder and select the knowledge-graph-creator folder on your Desktop.

Help me open my project folder in VS Code.

Build the HTML skeleton with vis-network CDN

Your HTML file defines the page structure: a sidebar on the left for controls and a main area on the right for the graph canvas. It also loads the vis-network library from a CDN so you can render interactive graphs without installing anything.

  • In the VS Code Explorer sidebar, click index.html to open it.
  • Paste the following code into the file:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Knowledge Graph Creator</title>
    <!-- Load vis-network library from CDN -->
    <script type="text/javascript" src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
    <!-- Link our stylesheet -->
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="app">
        <!-- Sidebar: controls for adding and managing graph elements -->
        <aside class="sidebar">
            <h1 class="sidebar-title">Knowledge Graph Creator</h1>

            <!-- Add Node section -->
            <section class="panel-section">
                <h2>Add Node</h2>
                <input type="text" id="node-label" placeholder="Node label..." />
                <select id="node-group">
                    <option value="concept">Concept (Blue)</option>
                    <option value="person">Person (Green)</option>
                    <option value="tool">Tool (Orange)</option>
                    <option value="event">Event (Purple)</option>
                </select>
                <button id="add-node-btn">Add Node</button>
            </section>

            <!-- Add Edge section -->
            <section class="panel-section">
                <h2>Add Edge</h2>
                <select id="edge-from">
                    <option value="">-- From Node --</option>
                </select>
                <select id="edge-to">
                    <option value="">-- To Node --</option>
                </select>
                <input type="text" id="edge-label" placeholder="Relationship (optional)..." />
                <button id="add-edge-btn">Add Edge</button>
            </section>

            <!-- Actions section -->
            <section class="panel-section">
                <h2>Actions</h2>
                <button id="delete-btn" class="btn-danger">Delete Selected</button>
                <button id="clear-btn" class="btn-danger">Clear Graph</button>
            </section>

            <!-- Details section -->
            <section class="panel-section">
                <h2>Details</h2>
                <div id="details-panel">
                    <p class="details-placeholder">Click a node to see details</p>
                </div>
            </section>

            <!-- Graph Elements list -->
            <section class="panel-section">
                <h2>Graph Elements</h2>
                <div id="element-list"></div>
            </section>
        </aside>

        <!-- Main graph canvas area -->
        <main class="graph-container" id="graph-container"></main>
    </div>

    <!-- Load our JavaScript -->
    <script src="app.js"></script>
</body>
</html>

What does this code do?

  • The <script> tag in the <head> loads the vis-network library from a CDN. This gives you access to the vis global object for creating graphs.
  • The .sidebar aside contains five sections: Add Node (text input + color group selector), Add Edge (from/to dropdowns + label), Actions (delete and clear buttons), Details (shows info about a selected node), and Graph Elements (lists everything in the graph).
  • The <main> element with id graph-container is where vis-network will render the interactive graph canvas.
  • The app.js script loads at the bottom of the body so it runs after all HTML elements exist in the DOM.
  • Save index.html by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Seeing a lot of angle brackets and unsure what's happening?

Make sure you pasted the code into index.html and not one of the other files. Check the filename in the tab at the top of the VS Code editor.

Help me understand the HTML structure.

What is a CDN?

A CDN (Content Delivery Network) hosts library files so you can load them with a single <script> tag. No downloads or installs needed. The vis-network library gets fetched from the CDN every time someone opens your page.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete index.html file. Make sure yours matches exactly:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Knowledge Graph Creator</title>
    <script type="text/javascript" src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="app">
        <aside class="sidebar">
            <h1 class="sidebar-title">Knowledge Graph Creator</h1>

            <section class="panel-section">
                <h2>Add Node</h2>
                <input type="text" id="node-label" placeholder="Node label..." />
                <select id="node-group">
                    <option value="concept">Concept (Blue)</option>
                    <option value="person">Person (Green)</option>
                    <option value="tool">Tool (Orange)</option>
                    <option value="event">Event (Purple)</option>
                </select>
                <button id="add-node-btn">Add Node</button>
            </section>

            <section class="panel-section">
                <h2>Add Edge</h2>
                <select id="edge-from">
                    <option value="">-- From Node --</option>
                </select>
                <select id="edge-to">
                    <option value="">-- To Node --</option>
                </select>
                <input type="text" id="edge-label" placeholder="Relationship (optional)..." />
                <button id="add-edge-btn">Add Edge</button>
            </section>

            <section class="panel-section">
                <h2>Actions</h2>
                <button id="delete-btn" class="btn-danger">Delete Selected</button>
                <button id="clear-btn" class="btn-danger">Clear Graph</button>
            </section>

            <section class="panel-section">
                <h2>Details</h2>
                <div id="details-panel">
                    <p class="details-placeholder">Click a node to see details</p>
                </div>
            </section>

            <section class="panel-section">
                <h2>Graph Elements</h2>
                <div id="element-list"></div>
            </section>
        </aside>

        <main class="graph-container" id="graph-container"></main>
    </div>

    <script src="app.js"></script>
</body>
</html>
Verify the setup in Chrome

Before writing any JavaScript, you need to confirm that Chrome can load your HTML file and that the vis-network library is available.

  • Open Google Chrome.
  • Drag the index.html file from the VS Code Explorer sidebar into the Chrome window. You can also press Cmd+O (macOS) or Ctrl+O (Windows) in Chrome and navigate to Desktop/knowledge-graph-creator/index.html.
  • Open Chrome DevTools by pressing F12 or Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
  • Click the Console tab in DevTools.
  • Confirm there are no red error messages in the console.
  • Type vis into the console and press Enter.

What should I see?

When you type vis and press Enter, the console should display an object containing DataSet, Network, and other properties. This confirms the library loaded successfully from the CDN.

The page itself will look mostly blank with some unstyled text on the left. That is expected. You will add styling in the next step.

Seeing an error or vis is not defined?

Check that the <script> tag in your <head> has the exact URL: https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js. A typo in the URL will prevent the library from loading.

Also make sure your computer is connected to the internet. The CDN file is fetched from the web each time you load the page.

Help me fix the vis-network library not loading.

Your project structure is ready and the visualization library is loaded. Next up, you will style the dark-themed interface so the sidebar and graph canvas look polished.

Style the Dark-Themed Interface

Your HTML structure is in place and loading in the browser. Right now, everything looks like unstyled default browser elements on a white background.

A knowledge graph tool needs a dark, focused interface that keeps attention on the graph itself. In this step, you will build a complete dark theme using CSS custom properties and Flexbox layout.

In this step, get ready to:
  • Define a reusable color palette with CSS custom properties.
  • Style form controls and buttons with dark theme colors and transitions.
  • Style the graph container and element display areas.
Define CSS custom properties and base dark theme

CSS custom properties let you define colors once and reuse them everywhere. This means you can change the entire color scheme by editing a single block of variables.

  • Switch to the style.css tab in VS Code.
  • Add the color palette, global reset, and body styles by pasting this code:
:root {
    --bg-primary: #1a1a2e;
    --bg-surface: #16213e;
    --bg-input: #0f3460;
    --text-primary: #e0e0e0;
    --text-secondary: #a0a0b0;
    --accent-blue: #4fc3f7;
    --accent-green: #66bb6a;
    --accent-orange: #ffa726;
    --accent-purple: #ab47bc;
    --border-color: #2a2a4a;
    --danger: #ef5350;
    --radius: 6px;
    --transition: 0.2s ease;
}

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    background-color: var(--bg-primary);
    color: var(--text-primary);
    height: 100vh;
    overflow: hidden;
}

What does this code do?

The :root block defines 14 custom properties that control every color, spacing radius, and transition speed across the app. Changing any value here updates every element that references it.

The * selector strips all default browser margins and padding. The box-sizing: border-box rule makes width calculations include padding and borders, so layout math stays predictable.

The body uses height: 100vh to fill the entire viewport and overflow: hidden to prevent page-level scrolling (the sidebar scrolls independently).

  • Save style.css.
  • Refresh your browser. The page background should now be a deep navy blue instead of white, and all default margins are gone.

Still seeing a white background?

Confirm that style.css is saved. Check that your index.html has <link rel="stylesheet" href="style.css"> in the <head> section. Both files must be in the same folder.

Help me debug why my CSS isn't loading.

Now that the foundation is set, add the Flexbox layout structure that positions the sidebar on the left and graph canvas on the right.

  • Add the layout and sidebar styles below the body block in style.css:
.app {
    display: flex;
    height: 100vh;
}

.sidebar {
    width: 320px;
    background-color: var(--bg-surface);
    border-right: 1px solid var(--border-color);
    padding: 20px;
    overflow-y: auto;
    display: flex;
    flex-direction: column;
    gap: 16px;
}

.sidebar-title {
    font-size: 1.2rem;
    color: var(--accent-blue);
    padding-bottom: 12px;
    border-bottom: 1px solid var(--border-color);
}

.panel-section h2 {
    font-size: 0.85rem;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--text-secondary);
    margin-bottom: 8px;
}

.panel-section {
    display: flex;
    flex-direction: column;
    gap: 8px;
}

What does this code do?

The .app container uses display: flex to place the sidebar and graph canvas side by side horizontally.

The .sidebar is fixed at 320px wide and uses overflow-y: auto so it scrolls independently when content overflows. Its internal flex-column layout with gap: 16px spaces each form section evenly.

The .panel-section h2 headings use uppercase text and muted color to act as quiet section labels without competing with the graph for attention.

  • Save style.css and refresh your browser.
  • You should see a dark sidebar panel on the left with the blue "Knowledge Graph Creator" title, small uppercase section headings, and the rest of the page as open dark space.

Sidebar not appearing on the left?

Make sure the .app class in your CSS matches the class="app" on the wrapping <div> in your HTML. Class names are case-sensitive.

Help me debug my Flexbox sidebar layout.

Style the form controls and buttons

The inputs and buttons currently use the browser's default styling, which clashes with the dark theme. You will style them to match the color palette and add smooth transitions for hover and focus states.

  • Add the input and select styles below your .panel-section block in style.css:
input[type="text"],
select {
    width: 100%;
    padding: 10px 12px;
    background-color: var(--bg-input);
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    color: var(--text-primary);
    font-size: 0.9rem;
    transition: border-color var(--transition);
}

input[type="text"]:focus,
select:focus {
    outline: none;
    border-color: var(--accent-blue);
}

What does this code do?

Both text inputs and dropdowns get the same dark treatment: a deep blue background (--bg-input) with a subtle border. The transition property animates the border color change smoothly over 0.2 seconds.

The :focus pseudo-class removes the browser's default outline ring and replaces it with a blue border highlight, giving clear visual feedback when a field is active.

  • Save style.css and refresh your browser.
  • Click into one of the text inputs. You should see it light up with a blue border on focus.

Inputs still look unstyled?

Check that your selector is input[type="text"] with the quotes around text. Missing quotes in the attribute selector will prevent it from matching.

Help me fix my CSS input styling.

  • Add the button styles below your select:focus block in style.css:
button {
    padding: 10px 16px;
    border: none;
    border-radius: var(--radius);
    background-color: var(--accent-blue);
    color: var(--bg-primary);
    font-size: 0.9rem;
    font-weight: 600;
    cursor: pointer;
    transition: opacity var(--transition), transform var(--transition);
}

button:hover {
    opacity: 0.85;
    transform: translateY(-1px);
}

button:active {
    transform: translateY(0);
}

.btn-danger {
    background-color: var(--danger);
    color: #fff;
}

What does this code do?

Default buttons get the blue accent color with dark text. The transition on opacity and transform gives a lift effect on hover (the button moves up 1px) and snaps back on click.

The .btn-danger class overrides the background to red for destructive actions like "Delete Selected" and "Clear Graph". This visual distinction helps users avoid accidental data loss.

  • Save style.css and refresh your browser.
  • Hover over the "Add Node" button. It should lift slightly and become more transparent. The "Delete Selected" and "Clear Graph" buttons should appear red.

Buttons not changing on hover?

Make sure you have both button:hover and button:active as separate selector blocks. If they are inside the main button block, the browser will ignore them.

Help me debug my button hover styles.

Style the graph container and display areas

The final piece is the graph canvas area that fills the remaining viewport space, plus the details panel and element list that will display graph information in the sidebar.

  • Add the graph container and display area styles below your .btn-danger block in style.css:
.graph-container {
    flex: 1;
    height: 100vh;
    background-color: var(--bg-primary);
}

#details-panel {
    background-color: var(--bg-input);
    border-radius: var(--radius);
    padding: 12px;
    min-height: 60px;
}

.details-placeholder {
    color: var(--text-secondary);
    font-size: 0.85rem;
    font-style: italic;
}

.detail-label {
    font-size: 0.8rem;
    color: var(--text-secondary);
    margin-bottom: 2px;
}

.detail-value {
    font-size: 0.95rem;
    color: var(--text-primary);
    margin-bottom: 8px;
}

#element-list {
    display: flex;
    flex-direction: column;
    gap: 4px;
    max-height: 200px;
    overflow-y: auto;
}

.element-item {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 6px 10px;
    background-color: var(--bg-input);
    border-radius: var(--radius);
    cursor: pointer;
    font-size: 0.85rem;
    transition: background-color var(--transition);
}

.element-item:hover {
    background-color: var(--border-color);
}

.color-dot {
    width: 10px;
    height: 10px;
    border-radius: 50%;
    flex-shrink: 0;
}

What does this code do?

The .graph-container uses flex: 1 to fill all remaining horizontal space after the 320px sidebar. Its background matches the page background so the vis-network canvas blends seamlessly.

The #details-panel and #element-list styles prepare the sidebar's information areas. The element list items (.element-item) include a color dot indicator and hover feedback so users can interact with them once JavaScript populates them.

  • Save style.css and refresh your browser.
  • You should see the full split-panel layout: a dark sidebar on the left (about one-quarter of the screen) with all controls, and a large open dark area on the right where the graph will render.

Graph area not filling the right side?

Make sure the parent .app has display: flex. Without it, flex: 1 on the graph container has no effect. Also confirm there are no typos in the class name (it should be graph-container with a hyphen).

Help me fix my graph container layout.

✔️ Awesome, I've got everything!

Great. Double check you have saved style.css and your browser shows the full dark-themed layout.

ⓧ I'd like to double check the full code

Here is the complete style.css file. Compare it against yours:

:root {
    --bg-primary: #1a1a2e;
    --bg-surface: #16213e;
    --bg-input: #0f3460;
    --text-primary: #e0e0e0;
    --text-secondary: #a0a0b0;
    --accent-blue: #4fc3f7;
    --accent-green: #66bb6a;
    --accent-orange: #ffa726;
    --accent-purple: #ab47bc;
    --border-color: #2a2a4a;
    --danger: #ef5350;
    --radius: 6px;
    --transition: 0.2s ease;
}

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    background-color: var(--bg-primary);
    color: var(--text-primary);
    height: 100vh;
    overflow: hidden;
}

.app {
    display: flex;
    height: 100vh;
}

.sidebar {
    width: 320px;
    background-color: var(--bg-surface);
    border-right: 1px solid var(--border-color);
    padding: 20px;
    overflow-y: auto;
    display: flex;
    flex-direction: column;
    gap: 16px;
}

.sidebar-title {
    font-size: 1.2rem;
    color: var(--accent-blue);
    padding-bottom: 12px;
    border-bottom: 1px solid var(--border-color);
}

.panel-section h2 {
    font-size: 0.85rem;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--text-secondary);
    margin-bottom: 8px;
}

.panel-section {
    display: flex;
    flex-direction: column;
    gap: 8px;
}

input[type="text"],
select {
    width: 100%;
    padding: 10px 12px;
    background-color: var(--bg-input);
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    color: var(--text-primary);
    font-size: 0.9rem;
    transition: border-color var(--transition);
}

input[type="text"]:focus,
select:focus {
    outline: none;
    border-color: var(--accent-blue);
}

button {
    padding: 10px 16px;
    border: none;
    border-radius: var(--radius);
    background-color: var(--accent-blue);
    color: var(--bg-primary);
    font-size: 0.9rem;
    font-weight: 600;
    cursor: pointer;
    transition: opacity var(--transition), transform var(--transition);
}

button:hover {
    opacity: 0.85;
    transform: translateY(-1px);
}

button:active {
    transform: translateY(0);
}

.btn-danger {
    background-color: var(--danger);
    color: #fff;
}

.graph-container {
    flex: 1;
    height: 100vh;
    background-color: var(--bg-primary);
}

#details-panel {
    background-color: var(--bg-input);
    border-radius: var(--radius);
    padding: 12px;
    min-height: 60px;
}

.details-placeholder {
    color: var(--text-secondary);
    font-size: 0.85rem;
    font-style: italic;
}

.detail-label {
    font-size: 0.8rem;
    color: var(--text-secondary);
    margin-bottom: 2px;
}

.detail-value {
    font-size: 0.95rem;
    color: var(--text-primary);
    margin-bottom: 8px;
}

#element-list {
    display: flex;
    flex-direction: column;
    gap: 4px;
    max-height: 200px;
    overflow-y: auto;
}

.element-item {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 6px 10px;
    background-color: var(--bg-input);
    border-radius: var(--radius);
    cursor: pointer;
    font-size: 0.85rem;
    transition: background-color var(--transition);
}

.element-item:hover {
    background-color: var(--border-color);
}

.color-dot {
    width: 10px;
    height: 10px;
    border-radius: 50%;
    flex-shrink: 0;
}

Why CSS custom properties instead of hardcoded colors?

Custom properties (variables) centralize your design decisions. If you decide to tweak the accent color from blue to teal later, you change one line in :root and every button, border, and highlight updates automatically.

This pattern is how professional design systems work. Tools like Material UI and Tailwind CSS use the same approach under the hood.

Your interface looks professional and polished. Next up, you will write the JavaScript that makes these form controls actually do something: adding nodes and edges to a live graph.

Build the Node and Edge Input System

Your dark-themed interface looks great, but it does nothing yet. The form inputs sit there waiting for JavaScript to bring them to life.

In this step, you will write the core logic that turns user input into graph data. Every node the learner types and every edge they connect will be stored in a data structure and rendered live on the canvas.

In this step, get ready to:
  • Set up data structures to hold nodes and edges.
  • Write functions that create nodes and edges from form input.
  • Initialize the vis-network graph and see nodes appear in real time.
Create the Add Node form logic

Before any node can appear on the graph, you need a data layer to hold it. The vis-network library provides a DataSet class that acts as a reactive collection. When you add or remove items, any connected network automatically updates.

  • In VS Code, open app.js and add the data layer at the top of the file:
// --- Data Layer ---
const nodes = new vis.DataSet();
const edges = new vis.DataSet();

const GROUP_COLORS = {
    concept: { background: '#4fc3f7', border: '#0288d1', highlight: { background: '#81d4fa', border: '#0288d1' }, hover: { background: '#81d4fa', border: '#0288d1' } },
    person: { background: '#66bb6a', border: '#388e3c', highlight: { background: '#a5d6a7', border: '#388e3c' }, hover: { background: '#a5d6a7', border: '#388e3c' } },
    tool: { background: '#ffa726', border: '#f57c00', highlight: { background: '#ffcc80', border: '#f57c00' }, hover: { background: '#ffcc80', border: '#f57c00' } },
    event: { background: '#ab47bc', border: '#7b1fa2', highlight: { background: '#ce93d8', border: '#7b1fa2' }, hover: { background: '#ce93d8', border: '#7b1fa2' } }
};

let nodeIdCounter = 0;
let network = null;

What does this code do?

The two vis.DataSet() instances are reactive arrays. Anything you add or remove from them triggers an automatic re-render in the graph.

The GROUP_COLORS object defines a color palette for each node category (concept, person, tool, event). Each group has background, border, highlight, and hover states so nodes change color on interaction.

  • Save app.js.

You won't see a change in the browser yet. The next chunk uses these data structures to create nodes.

Now write the function that reads the form inputs, validates them, and adds a new node to the DataSet.

  • Below the data layer in app.js, add the addNode function:
// --- Node Operations ---
function addNode() {
    const labelInput = document.getElementById('node-label');
    const groupSelect = document.getElementById('node-group');
    const label = labelInput.value.trim();
    if (!label) { labelInput.focus(); return; }
    nodeIdCounter++;
    const node = { id: nodeIdCounter, label: label, group: groupSelect.value };
    nodes.add(node);
    labelInput.value = '';
    updateDropdowns();
    updateElementList();
    saveGraph();
}

What does this code do?

  • It reads the text from the #node-label input and the selected group from #node-group.
  • If the label is empty, it focuses the input and stops. No blank nodes allowed.
  • It increments nodeIdCounter to generate a unique ID for each node.
  • After adding the node to the DataSet, it clears the input and calls helper functions to keep the UI in sync.
  • Save app.js.

You won't see a change yet. The addNode function calls updateDropdowns(), updateElementList(), and saveGraph() which we will write next.

Create the Add Edge form logic

Edges are the connections between nodes. A knowledge graph is only useful when you can express relationships like "uses", "depends on", or "created by" between concepts.

  • Below the addNode function in app.js, add the addEdge function:
// --- Edge Operations ---
function addEdge() {
    const fromSelect = document.getElementById('edge-from');
    const toSelect = document.getElementById('edge-to');
    const labelInput = document.getElementById('edge-label');
    const fromId = parseInt(fromSelect.value);
    const toId = parseInt(toSelect.value);
    const label = labelInput.value.trim();
    if (!fromId || !toId) return;
    if (fromId === toId) return;
    const edge = { from: fromId, to: toId };
    if (label) { edge.label = label; }
    edges.add(edge);
    labelInput.value = '';
    updateElementList();
    saveGraph();
}

What does this code do?

  • It reads the selected node IDs from the #edge-from and #edge-to dropdowns.
  • It validates that both dropdowns have a selection and that they are not the same node (no self-loops).
  • The relationship label is optional. If the learner typed one, it gets attached to the edge object.
  • After adding the edge, it clears the label input and updates the sidebar list.
  • Save app.js.

The edge dropdowns need to show the current list of nodes. Every time a node is added or removed, the dropdowns must refresh.

  • Below the addEdge function, add the updateDropdowns function:
// --- UI Updates ---
function updateDropdowns() {
    const fromSelect = document.getElementById('edge-from');
    const toSelect = document.getElementById('edge-to');
    const allNodes = nodes.get();
    const fromValue = fromSelect.value;
    const toValue = toSelect.value;
    fromSelect.innerHTML = '<option value="">-- From Node --</option>';
    toSelect.innerHTML = '<option value="">-- To Node --</option>';
    allNodes.forEach(function(node) {
        const option1 = document.createElement('option');
        option1.value = node.id; option1.textContent = node.label;
        fromSelect.appendChild(option1);
        const option2 = document.createElement('option');
        option2.value = node.id; option2.textContent = node.label;
        toSelect.appendChild(option2);
    });
    fromSelect.value = fromValue;
    toSelect.value = toValue;
}

What does this code do?

This function rebuilds the #edge-from and #edge-to dropdown options from scratch every time a node changes. It preserves whatever the learner had selected before the rebuild so their choice does not reset.

  • Save app.js.

You won't see a change yet. The next substep creates the network that renders everything.

Initialize the vis.Network instance

The vis.Network object takes a container element, your DataSets, and a large options object that controls how nodes look, how edges behave, and what physics engine drives the layout.

  • Scroll up in app.js and find the line let network = null; at the bottom of the data layer section.
  • Add a blank line below it, then paste the initNetwork function. It goes between the data layer and the // --- Node Operations --- comment:
// --- Network Initialization ---
function initNetwork() {
    const container = document.getElementById('graph-container');
    const data = { nodes: nodes, edges: edges };
    const options = {
        nodes: {
            shape: 'dot',
            size: 20,
            font: { size: 14, color: '#e0e0e0', face: 'Segoe UI' },
            borderWidth: 2,
            shadow: { enabled: true, color: 'rgba(0,0,0,0.3)', size: 8, x: 3, y: 3 }
        },
        edges: {
            color: { color: '#848484', highlight: '#4fc3f7', hover: '#4fc3f7' },
            width: 2,
            arrows: { to: { enabled: true, scaleFactor: 0.8 } },
            font: { size: 11, color: '#a0a0b0', face: 'Segoe UI', strokeWidth: 3, strokeColor: '#1a1a2e' },
            smooth: { type: 'curvedCW', roundness: 0.2 }
        },
        physics: {
            forceAtlas2Based: { gravitationalConstant: -30, centralGravity: 0.005, springLength: 150, springConstant: 0.05 },
            solver: 'forceAtlas2Based',
            stabilization: { iterations: 100 }
        },
        interaction: { hover: true, tooltipDelay: 200 },
        groups: {
            concept: { color: GROUP_COLORS.concept },
            person: { color: GROUP_COLORS.person },
            tool: { color: GROUP_COLORS.tool },
            event: { color: GROUP_COLORS.event }
        }
    };

    network = new vis.Network(container, data, options);

    network.on('selectNode', function(params) {
        if (params.nodes.length > 0) { showNodeDetails(params.nodes[0]); }
    });
    network.on('deselectNode', function() { clearDetails(); });
}

What's happening here?

  • Nodes use the dot shape with shadows and a custom font, giving them a polished floating appearance.
  • Edges have directional arrows enabled (arrows.to) and use curved lines so multiple edges between the same nodes stay visually distinct.
  • The physics solver forceAtlas2Based automatically arranges nodes with a natural spacing. Nodes repel each other and edges act like springs.
  • The groups option maps each group name to its color palette from GROUP_COLORS. When a node has group: 'concept', vis-network automatically colors it blue.
  • The selectNode and deselectNode event listeners update the sidebar details panel when the learner clicks a node.
  • Save app.js.

The network still cannot render because we have not called initNetwork() yet. The next chunks add the remaining helper functions and wire everything together.

The sidebar needs a live list that shows all nodes and edges. This function rebuilds that list every time the graph changes.

  • Below the updateDropdowns function, add the updateElementList function:
function updateElementList() {
    const listContainer = document.getElementById('element-list');
    const allNodes = nodes.get();
    const allEdges = edges.get();
    let html = '';
    allNodes.forEach(function(node) {
        const color = GROUP_COLORS[node.group] ? GROUP_COLORS[node.group].background : '#848484';
        html += '<div class="element-item" data-type="node" data-id="' + node.id + '">';
        html += '<span class="color-dot" style="background-color: ' + color + ';"></span>';
        html += '<span>' + node.label + '</span></div>';
    });
    allEdges.forEach(function(edge) {
        const fromNode = nodes.get(edge.from);
        const toNode = nodes.get(edge.to);
        if (fromNode && toNode) {
            const edgeLabel = edge.label ? ' (' + edge.label + ')' : '';
            html += '<div class="element-item" data-type="edge" data-id="' + edge.id + '">';
            html += '<span class="color-dot" style="background-color: #848484;"></span>';
            html += '<span>' + fromNode.label + ' \u2192 ' + toNode.label + edgeLabel + '</span></div>';
        }
    });
    if (!html) { html = '<p class="details-placeholder">No elements yet</p>'; }
    listContainer.innerHTML = html;
    listContainer.querySelectorAll('.element-item').forEach(function(item) {
        item.addEventListener('click', function() {
            const type = this.dataset.type;
            const id = this.dataset.id;
            if (type === 'node') {
                network.selectNodes([parseInt(id)]);
                network.focus(parseInt(id), { scale: 1.2, animation: true });
                showNodeDetails(parseInt(id));
            } else { network.selectEdges([id]); }
        });
    });
}

What does this code do?

This function builds HTML for the sidebar element list by looping through all nodes and edges. Each item gets a colored dot matching its group and a click listener that selects and focuses the corresponding element in the graph.

  • Save app.js.

Now add the functions that manage the details panel when a node is selected or deselected.

  • Below updateElementList, add the showNodeDetails and clearDetails functions:
function showNodeDetails(nodeId) {
    const node = nodes.get(nodeId);
    if (!node) return;
    const connectedNodes = network.getConnectedNodes(nodeId);
    const connectedEdges = network.getConnectedEdges(nodeId);
    const panel = document.getElementById('details-panel');
    panel.innerHTML = '<p class="detail-label">Label</p><p class="detail-value">' + node.label + '</p>' +
        '<p class="detail-label">Group</p><p class="detail-value">' + node.group + '</p>' +
        '<p class="detail-label">Connections</p><p class="detail-value">' + connectedNodes.length + ' nodes, ' + connectedEdges.length + ' edges</p>';
}

function clearDetails() {
    document.getElementById('details-panel').innerHTML = '<p class="details-placeholder">Click a node to see details</p>';
}

What does this code do?

  • showNodeDetails retrieves the clicked node's data and uses getConnectedNodes() and getConnectedEdges() to count its connections.
  • clearDetails resets the panel to its placeholder message when nothing is selected.
  • Save app.js.

Finally, add the persistence stubs, event listeners, and initialization calls that wire everything together.

  • At the bottom of app.js, add the following code:
// --- Persistence (stubs) ---
function saveGraph() {}
function loadGraph() {}

// --- Event Listeners ---
document.getElementById('add-node-btn').addEventListener('click', addNode);
document.getElementById('add-edge-btn').addEventListener('click', addEdge);
document.getElementById('node-label').addEventListener('keydown', function(e) { if (e.key === 'Enter') addNode(); });
document.getElementById('edge-label').addEventListener('keydown', function(e) { if (e.key === 'Enter') addEdge(); });

// --- Initialize ---
loadGraph();
initNetwork();

What does this code do?

  • saveGraph and loadGraph are empty placeholders for now. You will implement persistence in a later step.
  • The event listeners connect the Add Node and Add Edge buttons to their functions. They also let the learner press Enter in the text inputs instead of clicking.
  • loadGraph() runs first (in case saved data exists), then initNetwork() creates the graph visualization.
  • Save app.js.
  • Switch to Chrome and refresh the page.
  • Type JavaScript into the node label input and click Add Node.

You should see a blue dot labeled "JavaScript" appear in the graph area on the right. It floats and settles into position as the physics engine stabilizes.

Node not appearing?

  • Open Chrome DevTools with F12 and check the Console tab for errors. A common issue is a typo in an element ID like node-label or graph-container.
  • Make sure the initNetwork() call is at the very bottom of the file. If it runs before the DOM is ready, the container element will be null.
  • Confirm that the vis-network script tag in index.html comes before your app.js script tag. The library must load first.

Why isn't my node appearing in the graph?

  • Try adding a second node with a different group. Type React and select Tool (Orange) from the dropdown, then click Add Node.
  • Select JavaScript in the From dropdown and React in the To dropdown.
  • Type powers in the relationship label and click Add Edge.

You should see a curved arrow from the blue "JavaScript" node to the orange "React" node, with the label "powers" displayed along the edge.

✔️ Awesome, I've got everything!

Great. Double check you have saved app.js and your graph is rendering nodes and edges.

ⓧ I'd like to double check the full code

Here is the complete app.js at the end of this step. Compare your file against it:

// --- Data Layer ---
const nodes = new vis.DataSet();
const edges = new vis.DataSet();

const GROUP_COLORS = {
    concept: { background: '#4fc3f7', border: '#0288d1', highlight: { background: '#81d4fa', border: '#0288d1' }, hover: { background: '#81d4fa', border: '#0288d1' } },
    person: { background: '#66bb6a', border: '#388e3c', highlight: { background: '#a5d6a7', border: '#388e3c' }, hover: { background: '#a5d6a7', border: '#388e3c' } },
    tool: { background: '#ffa726', border: '#f57c00', highlight: { background: '#ffcc80', border: '#f57c00' }, hover: { background: '#ffcc80', border: '#f57c00' } },
    event: { background: '#ab47bc', border: '#7b1fa2', highlight: { background: '#ce93d8', border: '#7b1fa2' }, hover: { background: '#ce93d8', border: '#7b1fa2' } }
};

let nodeIdCounter = 0;
let network = null;

// --- Network Initialization ---
function initNetwork() {
    const container = document.getElementById('graph-container');
    const data = { nodes: nodes, edges: edges };
    const options = {
        nodes: {
            shape: 'dot',
            size: 20,
            font: { size: 14, color: '#e0e0e0', face: 'Segoe UI' },
            borderWidth: 2,
            shadow: { enabled: true, color: 'rgba(0,0,0,0.3)', size: 8, x: 3, y: 3 }
        },
        edges: {
            color: { color: '#848484', highlight: '#4fc3f7', hover: '#4fc3f7' },
            width: 2,
            arrows: { to: { enabled: true, scaleFactor: 0.8 } },
            font: { size: 11, color: '#a0a0b0', face: 'Segoe UI', strokeWidth: 3, strokeColor: '#1a1a2e' },
            smooth: { type: 'curvedCW', roundness: 0.2 }
        },
        physics: {
            forceAtlas2Based: { gravitationalConstant: -30, centralGravity: 0.005, springLength: 150, springConstant: 0.05 },
            solver: 'forceAtlas2Based',
            stabilization: { iterations: 100 }
        },
        interaction: { hover: true, tooltipDelay: 200 },
        groups: {
            concept: { color: GROUP_COLORS.concept },
            person: { color: GROUP_COLORS.person },
            tool: { color: GROUP_COLORS.tool },
            event: { color: GROUP_COLORS.event }
        }
    };

    network = new vis.Network(container, data, options);

    network.on('selectNode', function(params) {
        if (params.nodes.length > 0) { showNodeDetails(params.nodes[0]); }
    });
    network.on('deselectNode', function() { clearDetails(); });
}

// --- Node Operations ---
function addNode() {
    const labelInput = document.getElementById('node-label');
    const groupSelect = document.getElementById('node-group');
    const label = labelInput.value.trim();
    if (!label) { labelInput.focus(); return; }
    nodeIdCounter++;
    const node = { id: nodeIdCounter, label: label, group: groupSelect.value };
    nodes.add(node);
    labelInput.value = '';
    updateDropdowns();
    updateElementList();
    saveGraph();
}

// --- Edge Operations ---
function addEdge() {
    const fromSelect = document.getElementById('edge-from');
    const toSelect = document.getElementById('edge-to');
    const labelInput = document.getElementById('edge-label');
    const fromId = parseInt(fromSelect.value);
    const toId = parseInt(toSelect.value);
    const label = labelInput.value.trim();
    if (!fromId || !toId) return;
    if (fromId === toId) return;
    const edge = { from: fromId, to: toId };
    if (label) { edge.label = label; }
    edges.add(edge);
    labelInput.value = '';
    updateElementList();
    saveGraph();
}

// --- UI Updates ---
function updateDropdowns() {
    const fromSelect = document.getElementById('edge-from');
    const toSelect = document.getElementById('edge-to');
    const allNodes = nodes.get();
    const fromValue = fromSelect.value;
    const toValue = toSelect.value;
    fromSelect.innerHTML = '<option value="">-- From Node --</option>';
    toSelect.innerHTML = '<option value="">-- To Node --</option>';
    allNodes.forEach(function(node) {
        const option1 = document.createElement('option');
        option1.value = node.id; option1.textContent = node.label;
        fromSelect.appendChild(option1);
        const option2 = document.createElement('option');
        option2.value = node.id; option2.textContent = node.label;
        toSelect.appendChild(option2);
    });
    fromSelect.value = fromValue;
    toSelect.value = toValue;
}

function updateElementList() {
    const listContainer = document.getElementById('element-list');
    const allNodes = nodes.get();
    const allEdges = edges.get();
    let html = '';
    allNodes.forEach(function(node) {
        const color = GROUP_COLORS[node.group] ? GROUP_COLORS[node.group].background : '#848484';
        html += '<div class="element-item" data-type="node" data-id="' + node.id + '">';
        html += '<span class="color-dot" style="background-color: ' + color + ';"></span>';
        html += '<span>' + node.label + '</span></div>';
    });
    allEdges.forEach(function(edge) {
        const fromNode = nodes.get(edge.from);
        const toNode = nodes.get(edge.to);
        if (fromNode && toNode) {
            const edgeLabel = edge.label ? ' (' + edge.label + ')' : '';
            html += '<div class="element-item" data-type="edge" data-id="' + edge.id + '">';
            html += '<span class="color-dot" style="background-color: #848484;"></span>';
            html += '<span>' + fromNode.label + ' \u2192 ' + toNode.label + edgeLabel + '</span></div>';
        }
    });
    if (!html) { html = '<p class="details-placeholder">No elements yet</p>'; }
    listContainer.innerHTML = html;
    listContainer.querySelectorAll('.element-item').forEach(function(item) {
        item.addEventListener('click', function() {
            const type = this.dataset.type;
            const id = this.dataset.id;
            if (type === 'node') {
                network.selectNodes([parseInt(id)]);
                network.focus(parseInt(id), { scale: 1.2, animation: true });
                showNodeDetails(parseInt(id));
            } else { network.selectEdges([id]); }
        });
    });
}

function showNodeDetails(nodeId) {
    const node = nodes.get(nodeId);
    if (!node) return;
    const connectedNodes = network.getConnectedNodes(nodeId);
    const connectedEdges = network.getConnectedEdges(nodeId);
    const panel = document.getElementById('details-panel');
    panel.innerHTML = '<p class="detail-label">Label</p><p class="detail-value">' + node.label + '</p>' +
        '<p class="detail-label">Group</p><p class="detail-value">' + node.group + '</p>' +
        '<p class="detail-label">Connections</p><p class="detail-value">' + connectedNodes.length + ' nodes, ' + connectedEdges.length + ' edges</p>';
}

function clearDetails() {
    document.getElementById('details-panel').innerHTML = '<p class="details-placeholder">Click a node to see details</p>';
}

// --- Persistence (stubs) ---
function saveGraph() {}
function loadGraph() {}

// --- Event Listeners ---
document.getElementById('add-node-btn').addEventListener('click', addNode);
document.getElementById('add-edge-btn').addEventListener('click', addEdge);
document.getElementById('node-label').addEventListener('keydown', function(e) { if (e.key === 'Enter') addNode(); });
document.getElementById('edge-label').addEventListener('keydown', function(e) { if (e.key === 'Enter') addEdge(); });

// --- Initialize ---
loadGraph();
initNetwork();

Your graph is alive. You can add nodes in different color groups, connect them with labeled edges, and watch the physics engine arrange everything in real time. Next up, you will add click-to-select, deletion, and a live sidebar list that makes managing your graph much easier.

Add Interactivity and Graph Management

Your graph can create and display nodes and edges, and clicking a node already reveals its details in the sidebar. But there is no way to remove elements once they have been added.

In this step, you'll add delete and clear functionality so you can manage your graph as it grows. You'll also confirm that the sidebar element list stays in sync with every change.

In this step, get ready to:
  • Verify the click-to-select interaction shows node details.
  • Implement delete and clear graph operations.
  • Confirm the live element list updates with all changes.
Test click-to-select with connection highlighting

Your graph already supports click-to-select. When you click a node, the selectNode event fires and the sidebar shows the node's label, group, and connection count.

  • Switch back to Chrome where your graph is running.
  • Add two or three nodes using the sidebar form (try different groups like Concept and Person).
  • Add an edge connecting two of your nodes.
  • Click one of the connected nodes in the graph canvas.

You should see the Details panel in the sidebar update with the node's label, its group name, and the number of connections.

How does click-to-select work?

The showNodeDetails() function uses two vis-network methods: getConnectedNodes() returns an array of neighboring node IDs, and getConnectedEdges() returns the edge IDs touching that node.

The function then writes this information into the details-panel div so you always know what you've selected.

Details panel not updating?

Make sure you are clicking directly on a node dot, not on empty canvas space. If you click the background, the deselectNode event fires instead and clears the details.

Help me debug why clicking a node doesn't show details.

Implement delete functionality

Right now you can only add elements to your graph. A real graph editor needs the ability to remove nodes and edges, and to start fresh when needed.

  • In app.js, find the comment // --- UI Updates ---.
  • Add the following delete operations section directly above that comment:
// --- Delete Operations ---
function deleteSelected() {
    // Get whatever the user has clicked on in the graph
    const selection = network.getSelection();

    if (selection.nodes.length > 0) {
        // Remove each selected node and its connected edges
        selection.nodes.forEach(function(nodeId) {
            const connectedEdges = network.getConnectedEdges(nodeId);
            edges.remove(connectedEdges);
            nodes.remove(nodeId);
        });
    } else if (selection.edges.length > 0) {
        // Remove selected edges only
        edges.remove(selection.edges);
    }

    // Refresh the UI to reflect removals
    updateDropdowns();
    updateElementList();
    clearDetails();
    saveGraph();
}

What does this code do?

  • network.getSelection() returns an object with nodes and edges arrays containing the IDs of whatever the user last clicked.
  • When a node is selected, getConnectedEdges() finds all edges touching that node so they get removed too. Orphaned edges would cause errors.
  • If only edges are selected (no nodes), it removes just those edges and leaves the nodes intact.
  • Directly below the deleteSelected() function, add the clearGraph() function:
function clearGraph() {
    // Ask the user to confirm before wiping everything
    if (!confirm('Are you sure you want to clear the entire graph?')) return;

    // Remove all nodes and edges from the DataSets
    nodes.clear();
    edges.clear();
    nodeIdCounter = 0;

    // Refresh the UI
    updateDropdowns();
    updateElementList();
    clearDetails();
    localStorage.removeItem('knowledgeGraphData');
}

What does this code do?

  • confirm() shows a browser dialog asking the user to confirm before clearing. If they click Cancel, the function returns early and nothing is deleted.
  • nodes.clear() and edges.clear() empty the DataSets completely, which automatically removes all elements from the canvas.
  • Resetting nodeIdCounter to 0 means the next node created starts at ID 1 again.
  • localStorage.removeItem('knowledgeGraphData') clears any saved data so the graph does not reload after a refresh. You will implement the full save system in the next step.

Now you need to wire these functions to the buttons in your HTML. The Delete Selected and Clear Graph buttons already exist in the sidebar, but they have no event listeners yet.

  • Scroll down to the // --- Event Listeners --- section.
  • Find this line:
document.getElementById('add-edge-btn').addEventListener('click', addEdge);
  • Add the following two lines directly below it:
document.getElementById('delete-btn').addEventListener('click', deleteSelected);
document.getElementById('clear-btn').addEventListener('click', clearGraph);

What does this code do?

These lines connect the red Delete Selected and Clear Graph buttons in the Actions panel to the functions you just wrote. Clicking either button now triggers the corresponding operation.

  • Save app.js.
  • Switch back to Chrome and refresh the page.
  • Add two nodes and an edge connecting them.
  • Click a node in the graph to select it, then click the red Delete Selected button.

The selected node and its connected edge should disappear from both the graph canvas and the sidebar element list.

Node not disappearing when you click Delete?

  • Make sure you clicked the node in the graph canvas first. The node needs a blue highlight ring around it before pressing Delete.
  • Check that the event listener line uses delete-btn (with a hyphen), matching the button's id in index.html.
  • Open DevTools with F12 and check the Console tab for any error messages.

Help me debug why deleting a node is not working.

Verify the live graph element list

The sidebar already renders a live list of all nodes and edges. Every time you add or delete an element, updateElementList() rebuilds this list and attaches click handlers to each item.

  • Add three nodes with different groups (for example: JavaScript as Concept, React as Tool, and Conference as Event).
  • Check the Graph Elements section in the sidebar. Each node should appear with a colored dot matching its group.
  • Add an edge between two of the nodes.
  • Confirm the edge appears in the element list showing both node labels.
  • Click one of the items in the sidebar element list.

The graph should zoom in and center on that element, and the Details panel should update with its information.

  • Now click Clear Graph and confirm the dialog.

The graph canvas should be empty, the element list should show "No elements yet", and the dropdowns should be cleared.

How does the element list stay in sync?

Every function that changes the graph data (addNode, addEdge, deleteSelected, clearGraph) calls updateElementList() at the end. This pattern ensures the sidebar always reflects the current state of the graph.

Element list not updating after delete?

  • Check that deleteSelected() includes updateElementList() after removing elements.
  • Verify you saved app.js after adding the new code. Unsaved changes will not take effect.

Help me figure out why my element list is out of sync with the graph.

✔️ Awesome, I've got everything!

Great. Double check you have saved app.js before moving on.

ⓧ I'd like to double check the full code

Here is the full app.js at this point:

// --- Data Layer ---
const nodes = new vis.DataSet();
const edges = new vis.DataSet();

const GROUP_COLORS = {
    concept: { background: '#4fc3f7', border: '#0288d1', highlight: { background: '#81d4fa', border: '#0288d1' }, hover: { background: '#81d4fa', border: '#0288d1' } },
    person: { background: '#66bb6a', border: '#388e3c', highlight: { background: '#a5d6a7', border: '#388e3c' }, hover: { background: '#a5d6a7', border: '#388e3c' } },
    tool: { background: '#ffa726', border: '#f57c00', highlight: { background: '#ffcc80', border: '#f57c00' }, hover: { background: '#ffcc80', border: '#f57c00' } },
    event: { background: '#ab47bc', border: '#7b1fa2', highlight: { background: '#ce93d8', border: '#7b1fa2' }, hover: { background: '#ce93d8', border: '#7b1fa2' } }
};

let nodeIdCounter = 0;
let network = null;

// --- Network Initialization ---
function initNetwork() {
    const container = document.getElementById('graph-container');
    const data = { nodes: nodes, edges: edges };
    const options = {
        nodes: {
            shape: 'dot', size: 20,
            font: { size: 14, color: '#e0e0e0', face: 'Segoe UI' },
            borderWidth: 2,
            shadow: { enabled: true, color: 'rgba(0,0,0,0.3)', size: 8, x: 3, y: 3 }
        },
        edges: {
            color: { color: '#848484', highlight: '#4fc3f7', hover: '#4fc3f7' },
            width: 2,
            arrows: { to: { enabled: true, scaleFactor: 0.8 } },
            font: { size: 11, color: '#a0a0b0', face: 'Segoe UI', strokeWidth: 3, strokeColor: '#1a1a2e' },
            smooth: { type: 'curvedCW', roundness: 0.2 }
        },
        physics: {
            forceAtlas2Based: { gravitationalConstant: -30, centralGravity: 0.005, springLength: 150, springConstant: 0.05 },
            solver: 'forceAtlas2Based',
            stabilization: { iterations: 100 }
        },
        interaction: { hover: true, tooltipDelay: 200 },
        groups: {
            concept: { color: GROUP_COLORS.concept },
            person: { color: GROUP_COLORS.person },
            tool: { color: GROUP_COLORS.tool },
            event: { color: GROUP_COLORS.event }
        }
    };
    network = new vis.Network(container, data, options);
    network.on('selectNode', function(params) {
        if (params.nodes.length > 0) { showNodeDetails(params.nodes[0]); }
    });
    network.on('deselectNode', function() { clearDetails(); });
}

// --- Node Operations ---
function addNode() {
    const labelInput = document.getElementById('node-label');
    const groupSelect = document.getElementById('node-group');
    const label = labelInput.value.trim();
    if (!label) { labelInput.focus(); return; }
    nodeIdCounter++;
    const node = { id: nodeIdCounter, label: label, group: groupSelect.value };
    nodes.add(node);
    labelInput.value = '';
    updateDropdowns();
    updateElementList();
    saveGraph();
}

// --- Edge Operations ---
function addEdge() {
    const fromSelect = document.getElementById('edge-from');
    const toSelect = document.getElementById('edge-to');
    const labelInput = document.getElementById('edge-label');
    const fromId = parseInt(fromSelect.value);
    const toId = parseInt(toSelect.value);
    const label = labelInput.value.trim();
    if (!fromId || !toId) return;
    if (fromId === toId) return;
    const edge = { from: fromId, to: toId };
    if (label) { edge.label = label; }
    edges.add(edge);
    labelInput.value = '';
    updateElementList();
    saveGraph();
}

// --- Delete Operations ---
function deleteSelected() {
    const selection = network.getSelection();
    if (selection.nodes.length > 0) {
        selection.nodes.forEach(function(nodeId) {
            const connectedEdges = network.getConnectedEdges(nodeId);
            edges.remove(connectedEdges);
            nodes.remove(nodeId);
        });
    } else if (selection.edges.length > 0) {
        edges.remove(selection.edges);
    }
    updateDropdowns();
    updateElementList();
    clearDetails();
    saveGraph();
}

function clearGraph() {
    if (!confirm('Are you sure you want to clear the entire graph?')) return;
    nodes.clear();
    edges.clear();
    nodeIdCounter = 0;
    updateDropdowns();
    updateElementList();
    clearDetails();
    localStorage.removeItem('knowledgeGraphData');
}

// --- UI Updates ---
function updateDropdowns() {
    const fromSelect = document.getElementById('edge-from');
    const toSelect = document.getElementById('edge-to');
    const allNodes = nodes.get();
    const fromValue = fromSelect.value;
    const toValue = toSelect.value;
    fromSelect.innerHTML = '<option value="">-- From Node --</option>';
    toSelect.innerHTML = '<option value="">-- To Node --</option>';
    allNodes.forEach(function(node) {
        const option1 = document.createElement('option');
        option1.value = node.id; option1.textContent = node.label;
        fromSelect.appendChild(option1);
        const option2 = document.createElement('option');
        option2.value = node.id; option2.textContent = node.label;
        toSelect.appendChild(option2);
    });
    fromSelect.value = fromValue;
    toSelect.value = toValue;
}

function updateElementList() {
    const listContainer = document.getElementById('element-list');
    const allNodes = nodes.get();
    const allEdges = edges.get();
    let html = '';
    allNodes.forEach(function(node) {
        const color = GROUP_COLORS[node.group] ? GROUP_COLORS[node.group].background : '#848484';
        html += '<div class="element-item" data-type="node" data-id="' + node.id + '">';
        html += '<span class="color-dot" style="background-color: ' + color + ';"></span>';
        html += '<span>' + node.label + '</span></div>';
    });
    allEdges.forEach(function(edge) {
        const fromNode = nodes.get(edge.from);
        const toNode = nodes.get(edge.to);
        if (fromNode && toNode) {
            const edgeLabel = edge.label ? ' (' + edge.label + ')' : '';
            html += '<div class="element-item" data-type="edge" data-id="' + edge.id + '">';
            html += '<span class="color-dot" style="background-color: #848484;"></span>';
            html += '<span>' + fromNode.label + ' \u2192 ' + toNode.label + edgeLabel + '</span></div>';
        }
    });
    if (!html) { html = '<p class="details-placeholder">No elements yet</p>'; }
    listContainer.innerHTML = html;
    listContainer.querySelectorAll('.element-item').forEach(function(item) {
        item.addEventListener('click', function() {
            const type = this.dataset.type;
            const id = this.dataset.id;
            if (type === 'node') {
                network.selectNodes([parseInt(id)]);
                network.focus(parseInt(id), { scale: 1.2, animation: true });
                showNodeDetails(parseInt(id));
            } else { network.selectEdges([id]); }
        });
    });
}

function showNodeDetails(nodeId) {
    const node = nodes.get(nodeId);
    if (!node) return;
    const connectedNodes = network.getConnectedNodes(nodeId);
    const connectedEdges = network.getConnectedEdges(nodeId);
    const panel = document.getElementById('details-panel');
    panel.innerHTML = '<p class="detail-label">Label</p><p class="detail-value">' + node.label + '</p>' +
        '<p class="detail-label">Group</p><p class="detail-value">' + node.group + '</p>' +
        '<p class="detail-label">Connections</p><p class="detail-value">' + connectedNodes.length + ' nodes, ' + connectedEdges.length + ' edges</p>';
}

function clearDetails() {
    document.getElementById('details-panel').innerHTML = '<p class="details-placeholder">Click a node to see details</p>';
}

// --- Persistence (stubs) ---
function saveGraph() {}
function loadGraph() {}

// --- Event Listeners ---
document.getElementById('add-node-btn').addEventListener('click', addNode);
document.getElementById('add-edge-btn').addEventListener('click', addEdge);
document.getElementById('delete-btn').addEventListener('click', deleteSelected);
document.getElementById('clear-btn').addEventListener('click', clearGraph);
document.getElementById('node-label').addEventListener('keydown', function(e) { if (e.key === 'Enter') addNode(); });
document.getElementById('edge-label').addEventListener('keydown', function(e) { if (e.key === 'Enter') addEdge(); });

// --- Initialize ---
loadGraph();
initNetwork();

Your graph is now fully interactive. You can add, select, inspect, and remove elements. Next up, you'll make the graph persist so it survives a page refresh.

Persist the Graph with localStorage

Your graph is fully interactive now. You can add nodes, connect them with edges, click to inspect details, and delete elements. There is one critical problem: refresh the page and everything disappears.

In this step, you will wire up localStorage to automatically save your graph data every time it changes. When you reopen the page, the graph will restore itself from the saved data.

In this step, get ready to:
  • Implement auto-save so every change writes to localStorage.
  • Implement auto-load so the graph restores on page open.
  • Verify the Clear Graph button wipes both the DataSets and the localStorage entry.
Implement auto-save on every change

Right now, saveGraph() is an empty stub. Every add and delete function already calls it, so all you need to do is fill in the body.

  • In app.js, scroll down to the // --- Persistence (stubs) --- comment.
  • Find this line:
function saveGraph() {}
  • Replace that entire line with the following:
function saveGraph() {
    // Collect all current nodes, edges, and the ID counter into one object
    const data = {
        nodes: nodes.get(),
        edges: edges.get(),
        nodeIdCounter: nodeIdCounter
    };
    // Write the serialized JSON string to localStorage
    localStorage.setItem('knowledgeGraphData', JSON.stringify(data));
}

What does this code do?

nodes.get() and edges.get() pull every item out of the vis.js DataSets as plain JavaScript arrays.

JSON.stringify() converts the object into a string that localStorage can store. The nodeIdCounter is included so new nodes get IDs that never collide with restored ones.

Why is saveGraph() already being called?

In the previous step, addNode(), addEdge(), and deleteSelected() were written with saveGraph() calls at the end. They were calling the empty stub until now. With the real body in place, every change the user makes is instantly persisted.

  • Save app.js.

You won't see a visible change yet. The next chunk adds the loading side so you can confirm persistence works end-to-end.

Implement auto-load on page open

Saving is only useful if the data comes back when the page reopens. The loadGraph() function reads from localStorage, parses the JSON, and repopulates the DataSets before the network renders.

  • In app.js, find this line directly below the saveGraph() function you just wrote:
function loadGraph() {}
  • Replace that entire line with the following:
function loadGraph() {
    // Read the saved JSON string from localStorage
    const saved = localStorage.getItem('knowledgeGraphData');
    if (!saved) return;

    try {
        const data = JSON.parse(saved);
        // Only restore if the saved data has the expected structure
        if (data.nodes && data.edges) {
            nodes.add(data.nodes);
            edges.add(data.edges);
            nodeIdCounter = data.nodeIdCounter || 0;
            updateDropdowns();
            updateElementList();
        }
    } catch (e) {
        console.error('Failed to load saved graph:', e);
    }
}

What does this code do?

  • localStorage.getItem('knowledgeGraphData') retrieves the stored string. If nothing was saved, it returns null and the function exits early.
  • JSON.parse() converts the string back into a JavaScript object. The try/catch protects against corrupted data crashing the app.
  • nodes.add() and edges.add() push the restored arrays back into the vis.js DataSets so the network can render them.
  • updateDropdowns() and updateElementList() rebuild the sidebar UI to reflect the restored data.

Why is loadGraph() called before initNetwork()?

At the bottom of your file, loadGraph() runs first to populate the DataSets, then initNetwork() creates the network with that data already present. This means the graph appears fully restored on the very first render.

  • Save app.js and refresh your browser.
  • Add a couple of test nodes (for example, JavaScript and HTML) and an edge between them.
  • Refresh the page again. Your nodes, edges, and sidebar list should all reappear exactly as you left them.

Graph disappearing on refresh?

  • Open DevTools (F12), go to the Application tab, and expand Local Storage. Look for the key knowledgeGraphData. If it is missing, saveGraph() is not running. Check for typos in the function name.
  • If the key exists but the graph does not appear, check that loadGraph() is called before initNetwork() at the bottom of app.js.
  • Make sure you replaced the entire stub line function loadGraph() {} and did not accidentally nest the new function inside another function.

Help me debug why my graph is not persisting across page refreshes.

Verify the Clear Graph button

The clearGraph() function was written in the previous step and already includes localStorage.removeItem('knowledgeGraphData'). Now that persistence is live, you can confirm it works end-to-end.

  • Make sure you have at least one node in the graph.
  • Click the Clear Graph button in the sidebar.
  • A confirmation dialog will appear. Click OK.
  • Refresh the page. The graph should remain empty because the localStorage entry was removed.

Why does Clear Graph need to remove localStorage?

Without the localStorage.removeItem() call, clearing the DataSets would only last until the next refresh. The saved data would reload and the graph would reappear. Removing the key ensures a clear really means clear.

Graph coming back after clearing?

  • Open the Application tab in DevTools and check whether knowledgeGraphData still exists under Local Storage. If it does, your clearGraph() function may be missing the localStorage.removeItem('knowledgeGraphData') line.
  • Make sure you clicked OK on the confirm dialog. Clicking Cancel aborts the clear.

Help me figure out why Clear Graph is not removing my saved data.

✔️ Awesome, I've got everything!

Great. Double check you have saved app.js and confirmed persistence works by refreshing the browser with nodes present.

ⓧ I'd like to double check the full code

Here is the complete app.js file at this point. Compare your code against it:

// --- Data Layer ---
const nodes = new vis.DataSet();
const edges = new vis.DataSet();

const GROUP_COLORS = {
    concept: { background: '#4fc3f7', border: '#0288d1', highlight: { background: '#81d4fa', border: '#0288d1' }, hover: { background: '#81d4fa', border: '#0288d1' } },
    person: { background: '#66bb6a', border: '#388e3c', highlight: { background: '#a5d6a7', border: '#388e3c' }, hover: { background: '#a5d6a7', border: '#388e3c' } },
    tool: { background: '#ffa726', border: '#f57c00', highlight: { background: '#ffcc80', border: '#f57c00' }, hover: { background: '#ffcc80', border: '#f57c00' } },
    event: { background: '#ab47bc', border: '#7b1fa2', highlight: { background: '#ce93d8', border: '#7b1fa2' }, hover: { background: '#ce93d8', border: '#7b1fa2' } }
};

let nodeIdCounter = 0;
let network = null;

// --- Network Initialization ---
function initNetwork() {
    const container = document.getElementById('graph-container');
    const data = { nodes: nodes, edges: edges };
    const options = {
        nodes: {
            shape: 'dot',
            size: 20,
            font: {
                size: 14,
                color: '#e0e0e0',
                face: 'Segoe UI'
            },
            borderWidth: 2,
            shadow: {
                enabled: true,
                color: 'rgba(0,0,0,0.3)',
                size: 8,
                x: 3,
                y: 3
            }
        },
        edges: {
            color: {
                color: '#848484',
                highlight: '#4fc3f7',
                hover: '#4fc3f7'
            },
            width: 2,
            arrows: {
                to: { enabled: true, scaleFactor: 0.8 }
            },
            font: {
                size: 11,
                color: '#a0a0b0',
                face: 'Segoe UI',
                strokeWidth: 3,
                strokeColor: '#1a1a2e'
            },
            smooth: {
                type: 'curvedCW',
                roundness: 0.2
            }
        },
        physics: {
            forceAtlas2Based: {
                gravitationalConstant: -30,
                centralGravity: 0.005,
                springLength: 150,
                springConstant: 0.05
            },
            solver: 'forceAtlas2Based',
            stabilization: { iterations: 100 }
        },
        interaction: {
            hover: true,
            tooltipDelay: 200
        },
        groups: {
            concept: { color: GROUP_COLORS.concept },
            person: { color: GROUP_COLORS.person },
            tool: { color: GROUP_COLORS.tool },
            event: { color: GROUP_COLORS.event }
        }
    };

    network = new vis.Network(container, data, options);

    network.on('selectNode', function(params) {
        if (params.nodes.length > 0) {
            showNodeDetails(params.nodes[0]);
        }
    });

    network.on('deselectNode', function() {
        clearDetails();
    });
}

// --- Node Operations ---
function addNode() {
    const labelInput = document.getElementById('node-label');
    const groupSelect = document.getElementById('node-group');
    const label = labelInput.value.trim();

    if (!label) {
        labelInput.focus();
        return;
    }

    nodeIdCounter++;
    const node = {
        id: nodeIdCounter,
        label: label,
        group: groupSelect.value
    };

    nodes.add(node);
    labelInput.value = '';
    updateDropdowns();
    updateElementList();
    saveGraph();
}

// --- Edge Operations ---
function addEdge() {
    const fromSelect = document.getElementById('edge-from');
    const toSelect = document.getElementById('edge-to');
    const labelInput = document.getElementById('edge-label');

    const fromId = parseInt(fromSelect.value);
    const toId = parseInt(toSelect.value);
    const label = labelInput.value.trim();

    if (!fromId || !toId) return;
    if (fromId === toId) return;

    const edge = {
        from: fromId,
        to: toId
    };
    if (label) {
        edge.label = label;
    }

    edges.add(edge);
    labelInput.value = '';
    updateElementList();
    saveGraph();
}

// --- Delete Operations ---
function deleteSelected() {
    const selection = network.getSelection();

    if (selection.nodes.length > 0) {
        selection.nodes.forEach(function(nodeId) {
            const connectedEdges = network.getConnectedEdges(nodeId);
            edges.remove(connectedEdges);
            nodes.remove(nodeId);
        });
    } else if (selection.edges.length > 0) {
        edges.remove(selection.edges);
    }

    updateDropdowns();
    updateElementList();
    clearDetails();
    saveGraph();
}

function clearGraph() {
    if (!confirm('Are you sure you want to clear the entire graph?')) return;

    nodes.clear();
    edges.clear();
    nodeIdCounter = 0;
    updateDropdowns();
    updateElementList();
    clearDetails();
    localStorage.removeItem('knowledgeGraphData');
}

// --- UI Updates ---
function updateDropdowns() {
    const fromSelect = document.getElementById('edge-from');
    const toSelect = document.getElementById('edge-to');
    const allNodes = nodes.get();

    const fromValue = fromSelect.value;
    const toValue = toSelect.value;

    fromSelect.innerHTML = '<option value="">-- From Node --</option>';
    toSelect.innerHTML = '<option value="">-- To Node --</option>';

    allNodes.forEach(function(node) {
        const option1 = document.createElement('option');
        option1.value = node.id;
        option1.textContent = node.label;
        fromSelect.appendChild(option1);

        const option2 = document.createElement('option');
        option2.value = node.id;
        option2.textContent = node.label;
        toSelect.appendChild(option2);
    });

    fromSelect.value = fromValue;
    toSelect.value = toValue;
}

function updateElementList() {
    const listContainer = document.getElementById('element-list');
    const allNodes = nodes.get();
    const allEdges = edges.get();

    let html = '';

    allNodes.forEach(function(node) {
        const color = GROUP_COLORS[node.group] ? GROUP_COLORS[node.group].background : '#848484';
        html += '<div class="element-item" data-type="node" data-id="' + node.id + '">';
        html += '<span class="color-dot" style="background-color: ' + color + ';"></span>';
        html += '<span>' + node.label + '</span>';
        html += '</div>';
    });

    allEdges.forEach(function(edge) {
        const fromNode = nodes.get(edge.from);
        const toNode = nodes.get(edge.to);
        if (fromNode && toNode) {
            const edgeLabel = edge.label ? ' (' + edge.label + ')' : '';
            html += '<div class="element-item" data-type="edge" data-id="' + edge.id + '">';
            html += '<span class="color-dot" style="background-color: #848484;"></span>';
            html += '<span>' + fromNode.label + ' \u2192 ' + toNode.label + edgeLabel + '</span>';
            html += '</div>';
        }
    });

    if (!html) {
        html = '<p class="details-placeholder">No elements yet</p>';
    }

    listContainer.innerHTML = html;

    listContainer.querySelectorAll('.element-item').forEach(function(item) {
        item.addEventListener('click', function() {
            const type = this.dataset.type;
            const id = this.dataset.id;
            if (type === 'node') {
                network.selectNodes([parseInt(id)]);
                network.focus(parseInt(id), { scale: 1.2, animation: true });
                showNodeDetails(parseInt(id));
            } else {
                network.selectEdges([id]);
            }
        });
    });
}

function showNodeDetails(nodeId) {
    const node = nodes.get(nodeId);
    if (!node) return;

    const connectedNodes = network.getConnectedNodes(nodeId);
    const connectedEdges = network.getConnectedEdges(nodeId);

    const panel = document.getElementById('details-panel');
    panel.innerHTML =
        '<p class="detail-label">Label</p>' +
        '<p class="detail-value">' + node.label + '</p>' +
        '<p class="detail-label">Group</p>' +
        '<p class="detail-value">' + node.group + '</p>' +
        '<p class="detail-label">Connections</p>' +
        '<p class="detail-value">' + connectedNodes.length + ' nodes, ' + connectedEdges.length + ' edges</p>';
}

function clearDetails() {
    const panel = document.getElementById('details-panel');
    panel.innerHTML = '<p class="details-placeholder">Click a node to see details</p>';
}

// --- Persistence ---
function saveGraph() {
    const data = {
        nodes: nodes.get(),
        edges: edges.get(),
        nodeIdCounter: nodeIdCounter
    };
    localStorage.setItem('knowledgeGraphData', JSON.stringify(data));
}

function loadGraph() {
    const saved = localStorage.getItem('knowledgeGraphData');
    if (!saved) return;

    try {
        const data = JSON.parse(saved);
        if (data.nodes && data.edges) {
            nodes.add(data.nodes);
            edges.add(data.edges);
            nodeIdCounter = data.nodeIdCounter || 0;
            updateDropdowns();
            updateElementList();
        }
    } catch (e) {
        console.error('Failed to load saved graph:', e);
    }
}

// --- Event Listeners ---
document.getElementById('add-node-btn').addEventListener('click', addNode);
document.getElementById('add-edge-btn').addEventListener('click', addEdge);
document.getElementById('delete-btn').addEventListener('click', deleteSelected);
document.getElementById('clear-btn').addEventListener('click', clearGraph);

document.getElementById('node-label').addEventListener('keydown', function(e) {
    if (e.key === 'Enter') addNode();
});

document.getElementById('edge-label').addEventListener('keydown', function(e) {
    if (e.key === 'Enter') addEdge();
});

// --- Initialize ---
loadGraph();
initNetwork();

Your knowledge graph now saves itself automatically and restores on every page load. In the Secret Mission, you will add the ability to export your graph as a downloadable JSON file and import one from disk.

Secret mission

Add JSON Export and Import

Your graph currently persists in localStorage, which means it lives and dies with your browser. What if you could save your knowledge graph as a portable file and share it with someone else? In this secret mission, you will add export and import buttons that let you download your graph as a JSON file and reload it from disk.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The knowledge-graph-creator project folder containing index.html, style.css, and app.js.
  • A localStorage entry (knowledgeGraphData) storing your graph data in Chrome.

Keep everything running

No action needed. Choose this if you want to keep building your knowledge graph or experiment with new nodes and edges.

  • Your graph data persists in localStorage automatically. Just reopen index.html in Chrome anytime to pick up where you left off.
  • Your project files remain in the knowledge-graph-creator folder on your Desktop.

Pause - I'll come back to this later

Since this project is entirely local, there is nothing to pause. Your files and localStorage data stay on your machine with no ongoing cost.

  • Close the browser tab and VS Code when you are done.
  • Your graph data remains saved in localStorage. Reopen index.html whenever you are ready to continue.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow both steps below to fully clean up.

  • Clear the localStorage entry by opening Chrome DevTools (press F12).
  • Click the Application tab at the top of DevTools.
  • In the left sidebar, expand Local Storage and click on your file's origin.
  • Find the knowledgeGraphData entry, right-click it, and select Delete.
  • Delete the project folder by running this command in your terminal:

macOS

rm -rf ~/Desktop/knowledge-graph-creator

Windows

rmdir /s /q %USERPROFILE%\Desktop\knowledge-graph-creator

Nice Work!

Nice Work!

You just built a fully interactive knowledge graph creator from scratch using vanilla HTML, CSS, and JavaScript. Your app takes user input, renders a live physics-driven graph, and persists data across browser sessions.

You've learned how to:

  • Built a single-page web application with a dark-themed Flexbox layout styled using CSS custom properties.
  • Integrated a third-party visualization library (vis-network) via CDN and configured physics simulation, color groups, and directional edges for real-time graph rendering.
  • Built an event-driven interface with DOM manipulation, dynamic form updates, and automatic data persistence using localStorage and JSON serialization.
  • Secret Mission: Added file export and import using the Blob API and FileReader for portable graph data.

Ready to quiz yourself?