Skip to content
This repository was archived by the owner on Nov 7, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ The format is loosely inspired by Keep a Changelog.

### Added

- **Web 0.14.0, new world VisuFlow: draw flowcharts:** fourth world next to VisuStruct, VisuBot and VisuObject (…/app/?visuflow, colour violet, chosen at the start or in the gear menu; the world chooser now has two rows). Only drawing, no simulation and no code: drag the DIN 66001 symbols (Start, Anweisung, Ein-/Ausgabe, Verzweigung, Unterprogramm, Ende) from the bar onto the canvas, or tap a symbol to add it below the selected one, joined by an arrow; type the text right in the symbol (tap again or double click to change it). Drag an arrow from the dot at an exit to the next symbol; the diamond has the exits ja and nein (button „ja/nein tauschen“ swaps their sides), arrows run at right angles, loops go around on the right. Move with the mouse, finger or arrow keys on a 20 px grid, delete, undo/redo, title above the canvas, example „Tee kochen“ from lesson 1. Save and open as `.visuflow` file (the last plan stays in the browser), export as PNG, SVG or PDF (A4). Texts de/en/pt.
- **Web 0.13.38, comments in Java:** lesson 6 has a new step „Kommentare im Code“ after „Jetzt in Java“: single-line `//` and multi-line `/* … */` comments, Java skips them and the structure chart stays the same; Javadoc `/** … */` is only mentioned. The IPO example (with „Im Editor öffnen“) shows the task in a multi-line comment, `// Eingabe`, `// Verarbeitung`, `// Ausgabe`, an explanation after a line, the expected output and a note for next time; plus a fill-in-the-gaps exercise, an Angeberwissen (PL/I 1964, BCPL 1967), a competence, a checklist line and two quiz questions. Lesson 14 marks closing brackets with `} // Ende if` / `} // Ende for`, lesson 20 the class frame with `} // Ende main` / `} // Ende Klasse`. New glossary entry Kommentar and Nachschlagen section „Java › Kommentare“ (de/en/pt).
- **Web 0.13.36, sequence diagram in lesson 5:** step „Schritt für Schritt“ now shows a sequence as a structure chart: four boxes one below the other (`laenge`, `breite`, `flaeche = laenge * breite`, output 15) with a short caption that each box runs exactly once from top to bottom (de/en/pt).
- **Web 0.13.35, open Java examples in the editor:** a Java example in a lesson can now carry the button „Im Editor öffnen“. It opens the example in a new tab with Programmieren set to Java, so students can run it, change values and try again; the structure chart builds itself from the code. Lesson 4 step „Text in Java“ (Mia, 14, 1.62) uses it and says how to try it (de/en/pt). For authors: write ```` ```java editor ```` instead of ```` ```java ````; a test checks that every such example parses without errors.
Expand Down
4 changes: 2 additions & 2 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "visustruct-web",
"private": true,
"version": "0.13.38",
"version": "0.14.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
242 changes: 242 additions & 0 deletions web/src/core/flowModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
/**
* VisuFlow: Programmablaufplan (PAP, englisch flowchart) nach DIN 66001 frei zeichnen.
* Nur Zeichnen: keine Simulation, kein Code. Ein Plan besteht aus Symbolen (Mittelpunkt x/y)
* und Pfeilen vom Ausgang eines Symbols zum Eingang (oben) eines anderen.
*/

export type FlowShape = 'start' | 'ende' | 'anweisung' | 'ein' | 'verzweigung' | 'unterprogramm'

/** Ausgang eines Symbols: `out` unten, bei der Raute `ja` und `nein`. */
export type FlowPort = 'out' | 'ja' | 'nein'

export interface FlowNode {
id: string
shape: FlowShape
x: number
y: number
text: string
}

export interface FlowEdge {
id: string
from: string
port: FlowPort
to: string
}

export interface FlowDoc {
title: string
nodes: FlowNode[]
edges: FlowEdge[]
/** Raute: `true` = „ja“ geht nach rechts, „nein“ nach unten (Standard umgekehrt). */
yesRight?: string[]
}

export const FLOW_SHAPES: FlowShape[] = ['start', 'anweisung', 'ein', 'verzweigung', 'unterprogramm', 'ende']

export const FLOW_GRID = 20

/** Füllfarben wie im Lernbaustein „PAP bauen“ (papView.ts). */
export const FLOW_FILL: Record<FlowShape, string> = {
start: '#d1f2d9',
ende: '#d1f2d9',
anweisung: '#e5f0ff',
ein: '#f2e6ff',
verzweigung: '#fff4d6',
unterprogramm: '#e5f0ff',
}

