diff --git a/configure/package-lock.json b/configure/package-lock.json index 481959f16..628b3dda3 100644 --- a/configure/package-lock.json +++ b/configure/package-lock.json @@ -1,12 +1,12 @@ { "name": "configure", - "version": "0.1.0", + "version": "4.2.9-20260211", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "configure", - "version": "0.1.0", + "version": "4.2.9-20260211", "dependencies": { "@codemirror/lang-json": "^6.0.1", "@emotion/react": "^11.11.1", diff --git a/configure/src/metaconfigs/tab-ui-config.json b/configure/src/metaconfigs/tab-ui-config.json index 50d9d4fa2..0a95c60ba 100644 --- a/configure/src/metaconfigs/tab-ui-config.json +++ b/configure/src/metaconfigs/tab-ui-config.json @@ -235,6 +235,126 @@ } ] }, + { + "name": "Floating Panels (Center Area)", + "components": [ + { + "field": "panelSettings.floatingPanels", + "name": "Floating Panels", + "description": "Panels that float over the center map area. Each floating panel is anchored to one of six positions. At most one panel per position is allowed.", + "type": "objectarray", + "width": 24, + "object": [ + { + "field": "id", + "name": "ID", + "description": "Unique identifier for this floating panel (e.g., 'timeline-panel', 'legend-float').", + "type": "text", + "width": 6 + }, + { + "field": "position", + "name": "Position", + "description": "Where the panel floats within the map area.", + "type": "dropdown", + "options": [ + "float-top-left", + "float-top-center", + "float-top-right", + "float-bottom-left", + "float-bottom-center", + "float-bottom-right" + ], + "default": "float-bottom-center", + "width": 6 + }, + { + "field": "hasHeader", + "name": "Has Header", + "description": "Whether the panel shows a title bar with control buttons.", + "type": "checkbox", + "width": 4, + "defaultChecked": false + }, + { + "field": "title", + "name": "Title", + "description": "Display title shown in the header (only relevant when Has Header is enabled).", + "type": "text", + "width": 4 + }, + { + "field": "stateConstraints.allowedStates", + "name": "Allowed States", + "description": "Permitted states for this panel. Floating panels support collapsed (hidden) and expanded (visible) only.", + "type": "multiselect", + "options": ["collapsed", "expanded"], + "default": ["collapsed", "expanded"], + "width": 12 + }, + { + "field": "stateConstraints.defaultState", + "name": "Default State", + "description": "Initial visibility state when the panel is created.", + "type": "dropdown", + "options": ["collapsed", "expanded"], + "default": "expanded", + "width": 12 + }, + { + "field": "dimensions.defaultWidth", + "name": "Default Width", + "description": "Initial width of each tool card. Accepts any CSS unit (e.g. '300px', '40%', '20vw'). Leave empty to size to content.", + "type": "text", + "width": 4 + }, + { + "field": "dimensions.defaultHeight", + "name": "Default Height", + "description": "Initial height of each tool card. Accepts any CSS unit (e.g. '200px', '30%', '25vh'). Leave empty to size to content.", + "type": "text", + "width": 4 + }, + { + "field": "dimensions.minWidth", + "name": "Min Width", + "description": "Minimum width of each tool card. Accepts any CSS unit (e.g. '100px', '10%').", + "type": "text", + "width": 4 + }, + { + "field": "dimensions.maxWidth", + "name": "Max Width", + "description": "Maximum width of each tool card. Accepts any CSS unit (e.g. '600px', '50%').", + "type": "text", + "width": 4 + }, + { + "field": "dimensions.minHeight", + "name": "Min Height", + "description": "Minimum height of each tool card. Accepts any CSS unit (e.g. '100px', '10vh').", + "type": "text", + "width": 4 + }, + { + "field": "dimensions.maxHeight", + "name": "Max Height", + "description": "Maximum height of each tool card. Accepts any CSS unit (e.g. '400px', '40%', '50vh'). Useful for preventing top/bottom float zones from overlapping.", + "type": "text", + "width": 4 + }, + { + "field": "panelTools", + "name": "Tools", + "description": "Tool names to display in this floating panel. Each tool renders as its own card with a gap between cards. Must match tool names in the mission's tools array (e.g., 'Timeline', 'Legend').", + "type": "textarray", + "default": [], + "width": 24 + } + ] + } + ] + }, { "name": "Map Panel", "components": [ diff --git a/docs/MISSION_CONFIG_REFERENCE.md b/docs/MISSION_CONFIG_REFERENCE.md index 18337a049..4e74297d7 100644 --- a/docs/MISSION_CONFIG_REFERENCE.md +++ b/docs/MISSION_CONFIG_REFERENCE.md @@ -109,6 +109,37 @@ Determines where the panel is located: - **`"left"`**: Vertical panel on the left side - **`"right"`**: Vertical panel on the right side +**Floating positions** (render inside the center map area as overlays): + +- **`"float-top-left"`**: Floats in the top-left corner of the map +- **`"float-top-center"`**: Floats at the top-center of the map +- **`"float-top-right"`**: Floats in the top-right corner of the map +- **`"float-bottom-left"`**: Floats in the bottom-left corner of the map +- **`"float-bottom-center"`**: Floats at the bottom-center of the map +- **`"float-bottom-right"`**: Floats in the bottom-right corner of the map + +**Floating panel rules**: +- Multiple tools can be assigned to one floating panel — each tool renders as its own card with a gap between cards +- Only **one** floating panel is allowed per position — the validator rejects configs that assign more than one panel (in `panels` or `floatingPanels`) to the same float position +- Supported states: `"collapsed"` and `"expanded"` only (`"iconified"` and `"focused"` are not supported) +- `layoutType` is ignored for floating panels +- `capabilities.resizable` is not supported for floating panels +- In `overlay` layout style the panel has rounded corners; in `compact` layout style it has sharp corners +- A gap between cards is applied automatically; no outer gap is added in overlay mode (the panel's own margin handles it) + +**Floating panel `dimensions` fields**: + +All values accept any CSS unit string (e.g. `"40%"`, `"50vh"`, `"300px"`) or a plain number (treated as `px`). Dimensions apply to each individual tool card. + +| Field | Description | +|---|---| +| `defaultWidth` | Initial width (omit to size to content) | +| `defaultHeight` | Initial height (omit to size to content) | +| `minWidth` | Minimum width | +| `maxWidth` | Maximum width | +| `minHeight` | Minimum height | +| `maxHeight` | Maximum height — useful for capping top/bottom float zones so they never overlap (e.g. top panel `maxHeight: "40%"`, bottom panel `maxHeight: "50%"` leaves a 10% gap) | + ### priority Controls the order in which panels claim viewport space: @@ -441,6 +472,28 @@ Full mission configuration with four panels: "expandedSize": 300 }, "tools": ["Draw", "RasterTile", "Identifier"] + }, + { + "id": "timeline-panel", + "position": "float-bottom-center", + "priority": 0, + "layoutType": "stacked", + "stateConstraints": { + "allowedStates": ["collapsed", "expanded"], + "defaultState": "expanded" + }, + "panelTools": ["Timeline"] + }, + { + "id": "legend-float-panel", + "position": "float-bottom-right", + "priority": 0, + "layoutType": "stacked", + "stateConstraints": { + "allowedStates": ["collapsed", "expanded"], + "defaultState": "expanded" + }, + "panelTools": ["Legend"] } ] }, diff --git a/package.json b/package.json index b55026871..88ccc18d9 100644 --- a/package.json +++ b/package.json @@ -36,10 +36,10 @@ "cesium" ], "scripts": { - "db:start": "docker-compose -f docker-compose.db.yml up -d", - "db:stop": "docker-compose -f docker-compose.db.yml down", - "db:logs": "docker-compose -f docker-compose.db.yml logs -f", - "prestart": "docker-compose -f docker-compose.db.yml up -d --wait", + "db:start": "docker compose -f docker-compose.db.yml up -d", + "db:stop": "docker compose -f docker-compose.db.yml down", + "db:logs": "docker compose -f docker-compose.db.yml logs -f", + "prestart": "docker compose -f docker-compose.db.yml up -d --wait", "start": "node scripts/init-db.js && node scripts/server.js", "start:no-docker": "node scripts/init-db.js && node scripts/server.js", "start:prod": "node scripts/init-db.js && cross-env NODE_ENV=production node scripts/server.js", diff --git a/src/essence/Basics/PanelManager_/PanelManager_.ts b/src/essence/Basics/PanelManager_/PanelManager_.ts index 3e2f8a63c..882c64109 100644 --- a/src/essence/Basics/PanelManager_/PanelManager_.ts +++ b/src/essence/Basics/PanelManager_/PanelManager_.ts @@ -1,5 +1,5 @@ import { ToolOrientation, ToolMetadata } from '../ToolController_/types/tool'; -import { PanelPosition, PanelState, PanelLayoutType, PANEL_STATE } from './types/layout'; +import { PanelPosition, PanelState, PanelLayoutType, PANEL_STATE, FLOAT_POSITIONS } from './types/layout'; import { PanelConfig, PanelStateObject, PanelManager as PanelManagerInterface } from './types/panel'; import { mmgisAPI } from '../../mmgisAPI/mmgisAPI'; @@ -157,6 +157,14 @@ class PanelManager implements PanelManagerInterface { throw new Error(`Panel with ID ${panelId} not found`); } + if ((FLOAT_POSITIONS as Set).has(panel.config.position) && + (newState === PANEL_STATE.ICONIFIED || newState === PANEL_STATE.FOCUSED)) { + throw new Error( + `Float panels do not support '${newState}' state. ` + + `Only 'collapsed' and 'expanded' are allowed for float panel ${panelId}.` + ); + } + if (!panel.config.stateConstraints.allowedStates.includes(newState)) { throw new Error(`State transition to ${newState} is not allowed for panel ${panelId}`); } diff --git a/src/essence/Basics/PanelManager_/types/layout.ts b/src/essence/Basics/PanelManager_/types/layout.ts index a30296881..1cf64747d 100644 --- a/src/essence/Basics/PanelManager_/types/layout.ts +++ b/src/essence/Basics/PanelManager_/types/layout.ts @@ -6,9 +6,28 @@ export const PANEL_POSITION = { LEFT: 'left', RIGHT: 'right', BOTTOM: 'bottom', + FLOAT_TOP_LEFT: 'float-top-left', + FLOAT_TOP_CENTER: 'float-top-center', + FLOAT_TOP_RIGHT: 'float-top-right', + FLOAT_BOTTOM_LEFT: 'float-bottom-left', + FLOAT_BOTTOM_CENTER: 'float-bottom-center', + FLOAT_BOTTOM_RIGHT: 'float-bottom-right', } as const export type PanelPosition = (typeof PANEL_POSITION)[keyof typeof PANEL_POSITION] +/** + * 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) + /** * Visual states of a panel: * - collapsed: Hidden completely, takes no space in the viewport diff --git a/src/essence/Basics/PanelManager_/types/panel.ts b/src/essence/Basics/PanelManager_/types/panel.ts index 24a950ac0..41e25e266 100644 --- a/src/essence/Basics/PanelManager_/types/panel.ts +++ b/src/essence/Basics/PanelManager_/types/panel.ts @@ -62,6 +62,20 @@ export interface PanelDimensions { * - For left/right: this is the width */ expandedSize?: PanelSize; + + /** + * CSS sizing for floating panels — applied directly as CSS properties on the panel element. + * Numbers are treated as px; strings are passed through as-is (e.g. "50%", "40vh", "300px"). + * + * Distinct from PanelCapabilities.minSize/maxSize, which constrain drag-resize handles + * (single-axis, pixels only). These apply to both axes and support all CSS units. + */ + defaultWidth?: number | string; + defaultHeight?: number | string; + minWidth?: number | string; + maxWidth?: number | string; + minHeight?: number | string; + maxHeight?: number | string; } /** diff --git a/src/essence/Basics/UserInterface_/UserInterfaceModern_.css b/src/essence/Basics/UserInterface_/UserInterfaceModern_.css index bd83f44d7..054bb9c4e 100644 --- a/src/essence/Basics/UserInterface_/UserInterfaceModern_.css +++ b/src/essence/Basics/UserInterface_/UserInterfaceModern_.css @@ -41,8 +41,6 @@ /* Base Panel Styling */ .ui-panel { background: var(--theme-color-white, #ffffff); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); box-shadow: 0 4px 6px var(--theme-color-shadow, rgba(0, 0, 0, 0.15)); display: flex; flex-direction: column; @@ -465,4 +463,92 @@ body.ui-is-dragging .ui-modern-grid .ui-panel { border: none; } -/* Z-index management (no longer needed as variables, kept for reference) */ \ No newline at end of file +/* ============================================ + Floating Panels (center area overlays) + ============================================ */ + +:root { + --ui-float-gap: var(--theme-spacing-1, 0.5rem); + --ui-float-outer-gap: var(--theme-spacing-2, 1rem); +} + +/* Invisible wrapper that holds all float zones */ +.ui-float-regions { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 5; +} + +/* Each zone is an absolutely-positioned column of tool cards */ +/* No padding in overlay mode — .ui-panel margin already provides the outer gap */ +.ui-float-zone { + position: absolute; + display: flex; + flex-direction: column; + gap: var(--ui-float-gap); + pointer-events: none; +} + +/* Compact mode has no panel margin, so add outer gap on the zone instead */ +.ui-layout-compact .ui-float-zone { + padding: var(--ui-float-outer-gap); +} + +/* Zone positions */ +.ui-float-zone-top-left { top: 0; left: 0; } +.ui-float-zone-top-center { top: 0; left: 50%; transform: translateX(-50%); } +.ui-float-zone-top-right { top: 0; right: 0; } +.ui-float-zone-bottom-left { bottom: 0; left: 0; } +.ui-float-zone-bottom-center { bottom: 0; left: 50%; transform: translateX(-50%); } +.ui-float-zone-bottom-right { bottom: 0; right: 0; } + +/* Float panels re-enable pointer events */ +.ui-float-panel { + pointer-events: auto; + position: relative; + background-color: transparent; +} + +/* Compact layout: no margin */ +.ui-modern-grid.ui-layout-compact .ui-float-panel { + margin: 0; +} + +/* Overlay mode: tool cards have rounded corners (panel container is transparent) */ +.ui-modern-grid:not(.ui-layout-compact) .ui-float-panel .ui-tool-card { + border-radius: var(--theme-radius-md, 8px); +} + +/* Compact mode: tool cards are square */ +.ui-modern-grid.ui-layout-compact .ui-float-panel .ui-tool-card { + border-radius: 0; +} + +/* Collapsed float panels are hidden */ +.ui-float-panel[data-panel-state="collapsed"] { + display: none; +} + +/* Float panel body: stacks tools vertically with gaps; scrolls if content exceeds max-height */ +.ui-float-panel-body { + display: flex; + flex-direction: column; + gap: var(--ui-float-gap, 0.5rem); + overflow-y: auto; + flex: 1; + min-height: 0; +} + +/* Thin, subtle scrollbar inside float panels */ +.ui-float-panel-body::-webkit-scrollbar { width: 4px; } +.ui-float-panel-body::-webkit-scrollbar-track { background: transparent; } +.ui-float-panel-body::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.2); border-radius: 2px; } + +/* Float tool cards size to their content; panel body handles overflow */ +.ui-float-panel .ui-tool-card { + display: flex; + flex-shrink: 0; + padding: 2px; + overflow: hidden; +} \ No newline at end of file diff --git a/src/essence/Basics/UserInterface_/UserInterfaceModern_.js b/src/essence/Basics/UserInterface_/UserInterfaceModern_.js index 21f9ef061..b9aceb240 100644 --- a/src/essence/Basics/UserInterface_/UserInterfaceModern_.js +++ b/src/essence/Basics/UserInterface_/UserInterfaceModern_.js @@ -4,6 +4,7 @@ import { mmgisAPI } from '../../mmgisAPI/mmgisAPI' import ToolControllerModern_ from '../ToolController_/ToolControllerModern_' import { getValidIconClass } from '../ToolController_/ToolMetadataUtils' import { createLogger } from '../Logger_/Logger_' +import { FLOAT_POSITIONS } from '../PanelManager_/types/layout' import './UserInterfaceModern_.css' const logger = createLogger('UserInterfaceModern') @@ -83,6 +84,77 @@ const _createPanelHeader = (panel) => { return header } +// Converts a dimension value to a CSS string. +// Numbers are treated as px; strings are passed through as-is (e.g. "40%", "50vh"). +const _toCssValue = (v) => (typeof v === 'number' ? v + 'px' : v) + +const _renderFloatRegions = (floatPanels) => { + if (!floatPanels || floatPanels.length === 0) return null + + const fragment = $('
') + + // Group panels by their float position (validator enforces at most one per position) + const byPosition = {} + floatPanels.forEach(panel => { + const pos = panel.config.position + if (!byPosition[pos]) byPosition[pos] = [] + byPosition[pos].push(panel) + }) + + Object.entries(byPosition).forEach(([position, positionPanels]) => { + const suffix = position.replace('float-', '') + const zone = $(`
`) + + positionPanels.forEach(panel => { + const toolsMetadata = PanelManager_.getToolsForPanel(panel.id) || [] + if (toolsMetadata.length === 0) return + + // One panel card per float zone — all tools stack inside it + const panelDiv = $('
') + .attr('id', panel.containerId) + .attr('data-panel-state', panel.state) + + const dims = panel.config.dimensions || {} + + // Width constraints go on the panel container + const panelCss = {} + if (dims.defaultWidth) panelCss['width'] = _toCssValue(dims.defaultWidth) + if (dims.minWidth) panelCss['min-width'] = _toCssValue(dims.minWidth) + if (dims.maxWidth) panelCss['max-width'] = _toCssValue(dims.maxWidth) + if (Object.keys(panelCss).length) panelDiv.css(panelCss) + + if (panel.config.hasHeader) { + panelDiv.append(_createPanelHeader(panel)) + } + + // Body holds all tool cards stacked vertically with gaps + const body = $('
') + + // Height constraints go on the body so overflow-y: auto can trigger correctly. + // Applying max-height only to the panel won't constrain the flex body's actual height, + // so the body scroll never fires; setting it on the body directly fixes this. + const bodyCss = {} + if (dims.defaultHeight) bodyCss['height'] = _toCssValue(dims.defaultHeight) + if (dims.minHeight) bodyCss['min-height'] = _toCssValue(dims.minHeight) + 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) + toolCard.addClass('active') + body.append(toolCard) + toolLoadQueue.push(loadTool) + }) + + panelDiv.append(body) + zone.append(panelDiv) + }) + + fragment.append(zone) + }) + + return fragment +} + const _renderRegion = (regionName, regionPanels) => { if (!regionPanels || regionPanels.length === 0) return null @@ -356,17 +428,20 @@ const UserInterfaceModern_ = { gridWrapper.addClass('ui-layout-compact') } - // Group panels by position + // Group panels by position — float positions go into a separate bucket const layoutRegions = { top: [], left: [], right: [], bottom: [] } + const floatPanels = [] panels.forEach(p => { const pos = p.config?.position - if (layoutRegions[pos]) { + if (FLOAT_POSITIONS.has(pos)) { + floatPanels.push(p) + } else if (layoutRegions[pos]) { layoutRegions[pos].push(p) } }) @@ -413,6 +488,11 @@ const UserInterfaceModern_ = { } } + const floatRegions = _renderFloatRegions(floatPanels) + if (floatRegions) { + centralArea.append(floatRegions) + } + gridWrapper.append(_renderRegion('left', layoutRegions.left)) gridWrapper.append(_renderRegion('right', layoutRegions.right)) gridWrapper.append(_renderRegion('bottom', layoutRegions.bottom)) @@ -452,6 +532,8 @@ const UserInterfaceModern_ = { } panels.forEach(panel => { + const isFloatingPanel = FLOAT_POSITIONS.has(panel.config?.position) + const $panel = $(document.getElementById(panel.containerId)) if ($panel.length === 0) return @@ -475,7 +557,12 @@ const UserInterfaceModern_ = { $panel.find('.ui-panel-icon-btn').removeClass('active') } - if (panel.state === 'iconified') { + // Float panels are sized via dimensions.defaultWidth/defaultHeight applied + // once at render time (see _renderFloatRegions) — the edge-panel + // expandedSize/iconifiedSize logic below doesn't apply and would clobber them. + if (isFloatingPanel) { + // no-op + } else if (panel.state === 'iconified') { $panel.css({ width: '', height: '', flex: 'none' }) } else if (panel.state === 'expanded' || panel.state === 'focused') { let targetSize = panel.currentSize; diff --git a/src/essence/Validators/DashboardConfigValidator.js b/src/essence/Validators/DashboardConfigValidator.js index f2ff9533a..c9a949eac 100644 --- a/src/essence/Validators/DashboardConfigValidator.js +++ b/src/essence/Validators/DashboardConfigValidator.js @@ -12,7 +12,7 @@ * @typedef {import('../types/dashboard').ValidationResult} ValidationResult */ -import { PANEL_POSITION, PANEL_STATE, PANEL_LAYOUT_TYPE } from '../Basics/PanelManager_/types/layout' +import { PANEL_POSITION, PANEL_STATE, PANEL_LAYOUT_TYPE, FLOAT_POSITIONS } from '../Basics/PanelManager_/types/layout' import { TOOL_ORIENTATION } from '../Basics/ToolController_/types/tool' import { VALID_MODES, VALID_LAYOUT_STYLES } from '../types/dashboard' @@ -76,20 +76,59 @@ export function validateModernConfig(config) { } // Validate each panel + // Float positions are tracked across BOTH the regular "panels" array and the + // "floatingPanels" array so the one-panel-per-float-zone rule can't be bypassed + // by splitting panels with the same float position across the two arrays. const panelIds = new Set() + const floatPositions = new Set() panelSettings.panels.forEach((panel, index) => { - const panelErrors = validatePanelConfig(panel, index) + const panelErrors = validatePanelConfig(panel, index, false) errors.push(...panelErrors) - // Check for duplicate panel IDs if (panel.id) { if (panelIds.has(panel.id)) { errors.push(`Duplicate panel ID: "${panel.id}"`) } panelIds.add(panel.id) } + + // Float positions are honored by the renderer regardless of which array a + // panel was declared in (modern.js merges "panels" and "floatingPanels" + // before registration), so dedup must consider this array too. + if (panel.position && FLOAT_POSITIONS.has(panel.position)) { + if (floatPositions.has(panel.position)) { + errors.push(`Duplicate floating panel position: "${panel.position}" — each float zone can only have one panel`) + } + floatPositions.add(panel.position) + } }) + // Validate optional floatingPanels array + if (panelSettings.floatingPanels !== undefined) { + if (!Array.isArray(panelSettings.floatingPanels)) { + errors.push('panelSettings.floatingPanels must be an array') + } else { + panelSettings.floatingPanels.forEach((panel, index) => { + const panelErrors = validatePanelConfig(panel, index, true) + errors.push(...panelErrors) + + if (panel.id) { + if (panelIds.has(panel.id)) { + errors.push(`Duplicate panel ID: "${panel.id}"`) + } + panelIds.add(panel.id) + } + + if (panel.position && FLOAT_POSITIONS.has(panel.position)) { + if (floatPositions.has(panel.position)) { + errors.push(`Duplicate floating panel position: "${panel.position}" — each float zone can only have one panel`) + } + floatPositions.add(panel.position) + } + }) + } + } + // Validate tools array (optional) if (config.tools && !Array.isArray(config.tools)) { errors.push('Config "tools" must be an array') @@ -125,17 +164,24 @@ export function validateModernConfig(config) { * * @param {PanelConfig} panel - The panel config to validate * @param {number} index - Index in the panels array (for error messages) + * @param {boolean} isFloat - Whether this is a floating panel (relaxes layoutType/priority requirements) * @returns {string[]} - Array of error messages */ -function validatePanelConfig(panel, index) { +function validatePanelConfig(panel, index, isFloat = false) { const errors = [] - const prefix = `Panel[${index}]` + const prefix = isFloat ? `FloatingPanel[${index}]` : `Panel[${index}]` if (!panel || typeof panel !== 'object') { errors.push(`${prefix}: Must be an object`) return errors } + // What actually governs float rendering/state-machine behavior at runtime is the + // panel's position, not which config array it was declared in (modern.js merges + // "panels" and "floatingPanels" before registration). Use this for checks that + // must hold regardless of source array. + const isFloatPosition = !!(panel.position && FLOAT_POSITIONS.has(panel.position)) + // Required fields if (!panel.id || typeof panel.id !== 'string') { errors.push(`${prefix}: Must have a string "id"`) @@ -150,24 +196,40 @@ function validatePanelConfig(panel, index) { errors.push( `${prefix}: Must have a valid "position" (${VALID_POSITIONS.join(', ')})` ) + } else if (isFloat && !FLOAT_POSITIONS.has(panel.position)) { + errors.push( + `${prefix}: Floating panel position must be one of the float positions (float-top-left, float-top-center, etc.)` + ) } - // Validate priority - try to convert if string - if (panel.priority === undefined || panel.priority === null) { - errors.push(`${prefix}: Must have a "priority"`) - } else { + // Validate priority - optional for float panels + if (!isFloat) { + if (panel.priority === undefined || panel.priority === null) { + errors.push(`${prefix}: Must have a "priority"`) + } else { + const priorityNum = Number(panel.priority) + if (isNaN(priorityNum)) { + errors.push(`${prefix}: "priority" must be a valid number (got "${panel.priority}")`) + } else if (priorityNum < 0) { + errors.push(`${prefix}: "priority" must be non-negative`) + } + } + } else if (panel.priority !== undefined && panel.priority !== null) { const priorityNum = Number(panel.priority) - if (isNaN(priorityNum)) { - errors.push(`${prefix}: "priority" must be a valid number (got "${panel.priority}")`) - } else if (priorityNum < 0) { - errors.push(`${prefix}: "priority" must be non-negative`) + if (isNaN(priorityNum) || priorityNum < 0) { + errors.push(`${prefix}: "priority" must be a non-negative number when provided`) } } - if (!panel.layoutType || !VALID_LAYOUT_TYPES.includes(panel.layoutType)) { + // layoutType is required for edge panels, optional for float panels + if (!isFloat && (!panel.layoutType || !VALID_LAYOUT_TYPES.includes(panel.layoutType))) { errors.push( `${prefix}: Must have a valid "layoutType" (${VALID_LAYOUT_TYPES.join(', ')})` ) + } else if (isFloat && panel.layoutType && !VALID_LAYOUT_TYPES.includes(panel.layoutType)) { + errors.push( + `${prefix}: "layoutType" must be one of: ${VALID_LAYOUT_TYPES.join(', ')}` + ) } if (panel.hasHeader !== undefined && typeof panel.hasHeader !== 'boolean') { @@ -189,6 +251,13 @@ function validatePanelConfig(panel, index) { errors.push( `${prefix}.stateConstraints: Invalid state "${state}" (must be one of ${VALID_STATES.join(', ')})` ) + } else if (isFloatPosition && state !== PANEL_STATE.COLLAPSED && state !== PANEL_STATE.EXPANDED) { + // Float panels only support collapsed/expanded — PanelManager_.setPanelState + // throws for float + iconified/focused transitions, so the validator must + // reject these here rather than let it become a reachable runtime throw. + errors.push( + `${prefix}.stateConstraints: Floating panels only support "collapsed" and "expanded" states (got "${state}")` + ) } }) } @@ -273,11 +342,38 @@ function validatePanelConfig(panel, index) { } } } + + // CSS dimension fields for floating panels (number or CSS string e.g. "50%", "40vh") + // Empty strings are treated as "not provided" and skipped. + const cssDimFields = ['defaultWidth', 'defaultHeight', 'minWidth', 'maxWidth', 'minHeight', 'maxHeight'] + cssDimFields.forEach(field => { + const val = dim[field] + if (val !== undefined && val !== '' && !isValidCssDimension(val)) { + errors.push( + `${prefix}.dimensions: "${field}" must be a number (px), unitless "0", or a CSS string with a unit (px, %, vh, vw, svh, dvh, vmin, vmax, ch, rem, em) — got "${val}"` + ) + } + }) } return errors } +/** + * Returns true if v is a valid CSS dimension value: a number (treated as px) + * or a string with a number followed by a recognised CSS unit. + * @param {*} v + * @returns {boolean} + */ +function isValidCssDimension(v) { + if (typeof v === 'number') return true + if (typeof v === 'string') { + if (v === '0') return true + return /^\d+(\.\d+)?(px|%|vh|vw|svh|dvh|vmin|vmax|ch|rem|em)$/.test(v) + } + return false +} + /** * Sanitizes a string for safe use in HTML/URLs. * Removes potentially dangerous characters. diff --git a/src/essence/modern.js b/src/essence/modern.js index 486fb4d58..7b5b13c8e 100644 --- a/src/essence/modern.js +++ b/src/essence/modern.js @@ -262,9 +262,14 @@ class ModernInterface { */ _registerPanels() { const panelsConfig = this.configData?.panelSettings?.panels || [] + const floatingPanelsConfig = (this.configData?.panelSettings?.floatingPanels || []).map(p => ({ + layoutType: 'stacked', + ...p, + })) + const allPanels = [...panelsConfig, ...floatingPanelsConfig] const failedPanels = [] - panelsConfig.forEach(panelConfig => { + allPanels.forEach(panelConfig => { try { // Register with the global PanelManager PanelManager_.registerPanel(panelConfig) diff --git a/src/essence/types/dashboard.ts b/src/essence/types/dashboard.ts index 9b6f6810d..d5ae8dc8e 100644 --- a/src/essence/types/dashboard.ts +++ b/src/essence/types/dashboard.ts @@ -62,6 +62,9 @@ export interface PanelSettings { /** Array of panel configurations */ panels: PanelConfig[]; + /** Floating panels rendered inside the center map area */ + floatingPanels?: PanelConfig[]; + /** Layout style ('overlay' | 'compact') */ layoutStyle?: LayoutStyle; }