/**
 * @category    Webserver
 * @package     ATP1
 * @author      Sam Meredith <sam@aandtinstruments.com>
 * @version     0.1
 */

const DEFAULT_CONFIG = 
{
    "numberofpages": 1,
    "orientation": "portrait",
    "pagedefinitions": [
        {
            "pagenumber": 0,
            "numberoffields": 1,
            "fielddefinitions": [
                {
                    "fieldnumber": 0,
                    "channelnumber": 65,
                    "fontcolour": "#ffffff",
                    "backgroundcolour": "#000000",
                    "headingcolour": "#ffffff",
                    "misccolour": "#ffffff",
                    "rules": [],
                    "min": 0,
                    "max": 100,
                    "type": "text"
                }
            ],
            "bordercolour": "#ffffff"
        }
    ],
    "lighting": [
        { "level": 0, "percent": 100 },
        { "level": 1, "percent": 15  },
        { "level": 2, "percent": 50  },
        { "level": 4, "percent": 80  }
    ],
    "splashscreen": {
        "enabled": false,
        "splashbackground": "#000000"
    },
    "rules": [],
    "button": "default",
    "largeheadings": false,
    "contextswitching": {
        "enable": false,
        "enprestart": false,
        "timerch": 117,
        "timetodisplaytimer": 5,
        "switchingvarch": 89,
        "minlimit": 60,
        "maxlimit": 135,
        "transition0to1": 1000,
        "transition1to0": 1000,
        "transition1to2": 1000,
        "transition2to1": 1000,
    }
};
const DEFAULT_PAGE = DEFAULT_CONFIG.pagedefinitions[0];
const DEFAULT_FIELD = DEFAULT_PAGE.fielddefinitions[0];
const DEFAULT_RULE = 
{
    channelnumber: 65,
    condition: 'eq',
    value: 0,
    backgroundcolour: '#000000',
    headingcolour: '#ffffff',
    fontcolour: '#ffffff'
};

const COLOUR_OPTIONS = '<option value="#000000">Black</option> \
    <option value="#0000ff">Blue</option> \
    <option value="#00ffff">Cyan</option> \
    <option value="#008000">Green</option> \
    <option value="#222222">Grey</option> \
    <option value="#ff00ff">Magenta</option> \
    <option value="#ff0000">Red</option> \
    <option value="#ffff00">Yellow</option> \
    <option value="#ffffff">White</option>';

const MAX_RULES = 3;

const AWA_FIELD = 0x7051;
const AWS_FIELD = 0x704D;
const BSPD_FIELD = 0x7041;
const COG_M_FIELD = 0x70EA;
const COG_T_FIELD = 0x70E9;
const DEPTH_FIELD = 0x70C1;
const HEADING_FIELD = 0x7049;
const RUDDER_FIELD = 0x700B;
const TWA_FIELD = 0x7059;
const TWD_FIELD = 0x706D;
const TWS_FIELD = 0x7055;

const analogueFields = [AWA_FIELD, AWS_FIELD, BSPD_FIELD, COG_M_FIELD, COG_T_FIELD, DEPTH_FIELD,
    HEADING_FIELD, RUDDER_FIELD, TWA_FIELD, TWD_FIELD, TWS_FIELD];

class DisplayModel extends ObservableModel
{
    constructor(serial)
    {
        super();

        this.internal = !serial;

        this.serial = (serial || "internal").toLowerCase();

        this.displays = [];
        this.activePage = 0;
        this.savedConfigurations = [];

        // Load channels/menus
        this.channelMenu = new ChannelMenu();
    }

    get bordercolour()
    {
        return this.page.bordercolour;
    }
    set bordercolour(colourcode)
    {
        this.page.bordercolour = colourcode;
    }

    get configuration()
    {
        let display = this.displays.find(element => element.serial === this.serial);
        if (display) return display.configuration || DEFAULT_CONFIG;
        else return DEFAULT_CONFIG;
    }
    set configuration(s)
    {
        let display = this.displays.find(element => element.serial === this.serial);
        if (display)
        {
            display.configuration = s;
        }
        else
        {
            this.displays.push({ "serial": this.serial, "configuration": s });
        }
    }

    get fielddefinitions()
    {
        return this.page.fielddefinitions;
    }

    get numberoffields()
    {
        return this.page.numberoffields;
    }
    set numberoffields(count)
    {
        // If we're adding fields, set their configuration to default
        for (let i = this.numberoffields; i < count; i++)
        {
            this.page.fielddefinitions[i] = $.extend({}, DEFAULT_FIELD, { "fieldnumber": parseInt(i) });
        }

        // Remove any left over fields..
        this.page.fielddefinitions.splice(count);
        // ..and update the count in the configuration
        this.page.numberoffields = count;
        
        // Finally set the GridStack positions and sizes
        this.setDefaultAlignment(count, this.orientation);
    }

    get numberofpages()
    {
        return this.configuration.numberofpages;
    }
    set numberofpages(count)
    {
        // If we're adding fields, set their configuration to default
        for (let i = this.numberofpages; i < count; i++)
        {
            this.configuration.pagedefinitions[i] = $.extend({}, DEFAULT_PAGE, { "pagenumber": parseInt(i) });
        }

        // Remove any left over fields
        this.configuration.pagedefinitions.splice(count);
        // And update the count in the configuration
        this.configuration.numberofpages = count;
    }

    get orientation()
    {
        if (this.internal)
        {
            // Set based on device orientation
            if (screen.orientation.type == 'portrait-primary' || screen.orientation.type == 'portrait-secondary')
            {
                return "portrait";
            }
            else 
            {
                return "landscape";
            }
        }
        else return this.configuration.orientation;
    }

    get page()
    {
        return this.configuration.pagedefinitions[this.activePage];
    }

