mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-29 23:41:35 +08:00
Compare commits
1 Commits
fix/plugin
...
bb/pretext
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4db1ce08c |
217
skills/creative/pretext/SKILL.md
Normal file
217
skills/creative/pretext/SKILL.md
Normal file
@@ -0,0 +1,217 @@
|
||||
---
|
||||
name: pretext
|
||||
description: "Use when building creative browser demos with @chenglou/pretext — DOM-free text layout for ASCII art, typographic flow around obstacles, text-as-geometry games, kinetic typography, and text-powered generative art. Produces single-file HTML demos by default."
|
||||
version: 1.0.0
|
||||
author: Hermes Agent
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [creative-coding, typography, pretext, ascii-art, canvas, generative, text-layout, kinetic-typography]
|
||||
related_skills: [p5js, claude-design, excalidraw, architecture-diagram]
|
||||
---
|
||||
|
||||
# Pretext Creative Demos
|
||||
|
||||
## Overview
|
||||
|
||||
[`@chenglou/pretext`](https://github.com/chenglou/pretext) is a 15KB zero-dependency TypeScript library by Cheng Lou (React core, ReasonML, Midjourney) for **DOM-free multiline text measurement and layout**. It does one thing: given `(text, font, width)`, return the line breaks, per-line widths, per-grapheme positions, and total height — all via canvas measurement, no reflow.
|
||||
|
||||
That sounds like plumbing. It is not. Because it is fast and geometric, it is a **creative primitive**: you can reflow paragraphs around a moving sprite at 60fps, build games whose level geometry is made of real words, render proportional (not monospaced) ASCII art, animate variable-width Sloane donuts out of prose, shatter text into particles with exact per-grapheme starting positions, or pack shrink-wrapped multiline UI without any `getBoundingClientRect` thrash.
|
||||
|
||||
This skill exists so Hermes can make **cool demos** with it — the kind people post to X. See `pretext.cool` and `chenglou.me/pretext` for the community demo corpus.
|
||||
|
||||
## When to Use
|
||||
|
||||
Use when the user asks for:
|
||||
- A "pretext demo" / "cool pretext thing" / "text-as-X"
|
||||
- Text flowing around a moving shape (hero sections, editorial layouts, animated long-form pages)
|
||||
- ASCII-art effects using **real words or prose**, not monospace rasters
|
||||
- Games where the playfield / obstacles / bricks are made of text (Tetris-from-letters, Breakout-of-prose)
|
||||
- Kinetic typography with per-glyph physics (shatter, scatter, flock, flow)
|
||||
- Typographic generative art, especially with non-Latin scripts or mixed scripts
|
||||
- Multiline "shrink-wrap" UI (smallest container width that still fits the text)
|
||||
- Anything that would require knowing line breaks *before* rendering
|
||||
|
||||
Don't use for:
|
||||
- Static SVG/HTML pages where CSS already solves layout — just use CSS
|
||||
- Rich text editors, general inline formatting engines (pretext is intentionally narrow)
|
||||
- Image → text (use `ascii-art` / `ascii-video` skills)
|
||||
- Pure canvas generative art with no text role — use `p5js`
|
||||
|
||||
## Creative Standard
|
||||
|
||||
This is visual art rendered in a browser. Pretext returns numbers; **you** draw the thing.
|
||||
|
||||
- **Don't ship a "hello world" demo.** The `hello-orb-flow.html` template is the *starting* point. Every delivered demo must add intentional color, motion, composition, and one visual detail the user didn't ask for but will appreciate.
|
||||
- **Dark backgrounds, warm cores, considered palette.** Classic amber-on-black (CRT / terminal) works, but so do cold-white-on-charcoal (editorial) and desaturated pastels (risograph). Pick one and commit.
|
||||
- **Proportional fonts are the point.** Pretext's whole vibe is "not monospaced" — lean into it. Use Iowan Old Style, Inter, JetBrains Mono, Helvetica Neue, or a variable font. Never default sans.
|
||||
- **Real prose, not lorem ipsum.** The corpus should mean something. Short manifestos, poetry, the library's own README, a found text — never `lorem ipsum`.
|
||||
- **First-paint excellence.** No loading states, no blank frames. The demo must look shippable the instant it opens.
|
||||
|
||||
## Stack
|
||||
|
||||
Single self-contained HTML file per demo. No build step.
|
||||
|
||||
| Layer | Tool | Purpose |
|
||||
|-------|------|---------|
|
||||
| Core | `@chenglou/pretext` via `esm.sh` CDN | Text measurement + line layout |
|
||||
| Render | HTML5 Canvas 2D | Glyph rendering, per-frame composition |
|
||||
| Segmentation | `Intl.Segmenter` (built-in) | Grapheme splitting for emoji / CJK / combining marks |
|
||||
| Interaction | Raw DOM events | Mouse / touch / wheel — no framework |
|
||||
|
||||
```html
|
||||
<script type="module">
|
||||
import {
|
||||
prepare, layout, // use-case 1: simple height
|
||||
prepareWithSegments, layoutWithLines, // use-case 2a: fixed-width lines
|
||||
layoutNextLineRange, materializeLineRange, // use-case 2b: streaming / variable width
|
||||
measureLineStats, walkLineRanges, // stats without string allocation
|
||||
} from "https://esm.sh/@chenglou/pretext@0.0.6";
|
||||
</script>
|
||||
```
|
||||
|
||||
Pin the version. `@0.0.6` at time of writing — check [npm](https://www.npmjs.com/package/@chenglou/pretext) for the latest if demo behavior is off.
|
||||
|
||||
## The Two Use Cases
|
||||
|
||||
Almost everything reduces to one of these two shapes. Learn both.
|
||||
|
||||
### Use-case 1 — measure, then render with CSS/DOM
|
||||
|
||||
```js
|
||||
const prepared = prepare(text, "16px Inter");
|
||||
const { height, lineCount } = layout(prepared, 320, 20);
|
||||
```
|
||||
|
||||
You still let the browser draw the text. Pretext just tells you how tall the box will be at a given width, **without** a DOM read. Use for:
|
||||
- Virtualized lists where rows contain wrapping text
|
||||
- Masonry with precise card heights
|
||||
- "Does this label fit?" dev-time checks
|
||||
- Preventing layout shift when remote text loads
|
||||
|
||||
**Keep `font` and `letterSpacing` exactly in sync with your CSS.** The canvas `ctx.font` format (e.g. `"16px Inter"`, `"500 17px 'JetBrains Mono'"`) must match the rendered CSS, or measurements drift.
|
||||
|
||||
### Use-case 2 — measure *and* render yourself
|
||||
|
||||
```js
|
||||
const prepared = prepareWithSegments(text, FONT);
|
||||
const { lines } = layoutWithLines(prepared, 320, 26);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
ctx.fillText(lines[i].text, 0, i * 26);
|
||||
}
|
||||
```
|
||||
|
||||
This is where the creative work lives. You own the drawing, so you can:
|
||||
- Render to canvas, SVG, WebGL, or any coordinate system
|
||||
- Substitute per-glyph transforms (rotation, jitter, scale, opacity)
|
||||
- Use line metadata (width, grapheme positions) as geometry
|
||||
|
||||
For **variable-width-per-line** flow (text around a shape, text in a donut band, text in a non-rectangular column):
|
||||
|
||||
```js
|
||||
let cursor = { segmentIndex: 0, graphemeIndex: 0 };
|
||||
let y = 0;
|
||||
while (true) {
|
||||
const lineWidth = widthAtY(y); // your function: how wide is the corridor at this y?
|
||||
const range = layoutNextLineRange(prepared, cursor, lineWidth);
|
||||
if (!range) break;
|
||||
const line = materializeLineRange(prepared, range);
|
||||
ctx.fillText(line.text, leftEdgeAtY(y), y);
|
||||
cursor = range.end;
|
||||
y += lineHeight;
|
||||
}
|
||||
```
|
||||
|
||||
This is the most important pattern in the whole library. It's what unlocks "text flowing around a dragged sprite" — the demo that went viral on X.
|
||||
|
||||
### Helpers worth knowing
|
||||
|
||||
- `measureLineStats(prepared, maxWidth)` → `{ lineCount, maxLineWidth }` — the widest line, i.e. multiline shrink-wrap width.
|
||||
- `walkLineRanges(prepared, maxWidth, callback)` — iterate lines without allocating strings. Use for stats/physics over graphemes when you don't need the characters.
|
||||
- `@chenglou/pretext/rich-inline` — the same system but for paragraphs mixing fonts / chips / mentions. Import from the subpath.
|
||||
|
||||
## Demo Recipe Patterns
|
||||
|
||||
The community corpus (see `references/patterns.md`) clusters into a handful of strong patterns. Pick one and riff — don't invent a new category unless asked.
|
||||
|
||||
| Pattern | Key API | Example idea |
|
||||
|---|---|---|
|
||||
| **Reflow around obstacle** | `layoutNextLineRange` + per-row width function | Editorial paragraph that parts around a dragged cursor sprite |
|
||||
| **Text-as-geometry game** | `layoutWithLines` + per-line collision rects | Breakout where each brick is a measured word |
|
||||
| **Shatter / particles** | `walkLineRanges` → per-grapheme (x,y) → physics | Sentence that explodes into letters on click |
|
||||
| **Proportional ASCII** | `layoutWithLines` sampled across (theta,phi) of a 3D surface | Torus / sphere / wave made of prose glyphs (see `donut-orbit.html`) |
|
||||
| **Editorial multi-column** | `layoutNextLineRange` per column + shared cursor | Animated magazine spread with pull quotes |
|
||||
| **Kinetic type** | `layoutWithLines` + per-line transform over time | Star Wars crawl, wave, bounce, glitch |
|
||||
| **Multiline shrink-wrap** | `measureLineStats` | Quote card that auto-sizes to its tightest container |
|
||||
|
||||
See `templates/donut-orbit.html` and `templates/hello-orb-flow.html` for working single-file starters.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Pick a pattern** from the table above based on the user's brief.
|
||||
2. **Start from a template**:
|
||||
- `templates/hello-orb-flow.html` — text reflowing around a moving orb (reflow-around-obstacle pattern)
|
||||
- `templates/donut-orbit.html` — full 3D ASCII torus with orbit controls (proportional-ASCII + interaction)
|
||||
- `write_file` to a new `.html` in `/tmp/` or the user's workspace.
|
||||
3. **Swap the corpus** for something intentional to the brief. Real prose, 10-100 sentences, no lorem.
|
||||
4. **Tune the aesthetic** — font, palette, composition, interaction. This is the work; don't skip it.
|
||||
5. **Verify locally**:
|
||||
```sh
|
||||
cd <dir-with-html> && python3 -m http.server 8765
|
||||
# then open http://localhost:8765/<file>.html
|
||||
```
|
||||
6. **Check the console** — pretext will throw if `prepareWithSegments` is called with a bad font string; `Intl.Segmenter` is available in every modern browser.
|
||||
7. **Show the user the file path**, not just the code — they want to open it.
|
||||
|
||||
## Performance Notes
|
||||
|
||||
- `prepare()` / `prepareWithSegments()` is the expensive call. Do it **once** per text+font pair. Cache the handle.
|
||||
- On resize, only rerun `layout()` / `layoutWithLines()` — never re-prepare.
|
||||
- For per-frame animations where text doesn't change but geometry does, `layoutNextLineRange` in a tight loop is cheap enough to do every frame at 60fps for normal-length paragraphs.
|
||||
- When rendering thousands of glyphs per frame (e.g. the donut demo), use a **z-buffer keyed by screen cell** instead of sorting — see `templates/donut-orbit.html` for the pattern.
|
||||
- Canvas `ctx.font` setting is surprisingly slow; set it **once** per frame if font doesn't vary, not per `fillText` call.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Drifting CSS/canvas font strings.** `ctx.font = "16px Inter"` measured, but CSS says `font-family: Inter, sans-serif; font-size: 16px`. Fine *if* Inter loads. If Inter 404s, CSS falls back to sans-serif and measurements drift by 5-20%. Always `preload` the font or use a web-safe family.
|
||||
|
||||
2. **Re-preparing inside the animation loop.** Only `layout*` is cheap. Re-calling `prepare` every frame will tank perf. Keep the prepared handle in module scope.
|
||||
|
||||
3. **Forgetting `Intl.Segmenter` for grapheme splits.** Emoji, combining marks, CJK — `"é".split("")` gives you two chars. Use `new Intl.Segmenter(undefined, { granularity: "grapheme" })` when sampling individual visible glyphs.
|
||||
|
||||
4. **`break: 'never'` chips without `extraWidth`.** In `rich-inline`, if you use `break: 'never'` for an atomic chip/mention, you must also supply `extraWidth` for the pill padding — otherwise chip chrome overflows the container.
|
||||
|
||||
5. **Using `@chenglou/pretext` from `unpkg` with TypeScript-only entry.** Use `esm.sh` — it compiles the TS exports to browser-ready ESM automatically. `unpkg` will 404 or serve raw TS.
|
||||
|
||||
6. **Monospace fallbacks silently erasing the whole point.** Users seeing monospace-looking output often have a CSS `font-family` that fell through to `monospace`. Verify the actual rendered font via DevTools.
|
||||
|
||||
7. **Skipping rows vs adjusting width** when flowing around a shape. If the corridor on this row is too narrow to fit a line, *skip the row* (`y += lineHeight; continue;`) rather than passing a tiny maxWidth to `layoutNextLineRange` — pretext will return one-grapheme lines that look broken.
|
||||
|
||||
8. **Shipping a cold demo.** The default first-paint looks tutorial-grade. Add: vignette, subtle scanline, idle auto-motion, one carefully chosen interactive response (drag, hover, scroll, click). Without these, "cool pretext demo" lands as "intern repro of the README."
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] Demo is a single self-contained `.html` file — opens by double-click or `python3 -m http.server`
|
||||
- [ ] `@chenglou/pretext` imported via `esm.sh` with pinned version
|
||||
- [ ] Corpus is real prose, not lorem ipsum, and matches the demo's concept
|
||||
- [ ] Font string passed to `prepare` matches the CSS font exactly
|
||||
- [ ] `prepare()` / `prepareWithSegments()` called once, not per frame
|
||||
- [ ] Dark background + considered palette — not the default white canvas
|
||||
- [ ] At least one interactive response (drag / hover / scroll / click) or idle auto-motion
|
||||
- [ ] Tested locally with `python3 -m http.server` and confirmed no console errors
|
||||
- [ ] 60fps on a mid-tier laptop (or graceful degradation documented)
|
||||
- [ ] One "extra mile" detail the user didn't ask for
|
||||
|
||||
## Reference: Community Demos
|
||||
|
||||
Clone these for inspiration / patterns (all MIT-ish, linked from [pretext.cool](https://www.pretext.cool/)):
|
||||
|
||||
- **Pretext Breaker** — breakout with word-bricks — `github.com/rinesh/pretext-breaker`
|
||||
- **Tetris × Pretext** — `github.com/shinichimochizuki/tetris-pretext`
|
||||
- **Dragon animation** — `github.com/qtakmalay/PreTextExperiments`
|
||||
- **Somnai editorial engine** — `github.com/somnai-dreams/pretext-demos`
|
||||
- **Bad Apple!! ASCII** — `github.com/frmlinn/bad-apple-pretext`
|
||||
- **Drag-sprite reflow** — `github.com/dokobot/pretext-demo`
|
||||
- **Alarmy editorial clock** — `github.com/SmisLee/alarmy-pretext-demo`
|
||||
|
||||
Official playground: [chenglou.me/pretext](https://chenglou.me/pretext/) — accordion, bubbles, dynamic-layout, editorial-engine, justification-comparison, masonry, markdown-chat, rich-note.
|
||||
214
skills/creative/pretext/references/patterns.md
Normal file
214
skills/creative/pretext/references/patterns.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# Pretext Patterns
|
||||
|
||||
Copy-pasteable snippets for the most common pretext demo shapes. Each pattern is self-contained — drop into an HTML `<script type="module">` after importing from `https://esm.sh/@chenglou/pretext@0.0.6`.
|
||||
|
||||
## 1. Flow around an obstacle (variable-width column)
|
||||
|
||||
The signature pretext move. Row-by-row ask "how wide is the corridor here?" and let pretext break lines accordingly.
|
||||
|
||||
```js
|
||||
const prepared = prepareWithSegments(TEXT, FONT);
|
||||
const LINE_H = 24;
|
||||
|
||||
function drawFlow(ctx, obstacle /* {x,y,r} */, COL_X, COL_W, H) {
|
||||
let cursor = { segmentIndex: 0, graphemeIndex: 0 };
|
||||
let y = 72;
|
||||
while (y < H - 40) {
|
||||
const dy = y - obstacle.y;
|
||||
const inBand = Math.abs(dy) < obstacle.r;
|
||||
let x = COL_X, w = COL_W;
|
||||
if (inBand) {
|
||||
const half = Math.sqrt(obstacle.r ** 2 - dy ** 2);
|
||||
const leftW = Math.max(0, (obstacle.x - half) - COL_X);
|
||||
const rightW = Math.max(0, (COL_X + COL_W) - (obstacle.x + half));
|
||||
if (leftW >= rightW) { x = COL_X; w = leftW - 12; }
|
||||
else { x = obstacle.x + half + 12; w = rightW - 12; }
|
||||
if (w < 40) { y += LINE_H; continue; } // skip rather than squeeze
|
||||
}
|
||||
const range = layoutNextLineRange(prepared, cursor, w);
|
||||
if (!range) break;
|
||||
const line = materializeLineRange(prepared, range);
|
||||
ctx.fillText(line.text, x, y);
|
||||
cursor = range.end;
|
||||
y += LINE_H;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Obstacle variants:** circles (above), rectangles (use `Math.max(0, …)` on the row-segment), multiple obstacles (sort segments and emit the wider remaining lane), animated obstacles (recompute every frame — pretext is fast enough).
|
||||
|
||||
## 2. Text-as-geometry game (word-bricks with collision)
|
||||
|
||||
Use `layoutWithLines` to get stable line rects, then treat each word as an axis-aligned box for physics.
|
||||
|
||||
```js
|
||||
const prepared = prepareWithSegments(WORDS.join(" "), FONT);
|
||||
const { lines } = layoutWithLines(prepared, FIELD_W, 28);
|
||||
|
||||
// Build brick rects: split each line on spaces and measure word-by-word.
|
||||
const bricks = [];
|
||||
let y = 50;
|
||||
for (const line of lines) {
|
||||
let x = 10;
|
||||
for (const word of line.text.split(" ")) {
|
||||
const wPx = ctx.measureText(word).width; // or use walkLineRanges per word
|
||||
bricks.push({ x, y, w: wPx, h: 24, text: word, hp: 1 });
|
||||
x += wPx + ctx.measureText(" ").width;
|
||||
}
|
||||
y += 28;
|
||||
}
|
||||
```
|
||||
|
||||
Collision: standard AABB vs the ball. When `hp` drops to 0, the brick is "eaten." For the aesthetic: fade brick opacity with hp, trail particles from the letters on impact.
|
||||
|
||||
## 3. Shatter / explode typography
|
||||
|
||||
Use `walkLineRanges` + a manual grapheme walk to get `(x, y)` for every glyph, then spawn particles.
|
||||
|
||||
```js
|
||||
const prepared = prepareWithSegments(TEXT, FONT);
|
||||
const particles = [];
|
||||
let y = 100;
|
||||
walkLineRanges(prepared, COL_W, (line) => {
|
||||
// materialize so we get per-grapheme positions
|
||||
const range = materializeLineRange(prepared, line);
|
||||
const seg = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||
let x = COL_X;
|
||||
for (const { segment } of seg.segment(range.text)) {
|
||||
const w = ctx.measureText(segment).width;
|
||||
particles.push({ ch: segment, x, y, vx: 0, vy: 0, homeX: x, homeY: y });
|
||||
x += w;
|
||||
}
|
||||
y += LINE_H;
|
||||
});
|
||||
|
||||
// On click, kick particles outward from click point; ease them back to (homeX, homeY).
|
||||
canvas.addEventListener("click", (e) => {
|
||||
for (const p of particles) {
|
||||
const dx = p.x - e.clientX, dy = p.y - e.clientY;
|
||||
const d = Math.hypot(dx, dy) || 1;
|
||||
const force = 400 / (d * 0.2 + 1);
|
||||
p.vx += (dx / d) * force;
|
||||
p.vy += (dy / d) * force;
|
||||
}
|
||||
});
|
||||
|
||||
function tick(dt) {
|
||||
for (const p of particles) {
|
||||
p.vx *= 0.92; p.vy *= 0.92;
|
||||
p.vx += (p.homeX - p.x) * 0.06;
|
||||
p.vy += (p.homeY - p.y) * 0.06;
|
||||
p.x += p.vx * dt; p.y += p.vy * dt;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Proportional ASCII surface (donut / sphere / wave)
|
||||
|
||||
The "cool demos" money pattern. Sample a parametric 3D surface, use classic luminance → glyph picking, but replace the monospace grid with a **z-buffer keyed by screen cell** and pull glyphs from a real corpus in reading order.
|
||||
|
||||
See `templates/donut-orbit.html` in this skill for the full implementation. Key structure:
|
||||
|
||||
```js
|
||||
const CELL = 9; // px bucket
|
||||
const cols = Math.ceil(W / CELL), rows = Math.ceil(H / CELL);
|
||||
const zbuf = new Float32Array(cols * rows);
|
||||
const chbuf = new Array(cols * rows);
|
||||
|
||||
// Sample the surface
|
||||
for (let j = 0; j < PHI_STEPS; j++) {
|
||||
for (let i = 0; i < THETA_STEPS; i++) {
|
||||
const { sx, sy, ooz, L } = projectSurfacePoint(i, j);
|
||||
if (L <= 0) continue;
|
||||
const ci = (sx / CELL) | 0, ri = (sy / CELL) | 0;
|
||||
const idx = ri * cols + ci;
|
||||
if (ooz > zbuf[idx]) {
|
||||
zbuf[idx] = ooz;
|
||||
chbuf[idx] = GLYPHS[glyphIdx++ % GLYPHS.length];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw once
|
||||
for (let i = 0; i < chbuf.length; i++) if (chbuf[i]) ctx.fillText(chbuf[i], ...);
|
||||
```
|
||||
|
||||
The `GLYPHS` array comes from pretext:
|
||||
|
||||
```js
|
||||
const prepared = prepareWithSegments(CORPUS, FONT);
|
||||
const { lines } = layoutWithLines(prepared, 260, 16);
|
||||
const GLYPHS = [];
|
||||
for (const line of lines) {
|
||||
const seg = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||
for (const { segment } of seg.segment(line.text)) GLYPHS.push(segment);
|
||||
}
|
||||
```
|
||||
|
||||
Why not just `[...CORPUS]`? Because pretext gives you **reading-order graphemes after line-break decisions** — which makes the surface glyphs follow the corpus's natural rhythm, including non-Latin scripts and soft-hyphen-resolved breaks.
|
||||
|
||||
## 5. Editorial multi-column with shared cursor
|
||||
|
||||
Classic magazine layout: three columns, text flows from the end of column 1 into the top of column 2, etc. Pretext makes this trivial because the cursor is portable between `layoutNextLineRange` calls.
|
||||
|
||||
```js
|
||||
const prepared = prepareWithSegments(ARTICLE, FONT);
|
||||
let cursor = { segmentIndex: 0, graphemeIndex: 0 };
|
||||
|
||||
for (const col of [COL1, COL2, COL3]) {
|
||||
let y = col.y;
|
||||
while (y < col.y + col.h) {
|
||||
const range = layoutNextLineRange(prepared, cursor, col.w);
|
||||
if (!range) return;
|
||||
const line = materializeLineRange(prepared, range);
|
||||
ctx.fillText(line.text, col.x, y);
|
||||
cursor = range.end;
|
||||
y += LINE_H;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add pull quotes by treating them as obstacles in the middle column and using pattern #1 around them.
|
||||
|
||||
## 6. Multiline shrink-wrap (tightest-fitting card)
|
||||
|
||||
Given a max width, find the **smallest** container width that still produces the same line count. Useful for chat bubbles, quote cards, tooltip sizing.
|
||||
|
||||
```js
|
||||
const prepared = prepareWithSegments(text, FONT);
|
||||
const { lineCount, maxLineWidth } = measureLineStats(prepared, MAX_W);
|
||||
// card width = maxLineWidth + padding; card height = lineCount * LINE_H + padding
|
||||
```
|
||||
|
||||
For a demo that *visualizes* this, render the card shrinking from `MAX_W` down to `maxLineWidth` over a second — the line count stays constant but the right edge pulls in.
|
||||
|
||||
## 7. Kinetic typography
|
||||
|
||||
Animate per-line transforms over time. `layoutWithLines` gives you stable lines; index `i` drives the timing offset.
|
||||
|
||||
```js
|
||||
const { lines } = layoutWithLines(prepared, W - 80, 40);
|
||||
function frame(t) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const phase = t * 0.001 - i * 0.15;
|
||||
const y = 100 + i * 40 + Math.sin(phase) * 12;
|
||||
const opacity = 0.4 + 0.6 * Math.max(0, Math.sin(phase));
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.fillText(lines[i].text, 40, y);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Variants: Star Wars crawl (perspective skew per line), wave (sine y-offset), bounce (ease-in-out arrival), glitch (per-glyph random offset using `Intl.Segmenter`).
|
||||
|
||||
## 8. Font stack patterns
|
||||
|
||||
| Vibe | Font string | Palette hint |
|
||||
|------|-------------|--------------|
|
||||
| Editorial / serious | `17px/1.4 "Iowan Old Style", Georgia, serif` | bone `#e8e6df` on charcoal `#0c0d10` |
|
||||
| CRT / terminal | `600 13px "JetBrains Mono", ui-monospace, monospace` | amber `hsl(38 60% 62%)` on `#07070a` |
|
||||
| Humanist / modern | `500 17px Inter, ui-sans-serif, system-ui, sans-serif` | off-white `#f3efe6` on deep-navy `#0b1020` |
|
||||
| Display / poster | `700 64px "Playfair Display", serif` | hot-red `#ff4130` on cream `#f0ebe0` |
|
||||
| Engineering | `14px "IBM Plex Mono", monospace` | neon-green `#7cff7c` on near-black `#0a0a0c` |
|
||||
|
||||
Always load the web font explicitly (Google Fonts link tag or `@font-face`) so the canvas measurement matches the CSS render.
|
||||
322
skills/creative/pretext/templates/donut-orbit.html
Normal file
322
skills/creative/pretext/templates/donut-orbit.html
Normal file
@@ -0,0 +1,322 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no" />
|
||||
<title>DONUT.pretext — orbit</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
html, body {
|
||||
margin: 0; padding: 0; height: 100%; overflow: hidden;
|
||||
background: #07070a;
|
||||
color: #e6e2d6;
|
||||
font-family: "JetBrains Mono", "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
canvas { display: block; width: 100vw; height: 100vh; }
|
||||
.hud {
|
||||
position: fixed; top: 14px; left: 18px; z-index: 10;
|
||||
font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase;
|
||||
color: #c8b98a; opacity: 0.72; mix-blend-mode: screen;
|
||||
pointer-events: none;
|
||||
}
|
||||
.hud .k { color: #e6e2d6; }
|
||||
.hud .dot {
|
||||
display: inline-block; width: 6px; height: 6px; border-radius: 50%;
|
||||
background: #c8b98a; margin: 0 8px; vertical-align: middle;
|
||||
box-shadow: 0 0 8px #c8b98a;
|
||||
}
|
||||
.hud2 {
|
||||
position: fixed; bottom: 14px; right: 18px; z-index: 10;
|
||||
font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase;
|
||||
color: #6b6456; opacity: 0.8;
|
||||
pointer-events: none;
|
||||
}
|
||||
.corpus {
|
||||
position: fixed; bottom: 18px; left: 18px; z-index: 10;
|
||||
max-width: 320px; font-size: 10px; line-height: 1.45;
|
||||
color: #8c8166; opacity: 0.55; mix-blend-mode: screen;
|
||||
pointer-events: none;
|
||||
font-family: "Iowan Old Style", "Georgia", serif;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="c"></canvas>
|
||||
<div class="hud">
|
||||
<span class="k">DONUT.pretext</span> <span class="dot"></span>
|
||||
drag · orbit <span class="dot"></span>
|
||||
scroll · zoom <span class="dot"></span>
|
||||
<span id="fps">—</span> fps
|
||||
</div>
|
||||
<div class="hud2">measured · not monospaced · <a style="color:#6b6456" href="https://github.com/chenglou/pretext">@chenglou/pretext</a></div>
|
||||
<div class="corpus" id="corpus"></div>
|
||||
|
||||
<script type="module">
|
||||
// -------- pretext via ESM CDN (keeps this a single self-contained file) ----
|
||||
import {
|
||||
prepareWithSegments,
|
||||
layoutWithLines,
|
||||
walkLineRanges,
|
||||
measureLineStats,
|
||||
} from "https://esm.sh/@chenglou/pretext@0.0.6";
|
||||
|
||||
// -------- CORPUS ------------------------------------------------------------
|
||||
// A long, readable string. The donut's "luminance pixels" are real graphemes
|
||||
// from this text, sampled in reading order. Change at will.
|
||||
const CORPUS = `
|
||||
I have crawled through everything there is about how browsers measure text.
|
||||
For thirty years we asked the DOM. The DOM answered, at the cost of a reflow.
|
||||
Now we measure ourselves. Each glyph has a width. Each line has a break.
|
||||
Each break is a choice. And choices, stacked, become shapes.
|
||||
A donut, for instance, is just a choice about where light should land.
|
||||
We replace the light with letters. We replace the letters with meaning.
|
||||
We spin the meaning. We watch it wrap. We notice, for the first time,
|
||||
that typography was always a kind of orbit — a slow rotation of glyphs
|
||||
around the empty center of the thing you were trying to say.
|
||||
Pretext does not render. It measures. It returns numbers.
|
||||
But numbers, given width and height and a little trigonometry,
|
||||
conspire to draw a torus out of prose. AGI 春天到了. بدأت الرحلة 🚀.
|
||||
Line by line, the sentence becomes a surface. The surface becomes a solid.
|
||||
The solid is a donut and the donut is a log and the log says: we are here.
|
||||
`.trim().replace(/\s+/g, " ");
|
||||
|
||||
document.getElementById("corpus").textContent = CORPUS;
|
||||
|
||||
// -------- CANVAS ------------------------------------------------------------
|
||||
const canvas = document.getElementById("c");
|
||||
const ctx = canvas.getContext("2d", { alpha: false });
|
||||
let W = 0, H = 0, DPR = 1;
|
||||
function resize() {
|
||||
DPR = Math.min(window.devicePixelRatio || 1, 2);
|
||||
W = window.innerWidth; H = window.innerHeight;
|
||||
canvas.width = W * DPR; canvas.height = H * DPR;
|
||||
canvas.style.width = W + "px"; canvas.style.height = H + "px";
|
||||
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
|
||||
}
|
||||
addEventListener("resize", resize);
|
||||
resize();
|
||||
|
||||
// -------- PRETEXT LAYOUT ---------------------------------------------------
|
||||
// We lay the corpus out once at a reference column width, then sample
|
||||
// graphemes from the resulting line-stream in reading order. Pretext gives us
|
||||
// per-line width + per-grapheme width for free via layoutWithLines.
|
||||
const FONT = '600 13px "JetBrains Mono", ui-monospace, monospace';
|
||||
const prepared = prepareWithSegments(CORPUS, FONT);
|
||||
|
||||
// Grab a flat list of graphemes by walking a narrow layout. We only need them
|
||||
// for their characters; positions are computed live from the donut math.
|
||||
const { lines } = layoutWithLines(prepared, 260, 16);
|
||||
const GLYPHS = [];
|
||||
for (const line of lines) {
|
||||
// line.text comes as the reading-order materialised line. We split into
|
||||
// graphemes via Intl.Segmenter so emoji / combining marks stay intact.
|
||||
const seg = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||
for (const { segment } of seg.segment(line.text)) {
|
||||
if (segment !== " ") GLYPHS.push(segment);
|
||||
}
|
||||
GLYPHS.push(" "); // preserve word gaps so the donut has visible "spaces"
|
||||
}
|
||||
|
||||
// Also compute a maxLineWidth so we know the corpus' natural shrink-wrap,
|
||||
// just to prove we touched the stats API. (We display it in the HUD briefly.)
|
||||
const { maxLineWidth } = measureLineStats(prepared, 260);
|
||||
console.log("pretext maxLineWidth @260px:", maxLineWidth);
|
||||
|
||||
// -------- SLOANE DONUT MATH -------------------------------------------------
|
||||
// Classic torus of major radius R1 around minor radius R2, lit by a fixed
|
||||
// light direction. We sample in (theta, phi) and project with perspective.
|
||||
// Luminance L ∈ [-√2, √2]; we remap to [0,1] and use it as:
|
||||
// - opacity of the character
|
||||
// - choice of glyph from the ASCII ramp (optional — we prefer corpus glyphs)
|
||||
const R1 = 1.0; // donut tube radius
|
||||
const R2 = 2.0; // donut ring radius
|
||||
const K2 = 5; // camera distance
|
||||
// theta/phi step density — looser = more readable glyphs, denser = more donut.
|
||||
const THETA_STEPS = 90;
|
||||
const PHI_STEPS = 280;
|
||||
|
||||
// Orbit state
|
||||
const orbit = {
|
||||
yaw: 0.6, // rotation around Y (mouse X drag)
|
||||
pitch: -0.4, // rotation around X (mouse Y drag)
|
||||
zoom: 1.0, // radius scale
|
||||
autoYaw: 0.35, // rad/sec when idle
|
||||
autoPitch: 0.17,
|
||||
dragging: false,
|
||||
idleSince: performance.now(),
|
||||
};
|
||||
|
||||
// Mouse / touch
|
||||
function onDown(x, y) { orbit.dragging = true; orbit.lastX = x; orbit.lastY = y; }
|
||||
function onMove(x, y) {
|
||||
if (!orbit.dragging) return;
|
||||
const dx = x - orbit.lastX, dy = y - orbit.lastY;
|
||||
orbit.yaw += dx * 0.008;
|
||||
orbit.pitch += dy * 0.008;
|
||||
orbit.pitch = Math.max(-Math.PI / 2 + 0.05, Math.min(Math.PI / 2 - 0.05, orbit.pitch));
|
||||
orbit.lastX = x; orbit.lastY = y;
|
||||
orbit.idleSince = performance.now();
|
||||
}
|
||||
function onUp() { orbit.dragging = false; orbit.idleSince = performance.now(); }
|
||||
canvas.addEventListener("mousedown", e => onDown(e.clientX, e.clientY));
|
||||
addEventListener("mousemove", e => onMove(e.clientX, e.clientY));
|
||||
addEventListener("mouseup", onUp);
|
||||
canvas.addEventListener("touchstart", e => { const t = e.touches[0]; onDown(t.clientX, t.clientY); }, { passive: true });
|
||||
canvas.addEventListener("touchmove", e => { const t = e.touches[0]; onMove(t.clientX, t.clientY); }, { passive: true });
|
||||
canvas.addEventListener("touchend", onUp);
|
||||
canvas.addEventListener("wheel", e => {
|
||||
e.preventDefault();
|
||||
orbit.zoom *= Math.exp(-e.deltaY * 0.0012);
|
||||
orbit.zoom = Math.max(0.5, Math.min(2.6, orbit.zoom));
|
||||
orbit.idleSince = performance.now();
|
||||
}, { passive: false });
|
||||
|
||||
// -------- RENDER ------------------------------------------------------------
|
||||
let frame = 0;
|
||||
let fpsEma = 60, lastT = performance.now();
|
||||
const fpsEl = document.getElementById("fps");
|
||||
|
||||
function frameLoop(t) {
|
||||
const dt = Math.min(0.05, (t - lastT) / 1000);
|
||||
lastT = t;
|
||||
fpsEma = fpsEma * 0.92 + (1 / Math.max(dt, 1e-3)) * 0.08;
|
||||
if ((frame++ & 31) === 0) fpsEl.textContent = fpsEma.toFixed(0);
|
||||
|
||||
// idle auto-orbit (like OrbitControls autoRotate)
|
||||
const idleFor = (t - orbit.idleSince) / 1000;
|
||||
if (!orbit.dragging && idleFor > 1.2) {
|
||||
orbit.yaw += orbit.autoYaw * dt;
|
||||
orbit.pitch = -0.35 + Math.sin(t * 0.00015) * 0.25;
|
||||
}
|
||||
|
||||
// --- clear with a subtle vignette ---
|
||||
const grad = ctx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, Math.max(W, H) * 0.7);
|
||||
grad.addColorStop(0, "#0b0b11");
|
||||
grad.addColorStop(1, "#050507");
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// --- donut ---
|
||||
const cx = W / 2, cy = H / 2;
|
||||
const K1 = Math.min(W, H) * 0.32 * orbit.zoom; // on-screen scale
|
||||
|
||||
const cy_ = Math.cos(orbit.yaw), sy_ = Math.sin(orbit.yaw);
|
||||
const cp_ = Math.cos(orbit.pitch), sp_ = Math.sin(orbit.pitch);
|
||||
const spin = t * 0.0004; // slow intrinsic spin so static pose still lives
|
||||
|
||||
// Sort-by-depth is expensive for thousands of glyphs; we instead use a
|
||||
// z-buffer (1/z closer to camera wins) keyed by screen cell. Cell size
|
||||
// depends on font size.
|
||||
const CELL = 9; // px bucket
|
||||
const cols = Math.ceil(W / CELL), rows = Math.ceil(H / CELL);
|
||||
const zbuf = new Float32Array(cols * rows);
|
||||
const chbuf = new Array(cols * rows);
|
||||
const lumbuf = new Float32Array(cols * rows);
|
||||
|
||||
let glyphIdx = 0;
|
||||
for (let j = 0; j < PHI_STEPS; j++) {
|
||||
const phi = (j / PHI_STEPS) * Math.PI * 2;
|
||||
const cphi = Math.cos(phi), sphi = Math.sin(phi);
|
||||
for (let i = 0; i < THETA_STEPS; i++) {
|
||||
const theta = (i / THETA_STEPS) * Math.PI * 2 + spin;
|
||||
const ct = Math.cos(theta), st = Math.sin(theta);
|
||||
|
||||
// Torus point (pre-rotation)
|
||||
const circleX = R2 + R1 * ct;
|
||||
const circleY = R1 * st;
|
||||
|
||||
// apply spin-around-Y for a little life, then pitch, then yaw
|
||||
const x0 = circleX * cphi;
|
||||
const y0 = circleY;
|
||||
const z0 = -circleX * sphi;
|
||||
|
||||
// pitch (rotate around X)
|
||||
const x1 = x0;
|
||||
const y1 = y0 * cp_ - z0 * sp_;
|
||||
const z1 = y0 * sp_ + z0 * cp_;
|
||||
|
||||
// yaw (rotate around Y)
|
||||
const x2 = x1 * cy_ + z1 * sy_;
|
||||
const y2 = y1;
|
||||
const z2 = -x1 * sy_ + z1 * cy_;
|
||||
|
||||
const z = z2 + K2;
|
||||
if (z <= 0.1) continue;
|
||||
const ooz = 1 / z;
|
||||
|
||||
const sx = cx + K1 * x2 * ooz;
|
||||
const sy = cy - K1 * y2 * ooz;
|
||||
|
||||
// --- luminance: surface normal • light dir ---
|
||||
// Normal on torus surface (in pre-rotated frame) = (cphi*ct, st, -sphi*ct)
|
||||
const nx0 = cphi * ct;
|
||||
const ny0 = st;
|
||||
const nz0 = -sphi * ct;
|
||||
// Apply same pitch/yaw to the normal
|
||||
const nx1 = nx0;
|
||||
const ny1 = ny0 * cp_ - nz0 * sp_;
|
||||
const nz1 = ny0 * sp_ + nz0 * cp_;
|
||||
const nx2 = nx1 * cy_ + nz1 * sy_;
|
||||
const ny2 = ny1;
|
||||
const nz2 = -nx1 * sy_ + nz1 * cy_;
|
||||
|
||||
// Light: upper-right, slightly forward
|
||||
const lx = 0.577, ly = -0.577, lz = -0.577;
|
||||
const L = nx2 * lx + ny2 * ly + nz2 * lz; // ∈ [-1,1]-ish
|
||||
|
||||
if (L <= 0.02) { glyphIdx = (glyphIdx + 1) % GLYPHS.length; continue; }
|
||||
|
||||
// screen bucket (z-buffer)
|
||||
const ci = Math.floor(sx / CELL);
|
||||
const ri = Math.floor(sy / CELL);
|
||||
if (ci < 0 || ci >= cols || ri < 0 || ri >= rows) { glyphIdx = (glyphIdx + 1) % GLYPHS.length; continue; }
|
||||
const idx = ri * cols + ci;
|
||||
if (ooz > zbuf[idx]) {
|
||||
zbuf[idx] = ooz;
|
||||
const g = GLYPHS[glyphIdx];
|
||||
chbuf[idx] = g === " " ? "·" : g; // visible placeholder for spaces
|
||||
lumbuf[idx] = L;
|
||||
}
|
||||
glyphIdx = (glyphIdx + 1) % GLYPHS.length;
|
||||
}
|
||||
}
|
||||
|
||||
// --- draw glyphs ---
|
||||
ctx.font = FONT;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const i = r * cols + c;
|
||||
const ch = chbuf[i];
|
||||
if (!ch) continue;
|
||||
const L = lumbuf[i];
|
||||
const shade = Math.max(0, Math.min(1, L * 1.15));
|
||||
// warm core → cool rim: depth (1/z) biases hue
|
||||
const depth = zbuf[i];
|
||||
const depthN = Math.min(1, Math.max(0, (depth - 0.18) / 0.16));
|
||||
// color: vintage amber CRT, darker in shadow
|
||||
const hue = 38 + (1 - depthN) * -18; // 38 amber → 20 warmer-red at far
|
||||
const sat = 20 + shade * 40;
|
||||
const lit = 10 + shade * 62;
|
||||
ctx.fillStyle = `hsl(${hue} ${sat}% ${lit}%)`;
|
||||
ctx.globalAlpha = 0.25 + shade * 0.75;
|
||||
ctx.fillText(ch, c * CELL + CELL / 2, r * CELL + CELL / 2);
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// subtle scanline
|
||||
ctx.globalCompositeOperation = "overlay";
|
||||
ctx.fillStyle = "rgba(0,0,0,0.04)";
|
||||
for (let y = 0; y < H; y += 3) ctx.fillRect(0, y, W, 1);
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
|
||||
requestAnimationFrame(frameLoop);
|
||||
}
|
||||
requestAnimationFrame(frameLoop);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
95
skills/creative/pretext/templates/hello-orb-flow.html
Normal file
95
skills/creative/pretext/templates/hello-orb-flow.html
Normal file
@@ -0,0 +1,95 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>pretext hello — text flowing around an orb</title>
|
||||
<style>
|
||||
html,body { margin:0; padding:0; height:100%; background:#0c0d10; color:#e8e6df; overflow:hidden; }
|
||||
body { font-family: "Iowan Old Style", Georgia, serif; }
|
||||
canvas { display:block; width:100vw; height:100vh; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="c"></canvas>
|
||||
<script type="module">
|
||||
// Minimal pretext starter: long paragraph flows around a moving orb.
|
||||
// Uses layoutNextLineRange + variable-width streaming — the "killer app"
|
||||
// pattern that only pretext can do cheaply in the browser.
|
||||
import {
|
||||
prepareWithSegments,
|
||||
layoutNextLineRange,
|
||||
materializeLineRange,
|
||||
} from "https://esm.sh/@chenglou/pretext@0.0.6";
|
||||
|
||||
const TEXT = `Pretext measures text without touching the DOM. It returns numbers — widths, line breaks, cursors — and those numbers, arranged with a little imagination, become layouts the browser could never draw on its own. Here, a paragraph flows around a moving orb. Each line is asked for its own width, live. No reflows. No cheats. Just measurement. `.repeat(18);
|
||||
|
||||
const FONT = '17px/1.4 "Iowan Old Style", Georgia, serif';
|
||||
const LINE_H = 24;
|
||||
|
||||
const c = document.getElementById("c");
|
||||
const ctx = c.getContext("2d");
|
||||
let W, H, DPR;
|
||||
function resize() {
|
||||
DPR = Math.min(devicePixelRatio || 1, 2);
|
||||
W = innerWidth; H = innerHeight;
|
||||
c.width = W*DPR; c.height = H*DPR;
|
||||
c.style.width = W+"px"; c.style.height = H+"px";
|
||||
ctx.setTransform(DPR,0,0,DPR,0,0);
|
||||
}
|
||||
addEventListener("resize", resize); resize();
|
||||
|
||||
const prepared = prepareWithSegments(TEXT, FONT);
|
||||
|
||||
// Orb follows mouse (or bobs idly)
|
||||
const orb = { x: innerWidth*0.45, y: innerHeight*0.5, r: 140 };
|
||||
addEventListener("mousemove", e => { orb.x = e.clientX; orb.y = e.clientY; });
|
||||
|
||||
function frame(t) {
|
||||
ctx.fillStyle = "#0c0d10"; ctx.fillRect(0,0,W,H);
|
||||
|
||||
// glowing orb
|
||||
const g = ctx.createRadialGradient(orb.x, orb.y, 0, orb.x, orb.y, orb.r);
|
||||
g.addColorStop(0, "rgba(255,200,120,0.35)");
|
||||
g.addColorStop(0.6, "rgba(255,140,80,0.10)");
|
||||
g.addColorStop(1, "rgba(0,0,0,0)");
|
||||
ctx.fillStyle = g; ctx.fillRect(0,0,W,H);
|
||||
|
||||
// flow text as a column, routing around the orb row-by-row
|
||||
const COL_X = 60, COL_W = W - 120;
|
||||
let cursor = { segmentIndex: 0, graphemeIndex: 0 };
|
||||
let y = 72;
|
||||
ctx.fillStyle = "#e8e6df";
|
||||
ctx.font = FONT;
|
||||
ctx.textBaseline = "alphabetic";
|
||||
|
||||
while (y < H - 40) {
|
||||
// does this row intersect the orb band?
|
||||
const dy = y - orb.y;
|
||||
const bandY = Math.abs(dy) < orb.r;
|
||||
// lane = (left, width) skipping over the orb horizontally
|
||||
let x = COL_X, lineMaxW = COL_W;
|
||||
if (bandY) {
|
||||
const half = Math.sqrt(orb.r*orb.r - dy*dy);
|
||||
const orbLeft = orb.x - half, orbRight = orb.x + half;
|
||||
// choose the wider side, simple heuristic
|
||||
const leftWidth = Math.max(0, orbLeft - COL_X);
|
||||
const rightWidth = Math.max(0, COL_X + COL_W - orbRight);
|
||||
if (leftWidth >= rightWidth) { x = COL_X; lineMaxW = leftWidth - 12; }
|
||||
else { x = orbRight + 12; lineMaxW = rightWidth - 12; }
|
||||
if (lineMaxW < 40) { y += LINE_H; continue; }
|
||||
}
|
||||
|
||||
const range = layoutNextLineRange(prepared, cursor, lineMaxW);
|
||||
if (!range) break;
|
||||
const line = materializeLineRange(prepared, range);
|
||||
ctx.fillText(line.text, x, y);
|
||||
cursor = range.end;
|
||||
y += LINE_H;
|
||||
}
|
||||
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user