Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/// <reference types="cypress" />

import { getProfile } from '../datasets/profile';
import {
expandAccordionTabByHeader,
launchProfileToTwoD,
openTwoDSettingsDialog,
} from '../../../support/journey-helpers';

function getOverlapArea(first: DOMRect, second: DOMRect): number {
const overlapWidth = Math.max(0, Math.min(first.right, second.right) - Math.max(first.left, second.left));
const overlapHeight = Math.max(0, Math.min(first.bottom, second.bottom) - Math.max(first.top, second.top));

return overlapWidth * overlapHeight;
}

function getEffectiveZIndex(element: HTMLElement): number {
let current: HTMLElement | null = element;

while (current) {
const zIndex = current.ownerDocument.defaultView?.getComputedStyle(current).zIndex;
const numericZIndex = zIndex && zIndex !== 'auto' ? Number(zIndex) : NaN;

if (Number.isFinite(numericZIndex)) {
return numericZIndex;
}

current = current.parentElement;
}

return 0;
}

function expectRectInsideViewport(rect: DOMRect, win: Window, label: string): void {
expect(rect.left, `${label} left`).to.be.at.least(0);
expect(rect.top, `${label} top`).to.be.at.least(0);
expect(rect.right, `${label} right`).to.be.at.most(win.innerWidth);
expect(rect.bottom, `${label} bottom`).to.be.at.most(win.innerHeight);
}

describe('Journey Flow - Global Settings dialog positioning', () => {
const profile = getProfile('color-by-uploaded-categorical');

it('opens linked Global Settings above and beside the 2D Network Settings dialog', () => {
cy.viewport(1800, 900);

launchProfileToTwoD(profile);
openTwoDSettingsDialog();

cy.get('@twoDSettings').contains('.nav-link', 'Nodes').click({ force: true });
cy.get('@twoDSettings')
.find('.tab-pane:visible', { timeout: 15000 })
.should('exist')
.as('nodesTab');

expandAccordionTabByHeader('@nodesTab', 'Colors');
cy.get('@nodesTab').contains('button', 'Show Colors').scrollIntoView().click({ force: true });

cy.contains('.p-dialog-title', 'Global Settings', { timeout: 15000 })
.should('be.visible')
.parents('.p-dialog')
.should('be.visible')
.as('globalSettings');

cy.get('@twoDSettings').should('be.visible');
cy.get('@globalSettings').should('be.visible');

cy.get('@twoDSettings').then(($sourceDialog) => {
cy.get('@globalSettings').then(($globalSettingsDialog) => {
const sourceDialog = $sourceDialog[0] as HTMLElement;
const globalSettingsDialog = $globalSettingsDialog[0] as HTMLElement;
const win = globalSettingsDialog.ownerDocument.defaultView;

expect(win, 'dialog window').to.exist;

const sourceRect = sourceDialog.getBoundingClientRect();
const globalRect = globalSettingsDialog.getBoundingClientRect();

expectRectInsideViewport(sourceRect, win!, '2D Network Settings');
expectRectInsideViewport(globalRect, win!, 'Global Settings');

expect(getEffectiveZIndex(globalSettingsDialog), 'Global Settings z-index')
.to.be.greaterThan(getEffectiveZIndex(sourceDialog));

const centerX = Math.min(Math.max(globalRect.left + globalRect.width / 2, 1), win!.innerWidth - 1);
const centerY = Math.min(Math.max(globalRect.top + globalRect.height / 2, 1), win!.innerHeight - 1);
const topmostElement = globalSettingsDialog.ownerDocument.elementFromPoint(centerX, centerY);

expect(
topmostElement === globalSettingsDialog || globalSettingsDialog.contains(topmostElement),
'Global Settings is topmost at its center point',
).to.equal(true);

expect(getOverlapArea(sourceRect, globalRect), 'dialog overlap area on a wide viewport')
.to.be.lessThan(1);
});
});
});
});
51 changes: 51 additions & 0 deletions src/app/helperClasses/globalSettingsDialogRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
export type DialogRectSnapshot = {
top: number;
left: number;
right: number;
bottom: number;
width: number;
height: number;
};

export type GlobalSettingsDialogRequest = string | {
activeTab?: string;
sourceDialogRect?: DialogRectSnapshot;
};

export type NormalizedGlobalSettingsDialogRequest = {
activeTab: string;
sourceDialogRect?: DialogRectSnapshot;
};

export function createGlobalSettingsDialogRequest(
activeTab: string = 'Styling',
event?: MouseEvent
): Exclude<GlobalSettingsDialogRequest, string> {
return {
activeTab,
sourceDialogRect: getSourceDialogRect(event)
};
}

