2026-03-31 05:00:33 +07:00
|
|
|
/**
|
|
|
|
|
* Shared utility to load an example project into the editor and simulator stores.
|
|
|
|
|
* Used by both ExamplesPage (gallery click) and ExampleLoaderPage (direct URL).
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import type { ExampleProject } from '../data/examples';
|
|
|
|
|
import type { BoardKind } from '../types/board';
|
|
|
|
|
import { useEditorStore } from '../store/useEditorStore';
|
2026-04-23 10:22:35 +07:00
|
|
|
import { useSimulatorStore, DEFAULT_BOARD_POSITION } from '../store/useSimulatorStore';
|
2026-03-31 05:00:33 +07:00
|
|
|
import { useVfsStore } from '../store/useVfsStore';
|
|
|
|
|
import { isBoardComponent } from './boardPinMapping';
|
|
|
|
|
import { getInstalledLibraries, installLibrary } from '../services/libraryService';
|
|
|
|
|
import { trackOpenExample } from './analytics';
|
|
|
|
|
|
|
|
|
|
export interface LibraryInstallProgress {
|
|
|
|
|
total: number;
|
|
|
|
|
done: number;
|
|
|
|
|
current: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Install any missing Arduino libraries required by an example.
|
|
|
|
|
* Calls onProgress for UI updates; silently continues on failure.
|
|
|
|
|
*/
|
|
|
|
|
export async function ensureLibraries(
|
|
|
|
|
libs: string[],
|
|
|
|
|
onProgress?: (progress: LibraryInstallProgress | null) => void,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
if (libs.length === 0) return;
|
|
|
|
|
try {
|
|
|
|
|
const installed = await getInstalledLibraries();
|
|
|
|
|
const installedNames = new Set(
|
|
|
|
|
installed.map((l) => (l.library?.name ?? l.name ?? '').toLowerCase()),
|
|
|
|
|
);
|
|
|
|
|
const missing = libs.filter((l) => !installedNames.has(l.toLowerCase()));
|
|
|
|
|
if (missing.length === 0) return;
|
|
|
|
|
|
|
|
|
|
onProgress?.({ total: missing.length, done: 0, current: missing[0] });
|
|
|
|
|
for (let i = 0; i < missing.length; i++) {
|
|
|
|
|
onProgress?.({ total: missing.length, done: i, current: missing[i] });
|
|
|
|
|
await installLibrary(missing[i]);
|
|
|
|
|
}
|
|
|
|
|
onProgress?.(null);
|
|
|
|
|
} catch {
|
|
|
|
|
onProgress?.(null);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Load an example project into the editor + simulator stores.
|
|
|
|
|
* Does NOT navigate — the caller is responsible for navigation.
|
|
|
|
|
*/
|
|
|
|
|
export async function loadExample(
|
|
|
|
|
example: ExampleProject,
|
|
|
|
|
onLibraryProgress?: (progress: LibraryInstallProgress | null) => void,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
trackOpenExample(example.title);
|
|
|
|
|
|
|
|
|
|
// Auto-install required libraries
|
|
|
|
|
if (example.libraries && example.libraries.length > 0) {
|
|
|
|
|
await ensureLibraries(example.libraries, onLibraryProgress);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const {
|
2026-04-22 02:45:45 +07:00
|
|
|
setComponents,
|
|
|
|
|
setWires,
|
|
|
|
|
setBoardType,
|
2026-04-29 05:24:39 +07:00
|
|
|
setBoardLanguageMode,
|
2026-04-22 02:45:45 +07:00
|
|
|
activeBoardId,
|
|
|
|
|
boards,
|
|
|
|
|
addBoard,
|
|
|
|
|
removeBoard,
|
|
|
|
|
setActiveBoardId,
|
2026-04-16 18:39:47 +07:00
|
|
|
recalculateAllWirePositions,
|
2026-03-31 05:00:33 +07:00
|
|
|
} = useSimulatorStore.getState();
|
|
|
|
|
|
|
|
|
|
if (example.boards && example.boards.length > 0) {
|
|
|
|
|
// ── Multi-board loading ───────────────────────────────────────────────
|
|
|
|
|
const currentIds = boards.map((b) => b.id);
|
|
|
|
|
currentIds.forEach((id) => removeBoard(id));
|
|
|
|
|
|
|
|
|
|
example.boards.forEach((eb) => {
|
|
|
|
|
addBoard(eb.boardKind as BoardKind, eb.x, eb.y);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-29 05:24:39 +07:00
|
|
|
// Match addBoard's deterministic ID rule (useSimulatorStore.addBoard):
|
|
|
|
|
// 1st board of a kind → id = boardKind
|
|
|
|
|
// 2nd board of a kind → id = `${boardKind}-2`
|
|
|
|
|
// Nth board of a kind → id = `${boardKind}-N`
|
|
|
|
|
// This is what wires reference, so the loader must compute the same IDs
|
|
|
|
|
// when loading per-board code/vfs.
|
|
|
|
|
const kindCount = new Map<string, number>();
|
|
|
|
|
const boardIds: string[] = example.boards.map((eb) => {
|
|
|
|
|
const n = (kindCount.get(eb.boardKind) ?? 0) + 1;
|
|
|
|
|
kindCount.set(eb.boardKind, n);
|
|
|
|
|
return n === 1 ? eb.boardKind : `${eb.boardKind}-${n}`;
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-31 05:00:33 +07:00
|
|
|
const { boards: newBoards } = useSimulatorStore.getState();
|
2026-04-29 05:24:39 +07:00
|
|
|
example.boards.forEach((eb, idx) => {
|
|
|
|
|
const boardId = boardIds[idx];
|
2026-03-31 05:00:33 +07:00
|
|
|
const board = newBoards.find((b) => b.id === boardId);
|
|
|
|
|
if (!board) return;
|
|
|
|
|
|
|
|
|
|
if (eb.code) {
|
2026-04-29 06:40:48 +07:00
|
|
|
// Arduino-style boards (AVR, RP2040, ESP32, …) all need the `.ino`
|
|
|
|
|
// extension so arduino-cli auto-includes <Arduino.h>. Only the Pi 3B
|
|
|
|
|
// uses a different toolchain (Python via VFS or g++ for `.cpp`).
|
|
|
|
|
const filename = eb.boardKind === 'raspberry-pi-3' ? 'main.cpp' : 'sketch.ino';
|
2026-03-31 05:00:33 +07:00
|
|
|
useEditorStore.getState().setActiveGroup(board.activeFileGroupId);
|
|
|
|
|
useEditorStore.getState().loadFiles([{ name: filename, content: eb.code }]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 05:24:39 +07:00
|
|
|
if (eb.vfsFiles && eb.boardKind === 'raspberry-pi-3') {
|
2026-03-31 05:00:33 +07:00
|
|
|
const vfsState = useVfsStore.getState();
|
|
|
|
|
const tree = vfsState.getTree(boardId);
|
|
|
|
|
for (const [nodeId, node] of Object.entries(tree)) {
|
|
|
|
|
if (node.type === 'file' && eb.vfsFiles[node.name] !== undefined) {
|
|
|
|
|
vfsState.setContent(boardId, nodeId, eb.vfsFiles[node.name]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-29 05:24:39 +07:00
|
|
|
const firstArduinoIdx = example.boards.findIndex(
|
2026-03-31 05:00:33 +07:00
|
|
|
(eb) =>
|
|
|
|
|
eb.boardKind !== 'raspberry-pi-3' &&
|
|
|
|
|
eb.boardKind !== 'esp32' &&
|
|
|
|
|
eb.boardKind !== 'esp32-s3' &&
|
|
|
|
|
eb.boardKind !== 'esp32-c3',
|
|
|
|
|
);
|
2026-04-29 05:24:39 +07:00
|
|
|
if (firstArduinoIdx !== -1) {
|
|
|
|
|
setActiveBoardId(boardIds[firstArduinoIdx]);
|
2026-03-31 05:00:33 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const componentsWithoutBoard = example.components.filter(
|
|
|
|
|
(comp) =>
|
|
|
|
|
!comp.type.includes('arduino') &&
|
|
|
|
|
!comp.type.includes('pico') &&
|
|
|
|
|
!comp.type.includes('raspberry') &&
|
|
|
|
|
!comp.type.includes('esp32'),
|
|
|
|
|
);
|
|
|
|
|
setComponents(
|
|
|
|
|
componentsWithoutBoard.map((comp) => ({
|
|
|
|
|
id: comp.id,
|
2026-04-22 02:45:45 +07:00
|
|
|
metadataId: comp.type.replace(/^(wokwi|velxio)-/, ''),
|
2026-03-31 05:00:33 +07:00
|
|
|
x: comp.x,
|
|
|
|
|
y: comp.y,
|
|
|
|
|
properties: comp.properties,
|
|
|
|
|
})),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
setWires(
|
|
|
|
|
example.wires.map((wire) => ({
|
|
|
|
|
id: wire.id,
|
|
|
|
|
start: { componentId: wire.start.componentId, pinName: wire.start.pinName, x: 0, y: 0 },
|
|
|
|
|
end: { componentId: wire.end.componentId, pinName: wire.end.pinName, x: 0, y: 0 },
|
|
|
|
|
color: wire.color,
|
|
|
|
|
waypoints: [],
|
|
|
|
|
})),
|
|
|
|
|
);
|
2026-04-16 18:39:47 +07:00
|
|
|
recalculateAllWirePositions();
|
2026-03-31 05:00:33 +07:00
|
|
|
} else {
|
|
|
|
|
// ── Single-board loading ─────────────────────────────────────────────
|
2026-04-21 22:59:59 +07:00
|
|
|
// Analog-only SPICE examples are board-less. Remove every existing board
|
|
|
|
|
// so the canvas opens with just the analog circuit (boards are now
|
|
|
|
|
// optional — you can have 0, 1, or many at any time).
|
|
|
|
|
const isAnalogOnly = (example as any).boardFilter === 'analog';
|
|
|
|
|
if (isAnalogOnly) {
|
|
|
|
|
const currentIds = boards.map((b) => b.id);
|
|
|
|
|
currentIds.forEach((id) => removeBoard(id));
|
|
|
|
|
} else {
|
|
|
|
|
const targetBoard = example.boardType || 'arduino-uno';
|
2026-04-23 10:22:35 +07:00
|
|
|
// If boards[] is empty (e.g. a previous analog example removed every
|
|
|
|
|
// board), setBoardType can't work — it only maps over existing entries.
|
|
|
|
|
// Add a fresh board instead.
|
|
|
|
|
if (useSimulatorStore.getState().boards.length === 0) {
|
|
|
|
|
const newId = addBoard(
|
|
|
|
|
targetBoard as BoardKind,
|
|
|
|
|
DEFAULT_BOARD_POSITION.x,
|
|
|
|
|
DEFAULT_BOARD_POSITION.y,
|
|
|
|
|
);
|
|
|
|
|
setActiveBoardId(newId);
|
|
|
|
|
} else {
|
|
|
|
|
setBoardType(targetBoard);
|
|
|
|
|
}
|
2026-04-21 22:59:59 +07:00
|
|
|
}
|
2026-04-29 05:24:39 +07:00
|
|
|
|
|
|
|
|
// ── MicroPython + multi-file payloads ────────────────────────────────
|
|
|
|
|
// When the example specifies languageMode='micropython' or ships a
|
|
|
|
|
// files[] array, we go through setBoardLanguageMode + loadFiles instead
|
|
|
|
|
// of the legacy setCode() path so the editor opens the right file
|
|
|
|
|
// (main.py) with the right language mode.
|
|
|
|
|
const liveBoardId = useSimulatorStore.getState().activeBoardId;
|
|
|
|
|
const liveBoard = useSimulatorStore
|
|
|
|
|
.getState()
|
|
|
|
|
.boards.find((b) => b.id === liveBoardId);
|
|
|
|
|
|
|
|
|
|
if (example.languageMode === 'micropython' && liveBoard) {
|
|
|
|
|
setBoardLanguageMode(liveBoard.id, 'micropython');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (example.files && example.files.length > 0 && liveBoard) {
|
|
|
|
|
// Re-resolve the file group ID — setBoardLanguageMode replaces it.
|
|
|
|
|
const updatedBoard = useSimulatorStore
|
|
|
|
|
.getState()
|
|
|
|
|
.boards.find((b) => b.id === liveBoard.id);
|
|
|
|
|
const groupId = updatedBoard?.activeFileGroupId ?? liveBoard.activeFileGroupId;
|
|
|
|
|
const editorStore = useEditorStore.getState();
|
|
|
|
|
editorStore.setActiveGroup(groupId);
|
|
|
|
|
editorStore.loadFiles(example.files);
|
|
|
|
|
} else {
|
|
|
|
|
useEditorStore.getState().setCode(example.code);
|
|
|
|
|
}
|
2026-03-31 05:00:33 +07:00
|
|
|
|
|
|
|
|
const componentsWithoutBoard = example.components.filter(
|
|
|
|
|
(comp) =>
|
|
|
|
|
!comp.type.includes('arduino') &&
|
|
|
|
|
!comp.type.includes('pico') &&
|
|
|
|
|
!comp.type.includes('esp32'),
|
|
|
|
|
);
|
|
|
|
|
setComponents(
|
|
|
|
|
componentsWithoutBoard.map((comp) => ({
|
|
|
|
|
id: comp.id,
|
2026-04-22 02:45:45 +07:00
|
|
|
metadataId: comp.type.replace(/^(wokwi|velxio)-/, ''),
|
2026-03-31 05:00:33 +07:00
|
|
|
x: comp.x,
|
|
|
|
|
y: comp.y,
|
|
|
|
|
properties: comp.properties,
|
|
|
|
|
})),
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-21 22:59:59 +07:00
|
|
|
// After possibly removing every board, re-read activeBoardId.
|
|
|
|
|
const liveActiveBoardId = useSimulatorStore.getState().activeBoardId;
|
|
|
|
|
// For analog (board-less) examples we leave any 'arduino-uno' references
|
|
|
|
|
// in wires untouched — there shouldn't be any, but if there are we'd
|
|
|
|
|
// rather emit a dangling endpoint than silently graft them onto a board
|
|
|
|
|
// that no longer exists.
|
|
|
|
|
const remapBoardId = (id: string) =>
|
|
|
|
|
isBoardComponent(id) && liveActiveBoardId ? liveActiveBoardId : id;
|
2026-03-31 05:00:33 +07:00
|
|
|
|
|
|
|
|
setWires(
|
|
|
|
|
example.wires.map((wire) => ({
|
|
|
|
|
id: wire.id,
|
2026-04-22 02:45:45 +07:00
|
|
|
start: {
|
|
|
|
|
componentId: remapBoardId(wire.start.componentId),
|
|
|
|
|
pinName: wire.start.pinName,
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 0,
|
|
|
|
|
},
|
|
|
|
|
end: {
|
|
|
|
|
componentId: remapBoardId(wire.end.componentId),
|
|
|
|
|
pinName: wire.end.pinName,
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 0,
|
|
|
|
|
},
|
2026-03-31 05:00:33 +07:00
|
|
|
color: wire.color,
|
|
|
|
|
waypoints: [],
|
|
|
|
|
})),
|
|
|
|
|
);
|
2026-04-16 18:39:47 +07:00
|
|
|
recalculateAllWirePositions();
|
2026-03-31 05:00:33 +07:00
|
|
|
}
|
|
|
|
|
}
|