Skip to content

Commit 41059b1

Browse files
feat: embed Apollon diagrams in GitHub, GitLab, and iframes (#677)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 2115fe3 commit 41059b1

42 files changed

Lines changed: 2787 additions & 558 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@tumaet/server": minor
3+
"@tumaet/webapp": minor
4+
---
5+
6+
Embed your diagrams anywhere. Drop one into a README, an issue, a pull request, or any page as a Markdown image — it renders inline as a polished framed card with the diagram, its title, and an "Open in Apollon" button, matches light and dark themes, and updates as you keep editing — or paste a ready-made iframe snippet. The share dialog gains an "Embed" panel with copy-paste code, and embedding needs no sign-in or tokens: the diagram link is the key, exactly like opening it in the editor. Sharing now leads with live collaboration by default, and the dialog uploads a single shared copy whose access (collaborate, edit, or feedback) you can switch without ever creating duplicates. Embedded diagrams stay alive as long as they're viewed, so they won't quietly disappear from a README, and a popular one stays fast no matter how many people open it.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tumaet/server": patch
3+
---
4+
5+
Server-rendered diagram images and PDFs now include edges that connect to a node's in-between anchor points. Previously such an edge was silently dropped from the export, so a diagram could come out with its boxes but a missing connection.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tumaet/server": patch
3+
---
4+
5+
Server-rendered diagram images and PDFs now place edge labels on their edge (at the path midpoint) instead of at a fallback point off to the side. Edge-relative decorators (e.g. the SFC transition condition) are positioned the same way.

docs/library/api/conversion-api.md

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -80,30 +80,43 @@ curl -X POST http://localhost:8000/api/converter/pdf \
8080

8181
## Status codes
8282

83-
| Status | When |
84-
| ------ | ------------------------------------------------------------------------------ |
85-
| `200` | success — the body is the rendered file |
86-
| `400` | no model in the request body |
87-
| `413` | request body exceeds the size limit (`BODY_TOO_LARGE`) |
88-
| `422` | a node is missing valid geometry, e.g. width/height (`INVALID_PARAMS`) |
89-
| `500` | the worker could not render the model, e.g. an unsupported format (`INTERNAL`) |
90-
| `503` | the conversion queue is full — retry with backoff |
83+
| Status | When |
84+
| ------ | ------------------------------------------------------------------------------- |
85+
| `200` | success — the body is the rendered file |
86+
| `400` | no model in the request body |
87+
| `413` | request body exceeds the size limit (`BODY_TOO_LARGE`) |
88+
| `422` | a node is missing valid geometry, e.g. width/height (`INVALID_PARAMS`) |
89+
| `500` | the worker could not render the model, e.g. an unsupported format (`INTERNAL`) |
90+
| `503` | the render pipeline is busy / queue full (`RENDERER_BUSY`) — retry with backoff |
9191

9292
Most errors return the server's standard JSON body —
93-
`{ "error": "<CODE>", "message": "…", "requestId": "…" }`. The two exceptions are
94-
`400` and `503`, which the converter sends directly as a short
95-
`{ "error": "<message>" }`.
93+
`{ "error": "<CODE>", "message": "…", "requestId": "…" }`. A `503` additionally
94+
carries a `Retry-After` header (and `retryAfterSeconds` in the body) plus
95+
`Cache-Control: no-store`. The one exception to the standard body is `400` (no
96+
model), which the converter sends directly as a short `{ "error": "<message>" }`.
9697

9798
## Limits & tuning
9899

99-
Requests are serialised through a single render worker with a bounded queue, so
100-
one slow diagram can't exhaust the process. Defaults (override via environment):
101-
102-
| Variable | Default | Meaning |
103-
| ---------------------------- | ----------------- | ---------------------------- |
104-
| `MAX_SNAPSHOT_BYTES` | `5242880` (5 MiB) | max request body size |
105-
| `CONVERTER_MAX_QUEUE_LENGTH` | `20` | queued requests before `503` |
106-
| `CONVERTER_TIMEOUT_MS` | `30000` | per-conversion timeout |
100+
Renders run on a **self-calibrating worker pool** that sizes itself to the
101+
machine — no tuning required. The pool grows from one warm worker up to a
102+
ceiling derived from `min(CPU, memory)` (`availableParallelism − 1`, bounded by
103+
~60% of system memory ÷ per-worker footprint), reaps idle workers back to one,
104+
sheds excess load with `503` + `Retry-After`, and recycles workers to bound
105+
memory. Every value below has a derived default; override only to constrain a
106+
shared host.
107+
108+
| Variable | Default | Meaning |
109+
| ------------------------------------ | ----------------------- | ------------------------------------------------ |
110+
| `MAX_SNAPSHOT_BYTES` | `5242880` (5 MiB) | max request body size |
111+
| `CONVERTER_POOL_MAX` | _calibrated_ | max render workers (`min(cores−1, memBudget)`) |
112+
| `CONVERTER_POOL_MIN` | `1` | always-warm workers |
113+
| `CONVERTER_MAX_QUEUE_LENGTH` | `8 × poolMax` | queued requests before `503` |
114+
| `CONVERTER_MAX_QUEUE_WAIT_MS` | `10000` | drop a job that has waited longer (it's a ghost) |
115+
| `CONVERTER_TIMEOUT_{SVG,PNG,PDF}_MS` | `10000`/`15000`/`30000` | per-format render deadline |
116+
117+
The embed surface additionally serves identical revisions from an in-process
118+
render cache with single-flight, so a fan-out of viewers (e.g. GitHub Camo)
119+
collapses to one render per save.
107120

108121
## See also
109122

library/lib/edges/labelTypes/EdgeMultipleLabels.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,16 @@ export const EdgeMultipleLabels = ({
6565
)}
6666

6767
<text
68-
x={pathMiddlePosition.x + group.textOrigin.x + index * group.stackStep.x}
69-
y={pathMiddlePosition.y + group.textOrigin.y + index * group.stackStep.y}
68+
x={
69+
pathMiddlePosition.x +
70+
group.textOrigin.x +
71+
index * group.stackStep.x
72+
}
73+
y={
74+
pathMiddlePosition.y +
75+
group.textOrigin.y +
76+
index * group.stackStep.y
77+
}
7078
textAnchor={group.textAnchor}
7179
dominantBaseline="middle"
7280
style={{

library/tests/unit/messageLayout.test.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,16 @@ import { computeMessageLayout } from "@/edges/labelTypes/messageLayout"
33
import type { MessageData } from "@/edges/EdgeProps"
44
import type { IPoint } from "@/edges/Connection"
55

6-
const fwd = (id: string): MessageData => ({ id, text: "f", direction: "target" })
7-
const bwd = (id: string): MessageData => ({ id, text: "b", direction: "source" })
6+
const fwd = (id: string): MessageData => ({
7+
id,
8+
text: "f",
9+
direction: "target",
10+
})
11+
const bwd = (id: string): MessageData => ({
12+
id,
13+
text: "b",
14+
direction: "source",
15+
})
816

917
const VERTICAL = { src: { x: 100, y: 0 }, tgt: { x: 100, y: 300 } } // source above
1018
const HORIZONTAL = { src: { x: 0, y: 50 }, tgt: { x: 300, y: 50 } } // source left
@@ -51,8 +59,18 @@ describe("computeMessageLayout", () => {
5159
})
5260

5361
it("fixes the label side by orientation, not by which node is the source", () => {
54-
const a = computeMessageLayout([fwd("1")], VERTICAL.src, VERTICAL.tgt, false)
55-
const b = computeMessageLayout([fwd("1")], VERTICAL.tgt, VERTICAL.src, false)
62+
const a = computeMessageLayout(
63+
[fwd("1")],
64+
VERTICAL.src,
65+
VERTICAL.tgt,
66+
false
67+
)
68+
const b = computeMessageLayout(
69+
[fwd("1")],
70+
VERTICAL.tgt,
71+
VERTICAL.src,
72+
false
73+
)
5674
expect(Math.sign(a.forward.textOrigin.x)).toBe(
5775
Math.sign(b.forward.textOrigin.x)
5876
)

ops/dsms/record-of-processing.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ No off-host backups of personal data exist. No personal data leaves the EU.
106106
:::tip Paste-ready — DSMS "Custom Erasure Time"
107107

108108
```
109-
- Diagram content (Redis): 120 days from the last write, enforced by a native database TTL.
109+
- Diagram content (Redis): 120 days from the last access (opening, embedding, or editing the diagram), enforced by a native database TTL that is extended on each access.
110110
- Live-collaboration data (display name, cursor, selection): held only while the user is connected; dropped from server memory on disconnect.
111111
- IP address and user-agent: visible to the server only for the duration of an HTTP request; not persistently stored.
112112
- Operational events about the service (no personal data by design): size-bounded ring buffer, ~250 MB per container.
@@ -119,7 +119,7 @@ No off-host backups of personal data exist. No personal data leaves the EU.
119119
:::tip Paste-ready — DSMS "Reasoning for the erasure time"
120120

121121
```
122-
Diagram data is retained for 120 days from the last edit to support editing across teaching iterations. After 120 days of inactivity, deletion is performed automatically by the Redis engine. Operational events about the service are kept free of personal data by data minimisation at source (Art. 5(1)(c) + Art. 25 GDPR); a time-based retention period under Art. 5(1)(e) GDPR is therefore not required.
122+
Diagram data is retained for 120 days from the last access (opening, embedding, or editing the diagram) to support editing across teaching iterations and stable embeds in course materials. Each access resets the window; after 120 days of inactivity, deletion is performed automatically by the Redis engine. Operational events about the service are kept free of personal data by data minimisation at source (Art. 5(1)(c) + Art. 25 GDPR); a time-based retention period under Art. 5(1)(e) GDPR is therefore not required.
123123
```
124124

125125
:::

standalone/server/src/__tests__/conversion.int.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import path from "node:path"
66
import { buildApp } from "../http/app.js"
77
import { loadConfig } from "../config.js"
88
import { getRedis } from "./setup.js"
9+
import {
10+
ConversionResource,
11+
QueueFullError,
12+
} from "../resources/conversion-resource.js"
913

1014
// Drive the COMPILED worker — the real production artifact. Running it in its
1115
// own thread keeps the jsdom globals it installs out of this shared test
@@ -141,4 +145,25 @@ describe("POST /api/converter", () => {
141145
.send({ model: "{ not valid json" })
142146
expect(res.status).toBe(400)
143147
})
148+
149+
it("maps a full queue to a typed 503 RENDERER_BUSY with Retry-After", async () => {
150+
const busy = {
151+
render: () =>
152+
Promise.reject(new QueueFullError("Conversion queue is full")),
153+
} as unknown as ConversionResource
154+
const redis = await getRedis()
155+
const busyApp = buildApp({
156+
config: loadConfig(),
157+
redis,
158+
autoLogging: false,
159+
conversionResource: busy,
160+
})
161+
const res = await request(busyApp)
162+
.post("/api/converter/svg")
163+
.send({ model: loadModel() })
164+
expect(res.status).toBe(503)
165+
expect(res.body.error).toBe("RENDERER_BUSY")
166+
expect(res.headers["retry-after"]).toBe("2")
167+
expect(res.headers["cache-control"]).toBe("no-store")
168+
})
144169
})

0 commit comments

Comments
 (0)