loopable / web /src /customer-grid /overlayPlacement.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
ea2c336 verified
Raw
History Blame Contribute Delete
25.5 kB
// ---------------------------------------------------------------------------
// customer-grid / overlayPlacement.ts
// Wave-9 I14 β€” the overlay positioning ARITHMETIC, extracted from
// OverlaySurface.tsx so it can be proved instead of eyeballed.
//
// Why this file exists. I14 needs a flyout that opens to the RIGHT of the views
// rail, which meant a third `Placement` and a new branch of collision maths.
// Every other overlay on this surface already lands correctly, so a regression
// here is invisible until a user finds it β€” and the failure mode is the exact
// one this codebase has already paid for: a panel that is PAINTED but sits
// outside the viewport, i.e. unreachable, with every assertion still green
// ([[ui-invisible-to-assertions]]).
//
// Pure module, zero React imports β€” the same shape mapProjection.ts/
// mapGeometry.ts take for the map's maths, and for the same reason: a gate can
// compile it with tsc and run it under node.
//
// The numbers here are byte-for-byte the ones OverlaySurface computed inline
// before the extraction; only `right-start` is new.
// ---------------------------------------------------------------------------
export type Placement = "bottom-start" | "bottom-end" | "right-start";
export interface PlacementTarget {
left: number;
top: number;
right: number;
bottom: number;
}
export interface PlacementViewport {
left: number;
top: number;
width: number;
height: number;
}
export interface PlacementResult {
left: number;
top: number;
maxWidth: number;
maxHeight: number;
/** Which way the panel actually resolved. Diagnostic only β€” nothing styles off it β€”
* but it is what lets a gate assert "it flipped BECAUSE there was no room" rather
* than merely "it ended up somewhere on screen". */
side: "below" | "above" | "right" | "left";
}
/** Keep-away from the viewport edge, and from the anchor. The originals. */
export const OVERLAY_MARGIN = 8;
export const OVERLAY_GAP = 6;
// ------------------------------------------------------- wave-9 I2: the cell hover tip
/** Glide's horizontal padding inside a text cell (8px each side at the default theme). */
export const CELL_TEXT_PAD = 16;
/** Mirrors the `max-width` on `.cg-header-tip` / `.cg-cell-tip`. */
export const TIP_MAX_WIDTH = 280;
/**
* I2 β€” what a hovered cell should reveal, or `null` when there is nothing to reveal.
*
* The rule that makes this a feature rather than noise: **only text that is actually cut off
* gets a tip.** A tooltip on every cell repeats what the user can already read, trains them to
* ignore tips, and covers the row underneath while doing it. Grid cells never wrap here
* (`allowOverlay: false`, no `allowWrapping`), so "cut off" is a pure width comparison and is
* measured with the same canvas font glide draws with, not estimated from a character count.
*/
export function cellTipText(
text: string,
textPx: number,
cellPx: number
): string | null {
if (!text) return null;
return textPx > Math.max(0, cellPx - CELL_TEXT_PAD) ? text : null;
}
/**
* Keep a floating tip inside the viewport horizontally.
*
* ⚠ The EXISTING header tip does not do this β€” it uses the column's raw x, so a description on
* a right-edge column runs off screen. The cell tip clamps; the header tip is left alone
* because changing it is not in this item and it is somebody's shipped behaviour.
*/
export function tipLeft(
x: number,
viewportWidth: number,
tipWidth: number,
margin = OVERLAY_MARGIN
): number {
const w = Math.min(tipWidth, TIP_MAX_WIDTH);
return Math.round(Math.max(margin, Math.min(x, viewportWidth - margin - w)));
}
// -------------------------------------------------------- wave-9 I3: the header "(i)"
export const INFO_MARK_SIZE = 16;
/** Gap between the (i) and the header-menu slot to its right. */
export const INFO_MARK_GAP = 4;
/** Glide's type-icon footprint before every header label. */
export const HEADER_LABEL_X = 8 + Math.ceil(18 * 1.3); // = 32
/** Clearance the label keeps from the (i). Touching is not overlapping, but it reads as one. */
export const HEADER_LABEL_GAP = 6;
/** Enough title room for at least one normal glyph plus the ellipsis in Glide's header font. */
export const HEADER_MIN_TITLE_SPACE = 30;
/**
* Space kept clear at the LEFT of the (i), including Glide's type-icon footprint and the gap
* between title and mark. Below this the mark is dropped instead of covering the title.
*/
export const INFO_MARK_MIN_LABEL =
HEADER_LABEL_X + HEADER_LABEL_GAP + HEADER_MIN_TITLE_SPACE;
/**
* I3 β€” where the description "(i)" goes: **right-aligned in the header and vertically centred
* with the field name.**
*
* ⚠ SCOUTED FIRST, and glide's own API cannot do it. `overlayIcon` is drawn by
* `drawHeaderInner` at a HARD-CODED `drawX + 9`, `(height - 18) / 2 + 6` β€” a badge pinned to
* the bottom-right corner of the TYPE icon, on the far LEFT of the header. No prop moves it.
* So the mark is drawn by the `drawHeader` callback instead (glide's supported escape hatch:
* it hands you the ctx, the rect, the menu bounds and the sprite manager, plus a
* `drawContent()` for its own rendering) and `overlayIcon` is no longer set.
*
* `menuSlotWidth` is `menuBounds.width` β€” the "Β·Β·Β·" button's reserved strip at the right end.
* The (i) sits to the LEFT of that strip **whether or not the menu is currently showing**,
* because the menu only appears on hover and a mark that jumps sideways when you approach it
* is worse than one sitting slightly inboard.
*
* Returns `null` when the column is too narrow to carry the mark without covering its own
* label β€” a mark on top of the name is not "right-aligned", it is broken.
*/
export function infoMarkRect(
header: { x: number; y: number; width: number; height: number },
menuSlotWidth: number,
size = INFO_MARK_SIZE,
gap = INFO_MARK_GAP
): { x: number; y: number; size: number } | null {
return headerMarkLayout(header, menuSlotWidth, [size], gap)?.[0] ?? null;
}
// -------------------------------------- wave-14 item 15: the GROUP BAR's label vs the freeze
/**
* THE GROUP BAR'S FONT β€” the ONE copy, read by the cell that paints it
* (`useGetCellContent.groupHeaderCell`'s `themeOverride.baseFontStyle`) AND by the canvas context
* that measures it (`CustomerGrid.measureGroupText`).
*
* ⚠ It is SEMIBOLD, and that is the whole reason this constant exists rather than two literals.
* Glide paints a cell with the MERGED theme's `baseFontFull` (`data-grid-render.cells.js:269`),
* so a group bar paints at 600 while the grid's ordinary cell measurer
* (`measureCellText`) is set to plain "13px". Measuring the fit at the lighter weight
* UNDER-truncates β€” semibold Inter is ~4-6% wider β€” and the label then overflows into exactly
* the hard mid-glyph `ctx.clip()` that `fitGroupLabel` exists to prevent. Worse as the frozen
* strip gets WIDER, which is the opposite of intuition.
*
* This is the third instance of one bug: a measurement pinned to a weight the renderer no longer
* uses (`measureHeaderText`'s 600 vs the involved header's 700 was the second). Hence one
* constant, no literals, and a gate leg asserting it is not the base cell font.
*/
export const GROUP_HEADER_FONT = "600 13px";
/**
* Glide's own cell padding β€” the group bar's text does not get the full strip.
*
* Glide insets text by `cellHorizontalPadding` (8) on the LEFT and clips at the rect's right
* edge, so the strictly-usable width is `width - 8`. Reserving 16 (both sides) is DELIBERATELY
* conservative: it makes the label a few pixels shorter than it strictly must be, which is the
* safe direction to be wrong. ⚠ Do not let this slack be the thing that hides a font-weight
* mismatch β€” see `GROUP_HEADER_FONT`.
*/
export const GROUP_LABEL_PAD = 16;
/**
* Item 15 β€” the collapsible group bar's label, fitted to the FROZEN STRIP.
*
* ⚠ Why this has to exist, because it is the non-obvious half of pinning the first column.
* Freezing does NOT make a group bar's `span` vanish: glide SPLITS it, `getSpanBounds` returning
* `[frozenRect, contentRect]`, and paints the row's CONTENTS from the frozen half only (the
* scrollable half sets `skipContents`). But `frozenRect` is exactly the frozen columns' own
* width β€” substitute `sourceIndex = 0`, `startCol = 0`, `firstNonSticky = 1` and both of its
* loops run zero times β€” and glide clips to it with `ctx.rect(…); ctx.clip()` while `fillText`
* carries no `maxWidth`. So the label is cut MID-GLYPH, with no ellipsis, at the freeze line.
* Before the pin the bar drew across the whole row and this could not happen; the pin is what
* the owner asked for (R8), so the truncation has to become honest rather than the pin reverted.
*
* The COUNT survives and the LABEL is what gives way. The count is the number the reader came
* for and cannot be reconstructed from a prefix; a name usually can. So the label ellipsises and
* `(count)` is always re-appended.
*/
export function fitGroupLabel(
marker: string,
label: string,
count: string,
space: number | null,
measure: (text: string) => number
): string {
const compose = (text: string) => `${marker} ${text} (${count})`;
const full = compose(label);
if (space == null || space <= 0) return full;
if (measure(full) <= space) return full;
// What the label may occupy = the space minus everything in the line that is NOT the label.
const room = space - measure(compose(""));
// No room for a name at all: say the whole thing and let glide clip it. A bar reading
// "β–Ό (1,247)" names no group, which is worse than one whose name is cut short.
if (room <= 0) return full;
return compose(fitHeaderTitle(label, room, measure));
}
// ------------------------------------------ wave-14 R11: the header's right-hand MARK STRIP
/** The user-created-field dot (item 1 / R11). Small on purpose: it is an affordance saying
* "this column is yours, not Odoo's", not a category badge. */
export const CUSTOM_MARK_SIZE = 6;
/** Clearance BETWEEN two marks in the strip. */
export const HEADER_MARK_GAP = 6;
/**
* R11 β€” where the header's right-hand marks go, laid out from the OUTSIDE IN.
*
* There are two of them now: the description "(i)" (wave-9 I3) and the user-created-field dot
* (wave-14 item 1, replacing the yellow header wash the owner killed). They share one strip so
* they can never be drawn on the same pixels, and so the title's truncation reserve accounts for
* however many are actually present.
*
* `sizes` is the slot widths from the right edge inwards β€” build it with `headerMarkSizes()` so
* the ORDER lives in exactly one place. Slot 0 is adjacent to the menu strip; the (i) keeps it,
* because it is the one a pointer goes looking for and a hover target that moves between columns
* is worse than a passive dot that does.
*
* **All-or-nothing**: if the innermost slot would eat the label, this returns `null` and NOTHING
* is drawn. That is what lets `headerLabelSpace` and `CustomerGrid.drawGridHeader` read the same
* answer from one function β€” a per-mark decision would let the reserve say "no truncation" while
* the drawer still painted a mark over the name, which is the exact failure the wave-9 comment
* above warns about ([[ui-invisible-to-assertions]]).
*/
export function headerMarkLayout(
header: { x: number; y: number; width: number; height: number },
menuSlotWidth: number,
sizes: readonly number[],
gap = INFO_MARK_GAP
): { x: number; y: number; size: number }[] | null {
if (sizes.length === 0) return null;
const out: { x: number; y: number; size: number }[] = [];
let right = header.x + header.width - menuSlotWidth - gap;
for (const size of sizes) {
const x = right - size;
if (x - header.x < INFO_MARK_MIN_LABEL) return null;
out.push({
x: Math.round(x),
// Vertically centred in the header band β€” the same band the label's baseline is centred
// in, which is what "centred with the field name" means on a canvas.
y: Math.round(header.y + (header.height - size) / 2),
size,
});
right = x - HEADER_MARK_GAP;
}
return out;
}
/** The strip's ORDER, in one place: (i) outermost, the user-created dot inboard of it. Both the
* truncation reserve and the drawing path read this, so they cannot disagree about how many
* marks a column carries or which is which. */
export function headerMarkSizes(hasInfoMark: boolean, isCustomField: boolean): number[] {
const out: number[] = [];
if (hasInfoMark) out.push(INFO_MARK_SIZE);
if (isCustomField) out.push(CUSTOM_MARK_SIZE);
return out;
}
// ------------------------------------------------- owner item 16: the header title vs the (i)
/**
* Glide's own header metrics, READ OFF THE LIBRARY rather than guessed, and re-stated here
* because the fit below has to agree with the renderer to the pixel.
*
* `data-grid-render.header.js:197-212` drawX = x + cellHorizontalPadding, then
* drawX += ceil(headerIconSize * 1.3) once an `icon`
* is set β€” and `useGridColumns` sets one on EVERY
* column (the field-type mark, wave-8 I20).
* `:157` menuButtonSize = 30, a CONSTANT: the "Β·Β·Β·" slot is
* the same width on a 90px column and a 600px one.
* `common/styles.js:71-74` cellHorizontalPadding 8 Β· headerIconSize 18 Β·
* headerFontStyle "600 13px".
*
* ⚠ `theme.ts` overrides none of the three metrics, so these hold. If it ever does, they move
* together or the label creeps back under the mark.
*/
export const HEADER_MENU_SLOT = 30;
/**
* Item 16 β€” how much width the header LABEL may occupy before it runs under the (i), or `null`
* when nothing is in its way.
*
* ⚠ Derived from `infoMarkRect` rather than from a second copy of the same arithmetic. The mark
* is dropped entirely below `INFO_MARK_MIN_LABEL`, so a re-derived reserve would keep shrinking
* a label that has nothing to collide with β€” and the two numbers would drift the first time
* either constant moved. ONE geometry source, queried twice.
*
* `null` means "do not truncate": either the field carries no description (no mark is drawn) or
* the column is too narrow for the mark, which `infoMarkRect` already answers by returning null.
* Glide fades the title under the hover menu on its own; that is its behaviour and not this
* item's subject.
*/
export function headerLabelSpace(
width: number,
/**
* Wave-14 R11 β€” the marks this header carries. `true`/`false` is the pre-R11 shape (the (i)
* alone) and still means exactly what it meant; a `number[]` from `headerMarkSizes()` is the
* general form, and it is what a column with BOTH an (i) and a user-created dot must pass or
* the label gets reserved room for only one of them.
*/
marks: boolean | readonly number[],
menuSlotWidth = HEADER_MENU_SLOT
): number | null {
const sizes = marks === true ? [INFO_MARK_SIZE] : marks === false ? [] : marks;
// The INNERMOST mark is the one the label must clear, and `headerMarkLayout` is all-or-nothing,
// so this is still ONE geometry source queried twice rather than two copies of the arithmetic.
const layout = headerMarkLayout({ x: 0, y: 0, width, height: 32 }, menuSlotWidth, sizes);
if (!layout) return null;
return Math.max(0, layout[layout.length - 1].x - HEADER_LABEL_X - HEADER_LABEL_GAP);
}
/** The ellipsis glyph, as ONE character β€” "..." is three and measures wider. */
export const ELLIPSIS = "…";
/**
* Item 16 β€” `title` shortened to fit `space`, with an ellipsis, or returned untouched.
*
* `measure` is injected rather than reached for: the only honest measurement is canvas
* `measureText` in glide's own header font ("600 13px"), and a pure function is the half of
* this that a node gate can hold. Measuring at the wrong WEIGHT is the quiet failure β€”
* semibold 13px is wider than regular 13px, so measuring with the cell font under-truncates
* and the label still slides under the mark.
*
* Binary search over the cut point: `measure` is a canvas call per probe, and a linear walk
* down a 40-character label is 40 of them per column per render.
*
* Never returns a bare ellipsis: if not even one character fits, the caller is better served
* by the untouched title (glide will fade it) than by a header that says nothing at all.
*/
export function fitHeaderTitle(
title: string,
space: number | null,
measure: (text: string) => number
): string {
if (space == null || !title) return title;
if (measure(title) <= space) return title;
const ell = measure(ELLIPSIS);
if (ell > space) return title; // no room to say "there is more" β€” say it all
let lo = 0; // known to fit
let hi = title.length; // known not to
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (measure(title.slice(0, mid)) + ell <= space) lo = mid;
else hi = mid - 1;
}
if (lo <= 0) return title;
// A cut that lands on a trailing space would render as "Last order …".
return title.slice(0, lo).trimEnd() + ELLIPSIS;
}
// ------------------------------------------- owner item 19: the hover-only Expand affordance
export const EXPAND_BTN_SIZE = 22;
/** Breathing room between the button and the right edge of the primary cell. */
export const EXPAND_BTN_INSET = 6;
/** A row shorter than this cannot hold the button without touching the rules above and below. */
export const EXPAND_BTN_MIN_ROW = EXPAND_BTN_SIZE + 4;
/** Minimum primary-cell label room; independent of the header's type-icon geometry. */
export const EXPAND_BTN_MIN_LABEL = 46;
/**
* Item 19 β€” where the row's Expand button sits: **at the right end of the PRIMARY cell**,
* vertically centred on the row (Airtable's placement, and the reason the primary column is
* the frozen one).
*
* `primary` is the first DATA column's rect for that row, straight from glide's
* `DataEditorRef.getBounds(0, row)` β€” VIEWPORT coordinates, because that is what glide returns
* (`data-grid.js:82-83` adds the canvas's own `getBoundingClientRect()` before returning) and
* what `.cg-header-tip` already positions against. Taking it from glide rather than computing
* it means freeze, horizontal scroll and row-height mode are all handled by the thing that owns
* them.
*
* ⚠ The button is a real `<button>` laid over the canvas, so the rect below IS the hit test β€”
* there is no second copy of this geometry to drift out of step with the drawing, which is the
* failure mode a canvas-drawn affordance has ([[ui-invisible-to-assertions]]).
*
* `null` when the row or the column is too small to carry it: better absent than painted over
* the customer's name in a 28px row.
*/
export function expandButtonRect(
primary: { x: number; y: number; width: number; height: number },
/**
* The grid's own box, in the same viewport space. ⚠ NOT optional in practice: the button is
* `position: fixed`, and glide's bounds are VIEWPORT coordinates that follow the primary cell
* WHEREVER it goes β€” including outside the grid. An unclamped fixed button then paints over
* the chrome, on a row nobody can see: painted, plausible and in the wrong place
* ([[ui-invisible-to-assertions]]).
*
* ⚠ RETARGETED wave-14 item 15 (R8). This used to justify itself with "`freezeColumns` is 0
* while a grouping is active, so the primary column scrolls away like any other" β€” TRUE then,
* FALSE now: the first column is frozen in every mode, because glide splits a group bar's
* span across the freeze boundary rather than losing it (`getSpanBounds`). The VERTICAL case
* is what keeps this parameter load-bearing β€” a row scrolled under the header or past the
* bottom still reports a rect outside the box. The horizontal legs remain as defence in depth
* (a column drag, a box narrower than the frozen strip), and they are still asserted.
*/
box?: { x: number; y: number; width: number; height: number },
size = EXPAND_BTN_SIZE,
inset = EXPAND_BTN_INSET
): { x: number; y: number; size: number } | null {
if (primary.height < EXPAND_BTN_MIN_ROW) return null;
// It must not eat the whole cell: below this the name is entirely covered by its own control.
if (primary.width < size + inset * 2 + EXPAND_BTN_MIN_LABEL) return null;
const x = Math.round(primary.x + primary.width - inset - size);
const y = Math.round(primary.y + (primary.height - size) / 2);
// Absent rather than clamped: a clamped button would sit at the grid's edge pointing at a row
// whose primary cell is somewhere else entirely, which is a worse answer than no button.
if (box) {
if (x < box.x || x + size > box.x + box.width) return null;
if (y < box.y || y + size > box.y + box.height) return null;
}
return { x, y, size };
}
/**
* Where a panel of `panel` size should sit relative to `target`, inside `viewport`.
*
* The invariant every caller depends on, and the one the gate asserts for all three
* placements: **the returned box is inside the viewport whenever the viewport can hold it
* at all.** A panel larger than the viewport is pinned to the top-left margin rather than
* centred, so its first row stays reachable β€” a menu whose head is off-screen cannot be
* used even though every item "rendered".
*/
export function computeOverlayPosition(args: {
placement: Placement;
target: PlacementTarget;
/** The panel's DESIRED size, before clamping (scrollWidth/scrollHeight at the call site). */
panel: { width: number; height: number };
viewport: PlacementViewport;
}): PlacementResult {
const { placement, target, panel, viewport } = args;
const margin = OVERLAY_MARGIN;
const gap = OVERLAY_GAP;
const minLeft = viewport.left + margin;
const maxRight = viewport.left + viewport.width - margin;
const minTop = viewport.top + margin;
const maxBottom = viewport.top + viewport.height - margin;
const maxWidth = Math.max(1, maxRight - minLeft);
const width = Math.min(panel.width, maxWidth);
const desiredHeight = panel.height;
let left: number;
let top: number;
let maxHeight: number;
let side: PlacementResult["side"];
if (placement === "right-start") {
// Beside the anchor. Prefer the right; flip left only when the right genuinely cannot
// hold the panel AND the left has more room β€” the same "pick the roomier side" rule the
// vertical branch uses, turned 90 degrees.
const roomRight = Math.max(0, maxRight - (target.right + gap));
const roomLeft = Math.max(0, target.left - gap - minLeft);
const toRight = width <= roomRight || roomRight >= roomLeft;
side = toRight ? "right" : "left";
const unclampedLeft = toRight ? target.right + gap : target.left - gap - width;
left = Math.min(Math.max(unclampedLeft, minLeft), Math.max(minLeft, maxRight - width));
// Vertically the flyout is bounded by the VIEWPORT, not by the anchor: it opens level
// with the button and slides up only as far as it must to stay on screen. (A rail
// button near the bottom of a short iframe is the case that matters.)
maxHeight = Math.max(1, maxBottom - minTop);
top = Math.min(
Math.max(target.top, minTop),
Math.max(minTop, maxBottom - Math.min(desiredHeight, maxHeight))
);
} else {
const below = Math.max(0, maxBottom - (target.bottom + gap));
const above = Math.max(0, target.top - gap - minTop);
const placeBelow = desiredHeight <= below || below >= above;
side = placeBelow ? "below" : "above";
maxHeight = Math.max(1, placeBelow ? below : above);
const unclampedLeft = placement === "bottom-end" ? target.right - width : target.left;
// The horizontal clamp needs no `Math.max(minLeft, …)` on its outer bound, unlike the
// `right-start` branch above: `width` is ALREADY clamped to `maxWidth = maxRight - minLeft`,
// so `maxRight - width >= minLeft` always holds and the min cannot undo the max. Proven the
// only way worth proving it β€” a mutation that removed the "fix" left every leg green, which
// is what a guard against an impossible state looks like ([[gate-negative-control]]).
left = Math.min(Math.max(unclampedLeft, minLeft), maxRight - width);
// ⚠ D-20 (wave 20) β€” `Math.max(minTop, …)` IS load-bearing, and it was missing.
//
// The below-branch had no lower bound, so an anchor ABOVE the viewport put the panel above
// it too β€” painted, and unreachable. Not hypothetical: it is the null-anchor case D-20 is
// about (a null anchor degrades to a ZERO rect at the DOCUMENT origin, which is above the
// viewport whenever `visualViewport` carries an offset β€” a pinch-zoomed phone), and it is
// equally a trigger that scrolls out of view with its menu still open. `verify_overlay`'s
// `below-branch-loses-its-top-clamp` control restages exactly this.
top = placeBelow
? Math.max(minTop, Math.min(target.bottom + gap, maxBottom - maxHeight))
: Math.max(minTop, target.top - gap - Math.min(desiredHeight, maxHeight));
}
return {
left: Math.round(left),
top: Math.round(top),
maxWidth: Math.floor(maxWidth),
maxHeight: Math.floor(maxHeight),
side,
};
}