export type Measure = (text: string) => number

export interface FlowBox {
x: number
y: number
w: number
h: number
lines: string[]
}

export const FLOW_LINE_HEIGHT = 16
const MAX_TEXT = 170

/** Text auf Zeilen umbrechen (an Leerzeichen, harte Umbrüche bleiben). */
export function flowLines(text: string, max: number, measure: Measure): string[] {
const out: string[] = []
for (const para of text.split('\n')) {
let line = ''
for (const word of para.split(/\s+/).filter(Boolean)) {
const next = line ? `${line} ${word}` : word
if (line && measure(next) > max) {
out.push(line)
line = word
} else line = next
}
out.push(line)
}
return out.length ? out : ['']
}

const snap = (v: number) => Math.round(v / FLOW_GRID) * FLOW_GRID

/** Größe eines Symbols aus seinem Text; Breite und Höhe auf das Raster gerundet. */
export function flowBox(node: FlowNode, measure: Measure): FlowBox {
const lines = flowLines(node.text, MAX_TEXT, measure)
const textW = Math.max(0, ...lines.map(measure))
const textH = lines.length * FLOW_LINE_HEIGHT
let w: number
let h: number
if (node.shape === 'verzweigung') {
// Raute: Text passt in das innere Rechteck, wenn Breite und Höhe doppelt so groß sind.
w = Math.max(140, textW * 1.7 + 30)
h = Math.max(80, textH * 1.8 + 24)
} else {
const pad = node.shape === 'ein' ? 44 : node.shape === 'unterprogramm' ? 40 : node.shape === 'anweisung' ? 24 : 36
w = Math.max(node.shape === 'start' || node.shape === 'ende' ? 100 : 120, textW + pad)
h = Math.max(40, textH + 18)
}
return { x: node.x, y: node.y, w: Math.ceil(w / FLOW_GRID) * FLOW_GRID, h: Math.ceil(h / FLOW_GRID) * FLOW_GRID, lines }
}

/** Welche Ausgänge ein Symbol hat. */
export function flowPorts(shape: FlowShape): FlowPort[] {
if (shape === 'ende') return []
if (shape === 'verzweigung') return ['ja', 'nein']
return ['out']
}

export const hasInput = (shape: FlowShape) => shape !== 'start'

type Side = 'bottom' | 'right'

/** Seite eines Ausgangs. Raute: Standard „ja“ unten, „nein“ rechts; umschaltbar. */
export function portSide(doc: FlowDoc, node: FlowNode, port: FlowPort): Side {
if (port === 'out') return 'bottom'
const yesRight = doc.yesRight?.includes(node.id) ?? false
return (port === 'ja') === yesRight ? 'right' : 'bottom'
}

export function portPoint(box: FlowBox, side: Side): [number, number] {
return side === 'bottom' ? [box.x, box.y + box.h / 2] : [box.x + box.w / 2, box.y]
}

export const inputPoint = (box: FlowBox): [number, number] => [box.x, box.y - box.h / 2]

/**
* Rechtwinkliger Weg eines Pfeils vom Ausgang zum Eingang oben. Geht es nach oben zurück (Schleife),
* läuft der Pfeil rechts an beiden Symbolen vorbei.
*/
export function routeEdge(from: FlowBox, side: Side, to: FlowBox, lane = 0): [number, number][] {
const [sx, sy] = portPoint(from, side)
const [tx, ty] = inputPoint(to)
const gap = FLOW_GRID
const outside = Math.max(from.x + from.w / 2, to.x + to.w / 2) + gap * (1.5 + lane)
if (side === 'bottom') {
if (ty >= sy + gap) {
if (sx === tx) return [[sx, sy], [tx, ty]]
const my = sy + gap
return [[sx, sy], [sx, my], [tx, my], [tx, ty]]
}
return [[sx, sy], [sx, sy + gap], [outside, sy + gap], [outside, ty - gap], [tx, ty - gap], [tx, ty]]
}
// Ausgang rechts; liegt das Ziel rechts daneben auf gleicher Höhe, endet der Pfeil links an seiner Seite.
const left = to.x - to.w / 2
if (left >= sx + gap && Math.abs(to.y - sy) < to.h / 2) return [[sx, sy], [left, sy]]
if (ty >= sy + gap) {
if (tx >= sx + gap) return [[sx, sy], [tx, sy], [tx, ty]]
const rx = sx + gap
return [[sx, sy], [rx, sy], [rx, ty - gap], [tx, ty - gap], [tx, ty]]
}
const rx = Math.max(sx + gap, outside)
return [[sx, sy], [rx, sy], [rx, ty - gap], [tx, ty - gap], [tx, ty]]
}

// MARK: - Bearbeiten (immer neue Objekte, damit Rückgängig einfach den alten Stand behält)

export function emptyFlow(title = ''): FlowDoc {
return { title, nodes: [], edges: [] }
}

export function nextId(doc: FlowDoc, prefix: 'n' | 'e'): string {
const used = new Set([...doc.nodes.map((n) => n.id), ...doc.edges.map((e) => e.id)])
let i = 1
while (used.has(`${prefix}${i}`)) i++
return `${prefix}${i}`
}

export function addNode(doc: FlowDoc, shape: FlowShape, x: number, y: number, text: string): { doc: FlowDoc; id: string } {
const id = nextId(doc, 'n')
return { doc: { ...doc, nodes: [...doc.nodes, { id, shape, x: snap(x), y: snap(y), text }] }, id }
}

export function moveNode(doc: FlowDoc, id: string, x: number, y: number): FlowDoc {
return { ...doc, nodes: doc.nodes.map((n) => (n.id === id ? { ...n, x: Math.max(FLOW_GRID * 4, snap(x)), y: Math.max(FLOW_GRID * 2, snap(y)) } : n)) }
}

export function setText(doc: FlowDoc, id: string, text: string): FlowDoc {
return { ...doc, nodes: doc.nodes.map((n) => (n.id === id ? { ...n, text } : n)) }
}

/** Pfeil ziehen: Ein Ausgang hat höchstens einen Pfeil, ein neuer ersetzt den alten. */
export function connect(doc: FlowDoc, from: string, port: FlowPort, to: string): FlowDoc {
const a = doc.nodes.find((n) => n.id === from)
const b = doc.nodes.find((n) => n.id === to)
if (!a || !b || from === to || !flowPorts(a.shape).includes(port) || !hasInput(b.shape)) return doc
const edges = doc.edges.filter((e) => !(e.from === from && e.port === port))
return { ...doc, edges: [...edges, { id: nextId(doc, 'e'), from, port, to }] }
}

export function freePort(doc: FlowDoc, node: FlowNode): FlowPort | undefined {
return flowPorts(node.shape).find((p) => !doc.edges.some((e) => e.from === node.id && e.port === p))
}

export function removeNode(doc: FlowDoc, id: string): FlowDoc {
return {
...doc,
nodes: doc.nodes.filter((n) => n.id !== id),
edges: doc.edges.filter((e) => e.from !== id && e.to !== id),
yesRight: doc.yesRight?.filter((n) => n !== id),
}
}

export function removeEdge(doc: FlowDoc, id: string): FlowDoc {
return { ...doc, edges: doc.edges.filter((e) => e.id !== id) }
}

/** Raute: „ja“ und „nein“ tauschen die Seiten (die Pfeile bleiben an ihrem Ausgang). */
export function swapYesNo(doc: FlowDoc, id: string): FlowDoc {
const set = new Set(doc.yesRight ?? [])
if (set.has(id)) set.delete(id)
else set.add(id)
return { ...doc, yesRight: [...set] }
}

// MARK: - Datei (.visuflow, JSON)

export function flowToJSON(doc: FlowDoc): string {
return JSON.stringify({ format: 'visuflow', version: 1, ...doc }, null, 2)
}

export function flowFromJSON(text: string): FlowDoc | undefined {
let raw: unknown
try {
raw = JSON.parse(text)
} catch {
return undefined
}
if (!raw || typeof raw !== 'object') return undefined
const r = raw as Record<string, unknown>
if (!Array.isArray(r.nodes) || !Array.isArray(r.edges)) return undefined
const nodes: FlowNode[] = []
for (const n of r.nodes as Record<string, unknown>[]) {
if (!n || typeof n.id !== 'string' || !FLOW_SHAPES.includes(n.shape as FlowShape)) continue
if (!Number.isFinite(n.x) || !Number.isFinite(n.y) || nodes.some((m) => m.id === n.id)) continue
nodes.push({ id: n.id, shape: n.shape as FlowShape, x: n.x as number, y: n.y as number, text: typeof n.text === 'string' ? n.text : '' })
}
let doc: FlowDoc = { title: typeof r.title === 'string' ? r.title : '', nodes, edges: [] }
for (const e of r.edges as Record<string, unknown>[]) {
if (!e || typeof e.from !== 'string' || typeof e.to !== 'string' || typeof e.port !== 'string') continue
doc = connect(doc, e.from, e.port as FlowPort, e.to)
}
if (Array.isArray(r.yesRight)) doc.yesRight = (r.yesRight as unknown[]).filter((id): id is string => typeof id === 'string' && nodes.some((n) => n.id === id))
return doc
}
50 changes: 50 additions & 0 deletions web/src/i18n/de.strings
Original file line number Diff line number Diff line change
Expand Up @@ -690,3 +690,53 @@
"web_code_scopes" = "Blöcke farbig";
"web_code_scopes_hint" = "Code und Struktogramm: Schleife rosa, Verzweigung blau, Methode gelb, Klasse grün";
"web_pseudocode" = "Pseudocode";

