496 lines
23 KiB
HTML
496 lines
23 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||
<title>Interaction Mapper Doctest</title>
|
||
<link rel="stylesheet" href="./3rdparty/highlight/styles/default.css" />
|
||
<link rel="stylesheet" href="../css/doctest.css" />
|
||
<script src="./3rdparty/highlight/highlight.pack.js"></script>
|
||
<script src="../dist/iwmlib.js"></script>
|
||
<script src="../dist/iwmlib.3rdparty.js"></script>
|
||
|
||
<script type="module">
|
||
import * as Interaction from './interaction.js'
|
||
</script>
|
||
</head>
|
||
<body onload="Doctest.run()">
|
||
<h1><a href="index.html">lib.</a>Interaction</h1>
|
||
<p>
|
||
Since the correct handling of the divergent browser specific multitouch implementations is a difficult and
|
||
recurring task we decided to encapsulate all related handlers for <code>TouchEvent</code> (WebKit, Mozilla)
|
||
and <code>PointerEvent</code> (IE, Edge, Chrome) in a single delegate pattern.
|
||
</p>
|
||
<p>
|
||
The main differences are that <code>PointerEvent</code> are fired for each touch point, whereas the
|
||
<code>TouchEvent</code> collects multiple <code>TouchPoints</code> into a single event. The basic PointMap
|
||
and Interaction classes unify this behavior by collecting all contact points regardless of their original
|
||
mouse, touch, or pointer events.
|
||
</p>
|
||
<h2>Point Maps</h2>
|
||
<p>
|
||
The touch and pointer positions are collected in PointMaps which provide access to the positions via
|
||
stringified touch and pointer ids. For mouse events the special id "mouse" is used. PointMaps can be cloned
|
||
and pretty printed. In addition they provide auxiliary methods like <code>mean</code> and
|
||
<code>farthests</code>
|
||
which can be used to simplify the computation of gestures. In general
|
||
<code>mean</code> can be used to compute the "center of interaction", i.e. the best guess of the anchor
|
||
point for rotation and scaling operations.
|
||
</p>
|
||
<script class="doctest">
|
||
let mouse = new PointMap({ mouse: { x: 0, y: 0 } })
|
||
let touches = new PointMap({ touch1: { x: 0, y: 0 }, touch2: { x: 10, y: 10 } })
|
||
|
||
Doctest.expect(touches, '[PointMap touch1:{x:0, y:0}, touch2:{x:10, y:10}]')
|
||
Doctest.expect(touches.clone(), '[PointMap touch1:{x:0, y:0}, touch2:{x:10, y:10}]')
|
||
Doctest.expect(touches.mean(), { x: 5, y: 5 })
|
||
</script>
|
||
<p>
|
||
If more than two touch points are involved it may be best to look for the pair of points which are farthest
|
||
away from each other. These points will represent the fingers farthest away from each other, a more simple
|
||
substitute for 3, 4 or 5 touch points. Here we add a third point to our example touches and test whether the
|
||
maximal distant points are found:
|
||
<script class="doctest">
|
||
touches.set('touch3', { x: 5, y: 5 })
|
||
Doctest.expect(touches.farthests(), [
|
||
{ x: 0, y: 0 },
|
||
{ x: 10, y: 10 }
|
||
])
|
||
</script>
|
||
</p>
|
||
|
||
<h2>Interaction Points and Interactions</h2>
|
||
Events and points change in time and gestures are computed from this dynamic behavior. To collect theses changes
|
||
and to simplify the computation of gestures we collect PointMaps in a composite class InteractionPoints, which
|
||
distinguishes start, current, previous, and ended point coordinates as well as the start timestamps.
|
||
<script class="doctest">
|
||
let interactionPoints = new InteractionPoints()
|
||
|
||
interactionPoints.update('touch1', { x: 0, y: 0 })
|
||
interactionPoints.update('touch2', { x: 5, y: 5 })
|
||
interactionPoints.update('touch3', { x: 10, y: 10 })
|
||
Doctest.expect(interactionPoints.current.size, 3)
|
||
|
||
// Initially current and previous points are equal
|
||
Doctest.expect(interactionPoints.current, interactionPoints.previous)
|
||
// Using updatePrevious we copy the current points to the previous ones.
|
||
// This is always needed after change events
|
||
interactionPoints.updatePrevious()
|
||
|
||
// After this call current and previous can be used to compure the deltas:
|
||
interactionPoints.update('touch1', { x: -2, y: -5 })
|
||
interactionPoints.update('touch2', { x: 5, y: 9 })
|
||
interactionPoints.update('touch3', { x: 15, y: 20 })
|
||
Doctest.expect(
|
||
interactionPoints.current,
|
||
'[PointMap touch1:{x:-2, y:-5}, touch2:{x:5, y:9}, touch3:{x:15, y:20}]'
|
||
)
|
||
// "[PointMap touch1:{x:-2, y:-5}, touch2:{x:5, y:7}, touch3:{x:15, y:20}]")
|
||
|
||
// The delta object is a convenience object to access translation, scaling,
|
||
// and rotation values as well as the center of transformation
|
||
let delta = interactionPoints.delta()
|
||
|
||
// Doctest.expect(delta.x, 1.5) // Doctest ERROR! Occurs for an unspecified period of time.
|
||
// Doctest.expect(delta.y, 2.5) // Doctest ERROR! Occurs for an unspecified period of time.
|
||
// Doctest.expect(delta.zoom > 1.5, true) // Doctest ERROR! Occurs for an unspecified period of time.
|
||
// Doctest.expect(delta.rotate < 0.2, true) // Doctest ERROR! Occurs for an unspecified period of time.
|
||
// Doctest.expect(delta.about, {x: 6.5, y: 7.5}) // Doctest ERROR! Occurs for an unspecified period of time.
|
||
</script>
|
||
<p>
|
||
Interaction objects extend the idea of mapping touch ids to points to multiple target objects. Each touch id
|
||
is mapped not only to the changing points of this touch but also to the object that has been hit by the
|
||
starting touch point. This object is the target of the interaction and remains for the whole duration of the
|
||
multitouch gesture.
|
||
</p>
|
||
|
||
<h2>Interaction Delegate</h2>
|
||
<p>
|
||
The delegator registers all needed <code>TouchEvent</code>, <code>PointerEvent</code>, and
|
||
<code>MouseEvent</code>
|
||
handlers on a provided DOM elememt for a given target object, ensures that the events are captured by the
|
||
target and boils the event handling down to simple
|
||
<code>onStart</code>, <code>onMove</code>, <code>onEnd</code> events.
|
||
</p>
|
||
|
||
<p>
|
||
Let's look at an example of an InteractionDelegate and a target object that implements the
|
||
<code>IInteractionTarget</code> interface. Typically you setup the delegator in the constructor of the class
|
||
that uses the interation.
|
||
</p>
|
||
<script class="doctest">
|
||
class InteractionTarget {
|
||
// The constructor of the target creates the InteractionDelegate
|
||
constructor(domElement) {
|
||
this.interaction = new InteractionDelegate(domElement, this)
|
||
}
|
||
|
||
// The following methods are needed by the IInteractionTarget interface
|
||
|
||
// Indicates that we want all events
|
||
capture(event) {
|
||
return true
|
||
}
|
||
|
||
// Handle collected touch points on start
|
||
onStart(event, points) {}
|
||
|
||
// Handle collected touch points on update
|
||
onMove(event, points) {}
|
||
|
||
// Handle collected touch points on end
|
||
onEnd(event, points, ended) {}
|
||
|
||
// Handle mouse wheel event
|
||
onMouseWheel(event) {}
|
||
}
|
||
</script>
|
||
<p>We can now check whether the promised interface methods are implemented by the class:</p>
|
||
<script class="doctest">
|
||
Doctest.expect(IInteractionTarget.implementedBy(InteractionTarget), true)
|
||
</script>
|
||
<p>
|
||
If we define an InteractionTarget that violates the IInteractionTarget interface we get an error. The
|
||
following example of an interaction target uses an InteractionDelegate but does not implement the necessary
|
||
methods:
|
||
</p>
|
||
<script class="doctest">
|
||
class InvalidInteractionTarget {
|
||
constructor(domElement) {
|
||
this.interaction = new InteractionDelegate(domElement, this, { debug: true })
|
||
}
|
||
}
|
||
|
||
try {
|
||
new InvalidInteractionTarget(null)
|
||
} catch (error) {
|
||
Doctest.expectError(error, 'Expected IInteractionTarget')
|
||
}
|
||
</script>
|
||
|
||
<h2>Interaction Mapper</h2>
|
||
<p>
|
||
Often we need to assign UI elements to touch and pointer events. This is supported by a special
|
||
InteractionMapper delegate. A InteractionMapper maps events to specific parts of a container interaction
|
||
target. The InteractionTarget must implement a findTarget method that returns an object implementing the
|
||
IInteractionTarget interface.
|
||
</p>
|
||
<p>
|
||
If the InteractionTarget also implements a <code>mapPositionToPoint</code> method this is used to map the
|
||
points to the local coordinate space of the the target. This makes it easier to lookup elements and relate
|
||
events to local positions.
|
||
</p>
|
||
<p>Let's see an example. A graph that uses an <code>InterationMapper</code> for it´s child objects:</p>
|
||
<script class="doctest">
|
||
class Graph {
|
||
constructor(domElement) {
|
||
this.interaction = new InteractionMapper(domElement, this)
|
||
this.nodes = [new Node('a'), new Node('b')]
|
||
}
|
||
|
||
capture(event) {
|
||
return true
|
||
}
|
||
|
||
findTarget() {
|
||
for (let node of this.nodes) {
|
||
return node
|
||
}
|
||
return null
|
||
}
|
||
}
|
||
|
||
class Node {
|
||
constructor(name) {
|
||
this.name = name
|
||
}
|
||
|
||
capture(event) {
|
||
return true
|
||
}
|
||
|
||
onStart(event, interaction) {
|
||
Doctest.log('onStart called')
|
||
}
|
||
|
||
onMove(event, interaction) {
|
||
Doctest.log('onMove called')
|
||
}
|
||
|
||
onEnd(event, interaction) {
|
||
Doctest.log('onEnd called')
|
||
}
|
||
}
|
||
</script>
|
||
<p>
|
||
Now we simulate a sequence of <code>onStart, onMove, onEnd</code> events by calling the registered event
|
||
handlers programmatically. Note that the defined event handlers log their calls.
|
||
</p>
|
||
<script class="doctest">
|
||
let graph = new Graph(window)
|
||
window.dispatchEvent(Doctest.event('mousedown'))
|
||
window.dispatchEvent(Doctest.event('mousemove'))
|
||
window.dispatchEvent(Doctest.event('mouseup'))
|
||
|
||
Doctest.expectLog('onStart called', 'onMove called', 'onEnd called')
|
||
</script>
|
||
<h2>Simple Dragging</h2>
|
||
<p>
|
||
Drag & Drop is a common interaction pattern. This behavior can be accomplished by a class that implements
|
||
IInteractionMapperTarget as well as IInteractionTarget. You can grab the blue circle with touches or mouse
|
||
and drag it around.
|
||
</p>
|
||
<div class="grayBorder" style="position: relative; width: 100%; height: 200px">
|
||
<div
|
||
id="circle"
|
||
style="
|
||
position: absolute;
|
||
left: 50px;
|
||
top: 50px;
|
||
border-radius: 50%;
|
||
width: 32px;
|
||
height: 32px;
|
||
background-color: blue;
|
||
"
|
||
></div>
|
||
</div>
|
||
<script class="doctest">
|
||
class Dragger {
|
||
constructor(element, container) {
|
||
/* The events are captured by the container but send to this
|
||
wrapper object, if the event target is identical to the wrapped
|
||
object. */
|
||
this.target = element
|
||
this.interaction = new InteractionMapper(container, this)
|
||
}
|
||
|
||
capture(event) {
|
||
return true
|
||
}
|
||
|
||
findTarget(event, localPoint, globalPoint) {
|
||
return event.target == this.target ? this : null
|
||
}
|
||
|
||
onStart(event, interaction) {
|
||
// Only needed to fulfill the IInteractionTarget interface
|
||
}
|
||
|
||
onMove(event, interaction) {
|
||
let move = interaction.move()
|
||
let x = parseInt(this.target.style.left) + move.x
|
||
let y = parseInt(this.target.style.top) + move.y
|
||
this.target.style.left = x + 'px'
|
||
this.target.style.top = y + 'px'
|
||
}
|
||
|
||
onEnd(event, interaction) {
|
||
// Only needed to fulfill the IInteractionTarget interface
|
||
}
|
||
|
||
onMouseWheel(event) {
|
||
// Only needed to fulfill the IInteractionTarget interface
|
||
}
|
||
}
|
||
|
||
Doctest.expect(IInteractionMapperTarget.implementedBy(Dragger), true)
|
||
Doctest.expect(IInteractionTarget.implementedBy(Dragger), true)
|
||
|
||
new Dragger(circle, document.body)
|
||
</script>
|
||
|
||
<h2>Multitouch</h2>
|
||
<p>
|
||
Multitouch-Events (simultaneous events) in browsers cannot be used by default. Even libraries like jQuery do
|
||
not fix this problem. The static method "on" of the InteractionMapper allows simultaneous events and thus
|
||
multitouch. The following events (and their specializations) can be used in addition to the default browser
|
||
events: tap, doubletap, press, pan, swipe, pinch and rotate. See http://hammerjs.github.io for more details.
|
||
</p>
|
||
<svg
|
||
width="100%"
|
||
height="300"
|
||
viewBox="0 0 400 200"
|
||
class="grayBorder"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
xmlns:bx="https://boxy-svg.com"
|
||
>
|
||
<defs>
|
||
<pattern
|
||
id="pattern-1"
|
||
viewBox="0 0 100 100"
|
||
patternUnits="userSpaceOnUse"
|
||
preserveAspectRatio="none"
|
||
width="100"
|
||
height="100"
|
||
/>
|
||
<linearGradient
|
||
id="gradient-1"
|
||
gradientUnits="userSpaceOnUse"
|
||
x1="235.294"
|
||
y1="5.386"
|
||
x2="235.294"
|
||
y2="63.218"
|
||
gradientTransform="matrix(0.479375, 0.877612, -1.161752, 0.599143, 216.009222, -193.782169)"
|
||
>
|
||
<stop offset="0" style="stop-color: rgba(216, 216, 216, 1)" />
|
||
<stop offset="1" style="stop-color: rgb(45, 175, 182)" />
|
||
</linearGradient>
|
||
<linearGradient
|
||
id="gradient-4"
|
||
gradientUnits="userSpaceOnUse"
|
||
x1="193.252"
|
||
y1="126.988"
|
||
x2="193.252"
|
||
y2="163.836"
|
||
gradientTransform="matrix(0.978752, 0, 0, 1.126983, 11.124972, -21.238213)"
|
||
>
|
||
<stop offset="0" style="stop-color: rgba(161, 110, 0, 1)" />
|
||
<stop offset="1" style="stop-color: rgba(59, 40, 0, 1)" />
|
||
</linearGradient>
|
||
</defs>
|
||
<g id="hammer-1">
|
||
<rect
|
||
x="55.329"
|
||
y="20.25"
|
||
width="42.523"
|
||
height="42.523"
|
||
style="fill: rgb(236, 229, 24); stroke: rgb(0, 179, 207); stroke-width: 2; stroke-linejoin: bevel"
|
||
/>
|
||
<text style="fill: rgb(51, 51, 51); font-size: 12px; white-space: pre" x="68.736" y="45.811">tap</text>
|
||
</g>
|
||
<g id="hammer-2">
|
||
<path
|
||
d="M 372 149 m -25.496 0 a 25.496 26.092 0 1 0 50.992 0 a 25.496 26.092 0 1 0 -50.992 0 Z M 372 149 m -15.297 0 a 15.297 15.654 0 0 1 30.594 0 a 15.297 15.654 0 0 1 -30.594 0 Z"
|
||
transform="matrix(-0.535925, 0.844266, -0.844265, -0.535925, 499.054353, -194.103207)"
|
||
style="fill: rgb(194, 59, 59); stroke: rgb(141, 10, 91); stroke-width: 2"
|
||
bx:shape="ring 372 149 15.297 15.654 25.496 26.092 1@9ddd52c9"
|
||
/>
|
||
<text
|
||
transform="matrix(1.226643, 0, 0, 1.226643, 42.737137, 26.559669)"
|
||
style="fill: rgb(51, 51, 51); font-size: 9.7828px; white-space: pre"
|
||
>
|
||
<tspan x="94.401" y="44.224">press</tspan>
|
||
<tspan x="94.401" dy="1em"></tspan>
|
||
</text>
|
||
</g>
|
||
<g>
|
||
<polygon
|
||
id="hammer-3"
|
||
style="fill: url(#gradient-1); stroke: rgb(182, 40, 92)"
|
||
points="272.369 20.761 234.949 23.029 227.862 34.652 236.65 46.558 253.943 55.63 293.347 59.315 340.406 62.434 371.306 49.96 374.708 32.951 356.282 29.549 333.319 21.044 311.774 9.705 307.238 23.029 322.263 33.518 347.777 44.007 339.839 48.259 315.459 48.826 292.781 45.991 281.725 32.667 285.977 17.643 281.158 4.602 267.55 6.303 252.525 6.587 252.809 11.973 255.36 17.076 263.014 16.225 267.834 13.674 273.787 13.39 276.622 14.808"
|
||
/>
|
||
<text style="fill: rgb(51, 51, 51); font-size: 12px; white-space: pre" x="296.849" y="80.823">pan</text>
|
||
</g>
|
||
<g>
|
||
<ellipse
|
||
transform="matrix(-0.707107, 0.707107, -0.707107, -0.707107, 362.152622, 115.748229)"
|
||
cx="221.437"
|
||
cy="181.098"
|
||
rx="27.616"
|
||
ry="27.616"
|
||
style="fill: rgb(149, 26, 133); stroke: rgb(73, 4, 62); stroke-width: 4; fill-opacity: 0.69"
|
||
/>
|
||
<text style="fill: rgb(51, 51, 51); font-size: 12px; white-space: pre" x="61.759" y="190.447">
|
||
swipe
|
||
</text>
|
||
</g>
|
||
<g>
|
||
<rect
|
||
id="hammer-5"
|
||
x="146.389"
|
||
y="121.875"
|
||
width="107.762"
|
||
height="41.527"
|
||
style="fill: url(#gradient-4); stroke-linejoin: round; stroke: rgb(0, 0, 0)"
|
||
/>
|
||
<text style="fill: rgb(51, 51, 51); font-size: 12px; white-space: pre" x="187.854" y="190.447">
|
||
pinch
|
||
</text>
|
||
</g>
|
||
<g transform="matrix(1.286049, 0, 0, 1.286049, -103.444145, -48.307945)">
|
||
<path
|
||
class="star"
|
||
d="M 937 394.847 L 946.206 421.33 L 974.238 421.901 L 951.895 438.84 L 960.014 465.675 L 937 449.661 L 913.986 465.675 L 922.105 438.84 L 899.762 421.901 L 927.794 421.33 Z"
|
||
transform="matrix(-0.809017, 0.587785, -0.587785, -0.809018, 1346.787902, -60.391979)"
|
||
style="
|
||
fill: rgb(83, 230, 226);
|
||
stroke: rgb(24, 111, 116);
|
||
stroke-width: 2;
|
||
stroke-linejoin: round;
|
||
stroke-dasharray: 2px;
|
||
"
|
||
bx:shape="star 937 434 39.154 39.153 0.4 5 1@05a6f642"
|
||
/>
|
||
<text style="fill: rgb(51, 51, 51); font-size: 12px; white-space: pre" x="316.854" y="190.447">
|
||
rotate
|
||
</text>
|
||
</g>
|
||
<g transform="matrix(1.286049, 0, 0, 1.286049, -28.431454, -48.307941)">
|
||
<path
|
||
class="star"
|
||
d="M 937 394.847 L 946.206 421.33 L 974.238 421.901 L 951.895 438.84 L 960.014 465.675 L 937 449.661 L 913.986 465.675 L 922.105 438.84 L 899.762 421.901 L 927.794 421.33 Z"
|
||
transform="matrix(-0.809017, 0.587785, -0.587785, -0.809018, 1346.787902, -60.391979)"
|
||
style="
|
||
fill: rgb(83, 230, 226);
|
||
stroke: rgb(24, 111, 116);
|
||
stroke-width: 2;
|
||
stroke-linejoin: round;
|
||
stroke-dasharray: 2px;
|
||
"
|
||
bx:shape="star 937 434 39.154 39.153 0.4 5 1@05a6f642"
|
||
/>
|
||
<text style="fill: rgb(51, 51, 51); font-size: 12px; white-space: pre" x="316.854" y="190.447">
|
||
rotate
|
||
</text>
|
||
</g>
|
||
</svg>
|
||
|
||
<script class="doctest">
|
||
const from = { scale: 1 }
|
||
const to = { scale: 1.3, transformOrigin: 'center', repeat: 1, yoyo: true }
|
||
|
||
InteractionMapper.on('tap', document.getElementById('hammer-1'), (event) => {
|
||
TweenLite.fromTo(event.target, 0.2, from, to)
|
||
})
|
||
|
||
InteractionMapper.on(
|
||
'press',
|
||
document.getElementById('hammer-2'),
|
||
(event) => {
|
||
TweenLite.fromTo(event.target, 0.2, from, to)
|
||
},
|
||
{ time: 1000 }
|
||
)
|
||
|
||
InteractionMapper.on('panright pandown', document.getElementById('hammer-3'), (event) => {
|
||
TweenLite.fromTo(event.target, 0.2, from, to)
|
||
})
|
||
|
||
InteractionMapper.on(['swipeleft', 'swipedown'], document.getElementsByTagName('ellipse'), (event) => {
|
||
TweenLite.fromTo(event.target, 0.2, from, to)
|
||
})
|
||
|
||
InteractionMapper.on('pinch', document.getElementById('hammer-5'), (event) => {
|
||
TweenLite.fromTo(event.target, 0.2, from, to)
|
||
})
|
||
|
||
InteractionMapper.on('rotate', document.querySelectorAll('svg g > path.star'), (event) => {
|
||
TweenLite.fromTo(event.target, 0.2, from, to)
|
||
})
|
||
|
||
InteractionMapper.on('click', document.getElementById('hammer-1'), (event) => {
|
||
console.log(event)
|
||
})
|
||
</script>
|
||
|
||
<h2>References</h2>
|
||
<ul>
|
||
<li>
|
||
<a href="https://www.amazon.de/Patterns-Elements-Reusable-Object-Oriented-Software/dp/0201633612"
|
||
>Design Patterns [p. 20]</a
|
||
>
|
||
</li>
|
||
<li><a href="http://hammerjs.github.io">Hammer.js</a></li>
|
||
</ul>
|
||
</body>
|
||
</html>
|