PSEUDOCODE
// Define an object to handle Perlin generation
let perlin = {
    // Gets a random vector - not seeded - however results will be saved into a map
    rand_vect: function(){
        let theta = Math.random() * 2 * Math.PI;
        return {x: Math.cos(theta), y: Math.sin(theta)};
    },

    // Takes two points - the candidate point (x,y), and a grid corner (vx, vy)
    // Interpolates between the vector of the corner (grid_vector)
    // and the vector represented by corner->candidate point (candidate_vector)
    dot_prod_grid: function(x, y, vx, vy, octave){
        // Calculate the candidate vector from the co-ordinate
        let candidate_vector = {x: x - vx, y: y - vy};

        // Generate the unit-length Grid Vector, or fetch from cache if it exists
        let key = [vx, vy, octave].join(",");
        let grid_vector;
        if (this.vector_map.has(key)){
            grid_vector = this.vector_map.get(key);
        } else {
            grid_vector = this.rand_vect();
            this.vector_map.set(key, grid_vector);
        }

        // Return the dot-product
        return candidate_vector.x * grid_vector.x + candidate_vector.y * grid_vector.y;
    },


    // Weight Interpolation using various functions
    smootherstep: function(x) {
        // Smoother step interpolation (version from Ken Perlin)
        return (x**3 * (x * ((x*6) - 15) + 10));
    },
    smoothstep: function(x) {
        // Smoothstep interpolation (3x^2 - 2x^2)
        return x**2 * (3 - (2*x));
    },
    linear: function(x) {
        // Linear interpolation
        return x
    },
    // Value interpolation utilising various weight interpolation functions
    interpolate: function(weight, a, b) {
        return a + this.smootherstep(weight) * (b-a);
    },

    // Initialises the object and clears any saved gradients or vectors
    // vector_map are saved vectors to use when calculating dot products
    // perlin_cache are saved perlin values so if the same coordinates are called, the same output is fetched
    seed: function(){
        this.vector_map = new Map();
        this.perlin_cache = new Map();
    },

    // Main function to get perlin noise
    get: function(x, y, octave = 1) {

        // TotalPerlin is the accumulated value of multiple layers of perlin noise added to a point
        let TotalPerlin = 0;

        // Loop over every octave. At each octave a new value of perlin noise is generated and added to TotalPerlin
        for (let currentOctave = 1; currentOctave <= octave; currentOctave++) {

            // Checks if the cache contains a value for this coordinate
            let key = [x,y,currentOctave].join(",");
            if (this.perlin_cache.has(key))
                return this.perlin_cache.get(key);

            // Get the corners of the coordinate
            let xFloor = Math.floor(x);
            let yFloor = Math.floor(y);

            // Calculate Grid Corner values from the two vectors at every point
            let TopLeft = this.dot_prod_grid(x, y, xFloor,   yFloor, currentOctave);
            let TopRight = this.dot_prod_grid(x, y, xFloor+1, yFloor, currentOctave);
            let BottomLeft = this.dot_prod_grid(x, y, xFloor,   yFloor+1, currentOctave);
            let BottomRight = this.dot_prod_grid(x, y, xFloor+1, yFloor+1, currentOctave);

            // Interpolate between values at Grid Corners
            let xTop = this.interpolate(x-xFloor, TopLeft, TopRight);
            let xBottom = this.interpolate(x-xFloor, BottomLeft, BottomRight);
            let Value = this.interpolate(y-yFloor, xTop, xBottom);

            // Adjust amplitude based on octave
            Value /= (Math.pow(2, currentOctave-1));

            // Saves to cache in case the same coord is called in the future
            this.perlin_cache.set(key, Value);
            TotalPerlin += Value;

            // Adjust x and y to octaves for future iterations
            x *= 2;
            y *= 2;
        }
        return TotalPerlin;
    }
}

// Initialises the caches as soon as the object is defined
perlin.seed();