/**
* Excel-style selection: a list of rectangular ranges plus an active cell.
* The last range is the one Shift+arrow / Shift+click / drag extends;
* Ctrl+click starts a new range. Pure helpers — no React.
*/
export interface Pos {
r: number;
c: number;
}
/** `anchor` is where the range started (and the active cell); `focus` is the moving corner. */
export interface Range {
anchor: Pos;
focus: Pos;
}
export interface Selection {
ranges: Range[];
}
export interface Bounds {
rows: number;
cols: number;
}
const clamp = (p: Pos, b: Bounds): Pos => ({
r: Math.max(0, Math.min(b.rows - 1, p.r)),
c: Math.max(0, Math.min(b.cols - 1, p.c)),
});
export const single = (p: Pos): Selection => ({ ranges: [{ anchor: p, focus: p }] });
export const activeCell = (s: Selection): Pos => s.ranges[s.ranges.length - 1].anchor;
const last = (s: Selection) => s.ranges[s.ranges.length - 1];
/** Plain arrow: collapse to a single cell next to the active one. */
export const moveActive = (s: Selection, dr: number, dc: number, b: Bounds): Selection => {
const a = activeCell(s);
return single(clamp({ r: a.r + dr, c: a.c + dc }, b));
};
/** Shift+arrow: move the focus corner of the last range. */
export const extendBy = (s: Selection, dr: number, dc: number, b: Bounds): Selection => {
const l = last(s);
return extendTo(s, { r: l.focus.r + dr, c: l.focus.c + dc }, b);
};
/** Shift+click / drag: set the focus corner of the last range. */
export const extendTo = (s: Selection, p: Pos, b: Bounds): Selection => ({
ranges: [...s.ranges.slice(0, -1), { anchor: last(s).anchor, focus: clamp(p, b) }],
});
/** Ctrl+click: add a new single-cell range. */
export const addRange = (s: Selection, p: Pos): Selection => ({
ranges: [...s.ranges, { anchor: p, focus: p }],
});
export const selectAll = (b: Bounds): Selection => ({
ranges: [{ anchor: { r: 0, c: 0 }, focus: { r: b.rows - 1, c: b.cols - 1 } }],
});
export function rect(range: Range) {
return {
r0: Math.min(range.anchor.r, range.focus.r),
r1: Math.max(range.anchor.r, range.focus.r),
c0: Math.min(range.anchor.c, range.focus.c),
c1: Math.max(range.anchor.c, range.focus.c),
};
}
export function contains(s: Selection, p: Pos): boolean {
return s.ranges.some((range) => {
const { r0, r1, c0, c1 } = rect(range);
return p.r >= r0 && p.r <= r1 && p.c >= c0 && p.c <= c1;
});
}
/** Unique selected cells (overlapping ranges count once, as in Excel's status bar). */
export function selectedCells(s: Selection): Pos[] {
const seen = new Set<string>();
const out: Pos[] = [];
for (const range of s.ranges) {
const { r0, r1, c0, c1 } = rect(range);
for (let r = r0; r <= r1; r++)
for (let c = c0; c <= c1; c++) {
const k = `${r}:${c}`;
if (!seen.has(k)) {
seen.add(k);
out.push({ r, c });
}
}
}
return out;
}
export interface Stats {
count: number;
sum: number;
average: number;
min: number;
max: number;
}
export function stats(values: number[]): Stats | null {
if (values.length === 0) return null;
const sum = values.reduce((a, b) => a + b, 0);
return { count: values.length, sum, average: sum / values.length, min: Math.min(...values), max: Math.max(...values) };
}