feat: Integrate CircuitJS1 into Elemes LMS

- Added CircuitJSApi interface for simulator API interaction.
- Updated lesson page to support circuit simulation alongside code execution.
- Implemented separate output states for code and circuit evaluations.
- Enhanced lesson content rendering to include circuit embeds using markdown.
- Refactored backend to process circuit embed syntax and convert to HTML.
- Updated podman-compose configuration to include new environment variable for cursor offset.
- Created a proposal document outlining the feasibility and implementation plan for CircuitJS1 integration.
master
a2nr 2026-03-31 12:08:42 +07:00
parent 39d1b18c2a
commit d29c2f2e3e
18 changed files with 1535 additions and 401 deletions

View File

@ -1,7 +1,7 @@
# Elemes LMS — Dokumentasi Teknis
**Project:** LMS-C (Learning Management System untuk Pemrograman C)
**Terakhir diupdate:** 25 Maret 2026
**Terakhir diupdate:** 30 Maret 2026
---
@ -17,6 +17,7 @@ Tailscale Funnel (elemes-ts)
SvelteKit Frontend (elemes-frontend :3000)
├── SSR pages (lesson content embedded in HTML)
├── CodeMirror 6 editor (lazy-loaded)
├── CircuitJS simulator (iframe, GWT-compiled)
├── API proxy: /api/* → Flask
└── PWA manifest
@ -87,6 +88,8 @@ lms-c/
├── 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
@ -101,7 +104,8 @@ lms-c/
│ └── types/
│ ├── lesson.ts
│ ├── auth.ts
│ └── compiler.ts
│ ├── compiler.ts
│ └── circuitjs.ts # CircuitJSApi interface
├── routes/
│ ├── +layout.svelte
│ ├── +page.svelte # Home (lesson grid)
@ -112,7 +116,14 @@ lms-c/
│ └── progress/
│ └── +page.svelte # Teacher dashboard
└── static/
└── manifest.json # PWA manifest
├── 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
```
---
@ -134,6 +145,14 @@ Podman's aardvark-dns tidak berfungsi di environment ini (getaddrinfo EAI_AGAIN)
### 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.
### 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 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.
@ -185,6 +204,131 @@ Semua endpoint Flask diakses via SvelteKit proxy (`/api/*` → Flask `:5000`):
---
## 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.
### Arsitektur
```
content/z_test_circuit.md
│ ---INITIAL_CIRCUIT--- ... ---END_INITIAL_CIRCUIT---
▼ lesson_service.py: _extract_section()
Flask API (/api/lesson/<slug>.json)
│ { initial_circuit, expected_output, key_text, active_tabs: ["circuit"] }
▼ +page.ts SSR loader
lesson/[slug]/+page.svelte
<CircuitEditor initialCircuit={data.initial_circuit} />
▼ iframe onload → oncircuitjsloaded callback
CircuitEditor.svelte
│ simApi.importCircuit(text, false)
circuitjs1/circuitjs.html (GWT app in iframe)
│ Canvas rendering, simulasi real-time
▼ CrosshairOverlay.svelte (touch devices only)
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`.
| Method | Fungsi |
|--------|--------|
| `importCircuit(text, showMessage)` | Load circuit dari text |
| `exportCircuit()` | Ekspor circuit saat ini sebagai text |
| `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:
| 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 |
**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.
---
## Anti Copy-Paste System
Sistem berlapis untuk mencegah siswa meng-copy konten pelajaran dan mem-paste kode dari sumber eksternal ke editor.
@ -220,6 +364,72 @@ Mencegah siswa mem-paste kode dari sumber eksternal ke code editor. Diaktifkan v
---
## Touch Crosshair System (Fat Finger Fix)
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.
**File:** `frontend/src/lib/components/CrosshairOverlay.svelte`
**Dimount di:** `frontend/src/lib/components/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 |
### State Machine (Crosshair Aiming)
```
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 |
### 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 |
### 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 |
### Limitasi
- **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.
---
## Status Implementasi
- [x] **Phase 0:** Backend decomposition (monolith → Blueprints + services)

13
elemes.sh Executable file
View File

@ -0,0 +1,13 @@
#!/bin/bash
case "$1" in
runbuild)
podman-compose --env-file ../.env up --build --force-recreate -d
;;&
run)
podman-compose --env-file ../.env up -d
;;&
stop)
podman-compose --env-file ../.env down
;;
esac

View File

@ -6,7 +6,7 @@ COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
RUN npx svelte-kit sync && npm run build
## ── Runner ───────────────────────────────────────────────────────
FROM node:20-slim

View File

@ -158,6 +158,27 @@ img {
overflow: hidden;
}
/* ── Circuit embeds (rendered from ```circuit markdown fences) ── */
.circuit-embed {
margin: 0.75rem 0;
border: 1px solid var(--color-border);
border-radius: var(--radius);
overflow: hidden;
position: relative;
background: var(--color-bg-secondary);
}
.circuit-embed-wrapper {
display: block;
}
.circuit-embed-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
color: var(--color-text-muted);
font-size: 0.85rem;
}
/* ── Responsive ───────────────────────────────────────────────── */
@media (max-width: 768px) {
.container {

View File

@ -6,7 +6,6 @@
*/
import type { Handle } from '@sveltejs/kit';
import { execSync } from 'child_process';
function resolveBackend(): string {
const env = process.env.API_BACKEND;

View File

@ -0,0 +1,72 @@
/**
* Find `.circuit-embed` divs (generated by backend from ```circuit fences)
* and replace them with live CircuitJS iframes. Uses IntersectionObserver
* for lazy loading so multiple embeds don't all load at once.
*/
function loadCircuitEmbed(div: HTMLElement) {
const width = div.dataset.width || '100%';
const height = div.dataset.height || '400px';
const dataEl = div.querySelector('.circuit-data');
const circuitData = dataEl?.textContent?.trim() || '';
const wrapper = document.createElement('div');
wrapper.className = 'circuit-embed-wrapper';
wrapper.style.width = width;
wrapper.style.height = height;
const iframe = document.createElement('iframe');
iframe.src = '/circuitjs1/circuitjs.html';
iframe.style.width = '100%';
iframe.style.height = '100%';
iframe.style.border = 'none';
iframe.title = 'Circuit Simulator';
const loadCircuit = (api: any) => {
if (!circuitData) return;
try {
api.importCircuit(circuitData, false);
api.setSimRunning(true);
api.updateCircuit();
} catch (e) {
console.error('Circuit embed load error:', e);
}
const loadingEl = div.querySelector('.circuit-embed-loading');
if (loadingEl) loadingEl.remove();
};
iframe.onload = () => {
const win = iframe.contentWindow as any;
win.oncircuitjsloaded = (api: any) => loadCircuit(api);
// Fallback if callback doesn't fire
setTimeout(() => {
const api = win?.CircuitJS1;
if (api) loadCircuit(api);
}, 3000);
};
wrapper.appendChild(iframe);
div.appendChild(wrapper);
div.dataset.rendered = 'true';
}
export function renderCircuitEmbeds(container: HTMLElement) {
const embeds = container.querySelectorAll('.circuit-embed:not([data-rendered])');
if (embeds.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
loadCircuitEmbed(entry.target as HTMLElement);
observer.unobserve(entry.target);
}
});
},
{ rootMargin: '200px' }
);
embeds.forEach((el) => observer.observe(el));
}

View File

@ -1,17 +1,21 @@
<script lang="ts">
import CrosshairOverlay from './CrosshairOverlay.svelte';
import type { CircuitJSApi } from '$lib/types/circuitjs';
interface Props {
initialCircuit?: string;
storageKey?: string;
onready?: (api: any) => void;
onready?: (api: CircuitJSApi) => void;
}
let { initialCircuit = '', storageKey, onready }: Props = $props();
let iframe: HTMLIFrameElement;
let iframe = $state<HTMLIFrameElement>(null!);
let ready = $state(false);
let simApi = $state<any>(null);
let simApi = $state<CircuitJSApi | null>(null);
let saving = $state(false);
let saveTimeout: any;
let saveTimeout: ReturnType<typeof setTimeout>;
let autoSaveInterval: ReturnType<typeof setInterval>;
let lastLoadedCircuit = $state('');
function saveToStorage(text: string) {
@ -25,46 +29,26 @@
}
export function loadCircuitToSim(text: string) {
if (!simApi) {
console.warn("CircuitEditor: simApi not ready yet");
return;
}
if (!text) return;
const trimmed = text.trim();
if (!simApi) return;
const trimmed = text?.trim();
if (!trimmed) return;
console.log("CircuitEditor: Attempting to load circuit text (length:", trimmed.length, ")");
try {
if (typeof simApi.setCircuitText === 'function') {
simApi.setCircuitText(trimmed);
} else if (typeof simApi.setupText === 'function') {
simApi.setupText(trimmed);
} else if (typeof simApi.importCircuit === 'function') {
console.log("CircuitEditor: Calling importCircuit()...");
simApi.importCircuit(trimmed, false);
} else {
console.error("CircuitEditor: No import function found on simApi", simApi);
}
// Ensure it's running & rendering
if (typeof simApi.setSimRunning === 'function') simApi.setSimRunning(true);
if (typeof simApi.updateCircuit === 'function') simApi.updateCircuit();
simApi.importCircuit(trimmed, false);
simApi.setSimRunning(true);
simApi.updateCircuit();
} catch (err) {
console.error("CircuitEditor: Error loading circuit:", err);
}
}
function initSimulator(apiFromArg?: any) {
console.log("CircuitEditor: initSimulator triggered!");
const win = iframe?.contentWindow as any;
// Priority: Object from callback argument, then from window object
simApi = apiFromArg || (win ? win.CircuitJS1 : null);
if (simApi) {
console.log("CircuitEditor: API Object acquired successfully");
ready = true;
// Load initial circuit or draft
@ -72,7 +56,6 @@
if (storageKey) {
const saved = sessionStorage.getItem(storageKey);
if (saved) {
console.log("CircuitEditor: Found draft in sessionStorage");
toLoad = saved;
}
}
@ -89,9 +72,9 @@
// Setup auto-save polling
if (storageKey) {
setInterval(() => {
autoSaveInterval = setInterval(() => {
if (simApi && ready) {
const currentText = typeof simApi.exportCircuit === 'function' ? simApi.exportCircuit() : '';
const currentText = simApi.exportCircuit();
const saved = sessionStorage.getItem(storageKey);
if (currentText && currentText !== saved && currentText.trim().length > 10) {
saveToStorage(currentText);
@ -105,20 +88,17 @@
}
function handleIframeLoad() {
console.log("CircuitEditor: Iframe DOM loaded, setting up callback...");
if (iframe && iframe.contentWindow) {
const win = iframe.contentWindow as any;
// Define the callback that CircuitJS1 calls internally
win.oncircuitjsloaded = (api: any) => {
console.log("CircuitEditor: Callback oncircuitjsloaded received API argument");
initSimulator(api);
};
// Fallback: if already loaded or callback doesn't fire
setTimeout(() => {
if (!ready) {
console.log("CircuitEditor: Fallback check for API...");
initSimulator();
}
}, 3000);
@ -128,22 +108,29 @@
// Re-apply if initialCircuit prop changes
$effect(() => {
if (simApi && ready && initialCircuit && initialCircuit !== lastLoadedCircuit) {
console.log("CircuitEditor: initialCircuit prop changed, re-loading...");
lastLoadedCircuit = initialCircuit;
loadCircuitToSim(initialCircuit);
}
});
// Cleanup timers on destroy
$effect(() => {
return () => {
clearInterval(autoSaveInterval);
clearTimeout(saveTimeout);
};
});
export function getCircuitText(): string {
if (!simApi) return '';
return typeof simApi.exportCircuit === 'function' ? simApi.exportCircuit() : '';
return simApi.exportCircuit();
}
export function setCircuitText(text: string) {
loadCircuitToSim(text);
}
export function getApi() {
export function getApi(): CircuitJSApi | null {
return simApi;
}
</script>
@ -162,6 +149,10 @@
class:visible={ready}
></iframe>
{#if ready}
<CrosshairOverlay {iframe} />
{/if}
{#if storageKey && ready}
<div class="storage-indicator" title={saving ? "Menyimpan draf..." : "Draf tersimpan di browser"}>
<span class="indicator-icon" class:saving>

View File

@ -348,7 +348,6 @@
font-weight: 500;
}
.editor-wrapper :global(.cm-editor) {
...
min-height: 200px;
max-height: 60vh;
}

View File

@ -0,0 +1,418 @@
<script lang="ts">
import { env } from '$env/dynamic/public';
interface Props {
iframe: HTMLIFrameElement;
}
let { iframe }: Props = $props();
let overlayEl: HTMLDivElement;
let crosshairX = $state(0);
let crosshairY = $state(0);
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
let currentPointerX = 0;
let currentPointerY = 0;
let pointerDown = false;
let isTouchDevice = $state(false);
let lastTapTime = 0;
let tapCount = 0;
let singleTapTimer: ReturnType<typeof setTimeout> | null = null;
let holdingTimer: ReturnType<typeof setTimeout> | null = null;
// Two-finger tap detection
let activePointers = new Map<number, { x: number; y: number }>();
let twoFingerTapped = false;
const LONG_PRESS_MS = 400;
const DOUBLE_TAP_MS = 300;
const HOLDING_TIMEOUT_MS = 5000;
const CURSOR_OFFSET_Y = -(parseInt(env.PUBLIC_CURSOR_OFFSET_Y || '50', 10));
/**
* State machine:
* idle → long tap → aiming_start (crosshair muncul, belum click)
* aiming_start → release → holding (mousedown di crosshair, crosshair hilang)
* holding → long tap → aiming_end (crosshair muncul lagi, mousemove live)
* aiming_end → release → idle (mouseup di crosshair, selesai)
*
* idle → short tap → forward click
* holding → short tap → mouseup di posisi tap → idle
*/
type Phase = 'idle' | 'aiming_start' | 'holding' | 'aiming_end';
let phase = $state<Phase>('idle');
let showCrosshair = $derived(phase === 'aiming_start' || phase === 'aiming_end');
$effect(() => {
if (typeof window === 'undefined') return;
const mql = window.matchMedia('(hover: none) and (pointer: coarse)');
isTouchDevice = mql.matches;
const handler = (e: MediaQueryListEvent) => {
isTouchDevice = e.matches;
};
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
});
function getIframeTarget(x: number, y: number): Element | null {
try {
const doc = iframe?.contentDocument;
if (!doc) {
console.warn('[Crosshair] contentDocument null — mungkin cross-origin');
return null;
}
// Coba elementFromPoint dulu, fallback ke canvas
const el = doc.elementFromPoint(x, y);
if (el) return el;
return doc.querySelector('canvas');
} catch (e) {
console.warn('[Crosshair] Gagal akses iframe:', e);
return null;
}
}
function dispatchToCanvas(type: string, viewportX: number, viewportY: number) {
const iframeRect = iframe.getBoundingClientRect();
const x = viewportX - iframeRect.left;
const y = viewportY - iframeRect.top;
const target = getIframeTarget(x, y);
if (!target) {
console.warn('[Crosshair] Tidak ada target untuk dispatch', type);
return;
}
target.dispatchEvent(
new MouseEvent(type, {
clientX: x,
clientY: y,
screenX: viewportX,
screenY: viewportY,
bubbles: true,
cancelable: true,
button: 0,
buttons: type === 'mouseup' ? 0 : 1,
view: iframe.contentWindow!
})
);
}
function dispatchRightClick(viewportX: number, viewportY: number) {
const iframeRect = iframe.getBoundingClientRect();
const x = viewportX - iframeRect.left;
const y = viewportY - iframeRect.top;
const target = getIframeTarget(x, y);
if (!target) return;
const opts = {
clientX: x,
clientY: y,
screenX: viewportX,
screenY: viewportY,
bubbles: true,
cancelable: true,
button: 2,
buttons: 2,
view: iframe.contentWindow!
};
target.dispatchEvent(new MouseEvent('mousedown', opts));
target.dispatchEvent(new MouseEvent('contextmenu', opts));
target.dispatchEvent(new MouseEvent('mouseup', { ...opts, buttons: 0 }));
}
function focusIfEditable(viewportX: number, viewportY: number) {
const iframeRect = iframe.getBoundingClientRect();
const x = viewportX - iframeRect.left;
const y = viewportY - iframeRect.top;
const target = getIframeTarget(x, y);
if (!target) return;
const tag = target.tagName.toLowerCase();
const isEditable =
tag === 'input' || tag === 'textarea' || tag === 'select' ||
(target as HTMLElement).isContentEditable;
if (isEditable) {
(target as HTMLElement).focus();
}
}
function updateCrosshairPos(viewportX: number, viewportY: number) {
if (!overlayEl) return;
const rect = overlayEl.getBoundingClientRect();
crosshairX = viewportX - rect.left;
crosshairY = viewportY - rect.top + CURSOR_OFFSET_Y;
}
function getCrosshairViewport(): { x: number; y: number } {
const rect = overlayEl?.getBoundingClientRect();
if (!rect) return { x: currentPointerX, y: currentPointerY };
return { x: rect.left + crosshairX, y: rect.top + crosshairY };
}
function cancelTimer() {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
function cancelSingleTapTimer() {
if (singleTapTimer) {
clearTimeout(singleTapTimer);
singleTapTimer = null;
}
}
function cancelHoldingTimer() {
if (holdingTimer) {
clearTimeout(holdingTimer);
holdingTimer = null;
}
}
function startHoldingTimeout() {
cancelHoldingTimer();
holdingTimer = setTimeout(() => {
// Timeout — lepas click otomatis, kembali ke idle
if (phase === 'holding') {
dispatchToCanvas('mouseup', currentPointerX, currentPointerY);
phase = 'idle';
}
}, HOLDING_TIMEOUT_MS);
}
function vibrate() {
if (typeof navigator !== 'undefined' && navigator.vibrate) {
navigator.vibrate(30);
}
}
function handlePointerDown(e: PointerEvent) {
if (e.pointerType !== 'touch') return;
e.preventDefault();
activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
// Jari kedua turun → two-finger tap candidate, batalkan gesture lain
if (activePointers.size >= 2) {
cancelTimer();
cancelSingleTapTimer();
twoFingerTapped = true;
return;
}
pointerDown = true;
twoFingerTapped = false;
currentPointerX = e.clientX;
currentPointerY = e.clientY;
if (phase === 'idle' || phase === 'holding') {
cancelTimer();
cancelSingleTapTimer();
longPressTimer = setTimeout(() => {
// Long press detected
updateCrosshairPos(currentPointerX, currentPointerY);
vibrate();
if (phase === 'idle') {
phase = 'aiming_start';
} else if (phase === 'holding') {
cancelHoldingTimer();
phase = 'aiming_end';
}
}, LONG_PRESS_MS);
}
}
function handlePointerMove(e: PointerEvent) {
if (e.pointerType !== 'touch' || !pointerDown) return;
e.preventDefault();
currentPointerX = e.clientX;
currentPointerY = e.clientY;
if (phase === 'aiming_start') {
updateCrosshairPos(currentPointerX, currentPointerY);
} else if (phase === 'aiming_end') {
updateCrosshairPos(currentPointerX, currentPointerY);
// Live mousemove saat aiming release point
const ch = getCrosshairViewport();
dispatchToCanvas('mousemove', ch.x, ch.y);
}
}
function handlePointerUp(e: PointerEvent) {
if (e.pointerType !== 'touch') return;
e.preventDefault();
activePointers.delete(e.pointerId);
// Two-finger tap: dispatch right-click saat semua jari terangkat
if (twoFingerTapped) {
if (activePointers.size === 0) {
twoFingerTapped = false;
pointerDown = false;
cancelTimer();
// Right-click di posisi midpoint atau posisi terakhir
dispatchRightClick(e.clientX, e.clientY);
}
return;
}
if (!pointerDown) return;
pointerDown = false;
if (phase === 'aiming_start') {
// Release setelah aim pertama → mousedown di crosshair
const ch = getCrosshairViewport();
dispatchToCanvas('mousedown', ch.x, ch.y);
phase = 'holding';
startHoldingTimeout();
} else if (phase === 'aiming_end') {
// Release setelah aim kedua → mouseup di crosshair
const ch = getCrosshairViewport();
dispatchToCanvas('mouseup', ch.x, ch.y);
phase = 'idle';
} else if (phase === 'idle') {
// Short tap — detect single / double / triple
cancelTimer();
const x = e.clientX;
const y = e.clientY;
const now = Date.now();
if (now - lastTapTime < DOUBLE_TAP_MS) {
tapCount++;
} else {
tapCount = 1;
}
lastTapTime = now;
cancelSingleTapTimer();
if (tapCount === 2) {
// Double tap → dblclick (edit komponen di CircuitJS)
dispatchToCanvas('dblclick', x, y);
focusIfEditable(x, y);
tapCount = 0;
} else if (tapCount >= 3) {
// Triple tap → right click (context menu)
dispatchRightClick(x, y);
tapCount = 0;
} else {
// Single tap — tunggu DOUBLE_TAP_MS sebelum dispatch
singleTapTimer = setTimeout(() => {
dispatchToCanvas('mousedown', x, y);
setTimeout(() => {
dispatchToCanvas('mouseup', x, y);
dispatchToCanvas('click', x, y);
focusIfEditable(x, y);
}, 50);
tapCount = 0;
}, DOUBLE_TAP_MS);
}
} else if (phase === 'holding') {
// Short tap saat holding → mouseup di posisi tap
cancelTimer();
cancelHoldingTimer();
dispatchToCanvas('mouseup', e.clientX, e.clientY);
phase = 'idle';
}
cancelTimer();
}
function handlePointerCancel(e: PointerEvent) {
if (e.pointerType !== 'touch') return;
activePointers.delete(e.pointerId);
twoFingerTapped = false;
pointerDown = false;
cancelTimer();
cancelSingleTapTimer();
if (phase === 'aiming_start') {
phase = 'idle';
} else if (phase === 'aiming_end') {
// Cancel saat aiming release → lepas click
dispatchToCanvas('mouseup', currentPointerX, currentPointerY);
phase = 'idle';
} else if (phase === 'holding') {
cancelHoldingTimer();
dispatchToCanvas('mouseup', currentPointerX, currentPointerY);
phase = 'idle';
}
}
function handleContextMenu(e: Event) {
e.preventDefault();
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="crosshair-overlay"
class:touch-active={isTouchDevice}
bind:this={overlayEl}
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onpointercancel={handlePointerCancel}
oncontextmenu={handleContextMenu}
>
</div>
{#if showCrosshair}
<svg
class="crosshair-cursor"
style="transform: translate({crosshairX}px, {crosshairY}px)"
width="48"
height="48"
viewBox="0 0 48 48"
>
<circle cx="24" cy="24" r="18" fill="none" stroke="rgba(220, 53, 69, 0.8)" stroke-width="2" />
<line x1="24" y1="2" x2="24" y2="14" stroke="rgba(220, 53, 69, 0.8)" stroke-width="1.5" />
<line x1="24" y1="34" x2="24" y2="46" stroke="rgba(220, 53, 69, 0.8)" stroke-width="1.5" />
<line x1="2" y1="24" x2="14" y2="24" stroke="rgba(220, 53, 69, 0.8)" stroke-width="1.5" />
<line x1="34" y1="24" x2="46" y2="24" stroke="rgba(220, 53, 69, 0.8)" stroke-width="1.5" />
<circle cx="24" cy="24" r="2" fill="rgba(220, 53, 69, 0.9)" />
</svg>
{/if}
<style>
.crosshair-overlay {
position: absolute;
inset: 0;
z-index: 5;
pointer-events: none;
touch-action: none;
}
.crosshair-overlay.touch-active {
pointer-events: auto;
}
.crosshair-cursor {
position: absolute;
top: 0;
left: 0;
width: 48px;
height: 48px;
margin-left: -24px;
margin-top: -24px;
pointer-events: none;
z-index: 6;
will-change: transform;
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.5));
animation: crosshair-in 0.15s ease-out;
}
@keyframes crosshair-in {
from {
transform: scale(0.3);
opacity: 0;
}
to {
opacity: 1;
}
}
</style>

View File

@ -0,0 +1,87 @@
<script lang="ts">
import type { Lesson } from '$types/lesson';
interface Props {
lessons: Lesson[];
currentSlug: string;
}
let { lessons, currentSlug }: Props = $props();
</script>
{#if lessons.length}
<div class="all-lessons">
<h3 class="all-lessons-heading">Semua Pelajaran</h3>
<div class="all-lessons-list">
{#each lessons as lesson (lesson.filename)}
<a href="/lesson/{lesson.filename}"
class="lesson-item"
class:lesson-item-active={lesson.filename === currentSlug}>
{#if lesson.completed}
<span class="lesson-check">&#10003;</span>
{/if}
<span class="lesson-item-title">{lesson.title}</span>
</a>
{/each}
</div>
</div>
{/if}
<style>
.all-lessons {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--color-border);
}
.all-lessons-heading {
font-size: 0.9rem;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.5rem;
}
.all-lessons-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.lesson-item {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.6rem;
border-radius: 6px;
font-size: 0.82rem;
color: var(--color-text);
text-decoration: none;
transition: background 0.12s;
}
.lesson-item:hover {
background: var(--color-bg-secondary);
text-decoration: none;
color: var(--color-text);
}
.lesson-item-active {
background: var(--color-primary);
color: #fff;
font-weight: 600;
}
.lesson-item-active:hover {
background: var(--color-primary-dark);
color: #fff;
}
.lesson-check {
color: var(--color-success);
font-size: 0.75rem;
flex-shrink: 0;
}
.lesson-item-active .lesson-check {
color: #fff;
}
.lesson-item-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@ -1,27 +1,90 @@
<script lang="ts">
interface Props {
interface OutputSection {
output?: string;
error?: string;
loading?: boolean;
success?: boolean | null;
}
let { output = '', error = '', loading = false, success = null }: Props = $props();
interface Props {
code?: OutputSection;
circuit?: OutputSection;
hasCode?: boolean;
hasCircuit?: boolean;
}
let {
code = { output: '', error: '', loading: false, success: null },
circuit = { output: '', error: '', loading: false, success: null },
hasCode = true,
hasCircuit = false,
}: Props = $props();
// Determine overall loading state for header badge
let anyLoading = $derived(code.loading || circuit.loading);
// Determine overall success state: null if idle, true if all ran successfully, false if any error
let overallSuccess = $derived.by(() => {
const codeRan = code.success !== null && code.success !== undefined;
const circuitRan = circuit.success !== null && circuit.success !== undefined;
if (!codeRan && !circuitRan) return null;
if ((codeRan && code.success === false) || (circuitRan && circuit.success === false)) return false;
return true;
});
</script>
<div class="output-panel" class:has-error={!!error} class:has-success={success === true}>
<div class="output-panel">
<div class="output-header">
<span class="output-title">Output</span>
{#if loading}
{#if anyLoading}
<span class="status-badge running">Compiling...</span>
{:else if success === true}
{:else if overallSuccess === true}
<span class="status-badge success">Berhasil</span>
{:else if success === false}
{:else if overallSuccess === false}
<span class="status-badge error">Error</span>
{/if}
</div>
<pre class="output-body">{#if loading}Mengompilasi dan menjalankan kode...{:else if error}{error}{:else if output}{output}{:else}<span class="placeholder">Klik "Run" untuk menjalankan kode</span>{/if}</pre>
<div class="output-sections">
<!-- Code output section -->
{#if hasCode}
<div class="output-section" class:has-error={!!code.error} class:has-success={code.success === true}>
<div class="section-label">
<span class="section-icon">&#x1F4BB;</span> Code
{#if code.loading}
<span class="section-badge running">Compiling...</span>
{:else if code.success === true}
<span class="section-badge success">OK</span>
{:else if code.success === false}
<span class="section-badge error">Error</span>
{/if}
</div>
<pre class="output-body">{#if code.loading}Mengompilasi dan menjalankan kode...{:else if code.error}{code.error}{:else if code.output}{code.output}{:else}<span class="placeholder">Klik "Run" untuk menjalankan kode</span>{/if}</pre>
</div>
{/if}
<!-- Circuit output section -->
{#if hasCircuit}
<div class="output-section" class:has-error={!!circuit.error} class:has-success={circuit.success === true}>
<div class="section-label">
<span class="section-icon">&#x26A1;</span> Circuit
{#if circuit.loading}
<span class="section-badge running">Evaluating...</span>
{:else if circuit.success === true}
<span class="section-badge success">OK</span>
{:else if circuit.success === false}
<span class="section-badge error">Error</span>
{/if}
</div>
<pre class="output-body">{#if circuit.loading}Mengevaluasi rangkaian...{:else if circuit.error}{circuit.error}{:else if circuit.output}{circuit.output}{:else}<span class="placeholder">Klik "Cek Rangkaian" untuk mengevaluasi</span>{/if}</pre>
</div>
{/if}
<!-- Fallback when neither section has run -->
{#if !hasCode && !hasCircuit}
<pre class="output-body"><span class="placeholder">Tidak ada output</span></pre>
{/if}
</div>
</div>
<style>
@ -44,29 +107,56 @@
.output-title {
font-weight: 600;
}
.status-badge {
.status-badge, .section-badge {
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-size: 0.7rem;
font-size: 0.65rem;
font-weight: 600;
}
.status-badge.running {
.status-badge.running, .section-badge.running {
background: var(--color-warning);
color: #000;
}
.status-badge.success {
.status-badge.success, .section-badge.success {
background: var(--color-success);
color: #fff;
}
.status-badge.error {
.status-badge.error, .section-badge.error {
background: var(--color-danger);
color: #fff;
}
/* ── Output sections ───────────────────────────────── */
.output-sections {
display: flex;
flex-direction: column;
}
.output-section {
border-bottom: 1px solid var(--color-border);
}
.output-section:last-child {
border-bottom: none;
}
.section-label {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.75rem;
background: var(--color-bg-secondary);
font-size: 0.75rem;
font-weight: 600;
font-family: system-ui, sans-serif;
color: var(--color-text-muted);
border-bottom: 1px solid var(--color-border);
}
.section-icon {
font-size: 0.85rem;
}
.output-body {
padding: 0.75rem;
margin: 0;
min-height: 80px;
max-height: 300px;
min-height: 60px;
max-height: 200px;
overflow: auto;
font-size: 0.85rem;
white-space: pre-wrap;

View File

@ -0,0 +1,248 @@
<script lang="ts">
type TabType = 'info' | 'exercise' | 'editor' | 'circuit' | 'output';
interface Props {
isMobile: boolean;
mobileMode: 'hidden' | 'half' | 'full';
activeTab: TabType;
hasInfo: boolean;
hasExercise: boolean;
activeTabs: string[];
floating: boolean;
minimized: boolean;
onDragStart: (e: MouseEvent) => void;
onResizeStart: (e: MouseEvent) => void;
onFloatToggle: () => void;
onMinimize: () => void;
}
let {
isMobile,
mobileMode = $bindable(),
activeTab = $bindable(),
hasInfo,
hasExercise,
activeTabs,
floating,
minimized,
onDragStart,
onResizeStart,
onFloatToggle,
onMinimize,
}: Props = $props();
let touchStartY = 0;
const hasCodeEditor = $derived(
!activeTabs || activeTabs.length === 0 || activeTabs.includes('c') || activeTabs.includes('python')
);
const hasCircuit = $derived(activeTabs?.includes('circuit') ?? false);
function cycleMobileSheet() {
if (mobileMode === 'hidden') mobileMode = 'half';
else if (mobileMode === 'half') mobileMode = 'full';
else mobileMode = 'hidden';
}
function onSheetTouchStart(e: TouchEvent) {
touchStartY = e.touches[0].clientY;
}
function onSheetTouchEnd(e: TouchEvent) {
const delta = e.changedTouches[0].clientY - touchStartY;
if (delta < -60) {
// Swipe up → expand (pull sheet up)
if (mobileMode === 'hidden') mobileMode = 'half';
else mobileMode = 'full';
} else if (delta > 60) {
// Swipe down → collapse (shrink downward)
if (mobileMode === 'full') mobileMode = 'half';
else mobileMode = 'hidden';
}
}
</script>
{#snippet chromeTabs()}
{#if hasInfo}
<button class="chrome-tab" class:active={activeTab === 'info'} onclick={() => (activeTab = 'info')}>Info</button>
{/if}
{#if hasExercise}
<button class="chrome-tab" class:active={activeTab === 'exercise'} onclick={() => (activeTab = 'exercise')}>Exercise</button>
{/if}
{#if hasCodeEditor}
<button class="chrome-tab" class:active={activeTab === 'editor'} onclick={() => (activeTab = 'editor')}>Code</button>
{/if}
{#if hasCircuit}
<button class="chrome-tab" class:active={activeTab === 'circuit'} onclick={() => (activeTab = 'circuit')}>Circuit</button>
{/if}
<button class="chrome-tab" class:active={activeTab === 'output'} onclick={() => (activeTab = 'output')}>Output</button>
{/snippet}
{#if isMobile}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="panel-header sheet-handle"
ontouchstart={onSheetTouchStart}
ontouchend={onSheetTouchEnd}>
<button type="button" class="sheet-handle-bar-btn" onclick={cycleMobileSheet} aria-label="Resize panel">
<div class="sheet-handle-bar"></div>
</button>
<div class="chrome-tabs">
{@render chromeTabs()}
</div>
</div>
{:else if floating && !minimized}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="panel-header draggable" onmousedown={onDragStart}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span class="resize-handle" onmousedown={(e) => { e.stopPropagation(); onResizeStart(e); }} title="Resize">&#x25F3;</span>
<div class="chrome-tabs">
{@render chromeTabs()}
</div>
<div class="panel-actions">
<button type="button" class="panel-btn" onclick={onMinimize} title="Minimize"></button>
<button type="button" class="panel-btn" onclick={onFloatToggle} title="Dock editor"></button>
</div>
</div>
{:else if !isMobile}
<div class="panel-header">
<div class="chrome-tabs">
{@render chromeTabs()}
</div>
<div class="panel-actions">
<button type="button" class="btn-float-toggle" onclick={onFloatToggle} title="Float editor">&#x229E;</button>
</div>
</div>
{/if}
<style>
.panel-header {
display: flex;
align-items: flex-end;
gap: 0.25rem;
padding: 4px 8px 0;
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
user-select: none;
cursor: default;
min-height: 36px;
}
.panel-header.draggable { cursor: grab; }
.panel-header.draggable:active { cursor: grabbing; }
/* ── Actions / buttons ─────────────────────────────── */
.panel-actions {
display: flex;
gap: 0.25rem;
align-self: center;
}
.panel-btn {
background: none;
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 0.15rem 0.5rem;
cursor: pointer;
font-size: 0.8rem;
color: var(--color-text);
line-height: 1;
}
.panel-btn:hover { background: var(--color-border); }
.btn-float-toggle {
background: none;
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 0.2rem 0.5rem;
cursor: pointer;
font-size: 0.95rem;
color: var(--color-text-muted);
line-height: 1;
align-self: center;
}
.btn-float-toggle:hover {
background: var(--color-bg-secondary);
color: var(--color-text);
}
.resize-handle {
cursor: nwse-resize;
font-size: 0.9rem;
color: var(--color-text-muted);
line-height: 1;
padding: 0.1rem 0.3rem;
border-radius: 3px;
align-self: center;
}
.resize-handle:hover {
background: var(--color-border);
color: var(--color-text);
}
/* ── Chrome-style tabs ─────────────────────────────── */
.chrome-tabs {
display: flex;
align-items: flex-end;
gap: 2px;
flex: 1;
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.chrome-tabs::-webkit-scrollbar { display: none; }
.chrome-tab {
position: relative;
padding: 5px 12px;
border: 1px solid transparent;
border-bottom: none;
border-radius: 8px 8px 0 0;
background: transparent;
color: var(--color-text-muted);
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
margin-bottom: -1px;
z-index: 0;
transition: background 0.15s, color 0.15s;
}
.chrome-tab:hover:not(.active) {
background: var(--color-border);
color: var(--color-text);
}
.chrome-tab.active {
background: var(--color-bg);
color: var(--color-text);
font-weight: 600;
border-color: var(--color-border);
z-index: 1;
}
/* ── Mobile sheet handle ────────────────────────────── */
.sheet-handle {
flex-direction: column;
align-items: stretch;
border: none;
border-bottom: none;
width: 100%;
color: inherit;
font: inherit;
padding: 4px 8px 0;
}
.sheet-handle-bar-btn {
display: block;
width: 100%;
background: none;
border: none;
padding: 4px 0;
cursor: pointer;
}
.sheet-handle-bar {
width: 36px;
height: 4px;
background: var(--color-border);
border-radius: 2px;
margin: 0 auto;
}
</style>

View File

@ -0,0 +1,24 @@
/**
* Interface for CircuitJS1 simulator API exposed via iframe's window.CircuitJS1.
* Methods marked optional (?) may not exist depending on CircuitJS version.
*/
export interface CircuitJSApi {
/** Import circuit from text format */
importCircuit(text: string, showMessage: boolean): void;
/** Export current circuit as text */
exportCircuit(): string;
/** Set simulation running state */
setSimRunning(running: boolean): void;
/** Force circuit update/redraw */
updateCircuit(): void;
/** Get voltage at a named node */
getNodeVoltage(nodeName: string): number;
/** Alternative import methods (version-dependent) */
setCircuitText?(text: string): void;
setupText?(text: string): void;
}

View File

@ -5,12 +5,15 @@
import CircuitEditor from '$components/CircuitEditor.svelte';
import OutputPanel from '$components/OutputPanel.svelte';
import CelebrationOverlay from '$components/CelebrationOverlay.svelte';
import WorkspaceHeader from '$components/WorkspaceHeader.svelte';
import LessonList from '$components/LessonList.svelte';
import { compileCode, trackProgress } from '$services/api';
import { auth, authLoggedIn } from '$stores/auth';
import { lessonContext } from '$stores/lessonContext';
import { noSelect } from '$actions/noSelect';
import { createFloatingPanel } from '$actions/floatingPanel.svelte';
import { highlightAllCode } from '$actions/highlightCode';
import { renderCircuitEmbeds } from '$actions/renderCircuitEmbeds';
import { tick } from 'svelte';
import type { LessonContent } from '$types/lesson';
@ -23,10 +26,20 @@
let data = $state<LessonContent | null>(null);
let lessonCompleted = $state(false);
let currentCode = $state('');
let compileOutput = $state('');
let compileError = $state('');
let compiling = $state(false);
let compileSuccess = $state<boolean | null>(null);
// Separate output state for code and circuit
let codeOutput = $state('');
let codeError = $state('');
let codeLoading = $state(false);
let codeSuccess = $state<boolean | null>(null);
let circuitOutput = $state('');
let circuitError = $state('');
let circuitLoading = $state(false);
let circuitSuccess = $state<boolean | null>(null);
// Derived: any loading state (for disabling Run button)
let compiling = $derived(codeLoading || circuitLoading);
// UI state
let showSolution = $state(false);
@ -46,7 +59,6 @@
// Mobile state: 'hidden' (only handle bar), 'half' (60%), 'full' (100%)
let isMobile = $state(false);
let mobileMode = $state<'hidden' | 'half' | 'full'>('half');
let touchStartY = 0;
// Media query detection
$effect(() => {
@ -64,29 +76,6 @@
return () => mql.removeEventListener('change', handler);
});
function cycleMobileSheet() {
if (mobileMode === 'hidden') mobileMode = 'half';
else if (mobileMode === 'half') mobileMode = 'full';
else mobileMode = 'hidden';
}
function onSheetTouchStart(e: TouchEvent) {
touchStartY = e.touches[0].clientY;
}
function onSheetTouchEnd(e: TouchEvent) {
const delta = e.changedTouches[0].clientY - touchStartY;
if (delta > 60) {
// Swipe down: full→half→hidden
if (mobileMode === 'full') mobileMode = 'half';
else mobileMode = 'hidden';
} else if (delta < -60) {
// Swipe up: hidden→half→full
if (mobileMode === 'hidden') mobileMode = 'half';
else mobileMode = 'full';
}
}
const slug = $derived($page.params.slug);
// Sync lesson data when navigating between lessons
@ -95,9 +84,12 @@
data = lesson;
lessonCompleted = lesson.lesson_completed;
currentCode = lesson.initial_code_c || lesson.initial_python || lesson.initial_code || '';
compileOutput = '';
compileError = '';
compileSuccess = null;
codeOutput = '';
codeError = '';
codeSuccess = null;
circuitOutput = '';
circuitError = '';
circuitSuccess = null;
showSolution = false;
if (lesson.lesson_info) activeTab = 'info';
else if (lesson.exercise_content) activeTab = 'exercise';
@ -120,12 +112,18 @@
lessonContext.set(null);
});
// Apply syntax highlighting after content renders
// Apply syntax highlighting + circuit embeds after content renders
$effect(() => {
if (data) {
tick().then(() => {
if (contentEl) highlightAllCode(contentEl);
if (tabsEl) highlightAllCode(tabsEl);
if (contentEl) {
highlightAllCode(contentEl);
renderCircuitEmbeds(contentEl);
}
if (tabsEl) {
highlightAllCode(tabsEl);
renderCircuitEmbeds(tabsEl);
}
});
}
});
@ -142,16 +140,16 @@
if (!data || !circuitEditor) return;
const simApi = circuitEditor.getApi();
if (!simApi) {
compileError = "Simulator belum siap.";
compileSuccess = false;
circuitError = "Simulator belum siap.";
circuitSuccess = false;
activeTab = 'output';
return;
}
compiling = true;
compileOutput = 'Mengevaluasi rangkaian...';
compileError = '';
compileSuccess = null;
circuitLoading = true;
circuitOutput = 'Mengevaluasi rangkaian...';
circuitError = '';
circuitSuccess = null;
activeTab = 'output';
try {
@ -161,16 +159,16 @@
expectedState = JSON.parse(data.expected_output);
}
} catch (e) {
compileError = "Format EXPECTED_OUTPUT tidak valid (Harus JSON).";
compileSuccess = false;
compiling = false;
circuitError = "Format EXPECTED_OUTPUT tidak valid (Harus JSON).";
circuitSuccess = false;
circuitLoading = false;
return;
}
if (!expectedState) {
compileOutput = "Tidak ada kriteria evaluasi yang ditetapkan.";
compileSuccess = true;
compiling = false;
circuitOutput = "Tidak ada kriteria evaluasi yang ditetapkan.";
circuitSuccess = true;
circuitLoading = false;
return;
}
@ -197,25 +195,8 @@
}
}
if (expectedState.elements && typeof simApi.elements === 'function' && typeof simApi.getElm === 'function') {
const elmCount = simApi.elements();
const elements = [];
for (let i = 0; i < elmCount; i++) {
elements.push(simApi.getElm(i));
}
for (const [infoMatch, criteria] of Object.entries<any>(expectedState.elements)) {
let found = null;
for (const el of elements) {
try {
const info = typeof el.getInfo === 'function' ? el.getInfo() : null;
// the info from getInfo is an array or something we might not be able to parse natively via JS.
// but we skip elements checking for now unless user really needs it
} catch (e) {}
}
}
}
// End of elements check
// TODO: Element-level checking (e.g. expectedState.elements) belum diimplementasi.
// GWT getInfo() returns Java array yang sulit di-parse dari JS.
const circuitText = circuitEditor.getCircuitText();
const keyTextMatch = checkKeyText(circuitText, data.key_text ?? '');
@ -224,8 +205,8 @@
messages.push(`❌ Komponen wajib belum lengkap (lihat instruksi).`);
}
compileOutput = messages.join('\n');
compileSuccess = allPassed;
circuitOutput = messages.join('\n');
circuitSuccess = allPassed;
if (allPassed) {
showCelebration = true;
@ -241,10 +222,10 @@
}, 3000);
}
} catch (err: any) {
compileError = `Evaluasi gagal: ${err.message}`;
compileSuccess = false;
circuitError = `Evaluasi gagal: ${err.message}`;
circuitSuccess = false;
} finally {
compiling = false;
circuitLoading = false;
}
}
@ -254,10 +235,10 @@
return;
}
if (!data) return;
compiling = true;
compileOutput = '';
compileError = '';
compileSuccess = null;
codeLoading = true;
codeOutput = '';
codeError = '';
codeSuccess = null;
activeTab = 'output';
try {
@ -265,8 +246,8 @@
const res = await compileCode({ code, language: data.language });
if (res.success) {
compileOutput = res.output;
compileSuccess = true;
codeOutput = res.output;
codeSuccess = true;
if (data.expected_output) {
const outputMatch = res.output.trim() === data.expected_output.trim();
@ -291,14 +272,14 @@
}
}
} else {
compileError = res.error || 'Compilation failed';
compileSuccess = false;
codeError = res.error || 'Compilation failed';
codeSuccess = false;
}
} catch {
compileError = 'Gagal terhubung ke server';
compileSuccess = false;
codeError = 'Gagal terhubung ke server';
codeSuccess = false;
} finally {
compiling = false;
codeLoading = false;
}
}
@ -306,13 +287,16 @@
if (!data) return;
if (activeTab === 'circuit') {
circuitEditor?.setCircuitText(data.initial_circuit || data.initial_code);
circuitOutput = '';
circuitError = '';
circuitSuccess = null;
} else {
currentCode = data.initial_code;
editor?.setCode(data.initial_code);
codeOutput = '';
codeError = '';
codeSuccess = null;
}
compileOutput = '';
compileError = '';
compileSuccess = null;
}
function handleShowSolution() {
@ -352,24 +336,7 @@
oncontextmenu={(e) => e.preventDefault()}>
<div class="prose">{@html data.lesson_content}</div>
<!-- All lessons list -->
{#if data.ordered_lessons?.length}
<div class="all-lessons">
<h3 class="all-lessons-heading">Semua Pelajaran</h3>
<div class="all-lessons-list">
{#each data.ordered_lessons as lesson (lesson.filename)}
<a href="/lesson/{lesson.filename}"
class="lesson-item"
class:lesson-item-active={lesson.filename === slug}>
{#if lesson.completed}
<span class="lesson-check">&#10003;</span>
{/if}
<span class="lesson-item-title">{lesson.title}</span>
</a>
{/each}
</div>
</div>
{/if}
<LessonList lessons={data.ordered_lessons ?? []} currentSlug={slug} />
</div>
<!-- Floating restore button (visible when minimized) -->
@ -391,60 +358,23 @@
class:mobile-full={isMobile && mobileMode === 'full'}
style={float.style}>
<!-- Panel header -->
{#if isMobile}
<button class="panel-header sheet-handle"
ontouchstart={onSheetTouchStart}
ontouchend={onSheetTouchEnd}
onclick={cycleMobileSheet}>
<div class="sheet-handle-bar"></div>
<span class="panel-title">Workspace</span>
</button>
{:else if float.floating && !float.minimized}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="panel-header draggable" onmousedown={float.onDragStart}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span class="resize-handle" onmousedown={(e) => { e.stopPropagation(); float.onResizeStart(e); }} title="Resize">&#x25F3;</span>
<span class="panel-title">Workspace</span>
<div class="panel-actions">
<button type="button" class="panel-btn" onclick={float.minimize}
title="Minimize">▽</button>
<button type="button" class="panel-btn" onclick={float.toggle}
title="Dock editor">⊡</button>
</div>
</div>
{:else if !isMobile}
<div class="panel-header">
<span class="panel-title">Workspace</span>
<div class="panel-actions">
<button type="button" class="btn-float-toggle" onclick={float.toggle} title="Float editor">&#x229E;</button>
</div>
</div>
{/if}
<WorkspaceHeader
{isMobile}
bind:mobileMode
bind:activeTab
hasInfo={!!data.lesson_info}
hasExercise={!!data.exercise_content}
activeTabs={data.active_tabs ?? []}
floating={float.floating}
minimized={float.minimized}
onDragStart={float.onDragStart}
onResizeStart={float.onResizeStart}
onFloatToggle={float.toggle}
onMinimize={float.minimize}
/>
<!-- Editor body -->
<div class="editor-body" bind:this={tabsEl} class:body-hidden={isMobile && mobileMode === 'hidden'}>
<!-- Tabs -->
<div class="panel-tabs">
{#if data.lesson_info}
<button class="tab" class:active={activeTab === 'info'}
onclick={() => (activeTab = 'info')}>Informasi</button>
{/if}
{#if data.exercise_content}
<button class="tab" class:active={activeTab === 'exercise'}
onclick={() => (activeTab = 'exercise')}>Exercise</button>
{/if}
{#if !data.active_tabs || data.active_tabs.length === 0 || data.active_tabs.includes('c') || data.active_tabs.includes('python')}
<button class="tab" class:active={activeTab === 'editor'}
onclick={() => (activeTab = 'editor')}>Code Editor</button>
{/if}
{#if data.active_tabs?.includes('circuit')}
<button class="tab" class:active={activeTab === 'circuit'}
onclick={() => (activeTab = 'circuit')}>Circuit Simulator</button>
{/if}
<button class="tab" class:active={activeTab === 'output'}
onclick={() => (activeTab = 'output')}>Output</button>
</div>
<div class="editor-body" bind:this={tabsEl}>
<!-- Info tab panel -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
@ -537,10 +467,10 @@
<!-- Output tab panel -->
<div class="tab-panel" class:tab-hidden={activeTab !== 'output'}>
<OutputPanel
output={compileOutput}
error={compileError}
loading={compiling}
success={compileSuccess}
code={{ output: codeOutput, error: codeError, loading: codeLoading, success: codeSuccess }}
circuit={{ output: circuitOutput, error: circuitError, loading: circuitLoading, success: circuitSuccess }}
hasCode={!data.active_tabs || data.active_tabs.length === 0 || data.active_tabs.includes('c') || data.active_tabs.includes('python')}
hasCircuit={data.active_tabs?.includes('circuit') ?? false}
/>
</div>
</div>
@ -625,64 +555,6 @@
margin-bottom: 0.5rem;
}
/* ── All lessons list ──────────────────────────────────── */
.all-lessons {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--color-border);
}
.all-lessons-heading {
font-size: 0.9rem;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.5rem;
}
.all-lessons-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.lesson-item {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.6rem;
border-radius: 6px;
font-size: 0.82rem;
color: var(--color-text);
text-decoration: none;
transition: background 0.12s;
}
.lesson-item:hover {
background: var(--color-bg-secondary);
text-decoration: none;
color: var(--color-text);
}
.lesson-item-active {
background: var(--color-primary);
color: #fff;
font-weight: 600;
}
.lesson-item-active:hover {
background: var(--color-primary-dark);
color: #fff;
}
.lesson-check {
color: var(--color-success);
font-size: 0.75rem;
flex-shrink: 0;
}
.lesson-item-active .lesson-check {
color: #fff;
}
.lesson-item-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Editor area (docked mode) ──────────────────────────── */
.editor-area {
position: sticky;
@ -774,63 +646,6 @@
transform: scale(0.95);
}
/* ── Float toggle button ───────────────────────────────── */
.btn-float-toggle {
background: none;
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 0.2rem 0.5rem;
cursor: pointer;
font-size: 0.95rem;
color: var(--color-text-muted);
line-height: 1;
}
.btn-float-toggle:hover {
background: var(--color-bg-secondary);
color: var(--color-text);
}
/* ── Panel header ───────────────────────────────────────── */
.panel-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
user-select: none;
cursor: default;
flex-wrap: wrap;
}
.panel-header.draggable {
cursor: grab;
}
.panel-header.draggable:active {
cursor: grabbing;
}
.panel-title {
font-size: 0.85rem;
font-weight: 600;
flex: 1;
}
.panel-actions {
display: flex;
gap: 0.25rem;
}
.panel-btn {
background: none;
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 0.15rem 0.5rem;
cursor: pointer;
font-size: 0.8rem;
color: var(--color-text);
line-height: 1;
}
.panel-btn:hover {
background: var(--color-border);
}
/* ── Desktop floating mode ─────────────────────────────── */
.editor-area.floating {
position: fixed;
@ -850,29 +665,17 @@
flex-direction: column;
overflow: hidden;
}
.resize-handle {
cursor: nwse-resize;
font-size: 0.9rem;
color: var(--color-text-muted);
line-height: 1;
padding: 0.1rem 0.3rem;
border-radius: 3px;
}
.resize-handle:hover {
background: var(--color-border);
color: var(--color-text);
}
.editor-area.floating-hidden {
display: none !important;
}
/* ── Mobile bottom sheet ──────────────────────────────── */
/* ── Mobile bottom sheet ──────────────────────────────── */
.editor-area.mobile-sheet {
position: fixed;
top: auto;
bottom: 0;
left: 0;
right: 0;
top: auto;
z-index: 9999;
background: var(--color-bg);
border-top: 2px solid var(--color-primary);
@ -880,66 +683,64 @@
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
transition: max-height 0.3s ease, transform 0.3s ease;
overflow: hidden;
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1), border-radius 0.2s ease;
}
.editor-area.mobile-hidden {
max-height: 100vh;
transform: translateY(calc(100% - 48px));
height: 52px;
}
.editor-area.mobile-half {
max-height: 60vh;
transform: translateY(0);
height: 60vh;
}
.editor-area.mobile-full {
max-height: calc(100vh - 3rem);
top: 3rem;
height: calc(100vh - 3rem);
border-radius: 0;
transform: translateY(0);
}
.mobile-sheet .editor-body {
overscroll-behavior: contain;
}
.sheet-handle {
flex-direction: column;
border: none;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
width: 100%;
color: inherit;
font: inherit;
text-align: center;
}
.sheet-handle-bar {
width: 36px;
height: 4px;
background: var(--color-border);
border-radius: 2px;
margin: 0 auto 0.25rem;
}
/* ── Tabs ─────────────────────────────────────────────── */
.panel-tabs {
display: flex;
gap: 0;
margin-bottom: 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
overflow: hidden;
}
.tab {
/* ── Mobile full: expand content to fill ────────────── */
.editor-area.mobile-full .editor-body {
flex: 1;
padding: 0.5rem;
border: none;
background: var(--color-bg-secondary);
color: var(--color-text);
cursor: pointer;
font-weight: 500;
font-size: 0.85rem;
white-space: nowrap;
display: flex;
flex-direction: column;
min-height: 0;
}
.tab.active {
background: var(--color-primary);
color: #fff;
.editor-area.mobile-full .tab-panel:not(.tab-hidden) {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.editor-area.mobile-full .panel {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.editor-area.mobile-full :global(.circuit-container) {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.editor-area.mobile-full :global(.circuit-wrapper) {
flex: 1;
height: auto;
}
.editor-area.mobile-full :global(.editor-wrapper) {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.editor-area.mobile-full :global(.cm-editor) {
flex: 1;
max-height: none;
min-height: 0;
}
.editor-area.mobile-full :global(.cm-scroller) {
flex: 1;
}
/* ── Tab panels ────────────────────────────────────────── */
@ -950,8 +751,4 @@
display: none;
}
/* ── Utility ───────────────────────────────────────────── */
.editor-body.body-hidden {
display: none;
}
</style>

View File

@ -37,7 +37,6 @@
overflow: hidden;
}
.gwt-Frame{
scrolling="no";
border: 0px !important;
}
@ -64,8 +63,6 @@
color: LightGray;
}
.gwt-Label-current {
}
.gwt-Button.chbut {
padding:3px;

View File

@ -28,6 +28,7 @@ services:
- PUBLIC_APP_BAR_TITLE=${APP_BAR_TITLE}
- PUBLIC_COPYRIGHT_TEXT=${COPYRIGHT_TEXT}
- PUBLIC_PAGE_TITLE_SUFFIX=${PAGE_TITLE_SUFFIX}
- PUBLIC_CURSOR_OFFSET_Y=${CURSOR_OFFSET_Y:-50}
depends_on:
- elemes

123
proposal.md Normal file
View File

@ -0,0 +1,123 @@
# Proposal: Studi Kelayakan dan Perencanaan Implementasi CircuitJS1 di Elemes LMS
## Pendahuluan
Dokumen ini merupakan proposal dan studi kelayakan untuk mengintegrasikan **[CircuitJS1](https://github.com/pfalstad/circuitjs1)** (sebuah simulator rangkaian elektronik berbasis web) ke dalam ekosistem Elemes LMS. Tujuannya adalah untuk mentransisikan atau menambahkan kapabilitas LMS yang saat ini fokus pada pemrograman (Code Editor) menjadi platform pembelajaran rangkaian elektronik yang interaktif.
---
## 1. Cara Implementasi dengan Sistem Elemes yang Sekarang
Sistem Elemes saat ini terdiri dari Backend (Flask) dan Frontend (SvelteKit). Implementasi CircuitJS1 dapat dilakukan dengan mulus karena CircuitJS1 berjalan sepenuhnya di sisi klien (browser).
### a. Integrasi Frontend (SvelteKit)
- **Komponen Baru (`CircuitEditor.svelte`):** Komponen `CodeEditor.svelte` (berbasis CodeMirror) akan diganti atau didampingi dengan komponen baru yang memuat CircuitJS1.
- **Metode Embed:** CircuitJS1 dapat diintegrasikan menggunakan elemen `<iframe>` (dengan file HTML bawaan CircuitJS) yang disematkan di dalam halaman `/lesson/[slug]`. Alternatif lainnya adalah me-load file JavaScript CircuitJS secara langsung ke dalam DOM container SvelteKit.
- **Manajemen State (Auto-save):** CircuitJS1 memiliki fitur *Export/Import as Text*. Teks representasi rangkaian ini akan diperlakukan sama seperti "kode sumber" pada sistem saat ini. Teks tersebut dapat disimpan ke dalam `sessionStorage` (untuk auto-save) dan di-load kembali saat halaman di-refresh.
### b. Peran Backend (Flask)
- Karena kompilasi dan simulasi berjalan di browser (menggunakan JavaScript/HTML5), fungsi kompilasi di backend (`/api/compile` dan *compiler factory*) tidak lagi diperlukan untuk pelajaran elektronik.
- Backend hanya perlu fokus pada *API proxy*, autentikasi token (`/api/login`), penyajian konten pelajaran, dan pencatatan progres (`/api/track-progress`).
---
## 2. Cara Sistem Melakukan Evaluasi Terhadap Rangkaian Siswa
Pada sistem pemrograman C/Python, evaluasi dilakukan dengan menangkap `stdout` (Output) dari eksekusi kode di backend. Untuk rangkaian listrik, evaluasi dipindah ke **sisi klien (Frontend)** dengan dua pendekatan yang bisa digabungkan:
### a. Pendekatan Statis (Pencocokan Teks/Komponen)
CircuitJS merepresentasikan rangkaian dalam bentuk string teks baris per baris.
- Saat pengguna menekan tombol **"Submit" / "Cek Rangkaian"**, SvelteKit akan meminta teks representasi rangkaian dari iframe CircuitJS.
- Sistem akan mengecek ketersediaan komponen-komponen wajib menggunakan fitur yang mengadopsi mekanisme `---KEY_TEXT---` saat ini. Misalnya, memastikan ada resistor (`r`), sumber tegangan (`v`), atau LED dalam teks rangkaian siswa.
### b. Pendekatan Dinamis (Pencocokan Nilai Simulasi)
Karena CircuitJS disajikan secara *Same-Origin* (satu *domain/port* dengan SvelteKit), Elemes dapat mengakses langsung API global dari simulator tersebut tanpa batasan CORS.
Dengan pendekatan ini, data yang dimasukkan instruktur pada tag `---EXPECTED_STATE---` adalah berformat **JSON** yang mendefinisikan kriteria kelulusan simulasi.
**Contoh Format JSON `---EXPECTED_STATE---` di Markdown**:
```json
{
"nodes": {
"TestPoint_A": { "voltage": 5.0, "tolerance": 0.5 }
},
"elements": {
"Resistor_1": { "voltage_drop": 2.5 }
}
}
```
Sistem SvelteKit (melalui JavaScript) secara periodik akan mengekstrak state aktual komponen dengan merujuk pada **[Dokumentasi jsinterface CircuitJS1 (GitHub)](https://github.com/pfalstad/circuitjs1/blob/master/war/jsinterface.html)** atau melalui **[Live Example Interaktifnya](https://www.falstad.com/circuit/jsinterface.html)**:
1. Menarik API Simulator: `var sim = iframe.contentWindow.CircuitJS1;`
2. **`sim.getNodeVoltage(String n)`**: Mengambil nilai tegangan spesifik pada node observasi (misal node berlabel `TestPoint_A`).
3. **`sim.getElements()`**: Mengambil sekumpulan *array* objek komponen yang aktif di *canvas*. Frontend kemudian akan memanipulasinya (contoh pencarian berdasarkan indeks/teks properti) lalu memanggil properti internal seperti `.getVoltage(0)` atau *current*-nya.
- SvelteKit mencocokkan kondisi aktual simulator vs ekspektasi JSON. (*Contoh: Apakah tegangan di TestPoint_A masuk dalam range 4.5V s.d. 5.5V?*)
- Jika seluruh evaluasi menyatakan **LULUS**, maka SvelteKit mengirimkan HTTP request `POST /api/track-progress` ke Flask Backend untuk mencatat kelulusan siswa, lalu memunculkan *CelebrationOverlay* (kembang api).
---
## 3. Mengikuti Pola Pembuatan Konten yang Sekarang (Markdown)
Salah satu keunggulan arsitektur Elemes saat ini adalah kemudahan membuat konten hanya dengan file Markdown (`.md`) dan pemisah teks (delimiter). **Pola ini dapat dipertahankan 100% tanpa mengubah struktur parser backend secara masif.**
Pembuat konten (guru) cukup membuat rangkaian di CircuitJS versi publik, melakukan *Export as Text*, lalu memasukkannya ke dalam format Markdown Elemes.
**Pemetaan Delimiter Markdown Baru:**
| Tipe C/C++ (Default) | Tipe Python (Baru) | Tipe Electronics | Tipe Kuis (Tambahan) | Keterangan / Fungsi pada Modul |
|---|---|---|---|---|
| `---LESSON_INFO---` | `---LESSON_INFO---` | `---LESSON_INFO---` | `---LESSON_INFO---` | Sama. Berisi tujuan pembelajaran. |
| `---EXERCISE---` | `---EXERCISE---` | `---EXERCISE---` | `---EXERCISE---` | Sama. Soal instruksi untuk siswa. |
| `---INITIAL_CODE---` | `---INITIAL_PYTHON---` | `---INITIAL_CIRCUIT---` | `---INITIAL_QUIZ---` | Initial blok kode, simulator, atau *payload* kuis (JSON) saat dibuka. |
| `---SOLUTION_CODE---` | `---SOLUTION_PYTHON---` | `---SOLUTION_CIRCUIT---` | `---SOLUTION_QUIZ---` | Solusi akhir yang benar (kunci jawaban). |
| `---KEY_TEXT---` | `---KEY_TEXT---` | `---KEY_TEXT---` | `---KEY_TEXT---` | Komponen wajib atau validasi teks statis. |
| `---EXPECTED_OUTPUT---`| `---EXPECTED_OUTPUT---`| `---EXPECTED_STATE---` | `---EXPECTED_JSON---` | Output/Rules/Format evaluasi untuk hit *Track Progress*. |
### Konfigurasi Multi-Tab Workspace (Mode Hybrid)
Agar sistem Elemes tahu antarmuka mana yang perlu dimuat, kita akan menggunakan **Pendekatan Implisit Kolektif**.
Alih-alih memaksa satu modul *hanya* menjadi "pelajaran C" atau "pelajaran Elektronika", parser SvelteKit dan Backend akan bersikap asimilatif. Jika seorang instruktur (misalnya untuk modul *Embedded System* berbekal AVR8js) memasukkan semua tag sekaligus ke dalam satu materi, sistem akan merender **Sistem Multi-Tab di Workspace**.
Cara kerjanya (Pendeteksian Paralel):
- Apakah `---INITIAL_CODE---` ada? -> Aktifkan Tab **CodeEditor (C/C++)**.
- Apakah `---INITIAL_PYTHON---` ada? -> Aktifkan Tab **CodeEditor (Python)**.
- Apakah `---INITIAL_CIRCUIT---` ada? -> Aktifkan Tab **CircuitEditor**.
- Apakah `---INITIAL_QUIZ---` ada? -> Aktifkan Tab **QuizPanel**.
Visi ini sangat **Backward Compatible** dan sangat visioner secara infrastruktur. Ratusan materi lama Anda yang secara konvensional hanya memiliki tag `_CODE` murni akan tetap tampil sebagai satu layar editor penuh C/C++. Sedangkan materi yang kompleks di masa depan bisa memadukan keempat fungsi (*C, Python, Simulator, Quiz*) dalam sistem tab yang elegan.
**Workflow Pembuatan Konten oleh Guru:**
1. Buka CircuitJS di browser.
2. Gambar rangkaian "awal" (sebagian belum lengkap). Klik *File -> Export as Text*, salin ke dalam tag `---INITIAL_CIRCUIT---`.
3. Selesaikan rangkaiannya. Klik *Export as Text*, salin ke dalam tag `---SOLUTION_CIRCUIT---`.
4. Definisikan komponen yang diwajibkan di `---KEY_TEXT---`.
5. Siswa membuka Elemes LMS, SvelteKit mem-parsing string `INITIAL_CIRCUIT` dan menampilkannya sebagai rangkaian hidup di dalam CircuitJS.
## 4. Analisa Source Code & Refactoring Backend
Sistem `elemes/services/lesson_service.py` saat ini (`render_markdown_content`) memiliki parser *hardcoded* yang mengembalikan `7 item tuple` yang sangat restriktif. Menambahkan data elektronika ke *tuple* ini secara mentah akan menghasilkan rantai kembalian panjang (12+ item) yang rawan eror.
Oleh karena itu, bagian teknis dari proposal ini menyertakan perbaikan (*refactoring*) pada *blueprint* backend:
### a. Modifikasi `elemes/services/lesson_service.py`
- Mengubah titik temu fungsi `render_markdown_content` menjadi tipe data **Dictionary** (*key-value*) bernama `parsed_data`.
- Menerapkan pola deteksi Kolektif: Fungsi *extract* akan memverifikasi ekstensi tag dan mengumpulkannya ke dalam *array* metadata, misal `active_tabs`.
- Jika `---INITIAL_CODE---` ada -> `active_tabs.append("c")`
- Jika `---INITIAL_PYTHON---` ada -> `active_tabs.append("python")`
- Jika `---INITIAL_CIRCUIT---` ada -> `active_tabs.append("circuit")`
- Jika `---INITIAL_QUIZ---` ada -> `active_tabs.append("quiz")`
### b. Modifikasi Router `elemes/routes/lessons.py`
- Menyesuaikan API endpoint `/lesson/[slug].json` agar memproses keluaran `active_tabs` ini dengan format JSON yang valid ke Frontend.
### c. Modifikasi Frontend SvelteKit (`elemes/frontend/src/routes/lesson/[slug]/+page.svelte`)
- Berdasarkan **analisa *source code* UI saat ini**, komponen *Workspace* Elemes sudah memiliki infrastruktur `<div class="panel-tabs">` dan skema *state* bawaan.
- *State type* bawaan `activeTab` yang semula hanya menampung tipe statis (`'info'|'exercise'|'editor'|'output'`) akan diperlebar menjadi dinamis (`'editor_c' | 'editor_python' | 'circuit' | 'quiz'`).
- SvelteKit cukup melacak *array* `active_tabs` rakitan backend untuk membangun komponen *tab/button* secara kondisional. Jika *array* lebih dari 1, *tab* aktif bergantian (*Embedded Systems Mode*). Jika *array* hanya 1 (pada file lama), antarmuka langsung kembali ke 100% *full-editor* seperti kondisi *legacy*.
---
## Kesimpulan
Perubahan dari LMS pemrograman ke LMS simulasi rangkaian menggunakan CircuitJS1 **sangat feasible (layak)** dan **hemat sumber daya** karena:
1. Tidak membutuhkan komputasi simulasi berat di server (semua dilimpahkan ke client secara penuh).
2. Mempertahankan gaya pembuatan konten berbasis Markdown yang *author-friendly*; menggunakan pendekatan pendeteksian tag *Implisit* (redundansi pengetikan nol, 100% backward-compatible dengan skenario C/Python lama).
3. Proses migrasinya sangat terpusat: hanya butuh merapikan *parser tuple-to-dictionary* di backend dan membuat *komponen UI tunggal iframe CircuitJS1* yang diletakkan pada SvelteKit frontend.

View File

@ -4,6 +4,7 @@ Lesson loading, ordering, and markdown rendering.
import os
import re
import html as html_module
from functools import lru_cache
import markdown as md
@ -197,6 +198,42 @@ def get_ordered_lessons_with_learning_objectives(progress=None):
MD_EXTENSIONS = ['fenced_code', 'tables', 'nl2br', 'toc']
def _process_circuit_embeds(text):
"""Replace ```circuit[,width][,height] code fences with embeddable HTML divs.
Supported formats:
```circuit -> width=100%, height=400px
```circuit,500px -> width=100%, height=500px
```circuit,80%,500px -> width=80%, height=500px
"""
pattern = re.compile(
r'```circuit(?:,([^\s,`]+))?(?:,([^\s,`]+))?\s*\n(.*?)```',
re.DOTALL,
)
def _replacer(match):
param1 = match.group(1)
param2 = match.group(2)
# One param = height only; two params = width, height
if param1 and param2:
width, height = param1, param2
elif param1:
width, height = '100%', param1
else:
width, height = '100%', '400px'
data = html_module.escape(match.group(3).strip())
return (
f'<div class="circuit-embed" '
f'data-width="{html_module.escape(width)}" '
f'data-height="{html_module.escape(height)}">'
f'<pre class="circuit-data" style="display:none">{data}</pre>'
f'<div class="circuit-embed-loading">Memuat simulator...</div>'
f'</div>'
)
return pattern.sub(_replacer, text)
def _extract_section(content, start_marker, end_marker):
"""Extract text between markers and return (extracted, remaining_content)."""
if start_marker not in content or end_marker not in content:
@ -284,6 +321,13 @@ def render_markdown_content(file_path):
lesson_content = parts[0] if parts else lesson_content
exercise_content = parts[1] if len(parts) > 1 else ""
# Convert ```circuit fences to embed divs before markdown rendering
lesson_content = _process_circuit_embeds(lesson_content)
if exercise_content:
exercise_content = _process_circuit_embeds(exercise_content)
if lesson_info:
lesson_info = _process_circuit_embeds(lesson_info)
lesson_html = md.markdown(lesson_content, extensions=MD_EXTENSIONS)
exercise_html = md.markdown(exercise_content, extensions=MD_EXTENSIONS) if exercise_content else ""
lesson_info_html = md.markdown(lesson_info, extensions=MD_EXTENSIONS) if lesson_info else ""