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": "^5.55.4",
|
||||||
"svelte-check": "^4.4.6",
|
"svelte-check": "^4.4.6",
|
||||||
"typescript": "~6.0.2",
|
"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 Properties from './lib/Properties.svelte';
|
||||||
import Canvas from './lib/Canvas.svelte';
|
import Canvas from './lib/Canvas.svelte';
|
||||||
import { fcState } from './lib/flowchartState.svelte';
|
import { fcState } from './lib/flowchartState.svelte';
|
||||||
|
import { parseFlowchartText, exportToFlowchartText } from './lib/parser';
|
||||||
|
import { applyAutoLayout } from './lib/layout';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|
@ -21,26 +23,42 @@
|
||||||
window.addEventListener('message', (event) => {
|
window.addEventListener('message', (event) => {
|
||||||
if (event.data?.type === 'FLOWCHART_LOAD') {
|
if (event.data?.type === 'FLOWCHART_LOAD') {
|
||||||
try {
|
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)
|
// Legacy format (just data object)
|
||||||
if (!payload.initialData && !payload.draftData && payload.shapes) {
|
if (!payload.initialData && !payload.draftData && (payload.shapes || typeof payload === 'string')) {
|
||||||
fcState.initialData = payload;
|
const data = processData(payload);
|
||||||
fcState.loadData(payload);
|
fcState.initialData = data;
|
||||||
|
fcState.loadData(data);
|
||||||
} else {
|
} else {
|
||||||
// New format { initialData, draftData }
|
// New format { initialData, draftData }
|
||||||
if (payload.initialData) {
|
if (payload.initialData) {
|
||||||
fcState.initialData = payload.initialData;
|
fcState.initialData = processData(payload.initialData);
|
||||||
}
|
}
|
||||||
if (payload.draftData) {
|
if (payload.draftData) {
|
||||||
fcState.loadData(payload.draftData);
|
fcState.loadData(processData(payload.draftData));
|
||||||
} else if (payload.initialData) {
|
} else if (payload.initialData) {
|
||||||
fcState.loadData(payload.initialData);
|
fcState.loadData(processData(payload.initialData));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error("Invalid data format", 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>
|
<span class="label-text">Warna Isi</span>
|
||||||
<div class="color-palette">
|
<div class="color-palette">
|
||||||
{#each predefinedColors as color}
|
{#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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -87,7 +87,7 @@
|
||||||
<span class="label-text">Warna Garis</span>
|
<span class="label-text">Warna Garis</span>
|
||||||
<div class="color-palette">
|
<div class="color-palette">
|
||||||
{#each predefinedColors as color}
|
{#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 (selectedShapes.length) updateShape('strokeColor', color);
|
||||||
if (selectedArrows.length) updateArrow('strokeColor', color);
|
if (selectedArrows.length) updateArrow('strokeColor', color);
|
||||||
}}></button>
|
}}></button>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fcState } from './flowchartState.svelte';
|
import { fcState } from './flowchartState.svelte';
|
||||||
|
import { parseFlowchartText, exportToFlowchartText } from './parser';
|
||||||
|
import { applyAutoLayout } from './layout';
|
||||||
|
|
||||||
let fileInput: HTMLInputElement;
|
let fileInput: HTMLInputElement;
|
||||||
|
|
||||||
|
|
@ -27,9 +29,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNew() {
|
function handleNew() {
|
||||||
if (fcState.isIframeMode && fcState.initialData) {
|
if (fcState.isIframeMode) {
|
||||||
if (confirm('Kembalikan flowchart ke kondisi awal? Perubahan yang belum disimpan akan hilang.')) {
|
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 {
|
} else {
|
||||||
if (confirm('Buat flowchart baru? Perubahan yang belum disimpan akan hilang.')) {
|
if (confirm('Buat flowchart baru? Perubahan yang belum disimpan akan hilang.')) {
|
||||||
|
|
@ -47,14 +52,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleExport() {
|
function handleExport() {
|
||||||
const data = JSON.stringify({
|
const text = exportToFlowchartText(fcState.shapes, fcState.arrows);
|
||||||
shapes: fcState.shapes,
|
const blob = new Blob([text], { type: 'text/plain' });
|
||||||
arrows: fcState.arrows,
|
|
||||||
zoom: fcState.zoom,
|
|
||||||
panX: fcState.panX,
|
|
||||||
panY: fcState.panY
|
|
||||||
});
|
|
||||||
const blob = new Blob([data], { type: 'application/json' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
|
|
@ -75,9 +74,18 @@
|
||||||
reader.onload = (ev) => {
|
reader.onload = (ev) => {
|
||||||
try {
|
try {
|
||||||
const content = ev.target?.result as string;
|
const content = ev.target?.result as string;
|
||||||
const data = JSON.parse(content);
|
if (content.trim().startsWith('{')) {
|
||||||
fcState.loadData(data);
|
// 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) {
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
alert("Format file tidak valid!");
|
alert("Format file tidak valid!");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -149,7 +157,7 @@
|
||||||
<!-- MOBILE MENU ACTION -->
|
<!-- MOBILE MENU ACTION -->
|
||||||
<div class="topbar-actions mobile-only">
|
<div class="topbar-actions mobile-only">
|
||||||
<div class="dropdown-container">
|
<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>
|
<svg><use href="#icon-menu"/></svg>
|
||||||
</button>
|
</button>
|
||||||
{#if isMenuOpen}
|
{#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 { compileCode, trackProgress } from '$services/api';
|
||||||
import { checkKeyText, validateNodes } from '$services/exercise';
|
import { checkKeyText, validateNodes } from '$services/exercise';
|
||||||
import { evaluateVelxioSubmission } from '$services/velxio-evaluator';
|
import { evaluateVelxioSubmission } from '$services/velxio-evaluator';
|
||||||
|
import { evaluateFlowchartSubmission } from '$services/flowchart-evaluator';
|
||||||
import { evaluateCircuitSubmission, processLanguageEvaluation } from '$services/evaluators';
|
import { evaluateCircuitSubmission, processLanguageEvaluation } from '$services/evaluators';
|
||||||
import { getVelxioState, initVelxioBridge } from '$services/velxio-manager';
|
import { getVelxioState, initVelxioBridge } from '$services/velxio-manager';
|
||||||
import { VelxioBridge, type EvaluationResult } from '$services/velxio-bridge';
|
import { VelxioBridge, type EvaluationResult } from '$services/velxio-bridge';
|
||||||
|
|
@ -56,6 +57,7 @@
|
||||||
let cPassed = $state(false);
|
let cPassed = $state(false);
|
||||||
let pythonPassed = $state(false);
|
let pythonPassed = $state(false);
|
||||||
let circuitPassed = $state(false);
|
let circuitPassed = $state(false);
|
||||||
|
let flowchartPassed = $state(false);
|
||||||
|
|
||||||
// Velxio (Arduino simulator) state
|
// Velxio (Arduino simulator) state
|
||||||
let isVelxio = $derived(data?.active_tabs?.includes('velxio') ?? false);
|
let isVelxio = $derived(data?.active_tabs?.includes('velxio') ?? false);
|
||||||
|
|
@ -65,9 +67,11 @@
|
||||||
let velxioError = $state(false);
|
let velxioError = $state(false);
|
||||||
let velxioIframe = $state<HTMLIFrameElement | null>(null);
|
let velxioIframe = $state<HTMLIFrameElement | null>(null);
|
||||||
let velxioOut = $state(freshOutput());
|
let velxioOut = $state(freshOutput());
|
||||||
|
let flowchartOut = $state(freshOutput());
|
||||||
let hasArduinoCode = $derived(!!data?.initial_code_arduino);
|
let hasArduinoCode = $derived(!!data?.initial_code_arduino);
|
||||||
let isFlowchart = $derived(data?.active_tabs?.includes('flowchart') ?? false);
|
let isFlowchart = $derived(data?.active_tabs?.includes('flowchart') ?? false);
|
||||||
let flowchartStorageKey = $derived(`elemes_flowchart_draft_${slug}`);
|
let flowchartStorageKey = $derived(`elemes_flowchart_draft_${slug}`);
|
||||||
|
let flowchartTab = $state<any>(null);
|
||||||
|
|
||||||
// Velxio storage keys
|
// Velxio storage keys
|
||||||
let arduinoCodeKey = $derived(`elemes_arduino_code_${slug}`);
|
let arduinoCodeKey = $derived(`elemes_arduino_code_${slug}`);
|
||||||
|
|
@ -115,7 +119,7 @@
|
||||||
);
|
);
|
||||||
|
|
||||||
// Derived: any loading state (for disabling Run button)
|
// 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
|
// Build output sections for OutputPanel
|
||||||
let outputSections = $derived.by(() => {
|
let outputSections = $derived.by(() => {
|
||||||
|
|
@ -133,6 +137,9 @@
|
||||||
if (tabs.includes('velxio')) {
|
if (tabs.includes('velxio')) {
|
||||||
secs.push({ key: 'velxio', label: 'Arduino', icon: '\u{1F4DF}', data: velxioOut, placeholder: 'Klik "Compile & Run" untuk menjalankan kode', loadingText: 'Mengevaluasi...' });
|
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;
|
return secs;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -317,16 +324,59 @@
|
||||||
const needsC = data?.active_tabs?.includes('c');
|
const needsC = data?.active_tabs?.includes('c');
|
||||||
const needsPython = data?.active_tabs?.includes('python');
|
const needsPython = data?.active_tabs?.includes('python');
|
||||||
const needsCircuit = data?.active_tabs?.includes('circuit');
|
const needsCircuit = data?.active_tabs?.includes('circuit');
|
||||||
|
const needsFlowchart = data?.active_tabs?.includes('flowchart');
|
||||||
|
|
||||||
if (!data?.active_tabs?.length) return true;
|
if (!data?.active_tabs?.length) return true;
|
||||||
|
|
||||||
if (needsC && !cPassed) return false;
|
if (needsC && !cPassed) return false;
|
||||||
if (needsPython && !pythonPassed) return false;
|
if (needsPython && !pythonPassed) return false;
|
||||||
if (needsCircuit && !circuitPassed) return false;
|
if (needsCircuit && !circuitPassed) return false;
|
||||||
|
if (needsFlowchart && !flowchartPassed) return false;
|
||||||
|
|
||||||
return true;
|
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() {
|
async function evaluateCircuit() {
|
||||||
if (!data || !circuitEditor) return;
|
if (!data || !circuitEditor) return;
|
||||||
const simApi = circuitEditor.getApi();
|
const simApi = circuitEditor.getApi();
|
||||||
|
|
@ -416,6 +466,7 @@
|
||||||
|
|
||||||
async function handleRun() {
|
async function handleRun() {
|
||||||
if (activeTab === 'circuit') { await evaluateCircuit(); return; }
|
if (activeTab === 'circuit') { await evaluateCircuit(); return; }
|
||||||
|
if (activeTab === 'flowchart') { await evaluateFlowchart(); return; }
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
activeTab = 'output';
|
activeTab = 'output';
|
||||||
|
|
@ -434,6 +485,7 @@
|
||||||
if (hasPython) await evaluateLanguage('python');
|
if (hasPython) await evaluateLanguage('python');
|
||||||
if (tabs.includes('circuit')) await evaluateCircuit();
|
if (tabs.includes('circuit')) await evaluateCircuit();
|
||||||
if (tabs.includes('velxio')) await handleVelxioSubmit();
|
if (tabs.includes('velxio')) await handleVelxioSubmit();
|
||||||
|
if (tabs.includes('flowchart')) await evaluateFlowchart();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleReset() {
|
function handleReset() {
|
||||||
|
|
@ -453,8 +505,10 @@
|
||||||
}
|
}
|
||||||
Object.assign(velxioOut, freshOutput());
|
Object.assign(velxioOut, freshOutput());
|
||||||
} else if (activeTab === 'flowchart') {
|
} else if (activeTab === 'flowchart') {
|
||||||
localStorage.removeItem(flowchartStorageKey);
|
if (storageKey) localStorage.removeItem(flowchartStorageKey);
|
||||||
// Draft cleared. A proper reset might require iframe reload or postMessage
|
if (flowchartTab && typeof flowchartTab.handleLoad === 'function') {
|
||||||
|
flowchartTab.handleLoad(true);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const resetCode = currentLanguage === 'python'
|
const resetCode = currentLanguage === 'python'
|
||||||
? (data.initial_python || '')
|
? (data.initial_python || '')
|
||||||
|
|
@ -682,8 +736,12 @@
|
||||||
{#if isFlowchart}
|
{#if isFlowchart}
|
||||||
<div class="tab-panel flowchart-panel" class:tab-hidden={activeTab !== 'flowchart'}>
|
<div class="tab-panel flowchart-panel" class:tab-hidden={activeTab !== 'flowchart'}>
|
||||||
<FlowchartTab
|
<FlowchartTab
|
||||||
|
bind:this={flowchartTab}
|
||||||
storageKey={flowchartStorageKey}
|
storageKey={flowchartStorageKey}
|
||||||
initialData={data.initial_flowchart}
|
initialData={data.initial_flowchart}
|
||||||
|
onRun={handleRun}
|
||||||
|
onReset={handleReset}
|
||||||
|
compiling={compiling}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,13 @@
|
||||||
let {
|
let {
|
||||||
storageKey,
|
storageKey,
|
||||||
initialData,
|
initialData,
|
||||||
|
onRun,
|
||||||
|
compiling
|
||||||
}: {
|
}: {
|
||||||
storageKey?: string;
|
storageKey?: string;
|
||||||
initialData?: any;
|
initialData?: any;
|
||||||
|
onRun?: () => void;
|
||||||
|
compiling?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let iframe = $state<HTMLIFrameElement>(null!);
|
let iframe = $state<HTMLIFrameElement>(null!);
|
||||||
|
|
@ -19,7 +23,15 @@
|
||||||
localStorage.setItem(storageKey, event.data.payload);
|
localStorage.setItem(storageKey, event.data.payload);
|
||||||
}
|
}
|
||||||
setTimeout(() => saving = false, 1000);
|
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') {
|
} 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();
|
handleLoad();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -27,10 +39,10 @@
|
||||||
return () => window.removeEventListener('message', handleMessage);
|
return () => window.removeEventListener('message', handleMessage);
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleLoad() {
|
export function handleLoad(ignoreDraft = false) {
|
||||||
if (iframe && iframe.contentWindow) {
|
if (iframe && iframe.contentWindow) {
|
||||||
let draftData = null;
|
let draftData = null;
|
||||||
if (storageKey) {
|
if (storageKey && !ignoreDraft) {
|
||||||
const draft = localStorage.getItem(storageKey);
|
const draft = localStorage.getItem(storageKey);
|
||||||
if (draft) {
|
if (draft) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -39,22 +51,59 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (initialData || draftData) {
|
iframe.contentWindow.postMessage({
|
||||||
iframe.contentWindow.postMessage({
|
type: 'FLOWCHART_LOAD',
|
||||||
type: 'FLOWCHART_LOAD',
|
payload: JSON.stringify({
|
||||||
payload: JSON.stringify({
|
initialData: initialData,
|
||||||
initialData: initialData,
|
draftData: draftData
|
||||||
draftData: draftData
|
})
|
||||||
})
|
}, '*');
|
||||||
}, '*');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Gunakan cache-busting sederhana agar tidak membuka Nginx default yang tersimpan di cache
|
// Gunakan cache-busting sederhana agar tidak membuka Nginx default yang tersimpan di cache
|
||||||
let cb = $state(Date.now());
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="flowchart-container">
|
<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}
|
{#if storageKey}
|
||||||
<div class="storage-indicator-inline" title={saving ? "Menyimpan draf..." : "Draf tersimpan di browser"}>
|
<div class="storage-indicator-inline" title={saving ? "Menyimpan draf..." : "Draf tersimpan di browser"}>
|
||||||
<span class="indicator-icon" class:saving>
|
<span class="indicator-icon" class:saving>
|
||||||
|
|
@ -92,6 +141,47 @@
|
||||||
border: none;
|
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 {
|
.storage-indicator-inline {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 1rem;
|
bottom: 1rem;
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ def api_lesson(filename):
|
||||||
velxio_circuit = parsed_data.get('velxio_circuit', '')
|
velxio_circuit = parsed_data.get('velxio_circuit', '')
|
||||||
expected_serial_output = parsed_data.get('expected_serial_output', '')
|
expected_serial_output = parsed_data.get('expected_serial_output', '')
|
||||||
expected_wiring = parsed_data.get('expected_wiring', '')
|
expected_wiring = parsed_data.get('expected_wiring', '')
|
||||||
|
expected_flowchart = parsed_data.get('expected_flowchart', '')
|
||||||
|
|
||||||
evaluation_config_raw = parsed_data.get('evaluation_config', '')
|
evaluation_config_raw = parsed_data.get('evaluation_config', '')
|
||||||
evaluation_config = {}
|
evaluation_config = {}
|
||||||
|
|
@ -134,6 +135,7 @@ def api_lesson(filename):
|
||||||
'velxio_circuit': velxio_circuit,
|
'velxio_circuit': velxio_circuit,
|
||||||
'expected_serial_output': expected_serial_output,
|
'expected_serial_output': expected_serial_output,
|
||||||
'expected_wiring': expected_wiring,
|
'expected_wiring': expected_wiring,
|
||||||
|
'expected_flowchart': expected_flowchart,
|
||||||
'evaluation_config': evaluation_config,
|
'evaluation_config': evaluation_config,
|
||||||
'solution_code': solution_code,
|
'solution_code': solution_code,
|
||||||
'solution_circuit': solution_circuit,
|
'solution_circuit': solution_circuit,
|
||||||
|
|
|
||||||
|
|
@ -363,6 +363,9 @@ def render_markdown_content(file_path):
|
||||||
expected_wiring, lesson_content = _extract_section(
|
expected_wiring, lesson_content = _extract_section(
|
||||||
lesson_content, '---EXPECTED_WIRING---', '---END_EXPECTED_WIRING---')
|
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(
|
evaluation_config, lesson_content = _extract_section(
|
||||||
lesson_content, '---EVALUATION_CONFIG---', '---END_EVALUATION_CONFIG---')
|
lesson_content, '---EVALUATION_CONFIG---', '---END_EVALUATION_CONFIG---')
|
||||||
|
|
||||||
|
|
@ -393,6 +396,7 @@ def render_markdown_content(file_path):
|
||||||
'expected_output': expected_output,
|
'expected_output': expected_output,
|
||||||
'expected_output_python': expected_output_python,
|
'expected_output_python': expected_output_python,
|
||||||
'expected_circuit_output': expected_circuit_output,
|
'expected_circuit_output': expected_circuit_output,
|
||||||
|
'expected_flowchart': expected_flowchart,
|
||||||
'lesson_info': lesson_info_html,
|
'lesson_info': lesson_info_html,
|
||||||
'initial_code': initial_code,
|
'initial_code': initial_code,
|
||||||
'solution_code': solution_code,
|
'solution_code': solution_code,
|
||||||
|
|
@ -403,7 +407,7 @@ def render_markdown_content(file_path):
|
||||||
'initial_code_c': initial_code_c,
|
'initial_code_c': initial_code_c,
|
||||||
'initial_python': initial_python,
|
'initial_python': initial_python,
|
||||||
'initial_circuit': initial_circuit,
|
'initial_circuit': initial_circuit,
|
||||||
'initial_flowchart': initial_flowchart,
|
'initial_flowchart': initial_flowchart_str or initial_flowchart,
|
||||||
'initial_quiz': initial_quiz,
|
'initial_quiz': initial_quiz,
|
||||||
'initial_code_arduino': initial_code_arduino,
|
'initial_code_arduino': initial_code_arduino,
|
||||||
'velxio_circuit': velxio_circuit,
|
'velxio_circuit': velxio_circuit,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue