Co-Study4Grid / frontend /src /utils /popoverPlacement.ts
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
4.27 kB
// Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
// you can obtain one at http://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
// This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study.
import type { CSSProperties } from 'react';
/**
* Pin-anchored popover placement helpers used by
* `ActionOverviewDiagram`.
*
* Extracted into its own module so the component file only
* exports React components — `react-refresh/only-export-components`
* would otherwise refuse to fast-refresh the diagram when these
* pure helpers were colocated with the JSX.
*/
/** Approximate popover dimensions used by the placement heuristic. */
export const POPOVER_WIDTH = 340;
export const POPOVER_MAX_HEIGHT = 480;
/** Pixel offset between the pin and the popover so the pin tip stays visible. */
export const POPOVER_PIN_OFFSET = 28;
/** Safety margin from the viewport edge. */
export const POPOVER_VIEWPORT_MARGIN = 8;
export interface PopoverPlacement {
placeAbove: boolean;
horizontalAlign: 'start' | 'center' | 'end';
}
const defaultViewport = (): { width: number; height: number } => ({
width: typeof window !== 'undefined' ? window.innerWidth : 1024,
height: typeof window !== 'undefined' ? window.innerHeight : 768,
});
/**
* Decide where to anchor the popover relative to the pin so it
* stays inside the viewport.
*
* Rules:
* - Vertical: place ABOVE if there's not enough room below for
* the full popover; otherwise BELOW. Falls back to whichever
* side has more room when neither side fits the full max
* height.
* - Horizontal: align the popover's LEFT edge to the pin if the
* pin is in the left third of the viewport, RIGHT edge if the
* pin is in the right third, otherwise CENTRE the popover on
* the pin.
*/
export const decidePopoverPlacement = (
pinScreenX: number,
pinScreenY: number,
viewport: { width: number; height: number } = defaultViewport(),
): PopoverPlacement => {
const spaceBelow = viewport.height - pinScreenY - POPOVER_PIN_OFFSET - POPOVER_VIEWPORT_MARGIN;
const spaceAbove = pinScreenY - POPOVER_PIN_OFFSET - POPOVER_VIEWPORT_MARGIN;
let placeAbove: boolean;
if (spaceBelow >= POPOVER_MAX_HEIGHT) {
placeAbove = false;
} else if (spaceAbove >= POPOVER_MAX_HEIGHT) {
placeAbove = true;
} else {
placeAbove = spaceAbove > spaceBelow;
}
let horizontalAlign: 'start' | 'center' | 'end';
if (pinScreenX < viewport.width / 3) {
horizontalAlign = 'start';
} else if (pinScreenX > (viewport.width * 2) / 3) {
horizontalAlign = 'end';
} else {
horizontalAlign = 'center';
}
return { placeAbove, horizontalAlign };
};
/**
* Translate a placement decision into concrete CSS positioning.
* Uses `bottom` rather than `top` for above-placement so the
* popover anchors its bottom edge at the pin regardless of its
* actual rendered height (avoids the white-gap when the popover
* is shorter than POPOVER_MAX_HEIGHT).
*/
export const computePopoverStyle = (
pin: { screenX: number; screenY: number } & PopoverPlacement,
viewport: { width: number; height: number } = defaultViewport(),
): CSSProperties => {
let left: number;
if (pin.horizontalAlign === 'start') {
left = pin.screenX - POPOVER_PIN_OFFSET;
} else if (pin.horizontalAlign === 'end') {
left = pin.screenX - POPOVER_WIDTH + POPOVER_PIN_OFFSET;
} else {
left = pin.screenX - POPOVER_WIDTH / 2;
}
// Clamp to viewport with a small margin.
left = Math.max(
POPOVER_VIEWPORT_MARGIN,
Math.min(left, viewport.width - POPOVER_WIDTH - POPOVER_VIEWPORT_MARGIN),
);
if (pin.placeAbove) {
return {
position: 'fixed',
left,
bottom: viewport.height - pin.screenY + POPOVER_PIN_OFFSET,
};
}
return {
position: 'fixed',
left,
top: pin.screenY + POPOVER_PIN_OFFSET,
};
};