[script] Dock (mostly for programmers)

When I was working on the new addons, I thought about problems with space for the scripts in Eyewire. So, this time I’ve decided, that I’ll create a common space for all of them. That’s how the Dock idea was born.
It’s created as a Singleton (both the class and the whole script) and it’s injected directly into FlyWire source code. If you decide to use it, you’ll code will also be injected into the page, so there’ll be no issues with inaccessible window object.

To use it in your own addon, paste this code at the beginning of your TM script:

(() => {
  if (globalThis.dockIsReady) return main()
  let script = document.createElement('script')
  script.src = 'https://chrisraven.github.io/FlyWire-Dock/Dock.js'
  document.head.appendChild(script)
  let wait = setInterval(() => {
    if (globalThis.dockIsReady) {
      clearInterval(wait)
      main()
    }
  }, 100)
})()

It will check if the Dock has already been initialized (by another active addon) and if so, it’ll just run your main() function. Otherwise, it’ll inject the Dock code into the site and then run your main() function, when the Dock class is ready. It also waits for the left menu and user profile to be loaded, so you can be quite sure, that everything, that’ll be inside the main() function will work correctly timewise.

Inside the main() use this code:

function main() {
  let dock = new Dock()
  dock.addAddon([configuration object])
}

The configuration object should look like this:

{
  id: id_of_your_html_container,
  name: name/title_of_your_addon,
  html: html_part_of_your_script,
  css: css_part_of_the_script,
  events: events_that_you_want_to_append_to_the_html
}

The first four parameters should be strings and the last one (events) - object. The object should look like this:

{
  'selector1': {
    event1: callback1,
    event2: callback2,
    event3: { singleNode: true, handler: callback3 }
    // etc
  },
  'selector2': {
    // events as above
  }
  // etc
}

For example:

