Skip to content

Commit 1afd691

Browse files
committed
Initial commit: import sunken keep sketches
1 parent eb36e2a commit 1afd691

8 files changed

Lines changed: 314 additions & 35 deletions

File tree

.gitignore

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,10 @@ __pycache__/
3131

3232
# local-only artifacts — exist on disk but not part of the deployed site
3333
# archive/ holds pre-2023 Hexo drafts (kept for reference, not republished)
34-
# data/ holds large training datasets (e.g. MNIST for the 识 sketch)
34+
# /data/ holds large training datasets (e.g. MNIST for the 识 sketch);
35+
# leading slash anchors to repo root so public/data/ — which carries
36+
# sketch-runtime JSON like strokes.json — stays trackable and shipped
3537
# maintenance.html is the reusable "site under maintenance" cover page
3638
archive/
37-
data/
39+
/data/
3840
maintenance.html

.vscode/settings.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"python-envs.defaultEnvManager": "ms-python.python:conda",
3+
"python-envs.defaultPackageManager": "ms-python.python:conda"
4+
}

README.md

Lines changed: 20 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,30 @@
1-
# Astro Starter Kit: Minimal
1+
# 眠海 / SUNKEN KEEP
22

3-
```sh
4-
npm create astro@latest -- --template minimal
5-
```
3+
A small gallery of generative and interactive sketches.
64

