import { Animation } from "./animation.js"; import { CoupleRole, DanceRole, DancerIdentity, ExtendedDancerIdentity } from "./danceCommon.js"; import * as exampleAnimations from "./exampleAnimations.js"; import { DancerSetPosition, DancersSetPositions, dancerHeight, dancerHeightOffset, leftShoulder, lineDistance, rightShoulder, setDistance, degreesToRadians } from "./rendererConstants.js"; function hueForDancer(identity: DancerIdentity): number { if (identity.coupleRole == CoupleRole.Ones) { if (identity.danceRole == DanceRole.Lark) { return 0; //red } else { return 39; //orange } } else { if (identity.danceRole == DanceRole.Lark) { return 240; //blue } else { return 180; //teal } } } const colorForDancerCache = new Map( DancerIdentity.all().map(id => [id, [...Array(49).keys()] .map(reverseColorCacheKey) .map(({relativeSet, relativeLine}) => colorForDancer(id, relativeSet, relativeLine))])); const colorForDancerLabelCache = new Map( DancerIdentity.all().map(id => [id, [...Array(49).keys()] .map(i => reverseColorCacheKey(i)) .map(({relativeSet, relativeLine}) => colorForDancerLabel(id, relativeSet, relativeLine))])); function colorCacheKey(relativeSet: number, relativeLine: number) { relativeSet = Math.max(-3, Math.min(3, relativeSet)) + 3; relativeLine = Math.max(-3, Math.min(3, relativeLine)) + 3; return relativeLine * 7 + relativeSet; } function reverseColorCacheKey(key: number): { relativeSet: number, relativeLine: number } { return { relativeSet: (key % 7) - 3, relativeLine: Math.floor(key / 7) - 3, } } function colorForDancer(setIdentity: DancerIdentity, relativeSet: number, relativeLine: number) : string { const hue = hueForDancer(setIdentity); const sat = 100 - Math.abs(relativeLine * 40); const unclampedLum = 50 + relativeSet * 20; const lum = unclampedLum < 10 ? 10 : unclampedLum > 90 ? 90 : unclampedLum; return `hsl(${hue}, ${sat}%, ${lum}%)`; } function colorForDancerCached(setIdentity: DancerIdentity, relativeSet: number, relativeLine: number) : string { return colorForDancerCache.get(setIdentity)![colorCacheKey(relativeSet, relativeLine)]; } function colorForDancerById(identity: ExtendedDancerIdentity) : string { return colorForDancer(identity.setIdentity, identity.relativeSet, identity.relativeLine); } function colorForDancerLabel(setIdentity: DancerIdentity, relativeSet: number, relativeLine: number) : string { const dancerHue = hueForDancer(setIdentity); const hue = (dancerHue + 180) % 360; const sat = 100; const lum = dancerHue === 240 && relativeSet < 2 || relativeSet < 0 ? 100 : 20 - relativeSet * 40; return `hsl(${hue}, ${sat}%, ${lum}%)`; } function colorForDancerLabelCached(setIdentity: DancerIdentity, relativeSet: number, relativeLine: number) : string { return colorForDancerLabelCache.get(setIdentity)![colorCacheKey(relativeSet, relativeLine)]; } function colorForDancerLabelById(identity: ExtendedDancerIdentity) : string { return colorForDancerLabel(identity.setIdentity, identity.relativeSet, identity.relativeLine); } const positiveNumberStrings: string[] = [...Array(10).keys()].map(i => i.toString()); const negativeNumberStrings: string[] = [...Array(10).keys()].map(i => (-i).toString()); function cachedToString(smallNumber: number) { if (Math.abs(smallNumber) >= 10) return smallNumber.toString(); if (smallNumber < 0) return negativeNumberStrings[-smallNumber]; else return positiveNumberStrings[smallNumber]; } export class Renderer { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D; animation?: Animation; extraSets?: number; extraLines?: number; drawDebug: boolean = false; constructor(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) { this.canvas = canvas; this.ctx = ctx; } drawDancerBody(setIdentity: DancerIdentity, relativeSet: number, relativeLine: number, drawText: boolean) { this.ctx.beginPath(); this.ctx.moveTo(leftShoulder.x, leftShoulder.y); this.ctx.lineTo(rightShoulder.x, rightShoulder.y); this.ctx.lineTo(0, dancerHeight-dancerHeightOffset); this.ctx.fill(); // Draw dot at origin to identify "center" point of dancer. const backupFillStyle = this.ctx.fillStyle; this.ctx.fillStyle = 'black'; const pointSize = 0.05; this.ctx.fillRect(-pointSize/2, -pointSize/2, pointSize, pointSize); if (drawText) { this.ctx.save(); this.ctx.scale(-1, 1); this.ctx.rotate(Math.PI); this.ctx.fillStyle = colorForDancerLabelCached(setIdentity, relativeSet, relativeLine); this.ctx.font = '0.15px sans' this.ctx.fillText(setIdentity.danceRole === DanceRole.Lark ? 'L' : 'R', -0.14, +0.04); this.ctx.fillText(setIdentity.coupleRole === CoupleRole.Ones ? '1' : '2', +0.04, +0.04); this.ctx.font = '0.1px sans' this.ctx.fillText(cachedToString(relativeLine), +0.14, +0.04); this.ctx.fillText(cachedToString(relativeSet), -0.22, +0.04); this.ctx.restore(); } this.ctx.fillStyle = backupFillStyle; } drawDancer(position: DancerSetPosition, identity: ExtendedDancerIdentity, offsetSets: number, drawText: boolean, drawDebug: boolean) { this.ctx.save(); this.ctx.translate(identity.relativeLine * lineDistance, identity.relativeSet * setDistance); const relativeSet = identity.relativeSet + (offsetSets * (identity.setIdentity.coupleRole === CoupleRole.Ones ? 1 : -1)); this.ctx.fillStyle = this.ctx.strokeStyle = colorForDancerCached(identity.setIdentity, relativeSet, identity.relativeLine); if (drawDebug) { if (this.drawDebug && position.drawDebug) { this.ctx.save(); this.ctx.lineWidth = 0.05; position.drawDebug(this.ctx); this.ctx.restore(); } } else { this.ctx.translate(position.position.x, position.position.y); this.ctx.rotate(-degreesToRadians(position.rotation)); this.drawDancerBody(identity.setIdentity, relativeSet, identity.relativeLine, drawText); // Draw arms. this.ctx.lineWidth = 0.03; if (position.leftArmEnd) { this.ctx.beginPath(); this.ctx.moveTo(leftShoulder.x, leftShoulder.y); this.ctx.lineTo(position.leftArmEnd.x, position.leftArmEnd.y); this.ctx.stroke(); } if (position.rightArmEnd) { this.ctx.beginPath(); this.ctx.moveTo(rightShoulder.x, rightShoulder.y); this.ctx.lineTo(position.rightArmEnd.x, position.rightArmEnd.y); this.ctx.stroke(); } } this.ctx.restore(); } drawSet(positions: DancersSetPositions, relativeSet: number, relativeLine: number, offsetSets: number, drawText: boolean, drawDebug: boolean) { for (const entry of positions.entries()) { this.drawDancer(entry[1], { setIdentity: entry[0], relativeLine, relativeSet }, offsetSets, drawText, drawDebug); } } drawSets(positions: DancersSetPositions, offsetSets?: number, drawText?: boolean, drawDebug?: boolean) { const extraSets = this.extraSets ?? 0; const extraLines = this.extraLines ?? 0; for (var relativeLine = -extraLines; relativeLine <= extraLines; relativeLine++) { for (var relativeSet = -extraSets; relativeSet <= extraSets; relativeSet++) { this.drawSet(positions, relativeSet, relativeLine, offsetSets ?? 0, drawText ?? true, !!drawDebug); } } } drawSetsAtBeat(beat: number) { if (!this.animation) throw new Error("Attempted to render before setting animation."); this.drawSets(this.animation.positionsAtBeat(beat)); } drawSetsWithTrails(beat: number, progression?: number) { if (!this.animation) throw new Error("Attempted to render before setting animation."); this.clear(); const increments = 7; const trailLengthInBeats = 1; const incrementLength = trailLengthInBeats / increments; progression ??= 0; const offsetSets = this.animation.progression.y === 0 ? 0 : -((progression - (progression % 2)) / 2) / ((this.animation.progression.y * 2) / setDistance); for (var i = increments; i >= 0; i--) { const beatToDraw = beat - i*incrementLength; this.ctx.globalAlpha = i == 0 ? 1 : (1 - i/increments)*0.3; const positions = this.animation.positionsAtBeat(beatToDraw, progression % 2); if (this.drawDebug) this.drawSets(positions, offsetSets, true, true); this.drawSets(positions, offsetSets, i === 0, false); } } playAnimation(bpm: number, start: number, end: number) { const startTime = Date.now(); const msPerBeat = (60*1000)/bpm; const drawSetsWithTrails = (b: number) => this.drawSetsWithTrails(b); function anim() { const now = Date.now(); const msElapsed = now - startTime; const beat = start + msElapsed/msPerBeat; if (beat > end) { drawSetsWithTrails(end); } else { drawSetsWithTrails(beat); requestAnimationFrame(anim); } } anim(); } clear() { // TODO Right way to clear? this.ctx.fillStyle = 'white'; this.ctx.fillRect(-this.canvas.width, -this.canvas.height, 2*this.canvas.width, 2*this.canvas.height); const extraLines = this.extraLines ?? 0; const extraSets = this.extraSets ?? 0; for (var relativeLine = -extraLines; relativeLine <= extraLines; relativeLine++) { for (var relativeSet = -extraSets; relativeSet <= extraSets; relativeSet++) { this.ctx.save(); const hue = (relativeLine + relativeSet) % 2 === 0 ? 60 : 170; const sat = 100 - Math.abs(relativeLine * 40); const lum = Math.min(98, 90 + Math.abs(relativeSet) * 5); this.ctx.fillStyle = `hsl(${hue}, ${sat}%, ${lum}%)`; this.ctx.translate(relativeLine * lineDistance, relativeSet * setDistance); this.ctx.fillRect(-1.975, -1.975, 3.95, 3.95); this.ctx.restore(); } } } }