import * as animation from "./animation.js"; import * as danceLibrary from "./danceLibrary.js"; import * as interpreter from "./interpreter.js"; import * as renderer from "./renderer.js"; import { DancerIdentity } from "./danceCommon.js"; import { LibFigureDance, LibFigureMove, Move } from "./libfigureMapper.js"; import { animateLowLevelMove, LowLevelMove } from "./lowLevelMove.js"; import { figureBeats, figureToHtml } from "./libfigure/define-figure.js"; import { labelForBeats } from "./libfigure/dance.js"; import { setDistance } from "./rendererConstants.js"; const body = document.querySelector('body')!; const wrapperDiv = document.createElement('div'); const canvasDiv = document.createElement('div'); canvasDiv.id = 'canvasDiv'; const canvas = document.createElement('canvas'); for (const str of ["left", "right", "top", "bottom"]) { const hallLabel = document.createElement('span'); hallLabel.innerText = str; hallLabel.classList.add(str); hallLabel.classList.add('hallLabel'); canvasDiv.appendChild(hallLabel); } canvasDiv.appendChild(canvas); wrapperDiv.appendChild(canvasDiv); body.appendChild(wrapperDiv); const beatSliderLabel = document.createElement('label'); beatSliderLabel.innerText = 'Beat: '; const beatSlider = document.createElement('input'); beatSlider.type = 'range'; beatSlider.min = '0'; beatSlider.max = '64'; beatSlider.step = 'any'; beatSlider.value = '0'; beatSliderLabel.appendChild(beatSlider); const beatDisplay = document.createElement('span'); beatDisplay.className = 'beatDisplay'; beatDisplay.innerText = '0.0'; const ctx = canvas.getContext('2d', { alpha: false })!; const r = new renderer.Renderer(canvas, ctx); const defaultSetSizeInPx = canvas.offsetHeight / 3; type ResetCanvasSetting = { extraSets?: number; extraLines?: number; setSizeInPx?: number; zoom?: number; }; function resetCanvas({ extraSets, extraLines, setSizeInPx, zoom }: ResetCanvasSetting) { ctx.resetTransform(); extraLines ??= 0; extraSets ??= 0; // TODO Redo this on resize? const numSets = (1 + 2 * extraSets); setSizeInPx = zoom ? zoom * defaultSetSizeInPx : (setSizeInPx ?? defaultSetSizeInPx); const w = setSizeInPx * (1 + 2 * extraLines); const h = setSizeInPx * numSets; const s = window.devicePixelRatio; canvasDiv.style.width = w + 'px'; canvasDiv.style.height = h + 'px'; canvas.width = w * s; canvas.height = h * s; ctx.translate(canvas.width / 2.0, canvas.height / 2.0); const scale = numSets * setDistance; ctx.scale(canvas.height / scale, -canvas.height / scale); r.extraLines = Math.ceil(extraLines) + 1; r.extraSets = Math.ceil(extraSets) + 1; if (r.animation) { drawAtCurrentBeat(); } else { r.clear(); } } let canvasSetting: ResetCanvasSetting = { extraLines: 0.6, extraSets: 0.8, zoom: 1, }; function updateCanvasSettings(arg : ResetCanvasSetting) { if (arg.extraLines !== undefined) canvasSetting.extraLines = arg.extraLines; if (arg.extraSets !== undefined) canvasSetting.extraSets = arg.extraSets; if (arg.zoom) canvasSetting.zoom = arg.zoom; resetCanvas(canvasSetting); } updateCanvasSettings({}); const debugRender = document.createElement('input'); debugRender.type = 'checkbox'; debugRender.checked = r.drawDebug; const debugRenderLabel = document.createElement('label'); debugRenderLabel.htmlFor = debugRender.id = 'debugRender'; debugRenderLabel.innerText = 'Debug display'; const bpmSelector = document.createElement('input'); bpmSelector.type = 'number'; bpmSelector.value = '180'; bpmSelector.step = '20'; bpmSelector.id = 'bpm'; bpmSelector.style.width = '4em'; const bpmLabel = document.createElement('label'); bpmLabel.innerText = 'BPM'; bpmLabel.htmlFor = 'bpm'; const progressionSelector = document.createElement('input'); progressionSelector.type = 'number'; progressionSelector.value = '0'; progressionSelector.id = 'progression'; progressionSelector.style.width = '4em'; const progressionLabel = document.createElement('label'); progressionLabel.innerText = 'Progression'; progressionLabel.htmlFor = 'progression'; const autoProgress = document.createElement('input'); autoProgress.type = 'checkbox'; autoProgress.checked = true; const autoProgressLabel = document.createElement('label'); autoProgressLabel.htmlFor = autoProgress.id = 'autoProgress'; autoProgressLabel.innerText = 'Loop playback'; const playButton = document.createElement('button'); playButton.innerText = "Play"; const danceTitle = document.createElement('h1'); const movesList = document.createElement('ul'); wrapperDiv.appendChild(danceTitle); wrapperDiv.appendChild(movesList); wrapperDiv.appendChild(document.createElement('br')); wrapperDiv.appendChild(bpmSelector); wrapperDiv.appendChild(bpmLabel); wrapperDiv.appendChild(playButton); wrapperDiv.appendChild(autoProgress); wrapperDiv.appendChild(autoProgressLabel); wrapperDiv.appendChild(document.createElement('br')); wrapperDiv.appendChild(progressionSelector); wrapperDiv.appendChild(progressionLabel); wrapperDiv.appendChild(document.createElement('br')); wrapperDiv.appendChild(beatSliderLabel); wrapperDiv.appendChild(beatDisplay); function drawAtCurrentBeat() { r.drawSetsWithTrails(beatSlider.valueAsNumber, progressionSelector.valueAsNumber); const moveForCurrent = movesByBeat.at(Math.floor(beatSlider.valueAsNumber)); if (!moveForCurrent?.classList.contains('currentMove') ?? false) { for (let i = 0; i < movesByBeat.length; i++) { if (movesByBeat[i].classList.contains('currentMove')) { movesByBeat[i].classList.remove('currentMove'); } } moveForCurrent?.classList.add('currentMove'); } } debugRender.addEventListener('change', (ev) => { r.drawDebug = debugRender.checked; drawAtCurrentBeat(); restartAnimation(false); }) beatSlider.addEventListener('input', (ev) => { drawAtCurrentBeat(); beatDisplay.innerText = beatSlider.valueAsNumber.toFixed(1); restartAnimation(false); }); progressionSelector.addEventListener('input', (ev) => { drawAtCurrentBeat(); restartAnimation(false); }); let cancelAnim : number | undefined = undefined; function restartAnimation(startIfPaused: boolean) { if (!r.animation) return; if (cancelAnim !== undefined) { cancelAnimationFrame(cancelAnim); } else if (!startIfPaused) { return; } const bpm = parseFloat(bpmSelector.value); if (bpm < 0) { playAnimation( bpmSelector.valueAsNumber, beatSlider.value === '0' ? r.animation.numBeats : beatSlider.valueAsNumber, 0); } else if (bpm > 0) { playAnimation( bpmSelector.valueAsNumber, beatSlider.value === beatSlider.max ? 0 : beatSlider.valueAsNumber, r.animation.numBeats); } } playButton.addEventListener('click', (ev) => { if (cancelAnim !== undefined) { cancelAnimationFrame(cancelAnim); cancelAnim = undefined; playButton.innerText = 'Play'; } else { restartAnimation(true); } }); bpmSelector.addEventListener('change', (ev) => { restartAnimation(false); }); // Copied from my (Daniel Perelman's) dialect settings on contradb. const dialect = { "moves": { "gyre": "%S shoulder round" }, "dancers": { "ladle": "robin", "ladles": "robins", "first ladle": "first robin", "second ladle": "second robin", "gentlespoon": "lark", "gentlespoons": "larks", "first gentlespoon": "first lark", "second gentlespoon": "second lark" }, "text_in_dialect": true }; let dance: danceLibrary.ContraDBDance; let movesByBeat: HTMLLIElement[] = []; function buildMovesList() { if (!r.animation) return; removeAllChildNodes(movesList); movesByBeat = []; const formation = document.createElement('li'); formation.innerText = 'Starting formation: ' + dance.start_type; formation.className = 'formation'; movesList.appendChild(formation); let currentBeat: number = 0; let lastItem: HTMLLIElement | undefined; for (const figure of dance.figures) { const startBeat = currentBeat; currentBeat += figureBeats(figure); const moveItem = document.createElement('li'); let figureHtml: string; try { figureHtml = figureToHtml(figure, dialect); } catch (e) { figureHtml = "libfigureError: " + e; } const beatsDescription = "[" + labelForBeats(Math.floor(startBeat / 16) * 16) + " " + "(" + startBeat + "-" + currentBeat + ")] "; const figureDescription = document.createElement('span'); figureDescription.innerHTML = beatsDescription + figureHtml; for (const { label, beat } of [ { label: '<|', beat: startBeat }, { label: '+1', beat: startBeat + 1 }, { label: '½', beat: (startBeat + currentBeat) / 2 }, { label: '-1', beat: currentBeat - 1 }, { label: '|>', beat: currentBeat }, ]) { const jumpToButton = document.createElement('button'); jumpToButton.innerText = label; jumpToButton.addEventListener('click', (ev) => { setBeat(beat); restartAnimation(false); }); moveItem.appendChild(jumpToButton); } moveItem.appendChild(figureDescription); moveItem.classList.add('move'); for (let beat = startBeat; beat < currentBeat; beat++) { moveItem.classList.add('moveForBeat_' + beat); movesByBeat[beat] = moveItem; } if (startBeat === 0) moveItem.classList.add('currentMove'); figureDescription.addEventListener('click', (ev) => { setBeat(startBeat); restartAnimation(false); }); lastItem = moveItem; movesList.appendChild(moveItem); } if (lastItem) { lastItem.classList.add('moveForBeat_' + currentBeat); movesByBeat[currentBeat] = lastItem; } if (r.animation.progressionError) { const error = document.createElement('li'); error.classList.add('error'); error.classList.add('progressionError'); error.innerText = 'Dance progression does not work; dance was probably interpreted wrong: ' + r.animation.progressionError; movesList.appendChild(error); } const interpreterError = [...interpreter.interpretedDance.values()].flatMap(moves => moves.filter(m => m.interpreterError !== undefined).map(m => m.interpreterError)).at(0); if (interpreterError) { const error = document.createElement('li'); error.classList.add('error'); error.classList.add('interpreterError'); error.innerText = 'Interpreter failed on some move, see debug information below for more information: ' + interpreterError; movesList.appendChild(error); } } function setBeat(beat: number) { beatSlider.value = beat.toString(); beatDisplay.innerText = beat.toFixed(1); drawAtCurrentBeat(); } function playAnimation(bpm: number, start: number, end: number) { const startTime = Date.now(); const msPerBeat = (60 * 1000) / bpm; function anim() { if (!r.animation) return; const now = Date.now(); const msElapsed = now - startTime; let beat = start + msElapsed / msPerBeat; let changedProgression = false; function doEnd() { setBeat(end); cancelAnim = undefined; playButton.innerText = 'Play'; } if (bpm > 0 && beat > end) { if (autoProgress.checked) { beat -= r.animation.numBeats; progressionSelector.valueAsNumber++; changedProgression = true; } else { doEnd(); return; } } if (bpm < 0 && beat < end) { if (autoProgress.checked) { beat += r.animation.numBeats; progressionSelector.valueAsNumber--; changedProgression = true; } else { doEnd(); return; } } setBeat(beat); if (changedProgression) { restartAnimation(true); return; } cancelAnim = requestAnimationFrame(anim); playButton.innerText = 'Pause'; } anim(); } const displaySettingsDiv = document.createElement('div'); displaySettingsDiv.id = 'displaySettings'; displaySettingsDiv.style.margin = '1em'; const zoomSelector = document.createElement('input'); zoomSelector.type = 'number'; zoomSelector.min = '10'; zoomSelector.step = '10'; zoomSelector.value = '100'; zoomSelector.id = 'zoom'; zoomSelector.style.width = '4em'; zoomSelector.addEventListener('input', (ev) => { updateCanvasSettings({ zoom: zoomSelector.valueAsNumber / 100 }); }) const zoomLabel = document.createElement('label'); zoomLabel.innerText = '% zoom'; zoomLabel.htmlFor = 'zoom'; displaySettingsDiv.appendChild(zoomSelector); displaySettingsDiv.appendChild(zoomLabel); const extraSetsSelector = document.createElement('input'); extraSetsSelector.type = 'number'; extraSetsSelector.min = '0'; extraSetsSelector.step = '0.2'; extraSetsSelector.value = canvasSetting.extraSets!.toPrecision(1); extraSetsSelector.id = 'extraSets'; extraSetsSelector.style.width = '3em'; extraSetsSelector.addEventListener('input', (ev) => { updateCanvasSettings({ extraSets: extraSetsSelector.valueAsNumber }); }) const extraSetsLabel = document.createElement('label'); extraSetsLabel.innerText = '# extra sets: '; extraSetsLabel.htmlFor = 'extraSets'; displaySettingsDiv.appendChild(document.createElement('br')); displaySettingsDiv.appendChild(extraSetsLabel); displaySettingsDiv.appendChild(extraSetsSelector); const extraLinesSelector = document.createElement('input'); extraLinesSelector.type = 'number'; extraLinesSelector.min = '0'; extraLinesSelector.step = '0.2'; extraLinesSelector.value = canvasSetting.extraLines!.toPrecision(1); extraLinesSelector.id = 'extraLines'; extraLinesSelector.style.width = '3em'; extraLinesSelector.addEventListener('input', (ev) => { updateCanvasSettings({ extraLines: extraLinesSelector.valueAsNumber }); }) const extraLinesLabel = document.createElement('label'); extraLinesLabel.innerText = '# extra lines: '; extraLinesLabel.htmlFor = 'extraLines'; displaySettingsDiv.appendChild(document.createElement('br')); displaySettingsDiv.appendChild(extraLinesLabel); displaySettingsDiv.appendChild(extraLinesSelector); const trailIncrementsSelector = document.createElement('input'); trailIncrementsSelector.type = 'number'; trailIncrementsSelector.min = '0'; trailIncrementsSelector.step = '1'; trailIncrementsSelector.value = r.trailIncrements!.toString(); trailIncrementsSelector.id = 'trailIncrements'; trailIncrementsSelector.style.width = '3em'; trailIncrementsSelector.addEventListener('input', (ev) => { r.trailIncrements = trailIncrementsSelector.valueAsNumber; drawAtCurrentBeat(); restartAnimation(false); }) const trailIncrementsLabel = document.createElement('label'); trailIncrementsLabel.innerText = '# trails (faded previous positions): '; trailIncrementsLabel.htmlFor = 'trailIncrements'; displaySettingsDiv.appendChild(document.createElement('br')); displaySettingsDiv.appendChild(trailIncrementsLabel); displaySettingsDiv.appendChild(trailIncrementsSelector); const trailBeatsSelector = document.createElement('input'); trailBeatsSelector.type = 'number'; trailBeatsSelector.min = '0'; trailBeatsSelector.step = '0.1'; trailBeatsSelector.value = r.trailLengthInBeats!.toPrecision(1); trailBeatsSelector.id = 'trailBeats'; trailBeatsSelector.style.width = '3em'; trailBeatsSelector.addEventListener('input', (ev) => { r.trailLengthInBeats = trailBeatsSelector.valueAsNumber; drawAtCurrentBeat(); restartAnimation(false); }) const trailBeatsLabel = document.createElement('label'); trailBeatsLabel.innerText = '# max age of trails (faded previous positions) in beats: '; trailBeatsLabel.htmlFor = 'trailBeats'; displaySettingsDiv.appendChild(document.createElement('br')); displaySettingsDiv.appendChild(trailBeatsLabel); displaySettingsDiv.appendChild(trailBeatsSelector); displaySettingsDiv.appendChild(document.createElement('br')); displaySettingsDiv.appendChild(debugRender); displaySettingsDiv.appendChild(debugRenderLabel); wrapperDiv.appendChild(displaySettingsDiv); // Default dance is Two Hearts in Time by Isaac Banner. Selected arbitrarily. const defaultDanceTitle = "Two Hearts in Time"; const danceList = document.createElement('select'); for (const [idx, dance] of danceLibrary.dances.entries()) { const opt = document.createElement('option'); opt.value = idx.toString(); opt.innerText = dance.title + " by " + dance.choreographer_name; if (dance.title === defaultDanceTitle) { opt.selected = true; } danceList.appendChild(opt); } danceList.addEventListener('input', () => { danceJsonArea.value = JSON.stringify(danceLibrary.dances[danceList.value], undefined, 2); }) wrapperDiv.appendChild(danceList); const verifyButton = document.createElement('button'); verifyButton.innerText = 'Test loading every dance'; verifyButton.addEventListener('click', () => { danceList.childNodes.forEach((opt: HTMLOptionElement) => { if (opt.innerText.startsWith('[')) return; // already processed const dance = danceLibrary.dances[parseInt(opt.value)]; let interpreterError: string | undefined = undefined; let progressionError: string | undefined = undefined; let libfigureError: string | undefined = undefined; try { for (const figure of dance.figures) { figureToHtml(figure, dialect); } } catch (e) { libfigureError = 'libfigure ex: ' + e; } try { progressionError = interpreter.loadDance(dance).progressionError; const moveError = [...interpreter.interpretedDance.values()].flatMap(moves => moves.filter(m => m.interpreterError !== undefined).map(m => m.interpreterError)).at(0); if (moveError) { interpreterError = "interpreter move error: " + moveError; } } catch (e) { interpreterError = "interpreter exception: " + e; } if (!interpreterError && !progressionError && !libfigureError) { opt.innerText = "[OK] " + opt.innerText; } else { const errors = (libfigureError ? "[" + libfigureError + "] " : "") + (interpreterError ? "[" + interpreterError + "] " : "") + (progressionError ? "[" + progressionError + "] " : ""); opt.innerText = errors + opt.innerText; } }); }); wrapperDiv.appendChild(verifyButton); const danceJsonArea = document.createElement('textarea'); danceJsonArea.value = JSON.stringify(danceLibrary.dances.find(d => d.title === defaultDanceTitle), undefined, 2); danceJsonArea.rows = 15; danceJsonArea.cols = 30; const loadDanceButton = document.createElement('button'); loadDanceButton.innerText = 'Load Dance'; wrapperDiv.appendChild(document.createElement('br')); wrapperDiv.appendChild(danceJsonArea); wrapperDiv.appendChild(loadDanceButton); const copyDanceGuide = document.createElement('p'); copyDanceGuide.appendChild(document.createTextNode("You can get a dance's JSON from ContraDB by using the ")) const greasemonkeyLink = document.createElement('a'); greasemonkeyLink.href = 'https://en.wikipedia.org/wiki/Greasemonkey'; greasemonkeyLink.innerText = 'Greasemonkey'; copyDanceGuide.appendChild(greasemonkeyLink) copyDanceGuide.appendChild(document.createTextNode(' userscript ')) const userScriptLink = document.createElement('a'); userScriptLink.href = 'https://git.aweirdimagination.net/perelman/contra-renderer/src/branch/main/ContraDB%20dance%20exporter.user.js'; userScriptLink.innerText = 'ContraDB dance exporter.user.js'; copyDanceGuide.appendChild(userScriptLink) copyDanceGuide.appendChild(document.createTextNode('. While logged into ContraDB, the "🗐 Copy" button on a dance\'s page will copy the dance to the clipboard.')) wrapperDiv.appendChild(document.createElement('br')); wrapperDiv.appendChild(copyDanceGuide) const table = document.createElement('table'); function loadDance() { const loadedDance : LibFigureDance | danceLibrary.ContraDBDance = JSON.parse(danceJsonArea.value); removeAllChildNodes(danceTitle); if (loadedDance instanceof Array) { dance = { figures: loadedDance, id: 0, title: "", choreographer_name: "", start_type: "improper", preamble: "", notes: "", hook: "", }; } else { dance = loadedDance; const title = document.createElement('a'); title.href = 'https://contradb.com/dances/' + dance.id; title.innerText = dance.title; const author = document.createElement('span'); author.innerText = ' by ' + dance.choreographer_name; danceTitle.appendChild(title); danceTitle.appendChild(author); } r.animation = interpreter.loadDance(dance); if (cancelAnim !== undefined) { cancelAnimationFrame(cancelAnim); playButton.innerText = 'Play'; } progressionSelector.value = '0'; beatSlider.value = '0'; beatSlider.max = r.animation.numBeats.toString(); beatDisplay.innerText = '0.0'; r.drawSetsWithTrails(0); buildDebugTable(); buildMovesList(); } loadDanceButton.addEventListener('click', loadDance); loadDance(); function createJsonCell(content: any, rowSpan?: number, id?: DancerIdentity) { const cell = document.createElement('td'); const pre = document.createElement('pre'); // from https://stackoverflow.com/a/56150320 function replacer(key, value) { if (value instanceof Map) { let res = {}; for (var [k, v] of value.entries()) { res[k === undefined ? "undefined" : k.toString()] = v; } return res; } else if (!value) { return value; } else if (value.enumValue) { return value.enumValue; } else { return value; } } pre.innerText = content ? JSON.stringify(content, replacer, 2) : ""; cell.appendChild(pre); if (rowSpan) { cell.rowSpan = rowSpan; } if (id) { cell.classList.add(id.coupleRole.toString()); cell.classList.add(id.danceRole.toString()); } return cell; } function createHeaderCell(content: string, id?: DancerIdentity) { const cell = document.createElement('th'); cell.innerText = content; if (id) { cell.classList.add(id.coupleRole.toString()); cell.classList.add(id.danceRole.toString()); cell.colSpan = 2; } return cell; } const showDebug = document.createElement('input'); showDebug.type = 'checkbox'; showDebug.id = 'showDebug'; const showDebugLabel = document.createElement('label'); showDebugLabel.innerText = 'Show Debug Information'; showDebugLabel.htmlFor = 'showDebug'; body.appendChild(showDebug); body.appendChild(showDebugLabel); table.id = 'debug' function removeAllChildNodes(html: HTMLElement) { while (html.childNodes.length > 0) { html.removeChild(html.childNodes[html.childNodes.length - 1]); } } function buildDebugTable() { removeAllChildNodes(table); const headerRow = document.createElement('tr'); const roles = [DancerIdentity.OnesLark, DancerIdentity.OnesRobin, DancerIdentity.TwosLark, DancerIdentity.TwosRobin]; headerRow.appendChild(createHeaderCell("Move")); for (const role of roles) { headerRow.appendChild(createHeaderCell(role.toString(), role)); } table.appendChild(headerRow); const byMove: { move: Move, byRole: Map }[] = []; for (const [role, moveList] of interpreter.interpretedDance.entries()) { for (const move of moveList) { let entry = byMove.find(el => el.move === move.move); if (!entry) { entry = { move: move.move, byRole: new Map() }; byMove.push(entry); } let forRole = entry.byRole.get(role); if (!forRole) { forRole = []; entry.byRole.set(role, forRole); } const animation = animateLowLevelMove(move); let first = true; for (const animationSegment of animation) { if (first) { forRole.push({ lowLevelMove: move, animationSegment, numSegments: animation.length }); first = false; } else { forRole.push({ animationSegment }); } } } } for (const infoForMove of byMove) { const moveRow = document.createElement('tr'); const numRows = Math.max(...[...infoForMove.byRole.values()].map(l => l.length)); moveRow.appendChild(createJsonCell(infoForMove.move, numRows)); for (let i = 0; i < numRows; i++) { const row = i == 0 ? moveRow : document.createElement('tr'); for (const role of roles) { const lowLevelMove = infoForMove.byRole.get(role)?.at(i); if (!lowLevelMove) { row.appendChild(createJsonCell(undefined, undefined, role)); } else if (lowLevelMove.lowLevelMove) { row.appendChild(createJsonCell(lowLevelMove.lowLevelMove, lowLevelMove.numSegments, role)); } row.appendChild(createJsonCell(lowLevelMove?.animationSegment, undefined, role)); } table.appendChild(row); } } } buildDebugTable(); body.appendChild(table);