subDesTagesMitExtraKaese 2 gadi atpakaļ
vecāks
revīzija
1b413ec2b9

+ 0 - 0
__init__.py


+ 61 - 0
admin.py

@@ -0,0 +1,61 @@
+import random
+from django.urls import reverse
+from django.utils.html import format_html, format_html_join
+
+from django.contrib import admin
+
+from .models import *
+from .forms import PersonChangeForm
+
+def random_color():
+  r = lambda: random.randint(0,255)
+  return '#{:02x}{:02x}{:02x}'.format(r(), r(), r())
+
+@admin.register(Person)
+class PersonAdmin(admin.ModelAdmin):
+  list_select_related = ('parent', 'group')
+  list_display = ['name', 'get_partners', 'get_parent', 'birth_date', 'death_date', 'comment', 'color', 'group']
+  list_editable = ['color']#, 'group']
+  list_filter = ('group', 'birth_date')
+  search_fields = ['name', 'parent__name', 'partners__name']
+  ordering = ('group','name')
+  filter_horizontal = ('partners',)
+
+  def get_queryset(self, request):
+    qs = super().get_queryset(request)
+    return qs.prefetch_related('partners')
+
+  def get_partners(self, obj):
+    return format_html_join(', ', '<a href="{}">{}</a>', [(reverse("admin:stammbaum_person_change", args=[p.id]), p) for p in obj.partners.all()])
+  get_partners.short_description = "Partner"
+
+  def get_parent(self, obj):
+    if obj.parent:
+      return format_html('<a href="{}">{}</a>', reverse("admin:stammbaum_person_change", args=[obj.parent.id]), obj.parent)
+  get_parent.short_description = "Eltern"
+
+  def save_related(self, request, form, formsets, change):
+    super(PersonAdmin, self).save_related(request, form, formsets, change)
+    partners = form.instance.partners.all()
+    if not form.instance.color and len(partners) > 0:
+      color = random_color()
+      for partner in partners:
+        if partner.color:
+          color = partner.color
+          break
+      form.instance.color = color
+      form.instance.save()
+      
+    for partner in partners:
+      print(partner)
+      partner.color = form.instance.color
+      partner.save()
+
+  #form = PersonChangeForm
+
+@admin.register(Group)
+class GroupAdmin(admin.ModelAdmin):
+  list_display = ['name', 'color']
+  list_editable = ['name', 'color']
+  list_display_links = None
+  ordering = ('name',)

+ 6 - 0
apps.py

@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class StammbaumConfig(AppConfig):
+    default_auto_field = 'django.db.models.BigAutoField'
+    name = 'stammbaum'

+ 25 - 0
forms.py

@@ -0,0 +1,25 @@
+
+from django import forms
+from .models import Person
+from django.contrib.auth.forms import UserCreationForm
+from django.contrib.auth.models import User
+
+class UploadImage(forms.ModelForm):
+  class Meta:
+    model = Person
+    fields = ['image']
+
+ 
+class SignUpForm(UserCreationForm):
+    class Meta:
+        model = User
+        fields = ('first_name', 'last_name', 'email', 'username', 'password1', 'password2', )
+
+class PersonChangeForm(forms.ModelForm):
+  def __init__(self, instance) -> None:
+      print(instance)
+      super().__init__(instance)
+      #self.fields['partner'].queryset = Person.objects.complex_filter()
+  class Meta:
+    model = Person
+    fields = '__all__'

+ 51 - 0
migrations/0001_initial.py

@@ -0,0 +1,51 @@
+# Generated by Django 4.0.4 on 2022-04-18 12:32
+
+import colorfield.fields
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Group',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=255)),
+                ('color', colorfield.fields.ColorField(default='#fb35e8', image_field=None, max_length=18, samples=None)),
+            ],
+        ),
+        migrations.CreateModel(
+            name='Person',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=255)),
+                ('birth_date', models.DateField(blank=True, null=True)),
+                ('birth_town', models.CharField(blank=True, max_length=255, null=True)),
+                ('death_date', models.DateField(blank=True, null=True)),
+                ('death_town', models.CharField(blank=True, max_length=255, null=True)),
+                ('comment', models.TextField(blank=True, null=True)),
+                ('image', models.ImageField(blank=True, null=True, upload_to='stammbaum/images')),
+                ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='stammbaum.group')),
+                ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='parent_map', to='stammbaum.person')),
+            ],
+            options={
+                'permissions': [('view', 'Can view Stammbaum'), ('upload_image', 'Can upload images')],
+            },
+        ),
+        migrations.CreateModel(
+            name='Pair',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('color', colorfield.fields.ColorField(default='#c37768', image_field=None, max_length=18, samples=None)),
+                ('person1', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='person1', to='stammbaum.person')),
+                ('person2', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='person2', to='stammbaum.person')),
+            ],
+        ),
+    ]

+ 82 - 0
migrations/0002_auto_20220522_1624.py

@@ -0,0 +1,82 @@
+# Generated by Django 3.2.13 on 2022-05-22 14:24
+
+import colorfield.fields
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('stammbaum', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='group',
+            options={'verbose_name': 'Gruppe', 'verbose_name_plural': 'Gruppen'},
+        ),
+        migrations.AlterModelOptions(
+            name='person',
+            options={'permissions': [('view', 'Can view Stammbaum'), ('upload_image', 'Can upload images')], 'verbose_name': 'Person', 'verbose_name_plural': 'Personen'},
+        ),
+        migrations.AddField(
+            model_name='person',
+            name='color',
+            field=colorfield.fields.ColorField(blank=True, default=None, image_field=None, max_length=18, null=True, samples=None, verbose_name='Farbe'),
+        ),
+        migrations.AddField(
+            model_name='person',
+            name='partners',
+            field=models.ManyToManyField(blank=True, related_name='_stammbaum_person_partners_+', to='stammbaum.Person', verbose_name='Partner'),
+        ),
+        migrations.AlterField(
+            model_name='group',
+            name='color',
+            field=colorfield.fields.ColorField(default='#87bb37', image_field=None, max_length=18, samples=None),
+        ),
+        migrations.AlterField(
+            model_name='person',
+            name='birth_date',
+            field=models.DateField(blank=True, null=True, verbose_name='Geburtsdatum'),
+        ),
+        migrations.AlterField(
+            model_name='person',
+            name='birth_town',
+            field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Geburtsort'),
+        ),
+        migrations.AlterField(
+            model_name='person',
+            name='comment',
+            field=models.TextField(blank=True, null=True, verbose_name='Kommentar'),
+        ),
+        migrations.AlterField(
+            model_name='person',
+            name='death_date',
+            field=models.DateField(blank=True, null=True, verbose_name='Todesdatum'),
+        ),
+        migrations.AlterField(
+            model_name='person',
+            name='death_town',
+            field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Todesort'),
+        ),
+        migrations.AlterField(
+            model_name='person',
+            name='group',
+            field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.PROTECT, to='stammbaum.group', verbose_name='Gruppe'),
+            preserve_default=False,
+        ),
+        migrations.AlterField(
+            model_name='person',
+            name='image',
+            field=models.ImageField(blank=True, null=True, upload_to='stammbaum/images', verbose_name='Bild'),
+        ),
+        migrations.AlterField(
+            model_name='person',
+            name='parent',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='parent_map', to='stammbaum.person', verbose_name='Eltern'),
+        ),
+        migrations.DeleteModel(
+            name='Pair',
+        ),
+    ]

+ 0 - 0
migrations/__init__.py


+ 58 - 0
models.py

@@ -0,0 +1,58 @@
+from django.db import models
+
+from colorfield.fields import ColorField
+
+class Group(models.Model):
+  name = models.CharField(max_length=255)
+  color = ColorField(default='#87bb37')
+  def __str__(self) -> str:
+      return str(self.name)
+
+  class Meta:
+    verbose_name = "Gruppe"
+    verbose_name_plural = "Gruppen"
+
+class Person(models.Model):
+  name = models.CharField(max_length=255)
+
+  parent = models.ForeignKey('self', null=True, blank=True, on_delete=models.PROTECT, related_name='parent_map', verbose_name="Eltern")
+
+  partners = models.ManyToManyField('self', blank=True, verbose_name="Partner")
+
+  birth_date = models.DateField(null=True, blank=True, verbose_name="Geburtsdatum")
+  birth_town = models.CharField(max_length=255, null=True, blank=True, verbose_name="Geburtsort")
+  
+  death_date = models.DateField(null=True, blank=True, verbose_name="Todesdatum")
+  death_town = models.CharField(max_length=255, null=True, blank=True, verbose_name="Todesort")
+
+  comment = models.TextField(null=True, blank=True, verbose_name="Kommentar")
+
+  image = models.ImageField(upload_to='stammbaum/images', null=True, blank=True, verbose_name="Bild")
+
+  color = ColorField(null=True, blank=True, verbose_name="Farbe")
+
+  group = models.ForeignKey(Group, on_delete=models.PROTECT, verbose_name="Gruppe")
+
+  def __str__(self) -> str:
+    tmp = self.name
+    #if self.parent:
+    #  tmp = F"{self.parent.name} -> {tmp}"
+    
+    if self.birth_date:
+      tmp += " *" + self.birth_date.strftime("%d.%m.%Y")
+
+    return tmp
+
+  class Meta:
+    verbose_name = "Person"
+    verbose_name_plural = "Personen"
+    permissions = [
+      (
+        "view",
+        "Can view Stammbaum"
+      ),
+      (
+        "upload_image",
+        "Can upload images"
+      )
+    ]

+ 2463 - 0
static/stammbaum/hammer.js