/* VisuFlow (PAP zeichnen) */
"web_visuflow_subtitle" = "PAP zeichnen";
"web_visuflow_world_hint" = "VisuFlow: einen Programmablaufplan (PAP, englisch flowchart) frei zeichnen und als Bild oder PDF exportieren. Wechseln geht jederzeit im Zahnrad.";
"web_visuflow_palette" = "Symbole";
"web_visuflow_palette_hint" = "Antippen hängt das Symbol unter das gewählte an und verbindet es mit einem Pfeil. Du kannst es auch auf die Fläche ziehen.";
"web_visuflow_shape_start" = "Start";
"web_visuflow_shape_ende" = "Ende";
"web_visuflow_shape_anweisung" = "Anweisung";
"web_visuflow_shape_ein" = "Ein-/Ausgabe";
"web_visuflow_shape_verzweigung" = "Verzweigung";
"web_visuflow_shape_unterprogramm" = "Unterprogramm";
"web_visuflow_shape_start_tip" = "Oval: Hier beginnt der Ablauf";
"web_visuflow_shape_ende_tip" = "Oval: Hier endet der Ablauf";
"web_visuflow_shape_anweisung_tip" = "Rechteck: eine Anweisung, zum Beispiel „Wasser kochen“";
"web_visuflow_shape_ein_tip" = "Parallelogramm: Eingabe oder Ausgabe, zum Beispiel „Eingabe: Zahl“";
"web_visuflow_shape_verzweigung_tip" = "Raute: eine Frage mit den Ausgängen ja und nein";
"web_visuflow_shape_unterprogramm_tip" = "Rechteck mit zwei Strichen: ruft ein Unterprogramm auf";
"web_visuflow_text_start" = "Start";
"web_visuflow_text_ende" = "Ende";
"web_visuflow_text_anweisung" = "Anweisung";
"web_visuflow_text_ein" = "Eingabe: Zahl";
"web_visuflow_text_verzweigung" = "Bedingung?";
"web_visuflow_text_unterprogramm" = "Unterprogramm";
"web_visuflow_new_tip" = "Neu: leerer PAP mit Start (Rückgängig holt den alten zurück)";
"web_visuflow_open_tip" = "PAP-Datei (.visuflow) öffnen";
"web_visuflow_save_tip" = "PAP als Datei (.visuflow) speichern";
"web_visuflow_delete" = "Löschen";
"web_visuflow_delete_tip" = "Gewähltes Symbol oder gewählten Pfeil löschen (Entf)";
"web_visuflow_swap" = "ja/nein tauschen";
"web_visuflow_swap_tip" = "Raute: „ja“ und „nein“ tauschen die Seiten";
"web_visuflow_example" = "Beispiel";
"web_visuflow_example_tip" = "Beispiel „Tee kochen“ aus Lektion 1 laden (Rückgängig holt den alten PAP zurück)";
"web_visuflow_example_title" = "Tee kochen";
"web_visuflow_example_water" = "Wasser kochen";
"web_visuflow_example_bag" = "Teebeutel in die Tasse";
"web_visuflow_example_hot" = "Tee noch zu heiß?";
"web_visuflow_example_wait" = "Warten";
"web_visuflow_example_drink" = "Tee trinken";
"web_visuflow_title_label" = "Titel des PAP";
"web_visuflow_title_ph" = "Titel des PAP";
"web_visuflow_canvas_label" = "Zeichenfläche des PAP";
"web_visuflow_open_error" = "Die Datei ist kein VisuFlow-PAP.";
"web_visuflow_saved" = "Gespeichert als %@";
"web_visuflow_status_empty" = "Zieh ein Symbol aus der Leiste auf die Fläche oder tippe es an.";
"web_visuflow_status_idle" = "Symbol antippen wählt es aus. Vom Punkt am Ausgang ziehst du einen Pfeil zum nächsten Symbol.";
"web_visuflow_status_node" = "Ziehen verschiebt das Symbol, nochmal antippen ändert den Text. Vom Punkt am Ausgang einen Pfeil ziehen.";
"web_visuflow_status_edge" = "Pfeil gewählt. Löschen entfernt ihn.";
"web_visuflow_status_connect" = "Loslassen über dem Symbol, zu dem der Pfeil führt.";
"web_visuflow_edit_label" = "Text im Symbol";
50 changes: 50 additions & 0 deletions web/src/i18n/en.strings
Original file line number Diff line number Diff line change
Expand Up @@ -690,3 +690,53 @@
"web_code_scopes" = "Colored blocks";
"web_code_scopes_hint" = "Code and structogram: loop pink, branch blue, method yellow, class green";
"web_pseudocode" = "Pseudocode";

