(
+
+));
+
+const Clock = observer(({ clockId }: ClockProps) => {
+ const clockStore = useExtensionStore(clockExtension);
+ const clock = clockStore.useClock(clockId);
+ const header = useMemo(
+ () => (
+
+ Uhr:
+
+ ),
+ [clockId]
+ );
+ return (
+
+
+
+ );
+});
+
+export default Clock;
diff --git a/tdev-website/website/packages/pyodide-code/components/Clock/styles.module.scss b/tdev-website/website/pyodide/clock/src/components/Clock/styles.module.scss
similarity index 100%
rename from tdev-website/website/packages/pyodide-code/components/Clock/styles.module.scss
rename to tdev-website/website/pyodide/clock/src/components/Clock/styles.module.scss
diff --git a/tdev-website/website/packages/pyodide-code/models/Clock.ts b/tdev-website/website/pyodide/clock/src/models/Clock.ts
similarity index 100%
rename from tdev-website/website/packages/pyodide-code/models/Clock.ts
rename to tdev-website/website/pyodide/clock/src/models/Clock.ts
diff --git a/tdev-website/website/stores/ClockStore.ts b/tdev-website/website/pyodide/clock/src/stores/ClockStore.ts
similarity index 50%
rename from tdev-website/website/stores/ClockStore.ts
rename to tdev-website/website/pyodide/clock/src/stores/ClockStore.ts
index 49147b1fe..d951f6b08 100644
--- a/tdev-website/website/stores/ClockStore.ts
+++ b/tdev-website/website/pyodide/clock/src/stores/ClockStore.ts
@@ -1,18 +1,18 @@
-import { Clock } from '@tdev/packages/pyodide-code/models/Clock';
import { action, observable, observableRef } from 'mobx';
+import { Clock } from '../models/Clock';
class ClockStore {
@observableRef accessor defaultClock = new Clock();
- clocks = observable.map
([], { deep: false });
+ readonly clocks = observable.map([], { deep: false });
@action
useClock(id: string, animationSpeed?: number) {
- if (this.clocks.has(id)) {
- const clock = this.clocks.get(id)!;
+ const existingClock = this.clocks.get(id);
+ if (existingClock) {
if (animationSpeed !== undefined) {
- clock.setAnimationSpeed(animationSpeed);
+ existingClock.setAnimationSpeed(animationSpeed);
}
- return clock;
+ return existingClock;
}
const clock = new Clock();
if (animationSpeed !== undefined) {
@@ -24,14 +24,7 @@ class ClockStore {
@action
setTimeAsDegrees(id: string, hourDeg: number, minutesDeg: number, secondsDeg: number) {
- if (this.clocks.has(id)) {
- const clock = this.clocks.get(id)!;
- clock.setClock(hourDeg, minutesDeg, secondsDeg);
- return;
- }
- const clock = new Clock();
- clock.setClock(hourDeg, minutesDeg, secondsDeg);
- this.clocks.set(id, clock);
+ this.useClock(id).setClock(hourDeg, minutesDeg, secondsDeg);
}
}
diff --git a/tdev-website/website/pyodide/cookie-klicker/README.md b/tdev-website/website/pyodide/cookie-klicker/README.md
new file mode 100644
index 000000000..6165492a9
--- /dev/null
+++ b/tdev-website/website/pyodide/cookie-klicker/README.md
@@ -0,0 +1,34 @@
+# Cookie-Klicker
+
+Voraussetzung ist eine Teaching-Dev-Website mit installiertem `pyodide-code`-Package. Kopiere diesen vollständigen Ordner nach `website/pyodide/cookie-klicker` und ergänze im vorhandenen Pyodide-Plugin die `extensions`-Liste:
+
+```ts
+extensions: [
+ './website/pyodide/cookie-klicker'
+]
+```
+
+Starte den Entwicklungsserver neu. Der Browser-Store wird automatisch angelegt; Änderungen am `SiteStore` sind nicht nötig.
+
+## Im Unterrichtsmaterial verwenden
+
+```mdx
+import Cookie from '@tdev/pyodide/cookie-klicker/src/components/Cookie';
+
+
+```
+
+```python
+from cookie_klicker import use_cookie
+from webio import on_click, run
+
+cookie = use_cookie('mein-cookie')
+
+@on_click(cookie)
+def drehen(event):
+ cookie.set_rotation(cookie.rotation + 30)
+
+await run()
+```
+
+Die [Package-Dokumentation](https://github.com/GBSL-Informatik/teaching-dev/blob/main/packages/tdev/pyodide-code/docs/README.mdx) beschreibt die Erweiterungs-API.
diff --git a/tdev-website/website/pyodide/cookie-klicker/client.ts b/tdev-website/website/pyodide/cookie-klicker/client.ts
new file mode 100644
index 000000000..5ae0a320c
--- /dev/null
+++ b/tdev-website/website/pyodide/cookie-klicker/client.ts
@@ -0,0 +1,16 @@
+import { defineClientExtension } from '@tdev/pyodide-code/extensions';
+import type { CookieUpdate } from './protocol';
+import CookieStore from './src/stores/CookieStore';
+
+export default defineClientExtension({
+ createStore: (context) => new CookieStore(context),
+ handleMessage: (store, { targetId, payload }) => {
+ const cookie = store.useCookie(targetId);
+ if (payload.rotation !== undefined) {
+ cookie.setRotation(payload.rotation);
+ }
+ if (payload.color !== undefined) {
+ cookie.setColor(payload.color);
+ }
+ }
+});
diff --git a/tdev-website/website/pyodide/cookie-klicker/docs/index.mdx b/tdev-website/website/pyodide/cookie-klicker/docs/index.mdx
new file mode 100644
index 000000000..954053f2a
--- /dev/null
+++ b/tdev-website/website/pyodide/cookie-klicker/docs/index.mdx
@@ -0,0 +1,121 @@
+---
+page_id: b0dc6afc-4a60-42c7-b850-f5e2e6231097
+---
+
+import CodeShowcase from '@tdev-components/CodeShowcase';
+import Steps from '@tdev-components/Steps';
+import CookieComponent from '@tdev/pyodide/cookie-klicker/src/components/Cookie';
+
+# Cookie-Klicker
+
+Mit `webio` reagiert Python auf Klicks und Tastatureingaben aus dem Browser. Starte das Programm und klicke auf den Cookie:
+
+
+
+
+```py live_pyo id=c7848c97-6b5e-4604-afa7-510e53d8d678 title=Auf_Klicks_reagieren
+from cookie_klicker import use_cookie
+from webio import on_click, run
+
+cookie = use_cookie('cookie-einstieg')
+
+@on_click(cookie)
+def drehen(event):
+ cookie.set_rotation(cookie.rotation + 30)
+
+await run()
+```
+
+
+`webio` gehört zum Pyodide-Package. Die Cookie-Komponente und das Python-Modul `cookie_klicker` gehören zur kopierbaren Cookie-Erweiterung dieser Dokumentationswebsite. Für eine eigene Website ist die [Einrichtung unten](#auf-der-eigenen-website-einrichten) erforderlich.
+
+## Mehrere Cookies und Tastatureingaben
+
+Für die Pfeiltasten muss der zweite Cookie fokussiert sein, etwa durch [[Tab]] oder einen Klick.
+
+
+:::flex
+
+::br
+
+:::
+
+```py live_pyo id=62007a34-9647-4c62-83bd-7a0f829e502a title=Cookie_Klicker
+from random import randint
+from cookie_klicker import use_cookie
+from webio import on_click, on_event, on_key, run
+
+cookie = use_cookie('cookie')
+zweiter_cookie = use_cookie('cookie-zwei')
+klicks = 0
+
+@on_event(source=[cookie.events, zweiter_cookie.events])
+def log(event):
+ print(event)
+
+@on_click(cookie)
+def drehen(event):
+ global klicks
+ klicks += 1
+ cookie.set_rotation(cookie.rotation + randint(0, 360))
+ if klicks % 3 == 0:
+ hue = randint(0, 359)
+ saturation = randint(50, 100)
+ lightness = randint(35, 65)
+ cookie.set_color(f'hsl({hue}, {saturation}%, {lightness}%)')
+
+@on_click(zweiter_cookie)
+def schrittweise_drehen(event):
+ zweiter_cookie.set_rotation(zweiter_cookie.rotation + 30)
+
+@on_key(zweiter_cookie, 'ArrowLeft')
+def nach_links_drehen(event):
+ zweiter_cookie.set_rotation(zweiter_cookie.rotation - 5)
+
+@on_key(zweiter_cookie, 'ArrowRight')
+def nach_rechts_drehen(event):
+ zweiter_cookie.set_rotation(zweiter_cookie.rotation + 5)
+
+await run()
+```
+
+
+Die [webio-Referenz](../../02-webio.mdx) beschreibt die Handler, Ereignisattribute und Grenzen der Ereignisverarbeitung.
+
+## Cookies steuern
+
+`cookieId`
+: Verbindet die React-Komponente mit `use_cookie(id)`. Mehrere Ansichten derselben ID teilen ihren Zustand.
+`use_cookie(id)`
+: Liefert innerhalb derselben Ausführung denselben Handle zurück. Ein neuer Handle verändert die bisherige Anzeige erst mit seinem ersten Update.
+`events`
+: Ereignisquelle des Handles für `@on_event`.
+`rotation`
+: Lokaler Winkel des Handles in Grad, anfangs `0`. Er liest den Browser-Zustand nicht zurück.
+`set_rotation(winkel)`
+: Normalisiert den Winkel auf `[0, 360)` und aktualisiert die Anzeige.
+`color`
+: Lokale Farbe des Handles, anfangs `'var(--ifm-color-warning-light)'`.
+`set_color(css_farbe)`
+: Aktualisiert die Farbe, etwa mit `'#ff8800'` oder `'hsl(120, 70%, 50%)'`.
+
+## Auf der eigenen Website einrichten
+
+Voraussetzung ist das [installierte Pyodide-Package](../../README.mdx#installation).
+
+
+
+1. Kopiere den vollständigen Ordner [cookie-klicker](https://github.com/GBSL-Informatik/teaching-dev/tree/main/tdev-website/website/pyodide/cookie-klicker) nach __website/pyodide/cookie-klicker__. Er enthält Komponenten, Styles, Browser-Store und Python-Modul.
+2. Ergänze den Ordner in der bestehenden `extensions`-Liste des [Pyodide-Plugins](../../README.mdx#erweiterungen-registrieren):
+
+ ```ts title="siteConfig.ts"
+ extensions: [
+ './website/pyodide/cookie-klicker'
+ ]
+ ```
+
+3. Starte den Entwicklungsserver neu. Danach kannst du die MDX-Beispiele oben verwenden.
+
+
+
+Die __extension.json__ im kopierten Ordner legt den Namen und die Einstiegspunkte fest. Der Browser-Store wird automatisch initialisiert; der `SiteStore` deiner Website benötigt keine Ergänzungen.
diff --git a/tdev-website/website/pyodide/cookie-klicker/extension.json b/tdev-website/website/pyodide/cookie-klicker/extension.json
new file mode 100644
index 000000000..c77d99382
--- /dev/null
+++ b/tdev-website/website/pyodide/cookie-klicker/extension.json
@@ -0,0 +1,6 @@
+{
+ "name": "cookie_klicker",
+ "worker": "./worker.ts",
+ "client": "./client.ts",
+ "showcase": "./docs"
+}
diff --git a/tdev-website/website/pyodide/cookie-klicker/protocol.ts b/tdev-website/website/pyodide/cookie-klicker/protocol.ts
new file mode 100644
index 000000000..05fde687e
--- /dev/null
+++ b/tdev-website/website/pyodide/cookie-klicker/protocol.ts
@@ -0,0 +1,18 @@
+export const DEFAULT_COOKIE_COLOR = 'var(--ifm-color-warning-light)';
+
+export interface CookieUpdate {
+ rotation?: number;
+ color?: string;
+}
+
+export interface CookieClick {
+ type: 'click';
+ payload: null;
+}
+
+export interface CookieKey {
+ type: 'key';
+ payload: {
+ key: string;
+ };
+}
diff --git a/tdev-website/website/pyodide/cookie-klicker/src/components/Cookie/index.tsx b/tdev-website/website/pyodide/cookie-klicker/src/components/Cookie/index.tsx
new file mode 100644
index 000000000..a0c58ffdf
--- /dev/null
+++ b/tdev-website/website/pyodide/cookie-klicker/src/components/Cookie/index.tsx
@@ -0,0 +1,60 @@
+import { useExtensionStore } from '@tdev/pyodide-code/hooks/useExtensionStore';
+import clsx from 'clsx';
+import { observer } from 'mobx-react-lite';
+import type { KeyboardEvent } from 'react';
+import cookieExtension from '../../../client';
+import styles from './styles.module.scss';
+
+interface CookieComponentProps {
+ cookieId: string;
+ className?: string;
+}
+
+const CHOCOLATE_CHIPS = [
+ [65, 55],
+ [125, 48],
+ [148, 94],
+ [109, 106],
+ [57, 117],
+ [84, 151],
+ [139, 143]
+];
+
+const CookieComponent = observer(({ cookieId, className }: CookieComponentProps) => {
+ const cookieStore = useExtensionStore(cookieExtension);
+ const cookie = cookieStore.useCookie(cookieId);
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
+ event.preventDefault();
+ }
+ cookie.handleKey(event.key);
+ };
+
+ return (
+
+
+ {cookieId}
+
+ {cookie.isConnected ? 'Klicke auf den Cookie.' : 'Starte zuerst das Python-Programm.'}
+
+
+ );
+});
+
+export default CookieComponent;
diff --git a/tdev-website/website/pyodide/cookie-klicker/src/components/Cookie/styles.module.scss b/tdev-website/website/pyodide/cookie-klicker/src/components/Cookie/styles.module.scss
new file mode 100644
index 000000000..bbdbf6938
--- /dev/null
+++ b/tdev-website/website/pyodide/cookie-klicker/src/components/Cookie/styles.module.scss
@@ -0,0 +1,55 @@
+.container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+ margin-block: 1rem;
+}
+
+.cookie {
+ width: 12rem;
+ max-width: 100%;
+ padding: 0;
+ border: 0;
+ border-radius: 50%;
+ background: transparent;
+ cursor: pointer;
+ transition: scale 100ms;
+
+ &:focus-visible {
+ outline: 3px solid var(--ifm-color-primary);
+ outline-offset: 4px;
+ }
+
+ &:active:not(:disabled) {
+ scale: 0.95;
+ }
+
+ &:disabled {
+ cursor: default;
+ opacity: 0.6;
+ }
+}
+
+.image {
+ display: block;
+ width: 100%;
+}
+
+.dough {
+ stroke: var(--ifm-color-warning-darkest);
+ stroke-width: 5px;
+}
+
+.chip {
+ fill: var(--ifm-color-warning-darkest);
+}
+
+.label {
+ font-weight: var(--ifm-font-weight-bold);
+}
+
+.status {
+ color: var(--ifm-color-emphasis-700);
+ font-size: 0.875rem;
+}
diff --git a/tdev-website/website/pyodide/cookie-klicker/src/models/Cookie.ts b/tdev-website/website/pyodide/cookie-klicker/src/models/Cookie.ts
new file mode 100644
index 000000000..f3cf86300
--- /dev/null
+++ b/tdev-website/website/pyodide/cookie-klicker/src/models/Cookie.ts
@@ -0,0 +1,49 @@
+import type { EventSource } from '@tdev/pyodide-code/events';
+import { action, computed, observable } from 'mobx';
+import { DEFAULT_COOKIE_COLOR, type CookieClick, type CookieKey } from '../../protocol';
+import type CookieStore from '../stores/CookieStore';
+
+const CLICK_EVENT: CookieClick = { type: 'click', payload: null };
+
+export default class Cookie {
+ @observable accessor rotation = 0;
+ @observable accessor color = DEFAULT_COOKIE_COLOR;
+ readonly eventSource: EventSource;
+ readonly id: string;
+ readonly store: CookieStore;
+
+ constructor(id: string, store: CookieStore) {
+ this.id = id;
+ this.store = store;
+ this.eventSource = { extension: store.context.name, targetId: id };
+ }
+
+ @computed
+ get isConnected(): boolean {
+ return this.store.context.events.isConnected(this.eventSource);
+ }
+
+ @computed
+ get rotationTransform(): string {
+ return `rotate(${this.rotation} 100 100)`;
+ }
+
+ @action
+ setRotation(rotation: number) {
+ this.rotation = rotation;
+ }
+
+ @action
+ setColor(color: string) {
+ this.color = color;
+ }
+
+ handleClick = () => {
+ void this.store.context.events.emit(this.eventSource, CLICK_EVENT);
+ };
+
+ handleKey(key: string) {
+ const event: CookieKey = { type: 'key', payload: { key } };
+ void this.store.context.events.emit(this.eventSource, event);
+ }
+}
diff --git a/tdev-website/website/pyodide/cookie-klicker/src/stores/CookieStore.ts b/tdev-website/website/pyodide/cookie-klicker/src/stores/CookieStore.ts
new file mode 100644
index 000000000..65a5aa32d
--- /dev/null
+++ b/tdev-website/website/pyodide/cookie-klicker/src/stores/CookieStore.ts
@@ -0,0 +1,22 @@
+import type { ClientContext } from '@tdev/pyodide-code/extensions';
+import { action, observable } from 'mobx';
+import Cookie from '../models/Cookie';
+
+export default class CookieStore {
+ readonly context: ClientContext;
+ readonly cookies = observable.map([], { deep: false });
+
+ constructor(context: ClientContext) {
+ this.context = context;
+ }
+
+ @action
+ useCookie(id: string): Cookie {
+ let cookie = this.cookies.get(id);
+ if (!cookie) {
+ cookie = new Cookie(id, this);
+ this.cookies.set(id, cookie);
+ }
+ return cookie;
+ }
+}
diff --git a/tdev-website/website/pyodide/cookie-klicker/worker.ts b/tdev-website/website/pyodide/cookie-klicker/worker.ts
new file mode 100644
index 000000000..c88aeaf85
--- /dev/null
+++ b/tdev-website/website/pyodide/cookie-klicker/worker.ts
@@ -0,0 +1,48 @@
+import { defineWorkerExtension, type WorkerContext } from '@tdev/pyodide-code/extensions';
+import { DEFAULT_COOKIE_COLOR, type CookieUpdate } from './protocol';
+
+export default defineWorkerExtension((context) => {
+ const cookies = new Map>();
+ return {
+ cookie_klicker: {
+ use_cookie: (id: string) => {
+ const existingCookie = cookies.get(id);
+ if (existingCookie) {
+ return existingCookie;
+ }
+ const cookie = createCookie(context, id);
+ cookies.set(id, cookie);
+ return cookie;
+ }
+ }
+ };
+});
+
+const createCookie = (context: WorkerContext, id: string) => {
+ const events = context.events.source(id);
+ let rotation = 0;
+ let color = DEFAULT_COOKIE_COLOR;
+ return {
+ events,
+ get rotation() {
+ return rotation;
+ },
+ get color() {
+ return color;
+ },
+ set_rotation: (value: number) => {
+ if (!Number.isFinite(value)) {
+ throw new Error('Cookie rotation must be a finite number.');
+ }
+ rotation = ((value % 360) + 360) % 360;
+ context.send(id, { rotation });
+ },
+ set_color: (value: string) => {
+ if (typeof value !== 'string' || !value.trim()) {
+ throw new Error('Cookie color must be a non-empty CSS color string.');
+ }
+ color = value;
+ context.send(id, { color });
+ }
+ };
+};
diff --git a/tdev-website/website/pyodide/grid/README.md b/tdev-website/website/pyodide/grid/README.md
new file mode 100644
index 000000000..25265a2d2
--- /dev/null
+++ b/tdev-website/website/pyodide/grid/README.md
@@ -0,0 +1,62 @@
+# Grid
+
+Voraussetzung ist eine Teaching-Dev-Website mit installiertem `pyodide-code`-Package. Kopiere den vollständigen Ordner nach `website/pyodide/grid` und ergänze im bestehenden Pyodide-Plugin die `extensions`-Liste:
+
+```ts
+extensions: [
+ './website/pyodide/grid'
+]
+```
+
+Starte den Entwicklungsserver neu.
+
+## Im Unterrichtsmaterial verwenden
+
+```mdx
+import Grid from '@tdev/pyodide/grid/src/components/Grid';
+
+
+```
+
+```py
+from grid import Grid
+
+board = Grid(10, 10, 'white', grid_id='board')
+board[2][3] = 'navy'
+board[2][3].text = 'A'
+board[2][3].text_color = 'white'
+await board.render()
+```
+
+`gridId`, `grid_id`
+: Verbinden die Zeichenfläche mit dem Python-Gitter. Mehrere Ansichten derselben ID teilen die Anzeige; unabhängige Gitter benötigen unterschiedliche IDs.
+`board[row][col]`
+: Liefert eine leichte Zellansicht mit `color`, `text`, `text_color`, `text_scale`, `row`, `col` und `get(offset_x, offset_y)`. Die Nachbarsuche verbindet gegenüberliegende Ränder.
+`board.set_cells(values, palette=None)`
+: Übernimmt einen zusammenhängenden zweidimensionalen Integer-Buffer mit der Form `(rows, cols)`. Die Werte sind Indizes der bei `Grid(..., palette=colors)` oder bei `set_cells()` angegebenen Farbliste.
+`await board.render()`
+: Überträgt den vollständigen Zustand. Warte den Aufruf ab, bevor du erneut renderst. Farben und Text werden bis dahin nur im Worker geändert.
+
+Für grosse Gitter werden die Zellen kompakt gespeichert. NumPy erlaubt eine Berechnung ohne Python-Schleife über jede Zelle:
+
+```py
+import numpy as np
+from grid import Grid
+from webio import gameloop, run
+
+colors = ['navy', 'royalblue', 'deepskyblue', 'lightcyan']
+board = Grid(1000, 1000, grid_id='board', palette=colors)
+columns = np.arange(1000, dtype=np.uint16)
+state = np.empty((1000, 1000), dtype=np.uint8)
+
+@gameloop(fps=30, sources=[board])
+async def update(ctx):
+ offset = int(ctx.time / 20) % 100
+ state[:] = ((columns + offset) // 25) % len(colors)
+ board.set_cells(state)
+ await board.render()
+
+await run()
+```
+
+Die Python-Imports laden NumPy automatisch. Klickereignisse enthalten `row` und `col` sowie die Position `x` und `y` in Zellkoordinaten im Payload. Die zuletzt gezeichnete Ansicht bleibt nach Ende des Skripts erhalten.
diff --git a/tdev-website/website/pyodide/grid/client.ts b/tdev-website/website/pyodide/grid/client.ts
new file mode 100644
index 000000000..3a03b2553
--- /dev/null
+++ b/tdev-website/website/pyodide/grid/client.ts
@@ -0,0 +1,10 @@
+import { defineClientExtension } from '@tdev/pyodide-code/extensions';
+import type { GridFrame } from './protocol';
+import GridStore from './src/stores/GridStore';
+
+export default defineClientExtension({
+ createStore: (context) => new GridStore(context),
+ handleMessage: (store, { targetId, payload }) => {
+ store.useGrid(targetId).setFrame(payload);
+ }
+});
diff --git a/tdev-website/website/pyodide/grid/docs/index.mdx b/tdev-website/website/pyodide/grid/docs/index.mdx
new file mode 100644
index 000000000..15f620f91
--- /dev/null
+++ b/tdev-website/website/pyodide/grid/docs/index.mdx
@@ -0,0 +1,145 @@
+---
+page_id: 923acc02-a517-4492-9cd5-609e74aa69de
+---
+
+import CodeShowcase from '@tdev-components/CodeShowcase';
+import Steps from '@tdev-components/Steps';
+import Grid from '@tdev/pyodide/grid/src/components/Grid';
+
+# Grid
+
+Die Erweiterung `grid` stellt farbige Zellen auf einer Zeichenfläche dar. Einzelne Zellen lassen sich wie im [Brython-Modul](/docs/packages/tdev/brython-code/libraries/grid) ansprechen. Änderungen werden mit `await board.render()` gemeinsam angezeigt.
+
+
+
+
+```py live_pyo id=24e6722f-243c-4c8a-9d24-8f195d5b57f7 title=Zellen_und_Text
+from grid import Grid
+
+board = Grid(4, 5, 'skyblue', grid_id='small-grid')
+board[1][2] = 'navy'
+board[1][2].text = 'A'
+board[1][2].text_color = 'white'
+board[2][4].get(1, 0).color = 'gold'
+await board.render()
+```
+
+
+## Grosse Gitter animieren
+
+Für viele Änderungen pro Bild werden Farbwerte als Zahlen in einem zusammenhängenden Array übergeben. NumPy berechnet sie mit Array-Operationen; eine Python-Schleife über jede Zelle würde die Berechnung wieder verlangsamen. `import numpy` lädt das Paket vor der Ausführung automatisch.
+
+
+
+
+```py live_pyo id=cf6ba599-ad00-46e7-b442-4d914a109a13 title=Eine_Million_Zellen
+import numpy as np
+from os import system
+from grid import Grid
+from webio import gameloop, run
+
+colors = ['navy', 'royalblue', 'deepskyblue', 'lightcyan']
+board = Grid(1000, 1000, grid_id='large-grid', palette=colors)
+columns = np.arange(1000, dtype=np.uint16)
+state = np.empty((1000, 1000), dtype=np.uint8)
+
+@gameloop(fps=120, sources=[board])
+async def update(ctx):
+ offset = int(ctx.time / 20) % 100
+ state[:] = ((columns + offset) // 25) % len(colors)
+ board.set_cells(state)
+ if ctx.frame % 10 == 0:
+ system('clear')
+ print('FPS', round(1/ctx.dt))
+ await board.render()
+
+await run()
+```
+
+
+Das Gitter speichert Farben als kompakte Palettenindizes. Ein Aufruf von `render()` überträgt einen vollständigen Zustand an die Zeichenfläche. Warte diesen Aufruf ab, bevor du den nächsten Zustand überträgst. Die tatsächlich erreichbare Bildrate hängt auch von der Berechnung und dem verwendeten Gerät ab; Details zur Spielschleife stehen in der [webio-Referenz](../../02-webio.mdx#spielschleife).
+
+## Gitter und Zellen
+
+`Grid(rows, cols, color='', *, grid_id='grid', palette=None, scale=None)`
+: Erstellt ein Gitter mit positiven ganzzahligen Zeilen- und Spaltenzahlen. Ohne Farbe und Palette sind Zellen durchsichtig. Mit einer Palette und ohne Farbe beginnen alle Zellen bei Palettenindex `0`. Für Zellzugriffe ist NumPy nicht erforderlich.
+`gridId`, `grid_id`
+: Verbinden die React-Komponente mit dem Python-Gitter. Verwende für unabhängige Gitter unterschiedliche IDs. Standard in Python: `'grid'`.
+`palette`
+: Liste von CSS-Farben, beispielsweise `['black', 'white']`. Bei `set_cells()` entspricht `0` der ersten Farbe, `1` der zweiten Farbe usw. Einzelne Farbzuweisungen ergänzen bisher unbekannte Farben. Für lange Animationen mit wechselnden Farben ersetzt `set_cells(values, palette=colors)` die bisherige Palette, statt sie fortlaufend zu erweitern.
+`scale`
+: Positive ganzzahlige Zellgrösse in Zeichenflächenpixeln. Ohne Angabe wird das Gitter in eine Fläche von ungefähr 500 × 500 Pixeln eingepasst; jede Zelle bleibt mindestens einen Pixel gross. Die Darstellung verkleinert sich bei wenig Platz proportional.
+`rows`, `cols`, `dim_y`, `dim_x`
+: Unveränderliche Anzahl der Zeilen beziehungsweise Spalten.
+`size`
+: Tupel `(rows, cols)`.
+`board[row][col]`
+: Liefert eine leichte Ansicht einer Zelle. `board[row][col] = 'red'` ändert deren Farbe. Es werden nicht im Voraus Python-Objekte für alle Zellen angelegt.
+`cell.row`, `cell.col`
+: Zeile beziehungsweise Spalte der Zelle, beginnend bei `0`.
+`cell.color`
+: Veränderbare, vom Browser-Canvas unterstützte CSS-Farbe als Zeichenkette. Beispiele: `'red'`, `'#ff8800'`, `'rgb(20, 80, 160)'`. Mit `''` wird die Zelle durchsichtig.
+`cell.get(offset_x, offset_y)`
+: Liefert eine benachbarte Zelle. Die Ränder sind verbunden: Rechts neben der letzten Spalte liegt die erste Spalte.
+`cell.text`, `cell.text_color`, `cell.text_scale`
+: Veränderbarer Text und seine Darstellung. Standard: leerer Text, schwarze Schrift und Skalierung `0.8`. Text wird getrennt von den Zellfarben gespeichert; bei sehr kleinen Zellen ist er nicht lesbar.
+`fill(color='')`
+: Füllt alle Zellen mit einer Farbe. Text bleibt erhalten.
+`await render()`
+: Überträgt Farben und Text an alle Ansichten derselben `gridId`. Auch nach dem Füllen oder Ändern einzelner Zellen ist dieser Aufruf nötig. Ein weiterer gleichzeitiger Render-Aufruf wird abgelehnt.
+`copy(*, grid_id=None)`
+: Kopiert Zellfarben, Text und Skalierung. Ohne neue ID zeichnet die Kopie auf dieselbe Zeichenfläche.
+`to_list()`, `color_grid`, `grid`
+: Liefern die Farben als verschachtelte Python-Listen. Für grosse Gitter entstehen dabei viele Python-Werte; verwende diese Ansichten nicht für jeden Animationsschritt.
+
+## Ganze Zustände übernehmen
+
+`set_cells(values, palette=None)`
+: Übernimmt Palettenindizes aus einem zusammenhängenden zweidimensionalen Integer-Buffer, beispielsweise einem NumPy-Array mit `dtype=np.uint8`. Die Form muss `(rows, cols)` entsprechen. Unterstützt werden vorzeichenlose und vorzeichenbehaftete Integer mit höchstens 32 Bit sowie Boolean-Werte (`False` als `0`, `True` als `1`). Alle Werte müssen nichtnegativ sein und auf eine vorhandene Palettenfarbe verweisen.
+: Mit `palette` wird zugleich eine neue Farbliste gesetzt. Ohne Angabe gilt die bisherige Palette. Änderungen werden erst mit `await render()` sichtbar.
+
+Mit `np.ascontiguousarray(values)` kannst du eine nicht zusammenhängende NumPy-Ansicht vor der Übergabe umwandeln. Ein Gitter darf höchstens 16 777 216 Zellen enthalten. Die Zeichenfläche darf höchstens 8192 Pixel breit oder hoch sein und insgesamt höchstens 16 777 216 Pixel umfassen. Die Zellgrösse `scale` zählt dabei mit: Die Zeichenfläche ist `cols * scale` Pixel breit und `rows * scale` Pixel hoch.
+
+## Gitter aus Text
+
+```py
+board = Grid.from_text('''
+..x..
+.xxx.
+xxxxx
+''', {'.': 'white', 'x': 'navy', 'bg': ''}, grid_id='small-grid')
+await board.render()
+```
+
+`Grid.from_text(text, colors=None, *, grid_id='grid', scale=None)`
+: Erstellt ein Gitter aus Textzeilen. Das Dictionary ordnet Zeichen Farben zu; `bg` bestimmt die Farbe unbekannter Zeichen und aufgefüllter Zellen. Ohne Dictionary werden `s`, `1` und `x` schwarz dargestellt, die übrigen Zellen bleiben durchsichtig.
+
+## Ereignisse
+
+`events`
+: Ereignisquelle für `@gameloop`, `@on_click` und `@on_key` aus [webio](../../02-webio.mdx).
+`event.payload['row']`, `event.payload['col']`
+: Zeile und Spalte einer angeklickten Zelle, beginnend bei `0`.
+`event.x`, `event.y`
+: Klickposition in Zellkoordinaten; die Werte können Nachkommastellen enthalten. Dieselben Werte stehen im Payload.
+
+Tastaturereignisse gehen an die fokussierte Zeichenfläche und enthalten den Tastennamen in `event.payload['key']`. Die zuletzt gezeichnete Ansicht bleibt nach Ende oder Abbruch eines Skripts erhalten. Ein neues Python-Gitter beginnt mit seinen eigenen Startwerten und ersetzt die Anzeige beim ersten `render()`.
+
+## Auf der eigenen Website einrichten
+
+Voraussetzung ist das [installierte Pyodide-Package](../../README.mdx#installation).
+
+
+
+1. Kopiere den vollständigen Ordner [grid](https://github.com/GBSL-Informatik/teaching-dev/tree/main/tdev-website/website/pyodide/grid) nach __website/pyodide/grid__.
+2. Ergänze den Ordner in der bestehenden `extensions`-Liste des [Pyodide-Plugins](../../README.mdx#erweiterungen-registrieren):
+
+ ```ts title="siteConfig.ts"
+ extensions: [
+ './website/pyodide/grid'
+ ]
+ ```
+
+3. Starte den Entwicklungsserver neu. Danach kannst du die MDX-Beispiele oben verwenden.
+
+
diff --git a/tdev-website/website/pyodide/grid/extension.json b/tdev-website/website/pyodide/grid/extension.json
new file mode 100644
index 000000000..5ca69f3dc
--- /dev/null
+++ b/tdev-website/website/pyodide/grid/extension.json
@@ -0,0 +1,6 @@
+{
+ "name": "grid",
+ "worker": "./worker.ts",
+ "client": "./client.ts",
+ "showcase": "./docs"
+}
diff --git a/tdev-website/website/pyodide/grid/protocol.ts b/tdev-website/website/pyodide/grid/protocol.ts
new file mode 100644
index 000000000..90892589e
--- /dev/null
+++ b/tdev-website/website/pyodide/grid/protocol.ts
@@ -0,0 +1,21 @@
+export interface GridText {
+ row: number;
+ col: number;
+ text: string;
+ color: string;
+ scale: number;
+}
+
+export interface GridFrame {
+ rows: number;
+ cols: number;
+ scale: number;
+ cells: GridCells;
+ palette: readonly string[];
+ text: readonly GridText[];
+}
+
+export type GridCells = Uint8Array | Uint16Array | Uint32Array;
+
+export const MAX_GRID_CELLS = 16_777_216;
+export const MAX_CANVAS_DIMENSION = 8192;
diff --git a/tdev-website/website/pyodide/grid/src/components/Grid/index.tsx b/tdev-website/website/pyodide/grid/src/components/Grid/index.tsx
new file mode 100644
index 000000000..3a568ef58
--- /dev/null
+++ b/tdev-website/website/pyodide/grid/src/components/Grid/index.tsx
@@ -0,0 +1,63 @@
+import { useExtensionStore } from '@tdev/pyodide-code/hooks/useExtensionStore';
+import clsx from 'clsx';
+import { useEffect, useState, type KeyboardEvent, type MouseEvent } from 'react';
+import gridExtension from '../../../client';
+import GridRenderer from '../../rendering/GridRenderer';
+import styles from './styles.module.scss';
+
+interface GridProps {
+ gridId: string;
+ className?: string;
+}
+
+const SCROLL_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '];
+
+const Grid = ({ gridId, className }: GridProps) => {
+ const store = useExtensionStore(gridExtension);
+ const grid = store.useGrid(gridId);
+ const [canvas, setCanvas] = useState(null);
+
+ useEffect(() => {
+ if (!canvas) {
+ return;
+ }
+ return grid.attachRenderer(new GridRenderer(canvas));
+ }, [canvas, grid]);
+
+ const handleClick = (event: MouseEvent) => {
+ event.currentTarget.focus({ preventScroll: true });
+ const bounds = event.currentTarget.getBoundingClientRect();
+ if (bounds.width <= 0 || bounds.height <= 0) {
+ return;
+ }
+ const x = ((event.clientX - bounds.left) / bounds.width) * grid.cols;
+ const y = ((event.clientY - bounds.top) / bounds.height) * grid.rows;
+ grid.handleClick(x, y);
+ };
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (!grid.isConnected) {
+ return;
+ }
+ if (SCROLL_KEYS.includes(event.key)) {
+ event.preventDefault();
+ }
+ grid.handleKey(event.key);
+ };
+
+ return (
+
+ );
+};
+
+export default Grid;
diff --git a/tdev-website/website/pyodide/grid/src/components/Grid/styles.module.scss b/tdev-website/website/pyodide/grid/src/components/Grid/styles.module.scss
new file mode 100644
index 000000000..1b3592fb5
--- /dev/null
+++ b/tdev-website/website/pyodide/grid/src/components/Grid/styles.module.scss
@@ -0,0 +1,12 @@
+.grid {
+ display: block;
+ max-width: 100%;
+ height: auto;
+ margin-block: 1rem;
+ image-rendering: pixelated;
+
+ &:focus-visible {
+ outline: 3px solid var(--ifm-color-primary);
+ outline-offset: 4px;
+ }
+}
diff --git a/tdev-website/website/pyodide/grid/src/models/Grid.ts b/tdev-website/website/pyodide/grid/src/models/Grid.ts
new file mode 100644
index 000000000..bfb59a388
--- /dev/null
+++ b/tdev-website/website/pyodide/grid/src/models/Grid.ts
@@ -0,0 +1,94 @@
+import type { EventSource } from '@tdev/pyodide-code/events';
+import { computed } from 'mobx';
+import type { GridFrame } from '../../protocol';
+import type GridRenderer from '../rendering/GridRenderer';
+import type GridStore from '../stores/GridStore';
+
+export default class Grid {
+ readonly id: string;
+ readonly store: GridStore;
+ readonly eventSource: EventSource;
+ private frame?: GridFrame;
+ private colorContext?: CanvasRenderingContext2D;
+ private readonly renderers = new Set();
+
+ constructor(id: string, store: GridStore) {
+ this.id = id;
+ this.store = store;
+ this.eventSource = { extension: store.context.name, targetId: id };
+ }
+
+ @computed
+ get isConnected(): boolean {
+ return this.store.context.events.isConnected(this.eventSource);
+ }
+
+ get rows(): number {
+ return this.frame?.rows ?? 0;
+ }
+
+ get cols(): number {
+ return this.frame?.cols ?? 0;
+ }
+
+ setFrame(frame: GridFrame) {
+ this.validateColors(frame);
+ for (const renderer of this.renderers) {
+ renderer.draw(frame);
+ }
+ this.frame = frame;
+ }
+
+ attachRenderer(renderer: GridRenderer): () => void {
+ if (this.frame) {
+ renderer.draw(this.frame);
+ }
+ this.renderers.add(renderer);
+ return () => {
+ this.renderers.delete(renderer);
+ };
+ }
+
+ handleClick(x: number, y: number) {
+ const row = Math.floor(y);
+ const col = Math.floor(x);
+ if (row < 0 || row >= this.rows || col < 0 || col >= this.cols) {
+ return;
+ }
+ void this.store.context.events.emit(this.eventSource, { type: 'click', payload: { row, col, x, y } });
+ }
+
+ handleKey(key: string) {
+ void this.store.context.events.emit(this.eventSource, { type: 'key', payload: { key } });
+ }
+
+ private validateColors(frame: GridFrame) {
+ const context = this.getColorContext();
+ for (const color of [...frame.palette, ...frame.text.map((cell) => cell.color)]) {
+ // Canvas ignores unsupported colors; two sentinels distinguish rejection from black or white.
+ context.fillStyle = '#000000';
+ context.fillStyle = color;
+ const parsed = context.fillStyle;
+ context.fillStyle = '#ffffff';
+ context.fillStyle = color;
+ if (context.fillStyle !== parsed) {
+ throw new Error(`Invalid grid color: ${color}`);
+ }
+ }
+ }
+
+ private getColorContext(): CanvasRenderingContext2D {
+ if (this.colorContext) {
+ return this.colorContext;
+ }
+ const canvas = document.createElement('canvas');
+ canvas.width = 1;
+ canvas.height = 1;
+ const context = canvas.getContext('2d');
+ if (!context) {
+ throw new Error('The browser could not create a canvas context for grid colors.');
+ }
+ this.colorContext = context;
+ return context;
+ }
+}
diff --git a/tdev-website/website/pyodide/grid/src/python/grid.py b/tdev-website/website/pyodide/grid/src/python/grid.py
new file mode 100644
index 000000000..6959ad352
--- /dev/null
+++ b/tdev-website/website/pyodide/grid/src/python/grid.py
@@ -0,0 +1,188 @@
+"""Packed grids for Teaching Dev's Pyodide renderer."""
+
+from operator import index as _index
+from pyodide.ffi import to_js as _to_js
+from _grid_bridge import create_grid as _create_grid
+
+
+def _position(value, length):
+ value = _index(value)
+ if value < 0:
+ value += length
+ if not 0 <= value < length:
+ raise IndexError('Grid index out of range.')
+ return value
+
+
+class Cell:
+ __slots__ = ('_grid', 'row', 'col')
+
+ def __init__(self, grid, row, col):
+ self._grid = grid
+ self.row = row
+ self.col = col
+
+ @property
+ def color(self):
+ return self._grid._handle.get_color(self.row, self.col)
+
+ @color.setter
+ def color(self, color):
+ self._grid._handle.set_color(self.row, self.col, color)
+
+ @property
+ def text(self):
+ return self._grid._handle.get_text(self.row, self.col).text
+
+ @text.setter
+ def text(self, text):
+ self._grid._handle.set_text(self.row, self.col, str(text))
+
+ @property
+ def text_color(self):
+ return self._grid._handle.get_text(self.row, self.col).color
+
+ @text_color.setter
+ def text_color(self, color):
+ self._grid._handle.set_text_color(self.row, self.col, color)
+
+ @property
+ def text_scale(self):
+ return self._grid._handle.get_text(self.row, self.col).scale
+
+ @text_scale.setter
+ def text_scale(self, scale):
+ self._grid._handle.set_text_scale(self.row, self.col, scale)
+
+ def get(self, offset_x, offset_y):
+ row = (self.row + _index(offset_y)) % self._grid.dim_y
+ col = (self.col + _index(offset_x)) % self._grid.dim_x
+ return self._grid[row][col]
+
+ def __repr__(self):
+ return self.color
+
+
+class Row:
+ __slots__ = ('_grid', '_row')
+
+ def __init__(self, grid, row):
+ self._grid = grid
+ self._row = row
+
+ def __getitem__(self, col):
+ return Cell(self._grid, self._row, _position(col, self._grid.dim_x))
+
+ def __setitem__(self, col, color):
+ self[col].color = color
+
+ def __len__(self):
+ return self._grid.dim_x
+
+ def __iter__(self):
+ for col in range(len(self)):
+ yield Cell(self._grid, self._row, col)
+
+
+class Grid:
+ def __init__(self, rows, cols, color='', *, grid_id='grid', palette=None, scale=None):
+ colors = None if palette is None else _to_js(list(palette))
+ self._handle = _create_grid(grid_id, rows, cols, color, colors, scale)
+
+ @property
+ def grid_id(self):
+ return self._handle.grid_id
+
+ @property
+ def rows(self):
+ return self._handle.rows
+
+ @property
+ def cols(self):
+ return self._handle.cols
+
+ @property
+ def dim_x(self):
+ return self._handle.cols
+
+ @property
+ def dim_y(self):
+ return self._handle.rows
+
+ @property
+ def size(self):
+ return (self.dim_y, self.dim_x)
+
+ @property
+ def scale(self):
+ return self._handle.scale
+
+ @property
+ def palette(self):
+ return tuple(self._handle.palette)
+
+ @property
+ def events(self):
+ return self._handle.events
+
+ @property
+ def color_grid(self):
+ return self.to_list()
+
+ @property
+ def grid(self):
+ return self.to_list()
+
+ def fill(self, color=''):
+ self._handle.fill(color)
+
+ def set_cells(self, values, palette=None):
+ colors = None if palette is None else _to_js(list(palette))
+ # A buffer view avoids converting a NumPy array into a million Python objects.
+ with memoryview(values) as view:
+ self._handle.set_cells(view, colors)
+
+ async def render(self):
+ await self._handle.render()
+
+ def copy(self, *, grid_id=None):
+ grid = Grid.__new__(Grid)
+ grid._handle = self._handle.copy(self.grid_id if grid_id is None else grid_id)
+ return grid
+
+ def to_list(self):
+ return [[cell.color for cell in row] for row in self]
+
+ @classmethod
+ def from_text(cls, text, colors=None, *, grid_id='grid', scale=None):
+ colors = {'s': 'black', '1': 'black', 'x': 'black'} if colors is None else dict(colors)
+ lines = text.lower().splitlines()
+ while lines and not lines[0]:
+ lines.pop(0)
+ cols = max(map(len, lines), default=0)
+ if cols == 0:
+ raise ValueError('Grid text must contain at least one non-empty line.')
+ background = colors.get('bg', '')
+ grid = cls(len(lines), cols, background, grid_id=grid_id, scale=scale)
+ for row, line in enumerate(lines):
+ for col, character in enumerate(line):
+ grid[row][col] = colors.get(character, background)
+ return grid
+
+ def __getitem__(self, row):
+ return Row(self, _position(row, self.dim_y))
+
+ def __setitem__(self, row, colors):
+ values = list(colors)
+ if len(values) != self.dim_x:
+ raise ValueError('A row must contain exactly dim_x colors.')
+ target = self[row]
+ for col, color in enumerate(values):
+ target[col] = color
+
+ def __len__(self):
+ return self.dim_y
+
+ def __iter__(self):
+ for row in range(len(self)):
+ yield Row(self, row)
diff --git a/tdev-website/website/pyodide/grid/src/rendering/GridRenderer.ts b/tdev-website/website/pyodide/grid/src/rendering/GridRenderer.ts
new file mode 100644
index 000000000..eb0a99ad4
--- /dev/null
+++ b/tdev-website/website/pyodide/grid/src/rendering/GridRenderer.ts
@@ -0,0 +1,94 @@
+import type { GridFrame } from '../../protocol';
+
+export default class GridRenderer {
+ readonly canvas: HTMLCanvasElement;
+ private readonly context: CanvasRenderingContext2D;
+ private readonly pixelCanvas: HTMLCanvasElement;
+ private readonly pixelContext: CanvasRenderingContext2D;
+ private readonly colorContext: CanvasRenderingContext2D;
+ private colors = new Map();
+ private image?: ImageData;
+
+ constructor(canvas: HTMLCanvasElement) {
+ this.canvas = canvas;
+ this.context = this.getContext(canvas);
+ this.pixelCanvas = document.createElement('canvas');
+ this.pixelContext = this.getContext(this.pixelCanvas);
+ const colorCanvas = document.createElement('canvas');
+ colorCanvas.width = 1;
+ colorCanvas.height = 1;
+ this.colorContext = this.getContext(colorCanvas, true);
+ }
+
+ draw(frame: GridFrame) {
+ const image = this.getImage(frame.rows, frame.cols);
+ const pixels = new Uint32Array(image.data.buffer, image.data.byteOffset, image.data.length / 4);
+ const palette = this.resolvePalette(frame.palette);
+ for (let index = 0; index < frame.cells.length; index++) {
+ pixels[index] = palette[frame.cells[index]];
+ }
+ this.pixelContext.putImageData(image, 0, 0);
+
+ const width = frame.cols * frame.scale;
+ const height = frame.rows * frame.scale;
+ if (this.canvas.width !== width || this.canvas.height !== height) {
+ this.canvas.width = width;
+ this.canvas.height = height;
+ }
+ this.context.imageSmoothingEnabled = false;
+ this.context.clearRect(0, 0, width, height);
+ this.context.drawImage(this.pixelCanvas, 0, 0, width, height);
+ this.drawText(frame);
+ }
+
+ private getContext(canvas: HTMLCanvasElement, willReadFrequently = false): CanvasRenderingContext2D {
+ const context = canvas.getContext('2d', { willReadFrequently });
+ if (!context) {
+ throw new Error('The browser could not create a canvas context for the grid.');
+ }
+ return context;
+ }
+
+ private getImage(rows: number, cols: number): ImageData {
+ if (!this.image || this.image.width !== cols || this.image.height !== rows) {
+ this.pixelCanvas.width = cols;
+ this.pixelCanvas.height = rows;
+ this.image = this.pixelContext.createImageData(cols, rows);
+ }
+ return this.image;
+ }
+
+ private resolvePalette(colors: readonly string[]): number[] {
+ const currentColors = new Map();
+ const palette = colors.map((color) => {
+ const value = currentColors.get(color) ?? this.colors.get(color) ?? this.parseColor(color);
+ currentColors.set(color, value);
+ return value;
+ });
+ this.colors = currentColors;
+ return palette;
+ }
+
+ private parseColor(color: string): number {
+ this.colorContext.clearRect(0, 0, 1, 1);
+ this.colorContext.fillStyle = color;
+ this.colorContext.fillRect(0, 0, 1, 1);
+ const pixel = this.colorContext.getImageData(0, 0, 1, 1).data;
+ return new Uint32Array(pixel.buffer, pixel.byteOffset, 1)[0];
+ }
+
+ private drawText(frame: GridFrame) {
+ this.context.textAlign = 'center';
+ this.context.textBaseline = 'middle';
+ for (const cell of frame.text) {
+ this.context.fillStyle = cell.color;
+ this.context.font = `${frame.scale * cell.scale}px Arial`;
+ this.context.fillText(
+ cell.text,
+ (cell.col + 0.5) * frame.scale,
+ (cell.row + 0.5) * frame.scale,
+ frame.scale * 0.9
+ );
+ }
+ }
+}
diff --git a/tdev-website/website/pyodide/grid/src/stores/GridStore.ts b/tdev-website/website/pyodide/grid/src/stores/GridStore.ts
new file mode 100644
index 000000000..40aec50ac
--- /dev/null
+++ b/tdev-website/website/pyodide/grid/src/stores/GridStore.ts
@@ -0,0 +1,23 @@
+import type { ClientContext } from '@tdev/pyodide-code/extensions';
+import { action, observable } from 'mobx';
+import Grid from '../models/Grid';
+
+export default class GridStore {
+ readonly context: ClientContext;
+ readonly grids = observable.map([], { deep: false });
+
+ constructor(context: ClientContext) {
+ this.context = context;
+ }
+
+ @action
+ useGrid(id: string): Grid {
+ const existingGrid = this.grids.get(id);
+ if (existingGrid) {
+ return existingGrid;
+ }
+ const grid = new Grid(id, this);
+ this.grids.set(id, grid);
+ return grid;
+ }
+}
diff --git a/tdev-website/website/pyodide/grid/src/worker/WorkerGrid.ts b/tdev-website/website/pyodide/grid/src/worker/WorkerGrid.ts
new file mode 100644
index 000000000..c3008bdd2
--- /dev/null
+++ b/tdev-website/website/pyodide/grid/src/worker/WorkerGrid.ts
@@ -0,0 +1,248 @@
+import type { ExecutionEventSource } from '@tdev/pyodide-code/events';
+import type { WorkerContext } from '@tdev/pyodide-code/extensions';
+import type { PyBuffer, PyBufferView } from 'pyodide/ffi';
+import {
+ MAX_CANVAS_DIMENSION,
+ MAX_GRID_CELLS,
+ type GridCells,
+ type GridFrame,
+ type GridText
+} from '../../protocol';
+
+const TRANSPARENT = 'rgba(0,0,0,0)';
+const DEFAULT_TEXT = { text: '', color: 'black', scale: 0.8 };
+
+export default class WorkerGrid {
+ readonly events: ExecutionEventSource;
+ readonly rows: number;
+ readonly cols: number;
+ readonly scale: number;
+ readonly grid_id: string;
+ private readonly context: WorkerContext;
+ private cells: GridCells;
+ private colors: string[];
+ private colorIndices: Map;
+ private readonly labels = new Map();
+ private rendering = false;
+
+ constructor(
+ context: WorkerContext,
+ id: string,
+ rows: number,
+ cols: number,
+ color = '',
+ palette?: string[],
+ scale = Math.max(1, Math.floor(500 / Math.max(rows, cols)))
+ ) {
+ this.validateDimensions(rows, cols, scale);
+ this.context = context;
+ this.grid_id = id;
+ this.events = context.events.source(id);
+ this.rows = rows;
+ this.cols = cols;
+ this.scale = scale;
+ this.colors = this.validatePalette(palette ?? [color]);
+ this.colorIndices = new Map(this.colors.map((value, index) => [value, index]));
+ this.cells = this.createCells(this.colors.length);
+ if (color !== '') {
+ this.fill(color);
+ }
+ }
+
+ get palette(): readonly string[] {
+ return [...this.colors];
+ }
+
+ get_color(row: number, col: number): string {
+ return this.colors[this.cells[this.index(row, col)]];
+ }
+
+ set_color(row: number, col: number, color: string) {
+ const index = this.index(row, col);
+ const value = this.colorIndex(color);
+ this.cells[index] = value;
+ }
+
+ fill(color = '') {
+ const index = this.colorIndex(color);
+ this.cells.fill(index);
+ }
+
+ set_cells(values: PyBuffer, palette?: string[]) {
+ const colors = palette === undefined ? this.colors : this.validatePalette(palette);
+ const view = values.getBuffer();
+ try {
+ this.cells = this.readCells(view, colors.length);
+ if (palette !== undefined) {
+ this.colors = colors;
+ this.colorIndices = new Map(colors.map((value, index) => [value, index]));
+ }
+ } finally {
+ view.release();
+ }
+ }
+
+ get_text(row: number, col: number) {
+ return this.labels.get(this.index(row, col)) ?? DEFAULT_TEXT;
+ }
+
+ set_text(row: number, col: number, text: string) {
+ this.updateText(row, col, { text });
+ }
+
+ set_text_color(row: number, col: number, color: string) {
+ this.updateText(row, col, { color: this.validateColor(color) });
+ }
+
+ set_text_scale(row: number, col: number, scale: number) {
+ if (!Number.isFinite(scale) || scale <= 0) {
+ throw new Error('Cell text scale must be a positive finite number.');
+ }
+ this.updateText(row, col, { scale });
+ }
+
+ copy(id = this.grid_id): WorkerGrid {
+ const grid = new WorkerGrid(this.context, id, this.rows, this.cols, '', this.colors, this.scale);
+ grid.cells.set(this.cells);
+ for (const [index, label] of this.labels) {
+ grid.labels.set(index, { ...label });
+ }
+ return grid;
+ }
+
+ async render() {
+ if (this.rendering) {
+ throw new Error('Await grid.render() before rendering another frame.');
+ }
+ this.rendering = true;
+ try {
+ await this.context.sendAndWait(this.grid_id, {
+ rows: this.rows,
+ cols: this.cols,
+ scale: this.scale,
+ cells: this.cells.slice(),
+ palette: [...this.colors],
+ text: [...this.labels.values()]
+ .filter(({ text }) => text !== '')
+ .map((label) => ({ ...label }))
+ });
+ } finally {
+ this.rendering = false;
+ }
+ }
+
+ private validateDimensions(rows: number, cols: number, scale: number) {
+ if (!Number.isSafeInteger(rows) || rows < 1 || !Number.isSafeInteger(cols) || cols < 1) {
+ throw new Error('Grid rows and columns must be positive integers.');
+ }
+ if (rows * cols > MAX_GRID_CELLS) {
+ throw new Error(`A grid may contain at most ${MAX_GRID_CELLS} cells.`);
+ }
+ if (!Number.isSafeInteger(scale) || scale < 1) {
+ throw new Error('Grid scale must be a positive integer.');
+ }
+ if (rows * scale > MAX_CANVAS_DIMENSION || cols * scale > MAX_CANVAS_DIMENSION) {
+ throw new Error(`A grid canvas may be at most ${MAX_CANVAS_DIMENSION} pixels wide or high.`);
+ }
+ if (rows * cols * scale * scale > MAX_GRID_CELLS) {
+ throw new Error(`A grid canvas may contain at most ${MAX_GRID_CELLS} pixels.`);
+ }
+ }
+
+ private readCells(view: PyBufferView, paletteSize: number): GridCells {
+ if (view.ndim !== 2 || view.shape[0] !== this.rows || view.shape[1] !== this.cols) {
+ throw new Error(`Cell data must have shape (${this.rows}, ${this.cols}).`);
+ }
+ if (!view.c_contiguous) {
+ throw new Error('Cell data must be contiguous. Use numpy.ascontiguousarray(data).');
+ }
+ const data = view.data;
+ if (!(
+ data instanceof Uint8Array ||
+ data instanceof Int8Array ||
+ data instanceof Uint16Array ||
+ data instanceof Int16Array ||
+ data instanceof Uint32Array ||
+ data instanceof Int32Array
+ )) {
+ throw new Error('Cell data must use 8-, 16-, or 32-bit integers.');
+ }
+ const cells = this.createCells(paletteSize);
+ for (let index = 0; index < cells.length; index++) {
+ const value = data[view.offset + index];
+ if (value < 0 || value >= paletteSize) {
+ throw new Error(`Cell palette index ${value} is out of range.`);
+ }
+ cells[index] = value;
+ }
+ return cells;
+ }
+
+ private index(row: number, col: number): number {
+ if (
+ !Number.isSafeInteger(row) ||
+ row < 0 ||
+ row >= this.rows ||
+ !Number.isSafeInteger(col) ||
+ col < 0 ||
+ col >= this.cols
+ ) {
+ throw new Error('Grid cell coordinates are out of range.');
+ }
+ return row * this.cols + col;
+ }
+
+ private validateColor(color: string): string {
+ if (typeof color !== 'string') {
+ throw new Error('Cell colors must be CSS color strings.');
+ }
+ return color === '' ? TRANSPARENT : color;
+ }
+
+ private validatePalette(palette: string[]): string[] {
+ if (!Array.isArray(palette) || palette.length === 0) {
+ throw new Error('A grid palette must contain at least one CSS color.');
+ }
+ return palette.map((color) => this.validateColor(color));
+ }
+
+ private colorIndex(color: string): number {
+ const value = this.validateColor(color);
+ const existing = this.colorIndices.get(value);
+ if (existing !== undefined) {
+ return existing;
+ }
+ const index = this.colors.length;
+ this.colors.push(value);
+ this.colorIndices.set(value, index);
+ if (
+ (index === 256 && this.cells.BYTES_PER_ELEMENT === 1) ||
+ (index === 65536 && this.cells.BYTES_PER_ELEMENT === 2)
+ ) {
+ const cells = this.createCells(this.colors.length);
+ cells.set(this.cells);
+ this.cells = cells;
+ }
+ return index;
+ }
+
+ private createCells(paletteSize: number): GridCells {
+ const size = this.rows * this.cols;
+ if (paletteSize <= 256) {
+ return new Uint8Array(size);
+ }
+ if (paletteSize <= 65536) {
+ return new Uint16Array(size);
+ }
+ return new Uint32Array(size);
+ }
+
+ private updateText(
+ row: number,
+ col: number,
+ values: Partial>
+ ) {
+ const index = this.index(row, col);
+ this.labels.set(index, { ...DEFAULT_TEXT, ...this.labels.get(index), row, col, ...values });
+ }
+}
diff --git a/tdev-website/website/pyodide/grid/worker.ts b/tdev-website/website/pyodide/grid/worker.ts
new file mode 100644
index 000000000..4ba2f898b
--- /dev/null
+++ b/tdev-website/website/pyodide/grid/worker.ts
@@ -0,0 +1,20 @@
+import source from '!!raw-loader!./src/python/grid.py';
+import { defineWorkerExtension } from '@tdev/pyodide-code/extensions';
+import type { GridFrame } from './protocol';
+import WorkerGrid from './src/worker/WorkerGrid';
+
+export default defineWorkerExtension(
+ (context) => ({
+ _grid_bridge: {
+ create_grid: (
+ id: string,
+ rows: number,
+ cols: number,
+ color: string,
+ palette?: string[],
+ scale?: number
+ ) => new WorkerGrid(context, id, rows, cols, color, palette, scale)
+ }
+ }),
+ { grid: source }
+);
diff --git a/tdev-website/website/pyodide/snowfall-game/README.md b/tdev-website/website/pyodide/snowfall-game/README.md
new file mode 100644
index 000000000..41bda8e62
--- /dev/null
+++ b/tdev-website/website/pyodide/snowfall-game/README.md
@@ -0,0 +1,56 @@
+# Schneefall-Spiel
+
+Voraussetzung ist eine Teaching-Dev-Website mit installiertem `pyodide-code`-Package und `webio.gameloop`. Kopiere diesen vollständigen Ordner nach `website/pyodide/snowfall-game` und ergänze im vorhandenen Pyodide-Plugin die `extensions`-Liste:
+
+```ts
+extensions: [
+ './website/pyodide/snowfall-game'
+]
+```
+
+Starte den Entwicklungsserver neu. Der Browser-Store wird automatisch angelegt.
+
+## Im Unterrichtsmaterial verwenden
+
+```mdx
+import SnowfallGame from '@tdev/pyodide/snowfall-game/src/components/SnowfallGame';
+
+
+```
+
+```python
+from snowfall_game import use_scene
+from webio import gameloop, run
+
+scene = use_scene('snowfall')
+scene.configure(width=400, height=800, background_color='black')
+scene.add_snowflake(200, 0, 12, 80, color='lightblue')
+scene.add_dot(200, 400, 6, color='#ff8800')
+
+@gameloop(fps=30, sources=[scene])
+def update(ctx):
+ for snowflake in scene.snowflakes:
+ snowflake.y += snowflake.speed * ctx.dt
+ if snowflake.y > scene.height:
+ scene.remove_snowflake(snowflake)
+ scene.render()
+
+await run()
+```
+
+`ctx.dt` ist die seit dem letzten Frame verstrichene Zeit in Sekunden. `render()` überträgt den aktuellen Zustand als eine Nachricht an die Anzeige. Änderungen an Schneeflocken werden bis dahin nur im Worker gespeichert.
+
+`scene.add_snowflake(x, y, size, speed, color='white')`
+: Erzeugt ein `mdiSnowflake`-Symbol mit Mittelpunkt `(x, y)` und halber Symbolgrösse `size`. Gibt einen Handle mit veränderbaren Eigenschaften `x`, `y`, `size`, `speed` und `color` zurück.
+`scene.add_dot(x, y, size, color='white')`
+: Erzeugt einen ausgefüllten Kreis mit Mittelpunkt `(x, y)` und Radius `size`. Gibt einen Handle mit veränderbaren Eigenschaften `x`, `y`, `size` und `color` zurück. Punkte werden vor den Schneeflocken gezeichnet.
+`scene.snowflakes`, `scene.dots`
+: Momentaufnahmen der jeweiligen Handles. Während der Iteration können Elemente entfernt werden.
+`scene.remove_snowflake(snowflake)`, `scene.remove_dot(dot)`
+: Entfernen den angegebenen Handle aus der Szene.
+`scene.reset()`
+: Entfernt alle Schneeflocken und Punkte und behält die Szenenkonfiguration.
+`scene.render()`
+: Überträgt Positionen, Grössen und CSS-Farben gemeinsam an die Anzeige.
+
+Klickereignisse enthalten `x` und `y` im `payload`, bereits in die konfigurierten Szenenkoordinaten umgerechnet. Das SVG passt sich schmalen Fenstern an. Mehrere Ansichten derselben `sceneId` teilen ihren Zustand.
diff --git a/tdev-website/website/pyodide/snowfall-game/client.ts b/tdev-website/website/pyodide/snowfall-game/client.ts
new file mode 100644
index 000000000..5b3ceafcd
--- /dev/null
+++ b/tdev-website/website/pyodide/snowfall-game/client.ts
@@ -0,0 +1,10 @@
+import { defineClientExtension } from '@tdev/pyodide-code/extensions';
+import type { SceneSnapshot } from './protocol';
+import SceneStore from './src/stores/SceneStore';
+
+export default defineClientExtension({
+ createStore: (context) => new SceneStore(context),
+ handleMessage: (store, { targetId, payload }) => {
+ store.useScene(targetId).setSnapshot(payload);
+ }
+});
diff --git a/tdev-website/website/pyodide/snowfall-game/docs/index.mdx b/tdev-website/website/pyodide/snowfall-game/docs/index.mdx
new file mode 100644
index 000000000..fd15f4afb
--- /dev/null
+++ b/tdev-website/website/pyodide/snowfall-game/docs/index.mdx
@@ -0,0 +1,154 @@
+---
+page_id: 5aeee167-b38a-4c16-ad00-21aa4686a136
+---
+
+import CodeShowcase from '@tdev-components/CodeShowcase';
+import Steps from '@tdev-components/Steps';
+import SnowfallGame from '@tdev/pyodide/snowfall-game/src/components/SnowfallGame';
+
+# Schneefall-Spiel
+
+Mit `@gameloop` aktualisiert Python eine Szene regelmässig und verarbeitet dabei Browser-Ereignisse. Klicke auf eine Schneeflocke, um sie zu entfernen.
+
+
+
+
+```py live_pyo id=8ee3ce4d-6f41-43e4-afd8-17da3141e3d6 title=Schneefall
+from os import system
+from random import randint
+from webio import gameloop, run
+from snowfall_game import use_scene
+
+scene = use_scene('snowfall')
+scene.reset()
+scene.configure(width=400, height=600, background_color='black')
+scene.render()
+last_spawn_time = -1000
+click = None
+
+def spawn_snowflake():
+ x = randint(0, int(scene.width))
+ size = randint(8, 14)
+ speed = randint(40, 100)
+ scene.add_snowflake(x, 0, size, speed, color='lightblue')
+
+def update_snowflakes(dt):
+ for snowflake in scene.snowflakes:
+ snowflake.y += snowflake.speed * dt
+ if snowflake.y > scene.height:
+ scene.remove_snowflake(snowflake)
+
+def remove_click():
+ global click
+ if click is None:
+ return
+ scene.remove_dot(click)
+ click = None
+
+def handle_click(event):
+ global click
+ remove_click()
+ click = scene.add_dot(event.x, event.y, 10, color='red')
+
+ for snowflake in scene.snowflakes:
+ distance_squared = (snowflake.x - event.x)**2 + (snowflake.y - event.y)**2
+ if distance_squared < (snowflake.size + 10)**2:
+ scene.remove_snowflake(snowflake)
+ remove_click()
+ break
+
+@gameloop(fps=120, sources=[scene])
+def update(ctx):
+ global last_spawn_time
+ if ctx.time - last_spawn_time >= 1000:
+ spawn_snowflake()
+ last_spawn_time = ctx.time
+
+ update_snowflakes(ctx.dt)
+
+ for event in ctx.events:
+ if event.type == 'click':
+ handle_click(event)
+
+ if ctx.frame % 10 == 0:
+ system('clear')
+ print(round(1/ctx.dt))
+
+ scene.render()
+
+await run()
+```
+
+
+Die Geschwindigkeiten sind in Pixeln pro Sekunde angegeben. `ctx.dt` berücksichtigt die tatsächlich vergangene Zeit zwischen zwei Bildern. `scene.render()` überträgt alle Änderungen eines Bildes gemeinsam an den Browser. Details zum Ablauf stehen in der [webio-Referenz](../../02-webio.mdx#spielschleife).
+
+## Szene, Schneeflocken und Punkte
+
+`sceneId`
+: Verbindet die React-Komponente mit `use_scene(id)`. Mehrere Ansichten derselben ID teilen ihre Anzeige.
+`use_scene(id)`
+: Liefert innerhalb derselben Ausführung denselben lokalen Handle zurück.
+`configure(width=400, height=800, background_color='black')`
+: Legt die logische Grösse und Hintergrundfarbe fest. Die Darstellung verkleinert sich bei wenig Platz proportional; Spielkoordinaten bleiben unverändert.
+`width`, `height`
+: Logische Breite und Höhe der Szene in Pixeln.
+`reset()`
+: Entfernt alle Schneeflocken und Punkte und behält die Konfiguration bei.
+`add_snowflake(x, y, size, speed, color='white')`
+: Erzeugt eine Schneeflocke mit dem SVG-Symbol `mdiSnowflake` und gibt ihren Handle zurück. `size` ist die halbe Symbolgrösse in Pixeln; `(x, y)` ist die Mitte. Die Bewegung mit `speed` legt der Python-Code fest.
+`snowflakes`
+: Momentaufnahme der aktuellen Schneeflocken. Während der Iteration können Schneeflocken entfernt werden.
+`snowflake.x`, `snowflake.y`, `snowflake.size`, `snowflake.speed`, `snowflake.color`
+: Veränderbare Eigenschaften einer Schneeflocke. Position, Grösse und Farbe werden beim nächsten `render()` sichtbar.
+`remove_snowflake(snowflake)`
+: Entfernt die angegebene Schneeflocke.
+`add_dot(x, y, size, color='white')`
+: Erzeugt einen ausgefüllten Kreis mit Mittelpunkt `(x, y)` und Radius `size` in Pixeln. Gibt seinen Handle zurück. Punkte werden vor den Schneeflocken gezeichnet.
+`dots`
+: Momentaufnahme der aktuellen Punkte. Während der Iteration können Punkte entfernt werden.
+`dot.x`, `dot.y`, `dot.size`, `dot.color`
+: Veränderbare Eigenschaften eines Punktes. Änderungen werden beim nächsten `render()` sichtbar.
+`remove_dot(dot)`
+: Entfernt den angegebenen Punkt.
+`color`
+: CSS-Farbe als Zeichenkette, beispielsweise `'white'`, `'lightblue'` oder `'#ff8800'`. Standard für Schneeflocken und Punkte: `'white'`.
+`render()`
+: Überträgt den aktuellen Zustand der ganzen Szene. Änderungen an Handles, `configure()` und `reset()` werden erst damit sichtbar.
+`events`
+: Ereignisquelle für `@gameloop`, `@on_click` und weitere Handler aus `webio`.
+
+Klick-Ereignisse enthalten `event.x` und `event.y` in logischen Szenenkoordinaten, auch bei verkleinerter Darstellung. Dieselben Werte stehen in `event.payload['x']` und `event.payload['y']`. Tastatureingaben gehen an die fokussierte Szene und enthalten den Tastennamen in `event.payload['key']`.
+
+Einzelne Punkte lassen sich unabhängig von den Schneeflocken platzieren und verändern:
+
+```py
+dot = scene.add_dot(200, 400, 6, color='#ff8800')
+dot.x += 10
+dot.color = 'yellow'
+scene.render()
+```
+
+## Zustand und Lebensdauer
+
+Die Browser-Anzeige bleibt nach Ende oder Abbruch eines Skripts erhalten. Ein neuer Python-Handle beginnt mit den Standardwerten und ohne Schneeflocken oder Punkte. Sein erster `render()` ersetzt die bisherige Anzeige. Mit `reset()` und `configure()` am Skriptanfang lässt sich der gewünschte Startzustand ausdrücklich festlegen.
+
+## Auf der eigenen Website einrichten
+
+Voraussetzung ist das [installierte Pyodide-Package](../../README.mdx#installation).
+
+
+
+1. Kopiere den vollständigen Ordner [snowfall-game](https://github.com/GBSL-Informatik/teaching-dev/tree/main/tdev-website/website/pyodide/snowfall-game) nach __website/pyodide/snowfall-game__.
+2. Ergänze den Ordner in der bestehenden `extensions`-Liste des [Pyodide-Plugins](../../README.mdx#erweiterungen-registrieren):
+
+ ```ts title="siteConfig.ts"
+ extensions: [
+ './website/pyodide/snowfall-game'
+ ]
+ ```
+
+3. Starte den Entwicklungsserver neu. Danach kannst du das MDX-Beispiel oben verwenden.
+
+
+
+Die __extension.json__ im kopierten Ordner legt den Namen und die Einstiegspunkte fest. Der Browser-Store wird automatisch initialisiert; der `SiteStore` deiner Website benötigt keine Ergänzungen.
diff --git a/tdev-website/website/pyodide/snowfall-game/extension.json b/tdev-website/website/pyodide/snowfall-game/extension.json
new file mode 100644
index 000000000..0492be71b
--- /dev/null
+++ b/tdev-website/website/pyodide/snowfall-game/extension.json
@@ -0,0 +1,6 @@
+{
+ "name": "snowfall_game",
+ "worker": "./worker.ts",
+ "client": "./client.ts",
+ "showcase": "./docs"
+}
diff --git a/tdev-website/website/pyodide/snowfall-game/protocol.ts b/tdev-website/website/pyodide/snowfall-game/protocol.ts
new file mode 100644
index 000000000..7697634a4
--- /dev/null
+++ b/tdev-website/website/pyodide/snowfall-game/protocol.ts
@@ -0,0 +1,28 @@
+export interface SceneConfiguration {
+ width: number;
+ height: number;
+ background_color: string;
+}
+
+export interface DotSnapshot {
+ readonly id: number;
+ x: number;
+ y: number;
+ size: number;
+ color: string;
+}
+
+export interface SnowflakeSnapshot extends DotSnapshot {
+ speed: number;
+}
+
+export interface SceneSnapshot extends SceneConfiguration {
+ snowflakes: readonly SnowflakeSnapshot[];
+ dots: readonly DotSnapshot[];
+}
+
+export const DEFAULT_SCENE_CONFIGURATION: Readonly = {
+ width: 400,
+ height: 200,
+ background_color: 'black'
+};
diff --git a/tdev-website/website/pyodide/snowfall-game/src/components/SnowfallGame/index.tsx b/tdev-website/website/pyodide/snowfall-game/src/components/SnowfallGame/index.tsx
new file mode 100644
index 000000000..4312b410e
--- /dev/null
+++ b/tdev-website/website/pyodide/snowfall-game/src/components/SnowfallGame/index.tsx
@@ -0,0 +1,79 @@
+import { mdiSnowflake } from '@mdi/js';
+import { useExtensionStore } from '@tdev/pyodide-code/hooks/useExtensionStore';
+import clsx from 'clsx';
+import { observer } from 'mobx-react-lite';
+import type { KeyboardEvent, MouseEvent } from 'react';
+import snowfallExtension from '../../../client';
+import styles from './styles.module.scss';
+
+interface SnowfallGameProps {
+ sceneId: string;
+ className?: string;
+}
+
+const SCROLL_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '];
+
+const SnowfallGame = observer(({ sceneId, className }: SnowfallGameProps) => {
+ const store = useExtensionStore(snowfallExtension);
+ const scene = store.useScene(sceneId);
+ const { width, height, background_color, snowflakes, dots } = scene.snapshot;
+
+ const handleClick = (event: MouseEvent) => {
+ event.currentTarget.focus({ preventScroll: true });
+ const bounds = event.currentTarget.getBoundingClientRect();
+ if (bounds.width <= 0 || bounds.height <= 0) {
+ return;
+ }
+ const x = ((event.clientX - bounds.left) / bounds.width) * width;
+ const y = ((event.clientY - bounds.top) / bounds.height) * height;
+ scene.handleClick(x, y);
+ };
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (!scene.isConnected) {
+ return;
+ }
+ if (SCROLL_KEYS.includes(event.key)) {
+ event.preventDefault();
+ }
+ scene.handleKey(event.key);
+ };
+
+ return (
+
+
+
+ {scene.isConnected ? 'Klicke auf eine Schneeflocke.' : 'Starte zuerst das Python-Programm.'}
+
+
+ );
+});
+
+export default SnowfallGame;
diff --git a/tdev-website/website/pyodide/snowfall-game/src/components/SnowfallGame/styles.module.scss b/tdev-website/website/pyodide/snowfall-game/src/components/SnowfallGame/styles.module.scss
new file mode 100644
index 000000000..55c845f1e
--- /dev/null
+++ b/tdev-website/website/pyodide/snowfall-game/src/components/SnowfallGame/styles.module.scss
@@ -0,0 +1,26 @@
+.container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+ max-width: 100%;
+ margin-block: 1rem;
+}
+
+.scene {
+ display: block;
+ max-width: 100%;
+ height: auto;
+ cursor: crosshair;
+ border-radius: var(--ifm-global-radius);
+
+ &:focus-visible {
+ outline: 3px solid var(--ifm-color-primary);
+ outline-offset: 4px;
+ }
+}
+
+.status {
+ color: var(--ifm-color-emphasis-700);
+ font-size: 0.875rem;
+}
diff --git a/tdev-website/website/pyodide/snowfall-game/src/models/Scene.ts b/tdev-website/website/pyodide/snowfall-game/src/models/Scene.ts
new file mode 100644
index 000000000..758fc515f
--- /dev/null
+++ b/tdev-website/website/pyodide/snowfall-game/src/models/Scene.ts
@@ -0,0 +1,39 @@
+import type { EventSource } from '@tdev/pyodide-code/events';
+import { action, computed, observableRef } from 'mobx';
+import { DEFAULT_SCENE_CONFIGURATION, type SceneSnapshot } from '../../protocol';
+import type SceneStore from '../stores/SceneStore';
+
+export default class Scene {
+ readonly id: string;
+ readonly store: SceneStore;
+ readonly eventSource: EventSource;
+ @observableRef accessor snapshot: SceneSnapshot = {
+ ...DEFAULT_SCENE_CONFIGURATION,
+ snowflakes: [],
+ dots: []
+ };
+
+ constructor(id: string, store: SceneStore) {
+ this.id = id;
+ this.store = store;
+ this.eventSource = { extension: store.context.name, targetId: id };
+ }
+
+ @computed
+ get isConnected(): boolean {
+ return this.store.context.events.isConnected(this.eventSource);
+ }
+
+ @action
+ setSnapshot(snapshot: SceneSnapshot) {
+ this.snapshot = snapshot;
+ }
+
+ handleClick(x: number, y: number) {
+ void this.store.context.events.emit(this.eventSource, { type: 'click', payload: { x, y } });
+ }
+
+ handleKey(key: string) {
+ void this.store.context.events.emit(this.eventSource, { type: 'key', payload: { key } });
+ }
+}
diff --git a/tdev-website/website/pyodide/snowfall-game/src/stores/SceneStore.ts b/tdev-website/website/pyodide/snowfall-game/src/stores/SceneStore.ts
new file mode 100644
index 000000000..e1cb107e5
--- /dev/null
+++ b/tdev-website/website/pyodide/snowfall-game/src/stores/SceneStore.ts
@@ -0,0 +1,23 @@
+import type { ClientContext } from '@tdev/pyodide-code/extensions';
+import { action, observable } from 'mobx';
+import Scene from '../models/Scene';
+
+export default class SceneStore {
+ readonly context: ClientContext;
+ readonly scenes = observable.map([], { deep: false });
+
+ constructor(context: ClientContext) {
+ this.context = context;
+ }
+
+ @action
+ useScene(id: string): Scene {
+ const existingScene = this.scenes.get(id);
+ if (existingScene) {
+ return existingScene;
+ }
+ const scene = new Scene(id, this);
+ this.scenes.set(id, scene);
+ return scene;
+ }
+}
diff --git a/tdev-website/website/pyodide/snowfall-game/src/worker/WorkerScene.ts b/tdev-website/website/pyodide/snowfall-game/src/worker/WorkerScene.ts
new file mode 100644
index 000000000..0657e4057
--- /dev/null
+++ b/tdev-website/website/pyodide/snowfall-game/src/worker/WorkerScene.ts
@@ -0,0 +1,158 @@
+import type { ExecutionEventSource } from '@tdev/pyodide-code/events';
+import type { WorkerContext } from '@tdev/pyodide-code/extensions';
+import {
+ DEFAULT_SCENE_CONFIGURATION,
+ type DotSnapshot,
+ type SceneConfiguration,
+ type SceneSnapshot,
+ type SnowflakeSnapshot
+} from '../../protocol';
+
+interface ColorOptions {
+ color?: string;
+}
+
+const SCENE_OPTIONS = ['width', 'height', 'background_color'];
+
+export default class WorkerScene {
+ readonly events: ExecutionEventSource;
+ private readonly context: WorkerContext;
+ private readonly id: string;
+ private readonly flakes = new Set();
+ private readonly sceneDots = new Set();
+ private configuration: SceneConfiguration = { ...DEFAULT_SCENE_CONFIGURATION };
+ private nextShapeId = 0;
+
+ constructor(context: WorkerContext, id: string) {
+ this.context = context;
+ this.id = id;
+ this.events = context.events.source(id);
+ }
+
+ get width(): number {
+ return this.configuration.width;
+ }
+
+ get height(): number {
+ return this.configuration.height;
+ }
+
+ get background_color(): string {
+ return this.configuration.background_color;
+ }
+
+ get snowflakes(): SnowflakeSnapshot[] {
+ // A snapshot keeps removal during Python iteration from skipping the next flake.
+ return [...this.flakes];
+ }
+
+ get dots(): DotSnapshot[] {
+ return [...this.sceneDots];
+ }
+
+ configure(options: Partial = {}) {
+ if (!options || typeof options !== 'object') {
+ throw new Error('Scene configuration must contain width, height, or background_color options.');
+ }
+ for (const option of Object.keys(options)) {
+ if (!SCENE_OPTIONS.includes(option)) {
+ throw new Error(`Unknown scene configuration option: ${option}`);
+ }
+ }
+ const configuration = { ...this.configuration, ...options };
+ this.validateConfiguration(configuration);
+ this.configuration = configuration;
+ }
+
+ reset() {
+ this.flakes.clear();
+ this.sceneDots.clear();
+ }
+
+ add_snowflake(
+ x: number,
+ y: number,
+ size: number,
+ speed: number,
+ options: ColorOptions = {}
+ ): SnowflakeSnapshot {
+ const snowflake = Object.assign(this.createDot(x, y, size, options), { speed });
+ this.validateSnowflake(snowflake);
+ this.flakes.add(snowflake);
+ return snowflake;
+ }
+
+ add_dot(x: number, y: number, size: number, options: ColorOptions = {}): DotSnapshot {
+ const dot = this.createDot(x, y, size, options);
+ this.sceneDots.add(dot);
+ return dot;
+ }
+
+ remove_dot(dot: DotSnapshot) {
+ this.sceneDots.delete(dot);
+ }
+
+ remove_snowflake(snowflake: SnowflakeSnapshot) {
+ this.flakes.delete(snowflake);
+ }
+
+ render() {
+ this.validateConfiguration(this.configuration);
+ const snowflakes = [...this.flakes].map((snowflake) => {
+ this.validateSnowflake(snowflake);
+ return { ...snowflake };
+ });
+ const dots = [...this.sceneDots].map((dot) => {
+ this.validateDot(dot);
+ return { ...dot };
+ });
+ this.context.send(this.id, { ...this.configuration, snowflakes, dots });
+ }
+
+ private validateConfiguration(configuration: SceneConfiguration) {
+ if (!Number.isFinite(configuration.width) || configuration.width <= 0) {
+ throw new Error('Scene width must be a positive finite number.');
+ }
+ if (!Number.isFinite(configuration.height) || configuration.height <= 0) {
+ throw new Error('Scene height must be a positive finite number.');
+ }
+ if (typeof configuration.background_color !== 'string' || !configuration.background_color.trim()) {
+ throw new Error('Scene background_color must be a non-empty CSS color string.');
+ }
+ }
+
+ private createDot(x: number, y: number, size: number, options: ColorOptions): DotSnapshot {
+ const id = this.nextShapeId;
+ const dot: DotSnapshot = {
+ get id() {
+ return id;
+ },
+ x,
+ y,
+ size,
+ color: options.color ?? 'white'
+ };
+ this.validateDot(dot);
+ this.nextShapeId += 1;
+ return dot;
+ }
+
+ private validateDot(dot: DotSnapshot) {
+ if (!Number.isFinite(dot.x) || !Number.isFinite(dot.y)) {
+ throw new Error('Shape coordinates must be finite numbers.');
+ }
+ if (!Number.isFinite(dot.size) || dot.size <= 0) {
+ throw new Error('Shape size must be a positive finite number.');
+ }
+ if (typeof dot.color !== 'string' || !dot.color.trim()) {
+ throw new Error('Shape color must be a non-empty CSS color string.');
+ }
+ }
+
+ private validateSnowflake(snowflake: SnowflakeSnapshot) {
+ this.validateDot(snowflake);
+ if (!Number.isFinite(snowflake.speed)) {
+ throw new Error('Snowflake speed must be a finite number.');
+ }
+ }
+}
diff --git a/tdev-website/website/pyodide/snowfall-game/worker.ts b/tdev-website/website/pyodide/snowfall-game/worker.ts
new file mode 100644
index 000000000..f5d86b981
--- /dev/null
+++ b/tdev-website/website/pyodide/snowfall-game/worker.ts
@@ -0,0 +1,20 @@
+import { defineWorkerExtension } from '@tdev/pyodide-code/extensions';
+import type { SceneSnapshot } from './protocol';
+import WorkerScene from './src/worker/WorkerScene';
+
+export default defineWorkerExtension((context) => {
+ const scenes = new Map();
+ return {
+ snowfall_game: {
+ use_scene: (id: string) => {
+ const existingScene = scenes.get(id);
+ if (existingScene) {
+ return existingScene;
+ }
+ const scene = new WorkerScene(context, id);
+ scenes.set(id, scene);
+ return scene;
+ }
+ }
+ };
+});
diff --git a/tdev-website/website/stores/SiteStore.ts b/tdev-website/website/stores/SiteStore.ts
index 09b6fdf30..7ee7803be 100644
--- a/tdev-website/website/stores/SiteStore.ts
+++ b/tdev-website/website/stores/SiteStore.ts
@@ -1,8 +1,5 @@
import { RootStore } from '@tdev-stores/rootStore';
-import { observableRef } from 'mobx';
-import ClockStore from './ClockStore';
export default class SiteStore {
- @observableRef accessor clockStore = new ClockStore();
constructor(root: RootStore) {}
}
diff --git a/updateSync/packageDocsSync/actions.ts b/updateSync/packageDocsSync/actions.ts
index 34764c077..d2c396c8c 100644
--- a/updateSync/packageDocsSync/actions.ts
+++ b/updateSync/packageDocsSync/actions.ts
@@ -219,63 +219,39 @@ export const getPackageDocsConfigs = async (packagesDir: string): Promise {
+export const getDebouncedSyncer = async (
+ packageDir: string,
+ destDir: string,
+ afterSync?: () => Promise
+) => {
const syncQueue = new Set();
- const PackageConfigCache: Map = new Map();
- getPackageDocsConfigs(packageDir).then((configs) => {
- for (const cfg of configs) {
- const pkgKey = `${cfg.docs.org}/${cfg.docs.package}`;
- PackageConfigCache.set(pkgKey, cfg);
- }
- });
-
- const setPackageConfig = async (pkgKey: string, newConfig?: TdevPackageConfig | null) => {
- if (!newConfig) {
- return false;
- }
- const prevConfig = PackageConfigCache.get(pkgKey);
- if (prevConfig) {
- const prevDocsPath = resolveDir(destDir, prevConfig.docs.org, prevConfig.docs.package);
- const currentDocsPath = resolveDir(destDir, newConfig.docs.org, newConfig.docs.package);
- if (prevDocsPath !== currentDocsPath) {
- fs.rm(prevDocsPath, { recursive: true, force: true }).catch(() => {});
- }
- }
- PackageConfigCache.set(pkgKey, newConfig);
- return true;
- };
-
+ let pendingSync = Promise.resolve();
const syncDebounced = debounce(() => {
- const syncTasks: Promise[] = [];
+ const packages = new Map();
for (const pkgInfo of syncQueue) {
- if (!pkgInfo) {
- continue;
- }
- const pkgKey = `${pkgInfo.org}/${pkgInfo.package}`;
- if (pkgInfo.relativeSubPath === TDEV_PACKAGE_CONFIG_YML) {
- const res = getPackageDocsConfig(packageDir, pkgInfo.org, pkgInfo.package).then(
- (newConfig) => {
- return setPackageConfig(pkgKey, newConfig).then((changed) => {
- if (changed) {
- return syncDocsFolder(newConfig!, destDir);
- }
- return Promise.resolve(`ℹ️ ${pkgKey} docs config unchanged.`);
+ packages.set(`${pkgInfo.org}/${pkgInfo.package}`, pkgInfo);
+ }
+ syncQueue.clear();
+ // Package copies delete extra files, so extension docs must follow them in the same queue.
+ pendingSync = pendingSync
+ .then(async () => {
+ for (const pkgInfo of packages.values()) {
+ const config = await getPackageDocsConfig(packageDir, pkgInfo.org, pkgInfo.package);
+ if (config) {
+ console.log(await syncDocsFolder(config, destDir));
+ } else {
+ await fs.rm(path.join(destDir, pkgInfo.org, pkgInfo.package), {
+ recursive: true,
+ force: true
});
}
- );
- syncTasks.push(res);
- } else {
- const pkgConfig = PackageConfigCache.get(pkgKey);
- if (pkgConfig) {
- syncTasks.push(syncDocsFolder(pkgConfig, destDir));
}
- }
- }
- syncQueue.clear();
- return Promise.all(syncTasks).then((res) => {
- console.log(res.join('\n'));
- return res;
- });
+ await afterSync?.();
+ })
+ .catch((error) => {
+ console.error('Error synchronizing package documentation:', error);
+ });
+ return pendingSync;
}, 300);
return { syncQueue, syncDebounced };
};
diff --git a/updateSync/packageDocsSync/configuration.ts b/updateSync/packageDocsSync/configuration.ts
new file mode 100644
index 000000000..34f26da41
--- /dev/null
+++ b/updateSync/packageDocsSync/configuration.ts
@@ -0,0 +1,25 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { pathToFileURL } from 'node:url';
+import type { SiteConfig } from '../../src/siteConfig/siteConfig';
+import { withSiteConfig } from '../../src/siteConfig/withSiteConfig';
+import type { PackageDocsExtension } from './types';
+
+export const loadPyodideDocsSync = async (packageDir: string): Promise => {
+ const packagePath = path.resolve(packageDir, 'tdev/pyodide-code');
+ try {
+ await fs.access(packagePath);
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
+ return;
+ }
+ throw error;
+ }
+
+ // Keep optional package code out of both the runtime and TypeScript import graph.
+ const modulePath = pathToFileURL(path.join(packagePath, 'docsSync/index.ts')).href;
+ const adapter = (await import(modulePath)) as {
+ createDocsSync: (siteConfig: SiteConfig) => PackageDocsExtension;
+ };
+ return adapter.createDocsSync(await withSiteConfig());
+};
diff --git a/updateSync/packageDocsSync/index.ts b/updateSync/packageDocsSync/index.ts
index 5133bd01e..116bf9393 100644
--- a/updateSync/packageDocsSync/index.ts
+++ b/updateSync/packageDocsSync/index.ts
@@ -1,8 +1,9 @@
import fs from 'fs/promises';
import path from 'path';
import { getPackageDocsConfigs, syncCategoryFile, syncDocsFolder } from './actions';
+import type { PackageDocsExtension } from './types';
-const packageDocsSync = async (packageDir: string, destDir: string) => {
+const packageDocsSync = async (packageDir: string, destDir: string, extension?: PackageDocsExtension) => {
const srcPath = path.resolve(process.cwd(), packageDir);
const data = await getPackageDocsConfigs(srcPath);
// create the dest dir if it doesn't exist
@@ -26,6 +27,7 @@ const packageDocsSync = async (packageDir: string, destDir: string) => {
}),
syncCategoryFile(srcPath, destPath)
]);
+ await extension?.sync(destPath);
console.log(result.filter(Boolean).join('\n'));
return result;
};
diff --git a/updateSync/packageDocsSync/preBuild.ts b/updateSync/packageDocsSync/preBuild.ts
index 61afad104..d349d8a87 100644
--- a/updateSync/packageDocsSync/preBuild.ts
+++ b/updateSync/packageDocsSync/preBuild.ts
@@ -1,6 +1,8 @@
+import 'dotenv/config';
import minimist from 'minimist';
import path from 'path';
import packageDocsSync from '.';
+import { loadPyodideDocsSync } from './configuration';
const argv = minimist(process.argv.slice(2), {
string: ['src', 'dest'],
alias: { src: 'packages', dest: 'out' },
@@ -15,7 +17,7 @@ const PACKAGES_DIR = path.resolve(CWD, src);
const DEST_ROOT = path.resolve(CWD, dest);
const main = async () => {
- await packageDocsSync(PACKAGES_DIR, DEST_ROOT);
+ await packageDocsSync(PACKAGES_DIR, DEST_ROOT, await loadPyodideDocsSync(PACKAGES_DIR));
console.log('✅ Pre-build docs sync completed.');
};
diff --git a/updateSync/packageDocsSync/tests/configuration.test.ts b/updateSync/packageDocsSync/tests/configuration.test.ts
new file mode 100644
index 000000000..585cedb95
--- /dev/null
+++ b/updateSync/packageDocsSync/tests/configuration.test.ts
@@ -0,0 +1,179 @@
+import { execFile, spawn } from 'node:child_process';
+import { once } from 'node:events';
+import fs from 'node:fs/promises';
+import { createRequire } from 'node:module';
+import os from 'node:os';
+import path from 'node:path';
+import { promisify } from 'node:util';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import packageDocsSync from '..';
+import { withSiteConfig } from '../../../src/siteConfig/withSiteConfig';
+import { loadPyodideDocsSync } from '../configuration';
+
+vi.mock('../../../src/siteConfig/withSiteConfig', () => ({
+ withSiteConfig: vi.fn(async () => ({}))
+}));
+
+const temporaryDirectories: string[] = [];
+const execute = promisify(execFile);
+const resolve = createRequire(import.meta.url).resolve;
+
+const createSite = async () => {
+ const siteDir = await fs.mkdtemp(path.join(os.tmpdir(), 'optional-pyodide-docs-'));
+ temporaryDirectories.push(siteDir);
+ const packageDir = path.join(siteDir, 'packages');
+ const destination = path.join(siteDir, 'generated');
+ const docs = path.join(packageDir, 'other/example/docs');
+ await fs.mkdir(docs, { recursive: true });
+ await fs.writeFile(path.join(docs, 'README.md'), '# Example\n');
+ return { siteDir, packageDir, destination, docs };
+};
+
+const writeAdapter = async (packageDir: string, source: string) => {
+ const directory = path.join(packageDir, 'tdev/pyodide-code/docsSync');
+ await fs.mkdir(directory, { recursive: true });
+ await fs.writeFile(path.join(directory, 'index.ts'), source);
+};
+
+beforeEach(() => vi.clearAllMocks());
+
+afterEach(async () => {
+ await Promise.all(
+ temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true }))
+ );
+});
+
+describe('Optional Pyodide documentation integration', () => {
+ it('syncs other packages without loading site configuration when Pyodide is absent', async () => {
+ const site = await createSite();
+ const extension = await loadPyodideDocsSync(site.packageDir);
+ expect(extension).toBeUndefined();
+ expect(withSiteConfig).not.toHaveBeenCalled();
+
+ await packageDocsSync(site.packageDir, site.destination, extension);
+ expect(await fs.readFile(path.join(site.destination, 'other/example/README.md'), 'utf8')).toBe(
+ '# Example\n'
+ );
+ await expect(fs.access(path.join(site.destination, 'tdev/pyodide-code'))).rejects.toThrow();
+ });
+
+ it('loads a present adapter and its watch paths', async () => {
+ const site = await createSite();
+ await writeAdapter(
+ site.packageDir,
+ `
+export const createDocsSync = () => ({ watchPaths: ['example'], sync: async () => {} });
+`
+ );
+ const extension = await loadPyodideDocsSync(site.packageDir);
+ expect(extension?.watchPaths).toEqual(['example']);
+ expect(withSiteConfig).toHaveBeenCalledOnce();
+ await extension?.sync(site.destination);
+ });
+
+ it('reports a missing adapter in a present package', async () => {
+ const site = await createSite();
+ await fs.mkdir(path.join(site.packageDir, 'tdev/pyodide-code'), { recursive: true });
+ await expect(loadPyodideDocsSync(site.packageDir)).rejects.toThrow();
+ });
+
+ it.each([
+ "throw new Error('broken adapter');",
+ "export const createDocsSync = () => { throw new Error('broken adapter'); };"
+ ])('does not hide errors from an installed adapter: %s', async (source) => {
+ const site = await createSite();
+ await writeAdapter(site.packageDir, source);
+ await expect(loadPyodideDocsSync(site.packageDir)).rejects.toThrow('broken adapter');
+ });
+
+ it('runs prebuild, watch updates, and typecheck in a copied CLI without Pyodide', async () => {
+ const site = await createSite();
+ await prepareCopiedCli(site.siteDir);
+ const cliDir = path.join(site.siteDir, 'updateSync/packageDocsSync');
+ const args = ['--src', site.packageDir, '--dest', site.destination];
+ const options = { cwd: site.siteDir, env: { ...process.env, SITE_CONFIG_PATH: './siteConfig.ts' } };
+ await execute(
+ process.execPath,
+ ['--import', resolve('tsx'), path.join(cliDir, 'preBuild.ts'), ...args],
+ { ...options, timeout: 5000 }
+ );
+ expect(await fs.readFile(path.join(site.destination, 'other/example/README.md'), 'utf8')).toBe(
+ '# Example\n'
+ );
+ const compiler = path.join(path.dirname(resolve('typescript/package.json')), 'bin/tsc');
+ await execute(process.execPath, [compiler, '--project', site.siteDir], { ...options, timeout: 5000 });
+
+ const logPath = path.join(site.siteDir, 'watch.log');
+ const log = await fs.open(logPath, 'w');
+ const watcher = spawn(
+ process.execPath,
+ ['--import', resolve('tsx'), path.join(cliDir, 'watch.ts'), ...args],
+ { ...options, stdio: ['ignore', log.fd, log.fd] }
+ );
+ try {
+ await vi.waitFor(
+ async () => {
+ expect(await fs.readFile(logPath, 'utf8')).toContain('Watching for package');
+ },
+ { timeout: 3000 }
+ );
+ await fs.writeFile(path.join(site.docs, 'README.md'), '# Updated example\n');
+ await vi.waitFor(
+ async () => {
+ expect(
+ await fs.readFile(path.join(site.destination, 'other/example/README.md'), 'utf8')
+ ).toBe('# Updated example\n');
+ },
+ { timeout: 5000 }
+ );
+ } finally {
+ if (watcher.exitCode === null && watcher.signalCode === null) {
+ const stopped = once(watcher, 'exit');
+ watcher.kill();
+ await stopped;
+ }
+ await log.close();
+ }
+ }, 15000);
+});
+
+const prepareCopiedCli = async (siteDir: string) => {
+ await fs.cp(path.resolve(import.meta.dirname, '..'), path.join(siteDir, 'updateSync/packageDocsSync'), {
+ recursive: true,
+ filter: (source) => path.basename(source) !== 'tests'
+ });
+ await fs.mkdir(path.join(siteDir, 'src/siteConfig'), { recursive: true });
+ await fs.copyFile(
+ path.resolve(import.meta.dirname, '../../../src/siteConfig/withSiteConfig.ts'),
+ path.join(siteDir, 'src/siteConfig/withSiteConfig.ts')
+ );
+ await fs.writeFile(
+ path.join(siteDir, 'src/siteConfig/siteConfig.ts'),
+ 'export interface SiteConfig {}\n'
+ );
+ await fs.writeFile(
+ path.join(siteDir, 'siteConfig.ts'),
+ "throw new Error('Must not load optional site config');\n"
+ );
+ await fs.writeFile(path.join(siteDir, 'package.json'), JSON.stringify({ private: true }));
+ await fs.writeFile(
+ path.join(siteDir, 'tsconfig.json'),
+ JSON.stringify({
+ compilerOptions: {
+ target: 'ESNext',
+ module: 'ESNext',
+ moduleResolution: 'bundler',
+ strict: true,
+ esModuleInterop: true,
+ skipLibCheck: true,
+ noEmit: true,
+ types: ['node']
+ },
+ include: ['updateSync/**/*.ts', 'src/**/*.ts']
+ })
+ );
+ await fs.mkdir(path.join(siteDir, 'node_modules'), { recursive: true });
+ for (const name of ['@types', 'chokidar', 'dotenv', 'es-toolkit', 'js-yaml', 'minimist', 'tsx']) {
+ await fs.symlink(path.resolve('node_modules', name), path.join(siteDir, 'node_modules', name), 'dir');
+ }
+};
diff --git a/updateSync/packageDocsSync/types.ts b/updateSync/packageDocsSync/types.ts
new file mode 100644
index 000000000..af4b2a94c
--- /dev/null
+++ b/updateSync/packageDocsSync/types.ts
@@ -0,0 +1,4 @@
+export interface PackageDocsExtension {
+ watchPaths: string[];
+ sync: (destination: string) => Promise;
+}
diff --git a/updateSync/packageDocsSync/watch.ts b/updateSync/packageDocsSync/watch.ts
index 9852b1bc6..43ce032f6 100644
--- a/updateSync/packageDocsSync/watch.ts
+++ b/updateSync/packageDocsSync/watch.ts
@@ -1,11 +1,14 @@
import chokidar from 'chokidar';
+import 'dotenv/config';
import minimist from 'minimist';
import path from 'path';
import packageDocsSync from '.';
import { categoryFileLocation, getDebouncedSyncer, packageInfo, syncCategoryFile } from './actions';
+import { loadPyodideDocsSync } from './configuration';
const argv = minimist(process.argv.slice(2), {
string: ['src', 'dest'],
+ boolean: ['skip-initial'],
alias: { src: 'packages', dest: 'out' },
default: {
src: 'packages'
@@ -15,16 +18,33 @@ const argv = minimist(process.argv.slice(2), {
const PACKAGES_DIR = path.resolve(process.cwd(), argv.src);
const DEST_ROOT = path.resolve(process.cwd(), argv.dest);
-const watcher = chokidar.watch(PACKAGES_DIR, { ignoreInitial: true, persistent: true });
-
const main = async () => {
- const { syncQueue, syncDebounced } = await getDebouncedSyncer(PACKAGES_DIR, DEST_ROOT);
- const NODE_MODULES_TEST = /node_modules/;
- await packageDocsSync(PACKAGES_DIR, DEST_ROOT);
+ const pyodide = await loadPyodideDocsSync(PACKAGES_DIR);
+ const extensionPaths = pyodide?.watchPaths ?? [];
+ if (!argv['skip-initial']) {
+ await packageDocsSync(PACKAGES_DIR, DEST_ROOT, pyodide);
+ }
+ const { syncQueue, syncDebounced } = await getDebouncedSyncer(
+ PACKAGES_DIR,
+ DEST_ROOT,
+ pyodide ? () => pyodide.sync(DEST_ROOT) : undefined
+ );
+ const watcher = chokidar.watch([PACKAGES_DIR, ...extensionPaths], {
+ ignoreInitial: true,
+ persistent: true,
+ ignored: /(^|[/\\])node_modules([/\\]|$)/
+ });
watcher
.on('all', async (_event, filePath) => {
- if (NODE_MODULES_TEST.test(filePath)) {
+ const isExtension = extensionPaths.some((directory) => {
+ const relativePath = path.relative(directory, filePath);
+ return (
+ relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath))
+ );
+ });
+ if (isExtension) {
+ syncDebounced();
return null;
}
const pkgInfo = packageInfo(filePath, PACKAGES_DIR);
@@ -39,7 +59,7 @@ const main = async () => {
}
})
.on('ready', () => {
- console.log('Watching for docs changes in packages...');
+ console.log('Watching for package and Pyodide extension documentation changes...');
});
};
diff --git a/updateSync/tsconfig.json b/updateSync/tsconfig.json
index 6491f4b50..a3e732778 100644
--- a/updateSync/tsconfig.json
+++ b/updateSync/tsconfig.json
@@ -1,10 +1,4 @@
{
- "compilerOptions": {
- "target": "ES2020",
- "module": "commonjs",
- "outDir": "../../build/updateTdev",
- "rootDir": "./",
- "esModuleInterop": true,
- "skipLibCheck": true
- }
+ "extends": "../tsconfig.json",
+ "include": ["./**/*.ts"]
}