@tinyvolt/pie 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/.nvmrc +1 -0
  2. package/LICENSE +21 -0
  3. package/README.md +68 -2
  4. package/functions/midpoint.ts +29 -0
  5. package/functions/square.ts +20 -0
  6. package/init/index.js +87 -0
  7. package/init/site-agents.md +257 -0
  8. package/init/site-gitignore +28 -0
  9. package/init/site-tsconfig.json +20 -0
  10. package/init/site-vite.config.ts +6 -0
  11. package/layouts/header.tsx +16 -0
  12. package/layouts/index.tsx +10 -0
  13. package/nodes/square.tsx +36 -0
  14. package/package.json +63 -4
  15. package/pages/about.md +5 -0
  16. package/pages/example.md +7 -0
  17. package/pages/python-editor.md +29 -0
  18. package/public/favicon.ico +0 -0
  19. package/public/logo.svg +5 -0
  20. package/site.yml +1 -0
  21. package/src/app.css +387 -0
  22. package/src/app.tsx +30 -0
  23. package/src/authorStyles.ts +3 -0
  24. package/src/components/Header.tsx +16 -0
  25. package/src/components/PageMeta.tsx +40 -0
  26. package/src/components/PageTitle.tsx +10 -0
  27. package/src/components/SplitPane.tsx +170 -0
  28. package/src/components/icons.tsx +94 -0
  29. package/src/entry-client.tsx +4 -0
  30. package/src/entry-server.tsx +21 -0
  31. package/src/global.d.ts +7 -0
  32. package/src/lib/appDir.ts +4 -0
  33. package/src/lib/autodiff/index.ts +49 -0
  34. package/src/lib/layouts/layout.ts +20 -0
  35. package/src/lib/layouts/vitePlugin.ts +39 -0
  36. package/src/lib/markdown/components/CanvasPage.tsx +25 -0
  37. package/src/lib/markdown/components/MarkdownBody.tsx +42 -0
  38. package/src/lib/markdown/components/PythonEditor.tsx +254 -0
  39. package/src/lib/markdown/frontmatter.ts +83 -0
  40. package/src/lib/markdown/pages.ts +23 -0
  41. package/src/lib/markdown/render.ts +62 -0
  42. package/src/lib/markdown/vitePlugin.ts +94 -0
  43. package/src/lib/nodes/components/CenteredGrid.tsx +59 -0
  44. package/src/lib/nodes/components/NodeCanvas.tsx +58 -0
  45. package/src/lib/nodes/execute.ts +166 -0
  46. package/src/lib/nodes/functions/Node2NodeFunc.ts +307 -0
  47. package/src/lib/nodes/functions/array.ts +36 -0
  48. package/src/lib/nodes/functions/autodiff.ts +64 -0
  49. package/src/lib/nodes/functions/basic.ts +298 -0
  50. package/src/lib/nodes/functions/registry.ts +46 -0
  51. package/src/lib/nodes/functions/sideEffects.ts +193 -0
  52. package/src/lib/nodes/functions/vitePlugin.ts +40 -0
  53. package/src/lib/nodes/hooks/useCanvasResize.ts +38 -0
  54. package/src/lib/nodes/jax.ts +191 -0
  55. package/src/lib/nodes/render/renderLogic.tsx +306 -0
  56. package/src/lib/nodes/state/NameGenerator.ts +31 -0
  57. package/src/lib/nodes/state/Node.ts +211 -0
  58. package/src/lib/nodes/state/NodeStore.ts +110 -0
  59. package/src/lib/nodes/tensors/broadcast.ts +101 -0
  60. package/src/lib/nodes/tensors/tensor.ts +42 -0
  61. package/src/lib/python/lift.ts +105 -0
  62. package/src/lib/python/py/__pycache__/pie.cpython-314.pyc +0 -0
  63. package/src/lib/python/py/pie.py +231 -0
  64. package/src/lib/python/py/pie_mpl.py +15 -0
  65. package/src/lib/python/py/show_figures.py +5 -0
  66. package/src/lib/python/runner.ts +202 -0
  67. package/src/lib/python/worker.ts +109 -0
  68. package/src/lib/site/vitePlugin.ts +58 -0
  69. package/src/routes/[...slug].tsx +39 -0
  70. package/src/routes/index.tsx +24 -0
  71. package/src/routes/pages/index.tsx +20 -0
  72. package/styles/theme.css +27 -0
  73. package/vite.ts +96 -0
