[Rt-commit] rt branch, 4.6/lifecycle-ui-cleanup, created. rt-4.4.1-269-g6ac265b75

Craig Kaiser craig at bestpractical.com
Fri Oct 18 17:32:18 EDT 2019


The branch, 4.6/lifecycle-ui-cleanup has been created
        at  6ac265b756942c18ea553ed8684904af74125842 (commit)

- Log -----------------------------------------------------------------
commit e04fde3376de1cec418d26dda6057df25cff9069
Author: Craig Kaiser <craig at bestpractical.com>
Date:   Fri Sep 27 09:34:09 2019 -0400

    Signal lifecycle cache needs update using system LifecycleCacheNeedsUpdate method
    
    Lifecycle can now be edited in the web UI, meaning that all RT threads need to
    know when the cache is in need of update.

diff --git a/lib/RT/Lifecycle.pm b/lib/RT/Lifecycle.pm
index 784b38453..073b7526a 100644
--- a/lib/RT/Lifecycle.pm
+++ b/lib/RT/Lifecycle.pm
@@ -57,6 +57,8 @@ our %LIFECYCLES;
 our %LIFECYCLES_CACHE;
 our %LIFECYCLES_TYPES;
 
+my $lifecycle_cache_time = 0;
+
 # cache structure:
 #    {
 #        lifecycle_x => {
@@ -109,7 +111,7 @@ sub new {
     my $proto = shift;
     my $self = bless {}, ref($proto) || $proto;
 
-    $self->FillCache unless keys %LIFECYCLES_CACHE;
+    RT->System->LifecycleCacheNeedsUpdate(1);
 
     return $self;
 }
@@ -144,13 +146,19 @@ sub Load {
         @_,
     );
 
+    my $needs_update = RT->System->LifecycleCacheNeedsUpdate;
+    if ($needs_update > $lifecycle_cache_time) {
+        $self->FillCache();
+        $lifecycle_cache_time = $needs_update;
+    }
+
     if (defined $args{Name} and exists $LIFECYCLES_CACHE{ $args{Name} }) {
         $self->{'name'} = $args{Name};
         $self->{'data'} = $LIFECYCLES_CACHE{ $args{Name} };
         $self->{'type'} = $args{Type};
 
         my $found_type = $self->{'data'}{'type'};
-        warn "Found type of $found_type ne $args{Type}" if $found_type ne $args{Type};
+        warn "Found type of $found_type ne ".$args{'Type'} if $found_type ne $args{Type};
     } elsif (not $args{Name} and exists $LIFECYCLES_TYPES{ $args{Type} }) {
         $self->{'data'} = $LIFECYCLES_TYPES{ $args{Type} };
         $self->{'type'} = $args{Type};
@@ -198,8 +206,6 @@ sub ListAll {
     my $self = shift;
     my $for = shift || 'ticket';
 
-    $self->FillCache unless keys %LIFECYCLES_CACHE;
-
     return sort grep {$LIFECYCLES_CACHE{$_}{type} eq $for}
         grep $_ ne '__maps__', keys %LIFECYCLES_CACHE;
 }
@@ -468,8 +474,6 @@ sub RightsDescription {
     my $self = shift;
     my $type = shift;
 
-    $self->FillCache unless keys %LIFECYCLES_CACHE;
-
     my %tmp;
     foreach my $lifecycle ( values %LIFECYCLES_CACHE ) {
         next unless exists $lifecycle->{'rights'};
@@ -519,8 +523,6 @@ sub Actions {
     my $from = shift || return ();
     $from = lc $from;
 
-    $self->FillCache unless keys %LIFECYCLES_CACHE;
-
     my @res = grep lc $_->{'from'} eq $from || ( $_->{'from'} eq '*' && lc $_->{'to'} ne $from ),
         @{ $self->{'data'}{'actions'} };
 
@@ -595,7 +597,6 @@ that require translation.
 
 sub ForLocalization {
     my $self = shift;
-    $self->FillCache unless keys %LIFECYCLES_CACHE;
 
     my @res = ();
 
@@ -789,6 +790,8 @@ sub FillCache {
             and $class->can("RegisterRights");
     }
 
+    $lifecycle_cache_time = time;
+
     return;
 }
 
@@ -839,7 +842,7 @@ sub _SaveLifecycles {
         return ($ok, $msg) if !$ok;
     }
 
-    RT::Lifecycle->FillCache;
+    RT->System->LifecycleCacheNeedsUpdate(1);
 
     return 1;
 }
diff --git a/lib/RT/System.pm b/lib/RT/System.pm
index 08d79dde4..68a7f817d 100644
--- a/lib/RT/System.pm
+++ b/lib/RT/System.pm
@@ -256,6 +256,27 @@ sub ConfigCacheNeedsUpdate {
     }
 }
 
+=head2 LifecycleCacheNeedsUpdate ( 1 )
+
+Attribute to decide when we need to flush the list of lifecycles
+and re-register any changes. This is needed for the lifecycle UI editor.
+
+If passed a true value, will update the attribute to be the current time.
+
+=cut
+
+sub LifecycleCacheNeedsUpdate {
+    my $self   = shift;
+    my $update = shift;
+
+    if ($update) {
+        return $self->SetAttribute(Name => 'LifecycleCacheNeedsUpdate', Content => time);
+    } else {
+        my $cache = $self->FirstAttribute('LifecycleCacheNeedsUpdate');
+        return (defined $cache ? $cache->Content : 0 );
+    }
+}
+
 =head2 AddUpgradeHistory package, data
 
 Adds an entry to the upgrade history database. The package can be either C<RT>

commit 5d1f486e1a74b4084cad92e4f705edf7e0e1a223
Author: Craig Kaiser <craig at bestpractical.com>
Date:   Fri Sep 27 09:44:40 2019 -0400

    Show 'select' and 'create' page menu options for lifecycle pages

diff --git a/lib/RT/Interface/Web/MenuBuilder.pm b/lib/RT/Interface/Web/MenuBuilder.pm
index e3baabedd..9556e3f1d 100644
--- a/lib/RT/Interface/Web/MenuBuilder.pm
+++ b/lib/RT/Interface/Web/MenuBuilder.pm
@@ -1156,6 +1156,13 @@ sub _BuildAdminMenu {
                 RT::Interface::Web::EscapeURI(\$Name_uri);
                 RT::Interface::Web::EscapeURI(\$Type_uri);
 
+                my $lifecycles = $page->child( lifecycles =>
+                    title => loc('Lifecycles'),
+                    path  => '/Admin/Lifecycles/',
+                );
+                $lifecycles->child( select => title => loc('Select'), path => '/Admin/Lifecycles/');
+                $lifecycles->child( create => title => loc('Create'), path => '/Admin/Lifecycles/Create.html');
+
                 $page->child( basics => title => loc('Modify'),  path => "/Admin/Lifecycles/Modify.html?Type=" . $Type_uri . "&Name=" . $Name_uri );
                 $page->child( mappings => title => loc('Mappings'),  path => "/Admin/Lifecycles/Mappings.html?Type=" . $Type_uri . "&Name=" . $Name_uri );
             }

commit 1b671fc786ec025331568337d781e14d6232abff
Author: Craig Kaiser <craig at bestpractical.com>
Date:   Fri Sep 27 10:24:59 2019 -0400

    Make sure default value for lifecycle type is 'ticket'

diff --git a/lib/RT/Lifecycle.pm b/lib/RT/Lifecycle.pm
index 073b7526a..bec9fb72d 100644
--- a/lib/RT/Lifecycle.pm
+++ b/lib/RT/Lifecycle.pm
@@ -145,6 +145,7 @@ sub Load {
         Name => '',
         @_,
     );
+    $args{'Type'} = $args{'Type'} // 'ticket';
 
     my $needs_update = RT->System->LifecycleCacheNeedsUpdate;
     if ($needs_update > $lifecycle_cache_time) {

commit 9f8cedda33ab54e77d5b14bf6ff856994d731a54
Author: Craig Kaiser <craig at bestpractical.com>
Date:   Fri Sep 27 10:47:04 2019 -0400

    Add message when no lifecycle mappings available

diff --git a/share/html/Admin/Lifecycles/Mappings.html b/share/html/Admin/Lifecycles/Mappings.html
index 891936d79..c587293cd 100644
--- a/share/html/Admin/Lifecycles/Mappings.html
+++ b/share/html/Admin/Lifecycles/Mappings.html
@@ -83,6 +83,10 @@
     </&>
 % }
 
+% unless ( scalar @lifecycles ) {
+    <p><&|/l&>Mapping only available when more than one lifecycle exists</&></p>
+% }
+
 <& /Elements/Submit, Name => 'Update', Label => loc('Save Changes') &>
 
 </form>

commit 28653f03e99cc1c79af3d4ce3ab4c53bfdd0b340
Author: Craig Kaiser <craig at bestpractical.com>
Date:   Fri Sep 27 11:55:34 2019 -0400

    Remove checkered background from lifecycle editor graph

diff --git a/share/static/css/base/lifecycleui-editor.css b/share/static/css/base/lifecycleui-editor.css
index 7e83c6852..bd168915d 100644
--- a/share/static/css/base/lifecycleui-editor.css
+++ b/share/static/css/base/lifecycleui-editor.css
@@ -4,12 +4,6 @@
     width: 809px;
     height: 500px;
 
-    /* checkerboard pattern */
-    background: #F9F9F9 url('data:image/svg+xml,\
-        <svg xmlns="http://www.w3.org/2000/svg" width="400" height="400"         fill-opacity=".05" >\
-            <rect x="200" width="200" height="200" />\
-            <rect y="200" width="200" height="200" />\
-    </svg>');
     background-size: 25px 25px;
 }
 

commit 2d03d605bc3a2c201e4940e7b560a64829a074c1
Author: Craig Kaiser <craig at bestpractical.com>
Date:   Fri Sep 27 12:10:39 2019 -0400

    Center lifecycle editor

diff --git a/share/static/css/base/lifecycleui-editor.css b/share/static/css/base/lifecycleui-editor.css
index bd168915d..732a82043 100644
--- a/share/static/css/base/lifecycleui-editor.css
+++ b/share/static/css/base/lifecycleui-editor.css
@@ -1,19 +1,19 @@
+.lifecycle-ui {
+    margin-left: 10%;
+}
+
 .lifecycle-ui.editing svg {
-    display: inline-block;
     float: left;
-    width: 809px;
+    width: 60%;
     height: 500px;
-
-    background-size: 25px 25px;
 }
 
 .lifecycle-ui.editing .overlay-buttons {
-    left: 700px;
+    left: 50%;
 }
 
 .lifecycle-ui .inspector {
     display: inline-block;
-    width: 250px;
     min-height: 500px;
     border: 1px solid black;
 }

commit 7a3040e906e258e0fd9c4922b4e3e1c7778cdd0c
Author: Craig Kaiser <craig at bestpractical.com>
Date:   Fri Oct 18 08:27:46 2019 -0400

    Get text input box on status node click
    
    Have a text input box appear that allows editing the current status
    name. Clicking away or hitting the enter key should apply these changes
    to the status name. Saving the updates saves the changes to the RT database
    lifecycle.

diff --git a/share/static/js/lifecycleui-editor.js b/share/static/js/lifecycleui-editor.js
index c1f551bd9..297d80c33 100644
--- a/share/static/js/lifecycleui-editor.js
+++ b/share/static/js/lifecycleui-editor.js
@@ -1,676 +1,245 @@
 jQuery(function () {
-    var Super = RT.LifecycleViewer;
 
-    function Editor (container) {
-        Super.call(this);
-        this.pointHandleRadius = 5;
-    };
-    Editor.prototype = Object.create(Super.prototype);
-
-    Editor.prototype._initializeTemplates = function (container) {
-        var self = this;
-
-        Handlebars.registerHelper('select', function(value, options) {
-            var node = jQuery('<select />').html( options.fn(this) );
-            node.find('[value="' + value + '"]').attr({'selected':'selected'});
-            return node.html();
-        });
-
-        Handlebars.registerHelper('canAddTransition', function(fromStatus, toStatus, lifecycle) {
-            if (fromStatus == toStatus) {
-                return false;
-            }
-            return !lifecycle.hasTransition(fromStatus, toStatus);
-        });
-
-        Handlebars.registerHelper('canSelectTransition', function(fromStatus, toStatus, lifecycle) {
-            return lifecycle.hasTransition(fromStatus, toStatus);
-        });
-
-        Handlebars.registerHelper('selectedRights', function(lifecycle) {
-            return lifecycle.selectedRights();
-        });
-
-        Handlebars.registerHelper('truncate', function(text) {
-            if (text.length > 15) {
-                text = text.substr(0, 15) + '…';
-            }
-            return text;
-        });
-
-        var templates = {};
-        self.container.find('script.lifecycle-inspector-template').each(function () {
-            var type = jQuery(this).data('type');
-            var template = jQuery(this).html();
-            var fn = Handlebars.compile(template);
-            templates[type] = fn;
-            Handlebars.registerPartial('lifecycleui_' + type, fn);
-        });
-        return templates;
-    };
+    RT.Editor = class LifecycleEditor extends RT.LifecycleViewer {
+        constructor(container, name, config, ticketStatus) {
+            super( container, name, config, ticketStatus );
 
-    Editor.prototype._refreshInspector = function (refreshContent) {
-        var self = this;
-        var lifecycle = self.lifecycle;
-        var inspector = self.inspector;
-        var node = self.inspectorNode;
-
-        var params = { lifecycle: lifecycle };
-
-        var header = inspector.find('.header');
-        header.html(self.templates.header(params));
-
-        var refreshedNode = header;
-        if (refreshContent) {
-            var type = node ? node._type : 'canvas';
-            params[type] = node;
-            inspector.find('.content').html(self.templates[type](params));
-            refreshedNode = inspector;
+            this.pointHandleRadius = 5;
         }
 
-        refreshedNode.find(".toplevel").addClass('sf-menu sf-js-enabled sf-shadow').supersubs().superfish({ speed: 'fast' });
-
-        refreshedNode.find(':checkbox[data-show-hide]').each(function () {
-            var field = jQuery(this);
-            var selector = field.data('show-hide');
-            var flip = field.data('show-hide-flip') ? true : false;
-
-            var toggle = function () {
-                if ((field.prop('checked') ? true : false) != flip) {
-                    jQuery(selector).show();
-                } else {
-                    jQuery(selector).hide();
-                }
-            }
-            field.change(function (e) { toggle() });
-            toggle();
-        });
-
-        refreshedNode.find('option[data-show-hide]').each(function () {
-            var option = jQuery(this);
-            var field = option.closest('select');
-            var selector = option.data('show-hide');
-            var flip = option.data('show-hide-flip') ? true : false;
-
-            var toggle = function () {
-                if ((field.val() == option.val()) != flip) {
-                    jQuery(selector).show();
-                } else {
-                    jQuery(selector).hide();
-                }
-            }
-            field.change(function (e) { toggle() });
-            toggle();
-        });
-
-        refreshedNode.find(".combobox input.combo-text").each(function () {
-            ComboBox_Load(this.id);
-        });
-    };
-
-    Editor.prototype.setInspectorContent = function (node) {
-        this.inspectorNode = node;
-        this._refreshInspector(true);
-    };
-
-    Editor.prototype.bindInspectorEvents = function () {
-        var self = this;
-        var lifecycle = self.lifecycle;
-        var inspector = self.inspector;
-
-        inspector.on('change', ':input', function () {
-            var node = jQuery(this);
-            var value;
+        initializeEditor(node, name, config, focusStatus) {
+            var self = this;
+            self.initializeViewer(node, name, config, focusStatus);
+            self.container.closest('form[name=ModifyLifecycle]').submit(function (e) {
+                var config = self.lifecycle.exportAsConfiguration();
+                var form = jQuery(this);
+                var field = jQuery('<input type="hidden" name="Config">');
+                field.val(JSON.stringify(config));
+                form.append(field);
+                return true;
+            });
+        }
 
-            if (node.is('.combo-list')) {
-                value = node.val();
-                node = node.closest('.combobox').find('.combo-text');
-            }
-            else if (node.is(':checkbox')) {
-                value = node[0].checked;
-            }
-            else {
-                value = node.val();
-            }
+        clickedStatus(d, p_el) {
+            self = this;
 
-            var field = node.attr('name');
+            var circle = d3.select(p_el).select('circle')._groups[0][0];
 
-            var action = node.closest('li.action');
-            if (action.length) {
-                var action = lifecycle.itemForKey(action.data('key'));
-                lifecycle.updateItem(action, field, value);
-            }
-            else if (inspector.find('.canvas').length) {
-                lifecycle.update(field, value);
-            }
-            else {
-                lifecycle.updateItem(self.inspectorNode, field, value);
-            }
+            let current_val = d.name;
+            d.name = '';
             self.renderDisplay();
-        });
 
-        inspector.on('click', 'button.change-color', function (e) {
-            e.preventDefault();
-            var inputContainer = jQuery(this).closest('.color-control');
-            var field = inputContainer.data('field');
-            var pickerContainer = jQuery('tr.color-widget[data-field="'+field+'"]');
-            var picker = pickerContainer.find('.color-picker');
-            jQuery(this).remove();
-
-            var skipUpdateCallback = 0;
-            var farb = jQuery.farbtastic(picker, function (newColor) {
-                if (skipUpdateCallback) {
-                    return;
-                }
-                inputContainer.find('.current-color').val(newColor);
-                lifecycle.updateItem(self.inspectorNode, field, newColor, true);
+            var frm = d3.select(p_el).append("foreignObject");
+
+            var circle_d3 = d3.select(circle);
+            var inp = frm
+                .attr("x", circle_d3.attr('cx') - circle_d3.attr('r') + 10)
+                .attr("y", circle_d3.attr('cy') - circle_d3.attr('r') / 2 + 4)
+                .attr("width", circle_d3.attr('r') * 2)
+                .attr("height", 20)
+                .append("xhtml:form")
+                .append("input")
+                .attr("value", function() {
+                    this.focus();
+
+                    return current_val;
+                })
+            .attr("style", "width: 294px; background: transparent; border: none;")
+            // make the form go away when you jump out (form looses focus) or hit ENTER:
+            .on("blur", function() {
+                d.name = inp.node().value;
+                RT.Lifecycle.updateStatusName( current_val, d.name );
+
+                // Note to self: frm.remove() will remove the entire <g> group! Remember the D3 selection logic!
+                d3.select("foreignObject").remove();
                 self.renderDisplay();
-            });
-            farb.setColor(self.inspectorNode[field]);
-
-            // see farbtastic's implementation
-            jQuery('*', picker).mousedown(function () {
-                self.lifecycle.beginChangingColor();
-            });
-
-            var input = jQuery('<input class="current-color" size=8 maxlength=7>');
-            inputContainer.find('.current-color').replaceWith(input);
-            input.on('input', function () {
-                var newColor = input.val();
-                if (newColor.match(/^#[a-fA-F0-9]{6}$/)) {
-                    skipUpdateCallback = 1;
-                    farb.setColor(newColor);
-                    skipUpdateCallback = 0;
-
-                    lifecycle.updateItem(self.inspectorNode, field, newColor);
+            })
+            .on("keypress", function() {
+                // IE fix
+                if (!d3.event)
+                    d3.event = window.event;
+
+                var e = d3.event;
+                if (e.keyCode == 13)
+                {
+                    if (typeof(e.cancelBubble) !== 'undefined') // IE
+                        e.cancelBubble = true;
+                    if (e.stopPropagation)
+                        e.stopPropagation();
+                    e.preventDefault();
+
+                    d.name = inp.node().value;
+
+                    // odd. Should work in Safari, but the debugger crashes on this instead.
+                    // Anyway, it SHOULD be here and it doesn't hurt otherwise.
+                    d3.select("foreignObject").remove();
                     self.renderDisplay();
                 }
-            });
-            input.val(self.inspectorNode[field]);
-        });
-
-        inspector.on('click', 'button.delete', function (e) {
-            e.preventDefault();
-
-            var action = jQuery(this).closest('li.action');
-            if (action.length) {
-                lifecycle.deleteActionForTransition(self.inspectorNode, action.data('key'));
-                action.slideUp(200, function () { jQuery(this).remove() });
-            }
-            else {
-                lifecycle.deleteItemForKey(self.inspectorNode._key);
-                self.defocus();
-            }
-        });
-
-        inspector.on('click', 'button.clone', function (e) {
-            e.preventDefault();
-            var p = self.viewportCenterPoint();
-            var clone = self.lifecycle.cloneItem(self.inspectorNode, p[0], p[1]);
-            self.focusItem(clone);
-        });
-
-        inspector.on('click', 'button.add-action', function (e) {
-            e.preventDefault();
-            var action = lifecycle.createActionForTransition(self.inspectorNode);
-
-            var params = {action:action, lifecycle:lifecycle};
-            var html = self.templates.action(params);
-            jQuery(html).appendTo(inspector.find('ul.actions'))
-                        .hide()
-                        .slideDown(200);
-        });
-
-        inspector.on('click', 'a.add-transition', function (e) {
-            e.preventDefault();
-            var button = jQuery(this);
-            var fromStatus = button.data('from');
-            var toStatus   = button.data('to');
-
-            lifecycle.addTransition(fromStatus, toStatus);
-
-            button.closest('li').addClass('hidden');
-
-            inspector.find('a.select-transition[data-from="'+fromStatus+'"][data-to="'+toStatus+'"]').closest('li').removeClass('hidden');
-
-            self.renderDisplay();
-        });
-
-        inspector.on('click', 'a.select-status', function (e) {
-            e.preventDefault();
-            var statusName = jQuery(this).data('name');
-            var d = self.lifecycle.statusObjectForName(statusName);
-            self.focusItem(d);
-        });
-
-        inspector.on('mouseenter', 'a.select-status', function (e) {
-            var statusName = jQuery(this).data('name');
-            var d = self.lifecycle.statusObjectForName(statusName);
-            self.hoverItem(d);
-        });
-
-        inspector.on('mouseenter', 'a.add-transition', function (e) {
-            var statusName = jQuery(this).data('to');
-            var d = self.lifecycle.statusObjectForName(statusName);
-            self.hoverItem(d);
-        });
-
-        inspector.on('click', 'a.select-transition', function (e) {
-            e.preventDefault();
-            var button = jQuery(this);
-            var fromStatus = button.data('from');
-            var toStatus   = button.data('to');
-
-            var d = self.lifecycle.hasTransition(fromStatus, toStatus);
-            self.focusItem(d);
-        });
-
-        inspector.on('mouseenter', 'a.select-transition', function (e) {
-            var button = jQuery(this);
-            var fromStatus = button.data('from');
-            var toStatus   = button.data('to');
-
-            var d = self.lifecycle.hasTransition(fromStatus, toStatus);
-            self.hoverItem(d);
-        });
-
-        inspector.on('click', 'a.select-decoration', function (e) {
-            e.preventDefault();
-            var key = jQuery(this).data('key');
-            var d = self.lifecycle.itemForKey(key);
-            self.focusItem(d);
-        });
-
-        inspector.on('mouseenter', 'a.select-decoration', function (e) {
-            var key = jQuery(this).data('key');
-            var d = self.lifecycle.itemForKey(key);
-            self.hoverItem(d);
-        });
-
-        inspector.on('mouseleave', 'a.select-status, a.add-transition, a.select-transition, a.select-decoration', function () {
-            self.hoverItem(null);
-        });
-
-        inspector.on('click', '.add-status', function (e) {
-            e.preventDefault();
-            self.addNewStatus();
-        });
-
-        inspector.on('click', '.add-text', function (e) {
-            e.preventDefault();
-            self.addNewTextDecoration();
-        });
-
-        inspector.on('click', '.add-polygon', function (e) {
-            e.preventDefault();
-            self.addNewPolygonDecoration(jQuery(this).data('type'));
-        });
-
-        inspector.on('click', '.add-circle', function (e) {
-            e.preventDefault();
-            self.addNewCircleDecoration();
-        });
-
-        inspector.on('click', '.add-line', function (e) {
-            e.preventDefault();
-            self.addNewLineDecoration();
-        });
-
-        inspector.on('click', 'button.undo', function (e) {
-            e.preventDefault();
-            var frame = self.lifecycle.undo();
-            var uiState = frame[1];
-
-            if (uiState.focusKey) {
-                var node = self.lifecycle.itemForKey(uiState.focusKey);
-                self.focusItem(node);
-            }
-            else {
-                self.defocus();
-            }
-        });
-
-        inspector.on('click', 'button.redo', function (e) {
-            e.preventDefault();
-            var frame = self.lifecycle.redo();
-            var uiState = frame[1];
-
-            if (uiState.focusKey) {
-                var node = self.lifecycle.itemForKey(uiState.focusKey);
-                self.focusItem(node);
-            }
-            else {
-                self.defocus();
-            }
-        });
-
-        inspector.on('focus', 'textarea[name=text]', function (e) {
-            if (jQuery(this).val() == jQuery(this).data('default')) {
-                jQuery(this).val("");
-            }
-        });
-    };
-
-    Editor.prototype.addPointHandles = function (d) {
-        var self = this;
-        var points = [];
-
-        if (d._type == 'circle') {
-            points.push({
-                _key: d._key + '-r',
-                x: this.xScaleZeroInvert(d.r + this.pointHandleRadius/2),
-                y: 0
+                d.name = inp.node().value;
+                RT.Lifecycle.updateStatusName( current_val, d.name );
             });
         }
-        else {
-            for (var i = 0; i < d.points.length; ++i) {
-                points.push({
-                    _key: d._key + '-' + i,
-                    i: i,
-                    x: d.points[i].x,
-                    y: d.points[i].y
-                });
-            }
+        clickedTransition(d) {
+            this.focusItem(d);
         }
-        self.pointHandles = points;
-    };
-
-    Editor.prototype.removePointHandles = function () {
-        if (!this.pointHandles) {
-            return;
+        clickedDecoration(d) {
+            this.focusItem(d);
         }
-
-        delete this.pointHandles;
-        this.renderDecorations();
-    };
-
-    Editor.prototype.didDragPointHandle = function (d, node) {
-        var x = this.xScaleZeroInvert(d3.event.x);
-        var y = this.yScaleZeroInvert(d3.event.y);
-
-        if (this.xScaleZero(x) == this.xScaleZero(d.x) && this.yScaleZero(y) == this.yScaleZero(d.y)) {
-            return;
-        }
-
-        if (!d._dragging) {
-            this.lifecycle.beginDragging();
-            d._dragging = true;
-        }
-
-        d.x = x;
-        d.y = y;
-
-        if (this.inspectorNode._type == 'circle') {
-            this.lifecycle.moveCircleRadiusPoint(this.inspectorNode, this.xScaleZero(x), this.yScaleZero(y));
+        viewportCenterPoint() {
+            var rect = this.svg.node().getBoundingClientRect();
+            var x = (rect.width / 2 - this._currentZoom.x) / this._currentZoom.k;
+            var y = (rect.height / 2 - this._currentZoom.y) / this._currentZoom.k;
+            return [this.xScaleInvert(x), this.yScaleInvert(y)];
         }
-        else {
-            this.lifecycle.movePolygonPoint(this.inspectorNode, d.i, x, y);
+        addNewStatus() {
+            var p = this.viewportCenterPoint();
+            var status = this.lifecycle.createStatus(p[0], p[1]);
+            this.focusItem(status);
         }
 
-        this.renderDisplay();
-    };
-
-    // add rects under text decorations for highlighting
-    Editor.prototype.renderTextDecorations = function (initial) {
-        Super.prototype.renderTextDecorations.call(this, initial);
-        var self = this;
 
-        self.renderTextDecorationBackgrounds(initial);
-    };
 
-    Editor.prototype.renderTextDecorationBackgrounds = function (initial) {
-        var self = this;
-        var rects = self.decorationContainer.selectAll("rect.text-background")
-                         .data(self.lifecycle.decorations.text, function (d) { return d._key });
 
-        rects.exit()
-            .classed("removing", true)
-            .transition().duration(200*self.animationFactor)
-              .remove();
 
-        var newRects = rects.enter().insert("rect", ":first-child")
-                     .attr("data-key", function (d) { return d._key })
-                     .classed("text-background", true)
-                     .on("click", function (d) {
-                         d3.event.stopPropagation();
-                         self.clickedDecoration(d);
-                     })
-                     .call(function (rects) { self.didEnterTextDecorations(rects) });
 
-        if (!initial) {
-            newRects.style("opacity", 0.15)
-                    .transition().duration(200*self.animationFactor)
-                        .style("opacity", 1)
-                        .on("end", function () { d3.select(this).style("opacity", undefined) });
-        }
 
-        newRects.merge(rects)
-                      .classed("focus", function (d) { return self.isFocused(d) })
-                      .each(function (d) {
-                          var rect = d3.select(this);
-                          var label = self.decorationContainer.select("text[data-key='"+d._key+"']");
-                          var bbox = label.node().getBoundingClientRect();
-                          var width = bbox.width / self._currentZoom.k;
-                          var height = bbox.height / self._currentZoom.k;
-                          var padding = 5 / self._currentZoom.k;
 
-                          rect.attr("x", self.xScale(d.x)-padding)
-                              .attr("y", self.yScale(d.y)-padding)
-                              .attr("width", width+padding*2)
-                              .attr("height", height+padding*2);
-                      });
-    };
 
-    Editor.prototype.renderPolygonDecorations = function (initial) {
-        Super.prototype.renderPolygonDecorations.call(this, initial);
 
-        var self = this;
-        var handles = self.transformContainer.selectAll("circle.point-handle")
-                           .data(self.pointHandles || [], function (d) { return d._key });
 
-        handles.exit()
-              .remove();
 
-        var newHandles = handles.enter().append("circle")
-                           .classed("point-handle", true)
-                           .attr("r", self.pointHandleRadius)
-                           .call(d3.drag()
-                               .subject(function (d) { return { x: self.xScaleZero(d.x), y : self.yScaleZero(d.y) } })
-                               .on("start", function (d) { self.didBeginDrag(d, this) })
-                               .on("drag", function (d) { self.didDragPointHandle(d) })
-                               .on("end", function (d) { self.didEndDrag(d, this) })
-                           );
 
-        if (!initial) {
-            newHandles.style("opacity", 0.15)
-                      .transition().duration(200*self.animationFactor)
-                          .style("opacity", 1)
-                          .on("end", function () { d3.select(this).style("opacity", undefined) });
-        }
-
-        newHandles.merge(handles)
-                     .attr("transform", function (d) {
-                         var x = self.xScale(self.inspectorNode.x);
-                         var y = self.yScale(self.inspectorNode.y);
-                         if (self.inspectorNode._type == 'line') {
-                             y += 20;
-                         }
-                         return "translate(" + x + ", " + y + ")";
-                     })
-                     .attr("cx", function (d) { return self.xScaleZero(d.x) })
-                     .attr("cy", function (d) { return self.yScaleZero(d.y) })
-    };
-
-    Editor.prototype.clickedStatus = function (d) {
-        this.focusItem(d);
-    };
-
-    Editor.prototype.clickedTransition = function (d) {
-        this.focusItem(d);
-    };
-
-    Editor.prototype.clickedDecoration = function (d) {
-        this.focusItem(d);
-    };
-
-    Editor.prototype.didBeginDrag = function (d, node) { };
-
-    Editor.prototype.didEndDrag = function (d, node) {
-        d._dragging = false;
-    };
-
-    Editor.prototype.didDragItem = function (d, node) {
-        if (this.inspectorNode && this.inspectorNode._key != d._key) {
-            return;
+        _refreshLifecycleUI(refreshContent) {
+            var self = this;
+            var lifecycle = self.lifecycle;
+            var inspector = self.inspector;
+            var node = self.inspectorNode;
+            var params = { lifecycle: lifecycle };
+            var header = inspector.find('.header');
+            var refreshedNode = header;
+            if (refreshContent) {
+                var type = node ? node._type : 'canvas';
+                params[type] = node;
+                inspector.find('.content').html(self.templates[type](params));
+                refreshedNode = inspector;
+            }
+            refreshedNode.find(".toplevel").addClass('sf-menu sf-js-enabled sf-shadow').supersubs().superfish({ speed: 'fast' });
+            refreshedNode.find(':checkbox[data-show-hide]').each(function () {
+                var field = jQuery(this);
+                var selector = field.data('show-hide');
+                var flip = field.data('show-hide-flip') ? true : false;
+                var toggle = function () {
+                    if ((field.prop('checked') ? true : false) != flip) {
+                        jQuery(selector).show();
+                    }
+                    else {
+                        jQuery(selector).hide();
+                    }
+                };
+                field.change(function (e) { toggle(); });
+                toggle();
+            });
+            refreshedNode.find('option[data-show-hide]').each(function () {
+                var option = jQuery(this);
+                var field = option.closest('select');
+                var selector = option.data('show-hide');
+                var flip = option.data('show-hide-flip') ? true : false;
+                var toggle = function () {
+                    if ((field.val() == option.val()) != flip) {
+                        jQuery(selector).show();
+                    }
+                    else {
+                        jQuery(selector).hide();
+                    }
+                };
+                field.change(function (e) { toggle(); });
+                toggle();
+            });
+            refreshedNode.find(".combobox input.combo-text").each(function () {
+                ComboBox_Load(this.id);
+            });
         }
 
-        var x = this.xScaleInvert(d3.event.x);
-        var y = this.yScaleInvert(d3.event.y);
-
-        if (this.xScale(x) == this.xScale(d.x) && this.yScale(y) == this.yScale(d.y)) {
-            return;
+        // add rects under text decorations for highlighting
+        // renderTextDecorations(initial) {
+        //     Super.prototype.renderTextDecorations.call(this, initial);
+        //     var self = this;
+        //     self.renderTextDecorationBackgrounds(initial);
+        // }
+        renderTextDecorationBackgrounds(initial) {
+            var self = this;
+            var rects = self.decorationContainer.selectAll("rect.text-background")
+                .data(self.lifecycle.decorations.text, function (d) { return d._key; });
+            rects.exit()
+                .classed("removing", true)
+                .transition().duration(200 * self.animationFactor)
+                .remove();
+            var newRects = rects.enter().insert("rect", ":first-child")
+                .attr("data-key", function (d) { return d._key; })
+                .classed("text-background", true)
+                .on("click", function (d) {
+                    d3.event.stopPropagation();
+                    self.clickedDecoration(d);
+                })
+                .call(function (rects) { self.didEnterTextDecorations(rects); });
+            if (!initial) {
+                newRects.style("opacity", 0.15)
+                    .transition().duration(200 * self.animationFactor)
+                    .style("opacity", 1)
+                    .on("end", function () { d3.select(this).style("opacity", undefined); });
+            }
+            newRects.merge(rects)
+                .classed("focus", function (d) { return self.isFocused(d); })
+                .each(function (d) {
+                    var rect = d3.select(this);
+                    var label = self.decorationContainer.select("text[data-key='" + d._key + "']");
+                    var bbox = label.node().getBoundingClientRect();
+                    var width = bbox.width / self._currentZoom.k;
+                    var height = bbox.height / self._currentZoom.k;
+                    var padding = 5 / self._currentZoom.k;
+                    rect.attr("x", self.xScale(d.x) - padding)
+                        .attr("y", self.yScale(d.y) - padding)
+                        .attr("width", width + padding * 2)
+                        .attr("height", height + padding * 2);
+                });
         }
 
-        if (!d._dragging) {
-            this.lifecycle.beginDragging();
-            d._dragging = true;
+        didBeginDrag(d, node) { }
+        didEndDrag(d, node) {
+            d._dragging = false;
         }
-
-        this.lifecycle.moveItem(d, x, y);
-        this.renderDisplay();
-    };
-
-    Editor.prototype._createDrag = function () {
-        var self = this;
-        return d3.drag()
-                 .subject(function (d) { return { x: self.xScale(d.x), y : self.yScale(d.y) } })
-                 .on("start", function (d) { self.didBeginDrag(d, this) })
-                 .on("drag", function (d) { self.didDragItem(d, this) })
-                 .on("end", function (d) { self.didEndDrag(d, this) })
-    };
-
-    Editor.prototype.didEnterStatusNodes = function (statuses) {
-        statuses.call(this._createDrag());
-    };
-
-    Editor.prototype.didEnterTextDecorations = function (labels) {
-        labels.call(this._createDrag());
-    };
-
-    Editor.prototype.didEnterPolygonDecorations = function (polygons) {
-        polygons.call(this._createDrag());
-    };
-
-    Editor.prototype.didEnterCircleDecorations = function (circles) {
-        circles.call(this._createDrag());
-    };
-
-    Editor.prototype.didEnterLineDecorations = function (lines) {
-        lines.call(this._createDrag());
-    };
-
-    Editor.prototype.viewportCenterPoint = function () {
-        var rect = this.svg.node().getBoundingClientRect();
-        var x = (rect.width / 2 - this._currentZoom.x)/this._currentZoom.k;
-        var y = (rect.height / 2 - this._currentZoom.y)/this._currentZoom.k;
-        return [this.xScaleInvert(x), this.yScaleInvert(y)];
-    };
-
-    Editor.prototype.addNewStatus = function () {
-        var p = this.viewportCenterPoint();
-        var status = this.lifecycle.createStatus(p[0], p[1]);
-        this.focusItem(status);
-    };
-
-    Editor.prototype.addNewTextDecoration = function () {
-        var p = this.viewportCenterPoint();
-        var text = this.lifecycle.createTextDecoration(p[0], p[1]);
-        this.focusItem(text);
-    };
-
-    Editor.prototype.addNewPolygonDecoration = function (type) {
-        var p = this.viewportCenterPoint();
-        var polygon = this.lifecycle.createPolygonDecoration(p[0], p[1], type);
-        this.focusItem(polygon);
-    };
-
-    Editor.prototype.addNewCircleDecoration = function () {
-        var p = this.viewportCenterPoint();
-        var circle = this.lifecycle.createCircleDecoration(p[0], p[1], this.statusCircleRadius);
-        this.focusItem(circle);
-    };
-
-    Editor.prototype.addNewLineDecoration = function () {
-        var p = this.viewportCenterPoint();
-        var line = this.lifecycle.createLineDecoration(p[0], p[1]);
-        this.focusItem(line);
-    };
-
-    Editor.prototype.initializeEditor = function (node, name, config, focusStatus) {
-        var self = this;
-        self.initializeViewer(node, name, config, focusStatus);
-
-        self.templates = self._initializeTemplates(self.container);
-        self.inspector = self.container.find('.inspector');
-
-        self.setInspectorContent(null);
-        self.bindInspectorEvents();
-
-        self.container.closest('form[name=ModifyLifecycle]').submit(function (e) {
-            var config = self.lifecycle.exportAsConfiguration();
-            var form = jQuery(this);
-            var field = jQuery('<input type="hidden" name="Config">');
-            field.val(JSON.stringify(config));
-            form.append(field);
-            return true;
-        });
-
-        self.svg.on('click', function () { self.defocus() });
-
-        self.lifecycle.undoFrameCallback = function (frame) {
-            var uiState = {};
-            if (self._focusItem) {
-                uiState.focusKey = self._focusItem._key;
+        didDragItem(d, node) {
+            if (this.inspectorNode && this.inspectorNode._key != d._key) {
+                return;
             }
-            frame.push(uiState);
-        };
-
-        self.lifecycle.undoStateChangedCallback = function () {
-            self._refreshInspector(false);
-        };
-        self.lifecycle.undoStateChangedCallback();
-
-        setTimeout(function () {
-            jQuery('.results').slideUp();
-        }, 10*1000);
-    };
-
-    Editor.prototype.defocus = function () {
-        Super.prototype.defocus.call(this);
-        this.setInspectorContent(null);
-        this.removePointHandles();
-        this.hoverItem(null);
-        this.renderDisplay();
-    };
-
-    Editor.prototype.focusItem = function (item) {
-        Super.prototype.focusItem.call(this, item);
-        this.setInspectorContent(item);
-
-        if (item._type == 'polygon' || item._type == 'line' || item._type == 'circle') {
-            this.addPointHandles(item);
+            var x = this.xScaleInvert(d3.event.x);
+            var y = this.yScaleInvert(d3.event.y);
+            if (this.xScale(x) == this.xScale(d.x) && this.yScale(y) == this.yScale(d.y)) {
+                return;
+            }
+            if (!d._dragging) {
+                this.lifecycle.beginDragging();
+                d._dragging = true;
+            }
+            this.lifecycle.moveItem(d, x, y);
+            this.renderDisplay();
         }
-
-        this.renderDisplay();
-    };
-
-    Editor.prototype.hoverItem = function (item) {
-        this.svg.selectAll(".hover").classed('hover', false);
-
-        if (item) {
-            this.svg.selectAll("*[data-key='"+item._key+"']").classed('hover', true);
+        _createDrag() {
+            var self = this;
+            return d3.drag()
+                .subject(function (d) { return { x: self.xScale(d.x), y: self.yScale(d.y) }; })
+                .on("start", function (d) { self.didBeginDrag(d, this); })
+                .on("drag", function (d) { self.didDragItem(d, this); })
+                .on("end", function (d) { self.didEndDrag(d, this); });
+        }
+        didEnterStatusNodes(statuses) {
+            statuses.call(this._createDrag());
+        }
+        didEnterTextDecorations(labels) {
+            labels.call(this._createDrag());
+        }
+        didEnterLineDecorations(lines) {
+            lines.call(this._createDrag());
         }
     };
-
-    RT.LifecycleEditor = Editor;
 });
diff --git a/share/static/js/lifecycleui-viewer.js b/share/static/js/lifecycleui-viewer.js
index 85a190650..fe7bf7dc1 100644
--- a/share/static/js/lifecycleui-viewer.js
+++ b/share/static/js/lifecycleui-viewer.js
@@ -1,584 +1,511 @@
 jQuery(function () {
-    function Viewer (container) {
-        this.width  = 809;
-        this.height = 500;
-        this.statusCircleRadius = 35;
-        this.statusCircleRadiusFudge = 4; // required to give room for the arrowhead
-        this.gridSize = 10;
-        this.padding = this.statusCircleRadius * 2;
-        this.animationFactor = 1; // bump this to 10 debug JS animations
-    };
-
-    Viewer.prototype.createScale = function (size, padding) {
-        return d3.scaleLinear()
-                 .domain([0, 10000])
-                 .range([padding, size - padding]);
-    };
-
-    Viewer.prototype.gridScale = function (v) { return Math.round(v/this.gridSize) * this.gridSize };
-    Viewer.prototype.xScale = function (x) { return this.gridScale(this._xScale(x)) };
-    Viewer.prototype.yScale = function (y) { return this.gridScale(this._yScale(y)) };
-    Viewer.prototype.xScaleZero = function (x) { return this.gridScale(this._xScaleZero(x)) };
-    Viewer.prototype.yScaleZero = function (y) { return this.gridScale(this._yScaleZero(y)) };
-    Viewer.prototype.xScaleInvert = function (x) { return Math.floor(this._xScale.invert(x)) };
-    Viewer.prototype.yScaleInvert = function (y) { return Math.floor(this._yScale.invert(y)) };
-    Viewer.prototype.xScaleZeroInvert = function (x) { return Math.floor(this._xScaleZero.invert(x)) };
-    Viewer.prototype.yScaleZeroInvert = function (y) { return Math.floor(this._yScaleZero.invert(y)) };
-
-    Viewer.prototype.addZoomBehavior = function () {
-        var self = this;
-        self._zoom = d3.zoom()
-                       .scaleExtent([.3, 2])
-                       .on("zoom", function () {
-                           if (self.zoomControl) {
-                               self.didZoom();
-                           }
-                       });
-
-        self.svg.call(self._zoom);
-    };
-
-    Viewer.prototype.didZoom = function () {
-        this._currentZoom = d3.event.transform;
-        this.transformContainer.attr("transform", d3.event.transform);
-    };
-
-    Viewer.prototype.zoomScale = function (scaleBy, animated) {
-        if (animated) {
-            this.svg.transition()
-                    .duration(350*this.animationFactor)
-                    .call(this._zoom.scaleBy, scaleBy);
+    class Viewer {
+        constructor(container) {
+            this.width = 809;
+            this.height = 500;
+            this.statusCircleRadius = 35;
+            this.statusCircleRadiusFudge = 4; // required to give room for the arrowhead
+            this.gridSize = 10;
+            this.padding = this.statusCircleRadius * 2;
+            this.animationFactor = 1; // bump this to 10 debug JS animations
         }
-        else {
-            this.svg.call(this._zoom.scaleBy, scaleBy);
+        createScale(size, padding) {
+            return d3.scaleLinear()
+                .domain([0, 10000])
+                .range([padding, size - padding]);
         }
-    }
-
-    Viewer.prototype._setZoom = function (zoom, animated) {
-        if (animated) {
-            this.svg.transition()
-                    .duration(750*this.animationFactor)
+        gridScale(v) { return Math.round(v / this.gridSize) * this.gridSize; }
+        xScale(x) { return this.gridScale(this._xScale(x)); }
+        yScale(y) { return this.gridScale(this._yScale(y)); }
+        xScaleZero(x) { return this.gridScale(this._xScaleZero(x)); }
+        yScaleZero(y) { return this.gridScale(this._yScaleZero(y)); }
+        xScaleInvert(x) { return Math.floor(this._xScale.invert(x)); }
+        yScaleInvert(y) { return Math.floor(this._yScale.invert(y)); }
+        xScaleZeroInvert(x) { return Math.floor(this._xScaleZero.invert(x)); }
+        yScaleZeroInvert(y) { return Math.floor(this._yScaleZero.invert(y)); }
+        addZoomBehavior() {
+            var self = this;
+            self._zoom = d3.zoom()
+                .scaleExtent([.3, 2])
+                .on("zoom", function () {
+                    if (self.zoomControl) {
+                        self.didZoom();
+                    }
+                });
+            self.svg.call(self._zoom);
+        }
+        didZoom() {
+            this._currentZoom = d3.event.transform;
+            this.transformContainer.attr("transform", d3.event.transform);
+        }
+        zoomScale(scaleBy, animated) {
+            if (animated) {
+                this.svg.transition()
+                    .duration(350 * this.animationFactor)
+                    .call(this._zoom.scaleBy, scaleBy);
+            }
+            else {
+                this.svg.call(this._zoom.scaleBy, scaleBy);
+            }
+        }
+        _setZoom(zoom, animated) {
+            if (animated) {
+                this.svg.transition()
+                    .duration(750 * this.animationFactor)
                     .call(this._zoom.transform, zoom);
+            }
+            else {
+                this.svg.call(this._zoom.transform, zoom);
+            }
         }
-        else {
-            this.svg.call(this._zoom.transform, zoom);
+        resetZoom(animated) {
+            this._setZoom(this._zoomIdentity, animated);
         }
-    };
-
-    Viewer.prototype.resetZoom = function (animated) {
-        this._setZoom(this._zoomIdentity, animated);
-    };
-
-    Viewer.prototype.zoomToFit = function (animated) {
-        var bounds = this.transformContainer.node().getBBox();
-        var parent = this.transformContainer.node().parentElement;
-        var fullWidth = parent.clientWidth || parent.parentNode.clientWidth,
-            fullHeight = parent.clientHeight || parent.parentNode.clientHeight;
-        var width = bounds.width,
-            height = bounds.height;
-        var midX = bounds.x + width / 2,
-            midY = bounds.y + height / 2;
-        var scale = .9 / Math.max(width / fullWidth, height / fullHeight);
-        var tx = fullWidth / 2 - scale * midX;
-        var ty = fullHeight / 2 - scale * midY;
-
-        this._setZoom(d3.zoomIdentity.translate(tx, ty).scale(scale), animated);
-    };
-
-    Viewer.prototype.didEnterStatusNodes = function (statuses) { };
-    Viewer.prototype.didEnterTransitions = function (paths) { };
-    Viewer.prototype.didEnterTextDecorations = function (labels) { };
-    Viewer.prototype.didEnterPolygonDecorations = function (polygons) { };
-    Viewer.prototype.didEnterCircleDecorations = function (circles) { };
-    Viewer.prototype.didEnterLineDecorations = function (lines) { };
-
-    Viewer.prototype.renderStatusNodes = function (initial) {
-        var self = this;
-        var statuses = self.statusContainer.selectAll("g")
-                                           .data(self.lifecycle.statusObjects(), function (d) { return d._key });
-
-        var exitStatuses = statuses.exit()
-                                   .classed("removing", true)
-                                   .transition().duration(200*self.animationFactor)
-                                     .remove();
-
-        exitStatuses.select('circle')
-                    .attr("r", self.statusCircleRadius * .8);
-
-        var newStatuses = statuses.enter().append("g")
-                            .attr("data-key", function (d) { return d._key })
-                            .on("click", function (d) {
-                                d3.event.stopPropagation();
-                                self.clickedStatus(d);
-                            })
-                            .call(function (statuses) { self.didEnterStatusNodes(statuses) });
-
-        newStatuses.append("circle")
-                   .attr("r", initial ? self.statusCircleRadius : self.statusCircleRadius * .8)
-
-        newStatuses.append("text");
-
-        if (!initial) {
-            newStatuses.transition().duration(200*self.animationFactor)
-                         .select("circle")
-                         .attr("r", self.statusCircleRadius)
+        zoomToFit(animated) {
+            var bounds = this.transformContainer.node().getBBox();
+            var parent = this.transformContainer.node().parentElement;
+            var fullWidth = parent.clientWidth || parent.parentNode.clientWidth, fullHeight = parent.clientHeight || parent.parentNode.clientHeight;
+            var width = bounds.width, height = bounds.height;
+            var midX = bounds.x + width / 2, midY = bounds.y + height / 2;
+            var scale = .9 / Math.max(width / fullWidth, height / fullHeight);
+            var tx = fullWidth / 2 - scale * midX;
+            var ty = fullHeight / 2 - scale * midY;
+            this._setZoom(d3.zoomIdentity.translate(tx, ty).scale(scale), animated);
         }
-
-        var allStatuses = newStatuses.merge(statuses)
-                        .classed("focus", function (d) { return self.isFocused(d) })
-                        .classed("focus-from", function (d) { return self.isFocusedTransition(d, true) })
-                        .classed("focus-to", function (d) { return self.isFocusedTransition(d, false) });
-
-        allStatuses.select("circle")
-                     .attr("cx", function (d) { return self.xScale(d.x) })
-                     .attr("cy", function (d) { return self.yScale(d.y) })
-                     .attr("fill", function (d) { return d.color });
-
-        allStatuses.select("text")
-                      .attr("x", function (d) { return self.xScale(d.x) })
-                      .attr("y", function (d) { return self.yScale(d.y) })
-                      .attr("fill", function (d) { return d3.hsl(d.color).l > 0.35 ? '#000' : '#fff' })
-                      .text(function (d) { return d.name }).each(function () { self.truncateLabel(this) })
-    };
-
-    Viewer.prototype.clickedStatus = function (d) { };
-    Viewer.prototype.clickedTransition = function (d) { };
-    Viewer.prototype.clickedDecoration = function (d) { };
-
-    Viewer.prototype.truncateLabel = function (element) {
-        var node = d3.select(element),
-            textLength = node.node().getComputedTextLength(),
-            text = node.text();
-        while (textLength > this.statusCircleRadius*1.8 && text.length > 0) {
-            text = text.slice(0, -1);
-            node.text(text + '…');
-            textLength = node.node().getComputedTextLength();
+        didEnterStatusNodes(statuses) { }
+        didEnterTransitions(paths) { }
+        didEnterTextDecorations(labels) { }
+        didEnterPolygonDecorations(polygons) { }
+        didEnterCircleDecorations(circles) { }
+        didEnterLineDecorations(lines) { }
+        renderStatusNodes(initial) {
+            var self = this;
+            var statuses = self.statusContainer.selectAll("g")
+                .data(self.lifecycle.statusObjects(), function (d) { return d._key; });
+            var exitStatuses = statuses.exit()
+                .classed("removing", true)
+                .transition().duration(200 * self.animationFactor)
+                .remove();
+            exitStatuses.select('circle')
+                .attr("r", self.statusCircleRadius * .8);
+            var newStatuses = statuses.enter().append("g")
+                .attr("data-key", function (d) { return d._key; })
+                .on("click", function (d) {
+                    d3.event.stopPropagation();
+                    self.clickedStatus(d, this);
+                })
+                .call(function (statuses) { self.didEnterStatusNodes(statuses); });
+            newStatuses.append("circle")
+                .attr("r", initial ? self.statusCircleRadius : self.statusCircleRadius * .8);
+            newStatuses.append("text");
+            if (!initial) {
+                newStatuses.transition().duration(200 * self.animationFactor)
+                    .select("circle")
+                    .attr("r", self.statusCircleRadius);
+            }
+            var allStatuses = newStatuses.merge(statuses)
+                .classed("focus", function (d) { return self.isFocused(d); })
+                .classed("focus-from", function (d) { return self.isFocusedTransition(d, true); })
+                .classed("focus-to", function (d) { return self.isFocusedTransition(d, false); });
+            allStatuses.select("circle")
+                .attr("cx", function (d) { return self.xScale(d.x); })
+                .attr("cy", function (d) { return self.yScale(d.y); })
+                .attr("fill", function (d) { return d.color; });
+            allStatuses.select("text")
+                .attr("x", function (d) { return self.xScale(d.x); })
+                .attr("y", function (d) { return self.yScale(d.y); })
+                .attr("fill", function (d) { return d3.hsl(d.color).l > 0.35 ? '#000' : '#fff'; })
+                .text(function (d) { return d.name; }).each(function () { self.truncateLabel(this); });
         }
-    };
-
-    Viewer.prototype.transitionArc = function (d) {
-        // c* variables are circle centers
-        // a* variables are for the arc path which is from circle edge to circle edge
-        var from = this.lifecycle.statusObjectForName(d.from),
-              to = this.lifecycle.statusObjectForName(d.to),
-             cx0 = this.xScale(from.x),
-             cx1 = this.xScale(to.x),
-             cy0 = this.yScale(from.y),
-             cy1 = this.yScale(to.y),
-             cdx = cx1 - cx0,
-             cdy = cy1 - cy0;
-
-        // the circles on top of each other would calculate atan2(0,0) which is
-        // undefined and a little nonsensical
-        if (cdx == 0 && cdy == 0) {
-            return null;
+        clickedStatus(d) { }
+        clickedTransition(d) { }
+        clickedDecoration(d) { }
+        truncateLabel(element) {
+            var node = d3.select(element), textLength = node.node().getComputedTextLength(), text = node.text();
+            while (textLength > this.statusCircleRadius * 1.8 && text.length > 0) {
+                text = text.slice(0, -1);
+                node.text(text + '…');
+                textLength = node.node().getComputedTextLength();
+            }
         }
-
-        var theta = Math.atan2(cdy, cdx),
-                r = this.statusCircleRadius,
-              ax0 = cx0 + r * Math.cos(theta),
-              ay0 = cy0 + r * Math.sin(theta),
-              ax1 = cx1 - (r + this.statusCircleRadiusFudge) * Math.cos(theta),
-              ay1 = cy1 - (r + this.statusCircleRadiusFudge) * Math.sin(theta),
-               dr = Math.abs((ax1-ax0)*4) + Math.abs((ay1-ay0)*4);
-
-        return "M" + ax0 + "," + ay0 + " A" + dr + "," + dr + " 0 0,1 " + ax1 + "," + ay1;
-    };
-
-    Viewer.prototype.renderTransitions = function (initial) {
-        var self = this;
-        var paths = self.transitionContainer.selectAll("path")
-                        .data(self.lifecycle.transitions, function (d) { return d._key });
-
-        paths.exit().classed("removing", true)
-                    .each(function (d) {
-                        var length = this.getTotalLength();
-                        var path = d3.select(this);
-                        path.attr("stroke-dasharray", length + " " + length)
-                            .attr("stroke-dashoffset", 0)
-                            .style("marker-end", "none")
-                            .transition().duration(200*self.animationFactor).ease(d3.easeLinear)
-                              .attr("stroke-dashoffset", length)
-                              .remove();
-                    });
-
-        var newPaths = paths.enter().append("path")
-                         .attr("data-key", function (d) { return d._key })
-                         .on("click", function (d) {
-                             d3.event.stopPropagation();
-                             self.clickedTransition(d);
-                         })
-                         .call(function (paths) { self.didEnterTransitions(paths) });
-
-        newPaths.merge(paths)
-                      .attr("d", function (d) { return self.transitionArc(d) })
-                      .classed("dashed", function (d) { return d.style == 'dashed' })
-                      .classed("dotted", function (d) { return d.style == 'dotted' })
-                      .classed("focus", function (d) { return self.isFocused(d) })
-                      .classed("focus-from", function (d) { return self.isFocusedTransition(d, true) })
-                      .classed("focus-to", function (d) { return self.isFocusedTransition(d, false) });
-
-        if (!initial) {
-            newPaths.each(function (d) {
-                        var length = this.getTotalLength();
-                        var path = d3.select(this);
-                        path.attr("stroke-dasharray", length + " " + length)
-                            .attr("stroke-dashoffset", length)
-                            .style("marker-end", "none")
-                            .transition().duration(200*self.animationFactor).ease(d3.easeLinear)
-                              .attr("stroke-dashoffset", 0)
-                              .on("end", function () {
-                                d3.select(this)
-                                  .attr("stroke-dasharray", undefined)
-                                  .attr("stroke-offset", undefined)
-                                  .style("marker-end", undefined)
-                              })
-                    });
+        transitionArc(d) {
+            // c* variables are circle centers
+            // a* variables are for the arc path which is from circle edge to circle edge
+            var from = this.lifecycle.statusObjectForName(d.from), to = this.lifecycle.statusObjectForName(d.to), cx0 = this.xScale(from.x), cx1 = this.xScale(to.x), cy0 = this.yScale(from.y), cy1 = this.yScale(to.y), cdx = cx1 - cx0, cdy = cy1 - cy0;
+            // the circles on top of each other would calculate atan2(0,0) which is
+            // undefined and a little nonsensical
+            if (cdx == 0 && cdy == 0) {
+                return null;
+            }
+            var theta = Math.atan2(cdy, cdx), r = this.statusCircleRadius, ax0 = cx0 + r * Math.cos(theta), ay0 = cy0 + r * Math.sin(theta), ax1 = cx1 - (r + this.statusCircleRadiusFudge) * Math.cos(theta), ay1 = cy1 - (r + this.statusCircleRadiusFudge) * Math.sin(theta), dr = Math.abs((ax1 - ax0) * 4) + Math.abs((ay1 - ay0) * 4);
+            return "M" + ax0 + "," + ay0 + " A" + dr + "," + dr + " 0 0,1 " + ax1 + "," + ay1;
         }
-
-    };
-
-    Viewer.prototype._wrapTextDecoration = function (node, text) {
-        if (node.attr('data-text') == text) {
-            return;
+        renderTransitions(initial) {
+            var self = this;
+            var paths = self.transitionContainer.selectAll("path")
+                .data(self.lifecycle.transitions, function (d) { return d._key; });
+            paths.exit().classed("removing", true)
+                .each(function (d) {
+                    var length = this.getTotalLength();
+                    var path = d3.select(this);
+                    path.attr("stroke-dasharray", length + " " + length)
+                        .attr("stroke-dashoffset", 0)
+                        .style("marker-end", "none")
+                        .transition().duration(200 * self.animationFactor).ease(d3.easeLinear)
+                        .attr("stroke-dashoffset", length)
+                        .remove();
+                });
+            var newPaths = paths.enter().append("path")
+                .attr("data-key", function (d) { return d._key; })
+                .on("click", function (d) {
+                    d3.event.stopPropagation();
+                    self.clickedTransition(d);
+                })
+                .call(function (paths) { self.didEnterTransitions(paths); });
+            newPaths.merge(paths)
+                .attr("d", function (d) { return self.transitionArc(d); })
+                .classed("dashed", function (d) { return d.style == 'dashed'; })
+                .classed("dotted", function (d) { return d.style == 'dotted'; })
+                .classed("focus", function (d) { return self.isFocused(d); })
+                .classed("focus-from", function (d) { return self.isFocusedTransition(d, true); })
+                .classed("focus-to", function (d) { return self.isFocusedTransition(d, false); });
+            if (!initial) {
+                newPaths.each(function (d) {
+                    var length = this.getTotalLength();
+                    var path = d3.select(this);
+                    path.attr("stroke-dasharray", length + " " + length)
+                        .attr("stroke-dashoffset", length)
+                        .style("marker-end", "none")
+                        .transition().duration(200 * self.animationFactor).ease(d3.easeLinear)
+                        .attr("stroke-dashoffset", 0)
+                        .on("end", function () {
+                            d3.select(this)
+                                .attr("stroke-dasharray", undefined)
+                                .attr("stroke-offset", undefined)
+                                .style("marker-end", undefined);
+                        });
+                });
+            }
         }
-
-        var lines = text.split(/\n/),
-            lineHeight = 1.1;
-
-        if (node.attr('data-text')) {
-            node.selectAll("*").remove();
+        _wrapTextDecoration(node, text) {
+            if (node.attr('data-text') == text) {
+                return;
+            }
+            var lines = text.split(/\n/), lineHeight = 1.1;
+            if (node.attr('data-text')) {
+                node.selectAll("*").remove();
+            }
+            node.attr('data-text', text);
+            for (var i = 0; i < lines.length; ++i) {
+                node.append("tspan").attr("dy", (i + 1) * lineHeight + "em").text(lines[i]);
+            }
         }
-        node.attr('data-text', text);
-
-        for (var i = 0; i < lines.length; ++i) {
-            node.append("tspan").attr("dy", (i+1) * lineHeight + "em").text(lines[i]);
+        renderTextDecorations(initial) {
+            var self = this;
+            var labels = self.decorationContainer.selectAll("text")
+                .data(self.lifecycle.decorations.text, function (d) { return d._key; });
+            labels.exit()
+                .classed("removing", true)
+                .transition().duration(200 * self.animationFactor)
+                .remove();
+            var newLabels = labels.enter().append("text")
+                .attr("data-key", function (d) { return d._key; })
+                .on("click", function (d) {
+                    d3.event.stopPropagation();
+                    self.clickedDecoration(d);
+                })
+                .call(function (labels) { self.didEnterTextDecorations(labels); });
+            if (!initial) {
+                newLabels.style("opacity", 0.15)
+                    .transition().duration(200 * self.animationFactor)
+                    .style("opacity", 1)
+                    .on("end", function () { d3.select(this).style("opacity", undefined); });
+            }
+            newLabels.merge(labels)
+                .attr("x", function (d) { return self.xScale(d.x); })
+                .attr("y", function (d) { return self.yScale(d.y); })
+                .classed("bold", function (d) { return d.bold; })
+                .classed("italic", function (d) { return d.italic; })
+                .classed("focus", function (d) { return self.isFocused(d); })
+                .each(function (d) { self._wrapTextDecoration(d3.select(this), d.text); })
+                .selectAll("tspan")
+                .attr("x", function (d) { return self.xScale(d.x); })
+                .attr("y", function (d) { return self.yScale(d.y); });
         }
-    };
-
-    Viewer.prototype.renderTextDecorations = function (initial) {
-        var self = this;
-        var labels = self.decorationContainer.selectAll("text")
-                         .data(self.lifecycle.decorations.text, function (d) { return d._key });
-
-        labels.exit()
-            .classed("removing", true)
-            .transition().duration(200*self.animationFactor)
-              .remove();
-
-        var newLabels = labels.enter().append("text")
-                          .attr("data-key", function (d) { return d._key })
-                          .on("click", function (d) {
-                              d3.event.stopPropagation();
-                              self.clickedDecoration(d);
-                          })
-                          .call(function (labels) { self.didEnterTextDecorations(labels) });
-
-        if (!initial) {
-            newLabels.style("opacity", 0.15)
-                     .transition().duration(200*self.animationFactor)
-                         .style("opacity", 1)
-                         .on("end", function () { d3.select(this).style("opacity", undefined) });
+        renderPolygonDecorations(initial) {
+            var self = this;
+            var polygons = self.decorationContainer.selectAll("polygon")
+                .data(self.lifecycle.decorations.polygon, function (d) { return d._key; });
+            polygons.exit()
+                .classed("removing", true)
+                .transition().duration(200 * self.animationFactor)
+                .remove();
+            var newPolygons = polygons.enter().append("polygon")
+                .attr("data-key", function (d) { return d._key; })
+                .on("click", function (d) {
+                    d3.event.stopPropagation();
+                    self.clickedDecoration(d);
+                })
+                .call(function (polygons) { self.didEnterPolygonDecorations(polygons); });
+            if (!initial) {
+                newPolygons.style("opacity", 0.15)
+                    .transition().duration(200 * self.animationFactor)
+                    .style("opacity", 1)
+                    .on("end", function () { d3.select(this).style("opacity", undefined); });
+            }
+            newPolygons.merge(polygons)
+                .attr("stroke", function (d) { return d.renderStroke ? d.stroke : 'none'; })
+                .classed("dashed", function (d) { return d.strokeStyle == 'dashed'; })
+                .classed("dotted", function (d) { return d.strokeStyle == 'dotted'; })
+                .attr("fill", function (d) { return d.renderFill ? d.fill : 'none'; })
+                .attr("transform", function (d) { return "translate(" + self.xScale(d.x) + ", " + self.yScale(d.y) + ")"; })
+                .attr("points", function (d) {
+                    return jQuery.map(d.points, function (p) {
+                        return [self.xScaleZero(p.x), self.yScaleZero(p.y)].join(",");
+                    }).join(" ");
+                })
+                .classed("focus", function (d) { return self.isFocused(d); });
         }
-
-        newLabels.merge(labels)
-                      .attr("x", function (d) { return self.xScale(d.x) })
-                      .attr("y", function (d) { return self.yScale(d.y) })
-                      .classed("bold", function (d) { return d.bold })
-                      .classed("italic", function (d) { return d.italic })
-                      .classed("focus", function (d) { return self.isFocused(d) })
-                      .each(function (d) { self._wrapTextDecoration(d3.select(this), d.text) })
-              .selectAll("tspan")
-                      .attr("x", function (d) { return self.xScale(d.x) })
-                      .attr("y", function (d) { return self.yScale(d.y) })
-    };
-
-    Viewer.prototype.renderPolygonDecorations = function (initial) {
-        var self = this;
-        var polygons = self.decorationContainer.selectAll("polygon")
-                           .data(self.lifecycle.decorations.polygon, function (d) { return d._key });
-
-        polygons.exit()
-            .classed("removing", true)
-            .transition().duration(200*self.animationFactor)
-              .remove();
-
-        var newPolygons = polygons.enter().append("polygon")
-                            .attr("data-key", function (d) { return d._key })
-                            .on("click", function (d) {
-                                d3.event.stopPropagation();
-                                self.clickedDecoration(d);
-                            })
-                            .call(function (polygons) { self.didEnterPolygonDecorations(polygons) });
-
-        if (!initial) {
-            newPolygons.style("opacity", 0.15)
-                       .transition().duration(200*self.animationFactor)
-                           .style("opacity", 1)
-                           .on("end", function () { d3.select(this).style("opacity", undefined) });
+        renderCircleDecorations(initial) {
+            var self = this;
+            var circles = self.decorationContainer.selectAll("circle.decoration")
+                .data(self.lifecycle.decorations.circle, function (d) { return d._key; });
+            circles.exit()
+                .classed("removing", true)
+                .transition().duration(200 * self.animationFactor)
+                .remove();
+            var newCircles = circles.enter().append("circle")
+                .classed("decoration", true)
+                .attr("data-key", function (d) { return d._key; })
+                .on("click", function (d) {
+                    d3.event.stopPropagation();
+                    self.clickedDecoration(d);
+                })
+                .call(function (circles) { self.didEnterCircleDecorations(circles); });
+            if (!initial) {
+                newCircles.style("opacity", 0.15)
+                    .transition().duration(200 * self.animationFactor)
+                    .style("opacity", 1)
+                    .on("end", function () { d3.select(this).style("opacity", undefined); });
+            }
+            newCircles.merge(circles)
+                .attr("stroke", function (d) { return d.renderStroke ? d.stroke : 'none'; })
+                .classed("dashed", function (d) { return d.strokeStyle == 'dashed'; })
+                .classed("dotted", function (d) { return d.strokeStyle == 'dotted'; })
+                .attr("fill", function (d) { return d.renderFill ? d.fill : 'none'; })
+                .attr("cx", function (d) { return self.xScale(d.x); })
+                .attr("cy", function (d) { return self.yScale(d.y); })
+                .attr("r", function (d) { return d.r; })
+                .classed("focus", function (d) { return self.isFocused(d); });
         }
-
-        newPolygons.merge(polygons)
-                     .attr("stroke", function (d) { return d.renderStroke ? d.stroke : 'none' })
-                     .classed("dashed", function (d) { return d.strokeStyle == 'dashed' })
-                     .classed("dotted", function (d) { return d.strokeStyle == 'dotted' })
-                     .attr("fill", function (d) { return d.renderFill ? d.fill : 'none' })
-                     .attr("transform", function (d) { return "translate(" + self.xScale(d.x) + ", " + self.yScale(d.y) + ")" })
-                     .attr("points", function (d) {
-                         return jQuery.map(d.points, function(p) {
-                             return [self.xScaleZero(p.x),self.yScaleZero(p.y)].join(",");
-                         }).join(" ");
-                     })
-                    .classed("focus", function (d) { return self.isFocused(d) })
-    };
-
-    Viewer.prototype.renderCircleDecorations = function (initial) {
-        var self = this;
-        var circles = self.decorationContainer.selectAll("circle.decoration")
-                           .data(self.lifecycle.decorations.circle, function (d) { return d._key });
-
-        circles.exit()
-            .classed("removing", true)
-            .transition().duration(200*self.animationFactor)
-              .remove();
-
-        var newCircles = circles.enter().append("circle")
-                           .classed("decoration", true)
-                           .attr("data-key", function (d) { return d._key })
-                           .on("click", function (d) {
-                               d3.event.stopPropagation();
-                               self.clickedDecoration(d);
-                           })
-                           .call(function (circles) { self.didEnterCircleDecorations(circles) });
-
-        if (!initial) {
-            newCircles.style("opacity", 0.15)
-                      .transition().duration(200*self.animationFactor)
-                          .style("opacity", 1)
-                          .on("end", function () { d3.select(this).style("opacity", undefined) });
+        renderLineDecorations(initial) {
+            var self = this;
+            var lines = self.decorationContainer.selectAll("line")
+                .data(self.lifecycle.decorations.line, function (d) { return d._key; });
+            lines.exit()
+                .classed("removing", true)
+                .transition().duration(200 * self.animationFactor)
+                .remove();
+            var newLines = lines.enter().append("line")
+                .attr("data-key", function (d) { return d._key; })
+                .on("click", function (d) {
+                    d3.event.stopPropagation();
+                    self.clickedDecoration(d);
+                })
+                .call(function (lines) { self.didEnterLineDecorations(lines); });
+            if (!initial) {
+                newLines.each(function (d) {
+                    var length = Math.sqrt((d.points[1].x - d.points[0].x) ** 2 + (d.points[1].y - d.points[0].y) ** 2);
+                    var path = d3.select(this);
+                    path.attr("stroke-dasharray", length + " " + length)
+                        .attr("stroke-dashoffset", length)
+                        .style("marker-start", "none")
+                        .style("marker-end", "none")
+                        .transition().duration(200 * self.animationFactor).ease(d3.easeLinear)
+                        .attr("stroke-dashoffset", 0)
+                        .on("end", function () {
+                            d3.select(this)
+                                .attr("stroke-dasharray", undefined)
+                                .attr("stroke-offset", undefined)
+                                .style("marker-start", undefined)
+                                .style("marker-end", undefined);
+                        });
+                });
+            }
+            newLines.merge(lines)
+                .classed("dashed", function (d) { return d.style == 'dashed'; })
+                .classed("dotted", function (d) { return d.style == 'dotted'; })
+                .attr("transform", function (d) { return "translate(" + self.xScale(d.x) + ", " + self.yScale(d.y) + ")"; })
+                .attr("x1", function (d) { return self.xScaleZero(d.points[0].x); })
+                .attr("y1", function (d) { return self.yScaleZero(d.points[0].y); })
+                .attr("x2", function (d) { return self.xScaleZero(d.points[1].x); })
+                .attr("y2", function (d) { return self.yScaleZero(d.points[1].y); })
+                .classed("focus", function (d) { return self.isFocused(d); })
+                .attr("marker-start", function (d) { return d.startMarker == 'none' ? undefined : "url(#line_marker_" + d.startMarker + ")"; })
+                .attr("marker-end", function (d) { return d.endMarker == 'none' ? undefined : "url(#line_marker_" + d.endMarker + ")"; });
         }
-
-        newCircles.merge(circles)
-                     .attr("stroke", function (d) { return d.renderStroke ? d.stroke : 'none' })
-                     .classed("dashed", function (d) { return d.strokeStyle == 'dashed' })
-                     .classed("dotted", function (d) { return d.strokeStyle == 'dotted' })
-                     .attr("fill", function (d) { return d.renderFill ? d.fill : 'none' })
-                     .attr("cx", function (d) { return self.xScale(d.x) })
-                     .attr("cy", function (d) { return self.yScale(d.y) })
-                     .attr("r", function (d) { return d.r })
-                     .classed("focus", function (d) { return self.isFocused(d) })
-    };
-
-    Viewer.prototype.renderLineDecorations = function (initial) {
-        var self = this;
-        var lines = self.decorationContainer.selectAll("line")
-                           .data(self.lifecycle.decorations.line, function (d) { return d._key });
-
-        lines.exit()
-            .classed("removing", true)
-            .transition().duration(200*self.animationFactor)
-              .remove();
-
-        var newLines = lines.enter().append("line")
-                         .attr("data-key", function (d) { return d._key })
-                         .on("click", function (d) {
-                             d3.event.stopPropagation();
-                             self.clickedDecoration(d);
-                         })
-                         .call(function (lines) { self.didEnterLineDecorations(lines) });
-
-        if (!initial) {
-            newLines.each(function (d) {
-                        var length = Math.sqrt((d.points[1].x-d.points[0].x)**2 + (d.points[1].y-d.points[0].y)**2);
-                        var path = d3.select(this);
-                        path.attr("stroke-dasharray", length + " " + length)
-                            .attr("stroke-dashoffset", length)
-                            .style("marker-start", "none")
-                            .style("marker-end", "none")
-                            .transition().duration(200*self.animationFactor).ease(d3.easeLinear)
-                              .attr("stroke-dashoffset", 0)
-                              .on("end", function () {
-                                d3.select(this)
-                                  .attr("stroke-dasharray", undefined)
-                                  .attr("stroke-offset", undefined)
-                                  .style("marker-start", undefined)
-                                  .style("marker-end", undefined)
-                              })
-                    });
+        renderDecorations(initial) {
+            this.renderPolygonDecorations(initial);
+            this.renderCircleDecorations(initial);
+            this.renderLineDecorations(initial);
+            this.renderTextDecorations(initial);
         }
-
-        newLines.merge(lines)
-                     .classed("dashed", function (d) { return d.style == 'dashed' })
-                     .classed("dotted", function (d) { return d.style == 'dotted' })
-                     .attr("transform", function (d) { return "translate(" + self.xScale(d.x) + ", " + self.yScale(d.y) + ")" })
-                     .attr("x1", function (d) { return self.xScaleZero(d.points[0].x) })
-                     .attr("y1", function (d) { return self.yScaleZero(d.points[0].y) })
-                     .attr("x2", function (d) { return self.xScaleZero(d.points[1].x) })
-                     .attr("y2", function (d) { return self.yScaleZero(d.points[1].y) })
-                     .classed("focus", function (d) { return self.isFocused(d) })
-                     .attr("marker-start", function (d) { return d.startMarker == 'none' ? undefined : "url(#line_marker_" + d.startMarker + ")" })
-                     .attr("marker-end", function (d) { return d.endMarker == 'none' ? undefined : "url(#line_marker_" + d.endMarker + ")" })
-    };
-
-    Viewer.prototype.renderDecorations = function (initial) {
-        this.renderPolygonDecorations(initial);
-        this.renderCircleDecorations(initial);
-        this.renderLineDecorations(initial);
-        this.renderTextDecorations(initial);
-    };
-
-    Viewer.prototype.renderDisplay = function (initial) {
-        this.renderTransitions(initial);
-        this.renderStatusNodes(initial);
-        this.renderDecorations(initial);
-    };
-
-    Viewer.prototype.centerOnItem = function (item, animated) {
-        var rect = this.svg.node().getBoundingClientRect();
-        var scale = this._zoomIdentityScale;
-        var x = rect.width/2 - this.xScale(item.x) * scale;
-        var y = rect.height/2 - this.yScale(item.y) * scale;
-        this._zoomIdentity = d3.zoomIdentity.translate(x, y).scale(this._zoomIdentityScale);
-        this.resetZoom(animated);
-    };
-
-    Viewer.prototype.defocus = function () {
-        this._focusItem = null;
-        this.svg.classed("has-focus", false)
+        renderDisplay(initial) {
+            this.renderTransitions(initial);
+            this.renderStatusNodes(initial);
+            this.renderDecorations(initial);
+        }
+        centerOnItem(item, animated) {
+            var rect = this.svg.node().getBoundingClientRect();
+            var scale = this._zoomIdentityScale;
+            var x = rect.width / 2 - this.xScale(item.x) * scale;
+            var y = rect.height / 2 - this.yScale(item.y) * scale;
+            this._zoomIdentity = d3.zoomIdentity.translate(x, y).scale(this._zoomIdentityScale);
+            this.resetZoom(animated);
+        }
+        defocus() {
+            this._focusItem = null;
+            this.svg.classed("has-focus", false)
                 .attr('data-focus-type', undefined);
-    };
-
-    Viewer.prototype.focusItem = function (d) {
-        this.defocus();
-
-        this._focusItem = d;
-        this.svg.classed("has-focus", true)
-                .attr('data-focus-type', d._type);
-    };
-
-    Viewer.prototype.focusOnStatus = function (statusName, center, animated) {
-        if (!statusName) {
-            return;
         }
-
-        var meta = this.lifecycle.statusObjectForName(statusName);
-        this.focusItem(meta);
-
-        if (center) {
-            this.centerOnItem(meta, animated)
+        focusItem(d) {
+            this.defocus();
+            this._focusItem = d;
+            this.svg.classed("has-focus", true)
+                .attr('data-focus-type', d._type);
         }
-    };
-
-    Viewer.prototype.isFocused = function (d) {
-        if (!this._focusItem) {
-            return false;
+        focusOnStatus(statusName, center, animated) {
+            if (!statusName) {
+                return;
+            }
+            var meta = this.lifecycle.statusObjectForName(statusName);
+            this.focusItem(meta);
+            if (center) {
+                this.centerOnItem(meta, animated);
+            }
         }
-        return this._focusItem._key == d._key;
-    };
-
-    Viewer.prototype.isFocusedTransition = function (d, isFrom) {
-        if (!this._focusItem) {
-            return false;
+        isFocused(d) {
+            if (!this._focusItem) {
+                return false;
+            }
+            return this._focusItem._key == d._key;
         }
-
-        if (d._type == 'status') {
-            if (this._focusItem._type == 'status') {
-                if (isFrom) {
-                    return this.lifecycle.hasTransition(d.name, this._focusItem.name);
+        isFocusedTransition(d, isFrom) {
+            if (!this._focusItem) {
+                return false;
+            }
+            if (d._type == 'status') {
+                if (this._focusItem._type == 'status') {
+                    if (isFrom) {
+                        return this.lifecycle.hasTransition(d.name, this._focusItem.name);
+                    }
+                    else {
+                        return this.lifecycle.hasTransition(this._focusItem.name, d.name);
+                    }
                 }
-                else {
-                    return this.lifecycle.hasTransition(this._focusItem.name, d.name);
+                else if (this._focusItem._type == 'transition') {
+                    if (isFrom) {
+                        return this._focusItem.from == d.name;
+                    }
+                    else {
+                        return this._focusItem.to == d.name;
+                    }
                 }
             }
-            else if (this._focusItem._type == 'transition') {
-                if (isFrom) {
-                    return this._focusItem.from == d.name;
-                }
-                else {
-                    return this._focusItem.to == d.name;
+            else if (d._type == 'transition') {
+                if (this._focusItem._type == 'status') {
+                    if (isFrom) {
+                        return d.to == this._focusItem.name;
+                    }
+                    else {
+                        return d.from == this._focusItem.name;
+                    }
                 }
             }
+            return false;
         }
-        else if (d._type == 'transition') {
-            if (this._focusItem._type == 'status') {
-                if (isFrom) {
-                    return d.to == this._focusItem.name;
+
+        initializeViewer(node, name, config, focusStatus) {
+            var self = this;
+            self.container = jQuery(node);
+            self.svg = d3.select(node).select('svg');
+            self.transformContainer = self.svg.select('g.transform');
+            self.transitionContainer = self.svg.select('g.transitions');
+            self.statusContainer = self.svg.select('g.statuses');
+            self.decorationContainer = self.svg.select('g.decorations');
+            self._xScale = self.createScale(self.width, self.padding);
+            self._yScale = self.createScale(self.height, self.padding);
+            self._xScaleZero = self.createScale(self.width, 0);
+            self._yScaleZero = self.createScale(self.height, 0);
+            // zoom in a bit, but not too much
+            var scale = self.svg.node().getBoundingClientRect().width / self.width;
+            scale = scale ** .6;
+            self._zoomIdentityScale = scale;
+            self._zoomIdentity = self._currentZoom = d3.zoomIdentity.scale(self._zoomIdentityScale);
+            self.lifecycle = new RT.Lifecycle(name);
+            self.lifecycle.initializeFromConfig(config);
+            // need to start with zoom control on to set the initial zoom
+            this.zoomControl = true;
+            self.addZoomBehavior();
+            if (self.container.hasClass('center-status')) {
+                self.focusOnStatus(focusStatus, true, false);
+                self.renderDisplay(true);
+            }
+            else {
+                self.focusOnStatus(focusStatus, false, false);
+                self.renderDisplay(true);
+                if (self.container.hasClass('center-fit')) {
+                    self.zoomToFit(false);
                 }
-                else {
-                    return d.from == this._focusItem.name;
+                else if (self.container.hasClass('center-origin')) {
+                    self.resetZoom(false);
                 }
             }
+            self._zoomIdentity = self._currentZoom;
+            self.zoomControl = self.container.hasClass('zoomable');
+            self.container.on('click', 'button.zoom-in', function (e) {
+                e.preventDefault();
+                self.zoomScale(1.25, true);
+            });
+            self.container.on('click', 'button.zoom-out', function (e) {
+                e.preventDefault();
+                self.zoomScale(.75, true);
+            });
+            self.container.on('click', 'button.zoom-reset', function (e) {
+                e.preventDefault();
+                self.resetZoom(true);
+            });
         }
+    }
+;
+
+
+
+
+
+
+
+
+
+
+
 
-        return false;
-    };
 
-    Viewer.prototype.initializeViewer = function (node, name, config, focusStatus) {
-        var self = this;
 
-        self.container = jQuery(node);
-        self.svg       = d3.select(node).select('svg');
 
-        self.transformContainer  = self.svg.select('g.transform');
-        self.transitionContainer = self.svg.select('g.transitions');
-        self.statusContainer     = self.svg.select('g.statuses');
-        self.decorationContainer = self.svg.select('g.decorations');
 
-        self._xScale = self.createScale(self.width, self.padding);
-        self._yScale = self.createScale(self.height, self.padding);
-        self._xScaleZero = self.createScale(self.width, 0);
-        self._yScaleZero = self.createScale(self.height, 0);
 
-        // zoom in a bit, but not too much
-        var scale = self.svg.node().getBoundingClientRect().width / self.width;
-        scale = scale ** .6;
 
-        self._zoomIdentityScale = scale;
-        self._zoomIdentity = self._currentZoom = d3.zoomIdentity.scale(self._zoomIdentityScale);
 
-        self.lifecycle = new RT.Lifecycle(name);
-        self.lifecycle.initializeFromConfig(config);
 
-        // need to start with zoom control on to set the initial zoom
-        this.zoomControl = true;
 
-        self.addZoomBehavior();
 
-        if (self.container.hasClass('center-status')) {
-            self.focusOnStatus(focusStatus, true, false);
-            self.renderDisplay(true);
-        }
-        else {
-            self.focusOnStatus(focusStatus, false, false);
-            self.renderDisplay(true);
 
-            if (self.container.hasClass('center-fit')) {
-                self.zoomToFit(false);
-            }
-            else if (self.container.hasClass('center-origin')) {
-                self.resetZoom(false);
-            }
-        }
 
-        self._zoomIdentity = self._currentZoom;
 
-        self.zoomControl = self.container.hasClass('zoomable');
 
-        self.container.on('click', 'button.zoom-in', function (e) {
-            e.preventDefault();
-            self.zoomScale(1.25, true);
-        });
 
-        self.container.on('click', 'button.zoom-out', function (e) {
-            e.preventDefault();
-            self.zoomScale(.75, true);
-        });
 
-        self.container.on('click', 'button.zoom-reset', function (e) {
-            e.preventDefault();
-            self.resetZoom(true);
-        });
-    };
 
     RT.LifecycleViewer = Viewer;
 });

commit fae201ae174c0da50c1051088f6c2833beca9a05
Author: Craig Kaiser <craig at bestpractical.com>
Date:   Fri Oct 18 10:55:14 2019 -0400

    On right click create new status node

diff --git a/share/html/Elements/Lifecycle/Graph b/share/html/Elements/Lifecycle/Graph
index a0d72d400..ca2421a33 100644
--- a/share/html/Elements/Lifecycle/Graph
+++ b/share/html/Elements/Lifecycle/Graph
@@ -62,9 +62,6 @@
       </svg>
     </div>
 
-% if ($Editing) {
-    <& Inspector, %ARGS &>
-% }
 % if ($Interactive) {
     <& Interactive, %ARGS &>
 % }
@@ -82,7 +79,7 @@
 % }
 
 % if ($Editing) {
-                var editor = new RT.LifecycleEditor();
+                var editor = new RT.Editor( container, name, config, ticketStatus );
                 editor.initializeEditor(container, name, config, ticketStatus);
 % } else {
 % if ($Interactive) {
diff --git a/share/static/js/lifecycleui-editor.js b/share/static/js/lifecycleui-editor.js
index 297d80c33..887015395 100644
--- a/share/static/js/lifecycleui-editor.js
+++ b/share/static/js/lifecycleui-editor.js
@@ -16,8 +16,15 @@ jQuery(function () {
                 var field = jQuery('<input type="hidden" name="Config">');
                 field.val(JSON.stringify(config));
                 form.append(field);
+
                 return true;
             });
+            self.container.on('contextmenu', function (e) {
+                e.preventDefault();
+
+                self.addNewStatus()
+                self.renderDisplay();
+            });
         }
 
         clickedStatus(d, p_el) {
@@ -47,8 +54,13 @@ jQuery(function () {
             .attr("style", "width: 294px; background: transparent; border: none;")
             // make the form go away when you jump out (form looses focus) or hit ENTER:
             .on("blur", function() {
-                d.name = inp.node().value;
-                RT.Lifecycle.updateStatusName( current_val, d.name );
+                if ( inp.node().value && inp.node().value != current_val ) {
+                    d.name = inp.node().value;
+                    RT.Lifecycle.updateStatusName( current_val, d.name );
+                }
+                else {
+                    d.name = current_val;
+                }
 
                 // Note to self: frm.remove() will remove the entire <g> group! Remember the D3 selection logic!
                 d3.select("foreignObject").remove();
@@ -68,15 +80,17 @@ jQuery(function () {
                         e.stopPropagation();
                     e.preventDefault();
 
-                    d.name = inp.node().value;
+                    if ( inp.node().value && inp.node().value != current_val ) {
+                        d.name = inp.node().value;
+                        RT.Lifecycle.updateStatusName( current_val, d.name );
+                    }
+                    else {
+                        d.name = current_val;
+                    }
 
-                    // odd. Should work in Safari, but the debugger crashes on this instead.
-                    // Anyway, it SHOULD be here and it doesn't hurt otherwise.
                     d3.select("foreignObject").remove();
                     self.renderDisplay();
                 }
-                d.name = inp.node().value;
-                RT.Lifecycle.updateStatusName( current_val, d.name );
             });
         }
         clickedTransition(d) {
@@ -93,8 +107,7 @@ jQuery(function () {
         }
         addNewStatus() {
             var p = this.viewportCenterPoint();
-            var status = this.lifecycle.createStatus(p[0], p[1]);
-            this.focusItem(status);
+            this.lifecycle.createStatus(p[0], p[1]);
         }
 
 
diff --git a/share/static/js/lifecycleui-model.js b/share/static/js/lifecycleui-model.js
index 5e75bc3cb..74c37c3a3 100644
--- a/share/static/js/lifecycleui-model.js
+++ b/share/static/js/lifecycleui-model.js
@@ -1,859 +1,712 @@
 jQuery(function () {
     var _ELEMENT_KEY_SEQ = 0;
 
-    function Lifecycle (name) {
-        this.name = name;
-        this.type = 'ticket';
-        this.is_ticket = true;
-        this.statuses = [];
-        this.defaults = {};
-        this.transitions = [];
-        this.decorations = {};
-        this.ticket_zoom = 'dynamic';
-        this.ticket_center = 'status';
-
-        this.defaultColor = '#547CCC';
-        this._undoState = { undoStack: [], redoStack: [] };
-        this._keyMap = {};
-        this._statusMeta = {};
-    };
-
-    Lifecycle.prototype._initialPointsForPolygon = {
-        Line: [
-            {x: -700, y: 0},
-            {x:  700, y: 0},
-        ],
-        Triangle: [
-            {x:  700, y: 2000},
-            {x:   0, y:  0},
-            {x: -600, y: 2000}
-        ],
-        Rectangle: [
-            {x: -600, y: -600},
-            {x:  600, y: -600},
-            {x:  600, y:  600},
-            {x: -600, y:  600}
-        ]
-    };
-
-    Lifecycle.prototype.initializeFromConfig = function (config) {
-        var self = this;
-
-        if (config.type) {
-            self.type = config.type;
-            self.is_ticket = self.type == 'ticket';
-        }
-
-        if (config.ticket_display) {
-            self.ticket_display = config.ticket_display;
-        }
-
-        if (config.ticket_zoom) {
-            self.ticket_zoom = config.ticket_zoom;
-        }
-
-        if (config.ticket_center) {
-            self.ticket_center = config.ticket_center;
-        }
-
-        jQuery.each(['initial', 'active', 'inactive'], function (i, type) {
-            if (config[type]) {
-                self.statuses = self.statuses.concat(config[type]);
-                jQuery.each(config[type], function (j, statusName) {
-                    var item;
-                    if (config.statusExtra) {
-                        item = config.statusExtra[statusName] || {};
+    class Lifecycle {
+        constructor(name) {
+            this.name = name;
+            this.type = 'ticket';
+            this.is_ticket = true;
+            this.statuses = [];
+            this.defaults = {};
+            this.transitions = [];
+            this.decorations = {};
+            this.ticket_zoom = 'dynamic';
+            this.ticket_center = 'status';
+            this.defaultColor = '#547CCC';
+            this._undoState = { undoStack: [], redoStack: [] };
+            this._keyMap = {};
+            this._statusMeta = {};
+        }
+        initializeFromConfig(config) {
+            var self = this;
+            if (config.type) {
+                self.type = config.type;
+                self.is_ticket = self.type == 'ticket';
+            }
+            if (config.ticket_display) {
+                self.ticket_display = config.ticket_display;
+            }
+            if (config.ticket_zoom) {
+                self.ticket_zoom = config.ticket_zoom;
+            }
+            if (config.ticket_center) {
+                self.ticket_center = config.ticket_center;
+            }
+            jQuery.each(['initial', 'active', 'inactive'], function (i, type) {
+                if (config[type]) {
+                    self.statuses = self.statuses.concat(config[type]);
+                    jQuery.each(config[type], function (j, statusName) {
+                        var item;
+                        if (config.statusExtra) {
+                            item = config.statusExtra[statusName] || {};
+                        }
+                        else {
+                            item = {};
+                        }
+                        item._key = _ELEMENT_KEY_SEQ++;
+                        item._type = 'status';
+                        item.name = statusName;
+                        item.type = type;
+                        self._statusMeta[statusName] = item;
+                        self._keyMap[item._key] = item;
+                    });
+                }
+            });
+            var statusCount = self.statuses.length;
+            jQuery.each(self.statuses, function (i, statusName) {
+                var meta = self._statusMeta[statusName];
+                // arrange statuses evenly-spaced around a circle
+                if (!meta.x) {
+                    meta.x = 10000 * (Math.sin(2 * Math.PI * (i / statusCount)) + 1) / 2;
+                    meta.y = 10000 * (Math.cos(2 * Math.PI * (i / statusCount)) + 1) / 2;
+                }
+                ;
+                if (!meta.color) {
+                    meta.color = self.defaultColor;
+                }
+                ;
+            });
+            if (config.defaults) {
+                self.defaults = config.defaults;
+            }
+            if (config.transitions) {
+                jQuery.each(config.transitions, function (fromStatus, toList) {
+                    if (fromStatus == "") {
+                        jQuery.each(toList, function (i, toStatus) {
+                            self._statusMeta[toStatus].creation = true;
+                        });
                     }
                     else {
-                        item = {};
+                        jQuery.each(toList, function (i, toStatus) {
+                            var description = fromStatus + ' -> ' + toStatus;
+                            var transition;
+                            if (config.transitionExtra) {
+                                transition = config.transitionExtra[description] || {};
+                            }
+                            else {
+                                transition = {};
+                            }
+                            transition._key = _ELEMENT_KEY_SEQ++;
+                            transition._type = 'transition';
+                            transition.from = fromStatus;
+                            transition.to = toStatus;
+                            transition.style = transition.style || 'solid';
+                            transition.actions = [];
+                            self.transitions.push(transition);
+                            self._keyMap[transition._key] = transition;
+                        });
                     }
-                    item._key  = _ELEMENT_KEY_SEQ++;
-                    item._type = 'status';
-                    item.name  = statusName;
-                    item.type  = type;
-                    self._statusMeta[statusName] = item;
-                    self._keyMap[item._key] = item;
                 });
             }
-        });
-
-        var statusCount = self.statuses.length;
-
-        jQuery.each(self.statuses, function (i, statusName) {
-            var meta = self._statusMeta[statusName];
-            // arrange statuses evenly-spaced around a circle
-            if (!meta.x) {
-                meta.x = 10000 * (Math.sin(2 * Math.PI * (i/statusCount)) + 1) / 2;
-                meta.y = 10000 * (Math.cos(2 * Math.PI * (i/statusCount)) + 1) / 2;
-            };
-
-            if (!meta.color) {
-                meta.color = self.defaultColor;
-            };
-        });
-
-        if (config.defaults) {
-            self.defaults = config.defaults;
-        }
-
-        if (config.transitions) {
-            jQuery.each(config.transitions, function (fromStatus, toList) {
-                if (fromStatus == "") {
-                    jQuery.each(toList, function (i, toStatus) {
-                        self._statusMeta[toStatus].creation = true;
+            if (config.rights) {
+                jQuery.each(config.rights, function (description, right) {
+                    jQuery.each(self.transitions, function (i, transition) {
+                        var from = transition.from;
+                        var to = transition.to;
+                        if (description == (from + ' -> ' + to)
+                            || description == ('* -> ' + to)
+                            || description == (from + ' -> *')
+                            || description == ('* -> *')) {
+                            transition.right = right;
+                        }
                     });
+                });
+            }
+            jQuery.each(self.transitions, function (i, transition) {
+                if (!transition.right) {
+                    transition.right = self.defaultRightForTransition(transition);
                 }
-                else {
-                    jQuery.each(toList, function (i, toStatus) {
-                        var description = fromStatus + ' -> ' + toStatus;
-                        var transition;
-                        if (config.transitionExtra) {
-                            transition = config.transitionExtra[description] || {};
-                        }
-                        else {
-                            transition = {};
+            });
+            if (config.actions) {
+                var actions = config.actions;
+                // convert hash-based actions to array of pairs
+                if (jQuery.type(config.actions) == "object") {
+                    actions = [];
+                    jQuery.each(config.actions, function (description, action) {
+                        actions.push(description, action);
+                    });
+                }
+                for (var i = 0; i < actions.length; ++i) {
+                    var description;
+                    var spec;
+                    if (jQuery.type(actions[i]) == "string") {
+                        description = actions[i];
+                        spec = actions[++i];
+                    }
+                    else {
+                        spec = actions[i];
+                        var from = (delete spec.from) || '*';
+                        var to = (delete spec.to) || '*';
+                        description = from + ' -> ' + to;
+                    }
+                    jQuery.each(self.transitions, function (i, transition) {
+                        var from = transition.from;
+                        var to = transition.to;
+                        if (description == (from + ' -> ' + to)
+                            || description == ('* -> ' + to)
+                            || description == (from + ' -> *')
+                            || description == ('* -> *')) {
+                            var action = jQuery.extend({}, spec);
+                            action._key = _ELEMENT_KEY_SEQ++;
+                            action._type = 'action';
+                            transition.actions.push(action);
+                            self._keyMap[action._key] = action;
                         }
-                        transition._key    = _ELEMENT_KEY_SEQ++;
-                        transition._type   = 'transition';
-                        transition.from    = fromStatus;
-                        transition.to      = toStatus;
-                        transition.style   = transition.style || 'solid';
-                        transition.actions = [];
-
-                        self.transitions.push(transition);
-                        self._keyMap[transition._key] = transition;
                     });
                 }
+            }
+            self.decorations = {};
+            jQuery.each(['text', 'polygon', 'circle', 'line'], function (i, type) {
+                var decorations = [];
+                if (config.decorations && config.decorations[type]) {
+                    jQuery.each(config.decorations[type], function (i, decoration) {
+                        decoration._key = _ELEMENT_KEY_SEQ++;
+                        decoration._type = type;
+                        decorations.push(decoration);
+                        self._keyMap[decoration._key] = decoration;
+                    });
+                }
+                self.decorations[type] = decorations;
             });
         }
-
-        if (config.rights) {
-            jQuery.each(config.rights, function (description, right) {
-                jQuery.each(self.transitions, function (i, transition) {
-                    var from = transition.from;
-                    var to = transition.to;
-
-                    if (description == (from + ' -> ' + to)
-                     || description == ('* -> ' + to)
-                     || description == (from + ' -> *')
-                     || description == ('* -> *')) {
-                        transition.right = right;
-                    }
-                });
+        defaultRightForTransition(transition) {
+            if (this.type == 'asset') {
+                return 'ModifyAsset';
+            }
+            if (transition.to == 'deleted') {
+                return 'DeleteTicket';
+            }
+            return 'ModifyTicket';
+        }
+        _sanitizeForExport(o) {
+            var clone = jQuery.extend(true, {}, o);
+            var type = o._type;
+            jQuery.each(clone, function (key, value) {
+                if (key.substr(0, 1) == '_') {
+                    delete clone[key];
+                }
             });
-        }
-
-        jQuery.each(self.transitions, function (i, transition) {
-            if (!transition.right) {
-                transition.right = self.defaultRightForTransition(transition);
+            // remove additional redundant information to provide a single source
+            // of truth
+            if (type == 'status') {
+                delete clone.name;
+                delete clone.type;
+                delete clone.creation;
             }
-        });
-
-        if (config.actions) {
-            var actions = config.actions;
-
-            // convert hash-based actions to array of pairs
-            if (jQuery.type(config.actions) == "object") {
-                actions = [];
-                jQuery.each(config.actions, function(description, action) {
-                    actions.push(description, action);
-                });
+            else if (type == 'transition') {
+                delete clone.from;
+                delete clone.to;
+                delete clone.actions;
+                delete clone.right;
             }
-
-            for (var i = 0; i < actions.length; ++i) {
-                var description;
-                var spec;
-
-                if (jQuery.type(actions[i]) == "string") {
-                    description = actions[i];
-                    spec = actions[++i];
+            return clone;
+        }
+        exportAsConfiguration() {
+            var self = this;
+            var config = {
+                type: self.type,
+                initial: [],
+                active: [],
+                inactive: [],
+                defaults: self.defaults,
+                actions: [],
+                rights: {},
+                transitions: self.transitions,
+                ticket_display: self.ticket_display,
+                ticket_zoom: self.ticket_zoom,
+                ticket_center: self.ticket_center,
+                decorations: {},
+                statusExtra: {},
+                transitionExtra: {}
+            };
+            var transitions = { "": [] };
+            jQuery.each(self.statuses, function (i, statusName) {
+                var meta = self._statusMeta[statusName];
+                var statusType = meta.type;
+                config[statusType].push(statusName);
+                config.statusExtra[statusName] = self._sanitizeForExport(meta);
+                if (meta.creation) {
+                    transitions[""].push(statusName);
                 }
-                else {
-                    spec = actions[i];
-                    var from = (delete spec.from) || '*';
-                    var to = (delete spec.to) || '*';
-                    description = from + ' -> ' + to;
+            });
+            jQuery.each(self.transitions, function (i, transition) {
+                var from = transition.from;
+                var to = transition.to;
+                var description = transition.from + ' -> ' + transition.to;
+                config.transitionExtra[description] = self._sanitizeForExport(transition);
+                if (!transitions[from]) {
+                    transitions[from] = [];
                 }
-
-                jQuery.each(self.transitions, function (i, transition) {
-                    var from = transition.from;
-                    var to = transition.to;
-
-                    if (description == (from + ' -> ' + to)
-                     || description == ('* -> ' + to)
-                     || description == (from + ' -> *')
-                     || description == ('* -> *')) {
-                        var action = jQuery.extend({}, spec);
-                        action._key = _ELEMENT_KEY_SEQ++;
-                        action._type = 'action';
-                        transition.actions.push(action);
-                        self._keyMap[action._key] = action;
+                transitions[from].push(to);
+                if (transition.right) {
+                    config.rights[description] = transition.right;
+                }
+                jQuery.each(transition.actions, function (i, action) {
+                    if (action.label) {
+                        var serialized = { label: action.label };
+                        if (action.update) {
+                            serialized.update = action.update;
+                        }
+                        config.actions.push(description, serialized);
                     }
                 });
-            }
-        }
-
-        self.decorations = {};
-
-        jQuery.each(['text', 'polygon', 'circle', 'line'], function (i, type) {
-            var decorations = [];
-
-            if (config.decorations && config.decorations[type]) {
-                jQuery.each(config.decorations[type], function (i, decoration) {
-                    decoration._key = _ELEMENT_KEY_SEQ++;
-                    decoration._type = type;
-                    decorations.push(decoration);
-                    self._keyMap[decoration._key] = decoration;
+            });
+            config.transitions = transitions;
+            config.decorations = {};
+            jQuery.each(self.decorations, function (type, decorations) {
+                var out = [];
+                jQuery.each(decorations, function (i, decoration) {
+                    out.push(self._sanitizeForExport(decoration));
                 });
-            }
-
-            self.decorations[type] = decorations;
-        });
-    };
-
-    Lifecycle.prototype.defaultRightForTransition = function (transition) {
-        if (this.type == 'asset') {
-            return 'ModifyAsset';
+                config.decorations[type] = out;
+            });
+            return config;
+        }
+        updateStatusName(oldValue, newValue) {
+            var self = this;
+
+            // statusMeta key
+            var oldMeta = self._statusMeta[oldValue];
+            delete self._statusMeta[oldValue];
+            self._statusMeta[newValue] = oldMeta;
+            // statuses array value
+            var index = self.statuses.indexOf(oldValue);
+            self.statuses[index] = newValue;
+            // defaults
+            jQuery.each(self.defaults, function (key, statusName) {
+                if (statusName == oldValue) {
+                    self.defaults[key] = newValue;
+                }
+            });
+            // transitions
+            jQuery.each(self.transitions, function (i, transition) {
+                if (transition.from == oldValue) {
+                    transition.from = newValue;
+                }
+                if (transition.to == oldValue) {
+                    transition.to = newValue;
+                }
+            });
         }
-
-        if (transition.to == 'deleted') {
-            return 'DeleteTicket';
+        statusNameForKey(key) {
+            return this._keyMap[key].name;
         }
-
-        return 'ModifyTicket';
-    };
-
-    Lifecycle.prototype._sanitizeForExport = function (o) {
-        var clone = jQuery.extend(true, {}, o);
-        var type = o._type;
-        jQuery.each(clone, function (key, value) {
-            if (key.substr(0, 1) == '_') {
-                delete clone[key];
-            }
-        });
-
-        // remove additional redundant information to provide a single source
-        // of truth
-        if (type == 'status') {
-            delete clone.name;
-            delete clone.type;
-            delete clone.creation;
-        }
-        else if (type == 'transition') {
-            delete clone.from;
-            delete clone.to;
-            delete clone.actions;
-            delete clone.right;
+        statusObjects() {
+            return Object.values(this._statusMeta);
         }
-
-        return clone;
-    };
-
-    Lifecycle.prototype.exportAsConfiguration = function () {
-        var self = this;
-        var config = {
-            type: self.type,
-            initial: [],
-            active: [],
-            inactive: [],
-            defaults: self.defaults,
-            actions: [],
-            rights: {},
-            transitions: self.transitions,
-
-            ticket_display: self.ticket_display,
-            ticket_zoom: self.ticket_zoom,
-            ticket_center: self.ticket_center,
-            decorations: {},
-            statusExtra: {},
-            transitionExtra: {}
-        };
-
-        var transitions = { "": [] };
-
-        jQuery.each(self.statuses, function (i, statusName) {
-            var meta = self._statusMeta[statusName];
-            var statusType = meta.type;
-            config[statusType].push(statusName);
-            config.statusExtra[statusName] = self._sanitizeForExport(meta);
-
-            if (meta.creation) {
-                transitions[""].push(statusName);
-            }
-        });
-
-        jQuery.each(self.transitions, function (i, transition) {
-            var from = transition.from;
-            var to = transition.to;
-            var description = transition.from + ' -> ' + transition.to;
-
-            config.transitionExtra[description] = self._sanitizeForExport(transition);
-
-            if (!transitions[from]) {
-                transitions[from] = [];
-            }
-            transitions[from].push(to);
-
-            if (transition.right) {
-                config.rights[description] = transition.right;
+        keyForStatusName(statusName) {
+            return this._statusMeta[statusName]._key;
+        }
+        statusObjectForName(statusName) {
+            return this._statusMeta[statusName];
+        }
+        deleteStatus(key) {
+            var self = this;
+            self._saveUndoEntry(false);
+            var statusName = self.statusNameForKey(key);
+            if (!statusName) {
+                console.error("no status for key '" + key + "'; did you accidentally pass status name?");
             }
-
-            jQuery.each(transition.actions, function (i, action) {
-                if (action.label) {
-                    var serialized = { label : action.label };
-                    if (action.update) {
-                        serialized.update = action.update;
-                    }
-                    config.actions.push(description, serialized);
+            // internal book-keeping
+            delete self._statusMeta[statusName];
+            delete self._keyMap[key];
+            // statuses array value
+            var index = self.statuses.indexOf(statusName);
+            self.statuses.splice(index, 1);
+            // defaults
+            jQuery.each(self.defaults, function (key, value) {
+                if (value == statusName) {
+                    delete self.defaults[key];
                 }
             });
-        });
-
-        config.transitions = transitions;
-
-        config.decorations = {};
-        jQuery.each(self.decorations, function (type, decorations) {
-            var out = [];
-            jQuery.each(decorations, function (i, decoration) {
-                out.push(self._sanitizeForExport(decoration));
+            // transitions
+            self.transitions = jQuery.grep(self.transitions, function (transition) {
+                if (transition.from == statusName || transition.to == statusName) {
+                    return false;
+                }
+                return true;
             });
-            config.decorations[type] = out;
-        });
-
-        return config;
-    };
-
-    Lifecycle.prototype.updateStatusName = function (oldValue, newValue) {
-        var self = this;
-
-        // statusMeta key
-        var oldMeta = self._statusMeta[oldValue];
-        delete self._statusMeta[oldValue];
-        self._statusMeta[newValue] = oldMeta;
-
-        // statuses array value
-        var index = self.statuses.indexOf(oldValue);
-        self.statuses[index] = newValue;
-
-        // defaults
-        jQuery.each(self.defaults, function (key, statusName) {
-            if (statusName == oldValue) {
-                self.defaults[key] = newValue;
-            }
-        });
-
-        // transitions
-        jQuery.each(self.transitions, function (i, transition) {
-            if (transition.from == oldValue) {
-                transition.from = newValue;
-            }
-            if (transition.to == oldValue) {
-                transition.to = newValue;
-            }
-        });
-    };
-
-    Lifecycle.prototype.statusNameForKey = function (key) {
-        return this._keyMap[key].name;
-    };
-
-    Lifecycle.prototype.statusObjects = function () {
-        return Object.values(this._statusMeta);
-    };
-
-    Lifecycle.prototype.keyForStatusName = function (statusName) {
-        return this._statusMeta[statusName]._key;
-    };
-
-    Lifecycle.prototype.statusObjectForName = function (statusName) {
-        return this._statusMeta[statusName];
-    };
-
-    Lifecycle.prototype.deleteStatus = function (key) {
-        var self = this;
-
-        self._saveUndoEntry(false);
-
-        var statusName = self.statusNameForKey(key);
-        if (!statusName) {
-            console.error("no status for key '" + key + "'; did you accidentally pass status name?");
+            self._undoStateChanged();
         }
-
-        // internal book-keeping
-        delete self._statusMeta[statusName];
-        delete self._keyMap[key];
-
-        // statuses array value
-        var index = self.statuses.indexOf(statusName);
-        self.statuses.splice(index, 1);
-
-        // defaults
-        jQuery.each(self.defaults, function (key, value) {
-            if (value == statusName) {
-                delete self.defaults[key];
-            }
-        });
-
-        // transitions
-        self.transitions = jQuery.grep(self.transitions, function (transition) {
-            if (transition.from == statusName || transition.to == statusName) {
+        addTransition(fromStatus, toStatus) {
+            this._saveUndoEntry(false);
+            var transition = {
+                _key: _ELEMENT_KEY_SEQ++,
+                _type: 'transition',
+                from: fromStatus,
+                to: toStatus,
+                style: 'solid',
+                actions: []
+            };
+            this.transitions.push(transition);
+            this._keyMap[transition._key] = transition;
+            transition.right = this.defaultRightForTransition(transition);
+            this._undoStateChanged();
+            return transition;
+        }
+        hasTransition(fromStatus, toStatus) {
+            if (fromStatus == toStatus || !fromStatus || !toStatus) {
                 return false;
             }
-            return true;
-        });
-
-        self._undoStateChanged();
-    };
-
-    Lifecycle.prototype.addTransition = function (fromStatus, toStatus) {
-        this._saveUndoEntry(false);
-
-        var transition = {
-            _key    : _ELEMENT_KEY_SEQ++,
-            _type   : 'transition',
-            from    : fromStatus,
-            to      : toStatus,
-            style   : 'solid',
-            actions : []
-        };
-        this.transitions.push(transition);
-        this._keyMap[transition._key] = transition;
-
-        transition.right = this.defaultRightForTransition(transition);
-
-        this._undoStateChanged();
-
-        return transition;
-    };
-
-    Lifecycle.prototype.hasTransition = function (fromStatus, toStatus) {
-        if (fromStatus == toStatus || !fromStatus || !toStatus) {
+            for (var i = 0; i < this.transitions.length; ++i) {
+                var transition = this.transitions[i];
+                if (transition.from == fromStatus && transition.to == toStatus) {
+                    return transition;
+                }
+            }
+            ;
             return false;
         }
-
-        for (var i = 0; i < this.transitions.length; ++i) {
-            var transition = this.transitions[i];
-            if (transition.from == fromStatus && transition.to == toStatus) {
-                return transition;
-            }
-        };
-
-        return false;
-    };
-
-    Lifecycle.prototype.transitionsFrom = function (fromStatus) {
-        var transitions = [];
-        for (var i = 0; i < this.transitions.length; ++i) {
-            var transition = this.transitions[i];
-            if (transition.from == fromStatus) {
-                transitions.push(transition);
-            }
-        };
-        return transitions;
-    };
-
-    Lifecycle.prototype.transitionsTo = function (toStatus) {
-        var transitions = [];
-        for (var i = 0; i < this.transitions.length; ++i) {
-            var transition = this.transitions[i];
-            if (transition.to == toStatus) {
-                transitions.push(transition);
-            }
-        };
-        return transitions;
-    };
-
-    Lifecycle.prototype.deleteTransition = function (key) {
-        this._saveUndoEntry(false);
-
-        this.transitions = jQuery.grep(this.transitions, function (transition) {
-            if (transition._key == key) {
-                return false;
+        transitionsFrom(fromStatus) {
+            var transitions = [];
+            for (var i = 0; i < this.transitions.length; ++i) {
+                var transition = this.transitions[i];
+                if (transition.from == fromStatus) {
+                    transitions.push(transition);
+                }
             }
-            return true;
-        });
-        delete this._keyMap[key];
-
-        this._undoStateChanged();
-    };
-
-    Lifecycle.prototype.deleteDecoration = function (type, key) {
-        this._saveUndoEntry(false);
-
-        this.decorations[type] = jQuery.grep(this.decorations[type], function (decoration) {
-            if (decoration._key == key) {
-                return false;
+            ;
+            return transitions;
+        }
+        transitionsTo(toStatus) {
+            var transitions = [];
+            for (var i = 0; i < this.transitions.length; ++i) {
+                var transition = this.transitions[i];
+                if (transition.to == toStatus) {
+                    transitions.push(transition);
+                }
             }
-            return true;
-        });
-        delete this._keyMap[key];
-
-        this._undoStateChanged();
-    };
-
-    Lifecycle.prototype.itemForKey = function (key) {
-        return this._keyMap[key];
-    };
-
-    Lifecycle.prototype.deleteItemForKey = function (key) {
-        var item = this.itemForKey(key);
-        var type = item._type;
-
-        if (type == 'status') {
-            this.deleteStatus(key);
+            ;
+            return transitions;
         }
-        else if (type == 'transition') {
-            this.deleteTransition(key);
+        deleteTransition(key) {
+            this._saveUndoEntry(false);
+            this.transitions = jQuery.grep(this.transitions, function (transition) {
+                if (transition._key == key) {
+                    return false;
+                }
+                return true;
+            });
+            delete this._keyMap[key];
+            this._undoStateChanged();
         }
-        else if (type == 'text' || type == 'polygon' || type == 'circle' || type == 'line') {
-            this.deleteDecoration(type, key);
+        deleteDecoration(type, key) {
+            this._saveUndoEntry(false);
+            this.decorations[type] = jQuery.grep(this.decorations[type], function (decoration) {
+                if (decoration._key == key) {
+                    return false;
+                }
+                return true;
+            });
+            delete this._keyMap[key];
+            this._undoStateChanged();
         }
-        else {
-            console.error("unhandled type '" + type + "'");
+        itemForKey(key) {
+            return this._keyMap[key];
         }
-    };
-
-    Lifecycle.prototype.deleteActionForTransition = function (transition, key) {
-        this._saveUndoEntry(false);
-
-        transition.actions = jQuery.grep(transition.actions, function (action) {
-            if (action._key == key) {
-                return false;
+        deleteItemForKey(key) {
+            var item = this.itemForKey(key);
+            var type = item._type;
+            if (type == 'status') {
+                this.deleteStatus(key);
             }
-            return true;
-        });
-        delete this._keyMap[key];
-
-        this._undoStateChanged();
-    };
-
-    Lifecycle.prototype.updateItem = function (item, field, newValue, skipUndo) {
-        if (!skipUndo) {
+            else if (type == 'transition') {
+                this.deleteTransition(key);
+            }
+            else if (type == 'text' || type == 'polygon' || type == 'circle' || type == 'line') {
+                this.deleteDecoration(type, key);
+            }
+            else {
+                console.error("unhandled type '" + type + "'");
+            }
+        }
+        deleteActionForTransition(transition, key) {
             this._saveUndoEntry(false);
+            transition.actions = jQuery.grep(transition.actions, function (action) {
+                if (action._key == key) {
+                    return false;
+                }
+                return true;
+            });
+            delete this._keyMap[key];
+            this._undoStateChanged();
         }
-
-        var oldValue = item[field];
-
-        item[field] = newValue;
-
-        if (item._type == 'status' && field == 'name') {
-            this.updateStatusName(oldValue, newValue);
+        updateItem(item, field, newValue, skipUndo) {
+            if (!skipUndo) {
+                this._saveUndoEntry(false);
+            }
+            var oldValue = item[field];
+            item[field] = newValue;
+            if (item._type == 'status' && field == 'name') {
+                this.updateStatusName(oldValue, newValue);
+            }
+            if (!skipUndo) {
+                this._undoStateChanged();
+            }
         }
-
-        if (!skipUndo) {
+        createActionForTransition(transition) {
+            this._saveUndoEntry(false);
+            var action = {
+                _type: 'action',
+                _key: _ELEMENT_KEY_SEQ++,
+            };
+            transition.actions.push(action);
+            this._keyMap[action._key] = action;
             this._undoStateChanged();
+            return action;
         }
-    };
-
-    Lifecycle.prototype.createActionForTransition = function (transition) {
-        this._saveUndoEntry(false);
-
-        var action = {
-            _type : 'action',
-            _key  : _ELEMENT_KEY_SEQ++,
-        };
-        transition.actions.push(action);
-        this._keyMap[action._key] = action;
-
-        this._undoStateChanged();
-
-        return action;
-    };
-
-    Lifecycle.prototype.beginDragging = function () {
-        this._saveUndoEntry(true);
-    };
-
-    Lifecycle.prototype.beginChangingColor = function () {
-        this._saveUndoEntry(true);
-    };
-
-    Lifecycle.prototype.moveItem = function (item, x, y) {
-        item.x = x;
-        item.y = y;
-    };
-
-    Lifecycle.prototype.moveCircleRadiusPoint = function (circle, x, y) {
-        circle.r = Math.max(10, Math.sqrt(x**2 + y**2));
-    }
-
-    Lifecycle.prototype.movePolygonPoint = function (polygon, index, x, y) {
-        var point = polygon.points[index];
-        point.x = x;
-        point.y = y;
-    };
-
-    Lifecycle.prototype.createStatus = function (x, y) {
-        this._saveUndoEntry(false);
-
-        var name;
-        var i = 0;
-        while (1) {
-            name = 'status #' + ++i;
-            if (!this._statusMeta[name]) {
-                break;
-            }
+        beginDragging() {
+            this._saveUndoEntry(true);
         }
-
-        this.statuses.push(name);
-
-        var item = {
-            _key: _ELEMENT_KEY_SEQ++,
-            _type: 'status',
-            name:  name,
-            type:  'initial',
-            x:     x,
-            y:     y
-        };
-        item.color = this.defaultColor;
-
-        this._statusMeta[name] = item;
-        this._keyMap[item._key] = item;
-
-        this._undoStateChanged();
-
-        return item;
-    };
-
-    Lifecycle.prototype.createTextDecoration = function (x, y) {
-        this._saveUndoEntry(false);
-
-        var item = {
-            _key: _ELEMENT_KEY_SEQ++,
-            _type: 'text',
-            text:  'New label',
-            x:     x,
-            y:     y
-        };
-        this.decorations.text.push(item);
-        this._keyMap[item._key] = item;
-
-        this._undoStateChanged();
-        return item;
-    };
-
-    Lifecycle.prototype.createPolygonDecoration = function (x, y, type) {
-        this._saveUndoEntry(false);
-
-        var item = {
-            _key: _ELEMENT_KEY_SEQ++,
-            _type: 'polygon',
-            label: type,
-            stroke: '#000000',
-            renderStroke: true,
-            strokeStyle: 'solid',
-            fill: '#ffffff',
-            renderFill: true,
-            x: x,
-            y: y,
-            points: JSON.parse(JSON.stringify(this._initialPointsForPolygon[type]))
-        };
-        this.decorations.polygon.push(item);
-        this._keyMap[item._key] = item;
-
-        this._undoStateChanged();
-        return item;
-    };
-
-    Lifecycle.prototype.createCircleDecoration = function (x, y, r) {
-        this._saveUndoEntry(false);
-
-        var item = {
-            _key: _ELEMENT_KEY_SEQ++,
-            _type: 'circle',
-            label: 'Circle',
-            stroke: '#000000',
-            renderStroke: true,
-            strokeStyle: 'solid',
-            fill: '#ffffff',
-            renderFill: true,
-            x: x,
-            y: y,
-            r: r
-        };
-        this.decorations.circle.push(item);
-        this._keyMap[item._key] = item;
-
-        this._undoStateChanged();
-        return item;
-    };
-
-    Lifecycle.prototype.createLineDecoration = function (x, y) {
-        this._saveUndoEntry(false);
-
-        var item = {
-            _key: _ELEMENT_KEY_SEQ++,
-            _type: 'line',
-            label: 'Line',
-            style: 'solid',
-            startMarker: 'none',
-            endMarker: 'arrowhead',
-            x: x,
-            y: y,
-            points: JSON.parse(JSON.stringify(this._initialPointsForPolygon.Line))
-        };
-        this.decorations.line.push(item);
-        this._keyMap[item._key] = item;
-
-        this._undoStateChanged();
-        return item;
-    };
-
-    Lifecycle.prototype.update = function (field, value) {
-        this._saveUndoEntry(false);
-
-        if (field == 'on_create' || field == 'approved' || field == 'denied' || field == 'reminder_on_open' || field == 'reminder_on_resolve') {
-            this.defaults[field] = value;
+        beginChangingColor() {
+            this._saveUndoEntry(true);
         }
-        else if (field == 'ticket_display' || field == 'ticket_zoom' || field == 'ticket_center') {
-            this[field] = value;
+        moveItem(item, x, y) {
+            item.x = x;
+            item.y = y;
         }
-        else {
-            console.error("Unhandled field in Lifecycle.update: " + field);
+        moveCircleRadiusPoint(circle, x, y) {
+            circle.r = Math.max(10, Math.sqrt(x ** 2 + y ** 2));
         }
-
-        this._undoStateChanged();
-    };
-
-    Lifecycle.prototype._currentUndoFrame = function () {
-        var undoState = this._undoState;
-        var keyMap = this._keyMap;
-        delete this._undoState;
-        delete this._keyMap;
-
-        var entry = JSON.stringify(this);
-
-        this._undoState = undoState;
-        this._keyMap = keyMap;
-
-        var frame = [entry];
-        if (this.undoFrameCallback) {
-            this.undoFrameCallback(frame);
+        movePolygonPoint(polygon, index, x, y) {
+            var point = polygon.points[index];
+            point.x = x;
+            point.y = y;
         }
-
-        return frame;
-    };
-
-    Lifecycle.prototype._undoStateChanged = function () {
-        this._canUndo = this._undoState.undoStack.length > 0;
-        this._canRedo = this._undoState.redoStack.length > 0;
-
-        if (this.undoStateChangedCallback) {
-            this.undoStateChangedCallback();
+        createStatus(x, y) {
+            this._saveUndoEntry(false);
+            var name;
+            var i = 0;
+            while (1) {
+                name = 'status #' + ++i;
+                if (!this._statusMeta[name]) {
+                    break;
+                }
+            }
+            this.statuses.push(name);
+            var item = {
+                _key: _ELEMENT_KEY_SEQ++,
+                _type: 'status',
+                name: name,
+                type: 'initial',
+                x: x,
+                y: y
+            };
+            item.color = this.defaultColor;
+            this._statusMeta[name] = item;
+            this._keyMap[item._key] = item;
+            this._undoStateChanged();
+            return item;
         }
-    };
-
-    Lifecycle.prototype._saveUndoEntry = function (notify) {
-        var frame = this._currentUndoFrame();
-        this._undoState.undoStack.push(frame);
-        this._undoState.redoStack = [];
-
-        if (notify) {
+        createTextDecoration(x, y) {
+            this._saveUndoEntry(false);
+            var item = {
+                _key: _ELEMENT_KEY_SEQ++,
+                _type: 'text',
+                text: 'New label',
+                x: x,
+                y: y
+            };
+            this.decorations.text.push(item);
+            this._keyMap[item._key] = item;
             this._undoStateChanged();
+            return item;
         }
-    };
-
-    Lifecycle.prototype._rebuildKeyMap = function () {
-        var keyMap = {};
-        jQuery.each(this._statusMeta, function (name, meta) {
-            keyMap[meta._key] = meta;
-        });
-
-        jQuery.each(this.transitions, function (i, transition) {
-            keyMap[transition._key] = transition;
-            jQuery.each(transition.actions, function (j, action) {
-                keyMap[action._key] = action;
+        createPolygonDecoration(x, y, type) {
+            this._saveUndoEntry(false);
+            var item = {
+                _key: _ELEMENT_KEY_SEQ++,
+                _type: 'polygon',
+                label: type,
+                stroke: '#000000',
+                renderStroke: true,
+                strokeStyle: 'solid',
+                fill: '#ffffff',
+                renderFill: true,
+                x: x,
+                y: y,
+                points: JSON.parse(JSON.stringify(this._initialPointsForPolygon[type]))
+            };
+            this.decorations.polygon.push(item);
+            this._keyMap[item._key] = item;
+            this._undoStateChanged();
+            return item;
+        }
+        createCircleDecoration(x, y, r) {
+            this._saveUndoEntry(false);
+            var item = {
+                _key: _ELEMENT_KEY_SEQ++,
+                _type: 'circle',
+                label: 'Circle',
+                stroke: '#000000',
+                renderStroke: true,
+                strokeStyle: 'solid',
+                fill: '#ffffff',
+                renderFill: true,
+                x: x,
+                y: y,
+                r: r
+            };
+            this.decorations.circle.push(item);
+            this._keyMap[item._key] = item;
+            this._undoStateChanged();
+            return item;
+        }
+        createLineDecoration(x, y) {
+            this._saveUndoEntry(false);
+            var item = {
+                _key: _ELEMENT_KEY_SEQ++,
+                _type: 'line',
+                label: 'Line',
+                style: 'solid',
+                startMarker: 'none',
+                endMarker: 'arrowhead',
+                x: x,
+                y: y,
+                points: JSON.parse(JSON.stringify(this._initialPointsForPolygon.Line))
+            };
+            this.decorations.line.push(item);
+            this._keyMap[item._key] = item;
+            this._undoStateChanged();
+            return item;
+        }
+        update(field, value) {
+            this._saveUndoEntry(false);
+            if (field == 'on_create' || field == 'approved' || field == 'denied' || field == 'reminder_on_open' || field == 'reminder_on_resolve') {
+                this.defaults[field] = value;
+            }
+            else if (field == 'ticket_display' || field == 'ticket_zoom' || field == 'ticket_center') {
+                this[field] = value;
+            }
+            else {
+                console.error("Unhandled field in Lifecycle.update: " + field);
+            }
+            this._undoStateChanged();
+        }
+        _currentUndoFrame() {
+            var undoState = this._undoState;
+            var keyMap = this._keyMap;
+            delete this._undoState;
+            delete this._keyMap;
+            var entry = JSON.stringify(this);
+            this._undoState = undoState;
+            this._keyMap = keyMap;
+            var frame = [entry];
+            if (this.undoFrameCallback) {
+                this.undoFrameCallback(frame);
+            }
+            return frame;
+        }
+        _undoStateChanged() {
+            this._canUndo = this._undoState.undoStack.length > 0;
+            this._canRedo = this._undoState.redoStack.length > 0;
+            if (this.undoStateChangedCallback) {
+                this.undoStateChangedCallback();
+            }
+        }
+        _saveUndoEntry(notify) {
+            var frame = this._currentUndoFrame();
+            this._undoState.undoStack.push(frame);
+            this._undoState.redoStack = [];
+            if (notify) {
+                this._undoStateChanged();
+            }
+        }
+        _rebuildKeyMap() {
+            var keyMap = {};
+            jQuery.each(this._statusMeta, function (name, meta) {
+                keyMap[meta._key] = meta;
             });
-        });
-
-        jQuery.each(this.decorations, function (type, decorations) {
-            jQuery.each(decorations, function (i, decoration) {
-                keyMap[decoration._key] = decoration;
+            jQuery.each(this.transitions, function (i, transition) {
+                keyMap[transition._key] = transition;
+                jQuery.each(transition.actions, function (j, action) {
+                    keyMap[action._key] = action;
+                });
             });
-        });
-
-        this._keyMap = keyMap;
-    };
-
-    Lifecycle.prototype._restoreState = function (state) {
-        for (var key in state) {
-            this[key] = state[key];
-        }
-
-        this._rebuildKeyMap();
-    };
-
-    Lifecycle.prototype.undo = function () {
-        var undoStack = this._undoState.undoStack;
-        if (undoStack.length == 0) {
-            return null;
+            jQuery.each(this.decorations, function (type, decorations) {
+                jQuery.each(decorations, function (i, decoration) {
+                    keyMap[decoration._key] = decoration;
+                });
+            });
+            this._keyMap = keyMap;
         }
-
-        this._undoState.redoStack.push(this._currentUndoFrame());
-
-        var frame = undoStack.pop();
-        var entry = JSON.parse(frame[0]);
-
-        this._restoreState(entry);
-
-        this._undoStateChanged();
-
-        return frame;
-    };
-
-    Lifecycle.prototype.redo = function () {
-        var redoStack = this._undoState.redoStack;
-        if (redoStack.length == 0) {
-            return null;
+        _restoreState(state) {
+            for (var key in state) {
+                this[key] = state[key];
+            }
+            this._rebuildKeyMap();
         }
-
-        this._undoState.undoStack.push(this._currentUndoFrame());
-
-        var frame = redoStack.pop();
-        var entry = JSON.parse(frame[0]);
-
-        this._restoreState(entry);
-
-        this._undoStateChanged();
-
-        return frame;
-    };
-
-    Lifecycle.prototype.cloneItem = function (source, x, y) {
-        this._saveUndoEntry(false);
-
-        var clone = JSON.parse(JSON.stringify(source));
-        clone._key = _ELEMENT_KEY_SEQ++;
-        clone.x = x;
-        clone.y = y;
-
-        if (clone._type == 'polygon' || clone._type == 'circle' || clone._type == 'line' || clone._type == 'text') {
-            this.decorations[clone._type].push(clone);
+        undo() {
+            var undoStack = this._undoState.undoStack;
+            if (undoStack.length == 0) {
+                return null;
+            }
+            this._undoState.redoStack.push(this._currentUndoFrame());
+            var frame = undoStack.pop();
+            var entry = JSON.parse(frame[0]);
+            this._restoreState(entry);
+            this._undoStateChanged();
+            return frame;
         }
-        else {
-            console.error("Unhandled type for clone: " + clone._type);
+        redo() {
+            var redoStack = this._undoState.redoStack;
+            if (redoStack.length == 0) {
+                return null;
+            }
+            this._undoState.undoStack.push(this._currentUndoFrame());
+            var frame = redoStack.pop();
+            var entry = JSON.parse(frame[0]);
+            this._restoreState(entry);
+            this._undoStateChanged();
+            return frame;
         }
-
-        this._keyMap[clone._key] = clone;
-
-        this._undoStateChanged();
-
-        return clone;
-    };
-
-    Lifecycle.prototype.selectedRights = function () {
-        var rights = jQuery.map(this.transitions, function (transition) { return transition.right });
-
-        if (this.type == 'ticket') {
-            rights = rights.concat(['ModifyTicket', 'DeleteTicket']);
+        cloneItem(source, x, y) {
+            this._saveUndoEntry(false);
+            var clone = JSON.parse(JSON.stringify(source));
+            clone._key = _ELEMENT_KEY_SEQ++;
+            clone.x = x;
+            clone.y = y;
+            if (clone._type == 'polygon' || clone._type == 'circle' || clone._type == 'line' || clone._type == 'text') {
+                this.decorations[clone._type].push(clone);
+            }
+            else {
+                console.error("Unhandled type for clone: " + clone._type);
+            }
+            this._keyMap[clone._key] = clone;
+            this._undoStateChanged();
+            return clone;
         }
-        else if (this.type == 'asset') {
-            rights = rights.concat(['ModifyAsset']);
+        selectedRights() {
+            var rights = jQuery.map(this.transitions, function (transition) { return transition.right; });
+            if (this.type == 'ticket') {
+                rights = rights.concat(['ModifyTicket', 'DeleteTicket']);
+            }
+            else if (this.type == 'asset') {
+                rights = rights.concat(['ModifyAsset']);
+            }
+            return jQuery.unique(rights.sort());
         }
+    }
+;
 
-        return jQuery.unique(rights.sort());
+    Lifecycle.prototype._initialPointsForPolygon = {
+        Line: [
+            {x: -700, y: 0},
+            {x:  700, y: 0},
+        ],
+        Triangle: [
+            {x:  700, y: 2000},
+            {x:   0, y:  0},
+            {x: -600, y: 2000}
+        ],
+        Rectangle: [
+            {x: -600, y: -600},
+            {x:  600, y: -600},
+            {x:  600, y:  600},
+            {x: -600, y:  600}
+        ]
     };
 
-    RT.Lifecycle = Lifecycle;
+    RT.Lifecycle = new Lifecycle();
 });
 
diff --git a/share/static/js/lifecycleui-viewer.js b/share/static/js/lifecycleui-viewer.js
index fe7bf7dc1..93b725238 100644
--- a/share/static/js/lifecycleui-viewer.js
+++ b/share/static/js/lifecycleui-viewer.js
@@ -442,7 +442,8 @@ jQuery(function () {
             scale = scale ** .6;
             self._zoomIdentityScale = scale;
             self._zoomIdentity = self._currentZoom = d3.zoomIdentity.scale(self._zoomIdentityScale);
-            self.lifecycle = new RT.Lifecycle(name);
+            RT.Lifecycle.name = name;
+            self.lifecycle = RT.Lifecycle;
             self.lifecycle.initializeFromConfig(config);
             // need to start with zoom control on to set the initial zoom
             this.zoomControl = true;
@@ -471,41 +472,8 @@ jQuery(function () {
                 e.preventDefault();
                 self.zoomScale(.75, true);
             });
-            self.container.on('click', 'button.zoom-reset', function (e) {
-                e.preventDefault();
-                self.resetZoom(true);
-            });
         }
-    }
-;
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+    };
 
     RT.LifecycleViewer = Viewer;
 });

commit 72bb2b1a5573bf25ab54448b34716cc0329c8aae
Author: Craig Kaiser <craig at bestpractical.com>
Date:   Fri Oct 18 16:40:11 2019 -0400

    Move lifecycle editor and viewer into one file

diff --git a/share/html/Elements/Lifecycle/Graph b/share/html/Elements/Lifecycle/Graph
index ca2421a33..99f0e4814 100644
--- a/share/html/Elements/Lifecycle/Graph
+++ b/share/html/Elements/Lifecycle/Graph
@@ -78,17 +78,10 @@
                 var ticketStatus = undefined;
 % }
 
-% if ($Editing) {
                 var editor = new RT.Editor( container, name, config, ticketStatus );
+
                 editor.initializeEditor(container, name, config, ticketStatus);
-% } else {
-% if ($Interactive) {
-                var viewer = new RT.LifecycleViewerInteractive();
-% } else {
-                var viewer = new RT.LifecycleViewer();
-% }
-                viewer.initializeViewer(container, name, config, ticketStatus);
-% }
+                editor.initializeViewer(container, name, config, ticketStatus);
             });
         });
     </script>
diff --git a/share/html/Prefs/AboutMe.html b/share/html/Prefs/AboutMe.html
index e040cdc86..3359bdc9f 100644
--- a/share/html/Prefs/AboutMe.html
+++ b/share/html/Prefs/AboutMe.html
@@ -50,7 +50,7 @@
 
 <& /Elements/ListActions, actions => \@results &>
 
-<form action="<%RT->Config->Get('WebPath')%>/Prefs/AboutMe.html" method="post">
+<form action="<%RT->Config->Get('WebPath')%>/Prefs/AboutMe.html" method="post" enctype="multipart/form-data">
 <input type="hidden" class="hidden" name="id" value="<%$UserObj->Id%>" />
 
 <table width="100%" border="0">
diff --git a/share/static/css/base/lifecycleui-editor.css b/share/static/css/base/lifecycleui-editor.css
index 732a82043..25c1cfec8 100644
--- a/share/static/css/base/lifecycleui-editor.css
+++ b/share/static/css/base/lifecycleui-editor.css
@@ -1,15 +1,13 @@
 .lifecycle-ui {
-    margin-left: 10%;
 }
 
 .lifecycle-ui.editing svg {
-    float: left;
-    width: 60%;
+    width: 100%;
     height: 500px;
 }
 
 .lifecycle-ui.editing .overlay-buttons {
-    left: 50%;
+    left: 85%;
 }
 
 .lifecycle-ui .inspector {
diff --git a/share/static/css/base/lifecycleui-viewer.css b/share/static/css/base/lifecycleui-viewer.css
index d9805e079..4739e0b1c 100644
--- a/share/static/css/base/lifecycleui-viewer.css
+++ b/share/static/css/base/lifecycleui-viewer.css
@@ -112,6 +112,7 @@
 
 .lifecycle-ui .lifecycle-view {
     position: relative;
+    display: inline-block;
 }
 
 .lifecycle-ui .overlay-buttons button {
diff --git a/share/static/js/lifecycleui-editor.js b/share/static/js/lifecycleui-editor.js
index 887015395..65b8783a7 100644
--- a/share/static/js/lifecycleui-editor.js
+++ b/share/static/js/lifecycleui-editor.js
@@ -1,10 +1,20 @@
 jQuery(function () {
 
-    RT.Editor = class LifecycleEditor extends RT.LifecycleViewer {
+    RT.Editor = class LifecycleEditor {
         constructor(container, name, config, ticketStatus) {
-            super( container, name, config, ticketStatus );
-
+            self.container    = container;
+            self.name         = name;
+            self.config       = config;
+            self.ticketStatus = ticketStatus;
             this.pointHandleRadius = 5;
+
+            this.width = 809;
+            this.height = 500;
+            this.statusCircleRadius = 35;
+            this.statusCircleRadiusFudge = 4; // required to give room for the arrowhead
+            this.gridSize = 10;
+            this.padding = this.statusCircleRadius * 2;
+            this.animationFactor = 1; // bump this to 10 debug JS animations
         }
 
         initializeEditor(node, name, config, focusStatus) {
@@ -25,24 +35,32 @@ jQuery(function () {
                 self.addNewStatus()
                 self.renderDisplay();
             });
+            d3.select("body").on("keydown", function () {
+                if ( self._focusItem ) {
+                    if ( d3.event.keyCode == 68 || d3.event.keyCode == 46 ) {
+                        RT.Lifecycle.deleteStatus(self._focusItem._key);
+                        self.renderDisplay();
+                    }
+                    self.defocus();
+                }
+            })
         }
 
-        clickedStatus(d, p_el) {
+        clickedStatus(d) {
             self = this;
 
-            var circle = d3.select(p_el).select('circle')._groups[0][0];
+            let g          = d3.select(d3.select('#key-'+d._key)._groups[0][0]);
+            let circle     = d3.select(g)._groups[0][0].select('circle');
 
             let current_val = d.name;
             d.name = '';
             self.renderDisplay();
 
-            var frm = d3.select(p_el).append("foreignObject");
-
-            var circle_d3 = d3.select(circle);
+            var frm = g.append("foreignObject");
             var inp = frm
-                .attr("x", circle_d3.attr('cx') - circle_d3.attr('r') + 10)
-                .attr("y", circle_d3.attr('cy') - circle_d3.attr('r') / 2 + 4)
-                .attr("width", circle_d3.attr('r') * 2)
+                .attr("x", Number(circle.attr('cx')) - Number(circle.attr('r')) / 2)
+                .attr("y", Number(circle.attr('cy')) - Number(circle.attr('r')) / 4)
+                .attr("width", 200)
                 .attr("height", 20)
                 .append("xhtml:form")
                 .append("input")
@@ -110,18 +128,6 @@ jQuery(function () {
             this.lifecycle.createStatus(p[0], p[1]);
         }
 
-
-
-
-
-
-
-
-
-
-
-
-
         _refreshLifecycleUI(refreshContent) {
             var self = this;
             var lifecycle = self.lifecycle;
@@ -173,12 +179,6 @@ jQuery(function () {
             });
         }
 
-        // add rects under text decorations for highlighting
-        // renderTextDecorations(initial) {
-        //     Super.prototype.renderTextDecorations.call(this, initial);
-        //     var self = this;
-        //     self.renderTextDecorationBackgrounds(initial);
-        // }
         renderTextDecorationBackgrounds(initial) {
             var self = this;
             var rects = self.decorationContainer.selectAll("rect.text-background")
@@ -225,6 +225,7 @@ jQuery(function () {
             if (this.inspectorNode && this.inspectorNode._key != d._key) {
                 return;
             }
+
             var x = this.xScaleInvert(d3.event.x);
             var y = this.yScaleInvert(d3.event.y);
             if (this.xScale(x) == this.xScale(d.x) && this.yScale(y) == this.yScale(d.y)) {
@@ -243,7 +244,7 @@ jQuery(function () {
                 .subject(function (d) { return { x: self.xScale(d.x), y: self.yScale(d.y) }; })
                 .on("start", function (d) { self.didBeginDrag(d, this); })
                 .on("drag", function (d) { self.didDragItem(d, this); })
-                .on("end", function (d) { self.didEndDrag(d, this); });
+                .on("end", function (d) { self.didEndDrag(d, this); })
         }
         didEnterStatusNodes(statuses) {
             statuses.call(this._createDrag());
@@ -254,5 +255,487 @@ jQuery(function () {
         didEnterLineDecorations(lines) {
             lines.call(this._createDrag());
         }
-    };
+
+
+
+
+        // View
+        createScale(size, padding) {
+            return d3.scaleLinear()
+                .domain([0, 10000])
+                .range([padding, size - padding]);
+        }
+        gridScale(v) { return Math.round(v / this.gridSize) * this.gridSize; }
+        xScale(x) { return this.gridScale(this._xScale(x)); }
+        yScale(y) { return this.gridScale(this._yScale(y)); }
+        xScaleZero(x) { return this.gridScale(this._xScaleZero(x)); }
+        yScaleZero(y) { return this.gridScale(this._yScaleZero(y)); }
+        xScaleInvert(x) { return Math.floor(this._xScale.invert(x)); }
+        yScaleInvert(y) { return Math.floor(this._yScale.invert(y)); }
+        xScaleZeroInvert(x) { return Math.floor(this._xScaleZero.invert(x)); }
+        yScaleZeroInvert(y) { return Math.floor(this._yScaleZero.invert(y)); }
+        addZoomBehavior() {
+            var self = this;
+            self._zoom = d3.zoom()
+                .scaleExtent([.3, 2])
+                .on("zoom", function () {
+                    if (self.zoomControl) {
+                        self.didZoom();
+                    }
+                });
+            self.svg.call(self._zoom);
+        }
+        didZoom() {
+            this._currentZoom = d3.event.transform;
+            this.transformContainer.attr("transform", d3.event.transform);
+        }
+        zoomScale(scaleBy, animated) {
+            if (animated) {
+                this.svg.transition()
+                    .duration(350 * this.animationFactor)
+                    .call(this._zoom.scaleBy, scaleBy);
+            }
+            else {
+                this.svg.call(this._zoom.scaleBy, scaleBy);
+            }
+        }
+        _setZoom(zoom, animated) {
+            if (animated) {
+                this.svg.transition()
+                    .duration(750 * this.animationFactor)
+                    .call(this._zoom.transform, zoom);
+            }
+            else {
+                this.svg.call(this._zoom.transform, zoom);
+            }
+        }
+        resetZoom(animated) {
+            this._setZoom(this._zoomIdentity, animated);
+        }
+        zoomToFit(animated) {
+            var bounds = this.transformContainer.node().getBBox();
+            var parent = this.transformContainer.node().parentElement;
+            var fullWidth = parent.clientWidth || parent.parentNode.clientWidth, fullHeight = parent.clientHeight || parent.parentNode.clientHeight;
+            var width = bounds.width, height = bounds.height;
+            var midX = bounds.x + width / 2, midY = bounds.y + height / 2;
+            var scale = .9 / Math.max(width / fullWidth, height / fullHeight);
+            var tx = fullWidth / 2 - scale * midX;
+            var ty = fullHeight / 2 - scale * midY;
+            this._setZoom(d3.zoomIdentity.translate(tx, ty).scale(scale), animated);
+        }
+        didEnterStatusNodes(statuses) { }
+        didEnterTransitions(paths) { }
+        didEnterTextDecorations(labels) { }
+        didEnterPolygonDecorations(polygons) { }
+        didEnterCircleDecorations(circles) { }
+        didEnterLineDecorations(lines) { }
+        renderStatusNodes(initial) {
+            var self = this;
+            var statuses = self.statusContainer.selectAll("g")
+                .data(self.lifecycle.statusObjects(), function (d) { return d._key; });
+            var exitStatuses = statuses.exit()
+                .classed("removing", true)
+                .transition().duration(200 * self.animationFactor)
+                .remove();
+            exitStatuses.select('circle')
+                .attr("r", self.statusCircleRadius * .8);
+            var newStatuses = statuses.enter().append("g")
+                .attr("data-key", function (d) { return d._key; })
+                .attr("id", function (d) { return 'key-'+d._key; })
+                .call(function (statuses) { self.didEnterStatusNodes(statuses); });
+            newStatuses.append("circle")
+                .attr("r", initial ? self.statusCircleRadius : self.statusCircleRadius * .8)
+                .on("click", function (d) {
+                    d3.event.stopPropagation();
+
+                    self.focusItem(d)
+                })
+            newStatuses.append("text")
+                .attr("r", initial ? self.statusCircleRadius : self.statusCircleRadius * .8)
+                .on("click", function (d) {
+                    d3.event.stopPropagation();
+                    self.clickedStatus(d);
+                })
+            if (!initial) {
+                newStatuses.transition().duration(200 * self.animationFactor)
+                    .select("circle")
+                    .attr("r", self.statusCircleRadius);
+            }
+            var allStatuses = newStatuses.merge(statuses)
+                .classed("focus", function (d) { return self.isFocused(d); })
+                .classed("focus-from", function (d) { return self.isFocusedTransition(d, true); })
+                .classed("focus-to", function (d) { return self.isFocusedTransition(d, false); });
+            allStatuses.select("circle")
+                .attr("cx", function (d) { return self.xScale(d.x); })
+                .attr("cy", function (d) { return self.yScale(d.y); })
+                .attr("fill", function (d) { return d.color; });
+            allStatuses.select("text")
+                .attr("x", function (d) { return self.xScale(d.x); })
+                .attr("y", function (d) { return self.yScale(d.y); })
+                .attr("fill", function (d) { return d3.hsl(d.color).l > 0.35 ? '#000' : '#fff'; })
+                .text(function (d) { return d.name; }).each(function () { self.truncateLabel(this); });
+        }
+        clickedStatus(d) { }
+        clickedTransition(d) { }
+        clickedDecoration(d) { }
+        truncateLabel(element) {
+            var node = d3.select(element), textLength = node.node().getComputedTextLength(), text = node.text();
+            while (textLength > this.statusCircleRadius * 1.8 && text.length > 0) {
+                text = text.slice(0, -1);
+                node.text(text + '…');
+                textLength = node.node().getComputedTextLength();
+            }
+        }
+        transitionArc(d) {
+            // c* variables are circle centers
+            // a* variables are for the arc path which is from circle edge to circle edge
+            var from = this.lifecycle.statusObjectForName(d.from), to = this.lifecycle.statusObjectForName(d.to), cx0 = this.xScale(from.x), cx1 = this.xScale(to.x), cy0 = this.yScale(from.y), cy1 = this.yScale(to.y), cdx = cx1 - cx0, cdy = cy1 - cy0;
+            // the circles on top of each other would calculate atan2(0,0) which is
+            // undefined and a little nonsensical
+            if (cdx == 0 && cdy == 0) {
+                return null;
+            }
+            var theta = Math.atan2(cdy, cdx), r = this.statusCircleRadius, ax0 = cx0 + r * Math.cos(theta), ay0 = cy0 + r * Math.sin(theta), ax1 = cx1 - (r + this.statusCircleRadiusFudge) * Math.cos(theta), ay1 = cy1 - (r + this.statusCircleRadiusFudge) * Math.sin(theta), dr = Math.abs((ax1 - ax0) * 4) + Math.abs((ay1 - ay0) * 4);
+            return "M" + ax0 + "," + ay0 + " A" + dr + "," + dr + " 0 0,1 " + ax1 + "," + ay1;
+        }
+        renderTransitions(initial) {
+            var self = this;
+            var paths = self.transitionContainer.selectAll("path")
+                .data(self.lifecycle.transitions, function (d) { return d._key; });
+            paths.exit().classed("removing", true)
+                .each(function (d) {
+                    var length = this.getTotalLength();
+                    var path = d3.select(this);
+                    path.attr("stroke-dasharray", length + " " + length)
+                        .attr("stroke-dashoffset", 0)
+                        .style("marker-end", "none")
+                        .transition().duration(200 * self.animationFactor).ease(d3.easeLinear)
+                        .attr("stroke-dashoffset", length)
+                        .remove();
+                });
+            var newPaths = paths.enter().append("path")
+                .attr("data-key", function (d) { return d._key; })
+                .on("click", function (d) {
+                    d3.event.stopPropagation();
+                    self.clickedTransition(d);
+                })
+                .call(function (paths) { self.didEnterTransitions(paths); });
+            newPaths.merge(paths)
+                .attr("d", function (d) { return self.transitionArc(d); })
+                .classed("dashed", function (d) { return d.style == 'dashed'; })
+                .classed("dotted", function (d) { return d.style == 'dotted'; })
+                .classed("focus", function (d) { return self.isFocused(d); })
+                .classed("focus-from", function (d) { return self.isFocusedTransition(d, true); })
+                .classed("focus-to", function (d) { return self.isFocusedTransition(d, false); });
+            if (!initial) {
+                newPaths.each(function (d) {
+                    var length = this.getTotalLength();
+                    var path = d3.select(this);
+                    path.attr("stroke-dasharray", length + " " + length)
+                        .attr("stroke-dashoffset", length)
+                        .style("marker-end", "none")
+                        .transition().duration(200 * self.animationFactor).ease(d3.easeLinear)
+                        .attr("stroke-dashoffset", 0)
+                        .on("end", function () {
+                            d3.select(this)
+                                .attr("stroke-dasharray", undefined)
+                                .attr("stroke-offset", undefined)
+                                .style("marker-end", undefined);
+                        });
+                });
+            }
+        }
+        _wrapTextDecoration(node, text) {
+            if (node.attr('data-text') == text) {
+                return;
+            }
+            var lines = text.split(/\n/), lineHeight = 1.1;
+            if (node.attr('data-text')) {
+                node.selectAll("*").remove();
+            }
+            node.attr('data-text', text);
+            for (var i = 0; i < lines.length; ++i) {
+                node.append("tspan").attr("dy", (i + 1) * lineHeight + "em").text(lines[i]);
+            }
+        }
+        renderTextDecorations(initial) {
+            var self = this;
+            var labels = self.decorationContainer.selectAll("text")
+                .data(self.lifecycle.decorations.text, function (d) { return d._key; });
+            labels.exit()
+                .classed("removing", true)
+                .transition().duration(200 * self.animationFactor)
+                .remove();
+            var newLabels = labels.enter().append("text")
+                .attr("data-key", function (d) { return d._key; })
+                .on("click", function (d) {
+                    d3.event.stopPropagation();
+                    self.clickedDecoration(d);
+                })
+                .call(function (labels) { self.didEnterTextDecorations(labels); });
+            if (!initial) {
+                newLabels.style("opacity", 0.15)
+                    .transition().duration(200 * self.animationFactor)
+                    .style("opacity", 1)
+                    .on("end", function () { d3.select(this).style("opacity", undefined); });
+            }
+            newLabels.merge(labels)
+                .attr("x", function (d) { return self.xScale(d.x); })
+                .attr("y", function (d) { return self.yScale(d.y); })
+                .classed("bold", function (d) { return d.bold; })
+                .classed("italic", function (d) { return d.italic; })
+                .classed("focus", function (d) { return self.isFocused(d); })
+                .each(function (d) { self._wrapTextDecoration(d3.select(this), d.text); })
+                .selectAll("tspan")
+                .attr("x", function (d) { return self.xScale(d.x); })
+                .attr("y", function (d) { return self.yScale(d.y); });
+        }
+        renderPolygonDecorations(initial) {
+            var self = this;
+            var polygons = self.decorationContainer.selectAll("polygon")
+                .data(self.lifecycle.decorations.polygon, function (d) { return d._key; });
+            polygons.exit()
+                .classed("removing", true)
+                .transition().duration(200 * self.animationFactor)
+                .remove();
+            var newPolygons = polygons.enter().append("polygon")
+                .attr("data-key", function (d) { return d._key; })
+                .on("click", function (d) {
+                    d3.event.stopPropagation();
+                    self.clickedDecoration(d);
+                })
+                .call(function (polygons) { self.didEnterPolygonDecorations(polygons); });
+            if (!initial) {
+                newPolygons.style("opacity", 0.15)
+                    .transition().duration(200 * self.animationFactor)
+                    .style("opacity", 1)
+                    .on("end", function () { d3.select(this).style("opacity", undefined); });
+            }
+            newPolygons.merge(polygons)
+                .attr("stroke", function (d) { return d.renderStroke ? d.stroke : 'none'; })
+                .classed("dashed", function (d) { return d.strokeStyle == 'dashed'; })
+                .classed("dotted", function (d) { return d.strokeStyle == 'dotted'; })
+                .attr("fill", function (d) { return d.renderFill ? d.fill : 'none'; })
+                .attr("transform", function (d) { return "translate(" + self.xScale(d.x) + ", " + self.yScale(d.y) + ")"; })
+                .attr("points", function (d) {
+                    return jQuery.map(d.points, function (p) {
+                        return [self.xScaleZero(p.x), self.yScaleZero(p.y)].join(",");
+                    }).join(" ");
+                })
+                .classed("focus", function (d) { return self.isFocused(d); });
+        }
+        renderCircleDecorations(initial) {
+            var self = this;
+            var circles = self.decorationContainer.selectAll("circle.decoration")
+                .data(self.lifecycle.decorations.circle, function (d) { return d._key; });
+            circles.exit()
+                .classed("removing", true)
+                .transition().duration(200 * self.animationFactor)
+                .remove();
+            var newCircles = circles.enter().append("circle")
+                .classed("decoration", true)
+                .attr("data-key", function (d) { return d._key; })
+                .on("click", function (d) {
+                    d3.event.stopPropagation();
+                    self.clickedDecoration(d);
+                })
+                .call(function (circles) { self.didEnterCircleDecorations(circles); });
+            if (!initial) {
+                newCircles.style("opacity", 0.15)
+                    .transition().duration(200 * self.animationFactor)
+                    .style("opacity", 1)
+                    .on("end", function () { d3.select(this).style("opacity", undefined); });
+            }
+            newCircles.merge(circles)
+                .attr("stroke", function (d) { return d.renderStroke ? d.stroke : 'none'; })
+                .classed("dashed", function (d) { return d.strokeStyle == 'dashed'; })
+                .classed("dotted", function (d) { return d.strokeStyle == 'dotted'; })
+                .attr("fill", function (d) { return d.renderFill ? d.fill : 'none'; })
+                .attr("cx", function (d) { return self.xScale(d.x); })
+                .attr("cy", function (d) { return self.yScale(d.y); })
+                .attr("r", function (d) { return d.r; })
+                .classed("focus", function (d) { return self.isFocused(d); });
+        }
+        renderLineDecorations(initial) {
+            var self = this;
+            var lines = self.decorationContainer.selectAll("line")
+                .data(self.lifecycle.decorations.line, function (d) { return d._key; });
+            lines.exit()
+                .classed("removing", true)
+                .transition().duration(200 * self.animationFactor)
+                .remove();
+            var newLines = lines.enter().append("line")
+                .attr("data-key", function (d) { return d._key; })
+                .on("click", function (d) {
+                    d3.event.stopPropagation();
+                    self.clickedDecoration(d);
+                })
+                .call(function (lines) { self.didEnterLineDecorations(lines); });
+            if (!initial) {
+                newLines.each(function (d) {
+                    var length = Math.sqrt((d.points[1].x - d.points[0].x) ** 2 + (d.points[1].y - d.points[0].y) ** 2);
+                    var path = d3.select(this);
+                    path.attr("stroke-dasharray", length + " " + length)
+                        .attr("stroke-dashoffset", length)
+                        .style("marker-start", "none")
+                        .style("marker-end", "none")
+                        .transition().duration(200 * self.animationFactor).ease(d3.easeLinear)
+                        .attr("stroke-dashoffset", 0)
+                        .on("end", function () {
+                            d3.select(this)
+                                .attr("stroke-dasharray", undefined)
+                                .attr("stroke-offset", undefined)
+                                .style("marker-start", undefined)
+                                .style("marker-end", undefined);
+                        });
+                });
+            }
+            newLines.merge(lines)
+                .classed("dashed", function (d) { return d.style == 'dashed'; })
+                .classed("dotted", function (d) { return d.style == 'dotted'; })
+                .attr("transform", function (d) { return "translate(" + self.xScale(d.x) + ", " + self.yScale(d.y) + ")"; })
+                .attr("x1", function (d) { return self.xScaleZero(d.points[0].x); })
+                .attr("y1", function (d) { return self.yScaleZero(d.points[0].y); })
+                .attr("x2", function (d) { return self.xScaleZero(d.points[1].x); })
+                .attr("y2", function (d) { return self.yScaleZero(d.points[1].y); })
+                .classed("focus", function (d) { return self.isFocused(d); })
+                .attr("marker-start", function (d) { return d.startMarker == 'none' ? undefined : "url(#line_marker_" + d.startMarker + ")"; })
+                .attr("marker-end", function (d) { return d.endMarker == 'none' ? undefined : "url(#line_marker_" + d.endMarker + ")"; });
+        }
+        renderDecorations(initial) {
+            this.renderPolygonDecorations(initial);
+            this.renderCircleDecorations(initial);
+            this.renderLineDecorations(initial);
+            this.renderTextDecorations(initial);
+        }
+        renderDisplay(initial) {
+            this.renderTransitions(initial);
+            this.renderStatusNodes(initial);
+            this.renderDecorations(initial);
+        }
+        centerOnItem(item, animated) {
+            var rect = this.svg.node().getBoundingClientRect();
+            var scale = this._zoomIdentityScale;
+            var x = rect.width / 2 - this.xScale(item.x) * scale;
+            var y = rect.height / 2 - this.yScale(item.y) * scale;
+            this._zoomIdentity = d3.zoomIdentity.translate(x, y).scale(this._zoomIdentityScale);
+            this.resetZoom(animated);
+        }
+        defocus() {
+            this._focusItem = null;
+            this.svg.classed("has-focus", false)
+                .attr('data-focus-type', undefined);
+        }
+        focusItem(d) {
+            this.defocus();
+            this._focusItem = d;
+            this.svg.classed("has-focus", true)
+                .attr('data-focus-type', d._type);
+        }
+        focusOnStatus(statusName, center, animated) {
+            if (!statusName) {
+                return;
+            }
+            var meta = this.lifecycle.statusObjectForName(statusName);
+            this.focusItem(meta);
+            if (center) {
+                this.centerOnItem(meta, animated);
+            }
+        }
+        isFocused(d) {
+            if (!this._focusItem) {
+                return false;
+            }
+            return this._focusItem._key == d._key;
+        }
+        isFocusedTransition(d, isFrom) {
+            if (!this._focusItem) {
+                return false;
+            }
+            if (d._type == 'status') {
+                if (this._focusItem._type == 'status') {
+                    if (isFrom) {
+                        return this.lifecycle.hasTransition(d.name, this._focusItem.name);
+                    }
+                    else {
+                        return this.lifecycle.hasTransition(this._focusItem.name, d.name);
+                    }
+                }
+                else if (this._focusItem._type == 'transition') {
+                    if (isFrom) {
+                        return this._focusItem.from == d.name;
+                    }
+                    else {
+                        return this._focusItem.to == d.name;
+                    }
+                }
+            }
+            else if (d._type == 'transition') {
+                if (this._focusItem._type == 'status') {
+                    if (isFrom) {
+                        return d.to == this._focusItem.name;
+                    }
+                    else {
+                        return d.from == this._focusItem.name;
+                    }
+                }
+            }
+            return false;
+        }
+
+        initializeViewer(node, name, config, focusStatus) {
+            var self = this;
+            self.container = jQuery(node);
+            self.svg = d3.select(node).select('svg');
+            self.transformContainer = self.svg.select('g.transform');
+            self.transitionContainer = self.svg.select('g.transitions');
+            self.statusContainer = self.svg.select('g.statuses');
+            self.decorationContainer = self.svg.select('g.decorations');
+            self._xScale = self.createScale(self.width, self.padding);
+            self._yScale = self.createScale(self.height, self.padding);
+            self._xScaleZero = self.createScale(self.width, 0);
+            self._yScaleZero = self.createScale(self.height, 0);
+            // zoom in a bit, but not too much
+            var scale = self.svg.node().getBoundingClientRect().width / self.width;
+            scale = scale ** .6;
+            self._zoomIdentityScale = scale;
+            self._zoomIdentity = self._currentZoom = d3.zoomIdentity.scale(self._zoomIdentityScale);
+            RT.Lifecycle.name = name;
+            self.lifecycle = RT.Lifecycle;
+            self.lifecycle.initializeFromConfig(config);
+            // need to start with zoom control on to set the initial zoom
+            this.zoomControl = true;
+            self.addZoomBehavior();
+            if (self.container.hasClass('center-status')) {
+                self.focusOnStatus(focusStatus, true, false);
+                self.renderDisplay(true);
+            }
+            else {
+                self.focusOnStatus(focusStatus, false, false);
+                self.renderDisplay(true);
+                if (self.container.hasClass('center-fit')) {
+                    self.zoomToFit(false);
+                }
+                else if (self.container.hasClass('center-origin')) {
+                    self.resetZoom(false);
+                }
+            }
+            self._zoomIdentity = self._currentZoom;
+            self.zoomControl = self.container.hasClass('zoomable');
+            self.container.on('click', 'button.zoom-in', function (e) {
+                e.preventDefault();
+                self.zoomScale(1.25, true);
+            });
+            self.container.on('click', 'button.zoom-out', function (e) {
+                e.preventDefault();
+                self.zoomScale(.75, true);
+            });
+            self.container.on('click', 'button.zoom-reset', function (e) {
+                e.preventDefault();
+                self.resetZoom(true);
+            });
+        };
+    }
+
+
 });
diff --git a/share/static/js/lifecycleui-model.js b/share/static/js/lifecycleui-model.js
index 74c37c3a3..ca3f1abe6 100644
--- a/share/static/js/lifecycleui-model.js
+++ b/share/static/js/lifecycleui-model.js
@@ -16,6 +16,15 @@ jQuery(function () {
             this._undoState = { undoStack: [], redoStack: [] };
             this._keyMap = {};
             this._statusMeta = {};
+
+            // Viewer
+            this.width = 809;
+            this.height = 500;
+            this.statusCircleRadius = 35;
+            this.statusCircleRadiusFudge = 4; // required to give room for the arrowhead
+            this.gridSize = 10;
+            this.padding = this.statusCircleRadius * 2;
+            this.animationFactor = 1; // bump this to 10 debug JS animations
         }
         initializeFromConfig(config) {
             var self = this;
@@ -686,8 +695,13 @@ jQuery(function () {
             }
             return jQuery.unique(rights.sort());
         }
-    }
-;
+    };
+
+
+
+
+
+    
 
     Lifecycle.prototype._initialPointsForPolygon = {
         Line: [
@@ -706,7 +720,6 @@ jQuery(function () {
             {x: -600, y:  600}
         ]
     };
-
     RT.Lifecycle = new Lifecycle();
 });
 
diff --git a/share/static/js/lifecycleui-viewer.js b/share/static/js/lifecycleui-viewer.js
index 93b725238..ce68fd86a 100644
--- a/share/static/js/lifecycleui-viewer.js
+++ b/share/static/js/lifecycleui-viewer.js
@@ -90,14 +90,21 @@ jQuery(function () {
                 .attr("r", self.statusCircleRadius * .8);
             var newStatuses = statuses.enter().append("g")
                 .attr("data-key", function (d) { return d._key; })
+                .attr("id", function (d) { return 'key-'+d._key; })
+                .call(function (statuses) { self.didEnterStatusNodes(statuses); });
+            newStatuses.append("circle")
+                .attr("r", initial ? self.statusCircleRadius : self.statusCircleRadius * .8)
                 .on("click", function (d) {
                     d3.event.stopPropagation();
-                    self.clickedStatus(d, this);
+
+                    self.focusItem(d)
+                })
+            newStatuses.append("text")
+                .attr("r", initial ? self.statusCircleRadius : self.statusCircleRadius * .8)
+                .on("click", function (d) {
+                    d3.event.stopPropagation();
+                    self.clickedStatus(d);
                 })
-                .call(function (statuses) { self.didEnterStatusNodes(statuses); });
-            newStatuses.append("circle")
-                .attr("r", initial ? self.statusCircleRadius : self.statusCircleRadius * .8);
-            newStatuses.append("text");
             if (!initial) {
                 newStatuses.transition().duration(200 * self.animationFactor)
                     .select("circle")
@@ -472,6 +479,10 @@ jQuery(function () {
                 e.preventDefault();
                 self.zoomScale(.75, true);
             });
+            self.container.on('click', 'button.zoom-reset', function (e) {
+                e.preventDefault();
+                self.resetZoom(true);
+            });
         }
     };
 

commit 6ac265b756942c18ea553ed8684904af74125842
Author: Craig Kaiser <craig at bestpractical.com>
Date:   Fri Oct 18 17:31:56 2019 -0400

    Remove extra componenets and make Viewer/Editor one file

diff --git a/share/html/Elements/Lifecycle/Graph b/share/html/Elements/Lifecycle/Graph
index 99f0e4814..e4d617578 100644
--- a/share/html/Elements/Lifecycle/Graph
+++ b/share/html/Elements/Lifecycle/Graph
@@ -47,13 +47,7 @@
 %# END BPS TAGGED BLOCK }}}
 <div class="lifecycle-ui<% $Editing ? ' editing' : '' %><% $Interactive ? ' interactive' : '' %><% $Zoomable ? ' zoomable' : '' %> center-<% $Center || 'origin' %>" id="lifecycle-<% $id %>">
     <div class="lifecycle-view">
-      <div class="overlay-buttons">
-          <button class="zoom zoom-in">+</button>
-          <button class="zoom zoom-reset">0</button>
-          <button class="zoom zoom-out">-</button>
-      </div>
       <svg>
-          <& GraphExtras, %ARGS &>
           <g class="transform">
             <g class="decorations"></g>
             <g class="transitions"></g>
@@ -81,7 +75,6 @@
                 var editor = new RT.Editor( container, name, config, ticketStatus );
 
                 editor.initializeEditor(container, name, config, ticketStatus);
-                editor.initializeViewer(container, name, config, ticketStatus);
             });
         });
     </script>
diff --git a/share/static/css/base/lifecycleui-editor.css b/share/static/css/base/lifecycleui-editor.css
index 25c1cfec8..8fa65dbae 100644
--- a/share/static/css/base/lifecycleui-editor.css
+++ b/share/static/css/base/lifecycleui-editor.css
@@ -143,3 +143,16 @@
 .lifecycle-ui .inspector tr.section td {
     padding-top: 1em;
 }
+
+
+
+
+
+/* NEW */
+g text {
+    cursor: text;
+}
+
+g circle.node-selected {
+    fill: #98b9eb !important;
+  }
\ No newline at end of file
diff --git a/share/static/js/lifecycleui-editor.js b/share/static/js/lifecycleui-editor.js
index 65b8783a7..4841f0f78 100644
--- a/share/static/js/lifecycleui-editor.js
+++ b/share/static/js/lifecycleui-editor.js
@@ -19,7 +19,26 @@ jQuery(function () {
 
         initializeEditor(node, name, config, focusStatus) {
             var self = this;
-            self.initializeViewer(node, name, config, focusStatus);
+
+            self.container = jQuery(node);
+            self.svg = d3.select(node).select('svg');
+            self.transformContainer = self.svg.select('g.transform');
+            self.transitionContainer = self.svg.select('g.transitions');
+            self.statusContainer = self.svg.select('g.statuses');
+            self.decorationContainer = self.svg.select('g.decorations');
+            self._xScale = self.createScale(self.width, self.padding);
+            self._yScale = self.createScale(self.height, self.padding);
+            self._xScaleZero = self.createScale(self.width, 0);
+            self._yScaleZero = self.createScale(self.height, 0);
+            // zoom in a bit, but not too much
+            var scale = self.svg.node().getBoundingClientRect().width / self.width;
+            scale = scale ** .6;
+            self._zoomIdentityScale = scale;
+            self._zoomIdentity = self._currentZoom = d3.zoomIdentity.scale(self._zoomIdentityScale);
+            RT.Lifecycle.name = name;
+            self.lifecycle = RT.Lifecycle;
+            self.lifecycle.initializeFromConfig(config);
+
             self.container.closest('form[name=ModifyLifecycle]').submit(function (e) {
                 var config = self.lifecycle.exportAsConfiguration();
                 var form = jQuery(this);
@@ -44,6 +63,7 @@ jQuery(function () {
                     self.defocus();
                 }
             })
+            self.renderDisplay();
         }
 
         clickedStatus(d) {
@@ -54,6 +74,8 @@ jQuery(function () {
 
             let current_val = d.name;
             d.name = '';
+            // Defocus so that a 'd' key doesn't delete our node
+            self.defocus();
             self.renderDisplay();
 
             var frm = g.append("foreignObject");
@@ -119,8 +141,8 @@ jQuery(function () {
         }
         viewportCenterPoint() {
             var rect = this.svg.node().getBoundingClientRect();
-            var x = (rect.width / 2 - this._currentZoom.x) / this._currentZoom.k;
-            var y = (rect.height / 2 - this._currentZoom.y) / this._currentZoom.k;
+            var x = 0;
+            var y = 0;
             return [this.xScaleInvert(x), this.yScaleInvert(y)];
         }
         addNewStatus() {
@@ -201,20 +223,6 @@ jQuery(function () {
                     .style("opacity", 1)
                     .on("end", function () { d3.select(this).style("opacity", undefined); });
             }
-            newRects.merge(rects)
-                .classed("focus", function (d) { return self.isFocused(d); })
-                .each(function (d) {
-                    var rect = d3.select(this);
-                    var label = self.decorationContainer.select("text[data-key='" + d._key + "']");
-                    var bbox = label.node().getBoundingClientRect();
-                    var width = bbox.width / self._currentZoom.k;
-                    var height = bbox.height / self._currentZoom.k;
-                    var padding = 5 / self._currentZoom.k;
-                    rect.attr("x", self.xScale(d.x) - padding)
-                        .attr("y", self.yScale(d.y) - padding)
-                        .attr("width", width + padding * 2)
-                        .attr("height", height + padding * 2);
-                });
         }
 
         didBeginDrag(d, node) { }
@@ -274,61 +282,13 @@ jQuery(function () {
         yScaleInvert(y) { return Math.floor(this._yScale.invert(y)); }
         xScaleZeroInvert(x) { return Math.floor(this._xScaleZero.invert(x)); }
         yScaleZeroInvert(y) { return Math.floor(this._yScaleZero.invert(y)); }
-        addZoomBehavior() {
-            var self = this;
-            self._zoom = d3.zoom()
-                .scaleExtent([.3, 2])
-                .on("zoom", function () {
-                    if (self.zoomControl) {
-                        self.didZoom();
-                    }
-                });
-            self.svg.call(self._zoom);
-        }
-        didZoom() {
-            this._currentZoom = d3.event.transform;
-            this.transformContainer.attr("transform", d3.event.transform);
-        }
-        zoomScale(scaleBy, animated) {
-            if (animated) {
-                this.svg.transition()
-                    .duration(350 * this.animationFactor)
-                    .call(this._zoom.scaleBy, scaleBy);
-            }
-            else {
-                this.svg.call(this._zoom.scaleBy, scaleBy);
-            }
-        }
-        _setZoom(zoom, animated) {
-            if (animated) {
-                this.svg.transition()
-                    .duration(750 * this.animationFactor)
-                    .call(this._zoom.transform, zoom);
-            }
-            else {
-                this.svg.call(this._zoom.transform, zoom);
-            }
-        }
-        resetZoom(animated) {
-            this._setZoom(this._zoomIdentity, animated);
-        }
-        zoomToFit(animated) {
-            var bounds = this.transformContainer.node().getBBox();
-            var parent = this.transformContainer.node().parentElement;
-            var fullWidth = parent.clientWidth || parent.parentNode.clientWidth, fullHeight = parent.clientHeight || parent.parentNode.clientHeight;
-            var width = bounds.width, height = bounds.height;
-            var midX = bounds.x + width / 2, midY = bounds.y + height / 2;
-            var scale = .9 / Math.max(width / fullWidth, height / fullHeight);
-            var tx = fullWidth / 2 - scale * midX;
-            var ty = fullHeight / 2 - scale * midY;
-            this._setZoom(d3.zoomIdentity.translate(tx, ty).scale(scale), animated);
-        }
-        didEnterStatusNodes(statuses) { }
+
+        // Should we get rid of these
         didEnterTransitions(paths) { }
-        didEnterTextDecorations(labels) { }
         didEnterPolygonDecorations(polygons) { }
         didEnterCircleDecorations(circles) { }
         didEnterLineDecorations(lines) { }
+
         renderStatusNodes(initial) {
             var self = this;
             var statuses = self.statusContainer.selectAll("g")
@@ -354,6 +314,7 @@ jQuery(function () {
                 .attr("r", initial ? self.statusCircleRadius : self.statusCircleRadius * .8)
                 .on("click", function (d) {
                     d3.event.stopPropagation();
+
                     self.clickedStatus(d);
                 })
             if (!initial) {
@@ -375,9 +336,6 @@ jQuery(function () {
                 .attr("fill", function (d) { return d3.hsl(d.color).l > 0.35 ? '#000' : '#fff'; })
                 .text(function (d) { return d.name; }).each(function () { self.truncateLabel(this); });
         }
-        clickedStatus(d) { }
-        clickedTransition(d) { }
-        clickedDecoration(d) { }
         truncateLabel(element) {
             var node = d3.select(element), textLength = node.node().getComputedTextLength(), text = node.text();
             while (textLength > this.statusCircleRadius * 1.8 && text.length > 0) {
@@ -613,24 +571,26 @@ jQuery(function () {
             this.renderStatusNodes(initial);
             this.renderDecorations(initial);
         }
-        centerOnItem(item, animated) {
-            var rect = this.svg.node().getBoundingClientRect();
-            var scale = this._zoomIdentityScale;
-            var x = rect.width / 2 - this.xScale(item.x) * scale;
-            var y = rect.height / 2 - this.yScale(item.y) * scale;
-            this._zoomIdentity = d3.zoomIdentity.translate(x, y).scale(this._zoomIdentityScale);
-            this.resetZoom(animated);
-        }
         defocus() {
-            this._focusItem = null;
+            if ( this._focusItem ) {
+                // TODO Make this abstracted
+                let g          = d3.select(d3.select('#key-'+this._focusItem._key)._groups[0][0]);
+                let circle     = d3.select(g)._groups[0][0].select('circle');
+
+                circle.classed("node-selected", false);
+            }
             this.svg.classed("has-focus", false)
                 .attr('data-focus-type', undefined);
+            this._focusItem = null;
         }
         focusItem(d) {
             this.defocus();
             this._focusItem = d;
-            this.svg.classed("has-focus", true)
-                .attr('data-focus-type', d._type);
+
+            let g          = d3.select(d3.select('#key-'+d._key)._groups[0][0]);
+            let circle     = d3.select(g)._groups[0][0].select('circle');
+
+            circle.classed("node-selected", true);
         }
         focusOnStatus(statusName, center, animated) {
             if (!statusName) {
@@ -682,60 +642,6 @@ jQuery(function () {
             }
             return false;
         }
-
-        initializeViewer(node, name, config, focusStatus) {
-            var self = this;
-            self.container = jQuery(node);
-            self.svg = d3.select(node).select('svg');
-            self.transformContainer = self.svg.select('g.transform');
-            self.transitionContainer = self.svg.select('g.transitions');
-            self.statusContainer = self.svg.select('g.statuses');
-            self.decorationContainer = self.svg.select('g.decorations');
-            self._xScale = self.createScale(self.width, self.padding);
-            self._yScale = self.createScale(self.height, self.padding);
-            self._xScaleZero = self.createScale(self.width, 0);
-            self._yScaleZero = self.createScale(self.height, 0);
-            // zoom in a bit, but not too much
-            var scale = self.svg.node().getBoundingClientRect().width / self.width;
-            scale = scale ** .6;
-            self._zoomIdentityScale = scale;
-            self._zoomIdentity = self._currentZoom = d3.zoomIdentity.scale(self._zoomIdentityScale);
-            RT.Lifecycle.name = name;
-            self.lifecycle = RT.Lifecycle;
-            self.lifecycle.initializeFromConfig(config);
-            // need to start with zoom control on to set the initial zoom
-            this.zoomControl = true;
-            self.addZoomBehavior();
-            if (self.container.hasClass('center-status')) {
-                self.focusOnStatus(focusStatus, true, false);
-                self.renderDisplay(true);
-            }
-            else {
-                self.focusOnStatus(focusStatus, false, false);
-                self.renderDisplay(true);
-                if (self.container.hasClass('center-fit')) {
-                    self.zoomToFit(false);
-                }
-                else if (self.container.hasClass('center-origin')) {
-                    self.resetZoom(false);
-                }
-            }
-            self._zoomIdentity = self._currentZoom;
-            self.zoomControl = self.container.hasClass('zoomable');
-            self.container.on('click', 'button.zoom-in', function (e) {
-                e.preventDefault();
-                self.zoomScale(1.25, true);
-            });
-            self.container.on('click', 'button.zoom-out', function (e) {
-                e.preventDefault();
-                self.zoomScale(.75, true);
-            });
-            self.container.on('click', 'button.zoom-reset', function (e) {
-                e.preventDefault();
-                self.resetZoom(true);
-            });
-        };
     }
 
-
 });

-----------------------------------------------------------------------


More information about the rt-commit mailing list