This commit is contained in:
team3
2026-07-12 04:20:07 +02:00
parent 14bcf1b8e9
commit a284e03225
29 changed files with 866 additions and 329 deletions

View File

@@ -1,14 +1,15 @@
<script setup>
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import { api } from '../api.js'
import { render, gefiltert, lesestat, renderGestuft } from '../markdown.js'
import { render, lesestat } from '../markdown.js'
const props = defineProps({ topic: String, state: Object })
const daten = ref(null)
const level = ref('E') // Default: Einstieg — Umfang wächst per Toggle (E→M→S)
const level = ref('E') // Durchgang-Wahl: jede Stufe ist ein eigener Lese-Durchgang
const ansicht = ref('erklaerend') // erklaerend = Fließtext | kompakt = Stichpunkte
const LEVEL_ICON = { E: '●○○', M: '●●○', S: '●●●' }
const LEVEL_NAME = { E: 'Umfang: Grundgerüst', M: 'Umfang: + Standardstoff', S: 'Umfang: alles' }
const LEVEL_NAME = { E: 'Durchgang 1: Grundgerüst', M: 'Durchgang 2: Standardstoff',
S: 'Durchgang 3: Feinheiten' }
const fokus = ref(false) // Fokus: Vollbild — App-Chrome + Navigation weg, Karten aufgelöst
watch(fokus, (f) => document.body.classList.toggle('vollbild', f))
onUnmounted(() => document.body.classList.remove('vollbild'))
@@ -28,10 +29,9 @@ const htmlCache = new Map()
watch(daten, () => htmlCache.clear())
function html(s) {
const key = `${ansicht.value}|${level.value}|${s.baustein}`
const key = `${ansicht.value}|${s.baustein}`
if (!htmlCache.has(key)) {
htmlCache.set(key, ansicht.value === 'kompakt'
? render(s.kompakt || s.lang) : renderGestuft(s.lang, level.value))
htmlCache.set(key, render(ansicht.value === 'kompakt' ? s.kompakt : s.lang))
}
return htmlCache.get(key)
}
@@ -47,23 +47,29 @@ function hoehe(i) {
function setAnsicht(a) {
ansicht.value = a
laden()
}
// Nur Durchgänge anbieten, die es in diesem Thema gibt (ohne E- oder
// S-Bausteine überspringt der Toggle die Stufe)
const levels = computed(() => {
const da = new Set((daten.value?.kapitel || []).map((k) => k.level))
const folge = ['E', 'M', 'S'].filter((l) => da.has(l))
return folge.length ? folge : ['E']
})
watch(levels, (l) => { if (!l.includes(level.value)) level.value = l[0] })
function levelToggle() {
const folge = ['E', 'M', 'S']
level.value = folge[(folge.indexOf(level.value) + 1) % 3]
laden()
const folge = levels.value
level.value = folge[(folge.indexOf(level.value) + 1) % folge.length]
}
// Nur Kapitel/Bausteine mit Inhalt im gewählten Umfang anzeigen — der
// Level-Filter ließe sonst leere Titel-Hüllen stehen (36/66 Bausteine ohne E)
// Der gewählte Durchgang: nur seine Kapitel, nur Sections mit Inhalt
const anzeige = computed(() => {
const out = []
for (const kap of daten.value?.kapitel || []) {
const sections = kap.sections.filter((s) => ansicht.value === 'kompakt'
? (s.kompakt || s.lang || '').trim()
: lesestat(gefiltert(s.lang, level.value)).woerter >= 10)
if (kap.level !== level.value) continue
const sections = kap.sections.filter((s) =>
((ansicht.value === 'kompakt' ? s.kompakt : s.lang) || '').trim())
if (sections.length) out.push({ ...kap, sections })
}
return out
@@ -77,8 +83,7 @@ const stats = computed(() => {
let woerter = ansicht.value === 'kompakt' ? 0 : lesestat(kap.intro).woerter
let formeln = 0
for (const s of kap.sections) {
const st = lesestat(ansicht.value === 'kompakt' ? (s.kompakt || s.lang)
: gefiltert(s.lang, level.value))
const st = lesestat(ansicht.value === 'kompakt' ? s.kompakt : s.lang)
woerter += st.woerter
formeln += st.displayFormeln
}
@@ -156,12 +161,23 @@ function onScroll(e) {
<div v-for="(kap, i) in anzeige" :key="kap.kapitel_id" class="kapitel"
:ref="(el) => setKapEl(i, el)">
<h2 class="kapitel-titel">{{ i + 1 }}. {{ kap.titel }}</h2>
<div class="kapitel-meta">
{{ (stats.je[i]?.woerter || 0).toLocaleString('de-DE') }} Wörter ·
{{ zeit(stats.je[i]?.minuten || 0) }}
</div>
<template v-if="sichtbar(i)">
<p v-if="kap.intro && ansicht === 'erklaerend'"
style="color: var(--dim); margin-top: 0">{{ kap.intro }}</p>
<div v-for="s in kap.sections" :key="s.baustein" class="guide-section">
<h3 style="margin-top: 0">{{ s.titel }}</h3>
<div :class="ansicht === 'kompakt' ? 'kompakt' : 'markdown'" v-html="html(s)"></div>
<details v-if="ansicht === 'erklaerend' && s.fragen?.length" class="antworten">
<summary>Antworten zu Prüfe dich"</summary>
<div v-for="(f, fi) in s.fragen" :key="fi" class="antwort">
<div class="frage">{{ f.frage }}</div>
<div v-html="render(f.antwort)"></div>
</div>
</details>
</div>
</template>
<div v-else class="kapitel-platzhalter" :style="{ height: hoehe(i) + 'px' }"></div>

View File

@@ -48,8 +48,10 @@ export function render(text) {
return DOMPurify.sanitize(marked.parse(mathe(text || '')))
}
const RANG = { E: 0, M: 1, S: 2 }
const MARKER = /<!--\s*atom:\s*(\d+)\s*\|\s*([^|]*)\|\s*([EMS])\s*-->/g
// Atom-Marker sind eine unsichtbare Backend-Schnittstelle — vor der Anzeige
// ausblenden. Die Stufen-Trennung passiert seit dem Umbau über getrennte
// Bausteine je Level, nicht mehr über Marker-Filterung im Text.
const MARKER = /<!--\s*atom:[^>]*-->/g
// Wortzahl + Display-Formeln des sichtbaren Texts (für die Lesezeit-Schätzung)
export function lesestat(text) {
@@ -61,47 +63,3 @@ export function lesestat(text) {
const woerter = t.split(/\s+/).filter(Boolean).length
return { woerter, displayFormeln }
}
// Text an Atom-Markern in Segmente schneiden; Filter zeigt Atome bis zum gewählten Level.
export function segmente(lang) {
const out = []
let letzt = 0
let aktuell = { level: null, titel: '', text: '' }
for (const m of lang.matchAll(MARKER)) {
aktuell.text = lang.slice(letzt, m.index)
out.push(aktuell)
aktuell = { level: m[3], titel: m[2].trim(), text: '' }
letzt = m.index + m[0].length
}
aktuell.text = lang.slice(letzt)
out.push(aktuell)
return out
}
export function gefiltert(lang, maxLevel) {
if (!maxLevel || maxLevel === 'S') return lang
return segmente(lang)
.filter((s) => s.level === null || RANG[s.level] <= RANG[maxLevel])
.map((s) => s.text)
.join('\n')
}
// Gestuftes Rendern: Segmente über dem Level fallen weg, Segmente DARUNTER
// werden gedimmt (schon gelernt) — der Blick geht auf die neuen Inhalte.
// Kernpunkte/Prüfe-dich (nach dem letzten Atom) bleiben immer ungedimmt.
export function renderGestuft(lang, maxLevel) {
if (!maxLevel || maxLevel === 'E') return render(gefiltert(lang, maxLevel))
const seg = segmente(lang)
const letzt = seg[seg.length - 1]
const i = letzt.text.indexOf('**Kernpunkte:**')
if (i >= 0) {
seg.push({ level: null, titel: '', text: letzt.text.slice(i) })
letzt.text = letzt.text.slice(0, i)
}
return seg.map((s) => {
if (s.level !== null && RANG[s.level] > RANG[maxLevel]) return ''
const html = render(s.text)
const gelernt = s.level !== null && RANG[s.level] < RANG[maxLevel]
return gelernt ? `<div class="gelernt">${html}</div>` : html
}).join('')
}

View File

@@ -203,9 +203,13 @@ body.nav-zu .hauptbereich { padding-top: 38px; }
}
.fokus-aus:hover { opacity: 1; }
.seite.guide-text::-webkit-scrollbar { display: none; }
.markdown .gelernt { opacity: 0.45; } /* niedrigere Level = schon gelernt */
.guide-text .kapitel { scroll-margin-top: 8px; }
.guide-text .kapitel-titel { margin: 30px 0 6px; }
.guide-text .kapitel-titel { margin: 30px 0 2px; }
.guide-text .kapitel-meta { color: var(--dim); font-size: 13px; margin: 0 0 10px; }
.guide-section .antworten { margin-top: 10px; border-top: 1px solid var(--rand); padding-top: 8px; }
.guide-section .antworten summary { cursor: pointer; color: var(--dim); font-size: 14px; }
.guide-section .antworten .antwort { margin: 10px 0; }
.guide-section .antworten .frage { font-weight: 600; font-size: 14px; margin-bottom: 2px; }
.kapitel-platzhalter { border-left: 2px dashed var(--rand); margin: 8px 0 8px 4px; }
/* Kompakt-Ansicht: gleiche Lesetypografie wie der Fließtext, kein Grau-Kasten */
.guide-text .kompakt {