feat: 支持教学图鉴

This commit is contained in:
richarjiang
2026-10-18 17:26:16 +08:00
parent 3635b2a906
commit 3dc56a113b
29 changed files with 3056 additions and 5 deletions

151
docs/reformer-education.md Normal file
View File

@@ -0,0 +1,151 @@
# 「探索」Tab 与 Reformer 器械图鉴
用户界面采用正式内容呈现,不显示审阅状态或未开放功能占位。审核与来源记录仅作内部内容管理,移除标识不会自动补写或改变教练审核结论。器械示意及无实测阻力参数的说明继续保留。
## 本地查看
```sh
pnpm build:education
open packages/server/public/learn/reformer/index.html
```
`index.html` 是独立页面:内嵌 Vanilla JS、CSS 和 SVG,不请求 CDN、字体、图片、API 或 WebGL,可直接通过 `file://` 打开,也可部署到任意 HTTPS 静态站点。当前约 202 KiB,gzip 约 47 KiB,以构建日志为准。源码用 TypeScript 编写,由 esbuild 输出浏览器 JavaScript;没有 Vue/React 运行时。
开发时也可启动静态服务:
```sh
python3 -m http.server 4178 --directory packages/server/public
# 浏览器打开 http://localhost:4178/learn/reformer/
```
浏览器本地预览不需要微信业务域名配置。
## 小程序入口
底部导航新增「探索」:
- `pages/learn/index`:原生内容目录首页,采用统一封面卡片与简短导语。内容目录位于 `packages/app/src/content/explore.ts`;后续专题按条目追加,首页不绑定某一期内容。
- `pages/learn/reformer`:微信 `web-view` 容器,加载 `https://focus.richarjiang.com/learn/reformer/`。
- `pages/learn/reformer-static`:不依赖网页与网络的完整中文部件说明;网页错误回调也会提供此入口。容器先通过 HEAD 检查页面状态,未部署造成的 404 或网络失败会直接进入原生回退,避免显示服务器错误页面。
探索首页只保留导语和内容卡片。新增专题时在 `content/explore.ts` 追加标题、摘要、分类、形式、封面与页面路径,并确保目标页面已在 `pages.json` 注册。目录随小程序版本发布,已有 H5 正文可独立更新;没有为尚未上线的内容显示占位卡片。
网址统一在 `packages/app/src/config/education.ts` 配置,不接受外部 query 指定任意 URL。`web-view` 使用微信原生导航栏,其它页面沿用现有 CustomNavBar。分享入口回到探索首页。
## 交互
点击器械部件或横向部件列表,可以高亮并阅读 9 组部件说明。组装/分解按钮可随时反向切换,分解仅表达层次,不是实际拆装步骤。
「床板位置」滑块控制归一化的示意行程;「动作进度」控制完整的推出与回程。推出段占进度 0–50%,回程段占 50–100%,因此进度 100% 时床板回到起点。暂停保持当前位置,续播从当前进度继续,回放从起点开始。首次进入有一次缓慢演示,任何操作都会取消自动演示。浏览器隐藏或器械离屏时停止播放,回来后需手动继续。手机端器械卡片在控制区域滚动时保持可见。
支持逐根挂接及 3 个组合示例,至少保留一根连接。未连接弹簧的自由端随床板收纳,长度保持不变;已连接弹簧的固定端保持在弹簧杆,另一端随床板移动。绳索展示拉环收纳状态;组装状态的示意绳路保持绘图长度,松弛向框架外侧展开,绕滑轮外缘转向。分解时绳路只用于看清归属,不保持真实约束,不模拟具体人体动作、负载或传动比。
触摸与鼠标都可使用原生 range 控件;键盘方向键、Home/End 可调节滑块,Tab/Enter 可操作全部部件及按钮。有焦点提示和中文辅助说明。`prefers-reduced-motion` 下关闭播放与自动演示,位置和视图即时响应。关闭 JavaScript 时,图、全部部件文案和来源仍可阅读;「查看静态图鉴」还能主动切换阅读模式。
## 源码与构建
| 文件 | 作用 |
| --- | --- |
| `packages/app/src/content/reformer.ts` | 共用部件文案、参考来源、五弹簧配置、内部审核记录和版本 |
| `education/reformer/src/model.ts` | 纯运动模型、动画时序、实测标定接口;不包含 SVG/DOM |
| `education/reformer/src/geometry.ts` | Anatomy 等轴测实体、材料、绳路与弹簧端点;全部尺寸为绘图单位 |
| `education/reformer/src/controller.ts` | Vanilla DOM 交互、单个 rAF 循环、减少动态与媒体接口 |
| `education/reformer/src/page.html` | 页面结构与无脚本阅读内容 |
| `education/reformer/src/page.css` | 375–430px 移动布局及桌面布局 |
| `education/reformer/src/illustration.css` | SVG 四色阶材料与高亮样式 |
| `education/reformer/build.mjs` | 生成完整独立 HTML 和独立 SVG |
| `education/reformer/vendor/anatomy/` | 原版 Anatomy 绘图库、MIT 许可、上游版本说明 |
| `packages/server/public/learn/reformer/index.html` | 可直接部署的完整页面 |
`pnpm build:app` 和 `pnpm build:server` 都先生成教学页面与独立 SVG,不需联网下载 Anatomy。小程序内使用已生成的 PNG,避免原生图片渲染器对 SVG/CSS 的兼容差异。修改绘图后执行 `node education/reformer/check-browser.mjs /tmp/reformer-checks --write-native-fallback` 更新本地 PNG,再构建小程序。独立的 `pnpm build:education:release` 要求审核记录完整,否则失败。日常构建允许 pending 状态;该内部状态不显示在用户页面上。
```sh
pnpm build:education
pnpm --filter @mp-pilates/app type-check
pnpm build:app
pnpm build:server
node education/reformer/check-model.mjs
node education/reformer/check-browser.mjs /tmp/reformer-checks
```
浏览器检查需要 Node.js 22+(原生 WebSocket)与 Chrome/Chromium;默认查找 macOS Google Chrome,可通过 `CHROME_PATH` 指定其它位置。它检查 375/390/430/1100px、动作关键帧的 SVG 边界、部件选择、弹簧组合、播放暂停回放、实际触摸拖动、动画反转、无脚本与减少动态模式,以及控制台错误与外部资源请求,并输出截图。
模型检查覆盖:一个完整往返、固定端/随动端、收纳弹簧不伸长、运动中几何数值有效,以及未标定、缺失单根数据、无审核记录、数据无序、超出测量区间时拒绝计算。测试中的合成数据仅用于数学验证,不会进入页面。浏览器图形边界、端点和 Anatomy 线条检查不是完整的机械碰撞证明或工程认证;最终必须由教练对照实机检查。
## 部署到现有服务
Nest 启动时已通过静态中间件开放 `/learn/`,与 `/api/` 分离,不经过 JSON 包装或登录验证。`__dirname/../public/learn` 同时适用于现有 watch 与 dist 目录结构。部署脚本原有 rsync 会携带 `packages/server/public`,无需额外 Node 依赖,也无需数据库迁移。
完成教练审核后:
1. 更新共用内容文件中的 `review.status`、`coach`、`reviewedAt`,修改内容同时更新 `revision`。审核记录应对应本次修订。
2. 执行 `pnpm build:education:release`、类型检查和构建;由负责人确认页面与小程序。
3. 部署服务端或将 `public/learn/reformer/index.html` 放到静态 HTTPS 站点的 `/learn/reformer/index.html`。
4. 如现有 Nginx 只转发 `/api/`,增加 `/learn/` 转发。生产配置示例(保留现有其它 location):
```nginx
location ^~ /learn/ {
proxy_pass http://127.0.0.1:3008;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
gzip on;
gzip_types text/css application/javascript image/svg+xml;
# Nginx 默认会压缩 text/html。
}
```
`proxy_pass` 此处不带结尾 `/`,确保后端收到完整 `/learn/reformer/` 路径。也可用 Nginx 直接托管该目录。页面无第三方资源;服务器返回 `X-Content-Type-Options: nosniff`,HTML 使用可重新验证的缓存策略。
5. 在微信公众平台确认当前主体具备 `web-view` 能力,按[微信官方 web-view 文档](https://developers.weixin.qq.com/miniprogram/dev/component/web-view.html)配置 `focus.richarjiang.com` 为**业务域名**并放置校验文件。容器的 HEAD 检查也需要该域名在 request 合法域名中;默认与现有 API 共用域名。已有的 request 合法域名不等于业务域名;开发者工具的“不校验”选项不能代替生产配置。
6. 确认证书、HTTPS、正式账号与体验版真机都能打开页面,测试网页加载失败时的静态入口,再上传小程序。若账号暂不能使用 web-view,原生静态图鉴仍可访问。
2026-10-18 17:11(北京时间)按用户授权完成服务端发布,线上地址为 https://focus.richarjiang.com/learn/reformer/ 。本次没有上传或发布微信小程序。首次发布时页面带有审阅标识,随后按用户要求移除用户可见标识;微信 web-view 文档本次网络读取不可用,小程序发布前应在微信后台核对当前主体、业务域名和基础库的实际要求。
发布记录:
- 发布目录:\`/usr/local/web/releases/reformer-20261018-1792314611961/\`。
- 发布包 SHA-256:\`00a5d4cd0251903b11eda139e0b5fd4bdf66950dad64bf8a641125a4bcba1430\`,上传前后相同。
- 对比线上 204 个 server 构建文件,仅 \`main.js\` 与 \`main.js.map\` 有变化;96 个 shared 文件完全一致。本次更新两个入口文件并新增教学 HTML 与 SVG,沿用线上依赖与 Prisma Client,没有执行数据库迁移。
- 发布前保存 \`previous-build.tgz\`、进程与健康状态。发布后对运行文件逐项校验,确认环境文件摘要一致。
- 公网 HTML、SVG、健康接口与场馆信息接口均为 200,小程序容器使用的 HEAD 请求也为 200。HTML 为 206386 字节。PM2 在线,切换时重启一次,错误日志没有新增。
- 发布目录内保留 \`verification.json\`、\`process-before.json\`、\`process-after.json\` 和 \`rollback.sh\`。如需应用回退,先检查该脚本与当前线上后续变更,再执行;它恢复旧构建、撤回本次新增教学文件并重启原服务,不修改数据库和环境文件。
## 真实性与教练审核
微信业务域名校验文件已于 2026-10-18 17:24(北京时间)部署:
- 公网地址:`https://focus.richarjiang.com/IYfSihR4Io.txt`,HTTP 200、`text/plain`、32 字节,内容与用户原文件逐字节一致。
- 文件位置:`/www/wwwroot/focus.richarjiang.com/IYfSihR4Io.txt`;Nginx 为该根路径配置了精确匹配,避免被默认反向代理转发到 API。
- 配置备份和验证记录:`/usr/local/web/releases/wechat-domain-20261018-1792315399093/`。配置测试通过并已平滑加载,健康接口正常。微信后台仍需由用户点击保存完成平台校验。
2026-10-18 17:21(北京时间)按用户要求完成界面精简,移除首页、静态图鉴、H5 与 SVG 无障碍描述中的审阅标识;资料来源与示意说明归入详情。H5 静态更新备份位于 `/usr/local/web/releases/reformer-ui-20261018-1792315235344/`,公网 HTML/SVG 校验通过,健康接口 200,服务进程与重启次数保持不变。新的原生首页已构建,尚未上传小程序。
参考来源:
- [Balanced Body Studio Reformer 产品资料](https://www.pilates.com/products/pilates-studio-reformer):木质框架、五弹簧配置(3 红/1 蓝/1 绿)、滚轮与肩靠配置。
- [Balanced Body Reformer 指南](https://www.pilates.com/ultimate-guide-to-the-pilates-reformer/):床板、滑轨、脚踏杆、绳索、滑轮、弹簧的作用,以及阻力和辅助的区别。
- [Studio Reformer / Tower 厂家装配手册](https://balancedbody.eu/wp-content/uploads/2022/01/Studio-Reformer-with-tower-Unpacking-and-Assembly.pdf):供教练逐项对照实机。此手册应由教练完整复核,本次联网检索仅能读取其摘要。
- [Anatomy Skill](https://github.com/wheresryan22/anatomy):按 Figure 规模使用其 SVG 实体、投影和四色阶能力;来源版本和许可保存在 vendor 的 NOTICE 中。
这不是所有品牌的通用实机复刻。当前绘图尺寸、归一化行程、8 秒播放周期、分解距离均为界面示意,不表示实机的尺寸、可用行程、训练节奏或安全限位。弹簧颜色只对应上述参考机型。教练审阅时至少核对:
- 工作室实际品牌、型号、弹簧数量和颜色定义;是否需要替换参考配置。
- 弹簧杆、床板下方固定点、挂钩、轮组、限位、脚踏杆锁定与支撑关系。
- 滑轮外缘绳路、固定/调节位置、拉环收纳位置、绳索松弛表现。
- 头枕与肩靠位置、分解图层级是否易于理解且不会被误认为拆装教学。
- 示意行程、播放节奏和往返原理文案;全部 UI 文案与弹簧组合是否会被误解为练习处方。
- 375–430px 真机点击、拖动、暂停、回放和低性能设备表现。
## 弹簧实测接口
当前 `springCalibration` 是空表。页面始终显示「待实测标定」,没有 kg/N 读数,也不把颜色换算成公斤数。
`SpringCalibration` 为后续接入预留:来源、明确实机型号、教练、日期、同一安装状态下实际床板位置(m)与实测单根拉力(N)的有序采样,采样应包含安装预拉力。`forceAt()` 只对完整数据在测量区间内插值并求已连接弹簧之和;任意一根缺参数、无审核记录、数据不合法或需要外推,返回 `null`。
这个函数**尚未接到 UI**:真实力值还需要同一实机的有效行程映射、相同弹簧杆档位和测量方案,经教练审核后再实现。即使计算出弹簧合力,也不等于人体所受净阻力;摩擦、绳路与姿势等仍需另行建模。不要直接给当前绘图单位赋予米或牛顿。
## 外部人体教学接口
浏览器提供 `window.ReformerLesson.registerMedia(media)`,类型为 `ExternalTeachingMedia`,支持 `video` 或外部 `model` 页面。接入前在 controller 中配置 `approvedMediaOrigins`(默认空列表),并提供 HTTPS URL、来源、教练、审核日期和标题。默认不加载任何人体或外部资源。
视频使用原生 `<video controls playsinline preload="none">`;3D 教学页使用独立 iframe(sandbox 仅允许脚本,不开放顶级跳转)。实际模型供应方若需要其它沙箱能力,应单独评估并按微信业务域名要求配置。当前没有医学人体生成,也没有自建 WebGL 人体模型。

View File

@@ -0,0 +1,40 @@
import { build } from 'esbuild'
import { readFile, writeFile, mkdir, mkdtemp, rm } from 'node:fs/promises'
import { dirname, resolve, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { tmpdir } from 'node:os'
import { gzipSync } from 'node:zlib'
const here = dirname(fileURLToPath(import.meta.url)), root = resolve(here, '../..')
const temp = await mkdtemp(join(tmpdir(), 'reformer-build-'))
try {
await build({ entryPoints: [join(here, 'src/geometry.ts')], bundle: true, platform: 'node', format: 'esm', outfile: join(temp, 'geometry.mjs') })
const { createFigure, partsInventory } = await import(pathToFileURL(join(temp, 'geometry.mjs')).href)
await build({ entryPoints: [join(root, 'packages/app/src/content/reformer.ts')], bundle: true, platform: 'node', format: 'esm', outfile: join(temp, 'content.mjs') })
const { reformerParts, reformerLesson } = await import(pathToFileURL(join(temp, 'content.mjs')).href)
const live = await build({ entryPoints: [join(here, 'src/controller.ts')], bundle: true, platform: 'browser', format: 'iife', target: ['es2019'], minify: true, write: false })
const [template, css, illustration] = await Promise.all(['page.html', 'page.css', 'illustration.css'].map(name => readFile(join(here, 'src', name), 'utf8')))
const approved = reformerLesson.review.status === 'approved' && reformerLesson.review.coach && reformerLesson.review.reviewedAt
if (process.argv.includes('--release') && !approved) throw new Error('正式发布构建需要教练审核记录:status=approved、coach、reviewedAt。审阅预览请使用 pnpm build:education。')
const figure = createFigure()
const replacements = {
__THIRD_PARTY_LICENSE__: `Anatomy SVG kit · https://github.com/wheresryan22/anatomy\n${await readFile(join(here, 'vendor/anatomy/LICENSE'), 'utf8')}`,
'/*__CSS__*/': `${css}\n${illustration}`,
'/*__SCRIPT__*/': live.outputFiles[0].text.replaceAll('</script', '<\\/script'),
__FIGURE__: figure,
__PART_BUTTONS__: reformerParts.map(p => `<button data-part-button="${p.id}" aria-pressed="${p.id === 'carriage'}">${p.name}</button>`).join(''),
__STATIC_PARTS__: reformerParts.map(p => `<dt>${p.number} · ${p.name}</dt><dd>${p.description} ${p.observation}</dd>`).join(''),
__SPRING_BUTTONS__: reformerLesson.springs.map((s, i) => `<button class="spring-button" data-spring-button="${s.id}" aria-pressed="${i === 0 || i === 3}" aria-label="${s.label}弹簧,${s.level},切换连接" style="--spring-color:${s.color}"><span class="spring-color"></span><span>${s.label}</span><span class="spring-state">${i === 0 || i === 3 ? '已连接' : '未连接'}</span></button>`).join(''),
__REFERENCE__: reformerLesson.reference,
__SOURCES__: reformerLesson.sources.map(s => `<a href="${s.url}" target="_blank" rel="noopener noreferrer">${s.title} ↗</a>`).join(''),
}
let html = template
for (const [key, value] of Object.entries(replacements)) html = html.replace(key, () => value)
const output = join(root, 'packages/server/public/learn/reformer')
await mkdir(output, { recursive: true })
await writeFile(join(output, 'index.html'), html)
// Keep an inspectable standalone SVG. Native mini-programs use a checked-in PNG raster.
const svg = figure.replace('><title', `><style>${illustration}</style><title`)
await writeFile(join(output, 'reformer.svg'), svg)
console.log(`Reformer built: ${Math.round(Buffer.byteLength(html) / 1024)} KiB HTML, ${Math.round(gzipSync(html).length / 1024)} KiB gzip; no runtime requests. ${partsInventory.length} part categories.`)
} finally { await rm(temp, { recursive: true, force: true }) }

View File

@@ -0,0 +1,156 @@
// Dependency-free Chrome DevTools checks. Run after pnpm build:education.
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdtemp, rm, mkdir, writeFile, readFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import assert from 'node:assert/strict'
const chromePath = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
assert.ok(existsSync(chromePath), 'Set CHROME_PATH to a local Chrome/Chromium executable')
const profile = await mkdtemp(join(tmpdir(), 'reformer-chrome-'))
const shots = process.argv[2] || '/tmp/reformer-checks'
await mkdir(shots, { recursive: true })
const chrome = spawn(chromePath, ['--headless=new', '--no-sandbox', '--disable-gpu', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] })
let socket
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
try {
const port = await new Promise((done, fail) => {
const timer = setTimeout(() => fail(new Error('Chrome startup timed out')), 15000)
chrome.stderr.on('data', data => {
const match = String(data).match(/DevTools listening on ws:\/\/[^:]+:(\d+)/)
if (match) { clearTimeout(timer); done(Number(match[1])) }
})
chrome.on('error', fail)
chrome.on('exit', code => { clearTimeout(timer); fail(new Error(`Chrome exited: ${code}`)) })
})
const targets = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()
socket = new WebSocket(targets.find(t => t.type === 'page').webSocketDebuggerUrl)
await new Promise(resolve => socket.addEventListener('open', resolve, { once: true }))
let id = 0
const pending = new Map(), errors = [], requests = []
socket.addEventListener('message', event => {
const data = JSON.parse(event.data)
if (data.id) {
const task = pending.get(data.id)
if (task) { pending.delete(data.id); clearTimeout(task.timer); data.error ? task.reject(new Error(data.error.message)) : task.resolve(data.result) }
}
if (data.method === 'Runtime.exceptionThrown') errors.push(data.params.exceptionDetails.text)
if (data.method === 'Log.entryAdded' && data.params.entry.level === 'error') errors.push(data.params.entry.text)
if (data.method === 'Network.requestWillBeSent') requests.push(data.params.request.url)
})
const send = (method, params = {}) => new Promise((resolve, reject) => {
const key = ++id, timer = setTimeout(() => { pending.delete(key); reject(new Error(`${method} timed out`)) }, 12000)
pending.set(key, { resolve, reject, timer }); socket.send(JSON.stringify({ id: key, method, params }))
})
const evaluate = async expression => {
const result = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true })
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text)
return result.result.value
}
const until = async expression => {
for (let i = 0; i < 80; i++) { if (await evaluate(expression)) return; await wait(50) }
throw new Error(`Condition timed out: ${expression}`)
}
const shot = async (name, width, height = 900) => {
await evaluate('window.scrollTo(0,0)'); await wait(80)
const { data } = await send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true, clip: { x: 0, y: 0, width, height, scale: 1 } })
await writeFile(join(shots, `${name}.png`), Buffer.from(data, 'base64'))
}
for (const method of ['Page.enable', 'Runtime.enable', 'Log.enable', 'Network.enable']) await send(method)
const url = pathToFileURL(resolve('packages/server/public/learn/reformer/index.html')).href
const navigate = async (width, reduced = false, noScript = false) => {
await send('Emulation.setDeviceMetricsOverride', { width, height: 920, deviceScaleFactor: 1, mobile: width < 700 })
await send('Emulation.setTouchEmulationEnabled', { enabled: width < 700, maxTouchPoints: 1 })
await send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: reduced ? 'reduce' : 'no-preference' }] })
await send('Emulation.setScriptExecutionDisabled', { value: noScript })
await send('Page.navigate', { url })
await wait(150)
if (!noScript) await until('document.body.classList.contains("ready")')
}
const click = selector => evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`)
const setRange = (selector, value) => evaluate(`(()=>{const el=document.querySelector(${JSON.stringify(selector)});el.value=${value};el.dispatchEvent(new Event('input',{bubbles:true}));return true})()`)
const noOverflow = async () => assert.equal(await evaluate('document.documentElement.scrollWidth > innerWidth'), false, 'horizontal overflow')
for (const width of [375, 390, 430, 1100]) {
await navigate(width, true); await noOverflow()
assert.equal(await evaluate("/教练审阅版|教练已审核|尚未完成审核|待教练审核|人体教学暂未开放/.test(document.body.innerText)"), false, 'editorial labels and unavailable feature placeholders must not appear')
assert.equal(await evaluate("document.querySelector('#teaching-media').hidden"), true, 'unconfigured media stays out of the reading flow')
for (const value of [0, 25, 50, 75, 100]) {
await setRange('#timeline', value)
await until(`Math.abs(Number(document.querySelector('.stage').dataset.progress)-${value / 100})<0.0002`)
await noOverflow()
const bbox = await evaluate(`(()=>{const b=document.querySelector('.reformer-svg').getBBox();return {x:b.x,y:b.y,right:b.x+b.width,bottom:b.y+b.height}})()`)
assert.ok(bbox.x >= 0 && bbox.y >= 0 && bbox.right <= 760 && bbox.bottom <= 475, `drawing clipped at ${width}px / ${value}%: ${JSON.stringify(bbox)}`)
}
await setRange('#timeline', 0); await wait(40)
await shot(`assembled-${width}`, width, width < 700 ? 920 : 1000)
await click('[data-view="exploded"]'); await until("document.querySelector('.stage').dataset.explode==='1'")
await noOverflow(); await shot(`exploded-${width}`, width, 920)
await click('[data-view="assembled"]'); await wait(40)
await click('[data-part-button="pulleys"]')
assert.equal(await evaluate("document.querySelector('#part-name').textContent"), '滑轮')
assert.equal(await evaluate("document.querySelectorAll('[data-component=\"pulleys\"].is-selected').length"), 1)
await click('[data-preset="b1"]')
assert.equal(await evaluate("document.querySelectorAll('[data-spring-button][aria-pressed=\"true\"]').length"), 1)
await click('[data-spring-button="b1"]')
assert.equal(await evaluate("document.querySelectorAll('[data-spring-button][aria-pressed=\"true\"]').length"), 1, 'must retain one connection')
await click('[data-preset="r1,r2,r3,b1,g1"]')
assert.equal(await evaluate("document.querySelectorAll('[data-spring-button][aria-pressed=\"true\"]').length"), 5)
await setRange('#position', 70); await until("Math.abs(Number(document.querySelector('.stage').dataset.position)-0.7)<0.001")
await click('#static-toggle'); assert.equal(await evaluate("document.querySelector('.static-parts').getBoundingClientRect().height>0"), true)
await click('#static-toggle'); assert.equal(await evaluate("document.body.classList.contains('ready')"), true)
console.log(`${width}px: layout, geometry bounds, selection, springs, manual sliders, static toggle passed`)
}
await navigate(390)
await click('[data-part-button="carriage"]') // cancels the initial tour
await click('#play'); await wait(450); await click('#play')
const paused = await evaluate("Number(document.querySelector('.stage').dataset.progress)")
await wait(200)
assert.equal(await evaluate("Number(document.querySelector('.stage').dataset.progress)"), paused, 'pause must hold the position')
assert.ok(paused > 0, 'playback did not advance')
await setRange('#timeline', 75); await until("Math.abs(Number(document.querySelector('.stage').dataset.progress)-0.75)<0.0002")
await click('#play'); await wait(250); await click('#play')
assert.ok(await evaluate("Number(document.querySelector('.stage').dataset.progress) > 0.75"), 'resume should continue from seek position')
await click('#replay'); await wait(150); await click('#play')
assert.ok(await evaluate("Number(document.querySelector('.stage').dataset.progress) < 0.1"), 'replay should restart')
await click('[data-view="exploded"]'); await wait(120); await click('[data-view="assembled"]')
await until("Number(document.querySelector('.stage').dataset.explode)<0.0002")
// Actual touch input on the native range control, with the sticky diagram still in view.
await evaluate("document.querySelector('#position').scrollIntoView({block:'end'})")
await wait(100)
const rect = await evaluate("(()=>{const r=document.querySelector('#position').getBoundingClientRect();return {x:r.x,y:r.y,w:r.width,h:r.height}})()")
const touch = (type, x) => send('Input.dispatchTouchEvent', { type, touchPoints: type === 'touchEnd' ? [] : [{ x, y: rect.y + rect.h / 2, id: 1 }] })
await touch('touchStart', rect.x + 20)
for (let i = 0; i <= 12; i++) { await touch('touchMove', rect.x + 20 + i / 12 * (rect.w - 40)); await wait(20) }
await touch('touchEnd', 0); await wait(900)
assert.ok(await evaluate("Number(document.querySelector('.stage').dataset.position)>0.7"), 'touch drag failed')
assert.ok(await evaluate("document.querySelector('.diagram-panel').getBoundingClientRect().bottom > 0"), 'diagram must remain visible while adjusting slider')
const screen = await send('Page.captureScreenshot', { format: 'png' })
await writeFile(join(shots, 'touch-controls.png'), Buffer.from(screen.data, 'base64'))
console.log('Playback: pause, seek, resume, replay, mid-transition reversal and touch drag passed')
await navigate(390, false, true)
await wait(300)
// CDP evaluation remains usable while page-owned scripts are disabled.
assert.ok(await evaluate("document.querySelector('.static-parts').getBoundingClientRect().height > 100"), 'no-JS parts must be visible')
assert.equal(await evaluate("document.querySelector('.controls').getBoundingClientRect().height"), 0)
await shot('no-javascript', 390, 1600)
await navigate(390, true)
await wait(2000)
assert.equal(await evaluate("Number(document.querySelector('.stage').dataset.progress)"), 0, 'reduced motion must not start the tour')
assert.equal(await evaluate("document.querySelector('#play').disabled"), true)
if (process.argv.includes('--write-native-fallback')) {
const svg = await readFile('packages/server/public/learn/reformer/reformer.svg', 'utf8')
const data = await evaluate(`(async()=>{const image=new Image();image.src='data:image/svg+xml;charset=utf-8,'+encodeURIComponent(${JSON.stringify(svg)});await image.decode();const canvas=document.createElement('canvas');canvas.width=1140;canvas.height=713;canvas.getContext('2d').drawImage(image,0,0,1140,713);return canvas.toDataURL('image/png').split(',')[1]})()`)
await writeFile('packages/app/src/static/learn/reformer.png', Buffer.from(data, 'base64'))
console.log('Updated native PNG fallback from the standalone SVG.')
}
assert.deepEqual(errors, [], 'browser console errors')
assert.deepEqual(requests.filter(url => /^https?:/.test(url)), [], 'standalone page must not request external assets')
console.log(`No-JS fallback, reduced motion, zero external requests and clean browser console passed. Screenshots: ${shots}`)
} finally {
socket?.close(); chrome.kill('SIGKILL'); await wait(200); await rm(profile, { recursive: true, force: true })
}

View File

@@ -0,0 +1,44 @@
import { build } from 'esbuild'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import assert from 'node:assert/strict'
const temp = await mkdtemp(join(tmpdir(), 'reformer-model-'))
try {
for (const name of ['model', 'geometry']) await build({ entryPoints: [`education/reformer/src/${name}.ts`], bundle: true, platform: 'node', format: 'esm', outfile: join(temp, `${name}.mjs`) })
const { positionAt, forceAt, springCalibration } = await import(pathToFileURL(join(temp, 'model.mjs')).href)
const { drawing, springEndpoints, springPaths, hookPath, ropePaths, ropeRoutes } = await import(pathToFileURL(join(temp, 'geometry.mjs')).href)
assert.equal(positionAt(0), 0); assert.equal(positionAt(0.5), 1); assert.equal(positionAt(1), 0)
assert.equal(forceAt(0.1, ['r1'], springCalibration), null, 'unmeasured spring must not produce a force')
// Synthetic, test-only records. These values never ship as equipment parameters.
const record = { source: 'test fixture', equipment: 'test rig', coach: 'test reviewer', reviewedAt: 'test-only', samples: [{ positionM: 0, forceN: 4 }, { positionM: 0.2, forceN: 8 }] }
assert.equal(forceAt(0.1, ['a'], { a: record }), 6)
assert.equal(forceAt(0.1, ['a', 'b'], { a: record, b: record }), 12)
for (const pos of [-1, 0.3, NaN, Infinity]) assert.equal(forceAt(pos, ['a'], { a: record }), null, 'no extrapolation or invalid positions')
assert.equal(forceAt(0.1, ['a', 'a'], { a: record }), null, 'no duplicate springs')
assert.equal(forceAt(0.1, ['a', 'missing'], { a: record }), null, 'partial calibration must not produce a force')
for (const field of ['coach', 'reviewedAt', 'source', 'equipment']) assert.equal(forceAt(0.1, ['a'], { a: { ...record, [field]: '' } }), null)
assert.equal(forceAt(0.1, ['a'], { a: { ...record, samples: [...record.samples].reverse() } }), null)
assert.equal(forceAt(0.1, ['a'], { a: { ...record, samples: [{ positionM: 0, forceN: -4 }, { positionM: 0.2, forceN: 8 }] } }), null)
// Sample all connected/stowed spring paths over the full travel and intermediate explode poses.
for (let step = 0; step <= 100; step++) {
const pos = step / 100
const active = springEndpoints(pos, true), stowed = springEndpoints(pos, false)
assert.equal(active.start, drawing.anchorX, 'fixed springbar endpoint must stay fixed')
assert.equal(active.end, drawing.carriageAnchorX + pos * drawing.travel, 'bed endpoint must move with the bed')
assert.ok(Math.abs(stowed.end - stowed.start - 70) < 1e-8, 'disconnected spring must not stretch')
assert.ok(active.end > active.start, 'spring must not invert')
for (const explode of [0, 0.25, 0.5, 0.75, 1]) for (let i = 0; i < 5; i++) for (const connected of [true, false]) {
const coil = springPaths(pos, i, connected, explode), hook = hookPath(pos, i, connected, explode)
assert.ok(!/NaN|Infinity|undefined/.test(coil.front + coil.back + hook))
}
for (const explode of [0, 0.5, 1]) assert.ok(!/NaN|Infinity|undefined/.test(ropePaths(pos, explode).join('')))
for (const route of ropeRoutes(pos)) {
const length = route.slice(1).reduce((sum, p, i) => sum + Math.hypot(...p.map((v, j) => v - route[i][j])), 0)
assert.ok(Math.abs(length - 430) < 0.2, 'parked rope must conserve its drawing length across the full stroke')
}
}
console.log('Model passed: complete motion cycle, fixed and moving endpoints, stowed springs, finite geometry, calibration refusal and no extrapolation.')
} finally { await rm(temp, { recursive: true, force: true }) }

View File

@@ -0,0 +1,189 @@
import { reformerLesson, reformerParts, type ReformerPartId } from '../../../packages/app/src/content/reformer'
import { drawing, projection, explodeMoves, springPaths, hookPath, ropePaths } from './geometry'
import { translateAlong } from '../vendor/anatomy/iso-kit.mjs'
import { clamp01, follow, motionConfig, phaseAt, positionAt } from './model'
const $ = <T extends Element = HTMLElement>(selector: string) => {
const node = document.querySelector<T>(selector)
if (!node) throw new Error(`Missing lesson element: ${selector}`)
return node
}
const positionSlider = $<HTMLInputElement>('#position'), timeline = $<HTMLInputElement>('#timeline')
const stage = $('.stage'), play = $<HTMLButtonElement>('#play')
const reduced = matchMedia('(prefers-reduced-motion: reduce)')
const connected = new Set(['r1', 'b1'])
const state = { progress: 0, target: 0, explode: 0, explodeTarget: 0, playing: false, static: false, selected: 'carriage' as ReformerPartId }
let frame = 0, last = 0, visible = true, touched = false, tourTimer = 0
const componentNodes = [...document.querySelectorAll<SVGGElement>('[data-component]')]
const coils = drawing.springYs.map((_, i) => $<SVGPathElement>(`[data-coil="${i}"]`))
const coilBacks = drawing.springYs.map((_, i) => $<SVGPathElement>(`[data-coil-back="${i}"]`))
const hooks = drawing.springYs.map((_, i) => $<SVGPathElement>(`[data-hook="${i}"]`))
const ropeNodes = [0, 1].map(i => $<SVGPathElement>(`[data-rope="${i}"]`))
const carriage = $<SVGGElement>('[data-carriage]'), headrest = $<SVGGElement>('[data-headrest]')
const rails = $<SVGGElement>('[data-component="rails"]'), springBar = $<SVGGElement>('[data-spring-bar]')
const footbar = $<SVGGElement>('[data-component="footbar"]'), pulleys = $<SVGGElement>('[data-component="pulleys"]')
const readout = $('[data-readout]'), positionValue = $('#position-value'), phase = $('#phase'), progressValue = $('#progress-value')
const mini = $<SVGPathElement>('#spring-mini'), miniEnd = $('#spring-mini-end'), extension = $('#extension-state')
const transform = (el: Element, v: number[]) => el.setAttribute('transform', translateAlong(projection, v))
const announce = (message: string) => { $('#announcement').textContent = message }
function updatePlay() {
$('#play-label').textContent = reduced.matches ? '手动观察' : state.playing ? '暂停演示' : state.progress >= 0.9999 ? '再次播放' : '播放演示'
play.setAttribute('aria-label', state.playing ? '暂停动作' : '播放动作')
play.setAttribute('aria-pressed', String(state.playing))
play.disabled = reduced.matches
$('#play-icon').setAttribute('d', state.playing ? 'M7 4V16M13 4V16' : 'M7 4L16 10L7 16Z')
}
function draw() {
const position = positionAt(state.progress), offset = position * drawing.travel, e = state.explode
transform(carriage, [offset, 0, e * 90]); transform(headrest, [offset, 0, e * 117])
transform(rails, [0, 0, e * 22]); transform(springBar, [0, 0, e * 47])
transform(footbar, explodeMoves.footbar.map(v => v * e)); transform(pulleys, explodeMoves.pulleys.map(v => v * e))
reformerLesson.springs.forEach((spring, i) => {
const active = connected.has(spring.id), paths = springPaths(position, i, active, e)
coils[i].parentElement!.style.opacity = active ? '1' : '.38'
coils[i].setAttribute('d', paths.front); coilBacks[i].setAttribute('d', paths.back)
hooks[i].setAttribute('d', hookPath(position, i, active, e))
})
ropePaths(position, e).forEach((d, i) => ropeNodes[i].setAttribute('d', d))
const percent = Math.round(position * 100), progress = Math.round(state.progress * 100)
positionValue.innerHTML = `${percent}<small>%</small>`
positionSlider.value = String(position * 100); timeline.value = String(state.progress * 100)
positionSlider.style.setProperty('--fill', `${position * 100}%`); timeline.style.setProperty('--fill', `${state.progress * 100}%`)
progressValue.textContent = `${progress}%`; phase.textContent = phaseAt(state.progress)
readout.textContent = e > 0.001 ? '分解示意 · 非拆装步骤' : `示意行程 ${percent}%`
stage.dataset.position = String(position); stage.dataset.explode = String(e); stage.dataset.progress = String(state.progress)
const end = 95 + position * 170
const miniPoints = Array.from({ length: 241 }, (_, i) => {
const t = i / 240, x = 14 + t * (end - 20), y = 22 + Math.sin(t * 24 * Math.PI) * 5
return `${i ? 'L' : 'M'}${x.toFixed(2)} ${y.toFixed(2)}`
}).join('')
mini.setAttribute('d', `M11 22L14 22${miniPoints}L${end - 3} 22`)
miniEnd.setAttribute('cx', String(end)); extension.textContent = position < 0.002 ? '起始长度' : '随床板同步伸长'
}
function stop() { state.playing = false; updatePlay() }
function interact() { touched = true; clearTimeout(tourTimer) }
function run() {
if (!frame && visible && !document.hidden && !state.static) { last = 0; frame = requestAnimationFrame(tick) }
}
function tick(now: number) {
frame = 0
const dt = last ? Math.min((now - last) / 1000, 1 / 30) : 1 / 60
last = now
if (state.playing && !reduced.matches) {
state.target = clamp01(state.target + dt / motionConfig.cycleSeconds)
state.progress = state.target
if (state.target === 1) { stop(); announce('演示结束,床板已回到起始位置。') }
} else state.progress = reduced.matches ? state.target : follow(state.progress, state.target, dt, motionConfig.followSeconds)
state.explode = reduced.matches ? state.explodeTarget : follow(state.explode, state.explodeTarget, dt, motionConfig.explodeSeconds)
draw()
if (state.playing || state.progress !== state.target || state.explode !== state.explodeTarget) run()
}
function selectPart(id: ReformerPartId, scroll = false) {
const part = reformerParts.find(item => item.id === id)
if (!part) return
state.selected = id
componentNodes.forEach(el => el.classList.toggle('is-selected', el.dataset.component === id))
document.querySelectorAll<HTMLButtonElement>('[data-part-button]').forEach(el => el.setAttribute('aria-pressed', String(el.dataset.partButton === id)))
$('#part-number').textContent = part.number; $('#part-name').textContent = part.name; $('#part-en').textContent = part.en
$('#part-description').textContent = part.description; $('#part-observation').textContent = part.observation
if (scroll) $(`[data-part-button="${id}"]`).scrollIntoView({ behavior: reduced.matches ? 'instant' : 'smooth', block: 'nearest', inline: 'nearest' })
}
function setView(exploded: boolean) {
interact(); stop(); state.target = state.progress; state.explodeTarget = exploded ? 1 : 0
document.querySelectorAll<HTMLButtonElement>('[data-view]').forEach(el => el.setAttribute('aria-pressed', String((el.dataset.view === 'exploded') === exploded)))
$('#view-hint').textContent = exploded ? '看清层次,再轻点部件' : '轻点部件,探索它的作用'
announce(exploded ? '分解视图。仅展示层次,不是实际拆装顺序。' : '组装视图。'); run()
}
function updateSprings() {
document.querySelectorAll<HTMLButtonElement>('[data-spring-button]').forEach(button => {
const active = connected.has(button.dataset.springButton || '')
button.setAttribute('aria-pressed', String(active)); button.querySelector('.spring-state')!.textContent = active ? '已连接' : '未连接'
})
document.querySelectorAll<SVGGElement>('[data-spring]').forEach(node => { node.style.opacity = connected.has(node.dataset.spring || '') ? '1' : '.38' })
$('#spring-count').textContent = `已连接 ${connected.size} / 5`
selectPart('springs'); announce(`已连接 ${connected.size} 根弹簧。未标定真实拉力。`); draw()
}
stage.addEventListener('click', event => {
const target = (event.target as Element).closest<SVGGElement>('[data-component]')
if (target && !state.static) { interact(); stop(); state.target = state.progress; selectPart(target.dataset.component as ReformerPartId, true); announce($('#part-name').textContent || '') }
})
document.querySelectorAll<HTMLButtonElement>('[data-part-button]').forEach(el => el.addEventListener('click', () => {
interact(); stop(); state.target = state.progress; selectPart(el.dataset.partButton as ReformerPartId)
}))
document.querySelectorAll<HTMLButtonElement>('[data-view]').forEach(el => el.addEventListener('click', () => setView(el.dataset.view === 'exploded')))
positionSlider.addEventListener('input', () => {
interact(); stop()
// Pick the outbound branch for a manually selected bed position; the timeline stays in sync.
state.target = Math.acos(1 - 2 * clamp01(Number(positionSlider.value) / 100)) / (2 * Math.PI)
run()
})
timeline.addEventListener('input', () => { interact(); stop(); state.target = clamp01(Number(timeline.value) / 100); run() })
for (const slider of [positionSlider, timeline]) {
slider.addEventListener('pointerdown', () => { interact(); stop(); state.target = state.progress })
slider.addEventListener('change', () => announce('位置已调整,当前为示意行程。'))
}
play.addEventListener('click', () => {
interact()
if (state.playing) { stop(); state.target = state.progress; announce('演示已暂停。'); return }
if (reduced.matches) return
if (state.progress >= 0.9999) state.progress = 0
state.target = state.progress; state.playing = true; updatePlay(); announce('开始播放推出与回程示意。'); run()
})
$('#replay').addEventListener('click', () => {
interact(); state.progress = 0; state.target = 0; state.playing = !reduced.matches; updatePlay(); draw(); run()
})
document.querySelectorAll<HTMLButtonElement>('[data-spring-button]').forEach(el => el.addEventListener('click', () => {
interact(); stop(); state.target = state.progress
const id = el.dataset.springButton!
if (connected.has(id) && connected.size === 1) { announce('至少保留一根弹簧连接。'); return }
if (connected.has(id)) connected.delete(id); else connected.add(id)
updateSprings()
}))
document.querySelectorAll<HTMLButtonElement>('[data-preset]').forEach(el => el.addEventListener('click', () => {
interact(); stop(); state.target = state.progress; connected.clear(); el.dataset.preset!.split(',').forEach(id => connected.add(id)); updateSprings()
}))
$('#static-toggle').addEventListener('click', () => {
interact(); stop(); state.target = state.progress; state.static = !state.static
document.body.classList.toggle('ready', !state.static); document.body.classList.toggle('static-only', state.static)
$('#static-toggle').textContent = state.static ? '返回交互图鉴' : '切换为静态阅读'
$('.no-js-note').textContent = '静态阅读模式。下方可阅读全部中文部件说明。'
if (state.static) { cancelAnimationFrame(frame); frame = 0 } else run()
announce(state.static ? '已切换静态阅读。' : '已返回交互图鉴。')
})
document.addEventListener('visibilitychange', () => {
if (document.hidden) { stop(); state.target = state.progress; cancelAnimationFrame(frame); frame = 0; clearTimeout(tourTimer) } else run()
})
if ('IntersectionObserver' in window) new IntersectionObserver(entries => {
visible = entries[entries.length - 1].isIntersecting
if (!visible) { stop(); state.target = state.progress; cancelAnimationFrame(frame); frame = 0 } else run()
}, { rootMargin: '120px 0px' }).observe(stage)
reduced.addEventListener('change', () => { stop(); updatePlay(); run() })
export interface ExternalTeachingMedia { kind: 'video' | 'model'; url: string; source: string; coach: string; reviewedAt: string; title: string }
// Populate with reviewed provider origins before enabling external teaching media.
const approvedMediaOrigins: string[] = []
function registerMedia(media: ExternalTeachingMedia) {
const url = new URL(media.url)
if (!media.coach.trim() || !media.reviewedAt.trim() || !media.source.trim() || url.protocol !== 'https:' || !approvedMediaOrigins.includes(url.origin)) throw new Error('教学媒体需要已审核的来源、教练记录与 HTTPS 域名。')
const host = $('#external-media'); host.replaceChildren()
if (media.kind === 'video') {
const video = document.createElement('video'); video.src = url.href; video.controls = true; video.playsInline = true; video.preload = 'none'; video.style.width = '100%'; video.setAttribute('aria-label', media.title); host.append(video)
} else {
const iframe = document.createElement('iframe'); iframe.src = url.href; iframe.title = media.title; iframe.setAttribute('sandbox', 'allow-scripts'); iframe.loading = 'lazy'; iframe.style.cssText = 'width:100%;height:320px;border:0'; host.append(iframe)
}
host.hidden = false; $('#teaching-media').hidden = false; $('#media-placeholder').textContent = media.title
}
Object.defineProperty(window, 'ReformerLesson', { value: Object.freeze({ registerMedia, revision: reformerLesson.review.revision }), writable: false })
draw(); selectPart('carriage'); updatePlay(); document.body.classList.add('ready')
tourTimer = window.setTimeout(() => {
if (!touched && !reduced.matches && visible && !document.hidden && !state.static) { state.playing = true; updatePlay(); run() }
}, 1800)

View File

@@ -0,0 +1,156 @@
import * as k from '../vendor/anatomy/iso-kit.mjs'
import * as g from '../vendor/anatomy/lathe.mjs'
import { reformerLesson, type ReformerPartId } from '../../../packages/app/src/content/reformer'
// Drawing units only. These dimensions are NOT equipment specifications.
export const drawing = Object.freeze({ travel: 125, anchorX: -223, carriageAnchorX: -140, springZ: 82, springYs: [-36, -18, 0, 18, 36] })
export const projection = { origin: [380, 261], scale: 0.91, azimuth: 66, elevation: 30 }
export const explodeMoves: Record<ReformerPartId, number[]> = {
frame: [0, 0, 0], rails: [0, 0, 22], carriage: [0, 0, 90], springs: [0, 0, 47],
hooks: [0, 0, 47], footbar: [-22, 0, 26], ropes: [0, 0, 0], pulleys: [18, 0, 22], headrest: [0, 0, 117],
}
const S = (shape: ReturnType<typeof k.slabOf>, material = 'steel') => `<g class="material-${material}">${k.solidSvg(shape)}</g>`
const slab = (x: number, y: number, w: number, d: number, z: number, h: number, material: string, r = 2) => S(k.slabOf({ x, y, w, d, r }, z, h, projection, 4, 0.6), material)
const cylinder = (x: number, y: number, radius: number, z: number, h: number, material: string) => S(k.cylinder(x, y, radius, z, h, projection, 20, 0.4), material)
const rod = (a: [number, number, number], b: [number, number, number], r: number, material = 'steel') => {
const vector: [number, number, number] = [b[0] - a[0], b[1] - a[1], b[2] - a[2]], length = Math.hypot(...vector)
return S(g.disc(0, length, r, g.frameAlong(a, vector), projection, { steps: 20 }), material)
}
const seam = (plan: { x: number; y: number; w: number; d: number; r: number }, z: number) => k.lineSvg(k.planOutline(plan, z, projection), { tone: 'faint' })
const screws = (points: number[][]) => k.dotsSvg(points.map(p => k.iso(p as [number, number, number], projection)), { size: 1, tone: 'hi' })
const wrap = (id: ReformerPartId, content: string, extra = '') => `<g data-component="${id}" ${extra} class="component">${content}</g>`
export function springEndpoints(position: number, connected: boolean) {
const end = drawing.carriageAnchorX + position * drawing.travel
const start = connected ? drawing.anchorX : end - 70
return { start, end }
}
export function springPaths(position: number, index: number, connected: boolean, explode = 0) {
const { start, end } = springEndpoints(position, connected)
return k.coil(start + 7, end - 7, [drawing.springYs[index], drawing.springZ + explode * 47], 3.1, 17, 'x', projection)
}
export function hookPath(position: number, index: number, connected: boolean, explode = 0) {
const { start, end } = springEndpoints(position, connected)
const y = drawing.springYs[index], z = drawing.springZ + explode * 47
// Closed loops and straight necks meet the spring ends; no free-floating leaders.
return k.ring(start, y, 3.1, z, projection, 16) + k.segment([start + 3, y, z], [start + 7, y, z], projection)
+ k.segment([end - 7, y, z], [end - 3, y, z], projection) + k.ring(end, y, 3.1, z, projection, 16)
}
export function ropeRoutes(position: number, explode = 0) {
const paths: number[][][] = []
for (const side of [-1, 1]) {
const x = 66 + position * drawing.travel, y = side * 51
const anchor = [x + 16, y, 110 + explode * 90]
const pulley = [249 + explode * 18, side * 63, 151 + explode * 22]
const lower = [pulley[0], pulley[1], pulley[2] - 9], upper = [pulley[0], pulley[1], pulley[2] + 9]
const arc = Array.from({ length: 25 }, (_, i) => {
const angle = i / 24 * Math.PI
return [pulley[0] + Math.sin(angle) * 9, pulley[1], pulley[2] - Math.cos(angle) * 9]
})
const loop = [47 + position * drawing.travel + 7, side * 35, 130 + explode * 117]
// Parked loops: the two legs of each rope slacken as the carriage approaches the risers.
// Numerical bisection keeps their total centreline length constant within this drawing model.
const sample = (a: number[], b: number[], sag: number) => Array.from({ length: 25 }, (_, i) => {
const t = i / 24
// Slack bows out beside the frame, rather than cutting down through the bed.
return a.map((v, j) => v + (b[j] - v) * t + (j === 1 ? side * 4 * sag * t * (1 - t) : 0))
})
const length = (points: number[][]) => points.slice(1).reduce((sum, point, i) => sum + Math.hypot(...point.map((v, j) => v - points[i][j])), 0)
// Exploded pose separates assemblies conceptually; only the assembled rope conserves length.
const target = 430
let lo = 0, hi = 180
for (let j = 0; j < 12; j++) {
const mid = (lo + hi) / 2
if (length(sample(anchor, lower, mid)) + length(arc) + length(sample(upper, loop, mid)) < target) lo = mid
else hi = mid
}
const sag = (lo + hi) / 2 * (1 - explode * 0.75)
paths.push([...sample(anchor, lower, sag), ...arc.slice(1), ...sample(upper, loop, sag).slice(1)])
}
return paths
}
export const ropePaths = (position: number, explode = 0) => ropeRoutes(position, explode).map(points => k.pathOf(points.map(p => k.iso(p as [number, number, number], projection))))
export function createFigure() {
const back: string[] = [], front: string[] = [], carriage: string[] = [], head: string[] = []
// Four legs, pads, corner plates and fasteners.
for (const x of [-249, 230]) for (const y of [-77, 59]) {
const parts = slab(x, y, 20, 20, 6, 44, 'wood') + slab(x - 1, y - 1, 22, 22, 1, 5, 'rubber')
+ slab(x - 1, y - 1, 22, 22, 45, 5, 'wood')
;(y < 0 ? back : front).push(parts)
}
back.push(slab(-261, -83, 522, 15, 48, 26, 'wood', 4))
back.push(slab(246, -68, 15, 136, 48, 26, 'wood', 3))
front.push(slab(-261, 68, 522, 15, 48, 26, 'wood', 4))
front.push(slab(-261, -68, 15, 136, 48, 26, 'wood', 3))
const frameScrews = [-249, 249].flatMap(x => [-76, 76].map(y => [x, y, 74]))
front.push(screws(frameScrews))
const rails = [-66, 59].map(y => slab(-244, y, 488, 7, 66, 5, 'steel', 1)
+ screws([-228, 224].map(x => [x, y + 3.5, 71]))).join('')
+ [-240, 232].map(x => slab(x, -63, 8, 126, 71, 5, 'rubber')).join('')
// Four weight-bearing rollers plus four side guide wheels, attached to the bed plate.
for (const x of [-122, 75]) for (const y of [-59, 59]) {
carriage.push(slab(x - 7, y - 6, 14, 12, 84, 4, 'steel'))
carriage.push(rod([x, y - 3, 79], [x, y + 3, 79], 8, 'rubber'))
carriage.push(cylinder(x, y > 0 ? y + 5 : y - 5, 4, 77, 5, 'rubber'))
}
carriage.push(slab(-144, -43, 8, 86, 79, 8, 'steel'))
carriage.push(slab(-146, -57, 244, 114, 87, 6, 'steel', 5))
carriage.push(slab(-147, -56, 244, 112, 93, 14, 'upholstery', 8))
carriage.push(seam({ x: -143, y: -52, w: 236, d: 104, r: 7 }, 107))
for (const side of [-1, 1]) {
carriage.push(slab(78, side * 51 - 3, 8, 6, 107, 3, 'steel', 1))
carriage.push(cylinder(82, side * 51, 1.5, 110, 2, 'steel'))
}
// Carriage spring anchor crossmember is under the upholstery, not on top of it.
head.push(slab(44, -23, 52, 46, 107, 7, 'upholstery', 5))
for (const y of [-43, 26]) {
head.push(slab(20, y, 23, 17, 107, 4, 'steel', 3))
head.push(slab(21, y + 1, 21, 15, 111, 24, 'upholstery', 5))
head.push(cylinder(47, y < 0 ? -35 : 35, 2.2, 107, 23, 'steel'))
head.push(k.lineSvg(k.ring(47, y < 0 ? -35 : 35, 7, 130, projection, 24), { tone: 'mid' }))
}
const springBar = slab(-226, -48, 7, 96, 77, 8, 'steel')
+ drawing.springYs.map(y => cylinder(-223, y, 1.8, 85, 3, 'steel')).join('')
const springs = reformerLesson.springs.map((spring, i) => {
const connected = i === 0 || i === 3, path = springPaths(0, i, connected)
return `<g data-spring="${spring.id}" style="--spring-color:${spring.color};opacity:${connected ? 1 : .38}"><path class="spring-shadow" data-coil-back="${i}" d="${path.back}"/><path class="spring-wire" data-coil="${i}" d="${path.front}"/></g>`
}).join('')
const hooks = drawing.springYs.map((_, i) => `<path data-hook="${i}" class="hardware-line" d="${hookPath(0, i, i === 0 || i === 3)}"/>`).join('')
let footbar = ''
for (const y of [-75, 75]) {
footbar += slab(-238, y - 4, 30, 8, 74, 7, 'steel')
+ rod([-226, y, 80], [-194, y, 147], 3.4)
+ rod([-214, y, 82], [-202, y, 130], 2.5)
+ rod([-226, y - 2, 80], [-226, y + 2, 80], 6)
}
footbar += rod([-194, -75, 147], [-194, 75, 147], 6, 'rubber')
let pulleys = ''
for (const y of [-63, 63]) {
pulleys += slab(241, y - 8, 16, 16, 74, 8, 'steel')
+ cylinder(249, y, 3, 82, 67, 'steel')
+ rod([249, y - 3, 151], [249, y + 3, 151], 9, 'rubber')
+ rod([249, y - 4, 151], [249, y + 4, 151], 3, 'steel')
}
const ropes = ropePaths(0).map((d, i) => `<path class="rope" data-rope="${i}" d="${d}"/>`).join('')
const body = `<ellipse class="ground-shadow" cx="375" cy="345" rx="268" ry="46"/>
${wrap('frame', back.join(''), 'data-layer="frame-back"')}
${wrap('rails', rails)}${wrap('pulleys', pulleys)}
<g data-spring-assembly="">${wrap('hooks', springBar, 'data-spring-bar=""')}${wrap('springs', springs)}${wrap('hooks', hooks)}</g>
${wrap('ropes', ropes)}${wrap('carriage', carriage.join(''), 'data-carriage=""')}
${wrap('headrest', head.join(''), 'data-headrest=""')}
${wrap('frame', front.join(''), 'data-layer="frame-front"')}${wrap('footbar', footbar)}`
return `<svg xmlns="http://www.w3.org/2000/svg" class="reformer-svg" viewBox="0 0 760 475" role="img" aria-labelledby="machine-title machine-description"><title id="machine-title">核心床的等轴测结构图</title><desc id="machine-description">脚踏端在左侧,头端在右侧。床板沿两条滑轨移动,弹簧连接床板下方与脚踏端弹簧杆。图形尺寸、行程和绳索外观均为教学示意。</desc>${body}</svg>`
}
export const partsInventory = [
'木质长边框 ×2', '端部横梁 ×2', '支脚 ×4', '防滑脚垫 ×4', '角部承托 ×4',
'滑轨 ×2', '端部限位 ×2', '承重滚轮 ×4', '侧向导轮 ×4', '轮轴支架 ×4',
'床板底托', '软垫', '床板弹簧横梁', '头枕', '肩靠软垫 ×2', '肩靠底座 ×2', '拉环挂柱 ×2', '拉环 ×2',
'弹簧杆', '连接柱 ×5', '弹簧 ×5', '弹簧端环 ×10', '脚踏杆支臂 ×2', '支撑臂 ×2', '铰接轴 ×2', '脚踏软包',
'滑轮立柱底座 ×2', '立柱 ×2', '滑轮 ×2', '滑轮轴 ×2', '绳索 ×2', '固定螺钉',
]

View File

@@ -0,0 +1,7 @@
.reformer-svg{display:block;width:100%;height:100%;overflow:visible;--anatomy-paper:#d6d9d5;--anatomy-top:#e5e8e4;--anatomy-shade-0:#cbd0cb;--anatomy-shade-1:#b6beba;--anatomy-shade-2:#a3ada9;--anatomy-shade-3:#929e99;--anatomy-hi:#55605b;--anatomy-mid:#7c8881;--anatomy-lo:#a3aca3;--anatomy-faint:#b9c0b5;--anatomy-dot:#435047}
.iso-fill{fill:var(--anatomy-paper)}.iso-top{fill:var(--anatomy-top)}.iso-shade[data-shade="0"]{fill:var(--anatomy-shade-0)}.iso-shade[data-shade="1"]{fill:var(--anatomy-shade-1)}.iso-shade[data-shade="2"]{fill:var(--anatomy-shade-2)}.iso-shade[data-shade="3"]{fill:var(--anatomy-shade-3)}
.iso-outline,.iso-crease,.iso-bevel,.iso-line{fill:none;stroke-linecap:round;stroke-linejoin:round}.iso-outline{stroke:var(--anatomy-hi);stroke-width:.65;opacity:.65}.iso-crease{stroke:var(--anatomy-mid);stroke-width:.55;opacity:.5}.iso-bevel{stroke:#fff;stroke-width:.8;opacity:.45}.iso-line{stroke:var(--anatomy-lo);stroke-width:.8}.iso-line[data-tone="faint"]{stroke:var(--anatomy-faint)}.iso-dots circle{fill:var(--anatomy-dot);opacity:.65}
.material-wood{--anatomy-paper:#d6bb97;--anatomy-top:#efdbc0;--anatomy-shade-0:#e4cba8;--anatomy-shade-1:#d6b992;--anatomy-shade-2:#c7a47d;--anatomy-shade-3:#bb986e;--anatomy-hi:#ac8c68;--anatomy-mid:#bda07b}
.material-upholstery{--anatomy-paper:#78867e;--anatomy-top:#87968b;--anatomy-shade-0:#7e8d81;--anatomy-shade-1:#6c7b70;--anatomy-shade-2:#5a6b60;--anatomy-shade-3:#4c5e55;--anatomy-hi:#475d50;--anatomy-mid:#65766a;--anatomy-faint:#a5b0a0}
.material-rubber{--anatomy-paper:#626d66;--anatomy-top:#737d74;--anatomy-shade-0:#646f65;--anatomy-shade-1:#58635b;--anatomy-shade-2:#49574e;--anatomy-shade-3:#3f4d44;--anatomy-hi:#3d4c42}
.ground-shadow{fill:#7f8066;opacity:.10;filter:blur(13px)}.spring-wire,.spring-shadow,.hardware-line,.rope{fill:none;stroke-linecap:round;stroke-linejoin:round}.spring-wire{stroke:var(--spring-color);stroke-width:2}.spring-shadow{stroke:#677269;stroke-width:1;opacity:.6}.hardware-line{stroke:#7a837c;stroke-width:1.2}.rope{stroke:#bbb69e;stroke-width:1.8}.component{cursor:pointer;transition:filter .25s,opacity .25s}.component.is-selected{filter:drop-shadow(0 0 4px #91a281)}.component.is-selected .iso-outline{stroke:#344d36;stroke-width:1.5;opacity:1}.component.is-selected .spring-wire{stroke-width:3}.component.is-selected .rope{stroke:#63794c;stroke-width:3}

View File

@@ -0,0 +1,37 @@
/** Dimensionless teaching model. No real travel, spring rate or action cadence is implied. */
export const motionConfig = Object.freeze({ cycleSeconds: 8, followSeconds: 0.13, explodeSeconds: 0.24 })
export const clamp01 = (value: number) => Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0))
export const positionAt = (progress: number) => (1 - Math.cos(clamp01(progress) * Math.PI * 2)) / 2
export const phaseAt = (progress: number) => progress <= 0 || progress >= 1 ? '起始位置' : progress < 0.5 ? '向外滑动' : progress === 0.5 ? '远端位置' : '控制回程'
export const follow = (value: number, target: number, dt: number, seconds: number) => Math.abs(value - target) < 0.0001 ? target : value + (target - value) * (1 - Math.exp(-Math.max(0, dt) / seconds))
export interface SpringCalibration {
source: string
equipment: string
coach: string
reviewedAt: string
// Measured spring force at actual carriage positions; includes installed preload.
samples: readonly { positionM: number; forceN: number }[]
}
export function forceAt(positionM: number, connected: readonly string[], calibration: Readonly<Record<string, SpringCalibration>>): number | null {
if (!Number.isFinite(positionM) || positionM < 0 || new Set(connected).size !== connected.length || connected.length === 0) return null
let total = 0
for (const id of connected) {
const record = calibration[id]
if (!record?.source.trim() || !record.equipment.trim() || !record.coach.trim() || !record.reviewedAt.trim() || record.samples.length < 2) return null
const samples = record.samples
if (samples.some((s, i) => !Number.isFinite(s.positionM) || s.positionM < 0 || !Number.isFinite(s.forceN) || s.forceN < 0 || (i > 0 && s.positionM <= samples[i - 1].positionM))) return null
if (positionM < samples[0].positionM || positionM > samples[samples.length - 1].positionM) return null
const hi = samples.findIndex(s => s.positionM >= positionM)
if (hi === 0) total += samples[0].forceN
else {
const a = samples[hi - 1], b = samples[hi]
total += a.forceN + (b.forceN - a.forceN) * (positionM - a.positionM) / (b.positionM - a.positionM)
}
}
return total
}
// Deliberately empty. The renderer has no real-world metre scale and never calls forceAt.
export const springCalibration: Readonly<Record<string, SpringCalibration>> = Object.freeze({})

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,36 @@
<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f8f7f2"><meta name="description" content="触摸、拆解、移动,认识 Reformer 核心床的机械结构与弹簧原理。"><title>认识核心床 · Focus Core 探索</title><!-- __THIRD_PARTY_LICENSE__ --><style>/*__CSS__*/</style></head>
<body><main>
<header class="masthead"><div class="brand">FOCUS CORE<span>MOVEMENT LIBRARY</span></div><span class="issue">探索 · 器械篇 001</span></header>
<section class="intro"><div><div class="eyebrow">器械图鉴</div><h1>认识核心床</h1><p>在一推一回之间,读懂阻力。</p></div><span class="edition" aria-hidden="true">01</span></section>
<div class="layout">
<div>
<section class="diagram-panel iso-plate" aria-label="核心床交互图鉴">
<div class="diagram-top"><span class="figure-label">Fig. 01 / Reformer</span><div class="view-toggle interactive" role="group" aria-label="结构视图"><button data-view="assembled" aria-pressed="true">组装</button><button data-view="exploded" aria-pressed="false">分解</button></div></div>
<div class="stage">__FIGURE__</div>
<div class="diagram-foot"><span><i class="legend-dot"></i><span id="view-hint">轻点部件,探索它的作用</span></span><span data-readout>结构与行程均为示意</span></div>
<p class="no-js-note">当前为静态图鉴。下方可阅读全部部件说明;交互需在支持 JavaScript 的浏览器中打开。</p>
</section>
<nav class="part-list interactive" aria-label="选择器械部件">__PART_BUTTONS__</nav>
<section class="part-detail interactive interactive-block" aria-labelledby="part-name"><div class="part-heading"><span id="part-number" class="part-number">01</span><div><h2 id="part-name">滑动床板</h2><div id="part-en" class="english">Carriage</div></div></div><p id="part-description"></p><div class="observation" id="part-observation"></div></section>
<dl class="static-parts">__STATIC_PARTS__</dl>
</div>
<section class="controls interactive interactive-block" aria-label="运动与弹簧控制">
<div class="section-heading"><h2>让原理,动起来</h2><span class="small">TRY IT YOURSELF</span></div>
<div class="position-row"><label for="position">床板位置</label><output id="position-value" class="position-value">0<small>%</small></output></div>
<input id="position" class="range" type="range" min="0" max="100" step="0.1" value="0" aria-label="床板在示意行程中的位置">
<div class="range-ends"><span>靠近脚踏端</span><span>远离脚踏端</span></div>
<div class="playback"><div class="play-row"><button class="play" id="play" aria-label="播放动作"><svg viewBox="0 0 20 20" aria-hidden="true"><path id="play-icon" d="M7 4L16 10L7 16Z"/></svg><span id="play-label">播放演示</span></button><button class="replay" id="replay" aria-label="从头回放"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 7a7 7 0 1 1-1 6M4 2v5h5"/></svg></button><span class="phase" id="phase">起始位置</span></div>
<div class="timeline-row"><input id="timeline" class="range" type="range" min="0" max="100" step="0.1" value="0" aria-label="动作播放进度"><output id="progress-value">0%</output></div><div class="timeline-label">动作进度 · 推出 → 回程 · 节奏仅为示意</div></div>
<section class="spring-section"><div class="section-heading"><h2>换一组弹簧,看看变化</h2><span id="spring-count" class="small">已连接 2 / 5</span></div><div class="spring-buttons" role="group" aria-label="弹簧挂接组合">__SPRING_BUTTONS__</div><div class="spring-presets"><button data-preset="b1">一根蓝簧</button><button data-preset="r1,b1">红+蓝</button><button data-preset="r1,r2,r3,b1,g1">全部连接</button></div>
<div class="spring-readout"><div class="row"><span>已连接弹簧的伸长</span><span id="extension-state">起始长度</span></div><svg class="spring-graphic" viewBox="0 0 280 43" role="img" aria-label="已连接弹簧随床板位移伸长的示意"><path id="spring-mini" d=""/><circle cx="8" cy="22" r="3"/><circle id="spring-mini-end" cx="95" cy="22" r="3"/></svg><div class="row"><span>弹簧拉力</span><span>待实测标定</span></div></div>
<p class="fine">颜色仅对应本期参考机型。未接入真实弹簧参数,不计算阻力数值。组合切换用于理解结构,不作为训练建议。</p></section>
</section>
</div>
<section class="principle"><div class="eyebrow">A LITTLE UNDERSTANDING, A BETTER PRACTICE</div><h2>阻力,藏在弹簧的变化里。</h2><p>床板向外滑动时,已连接的弹簧随之伸长,产生朝向脚踏端的拉力。回程时,这股拉力仍然存在;<strong>控制回程</strong>也是练习的一部分。</p><div class="insight"><span class="leaf" aria-hidden="true">↗</span><div><h3>弹簧更多,不一定更难。</h3><p>弹簧既可能提供阻力,也可能提供辅助。难度还取决于动作、身体位置与控制要求,由教练选择合适组合。</p></div></div></section>
<section class="more"><details><summary>关于本期图鉴</summary><p>参考 __REFERENCE__。五根弹簧以 3 红、1 蓝、1 绿表示;不同品牌的结构与颜色定义可能不同。</p><p>本图为器械原理示意,比例、行程与播放节奏不代表实机设定。实际使用请遵循器械说明与教练指导。</p><div class="source-links">__SOURCES__</div></details><details><summary>查看静态图鉴</summary><p>无法运行动画时,可阅读这张结构图和全部中文部件说明。</p><button id="static-toggle" class="play js-only">切换为静态阅读</button><noscript><p>你当前已在静态阅读模式。</p></noscript></details></section>
<section id="teaching-media" class="more" hidden><h2 id="media-placeholder"></h2><div id="external-media" hidden></div></section>
<footer><span>FOCUS CORE · 慢慢理解,好好练习</span><span>VOL. 001</span></footer>
<div id="announcement" class="sr-only" role="status" aria-live="polite"></div>
</main><script>/*__SCRIPT__*/</script></body></html>

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ryan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,11 @@
# Anatomy
Upstream: https://github.com/wheresryan22/anatomy
Revision: `e9488296a44211c42701ff71705b7901470ec78f` (retrieved 2026-10-18).
`iso-kit.mjs` and `lathe.mjs` are unmodified upstream modules. See `LICENSE` for the MIT terms. The standalone HTML embeds the license as a comment. Only the functions needed by the browser controller are bundled; build-time solid geometry is precomputed.
The implementation follows the Anatomy Figure workflow: a parts inventory, isometric solid primitives, four-tone materials, split mechanism layers, a pure motion model, time-based easing and accessible controls. `partsInventory` is in `../../src/geometry.ts`.
This teaching illustration is not a CAD reproduction. Diagram proportions, assembly separation and playback timing are illustrative. Browser bounds and connection checks do not substitute for a manufacturer's mechanical validation or the required coach review.

View File

@@ -0,0 +1,674 @@
export const AZIMUTH = 45;
export const ELEVATION = 30;
export const SHADES = 4;
const RADIANS = Math.PI / 180;
const UNIT = Math.sqrt(1.6);
export function cameraOf({ scale, azimuth = AZIMUTH, elevation = ELEVATION }) {
const a = azimuth * RADIANS;
const e = elevation * RADIANS;
return { sinA: Math.sin(a), cosA: Math.cos(a), sinE: Math.sin(e), cosE: Math.cos(e), k: scale * UNIT };
}
export function iso([x, y, z], projection) {
const { origin } = projection;
const { sinA, cosA, sinE, cosE, k } = cameraOf(projection);
return [origin[0] + (x * sinA - y * cosA) * k, origin[1] + ((x * cosA + y * sinA) * sinE - z * cosE) * k];
}
export function towardViewer(projection) {
const { sinA, cosA } = cameraOf(projection);
return [cosA, sinA];
}
export function depthOf([x, y, z], projection) {
const { sinA, cosA, sinE, cosE } = cameraOf(projection);
return (x * cosA + y * sinA) * cosE + z * sinE;
}
const tenth = (value) => {
const text = (Math.round(value * 10) / 10).toFixed(1).replace(/\.0$/, "");
return text === "-0" ? "0" : text;
};
export function pathOf(points, closed = false) {
if (!points.length)
return "";
return points.map(([x, y], at) => `${at ? "L" : "M"}${tenth(x)} ${tenth(y)}`).join("") + (closed ? "Z" : "");
}
export const joinPaths = (paths) => paths.filter(Boolean).join("");
export function roundedPlan({ x, y, w, d, r }, steps = 8) {
const radius = Math.max(0, Math.min(r, w / 2, d / 2));
const corners = [
[x + w - radius, y + radius, -90],
[x + w - radius, y + d - radius, 0],
[x + radius, y + d - radius, 90],
[x + radius, y + radius, 180],
];
const out = [];
for (const [cx, cy, start] of corners) {
const count = radius > 0 ? steps : 0;
for (let step = 0; step <= count; step++) {
const angle = (start + (count ? (90 * step) / count : 45)) * RADIANS;
out.push([cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)]);
}
}
return out;
}
function signedArea(points) {
let area = 0;
for (let index = 0; index < points.length; index++) {
const [ax, ay] = points[index];
const [bx, by] = points[(index + 1) % points.length];
area += ax * by - bx * ay;
}
return area / 2;
}
const oriented = (points) => (signedArea(points) < 0 ? [...points].reverse() : points);
function convexHull(points) {
const sorted = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
if (sorted.length < 3)
return sorted;
const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
const lower = [];
for (const point of sorted) {
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], point) <= 0)
lower.pop();
lower.push(point);
}
const upper = [];
for (let index = sorted.length - 1; index >= 0; index--) {
const point = sorted[index];
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], point) <= 0)
upper.pop();
upper.push(point);
}
return [...lower.slice(0, -1), ...upper.slice(0, -1)];
}
function runsOf(flags, want) {
const count = flags.length;
const runs = [];
const start = flags.findIndex((flag, index) => flag !== flags[(index + count - 1) % count]);
if (start < 0)
return flags[0] === want ? [Array.from({ length: count + 1 }, (_, index) => index % count)] : [];
let run = null;
for (let step = 0; step < count; step++) {
const index = (start + step) % count;
if (flags[index] === want) {
if (!run)
run = [index];
run.push((index + 1) % count);
}
else if (run) {
runs.push(run);
run = null;
}
}
if (run)
runs.push(run);
return runs;
}
export function extrude(ring, z, height, projection, options = {}) {
const convex = options.convex ?? true;
const count = ring.length;
const counter = signedArea(ring) > 0;
const [vx, vy] = towardViewer(projection);
const { sinA, cosA } = cameraOf(projection);
const normals = ring.map(([ax, ay], index) => {
const [bx, by] = ring[(index + 1) % count];
const dx = bx - ax;
const dy = by - ay;
const length = Math.hypot(dx, dy) || 1;
return (counter ? [dy / length, -dx / length] : [-dy / length, dx / length]);
});
const facing = normals.map(([nx, ny]) => nx * vx + ny * vy > 1e-9);
const top = ring.map(([x, y]) => iso([x, y, z + height], projection));
const bottom = ring.map(([x, y]) => iso([x, y, z], projection));
const outline = [];
const crease = [];
for (const run of runsOf(facing, false))
outline.push(pathOf(run.map((index) => top[index])));
for (const run of runsOf(facing, true)) {
crease.push(pathOf(run.map((index) => top[index])));
if (height > 0)
outline.push(pathOf(run.map((index) => bottom[index])));
}
if (height > 0) {
for (let index = 0; index < count; index++) {
if (facing[(index + count - 1) % count] !== facing[index])
outline.push(pathOf([top[index], bottom[index]]));
}
}
let fill;
if (convex) {
fill = pathOf(convexHull([...top, ...bottom]), true);
}
else {
const parts = [pathOf(oriented(top), true), pathOf(oriented(bottom), true)];
for (let index = 0; index < count; index++) {
if (!facing[index])
continue;
const next = (index + 1) % count;
parts.push(pathOf(oriented([top[index], top[next], bottom[next], bottom[index]]), true));
}
fill = parts.join("");
}
const buckets = Array.from({ length: SHADES }, () => []);
if (height > 0) {
const [lx, ly] = [-sinA, cosA];
for (let index = 0; index < count; index++) {
if (!facing[index])
continue;
const [nx, ny] = normals[index];
const lit = (nx * lx + ny * ly + 1) / 2;
const shade = Math.min(SHADES - 1, Math.max(0, Math.floor(lit * SHADES)));
const next = (index + 1) % count;
buckets[shade].push(pathOf(oriented([top[index], top[next], bottom[next], bottom[index]]), true));
}
}
let bevel;
if (options.bevel && options.bevel > 0) {
const by = options.bevel;
const inset = ring.map(([x, y], index) => {
const [ax, ay] = normals[(index + count - 1) % count];
const [bx, by2] = normals[index];
const mx = ax + bx;
const my = ay + by2;
const length = Math.hypot(mx, my) || 1;
return [x - (mx / length) * by, y - (my / length) * by];
});
const insetTop = inset.map(([x, y]) => iso([x, y, z + height], projection));
bevel = runsOf(facing, true)
.map((run) => pathOf([top[run[0]], ...run.slice(1, -1).map((index) => insetTop[index]), top[run[run.length - 1]]]))
.join("");
}
return {
fill,
outline: outline.join(""),
crease: crease.join(""),
top: pathOf(oriented(top), true),
shades: buckets.map((paths) => paths.join("")),
bevel,
};
}
export const ringOf = (plan, steps = 8) => roundedPlan(plan, steps);
export function slabOf(plan, z, height, projection, steps = 8, bevel = 0) {
return extrude(ringOf(plan, steps), z, height, projection, { convex: true, bevel });
}
export function circleRing(cx, cy, radius, steps = 40) {
return Array.from({ length: steps }, (_, step) => {
const angle = (step / steps) * Math.PI * 2;
return [cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)];
});
}
export function cylinder(cx, cy, radius, z, height, projection, steps = 32, bevel = 0) {
return extrude(circleRing(cx, cy, radius, steps), z, height, projection, { convex: true, bevel });
}
export const insetPlan = ({ x, y, w, d, r }, by) => ({ x: x + by, y: y + by, w: w - by * 2, d: d - by * 2, r: Math.max(0, r - by) });
export function onTop(points, z, projection, closed = false) {
return pathOf(points.map(([x, y]) => iso([x, y, z], projection)), closed);
}
export const planOutline = (plan, z, projection, steps = 8) => onTop(ringOf(plan, steps), z, projection, true);
export const lineOnTop = (from, to, z, projection) => onTop([from, to], z, projection);
export const segment = (from, to, projection) => pathOf([iso(from, projection), iso(to, projection)]);
export const rise = (at, from, to, projection) => segment([at[0], at[1], from], [at[0], at[1], to], projection);
export const ring = (cx, cy, radius, z, projection, steps = 40) => onTop(circleRing(cx, cy, radius, steps), z, projection, true);
export const pointsAt = (points, projection) => points.map((point) => iso(point, projection));
export function dotGrid(x, y, columns, rows, gap, z, projection) {
const dots = [];
for (let row = 0; row < rows; row++) {
for (let column = 0; column < columns; column++)
dots.push(iso([x + column * gap, y + row * gap, z], projection));
}
return dots;
}
export function corners({ x, y, w, d }, inset = 0) {
return [
[x + inset, y + inset],
[x + w - inset, y + inset],
[x + inset, y + d - inset],
[x + w - inset, y + d - inset],
];
}
export function boxCorners({ x, y, w, d }, z0, z1) {
const out = [];
for (const px of [x, x + w])
for (const py of [y, y + d])
for (const z of [z0, z1])
out.push([px, py, z]);
return out;
}
export function knurl(cx, cy, radius, z0, z1, count, projection, inset = 0.4) {
const [vx, vy] = towardViewer(projection);
const lines = [];
for (let index = 0; index < count; index++) {
const angle = (index / count) * Math.PI * 2;
const nx = Math.cos(angle);
const ny = Math.sin(angle);
if (nx * vx + ny * vy < 0.15)
continue;
lines.push(segment([cx + radius * nx, cy + radius * ny, z0 + inset], [cx + radius * nx, cy + radius * ny, z1 - inset], projection));
}
return lines.join("");
}
export function sideArc(cx, cy, radius, z, projection, steps = 24) {
const [vx, vy] = towardViewer(projection);
const facing = Math.atan2(vy, vx);
const points = [];
for (let step = 0; step <= steps; step++) {
const angle = facing - Math.PI / 2 + (step / steps) * Math.PI;
points.push(iso([cx + radius * Math.cos(angle), cy + radius * Math.sin(angle), z], projection));
}
return pathOf(points);
}
export function ringSeam(ring, z, projection) {
const count = ring.length;
const counter = signedArea(ring) > 0;
const [vx, vy] = towardViewer(projection);
const facing = ring.map(([ax, ay], index) => {
const [bx, by] = ring[(index + 1) % count];
const dx = bx - ax;
const dy = by - ay;
const [nx, ny] = counter ? [dy, -dx] : [-dy, dx];
return nx * vx + ny * vy > 1e-9;
});
return runsOf(facing, true).map((run) => pathOf(run.map((index) => iso([ring[index][0], ring[index][1], z], projection)))).join("");
}
export const sideSeam = (plan, z, projection, steps = 8) => ringSeam(ringOf(plan, steps), z, projection);
export function sideRing(center, radius, axis, projection, steps = 20) {
const [px, py, pz] = center;
const points = [];
for (let step = 0; step < steps; step++) {
const angle = (step / steps) * Math.PI * 2;
const u = radius * Math.cos(angle);
const v = radius * Math.sin(angle);
points.push(iso(axis === "x" ? [px, py + u, pz + v] : [px + u, py, pz + v], projection));
}
return pathOf(points, true);
}
export function coil(from, to, center, radius, turns, axis, projection, perTurn = 16) {
const count = Math.max(1, Math.round(turns * perTurn));
const back = [];
const front = [];
let run = [];
let near = null;
for (let index = 0; index <= count; index++) {
const share = index / count;
const angle = share * turns * Math.PI * 2;
const u = radius * Math.cos(angle);
const v = radius * Math.sin(angle);
const along = from + (to - from) * share;
const point = axis === "x" ? [along, center[0] + u, center[1] + v] : [center[0] + u, along, center[1] + v];
const normal = axis === "x" ? [0, Math.cos(angle), Math.sin(angle)] : [Math.cos(angle), 0, Math.sin(angle)];
const facing = depthOf(normal, projection) > 0;
const flat = iso(point, projection);
if (near === null)
near = facing;
if (facing !== near) {
run.push(flat);
(near ? front : back).push(pathOf(run));
run = [flat];
near = facing;
continue;
}
run.push(flat);
}
if (run.length > 1)
(near ? front : back).push(pathOf(run));
return { back: back.join(""), front: front.join("") };
}
export function topTicks(from, to, step, every, edge, z, lengths, projection, axis = "x") {
const minor = [];
const major = [];
const first = Math.ceil(from / step) * step;
for (let at = first, index = Math.round(first / step); at <= to + 1e-9; at += step, index++) {
const big = index % every === 0;
const length = big ? lengths[1] : lengths[0];
const a = axis === "x" ? [at, edge] : [edge, at];
const b = axis === "x" ? [at, edge + length] : [edge + length, at];
(big ? major : minor).push(lineOnTop(a, b, z, projection));
}
return { minor: minor.join(""), major: major.join("") };
}
export function sideTicks(from, to, step, every, face, top, lengths, projection, axis = "x") {
const minor = [];
const major = [];
const first = Math.ceil(from / step) * step;
for (let at = first, index = Math.round(first / step); at <= to + 1e-9; at += step, index++) {
const big = index % every === 0;
const length = big ? lengths[1] : lengths[0];
const a = axis === "x" ? [at, face, top] : [face, at, top];
const b = axis === "x" ? [at, face, top - length] : [face, at, top - length];
(big ? major : minor).push(segment(a, b, projection));
}
return { minor: minor.join(""), major: major.join("") };
}
export function radialTicks(cx, cy, radius, count, every, z, lengths, projection, from = 0, span = Math.PI * 2) {
const minor = [];
const major = [];
const closed = Math.abs(span - Math.PI * 2) < 1e-9;
const steps = closed ? count : count + 1;
for (let index = 0; index < steps; index++) {
const angle = from + (index / count) * span;
const big = index % every === 0;
const inner = radius - (big ? lengths[1] : lengths[0]);
const sx = Math.sin(angle);
const sy = -Math.cos(angle);
(big ? major : minor).push(lineOnTop([cx + sx * inner, cy + sy * inner], [cx + sx * radius, cy + sy * radius], z, projection));
}
return { minor: minor.join(""), major: major.join("") };
}
export const haloOf = (plan, z, height, projection) => slabOf(plan, z, height, projection).fill;
export function axisVector(projection, [dx, dy, dz]) {
const origin = iso([0, 0, 0], projection);
const moved = iso([dx, dy, dz], projection);
return [moved[0] - origin[0], moved[1] - origin[1]];
}
const fine = (value) => Math.round(value * 100) / 100;
export function translateAlong(projection, by) {
const [x, y] = axisVector(projection, by);
return `translate(${fine(x)} ${fine(y)})`;
}
const thousandth = (value) => Math.round(value * 1000) / 1000;
function axesAt(at, z, projection) {
const origin = iso([at[0], at[1], z], projection);
const along = (point) => {
const [px, py] = iso(point, projection);
return [px - origin[0], py - origin[1]];
};
return { origin, x: along([at[0] + 1, at[1], z]), y: along([at[0], at[1] + 1, z]), z: along([at[0], at[1], z + 1]) };
}
const matrixOf = (u, v, origin) => `matrix(${thousandth(u[0])} ${thousandth(u[1])} ${thousandth(v[0])} ${thousandth(v[1])} ${thousandth(origin[0])} ${thousandth(origin[1])})`;
export function holdStill(element, [dx, dy]) {
const text = dx || dy ? `translate(${-dx} ${-dy})` : "";
element.querySelectorAll("[data-hold]").forEach((node) => (text ? node.setAttribute("transform", text) : node.removeAttribute("transform")));
}
export function topMatrix(at, z, projection, along = "x") {
const { origin, x, y } = axesAt(at, z, projection);
return along === "x" ? matrixOf(x, y, origin) : matrixOf([-y[0], -y[1]], x, origin);
}
export function sideMatrix(at, z, projection, face) {
const { origin, x, y, z: up } = axesAt(at, z, projection);
const down = [-up[0], -up[1]];
return face === "left" ? matrixOf(x, down, origin) : matrixOf([-y[0], -y[1]], down, origin);
}
export function screenRise(height, projection) {
const { cosE, k } = cameraOf(projection);
return height * cosE * k;
}
export function fitProjection(points, width, height, { pad = 24, headroom = 0, azimuth = AZIMUTH, elevation = ELEVATION } = {}) {
const unit = { origin: [0, 0], scale: 1, azimuth, elevation };
const flat = points.map((point) => iso(point, unit));
const xs = flat.map(([x]) => x);
const ys = flat.map(([, y]) => y);
const left = Math.min(...xs);
const right = Math.max(...xs);
const up = Math.min(...ys);
const down = Math.max(...ys);
const scale = Math.min((width - pad * 2) / (right - left), (height - pad * 2 - headroom) / (down - up));
return {
origin: [width / 2 - ((left + right) / 2) * scale, headroom + (height - headroom) / 2 - ((up + down) / 2) * scale],
scale,
azimuth,
elevation,
};
}
const escape = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
const attrs = (pairs) => Object.entries(pairs)
.filter(([, value]) => value !== undefined && value !== false)
.map(([key, value]) => ` ${key}="${escape(String(value))}"`)
.join("");
export function solidSvg(paths, { tone = "hi", crease = "faint", halo = false, lit = false, flat = false, className, inner = "" } = {}) {
const parts = [];
if (halo)
parts.push(`<path class="iso-halo" d="${paths.fill}"/>`);
parts.push(`<path class="iso-fill" d="${paths.fill}"/>`);
if (!flat) {
paths.shades.forEach((d, shade) => {
if (d)
parts.push(`<path class="iso-shade" data-shade="${shade}" d="${d}"/>`);
});
parts.push(`<path class="iso-top" d="${paths.top}"/>`);
}
if (crease !== "none" && paths.crease)
parts.push(`<path class="iso-line iso-crease" data-tone="${crease}" d="${paths.crease}"/>`);
if (crease !== "none" && paths.bevel)
parts.push(`<path class="iso-line iso-bevel" data-tone="${crease}" d="${paths.bevel}"/>`);
if (inner)
parts.push(inner);
parts.push(`<path class="iso-line iso-edge" data-tone="${tone}" d="${paths.outline}"/>`);
return `<g${attrs({ class: className ? `iso-solid ${className}` : "iso-solid", "data-lit": lit ? "" : undefined, "data-seamless": paths.seamless ? "" : undefined })}>${parts.join("")}</g>`;
}
export function lineSvg(d, { tone = "lo", dotted = false, dashed = false, flow = false, className, free } = {}) {
if (!d)
return "";
return `<path${attrs({
class: className ? `iso-line ${className}` : "iso-line",
"data-tone": tone,
"data-free": free === true ? "" : free || undefined,
"data-dotted": dotted ? "" : undefined,
"data-dashed": dashed ? "" : undefined,
"data-flow": flow ? "" : undefined,
d,
})}/>`;
}
export function dotsSvg(points, { size = 0.5, tone = "mid", pulse = false } = {}) {
const dots = points
.map(([x, y, alpha], index) => `<circle cx="${tenth(x)}" cy="${tenth(y)}" r="${size}"${alpha !== undefined && alpha < 1 ? ` fill-opacity="${Math.max(0, alpha).toFixed(2)}"` : ""}${pulse ? ` data-pulse="" style="--pulse-at:${(index * 137) % 1600}ms"` : ""}/>`)
.join("");
return `<g class="iso-dots" data-tone="${tone}">${dots}</g>`;
}
export function fadedSvg(list, style = {}) {
const groups = new Map();
for (const entry of list) {
const [d, alpha] = Array.isArray(entry) ? entry : [entry.d, entry.alpha];
if (!d || !(alpha > 0.02))
continue;
const level = Math.min(1, Math.round(alpha * 20) / 20);
groups.set(level, (groups.get(level) ?? "") + d);
}
return [...groups].map(([alpha, d]) => (alpha >= 1 ? lineSvg(d, style) : `<g opacity="${alpha.toFixed(2)}">${lineSvg(d, style)}</g>`)).join("");
}
export function fadeLineSvg(points, { fade = [0, 0], tone = "lo", className, stops = 6 } = {}) {
if (points.length < 2)
return "";
const ease = (t) => t * t * (3 - 2 * t);
const hashOf = (text) => {
let hash = 2166136261;
for (let index = 0; index < text.length; index++)
hash = Math.imul(hash ^ text.charCodeAt(index), 16777619);
return (hash >>> 0).toString(36);
};
const lengths = [0];
for (let index = 1; index < points.length; index++)
lengths.push(lengths[index - 1] + Math.hypot(points[index][0] - points[index - 1][0], points[index][1] - points[index - 1][1]));
const at = (length) => {
let index = 1;
while (index < points.length - 1 && lengths[index] < length)
index++;
const span = lengths[index] - lengths[index - 1] || 1;
const t = Math.max(0, Math.min(1, (length - lengths[index - 1]) / span));
return [points[index - 1][0] + (points[index][0] - points[index - 1][0]) * t, points[index - 1][1] + (points[index][1] - points[index - 1][1]) * t];
};
const pieceOf = (from, to) => [at(from), ...points.filter((_, index) => lengths[index] > from + 1e-6 && lengths[index] < to - 1e-6), at(to)];
const total = lengths[lengths.length - 1];
if (total < 1e-6)
return "";
let [start, end] = Array.isArray(fade) ? fade : [fade, fade];
if (start + end > total) {
const share = total / (start + end);
start *= share;
end *= share;
}
const paint = `var(--anatomy-${tone})`;
const ramp = (piece, rising) => {
const d = pathOf(piece);
const [x1, y1] = piece[0];
const [x2, y2] = piece[piece.length - 1];
const id = `iso-fade-${hashOf(`${d}|${tone}|${rising}`)}`;
const marks = Array.from({ length: stops + 1 }, (_, index) => {
const t = index / stops;
const alpha = ease(rising ? t : 1 - t);
return `<stop offset="${t.toFixed(3)}" stop-opacity="${alpha.toFixed(3)}" style="stop-color:${paint}"/>`;
}).join("");
const gradient = `<linearGradient id="${id}" gradientUnits="userSpaceOnUse" x1="${tenth(x1)}" y1="${tenth(y1)}" x2="${tenth(x2)}" y2="${tenth(y2)}">${marks}</linearGradient>`;
return `<defs>${gradient}</defs><path${attrs({ class: className ? `iso-line ${className}` : "iso-line", "data-tone": tone, "data-free": rising ? "start" : "end", d, style: `stroke:url(#${id})` })}/>`;
};
const parts = [];
if (start > 0.05)
parts.push(ramp(pieceOf(0, start), true));
if (total - end - start > 0.05)
parts.push(lineSvg(pathOf(pieceOf(start, total - end)), { tone, className }));
if (end > 0.05)
parts.push(ramp(pieceOf(total - end, total), false));
return `<g class="iso-fade">${parts.join("")}</g>`;
}
export function wireSvg(from, to, { via = [], tone = "lo", dotted = false, dashed = false, flow = false, ends = "both", size = 1.2 } = {}) {
const d = pathOf([from, ...via, to]);
const end = ([x, y]) => `<circle class="iso-wire-end" cx="${tenth(x)}" cy="${tenth(y)}" r="${size}"/>`;
return `<g class="iso-wire" data-tone="${tone}">${lineSvg(d, { tone, dotted, dashed, flow })}${ends === "both" || ends === "from" ? end(from) : ""}${ends === "both" || ends === "to" ? end(to) : ""}</g>`;
}
export function faceTextSvg(transform, text, { size = 3, tone = "mid", anchor = "start" } = {}) {
return `<text class="iso-face-text" data-tone="${tone}" transform="${transform}" font-size="${size}" text-anchor="${anchor}">${escape(text)}</text>`;
}
export function groupSvg(children, extra = {}) {
return `<g${attrs(extra)}>${Array.isArray(children) ? children.join("") : children}</g>`;
}
export function figureSvg({ width, height, label, body, standalone = false, className }) {
const style = standalone ? `<style>${ISO_CSS}</style>` : "";
return `<svg${attrs({
xmlns: "http://www.w3.org/2000/svg",
class: `iso-svg${standalone ? " iso" : ""}${className ? ` ${className}` : ""}`,
"data-theme": standalone || undefined,
viewBox: `0 0 ${width} ${height}`,
role: "img",
"aria-label": label,
})}>${style}${Array.isArray(body) ? body.join("") : body}</svg>`;
}
function swatchSvg(mark) {
if (mark === "raised" || mark === "flat" || mark === "lit") {
const body = mark === "flat" ? "M2 6.5 8 3.5 14 6.5 8 9.5Z" : "M2 4.5 8 1.5 14 4.5 14 6 8 9 2 6Z";
const crease = mark === "flat" ? "" : `<path class="iso-swatch-crease" d="M2 4.5 8 7.5 14 4.5"/>`;
return `<svg class="iso-swatch" data-mark="${mark}" viewBox="0 0 16 10" aria-hidden="true"><path d="${body}"/>${crease}</svg>`;
}
return `<svg class="iso-swatch" data-mark="${mark}" viewBox="0 0 16 10" aria-hidden="true"><path d="M1.5 5h13"/></svg>`;
}
export function plateHtml({ fig, title, hint, readout = "", keys = [], caption, body }) {
const corner = (name, text, extra = "") => `<span class="iso-plate-corner" data-corner="${name}"${extra}>${escape(text)}</span>`;
const legend = keys.length || caption
? `<figcaption class="iso-legend">${keys.length ? `<span class="iso-keys">${keys.map((key) => `<span class="iso-key">${swatchSvg(key.mark)}${escape(key.label)}</span>`).join("")}</span>` : ""}${caption ? `<span class="iso-caption">${escape(caption)}</span>` : ""}</figcaption>`
: "";
return `<figure class="iso-figure"><div class="iso-plate"><div class="iso-plate-corners" aria-hidden="true">${corner("fig", fig)}${title ? corner("title", title) : ""}${hint ? corner("hint", hint) : ""}${corner("readout", readout, " data-readout")}</div>${body}</div>${legend}</figure>`;
}
export function pageHtml({ title, theme = "dark", body, script = "", width = 650 }) {
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escape(title)}</title><style>${ISO_CSS}${PAGE_CSS.replace("__WIDTH__", String(width))}</style></head><body class="iso" data-theme="${theme}"><main class="iso-page">${body}</main>${script ? `<script type="module">${script.replace(/<\/(script)/gi, "<\\/$1")}</script>` : ""}</body></html>`;
}
const PAGE_CSS = `
*{box-sizing:border-box}
html{color-scheme:dark}
body[data-theme="light"]{color-scheme:light}
body{margin:0;background:var(--anatomy-page);color:var(--anatomy-text);font:14px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;-webkit-font-smoothing:antialiased}
.iso-page{width:min(__WIDTH__px,100% - 32px);margin:64px auto 96px}
`;
export const ISO_CSS = `
.iso{
--anatomy-page:#0b0b0b;--anatomy-card:#131313;--anatomy-text:#a3a3a3;--anatomy-strong:#e6e6e6;--anatomy-muted:#6e6e6e;
--anatomy-paper:#131313;--anatomy-face:#1b1b1b;--anatomy-top:#1c1c1c;
--anatomy-shade-0:#0f0f0f;--anatomy-shade-1:#121212;--anatomy-shade-2:#151515;--anatomy-shade-3:#181818;
--anatomy-lit-top:#262626;--anatomy-lit-shade:#1d1d1d;
--anatomy-hi:#d4d4d4;--anatomy-mid:#6a6a6a;--anatomy-lo:#3e3e3e;--anatomy-faint:#2a2a2a;--anatomy-lit:#ffffff;
--anatomy-halo:#050505;--anatomy-halo-opacity:.9;--anatomy-dot:#e6e6e6;
--anatomy-red:#ff6b6b;--anatomy-green:#6be0a0;--anatomy-blue:#6b9bff;
--anatomy-weight:.6px;--anatomy-ease:cubic-bezier(.32,.72,0,1);
--anatomy-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
--anatomy-lift:inset 0 1px 0 0 #ffffff0a,inset 0 0 0 1px #ffffff06,0 0 0 1px #00000026,0 1px 1px -.5px #00000014,0 3px 3px -1.5px #00000014,0 6px 6px -3px #00000014,0 12px 12px -6px #00000014;
}
.iso[data-theme="light"]{
--anatomy-page:#f2f2ef;--anatomy-card:#fbfbfa;--anatomy-text:#57575a;--anatomy-strong:#18181a;--anatomy-muted:#8c8c90;
--anatomy-paper:#fbfbfa;--anatomy-face:#ffffff;--anatomy-top:#ffffff;
--anatomy-shade-0:#dcdcd9;--anatomy-shade-1:#e5e5e2;--anatomy-shade-2:#ededea;--anatomy-shade-3:#f4f4f1;
--anatomy-lit-top:#ffffff;--anatomy-lit-shade:#eeeeeb;
--anatomy-hi:#26262a;--anatomy-mid:#8a8a8f;--anatomy-lo:#b4b4b8;--anatomy-faint:#d4d4d6;--anatomy-lit:#000000;
--anatomy-halo:#8a8a80;--anatomy-halo-opacity:.32;--anatomy-dot:#3a3a3e;
--anatomy-red:#e5484d;--anatomy-green:#30a46c;--anatomy-blue:#3e63dd;
--anatomy-lift:inset 0 1px 0 0 #ffffff,0 0 0 1px #0000000f,0 1px 1px -.5px #0000000a,0 3px 3px -1.5px #0000000a,0 6px 6px -3px #0000000a,0 12px 12px -6px #0000000a;
}
.iso-svg{display:block;width:100%;height:auto;overflow:visible}
.iso-svg path,.iso-svg circle{vector-effect:non-scaling-stroke}
.iso-fill{fill:var(--anatomy-paper);stroke:none}
.iso-solid[data-lit]>.iso-fill{fill:var(--anatomy-face)}
.iso-shade,.iso-top{stroke:none}
.iso-shade[data-shade="0"]{fill:var(--anatomy-shade-0)}
.iso-shade[data-shade="1"]{fill:var(--anatomy-shade-1)}
.iso-shade[data-shade="2"]{fill:var(--anatomy-shade-2)}
.iso-shade[data-shade="3"]{fill:var(--anatomy-shade-3)}
.iso-top{fill:var(--anatomy-top)}
.iso-solid[data-lit]>.iso-top{fill:var(--anatomy-lit-top)}
.iso-solid[data-lit]>.iso-shade{fill:var(--anatomy-lit-shade)}
.iso-solid[data-seamless]>.iso-shade,.iso-solid[data-seamless]>.iso-top{stroke-width:.4;stroke-linejoin:round}
.iso-solid[data-seamless]>.iso-shade[data-shade="0"]{stroke:var(--anatomy-shade-0)}
.iso-solid[data-seamless]>.iso-shade[data-shade="1"]{stroke:var(--anatomy-shade-1)}
.iso-solid[data-seamless]>.iso-shade[data-shade="2"]{stroke:var(--anatomy-shade-2)}
.iso-solid[data-seamless]>.iso-shade[data-shade="3"]{stroke:var(--anatomy-shade-3)}
.iso-solid[data-seamless]>.iso-top{stroke:var(--anatomy-top)}
.iso-solid[data-seamless][data-lit]>.iso-top{stroke:var(--anatomy-lit-top)}
.iso-solid[data-seamless][data-lit]>.iso-shade{stroke:var(--anatomy-lit-shade)}
.iso-solid[data-lit]>.iso-edge{stroke:var(--anatomy-lit)}
.iso-halo{fill:var(--anatomy-halo);stroke:none;filter:blur(9px);opacity:var(--anatomy-halo-opacity);transform:translateY(3px)}
.iso-line{fill:none;stroke:var(--anatomy-lo);stroke-width:var(--anatomy-weight);stroke-linecap:round;stroke-linejoin:round}
.iso-line[data-tone="hi"]{stroke:var(--anatomy-hi)}
.iso-line[data-tone="mid"]{stroke:var(--anatomy-mid)}
.iso-line[data-tone="lo"]{stroke:var(--anatomy-lo)}
.iso-line[data-tone="faint"]{stroke:var(--anatomy-faint)}
.iso-line[data-tone="lit"]{stroke:var(--anatomy-lit)}
.iso-line[data-tone="red"]{stroke:var(--anatomy-red)}
.iso-line[data-tone="green"]{stroke:var(--anatomy-green)}
.iso-line[data-tone="blue"]{stroke:var(--anatomy-blue)}
.iso-line[data-dotted]{stroke-dasharray:.01 3.5;stroke-width:1}
.iso-line[data-dashed]{stroke-dasharray:3 3}
.iso-line[data-flow]{stroke-dasharray:2 6}
.iso-wire-end{fill:var(--anatomy-paper);stroke:var(--anatomy-lo);stroke-width:var(--anatomy-weight)}
.iso-wire[data-tone="hi"] .iso-wire-end{stroke:var(--anatomy-hi)}
.iso-wire[data-tone="mid"] .iso-wire-end{stroke:var(--anatomy-mid)}
.iso-wire[data-tone="faint"] .iso-wire-end{stroke:var(--anatomy-faint)}
.iso-wire[data-tone="lit"] .iso-wire-end{stroke:var(--anatomy-lit)}
.iso-dots circle{fill:var(--anatomy-dot);stroke:none;opacity:.32}
.iso-dots[data-tone="hi"] circle{opacity:.85}
.iso-dots[data-tone="mid"] circle{opacity:.45}
.iso-dots[data-tone="lo"] circle{opacity:.22}
.iso-dots[data-tone="faint"] circle{opacity:.12}
.iso-face-text{fill:var(--anatomy-mid);font-family:var(--anatomy-mono);letter-spacing:.04em}
.iso-face-text[data-tone="hi"]{fill:var(--anatomy-hi)}
.iso-face-text[data-tone="lo"]{fill:var(--anatomy-lo)}
.iso-face-text[data-tone="faint"]{fill:var(--anatomy-faint)}
.tb-body{fill:var(--anatomy-shade-2);stroke:none}
.tb-shine{fill:var(--anatomy-top);stroke:none}
.tb-shade{fill:var(--anatomy-shade-0);stroke:none}
.tb-ring{fill:none;stroke:var(--anatomy-lo);stroke-width:.45;stroke-linecap:butt;stroke-linejoin:round}
.tb-ring[data-tone="mid"]{stroke:var(--anatomy-mid)}
.tb-ring[data-tone="faint"]{stroke:var(--anatomy-faint)}
.tb-ring[data-tone="hi"]{stroke:var(--anatomy-hi)}
@keyframes iso-flow{to{stroke-dashoffset:-32}}
@keyframes iso-pulse{0%,100%{opacity:.18}12%{opacity:1}40%{opacity:.18}}
.iso-line[data-flow]{animation:iso-flow 1600ms linear infinite}
.iso-dots circle[data-pulse]{animation:iso-pulse 1600ms steps(1,end) infinite;animation-delay:var(--pulse-at,0ms)}
.iso-figure{margin:32px 0 36px}
.iso-plate{position:relative;padding:52px 24px;border-radius:0;background:var(--anatomy-card);box-shadow:0 0 0 1px var(--anatomy-mid);--anatomy-paper:var(--anatomy-card)}
.iso-plate-corners{position:absolute;inset:18px 20px;pointer-events:none}
.iso-plate-corner{position:absolute;color:var(--anatomy-muted);font-family:var(--anatomy-mono);font-size:10.5px;line-height:1;letter-spacing:.04em;white-space:nowrap}
.iso-plate-corner[data-corner="fig"]{top:0;left:0;color:var(--anatomy-text)}
.iso-plate-corner[data-corner="title"]{top:0;right:0;text-transform:uppercase;letter-spacing:.1em}
.iso-plate-corner[data-corner="hint"]{bottom:0;left:0;text-transform:uppercase;letter-spacing:.1em}
.iso-plate-corner[data-corner="readout"]{right:0;bottom:0;color:var(--anatomy-text);font-variant-numeric:tabular-nums}
.iso-plate .iso-svg{margin:0 auto}
.iso-legend{display:grid;gap:8px;margin-top:12px;color:var(--anatomy-muted);font-size:13px;line-height:1.55}
.iso-keys{display:flex;flex-wrap:wrap;gap:6px 18px}
.iso-key{display:inline-flex;align-items:center;gap:7px}
.iso-swatch{width:16px;height:10px;flex:none;overflow:visible}
.iso-swatch path{fill:none;stroke:var(--anatomy-hi);stroke-width:1;vector-effect:non-scaling-stroke;stroke-linejoin:round;stroke-linecap:round}
.iso-swatch[data-mark="raised"] path,.iso-swatch[data-mark="flat"] path{stroke:var(--anatomy-mid)}
.iso-swatch[data-mark="lit"] path{stroke:var(--anatomy-lit)}
.iso-swatch[data-mark="dim"] path{stroke:var(--anatomy-lo)}
.iso-swatch[data-mark="dotted"] path{stroke-dasharray:.01 3;stroke-width:1.4}
.iso-swatch[data-mark="dashed"] path{stroke-dasharray:3 2.5}
.iso-swatch[data-mark="red"] path{stroke:var(--anatomy-red)}
.iso-swatch[data-mark="green"] path{stroke:var(--anatomy-green)}
.iso-swatch[data-mark="blue"] path{stroke:var(--anatomy-blue)}
.iso-swatch-crease{stroke:var(--anatomy-lo)!important}
@media (max-width:560px){.iso-plate{padding:48px 12px 70px}.iso-plate-corner[data-corner="hint"]{bottom:18px}}
@media (prefers-reduced-motion:reduce){.iso-line[data-flow],.iso-dots circle[data-pulse]{animation:none}}
`;

