src/lib/grid/selection.ts

110 lines
/**
 * 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) };
}