albert-svg
Version:
Dynamic SVG generation using Cassowary constraints
616 lines (535 loc) • 14.9 kB
JavaScript
// Copyright 2019 Google LLC
//
// 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 Text from "./Text";
import {
appendTo,
createElement,
findLast,
identity,
isPointLike,
last,
maxBy
} from "./utils";
import { eq, expression, geq } from "./helpers";
const CENTER = "center";
const HORIZONTAL_ALIGNMENTS = {
left: "leftEdge",
center: "centerX",
right: "rightEdge"
};
const VERTICAL_ALIGNMENTS = {
top: "topEdge",
baseline: "baseline",
center: "centerY",
bottom: "bottomEdge"
};
function findRowLeader(row) {
return maxBy(row, cell => (cell ? cell.ratios.height : 0));
}
function findColumnLeader(column) {
return maxBy(column, cell => (cell ? cell.ratios.width : 0));
}
function resolveAlignment(first, second) {
let horizontal, vertical;
if (first === CENTER && !second) {
horizontal = CENTER;
vertical = CENTER;
} else if (HORIZONTAL_ALIGNMENTS[first]) {
horizontal = first;
if (VERTICAL_ALIGNMENTS[second]) {
vertical = second;
}
} else if (VERTICAL_ALIGNMENTS[first]) {
vertical = first;
if (HORIZONTAL_ALIGNMENTS[second]) {
horizontal = second;
}
}
if (!horizontal && !vertical) {
throw new Error(`Cannot align to ${first}` + second ? `, ${second}` : "");
}
return { horizontal, vertical };
}
class Cell extends Text {
constructor(
table,
string,
attributes,
position,
horizontalAlignment,
verticalAlignment
) {
super(string, attributes);
this.table_ = table;
this.position_ = position;
this.horizontalAlignment = horizontalAlignment;
this.verticalAlignment = verticalAlignment;
this.suppressUpdates_ = false;
}
alignTo(first, second = undefined, updateTable = true) {
const { horizontal, vertical } = resolveAlignment(first, second);
if (horizontal) {
this.horizontalAlignment = horizontal;
}
if (vertical) {
this.verticalAlignment = vertical;
}
return this;
}
render() {
const el = super.render();
if (this.position_) {
el.setAttributeNS(null, "data-row", this.position_.y);
el.setAttributeNS(null, "data-column", this.position_.x);
}
return el;
}
adjustDimensions_() {
super.adjustDimensions_();
if (this.table_ && !this.suppressUpdates_) {
this.table_.updateLeaders(this.position_);
}
}
withUpdatesSuppressed(callback) {
this.suppressUpdates_ = true;
callback();
this.suppressUpdates_ = false;
}
}
class Slice {
constructor(table, cells, position, edges) {
this.table_ = table;
this.cells_ = cells;
this.position_ = position;
this.leftEdge = edges.left;
this.topEdge = edges.top;
this.rightEdge = edges.right;
this.bottomEdge = edges.bottom;
this.x = this.leftEdge;
this.y = this.topEdge;
this.width = expression(this.rightEdge).minus(this.leftEdge);
this.height = expression(this.bottomEdge).minus(this.topEdge);
this.centerX = this.leftEdge.plus(this.rightEdge).divide(2);
this.centerY = this.topEdge.plus(this.bottomEdge).divide(2);
}
contents() {
return this.cells_;
}
alignTo(one, two) {
for (const cell of this.cells_) {
if (cell === undefined) continue;
cell.alignTo(one, two);
}
return this;
}
setAttributes(attributes) {
for (const cell of this.cells_) {
if (cell === undefined) continue;
cell.withUpdatesSuppressed(() => {
cell.setAttributes(attributes);
});
}
this.table_.updateLeaders(this.position_);
return this;
}
format(start, end, attributes) {
for (const cell of this.cells_) {
if (cell === undefined) continue;
cell.withUpdatesSuppressed(() => {
cell.format(start, end, attributes);
});
}
this.table_.updateLeaders(this.position_);
return this;
}
formatRegexp(regexp, attributes) {
for (const cell of this.cells_) {
if (cell === undefined) continue;
cell.withUpdatesSuppressed(() => {
cell.formatRegexp(regexp, attributes);
});
}
this.table_.updateLeaders(this.position_);
return this;
}
}
export default class TextTable {
constructor(attributes = {}) {
this.attributes_ = attributes;
this.columnCount_ = 0;
this.rows_ = [];
this.headers_ = null;
this.spacing_ = { x: 0, y: 0 };
this.rowLeaders_ = [];
this.columnLeaders_ = [];
this.defaultAlignment_ = {
horizontal: "left",
vertical: "baseline"
};
this.fontSize = null;
this.leftEdge = null;
this.topEdge = null;
this.rightEdge = null;
this.bottomEdge = null;
this.x = null;
this.y = null;
this.width = null;
this.height = null;
this.centerX = null;
this.centerY = null;
}
addRow(row) {
if (row.length > this.columnCount_) {
this.setColumnCount_(row.length);
} else if (row.length < this.columnCount_) {
const rowCopy = row.slice();
rowCopy.length = this.columnCount_;
return this.addRow(rowCopy);
}
const y = this.rows_.length;
this.rows_.push(row.map((string, x) => this.createCell_(string, { x, y })));
this.updateLeaders({ y });
return this;
}
addColumn(column) {
const insertionIndex = this.columnCount_;
this.setColumnCount_(this.columnCount_ + 1);
const rowCount = this.rows_.length;
for (let y = 0; y < column.length; y++) {
const string = column[y];
if (y < rowCount) {
this.rows_[y][insertionIndex] = this.createCell_(string, {
x: insertionIndex,
y
});
} else {
const row = new Array(this.columnCount_);
row[insertionIndex] = this.createCell_(string, {
x: insertionIndex,
y
});
this.rows_.push(row);
}
}
this.updateLeaders({ x: insertionIndex });
return this;
}
addRows(...rows) {
for (const row of rows) {
this.addRow(row);
}
return this;
}
addColumns(...columns) {
for (const column of columns) {
this.addColumn(column);
}
return this;
}
getCell(x, y) {
return this.rows_[y][x];
}
getRow(y) {
return new Slice(
this,
this.rows_[y],
{ y },
{
left: this.leftEdge,
top: this.rowLeaders_[y].topEdge,
right: this.rightEdge,
bottom: this.rowLeaders_[y].bottomEdge
}
);
}
getColumn(x) {
return new Slice(
this,
this.column(x),
{ x },
{
left: this.columnLeaders_[x].leftEdge,
top: this.topEdge,
right: this.columnLeaders_[x].rightEdge,
bottom: this.bottomEdge
}
);
}
getAllCells() {
return new Slice(
this,
Array.from(this.cells()),
{},
{
left: this.leftEdge,
top: this.topEdge,
right: this.rightEdge,
bottom: this.bottomEdge
}
);
}
getRowCount() {
return this.rows_.length;
}
getColumnCount() {
return this.columnCount_;
}
lineHeight(multiplier = 1) {
return expression(this.fontSize).times(multiplier);
}
column(x) {
return this.rows_.reduce((column, row) => column.concat([row[x]]), []);
}
*cells() {
for (const row of this.rows_) {
for (const cell of row) {
if (cell) {
yield cell;
}
}
}
}
alignTo(one, two) {
this.getAllCells().alignTo(one, two);
Object.assign(this.defaultAlignment_, resolveAlignment(one, two));
return this;
}
setSpacing(xOrPoint, y = undefined) {
if (isPointLike(xOrPoint)) {
this.spacing_ = xOrPoint;
return this;
}
if (y === undefined) {
this.spacing_ = { x: xOrPoint, y: xOrPoint };
return this;
}
this.spacing_ = { x: xOrPoint, y };
return this;
}
setAttributes(attributes) {
Object.assign(this.attributes_, attributes);
this.getAllCells().setAttributes(attributes);
return this;
}
render() {
const el = createElement(
"g",
Object.assign({ "data-type": "texttable" }, this.attributes_)
);
for (const cell of this.cells()) {
el.appendChild(cell.render());
}
return el;
}
constraints() {
// All font sizes should be equal.
const equalFontSizes = Array.from(this.cells())
.slice(1)
.map(cell => eq(this.fontSize, cell.fontSize));
// The font size should be positive.
const positiveFontSize = [geq(this.fontSize, 1)];
// Per-row constraints
const rows = [];
this.rows_.forEach((row, y) => {
const leader = this.rowLeaders_[y];
appendTo(
rows,
row
.filter(cell => cell && cell !== leader)
.map(cell => {
const field = VERTICAL_ALIGNMENTS[cell.verticalAlignment];
return eq(leader[field], cell[field]);
})
);
});
// Per-column constraints
const columns = [];
for (let x = 0; x < this.columnCount_; x++) {
const column = this.column(x);
const leader = this.columnLeaders_[x];
appendTo(
columns,
column
.filter(cell => cell && cell !== leader)
.map(cell => {
const field = HORIZONTAL_ALIGNMENTS[cell.horizontalAlignment];
return eq(leader[field], cell[field]);
})
);
}
// Constraints between rows
const betweenRows = [];
for (let y = 1; y < this.rows_.length; y++) {
betweenRows.push(
eq(
expression(this.rowLeaders_[y].topEdge).minus(
this.rowLeaders_[y - 1].bottomEdge
),
this.spacing_.y
)
);
}
// Constraints between columns
const betweenColumns = [];
for (let x = 1; x < this.columnCount_; x++) {
betweenColumns.push(
eq(
expression(this.columnLeaders_[x].leftEdge).minus(
this.columnLeaders_[x - 1].rightEdge
),
this.spacing_.x
)
);
}
return equalFontSizes.concat(
positiveFontSize,
rows,
columns,
betweenRows,
betweenColumns
);
}
createCell_(string, position) {
if (string == null) {
return undefined;
}
return new Cell(
this,
string,
this.attributes_,
position,
this.defaultAlignment_.horizontal,
this.defaultAlignment_.vertical
);
}
setColumnCount_(count) {
if (count === this.columnCount_) {
return;
}
for (const row of this.rows_) {
row.length = count;
}
this.columnCount_ = count;
}
updateLeaders(position) {
// this assumes that all cells have an equal font size
// TODO: support the case when a cell can have different font size
if (!this.rows_.length) {
return;
}
if (position === undefined) {
position = {};
}
const { x, y } = position;
if (x !== undefined && y !== undefined) {
// The cell itself has changed
const cell = this.getCell(x, y);
this.tryUpdatingRowLeader_(y, cell);
this.tryUpdatingColumnLeader_(x, cell);
} else if (x !== undefined) {
// The entire column has changed
const column = this.column(x);
this.columnLeaders_[x] = findColumnLeader(column);
for (let y = 0; y < this.rows_.length; y++) {
this.tryUpdatingRowLeader_(y, column[y]);
}
} else if (y !== undefined) {
// The entire row has changed
const row = this.rows_[y];
this.rowLeaders_[y] = findRowLeader(row);
for (let x = 0; x < this.columnCount_; x++) {
this.tryUpdatingColumnLeader_(x, row[x]);
}
} else {
// The entire table has changed
for (let y = 0; y < this.rows_.length; y++) {
this.rowLeaders_[y] = findRowLeader(this.rows_[y]);
}
for (let x = 0; x < this.columnCount_; x++) {
this.columnLeaders_[x] = findColumnLeader(this.column(x));
}
}
this.updateExpressions_();
}
tryUpdatingRowLeader_(index, cell) {
if (!cell) {
return;
}
if (!this.rowLeaders_[index]) {
// There's no leader yet
this.rowLeaders_[index] = cell;
return;
}
if (this.rowLeaders_[index] === cell) {
// The ratio might be invalid, find a new leader just in case
this.rowLeaders_[index] = findRowLeader(this.rows_[index]);
return;
}
if (this.rowLeaders_[index].ratios.height < cell.ratios.height) {
// This cell is bigger than the leader
this.rowLeaders_[index] = cell;
}
}
tryUpdatingColumnLeader_(index, cell) {
if (!cell) {
return;
}
if (!this.columnLeaders_[index]) {
// There's no leader yet
this.columnLeaders_[index] = cell;
return;
}
if (this.columnLeaders_[index] === cell) {
// The ratio might be invalid, find a new leader just in case
this.columnLeaders_[index] = findColumnLeader(this.column(index));
return;
}
if (this.columnLeaders_[index].ratios.width < cell.ratios.width) {
// This cell is bigger than the leader
this.columnLeaders_[index] = cell;
}
}
updateExpressions_() {
if (!this.rows_.length) {
this.fontSize = null;
this.leftEdge = null;
this.topEdge = null;
this.rightEdge = null;
this.bottomEdge = null;
this.x = null;
this.y = null;
this.width = null;
this.height = null;
this.centerX = null;
this.centerY = null;
return;
}
const topMostCell = this.rowLeaders_.find(identity);
const bottomMostCell = findLast(this.rowLeaders_, identity);
const leftMostCell = this.columnLeaders_.find(identity);
const rightMostCell = findLast(this.columnLeaders_, identity);
const firstCell = this.cells().next().value;
this.fontSize = expression(firstCell.fontSize);
this.leftEdge = expression(leftMostCell.leftEdge);
this.topEdge = expression(topMostCell.topEdge);
this.rightEdge = expression(rightMostCell.rightEdge);
this.bottomEdge = expression(bottomMostCell.bottomEdge);
this.x = this.leftEdge;
this.y = this.topEdge;
this.width = this.rightEdge.minus(this.leftEdge);
this.height = this.bottomEdge.minus(this.topEdge);
this.centerX = this.leftEdge.plus(this.rightEdge).divide(2);
this.centerY = this.topEdge.plus(this.bottomEdge).divide(2);
}
}