From bc02eec44d5eeddceb6922973e7309bf9948da81 Mon Sep 17 00:00:00 2001 From: Simponic Date: Tue, 20 Dec 2022 23:20:41 -0700 Subject: [PATCH] Add fourier visualizer --- ft-visualizer/index.html | 44 ++++++ ft-visualizer/js/script.js | 301 +++++++++++++++++++++++++++++++++++++ index.html | 16 +- 3 files changed, 358 insertions(+), 3 deletions(-) create mode 100644 ft-visualizer/index.html create mode 100644 ft-visualizer/js/script.js diff --git a/ft-visualizer/index.html b/ft-visualizer/index.html new file mode 100644 index 0000000..ee593a4 --- /dev/null +++ b/ft-visualizer/index.html @@ -0,0 +1,44 @@ + + + + Simponic's FT Visualizer + + + +
+
+
+ +
+
+ + + + + diff --git a/ft-visualizer/js/script.js b/ft-visualizer/js/script.js new file mode 100644 index 0000000..5c6c4a2 --- /dev/null +++ b/ft-visualizer/js/script.js @@ -0,0 +1,301 @@ +const RENDER_TYPE = { + LATEX: 1, + FUNC: 2, +}; +const THRESHOLD = 1e-10; +const LATEX_SIGFIGS = 8; +const FONT = "Courier New"; +const FONT_HEIGHT_PX = 16; +const DX = 3; + +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, + gridBox: { width: 0.9, height: 0.9 }, + heights: Array(xLabels.length * DX - 1).fill(0), + }; +}; + +const dft = ( + heights, + render = RENDER_TYPE.LATEX, + threshold = THRESHOLD, + sigfigs = LATEX_SIGFIGS +) => { + 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.toPrecision(sigfigs)}\\cos\\left(${w}x\\frac{2}{${ + n / DX + }}\\pi+${phase.toPrecision(sigfigs)}\\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 || + state.diff.gridBox + ) { + resizeCanvas(state.diff); + state.gridBoxWidth = state.gridBox.width * state.width; + state.gridBoxHeight = state.gridBox.height * state.height; + + state.maxYLabelWidth = state.yLabels.reduce( + (a, label) => Math.max(ctx.measureText(label).width, a), + -Infinity + ); + + state.topLeftGridPos = { + x: (state.width - state.gridBoxWidth) / 2 + state.maxYLabelWidth, + y: (state.height - state.gridBoxHeight) / 2, + }; + + state.bottomRightGridPos = { + x: state.topLeftGridPos.x + state.gridBoxWidth, + y: state.topLeftGridPos.y + state.gridBoxHeight, + }; + } + if (Object.keys(state.diff).length) draw(state); + if (state.diff.heights) drawDesmos(); + 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 +) => { + xDividers.forEach(({ label, position }) => { + ctx.fillText( + label, + position.x - ctx.measureText(label).width / 2, + 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); + + ctx.font = `${FONT_HEIGHT_PX}px ${FONT}`; + + 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); + let dx = (bottomRightGridPos.x - topLeftGridPos.x) / (heights.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, + } = state; + const delta = Math.ceil(gridBoxWidth / heights.length); + 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); +})(); diff --git a/index.html b/index.html index 53b0ab2..c01175f 100644 --- a/index.html +++ b/index.html @@ -11,18 +11,17 @@ -
-

+

👋 Hello, I'm Simponic!
📖 This page hosts strictly static content.
🔔 My "real website" is at simponic.xyz. -

+

@@ -67,6 +66,17 @@

Zoom, pan, and "c" complex changes in this fun GPU-accelerated playground!

+ +
+
+ +
+ +
+

Discrete Fourier Visualizer

+

Draw how your year has gone and view a reactive graph containing its DFT by dragging your mouse over the canvas!

+
+