File diff suppressed because it is too large Load Diff

View File

@@ -5,8 +5,11 @@
"dev:server": "pnpm --filter @mp-pilates/server dev", "dev:server": "pnpm --filter @mp-pilates/server dev",
"dev:app": "pnpm --filter @mp-pilates/app dev:mp-weixin", "dev:app": "pnpm --filter @mp-pilates/app dev:mp-weixin",
"build:shared": "pnpm --filter @mp-pilates/shared build", "build:shared": "pnpm --filter @mp-pilates/shared build",
"build:server": "pnpm --filter @mp-pilates/server build", "build:education": "node education/reformer/build.mjs",
"build:app": "pnpm --filter @mp-pilates/app build:mp-weixin", "build:education:release": "node education/reformer/build.mjs --release",
"check:education": "node education/reformer/check-model.mjs && node education/reformer/check-browser.mjs",
"build:server": "pnpm build:education && pnpm --filter @mp-pilates/server build",
"build:app": "pnpm build:education && pnpm --filter @mp-pilates/app build:mp-weixin",
"test": "pnpm -r test", "test": "pnpm -r test",
"lint": "pnpm -r lint", "lint": "pnpm -r lint",
"deploy:server": "bash deploy-server.sh" "deploy:server": "bash deploy-server.sh"
@@ -23,5 +26,8 @@
"prisma" "prisma"
] ]
}, },
"version": "0.0.2" "version": "0.0.2",
"devDependencies": {
"esbuild": "0.20.2"
}
} }

View File

@@ -0,0 +1,2 @@
/** Same HTTPS host as the existing API; served outside /api by Nest's static middleware. */
export const REFORMER_LESSON_URL = 'https://focus.richarjiang.com/learn/reformer/'

View File

@@ -0,0 +1,22 @@
/** Add new published topics here; the Explore page renders the catalogue in order. */
export interface ExploreEntry {
id: string
title: string
summary: string
category: string
format: string
cover: string
path: string
}
export const exploreEntries: readonly ExploreEntry[] = [
{
id: 'reformer',
title: '认识核心床',
summary: '在一推一回之间,读懂阻力。',
category: '器械图鉴',
format: '交互探索',
cover: '/static/learn/reformer.png',
path: '/pages/learn/reformer',
},
]

View File

@@ -0,0 +1,34 @@
// Reference configuration, not a prescription for the studio's own equipment.
export const reformerLesson = {
title: '认识核心床',
subtitle: '在一推一回之间,读懂阻力。',
reference: 'Balanced Body Studio Reformer · 五弹簧参考配置',
// Internal provenance only; editorial metadata is never displayed as a product badge.
review: { status: 'pending' as 'pending' | 'approved', coach: '', reviewedAt: '', revision: '2026-10-18.2' },
springs: [
{ id: 'r1', label: '红 1', color: '#b56b61', level: '中阻力' },
{ id: 'r2', label: '红 2', color: '#b56b61', level: '中阻力' },
{ id: 'r3', label: '红 3', color: '#b56b61', level: '中阻力' },
{ id: 'b1', label: '蓝', color: '#668c9d', level: '轻阻力' },
{ id: 'g1', label: '绿', color: '#7b8e6e', level: '重阻力' },
],
sources: [
{ title: 'Balanced Body · Studio Reformer 产品资料', url: 'https://www.pilates.com/products/pilates-studio-reformer' },
{ title: 'Balanced Body · Reformer 结构与原理', url: 'https://www.pilates.com/ultimate-guide-to-the-pilates-reformer/' },
{ title: 'Studio Reformer / Tower 厂家装配手册', url: 'https://balancedbody.eu/wp-content/uploads/2022/01/Studio-Reformer-with-tower-Unpacking-and-Assembly.pdf' },
],
} as const
export const reformerParts = [
{ id: 'carriage', name: '滑动床板', en: 'Carriage', number: '01', description: '软垫床板通过底部滚轮沿滑轨移动。肩靠与头枕随床板一起运动,弹簧连接在床板下方。', observation: '拖动下方滑块,看床板与弹簧如何一起变化。' },
{ id: 'springs', name: '弹簧组', en: 'Springs', number: '02', description: '弹簧连接床板与脚踏端的弹簧杆。床板远离脚踏端时,已连接的弹簧被拉长;回程时,弹簧产生拉回趋势。', observation: '切到分解视图,更容易看见床板下方的弹簧。' },
{ id: 'hooks', name: '连接挂钩', en: 'Spring connections', number: '03', description: '连接件把弹簧一端固定在床板下方,另一端挂接脚踏端的弹簧杆。是否挂接,决定这根弹簧是否参与受力。', observation: '未挂接的弹簧随床板收纳,不会继续连接固定端。' },
{ id: 'frame', name: '主框架', en: 'Frame', number: '04', description: '木质或金属框架承托器械,并为滑轨、脚踏杆和滑轮立柱提供固定位置。框架在床板移动时保持不动。', observation: '这幅图参考木质框架机型,不代表所有品牌的结构。' },
{ id: 'rails', name: '滑轨与滚轮', en: 'Rails & wheels', number: '05', description: '两侧滑轨引导床板的运动方向。床板下方的承重轮与侧向导轮共同支撑并引导床板平稳移动。', observation: '滑轨负责导向,弹簧负责提供弹性拉力。' },
{ id: 'footbar', name: '脚踏杆', en: 'Footbar', number: '06', description: '为手或脚提供支撑点,使用者可借此推动床板。实际高度、角度和锁定方式随器械型号不同。', observation: '此处固定角度展示结构,实际设置由教练确认。' },
{ id: 'ropes', name: '绳索与拉环', en: 'Ropes & loops', number: '07', description: '绳索一端固定或调节于床板,经头端滑轮转向后连接拉环,将手脚的拉动传递给床板。', observation: '本图拉环处于收纳状态,随床板移动而自然松弛。' },
{ id: 'pulleys', name: '滑轮', en: 'Pulleys', number: '08', description: '位于头端立柱上的滑轮改变绳索方向,让拉环可从头端方向向使用者传力。它本身不是阻力来源。', observation: '图中绳路为结构示意,不模拟具体动作的传动比。' },
{ id: 'headrest', name: '头枕与肩靠', en: 'Headrest & shoulder rests', number: '09', description: '头枕提供头部支撑,肩靠帮助定位。两者安装在滑动床板上。头枕位置需按动作与个人情况由教练调整。', observation: '此处仅展示平放头枕,不演示人体姿势。' },
] as const
export type ReformerPartId = typeof reformerParts[number]['id']

