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 $ = (selector: string) => { const node = document.querySelector(selector) if (!node) throw new Error(`Missing lesson element: ${selector}`) return node } const positionSlider = $('#position'), timeline = $('#timeline') const stage = $('.stage'), play = $('#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('[data-component]')] const coils = drawing.springYs.map((_, i) => $(`[data-coil="${i}"]`)) const coilBacks = drawing.springYs.map((_, i) => $(`[data-coil-back="${i}"]`)) const hooks = drawing.springYs.map((_, i) => $(`[data-hook="${i}"]`)) const ropeNodes = [0, 1].map(i => $(`[data-rope="${i}"]`)) const carriage = $('[data-carriage]'), headrest = $('[data-headrest]') const rails = $('[data-component="rails"]'), springBar = $('[data-spring-bar]') const footbar = $('[data-component="footbar"]'), pulleys = $('[data-component="pulleys"]') const readout = $('[data-readout]'), positionValue = $('#position-value'), phase = $('#phase'), progressValue = $('#progress-value') const mini = $('#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}%` 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('[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('[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('[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('[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('[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('[data-part-button]').forEach(el => el.addEventListener('click', () => { interact(); stop(); state.target = state.progress; selectPart(el.dataset.partButton as ReformerPartId) })) document.querySelectorAll('[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('[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('[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)