7-
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
5+
🌊 **Live**: [sunkenkeep.space](https://sunkenkeep.space)
86

9-
## 🚀 Project Structure
7+
## About
108

11-
Inside of your Astro project, you'll see the following folders and files:
9+
Pieces explore generative systems, interactive dynamics, and
10+
the relationship between mathematics and aesthetic experience.
11+
Some works incorporate generative audio (e.g. *Clinamen*),
12+
others focus on visual procedural systems (e.g. *Cell*).
1213

13-
```text
14-
/
15-
├── public/
16-
├── src/
17-
│ └── pages/
18-
│ └── index.astro
19-
└── package.json
20-
```
14+
## Tech
2115

22-
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
16+
- [Astro](https://astro.build) — static site framework
17+
- Vanilla JavaScript / Canvas / Web Audio API for sketches
18+
- Custom domain via [sunkenkeep.space](https://sunkenkeep.space)
2319

24-
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
20+
## Sketches
2521

26-
Any static assets, like images, can be placed in the `public/` directory.
22+
- **Cell** (2026-05) — Voronoi tessellation driven by coupled pendulum dynamics
23+
- **Clinamen** (2026-04) — Generative bell synthesis triggered by floating river lanterns
24+
- **Smoke** (2026-04) — Particle-based smoke field
25+
- **Flow Field** (2026-04) — Drifting point lattice
2726

28-
## 🧞 Commands
27+
## Author
2928

30-
All commands are run from the root of the project, from a terminal:
31-
32-
| Command | Action |
33-
| :------------------------ | :----------------------------------------------- |
34-
| `npm install` | Installs dependencies |
35-
| `npm run dev` | Starts local dev server at `localhost:4321` |
36-
| `npm run build` | Build your production site to `./dist/` |
37-
| `npm run preview` | Preview your build locally, before deploying |
38-
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
39-
| `npm run astro -- --help` | Get help using the Astro CLI |
40-
41-
## 👀 Want to learn more?
42-
43-
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
29+
Ruiting Ma — currently exploring audio AI and generative models
30+
as part of a Master of AI at Monash University.

public/data/strokes.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

scripts/build-strokes.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Build the stroke-data JSON consumed by StrokeSketch.
4+
5+
For each requested character, the output carries:
6+
- glyph: Huiwen Mincho's full SVG outline path, scaled to a 1024 em
7+
box so it shares the coord system MMH medians live in. y-up,
8+
baseline at 0, ascender ~900, descender ~-124.
9+
- medians: list of stroke medians from make-me-a-hanzi, one inner
10+
array per stroke (`[[x, y], ...]`). These are the centerlines —
11+
StrokeSketch uses them to clip the Huiwen glyph into per-stroke
12+
bitmaps at runtime.
13+
14+
We keep only what the sketch consumes — no stroke outline polygons
15+
(the Huiwen path replaces them visually) and no per-stroke rendering
16+
metadata (bbox, etc., are computed in JS).
17+
18+
Usage:
19+
python scripts/build-strokes.py # PROTOTYPE_CHARS
20+
python scripts/build-strokes.py 字的颗粒度 # explicit set
21+
"""
22+
import json
23+
import sys
24+
from pathlib import Path
25+
from typing import Optional
26+
27+
from fontTools.ttLib import TTFont
28+
from fontTools.pens.svgPathPen import SVGPathPen
29+
from fontTools.pens.transformPen import TransformPen
30+
from fontTools.misc.transform import Transform
31+
32+
HERE = Path(__file__).resolve().parent
33+
ROOT = HERE.parent
34+
MMH_SRC = ROOT / "data" / "makemeahanzi" / "graphics.txt"
35+
FONT_SRC = ROOT / "src" / "assets" / "fonts" / "huiwen-mincho.ttf"
36+
OUT = ROOT / "public" / "data" / "strokes.json"
37+
38+
# Title + a representative flood-cue phrase. Picked for stroke-count
39+
# variety so the sketch reads as a mix of light/dense glyphs.
40+
PROTOTYPE_CHARS = "字的颗粒度这一段汉横跨整片海"
41+
42+
# MMH medians live in a 1024-wide em box. We scale Huiwen to match so
43+
# the two data sources align without further per-char tweaks.
44+
TARGET_UPEM = 1024
45+
46+
47+
def extract_glyph_path(font: TTFont, char: str) -> Optional[str]:
48+
"""Huiwen glyph as an SVG path string in the target 1024 em space.
49+
The font's own units-per-em can be anything (Huiwen Mincho is 1000);
50+
the TransformPen rescales as the path streams through. Returns None
51+
if the char isn't covered by the font's cmap."""
52+
cmap = font.getBestCmap()
53+
cp = ord(char)
54+
if cp not in cmap:
55+
return None
56+
glyph_set = font.getGlyphSet()
57+
glyph = glyph_set[cmap[cp]]
58+
59+
upem = font["head"].unitsPerEm
60+
s = TARGET_UPEM / upem
61+
62+
pen = SVGPathPen(glyph_set)
63+
glyph.draw(TransformPen(pen, Transform().scale(s, s)))
64+
return pen.getCommands()
65+
66+
67+
def main():
68+
chars = sys.argv[1] if len(sys.argv) > 1 else PROTOTYPE_CHARS
69+
wanted = set(chars)
70+
print(f"extracting {len(wanted)} char(s): {''.join(sorted(wanted))}")
71+
72+
if not MMH_SRC.exists():
73+
sys.exit(
74+
f"missing {MMH_SRC}\n"
75+
" run: curl -sL -o data/makemeahanzi/graphics.txt "
76+
"https://raw.githubusercontent.com/skishore/makemeahanzi/master/graphics.txt"
77+
)
78+
if not FONT_SRC.exists():
79+
sys.exit(f"missing {FONT_SRC}")
80+
81+
font = TTFont(str(FONT_SRC))
82+
print(f" font unitsPerEm = {font['head'].unitsPerEm} → normalize to {TARGET_UPEM}")
83+
84+
# MMH file is line-delimited JSON. We only need medians.
85+
mmh = {}
86+
with MMH_SRC.open(encoding="utf-8") as f:
87+
for line in f:
88+
if not line.strip():
89+
continue
90+
entry = json.loads(line)
91+
ch = entry.get("character")
92+
if ch in wanted:
93+
mmh[ch] = entry["medians"]
94+
if len(mmh) == len(wanted):
95+
break
96+
97+
out = {}
98+
missing = []
99+
for ch in wanted:
100+
path = extract_glyph_path(font, ch)
101+
medians = mmh.get(ch)
102+
if path is None:
103+
missing.append(f"{ch} (no Huiwen glyph)")
104+
continue
105+
if medians is None:
106+
missing.append(f"{ch} (no MMH medians)")
107+
continue
108+
out[ch] = {"glyph": path, "medians": medians}
109+
110+
if missing:
111+
print(f" WARNING: dropped {missing}")
112+
113+
OUT.parent.mkdir(parents=True, exist_ok=True)
114+
OUT.write_text(
115+
json.dumps(out, ensure_ascii=False, separators=(",", ":")),
116+
encoding="utf-8",
117+
)
118+
size_kb = OUT.stat().st_size / 1024
119+
total_strokes = sum(len(v["medians"]) for v in out.values())
120+
print(
121+
f"wrote {OUT.relative_to(ROOT)}: "
122+
f"{len(out)} char(s), {total_strokes} stroke(s), {size_kb:.1f} KB"
123+
)
124+
125+
126+
if __name__ == "__main__":
127+
main()

src/components/StrokeSketch.astro

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
---
2+
/*
3+
* Stroke sketch — 笔 / one character per cue, camera moves between them.
4+
*
5+
* Scaffold only. The previous prototype (per-stroke particles in 2D
6+
* fake-3D, dust/flood formations) read flat and was scrapped. The
7+
* replacement model:
8+
*
9+
* - each cue picks ONE character + a camera station {R, yaw, pitch, dx, dy}
10+
* - strokes are decomposed using MMH medians over the Huiwen Mincho
11+
* outline (see scripts/build-strokes.py); each stroke becomes its
12+
* own offscreen bitmap, pre-rendered once at sketch init
13+
* - 3D camera projects each stroke; size ∝ 1/R² so distance reads
14+
* dramatically; depth-of-field via ctx.filter blur, full opacity
15+
* - section transitions = camera lerps along a non-linear path
16+
* between stations, fixed ~1.2s duration
17+
*
18+
* Implementation deferred — this file currently just hosts the canvas
19+
* and the cue/pause listeners so the essay still loads while the
20+
* stage is being rewritten.
21+
*/
22+
---
23+
24+
<canvas id="stroke"></canvas>
25+
26+
<script>
27+
import { mountSketch } from '../lib/sketch-lifecycle';
28+
29+
mountSketch(() => {
30+
const canvas = document.getElementById('stroke') as HTMLCanvasElement | null;
31+
if (!canvas) return;
32+
const ctx = canvas.getContext('2d');
33+
if (!ctx) return;
34+
35+
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
36+
let w = 0, h = 0;
37+
38+
function resize() {
39+
const rect = canvas!.getBoundingClientRect();
40+
w = rect.width;
41+
h = rect.height;
42+
canvas!.width = w * dpr;
43+
canvas!.height = h * dpr;
44+
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
45+
ctx!.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim() || '#0f0f10';
46+
ctx!.fillRect(0, 0, w, h);
47+
}
48+
resize();
49+
50+
const onCue = (_e: Event) => { /* TODO: station lerp */ };
51+
const onPause = (_e: Event) => { /* TODO */ };
52+
window.addEventListener('stage:cue', onCue);
53+
window.addEventListener('stage:pause', onPause);
54+
window.addEventListener('resize', resize);
55+
const ro = new ResizeObserver(() => resize());
56+
ro.observe(canvas);
57+
58+
return () => {
59+
window.removeEventListener('stage:cue', onCue);
60+
window.removeEventListener('stage:pause', onPause);
61+
window.removeEventListener('resize', resize);
62+
ro.disconnect();
63+
};
64+
});
65+
</script>
66+
67+
<style>
68+
canvas#stroke {
69+
display: block;
70+
width: 100%;
71+
height: 100%;
72+
background: var(--bg);
73+
}
74+
</style>
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
---
2+
title: 字的颗粒度
3+
date: "2026-05-06"
4+
kind: ESSAY
5+
summary: Web 字体优化的标准做法几乎都是 Latin 答案的搬运,到中文这里每一步都要重答。
6+
tint: dune
7+
stage: stroke
8+
---
9+
10+
<section data-cue="dust">
11+
12+
中文 web 排版的"标准做法"几乎都是 Latin 答案的搬运。woff2、unicode-range 切片、`font-display: swap`、preload。这套答案在拉丁文里成立,是因为它有几个隐含前提。把这些前提逐条列出来,会发现中文一个都不满足。
13+
14+
这篇笔记是把这些前提拆开重做的过程。每一步都不是"中文版的 Latin 答案",是另一套答案。
15+
16+
</section>
17+
18+
<section data-cue="latin">
19+
20+
英文页面里,一段段落用到的字符几乎全在 Basic Latin(~95 字符)加几小块带音符的扩展区里。CSS 的 `unicode-range` 把字体按这些 Unicode 块切片,浏览器只下载本页 cmap 命中的片。一个典型页面只拉 ~10–30 KB。清爽、自动、无需后端。这是为什么这个答案会成为标准。
21+
22+
它成立靠一个隐含前提:字符集足够小、字符在 Unicode 里足够聚集。
23+
24+
</section>
25+
26+
<section data-cue="flood">
27+
28+
你正在读的这一段,不到 80 个汉字,跨了 CJK Unified、CJK Ext-A、有时再加 Compatibility 几块。把全套 Huiwen Mincho 切成 unicode-range 片,单页仍要拉接近全部 CJK 块——~15 MB,还多了几次 HTTP 请求。L3 切片对中文等于零优化。
29+
30+
问题不出在切法。出在尺度——把单元定在 unicode 范围这一层就太粗了。
31+
32+
</section>
33+
34+
<section data-cue="subset">
35+
36+
不问"这个字体多大",问"这一页用了哪些字"。
37+
38+
Build 时扫每张 HTML 的可见文本,对每个字体做一次 subset,只保留 cmap 命中的字形。单页字体降到 ~50 KB。300 倍的缩减——不是"压缩"做出来的,是"颗粒度"换出来的。
39+
40+
写成代码:fontTools 的 `Subsetter`,丢掉 `hinting``GPOS``GSUB``MATH``kern` 这些不为中文 web 服务的表(每片再省 10–30%),输出 woff2。一个 Python 脚本接进 Astro 的 `astro:build:done` 钩子,build 多花 2.4 秒。
41+
42+
</section>
43+
44+
<section data-cue="common">
45+
46+
单页解决了,跨页又冒出来:换页要不要重下一遍?
47+
48+
把"≥2 个页面用过"的字符并入 common pool,独占字进 per-page extras。Common 一进缓存就跨页复用,extras 只补差。整站全部部署 ~105 KB——比一个 woff2 单文件还小。
49+
50+
读者点下一篇,浏览器从 cache 取 common,从网络补几 KB 的 extras。几乎察觉不到字体在加载。
51+
52+
</section>
53+
54+
<section data-cue="swap">
55+
56+
字节问题解决了,视觉问题还没。`font-display` 三选项:
57+
58+
- `swap`:到了立即换 → 跳一下(FOUT)
59+
- `fallback`:短 block,超时 swap → 还是跳
60+
- `optional`~100ms 没到就这次别换了,下次再说 → 不跳
61+
62+
`optional` 是审美决定,不是性能决定。
63+
64+
再加一层:fallback 的系统字体声明 `ascent-override` / `descent-override` / `line-gap-override`,让它和真字体占同一个 line-box。即使下一次换了,也不跳。CLS 压到接近零。
65+
66+
</section>
67+
68+
<section data-cue="pool">
69+
70+
Sketch 用 `ctx.fillText` 画字,浏览器只在本页 subset 里找字形——没有就静默 fallback 到系统字体。运行时随机拼字、过程式造文本的 sketch 会偷偷换装,肉眼看不太出但字形错了。
71+
72+
解决法:任意元素挂 `data-font-pool="眠海潮汐..."`,build 扫 HTML 时把这串字符并进当页 subset。声明式、构建期、零运行时开销。是把"sketch 私有的字符需求"翻译成 build 能看见的语言。
73+
74+
</section>
75+
76+
<section data-cue="recap">
77+
78+
实测:3 页站点,总字体部署 ~105 KB(common 28 + extras 77),单页首屏 40–100 KB,build 多 2.4 秒。对比 Latin 答案的"单文件 woff2"约 30 MB,差 300 倍。
79+
80+
工具(fontTools、CSS `@font-face``font-display`)是通用的。尺度是中文给的。
81+
82+
</section>

src/pages/essays/[...slug].astro

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getCollection, render } from 'astro:content';
33
import EssayLayout from '../../layouts/EssayLayout.astro';
44
import BeginSketch from '../../components/BeginSketch.astro';
55
import ThreadSketch from '../../components/ThreadSketch.astro';
6+
import StrokeSketch from '../../components/StrokeSketch.astro';
67
78
export async function getStaticPaths() {
89
const essays = await getCollection('essays', (e) => import.meta.env.DEV || e.data.published);
@@ -19,6 +20,7 @@ export async function getStaticPaths() {
1920
const STAGES = {
2021
begin: BeginSketch,
2122
thread: ThreadSketch,
23+
stroke: StrokeSketch,
2224
} as const;
2325
type StageKey = keyof typeof STAGES;
2426

0 commit comments

Comments
 (0)