diff --git a/boxes-with-physics-demo.css b/boxes-with-physics-demo.css new file mode 100644 index 0000000..7aca434 --- /dev/null +++ b/boxes-with-physics-demo.css @@ -0,0 +1,9 @@ + +#drawing-area { + border: 1px solid black; + position: fixed; + top: 10px; + left: 10px; + bottom: 10px; + right: 10px; +} diff --git a/boxes-with-physics-demo.html b/boxes-with-physics-demo.html new file mode 100644 index 0000000..2a7b4cd --- /dev/null +++ b/boxes-with-physics-demo.html @@ -0,0 +1,45 @@ + + + + + + + + + + Direct Manipulation on a Web Page: Physics Version + + + + + + + + +
+ + + +
+
+ +
+
+ +
+ + + + + + + + + + + + diff --git a/boxes-with-physics-demo.js b/boxes-with-physics-demo.js new file mode 100644 index 0000000..f59db0f --- /dev/null +++ b/boxes-with-physics-demo.js @@ -0,0 +1,2 @@ + +$(() => $("#drawing-area").boxesWithPhysics()); diff --git a/boxes-with-physics.css b/boxes-with-physics.css new file mode 100644 index 0000000..9c5350d --- /dev/null +++ b/boxes-with-physics.css @@ -0,0 +1,22 @@ +/* Drawing area properties. */ +.drawing-area { + position: relative; + user-select: none; +} + +/* Box properties and their variants. */ +.box { + border: thin solid black; + position: absolute; +} + +.brick { + border: thin solid red; + position: absolute; +} + +.brick-highlight { + box-shadow: 0px 0px 6px #88F; + -moz-box-shadow: 0px 0px 6px #88F; + -webkit-box-shadow: 0px 0px 6px #88F; +} diff --git a/boxes-with-physics.js b/boxes-with-physics.js new file mode 100644 index 0000000..ddd2856 --- /dev/null +++ b/boxes-with-physics.js @@ -0,0 +1,271 @@ +($ => { + + /** + * Tracks a box as it is rubberbanded or moved across the drawing area. + * Note how we can use arrow function notation here because we don't need + * the `this` variable in this implementation. + */ + + let startDraw = event => { + $.each(event.changedTouches, function (index, touch) { + + + // We only respond to the left mouse button. + + // Add a new box to the drawing area. Note how we use + // the drawing area as a holder of "local" variables + // ("this" as bound by jQuery---which is also why we don't + // use arrow function notation here). + touch.target.anchorX = event.pageX; + touch.target.anchorY = event.pageY; + let position = { left: touch.target.anchorX, top: touch.target.anchorY }; + + touch.target.drawingBrick = $("
") + .appendTo("div.drawing-area") + .addClass("brick") + .data({ position }) // This is our model. + .offset(position); // This is our view. We keep them separate because the browser might change this. + + // Take away the highlight behavior while the draw is + // happening. + + + + }); + }; + + + + let trackDrag = event => { + $.each(event.changedTouches, function (index, touch) { + // Don't bother if we aren't tracking anything. + if (touch.target.movingBrick) { + // Reposition the object. + let newPosition = { + left: touch.pageX - touch.target.deltaX, + top: touch.pageY - touch.target.deltaY + }; + + // This form of `data` allows us to update values one attribute at a time. + $(touch.target).data('position', newPosition); + touch.target.movingBrick.offset(newPosition); + } + else if (touch.target.drawingBrick){ + let newPosition = { + left: (touch.target.anchorX < event.pageX) ? touch.target.anchorX : event.pageX, + top: (touch.target.anchorY < event.pageY) ? touch.target.anchorY : event.pageY + }; + + touch.target.drawingBrick + .data({ position: newPosition }) + .offset(newPosition) + .width(Math.abs(event.pageX - touch.target.anchorX)) + .height(Math.abs(event.pageY - touch.target.anchorY)); + + } + + }); + + // Don't do any touch scrolling. + event.preventDefault(); + }; + + + /** + * Concludes a drawing or moving sequence. + */ + let endDrag = event => { + $.each(event.changedTouches, (index, touch) => { + + if (touch.target.movingBrick) { + // Change state to "not-moving-anything" by clearing out + // touch.target.movingBrick. + touch.target.movingBrick = null; + } + else if (touch.target.drawingBrick){ + touch.target.drawingBrick + .bind("touchmove", trackDrag) + .bind("touchend", unhighlight) + .bind("touchstart", startMove); + + touch.target.drawingBrick = null; + } + + }); + }; + + /** + * Indicates that an element is unhighlighted. + */ + let unhighlight = event => $(event.currentTarget).removeClass("brick-highlight"); + + /** + * Begins a box move sequence. + */ + let startMove = event => { + $.each(event.changedTouches, (index, touch) => { + // Highlight the element. + $(touch.target).addClass("brick-highlight"); + + // Take note of the box's current (global) location. Also, set its velocity and acceleration to + // nothing because, well, _finger_. + let targetBrick = $(touch.target); + let startOffset = targetBrick.offset(); + targetBrick.data({ + position: startOffset + }); + + // Set the drawing area's state to indicate that it is + // in the middle of a move. + touch.target.movingBrick = targetBrick; + touch.target.deltaX = touch.pageX - startOffset.left; + touch.target.deltaY = touch.pageY - startOffset.top; + }); + + // Eat up the event so that the drawing area does not + // deal with it. + event.stopPropagation(); + }; + + /** + * The motion update routine. + */ + const FRICTION_FACTOR = 0.99; + const ACCELERATION_COEFFICIENT = 0.05; + const FRAME_RATE = 120; + const FRAME_DURATION = 1000 / FRAME_RATE; + + let lastTimestamp = 0; + let updateBoxes = timestamp => { + if (!lastTimestamp) { + lastTimestamp = timestamp; + } + + // Keep that frame rate under control. + if (timestamp - lastTimestamp < FRAME_DURATION) { + window.requestAnimationFrame(updateBoxes); + return; + } + + $("div.box").each((index, element) => { + let $element = $(element); + + // If it's highlighted, we don't accelerate it because it is under a finger. + if ($element.hasClass("brick-highlight")) { + return; + } + + // Note how we base all of our calculations from the _model_... + let s = $element.data('position'); + let v = $element.data('velocity'); + let a = $element.data('acceleration'); + + // The standard update-bounce sequence. + s.left += v.x; + s.top -= v.y; + + v.x += (a.x * ACCELERATION_COEFFICIENT); + v.y += (a.y * ACCELERATION_COEFFICIENT); + v.z += (a.z * ACCELERATION_COEFFICIENT); + + v.x *= FRICTION_FACTOR; + v.y *= FRICTION_FACTOR; + v.z *= FRICTION_FACTOR; + + let $parent = $element.parent(); + let bounds = { + left: $parent.offset().left, + top: $parent.offset().top + }; + + bounds.right = bounds.left + $parent.width(); + bounds.bottom = bounds.top + $parent.height(); + + + let wid = $element.width(); + let hei = $element.height(); + + + if ((s.left <= bounds.left) || (s.left + wid > bounds.right)) { + s.left = (s.left <= bounds.left) ? bounds.left : bounds.right - $element.width(); + v.x = -v.x; + } + + if ((s.top <= bounds.top) || (s.top + hei > bounds.bottom)) { + s.top = (s.top <= bounds.top) ? bounds.top : bounds.bottom - $element.height(); + v.y = -v.y; + } + + + // ...and the final result is sent on a one-way trip to the _view_. + $(element).offset(s); + }); + + + + lastTimestamp = timestamp; + window.requestAnimationFrame(updateBoxes); + }; + + /** + * Sets up the given jQuery collection as the drawing area(s). + */ + let setDrawingArea = jQueryElements => { + // Set up any pre-existing box elements for touch behavior. + jQueryElements + .addClass("drawing-area") + .each((index, element) => { + $(element) + .bind("touchstart", startDraw) + .bind("touchmove", trackDrag) + .bind("touchend", endDrag); + + }); + // Event handler setup must be low-level because jQuery + // doesn't relay touch-specific event properties. + + + + jQueryElements + .find("div.box").each((index, element) => { + $(element) + .off() + .data({ + position: $(element).offset(), + velocity: { x: 0, y: 0, z: 0 }, + acceleration: { x: 0, y: 0, z: 0 } + }); + }); + + jQueryElements + .find("div.brick").each((index, element) => { + $(element) + .bind("touchstart", startMove) + .bind("touchmove", trackDrag) + .bind("touchend", unhighlight) + .data({ + position: $(element).offset() + }); + }); + + + + + // In this sample, device acceleration is the _sole_ determiner of a box's acceleration. + window.ondevicemotion = event => { + let a = event.accelerationIncludingGravity; + $("div.box").each((index, element) => { + $(element).data('acceleration', a); + }); + }; + + // Start the animation sequence. + window.requestAnimationFrame(updateBoxes); + }; + + // No arrow function here because we don't want lexical scoping. + $.fn.boxesWithPhysics = function () { + setDrawingArea(this); + return this; + }; +})(jQuery);