@@ -0,0 +1,170 @@
1
+ import { createSignal, Show, type JSX } from 'solid-js';
2
+ import { Chevron, type Direction } from './icons';
3
+
4
+ /** The edge the first pane sits against. */
5
+ export type SplitSide = 'left' | 'right' | 'top' | 'bottom';
6
+ type Collapsed = 'none' | 'first' | 'second';
7
+
8
+ // Limits on the first pane's share, in %, while dragging.
9
+ const MIN_PERCENT = 20;
10
+ const MAX_PERCENT = 85;
11
+ /** Dragging a pane below this share, in %, collapses it. */
12
+ const SNAP_PERCENT = 8;
13
+
14
+ const SIDE_DIRECTION: Record<SplitSide, Direction> = { left: 'left', right: 'right', top: 'up', bottom: 'down' };
15
+ const OPPOSITE: Record<Direction, Direction> = { left: 'right', right: 'left', up: 'down', down: 'up' };
16
+ const BUTTONS_TRANSFORM: Record<Direction | 'center', string> = {
17
+ center: 'translate(-50%, -50%)',
18
+ left: 'translate(-100%, -50%)',
19
+ right: 'translate(0, -50%)',
20
+ up: 'translate(-50%, -100%)',
21
+ down: 'translate(-50%, 0)',
22
+ };
23
+
24
+ const HIDDEN_PANE: JSX.CSSProperties = { flex: '0 0 0%', overflow: 'hidden', visibility: 'hidden' };
25
+
26
+ /** The first pane's share of `rect`, in %, with the pointer at (`x`, `y`). */
27
+ function firstPercentAt(side: SplitSide, rect: DOMRect, x: number, y: number): number {
28
+ const fraction = {
29
+ left: (x - rect.left) / rect.width,
30
+ right: (rect.right - x) / rect.width,
31
+ top: (y - rect.top) / rect.height,
32
+ bottom: (rect.bottom - y) / rect.height,
33
+ }[side];
34
+ return fraction * 100;
35
+ }
36
+
37
+ /**
38
+ * Calls `onMove` on every pointer move until the pointer is released.
39
+ * Listens on the window, so the drag goes on after the pointer leaves the divider.
40
+ * Text selection is off meanwhile, so dragging doesn't highlight page text.
41
+ */
42
+ function trackDrag(onMove: (ev: PointerEvent) => void) {
43
+ document.body.style.userSelect = 'none';
44
+ const stop = () => {
45
+ document.body.style.userSelect = '';
46
+ window.removeEventListener('pointermove', onMove);
47
+ window.removeEventListener('pointerup', stop);
48
+ };
49
+ window.addEventListener('pointermove', onMove);
50
+ window.addEventListener('pointerup', stop);
51
+ }
52
+
53
+ /** The draggable bar between the panes, with a button to collapse each one. */
54
+ function SplitDivider(props: {
55
+ side: SplitSide;
56
+ collapsed: Collapsed;
57
+ setCollapsed: (c: Collapsed) => void;
58
+ firstLabel: string;
59
+ secondLabel: string;
60
+ onPointerDown: (e: PointerEvent) => void;
61
+ }) {
62
+ const isStacked = () => props.side === 'top' || props.side === 'bottom';
63
+ const firstDirection = () => SIDE_DIRECTION[props.side];
64
+ const secondDirection = () => OPPOSITE[firstDirection()];
65
+ // A collapsed pane's buttons sit beside the bar, on the open pane's side.
66
+ const buttonsTransform = () => {
67
+ const c = props.collapsed;
68
+ return BUTTONS_TRANSFORM[c === 'first' ? secondDirection() : c === 'second' ? firstDirection() : 'center'];
69
+ };
70
+ const firstButtonLabel = () => (props.collapsed === 'second' ? `Show ${props.secondLabel}` : `Hide ${props.firstLabel}`);
71
+ const secondButtonLabel = () => (props.collapsed === 'first' ? `Show ${props.firstLabel}` : `Hide ${props.secondLabel}`);
72
+
73
+ return (
74
+ <div
75
+ class="split-pane-divider"
76
+ classList={{ 'split-pane-divider--horizontal': isStacked() }}
77
+ onPointerDown={props.onPointerDown}
78
+ role="separator"
79
+ aria-orientation={isStacked() ? 'horizontal' : 'vertical'}
80
+ >
81
+ <div class="split-pane-buttons" style={{ transform: buttonsTransform() }}>
82
+ <Show when={props.collapsed !== 'first'}>
83
+ <button
84
+ class="split-pane-button"
85
+ onPointerDown={(e) => e.stopPropagation()}
86
+ onClick={() => props.setCollapsed(props.collapsed === 'second' ? 'none' : 'first')}
87
+ aria-label={firstButtonLabel()}
88
+ title={firstButtonLabel()}
89
+ >
90
+ <Chevron direction={firstDirection()} />
91
+ </button>
92
+ </Show>
93
+ <Show when={props.collapsed !== 'second'}>
94
+ <button
95
+ class="split-pane-button"
96
+ onPointerDown={(e) => e.stopPropagation()}
97
+ onClick={() => props.setCollapsed(props.collapsed === 'first' ? 'none' : 'second')}
98
+ aria-label={secondButtonLabel()}
99
+ title={secondButtonLabel()}
100
+ >
101
+ <Chevron direction={secondDirection()} />
102
+ </button>
103
+ </Show>
104
+ </div>
105
+ </div>
106
+ );
107
+ }
108
+
109
+ export interface SplitPaneProps {
110
+ side: SplitSide;
111
+ first: JSX.Element;
112
+ second: JSX.Element;
113
+ /** Names used on the collapse buttons, e.g. "Hide canvas". */
114
+ firstLabel: string;
115
+ secondLabel: string;
116
+ class?: string;
117
+ }
118
+
119
+ /**
120
+ * Two panes split by a draggable divider: `first` against `side`, `second` in the rest.
121
+ * Each pane can be collapsed with a button on the divider, or by dragging it nearly shut.
122
+ */
123
+ export function SplitPane(props: SplitPaneProps) {
124
+ let ref: HTMLDivElement | undefined;
125
+ const [firstPercent, setFirstPercent] = createSignal(50);
126
+ const [collapsed, setCollapsed] = createSignal<Collapsed>('none');
127
+
128
+ const startResize = (e: PointerEvent) => {
129
+ e.preventDefault();
130
+ trackDrag((ev) => {
131
+ if (!ref) return;
132
+ const pct = firstPercentAt(props.side, ref.getBoundingClientRect(), ev.clientX, ev.clientY);
133
+ if (pct < SNAP_PERCENT) {
134
+ setCollapsed('first');
135
+ } else if (pct > 100 - SNAP_PERCENT) {
136
+ setCollapsed('second');
137
+ } else {
138
+ setCollapsed('none');
139
+ setFirstPercent(Math.min(MAX_PERCENT, Math.max(MIN_PERCENT, pct)));
140
+ }
141
+ });
142
+ };
143
+
144
+ const firstStyle = (): JSX.CSSProperties => {
145
+ const c = collapsed();
146
+ if (c === 'first') return HIDDEN_PANE;
147
+ if (c === 'second') return { flex: '1 1 0%' };
148
+ return { flex: `0 0 ${firstPercent()}%` };
149
+ };
150
+ const secondStyle = (): JSX.CSSProperties => (collapsed() === 'second' ? HIDDEN_PANE : { flex: '1 1 0%' });
151
+
152
+ return (
153
+ <div class={`split-pane split-pane--${props.side} ${props.class ?? ''}`} ref={ref}>
154
+ <div class="split-pane-pane" style={firstStyle()}>
155
+ {props.first}
156
+ </div>
157
+ <SplitDivider
158
+ side={props.side}
159
+ collapsed={collapsed()}
160
+ setCollapsed={setCollapsed}
161
+ firstLabel={props.firstLabel}
162
+ secondLabel={props.secondLabel}
163
+ onPointerDown={startResize}
164
+ />
165
+ <div class="split-pane-pane" style={secondStyle()}>
166
+ {props.second}
167
+ </div>
168
+ </div>
169
+ );
170
+ }
@@ -0,0 +1,94 @@
1
+ import type { JSX } from 'solid-js';
2
+
3
+ export type Direction = 'left' | 'right' | 'up' | 'down';
4
+
5
+ const CHEVRON_TURN: Record<Direction, number> = { left: 0, up: 90, right: 180, down: 270 };
6
+
7
+ /** A small arrow pointing `direction`. */
8
+ export function Chevron(props: { direction: Direction }) {
9
+ return (
10
+ <svg
11
+ width="12"
12
+ height="12"
13
+ viewBox="0 0 24 24"
14
+ fill="none"
15
+ stroke="currentColor"
16
+ stroke-width="2"
17
+ stroke-linecap="round"
18
+ stroke-linejoin="round"
19
+ style={{ transform: `rotate(${CHEVRON_TURN[props.direction]}deg)` }}
20
+ >
21
+ <path d="M15 18l-6-6 6-6" />
22
+ </svg>
23
+ );
24
+ }
25
+
26
+ /** The shared frame for the icons below: 24×24 drawing, lines in the text color. */
27
+ function Icon(props: { children: JSX.Element }) {
28
+ return (
29
+ <svg
30
+ class="icon"
31
+ width="14"
32
+ height="14"
33
+ viewBox="0 0 24 24"
34
+ fill="none"
35
+ stroke="currentColor"
36
+ stroke-width="2"
37
+ stroke-linecap="round"
38
+ stroke-linejoin="round"
39
+ aria-hidden="true"
40
+ >
41
+ {props.children}
42
+ </svg>
43
+ );
44
+ }
45
+
46
+ export function PlayIcon() {
47
+ return (
48
+ <Icon>
49
+ <path d="M6 4l14 8-14 8z" fill="currentColor" />
50
+ </Icon>
51
+ );
52
+ }
53
+
54
+ export function StopIcon() {
55
+ return (
56
+ <Icon>
57
+ <rect x="5" y="5" width="14" height="14" rx="1" fill="currentColor" />
58
+ </Icon>
59
+ );
60
+ }
61
+
62
+ export function ResetIcon() {
63
+ return (
64
+ <Icon>
65
+ <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
66
+ <path d="M3 3v5h5" />
67
+ </Icon>
68
+ );
69
+ }
70
+
71
+ export function CopyIcon() {
72
+ return (
73
+ <Icon>
74
+ <rect x="8" y="8" width="14" height="14" rx="2" />
75
+ <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
76
+ </Icon>
77
+ );
78
+ }
79
+
80
+ export function CheckIcon() {
81
+ return (
82
+ <Icon>
83
+ <path d="M20 6L9 17l-5-5" />
84
+ </Icon>
85
+ );
86
+ }
87
+
88
+ export function CrossIcon() {
89
+ return (
90
+ <Icon>
91
+ <path d="M18 6L6 18M6 6l12 12" />
92
+ </Icon>
93
+ );
94
+ }
@@ -0,0 +1,4 @@
1
+ // @refresh reload
2
+ import { mount, StartClient } from "@solidjs/start/client";
3
+
4
+ mount(() => <StartClient />, document.getElementById("app")!);
@@ -0,0 +1,21 @@
1
+ // @refresh reload
2
+ import { createHandler, StartServer } from "@solidjs/start/server";
3
+
4
+ export default createHandler(() => (
5
+ <StartServer
6
+ document={({ assets, children, scripts }) => (
7
+ <html lang="en">
8
+ <head>
9
+ <meta charset="utf-8" />
10
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
11
+ <link rel="icon" href="/favicon.ico" />
12
+ {assets}
13
+ </head>
14
+ <body>
15
+ <div id="app">{children}</div>
16
+ {scripts}
17
+ </body>
18
+ </html>
19
+ )}
20
+ />
21
+ ));
@@ -0,0 +1,7 @@
1
+ /// <reference types="@solidjs/start/env" />
2
+
3
+ // The root `site.yml`, turned into a module by `src/lib/site/vitePlugin.ts`.
4
+ declare module '*/site.yml' {
5
+ const site: import('./lib/site/vitePlugin').Site;
6
+ export default site;
7
+ }
@@ -0,0 +1,4 @@
1
+ import path from 'node:path';
2
+
3
+ /** The app's `src/` folder: in this repo, or in `node_modules/@tinyvolt/pie` in a site. */
4
+ export const APP_DIR = path.resolve(import.meta.dirname, '..');
@@ -0,0 +1,49 @@
1
+ import { grad, numpy as np } from '@jax-js/jax';
2
+
3
+ /**
4
+ * How to walk a graph whose items carry their own inputs.
5
+ * `inputs` gives undefined for an item that wasn't computed from others.
6
+ */
7
+ export interface Graph<T> {
8
+ id: (item: T) => string;
9
+ inputs: (item: T) => T[] | undefined;
10
+ /** Computes `item` again from new inputs. */
11
+ recompute: (item: T, inputs: T[]) => T;
12
+ }
13
+
14
+ /**
15
+ * `target` computed again, with the item whose id is `varId` swapped for
16
+ * `replacement`. Items that don't read that item, directly or through others,
17
+ * are returned as they are and not recomputed.
18
+ */
19
+ export function replay<T>(graph: Graph<T>, target: T, varId: string, replacement: T): T {
20
+ const done = new Map<string, T>();
21
+ const visit = (item: T): T => {
22
+ const id = graph.id(item);
23
+ if (id === varId) return replacement;
24
+ if (done.has(id)) return done.get(id)!;
25
+ const inputs = graph.inputs(item);
26
+ let result = item;
27
+ if (inputs) {
28
+ const newInputs = inputs.map(visit);
29
+ if (newInputs.some((input, i) => input !== inputs[i])) result = graph.recompute(item, newInputs);
30
+ }
31
+ done.set(id, result);
32
+ return result;
33
+ };
34
+ return visit(target);
35
+ }
36
+
37
+ /**
38
+ * df/dxᵢ for each xᵢ in `xs`.
39
+ *
40
+ * jax-js frees an array once it is used. This uses up `xs` and whatever `f`
41
+ * returns, so pass `.ref` copies of values you keep.
42
+ *
43
+ * Nesting works: `f` may itself call `gradient`. Any value the inner `f` reads
44
+ * from an outer one must be captured by `f`, not passed in as an extra argument —
45
+ * jax-js drops the gradient through arguments it is not differentiating.
46
+ */
47
+ export function gradient(f: (...xs: np.Array[]) => np.Array, xs: np.Array[]): np.Array[] {
48
+ return grad(f, { argnums: xs.map((_, i) => i) })(...xs);
49
+ }
@@ -0,0 +1,20 @@
1
+ import type { Component } from 'solid-js';
2
+
3
+ export type LayoutModules = Record<string, { default?: Component }>;
4
+
5
+ /**
6
+ * The default export of a `layouts/` file, given an `import.meta.glob` of that one file,
7
+ * or `builtIn` when the site has no such file. Throws, naming the file, when it has no default export.
8
+ */
9
+ export function layout(modules: LayoutModules, builtIn: Component): Component {
10
+ const [entry] = Object.entries(modules);
11
+ if (!entry) return builtIn;
12
+ const [file, mod] = entry;
13
+ if (typeof mod.default !== 'function') throw new Error(noDefaultExport(file.slice(1)));
14
+ return mod.default;
15
+ }
16
+
17
+ /** Shared with `vitePlugin.ts`, so dev and build say the same thing. */
18
+ export function noDefaultExport(file: string): string {
19
+ return `${file} must have a default export: a Solid component`;
20
+ }
@@ -0,0 +1,39 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parseSync, type Plugin } from 'vite';
4
+ import { noDefaultExport } from './layout';
5
+
6
+ // Each `layouts/` file that replaces a built-in part of the site.
7
+ const LAYOUT_FILES = ['layouts/header.tsx', 'layouts/index.tsx'];
8
+
9
+ /**
10
+ * Fails the build when a `layouts/` file has no default export.
11
+ * Without this, the error only stops pages from prerendering.
12
+ */
13
+ export function checkLayouts(): Plugin {
14
+ let root = '';
15
+ let checked = false;
16
+
17
+ return {
18
+ name: 'check-layouts',
19
+ apply: 'build',
20
+ configResolved(config) {
21
+ root = config.root;
22
+ },
23
+ // Runs once per build environment (client, ssr, ...); one check is enough.
24
+ buildStart() {
25
+ if (checked) return;
26
+ checked = true;
27
+ const bad = LAYOUT_FILES.filter(file => {
28
+ const full = path.join(root, file);
29
+ if (!fs.existsSync(full)) return false;
30
+ const { module, errors } = parseSync(full, fs.readFileSync(full, 'utf8'));
31
+ // Vite reports syntax errors itself, with a better message.
32
+ if (errors.length) return false;
33
+ return !module.staticExports.some(e =>
34
+ e.entries.some(entry => entry.exportName.kind === 'Default' || entry.exportName.name === 'default'));
35
+ });
36
+ if (bad.length) this.error(bad.map(noDefaultExport).join('\n'));
37
+ },
38
+ };
39
+ }
@@ -0,0 +1,25 @@
1
+ import { SplitPane } from '~/components/SplitPane';
2
+ import { NodeCanvas } from '~/lib/nodes/components/NodeCanvas';
3
+ import { NodeStore } from '~/lib/nodes/state/NodeStore';
4
+ import type { CanvasSide } from '../frontmatter';
5
+ import { MarkdownBody } from './MarkdownBody';
6
+
7
+ /** A canvas on `side` of the page's text. `python-editor` blocks in the text draw on it. */
8
+ export function CanvasPage(props: { side: Exclude<CanvasSide, 'none'>; html: string }) {
9
+ const store = new NodeStore();
10
+
11
+ return (
12
+ <SplitPane
13
+ class="canvas-page"
14
+ side={props.side}
15
+ first={
16
+ <div class="canvas-page-canvas">
17
+ <NodeCanvas store={store} />
18
+ </div>
19
+ }
20
+ second={<MarkdownBody class="canvas-page-text" html={props.html} store={store} />}
21
+ firstLabel="canvas"
22
+ secondLabel="text"
23
+ />
24
+ );
25
+ }
@@ -0,0 +1,42 @@
1
+ import { onCleanup, onMount } from 'solid-js';
2
+ import { render } from 'solid-js/web';
3
+ import type { NodeStore } from '~/lib/nodes/state/NodeStore';
4
+ import { PythonScope } from '~/lib/python/runner';
5
+
6
+ const PYTHON_EDITOR_LANG = 'python-editor';
7
+
8
+ /**
9
+ * Rendered markdown. On the client, each ```python-editor block becomes a runnable editor;
10
+ * until then it shows as a plain code block. All editors on the page share Python variables.
11
+ * `store`: the canvas their `pie.*` calls draw on.
12
+ */
13
+ export function MarkdownBody(props: { html: string; store?: NodeStore; class?: string }) {
14
+ let ref!: HTMLElement;
15
+
16
+ onMount(() => {
17
+ const blocks = ref.querySelectorAll<HTMLElement>(`pre > code.language-${PYTHON_EDITOR_LANG}`);
18
+ if (blocks.length === 0) return;
19
+
20
+ const scope = new PythonScope(props.store);
21
+ const disposers: (() => void)[] = [];
22
+ onCleanup(() => {
23
+ disposers.forEach((dispose) => dispose());
24
+ scope.dispose();
25
+ });
26
+
27
+ // Loaded only on pages that have an editor.
28
+ import('./PythonEditor').then(({ PythonEditor }) => {
29
+ blocks.forEach((codeEl) => {
30
+ const pre = codeEl.parentElement!;
31
+ if (!pre.isConnected) return;
32
+ const code = codeEl.textContent ?? '';
33
+ const collapsed = codeEl.hasAttribute('data-collapsed');
34
+ const host = document.createElement('div');
35
+ pre.replaceWith(host);
36
+ disposers.push(render(() => <PythonEditor code={code} collapsed={collapsed} scope={scope} />, host));
37
+ });
38
+ });
39
+ });
40
+
41
+ return <main ref={ref} class={props.class} innerHTML={props.html} />;
42
+ }