/* VisuFlow (draw flowcharts) */
"web_visuflow_subtitle" = "Draw flowcharts";
"web_visuflow_world_hint" = "VisuFlow: draw a program flowchart freely and export it as an image or PDF. You can switch at any time in the gear menu.";
"web_visuflow_palette" = "Symbols";
"web_visuflow_palette_hint" = "Tap a symbol to add it below the selected one, joined by an arrow. You can also drag it onto the canvas.";
"web_visuflow_shape_start" = "Start";
"web_visuflow_shape_ende" = "End";
"web_visuflow_shape_anweisung" = "Statement";
"web_visuflow_shape_ein" = "Input/Output";
"web_visuflow_shape_verzweigung" = "Decision";
"web_visuflow_shape_unterprogramm" = "Subroutine";
"web_visuflow_shape_start_tip" = "Oval: the flow starts here";
"web_visuflow_shape_ende_tip" = "Oval: the flow ends here";
"web_visuflow_shape_anweisung_tip" = "Rectangle: one statement, for example “Boil water”";
"web_visuflow_shape_ein_tip" = "Parallelogram: input or output, for example “Input: number”";
"web_visuflow_shape_verzweigung_tip" = "Diamond: a question with the exits yes and no";
"web_visuflow_shape_unterprogramm_tip" = "Rectangle with two bars: calls a subroutine";
"web_visuflow_text_start" = "Start";
"web_visuflow_text_ende" = "End";
"web_visuflow_text_anweisung" = "Statement";
"web_visuflow_text_ein" = "Input: number";
"web_visuflow_text_verzweigung" = "Condition?";
"web_visuflow_text_unterprogramm" = "Subroutine";
"web_visuflow_new_tip" = "New: empty flowchart with Start (Undo brings the old one back)";
"web_visuflow_open_tip" = "Open a flowchart file (.visuflow)";
"web_visuflow_save_tip" = "Save the flowchart as a file (.visuflow)";
"web_visuflow_delete" = "Delete";
"web_visuflow_delete_tip" = "Delete the selected symbol or arrow (Del)";
"web_visuflow_swap" = "swap yes/no";
"web_visuflow_swap_tip" = "Diamond: “yes” and “no” swap sides";
"web_visuflow_example" = "Example";
"web_visuflow_example_tip" = "Load the example “Make tea” from lesson 1 (Undo brings the old flowchart back)";
"web_visuflow_example_title" = "Make tea";
"web_visuflow_example_water" = "Boil water";
"web_visuflow_example_bag" = "Tea bag into the cup";
"web_visuflow_example_hot" = "Tea still too hot?";
"web_visuflow_example_wait" = "Wait";
"web_visuflow_example_drink" = "Drink tea";
"web_visuflow_title_label" = "Flowchart title";
"web_visuflow_title_ph" = "Flowchart title";
"web_visuflow_canvas_label" = "Flowchart canvas";
"web_visuflow_open_error" = "This file is not a VisuFlow flowchart.";
"web_visuflow_saved" = "Saved as %@";
"web_visuflow_status_empty" = "Drag a symbol from the bar onto the canvas or tap it.";
"web_visuflow_status_idle" = "Tap a symbol to select it. Drag from the dot at its exit to draw an arrow to the next symbol.";
"web_visuflow_status_node" = "Drag to move the symbol, tap again to change the text. Drag an arrow from the dot at the exit.";
"web_visuflow_status_edge" = "Arrow selected. Delete removes it.";
"web_visuflow_status_connect" = "Let go over the symbol the arrow leads to.";
"web_visuflow_edit_label" = "Text in the symbol";
Loading
Loading