feat: 支持教学图鉴

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

View File

@@ -0,0 +1,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)