Imported from mmletgo/calculus-valley-journal (
src/engine/AGENTS.md). Install upstream withnpx skills add mmletgo/calculus-valley-journal --skill engine. Copyright stays with the author.
AGENTS.md — src/engine/ 实验台 Canvas 引擎
引擎是整个产品的核心地基:把一块
<canvas>+ 世界坐标视口封装成声明式绘图器 (plot.ts),并提供一批可直接组装的交互工具(tools/)。关卡内容 agent 只需 声明"视口 + 一组工具标签"即可搭出实验;坐标换算、devicePixelRatio 适配、 函数断点检测、像素锐利化、拖拽手感全部由引擎收口。
文件清单与依赖方向
src/engine/
├── numeric.ts # 纯数值工具(无 DOM):numericDerivative / secantSlope /
│ # niceStep / stepDecimals / formatTick
├── render.ts # 主题最低层:PALETTE 色板、PIXEL_FONT_STACK、snapTo 像素对齐、
│ # drawGrid 网格绘制
├── plot.ts # 核心:Viewport/Plotter 类型 + createPlotter 工厂 + PALETTE 重导出
└── tools/ # React 工具组件(引擎的"农具箱",经 lab/LabStage 的
# LabContext 消费 LabApi)
├── Curve.tsx # 函数曲线(fn 的声明式包装)
├── DraggablePoint.tsx# 可拖拽数据点(命中半径 17px,支持触屏)
├── Slider.tsx # 像素风滑块(自动 portal 到控制条)
├── ZoomLens.tsx # 跟随光标的圆形放大镜(默认 4x,圆内重绘网格/轴/曲线)
├── Slicer.tsx # 黎曼矩形切片(中点采样)+ 黎曼和上报
├── TangentLine.tsx # 切线(无数值导数时用数值微分),可拖动切点
├── Readout.tsx # 像素风读数条(default/good/bad 三色调,portal 到控制条)
├── TraceCursor.tsx # 光标十字准线 + (x, f(x)) 坐标读数
├── ProbePoint.tsx # 受控联动点(竖虚线+点,位置由 LabState 派生;滑块的画布镜像)
└── AreaUnder.tsx # 联动面积(f 曲线下 a→b 填充,界随 LabState 派生)
依赖单向:numeric.ts ← render.ts ← plot.ts;tools 只依赖 plot 的类型/色板与
lab 层的 hooks,不反向依赖关卡与业务。
核心概念
- 两套坐标:世界坐标(数学坐标,y 向上)与画布 CSS 像素坐标(y 向下)。
toScreen/toWorld互转;HTML overlay 定位与指针事件一律用 CSS 像素。 整站宽屏放大会对 html 施加 CSS zoom(见 theme.css):此时 clientWidth (元素坐标)与 getBoundingClientRect(视觉坐标)出现偏差,resize()实测 该比值(viewScale)——背板按视觉分辨率重建、toWorld入参先归一到元素 坐标,故 overlay 定位(toScreen)与指针命中(toWorld)对 zoom 均无感。 - 渲染队列模型:LabStage 的
redraw()= 清屏 → 背景色 → drawGrid 网格 → axes 坐标轴 → 依次执行onRender注册的回调。注册顺序即绘制顺序(先注册在 底层)——所以Slicer要写在Curve之前矩形才垫底。工具组件内部都用useLabRender注册,JSX 书写顺序即注册顺序。 - 断点检测(plot.fn):NaN/Inf 直接跳笔;单步采样跨越整屏高度时二分细分, 中点发散(如 1/x、tan 的渐近线)判定为断点跳笔;连续陡峭曲线(x³)只会被 细分、不会被撕碎。震荡函数(sin(1/x))细分到上限后按连续处理。
- 像素渲染约定:默认线宽 2px,线段坐标按线宽奇偶做整数/半像素对齐
(render.snapTo);文本字体栈
DotGothic16 → PingFang SC → …(中文可渲染); 物理像素 = CSS 尺寸 × devicePixelRatio × zoom 视觉比值,resize()内部处理, 调用方无需关心。
plot.ts API
createPlotter(canvas, vp): Plotter
工厂函数。LabStage 主画布与 ZoomLens 放大镜小画布各自持有独立实例。
Plotter.vp 每次=setViewport 后引用会更新,请随取随用,不要缓存。
| 方法 | 说明 |
|---|---|
setViewport(vp) |
切换视口(内部会修正退化视口防止除零);不负责重绘 |
resize() 【增补】 |
按 CSS 尺寸×DPR×zoom 视觉比值重设物理像素,尺寸/缩放变化返回 true |
clear(bg?) |
铺背景色(默认 PALETTE.bg 羊皮纸亮色) |
axes(o?) |
x/y 轴+刻度+数字标签;刻度步长缺省取约 72px 间距的 niceStep;某轴原点不在视口内则该轴不画 |
fn(f, o?) |
函数曲线(自适应采样 160~4096 点 + 断点检测) |
param(p, tRange, o?) |
参数曲线;非有限坐标或超大跳变断笔 |
point(x, y, o?) |
深描边圆点;label 绘制在点右上角 |
segment(x1,y1,x2,y2,o?) |
线段 |
polyline(pts, o?) |
折线(不闭合,NaN 处断笔) |
text(s, x, y, o?) |
文本;默认世界坐标,pixel: true 时为 CSS 像素(标注、读数用) |
fillUnder(f, a, b, o?) |
曲线下与 y=0 围成的填充(默认半透明金),a/b 可任意序 |
rect(x, y, w, h, o?) |
世界坐标矩形,(x,y) 为一角、w/h 可负;fill/stroke 至少给一个 |
toScreen(x,y) / toWorld(px,py) |
坐标互转;toWorld 入参为指针事件的视觉像素(clientX/Y − 画布 rect),内部自动归一 CSS zoom |
ctx 【增补】 |
原生 2D 上下文逃生舱口(自定义绘制用,常规绘制勿用) |
LineOpts = { color?, width?, dash? };颜色缺省:线=曲线主色、文本=ink、点=红。
PALETTE(Record<string,string>,取值镜像 theme.css 的 :root)
bg 羊皮纸底 | paper/paperDark 纸色 | ink/inkLight 墨色 | grid 网格 |
axis 轴 | tick 刻度 | curve 曲线主色(深林绿) | curveAlt 辅色(暮色蓝) |
grass 草地绿 | green 成功 | red 强调红 | gold/goldDark 金 |
fill 半透明金填充 | fillAlt 半透明蓝填充(镜像 --sky-mid 叠 alpha)| night 深夜蓝 | white。新色先查 theme.css 再镜像,禁止发明颜色。
工具组件(tools/)
通用约定:
- 都必须在
<LabStage>内部使用(经useLab()取 LabApi)。 - 传入的
f/df等函数 必须用useCallback稳定引用,否则每次渲染都触发重绘。 - 纯画布工具(Curve/Slicer/TraceCursor)不渲染 DOM;控制条工具(Slider/Readout) 自动 portal 到画布下方控制条;overlay 工具(DraggablePoint/TangentLine 手柄/ ZoomLens)渲染绝对定位热区。
- LabStage ready 之前不渲染 children,因此工具在渲染期访问
api.plotter是安全的。
Curve { f, color?, width?, dash? }
<Curve f={f} /> {/* 主曲线,深林绿 */}
<Curve f={df} color={PALETTE.red} dash={[6, 4]} /> {/* 导函数,红虚线 */}
DraggablePoint { id, initial, constraint?, color?, label?, vline?, hline?, onDrag?, at? }
const onCurve = useCallback(([x]) => [x, f(x)], [f]); // 锁定在曲线上
<DraggablePoint id="A" initial={[1, 1]} constraint={onCurve}
color={PALETTE.curveAlt} label="A" onDrag={(x, y, id) => setA(x)} />
<DraggablePoint id="probe" initial={[-2.5, 0]} label="竖线探针" vline /> {/* 垂线检验探针 */}
<DraggablePoint id="yProbe" initial={[0, 4]} label="横线" hline /> {/* 水平线检验探针 */}
- 拖动 → 过 constraint → 更新位置 → 自动 redraw →
onDrag(x, y, id)。 - 非受控(默认):内部状态驱动,
initial只在挂载时生效。 - 受控【增补】:传
at后位置完全由父组件驱动(必须配合 onDrag 回写状态, 否则点会弹回)。 vline【增补】:在点下层垫一条贯穿视口上下边的同色竖直虚线、随点 x 联动 (垂线检验的"竖线探针",L01 在用)。hline【增补】:与 vline 镜像——贯穿视口左右边的同色水平虚线、随点 y 联动(水平线检验的"横线探针",L02/L24 在用)。
Slider { id, label, min, max, step, initial, onChange, format? }
<Slider id="lv14-dx" label="区间宽度 Δx" min={0.05} max={3} step={0.01}
initial={2} onChange={setDx} format={(v) => v.toFixed(2)} />
写在 LabStage children 里即可,自动出现在下方控制条;数值框小数位默认按 step 推断。
ZoomLens { f?, zoom? }
<ZoomLens f={f} /> {/* 跟随光标,4x 放大,圆内重绘网格/轴/曲线+十字准线 */}
光标离开画布自动隐藏;放大镜是独立 Plotter 实例,与主画布同色板同断点检测。
Slicer { f, a, b, n, color?, onSum? }
const [sum, setSum] = useState(0);
<Slicer f={f} a={0} b={4} n={n} onSum={setSum} /> {/* 写在 Curve 之前 */}
<Curve f={f} />
n 个中点采样矩形(负高度同样成立);onSum(sum) 在 f/a/b/n 变化时上报中点黎曼和
(f 在区间内无定义时静默不上报)。
TangentLine { f, df?, at, draggable?, color?, onChange? }
<TangentLine f={f} at={x0} draggable onChange={setX0} /> {/* 无 df 时数值微分 */}
画横跨视口的切线+切点+斜率读数(斜率 2.00);拖动把 x 钳制在视口内并回调
onChange;at 受控同步。
Readout { label, value, tone? },tone 'default' | 'good' | 'bad'
<Readout label="平均斜率" value={slope.toFixed(4)} tone={matched ? 'good' : 'default'} />
自动进控制条。色调语义:default 墨棕 / good 草绿(逼近成功)/ bad 警示红(生产性 失败反馈——只给数据不给对错)。
TraceCursor { f? }
<TraceCursor f={f} /> {/* 十字虚线准线;f 存在时吸附 (x, f(x)) 并标注坐标 */}
ProbePoint { x, y?, f?, vline?, color?, label? }(受控,无交互)
<ProbePoint x={sliderX} f={fn} /> {/* 竖虚线 + f 曲线上的联动点 */}
<ProbePoint x={t} y={cos(t)} vline={false} color={PALETTE.gold} /> {/* 参数动点 */}
滑块的"画布镜像":位置完全由父组件从 LabState 派生,没有手柄、不抢交互。
y 缺省取 f(x);纵坐标非有限(0/0、定义域外)时点自动消失、只留竖线
(L09 的"死机画面"就靠它表达)。默认画贯穿视口的竖直虚线(vline 可关),
缺省色暮色蓝。纯画布工具。
AreaUnder { f, a, b, color? }(受控,无交互)
<AreaUnder f={fn} a={1} b={sliderB} /> {/* [1, b] 曲线下面积随滑块生长 */}
fillUnder 的受控声明式包装:填充 f 与 y=0 之间 a→b 的区域,界随 LabState
派生。缺省半透明金(PALETTE.fill),对照块传 PALETTE.fillAlt(半透明蓝)。
声明在 Curve 之前才垫底。a/b 退化(非有限或几乎相等)时静默跳过。纯画布工具。
数值工具(numeric.ts)
numericDerivative(f, x):中心差商,边界退化单侧,失败返回 NaN。secantSlope(f, a, b):割线斜率,a=b 或非有限返回 NaN。niceStep(rough):1/2/5×10^k 漂亮刻度步长。stepDecimals(step)/formatTick(v, step):滑块小数位与干净的刻度文本。
扩展指南(如何新增一个工具)
- 在
tools/新建 PascalCase.tsx,props 全部readonly+ 显式接口导出。 - 画东西:
useLabRender((p) => { … }, [deps])(deps 数组长度须恒定)。 - 要拖拽:
useDragWorld(onMove)返回的handlers展开到.lab-handle热区上, 热区位置用api.plotter.toScreen定位,touch-action: none已由类提供。 - 要进控制条:
useEffect(() => api.mountControl(), [api.mountControl])登记 +createPortal(…, api.controlsElement),controlsElement 为 null 时返回 null (hooks 必须全部在 early return 之前)。 - 每个函数/组件写两段式中文 JSDoc(业务逻辑/代码逻辑),更新本文件的工具清单。
已知边界
plotter.vp引用会在 setViewport 后更新,缓存旧引用会拿到过期视口。- overlay 手柄(DraggablePoint/TangentLine)的像素位置在渲染期经 toScreen
现算:画布尺寸变化时 LabStage 会 bump resizeTick 进 api 驱动工具重渲染,
自定义 overlay 工具只要经
useLab()取 api 即自动跟随新尺寸。 - Slicer/TangentLine 的"和/斜率"与画布同一口径,但关卡判分若要精确值应自行用 numeric.ts 的函数(数值微分精度 ~1e-6)。
- 动画请用
LabApi.animate(遵守 prefers-reduced-motion),不要自起 rAF。