{
  '#my_addon': {
    click: leftClickHandler,
    contextmenu: rightClickHandler
  },
  '#my_addon button' {
    click: buttonHandler
    mousemove: () => buttonMoveHandler(param)
    rightclick: (event) => buttonRightClickHandler(event, anotherParam)
  },
  '.many-nodes': {
    click: clickHandler
    dblclick: { singleNode: true, doubleClickHandler }
  }

The last two lines show how to pass parameters to the handlers.

If you need userId (actually, it’s a user’s e-mail, but it should be unique per player, so we can use it as an id), you can access it as a static field in the Dock class: Dock.userId.

The html you pass to the script, will be wrapped with a div, which id will be the id you’ve passed with the configuration object.

All configuration parameters are optional, but it you want to pass the html, you also have to pass id and name of the addon.

The id will be used for the wrapper, as said above, and the name will be displayed as the title of the addon (see the Presets addon and it’s title inside the popup - the frame around the buttons and the title are all added via the Dock script).

You you have any suggestions, how to improve the script, feel free to comment here or do pull requests on GitHub.
Here the link to the repository:

3 Likes

Functions of the Dock

It keeps all the addons in one place.
There are two small buttons in the top right corner of the Dock:

dock-buttons

If you click on the first one (A), a grid will show:

Dock-grid

When you see the grid, you can grab any of the addons (well, currently only one addon) by it’s title (in this case “Presets”) and drag it whereever you want. You can drag the addons even beyond the Dock, if you want. When you’re done with the positioning, just press the “A” button again and the grid will disappear.

When you click the second one (R), another popup will appear:

Here you can change the size of the Dock. It will still be at the top center of the window, but can have different sizes. Hopefully, in the future we’ll have to resize it much more :wink:

2 Likes

Added to the configuration object another object called options. For now, it has only one property - htmlOutsideDock. It deafults to false, but when set to true, the html passed to the addAddon() method isn’t appended to the Dock container, but to the document.body.

Example:

let dock = new Dock()
dock.addAddon({
  html: '<div>MyAddon</div>
  options: {
    htmlOutsideDock: true
  }
})

When this option is set to true, both id and name properties aren’t needed.

2 Likes

html field can now also be a function or an object.

Use as function:

dock.addAddon({
  html: generateHtml
})

function generateHtml() {
  return '<div>My HTML</div>'
}

Use as object:

dock.addAddon({
  html: {
    insert: {
      target: 'CSS selector',
      position: 'one of: beforebegin, afterbegin, beforeend, afterend'
    },
    text: '<div>My HTML</div>'
  }
})

The position values are described in MDN at insertAdjacentHTML.

2 Likes

Fixed issue, that when the Dock was in the moving mode (button A pressed, grid visible) and user resized the Dock, the grid didn’t match the new size until refresh.

State of the Dock (open/closed) is now remembered between refreshes.

Selectors in the Events object can now point to mutiple elements and each event will be added to each of the selected elements.

Added a proxy/mitm to the built-in fetch() function. Whenever Neuroglancer or Flywire uses the fetch() function, its url, params, body and response are copied. Then, when the fetch() response is ready, a “fetch” event is dispatched. In the “detail” field of the event you can access all the data from the communication. The response is cloned and already converted to JSON, so you don’t have to worry, that you’ll read the ReadableStream and the original target won’t be able to read it again. The event it dispatched on the “document” element.
Example of use:

document.addEventListener('fetch', e => {
  console.log(e.detail.url) // request url
  console.log(e.detail.params) // everything, that was passed as a second argument to the fetch() function (e.g. headers, method or body, if exists)
  console.log(e.detail.params.body) // body of a POST request (if exists)
  console.log(e.detail.response) // JSON object containing the response
})

For me, it was a useful way to detect all the splits, merges and claims of new cells and the results of these operations.

1 Like

Added third button - “M” (Move Dock), which works more like a handle. You left-click on it and while holding the button down, you can move the Dock whereever you want.

Changed the transparency and border radii to make the Dock look more like the Toolbox in Eyewire.

Renamed the “A” button (Addons) to “O” (Organize addons) and added tooltips to all the buttons.

Made the Element containing the Dock publicly available (via Dock.element) and made some unnecessary fields private.

Fixed an error, which caused, that it was impossible to resize the Dock, if a localStorage entry wasn’t available (which wasn’t by default).

Cleaned up the code a little bit.

2 Likes

Added some utility functions (well, actually moved them from the Permanent Colors addon to the Dock).

Here they are:

getSegmentId(x, y, z, callback)

Gets coordinates in form of 3 numbers and a callback and the callback receives the segmentId, that exists at the coords.
To use this function, you have to add these tags to your TM script:

// @grant        GM_xmlhttpRequest
// @grant        unsafeWindow
// @connect      services.itanna.io

ITANNA stands for “Interactive Tools for Analysis of Neuronal Network Anatomy" and is a NIH funded initiative to prepare some tools and services for working with varius Drosophila data.

To use the function you also have to add this prefix code instead of the usual one:

if (unsafeWindow.dockIsReady) return main()
let script = document.createElement('script')
script.src = 'https://chrisraven.github.io/FlyWire-Dock/Dock.js'
document.head.appendChild(script)
let wait = setInterval(() => {
  if (unsafeWindow.dockIsReady) {
    unsafeWindow.GM_xmlhttpRequest = GM_xmlhttpRequest
    clearInterval(wait)
    main()
  }
}, 100)

The difference is using unsafeWindow instead of globalThis and passing the GM_xmlhttpRequest function to the unsafeWindow to make it available in the context of the Dock.

getRootId(supervoxelId, callback)

Accepts a supervoxelId (e.g. from the getSegmentId() function) and the callback receives the rootId for that segment/supervoxel.

stringToUint64(string)

Accepts a number in form of a string (e.g. segmentId) and returns an Uint64 object (used extensively inside the Neuroglancer).
The Uint64 format is used, because the segment ids are larger than Number.MAX_SAFE_INTEGER. Currently BigInt's could be used, but when Neuroglancer was designed, that type wasn’t widely available (if at all).

rgbToUint64(string)

Takes a color value in form of a hex string (e.g. '#00FF00', but NOT the short form, e.g. '#FFF') and converts it to the Uint64 type.

EDIT:
All the methods are static, so you call them via:
Dock.methodName()

1 Like

Added 4 new static functions:

getCurrentCoords()
Returns the coordinates visible in the top bar (the white ones - corresponding to the current position of the axis). The result is an array.

jumpToCoords(coords)
Moves axis into given coordinates. Te argument should be in form of an array (e.g. from the getCurrentCoords() method).

getCurrentMouseCoords()
Returns current position of the mouse cursor as an array (3-dimensional vector). Works only, when mouse cursor is over the 2D panel. Those are the orange coords from the top bar.

getHighlightedSupervoxelId()
Returns ID of the supervoxel/leaf, which is currently under the mouse cursor. Again, works only, if cursor is over the 2D panel. It’s the value, which we can see to the right of the graph panel name.

All the get...() functions read their values from the UI, so they’re a bit sketchy, but should work :smiley:

Edit:
Also added posibility to pass an object as an event handler. The object should be in this form:

{
  singleNode: true,
  handler: myHandler
}

I’ve added it, so we could add an event to only the first element from the passed selector. I did it, because, for some reason, .querySelectorAll() worked correctly for this selector '.neuroglancer-layer-side-panel', but didn’t return anything for '.neuroglancer-layer-side-panel:first-of-type'. Which is weird, but couldn’t do anything with that.
I’ve also updated the example in the first post of this topic.

2 Likes

Added 3 more static functions:

getVoxelSize()
Returns a 3-dimensional vector (array), containing the size of a single voxel. Currently it’s 4 x 4 x 40

multiplyVec3()
divideVec3()
These two allows to, accordingly, multiply and divide two 3-dim vectors.
It’s useful, because sometimes the dimensions are already correct for the jumpToCoords() function and sometimes you have to multiply them by the voxel size (e.g. split points and annotations).

2 Likes

Created a static object literal called annotations which contains some useful functions for working with annotations:

add(coords, [type], [description])
This one creates a new annotation of a type (currently only POINT is working) at given coords with optional description. Returns ID of a reference to that annotation.

editDescription(refId, newDescription)
Allows changing of the descrption of an already existing annotation.

remove(refId)
Removes annotation, if exists.

There’s also an enum with all types of annotations:

type: {
          POINT: 0,
          LINE: 1,
          AXIS_ALIGNED_BOUNDING_BOX: 2,
          ELLIPSOID: 3,
          COLLECTION: 4,
          LINE_STRIP: 5,
          SPOKE: 6
        }

As I said before, for now, only POINT is supported.

Each of the functions creates an annotation tab, if one doesn’t already exist.

There are 2 other functions, used internally, but you can also use them:

getAnnotationLayer()
Creates a new annotation tab, if one doesn’t alredy exist and returns .layer.annotationLayerState.value.source, from which you can call various methods on all existing annotations.

getRef(refId)
Returns a reference to an annotation by an ID. You can call on it various methods, which will modify that single annotation.

Example use:

// create new annotation
let refId = Dock.annotations.add(Dock.getCurrentMouseCoords(), Dock.annotations.type.POINT, 'decription with typo')
// edit annotation's decription
Dock.annotations.editDescription(refId, 'description without typo')

// delete annotation
Dock.annotations.remove(refId)

Second thing:

There’s a new way to use the Dock inside your script. Instead of the old one, use this code at the beginning of you addon:

if (!document.getElementById('dock-script')) {
  let script = document.createElement('script')
  script.id = 'dock-script'
  script.src = 'https://chrisraven.github.io/FlyWire-Dock/Dock.js'
  document.head.appendChild(script)
}

let wait = setInterval(() => {
  if (unsafeWindow.dockIsReady) {
    clearInterval(wait)
    main()
  }
}, 100)

The previous version worked, but it created multiple <script> tags in the DOM (one for Dock from each script)

2 Likes

Added a new static method called Dock.dialog(). It will create a Dialog object on which you can call a show() method. During initialization, you can pass a properties object to the dialog() method:

let myDialog = Dock.dialog({
  html: /* content of the dialog */,
  id: /* id of the wrapper containing whole dialog html */,
  css: /* additional styles of a dialog */,
  okCallback: /* callback called, when the OK button is clicked */,
  okLabel: /* string with the name of the OK button (default is "OK") */,
  cancelCallback: /*callback called, when the Cancel button is clicked */,
  cancelLabel: /* string with the name of the Cancel button (default is "Cancel") */
})

myDialog.show() // displays the dialog
myDialog.hide() // hides the dialog
myDialog.id // ID you've passed as one of the parameters

if you omit any of the callbacks, the button associated with it won’t be displayed.
Both default buttons will close the dialog after calling their associated callbacks.
The html and events are created at the moment the .dialog() method is called, so you can modify the dialog before displaying it.
If you want to add more buttons or modify the existing ones, the selector is:
`#${myDialog.id} .button-wrapper`
For the content:
`#${myDialog.id} .content`

2 Likes

Added a static object literal called layers. Currently it has 4 methods:

getByName(name, [withIndexes = true])
Returns an array of layers with a given name.

getByType(type, [withIndexes = true])
Returns an array of layers with a given type. The current types are: image, segmentation_with_graph and annotation. The brain mesh layer doesn’t have a type. It’s only accessible by name - brain_mesh_v141.surf.

getAll()
Returns all the layers.

remove(index)
Removes a layer with specified index.

First two functions return either an array of layers (if the second argument is set to false), or array of {layer: /*layer object*/, index: /*index of the layer*/} objects.

The third function returns all the layers. If you want an index of a layer, just loop over the returned array with .forEach() and the second argument to the callback will be the index.

Fourth function delete a layer by an index. Warning: if you want to delete multiple layers at once and got an index array from some of the previous methods, reverse the array (array.reverse()). The reason is, that if you have, for example, an array of [3, 4] and you first delete the layer with index === 3, then all the layers after the deleted one will be reindexed, and the previous 4th will be now 3rd.

Example
Removes all annotation layers

let layers = Dock.layers.getByType('annotation')
let indexes = layers.map(layer => layer.index)
indexes.reverse().forEach(index => Dock.layers.remove(index))

Also added an active class to the classes available in the Dock. Useful mostly for buttons and text/number/email inputs.

2 Likes

Added .getMulticutRef(field, value) method to Dock.annotations object.

The first argument - field - can be either description or id.
The second one is the value of the selected field.
To get a value for description, you can use the Dock.getHighlightedSupervoxelId() method for example.
To get a value for id, you can use viewer.mouseState.pickedAnnotationId.

The method itself returns an object with two fields {source, reference}.
The first one points to the source of an annotation (there are two sources - A and B) and the second one is a reference to the annotation, you were looking for.

Example
Removes a mulicut point from segment under cursor (in 2D), if exists.

let description = Dock.getHighlightedSupervoxelId()
let point = Dock.annotations.getMulticutRef('description', description)
if (!point) return
point.source.delete(point.reference)
2 Likes

Added .toString() method to the result of Dock.stringToUint64(). Also, the result is now not an object literal, but an object inheriting from the Object object. I did it, because I needed the .toString() method to work when stringifying the result, but at the same, don’t be present in the result when cloning in Worker.postMessage().

Added .getRandomHexString() static method (copied from the Neuroglancer source) to generate ids for save states.

1 Like

Is there a keyboard shortcut to show/hide the dock, and if not, could there be one? I’d rather do that than have to pop open the left panel for Addons each time (I usually keep the left panel collapsed).

Alternatively, could Addons be added as a small icon somewhere on the top info bar?

1 Like

Shortcut could be added. A small icon should be doable too. I’ll do both, when I’ll be doing some programming.

2 Likes

Added a shortcut - Shift + A to toggle the visbility (Shift + a works too, of course).
As for an icon, I’m not sure, where would be the best place for it, so, for now, I’m not doing it.

2 Likes

Thanks KK!

Let me know if this is linked to the other issues with chat/keyboard commands being activated, but Shift+A is toggling Dock on/off when I type in chat. I’m assuming I need to pass this to our devs as a chat issue, yeah?

Oh, I totally forgot about the chat. I’ll try to fix it on my side.

1 Like

Ok, should be fixed (requires refreshing).

2 Likes