client.js

/**
 *
 * @class A Client service that provides widgets that can be embedded into views.
 *
 * @copyright (c) 2021 TLF Research Ltd.
 *
 */
function Client() {
  const self = this;

  this.NULL = "__null__";

  this.Globals = {};

  this.widgets = {
    static: {},
    disposable: {},
  };

  this.filters = {
    suppressAllFilterEvents: false,
    loaded: false,
    ready: {},
    hooks: [],
  };

  // Set chart library culture to UK
  kendo.culture("en-GB");

  // Handle browser history
  window.onpopstate = function (e) {
    if (e.state) {
      self.switchToView(e.state.view, false);
    }
  };

  /**
   * Defines a standard Kendo dataSource that can be used by most widgets
   *
   * @function widgetSimpleDataSource
   * @memberof Client
   *
   */
  this.widgetSimpleDataSource = {
    transport: {
      read: {
        dataType: "json",
        xhrFields: {
          withCredentials: true,
        },
      },
    },
    schema: {
      data: "rows",
      parse: server.parseResponse,
    },
  };

  /**
   * Sets up the standard client environment.
   *
   * @param {Function} onReady method to be invoked when the middleware layer has initialised.
   *
   * @function init
   * @memberof Client
   */
  this.init = function (onReady) {

    // Add required globals
    client.Globals = {
      project: {},
      now: new Date(),
      lastDataPointDate: null,
      user: null,
      data: {},
    };

    // Fetch base data from the API
    $.when(server.getInfo(Server.NO_CACHE), server.getUser(Server.NO_CACHE)).done(function (
      serverInfo,
      userInfo
    ) {
      const user = userInfo[0];
      self.Globals.user = user;

      const userActions = {
        // Logout action is always allowed
        logout: {
          label: "Logout",
          onClick: client.logout,
        },
      };

      // Add user actions based on their roles
      if (user.roles) {
        if (user.roles.includes("manage_users")) {
          userActions["users"] = {
            label: "Users",
            onClick: client.manageUsers,
          };
        }
        if (user.roles.includes("manage_realm")) {
          userActions["users"] = {
            label: "Realm",
            onClick: client.manageRealm,
          };
        }
        if (user.roles.includes("api_invoke_debug")) {
          userActions["users"] = {
            label: "Debug Mode",
            onClick: client.setDebug,
          };
        }
      }

      // Create a user menu widget
      client.addUser("user", {
        user: user,
        actions: userActions,
      });

      user.roles = user.roles || [];
      user.defaultView = getDefaultView();

      server.Globals = serverInfo[0];

      // Get and cache metadata about the first relevant project in the User's profile
      const applicableProjects = client.getApplicableProjects(user.projects);

      // Warn user if there is no project data to display
      if (applicableProjects.every(p => !p)) {
        console.warn("No applicable project data to display");
        return;
      }

      client.Globals.projects = [...applicableProjects];

      // Build a map by id of the project's index in the applicableProjects array
      const projectMap = {};
      applicableProjects.forEach((p, idx) => {
        if (p) projectMap[p.id] = idx
      });

      // Strip all null projects from the list before we try to fetch them
      const validProjects = applicableProjects.filter(p => p);

      server
        .getProjects(Convert.extractFromArray(validProjects, "id"))
        .done(function () {
          // If there's only one request then the arguments array consists solely of 3 elements containing the XHR response.
          // But, if there's more than one request, the arguments array is an array of tuples with each containing the appropriate XHR response.
          // This code recognises the 1-request scenario by looking for a single XHR request signature.
          // If found, it normalizes the result by copying it into a single element array.
          const results = arguments[1] === "success" ? [arguments] : arguments;

          for (let a = 0; a < results.length; a++) {
            const proj = results[a][0];

            const dateVars = client.getProjectVarsByCategory(proj, "response_date", "name");
            if (dateVars.length !== 1) {
              console.error("There must be exactly ONE variable in the " + proj.name + " project allocated to the 'response_date' category");
            } else {
              proj.dateVar = dateVars[0];
            }

            const indexVars = client.getProjectVarsByCategory(proj, "index", "name");
            if (indexVars.length === 1) {
              proj.indexVar = indexVars[0];
            }

            // Replace the project object from Keycloak in client.Globals.projects with the full project from WR3
            client.Globals.projects[projectMap[proj.id]] = proj;
          }

          if (typeof onReady === "function") {
            onReady.call(client);
          }

          // Check if we need to inject support for a Tour event
          if (config.TOUR && !client.getCookie("suppress-tour")) {
            client.injectCSS("/css/tour.css", () => {
              new Tour(config.TOUR);
            });
          }

        })
        .fail(function (jqXHR) {
          if (jqXHR.status === 401) {
            $("body").addClass("not-logged-in");
          }
        });
    });
  };

  /**
   * Function to get a cookie by name.
   *
   * @param {string} name Name of the cookie value to retrieve.
   *
   * @function getCookie
   * @memberof Client
   */
  this.getCookie = function (name) {
    const cookies = document.cookie.split(';').map(cookie => cookie.trim());
    for (const cookie of cookies) {
      const [cookieName, cookieValue] = cookie.split('=');
      if (cookieName === name) {
        return decodeURIComponent(cookieValue);
      }
    }
    return null;
  }

  /**
   * Function to set a cookie by name.
   *
   * @param {string} name Name of the cookie value to set.
   * @param {string} value New value.
   *
   * @function setCookie
   * @memberof Client
   */
  this.setCookie = function (name, value, daysToExpire, path = '/') {
    if (typeof name !== 'string' || typeof value !== 'string') {
      throw new Error('Cookie name and value must be strings.');
    }

    if (daysToExpire && typeof daysToExpire !== 'number') {
      throw new Error('Expiration days must be a number.');
    }

    const expires = daysToExpire ? `; expires=${new Date(Date.now() + daysToExpire * 86400000).toUTCString()}` : '';
    document.cookie = `${name}=${encodeURIComponent(value)}; path=${path}${expires}`;
  }

  /**
   * Function to delete a cookie by name.
   *
   * @param {string} name Name of the cookie to delete.
   *
   * @function deleteCookie
   * @memberof Client
   */
  this.deleteCookie = function (name) {
    const expirationDate = new Date(0).toUTCString();
    document.cookie = `${name}=; expires=${expirationDate}`;
  }

  /**
   * Adds additional Global variables
   *
   * @param {object} glob Additional variables to be added to the client.Globals object.
   */
  this.addGlobals = function (glob) {
    $.extend(this.Globals, glob);
  };

  /**
   * Adds any user claims that start with 'filter_' to the given filters object and returns a new filter object including the new filters.
   * The original filters are not modified.
   *
   * @param {object} filters Original filters object. They are not modified.
   */
  this.addSoftFilters = function (filters) {
    if (!filters) return filters;

    const newFilters = { ...filters };
    const claims = client.Globals.user.claims;
    for (let claimName in claims) {
      if (claimName.startsWith('filter_')) {
        const varName = claimName.slice(7);
        newFilters[varName] = `{${varName}} IN("${claims[claimName].join('","')}")`;
      }
    }
    return newFilters;
  };

  /**
   * Returns a new set of rows that have been filtered by any user claims that start with 'filter_'.
   *
   * @param {array} rows Original rows object. It is not modified.
   */
  this.softFilterRows = function (rows) {
    if (!rows) return rows;

    const claims = client.Globals.user.claims;

    const filter = {};
    for (let claimName in claims) {
      if (claimName.startsWith('filter_')) {
        const varName = claimName.slice(7);
        filter[varName] = claims[claimName];
      }
    }

    return rows.filter(row => {
      return Object.keys(filter).every(key => {

        const rowValue = row[key];
        const filterValue = filter[key];

        // Always match a rolled-up result
        if (row[`_aggr_${key}`]) {
          return true;
        }

        // If the filter value is an array, check whether any of its elements match the row value
        if (Array.isArray(filterValue)) {
          return filterValue.includes(rowValue);
        }

        // Otherwise, compare the filter value directly to the row value
        return rowValue === filterValue;
      });
    });
  };

  /**
   * Logs the user out.
   */
  this.logout = function () {
    document.body.dispatchEvent(new Event("beforeLogout"));
    window.location.href =
      window.location.origin +
      "/_callback?logout=" +
      encodeURIComponent(window.location.origin + "/status/logged_out.html");
  };

  /**
   * Loads a view that provides some basic user management features.
   */
  this.manageUsers = function () {
    self.switchToView("manage-users", false);
  };

  /**
   * Redirects to the Keycloak console to allow the realm to be managed via the IDP.
   */
  this.manageRealm = function () {
    window.location.href = config.IDP + "/auth/admin/" + config.REALM + "/console/";
  };

  /**
   * Sets debug mode on
   */
  this.setDebug = function () {
    const params = new URL(document.location).searchParams;
    params.append("debug", true);
    window.location.href = window.location.origin + "?" + params.toString();
  };

  /**
   * Allows the user to request a password reset.
   */
  this.resetPassword = function () {
    window.location.href = window.location.origin + "/identity/person/passwordReminder.htm";
  };

  /**
   * Returns a collection of Project Variables properties for variables that are in *any* of the given categories.
   * If props is a string a sinple array of scalar values is returned, otherwise it's an array of objects.
   *
   * @param {object} project Project to inspect
   * @param {array|string} categories Categories to inspect (null = get all categories)
   * @param {array|string} props Variable properties to return (the id property is always returned)
   *
   * @returns {array} Project variables in the given categories.
   */
  this.getProjectVarsByCategory = function (project, categories, props) {
    if (!project) {
      console.error("Missing project");
      return;
    }

    // Short-circuit if the project has no variables
    if (!project.vars) {
      return [];
    }

    const wasPropsArray = Array.isArray(props);

    props = this.normalizeToArray(props);
    categories = this.normalizeToArray(categories);
    categories = categories.map(function (c) {
      return c.toLowerCase();
    });

    // Add the ID property if we don't have it as we need it to sort
    if (!props.includes("id")) {
      props.push("id");
    }

    const v = Convert.extractFromMap(project.vars, props, function (_key, v) {
      return (
        categories.length === 0 ||
        (v.categories &&
          v.categories.some(function (vc) {
            return categories.includes(vc);
          }))
      );
    }).sort(Sort.Asc("id"));

    return wasPropsArray ? v : Convert.extractFromArray(v, props[0]);
  };

  /**
   * Cleans up the current view
   */
  this.clean = function () {
    // Remove old event handlers
    $(window).off("resize scroll click input filterChanged exportFilterChanged widgetChanged");

    // Clean up disposable page widgets
    for (let name in this.widgets.disposable) {
      const wgt = this.widgets.disposable[name];
      if (wgt.control) {
        // Call any given destructor
        if (wgt.control.options && typeof wgt.control.options.destroy === "function") {
          wgt.control.options.destroy();
        }

        // Call the underlying control's destroy method if there is one)
        if (typeof wgt.destroy === "function") {
          wgt.control.destroy();
        }
        wgt.control = null;
      }
    }
    this.widgets.disposable = {};

    // Remove all filter hooks
    this.filters.hooks = [];
  };

  /**
   * Returns the maximum absolute value of all the given array values, ignoring any sign.
   *
   * @param {array} a
   *
   * @return {Number}
   */
  this.getAbsMax = function (a) {
    return a.reduce(function (max, val) {
      val = Math.abs(val);
      if (val > max) {
        max = val;
      }
      return max;
    }, Number.NEGATIVE_INFINITY);
  };

  /**
   * Returns the quantised value of the given number.
   *
   * @param {Number} n
   * @param {Number} quantum
   *
   * @return {Number|NaN}
   */
  this.quantise = function (n, quantum) {
    return Math.ceil(n / quantum) * quantum;
  };

  /**
   * Returns the list of Projects that the User is allowed to see, and that are appropriate to this particular portal.
   * It also fetches all the Project variables.
   *
   * @param {array} projects - an array of the user's Projects
   * 
   * @return {array} Projects that are relevant to the current portal and user
   */
  this.getApplicableProjects = function (projects) {

    if (!Array.isArray(projects)) {
      return [];
    }

    return projects.reduce(function (acc, p) {
      if (p.client === config.CUSTOMER && config.PROJECTS.includes(p.name)) {
        acc.push(p);
      }
      return acc;
    }, []);
  };

  /**
   * Returns true if the current user is an Admin.
   */
  this.isAdmin = function () {
    if (!this.Globals.user) {
      return false;
    }

    return (
      this.Globals.user.roles.filter(function (r) {
        return r === "manage_users" || r === "manage_realm";
      }).length > 0
    );
  };

  /**
   * Loads the given view into the Page.
   *
   * @param {string} name Name of the view.
   * @param {string} [title] Title of the view (defaults to the name if not supplied)
   * @param {boolean} [addToHistory] When true (default) add this view to the browser's history
   */
  this.switchToView = function (name, title, addToHistory) {
    // Make sure that the page filters ready events are fired, even if there
    // are no page filters that drive dataBound events.
    checkAllFiltersLoaded("__switchToView");

    if (!this.allowedToSee(name)) {
      window.alert("No such report");

      return;
    }

    addToHistory = addToHistory !== false;

    this.clean();

    $.get({
      url: "/views/" + name + ".html",
      xhrFields: {
        withCredentials: true,
      },
      success: function (html) {
        const docTitle = (title || name) + " - " + config.PORTAL_NAME;
        $(document).attr("title", docTitle);
        $("#widgets").attr("class", "view-" + name);

        const view = document.getElementById("widgets");

        // Inject the views' HTML
        view.innerHTML = html;

        // Inject all the widget scripts
        const widgets = view.querySelectorAll("*[data-widget]");
        for (let c = 0; c < widgets.length; c++) {
          const data = widgets[c].dataset;
          self.injectScript(data.widget, "/js/widgets/" + data.widget + ".js");
        }

        // Inject all the Panels
        const panels = view.querySelectorAll("*[data-panel]");
        for (let c = 0; c < panels.length; c++) {
          const panel = new Panel();
          panel.attach(panels[c]);
        }

        // Add to browser history so we can navigate between views
        if (addToHistory) {
          window.history.pushState({
            view: name,
          },
            "",
            "?view=" + name
          );
        }

        // Scroll view contents immediately back to top left
        window.scrollTo({
          left: 0,
          top: 0,
          behavior: "auto",
        });

        $(window).triggerHandler("viewChanged", name);
      },
    }).fail(function (e) {
      if (e.status === 401) {
        window.location.replace(window.location.origin + "?view=" + name);
        return;
      }
      console.error("Cannot load the " + name + " view");
    });
  };

  /**
   * Returns a Kendo change handler that snaps a date picker's value to the start or end of whichever period its
   * depth selects - a month at depth "year", a year at depth "decade" or coarser. Returns null for day-granularity
   * pickers (depth "month", Kendo's default) as those select a specific date that must be left alone.
   *
   * This keeps the "to" end of a date range inclusive of the whole period the user picked, so that a filter ending
   * "23:59:59" covers all of it rather than just its first day. It also works around a bug in the version of Kendo
   * we're using whereby a datepicker in these modes returns a date whose day-of-month value is today's date if it's
   * *ever* been used to pick a date in the current year.
   *
   * @param {string} [depth] Kendo depth of the picker the handler is for
   * @param {string} rangeEnd Which end of the range the picker represents - either 'start' or 'end'
   * @param {date} [max] Picker's maximum permitted value, if any
   *
   * @returns {function|null} Change handler, or null if the picker's values need no snapping
   */
  function _getPeriodSnap(depth, rangeEnd, max) {
    // A day-granularity picker selects a real date, so there's no period to snap it to
    if (!depth || depth === "month") {
      return null;
    }

    const isEnd = rangeEnd === "end";

    return function () {
      const value = this.value();

      if (!value) {
        return;
      }

      let snapped;

      if (depth === "year") {
        snapped = isEnd ? DateTime.getLastOfMonth(value) : DateTime.getFirstOfMonth(value);
      } else {
        snapped = isEnd ? DateTime.getLastOfYear(value) : DateTime.getFirstOfYear(value);
      }

      // Kendo rejects values outside the picker's permitted range, so never snap past its max
      if (max && snapped > max) {
        snapped = max;
      }

      this.value(snapped);
    };
  }

  /**
   * Creates a new date range widget.
   * Expects two DOM input elements with an id that matches the given name plus '-from' and '-to' to use as the base for the widget.
   *
   * Pickers that select a whole period rather than a specific date (i.e. any depth coarser than "month") have their
   * values snapped to the start of that period for the 'from' picker and to the end of it for the 'to' picker, so
   * that the generated BETWEEN filter covers the whole of the selected 'to' period. Any change handler passed in
   * <code>options</code> runs first and cannot leave a half-open range behind - @see {@link _getPeriodSnap}.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {string} varName Name of the survey variable associated with this widget.
   * @param {object} [options] Custom widget options
   * @param {boolean} [isPageFilter] True if this widget is a page filter
   * @param {function} [onChange] Method to call when the widget value changes
   * @param {boolean} [suppressFilterEvents] When true updates to this widget will not generate filterChanged events.
   *
   * @see {@link addDatePicker} for options.
   *
   * @returns {object}
   */
  this.addDateRange = function (name, varName, options, isPageFilter, onChange, suppressFilterEvents) {
    const widgetType = isPageFilter ? "static" : "disposable";
    const control = {
      from: (this.widgets[widgetType][name + "-from"] = this.addDatePicker(
        name + "-from",
        null,
        $.extend({}, options, { _rangeEnd: "start" }),
        isPageFilter,
        onChange,
        suppressFilterEvents
      )),
      to: (this.widgets[widgetType][name + "-to"] = this.addDatePicker(
        name + "-to",
        null,
        $.extend({}, options, { _rangeEnd: "end" }),
        isPageFilter,
        onChange,
        suppressFilterEvents
      )),
    };

    // This doesn't depend on loading any remote data so if it's a filter it's always ready
    if (isPageFilter) {
      this.filters.ready[name] = true;
    }

    return (this.widgets[widgetType][name] = {
      control: control,
      filter: "{" + varName + '} BETWEEN "#value|from# 00:00:00" AND "#value|to# 23:59:59"',
      formatter: function (raw) {
        return DateTime.getSQLDate(raw);
      },
    });
  };

  /**
   * Creates a new date picker widget.
   * Expects a DOM input element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {string} varName Name of the survey variable associated with this widget.
   * @param {object} [options] Custom widget options
   * @param {boolean} [isPageFilter] True if this widget is a page filter
   * @param {function} [onChange] Method to call when the widget value changes
   * @param {boolean} [suppressFilterEvents] When true updates to this widget will not generate filterChanged events.
   *
   * @see {@link https://docs.telerik.com/kendo-ui/api/javascript/ui/datepicker} for options.
   *
   * @returns {object}
   */
  this.addDatePicker = function (name, varName, options, isPageFilter, onChange, suppressFilterEvents) {
    const widgetType = isPageFilter ? "static" : "disposable";
    const base = $("#" + name);

    const opts = {
      format: config.DATE_FORMAT,
      month: {
        empty: '<span class="k-state-disabled">#= data.value #</span>',
      },
    };

    if (!options || !options.allowFutureDates) {
      const max = new Date();
      max.setHours(23, 59, 59, 999);
      opts.max = max;
    }

    $.extend(opts, options);

    // Internal marker added by addDateRange to say which end of a range this picker is - it's not a Kendo option
    const rangeEnd = opts._rangeEnd;
    delete opts._rangeEnd;

    const control = base.kendoDatePicker(opts).data("kendoDatePicker");

    // This doesn't depend on loading any remote data so if it's a filter it's always ready
    if (isPageFilter) {
      this.filters.ready[name] = true;
    }

    // Snap range pickers to the period their depth selects. Kendo binds any caller-supplied change handler while
    // constructing the widget above, and handlers fire in the order they were bound, so this deliberately sits
    // between that handler and the filter trigger below - it corrects the value a caller's own workaround may have
    // left behind, and does so before anything reads the value to build a filter.
    if (rangeEnd) {
      const snap = _getPeriodSnap(opts.depth, rangeEnd, opts.max);

      if (snap) {
        control.bind("change", snap);
      }
    }

    control.bind("change", function (e) {
      if (typeof onChange === "function") {
        onChange.call(self, e.sender);
      }
      if (!suppressFilterEvents) {
        self._triggerFilterEvents(name);
      }
    });

    // Add an SQL filter if this control has an associated survey variable - month granularity only
    const filter = varName ? function () {
      const value = this.control.value();
      return value === null ? null : `DATE_FORMAT({${varName}}, \"%Y-%m\") = "${DateTime.getSQLMonth(value)}"`;
    } : null;

    return (this.widgets[widgetType][name] = {
      control: control,
      filter,
    });
  };

  /**
   * Creates a new data container.
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {options} options Custom widget options - NOTE: Only options.filterChanged is supported
   *
   * @returns {object}
   */
  this.addDataValue = function (name, options) {
    if (typeof options.filterChanged === "function") {
      const el = document.getElementById(name);
      const _server = new Proxy(server, getProxyHandler(el));
      self.onFilterChanged(options.filterChanged.bind(self, _server));
    }

    return (self.widgets.disposable[name] = {
      control: $("#" + name),
    });
  };

  /**
   * Creates a new chart widget.
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Custom widget options
   * 
   * @see {@link https://docs.telerik.com/kendo-ui/api/javascript/dataviz/ui/chart} for options
   *
   * @returns {object}
   */
  this.addChart = function (name, options) {

    // There's a bug in Kendo whereby sometimes a stacked 100% barchart doesn't have a 0-1 range
    // so force it here
    if (options && options.seriesDefaults && options.seriesDefaults.stack && options.seriesDefaults.type === 'bar' && options.seriesDefaults.stack.type === '100%') {
      if (!options.seriesDefaults.valueAxis) options.seriesDefaults.valueAxis = {};
      options.seriesDefaults.valueAxis.min = 0;
      options.seriesDefaults.valueAxis.max = 1;
    }

    const opts = $.extend({
      autoBind: false,
      resizable: true,
      chartArea: {
        background: "transparent",
      },
    },
      options
    );

    return createWidget(name, "kendoChart", opts);
  };

  /**
   * Creates a new comparison widget that allows the user to compare survey variables side-by-side by creating up to 6 "personas".
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Custom widget options:
   *
   * | Option | Type | Default | Comment |
   * | --- | --- | --- | --- |
   * | project | object | none | The Project used in the comparison. |
   * | vars | array | none | Array of Survey Variables to show in the comparison. |
   * | filter | string | none | An optional base filter that is **always** applied to **all** personas. |
   * | personaLabel | array | none | The root name used for each persona. Each person is numbered so e.g. if personaLabel = "tenant" then the first persona will be "tenant 1". |
   * | personaAttributes | array | none | An array describing the attributes that may be set for each persona. |
   * 
   * An example:
   * 
   *    const project = client.Globals.projects.find((project) => project.name === 'My Project');
   *    const datePresets = {
   *       'YearEnd 2024': client.Globals.datePreset.twoYearsAgoFinancialYear,
   *       'YearEnd 2025': client.Globals.datePreset.lastFinancialYear,
   *       'YearEnd 2026': client.Globals.datePreset.thisFinancialYear,
   *    }
   *
   *    client.addComparison(
   *     'comparison2', 
   *     {
   *       project:,
   *       vars: client.getProjectVarsByCategory(project, 'compare', ['name', 'caption']),
   *       filter: '{tenant_type}="LCRA"',
   *       personaLabel: 'tenant',
   *       personaAttributes: [
   *           { name: project.dateVar, label: 'Dates', type: 'daterange', defaultFromFilter: 'departure', presets: datePresets },
   *           { name: 'tenure_type', label: 'Tenure', type: 'dropdown' },
   *           { name: 'age_group', label: 'Age Group', type: 'dropdown' },
   *           { name: 'region', label: 'Region', type: 'dropdown' },
   *           { name: 'had_repair', label: 'Had a Repair', type: 'dropdown' },
   *       ]
   *   })
   * 
   *   In personaAttributes:
   *      "name" is the name of the variable.
   *      "label" is the label to use for the persona.
   *      "type" is the type of control to show for the persona. It can be one of:
   *         - dropdown
   *         - daterange
   *      "defaultFromFilter" is optional, and if present will cause the persona's attribute to be initialized from the current page filter for that variable (if any).
   *      "presets" is optional, and only works for dateranges. It defines a set of date presets that can be used to set the persona's date range.
   *
   * @returns {object}
   */
  this.addComparison = function (name, options) {
    const opts = $.extend({
      autoBind: false,
      resizable: true,
      chartArea: {
        background: "transparent",
      },
    },
      options
    );

    return createWidget(name, "kendoTLFComparison", opts);
  };

  /**
   * Creates a new interactive chart with a UI that allows the user to change the chart.
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Custom widget options
   * 
   * @see {@link https://docs.telerik.com/kendo-ui/api/javascript/dataviz/ui/chart} for options
   *
   * @returns {object}
   */
  this.addInteractiveChart = function (name, options) {
    const opts = $.extend({
      autoBind: false,
      resizable: true,
      chartArea: {
        background: "transparent",
      },
    },
      options
    );

    return createWidget(name, "kendoTLFInteractiveChart", opts);
  };

  /**
   * Creates a new TabStrip widget.
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Custom widget options
   * 
   * @see {@link https://docs.telerik.com/kendo-ui/api/javascript/ui/tabstrip} for options.
   *
   * @returns {object}
   */
  this.addTabStrip = function (name, options) {
    const opts = $.extend({
      animation: {
        open: {
          effects: "fadeIn",
        },
      },
    },
      options
    );

    return createWidget(name, "kendoTabStrip", opts);
  };

  /**
   * Creates a new Interview widget.
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Custom widget options -
   *
   *  Options specific to this widget:
   *
   * | Option | Type | Default | Comment |
   * | --- | --- | --- | --- |
   * | autoBind | boolean | false | When true the widget will bind to a data source during initialization. |
   * | datevar | string | null | Name of the survey variable containing an interview date. |
   * | export | boolean | false | When true an 'Export as a PDF' button will be shown. |
   * | inline | boolean | false | When true the widget will be displayed inline as part of the page. When false it will be hosted inside a Kendo dialog. |
   * | journey | object | null | JSON object whose keys define the label text shown in the widget, and whose values are strings that define participant variables (displayed in the left-hand column of the widget in a Participant section) - e.g. { 'My Name': 'participant_name' } |
   * | participant | object | null | JSON object whose keys define the label text shown in the widget, and whose values are objects that define customer journey variables (displayed in the left-hand column of the widget in a Journey section), Boolean variables should be identified via a 'bool': true value - e.g. 'One call' : { name: 'manytimes_bool', bool: true} |
   * | project | Project | null | Project associated with this widget. |
   * | satindex | string | 'satindex' | Name of the survey variable containing a sat index score. |
   * | show | function | null | Function that is called when the widget is shown. It is passed the ID of the displayed response. |
   * | showZeroScore | boolean | true | When true the Likert scale ranges for sat scores are set to 0-10. When false it's 1-10. |
   * | title | string | 'Interview' | Text to be displayed in the widget's tite bar. |
   * 
   * @returns {object}
   */
  this.addInterview = function (name, options) {
    const opts = $.extend({
      autoBind: false,
    },
      options
    );
    return createWidget(name, "kendoTLFInterview", opts);
  };

  /**
   * Creates a new Survey Inspector widget.
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Custom widget options -
   *
   *  Options specific to this widget:
   *
   * | Option | Type | Default | Comment |
   * | --- | --- | --- | --- |
   * 
   * @returns {object}
   */
  this.addSurveyInspector = function (name, options) {
    const opts = $.extend({
      autoBind: false,
    },
      options
    );
    return createWidget(name, "kendoTLFSurveyInspector", opts);
  };

  /**
   * Creates a new Heatmap widget.
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Custom widget options -
   *
   *  Options specific to this widget:
   *
   * | Option | Type | Default | Comment |
   * | --- | --- | --- | --- |
   * | project | Project | none | Project to inspect (Mandatory). |
   * | aggr | string | none | API aggregator function to use (Mandatory). |
   * | aggrBase | string | none | Aggregator base expression (only required for the 'prop' aggregator). |
   * | vars | Variable[] | none | Array of Variables to display as grid rows - must have at least the 'id', 'name' and 'label' keys (Mandatory). |
   * | group | string | Project dateVar | Grouping variable used to generate columns (Mandatory). |
   * | filters | string | Current page filters | Filter to apply to the data. |
   * | caption | string | none | The first column header's title - defaults to 'Question'. |
   * | scaleMode | string | none | Determines the scope of the Min/Max/Percentile values in the 'scale' key - one of GRID | ROW | COL (Mandatory). GRID = entire grid, ROW = per row, COL = per column |
   * | scale | object[] | none | Array of color stops (Mandatory). Must contain either 2 or 3 members. Each stop is of the form { type: <One of 'Min', 'Max', 'Number' or 'Percentile'>, value: Number (only required if type is 'Number' or 'Percentile'), color: Custom color (optional) as '#RRGGBB' }. |
   * | format | object[] | 'p1' | Kendo formatting string to apply to each cell's value |
   * | invertScale | string[] | none | Optional names of variables to invert the color scale for. |
   * | rowHeaderFormatter | string[] | none | Optional function to format each row's header HTML - it is passed the relevant Variable object. |
   * 
   * @example
   * 
   * const project = client.Globals.projects[0];
   * const vars = client.getProjectVarsByCategory(project, "var-category", ['id', 'name', 'label']);
   *
   * // Classify each question by section
   * const sections = {
   *     'hostel': ['hadprob2', 'hadprob3', 'hadprob5', 'hadprob6', 'hadprob10', 'hadprob12'],
   *     'guests': ['hadprob8'],
   *     'service': ['hadprob1', 'hadprob4', 'hadprob7', 'hadprob9', 'hadprob11'],
   *     'other': ['hadprob13'],
   * }
   *
   * // Custom formatter for each row that prepends the section name to the variable label
   * const rowHeaderFormatter = v => {
   *     // Find which section header the given variable name belongs to
   *     for (const section in sections) {
   *         if (sections[section].includes(v.name)) {
   *             // Prepend the section name to the returned header
   *             return `<span class="section-header section-header-${section}">${section}</span><span>${v.label}</span>`;
   *         }
   *     }
   *
   *     // If no section header was found, just return the variable label
   *     return v.label;
   * };
   * 
   * client.addHeatmap('heatmap-id', {
   *     project,  
   *     vars,                                           
   *     aggr: 'prop',                                   
   *     aggrBase: '{hadprob}=1',                        
   *     caption: 'Had a problem',                       
   *     scaleMode: 'GRID',                              
   *     scale: [
   *         {
   *             type: 'Min',                            
   *         },
   *         {
   *             type: 'Percentile',                     
   *             value: 50,                              
   *         },
   *         {
   *             type: 'Max',
   *         }
   *     ],
   *     invertScale: ['hadprob10'],                     
   *     rowHeaderFormatter                              
   * });
   * 
   * In this example the scale consists of three color stops:
   * 
   *   First stop: minimum value of the entire grid.
   *   Second stop: the median value of the entire grid.
   *   Third stop: the maximum value of the entire grid.
   * 
   * If the scaleMode was set to 'ROW' then the minimum, median and maximum values would be individually calculated for each *row*.
   * If the scaleMode was set to 'COL' then the minimum, median and maximum values would be individually calculated for each *column*.
   * 
   * No custom colors were specified so the default colors were used (which are the same as those used by Excel's Conditional Formatting feature).
   * The 'hadprob1' question has a negative sense e.g. 'Something was wrong' so its color scale is inverted (i.e. low is green => high is red). 
   * 
   * @returns {object}
   */
  this.addHeatmap = function (name, options) {
    const opts = $.extend({
      autoBind: false,
    },
      options
    );
    return createWidget(name, "kendoTLFHeatmap", opts);
  };

  /**
   * Creates a new Research Assistant widget.
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Custom widget options -
   *
   *  Options specific to this widget:
   *
   * | Option | Type | Default | Comment |
   * | --- | --- | --- | --- |
   * | project | Project | none | Project to inspect (Mandatory). |
   * 
   * @example
   * 
   * const project = client.Globals.projects.find((project) => project.name === 'My Project');
   * 
   * client.addAssistant('my-assistant-container', {project});
   * 
   * @returns {object}
   */
  this.addAssistant = function (name, options) {
    const opts = $.extend({
      autoBind: false,
    },
      options
    );
    return createWidget(name, "kendoTLFAssistant", opts);
  };

  /**
   * Adds a Perfect Experience widget.
   * Expects a DOM element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Options to control the appearance and behaviour of the widget.
   *
   *  Options specific to this widget:
   *
   * | Option | Type | Default | Comment |
   * | --- | --- | --- | --- |
   * | addSummary | boolean | true | When true a summary row will be added at the bottom of the widget to show the clients who said 'Yes' to all questions. |
   * | animate | boolean | true | When true charts animate when they are first created. |
   * | barHeight | number | 30 | The height of each bar in pixels. |
   * | categoryColor | string | "grey" | The color of the category label. |
   * | categoryPos | string | "outside-top" | The position of the category label relative to the bar - one of 'outside-top', 'outside-bottom', 'outside-end', 'inside-start', 'inside-end' |
   * | customFilter | function | null | Defines a function that may be used to modify the filters used to fetch data from the API |
   * | detailSplit | string | null | When set to the name of a variable will generate a bar chart split by the variable. The chart is displayed in the expandable detail row. |
   * | detailSplitFilter | string | null | Filter used in the API call when generating the split chart. |
   * | detailSplitSort | string | null | Sort used in the API call when generating the split chart. |
   * | detailTrend | boolean | `false` | When set to `true` the widget displays a trend chart in the expandable detail row. |
   * | header | boolean | `true` | When set to `true` the widget displays a header row above the main bar charts. |
   * | headerScoreTitle | string | "Sat Score" | Text to show in the header for the Sat scores columns (when `header` is true). |
   * | includeDontKnow | boolean | `false` | When true includes 'Don't Know' answers for Yes/No questions |
   * | leftBarColor | string | "grey" | Color to use for the left-hand set of bars. |
   * | ovsatAggr | string | 'avg' | API aggregator function to be used to calculate the sat result - one of  'avg or 'nps' |
   * | ovsatVar | string | OVSAT_VAR_NAME config setting | Name of the variable to be used to calculate the sat result |
   * | project | project | null | Project to use to display the widget. **MANDATORY** |
   * | quarterly | boolean | `false` | When `true` display the data in quarters (rather than months). |
   * | quarterStartMonth | number | 0 (=January) | If the quarters should not start in January specifiy the (zero-based) month number here. |
   * | rightBarColor | string | "silver" | Color to use for the right-hand set of bars. |
   * | rolling | string | null | The rolling period to apply to the data e.g. `3m(some_var_name)`. |
   * | rollingMode | number | `PE_ROLLING_NONE` | Which widget elements to apply the rolling period to. One or more of the following constants: `PE_ROLLING_NONE`, `PE_ROLLING_BAR`, `PE_ROLLING_SAT`, `PE_ROLLING_TREND`, `PE_ROLLING_SPLIT`, `PE_ROLLING_ALL`. Combine mutiple options using bitwise OR e.g. `PE_ROLLING_BAR | PE_ROLLING_SAT` |
   * | satFormat | string | "p1" | Kendo format to apply for the displayed Sat scores (applicable when `satScores` is true) - @see {@link https://docs.telerik.com/kendo-ui/globalization/intl/numberformatting}. |
   * | satScores | boolean | `true` | When true show the numeric sat scores at the ends of each bar |
   * | swapBarColors | array | [] | An array of one or more question names for which the left and right colors will be swapped. |
   * | vars | array | null | List of Yes/No response var names to display. Each variable will generate a new row with its sat scores and a bar chart. **MANDATORY** |
   * | weighted | boolean | `false` | When `true` all displayed data will be weighted using the weighting column identified by the variable in the Project's' `weight` category. |
   * | dontWeight | object | null | When the `filters` key is present as an array of var names weights will not be applied when the widget is filtered by those variables e.g. `{ filters: ['foo','bar'] }` |
   * 
   * @returns {object} Perfect Experience instance.
   */
  this.addPerfectExperience = function (name, options) {
    const opts = $.extend({
      autoBind: false,
    },
      options
    );
    return createWidget(name, "kendoTLFPerfectExperience", opts);
  };

  /**
   * Creates a new doughnut widget (based on a Kendo chart).
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Custom widget options
   * 
   * @see {@link https://demos.telerik.com/kendo-ui/donut-charts/index} for more.
   *
   * @returns {object}
   */
  this.addDoughnut = function (name, options) {
    const opts = $.extend({
      autoBind: false,
      resizable: true,
      chartArea: {
        background: "transparent",
      },
      legend: {
        visible: false,
      },
      legendItemClick: function (e) {
        //prevent toggling the series visibility on legend item click
        e.preventDefault();
      },
      tooltip: {
        visible: true,
      },
      seriesDefaults: {
        type: "donut",
        startAngle: 90,
        overlay: {
          gradient: "none",
        },
      },
      series: [{
        name: options.caption || "",
        type: "donut",
        holeSize: options.holeSize || parseInt(options.chartArea.width / 4.5, 10),
        labels: {
          color: options.labelColor || "silver",
          font: options.labelFont || "sans-serif",
          format: options.labelFormat || "n",
        },
        visual: function (e) {
          e.sender._center = e.center;
          e.sender._radius = e.radius;

          return e.createVisual();
        },
        data: [{
          category: options.categories && options.categories.length > 0 ?
            options.categories[0] : "Value",
          color: options.valueSegmentColor || "#808080",
        },
        {
          category: options.categories && options.categories.length > 1 ? options.categories[1] : "",
          color: options.emptySegmentColor || "#eeeeee",
        },
        ],
      },],
      render: client.doughnutRenderer,
    },
      options
    );

    return createWidget(name, "kendoChart", opts);
  };

  /**
   * Creates a new Pivot Analyser widget.
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Custom widget options
   *
   * @returns {object}
   */
  this.addPivotAnalyser = function (name, options) {
    const opts = $.extend({
      autoBind: true,
    },
      options
    );
    return createWidget(name, "kendoTLFPivotAnalyser", opts);
  };

  /**
   * Creates a new Sphere widget.
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Custom widget options
   *
   *  Options specific to this widget:
   *
   * | Option | Type | Default | Comment |
   * | --- | --- | --- | --- |
   * | autoBind | boolean | false | When true the widget will bind to a data source during initialization. |
   * | baseColor | string | '#808080' | CSS Color to use for the sphere. |
   * | speed | number | 1500 | Animation speed. |
   * | valueFormat | string | 'p0' | Kendo format to use for the displayed values - @see {@link https://docs.telerik.com/kendo-ui/globalization/intl/numberformatting}. |
   *
   * @returns {object}
   */
  this.addSphere = function (name, options) {
    const opts = $.extend({
      autoBind: false,
    },
      options
    );
    return createWidget(name, "kendoTLFSphere", opts);
  };

  /**
   * Creates a new Rings widget.
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Custom widget options
   *
   *  Options specific to this widget:
   *
   * | Option | Type | Default | Comment |
   * | --- | --- | --- | --- |
   * | animate | string | 'ease-out 2s' | CSS Animation string. |
   * | autoBind | boolean | false | When true the widget will bind to a data source during initialization. |
   * | closed | boolean | true | When true the rings on the widget are closed i.e. are complete circles. |
   * | field | string | null | Name of the variable to display in the widget. |
   * | labelField | string | null | Name of the variable whose value is displayed at the top of the widget. |
   * | transform | function | null | Method that is called with the value of the widget. Must return the value to be displayed. |
   * | width | string | '2' | Width of the rings. |
   *
   * @returns {object}
   */
  this.addRings = function (name, options) {
    const opts = $.extend({
      autoBind: false,
    },
      options
    );
    return createWidget(name, "kendoTLFRings", opts);
  };

  /**
   * Creates a new House widget.
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Custom widget options
   *
   *  Options specific to this widget:
   *
   * | Option | Type | Default | Comment |
   * | --- | --- | --- | --- |
   * | autoBind | boolean | false | When true the widget will bind to a data source during initialization. |
   * | valueFormat | string | 'p0' | Kendo format to use for the displayed values - @see {@link https://docs.telerik.com/kendo-ui/globalization/intl/numberformatting}. |
   * | speed | number | 1000 | Animation speed. |
   *
   * @returns {object}
   */
  this.addHouse = function (name, options) {
    const opts = $.extend({
      autoBind: false,
    },
      options
    );
    return createWidget(name, "kendoTLFHouse", opts);
  };

  /**
   * Creates a new Animated Number widget.
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Custom widget options
   *
   *  Options specific to this widget:
   *
   * | Option | Type | Default | Comment |
   * | --- | --- | --- | --- |
   * | autoBind | boolean | false | When true the widget will bind to a data source during initialization. |
   * | valueFormat | string | 'p0' | Kendo format to use for the displayed values - @see {@link https://docs.telerik.com/kendo-ui/globalization/intl/numberformatting}. |
   * | speed | number | 1000 | Animation speed. |
   *
   * @returns {object}
   */
  this.addAnimatedNumber = function (name, options) {
    const opts = $.extend({
      autoBind: false,
    },
      options
    );
    return createWidget(name, "kendoTLFAnimatedNumber", opts);
  };

  /**
   * Creates a new View Filters widget.
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Custom widget options
   *
   *  Options specific to this widget:
   *
   * | Option | Type | Default | Comment |
   * | --- | --- | --- | --- |
   * | mode | string | null | Sets a 'mode' string that determines how the filters are displayed. |
   * | project | Project | null | Sets the Project associated with the filters. |
   * | showNotApplicable | boolean | false | When true *all* possible project filters are shown by default. |
   *
   * @returns {object}
   */
  this.addViewFilters = function (name, options) {
    const opts = $.extend({
    },
      options
    );
    return createWidget(name, "kendoTLFViewFilters", opts);
  };

  /**
   * Creates a new waffle chart widget.
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Custom widget options
   *
   *  Options specific to this widget:
   *
   * | Option | Type | Default | Comment |
   * | --- | --- | --- | --- |
   * | autoBind | boolean | false | When true the widget will bind to a data source during initialization. |
   * | categories | array | 0 | Array of data category names that are used to divide up the waffle squares. |
   * | columns | number | 0 | Number of columns to show in the waffle. |
   * | height | number | 200 | Height of the waffle in pixels. |
   * | layout | string | 'alternate' | How the waffle squares are filled-in:'left-to-right' or 'alternate' i.e like a Snakes and Ladders board |
   * | speed | number | 0 | Animation speed. |
   * | series | array | [] | Array of objects representing the data series. |
   * | valueFormat | string | 'p0' | Kendo format to use for the displayed values - @see {@link https://docs.telerik.com/kendo-ui/globalization/intl/numberformatting}. |
   *
   * @returns {object}
   */
  this.addWaffle = function (name, options) {
    const opts = $.extend({
      autoBind: false,
    },
      options
    );
    return createWidget(name, "kendoTLFWaffle", opts);
  };

  /**
   * Creates a new logged-in User widget.
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} [options] Custom widget options
   *
   * @returns {object}
   */
  this.addUser = function (name, options) {
    options = options || {};
    const opts = $.extend({
      autoBind: false,
    },
      options
    );
    const widget = createWidget(name, "kendoTLFUser", opts);

    return widget;
  };

  /**
   * Creates a new stage widget.
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Custom widget options
   *
   * @returns {object}
   */
  this.addStage = function (name, options) {
    const opts = $.extend({
      autoBind: false,
      resizable: true,
    },
      options
    );
    return createWidget(name, "kendoTLFStage", opts);
  };

  /**
   * Creates a new podium widget.
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Custom widget options
   *
   * @returns {object}
   */
  this.addPodium = function (name, options) {
    const opts = $.extend({
      autoBind: false,
      resizable: true,
    },
      options
    );
    return createWidget(name, "kendoTLFPodium", opts);
  };

  /**
   * Creates a new journey widget.
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Custom widget options
   *
   * @returns {object}
   */
  this.addJourney = function (name, options) {
    const opts = $.extend({
      autoBind: false,
      resizable: true,
    },
      options
    );
    return createWidget(name, "kendoTLFJourney", opts);
  };

  /**
   * Creates a new sparkline widget i.e a simple chart with no axis, legend or tooltips.
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Custom widget options
   * 
   * @see {@link https://docs.telerik.com/kendo-ui/controls/charts/sparkline/overview} for more.
   *
   * @returns {object}
   */
  this.addSparkline = function (name, options) {
    const opts = $.extend({
      autoBind: false,
      chartArea: {
        background: "transparent",
      },
    },
      options
    );
    return createWidget(name, "kendoSparkline", opts);
  };

  /**
   * Creates a new grid widget.
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Custom widget options
   * 
   * @see {@link https://docs.telerik.com/kendo-ui/api/javascript/ui/grid} for options.
   *
   * @returns {object}
   */
  this.addGrid = function (name, options) {
    const opts = $.extend({
      autoBind: false,
    },
      options
    );
    return createWidget(name, "kendoGrid", opts);
  };

  /**
   * Creates a new Hot Alerts widget.
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Custom widget options
   *
   *  Options specific to this widget:
   *
   * | Option | Type | Default | Comment |
   * | --- | --- | --- | --- |
   * | author | string | "Unknown" | The name of the author that will be used in the attribution when comments and status changes are made. |
   * | filters | function | `client.getPageFilters()` | Function that must return an array of filters to be used when requesting HAs. The default simply uses any global page filters. |
   * | project | object | None | The Project to be used by the widget. |
   * | showFilter | boolean | `false` | When set to `true` the widget displays a multi-select that can be used to filter the HA grid by one or more statuses. |
   * | showStats | boolean | `false` | When set to `true` the widget displays counts for each possible HA status. |
   * | statuses | array | ['Open', 'Pending', 'Resolved'] | HA statuses to be shown. |
   * 
   * @returns {object}
   */
  this.addHotAlerts = function (name, options) {
    const opts = $.extend({
      autoBind: false,
    },
      options
    );
    return createWidget(name, "kendoTLFHotAlerts", opts);
  };

  /**
   * Creates a new Taskboard widget.
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Custom widget options
   *
   *  Options specific to this widget:
   * 
   * @returns {object}
   */
  this.addTaskboard = function (name, options) {
    const opts = $.extend({
      autoBind: false,
    },
      options
    );
    return createWidget(name, "kendoTLFTaskboard", opts);
  };

  /**
   * Creates a new Categories widget.
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Custom widget options
   *
   *  Options specific to this widget:
   *   project    {object} The project to fetch category data from
   *   codingVar  {string} The coding variable name (default: 'coding')
   *   dateVar    {string} The date variable name (defaults to project.dateVar)
   *
   *  The server fixes the columns it returns, so codingVar and dateVar only name the keys to read
   *  from each row - they do not change what is fetched.
   *
   *  Bar length and the right-hand figure are the percentage of coded respondents who mention the
   *  category. A respondent counts once however many comment questions they answered, and
   *  respondents are multi-coded, so these do not sum to 100%. The sentiment colours within a bar
   *  are shares of that category's mentions, which is a different denominator - a respondent
   *  tagged both positively and negatively counts once in the bar length but twice there.
   *
   * @returns {object}
   */
  this.addCategories = function (name, options) {
    const opts = $.extend({
      autoBind: false,
    },
      options
    );
    return createWidget(name, "kendoTLFCategories", opts);
  };

  /**
   * Creates a new slider widget.
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Custom widget options
   *
   * @see {@link https://docs.telerik.com/kendo-ui/api/javascript/ui/slider} for options.
   *
   * @returns {object}
   */
  this.addSlider = function (name, options) {
    const opts = $.extend({
      autoBind: false,
    },
      options
    );
    return createWidget(name, "kendoSlider", opts);
  };

  /**
   * Creates a new range slider widget.
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Custom widget options
   * 
   * @see {@link https://docs.telerik.com/kendo-ui/api/javascript/ui/rangeslider} for options.
   *
   * @returns {object}
   */
  this.addRangeSlider = function (name, options) {
    const opts = $.extend({
      autoBind: false,
    },
      options
    );
    return createWidget(name, "kendoRangeSlider", opts);
  };

  /**
   * Creates a new TreeList widget.
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Kendo TreeView widget options, plus the following TLF extensions:
   *
   * | Option | Type | Default | Comment |
   * | --- | --- | --- | --- |
   * | persistExpandedNodes | bool | false | When true the control will remember the expanded state of nodes. |
   * | persistScrollPosition | bool | false | When true the control will remember the current scroll position. |
   * | filter | function | undefined | A function that will be called with the rows returned by each API request. It may modify them before returning them. |
   * | databound | function | undefined | A function that will be called when the treelist's data is bound. It is passed the data array and may modify it before returning it. |
   * 
   * Each element in the Kendo columns array may specify any of the Kendo Column options, plus the following TLF extensions:
   * 
   * | Key | Type | Default | Comment |
   * | --- | --- | --- | --- |
   * | hierarchy | array | undefined | Defines the hierarchy to be diplayed in the column. In this case the field option must be set to 'value'. |
   * | api | object | undefined | Defines the API call to be used to fetch the column's data e.g. { aggregator: "avg", variable: "ease" } |
   * 
   * An example hierarchy object (expanded = true means the node is expanded to show its children on first load) would be:
   * 
   *  hierarchy: [
   *     { text: 'Overall', expanded: true },
   *     { varName: 'area', expanded: true },
   *     { varName: 'region' },
   *     { varName: 'company' },
   *     { varName: 'project_manager' },
   *     { varName: 'respondent' }
   *  ]
   * 
   * @param {function} filter A filter function that is called when the treelist's data is bound. It is passed the rows array and may modify it before returning it.

   * 
   * @see {@link https://docs.telerik.com/kendo-ui/api/javascript/ui/treelist} for TreeList options.
   * @see {@link https://docs.telerik.com/kendo-ui/api/javascript/ui/treelist/configuration/columns} for TreeList column options.
   *
   * @returns {object}
   */
  this.addTreeList = function (name, options) {

    // Method to calculate a unique hash for an object
    const calculateHash = obj => JSON.stringify(obj, Object.keys(obj).sort());

    // Install a default handler for the filterChanged event if no handler has been provided
    if (!options.filterChanged) {
      options.filterChanged = _updateTreeList;
    }

    const tlw = createWidget(name, "kendoTreeList", options);
    const tl = tlw.control;

    if (tl.options.persistExpandedNodes) {
      tl.bind("expand", _saveExpandedState);
      tl.bind("collapse", _saveExpandedState);
      tl.bind("dataBound", _restoreExpandedState);
    }

    if (tl.options.persistScrollPosition) {
      const gridContent = $("#" + name + " .k-grid-content");
      gridContent.scroll(function () {
        tlw.scrollTop = this.scrollTop;
      });
      tl.bind("dataBound", function () {
        if (typeof tlw.scrollTop !== "undefined") {
          gridContent[0].scrollTop = tlw.scrollTop;
        }
      });
    }

    return tlw;

    function _saveExpandedState() {
      // Have to do this after a short delay because the event fires *before* the node changes state
      setTimeout(function () {
        tlw.expandedNodes = JSON.stringify(
          $.map($("#" + name + " .k-i-collapse").closest("tr"), function (val) {
            return $(val).index();
          })
        );
      }, 200);
    }

    function _restoreExpandedState() {
      if (tlw.expandedNodes) {
        $.each(JSON.parse(tlw.expandedNodes), function (idx, val) {
          tl.expand(tl.content.find("tr").eq(val));
        });
      }
    }

    function _updateTreeList() {

      if (!options.columns) {
        console.error('The default handler for a TreeGrid widget requires a columns specification');
        return;
      }

      const grid = client.getControl(name);
      if (grid) {

        // This may take a while...
        kendo.ui.progress(grid.element, true);

        const proj = client.Globals.projects[0];
        const group = Convert.extractFromArray(options.columns[0].hierarchy, 'varName', h => h.varName);
        const groupVars = group.map(g => `{${g}}`);

        const hierarchyGroup = [...group];
        const signatureVars = hierarchyGroup.reduce((arr, g) => {
          arr.push(g);
          arr.push(`_aggr_${g}`);
          return arr;
        }, []);
        const hierarchyVar = hierarchyGroup.pop();

        const colsWithAPICalls = options.columns.filter(c => c.api);

        const pageFilters = client.getPageFiltersAsString();

        // Always load data for the hierarchy column, and add any API queries from the columns config
        const queries = [
          server.getResponsesDistinct(proj, hierarchyVar, pageFilters, hierarchyGroup.map(g => `{${g}}`)),
          ...colsWithAPICalls.map((col, colIdx) => server.getResponsesAggregate('COL' + (colIdx + 2), client.Globals.projects[0], col.api.aggregator, col.api.variable, pageFilters, groupVars, null, true))
        ];

        // The done method runs when all the above async remote requests complete, and receives the result as arguments in query order
        $.when.apply($, queries).done(function () {

          // Normalize results
          const results = arguments[1] === "success" ? [arguments] : [...arguments];

          // The hierachy is derived from the first result
          const firstResult = options.filter ? options.filter(results[0][0].rows) : results[0][0].rows;
          const { hierarchy, hierarchyIndex } = _getGridHierarchy(firstResult);

          // First column of the grid data contians the hierarchy
          let data = JSON.parse(JSON.stringify(hierarchy));

          // Add remote data to the local data object.
          // Each argument contains the results of the queries defined above.
          for (let c = 1; c < arguments.length; c++) {
            const results = arguments[c][0];
            if (results.rows.length) {
              const result = options.filter ? options.filter(results.rows) : results.rows;
              const varName = colsWithAPICalls[c - 1].api.variable;
              const dataValue = result[0][varName];
              if (typeof dataValue !== 'undefined' && dataValue !== null) {
                result.forEach(function (varData) {
                  if (varData[varName]) {
                    const hIndex = _getHierarchyIndex(varName, signatureVars, hierarchyIndex, varData);
                    if (hIndex != -1) {
                      const col = colsWithAPICalls[results.tag.slice(3) - 2];
                      data[hIndex][col.field] = kendo.toString(varData[varName], col.format || 'n0');
                    } else {
                      console.warn('Cannot find entry for', varData, 'is the data correct?');
                    }
                  }
                });
              }
            }
          }

          // Call any method to modify data before display
          if (typeof options.update === 'function') data = options.update(data);

          client.setWidgetDataSource(name, data);

          // Remove spinner
          kendo.ui.progress(grid.element, false);
        });
      }
    }

    // Returns a hierarchy in the flattened format required by the Kendo widget
    function _getGridHierarchy(rawHierarchy) {

      const rootNode = options.columns[0].hierarchy[0];

      let id = 1;

      // Convert all the rows into an JS object hierarchy
      const hierarchy = {};

      // Get all the keys for each level of the hierarchy
      const hierarchyVars = Convert.extractFromArray(options.columns[0].hierarchy, 'varName', h => h.varName);

      rawHierarchy.forEach((r, rowIdx) => {
        // Warn about any rows with nulls (and skip it)
        for (const key in r) {
          if (r[key] === null) {
            console.warn(`client.Globals.hierarchy row ${rowIdx} has one or more null values - skipping`);
            return;
          }
        }

        // Add data for this row to the relevant part of the object hierarchy
        let thisLevel = hierarchy;

        // Add all bar the last level to the hierarchy
        for (let idx = 0; idx < hierarchyVars.length - 1; idx++) {
          const k = hierarchyVars[idx];

          // If this is the last level make its initial value an array not an object (so we can push the last level values on to it)
          if (typeof thisLevel[r[k]] === 'undefined') { thisLevel[r[k]] = idx === hierarchyVars.length - 2 ? [] : {} };

          // Move down one level
          thisLevel = thisLevel[r[k]];
        }

        // Push this row's last-level value into the last level's array
        thisLevel.push(r[hierarchyVars[hierarchyVars.length - 1]]);
      })

      // Create a signature so as to identify the data requirements for each row
      // We start with needing all the _aggr_ values to be 1 (i.e. the overall summary results).
      const signature = hierarchyVars.reduce((sig, varName) => {
        sig[varName] = null;
        sig[`_aggr_${varName}`] = 1;
        return sig;
      }, {});

      // Define the root node of the hierarchy
      let treeData = [
        { id: id++, parentId: null, value: rootNode.text, expanded: rootNode.expanded, signature },
      ];

      // Convert the object hierarchy into the format needed by the Kendo TreeGrid and add it to the given TreeData array
      _addLevel(treeData, hierarchy, 1, 0, { ...signature });

      // Create an index to the hierarchy to improve performance when looking up values based on the signature.
      // We add a sort here to ensure that object keys in the stringified result are always in the same order.
      const treeIndex = treeData.reduce((obj, d, idx) => {
        obj[calculateHash(d.signature)] = idx;
        return obj;
      }, {});

      return { hierarchy: treeData, hierarchyIndex: treeIndex };

      // _addLevel recursively adds all the data in the given object hierarchy to the given Kendo TreeGrid array
      function _addLevel(data, h, parentId, levelId, signature) {

        const levelSig = { ...signature };
        const isLeafNode = Array.isArray(h);
        const levelVar = hierarchyVars[levelId];

        // Adjust the required aggregation settings for this level in the signature
        if (levelVar) levelSig[`_aggr_${levelVar}`] = 0;
        if (isLeafNode) levelSig[`_aggr_${levelVar}`] = 0;

        for (const key in h) {
          // If this is a leaf node then get the value of it rather than its key.
          // Leaf nodes may have multiple values encoded as a single string delimited by pipes.
          const values = isLeafNode ? h[key].split('|') : [key];

          values.forEach(value => {
            const rowSig = { ...levelSig };
            rowSig[levelVar] = value;
            data.push({ id: id++, parentId, value, expanded: options.columns[0].hierarchy[levelId + 1].expanded, signature: { ...rowSig } });

            // If this datum is an object (this test will also match an Array) we need to drop down a level
            if (typeof h[key] === 'object') {
              _addLevel(data, h[key], id - 1, levelId + 1, rowSig);
            }
          });
        }
      }
    }

    // Returns the index of the hierarchy row whose signature matches that of the given varData (or -1 if it wasn't found).
    function _getHierarchyIndex(varName, signatureVars, index, data) {
      // Extract keys for comparison
      const compareData = _extractKeys(data, signatureVars);

      // Remove the specified varName from the comparison data
      delete compareData[varName];

      // Calculate the hash of the modified comparison data
      const hash = calculateHash(compareData);

      // Return the index if the hash exists, otherwise return -1
      return index[hash] !== undefined ? index[hash] : -1;
    }

    // extractKeys extracts only the keys in the given array from the given object
    function _extractKeys(obj, keysArray) {
      return keysArray.reduce((result, key) => {
        if (Object.prototype.hasOwnProperty.call(obj, key)) {
          result[key] = obj[key];
        }
        return result;
      }, {});
    }

  };

  /**
   * Creates a new TreeView widget.
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Custom widget options
   * 
   * @see {@link https://docs.telerik.com/kendo-ui/api/javascript/ui/treeview} for options.
   *
   * @returns {object}
   */
  this.addTreeView = function (name, options) {
    const tlw = createWidget(name, "kendoTreeView", options);
    const tl = tlw.control;

    if (tl.options.persistExpandedNodes) {
      tl.bind("expand", saveExpandedState);
      tl.bind("collapse", saveExpandedState);
      tl.bind("dataBound", restoreExpandedState);
    }

    if (tl.options.persistScrollPosition) {
      const gridContent = $("#" + name + " .k-grid-content");
      gridContent.scroll(function () {
        tlw.scrollTop = this.scrollTop;
      });
      tl.bind("dataBound", function () {
        if (typeof tlw.scrollTop !== "undefined") {
          gridContent[0].scrollTop = tlw.scrollTop;
        }
      });
    }

    return tlw;

    function saveExpandedState() {
      // Have to do this after a short delay because the event fires *before* the node changes state
      setTimeout(function () {
        tlw.expandedNodes = JSON.stringify(
          $.map($("#" + name + " .k-i-collapse").closest("tr"), function (val) {
            return $(val).index();
          })
        );
      }, 200);
    }

    function restoreExpandedState() {
      if (tlw.expandedNodes) {
        $.each(JSON.parse(tlw.expandedNodes), function (idx, val) {
          tl.expand(tl.content.find("tr").eq(val));
        });
      }
    }
  };

  /**
   * Creates a new Calendar widget.
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Custom widget options
   * 
   * @see {@link http://docs.telerik.com/kendo-ui/api/javascript/ui/calendar|kendo.ui.Calendar} for options.
   *
   * @returns {object}
   */
  this.addCalendar = function (name, options) {
    return createWidget(name, "kendoCalendar", options);
  };

  /**
   * Creates a new radial gauge widget.
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Custom widget options
   * 
   * @see {@link https://docs.telerik.com/kendo-ui/api/javascript/dataviz/ui/radialgauge} for options.
   *
   * @returns {object}
   */
  this.addRadialGauge = function (name, options) {
    return createWidget(name, "kendoRadialGauge", options);
  };

  /**
   * Creates a new map widget (centred on the UK by default).
   * Expects a DOM div element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Custom widget options
   *
   * @returns {object}
   */
  this.addMap = function (name, options) {
    const self = this;

    const dataSources = [];

    const opts = $.extend({
      style: "standard",
      center: [55.781, -5.962],
      zoom: 5,
      minZoom: 5,
      maxZoom: 16,
      mouseZoom: false,
      mapLayers: [],
      layers: [],
      shapeCreated: onShapeCreated,
      shapeFeatureCreated: onShapeFeatureCreated,
      shapeMouseEnter: onShapeMouseEnter,
      shapeMouseLeave: onShapeMouseLeave,
      shapeClick: onShapeClick,
      markerCreated: onMarkerCreated,
    },
      options
    );

    const shapeStyles = {};

    // Add background map tiles
    let baseURL;
    switch (opts.style) {
      case "standard":
        baseURL = "https://a.tile.openstreetmap.org";
        break;
      case "humanitarian":
        baseURL = "https://a.tile.openstreetmap.fr/hot";
        break;
      default:
        console.error("Unsupported map style " + opts.style);
        return;
    }

    opts.layers.push({
      name: "background",
      autoBind: false,
      type: "tile",
      urlTemplate: baseURL + "/#= zoom #/#= x #/#= y #.png",
      attribution: "&copy; OpenStreetMap contributors",
    });

    // Convert any layers into Kendo-style layers
    opts.mapLayers.forEach((layer) => {
      if (!layer.name) {
        console.error("Missing layer name");
        return;
      }

      if (!layer.dataset) {
        console.error("Missing dataset for layer " + layer.name);
        return;
      }

      if (!layer.datasetNameField) {
        console.error("Missing datasetNameField for layer " + layer.name);
        return;
      }

      layer.areas = layer.areas || {};
      layer.show = layer.show || [];

      // Convert a simple color-shortcut scale to the full object format i.e. [ { label: 'xx', min: a, max: b, style: {...} }, {...} ]
      if (Array.isArray(layer.scale)) {
        for (let i = 0; i < layer.scale.length; i++) {
          if (typeof layer.scale[i] === "string") {
            layer.scale[i] = {
              label: `${i}-${i + 1}`,
              min: i,
              max: i + 1,
              style: {
                fill: {
                  color: layer.scale[i],
                },
              },
            };
          }
        }
        if (opts.showScale) {
          addScaleLegend(layer.scale);
        }
      }

      const dataSourceOpts = {
        type: "geojson",
        transport: {
          read: `https://static.leadershipfactor.com/vendor/ons/${layer.dataset}.json`,
        },
      };

      // Filter and transform results
      const show = Convert.extractFromArray(layer.show, "name");
      dataSourceOpts.schema = {
        parse: function (data) {
          data.features = data.features
            // Apply any aliases
            .map((row) => {
              if (
                layer.datasetTransform &&
                layer.datasetTransform[row.properties[layer.datasetNameField]]
              ) {
                row.properties[layer.datasetNameField] =
                  layer.datasetTransform[row.properties[layer.datasetNameField]];
              }
              return row;
            })

            // Filter if we have a show array
            .filter(
              (row) =>
                show.length === 0 || show.indexOf(row.properties[layer.datasetNameField]) !== -1
            );

          // Process any layer Area groupings
          if (layer.type === "shape") {
            Object.keys(layer.areas).forEach((area) => {
              if (layer.areas[area].group) {
                const showLocations = Convert.extractFromArray(
                  layer.show,
                  "name",
                  (lyr) => lyr.area === area
                );

                // Scan all the geo features, and if they're in the group remember their coordinates
                // and omit them from newFeatures (as the new grouped coords will replace them)
                const newFeatures = [];
                const memberPolygons = data.features.reduce((acc, f) => {
                  if (
                    showLocations.length &&
                    showLocations.indexOf(f.properties[layer.datasetNameField]) !== -1
                  ) {
                    const coords =
                      f.geometry.type === "Polygon" ?
                        turf.polygon(f.geometry.coordinates) :
                        turf.multiPolygon(f.geometry.coordinates);
                    acc.push(coords);
                  } else {
                    newFeatures.push(f);
                  }
                  return acc;
                }, []);

                data.features = newFeatures;

                // Create a union of all the grouped areas
                const groupValueGetter = layer.areas[area].getGroupValue;
                if (memberPolygons.length) {
                  const union = turf.union(...memberPolygons);
                  union.properties[layer.datasetNameField] = `${area}: ${showLocations.join(", ")}`;
                  union.properties.group = true;
                  union.properties.getGroupValue = groupValueGetter ?
                    groupValueGetter.bind(self, showLocations) :
                    null;
                  union.properties.area = area;
                  data.features.push(union);
                }
              }
            });
          }

          // Convert marker-layer data into a suitable format
          if (layer.type === "marker") {
            data = data.features.reduce((acc, d) => {
              acc.push({
                location: d.geometry.coordinates,
                title: d.properties.name,
              });
              return acc;
            }, []);
          }

          return data;
        },
      };

      const dataSource = new kendo.data.DataSource(dataSourceOpts);
      dataSources.push(dataSource);

      switch (layer.type) {
        case "shape":
          const shapeLayer = $.extend({
            name: layer.name,
            type: layer.type,
            autoBind: false,
            dataSource: dataSource,
          }, {
            style: layer.defaultAreaStyle || {},
          });
          opts.layers.push(shapeLayer);
          break;
        case "marker":
          opts.layers.push({
            name: layer.name,
            type: layer.type,
            autoBind: false,
            dataSource: dataSource,
          });
          break;
        default:
          console.error("Unexpected layer type", layer.type);
      }
    });

    // Emulate a refresh method as the Kendo map doesn't appear to have one :(
    opts.refresh = () => {
      dataSources.forEach((ds) => {
        ds.read();
      });
    };

    // Sets the method used to calculate the value associated with the given layer
    opts.addGetLayerShapeValue = (layer, fn) => {
      const layers = opts.mapLayers.filter((l) => l.name === layer);
      if (layers.length === 0) {
        console.warn("No such layer " + layer);
        return;
      }
      layers[0].getValue = fn;
    };

    // Sets the method used to calculate the value associated with the given grouped Area
    opts.addGetLayerGroupedAreaValue = (layer, area, fn) => {
      const layers = opts.mapLayers.filter((l) => l.name === layer);
      if (layers.length === 0) {
        console.warn("No such layer " + layer);
        return;
      }

      const layerArea = layers[0].areas[area];
      if (!layerArea) {
        console.warn(`No such area ${area} in layer ${layer}`);
        return;
      }

      layerArea.getGroupValue = fn;
    };

    // Sets the method used to return a style object for the given layer's area
    opts.addGetAreaStyle = (layer, area, fn) => {
      const layers = opts.mapLayers.filter((l) => l.name === layer);
      if (layers.length === 0) {
        console.warn("No such layer " + layer);
        return;
      }
      layers[0].areas = layers[0].areas || {};
      layers[0].areas[area] = fn;
    };

    // Method to return the style associated with a given scale value
    opts.getScaleStyle = (layer, value) => {
      const layers = opts.mapLayers.filter((l) => l.name === layer);
      if (layers.length === 0) {
        console.warn("No such layer " + layer);
        return {};
      }

      if (value === null) {
        return layers[0].defaultAreaStyle;
      }

      const scale = layers[0].scale || [];
      const matches = scale.filter((s) => value >= s.min && value < s.max);
      return matches.length ? matches[0].style : {};
    };

    const widget = createWidget(name, "kendoMap", opts);

    // Overlay any map layer scales
    opts.mapLayers.forEach((layer) => {
      if (layer.showScale) {
        if (!layer.scale) {
          console.err("Missing scale for layer", layer.name);
        } else {
          addScaleLegend(layer.showScale.position || "right", layer);
        }
      }
    });

    if (opts.mouseZoom === false) {
      widget.control.element.unbind("mousewheel");
      widget.control.element.unbind("DOMMouseScroll");
    }

    return widget;

    function addScaleLegend(posn, layer) {
      const defaultBackground =
        typeof layer.defaultAreaStyle.fill.color !== "undefined" ?
          layer.defaultAreaStyle.fill.color :
          "grey";
      const rows = layer.scale.reduce(
        (h, s) =>
          (h += `<tr><td>${s.label}</td><td style="background:${s.style.fill.color}"></td></tr>`),
        ""
      );
      const table = `
                <table class="map-scale k-pos-${posn}">
                    <thead><tr><th colspan="2">Score</th></tr></thead>
                    <tbody>
                        <tr><td>n/a</td><td style="background:${defaultBackground}"></td></tr>
                        ${rows}
                    </tbody>
                </table>
            `;
      widget.control.element[0].insertAdjacentHTML("beforeend", table);
    }

    function onShapeCreated(e) {
      const layerOpts = opts.mapLayers.filter((l) => l.name === e.layer.options.name)[0];
      const area = getShapeArea(layerOpts, e.shape);
      if (!area) {
        return;
      }

      const feature = e.shape.dataItem.properties[layerOpts.datasetNameField];

      let shapeStyle = {};

      // Handle a callback to set the shape style
      if (typeof layerOpts.areas[area] === "function") {
        shapeStyle = layerOpts.areas[area](feature);
      }

      // Handle a hard-coded shape style object
      if (typeof layerOpts.areas[area] === "object") {
        shapeStyle = layerOpts.areas[area];
      }

      $.extend(true, e.shape.options, layerOpts.defaultAreaStyle, shapeStyle);
      shapeStyles[feature] = {
        ...e.shape.options,
      };
    }

    function getShapeArea(layerOpts, shape) {
      if (shape.dataItem.properties.group) {
        return shape.dataItem.properties.area;
      }

      const location = shape.dataItem.properties[layerOpts.datasetNameField];
      const show = layerOpts.show.filter((s) => s.name === location)[0];
      if (show.length === 0) {
        console.warn(location + " does not exist in the dataset");
        return null;
      }

      return show.area;
    }

    function onShapeFeatureCreated(e) {
      const layerOpts = opts.mapLayers.filter((l) => l.name === e.layer.options.name)[0];
      const feature = e.dataItem.properties[layerOpts.datasetNameField];
      const area = Convert.extractFromArray(layerOpts.show, "area", (s) => s.name === feature)[0];
      const members = Convert.extractFromArray(layerOpts.show, "name", (s) => s.area === area);
      const shapeValue =
        typeof layerOpts.getValue === "function" ? layerOpts.getValue(feature) : null;
      const data = {
        ...e.properties,
        _NAME: feature,
        _AREA: area,
        _MEMBERS: members.join(", "),
        _VALUE: shapeValue ? shapeValue : "n/a",
      };

      let template;
      if (area) {
        // Default area tooltip shows the feature's name and its associated value
        template =
          layerOpts.areas && layerOpts.areas[area] && layerOpts.areas[area].label ?
            layerOpts.areas[area].label :
            "#: _NAME # (#: _VALUE #)";
      } else {
        template = "#: _NAME #";
      }

      e.group.options.tooltip = {
        content: kendo.template(template)(data),
        position: "cursor",
        offset: 10,
        width: 80,
        stroke: 1,
      };
    }

    function onShapeMouseEnter(e) {
      const layerOpts = opts.mapLayers.filter((l) => l.name === e.layer.options.name)[0];
      if (layerOpts.hoverAreaStyle) {
        const feature = e.shape.dataItem.properties[layerOpts.datasetNameField];
        const style = $.extend(true, {}, shapeStyles[feature], layerOpts.hoverAreaStyle);
        setShapeStyle(e.shape, style);
      }
    }

    function onShapeMouseLeave(e) {
      const layerOpts = opts.mapLayers.filter((l) => l.name === e.layer.options.name)[0];
      if (layerOpts.hoverAreaStyle) {
        const feature = e.shape.dataItem.properties[layerOpts.datasetNameField];
        const style = $.extend(true, {}, layerOpts.defaultAreaStyle, shapeStyles[feature]);
        setShapeStyle(e.shape, style);
      }
    }

    function onShapeClick(e) {
      const layerOpts = opts.mapLayers.filter((l) => l.name === e.layer.options.name)[0];
      const item = e.shape.dataItem;
      if (typeof layerOpts.onClick === "function" && item) {
        layerOpts.onClick(item.properties, getShapeValue(layerOpts, item));
      }
    }

    function getShapeValue(layerOpts, item) {
      // Grouped geo features need to provide a custom meethod to return their value
      if (item.properties.group && typeof item.properties.getGroupValue === "function") {
        return item.properties.getGroupValue();
      }

      const feature = item.properties[layerOpts.datasetNameField];
      if (typeof layerOpts.getValue === "function") {
        return layerOpts.getValue(feature);
      }

      return null;
    }

    function setShapeStyle(shape, style) {
      shape.fill(
        style && style.fill && style.fill.color ? style.fill.color : "gray",
        style && style.fill && style.fill.opacity ? style.fill.opacity : 1
      );
    }

    function onMarkerCreated(e) {
      const layerOpts = opts.mapLayers.filter((l) => l.name === e.layer.options.name)[0];
      e.marker.options.shape = layerOpts.shape || "pinTarget";
      e.marker.options.tooltip = {
        offset: -40,
        stroke: 1,
      };
    }
  };

  /**
   * Creates a new dropdown list widget and populates it with the given data.
   * Expects a DOM input element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {array} data The widget's datasource
   * @param {string} valueProp Datasource property to use for each list item's form value
   * @param {string} [textProp] Datasource property to use for each list item's displayed text
   * @param {string} [placeholder] Placeholder value
   * @param {boolean} [isPageFilter] True if this widget is a page filter
   * @param {function} [onChange] Method to call when the widget value changes
   * @param {string} [popupContainer] CSS selector of the element to append the popup containers to.
   * @param {boolean} [suppressFilterEvents] When true updates to this widget will not generate filterChanged events.
   * @param {boolean} [omitFromFilters] When true the dropdown will not add its value to the page filters.
   * @param {boolean} [multiVar] When true the dropdown is given a list of variables to choose from.
   *
   * @returns {object}
   */
  this.addDropdownFromData = function (
    name,
    data,
    valueProp,
    textProp,
    placeholder,
    isPageFilter,
    onChange,
    popupContainer,
    suppressFilterEvents,
    omitFromFilters,
    multiVar
  ) {
    const widgetType = isPageFilter ? "static" : "disposable";
    const base = $("#" + name);
    const clone = data.slice();

    if (base.length === 0) {
      console.error("Cannot find an HTML element with id ", name);
      return;
    }

    if (placeholder) {
      const item = {};
      item[valueProp] = multiVar ? "" : 0;
      item[textProp] = placeholder;
      clone.unshift(item);
    }

    if (isPageFilter) {
      this.filters.ready[name] = false;
    }

    const control = base
      .kendoDropDownList({
        dataValueField: valueProp,
        dataTextField: textProp || valueProp,
        autoWidth: true,
        valuePrimitive: true,
        dataSource: {
          data: clone,
        },
        dataBound: function () {
          checkAllFiltersLoaded(name, self.filters.suppressAllFilterEvents || suppressFilterEvents);
        },
        popup: {
          appendTo: $(popupContainer),
        },
        change: function (e) {
          let triggerFilterChanged = true;
          localStorage.setItem("widget-" + name, e.sender.value());
          if (typeof onChange === "function") {
            triggerFilterChanged = onChange.call(self, e.sender) !== false;
          }
          if (!self.filters.suppressAllFilterEvents && !suppressFilterEvents && triggerFilterChanged && self.filters.loaded) {
            self._triggerFilterEvents(name);
          }
        },
      })
      .data("kendoDropDownList");

    // In a multivar dropdown the value of the control is the name of the 'boolean' variable to test
    const filter = multiVar ? '#value#=1' : `{${valueProp}}="#value#"`;

    return (this.widgets[widgetType][name] = {
      control: control,
      filter: omitFromFilters ? null : filter,
    });
  };

  /**
   * Creates a new Kendo UI dropdown-style widget and populates it with all the distinct values of the given survey variable.
   * This is the common factory method for addDropdownFromSurveyVar and addComboBoxFromSurveyVar.
   *
   * @param {string} widgetName The name of the Kendo widget to create (e.g., 'kendoDropDownList', 'kendoComboBox').
   * @param {object} widgetOptions The widget-specific configuration options.
   * @param {object} project Project to use
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {string} varName Name of the survey variable to use
   * @param {boolean} [isPageFilter] True if this widget is a page filter
   * @param {function} [onChange] Method to call when the widget value changes
   * @param {string} [placeholder] Placeholder text
   * @param {string} [groupVar] Name(s) of the variable to group the results by
   * @param {string} [popupContainer] CSS selector of the element to append the popup containers to.
   * @param {array} [order] Order of the dropdown items.
   * @param {boolean} [suppressFilterEvents] When true updates to this widget will not generate filterChanged events.
   * @param {boolean} [omitFromFilters] When true the dropdown will not add its value to the page filters.
   *
   * @returns {object}
   */
  this._addKendoWidgetFromSurveyVar = function (
    widgetName,
    widgetOptions,
    project,
    name,
    varName,
    isPageFilter,
    onChange,
    placeholder,
    groupVar,
    popupContainer,
    order,
    suppressFilterEvents,
    omitFromFilters,
    filter
  ) {
    popupContainer = popupContainer || "body";

    const self = this;
    const widgetType = isPageFilter ? "static" : "disposable";
    const base = $("#" + name);

    const projectVar = server.getProjectVar(project, varName);

    if (typeof projectVar === "undefined") {
      // This might be because we're not allowed to see this variable's category
      return;
    }

    // getProjectVar will fetch a name independent of case so make sure varName is the actual name of the var
    varName = projectVar.name;

    if (isPageFilter) {
      this.filters.ready[name] = false;
    }

    let fetchVarName = varName;
    let sortVarName = varName;

    // If we want to group by a variable then we need to ask the API for distinct responses for *that* variable, and then
    // group the results by the given survey variable
    let group = "";
    if (groupVar) {
      fetchVarName = groupVar;
      group = "&group={" + varName + "}";
    }

    filter = filter ? "&filter=" + filter : '';

    const fetchVar = server.getProjectVar(project, fetchVarName);

    const url =
      Server.API_ENDPOINT +
      "/project/" +
      project.id +
      "/responses/distinct/" +
      fetchVar.id +
      "?sort={" +
      sortVarName +
      "}" +
      filter +
      group;

    const ds = {
      transport: {
        read: {
          dataType: "json",
          cache: false,
          url: url,
          xhrFields: {
            withCredentials: true,
          },
        },
      },
      change: function (e) {
        e.items.forEach(function (item) {
          item._value = item[fetchVar.name];

          // Replace raw boolean 0/1 text with variable metadata
          if (fetchVar.type === "bool") {
            if (item._value === 0 && fetchVar.falsevalue) {
              item._text = fetchVar.falsevalue;
              return;
            }
            if (item._value === 1 && fetchVar.truevalue) {
              item._text = fetchVar.truevalue;
              return;
            }
          }

          // Handle a null value
          if (item._value === null) {
            item._text = "n/a";
            item._value = self.NULL;
            return;
          }

          item._text = item._value;
        });
      },
      schema: {
        data: "rows",
        parse: function (response) {
          // If a specific item order is specified then apply it
          if (order) {
            response.rows = Move.inOrder(response.rows, fetchVarName, order);
          }

          for (let i = 0; i < response.rows.length; i++) {
            const row = response.rows[i];
            Object.keys(row).forEach(function (datum) {
              if (typeof row[datum] === "string" && row[datum] && !isNaN(row[datum])) {
                row[datum] = Number(row[datum]);
              }
            });
          }

          return response;
        },
      },
    };

    if (groupVar) {
      ds.group = {
        field: groupVar,
      };
    }

    // Base configuration common to both widgets
    const commonConfig = {
      dataValueField: "_value",
      dataTextField: "_text",
      dataSource: ds,
      dataBound: function () {
        checkAllFiltersLoaded(name, self.filters.suppressAllFilterEvents || suppressFilterEvents);
      },
      popup: {
        appendTo: $(popupContainer),
      },
      change: function (e) {
        let triggerFilterChanged = true;
        localStorage.setItem("widget-" + name, e.sender.value());
        if (typeof onChange === "function") {
          triggerFilterChanged = onChange.call(self, e.sender) !== false;
        }
        if (!self.filters.suppressAllFilterEvents && !suppressFilterEvents && triggerFilterChanged && self.filters.loaded) {
          self._triggerFilterEvents(name);
        }
      },
    };

    // Combine common and widget-specific options
    const finalConfig = Object.assign({}, commonConfig, widgetOptions);

    // Dynamically call the correct Kendo widget initializer
    const control = base[widgetName](finalConfig).data(widgetName);

    return (this.widgets[widgetType][name] = {
      control: control,
      filter: omitFromFilters ? null : "{" + varName + '}="#value#"',
    });
  };


  /**
   * Creates a new dropdown list widget and populates it with all the distinct values of the given survey variable.
   */
  this.addDropdownFromSurveyVar = function (...args) {
    const widgetName = "kendoDropDownList";
    // DropDownList uses 'optionLabel' instead of 'placeholder'
    const placeholder = args[5];
    const widgetOptions = {
      optionLabel: placeholder,
    };

    return this._addKendoWidgetFromSurveyVar(widgetName, widgetOptions, ...args);
  };

  /**
   * Creates a new combobox widget and populates it with all the distinct values of the given survey variable.
   */
  this.addComboBoxFromSurveyVar = function (...args) {
    const widgetName = "kendoComboBox";
    const placeholder = args[5];
    const widgetOptions = {
      placeholder: placeholder,
      filter: "contains",
    };

    return this._addKendoWidgetFromSurveyVar(widgetName, widgetOptions, ...args);
  };

  /**
   * Creates a new dropdown widget and populates it with all the distinct values from all the survey variables in the given variable category.
   * Expects a DOM input element with an id that matches the given name to use as the base for the widget.
   *
   * NOTE: Each variable in the category is expected to be a nullable boolean value.
   *
   * @param {object} project Project to use
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {string} categoryName Name of the survey variable category to use
   * @param {string} [placeholder] Placeholder value
   * @param {boolean} [isPageFilter] True if this widget is a page filter
   * @param {function} [onChange] Method to call when the widget value changes
   * @param {string} [popupContainer] CSS selector of the element to append the popup containers to.
   * @param {boolean} [suppressFilterEvents] When true updates to this widget will not generate filterChanged events.
   * @param {boolean} [omitFromFilters] When true the dropdown will not add its value to the page filters.
   * @see {@link https://docs.telerik.com/kendo-ui/api/javascript/ui/dropdownlist} for options.
   *
   * @returns {object}
   */
  this.addDropdownFromSurveyVarCategory = function (
    project,
    name,
    categoryName,
    placeholder,
    isPageFilter,
    onChange,
    popupContainer,
    suppressFilterEvents,
    omitFromFilters,
  ) {
    const items = [];

    const vars = this.getProjectVarsByCategory(project, categoryName, ["name", "label"]);

    // Add all the category variables
    items.push(...vars);

    return client.addDropdownFromData(
      name,
      items,
      "name",
      "label",
      placeholder,
      isPageFilter,
      onChange,
      popupContainer,
      suppressFilterEvents,
      omitFromFilters,
      true
    );
  };

  /**
   * Creates a new dropdown list widget and populates it with a ist of ranges based on the given divisions.
   * Expects a DOM input element with an id that matches the given name to use as the base for the widget.
   *
   * @param {object} project Project to use
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {string} varName Name of the survey variable to apply the given ranges to
   * @param {array} divisions Divisions that separate each range
   * @param {bool} extend If true the generated ranges include 'catch-all' ranges before and after the specified divisions
   * @param {boolean} [isPageFilter] True if this widget is a page filter
   * @param {function} [onChange] Method to call when the widget value changes
   * @param {string} [placeholder] Placeholder text
   * @param {string} [popupContainer] CSS selector of the element to append the (hidden) popup containers to.
   * @param {boolean} [suppressFilterEvents] When true updates to this widget will not generate filterChanged events.
   * @param {boolean} [omitFromFilters] When true the dropdown will not add its value to the page filters.
   *
   * @returns {object}
   */
  this.addDropdownFromDivisions = function (
    project,
    name,
    varName,
    divisions,
    extend,
    isPageFilter,
    onChange,
    placeholder,
    popupContainer,
    suppressFilterEvents,
    omitFromFilters
  ) {
    popupContainer = popupContainer || "body";
    const widgetType = isPageFilter ? "static" : "disposable";
    const base = $("#" + name);

    if (server.getProjectVar(project, varName) === "undefined") {
      // This might be because we're not allowed to see this variable's category
      return;
    }

    // This doesn't depend on loading any remote data so if it's a filter it's always ready
    if (isPageFilter) {
      this.filters.ready[name] = true;
    }

    if (divisions.length < (extend ? 1 : 2)) {
      console.error("Not enough divisions");
      return;
    }

    if (extend) {
      divisions.push(Number.MAX_SAFE_INTEGER);
      if (divisions[0] !== 0) {
        divisions.unshift(0);
      }
    }

    const data = [];
    for (let c = 0; c < divisions.length - 1; c++) {
      data.push({
        value: divisions[c] + "," + divisions[c + 1],
        text: getDivisionText(divisions[c], divisions[c + 1]),
      });
    }

    const ds = {
      data: data,
    };

    const control = base
      .kendoDropDownList({
        dataValueField: "value",
        dataTextField: "text",
        optionLabel: placeholder,
        dataSource: ds,
        dataBound: function () {
          checkAllFiltersLoaded(name, self.filters.suppressAllFilterEvents || suppressFilterEvents);
        },
        popup: {
          appendTo: $(popupContainer),
        },
        change: function (e) {
          let triggerFilterChanged = true;
          localStorage.setItem("widget-" + name, e.sender.value());
          if (typeof onChange === "function") {
            triggerFilterChanged = onChange.call(self, e.sender) !== false;
          }
          if (!self.filters.suppressAllFilterEvents && !suppressFilterEvents && triggerFilterChanged && self.filters.loaded) {
            self._triggerFilterEvents(name);
          }
        },
      })
      .data("kendoDropDownList");

    return (this.widgets[widgetType][name] = {
      control: control,
      filter: function () {
        if (omitFromFilters) {
          return null;
        }

        const v = control.value();
        if (!v) {
          return false;
        }

        // Value format is min,max
        vals = v.split(",");

        return "{" + varName + "} >= " + vals[0] + " AND {" + varName + "} < " + vals[1];
      },
    });

    function getDivisionText(a, b) {
      if (a === 0) {
        return "Less than " + b;
      }
      if (b === Number.MAX_SAFE_INTEGER) {
        return a + " or more";
      }

      return a + " - " + b;
    }
  };

  /**
   * Creates a new dropdown autocomplete list widget and populates it with all the distinct values of the given survey variable.
   * Expects a DOM input element with an id that matches the given name to use as the base for the widget.
   *
   * @param {object} project Project to use
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {string} varName Name of the survey variable to use
   * @param {boolean} [isPageFilter] True if this widget is a page filter
   * @param {function} [onChange] Method to call when the widget value changes
   * @param {number} height Height of the dropdown
   * @param {string} [placeholder] Placeholder value
   * @param {string} [groupVar] Name(s) of the variable to group the results by
   * @param {string} [popupContainer] CSS selector of the element to append the (hidden) popup containers to.
   * @param {boolean} [suppressFilterEvents] When true updates to this widget will not generate filterChanged events.
   * @param {boolean} [omitFromFilters] When true the dropdown will not add its value to the page filters.
   *
   * @returns {object}
   */
  this.addAutocompleteDropdownFromSurveyVar = function (
    project,
    name,
    varName,
    isPageFilter,
    onChange,
    height,
    placeholder,
    groupVar,
    popupContainer,
    suppressFilterEvents,
    omitFromFilters
  ) {
    popupContainer = popupContainer || "body";
    const widgetType = isPageFilter ? "static" : "disposable";
    const base = $("#" + name);

    if (server.getProjectVar(project, varName) === "undefined") {
      // This might be because we're not allowed to see this variable's category
      return;
    }

    if (isPageFilter) {
      this.filters.ready[name] = false;
    }

    let fetchVar = varName;
    let sortVar = varName;

    // If we want to group by a variable then we need to ask the API for distinct responses for *that* variable, and then
    // group the results by the given survey variable
    let group = "";
    if (groupVar) {
      fetchVar = groupVar;
      group = "&group={" + varName + "}";
    }

    const ds = {
      transport: {
        read: {
          dataType: "json",
          cache: false,
          url: Server.API_ENDPOINT +
            "/project/" +
            project.id +
            "/responses/distinct/" +
            server.getProjectVar(project, fetchVar).id +
            "?sort={" +
            sortVar +
            "}" +
            group,
          xhrFields: {
            withCredentials: true,
          },
        },
      },
      schema: {
        data: "rows",
        parse: server.parseResponse,
      },
    };

    if (groupVar) {
      ds.group = {
        field: groupVar,
      };
    }

    const control = base
      .kendoAutoComplete({
        dataTextField: varName,
        placeholder: placeholder,
        height: height,
        dataSource: ds,
        dataBound: function () {
          checkAllFiltersLoaded(name, self.filters.suppressAllFilterEvents || suppressFilterEvents);
        },
        popup: {
          appendTo: $(popupContainer),
        },
        change: function (e) {
          let triggerFilterChanged = true;
          localStorage.setItem("widget-" + name, e.sender.value());
          if (typeof onChange === "function") {
            triggerFilterChanged = onChange.call(self, e.sender) !== false;
          }
          if (!self.filters.suppressAllFilterEvents && !suppressFilterEvents && triggerFilterChanged && self.filters.loaded) {
            self._triggerFilterEvents(name);
          }
        },
      })
      .data("kendoAutoComplete");

    return (this.widgets[widgetType][name] = {
      control: control,
      filter: omitFromFilters ? null : "{" + varName + '}="#value#"',
    });
  };

  /**
   * Creates a new dropdowntree widget and populates it with the given data.
   * Expects a DOM input element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {array} data Data to display
   * @param {string} valueProp Property to use as the list value
   * @param {string} [textProp] Property to use as the list text
   * @param {boolean} [isPageFilter] True if this widget is a page filter
   * @param {object} [options] Custom widget options
   * @param {function} [onChange] Method to call when the widget value changes
   * @param {boolean} [suppressFilterEvents] When true updates to this widget will not generate filterChanged events.
   * @param {boolean} [omitFromFilters] When true the dropdown will not add its value to the page filters.
   *
   * @returns {object}
   */
  this.addDropdownTreeFromData = function (
    name,
    data,
    valueProp,
    textProp,
    isPageFilter,
    options,
    onChange,
    suppressFilterEvents,
    omitFromFilters
  ) {
    const widgetType = isPageFilter ? "static" : "disposable";
    const base = $("#" + name);
    const clone = data.slice();

    if (isPageFilter) {
      this.filters.ready[name] = false;
    }

    const opts = $.extend({
      dataValueField: valueProp,
      dataTextField: textProp || valueProp,
      valuePrimitive: true,
      dataSource: {
        data: clone,
      },
      dataBound: function (e) {
        checkAllFiltersLoaded(name, self.filters.suppressAllFilterEvents || suppressFilterEvents);
      },
      popup: {
        appendTo: $(popupContainer),
      },
      change: function (e) {
        let triggerFilterChanged = true;
        localStorage.setItem("widget-" + name, e.sender.value());
        if (typeof onChange === "function") {
          triggerFilterChanged = onChange.call(self, e.sender) !== false;
        }
        if (!self.filters.suppressAllFilterEvents && !suppressFilterEvents && triggerFilterChanged && self.filters.loaded) {
          self._triggerFilterEvents(name);
        }
      },
    },
      options
    );

    const control = base.kendoDropDownTree(opts).data("kendoDropDownTree");

    return (this.widgets[widgetType][name] = {
      control: control,
      filter: function () {
        return omitFromFilters ? null : this.control.value() || "1";
      },
    });
  };

  /**
   * Creates a new multi-select widget and populates it with the given data.
   * Expects a DOM input element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {array} data Data to display
   * @param {string} valueProp Property to use as the list value
   * @param {string} [textProp] Property to use as the list text
   * @param {string} [placeholder] Placeholder value
   * @param {boolean} [isPageFilter] True if this widget is a page filter
   * @param {object} [options] Custom widget options
   * @param {function} [onChange] Method to call when the widget value changes
   * @param {boolean} [suppressFilterEvents] When true updates to this widget will not generate filterChanged events.
   * 
   * @see {@link https://docs.telerik.com/kendo-ui/api/javascript/ui/multiselect} for options.
   *
   * @returns {object}
   */
  this.addMultiSelectFromData = function (
    name,
    data,
    valueProp,
    textProp,
    placeholder,
    isPageFilter,
    options,
    onChange,
    suppressFilterEvents
  ) {
    const widgetType = isPageFilter ? "static" : "disposable";
    const base = $("#" + name);
    const clone = data.slice();

    const opts = $.extend({
      dataValueField: valueProp,
      dataTextField: textProp || valueProp,
      dataSource: {
        data: clone,
        parameterMap: client.getParameterMap(),
      },
      dataBound: function (e) {
        // Preset given values
        if (options.defaultPreset) {
          const data = e.sender.dataSource.data();
          if (data.length) {
            const presets =
              options.defaultPreset === "ALL" ?
                Array.from(Array(data.length), function (e, i) {
                  return i + 1;
                }) :
                options.defaultPreset;
            const selected = [];
            presets.forEach(function (val) {
              if (typeof data[val - 1][valueProp] !== "undefined") {
                selected.push(data[val - 1][valueProp]);
              }
            });
            e.sender.value(selected);
          }
        }
        checkAllFiltersLoaded(name, self.filters.suppressAllFilterEvents || suppressFilterEvents);
      },
      change: changeHandler,
    },
      options
    );

    if (placeholder) {
      const item = {};
      item[valueProp] = 0;
      item[textProp] = placeholder;
      clone.unshift(item);
    }

    if (isPageFilter) {
      this.filters.ready[name] = false;
    }

    const control = base.kendoMultiSelect(opts).data("kendoMultiSelect");

    return (this.widgets[widgetType][name] = {
      control: control,
      filter: options.filter,
    });

    function changeHandler(e) {
      let triggerFilterChanged = true;
      localStorage.setItem("widget-" + name, e.sender.value());
      if (typeof onChange === "function") {
        triggerFilterChanged = onChange.call(self, e.sender) !== false;
      }
      if (!self.filters.suppressAllFilterEvents && !suppressFilterEvents && triggerFilterChanged && self.filters.loaded) {
        self._triggerFilterEvents(name);
      }
    }
  };

  /**
   * Creates a new multi-select widget and populates it with all the distinct values of the given survey variable.
   * Expects a DOM input element with an id that matches the given name to use as the base for the widget.
   *
   * @param {object} project Project to use
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {string} surveyVarName Name of the survey variable to use
   * @param {string} [placeholder] Placeholder value
   * @param {boolean} [isPageFilter] True if this widget is a page filter
   * @param {object} [options] Custom widget options
   * @param {function} [onChange] Method to call when the widget value changes
   * @param {boolean} [suppressFilterEvents] When true updates to this widget will not generate filterChanged events.
   * @param {string} [groupVarName] Name(s) of the variable to group the results by
   * @param {array} [order] Order of the multiselect items.
   * 
   * @see {@link https://docs.telerik.com/kendo-ui/api/javascript/ui/multiselect} for options.
   *
   * @returns {object}
   */
  this.addMultiSelectFromSurveyVar = function (
    project,
    name,
    surveyVarName,
    placeholder,
    isPageFilter,
    options,
    onChange,
    suppressFilterEvents,
    groupVarName,
    order
  ) {

    // If we want to group by a variable then we need to ask the API for distinct responses for *that* variable, and then
    // group the results by the given survey variable

    const fetchVar = server.getProjectVar(project, groupVarName || surveyVarName);
    if (typeof fetchVar === "undefined") {
      // This might just be because we're not allowed to see this variable's category, so issue a warning and return
      console.warn('Variable', groupVarName || surveyVarName, 'in project', project.id, 'is not visible (it might be sieved)');
      return;
    }

    const group = groupVarName ? `&group={${surveyVarName}}` : "";

    const opts = $.extend({
      dataValueField: fetchVar.name,
      dataTextField: fetchVar.name + "__label",
      filter: "contains",
      firstLoad: true,
      dataSource: {
        transport: {
          read: {
            dataType: "json",
            cache: false,
            url: `${Server.API_ENDPOINT}/project/${project.id}/responses/distinct/${fetchVar.id}?filter={${fetchVar.name}}!=" "${group}&sort={${fetchVar.name}}`,
            xhrFields: {
              withCredentials: true,
            },
          },
        },
        schema: {
          data: "rows",
          parse: function (response) {
            // If a specific item order is specified than apply it
            if (order) {
              response.rows = Move.inOrder(response.rows, fetchVar.name, order);
            }

            for (let i = 0; i < response.rows.length; i++) {
              const row = response.rows[i];

              // Add data labels
              row[fetchVar.name + "__label"] = client.getValueLabel(row[fetchVar.name]);

              // Convert numeric strings to numbers
              Object.keys(row).forEach(function (datum) {
                if (typeof row[datum] === "string" && row[datum] && !isNaN(row[datum])) {
                  row[datum] = Number(row[datum]);
                }
              });
            }

            return response;
          },
        },
      },

      dataBound: function (e) {
        // Preset given values
        if (opts.firstLoad && opts.defaultPreset) {
          const data = e.sender.dataSource.data();
          if (data.length) {
            const presets =
              opts.defaultPreset === "ALL" ?
                Array.from(Array(data.length), function (e, i) {
                  return i + 1;
                }) :
                opts.defaultPreset;
            const selected = [];
            presets.forEach(function (val) {
              if (typeof data[val - 1][fetchVar.name] !== "undefined") {
                selected.push(data[val - 1][fetchVar.name]);
              }
            });
            e.sender.value(selected);
          }
          opts.firstLoad = false;
        }
        checkAllFiltersLoaded(name, self.filters.suppressAllFilterEvents || suppressFilterEvents);
      },

      change: function (e) {
        let triggerFilterChanged = true;
        localStorage.setItem("widget-" + name, e.sender.value());
        if (typeof onChange === "function") {
          triggerFilterChanged = onChange.call(self, e.sender) !== false;
        }
        if (!self.filters.suppressAllFilterEvents && !suppressFilterEvents && triggerFilterChanged && self.filters.loaded) {
          self._triggerFilterEvents(name);
        }
      },
    },
      options
    );

    const widgetType = isPageFilter ? "static" : "disposable";
    const base = $("#" + name);

    if (isPageFilter) {
      this.filters.ready[name] = false;
    }

    const control = base.kendoMultiSelect(opts).data("kendoMultiSelect");

    return (this.widgets[widgetType][name] = {
      control: control,
      filter: function () {
        const values = this.control.value();

        // If we have no (or ALL) values then return false (which will omit the widget from the filter list)
        const haveAllValues = values.length === this.control.dataSource.data().length;
        const haveAValue = values.some((el) => el);
        if (haveAllValues || !haveAValue) {
          return false;
        }

        const haveANull = values.some((el) => el === null);
        if (haveANull) {
          return `({${fetchVar.name}} IN("${values.join('","')}") OR ${fetchVar.name} IS NULL)`;
        }
        return `{${fetchVar.name}} IN("${values.join('","')}")`;
      },
    });
  };

  /**
   * Creates a new multi-select widget and populates it with all the distinct values from all the survey variables in the given variable category.
   * Expects a DOM input element with an id that matches the given name to use as the base for the widget.
   *
   * NOTE: Each variable in the category is expected to be a nullable boolean value
   *
   * @param {object} project Project to use
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {string} categoryName Name of the survey variable category to use
   * @param {string} [placeholder] Placeholder value
   * @param {boolean} [isPageFilter] True if this widget is a page filter
   * @param {object} [options] Custom widget options
   * @param {function} [onChange] Method to call when the widget value changes
   * 
   * @see {@link https://docs.telerik.com/kendo-ui/api/javascript/ui/multiselect} for options.
   *
   * @returns {object}
   */
  this.addMultiSelectFromSurveyVarCategory = function (
    project,
    name,
    categoryName,
    placeholder,
    isPageFilter,
    options = {},
    onChange
  ) {
    const items = [];

    const vars = this.getProjectVarsByCategory(project, categoryName, ["name", "label"]);

    // Add a null option if requested
    if (options.addNullOption) {
      items.push({
        name: null,
        label: "n/a",
      });
    }

    // Add all the category variables
    items.push(...vars);

    options.filter = function () {
      const selected = this.control.value();
      const filter = selected.reduce((acc, v) => {
        if (v === null) {
          acc.push(
            vars
              .reduce((acc, v) => {
                acc.push(`{${v.name}} IS NULL`);
                return acc;
              }, [])
              .join(" AND ")
          );
        } else {
          acc.push(`{${v}}=1`);
        }
        return acc;
      }, []);

      // Return a catch-all filter if no variable filter was selected
      if (filter.length === 0) {
        filter.push("1");
      }

      return "(" + filter.join(" OR ") + ")";
    };

    return client.addMultiSelectFromData(
      name,
      items,
      "name",
      "label",
      placeholder,
      isPageFilter,
      options,
      onChange
    );
  };

  /**
   * Adds a video player.
   * Expects a DOM element with an id that matches the given name to use as the base for the player.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {string} src URL of the video file to play
   * @param {object} options Options to control the appearance and behaviour of the player.
   *
   * The following options are supported:
   *
   * clickAnyWhereToPlay (bool): When true the player can be toggled to play/pause by clicking anywhere in the video window.
   *
   * features (array): Describes the desired playback features - one or more of the following:
   *
   * 'play' - play/pause toggle button
   * 'time' - current time in mm::ss
   * 'volume' - volume control
   * 'skip-start' - button to skip to the start of the video
   * 'skip-backwards' - button to jump back 15 seconds
   * 'skip-forwards' - button to jump forwards 15 seconds
   * 'skip-end' - button to skip to the end of the video
   *
   * poster (string): path to an image file to show before the video is played (path is subject to CORS policy).
   *
   * fadeTo (string): either a CSS color value or a path to an image file (path is subject to CORS policy). The color or image
   * is shown after the end of the video.
   *
   * captions (string): path to a VTT track file for text captions to be displayed while the video is playing (path is subject to CORS policy).
   *
   * close (bool): When true a close button is displayed to allow the user to hide the video element.
   *
   * @returns {object} Video player instance.
   */
  this.addVideo = function (container, src, options) {
    const containerEl = document.getElementById(container);
    if (!containerEl) {
      console.error("Cannot find container", containerEl);
      return;
    }
    player = new VideoPlayer(containerEl, options);
    player.load(src);

    return player;
  };

  /**
   * Adds a Bar widget.
   * Expects a DOM element with an id that matches the given name to use as the base for the widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} options Options to control the appearance and behaviour of the Bar:
   *
   * | Option | Type | Default | Comment |
   * | --- | --- | --- | --- |
   * | animate | boolean | false | When true the bar expands out to its value. |
   * | reverse | boolean | false | When false the bar grows to the right; when true to the left. |
   * | color | string | 'grey' | Color of the value text. |
   * | value | number | 0 | Value of the bar. |
   * | count | number | 0 | A 'count' associated with the value of the bar e.g. a survey base that is shown in the tooltip. |
   * | format | string | 'p1' | The Kendo format string used to format the bar's value for display - @see {@link https://docs.telerik.com/kendo-ui/globalization/intl/numberformatting}. |
   * | height | number | 22 | The height of the bar in pixels. |
   * | category | object | {} | Describes the category associated with the Bar's value. Supports the following subkeys: color (string): color of the category text. (default: 'grey'); position (string): position of the category text relative to the bar - one of 'outside-top', 'outside-bottom', 'outside-end', 'inside-end', 'inside-start'. (default: 'outside-top'); value (strong): the category text (default: null) |
   * | label | object | {} | Describes the label used to display the Bar's value. Supports the following subkey: color (string): color of the value's label text. (default: 'white') |
   *
   * @returns {object} Bar instance.
   */
  this.addBar = function (container, options) {
    const containerEl = document.getElementById(container);
    if (!containerEl) {
      console.error("Cannot find container", containerEl);
      return;
    }

    return new Bar(containerEl, options);
  };

  /**
   * Summarises the selected values of a widget as a short piece of text.
   *
   * @param {object} ctrl A client widget's control
   *
   * @return {string|number} Text summary of the widget value.
   */
  this.summariseControlValue = function (ctrl) {
    const text = typeof ctrl.text === "function" ? ctrl.text() : null;
    const value = text || ctrl.value();

    if (!Array.isArray(value)) {
      return value;
    }

    const data = ctrl.dataSource && ctrl.dataSource.data();
    if (data && data.length && data.length === value.length) {
      return "All";
    }

    switch (value.length) {
      case 0:
        return "None";
      case 1:
        return ctrl.ns === ".kendoMultiSelect" ? getLabel(ctrl.dataItems(), value[0]) : value[0];
      default:
        return value.length + " options chosen";
    }

    function getLabel(dataItems, varName) {
      const v = dataItems.filter(function (item) {
        return item.name === varName;
      });
      return v.length ? v[0].label : varName;
    }
  };

  /**
   * Returns the given widget.
   *
   * @param {string} name Name of the widget to retrieve
   *
   * @return {jQuery}
   */
  this.getWidget = function (name) {
    const wgt = this.widgets.static[name] || this.widgets.disposable[name];
    if (!wgt) {
      console.debug(name + " widget not found");
      return null;
    }

    return wgt;
  };

  /**
   * Returns the underlying Kendo control for the given widget.
   *
   * @param {string} name
   *
   * @return {jQuery}
   */
  this.getControl = function (name) {
    const wgt = this.getWidget(name);

    let ctrl = wgt ? wgt.control : null;

    if (!ctrl) {
      return ctrl;
    }

    return ctrl;
  };

  /**
   * Sets the underlying Kendo control for the given widget.
   *
   * @param {string} name
   * @param {jQuery} ctrl
   */
  this.setControl = function (name, ctrl) {
    const wgt = this.getWidget(name);
    if (!wgt) {
      console.debug(name + " widget not found");
      return;
    }
    wgt.control = ctrl;
  };

  /**
   * Updates the local data in a widget.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} data New data to display
   */
  this.setWidgetData = function (name, data) {
    const control = this.getControl(name);
    if (control) {
      control.data = data;
      control.refresh();
    }
  };

  /**
   * Sets a widget's options.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {string} value Option
   * @param {*} value Option value
   */
  this.setOption = function (name, option, value) {
    const control = this.getControl(name);
    if (control) {
      control.data = data;
      control.refresh();
    }
  };

  /**
   * Set a widget's dataSource to a new one containing the given data.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {object} data New data to display
   */
  this.setWidgetDataSource = function (name, data) {
    const control = this.getControl(name);
    if (control) {
      let ds;
      switch (control.options.name) {
        case "TreeList":
          ds = new kendo.data.TreeListDataSource({
            data: data,
            parameterMap: client.getParameterMap(),
          });
          break;
        case "TreeView":
          ds = new kendo.data.HierarchicalDataSource({
            data: data,
            parameterMap: client.getParameterMap(),
          });
          break;
        default:
          ds = new kendo.data.DataSource({
            transport: {
              read: function (operation) {
                operation.success(operation.data.data || []);
              },
            },
            parameterMap: client.getParameterMap(),
          });
      }

      // If this is a page filter we need to reset the 'filters-loaded' state
      if (self.isPageFilter(name)) {
        self.filters.loaded = false;
      }

      ds.read({
        data: data,
      });
      control.setDataSource(ds);
    }
  };

  /**
   * Updates the URL used to provide the widget's data.
   *
   * @param {string} name Name of the widget (must be unique on a page)
   * @param {string} url New remote URL
   * @param {object} [query] Query parameters
   * @param {bool} [cache]  When true (=default) the data from the server is cached.
   */
  this.setWidgetURL = function (name, url, query, cache) {
    const control = this.getControl(name);
    if (control) {
      if (cache === false) {
        query = query || {};
        query["_"] = new Date().getTime();
      }
      const queryString = query ? server.formatQuery(query) : "";
      control.dataSource.options.transport.read.url = url + "?" + queryString;
      self.setPageFilterNotReady(name);
      if (control.options.dataSource && control.options.dataSource.serverPaging) {
        control.dataSource.page(1);
      } else {
        control.dataSource.read();
      }
    }
  };

  /**
   * Return true if the given widget is defined as a page filter.
   *
   * @return {boolean}
   */
  this.isPageFilter = function (name) {
    return typeof self.filters.ready[name] !== "undefined";
  };

  /**
   * Sets the given page filter state to be not ready.
   *
   * @return {boolean}
   */
  this.setPageFilterNotReady = function (name) {
    if (this.isPageFilter(name)) {
      this.filters.ready[name] = false;
      this.filters.loaded = false;
    }
  };

  /**
   * Returns a map containing current filter values.
   *
   * @param {array} [widgets] Widget filter values to return
   *
   * @returns {object}
   */
  this.getPageFilters = function (widgets) {
    const self = this;

    widgets = widgets ? Convert.simpleArrayToMap(widgets, true) : this.filters.ready;

    const rx = /(#(\w+)\|?(\w+)?#)/g;
    const extractVar = /{[^\{]+}/;

    let filters = {};

    for (let name in widgets) {
      const wgt = this.widgets.static[name] || this.widgets.disposable[name];
      if (!wgt) {
        console.error("Widget " + name + " does not exist");
        continue;
      }

      if (!wgt.filter) {
        continue;
      }

      // Deal with a filter function
      if (typeof wgt.filter === "function") {
        const result = wgt.filter.call(wgt);
        if (result !== false) {
          filters[name] = result;
        }
        continue;
      }

      // Deal with a missing widget control
      if (!wgt.control) {
        console.error(
          "Widget " +
          name +
          " does not have an associated HTML control  - do you need to add its container div to the HTML?"
        );
        continue;
      }

      let matches;
      let filter = wgt.filter;
      let include = true;
      while ((matches = rx.exec(wgt.filter)) !== null) {
        let val;

        if (matches[3]) {
          // Replace placeholder with the result of the control child's specified method
          val = wgt.control[matches[3]].control[matches[2]]();
        } else {
          // Replace placeholder with the result of the control's specified method
          val = wgt.control[matches[2]]();
        }

        // Don't include filters whose value is 'everything'
        if (val === config.ALL_PLACEHOLDER || val === "") {
          include = false;
          break;
        }

        // Run the value through any custom formatter
        if (wgt.formatter) {
          val = wgt.formatter(val);
        }

        // Handle null values
        if (val === self.NULL) {
          const varNameMatch = extractVar.exec(wgt.filter);
          if (varNameMatch && varNameMatch.length) {
            filter = varNameMatch[0] + " IS NULL";
            break;
          }
        }

        // Inject it into the filter string
        filter = filter.replace(matches[1], val);
      }

      rx.lastIndex = 0;

      if (include) {
        filters[name] = filter;
      }
    }

    // Run any filter hooks to modify the filters before they are returned to the caller
    if (Array.isArray(self.filters.hooks)) {
      self.filters.hooks.forEach(h => {
        filters = h(filters) || filters;
      });
    }

    return filters;
  };

  /**
   * Adds a new filter hook method to the list.
   *
   * @param {string} name Name of the filter hook
   * @param {function} hook Hook function to be called when a filter is to be applied
   */
  this.addFiltersHook = function (name, fn) {
    if (self.filters.hooks.some(f => f.toString() === fn.toString())) {
      return;
    }
    self.filters.hooks.push(fn);
  };

  /**
   * Sets whether ot not to suppress all filter events.
   *
   * @param {boolean} suppress When true all filter events are suppressed.
   *
   * @returns {object}
   */
  this.suppressAllFilterEvents = function (suppress) {
    self.filters.suppressAllFilterEvents = suppress;
  };

  /**
   * Returns the current set of page filters as a string.
   *
   * @param {array} [widgets] Widget filter values to return
   *
   * @returns {object}
   */
  this.getPageFiltersAsString = function (widgets) {
    return this.convertFiltersToString(this.getPageFilters(widgets));
  };

  /**
   * Converts the given set of filters into a string.
   *
   * @param {object} filterMap Map of filter values to be converted.
   *
   * @returns {object}
   */
  this.convertFiltersToString = function (filterMap) {
    const filters = [];
    Object.keys(filterMap).forEach(function (key) {
      if (filterMap[key]) {
        filters.push(filterMap[key]);
      }
    });

    return filters.join(" AND ");
  };

  /**
   * Calls the given function whenever a filter changes. Also calls it immediately if all filters are in the ready state.
   *
   * @param {function} callback Callback function.
   */
  this.onFilterChanged = function (callback) {
    $(window).on("filterChanged", callback.bind(this));
    if (this.filters.loaded) {
      callback.call(this);
    }
  };

  /**
   * Calls the given function whenever a filter changes. Also calls it immediately if all filters are in the ready state.
   * Unlike onFilterChanged this is not deactivated when the page view changes.
   *
   * @param {function} callback Callback function.
   */
  this.onPageFilterChanged = function (callback) {
    $(window).on("filterChangedPersist", callback.bind(this));
    if (this.filters.loaded) {
      callback.call(this);
    }
  };

  /**
   * Converts the given set of Kendo filters to a string that is compatible with the data API.
   *
   * @param {object} f Kendo filter to convert
   * @param {string} [nullValue] Optional string value to convert to an 'IS NULL' test
   *
   * @returns {string} An API filter that can be added to a filter collection
   */
  this.convertKendoFilters = function (filter, nullValue) {
    if (!filter) {
      return "";
    }

    return (
      "(" +
      filter.filters
        .reduce((acc, f) => {
          acc.push(getFilter(f));
          return acc;
        }, [])
        .join(` ${filter.logic} `) +
      ")"
    );

    function getFilter(f) {
      let isNull = false;
      if (typeof f.value === "string") {
        isNull = f.value.toLowerCase() === nullValue.toLowerCase();
      }

      const q = typeof f === "number" ? "" : '"';
      switch (f.operator) {
        case "eq":
          return isNull ? `{${f.field}} IS NULL` : `{${f.field}}=${q}${f.value}${q}`;
        case "neq":
          return isNull ? `{${f.field}} IS NOT NULL` : `{${f.field}}!=${q}${f.value}${q}`;
        case "gt":
          return `{${f.field}}>${q}${f.value}${q}`;
        case "gte":
          return `{${f.field}}>=${q}${f.value}${q}`;
        case "lt":
          return `{${f.field}}<${q}${f.value}${q}`;
        case "lte":
          return `{${f.field}}<=${q}${f.value}${q}`;
        case "isnull":
          return `{${f.field}} IS NULL`;
        case "isnotnull":
          return `{${f.field}} IS NOT NULL`;
        case "isempty":
          return `{${f.field}}=""`;
        case "isnotempty":
          return `{${f.field}}!=""`;
        case "contains":
          return `{${f.field}} LIKE "%${f.value}%"`;
        case "doesnotcontain":
          return `{${f.field}} NOT LIKE "%${f.value}%"`;
        case "startswith":
          return `{${f.field}} LIKE "${f.value}%"`;
        case "doesnotstartwith":
          return `{${f.field}} NOT LIKE "${f.value}%"`;
        case "endswith":
          return `{${f.field}} LIKE "%${f.value}"`;
        case "doesnotendwith":
          return `{${f.field}} NOT LIKE "%${f.value}"`;
        default:
          console.error(`Unexpected kendo filter "${f.operator}"`);
      }
    }
  };

  /**
   * getSharedTooltipTemplate fetches a template that shows the chart series name, the aggregator value and the sample base.
   * 
   * @param {string} aggregator The name of the aggregator function being used
   * @param {string} categoryVar The variable name of the category
   * @param {string} dataSource The variable name of the data source
   */
  this.getSharedTooltipTemplate = function (aggregator, categoryVar = 'category', dataSource = 'chartDataSource') {
    return `
        <table>
            <thead>
            #   let __category; #
            #   if (${categoryVar} instanceof Date) { #
            #       const __month = DateTime.getShortMonthName(${categoryVar}); #
            #       const __year = ${categoryVar}.getFullYear().toString().substring(2); #
            #       __category = __month + '-' + __year; #
            #   } else { #
                    # __category = ${categoryVar}; #
            #   } #
            <tr>
                <th colspan="4" class="tooltip-header">#: __category #</th>
            </tr>
            </thead>
            <tbody>
            # points.sort((a, b) => a.value < b.value ? 1 : -1).forEach(p => { #
            # const __data = ${dataSource}.at(p.categoryIx); #
            # if (__data) { #
            #   const __field = (p.series.variable || p.series.field).replace(/\\["|"\\]/g, ''); #
            #   let __countRaw = typeof __data[__field + '_count'] !== 'undefined' ? __data[__field + '_count'] : __data[__field + '__count']; #
            #   const __labelFormatMatches = p.series.labels.format ? p.series.labels.format.match(/:(\\w+)\}$/) : null #
            #   const __labelFormat = Array.isArray(__labelFormatMatches) && __labelFormatMatches.length == 2 ? __labelFormatMatches[1] : null #
            #   const __value = !isNaN(p.value) ? kendo.toString(p.value, p.series.valueFormat || __labelFormat || 'n1' ) : 'n/a'; #
            #   const __count = Number(__countRaw); #
            #   const __color = typeof p.series.color === 'function' ? p.series._defaults.color : p.series.color; #
            #   let __valStyle = ''; #
            #   let __valSuffix = ''; #
            #   if ('${aggregator}' === 'nps') { #
            #     __valStyle = (isNaN(p.value) || p.value < 0) ? 'color: red' : 'color: green'; #
            #   } #
            #   if ('${aggregator}' === 'csi') { #
            #     __valSuffix = '%'; #
            #   } #
            #   const __respStyle = config.VALID_SAMPLE_THRESHOLD && __count < config.VALID_SAMPLE_THRESHOLD ? 'color: red' : ''; #
                <tr class="tooltip-series">
                  <td>
                    <div class="tooltip-color" style="background: #: __color #"></div>
                  </td>
                  <td class="tooltip-series">#: p.series.name #</td>
                  <td class="tooltip-value" style="#: __valStyle #">#: __value + __valSuffix #</td>
                  <td class="tooltip-responses" style="#: __respStyle #">#: __count.toLocaleString() # #: 'response' + (__count !== 1 ? 's' : '') #</td>
                </tr>
            # } #
            </tbody>
            # }) #
        </table>
    `;
  };

  /**
   * getLatestProjectDate returns the latest date for which there is data across all the public Projects
   * defined in the current portal's config.
   * 
   * @param {object} filters Filters to apply when searching
   * @param {function} cb Function to call when the request completes
   *
   * @returns {Date|null}
   */
  this.getLatestProjectDate = function (filters, cb = (date) => { console.log(date) }) {
    if (!config.PROJECTS) {
      return null;
    }

    const requests = config.PROJECTS.reduce((req, pName) => {
      const p = client.Globals.projects.find(p => p.name === pName);
      if (!p.public) {
        req.push(server.getResponsesAggregate(p.dateVar, p, 'max', p.dateVar, client.convertFiltersToString(filters)));
        return req;
      }
    }, []);

    $.when.apply($, requests).done(function () {
      // If there's only one request then the arguments array consists solely of 3 elements containing the XHR response.
      // But, if there's more than one request, the arguments array is an array of tuples with each containing the appropriate XHR response.
      // This code recognises the 1-request scenario by looking for a single XHR request signature.
      // If found, it normalizes the result by copying it into a single element array.
      const results = arguments[1] === "success" ? [arguments] : arguments;
      const datasets = Array.from(results).map(r => r[0]);
      let latestDate = new Date(-8640000000000000);
      datasets.forEach(data => {
        const maxCandidate = new Date(data.rows[0][data.tag]);
        if (maxCandidate > latestDate) {
          latestDate = maxCandidate;
        }
      });

      cb(latestDate !== new Date(-8640000000000000) ? DateTime.setEndOfDay(latestDate) : null);
    });
  }

  /**
   * Injects the given named script src into the page.
   *
   * @param {string} id
   * @param {string} src
   * @param {function} [onload] Method to call when the script has loaded
   */
  this.injectScript = function (id, src, onload) {
    id = "script-" + id;

    // Remove script if it's already in the page
    const old = document.getElementById(id);
    if (old) {
      old.parentNode.removeChild(old);
    }

    const script = document.createElement("script");
    script.id = id;
    script.src = src;
    if (typeof onload === "function") {
      script.onload = onload;
    }
    document.getElementsByTagName("body")[0].appendChild(script);
  };

  /**
   * Injects the given CSS file into the page.
   *
   * @param {string} cssFileUrl The URL of the CSS file to inject.
   * @param {function} [onload] Method to call when the CSS file has loaded.
   * @param {function} [onerror] Method to call if the CSS file fails to load (default is to ignore the error)
   */
  this.injectCSS = function (cssFileUrl, onload, onerror) {
    if (!cssFileUrl) {
      console.error('No CSS file URL given.');
      return;
    }

    // Create a new link element
    const linkElement = document.createElement('link');
    linkElement.rel = 'stylesheet';
    linkElement.type = 'text/css';
    linkElement.href = cssFileUrl;

    // Check if a callback function is provided
    if (typeof onload === 'function') {
      linkElement.onload = onload;
    }

    if (typeof onerror === 'function') {
      linkElement.onerror = onerror;
    } else {
      // If no onerror callback is provided, prevent the browser's default error handling
      linkElement.onerror = (e) => {
        e.stopPropagation();
        e.preventDefault();
      };
    }

    document.getElementsByTagName('head')[0].appendChild(linkElement);
  }

  /**
   * Returns a parameter map needed to support this widget library.
   *
   * @return {function}
   */
  this.getParameterMap = function () {
    return function (data, type) {
      // Remap Kendo server-side page and sort to be compatible with the API
      if (type === "read") {
        return {
          limit: data.take,
          offset: data.skip,
          sort: data.sort ?
            data.sort
              .map(function (sort) {
                return "{" + sort.field + "} " + sort.dir;
              })
              .join(",") : "",
        };
      }
    };
  };

  this._getDefaultDateRange = function (f) {
    let range;
    if (typeof f.range === 'function') {
      range = f.range(self.Globals.lastDataPointDate);
    } else if (!f.range) {
      range = DateTime.getCurrentFinancialYear(client.Globals.lastDataPointDate, config.QUARTER_START_MONTH || DateTime.JANUARY);
    } else {
      range = client.Globals.datePreset[f.range || 'thisYear'];
      if (!range) {
        console.error(`Unknown date range '${range}'`);
        return;
      }
    }
    return range;
  }

  /**
   * Creates a set of page filters as defined by the config.PAGE_FILTERS object.
   *
   * @param {string} filtersID ID of the parent HTML element for the page filters UI
   * @param {string} statusID ID of the parent HTML element for the page filter status UI
   * @param {Project} proj Project to be filtered
   */
  this.createPageFilters = function (filtersID, statusID, proj) {

    const container = document.getElementById(filtersID);
    if (!container) return;

    // Inject the HTML for the filter status
    const status = document.getElementById(statusID);
    if (status) status.innerHTML = _getPageFiltersStatusHTML();

    // Inject the HTML for the filters
    container.innerHTML = _getPageFiltersHTML();
    // Wire up the filter reset button
    const onReset = () => {
      client.suppressAllFilterEvents(true);
      config.PAGE_FILTERS.forEach(f => {
        if (f.widget) {
          const ctl = f.widget.control;
          switch (f.type) {
            case 'multiselect':
              ctl.value([]);
              break;
            case 'dropdown':
            case 'dropdown-rolling':
              ctl.enable(true);
              ctl.select(0);
              break
            case 'dropdown-multivar':
              ctl.value('');
              break;
            case 'daterange':
              const range = self._getDefaultDateRange(f);
              if (range) {
                ctl.from.control.value(range.starts);
                ctl.from.control.enable(true);
                ctl.to.control.value(range.ends);
                ctl.to.control.enable(true);
              }
              if (f.byquarter) {
                document.getElementById(`${f.name}-group-by-quarter`).checked = false;
              }
              break;
          }
        }
      });
      client.suppressAllFilterEvents(false);
      $(window).triggerHandler('filterChanged');
      $(window).triggerHandler('filterChangedPersist');
    };

    const clearBtn = document.getElementById('page-filters-reset');
    clearBtn.removeEventListener('click', onReset);
    clearBtn.addEventListener('click', onReset);

    // Update filter status display if a filter changes
    $(window).on('filterChangedPersist', () => {
      config.PAGE_FILTERS.forEach(f => {
        const wrapper = document.getElementById(`filter-${f.name}-wrapper`);
        const status = document.getElementById(`filter-${f.name}-value`);
        if (status && wrapper) {
          const ctl = client.getControl(f.name);
          if (!ctl) return;
          switch (f.type) {
            case 'daterange': {
              status.textContent = `${kendo.toString(ctl.from.control.value(), config.SHORT_DATE_FORMAT)} to ${kendo.toString(ctl.to.control.value(), config.SHORT_DATE_FORMAT)}`;
              break;
            }
            case 'dropdown-rolling': {
              status.textContent = ctl.text();
              break;
            }
            default: {
              let val = ctl.value();
              if (Array.isArray(val)) {
                switch (val.length) {
                  case 0:
                    val = '-';
                    break;
                  case 1:
                    val = val[0];
                    break;
                  default:
                    val = val.length + ' options selected'
                }
              }
              if (val === '__null__') val = 'n/a';
              status.textContent = val || '-';
            }
          }
          wrapper.classList.toggle('filter-is-set', status.textContent && status.textContent !== '-');
        }
      });
    });

    // Add the necessary Kendo widgets
    config.PAGE_FILTERS.forEach(f => {

      switch (f.type) {

        case 'multiselect':
          f.widget = client.addMultiSelectFromSurveyVar(proj, f.name, f.name, null, true, {
            autoClose: false,
            clearButton: true,
            tagMode: 'single',
            filter: 'contains',
            placeholder: f.placeholder || 'Pick one or more...',
            tagTemplate: function (data) {
              return '<span>' + (data.values.length === 1 ? data.values[0] : data.values.length + ' options selected') + '</span>';
            },
            popup: {
              appendTo: $('#' + filtersID)
            }
          });
          break;

        case 'multiselect-multivar':
          f.widget = client.addMultiSelectFromSurveyVarCategory(proj, f.name, f.name, null, true, {
            autoClose: false,
            clearButton: true,
            tagMode: 'single',
            filter: 'contains',
            placeholder: f.placeholder || 'Pick one or more...',
            tagTemplate: function (data) {
              return '<span>' + (data.values.length === 1 ? data.values[0] : data.values.length + ' options selected') + '</span>';
            },
            popup: {
              appendTo: $('#' + filtersID)
            }
          }
          );
          break;

        case 'dropdown':
          if (f.data) {
            f.widget = client.addDropdownFromData(f.name, f.data, 'value', 'label', null, true, null, '#' + filtersID, false, true, false);
          } else {
            f.widget = client.addDropdownFromSurveyVar(proj, f.name, f.name, true, null, config.ALL_PLACEHOLDER, null, '#' + filtersID, f.order);
          }
          break;

        case 'dropdown-rolling':
          const samples = [
            { label: "Monthly", value: '' },
          ];
          if (f["three-month"] === undefined || f["three-month"]) {
            samples.push({ label: "Three Month Rolling", value: `3m(${proj.dateVar})` },)
          }
          if (f["twelve-month"] === undefined || f["twelve-month"]) {
            samples.push({ label: "Twelve Month Rolling", value: `12m(${proj.dateVar})` },)
          }
          if (f.ytd === undefined || f.ytd) {
            samples.push({ label: "YTD Rolling", value: `ytd(${proj.dateVar},${config.QUARTER_START_MONTH || 0})` })
          }
          f.widget = client.addDropdownFromData(f.name, samples, 'value', 'label', null, true, (el) => { $(window).trigger('rollingChanged', el.value()) }, '#' + filtersID, false, true, false);
          break;

        case 'dropdown-multivar':
          // Wrap all varNames in braces
          const data = f.variables.map(v => ({ varName: `{${v.varName}}`, label: v.label }));
          f.widget = client.addDropdownFromData(f.name, data, 'varName', 'label', config.ALL_PLACEHOLDER, true, null, '#' + filtersID, false, false, true);
          break;

        case 'daterange':
          // addDateRange snaps the from/to values to the period the pickers select, so there's no need for a
          // change handler here - @see {@link _getPeriodSnap}
          f.widget = client.addDateRange(f.name, proj.dateVar, {
            start: f.start || 'month',
            depth: f.depth || 'month',
            format: f.format || config.SHORT_DATE_FORMAT || 'M/d/yyyy',
            footer: f.footer !== false,
            allowFutureDates: true,
            popup: {
              appendTo: $('#' + filtersID)
            }
          }, true);

          const range = self._getDefaultDateRange(f);
          if (range) {
            f.widget.control.from.control.value(range.starts);
            f.widget.control.to.control.value(range.ends);
          }

          if (f.byquarter) {
            document.getElementById(`${f.name}-group-by-quarter`).addEventListener('change', (e) => {
              // Disable date pickers if we're grouping by quarters
              f.widget.control.from.control.enable(!e.target.checked);
              f.widget.control.to.control.enable(!e.target.checked);
              // Disable any sampling dropdown if we're grouping by quarters
              config.PAGE_FILTERS.forEach(f => {
                if (f.type === "dropdown-rolling") {
                  const sampling = client.getControl(f.name);
                  sampling.select(0);
                  sampling.enable(!e.target.checked);
                }
              })
              // Emit a custom event when the 'By Quarter' filter changes
              $(window).triggerHandler('quarterChanged', e.target.checked);

              // Trigger a view refresh
              $(window).triggerHandler("filterChanged");
              $(window).triggerHandler("filterChangedPersist");
            });
          }

          break;

        default:
          console.error(`Unsupported page filter type '${f.type}'`);
      }
    });
  }

  // Provides a custom renders for a simplified doughnut chart (with just a single series)
  this.doughnutRenderer = function (e) {
    const chart = e.sender;

    if (typeof e.sender._center === "undefined" || chart.options.series.length === 0) {
      return;
    }

    const series = chart.options.series[0];
    const draw = kendo.drawing;
    const geom = kendo.geometry;
    const circleGeometry = new geom.Circle(chart._center, chart._radius);
    const valueBox = circleGeometry.bbox();

    if (series.name) {
      const nameBox = circleGeometry.bbox();
      nameBox.origin.y -= 8;

      const seriesName = new draw.Text(series.name, [0, 0], {
        fill: {
          color: series.data[0].color,
        },
      });

      draw.align([seriesName], nameBox, "center");
      draw.vAlign([seriesName], nameBox, "center");
      chart.surface.draw(seriesName);

      valueBox.origin.y += 8;
    }

    // Series value
    const displayValue = kendo.toString(series.data[0].value, chart.options.labelFormat || "n");
    const seriesValue = new draw.Text(displayValue, [0, 0], {
      fill: {
        color: series.labels.color,
      },
      font: series.labels.font,
    });

    draw.align([seriesValue], valueBox, "center");
    draw.vAlign([seriesValue], valueBox, "center");

    chart.surface.draw(seriesValue);

    // Only run the chart animations once
    setTimeout(function () {
      e.sender.options.transitions = false;
    }, 1000);
  };

  // Returns true if the current user is allowed to see the given view
  this.allowedToSee = function (view) {
    const user = this.Globals.user;

    if (user.access_control !== "views" || this.isAdmin()) {
      return true;
    }

    const viewRoles = user.roles.filter(function (r) {
      return r.substr(0, 5) === "view_";
    });
    if (viewRoles.length === 0) {
      return false;
    }

    return viewRoles.includes("view_" + view);
  };

  function checkAllFiltersLoaded(name, suppressFilterEvents) {
    // Short-circuit if all filters are loaded
    if (self.filters.loaded) {
      return;
    }

    // If this is a page filter mark it as ready
    if (self.isPageFilter(name)) {
      self.filters.ready[name] = true;
    }

    // Are all the page filters ready now?
    let total = 0;
    let finished = 0;
    for (let f in self.filters.ready) {
      total++;
      if (self.filters.ready[f] === true) {
        finished++;
      }
    }

    // If yes trigger events
    if (finished === total) {
      self.filters.loaded = true;
      if (!suppressFilterEvents) {
        self._triggerFilterEvents(name);
      }
    }
  }

  function createWidget(name, type, options) {
    // Add a widget of the required type to the DOM
    const container = $("#" + name);
    if (!container) {
      console.error("Cannot find container element", name);
      return;
    }

    if (typeof container[type] !== "function") {
      console.error(
        "Cannot find a control of type",
        type,
        " - if it's a custom control have you included its control file?"
      );
      return null;
    }

    const control = container[type](options).data(type);
    if (!control) {
      console.error("Cannot create a control of type", type);
      return;
    }

    self.widgets.disposable[name] = {
      control: control,
      data: {},
    };

    if (typeof options.created === "function") {
      options.created.call(self.widgets.disposable[name], options, self.widgets.disposable[name].data);
    }

    if (typeof options.filterChanged === "function") {
      const _server = new Proxy(server, getProxyHandler(control.element[0]));
      self.onFilterChanged(options.filterChanged.bind(self, _server, self.widgets.disposable[name].data));
    }

    if (options.resizable) {
      $(window).on("resize", function () {
        if (control && control.element && typeof control.resize === "function") {
          control.resize();
        }
      });
    }

    if (typeof options.widgetChanged === "function") {
      $(window).on("widgetChanged", options.widgetChanged.bind(self));
    }

    return self.widgets.disposable[name];
  }

  /**
   * getDefaultView returns the first view that the logge-in user is allowed to see.
   *
   * @returns {string}
   */
  function getDefaultView() {
    if (!config.VIEWS) {
      return "";
    }

    for (let c = 0; c < config.VIEWS.length; c++) {
      if (client.allowedToSee(config.VIEWS[c])) {
        return config.VIEWS[c];
      }
    }

    return "";
  }

  // This proxies the server object so it can intercept server API calls and their results
  function getProxyHandler(control) {
    return {
      get: function (target, prop, receiver) {
        // Return the original prop if it isn't a function
        if (typeof target[prop] !== "function") {
          return target[prop];
        }

        let el;
        const trace = server.debugger && prop === "getResponsesAggregate";
        if (trace) {
          el = server.debugger.add("api", prop, control.id);
        }
        const orig = target[prop];
        return function (...args) {
          let result = orig.apply(this, args);
          if (!trace) {
            return result;
          }
          const tag = args.length > 2 ? args[0] : "n/a";
          const rolling = args.length > 10 ? args[10] : "";
          el.querySelector(`.__debugger-log-api-result-tag`).textContent = tag;
          el.querySelector(`.__debugger-log-api-result-rolling`).textContent = rolling ?
            "rolling" :
            "";
          return result.then((data) => {
            el.querySelector(`.__debugger-log-api-result-sql`).innerHTML = getSQL(data);
            el.querySelector(`.__debugger-log-api-result-data`).innerHTML = getDataTable(data);
            return result;
          });
        };
      },
    };
  }

  function getSQL(data) {
    if (!data.sql) {
      return "SQL not available - switch API debug mode on and refresh.";
    }

    let html = `<div class="__debugger-log-sql-main">${data.sql}</div>`;

    if (data.rsql) {
      html += data.rsql.reduce((h, r) => {
        h += `<div class="__debugger-log-sql-rolling">${r}</div>`;
        return h;
      }, "");
    }

    return html;
  }

  function getDataTable(data) {
    if (data.rows.length === 0) {
      return '<span class="__debugger-log-api-result-placeholder">No result</span>';
    }
    const headers = Object.keys(data.rows[0]).reduce((h, field) => {
      h += `<th>${field}</th>`;
      return h;
    }, "");

    const results = data.rows.reduce((h, r) => {
      h += "<tr>";
      for (const prop in r) {
        h += `<td>${r[prop]}</td>`;
      }
      h += "</tr>";
      return h;
    }, "");

    return `<table style="width:100%"><thead><tr>${headers}</tr></thead><tbody>${results}</tbody></table>`;
  }

  function _getPageFiltersHTML() {

    if (!config.PAGE_FILTERS) return '';

    const filters = config.PAGE_FILTERS.reduce((h, f) => {

      // Build the raw HTML for the widget
      let widget = '';
      switch (f.type) {
        case 'daterange':
          widget = `<input id="${f.name}-from" title="from"/><i class="date-connector fas fa-arrow-right"></i><input id="${f.name}-to" title="to"/>`;
          if (f.byquarter) {
            widget += `
            <label id="by-quarter-check" class="filter-label">
              <div><input type="checkbox" id="${f.name}-group-by-quarter" /></div>
              <div>By Quarter</div>
            </label>
          `;
          }
          break;
        default:
          widget = `<input id="${f.name}" />`;
      }

      return h += `
        <li id="filter-${f.name}">
            <h2>${f.label}</h2>
            ${widget}
        </li>
    `}, '');

    return `
        <ul id="page-filters">
            ${filters}
            <li class="reset-button-container">
                <button id="page-filters-reset" class="k-button k-info page-filter-reset" href="#">RESET FILTERS</button>
            </li>
        </ul>
    `;
  }

  function _getPageFiltersStatusHTML() {

    if (!config.PAGE_FILTERS) return '';

    const statuses = config.PAGE_FILTERS.reduce((h, f) => h += `
        <span id="filter-${f.name}-wrapper" class="filtered-by-wrapper">
            <span class="filter-wrapper-label">${f.label}</span>
            <span class="filter-wrapper-value"><span id="filter-${f.name}-value">-</span></span>
        </span>
    `, '');

    return `
        <div id="page-filters-status-section">
            ${statuses}
        </div>
    `;
  }
}

Client.prototype._triggerFilterEvents = (name) => {
  $(window).triggerHandler("filterChangedPersist", name);
  $(window).triggerHandler("filterChanged", name);
}

/**
* Scans all <a> tags on the page and modifies their href attributes to a '.wr3s' version
* based on a dynamic set of conditions.
*
* Intended for staging environments where all links to a production domain
* (e.g., 'client.leadershipfactor.com') should be rewritten to their corresponding
* staging version (e.g., 'client.wr3s.leadershipfactor.com').
*
* The update happens iff:
* 1. The current page's URL (window.location) is already on a '.wr3s' subdomain.
* 2. The link's hostname ends with the target base domain (e.g., 'leadershipfactor.com').
* 3. The link's hostname does NOT already contain '.wr3s'.
*/
Client.prototype.modifyLinksIfOnStaging = function () {
  // The base domain suffix to look for in hrefs.
  const baseDomainSuffix = 'leadershipfactor.com';
  const wr3sSubdomainPart = 'wr3s';

  // Check if the current page is on a '.wr3s' subdomain.
  // This ensures we only run the update logic if we're on a staging subdomain.
  if (!window.location.hostname.includes(`.${wr3sSubdomainPart}.`)) {
    return;
  }

  console.warn(`Staging detected: modifying <a> tag hrefs to include '.wr3s' subdomain...`);

  // Loop through each link to check its properties.
  document.querySelectorAll('a').forEach(link => {
    const linkHostname = link.hostname;

    const isTargetDomain = linkHostname.endsWith(baseDomainSuffix);
    const isAlreadyUpdated = linkHostname.includes(`.${wr3sSubdomainPart}.`);

    if (isTargetDomain && !isAlreadyUpdated) {
      const originalHref = link.href;

      // Construct the new hostname dynamically.
      // This correctly handles both `sub.domain.com` -> `sub.wr3s.domain.com`
      // and the base case `domain.com` -> `wr3s.domain.com`
      const newHostname = linkHostname.replace(baseDomainSuffix, `${wr3sSubdomainPart}.${baseDomainSuffix}`);

      try {
        const url = new URL(originalHref);
        url.hostname = newHostname;
        link.href = url.toString();
      } catch (e) {
        // This is a failsafe for invalid hrefs like "mailto:", "tel:", etc.,
        // though the `link.hostname` check should already filter them out.
        console.warn(`Could not process potentially invalid href: "${originalHref}"`, e);
      }
    }
  });
}

/**
 * Normalizes the given value to an array.
 *
 * @param val
 * @return {Array}
 */
Client.prototype.normalizeToArray = function (val) {
  if (typeof val === "undefined" || val === null) {
    return [];
  }

  return Array.isArray(val) ? val : [val];
};

/**
 * Downloads the given data as a CSV
 */
Client.prototype.downloadAsCSV = function (fileName, categories, data) {
  let header = ["category"];

  for (let j = 0; j < data.length; j++) {
    let category = data[j].name;
    header.push(category);
  }

  const rows = [header];

  for (let i = 0; i < categories.length; i++) {
    const datum = [categories[i]];
    for (let j = 0; j < data.length; j++) {
      datum.push(data[j].data[i]);
    }
    rows.push(datum);
  }

  let csvContent = "";

  rows.forEach(function (rowArray) {
    let row = '"' + rowArray.join('","') + '"';
    csvContent += row + "\r\n";
  });

  // Create a link to manage the download
  let downloadLink = document.createElement("a");
  let blob = new Blob(["\ufeff", csvContent]);
  downloadLink.href = URL.createObjectURL(blob);
  downloadLink.download = fileName;
  document.body.appendChild(downloadLink);
  downloadLink.click();
  document.body.removeChild(downloadLink);
};

/**
 * Given some data rows, a set of buckets and a sorted set of categories, returns a set of chart series suitable for a Kendo stacked bar chart.
 *
 * @param data
 * @param buckets
 * @param categories
 *
 * @return {Array}
 */
Client.prototype.buildBucketSeries = function (data, buckets, categories) {
  let series = [];
  Object.keys(buckets).forEach(function (bucket) {
    series.push({
      name: bucket,
      gap: 1.2,
      color: buckets[bucket] || "silver",
      data: (function () {
        const counts = [];
        categories.forEach(function (category) {
          let count = null;
          if (typeof data[category] !== "undefined") {
            data[category].forEach(function (row) {
              if (row._bucket === bucket) {
                count = row.count;
              }
            });
          }
          counts.push(count);
        });
        return counts;
      })(),
    });
  });

  return series;
};

/**
 * escapeHTML allows HTML to be safely emitted by scripts
 */
Client.prototype.escapeHTML = function (h) {
  const entityMap = {
    "&": "&amp;",
    "<": "&lt;",
    ">": "&gt;",
    '"': "&quot;",
    "'": "&#39;",
    "/": "&#x2F;",
    "`": "&#x60;",
    "=": "&#x3D;",
  };

  return String(h).replace(/[&<>"'`=\/]/g, function (s) {
    return entityMap[s];
  });
};

/**
 * getValueLabel returns a label for the given value
 *
 * @param {any} v Data value to inspect
 *
 * @returns {string}
 */
Client.prototype.getValueLabel = function (v) {
  switch (v) {
    case null:
      return "n/a";
    case "":
      return "<blank>";
    default:
      return v;
  }
};

/**
 * toKebabLowerCase returns a lower cased string in kebab-case
 */
Client.prototype.toKebabLowerCase = function (s) {
  s = s.replace(/[\s_\-]+/g, "-");

  return s.toLowerCase();
};

/**
 * toTitleCase returns a string in Title Case
 */
Client.prototype.toTitleCase = function (s) {
  let words = s.toLowerCase().split(/[\s_\-]+/);
  for (let i = 0; i < words.length; i++) {
    words[i] = words[i][0].toUpperCase() + words[i].slice(1);
  }

  return words.join(" ");
};

/**
 * truncateMiddle returns the given string trucated to a maximum of strLen characters.
 * with the separator (default: ...) in the middle.
 *
 * @param {string} fullStr String to truncate
 * @param {number} strLen Maximum lenght of the truncated string
 * @param {string} separator Separator string to use in the middle of the truncated string.
 * @returns {string}
 */
Client.prototype.truncateMiddle = function (fullStr, strLen, separator) {
  if (fullStr.length <= strLen) {
    return fullStr;
  }

  separator = separator || "...";

  const sepLen = separator.length,
    charsToShow = strLen - sepLen,
    frontChars = Math.ceil(charsToShow / 2),
    backChars = Math.floor(charsToShow / 2);

  return (
    fullStr.substring(0, frontChars) + separator + fullStr.substring(fullStr.length - backChars)
  );
};

/**
 * getValidID returns a string that can be used as a valid ID for an HTML element
 */
Client.prototype.getValidID = function (s) {
  s = s.replace(/\s+/g, "_");

  return s.toLowerCase();
};

// toUpperCaseFirst returns the given string with an uppercase first character
Client.prototype.toUpperCaseFirst = function (s) {
  return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
};

// normaliseName normalises the given name to lowercase and replaces any non-alphanumeric characters with dashes
Client.prototype.normaliseName = function (s) {
  return s.toLowerCase().replaceAll(/[^a-zA-Z0-9]+/g, "-");
};

// Implements a Panel decorator
function Panel() {
  this.wrapper = null;
  this._title = null;
}

/**
 * Attaches the given element to the Panel.
 *
 * @param {Element} container
 */
Panel.prototype.attach = function (container) {
  // Make sure the passed element is intended to be a Panel container
  if (!container.dataset || typeof container.dataset.panel === "undefined") {
    console.error("Provided element is missing a data-panel attribute");
    return;
  }

  this._decorate(container);
};

/**
 * Gets the title text displayed in the Panel.
 */
Panel.prototype.getTitle = function () {
  return this._title ? this._title.textContent : "";
};

/**
 * Sets the title displayed in the Panel.
 *
 * @param {string} title
 */
Panel.prototype.setTitle = function (title) {
  if (this._title) {
    this._title.textContent = title;
  }
};

/**
 * Decorates the Panel container element.
 *
 * @param {HTMLElement} container
 *
 * @return {HTMLDivElement}
 */
Panel.prototype._decorate = function (container) {
  container.classList.add("panel-container");

  const title = document.createElement("h2");
  title.className = "panel-title";
  title.textContent = container.dataset.paneltitle || "";
  container.prepend(title);

  this.wrapper = container;
  this._title = title;

  // Clean container
  container.removeAttribute("data-panel");
  container.removeAttribute("data-paneltitle");
};

// MainSidebar creates a left-hand Sidebar for navigation
class MainSidebar {
  constructor(id, views, onSelect) {

    const self = this;
    const sb = document.getElementById(id);
    const sbMenu = document.getElementById(id + '-menu');
    const sbLinks = document.querySelector('#' + id + '-links');
    if (!sb || !sbMenu || !sbLinks) {
      console.error('Cannot create a sidebar - missing one of more elements');
      return;
    }

    // Add all the views the User is allowed to see
    let html = '';
    Object.keys(views).forEach(function (view) {
      if (client.allowedToSee(view)) {
        const linkClass = views[view].linkClass ? `class="${views[view].linkClass}"` : '';
        html += `<li data-view="${view}" ${linkClass}><i class="fas ${views[view].icon}"></i><span class="when-open">${views[view].label}</span></li>`;
      }
    });
    sbLinks.insertAdjacentHTML('afterbegin', html);

    const sbItems = sbLinks.querySelectorAll('li');

    sb.addEventListener('click', function (e) {
      if (e.target.tagName === 'LI') {
        onSelect.call(self, e.target.dataset.view);
      }
    });

    this.activateLink = function (view) {
      const sbHighlight = document.getElementById(id + '-highlight');

      // Update link state
      for (let c = 0; c < sbItems.length; c++) {
        const item = sbItems[c];
        if (item.dataset.view === view) {
          item.classList.add('selected');
          if (sbHighlight) {
            sbHighlight.style.top = item.offsetTop + 'px';
            sbHighlight.style.opacity = 1;
          }
        } else {
          item.classList.remove('selected');
        }
      }
    };
  }
}