diff --git a/histomicsui/web_client/panels/DrawWidget.js b/histomicsui/web_client/panels/DrawWidget.js index 5f85572c..0ffee5e8 100644 --- a/histomicsui/web_client/panels/DrawWidget.js +++ b/histomicsui/web_client/panels/DrawWidget.js @@ -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', @@ -110,6 +112,7 @@ var DrawWidget = Panel.extend({ delete this._skipRenderHTML; } } else { + this._sortElements(); this.$el.html(drawWidget({ title: 'Draw', elements: this.collection.models, @@ -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}); @@ -372,6 +377,7 @@ var DrawWidget = Panel.extend({ } } } + this._reorderElementDom(); }, /** @@ -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) { @@ -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 = {}; diff --git a/histomicsui/web_client/stylesheets/panels/drawWidget.styl b/histomicsui/web_client/stylesheets/panels/drawWidget.styl index 36df017c..5068c8b1 100644 --- a/histomicsui/web_client/stylesheets/panels/drawWidget.styl +++ b/histomicsui/web_client/stylesheets/panels/drawWidget.styl @@ -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 diff --git a/histomicsui/web_client/templates/panels/drawWidget.pug b/histomicsui/web_client/templates/panels/drawWidget.pug index 6a2374d9..e66f5b69 100644 --- a/histomicsui/web_client/templates/panels/drawWidget.pug +++ b/histomicsui/web_client/templates/panels/drawWidget.pug @@ -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*: diff --git a/tests/web_client_specs/annotationSpec.js b/tests/web_client_specs/annotationSpec.js index cdfe4f27..f11f053c 100644 --- a/tests/web_client_specs/annotationSpec.js +++ b/tests/web_client_specs/annotationSpec.js @@ -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); }); @@ -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); }); @@ -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 @@ -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();