const RENDER_TYPE = { LATEX: 1, FUNC: 2, }; const THRESHOLD = 1e-12; const FONT = "Courier New"; const FONT_HEIGHT_PX = 24; const DX = 4; const canvas = document.getElementById("canvas"); const ctx = canvas.getContext("2d"); let state = {}; const initializeState = () => { const xLabels = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sept", "Oct", "Nov", "Dec", "Jan", ]; const yLabels = ["Great", "Good", "Meh", "Bad", "Horrible"]; return { width: canvas.parentElement.clientWidth, height: canvas.parentElement.clientHeight, xLabels, yLabels, yLabelPadding: 12, heights: Array(xLabels.length * DX - 1).fill(0), }; }; const dft = (heights, render = RENDER_TYPE.LATEX, threshold = THRESHOLD) => { const n = heights.length; return Array(n) .fill() .map((x, w) => { const rate = -2 * Math.PI * w; const s = heights.reduce( (a, x, i) => ({ re: a.re + x * Math.cos((rate * i) / n), im: a.im + x * Math.sin((rate * i) / n), }), { re: 0, im: 0 } ); Object.entries(s).forEach( ([key, value]) => (s[key] = ((Math.abs(value) < threshold ? 0 : 1) * value) / n) ); const amp = Math.sqrt(s.re * s.re + s.im * s.im); const phase = Math.atan2(s.im, s.re); switch (render) { case RENDER_TYPE.LATEX: return `${amp}\\cos\\left(${w}\\frac{${ 2 * DX }\\pi}{${n}}x+${phase}\\right)`; case RENDER_TYPE.FUNC: return (t) => amp * Math.cos(w * t + phase); } }); }; const resizeCanvas = ({ width, height }) => { canvas.width = width; canvas.style.width = width; canvas.height = height; canvas.style.height = height; }; const loop = () => { const stateChanges = Object.keys(state.diff); if (stateChanges.length > 0) { state = { ...state, ...state.diff }; if ( state.diff.width || state.diff.height || state.diff.xLabels || state.diff.yLabels ) { resizeCanvas(state.diff); ctx.font = `${FONT_HEIGHT_PX}px ${FONT}`; state.maxYLabelWidth = state.yLabels.reduce( (a, label) => Math.max(ctx.measureText(label).width, a), -Infinity ); state.gridBoxWidth = state.width - state.maxYLabelWidth - state.yLabelPadding; state.gridBoxHeight = state.height - 2.5 * FONT_HEIGHT_PX; // 2.5 to include bottom part of tall letters ("g", "y", etc.) state.topLeftGridPos = { x: state.maxYLabelWidth + state.yLabelPadding, y: FONT_HEIGHT_PX, }; state.bottomRightGridPos = { x: state.topLeftGridPos.x + state.gridBoxWidth, y: state.topLeftGridPos.y + state.gridBoxHeight, }; } if (state.diff.heights) drawDesmos(); draw(state); state.diff = {}; } requestAnimationFrame(loop); }; const drawLine = (pos1, pos2) => { ctx.beginPath(); ctx.moveTo(pos1.x, pos1.y); ctx.lineTo(pos2.x, pos2.y); ctx.stroke(); }; const drawDividers = ( xDividers, yDividers, topLeftGridPos, bottomRightGridPos, yLabelPadding ) => { ctx.font = `${FONT_HEIGHT_PX}px ${FONT}`; xDividers.forEach(({ label, position }) => { ctx.fillText(label, position.x - ctx.measureText(label).width, position.y); drawLine( { ...position, y: topLeftGridPos.y }, { ...position, y: bottomRightGridPos.y } ); }); yDividers.forEach(({ label, position }) => { ctx.fillText( label, topLeftGridPos.x - yLabelPadding - ctx.measureText(label).width, position.y ); drawLine( { ...position, x: topLeftGridPos.x }, { ...position, x: bottomRightGridPos.x } ); }); }; const draw = ({ heights, gridBoxWidth, gridBoxHeight, topLeftGridPos, bottomRightGridPos, maxYLabelWidth, xLabels, yLabels, width, height, }) => { ctx.clearRect(0, 0, width, height); const xDividers = xLabels.map((label, i) => ({ label, position: { x: topLeftGridPos.x + (gridBoxWidth / (xLabels.length - 1)) * i, y: bottomRightGridPos.y + FONT_HEIGHT_PX, }, })); const yDividers = yLabels.map((label, i) => ({ label, position: { x: 0, y: topLeftGridPos.y + (gridBoxHeight / (yLabels.length - 1)) * i, }, })); drawDividers(xDividers, yDividers, topLeftGridPos, bottomRightGridPos, 12); const dx = gridBoxWidth / (DX * (xLabels.length - 1)); const prevStrokeStyle = ctx.strokeStyle; ctx.strokeStyle = "red"; for (let i = 0; i < heights.length; ++i) { const x = dx * i + topLeftGridPos.x; drawLine( { x, y: (gridBoxHeight / 2) * (1 - heights[i]) + topLeftGridPos.y }, { x: x + dx, y: (gridBoxHeight / 2) * (1 - heights[i + 1]) + topLeftGridPos.y, } ); } ctx.strokeStyle = prevStrokeStyle; }; const calculator = Desmos.GraphingCalculator( document.getElementById("calculator"), { expressionsCollapsed: true, autosize: true, } ); calculator.setMathBounds({ left: -0.8, right: 12, bottom: -3, top: 3, }); const drawDesmos = () => { const equations = dft(state.heights); calculator.setExpression({ id: `graph-total`, latex: equations.map((_x, i) => `y_{${i}}`).join(" + "), }); equations.forEach((x, i) => calculator.setExpression({ id: `graph${i}`, latex: `y_{${i}}=${x}`, hidden: true, }) ); }; let isDown = false; canvas.addEventListener( "mousedown", (e) => { e.preventDefault(); isDown = true; }, true ); canvas.addEventListener( "mouseup", (e) => { e.preventDefault(); isDown = false; }, true ); canvas.addEventListener( "mousemove", (e) => { e.preventDefault(); if (isDown) { const rect = canvas.getBoundingClientRect(); const [x, y] = [e.clientX - rect.left, e.clientY - rect.top]; const { topLeftGridPos, bottomRightGridPos, gridBoxWidth, gridBoxHeight, heights, xLabels, } = state; const delta = gridBoxWidth / (DX * (xLabels.length - 1)); const bin = Math.min( Math.round(Math.max(x - topLeftGridPos.x, 0) / delta), heights.length - 1 ); heights[bin] = Math.min( Math.max(1 - (2 * (y - topLeftGridPos.y)) / gridBoxHeight, -1), 1 ); state.diff.heights = heights; } }, true ); window.addEventListener("resize", () => { state.diff = { ...state.diff, width: canvas.parentElement.clientWidth, height: canvas.parentElement.clientHeight, }; }); (() => { state.diff = initializeState(); window.requestAnimationFrame(loop); })();