diff --git a/README.md b/README.md index f4ea79a..e93ad91 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,18 @@ var z = Matrix.ones(2, 3); // z = Matrix [[1, 1, 1], [1, 1, 1], rows: 2, column var z = Matrix.eye(3, 4); // z = Matrix [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], rows: 3, columns: 4]. there are 1 only in the diagonal ``` +#### Concatenation of matrices +```js +var M = new Matrix([ + [1, 2], + [3, 4], +]); + +var stacked = M.concat([[5, 6]]); // stacked = Matrix [[1, 2], [3, 4], [5, 6], rows: 3, columns: 2] +var widened = M.concat(Matrix.columnVector([5, 6]), 'column'); // widened = Matrix [[1, 2, 5], [3, 4, 6], rows: 2, columns: 3] +``` +Concatenating by row needs the same number of columns on both sides, concatenating by column needs the same number of rows. The two operands are left untouched. + ### Maths ```js const { diff --git a/matrix.d.ts b/matrix.d.ts index a418fe8..5563a4d 100644 --- a/matrix.d.ts +++ b/matrix.d.ts @@ -763,6 +763,15 @@ export abstract class AbstractMatrix { startColumn: number, ): this; + /** + * Returns a new matrix made of this matrix followed by the other one. + * Concatenating by 'row' stacks the two matrices vertically and requires the same number of columns. + * Concatenating by 'column' stacks them side by side and requires the same number of rows. + * @param other - The matrix to append. + * @param by - Concatenate by 'row' or 'column'. Default: `'row'`. + */ + concat(other: MaybeMatrix, by?: MatrixDimension): Matrix; + /** * Return a new matrix based on a selection of rows and columns. * Order of the indices matters and the same index can be used more than once. diff --git a/src/__tests__/matrix/concat.test.js b/src/__tests__/matrix/concat.test.js new file mode 100644 index 0000000..5d5abde --- /dev/null +++ b/src/__tests__/matrix/concat.test.js @@ -0,0 +1,184 @@ +import { describe, it, expect } from 'vitest'; + +import { Matrix, MatrixTransposeView, SymmetricMatrix } from '../..'; + +describe('concat', () => { + const matrix = new Matrix([ + [1, 2], + [3, 4], + ]); + const other = new Matrix([ + [5, 6], + [7, 8], + [9, 10], + ]); + + it('by row stacks the matrices vertically', () => { + const result = matrix.concat(other, 'row'); + expect(result.rows).toBe(5); + expect(result.columns).toBe(2); + expect(result.to2DArray()).toStrictEqual([ + [1, 2], + [3, 4], + [5, 6], + [7, 8], + [9, 10], + ]); + }); + + it('by column stacks the matrices side by side', () => { + const result = matrix.concat(other.transpose(), 'column'); + expect(result.rows).toBe(2); + expect(result.columns).toBe(5); + expect(result.to2DArray()).toStrictEqual([ + [1, 2, 5, 7, 9], + [3, 4, 6, 8, 10], + ]); + }); + + it('concatenates by row when the dimension is omitted', () => { + expect(matrix.concat(other).to2DArray()).toStrictEqual( + matrix.concat(other, 'row').to2DArray(), + ); + }); + + it('accepts a 2D array', () => { + expect(matrix.concat([[5, 6]]).to2DArray()).toStrictEqual([ + [1, 2], + [3, 4], + [5, 6], + ]); + }); + + it('leaves both operands untouched', () => { + matrix.concat(other, 'row'); + expect(matrix.to2DArray()).toStrictEqual([ + [1, 2], + [3, 4], + ]); + expect(other.rows).toBe(3); + }); + + it('always returns a plain matrix', () => { + const symmetric = new SymmetricMatrix([ + [1, 2], + [2, 3], + ]); + const result = symmetric.concat(matrix, 'row'); + expect(result).toBeInstanceOf(Matrix); + expect(result.to2DArray()).toStrictEqual([ + [1, 2], + [2, 3], + [1, 2], + [3, 4], + ]); + }); + + it('reads a view along the view dimensions', () => { + const view = new MatrixTransposeView( + new Matrix([ + [1, 2, 3], + [4, 5, 6], + ]), + ); + expect(view.concat([[7, 8]], 'row').to2DArray()).toStrictEqual([ + [1, 4], + [2, 5], + [3, 6], + [7, 8], + ]); + }); + + it('appends a column vector', () => { + const result = matrix.concat(Matrix.columnVector([5, 6]), 'column'); + expect(result.to2DArray()).toStrictEqual([ + [1, 2, 5], + [3, 4, 6], + ]); + }); + + it('appends a row vector', () => { + const result = matrix.concat(Matrix.rowVector([5, 6]), 'row'); + expect(result.to2DArray()).toStrictEqual([ + [1, 2], + [3, 4], + [5, 6], + ]); + }); + + it('chains to gather several matrices', () => { + const result = matrix + .concat([[5, 6]], 'row') + .concat(Matrix.columnVector([7, 8, 9]), 'column'); + expect(result.to2DArray()).toStrictEqual([ + [1, 2, 7], + [3, 4, 8], + [5, 6, 9], + ]); + }); +}); + +describe('concat with degenerate matrices', () => { + it('by row with a matrix without rows', () => { + const result = new Matrix(0, 2).concat(new Matrix([[1, 2]]), 'row'); + expect(result.to2DArray()).toStrictEqual([[1, 2]]); + }); + + it('by row onto a matrix without rows', () => { + const result = new Matrix([[1, 2]]).concat(new Matrix(0, 2), 'row'); + expect(result.to2DArray()).toStrictEqual([[1, 2]]); + }); + + it('by column with a matrix without columns', () => { + const result = new Matrix(2, 0).concat(new Matrix([[1], [2]]), 'column'); + expect(result.to2DArray()).toStrictEqual([[1], [2]]); + }); + + it('by column onto a matrix without columns', () => { + const result = new Matrix([[1], [2]]).concat(new Matrix(2, 0), 'column'); + expect(result.to2DArray()).toStrictEqual([[1], [2]]); + }); + + it('by column of two matrices without rows', () => { + const result = new Matrix(0, 1).concat(new Matrix(0, 2), 'column'); + expect(result.rows).toBe(0); + expect(result.columns).toBe(3); + }); + + it('by row of two matrices without columns', () => { + const result = new Matrix(1, 0).concat(new Matrix(2, 0), 'row'); + expect(result.rows).toBe(3); + expect(result.columns).toBe(0); + }); + + it('by row of two 0x0 matrices', () => { + const result = new Matrix(0, 0).concat(new Matrix(0, 0), 'row'); + expect(result.rows).toBe(0); + expect(result.columns).toBe(0); + }); +}); + +describe('concat error handling', () => { + const matrix = new Matrix([ + [1, 2], + [3, 4], + ]); + + it('throws when the number of columns differs', () => { + expect(() => matrix.concat(new Matrix([[1, 2, 3]]), 'row')).toThrow( + /^both matrices must have the same number of columns$/, + ); + }); + + it('throws when the number of rows differs', () => { + expect(() => matrix.concat(new Matrix([[1, 2, 3]]), 'column')).toThrow( + /^both matrices must have the same number of rows$/, + ); + }); + + it('throws when the dimension is unknown', () => { + expect(() => matrix.concat(matrix, 'diagonal')).toThrow( + /^invalid option: diagonal$/, + ); + }); +}); diff --git a/src/matrix.js b/src/matrix.js index bd8e513..552a9b6 100644 --- a/src/matrix.js +++ b/src/matrix.js @@ -1420,6 +1420,36 @@ export class AbstractMatrix { return this; } + concat(other, by = 'row') { + other = Matrix.checkMatrix(other); + switch (by) { + case 'row': { + if (this.columns !== other.columns) { + throw new RangeError( + 'both matrices must have the same number of columns', + ); + } + const result = new Matrix(this.rows + other.rows, this.columns); + result.setSubMatrix(this, 0, 0); + result.setSubMatrix(other, this.rows, 0); + return result; + } + case 'column': { + if (this.rows !== other.rows) { + throw new RangeError( + 'both matrices must have the same number of rows', + ); + } + const result = new Matrix(this.rows, this.columns + other.columns); + result.setSubMatrix(this, 0, 0); + result.setSubMatrix(other, 0, this.columns); + return result; + } + default: + throw new Error(`invalid option: ${by}`); + } + } + selection(rowIndices, columnIndices) { checkRowIndices(this, rowIndices); checkColumnIndices(this, columnIndices);