iwmlib/lib/interaction.html

496 lines
23 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>