contra-renderer/www/js/main.ts

720 lines
24 KiB
TypeScript

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<DancerIdentity, { lowLevelMove?: LowLevelMove, animationSegment: animation.AnimationSegment, numSegments?: number }[]> }[] = [];
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<DancerIdentity, { lowLevelMove?: LowLevelMove, animationSegment: animation.AnimationSegment, numSegments?: number }[]>() };
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);