<commit_before>/// <reference path="../src/microReact.ts" />
/// <reference path="../src/api.ts" />
/// <reference path="../src/client.ts" />
/// <reference path="../src/tableEditor.ts" />
/// <reference path="../src/glossary.ts" />
module eveEditor {
  var localState = api.localState;
  var ixer = api.ixer;
  var code = api.code;
  var DEBUG = window["DEBUG"];

  export function executeDispatch(diffs, storeEvent, sendToServer) {
    if(diffs && diffs.length) {
      if(storeEvent) {
        var eventItem = {event: event, diffs: diffs, children: [], parent: eventStack, localState: api.clone(localState), root: false};
        eventStack.children.push(eventItem);
        eventStack = eventItem;
      }

      ixer.handleDiffs(diffs);
      if(sendToServer) {
        if(DEBUG.DELAY) {
          setTimeout(function() {
            client.sendToServer(diffs, false);
          }, DEBUG.DELAY);
        } else {
          client.sendToServer(diffs, false);
        }
      }

    } else {
      //       console.warn("No diffs to index, skipping.");
    }

    //@TODO: since we don't have a way to determine if localState has changed, we have
    //to render anytime dispatch is called
    drawn.render();
  }
}

module drawn {

  declare var uuid;
  const localState = api.localState;
  const ixer = api.ixer;
  const code = api.code;

  //---------------------------------------------------------
  // Constants
  //---------------------------------------------------------

  const nodeWidthMultiplier = 9;
  const nodeSmallWidthMultiplier = 8;
  const nodeWidthPadding = 10;
  const nodeHeight = 18;
  const nodeHeightPadding = 3;
  const nodeWidthMin = 50;

  //---------------------------------------------------------
  // Utils
  //---------------------------------------------------------

   function coerceInput(input) {
        if (input.match(/^-?[\d]+$/gim)) {
            return parseInt(input);
        }
        else if (input.match(/^-?[\d]+\.[\d]+$/gim)) {
            return parseFloat(input);
        }
        else if (input === "true") {
            return true;
        }
        else if (input === "false") {
            return false;
        }
        return input;
    }

    function stopPropagation(e) {
        e.stopPropagation();
    }

    function preventDefault(e) {
        e.preventDefault();
    }

    function focusOnce(node, elem) {
        if (!elem.__focused) {
            setTimeout(function () { node.focus(); }, 5);
            elem.__focused = true;
            if(elem.contentEditable && node.firstChild) {
              let range = document.createRange();
              range.setStart(node.firstChild, node.textContent.length);
              range.setEnd(node.firstChild, node.textContent.length);
              let sel = window.getSelection();
              sel.removeAllRanges();
              sel.addRange(range);
            }
        }
    }

	//---------------------------------------------------------
  // Renderer
  //---------------------------------------------------------

  export var renderer = new microReact.Renderer();
  document.body.appendChild(renderer.content);
  renderer.queued = false;
  export function render() {
   if(renderer.queued === false) {
      renderer.queued = true;
      // @FIXME: why does using request animation frame cause events to stack up and the renderer to get behind?
      setTimeout(function() {
      // requestAnimationFrame(function() {
        var start = performance.now();
        var tree = root();
        var total = performance.now() - start;
        if(total > 10) {
          console.log("Slow root: " + total);
        }
        renderer.render(tree);
        renderer.queued = false;
      }, 16);
    }
  }

  window.addEventListener("resize", render);

  //---------------------------------------------------------
  // localState
  //---------------------------------------------------------

  localState.selectedNodes = {};
  localState.overlappingNodes = {};

  var fieldToEntity = {}

  export var entities = [];
  for(var field in fieldToEntity) {
    var ent = fieldToEntity[field];
    if (entities.indexOf(ent) === -1) {
      entities.push(ent);
    }
  }

  export var positions = {}

  function loadPositions() {
    var loadedPositions = ixer.select("editor node position", {});
    for(var pos of loadedPositions) {
      positions[pos["editor node position: node"]] = {top: pos["editor node position: y"], left: pos["editor node position: x"]};
    }
  }

  localState.drawnUiActiveId = false;
  localState.errors = [];

  //---------------------------------------------------------
  // Node helpers
  //---------------------------------------------------------

  function findNodesIntersecting(currentNode, nodes, nodeLookup) {
    let currentNodePosition = nodeDisplayInfo(currentNode);
    let overlaps = [];
    for (let node of nodes) {
      if (node.id === currentNode.id) continue;
      let nodePosition = nodeDisplayInfo(nodeLookup[node.id]);

      if (currentNodePosition.left + currentNodePosition.width > nodePosition.left &&
        currentNodePosition.left < nodePosition.left + nodePosition.width &&
        currentNodePosition.top + currentNodePosition.height > nodePosition.top &&
        currentNodePosition.top < nodePosition.top + nodePosition.height) {
        overlaps.push(node.id);
      }
    }
    return overlaps;
  }

  function intersectionAction(nodeA, nodeB): any {
    //given two nodes, we check to see if their intersection should cause something to happen
    //e.g. two attributes intersecting would signal joining them
    if(nodeA.type === "attribute" && nodeB.type === "attribute") {
      return "joinNodes";
    }
    return false;
  }

  function actionableIntersections(viewId, currentNodeId, radius = 30) {
    let {nodeLookup, nodes} = viewToEntityInfo(ixer.selectOne("view", {view: viewId}));
    let overlaps = findNodesIntersecting(nodeLookup[currentNodeId], nodes, nodeLookup);
    let curNode = nodeLookup[currentNodeId];
    let actions = [];
    let lookup = {};
    for(let overlappingId of overlaps) {
      let overlappingNode = nodeLookup[overlappingId];
      let action = intersectionAction(curNode, overlappingNode);
      if(action) {
        let info = {node: curNode, target: overlappingNode, action};
        actions.push(info);
        lookup[overlappingId] = info;
      }
    }
    return {actions, lookup};
  }

  function getNodesInRectangle(viewId, box) {
    let {nodes} = viewToEntityInfo(ixer.selectOne("view", {view: viewId}));
    let boxLeft = Math.min(box.start.x, box.end.x);
    let boxRight = Math.max(box.start.x, box.end.x)
    let boxTop = Math.min(box.start.y, box.end.y);
    let boxBottom = Math.max(box.start.y, box.end.y);
    return nodes.map((node) => {
      return {node, displayInfo: nodeDisplayInfo(node)};
    }).filter((info) => {
      let {node, displayInfo} = info;
      let overlapLeft = Math.max(boxLeft, displayInfo.left);
      let overlapRight = Math.min(boxRight, displayInfo.left + displayInfo.width);
      let overlapTop = Math.max(boxTop, displayInfo.top);
      let overlapBottom = Math.min(boxBottom, displayInfo.top + displayInfo.height);
      return overlapLeft < overlapRight && overlapTop < overlapBottom;
    });
  }

  function nodesToRectangle(nodes) {
    let top = Infinity;
    let left = Infinity;
    let bottom = -Infinity;
    let right = -Infinity;
    for(var node of nodes) {
      let info = nodeDisplayInfo(node);
      if(info.left < left) left = info.left;
      if(info.left + info.width > right) right = info.left + info.width;
      if(info.top < top) top = info.top;
      if(info.top + info.height > bottom) bottom = info.top + info.height;
    }
    return {top, left, width: right - left, height: bottom - top};
  }

  //---------------------------------------------------------
  // AST helpers
  //---------------------------------------------------------

  function sourceHasJoins(sourceId) {
    let bindings = ixer.select("binding", {source: sourceId});
    for(let binding of bindings) {
      let variableId = binding["binding: variable"];
      if(ixer.select("binding", {variable: variableId}).length > 1) return true;
      if(ixer.select("ordinal binding", {variable: variableId}).length) return true;
      if(ixer.select("constant", {variable: variableId}).length) return true;
    }
    return false;
  }

  function removeVariable(variableId) {
    let diffs = [];
    diffs.push(api.remove("variable", {variable: variableId}));
    diffs.push(api.remove("constant", {variable: variableId}));
    // we need to remove any bindings to this variable
    diffs.push(api.remove("binding", {variable: variableId}));
    diffs.push(api.remove("ordinal binding", {variable: variableId}));
    // we also need to remove any fields and selects that pull from the variable
    let selects = ixer.select("select", { variable: variableId });
    for(let select of selects) {
      let fieldId = select["select: field"];
      diffs.push(api.remove("field", { field: fieldId}));
      diffs.push(api.remove("select", { variable: variableId }));
    }
    return diffs;
  }

  function removeSource(sourceId) {
    var diffs = [
      api.remove("source", {source: sourceId}),
      api.remove("binding", {source: sourceId})
    ]
    let bindings = ixer.select("binding", {source: sourceId});
    for(let binding of bindings) {
      let variableId = binding["binding: variable"];
      // determine if this is the only binding for this variable
      let allVariableBindings = ixer.select("binding", {variable: variableId});
      let singleBinding = allVariableBindings.length === 1;
      // if this variable is only bound to this field, then we need to remove it
      if(singleBinding) {
        diffs.push.apply(diffs, removeVariable(variableId));
      } else {
        // we need to check if the remaining bindings are all inputs, if so we
        // bind it to a constant to ensure the code remains valid
        let needsConstant = true;
        let input;
        for(let variableBinding of allVariableBindings) {
           if(variableBinding === binding) continue;
           let fieldId = variableBinding["binding: field"];
           let kind = ixer.selectOne("field", {field: fieldId})["field: kind"];
           if(kind === "output") {
             needsConstant = false;
             break;
           } else {
             input = variableBinding;
           }
        }
        if(needsConstant) {
           let fieldId = input["binding: field"];
           let sourceViewId = ixer.selectOne("source", {source: input["binding: source"]})["source: source view"];
           diffs.push(api.insert("constant", {variable: variableId, value: api.newPrimitiveDefaults[sourceViewId][fieldId]}));
        }
      }
    }
    let ordinal = ixer.selectOne("ordinal binding", {source: sourceId});
    if(ordinal) {
       diffs.push.apply(diffs, removeVariable(ordinal["ordinal binding: variable"]));
    }
    return diffs;
  }

  function addSourceFieldVariable(queryId, sourceViewId, sourceId, fieldId) {
    let diffs = [];
    let kind;
    // check if we're adding an ordinal
    if(fieldId === "ordinal") {
      kind = "ordinal";
    } else {
      kind = ixer.selectOne("field", {field: fieldId})["field: kind"];
    }
    // add a variable
    let variableId = uuid();
    diffs.push(api.insert("variable", {view: queryId, variable: variableId}));
    if(kind === "ordinal") {
      // create an ordinal binding
      diffs.push(api.insert("ordinal binding", {variable: variableId, source: sourceId}));
    } else {
      // bind the field to it
      diffs.push(api.insert("binding", {variable: variableId, source: sourceId, field: fieldId}));
    }
    if(kind === "output" || kind === "ordinal") {
      // select the field
      diffs.push.apply(diffs, dispatch("addSelectToQuery", {viewId: queryId, variableId: variableId, name: code.name(fieldId) || fieldId}, true));
    } else {
      // otherwise we're an input field and we need to add a default constant value
      diffs.push(api.insert("constant", {variable: variableId, value: api.newPrimitiveDefaults[sourceViewId][fieldId]}));
    }
    return diffs;
  }

  //---------------------------------------------------------
  // Dispatch
  //---------------------------------------------------------

  function dispatch(event, info, rentrant?) {
    //console.log("dispatch[" + event + "]", info);
    var diffs = [];
    switch(event) {
      //---------------------------------------------------------
      // Node selection
      //---------------------------------------------------------
      case "selectNode":
        var node = info.node;
        //if this node is already in the selection, we should ignore this
        if(localState.selectedNodes[node.id]) return;
        //if shift isn't pressed, then we need to clear the current selection
        if(!info.shiftKey) {
          dispatch("clearSelection", {}, true);
        }
        localState.selectedNodes[node.id] = node;
        //build a query with the selected things in it
      break;
      case "clearSelection":
        // diffs.push(api.remove("view", api.retrieve("view", {view: localState.selectedViewId})));
        localState.selectedNodes = {};
        localState.selectedViewId = uuid();
      break;
      case "removeSelection":
        var removedSources = {};
        for(let nodeId in localState.selectedNodes) {
          let node = localState.selectedNodes[nodeId];
          if(node.type === "relationship") {
            removedSources[node.id] = true;
            diffs.push.apply(diffs, removeSource(node.id));
          } else if (node.type === "primitive") {
            removedSources[node.sourceId] = true;
            diffs.push.apply(diffs, removeSource(node.sourceId));
          }
        }
        // we need to check for any variables that got orphaned by removing all the given sources
        for(let variable of ixer.select("variable", {view: localState.drawnUiActiveId})) {
          let variableId = variable["variable: variable"];
          let bindings = ixer.select("binding", {variable: variableId});
          let shouldRemove = true;
          for(let binding of bindings) {
            if(!removedSources[binding["binding: source"]]) {
              shouldRemove = false;
              break;
            }
          }
          if(shouldRemove) {
            diffs.push.apply(diffs, removeVariable(variableId));
          }
        }
        dispatch("clearSelection", {}, true);
      break;
      case "startBoxSelection":
        //if shift isn't pressed, then we need to clear the current selection
        if(!info.shiftKey) {
          dispatch("clearSelection", {}, true);
        }
        localState.selecting = true;
        localState.boxSelection = {start: info.coords};
      break;
      case "continueBoxSelection":
        if(!localState.selecting) return;
        localState.boxSelection.end = info;
      break;
      case "endBoxSelection":
        if(localState.boxSelection && localState.boxSelection.end) {
          var boxSelectedNodes = getNodesInRectangle(localState.drawnUiActiveId, localState.boxSelection);
          boxSelectedNodes.forEach((info) => {
            let {node} = info;
            localState.selectedNodes[node.id] = node;
          });
        }
        localState.selecting = false;
        localState.boxSelection = false;
      break;
      //---------------------------------------------------------
      // Node positioning
      //---------------------------------------------------------
      case "setDragOffset":
        localState.dragOffsetX = info.x;
        localState.dragOffsetY = info.y;
      break;
      case "setNodePosition":
        var originalPosition = positions[info.node.id];
        var offsetLeft = info.pos.left - originalPosition.left;
        var offsetTop = info.pos.top - originalPosition.top;
        var selectionSize = 0;
        for(let nodeId in localState.selectedNodes) {
          let node = localState.selectedNodes[nodeId];
          let prevPosition = positions[node.id];
          positions[node.id] = {left: prevPosition.left + offsetLeft, top: prevPosition.top + offsetTop};
          selectionSize++;
        }
        // if we have only one thing selected we need to check for overlaps to show potential actions that
        // could take place
        if(selectionSize === 1) {
          localState.overlappingNodes = actionableIntersections(localState.drawnUiActiveId, info.node.id).lookup;
        } else {
          localState.overlappingNodes = {};
        }
      break;
      case "finalNodePosition":
       var selectionSize = 0;
       for(let nodeId in localState.selectedNodes) {
          let node = localState.selectedNodes[nodeId];
          let currentPos = positions[node.id];
          diffs.push(api.insert("editor node position", {node: nodeId, x: currentPos.left, y: currentPos.top}),
                     api.remove("editor node position", {node: nodeId}));
          selectionSize++;
        }
        // @TODO: Check for potential overlap with other nodes
        if(selectionSize === 1) {
          let {lookup, actions} = actionableIntersections(localState.drawnUiActiveId, info.node.id);
          for(let action of actions) {
            diffs.push.apply(diffs, dispatch(action.action, action, true));
          }
        }
      break;
      //---------------------------------------------------------
      // Navigation
      //---------------------------------------------------------
      case "openRelationship":
        localState.drawnUiActiveId = info.node.source["source: source view"];
        diffs = dispatch("clearSelection", {}, true);
      break;
      case "openQuery":
        localState.drawnUiActiveId = info.queryId;
      break;
      case "gotoQuerySelector":
        localState.drawnUiActiveId = false;
      break;
      //---------------------------------------------------------
      // Query building
      //---------------------------------------------------------
      case "createNewQuery":
        let newId = uuid();
        localState.drawnUiActiveId = newId;
        diffs = [
          api.insert("view", {view: newId, kind: "join", dependents: {"display name": {name: "New query!"}, "tag": [{tag: "remote"}]}})
        ];
      break;
      case "addViewAndMaybeJoin":
        var sourceId = uuid();
        var queryId = localState.drawnUiActiveId;
        diffs = [
          api.insert("source", {view: queryId, source: sourceId, "source view": info.viewId})
        ];
        // if there's a selection, we want to try and join on those nodes if possible
        // so that we don't produce product joins all the time
        var potentialJoinNodes = {};
        for(let selectedId in localState.selectedNodes) {
          let node = localState.selectedNodes[selectedId];
          // we can only join on attributes
          if(node.type === "attribute") {
            potentialJoinNodes[node.name] = node;
          }
        }
        // add variables for all the fields of this view
        var sourceView = ixer.selectOne("view", {view: info.viewId});
        ixer.select("field", {view: info.viewId}).forEach(function(field) {
            let fieldId = field["field: field"];
            let name = code.name(fieldId);
            // check if we should try to join this field to one of the potential join nodes
            if(potentialJoinNodes[name]) {
              // if we're going to join, we just need a binding to this node
              diffs.push(api.insert("binding", {source: sourceId, field: fieldId, variable: potentialJoinNodes[name].variable}));
            } else {
              // otherwise we need to create a variable for this field
              diffs.push.apply(diffs, addSourceFieldVariable(queryId, info.viewId, sourceId, fieldId));
            }
        });
      break;
      case "joinNodes":
        var {target, node} = info;
        if(!node || !target) throw new Error("Trying to join at least one non-existent node");
        var variableId = node.variable;
        var variableIdToRemove = target.variable;

        // transfer all the bindings to the new variable
        var oldBindings = ixer.select("binding", {variable: variableIdToRemove});
        for(let binding of oldBindings) {
          let sourceId = binding["binding: source"];
          let fieldId = binding["binding: field"];
          diffs.push(api.insert("binding", {variable: variableId, source: sourceId, field: fieldId}));
        }
        // check for an ordinal binding and move it over if it exists
        var ordinalBinding = ixer.selectOne("ordinal binding", {variable: variableIdToRemove});
        if(ordinalBinding) {
          diffs.push(api.insert("ordinal binding", {variable: variableId, source: ordinalBinding["ordinal binding: source"]}));
        }

        // remove the old variable
        diffs.push.apply(diffs, removeVariable(variableIdToRemove));

        //if either of these nodes are a primitive input, then we should remove any constant
        //constraint that was on there.
        var primitiveNode;
        var nonPrimitiveNode;
        if(target.isInput) {
          primitiveNode = target;
          nonPrimitiveNode = node;
        } else if(node.isInput) {
          primitiveNode = node;
          nonPrimitiveNode = target;
        }
        if(primitiveNode) {
          // ensure that these nodes can act as inputs:
          // if it's a vector input this has to be a non-grouped, sourceChunked attribute
          // if it's a scalar input this has to be either grouped or a non-sourceChunked attribute
          if(primitiveNode.inputKind === "vector input" && (nonPrimitiveNode.grouped || !nonPrimitiveNode.sourceChunked)) {
            //we do this as a normal dispatch as we want to bail out in the error case.
            return dispatch("setError", {errorText: "Aggregates require columns as input, try selecting the source and chunking it."});
          } else if(primitiveNode.inputKind === "scalar input" && !nonPrimitiveNode.grouped && nonPrimitiveNode.sourceChunked) {
            //we do this as a normal dispatch as we want to bail out in the error case.
            return dispatch("setError", {errorText: "Normal functions can't take columns as input, you could try unchunking the source or grouping this field."});
          }
          diffs.push(api.remove("constant", {variable: primitiveNode.variable}));
        }
        diffs.push.apply(diffs, dispatch("clearSelection", info, true));
      break;
      case "joinSelection":
        let ids = Object.keys(localState.selectedNodes);
        let root = localState.selectedNodes[ids[0]];
        for(let nodeId of ids.slice(1)) {
          let node = localState.selectedNodes[nodeId];
          diffs.push.apply(diffs, dispatch("joinNodes", {node, target: root}, true));
        }
      break;
      case "unjoinNodes":
        var queryId = localState.drawnUiActiveId;
        var variableIdToRemove = info.variableId;
        var oldBindings = ixer.select("binding", {variable: variableIdToRemove});
         // push all the bindings onto their own variables, skipping the first as that one can reuse
         // the current variable
        for(let binding of oldBindings.slice(1)) {
          let sourceId = binding["binding: source"];
          let fieldId = binding["binding: field"];
          let sourceViewId = ixer.selectOne("source", {source: sourceId})["source: source view"];
          diffs.push.apply(diffs, addSourceFieldVariable(queryId, sourceViewId, sourceId, fieldId));
          diffs.push(api.remove("binding", {variable: variableIdToRemove, source: sourceId, field: fieldId}));
        }
        // check for an ordinal binding and create a new variable for it if it exists
        var ordinalBinding = ixer.selectOne("ordinal binding", {variable: variableIdToRemove});
        if(ordinalBinding) {
          diffs.push.apply(diffs, addSourceFieldVariable(queryId, null, ordinalBinding["ordinal binding: source"], "ordinal"));
          diffs.push(api.remove("ordinal binding", {variable: variableIdToRemove}));
        }
        // we have to check to make sure that if the original binding represents an input it gets a default
        // added to it to prevent the server from crashing
        var fieldId = oldBindings[0]["binding: field"];
        var kind = ixer.selectOne("field", {field: fieldId})["field: kind"];
        if(kind !== "output") {
          let sourceViewId = ixer.selectOne("source", {source: oldBindings[0]["binding: source"]})["source: source view"];
          diffs.push(api.insert("constant", {variable: variableIdToRemove, value: api.newPrimitiveDefaults[sourceViewId][fieldId]}));
        }

      break;
      case "removeSelectFromQuery":
        var selects = ixer.select("select", {view: info.viewId, variable: info.variableId}) || [];
        for(let select of selects) {
          let fieldId = select["select: field"];
          diffs.push(api.remove("field", {field: fieldId}));
        }
        diffs.push(api.remove("select", {view: info.viewId, variable: info.variableId}));
      break;
      case "addSelectToQuery":
        var name = info.name;
        var fields = ixer.select("field", {view: info.viewId}) || [];
        var neueField = api.insert("field", {view: info.viewId, kind: "output", dependents: {
          "display name": {name: name},
          "display order": {priority: -fields.length}
        }});
        var fieldId = neueField.content.field;

        // check to make sure this isn't only a negated attribute
        var onlyNegated = !info.allowNegated;
        var bindings = ixer.select("binding", {variable: info.variableId});
        for(let binding of bindings) {
          let sourceId = binding["binding: source"];
          if(!ixer.selectOne("negated source", {source: sourceId})) {
            onlyNegated = false;
          }
        }
        if(bindings.length && onlyNegated) {
          return dispatch("setError", {errorText: "Attributes that belong to a negated source that aren't joined with something else, can't be selected since they represent the absence of a value."});
        }

        diffs = [
          neueField,
          api.insert("select", {view: info.viewId, field: fieldId, variable: info.variableId})
        ];
      break;
      case "selectSelection":
        for(let nodeId in localState.selectedNodes) {
          let node = localState.selectedNodes[nodeId];
          diffs.push.apply(diffs, dispatch("addSelectToQuery", {variableId: node.variable, name: node.name, viewId: localState.drawnUiActiveId}, true));
        }
      break;
      case "unselectSelection":
        for(let nodeId in localState.selectedNodes) {
          let node = localState.selectedNodes[nodeId];
          diffs.push.apply(diffs, dispatch("removeSelectFromQuery", {variableId: node.variable, viewId: localState.drawnUiActiveId}, true));
        }
      break;
      case "setQueryName":
        if(info.value === ixer.selectOne("display name", {id: info.viewId})["display name: name"]) return;
        diffs.push(api.insert("display name", {id: info.viewId, name: info.value}),
                   api.remove("display name", {id: info.viewId}));
      break;
      case "addFilter":
        var variableId = info.node.variable;
        diffs.push(api.insert("constant", {variable: variableId, value: ""}));
        dispatch("modifyFilter", info, true);
      break;
      case "modifyFilter":
        localState.modifyingFilterNodeId = info.node.id;
      break;
      case "removeFilter":
        var variableId = info.node.variable;
        diffs.push(api.remove("constant", {variable: variableId}));
      break;
      case "stopModifyingFilter":
        //insert a constant
        var variableId = info.node.variable;
        diffs.push(api.remove("constant", {variable: variableId}));
        diffs.push(api.insert("constant", {variable: variableId, value: info.value}));
        localState.modifyingFilterNodeId = undefined;
      break;
      case "chunkSource":
        var sourceId = info.node.source["source: source"];
        diffs.push(api.insert("chunked source", {view: info.viewId, source: sourceId}));
      break;
      case "unchunkSource":
        var sourceId = info.node.source["source: source"];
        diffs.push(api.remove("chunked source", {view: info.viewId, source: sourceId}));
      break;
      case "addOrdinal":
        var sourceId = info.node.source["source: source"];
        // @TODO: we need a way to create a variable for this to really work
        var fields = ixer.select("field", {view: info.viewId}) || [];
        var neueField = api.insert("field", {view: info.viewId, kind: "output", dependents: {
          "display name": {name: "ordinal"},
          "display order": {priority: -fields.length}
        }});
        var fieldId = neueField.content.field;
        var variableId = uuid();
        diffs.push(
          neueField,
          // create a variable
          api.insert("variable", {view: info.viewId, variable: variableId}),
          // bind the ordinal to it
          api.insert("ordinal binding", {source: sourceId, variable: variableId}),
          // select the variable into the created field
          api.insert("select", {view: info.viewId, variable: variableId, field: fieldId})
        );
      break;
      case "removeOrdinal":
        var sourceId = info.node.source["source: source"];
        var variableId = ixer.selectOne("ordinal binding", {source: sourceId})["ordinal binding: variable"];
        diffs = removeVariable(variableId);
      break;
      case "groupAttribute":
        var variableId = info.node.variable;
        var bindings = ixer.select("binding", {variable: variableId});
        if(bindings.length > 1) {
          //we do this as a normal dispatch as we want to bail out in the error case.
          return dispatch("setError", {errorText: "Cannot group an attribute that has multiple bindings, not sure what to do."});
          return;
        }
        var sourceId = bindings[0]["binding: source"];
        var fieldId = bindings[0]["binding: field"];
        diffs.push(api.insert("grouped field", {view: info.viewId, source: sourceId, field: fieldId}));
      break;
      case "ungroupAttribute":
        var variableId = info.node.variable;
        var bindings = ixer.select("binding", {variable: variableId});
        if(bindings.length > 1) {
          //we do this as a normal dispatch as we want to bail out in the error case.
          return dispatch("setError", {errorText: "Cannot group an attribute that has multiple bindings, not sure what to do."});
        }
        var sourceId = bindings[0]["binding: source"];
        var fieldId = bindings[0]["binding: field"];
        diffs.push(api.remove("grouped field", {view: info.viewId, source: sourceId, field: fieldId}));
      break;
      case "negateSource":
        diffs.push(api.insert("negated source", {view: info.viewId, source: info.sourceId}));
        // you can't select anything from a negated source, so if there are no joins on a variable this
        // source uses we need to deselect it
        for(let binding of ixer.select("binding", {source: info.sourceId})) {
          let variableId = binding["binding: variable"];
          if(ixer.select("binding", {variable: variableId}).length === 1) {
            diffs.push.apply(diffs, dispatch("removeSelectFromQuery", {variableId: variableId, viewId: localState.drawnUiActiveId}, true));
          }
        }
      break;
      case "unnegateSource":
        diffs.push(api.remove("negated source", {view: info.viewId, source: info.sourceId}));
        // since we removed all your selects when you negated the source, let's re-select them
        var sourceViewId = ixer.selectOne("source", {source: info.sourceId})["source: source view"];
        ixer.select("field", {view: sourceViewId}).forEach(function(field) {
            let fieldId = field["field: field"];
            let binding = ixer.selectOne("binding", {source: info.sourceId, field: fieldId});
            let bindingVariableId = binding["binding: variable"];
            if(!ixer.selectOne("select", {variable: bindingVariableId})) {
              diffs.push.apply(diffs, dispatch("addSelectToQuery", {variableId: bindingVariableId, name: code.name(fieldId), viewId: localState.drawnUiActiveId, allowNegated: true}, true));
            }
        });
      break;
      //---------------------------------------------------------
      // Errors
      //---------------------------------------------------------
      case "setError":
        var errorId = localState.errors.length;
        var newError: any = {text: info.errorText, time: api.now(), id: errorId};
        newError.errorTimeout = setTimeout(() => dispatch("fadeError", {errorId}), 2000);
        localState.errors.push(newError);
      break;
      case "fadeError":
        var errorId = info.errorId;
        var currentError = localState.errors[errorId];
        currentError.fading = true;
        currentError.errorTimeout = setTimeout(() => dispatch("clearError", {errorId: info.errorId}), 1000);
      break;
      case "clearError":
        // localState.errors = false;
      break;
      //---------------------------------------------------------
      // search
      //---------------------------------------------------------
      case "updateSearch":
        localState.searchingFor = info.value;
        localState.searchResults = searchResultsFor(info.value);
      break;
      case "startSearching":
        localState.searching = true;
        var searchValue = info.value || "";
        // when we start searching, lets check if there are attributes selected and if there
        // are, go ahead and add filters to our search for them. This makes it really easy to
        // figure out what you can join those attributes on
        for(let nodeId in localState.selectedNodes) {
          let node = localState.selectedNodes[nodeId];
          if(node.type === "attribute") {
            searchValue += `[field: ${node.name}] `;
          }
        }
        diffs.push.apply(diffs, dispatch("updateSearch", {value: searchValue}, true));
      break;
      case "stopSearching":
        localState.searching = false;
      break;
      case "handleSearchKey":
        if(info.keyCode === api.KEYS.ENTER) {
          // execute whatever the first result's action is
          let currentSearchGroup = localState.searchResults[0];
          if(currentSearchGroup && currentSearchGroup.results.length) {
            let results = currentSearchGroup.results;
            currentSearchGroup.onSelect(null, {result: currentSearchGroup.results[results.length - 1]});
          }
          diffs.push.apply(diffs, dispatch("stopSearching", {}, true));
        } else if(info.keyCode === api.KEYS.ESC) {
          diffs.push.apply(diffs, dispatch("stopSearching", {}, true));
        } else if(info.keyCode === api.KEYS.F && (info.ctrlKey || info.metaKey)) {
          diffs.push.apply(diffs, dispatch("stopSearching", {}, true));
          info.e.preventDefault();
        }
      break;
      //---------------------------------------------------------
      // Tooltip
      //---------------------------------------------------------
      case "showButtonTooltip":
        localState.maybeShowingTooltip = true;
        var tooltip = {
          content: {c: "button-info", children: [
            {c: "header", text: info.header},
            {c: "description", text: info.description},
            info.disabledMessage ? {c: "disabled-message", text: "Disabled because " + info.disabledMessage} : undefined,
          ]},
          x: info.x + 10,
          y: info.y
        };
        if(!localState.tooltip) {
          localState.tooltipTimeout = setTimeout(function() {
            dispatch("showTooltip", tooltip);
          }, 500);
        } else {
          diffs = dispatch("showTooltip", tooltip, true);
        }
      break;
      case "hideButtonTooltip":
        clearTimeout(localState.tooltipTimeout);
        localState.maybeShowingTooltip = false;
        localState.tooltipTimeout = setTimeout(function() {
          if(!localState.maybeShowingTooltip) {
            dispatch("hideTooltip", {});
          }
        }, 10);
      break;
      case "showTooltip":
        localState.tooltip = info;
      break;
      case "hideTooltip":
        localState.tooltip = false;
      break;
      //---------------------------------------------------------
      // Menu
      //---------------------------------------------------------
      case "showMenu":
        localState.menu = {top: info.y, left: info.x, contentFunction: info.contentFunction};
      break;
      case "clearMenu":
        localState.menu = false;
      break;
      default:
        console.error("Unknown dispatch:", event, info);
        break;
    }

    if(!rentrant) {
      if(diffs.length) {
        let formatted = api.toDiffs(diffs);
        ixer.handleDiffs(formatted);
        client.sendToServer(formatted, false);
      }
      render();
    }
    return diffs;
  }

  //---------------------------------------------------------
  // Search
  //---------------------------------------------------------

  function scoreHaystack(haystack, needle) {
    let score = 0;
    let found = {};
    let lowerHaystack = haystack.toLowerCase();
    if(needle.length === 1 && haystack === needle[0]) {
      score += 2;
    }
    for(let word of needle) {
      let ix = lowerHaystack.indexOf(word);
      if(ix === 0) {
        score += 1;
      }
      if(ix > -1) {
        score += 1;
        found[word] = ix;
      }
    }
    return {score, found};
  }

  function sortByScore(a, b) {
    let aScore = a.score.score;
    let bScore = b.score.score;
    if(aScore === bScore) {
      return b.text.length - a.text.length;
    }
    return aScore - bScore;
  }

  var availableFilters = ["field", "tag"];
  function searchResultsFor(searchValue) {
    let start = api.now();

    // search results should be an ordered set of maps that contain the kind of results
    // being provided, the ordered set of results, and a selection handler
    let searchResults = [];

    // see if there are any filters
    let filters = [];
    let normalizedSearchValue = searchValue.trim().toLowerCase();
    for(let filter of availableFilters) {
      let regex = new RegExp(`\\[${filter}:(.*?)\\]\s*`, "g");
      let origSearch = normalizedSearchValue;
      let match;
      while(match = regex.exec(origSearch)) {
        normalizedSearchValue = normalizedSearchValue.replace(match[0], "");
        filters.push({type: filter, value: match[1].trim()});
      }
    }

    let needle = normalizedSearchValue.trim().split(" ");

    let rels = searchRelations(needle, filters);
    if(rels) searchResults.push(rels);

    let glossary = searchGlossary(needle);
    if(glossary) searchResults.push(glossary);

    let end = api.now();
    if(end - start > 5) {
      console.error("Slow search (>5 ms):", end - start, searchValue);
    }
    return searchResults;
  }

