From 5799b599a1a498f1bb8776a06f8fa8da93f2ab7f Mon Sep 17 00:00:00 2001 From: Sandesh Pandey Date: Mon, 29 Jun 2026 13:20:10 -0500 Subject: [PATCH 1/9] feat: Extend ToolControllerModern to support dynamic loading and unloading --- .../ToolController_/ToolControllerModern_.js | 185 +++++++++++++++++- .../ToolController_/ToolMetadataUtils.js | 34 ++++ .../UserInterface_/UserInterfaceModern_.css | 7 + .../UserInterface_/UserInterfaceModern_.js | 54 +++-- src/essence/mmgisAPI/mmgisAPI.js | 175 +++++++++++++++++ 5 files changed, 441 insertions(+), 14 deletions(-) diff --git a/src/essence/Basics/ToolController_/ToolControllerModern_.js b/src/essence/Basics/ToolController_/ToolControllerModern_.js index c59d407d1..23aaf3089 100644 --- a/src/essence/Basics/ToolController_/ToolControllerModern_.js +++ b/src/essence/Basics/ToolController_/ToolControllerModern_.js @@ -7,10 +7,27 @@ const logger = createLogger('ToolControllerModern') // --- Module-Level State --- /** - * Map of loaded tool instances: targetId -> { toolInstance, toolName, toolId } + * Map of loaded tool instances: targetId -> { toolInstance, toolName, toolId, toolMetadata } */ const loadedTools = new Map() +/** + * Reverse lookup: toolId -> targetId (populated only for currently-loaded tools) + */ +const toolIdToTargetId = new Map() + +/** + * Set of toolIds that are currently hidden via show/hide API or startHidden config + */ +const hiddenTools = new Set() + +/** + * Deferred tools: toolId -> { toolMetadata, targetId } + * Tracks tools whose DOM container exists but whose make() has not been called + * (either startUnloaded at init, or unloaded via unloadPlugin API) + */ +const deferredTools = new Map() + // --- Internal Helper Functions --- /** @@ -286,9 +303,23 @@ const ToolControllerModern_ = { toolInstance: ToolClass, toolName: toolMetadata.name, toolId: toolMetadata.id, + toolMetadata: toolMetadata, targetId: targetId }) + // Register reverse lookup (toolId -> targetId) for show/hide/unload by toolId + toolIdToTargetId.set(toolMetadata.id, targetId) + + // Remove from deferred registry if it was queued there + deferredTools.delete(toolMetadata.id) + + // Sync hidden state: if the container already has plugin-hidden class + // (set by createToolCard when startHidden is true), track it + const el = document.getElementById(targetId) + if (el && el.classList.contains('plugin-hidden')) { + hiddenTools.add(toolMetadata.id) + } + logger.debug(`Loaded tool "${toolMetadata.name}" in container "${targetId}"`) return ToolClass @@ -310,7 +341,7 @@ const ToolControllerModern_ = { } try { - const { toolInstance, toolName } = loadedTools.get(targetId) + const { toolInstance, toolName, toolId } = loadedTools.get(targetId) // Call destroy() if available if (typeof toolInstance.destroy === 'function') { @@ -320,6 +351,12 @@ const ToolControllerModern_ = { // Remove from tracking loadedTools.delete(targetId) + // Clean up reverse lookup and hidden state + if (toolId) { + toolIdToTargetId.delete(toolId) + hiddenTools.delete(toolId) + } + logger.debug(`Destroyed tool "${toolName}" from container "${targetId}"`) return true @@ -330,7 +367,7 @@ const ToolControllerModern_ = { }, /** - * Destroy all loaded tools + * Destroy all loaded tools and clear all plugin lifecycle state */ destroyAllTools: function () { const targetIds = Array.from(loadedTools.keys()) @@ -340,6 +377,9 @@ const ToolControllerModern_ = { targetIds.forEach(targetId => { this.destroyTool(targetId) }) + + // Clear deferred registry (destroyTool already clears toolIdToTargetId and hiddenTools) + deferredTools.clear() }, /** @@ -381,6 +421,145 @@ const ToolControllerModern_ = { reloadTool: function (toolMetadata, targetId) { this.destroyTool(targetId) return this.loadTool(toolMetadata, targetId) + }, + + // ======================================================================== + // Plugin Visibility & Lifecycle API (show/hide/load/unload by plugin ID) + // ======================================================================== + + /** + * Register a tool as deferred — its DOM container exists but make() has not been called. + * Called at init time for tools with startUnloaded:true, and internally by unloadPlugin. + * + * @param {Object} toolMetadata - Tool metadata object + * @param {string} targetId - DOM element ID of the tool's container + */ + registerDeferred: function (toolMetadata, targetId) { + if (!toolMetadata || !targetId) return + deferredTools.set(toolMetadata.id, { toolMetadata, targetId }) + logger.debug(`Registered deferred tool "${toolMetadata.id}" in container "${targetId}"`) + }, + + /** + * Show a plugin that was hidden via hidePlugin or startHidden config. + * The plugin must already be loaded — its instance and state are preserved. + * + * @param {string} pluginId - Tool ID (e.g., 'TitleTool') + * @returns {boolean} True if shown successfully, false if not found + */ + showPlugin: function (pluginId) { + const targetId = toolIdToTargetId.get(pluginId) + if (!targetId) { + logger.warn(`showPlugin: "${pluginId}" is not a loaded plugin`) + return false + } + document.getElementById(targetId)?.classList.remove('plugin-hidden') + hiddenTools.delete(pluginId) + logger.debug(`Showed plugin "${pluginId}"`) + return true + }, + + /** + * Hide a plugin without destroying it. Its instance and internal state are preserved; + * calling showPlugin later restores it exactly as left. + * + * @param {string} pluginId - Tool ID + * @returns {boolean} True if hidden successfully, false if not found + */ + hidePlugin: function (pluginId) { + const targetId = toolIdToTargetId.get(pluginId) + if (!targetId) { + logger.warn(`hidePlugin: "${pluginId}" is not a loaded plugin`) + return false + } + document.getElementById(targetId)?.classList.add('plugin-hidden') + hiddenTools.add(pluginId) + logger.debug(`Hid plugin "${pluginId}"`) + return true + }, + + /** + * Load a plugin that is currently deferred (startUnloaded at init, or previously unloaded). + * Calls make() on the existing DOM container. The plugin starts visible after load. + * + * @param {string} pluginId - Tool ID + * @returns {Object|null} Tool instance or null if not found / load failed + */ + loadPlugin: function (pluginId) { + if (toolIdToTargetId.has(pluginId)) { + logger.warn(`loadPlugin: "${pluginId}" is already loaded`) + return loadedTools.get(toolIdToTargetId.get(pluginId))?.toolInstance || null + } + const deferred = deferredTools.get(pluginId) + if (!deferred) { + logger.warn(`loadPlugin: "${pluginId}" not found in deferred registry`) + return null + } + const instance = this.loadTool(deferred.toolMetadata, deferred.targetId) + if (instance) { + deferredTools.delete(pluginId) + // Fresh load always starts visible — remove any leftover hidden class + document.getElementById(deferred.targetId)?.classList.remove('plugin-hidden') + hiddenTools.delete(pluginId) + logger.debug(`Loaded deferred plugin "${pluginId}"`) + } + return instance + }, + + /** + * Fully unload a plugin, releasing its instance and resources. + * The DOM container is emptied but kept in place so loadPlugin can recreate it later. + * + * @param {string} pluginId - Tool ID + * @returns {boolean} True if unloaded successfully, false if not found or already unloaded + */ + unloadPlugin: function (pluginId) { + const targetId = toolIdToTargetId.get(pluginId) + if (!targetId) { + logger.warn(`unloadPlugin: "${pluginId}" is not loaded`) + return false + } + const toolData = loadedTools.get(targetId) + if (!toolData) { + logger.warn(`unloadPlugin: "${pluginId}" data not found`) + return false + } + const savedMetadata = toolData.toolMetadata + + // Destroy instance and clean up tracking state + this.destroyTool(targetId) + + // Clear the container content without removing the element itself + const container = document.getElementById(targetId) + if (container) { + container.innerHTML = '' + container.classList.remove('plugin-hidden') + } + + // Register for future reload + deferredTools.set(pluginId, { toolMetadata: savedMetadata, targetId }) + logger.debug(`Unloaded plugin "${pluginId}", registered as deferred`) + return true + }, + + /** + * Check if a plugin is currently loaded (make() has been called and not yet destroyed) + * + * @param {string} pluginId - Tool ID + * @returns {boolean} + */ + isPluginLoaded: function (pluginId) { + return toolIdToTargetId.has(pluginId) + }, + + /** + * Check if a plugin is currently hidden (loaded but not visible) + * + * @param {string} pluginId - Tool ID + * @returns {boolean} + */ + isPluginHidden: function (pluginId) { + return hiddenTools.has(pluginId) } } diff --git a/src/essence/Basics/ToolController_/ToolMetadataUtils.js b/src/essence/Basics/ToolController_/ToolMetadataUtils.js index d2632c25e..7ae162dd4 100644 --- a/src/essence/Basics/ToolController_/ToolMetadataUtils.js +++ b/src/essence/Basics/ToolController_/ToolMetadataUtils.js @@ -298,6 +298,24 @@ export function sanitizeToolMetadata(metadata) { ) } + if (metadata.startHidden !== undefined) { + sanitized.startHidden = sanitizeBoolean( + metadata.startHidden, + false, + 'startHidden', + sanitized.id + ) + } + + if (metadata.startUnloaded !== undefined) { + sanitized.startUnloaded = sanitizeBoolean( + metadata.startUnloaded, + false, + 'startUnloaded', + sanitized.id + ) + } + return sanitized } @@ -383,6 +401,14 @@ export function validateToolMetadata(metadata) { warnings.push('modernLayoutSupport must be a boolean') } + if (metadata.startHidden !== undefined && typeof metadata.startHidden !== 'boolean') { + warnings.push('startHidden must be a boolean') + } + + if (metadata.startUnloaded !== undefined && typeof metadata.startUnloaded !== 'boolean') { + warnings.push('startUnloaded must be a boolean') + } + if (warnings.length > 0) { logger.warn(`Tool "${metadata.name || 'Unknown'}" (${metadata.id}) has metadata issues:`, warnings) } @@ -542,6 +568,14 @@ export function generateToolMetadata(toolConfig) { rawMetadata.height = declaredMetadata.height || toolConfig.height } + if (declaredMetadata.startHidden !== undefined) { + rawMetadata.startHidden = declaredMetadata.startHidden + } + + if (declaredMetadata.startUnloaded !== undefined) { + rawMetadata.startUnloaded = declaredMetadata.startUnloaded + } + // Validate metadata to catch configuration errors validateToolMetadata(rawMetadata) diff --git a/src/essence/Basics/UserInterface_/UserInterfaceModern_.css b/src/essence/Basics/UserInterface_/UserInterfaceModern_.css index 054bb9c4e..a138342a2 100644 --- a/src/essence/Basics/UserInterface_/UserInterfaceModern_.css +++ b/src/essence/Basics/UserInterface_/UserInterfaceModern_.css @@ -5,6 +5,13 @@ /* Modern UI uses theme tokens directly - no local overrides needed */ +/* Plugin-level visibility control (show/hide API and startHidden config). + * Applied to individual .ui-tool-card elements, not the panel itself, + * so other tools sharing the same panel are not affected. */ +.ui-tool-card.plugin-hidden { + display: none !important; +} + /* Full screen grid layout */ .ui-modern-grid { display: grid; diff --git a/src/essence/Basics/UserInterface_/UserInterfaceModern_.js b/src/essence/Basics/UserInterface_/UserInterfaceModern_.js index b683d713d..18e42d918 100644 --- a/src/essence/Basics/UserInterface_/UserInterfaceModern_.js +++ b/src/essence/Basics/UserInterface_/UserInterfaceModern_.js @@ -1,6 +1,6 @@ import $ from 'jquery' import PanelManager_ from '../PanelManager_/PanelManager_' -import { mmgisAPI } from '../../mmgisAPI/mmgisAPI' +import { mmgisAPI, mmgisAPI_ } from '../../mmgisAPI/mmgisAPI' import ToolControllerModern_ from '../ToolController_/ToolControllerModern_' import { getValidIconClass } from '../ToolController_/ToolMetadataUtils' import { createLogger } from '../Logger_/Logger_' @@ -17,6 +17,7 @@ let panels = [] let layoutStyle = '' let toolLoadQueue = [] let cleanupLayoutListener = null +let cleanupCoreCommandDispatcher = null let _resizeObserver = null // --- DOM Builder Helpers --- @@ -140,10 +141,14 @@ const _renderFloatRegions = (floatPanels) => { if (dims.maxHeight) bodyCss['max-height'] = _toCssValue(dims.maxHeight) if (Object.keys(bodyCss).length) body.css(bodyCss) toolsMetadata.forEach(toolMetadata => { - const { toolCard, loadTool } = UserInterfaceModern_.createToolCard(toolMetadata, panel.containerId) + const { toolCard, loadTool, targetId } = UserInterfaceModern_.createToolCard(toolMetadata, panel.containerId) toolCard.addClass('active') body.append(toolCard) - toolLoadQueue.push(loadTool) + if (toolMetadata.startUnloaded) { + toolLoadQueue.push(() => ToolControllerModern_.registerDeferred(toolMetadata, targetId)) + } else { + toolLoadQueue.push(loadTool) + } }) panelDiv.append(body) @@ -228,6 +233,15 @@ const UserInterfaceModern_ = { this.render() + // Wire plugin lifecycle API into mmgisAPI so plugins and core can call + // showPlugin/hidePlugin/loadPlugin/unloadPlugin without a direct import + mmgisAPI_._pluginController = ToolControllerModern_ + mmgisAPI_._panelManager = PanelManager_ + + if (!cleanupCoreCommandDispatcher) { + cleanupCoreCommandDispatcher = mmgisAPI_._initCoreCommandDispatcher() + } + if (!cleanupLayoutListener) { cleanupLayoutListener = mmgisAPI.on('mmgis-panel-layout-changed', this.syncDOMState.bind(this)) } @@ -251,6 +265,10 @@ const UserInterfaceModern_ = { destroy: function () { ToolControllerModern_.destroyAllTools() toolLoadQueue = [] // Clear any pending loads + if (cleanupCoreCommandDispatcher) { + cleanupCoreCommandDispatcher() + cleanupCoreCommandDispatcher = null + } if (cleanupLayoutListener) { cleanupLayoutListener() cleanupLayoutListener = null @@ -261,6 +279,8 @@ const UserInterfaceModern_ = { } $('#modern-content').empty() panels = [] + mmgisAPI_._pluginController = null + mmgisAPI_._panelManager = null }, /** @@ -287,13 +307,17 @@ const UserInterfaceModern_ = { .addClass('ui-tool-card') .addClass(layoutClass) .addClass(isActive ? 'active' : '') + // Apply hidden class at init for tools configured with startHidden:true + .addClass(toolMetadata.startHidden ? 'plugin-hidden' : '') .attr('data-tool', toolId) .attr('id', targetId) - // Return card and a function to load the tool (called after append) - // The load function checks if DOM element still exists before loading + // Return card, a function to load the tool (called after append), and targetId + // so callers can register deferred tools without re-deriving the container ID. + // The load function checks if DOM element still exists before loading. return { toolCard: toolCard, + targetId: targetId, loadTool: () => { const targetElement = document.getElementById(targetId) @@ -343,10 +367,14 @@ const UserInterfaceModern_ = { tabBar.append(tab) - // Create tab content container and queue tool loading - const { toolCard, loadTool } = this.createToolCard(toolMetadata, panel.containerId, 'tabbed', isActive) + // Create tab content container and queue tool loading (or deferral) + const { toolCard, loadTool, targetId } = this.createToolCard(toolMetadata, panel.containerId, 'tabbed', isActive) tabContentArea.append(toolCard) - toolLoadQueue.push(loadTool) + if (toolMetadata.startUnloaded) { + toolLoadQueue.push(() => ToolControllerModern_.registerDeferred(toolMetadata, targetId)) + } else { + toolLoadQueue.push(loadTool) + } }) body.append(tabBar).append(tabContentArea) @@ -382,10 +410,14 @@ const UserInterfaceModern_ = { const toolsMetadata = PanelManager_.getToolsForPanel(panel.id) || [] toolsMetadata.forEach(toolMetadata => { - const { toolCard, loadTool } = this.createToolCard(toolMetadata, panel.containerId) + const { toolCard, loadTool, targetId } = this.createToolCard(toolMetadata, panel.containerId) body.append(toolCard) - // Queue tool loading for after DOM is fully rendered - toolLoadQueue.push(loadTool) + // Queue tool loading (or deferred registration) for after DOM is fully rendered + if (toolMetadata.startUnloaded) { + toolLoadQueue.push(() => ToolControllerModern_.registerDeferred(toolMetadata, targetId)) + } else { + toolLoadQueue.push(loadTool) + } }) }, diff --git a/src/essence/mmgisAPI/mmgisAPI.js b/src/essence/mmgisAPI/mmgisAPI.js index cc4c05e86..a589a3c4e 100644 --- a/src/essence/mmgisAPI/mmgisAPI.js +++ b/src/essence/mmgisAPI/mmgisAPI.js @@ -23,6 +23,11 @@ var mmgisAPI_ = { _events: events, _handlers: handlers, + // Set by UserInterfaceModern_ on init; null when modern layout is not active + _pluginController: null, + // Set by UserInterfaceModern_ on init; null when modern layout is not active + _panelManager: null, + // Exposes Leaflet map object map: null, // Initialize the map variable @@ -452,6 +457,109 @@ var mmgisAPI_ = { unproject: function (xy) { return window.mmgisglobal.customCRS.unproject(xy) }, + showPlugin: function (pluginId) { + if (!mmgisAPI_._pluginController) { + console.warn('[mmgisAPI] showPlugin: modern layout not active') + return false + } + return mmgisAPI_._pluginController.showPlugin(pluginId) + }, + hidePlugin: function (pluginId) { + if (!mmgisAPI_._pluginController) { + console.warn('[mmgisAPI] hidePlugin: modern layout not active') + return false + } + return mmgisAPI_._pluginController.hidePlugin(pluginId) + }, + loadPlugin: function (pluginId) { + if (!mmgisAPI_._pluginController) { + console.warn('[mmgisAPI] loadPlugin: modern layout not active') + return null + } + return mmgisAPI_._pluginController.loadPlugin(pluginId) + }, + unloadPlugin: function (pluginId) { + if (!mmgisAPI_._pluginController) { + console.warn('[mmgisAPI] unloadPlugin: modern layout not active') + return false + } + return mmgisAPI_._pluginController.unloadPlugin(pluginId) + }, + isPluginLoaded: function (pluginId) { + return mmgisAPI_._pluginController?.isPluginLoaded(pluginId) ?? false + }, + isPluginHidden: function (pluginId) { + return mmgisAPI_._pluginController?.isPluginHidden(pluginId) ?? false + }, + showPanel: function (panelId) { + if (!mmgisAPI_._panelManager) { + console.warn('[mmgisAPI] showPanel: modern layout not active') + return false + } + try { + const panel = mmgisAPI_._panelManager.getPanelState(panelId) + if (!panel) { + console.warn(`[mmgisAPI] showPanel: panel "${panelId}" not found`) + return false + } + if (panel.state !== 'collapsed') return true + mmgisAPI_._panelManager.togglePanelCollapsed(panelId) + return true + } catch (e) { + console.warn('[mmgisAPI] showPanel failed:', e) + return false + } + }, + hidePanel: function (panelId) { + if (!mmgisAPI_._panelManager) { + console.warn('[mmgisAPI] hidePanel: modern layout not active') + return false + } + try { + const panel = mmgisAPI_._panelManager.getPanelState(panelId) + if (!panel) { + console.warn(`[mmgisAPI] hidePanel: panel "${panelId}" not found`) + return false + } + if (panel.state === 'collapsed') return true + mmgisAPI_._panelManager.setPanelState(panelId, 'collapsed') + return true + } catch (e) { + console.warn('[mmgisAPI] hidePanel failed:', e) + return false + } + }, + togglePanel: function (panelId) { + if (!mmgisAPI_._panelManager) { + console.warn('[mmgisAPI] togglePanel: modern layout not active') + return false + } + try { + mmgisAPI_._panelManager.togglePanelCollapsed(panelId) + return true + } catch (e) { + console.warn('[mmgisAPI] togglePanel failed:', e) + return false + } + }, + _initCoreCommandDispatcher: function () { + const handler = (payload) => { + const { action, ...args } = payload || {} + switch (action) { + case 'showPlugin': mmgisAPI_.showPlugin(args.pluginId); break + case 'hidePlugin': mmgisAPI_.hidePlugin(args.pluginId); break + case 'loadPlugin': mmgisAPI_.loadPlugin(args.pluginId); break + case 'unloadPlugin': mmgisAPI_.unloadPlugin(args.pluginId); break + case 'showPanel': mmgisAPI_.showPanel(args.panelId); break + case 'hidePanel': mmgisAPI_.hidePanel(args.panelId); break + case 'togglePanel': mmgisAPI_.togglePanel(args.panelId); break + default: + console.warn('[mmgisAPI] core:command: unknown action:', action) + } + } + events.on('core:command', handler) + return () => events.off('core:command', handler) + }, toggleLayer: async function (layerName, on) { if (layerName in L_.layers.data) { if (on === undefined || on === null) { @@ -750,6 +858,73 @@ var mmgisAPI = { */ toggleLayer: mmgisAPI_.toggleLayer, + // ============ PLUGIN LIFECYCLE API (modern layout only) ============ + + /** + * Show a hidden plugin. The plugin must be loaded; its internal state is preserved. + * @param {string} pluginId - Tool ID (e.g., 'TitleTool') + * @returns {boolean} True if shown, false if plugin not found or layout not active + */ + showPlugin: mmgisAPI_.showPlugin, + + /** + * Hide a plugin without destroying it. State is preserved; showPlugin restores it. + * @param {string} pluginId - Tool ID + * @returns {boolean} True if hidden, false if plugin not found or layout not active + */ + hidePlugin: mmgisAPI_.hidePlugin, + + /** + * Load a plugin that is currently deferred (startUnloaded at init, or previously unloaded). + * Calls make() on the existing DOM container. The plugin starts visible. + * @param {string} pluginId - Tool ID + * @returns {Object|null} Tool instance or null on failure + */ + loadPlugin: mmgisAPI_.loadPlugin, + + /** + * Fully unload a plugin, calling destroy() and releasing all resources. + * The DOM container remains so loadPlugin can recreate it later. + * @param {string} pluginId - Tool ID + * @returns {boolean} True if unloaded, false if not found or layout not active + */ + unloadPlugin: mmgisAPI_.unloadPlugin, + + /** + * Check whether a plugin is currently loaded (make() has been called and not destroyed). + * @param {string} pluginId - Tool ID + * @returns {boolean} + */ + isPluginLoaded: mmgisAPI_.isPluginLoaded, + + /** + * Check whether a plugin is currently hidden (loaded but not visible). + * @param {string} pluginId - Tool ID + * @returns {boolean} + */ + isPluginHidden: mmgisAPI_.isPluginHidden, + + /** + * Show a collapsed panel, restoring its last visible state. + * @param {string} panelId - Panel ID + * @returns {boolean} True if shown, false if not found or layout not active + */ + showPanel: mmgisAPI_.showPanel, + + /** + * Collapse a panel without destroying its contents. + * @param {string} panelId - Panel ID + * @returns {boolean} True if hidden, false if not found or layout not active + */ + hidePanel: mmgisAPI_.hidePanel, + + /** + * Toggle a panel between collapsed and its last visible state. + * @param {string} panelId - Panel ID + * @returns {boolean} True if toggled, false if not found or layout not active + */ + togglePanel: mmgisAPI_.togglePanel, + /** overwriteLegends - overwrite the contents displayed in the LegendTool; useful when used with `toggleSeparatedTool` event listener in mmgisAPI * @param {array} - legends - an array of objects, where each object must contain the following keys: legend, layerUUID, display_name, opacity. The value for the legend key should be in the same format as what is stored in the layers data under the `_legend` key (i.e. `L_.layers.data[layerName]._legend`). layerUUID and display_name should be strings and opacity should be a number between 0 and 1. */ From c2a47781fa8c9e01824e95d82fcf9d408870438f Mon Sep 17 00:00:00 2001 From: Sandesh Pandey Date: Mon, 29 Jun 2026 13:20:52 -0500 Subject: [PATCH 2/9] feat: Enhance actionButtonLink handling with core events and plugin events --- src/essence/Tools/Title/MMGISTitleAdapter.tsx | 24 ++++++++++++++++--- src/essence/Tools/Title/config.json | 2 +- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/essence/Tools/Title/MMGISTitleAdapter.tsx b/src/essence/Tools/Title/MMGISTitleAdapter.tsx index a31a9ba21..f467b5346 100644 --- a/src/essence/Tools/Title/MMGISTitleAdapter.tsx +++ b/src/essence/Tools/Title/MMGISTitleAdapter.tsx @@ -87,10 +87,28 @@ export function MMGISTitleAdapter() { if (!link) return if (link.startsWith('http://') || link.startsWith('https://')) { window.open(link, '_blank', 'noopener,noreferrer') - } else { - // Emit under this plugin's namespace: 'plugin:title:'. - mmgisEmit(`plugin:${PLUGIN_ID}:${link}`) + return } + // Core commands using the "core:action:target" syntax (e.g. "core:showPlugin:LayersTool") + if (link.startsWith('core:')) { + const parts = link.substring(5).split(':') + const action = parts[0] + const target = parts.slice(1).join(':') + + // Dispatch with the target mapped to both possible expected arguments + // so the core dispatcher (mmgisAPI._initCoreCommandDispatcher) can consume what it needs. + mmgisEmit('core:command', { action, pluginId: target, panelId: target, targetId: target }) + return + } + + // Custom namespaced events (e.g. "LayerManager:someEvent") + if (link.indexOf(':') !== -1) { + mmgisEmit(link) + return + } + + // Fallback: simple event under this plugin's namespace (no colons) + mmgisEmit(`plugin:${PLUGIN_ID}:${link}`) }, [state.actionButtonLink]) return ( diff --git a/src/essence/Tools/Title/config.json b/src/essence/Tools/Title/config.json index 8ae549a0f..31aee9a71 100644 --- a/src/essence/Tools/Title/config.json +++ b/src/essence/Tools/Title/config.json @@ -63,7 +63,7 @@ { "field": "variables.actionButtonLink", "name": "Button Link", - "description": "An external http(s) URL (opened in a new tab), or a bus event name emitted under this plugin's namespace as 'plugin:title:' for another plugin to handle. Leave empty to hide the button.", + "description": "An external http(s) URL (opened in a new tab); a core command using 'core:action:target' (e.g. 'core:showPlugin:LayersTool', 'core:togglePanel:left-panel'); or a custom event name like 'LayerManager:someEvent'. Simple strings without colons are emitted as 'plugin:title:'. Leave empty to hide the button.", "type": "text", "width": 6 } From 92e50870a27c9f50a1e5a6a32ed651787ae8abc0 Mon Sep 17 00:00:00 2001 From: Sandesh Pandey Date: Tue, 30 Jun 2026 09:59:56 -0500 Subject: [PATCH 3/9] refactor: replace core:command with direct action events --- src/essence/Tools/Title/MMGISTitleAdapter.tsx | 5 +--- src/essence/mmgisAPI/mmgisAPI.js | 25 ++++++++----------- 2 files changed, 11 insertions(+), 19 deletions(-) diff --git a/src/essence/Tools/Title/MMGISTitleAdapter.tsx b/src/essence/Tools/Title/MMGISTitleAdapter.tsx index f467b5346..8bd7cfc4a 100644 --- a/src/essence/Tools/Title/MMGISTitleAdapter.tsx +++ b/src/essence/Tools/Title/MMGISTitleAdapter.tsx @@ -94,10 +94,7 @@ export function MMGISTitleAdapter() { const parts = link.substring(5).split(':') const action = parts[0] const target = parts.slice(1).join(':') - - // Dispatch with the target mapped to both possible expected arguments - // so the core dispatcher (mmgisAPI._initCoreCommandDispatcher) can consume what it needs. - mmgisEmit('core:command', { action, pluginId: target, panelId: target, targetId: target }) + mmgisEmit(`core:${action}`, { pluginId: target, panelId: target }) return } diff --git a/src/essence/mmgisAPI/mmgisAPI.js b/src/essence/mmgisAPI/mmgisAPI.js index a589a3c4e..a5be611a9 100644 --- a/src/essence/mmgisAPI/mmgisAPI.js +++ b/src/essence/mmgisAPI/mmgisAPI.js @@ -543,22 +543,17 @@ var mmgisAPI_ = { } }, _initCoreCommandDispatcher: function () { - const handler = (payload) => { - const { action, ...args } = payload || {} - switch (action) { - case 'showPlugin': mmgisAPI_.showPlugin(args.pluginId); break - case 'hidePlugin': mmgisAPI_.hidePlugin(args.pluginId); break - case 'loadPlugin': mmgisAPI_.loadPlugin(args.pluginId); break - case 'unloadPlugin': mmgisAPI_.unloadPlugin(args.pluginId); break - case 'showPanel': mmgisAPI_.showPanel(args.panelId); break - case 'hidePanel': mmgisAPI_.hidePanel(args.panelId); break - case 'togglePanel': mmgisAPI_.togglePanel(args.panelId); break - default: - console.warn('[mmgisAPI] core:command: unknown action:', action) - } + const handlers = { + 'core:showPlugin': ({ pluginId }) => mmgisAPI_.showPlugin(pluginId), + 'core:hidePlugin': ({ pluginId }) => mmgisAPI_.hidePlugin(pluginId), + 'core:loadPlugin': ({ pluginId }) => mmgisAPI_.loadPlugin(pluginId), + 'core:unloadPlugin': ({ pluginId }) => mmgisAPI_.unloadPlugin(pluginId), + 'core:showPanel': ({ panelId }) => mmgisAPI_.showPanel(panelId), + 'core:hidePanel': ({ panelId }) => mmgisAPI_.hidePanel(panelId), + 'core:togglePanel': ({ panelId }) => mmgisAPI_.togglePanel(panelId), } - events.on('core:command', handler) - return () => events.off('core:command', handler) + Object.entries(handlers).forEach(([ev, fn]) => events.on(ev, fn)) + return () => Object.entries(handlers).forEach(([ev, fn]) => events.off(ev, fn)) }, toggleLayer: async function (layerName, on) { if (layerName in L_.layers.data) { From c6014d60077978649acaa67a38220d1310f0f82d Mon Sep 17 00:00:00 2001 From: Sandesh Pandey Date: Tue, 30 Jun 2026 10:29:40 -0500 Subject: [PATCH 4/9] feat: Hide container for deferred tools to prevent background display --- .../Basics/ToolController_/ToolControllerModern_.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/essence/Basics/ToolController_/ToolControllerModern_.js b/src/essence/Basics/ToolController_/ToolControllerModern_.js index 23aaf3089..5fc199f63 100644 --- a/src/essence/Basics/ToolController_/ToolControllerModern_.js +++ b/src/essence/Basics/ToolController_/ToolControllerModern_.js @@ -437,6 +437,8 @@ const ToolControllerModern_ = { registerDeferred: function (toolMetadata, targetId) { if (!toolMetadata || !targetId) return deferredTools.set(toolMetadata.id, { toolMetadata, targetId }) + // Hide the card so an empty container doesn't show background/shadow + document.getElementById(targetId)?.classList.add('plugin-hidden') logger.debug(`Registered deferred tool "${toolMetadata.id}" in container "${targetId}"`) }, @@ -529,11 +531,12 @@ const ToolControllerModern_ = { // Destroy instance and clean up tracking state this.destroyTool(targetId) - // Clear the container content without removing the element itself + // Clear the container content without removing the element itself; + // hide it so the empty card doesn't show background/shadow const container = document.getElementById(targetId) if (container) { container.innerHTML = '' - container.classList.remove('plugin-hidden') + container.classList.add('plugin-hidden') } // Register for future reload From e2a718a533d8f5b40a17d8c2e0db173e8a415ad1 Mon Sep 17 00:00:00 2001 From: Sandesh Pandey Date: Wed, 1 Jul 2026 09:54:28 -0500 Subject: [PATCH 5/9] Synchronize visibility indicators for tabbed panels and icon trays based on tool state --- .../ToolController_/ToolControllerModern_.js | 88 +++++++++++++++++-- .../UserInterface_/UserInterfaceModern_.css | 12 +++ .../UserInterface_/UserInterfaceModern_.js | 22 ++++- src/essence/mmgisAPI/mmgisAPI.js | 9 +- 4 files changed, 121 insertions(+), 10 deletions(-) diff --git a/src/essence/Basics/ToolController_/ToolControllerModern_.js b/src/essence/Basics/ToolController_/ToolControllerModern_.js index 5fc199f63..08ca61f61 100644 --- a/src/essence/Basics/ToolController_/ToolControllerModern_.js +++ b/src/essence/Basics/ToolController_/ToolControllerModern_.js @@ -63,6 +63,76 @@ const _findPanel = (metadata, registeredPanels) => { return null } +/** + * Keep a tabbed panel's tab button in sync with its content card's hidden state. + * No-op for stacked layouts, which have no .ui-panel-tabs sibling. + * If the hidden tab was active, activates the next visible tab (and its content) + * so the panel never settles on a clickable tab with nothing behind it, or no + * active tab at all. + * + * @param {Element} panelEl - The tool's ancestor .ui-panel element + * @param {string} toolId - Tool ID + * @param {boolean} hidden - Whether the tab should be hidden + */ +const _syncTabVisibility = (panelEl, toolId, hidden) => { + const tabBar = panelEl?.querySelector('.ui-panel-tabs') + if (!tabBar) return + + const tabs = Array.from(tabBar.children) + const tabEl = tabs.find(t => t.dataset.tool === toolId) + if (!tabEl) return + + tabEl.classList.toggle('plugin-hidden', hidden) + + const hasActiveTab = tabs.some(t => !t.classList.contains('plugin-hidden') && t.classList.contains('active')) + if (hasActiveTab) return + + // No visible tab is active (either this one just got hidden while active, + // or this one was just shown and nothing else is active) — activate the + // first visible tab and its matching content. + tabs.forEach(t => t.classList.remove('active')) + const nextTab = tabs.find(t => !t.classList.contains('plugin-hidden')) + if (!nextTab) return + nextTab.classList.add('active') + const contentArea = panelEl.querySelector('.ui-panel-tab-content-area') + if (!contentArea) return + Array.from(contentArea.children).forEach(c => { + c.classList.toggle('active', c.dataset.tool === nextTab.dataset.tool) + }) +} + +/** + * Keep a panel's iconified-state icon-tray button in sync with its content + * card's hidden state, so a hidden/unloaded tool doesn't leave a clickable + * icon pointing at nothing while the panel is iconified/focused. + * + * @param {Element} panelEl - The tool's ancestor .ui-panel element + * @param {string} toolId - Tool ID + * @param {boolean} hidden - Whether the icon button should be hidden + */ +const _syncIconTrayVisibility = (panelEl, toolId, hidden) => { + const iconTray = panelEl?.querySelector('.ui-panel-icons') + if (!iconTray) return + const iconBtn = Array.from(iconTray.children).find(b => b.dataset.tool === toolId) + iconBtn?.classList.toggle('plugin-hidden', hidden) +} + +/** + * Keep a tool's auxiliary visibility indicators (tabbed-panel tab button, + * iconified-panel icon-tray button) in sync with its content card's hidden state. + * + * @param {string} targetId - DOM element ID of the tool's content container + * @param {string} toolId - Tool ID + * @param {boolean} hidden - Whether the tool's indicators should be hidden + */ +const _syncVisibilityIndicators = (targetId, toolId, hidden) => { + const targetEl = document.getElementById(targetId) + const panelEl = targetEl?.closest('.ui-panel') + if (!panelEl) return + _syncTabVisibility(panelEl, toolId, hidden) + _syncIconTrayVisibility(panelEl, toolId, hidden) +} + const ToolControllerModern_ = { /** @@ -439,6 +509,7 @@ const ToolControllerModern_ = { deferredTools.set(toolMetadata.id, { toolMetadata, targetId }) // Hide the card so an empty container doesn't show background/shadow document.getElementById(targetId)?.classList.add('plugin-hidden') + _syncVisibilityIndicators(targetId, toolMetadata.id, true) logger.debug(`Registered deferred tool "${toolMetadata.id}" in container "${targetId}"`) }, @@ -457,6 +528,7 @@ const ToolControllerModern_ = { } document.getElementById(targetId)?.classList.remove('plugin-hidden') hiddenTools.delete(pluginId) + _syncVisibilityIndicators(targetId, pluginId, false) logger.debug(`Showed plugin "${pluginId}"`) return true }, @@ -476,6 +548,7 @@ const ToolControllerModern_ = { } document.getElementById(targetId)?.classList.add('plugin-hidden') hiddenTools.add(pluginId) + _syncVisibilityIndicators(targetId, pluginId, true) logger.debug(`Hid plugin "${pluginId}"`) return true }, @@ -485,17 +558,17 @@ const ToolControllerModern_ = { * Calls make() on the existing DOM container. The plugin starts visible after load. * * @param {string} pluginId - Tool ID - * @returns {Object|null} Tool instance or null if not found / load failed + * @returns {boolean} True if loaded (or already loaded), false if not found / load failed */ loadPlugin: function (pluginId) { if (toolIdToTargetId.has(pluginId)) { logger.warn(`loadPlugin: "${pluginId}" is already loaded`) - return loadedTools.get(toolIdToTargetId.get(pluginId))?.toolInstance || null + return true } const deferred = deferredTools.get(pluginId) if (!deferred) { logger.warn(`loadPlugin: "${pluginId}" not found in deferred registry`) - return null + return false } const instance = this.loadTool(deferred.toolMetadata, deferred.targetId) if (instance) { @@ -503,9 +576,10 @@ const ToolControllerModern_ = { // Fresh load always starts visible — remove any leftover hidden class document.getElementById(deferred.targetId)?.classList.remove('plugin-hidden') hiddenTools.delete(pluginId) + _syncVisibilityIndicators(deferred.targetId, pluginId, false) logger.debug(`Loaded deferred plugin "${pluginId}"`) } - return instance + return !!instance }, /** @@ -538,6 +612,7 @@ const ToolControllerModern_ = { container.innerHTML = '' container.classList.add('plugin-hidden') } + _syncVisibilityIndicators(targetId, pluginId, true) // Register for future reload deferredTools.set(pluginId, { toolMetadata: savedMetadata, targetId }) @@ -556,7 +631,10 @@ const ToolControllerModern_ = { }, /** - * Check if a plugin is currently hidden (loaded but not visible) + * Check if a plugin is currently hidden via hidePlugin/startHidden (loaded but not visible). + * Returns false for a plugin that was never loaded or is currently unloaded/deferred + * (startUnloaded, or unloadPlugin) — those are also visually hidden but are reported + * via isPluginLoaded()===false instead. Check both to fully reason about visibility. * * @param {string} pluginId - Tool ID * @returns {boolean} diff --git a/src/essence/Basics/UserInterface_/UserInterfaceModern_.css b/src/essence/Basics/UserInterface_/UserInterfaceModern_.css index a138342a2..46c2a201c 100644 --- a/src/essence/Basics/UserInterface_/UserInterfaceModern_.css +++ b/src/essence/Basics/UserInterface_/UserInterfaceModern_.css @@ -12,6 +12,18 @@ display: none !important; } +/* Same visibility control applied to a tabbed panel's tab button, so a + * hidden/unloaded tool's tab isn't left clickable with nothing behind it. */ +.ui-panel-tab.plugin-hidden { + display: none !important; +} + +/* Same visibility control applied to an iconified-panel's icon-tray button, + * so a hidden/unloaded tool's icon isn't left clickable with nothing behind it. */ +.ui-panel-icon-btn.plugin-hidden { + display: none !important; +} + /* Full screen grid layout */ .ui-modern-grid { display: grid; diff --git a/src/essence/Basics/UserInterface_/UserInterfaceModern_.js b/src/essence/Basics/UserInterface_/UserInterfaceModern_.js index f400cdc2d..ad0ca6bff 100644 --- a/src/essence/Basics/UserInterface_/UserInterfaceModern_.js +++ b/src/essence/Basics/UserInterface_/UserInterfaceModern_.js @@ -34,6 +34,9 @@ const _createPanelIconTray = (panel) => { const iconSpan = $('').addClass(iconClass) const iconBtn = $('') .addClass('ui-panel-icon-btn') + // Apply hidden class at init for tools configured with startHidden/startUnloaded, + // mirrors the 'plugin-hidden' class applied to the content card. + .addClass(toolMetadata.startHidden || toolMetadata.startUnloaded ? 'plugin-hidden' : '') .attr('title', toolName) // jQuery escapes attribute values .attr('data-tool', toolId) .append(iconSpan) @@ -347,10 +350,20 @@ const UserInterfaceModern_ = { const toolsMetadata = PanelManager_.getToolsForPanel(panel.id) || [] + // Pick the first tool that isn't starting hidden/unloaded to be the + // active tab, so we don't land on a tab whose content is invisible. + // Falls back to idx 0 if every tool starts hidden/unloaded. + const firstVisibleIdx = toolsMetadata.findIndex(t => !t.startHidden && !t.startUnloaded) + const activeIdx = firstVisibleIdx === -1 ? 0 : firstVisibleIdx + toolsMetadata.forEach((toolMetadata, idx) => { const toolName = toolMetadata.name || toolMetadata.id const toolId = toolMetadata.id - const isActive = idx === 0 + const isActive = idx === activeIdx + // Mirrors the 'plugin-hidden' class applied to the content card below, + // so a tool that starts hidden/unloaded doesn't leave a clickable tab + // pointing at invisible content. + const startsHidden = !!(toolMetadata.startHidden || toolMetadata.startUnloaded) // Create tab using safe jQuery methods const iconClass = getValidIconClass(toolMetadata.icon, toolId) @@ -360,6 +373,7 @@ const UserInterfaceModern_ = { const tab = $('
') .addClass('ui-panel-tab') .addClass(isActive ? 'active' : '') + .addClass(startsHidden ? 'plugin-hidden' : '') .attr('data-tool', toolId) .append(iconSpan) .append(textSpan) @@ -448,6 +462,12 @@ const UserInterfaceModern_ = { * Renders the layout into the #modern-content element */ render: function () { + // Defensive: a re-render destroys the DOM the lifecycle registries point to, + // so any already-loaded/deferred tools must be cleared or they'd reference + // stale elements (isPluginLoaded would lie, loadTool's "already loaded" guard + // would try to destroy a tool whose DOM no longer exists). + ToolControllerModern_.destroyAllTools() + const container = $('#modern-content') container.empty() diff --git a/src/essence/mmgisAPI/mmgisAPI.js b/src/essence/mmgisAPI/mmgisAPI.js index a5be611a9..3cbdead2d 100644 --- a/src/essence/mmgisAPI/mmgisAPI.js +++ b/src/essence/mmgisAPI/mmgisAPI.js @@ -5,6 +5,7 @@ import QueryURL from '../Ancillary/QueryURL' import TimeControl from '../Basics/TimeControl_/TimeControl' import Login from '../Ancillary/Login/Login' import LegendTool from '../Tools/Legend/LegendTool.js' +import { PANEL_STATE } from '../Basics/PanelManager_/types/layout' import mitt from 'mitt' import $ from 'jquery' @@ -502,7 +503,7 @@ var mmgisAPI_ = { console.warn(`[mmgisAPI] showPanel: panel "${panelId}" not found`) return false } - if (panel.state !== 'collapsed') return true + if (panel.state !== PANEL_STATE.COLLAPSED) return true mmgisAPI_._panelManager.togglePanelCollapsed(panelId) return true } catch (e) { @@ -521,8 +522,8 @@ var mmgisAPI_ = { console.warn(`[mmgisAPI] hidePanel: panel "${panelId}" not found`) return false } - if (panel.state === 'collapsed') return true - mmgisAPI_._panelManager.setPanelState(panelId, 'collapsed') + if (panel.state === PANEL_STATE.COLLAPSED) return true + mmgisAPI_._panelManager.setPanelState(panelId, PANEL_STATE.COLLAPSED) return true } catch (e) { console.warn('[mmgisAPI] hidePanel failed:', e) @@ -873,7 +874,7 @@ var mmgisAPI = { * Load a plugin that is currently deferred (startUnloaded at init, or previously unloaded). * Calls make() on the existing DOM container. The plugin starts visible. * @param {string} pluginId - Tool ID - * @returns {Object|null} Tool instance or null on failure + * @returns {boolean} True if loaded, false if not found or load failed */ loadPlugin: mmgisAPI_.loadPlugin, From 1fd149386514dec728ec7d671f3883273062c360 Mon Sep 17 00:00:00 2001 From: Sandesh Pandey Date: Wed, 1 Jul 2026 13:43:43 -0500 Subject: [PATCH 6/9] Fix NaN priority value on floating panels and add support for edge and floating panel configurations --- .../Basics/PanelManager_/PanelManager_.ts | 15 +++- .../Basics/PanelManager_/types/layout.ts | 30 ++++--- .../Basics/PanelManager_/types/panel.ts | 80 ++++++++++++++----- .../Validators/DashboardConfigValidator.js | 2 + src/essence/types/dashboard.ts | 4 + .../panelManager/panelManager.queries.spec.js | 29 +++++++ 6 files changed, 129 insertions(+), 31 deletions(-) diff --git a/src/essence/Basics/PanelManager_/PanelManager_.ts b/src/essence/Basics/PanelManager_/PanelManager_.ts index 882c64109..df06d8110 100644 --- a/src/essence/Basics/PanelManager_/PanelManager_.ts +++ b/src/essence/Basics/PanelManager_/PanelManager_.ts @@ -3,6 +3,17 @@ import { PanelPosition, PanelState, PanelLayoutType, PANEL_STATE, FLOAT_POSITION import { PanelConfig, PanelStateObject, PanelManager as PanelManagerInterface } from './types/panel'; import { mmgisAPI } from '../../mmgisAPI/mmgisAPI'; +/** + * Float panels don't require a priority (they don't compete for edge viewport + * space), so `config.priority` may be undefined. Treat missing priority as + * lowest priority (sorts last) instead of letting `undefined - undefined` + * produce NaN, which Array.prototype.sort treats as "leave order unchanged" + * and yields insertion-order-dependent, effectively unsorted results. + */ +function normalizePriority(priority: number | undefined): number { + return priority === undefined ? Infinity : priority; +} + class PanelManager implements PanelManagerInterface { private panels: Map = new Map(); @@ -271,7 +282,7 @@ class PanelManager implements PanelManagerInterface { getPanelsAtPosition(position: PanelPosition): PanelStateObject[] { return Array.from(this.panels.values()) .filter(p => p.config.position === position) - .sort((a, b) => a.config.priority - b.config.priority); + .sort((a, b) => normalizePriority(a.config.priority) - normalizePriority(b.config.priority)); } /** @@ -282,7 +293,7 @@ class PanelManager implements PanelManagerInterface { */ getAllPanelsByPriority(): PanelStateObject[] { return Array.from(this.panels.values()) - .sort((a, b) => a.config.priority - b.config.priority); + .sort((a, b) => normalizePriority(a.config.priority) - normalizePriority(b.config.priority)); } /** diff --git a/src/essence/Basics/PanelManager_/types/layout.ts b/src/essence/Basics/PanelManager_/types/layout.ts index 1cf64747d..98b3185aa 100644 --- a/src/essence/Basics/PanelManager_/types/layout.ts +++ b/src/essence/Basics/PanelManager_/types/layout.ts @@ -1,11 +1,20 @@ /** - * Supported panel positions (regions where panels can be placed). + * Positions for edge panels (top/left/right/bottom), which claim viewport + * space and therefore require a `priority` + `layoutType`. */ -export const PANEL_POSITION = { +const EDGE_POSITION = { TOP: 'top', LEFT: 'left', RIGHT: 'right', BOTTOM: 'bottom', +} as const +export type EdgePanelPosition = (typeof EDGE_POSITION)[keyof typeof EDGE_POSITION] + +/** + * Positions for floating panels, which render as overlays inside the center + * map area and don't compete for edge viewport space. + */ +const FLOAT_POSITION = { FLOAT_TOP_LEFT: 'float-top-left', FLOAT_TOP_CENTER: 'float-top-center', FLOAT_TOP_RIGHT: 'float-top-right', @@ -13,20 +22,19 @@ export const PANEL_POSITION = { FLOAT_BOTTOM_CENTER: 'float-bottom-center', FLOAT_BOTTOM_RIGHT: 'float-bottom-right', } as const -export type PanelPosition = (typeof PANEL_POSITION)[keyof typeof PANEL_POSITION] +export type FloatPanelPosition = (typeof FLOAT_POSITION)[keyof typeof FLOAT_POSITION] + +/** + * Supported panel positions (regions where panels can be placed). + */ +export const PANEL_POSITION = { ...EDGE_POSITION, ...FLOAT_POSITION } as const +export type PanelPosition = EdgePanelPosition | FloatPanelPosition /** * Set of all float positions for quick membership checks. * Float panels render inside the center map area as overlays. */ -export const FLOAT_POSITIONS = new Set([ - PANEL_POSITION.FLOAT_TOP_LEFT, - PANEL_POSITION.FLOAT_TOP_CENTER, - PANEL_POSITION.FLOAT_TOP_RIGHT, - PANEL_POSITION.FLOAT_BOTTOM_LEFT, - PANEL_POSITION.FLOAT_BOTTOM_CENTER, - PANEL_POSITION.FLOAT_BOTTOM_RIGHT, -] as const) +export const FLOAT_POSITIONS = new Set(Object.values(FLOAT_POSITION)) /** * Visual states of a panel: diff --git a/src/essence/Basics/PanelManager_/types/panel.ts b/src/essence/Basics/PanelManager_/types/panel.ts index 41e25e266..562df6dbf 100644 --- a/src/essence/Basics/PanelManager_/types/panel.ts +++ b/src/essence/Basics/PanelManager_/types/panel.ts @@ -1,5 +1,11 @@ import { ToolOrientation, ToolMetadata } from '../../ToolController_/types/tool'; -import { PanelPosition, PanelState, PanelLayoutType } from './layout'; +import { + PanelPosition, + EdgePanelPosition, + FloatPanelPosition, + PanelState, + PanelLayoutType, +} from './layout'; /** * Panel size configuration. @@ -98,7 +104,8 @@ export interface PanelCapabilities { /** * Whether this panel can be resized by the user via drag handles. - * Default: false + * Default: false. Not supported on floating panels — dragging happens + * via move handles instead, so this must be false/omitted there. */ resizable?: boolean; @@ -116,29 +123,16 @@ export interface PanelCapabilities { } /** - * Complete configuration for a panel region. - * Defines behavior, constraints, and appearance. + * Fields shared by every panel, regardless of whether it's an edge or + * floating panel. */ -export interface PanelConfig { +interface BasePanelConfig { /** Unique identifier for this panel */ id: string; /** Display title for the panel */ title?: string; - /** Which region this panel occupies */ - position: PanelPosition; - - /** - * Layout priority for space allocation. - * Lower numbers claim viewport space first. - * Example: top=0, right=1, bottom=2, left=3 - */ - priority: PanelPriority; - - /** How tools are laid out when panel is in 'expanded' state */ - layoutType: PanelLayoutType; - /** Which states are allowed and what the default state is */ stateConstraints: PanelStateConstraints; @@ -168,6 +162,56 @@ export interface PanelConfig { panelTools?: string[]; } +/** + * Configuration for an edge panel (top/left/right/bottom). + * Edge panels compete for viewport space, so `priority` (stacking/allocation + * order) and `layoutType` (how multiple tools are arranged) are required. + */ +export interface EdgePanelConfig extends BasePanelConfig { + /** Which edge region this panel occupies */ + position: EdgePanelPosition; + + /** + * Layout priority for space allocation. + * Lower numbers claim viewport space first. + * Example: top=0, right=1, bottom=2, left=3 + */ + priority: PanelPriority; + + /** How tools are laid out when panel is in 'expanded' state */ + layoutType: PanelLayoutType; +} + +/** + * Configuration for a floating panel, rendered as an overlay inside the + * center map area. Floats don't compete for edge viewport space, so + * `priority` and `layoutType` are optional, and drag-resize isn't supported. + */ +export interface FloatPanelConfig extends BasePanelConfig { + /** Which float zone this panel occupies */ + position: FloatPanelPosition; + + /** + * Not used for float layout/rendering, but still affects fallback tool + * assignment: ToolControllerModern_._findPanel picks the first compatible + * panel from the combined edge+float priority order, so a low priority + * here can out-rank an edge panel for an unassigned tool. + */ + priority?: PanelPriority; + + /** Accepted but inert for floats — _renderFloatRegions always stacks tools regardless of this value */ + layoutType?: PanelLayoutType; + + /** Floats can't be drag-resized; `resizable` must be false/omitted */ + capabilities?: Omit & { resizable?: false }; +} + +/** + * Complete configuration for a panel region. + * Defines behavior, constraints, and appearance. + */ +export type PanelConfig = EdgePanelConfig | FloatPanelConfig; + /** * Runtime state object for an active panel. * Tracks current state, configuration, and associated tools. diff --git a/src/essence/Validators/DashboardConfigValidator.js b/src/essence/Validators/DashboardConfigValidator.js index c9a949eac..86c0c221d 100644 --- a/src/essence/Validators/DashboardConfigValidator.js +++ b/src/essence/Validators/DashboardConfigValidator.js @@ -294,6 +294,8 @@ function validatePanelConfig(panel, index, isFloat = false) { if (cap.resizable !== undefined && typeof cap.resizable !== 'boolean') { errors.push(`${prefix}.capabilities: "resizable" must be a boolean`) + } else if (isFloatPosition && cap.resizable === true) { + errors.push(`${prefix}.capabilities: "resizable" is not supported on floating panels`) } let minSize, maxSize diff --git a/src/essence/types/dashboard.ts b/src/essence/types/dashboard.ts index d5ae8dc8e..52975b78d 100644 --- a/src/essence/types/dashboard.ts +++ b/src/essence/types/dashboard.ts @@ -10,6 +10,8 @@ // Import panel types from the source of truth import type { PanelConfig, + EdgePanelConfig, + FloatPanelConfig, PanelStateConstraints, PanelCapabilities, PanelDimensions, @@ -18,6 +20,8 @@ import type { // Re-export them for consumers of this module export type { PanelConfig, + EdgePanelConfig, + FloatPanelConfig, PanelStateConstraints, PanelCapabilities, PanelDimensions, diff --git a/tests/unit/panelManager/panelManager.queries.spec.js b/tests/unit/panelManager/panelManager.queries.spec.js index 5164856c3..ffd31b40a 100644 --- a/tests/unit/panelManager/panelManager.queries.spec.js +++ b/tests/unit/panelManager/panelManager.queries.spec.js @@ -63,6 +63,20 @@ test.describe('PanelManager - Queries', () => { expect(panels[1].id).toBe('panel-3') expect(panels[2].id).toBe('panel-1') }) + + test('sorts float panels (no priority) after prioritized panels instead of producing NaN order', () => { + // Float panels legitimately omit priority (see DashboardConfigValidator). + // `undefined - number` is NaN, and Array.prototype.sort treats a NaN + // comparator result as "leave order unchanged" - this must not happen. + const floatConfig = createMockPanelConfig({ id: 'float-panel', priority: undefined }) + const prioritized = createMockPanelConfig({ id: 'prioritized-panel', priority: 2 }) + + panelManager.registerPanel(floatConfig) + panelManager.registerPanel(prioritized) + + const panels = panelManager.getPanelsAtPosition(PANEL_POSITION.LEFT) + expect(panels.map(p => p.id)).toEqual(['prioritized-panel', 'float-panel']) + }) }) test.describe('getAllPanelsByPriority', () => { @@ -89,6 +103,21 @@ test.describe('PanelManager - Queries', () => { expect(panels[2].id).toBe('bottom') expect(panels[3].id).toBe('left') }) + + test('sorts float panels (no priority) after prioritized panels', () => { + const edge = createMockPanelConfig({ id: 'edge', position: PANEL_POSITION.TOP, priority: 0 }) + const float = createMockPanelConfig({ + id: 'float', + position: PANEL_POSITION.FLOAT_TOP_LEFT, + priority: undefined, + }) + + panelManager.registerPanel(float) + panelManager.registerPanel(edge) + + const panels = panelManager.getAllPanelsByPriority() + expect(panels.map(p => p.id)).toEqual(['edge', 'float']) + }) }) test.describe('isToolCompatible', () => { From 4ab3767af4a706673a4c97fdffdf2d8220db3fc7 Mon Sep 17 00:00:00 2001 From: Sandesh Pandey Date: Thu, 2 Jul 2026 10:07:09 -0500 Subject: [PATCH 7/9] Enhance plugin visibility checks to include deferred/unloaded states --- .../Basics/ToolController_/ToolControllerModern_.js | 9 ++++----- src/essence/mmgisAPI/mmgisAPI.js | 6 ++++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/essence/Basics/ToolController_/ToolControllerModern_.js b/src/essence/Basics/ToolController_/ToolControllerModern_.js index 08ca61f61..7aaa23178 100644 --- a/src/essence/Basics/ToolController_/ToolControllerModern_.js +++ b/src/essence/Basics/ToolController_/ToolControllerModern_.js @@ -631,16 +631,15 @@ const ToolControllerModern_ = { }, /** - * Check if a plugin is currently hidden via hidePlugin/startHidden (loaded but not visible). - * Returns false for a plugin that was never loaded or is currently unloaded/deferred - * (startUnloaded, or unloadPlugin) — those are also visually hidden but are reported - * via isPluginLoaded()===false instead. Check both to fully reason about visibility. + * Check if a plugin is currently hidden from view — via hidePlugin/startHidden + * (loaded but not visible), or because it's deferred/unloaded (startUnloaded, + * or unloadPlugin) * * @param {string} pluginId - Tool ID * @returns {boolean} */ isPluginHidden: function (pluginId) { - return hiddenTools.has(pluginId) + return hiddenTools.has(pluginId) || deferredTools.has(pluginId) } } diff --git a/src/essence/mmgisAPI/mmgisAPI.js b/src/essence/mmgisAPI/mmgisAPI.js index 3cbdead2d..0c019c3fa 100644 --- a/src/essence/mmgisAPI/mmgisAPI.js +++ b/src/essence/mmgisAPI/mmgisAPI.js @@ -475,7 +475,7 @@ var mmgisAPI_ = { loadPlugin: function (pluginId) { if (!mmgisAPI_._pluginController) { console.warn('[mmgisAPI] loadPlugin: modern layout not active') - return null + return false } return mmgisAPI_._pluginController.loadPlugin(pluginId) }, @@ -894,7 +894,9 @@ var mmgisAPI = { isPluginLoaded: mmgisAPI_.isPluginLoaded, /** - * Check whether a plugin is currently hidden (loaded but not visible). + * Check whether a plugin is not currently visible — either explicitly hidden + * via hidePlugin/startHidden while loaded, or deferred/unloaded (startUnloaded, + * or unloadPlugin). Use isPluginLoaded alongside this to tell the two apart. * @param {string} pluginId - Tool ID * @returns {boolean} */ From f33c27c292a5d701729108cdbd557bf844d97562 Mon Sep 17 00:00:00 2001 From: Sandesh Pandey Date: Thu, 2 Jul 2026 10:07:29 -0500 Subject: [PATCH 8/9] Refactor tool destruction logic to ensure consistent cleanup and error handling --- .../ToolController_/ToolControllerModern_.js | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/essence/Basics/ToolController_/ToolControllerModern_.js b/src/essence/Basics/ToolController_/ToolControllerModern_.js index 7aaa23178..b6d9729e4 100644 --- a/src/essence/Basics/ToolController_/ToolControllerModern_.js +++ b/src/essence/Basics/ToolController_/ToolControllerModern_.js @@ -410,15 +410,20 @@ const ToolControllerModern_ = { return false } - try { - const { toolInstance, toolName, toolId } = loadedTools.get(targetId) + const { toolInstance, toolName, toolId } = loadedTools.get(targetId) + let destroyed = true + try { // Call destroy() if available if (typeof toolInstance.destroy === 'function') { toolInstance.destroy() } - - // Remove from tracking + } catch (error) { + logger.error(`Error destroying tool in container "${targetId}":`, error) + destroyed = false + } finally { + // Always remove from tracking, even if destroy() threw, so a + // misbehaving plugin can't leave stale/zombie lifecycle state loadedTools.delete(targetId) // Clean up reverse lookup and hidden state @@ -426,14 +431,13 @@ const ToolControllerModern_ = { toolIdToTargetId.delete(toolId) hiddenTools.delete(toolId) } + } + if (destroyed) { logger.debug(`Destroyed tool "${toolName}" from container "${targetId}"`) - - return true - } catch (error) { - logger.error(`Error destroying tool in container "${targetId}":`, error) - return false } + + return destroyed }, /** From 7ec880469669f4d016fb415eb60965e8ceffa8e7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 2 Jul 2026 15:08:01 +0000 Subject: [PATCH 9/9] chore: bump version to 4.2.12-20260702 [version bump] --- configure/package.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/configure/package.json b/configure/package.json index b920c8496..2c4756dac 100644 --- a/configure/package.json +++ b/configure/package.json @@ -1,6 +1,6 @@ { "name": "configure", - "version": "4.2.11-20260611", + "version": "4.2.12-20260702", "homepage": "./configure/build", "private": true, "dependencies": { diff --git a/package.json b/package.json index 88ccc18d9..53128d446 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mmgis", - "version": "4.2.11-20260611", + "version": "4.2.12-20260702", "description": "A web-based mapping and localization solution for science operation on planetary missions.", "homepage": "build", "repository": {