diff --git a/configure/package.json b/configure/package.json
index cffa7424d..3ed42e47c 100644
--- a/configure/package.json
+++ b/configure/package.json
@@ -1,6 +1,6 @@
{
"name": "configure",
- "version": "5.2.5-20260622",
+ "version": "5.2.7-20260629",
"homepage": "./configure/build",
"private": true,
"dependencies": {
diff --git a/package.json b/package.json
index 16a6b67ce..e07342ecb 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "mmgis",
- "version": "5.2.5-20260622",
+ "version": "5.2.7-20260629",
"description": "A web-based mapping and localization solution for science operation on planetary missions.",
"homepage": "build",
"repository": {
diff --git a/src/essence/Basics/Layers_/Filtering/Filtering.css b/src/essence/Basics/Layers_/Filtering/Filtering.css
index a8ae078bd..c5e352cd6 100644
--- a/src/essence/Basics/Layers_/Filtering/Filtering.css
+++ b/src/essence/Basics/Layers_/Filtering/Filtering.css
@@ -183,6 +183,7 @@
}
.layersTool_filtering_value_operator {
box-sizing: border-box;
+ width: 30px;
}
.layersTool_filtering_value .layersTool_filtering_value_value {
flex: 1;
@@ -264,14 +265,6 @@
.layersTool_filtering_group_operator_select.op_not_or {
background: var(--color-p4);
}
-.layersTool_filtering_group_operator_select .dropy__title span {
- padding: 4px 0px !important;
- width: 100%;
- font-size: 12px;
- font-weight: bold;
- letter-spacing: 1px;
- text-align: center !important;
-}
.layersTool_filtering_value_operator_select:hover {
background: var(--color-a2);
@@ -301,26 +294,6 @@
background: #fffb80;
}
-#layersTool_filtering .dropy,
-#layersTool_filtering .dropy__title {
- height: 30px;
- border: none;
-}
-#layersTool_filtering .dropy__title span {
- padding: 6px;
-}
-#layersTool_filtering .dropy .dropy__content li a {
- padding: 6px;
- border-top: 1px solid var(--color-m);
- margin-bottom: 0px;
- height: 30px;
-}
-#layersTool_filtering .dropy__content .dropy__header {
- display: none;
-}
-#layersTool_filtering .dropy__content ul {
- transition: none;
-}
#layersTool_filtering_submit_loading {
display: none;
diff --git a/src/essence/Basics/Layers_/Filtering/Filtering.js b/src/essence/Basics/Layers_/Filtering/Filtering.js
index 8de1f15d0..eae549bbd 100644
--- a/src/essence/Basics/Layers_/Filtering/Filtering.js
+++ b/src/essence/Basics/Layers_/Filtering/Filtering.js
@@ -10,7 +10,7 @@ import ESFilterer from './ESFilterer'
import GeodatasetFilterer from './GeodatasetFilterer'
import Help from '../../UserInterface_/components/Help/Help'
-import Dropy from '../../../../external/Dropy/dropy'
+import OpGridSelector from './OpGridSelector'
import { circle } from '@turf/turf'
import Sortable from 'sortablejs'
@@ -191,6 +191,7 @@ const Filtering = {
destroy: function () {
// Clear Spatial Filter
Map_.rmNotNull(Filtering.mapSpatialLayer)
+ OpGridSelector.destroy()
$('#layersTool_filtering').remove()
},
@@ -520,58 +521,31 @@ const Filtering = {
Filtering.setSubmitButtonState(true)
})
- // Operator Dropdown
+ // Operator Grid Selector
elmId = `#layersTool_filtering_group_operator_${F_.getSafeName(
layerName
)}_${id}`
const ops = ['AND', 'OR', 'NOT_AND', 'NOT_OR']
const opId = Math.max(ops.indexOf(options.op), 0)
- $(elmId).html(
- Dropy.construct(
- [
- `
All Must Match (AND)
`,
- `Any May Match (OR)
`,
- `Not All May Match (NOT AND)
`,
- `None Must Match (NOT OR)
`,
- ],
- 'op',
- opId,
- { openUp: true, hideChevron: true }
- )
- )
- Dropy.init($(elmId), function (idx) {
- const newOp = ops[idx]
- Filtering.filters[layerName].values[id].op = newOp
- switch (newOp) {
- case 'AND':
- $(elmId).removeClass('op_or')
- $(elmId).removeClass('op_not_and')
- $(elmId).removeClass('op_not_or')
- $(elmId).addClass('op_and')
- break
- case 'OR':
- $(elmId).removeClass('op_and')
- $(elmId).removeClass('op_not_and')
- $(elmId).removeClass('op_not_or')
- $(elmId).addClass('op_or')
- break
- case 'NOT_AND':
- $(elmId).removeClass('op_and')
- $(elmId).removeClass('op_or')
- $(elmId).removeClass('op_not_or')
- $(elmId).addClass('op_not_and')
- break
- case 'NOT_OR':
- $(elmId).removeClass('op_and')
- $(elmId).removeClass('op_or')
- $(elmId).removeClass('op_not_and')
- $(elmId).addClass('op_not_or')
- break
- default:
- break
- }
- Filtering.setSubmitButtonState(true)
+
+ const groupOpItems = [
+ { html: `AND
`, title: 'All Must Match (AND)' },
+ { html: `OR
`, title: 'Any May Match (OR)' },
+ { html: `NAND
`, title: 'Not All May Match (NOT AND)' },
+ { html: `NOR
`, title: 'None Must Match (NOT OR)' },
+ ]
+
+ OpGridSelector.init($(elmId), groupOpItems, opId, {
+ columns: 4,
+ onSelect: function (idx) {
+ const newOp = ops[idx]
+ Filtering.filters[layerName].values[id].op = newOp
+ $(elmId)
+ .removeClass('op_and op_or op_not_and op_not_or')
+ .addClass('op_' + newOp.toLowerCase())
+ Filtering.setSubmitButtonState(true)
+ },
})
},
attachValueEvents: function (id, layerName, options) {
@@ -696,7 +670,7 @@ const Filtering = {
} else $(this).css('border', '1px solid var(--color-p4)')
})
- // Operator Dropdown
+ // Operator Grid Selector
elmId = `#layersTool_filtering_value_operator_${F_.getSafeName(
layerName
)}_${id}`
@@ -714,28 +688,26 @@ const Filtering = {
'endswith',
]
const opId = Math.max(ops.indexOf(options.op), 0)
- $(elmId).html(
- Dropy.construct(
- [
- ``,
- `!=
`,
- `in
`,
- ``,
- ``,
- ``,
- ``,
- ``,
- ``,
- ``,
- ],
- 'op',
- opId,
- { openUp: true, hideChevron: true }
- )
- )
- Dropy.init($(elmId), function (idx) {
- Filtering.filters[layerName].values[id].op = ops[idx]
- Filtering.setSubmitButtonState(true)
+
+ const valueOpItems = [
+ { html: ``, title: 'Equals' },
+ { html: `!=
`, title: 'Not Equals' },
+ { html: `in
`, title: 'Comma-separated list' },
+ { html: ``, title: 'Less than' },
+ { html: ``, title: 'Greater than' },
+ { html: ``, title: 'Less than or Equal' },
+ { html: ``, title: 'Greater than or Equal' },
+ { html: ``, title: 'Contains' },
+ { html: ``, title: 'Begins With' },
+ { html: ``, title: 'Ends With' },
+ ]
+
+ OpGridSelector.init($(elmId), valueOpItems, opId, {
+ columns: 5,
+ onSelect: function (idx) {
+ Filtering.filters[layerName].values[id].op = ops[idx]
+ Filtering.setSubmitButtonState(true)
+ },
})
// Value AutoComplete
diff --git a/src/essence/Basics/Layers_/Filtering/OpGridSelector.css b/src/essence/Basics/Layers_/Filtering/OpGridSelector.css
new file mode 100644
index 000000000..93b821001
--- /dev/null
+++ b/src/essence/Basics/Layers_/Filtering/OpGridSelector.css
@@ -0,0 +1,45 @@
+.op-grid-trigger {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ cursor: pointer;
+ transition: background 0.2s cubic-bezier(0.785, 0.135, 0.15, 0.86);
+}
+.op-grid-trigger:hover {
+ background: var(--color-a3);
+}
+
+.op-grid-popup {
+ display: grid;
+ background: var(--color-a1);
+ border: 1px solid var(--color-a4);
+ border-radius: 3px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
+ padding: 2px;
+ gap: 1px;
+}
+
+.op-grid-item {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 28px;
+ min-width: 28px;
+ padding: 2px 6px;
+ cursor: pointer;
+ border-radius: 2px;
+ color: var(--color-f);
+ transition: background 0.1s ease;
+}
+.op-grid-item:hover {
+ background: var(--color-a4);
+}
+.op-grid-item--selected {
+ background: var(--color-c);
+ font-weight: bold;
+}
+.op-grid-item--selected:hover {
+ background: var(--color-c2);
+}
diff --git a/src/essence/Basics/Layers_/Filtering/OpGridSelector.js b/src/essence/Basics/Layers_/Filtering/OpGridSelector.js
new file mode 100644
index 000000000..73ebd3660
--- /dev/null
+++ b/src/essence/Basics/Layers_/Filtering/OpGridSelector.js
@@ -0,0 +1,131 @@
+// Compact grid-based operator selector for the Filtering tool.
+// Renders a trigger button showing the current selection and a
+// fixed-position popup grid that escapes ancestor overflow clipping.
+
+import $ from 'jquery'
+
+import './OpGridSelector.css'
+
+let activePopup = null
+let scrollHandler = null
+let clickHandler = null
+
+function closeActivePopup() {
+ if (activePopup) {
+ activePopup.remove()
+ activePopup = null
+ }
+ if (scrollHandler) {
+ document.removeEventListener('scroll', scrollHandler, true)
+ scrollHandler = null
+ }
+ if (clickHandler) {
+ document.removeEventListener('mousedown', clickHandler, true)
+ clickHandler = null
+ }
+}
+
+/**
+ * Initialize an operator grid selector.
+ * @param {jQuery} $container - The container element to render into
+ * @param {Array} items - Array of { html, title } for each grid cell
+ * @param {number} selectedIdx - Initially selected index
+ * @param {object} opts - { columns, onSelect }
+ * columns: number of grid columns (default 4)
+ * onSelect(idx): callback fired when an item is selected
+ */
+function init($container, items, selectedIdx, opts = {}) {
+ const columns = opts.columns || 4
+ const onSelect = opts.onSelect || (() => {})
+
+ let currentIdx = selectedIdx
+
+ // Render trigger button showing current selection
+ const triggerHtml = `${items[currentIdx].html}
`
+ $container.html(triggerHtml)
+
+ const $trigger = $container.find('.op-grid-trigger')
+
+ $trigger.on('click', function (e) {
+ e.stopPropagation()
+
+ // If this popup is already open, close it
+ if (activePopup && activePopup.data('owner') === $container[0]) {
+ closeActivePopup()
+ return
+ }
+
+ // Close any other open popup
+ closeActivePopup()
+
+ // Build grid popup
+ let gridHtml = `'
+
+ const $popup = $(gridHtml)
+ $popup.data('owner', $container[0])
+ $('body').append($popup)
+ activePopup = $popup
+
+ // Position: fixed relative to trigger
+ const bcr = $trigger[0].getBoundingClientRect()
+ const openDown = bcr.top < window.innerHeight / 2
+
+ $popup.css({
+ position: 'fixed',
+ left: bcr.left,
+ zIndex: 10000,
+ })
+
+ if (openDown) {
+ $popup.css('top', bcr.bottom + 2)
+ } else {
+ // Measure popup height then position above trigger
+ $popup.css({ top: 0, visibility: 'hidden' })
+ const popupHeight = $popup.outerHeight()
+ $popup.css({
+ top: bcr.top - popupHeight - 2,
+ visibility: 'visible',
+ })
+ }
+
+ // Item click
+ $popup.on('click', '.op-grid-item', function () {
+ const idx = parseInt($(this).attr('data-idx'), 10)
+ currentIdx = idx
+ $trigger.html(items[idx].html)
+ onSelect(idx)
+ closeActivePopup()
+ })
+
+ // Close on scroll (capture phase catches nested scrollables)
+ scrollHandler = () => closeActivePopup()
+ document.addEventListener('scroll', scrollHandler, true)
+
+ // Close on outside click
+ clickHandler = (evt) => {
+ if (
+ activePopup &&
+ !activePopup[0].contains(evt.target) &&
+ !$trigger[0].contains(evt.target)
+ ) {
+ closeActivePopup()
+ }
+ }
+ // Delay to avoid catching the current click
+ setTimeout(() => {
+ document.addEventListener('mousedown', clickHandler, true)
+ }, 0)
+ })
+}
+
+// Clean up any open popup (call on destroy)
+function destroy() {
+ closeActivePopup()
+}
+
+export default { init, destroy }