<header>
  <h1>jSphere</h1>
  <h2>Just to see what I could do.</h2>
</header>
<main>
  <section id="controls">
    <h3>Controls</h3>
    <ul id="controls-listing">
      <li>Rotate: Click and drag</li>
      <li>Pan: Hold shift, click and drag</li>
      <li>Zoom: Hold ctrl, click and drag</li>
    </ul>
  </section>
  <canvas id="canvas" width="800" height="700"></canvas>
  <p class="psst">Psst. There are a couple (that's 2) hidden key combos that do some things that I found by accident, so play around.</p>
</main>
body {
  background-color: #333;
  font-family: Lato;
  color: #DDD;
}

h1,h2,h3,h4 {
  font-weight: 100;
  position: relative;
  margin: 30px auto;
  text-align: center;
}
h1 {
  font-size: 64px;
}
p {
  line-height: 30px;
}

header > h2 {
  font-style: italic;
}


main {
  max-width: 810px;
  margin: 0px auto;

  display: flex;
  flex-direction: column;
  align-items: stretch;
}

#controls > h3 {
  text-align: center;
  margin-bottom: 15px;
}

ul#controls-listing {
  /** removing the default ul padding*/
  padding: 0;

  /* flexbox fallback if grid is not supported */
  display: flex;
  justify-content: space-between;

  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 1fr;
}
ul#controls-listing > li {
  list-style: none;
  text-align: center;
}
ul#controls-listing > li:first-child {
  text-align: left;
}
ul#controls-listing > li:last-child {
  text-align: right;
}

canvas {
  border: 2px solid black;
  touch-action: none;
}

.psst {
  font-style: italic;
}
/* Drawing util functions */

/**
 * Draw a solid-color circle. Equivalent to `ctx.fillRect()`, but for circles!
 * @param {CanvasRenderingContext2D} ctx
 * @param {Number} x        x coordinate of the center point of the circle
 * @param {Number} y        y coordinate of the center point of the circle
 * @param {Number} r        radius of the circle in pixels
 * @param {String} color    Fill color of the circle
 */
function fillCircle(ctx, x, y, r, color) {
    const oldFS = ctx.fillStyle;
    ctx.fillStyle = color;
    ctx.beginPath();
    ctx.arc(x, y, r, 0, 2 * Math.PI, false);
    ctx.closePath();
    ctx.fill();
    ctx.fillStyle = oldFS;
}

/**
 * Fill a circle with a radial gradient.
 * @param {CanvasRenderingContext2D} ctx
 * @param {Number} x        x coordinate of the center point of the circle
 * @param {Number} y        y coordinate of the center point of the circle
 * @param {Number} r        radius of the circle in pixels
 * @param {String} color1   Start color for the gradient
 * @param {String} color2   End color for the gradient
 */
function fillCircleGradient(ctx, x, y, r, color1, color2) {
    const oldFS = ctx.fillStyle;
    ctx.beginPath();
    ctx.arc(x, y, r, 0, 2 * Math.PI, false);
    ctx.closePath();
    // create radial gradient
    const grd = ctx.createRadialGradient(x-r/5, y-r/5, 0, x-r/5, y-r/5, r*0.8);
    // light blue
    grd.addColorStop(0, color1);
    // dark blue
    grd.addColorStop(1, color2);
    ctx.fillStyle = grd;
    ctx.fill();
    ctx.fillStyle = oldFS;
}



/**
 * @class Dot
 * The Dot class represents one point on the surface of sphere.
 * Each Dot is displayed as a smaller sphere attached to its point.
 *
 * When drawn to the canvas, each dot is considered to be either in
 * the foreground or the background. This is determined by the Sphere
 * object, but basically, if its Z value is less than that of the
 * sphere's origin (center) point, it is in the background. Otherwise,
 * it is in the foreground.
 *
 * If in the foreground, the dot has a 10 pixel radius and is drawn with
 * a dark color. If in the background, the dot has a 5 pixel radius and
 * is drawn with a very light color, with those furthest back being almost
 * invisible. This gives the illusion of depth.
 *
 * @member {Number} x       The x coordinate of the dot
 * @member {Number} y       The y coordinate of the dot
 * @member {Number} z       The z coordinate of the dot
 * @member {Boolean} fg     Flag indicating whether the dot is in the foreground
 */
class Dot {
    constructor({x=0, y=0, z=0, fg=true}={}) {
        Object.assign(this, {x, y, z, fg});
    }

    /**
     * Scale this dot's position by multiplying all corrdinates by the given scale factor
     * @param {Number} scaleFactor
     */
    scale(scaleFactor) {
        this.x *= scaleFactor
        this.y *= scaleFactor
        this.z *= scaleFactor
    }

    /**
     * Move this dot to a new position indicated by the given x, y, and z distances
     * @param {Number} [x=0]
     * @param {Number} [y=0]
     * @param {Number} [z=0]
     */
    translate({x=0, y=0, z=0}) {
        this.x += x
        this.y += y
        this.z += z
    }
}

/**
 * @class Sphere
 * The Sphere class represents the sphere object itself. It is basically
 * comprised of a set of points on its surface and various numbers to keep track
 * of how it should be drawn next.
 *
 * When drawn to the canvas, the dot list is dynamically sorted by Z-value and
 * drawn back-to-front, so that the foremost dots are consistently drawn on top
 * of those behind them. The further back a dot is (i.e. the lower its z-value)
 * the lighter it is colored. This way, those in back blend into the background,
 * becoming almost invisible.
 *
 * @member {Number} x       The x coordinate of the center of the sphere
 * @member {Number} y       The y coordinate of the center of the sphere
 * @member {Number} z       The z coordinate of the center of the sphere
 * @member {Number} r       Radius of the sphere.
 * @member {Number} circleSize  Radius of the dots to be drawn.
 * @member {Boolean} drawSpheres    Flag indicating whether surface Dots should
 *                                  be drawn as spheres or points.
 * @member {Array.<Array.<Dot>>} points     2D array of Dots on the surface of
 *                                          the sphere
 * @member {CanvasRenderingContext2D} ctx   Canvas context with which to draw
 */
class Sphere {
    constructor({ctx, x=200, y=200, z=0, r=90, drawSpheres=false}={}) {
        Object.assign(this, {ctx, x, y, z, r, drawSpheres});

        this.canvas = ctx.canvas;

        // 10 pixel dots
        this.circleSize = 10;

        // The angle delta to use when calculating the surface point positions;
        // a larger angstep means fewer points on the surface
        const angstep = Math.PI/10;
        this.points = [];
        // Loop from 0 to 2*pi, creating one row of points at each step
        for (let angxy=0; angxy<2*Math.PI; angxy+=angstep){
            for (let angyz=0; angyz<2*Math.PI; angyz+=angstep) {
                // Loop from 0 to 2*pi, creating one point at each step
                this.points.push(new Dot({
                    x: r * Math.cos(angxy) * Math.sin(angyz) + x,
                    y: r * Math.sin(angxy) * Math.sin(angyz) + y,
                    z: r * Math.cos(angyz) + z,
                    fg: Math.cos(angyz) > 0
                }));
            }
        }
    }

    /**
     * Draw to the canvas
     */
    draw() {
        // Store the context's fillStyle
        const tmpStyle = this.ctx.fillStyle;


        // Clear the canvas
        this.canvas.width -= 1;
        this.canvas.width += 1;

        // Set the canvas background color
        this.ctx.fillStyle = '#FFF';
        // Fill the canvas with the selected background color
        this.ctx.fillRect (0, 0, this.canvas.width, this.canvas.height);

        // Sort the points by z-value

        // Clone the points array to avoid modifying it
        const z_sorted = this.points.slice();

        // Add the origin point of the sphere
        z_sorted.push(new Dot({x: this.x, y: this.y, z: this.z}));

        // Sort the points by z value
        z_sorted.sort(function(a,b){return (b.z-a.z)});


        for (const point of z_sorted) {
            let color;
            // If drawing the origin point, draw it blue
            if (point.x == this.x && point.y == this.y && point.z == this.z) {
                color = "#27F";
            }
            // Else, draw the point a shade of gray relative to the z-value,
            // with darker pixels in the front and lighter pixels farther back
            else {
                const n = Math.round(((point.z + this.r)/(this.r*2)) * 250);
                color = "rgb(" + n + "," + n + "," + n + ")";
            }

            if (this.drawSpheres) {
                fillCircleGradient(this.ctx, point.x, point.y, this.circleSize, "#FFFFFF", color);
            }
            else {
                fillCircle(this.ctx, point.x, point.y, this.circleSize, color);
            }
        }

        // Restore the context's fillStyle
        this.ctx.fillStyle = tmpStyle;
    }

    /**
     * Zoom in or out (ctrl drag)
     */
    zoom(x, y) {
        const length = Math.round(Math.sqrt(x*x + y*y));
        const scaleFactor = (this.r + (x > 0 ? length : -length)) / this.r;

        // Scale the sphere
        this.x *= scaleFactor;
        this.y *= scaleFactor;
        this.z *= scaleFactor;
        this.r *= scaleFactor;
        this.circleSize *= scaleFactor;

        // Scale each point
        for (const point of this.points) {
            point.scale(scaleFactor);
        }
        // Redraw in new positions
        this.draw();
    }

    /**
     * Pan (shift drag)
     */
    pan(x, y) {
        // Translate the sphere's origin
        this.x += x;
        this.y += y;

        // Translate each point
        for (const point of this.points) {
            point.translate({x, y})
        }

        // Redraw in new positions
        this.draw();
    }

    /**
     * Rotate the sphere (drag with no modifier keys)
     */
    rotate(x, y) {
        // Vertical rotation
        this.rotateXZ(((Math.PI/2)/this.r)*x);
        // Horizontal rotation
        this.rotateYZ(((Math.PI/2)/this.r)*y);

        // Redraw in new positions
        this.draw();
    }