function getSourceDialogRect(event?: MouseEvent): DialogRectSnapshot | undefined {
const eventTarget = event?.currentTarget instanceof HTMLElement
? event.currentTarget
: event?.target instanceof HTMLElement
? event.target
: undefined;

const sourceDialog = eventTarget?.closest('.p-dialog');
if (!sourceDialog) {
return undefined;
}

const rect = sourceDialog.getBoundingClientRect();
return {
top: rect.top,
left: rect.left,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height
};
}
3 changes: 3 additions & 0 deletions src/app/microbe-trace-next-plugin.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,9 @@

<p-dialog id="global-settings-modal" data-testid="app-global-settings-dialog"
[(visible)]="GlobalSettingsDialogSettings.isVisible"
[baseZIndex]="GlobalSettingsDialogBaseZIndex"
[style]="GlobalSettingsDialogStyle"
[keepInViewport]="true"
header="Global Settings" >
<div class="modal-dialog" role="document">
<div class="modal-content">
Expand Down
166 changes: 164 additions & 2 deletions src/app/microbe-trace-next-plugin.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ import {
NodeShapeOption,
resolveNodeShapeKey
} from '@app/contactTraceCommonServices/node-shapes';
import {
DialogRectSnapshot,
GlobalSettingsDialogRequest,
NormalizedGlobalSettingsDialogRequest
} from './helperClasses/globalSettingsDialogRequest';

