Create a gist now

Instantly share code, notes, and snippets.

What would you like to do?
BetterExplained Fourier Example
<html>
<head>
<script src="//cdnjs.cloudflare.com/ajax/libs/underscore.js/1.4.2/underscore-min.js"></script>
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.8.2/jquery.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/modernizr/2.6.2/modernizr.min.js"></script>
<script src="//ajax.cdnjs.com/ajax/libs/json2/20110223/json2.js"></script>
<!--
TODO:
DONE: Have a "details mode" where we see how we got the frequencies.
- In details mode, have a table / dropdown for you to pick what frequency to analyze
-->
<script type="text/javascript">
// from view-source:http://treeblurb.com/dev_math/sin_canv00.html
var x_size = 150;
var y_size = 100;
var settings = {
canvas: {
width: 0, // autodetected
height: 0,
},
timegraph_center_x: 150,
timegraph_center_y: 100,
timegraph_height: 60,
timegraph_width: 360,
circle_radius: 60,
circle_center_x: 80,
circle_center_y: 100,
axis_margin_left: 15,
axis_margin_top: 15,
axis_margin_bottom: 15,
axis_margin_right: 25,
refresh: 50, // interval refresh in ms
steps: 60, // # of intervals to divide wave into
cyclegraph_dot: {
strokeStyle: "#ccc",
lineWidth: 1.5,
radius: 3.5,
fillStyle: "Orange"
},
timegraph_dot: {
strokeStyle: "#ccccff",
lineWidth: 1.0,
radius: 3.5,
fillStyle: "Orange"
},
axes: {
strokeStyle: "#999",
lineWidth: 0.5
},
wave: {
fillStyle: "rgba(0,0,0,0)",
strokeStyle: "#C2A7DD",
lineWidth: 1.5
},
combined: {
color: "#4A93FA"
},
interval: {
dotcolor: "rgba(255, 165, 0, 0.8)",
lineStyle: "rgba(255, 165, 0, 0.5)",
lineWidth: 1.0,
},
unitcircle: {
fillStyle: "rgba(0,0,0,0)",
strokeStyle: "#999",
lineWidth: 0.5
},
cycle: {
poscolor: "#37B610",
negcolor: "#E95C59",
zerocolor: "#999"
},
text: {
font: "normal 12px Courier",
fillStyle: "#888"
}
};
function getURLParameter(name) {
return decodeURIComponent((new RegExp('[?|&]' + name + '=' + '([^&;]+?)(&|#|;|$)').exec(location.search)||[,""])[1].replace(/\+/g, '%20'))||null;
}
function roundTo(n, digits) {
return Math.round(n * Math.pow(10, digits)) / Math.pow(10, digits);
}
// TODO: perhaps unnecessary, but good for learning: load canvas caches (if possible) and draw into the original
var cachedCanvas = {};
function getCachedCanvas(key) {
return cachedCanvas[key] || null;
}
function setCachedCanvas(key, ctx) {
// flush cache
if (_(cachedCanvas).keys().length > 25) {
cachedCanvas = {};
}
cachedCanvas[key] = ctx;
}
function generateCanvas() {
var canvas = document.createElement('canvas');
canvas.width = settings.canvas.width;
canvas.height = settings.canvas.height;
return canvas;
}
function cachedRender(key, ctx, renderfn) {
key = JSON.stringify(key);
var cachedCanvas = getCachedCanvas(key);
if (!cachedCanvas) {
cachedCanvas = generateCanvas();
cached_ctx = cachedCanvas.getContext("2d");
resetCanvas(cached_ctx);
renderfn(cached_ctx);
setCachedCanvas(key, cachedCanvas);
}
ctx.drawImage(cachedCanvas, 0, 0);
}
// cycleFn: function(x) that returns value for a time point
// key: unique key for this call, used for caching
function timegraph_path(ctx, cycleFn, strokeStyle)
{
var N = settings.timegraph_width; // buttery-smooth, pixel-by-pixel
var dx = 2 * (Math.PI) / N;;
var x = 0;
var px = settings.timegraph_center_x;
var px_orig = px;
var py = settings.timegraph_center_y;
ctx.beginPath();
ctx.lineWidth = settings.wave.lineWidth;
ctx.strokeStyle = strokeStyle || settings.wave.strokeStyle;
// have one extra point so curves wrap nicely
for (var i = 0; i <= N; i++) {
var x = 2 * Math.PI * i/N;
y = cycleFn(x);
var px = settings.timegraph_center_x + x * (180 / Math.PI) * settings.timegraph_width / 360;
var py = settings.timegraph_center_y - settings.timegraph_height*y;
if (i == 0) {
ctx.moveTo(px, py);
}
else {
ctx.lineTo(px, py);
}
}
ctx.stroke();
ctx.closePath();
}
function path_circ(ctx, x, y, r)
{
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2, true); //arc(x, y, radius, startAngle, endAngle, anticlockwise)
ctx.stroke();
ctx.closePath();
}
function path_line(ctx, x0, y0, x1, y1)
{
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.stroke();
ctx.closePath();
}
// place circle on canvas
function path_dot(ctx, x, y, radius)
{
radius = radius || 3.5;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2, true); // arc(x, y, radius, startAngle, endAngle, anticlockwise)
ctx.fill();
ctx.closePath();
}
// dot on cycle chart
function cyclegraph_dot(ctx, x, y, fillStyle)
{
var x = settings.circle_center_x + settings.circle_radius*x;
var y = settings.circle_center_y - settings.circle_radius*y;
ctx.strokeStyle = settings.cyclegraph_dot.strokeStyle;
ctx.lineWidth = settings.cyclegraph_dot.lineWidth;
// line to origin
path_line(ctx, settings.circle_center_x, settings.circle_center_y, x, y);
// draw circle itself
ctx.fillStyle = fillStyle || settings.cyclegraph_dot.fillStyle;
path_dot(ctx, x, y, settings.cyclegraph_dot.radius);
}
// draw interval marker
function cyclegraph_interval(ctx, x, y, color)
{
var x = settings.circle_center_x + settings.circle_radius*x;
var y = settings.circle_center_y - settings.circle_radius*y;
ctx.strokeStyle = color || settings.interval.fillStyle;
ctx.lineWidth = settings.interval.lineWidth;
// line to origin
path_line(ctx, settings.circle_center_x, settings.circle_center_y, x, y);
}
function timegraph_interval(ctx, t, color)
{
var x = settings.timegraph_center_x + t * 180/Math.PI * settings.timegraph_width / 360;
var min_y = settings.axis_margin_top;
var max_y = settings.canvas.height - settings.axis_margin_bottom;
ctx.fillStyle = color || settings.interval.fillStyle;
ctx.lineWidth = settings.interval.lineWidth;
// drop line to baseline
path_line(ctx, x, min_y, x, max_y);
}
// dot on time chart
function timegraph_dot(ctx, t, height, fillStyle)
{
var x = settings.timegraph_center_x + t * 180/Math.PI * settings.timegraph_width / 360;
var y = settings.timegraph_center_y - settings.timegraph_height * height;
ctx.fillStyle = fillStyle || settings.timegraph_dot.fillStyle;
path_dot(ctx, x, y, settings.timegraph_dot.radius);
ctx.strokeStyle = settings.timegraph_dot.strokeStyle;
ctx.lineWidth = settings.timegraph_dot.lineWidth;
}
// parse cycle text and return array of cycle objects (amp, freq, phase)
// cycles are 0th 1st 2nd 3rd... or 0th 1st & -1st 2nd & -2nd
function getCycles() {
$cycleInput = $('input[name=data-cycles]');
var text = $cycleInput.val();
text = text.replace(/\s+&\s+/g, '&');
var strings = _(text.split(/[\s,]+/)).reject(function(i){ return i == null || i == "";});
var index = 0;
var cycles = [];
function parseCycle(str, freq){
var matches = str.split(/[@:]/);
return {
freq: freq,
amp: parseFloat(matches[0]),
phase: parseFloat(matches[1] || 0)
};
}
_(strings).each(function(i){
var posneg = i.split('&');
cycles.push(parseCycle(posneg[0], index));
// specified negative cycle too
if (posneg[1]) {
cycles.push(parseCycle(posneg[1], -1 * index));
}
index++;
});
cycles = _(cycles).reject(function(i){ return _.isNaN(i.amp); });
return cycles;
}
function resetCanvas(ctx) {
ctx.clearRect(0, 0, settings.canvas.width, settings.canvas.height);
}
function drawAxes(ctx, scale) {
// style axes
ctx.strokeStyle = settings.axes.strokeStyle;
ctx.lineWidth = settings.axes.lineWidth;
// x-axis both graphs
path_line(ctx,
settings.circle_center_x - settings.circle_radius - settings.axis_margin_left,
settings.circle_center_y,
settings.timegraph_center_x + settings.timegraph_width,
settings.timegraph_center_y);
// y-axis for circle
path_line(ctx, settings.circle_center_x, settings.axis_margin_top, settings.circle_center_x,settings.canvas.height - settings.axis_margin_bottom);
// y-axis for time series
path_line(ctx, settings.timegraph_center_x, settings.axis_margin_top, settings.timegraph_center_x, settings.canvas.height - settings.axis_margin_bottom);
// unit circle
ctx.fillStyle = settings.unitcircle.fillStyle;
ctx.strokeStyle = settings.unitcircle.strokeStyle;
ctx.lineWidth = settings.unitcircle.lineWidth;
path_circ(ctx, settings.circle_center_x, settings.circle_center_y, settings.circle_radius * scale);
// line for the wave itself
ctx.fillStyle = settings.wave.fillStyle;
ctx.strokeStyle = settings.wave.strokeStyle;
ctx.lineWidth = settings.wave.lineWidth;
}
function drawFourier(ctx, options)
{
var start = new Date();
options = options || {};
// position to move to has been scaled along a circle
var r = (step/settings.steps) * 2.0 * Math.PI;
var cycles = getCycles();
var N = cycles.length;
var timeseries = Fourier.InverseTransform(cycles);
var combined = Fourier.totalValue(r, cycles);
var max_amplitude_time = _(_(timeseries).pluck('amp')).max();
var max_real = _(_(timeseries).pluck('real')).max();
// adjust scale if we are hiding the total
if (!$('#showcombined').is(':checked')) {
max_amplitude = _(_(cycles).pluck('amp')).max();
}
var scale = max_amplitude_time > 0 ? 1 / max_amplitude_time : 1;
if (scale > 1) {
scale = 1;
}
resetCanvas(ctx);
drawAxes(ctx, scale);
function getCycleColor(cycle) {
var color = cycle.freq > 0 ? settings.cycle.poscolor : settings.cycle.negcolor;
if (cycle.freq == 0){
color = settings.cycle.zerocolor;
}
return color;
}
function drawStatus(text, color) {
ctx.font = settings.text.font;
ctx.fillStyle = color || settings.text.fillStyle;
ctx.fillText(text, settings.timegraph_center_x + 10, canvas.height - settings.axis_margin_bottom);
}
function drawIntervals(){
// draw lines showing the intervals
_(timeseries).each(function(point){
// ignore first interval, there's already an x-axis
if (point.x > 0) {
timegraph_interval(ctx, point.x, settings.interval.lineStyle);
}
var value = Fourier.totalValue(point.x, {freq: 1, phase: 0, amp: 1});
cyclegraph_interval(ctx, value.real * scale, value.im * scale, settings.interval.lineStyle);
});
}
if (!$('input[name=data-time]').is(':focus')) {
var str = _(timeseries).map(function(point){return Math.round(point.real * 10) / 10;}).join(" ");
$('input[name=data-time]').val(str);
}
if ($('#showreverse').is(':checked')) {
// show only the total and the cycle we want
var soloFreq = parseInt($('#reversefreq').val());
scale = 1 / max_amplitude_time;
var cycle = {
freq: -1 * soloFreq,
phase: 0,
amp: 1
};
var color = getCycleColor(cycle);
var cycleTotal = Fourier.totalValue(r, cycle);
drawIntervals();
cyclegraph_dot(ctx, cycleTotal.real * scale, cycleTotal.im * scale, color);
timegraph_dot(ctx, r, cycleTotal.real * scale, color);
cachedRender(["timegraph_path", cycle, scale, color], ctx, function(ctx){
timegraph_path(ctx, function(x){ return Fourier.totalValue(x, cycle).real * scale;}, color);
});
var totalReal = 0;
var totalIm = 0;
_(timeseries).each(function(point, i){
// draw the multiplied signal
var thisCycle = Fourier.totalValue(point.x, cycle);
if (point.x < r) {
timegraph_dot(ctx, point.x, point.real * thisCycle.real * scale, settings.cycle.negcolor);
cyclegraph_dot(ctx, point.real * thisCycle.real * scale, point.real * thisCycle.im * scale, settings.cycle.negcolor);
}
totalReal += point.real * thisCycle.real;
totalIm += point.real * thisCycle.im;
});
if (timeseries.length > 0 && r > timeseries[timeseries.length - 1].x) {
// show the final average
var avgReal = totalReal / N;
var avgIm = totalIm / N;
var avgRealRounded = roundTo(avgReal, 2);
var avgImRounded = roundTo(avgIm, 2);
var ampRounded = Math.round(Math.sqrt(avgReal * avgReal + avgIm * avgIm), 2);
var phase = roundTo(Math.atan2(avgImRounded, avgRealRounded) * 180/Math.PI, 0);
cyclegraph_dot(ctx, avgReal * scale, avgIm * scale, settings.combined.color);
var text = "avg: " + " re: " + avgRealRounded + " im: " + avgImRounded;
// text += " [" + ampRounded + (ampRounded != 0 && phase != 0 ? phase : '' ) + "]";
drawStatus(text, settings.combined.color);
}
return;
}
if ($('#showparts').is(':checked')) {
_(cycles).each(function(cycle){
var color = getCycleColor(cycle);
var cycleTotal = Fourier.totalValue(r, cycle);
cyclegraph_dot(ctx, cycleTotal.real * scale, cycleTotal.im * scale, color);
timegraph_dot(ctx, r, cycleTotal.real * scale, color);
timegraph_path(ctx, function(x){ return Fourier.totalValue(x, cycle).real * scale;}, color, cycle);
});
}
// ticks
if ($('#showdiscrete').is(':checked')){
drawIntervals();
}
// current combined point
if ($('#showcombined').is(':checked')) {
cyclegraph_dot(ctx, combined.real * scale, combined.im * scale, settings.combined.color);
timegraph_path(ctx, function(x){return Fourier.totalValue(x, cycles).real * scale;}, settings.combined.color, cycles);
timegraph_dot(ctx, r, combined.real * scale, settings.combined.color);
_(timeseries).each(function(point){
timegraph_dot(ctx, point.x, point.real * scale, settings.interval.dotcolor);
cyclegraph_dot(ctx, point.real * scale, point.im * scale, settings.interval.dotcolor);
});
}
// label values
if ($('#running').is(':checked') == false) {
var text = "t: " + roundTo(r, 1) + " re: " + roundTo(combined.real, 1) + " im: " + roundTo(combined.im, 1);
}
}
function init()
{
var canvas = $('#canvas').get(0);
var ctx = canvas.getContext("2d");
settings.canvas.width = canvas.width;
settings.canvas.height = canvas.height;
setInterval(function () {
if ($('#running').is(':checked')) {
drawFourier(ctx);
advanceTime();
}
}, settings.refresh);
step = 0;
function advanceTime(){
step++;
if (step > settings.steps){
step=0;
}
}
$('#reset').click(function(e){
e.preventDefault();
step = 0;
drawFourier(ctx);
});
$('input').not('.nochange').change(function(){drawFourier(ctx);}).keyup(function(){drawFourier(ctx);});
$('#time').css('visibility', 'hidden').change(function(){
step = ($(this).val() / 100) * settings.steps;
drawFourier(ctx);
});
$('#running').change(function(){
$('#time').css('visibility', $(this).is(':checked') ? 'hidden' : '');
$('#time').val((step / settings.steps) * 100);
});
if (!Modernizr.inputtypes.range){
$('#time').css('width', '30px');
}
$('input[name=data-time]').keyup(function(){
var timeseries = Fourier.parseTimeSeries($(this).val());
var transform = Fourier.Transform(timeseries);
var newCycles = Fourier.getCyclesFromData(transform);
var newString = Fourier.getStringFromCycles(newCycles);
$('input[name=data-cycles]').val(newString);
drawFourier(ctx, {dataupdate: false});
});
$('#canvas').click(function(){
$('#running').click();
})
$('.mrfourier').click(function(e){
e.preventDefault();
$('.fourierchart').toggleClass('theme-dark');
});
};
$(function(){
init();
var cycles = getURLParameter("cycles");
if (cycles) {
cycles = cycles.replace(/,/g, " ");
$('input[name=data-cycles]').val(cycles);
} else {
var time = getURLParameter("time");
if (time) {
time = time.replace(/,/g, " ");
$('input[name=data-cycles]').val("");
$('input[name=data-time]').val(time).trigger('keyup');
$('#running').trigger('click');
}
}
});
var Fourier = {};
/*
Transform a discrete time series to frequency components
@param data (array): time-series numbers
@returns frequencies: array of frequency objects, indexed by frequency (f=0 ... N-1):
{real part, imaginary part, magnitude (computed), phase in degrees (computed) }
*/
Fourier.Transform = function(data) {
var N = data.length;
var frequencies = [];
// for every frequency...
for (var freq = 0; freq < N; freq++) {
var re = 0;
var im = 0;
// for every point in time...
for (var t = 0; t < N; t++) {
// Spin the signal _backwards_ at each frequency (as radians/s, not Hertz)
var rate = -1 * (2 * Math.PI) * freq;
// How far around the circle have we gone at time=t?
var time = t / N;
var distance = rate * time;
// datapoint * e^(-i*2*pi*f) is complex, store each part
var re_part = data[t] * Math.cos(distance);
var im_part = data[t] * Math.sin(distance);
// add this data point's contribution
re += re_part;
im += im_part;
}
// Close to zero? You're zero.
if (Math.abs(re) < 1e-10) { re = 0; }
if (Math.abs(im) < 1e-10) { im = 0; }
// Average contribution at this frequency
re = re / N;
im = im / N;
frequencies[freq] = {
re: re,
im: im,
freq: freq,
amp: Math.sqrt(re*re + im*im),
phase: Math.atan2(im, re) * 180 / Math.PI // in degrees
};
}
return frequencies;
}
// return data point for all cycles {x, real, im, amp}
Fourier.totalValue = function(x, cycles) {
cycles = _.isArray(cycles) ? cycles : [cycles];
var real = 0;
var im = 0;
_(cycles).each(function(cycle){
real += cycle.amp * Math.cos(x * cycle.freq + cycle.phase * Math.PI/180);
im += cycle.amp * Math.sin(x * cycle.freq + cycle.phase * Math.PI/180);
});
return {
x: x,
real: real,
im: im,
amp: Math.sqrt(real*real + im*im)
};
};
Fourier.realValue = function(x, cycle) {
return Fourier.totalValue(x, cycle).real;
};
Fourier.imaginaryValue = function(x, cycle) {
return Fourier.totalValue(x, cycle).im;
};
// return time series of data points {x, real, im, amp}
Fourier.InverseTransform = function(cycles) {
var timeseries = [];
var len = cycles.length;
for (var i = 0; i < len; i++) {
var pos = i/len * 2 * Math.PI;
var total = Fourier.totalValue(pos, cycles);
timeseries.push(total);
}
return timeseries;
};
// Do a fourier transform on this data string
Fourier.getCyclesFromData = function(data, rounding){
rounding = rounding || 2;
return _(data).map(function(i){
return {
freq: i.freq,
phase: Math.round(i.phase * Math.pow(10, 1)) / Math.pow(10, 1),
amp: Math.round(i.amp * Math.pow(10, rounding)) / Math.pow(10, rounding)
};
});
};
// convert cycles into parseable string
Fourier.getStringFromCycles = function(cycles){
var str = "";
_(cycles).each(function(i){
str += i.amp;
if (i.phase != 0 && i.amp != 0){
str += ":" + i.phase;
}
str += " ";
});
return str;
}
// Return array of numbers given a time-series string ("1 2.3 -4"). Comma or space separated
Fourier.parseTimeSeries = function(text) {
var strings = _(text.split(/[\s,]+/)).reject(function(i){ return i == null || i == "";});
return _(strings).map(function(i){return parseFloat(i);});
}
</script>
</head>
<style type="text/css">
input[type=text] {
font-family: monospace;
width: 240px;
}
input[name=data-time] {
width: 160px;
}
#time {
width: 100px;
}
.fourierchart {
width: 520px;
border: 1px solid #ccc;
position: relative;
}
label[for=showcombined] {
color: #4A93FA;
}
label[for=showparts] {
color: #37B610;
}
label[for=showdiscrete]{
color: #E7A020;
}
.commands {
font-family: 'Lucida Console', 'Courier New', monospace;
font-size: 11px;
background: #eaeaea;
color: #333;
padding: 1px 4px;
}
.gradient {
background: rgb(238,238,238); /* Old browsers */
background: -moz-linear-gradient(top, rgba(238,238,238,1) 0%, rgba(204,204,204,1) 100%); /* FF3.6+ */
background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,rgba(238,238,238,1)), color-stop(100%,rgba(204,204,204,1))); /* Chrome,Safari4+ */
background: -webkit-linear-gradient(top, rgba(238,238,238,1) 0%,rgba(204,204,204,1) 100%); /* Chrome10+,Safari5.1+ */
background: -o-linear-gradient(top, rgba(238,238,238,1) 0%,rgba(204,204,204,1) 100%); /* Opera 11.10+ */
background: -ms-linear-gradient(top, rgba(238,238,238,1) 0%,rgba(204,204,204,1) 100%); /* IE10+ */
background: linear-gradient(to bottom, rgba(238,238,238,1) 0%,rgba(204,204,204,1) 100%); /* W3C */
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#eeeeee', endColorstr='#cccccc',GradientType=0 ); /* IE6-9 */
}
.theme-dark #canvas {
background: #0F2338;
}
.help {
font-family: Verdana;
font-size: 12px;
position: absolute;
background: #fafafa;
padding: 5px;
color: #333;
border: 1px solid #ccc;
top: -150px;
left: -420px;
display: none;
width: 400px;
}
.mrfourier:hover .help {
xdisplay: block;
}
.mrfourier {
float: right;
display: inline-block;
width: 40px;
height: 40px;
/* image from Wikipedia: http://upload.wikimedia.org/wikipedia/commons/thumb/a/aa/Joseph_Fourier.jpg/490px-Joseph_Fourier.jpg; */
background: url(http://betterexplained.com/examples/fourier/fourier.png);
position: relative;
top: -24px;
left: -3px;
background-size: 100%;
background-repeat-x: no-repeat;
background-position-x: 0px;
border-radius: 81px;
border: 1px solid #E5E5E5;
}
</style>
<body>
<div class="fourierchart">
<div class="commands gradient">
Cycles <input type="text" name="data-cycles" value = "1 0 0"></input>
Time <input type="text" name="data-time" class="nochange">
</div>
<canvas id="canvas" width="520px" height="200px" style="">
This browser doesn't support canvas! Try <a href="http://google.com/chrome">Google Chrome</a>.
</canvas>
<div class="commands gradient2">
<input type="checkbox" id="showcombined" checked="checked" name="showcombined"><label for="showcombined">Total</label>
<input type="checkbox" id="showparts" checked="checked" name="showparts"><label for="showparts">Parts</label>
<input type="checkbox" id="showdiscrete" name="showdiscrete"><label for="showdiscrete">Ticks</label>
<input type="checkbox" id="showreverse" name="showreverse"><label for="showreverse">Derive</label>
<input type="text" id="reversefreq" style="width: 20px" value="1">
</input>
<input type="checkbox" id="running" checked="checked" name="running" class="nochange"><label for="running">Running?</label>
<input type="range" id="time" min="0" max="100"></input>
<a href="" class="mrfourier">
<div class="help">
Enter frequencies (cycles/sec aka Hz) and see their time values, or vice-versa
<ul>
<li>Frequency input: <code>1 0 2:45</code> is 0Hz (size 1) + 1Hz (size 0) + 2Hz (size 2, phase-shifted 45-degrees) </li>
<li>Time input: <code>1 2 3</code> generates a wave that hits 1 2 3
</ul>
Click Mr. Fourier for night mode. Have fun!
<br/>
</div>
</a>
</div>
</div>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment