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