@teachinglab/omd
Version:
omd
542 lines (448 loc) • 20.3 kB
JavaScript
import { omdColor } from "./omdColor.js";
import { jsvgGroup, jsvgRect, jsvgClipMask, jsvgLine, jsvgEllipse, jsvgTextBox, jsvgTextLine, jsvgPath } from "@teachinglab/jsvg";
import {
omdRightTriangle,
omdIsoscelesTriangle,
omdRectangle,
omdEllipse,
omdCircle,
omdRegularPolygon
} from "./omdShapes.js";
export class omdCoordinatePlane extends jsvgGroup {
constructor() {
super();
this.graphEquations = [];
this.lineSegments = [];
this.dotValues = [];
this.shapeSet = [];
this.xMin = -5;
this.xMax = 5;
this.yMin = -5;
this.yMax = 5;
this.xLabel = "";
this.yLabel = "";
this.axisLabelOffsetPx = 20; //offset from axes
this.size = "medium";
this.tickInterval = 1; //interval between ticks
this.forceAllTickLabels = true; // show all tick labels despite potential overlap
this.tickLabelOffsetPx = 5; //offset from axes
this.showTickLabels = true; // show numeric tick labels
// Background customization properties
this.backgroundColor = omdColor.lightGray;
this.backgroundCornerRadius = 15;
this.backgroundOpacity = 1.0;
this.showBackground = true;
this.calculatePadding();
this.updateLayout();
}
loadFromJSON(data) {
// Assume valid JSON shape
this.graphEquations = data.graphEquations || [];
this.lineSegments = data.lineSegments || [];
this.dotValues = data.dotValues || [];
this.shapeSet = data.shapeSet || [];
this.xMin = (data.xMin !== undefined) ? data.xMin : this.xMin;
this.xMax = (data.xMax !== undefined) ? data.xMax : this.xMax;
this.yMin = (data.yMin !== undefined) ? data.yMin : this.yMin;
this.yMax = (data.yMax !== undefined) ? data.yMax : this.yMax;
this.xLabel = (data.xLabel !== undefined) ? data.xLabel : this.xLabel;
this.yLabel = (data.yLabel !== undefined) ? data.yLabel : this.yLabel;
this.axisLabelOffsetPx = (data.axisLabelOffsetPx !== undefined) ? data.axisLabelOffsetPx : this.axisLabelOffsetPx;
this.size = (data.size !== undefined) ? data.size : this.size;
this.tickInterval = (data.tickInterval !== undefined) ? data.tickInterval : this.tickInterval;
this.tickLabelOffsetPx = (data.tickLabelOffsetPx !== undefined) ? data.tickLabelOffsetPx : this.tickLabelOffsetPx;
this.forceAllTickLabels = (data.forceAllTickLabels !== undefined) ? data.forceAllTickLabels : this.forceAllTickLabels;
this.showTickLabels = (data.showTickLabels !== undefined) ? data.showTickLabels : this.showTickLabels;
// Load background customization properties
this.backgroundColor = data.backgroundColor || this.backgroundColor;
this.backgroundCornerRadius = (data.backgroundCornerRadius !== undefined) ? data.backgroundCornerRadius : this.backgroundCornerRadius;
this.backgroundOpacity = (data.backgroundOpacity !== undefined) ? data.backgroundOpacity : this.backgroundOpacity;
this.showBackground = (data.showBackground !== undefined) ? data.showBackground : this.showBackground;
this.calculatePadding();
this.updateLayout();
}
updateLayout() {
this.removeAllChildren();
this.calculateGraphDimensions();
const outerWidth = this.width;
const outerHeight = this.height;
const clipMask = new jsvgClipMask(outerWidth, outerHeight, 15);
this.addChild(clipMask);
const whiteRect = new jsvgRect();
whiteRect.setWidthAndHeight(outerWidth, outerHeight);
// Apply customizable background properties
if (this.showBackground) {
whiteRect.setFillColor(this.backgroundColor);
if (this.backgroundOpacity < 1.0) {
whiteRect.setOpacity(this.backgroundOpacity);
}
} else {
whiteRect.setFillColor("transparent");
}
whiteRect.setStrokeWidth(0);
if (whiteRect.setCornerRadius && this.backgroundCornerRadius > 0) {
whiteRect.setCornerRadius(this.backgroundCornerRadius);
}
clipMask.addChild(whiteRect);
const contentGroup = new jsvgGroup();
contentGroup.setPosition(0, 0);
clipMask.addChild(contentGroup);
this.setWidthAndHeight(outerWidth, outerHeight);
this.svgObject.setAttribute("viewBox", `0 0 ${outerWidth} ${outerHeight}`);
const gridHolder = new jsvgGroup();
contentGroup.addChild(gridHolder);
this.makeGrid(gridHolder);
const graphClipMask = new jsvgClipMask(this.graphWidth, this.graphHeight, 0);
graphClipMask.setPosition(this.paddingLeft, this.paddingTop);
contentGroup.addChild(graphClipMask);
// Traditional explicit calls instead of dynamic invocation
const functionsHolder = new jsvgGroup();
graphClipMask.addChild(functionsHolder);
this.graphMultipleFunctions(functionsHolder);
const segmentsHolder = new jsvgGroup();
graphClipMask.addChild(segmentsHolder);
this.drawLineSegments(segmentsHolder);
const dotsHolder = new jsvgGroup();
graphClipMask.addChild(dotsHolder);
this.plotDots(dotsHolder);
const shapesHolder = new jsvgGroup();
graphClipMask.addChild(shapesHolder);
this.addShapes(shapesHolder);
}
createAxisGrid(gridHolder, isXAxis) {
const interval = this.tickInterval || 1;
const min = isXAxis ? this.xMin : this.yMin;
const max = isXAxis ? this.xMax : this.yMax;
const firstTick = Math.ceil(min);
const lastTick = Math.floor(max);
const minLabelSpacing = 10;
const labelWidthEstimate = 20; // matches createNumericLabel width
const requiredLabelSpacing = minLabelSpacing + labelWidthEstimate;
// Compute label interval for label display only
let labelInterval = interval;
if (!this.forceAllTickLabels) {
const span = isXAxis ? this.xMax - this.xMin : this.yMax - this.yMin;
let floored = Math.floor(span / 10) * 10;
let raw = floored / 6;
let rounded = Math.round(raw / 5) * 5;
if (rounded === 0) rounded = 1;
labelInterval = rounded;
}
let lastLabelPos = -Infinity;
let zeroLineDrawn = false;
for (let value = firstTick; value <= lastTick; value += interval) {
const pos = this.computeAxisPos(isXAxis, value);
const line = new jsvgLine();
this.setAxisLineEndpoints(isXAxis, line, pos);
line.setStrokeColor("black");
line.setOpacity(value === 0 ? 1.0 : 0.15);
gridHolder.addChild(line);
if (value === 0) {
zeroLineDrawn = true;
}
let shouldShowLabel = false;
if (this.showTickLabels) {
if (this.forceAllTickLabels) {
shouldShowLabel = true;
} else {
// Only show label if value is a multiple of labelInterval
const eps = 1e-9;
const isMultiple = (v, candidate) => Math.abs(v / candidate - Math.round(v / candidate)) < eps;
shouldShowLabel = isMultiple(value, labelInterval);
}
}
if (shouldShowLabel) {
this.addTickLabel(gridHolder, isXAxis, pos, value);
// store the actual clamped center used by addTickLabel
if (isXAxis) {
const center = Math.max(20, Math.min(pos, this.width - 20));
lastLabelPos = center;
} else {
const center = Math.max(15, Math.min(pos, this.height - 7.5));
lastLabelPos = center;
}
}
}
if (0 >= min && 0 <= max && !zeroLineDrawn) {
const zeroPos = this.computeAxisPos(isXAxis, 0);
const zeroLine = new jsvgLine();
this.setAxisLineEndpoints(isXAxis, zeroLine, zeroPos);
zeroLine.setStrokeColor("black");
zeroLine.setOpacity(1.0);
gridHolder.addChild(zeroLine);
}
}
createAxisLabels(gridHolder) {
if (this.xLabel) {
const w = Math.max(60, this.xLabel.length * 10);
const h = 20;
const xAxisLabel = new jsvgTextBox();
xAxisLabel.setWidthAndHeight(w, h);
xAxisLabel.setText(this.xLabel);
xAxisLabel.setFontSize(14);
xAxisLabel.setFontColor("black");
xAxisLabel.setAlignment("center");
xAxisLabel.setVerticalCentering();
xAxisLabel.setFontWeight(600);
const idealX = this.paddingLeft + this.graphWidth / 2 - w / 2;
const lx = Math.max(5, Math.min(idealX, this.width - w - 5));
xAxisLabel.setPosition(lx, this.height - this.paddingBottom + this.axisLabelOffsetPx);
gridHolder.addChild(xAxisLabel);
}
if (this.yLabel) {
const fontSize = 14;
const finalX = Math.max(15, this.paddingLeft - (this.axisLabelOffsetPx + 10));
const finalY = this.paddingTop + this.graphHeight / 2;
const labelGroup = new jsvgGroup();
const yAxisLabel = new jsvgTextLine();
yAxisLabel.setText(this.yLabel);
yAxisLabel.setFontSize(fontSize);
yAxisLabel.setFontColor("black");
yAxisLabel.setPosition(0, 0);
yAxisLabel.svgObject.setAttribute("text-anchor", "middle");
yAxisLabel.svgObject.setAttribute("dominant-baseline", "middle");
yAxisLabel.setFontWeight(600);
labelGroup.addChild(yAxisLabel);
labelGroup.svgObject.setAttribute("transform", `translate(${finalX}, ${finalY}) rotate(-90)`);
gridHolder.addChild(labelGroup);
}
}
makeGrid(gridHolder) {
this.createAxisGrid(gridHolder, true);
this.createAxisGrid(gridHolder, false);
this.createAxisLabels(gridHolder);
}
// ===== Helper functions =====
calculatePadding() {
this.paddingLeft = this.yLabel ? 50 : 30;
this.paddingBottom = this.xLabel ? 50 : 30;
this.paddingTop = 25;
this.paddingRight = 25;
}
computeAxisPos(isXAxis, value) {
return isXAxis
? this.paddingLeft + (value - this.xMin) * this.xSpacer
: this.height - this.paddingBottom - (value - this.yMin) * this.ySpacer;
}
setAxisLineEndpoints(isXAxis, line, pos) {
if (isXAxis) {
line.setEndpoints(pos, this.paddingTop, pos, this.height - this.paddingBottom);
} else {
line.setEndpoints(this.paddingLeft, pos, this.width - this.paddingRight, pos);
}
}
addTickLabel(gridHolder, isXAxis, pos, value) {
const label = this.createNumericLabel(value);
if (isXAxis) {
const lx = Math.max(10, Math.min(pos - 10, this.width - 30));
label.setPosition(lx, this.height - this.paddingBottom + this.tickLabelOffsetPx);
} else {
const lx = Math.max(5, this.paddingLeft - (this.tickLabelOffsetPx + 20));
const ly = Math.max(7.5, Math.min(pos - 7.5, this.height - 15));
label.setPosition(lx, ly);
}
gridHolder.addChild(label);
}
toGraphPixelX(x) {
return (x - this.xMin) * this.xSpacer;
}
toGraphPixelY(y) {
return this.graphHeight - (y - this.yMin) * this.ySpacer;
}
createNumericLabel(text) {
const label = new jsvgTextBox();
label.setWidthAndHeight(20, 15);
label.setText(String(text));
label.setFontSize(9);
label.setFontColor(this.getAllowedColor("black"));
label.setAlignment("center");
label.setVerticalCentering();
return label;
}
getAllowedColor(inputColor) {
if (!inputColor) return "black";
const k = String(inputColor).trim();
return omdColor[k.toLowerCase()] || k || "black"; //first try omdColor, then try string, then black
}
assignFromData(data, mappings) {
for (const [key, type] of Object.entries(mappings)) {
if (typeof data[key] === type) this[key] = data[key];
}
}
calculateGraphDimensions() {
let baseSize = 200;
let dotSize = 8;
if (this.size === "large") {
baseSize = 300;
dotSize = 12;
} else if (this.size === "small") {
baseSize = 100;
dotSize = 6;
}
this.dotSize = dotSize;
this.xSpan = this.xMax - this.xMin;
this.ySpan = this.yMax - this.yMin;
const unit = Math.min(baseSize / this.xSpan, baseSize / this.ySpan);
this.graphWidth = unit * this.xSpan;
this.graphHeight = unit * this.ySpan;
this.width = this.graphWidth + this.paddingLeft + this.paddingRight;
this.height = this.graphHeight + this.paddingTop + this.paddingBottom;
this.xSpacer = unit;
this.ySpacer = unit;
this.originX = this.paddingLeft - this.xMin * this.xSpacer;
this.originY = this.height - this.paddingBottom + this.yMin * this.ySpacer;
}
graphMultipleFunctions(holder) {
for (const functionConfig of this.graphEquations) {
const path = new jsvgPath();
path.setStrokeColor(this.getAllowedColor(functionConfig.color));
path.setStrokeWidth(functionConfig.strokeWidth);
this.graphFunctionWithDomain(path, functionConfig.equation, functionConfig.domain);
holder.addChild(path);
if (functionConfig.label) {
this.addFunctionLabel(holder, functionConfig);
}
}
}
graphFunctionWithDomain(pathObject, functionString, domain) {
pathObject.clearPoints();
let expression = functionString;
if (expression.toLowerCase().startsWith("y=")) {
expression = expression.substring(2).trim();
}
const compiledExpression = math.compile(expression);
const leftLimit = (domain && typeof domain.min === 'number') ? domain.min : this.xMin;
const rightLimit = (domain && typeof domain.max === 'number') ? domain.max : this.xMax;
const step = Math.abs(rightLimit - leftLimit) / 1000;
for (let x = leftLimit; x <= rightLimit; x += step) {
const y = compiledExpression.evaluate({ x });
pathObject.addPoint(this.toGraphPixelX(x), this.toGraphPixelY(y));
}
pathObject.updatePath();
}
addFunctionLabel(holder, functionConfig) {
const labelText = String(functionConfig.label);
const fontSize = 12;
const padding = 6;
const estimatedWidth = Math.max(40, labelText.length * (fontSize * 0.6));
const estimatedHeight = fontSize + padding;
const label = new jsvgTextBox();
label.setWidthAndHeight(estimatedWidth, estimatedHeight);
label.setText(labelText);
label.setFontSize(fontSize);
label.setFontColor(this.getAllowedColor(functionConfig.color || "black"));
label.setAlignment("center");
label.setVerticalCentering();
label.setFontWeight(500);
const anchorX = (typeof functionConfig.labelAtX === 'number')
? functionConfig.labelAtX
: this.xMin + (this.xMax - this.xMin) * 0.1;
let anchorY = this.yMin + (this.yMax - this.yMin) * 0.1;
let equationBody = functionConfig.equation;
if (equationBody.toLowerCase().startsWith("y=")) {
equationBody = equationBody.substring(2).trim();
}
const compiled = math.compile(equationBody);
const yVal = compiled.evaluate({ x: anchorX });
anchorY = yVal;
const xPx = this.toGraphPixelX(anchorX);
const yPx = this.toGraphPixelY(anchorY);
const positionPref = (functionConfig.labelPosition || 'below').toLowerCase();
const offset = 10;
let finalX = xPx;
let finalY = yPx;
switch (positionPref) {
case 'above':
finalY = yPx - offset;
break;
case 'below':
finalY = yPx + offset;
break;
case 'left':
finalX = xPx - offset;
break;
case 'right':
finalX = xPx + offset;
break;
default:
finalY = yPx + offset;
}
label.setPosition(finalX, finalY);
holder.addChild(label);
}
drawLineSegments(holder) {
for (const segment of this.lineSegments) {
const x1 = this.toGraphPixelX(segment.point1[0]);
const y1 = this.toGraphPixelY(segment.point1[1]);
const x2 = this.toGraphPixelX(segment.point2[0]);
const y2 = this.toGraphPixelY(segment.point2[1]);
const line = new jsvgLine();
line.setEndpoints(x1, y1, x2, y2);
line.setStrokeColor(this.getAllowedColor(segment.color || "blue"));
line.setStrokeWidth(segment.strokeWidth || 2);
holder.addChild(line);
}
}
plotDots(holder) {
for (const dot of this.dotValues) {
const pX = this.toGraphPixelX(dot[0]);
const pY = this.toGraphPixelY(dot[1]);
const ellipse = new jsvgEllipse();
ellipse.setWidthAndHeight(this.dotSize, this.dotSize);
ellipse.setPosition(pX, pY);
ellipse.setFillColor(this.getAllowedColor(dot.length >= 3 ? dot[2] : "black"));
holder.addChild(ellipse);
}
}
addShapes(holder) {
holder.setPosition(
-this.xMin * this.xSpacer,
this.graphHeight + this.yMin * this.ySpacer
);
const typeToCtor = {
rightTriangle: omdRightTriangle,
isoscelesTriangle: omdIsoscelesTriangle,
rectangle: omdRectangle,
ellipse: omdEllipse,
circle: omdCircle,
regularPolygon: omdRegularPolygon
};
for (const shapeData of this.shapeSet) {
const Ctor = typeToCtor[shapeData.omdType];
if (!Ctor) {
continue;
}
shapeData.unitScale = this.xSpacer;
const shape = new Ctor();
shape.loadFromJSON(shapeData);
if (shape.shapePath) {
shape.shapePath.setFillColor("none");
}
holder.addChild(shape);
}
}
// Background customization methods
setBackgroundColor(color) {
this.backgroundColor = color;
this.updateLayout();
}
setBackgroundCornerRadius(radius) {
this.backgroundCornerRadius = radius;
this.updateLayout();
}
setBackgroundOpacity(opacity) {
this.backgroundOpacity = Math.max(0, Math.min(1, opacity));
this.updateLayout();
}
setShowBackground(show) {
this.showBackground = show;
this.updateLayout();
}
setBackgroundStyle(options = {}) {
if (options.backgroundColor !== undefined) this.backgroundColor = options.backgroundColor;
if (options.cornerRadius !== undefined) this.backgroundCornerRadius = options.cornerRadius;
if (options.opacity !== undefined) this.backgroundOpacity = Math.max(0, Math.min(1, options.opacity));
if (options.show !== undefined) this.showBackground = options.show;
this.updateLayout();
}
}