feat: 支持教学图鉴
This commit is contained in:
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
Reference in New Issue
Block a user