feat: add React card renderer and interactive UML

This commit is contained in:
user
2026-07-16 13:19:22 +02:00
parent da06a2b81c
commit 41804a8ad1
9 changed files with 2143 additions and 37 deletions
+729
View File
@@ -0,0 +1,729 @@
import React, { useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import "./react.css";
type Status = "not_started" | "discussed" | "completed";
type InteractiveArea = "compile" | "bss" | "stack" | "registers" | "text";
type InteractiveSymbol = {
id: string;
area: InteractiveArea;
declaration: string;
description: string;
};
type UmlStage = {
id: string;
label: string;
description: string;
code_ref?: string;
};
type InteractiveAsset = {
kind: "allocator-memory-flow" | "uml-sequence";
storage_key?: string;
arena_size?: number;
allocations?: number[];
symbols?: InteractiveSymbol[];
stages?: UmlStage[];
};
type Asset = {
label: string;
caption: string;
alt: string;
href: string;
width: number;
kind: string;
source_href?: string;
interactive?: InteractiveAsset | null;
};
type FlowStep = { id: string; title: string; content_html: string };
type FlowBlock = {
id: string;
kind: "block" | "exercise";
title: string;
content_html: string;
steps: FlowStep[];
};
type Task = {
ref: string;
title: string;
uuid?: string;
criterion: string;
prompt_html?: string;
flow: FlowBlock[];
};
type Section = {
id: string;
index: number;
title: string;
header_html: string;
left_margin_html: string;
right_margin_html: string;
content_html: string;
tasks: Task[];
assets: Asset[];
steps: { id: string; title: string }[];
};
type Progress = {
items: Record<string, { status: Status }>;
summary: { total: number; counts: Record<Status, number> };
};
type CardModel = {
schema: string;
card: Record<string, string>;
total_pages: number;
front: any;
sections: Section[];
dictionary: { header_html: string; content_html: string };
toc: { id: string; label: string }[];
};
const Html = ({ html, className }: { html: string; className?: string }) => (
<div className={className} dangerouslySetInnerHTML={{ __html: html }} />
);
function Header({ html }: { html: string }) {
return <Html html={html} />;
}
function Margin({ side, html }: { side: "left" | "right"; html: string }) {
return (
<aside
className={`pdf-margin ${side}`}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
function Sheet({
header,
left = "",
right = "",
children,
className = "",
}: React.PropsWithChildren<any>) {
return (
<article className={`sheet ${className}`}>
<div className="sheet-content">
<Header html={header} />
<Margin side="left" html={left} />
<section className="pdf-main card-section">{children}</section>
<Margin side="right" html={right} />
</div>
</article>
);
}
function Topbar({
toc,
scale,
setScale,
}: {
toc: CardModel["toc"];
scale: number;
setScale: (value: number) => void;
}) {
return (
<nav className="topbar">
<div className="topbar-inner">
<details>
<summary>Spis treści</summary>
<div className="toc-links">
{toc.map((item) => (
<a key={item.id} href={`#${item.id}`}>
{item.label}
</a>
))}
</div>
</details>
<div className="viewer-zoom-controls">
<output className="viewer-zoom-status">
Płótno {Math.round(scale * 100)}% · Treść 100%
</output>
<button className="viewer-zoom-reset" onClick={() => setScale(2.18)}>
Reset zoom
</button>
</div>
</div>
</nav>
);
}
function FrontPage({ front }: { front: any }) {
return (
<Sheet
header={front.header_html}
left={front.left_margin_html}
right={front.right_margin_html}
className="front-sheet"
>
<section className="front-scope front-goal">
<h2>{front.goal_title}</h2>
<Html html={front.goal_html} />
</section>
<section className="front-scope front-card-scope">
<h2>{front.scope_title}</h2>
{front.scope_html && <Html html={front.scope_html} />}
<div className="scope-table-wrap">
<table className="scope-table scope-table--task">
<thead>
<tr>
{front.scope_headers.map((header: string) => (
<th key={header}>{header}</th>
))}
</tr>
</thead>
<tbody>
{front.scope_rows.map((row: any) => (
<tr
key={`${row.chapter}-${row.task}`}
className={row.key ? "scope-row--key" : ""}
>
<td>
<code>{row.chapter}</code>
</td>
<td>
<code>{row.task}</code>
</td>
<td dangerouslySetInnerHTML={{ __html: row.idea_html }} />
<td>{row.priority}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</Sheet>
);
}
function TaskStep({ taskRef, step }: { taskRef: string; step: FlowStep }) {
return (
<li id={`${taskRef}-${step.id}`}>
<strong>{step.title}</strong>
<Html html={step.content_html} />
</li>
);
}
function TaskBlock({ taskRef, block }: { taskRef: string; block: FlowBlock }) {
const label = block.kind === "exercise" ? "Ćwiczenie" : "Block";
return (
<section
className={`task-block task-block--${block.kind}`}
id={`${taskRef}-${block.id}`}
>
<header>
<span>{label}</span>
<h4>{block.title}</h4>
</header>
<Html html={block.content_html} />
{block.steps.length > 0 && (
<ol className="block-steps">
{block.steps.map((step) => (
<TaskStep key={step.id} taskRef={taskRef} step={step} />
))}
</ol>
)}
</section>
);
}
function TaskFlow({ task }: { task: Task }) {
if (!task.flow.length)
return (
<article className="task-legacy" id={task.ref}>
<strong>{task.ref.toUpperCase()}</strong>
<Html html={task.prompt_html ?? ""} />
<footer>
<strong>Task acceptance:</strong> {task.criterion}
</footer>
</article>
);
return (
<article className="task-flow" id={task.ref}>
<header className="task-flow-header">
<span>{task.ref.toUpperCase()}</span>
<h3>{task.title}</h3>
{task.uuid && <code>{task.uuid}</code>}
</header>
{task.flow.map((block) => (
<TaskBlock key={block.id} taskRef={task.ref} block={block} />
))}
<footer>
<strong>Task acceptance:</strong> {task.criterion}
</footer>
</article>
);
}
const areaLabels: Record<InteractiveArea, string> = {
compile: "KOMPILACJA",
bss: ".BSS",
stack: "STOS",
registers: "REJESTRY",
text: ".TEXT",
};
const allocationColors = ["#9bcfe2", "#c4dfc0", "#f4c889", "#d2b6dc"];
function InteractiveMemoryFlow({
config,
title,
}: {
config: InteractiveAsset;
title: string;
}) {
const arenaSize = config.arena_size ?? 64;
const allocations = config.allocations ?? [5, 7];
const symbols = config.symbols ?? [];
const storageKey = `${config.storage_key ?? "allocator-memory-flow"}-stage`;
const [stage, setStage] = useState(() =>
Math.min(allocations.length, Number(localStorage.getItem(storageKey) ?? 0)),
);
const [focus, setFocus] = useState(symbols[0]?.id ?? "");
useEffect(
() => localStorage.setItem(storageKey, String(stage)),
[stage, storageKey],
);
const used = allocations.slice(0, stage).reduce((sum, size) => sum + size, 0);
const focused = symbols.find((symbol) => symbol.id === focus) ?? symbols[0];
const cells = Array.from({ length: arenaSize }, (_, index) => index);
const allocationIndex = (cell: number) => {
let end = 0;
for (let index = 0; index < stage; index += 1) {
end += allocations[index];
if (cell < end) return index;
}
return -1;
};
const stageText =
stage === 0
? "allocator_reset(): allocp = allocbuf"
: `alloc_local(${allocations[stage - 1]}): allocp = allocbuf + ${used}`;
const areas = (Object.keys(areaLabels) as InteractiveArea[]).filter((area) =>
symbols.some((symbol) => symbol.area === area),
);
return (
<div className="memory-react" aria-label={`Interaktywny diagram: ${title}`}>
<header>
<div>
<small>INTERAKTYWNY MODEL PAMIĘCI</small>
<h3>{title}</h3>
</div>
<div className="memory-actions">
<button aria-pressed={stage === 0} onClick={() => setStage(0)}>
definicje / reset
</button>
{allocations.map((size, index) => (
<button
key={`${size}-${index}`}
aria-pressed={stage === index + 1}
onClick={() => setStage(index + 1)}
>
alloc({size})
</button>
))}
</div>
</header>
<div className="memory-lanes">
{areas.map((area) => (
<section className={`memory-lane ${area}`} key={area}>
<b>{areaLabels[area]}</b>
{symbols
.filter((symbol) => symbol.area === area)
.map((symbol) => (
<button
key={symbol.id}
className={focus === symbol.id ? "active" : ""}
onClick={() => setFocus(symbol.id)}
>
<code>{symbol.declaration}</code>
</button>
))}
</section>
))}
</div>
<div className="arena-wrap">
<div className="arena-label">
<code>allocbuf[{arenaSize}]</code>
<span>
użyte {used} B / wolne {arenaSize - used} B
</span>
</div>
<div
className="arena"
style={
{
"--allocp": used,
"--arena-size": arenaSize,
} as React.CSSProperties
}
>
{cells.map((cell) => {
const segment = allocationIndex(cell);
return (
<i
key={cell}
style={
segment >= 0
? {
background:
allocationColors[segment % allocationColors.length],
}
: undefined
}
/>
);
})}
<span className="allocp">allocp {used}</span>
</div>
</div>
<p className="memory-stage">
<strong>
{stage + 1}/{allocations.length + 1}
</strong>{" "}
<code>{stageText}</code>
</p>
{focused && (
<aside className="memory-inspector">
<strong>{focused.id}</strong>
<code>{focused.declaration}</code>
<span>{focused.description}</span>
</aside>
)}
</div>
);
}
function InteractiveUmlSequence({
config,
asset,
}: {
config: InteractiveAsset;
asset: Asset;
}) {
const stages = config.stages ?? [];
const storageKey = `${config.storage_key ?? asset.label}-uml-stage`;
const [stageIndex, setStageIndex] = useState(() =>
Math.max(
0,
Math.min(
stages.length - 1,
Number(localStorage.getItem(storageKey) ?? 0),
),
),
);
useEffect(
() => localStorage.setItem(storageKey, String(stageIndex)),
[stageIndex, storageKey],
);
const stage = stages[stageIndex];
return (
<div className="uml-react">
<header>
<div>
<small>KANONICZNY DIAGRAM UML · PLANTUML</small>
<h3>Reset i dwa przydziały liniowe</h3>
</div>
<div className="uml-actions">
{stages.map((item, index) => (
<button
key={item.id}
aria-pressed={stageIndex === index}
onClick={() => setStageIndex(index)}
>
{item.label}
</button>
))}
</div>
</header>
<a
className="uml-canvas"
href={asset.href}
target="_blank"
rel="noopener"
>
<img src={asset.href} alt={asset.alt} />
</a>
{stage && (
<aside className="uml-stage">
<strong>{stage.label}</strong>
<span>{stage.description}</span>
{stage.code_ref && <code>{stage.code_ref}</code>}
</aside>
)}
</div>
);
}
function AssetFigure({ asset, number }: { asset: Asset; number: number }) {
const interactiveKind = asset.interactive?.kind;
const isInteractive = Boolean(interactiveKind);
return (
<figure
className={`card-asset asset-${asset.kind} ${isInteractive ? "asset-interactive" : ""}`}
id={asset.label.replace(":", "-")}
>
{interactiveKind === "uml-sequence" ? (
<InteractiveUmlSequence config={asset.interactive!} asset={asset} />
) : interactiveKind === "allocator-memory-flow" ? (
<>
<InteractiveMemoryFlow
config={asset.interactive!}
title={asset.caption}
/>
<img
className="asset-static-fallback"
src={asset.href}
alt={asset.alt}
/>
</>
) : (
<a href={asset.href} target="_blank" rel="noopener">
<img
src={asset.href}
alt={asset.alt}
loading="lazy"
style={{ width: `${asset.width * 100}%` }}
/>
</a>
)}
{asset.source_href && (
<a
className="asset-source-link"
href={asset.source_href}
target="_blank"
rel="noopener"
>
Źródło PlantUML
</a>
)}
<figcaption>
Rys. {number}. {asset.caption}
</figcaption>
</figure>
);
}
function StepMap({
steps,
progress,
onCycle,
}: {
steps: Section["steps"];
progress: Progress | null;
onCycle: (id: string, status: Status) => void;
}) {
if (!steps.length) return null;
const labels: Record<Status, string> = {
not_started: "○ do omówienia",
discussed: "◐ omówione",
completed: "● wykonane",
};
return (
<div className="step-map">
{steps.map((step) => {
const status: Status =
progress?.items?.[step.id]?.status ?? "not_started";
return (
<div
className={`step-row ${status === "completed" ? "lesson-progress-completed" : ""}`}
key={step.id}
>
<button
className="lesson-progress-control"
data-status={status}
onClick={() => onCycle(step.id, status)}
disabled={!progress}
>
{labels[status]}
</button>
<span className="step-title">{step.title}</span>
</div>
);
})}
</div>
);
}
function SectionPage({
section,
progress,
onCycle,
figureOffset,
}: {
section: Section;
progress: Progress | null;
onCycle: (id: string, status: Status) => void;
figureOffset: number;
}) {
return (
<Sheet
header={section.header_html}
left={section.left_margin_html}
right={section.right_margin_html}
className="section-sheet"
>
<div id={section.id} className="section-anchor" />
<h2 className="section-heading">
<span className="section-index">{section.index}</span>
<span className="section-title">{section.title}</span>
</h2>
<StepMap steps={section.steps} progress={progress} onCycle={onCycle} />
{section.content_html && <Html html={section.content_html} />}
<div className="tasks">
{section.tasks.map((task) => (
<TaskFlow key={task.ref} task={task} />
))}
</div>
{section.assets.map((asset, index) => (
<AssetFigure
key={asset.label}
asset={asset}
number={figureOffset + index + 1}
/>
))}
</Sheet>
);
}
function ProgressDock({
progress,
error,
}: {
progress: Progress | null;
error: string;
}) {
if (!progress?.summary && !error) return null;
const counts = progress?.summary.counts;
return (
<aside className="lesson-progress-dock">
<strong>Przebieg zajęć</strong>
{counts && (
<p>
Wykonane: {counts.completed}/{progress!.summary.total} · omówione:{" "}
{counts.discussed}/{progress!.summary.total}
</p>
)}
{error && <p className="lesson-progress-error">{error}</p>}
<a href="/api/report/teams.md" target="_blank" rel="noopener">
Otwórz raport do Teams
</a>
</aside>
);
}
function App({ model }: { model: CardModel }) {
const [scale, setScaleState] = useState(2.18);
const [progress, setProgress] = useState<Progress | null>(null);
const [progressError, setProgressError] = useState("");
const setScale = (value: number) =>
setScaleState(Math.max(0.25, Math.min(3, value)));
useEffect(() => {
document.documentElement.style.setProperty(
"--viewer-canvas-scale",
scale.toFixed(3),
);
}, [scale]);
useEffect(() => {
fetch("/api/progress")
.then((response) =>
response.ok
? response.json()
: Promise.reject(new Error(`HTTP ${response.status}`)),
)
.then((data) => setProgress(data.progress))
.catch((error) =>
setProgressError(
`Postęp działa tylko przez serwer karty (${error.message}).`,
),
);
}, []);
useEffect(() => {
const wheel = (event: WheelEvent) => {
if (!event.altKey || event.ctrlKey) return;
event.preventDefault();
setScaleState((current) =>
Math.max(
0.25,
Math.min(
3,
current *
Math.exp(-Math.max(-120, Math.min(120, event.deltaY)) * 0.001),
),
),
);
};
window.addEventListener("wheel", wheel, { passive: false });
return () => window.removeEventListener("wheel", wheel);
}, []);
const cycle = async (id: string, current: Status) => {
const next: Record<Status, Status> = {
not_started: "discussed",
discussed: "completed",
completed: "not_started",
};
try {
const response = await fetch(`/api/progress/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ status: next[current] }),
});
if (!response.ok) throw new Error(await response.text());
setProgress((await response.json()).progress);
setProgressError("");
} catch (error) {
setProgressError(
`Nie zapisano postępu: ${error instanceof Error ? error.message : String(error)}`,
);
}
};
let figureCount = 0;
return (
<>
<Topbar toc={model.toc} scale={scale} setScale={setScale} />
<main className="paper">
<FrontPage front={model.front} />
{model.sections.map((section) => {
const offset = figureCount;
figureCount += section.assets.length;
return (
<SectionPage
key={section.id}
section={section}
progress={progress}
onCycle={cycle}
figureOffset={offset}
/>
);
})}
<Sheet
header={model.dictionary.header_html}
className="dictionary-sheet"
>
<h2>Słownik WE/EN/EK/KW</h2>
<Html html={model.dictionary.content_html} />
</Sheet>
</main>
<ProgressDock progress={progress} error={progressError} />
</>
);
}
async function start() {
const root = createRoot(document.getElementById("root")!);
try {
const response = await fetch("./card-data.json", { cache: "no-store" });
if (!response.ok)
throw new Error(`Nie można wczytać card-data.json: ${response.status}`);
const model = (await response.json()) as CardModel;
root.render(<App model={model} />);
} catch (error) {
root.render(
<main className="app-load-error">
<h1>Nie udało się uruchomić karty</h1>
<pre>{error instanceof Error ? error.message : String(error)}</pre>
</main>,
);
}
}
void start();