type ThresholdSweepSnapshot = {
threshold: number;
Expand Down Expand Up @@ -62,6 +67,12 @@ type DashboardOpenEntry = {

type KeyTableDisplayMode = 'Show' | 'Dock' | 'Hide';

type DialogPlacementCandidate = {
top: number;
left: number;
overlapArea: number;
};

interface NodeShapeOptionGroup {
key: NodeShapeGroupKey;
label: string;
Expand Down Expand Up @@ -153,7 +164,11 @@ export class MicrobeTraceNextHomeComponent extends AppComponentBase implements A
ExportDashboardScale: number = 1;
ExportDashboardResolution: { width: number, height:number, summary:string} = {width: 0, height: 0, summary: ''};
public readonly keyTablesController = new KeyTablesController();
public readonly GlobalSettingsDialogBaseZIndex = 5000;
public GlobalSettingsDialogStyle: Record<string, string> = { 'z-index': String(this.GlobalSettingsDialogBaseZIndex) };
private preserveNextKeyTablesRemoval: boolean = false;
private readonly linkedSettingsDialogGap = 16;
private readonly linkedSettingsDialogViewportMargin = 8;

private thresholdDebouncer: Subject<number> = new Subject<number>();

Expand Down Expand Up @@ -4605,7 +4620,9 @@ export class MicrobeTraceNextHomeComponent extends AppComponentBase implements A

}

DisplayGlobalSettingsDialog(activeTab = "Styling") {
DisplayGlobalSettingsDialog(request: GlobalSettingsDialogRequest = "Styling") {
const dialogRequest = this.normalizeGlobalSettingsDialogRequest(request);
this.GlobalSettingsDialogStyle = this.getGlobalSettingsDialogBaseStyle();

this.getGlobalSettingsData();
// TODO: May need to refacor this
Expand All @@ -4621,7 +4638,152 @@ export class MicrobeTraceNextHomeComponent extends AppComponentBase implements A
this.commonService.updateThresholdHistogram(this.linkThresholdSparkline.nativeElement);
this.refreshThresholdStabilityPanel(false);

this.globalSettingsTab.tabs[activeTab === "Styling" ? 1 : 0].active = true;
this.globalSettingsTab.tabs[dialogRequest.activeTab === "Styling" ? 1 : 0].active = true;

if (dialogRequest.sourceDialogRect) {
this.scheduleGlobalSettingsDialogPlacement(dialogRequest.sourceDialogRect);
}
}

private normalizeGlobalSettingsDialogRequest(request: GlobalSettingsDialogRequest): NormalizedGlobalSettingsDialogRequest {
if (typeof request === 'string') {
return { activeTab: request };
}

return {
activeTab: request?.activeTab ?? 'Styling',
sourceDialogRect: request?.sourceDialogRect
};
}

private getGlobalSettingsDialogBaseStyle(): Record<string, string> {
return { 'z-index': String(this.GlobalSettingsDialogBaseZIndex) };
}

private getGlobalSettingsDialogPlacementStyle(top: number, left: number): Record<string, string> {
const margin = this.linkedSettingsDialogViewportMargin;
return {
...this.getGlobalSettingsDialogBaseStyle(),
position: 'fixed',
top: `${Math.round(top)}px`,
left: `${Math.round(left)}px`,
margin: '0',
transform: 'none',
overflow: 'auto',
'max-height': `calc(100vh - ${margin * 2}px)`,
'max-width': `calc(100vw - ${margin * 2}px)`
};
}

private scheduleGlobalSettingsDialogPlacement(sourceDialogRect: DialogRectSnapshot): void {
const runNextFrame = window.requestAnimationFrame?.bind(window) ?? ((callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 0));

setTimeout(() => {
runNextFrame(() => {
runNextFrame(() => this.positionGlobalSettingsDialogNearSource(sourceDialogRect));
});
}, 0);
}

private positionGlobalSettingsDialogNearSource(sourceDialogRect: DialogRectSnapshot): void {
const dialog = this.getVisibleDialogByTitle('Global Settings');
if (!dialog) {
return;
}

const dialogRect = dialog.getBoundingClientRect();
const dialogWidth = dialog.offsetWidth || dialogRect.width;
const dialogHeight = dialog.offsetHeight || dialogRect.height;
const placement = this.findBestGlobalSettingsDialogPlacement(sourceDialogRect, dialogWidth, dialogHeight);
const style = this.getGlobalSettingsDialogPlacementStyle(placement.top, placement.left);

this.GlobalSettingsDialogStyle = style;
this.applyDialogStyle(dialog, style);
this.cdref.detectChanges();
}

private getVisibleDialogByTitle(title: string): HTMLElement | undefined {
return Array
.from(document.querySelectorAll<HTMLElement>('.p-dialog'))
.find(dialog => {
const titleElement = dialog.querySelector<HTMLElement>('.p-dialog-title');
const computedStyle = window.getComputedStyle(dialog);
return titleElement?.textContent?.trim() === title
&& computedStyle.display !== 'none'
&& computedStyle.visibility !== 'hidden';
});
}

private findBestGlobalSettingsDialogPlacement(
sourceRect: DialogRectSnapshot,
dialogWidth: number,
dialogHeight: number
): DialogPlacementCandidate {
const gap = this.linkedSettingsDialogGap;
const margin = this.linkedSettingsDialogViewportMargin;
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const placementWidth = Math.min(dialogWidth, Math.max(0, viewportWidth - margin * 2));
const placementHeight = Math.min(dialogHeight, Math.max(0, viewportHeight - margin * 2));
const rawCandidates = [
{ left: sourceRect.right + gap, top: sourceRect.top },
{ left: sourceRect.left - placementWidth - gap, top: sourceRect.top },
{ left: sourceRect.left, top: sourceRect.bottom + gap },
{ left: sourceRect.left, top: sourceRect.top - placementHeight - gap }
];

const candidates = rawCandidates.map(candidate => {
const placement = this.clampDialogPlacement(candidate.left, candidate.top, placementWidth, placementHeight);
return {
...placement,
overlapArea: this.getDialogOverlapArea(
{
...placement,
right: placement.left + placementWidth,
bottom: placement.top + placementHeight,
width: placementWidth,
height: placementHeight
},
sourceRect
)
};
});

return candidates.find(candidate => candidate.overlapArea === 0)
?? candidates.reduce((best, candidate) => candidate.overlapArea < best.overlapArea ? candidate : best);
}

private clampDialogPlacement(
left: number,
top: number,
dialogWidth: number,
dialogHeight: number
): { top: number; left: number } {
const margin = this.linkedSettingsDialogViewportMargin;
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const maxLeft = Math.max(margin, viewportWidth - dialogWidth - margin);
const maxTop = Math.max(margin, viewportHeight - dialogHeight - margin);

return {
left: this.clamp(left, margin, maxLeft),
top: this.clamp(top, margin, maxTop)
};
}

private getDialogOverlapArea(first: DialogRectSnapshot, second: DialogRectSnapshot): number {
const width = Math.max(0, Math.min(first.right, second.right) - Math.max(first.left, second.left));
const height = Math.max(0, Math.min(first.bottom, second.bottom) - Math.max(first.top, second.top));

return width * height;
}

private clamp(value: number, minimum: number, maximum: number): number {
return Math.min(Math.max(value, minimum), maximum);
}

private applyDialogStyle(dialog: HTMLElement, style: Record<string, string>): void {
Object.entries(style).forEach(([property, value]) => dialog.style.setProperty(property, value));
}

toggleThresholdStabilityPanel(): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
<div class="form-group row" title="">
<div class="col-3" style="padding-right: 0px;"><label>Node Color</label></div>
<div class="col-7">
<button id="bubble-open-global-styling" pButton type="button" label="Show Colors" class="ui-button-raised width-percent-100" (click)="showGlobalSettings()" [style]="{'background-color': '#2196f3', 'border': '1px solid #2196f3'}"></button>
<button id="bubble-open-global-styling" pButton type="button" label="Show Colors" class="ui-button-raised width-percent-100" (click)="showGlobalSettings($event)" [style]="{'background-color': '#2196f3', 'border': '1px solid #2196f3'}"></button>
</div>
</div>
</p-dialog>
Expand Down
Loading
Loading