Repository navigation
Expand file tree
/
Copy pathpap.ts
More file actions
157 lines (147 loc) · 5.93 KB
/
Copy pathpap.ts
File metadata and controls
157 lines (147 loc) · 5.93 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
/**
* PAP bauen: Ein Programmablaufplan mit leeren Plätzen, die fertigen Elemente legt man selbst hinein.
*
* ::: pap
* titel: Übung: Kuchen backen als PAP
* auftrag: Lege jedes Element an seinen Platz.
* start: Start
* anweisung: Teig rühren
* frage: Teig glatt? | nein -> Teig rühren
* frage: Stäbchen sauber? | nein: 5 Minuten weiterbacken
* ausgabe: „Fertig!“
* ende: Ende
* erklärung: Wird gezeigt, wenn alles stimmt.
* legende: ja
* :::
*
* Die Zeilen stehen von oben nach unten in der Hauptspalte. Eine Frage (Raute) hat zwei Ausgänge: Einer geht nach unten
* weiter, der andere steht hinter `|`. `nein -> Text` führt mit einem Pfeil zurück nach oben zu dem Element mit diesem
* Text. `nein: Text` führt zu einer Anweisung rechts daneben, von dort geht ein Pfeil zurück zur Raute (Schleife wie
* „1 Minute warten“ in Lektion 1). Statt `nein` geht auch `ja`; der andere Ausgang ist dann der nach unten.
*/
export type PapShape = 'oval' | 'rechteck' | 'raute' | 'parallelogramm'
export interface PapNode {
shape: PapShape
text: string
/** Nur Raute: welcher Ausgang zur Seite geht. */
sideExit?: 'ja' | 'nein'
/** Rücksprung nach oben: Index des Ziels in `nodes`. */
backTo?: number
/** Anweisung rechts neben der Raute (Schleife), Index in `nodes`. */
side?: number
/** Steht rechts neben einer Raute statt in der Hauptspalte. */
isSide?: boolean
}
export interface Pap {
title: string
task: string
nodes: PapNode[]
explanation?: string
/** `legende: ja` zeigt unter dem Plan, was die Symbole bedeuten. */
legend?: boolean
}
const shapeOf: Record<string, PapShape> = {
start: 'oval',
ende: 'oval',
anweisung: 'rechteck',
eingabe: 'parallelogramm',
ausgabe: 'parallelogramm',
frage: 'raute',
}
export function parsePap(inner: string[], file: string): Pap {
const pap: Pap = { title: '', task: '', nodes: [] }
const pending: { node: number; target: string; line: string }[] = []
for (const raw of inner) {
const line = raw.trim()
if (!line) continue
const m = /^([a-zäöü]+):\s*(.*)$/i.exec(line)
if (!m) throw new Error(`${file}: Unbekannte Zeile im PAP: „${line}“.`)
const key = m[1].toLowerCase()
const value = m[2].trim()
if (key === 'titel') pap.title = value
else if (key === 'auftrag') pap.task = value
else if (key === 'erklärung' || key === 'erklaerung') pap.explanation = value
else if (key === 'legende') pap.legend = /^(ja|yes|true|sim)$/i.test(value)
else if (key in shapeOf) {
const [text, exit] = value.split(/\s+\|\s+/)
if (!text) throw new Error(`${file}: Leeres Element im PAP: „${line}“.`)
const node: PapNode = { shape: shapeOf[key], text: text.trim() }
pap.nodes.push(node)
if (key !== 'frage') {
if (exit) throw new Error(`${file}: Nur eine Frage hat einen zweiten Ausgang: „${line}“.`)
continue
}
const e = /^(ja|nein)\s*(->|:)\s*(.+)$/i.exec(exit ?? '')
if (!e) throw new Error(`${file}: Eine Frage braucht „| nein -> Text“ oder „| nein: Text“: „${line}“.`)
node.sideExit = e[1].toLowerCase() as 'ja' | 'nein'
if (e[2] === ':') {
node.side = pap.nodes.length
pap.nodes.push({ shape: 'rechteck', text: e[3].trim(), isSide: true })
} else {
pending.push({ node: pap.nodes.length - 1, target: e[3].trim(), line })
}
} else throw new Error(`${file}: Unbekannter Schlüssel „${m[1]}“ im PAP.`)
}
for (const p of pending) {
const target = pap.nodes.findIndex((n, i) => i < p.node && !n.isSide && n.text === p.target)
if (target < 0) throw new Error(`${file}: Rücksprung zu „${p.target}“: kein Element mit diesem Text weiter oben („${p.line}“).`)
pap.nodes[p.node].backTo = target
}
if (!pap.title) throw new Error(`${file}: Ein PAP braucht einen Titel.`)
if (pap.nodes.length < 3) throw new Error(`${file}: PAP „${pap.title}“ braucht mindestens drei Elemente.`)
return pap
}
/** Ein Element passt auf einen Platz, wenn Form und Text stimmen (zwei gleiche Elemente sind austauschbar). */
export function papFits(pap: Pap, slot: number, card: number): boolean {
const a = pap.nodes[slot]
const b = pap.nodes[card]
return a.shape === b.shape && a.text === b.text
}
/** Lage im Bild (viewBox 400 breit): Mittelpunkt, Breite, Höhe. */
export interface PapBox {
x: number
y: number
w: number
h: number
}
export const PAP_WIDTH = 400
const MAIN_X = 140
const MAIN_W = 180
const SIDE_X = 327
const SIDE_W = 130
const GAP = 20
export function papHeight(shape: PapShape): number {
return shape === 'raute' ? 56 : shape === 'oval' ? 30 : 36
}
/** Plätze der Elemente und Gesamthöhe; Seitenkästen stehen auf Höhe ihrer Raute. */
export function papLayout(pap: Pap): { boxes: PapBox[]; height: number } {
const boxes: PapBox[] = []
let y = 10
pap.nodes.forEach((n, i) => {
if (n.isSide) return
const h = papHeight(n.shape)
// Platz für den Pfeil, der von oben in eine Schleife zurückkommt.
if (pap.nodes.some((o) => o.backTo === i) || n.side !== undefined) y += 12
boxes[i] = { x: MAIN_X, y: y + h / 2, w: n.shape === 'raute' ? 170 : n.shape === 'oval' ? 110 : MAIN_W, h }
if (n.side !== undefined) boxes[n.side] = { x: SIDE_X, y: y + h / 2, w: SIDE_W, h: 40 }
y += h + GAP
})
return { boxes, height: y - GAP + 10 }
}
/** Text auf höchstens zwei Zeilen umbrechen, wenn er bei Schriftgröße 13 nicht in `width` passt. */
export function papLines(text: string, width: number): string[] {
const fits = (s: string) => s.length * 6.6 <= width
if (fits(text)) return [text]
const words = text.split(' ')
let best: string[] = [text]
let bestLong = Infinity
for (let i = 1; i < words.length; i++) {
const lines = [words.slice(0, i).join(' '), words.slice(i).join(' ')]
const long = Math.max(...lines.map((l) => l.length))
if (long < bestLong) {
best = lines
bestLong = long
}
}
return best
}