spicebook/frontend/src/lib/notebook-store.ts
Ryan Malloy 8abd7719bf Initial SpiceBook MVP: notebook interface for circuit simulation
Phase 1 implementation with ngspice backend and Astro/React frontend:

Backend (FastAPI):
- ngspice subprocess engine with custom .raw file parser
- Notebook CRUD with .spicebook JSON format (filesystem storage)
- Simulation endpoints (standalone + cell-in-notebook)
- SVG waveform export endpoint
- 18 REST API routes, 5 passing tests

Frontend (Astro 5 + React 19):
- Notebook editor as React island with Zustand state management
- CodeMirror 6 with custom SPICE language mode (syntax highlighting
  for dot commands, components, engineering notation, comments)
- uPlot waveform viewer with transient and AC/Bode plot modes
- Markdown cells with edit/preview toggle
- Notebook list with card grid UI
- Dark theme, Tailwind CSS 4, Lucide icons

Infrastructure:
- Docker Compose with dev/prod targets
- Caddy-based frontend prod serving
- 3 example notebooks (RC filter, voltage divider, CE amplifier)
2026-02-13 01:44:38 -07:00

315 lines
7.7 KiB
TypeScript

import { create } from 'zustand';
import type {
Notebook,
Cell,
CellType,
CellOutput,
SimulationResponse,
} from './types';
import * as api from './api';
function generateId(): string {
return crypto.randomUUID().slice(0, 8);
}
function makeCell(type: CellType): Cell {
const templates: Record<CellType, string> = {
markdown: '## New Section\n\nDescribe your circuit here.',
spice: '* SPICE Netlist\n\nR1 in out 1k\nV1 in 0 DC 5\n\n.op\n.end',
python: '# Python analysis\nimport numpy as np\n',
schematic: '',
};
return {
id: generateId(),
type,
source: templates[type],
outputs: [],
};
}
interface NotebookStore {
// State
notebook: Notebook | null;
notebookId: string | null;
activeCell: string | null;
runningCells: Set<string>;
dirty: boolean;
loading: boolean;
error: string | null;
saving: boolean;
// Actions
loadNotebook: (id: string) => Promise<void>;
saveNotebook: () => Promise<void>;
updateCellSource: (cellId: string, source: string) => void;
addCell: (type: CellType, afterId?: string) => void;
deleteCell: (cellId: string) => void;
moveCell: (cellId: string, direction: 'up' | 'down') => void;
setActiveCell: (cellId: string | null) => void;
runCell: (cellId: string) => Promise<void>;
runAllCells: () => Promise<void>;
setCellOutput: (cellId: string, output: CellOutput) => void;
updateTitle: (title: string) => void;
updateEngine: (engine: string) => void;
clearError: () => void;
}
export const useNotebookStore = create<NotebookStore>((set, get) => ({
notebook: null,
notebookId: null,
activeCell: null,
runningCells: new Set(),
dirty: false,
loading: false,
error: null,
saving: false,
loadNotebook: async (id: string) => {
set({ loading: true, error: null });
try {
const notebook = await api.getNotebook(id);
set({
notebook,
notebookId: id,
loading: false,
dirty: false,
activeCell: notebook.cells.length > 0 ? notebook.cells[0].id : null,
});
} catch (err) {
set({
loading: false,
error: err instanceof Error ? err.message : 'Failed to load notebook',
});
}
},
saveNotebook: async () => {
const { notebook, notebookId } = get();
if (!notebook || !notebookId) return;
set({ saving: true });
try {
const updated = await api.updateNotebook(notebookId, notebook);
set({ notebook: updated, dirty: false, saving: false });
} catch (err) {
set({
saving: false,
error: err instanceof Error ? err.message : 'Failed to save notebook',
});
}
},
updateCellSource: (cellId: string, source: string) => {
const { notebook } = get();
if (!notebook) return;
set({
notebook: {
...notebook,
cells: notebook.cells.map((cell) =>
cell.id === cellId ? { ...cell, source } : cell,
),
},
dirty: true,
});
},
addCell: (type: CellType, afterId?: string) => {
const { notebook } = get();
if (!notebook) return;
const newCell = makeCell(type);
let cells: Cell[];
if (afterId) {
const idx = notebook.cells.findIndex((c) => c.id === afterId);
cells = [
...notebook.cells.slice(0, idx + 1),
newCell,
...notebook.cells.slice(idx + 1),
];
} else {
cells = [...notebook.cells, newCell];
}
set({
notebook: { ...notebook, cells },
activeCell: newCell.id,
dirty: true,
});
},
deleteCell: (cellId: string) => {
const { notebook, activeCell } = get();
if (!notebook) return;
const cells = notebook.cells.filter((c) => c.id !== cellId);
const newActive =
activeCell === cellId
? cells.length > 0
? cells[0].id
: null
: activeCell;
set({
notebook: { ...notebook, cells },
activeCell: newActive,
dirty: true,
});
},
moveCell: (cellId: string, direction: 'up' | 'down') => {
const { notebook } = get();
if (!notebook) return;
const idx = notebook.cells.findIndex((c) => c.id === cellId);
if (idx === -1) return;
const targetIdx = direction === 'up' ? idx - 1 : idx + 1;
if (targetIdx < 0 || targetIdx >= notebook.cells.length) return;
const cells = [...notebook.cells];
[cells[idx], cells[targetIdx]] = [cells[targetIdx], cells[idx]];
set({
notebook: { ...notebook, cells },
dirty: true,
});
},
setActiveCell: (cellId: string | null) => {
set({ activeCell: cellId });
},
runCell: async (cellId: string) => {
const { notebook, notebookId, runningCells } = get();
if (!notebook || !notebookId) return;
const cell = notebook.cells.find((c) => c.id === cellId);
if (!cell || cell.type !== 'spice') return;
const newRunning = new Set(runningCells);
newRunning.add(cellId);
set({ runningCells: newRunning });
try {
// Try the cell-specific endpoint first, fall back to raw simulation
let result: SimulationResponse;
try {
result = await api.runCell(notebookId, cellId);
} catch {
result = await api.runSimulation(
cell.source,
notebook.metadata.engine,
);
}
const output: CellOutput = {
output_type: result.success ? 'simulation_result' : 'error',
data: {
success: result.success,
waveform: result.waveform || null,
log: result.log,
error: result.error || null,
elapsed_seconds: result.elapsed_seconds,
},
timestamp: new Date().toISOString(),
};
const updatedNotebook = get().notebook;
if (updatedNotebook) {
set({
notebook: {
...updatedNotebook,
cells: updatedNotebook.cells.map((c) =>
c.id === cellId ? { ...c, outputs: [output] } : c,
),
},
});
}
} catch (err) {
const output: CellOutput = {
output_type: 'error',
data: {
success: false,
log: '',
error: err instanceof Error ? err.message : 'Simulation failed',
elapsed_seconds: 0,
},
timestamp: new Date().toISOString(),
};
const updatedNotebook = get().notebook;
if (updatedNotebook) {
set({
notebook: {
...updatedNotebook,
cells: updatedNotebook.cells.map((c) =>
c.id === cellId ? { ...c, outputs: [output] } : c,
),
},
});
}
} finally {
const currentRunning = new Set(get().runningCells);
currentRunning.delete(cellId);
set({ runningCells: currentRunning });
}
},
runAllCells: async () => {
const { notebook } = get();
if (!notebook) return;
const spiceCells = notebook.cells.filter((c) => c.type === 'spice');
for (const cell of spiceCells) {
await get().runCell(cell.id);
}
},
setCellOutput: (cellId: string, output: CellOutput) => {
const { notebook } = get();
if (!notebook) return;
set({
notebook: {
...notebook,
cells: notebook.cells.map((c) =>
c.id === cellId ? { ...c, outputs: [...c.outputs, output] } : c,
),
},
});
},
updateTitle: (title: string) => {
const { notebook } = get();
if (!notebook) return;
set({
notebook: {
...notebook,
metadata: { ...notebook.metadata, title },
},
dirty: true,
});
},
updateEngine: (engine: string) => {
const { notebook } = get();
if (!notebook) return;
set({
notebook: {
...notebook,
metadata: { ...notebook.metadata, engine },
},
dirty: true,
});
},
clearError: () => {
set({ error: null });
},
}));