Skip to content
6 changes: 6 additions & 0 deletions docs/scriptappy.json
Original file line number Diff line number Diff line change
Expand Up @@ -4517,6 +4517,12 @@
"description": "left, right, top or bottom",
"optional": true,
"type": "string"
},
"overlap": {
"description": "If true, the component is anchored at the center boundary and does not consume layout space",
"optional": true,
"defaultValue": false,
"type": "boolean"
}
}
},
Expand Down
1 change: 1 addition & 0 deletions packages/picasso.js/src/core/chart/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ import componentCollectionFn from './component-collection';
* @property {number} [layout.prioOrder = 0]
* @property {string | {width: string, height: string}} [layout.minimumLayoutMode] Refer to layout sizes defined by layoutModes in `strategy`
* @property {string} [layout.dock] left, right, top or bottom
* @property {boolean} [layout.overlap = false] If true, the component is anchored at the center boundary and does not consume layout space
* @property {boolean} [show = true] If the component should be rendered
* @property {string} [scale] Named scale. Will be provided to the component if it asks for it.
* @property {string} [formatter] Named formatter. Fallback to create formatter from scale. Will be provided to the component if it asks for it.
Expand Down
2 changes: 2 additions & 0 deletions packages/picasso.js/src/core/component/component-factory.js
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ function createDockDefinition(settings, preferredSize, logger) {
def.dock = getLayoutProperty('dock');
def.prioOrder = getLayoutProperty('prioOrder');
def.minimumLayoutMode = getLayoutProperty('minimumLayoutMode');
def.overlap = getLayoutProperty('overlap');

// move layout properties to layout object
settings.layout = settings.layout || {};
Expand All @@ -174,6 +175,7 @@ function createDockDefinition(settings, preferredSize, logger) {
settings.layout.prioOrder = typeof def.prioOrder !== 'undefined' ? def.prioOrder : settings.layout.prioOrder;
settings.layout.dock = def.dock || settings.layout.dock;
settings.layout.minimumLayoutMode = def.minimumLayoutMode || settings.layout.minimumLayoutMode;
settings.layout.overlap = typeof def.overlap !== 'undefined' ? def.overlap : settings.layout.overlap;

// not directly a dock layout property
def.show = settings.show;
Expand Down
279 changes: 279 additions & 0 deletions packages/picasso.js/src/core/layout/dock/__tests__/dock-layout.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ describe('Dock Layout', () => {
minimumLayoutMode,
size = 0,
key,
overlap = false,
} = {}) {
const dummy = {};
dummy.key = key;
Expand All @@ -19,6 +20,7 @@ describe('Dock Layout', () => {
displayOrder,
prioOrder,
minimumLayoutMode,
overlap,
});

dummy.preferredSize = () => ({ width: size, height: size, edgeBleed });
Expand Down Expand Up @@ -398,6 +400,283 @@ describe('Dock Layout', () => {
});
});

describe('Overlap', () => {
let rect;
let dl;

beforeEach(() => {
rect = createRect(0, 0, 1000, 1000);
dl = dockLayout();
});

it('should position two overlap top components at the same anchor and reduce center by max height', () => {
// compA: normal top, 30px — reduces center to y=30
// compB: overlap top, 20px — max(30, 20)=30, center stays at y=30
// compC: overlap top, 15px — max stays 20, center stays at y=30
// all overlap bottoms align with compA's bottom (y=30)
const compA = componentMock({ dock: 'top', size: 30 });
const compB = componentMock({ dock: 'top', size: 20, overlap: true });
const compC = componentMock({ dock: 'top', size: 15, overlap: true });
const center = componentMock();

dl.layout(rect, [compA, compB, compC, center]);

// center: max(30, 20) = 30
expect(center.rect, 'center rect incorrect').to.deep.include({ x: 0, y: 30, width: 1000, height: 970 });

// compA: normal top, y=0, h=30, bottom=30
expect(compA.rect, 'compA rect incorrect').to.deep.include({ x: 0, y: 0, width: 1000, height: 30 });

// compB: overlap top, anchor=30, y=30-20=10, bottom=30 (same as compA)
expect(compB.rect, 'compB rect incorrect').to.deep.include({ x: 0, y: 10, width: 1000, height: 20 });

// compC: overlap top, anchor=30, y=30-15=15, bottom=30 (same as compA)
expect(compC.rect, 'compC rect incorrect').to.deep.include({ x: 0, y: 15, width: 1000, height: 15 });
});

it('should position overlap bottom components at the same anchor and reduce center by max height', () => {
const compA = componentMock({ dock: 'bottom', size: 40 });
const compB = componentMock({ dock: 'bottom', size: 25, overlap: true });
const compC = componentMock({ dock: 'bottom', size: 10, overlap: true });
const center = componentMock();

dl.layout(rect, [compA, compB, compC, center]);

// center: max(1000-40=960, 1000-25=975) → bottom stays at 960, height=960
expect(center.rect, 'center rect incorrect').to.deep.include({ x: 0, y: 0, width: 1000, height: 960 });

// compA: normal bottom, y=960, h=40 (inner edge at y=960)
expect(compA.rect, 'compA rect incorrect').to.deep.include({ x: 0, y: 960, width: 1000, height: 40 });

// compB: overlap bottom, anchor=960, y=960, h=25 — inner edge aligns with compA
expect(compB.rect, 'compB rect incorrect').to.deep.include({ x: 0, y: 960, width: 1000, height: 25 });

// compC: overlap bottom, anchor=960, y=960, h=10 — overlaps compB
expect(compC.rect, 'compC rect incorrect').to.deep.include({ x: 0, y: 960, width: 1000, height: 10 });
});

Comment thread
quanho marked this conversation as resolved.
it('should position overlap left components at the same anchor and reduce center by max width', () => {
const compA = componentMock({ dock: 'left', size: 40 });
const compB = componentMock({ dock: 'left', size: 25, overlap: true });
const compC = componentMock({ dock: 'left', size: 10, overlap: true });
const center = componentMock();

dl.layout(rect, [compA, compB, compC, center]);

// center: max(40, 25) → left boundary stays at x=40, width=960
expect(center.rect, 'center rect incorrect').to.deep.include({ x: 40, y: 0, width: 960, height: 1000 });

// compA: normal left, x=0, w=40 (inner edge at x=40)
expect(compA.rect, 'compA rect incorrect').to.deep.include({ x: 0, y: 0, width: 40, height: 1000 });

// compB: overlap left, anchor=40, x=15, w=25 — inner edge aligns with compA
expect(compB.rect, 'compB rect incorrect').to.deep.include({ x: 15, y: 0, width: 25, height: 1000 });

// compC: overlap left, anchor=40, x=30, w=10 — overlaps compB
expect(compC.rect, 'compC rect incorrect').to.deep.include({ x: 30, y: 0, width: 10, height: 1000 });
});

it('should position overlap right components at the same anchor and reduce center by max width', () => {
const compA = componentMock({ dock: 'right', size: 40 });
const compB = componentMock({ dock: 'right', size: 25, overlap: true });
const compC = componentMock({ dock: 'right', size: 10, overlap: true });
const center = componentMock();

dl.layout(rect, [compA, compB, compC, center]);

// center: min(1000-40=960, 1000-25=975) → right boundary stays at x=960, width=960
expect(center.rect, 'center rect incorrect').to.deep.include({ x: 0, y: 0, width: 960, height: 1000 });

// compA: normal right, x=960, w=40 (inner edge at x=960)
expect(compA.rect, 'compA rect incorrect').to.deep.include({ x: 960, y: 0, width: 40, height: 1000 });

// compB: overlap right, anchor=960, x=960, w=25 — inner edge aligns with compA
expect(compB.rect, 'compB rect incorrect').to.deep.include({ x: 960, y: 0, width: 25, height: 1000 });

// compC: overlap right, anchor=960, x=960, w=10 — overlaps compB
expect(compC.rect, 'compC rect incorrect').to.deep.include({ x: 960, y: 0, width: 10, height: 1000 });
});
it('should expand center when overlap exceeds sum of normal components', () => {
// A=30 normal, B=20 normal, C=70 overlap → center = max(50, 70) = 70
// vRect starts at reducedRect.y=70: A (innermost) placed at y=40, B at y=20
// A's inner edge (bottom=70) aligns with center boundary and C's inner edge
const compA = componentMock({ dock: 'top', size: 30 });
const compB = componentMock({ dock: 'top', size: 20 });
const compC = componentMock({ dock: 'top', size: 70, overlap: true });
const center = componentMock();

dl.layout(rect, [compA, compB, compC, center]);

expect(center.rect, 'center rect incorrect').to.deep.include({ x: 0, y: 70, width: 1000, height: 930 });

// compA: innermost normal, bottom=70 (aligns with center and C's inner edge)
expect(compA.rect, 'compA rect incorrect').to.deep.include({ x: 0, y: 40, width: 1000, height: 30 });
// compB: outermost normal
expect(compB.rect, 'compB rect incorrect').to.deep.include({ x: 0, y: 20, width: 1000, height: 20 });
// compC: overlap, anchor=70, bottom=70 (same as compA's bottom)
expect(compC.rect, 'compC rect incorrect').to.deep.include({ x: 0, y: 0, width: 1000, height: 70 });
});

it('should not affect center when there are no non-overlap components on the same edge', () => {
// Only overlap top components — center should still be reduced by max overlap size
const compA = componentMock({ dock: 'top', size: 30, overlap: true });
const compB = componentMock({ dock: 'top', size: 20, overlap: true });
const center = componentMock();

dl.layout(rect, [compA, compB, center]);

// No normal top: only overlap max=30 reduces center
expect(center.rect, 'center rect incorrect').to.deep.include({ x: 0, y: 30, width: 1000, height: 970 });

// Both anchored at y=30
expect(compA.rect, 'compA rect incorrect').to.deep.include({ x: 0, y: 0, width: 1000, height: 30 });
expect(compB.rect, 'compB rect incorrect').to.deep.include({ x: 0, y: 10, width: 1000, height: 20 });
});

it('should correctly position components docked at overlap components via @ syntax', () => {
// A: normal top 30px → center at y=30
// B: overlap top 20px → max(30,20)=30, center stays at y=30
// C: overlap top 15px → max stays 20, center stays at y=30
// D: @A, E: @B, F: @C — inherit refs' rects
const compA = componentMock({ dock: 'top', size: 30, key: 'A' });
const compB = componentMock({ dock: 'top', size: 20, overlap: true, key: 'B' });
const compC = componentMock({ dock: 'top', size: 15, overlap: true, key: 'C' });
const compD = componentMock({ dock: '@A' });
const compE = componentMock({ dock: '@B' });
const compF = componentMock({ dock: '@C' });
const center = componentMock();

dl.layout(rect, [compA, compB, compC, compD, compE, compF, center]);

expect(center.rect, 'center rect incorrect').to.deep.include({ x: 0, y: 30, width: 1000, height: 970 });

expect(compA.rect, 'compA rect incorrect').to.deep.include({ x: 0, y: 0, width: 1000, height: 30 });
expect(compB.rect, 'compB rect incorrect').to.deep.include({ x: 0, y: 10, width: 1000, height: 20 });
expect(compC.rect, 'compC rect incorrect').to.deep.include({ x: 0, y: 15, width: 1000, height: 15 });

// @-docked components inherit the rect of their referenced component
expect(compD.rect, 'compD (@A) rect incorrect').to.deep.include({ x: 0, y: 0, width: 1000, height: 30 });
expect(compE.rect, 'compE (@B) rect incorrect').to.deep.include({ x: 0, y: 10, width: 1000, height: 20 });
expect(compF.rect, 'compF (@C) rect incorrect').to.deep.include({ x: 0, y: 15, width: 1000, height: 15 });
});

it('should accumulate edgeBleed from overlap components', () => {
// overlap top component has edgeBleed.left=50, which should reduce the center x
const compA = componentMock({ dock: 'top', size: 30, overlap: true, edgeBleed: { left: 50 } });
const center = componentMock();

dl.layout(rect, [compA, center]);

// center.y pushed to 30 by overlap max-size, center.x pushed to 50 by edgeBleed
expect(center.rect, 'center rect should account for overlap edgeBleed').to.deep.include({
x: 50,
y: 30,
width: 950,
height: 970,
});
});

it('should hide lowest-priority overlap component when its size would violate center minHeightRatio', () => {
// layout: 1000x1000, minHeightRatio=0.6 → minCenterHeight=600
// overlap top A (prioOrder=0, higher prio) size=500 → center height=500 < 600 → invalid
// overlap top B (prioOrder=1, lower prio) size=500 → shed first
// After shedding B: only A remains, center height=500 < 600 → invalid → shed A too
// After shedding both: center height=1000 ≥ 600 → valid
const settings = { center: { minHeightRatio: 0.6 } };
dl.settings(settings);

const compA = componentMock({ dock: 'top', size: 500, overlap: true, prioOrder: 0 });
const compB = componentMock({ dock: 'top', size: 500, overlap: true, prioOrder: 1 });
const center = componentMock();

const { visible, hidden } = dl.layout(rect, [compA, compB, center]);

expect(hidden).to.include(compB);
expect(hidden).to.include(compA);
expect(visible).to.include(center);
expect(center.rect, 'center rect should be full height').to.deep.include({ y: 0, height: 1000 });
});

it('should keep higher-priority overlap component when shedding lower-priority one is sufficient', () => {
// layout: 1000x1000, minHeightRatio=0.6 → minCenterHeight=600
// compA (prioOrder=0, higher prio) size=300 → alone: center height=700 ≥ 600 → valid
// compB (prioOrder=1, lower prio) size=500 → together: center height=500 < 600 → shed B first
// After shedding B: only A, center height=700 ≥ 600 → valid
const settings = { center: { minHeightRatio: 0.6 } };
dl.settings(settings);

const compA = componentMock({ dock: 'top', size: 300, overlap: true, prioOrder: 0 });
const compB = componentMock({ dock: 'top', size: 500, overlap: true, prioOrder: 1 });
const center = componentMock();

const { visible, hidden } = dl.layout(rect, [compA, compB, center]);

expect(hidden).to.include(compB);
expect(visible).to.include(compA);
expect(visible).to.include(center);
expect(center.rect, 'center rect should be reduced by compA only').to.deep.include({ y: 300, height: 700 });
});

it('should clamp center rect dimensions to non-negative when overlap exceeds layout size', () => {
// overlap top size=1200 on a 1000px tall layout — would yield negative height without guard
const compA = componentMock({ dock: 'top', size: 1200, overlap: true, prioOrder: 0 });
const center = componentMock();

const { hidden } = dl.layout(rect, [compA, center]);

// compA is hidden because its constraint is too large to satisfy minHeightRatio
expect(hidden).to.include(compA);
// center dimensions must never be negative
expect(center.rect.width, 'center width must be non-negative').to.be.at.least(0);
expect(center.rect.height, 'center height must be non-negative').to.be.at.least(0);
});

it('should support chained @ references through overlap components', () => {
// A: normal top 30px → placed at y=20 (inner), center top at y=50
// B: normal top 20px → stacks above A, placed at y=0
// C: @A → same rect as A (overlaps A)
// D: @B → same rect as B (overlaps B)
// E: @A → same rect as A
// F: @B → same rect as B
// G: @C → same rect as C (= A)
// H: @D → same rect as D (= B)
const compA = componentMock({ dock: 'top', size: 30, key: 'A' });
const compB = componentMock({ dock: 'top', size: 20, key: 'B' });
const compC = componentMock({ dock: '@A', key: 'C' });
const compD = componentMock({ dock: '@B', key: 'D' });
const compE = componentMock({ dock: '@A' });
const compF = componentMock({ dock: '@B' });
const compG = componentMock({ dock: '@C' });
const compH = componentMock({ dock: '@D' });
const center = componentMock();

dl.layout(rect, [compA, compB, compC, compD, compE, compF, compG, compH, center]);

// center reduced by 30+20=50
expect(center.rect, 'center rect incorrect').to.deep.include({ x: 0, y: 50, width: 1000, height: 950 });

// A: innermost top, y = 50-30 = 20
expect(compA.rect, 'compA rect incorrect').to.deep.include({ x: 0, y: 20, width: 1000, height: 30 });
// B: stacks above A, y = 20-20 = 0
expect(compB.rect, 'compB rect incorrect').to.deep.include({ x: 0, y: 0, width: 1000, height: 20 });

// C overlaps A (same rect)
expect(compC.rect, 'compC (@A) rect incorrect').to.deep.include({ x: 0, y: 20, width: 1000, height: 30 });
// D overlaps B (same rect)
expect(compD.rect, 'compD (@B) rect incorrect').to.deep.include({ x: 0, y: 0, width: 1000, height: 20 });

// E also overlaps A
expect(compE.rect, 'compE (@A) rect incorrect').to.deep.include({ x: 0, y: 20, width: 1000, height: 30 });
// F also overlaps B
expect(compF.rect, 'compF (@B) rect incorrect').to.deep.include({ x: 0, y: 0, width: 1000, height: 20 });

// G chains through C → same rect as A
expect(compG.rect, 'compG (@C) rect incorrect').to.deep.include({ x: 0, y: 20, width: 1000, height: 30 });
// H chains through D → same rect as B
expect(compH.rect, 'compH (@D) rect incorrect').to.deep.include({ x: 0, y: 0, width: 1000, height: 20 });
});
});

describe('Settings', () => {
let settings;
let container;
Expand Down
16 changes: 16 additions & 0 deletions packages/picasso.js/src/core/layout/dock/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ function create(settings = {}, callbackContext = {}) {
preferredSize = 0,
minimumLayoutMode,
show = true,
overlap = false,
} = settings;

// avoid empty string dock
Expand Down Expand Up @@ -142,6 +143,21 @@ function create(settings = {}, callbackContext = {}) {
}
return typeof show === 'function' ? show(callbackContext) : show;
},

/**
* Set whether this component overlaps other components at the same dock position.
* When true, the component is positioned at the same edge as non-overlapping components
* at that dock position and does not affect the layout space of other components.
* @param {boolean} [val=false] - Toggle overlap
* @returns {this|boolean} The current context or overlap
*/
overlap(val) {
if (typeof val !== 'undefined') {
overlap = val;
return this;
}
return typeof overlap === 'function' ? overlap(callbackContext) : overlap;
},
};

return dockConfig;
Expand Down
Loading
Loading