  function arrayIntersect(a, b) {
    let ai = 0;
    let bi = 0;
    let result = [];
    while(ai < a.length && bi < b.length){
       if (a[ai] < b[bi] ){ ai++; }
       else if (a[ai] > b[bi] ){ bi++; }
       else {
         result.push(a[ai]);
         ai++;
         bi++;
       }
    }
    return result;
  }

  function searchRelations(needle, filters) {
    let matchingViews = [];
    let viewIds;
    //handle filters
    for(let filter of filters) {
      if(filter.type === "field") {
        // we need to only look at views with a field with the given name
        var potentialViews = [];
        ixer.select("display name", {name: filter.value}).forEach((name) => {
          let field = ixer.selectOne("field", {field: name["display name: id"]});
          if(field) {
            potentialViews.push(field["field: view"]);
          }
       });
       potentialViews.sort();
       if(!viewIds) {
          viewIds = potentialViews;
        } else {
          viewIds = arrayIntersect(viewIds, potentialViews);
        }
      } else if(filter.type === "tag") {
        // we only look at views with the given tag
        let tagged = ixer.select("tag", {tag: filter.value});
        if(!viewIds) {
          viewIds = [];
          tagged.forEach((tag) => {
            viewIds.push(tag["tag: view"]);
          });
          viewIds.sort();
        } else {
          let taggedIds = tagged.map((tag) => tag["tag: view"]).sort();
          viewIds = arrayIntersect(viewIds, taggedIds);
        }
      }
    }

    if(!filters.length) {
      viewIds = ixer.select("view", {}).map((view) => view["view: view"]);
    }

    for(let viewId of viewIds) {
      let name = code.name(viewId);
      let score = scoreHaystack(name, needle);
      if(score.score) {
        let description = ixer.selectOne("view description", {view: viewId});
        if(description) {
          description = description["view description: description"];
        } else {
          description = "No description :(";
        }
        matchingViews.push({text: name, viewId, score, description});
      }
    }
    matchingViews.sort(sortByScore);
    return {kind: "Sources", results: matchingViews, onSelect: (e, elem) => {
      dispatch("addViewAndMaybeJoin", {viewId: elem.result.viewId});
    }};
  }

  function searchGlossary(needle) {
    let matchingTerms = [];
    for(let term of glossary.terms) {
      let score = scoreHaystack(term.term, needle);
      if(score.score) {
        matchingTerms.push({text: term.term, description: term.description, score});
      }
    }
    matchingTerms.sort(sortByScore);
    return {kind: "Glossary", results: matchingTerms, onSelect: () => { console.log("selected glossary item")}};
  }

  //---------------------------------------------------------
  // root
  //---------------------------------------------------------

  function root() {
    var page:any;
    if(localState.drawnUiActiveId) {
      page = queryUi(localState.drawnUiActiveId, true);
    } else {
      page = querySelector();
    }
    return {id: "root", children: [page]};
  }

  function querySelector() {
    var queries = api.ixer.select("view", {kind: "join"}).map((view) => {
      var viewId = view["view: view"];
      return {c: "query-item", queryId: viewId, click: openQuery, children:[
        {c: "query-name", text: code.name(viewId)},
        queryUi(viewId)
      ]};
    });
    return {c: "query-selector-wrapper", children: [
      {c: "button", text: "add query", click: createNewQuery},
      {c: "query-selector", children: queries}
    ]};
  }

  function createNewQuery(e, elem) {
    dispatch("createNewQuery", {});
  }

  function openQuery(e, elem) {
    dispatch("openQuery", {queryId: elem.queryId});
  }

  function queryUi(viewId, showResults = false) {
    var view = ixer.selectOne("view", {view: viewId});
    if(!view) return;
    return {c: "query", children: [
      tooltipUi(),
      localState.drawnUiActiveId ? queryTools(view) : undefined,
      {c: "container", children: [
        {c: "surface", children: [
          {c: "query-name-input", contentEditable: true, blur: setQueryName, viewId: viewId, text: code.name(viewId)},
          queryMenu(view),
          queryCanvas(view),
          queryErrors(view),
        ]},
        showResults ? queryResults(viewId) : undefined
      ]}
    ]};
  }

  function tooltipUi() {
    let tooltip = localState.tooltip;
    if(tooltip) {
      let elem = {c: "tooltip", left: tooltip.x, top: tooltip.y};
      if(typeof tooltip.content === "string") {
        elem["text"] = tooltip.content;
      } else {
        elem["children"] = [tooltip.content];
      }
      return elem;
    }
  }

  function queryErrors(view) {
    let errors = localState.errors.map((error) => {
      let klass = "error";
      if(error.fading) {
        klass += " fade";
      }
      return {c: klass, text: error.text};
    }).reverse();
    return {c: "query-errors", children: errors};
  }

  function queryTools(view) {
    // What tools are available depends on what is selected.
    // no matter what though you should be able to go back to the
    // query selector.
    let tools:any = [
       {c: "tool", text: "back", click: gotoQuerySelector},
    ];

    let viewId = view["view: view"];

    // @FIXME: we ask for the entity info multiple times to draw the editor
    // we should probably find a way to do it in just one.
    let {nodeLookup} = viewToEntityInfo(view);

    let selectedNodes = Object.keys(localState.selectedNodes).map(function(nodeId) {
      // we can't rely on the actual nodes of the uiSelection because they don't get updated
      // so we have to look them up again.
      return nodeLookup[nodeId];
    });

    let disabled = {};
    let actions = {
      "join": {func: joinSelection, text: "Join"},
      "select": {func: selectAttribute, text: "Show"},
      "filter": {func: addFilter, text: "Filter"},
      "group": {func: groupAttribute, text: "Group"},
      "chunk": {func: chunkSource, text: "Chunk"},
      "ordinal": {func: addOrdinal, text: "Ordinal"},
      "negate": {func: negateSource, text: "Negate"},
    }

    // no selection
    if(!selectedNodes.length) {
      disabled = {
        "join": "join only applies to attributes",
        "select": "select only applies to attributes",
        "filter": "filter only applies to attributes",
        "group": "group only applies to attributes",
        "chunk": "chunk only applies to sources",
        "ordinal": "ordinal only applies to sources",
        "negate": "negate only applies to sources",
      }

    // single selection
    } else if(selectedNodes.length === 1) {
      let node = selectedNodes[0];
      if(node.type === "attribute") {
        disabled["chunk"] = "chunk only applies to sources";
        disabled["ordinal"] = "ordinal only applies to sources";
        disabled["negate"] = "negate only applies to sources";
        if(!node.mergedAttributes) {
          // you can't select a node if the source is negated and it's not joined with anything else
          if(node.sourceNegated) {
            disabled["select"] = "negated sources prove the absence of a row, which means you'd be selecting from nothing."
          }
          disabled["join"] = "multiple attributes aren't joined together on this node.";
        } else {
          actions["join"] = {func: unjoinNodes, text: "Unjoin"};
        }

        if(ixer.selectOne("select", {view: viewId, variable: node.variable})) {
          actions["select"] = {func: unselectAttribute, text: "Hide"};
        }
        if(node.filter) {
          actions["filter"] = {func: removeFilter, text: "Unfilter"};
        }
        // if this node's source is chunked or there's an ordinal binding, we can group
        if(node.sourceChunked || node.sourceHasOrdinal) {
          if(node.grouped) {
            actions["group"] = {func: ungroupAttribute, text: "Ungroup"};
          }
        } else {
          disabled["group"] = "To group an attribute, the source must either have an ordinal or be chunked";
        }
      } else if(node.type === "relationship") {
        disabled["select"] = "select only applies to attributes.";
        disabled["filter"] = "filter only applies to attributes.";
        disabled["group"] = "group only applies to attributes.";
        disabled["join"] = "join only applies to attributes.";
        let hasJoins = sourceHasJoins(node.id);
        if(hasJoins) {
          disabled["chunk"] = "you cannot chunk if attributes of the source are joined to other sources";
        }
        if(node.chunked) {
          actions["chunk"] = {func: unchunkSource, text: "Unchunk"};
           if(hasJoins) {
              disabled["chunk"] = "you cannot unchunk if attributes of the source are joined to other sources";
           }
        }
        if(node.isNegated) {
          actions["negate"] = {func: unnegateSource, text: "Unnegate"};
        }
        if(node.hasOrdinal) {
          actions["ordinal"] = {func: removeOrdinal, text: "Unordinal"};
        }

      }

    //multi-selection
    } else {
      disabled = {
        "filter": "filter only applies to single attributes",
        "group": "group only applies to single attributes",
        "chunk": "chunk only applies to single sources",
        "ordinal": "ordinal only applies to single sources",
        "negate": "negate only applies to single sources",
      }

      // join and select are only valid if everything is an attribute, so if we
      // find a non-attribute, we have to disable them<commit_msg>allow chunking and unchunking of sources that have joins, by grouping and ungrouping as necessary<commit_after>/// <reference path="../src/microReact.ts" />
/// <reference path="../src/api.ts" />
/// <reference path="../src/client.ts" />
/// <reference path="../src/tableEditor.ts" />
/// <reference path="../src/glossary.ts" />
module eveEditor {
  var localState = api.localState;
  var ixer = api.ixer;
  var code = api.code;
  var DEBUG = window["DEBUG"];

  export function executeDispatch(diffs, storeEvent, sendToServer) {
    if(diffs && diffs.length) {
      if(storeEvent) {
        var eventItem = {event: event, diffs: diffs, children: [], parent: eventStack, localState: api.clone(localState), root: false};
        eventStack.children.push(eventItem);
        eventStack = eventItem;
      }

      ixer.handleDiffs(diffs);
      if(sendToServer) {
        if(DEBUG.DELAY) {
          setTimeout(function() {
            client.sendToServer(diffs, false);
          }, DEBUG.DELAY);
        } else {
          client.sendToServer(diffs, false);
        }
      }

    } else {
      //       console.warn("No diffs to index, skipping.");
    }

    //@TODO: since we don't have a way to determine if localState has changed, we have
    //to render anytime dispatch is called
    drawn.render();
  }
}

module drawn {

  declare var uuid;
  const localState = api.localState;
  const ixer = api.ixer;
  const code = api.code;

  //---------------------------------------------------------
  // Constants
  //---------------------------------------------------------

  const nodeWidthMultiplier = 9;
  const nodeSmallWidthMultiplier = 8;
  const nodeWidthPadding = 10;
  const nodeHeight = 18;
  const nodeHeightPadding = 3;
  const nodeWidthMin = 50;

  //---------------------------------------------------------
  // Utils
  //---------------------------------------------------------

   function coerceInput(input) {
        if (input.match(/^-?[\d]+$/gim)) {
            return parseInt(input);
        }
        else if (input.match(/^-?[\d]+\.[\d]+$/gim)) {
            return parseFloat(input);
        }
        else if (input === "true") {
            return true;
        }
        else if (input === "false") {
            return false;
        }
        return input;
    }

    function stopPropagation(e) {
        e.stopPropagation();
    }

    function preventDefault(e) {
        e.preventDefault();
    }

    function focusOnce(node, elem) {
        if (!elem.__focused) {
            setTimeout(function () { node.focus(); }, 5);
            elem.__focused = true;
            if(elem.contentEditable && node.firstChild) {
              let range = document.createRange();
              range.setStart(node.firstChild, node.textContent.length);
              range.setEnd(node.firstChild, node.textContent.length);
              let sel = window.getSelection();
              sel.removeAllRanges();
              sel.addRange(range);
            }
        }
    }

	//---------------------------------------------------------
  // Renderer
  //---------------------------------------------------------

  export var renderer = new microReact.Renderer();
  document.body.appendChild(renderer.content);
  renderer.queued = false;
  export function render() {
   if(renderer.queued === false) {
      renderer.queued = true;
      // @FIXME: why does using request animation frame cause events to stack up and the renderer to get behind?
      setTimeout(function() {
      // requestAnimationFrame(function() {
        var start = performance.now();
        var tree = root();
        var total = performance.now() - start;
        if(total > 10) {
          console.log("Slow root: " + total);
        }
        renderer.render(tree);
        renderer.queued = false;
      }, 16);
    }
  }

  window.addEventListener("resize", render);

  //---------------------------------------------------------
  // localState
  //---------------------------------------------------------

  localState.selectedNodes = {};
  localState.overlappingNodes = {};

  var fieldToEntity = {}

  export var entities = [];
  for(var field in fieldToEntity) {
    var ent = fieldToEntity[field];
    if (entities.indexOf(ent) === -1) {
      entities.push(ent);
    }
  }

  export var positions = {}

  function loadPositions() {
    var loadedPositions = ixer.select("editor node position", {});
    for(var pos of loadedPositions) {
      positions[pos["editor node position: node"]] = {top: pos["editor node position: y"], left: pos["editor node position: x"]};
    }
  }

  localState.drawnUiActiveId = false;
  localState.errors = [];

  //---------------------------------------------------------
  // Node helpers
  //---------------------------------------------------------

  function findNodesIntersecting(currentNode, nodes, nodeLookup) {
    let currentNodePosition = nodeDisplayInfo(currentNode);
    let overlaps = [];
    for (let node of nodes) {
      if (node.id === currentNode.id) continue;
      let nodePosition = nodeDisplayInfo(nodeLookup[node.id]);

      if (currentNodePosition.left + currentNodePosition.width > nodePosition.left &&
        currentNodePosition.left < nodePosition.left + nodePosition.width &&
        currentNodePosition.top + currentNodePosition.height > nodePosition.top &&
        currentNodePosition.top < nodePosition.top + nodePosition.height) {
        overlaps.push(node.id);
      }
    }
    return overlaps;
  }

  function intersectionAction(nodeA, nodeB): any {
    //given two nodes, we check to see if their intersection should cause something to happen
    //e.g. two attributes intersecting would signal joining them
    if(nodeA.type === "attribute" && nodeB.type === "attribute") {
      return "joinNodes";
    }
    return false;
  }

  function actionableIntersections(viewId, currentNodeId, radius = 30) {
    let {nodeLookup, nodes} = viewToEntityInfo(ixer.selectOne("view", {view: viewId}));
    let overlaps = findNodesIntersecting(nodeLookup[currentNodeId], nodes, nodeLookup);
    let curNode = nodeLookup[currentNodeId];
    let actions = [];
    let lookup = {};
    for(let overlappingId of overlaps) {
      let overlappingNode = nodeLookup[overlappingId];
      let action = intersectionAction(curNode, overlappingNode);
      if(action) {
        let info = {node: curNode, target: overlappingNode, action};
        actions.push(info);
        lookup[overlappingId] = info;
      }
    }
    return {actions, lookup};
  }

  function getNodesInRectangle(viewId, box) {
    let {nodes} = viewToEntityInfo(ixer.selectOne("view", {view: viewId}));
    let boxLeft = Math.min(box.start.x, box.end.x);
    let boxRight = Math.max(box.start.x, box.end.x)
    let boxTop = Math.min(box.start.y, box.end.y);
    let boxBottom = Math.max(box.start.y, box.end.y);
    return nodes.map((node) => {
      return {node, displayInfo: nodeDisplayInfo(node)};
    }).filter((info) => {
      let {node, displayInfo} = info;
      let overlapLeft = Math.max(boxLeft, displayInfo.left);
      let overlapRight = Math.min(boxRight, displayInfo.left + displayInfo.width);
      let overlapTop = Math.max(boxTop, displayInfo.top);
      let overlapBottom = Math.min(boxBottom, displayInfo.top + displayInfo.height);
      return overlapLeft < overlapRight && overlapTop < overlapBottom;
    });
  }

  function nodesToRectangle(nodes) {
    let top = Infinity;
    let left = Infinity;
    let bottom = -Infinity;
    let right = -Infinity;
    for(var node of nodes) {
      let info = nodeDisplayInfo(node);
      if(info.left < left) left = info.left;
      if(info.left + info.width > right) right = info.left + info.width;
      if(info.top < top) top = info.top;
      if(info.top + info.height > bottom) bottom = info.top + info.height;
    }
    return {top, left, width: right - left, height: bottom - top};
  }

  //---------------------------------------------------------
  // AST helpers
  //---------------------------------------------------------

  function joinedBindingsFromSource(sourceId) {
    let joined = [];
    let bindings = ixer.select("binding", {source: sourceId});
    for(let binding of bindings) {
      let variableId = binding["binding: variable"];
      if(ixer.select("binding", {variable: variableId}).length > 1
         || ixer.select("ordinal binding", {variable: variableId}).length
         || ixer.select("constant", {variable: variableId}).length) {
        joined.push(binding);
      }
    }
    return joined;
  }

  function removeVariable(variableId) {
    let diffs = [];
    diffs.push(api.remove("variable", {variable: variableId}));
    diffs.push(api.remove("constant", {variable: variableId}));
    // we need to remove any bindings to this variable
    diffs.push(api.remove("binding", {variable: variableId}));
    diffs.push(api.remove("ordinal binding", {variable: variableId}));
    // we also need to remove any fields and selects that pull from the variable
    let selects = ixer.select("select", { variable: variableId });
    for(let select of selects) {
      let fieldId = select["select: field"];
      diffs.push(api.remove("field", { field: fieldId}));
      diffs.push(api.remove("select", { variable: variableId }));
    }
    return diffs;
  }

  function removeSource(sourceId) {
    var diffs = [
      api.remove("source", {source: sourceId}),
      api.remove("binding", {source: sourceId})
    ]
    let bindings = ixer.select("binding", {source: sourceId});
    for(let binding of bindings) {
      let variableId = binding["binding: variable"];
      // determine if this is the only binding for this variable
      let allVariableBindings = ixer.select("binding", {variable: variableId});
      let singleBinding = allVariableBindings.length === 1;
      // if this variable is only bound to this field, then we need to remove it
      if(singleBinding) {
        diffs.push.apply(diffs, removeVariable(variableId));
      } else {
        // we need to check if the remaining bindings are all inputs, if so we
        // bind it to a constant to ensure the code remains valid
        let needsConstant = true;
        let input;
        for(let variableBinding of allVariableBindings) {
           if(variableBinding === binding) continue;
           let fieldId = variableBinding["binding: field"];
           let kind = ixer.selectOne("field", {field: fieldId})["field: kind"];
           if(kind === "output") {
             needsConstant = false;
             break;
           } else {
             input = variableBinding;
           }
        }
        if(needsConstant) {
           let fieldId = input["binding: field"];
           let sourceViewId = ixer.selectOne("source", {source: input["binding: source"]})["source: source view"];
           diffs.push(api.insert("constant", {variable: variableId, value: api.newPrimitiveDefaults[sourceViewId][fieldId]}));
        }
      }
    }
    let ordinal = ixer.selectOne("ordinal binding", {source: sourceId});
    if(ordinal) {
       diffs.push.apply(diffs, removeVariable(ordinal["ordinal binding: variable"]));
    }
    return diffs;
  }

  function addSourceFieldVariable(queryId, sourceViewId, sourceId, fieldId) {
    let diffs = [];
    let kind;
    // check if we're adding an ordinal
    if(fieldId === "ordinal") {
      kind = "ordinal";
    } else {
      kind = ixer.selectOne("field", {field: fieldId})["field: kind"];
    }
    // add a variable
    let variableId = uuid();
    diffs.push(api.insert("variable", {view: queryId, variable: variableId}));
    if(kind === "ordinal") {
      // create an ordinal binding
      diffs.push(api.insert("ordinal binding", {variable: variableId, source: sourceId}));
    } else {
      // bind the field to it
      diffs.push(api.insert("binding", {variable: variableId, source: sourceId, field: fieldId}));
    }
    if(kind === "output" || kind === "ordinal") {
      // select the field
      diffs.push.apply(diffs, dispatch("addSelectToQuery", {viewId: queryId, variableId: variableId, name: code.name(fieldId) || fieldId}, true));
    } else {
      // otherwise we're an input field and we need to add a default constant value
      diffs.push(api.insert("constant", {variable: variableId, value: api.newPrimitiveDefaults[sourceViewId][fieldId]}));
    }
    return diffs;
  }

  //---------------------------------------------------------
  // Dispatch
  //---------------------------------------------------------

  function dispatch(event, info, rentrant?) {
    //console.log("dispatch[" + event + "]", info);
    var diffs = [];
    switch(event) {
      //---------------------------------------------------------
      // Node selection
      //---------------------------------------------------------
      case "selectNode":
        var node = info.node;
        //if this node is already in the selection, we should ignore this
        if(localState.selectedNodes[node.id]) return;
        //if shift isn't pressed, then we need to clear the current selection
        if(!info.shiftKey) {
          dispatch("clearSelection", {}, true);
        }
        localState.selectedNodes[node.id] = node;
        //build a query with the selected things in it
      break;
      case "clearSelection":
        // diffs.push(api.remove("view", api.retrieve("view", {view: localState.selectedViewId})));
        localState.selectedNodes = {};
        localState.selectedViewId = uuid();
      break;
      case "removeSelection":
        var removedSources = {};
        for(let nodeId in localState.selectedNodes) {
          let node = localState.selectedNodes[nodeId];
          if(node.type === "relationship") {
            removedSources[node.id] = true;
            diffs.push.apply(diffs, removeSource(node.id));
          } else if (node.type === "primitive") {
            removedSources[node.sourceId] = true;
            diffs.push.apply(diffs, removeSource(node.sourceId));
          }
        }
        // we need to check for any variables that got orphaned by removing all the given sources
        for(let variable of ixer.select("variable", {view: localState.drawnUiActiveId})) {
          let variableId = variable["variable: variable"];
          let bindings = ixer.select("binding", {variable: variableId});
          let shouldRemove = true;
          for(let binding of bindings) {
            if(!removedSources[binding["binding: source"]]) {
              shouldRemove = false;
              break;
            }
          }
          if(shouldRemove) {
            diffs.push.apply(diffs, removeVariable(variableId));
          }
        }
        dispatch("clearSelection", {}, true);
      break;
      case "startBoxSelection":
        //if shift isn't pressed, then we need to clear the current selection
        if(!info.shiftKey) {
          dispatch("clearSelection", {}, true);
        }
        localState.selecting = true;
        localState.boxSelection = {start: info.coords};
      break;
      case "continueBoxSelection":
        if(!localState.selecting) return;
        localState.boxSelection.end = info;
      break;
      case "endBoxSelection":
        if(localState.boxSelection && localState.boxSelection.end) {
          var boxSelectedNodes = getNodesInRectangle(localState.drawnUiActiveId, localState.boxSelection);
          boxSelectedNodes.forEach((info) => {
            let {node} = info;
            localState.selectedNodes[node.id] = node;
          });
        }
        localState.selecting = false;
        localState.boxSelection = false;
      break;
      //---------------------------------------------------------
      // Node positioning
      //---------------------------------------------------------
      case "setDragOffset":
        localState.dragOffsetX = info.x;
        localState.dragOffsetY = info.y;
      break;
      case "setNodePosition":
        var originalPosition = positions[info.node.id];
        var offsetLeft = info.pos.left - originalPosition.left;
        var offsetTop = info.pos.top - originalPosition.top;
        var selectionSize = 0;
        for(let nodeId in localState.selectedNodes) {
          let node = localState.selectedNodes[nodeId];
          let prevPosition = positions[node.id];
          positions[node.id] = {left: prevPosition.left + offsetLeft, top: prevPosition.top + offsetTop};
          selectionSize++;
        }
        // if we have only one thing selected we need to check for overlaps to show potential actions that
        // could take place
        if(selectionSize === 1) {
          localState.overlappingNodes = actionableIntersections(localState.drawnUiActiveId, info.node.id).lookup;
        } else {
          localState.overlappingNodes = {};
        }
      break;
      case "finalNodePosition":
       var selectionSize = 0;
       for(let nodeId in localState.selectedNodes) {
          let node = localState.selectedNodes[nodeId];
          let currentPos = positions[node.id];
          diffs.push(api.insert("editor node position", {node: nodeId, x: currentPos.left, y: currentPos.top}),
                     api.remove("editor node position", {node: nodeId}));
          selectionSize++;
        }
        // @TODO: Check for potential overlap with other nodes
        if(selectionSize === 1) {
          let {lookup, actions} = actionableIntersections(localState.drawnUiActiveId, info.node.id);
          for(let action of actions) {
            diffs.push.apply(diffs, dispatch(action.action, action, true));
          }
        }
      break;
      //---------------------------------------------------------
      // Navigation
      //---------------------------------------------------------
      case "openRelationship":
        localState.drawnUiActiveId = info.node.source["source: source view"];
        diffs = dispatch("clearSelection", {}, true);
      break;
      case "openQuery":
        localState.drawnUiActiveId = info.queryId;
      break;
      case "gotoQuerySelector":
        localState.drawnUiActiveId = false;
      break;
      //---------------------------------------------------------
      // Query building
      //---------------------------------------------------------
      case "createNewQuery":
        let newId = uuid();
        localState.drawnUiActiveId = newId;
        diffs = [
          api.insert("view", {view: newId, kind: "join", dependents: {"display name": {name: "New query!"}, "tag": [{tag: "remote"}]}})
        ];
      break;
      case "addViewAndMaybeJoin":
        var sourceId = uuid();
        var queryId = localState.drawnUiActiveId;
        diffs = [
          api.insert("source", {view: queryId, source: sourceId, "source view": info.viewId})
        ];
        // if there's a selection, we want to try and join on those nodes if possible
        // so that we don't produce product joins all the time
        var potentialJoinNodes = {};
        for(let selectedId in localState.selectedNodes) {
          let node = localState.selectedNodes[selectedId];
          // we can only join on attributes
          if(node.type === "attribute") {
            potentialJoinNodes[node.name] = node;
          }
        }
        // add variables for all the fields of this view
        var sourceView = ixer.selectOne("view", {view: info.viewId});
        ixer.select("field", {view: info.viewId}).forEach(function(field) {
            let fieldId = field["field: field"];
            let name = code.name(fieldId);
            // check if we should try to join this field to one of the potential join nodes
            if(potentialJoinNodes[name]) {
              // if we're going to join, we just need a binding to this node
              diffs.push(api.insert("binding", {source: sourceId, field: fieldId, variable: potentialJoinNodes[name].variable}));
            } else {
              // otherwise we need to create a variable for this field
              diffs.push.apply(diffs, addSourceFieldVariable(queryId, info.viewId, sourceId, fieldId));
            }
        });
      break;
      case "joinNodes":
        var {target, node} = info;
        if(!node || !target) throw new Error("Trying to join at least one non-existent node");
        var variableId = node.variable;
        var variableIdToRemove = target.variable;

        // transfer all the bindings to the new variable
        var oldBindings = ixer.select("binding", {variable: variableIdToRemove});
        for(let binding of oldBindings) {
          let sourceId = binding["binding: source"];
          let fieldId = binding["binding: field"];
          diffs.push(api.insert("binding", {variable: variableId, source: sourceId, field: fieldId}));
        }
        // check for an ordinal binding and move it over if it exists
        var ordinalBinding = ixer.selectOne("ordinal binding", {variable: variableIdToRemove});
        if(ordinalBinding) {
          diffs.push(api.insert("ordinal binding", {variable: variableId, source: ordinalBinding["ordinal binding: source"]}));
        }

        // remove the old variable
        diffs.push.apply(diffs, removeVariable(variableIdToRemove));

        //if either of these nodes are a primitive input, then we should remove any constant
        //constraint that was on there.
        var primitiveNode;
        var nonPrimitiveNode;
        if(target.isInput) {
          primitiveNode = target;
          nonPrimitiveNode = node;
        } else if(node.isInput) {
          primitiveNode = node;
          nonPrimitiveNode = target;
        }
        if(primitiveNode) {
          // ensure that these nodes can act as inputs:
          // if it's a vector input this has to be a non-grouped, sourceChunked attribute
          // if it's a scalar input this has to be either grouped or a non-sourceChunked attribute
          if(primitiveNode.inputKind === "vector input" && (nonPrimitiveNode.grouped || !nonPrimitiveNode.sourceChunked)) {
            //we do this as a normal dispatch as we want to bail out in the error case.
            return dispatch("setError", {errorText: "Aggregates require columns as input, try selecting the source and chunking it."});
          } else if(primitiveNode.inputKind === "scalar input" && !nonPrimitiveNode.grouped && nonPrimitiveNode.sourceChunked) {
            //we do this as a normal dispatch as we want to bail out in the error case.
            return dispatch("setError", {errorText: "Normal functions can't take columns as input, you could try unchunking the source or grouping this field."});
          }
          diffs.push(api.remove("constant", {variable: primitiveNode.variable}));
        }
        diffs.push.apply(diffs, dispatch("clearSelection", info, true));
      break;
      case "joinSelection":
        let ids = Object.keys(localState.selectedNodes);
        let root = localState.selectedNodes[ids[0]];
        for(let nodeId of ids.slice(1)) {
          let node = localState.selectedNodes[nodeId];
          diffs.push.apply(diffs, dispatch("joinNodes", {node, target: root}, true));
        }
      break;
      case "unjoinNodes":
        var queryId = localState.drawnUiActiveId;
        var variableIdToRemove = info.variableId;
        var oldBindings = ixer.select("binding", {variable: variableIdToRemove});
         // push all the bindings onto their own variables, skipping the first as that one can reuse
         // the current variable
        for(let binding of oldBindings.slice(1)) {
          let sourceId = binding["binding: source"];
          let fieldId = binding["binding: field"];
          let sourceViewId = ixer.selectOne("source", {source: sourceId})["source: source view"];
          diffs.push.apply(diffs, addSourceFieldVariable(queryId, sourceViewId, sourceId, fieldId));
          diffs.push(api.remove("binding", {variable: variableIdToRemove, source: sourceId, field: fieldId}));
        }
        // check for an ordinal binding and create a new variable for it if it exists
        var ordinalBinding = ixer.selectOne("ordinal binding", {variable: variableIdToRemove});
        if(ordinalBinding) {
          diffs.push.apply(diffs, addSourceFieldVariable(queryId, null, ordinalBinding["ordinal binding: source"], "ordinal"));
          diffs.push(api.remove("ordinal binding", {variable: variableIdToRemove}));
        }
        // we have to check to make sure that if the original binding represents an input it gets a default
        // added to it to prevent the server from crashing
        var fieldId = oldBindings[0]["binding: field"];
        var kind = ixer.selectOne("field", {field: fieldId})["field: kind"];
        if(kind !== "output") {
          let sourceViewId = ixer.selectOne("source", {source: oldBindings[0]["binding: source"]})["source: source view"];
          diffs.push(api.insert("constant", {variable: variableIdToRemove, value: api.newPrimitiveDefaults[sourceViewId][fieldId]}));
        }

      break;
      case "removeSelectFromQuery":
        var selects = ixer.select("select", {view: info.viewId, variable: info.variableId}) || [];
        for(let select of selects) {
          let fieldId = select["select: field"];
          diffs.push(api.remove("field", {field: fieldId}));
        }
        diffs.push(api.remove("select", {view: info.viewId, variable: info.variableId}));
      break;
      case "addSelectToQuery":
        var name = info.name;
        var fields = ixer.select("field", {view: info.viewId}) || [];
        var neueField = api.insert("field", {view: info.viewId, kind: "output", dependents: {
          "display name": {name: name},
          "display order": {priority: -fields.length}
        }});
        var fieldId = neueField.content.field;

        // check to make sure this isn't only a negated attribute
        var onlyNegated = !info.allowNegated;
        var bindings = ixer.select("binding", {variable: info.variableId});
        for(let binding of bindings) {
          let sourceId = binding["binding: source"];
          if(!ixer.selectOne("negated source", {source: sourceId})) {
            onlyNegated = false;
          }
        }
        if(bindings.length && onlyNegated) {
          return dispatch("setError", {errorText: "Attributes that belong to a negated source that aren't joined with something else, can't be selected since they represent the absence of a value."});
        }

        diffs = [
          neueField,
          api.insert("select", {view: info.viewId, field: fieldId, variable: info.variableId})
        ];
      break;
      case "selectSelection":
        for(let nodeId in localState.selectedNodes) {
          let node = localState.selectedNodes[nodeId];
          diffs.push.apply(diffs, dispatch("addSelectToQuery", {variableId: node.variable, name: node.name, viewId: localState.drawnUiActiveId}, true));
        }
      break;
      case "unselectSelection":
        for(let nodeId in localState.selectedNodes) {
          let node = localState.selectedNodes[nodeId];
          diffs.push.apply(diffs, dispatch("removeSelectFromQuery", {variableId: node.variable, viewId: localState.drawnUiActiveId}, true));
        }
      break;
      case "setQueryName":
        if(info.value === ixer.selectOne("display name", {id: info.viewId})["display name: name"]) return;
        diffs.push(api.insert("display name", {id: info.viewId, name: info.value}),
                   api.remove("display name", {id: info.viewId}));
      break;
      case "addFilter":
        var variableId = info.node.variable;
        diffs.push(api.insert("constant", {variable: variableId, value: ""}));
        dispatch("modifyFilter", info, true);
      break;
      case "modifyFilter":
        localState.modifyingFilterNodeId = info.node.id;
      break;
      case "removeFilter":
        var variableId = info.node.variable;
        diffs.push(api.remove("constant", {variable: variableId}));
      break;
      case "stopModifyingFilter":
        //insert a constant
        var variableId = info.node.variable;
        diffs.push(api.remove("constant", {variable: variableId}));
        diffs.push(api.insert("constant", {variable: variableId, value: info.value}));
        localState.modifyingFilterNodeId = undefined;
      break;
      case "chunkSource":
        var sourceId = info.node.source["source: source"];
        diffs.push(api.insert("chunked source", {view: info.viewId, source: sourceId}));
        // we need to group any fields that are joined to ensure the join continues to do what you'd expect
        for(let binding of joinedBindingsFromSource(sourceId)) {
          let fieldId = binding["binding: field"];
          diffs.push(api.insert("grouped field", {view: info.viewId, source: sourceId, field: fieldId}));
        }
      break;
      case "unchunkSource":
        var sourceId = info.node.source["source: source"];
        diffs.push(api.remove("chunked source", {view: info.viewId, source: sourceId}));
        // when you unchunk, we should ungroup the fields that we grouped when chunking.
        for(let binding of joinedBindingsFromSource(sourceId)) {
          console.log(binding);
          let fieldId = binding["binding: field"];
          let variableId = binding["binding: variable"];
          // We have to check for an aggregate binding, as unchunking will cause the
          // vector binding to error out. If there is an aggregate binding, then we have to bail
          // out of unchunking.
          for(let variableBinding of ixer.select("binding", {variable: variableId})) {
            let fieldKind = ixer.selectOne("field", {field: variableBinding["binding: field"]})["field: kind"];
            console.log("fieldKind", fieldKind);
            if(fieldKind === "vector input") {
              return dispatch("setError", {errorText: "Cannot unchunk this source because it's bound to an aggregate, which requires a column."});
            }
          }
          diffs.push(api.remove("grouped field", {view: info.viewId, source: sourceId, field: fieldId}));
        }
      break;
      case "addOrdinal":
        var sourceId = info.node.source["source: source"];
        // @TODO: we need a way to create a variable for this to really work
        var fields = ixer.select("field", {view: info.viewId}) || [];
        var neueField = api.insert("field", {view: info.viewId, kind: "output", dependents: {
          "display name": {name: "ordinal"},
          "display order": {priority: -fields.length}
        }});
        var fieldId = neueField.content.field;
        var variableId = uuid();
        diffs.push(
          neueField,
          // create a variable
          api.insert("variable", {view: info.viewId, variable: variableId}),
          // bind the ordinal to it
          api.insert("ordinal binding", {source: sourceId, variable: variableId}),
          // select the variable into the created field
          api.insert("select", {view: info.viewId, variable: variableId, field: fieldId})
        );
      break;
      case "removeOrdinal":
        var sourceId = info.node.source["source: source"];
        var variableId = ixer.selectOne("ordinal binding", {source: sourceId})["ordinal binding: variable"];
        diffs = removeVariable(variableId);
      break;
      case "groupAttribute":
        var variableId = info.node.variable;
        var bindings = ixer.select("binding", {variable: variableId});
        if(bindings.length > 1) {
          //we do this as a normal dispatch as we want to bail out in the error case.
          return dispatch("setError", {errorText: "Cannot group an attribute that has multiple bindings, not sure what to do."});
          return;
        }
        var sourceId = bindings[0]["binding: source"];
        var fieldId = bindings[0]["binding: field"];
        diffs.push(api.insert("grouped field", {view: info.viewId, source: sourceId, field: fieldId}));
      break;
      case "ungroupAttribute":
        var variableId = info.node.variable;
        var bindings = ixer.select("binding", {variable: variableId});
        if(bindings.length > 1) {
          //we do this as a normal dispatch as we want to bail out in the error case.
          return dispatch("setError", {errorText: "Cannot group an attribute that has multiple bindings, not sure what to do."});
        }
        var sourceId = bindings[0]["binding: source"];
        var fieldId = bindings[0]["binding: field"];
        diffs.push(api.remove("grouped field", {view: info.viewId, source: sourceId, field: fieldId}));
      break;
      case "negateSource":
        diffs.push(api.insert("negated source", {view: info.viewId, source: info.sourceId}));
        // you can't select anything from a negated source, so if there are no joins on a variable this
        // source uses we need to deselect it
        for(let binding of ixer.select("binding", {source: info.sourceId})) {
          let variableId = binding["binding: variable"];
          if(ixer.select("binding", {variable: variableId}).length === 1) {
            diffs.push.apply(diffs, dispatch("removeSelectFromQuery", {variableId: variableId, viewId: localState.drawnUiActiveId}, true));
          }
        }
      break;
      case "unnegateSource":
        diffs.push(api.remove("negated source", {view: info.viewId, source: info.sourceId}));
        // since we removed all your selects when you negated the source, let's re-select them
        var sourceViewId = ixer.selectOne("source", {source: info.sourceId})["source: source view"];
        ixer.select("field", {view: sourceViewId}).forEach(function(field) {
            let fieldId = field["field: field"];
            let binding = ixer.selectOne("binding", {source: info.sourceId, field: fieldId});
            let bindingVariableId = binding["binding: variable"];
            if(!ixer.selectOne("select", {variable: bindingVariableId})) {
              diffs.push.apply(diffs, dispatch("addSelectToQuery", {variableId: bindingVariableId, name: code.name(fieldId), viewId: localState.drawnUiActiveId, allowNegated: true}, true));
            }
        });
      break;
      //---------------------------------------------------------
      // Errors
      //---------------------------------------------------------
      case "setError":
        var errorId = localState.errors.length;
        var newError: any = {text: info.errorText, time: api.now(), id: errorId};
        newError.errorTimeout = setTimeout(() => dispatch("fadeError", {errorId}), 2000);
        localState.errors.push(newError);
      break;
      case "fadeError":
        var errorId = info.errorId;
        var currentError = localState.errors[errorId];
        currentError.fading = true;
        currentError.errorTimeout = setTimeout(() => dispatch("clearError", {errorId: info.errorId}), 1000);
      break;
      case "clearError":
        // localState.errors = false;
      break;
      //---------------------------------------------------------
      // search
      //---------------------------------------------------------
      case "updateSearch":
        localState.searchingFor = info.value;
        localState.searchResults = searchResultsFor(info.value);
      break;
      case "startSearching":
        localState.searching = true;
        var searchValue = info.value || "";
        // when we start searching, lets check if there are attributes selected and if there
        // are, go ahead and add filters to our search for them. This makes it really easy to
        // figure out what you can join those attributes on
        for(let nodeId in localState.selectedNodes) {
          let node = localState.selectedNodes[nodeId];
          if(node.type === "attribute") {
            searchValue += `[field: ${node.name}] `;
          }
        }
        diffs.push.apply(diffs, dispatch("updateSearch", {value: searchValue}, true));
      break;
      case "stopSearching":
        localState.searching = false;
      break;
      case "handleSearchKey":
        if(info.keyCode === api.KEYS.ENTER) {
          // execute whatever the first result's action is
          let currentSearchGroup = localState.searchResults[0];
          if(currentSearchGroup && currentSearchGroup.results.length) {
            let results = currentSearchGroup.results;
            currentSearchGroup.onSelect(null, {result: currentSearchGroup.results[results.length - 1]});
          }
          diffs.push.apply(diffs, dispatch("stopSearching", {}, true));
        } else if(info.keyCode === api.KEYS.ESC) {
          diffs.push.apply(diffs, dispatch("stopSearching", {}, true));
        } else if(info.keyCode === api.KEYS.F && (info.ctrlKey || info.metaKey)) {
          diffs.push.apply(diffs, dispatch("stopSearching", {}, true));
          info.e.preventDefault();
        }
      break;
      //---------------------------------------------------------
      // Tooltip
      //---------------------------------------------------------
      case "showButtonTooltip":
        localState.maybeShowingTooltip = true;
        var tooltip = {
          content: {c: "button-info", children: [
            {c: "header", text: info.header},
            {c: "description", text: info.description},
            info.disabledMessage ? {c: "disabled-message", text: "Disabled because " + info.disabledMessage} : undefined,
          ]},
          x: info.x + 10,
          y: info.y
        };
        if(!localState.tooltip) {
          localState.tooltipTimeout = setTimeout(function() {
            dispatch("showTooltip", tooltip);
          }, 500);
        } else {
          diffs = dispatch("showTooltip", tooltip, true);
        }
      break;
      case "hideButtonTooltip":
        clearTimeout(localState.tooltipTimeout);
        localState.maybeShowingTooltip = false;
        localState.tooltipTimeout = setTimeout(function() {
          if(!localState.maybeShowingTooltip) {
            dispatch("hideTooltip", {});
          }
        }, 10);
      break;
      case "showTooltip":
        localState.tooltip = info;
      break;
      case "hideTooltip":
        localState.tooltip = false;
      break;
      //---------------------------------------------------------
      // Menu
      //---------------------------------------------------------
      case "showMenu":
        localState.menu = {top: info.y, left: info.x, contentFunction: info.contentFunction};
      break;
      case "clearMenu":
        localState.menu = false;
      break;
      default:
        console.error("Unknown dispatch:", event, info);
        break;
    }

    if(!rentrant) {
      if(diffs.length) {
        let formatted = api.toDiffs(diffs);
        ixer.handleDiffs(formatted);
        client.sendToServer(formatted, false);
      }
      render();
    }
    return diffs;
  }

  //---------------------------------------------------------
  // Search
  //---------------------------------------------------------

  function scoreHaystack(haystack, needle) {
    let score = 0;
    let found = {};
    let lowerHaystack = haystack.toLowerCase();
    if(needle.length === 1 && haystack === needle[0]) {
      score += 2;
    }
    for(let word of needle) {
      let ix = lowerHaystack.indexOf(word);
      if(ix === 0) {
        score += 1;
      }
      if(ix > -1) {
        score += 1;
        found[word] = ix;
      }
    }
    return {score, found};
  }

  function sortByScore(a, b) {
    let aScore = a.score.score;
    let bScore = b.score.score;
    if(aScore === bScore) {
      return b.text.length - a.text.length;
    }
    return aScore - bScore;
  }

  var availableFilters = ["field", "tag"];
  function searchResultsFor(searchValue) {
    let start = api.now();

    // search results should be an ordered set of maps that contain the kind of results
    // being provided, the ordered set of results, and a selection handler
    let searchResults = [];

    // see if there are any filters
    let filters = [];
    let normalizedSearchValue = searchValue.trim().toLowerCase();
    for(let filter of availableFilters) {
      let regex = new RegExp(`\\[${filter}:(.*?)\\]\s*`, "g");
      let origSearch = normalizedSearchValue;
      let match;
      while(match = regex.exec(origSearch)) {
        normalizedSearchValue = normalizedSearchValue.replace(match[0], "");
        filters.push({type: filter, value: match[1].trim()});
      }
    }

    let needle = normalizedSearchValue.trim().split(" ");

    let rels = searchRelations(needle, filters);
    if(rels) searchResults.push(rels);

    let glossary = searchGlossary(needle);
    if(glossary) searchResults.push(glossary);

    let end = api.now();
    if(end - start > 5) {
      console.error("Slow search (>5 ms):", end - start, searchValue);
    }
    return searchResults;
  }

  function arrayIntersect(a, b) {
    let ai = 0;
    let bi = 0;
    let result = [];
    while(ai < a.length && bi < b.length){
       if (a[ai] < b[bi] ){ ai++; }
       else if (a[ai] > b[bi] ){ bi++; }
       else {
         result.push(a[ai]);
         ai++;
         bi++;
       }
    }
    return result;
  }

  function searchRelations(needle, filters) {
    let matchingViews = [];
    let viewIds;
    //handle filters
    for(let filter of filters) {
      if(filter.type === "field") {
        // we need to only look at views with a field with the given name
        var potentialViews = [];
        ixer.select("display name", {name: filter.value}).forEach((name) => {
          let field = ixer.selectOne("field", {field: name["display name: id"]});
          if(field) {
            potentialViews.push(field["field: view"]);
          }
       });
       potentialViews.sort();
       if(!viewIds) {
          viewIds = potentialViews;
        } else {
          viewIds = arrayIntersect(viewIds, potentialViews);
        }
      } else if(filter.type === "tag") {
        // we only look at views with the given tag
        let tagged = ixer.select("tag", {tag: filter.value});
        if(!viewIds) {
          viewIds = [];
          tagged.forEach((tag) => {
            viewIds.push(tag["tag: view"]);
          });
          viewIds.sort();
        } else {
          let taggedIds = tagged.map((tag) => tag["tag: view"]).sort();
          viewIds = arrayIntersect(viewIds, taggedIds);
        }
      }
    }

    if(!filters.length) {
      viewIds = ixer.select("view", {}).map((view) => view["view: view"]);
    }

    for(let viewId of viewIds) {
      let name = code.name(viewId);
      let score = scoreHaystack(name, needle);
      if(score.score) {
        let description = ixer.selectOne("view description", {view: viewId});
        if(description) {
          description = description["view description: description"];
        } else {
          description = "No description :(";
        }
        matchingViews.push({text: name, viewId, score, description});
      }
    }
    matchingViews.sort(sortByScore);
    return {kind: "Sources", results: matchingViews, onSelect: (e, elem) => {
      dispatch("addViewAndMaybeJoin", {viewId: elem.result.viewId});
    }};
  }

  function searchGlossary(needle) {
    let matchingTerms = [];
    for(let term of glossary.terms) {
      let score = scoreHaystack(term.term, needle);
      if(score.score) {
        matchingTerms.push({text: term.term, description: term.description, score});
      }
    }
    matchingTerms.sort(sortByScore);
    return {kind: "Glossary", results: matchingTerms, onSelect: () => { console.log("selected glossary item")}};
  }

  //---------------------------------------------------------
  // root
  //---------------------------------------------------------

  function root() {
    var page:any;
    if(localState.drawnUiActiveId) {
      page = queryUi(localState.drawnUiActiveId, true);
    } else {
      page = querySelector();
    }
    return {id: "root", children: [page]};
  }

  function querySelector() {
    var queries = api.ixer.select("view", {kind: "join"}).map((view) => {
      var viewId = view["view: view"];
      return {c: "query-item", queryId: viewId, click: openQuery, children:[
        {c: "query-name", text: code.name(viewId)},
        queryUi(viewId)
      ]};
    });
    return {c: "query-selector-wrapper", children: [
      {c: "button", text: "add query", click: createNewQuery},
      {c: "query-selector", children: queries}
    ]};
  }

  function createNewQuery(e, elem) {
    dispatch("createNewQuery", {});
  }

  function openQuery(e, elem) {
    dispatch("openQuery", {queryId: elem.queryId});
  }

  function queryUi(viewId, showResults = false) {
    var view = ixer.selectOne("view", {view: viewId});
    if(!view) return;
    return {c: "query", children: [
      tooltipUi(),
      localState.drawnUiActiveId ? queryTools(view) : undefined,
      {c: "container", children: [
        {c: "surface", children: [
          {c: "query-name-input", contentEditable: true, blur: setQueryName, viewId: viewId, text: code.name(viewId)},
          queryMenu(view),
          queryCanvas(view),
          queryErrors(view),
        ]},
        showResults ? queryResults(viewId) : undefined
      ]}
    ]};
  }

  function tooltipUi() {
    let tooltip = localState.tooltip;
    if(tooltip) {
      let elem = {c: "tooltip", left: tooltip.x, top: tooltip.y};
      if(typeof tooltip.content === "string") {
        elem["text"] = tooltip.content;
      } else {
        elem["children"] = [tooltip.content];
      }
      return elem;
    }
  }

  function queryErrors(view) {
    let errors = localState.errors.map((error) => {
      let klass = "error";
      if(error.fading) {
        klass += " fade";
      }
      return {c: klass, text: error.text};
    }).reverse();
    return {c: "query-errors", children: errors};
  }

  function queryTools(view) {
    // What tools are available depends on what is selected.
    // no matter what though you should be able to go back to the
    // query selector.
    let tools:any = [
       {c: "tool", text: "back", click: gotoQuerySelector},
    ];

    let viewId = view["view: view"];

    // @FIXME: we ask for the entity info multiple times to draw the editor
    // we should probably find a way to do it in just one.
    let {nodeLookup} = viewToEntityInfo(view);

    let selectedNodes = Object.keys(localState.selectedNodes).map(function(nodeId) {
      // we can't rely on the actual nodes of the uiSelection because they don't get updated
      // so we have to look them up again.
      return nodeLookup[nodeId];
    });

    let disabled = {};
    let actions = {
      "join": {func: joinSelection, text: "Join"},
      "select": {func: selectAttribute, text: "Show"},
      "filter": {func: addFilter, text: "Filter"},
      "group": {func: groupAttribute, text: "Group"},
      "chunk": {func: chunkSource, text: "Chunk"},
      "ordinal": {func: addOrdinal, text: "Ordinal"},
      "negate": {func: negateSource, text: "Negate"},
    }

    // no selection
    if(!selectedNodes.length) {
      disabled = {
        "join": "join only applies to attributes",
        "select": "select only applies to attributes",
        "filter": "filter only applies to attributes",
        "group": "group only applies to attributes",
        "chunk": "chunk only applies to sources",
        "ordinal": "ordinal only applies to sources",
        "negate": "negate only applies to sources",
      }

    // single selection
    } else if(selectedNodes.length === 1) {
      let node = selectedNodes[0];
      if(node.type === "attribute") {
        disabled["chunk"] = "chunk only applies to sources";
        disabled["ordinal"] = "ordinal only applies to sources";
        disabled["negate"] = "negate only applies to sources";
        if(!node.mergedAttributes) {
          // you can't select a node if the source is negated and it's not joined with anything else
          if(node.sourceNegated) {
            disabled["select"] = "negated sources prove the absence of a row, which means you'd be selecting from nothing."
          }
          disabled["join"] = "multiple attributes aren't joined together on this node.";
        } else {
          actions["join"] = {func: unjoinNodes, text: "Unjoin"};
        }

        if(ixer.selectOne("select", {view: viewId, variable: node.variable})) {
          actions["select"] = {func: unselectAttribute, text: "Hide"};
        }
        if(node.filter) {
          actions["filter"] = {func: removeFilter, text: "Unfilter"};
        }
        // if this node's source is chunked or there's an ordinal binding, we can group
        if(node.sourceChunked || node.sourceHasOrdinal) {
          if(node.grouped) {
            actions["group"] = {func: ungroupAttribute, text: "Ungroup"};
          }
        } else {
          disabled["group"] = "To group an attribute, the source must either have an ordinal or be chunked";
        }
      } else if(node.type === "relationship") {
        disabled["select"] = "select only applies to attributes.";
        disabled["filter"] = "filter only applies to attributes.";
        disabled["group"] = "group only applies to attributes.";
        disabled["join"] = "join only applies to attributes.";
        if(node.chunked) {
          actions["chunk"] = {func: unchunkSource, text: "Unchunk"};
        }
        if(node.isNegated) {
          actions["negate"] = {func: unnegateSource, text: "Unnegate"};
        }
        if(node.hasOrdinal) {
          actions["ordinal"] = {func: removeOrdinal, text: "Unordinal"};
        }

      }

    //multi-selection
    } else {
      disabled = {
        "filter": "filter only applies to single attributes",
        "group": "group only applies to single attributes",
        "chunk": "chunk only applies to single sources",
        "ordinal": "ordinal only applies to single sources",
        "negate": "negate only applies to single sources",
      }

      // join and select are only valid if everything is an attribute, so if we
      // find a non-attribute, we have to disable them
{'fraction_non_alphanumeric': 0.16294446522631456, 'fraction_numerical': 0.0013749734609901831, 'mean_word_length': 2.1637666325486182, 'pattern_counts': {'":': 146, '<': 31, '<?xml version=': 0, '>': 63, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '7186098', 'n_tokens_mistral': 27264, 'n_tokens_neox': 26755, 'n_words': 7298}