/*
* This file is part of TREB.
*
* TREB is free software: you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* TREB is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
* details.
*
* You should have received a copy of the GNU General Public License along
* with TREB. If not, see .
*
* Copyright 2022-2026 trebco, llc.
* info@treb.app
*
*/
/**
* Structure represents a cell address. Note that row and column are 0-based.
*/
export interface ICellAddress {
/** 0-based row */
row: number;
/** 0-based column */
column: number;
absolute_row?: boolean;
absolute_column?: boolean;
sheet_id?: number;
/** spill reference */
spill?: boolean;
}
/**
* this version of the interface requires a sheet ID
*/
export interface ICellAddress2 extends ICellAddress {
sheet_id: number;
}
/**
* Structure represents a 2d range of cells.
*
* @privateRemarks
*
* FIXME: should be just Partial? (...) OTOH, this at least
* enforces two addresses, which seems useful
*/
export interface IArea {
start: ICellAddress;
end: ICellAddress;
}
export type SerializedArea = IArea & { start: ICellAddress & { sheet: string }};
export interface PatchOptions {
before_column: number;
column_count: number;
before_row: number;
row_count: number;
}
/**
* type guard function
* FIXME: is there a naming convention for these?
*
* @internal
*/
export const IsCellAddress = (obj: unknown): obj is ICellAddress => {
return (
obj !== null
&& typeof obj === 'object'
&& 'row' in obj
&& 'column' in obj);
};
/** @internal */
export const IsArea = (obj: unknown): obj is IArea => {
return (
obj !== null
&& typeof obj === 'object'
&& 'start' in obj
&& IsCellAddress(obj.start)
&& 'end' in obj
&& IsCellAddress(obj.end));
};
export interface Dimensions {
rows: number;
columns: number;
}
/**
* class represents a rectangular area on a sheet. can be a range,
* single cell, entire row/column, or entire sheet.
*
* "entire" row/column/sheet is represented with an infinity in the
* start/end value for row/column/both, so watch out on loops. the
* sheet class has a method for reducing infinite ranges to actual
* populated ranges.
*
* infinitiy is turning into a headache because it doesn't serialize
* to json properly. should we switch to a flag, or -1, or something?
*/
export class Area implements IArea {
// tslint:disable-next-line:variable-name
private start_: ICellAddress;
// tslint:disable-next-line:variable-name
private end_: ICellAddress;
/**
*
* @param start
* @param end
* @param normalize: calls the normalize function
*/
constructor(start: ICellAddress, end: ICellAddress = start, normalize = false){
/*
// copy
this.start_ = {
row: start.row, column: start.column,
absolute_column: !!start.absolute_column,
absolute_row: !!start.absolute_row };
this.end_ = {
row: end.row, column: end.column,
absolute_column: !!end.absolute_column,
absolute_row: !!end.absolute_row };
*/
// patch nulls. this is an effect of transferring via JSON,
// infinities are -> null. make sure to strict === null.
// NOTE that the patch function returns a clone, so we can store the
// returned object (instead of copying, which we used to do).
this.end_ = this.PatchNull(end);
this.start_ = this.PatchNull(start);
if (normalize) this.Normalize();
// this.ResetIterator();
}
public static FromColumn(column: number): Area {
return new Area({row: Infinity, column});
}
public static FromRow(row: number): Area {
return new Area({row, column: Infinity});
}
public static ColumnToLabel(c: number): string {
let s = String.fromCharCode(65 + c % 26);
while (c > 25){
c = Math.floor(c / 26) - 1;
s = String.fromCharCode(65 + c % 26) + s;
}
return s;
}
public static CellAddressToLabel(address: ICellAddress, sheet_id = false): string {
if (address.row === Infinity && address.column === Infinity) {
throw new Error('this is going to break something');
}
const prefix = sheet_id ? `${address.sheet_id || 0}!` : '';
if (address.row === Infinity) {
return prefix
+ (address.absolute_column ? '$' : '')
+ this.ColumnToLabel(address.column)
// + (address.absolute_row ? '$' : '')
// + (address.row + 1);
;
}
if (address.column === Infinity) {
return prefix
// + (address.absolute_column ? '$' : '')
// + this.ColumnToLabel(address.column)
+ (address.absolute_row ? '$' : '')
+ (address.row + 1)
;
}
return prefix
+ (address.absolute_column ? '$' : '')
+ this.ColumnToLabel(address.column)
+ (address.absolute_row ? '$' : '')
+ (address.row + 1)
+ (address.spill ? '#' : '');
}
/**
* merge two areas and return a new area.
* UPDATE to support arbitrary arguments
*/
public static Join(base: IArea, ...args: Array): Area {
const area = new Area(base.start, base.end);
for (const arg of args) {
if (arg) {
area.ConsumeAddress(arg.start);
area.ConsumeAddress(arg.end);
}
}
return area;
}
/**
* creates an area that expands the original area in all directions
* (except at the top/left edges)
*/
public static Bleed(area: IArea, length = 1): Area {
return new Area({
row: Math.max(0, area.start.row - length),
column: Math.max(0, area.start.column - length),
sheet_id: area.start.sheet_id,
}, {
row: area.end.row + length,
column: area.end.column + length,
});
}
/**
* adjust an area in response to an insert/delete operation.
* I noticed we were doing this in several places. moved here to unify.
*
* @param source - the starting area. we'll create a new object to return
* (we will not mutate in place)
*/
public static PatchArea(source: IArea, options: PatchOptions): Area | false {
const { before_column, column_count, before_row, row_count } = options;
let area = new Area(source.start, source.end);
const sheet_id = source.start.sheet_id;
if (column_count && before_column <= area.end.column) {
/*
// (1) we are before the insert point, not affected
if (before_column > range.end.column) {
continue;
}
*/
if (column_count > 0) {
// (2) it's an insert and we are past the insert point:
// increment [start] and [end] by [count]
if (before_column <= area.start.column) {
area.Shift(0, column_count);
}
// (3) it's an insert and we contain the insert point:
// increment [end] by [count]
else if (before_column > area.start.column && before_column <= area.end.column) {
area.ConsumeAddress({row: area.end.row, column: area.end.column + column_count});
}
else {
console.warn(`AA X case 1`, before_column, column_count, JSON.stringify(area));
}
}
else if (column_count < 0) {
// (4) it's a delete and we are past the delete point (before+count):
// decrement [start] and [end] by [count]
if (before_column - column_count <= area.start.column) {
area.Shift(0, column_count);
}
// (5) it's a delete and contains the entire range
else if (before_column <= area.start.column && before_column - column_count > area.end.column) {
// we can actually just return at this point
return false;
}
// (6) it's a delete and contains part of the range. clip the range.
else if (before_column <= area.start.column) {
const last_column = before_column - column_count - 1;
area = new Area({
row: area.start.row, column: last_column + 1 + column_count, sheet_id }, {
row: area.end.row, column: area.end.column + column_count });
}
else if (before_column <= area.end.column) {
const last_column = before_column - column_count - 1;
if (last_column >= area.end.column) {
area = new Area({
row: area.start.row, column: area.start.column, sheet_id }, {
row: area.end.row, column: before_column - 1 });
}
else {
area = new Area({
row: area.start.row, column: area.start.column, sheet_id }, {
row: area.end.row, column: area.start.column + area.columns + column_count - 1});
}
}
else {
console.warn(`AA X case 2`, before_column, column_count, JSON.stringify(area));
}
}
}
if (row_count && before_row <= area.end.row) {
/*
// (1) we are before the insert point, not affected
if (before_column > range.end.column) {
continue;
}
*/
if (row_count > 0) {
// (2) it's an insert and we are past the insert point:
// increment [start] and [end] by [count]
if (before_row <= area.start.row) {
area.Shift(row_count, 0);
}
// (3) it's an insert and we contain the insert point:
// increment [end] by [count]
else if (before_row > area.start.row && before_row <= area.end.row) {
area.ConsumeAddress({row: area.end.row + row_count, column: area.end.column});
}
else {
console.warn(`AA X case 3`, before_row, row_count, JSON.stringify(area));
}
}
else if (row_count < 0) {
// (4) it's a delete and we are past the delete point (before+count):
// decrement [start] and [end] by [count]
if (before_row - row_count <= area.start.row) {
area.Shift(row_count, 0);
}
// (5) it's a delete and contains the entire range
else if (before_row <= area.start.row && before_row - row_count > area.end.row) {
return false;
}
// (6) it's a delete and contains part of the range. clip the range.
else if (before_row <= area.start.row) {
const last_row = before_row - row_count - 1;
area = new Area({
column: area.start.column, row: last_row + 1 + row_count, sheet_id }, {
column: area.end.column, row: area.end.row + row_count });
}
else if (before_row <= area.end.row) {
const last_row = before_row - row_count - 1;
if (last_row >= area.end.row) {
area = new Area({
column: area.start.column, row: area.start.row, sheet_id }, {
column: area.end.column, row: before_row - 1 });
}
else {
area = new Area({
column: area.start.column, row: area.start.row, sheet_id }, {
column: area.end.column, row: area.start.row + area.rows + row_count - 1 });
}
}
else {
console.warn(`AA X case 4`, before_row, row_count, JSON.stringify(area));
}
}
}
return area;
}
/** accessor returns a _copy_ of the start address */
public get start(): ICellAddress {
return { ...this.start_ };
}
/** accessor */
public set start(value: ICellAddress){ this.start_ = value; }
/** accessor returns a _copy_ of the end address */
public get end(): ICellAddress {
return { ...this.end_ };
}
/** accessor */
public set end(value: ICellAddress){ this.end_ = value; }
/** returns number of rows, possibly infinity */
public get rows(): number {
if (this.start_.row === Infinity || this.end_.row === Infinity) return Infinity;
return this.end_.row - this.start_.row + 1;
}
/** returns number of columns, possibly infinity */
public get columns(): number {
if (this.start_.column === Infinity || this.end_.column === Infinity) return Infinity;
return this.end_.column - this.start_.column + 1;
}
/** returns number of cells, possibly infinity */
public get count(): number {
return this.rows * this.columns;
}
/** returns flag indicating this is the entire sheet, usually after "select all" */
public get entire_sheet(): boolean {
return this.entire_row && this.entire_column;
}
/** returns flag indicating this range includes infinite rows */
public get entire_column(): boolean {
return (this.start_.row === Infinity);
}
/** returns flag indicating this range includes infinite columns */
public get entire_row(): boolean {
return (this.start_.column === Infinity);
}
public PatchNull(address: ICellAddress): ICellAddress {
const copy = { ...address };
if (copy.row === null) { copy.row = Infinity; }
if (copy.column === null) { copy.column = Infinity; }
return copy;
}
public SetSheetID(id: number) {
this.start_.sheet_id = id;
}
public Normalize(){
/*
let columns = [this.start.column, this.end.column].sort((a, b) => a-b);
let rows = [this.start.row, this.end.row].sort((a, b) => a-b);
this.start_ = {row: rows[0], column: columns[0]};
this.end = {row:rows[1], column: columns[1]};
*/
// we need to bind the element and the absolute/relative status
// so sorting is too simple
const start = { ...this.start_ };
const end = { ...this.end_ };
/*
const start = {
sheet_id: this.start_.sheet_id,
row: this.start_.row,
column: this.start_.column,
absolute_column: this.start_.absolute_column,
absolute_row: this.start_.absolute_row };
const end = {
sheet_id: this.end_.sheet_id, // we don't ever use this, but copy JIC
row: this.end_.row,
column: this.end_.column,
absolute_column: this.end_.absolute_column,
absolute_row: this.end_.absolute_row };
*/
// swap row
if (start.row === Infinity || end.row === Infinity){
start.row = end.row = Infinity;
}
else if (start.row > end.row){
start.row = this.end_.row;
start.absolute_row = this.end_.absolute_row;
end.row = this.start_.row;
end.absolute_row = this.start_.absolute_row;
}
// swap column
if (start.column === Infinity || end.column === Infinity){
start.column = end.column = Infinity;
}
else if (start.column > end.column){
start.column = this.end_.column;
start.absolute_column = this.end_.absolute_column;
end.column = this.start_.column;
end.absolute_column = this.start_.absolute_column;
}
this.start_ = start;
this.end_ = end;
}
/** returns the top-left cell in the area */
public TopLeft(): ICellAddress {
const address = {row: 0, column: 0};
if (!this.entire_row) address.column = this.start.column;
if (!this.entire_column) address.row = this.start.row;
return address;
}
/** returns the bottom-right cell in the area */
public BottomRight(): ICellAddress {
const address = {row: 0, column: 0};
if (!this.entire_row) address.column = this.end.column;
if (!this.entire_column) address.row = this.end.row;
return address;
}
public ContainsRow(row: number): boolean {
return this.entire_column || (row >= this.start_.row && row <= this.end_.row);
}
public ContainsColumn(column: number): boolean {
return this.entire_row || (column >= this.start_.column && column <= this.end_.column);
}
public Contains(address: ICellAddress): boolean {
return (this.entire_column || (address.row >= this.start_.row && address.row <= this.end_.row))
&& (this.entire_row || (address.column >= this.start_.column && address.column <= this.end_.column));
}
/**
* returns true if this area completely contains the argument area
* (also if areas are ===, as a side effect). note that this returns
* true if A contains B, but not vice-versa
*/
public ContainsArea(area: Area): boolean {
return this.start.column <= area.start.column
&& this.end.column >= area.end.column
&& this.start.row <= area.start.row
&& this.end.row >= area.end.row;
}
/**
* returns true if there's an intersection. note that this won't work
* if there are infinities -- needs real area ?
*/
public Intersects(area: Area): boolean {
return !(area.start.column > this.end.column
|| this.start.column > area.end.column
|| area.start.row > this.end.row
|| this.start.row > area.end.row);
}
public Equals(area: Area): boolean {
return area.start_.row === this.start_.row
&& area.start_.column === this.start_.column
&& area.end_.row === this.end_.row
&& area.end_.column === this.end_.column;
}
public Equals2(area: IArea): boolean {
return area.start.row === this.start_.row
&& area.start.column === this.start_.column
&& area.end.row === this.end_.row
&& area.end.column === this.end_.column;
}
public Clone(): Area {
return new Area(this.start, this.end); // ensure copies
}
/* removed, use iterator
public Array(): ICellAddress[] {
if (this.entire_column || this.entire_row) throw new Error('can\'t convert infinite area to array');
const array: ICellAddress[] = new Array(this.rows * this.columns);
const sheet_id = this.start_.sheet_id;
let index = 0;
// does this need sheet ID?
for (let row = this.start_.row; row <= this.end_.row; row++){
for (let column = this.start_.column; column <= this.end_.column; column++){
array[index++] = { row, column, sheet_id };
}
}
return array;
}
*/
get left(): Area{
const area = new Area(this.start_, this.end_);
area.end_.column = area.start_.column;
return area;
}
get right(): Area{
const area = new Area(this.start_, this.end_);
area.start_.column = area.end_.column;
return area;
}
get top(): Area{
const area = new Area(this.start_, this.end_);
area.end_.row = area.start_.row;
return area;
}
get bottom(): Area{
const area = new Area(this.start_, this.end_);
area.start_.row = area.end_.row;
return area;
}
/** shifts range in place */
public Shift(rows: number, columns: number): Area {
this.start_.row += rows;
this.start_.column += columns;
this.end_.row += rows;
this.end_.column += columns;
return this; // fluent
}
/** Resizes range in place so that it includes the given address */
public ConsumeAddress(addr: ICellAddress): void {
if (!this.entire_row){
if (addr.column < this.start_.column) this.start_.column = addr.column;
if (addr.column > this.end_.column) this.end_.column = addr.column;
}
if (!this.entire_column){
if (addr.row < this.start_.row) this.start_.row = addr.row;
if (addr.row > this.end_.row) this.end_.row = addr.row;
}
}
/** utility for removing headers from dataset selection */
public RemoveHeaderRow() {
this.start_.row++;
return this;
}
/** utility for removing headers from dataset selection */
public RemoveHeaderColumn() {
this.start_.column++;
return this;
}
/** returns one column of the area */
public GetColumn(column: number) {
if (column < this.start.column || column > this.end.column) {
throw new Error('invalid column');
}
return new Area({
row: this.start.row, column,
}, {
row: this.end.row, column
});
}
/** returns one column of the area */
public GetRow(row: number) {
if (row < this.start.row || row > this.end.row) {
throw new Error('invalid row');
}
return new Area({
row, column: this.start.column,
}, {
row, column: this.end.column,
});
}
/** Resizes range in place to be the requested shape */
public Reshape(rows: number, columns: number): Area {
this.end_.row = this.start_.row + rows - 1;
this.end_.column = this.start_.column + columns - 1;
return this; // fluent
}
/** Resizes range in place so that it includes the given area (merge) */
public ConsumeArea(area: IArea): void {
this.ConsumeAddress(area.start);
this.ConsumeAddress(area.end);
}
/** resizes range in place (updates end) */
public Resize(rows: number, columns: number): Area {
this.end_.row = this.start_.row + rows - 1;
this.end_.column = this.start_.column + columns - 1;
return this; // fluent
}
/* *
* preferred to straight iterator. actually in this class iterator
* is OK but in some other cases we'll want to generate like this
*
* eh I don't know about this inline function, is that going to be
* optimized out? ...
* /
public get contents(): Generator<{ row: number, column: number, sheet_id?: number }> {
if (this.entire_row || this.entire_column) {
throw new Error(`don't iterate infinite area`);
}
const sheet_id = this.start_.sheet_id;
const start_column = this.start_.column;
const end_column = this.end_.column;
const start_row = this.start_.row;
const end_row = this.end_.row;
function *generator() {
for (let column = start_column; column <= end_column; column++){
for (let row = start_row; row <= end_row; row++){
yield {column, row, sheet_id};
}
}
}
return generator();
}
*/
/**
* modernizing. this is a proper iterator. generators are prettier
* but there's at least some performance cost -- I'm not sure how
* much, but it's non-zero.
*/
public [Symbol.iterator](): Iterator {
if (this.entire_row || this.entire_column) {
throw new Error(`don't iterate infinite area`);
}
let row = this.start_.row;
let column = this.start_.column;
// this now uses "live" references, so if the object were mutated
// during iteration the iterator would reflect those changes. which
// seems bad, but also correct.
return {
next: () => {
const value = { column, row, sheet_id: this.start_.sheet_id };
if (column > this.end_.column) {
return {
done: true,
value: undefined,
};
}
if (++row > this.end_.row) {
row = this.start_.row;
column++;
}
return { value };
},
};
}
/* * @deprecated * /
public Iterate(f: (...args: any[]) => any): void {
if (this.entire_column || this.entire_row) {
console.warn(`don't iterate infinite area`);
return;
}
for (let c = this.start_.column; c <= this.end_.column; c++){
for (let r = this.start_.row; r <= this.end_.row; r++){
f({column: c, row: r, sheet_id: this.start_.sheet_id});
}
}
}
*/
/* *
* testing: we may have to polyfill for IE11, or just not use it at
* all, depending on support level... but it works OK (kind of a clumsy
* implementation though).
*
* as it turns out we don't really use iteration that much (I thought
* we did) so it's probably not worth the polyfill...
*
* /
public next(): IteratorResult {
// sanity
if (this.entire_column || this.entire_row) {
console.warn('don\'t iterate over infinte range');
return { value: undefined, done: true };
}
// return current, unless it's OOB; if so, advance
if (this.iterator_index.column > this.end.column) {
this.iterator_index.column = this.start_.column;
this.iterator_index.row++;
if (this.iterator_index.row > this.end.row) {
this.ResetIterator();
return { value: undefined, done: true };
}
}
const result = { value: { ...this.iterator_index }, done: false };
this.iterator_index.column++;
return result;
}
public [Symbol.iterator](): IterableIterator {
return this;
}
*/
/**
* returns the range in A1-style spreadsheet addressing. if the
* entire sheet is selected, returns nothing (there's no way to
* express that in A1 notation). returns the row numbers for entire
* columns and vice-versa for rows.
*/
get spreadsheet_label(): string {
let s: string;
if (this.entire_sheet) return '';
if (this.entire_column){
s = Area.ColumnToLabel(this.start_.column);
s += ':' + Area.ColumnToLabel(this.end_.column);
return s;
}
if (this.entire_row){
s = String(this.start_.row + 1);
s += ':' + (this.end_.row + 1);
return s;
}
s = Area.CellAddressToLabel(this.start_);
if (this.columns > 1 || this.rows > 1) return s + ':' + Area.CellAddressToLabel(this.end_);
return s;
}
/**
* FIXME: is this different than what would be returned if
* we just used the default json serializer? (...)
*
* NOTE: we could return just the start if size === 1. if
* you pass an undefined to the Area class ctor it will reuse
* the start.
*
*/
public toJSON(): IArea {
return {
start: { ...this.start_ },
end: { ...this.end_ },
};
/*
return {
start: {
row: this.start.row,
absolute_row: this.start.absolute_row,
column: this.start.column,
absolute_column: this.start.absolute_column,
},
end: {
row: this.end.row,
absolute_row: this.end.absolute_row,
column: this.end.column,
absolute_column: this.end.absolute_column,
},
};
*/
}
/*
private ResetIterator() {
this.iterator_index = {
row: this.start_.row,
column: this.start_.column,
sheet_id: this.start_.sheet_id,
};
}
*/
}