Merge pull request 'feat: project manager' (#21) from feat/project-manager into main
Some checks failed
Deploy to GitHub Pages / build_site (push) Has been cancelled

Reviewed-on: #21
This commit was merged in pull request #21.
This commit is contained in:
2026-01-21 16:35:03 +01:00
15 changed files with 293 additions and 62 deletions

View File

@@ -1,4 +1,5 @@
import type { Box } from '@nodarium/types'; import type { Box } from '@nodarium/types';
import type { Color } from 'three';
import { Vector3 } from 'three/src/math/Vector3.js'; import { Vector3 } from 'three/src/math/Vector3.js';
import Component from './Debug.svelte'; import Component from './Debug.svelte';
import { lines, points, rects } from './store'; import { lines, points, rects } from './store';
@@ -11,7 +12,6 @@ export function debugPosition(x: number, y: number) {
} }
export function debugRect(rect: Box) { export function debugRect(rect: Box) {
console.log(rect);
rects.update((r) => { rects.update((r) => {
r.push(rect); r.push(rect);
return r; return r;

View File

@@ -109,6 +109,7 @@ export class GraphManager extends EventEmitter<{
const serialized = { const serialized = {
id: this.graph.id, id: this.graph.id,
settings: $state.snapshot(this.settings), settings: $state.snapshot(this.settings),
meta: $state.snapshot(this.graph.meta),
nodes, nodes,
edges edges
}; };
@@ -273,7 +274,7 @@ export class GraphManager extends EventEmitter<{
}) })
); );
const edges = graph.edges.map((edge) => { this.edges = graph.edges.map((edge) => {
const from = nodes.get(edge[0]); const from = nodes.get(edge[0]);
const to = nodes.get(edge[2]); const to = nodes.get(edge[2]);
if (!from || !to) { if (!from || !to) {
@@ -286,8 +287,6 @@ export class GraphManager extends EventEmitter<{
return [from, edge[1], to, edge[3]] as Edge; return [from, edge[1], to, edge[3]] as Edge;
}); });
this.edges = [...edges];
this.nodes.clear(); this.nodes.clear();
for (const [id, node] of nodes) { for (const [id, node] of nodes) {
this.nodes.set(id, node); this.nodes.set(id, node);
@@ -304,7 +303,7 @@ export class GraphManager extends EventEmitter<{
this.status = 'loading'; this.status = 'loading';
this.id = graph.id; this.id = graph.id;
logger.info('loading graph', $state.snapshot(graph)); logger.info('loading graph', { nodes: graph.nodes, edges: graph.edges, id: graph.id });
const nodeIds = Array.from(new Set([...graph.nodes.map((n) => n.type)])); const nodeIds = Array.from(new Set([...graph.nodes.map((n) => n.type)]));
await this.registry.load(nodeIds); await this.registry.load(nodeIds);
@@ -644,6 +643,13 @@ export class GraphManager extends EventEmitter<{
if (this.currentUndoGroup) return; if (this.currentUndoGroup) return;
const state = this.serialize(); const state = this.serialize();
this.history.save(state); this.history.save(state);
// This is some stupid race condition where the graph-manager emits a save event
// when the graph is not fully loaded
if (this.nodes.size === 0 && this.edges.length === 0) {
return;
}
this.emit('save', state); this.emit('save', state);
logger.log('saving graphs', state); logger.log('saving graphs', state);
} }

View File

@@ -105,7 +105,7 @@
onwheel={(ev) => mouseEvents.handleMouseScroll(ev)} onwheel={(ev) => mouseEvents.handleMouseScroll(ev)}
bind:this={graphState.wrapper} bind:this={graphState.wrapper}
class="graph-wrapper" class="graph-wrapper"
style="height: 100vh;" style="height: 100%;"
class:is-panning={graphState.isPanning} class:is-panning={graphState.isPanning}
class:is-hovering={graphState.hoveredNodeId !== -1} class:is-hovering={graphState.hoveredNodeId !== -1}
aria-label="Graph" aria-label="Graph"

View File

@@ -11,7 +11,7 @@
import { setupKeymaps } from "../keymaps"; import { setupKeymaps } from "../keymaps";
type Props = { type Props = {
graph: Graph; graph?: Graph;
registry: NodeRegistry; registry: NodeRegistry;
settings?: Record<string, any>; settings?: Record<string, any>;
@@ -70,12 +70,6 @@
} }
}); });
$effect(() => {
if (settingTypes && settings) {
manager.setSettings(settings);
}
});
manager.on("settings", (_settings) => { manager.on("settings", (_settings) => {
settingTypes = { ...settingTypes, ..._settings.types }; settingTypes = { ...settingTypes, ..._settings.types };
settings = _settings.values; settings = _settings.values;
@@ -85,7 +79,11 @@
manager.on("save", (save) => onsave?.(save)); manager.on("save", (save) => onsave?.(save));
manager.load(graph); $effect(() => {
if (graph) {
manager.load(graph);
}
});
</script> </script>
<GraphEl {keymap} /> <GraphEl {keymap} />

View File

@@ -4,7 +4,6 @@ import path from 'path';
export async function getWasm(id: `${string}/${string}/${string}`) { export async function getWasm(id: `${string}/${string}/${string}`) {
const filePath = path.resolve(`./static/nodes/${id}`); const filePath = path.resolve(`./static/nodes/${id}`);
console.log({ filePath });
try { try {
await fs.access(filePath); await fs.access(filePath);

View File

@@ -0,0 +1,108 @@
<script lang="ts">
import type { Graph } from "$lib/types";
import { defaultPlant, plant, lottaFaces } from "$lib/graph-templates";
import type { ProjectManager } from "./project-manager.svelte";
const { projectManager } = $props<{ projectManager: ProjectManager }>();
let showNewProject = $state(false);
let newProjectName = $state("");
let selectedTemplate = $state("defaultPlant");
const templates = [
{
name: "Default Plant",
value: "defaultPlant",
graph: defaultPlant as unknown as Graph,
},
{ name: "Plant", value: "plant", graph: plant as unknown as Graph },
{
name: "Lotta Faces",
value: "lottaFaces",
graph: lottaFaces as unknown as Graph,
},
];
function handleCreate() {
const template =
templates.find((t) => t.value === selectedTemplate) || templates[0];
projectManager.handleCreateProject(template.graph, newProjectName);
newProjectName = "";
showNewProject = false;
}
</script>
<header
class="flex justify-between px-4 h-[70px] border-b-1 border-[var(--outline)] items-center"
>
<h3>Project</h3>
<button
class="px-3 py-1 bg-[var(--layer-0)] rounded"
onclick={() => (showNewProject = !showNewProject)}
>
New
</button>
</header>
{#if showNewProject}
<div class="flex flex-col px-4 py-3 border-b-1 border-[var(--outline)] gap-2">
<input
type="text"
bind:value={newProjectName}
placeholder="Project name"
class="w-full px-2 py-2 bg-gray-800 border border-gray-700 rounded"
onkeydown={(e) => e.key === "Enter" && handleCreate()}
/>
<select
bind:value={selectedTemplate}
class="w-full px-2 py-2 bg-gray-800 border border-gray-700 rounded"
>
{#each templates as template}
<option value={template.value}>{template.name}</option>
{/each}
</select>
<button
class="cursor-pointer self-end px-3 py-1 bg-blue-600 rounded"
onclick={() => handleCreate()}
>
Create
</button>
</div>
{/if}
<div class="p-4 text-white min-h-screen">
{#if projectManager.loading}
<p>Loading...</p>
{/if}
<ul class="space-y-2">
{#each projectManager.projects as project (project.id)}
<li>
<div
class="w-full text-left px-3 py-2 rounded cursor-pointer {projectManager
.activeProjectId.value === project.id
? 'bg-blue-600'
: 'bg-gray-800 hover:bg-gray-700'}"
onclick={() => projectManager.handleSelectProject(project.id!)}
role="button"
tabindex="0"
onkeydown={(e) =>
e.key === "Enter" &&
projectManager.handleSelectProject(project.id!)}
>
<div class="flex justify-between items-center">
<span>{project.meta?.title || "Untitled"}</span>
<button
class="text-red-400 hover:text-red-300"
onclick={() => {
projectManager.handleDeleteProject(project.id!);
}}
>
×
</button>
</div>
</div>
</li>
{/each}
</ul>
</div>

View File

@@ -0,0 +1,52 @@
import type { Graph } from '@nodarium/types';
import { type IDBPDatabase, openDB } from 'idb';
export interface GraphDatabase {
projects: Graph;
}
const DB_NAME = 'nodarium-graphs';
const DB_VERSION = 1;
const STORE_NAME = 'graphs';
let dbPromise: Promise<IDBPDatabase<GraphDatabase>> | null = null;
export function getDB() {
if (!dbPromise) {
dbPromise = openDB<GraphDatabase>(DB_NAME, DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
}
});
}
return dbPromise;
}
export async function getGraph(id: number): Promise<Graph | undefined> {
const db = await getDB();
return db.get(STORE_NAME, id);
}
export async function saveGraph(graph: Graph): Promise<Graph> {
const db = await getDB();
graph.meta = { ...graph.meta, lastModified: new Date().toISOString() };
await db.put(STORE_NAME, graph);
return graph;
}
export async function deleteGraph(id: number): Promise<void> {
const db = await getDB();
await db.delete(STORE_NAME, id);
}
export async function getGraphs(): Promise<Graph[]> {
const db = await getDB();
return db.getAll(STORE_NAME);
}
export async function clear(): Promise<void> {
const db = await getDB();
return db.clear(STORE_NAME);
}

View File

@@ -0,0 +1,85 @@
import * as templates from '$lib/graph-templates';
import { localState } from '$lib/helpers/localState.svelte';
import type { Graph } from '@nodarium/types';
import * as db from './project-database.svelte';
export class ProjectManager {
public graph = $state<Graph>();
private projects = $state<Graph[]>([]);
private activeProjectId = localState<number | undefined>(
'node.activeProjectId',
undefined
);
public readonly loading = $derived(this.graph?.id !== this.activeProjectId.value);
constructor() {
this.init();
}
async saveGraph(g: Graph) {
db.saveGraph(g);
}
private async init() {
await db.getDB();
this.projects = await db.getGraphs();
if (this.activeProjectId.value !== undefined) {
let loadedGraph = await db.getGraph(this.activeProjectId.value);
if (loadedGraph) {
this.graph = loadedGraph;
}
}
if (!this.graph) {
if (this.projects?.length && this.projects[0]?.id !== undefined) {
this.graph = this.projects[0];
this.activeProjectId.value = this.graph.id;
}
}
if (!this.graph) {
this.handleCreateProject();
}
}
public handleCreateProject(
g: Graph = templates.defaultPlant as unknown as Graph,
title: string = 'New Project'
) {
let id = g?.id || 0;
while (this.projects.find((p) => p.id === id)) {
id++;
}
g.id = id;
if (!g.meta) g.meta = {};
if (!g.meta.title) g.meta.title = title;
db.saveGraph(g);
this.projects = [...this.projects, g];
this.handleSelectProject(id);
}
public async handleDeleteProject(projectId: number) {
await db.deleteGraph(projectId);
if (this.projects.length === 1) {
this.graph = undefined;
this.projects = [];
} else {
this.projects = this.projects.filter((p) => p.id !== projectId);
const id = this.projects[0].id;
if (id !== undefined) {
this.handleSelectProject(id);
}
}
}
public async handleSelectProject(id: number) {
if (this.activeProjectId.value !== id) {
const project = await db.getGraph(id);
this.graph = project;
this.activeProjectId.value = id;
}
}
}

View File

@@ -92,13 +92,6 @@
geo.attributes.position.array[i + 2], geo.attributes.position.array[i + 2],
] as Vector3Tuple; ] as Vector3Tuple;
} }
// $effect(() => {
// console.log({
// geometries: $state.snapshot(geometries),
// indices: appSettings.value.debug.showIndices,
// });
// });
</script> </script>
<Camera {center} {centerCamera} /> <Camera {center} {centerCamera} />

View File

@@ -95,7 +95,7 @@
<SmallPerformanceViewer {fps} store={perf} /> <SmallPerformanceViewer {fps} store={perf} />
{/if} {/if}
<div style="height: 100vh"> <div style="height: 100%">
<Canvas> <Canvas>
<Scene <Scene
bind:this={sceneComponent} bind:this={sceneComponent}

View File

@@ -43,7 +43,4 @@
align-items: center; align-items: center;
padding-left: 1em; padding-left: 1em;
} }
h3 {
margin: 0px;
}
</style> </style>

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { Graph } from "$lib/types"; import type { Graph } from "$lib/types";
const { graph }: { graph: Graph } = $props(); const { graph }: { graph?: Graph } = $props();
function convert(g: Graph): string { function convert(g: Graph): string {
return JSON.stringify( return JSON.stringify(
@@ -16,5 +16,5 @@
</script> </script>
<pre> <pre>
{convert(graph)} {graph ? convert(graph) : 'No graph loaded'}
</pre> </pre>

View File

@@ -28,6 +28,8 @@
import BenchmarkPanel from "$lib/sidebar/panels/BenchmarkPanel.svelte"; import BenchmarkPanel from "$lib/sidebar/panels/BenchmarkPanel.svelte";
import { debounceAsyncFunction } from "$lib/helpers"; import { debounceAsyncFunction } from "$lib/helpers";
import GraphSource from "$lib/sidebar/panels/GraphSource.svelte"; import GraphSource from "$lib/sidebar/panels/GraphSource.svelte";
import { ProjectManager } from "$lib/project-manager/project-manager.svelte";
import ProjectManagerEl from "$lib/project-manager/ProjectManager.svelte";
let performanceStore = createPerformanceStore(); let performanceStore = createPerformanceStore();
@@ -37,6 +39,7 @@
const runtimeCache = new MemoryRuntimeCache(); const runtimeCache = new MemoryRuntimeCache();
const memoryRuntime = new MemoryRuntimeExecutor(nodeRegistry, runtimeCache); const memoryRuntime = new MemoryRuntimeExecutor(nodeRegistry, runtimeCache);
memoryRuntime.perf = performanceStore; memoryRuntime.perf = performanceStore;
const pm = new ProjectManager();
const runtime = $derived( const runtime = $derived(
appSettings.value.debug.useWorker ? workerRuntime : memoryRuntime, appSettings.value.debug.useWorker ? workerRuntime : memoryRuntime,
@@ -64,15 +67,6 @@
let activeNode = $state<NodeInstance | undefined>(undefined); let activeNode = $state<NodeInstance | undefined>(undefined);
let scene = $state<Group>(null!); let scene = $state<Group>(null!);
let graph = $state(
localStorage.getItem("graph")
? JSON.parse(localStorage.getItem("graph")!)
: templates.defaultPlant,
);
function handleSave(graph: Graph) {
localStorage.setItem("graph", JSON.stringify(graph));
}
let graphInterface = $state<ReturnType<typeof GraphInterface>>(null!); let graphInterface = $state<ReturnType<typeof GraphInterface>>(null!);
let viewerComponent = $state<ReturnType<typeof Viewer>>(); let viewerComponent = $state<ReturnType<typeof Viewer>>();
const manager = $derived(graphInterface?.manager); const manager = $derived(graphInterface?.manager);
@@ -91,21 +85,16 @@
callback: () => randomGenerate(), callback: () => randomGenerate(),
}, },
]); ]);
let graphSettings = $state<Record<string, any>>({}); let graphSettings = $state<Record<string, any>>({});
let graphSettingTypes = $state({
randomSeed: { type: "boolean", value: false },
});
$effect(() => { $effect(() => {
if (graphSettings) { if (graphSettings && graphSettingTypes) {
manager?.setSettings($state.snapshot(graphSettings)); manager?.setSettings($state.snapshot(graphSettings));
} }
}); });
type BooleanSchema = {
[key: string]: {
type: "boolean";
value: false;
};
};
let graphSettingTypes = $state<BooleanSchema>({
randomSeed: { type: "boolean", value: false },
});
async function update( async function update(
g: Graph, g: Graph,
@@ -183,19 +172,21 @@
/> />
</Grid.Cell> </Grid.Cell>
<Grid.Cell> <Grid.Cell>
<GraphInterface {#if pm.graph}
{graph} <GraphInterface
bind:this={graphInterface} graph={pm.graph}
registry={nodeRegistry} bind:this={graphInterface}
showGrid={appSettings.value.nodeInterface.showNodeGrid} registry={nodeRegistry}
snapToGrid={appSettings.value.nodeInterface.snapToGrid} showGrid={appSettings.value.nodeInterface.showNodeGrid}
bind:activeNode snapToGrid={appSettings.value.nodeInterface.snapToGrid}
bind:showHelp={appSettings.value.nodeInterface.showHelp} bind:activeNode
bind:settings={graphSettings} bind:showHelp={appSettings.value.nodeInterface.showHelp}
bind:settingTypes={graphSettingTypes} bind:settings={graphSettings}
onresult={(result) => handleUpdate(result)} bind:settingTypes={graphSettingTypes}
onsave={(graph) => handleSave(graph)} onsave={(g) => pm.saveGraph(g)}
/> onresult={(result) => handleUpdate(result)}
/>
{/if}
<Sidebar> <Sidebar>
<Panel id="general" title="General" icon="i-[tabler--settings]"> <Panel id="general" title="General" icon="i-[tabler--settings]">
<NestedSettings <NestedSettings
@@ -236,13 +227,16 @@
<PerformanceViewer data={$performanceStore} /> <PerformanceViewer data={$performanceStore} />
{/if} {/if}
</Panel> </Panel>
<Panel id="projects" icon="i-[tabler--folder-open]">
<ProjectManagerEl projectManager={pm} />
</Panel>
<Panel <Panel
id="graph-source" id="graph-source"
title="Graph Source" title="Graph Source"
hidden={!appSettings.value.debug.showGraphJson} hidden={!appSettings.value.debug.showGraphJson}
icon="i-[tabler--code]" icon="i-[tabler--code]"
> >
<GraphSource graph={graph && manager.serialize()} /> <GraphSource graph={pm.graph ?? manager?.serialize()} />
</Panel> </Panel>
<Panel <Panel
id="benchmark" id="benchmark"

View File

@@ -26,7 +26,6 @@
let nodeWasmWrapper = $state<ReturnType<typeof createWasmWrapper>>(); let nodeWasmWrapper = $state<ReturnType<typeof createWasmWrapper>>();
async function fetchNodeData(nodeId?: NodeId) { async function fetchNodeData(nodeId?: NodeId) {
console.log("FETCHING", { nodeId });
nodeWasm = undefined; nodeWasm = undefined;
nodeInstance = undefined; nodeInstance = undefined;

View File