    init()
    {
        let self = this;

        let async = [];

        let configurationLoaded = $.Deferred();
        async.push(configurationLoaded);

        async.push($.when(readSection("Display"), getServerMillis(), self.loadSavedConfigurations(),
        this.channelMenu.loadSavedChannels()).done(function(displayResponse, timestampResponse, savedResponse)
        {
            let configurationAsync = [];

            if (savedResponse[1] === "success" && savedResponse[0].status == true) 
            {
                $.each(savedResponse[0].response, function(key, value) 
                {
                    self.savedConfigurations.push(value);
                });
            }

            let timestamp = 0;
            if (timestampResponse[1] === "success") timestamp = parseInt(timestampResponse[0]);

            // Parse the BFDs from the display section
            if (displayResponse[1] === "success")
            {
                let jsonResponse = JSON.parse(displayResponse[0]);

                if (self.internal) 
                {
                    configurationAsync.push(readSingle("bfdpagedef", "0").done(function(configResponse)
                    {
                        self.displays.push({ "serial": "internal", "configuration": $.extend({}, DEFAULT_CONFIG, JSON.parse(configResponse || "{}"))});
                    }))
                }
                else
                {
                    for (let display in jsonResponse)
                    {
                        if (jsonResponse.hasOwnProperty(display) && display.indexOf("bfd") != -1 )
                        {
                            let split = jsonResponse[display].split(',');

                            self.displays.push(
                                {
                                    "serial": display,
                                    "address": split[1],
                                    "timestamp": split[2],
                                    "detected": ((timestamp - parseInt(split[2]) < 600000) || (display.toLowerCase() == self.serial))
                                }
                            )

                            configurationAsync.push($.when(readSingle("bfdpagedef", display), readSingle("bfdnames", display)).done(function(configResponse, nameResponse)
                            {
                                let name = display.toUpperCase();
                                if (nameResponse[1] === "success")
                                {
                                    name = nameResponse[0] || display.toUpperCase();
                                }

                                if (configResponse[1] === "success")
                                {
                                    let config = self.displays.find(element => element.serial === display);

                                    let configuration;
                                    try
                                    {
                                        let JSONConfig = JSON.parse(configResponse[0]);
                                        configuration = $.extend(true, {}, DEFAULT_CONFIG, JSONConfig);
                                    }
                                    catch (e)
                                    {
                                        configuration = undefined;
                                    }

                                    if (config)
                                    {
                                        config.name = name;
                                        config.configuration = configuration;
                                    }
                                    else self.displays.push({ "serial": display, "name": name, "configuration": configuration });
                                }
                            }));
                        }
                    }
                }

                $.when.apply($, configurationAsync).then(function() 
                { 
                    configurationLoaded.resolve(); 
                });
            }
        }))

        $.when.apply($, async).then(function()
        {
            self.notifyAll();
        });
    }

    loadSavedConfigurations()
    {
        return $.ajax(
        {
            url: "utils.file.php",
            type: "POST",
            data:
            {
                action: "fileList",
                path: "logs/database/",
                pattern: "/.\.bfdbackup/"
            }
        });
    }

    persistData()
    {
        if (this.internal)
        {
            writeToDatabase([{section: "bfdpagedef", key: "0", value: JSON.stringify(this.configuration)}], "");
        }
        else writeToDatabase([{section: "bfdpagedef", key: this.serial, value: JSON.stringify(this.configuration)}], "PageDefs");
    }

    
    setDefaultAlignment(count, orientation)
    {
        switch (count)
        {
            case 1:
            {
                let field = this.fielddefinitions[0];
                field.x = 0;
                field.y = 0;
                field.h = 12;
                field.w = 12;

                break;
            }

            case 2:
            {
                let field = this.fielddefinitions[0];
                field.x = 0;
                field.y = 0;
                field.h = 6;
                field.w = 12;

                field = this.fielddefinitions[1];
                field.x = 0;
                field.y = 6;
                field.h = 6;
                field.w = 12;

                break;
            }

            case 3:
            {
                if (orientation == "portrait")
                {
                    let field = this.fielddefinitions[0];
                    field.x = 0;
                    field.y = 0;
                    field.h = 4;
                    field.w = 12;

                    field = this.fielddefinitions[1];
                    field.x = 0;
                    field.y = 4;
                    field.h = 4;
                    field.w = 12;

                    field = this.fielddefinitions[2];
                    field.x = 0;
                    field.y = 8;
                    field.h = 4;
                    field.w = 12;
                }
                else
                {
                    let field = this.fielddefinitions[0];
                    field.x = 0;
                    field.y = 0;
                    field.h = 8;
                    field.w = 12;

                    field = this.fielddefinitions[1];
                    field.x = 0;
                    field.y = 8;
                    field.h = 4;
                    field.w = 6;

                    field = this.fielddefinitions[2];
                    field.x = 6;
                    field.y = 8;
                    field.h = 4;
                    field.w = 6;
                }

                break;
            }

            case 4:
            {
                if (orientation == "portrait")
                {
                    let field = this.fielddefinitions[0];
                    field.x = 0;
                    field.y = 0;
                    field.h = 3;
                    field.w = 12;

                    field = this.fielddefinitions[1];
                    field.x = 0;
                    field.y = 3;
                    field.h = 3;
                    field.w = 12;

                    field = this.fielddefinitions[2];
                    field.x = 0;
                    field.y = 6;
                    field.h = 3;
                    field.w = 12;

                    field = this.fielddefinitions[3];
                    field.x = 0;
                    field.y = 9;
                    field.h = 3;
                    field.w = 12;
                }
                else
                {
                    let field = this.fielddefinitions[0];
                    field.x = 0;
                    field.y = 0;
                    field.h = 6;
                    field.w = 6;

                    field = this.fielddefinitions[1];
                    field.x = 6;
                    field.y = 0;
                    field.h = 6;
                    field.w = 6;

                    field = this.fielddefinitions[2];
                    field.x = 0;
                    field.y = 6;
                    field.h = 6;
                    field.w = 6;

                    field = this.fielddefinitions[3];
                    field.x = 6;
                    field.y = 6;
                    field.h = 6;
                    field.w = 6;
                }

                break;
            }

            case 5:
            {
                if (orientation == "portrait")
                {
                    let field = this.fielddefinitions[0];
                    field.x = 0;
                    field.y = 0;
                    field.h = 6;
                    field.w = 12;

                    field = this.fielddefinitions[1];
                    field.x = 0;
                    field.y = 6;
                    field.h = 3;
                    field.w = 6;

                    field = this.fielddefinitions[2];
                    field.x = 6;
                    field.y = 6;
                    field.h = 3;
                    field.w = 6;

                    field = this.fielddefinitions[3];
                    field.x = 0;
                    field.y = 9;
                    field.h = 3;
                    field.w = 6;

                    field = this.fielddefinitions[4];
                    field.x = 6;
                    field.y = 9;
                    field.h = 3;
                    field.w = 6;
                }
                else
                {
                    let field = this.fielddefinitions[0];
                    field.x = 0;
                    field.y = 0;
                    field.h = 4;
                    field.w = 12;

                    field = this.fielddefinitions[1];
                    field.x = 0;
                    field.y = 4;
                    field.h = 4;
                    field.w = 6;

                    field = this.fielddefinitions[2];
                    field.x = 6;
                    field.y = 4;
                    field.h = 4;
                    field.w = 6;

                    field = this.fielddefinitions[3];
                    field.x = 0;
                    field.y = 8;
                    field.h = 4;
                    field.w = 6;

                    field = this.fielddefinitions[4];
                    field.x = 6;
                    field.y = 8;
                    field.h = 4;
                    field.w = 6;
                }

                break;
            }

            case 6:
            {
                let field = this.fielddefinitions[0];
                field.x = 0;
                field.y = 0;
                field.h = 4;
                field.w = 6;

                field = this.fielddefinitions[1];
                field.x = 6;
                field.y = 0;
                field.h = 4;
                field.w = 6;

                field = this.fielddefinitions[2];
                field.x = 0;
                field.y = 4;
                field.h = 4;
                field.w = 6;

                field = this.fielddefinitions[3];
                field.x = 6;
                field.y = 4;
                field.h = 4;
                field.w = 6;

                field = this.fielddefinitions[4];
                field.x = 0;
                field.y = 8;
                field.h = 4;
                field.w = 6;

                field = this.fielddefinitions[5];
                field.x = 6;
                field.y = 8;
                field.h = 4;
                field.w = 6;

                break;
            }

            default:
                break;
        }
    }
}

class DisplayController extends BasicController
{
    constructor(model)
    {
        super(model);
    }

    changeBorderColour(e)
    {
        this.model.bordercolour = $(e.target).val();
        this.model.notifyAll();
    }

    changeFieldCount(e)
    {
        this.model.numberoffields = parseInt($(e.target).val());
        this.model.notifyAll();
    }

    changeFieldDefinition(fieldnumber, channel, dataColour, headingColour, backgroundColour, miscColour, type, min, max)
    {
        this.model.fielddefinitions[fieldnumber].channelnumber = parseInt(channel);
        this.model.fielddefinitions[fieldnumber].backgroundcolour = backgroundColour;
        this.model.fielddefinitions[fieldnumber].fontcolour = dataColour;
        this.model.fielddefinitions[fieldnumber].headingcolour = headingColour;
        this.model.fielddefinitions[fieldnumber].misccolour = miscColour;
        this.model.fielddefinitions[fieldnumber].type = type;
        this.model.fielddefinitions[fieldnumber].min = min;
        this.model.fielddefinitions[fieldnumber].max = max;

        this.model.notifyAll();
    }

    changeJSON(e)
    {
        try
        {
            this.model.configuration = JSON.parse($(e.target).val());
        } 
        catch (e)
        {
            $('#dialog-text').text(e.message);
            $('#dialog').dialog( {
                title: "Invalid JSON"
            }).dialog('open');

            return false;
        }
    }

    changePage(e)
    {
        this.model.activePage = parseInt($(e.target).val()) - 1;
        this.model.notifyAll();
    }

    changePageCount(e)
    {
        this.model.numberofpages = parseInt($(e.target).val());
        this.model.notifyAll();
    }

    changeRules(fieldnumber, rules)
    {
        this.model.fielddefinitions[fieldnumber].rules = rules;
    }

    changeSelectedDisplay(e)
    {
        this.model.serial = $(e.target).find('option:selected').val();

        this.model.notifyAll();
    }

    deleteConfiguration(e) 
    {
        let self = this;
        let file = $('#saved-configs option:selected').val();

        $.ajax(
        {
            url: '/helpers.php',
            type: 'POST',
            data:
            {
                'action': 'deleteConfiguration', 
                'filename': "logs/database/" + file,
            },
            success: function()
            {
                $('#saved-configs option[value="' + file + '"]').remove();
                $('#saved-configs').change();

                self.model.savedConfigurations.splice(self.model.savedConfigurations.findIndex(e => e == file), 1);
            }
        });
    }

    loadConfiguration() 
    {
        let file = $('#saved-configs option:selected').val();
        let self = this;

        $('#dialog-text').text("Display '" + self.model.serial + "' will be replaced with configuration '"
            + $('#saved-configs option:selected').text() + "'. This will not be sent to the display until"
            + " apply is clicked.");

        $('#dialog').dialog(
        {
            title: "Are you sure?",
            buttons:
            {
                Ok: function()
                {
                    $(this).dialog("close");

                    $.ajax(
                    {
                        url: "utils.file.php",
                        type: "POST",
                        data:
                        {
                            action: "getLocalFile",
                            path: "logs/database/" + file
                        },
                        success: function(response)
                        {
                            self.model.configuration = response;
                        }
                    }).done(function()
                    {
                        self.model.notifyAll();
                    });
                },
                Cancel: function()
                {
                    $(this).dialog("close");
                }
            },
            width: 300
        }).dialog("open");
    }

    resetConfiguration()
    {
        let self = this;

        $('#dialog-text').text('This will immediately overwrite the current configuration. Press OK to continue.');
        $('#dialog').dialog(
        {
            title: "Are you sure?",
            buttons:
            {
                Ok: function()
                {
                    $(this).dialog("close");

                    if (self.model.internal)
                    { 
                        writeSingle("BFDPageDef", "0", JSON.stringify(DEFAULT_CONFIG),
                            "").done(function() { location.reload(); });
                    }
                    else
                    {
                        writeSingle("BFDPageDef", self.model.serial, JSON.stringify(DEFAULT_CONFIG),
                            "PageDefs").done(function() { location.reload(); });
                    }
                },
                Cancel: function() 
                {
                    $(this).dialog("close");
                }
            },
            width: 300
        }).dialog("open");
    }

    saveConfiguration()
    {
        var configurationName = $('#save-config-name').val();

		// Check name is valid
		if ( !/^(?:\w+\s*)+$/.test(configurationName) ) 
        {
			$('#save-config-name').focus().select();
			$('#dialog-text').text('Configuration name can contain alphanumeric characters, underscores and spaces only.');
			$('#dialog').dialog(
            {
				title: "Invalid Name",
				buttons:
                {
					Ok: function()
                    {
						$(this).dialog("close");
					}
                },
                width: 300
            }).dialog("open");
            
			return false;
		}
        else
        {
            $.ajax(
            {
                url: '/utils.file.php',
                type: 'POST',
                data:
                {
                    'action': 'savebfd', 
                    'name': configurationName,
                    'config': JSON.stringify(this.model.configuration)
                },
                datatype: 'application/json',
                success: function(response)
                {
                    if (response.status == true)
                    {
                        $('#dialog-text').text('Configuration saved successfully.');
                        $('#dialog').dialog(
                        {
                            title:"Configuration Saved",
                            buttons:
                            {
                                Ok: function()
                                {
                                    $(this).dialog("close");
                                    location.reload();
                                }
                            },
                            width: 300
                        }).dialog("open");
                    }
                    else
                    {
                        $('#dialog-text').text('Error saving configuraton. ' + response.response);
                        $('#dialog').dialog(
                        {
                            title:"Configuration Error",
                            buttons:
                            {
                                Ok: function()
                                {
                                    $(this).dialog("close");
                                }
                            },
                            width: 300
                        }).dialog("open");
                    }
                }
            });
        }
    }

    updateGrid(event, items)
    {
        items.forEach(item => {
            const el = item.el;
            const index = [...el.parentElement.children].indexOf(el);

            this.model.fielddefinitions[index].x = item.x;
            this.model.fielddefinitions[index].y = item.y;
            this.model.fielddefinitions[index].w = item.w;
            this.model.fielddefinitions[index].h = item.h;
        });
    }
}

class DisplayView extends BasicView
{
    constructor(controller, JSON)
    {
        super(controller);

        this.JSON = JSON;
        this.menuString = null;

        this.controller.model.init();
        
        this.fieldDaemon.callback = function($container, valueReference)
        {
            let $cell = $container.closest('.grid-cell');
            if ($cell.hasClass('analogue-cell'))
            {
                const field = $container.attr('data-field');
                let pointer = $cell.children('.pointer')[0];

                // Heading fields rotate the dial not the pointer
                if (field == HEADING_FIELD || field == COG_M_FIELD || field == COG_T_FIELD)
                {
                    pointer = $cell.children('.dial')[0];
                }

                const datum = valueReference.valueString;
                if (datum)
                {
                    let target = 0;
                    
                    let value = parseFloat(datum.replace(/[^\d\.]*/g, ''));
                    if (!isNaN(value))
                    {
                        $(pointer).show();

                        if (field == HEADING_FIELD || field == COG_M_FIELD || field == COG_T_FIELD)
                        {
                            target = 360 - value;
                        }
                        else if (field == TWS_FIELD || field == AWS_FIELD)
                        {
                            // Magnified scale (x4.8) between 0 and 50kts
                            // 0 is at -120deg
                            target  =-120 + Math.min(value, 55) * 4.8;
                        }
                        else if (field == BSPD_FIELD)
                        {
                            // 0-20kts with 0 at -120deg
                            target = -120 + Math.min(value, 20) * 16;
                        }
                        else if (field == DEPTH_FIELD)
                        {
                            // Scaling is different between 0-10 and 10+ metres
                            if (value <= 10) target = -120 + value * 12;
                            else target = (Math.min(value, 110) - 10) * (120 / 90); 
                        }
                        else if (field == RUDDER_FIELD)
                        {
                            // Magnified scale (x3) between -40 and 40
                            // A little overshoot is allowed
                            target = 180 - (Math.min(Math.max(value, -41), 41) * 3);
                        }
                        else
                        { // AWA (default) is already a value between -180 and 180
                            target = value;
                        }

                        $(pointer).show();
                    }
                    else $(pointer).hide();

                    let previousTarget = parseInt($(pointer).attr('data-target') || 0);

                    // Normalise the target angle 0 - 360
                    target = (target + 360) % 360;
                    // Calculate the delta from the previous rotation target, normalised 0 - 360
                    let delta = (target - previousTarget) % 360;
                    // Then renormalise into the range +/- 180
                    // This ensures that we always rotate through the smallest angle (whereas by default going from 350 -> 5 will do a full 345 anti-clockwise)
                    // Note: If you span in a circle for ages this would eventually wrap, but that isn't realistic
                    delta = ((delta + 540) % 360) - 180;
                    target = previousTarget + delta;

                    $(pointer).attr('data-target', target);

                    // The BFD uses a cubic bezier curve to control the (smooth) transition from the current rotation to the target
                    // The webserver achieves pretty good behaviour with the default transition, so we just use that for simplicity
                    // But here is the cubic bezier curve if we ever want it again
                    // let previousP1Y = parseInt($(pointer).attr('data-p1y') || 0);
                    // let previousRotation = parseInt($(pointer).attr('data-rotation') || 0);
                    // // First extract the current rotation angle
                    // let rotation = 0;
                    // let transform = $(pointer).css('transform'); // This returns a rotation matrix
                    // if (transform && transform !== "none")
                    // {
                    //     const values = transform.split('(')[1].split(')')[0].split(',');
                    //     const a = values[0]; // cos(X)
                    //     const b = values[1]; // sin(X)

                    //     rotation = Math.round(Math.atan2(b, a) * (180/Math.PI));
                    //     rotation = (rotation + 360) % 360;
                    // }

                    // $(pointer).attr('data-rotation', rotation);

                    // delta = (rotation - previousTarget) % 360;
                    // delta = ((delta + 540) % 360) - 180;

                    // // How far did we get through the previous rotation
                    // let t = 0;
                    // if (previousTarget - previousRotation != 0) t = (rotation - previousRotation) / (previousTarget - previousRotation);
                    // t = Math.max(0, Math.min(1, Math.min(1, Math.abs(t))));

                    // // Determine the gradient of the bezier curve at t (see https://en.wikipedia.org/wiki/B%C3%A9zier_curve#Cubic_B%C3%A9zier_curves)
                    // // We can think of this as the 'pointer velocity'
                    // // We select P1 so that the velocity of the ongoing rotation is maintained
                    // const P0 = { x: 0, y: 0 };
                    // const P1 = { x: 0.3, y: previousP1Y };
                    // const P2 = { x: 0.8, y: 1 };
                    // const P3 = { x: 1, y: 1 };
                    // // B'(t) = 3(1-t)**2(P1 - P0) + 6(1-t)t(P2 - P1) + 3t**2(P3 - P2)
                    // let gradient = ((3 * ((1 - t) ** 2) * (P1.y - P0.y)) +
                    //                 (6 * (1 - t) * t * (P2.y - P1.y))) /
                    //                ((3 * ((1 - t) ** 2) * (P1.x - P0.x)) +
                    //                 (6 * (1 - t) * t * (P2.x - P1.x)) +
                    //                 (3 * (t ** 2) * (P3.x - P2.x)));

                    // P1.y = gradient * P1.x;

                    // $(pointer).attr('data-p1y', P1.y);

                    // $(pointer).css({
                    //     'transition': `transform 2s cubic-bezier(${P1.x},${P1.y},${P2.x},${P2.y})`,
                    //     'transform': `rotate(${target}deg)`
                    // });

                    $(pointer).css({
                        'transition': `transform 3s`,
                        'transform': `rotate(${target}deg)`
                    });
                }
                else $(pointer).hide();
            }
            else if ($cell.hasClass('bar-cell'))
            {
                const canvas = $cell.find('canvas');
                const field = $container.attr('data-field');

                const datum = valueReference.valueString.replace(/[^\d\.]*/g, '');
                if (datum)
                {
                    let chart = Chart.getChart(canvas.attr('id'));
                    chart.data.datasets[0].data[0] = datum;
                    chart.update();
                }  
            }
        }

        this.mousedownTarget = null;
    }

    addRule(e)
    {
        let self = this;

        if ($('#rule-container').children().length >= MAX_RULES)
        {
            //alert("Maximum number of rules is " + MAX_RULES + ".");
            return;
        }

        let rule = $.extend({}, DEFAULT_RULE, {

            channelnumber: parseInt($('#field-channel-select').val()),
            backgroundcolour: $('#background-colour-select').val(),
            headingcolour: $('#heading-colour-select').val(),
            fontcolour: $('#font-colour-select').val()
        });

        self.buildRule($('#rule-container'), rule);
    }

    bindEvents()
    {
        super.bindEvents();

        $('#display-select').on('change', this.controller.changeSelectedDisplay.bind(this.controller));

        $(document).delegate('textarea', 'keydown', this.onTextAreaKeydown);
        $('#json-area').on('change', this.controller.changeJSON.bind(this.controller));

        $('#fullscreen').on('click', (e) => { e.preventDefault(); if (!this.JSON) document.getElementById('grid-container').requestFullscreen() });

        $('#page-config-container').on('click', this.togglePageConfig.bind(this));
        $('#page-config-options').on('change', '#page-count-select', this.controller.changePageCount.bind(this.controller));
        $('#page-config-options').on('change', '#page-select', this.controller.changePage.bind(this.controller));
        $('#page-config-options').on('change', '#field-count-select', this.controller.changeFieldCount.bind(this.controller));
        $('#page-config-options').on('change', '#border-colour-picker', this.controller.changeBorderColour.bind(this.controller));

        // Data grid
        // 'click' events fire with e.target set to the target on release
        // This would allow a drag action to fire the cell configuration if the pointer ends the drag over the cell itself
        // To avoid that, capture the target on mousedown and compare to the target on mouseup
        $('#data-grid').on('mousedown', '.grid-cell', (e) => { this.mousedownTarget = e.target; });
        $('#data-grid').on('mouseup', '.grid-cell', (e) => { if (e.target.isSameNode(this.mousedownTarget)) this.openCellConfiguration(e); this.mousedownTarget = null; });

        // Field configuration
        $('#dialog').on('change', '#field-menu-select', this.changeFieldMenu.bind(this));
        $('#dialog').on('change', '.colour-picker', (e) => this.updateColourPicker($(e.currentTarget), $(e.target).val()));
        $('#dialog').on('click', '#rule-container .rule-close', function(e) { $(e.target).closest('.rule-wrapper').remove(); });

        $('#dialog').on('change', '#field-channel-select', this.changeFieldChannel.bind(this));
        $('#dialog').on('change', '#cell-type-select', this.changeFieldType.bind(this));

        $('#dialog').on('click', '.tab', this.changeTab.bind(this));
        $('#dialog').on('click', '#add-rule', this.addRule.bind(this));

        // Save/Load/Delete
        $('#delete-button').on('click', this.controller.deleteConfiguration.bind(this.controller));
        $('#save-button').on('click', this.controller.saveConfiguration.bind(this.controller));
        $('#load-button').on('click', this.controller.loadConfiguration.bind(this.controller));
        $('#reset-button').on('click', this.controller.resetConfiguration.bind(this.controller));

        screen.orientation.addEventListener("change", (e) => {
            this.controller.model.notifyAll();
        });
    }

    buildAnalogueCell(channel, name)
    {
        const field = 0x7000 + parseInt(channel);
        const fieldString = "0x" + ("0000" + field.toString(16)).slice(-4);

        let dial = "awa.svg";
        let pointer = "pointer.svg";
        if (field == RUDDER_FIELD) dial = "rudder.svg";
        else if (field == TWA_FIELD) dial = "twa.svg";
        else if (field == TWD_FIELD) dial = "twd.svg";
        else if (field == HEADING_FIELD || field == COG_M_FIELD || field === COG_T_FIELD)
        {
            dial = "compass.svg";
            pointer = "vessel.svg";
        } 
        else if (field == AWS_FIELD) dial = "aws.svg";
        else if (field == DEPTH_FIELD) dial = "depth.svg";
        else if (field == TWS_FIELD) dial = "tws.svg";
        else if (field == BSPD_FIELD) dial = "bsp.svg";

        return $(`
            <div class="grid-cell analogue-cell" data-channel="${channel}">
                <div class="field-container" data-field="${fieldString}" data-sender="dummy" data-instance="0">
                    OFF
                </div>
                <img class="dial" src="images/analogue/${dial}" alt="dial"/>
                <img class="pointer" src="images/analogue/${pointer}" alt="pointer"/>
                <div class="grid-cell-title">
                    ${name}
                </div>
            </div>
        `);      
    }

    buildBarCell(channel, name)
    {
        const field = 0x7000 + parseInt(channel);
        const fieldString = "0x" + ("0000" + field.toString(16)).slice(-4);

        return $(`
            <div class="grid-cell bar-cell" data-channel="${channel}">
                <div class="field-container" data-field="${fieldString}" data-sender="dummy" data-instance="0">
                    OFF
                </div>
                <div class="bar-field">
                    <canvas></canvas>
                </div>
                <div class="grid-cell-title">
                    ${name}
                </div>
            </div>
        `);
    }

    buildGridCell(channel, name)
    {
        return $(
                '<div class="grid-cell" data-channel="' + channel + '">'
            +       '<div class="field-container" data-field="0x' + ("0000" + (0x7000 + parseInt(channel)).toString(16)).slice(-4) + '" '
            +           'data-sender="dummy" data-instance="0">'
            +               'OFF'
            +       '</div>'
            +       '<div class="grid-cell-title">'
            +           name
            +       '</div>'
            +   '</div>'
        );
    }

    buildRule($container, rule)
    {
        $container.append(
                '<div class="rule-wrapper">'
            +       '<div class="rule-close"><i class="fa fa-times fa-sm"></i></div>'
            +       '<div class="rule-content">'
            +           '<fieldset id="dialog-field">'
            +               '<legend>'
            +                   '<h2>Condition</h2>'
            +               '</legend>'
            +               '<div class="flex-row l-row-wrap">'
            +                   '<div class="l-half-row l-row-wrap">'
            +                       '<div>Condition:</div>'
            +                       '<select class="condition-select">'
            +                           '<option value="gt">Greater (&gt;)</option>'
            +                           '<option value="eq">Equal (&#61;)</option>'
            +                           '<option value="lt">Less (&lt;)</option>'
            +                       '</select>'
            +                   '</div>'
            +                   '<div class="l-half-row l-row-wrap">'
            +                       '<div>Value:</div>'
            +                       '<input type=number class="condition-value" step=0.1>'
            +                   '</div>'
            +               '</div>'
            +           '</fieldset>'
            +           '<fieldset id="dialog-field-style">'
            +               '<legend>'
            +                   '<h2>Style</h2>'
            +               '</legend>'
            +               '<div class="flex-row l-row-wrap">'
            +                   '<div class="l-half-row l-row-wrap">'
            +                       '<div>Background:</div>'
            +                       '<div class="colour-picker" id="background-colour-picker">'
            +                           '<input type="color">'
            +                           '<select id="background-colour-select">'
            +                               COLOUR_OPTIONS
            +                           '</select>'
            +                       '</div>'
            +                   '</div>'
            +                   '<div class="l-half-row l-row-wrap">'
            +                       '<div>Heading:</div>'
            +                       '<div class="colour-picker" id="heading-colour-picker">'
            +                           '<input type="color">'
            +                           '<select id="heading-colour-select">'
            +                               COLOUR_OPTIONS
            +                           '</select>'
            +                       '</div>'
            +                   '</div>'
            +               '</div>'
            +               '<div class="flex-row l-row-wrap">'
            +                   '<div class="l-half-row l-row-wrap">'
            +                       '<div>Data:</div>'
            +                       '<div class="colour-picker" id="data-colour-picker">'
            +                           '<input type="color">'
            +                           '<select id="data-colour-select">'
            +                               COLOUR_OPTIONS
            +                           '</select>'
            +                       '</div>'
            +                   '</div>'
            +                   '<div class="l-half-row"></div>'
            +               '</div>'
            +           '</fieldset>'
            +       '</div>'
            +   '</div>'
        );

        let $rule = $container.children('.rule-wrapper').last();
        $rule.find('.condition-select').val(rule.condition);
        $rule.find('.condition-value').val(rule.value);
        this.updateColourPicker($rule.find('#data-colour-picker'), rule.fontcolour || '#ffffff');
        this.updateColourPicker($rule.find('#heading-colour-picker'), rule.headingcolour || '#ffffff');
        this.updateColourPicker($rule.find('#background-colour-picker'), rule.backgroundcolour || '#000000');
    }

    changeFieldChannel(e)
    {
        let channel = parseInt($(e.target).val());
        let selectedType = $('#cell-type-select').val();

        $('#cell-type-select').empty();
        $('#cell-type-select').append('<option value="text">Text</option> \
            <option value="bar">Bar</option>');

        let field = channel + 0x7000;
        if (analogueFields.includes(field))
        {
            $('#cell-type-select').append('<option value="analogue">Analogue</option>');
        }
        else if (selectedType === "analogue") selectedType = "text";

        $('#dialog').find('#cell-type-select option[value="' + selectedType + '"]').prop('selected', true);
        $('#dialog').find('#cell-type-select').change();
    }

    changeFieldMenu(e)
    {
        let model = this.controller.model;

        let currentMenu = model.channelMenu.getMenuForName($(e.currentTarget).val());
        this.populateChannels(model, currentMenu);
        // Force a change event on the channel select so that its value updates
        $('#dialog-text').find('#field-channel-select').change();
    }

    changeFieldType(e)
    {
        let selectedType = $(e.target).val();

        if (selectedType === "bar")
        {
            $('#min-max-container').show();
        }
        else
        {
            $('#min-max-container').hide();
        }
    }

    changeTab(e)
    {
        let $wrapper = $(e.target).closest('.tab-wrapper');
        $wrapper.find('.tab').removeClass("active");
        $(e.target).addClass("active");

        $wrapper.find('.tab-content').hide();
        $('#' + $(e.target).attr('tab')).show();
    }

    // Handle tabs in JSON textarea
    onTextAreaKeydown(e)
    {
        let keyCode = e.keyCode || e.which;

        if (keyCode === 9)
        {
            e.preventDefault();

            let start = this.selectionStart;
            let end = this.selectionEnd;
            // set textarea value to: text before caret + tab + text after caret
            $(this).val($(this).val().substring(0, start) + "\t" + $(this).val().substring(end));
            // put caret at right position again
            this.selectionEnd = start + 1;
        }
    }

    openCellConfiguration(e)
    {
        if ($(e.target).hasClass("ui-resizable-handle")) 
        {
            e.stopPropagation();
            return;
        }

        let self = this;
        let fieldnumber = $(e.currentTarget).index();

        let $dialogText = $(
                '<div class="tab-wrapper">'
            +       '<div class="tab-container">'
            +           '<button class="tab active" tab="tab-field">Field</button>'
            + (this.controller.model.internal ? '' : '<button class="tab" tab="tab-rules">Rules</button>') // Rules don't do anything for the internal display
            +       '</div>'
            +       '<div class="box box-bottom tab-content" id="tab-field">'
            +           '<fieldset id="dialog-field">'
            +               '<legend>'
            +                   '<h2>Data</h2>'
            +               '</legend>'
            +               '<div class="flex-row l-row-wrap">'
            +                   '<div class="l-half-row l-row-wrap">'
            +                       '<div>Menu:</div>'
            +                       '<select id="field-menu-select"></select>'
            +                   '</div>'
            +                   '<div class="l-half-row l-row-wrap">'
            +                       '<div>Channel:</div>'
            +                       '<select id="field-channel-select"></select>'
            +                   '</div>'
            +               '</div>'
            +           '</fieldset>'
            +           '<fieldset id="dialog-cell-type">'
            +               '<legend>'
            +                   '<h2>Format</h2>'
            +               '</legend>'
            +               '<div class="flex-row l-row-wrap">'
            +                   '<div class="l-half-row l-row-wrap">'
            +                   '</div>'
            +                   '<div class="l-half-row l-row-wrap">'
            +                       '<div>Type:</div>'
            +                       '<select id="cell-type-select">'
            +                           '<option value="text">Text</option>'
            +                           '<option value="bar">Bar</option>'
            +                       '</select>'
            +                   '</div>'
            +               '</div>'
            +               '<div class="l-row l-row-wrap" id="min-max-container">'
            +                   '<div class="l-half-row l-row-wrap">'
            +                       '<div>Min:</div>'
            +                       '<input type="number" id="min-input" placeholder="0">'
            +                   '</div>'
            +                   '<div class="l-half-row l-row-wrap">'
            +                       '<div>Max:</div>'
            +                       '<input type="number" id="max-input" placeholder="100">'
            +                   '</div>'
            +               '</div>'
            +           '</fieldset>'
            +           '<fieldset id="dialog-field-style">'
            +               '<legend>'
            +                   '<h2>Style</h2>'
            +               '</legend>'
            +               '<div class="flex-row l-row-wrap">'
            +                   '<div class="l-half-row l-row-wrap">'
            +                       '<div>Background:</div>'
            +                       '<div class="colour-picker" id="background-colour-picker">'
            +                           '<input type="color">'
            +                           '<select id="background-colour-select">'
            +                               COLOUR_OPTIONS
            +                           '</select>'
            +                       '</div>'
            +                   '</div>'
            +                   '<div class="l-half-row l-row-wrap">'
            +                       '<div>Heading:</div>'
            +                       '<div class="colour-picker" id="heading-colour-picker">'
            +                           '<input type="color">'
            +                           '<select id="heading-colour-select">'
            +                               COLOUR_OPTIONS
            +                           '</select>'
            +                       '</div>'
            +                   '</div>'
            +               '</div>'
            +               '<div class="flex-row l-row-wrap">'
            +                   '<div class="l-half-row l-row-wrap">'
            +                       '<div>Data:</div>'
            +                       '<div class="colour-picker" id="data-colour-picker">'
            +                           '<input type="color">'
            +                           '<select id="data-colour-select">'
            +                               COLOUR_OPTIONS
            +                           '</select>'
            +                       '</div>'
            +                   '</div>'
            +                   '<div class="l-half-row l-row-wrap" id="misc-colour-container">'
            +                       '<div>Bar Border:</div>'
            +                       '<div class="colour-picker" id="misc-colour-picker">'
            +                           '<input type="color">'
            +                           '<select id="misc-colour-select">'
            +                               COLOUR_OPTIONS
            +                           '</select>'
            +                       '</div>'
            +                   '</div>'
            +               '</div>'
            +           '</fieldset>'
            +       '</div>'
            +       '<div class="tab-content" id="tab-rules">'
            +           '<div class="flex-row l-row-nowrap">'
            +               '<div class="box flex-row">'
            +                   '<div class="tooltip-wrapper">'
            +                       '<h2>Create Rule:</h2>'
            +                       '<div class="tooltip"><p>'
            +                           'Rules change field style when the data meets the specified condition. '
            +                           'If rules overlap then the last defined rule will apply.'
            +                       '</p></div>'
            +                   '</div>'
            +               '</div>'
            +           '<a class="button button-medium button-white" id="add-rule"><i class="fa fa-plus"></i></a>'
            +           '</div>'
            +           '<div id="rule-container">'
            +           '</div>'
            +       '</div>'
            +   '</div>'
        );
        // Using $dialogText.prop('outerHTML') only returns the first element
        // Instead append to a wrapper and use .html()
        $('#dialog-text').html($('<div>').append($dialogText).html());

        // Set the colour pickers, must be called after elements are added to DOM
        let model = this.controller.model;
        this.updateColourPicker($('#dialog-text').find('#data-colour-picker'), model.fielddefinitions[fieldnumber].fontcolour || '#ffffff');
        this.updateColourPicker($('#dialog-text').find('#heading-colour-picker'), model.fielddefinitions[fieldnumber].headingcolour || '#ffffff');
        this.updateColourPicker($('#dialog-text').find('#background-colour-picker'), model.fielddefinitions[fieldnumber].backgroundcolour || '#000000');
        this.updateColourPicker($('#dialog-text').find('#misc-colour-picker'), model.fielddefinitions[fieldnumber].misccolour || '#ffffff');

        // Load the menu options
        this.populateMenus(model.channelMenu.getMenus());

        // Set the selected menu to the first menu which includes the current channel
        // currentTarget is always the .grid-cell (whereas target could be the .grid-cell or the .l-data-field)
        let channel = parseInt($(e.currentTarget).attr('data-channel'));
        let currentMenu = model.channelMenu.getMenuForChannel(channel);
        $('#dialog-text').find('#field-menu-select option[value="' + currentMenu.name + '"]').prop('selected', true);

        // Populate the channels for the current menu
        this.populateChannels(model, currentMenu);
        $('#dialog-text').find('#field-channel-select option[value="' + channel + '"]').prop('selected', true);

        // Conditionally enable format selection
        let field = channel + 0x7000;
        if (analogueFields.includes(field))
        {
            $('#cell-type-select').append('<option value="analogue">Analogue</option>');
        }

        const type = model.fielddefinitions[fieldnumber].type || "text";
        $('#dialog-cell-type').find('#cell-type-select option[value="' + type + '"]').prop('selected', true);

        // Set and (conditionally) hide min/max values
        if (type !== "bar")
        {
            $('#dialog-cell-type').find('#min-max-container').hide();
            $('#dialog-text').find('#misc-colour-container').css('visibility', 'hidden');
        }
        $('#dialog-cell-type').find('#min-input').val(isNaN(model.fielddefinitions[fieldnumber].min) ? 0 : model.fielddefinitions[fieldnumber].min);
        $('#dialog-cell-type').find('#max-input').val(isNaN(model.fielddefinitions[fieldnumber].max) ? 100 : model.fielddefinitions[fieldnumber].max);

        $('#tab-field').show();

        // Populate the field rules
        if (model.fielddefinitions[fieldnumber].hasOwnProperty('rules'))
        {
            model.fielddefinitions[fieldnumber].rules.forEach(function(rule, index)
            {
                self.buildRule($('#dialog-text').find('#rule-container'), rule);
            });
        }

        $('#dialog').dialog({
            title: 'Field Configuration',
            buttons:
            {
                Ok: function()
                {
                    let channel = parseInt($('#dialog-text').find('#field-channel-select').val());
                    let dataColour = $('#dialog-text').find('#data-colour-select').val();
                    let headingColour = $('#dialog-text').find('#heading-colour-select').val();
                    let backgroundColour = $('#dialog-text').find('#background-colour-select').val();
                    let miscColour = $('#dialog-text').find('#misc-colour-select').val();
                    let type = $('#dialog-text').find('#cell-type-select').val();
                    let min = $('#dialog-text').find('#min-input').val();
                    let max = $('#dialog-text').find('#max-input').val();

                    self.controller.changeFieldDefinition(fieldnumber, channel, dataColour, headingColour, backgroundColour, miscColour, type, min, max);
                    
                    // Update rules
                    let rules = [];
                    $.each($('#dialog-text').find('#rule-container').children('.rule-wrapper'), function(index, element)
                    {
                        rules.push($.extend({}, DEFAULT_RULE,
                            {
                                 // For now, just set the rule channel to the data channel
                                //channelnumber: parseInt(element.find('.rule-channel').val()),
                                channelnumber: channel,
                                condition: $(element).find('.condition-select').val(),
                                value: parseFloat($(element).find('.condition-value').val()),
                                backgroundcolour: $(element).find('#background-colour-select').val(),
                                headingcolour: $(element).find('#heading-colour-select').val(),
                                fontcolour: $(element).find('#data-colour-select').val()
                            })
                        )
                    });
                    self.controller.changeRules(fieldnumber, rules);

                    $(this).dialog('close');
                },
                Cancel: function() { $(this).dialog('close'); }
            },
            modal: true,
            width: Math.min(800, Math.max($(window).width() * 0.6, 300)),
        }).dialog('open');

        document.activeElement.blur();
    }

    populateChannels(model, menu, exclude = [])
    {
        let wrapper = $('#dialog-text').find('#field-channel-select');
        wrapper.empty();

        if (!menu || !menu.channels || menu.channels.length == 0) return;

        let temp = $('<div>');
        
        menu.channels.forEach(function(channel)
        {
            if (!exclude.includes(channel))
            {
                let menuChannel = model.channelMenu.getChannel(channel);

                temp.append('<option value="' + menuChannel.channel + '">' + menuChannel.name + '</option>');
            }
        });

        wrapper.append(temp.html());
    }

    populateMenus(menus)
    {
        let wrapper = $('#dialog-text').find('#field-menu-select');
        wrapper.empty();

        if (this.menuString == null)
        {
            let temp = $('<div>');

            for (const menu of menus)
            {
                if (menu && menu.channels && menu.channels.length > 0)
                {
                    temp.append('<option value="' + menu.name + '">' + menu.name + '</option>');
                }
            }

            this.menuString = temp.html();
        }

        wrapper.append(this.menuString);
    }