@@ -0,0 +1,2463 @@
+/*! Hammer.JS - v2.0.4 - 2014-09-28
+ * http://hammerjs.github.io/
+ *
+ * Copyright (c) 2014 Jorik Tangelder;
+ * Licensed under the MIT license */
+(function(window, document, exportName, undefined) {
+  'use strict';
+
+var VENDOR_PREFIXES = ['', 'webkit', 'moz', 'MS', 'ms', 'o'];
+var TEST_ELEMENT = document.createElement('div');
+
+var TYPE_FUNCTION = 'function';
+
+var round = Math.round;
+var abs = Math.abs;
+var now = Date.now;
+
+/**
+ * set a timeout with a given scope
+ * @param {Function} fn
+ * @param {Number} timeout
+ * @param {Object} context
+ * @returns {number}
+ */
+function setTimeoutContext(fn, timeout, context) {
+    return setTimeout(bindFn(fn, context), timeout);
+}
+
+/**
+ * if the argument is an array, we want to execute the fn on each entry
+ * if it aint an array we don't want to do a thing.
+ * this is used by all the methods that accept a single and array argument.
+ * @param {*|Array} arg
+ * @param {String} fn
+ * @param {Object} [context]
+ * @returns {Boolean}
+ */
+function invokeArrayArg(arg, fn, context) {
+    if (Array.isArray(arg)) {
+        each(arg, context[fn], context);
+        return true;
+    }
+    return false;
+}
+
+/**
+ * walk objects and arrays
+ * @param {Object} obj
+ * @param {Function} iterator
+ * @param {Object} context
+ */
+function each(obj, iterator, context) {
+    var i;
+
+    if (!obj) {
+        return;
+    }
+
+    if (obj.forEach) {
+        obj.forEach(iterator, context);
+    } else if (obj.length !== undefined) {
+        i = 0;
+        while (i < obj.length) {
+            iterator.call(context, obj[i], i, obj);
+            i++;
+        }
+    } else {
+        for (i in obj) {
+            obj.hasOwnProperty(i) && iterator.call(context, obj[i], i, obj);
+        }
+    }
+}
+
+/**
+ * extend object.
+ * means that properties in dest will be overwritten by the ones in src.
+ * @param {Object} dest
+ * @param {Object} src
+ * @param {Boolean} [merge]
+ * @returns {Object} dest
+ */
+function extend(dest, src, merge) {
+    var keys = Object.keys(src);
+    var i = 0;
+    while (i < keys.length) {
+        if (!merge || (merge && dest[keys[i]] === undefined)) {
+            dest[keys[i]] = src[keys[i]];
+        }
+        i++;
+    }
+    return dest;
+}
+
+/**
+ * merge the values from src in the dest.
+ * means that properties that exist in dest will not be overwritten by src
+ * @param {Object} dest
+ * @param {Object} src
+ * @returns {Object} dest
+ */
+function merge(dest, src) {
+    return extend(dest, src, true);
+}
+
+/**
+ * simple class inheritance
+ * @param {Function} child
+ * @param {Function} base
+ * @param {Object} [properties]
+ */
+function inherit(child, base, properties) {
+    var baseP = base.prototype,
+        childP;
+
+    childP = child.prototype = Object.create(baseP);
+    childP.constructor = child;
+    childP._super = baseP;
+
+    if (properties) {
+        extend(childP, properties);
+    }
+}
+
+/**
+ * simple function bind
+ * @param {Function} fn
+ * @param {Object} context
+ * @returns {Function}
+ */
+function bindFn(fn, context) {
+    return function boundFn() {
+        return fn.apply(context, arguments);
+    };
+}
+
+/**
+ * let a boolean value also be a function that must return a boolean
+ * this first item in args will be used as the context
+ * @param {Boolean|Function} val
+ * @param {Array} [args]
+ * @returns {Boolean}
+ */
+function boolOrFn(val, args) {
+    if (typeof val == TYPE_FUNCTION) {
+        return val.apply(args ? args[0] || undefined : undefined, args);
+    }
+    return val;
+}
+
+/**
+ * use the val2 when val1 is undefined
+ * @param {*} val1
+ * @param {*} val2
+ * @returns {*}
+ */
+function ifUndefined(val1, val2) {
+    return (val1 === undefined) ? val2 : val1;
+}
+
+/**
+ * addEventListener with multiple events at once
+ * @param {EventTarget} target
+ * @param {String} types
+ * @param {Function} handler
+ */
+function addEventListeners(target, types, handler) {
+    each(splitStr(types), function(type) {
+        target.addEventListener(type, handler, false);
+    });
+}
+
+/**
+ * removeEventListener with multiple events at once
+ * @param {EventTarget} target
+ * @param {String} types
+ * @param {Function} handler
+ */
+function removeEventListeners(target, types, handler) {
+    each(splitStr(types), function(type) {
+        target.removeEventListener(type, handler, false);
+    });
+}
+
+/**
+ * find if a node is in the given parent
+ * @method hasParent
+ * @param {HTMLElement} node
+ * @param {HTMLElement} parent
+ * @return {Boolean} found
+ */
+function hasParent(node, parent) {
+    while (node) {
+        if (node == parent) {
+            return true;
+        }
+        node = node.parentNode;
+    }
+    return false;
+}
+
+/**
+ * small indexOf wrapper
+ * @param {String} str
+ * @param {String} find
+ * @returns {Boolean} found
+ */
+function inStr(str, find) {
+    return str.indexOf(find) > -1;
+}
+
+/**
+ * split string on whitespace
+ * @param {String} str
+ * @returns {Array} words
+ */
+function splitStr(str) {
+    return str.trim().split(/\s+/g);
+}
+
+/**
+ * find if a array contains the object using indexOf or a simple polyFill
+ * @param {Array} src
+ * @param {String} find
+ * @param {String} [findByKey]
+ * @return {Boolean|Number} false when not found, or the index
+ */
+function inArray(src, find, findByKey) {
+    if (src.indexOf && !findByKey) {
+        return src.indexOf(find);
+    } else {
+        var i = 0;
+        while (i < src.length) {
+            if ((findByKey && src[i][findByKey] == find) || (!findByKey && src[i] === find)) {
+                return i;
+            }
+            i++;
+        }
+        return -1;
+    }
+}
+
+/**
+ * convert array-like objects to real arrays
+ * @param {Object} obj
+ * @returns {Array}
+ */
+function toArray(obj) {
+    return Array.prototype.slice.call(obj, 0);
+}
+
+/**
+ * unique array with objects based on a key (like 'id') or just by the array's value
+ * @param {Array} src [{id:1},{id:2},{id:1}]
+ * @param {String} [key]
+ * @param {Boolean} [sort=False]
+ * @returns {Array} [{id:1},{id:2}]
+ */
+function uniqueArray(src, key, sort) {
+    var results = [];
+    var values = [];
+    var i = 0;
+
+    while (i < src.length) {
+        var val = key ? src[i][key] : src[i];
+        if (inArray(values, val) < 0) {
+            results.push(src[i]);
+        }
+        values[i] = val;
+        i++;
+    }
+
+    if (sort) {
+        if (!key) {
+            results = results.sort();
+        } else {
+            results = results.sort(function sortUniqueArray(a, b) {
+                return a[key] > b[key];
+            });
+        }
+    }
+
+    return results;
+}
+
+/**
+ * get the prefixed property
+ * @param {Object} obj
+ * @param {String} property
+ * @returns {String|Undefined} prefixed
+ */
+function prefixed(obj, property) {
+    var prefix, prop;
+    var camelProp = property[0].toUpperCase() + property.slice(1);
+
+    var i = 0;
+    while (i < VENDOR_PREFIXES.length) {
+        prefix = VENDOR_PREFIXES[i];
+        prop = (prefix) ? prefix + camelProp : property;
+
+        if (prop in obj) {
+            return prop;
+        }
+        i++;
+    }
+    return undefined;
+}
+
+/**
+ * get a unique id
+ * @returns {number} uniqueId
+ */
+var _uniqueId = 1;
+function uniqueId() {
+    return _uniqueId++;
+}
+
+/**
+ * get the window object of an element
+ * @param {HTMLElement} element
+ * @returns {DocumentView|Window}
+ */
+function getWindowForElement(element) {
+    var doc = element.ownerDocument;
+    return (doc.defaultView || doc.parentWindow);
+}
+
+var MOBILE_REGEX = /mobile|tablet|ip(ad|hone|od)|android/i;
+
+var SUPPORT_TOUCH = ('ontouchstart' in window);
+var SUPPORT_POINTER_EVENTS = prefixed(window, 'PointerEvent') !== undefined;
+var SUPPORT_ONLY_TOUCH = SUPPORT_TOUCH && MOBILE_REGEX.test(navigator.userAgent);
+
+var INPUT_TYPE_TOUCH = 'touch';
+var INPUT_TYPE_PEN = 'pen';
+var INPUT_TYPE_MOUSE = 'mouse';
+var INPUT_TYPE_KINECT = 'kinect';
+
+var COMPUTE_INTERVAL = 25;
+
+var INPUT_START = 1;
+var INPUT_MOVE = 2;
+var INPUT_END = 4;
+var INPUT_CANCEL = 8;
+
+var DIRECTION_NONE = 1;
+var DIRECTION_LEFT = 2;
+var DIRECTION_RIGHT = 4;
+var DIRECTION_UP = 8;
+var DIRECTION_DOWN = 16;
+
+var DIRECTION_HORIZONTAL = DIRECTION_LEFT | DIRECTION_RIGHT;
+var DIRECTION_VERTICAL = DIRECTION_UP | DIRECTION_DOWN;
+var DIRECTION_ALL = DIRECTION_HORIZONTAL | DIRECTION_VERTICAL;
+
+var PROPS_XY = ['x', 'y'];
+var PROPS_CLIENT_XY = ['clientX', 'clientY'];
+
+/**
+ * create new input type manager
+ * @param {Manager} manager
+ * @param {Function} callback
+ * @returns {Input}
+ * @constructor
+ */
+function Input(manager, callback) {
+    var self = this;
+    this.manager = manager;
+    this.callback = callback;
+    this.element = manager.element;
+    this.target = manager.options.inputTarget;
+
+    // smaller wrapper around the handler, for the scope and the enabled state of the manager,
+    // so when disabled the input events are completely bypassed.
+    this.domHandler = function(ev) {
+        if (boolOrFn(manager.options.enable, [manager])) {
+            self.handler(ev);
+        }
+    };
+
+    this.init();
+
+}
+
+Input.prototype = {
+    /**
+     * should handle the inputEvent data and trigger the callback
+     * @virtual
+     */
+    handler: function() { },
+
+    /**
+     * bind the events
+     */
+    init: function() {
+        this.evEl && addEventListeners(this.element, this.evEl, this.domHandler);
+        this.evTarget && addEventListeners(this.target, this.evTarget, this.domHandler);
+        this.evWin && addEventListeners(getWindowForElement(this.element), this.evWin, this.domHandler);
+    },
+
+    /**
+     * unbind the events
+     */
+    destroy: function() {
+        this.evEl && removeEventListeners(this.element, this.evEl, this.domHandler);
+        this.evTarget && removeEventListeners(this.target, this.evTarget, this.domHandler);
+        this.evWin && removeEventListeners(getWindowForElement(this.element), this.evWin, this.domHandler);
+    }
+};
+
+/**
+ * create new input type manager
+ * called by the Manager constructor
+ * @param {Hammer} manager
+ * @returns {Input}
+ */
+function createInputInstance(manager) {
+    var Type;
+    var inputClass = manager.options.inputClass;
+
+    if (inputClass) {
+        Type = inputClass;
+    } else if (SUPPORT_POINTER_EVENTS) {
+        Type = PointerEventInput;
+    } else if (SUPPORT_ONLY_TOUCH) {
+        Type = TouchInput;
+    } else if (!SUPPORT_TOUCH) {
+        Type = MouseInput;
+    } else {
+        Type = TouchMouseInput;
+    }
+    return new (Type)(manager, inputHandler);
+}
+
+/**
+ * handle input events
+ * @param {Manager} manager
+ * @param {String} eventType
+ * @param {Object} input
+ */
+function inputHandler(manager, eventType, input) {
+    var pointersLen = input.pointers.length;
+    var changedPointersLen = input.changedPointers.length;
+    var isFirst = (eventType & INPUT_START && (pointersLen - changedPointersLen === 0));
+    var isFinal = (eventType & (INPUT_END | INPUT_CANCEL) && (pointersLen - changedPointersLen === 0));
+
+    input.isFirst = !!isFirst;
+    input.isFinal = !!isFinal;
+
+    if (isFirst) {
+        manager.session = {};
+    }
+
+    // source event is the normalized value of the domEvents
+    // like 'touchstart, mouseup, pointerdown'
+    input.eventType = eventType;
+
+    // compute scale, rotation etc
+    computeInputData(manager, input);
+
+    // emit secret event
+    manager.emit('hammer.input', input);
+
+    manager.recognize(input);
+    manager.session.prevInput = input;
+}
+
+/**
+ * extend the data with some usable properties like scale, rotate, velocity etc
+ * @param {Object} manager
+ * @param {Object} input
+ */
+function computeInputData(manager, input) {
+    var session = manager.session;
+    var pointers = input.pointers;
+    var pointersLength = pointers.length;
+
+    // store the first input to calculate the distance and direction
+    if (!session.firstInput) {
+        session.firstInput = simpleCloneInputData(input);
+    }
+
+    // to compute scale and rotation we need to store the multiple touches
+    if (pointersLength > 1 && !session.firstMultiple) {
+        session.firstMultiple = simpleCloneInputData(input);
+    } else if (pointersLength === 1) {
+        session.firstMultiple = false;
+    }
+
+    var firstInput = session.firstInput;
+    var firstMultiple = session.firstMultiple;
+    var offsetCenter = firstMultiple ? firstMultiple.center : firstInput.center;
+
+    var center = input.center = getCenter(pointers);
+    input.timeStamp = now();
+    input.deltaTime = input.timeStamp - firstInput.timeStamp;
+
+    input.angle = getAngle(offsetCenter, center);
+    input.distance = getDistance(offsetCenter, center);
+
+    computeDeltaXY(session, input);
+    input.offsetDirection = getDirection(input.deltaX, input.deltaY);
+
+    input.scale = firstMultiple ? getScale(firstMultiple.pointers, pointers) : 1;
+    input.rotation = firstMultiple ? getRotation(firstMultiple.pointers, pointers) : 0;
+
+    computeIntervalInputData(session, input);
+
+    // find the correct target
+    var target = manager.element;
+    if (hasParent(input.srcEvent.target, target)) {
+        target = input.srcEvent.target;
+    }
+    input.target = target;
+}
+
+function computeDeltaXY(session, input) {
+    var center = input.center;
+    var offset = session.offsetDelta || {};
+    var prevDelta = session.prevDelta || {};
+    var prevInput = session.prevInput || {};
+
+    if (input.eventType === INPUT_START || prevInput.eventType === INPUT_END) {
+        prevDelta = session.prevDelta = {
+            x: prevInput.deltaX || 0,
+            y: prevInput.deltaY || 0
+        };
+
+        offset = session.offsetDelta = {
+            x: center.x,
+            y: center.y
+        };
+    }
+
+    input.deltaX = prevDelta.x + (center.x - offset.x);
+    input.deltaY = prevDelta.y + (center.y - offset.y);
+}
+
+/**
+ * velocity is calculated every x ms
+ * @param {Object} session
+ * @param {Object} input
+ */
+function computeIntervalInputData(session, input) {
+    var last = session.lastInterval || input,
+        deltaTime = input.timeStamp - last.timeStamp,
+        velocity, velocityX, velocityY, direction;
+
+    if (input.eventType != INPUT_CANCEL && (deltaTime > COMPUTE_INTERVAL || last.velocity === undefined)) {
+        var deltaX = last.deltaX - input.deltaX;
+        var deltaY = last.deltaY - input.deltaY;
+
+        var v = getVelocity(deltaTime, deltaX, deltaY);
+        velocityX = v.x;
+        velocityY = v.y;
+        velocity = (abs(v.x) > abs(v.y)) ? v.x : v.y;
+        direction = getDirection(deltaX, deltaY);
+
+        session.lastInterval = input;
+    } else {
+        // use latest velocity info if it doesn't overtake a minimum period
+        velocity = last.velocity;
+        velocityX = last.velocityX;
+        velocityY = last.velocityY;
+        direction = last.direction;
+    }
+
+    input.velocity = velocity;
+    input.velocityX = velocityX;
+    input.velocityY = velocityY;
+    input.direction = direction;
+}
+
+/**
+ * create a simple clone from the input used for storage of firstInput and firstMultiple
+ * @param {Object} input
+ * @returns {Object} clonedInputData
+ */
+function simpleCloneInputData(input) {
+    // make a simple copy of the pointers because we will get a reference if we don't
+    // we only need clientXY for the calculations
+    var pointers = [];
+    var i = 0;
+    while (i < input.pointers.length) {
+        pointers[i] = {
+            clientX: round(input.pointers[i].clientX),
+            clientY: round(input.pointers[i].clientY)
+        };
+        i++;
+    }
+
+    return {
+        timeStamp: now(),
+        pointers: pointers,
+        center: getCenter(pointers),
+        deltaX: input.deltaX,
+        deltaY: input.deltaY
+    };
+}
+
+/**
+ * get the center of all the pointers
+ * @param {Array} pointers
+ * @return {Object} center contains `x` and `y` properties
+ */
+function getCenter(pointers) {
+    var pointersLength = pointers.length;
+
+    // no need to loop when only one touch
+    if (pointersLength === 1) {
+        return {
+            x: round(pointers[0].clientX),
+            y: round(pointers[0].clientY)
+        };
+    }
+
+    var x = 0, y = 0, i = 0;
+    while (i < pointersLength) {
+        x += pointers[i].clientX;
+        y += pointers[i].clientY;
+        i++;
+    }
+
+    return {
+        x: round(x / pointersLength),
+        y: round(y / pointersLength)
+    };
+}
+
+/**
+ * calculate the velocity between two points. unit is in px per ms.
+ * @param {Number} deltaTime
+ * @param {Number} x
+ * @param {Number} y
+ * @return {Object} velocity `x` and `y`
+ */
+function getVelocity(deltaTime, x, y) {
+    return {
+        x: x / deltaTime || 0,
+        y: y / deltaTime || 0
+    };
+}
+
+/**
+ * get the direction between two points
+ * @param {Number} x
+ * @param {Number} y
+ * @return {Number} direction
+ */
+function getDirection(x, y) {
+    if (x === y) {
+        return DIRECTION_NONE;
+    }
+
+    if (abs(x) >= abs(y)) {
+        return x > 0 ? DIRECTION_LEFT : DIRECTION_RIGHT;
+    }
+    return y > 0 ? DIRECTION_UP : DIRECTION_DOWN;
+}
+
+/**
+ * calculate the absolute distance between two points
+ * @param {Object} p1 {x, y}
+ * @param {Object} p2 {x, y}
+ * @param {Array} [props] containing x and y keys
+ * @return {Number} distance
+ */
+function getDistance(p1, p2, props) {
+    if (!props) {
+        props = PROPS_XY;
+    }
+    var x = p2[props[0]] - p1[props[0]],
+        y = p2[props[1]] - p1[props[1]];
+
+    return Math.sqrt((x * x) + (y * y));
+}
+
+/**
+ * calculate the angle between two coordinates
+ * @param {Object} p1
+ * @param {Object} p2
+ * @param {Array} [props] containing x and y keys
+ * @return {Number} angle
+ */
+function getAngle(p1, p2, props) {
+    if (!props) {
+        props = PROPS_XY;
+    }
+    var x = p2[props[0]] - p1[props[0]],
+        y = p2[props[1]] - p1[props[1]];
+    return Math.atan2(y, x) * 180 / Math.PI;
+}
+
+/**
+ * calculate the rotation degrees between two pointersets
+ * @param {Array} start array of pointers
+ * @param {Array} end array of pointers
+ * @return {Number} rotation
+ */
+function getRotation(start, end) {
+    return getAngle(end[1], end[0], PROPS_CLIENT_XY) - getAngle(start[1], start[0], PROPS_CLIENT_XY);
+}
+
+/**
+ * calculate the scale factor between two pointersets
+ * no scale is 1, and goes down to 0 when pinched together, and bigger when pinched out
+ * @param {Array} start array of pointers
+ * @param {Array} end array of pointers
+ * @return {Number} scale
+ */
+function getScale(start, end) {
+    return getDistance(end[0], end[1], PROPS_CLIENT_XY) / getDistance(start[0], start[1], PROPS_CLIENT_XY);
+}
+
+var MOUSE_INPUT_MAP = {
+    mousedown: INPUT_START,
+    mousemove: INPUT_MOVE,
+    mouseup: INPUT_END
+};
+
+var MOUSE_ELEMENT_EVENTS = 'mousedown';
+var MOUSE_WINDOW_EVENTS = 'mousemove mouseup';
+
+/**
+ * Mouse events input
+ * @constructor
+ * @extends Input
+ */
+function MouseInput() {
+    this.evEl = MOUSE_ELEMENT_EVENTS;
+    this.evWin = MOUSE_WINDOW_EVENTS;
+
+    this.allow = true; // used by Input.TouchMouse to disable mouse events
+    this.pressed = false; // mousedown state
+
+    Input.apply(this, arguments);
+}
+
+inherit(MouseInput, Input, {
+    /**
+     * handle mouse events
+     * @param {Object} ev
+     */
+    handler: function MEhandler(ev) {
+        var eventType = MOUSE_INPUT_MAP[ev.type];
+
+        // on start we want to have the left mouse button down
+        if (eventType & INPUT_START && ev.button === 0) {
+            this.pressed = true;
+        }
+
+        if (eventType & INPUT_MOVE && ev.which !== 1) {
+            eventType = INPUT_END;
+        }
+
+        // mouse must be down, and mouse events are allowed (see the TouchMouse input)
+        if (!this.pressed || !this.allow) {
+            return;
+        }
+
+        if (eventType & INPUT_END) {
+            this.pressed = false;
+        }
+
+        this.callback(this.manager, eventType, {
+            pointers: [ev],
+            changedPointers: [ev],
+            pointerType: INPUT_TYPE_MOUSE,
+            srcEvent: ev
+        });
+    }
+});
+
+var POINTER_INPUT_MAP = {
+    pointerdown: INPUT_START,
+    pointermove: INPUT_MOVE,
+    pointerup: INPUT_END,
+    pointercancel: INPUT_CANCEL,
+    pointerout: INPUT_CANCEL
+};
+
+// in IE10 the pointer types is defined as an enum
+var IE10_POINTER_TYPE_ENUM = {
+    2: INPUT_TYPE_TOUCH,
+    3: INPUT_TYPE_PEN,
+    4: INPUT_TYPE_MOUSE,
+    5: INPUT_TYPE_KINECT // see https://twitter.com/jacobrossi/status/480596438489890816
+};
+
+var POINTER_ELEMENT_EVENTS = 'pointerdown';
+var POINTER_WINDOW_EVENTS = 'pointermove pointerup pointercancel';
+
+// IE10 has prefixed support, and case-sensitive
+if (window.MSPointerEvent) {
+    POINTER_ELEMENT_EVENTS = 'MSPointerDown';
+    POINTER_WINDOW_EVENTS = 'MSPointerMove MSPointerUp MSPointerCancel';
+}
+
+/**
+ * Pointer events input
+ * @constructor
+ * @extends Input
+ */
+function PointerEventInput() {
+    this.evEl = POINTER_ELEMENT_EVENTS;
+    this.evWin = POINTER_WINDOW_EVENTS;
+
+    Input.apply(this, arguments);
+
+    this.store = (this.manager.session.pointerEvents = []);
+}
+
+inherit(PointerEventInput, Input, {
+    /**
+     * handle mouse events
+     * @param {Object} ev
+     */
+    handler: function PEhandler(ev) {
+        var store = this.store;
+        var removePointer = false;
+
+        var eventTypeNormalized = ev.type.toLowerCase().replace('ms', '');
+        var eventType = POINTER_INPUT_MAP[eventTypeNormalized];
+        var pointerType = IE10_POINTER_TYPE_ENUM[ev.pointerType] || ev.pointerType;
+
+        var isTouch = (pointerType == INPUT_TYPE_TOUCH);
+
+        // get index of the event in the store
+        var storeIndex = inArray(store, ev.pointerId, 'pointerId');
+
+        // start and mouse must be down
+        if (eventType & INPUT_START && (ev.button === 0 || isTouch)) {
+            if (storeIndex < 0) {
+                store.push(ev);
+                storeIndex = store.length - 1;
+            }
+        } else if (eventType & (INPUT_END | INPUT_CANCEL)) {
+            removePointer = true;
+        }
+
+        // it not found, so the pointer hasn't been down (so it's probably a hover)
+        if (storeIndex < 0) {
+            return;
+        }
+
+        // update the event in the store
+        store[storeIndex] = ev;
+
+        this.callback(this.manager, eventType, {
+            pointers: store,
+            changedPointers: [ev],
+            pointerType: pointerType,
+            srcEvent: ev
+        });
+
+        if (removePointer) {
+            // remove from the store
+            store.splice(storeIndex, 1);
+        }
+    }
+});
+
+var SINGLE_TOUCH_INPUT_MAP = {
+    touchstart: INPUT_START,
+    touchmove: INPUT_MOVE,
+    touchend: INPUT_END,
+    touchcancel: INPUT_CANCEL
+};
+
+var SINGLE_TOUCH_TARGET_EVENTS = 'touchstart';
+var SINGLE_TOUCH_WINDOW_EVENTS = 'touchstart touchmove touchend touchcancel';
+
+/**
+ * Touch events input
+ * @constructor
+ * @extends Input
+ */
+function SingleTouchInput() {
+    this.evTarget = SINGLE_TOUCH_TARGET_EVENTS;
+    this.evWin = SINGLE_TOUCH_WINDOW_EVENTS;
+    this.started = false;
+
+    Input.apply(this, arguments);
+}
+
+inherit(SingleTouchInput, Input, {
+    handler: function TEhandler(ev) {
+        var type = SINGLE_TOUCH_INPUT_MAP[ev.type];
+
+        // should we handle the touch events?
+        if (type === INPUT_START) {
+            this.started = true;
+        }
+
+        if (!this.started) {
+            return;
+        }
+
+        var touches = normalizeSingleTouches.call(this, ev, type);
+
+        // when done, reset the started state
+        if (type & (INPUT_END | INPUT_CANCEL) && touches[0].length - touches[1].length === 0) {
+            this.started = false;
+        }
+
+        this.callback(this.manager, type, {
+            pointers: touches[0],
+            changedPointers: touches[1],
+            pointerType: INPUT_TYPE_TOUCH,
+            srcEvent: ev
+        });
+    }
+});
+
+/**
+ * @this {TouchInput}
+ * @param {Object} ev
+ * @param {Number} type flag
+ * @returns {undefined|Array} [all, changed]
+ */
+function normalizeSingleTouches(ev, type) {
+    var all = toArray(ev.touches);
+    var changed = toArray(ev.changedTouches);
+
+    if (type & (INPUT_END | INPUT_CANCEL)) {
+        all = uniqueArray(all.concat(changed), 'identifier', true);
+    }
+
+    return [all, changed];
+}
+
+var TOUCH_INPUT_MAP = {
+    touchstart: INPUT_START,
+    touchmove: INPUT_MOVE,
+    touchend: INPUT_END,
+    touchcancel: INPUT_CANCEL
+};
+
+var TOUCH_TARGET_EVENTS = 'touchstart touchmove touchend touchcancel';
+
+/**
+ * Multi-user touch events input
+ * @constructor
+ * @extends Input
+ */
+function TouchInput() {
+    this.evTarget = TOUCH_TARGET_EVENTS;
+    this.targetIds = {};
+
+    Input.apply(this, arguments);
+}
+
+inherit(TouchInput, Input, {
+    handler: function MTEhandler(ev) {
+        var type = TOUCH_INPUT_MAP[ev.type];
+        var touches = getTouches.call(this, ev, type);
+        if (!touches) {
+            return;
+        }
+
+        this.callback(this.manager, type, {
+            pointers: touches[0],
+            changedPointers: touches[1],
+            pointerType: INPUT_TYPE_TOUCH,
+            srcEvent: ev
+        });
+    }
+});
+
+/**
+ * @this {TouchInput}
+ * @param {Object} ev
+ * @param {Number} type flag
+ * @returns {undefined|Array} [all, changed]
+ */
+function getTouches(ev, type) {
+    var allTouches = toArray(ev.touches);
+    var targetIds = this.targetIds;
+
+    // when there is only one touch, the process can be simplified
+    if (type & (INPUT_START | INPUT_MOVE) && allTouches.length === 1) {
+        targetIds[allTouches[0].identifier] = true;
+        return [allTouches, allTouches];
+    }
+
+    var i,
+        targetTouches,
+        changedTouches = toArray(ev.changedTouches),
+        changedTargetTouches = [],
+        target = this.target;
+
+    // get target touches from touches
+    targetTouches = allTouches.filter(function(touch) {
+        return hasParent(touch.target, target);
+    });
+
+    // collect touches
+    if (type === INPUT_START) {
+        i = 0;
+        while (i < targetTouches.length) {
+            targetIds[targetTouches[i].identifier] = true;
+            i++;
+        }
+    }
+
+    // filter changed touches to only contain touches that exist in the collected target ids
+    i = 0;
+    while (i < changedTouches.length) {
+        if (targetIds[changedTouches[i].identifier]) {
+            changedTargetTouches.push(changedTouches[i]);
+        }
+
+        // cleanup removed touches
+        if (type & (INPUT_END | INPUT_CANCEL)) {
+            delete targetIds[changedTouches[i].identifier];
+        }
+        i++;
+    }
+
+    if (!changedTargetTouches.length) {
+        return;
+    }
+
+    return [
+        // merge targetTouches with changedTargetTouches so it contains ALL touches, including 'end' and 'cancel'
+        uniqueArray(targetTouches.concat(changedTargetTouches), 'identifier', true),
+        changedTargetTouches
+    ];
+}
+
+/**
+ * Combined touch and mouse input
+ *
+ * Touch has a higher priority then mouse, and while touching no mouse events are allowed.
+ * This because touch devices also emit mouse events while doing a touch.
+ *
+ * @constructor
+ * @extends Input
+ */
+function TouchMouseInput() {
+    Input.apply(this, arguments);
+
+    var handler = bindFn(this.handler, this);
+    this.touch = new TouchInput(this.manager, handler);
+    this.mouse = new MouseInput(this.manager, handler);
+}
+
+inherit(TouchMouseInput, Input, {
+    /**
+     * handle mouse and touch events
+     * @param {Hammer} manager
+     * @param {String} inputEvent
+     * @param {Object} inputData
+     */
+    handler: function TMEhandler(manager, inputEvent, inputData) {
+        var isTouch = (inputData.pointerType == INPUT_TYPE_TOUCH),
+            isMouse = (inputData.pointerType == INPUT_TYPE_MOUSE);
+
+        // when we're in a touch event, so  block all upcoming mouse events
+        // most mobile browser also emit mouseevents, right after touchstart
+        if (isTouch) {
+            this.mouse.allow = false;
+        } else if (isMouse && !this.mouse.allow) {
+            return;
+        }
+
+        // reset the allowMouse when we're done
+        if (inputEvent & (INPUT_END | INPUT_CANCEL)) {
+            this.mouse.allow = true;
+        }
+
+        this.callback(manager, inputEvent, inputData);
+    },
+
+    /**
+     * remove the event listeners
+     */
+    destroy: function destroy() {
+        this.touch.destroy();
+        this.mouse.destroy();
+    }
+});
+
+var PREFIXED_TOUCH_ACTION = prefixed(TEST_ELEMENT.style, 'touchAction');
+var NATIVE_TOUCH_ACTION = PREFIXED_TOUCH_ACTION !== undefined;
+
+// magical touchAction value
+var TOUCH_ACTION_COMPUTE = 'compute';
+var TOUCH_ACTION_AUTO = 'auto';
+var TOUCH_ACTION_MANIPULATION = 'manipulation'; // not implemented
+var TOUCH_ACTION_NONE = 'none';
+var TOUCH_ACTION_PAN_X = 'pan-x';
+var TOUCH_ACTION_PAN_Y = 'pan-y';
+
+/**
+ * Touch Action
+ * sets the touchAction property or uses the js alternative
+ * @param {Manager} manager
+ * @param {String} value
+ * @constructor
+ */
+function TouchAction(manager, value) {
+    this.manager = manager;
+    this.set(value);
+}
+
+TouchAction.prototype = {
+    /**
+     * set the touchAction value on the element or enable the polyfill
+     * @param {String} value
+     */
+    set: function(value) {
+        // find out the touch-action by the event handlers
+        if (value == TOUCH_ACTION_COMPUTE) {
+            value = this.compute();
+        }
+
+        if (NATIVE_TOUCH_ACTION) {
+            this.manager.element.style[PREFIXED_TOUCH_ACTION] = value;
+        }
+        this.actions = value.toLowerCase().trim();
+    },
+
+    /**
+     * just re-set the touchAction value
+     */
+    update: function() {
+        this.set(this.manager.options.touchAction);
+    },
+
+    /**
+     * compute the value for the touchAction property based on the recognizer's settings
+     * @returns {String} value
+     */
+    compute: function() {
+        var actions = [];
+        each(this.manager.recognizers, function(recognizer) {
+            if (boolOrFn(recognizer.options.enable, [recognizer])) {
+                actions = actions.concat(recognizer.getTouchAction());
+            }
+        });
+        return cleanTouchActions(actions.join(' '));
+    },
+
+    /**
+     * this method is called on each input cycle and provides the preventing of the browser behavior
+     * @param {Object} input
+     */
+    preventDefaults: function(input) {
+        // not needed with native support for the touchAction property
+        if (NATIVE_TOUCH_ACTION) {
+            return;
+        }
+
+        var srcEvent = input.srcEvent;
+        var direction = input.offsetDirection;
+
+        // if the touch action did prevented once this session
+        if (this.manager.session.prevented) {
+            srcEvent.preventDefault();
+            return;
+        }
+
+        var actions = this.actions;
+        var hasNone = inStr(actions, TOUCH_ACTION_NONE);
+        var hasPanY = inStr(actions, TOUCH_ACTION_PAN_Y);
+        var hasPanX = inStr(actions, TOUCH_ACTION_PAN_X);
+
+        if (hasNone ||
+            (hasPanY && direction & DIRECTION_HORIZONTAL) ||
+            (hasPanX && direction & DIRECTION_VERTICAL)) {
+            return this.preventSrc(srcEvent);
+        }
+    },
+
+    /**
+     * call preventDefault to prevent the browser's default behavior (scrolling in most cases)
+     * @param {Object} srcEvent
+     */
+    preventSrc: function(srcEvent) {
+        this.manager.session.prevented = true;
+        srcEvent.preventDefault();
+    }
+};
+
+/**
+ * when the touchActions are collected they are not a valid value, so we need to clean things up. *
+ * @param {String} actions
+ * @returns {*}
+ */
+function cleanTouchActions(actions) {
+    // none
+    if (inStr(actions, TOUCH_ACTION_NONE)) {
+        return TOUCH_ACTION_NONE;
+    }
+
+    var hasPanX = inStr(actions, TOUCH_ACTION_PAN_X);
+    var hasPanY = inStr(actions, TOUCH_ACTION_PAN_Y);
+
+    // pan-x and pan-y can be combined
+    if (hasPanX && hasPanY) {
+        return TOUCH_ACTION_PAN_X + ' ' + TOUCH_ACTION_PAN_Y;
+    }
+
+    // pan-x OR pan-y
+    if (hasPanX || hasPanY) {
+        return hasPanX ? TOUCH_ACTION_PAN_X : TOUCH_ACTION_PAN_Y;
+    }
+
+    // manipulation
+    if (inStr(actions, TOUCH_ACTION_MANIPULATION)) {
+        return TOUCH_ACTION_MANIPULATION;
+    }
+
+    return TOUCH_ACTION_AUTO;
+}
+
+/**
+ * Recognizer flow explained; *
+ * All recognizers have the initial state of POSSIBLE when a input session starts.
+ * The definition of a input session is from the first input until the last input, with all it's movement in it. *
+ * Example session for mouse-input: mousedown -> mousemove -> mouseup
+ *
+ * On each recognizing cycle (see Manager.recognize) the .recognize() method is executed
+ * which determines with state it should be.
+ *
+ * If the recognizer has the state FAILED, CANCELLED or RECOGNIZED (equals ENDED), it is reset to
+ * POSSIBLE to give it another change on the next cycle.
+ *
+ *               Possible
+ *                  |
+ *            +-----+---------------+
+ *            |                     |
+ *      +-----+-----+               |
+ *      |           |               |
+ *   Failed      Cancelled          |
+ *                          +-------+------+
+ *                          |              |
+ *                      Recognized       Began
+ *                                         |
+ *                                      Changed
+ *                                         |
+ *                                  Ended/Recognized
+ */
+var STATE_POSSIBLE = 1;
+var STATE_BEGAN = 2;
+var STATE_CHANGED = 4;
+var STATE_ENDED = 8;
+var STATE_RECOGNIZED = STATE_ENDED;
+var STATE_CANCELLED = 16;
+var STATE_FAILED = 32;
+
+/**
+ * Recognizer
+ * Every recognizer needs to extend from this class.
+ * @constructor
+ * @param {Object} options
+ */
+function Recognizer(options) {
+    this.id = uniqueId();
+
+    this.manager = null;
+    this.options = merge(options || {}, this.defaults);
+
+    // default is enable true
+    this.options.enable = ifUndefined(this.options.enable, true);
+
+    this.state = STATE_POSSIBLE;
+
+    this.simultaneous = {};
+    this.requireFail = [];
+}
+
+Recognizer.prototype = {
+    /**
+     * @virtual
+     * @type {Object}
+     */
+    defaults: {},
+
+    /**
+     * set options
+     * @param {Object} options
+     * @return {Recognizer}
+     */
+    set: function(options) {
+        extend(this.options, options);
+
+        // also update the touchAction, in case something changed about the directions/enabled state
+        this.manager && this.manager.touchAction.update();
+        return this;
+    },
+
+    /**
+     * recognize simultaneous with an other recognizer.
+     * @param {Recognizer} otherRecognizer
+     * @returns {Recognizer} this
+     */
+    recognizeWith: function(otherRecognizer) {
+        if (invokeArrayArg(otherRecognizer, 'recognizeWith', this)) {
+            return this;
+        }
+
+        var simultaneous = this.simultaneous;
+        otherRecognizer = getRecognizerByNameIfManager(otherRecognizer, this);
+        if (!simultaneous[otherRecognizer.id]) {
+            simultaneous[otherRecognizer.id] = otherRecognizer;
+            otherRecognizer.recognizeWith(this);
+        }
+        return this;
+    },
+
+    /**
+     * drop the simultaneous link. it doesnt remove the link on the other recognizer.
+     * @param {Recognizer} otherRecognizer
+     * @returns {Recognizer} this
+     */
+    dropRecognizeWith: function(otherRecognizer) {
+        if (invokeArrayArg(otherRecognizer, 'dropRecognizeWith', this)) {
+            return this;
+        }
+
+        otherRecognizer = getRecognizerByNameIfManager(otherRecognizer, this);
+        delete this.simultaneous[otherRecognizer.id];
+        return this;
+    },
+
+    /**
+     * recognizer can only run when an other is failing
+     * @param {Recognizer} otherRecognizer
+     * @returns {Recognizer} this
+     */
+    requireFailure: function(otherRecognizer) {
+        if (invokeArrayArg(otherRecognizer, 'requireFailure', this)) {
+            return this;
+        }
+
+        var requireFail = this.requireFail;
+        otherRecognizer = getRecognizerByNameIfManager(otherRecognizer, this);
+        if (inArray(requireFail, otherRecognizer) === -1) {
+            requireFail.push(otherRecognizer);
+            otherRecognizer.requireFailure(this);
+        }
+        return this;
+    },
+
+    /**
+     * drop the requireFailure link. it does not remove the link on the other recognizer.
+     * @param {Recognizer} otherRecognizer
+     * @returns {Recognizer} this
+     */
+    dropRequireFailure: function(otherRecognizer) {
+        if (invokeArrayArg(otherRecognizer, 'dropRequireFailure', this)) {
+            return this;
+        }
+
+        otherRecognizer = getRecognizerByNameIfManager(otherRecognizer, this);
+        var index = inArray(this.requireFail, otherRecognizer);
+        if (index > -1) {
+            this.requireFail.splice(index, 1);
+        }
+        return this;
+    },
+
+    /**
+     * has require failures boolean
+     * @returns {boolean}
+     */
+    hasRequireFailures: function() {
+        return this.requireFail.length > 0;
+    },
+
+    /**
+     * if the recognizer can recognize simultaneous with an other recognizer
+     * @param {Recognizer} otherRecognizer
+     * @returns {Boolean}
+     */
+    canRecognizeWith: function(otherRecognizer) {
+        return !!this.simultaneous[otherRecognizer.id];
+    },
+
+    /**
+     * You should use `tryEmit` instead of `emit` directly to check
+     * that all the needed recognizers has failed before emitting.
+     * @param {Object} input
+     */
+    emit: function(input) {
+        var self = this;
+        var state = this.state;
+
+        function emit(withState) {
+            self.manager.emit(self.options.event + (withState ? stateStr(state) : ''), input);
+        }
+
+        // 'panstart' and 'panmove'
+        if (state < STATE_ENDED) {
+            emit(true);
+        }
+
+        emit(); // simple 'eventName' events
+
+        // panend and pancancel
+        if (state >= STATE_ENDED) {
+            emit(true);
+        }
+    },
+
+    /**
+     * Check that all the require failure recognizers has failed,
+     * if true, it emits a gesture event,
+     * otherwise, setup the state to FAILED.
+     * @param {Object} input
+     */
+    tryEmit: function(input) {
+        if (this.canEmit()) {
+            return this.emit(input);
+        }
+        // it's failing anyway
+        this.state = STATE_FAILED;
+    },
+
+    /**
+     * can we emit?
+     * @returns {boolean}
+     */
+    canEmit: function() {
+        var i = 0;
+        while (i < this.requireFail.length) {
+            if (!(this.requireFail[i].state & (STATE_FAILED | STATE_POSSIBLE))) {
+                return false;
+            }
+            i++;
+        }
+        return true;
+    },
+
+    /**
+     * update the recognizer
+     * @param {Object} inputData
+     */
+    recognize: function(inputData) {
+        // make a new copy of the inputData
+        // so we can change the inputData without messing up the other recognizers
+        var inputDataClone = extend({}, inputData);
+
+        // is is enabled and allow recognizing?
+        if (!boolOrFn(this.options.enable, [this, inputDataClone])) {
+            this.reset();
+            this.state = STATE_FAILED;
+            return;
+        }
+
+        // reset when we've reached the end
+        if (this.state & (STATE_RECOGNIZED | STATE_CANCELLED | STATE_FAILED)) {
+            this.state = STATE_POSSIBLE;
+        }
+
+        this.state = this.process(inputDataClone);
+
+        // the recognizer has recognized a gesture
+        // so trigger an event
+        if (this.state & (STATE_BEGAN | STATE_CHANGED | STATE_ENDED | STATE_CANCELLED)) {
+            this.tryEmit(inputDataClone);
+        }
+    },
+
+    /**
+     * return the state of the recognizer
+     * the actual recognizing happens in this method
+     * @virtual
+     * @param {Object} inputData
+     * @returns {Const} STATE
+     */
+    process: function(inputData) { }, // jshint ignore:line
+
+    /**
+     * return the preferred touch-action
+     * @virtual
+     * @returns {Array}
+     */
+    getTouchAction: function() { },
+
+    /**
+     * called when the gesture isn't allowed to recognize
+     * like when another is being recognized or it is disabled
+     * @virtual
+     */
+    reset: function() { }
+};
+
+/**
+ * get a usable string, used as event postfix
+ * @param {Const} state
+ * @returns {String} state
+ */
+function stateStr(state) {
+    if (state & STATE_CANCELLED) {
+        return 'cancel';
+    } else if (state & STATE_ENDED) {
+        return 'end';
+    } else if (state & STATE_CHANGED) {
+        return 'move';
+    } else if (state & STATE_BEGAN) {
+        return 'start';
+    }
+    return '';
+}
+
+/**
+ * direction cons to string
+ * @param {Const} direction
+ * @returns {String}
+ */
+function directionStr(direction) {
+    if (direction == DIRECTION_DOWN) {
+        return 'down';
+    } else if (direction == DIRECTION_UP) {
+        return 'up';
+    } else if (direction == DIRECTION_LEFT) {
+        return 'left';
+    } else if (direction == DIRECTION_RIGHT) {
+        return 'right';
+    }
+    return '';
+}
+
+/**
+ * get a recognizer by name if it is bound to a manager
+ * @param {Recognizer|String} otherRecognizer
+ * @param {Recognizer} recognizer
+ * @returns {Recognizer}
+ */
+function getRecognizerByNameIfManager(otherRecognizer, recognizer) {
+    var manager = recognizer.manager;
+    if (manager) {
+        return manager.get(otherRecognizer);
+    }
+    return otherRecognizer;
+}
+
+/**
+ * This recognizer is just used as a base for the simple attribute recognizers.
+ * @constructor
+ * @extends Recognizer
+ */
+function AttrRecognizer() {
+    Recognizer.apply(this, arguments);
+}
+
+inherit(AttrRecognizer, Recognizer, {
+    /**
+     * @namespace
+     * @memberof AttrRecognizer
+     */
+    defaults: {
+        /**
+         * @type {Number}
+         * @default 1
+         */
+        pointers: 1
+    },
+
+    /**
+     * Used to check if it the recognizer receives valid input, like input.distance > 10.
+     * @memberof AttrRecognizer
+     * @param {Object} input
+     * @returns {Boolean} recognized
+     */
+    attrTest: function(input) {
+        var optionPointers = this.options.pointers;
+        return optionPointers === 0 || input.pointers.length === optionPointers;
+    },
+
+    /**
+     * Process the input and return the state for the recognizer
+     * @memberof AttrRecognizer
+     * @param {Object} input
+     * @returns {*} State
+     */
+    process: function(input) {
+        var state = this.state;
+        var eventType = input.eventType;
+
+        var isRecognized = state & (STATE_BEGAN | STATE_CHANGED);
+        var isValid = this.attrTest(input);
+
+        // on cancel input and we've recognized before, return STATE_CANCELLED
+        if (isRecognized && (eventType & INPUT_CANCEL || !isValid)) {
+            return state | STATE_CANCELLED;
+        } else if (isRecognized || isValid) {
+            if (eventType & INPUT_END) {
+                return state | STATE_ENDED;
+            } else if (!(state & STATE_BEGAN)) {
+                return STATE_BEGAN;
+            }
+            return state | STATE_CHANGED;
+        }
+        return STATE_FAILED;
+    }
+});
+
+/**
+ * Pan
+ * Recognized when the pointer is down and moved in the allowed direction.
+ * @constructor
+ * @extends AttrRecognizer
+ */
+function PanRecognizer() {
+    AttrRecognizer.apply(this, arguments);
+
+    this.pX = null;
+    this.pY = null;
+}
+
+inherit(PanRecognizer, AttrRecognizer, {
+    /**
+     * @namespace
+     * @memberof PanRecognizer
+     */
+    defaults: {
+        event: 'pan',
+        threshold: 10,
+        pointers: 1,
+        direction: DIRECTION_ALL
+    },
+
+    getTouchAction: function() {
+        var direction = this.options.direction;
+        var actions = [];
+        if (direction & DIRECTION_HORIZONTAL) {
+            actions.push(TOUCH_ACTION_PAN_Y);
+        }
+        if (direction & DIRECTION_VERTICAL) {
+            actions.push(TOUCH_ACTION_PAN_X);
+        }
+        return actions;
+    },
+
+    directionTest: function(input) {
+        var options = this.options;
+        var hasMoved = true;
+        var distance = input.distance;
+        var direction = input.direction;
+        var x = input.deltaX;
+        var y = input.deltaY;
+
+        // lock to axis?
+        if (!(direction & options.direction)) {
+            if (options.direction & DIRECTION_HORIZONTAL) {
+                direction = (x === 0) ? DIRECTION_NONE : (x < 0) ? DIRECTION_LEFT : DIRECTION_RIGHT;
+                hasMoved = x != this.pX;
+                distance = Math.abs(input.deltaX);
+            } else {
+                direction = (y === 0) ? DIRECTION_NONE : (y < 0) ? DIRECTION_UP : DIRECTION_DOWN;
+                hasMoved = y != this.pY;
+                distance = Math.abs(input.deltaY);
+            }
+        }
+        input.direction = direction;
+        return hasMoved && distance > options.threshold && direction & options.direction;
+    },
+
+    attrTest: function(input) {
+        return AttrRecognizer.prototype.attrTest.call(this, input) &&
+            (this.state & STATE_BEGAN || (!(this.state & STATE_BEGAN) && this.directionTest(input)));
+    },
+
+    emit: function(input) {
+        this.pX = input.deltaX;
+        this.pY = input.deltaY;
+
+        var direction = directionStr(input.direction);
+        if (direction) {
+            this.manager.emit(this.options.event + direction, input);
+        }
+
+        this._super.emit.call(this, input);
+    }
+});
+
+/**
+ * Pinch
+ * Recognized when two or more pointers are moving toward (zoom-in) or away from each other (zoom-out).
+ * @constructor
+ * @extends AttrRecognizer
+ */
+function PinchRecognizer() {
+    AttrRecognizer.apply(this, arguments);
+}
+
+inherit(PinchRecognizer, AttrRecognizer, {
+    /**
+     * @namespace
+     * @memberof PinchRecognizer
+     */
+    defaults: {
+        event: 'pinch',
+        threshold: 0,
+        pointers: 2
+    },
+
+    getTouchAction: function() {
+        return [TOUCH_ACTION_NONE];
+    },
+
+    attrTest: function(input) {
+        return this._super.attrTest.call(this, input) &&
+            (Math.abs(input.scale - 1) > this.options.threshold || this.state & STATE_BEGAN);
+    },
+
+    emit: function(input) {
+        this._super.emit.call(this, input);
+        if (input.scale !== 1) {
+            var inOut = input.scale < 1 ? 'in' : 'out';
+            this.manager.emit(this.options.event + inOut, input);
+        }
+    }
+});
+
+/**
+ * Press
+ * Recognized when the pointer is down for x ms without any movement.
+ * @constructor
+ * @extends Recognizer
+ */
+function PressRecognizer() {
+    Recognizer.apply(this, arguments);
+
+    this._timer = null;
+    this._input = null;
+}
+
+inherit(PressRecognizer, Recognizer, {
+    /**
+     * @namespace
+     * @memberof PressRecognizer
+     */
+    defaults: {
+        event: 'press',
+        pointers: 1,
+        time: 500, // minimal time of the pointer to be pressed
+        threshold: 5 // a minimal movement is ok, but keep it low
+    },
+
+    getTouchAction: function() {
+        return [TOUCH_ACTION_AUTO];
+    },
+
+    process: function(input) {
+        var options = this.options;
+        var validPointers = input.pointers.length === options.pointers;
+        var validMovement = input.distance < options.threshold;
+        var validTime = input.deltaTime > options.time;
+
+        this._input = input;
+
+        // we only allow little movement
+        // and we've reached an end event, so a tap is possible
+        if (!validMovement || !validPointers || (input.eventType & (INPUT_END | INPUT_CANCEL) && !validTime)) {
+            this.reset();
+        } else if (input.eventType & INPUT_START) {
+            this.reset();
+            this._timer = setTimeoutContext(function() {
+                this.state = STATE_RECOGNIZED;
+                this.tryEmit();
+            }, options.time, this);
+        } else if (input.eventType & INPUT_END) {
+            return STATE_RECOGNIZED;
+        }
+        return STATE_FAILED;
+    },
+
+    reset: function() {
+        clearTimeout(this._timer);
+    },
+
+    emit: function(input) {
+        if (this.state !== STATE_RECOGNIZED) {
+            return;
+        }
+
+        if (input && (input.eventType & INPUT_END)) {
+            this.manager.emit(this.options.event + 'up', input);
+        } else {
+            this._input.timeStamp = now();
+            this.manager.emit(this.options.event, this._input);
+        }
+    }
+});
+
+/**
+ * Rotate
+ * Recognized when two or more pointer are moving in a circular motion.
+ * @constructor
+ * @extends AttrRecognizer
+ */
+function RotateRecognizer() {
+    AttrRecognizer.apply(this, arguments);
+}
+
+inherit(RotateRecognizer, AttrRecognizer, {
+    /**
+     * @namespace
+     * @memberof RotateRecognizer
+     */
+    defaults: {
+        event: 'rotate',
+        threshold: 0,
+        pointers: 2
+    },
+
+    getTouchAction: function() {
+        return [TOUCH_ACTION_NONE];
+    },
+
+    attrTest: function(input) {
+        return this._super.attrTest.call(this, input) &&
+            (Math.abs(input.rotation) > this.options.threshold || this.state & STATE_BEGAN);
+    }
+});
+
+/**
+ * Swipe
+ * Recognized when the pointer is moving fast (velocity), with enough distance in the allowed direction.
+ * @constructor
+ * @extends AttrRecognizer
+ */
+function SwipeRecognizer() {
+    AttrRecognizer.apply(this, arguments);
+}
+
+inherit(SwipeRecognizer, AttrRecognizer, {
+    /**
+     * @namespace
+     * @memberof SwipeRecognizer
+     */
+    defaults: {
+        event: 'swipe',
+        threshold: 10,
+        velocity: 0.65,
+        direction: DIRECTION_HORIZONTAL | DIRECTION_VERTICAL,
+        pointers: 1
+    },
+
+    getTouchAction: function() {
+        return PanRecognizer.prototype.getTouchAction.call(this);
+    },
+
+    attrTest: function(input) {
+        var direction = this.options.direction;
+        var velocity;
+
+        if (direction & (DIRECTION_HORIZONTAL | DIRECTION_VERTICAL)) {
+            velocity = input.velocity;
+        } else if (direction & DIRECTION_HORIZONTAL) {
+            velocity = input.velocityX;
+        } else if (direction & DIRECTION_VERTICAL) {
+            velocity = input.velocityY;
+        }
+
+        return this._super.attrTest.call(this, input) &&
+            direction & input.direction &&
+            input.distance > this.options.threshold &&
+            abs(velocity) > this.options.velocity && input.eventType & INPUT_END;
+    },
+
+    emit: function(input) {
+        var direction = directionStr(input.direction);
+        if (direction) {
+            this.manager.emit(this.options.event + direction, input);
+        }
+
+        this.manager.emit(this.options.event, input);
+    }
+});
+
+/**
+ * A tap is ecognized when the pointer is doing a small tap/click. Multiple taps are recognized if they occur
+ * between the given interval and position. The delay option can be used to recognize multi-taps without firing
+ * a single tap.
+ *
+ * The eventData from the emitted event contains the property `tapCount`, which contains the amount of
+ * multi-taps being recognized.
+ * @constructor
+ * @extends Recognizer
+ */
+function TapRecognizer() {
+    Recognizer.apply(this, arguments);
+
+    // previous time and center,
+    // used for tap counting
+    this.pTime = false;
+    this.pCenter = false;
+
+    this._timer = null;
+    this._input = null;
+    this.count = 0;
+}
+
+inherit(TapRecognizer, Recognizer, {
+    /**
+     * @namespace
+     * @memberof PinchRecognizer
+     */
+    defaults: {
+        event: 'tap',
+        pointers: 1,
+        taps: 1,
+        interval: 300, // max time between the multi-tap taps
+        time: 250, // max time of the pointer to be down (like finger on the screen)
+        threshold: 2, // a minimal movement is ok, but keep it low
+        posThreshold: 10 // a multi-tap can be a bit off the initial position
+    },
+
+    getTouchAction: function() {
+        return [TOUCH_ACTION_MANIPULATION];
+    },
+
+    process: function(input) {
+        var options = this.options;
+
+        var validPointers = input.pointers.length === options.pointers;
+        var validMovement = input.distance < options.threshold;
+        var validTouchTime = input.deltaTime < options.time;
+
+        this.reset();
+
+        if ((input.eventType & INPUT_START) && (this.count === 0)) {
+            return this.failTimeout();
+        }
+
+        // we only allow little movement
+        // and we've reached an end event, so a tap is possible
+        if (validMovement && validTouchTime && validPointers) {
+            if (input.eventType != INPUT_END) {
+                return this.failTimeout();
+            }
+
+            var validInterval = this.pTime ? (input.timeStamp - this.pTime < options.interval) : true;
+            var validMultiTap = !this.pCenter || getDistance(this.pCenter, input.center) < options.posThreshold;
+
+            this.pTime = input.timeStamp;
+            this.pCenter = input.center;
+
+            if (!validMultiTap || !validInterval) {
+                this.count = 1;
+            } else {
+                this.count += 1;
+            }
+
+            this._input = input;
+
+            // if tap count matches we have recognized it,
+            // else it has began recognizing...
+            var tapCount = this.count % options.taps;
+            if (tapCount === 0) {
+                // no failing requirements, immediately trigger the tap event
+                // or wait as long as the multitap interval to trigger
+                if (!this.hasRequireFailures()) {
+                    return STATE_RECOGNIZED;
+                } else {
+                    this._timer = setTimeoutContext(function() {
+                        this.state = STATE_RECOGNIZED;
+                        this.tryEmit();
+                    }, options.interval, this);
+                    return STATE_BEGAN;
+                }
+            }
+        }
+        return STATE_FAILED;
+    },
+
+    failTimeout: function() {
+        this._timer = setTimeoutContext(function() {
+            this.state = STATE_FAILED;
+        }, this.options.interval, this);
+        return STATE_FAILED;
+    },
+
+    reset: function() {
+        clearTimeout(this._timer);
+    },
+
+    emit: function() {
+        if (this.state == STATE_RECOGNIZED ) {
+            this._input.tapCount = this.count;
+            this.manager.emit(this.options.event, this._input);
+        }
+    }
+});
+
+/**
+ * Simple way to create an manager with a default set of recognizers.
+ * @param {HTMLElement} element
+ * @param {Object} [options]
+ * @constructor
+ */
+function Hammer(element, options) {
+    options = options || {};
+    options.recognizers = ifUndefined(options.recognizers, Hammer.defaults.preset);
+    return new Manager(element, options);
+}
+
+/**
+ * @const {string}
+ */
+Hammer.VERSION = '2.0.4';
+
+/**
+ * default settings
+ * @namespace
+ */
+Hammer.defaults = {
+    /**
+     * set if DOM events are being triggered.
+     * But this is slower and unused by simple implementations, so disabled by default.
+     * @type {Boolean}
+     * @default false
+     */
+    domEvents: false,
+
+    /**
+     * The value for the touchAction property/fallback.
+     * When set to `compute` it will magically set the correct value based on the added recognizers.
+     * @type {String}
+     * @default compute
+     */
+    touchAction: TOUCH_ACTION_COMPUTE,
+
+    /**
+     * @type {Boolean}
+     * @default true
+     */
+    enable: true,
+
+    /**
+     * EXPERIMENTAL FEATURE -- can be removed/changed
+     * Change the parent input target element.
+     * If Null, then it is being set the to main element.
+     * @type {Null|EventTarget}
+     * @default null
+     */
+    inputTarget: null,
+
+    /**
+     * force an input class
+     * @type {Null|Function}
+     * @default null
+     */
+    inputClass: null,
+
+    /**
+     * Default recognizer setup when calling `Hammer()`
+     * When creating a new Manager these will be skipped.
+     * @type {Array}
+     */
+    preset: [
+        // RecognizerClass, options, [recognizeWith, ...], [requireFailure, ...]
+        [RotateRecognizer, { enable: false }],
+        [PinchRecognizer, { enable: false }, ['rotate']],
+        [SwipeRecognizer,{ direction: DIRECTION_HORIZONTAL }],
+        [PanRecognizer, { direction: DIRECTION_HORIZONTAL }, ['swipe']],
+        [TapRecognizer],
+        [TapRecognizer, { event: 'doubletap', taps: 2 }, ['tap']],
+        [PressRecognizer]
+    ],
+
+    /**
+     * Some CSS properties can be used to improve the working of Hammer.
+     * Add them to this method and they will be set when creating a new Manager.
+     * @namespace
+     */
+    cssProps: {
+        /**
+         * Disables text selection to improve the dragging gesture. Mainly for desktop browsers.
+         * @type {String}
+         * @default 'none'
+         */
+        userSelect: 'none',
+
+        /**
+         * Disable the Windows Phone grippers when pressing an element.
+         * @type {String}
+         * @default 'none'
+         */
+        touchSelect: 'none',
+
+        /**
+         * Disables the default callout shown when you touch and hold a touch target.
+         * On iOS, when you touch and hold a touch target such as a link, Safari displays
+         * a callout containing information about the link. This property allows you to disable that callout.
+         * @type {String}
+         * @default 'none'
+         */
+        touchCallout: 'none',
+
+        /**
+         * Specifies whether zooming is enabled. Used by IE10>
+         * @type {String}
+         * @default 'none'
+         */
+        contentZooming: 'none',
+
+        /**
+         * Specifies that an entire element should be draggable instead of its contents. Mainly for desktop browsers.
+         * @type {String}
+         * @default 'none'
+         */
+        userDrag: 'none',
+
+        /**
+         * Overrides the highlight color shown when the user taps a link or a JavaScript
+         * clickable element in iOS. This property obeys the alpha value, if specified.
+         * @type {String}
+         * @default 'rgba(0,0,0,0)'
+         */
+        tapHighlightColor: 'rgba(0,0,0,0)'
+    }
+};
+
+var STOP = 1;
+var FORCED_STOP = 2;
+
+/**
+ * Manager
+ * @param {HTMLElement} element
+ * @param {Object} [options]
+ * @constructor
+ */
+function Manager(element, options) {
+    options = options || {};
+
+    this.options = merge(options, Hammer.defaults);
+    this.options.inputTarget = this.options.inputTarget || element;
+
+    this.handlers = {};
+    this.session = {};
+    this.recognizers = [];
+
+    this.element = element;
+    this.input = createInputInstance(this);
+    this.touchAction = new TouchAction(this, this.options.touchAction);
+
+    toggleCssProps(this, true);
+
+    each(options.recognizers, function(item) {
+        var recognizer = this.add(new (item[0])(item[1]));
+        item[2] && recognizer.recognizeWith(item[2]);
+        item[3] && recognizer.requireFailure(item[3]);
+    }, this);
+}
+
+Manager.prototype = {
+    /**
+     * set options
+     * @param {Object} options
+     * @returns {Manager}
+     */
+    set: function(options) {
+        extend(this.options, options);
+
+        // Options that need a little more setup
+        if (options.touchAction) {
+            this.touchAction.update();
+        }
+        if (options.inputTarget) {
+            // Clean up existing event listeners and reinitialize
+            this.input.destroy();
+            this.input.target = options.inputTarget;
+            this.input.init();
+        }
+        return this;
+    },
+
+    /**
+     * stop recognizing for this session.
+     * This session will be discarded, when a new [input]start event is fired.
+     * When forced, the recognizer cycle is stopped immediately.
+     * @param {Boolean} [force]
+     */
+    stop: function(force) {
+        this.session.stopped = force ? FORCED_STOP : STOP;
+    },
+
+    /**
+     * run the recognizers!
+     * called by the inputHandler function on every movement of the pointers (touches)
+     * it walks through all the recognizers and tries to detect the gesture that is being made
+     * @param {Object} inputData
+     */
+    recognize: function(inputData) {
+        var session = this.session;
+        if (session.stopped) {
+            return;
+        }
+
+        // run the touch-action polyfill
+        this.touchAction.preventDefaults(inputData);
+
+        var recognizer;
+        var recognizers = this.recognizers;
+
+        // this holds the recognizer that is being recognized.
+        // so the recognizer's state needs to be BEGAN, CHANGED, ENDED or RECOGNIZED
+        // if no recognizer is detecting a thing, it is set to `null`
+        var curRecognizer = session.curRecognizer;
+
+        // reset when the last recognizer is recognized
+        // or when we're in a new session
+        if (!curRecognizer || (curRecognizer && curRecognizer.state & STATE_RECOGNIZED)) {
+            curRecognizer = session.curRecognizer = null;
+        }
+
+        var i = 0;
+        while (i < recognizers.length) {
+            recognizer = recognizers[i];
+
+            // find out if we are allowed try to recognize the input for this one.
+            // 1.   allow if the session is NOT forced stopped (see the .stop() method)
+            // 2.   allow if we still haven't recognized a gesture in this session, or the this recognizer is the one
+            //      that is being recognized.
+            // 3.   allow if the recognizer is allowed to run simultaneous with the current recognized recognizer.
+            //      this can be setup with the `recognizeWith()` method on the recognizer.
+            if (session.stopped !== FORCED_STOP && ( // 1
+                    !curRecognizer || recognizer == curRecognizer || // 2
+                    recognizer.canRecognizeWith(curRecognizer))) { // 3
+                recognizer.recognize(inputData);
+            } else {
+                recognizer.reset();
+            }
+
+            // if the recognizer has been recognizing the input as a valid gesture, we want to store this one as the
+            // current active recognizer. but only if we don't already have an active recognizer
+            if (!curRecognizer && recognizer.state & (STATE_BEGAN | STATE_CHANGED | STATE_ENDED)) {
+                curRecognizer = session.curRecognizer = recognizer;
+            }
+            i++;
+        }
+    },
+
+    /**
+     * get a recognizer by its event name.
+     * @param {Recognizer|String} recognizer
+     * @returns {Recognizer|Null}
+     */
+    get: function(recognizer) {
+        if (recognizer instanceof Recognizer) {
+            return recognizer;
+        }
+
+        var recognizers = this.recognizers;
+        for (var i = 0; i < recognizers.length; i++) {
+            if (recognizers[i].options.event == recognizer) {
+                return recognizers[i];
+            }
+        }
+        return null;
+    },
+
+    /**
+     * add a recognizer to the manager
+     * existing recognizers with the same event name will be removed
+     * @param {Recognizer} recognizer
+     * @returns {Recognizer|Manager}
+     */
+    add: function(recognizer) {
+        if (invokeArrayArg(recognizer, 'add', this)) {
+            return this;
+        }
+
+        // remove existing
+        var existing = this.get(recognizer.options.event);
+        if (existing) {
+            this.remove(existing);
+        }
+
+        this.recognizers.push(recognizer);
+        recognizer.manager = this;
+
+        this.touchAction.update();
+        return recognizer;
+    },
+
+    /**
+     * remove a recognizer by name or instance
+     * @param {Recognizer|String} recognizer
+     * @returns {Manager}
+     */
+    remove: function(recognizer) {
+        if (invokeArrayArg(recognizer, 'remove', this)) {
+            return this;
+        }
+
+        var recognizers = this.recognizers;
+        recognizer = this.get(recognizer);
+        recognizers.splice(inArray(recognizers, recognizer), 1);
+
+        this.touchAction.update();
+        return this;
+    },
+
+    /**
+     * bind event
+     * @param {String} events
+     * @param {Function} handler
+     * @returns {EventEmitter} this
+     */
+    on: function(events, handler) {
+        var handlers = this.handlers;
+        each(splitStr(events), function(event) {
+            handlers[event] = handlers[event] || [];
+            handlers[event].push(handler);
+        });
+        return this;
+    },
+
+    /**
+     * unbind event, leave emit blank to remove all handlers
+     * @param {String} events
+     * @param {Function} [handler]
+     * @returns {EventEmitter} this
+     */
+    off: function(events, handler) {
+        var handlers = this.handlers;
+        each(splitStr(events), function(event) {
+            if (!handler) {
+                delete handlers[event];
+            } else {
+                handlers[event].splice(inArray(handlers[event], handler), 1);
+            }
+        });
+        return this;
+    },
+
+    /**
+     * emit event to the listeners
+     * @param {String} event
+     * @param {Object} data
+     */
+    emit: function(event, data) {
+        // we also want to trigger dom events
+        if (this.options.domEvents) {
+            triggerDomEvent(event, data);
+        }
+
+        // no handlers, so skip it all
+        var handlers = this.handlers[event] && this.handlers[event].slice();
+        if (!handlers || !handlers.length) {
+            return;
+        }
+
+        data.type = event;
+        data.preventDefault = function() {
+            data.srcEvent.preventDefault();
+        };
+
+        var i = 0;
+        while (i < handlers.length) {
+            handlers[i](data);
+            i++;
+        }
+    },
+
+    /**
+     * destroy the manager and unbinds all events
+     * it doesn't unbind dom events, that is the user own responsibility
+     */
+    destroy: function() {
+        this.element && toggleCssProps(this, false);
+
+        this.handlers = {};
+        this.session = {};
+        this.input.destroy();
+        this.element = null;
+    }
+};
+
+/**
+ * add/remove the css properties as defined in manager.options.cssProps
+ * @param {Manager} manager
+ * @param {Boolean} add
+ */
+function toggleCssProps(manager, add) {
+    var element = manager.element;
+    each(manager.options.cssProps, function(value, name) {
+        element.style[prefixed(element.style, name)] = add ? value : '';
+    });
+}
+
+/**
+ * trigger dom event
+ * @param {String} event
+ * @param {Object} data
+ */
+function triggerDomEvent(event, data) {
+    var gestureEvent = document.createEvent('Event');
+    gestureEvent.initEvent(event, true, true);
+    gestureEvent.gesture = data;
+    data.target.dispatchEvent(gestureEvent);
+}
+
+extend(Hammer, {
+    INPUT_START: INPUT_START,
+    INPUT_MOVE: INPUT_MOVE,
+    INPUT_END: INPUT_END,
+    INPUT_CANCEL: INPUT_CANCEL,
+
+    STATE_POSSIBLE: STATE_POSSIBLE,
+    STATE_BEGAN: STATE_BEGAN,
+    STATE_CHANGED: STATE_CHANGED,
+    STATE_ENDED: STATE_ENDED,
+    STATE_RECOGNIZED: STATE_RECOGNIZED,
+    STATE_CANCELLED: STATE_CANCELLED,
+    STATE_FAILED: STATE_FAILED,
+
+    DIRECTION_NONE: DIRECTION_NONE,
+    DIRECTION_LEFT: DIRECTION_LEFT,
+    DIRECTION_RIGHT: DIRECTION_RIGHT,
+    DIRECTION_UP: DIRECTION_UP,
+    DIRECTION_DOWN: DIRECTION_DOWN,
+    DIRECTION_HORIZONTAL: DIRECTION_HORIZONTAL,
+    DIRECTION_VERTICAL: DIRECTION_VERTICAL,
+    DIRECTION_ALL: DIRECTION_ALL,
+
+    Manager: Manager,
+    Input: Input,
+    TouchAction: TouchAction,
+
+    TouchInput: TouchInput,
+    MouseInput: MouseInput,
+    PointerEventInput: PointerEventInput,
+    TouchMouseInput: TouchMouseInput,
+    SingleTouchInput: SingleTouchInput,
+
+    Recognizer: Recognizer,
+    AttrRecognizer: AttrRecognizer,
+    Tap: TapRecognizer,
+    Pan: PanRecognizer,
+    Swipe: SwipeRecognizer,
+    Pinch: PinchRecognizer,
+    Rotate: RotateRecognizer,
+    Press: PressRecognizer,
+
+    on: addEventListeners,
+    off: removeEventListeners,
+    each: each,
+    merge: merge,
+    extend: extend,
+    inherit: inherit,
+    bindFn: bindFn,
+    prefixed: prefixed
+});
+
+if (typeof define == TYPE_FUNCTION && define.amd) {
+    define(function() {
+        return Hammer;
+    });
+} else if (typeof module != 'undefined' && module.exports) {
+    module.exports = Hammer;
+} else {
+    window[exportName] = Hammer;
+}
+
+})(window, document, 'Hammer');

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
static/stammbaum/jquery.min.js


+ 395 - 0
static/stammbaum/main.js

@@ -0,0 +1,395 @@
+var rowId = [];
+var tree, persons, pairs, groups;
+var group = null;
+var panZoom;
+
+window.onload = function() {
+  const parameterList = new URLSearchParams(window.location.search);
+  group = parameterList.get("group");
+
+  tree = new SVGTree(document.getElementById('chart'), 'svg0');
+  persons = JSON.parse(document.getElementById('person-data').textContent);
+  pairs = JSON.parse(document.getElementById('pair-data').textContent);
+  groups = JSON.parse(document.getElementById('group-data').textContent);
+  
+  // construct persons
+  for (let i=0; i<persons.length; i++) {
+    const p = persons[i];
+    persons[i] = {
+      id: p[0],
+      name: p[1],
+      parent_id: p[2],
+      birth_date: p[3],
+      birth_town: p[4],
+      death_date: p[5],
+      death_town: p[6],
+      comment: p[7],
+      image: p[8],
+      color: p[9],
+      group_id: p[10]
+    };
+  }
+
+  // construct pairs
+  for (let i = 0; i < pairs.length; i++) {
+    const p = pairs[i];
+    pairs[i] = {
+      id: p[0],
+      from_person_id: p[1],
+      to_person_id: p[2]
+    };
+  }
+
+  // find partners
+  for(const pair of pairs) {
+    const p1 = persons.find(p => p.id === pair.from_person_id);
+    const p2 = persons.find(p => p.id === pair.to_person_id);
+    pair.from_person = p1;
+    pair.to_person = p2;
+    p1.partner = p2;
+    p1.pair = pair;
+    p2.partner = p1;
+    p2.pair = pair;
+  }
+  
+  // find group, parents and children
+  for(const person of persons) {
+    const group = groups.find(g => g.id == person.group_id);
+    const parent = persons.find(p => p.id === person.parent_id && p.id != person.id);
+    const children = persons.filter(p => p.parent_id === person.id && p.id != person.id);
+    person.group = group;
+    person.parent = parent;
+    person.children = children;
+  }
+
+  // hide persons without tree
+  for (const person of persons) {
+    person.hasCard =
+      person.hasCard ||
+      !person.partner ||
+      !!person.parent ||
+      person.children.length > 0;
+
+    if (person.partner) {
+      person.partner.hasCard =
+        !person.hasCard ||
+        !!person.partner.parent ||
+        person.partner.children.length > 0;
+    }
+  }
+
+  // order persons to allow shifting down level pairs and get related children closer
+  persons.sort(function(a, b) {
+    if(a.group_id != b.group_id && a.group_id != null)
+      return a.group_id - b.group_id;
+
+    function findRelation(p) {
+      if (!p.hasCard)
+        return null;
+
+      if (p.partner && p.partner.hasCard == true)
+        return p.pair.id;
+
+      var ret = null;
+      for (const child of p.children){
+        ret = findRelation(child);
+        if(ret !== null)
+          return ret;
+      }
+      return null;
+    }
+
+    if(!a.parent || !b.parent) {
+      var relationId = findRelation(a) - findRelation(b);
+      if(relationId != null)
+        return relationId;
+    }
+
+    const dt1 = a.birth_date || a.death_date || "9999-01-01";
+    const dt2 = b.birth_date || b.death_date || "9999-01-01";
+    return new Date(dt1) - new Date(dt2);
+  });
+
+  // add groups
+  for(const row of groups) {
+      $('#group').append(`<div class='overlay ${row.id}' style='color: black; background: linear-gradient(rgba(0,0,0,0),${row.color}, rgba(0,0,0,0));' onclick='display(${row.id});'>${row.name}</div>`);
+  }
+  $('#loader').hide();
+  
+  /**
+   * calculate position of cards
+   * @param {*} personList current persons
+   * @param {*} level row of the current card
+   * @param {*} accX maximum X positions per layer
+   * @returns width of the tree
+   */
+  function assignCoords(personList, level=0, accX=[0]) {
+    
+    personList = personList.filter(p => p.hasCard);
+    const margin = 20;
+        
+    let startX = 0, endX = 0;
+
+    for (let j=0; j<personList.length; j++) {
+      const person = personList[j];
+
+      // set y
+      person.level = level;
+      if(person.partner && person.partner.level > level) {
+        person.level = person.partner.level;
+      }
+      person.y = person.level * (tree.cardHeight + 40);
+
+      // set x
+      accX[person.level + 1] = accX[person.level + 1] || 0;
+
+      // fill in skipped layers
+      for(var i=level; i<=person.level; i++)
+        accX[i] = accX.slice(level,person.level+1).reduce((acc, x) => Math.max(acc, x), 0);
+
+      const width = tree.cardWidth * (person.partner == null ? 1 : 2);
+      const origAccX = accX.slice();
+      const subWidth = assignCoords(person.children, person.level + 1, accX);
+
+      const requiredForSiblings = personList.slice(j + 1).reduce((acc, p) => acc + tree.cardWidth * (person.partner == null ? 1 : 2) + margin, 0);
+      const remainingWidthAboveChildren = accX[person.level+1] - accX[person.level];
+      const idealX = subWidth > 0 ? accX[person.level + 1] - subWidth / 2 - width / 2 : accX[person.level] + margin;
+
+
+      if (idealX >= accX[person.level] + margin) {
+        // perfect fit
+        person.x = idealX;
+      } else if(remainingWidthAboveChildren > margin + width) {
+        // still fits above children
+        person.x = accX[person.level] + margin;
+      } else if(subWidth == 0) {
+        // no children
+        person.x = accX[person.level] + margin;
+      } else if (subWidth < width) {
+        // center children under this card to avoid overlap
+        person.x = accX[person.level] + margin;
+        for (var i = person.level + 1; i < accX.length; i++)
+          accX[i] = Math.max(origAccX[i], person.x + width/2 - subWidth/2 - margin);
+        assignCoords(person.children, person.level + 1, accX);
+      } else {
+        // we need to move children under this card to avoid overlap
+        person.x = accX[person.level] + margin;
+        for (var i = person.level + 1; i < accX.length; i++)
+          accX[i] = Math.max(origAccX[i], person.x + width - subWidth - margin);
+        assignCoords(person.children, person.level + 1, accX);
+        // center this node again
+        person.x = Math.max(origAccX[person.level] + margin, accX[person.level + 1] - subWidth / 2 - width / 2);
+
+      }
+      if(!startX)
+        startX = person.x;
+      endX = person.x + width;
+
+      accX[person.level] = person.x + width;
+
+      // fill in skipped layers
+      for (var i = level; i <= person.level; i++)
+        accX[i] = accX.slice(level, person.level + 1).reduce((acc, x) => Math.max(acc, x), 0);
+    }
+    return endX - startX;
+  }
+  assignCoords(persons.filter(p => p.parent_id == null));
+  
+
+  // draw links
+  for(const link of pairs) {
+    const minId = Math.min(link.from_person_id, link.to_person_id);
+    const maxId = Math.max(link.from_person_id, link.to_person_id);
+    if (!document.getElementById(`link_line_${minId}_${maxId}`)) {
+      if (link.from_person.hasCard && link.to_person.hasCard)
+        tree.drawLink(link);
+    }
+  }
+
+  let drag = false;
+  for(const person of persons) {
+    if(!person.hasCard)
+      continue;
+
+    let color = person.color;
+    if(!person.partner || !person.partner.hasCard) {
+      color = null;
+    }
+    
+    const card = tree.drawCard(person, person.group ? person.group.color : "red", color);
+    card.addEventListener('mousedown', () => drag = false);
+    card.addEventListener('mousemove', () => drag = true);
+    card.addEventListener('mouseover', onMouseover);
+    card.addEventListener('click', onSelect);
+  }
+  for(const person of persons) {
+    if(person.hasCard)
+      tree.drawLines(person, person.children, person.group ? person.group.color : "#ff0000");
+  }
+
+  function onMouseover(event) {
+    const person_id = this.id.replace('card_', '');
+    const link = pairs.find(r => r.from_person_id == person_id || r.to_person_id == person_id);
+    if(link == null)
+      return;
+    
+    if (group === null || link.from_person.group_id === link.to_person.group_id) {
+      const minId = Math.min(link.from_person_id, link.to_person_id);
+      const maxId = Math.max(link.from_person_id, link.to_person_id);
+      $('.link_lines').hide();
+      $(`#link_line_${minId}_${maxId}`).show();
+    }
+  }
+
+  function onSelect(event) {
+    if(drag) {
+      event.preventDefault();
+    } else {
+
+      const person = persons.find(p => p.id == this.id.replace('card_', ''));
+      $('#img_name').html(`${person.name} (${person.group_id})` + (person.partner != null ? '<br>' + person.partner.name : ''));
+      $('#name').val(person.name);
+      $('#form').attr('action', `/stammbaum/person/${person.id}/upload`);
+      $('.info').css('display', 'inline-block');
+    }
+  } 
+  panZoom = panZoomInit();
+  display(group);
+
+
+  var saveData = (function () {
+    var a = document.createElement("a");
+    document.body.appendChild(a);
+    a.style = "display: none";
+    return function (data, fileName) {
+      var blob = new Blob([data], {type: 'image/svg+xml;charset=utf-8'});
+      var url = window.URL.createObjectURL(blob);
+      a.href = url;
+      a.download = fileName;
+      a.click();
+      window.URL.revokeObjectURL(url);
+    };
+  }());
+
+  document.getElementById('save').addEventListener('click', function() {
+    $('.link_lines').hide();
+    var svg = document.getElementById('svg0').cloneNode(true);
+    function iterRemove(node) {
+      for(let child of node.children)
+        iterRemove(child);
+      if(node.style.display === "none") {
+        console.log(node);
+        node.remove();
+      }
+    }
+    iterRemove(svg);
+    var data = (new XMLSerializer()).serializeToString(svg);
+    saveData(data, "stammbaum.svg");
+  });
+}
+
+
+function display(g) {
+
+  group = g;
+  history.replaceState({}, 'Stammbaum ' + (group + 1), '/stammbaum/' + (group !== null ? '?group=' + group : ''));
+  
+  for(const person of persons) {
+    if(!person.hasCard)
+      continue;
+
+    if(group === null || person.group_id == group) {
+      document.getElementById(`card_${person.id}`).style.display = 'inline';
+      document.getElementById(`line_${person.id}`).style.display = 'inline';
+    } else {
+      document.getElementById(`card_${person.id}`).style.display = 'none';
+      document.getElementById(`line_${person.id}`).style.display = 'none';
+    }
+  }
+
+  $('.link_lines').hide();
+
+  panZoom.updateBBox();
+  panZoom.fit();
+  panZoom.center();
+} 
+
+function panZoomInit() {
+  return svgPanZoom('#svg0', {
+    maxZoom: 100,
+    dblClickZoomEnabled: false,
+    zoomScaleSensitivity: 0.3,
+    beforePan: function (oldPan, newPan) {
+      var stopHorizontal = false
+      , stopVertical = false
+      , gutterWidth = 100
+      , gutterHeight = 100
+        // Computed variables
+      , sizes = this.getSizes()
+      , leftLimit = -((sizes.viewBox.x + sizes.viewBox.width) * sizes.realZoom) + gutterWidth
+      , rightLimit = sizes.width - gutterWidth - (sizes.viewBox.x * sizes.realZoom)
+      , topLimit = -((sizes.viewBox.y + sizes.viewBox.height) * sizes.realZoom) + gutterHeight
+      , bottomLimit = sizes.height - gutterHeight - (sizes.viewBox.y * sizes.realZoom)
+
+    customPan = {}
+    customPan.x = Math.max(leftLimit, Math.min(rightLimit, newPan.x))
+    customPan.y = Math.max(topLimit, Math.min(bottomLimit, newPan.y))
+
+    return customPan
+    },
+  customEventsHandler: {
+    haltEventListeners: ['touchstart', 'touchend', 'touchmove', 'touchleave', 'touchcancel']
+    , init: function(options) {
+      var instance = options.instance
+        , initialScale = 1
+        , pannedX = 0
+        , pannedY = 0
+
+      // Init Hammer
+      // Listen only for pointer and touch events
+      this.hammer = Hammer(options.svgElement, {
+        inputClass: Hammer.SUPPORT_POINTER_EVENTS ? Hammer.PointerEventInput : Hammer.TouchInput
+      })
+
+      // Enable pinch
+      this.hammer.get('pinch').set({enable: true})
+
+      // Handle double tap
+      this.hammer.on('doubletap', function(ev){
+        instance.zoomIn()
+      })
+
+      // Handle pan
+      this.hammer.on('panstart panmove', function(ev){
+        // On pan start reset panned variables
+        if (ev.type === 'panstart') {
+          pannedX = 0
+          pannedY = 0
+        }
+
+        // Pan only the difference
+        instance.panBy({x: ev.deltaX - pannedX, y: ev.deltaY - pannedY})
+        pannedX = ev.deltaX
+        pannedY = ev.deltaY
+      })
+
+      // Handle pinch
+      this.hammer.on('pinchstart pinchmove', function(ev){
+        // On pinch start remember initial zoom
+        if (ev.type === 'pinchstart') {
+          initialScale = instance.getZoom()
+          instance.zoomAtPoint(initialScale * ev.scale, {x: ev.center.x, y: ev.center.y})
+        }
+
+        instance.zoomAtPoint(initialScale * ev.scale, {x: ev.center.x, y: ev.center.y})
+      })
+
+      // Prevent moving the page on some devices when panning over SVG
+      options.svgElement.addEventListener('touchmove', function(e){ e.preventDefault(); });
+    }
+    , destroy: function(){
+        this.hammer.destroy()
+      }
+    }
+  });
+}

+ 65 - 0
static/stammbaum/style.css

@@ -0,0 +1,65 @@
+@media print {
+  #header, #group, #loader, .link_lines, #print_box {
+    display: none !important;
+  }
+  #chart {
+    width: 29.7cm;
+    height: 21cm;
+  }
+}
+#chart {
+  width: 100vw;
+  height: 100vh;
+  position: fixed;
+  top:0;
+  left:0;
+}
+body {
+  position: relative;
+  overflow-x: hidden;
+}
+.wrapper>:not(.content) {
+  z-index: 10;
+}
+.content {
+  z-index: 1;
+  min-height: 80vh;
+}
+.loader {
+    border: 16px solid #f3f3f3; /* Light grey */
+    border-top: 16px solid #3498db; /* Blue */
+    border-radius: 50%;
+    box-sizing: border-box;
+    width: 100px;
+    height: 100px;
+    animation: spin 2s linear infinite;
+    margin: auto;
+}
+
+@keyframes spin {
+    0% { transform: rotate(0deg); }
+    100% { transform: rotate(360deg); }
+}
+
+div.overlay {
+  border-radius: 20px;opacity:.8;padding:15px;margin:5px;
+  display: inline-block;vertical-align: top; background: linear-gradient(rgba(0,0,0,0), #E6E6FA, rgba(0,0,0,0));
+  text-align: center;
+  cursor: pointer;
+}
+form {
+  display: inline;
+}
+div.info {
+  display: none;
+}
+#img_name {
+  font-weight: bold;
+}
+
+#group > div.overlay {
+  color: white;
+  vertical-align: bottom;
+}
+
+a.overlay {color: black; text-decoration: none;}

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 2 - 0
static/stammbaum/svg-pan-zoom.min.js


+ 235 - 0
static/stammbaum/tree.js

@@ -0,0 +1,235 @@
+class SVGTree {
+  svgElem = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+  cardWidth  = 120;
+  cardHeight = 310;
+
+  constructor(parent, id) {
+    //this.svgElem.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', '#down-arrow');
+    this.svgElem.setAttribute('xmlns', "http://www.w3.org/2000/svg");
+    this.svgElem.style.width  = "100%";
+    this.svgElem.style.height = "100%";
+    this.svgElem.id = id;
+    this.svgElem.innerHTML = `
+    <defs>
+      <linearGradient id="gradient-card-bg" x1="0%" y1="0%" x2="0%" y2="100%"> 
+        <stop offset="0%" stop-color="#edf7ff" /> 
+        <stop offset="100%" stop-color="#cde7ee" /> 
+      </linearGradient>
+    </defs>
+    <style>
+    text {
+      font-family:arial,helvetica;  
+      font-size: 0.8em;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+    .name {
+      font-weight: bold;
+    }
+    .image {
+      object-fit: cover;
+      object-position: center;
+      overflow: hidden;
+    }
+    .comment {
+      font-family: Courier;
+    }
+    .dashes {
+      stroke-dasharray: 3,3;
+      stroke: blue;
+      stroke-width: 2;
+      fill: none;
+    }
+    .card {
+      cursor: pointer;
+    }
+    .line {
+      stroke-width: 10;
+      fill: transparent;
+      opacity: 70%;
+    }
+    .link_lines {
+      stroke-width: 120;
+      stroke-linecap: round;
+      fill: transparent;
+      display: none;
+    }
+    </style>
+    `;
+    parent.appendChild(this.svgElem);
+  }
+
+  drawCard(person, groupColor, linkColor) {
+    const cardDouble = person.partner != null;
+    let node = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+    node.setAttribute('x', person.x);
+    node.setAttribute('y', person.y);
+    const width = (cardDouble ? this.cardWidth * 2 : this.cardWidth);
+    node.setAttribute('width',  width);
+    node.setAttribute('height', this.cardHeight);
+    node.setAttribute('viewBox', `0 0 ${width} ${this.cardHeight}`);
+    node.style.overflow = "visible";
+    node.id = `card_${person.id}`;
+    node.classList.add('card');
+    const name = wordWrap(html2svg(person.name || ""), 17, 3);
+    const partner = wordWrap(html2svg(person.partner ? person.partner.name : ""), 18, 3);
+    let comment = wordWrap(html2svg(person.comment || ""), cardDouble ? 30 : 15, 3);
+    if(!person.comment && person.partner)
+      comment = wordWrap(html2svg(person.partner.comment || ""), cardDouble ? 30 : 15, 3);
+      
+    let html = `
+    <rect x="0" y="0" rx="10" ry="10" width="${width}" height="${this.cardHeight}"
+      style="fill:${linkColor === null ? 'url(#gradient-card-bg)' : linkColor};stroke:${groupColor};stroke-width:2;opacity:0.5" />    
+    `;
+    if(person.image) {
+      html += `
+      <clipPath id="clip_${person.id}">
+        <rect id="rect" rx="6" x="10" y="45" width="${width-20}" height="150"/>
+      </clipPath>
+      <a xlink:href="https://pi.justprojects.de/stammbaum/person/${person.id}/image/" target="_blank">
+        <image x="10" y="45" height="150" id="image_${person.id}" class="image" xlink:href="" clip-path="url(#clip_${person.id})" preserveAspectRatio="xMinYMin slice"/>
+      </a>
+      `;
+      fetch(`/stammbaum/person/${person.id}/thumb/`).then(function(response) {
+        return response.blob();
+      }).then(function(myBlob) {
+        var reader = new FileReader();
+        reader.onloadend = function() {
+          document.getElementById(`image_${person.id}`).setAttribute('xlink:href', reader.result);
+        }
+        reader.readAsDataURL(myBlob);
+        var objectURL = URL.createObjectURL(myBlob);
+      });
+    }
+    
+    html += `
+    <text class="name" text-anchor="middle" y="20">
+      <tspan x="${this.cardWidth*.5}" dy="0">${name[0]}</tspan>
+      <tspan x="${this.cardWidth*.5}" dy="1.2em">${name[1]}</tspan>
+      <tspan x="${this.cardWidth*.5}" dy="1.2em">${name[2]}</tspan>
+    </text>
+    <text y="210" text-anchor="middle" class="birth">
+      <tspan class="birth" dy="0" x="${this.cardWidth*.5}">${person.birth_date ? '*' + person.birth_date : ''}</tspan>
+      <tspan class="birth-loc" dy="1.2em" x="${this.cardWidth*.5}">${person.birth_town || ''}</tspan>
+      <tspan class="death" dy="1.2em" x="${this.cardWidth*.5}">${person.death_date ? '&dagger;' + person.death_date : ''}</tspan>
+      <tspan class="death-loc" dy="1.2em" x="${this.cardWidth*.5}">${person.death_town || ''}</tspan>
+    </text>
+    <text y="272">
+      <tspan class="comment" dy="0" x="10">${comment[0]}</tspan>
+      <tspan class="comment" dy="1.2em" x="10">${comment[1]}</tspan>
+      <tspan class="comment" dy="1.2em" x="10">${comment[2]}</tspan>
+    </text>
+    `;
+
+    if(cardDouble) {
+      html = `<path d="M ${this.cardWidth-1} 0 L ${this.cardWidth-1} 260" class="dashes"/>` + html + `
+      <text class="name partner" text-anchor="middle" y="20">
+        <tspan x="${this.cardWidth*1.5}" dy="0">${partner[0]}</tspan>
+        <tspan x="${this.cardWidth*1.5}" dy="1.2em">${partner[1]}</tspan>
+        <tspan x="${this.cardWidth*1.5}" dy="1.2em">${partner[2]}</tspan>
+      </text>
+      <text y="210" text-anchor="middle" class="birth">
+        <tspan class="birth" dy="0" x="${this.cardWidth*1.5}">${person.partner.birth_date ? '*' + person.partner.birth_date : ''}</tspan>
+        <tspan class="birth-loc" dy="1.2em" x="${this.cardWidth * 1.5}">${person.partner.birth_town || ''}</tspan>
+        <tspan class="death" dy="1.2em" x="${this.cardWidth*1.5}">${person.partner.death_date ? '&dagger;' + person.partner.death_date : ''}</tspan>
+        <tspan class="death-loc" dy="1.2em" x="${this.cardWidth * 1.5}">${person.partner.death_town || ''}</tspan>
+      </text>
+      `;
+    }
+
+    node.innerHTML = html;
+    if(document.getElementsByClassName('svg-pan-zoom_viewport').length > 0)
+      this.svgElem.firstChild.appendChild(node);
+    else
+      this.svgElem.appendChild(node);
+    return node;
+  }
+
+  drawLines(person, children, groupColor) {
+    let path = document.createElementNS('http://www.w3.org/2000/svg', 'path'), d = '';
+    const cardDouble = person.partner != null;
+    
+    // find the highest common mid y level
+    const upperRadius = children.reduce((min, child) => Math.min(min, Math.abs(person.y + this.cardHeight - child.y) / 2), 1000);
+
+    for(const child of children) {
+      const x1 = person.x + this.cardWidth * (cardDouble ? 1.0 : 0.5);
+      const y1 = person.y + this.cardHeight;
+      const x2 = child.x + this.cardWidth * 0.5;
+      const y2 = child.y;
+      const ym = (y1 + y2) / 2;
+      const radius = Math.abs(y1 - y2) * 0.5;
+      const xd = Math.abs(x1 - x2);
+      const dir = (x1 < x2 ? 1 : -1);
+
+      d += ` M${x1} ${y1}`; //move to start
+
+      if(xd <= radius + 5) {
+        d += ` C${x1} ${ym}, ${x2} ${ym}, ${x2} ${y2}`; // cubic bezier
+      } else {
+        d += ` q0 ${upperRadius}, ${dir*upperRadius} ${upperRadius}`; // 90° quadratic bezier
+
+        if(xd <= 2*radius) {
+          d += ` C${x1 + upperRadius*dir} ${ym}, ${x2} ${ym}, ${x2} ${y2}`; // cubic bezier
+        } else {
+          d += ` L${x2 - radius*dir} ${y1+upperRadius}`; // line side
+          d += ` q${dir*radius} 0, ${dir*radius} ${radius}`; // 90° quadratic bezier
+          d += ` L${x2} ${y2}`; // line down
+        }
+      }
+    }
+    path.setAttribute('d', d);
+    path.setAttribute('stroke', groupColor);
+    path.classList.add('line');
+    path.id = `line_${person.id}`;
+    if(document.getElementsByClassName('svg-pan-zoom_viewport').length > 0)
+      this.svgElem.firstChild.appendChild(path);
+    else
+      this.svgElem.appendChild(path);
+    return path;
+  }
+
+  drawLink(link) {
+    let path = document.createElementNS('http://www.w3.org/2000/svg', 'path'), d = '';
+    const x1 = link.from_person.x + this.cardWidth;
+    const y1 = link.from_person.y + this.cardHeight / 2;
+    const x2 = link.to_person.x + this.cardWidth;
+    const y2 = link.to_person.y + this.cardHeight / 2;
+    const ym = (y1 + y2) / 2;
+    d += ` M${x1} ${y1}`;
+    d += ` C${x1} ${ym} ${x2} ${ym} ${x2} ${y2}`;
+
+    path.setAttribute('d', d);
+    path.setAttribute('stroke', link.from_person.color);
+    path.classList.add('link_lines');
+    const minId = Math.min(link.from_person_id, link.to_person_id);
+    const maxId = Math.max(link.from_person_id, link.to_person_id);
+    path.id = `link_line_${minId}_${maxId}`;
+    if(document.getElementsByClassName('svg-pan-zoom_viewport').length > 0)
+      this.svgElem.firstChild.appendChild(path);
+    else
+      this.svgElem.appendChild(path);
+    return path;
+  }
+}
+
+function wordWrap(str = "", maxWidth, lines) {
+  let res = new Array(lines).fill("");
+  if(typeof str != "string")
+    return res;
+  
+  let parts = str.split(' ');
+  for(let i=0; i<lines; i++) {
+    while(parts.length > 0 && res[i].length + parts[0].length < maxWidth) {
+      res[i] += (res[i] === "" ? "" : " ") + parts.shift();
+    }
+  }
+  if(parts.length > 0) {
+    res[lines-1] += " " + parts.join(" ");
+  }
+  return res;
+}
+
+function html2svg(str) {
+  return str.replace("<u>", "*").replace("</u>", "*").replace(/<span .*>|<\/span>/, '');
+}

+ 105 - 0
templates/stammbaum/index.html

@@ -0,0 +1,105 @@
+
+{% extends 'homepage/base.html' %}
+{% load static %}
+
+{% block title %}Stammbaum{% endblock %}
+{% block header %}Stammbaum{% endblock %}
+
+{% block status %}
+{% if user.is_authenticated %}
+<p style="text-align:center">
+  Hi {% firstof user.first_name user.username %}!<br/>
+  {% if user.is_staff %}
+  <a href="/admin/stammbaum/" class="link" target="_blank">Stammbaum bearbeiten</a>
+  {% endif %}
+  <a href="{% url 'stammbaum-logout' %}" class="link">Logout</a>
+</p>
+{% else %}
+<p style="text-align:center">
+  You are not logged in<br/>
+  <a href="{% url 'stammbaum-login' %}" class="link">Login</a>
+  <a href="{% url 'stammbaum-register' %}" class="link">Register</a>
+</p>
+{% endif %}
+{% endblock %}
+
+{% block sidebar %}
+<a href="mailto:stammbaum@justprojects.de" title="stammbaum@justprojects.de" target="_blank"  class='overlay'>
+  <div class='overlay'><b>Kontakt@mail</b></div>
+</a>
+{% if perms.stammbaum.view %}
+<div class='overlay'>
+    <div id='img_name'>Bitte Person ausw&auml;hlen, um Bild hinzuzuf&uuml;gen.</div>
+</div>
+{% if perms.stammbaum.upload_image %}
+<form method="get" id="form" action="#">
+  <div class='overlay info'>
+    <input type='submit' value='Bild hochladen'>
+  </div>
+</form>
+{% endif %}
+
+<div class='overlay' id='save'>
+  Speichern als SVG
+</div>
+<div id="group">
+  <div class='overlay all' style='color:black' onclick='display(null);'>
+    <b>Alles anzeigen</b>
+  </div>
+</div>
+
+{% endif %}
+{% endblock %}
+
+
+{% block head %}
+{% if perms.stammbaum.view %}
+<script src="{% static 'stammbaum/svg-pan-zoom.min.js' %}"></script>
+<script src="{% static 'stammbaum/jquery.min.js' %}"></script>
+<script src="{% static 'stammbaum/hammer.js' %}"></script>
+<script src="{% static 'stammbaum/tree.js' %}"></script>
+<script src="{% static 'stammbaum/main.js' %}"></script>
+<link rel="stylesheet" href="{% static 'stammbaum/style.css' %}">
+  {{ persons|json_script:"person-data" }}
+  {{ pairs|json_script:"pair-data" }}
+  {{ groups|json_script:"group-data" }}
+{% endif %}
+{% endblock %}
+
+{% block content %}
+{% if user.is_authenticated %}
+  {% if perms.stammbaum.view %}
+  <div id="chart"></div>
+
+  {% else %}
+
+  <div class="news">
+
+    <h2 class="news_title">Information</h2>
+    <div class="news_body">
+      <p>
+        Ihr Account wurde noch nicht freigeschaltet.
+      </p>
+      <p>
+        Sie haben keine Berechtigung diesen Inhalt anzusehen.
+      </p>
+      <p>
+        Bitte melden Sie sich bei einem Administrator.
+      </p>
+    </div>
+  </div>
+
+  {% endif %}
+
+{% else %}
+
+<div class="news">
+
+  <h2 class="news_title">Zugriff verweigert</h2>
+  <p>
+    Bitte melden Sie sich an, um diesen Inhalt zu sehen.
+  </p>
+</div>
+
+{% endif %}
+{% endblock %}

+ 35 - 0
templates/stammbaum/login.html

@@ -0,0 +1,35 @@
+{% extends 'homepage/base.html' %}
+{% load static %}
+
+{% block head %}<link rel="stylesheet" href="{% static 'homepage/form.css' %}">{% endblock %}
+
+{% block title %}Login{% endblock %}
+{% block header %}Stammbaum{% endblock %}
+
+{% block status %}
+{% if user.is_authenticated %}
+<p style="text-align:center">
+  Hi {% firstof user.first_name user.username %}!<br />
+  <a href="{% url 'stammbaum-logout' %}" class="link">Logout</a>
+</p>
+{% else %}
+<p style="text-align:center">
+  You don't have an account?<br />
+  <a href="{% url 'stammbaum-register' %}" class="link">Register</a>
+</p>
+{% endif %}
+{% endblock %}
+
+{% block content %}
+
+
+<div class="news">
+  
+  <h2 class="news_title">Login</h2>
+  <form method="post" class="form_container">
+    {% csrf_token %}
+    {{ form.as_p }}
+    <input type="hidden" name="next" value="/stammbaum/" />
+    <button type="submit" class="submitbtn">Login</button>
+  </form></div>
+{% endblock %}

+ 42 - 0
templates/stammbaum/register.html

@@ -0,0 +1,42 @@
+{% extends 'homepage/base.html' %}
+{% load static %}
+
+{% block head %}<link rel="stylesheet" href="{% static 'homepage/form.css' %}">{% endblock %}
+
+{% block title %}Registrierung{% endblock %}
+{% block header %}Stammbaum{% endblock %}
+
+{% block status %}
+{% if user.is_authenticated %}
+<p style="text-align:center">
+  Hi {% firstof user.first_name user.username %}!<br />
+  <a href="{% url 'stammbaum-logout' %}" class="link">Logout</a>
+</p>
+{% else %}
+<p style="text-align:center">
+  You are not logged in<br />
+  <a href="{% url 'stammbaum-login' %}" class="link">Login</a>
+</p>
+{% endif %}
+{% endblock %}
+
+{% block content %}
+
+<div class="news">
+
+  <h2 class="news_title">Registrierung</h2>
+  <p>
+    Um Zugriff auf den Stammbaum zu erhalten, m&uuml;ssen Sie sich registrieren. Ihr Account wird anschließend von einem Admin freigeschaltet.
+  </p>
+  <hr/>
+  <form method="post" class="form_container">
+    {% csrf_token %}
+    {{ form.as_p }}
+    
+    <button type="submit" class="submitbtn">Registrieren</button>
+  </form>
+
+  <hr/>
+  <p>Already have an account? <a href="{% url 'stammbaum-login' %}">Sign in</a>.</p>
+</div>
+{% endblock %}

+ 31 - 0
templates/stammbaum/upload.html

@@ -0,0 +1,31 @@
+{% extends 'homepage/base.html' %}
+{% load static %}
+
+{% block title %}Stammbaum{% endblock %}
+{% block header %}Bild f&uuml;r {{ person.name }} hochladen{% endblock %}
+
+{% block status %}
+{% if user.is_authenticated %}
+<p style="text-align:center">
+  Hi {{ user.username }}!<br/>
+  <a href="{% url 'stammbaum-logout' %}" class="link">Logout</a>
+</p>
+{% else %}
+<p style="text-align:center">
+  You are not logged in<br/>
+  <a href="{% url 'stammbaum-login' %}" class="link">Login</a>
+</p>
+{% endif %}
+<br/>
+<a href="/stammbaum/">Zur&uuml;ck</a>
+{% endblock %}
+
+{% block content %}
+<div style="margin: auto">
+  <form method="post" enctype="multipart/form-data">
+    {% csrf_token %}
+    {{ form.as_p }}
+    <button type="submit">Upload</button>
+  </form>
+</div>
+{% endblock %}

+ 3 - 0
tests.py

@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.

+ 17 - 0
urls.py

@@ -0,0 +1,17 @@
+from django.urls import path, include
+
+from . import views
+
+urlpatterns = [
+  path('', views.index, name='index'),
+  path('login/', views.Login.as_view(), name='stammbaum-login'),
+  path('logout/', views.logout, name='stammbaum-logout'),
+  path('register/', views.register, name='stammbaum-register'),
+
+
+  path('group/<int:group_id>/', views.index, name='group'),
+  path('person/<int:person_id>/thumb/', views.image_preview, name='preview'),
+
+  path('person/<int:person_id>/image/', views.image, name='image'),
+  path('person/<int:pk>/upload/', views.UploadImageView.as_view(), name='upload'),
+]

+ 95 - 0
views.py

@@ -0,0 +1,95 @@
+import os
+from PIL import Image
+from django.shortcuts import redirect, render
+from django.template import loader
+
+from django.http import HttpResponse, FileResponse
+from django.urls import reverse_lazy
+from django.views.generic import UpdateView
+from django.views.decorators.cache import cache_control
+
+from django.contrib import auth
+from django.contrib.auth.decorators import permission_required
+from django.contrib.auth.mixins import PermissionRequiredMixin
+
+from .models import *
+from .forms import *
+from website.settings import BASE_DIR
+
+def index(request, group_id=None):
+  if request.user.has_perm('stammbaum.view'):
+    if group_id == None:
+      persons = Person.objects
+    else:
+      persons = Person.objects.filter(group_id=group_id)
+
+    partners = Person.partners.through.objects
+
+    context = {
+      'persons': list(persons.values_list()),
+      'pairs': list(partners.values_list()),
+      'groups': list(Group.objects.values())
+    }
+  else:
+    context = {}
+  template = loader.get_template('stammbaum/index.html')
+  return HttpResponse(template.render(context, request))
+
+class Login(auth.views.LoginView):
+  template_name = 'stammbaum/login.html'
+
+def register(request):
+  if request.method == 'POST':
+    form = SignUpForm(request.POST)
+    if form.is_valid():
+      user = form.save()
+      user.refresh_from_db()  
+      # load the profile instance created by the signal
+      user.save()
+      raw_password = form.cleaned_data.get('password1')
+
+      # login user after signing up
+      user = auth.authenticate(username=user.username, password=raw_password)
+      auth.login(request, user)
+
+      # redirect user to home page
+      return redirect('/stammbaum/')
+  else:
+      form = SignUpForm()
+  return render(request, 'stammbaum/register.html', context={"form": SignUpForm})
+
+def logout(request):
+  auth.logout(request)
+  template = loader.get_template('stammbaum/index.html')
+  return HttpResponse(template.render(None, request))
+
+@permission_required("stammbaum.view")
+@cache_control(max_age=3600)
+def image_preview(request, person_id):
+  person = Person.objects.filter(id=person_id).first()
+  if person and person.image:
+    img = Image.open(os.path.join(BASE_DIR, '../data', str(person.image)))
+  else:
+    img = Image.open(os.path.join(BASE_DIR, '../data', "default.jpeg"))
+
+  img.thumbnail((256, 256))
+  res = HttpResponse(content_type="image/jpeg")
+  img.save(res, format='JPEG')
+  return res
+
+@permission_required("stammbaum.view")
+@cache_control(max_age=3600)
+def image(request, person_id):
+  person = Person.objects.filter(id=person_id).first()
+  if person and person.image:
+    img = open(os.path.join(BASE_DIR, '../data', str(person.image)), 'rb')
+  else:
+    img = open(os.path.join(BASE_DIR, '../data', "default.jpeg"), 'rb')
+  return FileResponse(img)
+
+class UploadImageView(PermissionRequiredMixin, UpdateView):
+  model = Person
+  form_class = UploadImage
+  permission_required = ("stammbaum.view", "stammbaum.upload_image")
+  template_name = "stammbaum/upload.html"
+  success_url = "/stammbaum/"

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels