@teachinglab/omd
Version:
omd
369 lines (320 loc) • 13.7 kB
JavaScript
import { omdColor } from "./omdColor.js";
import { jsvgGroup, jsvgRect, jsvgTextBox } from "@teachinglab/jsvg";
export class omdTable extends jsvgGroup
{
constructor()
{
// initialization
super();
this.type = "omdTable";
this.equation = "";
this.data = [];
this.headers = ['x', 'y'];
this.xMin = -5;
this.xMax = 5;
this.stepSize = 1;
this.title = "";
this.fontSize = 14;
this.headerFontSize = 16;
this.fontFamily = "Albert Sans";
this.headerFontFamily = "Albert Sans";
this.cellHeight = 35;
this.headerHeight = 40;
this.minCellWidth = 80;
this.maxCellWidth = 300;
this.padding = 10;
this.updateLayout();
}
// Estimate title width in pixels based on font size and text length
estimateTitleWidth()
{
if (!this.title || this.title.length === 0) return 0;
const titleFontSize = this.headerFontSize + 2;
const padding = 40; // side padding inside the title text box
const minWidth = 200;
const estimated = Math.round(this.title.length * (titleFontSize * 0.6)) + padding;
return Math.max(minWidth, estimated);
}
loadFromJSON( data )
{
if ( typeof data.equation != "undefined" )
this.equation = data.equation;
if ( typeof data.data != "undefined" )
this.data = data.data;
if ( typeof data.headers != "undefined" )
this.headers = data.headers;
if ( typeof data.xMin != "undefined" )
this.xMin = data.xMin;
if ( typeof data.xMax != "undefined" )
this.xMax = data.xMax;
if ( typeof data.stepSize != "undefined" )
this.stepSize = data.stepSize;
if ( typeof data.title != "undefined" )
this.title = data.title;
if ( typeof data.fontSize != "undefined" )
this.fontSize = data.fontSize;
if ( typeof data.headerFontSize != "undefined" )
this.headerFontSize = data.headerFontSize;
if ( typeof data.fontFamily != "undefined" )
this.fontFamily = data.fontFamily;
if ( typeof data.headerFontFamily != "undefined" )
this.headerFontFamily = data.headerFontFamily;
if ( typeof data.cellHeight != "undefined" )
this.cellHeight = data.cellHeight;
if ( typeof data.headerHeight != "undefined" )
this.headerHeight = data.headerHeight;
if ( typeof data.minCellWidth != "undefined" )
this.minCellWidth = data.minCellWidth;
if ( typeof data.maxCellWidth != "undefined" )
this.maxCellWidth = data.maxCellWidth;
if ( typeof data.padding != "undefined" )
this.padding = data.padding;
this.updateLayout();
}
setEquation( equation )
{
this.equation = equation;
this.updateLayout();
}
setData( data, headers )
{
this.data = data;
if ( headers )
this.headers = headers;
this.updateLayout();
}
calculateOptimalCellWidth(columnIndex)
{
let maxLength = (this.headers[columnIndex] ?? '').toString().length;
// Assume rows are arrays aligned with headers
for (let row of this.data) {
const cellValue = row[columnIndex];
if (cellValue !== null && cellValue !== undefined) {
maxLength = Math.max(maxLength, cellValue.toString().length);
}
}
// Estimate width based on character count (approximate 8 pixels per character)
const estimatedWidth = Math.max(maxLength * 8 + this.padding * 2, this.minCellWidth);
return Math.min(estimatedWidth, this.maxCellWidth);
}
generateDataFromEquation()
{
if (!this.equation || this.equation.trim().length === 0) return;
// Clear existing data and set headers
this.data = [];
this.headers = ['x', 'y'];
// Basic normalization for inline math
let expression = this.equation;
if (expression.toLowerCase().startsWith('y=')) {
expression = expression.substring(2).trim();
}
expression = expression
.replace(/(\d)([a-z])/gi, '$1*$2')
.replace(/([a-z])(\d)/gi, '$1*$2')
.replace(/\^/g, '**');
const evaluateExpression = new Function('x', `return ${expression};`);
for (let x = this.xMin; x <= this.xMax; x += this.stepSize) {
let y = evaluateExpression(x);
y = Math.round(y * 100) / 100;
this.data.push([x, y]);
}
}
updateLayout()
{
this.removeAllChildren();
// If an equation is provided, generate data before measuring/layout
if (this.equation && this.equation.length > 0) {
this.generateDataFromEquation();
}
// Calculate table dimensions
const numCols = this.headers.length;
const numRows = this.data.length;
let cellWidths = [];
let totalWidth = 0;
for (let col = 0; col < numCols; col++) {
const width = this.calculateOptimalCellWidth(col);
cellWidths.push(width);
totalWidth += width;
}
this.width = totalWidth;
const titleOffset = (this.title && this.title.length > 0) ? 30 : 0;
const bodyHeight = this.headerHeight + numRows * this.cellHeight;
const totalHeight = titleOffset + bodyHeight;
this.height = totalHeight;
// Compute a display width that ensures the title is not clipped,
// without changing column widths or table background.
const titleBoxWidth = this.estimateTitleWidth();
const displayWidth = Math.max(this.width, titleBoxWidth);
// Table background with corner radius (all four corners, covers full height)
const tableBg = new jsvgRect();
tableBg.setWidthAndHeight(this.width, bodyHeight);
tableBg.setFillColor(omdColor.lightGray);
tableBg.setCornerRadius(15);
tableBg.setStrokeWidth(0);
const contentOffsetX = Math.max(0, (displayWidth - this.width) / 2);
tableBg.setPosition(contentOffsetX, titleOffset);
this.addChild(tableBg);
// Draw a rounded footer rectangle under the last row to provide bottom rounded corners
if (numRows > 0) {
const footer = new jsvgRect();
footer.setWidthAndHeight(this.width, this.cellHeight);
footer.setFillColor(omdColor.lightGray);
footer.setCornerRadius(15);
footer.setStrokeWidth(0);
const footerY = titleOffset + this.headerHeight + (numRows - 1) * this.cellHeight;
footer.setPosition(contentOffsetX, footerY);
this.addChild(footer);
}
// Generate data from equation if provided; otherwise assume valid data/headers
if (this.equation && this.equation.length > 0) {
this.generateDataFromEquation();
}
let currentY = 0;
// Add title if provided
if (this.title && this.title.length > 0) {
var titleText = new jsvgTextBox();
// Use an expanded text box width (only for title) to avoid clipping
titleText.setWidthAndHeight(titleBoxWidth, 25);
titleText.setText(this.title);
titleText.setFontFamily(this.headerFontFamily);
titleText.setFontColor("black");
titleText.setFontSize(this.headerFontSize + 2);
titleText.setAlignment("center");
titleText.setVerticalCentering();
// Center the title within the display width (table is centered within display)
const titleX = Math.max(0, (displayWidth - titleBoxWidth) / 2);
titleText.setPosition(titleX, currentY);
titleText.setFontWeight(600);
this.addChild(titleText);
currentY += 30;
}
// Create header row (lightGray, no border, no rounded corners for cells)
let currentX = 0;
for (let col = 0; col < numCols; col++) {
const cellWidth = cellWidths[col];
var headerRect = new jsvgRect();
headerRect.setWidthAndHeight(cellWidth, this.headerHeight);
headerRect.setFillColor(omdColor.lightGray);
// Use rx/ry for top corners only on first and last header cells
if (col === 0 && numCols === 1) {
headerRect.setCornerRadius(15); // single column, round all corners
} else if (col === 0) {
headerRect.setCornerRadius(15); // round top-left
} else if (col === numCols - 1) {
headerRect.setCornerRadius(15); // round top-right
} else {
headerRect.setCornerRadius(0);
}
headerRect.setStrokeWidth(0);
headerRect.setPosition(currentX + contentOffsetX, currentY);
this.addChild(headerRect);
const headerText = this.createHeaderTextBox(
cellWidth,
this.headerHeight,
this.headers[col] || `Col ${col + 1}`
);
headerText.setPosition(currentX + contentOffsetX, currentY);
this.addChild(headerText);
currentX += cellWidth;
}
currentY += this.headerHeight;
// Create data rows with alternating colors
for (let row = 0; row < numRows; row++) {
const rowData = this.data[row];
let currentX = 0;
// Alternating bar: odd rows white 50% opacity, even rows transparent
if (row % 2 === 0) {
var barRect = new jsvgRect();
barRect.setWidthAndHeight(this.width, this.cellHeight);
barRect.setFillColor("rgba(255,255,255,0.5)");
// Round the bottom corners on the last row to respect the table background rounding
if (row === numRows - 1) {
barRect.setCornerRadius(15);
} else {
barRect.setCornerRadius(0);
}
barRect.setStrokeWidth(0);
barRect.setPosition(contentOffsetX, currentY);
this.addChild(barRect);
}
for (let col = 0; col < numCols; col++) {
const cellWidth = cellWidths[col];
const cellText = this.createBodyTextBox(cellWidth, this.cellHeight, "");
const cellValue = rowData[col];
cellText.setText((cellValue ?? '').toString());
cellText.setPosition(currentX + contentOffsetX, currentY);
this.addChild(cellText);
currentX += cellWidth;
}
currentY += this.cellHeight;
}
// Draw vertical dividing lines at each column boundary (except the far right edge)
if (numCols > 1) {
let x = 0;
for (let col = 0; col < numCols - 1; col++) {
x += cellWidths[col];
const vline = new jsvgRect();
vline.setWidthAndHeight(1, Math.max(0, bodyHeight - 1));
vline.setFillColor("black");
vline.setCornerRadius(0);
vline.setOpacity(0.5);
vline.setStrokeWidth(0);
vline.setPosition(x + contentOffsetX, titleOffset);
this.addChild(vline);
}
}
// Use displayWidth for the viewBox so the title never clips,
// but keep the table background at the original table width
this.setWidthAndHeight(displayWidth, totalHeight);
this.svgObject.setAttribute("viewBox", `0 0 ${displayWidth} ${totalHeight}`);
}
// ===== Helpers for consistent styling (match other components) =====
createHeaderTextBox(width, height, text) {
const tb = new jsvgTextBox();
tb.setWidthAndHeight(width, height);
tb.setText(text);
tb.setFontFamily(this.headerFontFamily);
tb.setFontColor("black");
tb.setFontSize(this.headerFontSize);
tb.setAlignment("center");
tb.setVerticalCentering();
tb.setFontWeight(600);
return tb;
}
createBodyTextBox(width, height, text) {
const tb = new jsvgTextBox();
tb.setWidthAndHeight(width, height);
tb.setText(text);
tb.setFontFamily(this.fontFamily);
tb.setFontColor("black");
tb.setFontSize(this.fontSize);
tb.setAlignment("center");
tb.setVerticalCentering();
tb.setFontWeight(400);
return tb;
}
addRow( rowData )
{
this.data.push( rowData );
this.updateLayout();
}
setHeaders( headers )
{
this.headers = headers;
this.updateLayout();
}
setFont( fontFamily, headerFontFamily )
{
this.fontFamily = fontFamily;
if ( headerFontFamily )
this.headerFontFamily = headerFontFamily;
else
this.headerFontFamily = fontFamily;
this.updateLayout();
}
clearData()
{
this.data = [];
this.updateLayout();
}
}