    replaceJSON(key, value)
    {
        if (key == "serialnumber" || key == "demomode")
        {
            return undefined;
        }

        return value;
    }

    togglePageConfig(e)
    {
        if ($('#fullscreen').is(e.target))
        {
            e.stopPropagation();
            return;
        }

        if ($('#page-config-options').is(':hidden'))
        {
            $('#page-config-container').css('border-bottom-right-radius', '0');
            $('#page-config-container').css('border-bottom-left-radius', '0');
        }

        $('#page-config-options').slideToggle('500', function()
        {
            if ($('#page-config-options').is(':hidden'))
            {
                $('#page-config-container').css('border-bottom-right-radius', '4px');
                $('#page-config-container').css('border-bottom-left-radius', '4px');
            }

            $('#page-config-container i').toggleClass('fa-angle-down fa-angle-up');
        });
    }

    update(model)
    {
        console.log(model);

        if (!model.internal)
        {
            $('#display-select-container').show();
            $('#page-config-container').removeClass('top-wrapper');
            $('#page-config-container').addClass('box-bottom');
        }

        if (!model.configuration)
        {
            model.configuration = DEFAULT_CONFIG;

            // Write error
            $('#dialog-text').text(
                'Loaded configuration not valid, reverting to default.'
            );

            $('#dialog').dialog({
                title: 'Configuration Invalid',
                buttons: {
                    Ok: function() { $(this).dialog('close'); }
                }
            }
            ).dialog('open');
        }

        // On systems that predate the introduction of GridStack, they will have no position properties
        if (model.fielddefinitions[0].x === undefined) model.setDefaultAlignment(model.numberoffields, model.orientation);

        if (this.JSON)
        {
            $('#grid-container').hide();

            $('#json-area').attr("data-serial", model.serial);
            $('#json-area').val(JSON.stringify(model.configuration, this.replaceJSON, 4));
            $('#json-area').css('display', 'flex');
        }
        else
        {
            $('#data-grid').attr('class', 'grid grid-fields-' + model.numberoffields);
            $('#data-grid').attr('class', 'grid grid-stack grid-fields-' + model.numberoffields);

            // Allow setting the grid background colour in case someone wants to leave gaps?
            // Don't expose a configuration option for it because I think it is unusual
            $('#grid-container').css('background-color', model.configuration.backgroundcolour || "#000000");

            const options = {
                animate: false,
                cellHeight: "8.32vh",
                cellWidth: "8.32vw",
                float: true,
                row: 12
            }
            let grid = GridStack.init(options);
            grid.on('change', this.controller.updateGrid.bind(this.controller));

            // Set the grid orientation
            if (model.orientation === 'portrait')
            {
                $('#data-grid').addClass('grid-portrait');
            }
            else if (model.orientation === 'landscape')
            {
                $('#data-grid').addClass('grid-landscape');
            }

            // Populate the grid
            grid.removeAll();

            for (let i = 0; i < model.numberoffields && i < model.fielddefinitions.length; i++)
            {
                let field = model.fielddefinitions[i];

                let channel = model.channelMenu.getChannel(field.channelnumber);

                let $cell;
                if (field.type == "analogue")
                {
                    $cell = this.buildAnalogueCell(field.channelnumber, channel.name);
                }
                else if (field.type == "bar")
                {
                    $cell = this.buildBarCell(field.channelnumber, channel.name);

                    const canvas = $cell.find('canvas')[0];
                    $(canvas).attr('id', "bar" + i);

                    canvas.style.border = `2px solid ${field.misccolour}`

                    const ctx = canvas.getContext('2d');
                    const chart = new Chart(ctx, chartOptionsBar(field.fontcolour,
                        (field.w >= field.h),
                        isNaN(field.min) ? 0 : field.min,
                        isNaN(field.max) ? 100 : field.max));
                }
                else
                {
                    $cell = this.buildGridCell(field.channelnumber, channel.name);
                }

                $cell.find('.field-container').prop('id', 'field-' + i);
                $cell.css('color', field.fontcolour || '#ffffff');
                $cell.find('.grid-cell-title').css('color', field.headingcolour || '#ffffff');
                $cell.css('background', field.backgroundcolour || '#000000');
                $cell.css('border-color', model.bordercolour || '#ffffff');

                grid.makeWidget($cell[0], {x: field.x, y: field.y, w: field.w, h: field.h});
            }

            // Remove outer borders
            const items = grid.getGridItems();
                items.forEach(item => {
                    if (item.gridstackNode.x == 0) $(item).css('border-left', 'none');
                    if (item.gridstackNode.y == 0) $(item).css('border-top', 'none');
                    if (item.gridstackNode.x + item.gridstackNode.w == 12) $(item).css('border-right', 'none');
                    if (item.gridstackNode.y + item.gridstackNode.h == 12) $(item).css('border-bottom', 'none');
                });
        }

        $('#display-select').empty();
        model.displays.forEach(function(display)
        {
            if (display.detected === true)
                $('#display-select').append('<option value="' + display.serial + '">' + display.name + '</option>');
        });

        if ($('#display-select option[value="' + model.serial + '"]').length > 0)
        {
            $('#display-select option[value="' + model.serial + '"]').prop('selected', true);
            $('#display-select').css('box-shadow', 'initial');
        }
        else
        {
            $('#display-select').append('<option value="" selected>Unknown Display</option>');
            $('#display-select').css('box-shadow', '0 0 3px #CC0000');
        }

        // Populate the page configuration options
        $('#page-count-select').find('option[value="' + model.numberofpages + '"]').prop('selected', true);

        $('#page-select').empty();
        for (let i = 1; i <= model.numberofpages; i++)
        {
            $('#page-select').append('<option value="' + i + '">' + i + '</option>');
        }
        $('#page-select').find('option[value="' + (model.activePage + 1) + '"]').prop('selected', true);

        $('#field-count-select').find('option[value="' + model.numberoffields + '"]').prop('selected', true);
        this.updateColourPicker($('#border-colour-picker'), model.bordercolour || '#ffffff');

        // Populate saved configurations
        $('#saved-configs').empty();
        for (let i = 0; i < model.savedConfigurations.length; i++) 
        {
            $('#saved-configs').append(
                '<option value="' + model.savedConfigurations[i] + '">' + model.savedConfigurations[i].split('.')[0] + '</option>'
            );
        }
    }

    updateColourPicker($picker, colour)
    {
        let $input = $picker.children('input');
        let $select = $picker.children('select');

        if ($select.children('option[value="' + colour + '"]').length === 0)
        {
            // This must be a custom colour
            // Remove then re-add a custom option, since hidden options don't seem to update properly
            $select.children('option[name="custom"]').remove();
            $select.append('<option name="custom" value="' + colour + '" hidden>' + colour.toUpperCase() + '</option>');
        }

        $input.val(colour);
        $select.val(colour);
    }
}
