feat: 支持教学图鉴

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff