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])
+})