Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions histomicsui/web_client/panels/DrawWidget.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ var DrawWidget = Panel.extend({
'click .h-draw': 'drawElement',
'click .h-group-count-option .h-group-count-select': 'selectElementsInGroup',
'change .h-style-group': '_setToSelectedStyleGroup',
'change .h-sort-mode': '_changeSortMode',
'click .h-sort-order': '_toggleSortOrder',
'change .h-brush-shape,.h-brush-size,.h-brush-screen': '_changeBrush',
'change .h-fixed-shape,.h-fixed-height,.h-fixed-width': '_changeShapeConstraint',
'click .h-configure-style-group': '_styleGroupEditor',
Expand Down Expand Up @@ -110,6 +112,7 @@ var DrawWidget = Panel.extend({
delete this._skipRenderHTML;
}
} else {
this._sortElements();
this.$el.html(drawWidget({
title: 'Draw',
elements: this.collection.models,
Expand All @@ -123,6 +126,8 @@ var DrawWidget = Panel.extend({
collapsed: this.$('.s-panel-content.collapse').length && !this.$('.s-panel-content').hasClass('in'),
firstRender: true,
displayIdStart: 0,
sortMode: this._editOptions.sort_mode || 'label',
sortOrder: this._editOptions.sort_order || 'asc',
partialCount: this.annotation && this.annotation._pageElements
}));
this.$('.h-dropdown-content').collapse({toggle: false});
Expand Down Expand Up @@ -372,6 +377,7 @@ var DrawWidget = Panel.extend({
}
}
}
this._reorderElementDom();
},

/**
Expand Down Expand Up @@ -818,6 +824,17 @@ var DrawWidget = Panel.extend({
if (!opts.size_mode) {
opts.size_mode = 'unconstrained';
}
if (opts.sort_mode === 'label-reverse') {
// migrate the legacy combined mode/order value
opts.sort_mode = 'label';
opts.sort_order = 'desc';
}
if (!opts.sort_mode || !['label', 'group', 'shape', 'count'].includes(opts.sort_mode)) {
opts.sort_mode = 'label';
}
if (!opts.sort_order || !['asc', 'desc'].includes(opts.sort_order)) {
opts.sort_order = 'asc';
}
},

updateCount(groupName, change) {
Expand Down Expand Up @@ -1089,6 +1106,128 @@ var DrawWidget = Panel.extend({
this.parentView.trigger('h:highlightAnnotation');
},

/**
* Resolve the displayed shape name of an element, matching the label logic in
* drawWidgetElement.pug (closed polylines are polygons, open ones are lines).
*
* @param {ElementModel} model The element to inspect.
* @returns {string} The shape name.
*/
_elementShape(model) {
const element = model.attributes;
return element.type === 'polyline'
? (element.closed ? 'polygon' : 'line')
: element.type;
},

/**
* Resolve the group name of an element, falling back to the default group.
*
* @param {ElementModel} model The element to inspect.
* @returns {string} The group name.
*/
_elementGroupName(model) {
return model.attributes.group || this.parentView._defaultGroup;
},

/**
* Compute a lexical sort key for an element that matches its displayed label. The number is
* intentionally omitted so that elements sort by their base label before enumeration.
*
* @param {ElementModel} model The element to compute a key for.
* @returns {string} A lower-case sort key.
*/
_elementSortKey(model) {
const element = model.attributes;
const userLabel = (element.label || {}).value;
if (userLabel) {
return ('' + userLabel).toLowerCase();
}
const shape = this._elementShape(model);
if (['point', 'polyline', 'rectangle', 'ellipse', 'circle'].includes(element.type)) {
return `${this._elementGroupName(model)} ${shape}`.toLowerCase();
}
return ('' + shape).toLowerCase();
},

/**
* Count how many elements in the current collection belong to each group.
*
* @returns {Object} A map of group name to element count.
*/
_elementGroupCounts() {
const counts = {};
this.collection.models.forEach((model) => {
const group = this._elementGroupName(model);
counts[group] = (counts[group] || 0) + 1;
});
return counts;
},

/**
* Sort the element collection's models in place according to the current sort mode.
*/
_sortElements() {
const groupCounts = this._elementGroupCounts();
const comparators = {
label: (elementA, elementB) => this._elementSortKey(elementA).localeCompare(this._elementSortKey(elementB)),
group: (elementA, elementB) => this._elementGroupName(elementA).toLowerCase().localeCompare(this._elementGroupName(elementB).toLowerCase()),
shape: (elementA, elementB) => this._elementShape(elementA).toLowerCase().localeCompare(this._elementShape(elementB).toLowerCase()),
count: (elementA, elementB) => {
const groupA = this._elementGroupName(elementA);
const groupB = this._elementGroupName(elementB);
const countDiff = groupCounts[groupA] - groupCounts[groupB];
if (countDiff !== 0) {
return countDiff;
}
return groupA.toLowerCase().localeCompare(groupB.toLowerCase());
}
};
const comparator = comparators[this._editOptions.sort_mode] || comparators.label;
const ordered = this._editOptions.sort_order === 'desc'
? (elementA, elementB) => -comparator(elementA, elementB)
: comparator;
this.collection.models.sort(ordered);
},

/**
* Reorder the already-rendered element rows to match the current sort without rebuilding the
* list. Auto-assigned enumeration numbers are left as-is here and are recomputed on the next
* full render.
*/
_reorderElementDom() {
const container = this.$el.find('.h-elements-container');
if (!container.length) {
return;
}
this._sortElements();
const rowsById = {};
container.children('.h-element').each((index, node) => {
rowsById[$(node).attr('data-id')] = node;
});
this.collection.models.forEach((model) => {
const node = rowsById[model.id];
if (node) {
container.append(node);
}
});
},

_changeSortMode() {
this._saveEditOptions({sort_mode: this.$('.h-sort-mode').val()});
this.render();
},

/**
* Toggle between ascending and descending order for the current sort
* mode, persist the choice, and re-render.
*/
_toggleSortOrder() {
const order = this._editOptions.sort_order === 'desc' ? 'asc' : 'desc';
this._saveEditOptions({sort_order: order});
this.render();
},

_recalculateGroupAggregation() {
const groups = [];
const used = {};
Expand Down
22 changes: 22 additions & 0 deletions histomicsui/web_client/stylesheets/panels/drawWidget.styl
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,28 @@
flex-shrink 0
padding-left 6px

.h-sort-row
display flex
flex-direction row
align-items center
width 100%
margin 10px 0

.h-sort-label
flex-shrink 0
margin 0 6px 0 0
font-weight bold

.h-sort-mode
flex-grow 1
flex-shrink 1
width auto

.h-sort-order
flex-shrink 0
margin-left 6px
cursor pointer

.btn-default.active
background-color #5790ff

Expand Down
11 changes: 11 additions & 0 deletions histomicsui/web_client/templates/panels/drawWidget.pug
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,17 @@ block content
label(title="If checked, the size is in screen pixels. If unchecked, the size is in base image pixels")
input.h-brush-screen(type="checkbox", checked=opts.brush_screen ? 'checked' : undefined)
| Screen
.h-sort-row
label.h-sort-label(for='h-sort-mode') Sort By
select#h-sort-mode.h-sort-mode.form-control.input-sm(title='Sort annotation elements')
option(value='label', selected=sortMode === 'label') Label
option(value='group', selected=sortMode === 'group') Group
option(value='shape', selected=sortMode === 'shape') Shape
option(value='count', selected=sortMode === 'count') Count
a.h-sort-order(
href='javascript:void(0)',
title=sortOrder === 'desc' ? 'Sort descending (click to sort ascending)' : 'Sort ascending (click to sort descending)')
i(class=sortOrder === 'desc' ? 'icon-sort-alt-down' : 'icon-sort-alt-up')
.h-group-count
if partialCount
b.h-group-count-label(title='* These counts only apply to loaded elements') Count*:
Expand Down
124 changes: 120 additions & 4 deletions tests/web_client_specs/annotationSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -276,13 +276,21 @@ girderTest.promise.done(function () {
return $('.h-elements-container .h-element').length === 2;
}, 'point to be created');
runs(function () {
expect($('.h-elements-container .h-element:last .h-element-label').text()).toBe('default point 2');
// Elements are sorted lexically by label, so target the newly drawn point by
// its label rather than by position.
expect($('.h-elements-container .h-element .h-element-label').filter(function () {
return $(this).text() === 'default point 2';
}).length).toBe(1);
});
checkAutoSave('drawn 1', 2, annotationInfo);
});

it('delete the second point', function () {
$('.h-elements-container .h-element:last .h-delete-element').click();
// Elements are sorted lexically by label, so delete the just drawn point by its
// label rather than by position.
$('.h-elements-container .h-element').filter(function () {
return $(this).find('.h-element-label').text() === 'default point 2';
}).find('.h-delete-element').click();
expect($('.h-elements-container .h-element').length).toBe(1);
checkAutoSave('drawn 1', 1, annotationInfo);
});
Expand Down Expand Up @@ -312,7 +320,11 @@ girderTest.promise.done(function () {
return $('.h-elements-container .h-element').length === 2;
}, 'point to be created');
runs(function () {
expect($('.h-elements-container .h-element:last .h-element-label').text()).toBe('default point 2');
// Elements are sorted lexically by label, so target the newly drawn point by
// its label rather than by position.
expect($('.h-elements-container .h-element .h-element-label').filter(function () {
return $(this).text() === 'default point 2';
}).length).toBe(1);
});
checkAutoSave('drawn 1', 2, annotationInfo);
});
Expand Down Expand Up @@ -349,7 +361,11 @@ girderTest.promise.done(function () {
});

it('delete the last point', function () {
$('.h-elements-container .h-element:last .h-delete-element').click();
// Elements are sorted lexically by label, so delete the just drawn point by its
// label rather than by position.
$('.h-elements-container .h-element').filter(function () {
return $(this).find('.h-element-label').text() === 'default point 2';
}).find('.h-delete-element').click();
expect($('.h-elements-container .h-element').length).toBe(1);

// reset the draw state
Expand Down Expand Up @@ -609,6 +625,106 @@ girderTest.promise.done(function () {
});
});

describe('Sort By', function () {
var drawWidget;
var testLabels = ['Alpha', 'Bravo', 'Charlie', 'Delta'];
var testIds = ['sort-test-alpha', 'sort-test-bravo', 'sort-test-charlie', 'sort-test-delta'];

function testElementLabels() {
return $('.h-elements-container .h-element-label').map(function () {
return $(this).text();
}).get().filter(function (text) {
return testLabels.indexOf(text) !== -1;
});
}

it('add elements with distinct labels, groups, and shapes', function () {
runs(function () {
drawWidget = huiTest.app.bodyView.drawWidget;
drawWidget.collection.add([
{id: 'sort-test-charlie', type: 'point', label: {value: 'Charlie'}, group: 'rareGroup'},
{id: 'sort-test-alpha', type: 'point', label: {value: 'Alpha'}, group: 'commonGroup'},
{id: 'sort-test-bravo', type: 'rectangle', label: {value: 'Bravo'}, group: 'commonGroup'},
{id: 'sort-test-delta', type: 'polyline', closed: false, label: {value: 'Delta'}, group: 'commonGroup'}
]);
});
waitsFor(function () {
return testElementLabels().length === testLabels.length;
}, 'test elements to be added');
});

it('defaults to sorting by label, ascending', function () {
runs(function () {
expect($('.h-sort-mode').val()).toBe('label');
expect($('.h-sort-order i').hasClass('icon-sort-alt-up')).toBe(true);
expect(testElementLabels()).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta']);
});
});

it('toggles to descending order', function () {
runs(function () {
$('.h-sort-order').click();
});
runs(function () {
expect($('.h-sort-order i').hasClass('icon-sort-alt-down')).toBe(true);
expect($('.h-sort-order').attr('title')).toMatch(/^Sort descending/);
expect(testElementLabels()).toEqual(['Delta', 'Charlie', 'Bravo', 'Alpha']);
});
});

it('toggles back to ascending order', function () {
runs(function () {
$('.h-sort-order').click();
});
runs(function () {
expect($('.h-sort-order i').hasClass('icon-sort-alt-up')).toBe(true);
expect($('.h-sort-order').attr('title')).toMatch(/^Sort ascending/);
expect(testElementLabels()).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta']);
});
});

it('sorts by group', function () {
runs(function () {
$('.h-sort-mode').val('group').trigger('change');
});
runs(function () {
var order = testElementLabels();
// "commonGroup" sorts before "rareGroup" lexically.
expect(order.indexOf('Charlie')).toBe(order.length - 1);
});
});

it('sorts by shape', function () {
runs(function () {
$('.h-sort-mode').val('shape').trigger('change');
});
runs(function () {
var order = testElementLabels();
expect(order.indexOf('Delta')).toBeLessThan(order.indexOf('Bravo'));
});
});

it('sorts by count, rare groups first', function () {
runs(function () {
$('.h-sort-mode').val('count').trigger('change');
});
runs(function () {
var order = testElementLabels();
expect(order[0]).toBe('Charlie');
});
});

it('removes the test elements and restores label sort', function () {
runs(function () {
drawWidget.collection.remove(testIds);
$('.h-sort-mode').val('label').trigger('change');
});
waitsFor(function () {
return testElementLabels().length === 0;
}, 'test elements to be removed');
});
});

describe('Annotation styles', function () {
it('create a new annotation', function () {
$('.h-create-annotation').click();
Expand Down