feat: 支持教学图鉴
This commit is contained in:
151
docs/reformer-education.md
Normal file
151
docs/reformer-education.md
Normal 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 人体模型。
|
||||||
40
education/reformer/build.mjs
Normal file
40
education/reformer/build.mjs
Normal 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 }) }
|
||||||
156
education/reformer/check-browser.mjs
Normal file
156
education/reformer/check-browser.mjs
Normal 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 })
|
||||||
|
}
|
||||||
44
education/reformer/check-model.mjs
Normal file
44
education/reformer/check-model.mjs
Normal 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 }) }
|
||||||
189
education/reformer/src/controller.ts
Normal file
189
education/reformer/src/controller.ts
Normal 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)
|
||||||
156
education/reformer/src/geometry.ts
Normal file
156
education/reformer/src/geometry.ts
Normal 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', '固定螺钉',
|
||||||
|
]
|
||||||
7
education/reformer/src/illustration.css
Normal file
7
education/reformer/src/illustration.css
Normal 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}
|
||||||
37
education/reformer/src/model.ts
Normal file
37
education/reformer/src/model.ts
Normal 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({})
|
||||||
8
education/reformer/src/page.css
Normal file
8
education/reformer/src/page.css
Normal file
File diff suppressed because one or more lines are too long
36
education/reformer/src/page.html
Normal file
36
education/reformer/src/page.html
Normal 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>
|
||||||
21
education/reformer/vendor/anatomy/LICENSE
vendored
Normal file
21
education/reformer/vendor/anatomy/LICENSE
vendored
Normal 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.
|
||||||
11
education/reformer/vendor/anatomy/NOTICE.md
vendored
Normal file
11
education/reformer/vendor/anatomy/NOTICE.md
vendored
Normal 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.
|
||||||
674
education/reformer/vendor/anatomy/iso-kit.mjs
vendored
Normal file
674
education/reformer/vendor/anatomy/iso-kit.mjs
vendored
Normal 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
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}}
|
||||||
|
`;
|
||||||
1184
education/reformer/vendor/anatomy/lathe.mjs
vendored
Normal file
1184
education/reformer/vendor/anatomy/lathe.mjs
vendored
Normal file
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
packages/app/src/config/education.ts
Normal file
2
packages/app/src/config/education.ts
Normal 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/'
|
||||||
22
packages/app/src/content/explore.ts
Normal file
22
packages/app/src/content/explore.ts
Normal 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',
|
||||||
|
},
|
||||||
|
]
|
||||||
34
packages/app/src/content/reformer.ts
Normal file
34
packages/app/src/content/reformer.ts
Normal 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']
|
||||||
@@ -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": "个人中心",
|
||||||
|
|||||||
86
packages/app/src/pages/learn/index.vue
Normal file
86
packages/app/src/pages/learn/index.vue
Normal 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>
|
||||||
22
packages/app/src/pages/learn/reformer-static.vue
Normal file
22
packages/app/src/pages/learn/reformer-static.vue
Normal 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>
|
||||||
35
packages/app/src/pages/learn/reformer.vue
Normal file
35
packages/app/src/pages/learn/reformer.vue
Normal 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>
|
||||||
BIN
packages/app/src/static/learn/reformer.png
Normal file
BIN
packages/app/src/static/learn/reformer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 240 KiB |
BIN
packages/app/src/static/tab/learn-active.png
Normal file
BIN
packages/app/src/static/tab/learn-active.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
BIN
packages/app/src/static/tab/learn.png
Normal file
BIN
packages/app/src/static/tab/learn.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
81
packages/server/public/learn/reformer/index.html
Normal file
81
packages/server/public/learn/reformer/index.html
Normal file
File diff suppressed because one or more lines are too long
14
packages/server/public/learn/reformer/reformer.svg
Normal file
14
packages/server/public/learn/reformer/reformer.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 162 KiB |
@@ -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
6
pnpm-lock.yaml
generated
@@ -6,7 +6,11 @@ settings:
|
|||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.: {}
|
.:
|
||||||
|
devDependencies:
|
||||||
|
esbuild:
|
||||||
|
specifier: 0.20.2
|
||||||
|
version: 0.20.2
|
||||||
|
|
||||||
packages/app:
|
packages/app:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
Reference in New Issue
Block a user