/* * Copyright 2016 Palantir Technologies, Inc. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { type CellCoordinates, type FocusedCellCoordinates, type FocusedRegion, FocusMode } from "./common/cellTypes"; import * as Classes from "./common/classes"; import { Utils } from "./common/utils"; /** * `Region`s contain sets of cells. Additionally, a distinction is drawn, for * example, between all cells within a column and the whole column itself. * The `RegionCardinality` enum represents these distinct types of `Region`s. */ export enum RegionCardinality { /** * A region that contains a finite rectangular group of table cells */ CELLS = "cells", /** * A region that represents all cells within 1 or more rows. */ FULL_ROWS = "full-rows", /** * A region that represents all cells within 1 or more columns. */ FULL_COLUMNS = "full-columns", /** * A region that represents all cells in the table. */ FULL_TABLE = "full-table", } /** * A convenience object for subsets of `RegionCardinality` that are commonly * used as the `selectionMode` prop of the ``. */ export const SelectionModes = { ALL: [ RegionCardinality.FULL_TABLE, RegionCardinality.FULL_COLUMNS, RegionCardinality.FULL_ROWS, RegionCardinality.CELLS, ], COLUMNS_AND_CELLS: [RegionCardinality.FULL_COLUMNS, RegionCardinality.CELLS], COLUMNS_ONLY: [RegionCardinality.FULL_COLUMNS], NONE: [] as RegionCardinality[], ROWS_AND_CELLS: [RegionCardinality.FULL_ROWS, RegionCardinality.CELLS], ROWS_ONLY: [RegionCardinality.FULL_ROWS], }; export enum ColumnLoadingOption { CELLS = "cells", HEADER = "column-header", } export enum RowLoadingOption { CELLS = "cells", HEADER = "row-header", } export enum TableLoadingOption { CELLS = "cells", COLUMN_HEADERS = "column-header", ROW_HEADERS = "row-header", } export interface StyledRegionGroup { className?: string; regions: Region[]; } /** * An _inclusive_ interval of ZERO-indexed cell indices. */ export type CellInterval = [number, number]; /** * Small datastructure for storing cell coordinates [row, column] */ export type CellCoordinate = [number, number]; /** * @see Regions.getRegionCardinality for more about the format of this object. */ export interface Region { /** * The first and last row indices in the region, inclusive and zero-indexed. * If `rows` is `null`, then all rows are understood to be included in the * region. */ rows?: CellInterval | null; /** * The first and last column indices in the region, inclusive and * zero-indexed. If `cols` is `null`, then all columns are understood to be * included in the region. */ cols?: CellInterval | null; } /** * A fully-defined cells `Region`, with both column and row bounds. */ export type NonNullRegion = Required<{ [P in keyof Region]: NonNullable; }>; /** * Table Regions API. * * @see https://blueprintjs.com/docs/#table/api.region */ /* eslint-disable-next-line @typescript-eslint/no-extraneous-class */ export class Regions { /** * Determines the cardinality of a region. We use null values to indicate * an unbounded interval. Therefore, an example of a region containing the * second and third columns would be: * * ```js * { rows: null, cols: [1, 2] } * ``` * * In this case, this method would return `RegionCardinality.FULL_COLUMNS`. * * If both rows and columns are unbounded, then the region covers the * entire table. Therefore, a region like this: * * ```js * { rows: null, cols: null } * ``` * * will return `RegionCardinality.FULL_TABLE`. * * An example of a region containing a single cell in the table would be: * * ```js * { rows: [5, 5], cols: [2, 2] } * ``` * * In this case, this method would return `RegionCardinality.CELLS`. */ public static getRegionCardinality(region: Region) { if (region.cols != null && region.rows != null) { return RegionCardinality.CELLS; } else if (region.cols != null) { return RegionCardinality.FULL_COLUMNS; } else if (region.rows != null) { return RegionCardinality.FULL_ROWS; } else { return RegionCardinality.FULL_TABLE; } } public static getFocusCellCoordinatesFromRegion(region: Region): CellCoordinates { const regionCardinality = Regions.getRegionCardinality(region); // HACKHACK: non-null assertions ahead, consider designing some type guards instead switch (regionCardinality) { case RegionCardinality.FULL_TABLE: return { col: 0, row: 0 }; case RegionCardinality.FULL_COLUMNS: return { col: region.cols![0], row: 0 }; case RegionCardinality.FULL_ROWS: return { col: 0, row: region.rows![0] }; case RegionCardinality.CELLS: return { col: region.cols![0], row: region.rows![0] }; } } /** * Returns a deep copy of the provided region. */ public static copy(region: Region): Region { const cardinality = Regions.getRegionCardinality(region); // HACKHACK: non-null assertions ahead, consider designing some type guards instead // N.B. we need to be careful not to explicitly spell out `rows: undefined` // (e.g.) if the "rows" key is completely absent, otherwise // deep-equality checks will fail. if (cardinality === RegionCardinality.CELLS) { return Regions.cell(region.rows![0], region.cols![0], region.rows![1], region.cols![1]); } else if (cardinality === RegionCardinality.FULL_COLUMNS) { return Regions.column(region.cols![0], region.cols![1]); } else if (cardinality === RegionCardinality.FULL_ROWS) { return Regions.row(region.rows![0], region.rows![1]); } else { return Regions.table(); } } /** * Returns a region containing one or more cells. */ public static cell(row: number, col: number, row2?: number, col2?: number): NonNullRegion { return { cols: this.normalizeInterval(col, col2), rows: this.normalizeInterval(row, row2), }; } /** * Returns a region containing one or more full rows. */ public static row(row: number, row2?: number): Region { return { rows: this.normalizeInterval(row, row2) }; } /** * Returns a region containing one or more full columns. */ public static column(col: number, col2?: number): Region { return { cols: this.normalizeInterval(col, col2) }; } /** * Returns a region containing the entire table. */ public static table(): Region { return {}; } /** * Adds the region to the end of a cloned copy of the supplied region * array. */ public static add(regions: Region[], region: Region) { const copy = regions.slice(); copy.push(region); return copy; } /** * Replaces the region at the end of a cloned copy of the supplied region * array, or at the specific index if one is provided. */ public static update(regions: Region[], region: Region, index?: number) { const copy = regions.slice(); if (index != null) { copy.splice(index, 1, region); } else { copy.pop(); copy.push(region); } return copy; } /** * Clamps the region's start and end indices between 0 and the provided * maximum values. */ public static clampRegion(region: Region, maxRowIndex: number, maxColumnIndex: number) { const nextRegion = Regions.copy(region); if (region.rows != null) { nextRegion.rows![0] = Utils.clamp(region.rows[0], 0, maxRowIndex); nextRegion.rows![1] = Utils.clamp(region.rows[1], 0, maxRowIndex); } if (region.cols != null) { nextRegion.cols![0] = Utils.clamp(region.cols[0], 0, maxColumnIndex); nextRegion.cols![1] = Utils.clamp(region.cols[1], 0, maxColumnIndex); } return nextRegion; } /** * Returns true iff the specified region is equal to the last region in * the region list. This allows us to avoid immediate additive re-selection. */ public static lastRegionIsEqual(regions: Region[] | null | undefined, region: Region) { if (regions == null || regions.length === 0) { return false; } const lastRegion = regions[regions.length - 1]; return Regions.regionsEqual(lastRegion, region); } /** * Returns the index of the region that is equal to the supplied * parameter. Returns -1 if no such region is found. */ public static findMatchingRegion(regions: Region[] | null | undefined, region: Region) { if (regions == null) { return -1; } for (let i = 0; i < regions.length; i++) { if (Regions.regionsEqual(regions[i], region)) { return i; } } return -1; } /** * Returns the index of the region that wholly contains the supplied * parameter. Returns -1 if no such region is found. */ public static findContainingRegion(regions: Region[] | null | undefined, region: Region) { if (regions == null) { return -1; } for (let i = 0; i < regions.length; i++) { if (Regions.regionContains(regions[i], region)) { return i; } } return -1; } /** * Returns true if the regions contain a region that has FULL_COLUMNS * cardinality and contains the specified column index. */ public static hasFullColumn(regions: Region[] | null | undefined, col: number) { if (regions == null) { return false; } for (const region of regions) { const cardinality = Regions.getRegionCardinality(region); if (cardinality === RegionCardinality.FULL_TABLE) { return true; } if (cardinality === RegionCardinality.FULL_COLUMNS && Regions.intervalContainsIndex(region.cols!, col)) { return true; } } return false; } /** * Returns true if the regions contain a region that has FULL_ROWS * cardinality and contains the specified row index. */ public static hasFullRow(regions: Region[] | null | undefined, row: number) { if (regions == null) { return false; } for (const region of regions) { const cardinality = Regions.getRegionCardinality(region); if (cardinality === RegionCardinality.FULL_TABLE) { return true; } if (cardinality === RegionCardinality.FULL_ROWS && Regions.intervalContainsIndex(region.rows!, row)) { return true; } } return false; } /** * Returns true if the regions contain a region that has FULL_TABLE cardinality */ public static hasFullTable(regions: Region[]) { if (regions == null) { return false; } for (const region of regions) { const cardinality = Regions.getRegionCardinality(region); if (cardinality === RegionCardinality.FULL_TABLE) { return true; } } return false; } /** * Returns true if the regions fully contain the query region. */ public static containsRegion(regions: Region[], query: Region) { return Regions.overlapsRegion(regions, query, false); } /** * Returns true if the regions at least partially overlap the query region. */ public static overlapsRegion(regions: Region[], query: Region, allowPartialOverlap = false) { const intervalCompareFn = allowPartialOverlap ? Regions.intervalOverlaps : Regions.intervalContains; if (regions == null || query == null) { return false; } for (const region of regions) { const cardinality = Regions.getRegionCardinality(region); switch (cardinality) { case RegionCardinality.FULL_TABLE: return true; case RegionCardinality.FULL_COLUMNS: if (intervalCompareFn(region.cols!, query.cols)) { return true; } continue; case RegionCardinality.FULL_ROWS: if (intervalCompareFn(region.rows!, query.rows)) { return true; } continue; case RegionCardinality.CELLS: if (intervalCompareFn(region.cols!, query.cols) && intervalCompareFn(region.rows!, query.rows)) { return true; } continue; default: break; } } return false; } public static eachUniqueFullColumn(regions: Region[], iteratee: (col: number) => void) { if (regions == null || regions.length === 0 || iteratee == null) { return; } const seen: { [col: number]: boolean } = {}; regions.forEach((region: Region) => { if (Regions.getRegionCardinality(region) === RegionCardinality.FULL_COLUMNS) { const [start, end] = region.cols!; for (let col = start; col <= end; col++) { if (!seen[col]) { seen[col] = true; iteratee(col); } } } }); } public static eachUniqueFullRow(regions: Region[], iteratee: (row: number) => void) { if (regions == null || regions.length === 0 || iteratee == null) { return; } const seen: { [row: number]: boolean } = {}; regions.forEach((region: Region) => { if (Regions.getRegionCardinality(region) === RegionCardinality.FULL_ROWS) { const [start, end] = region.rows!; for (let row = start; row <= end; row++) { if (!seen[row]) { seen[row] = true; iteratee(row); } } } }); } /** * Using the supplied array of non-contiguous `Region`s, this method * returns an ordered array of every unique cell that exists in those * regions. */ public static enumerateUniqueCells( regions: Region[] | null | undefined, numRows: number, numCols: number, ): CellCoordinate[] { if (regions == null || regions.length === 0) { return []; } const seen: { [key: string]: boolean } = {}; const list: CellCoordinate[] = []; for (const region of regions) { Regions.eachCellInRegion(region, numRows, numCols, (row: number, col: number) => { // add to list if not seen const key = `${row}-${col}`; if (seen[key] !== true) { seen[key] = true; list.push([row, col]); } }); } // sort list by rows then columns list.sort(Regions.rowFirstComparator); return list; } /** * Using the supplied region, returns an "equivalent" region of * type CELLS that define the bounds of the given region */ public static getCellRegionFromRegion(region: Region, numRows: number, numCols: number): NonNullRegion { const regionCardinality = Regions.getRegionCardinality(region); switch (regionCardinality) { case RegionCardinality.FULL_TABLE: return Regions.cell(0, 0, numRows - 1, numCols - 1); case RegionCardinality.FULL_COLUMNS: return Regions.cell(0, region.cols![0], numRows - 1, region.cols![1]); case RegionCardinality.FULL_ROWS: return Regions.cell(region.rows![0], 0, region.rows![1], numCols - 1); case RegionCardinality.CELLS: return Regions.cell(region.rows![0], region.cols![0], region.rows![1], region.cols![1]); } } public static getRegionFromFocusedRegion(focusedRegion: FocusedRegion): Region { switch (focusedRegion.type) { case FocusMode.CELL: return Regions.cell(focusedRegion.row, focusedRegion.col); case FocusMode.ROW: return Regions.row(focusedRegion.row); } } /** * Maps a dense array of cell coordinates to a sparse 2-dimensional array * of cell values. * * We create a new 2-dimensional array representing the smallest single * contiguous `Region` that contains all cells in the supplied array. We * invoke the mapper callback only on the cells in the supplied coordinate * array and store the result. Returns the resulting 2-dimensional array. * * If there is no contiguous `Region` which contains all the cells, we * return `undefined`. */ public static sparseMapCells( cells: CellCoordinate[], mapper: (row: number, col: number) => T, ): T[][] | undefined { const bounds = Regions.getBoundingRegion(cells); if (bounds === undefined) { return undefined; } const numRows = bounds.rows[1] + 1 - bounds.rows[0]; const numCols = bounds.cols[1] + 1 - bounds.cols[0]; const result = Utils.times(numRows, () => new Array(numCols)); cells.forEach(([row, col]) => { result[row - bounds.rows[0]][col - bounds.cols[0]] = mapper(row, col); }); return result; } /** * Returns the smallest single contiguous `Region` that contains all cells in the * supplied array. */ public static getBoundingRegion(cells: CellCoordinate[]): NonNullRegion | undefined { let minRow: number | undefined; let maxRow: number | undefined; let minCol: number | undefined; let maxCol: number | undefined; for (const [row, col] of cells) { minRow = minRow === undefined || row < minRow ? row : minRow; maxRow = maxRow === undefined || row > maxRow ? row : maxRow; minCol = minCol === undefined || col < minCol ? col : minCol; maxCol = maxCol === undefined || col > maxCol ? col : maxCol; } if (minRow === undefined || maxRow === undefined || minCol === undefined || maxCol === undefined) { return undefined; } return { cols: [minCol, maxCol], rows: [minRow, maxRow], }; } public static isValid(region: Region | null | undefined) { if (region == null) { return false; } if (region.rows != null && (region.rows[0] < 0 || region.rows[1] < 0)) { return false; } if (region.cols != null && (region.cols[0] < 0 || region.cols[1] < 0)) { return false; } return true; } public static isRegionValidForTable(region: Region, numRows: number, numCols: number) { if (numRows === 0 || numCols === 0) { return false; } else if (region.rows != null && !intervalInRangeInclusive(region.rows, 0, numRows - 1)) { return false; } else if (region.cols != null && !intervalInRangeInclusive(region.cols, 0, numCols - 1)) { return false; } return true; } public static joinStyledRegionGroups( selectedRegions: Region[], otherRegions: StyledRegionGroup[], focusedRegionOrCell: FocusedRegion | FocusedCellCoordinates | undefined, ) { const focusedRegion: FocusedRegion | undefined = focusedRegionOrCell == null ? undefined : "type" in focusedRegionOrCell ? focusedRegionOrCell : { ...focusedRegionOrCell, type: FocusMode.CELL }; let regionGroups: StyledRegionGroup[] = []; if (otherRegions != null) { regionGroups = regionGroups.concat(otherRegions); } if (selectedRegions != null && selectedRegions.length > 0) { regionGroups.push({ className: Classes.TABLE_SELECTION_REGION, regions: selectedRegions, }); } if (focusedRegion !== undefined) { regionGroups.push({ className: Classes.TABLE_FOCUS_REGION, regions: [Regions.getRegionFromFocusedRegion(focusedRegion)], }); } return regionGroups; } public static regionsEqual(regionA: Region, regionB: Region) { return Regions.intervalsEqual(regionA.rows, regionB.rows) && Regions.intervalsEqual(regionA.cols, regionB.cols); } /** * Expands an old region to the minimal bounding region that also contains * the new region. If the regions have different cardinalities, then the new * region is returned. Useful for expanding a selected region on * shift+click, for instance. */ public static expandRegion(oldRegion: Region, newRegion: Region): Region { const oldRegionCardinality = Regions.getRegionCardinality(oldRegion); const newRegionCardinality = Regions.getRegionCardinality(newRegion); if (newRegionCardinality !== oldRegionCardinality) { return newRegion; } switch (newRegionCardinality) { case RegionCardinality.FULL_ROWS: { const rowStart = Math.min(oldRegion.rows![0], newRegion.rows![0]); const rowEnd = Math.max(oldRegion.rows![1], newRegion.rows![1]); return Regions.row(rowStart, rowEnd); } case RegionCardinality.FULL_COLUMNS: { const colStart = Math.min(oldRegion.cols![0], newRegion.cols![0]); const colEnd = Math.max(oldRegion.cols![1], newRegion.cols![1]); return Regions.column(colStart, colEnd); } case RegionCardinality.CELLS: { const rowStart = Math.min(oldRegion.rows![0], newRegion.rows![0]); const colStart = Math.min(oldRegion.cols![0], newRegion.cols![0]); const rowEnd = Math.max(oldRegion.rows![1], newRegion.rows![1]); const colEnd = Math.max(oldRegion.cols![1], newRegion.cols![1]); return Regions.cell(rowStart, colStart, rowEnd, colEnd); } default: return Regions.table(); } } /** * Iterates over the cells within an `Region`, invoking the callback with * each cell's coordinates. */ private static eachCellInRegion( region: Region, numRows: number, numCols: number, iteratee: (row: number, col: number) => void, ) { const cardinality = Regions.getRegionCardinality(region); switch (cardinality) { case RegionCardinality.FULL_TABLE: for (let row = 0; row < numRows; row++) { for (let col = 0; col < numCols; col++) { iteratee(row, col); } } break; case RegionCardinality.FULL_COLUMNS: for (let row = 0; row < numRows; row++) { for (let col = region.cols![0]; col <= region.cols![1]; col++) { iteratee(row, col); } } break; case RegionCardinality.FULL_ROWS: for (let row = region.rows![0]; row <= region.rows![1]; row++) { for (let col = 0; col < numCols; col++) { iteratee(row, col); } } break; case RegionCardinality.CELLS: for (let row = region.rows![0]; row <= region.rows![1]; row++) { for (let col = region.cols![0]; col <= region.cols![1]; col++) { iteratee(row, col); } } break; default: break; } } private static regionContains(regionA: Region, regionB: Region) { // containsRegion expects an array of regions as the first param return Regions.overlapsRegion([regionA], regionB, false); } private static intervalsEqual(ivalA: CellInterval | null | undefined, ivalB: CellInterval | null | undefined) { if (ivalA == null) { return ivalB == null; } else if (ivalB == null) { return false; } else { return ivalA[0] === ivalB[0] && ivalA[1] === ivalB[1]; } } private static intervalContainsIndex(interval: CellInterval, index: number) { if (interval == null) { return false; } return interval[0] <= index && interval[1] >= index; } private static intervalContains(ivalA: CellInterval | null | undefined, ivalB: CellInterval | null | undefined) { if (ivalA == null || ivalB == null) { return false; } return ivalA[0] <= ivalB[0] && ivalB[1] <= ivalA[1]; } private static intervalOverlaps(ivalA: CellInterval | null | undefined, ivalB: CellInterval | null | undefined) { if (ivalA == null || ivalB == null) { return false; } if (ivalA[1] < ivalB[0] || ivalA[0] > ivalB[1]) { return false; } return true; } private static rowFirstComparator(a: CellCoordinate, b: CellCoordinate) { const rowDiff = a[0] - b[0]; return rowDiff === 0 ? a[1] - b[1] : rowDiff; } private static numericalComparator(a: number, b: number) { return a - b; } private static normalizeInterval(coord: number, coord2?: number) { if (coord2 == null) { coord2 = coord; } const interval = [coord, coord2]; interval.sort(Regions.numericalComparator); return interval as CellInterval; } } function intervalInRangeInclusive(interval: CellInterval, minInclusive: number, maxInclusive: number) { return ( inRangeInclusive(interval[0], minInclusive, maxInclusive) && inRangeInclusive(interval[1], minInclusive, maxInclusive) ); } function inRangeInclusive(value: number, minInclusive: number, maxInclusive: number) { return value >= minInclusive && value <= maxInclusive; }