    /**
     * Rotate around the z-axis
     */
    rotateXY(ang) {
        for (const point of this.points) {
            const px = point.x - this.x;
            const py = point.y - this.y;

            const newx = px*Math.cos(ang)-py*Math.sin(ang);
            const newy = px*Math.sin(ang)+py*Math.cos(ang);

            point.x = newx+this.x;
            point.y = newy+this.y;
        }
    }

    /**
     * Rotate around the y-axis
     */
    rotateXZ(ang) {
        for (const point of this.points) {
            const px = point.x - this.x;
            const pz = point.z - this.z;

            const newx = px*Math.cos(ang)-pz*Math.sin(ang);
            const newz = px*Math.sin(ang)+pz*Math.cos(ang);

            point.x = newx+this.x;
            point.z = newz+this.z;
        }
    }

    /**
     * Rotate around the x-axis
     */
    rotateYZ(ang) {
        for (const point of this.points) {
            const py = point.y - this.y;
            const pz = point.z - this.z;

            const newy = py*Math.cos(ang)-pz*Math.sin(ang);
            const newz = py*Math.sin(ang)+pz*Math.cos(ang);

            point.y = newy+this.y;
            point.z = newz+this.z;
        }
    }

    /**
     * Split pan!  (Hidden function, alt+shift drag)
     */
    hiddenFun1(x, y) {
        // Extend the radius of the sphere
        this.r += Math.round(Math.sqrt(x*x + y*y));

        // Translate each point
        for (const point of this.points) {
            point.translate({
                x: point.x > this.x ? x : -x,
                y: point.y > this.y ? y : -y
            })
        }

        // Redraw in new positions
        this.draw();
    }

    /**
     * Cigar Zoom!  (Hidden function, alt+ctrl drag)
     */
    hiddenFun2(x, y) {
        const length = Math.round(Math.sqrt(x*x + y*y));
        const scaleFactor = (this.r + (x>0?length:-length))/this.r;

        // Scale the sphere
        this.r += length;
        this.x *= scaleFactor;
        this.y *= scaleFactor;

        // Scale each point
        for (const point of this.points) {
            point.scale(scaleFactor);
        }

        // Redraw in new positions
        this.draw();
    }

}


/****************
 * Main code    *
 ****************/
function main() {
    const canvas = document.getElementById("canvas");
    const ctx = canvas.getContext("2d");
    const sphere = new Sphere({
        ctx,
        x: canvas.width/2,
        y: canvas.height/2,
        z: 0,
        r: 275,
        drawSpheres: true
    });
    sphere.draw();

    // Prefer 'pointer' events when available
    const pointerEvent = (
      'onpointermove' in document.body
        ? 'pointer'
        : 'mouse'
    );
    // Shorthands for 'mouse' or 'pointer' events
    const [downEvt, upEvt, moveEvt, outEvt] = (
      ['down', 'up', 'move', 'out']
        .map(evtType => pointerEvent + evtType)
    );

    // Drag events
    let dragOrigin;
    canvas.addEventListener(downEvt, dragStartHandler)
    function dragStartHandler(e){
        dragOrigin = {
            x: e.clientX,
            y: e.clientY
        };
        document.addEventListener(moveEvt, dragHandler);

        window.addEventListener(upEvt, dragStopHandler);
        window.addEventListener(outEvt, dragStopHandler);

        canvas.addEventListener(outEvt, stopPropagation);
        document.body.addEventListener(outEvt, stopPropagation);
    }

    function dragHandler(e){
        if (e.ctrlKey || e.metaKey) {
            if (e.altKey) sphere.hiddenFun2(e.clientX-dragOrigin.x, e.clientY-dragOrigin.y);
            else sphere.zoom(e.clientX-dragOrigin.x, e.clientY-dragOrigin.y, -1);
        }
        else if (e.shiftKey) {
            if (e.altKey) sphere.hiddenFun1(e.clientX-dragOrigin.x, e.clientY-dragOrigin.y);
            else sphere.pan(e.clientX-dragOrigin.x, e.clientY-dragOrigin.y);
        }
        else {
            sphere.rotate(e.clientX-dragOrigin.x, e.clientY-dragOrigin.y);
        }
        dragOrigin.x = e.clientX;
        dragOrigin.y = e.clientY;
    }

    function dragStopHandler(e) {
        document.removeEventListener(moveEvt, dragHandler);
        document.removeEventListener(upEvt, dragStopHandler);
        document.removeEventListener(outEvt, dragStopHandler);

        canvas.removeEventListener(outEvt, stopPropagation);
        document.body.removeEventListener(outEvt, stopPropagation);
    }

    function stopPropagation(e) {
        e.stopPropagation();
    }
}
main();

External CSS

  1. https://fonts.googleapis.com/css?family=Lato

External JavaScript

This Pen doesn't use any external JavaScript resources.