add velxio as submodule
This commit is contained in:
parent
5f57e67a25
commit
74a8d87853
|
|
@ -0,0 +1,3 @@
|
|||
[submodule "velxio"]
|
||||
path = velxio
|
||||
url = https://git.manakin-gentoo.ts.net/a2nr/velxio.git
|
||||
|
|
@ -12,6 +12,9 @@
|
|||
},
|
||||
"/assets/": {
|
||||
"Proxy": "http://elemes:5000/assets/"
|
||||
},
|
||||
"/velxio/": {
|
||||
"Proxy": "http://velxio:80/"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
605
documentation.md
605
documentation.md
|
|
@ -1,7 +1,7 @@
|
|||
# Elemes LMS — Dokumentasi Teknis
|
||||
|
||||
**Project:** LMS-C (Learning Management System untuk Pemrograman C)
|
||||
**Terakhir diupdate:** 30 Maret 2026
|
||||
**Project:** LMS-C (Learning Management System untuk Pemrograman C & Arduino)
|
||||
**Terakhir diupdate:** 8 April 2026
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -12,12 +12,17 @@ Internet (HTTPS :443)
|
|||
│
|
||||
▼
|
||||
Tailscale Funnel (elemes-ts)
|
||||
│
|
||||
├── / → SvelteKit Frontend (elemes-frontend :3000)
|
||||
├── /assets/ → Flask Backend (elemes :5000)
|
||||
├── /velxio/ → Velxio Arduino Simulator (velxio :80)
|
||||
│
|
||||
▼
|
||||
SvelteKit Frontend (elemes-frontend :3000)
|
||||
├── SSR pages (lesson content embedded in HTML)
|
||||
├── CodeMirror 6 editor (lazy-loaded)
|
||||
├── CircuitJS simulator (iframe, GWT-compiled)
|
||||
├── CircuitJS simulator (iframe, GWT-compiled) — mode "circuit"
|
||||
├── Velxio Arduino simulator (iframe, React) — mode "velxio"
|
||||
├── API proxy: /api/* → Flask
|
||||
└── PWA manifest
|
||||
│
|
||||
|
|
@ -27,17 +32,24 @@ Flask API Backend (elemes :5000)
|
|||
├── Token authentication (CSV)
|
||||
├── Progress tracking
|
||||
└── Lesson content parsing (markdown)
|
||||
│
|
||||
Velxio Arduino Simulator (velxio :80)
|
||||
├── React + Vite frontend (editor + simulator canvas)
|
||||
├── FastAPI backend (arduino-cli compile)
|
||||
├── AVR8 / RP2040 CPU emulation (browser)
|
||||
└── PostMessage bridge ↔ Elemes (EmbedBridge.ts)
|
||||
```
|
||||
|
||||
### Container Setup
|
||||
|
||||
| Container | Image | IP (static) | Port | Fungsi |
|
||||
|-----------|-------|-------------|------|--------|
|
||||
| `elemes` | Python 3.11 + gcc | 10.89.100.10 | 5000 | Flask API |
|
||||
| `elemes-frontend` | Node 20 | 10.89.100.11 | 3000 | SvelteKit SSR |
|
||||
| `elemes-ts` | Tailscale | 10.89.100.12 | 443 | HTTPS Funnel |
|
||||
| Container | Image | Port | Fungsi |
|
||||
|-----------|-------|------|--------|
|
||||
| `elemes` | Python 3.11 + gcc | 5000 | Flask API (compile, auth, lessons, progress) |
|
||||
| `elemes-frontend` | Node 20 | 3000 | SvelteKit SSR |
|
||||
| `velxio` | Node + Python + arduino-cli | 80 | Simulator Arduino (React + FastAPI) |
|
||||
| `elemes-ts` | Tailscale | 443 | HTTPS Funnel + reverse proxy |
|
||||
|
||||
Static IPs karena aardvark-dns (podman DNS) tidak resolve hostname antar container.
|
||||
Container berkomunikasi via hostname Podman (compose service name). Proxy rules di `config/sinau-c-tail.json`.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -45,21 +57,30 @@ Static IPs karena aardvark-dns (podman DNS) tidak resolve hostname antar contain
|
|||
|
||||
```
|
||||
lms-c/
|
||||
├── content/ # 25 lesson markdown
|
||||
├── content/ # Lesson markdown files
|
||||
│ ├── home.md # Daftar lesson + landing page
|
||||
│ ├── hello_world.md # Lesson C
|
||||
│ ├── variabel.md # Lesson C
|
||||
│ ├── rangkaian_dasar.md # Lesson Circuit
|
||||
│ └── led_blink_arduino.md # Lesson Arduino/Velxio
|
||||
├── assets/ # Gambar untuk lesson
|
||||
├── tokens_siswa.csv # Data siswa & progress
|
||||
├── config/sinau-c-tail.json # Tailscale serve config
|
||||
├── state/ # Tailscale runtime state
|
||||
├── .env # Environment variables
|
||||
│
|
||||
└── elemes/ # Semua kode aplikasi
|
||||
├── app.py # Flask create_app() factory
|
||||
├── config.py # Environment config
|
||||
├── config.py # CONTENT_DIR, TOKENS_FILE
|
||||
├── Dockerfile # Flask container
|
||||
├── gunicorn.conf.py # Production WSGI
|
||||
├── requirements.txt # Python deps
|
||||
├── podman-compose.yml # 3 services
|
||||
├── podman-compose.yml # 4 services (elemes, frontend, velxio, ts)
|
||||
├── elemes.sh # CLI: init, run, runbuild, stop, generatetoken
|
||||
├── generate_tokens.py # Utility: generate CSV tokens
|
||||
├── proposal.md # Proposal integrasi Velxio (referensi arsitektur)
|
||||
│
|
||||
├── config/
|
||||
│ └── sinau-c-tail.json # Tailscale Serve proxy rules
|
||||
│
|
||||
├── compiler/ # Code compilation
|
||||
│ ├── __init__.py # CompilerFactory
|
||||
|
|
@ -70,60 +91,83 @@ lms-c/
|
|||
├── routes/ # Flask Blueprints
|
||||
│ ├── auth.py # /login, /logout, /validate-token
|
||||
│ ├── compile.py # /compile
|
||||
│ ├── lessons.py # /lessons, /lesson/<slug>.json
|
||||
│ └── progress.py # /track-progress, /progress-report.json
|
||||
│ ├── lessons.py # /lessons, /lesson/<slug>.json, /get-key-text/<slug>
|
||||
│ └── progress.py # /track-progress, /progress-report.json, export-csv
|
||||
│
|
||||
├── services/ # Business logic
|
||||
│ ├── token_service.py # CSV token CRUD
|
||||
│ └── lesson_service.py # Markdown parsing
|
||||
│ └── lesson_service.py # Markdown parsing + rendering
|
||||
│
|
||||
└── frontend/ # SvelteKit PWA
|
||||
├── package.json
|
||||
├── svelte.config.js # adapter-node + path aliases
|
||||
├── vite.config.ts # API proxy (dev only)
|
||||
├── Dockerfile # Multi-stage build
|
||||
└── src/
|
||||
├── hooks.server.ts # API proxy (production)
|
||||
├── app.html
|
||||
├── app.css
|
||||
├── lib/
|
||||
│ ├── components/
|
||||
│ │ ├── CircuitEditor.svelte # CircuitJS iframe wrapper
|
||||
│ │ ├── CrosshairOverlay.svelte # Touch precision overlay (fat finger fix)
|
||||
│ │ ├── CodeEditor.svelte # CodeMirror 6 (lazy-loaded)
|
||||
│ │ ├── Navbar.svelte
|
||||
│ │ ├── LessonCard.svelte
|
||||
│ │ ├── OutputPanel.svelte
|
||||
│ │ ├── ProgressBadge.svelte
|
||||
│ │ └── Footer.svelte
|
||||
│ ├── stores/
|
||||
│ │ ├── auth.ts # Svelte writable stores
|
||||
│ │ └── theme.ts # Dark/light toggle
|
||||
│ ├── services/
|
||||
│ │ └── api.ts # Flask API client
|
||||
│ └── types/
|
||||
│ ├── lesson.ts
|
||||
│ ├── auth.ts
|
||||
│ ├── compiler.ts
|
||||
│ └── circuitjs.ts # CircuitJSApi interface
|
||||
├── routes/
|
||||
│ ├── +layout.svelte
|
||||
│ ├── +page.svelte # Home (lesson grid)
|
||||
│ ├── +page.ts # SSR data loader
|
||||
│ ├── lesson/[slug]/
|
||||
│ │ ├── +page.svelte # Lesson viewer + editor
|
||||
│ │ └── +page.ts # SSR data loader
|
||||
│ └── progress/
|
||||
│ └── +page.svelte # Teacher dashboard
|
||||
└── static/
|
||||
├── manifest.json # PWA manifest
|
||||
└── circuitjs1/ # CircuitJS simulator (GWT-compiled)
|
||||
├── circuitjs.html # GWT entry point (loaded in iframe)
|
||||
├── lz-string.min.js # LZ compression (circuit export)
|
||||
└── circuitjs1/ # GWT compiled output
|
||||
├── *.cache.js # Compiled permutations
|
||||
├── circuitjs1.nocache.js # Bootstrap loader
|
||||
└── circuits/ # Bundled example circuits
|
||||
├── frontend/ # SvelteKit (Svelte 5)
|
||||
│ ├── package.json
|
||||
│ ├── svelte.config.js # adapter-node + path aliases
|
||||
│ ├── vite.config.ts
|
||||
│ ├── Dockerfile # Multi-stage build
|
||||
│ └── src/
|
||||
│ ├── hooks.server.ts # API proxy: /api/* → Flask, /assets/* → Flask
|
||||
│ ├── app.html
|
||||
│ ├── app.css
|
||||
│ ├── lib/
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── CodeEditor.svelte # CodeMirror 6 (lazy-loaded, anti-paste)
|
||||
│ │ │ ├── CircuitEditor.svelte # CircuitJS iframe wrapper
|
||||
│ │ │ ├── CrosshairOverlay.svelte # Touch precision overlay (CircuitJS)
|
||||
│ │ │ ├── OutputPanel.svelte # Multi-section output (C, Python, Circuit, Arduino)
|
||||
│ │ │ ├── CelebrationOverlay.svelte # Lesson completion animation
|
||||
│ │ │ ├── WorkspaceHeader.svelte # Tab switcher + floating/mobile controls
|
||||
│ │ │ ├── LessonFooterNav.svelte # Prev/next lesson navigation
|
||||
│ │ │ ├── Navbar.svelte
|
||||
│ │ │ ├── LessonCard.svelte
|
||||
│ │ │ ├── LessonList.svelte
|
||||
│ │ │ ├── ProgressBadge.svelte
|
||||
│ │ │ └── Footer.svelte
|
||||
│ │ ├── stores/
|
||||
│ │ │ ├── auth.ts # Svelte writable stores (login, token)
|
||||
│ │ │ ├── lessonContext.ts # Current lesson nav context
|
||||
│ │ │ └── theme.ts # Dark/light toggle
|
||||
│ │ ├── services/
|
||||
│ │ │ ├── api.ts # Flask API client
|
||||
│ │ │ ├── exercise.ts # checkKeyText(), validateNodes()
|
||||
│ │ │ └── velxio-bridge.ts # PostMessage bridge ke Velxio iframe
|
||||
│ │ ├── actions/
|
||||
│ │ │ ├── floatingPanel.svelte.ts # Draggable/resizable editor panel
|
||||
│ │ │ ├── highlightCode.ts # Syntax highlighting post-render
|
||||
│ │ │ ├── noSelect.ts # Anti-select directive
|
||||
│ │ │ └── renderCircuitEmbeds.ts # Inline circuit embed renderer
|
||||
│ │ └── types/
|
||||
│ │ ├── lesson.ts # LessonContent interface
|
||||
│ │ ├── auth.ts
|
||||
│ │ ├── compiler.ts
|
||||
│ │ └── circuitjs.ts # CircuitJSApi interface
|
||||
│ ├── routes/
|
||||
│ │ ├── +layout.svelte # App shell (Navbar, theme)
|
||||
│ │ ├── +page.svelte # Home (lesson grid)
|
||||
│ │ ├── +page.ts # SSR data loader
|
||||
│ │ ├── lesson/[slug]/
|
||||
│ │ │ ├── +page.svelte # Lesson viewer (semua mode)
|
||||
│ │ │ └── +page.ts # SSR data loader
|
||||
│ │ └── progress/
|
||||
│ │ └── +page.svelte # Teacher dashboard
|
||||
│ └── static/
|
||||
│ ├── manifest.json # PWA manifest
|
||||
│ └── circuitjs1/ # CircuitJS simulator (GWT-compiled)
|
||||
│
|
||||
└── velxio/ # Velxio fork (Git submodule)
|
||||
├── frontend/ # React + Vite + TypeScript
|
||||
│ └── src/
|
||||
│ ├── services/EmbedBridge.ts # PostMessage listener (Velxio side)
|
||||
│ ├── pages/EditorPage.tsx # Editor + embed mode
|
||||
│ ├── components/editor/EditorToolbar.tsx
|
||||
│ ├── components/simulator/SimulatorCanvas.tsx # Canvas + touch/pinch + undo/redo toolbar
|
||||
│ ├── components/simulator/SimulatorCanvas.css # Canvas styling (undo-controls class)
|
||||
│ ├── components/simulator/WireInProgressRenderer.tsx # Wire preview + crosshair
|
||||
│ ├── components/simulator/WireLayer.tsx # Wire SVG rendering + segment handles
|
||||
│ ├── components/simulator/PinOverlay.tsx
|
||||
│ ├── store/useEditorStore.ts # Multi-file workspace
|
||||
│ └── store/useSimulatorStore.ts # Simulation state, wires, undo/redo stacks
|
||||
├── backend/ # FastAPI + arduino-cli
|
||||
├── Dockerfile.standalone
|
||||
└── CLAUDE.md # Dokumentasi teknis lengkap Velxio
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -136,50 +180,59 @@ lms-c/
|
|||
- SSR untuk konten markdown (instant first paint)
|
||||
- PWA installable tanpa app store
|
||||
|
||||
### Kenapa Svelte writable stores, bukan runes ($state)?
|
||||
Svelte 5 runes (`$state`, `$derived`) hanya bekerja di dalam file `.svelte`. File `.ts` biasa tidak diproses oleh Svelte compiler, sehingga `$state()` menjadi `ReferenceError` saat runtime di server. Solusi: gunakan `writable()` dari `svelte/store` di file `.ts`, dan `$storeName` auto-subscription di `.svelte`.
|
||||
|
||||
### Kenapa static IP, bukan DNS?
|
||||
Podman's aardvark-dns tidak berfungsi di environment ini (getaddrinfo EAI_AGAIN). Workaround: assign static IP per container via IPAM config di podman-compose.yml.
|
||||
### Svelte 5 Runes vs Writable Stores
|
||||
Proyek ini menggunakan **Svelte 5** dengan dua pola state management:
|
||||
- **Runes (`$state`, `$derived`, `$effect`)** — digunakan di file `.svelte` (contoh: `lesson/[slug]/+page.svelte` menggunakan `$state` untuk semua UI state, `$derived` untuk computed values, `$effect` untuk side effects)
|
||||
- **Writable stores (`writable()`)** — digunakan di file `.ts` biasa karena runes tidak diproses oleh Svelte compiler di luar file `.svelte`. Contoh: `auth.ts`, `lessonContext.ts`, `theme.ts`
|
||||
|
||||
### Kenapa hooks.server.ts untuk API proxy?
|
||||
Vite `server.proxy` hanya bekerja di dev mode (`vite dev`). Di production (adapter-node), SvelteKit tidak punya proxy. `hooks.server.ts` mem-forward `/api/*` ke Flask backend saat runtime.
|
||||
Vite `server.proxy` hanya bekerja di dev mode (`vite dev`). Di production (adapter-node), SvelteKit tidak punya proxy. `hooks.server.ts` mem-forward `/api/*` dan `/assets/*` ke Flask backend saat runtime. Prefix `/api/` di-strip sebelum forward (Flask routes tidak pakai `/api/` prefix).
|
||||
|
||||
### Kenapa transparent overlay untuk touch, bukan inject ke iframe?
|
||||
CircuitJS adalah GWT-compiled app — tidak bisa modify source code-nya. Alternatif lain:
|
||||
- **Inject script via `contentDocument`**: Fragile, GWT overwrite DOM handlers.
|
||||
- **PostMessage**: Tidak bisa dispatch native MouseEvent dari luar.
|
||||
- **Transparent overlay**: Intercept PointerEvent di parent, konversi ke MouseEvent, dispatch ke iframe via `contentDocument.elementFromPoint()`. Paling reliable karena tidak bergantung pada internal CircuitJS.
|
||||
|
||||
Overlay dinonaktifkan (`pointer-events: none`) di desktop sehingga mouse events langsung tembus ke iframe tanpa overhead.
|
||||
### Kenapa transparent overlay untuk touch CircuitJS?
|
||||
CircuitJS adalah GWT-compiled app — tidak bisa modify source code-nya. Transparent overlay mengintercept PointerEvent di parent, konversi ke MouseEvent, dispatch ke iframe via `contentDocument.elementFromPoint()`. Overlay dinonaktifkan (`pointer-events: none`) di desktop sehingga mouse events langsung tembus ke iframe.
|
||||
|
||||
### Kenapa lazy-load CodeMirror?
|
||||
CodeMirror 6 bundle ~475KB. Dengan dynamic `import()`, lesson content (text) muncul langsung via SSR, editor menyusul setelah JS bundle selesai download. Perceived load time jauh lebih cepat.
|
||||
CodeMirror 6 bundle ~475KB. Dengan dynamic `import()`, lesson content (text) muncul langsung via SSR, editor menyusul setelah JS bundle selesai download.
|
||||
|
||||
### Kenapa Velxio di-embed via iframe, bukan komponen?
|
||||
Velxio adalah aplikasi React lengkap (editor + simulator + serial monitor + wire system). Mengekstrak komponen-komponennya ke SvelteKit tidak praktis. Iframe + PostMessage bridge memungkinkan kedua sistem dikembangkan secara independen. Same-origin via Tailscale Serve proxy (`/velxio/`), jadi tidak perlu CORS/CSP khusus.
|
||||
|
||||
---
|
||||
|
||||
## Cara Menjalankan
|
||||
|
||||
```bash
|
||||
# Setup awal (sekali)
|
||||
cd elemes/
|
||||
podman-compose --env-file ../.env up --build -d
|
||||
./elemes.sh init
|
||||
|
||||
# Edit ../.env sesuai kebutuhan (ELEMES_HOST, TS_AUTHKEY, branding)
|
||||
|
||||
# Build & run semua container
|
||||
./elemes.sh runbuild
|
||||
|
||||
# Atau: run tanpa rebuild
|
||||
./elemes.sh run
|
||||
|
||||
# Stop
|
||||
./elemes.sh stop
|
||||
|
||||
# Rebuild tanpa cache
|
||||
./elemes.sh runclearbuild
|
||||
|
||||
# Generate token siswa dari content
|
||||
./elemes.sh generatetoken
|
||||
```
|
||||
|
||||
- **Frontend:** http://localhost:3000
|
||||
- **Frontend:** http://localhost:3000 (dev)
|
||||
- **Tailscale:** https://{ELEMES_HOST}.{tailnet}.ts.net
|
||||
|
||||
### Rebuild setelah perubahan kode
|
||||
|
||||
```bash
|
||||
podman-compose --env-file ../.env down
|
||||
podman-compose --env-file ../.env up --build -d
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
```bash
|
||||
podman logs elemes # Flask API
|
||||
podman logs elemes-frontend # SvelteKit
|
||||
podman logs velxio # Velxio simulator
|
||||
podman logs elemes-ts # Tailscale
|
||||
```
|
||||
|
||||
|
|
@ -187,35 +240,135 @@ podman logs elemes-ts # Tailscale
|
|||
|
||||
## API Endpoints
|
||||
|
||||
Semua endpoint Flask diakses via SvelteKit proxy (`/api/*` → Flask `:5000`):
|
||||
Semua endpoint Flask diakses via SvelteKit proxy (`/api/*` → Flask `:5000`, prefix `/api/` di-strip):
|
||||
|
||||
| Method | Path | Fungsi |
|
||||
|--------|------|--------|
|
||||
| POST | `/api/login` | Login dengan token |
|
||||
| POST | `/api/logout` | Logout |
|
||||
| POST | `/api/validate-token` | Validasi token |
|
||||
| GET | `/api/lessons` | Daftar lesson + home content |
|
||||
| GET | `/api/lesson/<slug>.json` | Data lesson lengkap |
|
||||
| GET | `/api/get-key-text/<slug>` | Key text untuk lesson |
|
||||
| POST | `/api/compile` | Compile & run kode |
|
||||
| POST | `/api/track-progress` | Track progress siswa |
|
||||
| GET | `/api/progress-report.json` | Data progress semua siswa |
|
||||
| GET | `/api/progress-report/export-csv` | Export CSV |
|
||||
| Method | Frontend Path | Flask Path | Fungsi |
|
||||
|--------|--------------|------------|--------|
|
||||
| POST | `/api/login` | `/login` | Login dengan token |
|
||||
| POST | `/api/logout` | `/logout` | Logout |
|
||||
| POST | `/api/validate-token` | `/validate-token` | Validasi token |
|
||||
| GET | `/api/lessons` | `/lessons` | Daftar lesson + home content |
|
||||
| GET | `/api/lesson/<slug>.json` | `/lesson/<slug>.json` | Data lesson lengkap |
|
||||
| GET | `/api/get-key-text/<slug>` | `/get-key-text/<slug>` | Key text untuk lesson |
|
||||
| POST | `/api/compile` | `/compile` | Compile & run kode (C/Python) |
|
||||
| POST | `/api/track-progress` | `/track-progress` | Track progress siswa |
|
||||
| GET | `/api/progress-report.json` | `/progress-report.json` | Data progress semua siswa |
|
||||
| GET | `/api/progress-report/export-csv` | `/progress-report/export-csv` | Export CSV |
|
||||
| GET | `/assets/<path>` | `/assets/<path>` | Static assets (proxy langsung, tanpa strip) |
|
||||
|
||||
---
|
||||
|
||||
## Mode Lesson
|
||||
|
||||
Elemes mendukung beberapa mode lesson melalui **marker** di file markdown. Mode ditentukan otomatis dari marker yang ada:
|
||||
|
||||
| Mode | Marker | Tab yang muncul | Evaluasi |
|
||||
|------|--------|-----------------|----------|
|
||||
| **C** | `---INITIAL_CODE---` | Editor (C) + Output | stdout matching + key_text |
|
||||
| **Python** | `---INITIAL_PYTHON---` | Editor (Python) + Output | stdout matching + key_text |
|
||||
| **Circuit** | `---INITIAL_CIRCUIT---` | Circuit + Output | node voltage + key_text |
|
||||
| **Arduino/Velxio** | `---INITIAL_CODE_ARDUINO---` | Velxio (iframe) + Output | serial + wiring + key_text |
|
||||
| **Velxio circuit-only** | `---VELXIO_CIRCUIT---` (tanpa code) | Velxio (no editor) + Output | wiring + key_text |
|
||||
| **Hybrid** | C/Python + Circuit | Editor + Circuit + Output | AND-logic: kedua harus pass |
|
||||
|
||||
### Markdown Sections yang Dikenali
|
||||
|
||||
| Section | Fungsi |
|
||||
|---------|--------|
|
||||
| `---LESSON_INFO---` / `---END_LESSON_INFO---` | Info tab (learning objectives) |
|
||||
| `---EXERCISE---` | Instruksi latihan (separator) |
|
||||
| `---INITIAL_CODE---` / `---END_INITIAL_CODE---` | Kode awal C |
|
||||
| `---INITIAL_PYTHON---` / `---END_INITIAL_PYTHON---` | Kode awal Python |
|
||||
| `---INITIAL_CIRCUIT---` / `---END_INITIAL_CIRCUIT---` | Circuit text awal (CircuitJS format) |
|
||||
| `---INITIAL_QUIZ---` / `---END_INITIAL_QUIZ---` | Quiz data |
|
||||
| `---INITIAL_CODE_ARDUINO---` / `---END_INITIAL_CODE_ARDUINO---` | Kode awal Arduino |
|
||||
| `---VELXIO_CIRCUIT---` / `---END_VELXIO_CIRCUIT---` | Circuit JSON untuk Velxio (komponen + wires) |
|
||||
| `---EXPECTED_OUTPUT---` / `---END_EXPECTED_OUTPUT---` | Expected stdout (C/Python) atau node voltage JSON (Circuit) |
|
||||
| `---EXPECTED_CIRCUIT_OUTPUT---` / `---END_EXPECTED_CIRCUIT_OUTPUT---` | Expected circuit output (hybrid mode) |
|
||||
| `---EXPECTED_SERIAL_OUTPUT---` / `---END_EXPECTED_SERIAL_OUTPUT---` | Expected serial output (Arduino) |
|
||||
| `---EXPECTED_WIRING---` / `---END_EXPECTED_WIRING---` | Expected wiring JSON (Arduino) |
|
||||
| `---KEY_TEXT---` / `---END_KEY_TEXT---` | Keyword wajib di source code / circuit |
|
||||
| `---KEY_TEXT_CIRCUIT---` / `---END_KEY_TEXT_CIRCUIT---` | Keyword wajib di circuit (hybrid) |
|
||||
| `---SOLUTION_CODE---` / `---END_SOLUTION_CODE---` | Solusi kode (ditampilkan setelah selesai) |
|
||||
| `---SOLUTION_CIRCUIT---` / `---END_SOLUTION_CIRCUIT---` | Solusi circuit |
|
||||
|
||||
---
|
||||
|
||||
## Velxio Integration (Arduino Simulator)
|
||||
|
||||
### PostMessage Bridge Protocol
|
||||
|
||||
Komunikasi antara Elemes dan Velxio iframe via `window.postMessage`:
|
||||
|
||||
**Elemes → Velxio (Commands):**
|
||||
| Message Type | Payload | Fungsi |
|
||||
|---|---|---|
|
||||
| `elemes:load_code` | `{ files: [{name, content}] }` | Load source code ke editor |
|
||||
| `elemes:load_circuit` | `{ board, components, wires }` | Load rangkaian ke simulator |
|
||||
| `elemes:set_embed_mode` | `{ hideAuth, hideComponentPicker }` | Configure embed UI |
|
||||
| `elemes:get_source_code` | — | Request source code |
|
||||
| `elemes:get_serial_log` | — | Request serial output |
|
||||
| `elemes:get_wires` | — | Request wire topology |
|
||||
| `elemes:stop` | — | Stop simulation |
|
||||
| `elemes:ping` | — | Re-trigger ready signal |
|
||||
|
||||
**Velxio → Elemes (Events):**
|
||||
| Message Type | Payload | Fungsi |
|
||||
|---|---|---|
|
||||
| `velxio:ready` | `{ version }` | Iframe siap (broadcast tiap 300ms sampai parent acknowledge) |
|
||||
| `velxio:compile_result` | `{ success }` | Kompilasi selesai |
|
||||
| `velxio:source_code` | `{ files }` | Response ke get_source_code |
|
||||
| `velxio:serial_log` | `{ log }` | Response ke get_serial_log |
|
||||
| `velxio:wires` | `{ wires }` | Response ke get_wires |
|
||||
|
||||
### Flow Lesson Arduino
|
||||
|
||||
```
|
||||
1. User buka lesson → frontend fetch /api/lesson/<slug>.json
|
||||
2. Backend parse markdown → extract INITIAL_CODE_ARDUINO, VELXIO_CIRCUIT, dll
|
||||
3. Frontend detect active_tabs=['velxio'] → render iframe src="/velxio/editor?embed=true"
|
||||
4. Iframe load → Velxio EmbedBridge broadcast velxio:ready (tiap 300ms)
|
||||
5. Frontend initVelxioBridge() → create VelxioBridge → acknowledge → stop broadcast
|
||||
6. Bridge send: set_embed_mode, load_circuit, load_code
|
||||
7. Siswa edit kode + wiring di Velxio → klik Compile & Run
|
||||
8. Velxio notify: velxio:compile_result { success: true }
|
||||
9. Auto-evaluate setelah 3 detik (tunggu serial output)
|
||||
10. Evaluate: get_source_code → key_text, get_serial_log → serial match, get_wires → wiring match
|
||||
11. Jika semua pass → completeLesson() → celebration + track progress
|
||||
```
|
||||
|
||||
### Evaluasi Arduino (3 jenis, semua harus pass)
|
||||
|
||||
| Evaluasi | Mekanisme | Sumber Data |
|
||||
|----------|-----------|-------------|
|
||||
| **Key Text** | Keyword wajib ada di source code | `---KEY_TEXT---` di markdown |
|
||||
| **Serial Output** | Subsequence matching (expected lines muncul dalam urutan di actual) | `---EXPECTED_SERIAL_OUTPUT---` |
|
||||
| **Wiring** | Lenient graph comparison (expected edges harus ada, extra OK) | `---EXPECTED_WIRING---` (JSON array of pairs) |
|
||||
|
||||
### File-file Kunci
|
||||
|
||||
| Sisi | File | Fungsi |
|
||||
|------|------|--------|
|
||||
| Elemes | `frontend/src/lib/services/velxio-bridge.ts` | VelxioBridge class (send commands, evaluate) |
|
||||
| Elemes | `frontend/src/routes/lesson/[slug]/+page.svelte` | initVelxioBridge(), handleVelxioSubmit() |
|
||||
| Velxio | `frontend/src/services/EmbedBridge.ts` | PostMessage listener (handle commands) |
|
||||
| Velxio | `frontend/src/pages/EditorPage.tsx` | Embed mode UI control |
|
||||
| Velxio | `frontend/src/components/editor/EditorToolbar.tsx` | Embed-aware toolbar |
|
||||
|
||||
---
|
||||
|
||||
## CircuitJS Integration
|
||||
|
||||
Integrasi Falstad CircuitJS1 sebagai simulator rangkaian interaktif di dalam tab "Circuit" pada halaman lesson. CircuitJS adalah aplikasi GWT (Java → JavaScript) yang di-embed via iframe same-origin.
|
||||
Integrasi Falstad CircuitJS1 sebagai simulator rangkaian interaktif di tab "Circuit". CircuitJS adalah aplikasi GWT (Java → JavaScript) yang di-embed via iframe same-origin.
|
||||
|
||||
### Arsitektur
|
||||
|
||||
```
|
||||
content/z_test_circuit.md
|
||||
content/rangkaian_dasar.md
|
||||
│ ---INITIAL_CIRCUIT--- ... ---END_INITIAL_CIRCUIT---
|
||||
│
|
||||
▼ lesson_service.py: _extract_section()
|
||||
Flask API (/api/lesson/<slug>.json)
|
||||
Flask API (/lesson/<slug>.json)
|
||||
│ { initial_circuit, expected_output, key_text, active_tabs: ["circuit"] }
|
||||
│
|
||||
▼ +page.ts SSR loader
|
||||
|
|
@ -234,22 +387,9 @@ circuitjs1/circuitjs.html (GWT app in iframe)
|
|||
Touch event forwarding → synthetic MouseEvent dispatch
|
||||
```
|
||||
|
||||
### File-file Utama
|
||||
|
||||
| File | Fungsi |
|
||||
|------|--------|
|
||||
| `frontend/static/circuitjs1/circuitjs.html` | GWT entry point, di-load dalam iframe |
|
||||
| `frontend/static/circuitjs1/circuitjs1/circuitjs1.nocache.js` | GWT bootstrap — memilih permutation `.cache.js` berdasarkan browser |
|
||||
| `frontend/static/circuitjs1/lz-string.min.js` | Kompresi LZ untuk circuit text export |
|
||||
| `frontend/src/lib/components/CircuitEditor.svelte` | Wrapper iframe + API bridge |
|
||||
| `frontend/src/lib/components/CrosshairOverlay.svelte` | Touch precision overlay |
|
||||
| `services/lesson_service.py` | Parsing markdown, ekstraksi `---INITIAL_CIRCUIT---` |
|
||||
| `routes/lessons.py` | API endpoint, serve `initial_circuit` ke frontend |
|
||||
| `frontend/src/routes/lesson/[slug]/+page.svelte` | Evaluasi rangkaian (`evaluateCircuit()`) |
|
||||
|
||||
### CircuitJS API (via `iframe.contentWindow.CircuitJS1`)
|
||||
|
||||
API object didapatkan melalui callback `window.oncircuitjsloaded` yang dipanggil oleh GWT setelah inisialisasi, dengan fallback 3 detik via `window.CircuitJS1`.
|
||||
API object didapatkan melalui callback `window.oncircuitjsloaded`, dengan fallback 3 detik via `window.CircuitJS1`.
|
||||
|
||||
| Method | Fungsi |
|
||||
|--------|--------|
|
||||
|
|
@ -258,184 +398,169 @@ API object didapatkan melalui callback `window.oncircuitjsloaded` yang dipanggil
|
|||
| `getNodeVoltage(nodeName)` | Query tegangan di named node |
|
||||
| `setSimRunning(bool)` | Jalankan/hentikan simulasi |
|
||||
| `updateCircuit()` | Redraw setelah perubahan |
|
||||
| `elements()` | Jumlah elemen di circuit (belum dipakai) |
|
||||
| `getElm(index)` | Ambil elemen berdasarkan index (belum dipakai) |
|
||||
|
||||
### Circuit Text Format
|
||||
|
||||
CircuitJS menggunakan format XML-like custom. Contoh dari `content/z_test_circuit.md`:
|
||||
|
||||
```xml
|
||||
<cir f="1" ts="0.000005" ic="10.20027730826997" cb="50" pb="50" vr="5" mts="5e-11">
|
||||
<v x="80 200 80 112" f="0" wf="0" maxv="5"/> <!-- Voltage source 5V -->
|
||||
<r x="80 112 176 112" f="0" r="1000"/> <!-- Resistor 1kΩ -->
|
||||
<r x="176 112 176 200" f="0" r="1000"/> <!-- Resistor 1kΩ -->
|
||||
<w x="176 200 80 200" f="0"/> <!-- Wire -->
|
||||
<ln x="176 112 208 32" f="0" te="TestPoint_A"/> <!-- Named node (label) -->
|
||||
</cir>
|
||||
```
|
||||
|
||||
### Evaluasi Rangkaian
|
||||
|
||||
Saat siswa klik "Cek Rangkaian", fungsi `evaluateCircuit()` di `+page.svelte` menjalankan validasi:
|
||||
Saat siswa klik "Cek Rangkaian", fungsi `evaluateCircuit()` menjalankan:
|
||||
|
||||
| Langkah | Mekanisme | Sumber Data |
|
||||
|---------|-----------|-------------|
|
||||
| 1. Parse kriteria | `JSON.parse(data.expected_output)` | Markdown `---EXPECTED_OUTPUT---` |
|
||||
| 2. Cek tegangan node | `simApi.getNodeVoltage(nodeName)` vs expected ± tolerance | `expected_output.nodes` |
|
||||
| 3. Cek komponen wajib | `circuitEditor.getCircuitText()` → `checkKeyText()` (string contains) | Markdown `---KEY_TEXT---` |
|
||||
| 4. Track progress | `POST /api/track-progress` (jika semua passed) | Auth token |
|
||||
1. Parse kriteria dari `expected_output` (JSON)
|
||||
2. Cek tegangan node via `simApi.getNodeVoltage()` ± tolerance
|
||||
3. Cek komponen wajib via `checkKeyText()` (string contains pada circuit text)
|
||||
4. Track progress jika semua passed
|
||||
|
||||
**Expected Output JSON Format:**
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": {
|
||||
"TestPoint_A": { "voltage": 2.5, "tolerance": 0.2 }
|
||||
},
|
||||
"elements": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Lesson Markdown Format (Circuit)
|
||||
|
||||
Section-section yang dikenali oleh `lesson_service.py` untuk lesson circuit:
|
||||
|
||||
| Section | Fungsi |
|
||||
|---------|--------|
|
||||
| `---INITIAL_CIRCUIT---` ... `---END_INITIAL_CIRCUIT---` | Circuit text awal yang dimuat ke simulator |
|
||||
| `---SOLUTION_CIRCUIT---` ... `---END_SOLUTION_CIRCUIT---` | Solusi (ditampilkan setelah lesson selesai) |
|
||||
| `---EXPECTED_OUTPUT---` ... `---END_EXPECTED_OUTPUT---` | JSON kriteria evaluasi (node voltages) |
|
||||
| `---KEY_TEXT---` ... `---END_KEY_TEXT---` | Teks/komponen wajib (string matching pada circuit text) |
|
||||
| `---EXERCISE---` | Instruksi untuk siswa (di bawah separator ini) |
|
||||
|
||||
Keberadaan `---INITIAL_CIRCUIT---` secara otomatis menambahkan `'circuit'` ke `active_tabs[]`, yang menampilkan tab Circuit di halaman lesson.
|
||||
|
||||
### Auto-save
|
||||
|
||||
`CircuitEditor` mendukung auto-save ke `sessionStorage` (polling setiap 5 detik):
|
||||
|
||||
- **Key:** `elemes_circuit_{slug}` (hanya saat user login & bukan mode solusi)
|
||||
- **Restore:** Saat load, cek sessionStorage dulu, fallback ke `initialCircuit` prop
|
||||
- **Export:** `simApi.exportCircuit()` → bandingkan dengan saved → simpan jika berbeda
|
||||
|
||||
### Catatan Teknis
|
||||
|
||||
- **GWT tidak butuh build step**: File `.cache.js` sudah ter-compile. Copy as-is ke `frontend/static/`.
|
||||
- **Same-origin wajib**: `contentDocument` access membutuhkan iframe same-origin. CircuitJS di-serve dari `/circuitjs1/` path di SvelteKit static.
|
||||
- **Callback discovery**: GWT memanggil `window.oncircuitjsloaded(api)` setelah inisialisasi. Ini lebih reliable daripada polling `window.CircuitJS1` yang mungkin belum tersedia.
|
||||
- **TypeScript interface**: `CircuitJSApi` di `types/circuitjs.ts` mengetik semua method yang digunakan. `simApi` bertipe `CircuitJSApi | null`, bukan `any`.
|
||||
- **Auto-save cleanup**: `setInterval` untuk auto-save di-cleanup via `$effect` return saat komponen destroy, mencegah memory leak.
|
||||
- **Restore:** Cek sessionStorage dulu, fallback ke `initialCircuit` prop
|
||||
|
||||
---
|
||||
|
||||
## Anti Copy-Paste System
|
||||
|
||||
Sistem berlapis untuk mencegah siswa meng-copy konten pelajaran dan mem-paste kode dari sumber eksternal ke editor.
|
||||
Sistem berlapis untuk mencegah siswa meng-copy konten pelajaran dan mem-paste kode dari sumber eksternal.
|
||||
|
||||
### Selection & Copy Prevention (Halaman Lesson)
|
||||
### Selection & Copy Prevention (Konten Lesson)
|
||||
|
||||
**File:** `frontend/src/routes/lesson/[slug]/+page.svelte`
|
||||
|
||||
Mencegah siswa men-select dan meng-copy teks dari konten pelajaran (termasuk code blocks).
|
||||
**File:** `lesson/[slug]/+page.svelte`
|
||||
|
||||
| Layer | Mekanisme | Target |
|
||||
|-------|-----------|--------|
|
||||
| CSS | `user-select: none`, `-webkit-touch-callout: none` | `.lesson-content`, `.lesson-info` |
|
||||
| Events | `onselectstart`, `oncopy`, `oncut`, `oncontextmenu` → `preventDefault()` | `.lesson-content`, `.lesson-info` |
|
||||
| JS | `selectionchange` + `mouseup` + `touchend` → `getSelection().removeAllRanges()` | Fallback aktif — clear selection jika terjadi di area konten (scoped, tidak mengganggu editor) |
|
||||
| CSS | `user-select: none`, `-webkit-touch-callout: none` | `.lesson-content`, info/exercise tabs |
|
||||
| Events | `onselectstart`, `oncopy`, `oncut`, `oncontextmenu` → `preventDefault()` | Konten lesson |
|
||||
| Directive | `use:noSelect` | Reusable action |
|
||||
|
||||
### Paste Prevention (CodeEditor)
|
||||
|
||||
**File:** `frontend/src/lib/components/CodeEditor.svelte`
|
||||
**File:** `CodeEditor.svelte`
|
||||
|
||||
Mencegah siswa mem-paste kode dari sumber eksternal ke code editor. Diaktifkan via prop `noPaste={true}`.
|
||||
Diaktifkan via prop `noPaste={true}`.
|
||||
|
||||
| Layer | Mekanisme | Menangani |
|
||||
|-------|-----------|-----------|
|
||||
| 1 | `EditorView.domEventHandlers` — `paste`, `drop`, `beforeinput` → `preventDefault()` | Desktop paste, iOS paste |
|
||||
| A | `EditorState.transactionFilter` — block `input.paste` + heuristik ukuran (>2 baris atau >20 chars untuk 2 baris) | Standard paste + **GBoard clipboard panel** (paste via IME yang menyamar sebagai `input.type.compose`) |
|
||||
| C | `EditorView.clipboardInputFilter` — replace clipboard text → `''` (runtime check) | Standard paste (jika API tersedia) |
|
||||
| D | `EditorView.inputHandler` — block multi-line insertion >20 chars | GBoard clipboard via DOM mutations |
|
||||
| 2 | DOM capture-phase listeners — `paste`, `copy`, `cut`, `contextmenu`, `drop` → `preventDefault()` | Backup DOM-level |
|
||||
| B | `input` event listener — `CM.undo()` jika `insertFromPaste` | Fallback post-hoc revert |
|
||||
| DOM handlers | `paste`, `drop`, `beforeinput` → `preventDefault()` | Desktop paste, iOS paste |
|
||||
| Transaction filter | Block `input.paste` + heuristik ukuran (>2 baris atau >20 chars) | Standard paste + GBoard clipboard panel |
|
||||
| Clipboard filter | Replace clipboard text → `''` | Standard paste (jika API tersedia) |
|
||||
| Input handler | Block multi-line insertion >20 chars | GBoard clipboard via DOM mutations |
|
||||
|
||||
**Limitasi:** GBoard clipboard panel menyuntikkan teks lewat IME composition system (bukan clipboard API), sehingga tidak bisa dibedakan 100% dari ketikan biasa. Heuristik ukuran teks digunakan untuk mendeteksi dan memblokir mayoritas kasus paste, namun paste 1 baris pendek (<20 chars) masih bisa lolos.
|
||||
**Limitasi:** GBoard clipboard panel menggunakan IME composition, tidak bisa dibedakan 100% dari ketikan biasa. Paste 1 baris pendek (<20 chars) masih bisa lolos.
|
||||
|
||||
---
|
||||
|
||||
## Touch Crosshair System (Fat Finger Fix)
|
||||
## Touch Crosshair System (CircuitJS)
|
||||
|
||||
Sistem overlay untuk memberikan presisi interaksi di iframe CircuitJS pada perangkat sentuh. Aktif hanya pada touch device (deteksi via CSS media query `hover: none` + `pointer: coarse`), tidak mengganggu interaksi mouse di desktop.
|
||||
Overlay untuk presisi interaksi di iframe CircuitJS pada perangkat sentuh. Aktif hanya pada touch device (CSS media query `hover: none` + `pointer: coarse`).
|
||||
|
||||
**File:** `frontend/src/lib/components/CrosshairOverlay.svelte`
|
||||
**Dimount di:** `frontend/src/lib/components/CircuitEditor.svelte`
|
||||
**File:** `CrosshairOverlay.svelte` (dimount di `CircuitEditor.svelte`)
|
||||
|
||||
### Gesture Mapping
|
||||
|
||||
| Gesture | Action | Keterangan |
|
||||
|---------|--------|------------|
|
||||
| Single tap | `click` | Delay 300ms (menunggu double/triple). Termasuk toolbar CircuitJS |
|
||||
| Double tap | `dblclick` | Edit komponen (buka dialog edit di CircuitJS) |
|
||||
| Triple tap | Right-click (`contextmenu`) | Fallback untuk right-click satu jari |
|
||||
| Two-finger tap | Right-click (`contextmenu`) | Gesture natural untuk right-click |
|
||||
| Long tap (400ms) | Crosshair aiming mode | 4-phase state machine untuk presisi drag |
|
||||
| Gesture | Action |
|
||||
|---------|--------|
|
||||
| Single tap | `click` (delay 300ms) |
|
||||
| Double tap | `dblclick` (edit komponen) |
|
||||
| Triple tap | Right-click (`contextmenu`) |
|
||||
| Two-finger tap | Right-click |
|
||||
| Long tap (400ms) | Crosshair aiming mode (4-phase state machine) |
|
||||
|
||||
### State Machine (Crosshair Aiming)
|
||||
### Cara Kerja
|
||||
|
||||
```
|
||||
idle → [long tap] → aiming_start (crosshair muncul, belum click)
|
||||
│
|
||||
[release] → holding (mousedown dispatch di posisi crosshair)
|
||||
│ │
|
||||
[long tap] → aiming_end [5s timeout]
|
||||
│ │ │
|
||||
│ [release] ▼
|
||||
│ │ mouseup + idle
|
||||
│ ▼
|
||||
│ idle (mouseup dispatch)
|
||||
│
|
||||
[short tap] → mouseup + idle
|
||||
```
|
||||
|
||||
### Cara Kerja Event Forwarding
|
||||
|
||||
| Layer | Mekanisme | Tujuan |
|
||||
|-------|-----------|--------|
|
||||
| Overlay | `<div>` transparan dengan `pointer-events: auto` (touch only) | Intercept semua touch event sebelum iframe |
|
||||
| Koordinat | `getBoundingClientRect()` → konversi viewport ke iframe-local | Akurasi posisi di dalam iframe |
|
||||
| Target | `iframe.contentDocument.elementFromPoint(x, y)` | Temukan elemen yang tepat (canvas, toolbar, dialog) |
|
||||
| Dispatch | `new MouseEvent()` dengan `view: iframe.contentWindow` | GWT CircuitJS menerima event seolah native |
|
||||
| Focus | `focusIfEditable()` → `.focus()` pada input/textarea | Virtual keyboard muncul saat tap text field |
|
||||
Overlay transparan mengintercept touch events → konversi koordinat ke iframe-local → `elementFromPoint()` untuk target → dispatch synthetic `MouseEvent` ke iframe.
|
||||
|
||||
### Konfigurasi
|
||||
|
||||
| Variable | Default | Lokasi | Fungsi |
|
||||
|----------|---------|--------|--------|
|
||||
| `PUBLIC_CURSOR_OFFSET_Y` | `50` | `podman-compose.yml` | Offset Y crosshair dari posisi jari (pixel). Semakin besar, crosshair semakin jauh di atas jari |
|
||||
| Variable | Default | Fungsi |
|
||||
|----------|---------|--------|
|
||||
| `PUBLIC_CURSOR_OFFSET_Y` | `50` | Offset Y crosshair dari jari (pixel) |
|
||||
|
||||
### Konstanta Internal
|
||||
---
|
||||
|
||||
| Nama | Nilai | Fungsi |
|
||||
|------|-------|--------|
|
||||
| `LONG_PRESS_MS` | 400ms | Durasi tahan untuk aktifkan crosshair |
|
||||
| `DOUBLE_TAP_MS` | 300ms | Window waktu antara tap untuk deteksi double/triple |
|
||||
| `HOLDING_TIMEOUT_MS` | 5000ms | Safety net — auto-mouseup jika terjebak di holding state |
|
||||
## Velxio Mobile Wiring Crosshair
|
||||
|
||||
### Limitasi
|
||||
Berbeda dari CircuitJS crosshair (yang menggunakan overlay), Velxio crosshair ada di dalam simulator canvas sendiri (SVG).
|
||||
|
||||
- **Single tap delay 300ms**: Trade-off untuk membedakan single/double/triple tap. Tidak bisa dihindari tanpa mengorbankan multi-tap detection.
|
||||
- **Synthetic focus**: Virtual keyboard mungkin tidak muncul di semua browser karena `.focus()` pada elemen di dalam iframe tidak selalu dianggap "user gesture" oleh browser.
|
||||
- **Same-origin only**: `contentDocument` access membutuhkan iframe same-origin. CircuitJS di-serve dari path yang sama (`/circuitjs1/`), jadi ini bukan masalah.
|
||||
**File:** `velxio/frontend/src/components/simulator/WireInProgressRenderer.tsx`
|
||||
|
||||
Saat user sedang menarik wire, garis bantu horizontal + vertikal muncul di posisi cursor:
|
||||
- Style: dashed `rgba(255,255,255,0.25)`, `strokeWidth=0.5`, `strokeDasharray="8,6"`
|
||||
- Panjang: 8000px ke setiap arah (cukup untuk semua zoom level)
|
||||
- Terinspirasi CircuitJS — membantu alignment karena jari menutupi pin target
|
||||
|
||||
### Pinch-Zoom Preserve Wire
|
||||
|
||||
**File:** `velxio/frontend/src/components/simulator/SimulatorCanvas.tsx`
|
||||
|
||||
Sebelumnya, pinch-to-zoom (2 jari) otomatis cancel wire yang sedang ditarik. Sekarang wire-in-progress tetap aktif selama zoom. Preview freeze (hanya update saat 1 jari), lalu resume setelah zoom selesai.
|
||||
|
||||
---
|
||||
|
||||
## Wire Undo/Redo
|
||||
|
||||
Snapshot-based undo/redo untuk operasi wire di Velxio simulator. Memungkinkan user membatalkan kesalahan wiring tanpa harus memilih dan menghapus wire secara manual.
|
||||
|
||||
### Arsitektur
|
||||
|
||||
```
|
||||
User action (add/remove/update/finish wire)
|
||||
│
|
||||
├── Push current state.wires → wireUndoStack (max 50)
|
||||
├── Clear wireRedoStack
|
||||
└── Mutate state.wires
|
||||
|
||||
Undo (Ctrl+Z / toolbar button)
|
||||
│
|
||||
├── Pop wireUndoStack → restore as state.wires
|
||||
├── Push current state.wires → wireRedoStack
|
||||
└── Reset selectedWireId = null
|
||||
|
||||
Redo (Ctrl+Shift+Z / toolbar button)
|
||||
│
|
||||
├── Pop wireRedoStack → restore as state.wires
|
||||
├── Push current state.wires → wireUndoStack
|
||||
└── Reset selectedWireId = null
|
||||
```
|
||||
|
||||
### File yang Terlibat
|
||||
|
||||
| File | Perubahan |
|
||||
|------|-----------|
|
||||
| `velxio/frontend/src/store/useSimulatorStore.ts` | State: `wireUndoStack`, `wireRedoStack`. Actions: `undoWire()`, `redoWire()`. Snapshot push di `addWire`, `removeWire`, `updateWire`, `finishWireCreation`. |
|
||||
| `velxio/frontend/src/components/simulator/SimulatorCanvas.tsx` | Keyboard handler (`Ctrl+Z`/`Ctrl+Shift+Z`), toolbar buttons (undo/redo icons), store selectors (`canUndoWire`, `canRedoWire`). |
|
||||
| `velxio/frontend/src/components/simulator/SimulatorCanvas.css` | Class `.undo-controls` — styling identik dengan `.zoom-controls` tapi **tidak di-hide** di mobile media query. |
|
||||
|
||||
### Keputusan Desain
|
||||
|
||||
1. **Snapshot-based vs action-based** — Dipilih snapshot (simpan seluruh `wires[]` per step) karena lebih sederhana dan reliable. Trade-off: memory lebih besar, tapi dengan limit 50 entries dan array wire yang kecil, tidak masalah.
|
||||
2. **Separate CSS class** — `.undo-controls` terpisah dari `.zoom-controls` karena zoom di-hide di mobile (`display: none` — pakai pinch-to-zoom), tapi undo/redo harus tetap visible.
|
||||
3. **`setWires()` tidak push snapshot** — `setWires()` digunakan oleh `EmbedBridge` saat load circuit dari LMS. Ini bukan user action, jadi tidak masuk undo history.
|
||||
4. **`selectedWireId: null` saat undo/redo** — Mencegah bug dimana UI mencoba menampilkan info wire yang sudah tidak ada setelah undo.
|
||||
|
||||
---
|
||||
|
||||
## Status Implementasi
|
||||
|
||||
- [x] **Phase 0:** Backend decomposition (monolith → Blueprints + services)
|
||||
- [x] **Phase 1:** SvelteKit scaffolding (adapter-node, TypeScript, path aliases)
|
||||
- [x] **Phase 2:** Core components (CodeEditor, Navbar, auth/theme stores, API client)
|
||||
- [x] **Phase 3:** Pages (Home, Lesson, Progress) + SSR data loading
|
||||
- [x] **Phase 5:** Containerization (3-container setup, static IPs, API proxy)
|
||||
- [ ] **Phase 4:** PWA (service worker, offline caching, icons)
|
||||
- [ ] **Phase 6:** Polish (Tailscale config update, testing)
|
||||
- [x] Backend decomposition (monolith → Blueprints + services)
|
||||
- [x] SvelteKit scaffolding (adapter-node, Svelte 5, path aliases)
|
||||
- [x] Core components (CodeEditor, Navbar, auth/theme stores, API client)
|
||||
- [x] Pages (Home, Lesson, Progress) + SSR data loading
|
||||
- [x] Containerization (4-container setup, API proxy)
|
||||
- [x] CircuitJS integration (iframe, evaluasi node voltage, touch overlay)
|
||||
- [x] Floating/mobile editor panel (draggable, resizable, bottom sheet)
|
||||
- [x] Anti copy-paste system (lesson content + code editor)
|
||||
- [x] Velxio fork + EmbedBridge (PostMessage protocol)
|
||||
- [x] Velxio integration di Elemes (bridge, parsing, UI, evaluasi)
|
||||
- [x] Mobile wiring UX (pinch-zoom preserve wire, crosshair alignment)
|
||||
- [x] Wire undo/redo (snapshot-based, Ctrl+Z/Ctrl+Shift+Z, toolbar button, mobile-friendly)
|
||||
- [x] Contoh lesson Arduino (LED Blink)
|
||||
- [ ] PWA (service worker, offline caching)
|
||||
- [ ] Contoh lesson Arduino tambahan (2-3 lagi)
|
||||
- [ ] Velxio enhancements (lock komponen, solution overlay, multi-board)
|
||||
|
|
|
|||
25
elemes.sh
25
elemes.sh
|
|
@ -45,23 +45,34 @@ init)
|
|||
echo " 3. Edit ../tokens_siswa.csv untuk data siswa"
|
||||
echo " 4. Jalankan: ./elemes.sh runbuild"
|
||||
;;
|
||||
stop | run | runbuild)
|
||||
stop | run | runbuild | runclearbuild)
|
||||
echo "Stop Container..."
|
||||
podman-compose -p "$PROJECT_NAME" --env-file ../.env down
|
||||
;;&
|
||||
runbuild)
|
||||
echo "Build and Run Container..."
|
||||
podman-compose -p "$PROJECT_NAME" --env-file ../.env up --build --force-recreate -d
|
||||
stop) ;;
|
||||
runclearbuild)
|
||||
echo "Cleanup dangling images..."
|
||||
podman image prune -f
|
||||
echo "Build Container (no cache)..."
|
||||
podman-compose -p "$PROJECT_NAME" --env-file ../.env build --no-cache
|
||||
;;&
|
||||
runbuild)
|
||||
echo "Build Container..."
|
||||
podman-compose -p "$PROJECT_NAME" --env-file ../.env build
|
||||
;;&
|
||||
runbuild | runclearbuild)
|
||||
echo "Run Container..."
|
||||
podman-compose -p "$PROJECT_NAME" --env-file ../.env up --force-recreate -d
|
||||
;;
|
||||
run)
|
||||
echo "Run Container..."
|
||||
podman-compose -p "$PROJECT_NAME" --env-file ../.env up -d
|
||||
;;&
|
||||
;;
|
||||
generatetoken)
|
||||
echo "Generating tokens_siswa.csv from content..."
|
||||
python3 "$SCRIPT_DIR/generate_tokens.py"
|
||||
;;&
|
||||
;;
|
||||
*)
|
||||
echo "elemes.sh ( init | run | runbuild | stop | generatetoken )"
|
||||
echo "elemes.sh ( init | run | runbuild | runclearbuild | stop | generatetoken )"
|
||||
;;
|
||||
esac
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
type TabType = 'info' | 'exercise' | 'editor' | 'circuit' | 'output';
|
||||
type TabType = 'info' | 'exercise' | 'editor' | 'circuit' | 'output' | 'velxio';
|
||||
|
||||
interface Props {
|
||||
isMobile: boolean;
|
||||
|
|
@ -37,8 +37,9 @@
|
|||
|
||||
const hasC = $derived(activeTabs?.includes('c') ?? false);
|
||||
const hasPython = $derived(activeTabs?.includes('python') ?? false);
|
||||
const hasVelxio = $derived(activeTabs?.includes('velxio') ?? false);
|
||||
const hasCodeEditor = $derived(
|
||||
!activeTabs || activeTabs.length === 0 || hasC || hasPython
|
||||
!hasVelxio && (!activeTabs || activeTabs.length === 0 || hasC || hasPython)
|
||||
);
|
||||
const hasMultiLang = $derived(hasC && hasPython);
|
||||
const hasCircuit = $derived(activeTabs?.includes('circuit') ?? false);
|
||||
|
|
@ -85,6 +86,9 @@
|
|||
{#if hasCircuit}
|
||||
<button class="chrome-tab" class:active={activeTab === 'circuit'} onclick={() => (activeTab = 'circuit')}>Circuit</button>
|
||||
{/if}
|
||||
{#if hasVelxio}
|
||||
<button class="chrome-tab" class:active={activeTab === 'velxio'} onclick={() => (activeTab = 'velxio')}>Arduino</button>
|
||||
{/if}
|
||||
<button class="chrome-tab" class:active={activeTab === 'output'} onclick={() => (activeTab = 'output')}>Output</button>
|
||||
{/snippet}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,237 @@
|
|||
/**
|
||||
* PostMessage bridge for communicating with Velxio iframe (Arduino simulator).
|
||||
*
|
||||
* Elemes sends commands (load code, load circuit, get state) and Velxio
|
||||
* responds with events. Evaluation (serial, key_text, wiring) runs on the
|
||||
* Elemes side using data received from Velxio.
|
||||
*/
|
||||
|
||||
import { checkKeyText } from './exercise';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface VelxioFile {
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface VelxioWire {
|
||||
start: { componentId: string; pinName: string };
|
||||
end: { componentId: string; pinName: string };
|
||||
}
|
||||
|
||||
interface EvaluationExpected {
|
||||
key_text?: string;
|
||||
serial_output?: string;
|
||||
wiring?: [string, string][];
|
||||
}
|
||||
|
||||
export interface EvaluationResult {
|
||||
pass: boolean;
|
||||
key_text?: boolean;
|
||||
serial?: boolean;
|
||||
wiring?: boolean;
|
||||
messages: string[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bridge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const READY_TIMEOUT_MS = 30_000;
|
||||
const REQUEST_TIMEOUT_MS = 5_000;
|
||||
|
||||
export class VelxioBridge {
|
||||
private iframe: HTMLIFrameElement;
|
||||
private pending: Record<string, (data: any) => void> = {};
|
||||
private onReadyCallback: (() => void) | null = null;
|
||||
private boundOnMessage: (e: MessageEvent) => void;
|
||||
|
||||
constructor(iframe: HTMLIFrameElement) {
|
||||
this.iframe = iframe;
|
||||
this.boundOnMessage = this.onMessage.bind(this);
|
||||
window.addEventListener('message', this.boundOnMessage);
|
||||
}
|
||||
|
||||
// === Lifecycle ===
|
||||
|
||||
/** Register a callback for when Velxio iframe reports ready. */
|
||||
onReady(callback: () => void) {
|
||||
this.onReadyCallback = callback;
|
||||
}
|
||||
|
||||
/** Wait for velxio:ready with timeout. Returns false if timed out. */
|
||||
waitForReady(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => resolve(false), READY_TIMEOUT_MS);
|
||||
this.onReady(() => {
|
||||
clearTimeout(timer);
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
window.removeEventListener('message', this.boundOnMessage);
|
||||
this.pending = {};
|
||||
}
|
||||
|
||||
// === Commands (Elemes → Velxio) ===
|
||||
|
||||
loadCode(files: VelxioFile[]) {
|
||||
this.send('elemes:load_code', { files });
|
||||
}
|
||||
|
||||
loadCircuit(circuitJson: string) {
|
||||
try {
|
||||
const data = JSON.parse(circuitJson);
|
||||
this.send('elemes:load_circuit', data);
|
||||
} catch {
|
||||
console.error('VelxioBridge: invalid circuit JSON');
|
||||
}
|
||||
}
|
||||
|
||||
setEmbedMode(options: { hideEditor?: boolean; hideAuth?: boolean; hideComponentPicker?: boolean }) {
|
||||
this.send('elemes:set_embed_mode', options);
|
||||
}
|
||||
|
||||
// === Evaluation ===
|
||||
|
||||
async evaluate(expected: EvaluationExpected): Promise<EvaluationResult> {
|
||||
const result: EvaluationResult = { pass: false, messages: [] };
|
||||
const dbg: string[] = [];
|
||||
|
||||
// 1. Key text check
|
||||
if (expected.key_text) {
|
||||
const resp = await this.request('elemes:get_source_code', 'velxio:source_code');
|
||||
if (resp) {
|
||||
const allCode = (resp.files as VelxioFile[]).map(f => f.content).join('\n');
|
||||
result.key_text = checkKeyText(allCode, expected.key_text);
|
||||
result.messages.push(result.key_text
|
||||
? '✅ Kata kunci ditemukan dalam kode'
|
||||
: '❌ Kata kunci yang dibutuhkan belum ada dalam kode');
|
||||
dbg.push(`[DBG key_text] keys="${expected.key_text.replace(/\n/g, ', ')}" → ${result.key_text}`);
|
||||
} else {
|
||||
result.key_text = false;
|
||||
result.messages.push('❌ Gagal mengambil source code dari simulator');
|
||||
dbg.push('[DBG key_text] resp=null (timeout)');
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Serial output
|
||||
if (expected.serial_output) {
|
||||
const resp = await this.request('elemes:get_serial_log', 'velxio:serial_log');
|
||||
if (resp) {
|
||||
const actualLog = resp.log as string;
|
||||
result.serial = this.matchSerial(actualLog, expected.serial_output);
|
||||
result.messages.push(result.serial
|
||||
? '✅ Serial output sesuai'
|
||||
: '❌ Serial output belum sesuai dengan yang diharapkan');
|
||||
dbg.push(`[DBG serial] actual(${actualLog.length} chars)="${actualLog.substring(0, 120).replace(/\n/g, '↵')}" → ${result.serial}`);
|
||||
} else {
|
||||
result.serial = false;
|
||||
result.messages.push('❌ Gagal mengambil serial log. Pastikan program sudah di-Run terlebih dahulu');
|
||||
dbg.push('[DBG serial] resp=null (timeout)');
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Wiring
|
||||
if (expected.wiring) {
|
||||
const resp = await this.request('elemes:get_wires', 'velxio:wires');
|
||||
if (resp) {
|
||||
const studentWires = resp.wires as VelxioWire[];
|
||||
const edges = studentWires.map(w =>
|
||||
`${w.start.componentId}:${w.start.pinName}↔${w.end.componentId}:${w.end.pinName}`
|
||||
);
|
||||
result.wiring = this.matchWiring(studentWires, expected.wiring);
|
||||
result.messages.push(result.wiring
|
||||
? '✅ Rangkaian wiring benar'
|
||||
: '❌ Wiring belum sesuai. Periksa kembali koneksi komponen');
|
||||
dbg.push(`[DBG wiring] ${studentWires.length} wires: ${edges.join(' | ')}`);
|
||||
dbg.push(`[DBG wiring] expected: ${expected.wiring.map(p => p.join('↔')).join(' | ')}`);
|
||||
dbg.push(`[DBG wiring] → ${result.wiring}`);
|
||||
} else {
|
||||
result.wiring = false;
|
||||
result.messages.push('❌ Gagal mengambil data wiring dari simulator');
|
||||
dbg.push('[DBG wiring] resp=null (timeout)');
|
||||
}
|
||||
}
|
||||
|
||||
// Overall pass: all checked criteria must be true
|
||||
const checks = [result.key_text, result.serial, result.wiring].filter(v => v !== undefined);
|
||||
result.pass = checks.length > 0 && checks.every(Boolean);
|
||||
|
||||
// Append debug info to messages so it's visible in the output panel
|
||||
result.messages.push('', '── Debug ──', ...dbg);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// === Internal ===
|
||||
|
||||
private send(type: string, payload: Record<string, any> = {}) {
|
||||
this.iframe.contentWindow?.postMessage({ type, ...payload }, '*');
|
||||
}
|
||||
|
||||
private request(sendType: string, expectType: string): Promise<any | null> {
|
||||
return new Promise((resolve) => {
|
||||
this.pending[expectType] = resolve;
|
||||
this.send(sendType);
|
||||
setTimeout(() => {
|
||||
if (this.pending[expectType]) {
|
||||
delete this.pending[expectType];
|
||||
resolve(null);
|
||||
}
|
||||
}, REQUEST_TIMEOUT_MS);
|
||||
});
|
||||
}
|
||||
|
||||
private onMessage(event: MessageEvent) {
|
||||
const { type } = event.data || {};
|
||||
if (!type?.startsWith('velxio:')) return;
|
||||
|
||||
if (type === 'velxio:ready' && this.onReadyCallback) {
|
||||
this.onReadyCallback();
|
||||
}
|
||||
|
||||
if (this.pending[type]) {
|
||||
this.pending[type](event.data);
|
||||
delete this.pending[type];
|
||||
}
|
||||
}
|
||||
|
||||
/** Subsequence match: expected lines must appear in order within actual. */
|
||||
private matchSerial(actual: string, expected: string): boolean {
|
||||
const actualLines = actual.trim().split('\n').map(l => l.trim());
|
||||
const expectedLines = expected.trim().split('\n').map(l => l.trim());
|
||||
let j = 0;
|
||||
for (const line of actualLines) {
|
||||
if (j < expectedLines.length && line === expectedLines[j]) j++;
|
||||
if (j === expectedLines.length) return true;
|
||||
}
|
||||
return j === expectedLines.length;
|
||||
}
|
||||
|
||||
/** Lenient wiring check: all expected edges must exist in student wires. */
|
||||
private matchWiring(studentWires: VelxioWire[], expectedPairs: [string, string][]): boolean {
|
||||
// Normalize power pin names (e.g., GND.2 → GND, VCC.1 → VCC)
|
||||
const normalizePin = (pin: string) => pin.replace(/^(GND|VCC|5V|3V3|3\.3V)\.\d+$/i, '$1');
|
||||
|
||||
const norm = (a: string, b: string) => {
|
||||
const normA = a.replace(/:(.+)$/, (_, pin) => ':' + normalizePin(pin));
|
||||
const normB = b.replace(/:(.+)$/, (_, pin) => ':' + normalizePin(pin));
|
||||
return [normA, normB].sort().join('↔');
|
||||
};
|
||||
const studentEdges = new Set(
|
||||
studentWires.map(w =>
|
||||
norm(
|
||||
`${w.start.componentId}:${w.start.pinName}`,
|
||||
`${w.end.componentId}:${w.end.pinName}`
|
||||
)
|
||||
)
|
||||
);
|
||||
return expectedPairs.every(([a, b]) => studentEdges.has(norm(a, b)));
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,10 @@ export interface LessonContent {
|
|||
initial_python: string;
|
||||
initial_circuit: string;
|
||||
initial_quiz: string;
|
||||
initial_code_arduino: string;
|
||||
velxio_circuit: string;
|
||||
expected_serial_output: string;
|
||||
expected_wiring: string;
|
||||
solution_code: string;
|
||||
key_text: string;
|
||||
lesson_title: string;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
import LessonList from '$components/LessonList.svelte';
|
||||
import { compileCode, trackProgress } from '$services/api';
|
||||
import { checkKeyText, validateNodes } from '$services/exercise';
|
||||
import { VelxioBridge, type EvaluationResult } from '$services/velxio-bridge';
|
||||
import { auth, authLoggedIn } from '$stores/auth';
|
||||
import { lessonContext } from '$stores/lessonContext';
|
||||
import { noSelect } from '$actions/noSelect';
|
||||
|
|
@ -46,6 +47,15 @@
|
|||
let codePassed = $state(false);
|
||||
let circuitPassed = $state(false);
|
||||
|
||||
// Velxio (Arduino simulator) state
|
||||
let isVelxio = $derived(data?.active_tabs?.includes('velxio') ?? false);
|
||||
let velxioBridge = $state<VelxioBridge | null>(null);
|
||||
let velxioReady = $state(false);
|
||||
let velxioError = $state(false);
|
||||
let velxioIframe = $state<HTMLIFrameElement | null>(null);
|
||||
let velxioOut = $state(freshOutput());
|
||||
let hasArduinoCode = $derived(!!data?.initial_code_arduino);
|
||||
|
||||
// Derived: is this a hybrid lesson (has both code and circuit)?
|
||||
let isHybrid = $derived(
|
||||
(data?.active_tabs?.includes('c') || data?.active_tabs?.includes('python')) &&
|
||||
|
|
@ -68,12 +78,15 @@
|
|||
if (tabs.includes('circuit')) {
|
||||
secs.push({ key: 'circuit', label: 'Circuit', icon: '\u26A1', data: circuitOut, placeholder: 'Klik "Cek Rangkaian" untuk mengevaluasi', loadingText: 'Mengevaluasi rangkaian...' });
|
||||
}
|
||||
if (tabs.includes('velxio')) {
|
||||
secs.push({ key: 'velxio', label: 'Arduino', icon: '\u{1F4DF}', data: velxioOut, placeholder: 'Klik "Submit" untuk mengevaluasi', loadingText: 'Mengevaluasi...' });
|
||||
}
|
||||
return secs;
|
||||
});
|
||||
|
||||
// UI state
|
||||
let showSolution = $state(false);
|
||||
let activeTab = $state<'info' | 'exercise' | 'editor' | 'circuit' | 'output'>('info');
|
||||
let activeTab = $state<'info' | 'exercise' | 'editor' | 'circuit' | 'output' | 'velxio'>('info');
|
||||
|
||||
let editor = $state<CodeEditor | null>(null);
|
||||
let circuitEditor = $state<CircuitEditor | null>(null);
|
||||
|
|
@ -129,11 +142,19 @@
|
|||
cOut = freshOutput();
|
||||
pyOut = freshOutput();
|
||||
circuitOut = freshOutput();
|
||||
velxioOut = freshOutput();
|
||||
codePassed = false;
|
||||
circuitPassed = false;
|
||||
showSolution = false;
|
||||
|
||||
// Cleanup previous Velxio bridge
|
||||
if (velxioBridge) { velxioBridge.destroy(); velxioBridge = null; }
|
||||
velxioReady = false;
|
||||
velxioError = false;
|
||||
const isVelxioLesson = lesson.active_tabs?.includes('velxio');
|
||||
if (lesson.lesson_info) activeTab = 'info';
|
||||
else if (lesson.exercise_content) activeTab = 'exercise';
|
||||
else if (isVelxioLesson) activeTab = 'velxio' as any;
|
||||
else if (lesson.active_tabs?.includes('circuit') && !hasC && !hasPython) activeTab = 'circuit';
|
||||
else activeTab = 'editor';
|
||||
mobileMode = 'half';
|
||||
|
|
@ -163,9 +184,10 @@
|
|||
prevLanguage = currentLanguage;
|
||||
});
|
||||
|
||||
// Clear lesson context when leaving page
|
||||
// Clear lesson context and Velxio bridge when leaving page
|
||||
beforeNavigate(() => {
|
||||
lessonContext.set(null);
|
||||
if (velxioBridge) { velxioBridge.destroy(); velxioBridge = null; }
|
||||
});
|
||||
|
||||
// Apply syntax highlighting + circuit embeds after content renders
|
||||
|
|
@ -338,6 +360,234 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Velxio (Arduino simulator) ===
|
||||
|
||||
/** Try to directly read Velxio Zustand stores from iframe (same-origin). */
|
||||
function getVelxioStores(iframe: HTMLIFrameElement): { editor: any; simulator: any } | null {
|
||||
try {
|
||||
const win = iframe.contentWindow as any;
|
||||
if (!win) return null;
|
||||
// Zustand stores expose getState() on the hook; we look for the global store refs
|
||||
// that Velxio's EmbedBridge.ts imports. As a fallback, walk __ZUSTAND__ if available.
|
||||
const editorState = win.__VELXIO_EDITOR_STORE__?.getState?.();
|
||||
const simState = win.__VELXIO_SIMULATOR_STORE__?.getState?.();
|
||||
if (editorState && simState) return { editor: editorState, simulator: simState };
|
||||
return null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function initVelxioBridge(iframe: HTMLIFrameElement) {
|
||||
velxioIframe = iframe;
|
||||
|
||||
let settled = false;
|
||||
const onMessage = (e: MessageEvent) => {
|
||||
const type = e.data?.type;
|
||||
if (!type) return;
|
||||
|
||||
if (!settled && type === 'velxio:ready') {
|
||||
settled = true;
|
||||
velxioBridge = new VelxioBridge(iframe);
|
||||
velxioReady = true;
|
||||
if (!data) return;
|
||||
velxioBridge.setEmbedMode({ hideAuth: true, hideComponentPicker: true });
|
||||
if (data.velxio_circuit) velxioBridge.loadCircuit(data.velxio_circuit);
|
||||
if (data.initial_code_arduino) {
|
||||
velxioBridge.loadCode([{ name: 'sketch.ino', content: data.initial_code_arduino }]);
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'velxio:compile_result' && e.data.success) {
|
||||
setTimeout(() => handleVelxioSubmit(), 5000);
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', onMessage);
|
||||
|
||||
// Fallback: if PostMessage bridge never connects, try direct iframe access (same-origin)
|
||||
// and also use it to send initial data via postMessage directly
|
||||
const pollReady = setInterval(() => {
|
||||
if (settled) { clearInterval(pollReady); return; }
|
||||
try {
|
||||
const win = iframe.contentWindow as any;
|
||||
if (!win || !win.document) return;
|
||||
// Check if React app loaded by looking for the root element with content
|
||||
const root = win.document.getElementById('root');
|
||||
if (!root || !root.children.length) return;
|
||||
|
||||
// Iframe loaded — mark as ready even without velxio:ready message
|
||||
settled = true;
|
||||
clearInterval(pollReady);
|
||||
velxioReady = true;
|
||||
|
||||
// Try sending commands directly via postMessage (same-origin, should work)
|
||||
if (data) {
|
||||
win.postMessage({ type: 'elemes:set_embed_mode', hideAuth: true, hideComponentPicker: true }, '*');
|
||||
if (data.velxio_circuit) {
|
||||
try {
|
||||
const circuitData = JSON.parse(data.velxio_circuit);
|
||||
win.postMessage({ type: 'elemes:load_circuit', ...circuitData }, '*');
|
||||
} catch {}
|
||||
}
|
||||
if (data.initial_code_arduino) {
|
||||
win.postMessage({ type: 'elemes:load_code', files: [{ name: 'sketch.ino', content: data.initial_code_arduino }] }, '*');
|
||||
}
|
||||
}
|
||||
} catch { /* cross-origin or not ready yet */ }
|
||||
}, 1000);
|
||||
|
||||
setTimeout(() => {
|
||||
clearInterval(pollReady);
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
window.removeEventListener('message', onMessage);
|
||||
// Don't show error — the Submit button + direct access still works
|
||||
// velxioError = true;
|
||||
}, 30_000);
|
||||
}
|
||||
|
||||
async function handleVelxioSubmit() {
|
||||
if (!data) return;
|
||||
|
||||
Object.assign(velxioOut, { loading: true, output: 'Mengevaluasi...', error: '', success: null });
|
||||
activeTab = 'output';
|
||||
|
||||
try {
|
||||
// === Gather data: try PostMessage bridge first, fall back to direct iframe access ===
|
||||
let sourceCode = '';
|
||||
let serialLog = '';
|
||||
let wireList: { start: { componentId: string; pinName: string }; end: { componentId: string; pinName: string } }[] = [];
|
||||
const dbg: string[] = [];
|
||||
|
||||
if (velxioBridge) {
|
||||
// Try bridge (PostMessage)
|
||||
dbg.push('[metode: PostMessage bridge]');
|
||||
const srcResp = await velxioBridge['request']('elemes:get_source_code', 'velxio:source_code');
|
||||
if (srcResp) sourceCode = (srcResp.files as any[]).map((f: any) => f.content).join('\n');
|
||||
else dbg.push('[!] get_source_code timeout');
|
||||
|
||||
const serResp = await velxioBridge['request']('elemes:get_serial_log', 'velxio:serial_log');
|
||||
if (serResp) serialLog = serResp.log as string;
|
||||
else dbg.push('[!] get_serial_log timeout');
|
||||
|
||||
const wireResp = await velxioBridge['request']('elemes:get_wires', 'velxio:wires');
|
||||
if (wireResp) wireList = wireResp.wires as any[];
|
||||
else dbg.push('[!] get_wires timeout');
|
||||
}
|
||||
|
||||
// Fallback: direct iframe store access (same-origin)
|
||||
if (!sourceCode && velxioIframe) {
|
||||
dbg.push('[fallback: direct iframe access]');
|
||||
try {
|
||||
const win = velxioIframe.contentWindow as any;
|
||||
// Access Zustand stores via window globals (we'll expose them)
|
||||
// Or try to find the stores on the module scope
|
||||
const editorStore = win.__VELXIO_EDITOR_STORE__?.getState?.();
|
||||
const simStore = win.__VELXIO_SIMULATOR_STORE__?.getState?.();
|
||||
|
||||
if (editorStore?.files) {
|
||||
sourceCode = editorStore.files.map((f: any) => f.content).join('\n');
|
||||
dbg.push(`[direct] source: ${sourceCode.length} chars`);
|
||||
} else {
|
||||
dbg.push('[direct] editor store not found');
|
||||
}
|
||||
|
||||
if (simStore) {
|
||||
const board = simStore.boards?.find((b: any) => b.id === simStore.activeBoardId);
|
||||
serialLog = board?.serialOutput ?? simStore.serialOutput ?? '';
|
||||
dbg.push(`[direct] serial: ${serialLog.length} chars`);
|
||||
wireList = simStore.wires ?? [];
|
||||
dbg.push(`[direct] wires: ${wireList.length}`);
|
||||
} else {
|
||||
dbg.push('[direct] simulator store not found');
|
||||
}
|
||||
} catch (e: any) {
|
||||
dbg.push(`[direct] error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// === Evaluate ===
|
||||
const messages: string[] = [];
|
||||
let keyTextPass: boolean | undefined;
|
||||
let serialPass: boolean | undefined;
|
||||
let wiringPass: boolean | undefined;
|
||||
|
||||
// 1. Key text
|
||||
if (data.key_text) {
|
||||
const keys = data.key_text.split('\n').map(k => k.trim()).filter(k => k.length > 0);
|
||||
keyTextPass = keys.every(key => sourceCode.includes(key));
|
||||
messages.push(keyTextPass
|
||||
? '✅ Kata kunci ditemukan dalam kode'
|
||||
: '❌ Kata kunci belum ada dalam kode');
|
||||
dbg.push(`[key_text] keys="${keys.join(', ')}" → ${keyTextPass}`);
|
||||
}
|
||||
|
||||
// 2. Serial output
|
||||
if (data.expected_serial_output) {
|
||||
const actualLines = serialLog.trim().split('\n').map(l => l.trim());
|
||||
const expectedLines = data.expected_serial_output.trim().split('\n').map(l => l.trim());
|
||||
let j = 0;
|
||||
for (const line of actualLines) {
|
||||
if (j < expectedLines.length && line === expectedLines[j]) j++;
|
||||
if (j === expectedLines.length) break;
|
||||
}
|
||||
serialPass = j === expectedLines.length;
|
||||
messages.push(serialPass
|
||||
? '✅ Serial output sesuai'
|
||||
: '❌ Serial output belum sesuai');
|
||||
const preview = serialLog.substring(0, 150).replace(/\n/g, '↵');
|
||||
dbg.push(`[serial] actual(${serialLog.length}ch)="${preview}" → ${serialPass}`);
|
||||
}
|
||||
|
||||
// 3. Wiring
|
||||
if (data.expected_wiring) {
|
||||
let expectedPairs: [string, string][] = [];
|
||||
try { expectedPairs = JSON.parse(data.expected_wiring); } catch {}
|
||||
|
||||
// Normalize power pin names (e.g., GND.2 → GND, VCC.1 → VCC)
|
||||
const normalizePin = (pin: string) => pin.replace(/^(GND|VCC|5V|3V3|3\.3V)\.\d+$/i, '$1');
|
||||
|
||||
const norm = (a: string, b: string) => {
|
||||
const normA = a.replace(/:(.+)$/, (_, pin) => ':' + normalizePin(pin));
|
||||
const normB = b.replace(/:(.+)$/, (_, pin) => ':' + normalizePin(pin));
|
||||
return [normA, normB].sort().join('↔');
|
||||
};
|
||||
const studentEdges = new Set(
|
||||
wireList.map(w => norm(
|
||||
`${w.start.componentId}:${w.start.pinName}`,
|
||||
`${w.end.componentId}:${w.end.pinName}`
|
||||
))
|
||||
);
|
||||
wiringPass = expectedPairs.every(([a, b]) => studentEdges.has(norm(a, b)));
|
||||
messages.push(wiringPass
|
||||
? '✅ Rangkaian wiring benar'
|
||||
: '❌ Wiring belum sesuai');
|
||||
|
||||
const edgesStr = wireList.map(w =>
|
||||
`${w.start.componentId}:${w.start.pinName}↔${w.end.componentId}:${w.end.pinName}`
|
||||
);
|
||||
dbg.push(`[wiring] student: ${edgesStr.join(' | ') || '(kosong)'}`);
|
||||
dbg.push(`[wiring] expected: ${expectedPairs.map(p => p.join('↔')).join(' | ')}`);
|
||||
dbg.push(`[wiring] → ${wiringPass}`);
|
||||
}
|
||||
|
||||
const checks = [keyTextPass, serialPass, wiringPass].filter(v => v !== undefined);
|
||||
const pass = checks.length > 0 && checks.every(Boolean);
|
||||
|
||||
messages.push('', '── Debug ──', ...dbg);
|
||||
|
||||
velxioOut.output = messages.join('\n');
|
||||
velxioOut.success = pass;
|
||||
|
||||
if (pass) {
|
||||
await completeLesson();
|
||||
setTimeout(() => { showCelebration = false; activeTab = 'velxio'; }, 3000);
|
||||
}
|
||||
} catch (err: any) {
|
||||
Object.assign(velxioOut, { error: `Evaluasi gagal: ${err.message}`, success: false });
|
||||
} finally {
|
||||
velxioOut.loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -450,6 +700,35 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Velxio (Arduino) tab panel -->
|
||||
{#if isVelxio}
|
||||
<div class="tab-panel velxio-panel" class:tab-hidden={activeTab !== 'velxio'}>
|
||||
{#if velxioError}
|
||||
<div class="velxio-fallback">
|
||||
Simulator Arduino sedang tidak tersedia.
|
||||
Hubungi guru jika masalah berlanjut.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="velxio-toolbar">
|
||||
<button type="button" class="btn btn-success" onclick={handleVelxioSubmit}
|
||||
disabled={velxioOut.loading}>
|
||||
{velxioOut.loading ? 'Mengevaluasi...' : '✓ Submit'}
|
||||
</button>
|
||||
<span class="velxio-status">
|
||||
{velxioReady ? '🟢 Bridge' : '🔵 Direct'}
|
||||
</span>
|
||||
</div>
|
||||
<!-- svelte-ignore a11y_missing_attribute -->
|
||||
<iframe
|
||||
class="velxio-iframe"
|
||||
src="/velxio/editor?embed=true{hasArduinoCode ? '' : '&hideEditor=true'}"
|
||||
onload={(e) => initVelxioBridge(e.currentTarget as HTMLIFrameElement)}
|
||||
allow="cross-origin-isolated"
|
||||
></iframe>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Editor tab panel -->
|
||||
{#if !data.active_tabs || data.active_tabs.length === 0 || data.active_tabs.includes('c') || data.active_tabs.includes('python')}
|
||||
<div class="tab-panel" class:tab-hidden={activeTab !== 'editor'}>
|
||||
|
|
@ -575,6 +854,8 @@
|
|||
}
|
||||
.editor-area .editor-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
min-height: 0;
|
||||
|
|
@ -658,6 +939,7 @@
|
|||
right: 1rem;
|
||||
top: auto;
|
||||
width: 45vw;
|
||||
height: 70vh;
|
||||
min-width: 320px;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
|
|
@ -736,4 +1018,42 @@
|
|||
display: none;
|
||||
}
|
||||
|
||||
/* ── Velxio (Arduino simulator) ─────────────────────── */
|
||||
.velxio-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.velxio-status {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.velxio-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.velxio-panel.tab-hidden {
|
||||
display: none;
|
||||
}
|
||||
.velxio-iframe {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 400px;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.velxio-fallback {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,22 @@ services:
|
|||
depends_on:
|
||||
- elemes
|
||||
|
||||
velxio:
|
||||
# image: ghcr.io/a2nr/velxio-elemes:latest
|
||||
build:
|
||||
context: ./velxio
|
||||
dockerfile: Dockerfile.standalone
|
||||
args:
|
||||
VITE_BASE_PATH: /velxio/
|
||||
VITE_API_BASE: /velxio/api
|
||||
environment:
|
||||
- SECRET_KEY=embed-only-no-auth-needed
|
||||
- DATABASE_URL=sqlite+aiosqlite:////app/data/velxio.db
|
||||
- DATA_DIR=/app/data
|
||||
volumes:
|
||||
- velxio-data:/app/data
|
||||
- velxio-arduino-libs:/root/.arduino15
|
||||
|
||||
elemes-ts:
|
||||
image: docker.io/tailscale/tailscale:latest
|
||||
hostname: ${ELEMES_HOST}
|
||||
|
|
@ -51,6 +67,10 @@ services:
|
|||
env_file:
|
||||
- ../.env
|
||||
|
||||
volumes:
|
||||
velxio-data:
|
||||
velxio-arduino-libs:
|
||||
|
||||
networks:
|
||||
main_network:
|
||||
drive: bridge
|
||||
|
|
|
|||
|
|
@ -63,6 +63,12 @@ def api_lesson(filename):
|
|||
initial_python = parsed_data.get('initial_python', '')
|
||||
initial_quiz = parsed_data.get('initial_quiz', '')
|
||||
|
||||
# Arduino/Velxio fields
|
||||
initial_code_arduino = parsed_data.get('initial_code_arduino', '')
|
||||
velxio_circuit = parsed_data.get('velxio_circuit', '')
|
||||
expected_serial_output = parsed_data.get('expected_serial_output', '')
|
||||
expected_wiring = parsed_data.get('expected_wiring', '')
|
||||
|
||||
if not initial_code:
|
||||
initial_code = (
|
||||
'#include <stdio.h>\n\nint main() {\n'
|
||||
|
|
@ -108,6 +114,10 @@ def api_lesson(filename):
|
|||
'initial_code_c': initial_code_c,
|
||||
'initial_python': initial_python,
|
||||
'initial_quiz': initial_quiz,
|
||||
'initial_code_arduino': initial_code_arduino,
|
||||
'velxio_circuit': velxio_circuit,
|
||||
'expected_serial_output': expected_serial_output,
|
||||
'expected_wiring': expected_wiring,
|
||||
'solution_code': solution_code,
|
||||
'solution_circuit': solution_circuit,
|
||||
'key_text': key_text,
|
||||
|
|
|
|||
|
|
@ -259,14 +259,21 @@ def render_markdown_content(file_path):
|
|||
active_tabs = []
|
||||
|
||||
# Check for collective tags before extracting them
|
||||
if '---INITIAL_CODE---' in lesson_content:
|
||||
active_tabs.append('c')
|
||||
if '---INITIAL_PYTHON---' in lesson_content:
|
||||
active_tabs.append('python')
|
||||
# Priority: INITIAL_CODE_ARDUINO → velxio mode (exclusive, ignores C/Python tabs)
|
||||
if '---INITIAL_CODE_ARDUINO---' in lesson_content:
|
||||
active_tabs.append('velxio')
|
||||
else:
|
||||
if '---INITIAL_CODE---' in lesson_content:
|
||||
active_tabs.append('c')
|
||||
if '---INITIAL_PYTHON---' in lesson_content:
|
||||
active_tabs.append('python')
|
||||
if '---INITIAL_CIRCUIT---' in lesson_content:
|
||||
active_tabs.append('circuit')
|
||||
if '---INITIAL_QUIZ---' in lesson_content:
|
||||
active_tabs.append('quiz')
|
||||
# Velxio circuit-only: has VELXIO_CIRCUIT but no INITIAL_CODE_ARDUINO
|
||||
if '---VELXIO_CIRCUIT---' in lesson_content and 'velxio' not in active_tabs:
|
||||
active_tabs.append('velxio')
|
||||
|
||||
# Default to 'c' if nothing specified (for backwards compatibility)
|
||||
if not active_tabs and '---INITIAL_CODE---' not in lesson_content and '---INITIAL_PYTHON---' not in lesson_content and '---INITIAL_CIRCUIT---' not in lesson_content and '---INITIAL_QUIZ---' not in lesson_content:
|
||||
|
|
@ -307,16 +314,29 @@ def render_markdown_content(file_path):
|
|||
# Initial codes (C, Python, Circuit, Quiz)
|
||||
initial_code_c, lesson_content = _extract_section(
|
||||
lesson_content, '---INITIAL_CODE---', '---END_INITIAL_CODE---')
|
||||
|
||||
|
||||
initial_python, lesson_content = _extract_section(
|
||||
lesson_content, '---INITIAL_PYTHON---', '---END_INITIAL_PYTHON---')
|
||||
|
||||
|
||||
initial_circuit, lesson_content = _extract_section(
|
||||
lesson_content, '---INITIAL_CIRCUIT---', '---END_INITIAL_CIRCUIT---')
|
||||
|
||||
|
||||
initial_quiz, lesson_content = _extract_section(
|
||||
lesson_content, '---INITIAL_QUIZ---', '---END_INITIAL_QUIZ---')
|
||||
|
||||
# Arduino/Velxio sections
|
||||
initial_code_arduino, lesson_content = _extract_section(
|
||||
lesson_content, '---INITIAL_CODE_ARDUINO---', '---END_INITIAL_CODE_ARDUINO---')
|
||||
|
||||
velxio_circuit, lesson_content = _extract_section(
|
||||
lesson_content, '---VELXIO_CIRCUIT---', '---END_VELXIO_CIRCUIT---')
|
||||
|
||||
expected_serial_output, lesson_content = _extract_section(
|
||||
lesson_content, '---EXPECTED_SERIAL_OUTPUT---', '---END_EXPECTED_SERIAL_OUTPUT---')
|
||||
|
||||
expected_wiring, lesson_content = _extract_section(
|
||||
lesson_content, '---EXPECTED_WIRING---', '---END_EXPECTED_WIRING---')
|
||||
|
||||
# Just use whichever initial code matched as the generic 'initial_code' for simplicity
|
||||
# if only one type exists, but return all as dictionary values.
|
||||
# Typically frontend uses 'initial_code' for legacy.
|
||||
|
|
@ -353,6 +373,10 @@ def render_markdown_content(file_path):
|
|||
'initial_python': initial_python,
|
||||
'initial_circuit': initial_circuit,
|
||||
'initial_quiz': initial_quiz,
|
||||
'initial_code_arduino': initial_code_arduino,
|
||||
'velxio_circuit': velxio_circuit,
|
||||
'expected_serial_output': expected_serial_output,
|
||||
'expected_wiring': expected_wiring,
|
||||
'active_tabs': active_tabs
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 52321dffe839481eb849a92ec02dd3727246adef
|
||||
Loading…
Reference in New Issue