diff --git a/configure/src/metaconfigs/tab-ui-config.json b/configure/src/metaconfigs/tab-ui-config.json index 55e3b8451..4ec80f478 100644 --- a/configure/src/metaconfigs/tab-ui-config.json +++ b/configure/src/metaconfigs/tab-ui-config.json @@ -245,6 +245,42 @@ { "name": "Floating Panels (Center Area)", "components": [ + { + "field": "panelSettings.floatingOffset.0", + "name": "Top Offset (px)", + "description": "Moves the top-left, top-center and top-right floating panels this many pixels below the map's top edge.", + "type": "number", + "width": 3, + "min": 0, + "max": 200 + }, + { + "field": "panelSettings.floatingOffset.1", + "name": "Right Offset (px)", + "description": "Moves the top-right and bottom-right floating panels this many pixels in from the map's right edge.", + "type": "number", + "width": 3, + "min": 0, + "max": 200 + }, + { + "field": "panelSettings.floatingOffset.2", + "name": "Bottom Offset (px)", + "description": "Moves the bottom-left, bottom-center and bottom-right floating panels this many pixels above the map's bottom edge, e.g. to keep them clear of the basemap's logo and attribution.", + "type": "number", + "width": 3, + "min": 0, + "max": 200 + }, + { + "field": "panelSettings.floatingOffset.3", + "name": "Left Offset (px)", + "description": "Moves the top-left and bottom-left floating panels this many pixels in from the map's left edge.", + "type": "number", + "width": 3, + "min": 0, + "max": 200 + }, { "field": "panelSettings.floatingPanels", "name": "Floating Panels", diff --git a/docs/MISSION_CONFIG_REFERENCE.md b/docs/MISSION_CONFIG_REFERENCE.md index 3d9da9893..0db61a307 100644 --- a/docs/MISSION_CONFIG_REFERENCE.md +++ b/docs/MISSION_CONFIG_REFERENCE.md @@ -65,6 +65,7 @@ The mission configuration controls the layout and behavior of panels in modern i | Field | Type | Required | Description | |-------|------|----------|-------------| | `layoutStyle` | String | No | `"overlay"` (default) or `"compact"` layout style | +| `floatingOffset` | Array | No | Pixels the floating panels sit in from each edge of the map, as `[top, right, bottom, left]`, e.g. `[0, 0, 32, 0]` to clear the basemap's logo and attribution. Missing entries are `0`; each entry is at most `200`. The center zones stay centered between the left and right offsets | | `panels` | Array | Yes | Array of panel configuration objects | ## Panel Configuration diff --git a/src/essence/Basics/UserInterface_/UserInterfaceModern_.css b/src/essence/Basics/UserInterface_/UserInterfaceModern_.css index 3806531cf..b5e7ab5be 100644 --- a/src/essence/Basics/UserInterface_/UserInterfaceModern_.css +++ b/src/essence/Basics/UserInterface_/UserInterfaceModern_.css @@ -532,10 +532,18 @@ body.ui-is-dragging .ui-modern-grid .ui-panel { --ui-float-outer-gap: var(--theme-spacing-2, 1rem); } -/* Invisible wrapper that holds all float zones */ +/* Invisible wrapper that holds all float zones. It sits + --ui-float-offset-{top,right,bottom,left} in from the map's edges + (panelSettings.floatingOffset), so every zone moves with it and the center + zones stay centered between the side offsets. Each side is capped at a + quarter of the map's size so large offsets on a small viewport still leave + the zones room. */ .ui-float-regions { position: absolute; - inset: 0; + top: min(var(--ui-float-offset-top, 0px), 25%); + right: min(var(--ui-float-offset-right, 0px), 25%); + bottom: min(var(--ui-float-offset-bottom, 0px), 25%); + left: min(var(--ui-float-offset-left, 0px), 25%); pointer-events: none; z-index: 5; } diff --git a/src/essence/Basics/UserInterface_/UserInterfaceModern_.js b/src/essence/Basics/UserInterface_/UserInterfaceModern_.js index cd8595a56..d495450bf 100644 --- a/src/essence/Basics/UserInterface_/UserInterfaceModern_.js +++ b/src/essence/Basics/UserInterface_/UserInterfaceModern_.js @@ -6,6 +6,7 @@ import { getValidIconClass } from '../ToolController_/ToolMetadataUtils' import { createLogger } from '../Logger_/Logger_' import { PANEL_STATE, FLOAT_POSITIONS } from '../PanelManager_/types/layout' import { toCssDimension, toPixelNumber } from '../PanelManager_/dimensions' +import { FLOAT_OFFSET_SIDES, MAX_FLOAT_OFFSET } from '../../types/dashboard' import './UserInterfaceModern_.css' const logger = createLogger('UserInterfaceModern') @@ -16,6 +17,7 @@ const DEFAULT_MAX_PANEL_SIZE = 9999; // --- Module-Level State --- let panels = [] let layoutStyle = '' +let floatOffsets = [0, 0, 0, 0] let toolLoadQueue = [] let cleanupLayoutListener = null let _resizeObserver = null @@ -111,10 +113,27 @@ const _createPanelHeader = (panel) => { return header } -const _renderFloatRegions = (floatPanels) => { +/** + * panelSettings.floatingOffset as four pixel counts in [top, right, bottom, + * left] order. A missing or unusable entry is 0; each is capped at + * MAX_FLOAT_OFFSET. + */ +const _toFloatOffsets = (value) => { + const entries = Array.isArray(value) ? value : [] + return FLOAT_OFFSET_SIDES.map((_, i) => + Math.min(toPixelNumber(entries[i], { allowZero: true }) ?? 0, MAX_FLOAT_OFFSET) + ) +} + +const _renderFloatRegions = (floatPanels, offsets = []) => { if (!floatPanels || floatPanels.length === 0) return null const fragment = $('
') + FLOAT_OFFSET_SIDES.forEach((side, i) => { + if (offsets[i] > 0) { + fragment[0].style.setProperty(`--ui-float-offset-${side}`, `${offsets[i]}px`) + } + }) // Group panels by their float position (validator enforces at most one per position) const byPosition = {} @@ -276,10 +295,12 @@ const UserInterfaceModern_ = { * @param {Array} panelsArr - Array of panel objects (parsed from config & PanelManager) * @param {string} style - The layout style ('overlay' | 'compact') * @param {string} theme - The theme identifier (e.g. 'default', 'disasters') + * @param {Array} floatOffset - Pixels the float zones sit in from each edge of the map, as [top, right, bottom, left]; a missing or unusable entry means 0, and each is capped at MAX_FLOAT_OFFSET */ - init: function (panelsArr, style = 'overlay', theme = 'default') { + init: function (panelsArr, style = 'overlay', theme = 'default', floatOffset = []) { panels = panelsArr layoutStyle = style + floatOffsets = _toFloatOffsets(floatOffset) // Dynamic import() so webpack emits one lazy CSS chunk per theme and // only the configured one is fetched (a sync require() here bundled @@ -613,7 +634,7 @@ const UserInterfaceModern_ = { } } - const floatRegions = _renderFloatRegions(floatPanels) + const floatRegions = _renderFloatRegions(floatPanels, floatOffsets) if (floatRegions) { centralArea.append(floatRegions) } @@ -870,4 +891,4 @@ const UserInterfaceModern_ = { } export default UserInterfaceModern_ -export { _createPanelHeader, _createPanelIconTray, _renderRegion } +export { _createPanelHeader, _createPanelIconTray, _renderRegion, _renderFloatRegions, _toFloatOffsets } diff --git a/src/essence/Validators/DashboardConfigValidator.js b/src/essence/Validators/DashboardConfigValidator.js index 69e540414..48f1ec51f 100644 --- a/src/essence/Validators/DashboardConfigValidator.js +++ b/src/essence/Validators/DashboardConfigValidator.js @@ -14,7 +14,7 @@ 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' +import { VALID_MODES, VALID_LAYOUT_STYLES, FLOAT_OFFSET_SIDES, MAX_FLOAT_OFFSET } from '../types/dashboard' import { isDimensionSet, toCssDimension, toPixelNumber, DIMENSION_HINT } from '../Basics/PanelManager_/dimensions' // Extract valid values from TypeScript constants @@ -76,6 +76,36 @@ export function validateModernConfig(config) { ) } + // Validate floatingOffset (optional [top, right, bottom, left], each defaults to 0) + const offsets = panelSettings.floatingOffset + if (offsets !== undefined && offsets !== null) { + if (!Array.isArray(offsets)) { + warnings.push( + 'panelSettings.floatingOffset must be an array of [top, right, bottom, left] pixels; using 0 on every side' + ) + } else { + if (offsets.length > FLOAT_OFFSET_SIDES.length) { + warnings.push( + `panelSettings.floatingOffset has more than ${FLOAT_OFFSET_SIDES.length} entries; extra entries are ignored` + ) + } + FLOAT_OFFSET_SIDES.forEach((side, i) => { + const offset = offsets[i] + if (!isDimensionSet(offset)) return + const offsetPx = toPixelNumber(offset, { allowZero: true }) + if (offsetPx === null) { + warnings.push( + `panelSettings.floatingOffset[${i}] (${side}) must be a non-negative number of pixels; using 0` + ) + } else if (offsetPx > MAX_FLOAT_OFFSET) { + warnings.push( + `panelSettings.floatingOffset[${i}] (${side}) must be at most ${MAX_FLOAT_OFFSET} pixels; using ${MAX_FLOAT_OFFSET}` + ) + } + }) + } + } + // Validate theme (optional, defaults to 'default') if (config.msv && config.msv.theme && typeof config.msv.theme !== 'string') { errors.push('msv.theme must be a string') diff --git a/src/essence/modern.js b/src/essence/modern.js index 9cbac7c9a..1f7a259f1 100644 --- a/src/essence/modern.js +++ b/src/essence/modern.js @@ -235,9 +235,10 @@ class ModernInterface { const layoutStyle = this.configData?.panelSettings?.layoutStyle || 'overlay' const theme = this.configData?.msv?.theme || 'default' + const floatOffset = this.configData?.panelSettings?.floatingOffset // Initialize the User Interface with the sorted panels from PanelManager - UserInterfaceModern_.init(activePanels, layoutStyle, theme) + UserInterfaceModern_.init(activePanels, layoutStyle, theme, floatOffset) // TimeControl must initialize ahead of Map_ because it seeds each // time-enabled layer's time window, which Map_.init reads when it diff --git a/src/essence/types/dashboard.ts b/src/essence/types/dashboard.ts index fd619805e..560c03ec3 100644 --- a/src/essence/types/dashboard.ts +++ b/src/essence/types/dashboard.ts @@ -39,6 +39,18 @@ export type InterfaceMode = typeof VALID_MODES[number]; export const VALID_LAYOUT_STYLES = ['overlay', 'compact'] as const; export type LayoutStyle = typeof VALID_LAYOUT_STYLES[number]; +/** + * The sides panelSettings.floatingOffset lists, in CSS shorthand order. + */ +export const FLOAT_OFFSET_SIDES = ['top', 'right', 'bottom', 'left'] as const; +export type FloatOffsetSide = typeof FLOAT_OFFSET_SIDES[number]; + +/** + * Largest value of each panelSettings.floatingOffset entry, in pixels. The + * Configure fields (tab-ui-config.json) carry the same max. + */ +export const MAX_FLOAT_OFFSET = 200; + /** * Mission, Site, Venue configuration object */ @@ -71,6 +83,14 @@ export interface PanelSettings { /** Layout style ('overlay' | 'compact') */ layoutStyle?: LayoutStyle; + + /** + * Pixels the floating panels sit in from each edge of the map, as + * [top, right, bottom, left]. Each entry is a number or a numeric/'px' + * string ('24', '24px'); a missing, null or '' entry (a cleared Configure + * field) means 0. Each entry is capped at MAX_FLOAT_OFFSET. + */ + floatingOffset?: Array; } /** diff --git a/tests/unit/DashboardConfigValidator.spec.js b/tests/unit/DashboardConfigValidator.spec.js index 98b94ea45..c34149d1c 100644 --- a/tests/unit/DashboardConfigValidator.spec.js +++ b/tests/unit/DashboardConfigValidator.spec.js @@ -91,6 +91,43 @@ test.describe('DashboardConfigValidator', () => { expect(result.errors[0]).toMatch(/panelSettings.layoutStyle must be one of/); }); + test('warns on an unusable floatingOffset and accepts pixel counts per side', () => { + const config = (floatingOffset) => ({ + panelSettings: { + floatingOffset, + panels: [ + { + id: 'panel-1', + position: 'left', + priority: 1, + layoutType: 'stacked', + stateConstraints: { + allowedStates: ['expanded'], + defaultState: 'expanded' + } + } + ] + } + }); + const warningsFor = (offset) => validateModernConfig(config(offset)).warnings; + + expect(warningsFor(undefined)).toHaveLength(0); + expect(warningsFor([0, 0, 24, 0])).toHaveLength(0); + expect(warningsFor(['8', '16px', 200, 0])).toHaveLength(0); + expect(warningsFor([null, null, 32])).toHaveLength(0); + expect(warningsFor(['', '', '', ''])).toHaveLength(0); + expect(warningsFor([Infinity, 0, '1e400', 0])).toHaveLength(2); + expect(warningsFor([0, 0, '201px', 0])[0]).toMatch( + /floatingOffset\[2\] \(bottom\) must be at most 200 pixels/ + ); + expect(warningsFor([0, 0, 0, -5])[0]).toMatch( + /floatingOffset\[3\] \(left\) must be a non-negative number/ + ); + expect(warningsFor(24)[0]).toMatch(/floatingOffset must be an array/); + expect(warningsFor([1, 2, 3, 4, 5])[0]).toMatch(/more than 4 entries/); + expect(validateModernConfig(config([0, 0, 0, -5])).valid).toBe(true); + }); + test('catches defaultState not in allowedStates', () => { const config = { panelSettings: { diff --git a/tests/unit/userInterfaceModern.floatOffset.spec.js b/tests/unit/userInterfaceModern.floatOffset.spec.js new file mode 100644 index 000000000..c7a19bcf0 --- /dev/null +++ b/tests/unit/userInterfaceModern.floatOffset.spec.js @@ -0,0 +1,52 @@ +import { test, expect, vi } from 'vitest' +vi.mock('../../src/essence/Basics/Viewer_/Viewer_', () => ({ default: {} })) + +import PanelManager_ from '../../src/essence/Basics/PanelManager_/PanelManager_.ts' + +// The float regions wrapper carries panelSettings.floatingOffset +// ([top, right, bottom, left]) as --ui-float-offset-{side}, and the CSS insets +// the wrapper by those values. + +const { _renderFloatRegions, _toFloatOffsets } = await import( + '../../src/essence/Basics/UserInterface_/UserInterfaceModern_.js' +) + +const floatPanels = () => { + PanelManager_.clear() + PanelManager_.registerPanel({ + id: 'scale', + position: 'float-bottom-left', + layoutType: 'stacked', + priority: 0, + stateConstraints: { allowedStates: ['expanded'], defaultState: 'expanded' }, + }) + return [PanelManager_.getPanelState('scale')] +} + +const offsetsOf = (regions) => + ['top', 'right', 'bottom', 'left'].map((side) => + regions[0].style.getPropertyValue(`--ui-float-offset-${side}`) + ) + +test('each configured side insets the float regions', () => { + expect(offsetsOf(_renderFloatRegions(floatPanels(), [8, 16, 32, 4]))).toEqual([ + '8px', + '16px', + '32px', + '4px', + ]) +}) + +test('without offsets the zones stay on the edges', () => { + expect(offsetsOf(_renderFloatRegions(floatPanels()))).toEqual(['', '', '', '']) + expect(offsetsOf(_renderFloatRegions(floatPanels(), [0, 0, 0, 0]))).toEqual(['', '', '', '']) +}) + +test('the configured array is read as four capped pixel counts', () => { + expect(_toFloatOffsets([0, '12', '24px', 4])).toEqual([0, 12, 24, 4]) + expect(_toFloatOffsets([null, null, 32])).toEqual([0, 0, 32, 0]) + expect(_toFloatOffsets(['', -5, 'abc', 500])).toEqual([0, 0, 0, 200]) + expect(_toFloatOffsets([1, 2, 3, 4, 5])).toEqual([1, 2, 3, 4]) + expect(_toFloatOffsets(undefined)).toEqual([0, 0, 0, 0]) + expect(_toFloatOffsets(24)).toEqual([0, 0, 0, 0]) +})