View File

@@ -33,6 +33,18 @@
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{
"path": "pages/learn/index",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/learn/reformer",
"style": { "navigationBarTitleText": "认识核心床", "navigationBarBackgroundColor": "#f8f7f2" }
},
{
"path": "pages/learn/reformer-static",
"style": { "navigationStyle": "custom" }
},
{ {
"path": "pages/card/detail", "path": "pages/card/detail",
"style": { "style": {
@@ -346,6 +358,12 @@
"iconPath": "static/tab/booking.png", "iconPath": "static/tab/booking.png",
"selectedIconPath": "static/tab/booking-active.png" "selectedIconPath": "static/tab/booking-active.png"
}, },
{
"pagePath": "pages/learn/index",
"text": "探索",
"iconPath": "static/tab/learn.png",
"selectedIconPath": "static/tab/learn-active.png"
},
{ {
"pagePath": "pages/profile/index", "pagePath": "pages/profile/index",
"text": "个人中心", "text": "个人中心",

View File

@@ -0,0 +1,86 @@
<template>
<view class="learn-page" :style="{ paddingTop: navBarHeight + 'px' }">
<CustomNavBar title="探索" />
<view class="content">
<view class="intro">
<text class="eyebrow">FOCUS CORE · JOURNAL</text>
<text class="title">读懂每一次练习。</text>
<text class="subtitle">关于器械、动作与身体的小小发现。</text>
</view>
<view class="library">
<button v-for="entry in exploreEntries" :key="entry.id" class="lesson-card"
hover-class="lesson-card--pressed" :aria-label="entry.title + ',' + entry.format" @tap="openEntry(entry)">
<view class="cover"><image class="cover-image" :src="entry.cover" mode="aspectFit" /></view>
<view class="card-body">
<view class="card-meta"><text>{{ entry.category }}</text><text class="meta-dot">·</text><text>{{ entry.format }}</text></view>
<view class="card-heading"><text class="card-title">{{ entry.title }}</text><text class="card-arrow" aria-hidden="true">↗</text></view>
<text class="card-summary">{{ entry.summary }}</text>
</view>
</button>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { onShareAppMessage } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { exploreEntries, type ExploreEntry } from '../../content/explore'
const { navBarHeight } = getSystemLayout()
function openEntry(entry: ExploreEntry) { uni.navigateTo({ url: entry.path }) }
onShareAppMessage(() => ({ title: '探索 | 读懂每一次练习 · Focus Core', path: '/pages/learn/index' }))
</script>
<style lang="scss" scoped>
.learn-page {
min-height: 100vh;
box-sizing: border-box;
background: #f8f7f2;
color: #374438;
}
.content { padding: 44rpx 40rpx 64rpx; }
.intro { padding: 18rpx 0 48rpx; }
.eyebrow {
display: block;
font: 19rpx Baskerville, 'Times New Roman', serif;
letter-spacing: 3rpx;
color: #98a18e;
}
.title {
display: block;
margin-top: 23rpx;
font-family: 'Songti SC', STSong, serif;
font-size: 45rpx;
line-height: 1.55;
letter-spacing: 2rpx;
}
.subtitle { display: block; margin-top: 16rpx; font-size: 23rpx; line-height: 1.8; color: #929a87; }
.library { display: flex; flex-direction: column; gap: 32rpx; }
.lesson-card {
display: block;
width: 100%;
margin: 0;
padding: 0;
overflow: hidden;
border: 1rpx solid #e1e6d8;
border-radius: 25rpx;
background: #fffefa;
text-align: left;
line-height: normal;
transition: opacity .16s;
&::after { border: 0; }
&--pressed { opacity: .75; }
}
.cover { height: 270rpx; background: #eef1e6; }
.cover-image { display: block; width: 100%; height: 100%; }
.card-body { padding: 28rpx 30rpx 32rpx; }
.card-meta { display: flex; align-items: center; gap: 12rpx; color: #909b80; font-size: 18rpx; letter-spacing: 1rpx; }
.meta-dot { color: #b8c2a9; }
.card-heading { display: flex; align-items: center; justify-content: space-between; gap: 20rpx; margin-top: 17rpx; }
.card-title { font: 33rpx/1.5 'Songti SC', STSong, serif; letter-spacing: 2rpx; }
.card-arrow { flex-shrink: 0; font-size: 30rpx; color: #7f9170; line-height: 1; }
.card-summary { display: block; margin-top: 11rpx; font-size: 23rpx; line-height: 1.8; color: #929b88; }
@media (prefers-reduced-motion: reduce) { .lesson-card { transition: none; } }
</style>

View File

@@ -0,0 +1,22 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight + 'px' }">
<CustomNavBar title="核心床图鉴" show-back />
<view class="content"><text class="eyebrow">FIG. 01 / THE REFORMER</text><text class="title">认识核心床</text><text class="subtitle">{{ reformerLesson.subtitle }}</text><image class="illustration" src="/static/learn/reformer.png" mode="aspectFit" /><text class="diagram-note">器械结构与行程为示意,实际设置请遵循实机说明与教练指导。</text>
<view v-for="part in reformerParts" :key="part.id" class="part"><view class="heading"><text class="number">{{ part.number }}</text><view><text class="part-title">{{ part.name }}</text><text class="english">{{ part.en }}</text></view></view><text class="description">{{ part.description }}</text><text class="observation">↳ {{ part.observation }}</text></view>
<view class="note"><text class="note-title">弹簧既提供阻力,也可能提供辅助。</text><text class="description">组合的选择需要考虑动作与身体位置,由教练确认。本期未接入真实弹簧参数,不计算阻力数值。</text></view>
<view class="sources"><text class="source-title">资料来源</text><text class="description">{{ reformerLesson.reference }}。本图不代表所有品牌的结构。</text><view v-for="source in reformerLesson.sources" :key="source.url" class="source" @tap="copySource(source.url)"><text>{{ source.title }}</text><text>复制链接 ↗</text></view></view>
</view>
</view>
</template>
<script setup lang="ts">
import { onShareAppMessage } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { reformerLesson, reformerParts } from '../../content/reformer'
const { navBarHeight } = getSystemLayout()
function copySource(url: string) { uni.setClipboardData({ data: url }) }
onShareAppMessage(() => ({ title: '认识核心床 | Focus Core 静态图鉴', path: '/pages/learn/reformer-static' }))
</script>
<style scoped>
.page{min-height:100vh;background:#f8f7f2;color:#44533c;box-sizing:border-box}.content{padding:48rpx 42rpx 70rpx}.eyebrow{font-size:20rpx;letter-spacing:3rpx;color:#8d9a7c}.title{display:block;font-family:'Songti SC',STSong,serif;font-size:47rpx;letter-spacing:4rpx;margin:23rpx 0 20rpx}.subtitle{font-size:24rpx;color:#8a957e}.illustration{width:100%;height:410rpx;background:#f0f2e8;border-radius:25rpx;margin:35rpx 0 24rpx}.diagram-note{display:block;font-size:21rpx;line-height:1.9;color:#a0a68f}.part{padding:36rpx 0;border-bottom:1rpx solid #e0e5d8}.heading{display:flex;align-items:center;gap:24rpx;margin-bottom:22rpx}.number{font:italic 55rpx Baskerville,serif;color:#b0bda0}.part-title{font-size:29rpx;display:block;letter-spacing:2rpx}.english{font:italic 23rpx Baskerville,serif;color:#98a28c;margin-top:8rpx;display:block}.description{display:block;font-size:24rpx;line-height:1.95;color:#858f79}.observation{display:block;font-size:21rpx;line-height:1.9;color:#9ba58e;margin-top:16rpx}.note{padding:30rpx;border-radius:22rpx;background:#eaf0e1;margin-top:38rpx}.note-title{display:block;font-size:26rpx;margin-bottom:18rpx}.sources{margin-top:48rpx}.source-title{display:block;font-size:27rpx;margin-bottom:20rpx}.source{display:flex;justify-content:space-between;align-items:center;gap:20rpx;padding:26rpx 0;border-bottom:1rpx solid #e0e5d8;font-size:20rpx;line-height:1.9;color:#879478}.source text:last-child{flex-shrink:0;color:#a1ab92}
</style>

View File

@@ -0,0 +1,35 @@
<template>
<web-view v-if="!loading && !failed" :src="REFORMER_LESSON_URL" @error="failed = true" />
<view v-else class="fallback">
<text class="eyebrow">FOCUS CORE · 器械图鉴</text><text class="title">认识核心床</text>
<image class="illustration" src="/static/learn/reformer.png" mode="aspectFit" />
<text class="message">{{ loading ? '正在准备交互图鉴…也可以先阅读静态图鉴。' : '交互页面暂时无法打开。你仍可阅读完整的静态图鉴。' }}</text>
<button class="primary" @tap="openStatic">阅读静态图鉴</button><button v-if="!loading" class="retry" @tap="prepare">重新加载</button>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad, onUnload, onShareAppMessage } from '@dcloudio/uni-app'
import { REFORMER_LESSON_URL } from '../../config/education'
const failed = ref(false), loading = ref(true)
let probe: UniApp.RequestTask | undefined
function prepare() {
loading.value = true; failed.value = false
// A deployed 404 is a successful web-view load. Check status before opening it.
probe = uni.request({
url: REFORMER_LESSON_URL,
method: 'HEAD',
timeout: 8000,
success: result => { failed.value = result.statusCode < 200 || result.statusCode >= 300 },
fail: () => { failed.value = true },
complete: () => { loading.value = false },
})
}
onLoad(prepare)
onUnload(() => probe?.abort())
function openStatic() { uni.redirectTo({ url: '/pages/learn/reformer-static' }) }
onShareAppMessage(() => ({ title: '认识核心床 | Focus Core 器械图鉴', path: '/pages/learn/index' }))
</script>
<style scoped>
.fallback{min-height:100vh;padding:60rpx 42rpx;background:#f8f7f2;box-sizing:border-box;color:#4b5d43}.eyebrow{font-size:20rpx;letter-spacing:3rpx;color:#899679}.title{display:block;font-family:'Songti SC',STSong,serif;font-size:46rpx;margin-top:26rpx}.illustration{width:100%;height:420rpx}.message{display:block;line-height:1.9;font-size:26rpx;color:#8a927e}.primary,.retry{margin-top:32rpx;font-size:26rpx;border-radius:50rpx;line-height:88rpx}.primary{background:#566c4b;color:#fffdf4}.retry{background:transparent;color:#869478}.retry::after{border:0}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 162 KiB

View File

@@ -1,4 +1,6 @@
import { NestFactory } from '@nestjs/core' import { NestFactory } from '@nestjs/core'
import { NestExpressApplication } from '@nestjs/platform-express'
import { join } from 'path'
import { Logger, ValidationPipe } from '@nestjs/common' import { Logger, ValidationPipe } from '@nestjs/common'
import { ConfigService } from '@nestjs/config' import { ConfigService } from '@nestjs/config'
import { WinstonModule } from 'nest-winston' import { WinstonModule } from 'nest-winston'
@@ -11,9 +13,20 @@ import { ApiExceptionFilter } from './common/filters/api-exception.filter'
async function bootstrap() { async function bootstrap() {
const logger = WinstonModule.createLogger(loggerConfig) const logger = WinstonModule.createLogger(loggerConfig)
const app = await NestFactory.create(AppModule, { logger }) const app = await NestFactory.create<NestExpressApplication>(AppModule, { logger })
const configService = app.get(ConfigService) const configService = app.get(ConfigService)
// Public, dependency-free educational pages; keep them outside the JSON API prefix.
app.useStaticAssets(join(__dirname, '..', 'public', 'learn'), {
prefix: '/learn/',
index: 'index.html',
maxAge: 0,
setHeaders: (response) => {
response.setHeader('X-Content-Type-Options', 'nosniff')
response.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
},
})
app.setGlobalPrefix('api') app.setGlobalPrefix('api')
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({

6
pnpm-lock.yaml generated
View File

@@ -6,7 +6,11 @@ settings:
importers: importers:
.: {} .:
devDependencies:
esbuild:
specifier: 0.20.2
version: 0.20.2
packages/app: packages/app:
dependencies: dependencies: