web/js/panel.js

Summary

Maintainability
F
3 mos
Test Coverage
/**********************************************************************************************
 *  panel Servlet - Draw JMRI panels on browser screen
 *    Retrieves panel xml from JMRI and builds panel client-side from that xml, including
 *    click functions.  Sends and listens for changes to panel elements using the JSON WebSocket server.
 *    If no parm passed, page will list links to available panels.
 *  Approach:  Read panel's xml and create widget objects in the browser with all needed attributes.
 *    There are 4 "widgetFamily"s: text, icon, drawn and switch.  States are handled by storing member's
 *    iconX, textX, cssX where X is the state.  The corresponding members are "shown" whenever the state changes.
 *    CSS classes are used throughout to attach events to correct widgets, as well as control appearance.
 *    The JSON type is used to send changes to JSON server and to listen for changes made elsewhere.
 *    Drawn widgets are handled by drawing directly on the javascript "canvas" layer.
 *    Switch widgets are handled by drawing directly on an individual javascript "canvas", placed in a flexbox layout.
 *
 *  See java/src/jmri/server/json/JsonNamedBeanSocketService.java#onMessage() for GET method that adds a listener.
 *  See JMRI Web Server - Panel Servlet in help/en/html/web/PanelServlet.shtml for an example description of
 *  the interaction between the Web Servlets, the Web Browser and the JMRI application.
 *
 *  TODO: show error dialog while retrying connection
 *  TODO: add Cancel button to return to home page on errors (not found, etc.)
 *  TODO: handle "&" in usernames (see Indicator Demo 00.xml)
 *  TODO: update drawn track on color and width changes (would need to create system objects to reflect these chgs)
 *  TODO: research movement of locoicons ("promote" locoicon to system entity in JMRI?, add panel-level listeners?)
 *  TODO: deal with mouseleave, mouseout, touchout, etc. Slide off Stop button on rb1 for example.
 *  TODO: handle inputs/selection on various memory widgets
 *  TODO: alignment of memoryIcons without fixed width is very different.  Recommended workaround is to use fixed width.
 *  TODO:    ditto for sensorIcons with text
 *  TODO: add support for slipturnouticon (one2beros)
 *  TODO: handle (and test) disableWhenOccupied for layoutslip
 *  TODO: handle block color and track widths for turntable raytracks
 *
 **********************************************************************************************/

var log = new Logger();

//persistent (global) variables
var $gWidgets = {};         //array of all widget objects, key=CSSId
var $gPanelList = {};       //store list of available panels
var $gPanel = {};           //store overall panel info
var whereUsed = {};         //associative array of array of elements indexed by systemName or userName
var audioIconIDs = {};      //associative array of audio icons
var occupancyNames = {};    //associative array of array of elements indexed by occupancy sensor name
var $oblockNames = {};      //associative array of array of elements indexed by occupancy block name (CPE panels)
var $gPts = {};             //array of all points, key="pointname.pointtype" (used for layoutEditor panels)
var $gBlks = {};            //array of all blocks, key="blockname" (used for layoutEditor panels)
var $gCtx;                  //persistent context of canvas layer
var $gDashArray = [12, 12]; //on,off of dashed lines
var $rows = 1;              //persistent storage of shared switchboard property number of rows, if 0 use autoRows
var $total = 1;             //persistent storage of shared switchboard property total number of items displayed
var $autoRows = 0;
var $activeColor = 'red';
var $inactiveColor = 'gray';
var $unknownColor = 'gray';
var $showUserName = 'no';
var DOWNEVENT = 'touchstart mousedown';  // check both touch and mouse events
var UPEVENT = 'touchend mouseup';
var SIZE = 3;               // default factor for circles

var UNKNOWN = '0';          // constants to match JSON Server state names
var ACTIVE = '2';
var CLOSED = '2';
var INACTIVE = '4';
var THROWN = '4';
var INCONSISTENT = '8';

var ALLOCATED = 0x10;       // constants to match JSON Server oblock status names
var RUNNING = 0x20;         // Oblock that running train has reached
var OUT_OF_SERVICE = 0x40;  // Oblock that should not be used
var TRACK_ERROR = 0x80;     // Oblock has Error

var CLOSEDCLOSED = '5';     // constants for slipturnouticon
var CLOSEDTHROWN = '7';
var THROWNCLOSED = '9';
var THROWNTHROWN = '11';

var PT_CEN = ".POS_POINT";  // named constants for point types
var PT_A = ".TURNOUT_A";
var PT_B = ".TURNOUT_B";
var PT_C = ".TURNOUT_C";
var PT_D = ".TURNOUT_D";

var LEVEL_XING_A = ".LEVEL_XING_A";
var LEVEL_XING_B = ".LEVEL_XING_B";
var LEVEL_XING_C = ".LEVEL_XING_C";
var LEVEL_XING_D = ".LEVEL_XING_D";

var SLIP_A = ".SLIP_A";
var SLIP_B = ".SLIP_B";
var SLIP_C = ".SLIP_C";
var SLIP_D = ".SLIP_D";

var STATE_AC = 0x02;
var STATE_BD = 0x04;
var STATE_AD = 0x06;
var STATE_BC = 0x08;

var DARK = 0x00;  //named constants for signalhead states
var RED = 0x01;
var FLASHRED = 0x02;
var YELLOW = 0x04;
var FLASHYELLOW = 0x08;
var GREEN = 0x10;
var FLASHGREEN = 0x20;
var LUNAR = 0x40;
var FLASHLUNAR = 0x80;
var HELD = 0x0100;  //additional to deal with "Held" pseudo-state

var RH_TURNOUT = "RH_TURNOUT"; //named constants for turnout types
var LH_TURNOUT = "LH_TURNOUT";
var WYE_TURNOUT = "WYE_TURNOUT";
var DOUBLE_XOVER = "DOUBLE_XOVER";
var RH_XOVER = "RH_XOVER";
var LH_XOVER = "LH_XOVER";
var SINGLE_SLIP = "SINGLE_SLIP";
var DOUBLE_SLIP = "DOUBLE_SLIP";

var jmri = null;

var jmri_logging = false;

/******************************************************************
*  ======= Debug functions =======
*/

// log object properties
function $logProperties(obj) {
    if (jmri_logging) {
        var $propList = "";
        for (var $propName in obj) {
            if (isDefined(obj[$propName])) {
                $propList += ($propName + "='" + obj[$propName] + "', ");
            }
        }
        log.log("$logProperties(obj): " + $propList + ".");
    }
}

function isUndefined(x) {
    return (typeof x === "undefined");
}

function isDefined(x) {
    return (typeof x !== "undefined");
}

/******************************************************************
*  ======= Primary functions =======
*/

// request the panel xml from the server, and set up callback to process the response
var requestPanelXML = function(panelName) {
    $("#activity-alert").addClass("show").removeClass("hidden");
    $.ajax({
        type: "GET",
        url: "/panel/" + panelName + "?format=xml", // request proper url
        success: function(data, textStatus, jqXHR) {
            processPanelXML(data, textStatus, jqXHR);
            setTitle($gPanel["name"]);  // set final title once load completes, helps with testing
            // set new attribute data-panel-name on the panel-area div to the panel name so that a user's css can use it.
            $("#panel-area").attr("data-panel-name", $gPanel["name"]);
        },
        error: function( jqXHR, textStatus, errorThrown) {
            alert("Error retrieving panel xml from server.  Please press OK to retry.\n\nDetails: " +
            textStatus + " - " + errorThrown);
            window.location = window.location.pathname;
        },
        async: true,
        timeout: 15000, // very long timeout, since this can be a slow process for complicated panels
        dataType: "xml"
    });
};

// process the response returned for the requestPanelXML command
function processPanelXML($returnedData, $success, $xhr) {

    $('div#messageText').text("rendering panel from xml, please wait...");
    $("#activity-alert").addClass("show").removeClass("hidden");
    var $xml = $($returnedData);  //jQuery-ize returned data for easier access

    //remove whitespace
    $xml.xmlClean();

    //get the panel-level values from the xml
    var $panel = $xml.find('panel');
    $($panel[0].attributes).each(function() {
        $gPanel[this.name] = this.value;
    });
    $("#panel-area").width($gPanel.panelwidth);
    $("#panel-area").height($gPanel.panelheight);

    // insert the canvas layer and set up context used by LayoutEditor "drawn" objects, set some defaults
    if ($gPanel.paneltype == "LayoutPanel") {
        createPanelCanvas(); //insure canvas layer is available for drawing
    }

    // set up context used by SwitchboardEditor "beanswitch" objects, set some defaults
    var $swWidthPx;
    var $swHeightPx;
    if ($gPanel.paneltype == "Switchboard") {
        $("#panel-area").width("100%"); // reset to fill the (mobile) screen
        $("#panel-area").height("100%"); // reset to fill the (mobile) screen
        // background color already set for #panel-area, inherited
        $activeColor = $gPanel.activecolor;
        $inactiveColor = $gPanel.inactivecolor;
        $showUserName = $gPanel.showusername;
        $total = Number($gPanel.total);
        $rows = Number($gPanel.rows);
        if ($rows == 0) { // AutoRows set, automatically choose grid showing largest tiles using flexbox
            $("#panel-area").css({'display': "flex", 'flex-flow': "row wrap"})
            $autoRows = 1;
            $rows = autoRows(window.screen.width, window.screen.height - 200); // use (mobile) screen size, leave space for header
            // check browser window (window.innerWidth) size vs whole screen (window.screen.width)
            $swWidth = Math.ceil(0.95*Math.min(window.screen.width, window.innerWidth)*Math.max(0.01, Math.min(1, 1/(Math.ceil($total/$rows)))));
            $swWidth = Math.max(Math.min($swWidth, 200), 70); // catch extreme width result
            $swHeight = Math.ceil(0.9*Math.min(window.screen.height, window.innerHeight)/Math.max(0.01, $rows));
            $swHeight = Math.max($swHeight, 90); // minimum height to display 2 labels
            // 0.9 to leave room for the Switchboard name label at top
        } else {
           $swWidth = Math.ceil(0.95*($gPanel.panelwidth)*Math.max(0.01, Math.min(1, 1/(Math.ceil($total/$rows)))));
            // calculate from jmri rows number, 95pc to fit on screen
            // Math.min(1,... to prevent >100% width calc (when hide unconnected selected)
            // Math.max(0.001,... to prevent 0 width in case 0 items are connected
            // 1/Math.ceil($total/$rows) to account for unused tiles:
            // include RxC unused cells in calc: for 22 switches we need at least 24 tiles (4x6, 3x8, 2x12 etc)
            $swHeight = Math.ceil(0.9*$gPanel.panelheight/Math.max(0.01, $rows));
            // Math.max(0.001,... to prevent 0 division in case 0 items are connected
        }
        var onOffSpans = "";
        if (($gPanel.type == "L") && ($gPanel.controlling == "yes")) {
            // handlers to switch on/off, I18N
            onOffSpans = "&nbsp;<span id='allOff' class='lightswitch'>All Off</span>&nbsp;<span id='allOn' class='lightswitch'>All On</span>";
            // handlers added later
        }
        // add short banner at top of Swb
        $("#panel-area").append("<div id='name' class='show' style='color: " + $gPanel.defaulttextcolor +
            ";'>&nbsp;Switchboard &quot;" + $gPanel.name + "&quot; (conn: " +
            $gPanel.connection + ",  type: " + $gPanel.type + ")" + onOffSpans + "</div>"); // TODO I18N
    }

    // process all elements in the panel xml, drawing them on screen, and building persistent array of widgets
    $panel.contents().each(
        function() {
            var $widget = new Array();
            $widget['widgetType'] = this.nodeName;
            $widget['scale'] = "1"; //default to no scale
            $widget['degrees'] = 0; //default to no rotation
            $widget['rotation'] = 0; // default to no rotation
            // convert attributes to an object array
            $(this.attributes).each(function() {
                $widget[this.name] = this.value;
            });
            //default various css attributes to not-set, then set in later code as needed
            var $hoverText = "";

            // add and normalize the various type-unique values, from the various spots they are stored
            // icon names based on states returned from JSON server,
            $widget['state'] = UNKNOWN; //initial state is unknown
            $widget.jsonType = ""; //default to no JSON type (avoid undefined)

            if (isUndefined($widget["systemName"]) && isDefined($widget["id"])) {
                $widget.systemName = $widget["id"]; //set systemName from id if missing
            }
            $widget["id"] = "widget-" + $gUnique(); //set id to a unique value (since same element can be in multiple widgets)
            $widget['widgetFamily'] = $getWidgetFamily($widget, this);
            var $jc = "";
            if (isDefined($widget["class"])) {
                var $ta = $widget["class"].split('.'); //get last part of java class name for a css class
                $jc = $ta[$ta.length - 1];
            }
            if ($widget.widgetFamily == "switch") {
                $widget['classes'] = $widget.widgetType + " " + $jc; // rest of classes are not used on a switch
            } else {
                $widget['classes'] = $widget.widgetType + " " + $widget.widgetFamily + " rotatable " + $jc;
            }
            if ($widget.momentary == "true") {
                $widget.classes += " momentary ";
            }
            if ($widget.hidden == "yes") {
                $widget.classes += " hidden ";
            }
            // set additional values in this widget
            switch ($widget.widgetFamily) {
                case "icon" :
                    $widget['styles'] = $getTextCSSFromObj($widget);
                    switch ($widget.widgetType) {
                        case "positionablelabel" :
                            $widget['icon' + UNKNOWN] = $(this).find('icon').attr('url');
                            $widget['rotation'] = $(this).find('icon').find('rotation').text() * 1;
                            $widget['degrees'] = ($(this).find('icon').attr('degrees') * 1) - ($widget.rotation * 90);
                            $widget['scale'] = $(this).find('icon').attr('scale');
                            break;
                        case "audioicon" :
                            $widget.jsonType = 'audio'; // JSON object type
                            $widget['identity'] = $(this).find('Identity').text();
                            audioIconIDs['audioicon:'+$widget['identity']] = $widget;   // Ensure the key is a string, not a number
                            $widget['icon' + UNKNOWN] = $(this).find('icon').attr('url');
                            $widget['sound'] = $(this).attr('sound');
                            $widget['onClickOperation'] = $(this).attr('onClickOperation');
                            $widget['audio_widget'] = new Audio($widget['sound']);
                            $widget['playSoundWhenJmriPlays'] = $(this).attr('playSoundWhenJmriPlays') == "yes";
                            $widget['stopSoundWhenJmriStops'] = $(this).attr('stopSoundWhenJmriStops') == "yes";
                            $widget['rotation'] = $(this).find('icon').find('rotation').text() * 1;
                            $widget['degrees'] = ($(this).find('icon').attr('degrees') * 1) - ($widget.rotation * 90);
                            $widget['scale'] = $(this).find('icon').attr('scale');
                            $widget.classes += " " + $widget.jsonType + " clickable"; //make it clickable
                            if (!$('#' + $widget.id).hasClass('clickable')) {
                                $('#' + $widget.id).addClass("clickable");
                                $('#' + $widget.id).bind(UPEVENT, $handleClick);
                            }
                            jmri.getAudio($widget.systemName);
                            jmri.getAudioIcon($widget['identity']);
                            break;
                        case "logixngicon" :
                            $widget['identity'] = $(this).find('Identity').text();
                            $widget['icon' + UNKNOWN] = $(this).find('icon').attr('url');
                            $widget['rotation'] = $(this).find('icon').find('rotation').text() * 1;
                            $widget['degrees'] = ($(this).find('icon').attr('degrees') * 1) - ($widget.rotation * 90);
                            $widget['scale'] = $(this).find('icon').attr('scale');
                            $widget.classes += " " + $widget.jsonType + " clickable"; //make it clickable
                            if (!$('#' + $widget.id).hasClass('clickable')) {
                                $('#' + $widget.id).addClass("clickable");
                                $('#' + $widget.id).bind(UPEVENT, $handleClick);
                            }
                            break;
                        case "linkinglabel" :
                            $widget['icon' + UNKNOWN] = $(this).find('icon').attr('url');
                            $widget['rotation'] = $(this).find('icon').find('rotation').text() * 1;
                            $widget['degrees'] = ($(this).find('icon').attr('degrees') * 1) - ($widget.rotation * 90);
                            $widget['scale'] = $(this).find('icon').attr('scale');
                            $url = $(this).find('url').text();
                            $widget['url'] = $url; //default to using url value as is
                            if ($widget.forcecontroloff != "true") {
                                $widget.classes += " " + $widget.jsonType + " clickable ";
                            }
                            break;
                        case "indicatortrackicon" : // TODO clean up unused icon copies, carefully
                            // named after (o)block
                            $widget['icon' + UNKNOWN] = $(this).find('iconmap').find('ClearTrack').attr('url'); // clear via oblock
                            $widget['icon2'] = $(this).find('iconmap').find('OccupiedTrack').attr('url'); // occupied via sensor
                            $widget['icon4'] = $widget['icon' + UNKNOWN]; // clear via sensor
                            $widget['icon8'] = $widget['icon' + UNKNOWN]; // status from sensor inconsistent
                            $widget['icon16'] = $(this).find('iconmap').find('AllocatedTrack').attr('url'); //
                            $widget['icon32'] = $(this).find('iconmap').find('PositionTrack').attr('url'); // Running
                            $widget['icon64'] = $(this).find('iconmap').find('DontUseTrack').attr('url'); // Not in use
                            $widget['icon128'] = $(this).find('iconmap').find('ErrorTrack').attr('url'); // Power Error

                            $widget['iconOccupied' + UNKNOWN] = $(this).find('iconmap').find('OccupiedTrack').attr('url');
                            $widget['iconOccupied2'] = $(this).find('iconmap').find('OccupiedTrack').attr('url');
                            $widget['iconOccupied16'] = $(this).find('iconmap').find('OccupiedTrack').attr('url'); // Allocated + Occupied
                            $widget['iconOccupied32'] = $(this).find('iconmap').find('PositionTrack').attr('url');
                            $widget['iconOccupied128'] = $(this).find('iconmap').find('ErrorTrack').attr('url');
                            $widget['rotation'] = $(this).find('iconmap').find('ClearTrack').find('rotation').text() * 1;
                            $widget['degrees'] = ($(this).find('iconmap').find('ClearTrack').attr('degrees') * 1) - ($widget.rotation * 90);
                            $widget['scale'] = $(this).find('iconmap').find('ClearTrack').attr('scale');
                            // CPE CircuitBuilder Oblocks
                            if ($(this).find('occupancysensor').text()) { // store occupancy sensor name
                               $widget['occupancysensor'] = $(this).find('occupancysensor').text();
                               $widget['name'] = $widget.occupancysensor;
                               $widget['occupancyblock'] = "none"; // clear oblockname
                               //console.log("ITI SENSOR=" + $widget['occupancysensor']);
                               //$widget.jsonType = "sensor"; // JSON object type - not necessary
                               jmri.getSensor($widget["occupancysensor"]); // listen for occupancy changes
                           } else if ($(this).find('oblocksysname').text() && ($(this).find('oblocksysname').text() != "none")) {
                                // extract the occupancyblock name
                                $widget['oblocksysname'] = $(this).find('oblocksysname').text();
                                $widget['name'] = $(this).find('occupancyblock').text(); // display name of oblock in hovertext, like CPE
                                $widget['occupancysensor'] = "none"; // clear occ.sensorname
                                //console.log("ITI OBLOCK =" + $widget['oblocksysname']);
                                jmri.getOblock($widget["oblocksysname"]); // listen for oblock changes via json, fired by OBlock#setState()
                                // store ControlPanelEditor oblocks where-used
                                $store_occupancyblock($widget.id, $widget.oblocksysname);
                            }
                            $widget['occupancystate'] = UNKNOWN;
                            break;
                        case "indicatorturnouticon" :
                            $widget['name'] = $(this).find('turnout').text(); // it could be empty on incomplete indicators
                            $widget.jsonType = 'turnout'; // JSON object type
                            $widget['icon' + UNKNOWN] = $(this).find('iconmaps').find('ClearTrack').find('BeanStateUnknown').attr('url');
                            $widget['icon2'] = $(this).find('iconmaps').find('ClearTrack').find('TurnoutStateClosed').attr('url'); // Clear + Closed
                            $widget['icon4'] = $(this).find('iconmaps').find('ClearTrack').find('TurnoutStateThrown').attr('url'); // Clear + Thrown
                            $widget['icon8'] = $(this).find('iconmaps').find('ClearTrack').find('BeanStateInconsistent').attr('url');
                            $widget['icon16'] = $(this).find('iconmaps').find('AllocatedTrack').find('BeanStateUnknown').attr('url');       // Allocated + ?
                            $widget['icon18'] = $(this).find('iconmaps').find('AllocatedTrack').find('TurnoutStateClosed').attr('url');     // Allocated + Closed
                            $widget['icon20'] = $(this).find('iconmaps').find('AllocatedTrack').find('TurnoutStateThrown').attr('url');     // Allocated + Thrown
                            $widget['icon22'] = $(this).find('iconmaps').find('AllocatedTrack').find('BeanStateInconsistent').attr('url');// Allocated + X
                            $widget['icon32'] = $(this).find('iconmaps').find('AllocatedTrack').find('BeanStateUnknown').attr('url');     // Running + ? (should be Occupied, see below)
                            $widget['icon34'] = $(this).find('iconmaps').find('PositionTrack').find('TurnoutStateClosed').attr('url');    // Running + Closed
                            $widget['icon36'] = $(this).find('iconmaps').find('PositionTrack').find('TurnoutStateThrown').attr('url');    // Running + Thrown
                            $widget['icon38'] = $(this).find('iconmaps').find('PositionTrack').find('BeanStateInconsistent').attr('url'); // Running + X
                            $widget['icon64'] = $(this).find('iconmaps').find('DontUseTrack').find('BeanStateUnknown').attr('url');         // Not in use + ?
                            $widget['icon66'] = $(this).find('iconmaps').find('DontUseTrack').find('TurnoutStateClosed').attr('url');       // Not in use + Closed
                            $widget['icon68'] = $(this).find('iconmaps').find('DontUseTrack').find('TurnoutStateThrown').attr('url');       // Not in use + Thrown
                            $widget['icon70'] = $(this).find('iconmaps').find('DontUseTrack').find('BeanStateInconsistent').attr('url');    // Not in use + X
                            $widget['icon128'] = $(this).find('iconmaps').find('ErrorTrack').find('BeanStateUnknown').attr('url');      // Power Error + ?
                            $widget['icon130'] = $(this).find('iconmaps').find('ErrorTrack').find('TurnoutStateClosed').attr('url');    // Power Error + Closed
                            $widget['icon132'] = $(this).find('iconmaps').find('ErrorTrack').find('TurnoutStateThrown').attr('url');    // Power Error + Thrown
                            $widget['icon134'] = $(this).find('iconmaps').find('ErrorTrack').find('BeanStateInconsistent').attr('url'); // Power Error + X

                            $widget['iconOccupied' + UNKNOWN] = $(this).find('iconmaps').find('OccupiedTrack').find('BeanStateUnknown').attr('url');// 4 icons for
                            $widget['iconOccupied2'] = $(this).find('iconmaps').find('OccupiedTrack').find('TurnoutStateClosed').attr('url');       // occ.detect
                            $widget['iconOccupied4'] = $(this).find('iconmaps').find('OccupiedTrack').find('TurnoutStateThrown').attr('url');       // by sensor
                            $widget['iconOccupied8'] = $(this).find('iconmaps').find('OccupiedTrack').find('BeanStateInconsistent').attr('url');    //
                            $widget['iconOccupied16'] = $(this).find('iconmaps').find('OccupiedTrack').find('BeanStateUnknown').attr('url');     // 4 icons for
                            $widget['iconOccupied18'] = $(this).find('iconmaps').find('OccupiedTrack').find('TurnoutStateClosed').attr('url');   // occ.detect
                            $widget['iconOccupied20'] = $(this).find('iconmaps').find('OccupiedTrack').find('TurnoutStateThrown').attr('url');   // by oblock
                            $widget['iconOccupied22'] = $(this).find('iconmaps').find('OccupiedTrack').find('BeanStateInconsistent').attr('url');//
                            $widget['iconOccupied32'] = $(this).find('iconmaps').find('AllocatedTrack').find('BeanStateUnknown').attr('url');       // Running + ?
                            $widget['iconOccupied34'] = $(this).find('iconmaps').find('PositionTrack').find('TurnoutStateClosed').attr('url');      // Running + Closed
                            $widget['iconOccupied36'] = $(this).find('iconmaps').find('PositionTrack').find('TurnoutStateThrown').attr('url');      // Running + Thrown
                            $widget['iconOccupied38'] = $(this).find('iconmaps').find('PositionTrack').find('BeanStateInconsistent').attr('url');   // Running + X
                            $widget['iconOccupied128'] = $(this).find('iconmaps').find('ErrorTrack').find('BeanStateUnknown').attr('url');
                            $widget['iconOccupied130'] = $(this).find('iconmaps').find('ErrorTrack').find('TurnoutStateClosed').attr('url');
                            $widget['iconOccupied132'] = $(this).find('iconmaps').find('ErrorTrack').find('TurnoutStateThrown').attr('url');
                            $widget['iconOccupied134'] = $(this).find('iconmaps').find('ErrorTrack').find('BeanStateInconsistent').attr('url');
                            // no icons for Occupied + DontUseTrack
                            $widget['rotation'] = $(this).find('iconmaps').find('ClearTrack').find('BeanStateUnknown').find('rotation').text() * 1;
                            $widget['degrees'] = ($(this).find('iconmaps').find('ClearTrack').find('BeanStateUnknown').attr('degrees') * 1) - ($widget.rotation * 90);
                            $widget['scale'] = $(this).find('iconmaps').find('ClearTrack').find('BeanStateUnknown').attr('scale');
                            if ($widget.forcecontroloff != "true") {
                                $widget.classes += " " + $widget.jsonType + " clickable ";
                            }
                            // CPE CircuitBuilder Oblocks
                            if ($(this).find('occupancysensor').text()) { // instead, store occupancy sensor name
                                $widget['occupancyblock'] = "none"; // clear oblockname
                                $widget['occupancysensor'] = $(this).find('occupancysensor').text();
                                //console.log("ITOI SENSOR =" + $widget['occupancysensor']);
                                jmri.getSensor($widget["occupancysensor"]); // listen for occupancy changes
                                $store_occupancysensor($widget.id, $widget.occupancysensor); // only do that now we know no oblock is set
                            } else if ($(this).find('oblocksysname').text() && ($(this).find('oblocksysname').text() != "none")) {
                                // extract the occupancy block name
                                $widget['oblocksysname'] = $(this).find('oblocksysname').text();
                                $widget['occupancysensor'] = "none"; // clear oblockname
                                //console.log("ITOI OBLOCK =" + $widget['oblocksysname']);
                                jmri.getOblock($widget["oblocksysname"]); // listen for oblock changes, fired by Block#setState(), under development
                                $store_occupancyblock($widget.id, $widget.oblocksysname);
                            }
                            $widget['occupancystate'] = UNKNOWN;
                            jmri.getTurnout($widget["systemName"]);
                            break;
                        case "turnouticon" :
                            $widget['name'] = $widget.turnout; //normalize name
                            $widget.jsonType = "turnout"; // JSON object type
                            $widget['icon' + UNKNOWN] = $(this).find('icons').find('unknown').attr('url');
                            $widget['icon2'] = $(this).find('icons').find('closed').attr('url');
                            $widget['icon4'] = $(this).find('icons').find('thrown').attr('url');
                            $widget['icon8'] = $(this).find('icons').find('inconsistent').attr('url');
                            $widget['rotation'] = $(this).find('icons').find('unknown').find('rotation').text() * 1;
                            $widget['degrees'] = ($(this).find('icons').find('unknown').attr('degrees') * 1) - ($widget.rotation * 90);
                            $widget['scale'] = $(this).find('icons').find('unknown').attr('scale');
                            if ($widget.forcecontroloff != "true") {
                                $widget.classes += " " + $widget.jsonType + " clickable ";
                            }
                            jmri.getTurnout($widget["systemName"]);
                            break;
                        case "sensoricon" :
                            $widget['name'] = $widget.sensor; //normalize name
                            $widget.jsonType = "sensor"; // JSON object type
                            $widget['icon' + UNKNOWN] = $(this).find('unknown').attr('url');
                            $widget['icon2'] = $(this).find('active').attr('url');
                            $widget['icon4'] = $(this).find('inactive').attr('url');
                            $widget['icon8'] = $(this).find('inconsistent').attr('url');
                            $widget['rotation'] = $(this).find('unknown').find('rotation').text() * 1;
                            $widget['degrees'] = ($(this).find('unknown').attr('degrees') * 1) - ($widget.rotation * 90);
                            $widget['scale'] = $(this).find('unknown').attr('scale');
                            if ($widget.forcecontroloff != "true") {
                                $widget.classes += " " + $widget.jsonType + " clickable ";
                            }
                            if (isUndefined($widget["systemName"]))
                                $widget["systemName"] = $widget.name;
                            jmri.getSensor($widget["systemName"]);
                            break;
                        case "LightIcon" :
                            $widget['name'] = $widget.light; //normalize name
                            $widget.jsonType = "light"; // JSON object type
                            $widget['icon' + UNKNOWN] = $(this).find('icons').find('unknown').attr('url');
                            $widget['icon2'] = $(this).find('icons').find('on').attr('url');
                            $widget['icon4'] = $(this).find('icons').find('off').attr('url');
                            $widget['icon8'] = $(this).find('icons').find('inconsistent').attr('url');
                            $widget['rotation'] = $(this).find('icons').find('unknown').find('rotation').text() * 1;
                            $widget['degrees'] = ($(this).find('icons').find('unknown').attr('degrees') * 1) - ($widget.rotation * 90);
                            $widget['scale'] = $(this).find('unknown').attr('scale');
                            if ($widget.forcecontroloff != "true") {
                                $widget.classes += " " + $widget.jsonType + " clickable ";
                            }
                            if (isUndefined($widget["systemName"]))
                                $widget["systemName"] = $widget.name;
                            jmri.getLight($widget["systemName"]);
                            break;
                        case "signalheadicon" :
                            $widget['name'] = $widget.signalhead; //normalize name
                            $widget.jsonType = "signalHead"; // JSON object type
                            $widget['icon' + HELD] = $(this).find('icons').find('held').attr('url');
                            $widget['icon' + DARK] = $(this).find('icons').find('dark').attr('url');
                            $widget['icon' + RED] = $(this).find('icons').find('red').attr('url');
                            if (isUndefined($widget['icon' + RED])) { //look for held if no red
                                $widget['icon' + RED] = $(this).find('icons').find('held').attr('url');
                            }
                            $widget['icon' + YELLOW] = $(this).find('icons').find('yellow').attr('url');
                            $widget['icon' + GREEN] = $(this).find('icons').find('green').attr('url');
                            $widget['icon' + FLASHRED] = $(this).find('icons').find('flashred').attr('url');
                            $widget['icon' + FLASHYELLOW] = $(this).find('icons').find('flashyellow').attr('url');
                            $widget['icon' + FLASHGREEN] = $(this).find('icons').find('flashgreen').attr('url');
                            $widget['icon' + LUNAR] = $(this).find('icons').find('lunar').attr('url');
                            $widget['icon' + FLASHLUNAR] = $(this).find('icons').find('lunar').attr('url');
                            $widget['rotation'] = $(this).find('icons').find('dark').find('rotation').text() * 1;
                            $widget['degrees'] = ($(this).find('icons').find('dark').attr('degrees') * 1) - ($widget.rotation * 90);
                            $widget['scale'] = $(this).find('icons').find('dark').attr('scale');
                            if ($widget.forcecontroloff != "true") {
                                $widget.classes += " " + $widget.jsonType + " clickable ";
                            }
                            jmri.getSignalHead($widget["systemName"]);
                            break;
                        case "signalmasticon" :
                            $widget['name'] = $widget.signalmast; //normalize name
                            $widget.jsonType = "signalMast"; // JSON object type
                            var icons = $(this).find('icons').children(); //get array of icons
                            icons.each(function(i, item) {  //loop thru icons array and set all iconXX urls for widget
                                $widget['icon' + $(item).attr('aspect')] = $(item).attr('url');
                            });
                            $widget['degrees'] = $(this).attr('degrees') * 1;
                            $widget['scale'] = $(this).attr('scale');
                            if ($widget.forcecontroloff != "true") {
                                $widget.classes += " " + $widget.jsonType + " clickable ";
                            }
                            if (isDefined($widget["iconUnlit"])) {
                                $widget['state'] = "Unlit"; //set the initial aspect to Unlit if defined
                            } else {
                                $widget['state'] = "Unknown"; //else set to Unknown
                            }
                            jmri.getSignalMast($widget["systemName"]);
                            break;
                        case "multisensoricon" :
                            //create multiple widgets, 1st with all images, stack others with non-active states set to a clear image
                            //  set up siblings array so each widget can also set state of the others
                            $widget.jsonType = "sensor"; // JSON object type
                            $widget['icon' + UNKNOWN] = $(this).find('unknown').attr('url');
                            $widget['icon4'] = $(this).find('inactive').attr('url');
                            $widget['icon8'] = $(this).find('inconsistent').attr('url');
                            $widget['rotation'] = $(this).find('unknown').find('rotation').text() * 1;
                            $widget['degrees'] = ($(this).find('unknown').attr('degrees') * 1) - ($widget.rotation * 90);
                            $widget['scale'] = $(this).find('unknown').attr('scale');
                            if ($widget.forcecontroloff != "true") {
                                $widget.classes += " " + $widget.jsonType + " clickable ";
                            }
                            $widget['siblings'] = new Array();  //array of related multisensors
                            $widget['hoverText'] = "";          //for override of hovertext
                            var actives = $(this).find('active'); //get array of actives used by this multisensor
                            var $id = $widget.id;
                            actives.each(function(i, item) {  //loop thru array once to set up siblings array, to be copied to all siblings
                                $widget.siblings.push($id);
                                $widget.hoverText += $(item).attr('sensor') + " "; //add sibling names to hovertext
                                $id = "widget-" + $gUnique(); //set new id to a unique value for each sibling
                            });
                            actives.each(function(i, item) {  //loop thru array again to create each widget
                                $widget['id'] = $widget.siblings[i];         // use id already set in sibling array
                                $widget.name = $(item).attr('sensor');
                                $widget['icon2'] = $(item).attr('url');
                                if (i < actives.size() - 1) { //only save widget and make a new one if more than one active found
                                    $preloadWidgetImages($widget); //start loading all images
                                    $widget['safeName'] = $safeName($widget.name);  //add a html-safe version of name
                                    $widget["systemName"] = $widget.name;
                                    $gWidgets[$widget.id] = $widget; //store widget in persistent array
                                    $drawIcon($widget); //actually place and position the widget on the panel
                                    jmri.getSensor($widget["systemName"]);
                                    if (!($widget.systemName in whereUsed)) {  //set where-used for this new sensor
                                        whereUsed[$widget.systemName] = new Array();
                                    }
                                    whereUsed[$widget.systemName][whereUsed[$widget.systemName].length] = $widget.id;
                                    $widget = jQuery.extend(true, {}, $widget); //get a new copy of widget
                                    $widget['icon' + UNKNOWN] = "/web/images/transparent_1x1.png";
                                    $widget['icon4'] = "/web/images/transparent_1x1.png"; //set non-actives to transparent image
                                    $widget['icon8'] = "/web/images/transparent_1x1.png";
                                    $widget['state'] = ACTIVE; //to avoid sizing based on the transparent image
                                }
                            });
                            $widget["systemName"] = $widget.name;
                            jmri.getSensor($widget["systemName"]);
                            break;
                        case "memoryicon" :
                            $widget['name'] = $widget.memory; //normalize name
                            $widget.jsonType = "memory"; // JSON object type
                            $widget['state'] = null; //set initial state to null
                            $widget['iconnull']="/web/images/transparent_19x16.png"; //transparent image for null value
                            var memorystates = $(this).find('memorystate');
                            memorystates.each(function(i, item) {  //get any memorystates defined
                                //store icon url in "iconXX" where XX is the state to match
                                $widget['icon' + item.attributes['value'].value] = item.attributes['icon'].value;
                            });
                            if (isUndefined($widget["systemName"]))
                                $widget["systemName"] = $widget.name;
                            jmri.getMemory($widget["systemName"]);
                            break;
                        case "slipturnouticon" : // added 2022, adapted from indicatorturnouticon
                            // no direct link to a JSON/named bean (systemName = id)
                            // also used for three way turnouts
                            // see java/src/jmri/jmrit/display/SlipTurnoutIcon.java
                            // and java/src/jmri/jmrit/display/configurexml/SlipTurnoutIconXml.java
                            $widget['turnoutEast'] = $(this).find('turnoutEast').text();
                            $widget['turnoutWest'] = $(this).find('turnoutWest').text();
                            $widget['name'] = $widget['turnoutEast'] + " " +$widget['turnoutWest'];
                            $widget.jsonType = "turnout"; // JSON object type, used to send commands in $handleClick(e)
                            $widget['slipicontype'] = $(this).find('turnoutType').text();
                            $widget['slipStateEast'] = UNKNOWN;
                            $widget['slipStateWest'] = UNKNOWN;
                            $widget['slipState'] = UNKNOWN; // combined state
                            // set icons
                            $widget['icon' + UNKNOWN] = $(this).find('unknown').attr('url');
                            $widget['icon' + INCONSISTENT] = $(this).find('inconsistent').attr('url');
                            $widget['icon5'] = $(this).find('upperWestToLowerEast').attr('url');
                            $widget['icon7'] = $widget['icon' + INCONSISTENT]; // state 7 loaded later where supported
                            $widget['icon9'] = $(this).find('lowerWestToLowerEast').attr('url');
                            $widget['icon11'] = $(this).find('lowerWestToUpperEast').attr('url');

                            switch ($widget.turnoutType) {
                                case "singleSlip" :
                                    // $widget['singleSlipRoute'] = "lowerWestToLowerEast" or "upperWestToUpperEast"
                                    if ($widget.singleSlipRoute == "upperWestToUpperEast") {
                                        $widget['icon7'] = $(this).find('upperWestToUpperEast').attr('url');
                                    }
                                    break;
                                case "threeWay" :
                                    // $widget['firstTurnoutExit'] = "upper" or "lower"
                                    if ($widget.firstTurnoutExit == "lower") { // swap icons7 and 9
                                        $widget['icon7'] = $widget.icon9;
                                        $widget['icon9'] = $(this).find('lowerWestToUpperEast').attr('url');
                                    }
                                    break;
                                case "scissor" :
                                    $widget['turnoutLowerEast'] = $(this).find('turnoutLowerEast').text();
                                    $widget['turnoutLowerWest'] = $(this).find('turnoutLowerWest').text();
                                    if (isDefined($widget.turnoutLowerEast)) {
                                        $widget['singleCrossOver'] = "false";
                                        // connect 2 extra turnouts now to prevent extra switch case below, no need to listen
                                        // jmri.getTurnout($widget['turnoutLowerEast']);
                                        // jmri.getTurnout($widget['turnoutLowerWest']);
                                    } else {
                                        $widget['singleCrossOver'] = "true";
                                    }
                                    $widget['icon7'] = $widget.icon5;  // UWLE
                                    $widget['icon5'] = $widget.icon9;  // LWLE
                                    $widget['icon9'] = $widget.icon11; // LWUE
                                    $widget['icon11'] = $widget['icon' + INCONSISTENT];
                                    break;
                                case "doubleSlip" : // default
                                    $widget['icon7'] = $(this).find('upperWestToUpperEast').attr('url');
                                    break;
                            }

                            $widget['rotation'] = $(this).find('lowerWestToLowerEast').find('rotation').text() * 1;
                            $widget['degrees'] = ($(this).find('lowerWestToLowerEast').attr('degrees') * 1) - ($widget.rotation * 90);
                            $widget['scale'] = $(this).find('lowerWestToLowerEast').attr('scale');
                            if ($widget.forcecontroloff != "true") {
                                $widget.classes += " " + $widget.jsonType + " clickable ";
                            }
                            jmri.getTurnout($widget['turnoutEast']);
                            jmri.getTurnout($widget['turnoutWest']);

                            // add turnout to whereUsed array (as $widget.id + 'e')
                            if (!($widget.turnoutEast in whereUsed)) {  //set where-used for this new turnout
                               whereUsed[$widget.turnoutEast] = new Array();
                            }
                            whereUsed[$widget.turnoutEast][whereUsed[$widget.turnoutEast].length] = $widget.id + "e";

                            // add turnoutB to whereUsed array (as $widget + 'w')
                            if (!($widget.turnoutWest in whereUsed)) {  //set where-used for this new turnout
                               whereUsed[$widget.turnoutWest] = new Array();
                            }
                            whereUsed[$widget.turnoutWest][whereUsed[$widget.turnoutWest].length] = $widget.id + "w";
                            // TODO add the extra 2 turnouts to whereUsed that optionally are part of a scissor?
                            break;
                    }

                    $preloadWidgetImages($widget); //start loading all images
                    $widget['safeName'] = $safeName($widget.name);  //add a html-safe version of name
                    $gWidgets[$widget.id] = $widget; //store widget in persistent array
                    $drawIcon($widget); //actually place and position the widget on the panel
                    break;

                case "text" :
                    $widget['styles'] = $getTextCSSFromObj($widget);
                    switch ($widget.widgetType) {
                        case "audioicon" :
                            $widget.jsonType = 'audio'; // JSON object type
                            $widget['identity'] = $(this).find('Identity').text();
                            audioIconIDs['audioicon:'+$widget['identity']] = $widget;   // Ensure the key is a string, not a number
                            $widget['sound'] = $(this).attr('sound');
                            $widget['onClickOperation'] = $(this).attr('onClickOperation');
                            $widget['audio_widget'] = new Audio($widget['sound']);
                            $widget['playSoundWhenJmriPlays'] = $(this).attr('playSoundWhenJmriPlays') == "yes";
                            $widget['stopSoundWhenJmriStops'] = $(this).attr('stopSoundWhenJmriStops') == "yes";
                            $widget.styles['user-select'] = "none";
                            $widget.classes += " " + $widget.jsonType + " clickable ";
                            if (!$('#' + $widget.id).hasClass('clickable')) {
                                $('#' + $widget.id).addClass("clickable");
                                $('#' + $widget.id).bind(UPEVENT, $handleClick);
                            }
                            jmri.getAudio($widget.systemName);
                            jmri.getAudioIcon($widget['identity']);
                            break;
                        case "logixngicon" :
                            $widget.jsonType = "logixngicon"; // JSON object type
                            $widget['identity'] = $(this).find('Identity').text();
                            $widget.styles['user-select'] = "none";
                            $widget.classes += " " + $widget.jsonType + " clickable ";
                            break;
                        case "sensoricon" :
                            $widget['name'] = $widget.sensor; //normalize name
                            $widget.jsonType = "sensor"; // JSON object type
                            //set each state's text
                            $widget['text' + UNKNOWN] = $(this).find('unknownText').attr('text');
                            $widget['text2'] = $(this).find('activeText').attr('text');
                            $widget['text4'] = $(this).find('inactiveText').attr('text');
                            $widget['text8'] = $(this).find('inconsistentText').attr('text');
                            //set each state's css attribute array (text color, etc.)
                            $widget['css' + UNKNOWN] = $getTextCSSFromObj($getObjFromXML($(this).find('unknownText')[0]));
                            $widget['css2'] = $getTextCSSFromObj($getObjFromXML($(this).find('activeText')[0]));
                            $widget['css4'] = $getTextCSSFromObj($getObjFromXML($(this).find('inactiveText')[0]));
                            $widget['css8'] = $getTextCSSFromObj($getObjFromXML($(this).find('inconsistentText')[0]));
                            if (isDefined($widget.name) && $widget.forcecontroloff != "true") {
                                $widget.classes += " " + $widget.jsonType + " clickable ";
                            }
                            if (isUndefined($widget["systemName"]))
                                $widget["systemName"] = $widget.name;
                            jmri.getSensor($widget["systemName"]);
                            break;
                        case "locoicon" :
                        case "trainicon" :
                            //also set the background icon for this one (additional css in .html file)
                            $widget['icon' + UNKNOWN] = $(this).find('icon').attr('url');
                            $widget.styles['background-image'] = "url('" + $widget['icon' + UNKNOWN] + "')";
                            $widget['scale'] = $(this).find('icon').attr('scale');
                            if ($widget.scale != 1) {
                                $widget.styles['background-size'] = $widget.scale * 100 + "%";
                                $widget.styles['line-height'] = $widget.scale * 20 + "px";  //center vertically
                            }
                            break;
                        case "fastclock" :
                            jmri.getMemory("IMRATEFACTOR"); //enable updates for fast clock rate
                            $widget['name'] = 'IMCURRENTTIME';  // already defined in JMRI
                            $widget.jsonType = 'memory';
                            $widget.styles['width'] = "166px";  //hard-coded to match original size of clock image
                            $widget.styles['height'] = "166px";
                            $widget['scale'] = $(this).attr('scale');
                            if (isUndefined($widget.level)) {
                                $widget['level'] = 10;  //if not included in xml
                            }
                            $widget['text'] = "00:00 AM";
                            $widget['state'] = "00:00 AM";
                            if (isUndefined($widget["systemName"]))
                                $widget["systemName"] = $widget.name;
                            jmri.getMemory($widget["systemName"]);
                            break;
                        case "memoryicon" :
                            $widget['name'] = $widget.memory; //normalize name
                            $widget.jsonType = "memory"; // JSON object type
                            $widget['text'] = $widget.memory; //use name for initial text
                            $widget['state'] = $widget.memory; //use name for initial state as well
                            if (isUndefined($widget["systemName"]))
                                $widget["systemName"] = $widget.name;
                            jmri.getMemory($widget["systemName"]);
                            break;
                        case "reportericon" :
                            $widget['name'] = $widget.reporter; //normalize name
                            $widget.jsonType = "reporter"; // JSON object type
                            $widget['text'] = $widget.reporter; //use name for initial text
                            if (isUndefined($widget["systemName"]))
                                $widget["systemName"] = $widget.name;
                            jmri.getReporter($widget["systemName"]);
                            break;
                        case "BlockContentsIcon" :
                            $widget['name'] = $widget.systemName; //normalize name (id got stepped on)
                            $widget.jsonType = "block"; // JSON object type
                            $widget['text'] = $widget.name; //use name for initial text
                            $widget['state'] = $widget.name; //use name for initial state as well
                            jmri.getBlock($widget["systemName"]);
                            break;
                        case "memoryInputIcon" :
                        case "memoryComboIcon" :
                            $widget['name'] = $widget.memory; //normalize name
                            $widget.jsonType = "memory"; // JSON object type
                            $widget['text'] = $widget.memory; //use name for initial text
                            $widget['state'] = $widget.memory; //use name for initial state as well
                            $widget.styles['border'] = "1px solid black" //add border for looks (temporary)
                            if (isUndefined($widget["systemName"]))
                                $widget["systemName"] = $widget.name;
                            jmri.getMemory($widget["systemName"]);
                            break;
                        case "linkinglabel" :
                            $url = $(this).find('url').text();
                            $widget['url'] = $url; //just store url value in widget, for use in click handler
                            if ($widget.forcecontroloff != "true") {
                                $widget.classes += " " + $widget.jsonType + " clickable ";
                            }
                            break;
                    }

                    $widget['safeName'] = $safeName($widget.name);
                    switch ($widget['orientation']) { // use orientation instead of degrees if populated
                        case "vertical_up"   : $widget.degrees = 270;
                        case "vertical_down" : $widget.degrees = 90;
                    }
                    $gWidgets[$widget.id] = $widget; //store widget in persistent array

                    $("#panel-area").append("<div id=" + $widget.id + " class='" + $widget.classes + "'>" +
                        $widget.text + "</div>");
                    $("#panel-area>#" + $widget.id).css($widget.styles); // apply style array to widget
                    $setWidgetPosition($("#panel-area>#" + $widget.id));
                    break;

                case "drawn" :
                    if (jmri_logging) {
                        log.log("case drawn " + $widget.widgetType);
                        $logProperties($widget);
                    }
                    switch ($widget.widgetType) {
                        case "positionablepoint" :
                            //log.log("#### Positionable Point ####");
                            //just store these points in persistent variable for use when drawing tracksegments and layoutturnouts
                            //End bumpers and Connectors use wrong type, so always store as .POS_POINT
                            $gPts[$widget.ident + ".POS_POINT"] = $widget;
                            break;
                        case "layoutblock" :
                            $widget['state'] = UNKNOWN;  //add a state member for this block
                            $widget["blockcolor"] = $widget.trackcolor; //init blockcolor to trackcolor
                            //store these blocks in a persistent var
                            $gBlks[$widget.systemName] = $widget;
                            //log.log("layoutblock:");
                            $logProperties($widget);
                            //log.log("block[" + $widget.systemName + "].blockcolor: '" + $widget.trackcolor + "'.")
                            jmri.getLayoutBlock($widget.systemName);
                            break;
                        case "layoutturnout" :
                            $widget['id'] = $widget.ident;
                            $widget['name'] = $widget.turnoutname; //normalize name
                            $widget['safeName'] = $safeName($widget.name);  //add a html-safe version of name
                            $widget.jsonType = "turnout"; // JSON object type
                            $widget['x'] = $widget.xcen; //normalize x,y
                            $widget['y'] = $widget.ycen;
                            if (isDefined($widget.name) && ($widget.disabled !== "yes")) {
                                $widget.classes += " " + $widget.jsonType + " clickable "; //make it clickable (unless no turnout assigned)
                            }
                            //set widget occupancy sensor from block to speed affected changes later
                            if (isDefined($gBlks[$widget.blockname])) {
                                $widget['occupancysensorA'] = $gBlks[$widget.blockname].occupancysensor;
                                $widget['occupancystateA'] = $gBlks[$widget.blockname].state;
                            }
                            if (isDefined($gBlks[$widget.blockbname])) {
                                $widget['occupancysensorB'] = $gBlks[$widget.blockbname].occupancysensor;
                                $widget['occupancystateB'] = $gBlks[$widget.blockbname].state;
                            }
                            if (isDefined($gBlks[$widget.blockcname])) {
                                $widget['occupancysensorC'] = $gBlks[$widget.blockcname].occupancysensor;
                                $widget['occupancystateC'] = $gBlks[$widget.blockcname].state;
                            }
                            if (isDefined($gBlks[$widget.blockdname])) {
                                $widget['occupancysensorD'] = $gBlks[$widget.blockdname].occupancysensor;
                                $widget['occupancystateD'] = $gBlks[$widget.blockdname].state;
                            }
                            $gWidgets[$widget.id] = $widget; //store widget in persistent array
                            $storeTurnoutPoints($widget); //also store the turnout's 3 end points for other connections
                            $drawTurnout($widget); //draw the turnout

                            // add an empty, but clickable, div to the panel and position it over the turnout circle, if control allowed
                            if  ($gPanel.controlling == "yes") {
                                $hoverText = " title='" + $widget.name + "' alt='" + $widget.name + "'";
                                $("#panel-area").append("<div id=" + $widget.id + " class='" + $widget.classes + "' " + $hoverText + "></div>");
                                var $cr = $gPanel.turnoutcirclesize * SIZE;  //turnout circle radius
                                var $cd = $cr * 2;
                                $("#panel-area>#" + $widget.id).css(
                                {
                                    position: 'absolute',
                                    left: ($widget.x - $cr) + 'px',
                                    top: ($widget.y - $cr) + 'px',
                                    zIndex: 3,
                                    width: $cd + 'px',
                                    height: $cd + 'px'
                                });
                            }
                            if (isUndefined($widget["systemName"])) {
                                $widget["systemName"] = $widget.name;
                            }
                            jmri.getTurnout($widget["systemName"]);
                            if ($widget["occupancysensorA"])
                                jmri.getSensor($widget["occupancysensorA"]); //listen for occupancy changes
                            if ($widget["occupancysensorB"])
                                jmri.getSensor($widget["occupancysensorB"]); //listen for occupancy changes
                            if ($widget["occupancysensorC"])
                                jmri.getSensor($widget["occupancysensorC"]); //listen for occupancy changes
                            if ($widget["occupancysensorD"])
                                jmri.getSensor($widget["occupancysensorD"]); //listen for occupancy changes
                            break;
                        case 'layoutSlip' :
                            $widget['id'] = $widget.ident;
                            $widget['name'] = $widget.ident;
                            $widget['safeName'] = $safeName($widget.name);  //add a html-safe version of name
                            $widget.jsonType = "turnout"; // JSON object type

                            //save the slip state to turnout state information
                            $widget['turnout'] = $(this).find('turnout:first').text();
                            $widget['turnoutB'] = $(this).find('turnoutB:first').text();
                            $widget['stateA'] = UNKNOWN;
                            $widget['stateB'] = UNKNOWN;

                            //log.log("tA: " + $widget.turnout + ", tB: " + $widget.turnoutB);

                            $widget['turnoutA_AC'] = Number($(this).find('states').find('A-C').find('turnout').text());
                            $widget['turnoutA_AD'] = Number($(this).find('states').find('A-D').find('turnout').text());
                            $widget['turnoutA_BC'] = Number($(this).find('states').find('B-C').find('turnout').text());
                            $widget['turnoutA_BD'] = Number($(this).find('states').find('B-D').find('turnout').text());

                            $widget['turnoutB_AC'] = Number($(this).find('states').find('A-C').find('turnoutB').text());
                            $widget['turnoutB_AD'] = Number($(this).find('states').find('A-D').find('turnoutB').text());
                            $widget['turnoutB_BC'] = Number($(this).find('states').find('B-C').find('turnoutB').text());
                            $widget['turnoutB_BD'] = Number($(this).find('states').find('B-D').find('turnoutB').text());

                            // default to this state
                            $widget['state'] = UNKNOWN;

                            $widget['x'] = $widget.xcen; //normalize x,y
                            $widget['y'] = $widget.ycen;

                            if ((isDefined($widget.turnout) || isDefined($widget.turnoutB))
                                    && ($widget.disabled !== "yes")) {
                                $widget.classes += " " + $widget.jsonType + " clickable ";
                            }

                            //set widget occupancy sensor from block to speed affected changes later
                            if (isDefined($gBlks[$widget.blockname])) {
                                $widget['occupancysensorA'] = $gBlks[$widget.blockname].occupancysensor;
                                $widget['occupancystateA'] = $gBlks[$widget.blockname].state;
                            }
                            if (isDefined($gBlks[$widget.blockbname])) {
                                $widget['occupancysensorB'] = $gBlks[$widget.blockbname].occupancysensor;
                                $widget['occupancystateB'] = $gBlks[$widget.blockbname].state;
                            }
                            if (isDefined($gBlks[$widget.blockcname])) {
                                $widget['occupancysensorC'] = $gBlks[$widget.blockcname].occupancysensor;
                                $widget['occupancystateC'] = $gBlks[$widget.blockcname].state;
                            }
                            if (isDefined($gBlks[$widget.blockdname])) {
                                $widget['occupancysensorD'] = $gBlks[$widget.blockdname].occupancysensor;
                                $widget['occupancystateD'] = $gBlks[$widget.blockdname].state;
                            }

                            $gWidgets[$widget.id] = $widget;    //store widget in persistent array
                            $storeSlipPoints($widget);          //also store the slip's 4 end points for other connections
                            $drawSlip($widget);                 //draw the slip

                            if ($gPanel.controlling == "yes") {
                                // convenience variables for points (A, B, C, D)
                                var a = $getPoint($widget.ident + SLIP_A);
                                var b = $getPoint($widget.ident + SLIP_B);
                                var c = $getPoint($widget.ident + SLIP_C);
                                var d = $getPoint($widget.ident + SLIP_D);

                                var $cr = $gPanel.turnoutcirclesize * SIZE; //turnout circle radius
                                var $cd = $cr * 2;                          //turnout circle diameter

                                // center
                                var cen = [$widget.xcen, $widget.ycen];
                                // left center
                                var lcen = $point_midpoint(a, b);
                                var ldelta = $point_subtract(cen, lcen);

                                // left fraction
                                var lf = $cr / Math.hypot(ldelta[0], ldelta[1]);
                                // left circle
                                var lcc = $point_lerp(cen, lcen, lf);

                                //add an empty, but clickable, div to the panel and position it over the left turnout circle
                                $hoverText = " title='" + $widget.turnout + "' alt='" + $widget.turnout + "'";
                                $("#panel-area").append("<div id=" + $widget.id + "l class='" + $widget.classes + "' " + $hoverText + "></div>");
                                $("#panel-area>#" + $widget.id + "l").css(
                                    {position: 'absolute', left: (lcc[0] - $cr) + 'px', top: (lcc[1] - $cr) + 'px', zIndex: 3,
                                        width: $cd + 'px', height: $cd + 'px'});
                                // right center
                                var rcen = $point_midpoint(c, d);
                                var rdelta = $point_subtract(cen, rcen);
                                // right fraction
                                var rf = $cr / Math.hypot(rdelta[0], rdelta[1]);
                                // right circle
                                var rcc = $point_lerp(cen, rcen, rf);

                                //add an empty, but clickable, div to the panel and position it over the right turnout circle
                                $hoverText = " title='" + $widget.turnoutB + "' alt='" + $widget.turnoutB + "'";
                                $("#panel-area").append("<div id=" + $widget.id + "r class='" + $widget.classes + "' " + $hoverText + "></div>");
                                $("#panel-area>#" + $widget.id + "r").css(
                                    {position: 'absolute', left: (rcc[0] - $cr) + 'px', top: (rcc[1] - $cr) + 'px', zIndex: 3,
                                        width: $cd + 'px', height: $cd + 'px'});
                            }

                            // set up notifications (?)
                            jmri.getTurnout($widget["turnout"]);
                            jmri.getTurnout($widget["turnoutB"]);

                            if ($widget["occupancysensorA"])
                                jmri.getSensor($widget["occupancysensorA"]); //listen for occupancy changes
                            if ($widget["occupancysensorB"])
                                jmri.getSensor($widget["occupancysensorB"]); //listen for occupancy changes
                            if ($widget["occupancysensorC"])
                                jmri.getSensor($widget["occupancysensorC"]); //listen for occupancy changes
                            if ($widget["occupancysensorD"])
                                jmri.getSensor($widget["occupancysensorD"]); //listen for occupancy changes

                            // NOTE: turnout & turnoutB may appear to be swapped here however this is intentional
                            // (since the left turnout controls the right points and vice-versa) and we want
                            // the slip circles to toggle the points (not the turnout) on the corresponding side.
                            //
                            // note: the <div> areas above have their titles & alts turnouts swapped (left <-> right) also

                            // add turnout to whereUsed array (as $widget.id + 'r')
                            if (!($widget.turnout in whereUsed)) {  //set where-used for this new turnout
                               whereUsed[$widget.turnout] = new Array();
                            }
                            whereUsed[$widget.turnout][whereUsed[$widget.turnout].length] = $widget.id + "r";

                            // add turnoutB to whereUsed array (as $widget + 'l')
                            if (!($widget.turnoutB in whereUsed)) {  //set where-used for this new turnout
                               whereUsed[$widget.turnoutB] = new Array();
                            }
                            whereUsed[$widget.turnoutB][whereUsed[$widget.turnoutB].length] = $widget.id + "l";
                            break;
                        case "tracksegment" :
                            //log.log("#### Track Segment ####");
                            //set widget occupancy sensor from block to speed affected changes later
                            if (isDefined($gBlks[$widget.blockname])) {
                                $widget['occupancysensor'] = $gBlks[$widget.blockname].occupancysensor;
                                $widget['occupancystate'] = $gBlks[$widget.blockname].state;
                            }
                            //store this widget in persistent array, with ident as key
                            $widget['id'] = $widget.ident;
                            $gWidgets[$widget.id] = $widget;

                            if ($widget.bezier == "yes") {
                                $widget['controlpoints'] = $(this).find('controlpoint');
                            }

                            // find decorations
                            var $decorations = $(this).find('decorations');

                            //copy arrow decoration
                            //<arrow style="4" end="stop" direction="out" color="#000000" linewidth="4" length="16" gap="1" />
                            var $arrow = $decorations.find('arrow');
                            var $arrowstyle = $arrow.attr('style');
                            if (isDefined($arrowstyle)) {
                                if (Number($arrowstyle) > 0) {
                                    $widget['arrow'] = new ArrowDecoration($widget, $arrow);
                                }
                            }

                            //copy bridge decoration
                            //<bridge side="both" end="both" color="#000000" linewidth="1" approachwidth="8" deckwidth="10" />
                            var $bridge = $decorations.find('bridge');
                            var $bridgeside = $bridge.attr('side');
                            if (isDefined($bridgeside)) {
                                $widget['bridge'] = new BridgeDecoration($widget, $bridge);
                            }

                            //copy bumper decoration
                            //<bumper end="stop" color="#000000" linewidth="2" length="16" />
                            var $bumper = $decorations.find('bumper');
                            var $bumperend = $bumper.attr('end');
                            if (isDefined($bumperend)) {
                                $widget['bumper'] = new BumperDecoration($widget, $bumper);
                            }

                            //copy tunnel decoration
                            //<tunnel side="right" end="both" color="#FF00FF" linewidth="2" entrancewidth="16" floorwidth="12" />
                            var $tunnel = $decorations.find('tunnel');
                            var $tunnelside = $tunnel.attr('side');
                            if (isDefined($tunnelside)) {
                                $widget['tunnel'] = new TunnelDecoration($widget, $tunnel);
                            }

                            if ($widget["occupancysensor"])
                                jmri.getSensor($widget["occupancysensor"]); //listen for occupancy changes

                            //draw the tracksegment
                            $drawTrackSegment($widget);
                            break;
                        case "levelxing" :
                            $widget['x'] = $widget.xcen; //normalize x,y
                            $widget['y'] = $widget.ycen;
                            //set widget occupancy sensor from block to speed affected changes later
                            //TODO: handle BD block
                            if (isDefined($gBlks[$widget.blocknameac])) {
                                $widget['occupancysensorAC'] = $gBlks[$widget.blocknameac].occupancysensor;
                                $widget['occupancystateAC'] = $gBlks[$widget.blocknameac].state;
                            }
                            if (isDefined($gBlks[$widget.blocknamebd])) {
                                $widget['occupancysensorBD'] = $gBlks[$widget.blocknamebd].occupancysensor;
                                $widget['occupancystateBD'] = $gBlks[$widget.blocknamebd].state;
                            }
                            //store widget in persistent array
                            //$widget['id'] = $widget.ident;
                            $gWidgets[$widget.id] = $widget;
                            //also store the xing's 4 end points for other connections
                            $storeLevelXingPoints($widget);
                            //draw the xing
                            $drawLevelXing($widget);

                            //listen for occupancy changes
                            if ($widget["occupancysensorAC"])
                                jmri.getSensor($widget["occupancysensorAC"]);
                            if ($widget["occupancysensorBD"])
                                jmri.getSensor($widget["occupancysensorBD"]);
                            break;
                        case "layoutturntable" :
                            //log.log("#### Layout Turntable ####");
                            $widget['id'] = $widget.ident;
                            $widget['name'] = $widget.ident;
                            $widget['safeName'] = $safeName($widget.name); //add a html-safe version of name
                            $widget.jsonType = "turnout"; // JSON object type
                            $gWidgets[$widget.id] = $widget; //store widget in persistent array

                            if ($widget.turnoutControlled == "yes") {
                                $widget.classes += " " + $widget.jsonType + " clickable"; //make it clickable
                                if (!$('#' + $widget.id).hasClass('clickable')) {
                                    $('#' + $widget.id).addClass("clickable");
                                    $('#' + $widget.id).bind(UPEVENT, $handleClick);
                                }
                            }

                            //get the center
                            var $txcen = $widget.xcen * 1;
                            var $tycen = $widget.ycen * 1;

                            var $tr = $widget.radius * 1; //turntable circle radius
                            var $td = $tr * 2;

                            var $cr = $gPanel.turnoutcirclesize * SIZE; //turnout circle radius
                            var $cd = $cr * 2;

                            //loop thru raytracks, calc and store end of ray point for each
                            $widget['raytracks'] = $(this).find('raytrack');
                            $widget.raytracks.each(function(i, item) {
                                $logProperties(item);
                                //note:the 50 offset is due to TrackSegment.java TURNTABLE_RAY_OFFSET
                                //var rayID = $widget.ident + "." + (50 + item.attributes.index.value * 1);
                                var rayID = $widget.ident + ".TURNTABLE_RAY_" + (item.attributes.index.value * 1);
                                var $t = {ident:rayID};
                                var $angle = $toRadians(item.attributes.angle.value);
                                $t['x'] = $txcen + (($tr + $cr) * Math.sin($angle));
                                $t['y'] = $tycen - (($tr + $cr) * Math.cos($angle));
                                $gPts[$t.ident] = $t; //store the endpoint of this ray

                                if (isDefined(item.attributes.turnout)) {
                                    var turnout = item.attributes.turnout.value;
                                    var state = item.attributes.turnoutstate.value;
                                    //add an empty, but clickable, div to the panel and position it over the turnout circle, if control allowed
                                    if ($gPanel.controlling == "yes") {
                                        $("#panel-area").append("<div " +
                                                "id='" + rayID + "' " +
                                                "class='" + $widget.classes + "' " +
                                                "style='position:absolute;" +
                                                "left:" + ($t.x - $cr) + "px;" +
                                                "top: " + ($t.y - $cr) + "px;" +
                                                "z-index: 3;" +
                                                "width:" + $cd + "px;" +
                                                "height:" + $cd + "px;' " +
                                                "title='" + turnout + "(" + state + ")' " +
                                                "alt='" + turnout + "'" +
                                                "></div>");
                                    }
                                    //set up notifications
                                    jmri.getTurnout(turnout);

                                    // add turnout to whereUsed array (as $widget + 'r')
                                    if (!(turnout in whereUsed)) { //set where-used for this new turnout
                                        whereUsed[turnout] = new Array();
                                    }
                                    whereUsed[turnout].push(rayID);
                                }
                            });

                            //draw the turntable
                            $drawTurntable($widget);
                            break;
                        case "backgroundColor": // set background color of the window
                            $("body").css({"background-color": "rgb(" + $widget.red + "," + $widget.green + "," + $widget.blue + ")"});
                            break;
                        case "layoutShape" :
                            //log.log("#### Layout Shape ####");
                            //store this widget in persistent array, with ident as key
                            $widget['id'] = $widget.ident;
                            $gWidgets[$widget.id] = $widget;

                            $widget['points'] = $(this).find('point');

                            //draw the LayoutShape
                            $drawLayoutShape($widget);
                            break;
                        case "positionableRectangle" : //just like RoundRect except cornerRadius set to 0;
                        case "positionableRoundRect" :
                            //log.log("#### positionableRoundRect ####");
                            //copy and reformat some attributes from children into object
                            $widget['width'] = $(this).find('size').attr('width');
                            $widget['height'] = $(this).find('size').attr('height');
                            $widget['cornerRadius'] = $(this).find('size').attr('cornerRadius');
                            if (isUndefined($widget['cornerRadius'])) {
                                $widget['cornerRadius'] = 0; //default to no corner
                            }                             
                            lc = $(this).find('lineColor');
                            $widget['lineColor'] = 
                                'rgba('+lc.attr('red')+','+lc.attr('green')+',' +
                                lc.attr('blue')+','+lc.attr('alpha')/256+')';
                            fc = $(this).find('fillColor');
                            $widget['fillColor'] = 
                                 'rgba('+fc.attr('red')+','+fc.attr('green')+',' +
                                fc.attr('blue')+','+fc.attr('alpha')/256+')';
                            //store this widget in persistent array, with ident as key
                            $widget['id'] = $widget.ident;
                            $gWidgets[$widget.id] = $widget;
                            //draw the positionableRoundRect
                            $drawPositionableRoundRect($widget);
                            break;
                        case "positionableCircle" : //identical except circle has size radius, 
                        case "positionableEllipse" : //ellipse has size width height
                            //copy and reformat some attributes from children into object
                            $widget['radius'] = ($(this).find('size').attr('radius'));
                            if (isDefined($widget['radius'])) {
                                $widget['height'] = $widget.radius; //use radius for height if populated
                                $widget['width']  = $widget.radius; //use radius for width if populated                               
                            } else {                            
                                $widget['height'] = ($(this).find('size').attr('height'));
                                $widget['width']  = ($(this).find('size').attr('width'));
                            }
                            lc = $(this).find('lineColor');
                            $widget['lineColor'] = 
                                'rgba('+lc.attr('red')+','+lc.attr('green')+',' +
                                lc.attr('blue')+','+lc.attr('alpha')/256+')';
                            fc = $(this).find('fillColor');
                            $widget['fillColor'] = 
                                 'rgba('+fc.attr('red')+','+fc.attr('green')+',' + 
                                fc.attr('blue')+','+fc.attr('alpha')/256+')';
                            //store this widget in persistent array, with ident as key
                            $widget['id'] = $widget.ident;
                            $gWidgets[$widget.id] = $widget;
                            //draw the positionableEllipse
                            $drawPositionableEllipse($widget);
                            break;
                        default:
                            log.warn("unknown $widget.widgetType: " + $widget.widgetType + ".");
                            break;
                    }
                    break;

                case "switch" : // Switchboard BeanSwitches
                    // they have no x,y
                    $widget['styles'] = {}; // clear built-in styles
                    $widget['name'] = $widget.label; // normalize name from label
                    $widget['text'] = $widget.label; // use label as initial button text too
                    $widget.styles['width'] = $swWidth + "px";
                    $widget.styles['height'] = $swHeight + "px";
                    // colors, values from Editor via SwitchboardServlet
                    $widget['swColor' + UNKNOWN] = 'LightGray'; // unknown
                    $widget['swColor2'] = $activeColor; // active = red
                    $widget['swColor4'] = $inactiveColor;  // inactive = green
                    $widget['swColor8'] = 'Gray'; // inconsistent
                    if ($widget.connected == "true") {
                        switch ($widget['type']) {
                            case "T" :
                                $widget.jsonType = "turnout"; // JSON object type
                                jmri.getTurnout($widget["systemName"]); // switch follows state on layout
                                break;
                            case "S" :
                                $widget.jsonType = "sensor"; // JSON object type
                                jmri.getSensor($widget["systemName"]);
                                break;
                            case "L":
                                $widget.jsonType = "light"; // JSON object type
                                jmri.getLight($widget["systemName"]);
                                break;
                            // more types of NamedBeans?
                            default :
                                break; // skip
                        }
                    }
                    var $canvas = "";
                    switch ($widget.shape) { // set each state's text
                        case "symbol" :
                        case "icon" :
                        case "drawing" :
                            // settings for symbol/icon
                            $widget['text' + UNKNOWN] = $widget.text; // show state changes in color, not in label?
                            $widget['text2'] = $(this).find('activeText').attr('text');
                            $widget['text4'] = $(this).find('inactiveText').attr('text');
                            $widget['text8'] = $(this).find('inconsistentText').attr('text');
                            // add a canvas to the text label, reduce canvas HxW to fit inside the div
                            $canvas = "<canvas id=" + $widget.id + "c class='bscanvas' width='" + ($swWidth - 12) + "px' height='" +
                                ($swHeight - 12) + "px' style='border:1px solid white;'></canvas>"; // to insert later
                            break;
                        case "button" : // mimick java switchboard buttons
                        default :
                            // add some html to show user name on line 2 when shape is button
                            $widget['text' + UNKNOWN] = getSwitchButtonLabel($(this).find('unknownText').attr('text'), $widget.username);
                            $widget['text2'] = getSwitchButtonLabel($(this).find('activeText').attr('text'), $widget.username);
                            $widget['text4'] = getSwitchButtonLabel($(this).find('inactiveText').attr('text'), $widget.username);
                            $widget['text8'] = getSwitchButtonLabel($(this).find('inconsistentText').attr('text'), $widget.username);
                    }
                    // common settings for all beanswitche shapes
                    $widget.classes += " " + $widget.shape + " ";
                    $widget['state'] = UNKNOWN; // use UNKNOWN for initial state

                    $widget.styles['color'] = $widget.text['color']; // use jmri color
                    // other CSS properties set in css, class .beanswitch

                    if ($widget.connected == "true") {
                        $widget['text'] = $widget.text0; // add UNKNOWN state to label of connected switches
                        $widget.styles['border-color'] = "black"; //$widget['swColor' + UNKNOWN];
                        $widget.classes += " " + $widget.jsonType + " clickable connected";
                    }

                    $gWidgets[$widget.id] = $widget; // store widget in persistent array

                    if ($widget.shape == "button") {
                        // "button", put only the text (system + user name) element on the page
                        $("#panel-area").append("<div id=" + $widget.id + " class='" + $widget.classes +
                            "'>" + $widget.text + "</div>");
                    } else {
                        // add a local canvas
                        $("#panel-area").append("<div id=" + $widget.id + " class='" + $widget.classes +
                            "' role='img'>" + $canvas + "</div>");
                    }
                    $("#panel-area>#" + $widget.id).css($widget.styles); // apply style array to widget
                    // beanswitch setup ready
                    break;

                default:
                    //log any unsupported widgets, listing childnodes as info
                    $("div#logArea").append("<br />Unsupported: " + $widget.widgetType + ":");
                    $(this.attributes).each(function() {
                        $("div#logArea").append(" " + this.name);
                    });
                    $("div#logArea").append(" | ");
                    $(this.childNodes).each(function() {
                        $("div#logArea").append(" " + this.nodeName);
                    });
                    break;
            }
            // add widget.id to whereUsed array to support updates from layout
            if ($widget.systemName) {
                if (!($widget.systemName in whereUsed)) {
                    whereUsed[$widget.systemName] = new Array();
                }
                whereUsed[$widget.systemName][whereUsed[$widget.systemName].length] = $widget.id;
            }
            if ($gWidgets[$widget.id]) {
                // store LayoutEditor occupancy sensors where-used
                if ($widget.occupancysensor != "none") {
                    $store_occupancysensor($widget.id, $widget.occupancysensor);
                }
                $store_occupancysensor($widget.id, $widget.occupancysensorA);
                $store_occupancysensor($widget.id, $widget.occupancysensorB);
                $store_occupancysensor($widget.id, $widget.occupancysensorC);
                $store_occupancysensor($widget.id, $widget.occupancysensorD);
                $store_occupancysensor($widget.id, $widget.occupancysensorAC);
                $store_occupancysensor($widget.id, $widget.occupancysensorBD);
            }
        }  //end of function
    );  //end of each

    //only enable click events if panel is marked to allow control
    if ($gPanel.controlling == "yes") {
        //hook up mouseup state toggle function to non-momentary clickable widgets, except for multisensor and linkinglabel
        $('.clickable:not(.momentary):not(.multisensoricon):not(.linkinglabel)').bind(UPEVENT, $handleClick);

        //hook up mouseup state change function to multisensor (special handling)
        $('.clickable.multisensoricon').bind('click', $handleMultiClick);

        //hook up mouseup function to linkinglabel (special handling)
        $('.clickable.linkinglabel').bind(UPEVENT, $handleLinkingLabelClick);

        //momentary widgets always go active on mousedown, and inactive on mouseup, current state is ignored
        $('.clickable.momentary').bind(DOWNEVENT, function(e) {
            e.stopPropagation();
            e.preventDefault(); //prevent double-firing (touch + click)
            sendElementChange($gWidgets[this.id].jsonType, $gWidgets[this.id].systemName, ACTIVE);  //send active on down
        }).bind(UPEVENT, function(e) {
            e.stopPropagation();
            e.preventDefault(); //prevent double-firing (touch + click)
            sendElementChange($gWidgets[this.id].jsonType, $gWidgets[this.id].systemName, INACTIVE);  //send inactive on up
        });

        // Switchboard All Off/All On buttons
        $(".lightswitch#allOff").bind(UPEVENT, $handleClickAllOff); // all Lights Off
        $(".lightswitch#allOn").bind(UPEVENT, $handleClickAllOn); // all Lights On
    }

    $drawAllDrawnWidgets(); // draw all the drawn widgets once more, to address some bidirectional dependencies in the xml
    $drawAllSwitchIcons(); // draw icon first time

    $("#activity-alert").addClass("hidden").removeClass("show");
} // end of processPanelXML


/******************************************************************
*  ======= Click Handling functions =======
*/

// perform regular click-handling, bound to click event for clickable, non-momentary widgets, except for multisensor and linkinglabel.
function $handleClick(e) {
    if (jmri_logging) {
        log.log("$handleClick()");
    }

    e.stopPropagation();
    e.preventDefault(); //prevent double-firing (touch + click)

    // if (null == $widget) {
    //     $logProperties(this);
    // }

    // special case for LE layoutSlips
    if (this.className.startsWith('layoutSlip ')) {
        if (this.id.startsWith("SL") && (this.id.endsWith("r") || this.id.endsWith("l"))) {
            var $slipID = this.id.slice(0, -1);
            var $widget = $gWidgets[$slipID];

            if (this.id.endsWith("l")) {
                $widget["side"] = "left";
            } else if (this.id.endsWith("r")) {
                $widget["side"] = "right";
            }
            if (jmri_logging) {
                log.log("\nlayoutSlip-side:" + $widget.side);
            }

            // convert current slip state to current turnout states
            var $oldStateA, $oldStateB;
            [$oldStateA, $oldStateB] = [$widget.stateA, $widget.stateB];

            // determine next slip state
            var $newState = getNextSlipState($widget);

            if (jmri_logging) {
                log.log("$handleClick:layoutSlip: change state from " +
                    slipStateToString($widget.state) + " to " + slipStateToString($newState) + ".");
            }

            // convert new slip state to new turnout states
            var $newStateA, $newStateB;
            [$newStateA, $newStateB] = getTurnoutStatesForSlipState($widget, $newState);

            if ($oldStateA != $newStateA) {
                sendElementChange($widget.jsonType, $widget.turnout, $newStateA);
            }
            if ($oldStateB != $newStateB) {
                sendElementChange($widget.jsonType, $widget.turnoutB, $newStateB);
            }
            //jmri_logging = false;
        } else {
            log.warn("$handleClick(e): unknown slip widget " + this.id);
            $logProperties(this);
        }
    // special case for LE layoutTurntable
    } else if (this.className.startsWith('layoutturntable ')) {
        var $rayID = this.id;
        var $turntableID = $rayID.split(".")[0];
        var $widget = $gWidgets[$turntableID];
        $widget.raytracks.each(function(i, item) {
            $logProperties(item);
            //note: offset 50 is due to TrackSegment.java TURNTABLE_RAY_OFFSET
            var rayID = $turntableID + ".TURNTABLE_RAY_" + (item.attributes.index.value * 1);
            if (rayID == $rayID) {
                if (isDefined(item.attributes.turnout)) {
                    var turnout = item.attributes.turnout.value;
                    var state = item.attributes.turnoutstate.value;
                    var $newState = (state == 'thrown') ? THROWN : CLOSED;
                    sendElementChange($widget.jsonType, turnout, $newState);
                }
            }
        });
    } else if (this.className.startsWith('slipturnouticon')) {
        // special handling of slipturnouticon, which has (at least) 2 turnouts
        var $widget = $gWidgets[this.id];
        var $newState = $getNextState($widget); // determine next state from current state
        var $turnoutWestNewState = 0;
        var $turnoutEastNewState = 0;
        // we may need to send a command to multiple turnouts
        switch ($newState) {
            case 5 :
                $turnoutWestNewState = CLOSED;
                $turnoutEastNewState = CLOSED;
                break;
            case 7 :
                $turnoutWestNewState = THROWN;
                $turnoutEastNewState = CLOSED;
                break;
            case 9 :
                $turnoutWestNewState = CLOSED;
                $turnoutEastNewState = THROWN;
                break;
            case 11 :
                $turnoutWestNewState = THROWN;
                $turnoutEastNewState = THROWN;
                break;
            }
        sendElementChange($widget.jsonType, $widget.turnoutWest, $turnoutWestNewState);
        sendElementChange($widget.jsonType, $widget.turnoutEast, $turnoutEastNewState);
        if (isDefined($widget.turnoutLowerWest)) {
            sendElementChange($widget.jsonType, $widget.turnoutLowerWest, $turnoutEastNewState); // note: same as turnoutWest
        }
        if (isDefined($widget.turnoutLowerEast)) {
            sendElementChange($widget.jsonType, $widget.turnoutLowerEast, $turnoutWestNewState); // note: same as turnoutEast
        }
        return;
    } else if (this.className.startsWith('audioicon ')) {
        // special handling of audioicon
        var $widget = $gWidgets[this.id];
        switch ($widget['onClickOperation']) {
            case "PlaySoundLocally":
                if ($widget['audio_widget'].paused) {   // Sound is stopped
                    $widget['audio_widget'].loop = false;
//                    $widget['audio_widget'].loop = (playNumLoops == -1);
                    $widget['audio_widget'].play();
                } else {                                // Sound is playing
                    $widget['audio_widget'].pause();
                    $widget['audio_widget'].currentTime = 0;
                }
                break;
            case "PlaySoundGlobally":
                if ($widget['state'] == 16) {           // Sound is stopped
                    jmri.setAudio($widget.systemName, "Play");
                } else if ($widget['state'] == 17) {    // Sound is playing
                    jmri.setAudio($widget.systemName, "Stop");
                }
                break;
        }
    } else if (this.className.startsWith('logixngicon ')) {
        // special handling of logixngicon
        var $widget = $gWidgets[this.id];
        jmri.clickLogixNGIcon($widget['identity']);
    } else {
        var $widget = $gWidgets[this.id];
        var $newState = $getNextState($widget); // determine next state from current state
        sendElementChange($widget.jsonType, $widget.systemName, $newState);
        //also send new state to related turnout
        if (isDefined($widget.turnoutB)) {
            sendElementChange($widget.jsonType, $widget.turnoutB, $newState);
        }
        //used for crossover, LE layoutTurnout type 5
        if (isDefined($widget.secondturnoutname)) {
            //invert 2nd turnout if requested
            if ($widget.secondturnoutinverted == "true") {
                $newState = ($newState == CLOSED ? THROWN : CLOSED);
            }
            sendElementChange($widget.jsonType, $widget.secondturnoutname, $newState);
        }
    }
}

// perform multisensor click-handling, bound to click event for clickable multisensor widgets.
function $handleMultiClick(e) {
    e.stopPropagation();
    e.preventDefault(); //prevent double-firing (touch + click)
    var $widget = $gWidgets[this.id];
    var clickX = (e.offsetX || e.pageX - $(e.target).offset().left); //get click position on the widget
    var clickY = (e.offsetY || e.pageY - $(e.target).offset().top );
    if (jmri_logging) {
        log.log("handleMultiClick X,Y on WxH: " + clickX + "," + clickY + " on " + this.width + "x" + this.height);
    }
    //increment or decrement based on where the click occurred on image
    var missed = true; //flag if click x,y outside image bounds, indicates we didn't get good values
    var dec = false;
    if ($widget.updown == "true") {
        if (clickY >= 0 && clickY <= this.height) missed = false;
        if (clickY > this.height / 2) dec = true;
    } else {
        if (clickX >= 0 && clickX <= this.width)  missed = false;
        if (clickX < this.width / 2)  dec = true;
    }
    var displaying = 0;
    for (i in $widget.siblings) {  //determine which is currently active
        if ($gWidgets[$widget.siblings[i]].state == ACTIVE) {
            displaying = i; //flag the current active sibling
        }
    }
    var next;  //determine which is the next one to be set active (loop around only if click outside object)
    if (dec) {
        next = displaying - 1;
        if (next < 0)
            if (missed)
                next = i;
            else
                next = 0;
    } else {
        next = displaying * 1 + 1;
        if (next > i)
            if (missed)
                next = 0;
            else
                next = i;
    }
    for (i in $widget.siblings) {  //loop through siblings and send changes as needed
        if (i == next) {
            if ($gWidgets[$widget.siblings[i]].state != ACTIVE) {
                sendElementChange('sensor', $gWidgets[$widget.siblings[i]].name, ACTIVE);  //set next sensor to active and send command to JMRI server
            }
        } else {
            if ($gWidgets[$widget.siblings[i]].state != INACTIVE) {
                sendElementChange('sensor', $gWidgets[$widget.siblings[i]].name, INACTIVE);  //set all other siblings to inactive if not already
            }
        }
    }
}

//perform click-handling of linkinglabel widgets (3 cases: complete url or frame:<name> where name is a panel or a frame)
function $handleLinkingLabelClick(e) {
    e.stopPropagation();
    e.preventDefault(); //prevent double-firing (touch + click)
    var $widget = $gWidgets[this.id];
    var $url = $widget.url;
    if ($url.toLowerCase().indexOf("frame:") == 0) {
        $frameName = $url.substring(6); //if "frame" found, remove it
        $frameUrl = $gPanelList[$frameName];  //find panel in panel list
        if (isUndefined($frameUrl)) {
            $url = "/frame/" + $frameName + ".html"; //not in list, open using frameserver
        } else {
            $url = "/panel/" + $frameUrl; //format for panel server
        }
    }
    window.location = $url;  //navigate to the specified url
}

function $handleClickAllOn(e) { // click button on Switchboards
    //loop thru widgets, setting each connected light to CLOSED/2, when button on top of switchboard was clicked
    jQuery.each($gWidgets, function($id, $widget) {
        if ($widget.connected == "true") {
            sendElementChange($widget.jsonType, $widget.systemName, CLOSED);
        }
    });
};

function $handleClickAllOff(e) { // click button on Switchboards
    //loop thru widgets, setting each connected light to THROWN/4, when button on top of switchboard was clicked
    jQuery.each($gWidgets, function($id, $widget) {
        if ($widget.connected == "true") {
            sendElementChange($widget.jsonType, $widget.systemName, THROWN);
        }
    });
};

// End of Click Handling functions


/******************************************************************
*  ======= (Control) Panel functions =======
*/

//draw an icon-type widget (pass in widget)
function $drawIcon($widget) {
    var $hoverText = "";
    if (isDefined($widget.hoverText)) {
        $hoverText = " title='" + $widget.hoverText + "' alt='" + $widget.hoverText + "'";
    }
    if ($hoverText == "" && isDefined($widget.name)) { // if name available, use it as hover text if still blank
        $hoverText = " title='" + $widget.name + "' alt='" + $widget.name + "'";
    }

    // additional naming for indicator*icon widgets to reflect occupancy
    $indicator = "";
    $state = "";
    if ($widget.widgetType == "indicatortrackicon" || $widget.widgetType == "indicatorturnouticon") { // check oblock status
        $indicator = ((($widget.occupancystate & 0x2) == 0x2)  ? "Occupied" : ""); // look only at bit 2, compare to $redrawIcon()
        Ostate = ($widget.occupancystate & 0xF0); // binary 11110000, discards (in)active bits in occupancy which we already used above
        $state = Ostate | $widget.state; // adds Turnout state back in to fetch TO state = position icon
        // $hoverText is updated for OUT_OF_SERVICE on redraw only
    } else if ($widget.widgetType == "slipturnouticon") { // check turnout states, compare to $redrawIcon()
        $state = $widget.slipState; // combined Turnouts state
    } else {
        $indicator = ($widget.occupancysensor && $widget.occupancystate == ACTIVE ? "Occupied" : "");
        $state = $widget.state;
    }

    // add the image to the panel area, with appropriate css classes and id (skip any unsupported)
    if (isDefined($widget['icon' + $indicator + $state])) {
        $imgHtml = "<img id=" + $widget.id + " class='" + $widget.classes +
                "' src='" + $widget["icon" + $indicator + $state].replaceAll("'","&apos;") + "' " + $hoverText + "/>"

        $("#panel-area").append($imgHtml); // put the html in the panel

        $("#panel-area>#" + $widget.id).css($widget.styles); // apply style array to widget

        // add overlay text if specified, one layer above, and copy attributes (except background-color)
        if (isDefined($widget.text)) {
            $("#panel-area").append("<div id=" + $widget.id + "-overlay class='overlay'>" + $widget.text + "</div>");
            ovlCSS = {position:'absolute', left: $widget.x + 'px', top: $widget.y + 'px', zIndex: $widget.level*1 + 1, pointerEvents: 'none'};
            $.extend(ovlCSS, $widget.styles); // append the styles from the widget
            delete ovlCSS['background-color'];  // clear the background color
            if (isDefined($widget.fixedHeight)) {
                $.extend(ovlCSS, {lineHeight: $widget.fixedHeight + 'px'}); // add lineheight for vertical centering (if set)
            }
            $("#panel-area>#" + $widget.id + "-overlay").css(ovlCSS);
        }
    } else {
        log.error("ERROR: image not defined for " + $widget.widgetType + " " + $widget.id + ", TOstate=" + $state + ", iconstate=" + $state + " ["+$indicator+"] (icon" + $indicator + $state + ")");
    }
    $setWidgetPosition($("#panel-area #" + $widget.id));
}

//draw the analog clock (pass in widget), called on each update of clock
function $drawClock($widget) {
    var $fs = $widget.scale * 100;  // scale percentage, used for text
    var $fcr = $gWidgets['IMRATEFACTOR'].state * 1; // get the fast clock rate factor from its widget
    var $h = "";
    $h += "<div class='clocktext' style='font-size:" + $fs + "%;' >" + $widget.state + "<br />" + $fcr + ":1</div>";  //add the text
    $h += "<img class='clockface' src='/web/images/clockface.png' />";              //add the clock face
    $h += "<img class='clockhourhand' src='/web/images/clockhourhand.png' />";      //add the hour hand
    $h += "<img class='clockminutehand' src='/web/images/clockminutehand.png' />";  //add the minute hand
    $("#panel-area>#" + $widget.id).html($h); //set the html for the widget

    var hours = $widget.state.split(':')[0];    //extract hours from format "H:MM AM"
    var mins = $widget.state.split(':')[1].split(' ')[0]; //extract minutes
    var hdegree = hours * 30 + (mins / 2);
    var hrotate = "rotate(" + hdegree + "deg)";
    $("div.fastclock>img.clockhourhand").css({"transform": hrotate}); //set rotation for hour hand
    var mdegree = mins * 6;
    var mrotate = "rotate(" + mdegree + "deg)";
    $("div.fastclock>img.clockminutehand").css({"transform": mrotate}); //set rotation for minute hand
}

// end of Control Panel functions


//build and return CSS array from attributes passed in
var $getTextCSSFromObj = function($widget) {
    var $retCSS = {};
    $retCSS['color'] = '';  //only clear attributes
    $retCSS['background-color'] = '';
    if (isDefined($widget.red)) {
        $retCSS['color'] = "rgb(" + $widget.red + "," + $widget.green + "," + $widget.blue + ") ";
    }
    //check for new hasBackground element, ignore background colors unless set to yes
    if (isDefined($widget.hasBackground) && $widget.hasBackground == "yes") {
        $retCSS['background-color'] = "rgb(" + $widget.redBack + "," + $widget.greenBack + "," + $widget.blueBack + ") ";
    }
    if (isUndefined($widget.hasBackground) && isDefined($widget.redBack)) {
        $retCSS['background-color'] = "rgb(" + $widget.redBack + "," + $widget.greenBack + "," + $widget.blueBack + ") ";
    }
    if (isDefined($widget.size)) {
        $retCSS['font-size'] = $widget.size + "px ";
    }
    if (isDefined($widget.fontFamily)) {
        $retCSS['font-family'] = $widget.fontFamily;
    }
    if (isDefined($widget.margin)) {
        $retCSS['padding'] = $widget.margin + "px ";
    }
    if (isDefined($widget.borderSize)) {
        $retCSS['border-width'] = $widget.borderSize + "px ";
    }
    if (isDefined($widget.redBorder)) {
        $retCSS['border-color'] = "rgb(" + $widget.redBorder + "," + $widget.greenBorder + "," + $widget.blueBorder + ") ";
        $retCSS['border-style'] = 'solid';
    }
    if (isDefined($widget.fixedWidth)) {
        $retCSS['width'] = $widget.fixedWidth + "px ";
    }
    if (isDefined($widget.fixedHeight)) {
        $retCSS['height'] = $widget.fixedHeight + "px ";
    }
    if (isDefined($widget.justification)) {
        if ($widget.justification == "centre") {
            $retCSS['text-align'] = "center";
        } else {
            $retCSS['text-align'] = $widget.justification;
        }
    }
    if (isDefined($widget.style)) {
        switch ($widget.style) { //set font based on style attrib from xml
            case "1":
                $retCSS['font-weight'] = 'bold';
                break;
            case "2":
                $retCSS['font-style'] = 'italic';
                break;
            case "3":
                $retCSS['font-weight'] = 'bold';
                $retCSS['font-style'] = 'italic';
                break;
        }
    }

    return $retCSS;
};

//get width of an html element by wrapping a copy in a div, then getting width of div
function $getElementWidth($e) {
    o = $e.clone();
    o.wrap('<div></div>').css({'position': 'absolute', 'float': 'left', 'white-space': 'nowrap', 'visibility': 'hidden'}).appendTo($('body'));
    w = o.width();
    o.remove();
    return w;
}

//place widget in correct position, rotation, z-index and scale. (pass in dom element, to simplify calling from e.load())
var $setWidgetPosition = function(e) {

    var $id = e.attr('id');
    var $widget = $gWidgets[$id];  // look up the widget and get its panel properties

    if (isDefined($widget) && isDefined(e[0])) {
        //don't bother if widget not found (never called for beanswitch)
        var $height = 0;
        var $width  = 0;
        // use html5 original sizes if available
        if (isDefined(e[0].naturalHeight)) {
            $height = e[0].naturalHeight * $widget.scale;
        } else {
            $height = e.height() * $widget.scale;
        }
        if (isDefined(e[0].naturalWidth)) {
            $width = e[0].naturalWidth * $widget.scale;
        } else {
            $width = e.width() * $widget.scale;
        }
        if ($widget.widgetFamily == "text") {  //special handling to get width of free-floating text
            $width = $getElementWidth(e) * $widget.scale;
        }

        // calculate x and y adjustment needed to keep upper left of bounding box in the same spot
        // adapted to match JMRI's NamedIcon.rotate(). Note: transform-origin set in .css file
        var tx = 0;
        var ty = 0;

        if ($height > 0 && ($widget.degrees !== 0 || $widget.scale != 1)) { // only calc offset if needed

            var $rad = $toRadians($widget.degrees);

            if (0 <= $widget.degrees && $widget.degrees < 90
                    || -360 < $widget.degrees && $widget.degrees <= -270) {
                tx = $height * Math.sin($rad);
                ty = 0.0;
            } else if (90 <= $widget.degrees && $widget.degrees < 180
                    || -270 < $widget.degrees && $widget.degrees <= -180) {
                tx = $height * Math.sin($rad) - $width * Math.cos($rad);
                ty = -$height * Math.cos($rad);
            } else if (180 <= $widget.degrees && $widget.degrees < 270
                    || -180 < $widget.degrees && $widget.degrees <= -90) {
                tx = -$width * Math.cos($rad);
                ty = -$width * Math.sin($rad) - $height * Math.cos($rad);
            } else /* if (270<=$widget.degrees && $widget.degrees<360) */{
                tx = 0.0;
                ty = -$width * Math.sin($rad);
            }
        }
        // position widget to adjusted position, set z-index, then set rotation
        e.css({
            position : 'absolute',
            left : (parseInt($widget.x) + tx) + 'px',
            top : (parseInt($widget.y) + ty) + 'px',
            zIndex : $widget.level
        });
        if ($widget.degrees !== 0) {
            var $rot = "rotate(" + $widget.degrees + "deg)";
            e.css({
                "transform" : $rot
            });
        }
        // set new height and width if scale specified
        if ($widget.scale != 1 && $height > 0) {
            e.css({
                height : $height + 'px',
                width : $width + 'px'
            });
        }
        // if this is an image that's rotated or scaled, set callback to
        // reposition on every icon load, as the icons can be different sizes.
        if (e.is("img") && ($widget.degrees !== 0 || $widget.scale != 1.0)) {
            e.unbind('load');
            e.load(function() {
                $setWidgetPosition($(this));
            });
        }

    }
};

// reDraw an icon-based widget to reflect changes to state or occupancy
var $reDrawIcon = function($widget) {
    // additional naming for indicator*icon widgets to reflect occupancy, error presendence status was already filtered in updateOblocks()
    $indicator = "";
    if ($widget.widgetType == "indicatortrackicon" || $widget.widgetType == "indicatorturnouticon") { // check oblock status
        $indicator = ((($widget.occupancystate & 0x2) == 0x2)  ? "Occupied" : ""); // look only at bit 2, compare to $drawIcon()
        Ostate = ($widget.occupancystate & 0xF0); // binary + 11110000, discards (in)active occupancy info in bits 1-4
        $state = (Ostate | $widget.state); // adds Turnout state back in to insert TO state = position icon
        if (isDefined($widget.name)) { // intended for indicatorturnouts to show they are not clickable
            $('img#' + $widget.id).attr('title', $widget.name + ((Ostate & 0x40) == OUT_OF_SERVICE ? " (off)" : ""));
            // explain why not clickable TODO I18N tooltip for OOS + ERROR
        }
    } else if ($widget.widgetType == "slipturnouticon") {
        $state = $widget.slipState; // widget is not a bean, fetch combined state as stored in widget, calculated from 2 turnout states
        //log.log("STI $redrawIcon state: " + $state);
        // adjust some states, copied from Display/SlipTurnoutIcon#displayState(int state), not required?
        //        if ($widget.turnoutType == "scissor") {
        //            switch ($state) {
        //                case 5 :
        //                    log.log("########### STI $redrawIcon state: " + $state + " set to 0 for Scissor");
        //                    $state = 0;
        //                    break;
        //            }
        //        }
    } else { // default handling
        $indicator = ($widget.occupancysensor && $widget.occupancystate == ACTIVE ? "Occupied" : "");
        $state = $widget.state;
    }
    // set image src to requested state's image, if defined
    if ($widget['icon' + $indicator + $state]) {
        $('img#' + $widget.id).attr('src', $widget['icon' + $indicator + ($state + "")]);
    } else if ($widget['defaulticon']) {  // if state icon not found, use default icon if provided
        $('img#' + $widget.id).attr('src', $widget['defaulticon']);
    } else {
        log.error("ERROR: image not defined for " + $widget.widgetType + " " + $widget.id + ", state=" + $widget.state + ", status=" + $widget.occupancystate + ", iconstate=" + $state + " ["+$indicator+"] (icon" + $indicator + $state + ")");
    }
};

// set new value for widget, showing proper icon, return widgets changed
var $setWidgetState = function($id, $newState, data) {
    var $widget = $gWidgets[$id];

    // if undefined widget this must be a LE slip or a PE slipTurnoutIcon
    if (isUndefined($widget)) {
        // does it have "e" or "w" suffix? it's a slipTurnoutIcon
        if ($id.endsWith("e") || $id.endsWith("w")) {
            if (jmri_logging) {
                log.log("$setWidgetState STI " + $id + " to state " + $newState);
            }
            // remove suffix
            var $slipID = $id.slice(0, -1);
            // get the slip widget
            $widget = $gWidgets[$slipID];
            // determine combined slipState for icon0/5/7/9/11
            $turnoutName = data.name; // systemName
            //log.log("change from turnout: " + $turnoutName + " to state: " + $newState);
            if (($turnoutName == $widget.turnoutEast) || (data.userName == $widget.turnoutEast)) {
                // east turnout                          // also compare source by userName
                $widget.slipStateEast = $newState; // store turnout state e
            } else if (($turnoutName == $widget.turnoutWest) || (data.userName == $widget.turnoutWest)) {
                // west turnout                                 // also compare source by userName
                $widget.slipStateWest = $newState; // store turnout state w
            }
            // handle changes from the 2 extra turnouts, if defined (they mirror the basic e and w turnouts)
            // only CCCC, TCCT and CTTC are valid turnout state combinations (for slipState 5, 7 and 9 respectively)
            if (($turnoutName == $widget.turnoutLowerWest) || (data.userName == $widget.turnoutLowerWest)) {
                // scissor additional west turnout, handle like turnoutEast
                if (($newState == CLOSED) && ((($widget.slipStateWest == CLOSED) && ($widget.slipStateEast == CLOSED)) ||
                    (($widget.slipStateWest == THROWN) && ($widget.slipStateEast == CLOSED)))) {
                        $widget.slipStateEast = $newState;
                } else if (($newState == THROWN) && ($widget.slipStateWest == CLOSED) && ($widget.slipStateEast == THROWN)) {
                        $widget.slipStateEast = $newState;
                } else {
                    $newState = INCONSISTENT;
                }
            }
            if (($turnoutName == $widget.turnoutLowerEast) || (data.userName == $widget.turnoutLowerEast)) {
                // scissor additional east turnout, handle like turnoutWest
                if (($newState == CLOSED) && ((($widget.slipStateWest == CLOSED) && ($widget.slipStateEast == CLOSED)) ||
                    (($widget.slipStateWest == CLOSED) && ($widget.slipStateEast == THROWN)))) {
                        $widget.slipStateWest = $newState;
                } else if (($newState == THROWN) && ($widget.slipStateWest == THROWN) && ($widget.slipStateEast == CLOSED)) {
                        $widget.slipStateWest = $newState;
                } else {
                    $newState = INCONSISTENT;
                }
            }

            if ($widget.slipStateWest == UNKNOWN || $widget.slipStateEast == UNKNOWN) {
                $widget.slipState = UNKNOWN; // incomplete inputs, set state UNKNOWN
            } else if ($newState == INCONSISTENT) {
                $widget.slipState = INCONSISTENT;
            } else {
                // fix some special sequences, as in java/src/jmri/jmrit/display/SlipTurnoutIcon.java#displayState(state)
                if ($widget.turnoutType == "threeWay" && $widget.slipStateWest == THROWN && $widget.slipStateEast == CLOSED) {
                    // ignore slipStateEast CLOSED (slipstate 7), use slipstate 11 instead, like Panel SlipTurnoutIcon.java
                    $widget.slipState = (THROWN << 1) | ($widget.slipStateWest >> 1) | 0x01;
                } else {
                    $widget.slipState = ($widget.slipStateEast << 1) | ($widget.slipStateWest >> 1) | 0x01;
                }
            }
            log.log("#### $setWidgetState(slipturnouticon " + $slipID + ", " + $widget.slipState +
                "); (was " + $widget.slipState + ")");
            $newState = $widget.slipState;
            // is overwritten by $newState at end of method, so temp only to pass next if-statement and redraw correctly
            $id = $slipID;

        // does it have "l" or "r" suffix? it's an LE slip
        } else if ($id.endsWith("l") || $id.endsWith("r")) {
            if (jmri_logging) {
                log.log("\n#### INFO: clicked slip " + $id + " to state " + $newState);
            }

            // remove suffix
            var $slipID = $id.slice(0, -1);
            // get the slip widget
            $widget = $gWidgets[$slipID];

            // convert current slip state to current turnout states
            var $stateA, $stateB;
            //[$stateA, $stateB] = getTurnoutStatesForSlip($widget);
            //[$stateA, $stateB] = [$widget.turnout.state, $widget.turnoutB.state];
            [$stateA, $stateB] = [$widget.stateA, $widget.stateB];
            $widget.state = getSlipStateForTurnoutStates($widget, $stateA, $stateB);
            if (jmri_logging) {
                log.log("#### Slip " + $widget.name +
                    " before: " + slipStateToString($widget.state) +
                    ", stateA: " + turnoutStateToString($stateA) +
                    ", stateB: " + turnoutStateToString($stateB));
            }

            // change appropriate turnout state
            if ($id.endsWith("r")) {
                if ($stateA != $newState) {
                    if (jmri_logging) {
                        log.log("#### Changed r slip " + $widget.name +
                            " $stateA from " + turnoutStateToString($stateA) +
                            " to " + turnoutStateToString($newState));
                    }
                    $stateA = $newState;
                    $widget.stateA = $stateA;
                }
            } else if ($id.endsWith("l")) {
                if ($stateB != $newState) {
                    if (jmri_logging) {
                        log.log("#### Changed l slip " + $widget.name +
                            " $stateB from " + turnoutStateToString($stateB) +
                            " to " + turnoutStateToString($newState));
                    }
                    $stateB = $newState;
                    $widget.stateB = $stateB;
                }
            }

            // turn turnout states back into slip state
            $newState = getSlipStateForTurnoutStates($widget, $stateA, $stateB);
            if (jmri_logging) {
                log.log("#### Slip " + $widget.name +
                    " after: " + slipStateToString($newState) +
                    ", stateA: " + turnoutStateToString($stateA) +
                    ", stateB: " + turnoutStateToString($stateB));
            }

            if ($widget.state != $newState) {
               if (jmri_logging) {
                   log.log("#### Changing slip " + $widget.name + " from " + slipStateToString($widget.state) +
                       " to " + slipStateToString($newState));
               }
            }
            //jmri_logging = false;

            // set $id to slip id
            $id = $slipID;
        } else if ($id.startsWith("TUR")) {
            //log.log("$setWidgetState(" + $id + ", " + $newState + ", " + data + ")");
            $logProperties(data);

            var turntableID = $id.split(".")[0];
            $widget = $gWidgets[turntableID];
            $widget['activeRayID'] = $id;
            $widget['activeRayTurnout'] = data.name;
            $widget['activeRayState'] = turnoutStateToString($newState);
            $drawTurntable($widget);
            return;
        } else {
            if (jmri_logging) {
                log.log("$setWidgetState unknown $id: '" + $id + "'.");
            }
            return;
        }
    } else if ($widget.widgetType == 'layoutSlip') {
        // JMRI doesn't send slip states, it sends slip turnout states
        // so ignore this (incorrect) slip state change
        if (jmri_logging) {
            log.log("#### $setWidgetState(slip " + $id + ", " + slipStateToString($newState) +
                "); (was " + slipStateToString($widget.state) + ")");
        }
        return;
    }

    if ($widget.state !== $newState) { // don't bother if already this value
        if (jmri_logging) {
            log.log("JMRI changed " + $id + " (" + $widget.jsonType + " " + $widget.name + ") from state '" + $widget.state + "' to '" + $newState + "'.");
        }
        if (data.type == "sensor" && ($widget.widgetType == "indicatortrackicon" || $widget.widgetType == "indicatortrackicon")) {
            $widget.occupancystate = $newState;
        } else { // standard handling of icon widgets
            $widget.state = $newState;
        }
        // override the state with idTag's "name" in a very specific circumstance
        if (($widget.jsonType == "memory" || $widget.jsonType == "block" || $widget.jsonType == "reporter" ) &&
                $widget.widgetFamily == "icon" && data.value !== null && data.value.type == "idTag") {
            $widget.state = data.value.data.name;
        }

        switch ($widget.widgetFamily) {
            case "icon" :
                if ($widget.widgetType == "indicatortrackicon" || $widget.widgetType == "indicatortrackicon") {
                    if ($widget.occupancysensor != "none") {
                        $widget.occupancystate = $newState;
                        //console.log("SET widget " + $widget.id + " to state=" + $newState);
                    } else if ($widget.occupancyblock != "none") { // expected for turnout
                        // if defined, follow the occupancyblock and ignore any sensors, don't set widget.state (used for turnout state)
                        // only pick up the turnout state change, bits 0-4
                        $widget.state = ($newState & 0xF) ;
                        //console.log("WARNING UNEXPECTED ITOI widget=" + $widget.id + " to state=" + $newState); // TODO clean up
                    }
                }
                $reDrawIcon($widget);
                break;
            case "text" :
                if ($widget.jsonType == "memory" || $widget.jsonType == "block" || $widget.jsonType == "reporter" ) {
                    if ($widget.widgetType == "fastclock") {
                        $drawClock($widget);
                    } else { // set memory/block/reporter text or html to new value from server, clearing "null"
                        if ($newState == null) {
                            $('div#' + $id).text("");
                        } else if ($newState.startsWith("<html>")) {
                            $('div#' + $id).html($newState);
                        } else {
                            $('div#' + $id).text($newState);
                        }
                    }
                } else {
                    if (isDefined($widget['text' + $newState])) {
                        $('div#' + $id).text($widget['text' + $newState]); // set text to new state's text
                    }
                    if (isDefined($widget['css' + $newState])) {
                        $('div#' + $id).css($widget['css' + $newState]); // set css to new state's css
                    }
                }
                break;
            case "drawn" :
                if ($widget.widgetType == "layoutturnout") {
                    $drawTurnout($widget);
                } else if ($widget.widgetType == 'layoutSlip') {
                    $drawSlip($widget);
                }
                break;
            case "switch" : // Switchboard
                if ($widget.widgetType == "beanswitch" && isDefined($widget['shape'])) {
                    if ($widget.shape == "button") { // update div css
                        $('div#' + $id).text($widget['text' + $newState]); // set text to new state's text
                        $('div#' + $id).css({"background-color": $widget['swColor' + $newState]});
                    } else { // icon, symbol, slider (drawing) are directly drawn on canvas
                        $widget.text = $widget['text' + $newState]; // set text in Widget to new state's text
                        $drawWidgetSymbol($id, $newState);
                    } // for newly created items, reload web page to activate json binding
                }
                break;
        }
        $gWidgets[$id].state = $newState;  // update the persistent widget to the new state
    }
};

//return a unique ID # when called
var $gUnique = function() {
    if (isUndefined($gUnique.id)) {
        $gUnique.id = 0;
    }
    $gUnique.id++;
    return $gUnique.id;
};

//clean up a name, for example to use as an id
var $safeName = function($name) {
    if (isUndefined($name)) {
        return "unique-" + $gUnique();
    } else {
        return $name.replace(/:/g, "_").replace(/ /g, "_").replace(/%20/g, "_");
    }
};

//send request for state change
var sendElementChange = function(type, name, state) {
    //log.log("Sending JMRI " + type + " '" + name + "' state '" + state + "'.");
    jmri.setObject(type, name, state);
};

//show unexpected ajax errors
$(document).ajaxError(function(event, xhr, opt, exception) {
    if (xhr.statusText != "abort" && xhr.status != 0) {
        var $msg = "AJAX Error requesting " + opt.url + ", status= " + xhr.status + " " + xhr.statusText;
        $('div#messageText').text($msg);
        $("#activity-alert").addClass("show").removeClass("hidden");
        $('dvi#workingMessage').position({within: "window"});
        log.log($msg);
        return;
    }
    if (xhr.statusText == "timeout") {
        var $msg = "AJAX timeout " + opt.url + ", status= " + xhr.status + " " + xhr.statusText + " resending list....";
        log.log($msg);
        // TODO: need to recover somehow
    }
});

//clear out whitespace from xml, function adapted from
//http://stackoverflow.com/questions/1539367/remove-whitespace-and-line-breaks-between-html-elements-using-jquery/3103269#3103269
jQuery.fn.xmlClean = function() {
    this.contents().filter(function() {
        if (this.nodeType != 3) {
            $(this).xmlClean();
            return false;
        }
        else {
            return !/\S/.test(this.nodeValue);
        }
    }).remove();
}

// handle the toggling (or whatever) of the "next" state for the passed-in widget
var $getNextState = function($widget) {
    var $nextState = undefined;
    $logProperties($widget);

    if ($widget.widgetType == 'signalheadicon') { //special case for signalheadicons
        switch ($widget.clickmode * 1) {          //   logic based on SignalHeadIcon.java
            case 0 :
                switch ($widget.state * 1) {  // (* 1 is to insure numeric comparisons)
                    case RED:
                    case FLASHRED:
                        $nextState = YELLOW;
                        break;
                    case YELLOW:
                    case FLASHYELLOW:
                        $nextState = GREEN;
                        break;
                    default: //also catches GREEN and FLASHGREEN
                        $nextState = RED;
                        break;
                }
            case 1 :
                // TODO: handle lit/unlit toggle
                // getSignalHead().setLit(!getSignalHead().getLit());
                break;
            case 2 :
                // getSignalHead().setHeld(!getSignalHead().getHeld());
                $nextState = ($widget.state * 1 == HELD ? RED : HELD);  //toggle between red and held states
                break;
            case 3: // loop through all elements, finding iconX and get "next one", skipping special ones
                var $firstState = undefined;
                var $currentState = undefined;
                for (k in $widget) {
                    var s = k.substr(4) * 1; //extract the state from current icon var, insure it is treated as numeric
                    //get valid value, name starts with 'icon', but not the HELD or DARK ones
                    if (k.indexOf('icon') == 0 && isDefined($widget[k]) && k != 'icon' + HELD && k != 'icon' + DARK) {
                        if (isUndefined($firstState))
                            $firstState = s;  //remember the first state (for last one)
                        if (isDefined($currentState) && isUndefined($nextState))
                            $nextState = s; //last one was the current, so this one must be next
                        if (s == $widget.state)
                            $currentState = s;
                        // log.log('key: '+k+" first="+$firstState+" current="+$currentState+" next="+$nextState);
                    }
                }
                if (isUndefined($nextState))
                    $nextState = $firstState;  // if still not set, start over
        }

    } else if ($widget.widgetType == 'signalmasticon') { // special case for signalmasticons
        // loop through all elements, finding iconXXX and get next iconXXX, skipping special ones
        switch ($widget.clickmode * 1) {          //   logic based on SignalMastIcon.java
            case 0 :
                var $firstState = undefined;
                var $currentState = undefined;
                for (k in $widget) {
                    var s = k.substr(4); //extract the state from current icon var
                    //look for next icon value, skipping Held, Dark and Unknown
                    if (k.indexOf('icon') == 0 && isDefined($widget[k]) && s != 'Held' && s != 'Dark'
                    && s !='Unlit' && s !=  'Unknown') {
                        if (isUndefined($firstState))
                            $firstState = s;  // remember the first state (for last one)
                        if (isDefined($currentState) && isUndefined($nextState))
                            $nextState = s; // last one was the current, so this one must be next
                        if (s == $widget.state)
                            $currentState = s;
                    }
                };
                if (isUndefined($nextState))
                    $nextState = $firstState;  // if still not set, start over
                break;

            case 1 :
                //TODO: handle lit/unlit states
                break;

            case 2 :
                //toggle between stop and held state
                $nextState = ($widget.state == "Held" ? "Stop" : "Held");
                break;

            };

    } else if ($widget.widgetType == 'slipturnouticon') {
        // slipturnouticons store the current state in .slipState, not .state
        switch ($widget.turnoutType) { // logic based on java/src/jmri/jmrit/display/SlipTurnoutIcon.java
            case "doubleSlip" :
                $nextState = ($widget.slipState == 11 ? 5 : $widget.slipState + 2);
                break;
            case "singleSlip" :
                if ($widget.singleSlipRoute == "lowerWestToLowerEast") {
                    switch ($widget.slipState) {
                        case 5 :
                            $nextState = 9;
                            break;
                        case 9 :
                            $nextState = 11;
                            break;
                        case 11 :
                            $nextState = 5;
                            break;
                    }
                } else if ($widget.singleSlipRoute == "upperWestToUpperEast") {
                    switch ($widget.slipState) {
                        case 5 :
                            $nextState = 11;
                            break;
                        case 7 :
                            $nextState = 5;
                            break;
                        case 11 :
                            $nextState = 7;
                            break;
                    }
                }
                break;
            case "threeWay" :
                if ($widget.firstTurnoutExit == "lower") {
                    $nextState = ($widget.slipState == 9 ? 5 : $widget.slipState + 2);
                } else { // $widget.firstTurnoutExit == "upper"
                    switch ($widget.slipState) {
                        case 5 :
                            $nextState = 9;
                            break;
                        case 9 :
                            $nextState = 11;
                            break;
                        case 11 :
                            $nextState = 5;
                            break;
                    }
                }
                break;
            case "scissor" :
                $nextState = ($widget.slipState == 9 ? 5 : $widget.slipState + 2);
                    // State 11 not allowed for a scissor
                    // does not provide 5 after 7 as it would require extra logic
                    $nextState = ($widget.slipState == 9 ? 5 : $widget.slipState + 2);
                break;
            };

    } else {  // default: start with INACTIVE, then toggle to ACTIVE and back (same for turnout states: 2 <> 4)
        $nextState = ($widget.state == ACTIVE ? INACTIVE : ACTIVE);
    }

    if (isUndefined($nextState))
        $nextState = $widget.state;  //default to no change
    return $nextState;
};

// preload all images referred to by the widget
var $preloadWidgetImages = function($widget) {
    for (k in $widget) {
        if (k.indexOf('icon') == 0 && isDefined($widget[k]) && $widget[k] !== "yes") {
        //if attribute names starts with 'icon', it's an image, so preload it
            $("<img src='" + $widget[k] + "'/>");
        }
    }
};

// determine widget "family" for broadly grouping behaviors
// note: not-yet-supported widgets are commented out here so as to return undefined
var $getWidgetFamily = function($widget, $element) {

    if (($widget.widgetType == "positionablelabel" || $widget.widgetType == "linkinglabel"
            || $widget.widgetType == "audioicon" || $widget.widgetType == "logixngicon")
            && isDefined($widget.text)) {
        return "text";  //special case to distinguish text vs. icon labels
    }
    if ($widget.widgetType == "sensoricon" && $widget.icon == "no") {
        return "text";  //special case to distinguish text vs. icon labels
    }
    if ($widget.widgetType == "memoryicon" && $($element).find('memorystate').length == 0) {
        return "text";  //if no memorystate icons, treat as text
    }
    switch ($widget.widgetType) {
        case "locoicon" :
        case "trainicon" :
        case "memoryComboIcon" :
        case "memoryInputIcon" :
        case "fastclock" :
        case "BlockContentsIcon" :
        case "reportericon" :
            return "text";
            break;
        case "positionablelabel" :
        case "audioicon" :
        case "logixngicon" :
        case "linkinglabel" :
        case "turnouticon" :
        case "sensoricon" :
        case "LightIcon" :
        case "multisensoricon" :
        case "signalheadicon" :
        case "signalmasticon" :
        case "indicatortrackicon" :
        case "indicatorturnouticon" :
        case "memoryicon" :
        case "slipturnouticon" :
            return "icon";
            break;
        case 'layoutSlip' :
        case "layoutturnout" :
        case "tracksegment" :
        case "positionablepoint" :
        case "backgroundColor" :
        case "layoutblock" :
        case "levelxing" :
        case "layoutturntable" :
        case "layoutShape" :
        case "positionableRectangle" :
        case "positionableRoundRect" :
        case "positionableCircle" :
        case "positionableEllipse" :
            return "drawn";
            break;
        case "beanswitch" :
            return "switch";
            break;
    }
    log.log("unhandled widget type of '" + $widget.widgetType +"' id = "+$widget.id);
    return; //unrecognized widget returns undefined
};

function listPanels(name) {
    $.ajax({
        url: "/panel/?format=json",
        data: {},
        success: function(data, textStatus, jqXHR) {
            if (data.length !== 0) {
                $.each(data, function(index, value) {
                    $gPanelList[value.data.userName] = value.data.name;
                });
            }
            if (name === null || typeof (panelName) === undefined) {
                if (data.length !== 0) {
                    $("#panel-list").empty();
                    $("#activity-alert").addClass("hidden").removeClass("show");
                    $("#panel-list").addClass("show").removeClass("hidden");
                    $.each(data, function(index, value) {
                        $("#panel-list").append("<div class=\"col-sm-6 col-md-4 col-lg-3\"><div class=\"thumbnail\"><a href=\"/panel/" + value.data.name + "\"><div class=\"thumbnail-image\"><img src=\"/panel/" + value.data.name + "?format=png\" style=\"width: 100%;\"></div><div class=\"caption\">" + value.data.userName + "</div></a></div></div>");
                        // (12 / col-lg-#) % index + 1
                        if (4 % (index + 1)) {
                            $("#panel-list").append("<div class=\"clearfix visible-lg\"></div>");
                        }
                        // (12 / col-md-#) % index + 1
                        if (3 % (index + 1)) {
                            $("#panel-list").append("<div class=\"clearfix visible-md\"></div>");
                        }
                        // (12 / col-sm-#) % index + 1
                        if (2 % (index + 1)) {
                            $("#panel-list").append("<div class=\"clearfix visible-sm\"></div>");
                        }
                    });
                    // resizeThumbnails(); // sometimes gets .thumbnail sizes too small under image. TODO Fix it
                } else {
                    $("#activity-alert").addClass("hidden").removeClass("show");
                    $("#warning-no-panels").addClass("show").removeClass("hidden");
                }
            }
        }
    });
}

function resizeThumbnails() {
    tallest = 0;
    $(".thumbnail-image").each(function() {
        thisHeight = $("img", this).height();
        if (thisHeight > tallest) {
            tallest = thisHeight;
        }
    });
    $(".thumbnail-image").each(function() {
        $(this).height(tallest);
    });
}

$(window).resize(function() {
    resizeThumbnails();
});

//-----------------------------------------javascript processing starts here (main) ---------------------------------------------
$(document).ready(function() {
    // get panel name if passed as a parameter
    var panelName = getParameterByName("name");
    // get panel name if part of the path
    if (panelName === null || typeof (panelName) === undefined) {
        var path = $(location).attr('pathname');
        path = path.split("/");
        if (path.length > 3) {
            panelName = path[path.length - 2] + "/" + path[path.length - 1];
        }
    }
    // setup the functional menu items
    $("#navbar-panel-reload > a").attr("href", location.href);
    $("#navbar-panel-xml > a").attr("href", location.href + "?format=xml");
    // show panel thumbnails if no panel name
    listPanels(panelName);
    if (panelName === null || typeof (panelName) === undefined) {
        $("#panel-list").addClass("show").removeClass("hidden");
        $("#panel-area").addClass("hidden").removeClass("show");
        // hide the Show XML menu when listing panels
        $("#navbar-panel-xml").addClass("hidden").removeClass("show");
    } else {
        // note: the functions and parameter names must match exactly those in web/js/jquery.jmri.js
        // see for example jmri/server/json/turnout/turnout-server.json
        jmri = $.JMRI({
            didReconnect: function() {
                // if a reconnect is triggered, reload the page - it is the
                // simplest method to refresh every object in the panel
                log.log("Reloading at reconnect");
                location.reload(false);
            },
            audio: function(name, state, data) {
                $.each(whereUsed[name], function(index, widgetId) {
                    $widget = $gWidgets[widgetId];
                    $widget['state'] = state;

                    if (state == 16 && $widget['stopSoundWhenJmriStops']) {         // Sound is stopped
                        $widget['audio_widget'].pause();
                        $widget['audio_widget'].currentTime = 0;
                    } else if (state == 17 && $widget['playSoundWhenJmriPlays']) {  // Sound is playing
                        $widget['audio_widget'].currentTime = 0;
                        $widget['audio_widget'].loop = (data.playNumLoops == -1);
                        $widget['audio_widget'].play();
                    }
                });
            },
            audioicon: function(identity, command, playNumLoops) {
                $widget = audioIconIDs['audioicon:'+identity];
                if (command == "Play") {
                    $widget['audio_widget'].loop = (playNumLoops == -1);
                    $widget['audio_widget'].play();
                } else if (command == "Stop") {
                    $widget['audio_widget'].pause();
                    $widget['audio_widget'].currentTime = 0;
                }
            },
            light: function(name, state, data) {
                updateWidgets(name, state, data);
            },
            block: function(name, value, data) {
                //console.log("HEARD BLOCK " + name + " value=" + value);
                if (value !== null) {
                    if (value.type == "idTag") {
                        value = value.data.userName; // for idTags, use the value in userName instead
                    } else if (value.type == "reporter") {
                        value = value.data.value;    // for reporters, use the value in data instead
                    } else if (value.type == "rosterEntry") {
                        if (value.data.icon !== null) {
                            value = "<html><img src='" + value.data.icon + "'></html>"; // for rosterEntries, create an image tag instead
                        } else {
                            value = value.data.name; // if roster icon not set, just show the name
                        }
                    }
                }
                updateWidgets(name, value, data);
            },
            oblock: function(name, status, data) { // data contains data.status (Allocated, Occupied,... not state)
                //console.log("HEARD JSON OBLOCK " + name + " status=" + status + " (" + data.status + ")");
                if (data.status !== null) {
                    updateOblocks(name, data.status); // only for indicator(turnout)trackicon widgets
                }
            },
            layoutBlock: function(name, value, data) {
                setBlockColor(name, data.blockColor);
            },
            memory: function(name, value, data) {
                if (value !== null) {
                    //console.log("MEMORY " + name + " value=" + value + " data=" + data);
                    if (value.type == "idTag") {
                        value = value.data.userName; // for idTags, use the value in userName instead
                    } else if (value.type == "reporter"){
                        value = value.data.value;    // for reporters, use the value in data instead
                    } else if (value.type == "rosterEntry") {
                        if (value.data.icon !== null) {
                            value = "<html><img src='" + value.data.icon + "'></html>"; // for rosterEntries, create an image tag instead
                        } else {
                            value = value.data.name; // if roster icon not set, just show the name
                        }
                    }
                }
                updateWidgets(name, value, data);
            },
            reporter: function(name, value, data) {
                //console.log("REPORTER " + name + " value=" + value + " data=" + data);
                updateWidgets(name, value, data);
            },
            route: function(name, state, data) {
                updateWidgets(name, state, data);
            },
            sensor: function(name, state, data) {
                updateOccupancy(name, state, data);
                //console.log("Sensor " + name + " state=" + state);
                updateWidgets(name, state, data);
            },
            signalHead: function(name, state, data) {
                updateWidgets(name, state, data);
            },
            signalMast: function(name, state, data) {
                updateWidgets(name, state, data);
            },
            turnout: function(name, state, data) {
                //console.log("Turnout " + name + " state=" + state);
                updateWidgets(name, state, data);
            }
        });
        $("#panel-list").addClass("hidden").removeClass("show");
        $("#panel-area").addClass("show").removeClass("hidden");

        // include name of panel in page title. Will be updated to userName later
        setTitle("Loading " + panelName + "...");

        //get updates to fast clock rate
        getRateFactor();

        // request actual xml of panel, and process it on return
        // uses setTimeout simply to not block other JavaScript since
        // requestPanelXML has a long timeout
        setTimeout(function() {
            requestPanelXML(panelName);
        }, 500);
    }
});

//------------------------------------------- end of main -------------------------------------------

// Add Widget to store fastclock rate
function getRateFactor() {
    $widget = new Array();
    $widget.jsonType = "memory";
    $widget['name'] = "IMRATEFACTOR";  // already defined in JMRI
    $widget['id'] = $widget['name'];
    $widget['safeName'] = $widget['name'];
    $widget['systemName'] = $widget['name'];
    $widget['state'] = "1.0";
    $gWidgets[$widget.id] = $widget;
    if (!($widget.systemName in whereUsed)) {  //set where-used for this new memory
        whereUsed[$widget.systemName] = new Array();
    }
    whereUsed[$widget.systemName][whereUsed[$widget.systemName].length] = $widget.id;
}

/******************************************************************
*  ======= Switchboard functions =======
*/

// used to find largest tiles on Switchboard screen
function autoRows(screenwidth, screenheight) {
    // calculations repeated from SwitchboardEditor for web display
    // find cell matrix that allows largest size icons
    var $cellProp = 1; // assume square tiles prop 1:1 to keep it simple for now
    var $paneEffectiveWidth = Math.ceil(screenwidth / $cellProp);
    var $columnsNum = 1;
    var $rowsNum = 1;
    var $tileSize = 0.1; // start value
    var $tileSizeOld = 0;
    var $totalDisplayed = Math.max($total, 1); // if all items unconnected and set to be hidden, use 1
    while ($tileSize > $tileSizeOld) {
        $rowsNum = ($totalDisplayed + $columnsNum - 1) / $columnsNum; // roundup int
        $tileSizeOld = $tileSize; // store for comparison
        $tileSize = Math.min(($paneEffectiveWidth / $columnsNum), ((screenheight - 90) / $rowsNum));
        // screenheight-90px to leave room for menubar
        if ($tileSize <= $tileSizeOld) {
            break;
        }
        $columnsNum++;
    }
    return $rowsNum;
}

function getSwitchButtonLabel(label, subLabel) {
    if (($showUserName == "no") || (subLabel == "") || isUndefined(subLabel)) {
        return label;
    } else {
        subLabel = subLabel.substring(0, (Math.min(subLabel.length, 25)));
        return label + " (" + subLabel + ")"; // will wrap but TODO show on 2 lines of text
    }
}

// Draw symbol on the beanswitch widget canvas
var $drawWidgetSymbol = function(id, state) {
    // draw on $widget canvas
    var $canvas = document.getElementById(id + "c");
    var shape = $gWidgets[id].shape;
    if (shape == "button" || typeof $canvas === null) {
        return; // no canvas created (shape = "buttons")
    }
    var ctx = $canvas.getContext("2d");
    ctx.save();
    // backgroundcolor shows through by inherit
    ctx.clearRect(0, 0, $canvas.width, $canvas.height); //  for alternating text and 'moving' items

    ctx.fillStyle = (state == "2" ? $activeColor : $inactiveColor); // simple change in color
    ctx.strokeStyle = "black";
    ctx.translate($canvas.width/2, $canvas.height/2); // origin in center of canvas, easy!
    var radius = Math.min($canvas.width * 0.3, $canvas.height * 0.3);

    switch (shape) {
        // draw methods
        case "icon" : // slider, 1 shape for all switchtypes (S, T, L)
            ctx.beginPath(); // the sliderspace
            if (state == "2") {
                ctx.strokeStyle = $activeColor;
            } else if (state == "4") {
                ctx.strokeStyle = $inactiveColor;
            } else {
                ctx.strokeStyle = "darkgray";
            }
            ctx.lineCap = "round";
            ctx.lineWidth = radius;
            ctx.moveTo(-radius/2, 0);
            ctx.lineTo(radius/2, 0);
            ctx.stroke();
            ctx.beginPath(); // the knob
            var knobX = (state == "2" ? radius/2 : -radius/2);
            ctx.arc(knobX, 0, radius/2, 0, 2 * Math.PI);
            ctx.fillStyle = "white";
            ctx.fill();
            ctx.strokeStyle = "black";
            ctx.lineWidth = 1;
            ctx.stroke();
            break;
        case "drawing" : // Maerklin Keyboard, 1 shape for all switchtypes (S, T, L)
            // red = upper rounded rect
            ctx.fillStyle = (state == "2" ? $activeColor : "pink");
            ctx.fillRect(-0.5*radius, -1.1*radius, radius, radius/3);
            // + rounded outline
            ctx.lineJoin = "round";
            ctx.lineWidth = radius/5;
            ctx.strokeStyle = (state == "2" ? $activeColor : "pink");
            ctx.strokeRect(-0.5*radius, -1.1*radius, radius, radius/3);
            // green = lower rounded rect
            ctx.fillStyle = (state == "4" ? $inactiveColor : "lightgreen");
            ctx.fillRect(-0.5*radius, 1.1*radius, radius, radius/-3);
            // + rounded outline
            ctx.lineJoin = "round";
            ctx.lineWidth = 10;
            ctx.strokeStyle = (state == "4" ? $inactiveColor : "lightgreen");
            ctx.strokeRect(-0.5*radius, 1.1*radius, radius, radius/-3);
            // add round LED at top
            var grd = ctx.createRadialGradient(-0.1*radius, -1.4*radius, 0.5*radius, 0.1*radius, -1.6*radius, 0);
            grd.addColorStop(0, (state == "2" ? $activeColor : "lightgray"));
            grd.addColorStop(1, "white");
            ctx.fillStyle = grd;
            ctx.arc(0, -1.55*radius, radius/6, 0, 2 * Math.PI);
            ctx.fill();
            ctx.lineWidth = 0.2;
            ctx.strokeStyle = "black";
            ctx.stroke();
            break;
        case "symbol" : // Mimic classic icons as vector drawing, specific shape per switchtype (S, T, L)
            switch ($gWidgets[id].type) {
                case "L" : // light
                    // line (wire) at back
                    ctx.beginPath();
                    ctx.lineWidth = (state == "2" ? "3" : "1"); // thinner outline if Off
                    ctx.moveTo(-0.4 * $canvas.width, 0);
                    ctx.lineTo(0.4 * $canvas.width, 0);
                    ctx.stroke();
                    // filled circle
                    var grd = ctx.createRadialGradient(0, 0, 1.5 * radius, 8, -8, 4);
                    grd.addColorStop(0, (state == "2" ? "yellow" : "lightgray"));
                    grd.addColorStop(1, "white");
                    ctx.fillStyle = grd;
                    ctx.beginPath();
                    ctx.arc(0, 0, radius, 0, 2 * Math.PI);
                    ctx.fill();
                    ctx.lineWidth = (state == "2" ? "3" : "1"); // thinner outline if Off
                    ctx.stroke();
                    // cross
                    ctx.lineWidth = 1;
                    ctx.moveTo(radius * -0.74, radius * -0.74);
                    ctx.lineTo(radius * 0.74, radius * 0.74);
                    ctx.stroke();
                    ctx.lineWidth = 1;
                    ctx.moveTo(radius * -0.74, radius * 0.74);
                    ctx.lineTo(radius * 0.74, radius * -0.74);
                    ctx.stroke();
                    break;
                case "S" : // sensor
                    var grd = ctx.createRadialGradient(0, 0, 1.5 * radius, 8, -8, 4);
                    grd.addColorStop(0, (state == "2" ? $activeColor : "lightgray"));
                    grd.addColorStop(1, "white");
                    ctx.fillStyle = grd;
                    ctx.beginPath();
                    ctx.arc(0, 0, radius, 0, 2 * Math.PI);
                    ctx.fill();
                    ctx.lineWidth = (state == "2" ? "3" : "1"); // thinner outline if Off
                    ctx.stroke();
                    break;
                case "T" : // turnout, orientation on screen same as JMRI
                default :
                    ctx.lineWidth = radius/2.9;
                    // points, at the back
                    ctx.strokeStyle = "lightgray";
                    // --angled turnout shape
                    //ctx.moveTo(-0.4 * $canvas.width, -20);
                    //ctx.lineTo(0.1 * $canvas.width, 10);
                    //ctx.lineTo(-0.4 * $canvas.width, 10);
                    // --curved turnout shape
                    ctx.moveTo(0.4 * $canvas.width, 10);
                    ctx.lineTo(-0.4 * $canvas.width, 10);
                    ctx.stroke();
                    ctx.beginPath();
                    ctx.arc(0.4 * $canvas.width, 10 - 1.5 * $canvas.width, 1.5 * $canvas.width, 0.5 * Math.PI, 0.675 * Math.PI);
                    // --up to here
                    ctx.stroke();
                    // active line, start with new color
                    ctx.beginPath();
                    ctx.strokeStyle = $activeColor;
                    // --angled turnout shape
                    //var endY = (state == "2" ? "10" : "-20");
                    //ctx.moveTo(0.4 * $canvas.width, 10);
                    //ctx.lineTo(0.1 * $canvas.width, 10);
                    //ctx.lineTo(-0.4 * $canvas.width, endY);
                    // --curved turnout shape
                    if (state == "2") {
                        ctx.moveTo(0.4 * $canvas.width, 10);
                        ctx.lineTo(-0.4 * $canvas.width, 10);
                    } else {
                        ctx.arc(0.4 * $canvas.width, 10 - 1.5 * $canvas.width, 1.5 * $canvas.width, 0.5 * Math.PI, 0.675 * Math.PI);
                    }
                    // --up to here
                    ctx.stroke();
                    break;
            }
            default :
            // only render label
    }

    // draw label (system name + state) text
    //ctx.restore(); // resets origin and stroke&fill
    ctx.fillStyle = (state == "0" ? $unknownColor : $gPanel.defaulttextcolor); // simple change in color
    ctx.font = "16px Arial";
    ctx.textAlign = 'center';
    if (shape == "drawing") { // text centered vertically between Maerklin buttons
        ctx.fillText($gWidgets[id].text, 0, 0);
    } else {
        ctx.fillText($gWidgets[id].text, 0, -0.5 * $canvas.height + 16); // +16 for text size below top
    }
    // draw sublabel (user name) text
    ctx.font = "italic 10px Arial";
    if (shape == "drawing") { // text centered between Maerklin buttons
        ctx.fillText($gWidgets[id].username, 0, 0.4 * $canvas.height);
    } else {
        ctx.fillText($gWidgets[id].username, 0, 0.4 * $canvas.height);
    }
    ctx.restore(); // restore color and width back to default
};

// End of Swichboard functions


/******************************************************************
*  ======= Layout Editor functions =======
*/

//draw a Tracksegment (pass in widget)
function $drawTrackSegment($widget) {
    //if set to hidden, don't draw anything
    if ($widget.hidden == "yes") {
        return;
    }

    // if positional points have not been loaded...
    if (Object.keys($gPts).length == 0) {
        return; // ... don't try to draw anything yet
    }

    //get the endpoints by name
    var $ep1, $ep2;
    [$ep1, $ep2] = $getEndPoints$($widget);
    if (isUndefined($ep1)) {
            log.warn("can't draw tracksegment " + $widget.ident + ": connect1: " + $widget.connect1name + "." + $widget.type1 + " undefined.");
        return;
    }
    if (isUndefined($ep2)) {
            log.warn("can't draw tracksegment " + $widget.ident + ": connect2: " + $widget.connect2name + "." + $widget.type2 + " undefined.");
        return;
    }

    $gCtx.save();   // save current line width and color

    //get width (assume no block assigned)
    var $width = $gPanel.sidelinetrackwidth;
    if ($widget.mainline == "yes") {
        $width = $gPanel.mainlinetrackwidth;
    }

    var $color = $getTrackColor($widget);

    var $blk = $gBlks[$widget.blockname];
    if (isDefined($blk)) {
        $color = $blk.blockcolor;

        //block assigned; use block width
        $width = $gPanel.sidelineblockwidth;
        if ($widget.mainline == "yes") {
            $width = $gPanel.mainlineblockwidth;
        }
    }

    // set color and width
    if (isDefined($color)) {
        $gCtx.strokeStyle = $color;
    }
    if (isDefined($width)) {
        $gCtx.lineWidth = $width;
    }

    if ($widget.dashed == "yes") {
        $gCtx.setLineDash([6, 4]);
    }

    if ($widget.bezier == "yes") {
        $drawTrackSegmentBezier($widget);
    } else if ($widget.circle == "yes") {
        $drawTrackSegmentCircle($widget);
    } else if ($widget.arc == "yes") {  //draw arc of ellipse
        $drawTrackSegmentArc($widget);
    } else {
        $drawLine($ep1.x, $ep1.y, $ep2.x, $ep2.y, $color, $width);
    }

    if ($widget.dashed == "yes") {
        $gCtx.setLineDash([]);
    }

    //draw its decorations
    $drawDecorations($widget);

    $gCtx.restore();        // restore color and width back to default
}   // $drawTrackSegment

function $drawTrackSegmentBezier($widget) {
    //get the endpoints by name
    var ep1, ep2;
    [ep1, ep2] = $getEndPoints($widget);
    var $cps = $widget.controlpoints;   // get the control points
    var points = [[ep1[0], ep1[1]]];    // first point
    $cps.each(function( idx, elem ) {   // control points
        points.push($getLayoutPoint(elem));
    });
    points.push([ep2[0], ep2[1]]);  // last point

    //$point_log("points[0]", points[0]);

    $drawBezier(points, $gCtx.strokeStyle, $gCtx.lineWidth, 0);
}

function $drawTrackSegmentCircle($widget) {
    //get the endpoints by name
    var $ep1, $ep2;
    [$ep1, $ep2] = $getEndPoints$($widget);
    if (isUndefined($widget.angle) || ($widget.angle == 0)) {
        $widget['angle'] = "90";
    }
    //draw curved line
    if ($widget.flip == "yes") {
        $drawArc($ep2.x, $ep2.y, $ep1.x, $ep1.y, $widget.angle);
    } else {
        $drawArc($ep1.x, $ep1.y, $ep2.x, $ep2.y, $widget.angle);
    }
}

function $drawTrackSegmentArc($widget) {
    //get the endpoints by name
    var $ep1, $ep2;
    [$ep1, $ep2] = $getEndPoints$($widget);
    var ep1x = Number($ep1.x), ep1y = Number($ep1.y), ep2x = Number($ep2.x), ep2y = Number($ep2.y);
    if ($widget.flip == "yes") {
        [ep1x, ep1y, ep2x, ep2y] = [ep2x, ep2y, ep1x, ep1y];
    }

    var x, y;
    var rw = ep2x - ep1x, rh = ep2y - ep1y;

    var startAngleRAD, stopAngleRAD;
    if (rw < 0) {
        rw = -rw;
        if (rh < 0) {                       //log.log("**** QUAD ONE ****");
            x = ep1x; y = ep2y;
            rh = -rh;
            startAngleRAD = Math.PI / 2;
            stopAngleRAD = Math.PI;
        } else {                            //log.log("**** QUAD TWO ****");
            x = ep2x; y = ep1y;
            startAngleRAD = 0;
            stopAngleRAD = Math.PI / 2;
        }
    } else {
        if (rh < 0) {                       //log.log("**** QUAD THREE ****");
            x = ep2x; y = ep1y;
            rh = -rh;
            startAngleRAD = Math.PI;
            stopAngleRAD = -Math.PI / 2;
        } else {                            //log.log("**** QUAD FOUR ****");
            x = ep1x; y = ep2y;
            startAngleRAD = -Math.PI / 2;
            stopAngleRAD = 0;
        }
    }

    $drawEllipse(x, y, rw, rh, startAngleRAD, stopAngleRAD);
}

function $getEndPoints$($widget) {
    var $ep1 = $gPts[$widget.connect1name + "." + $widget.type1];
    var $ep2 = $gPts[$widget.connect2name + "." + $widget.type2];
    return [$ep1, $ep2];
}

function $getEndPoints($widget) {
    var $ep1, $ep2;
    [$ep1, $ep2] = $getEndPoints$($widget);
    var ep1 = [Number($ep1.x), Number($ep1.y)];
    var ep2 = [Number($ep2.x), Number($ep2.y)];
    return [ep1, ep2];
}

//
//draw decorations
//
function $drawDecorations($widget) {
    if (isDefined($widget.arrow)) {
        $widget.arrow.draw();
    }
    if (isDefined($widget.bridge)) {
        $widget.bridge.draw();
    }
    if (isDefined($widget.bumper)) {
        $widget.bumper.draw();
    }
    if (isDefined($widget.tunnel)) {
        $widget.tunnel.draw();
    }
}   // $drawDecorations

// draw a turntable (pass in widget)
// from jmri.jmrit.display.layoutEditor.layoutTurntable
function $drawTurntable($widget) {
    $logProperties($widget);

    //get the center
    var $txcen = $widget.xcen * 1;
    var $tycen = $widget.ycen * 1;

    var $tr = $widget.radius * 1; //turntable circle radius
    var $cr = $gPanel.turnoutcirclesize * SIZE; //turnout circle radius
    //var $cd = $cr * 2;

    //the fraction that $cr is of ($tr + $cr)
    //(used to draw ray tracks from circle to ray end point (control circle))
    var f = $cr / ($tr + $cr);

    //loop thru raytracks drawing each one (and control circles if it has a turnout)
    $widget.raytracks.each(function(i, item) {
        $logProperties(item);
        //var rayID = $widget.ident + "." + (50 + item.attributes.index.value * 1);
        var rayID = $widget.ident + ".TURNTABLE_RAY_" + (item.attributes.index.value * 1);
        var $t = $gPts[rayID];
        //draw the line from ray endpoint to turntable edge
        var $t1 = [];
        $t1['x'] = $t.x - (($t.x - $txcen) * f);
        $t1['y'] = $t.y - (($t.y - $tycen) * f);
        $drawLine($t1.x, $t1.y, $t.x, $t.y, $getTrackColor($widget), $gPanel.sidelinetrackwidth);

        if (isDefined(item.attributes.turnout) && ($gPanel.controlling == "yes")) {
            // var turnout = item.attributes.turnout.value;
            // var state = item.attributes.turnoutstate.value;
            // log.log("$drawTurntable ray # " + i + " turnout: '" + turnout + "', state: " + state);
            //draw the turnout control circle
            $drawCircle($t.x, $t.y, $cr, $gPanel.turnoutcirclecolor, 1);
        }
        if (isDefined($widget.activeRayID)) {
            var drawFlag = false;
            if (isDefined(item.attributes.turnout)) {
                var turnout = item.attributes.turnout.value;
                if (turnout == $widget.activeRayTurnout) {
                    var state = item.attributes.turnoutstate.value;
                    if (state.toUpperCase() == $widget.activeRayState) {
                        drawFlag = true;
                    }
                }
            }
            var $angle = $toRadians(item.attributes.angle.value);
            var $t1 = [];
            $t1['x'] = $txcen + ($tr * Math.sin($angle));
            $t1['y'] = $tycen - ($tr * Math.cos($angle));
            var $t2 = [];
            $t2['x'] = $txcen - ($tr * Math.sin($angle));
            $t2['y'] = $tycen + ($tr * Math.cos($angle));
            if (drawFlag) {
                $drawLine($t1.x, $t1.y, $t2.x, $t2.y, $getTrackColor($widget), $gPanel.sidelinetrackwidth);
            } else {
                $drawLine($t1.x, $t1.y, $t2.x, $t2.y, $gPanel.backgroundcolor, $gPanel.sidelinetrackwidth);
            }
        }
    });

    var $turntablecirclelinewidth = 2; //matches LayoutTurntableView.java
    $drawCircle($txcen, $tycen, $tr, $getTrackColor($widget), $turntablecirclelinewidth);
    $drawCircle($txcen, $tycen, $tr / 4, $getTrackColor($widget), $turntablecirclelinewidth);
}   //$drawTurntable

//draw a LevelXing (pass in widget)
function $drawLevelXing($widget) {
    //if set to hidden, don't draw anything
    if ($widget.hidden == "yes") {
        return;
    }
    //set colors and widths based on connected segments and blocks
    var $colorAC = $getLegColor($gWidgets[$widget.connectaname], $widget.blocknameac);
    var $colorBD = $getLegColor($gWidgets[$widget.connectbname], $widget.blocknamebd);
    var $widthAC = $getLegWidth($gWidgets[$widget.connectaname], $widget.blocknameac);
    var $widthBD = $getLegWidth($gWidgets[$widget.connectbname], $widget.blocknamebd);

    //retrieve the points
    var cen = [$widget.xcen, $widget.ycen];
    var a = $getPoint($widget.ident + LEVEL_XING_A);
    var b = $getPoint($widget.ident + LEVEL_XING_B);
    var c = $getPoint($widget.ident + LEVEL_XING_C);
    var d = $getPoint($widget.ident + LEVEL_XING_D);

    //levelxing   A
    //          D-+-B
    //            C
    $drawLineP(a, c, $colorAC, $widthAC); //A to C
    $drawLineP(b, d, $colorBD, $widthBD); //B to D
}

//draw a Turnout (pass in widget)
//  see LayoutTurnout.draw()
// colors and widths based on side vs main then block color, turnout can be all one block, or several blocks
function $drawTurnout($widget) {
    //if set to hidden, don't draw anything
    if ($widget.hidden == "yes") {
        return;
    }
 
    //set erase color and width
    var $eraseColor = $gPanel.backgroundcolor;
    var $eraseWidth = $gPanel.mainlinetrackwidth;
 
    //set colors and widths based on connected segments and blocks
    var $colorA = $getLegColor($gWidgets[$widget.connectaname], $widget.blockname);
    var $colorB = $getLegColor($gWidgets[$widget.connectbname], 
        ($widget.blockbname ? $widget.blockbname : $widget.blockname)); //use bname if set
    var $colorC = $getLegColor($gWidgets[$widget.connectcname], 
        ($widget.blockcname ? $widget.blockcname : $widget.blockname)); //use cname if set
    var $colorD = $getLegColor($gWidgets[$widget.connectdname], 
        ($widget.blockdname ? $widget.blockdname : $widget.blockname)); //use dname if set

    var $widthA = $getLegWidth($gWidgets[$widget.connectaname], $widget.blockname);
    var $widthB = $getLegWidth($gWidgets[$widget.connectbname], 
        ($widget.blockbname ? $widget.blockbname : $widget.blockname)); //use bname if set
    var $widthC = $getLegWidth($gWidgets[$widget.connectcname], 
        ($widget.blockcname ? $widget.blockcname : $widget.blockname)); //use cname if set
    var $widthD = $getLegWidth($gWidgets[$widget.connectdname], 
        ($widget.blockdname ? $widget.blockdname : $widget.blockname)); //use dname if set

    var cen = [$widget.xcen * 1, $widget.ycen * 1]
    var a = $getPoint($widget.ident + ".TURNOUT_A");
    var b = $getPoint($widget.ident + ".TURNOUT_B");
    var c = $getPoint($widget.ident + ".TURNOUT_C");

    var ab = $point_midpoint(a, b);

    //turnout A--+--B
    //            \-C
    if ($widget.type == LH_TURNOUT || $widget.type == RH_TURNOUT || $widget.type == WYE_TURNOUT) {
        //always draw from a to cen
        $drawLineP(a, cen, $colorA, $widthA); //a to cen

        //if closed or thrown, draw the selected leg and erase the other one
        if ($widget.state == CLOSED || $widget.state == THROWN) {
            if ($widget.state == $widget.continuing) {
                $drawLineP(cen, c, $eraseColor, $widthC); //erase center to C (diverging leg)
                if ($gPanel.turnoutdrawunselectedleg == 'yes') {
                    $drawLineP(c, $point_midpoint(cen, c), $colorC, $widthC); //C to midC (diverging leg)
                }
                $drawLineP(cen, b, $colorB, $widthB); //center to B (straight leg)
            } else {
                    $drawLineP(cen, b, $eraseColor, $widthB); //erase center to B (straight leg)
                if ($gPanel.turnoutdrawunselectedleg == 'yes') {
                    $drawLineP(b, $point_midpoint(cen, b), $colorB, $widthB); //B to midB (straight leg)
                }
                $drawLineP(cen, c, $colorC, $widthC); //center to C (diverging leg)
            }
        } else {  //if state is undefined, draw both legs
            $drawLineP(cen, b, $colorB, $widthB); //center to B (straight leg)
            $drawLineP(cen, c, $colorC, $widthC); //center to C (diverging leg)
        }
        // xover A--B
        //       D--C
    } else if ($widget.type == LH_XOVER || $widget.type == RH_XOVER || $widget.type == DOUBLE_XOVER) {
        var d = $getPoint($widget.ident + ".TURNOUT_D");

        var ab = $point_midpoint(a, b);
        var cd = $point_midpoint(c, d);

        if ($widget.state == CLOSED || $widget.state == THROWN) {
            $drawLineP(a, b, $eraseColor, $eraseWidth);      //erase A to B
            $drawLineP(c, d, $eraseColor, $eraseWidth);      //erase C to D
            $drawLineP(ab, cd, $eraseColor, $eraseWidth);    //erase midAB to midDC
            $drawLineP(a, c, $eraseColor, $eraseWidth);      //erase A to C
            $drawLineP(b, d, $eraseColor, $eraseWidth);      //erase B to D
            if ($widget.state == $widget.continuing) {
                //draw closed legs
                $drawLineP(a, ab, $colorA, $widthA);    //A to mid ab
                $drawLineP(b, ab, $colorB, $widthB);    //B to mid ab
                $drawLineP(c, cd, $colorC, $widthC);    //C to mid cd
                $drawLineP(d, cd, $colorD, $widthD);    //D to mid cd
                //draw open legs
                if ($widget.type == DOUBLE_XOVER) {
                    var acen = $point_midpoint(a, cen);
                    var bcen = $point_midpoint(b, cen);
                    var ccen = $point_midpoint(c, cen);
                    var dcen = $point_midpoint(d, cen);
                    $drawLineP(acen, cen, $colorA, $widthA);    //mid a cen to cen
                    $drawLineP(bcen, cen, $colorB, $widthB);    //mid b cen to cen
                    $drawLineP(ccen, cen, $colorC, $widthC);    //mid c cen to cen
                    $drawLineP(dcen, cen, $colorD, $widthD);    //mid d cen to cen
                } else if ($widget.type == RH_XOVER) {
                    $drawLineP($point_midpoint(ab, cen), cen, $colorA, $widthA);
                    $drawLineP($point_midpoint(cd, cen), cen, $colorC, $widthC);
                } else if ($widget.type == LH_XOVER) {
                    $drawLineP($point_midpoint(ab, cen), cen, $colorB, $widthB);
                    $drawLineP($point_midpoint(cd, cen), cen, $colorD, $widthD);
                }
            } else {
                var aab = $point_midpoint(a, ab);
                var abb = $point_midpoint(ab, b);
                var ccd = $point_midpoint(c, cd);
                var cdd = $point_midpoint(cd, d);
                if ($widget.type == DOUBLE_XOVER) {
                    //draw open legs
                    $drawLineP(ab, aab, $colorA, $widthA);
                    $drawLineP(ab, abb, $colorB, $widthB);
                    $drawLineP(cd, ccd, $colorC, $widthC);
                    $drawLineP(cd, cdd, $colorD, $widthD);

                    //draw closed legs
                    $drawLineP(a, cen, $colorA, $widthA);
                    $drawLineP(b, cen, $colorB, $widthB);
                    $drawLineP(c, cen, $colorC, $widthC);   //C to cen
                    $drawLineP(d, cen, $colorD, $widthD);   //D to cen
                } else if ($widget.type == RH_XOVER) {
                    //draw open legs
                    $drawLineP(b, abb, $colorB, $widthB);
                    $drawLineP(d, cdd, $colorD, $widthD);

                    //draw closed legs
                    $drawLineP(a, ab, $colorA, $widthA);    //A to mid ab
                    $drawLineP(ab, cen, $colorA, $widthA);  //midAB to cen
                    $drawLineP(cen, cd, $colorC, $widthC);  //cen to midDC
                    $drawLineP(c, cd, $colorC, $widthC);    //C to mid cd
                } else {  //LH_XOVER
                    //draw open legs
                    $drawLineP(a, aab, $colorA, $widthA);
                    $drawLineP(c, ccd, $colorC, $widthC);

                    //draw closed legs
                    $drawLineP(b, ab, $colorB, $widthB);    //B to mid ab
                    $drawLineP(ab, cen, $colorB, $widthB);  //midAB to cen
                    $drawLineP(cen, cd, $colorD, $widthD);  //cen to midDC
                    $drawLineP(d, cd, $colorD, $widthD);    //D to mid cd
                }
            }
        } else {  //if state is undefined, draw all legs
            $drawLineP(a, ab, $colorA, $widthA);    //A to mid ab
            $drawLineP(b, ab, $colorB, $widthB);    //B to mid ab
            $drawLineP(c, cd, $colorC, $widthC);    //C to mid cd
            $drawLineP(d, cd, $colorD, $widthD);    //D to mid cd
            if ($widget.type == DOUBLE_XOVER) {
                $drawLineP(a, cen, $colorA, $widthA);   //A to cen
                $drawLineP(b, cen, $colorB, $widthB);   //B to cen
                $drawLineP(c, cen, $colorC, $widthC);   //C to cen
                $drawLineP(d, cen, $colorD, $widthD);   //D to cen
            } else if ($widget.type == RH_XOVER) {
                $drawLineP(ab, cen, $colorA, $widthA);  //midAB to cen
                $drawLineP(cen, cd, $colorC, $widthC);  //cen to midDC
            } else {  //LH_XOVER
                $drawLineP(ab, cen, $colorB, $widthB);  //midAB to cen
                $drawLineP(cen, cd, $colorD, $widthD);  //cen to midDC
            }
        }
    }

    // erase and draw turnout circles if enabled, including occupancy check
    if (($gPanel.turnoutcircles == "yes") && ($gPanel.controlling == "yes") && ($widget.disabled !== "yes")) {
        $drawCircle($widget.xcen, $widget.ycen, $gPanel.turnoutcirclesize * SIZE, $eraseColor, 1);
        if  (($widget.disableWhenOccupied !== "yes") || ($widget.occupancystate != ACTIVE)) {
            var $color = $gPanel.turnoutcirclecolor;

            if ($widget.state != CLOSED) {
                $color = $gPanel.turnoutcirclethrowncolor;
            }
            if ($gPanel.turnoutfillcontrolcircles == "yes") {
                $fillCircle($widget.xcen, $widget.ycen, $gPanel.turnoutcirclesize * SIZE, $color, 1);
            } else {
                $drawCircle($widget.xcen, $widget.ycen, $gPanel.turnoutcirclesize * SIZE, $color, 1);
            }
        }
        // if disableWhenOccupied requested, disable click if enabled and active
        if  ($widget.disableWhenOccupied == "yes") {
            if ($widget.occupancystate == ACTIVE) {
                $('#'+$widget.id).removeClass("clickable");
                $('#'+$widget.id).unbind(UPEVENT, $handleClick);
            } else {
                $('#'+$widget.id).addClass("clickable");
                $('#'+$widget.id).bind(UPEVENT, $handleClick);
            }
        }
    }
}   // function $drawTurnout($widget)

// compute width of turnout leg based on connected segment, then block type
function $getLegWidth(cs, bn) {
    var width = $gPanel.sidelinetrackwidth;
    if (isDefined(cs)) {
        if (cs.mainline == "yes") {
            width = $gPanel.mainlinetrackwidth;
        }
        var blk = $gBlks[bn];
        if (isDefined(blk)) {
            if (cs.mainline=="yes") {        
                width = $gPanel.mainlineblockwidth;;
            } else {
                width = $gPanel.sidelineblockwidth;;
            }
        }
    }
    return width;
}

// compute color of turnout leg based on connected segment, then its block color
function $getLegColor(cs, bn) {
    var color = $gPanel.defaulttrackcolor;
    if (isDefined(cs)) {
        if (isDefined($gPanel.mainRailColor) && (cs.mainline == "yes")) {
            color = $gPanel.mainRailColor;
        } else {
            if (isDefined($gPanel.sideRailColor)) {
                color = $gPanel.sideRailColor;
            }
        }
        var blk = $gBlks[bn];
        if (isDefined(blk)) {
            color = blk.blockcolor;
        }
    }
    return color;
       
}   // function $getLegColor()

//set trackcolor by default, then main/side
var $getTrackColor = function(e) {
    var color = $gPanel.defaulttrackcolor;
    if (isDefined($gPanel.mainRailColor) && (e.mainline == "yes")) {
        color = $gPanel.mainRailColor;
    }
    if (isDefined($gPanel.sideRailColor) && (e.mainline != "yes")) {
        color = $gPanel.sideRailColor;
    }
    return color;
}

//draw a Slip (pass in widget)
//  see LayoutSlip.draw()
function $drawSlip($widget) {
    //if set to hidden, don't draw anything
    if ($widget.hidden == "yes") {
        return;
    }
    if (jmri_logging) {
        log.log("$drawSlip(" + $widget.id + "): state: " + $widget.state);
    }

    var $mainWidth = $gPanel.mainlinetrackwidth;
    var $sideWidth = $gPanel.sidelinetrackwidth;

    var $widthA = $sideWidth;
    if (isDefined($gWidgets[$widget.connectaname])) {
        if ($gWidgets[$widget.connectaname].mainline == "yes") {
            $widthA = $mainWidth;
        }
    }

    var $widthB = $sideWidth;
    if (isDefined($gWidgets[$widget.connectbname])) {
        if ($gWidgets[$widget.connectbname].mainline == "yes") {
            $widthB = $mainWidth;
        }
    }

    var $widthC = $sideWidth;
    if (isDefined($gWidgets[$widget.connectcname])) {
        if ($gWidgets[$widget.connectcname].mainline == "yes") {
            $widthC = $mainWidth;
        }
    }

    var $widthD = $sideWidth;
    if (isDefined($gWidgets[$widget.connectdname])) {
        if ($gWidgets[$widget.connectdname].mainline == "yes") {
            $widthD = $mainWidth;
        }
    }

    var cen = [$widget.xcen * 1, $widget.ycen * 1]
    var a = $getPoint($widget.ident + SLIP_A);
    var b = $getPoint($widget.ident + SLIP_B);
    var c = $getPoint($widget.ident + SLIP_C);
    var d = $getPoint($widget.ident + SLIP_D);

    var $eraseColor = $gPanel.backgroundcolor;
    var $trackColor = $getTrackColor($widget);

    var $blkA = $gBlks[$widget.blockname];
    var $colorA = isDefined($blkA) ? $blkA.blockcolor : $trackColor;
    var $colorAt = isDefined($blkA) ? $blkA.trackcolor : $trackColor;
    var $blkB = $gBlks[$widget.blockbname];
    var $colorB = isDefined($blkB) ? $blkB.blockcolor : $colorA;
    var $colorBt = isDefined($blkB) ? $blkB.trackcolor : $colorAt;
    var $blkC = $gBlks[$widget.blockcname];
    var $colorC = isDefined($blkC) ? $blkC.blockcolor : $colorA;
    var $colorCt = isDefined($blkC) ? $blkC.trackcolor : $colorAt;
    var $blkD = $gBlks[$widget.blockdname];
    var $colorD = isDefined($blkD) ? $blkD.blockcolor : $colorA;
    var $colorDt = isDefined($blkD) ? $blkD.trackcolor : $colorAt;

    //slip A==-==D
    //      \\ //
    //        X
    //      // \\
    //     B==-==C
    // var STATE_AC = 0x02;
    // var STATE_BD = 0x04;
    // var STATE_AD = 0x06;
    // var STATE_BC = 0x08;

    // ERASE EVERYTHING FIRST
    var acen3rd = $point_third(a, cen);
    var bcen3rd = $point_third(b, cen);
    var ccen3rd = $point_third(c, cen);
    var dcen3rd = $point_third(d, cen);
    var ad3rd = $point_midpoint(acen3rd, dcen3rd);
    var bc3rd = $point_midpoint(bcen3rd, ccen3rd);

    if ($widget.state != STATE_AC) {
        $drawLineP(a, acen3rd, $eraseColor, $mainWidth);
        $drawLineP(acen3rd, ccen3rd, $eraseColor, $mainWidth);      //erase AC
        $drawLineP(ccen3rd, c, $eraseColor, $mainWidth);
    }
    if ($widget.state != STATE_BD) {
        $drawLineP(b, bcen3rd, $eraseColor, $mainWidth);
        $drawLineP(bcen3rd, dcen3rd, $eraseColor, $mainWidth);      //erase BD
        $drawLineP(dcen3rd, d, $eraseColor, $mainWidth);
    }
    if ($widget.state != STATE_AD) {
        $drawLineP(a, acen3rd, $eraseColor, $mainWidth);
        $drawLineP(acen3rd, dcen3rd, $eraseColor, $mainWidth);      //erase AD
        $drawLineP(dcen3rd, d, $eraseColor, $mainWidth);
    }
    if ($widget.slipType == DOUBLE_SLIP) {
        if ($widget.state != STATE_BC) {
            $drawLineP(b, bcen3rd, $eraseColor, $mainWidth);
            $drawLineP(bcen3rd, ccen3rd, $eraseColor, $mainWidth);  //erase BC
            $drawLineP(ccen3rd, c, $eraseColor, $mainWidth);
        }
    }

    // THEN DRAW ROUTE
    var forceUnselected = false;
    if ($widget.state == STATE_AD) {
        // draw A<===>D
        $drawLineP(a, acen3rd, $colorA, $widthA);
        $drawLineP(acen3rd, ad3rd, $colorA, $widthA);
        $drawLineP(d, dcen3rd, $colorD, $widthD);
        $drawLineP(dcen3rd, ad3rd, $colorD, $widthD);
    } else if ($widget.state == STATE_AC) {
        // draw A<===>C
        $drawLineP(a, acen3rd, $colorA, $widthA);
        $drawLineP(acen3rd, cen, $colorA, $widthA);
        $drawLineP(c, ccen3rd, $colorC, $widthC);
        $drawLineP(ccen3rd, cen, $colorC, $widthC);
    } else if ($widget.state == STATE_BD) {
        // draw B<===>D
        $drawLineP(b, bcen3rd, $colorB, $widthB);
        $drawLineP(bcen3rd, cen, $colorB, $widthB);
        $drawLineP(d, dcen3rd, $colorD, $widthD);
        $drawLineP(dcen3rd, cen, $colorD, $widthD);
    } else if ($widget.state == STATE_BC) {
        if ($widget.slipType == DOUBLE_SLIP) {
            // draw B<===>C
            $drawLineP(b, bcen3rd, $colorB, $widthB);
            $drawLineP(bcen3rd, bc3rd, $colorB, $widthB);
            $drawLineP(c, ccen3rd, $colorC, $widthC);
            $drawLineP(ccen3rd, bc3rd, $colorC, $widthC);
        }   // DOUBLE_SLIP
    } else {
        forceUnselected = true; // if not valid state force drawing unselected
    }

    if (forceUnselected || ($gPanel.turnoutdrawunselectedleg == 'yes')) {
        if ($widget.state == STATE_AC) {
            $drawLineP(b, bcen3rd, $colorBt, $widthB);
            $drawLineP(d, dcen3rd, $colorDt, $widthD);
        } else if ($widget.state == STATE_BD) {
            $drawLineP(a, acen3rd, $colorAt, $widthA);
            $drawLineP(c, ccen3rd, $colorCt, $widthC);
        } else if ($widget.state == STATE_AD) {
            $drawLineP(b, bcen3rd, $colorBt, $widthB);
            $drawLineP(c, ccen3rd, $colorCt, $widthC);
        } else if ($widget.state == STATE_BC) {
            $drawLineP(a, acen3rd, $colorAt, $widthA);
            $drawLineP(d, dcen3rd, $colorDt, $widthD);
        } else {
            $drawLineP(a, acen3rd, $colorAt, $widthA);
            $drawLineP(b, bcen3rd, $colorBt, $widthB);
            $drawLineP(c, ccen3rd, $colorCt, $widthC);
            $drawLineP(d, dcen3rd, $colorDt, $widthD);
        }
    }

    if (($gPanel.turnoutcircles == "yes") && ($gPanel.controlling == "yes") && ($widget.disabled !== "yes")) {
        //draw the two control circles
        var $cr = $gPanel.turnoutcirclesize * SIZE;  //turnout circle radius

        // center
        var cen = [$widget.xcen, $widget.ycen];
        // left center
        var lcen = $point_midpoint(a, b);
        var ldelta = $point_subtract(cen, lcen);

        // left fraction
        var lf = $cr / Math.hypot(ldelta[0], ldelta[1]);
        // left circle
        var lcc = $point_lerp(cen, lcen, lf);

        $drawCircleP(lcc, $cr, $gPanel.turnoutcirclecolor, 1);

        // right center
        var rcen = $point_midpoint(c, d);
        var rdelta = $point_subtract(cen, rcen);
        // right fraction
        var rf = $cr / Math.hypot(rdelta[0], rdelta[1]);
        // right circle
        var rcc = $point_lerp(cen, rcen, rf);

        $drawCircleP(rcc, $cr, $gPanel.turnoutcirclecolor, 1);
    }
}   // function $drawSlip($widget)

function $drawPositionableRoundRect($widget) {
    //log.log("drawing PositionableRoundRect")
    createPanelCanvas(); //insure canvas layer is available for drawing

    $gCtx.save();   // save current line width and color

    if (isDefined($widget.lineColor)) {
        $gCtx.strokeStyle = $widget.lineColor;
    }
    if (isDefined($widget.fillColor)) {
        $gCtx.fillStyle = $widget.fillColor;
    }
    if (isDefined($widget.lineWidth)) {
        $gCtx.lineWidth = $widget.lineWidth;
    }

    $gCtx.beginPath();
//    $gCtx.rotate($toRadians($widget.degrees));
    $gCtx.roundRect($widget.x, $widget.y, $widget.width, $widget.height, $widget.cornerRadius);
    $gCtx.stroke()
    $gCtx.fill()
    $gCtx.restore();        // restore color and width back to default

}   // function $drawPositionableRoundRect($widget)

function $drawPositionableEllipse($widget) {
    //log.log("drawing PositionableEllipse");
    createPanelCanvas(); //insure canvas layer is available for drawing

    $gCtx.save();   // save current line width and color

    if (isDefined($widget.lineColor)) {
        $gCtx.strokeStyle = $widget.lineColor;
    }
    if (isDefined($widget.fillColor)) {
        $gCtx.fillStyle = $widget.fillColor;
    }
    if (isDefined($widget.lineWidth)) {
        $gCtx.lineWidth = $widget.lineWidth;
    }
    rw = $widget.width/2;
    rh = $widget.height/2;
    x  = $widget.x * 1.0;
    y  = $widget.y * 1.0;
    $gCtx.beginPath();
    $gCtx.ellipse(x + rw, y + rh, rw, rh, $toRadians($widget.degrees), 0, 2 * Math.PI);    
    $gCtx.stroke()
    $gCtx.fill()
    $gCtx.restore();        // restore color and width back to default

}   // function $drawPositionableEllipse($widget)

function $drawLayoutShape($widget) {
    var $pts = $widget.points;   // get the points
    var len = $pts.length;
    if (len > 0) {
        $gCtx.save();   // save current line width and color

        if (isDefined($widget.lineColor)) {
            $gCtx.strokeStyle = $widget.lineColor;
        }
        if (isDefined($widget.fillColor)) {
            $gCtx.fillStyle = $widget.fillColor;
        }
        if (isDefined($widget.linewidth)) {
            $gCtx.lineWidth = $widget.linewidth; //TODO: check case on this
        }

        $gCtx.beginPath();

        var shapeType = $widget.type;

        $pts.each(function(idx, $lsp) {  //loop thru points
            // this point
            var p = $getLayoutPoint($lsp);

            // left point
            var idxL = $wrapValue(idx - 1, 0, len);
            var $lspL = $pts[idxL];
            var pL = $getLayoutPoint($lspL);
            var midL = $point_midpoint(pL, p);

            // right point
            var idxR = $wrapValue(idx + 1, 0, len);
            var $lspR = $pts[idxR];
            var pR = $getLayoutPoint($lspR);
            var midR = $point_midpoint(p, pR);

            var lspt = $lsp.attributes.type.value;  // Straight or Curve

            // if this is an open shape...
            if (shapeType == "eOpen") {
                // and this is first or last point...
                if ((idx == 0) || (idxR == 0)) {
                    // then force straight shape point type
                    lspt = "Straight";
                }
            }
            if (lspt == "Straight") {
                if (idx == 0) { // if this is the first point...
                    // ...and our shape is open...
                    if (shapeType == "Open") {
                        $gCtx.moveTo(p[0], p[1]);    // then start here
                    } else {    // otherwise
                        $gCtx.moveTo(midL[0], midL[1]);  //start here
                        $gCtx.lineTo(p[0], p[1]);        //draw to here
                    }
                } else {
                    $gCtx.lineTo(midL[0], midL[1]);  //start here
                    $gCtx.lineTo(p[0], p[1]);        //draw to here
                }
                // if this is not the last point...
                // ...or our shape isn't open
                if ((idxR != 0) || (shapeType == "Open")) {
                    $gCtx.lineTo(midR[0], midR[1]);      // draw to here
                }
            } else if (lspt == "Curve") {
                if (idx == 0) { // if this is the first point
                    $gCtx.moveTo(midL[0], midL[1]);  // then start here
                }
                $gCtx.quadraticCurveTo(p[0], p[1], midR[0], midR[1]);
            } else {
                log.error("ERROR: unexpected LayoutShape point type '" + lspt + "' for " + $widget.ide);
            }
        });   // $pts.each(function(idx, $lsp)

        if (shapeType == "Filled") {
            $gCtx.fill();
        }
        $gCtx.stroke();

        $gCtx.restore();        // restore color and width back to default
    }   // if (len > 0)
}

function $getLayoutPoint($p) {
    return [Number($p.attributes.x.value), Number($p.attributes.y.value)];
}

// wrap inValue around between minVal and maxVal
function $wrapValue(inValue, minVal, maxVal) {
    var range = maxVal - minVal;
    return ((inValue % range) + range) % range;
}

function $lerp(value1, value2, amount) {
    return ((1 - amount) * value1) + (amount * value2);
}

function $half(value1, value2) {
    return $lerp(value1, value2, 1 / 2);
}

function $third(value1, value2) {
    return $lerp(value1, value2, 1 / 3);
}

function $store_occupancysensor(id, sensor) {
    if (id && sensor) {
        if (!(sensor in occupancyNames)) {
            occupancyNames[sensor] = new Array();
        }
        occupancyNames[sensor][occupancyNames[sensor].length] = id;
        //console.log("sensor " + sensor + " stored with widget " + id);
    }
}

function $store_occupancyblock(id, oblock) {
    if (id && oblock) {
        if (!(oblock in $oblockNames)) {
            $oblockNames[oblock] = new Array();
        }
        $oblockNames[oblock][$oblockNames[oblock].length] = id; // id = widgetId
        //console.log("oblock " + oblock + " stored with widget " + id);
    }
}

//store the various points defined with a Turnout (pass in widget)
//see jmri.jmrit.display.layoutEditor.LayoutTurnout.java for background
function $storeTurnoutPoints($widget) {
    var $t = [];
    $t['ident'] = $widget.ident + ".TURNOUT_B";  //store B endpoint
    $t['x'] = $widget.xb * 1;
    $t['y'] = $widget.yb * 1;
    $gPts[$t.ident] = $t;

    $t = [];
    $t['ident'] = $widget.ident + ".TURNOUT_C";  //store C endpoint
    $t['x'] = $widget.xc * 1;
    $t['y'] = $widget.yc * 1;
    $gPts[$t.ident] = $t;

    if ($widget.type == LH_TURNOUT || $widget.type == RH_TURNOUT) {
        $t = [];
        $t['ident'] = $widget.ident + ".TURNOUT_A";  //calculate and store A endpoint (mirror of B for these)
        $t['x'] = $widget.xcen - ($widget.xb - $widget.xcen);
        $t['y'] = $widget.ycen - ($widget.yb - $widget.ycen);
        $gPts[$t.ident] = $t;
    } else if ($widget.type == WYE_TURNOUT) {
        $t = [];
        $t['ident'] = $widget.ident + ".TURNOUT_A";  //store A endpoint
        $t['x'] = $widget.xa * 1;
        $t['y'] = $widget.ya * 1;
        $gPts[$t.ident] = $t;
    } else if ($widget.type == LH_XOVER || $widget.type == RH_XOVER || $widget.type == DOUBLE_XOVER) {
        $t = [];
        $t['ident'] = $widget.ident + ".TURNOUT_A";  //calculate and store A endpoint (mirror of C for these)
        $t['x'] = $widget.xcen - ($widget.xc - $widget.xcen);
        $t['y'] = $widget.ycen - ($widget.yc - $widget.ycen);
        $gPts[$t.ident] = $t;
        $t = [];
        $t['ident'] = $widget.ident + ".TURNOUT_D";  //calculate and store D endpoint (mirror of B for these)
        $t['x'] = $widget.xcen - ($widget.xb - $widget.xcen);
        $t['y'] = $widget.ycen - ($widget.yb - $widget.ycen);
        $gPts[$t.ident] = $t;
    }
}

//store the various points defined with a Slip (pass in widget)
//see jmri.jmrit.display.layoutEditor.LayoutSlip.java for background
function $storeSlipPoints($widget) {
    var $t = [];
    $t['ident'] = $widget.ident + SLIP_A;  //store A endpoint
    $t['x'] = $widget.xa * 1;
    $t['y'] = $widget.ya * 1;
    $gPts[$t.ident] = $t;

    $t = [];
    $t['ident'] = $widget.ident + SLIP_B;  //store B endpoint
    $t['x'] = $widget.xb * 1;
    $t['y'] = $widget.yb * 1;
    $gPts[$t.ident] = $t;

    $t = [];
    $t['ident'] = $widget.ident + SLIP_C;  //calculate and store C endpoint (mirror of A for these)
    $t['x'] = $widget.xcen - ($widget.xa - $widget.xcen);
    $t['y'] = $widget.ycen - ($widget.ya - $widget.ycen);
    $gPts[$t.ident] = $t;

    $t = [];
    $t['ident'] = $widget.ident + SLIP_D;  //calculate and store D endpoint (mirror of B for these)
    $t['x'] = $widget.xcen - ($widget.xb - $widget.xcen);
    $t['y'] = $widget.ycen - ($widget.yb - $widget.ycen);
    $gPts[$t.ident] = $t;
}

//store the various points defined with a LevelXing (pass in widget)
//see jmri.jmrit.display.layoutEditor.LevelXing.java for background
function $storeLevelXingPoints($widget) {
    var $t = [];
    $t['ident'] = $widget.ident + LEVEL_XING_A;  //store A endpoint
    $t['x'] = $widget.xa * 1;
    $t['y'] = $widget.ya * 1;
    $gPts[$t.ident] = $t;

    $t = [];
    $t['ident'] = $widget.ident + LEVEL_XING_B;  //store B endpoint
    $t['x'] = $widget.xb * 1;
    $t['y'] = $widget.yb * 1;
    $gPts[$t.ident] = $t;

    $t = [];
    $t['ident'] = $widget.ident + LEVEL_XING_C;  //calculate and store A endpoint (mirror of A for these)
    $t['x'] = $widget.xcen - ($widget.xa - $widget.xcen);
    $t['y'] = $widget.ycen - ($widget.ya - $widget.ycen);
    $gPts[$t.ident] = $t;

    $t = [];
    $t['ident'] = $widget.ident + LEVEL_XING_D;  //calculate and store D endpoint (mirror of B for these)
    $t['x'] = $widget.xcen - ($widget.xb - $widget.xcen);
    $t['y'] = $widget.ycen - ($widget.yb - $widget.ycen);
    $gPts[$t.ident] = $t;
}

//drawLine, passing in values from xml
function $drawLine($p1x, $p1y, $p2x, $p2y, $color, $width, dashArray) {
    $gCtx.save();   // save current line width and color

    if (isDefined($color)) {
        $gCtx.strokeStyle = $color;
    }
    if (isDefined($width)) {
        $gCtx.lineWidth = $width;
    }

    $gCtx.beginPath();

    if (isDefined(dashArray)) {
        $gCtx.setLineDash(dashArray);
    }

    $gCtx.moveTo($p1x, $p1y);
    $gCtx.lineTo($p2x, $p2y);

    $gCtx.stroke();

    if (isDefined(dashArray)) {
        $gCtx.setLineDash([]);
    }

    $gCtx.restore();        // restore color and width back to default
}

function $drawLineP($p1, $p2, $color, $width) {
    $drawLine($p1[0], $p1[1], $p2[0], $p2[1], $color, $width);
}

//drawLine, passing in values from xml
function $drawDashedLine($p1x, $p1y, $p2x, $p2y, $color, $width, dashArray) {
    $drawLine($p1x, $p1y, $p2x, $p2y, $color, $width, dashArray);
}

// function $drawDashedLineP($p1, $p2, $color, $width, dashArray) {
//     $drawDashedLine($p1[0], $p1[1], $p2[0], $p2[1], $color, $width, dashArray);
// }

//draw a Circle (color and width are optional)
function $drawCircleP($p, $radius, $color, $width) {
    $drawCircle($p[0], $p[1], $radius, $color, $width);
}

function $drawCircle($px, $py, $radius, $color, $width) {
    $gCtx.save();   // save current line width and color

    // set color and width
    if (isDefined($color)) {
        $gCtx.strokeStyle = $color;
    }
    if (isDefined($width)) {
        $gCtx.lineWidth = $width;
    }

    $gCtx.beginPath();
    $gCtx.arc($px, $py, $radius, 0, 2 * Math.PI, false);
    $gCtx.stroke();

    $gCtx.restore(); // restore color and width back to default
}

//draw a Circle (color and width are optional)
function $fillCircleP($p, $radius, $color, $width) {
    $fillCircle($p[0], $p[1], $radius, $color, $width);
}
function $fillCircle($px, $py, $radius, $color, $width) {
    $gCtx.save();   // save current line width and color

    // set color and width
    if (isDefined($color)) {
        $gCtx.fillStyle = $color;
    }
    if (isDefined($width)) {
        $gCtx.lineWidth = $width;
    }

    $gCtx.beginPath();
    $gCtx.arc($px, $py, $radius, 0, 2 * Math.PI, false);
    $gCtx.fill();

    $gCtx.restore();        // restore color and width back to default
}

//drawArc, passing in values from xml
function $drawArc(pt1x, pt1y, pt2x, pt2y, degrees, $color, $width) {
    // Compute arc's chord
    var a = pt2x - pt1x;
    var o = pt2y - pt1y;
    var chord = Math.hypot(a, o);   //in pixels
    if (chord > 0) {  //don't bother if no length
        $gCtx.save();   // save current line width and color

        // set color and width
        if (isDefined($color)) {
            $gCtx.strokeStyle = $color;
        }
        if (isDefined($width)) {
            $gCtx.lineWidth = $width;
        }

        var halfAngleRAD = $toRadians(degrees / 2);
        var radius = (chord / 2) / (Math.sin(halfAngleRAD));  //in pixels
        var startRAD = Math.atan2(a, o) - halfAngleRAD; //in radians

        // calculate center of circle
        var cx = (pt2x * 1.0) - Math.cos(startRAD) * radius;
        var cy = (pt2y * 1.0) + Math.sin(startRAD) * radius;

        //calculate start and end angle
        var startAngleRAD = Math.atan2(pt1y - cy, pt1x - cx); //in radians
        var endAngleRAD = Math.atan2(pt2y - cy, pt2x - cx); //in radians

        $gCtx.beginPath();
        $gCtx.arc(cx, cy, radius, startAngleRAD, endAngleRAD, false);
        $gCtx.stroke();

        $gCtx.restore();        // restore color and width back to default
    }
}

function $drawArcP(pt1, pt2, degrees) {
    $drawArc(pt1[0], pt1[1], pt2[0], pt2[1], degrees);
}

function $drawEllipse(x, y, rw, rh, startAngleRAD, stopAngleRAD)
{
    $gCtx.beginPath();
    $gCtx.ellipse(x, y, rw, rh, 0, startAngleRAD, stopAngleRAD);
    $gCtx.stroke();
}

//  $drawBezier
var bezier1st = true;
function $drawBezier(points, $color, $width, displacement) {
    $gCtx.save();   // save current line width and color

    $gCtx.strokeStyle = $color;
    $gCtx.lineWidth = $width;

    try {
        bezier1st = true;
        $gCtx.beginPath();
        $plotBezier(points, 0, displacement);
        $gCtx.stroke();
    } catch (e) {
        if (jmri_logging) {
            log.log("$plotBezier exception: " + e);
            var vDebug = "";
            for (var prop in e) {
               vDebug += "      ["+ prop+ "]: '"+ e[prop]+ "'\n";
            }
            vDebug += "toString(): " + " value: [" + e.toString() + "]";
            log.log(vDebug);
        }
    }

    $gCtx.restore();        // restore color and width back to default
}

//
//plotBezier - recursive function to draw bezier curve
//
function $plotBezier(points, depth, displacement) {
    var len = points.length, idx, jdx;

    // calculate flatness to determine if we need to recurse...
    var outer_distance = 0;
    for (var idx = 1; idx < len; idx++) {
        outer_distance += $point_distance(points[idx - 1], points[idx]);
    }
    var inner_distance = $point_distance(points[0], points[len - 1]);
    var flatness = outer_distance / inner_distance;

    // depth prevents stack overflow
    // (I picked 12 because 2^12 = 2048 is larger than most monitors ;-)
    // the flatness comparison value is somewhat arbitrary.
    // (I just kept moving it closer to 1 until I got good results. ;-)
    if ((depth > 12) || (flatness <= 1.001)) {
        var p0 = points[0], pN = points[len - 1];

        var vO = $point_normalizeTo($point_orthogonal($point_subtract(pN, p0)), displacement);
        //$point_log("vO", vO);

        if (bezier1st) {
            var p0P = $point_add(p0, vO);
            //$point_log("p0P", p0P);
            $gCtx.moveTo(p0P[0], p0P[1]);
            bezier1st = false;
        }
        var pNP = $point_add(pN, vO);
        $gCtx.lineTo(pNP[0], pNP[1]);
    } else {
        // calculate (len - 1) order of points
        // (zero'th order are the input points)
        var orderPoints = [];
        for (idx = 0; idx < len - 1; idx++) {
            var nthOrderPoints = [];
            for (jdx = 0; jdx < len - 1 - idx; jdx++) {
                if (idx == 0) {
                    nthOrderPoints.push($point_midpoint(points[jdx], points[jdx + 1]));
                } else {
                    nthOrderPoints.push($point_midpoint(orderPoints[idx - 1][jdx], orderPoints[idx - 1][jdx + 1]));
                }
            }
            orderPoints.push(nthOrderPoints);
        }

        // collect left points
        var leftPoints = [];
        leftPoints.push(points[0]);
        for (idx = 0; idx < len - 1; idx++) {
            leftPoints.push(orderPoints[idx][0]);
        }
        // draw left side Bezier
        $plotBezier(leftPoints, depth + 1, displacement);

        // collect right points
        var rightPoints = [];
        for (idx = 0; idx < len - 1; idx++) {
            rightPoints.push(orderPoints[len - 2 - idx][idx]);
        }
        rightPoints.push(points[len - 1]);
        // draw right side Bezier
        $plotBezier(rightPoints, depth + 1, displacement);
    }
}

function $point_log(prefix, p) {
    log.log(prefix + ": {" + p[0] + ", " + p[1] + "}");
}

function $getPoint(name) {
    var point$ = $gPts[name];
    return [Number(point$.x), Number(point$.y)];
}

function $point_length(p) {
    var dx = p[0];
    var dy = p[1];
    return Math.hypot(dx, dy);
}

function $point_add(p1, p2) {
    return [p1[0] + p2[0], p1[1] + p2[1]];
}

function $point_subtract(p1, p2) {
    return [p1[0] - p2[0], p1[1] - p2[1]];
}

function $point_distance(p1, p2) {
    var delta = $point_subtract(p1, p2);
    return Math.hypot(delta[0], delta[1]);
}

function $point_midpoint(p1, p2) {
    return [$half(p1[0], p2[0]), $half(p1[1], p2[1])];
}

function $point_normalizeTo(p, new_length) {
    var m = new_length / $point_length(p);
    return [p[0] * m, p[1] * m];
}

function $point_orthogonal(p) {
    return [-p[1],p[0]];
}

function $computeAngleRAD(v) {
    return Math.atan2(v[0], v[1]);
}

function $computeAngleRAD2(p1, p2) {
    return $computeAngleRAD($point_subtract(p1, p2));
}

// Converts from degrees to radians.
function $toRadians(degrees) {
    return degrees * Math.PI / 180;
};

// Converts from radians to degrees.
function $toDegrees(radians) {
    return radians * 180 / Math.PI;
};

// rotate a point vector
function $point_rotate(point, angleRAD) {
    var sinA = Math.sin(angleRAD), cosA = Math.cos(angleRAD);
    var x = point[0], y = point[1];
    return [cosA * x - sinA * y, sinA * x + cosA * y];
}

function $point_lerp(p1, p2, amount) {
    return [$lerp(p1[0], p2[0], amount), $lerp(p1[1], p2[1], amount)]
}

function $point_third(p1, p2) {
    return $point_lerp(p1, p2, 1.0/3.0);
}

//set object attributes from xml attributes, returning object
var $getObjFromXML = function(e) {
    var $widget = {};
    $(e.attributes).each(function() {
        $widget[this.name] = this.value;
    });
    return $widget;
};

//redraw all "drawn" elements for given block (called after color change)
function $redrawBlock(blockName) {
    //log.log("redrawing all tracks for block " + blockName);
    //loop thru widgets, if block matches, redraw widget by proper method
    jQuery.each($gWidgets, function($id, $widget) {
        $logProperties($widget);
        if (($widget.blockname == blockName)
        || ($widget.blocknameac == blockName)
        || ($widget.blocknamebd == blockName)
        || ($widget.blockbname == blockName)
        || ($widget.blockcname == blockName)
        || ($widget.blockdname == blockName)) {
            switch ($widget.widgetType) {
                case 'layoutturnout' :
                    $drawTurnout($widget);
                    break;
                case 'layoutSlip' :
                    $drawSlip($widget);
                    break;
                case 'tracksegment' :
                    $drawTrackSegment($widget);
                    break;
                case 'levelxing' :
                    $drawLevelXing($widget);
                    break;
            }
        }
        if ($widget.widgetType == 'layoutSlip') {
            if ((isDefined($widget.connectaname) && ($gWidgets[$widget.connectaname].blockname == blockName))
            || isDefined($widget.connectbname) && ($gWidgets[$widget.connectbname].blockname == blockName)
            || isDefined($widget.connectcname) && ($gWidgets[$widget.connectcname].blockname == blockName)
            || isDefined($widget.connectdname) && ($gWidgets[$widget.connectdname].blockname == blockName)){
                $drawSlip($widget);
            }
        }
    });
};

//redraw all "drawn" elements to overcome some bidirectional dependencies in the xml
var $drawAllDrawnWidgets = function() {
    //loop thru widgets, redrawing each visible widget by proper method
    jQuery.each($gWidgets, function($id, $widget) {
        switch ($widget.widgetType) {
            case 'layoutturnout' :
                $drawTurnout($widget);
                break;
            case 'layoutSlip' :
                $drawSlip($widget);
                break;
            case 'tracksegment' :
                $drawTrackSegment($widget);
                break;
            case 'levelxing' :
                $drawLevelXing($widget);
                break;
        }
    });
};

// redraw all "icon" Control Panel elements. Called after a delay to allow loading of images.
var $drawAllIconWidgets = function() {
    //loop thru widgets, repositioning each icon widget
    jQuery.each($gWidgets, function($id, $widget) {
        switch ($widget.widgetFamily) {
            case 'icon' :
                $setWidgetPosition($("#panel-area > #" + $widget.id));
                break;
        }
    });
};

// draw all beanswitch icons first time
var $drawAllSwitchIcons = function() {
    jQuery.each($gWidgets, function($id, $widget) {
        switch ($widget.widgetFamily) {
            case 'switch' :
                if (isDefined($widget['shape']) && ($widget.shape != "button")) {
                    $drawWidgetSymbol($id, UNKNOWN); // draw first time UNKNOWN = 0
                }
                break;
        }
    });
};

function createPanelCanvas() {
    if ($gCtx == undefined) {  //create canvas if not already created
        $("#panel-area").prepend("<canvas id='panelCanvas' width=" + $gPanel.panelwidth + "px height=" +
            $gPanel.panelheight + "px style='position:absolute;z-index:2;'>");
        var canvas = document.getElementById("panelCanvas");
        $gCtx = canvas.getContext("2d");
        $gCtx.strokeStyle = $gPanel.defaulttrackcolor;
        $gCtx.lineWidth = $gPanel.sidelinetrackwidth;
        //set background color from panel attribute (single hex value)
        $("#panel-area").css({'background-color': $gPanel.backgroundcolor});
    }    
};

function updateWidgets(name, state, data) {
    // update all widgets based on the element that changed, using systemname
    if (whereUsed[name]) {
        //log.log("updateWidgets(" + name + ", " + state);
        $.each(whereUsed[name], function(index, widgetId) {
            $setWidgetState(widgetId, state, data);
        });
    }
    //update all widgets based on the element that changed, using username
    if (isDefined(data.userName) && whereUsed[data.userName]) {
        //log.log("updateWidgets by username (" + data.userName + "), " + state);
        $.each(whereUsed[data.userName], function(index, widgetId) {
            $setWidgetState(widgetId, state, data);
        });
    }
}

function updateOccupancy(sensorName, state, data) {
    // handle occupancy sensors by systemname
    if (occupancyNames[sensorName]) {
        updateOccupancySub(sensorName, state);
    }
    // handle occupancy sensors by username
    if (occupancyNames[data.userName]) {
        updateOccupancySub(data.userName, state);
    }
}

function updateOccupancySub(sensorName, state) {
    if (occupancyNames[sensorName]) {
        $.each(occupancyNames[sensorName], function(index, widgetId) {
            $widget = $gWidgets[widgetId];

            updateBlockSensorState($widget.blockname, sensorName, state);
            updateBlockSensorState($widget.blocknameac, sensorName, state);
            updateBlockSensorState($widget.blocknamebd, sensorName, state);
            updateBlockSensorState($widget.blockbname, sensorName, state);
            updateBlockSensorState($widget.blockcname, sensorName, state);
            updateBlockSensorState($widget.blockdname, sensorName, state);

            $widget.occupancystate = state; // set occupancy for the widget to the newstate

            switch ($widget.widgetType) {
                case 'layoutturnout' :
                    $drawTurnout($widget);
                    break;
                case 'layoutSlip' :
                    $drawSlip($widget);
                    break;
                case 'indicatortrackicon' :
                case 'indicatorturnouticon' :
                    $reDrawIcon($widget)
                    //console.log("IT(O)I sensor change");
                    break;
                default :
                    break;
            }
        });
    }
}

function updateBlockSensorState(blockName, sensorName, sensorState) {
    if (isDefined(blockName)) {
        var $blk = $gBlks[blockName];
        if (isDefined($blk)) {
            if (isDefined($blk.occupancysensor)
                && ($blk.occupancysensor == sensorName)) {
                $blk.state = sensorState;
            }
        }
    }
}

function setBlockColor(blockName, newColor) {
    //log.log("setBlockColor(" + blockName + ", " + newColor + ");");
    var $blk = $gBlks[blockName];
    if (isDefined($blk)) {
        $gBlks[blockName].blockcolor = newColor;
    } else {
        log.error("ERROR: block " + blockName + " not found for color " + newColor);
    }
    $redrawBlock(blockName);
}

function updateOblocks(oblockName, status) { // based on updateOccupancy()
    // all oblocks are handled by their systemname
    if ($oblockNames[oblockName]) {
        $.each($oblockNames[oblockName], function(index, widgetId) {
            $widget = $gWidgets[widgetId];
            switch ($widget.widgetType) {
                case 'indicatortrackicon' :
                case 'indicatorturnouticon' : // does not receive turnout state via oblock
                    //console.log("updateOblocks UNFILTERED " + oblockName + " on widget " + $widget.id + " status=" + status);
                    if (status < 0x16) { // ignore (un)occupied
                        // pass on as is
                    } else if ((status & TRACK_ERROR) == TRACK_ERROR) { // ErrorTrack, swallow DontUse, Allocated 0x80
                        status = (status & 0x86);
                    } else if ((status & OUT_OF_SERVICE) == OUT_OF_SERVICE) { // DontUseTrack, swallow Allocated, ignore Occupied 0x40
                        status = (status & 0x40);
                    } else if ((status & RUNNING) == RUNNING) { // Running = occupied by train (via oblock) 0x20
                        status = (status & 0x22); // keep Occupied bit
                    } else if ((status & 0x12) == 0x2) { // Occupied, swallow Allocated
                        status = (status & 0x2);
                    } else if ((status & ALLOCATED) == ALLOCATED) { // Allocated 0x10
                        status = (status & 0x12); // only keep Occupied bit, it should overrule ALLOCATED
                    }
                    $widget.occupancystate = status; // set occupancy for the widget to the new occ.status
                    //console.log("updateOblocks FILTERED FOR " + oblockName + " on widget " + $widget.id + " status=" + $widget.occupancystate);

                    // enable/disable turnout click handling
                        if (status == OUT_OF_SERVICE) {
                            $('#'+$widget.id).removeClass("clickable");
                            $('#'+$widget.id).unbind(UPEVENT, $handleClick);
                        } else {
                            $('#'+$widget.id).addClass("clickable");
                            $('#'+$widget.id).bind(UPEVENT, $handleClick);
                        }

                    $reDrawIcon($widget);
                    break;
                default:
                    break; // shouldn't get here
            }
        });
    }
}

// convert turnout state to string
function turnoutStateToString(state) {
    result = "UKNOWN"
    switch (state) {
        case 2:
            result = "CLOSED";
            break;
        case 4:
            result = "THROWN";
            break;
        case 8:
            result = "INCONSISTENT";
            break;
    }
    return result;
}

// convert slip state to string
function slipStateToString(state) {
    result = "UNKNOWN";
    switch (state) {
        case STATE_AC:
            result = "STATE_AC";
            break;
        case STATE_AD:
            result = "STATE_AD";
            break;
        case STATE_BC:
            result = "STATE_BC";
            break;
        case STATE_BD:
            result = "STATE_BD";
            break;
    }
    return result;
}

function getTurnoutStatesForSlipState(slipWidget, slipState) {
    var results = [UNKNOWN, UNKNOWN];
    if (isDefined(slipWidget)) {
        if (slipWidget.widgetType == 'layoutSlip') {
            switch (slipState) {
                case STATE_AC:
                    results = [slipWidget.turnoutA_AC, slipWidget.turnoutB_AC];
                    break;
                case STATE_AD:
                    results = [slipWidget.turnoutA_AD, slipWidget.turnoutB_AD];
                    break;
                case STATE_BC:
                    results = [slipWidget.turnoutA_BC, slipWidget.turnoutB_BC];
                    break;
                case STATE_BD:
                    results = [slipWidget.turnoutA_BD, slipWidget.turnoutB_BD];
                    break;
            }
        }
    }
    return results;
}

function getTurnoutStatesForSlip(slipWidget) {
    return getTurnoutStatesForSlipState(slipWidget, slipWidget.state);
}

function getSlipStateForTurnoutStatesClosest(slipWidget, stateA, stateB, useClosest) {
    var result = UNKNOWN;
    if ((stateA == slipWidget.turnoutA_AC) && (stateB == slipWidget.turnoutB_AC)) {
        result = STATE_AC;
    } else if ((stateA == slipWidget.turnoutA_AD) && (stateB == slipWidget.turnoutB_AD)) {
        result = STATE_AD;
    } else if ((slipWidget.slipType == DOUBLE_SLIP)
        && (stateA == slipWidget.turnoutA_BC) && (stateB == slipWidget.turnoutB_BC)) {
        result = STATE_BC;
    } else if ((stateA == slipWidget.turnoutA_BD) && (stateB == slipWidget.turnoutB_BD)) {
        result = STATE_BD;
    } else if (useClosest) {
        if ((stateA == slipWidget.turnoutA_AC) || (stateB == slipWidget.turnoutB_AC)) {
            result = STATE_AC;
        } else if ((stateA == slipWidget.turnoutA_AD) || (stateB == slipWidget.turnoutB_AD)) {
            result = STATE_AD;
        } else if ((slipWidget.slipType == DOUBLE_SLIP)
            && (stateA == slipWidget.turnoutA_BC) || (stateB == slipWidget.turnoutB_BC)) {
            result = STATE_BC;
        } else if ((stateA == slipWidget.turnoutA_BD) || (stateB == slipWidget.turnoutB_BD)) {
            result = STATE_BD;
        } else {
            result = STATE_AD;
        }
    }
    return result;
}

function getSlipStateForTurnoutStates(slipWidget, stateA, stateB) {
    return getSlipStateForTurnoutStatesClosest(slipWidget, stateA, stateB, false)
}

//slip A==-==D
//      \\ //
//        X
//      // \\
//     B==-==C
// var STATE_AC = 0x02;
// var STATE_BD = 0x04;
// var STATE_AD = 0x06;
// var STATE_BC = 0x08;
// var CLOSED = '2';
// var THROWN = '4';

function getNextSlipState(slipWidget) {
    var result = UNKNOWN;

    // log.log("****************************");
    // log.log("slipWidget.side:" + slipWidget.side);
    // log.log("  slipWidget.state:" + slipWidget.state);
    switch (slipWidget.side) {
        case 'left': {
            switch (slipWidget.state) {
                case STATE_AC:
                    if (slipWidget.slipType == SINGLE_SLIP) {
                        result = STATE_BD;
                    } else {
                        result = STATE_BC;
                    }
                    break;
                case STATE_AD:
                    result = STATE_BD;
                    break;
                case STATE_BC:
                default:
                    result = STATE_AC;
                    break;
                case STATE_BD:
                    result = STATE_AD;
                    break;
            }
            break;
        }
        case 'right': {
            switch (slipWidget.state) {
                case STATE_AC:
                    result = STATE_AD;
                    break;
                case STATE_AD:
                    result = STATE_AC;
                    break;
                case STATE_BC:
                default:
                    result = STATE_BD;
                    break;
                case STATE_BD:
                    if (slipWidget.slipType == SINGLE_SLIP) {
                        result = STATE_AC;
                    } else {
                        result = STATE_BC;
                    }
                    break;
            }
            break;
        }
        default: {
            log.log("getNextSlipState($widget): unknown $widget.side: " + slipWidget.side);
            break;
        }
    }
    return result;
}
// ======= End of Layout Editor functions =======

/******************************************************************
*  ======= Layout Editor Decoration classes =======
*/

class Decoration {
    constructor($widget) {
        //log.log("Decoration.constructor(...)");
        $logProperties(this.$widget);
        this.$widget = $widget;
    }
    getEndPoints() {
        [this.ep1, this.ep2] = $getEndPoints(this.$widget);
        //log.log("ep1 = {" + this.ep1[0] + "," + this.ep1[1] + "}, ep2 = {" + this.ep2[0] + "," + this.ep2[1] + "}");
    }
    getAngles() {
        var $widget = this.$widget;
        if ($widget.bezier == "yes") {
            this.getBezierAngles();
        } else if ($widget.circle == "yes") {
            this.getCircleAngles();
        } else if ($widget.arc == "yes") {
            this.getArcAngles();
        } else {
            this.startAngleRAD = (Math.PI / 2) - $computeAngleRAD2(this.ep2, this.ep1);
            this.stopAngleRAD = this.startAngleRAD;
        }
        //log.log("startAngleDEG: " + $toDegrees(this.startAngleRAD) + ", stopAngleDEG: " + $toDegrees(this.stopAngleRAD) + ".");
    }
    getBezierAngles() {
        var $widget = this.$widget;
        var $cps = $widget.controlpoints;   // get the control points
        var $cp0 = $cps[0];
        var $cpN = $cps[$cps.length - 1];
        var cp0 = $getLayoutPoint($cp0);
        var cpN = $getLayoutPoint($cpN);
        this.startAngleRAD = (Math.PI / 2) - $computeAngleRAD2(cp0, this.ep1);
        this.stopAngleRAD = (Math.PI / 2) - $computeAngleRAD2(this.ep2, cpN);
    }
    getCircleAngles() {
        var $widget = this.$widget;
        var extentAngleDEG = $widget.angle;
        if (extentAngleDEG == 0) {
            extentAngleDEG = 90;
        }
        var startAngleRAD, stopAngleRAD;
        // Convert angle to radiants in order to speed up math
        var halfAngleRAD = $toRadians(extentAngleDEG) / 2;
        // Compute arc's chord
        var a = this.ep2[0] - this.ep1[0];
        var o = this.ep2[1] - this.ep1[1];
        var chord = Math.hypot(a, o);
        // Make sure chord is not null
        // In such a case (ep1 == ep2), there is no arc to draw
        if (chord > 0) {
            var midAngleRAD = Math.atan2(a, o);
            startAngleRAD = (Math.PI / 2) - (midAngleRAD + halfAngleRAD);
            stopAngleRAD = (Math.PI / 2) - (midAngleRAD - halfAngleRAD);
        }
        this.startAngleRAD = startAngleRAD; this.stopAngleRAD = stopAngleRAD;
    }
    getArcAngles() {
        var startAngleRAD, stopAngleRAD;
        if (this.ep1[0] < this.ep2[0]) {
            if (this.ep1[1] < this.ep2[1]) {    //log.log("#### QUAD ONE ####");
                startAngleRAD = 0; stopAngleRAD = Math.PI / 2;
            } else {                            //log.log("#### QUAD TWO ####");
                startAngleRAD = -Math.PI / 2; stopAngleRAD = 0;
            }
        } else {
            if (this.ep1[1] < this.ep2[1]) {    //log.log("#### QUAD THREE ####");
                startAngleRAD = Math.PI / 2; stopAngleRAD = Math.PI;
            } else {                            //log.log("#### QUAD FOUR ####");
                startAngleRAD = Math.PI; stopAngleRAD = -Math.PI / 2;
            }
        }
        this.startAngleRAD = startAngleRAD; this.stopAngleRAD = stopAngleRAD;
    }

    draw() {
        this.getEndPoints();
        this.getAngles();
    }

    getArcParams(rw, rh, tp1, tp2) {
        var x, y;
        if (rw < 0) {
            rw = -rw;
            if (rh < 0) {                   //log.log("**** QUAD ONE ****");
                x = tp1[0]; y = tp2[1];
                rh = -rh;
            } else {                        //log.log("**** QUAD TWO ****");
                x = tp2[0]; y = tp1[1];
            }
        } else {
            if (rh < 0) {                   //log.log("**** QUAD THREE ****");
                x = tp2[0]; y = tp1[1];
                rh = -rh;
            } else {                        //log.log("**** QUAD FOUR ****");
                x = tp1[0]; y = tp2[1];
            }
        }
        return [x, y, rw, rh];
    }
}   // class Decoration

class ArrowDecoration extends Decoration {
    constructor($widget, $arrow) {
        super($widget);
        //<arrow style="4" end="stop" direction="out" color="#000000" linewidth="4" length="16" gap="1" />
        this.style = Number($arrow.attr('style'));
        this.end = $arrow.attr('end');
        this.direction = $arrow.attr('direction');
        this.color = $arrow.attr('color');
        this.linewidth = Number($arrow.attr('linewidth'));
        this.length = Number($arrow.attr('length'));
        this.gap = Number($arrow.attr('gap'));
        //log.log("arrow: {end:" + this.end + ", dir: " + this.direction + "}");
    }
    draw() {
        super.draw();
        $gCtx.save();   // save current line width and color
        // set color and width
        $gCtx.strokeStyle = this.color;
        $gCtx.fillStyle = this.color;
        $gCtx.lineWidth = this.linewidth;
        this.drawArrowStart();
        this.drawArrowStop();
        $gCtx.restore();        // restore color and width back to default
    }
    drawArrowStart() {
        var angleRAD = this.startAngleRAD;
        if (this.$widget.flip == "yes") {
            angleRAD = this.stopAngleRAD;
        }
        this.offset = 1;        // draw the start arrows
        if ((this.end == "start") || (this.end == "both")) {
            if ((this.direction == "in") || (this.direction == "both")) {
                this.drawArrowIn(this.ep1, Math.PI + angleRAD);
            }
            if ((this.direction == "out") || (this.direction == "both")) {
                this.drawArrowOut(this.ep1, Math.PI + angleRAD);
            }
        }
    }
    drawArrowStop() {
        var angleRAD = this.stopAngleRAD;
        if (this.$widget.flip == "yes") {
            angleRAD = this.startAngleRAD;
        }
        this.offset = 1;        // draw the stop arrows
        if ((this.end == "stop") || (this.end == "both")) {
            if ((this.direction == "in") || (this.direction == "both")) {
                this.drawArrowIn(this.ep2, angleRAD);
            }
            if ((this.direction == "out") || (this.direction == "both")) {
                this.drawArrowOut(this.ep2, angleRAD);
            }
        }
    }
    drawArrowIn(ep, angleRAD) {
        $gCtx.save();
        $gCtx.translate(ep[0], ep[1]);
        $gCtx.rotate(angleRAD);

        switch (this.style) {
            default:
                this.style = 0;
            case 0:
                break;
            case 1:
                this.drawArrow1In();
                break;
            case 2:
                this.drawArrow2In();
                break;
            case 3:
                this.drawArrow3In();
                break;
            case 4:
                this.drawArrow4In();
                break;
            case 5:
                this.drawArrow5In();
        }
        $gCtx.restore();
    }   // drawArrowIn

    drawArrowOut(ep, angleRAD) {
        $gCtx.save();
        $gCtx.translate(ep[0], ep[1]);
        $gCtx.rotate(angleRAD);

        switch (this.style) {
            default:
                this.style = 0;
            case 0:
                break;
            case 1:
                this.drawArrow1Out();
                break;
            case 2:
                this.drawArrow2Out();
                break;
            case 3:
                this.drawArrow3Out();
                break;
            case 4:
                this.drawArrow4Out();
                break;
            case 5:
                this.drawArrow5Out();
        }
        $gCtx.restore();
    }   // drawArrowIn

    drawArrow1In() {
        var p1 = [this.offset + this.length, -this.length];
        var p2 = [this.offset, 0];
        var p3 = [this.offset + this.length, +this.length];

        $drawLineP(p1, p2);
        $drawLineP(p2, p3);
        this.offset += this.length + this.gap;
    }

    drawArrow1Out() {
        var p1 = [this.offset, -this.length];
        var p2 = [this.offset + this.length, 0];
        var p3 = [this.offset, +this.length];

        $drawLineP(p1, p2);
        $drawLineP(p2, p3);
        this.offset += this.length + this.gap;
    }

    drawArrow2In() {
        var p1 = [this.offset + this.length, -this.length];
        var p2 = [this.offset, 0];
        var p3 = [this.offset + this.length, +this.length];
        var p4 = [this.offset + this.linewidth + this.gap + this.length, -this.length];
        var p5 = [this.offset + this.linewidth + this.gap, 0];
        var p6 = [this.offset + this.linewidth + this.gap + this.length, +this.length];

        $drawLineP(p1, p2);
        $drawLineP(p2, p3);
        $drawLineP(p4, p5);
        $drawLineP(p5, p6);
        this.offset += this.length + (2 * (this.linewidth + this.gap));
    }

    drawArrow2Out() {
        var p1 = [this.offset, -this.length];
        var p2 = [this.offset + this.length, 0];
        var p3 = [this.offset, +this.length];
        var p4 = [this.offset + this.linewidth + this.gap, -this.length];
        var p5 = [this.offset + this.linewidth + this.gap + this.length, 0];
        var p6 = [this.offset + this.linewidth + this.gap, +this.length];

        $drawLineP(p1, p2);
        $drawLineP(p2, p3);
        $drawLineP(p4, p5);
        $drawLineP(p5, p6);
        this.offset += this.length + (2 * (this.linewidth + this.gap));
    }

    drawArrow3In() {
        var p1 = [this.offset + this.length, -this.length];
        var p2 = [this.offset, 0];
        var p3 = [this.offset + this.length, +this.length];

        $gCtx.beginPath();
        $gCtx.moveTo(p1[0], p1[1]);
        $gCtx.lineTo(p2[0], p2[1]);
        $gCtx.lineTo(p3[0], p3[1]);
        $gCtx.closePath();
        if (this.linewidth > 1) {
            $gCtx.fill();
        } else {
            $gCtx.stroke();
        }
        this.offset += this.length + this.gap;
    }

    drawArrow3Out() {
        var p1 = [this.offset, -this.length];
        var p2 = [this.offset + this.length, 0];
        var p3 = [this.offset, +this.length];

        $gCtx.beginPath();
        $gCtx.moveTo(p1[0], p1[1]);
        $gCtx.lineTo(p2[0], p2[1]);
        $gCtx.lineTo(p3[0], p3[1]);
        $gCtx.closePath();
        if (this.linewidth > 1) {
            $gCtx.fill();
        } else {
            $gCtx.stroke();
        }
        this.offset += this.length + this.gap;
    }

    drawArrow4In() {
        var p1 = [this.offset, 0];
        var p2 = [this.offset + (4 * this.length), -this.length];
        var p3 = [this.offset + (3 * this.length), 0];
        var p4 = [this.offset + (4 * this.length), +this.length];

        $drawLineP(p1, p3);
        $drawLineP(p2, p3);
        $drawLineP(p3, p4);
        this.offset += (3 * this.length) + this.gap;
    }

    drawArrow4Out() {
        var p1 = [this.offset, 0];
        var p2 = [this.offset + (2 * this.length), -this.length];
        var p3 = [this.offset + (3 * this.length), 0];
        var p4 = [this.offset + (2 * this.length), +this.length];

        $drawLineP(p1, p3);
        $drawLineP(p2, p3);
        $drawLineP(p3, p4);
        this.offset += (3 * this.length) + this.gap;
    }

    drawArrow5In() {
        var p1 = [this.offset, 0];
        var p2 = [this.offset + (4 * this.length), -this.length];
        var p3 = [this.offset + (3 * this.length), 0];
        var p4 = [this.offset + (4 * this.length), +this.length];

        $gCtx.beginPath();
        $gCtx.moveTo(p4[0], p4[1]);
        $gCtx.lineTo(p2[0], p2[1]);
        $gCtx.lineTo(p3[0], p3[1]);
        $gCtx.closePath();
        if (this.linewidth > 1) {
            $gCtx.fill();
        } else {
            $gCtx.stroke();
        }
        $drawLineP(p1, p3);
        this.offset += (3 * this.length) + this.gap;
    }

    drawArrow5Out() {
        var p1 = [this.offset, 0];
        var p2 = [this.offset + (2 * this.length), -this.length];
        var p3 = [this.offset + (3 * this.length), 0];
        var p4 = [this.offset + (2 * this.length), +this.length];

        $gCtx.beginPath();
        $gCtx.moveTo(p4[0], p4[1]);
        $gCtx.lineTo(p2[0], p2[1]);
        $gCtx.lineTo(p3[0], p3[1]);
        $gCtx.closePath();
        if (this.linewidth > 1) {
            $gCtx.fill();
        } else {
            $gCtx.stroke();
        }
        $drawLineP(p1, p3);
        this.offset += (3 * this.length) + this.gap;
    }
}   // class ArrowDecoration

class BridgeDecoration extends Decoration {
    constructor($widget, $bridge) {
        super($widget);
        //<bridge side="both" end="both" color="#000000" linewidth="1" approachwidth="8" deckwidth="10" />
        this.side = $bridge.attr('side');
        this.end = $bridge.attr('end');
        this.color = $bridge.attr('color');
        this.linewidth = Number($bridge.attr('linewidth'));
        this.approachwidth = Number($bridge.attr('approachwidth'));
        this.deckwidth = Number($bridge.attr('deckwidth'));
    }
    draw() {
        super.draw();
        var $widget = this.$widget;
        $gCtx.save();   // save current line width and color
        // set color and width
        $gCtx.strokeStyle = this.color;
        $gCtx.fillStyle = this.color;
        $gCtx.lineWidth = this.linewidth;
        if ($widget.circle == "yes") {
            this.drawBridgeCircle();
        } else if ($widget.arc == "yes") {
            this.drawBridgeArc();
        } else if ($widget.bezier == "yes") {
            this.drawBridgeBezier();
        } else {
            this.drawBridgeStrait();
        }
        this.drawBridgeEnds();
        $gCtx.restore();        // restore color and width back to default
    }   // draw()

    drawBridgeCircle() {
        var $widget = this.$widget;
        var halfWidth = this.deckwidth / 2;
        var ep1 = this.ep1, ep2 = this.ep2;
        var startAngleRAD = this.startAngleRAD, stopAngleRAD = this.stopAngleRAD;
        var v = [0, +halfWidth];
        if ($widget.flip == "yes") {
            v = [0, -halfWidth];
            [startAngleRAD, stopAngleRAD] = [stopAngleRAD, startAngleRAD];
        }
        if ((this.side == "right") || (this.side == "both")) {
            var tp1 = $point_add(ep1, $point_rotate(v, startAngleRAD));
            var tp2 = $point_add(ep2, $point_rotate(v, stopAngleRAD));
            if ($widget.flip == "yes") {
                $drawArcP(tp2, tp1, $widget.angle);
            } else {
                $drawArcP(tp1, tp2, $widget.angle);
            }
        }
        if ((this.side == "left") || (this.side == "both")) {
            var tp1 = $point_subtract(ep1, $point_rotate(v, startAngleRAD));
            var tp2 = $point_subtract(ep2, $point_rotate(v, stopAngleRAD));
            if ($widget.flip == "yes") {
                $drawArcP(tp2, tp1, $widget.angle);
            } else {
                $drawArcP(tp1, tp2, $widget.angle);
            }
        }
    }
    drawBridgeArc() {   //draw arc of ellipse
        var $widget = this.$widget;
        var tp1 = this.ep1, tp2 = this.ep2;
        var startAngleRAD = this.startAngleRAD, stopAngleRAD = this.stopAngleRAD;
        if ($widget.flip == "yes") {
            [tp1, tp2] = [tp2, tp1];
            startAngleRAD += Math.PI;
            stopAngleRAD += Math.PI;
        }
        var halfWidth = this.deckwidth / 2;
        var x, y;
        var rw = tp2[0] - tp1[0], rh = tp2[1] - tp1[1];
        [x, y, rw, rh] = this.getArcParams(rw, rh, tp1, tp2);

        rw -= halfWidth;    rh -= halfWidth;
        if ((this.side == "right") || (this.side == "both")) {
            $drawEllipse(x, y, rw, rh, Math.PI + stopAngleRAD, startAngleRAD);
        }
        rw += this.deckwidth;  rh += this.deckwidth;
        if ((this.side == "left") || (this.side == "both")) {
            $drawEllipse(x, y, rw, rh, Math.PI + stopAngleRAD, startAngleRAD);
        }
    }   // drawBridgeArc()

    drawBridgeBezier() {
        var $widget = this.$widget;
        var ep1 = this.ep1, ep2 = this.ep2;
        var points = [[ep1[0], ep1[1]]];    // first point
        var $cps = $widget.controlpoints;   // get the control points
        $cps.each(function( idx, elem ) {   // control points
            points.push($getLayoutPoint(elem));
        });
        points.push([ep2[0], ep2[1]]);  // last point
        var halfWidth = this.deckwidth / 2;
        if (((this.side == "left") || (this.side == "both"))) {
            $drawBezier(points, this.color, this.linewidth, -halfWidth);
        }
        if ((this.side == "right") || (this.side == "both")) {
            $drawBezier(points, this.color, this.linewidth, +halfWidth);
        }
    }
    drawBridgeStrait() {
        var $widget = this.$widget;
        var ep1 = this.ep1, ep2 = this.ep2;
        var halfWidth = this.deckwidth / 2;
        var vector = $point_orthogonal($point_normalizeTo($point_subtract(ep2, ep1), halfWidth));
        if ((this.side == "right") || (this.side == "both")) {
            $drawLineP($point_add(ep1, vector), $point_add(ep2, vector));
        }
        if (((this.side == "left") || (this.side == "both"))) {
            $drawLineP($point_subtract(ep1, vector), $point_subtract(ep2, vector));
        }
    }
    drawBridgeEnds() {
        if ((this.end == "entry") || (this.end == "both")) {
            this.drawBridgeEntry();
        }
        if ((this.end == "exit") || (this.end == "both")) {
            this.drawBridgeExit();
       }
    }
    drawBridgeEntry() {
        var $widget = this.$widget;
        var ep1 = this.ep1;
        var startAngleRAD = this.startAngleRAD, stopAngleRAD = this.stopAngleRAD;
        var halfWidth = this.deckwidth / 2;
        var isRight = ((this.side == "right") || (this.side == "both"));
        var isLeft = ((this.side == "left") || (this.side == "both"));
        if ($widget.flip == "yes") {
            [isRight, isLeft] = [isLeft, isRight];
            [startAngleRAD, stopAngleRAD] = [stopAngleRAD, startAngleRAD];
        }
        var p1, p2;
        if (isRight) {
            p1 = [-this.approachwidth, +this.approachwidth + halfWidth];
            p2 = [0, +halfWidth];
            p1 = $point_add($point_rotate(p1, startAngleRAD), ep1);
            p2 = $point_add($point_rotate(p2, startAngleRAD), ep1);
            $drawLineP(p1, p2);
        }
        if (isLeft) {
            p1 = [-this.approachwidth, -this.approachwidth - halfWidth];
            p2 = [0, -halfWidth];
            p1 = $point_add($point_rotate(p1, startAngleRAD), ep1);
            p2 = $point_add($point_rotate(p2, startAngleRAD), ep1);
            $drawLineP(p1, p2);
        }
    }
    drawBridgeExit() {
        var $widget = this.$widget;
        var ep2 = this.ep2;
        var startAngleRAD = this.startAngleRAD, stopAngleRAD = this.stopAngleRAD;
        var halfWidth = this.deckwidth / 2;
        var isRight = ((this.side == "right") || (this.side == "both"));
        var isLeft = ((this.side == "left") || (this.side == "both"));
        if ($widget.flip == "yes") {
            [isRight, isLeft] = [isLeft, isRight];
            [startAngleRAD, stopAngleRAD] = [stopAngleRAD, startAngleRAD];
        }
        var p1, p2;
        if (isRight) {
            p1 = [+this.approachwidth, +this.approachwidth + halfWidth];
            p2 = [0, +halfWidth];
            p1 = $point_add($point_rotate(p1, stopAngleRAD), ep2);
            p2 = $point_add($point_rotate(p2, stopAngleRAD), ep2);
            $drawLineP(p1, p2);
        }
        if (isLeft) {
            p1 = [+this.approachwidth, -this.approachwidth - halfWidth];
            p2 = [0, -halfWidth];
            p1 = $point_add($point_rotate(p1, stopAngleRAD), ep2);
            p2 = $point_add($point_rotate(p2, stopAngleRAD), ep2);
            $drawLineP(p1, p2);
        }
    }
}   // BridgeDecoration

class BumperDecoration extends Decoration {
    constructor($widget, $bumper) {
        super($widget);
        //<bumper end="stop" color="#000000" linewidth="2" length="16" />
        this.end = $bumper.attr('end');
        this.color = $bumper.attr('color');
        this.linewidth = Number($bumper.attr('linewidth'));
        this.length = Number($bumper.attr('length'));
    }
    draw() {
        super.draw();
        $gCtx.save();   // save current line width and color
        // set color and width
        $gCtx.strokeStyle = this.color;
        $gCtx.fillStyle = this.color;
        $gCtx.lineWidth = this.linewidth;
        var $widget = this.$widget;
        var startAngleRAD = this.startAngleRAD, stopAngleRAD = this.stopAngleRAD;
        if ($widget.flip == "yes") {
            [startAngleRAD, stopAngleRAD] = [stopAngleRAD, startAngleRAD];
        }
        var bumperLength = this.length;
        var halfLength = bumperLength / 2;
        // common points
        if ((this.end == "start") || (this.end == "both")) {
            var p1 = [0, -halfLength], p2 = [0, +halfLength];
            var p1 = $point_add($point_rotate(p1, startAngleRAD), this.ep1);
            var p2 = $point_add($point_rotate(p2, startAngleRAD), this.ep1);
            $drawLineP(p1, p2);   // draw cross tie
        }
        if ((this.end == "stop") || (this.end == "both")) {
            var p1 = [0, -halfLength], p2 = [0, +halfLength];
            var p1 = $point_add($point_rotate(p1, stopAngleRAD), this.ep2);
            var p2 = $point_add($point_rotate(p2, stopAngleRAD), this.ep2);
            $drawLineP(p1, p2);   // draw cross tie
        }
        $gCtx.restore();        // restore color and width back to default
    }
}   //  class BumperDecoration

class TunnelDecoration extends Decoration {
    constructor($widget, $tunnel) {
        super($widget);
        //<tunnel side="right" end="both" color="#FF00FF" linewidth="2" entrancewidth="16" floorwidth="12" />
        this.side = $tunnel.attr('side');

        this.end = $tunnel.attr('end');
        this.color = $tunnel.attr('color');
        this.linewidth = Number($tunnel.attr('linewidth'));
        this.entrancewidth = Number($tunnel.attr('entrancewidth'));
        this.floorwidth = Number($tunnel.attr('floorwidth'));
    }
    draw() {
        super.draw();
        var $widget = this.$widget;
        $gCtx.save();   // save current line width and color
        // set color and width
        $gCtx.strokeStyle = this.color;
        $gCtx.fillStyle = this.color;
        $gCtx.lineWidth = this.linewidth;
        $gCtx.setLineDash([6, 4]);
        if ($widget.circle == "yes") {
            this.drawTunnelCircle();
        } else if ($widget.arc == "yes") {
            this.drawTunnelArc();
        } else if ($widget.bezier == "yes") {
            this.drawTunnelBezier();
        } else {
            this.drawTunnelStrait();
        }
        $gCtx.setLineDash([]);
        this.drawTunnelEnds();
        $gCtx.restore();        // restore color and width back to default
    }   // draw()

    drawTunnelCircle() {
        var $widget = this.$widget;
        var halfWidth = this.floorwidth / 2;
        var ep1 = this.ep1, ep2 = this.ep2;
        var startAngleRAD = this.startAngleRAD, stopAngleRAD = this.stopAngleRAD;
        var v = [0, +halfWidth];
        if ($widget.flip == "yes") {
            v = [0, -halfWidth];
            [startAngleRAD, stopAngleRAD] = [stopAngleRAD, startAngleRAD];
        }
        if ((this.side == "right") || (this.side == "both")) {
            var tp1 = $point_add(ep1, $point_rotate(v, startAngleRAD));
            var tp2 = $point_add(ep2, $point_rotate(v, stopAngleRAD));
            if ($widget.flip == "yes") {
                $drawArcP(tp2, tp1, $widget.angle);
            } else {
                $drawArcP(tp1, tp2, $widget.angle);
            }
        }
        if ((this.side == "left") || (this.side == "both")) {
            var tp1 = $point_subtract(ep1, $point_rotate(v, startAngleRAD));
            var tp2 = $point_subtract(ep2, $point_rotate(v, stopAngleRAD));
            if ($widget.flip == "yes") {
                $drawArcP(tp2, tp1, $widget.angle);
            } else {
                $drawArcP(tp1, tp2, $widget.angle);
            }
        }
    }
    drawTunnelArc() {   //draw arc of ellipse
        var $widget = this.$widget;
        var tp1 = this.ep1, tp2 = this.ep2;
        var startAngleRAD = this.startAngleRAD, stopAngleRAD = this.stopAngleRAD;
        if ($widget.flip == "yes") {
            [tp1, tp2] = [tp2, tp1];
            startAngleRAD += Math.PI;
            stopAngleRAD += Math.PI;
        }
        var halfWidth = this.floorwidth / 2;
        var x, y;
        var rw = tp2[0] - tp1[0], rh = tp2[1] - tp1[1];
        [x, y, rw, rh] = this.getArcParams(rw, rh, tp1, tp2);

        rw -= halfWidth;    rh -= halfWidth;
        if ((this.side == "right") || (this.side == "both")) {
            $drawEllipse(x, y, rw, rh, Math.PI + stopAngleRAD, startAngleRAD);
        }
        rw += this.floorwidth;  rh += this.floorwidth;
        if ((this.side == "left") || (this.side == "both")) {
            $drawEllipse(x, y, rw, rh, Math.PI + stopAngleRAD, startAngleRAD);
        }
    }   // drawTunnelArc()

    drawTunnelBezier() {
        var $widget = this.$widget;
        var ep1 = this.ep1, ep2 = this.ep2;
        var points = [[ep1[0], ep1[1]]];    // first point
        var $cps = $widget.controlpoints;   // get the control points
        $cps.each(function( idx, elem ) {   // control points
            points.push($getLayoutPoint(elem));
        });
        points.push([ep2[0], ep2[1]]);  // last point
        var halfWidth = this.floorwidth / 2;
        if (((this.side == "left") || (this.side == "both"))) {
            $drawBezier(points, this.color, this.linewidth, -halfWidth);
        }
        if ((this.side == "right") || (this.side == "both")) {
            $drawBezier(points, this.color, this.linewidth, +halfWidth);
        }
    }
    drawTunnelStrait() {
        var $widget = this.$widget;
        var ep1 = this.ep1, ep2 = this.ep2;
        var halfWidth = this.floorwidth / 2;
        var vector = $point_orthogonal($point_normalizeTo($point_subtract(ep2, ep1), halfWidth));
        if ((this.side == "right") || (this.side == "both")) {
            $drawLineP($point_add(ep1, vector), $point_add(ep2, vector));
        }
        if (((this.side == "left") || (this.side == "both"))) {
            $drawLineP($point_subtract(ep1, vector), $point_subtract(ep2, vector));
        }
    }
    drawTunnelEnds() {
        if ((this.end == "entry") || (this.end == "both")) {
            this.drawTunnelEntry();
        }
        if ((this.end == "exit") || (this.end == "both")) {
            this.drawTunnelExit();
       }
    }
    drawTunnelEntry() {
        var $widget = this.$widget;
        var ep1 = this.ep1;
        var angleRAD = this.startAngleRAD;
        var isRight = ((this.side == "right") || (this.side == "both"));
        var isLeft = ((this.side == "left") || (this.side == "both"));
        if ($widget.flip == "yes") {
            [isRight, isLeft] = [isLeft, isRight];  // swap left and right
            angleRAD = this.stopAngleRAD;
        }

        $gCtx.save();
        $gCtx.translate(ep1[0], ep1[1]);
        $gCtx.rotate(angleRAD);

        if (isRight) {
            this.drawTunnelEntryRight();
        }
        if (isLeft) {
            this.drawTunnelEntryLeft();
        }
        $gCtx.restore();
    }
    drawTunnelEntryRight() {
        var halfWidth = this.floorwidth / 2;
        var halfEntranceWidth = this.entrancewidth / 2;
        var halfFloorWidth = this.floorwidth / 2;
        var halfDiffWidth = halfEntranceWidth - halfFloorWidth;
        var p1, p2, p3, p4, p5, p6, p7;
        p1 = [0, 0];
        p2 = [0, +halfFloorWidth];
        p3 = [0, +halfEntranceWidth];
        p4 = [-halfEntranceWidth - halfFloorWidth, +halfEntranceWidth];
        p5 = [-halfEntranceWidth - halfFloorWidth, +halfEntranceWidth - halfDiffWidth];
        p6 = [-halfFloorWidth, +halfEntranceWidth - halfDiffWidth];
        p7 = [-halfDiffWidth, 0];

        $gCtx.beginPath();
        $gCtx.moveTo(p1[0], p1[1]);
        $gCtx.lineTo(p2[0], p2[1]);
        $gCtx.quadraticCurveTo(p3[0], p3[1], p4[0], p4[1]);
        $gCtx.lineTo(p5[0], p5[1]);
        $gCtx.quadraticCurveTo(p6[0], p6[1], p7[0], p7[1]);
        $gCtx.closePath();
        $gCtx.stroke();
    }
    drawTunnelEntryLeft() {
        var halfWidth = this.floorwidth / 2;
        var halfEntranceWidth = this.entrancewidth / 2;
        var halfFloorWidth = this.floorwidth / 2;
        var halfDiffWidth = halfEntranceWidth - halfFloorWidth;
        var p1, p2, p3, p4, p5, p6, p7;
        p1 = [0, 0];
        p2 = [0, -halfFloorWidth];
        p3 = [0, -halfEntranceWidth];
        p4 = [-halfEntranceWidth - halfFloorWidth, -halfEntranceWidth];
        p5 = [-halfEntranceWidth - halfFloorWidth, -halfEntranceWidth + halfDiffWidth];
        p6 = [-halfFloorWidth, -halfEntranceWidth + halfDiffWidth];
        p7 = [-halfDiffWidth, 0];

        $gCtx.beginPath();
        $gCtx.moveTo(p1[0], p1[1]);
        $gCtx.lineTo(p2[0], p2[1]);
        $gCtx.quadraticCurveTo(p3[0], p3[1], p4[0], p4[1]);
        $gCtx.lineTo(p5[0], p5[1]);
        $gCtx.quadraticCurveTo(p6[0], p6[1], p7[0], p7[1]);
        $gCtx.closePath();
        $gCtx.stroke();
    }
    drawTunnelExit() {
        var $widget = this.$widget;
        var ep2 = this.ep2;
        var angleRAD = this.stopAngleRAD;
        var isRight = ((this.side == "right") || (this.side == "both"));
        var isLeft = ((this.side == "left") || (this.side == "both"));
        if ($widget.flip == "yes") {
            [isRight, isLeft] = [isLeft, isRight];
            angleRAD = this.startAngleRAD;
        }

        var halfWidth = this.floorwidth / 2;
        var halfEntranceWidth = this.entrancewidth / 2;
        var halfFloorWidth = this.floorwidth / 2;
        var halfDiffWidth = halfEntranceWidth - halfFloorWidth;

        var p1, p2, p3, p4, p5, p6, p7;

        $gCtx.save();
        $gCtx.translate(ep2[0], ep2[1]);
        $gCtx.rotate(angleRAD);

        if (isRight) {
            this.drawTunnelExitRight();
        }
        if (isLeft) {
            this.drawTunnelExitLeft();
        }
        $gCtx.restore();
    }
    drawTunnelExitRight() {
        var halfWidth = this.floorwidth / 2;
        var halfEntranceWidth = this.entrancewidth / 2;
        var halfFloorWidth = this.floorwidth / 2;
        var halfDiffWidth = halfEntranceWidth - halfFloorWidth;
        var p1, p2, p3, p4, p5, p6, p7;
        p1 = [0, 0];
        p2 = [0, +halfFloorWidth];
        p3 = [0, +halfEntranceWidth];
        p4 = [halfEntranceWidth + halfFloorWidth, +halfEntranceWidth];
        p5 = [halfEntranceWidth + halfFloorWidth, +halfEntranceWidth - halfDiffWidth];
        p6 = [halfFloorWidth, +halfEntranceWidth - halfDiffWidth];
        p7 = [halfDiffWidth, 0];

        $gCtx.beginPath();
        $gCtx.moveTo(p1[0], p1[1]);
        $gCtx.lineTo(p2[0], p2[1]);
        $gCtx.quadraticCurveTo(p3[0], p3[1], p4[0], p4[1]);
        $gCtx.lineTo(p5[0], p5[1]);
        $gCtx.quadraticCurveTo(p6[0], p6[1], p7[0], p7[1]);
        $gCtx.closePath();
        $gCtx.stroke();
    }
    drawTunnelExitLeft() {
        var halfWidth = this.floorwidth / 2;
        var halfEntranceWidth = this.entrancewidth / 2;
        var halfFloorWidth = this.floorwidth / 2;
        var halfDiffWidth = halfEntranceWidth - halfFloorWidth;
        var p1, p2, p3, p4, p5, p6, p7;
        p1 = [0, 0];
        p2 = [0, -halfFloorWidth];
        p3 = [0, -halfEntranceWidth];
        p4 = [halfEntranceWidth + halfFloorWidth, -halfEntranceWidth];
        p5 = [halfEntranceWidth + halfFloorWidth, -halfEntranceWidth + halfDiffWidth];
        p6 = [halfFloorWidth, -halfEntranceWidth + halfDiffWidth];
        p7 = [halfDiffWidth, 0];

        $gCtx.beginPath();
        $gCtx.moveTo(p1[0], p1[1]);
        $gCtx.lineTo(p2[0], p2[1]);
        $gCtx.quadraticCurveTo(p3[0], p3[1], p4[0], p4[1]);
        $gCtx.lineTo(p5[0], p5[1]);
        $gCtx.quadraticCurveTo(p6[0], p6[1], p7[0], p7[1]);
        $gCtx.closePath();
        $gCtx.stroke();
    }
}

// End of Layout Editor Decoration classes =======