feat(flowchart): implement text-based logic with auto-layout and migrate evaluation to frontend
This commit is contained in:
parent
0ff56ed9d2
commit
433c095394
|
|
@ -16,6 +16,8 @@
|
|||
"svelte": "^5.55.4",
|
||||
"svelte-check": "^4.4.6",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.10"
|
||||
"vite": "^8.0.10",
|
||||
"dagre": "^0.8.5",
|
||||
"@types/dagre": "^0.7.52"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
import Properties from './lib/Properties.svelte';
|
||||
import Canvas from './lib/Canvas.svelte';
|
||||
import { fcState } from './lib/flowchartState.svelte';
|
||||
import { parseFlowchartText, exportToFlowchartText } from './lib/parser';
|
||||
import { applyAutoLayout } from './lib/layout';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
onMount(() => {
|
||||
|
|
@ -21,26 +23,42 @@
|
|||
window.addEventListener('message', (event) => {
|
||||
if (event.data?.type === 'FLOWCHART_LOAD') {
|
||||
try {
|
||||
const payload = JSON.parse(event.data.payload);
|
||||
const payload = typeof event.data.payload === 'string' ? JSON.parse(event.data.payload) : event.data.payload;
|
||||
|
||||
function processData(data: any) {
|
||||
if (typeof data === 'string' && !data.trim().startsWith('{')) {
|
||||
const { shapes, arrows } = parseFlowchartText(data);
|
||||
return applyAutoLayout(shapes, arrows);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// Legacy format (just data object)
|
||||
if (!payload.initialData && !payload.draftData && payload.shapes) {
|
||||
fcState.initialData = payload;
|
||||
fcState.loadData(payload);
|
||||
if (!payload.initialData && !payload.draftData && (payload.shapes || typeof payload === 'string')) {
|
||||
const data = processData(payload);
|
||||
fcState.initialData = data;
|
||||
fcState.loadData(data);
|
||||
} else {
|
||||
// New format { initialData, draftData }
|
||||
if (payload.initialData) {
|
||||
fcState.initialData = payload.initialData;
|
||||
fcState.initialData = processData(payload.initialData);
|
||||
}
|
||||
if (payload.draftData) {
|
||||
fcState.loadData(payload.draftData);
|
||||
fcState.loadData(processData(payload.draftData));
|
||||
} else if (payload.initialData) {
|
||||
fcState.loadData(payload.initialData);
|
||||
fcState.loadData(processData(payload.initialData));
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
console.error("Invalid data format", e);
|
||||
}
|
||||
} else if (event.data?.type === 'FLOWCHART_GET_TEXT') {
|
||||
const text = exportToFlowchartText(fcState.shapes, fcState.arrows);
|
||||
window.parent.postMessage({
|
||||
type: 'FLOWCHART_TEXT_RESPONSE',
|
||||
requestId: event.data.requestId,
|
||||
payload: text
|
||||
}, '*');
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@
|
|||
<span class="label-text">Warna Isi</span>
|
||||
<div class="color-palette">
|
||||
{#each predefinedColors as color}
|
||||
<button class="color-btn" style="background: {color};" class:active={sharedFillColor === color} onclick={() => updateShape('fillColor', color)}></button>
|
||||
<button class="color-btn" title={color} style="background: {color};" class:active={sharedFillColor === color} onclick={() => updateShape('fillColor', color)}></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -87,7 +87,7 @@
|
|||
<span class="label-text">Warna Garis</span>
|
||||
<div class="color-palette">
|
||||
{#each predefinedColors as color}
|
||||
<button class="color-btn" style="background: {color};" class:active={sharedStrokeColor === color} onclick={() => {
|
||||
<button class="color-btn" title={color} style="background: {color};" class:active={sharedStrokeColor === color} onclick={() => {
|
||||
if (selectedShapes.length) updateShape('strokeColor', color);
|
||||
if (selectedArrows.length) updateArrow('strokeColor', color);
|
||||
}}></button>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { fcState } from './flowchartState.svelte';
|
||||
import { parseFlowchartText, exportToFlowchartText } from './parser';
|
||||
import { applyAutoLayout } from './layout';
|
||||
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
|
|
@ -27,9 +29,12 @@
|
|||
}
|
||||
|
||||
function handleNew() {
|
||||
if (fcState.isIframeMode && fcState.initialData) {
|
||||
if (fcState.isIframeMode) {
|
||||
if (confirm('Kembalikan flowchart ke kondisi awal? Perubahan yang belum disimpan akan hilang.')) {
|
||||
fcState.loadData(fcState.initialData);
|
||||
// Notify parent to reset and re-send initial data
|
||||
if (window.parent && window.parent !== window) {
|
||||
window.parent.postMessage({ type: 'FLOWCHART_RESET' }, '*');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (confirm('Buat flowchart baru? Perubahan yang belum disimpan akan hilang.')) {
|
||||
|
|
@ -47,14 +52,8 @@
|
|||
}
|
||||
|
||||
function handleExport() {
|
||||
const data = JSON.stringify({
|
||||
shapes: fcState.shapes,
|
||||
arrows: fcState.arrows,
|
||||
zoom: fcState.zoom,
|
||||
panX: fcState.panX,
|
||||
panY: fcState.panY
|
||||
});
|
||||
const blob = new Blob([data], { type: 'application/json' });
|
||||
const text = exportToFlowchartText(fcState.shapes, fcState.arrows);
|
||||
const blob = new Blob([text], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
|
|
@ -75,9 +74,18 @@
|
|||
reader.onload = (ev) => {
|
||||
try {
|
||||
const content = ev.target?.result as string;
|
||||
const data = JSON.parse(content);
|
||||
fcState.loadData(data);
|
||||
if (content.trim().startsWith('{')) {
|
||||
// Legacy JSON support
|
||||
const data = JSON.parse(content);
|
||||
fcState.loadData(data);
|
||||
} else {
|
||||
// New Text-based parsing with Auto-Layout
|
||||
const { shapes, arrows } = parseFlowchartText(content);
|
||||
const layouted = applyAutoLayout(shapes, arrows);
|
||||
fcState.loadData(layouted);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert("Format file tidak valid!");
|
||||
}
|
||||
};
|
||||
|
|
@ -149,7 +157,7 @@
|
|||
<!-- MOBILE MENU ACTION -->
|
||||
<div class="topbar-actions mobile-only">
|
||||
<div class="dropdown-container">
|
||||
<button class="topbar-btn" onclick={(e) => { e.stopPropagation(); isMenuOpen = !isMenuOpen; }}>
|
||||
<button class="topbar-btn" title="Menu" onclick={(e) => { e.stopPropagation(); isMenuOpen = !isMenuOpen; }}>
|
||||
<svg><use href="#icon-menu"/></svg>
|
||||
</button>
|
||||
{#if isMenuOpen}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
import dagre from 'dagre';
|
||||
import type { Shape, Arrow } from './flowchartState.svelte';
|
||||
|
||||
export function applyAutoLayout(shapes: Shape[], arrows: Arrow[]): { shapes: Shape[], arrows: Arrow[] } {
|
||||
const g = new dagre.graphlib.Graph();
|
||||
|
||||
// Set an object for the graph label
|
||||
g.setGraph({
|
||||
rankdir: 'TB',
|
||||
nodesep: 50,
|
||||
ranksep: 70,
|
||||
marginx: 50,
|
||||
marginy: 50
|
||||
});
|
||||
|
||||
// Default to assigning a new object as a label for each edge.
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
// Add nodes to the graph
|
||||
shapes.forEach(shape => {
|
||||
// Estimate width based on text length if it's too long
|
||||
const estimatedW = Math.max(shape.w, shape.text.length * 8 + 40);
|
||||
g.setNode(shape.id, { width: estimatedW, height: shape.h });
|
||||
// Update shape width for the canvas
|
||||
shape.w = estimatedW;
|
||||
});
|
||||
|
||||
// Add edges to the graph
|
||||
arrows.forEach(arrow => {
|
||||
g.setEdge(arrow.fromId, arrow.toId);
|
||||
});
|
||||
|
||||
// Calculate layout
|
||||
dagre.layout(g);
|
||||
|
||||
// Update shape coordinates
|
||||
shapes.forEach(shape => {
|
||||
const node = g.node(shape.id);
|
||||
if (node) {
|
||||
// Dagre uses center coordinates, we use top-left
|
||||
shape.x = node.x - node.width / 2;
|
||||
shape.y = node.y - node.height / 2;
|
||||
}
|
||||
});
|
||||
|
||||
// For arrows, we let the canvas logic or a simple straight line handle it for now
|
||||
// unless we want to use dagre's edge points.
|
||||
// Straight lines are easier to manage with manual dragging later.
|
||||
arrows.forEach(arrow => {
|
||||
arrow.points = []; // Reset points for straight lines
|
||||
arrow.routing = 'straight';
|
||||
});
|
||||
|
||||
return { shapes, arrows };
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
import type { Shape, Arrow } from './flowchartState.svelte';
|
||||
|
||||
export interface ParsedData {
|
||||
shapes: Shape[];
|
||||
arrows: Arrow[];
|
||||
}
|
||||
|
||||
export function parseFlowchartText(text: string): ParsedData {
|
||||
const lines = text.split('\n');
|
||||
const shapes: Shape[] = [];
|
||||
const arrows: Arrow[] = [];
|
||||
const shapeMap = new Map<string, Shape>();
|
||||
|
||||
// Regex patterns
|
||||
// Node: id[type] "Label"
|
||||
const nodeRegex = /^(\w+)(?:\[(\w+)\])?(?:\s+"([^"]+)")?$/;
|
||||
// Edge: from --> to "Label"
|
||||
const edgeRegex = /^(\w+)\s*-->\s*(\w+)(?:\s+"([^"]+)")?$/;
|
||||
|
||||
let nextAutoId = 1;
|
||||
|
||||
function getOrCreateShape(id: string, typeStr?: string, label?: string): Shape {
|
||||
if (shapeMap.has(id)) {
|
||||
const existing = shapeMap.get(id)!;
|
||||
if (typeStr) existing.type = typeStr as any;
|
||||
if (label) existing.text = label;
|
||||
return existing;
|
||||
}
|
||||
|
||||
const shape: Shape = {
|
||||
id,
|
||||
type: (typeStr as any) || 'rect',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 180,
|
||||
h: 50,
|
||||
r: typeStr === 'roundrect' ? 25 : 0,
|
||||
text: label || id,
|
||||
fillColor: '#ffffff',
|
||||
strokeColor: '#000000',
|
||||
strokeWidth: 2
|
||||
};
|
||||
|
||||
// Adjust defaults based on type
|
||||
if (shape.type === 'circle') {
|
||||
shape.w = 80;
|
||||
shape.h = 80;
|
||||
} else if (shape.type === 'diamond') {
|
||||
shape.w = 150;
|
||||
shape.h = 80;
|
||||
}
|
||||
|
||||
shapes.push(shape);
|
||||
shapeMap.set(id, shape);
|
||||
return shape;
|
||||
}
|
||||
|
||||
for (let line of lines) {
|
||||
line = line.trim();
|
||||
if (!line || line.startsWith('#') || line.startsWith('//')) continue;
|
||||
|
||||
const edgeMatch = line.match(edgeRegex);
|
||||
if (edgeMatch) {
|
||||
const [, fromId, toId, label] = edgeMatch;
|
||||
getOrCreateShape(fromId);
|
||||
getOrCreateShape(toId);
|
||||
|
||||
arrows.push({
|
||||
id: `a_${nextAutoId++}`,
|
||||
type: 'arrow',
|
||||
fromId,
|
||||
toId,
|
||||
label: label || '',
|
||||
strokeColor: '#000000',
|
||||
strokeWidth: 2,
|
||||
routing: 'straight',
|
||||
points: []
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const nodeMatch = line.match(nodeRegex);
|
||||
if (nodeMatch) {
|
||||
const [, id, type, label] = nodeMatch;
|
||||
getOrCreateShape(id, type, label);
|
||||
}
|
||||
}
|
||||
|
||||
return { shapes, arrows };
|
||||
}
|
||||
|
||||
export function exportToFlowchartText(shapes: Shape[], arrows: Arrow[]): string {
|
||||
let text = "";
|
||||
|
||||
// Export Nodes
|
||||
shapes.forEach(s => {
|
||||
text += `${s.id}[${s.type}] "${s.text.replace(/"/g, '\\"')}"\n`;
|
||||
});
|
||||
|
||||
if (shapes.length > 0) text += "\n";
|
||||
|
||||
// Export Edges
|
||||
arrows.forEach(a => {
|
||||
const label = a.label ? ` "${a.label.replace(/"/g, '\\"')}"` : "";
|
||||
text += `${a.fromId} --> ${a.toId}${label}\n`;
|
||||
});
|
||||
|
||||
return text;
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
export interface FlowchartEvalResult {
|
||||
pass: boolean;
|
||||
score: number;
|
||||
output: string;
|
||||
feedback: string[];
|
||||
}
|
||||
|
||||
interface ParsedGraph {
|
||||
nodes: Map<string, { type: string; text: string }>;
|
||||
edges: Array<{ from: string; to: string; label: string }>;
|
||||
}
|
||||
|
||||
function parseToGraph(text: string): ParsedGraph {
|
||||
const nodes = new Map<string, { type: string; text: string }>();
|
||||
const edges: Array<{ from: string; to: string; label: string }> = [];
|
||||
|
||||
const nodeRegex = /^(\w+)(?:\[(\w+)\])?(?:\s+"([^"]+)")?$/;
|
||||
const edgeRegex = /^(\w+)\s*-->\s*(\w+)(?:\s+"([^"]+)")?$/;
|
||||
|
||||
const lines = text.split('\n');
|
||||
for (let line of lines) {
|
||||
line = line.trim();
|
||||
if (!line || line.startsWith('#') || line.startsWith('//')) continue;
|
||||
|
||||
const edgeMatch = line.match(edgeRegex);
|
||||
if (edgeMatch) {
|
||||
const [, u, v, label] = edgeMatch;
|
||||
edges.push({ from: u, to: v, label: (label || "").toLowerCase().trim() });
|
||||
continue;
|
||||
}
|
||||
|
||||
const nodeMatch = line.match(nodeRegex);
|
||||
if (nodeMatch) {
|
||||
const [, id, type, nodeText] = nodeMatch;
|
||||
nodes.set(id, {
|
||||
type: type || "rect",
|
||||
text: (nodeText || id).toLowerCase().trim()
|
||||
});
|
||||
}
|
||||
}
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates student flowchart against reference using keyword and connection matching.
|
||||
*/
|
||||
export function evaluateFlowchartSubmission(
|
||||
studentText: string,
|
||||
referenceText: string
|
||||
): FlowchartEvalResult {
|
||||
if (!referenceText) {
|
||||
return { pass: true, score: 100, output: "Luar biasa! Alur logika Anda sempurna.", feedback: [] };
|
||||
}
|
||||
|
||||
const student = parseToGraph(studentText);
|
||||
const reference = parseToGraph(referenceText);
|
||||
|
||||
let totalPoints = 0;
|
||||
const feedback: string[] = [];
|
||||
|
||||
// 1. Keyword Matching (40 points)
|
||||
const refTexts = Array.from(reference.nodes.values()).map(n => n.text);
|
||||
const stuTexts = Array.from(student.nodes.values()).map(n => n.text);
|
||||
|
||||
let foundKeywords = 0;
|
||||
for (const refText of refTexts) {
|
||||
const found = stuTexts.some(st => st.includes(refText) || refText.includes(st));
|
||||
if (found) {
|
||||
foundKeywords++;
|
||||
} else {
|
||||
feedback.push(`❌ Kurang instruksi: "${refText}"`);
|
||||
}
|
||||
}
|
||||
|
||||
const keywordScore = refTexts.length > 0 ? (foundKeywords / refTexts.length) * 40 : 40;
|
||||
totalPoints += keywordScore;
|
||||
|
||||
// 2. Connection Matching (60 points)
|
||||
// Map student IDs to reference IDs based on keyword matching
|
||||
const idMap = new Map<string, string>();
|
||||
for (const [sId, sNode] of student.nodes.entries()) {
|
||||
for (const [rId, rNode] of reference.nodes.entries()) {
|
||||
if (sNode.text.includes(rNode.text) || rNode.text.includes(sNode.text)) {
|
||||
idMap.set(sId, rId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stuEdgesMapped = student.edges
|
||||
.filter(e => idMap.has(e.from) && idMap.has(e.to))
|
||||
.map(e => ({ from: idMap.get(e.from)!, to: idMap.get(e.to)!, label: e.label }));
|
||||
|
||||
let foundEdges = 0;
|
||||
for (const refEdge of reference.edges) {
|
||||
const found = stuEdgesMapped.some(se =>
|
||||
se.from === refEdge.from && se.to === refEdge.to
|
||||
);
|
||||
|
||||
if (found) {
|
||||
foundEdges++;
|
||||
} else {
|
||||
const uText = reference.nodes.get(refEdge.from)?.text || refEdge.from;
|
||||
const vText = reference.nodes.get(refEdge.to)?.text || refEdge.to;
|
||||
feedback.push(`❌ Alur terputus: "${uText}" harus terhubung ke "${vText}"`);
|
||||
}
|
||||
}
|
||||
|
||||
const connectionScore = reference.edges.length > 0 ? (foundEdges / reference.edges.length) * 60 : 60;
|
||||
totalPoints += connectionScore;
|
||||
|
||||
const finalScore = Math.round(totalPoints);
|
||||
const pass = finalScore >= 70;
|
||||
|
||||
if (pass && feedback.length === 0) {
|
||||
feedback.push("✅ Luar biasa! Alur logika Anda sempurna.");
|
||||
} else if (pass) {
|
||||
feedback.push("⚠️ Alur logika cukup baik, tapi ada beberapa bagian yang kurang tepat.");
|
||||
}
|
||||
|
||||
return {
|
||||
pass,
|
||||
score: finalScore,
|
||||
output: feedback.join('\n'),
|
||||
feedback
|
||||
};
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@
|
|||
import { compileCode, trackProgress } from '$services/api';
|
||||
import { checkKeyText, validateNodes } from '$services/exercise';
|
||||
import { evaluateVelxioSubmission } from '$services/velxio-evaluator';
|
||||
import { evaluateFlowchartSubmission } from '$services/flowchart-evaluator';
|
||||
import { evaluateCircuitSubmission, processLanguageEvaluation } from '$services/evaluators';
|
||||
import { getVelxioState, initVelxioBridge } from '$services/velxio-manager';
|
||||
import { VelxioBridge, type EvaluationResult } from '$services/velxio-bridge';
|
||||
|
|
@ -56,6 +57,7 @@
|
|||
let cPassed = $state(false);
|
||||
let pythonPassed = $state(false);
|
||||
let circuitPassed = $state(false);
|
||||
let flowchartPassed = $state(false);
|
||||
|
||||
// Velxio (Arduino simulator) state
|
||||
let isVelxio = $derived(data?.active_tabs?.includes('velxio') ?? false);
|
||||
|
|
@ -65,9 +67,11 @@
|
|||
let velxioError = $state(false);
|
||||
let velxioIframe = $state<HTMLIFrameElement | null>(null);
|
||||
let velxioOut = $state(freshOutput());
|
||||
let flowchartOut = $state(freshOutput());
|
||||
let hasArduinoCode = $derived(!!data?.initial_code_arduino);
|
||||
let isFlowchart = $derived(data?.active_tabs?.includes('flowchart') ?? false);
|
||||
let flowchartStorageKey = $derived(`elemes_flowchart_draft_${slug}`);
|
||||
let flowchartTab = $state<any>(null);
|
||||
|
||||
// Velxio storage keys
|
||||
let arduinoCodeKey = $derived(`elemes_arduino_code_${slug}`);
|
||||
|
|
@ -115,7 +119,7 @@
|
|||
);
|
||||
|
||||
// Derived: any loading state (for disabling Run button)
|
||||
let compiling = $derived(cOut.loading || pyOut.loading || circuitOut.loading);
|
||||
let compiling = $derived(cOut.loading || pyOut.loading || circuitOut.loading || flowchartOut.loading);
|
||||
|
||||
// Build output sections for OutputPanel
|
||||
let outputSections = $derived.by(() => {
|
||||
|
|
@ -133,6 +137,9 @@
|
|||
if (tabs.includes('velxio')) {
|
||||
secs.push({ key: 'velxio', label: 'Arduino', icon: '\u{1F4DF}', data: velxioOut, placeholder: 'Klik "Compile & Run" untuk menjalankan kode', loadingText: 'Mengevaluasi...' });
|
||||
}
|
||||
if (tabs.includes('flowchart')) {
|
||||
secs.push({ key: 'flowchart', label: 'Flowchart', icon: '\u{1F531}', data: flowchartOut, placeholder: 'Klik "Cek Flowchart" untuk mengevaluasi alur logika', loadingText: 'Mengevaluasi alur...' });
|
||||
}
|
||||
return secs;
|
||||
});
|
||||
|
||||
|
|
@ -317,16 +324,59 @@
|
|||
const needsC = data?.active_tabs?.includes('c');
|
||||
const needsPython = data?.active_tabs?.includes('python');
|
||||
const needsCircuit = data?.active_tabs?.includes('circuit');
|
||||
const needsFlowchart = data?.active_tabs?.includes('flowchart');
|
||||
|
||||
if (!data?.active_tabs?.length) return true;
|
||||
|
||||
if (needsC && !cPassed) return false;
|
||||
if (needsPython && !pythonPassed) return false;
|
||||
if (needsCircuit && !circuitPassed) return false;
|
||||
if (needsFlowchart && !flowchartPassed) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function evaluateFlowchart() {
|
||||
if (!pageData?.lesson || !flowchartTab) return;
|
||||
|
||||
Object.assign(flowchartOut, { loading: true, output: 'Mengevaluasi alur...', error: '', success: null });
|
||||
activeTab = 'output';
|
||||
|
||||
try {
|
||||
const flowchartText = await flowchartTab.getFlowchartText();
|
||||
if (!flowchartText) {
|
||||
Object.assign(flowchartOut, { error: 'Gagal mengambil data flowchart.', success: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const expectedFlowchart = pageData.lesson.expected_flowchart || '';
|
||||
|
||||
if (!expectedFlowchart) {
|
||||
Object.assign(flowchartOut, { error: 'Kunci jawaban tidak tersedia untuk pelajaran ini.', success: false });
|
||||
return;
|
||||
}
|
||||
|
||||
// Perform evaluation in frontend
|
||||
const result = evaluateFlowchartSubmission(flowchartText, expectedFlowchart);
|
||||
|
||||
flowchartOut.output = result.output;
|
||||
flowchartOut.success = result.pass;
|
||||
|
||||
if (flowchartOut.success) {
|
||||
flowchartPassed = true;
|
||||
if (checkAllPassed()) {
|
||||
await completeLesson();
|
||||
setTimeout(() => { showCelebration = false; activeTab = 'flowchart'; }, 3000);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[ERROR] Flowchart evaluation failed:', err);
|
||||
Object.assign(flowchartOut, { error: `Terjadi kesalahan saat evaluasi: ${err.message}`, success: false });
|
||||
} finally {
|
||||
flowchartOut.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function evaluateCircuit() {
|
||||
if (!data || !circuitEditor) return;
|
||||
const simApi = circuitEditor.getApi();
|
||||
|
|
@ -416,6 +466,7 @@
|
|||
|
||||
async function handleRun() {
|
||||
if (activeTab === 'circuit') { await evaluateCircuit(); return; }
|
||||
if (activeTab === 'flowchart') { await evaluateFlowchart(); return; }
|
||||
if (!data) return;
|
||||
|
||||
activeTab = 'output';
|
||||
|
|
@ -434,6 +485,7 @@
|
|||
if (hasPython) await evaluateLanguage('python');
|
||||
if (tabs.includes('circuit')) await evaluateCircuit();
|
||||
if (tabs.includes('velxio')) await handleVelxioSubmit();
|
||||
if (tabs.includes('flowchart')) await evaluateFlowchart();
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
|
|
@ -453,8 +505,10 @@
|
|||
}
|
||||
Object.assign(velxioOut, freshOutput());
|
||||
} else if (activeTab === 'flowchart') {
|
||||
localStorage.removeItem(flowchartStorageKey);
|
||||
// Draft cleared. A proper reset might require iframe reload or postMessage
|
||||
if (storageKey) localStorage.removeItem(flowchartStorageKey);
|
||||
if (flowchartTab && typeof flowchartTab.handleLoad === 'function') {
|
||||
flowchartTab.handleLoad(true);
|
||||
}
|
||||
} else {
|
||||
const resetCode = currentLanguage === 'python'
|
||||
? (data.initial_python || '')
|
||||
|
|
@ -682,8 +736,12 @@
|
|||
{#if isFlowchart}
|
||||
<div class="tab-panel flowchart-panel" class:tab-hidden={activeTab !== 'flowchart'}>
|
||||
<FlowchartTab
|
||||
bind:this={flowchartTab}
|
||||
storageKey={flowchartStorageKey}
|
||||
initialData={data.initial_flowchart}
|
||||
onRun={handleRun}
|
||||
onReset={handleReset}
|
||||
compiling={compiling}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,13 @@
|
|||
let {
|
||||
storageKey,
|
||||
initialData,
|
||||
onRun,
|
||||
compiling
|
||||
}: {
|
||||
storageKey?: string;
|
||||
initialData?: any;
|
||||
onRun?: () => void;
|
||||
compiling?: boolean;
|
||||
} = $props();
|
||||
|
||||
let iframe = $state<HTMLIFrameElement>(null!);
|
||||
|
|
@ -19,7 +23,15 @@
|
|||
localStorage.setItem(storageKey, event.data.payload);
|
||||
}
|
||||
setTimeout(() => saving = false, 1000);
|
||||
} else if (event.data?.type === 'FLOWCHART_RESET') {
|
||||
if (storageKey) {
|
||||
localStorage.removeItem(storageKey);
|
||||
}
|
||||
handleLoad(true);
|
||||
} else if (event.data?.type === 'FLOWCHART_READY') {
|
||||
// If READY is received, it means the iframe just loaded or was reset.
|
||||
// We check if the last message from iframe was a request to reset.
|
||||
// For simplicity, we can just send the data.
|
||||
handleLoad();
|
||||
}
|
||||
}
|
||||
|
|
@ -27,10 +39,10 @@
|
|||
return () => window.removeEventListener('message', handleMessage);
|
||||
});
|
||||
|
||||
function handleLoad() {
|
||||
export function handleLoad(ignoreDraft = false) {
|
||||
if (iframe && iframe.contentWindow) {
|
||||
let draftData = null;
|
||||
if (storageKey) {
|
||||
if (storageKey && !ignoreDraft) {
|
||||
const draft = localStorage.getItem(storageKey);
|
||||
if (draft) {
|
||||
try {
|
||||
|
|
@ -39,22 +51,59 @@
|
|||
}
|
||||
}
|
||||
|
||||
if (initialData || draftData) {
|
||||
iframe.contentWindow.postMessage({
|
||||
type: 'FLOWCHART_LOAD',
|
||||
payload: JSON.stringify({
|
||||
initialData: initialData,
|
||||
draftData: draftData
|
||||
})
|
||||
}, '*');
|
||||
}
|
||||
iframe.contentWindow.postMessage({
|
||||
type: 'FLOWCHART_LOAD',
|
||||
payload: JSON.stringify({
|
||||
initialData: initialData,
|
||||
draftData: draftData
|
||||
})
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
// Gunakan cache-busting sederhana agar tidak membuka Nginx default yang tersimpan di cache
|
||||
let cb = $state(Date.now());
|
||||
export function getFlowchartText(): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const requestId = Math.random().toString(36).substring(7);
|
||||
const handler = (event: MessageEvent) => {
|
||||
if (event.data?.type === 'FLOWCHART_TEXT_RESPONSE' && event.data?.requestId === requestId) {
|
||||
window.removeEventListener('message', handler);
|
||||
resolve(event.data.payload);
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', handler);
|
||||
|
||||
if (iframe && iframe.contentWindow) {
|
||||
iframe.contentWindow.postMessage({
|
||||
type: 'FLOWCHART_GET_TEXT',
|
||||
requestId: requestId
|
||||
}, '*');
|
||||
} else {
|
||||
window.removeEventListener('message', handler);
|
||||
resolve('');
|
||||
}
|
||||
|
||||
// Timeout fallback
|
||||
setTimeout(() => {
|
||||
window.removeEventListener('message', handler);
|
||||
resolve('');
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flowchart-container">
|
||||
<button
|
||||
type="button"
|
||||
class="floating-action-btn"
|
||||
onclick={onRun}
|
||||
disabled={compiling}
|
||||
title="Evaluasi alur logika Anda"
|
||||
>
|
||||
<span class="btn-icon">{compiling ? '⌛' : '▶'}</span>
|
||||
<span class="btn-text">{compiling ? 'Mengevaluasi...' : 'Cek Flowchart'}</span>
|
||||
</button>
|
||||
|
||||
{#if storageKey}
|
||||
<div class="storage-indicator-inline" title={saving ? "Menyimpan draf..." : "Draf tersimpan di browser"}>
|
||||
<span class="indicator-icon" class:saving>
|
||||
|
|
@ -92,6 +141,47 @@
|
|||
border: none;
|
||||
}
|
||||
|
||||
.floating-action-btn {
|
||||
position: absolute;
|
||||
top: 64px;
|
||||
left: 12px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: #10b981;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.floating-action-btn:hover:not(:disabled) {
|
||||
background: #059669;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
.floating-action-btn:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.floating-action-btn:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.storage-indicator-inline {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ def api_lesson(filename):
|
|||
velxio_circuit = parsed_data.get('velxio_circuit', '')
|
||||
expected_serial_output = parsed_data.get('expected_serial_output', '')
|
||||
expected_wiring = parsed_data.get('expected_wiring', '')
|
||||
expected_flowchart = parsed_data.get('expected_flowchart', '')
|
||||
|
||||
evaluation_config_raw = parsed_data.get('evaluation_config', '')
|
||||
evaluation_config = {}
|
||||
|
|
@ -134,6 +135,7 @@ def api_lesson(filename):
|
|||
'velxio_circuit': velxio_circuit,
|
||||
'expected_serial_output': expected_serial_output,
|
||||
'expected_wiring': expected_wiring,
|
||||
'expected_flowchart': expected_flowchart,
|
||||
'evaluation_config': evaluation_config,
|
||||
'solution_code': solution_code,
|
||||
'solution_circuit': solution_circuit,
|
||||
|
|
|
|||
|
|
@ -363,6 +363,9 @@ def render_markdown_content(file_path):
|
|||
expected_wiring, lesson_content = _extract_section(
|
||||
lesson_content, '---EXPECTED_WIRING---', '---END_EXPECTED_WIRING---')
|
||||
|
||||
expected_flowchart, lesson_content = _extract_section(
|
||||
lesson_content, '---EXPECTED_FLOWCHART---', '---END_EXPECTED_FLOWCHART---')
|
||||
|
||||
evaluation_config, lesson_content = _extract_section(
|
||||
lesson_content, '---EVALUATION_CONFIG---', '---END_EVALUATION_CONFIG---')
|
||||
|
||||
|
|
@ -393,6 +396,7 @@ def render_markdown_content(file_path):
|
|||
'expected_output': expected_output,
|
||||
'expected_output_python': expected_output_python,
|
||||
'expected_circuit_output': expected_circuit_output,
|
||||
'expected_flowchart': expected_flowchart,
|
||||
'lesson_info': lesson_info_html,
|
||||
'initial_code': initial_code,
|
||||
'solution_code': solution_code,
|
||||
|
|
@ -403,7 +407,7 @@ def render_markdown_content(file_path):
|
|||
'initial_code_c': initial_code_c,
|
||||
'initial_python': initial_python,
|
||||
'initial_circuit': initial_circuit,
|
||||
'initial_flowchart': initial_flowchart,
|
||||
'initial_flowchart': initial_flowchart_str or initial_flowchart,
|
||||
'initial_quiz': initial_quiz,
|
||||
'initial_code_arduino': initial_code_arduino,
|
||||
'velxio_circuit': velxio_circuit,
|
||||
|
|
|
|||
Loading…
Reference in New Issue