From ad1f1fb055efbf69daed28eca3a74e0153f0c51e Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Fri, 7 Mar 2025 12:37:23 -0600 Subject: [PATCH 1/3] Switch to a better extension system for the GraphTool. For JavaScript instead of piecing together the parts of the class definition, use a function that returns the class definition directly. This has the advantage of being able to use `super` directly to access methods of the parent class, and is just much easier to work with. All of the graph objects and graph tools are now in separate files (except the SelectTool and SolidDashTool which are core functionality). For Perl, all graph objects are packages that derive from the GraphTool::GraphObject package instead of the previous method of defining cmp and tikz subroutines in a hash. A mapping from the string identifier to the package is maintained and used to construct the correct perl object for a graph tool object. There is a compatibility layer for objects added the old way. --- htdocs/js/GraphTool/circletool.js | 205 ++ htdocs/js/GraphTool/cubictool.js | 468 ++-- htdocs/js/GraphTool/filltool.js | 388 +++ htdocs/js/GraphTool/graphtool.js | 1127 +------- htdocs/js/GraphTool/intervaltools.js | 1091 ++++---- htdocs/js/GraphTool/linetool.js | 205 ++ htdocs/js/GraphTool/parabolatool.js | 270 ++ htdocs/js/GraphTool/pointtool.js | 296 +- htdocs/js/GraphTool/quadratictool.js | 344 ++- htdocs/js/GraphTool/quadrilateral.js | 617 ++-- htdocs/js/GraphTool/segments.js | 290 +- htdocs/js/GraphTool/sinewavetool.js | 435 ++- htdocs/js/GraphTool/triangle.js | 513 ++-- macros/graph/parserGraphTool.pl | 3859 ++++++++++++-------------- 14 files changed, 4838 insertions(+), 5270 deletions(-) create mode 100644 htdocs/js/GraphTool/circletool.js create mode 100644 htdocs/js/GraphTool/filltool.js create mode 100644 htdocs/js/GraphTool/linetool.js create mode 100644 htdocs/js/GraphTool/parabolatool.js diff --git a/htdocs/js/GraphTool/circletool.js b/htdocs/js/GraphTool/circletool.js new file mode 100644 index 000000000..cdd2090d3 --- /dev/null +++ b/htdocs/js/GraphTool/circletool.js @@ -0,0 +1,205 @@ +/* global graphTool, JXG */ + +'use strict'; + +(() => { + if (graphTool && graphTool.circleTool) return; + + graphTool.circleTool = { + Circle(gt) { + return class Circle extends gt.GraphObject { + static strId = 'circle'; + + constructor(center, point, solid) { + super( + gt.board.create('circle', [center, point], { + fixed: true, + highlight: false, + strokeColor: gt.color.curve, + dash: solid ? 0 : 2 + }) + ); + this.definingPts.push(center, point); + this.focusPoint = center; + + // Redefine the circle's hasPoint method to return true if the center point has the given + // coordinates, so that a pointer over the center point will give focus to the object with the + // center point activated. + const circleHasPoint = this.baseObj.hasPoint.bind(this.baseObj); + this.baseObj.hasPoint = (x, y) => circleHasPoint(x, y) || center.hasPoint(x, y); + } + + stringify() { + return [ + this.constructor.strId, + this.baseObj.getAttribute('dash') == 0 ? 'solid' : 'dashed', + ...this.definingPts.map( + (point) => + `(${gt.snapRound(point.X(), gt.snapSizeX)},${gt.snapRound(point.Y(), gt.snapSizeY)})` + ) + ].join(','); + } + + fillCmp(point) { + return gt.sign( + this.baseObj.stdform[3] * (point[1] * point[1] + point[2] * point[2]) + + JXG.Math.innerProduct(point, this.baseObj.stdform) + ); + } + + static restore(string) { + let pointData = gt.pointRegexp.exec(string); + const points = []; + while (pointData) { + points.push(pointData.slice(1, 3)); + pointData = gt.pointRegexp.exec(string); + } + if (points.length < 2) return false; + const center = gt.createPoint(parseFloat(points[0][0]), parseFloat(points[0][1])); + const point = gt.createPoint(parseFloat(points[1][0]), parseFloat(points[1][1]), center); + return new this(center, point, /solid/.test(string)); + } + }; + }, + + CircleTool(gt) { + return class CirlceTool extends gt.GenericTool { + object = 'circle'; + useStandardActivation = true; + activationHelpText = 'Plot the center of the circle.'; + useStandardDeactivation = true; + constructionObjects = ['center']; + + constructor(container, iconName, tooltip) { + super(container, iconName ?? 'circle', tooltip ?? 'Circle Tool: Graph a circle.'); + this.supportsSolidDash = true; + } + + handleKeyEvent(e) { + if (!this.hlObjs.hl_point || !gt.board.containerObj.contains(document.activeElement)) return; + + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + + if (this.center) this.phase2(this.hlObjs.hl_point.coords.usrCoords); + else this.phase1(this.hlObjs.hl_point.coords.usrCoords); + } + } + + updateHighlights(e) { + this.hlObjs.hl_circle?.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); + this.hlObjs.hl_point?.rendNode.focus(); + + let coords; + if (e instanceof MouseEvent && e.type === 'pointermove') { + coords = gt.getMouseCoords(e); + this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [ + coords.usrCoords[1], + coords.usrCoords[2] + ]); + } else if (e instanceof KeyboardEvent && e.type === 'keydown') { + coords = this.hlObjs.hl_point.coords; + } else if (e instanceof JXG.Coords) { + coords = e; + this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [ + coords.usrCoords[1], + coords.usrCoords[2] + ]); + } else return false; + + if (!this.hlObjs.hl_point) { + this.hlObjs.hl_point = gt.board.create('point', [coords.usrCoords[1], coords.usrCoords[2]], { + size: 2, + color: gt.color.underConstruction, + snapToGrid: true, + highlight: false, + snapSizeX: gt.snapSizeX, + snapSizeY: gt.snapSizeY, + withLabel: false + }); + this.hlObjs.hl_point.rendNode.focus(); + } + + // Make sure the highlight point is not moved off the board or on the center. + if (e instanceof Event) gt.adjustDragPosition(e, this.hlObjs.hl_point, this.center); + + if (this.center && !this.hlObjs.hl_circle) { + this.hlObjs.hl_circle = gt.board.create('circle', [this.center, this.hlObjs.hl_point], { + fixed: true, + strokeColor: gt.color.underConstruction, + highlight: false, + dash: gt.drawSolid ? 0 : 2 + }); + } + + gt.setTextCoords(this.hlObjs.hl_point.X(), this.hlObjs.hl_point.Y()); + gt.board.update(); + return true; + } + + // In phase1 the user has selected a point. If the point is on the board, then create the center of the + // circle, and set up phase2. + phase1(coords) { + if (!gt.boardHasPoint(coords[1], coords[2])) return; + + gt.board.off('up'); + + this.center = gt.board.create('point', [coords[1], coords[2]], { + size: 2, + withLabel: false, + highlight: false, + snapToGrid: true, + snapSizeX: gt.snapSizeX, + snapSizeY: gt.snapSizeY + }); + this.center.setAttribute({ fixed: true }); + + // Get a new x coordinate that is to the right, unless that is off the board. + // In that case go left instead. + let newX = this.center.X() + gt.snapSizeX; + if (newX > gt.board.getBoundingBox()[2]) newX = this.center.X() - gt.snapSizeX; + + this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [newX, this.center.Y()], gt.board)); + + this.helpText = 'Plot a point on the circle.'; + gt.updateHelp(); + + gt.board.on('up', (e) => this.phase2(gt.getMouseCoords(e).usrCoords)); + + gt.board.update(); + } + + // In phase2 the user has selected a second point. + // If that point is on the board, then finalize the circle. + phase2(coords) { + if (!gt.boardHasPoint(coords[1], coords[2])) return; + + // If the current coordinates are the same those of the first point, + // then use the highlight point coordinates instead. + if ( + Math.abs(this.center.X() - gt.snapRound(coords[1], gt.snapSizeX)) < JXG.Math.eps && + Math.abs(this.center.Y() - gt.snapRound(coords[2], gt.snapSizeY)) < JXG.Math.eps + ) + coords = this.hlObjs.hl_point.coords.usrCoords; + + gt.board.off('up'); + + const center = this.center; + delete this.center; + + center.setAttribute(gt.definingPointAttributes); + center.on('down', () => gt.onPointDown(center)); + center.on('up', () => gt.onPointUp(center)); + + const point = gt.createPoint(coords[1], coords[2], center); + gt.selectedObj = new gt.graphObjectTypes[this.object](center, point, gt.drawSolid); + gt.selectedObj.focusPoint = point; + gt.graphedObjs.push(gt.selectedObj); + + this.finish(); + } + }; + } + }; +})(); diff --git a/htdocs/js/GraphTool/cubictool.js b/htdocs/js/GraphTool/cubictool.js index ddba37c14..c65983a3f 100644 --- a/htdocs/js/GraphTool/cubictool.js +++ b/htdocs/js/GraphTool/cubictool.js @@ -1,103 +1,74 @@ /* global graphTool, JXG */ +'use strict'; + (() => { if (graphTool && graphTool.cubicTool) return; graphTool.cubicTool = { - Cubic: { - preInit(gt, point1, point2, point3, point4, solid) { - [point1, point2, point3, point4].forEach((point) => { - point.setAttribute(gt.definingPointAttributes); - if (!gt.isStatic) { - point.on('down', () => gt.onPointDown(point)); - point.on('up', () => gt.onPointUp(point)); + Cubic(gt) { + return class extends gt.GraphObject { + static strId = 'cubic'; + + constructor(point1, point2, point3, point4, solid) { + for (const point of [point1, point2, point3, point4]) { + point.setAttribute(gt.definingPointAttributes); + if (!gt.isStatic) { + point.on('down', () => gt.onPointDown(point)); + point.on('up', () => gt.onPointUp(point)); + } } - }); - return gt.graphObjectTypes.cubic.createCubic(point1, point2, point3, point4, solid, gt.color.curve); - }, - - postInit(_gt, point1, point2, point3, point4) { - this.definingPts.push(point1, point2, point3, point4); - this.focusPoint = point1; - }, - - stringify(gt) { - return [ - this.baseObj.getAttribute('dash') == 0 ? 'solid' : 'dashed', - ...this.definingPts.map( - (point) => `(${gt.snapRound(point.X(), gt.snapSizeX)},${gt.snapRound(point.Y(), gt.snapSizeY)})` - ) - ].join(','); - }, - - fillCmp(gt, point) { - return gt.sign(point[2] - this.baseObj.Y(point[1])); - }, - - restore(gt, string) { - let pointData = gt.pointRegexp.exec(string); - const points = []; - while (pointData) { - points.push(pointData.slice(1, 3)); - pointData = gt.pointRegexp.exec(string); + super(gt.graphObjectTypes.cubic.createCubic(point1, point2, point3, point4, solid, gt.color.curve)); + this.definingPts.push(point1, point2, point3, point4); + this.focusPoint = point1; } - if (points.length < 4) return false; - const point1 = gt.graphObjectTypes.cubic.createPoint( - parseFloat(points[0][0]), - parseFloat(points[0][1]) - ); - const point2 = gt.graphObjectTypes.cubic.createPoint( - parseFloat(points[1][0]), - parseFloat(points[1][1]), - [point1] - ); - const point3 = gt.graphObjectTypes.cubic.createPoint( - parseFloat(points[2][0]), - parseFloat(points[2][1]), - [point1, point2] - ); - const point4 = gt.graphObjectTypes.cubic.createPoint( - parseFloat(points[3][0]), - parseFloat(points[3][1]), - [point1, point2, point3] - ); - return new gt.graphObjectTypes.cubic(point1, point2, point3, point4, /solid/.test(string)); - }, - - helperMethods: { - createParabola(gt, point1, point2, point3, solid, color) { - return gt.board.create( - 'curve', - [ - // x and y coordinates of point on curve - (x) => x, - (x) => { - const x1 = point1.X(), - x2 = point2.X(), - x3 = point3.X(), - y1 = point1.Y(), - y2 = point2.Y(), - y3 = point3.Y(); - return ( - ((x - x2) * (x - x3) * y1) / ((x1 - x2) * (x1 - x3)) + - ((x - x1) * (x - x3) * y2) / ((x2 - x1) * (x2 - x3)) + - ((x - x1) * (x - x2) * y3) / ((x3 - x1) * (x3 - x2)) - ); - }, - // domain minimum and maximum - () => gt.board.getBoundingBox()[0], - () => gt.board.getBoundingBox()[2] - ], - { - strokeWidth: 2, - highlight: false, - strokeColor: color ? color : gt.color.underConstruction, - dash: solid ? 0 : 2 - } + + stringify() { + return [ + this.constructor.strId, + this.baseObj.getAttribute('dash') == 0 ? 'solid' : 'dashed', + ...this.definingPts.map( + (point) => + `(${gt.snapRound(point.X(), gt.snapSizeX)},${gt.snapRound(point.Y(), gt.snapSizeY)})` + ) + ].join(','); + } + + fillCmp(point) { + return gt.sign(point[2] - this.baseObj.Y(point[1])); + } + + static restore(string) { + let pointData = gt.pointRegexp.exec(string); + const points = []; + while (pointData) { + points.push(pointData.slice(1, 3)); + pointData = gt.pointRegexp.exec(string); + } + if (points.length < 4) return false; + const point1 = gt.graphObjectTypes.quadratic.createPoint( + parseFloat(points[0][0]), + parseFloat(points[0][1]) + ); + const point2 = gt.graphObjectTypes.quadratic.createPoint( + parseFloat(points[1][0]), + parseFloat(points[1][1]), + [point1] ); - }, + const point3 = gt.graphObjectTypes.quadratic.createPoint( + parseFloat(points[2][0]), + parseFloat(points[2][1]), + [point1, point2] + ); + const point4 = gt.graphObjectTypes.quadratic.createPoint( + parseFloat(points[3][0]), + parseFloat(points[3][1]), + [point1, point2, point3] + ); + return new this(point1, point2, point3, point4, /solid/.test(string)); + } - createCubic(gt, point1, point2, point3, point4, solid, color) { + static createCubic(point1, point2, point3, point4, solid, color) { return gt.board.create( 'curve', [ @@ -130,101 +101,30 @@ dash: solid ? 0 : 2 } ); - }, - - // Prevent a point from being moved off the board by a drag. If a group of other points is provided, - // then also prevent the point from being moved into the same vertical line as any of those points. - // Note that when this method is called, the point has already been moved by JSXGraph. Note that this - // ensures that the graphed object is a function, but does not prevent the cubic from degenerating into - // a quadratic or a line. - adjustDragPosition(gt, e, point, groupedPoints) { - const bbox = gt.board.getBoundingBox(); - - let left_x = point.X() < bbox[0] ? bbox[0] : point.X() > bbox[2] ? bbox[2] : point.X(); - let right_x = left_x; - let y = point.Y() < bbox[3] ? bbox[3] : point.Y() > bbox[1] ? bbox[1] : point.Y(); - - while (groupedPoints.some((groupedPoint) => left_x === groupedPoint.X())) left_x -= gt.snapSizeX; - while (groupedPoints.some((groupedPoint) => right_x === groupedPoint.X())) right_x += gt.snapSizeX; - - if (!gt.boardHasPoint(point.X(), point.Y()) || point.X() !== left_x || point.X() !== right_x) { - let preferLeft; - if (e.type === 'pointermove') { - const mouseX = gt.getMouseCoords(e).usrCoords[1]; - preferLeft = Math.abs(mouseX - left_x) < Math.abs(mouseX - right_x); - } else if (e.type === 'keydown') { - preferLeft = e.key === 'ArrowLeft'; - } - - point.setPosition(JXG.COORDS_BY_USER, [ - left_x < bbox[0] ? right_x : preferLeft || right_x > bbox[2] ? left_x : right_x, - y - ]); - } - }, - - groupedPointDrag(gt, e) { - gt.graphObjectTypes.cubic.adjustDragPosition(e, this, this.grouped_points); - gt.setTextCoords(this.X(), this.Y()); - gt.updateObjects(); - gt.updateText(); - }, - - createPoint(gt, x, y, grouped_points) { - const point = gt.board.create( - 'point', - [gt.snapRound(x, gt.snapSizeX), gt.snapRound(y, gt.snapSizeY)], - { - size: 2, - snapSizeX: gt.snapSizeX, - snapSizeY: gt.snapSizeY, - withLabel: false - } - ); - point.setAttribute({ snapToGrid: true }); - - if (!gt.isStatic) { - if (typeof grouped_points !== 'undefined' && grouped_points.length) { - point.grouped_points = []; - grouped_points.forEach((paired_point) => { - point.grouped_points.push(paired_point); - if (!paired_point.grouped_points) { - paired_point.grouped_points = []; - paired_point.on('drag', gt.graphObjectTypes.cubic.groupedPointDrag); - } - paired_point.grouped_points.push(point); - if ( - !paired_point.eventHandlers.drag || - paired_point.eventHandlers.drag.every( - (dragHandler) => - dragHandler.handler !== gt.graphObjectTypes.cubic.groupedPointDrag - ) - ) - paired_point.on('drag', gt.graphObjectTypes.cubic.groupedPointDrag); - }); - point.on('drag', gt.graphObjectTypes.cubic.groupedPointDrag, point); - } - } - - return point; } - } + }; }, - CubicTool: { - iconName: 'cubic', - tooltip: '4-Point Cubic Tool: Graph a cubic function.', - - initialize(gt) { - this.supportsSolidDash = true; + CubicTool(gt) { + return class extends gt.GenericTool { + object = 'cubic'; + supportsSolidDash = true; + useStandardActivation = true; + activationHelpText = 'Plot four points on the cubic.'; + useStandardDeactivation = true; + constructionObjects = ['point1', 'point2', 'point3']; + + constructor(container, iconName, tooltip) { + super(container, iconName ?? 'cubic', tooltip ?? '4-Point Cubic Tool: Graph a cubic function.'); + } - this.phase1 = (coords) => { + phase1(coords) { // Don't allow the point to be created off the board. if (!gt.boardHasPoint(coords[1], coords[2])) return; gt.board.off('up'); - this.point1 = gt.graphObjectTypes.cubic.createPoint(coords[1], coords[2]); + this.point1 = gt.graphObjectTypes.quadratic.createPoint(coords[1], coords[2]); this.point1.setAttribute({ fixed: true, highlight: false }); // Get a new x coordinate that is to the right, unless that is off the board. @@ -240,9 +140,9 @@ gt.board.on('up', (e) => this.phase2(gt.getMouseCoords(e).usrCoords)); gt.board.update(); - }; + } - this.phase2 = (coords) => { + phase2(coords) { if (!gt.boardHasPoint(coords[1], coords[2])) return; // If the current coordinates are on the same vertical line as the first point, @@ -252,7 +152,7 @@ gt.board.off('up'); - this.point2 = gt.graphObjectTypes.cubic.createPoint(coords[1], coords[2], [this.point1]); + this.point2 = gt.graphObjectTypes.quadratic.createPoint(coords[1], coords[2], [this.point1]); this.point2.setAttribute({ fixed: true, highlight: false }); // Get a new x coordinate that is to the right, unless that is off the board. @@ -271,9 +171,9 @@ gt.board.on('up', (e) => this.phase3(gt.getMouseCoords(e).usrCoords)); gt.board.update(); - }; + } - this.phase3 = (coords) => { + phase3(coords) { if (!gt.boardHasPoint(coords[1], coords[2])) return; // If the current coordinates are on the same vertical line as the first point, or on the same @@ -285,7 +185,7 @@ coords = this.hlObjs.hl_point.coords.usrCoords; gt.board.off('up'); - this.point3 = gt.graphObjectTypes.cubic.createPoint(coords[1], coords[2], [ + this.point3 = gt.graphObjectTypes.quadratic.createPoint(coords[1], coords[2], [ this.point1, this.point2 ]); @@ -311,9 +211,9 @@ gt.board.on('up', (e) => this.phase4(gt.getMouseCoords(e).usrCoords)); gt.board.update(); - }; + } - this.phase4 = (coords) => { + phase4(coords) { if (!gt.boardHasPoint(coords[1], coords[2])) return; // If the current coordinates are on the same vertical line as the first point, on the same vertical @@ -328,12 +228,12 @@ gt.board.off('up'); - const point4 = gt.graphObjectTypes.cubic.createPoint(coords[1], coords[2], [ + const point4 = gt.graphObjectTypes.quadratic.createPoint(coords[1], coords[2], [ this.point1, this.point2, this.point3 ]); - gt.selectedObj = new gt.graphObjectTypes.cubic( + gt.selectedObj = new gt.graphObjectTypes[this.object]( this.point1, this.point2, this.point3, @@ -347,125 +247,109 @@ delete this.point3; this.finish(); - }; - }, + } - handleKeyEvent(gt, e) { - if (!this.hlObjs.hl_point || !gt.board.containerObj.contains(document.activeElement)) return; + handleKeyEvent(e) { + if (!this.hlObjs.hl_point || !gt.board.containerObj.contains(document.activeElement)) return; - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - e.stopPropagation(); + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); - if (this.point3) this.phase4(this.hlObjs.hl_point.coords.usrCoords); - else if (this.point2) this.phase3(this.hlObjs.hl_point.coords.usrCoords); - else if (this.point1) this.phase2(this.hlObjs.hl_point.coords.usrCoords); - else this.phase1(this.hlObjs.hl_point.coords.usrCoords); - } - }, - - updateHighlights(gt, e) { - this.hlObjs.hl_line?.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); - this.hlObjs.hl_parabola?.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); - this.hlObjs.hl_cubic?.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); - this.hlObjs.hl_point?.rendNode.focus(); - - let coords; - if (e instanceof MouseEvent && e.type === 'pointermove') { - coords = gt.getMouseCoords(e); - this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [coords.usrCoords[1], coords.usrCoords[2]]); - } else if (e instanceof KeyboardEvent && e.type === 'keydown') { - coords = this.hlObjs.hl_point.coords; - } else if (e instanceof JXG.Coords) { - coords = e; - this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [coords.usrCoords[1], coords.usrCoords[2]]); - } else return false; - - if (!this.hlObjs.hl_point) { - this.hlObjs.hl_point = gt.board.create('point', [coords.usrCoords[1], coords.usrCoords[2]], { - size: 2, - color: gt.color.underConstruction, - snapToGrid: true, - snapSizeX: gt.snapSizeX, - snapSizeY: gt.snapSizeY, - highlight: false, - withLabel: false - }); - this.hlObjs.hl_point.rendNode.focus(); + if (this.point3) this.phase4(this.hlObjs.hl_point.coords.usrCoords); + else if (this.point2) this.phase3(this.hlObjs.hl_point.coords.usrCoords); + else if (this.point1) this.phase2(this.hlObjs.hl_point.coords.usrCoords); + else this.phase1(this.hlObjs.hl_point.coords.usrCoords); + } } - // Make sure the highlight point is not moved off the board or onto the same - // vertical line as any of the other points that have already been created. - if (e instanceof Event) { - const groupedPoints = []; - if (this.point1) groupedPoints.push(this.point1); - if (this.point2) groupedPoints.push(this.point2); - if (this.point3) groupedPoints.push(this.point3); - gt.graphObjectTypes.cubic.adjustDragPosition(e, this.hlObjs.hl_point, groupedPoints); - } + updateHighlights(e) { + this.hlObjs.hl_line?.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); + this.hlObjs.hl_parabola?.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); + this.hlObjs.hl_cubic?.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); + this.hlObjs.hl_point?.rendNode.focus(); + + let coords; + if (e instanceof MouseEvent && e.type === 'pointermove') { + coords = gt.getMouseCoords(e); + this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [ + coords.usrCoords[1], + coords.usrCoords[2] + ]); + } else if (e instanceof KeyboardEvent && e.type === 'keydown') { + coords = this.hlObjs.hl_point.coords; + } else if (e instanceof JXG.Coords) { + coords = e; + this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [ + coords.usrCoords[1], + coords.usrCoords[2] + ]); + } else return false; - if (this.point3 && !this.hlObjs.hl_cubic) { - // Delete the temporary highlight parabola if it exists. - if (this.hlObjs.hl_parabola) { - gt.board.removeObject(this.hlObjs.hl_parabola); - delete this.hlObjs.hl_parabola; + if (!this.hlObjs.hl_point) { + this.hlObjs.hl_point = gt.board.create('point', [coords.usrCoords[1], coords.usrCoords[2]], { + size: 2, + color: gt.color.underConstruction, + snapToGrid: true, + snapSizeX: gt.snapSizeX, + snapSizeY: gt.snapSizeY, + highlight: false, + withLabel: false + }); + this.hlObjs.hl_point.rendNode.focus(); } - this.hlObjs.hl_cubic = gt.graphObjectTypes.cubic.createCubic( - this.point1, - this.point2, - this.point3, - this.hlObjs.hl_point, - gt.drawSolid - ); - } else if (this.point2 && !this.point3 && !this.hlObjs.hl_parabola) { - // Delete the temporary highlight line if it exists. - if (this.hlObjs.hl_line) { - gt.board.removeObject(this.hlObjs.hl_line); - delete this.hlObjs.hl_line; + // Make sure the highlight point is not moved off the board or onto the same + // vertical line as any of the other points that have already been created. + if (e instanceof Event) { + const groupedPoints = []; + if (this.point1) groupedPoints.push(this.point1); + if (this.point2) groupedPoints.push(this.point2); + if (this.point3) groupedPoints.push(this.point3); + gt.graphObjectTypes.quadratic.adjustDragPosition(e, this.hlObjs.hl_point, groupedPoints); } - this.hlObjs.hl_parabola = gt.graphObjectTypes.cubic.createParabola( - this.point1, - this.point2, - this.hlObjs.hl_point, - gt.drawSolid - ); - } else if (this.point1 && !this.point2 && !this.hlObjs.hl_line) { - this.hlObjs.hl_line = gt.board.create('line', [this.point1, this.hlObjs.hl_point], { - fixed: true, - strokeColor: gt.color.underConstruction, - highlight: false, - dash: gt.drawSolid ? 0 : 2 - }); - } - - gt.setTextCoords(this.hlObjs.hl_point.X(), this.hlObjs.hl_point.Y()); - gt.board.update(); - return true; - }, - - deactivate(gt) { - delete this.helpText; - gt.board.off('up'); - ['point1', 'point2', 'point3'].forEach(function (point) { - if (this[point]) gt.board.removeObject(this[point]); - delete this[point]; - }, this); - gt.board.containerObj.style.cursor = 'auto'; - }, - - activate(gt) { - gt.board.containerObj.style.cursor = 'none'; + if (this.point3 && !this.hlObjs.hl_cubic) { + // Delete the temporary highlight parabola if it exists. + if (this.hlObjs.hl_parabola) { + gt.board.removeObject(this.hlObjs.hl_parabola); + delete this.hlObjs.hl_parabola; + } - // Draw a highlight point on the board. - this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [0, 0], gt.board)); + this.hlObjs.hl_cubic = gt.graphObjectTypes.cubic.createCubic( + this.point1, + this.point2, + this.point3, + this.hlObjs.hl_point, + gt.drawSolid + ); + } else if (this.point2 && !this.point3 && !this.hlObjs.hl_parabola) { + // Delete the temporary highlight line if it exists. + if (this.hlObjs.hl_line) { + gt.board.removeObject(this.hlObjs.hl_line); + delete this.hlObjs.hl_line; + } - this.helpText = 'Plot four points on the cubic.'; - gt.updateHelp(); + this.hlObjs.hl_parabola = gt.graphObjectTypes.quadratic.createQuadratic( + this.point1, + this.point2, + this.hlObjs.hl_point, + gt.drawSolid + ); + } else if (this.point1 && !this.point2 && !this.hlObjs.hl_line) { + this.hlObjs.hl_line = gt.board.create('line', [this.point1, this.hlObjs.hl_point], { + fixed: true, + strokeColor: gt.color.underConstruction, + highlight: false, + dash: gt.drawSolid ? 0 : 2 + }); + } - gt.board.on('up', (e) => this.phase1(gt.getMouseCoords(e).usrCoords)); - } + gt.setTextCoords(this.hlObjs.hl_point.X(), this.hlObjs.hl_point.Y()); + gt.board.update(); + return true; + } + }; } }; })(); diff --git a/htdocs/js/GraphTool/filltool.js b/htdocs/js/GraphTool/filltool.js new file mode 100644 index 000000000..3935edfbc --- /dev/null +++ b/htdocs/js/GraphTool/filltool.js @@ -0,0 +1,388 @@ +/* global graphTool, JXG */ + +'use strict'; + +(() => { + if (graphTool && graphTool.fillTool) return; + + graphTool.fillTool = { + Fill(gt) { + return class Fill extends gt.GraphObject { + static strId = 'fill'; + supportsSolidDash = false; + + constructor(point) { + super(point); + + // Make the point invisible, but not with the jsxgraph visible attribute. + // The icon will be shown instead. + point.setAttribute({ + strokeOpacity: 0, + highlightStrokeOpacity: 0, + fillOpacity: 0, + highlightFillOpacity: 0, + fixed: gt.isStatic + }); + this.definingPts.push(point); + this.focusPoint = point; + this.isAnswer = gt.graphingAnswers; + this.focused = true; + this.updateTimeout = 0; + this.update(); + this.isStatic = gt.isStatic; + + point.rendNode.classList.add('hidden-fill-point'); + + // The icon is what is actually shown. It is centered on the point which is the actual object. + this.icon = gt.board.create( + 'image', + [ + () => this.constructor.fillIcon(this.focused ? gt.color.pointHighlight : gt.color.fill), + [() => point.X() - 12 / gt.board.unitX, () => point.Y() - 12 / gt.board.unitY], + [() => 24 / gt.board.unitX, () => 24 / gt.board.unitY] + ], + { withLabel: false, highlight: false, layer: 8, name: 'FillIcon', fixed: true } + ); + + if (!gt.isStatic) { + this.on('drag', (e) => { + gt.adjustDragPosition(e, this.baseObj); + this.update(); + gt.updateText(); + }); + } + } + + // The fill object has an invisible focus object. So the focus/blur methods need to be overridden. + blur() { + this.focused = false; + this.baseObj.setAttribute({ fixed: true }); + gt.board.update(); + gt.updateHelp(); + } + + focus() { + this.focused = true; + this.baseObj.setAttribute({ fixed: false }); + gt.board.update(); + this.baseObj.rendNode.focus(); + gt.updateHelp(); + } + + remove() { + gt.board.removeObject(this.icon); + if (this.fillObj) gt.board.removeObject(this.fillObj); + super.remove(); + } + + update() { + const updateReal = () => { + this.updateTimeout = 0; + if (this.fillObj) { + gt.board.removeObject(this.fillObj); + delete this.fillObj; + } + + // If the fill point is not on the board, then the flood fill algorithm will loop infinitely. + // So bail. + if (!gt.boardHasPoint(...this.baseObj.coords.usrCoords.slice(1))) return; + + const allObjects = gt.graphedObjs + .concat(gt.staticObjs) + .filter((o) => !(o instanceof gt.graphObjectTypes['fill'])); + + // Determine which side of each object needs to be shaded. If the point + // is on a graphed object, then don't fill. + const a_vals = Array(allObjects.length); + for (const [i, object] of allObjects.entries()) { + a_vals[i] = object.fillCmp(this.baseObj.coords.usrCoords); + if (a_vals[i] == 0) return; + } + + const bBox = gt.board.getBoundingBox(); + + const canvas = document.createElement('canvas'); + canvas.width = gt.board.canvasWidth + 1; + canvas.height = gt.board.canvasHeight + 1; + const context = canvas.getContext('2d'); + const colorLayerData = context.getImageData(0, 0, canvas.width, canvas.height); + + const fillRed = Number('0x' + gt.color.fill.slice(1, 3)); + const fillBlue = Number('0x' + gt.color.fill.slice(3, 5)); + const fillGreen = Number('0x' + gt.color.fill.slice(5)); + + const fillPixel = (pixelPos) => { + colorLayerData.data[pixelPos] = fillRed; + colorLayerData.data[pixelPos + 1] = fillBlue; + colorLayerData.data[pixelPos + 2] = fillGreen; + colorLayerData.data[pixelPos + 3] = 255; + }; + + if (gt.options.useFloodFill) { + const isFilled = (pixelPos) => + colorLayerData.data[pixelPos] == fillRed && + colorLayerData.data[pixelPos + 1] == fillBlue && + colorLayerData.data[pixelPos + 2] == fillGreen; + + const isBoundaryPixel = (x, y, fromDir) => { + const curPixel = [1, bBox[0] + x / gt.board.unitX, bBox[1] - y / gt.board.unitY]; + const fromPixel = [ + 1, + curPixel[1] + fromDir[0] / gt.board.unitX, + curPixel[2] + fromDir[1] / gt.board.unitY + ]; + for (const [i, object] of allObjects.entries()) { + if (object.onBoundary(curPixel, a_vals[i], fromPixel)) return true; + } + return false; + }; + + const pixelStack = [ + [ + Math.round((this.definingPts[0].X() - bBox[0]) * gt.board.unitX), + Math.round((bBox[1] - this.definingPts[0].Y()) * gt.board.unitY) + ] + ]; + + while (pixelStack.length) { + const newPos = pixelStack.pop(); + let x = newPos[0]; + let y = newPos[1]; + + // Get current pixel position. + let pixelPos = (y * canvas.width + x) * 4; + + // Go up until the boundary of the fill region or the edge of the canvas is reached. + while (y >= 0 && !isBoundaryPixel(x, y, [0, 1])) { + y -= 1; + pixelPos -= canvas.width * 4; + } + + y += 1; + pixelPos += canvas.width * 4; + let reachLeft = false; + let reachRight = false; + + // Go down until the boundary of the fill region or the edge of the canvas is reached. + while (y < canvas.height && !isBoundaryPixel(x, y, [0, -1])) { + // FIXME: This should not be needed, but for some reason when several segments or + // vectors are plotted in certain positions the algorithm starts filling already + // filled pixels repeatedly and loops infinitely. The similar Perl code in the macro + // does not do this. + if (isFilled(pixelPos)) break; + + fillPixel(pixelPos); + + // While proceeding down check to the left and right to see + // if the fill region extends in those directions. + if (x > 0) { + if (!isFilled(pixelPos - 4) && !isBoundaryPixel(x - 1, y, [1, 0])) { + if (!reachLeft) { + // Add pixel to stack + pixelStack.push([x - 1, y]); + reachLeft = true; + } + } else reachLeft = false; + } + + if (x < canvas.width - 1) { + if (!isFilled(pixelPos + 4) && !isBoundaryPixel(x + 1, y, [-1, 0])) { + if (!reachRight) { + // Add pixel to stack + pixelStack.push([x + 1, y]); + reachRight = true; + } + } else reachRight = false; + } + + y += 1; + pixelPos += canvas.width * 4; + } + } + } else { + const isFillPixel = (x, y) => { + const curPixel = [ + 1.0, + (x - gt.board.origin.scrCoords[1]) / gt.board.unitX, + (gt.board.origin.scrCoords[2] - y) / gt.board.unitY + ]; + for (let i = 0; i < allObjects.length; ++i) { + if (allObjects[i].fillCmp(curPixel) != a_vals[i]) return false; + } + return true; + }; + + for (let j = 0; j < canvas.width; ++j) { + for (let k = 0; k < canvas.height; ++k) { + if (isFillPixel(j, k)) fillPixel((k * canvas.width + j) * 4); + } + } + } + + context.putImageData(colorLayerData, 0, 0); + const dataURL = canvas.toDataURL('image/png'); + canvas.remove(); + + this.fillObj = gt.board.create( + 'image', + [dataURL, [bBox[0], bBox[3]], [bBox[2] - bBox[0], bBox[1] - bBox[3]]], + { withLabel: false, highlight: false, fixed: true, layer: 0 } + ); + }; + + if (!('isStatic' in this) || (gt.isStatic && !gt.graphingAnswers) || this.isAnswer) { + // The only time this happens is on initial construction or if the board is static. + updateReal(); + return; + } else if (this.isStatic) return; + + if (this.updateTimeout) clearTimeout(this.updateTimeout); + this.updateTimeout = setTimeout(updateReal, 100); + } + + stringify() { + return [ + this.constructor.strId, + `(${gt.snapRound(this.baseObj.X(), gt.snapSizeX)},${gt.snapRound( + this.baseObj.Y(), + gt.snapSizeY + )})` + ].join(','); + } + + static restore(string) { + let pointData = gt.pointRegexp.exec(string); + const points = []; + while (pointData) { + points.push(pointData.slice(1, 3)); + pointData = gt.pointRegexp.exec(string); + } + if (!points.length) return false; + return new this(gt.createPoint(parseFloat(points[0][0]), parseFloat(points[0][1]))); + } + + // This is the icon used for the fill tool and fill graph object. + static fillIcon(color) { + return ( + 'data:image/svg+xml,' + + encodeURIComponent( + "" + + "" + + "" + + "" + + '' + ) + ); + } + }; + }, + + FillTool(gt) { + return class FillTool extends gt.GenericTool { + object = 'fill'; + useStandardActivation = true; + activationHelpText = 'Choose a point in the region to be filled.'; + useStandardDeactivation = true; + + constructor(container, iconName, tooltip) { + super( + container, + iconName ?? 'fill', + tooltip ?? 'Region Shading Tool: Shade a region in the graph.' + ); + } + + handleKeyEvent(e) { + if (!this.hlObjs.hl_point || !gt.board.containerObj.contains(document.activeElement)) return; + + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + + this.phase1(this.hlObjs.hl_point.coords.usrCoords); + } + } + + updateHighlights(e) { + this.hlObjs.hl_point?.rendNode.focus(); + + let coords; + if (e instanceof MouseEvent && e.type === 'pointermove') { + coords = gt.getMouseCoords(e); + this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [ + coords.usrCoords[1], + coords.usrCoords[2] + ]); + } else if (e instanceof KeyboardEvent && e.type === 'keydown') { + coords = this.hlObjs.hl_point.coords; + } else if (e instanceof JXG.Coords) { + coords = e; + this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [ + coords.usrCoords[1], + coords.usrCoords[2] + ]); + } else return false; + + if (!this.hlObjs.hl_point) { + this.hlObjs.hl_point = gt.board.create('point', [coords.usrCoords[1], coords.usrCoords[2]], { + size: 2, + strokeColor: 'transparent', + fillColor: 'transparent', + strokeOpacity: 0, + fillOpacity: 0, + highlight: false, + withLabel: false, + snapToGrid: true, + snapSizeX: gt.snapSizeX, + snapSizeY: gt.snapSizeY + }); + this.hlObjs.hl_point.rendNode.classList.add('hidden-fill-point'); + + this.hlObjs.hl_icon = gt.board.create( + 'image', + [ + gt.graphObjectTypes.fill.fillIcon(gt.color.fill), + [ + () => this.hlObjs.hl_point.X() - 12 / gt.board.unitX, + () => this.hlObjs.hl_point.Y() - 12 / gt.board.unitY + ], + [() => 24 / gt.board.unitX, () => 24 / gt.board.unitY] + ], + { withLabel: false, highlight: false, fixed: true, layer: 8 } + ); + + this.hlObjs.hl_point.rendNode.focus(); + } + + // Make sure the point/icon is not moved off the board. + if (e instanceof Event) gt.adjustDragPosition(e, this.hlObjs.hl_point); + + gt.setTextCoords(coords.usrCoords[1], coords.usrCoords[2]); + gt.board.update(); + return true; + } + + phase1(coords) { + // Don't allow the fill to be created off the board + if (!gt.boardHasPoint(coords[1], coords[2])) return; + + gt.board.off('up'); + + gt.selectedObj = new gt.graphObjectTypes[this.object](gt.createPoint(coords[1], coords[2])); + gt.graphedObjs.push(gt.selectedObj); + + this.finish(); + } + }; + } + }; +})(); diff --git a/htdocs/js/GraphTool/graphtool.js b/htdocs/js/GraphTool/graphtool.js index 5da125666..8fe2d1a6e 100644 --- a/htdocs/js/GraphTool/graphtool.js +++ b/htdocs/js/GraphTool/graphtool.js @@ -66,25 +66,6 @@ window.graphTool = (containerId, options) => { 'SolidDashTool' ]; - // This is the icon used for the fill tool and fill graph object. - gt.fillIcon = (color) => - 'data:image/svg+xml,' + - encodeURIComponent( - "" + - "" + - "" + - "" + - '' - ); - if ('htmlInputId' in options) gt.html_input = document.getElementById(options.htmlInputId); const cfgOptions = { title: 'WeBWorK Graph Tool', @@ -680,6 +661,18 @@ window.graphTool = (containerId, options) => { gt.pointRegexp = /\( *(-?[0-9]*(?:\.[0-9]*)?), *(-?[0-9]*(?:\.[0-9]*)?) *\)/g; + // This returns true if the points p1, p2, and p3 are colinear. + // Note that p1 must be an array of two numbers, and p2 and p3 must be JSXGraph points. + gt.areColinear = (p1, p2, p3) => { + return Math.abs((p1[1] - p2.Y()) * (p3.X() - p2.X()) - (p3.Y() - p2.Y()) * (p1[0] - p2.X())) < JXG.Math.eps; + }; + + // This returns true if the point p1 is on one of the lines through the pairs of points given in p2, p3, and p4. + // Note that p1 must be an array of two numbers, and p2, p3, and p4 must be JSXGraph points. + gt.arePairwiseColinear = (p1, p2, p3, p4) => { + return gt.areColinear(p1, p2, p3) || gt.areColinear(p1, p2, p4) || gt.areColinear(p1, p3, p4); + }; + // Prevent a point from being moved off the board by a drag. If a paired point is provided, then also prevent the // point from being moved into the same position as the paired point by a drag. Note that when this method is // called, the point has already been moved by JSXGraph. This prevents lines and circles from being made @@ -837,12 +830,14 @@ window.graphTool = (containerId, options) => { class GraphObject { supportsSolidDash = true; + definingPts = []; + + // This is used to cache the last focused point for this object. If focus is + // returned by a pointer event then this point will be refocused. + focusPoint = null; + constructor(jsxGraphObject) { this.baseObj = jsxGraphObject; - this.definingPts = []; - // This is used to cache the last focused point for this object. If focus is - // returned by a pointer event then this point will be refocused. - this.focusPoint = null; } handleKeyEvent(/* e, el */) {} @@ -941,453 +936,20 @@ window.graphTool = (containerId, options) => { return obj; } } - - // Line graph object - class Line extends GraphObject { - static strId = 'line'; - - constructor(point1, point2, solid) { - super( - gt.board.create('line', [point1, point2], { - fixed: true, - highlight: false, - strokeColor: gt.color.curve, - dash: solid ? 0 : 2 - }) - ); - this.definingPts.push(point1, point2); - this.focusPoint = point1; - } - - stringify() { - return [ - Line.strId, - this.baseObj.getAttribute('dash') == 0 ? 'solid' : 'dashed', - ...this.definingPts.map( - (point) => `(${gt.snapRound(point.X(), gt.snapSizeX)},${gt.snapRound(point.Y(), gt.snapSizeY)})` - ) - ].join(','); - } - - fillCmp(point) { - return gt.sign(JXG.Math.innerProduct(point, this.baseObj.stdform)); - } - - static restore(string) { - let pointData = gt.pointRegexp.exec(string); - const points = []; - while (pointData) { - points.push(pointData.slice(1, 3)); - pointData = gt.pointRegexp.exec(string); - } - if (points.length < 2) return false; - const point1 = gt.createPoint(parseFloat(points[0][0]), parseFloat(points[0][1])); - const point2 = gt.createPoint(parseFloat(points[1][0]), parseFloat(points[1][1]), point1); - return new gt.graphObjectTypes.line(point1, point2, /solid/.test(string)); - } - } - - // Circle graph object - class Circle extends GraphObject { - static strId = 'circle'; - - constructor(center, point, solid) { - super( - gt.board.create('circle', [center, point], { - fixed: true, - highlight: false, - strokeColor: gt.color.curve, - dash: solid ? 0 : 2 - }) - ); - this.definingPts.push(center, point); - this.focusPoint = center; - - // Redefine the circle's hasPoint method to return true if the center point has the given coordinates, so - // that a pointer over the center point will give focus to the object with the center point activated. - const circleHasPoint = this.baseObj.hasPoint.bind(this.baseObj); - this.baseObj.hasPoint = (x, y) => circleHasPoint(x, y) || center.hasPoint(x, y); - } - - stringify() { - return [ - Circle.strId, - this.baseObj.getAttribute('dash') == 0 ? 'solid' : 'dashed', - ...this.definingPts.map( - (point) => `(${gt.snapRound(point.X(), gt.snapSizeX)},${gt.snapRound(point.Y(), gt.snapSizeY)})` - ) - ].join(','); - } - - fillCmp(point) { - return gt.sign( - this.baseObj.stdform[3] * (point[1] * point[1] + point[2] * point[2]) + - JXG.Math.innerProduct(point, this.baseObj.stdform) - ); - } - - static restore(string) { - let pointData = gt.pointRegexp.exec(string); - const points = []; - while (pointData) { - points.push(pointData.slice(1, 3)); - pointData = gt.pointRegexp.exec(string); - } - if (points.length < 2) return false; - const center = gt.createPoint(parseFloat(points[0][0]), parseFloat(points[0][1])); - const point = gt.createPoint(parseFloat(points[1][0]), parseFloat(points[1][1]), center); - return new gt.graphObjectTypes.circle(center, point, /solid/.test(string)); - } - } - - // Parabola graph object. - // The underlying jsxgraph object is really a curve. The problem with the - // jsxgraph parabola object is that it can not be created from the vertex - // and a point on the graph of the parabola. - const aVal = (vertex, point, vertical) => - vertical - ? (point.Y() - vertex.Y()) / Math.pow(point.X() - vertex.X(), 2) - : (point.X() - vertex.X()) / Math.pow(point.Y() - vertex.Y(), 2); - - const createParabola = (vertex, point, vertical, solid, color) => { - if (vertical) - return gt.board.create( - 'curve', - [ - // x and y coordinates of point on curve - (x) => x, - (x) => aVal(vertex, point, vertical) * Math.pow(x - vertex.X(), 2) + vertex.Y(), - // domain minimum and maximum - () => gt.board.getBoundingBox()[0], - () => gt.board.getBoundingBox()[2] - ], - { - strokeWidth: 2, - highlight: false, - strokeColor: color ? color : gt.color.underConstruction, - dash: solid ? 0 : 2 - } - ); - else - return gt.board.create( - 'curve', - [ - // x and y coordinate of point on curve - (x) => aVal(vertex, point, vertical) * Math.pow(x - vertex.Y(), 2) + vertex.X(), - (x) => x, - // domain minimum and maximum - () => gt.board.getBoundingBox()[3], - () => gt.board.getBoundingBox()[1] - ], - { - strokeWidth: 2, - highlight: false, - strokeColor: color ? color : gt.color.underConstruction, - dash: solid ? 0 : 2 - } - ); - }; - - class Parabola extends GraphObject { - static strId = 'parabola'; - - constructor(vertex, point, vertical, solid) { - super(createParabola(vertex, point, vertical, solid, gt.color.curve)); - this.definingPts.push(vertex, point); - this.vertical = vertical; - this.focusPoint = vertex; - } - - stringify() { - return [ - Parabola.strId, - this.baseObj.getAttribute('dash') == 0 ? 'solid' : 'dashed', - this.vertical ? 'vertical' : 'horizontal', - ...this.definingPts.map( - (point) => `(${gt.snapRound(point.X(), gt.snapSizeX)},${gt.snapRound(point.Y(), gt.snapSizeY)})` - ) - ].join(','); - } - - fillCmp(point) { - if (this.vertical) return gt.sign(point[2] - this.baseObj.Y(point[1])); - else return gt.sign(point[1] - this.baseObj.X(point[2])); - } - - static restore(string) { - let pointData = gt.pointRegexp.exec(string); - const points = []; - while (pointData) { - points.push(pointData.slice(1, 3)); - pointData = gt.pointRegexp.exec(string); - } - if (points.length < 2) return false; - const vertex = gt.createPoint(parseFloat(points[0][0]), parseFloat(points[0][1])); - const point = gt.createPoint(parseFloat(points[1][0]), parseFloat(points[1][1]), vertex, true); - return new gt.graphObjectTypes.parabola(vertex, point, /vertical/.test(string), /solid/.test(string)); - } - } - - // Fill graph object - class Fill extends GraphObject { - static strId = 'fill'; - - constructor(point) { - super(point); - this.supportsSolidDash = false; - - // Make the point invisible, but not with the jsxgraph visible attribute. The icon will be shown instead. - point.setAttribute({ - strokeOpacity: 0, - highlightStrokeOpacity: 0, - fillOpacity: 0, - highlightFillOpacity: 0, - fixed: gt.isStatic - }); - this.definingPts.push(point); - this.focusPoint = point; - this.isAnswer = gt.graphingAnswers; - this.focused = true; - this.updateTimeout = 0; - this.update(); - this.isStatic = gt.isStatic; - - point.rendNode.classList.add('hidden-fill-point'); - - // The icon is what is actually shown. It is centered on the point which is the actual object. - this.icon = gt.board.create( - 'image', - [ - () => gt.fillIcon(this.focused ? gt.color.pointHighlight : gt.color.fill), - [() => point.X() - 12 / gt.board.unitX, () => point.Y() - 12 / gt.board.unitY], - [() => 24 / gt.board.unitX, () => 24 / gt.board.unitY] - ], - { withLabel: false, highlight: false, layer: 8, name: 'FillIcon', fixed: true } - ); - - if (!gt.isStatic) { - this.on('drag', (e) => { - gt.adjustDragPosition(e, this.baseObj); - this.update(); - gt.updateText(); - }); - } - } - - // The fill object has an invisible focus object. So the focus/blur methods need to be overridden. - blur() { - this.focused = false; - this.baseObj.setAttribute({ fixed: true }); - gt.board.update(); - gt.updateHelp(); - } - - focus() { - this.focused = true; - this.baseObj.setAttribute({ fixed: false }); - gt.board.update(); - this.baseObj.rendNode.focus(); - gt.updateHelp(); - } - - remove() { - gt.board.removeObject(this.icon); - if (this.fillObj) gt.board.removeObject(this.fillObj); - super.remove(); - } - - update() { - const updateReal = () => { - this.updateTimeout = 0; - if (this.fillObj) { - gt.board.removeObject(this.fillObj); - delete this.fillObj; - } - - // If the fill point is not on the board, then the flood fill algorithm will loop infinitely. So bail. - if (!gt.boardHasPoint(...this.baseObj.coords.usrCoords.slice(1))) return; - - const allObjects = gt.graphedObjs - .concat(gt.staticObjs) - .filter((o) => !(o instanceof gt.graphObjectTypes['fill'])); - - // Determine which side of each object needs to be shaded. If the point - // is on a graphed object, then don't fill. - const a_vals = Array(allObjects.length); - for (const [i, object] of allObjects.entries()) { - a_vals[i] = object.fillCmp(this.baseObj.coords.usrCoords); - if (a_vals[i] == 0) return; - } - - const bBox = gt.board.getBoundingBox(); - - const canvas = document.createElement('canvas'); - canvas.width = gt.board.canvasWidth + 1; - canvas.height = gt.board.canvasHeight + 1; - const context = canvas.getContext('2d'); - const colorLayerData = context.getImageData(0, 0, canvas.width, canvas.height); - - const fillRed = Number('0x' + gt.color.fill.slice(1, 3)); - const fillBlue = Number('0x' + gt.color.fill.slice(3, 5)); - const fillGreen = Number('0x' + gt.color.fill.slice(5)); - - const fillPixel = (pixelPos) => { - colorLayerData.data[pixelPos] = fillRed; - colorLayerData.data[pixelPos + 1] = fillBlue; - colorLayerData.data[pixelPos + 2] = fillGreen; - colorLayerData.data[pixelPos + 3] = 255; - }; - - if (options.useFloodFill) { - const isFilled = (pixelPos) => - colorLayerData.data[pixelPos] == fillRed && - colorLayerData.data[pixelPos + 1] == fillBlue && - colorLayerData.data[pixelPos + 2] == fillGreen; - - const isBoundaryPixel = (x, y, fromDir) => { - const curPixel = [1, bBox[0] + x / gt.board.unitX, bBox[1] - y / gt.board.unitY]; - const fromPixel = [ - 1, - curPixel[1] + fromDir[0] / gt.board.unitX, - curPixel[2] + fromDir[1] / gt.board.unitY - ]; - for (const [i, object] of allObjects.entries()) { - if (object.onBoundary(curPixel, a_vals[i], fromPixel)) return true; - } - return false; - }; - - const pixelStack = [ - [ - Math.round((this.definingPts[0].X() - bBox[0]) * gt.board.unitX), - Math.round((bBox[1] - this.definingPts[0].Y()) * gt.board.unitY) - ] - ]; - - while (pixelStack.length) { - const newPos = pixelStack.pop(); - let x = newPos[0]; - let y = newPos[1]; - - // Get current pixel position. - let pixelPos = (y * canvas.width + x) * 4; - - // Go up until the boundary of the fill region or the edge of the canvas is reached. - while (y >= 0 && !isBoundaryPixel(x, y, [0, 1])) { - y -= 1; - pixelPos -= canvas.width * 4; - } - - y += 1; - pixelPos += canvas.width * 4; - let reachLeft = false; - let reachRight = false; - - // Go down until the boundary of the fill region or the edge of the canvas is reached. - while (y < canvas.height && !isBoundaryPixel(x, y, [0, -1])) { - // FIXME: This should not be needed, but for some reason when several segments or vectors - // are plotted in certain positions the algorithm starts filling already filled pixels - // repeatedly and loops infinitely. The similar Perl code in the macro does not do this. - if (isFilled(pixelPos)) break; - - fillPixel(pixelPos); - - // While proceeding down check to the left and right to see - // if the fill region extends in those directions. - if (x > 0) { - if (!isFilled(pixelPos - 4) && !isBoundaryPixel(x - 1, y, [1, 0])) { - if (!reachLeft) { - // Add pixel to stack - pixelStack.push([x - 1, y]); - reachLeft = true; - } - } else reachLeft = false; - } - - if (x < canvas.width - 1) { - if (!isFilled(pixelPos + 4) && !isBoundaryPixel(x + 1, y, [-1, 0])) { - if (!reachRight) { - // Add pixel to stack - pixelStack.push([x + 1, y]); - reachRight = true; - } - } else reachRight = false; - } - - y += 1; - pixelPos += canvas.width * 4; - } - } - } else { - const isFillPixel = (x, y) => { - const curPixel = [ - 1.0, - (x - gt.board.origin.scrCoords[1]) / gt.board.unitX, - (gt.board.origin.scrCoords[2] - y) / gt.board.unitY - ]; - for (let i = 0; i < allObjects.length; ++i) { - if (allObjects[i].fillCmp(curPixel) != a_vals[i]) return false; - } - return true; - }; - - for (let j = 0; j < canvas.width; ++j) { - for (let k = 0; k < canvas.height; ++k) { - if (isFillPixel(j, k)) fillPixel((k * canvas.width + j) * 4); - } - } - } - - context.putImageData(colorLayerData, 0, 0); - const dataURL = canvas.toDataURL('image/png'); - canvas.remove(); - - this.fillObj = gt.board.create( - 'image', - [dataURL, [bBox[0], bBox[3]], [bBox[2] - bBox[0], bBox[1] - bBox[3]]], - { withLabel: false, highlight: false, fixed: true, layer: 0 } - ); - }; - - if (!('isStatic' in this) || (gt.isStatic && !gt.graphingAnswers) || this.isAnswer) { - // The only time this happens is on initial construction or if the board is static. - updateReal(); - return; - } else if (this.isStatic) return; - - if (this.updateTimeout) clearTimeout(this.updateTimeout); - this.updateTimeout = setTimeout(updateReal, 100); - } - - stringify() { - return [ - Fill.strId, - `(${gt.snapRound(this.baseObj.X(), gt.snapSizeX)},${gt.snapRound(this.baseObj.Y(), gt.snapSizeY)})` - ].join(','); - } - - static restore(string) { - let pointData = gt.pointRegexp.exec(string); - const points = []; - while (pointData) { - points.push(pointData.slice(1, 3)); - pointData = gt.pointRegexp.exec(string); - } - if (!points.length) return false; - return new gt.graphObjectTypes.fill(gt.createPoint(parseFloat(points[0][0]), parseFloat(points[0][1]))); - } - } + gt.GraphObject = GraphObject; gt.graphObjectTypes = {}; - gt.graphObjectTypes[Line.strId] = Line; - gt.graphObjectTypes[Parabola.strId] = Parabola; - gt.graphObjectTypes[Circle.strId] = Circle; - gt.graphObjectTypes[Fill.strId] = Fill; // Load any custom graph objects. if ('customGraphObjects' in options) { - Object.keys(options.customGraphObjects).forEach((name) => { - const graphObject = options.customGraphObjects[name]; + for (const [name, graphObject] of options.customGraphObjects) { + if (typeof graphObject === 'function') { + gt.graphObjectTypes[name] = graphObject.call(null, gt); + continue; + } + + // The following approach should be considered deprecated. + // Use the class definition function approach above instead. const parentObject = 'parent' in graphObject ? graphObject.parent @@ -1511,15 +1073,15 @@ window.graphTool = (containerId, options) => { // These are static class methods. if ('helperMethods' in graphObject) { - Object.keys(graphObject.helperMethods).forEach((method) => { + for (const method of Object.keys(graphObject.helperMethods)) { customGraphObject[method] = function (...args) { return graphObject.helperMethods[method].apply(this, [gt, ...args]); }; - }); + } } gt.graphObjectTypes[customGraphObject.strId] = customGraphObject; - }); + } } // Generic tool class from which all the graphing tools derive. Most of the methods, if overridden, must call the @@ -1548,6 +1110,14 @@ window.graphTool = (containerId, options) => { gt.activeTool = this; if (!(this instanceof SelectTool)) gt.board.containerObj.focus(); this.button.disabled = true; + + if (this.useStandardActivation) { + gt.board.containerObj.style.cursor = 'none'; + this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [0, 0], gt.board)); + if (this.activationHelpText) this.helpText = this.activationHelpText; + gt.board.on('up', (e) => this.phase1(gt.getMouseCoords(e).usrCoords)); + } + gt.updateHelp(); } @@ -1571,11 +1141,24 @@ window.graphTool = (containerId, options) => { } } + // If graphing is interupted by pressing escape or the graph tool losing focus, + // then clean up whatever has been done so far and deactivate the tool. deactivate() { + if (this.useStandardDeactivation) { + delete this.helpText; + gt.board.off('up'); + for (const object of this.constructionObjects ?? []) { + if (this[object]) gt.board.removeObject(this[object]); + delete this[object]; + } + gt.board.containerObj.style.cursor = 'auto'; + } + this.button.disabled = false; this.removeHighlights(); } } + gt.GenericTool = GenericTool; // Select tool class SelectTool extends GenericTool { @@ -1740,587 +1323,6 @@ window.graphTool = (containerId, options) => { } } - // Line graphing tool - class LineTool extends GenericTool { - constructor(container, iconName, tooltip) { - super(container, iconName ? iconName : 'line', tooltip ? tooltip : 'Line Tool: Graph a line.'); - this.supportsSolidDash = true; - } - - handleKeyEvent(e) { - if (!this.hlObjs.hl_point || !gt.board.containerObj.contains(document.activeElement)) return; - - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - e.stopPropagation(); - - if (this.point1) this.phase2(this.hlObjs.hl_point.coords.usrCoords); - else this.phase1(this.hlObjs.hl_point.coords.usrCoords); - } - } - - updateHighlights(e) { - this.hlObjs.hl_line?.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); - this.hlObjs.hl_point?.rendNode.focus(); - - let coords; - if (e instanceof MouseEvent && e.type === 'pointermove') { - coords = gt.getMouseCoords(e); - this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [coords.usrCoords[1], coords.usrCoords[2]]); - } else if (e instanceof KeyboardEvent && e.type === 'keydown') { - coords = this.hlObjs.hl_point.coords; - } else if (e instanceof JXG.Coords) { - coords = e; - this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [coords.usrCoords[1], coords.usrCoords[2]]); - } else return false; - - if (!this.hlObjs.hl_point) { - this.hlObjs.hl_point = gt.board.create('point', [coords.usrCoords[1], coords.usrCoords[2]], { - size: 2, - color: gt.color.underConstruction, - snapToGrid: true, - highlight: false, - snapSizeX: gt.snapSizeX, - snapSizeY: gt.snapSizeY, - withLabel: false - }); - this.hlObjs.hl_point.rendNode.focus(); - } - - // Make sure the highlight point is not moved off the board or on the other point. - if (e instanceof Event) gt.adjustDragPosition(e, this.hlObjs.hl_point, this.point1); - - if (this.point1 && !this.hlObjs.hl_line) { - this.hlObjs.hl_line = gt.board.create('line', [this.point1, this.hlObjs.hl_point], { - fixed: true, - strokeColor: gt.color.underConstruction, - highlight: false, - dash: gt.drawSolid ? 0 : 2 - }); - } - - gt.setTextCoords(this.hlObjs.hl_point.X(), this.hlObjs.hl_point.Y()); - gt.board.update(); - return true; - } - - // If graphing is interupted by pressing escape or the graph tool losing focus, - // then clean up whatever has been done so far and deactivate the tool. - deactivate() { - delete this.helpText; - gt.board.off('up'); - if (this.point1) gt.board.removeObject(this.point1); - delete this.point1; - gt.board.containerObj.style.cursor = 'auto'; - super.deactivate(); - } - - activate() { - super.activate(); - gt.board.containerObj.style.cursor = 'none'; - - // Draw a highlight point on the board. - this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [0, 0], gt.board)); - - this.helpText = 'Plot two points on the line.'; - gt.updateHelp(); - - // Wait for the user to select the first point. - gt.board.on('up', (e) => this.phase1(gt.getMouseCoords(e).usrCoords)); - } - - // In phase1 the user has selected a point. If that point is on the board, then make - // that the first point for the line, and set up phase2. - phase1(coords) { - // Don't allow the point to be created off the board. - if (!gt.boardHasPoint(coords[1], coords[2])) return; - - gt.board.off('up'); - - this.point1 = gt.board.create('point', [coords[1], coords[2]], { - size: 2, - withLabel: false, - highlight: false, - snapToGrid: true, - snapSizeX: gt.snapSizeX, - snapSizeY: gt.snapSizeY - }); - this.point1.setAttribute({ fixed: true }); - - // Get a new x coordinate that is to the right, unless that is off the board. - // In that case go left instead. - let newX = this.point1.X() + gt.snapSizeX; - if (newX > gt.board.getBoundingBox()[2]) newX = this.point1.X() - gt.snapSizeX; - - this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [newX, this.point1.Y()], gt.board)); - - this.helpText = 'Plot one more point on the line.'; - gt.updateHelp(); - - gt.board.on('up', (e) => this.phase2(gt.getMouseCoords(e).usrCoords)); - - gt.board.update(); - } - - // In phase2 the user has selected a second point. If that point is on the board , then finalize the line. - phase2(coords) { - if (!gt.boardHasPoint(coords[1], coords[2])) return; - - // If the current coordinates are the same those of the first point, - // then use the highlight point coordinates instead. - if ( - Math.abs(this.point1.X() - gt.snapRound(coords[1], gt.snapSizeX)) < JXG.Math.eps && - Math.abs(this.point1.Y() - gt.snapRound(coords[2], gt.snapSizeY)) < JXG.Math.eps - ) - coords = this.hlObjs.hl_point.coords.usrCoords; - - gt.board.off('up'); - - const point1 = this.point1; - delete this.point1; - - point1.setAttribute(gt.definingPointAttributes); - point1.on('down', () => gt.onPointDown(point1)); - point1.on('up', () => gt.onPointUp(point1)); - - const point2 = gt.createPoint(coords[1], coords[2], point1); - gt.selectedObj = new gt.graphObjectTypes.line(point1, point2, gt.drawSolid); - gt.selectedObj.focusPoint = point2; - gt.graphedObjs.push(gt.selectedObj); - - this.finish(); - } - } - - // Circle graphing tool - class CircleTool extends GenericTool { - constructor(container, iconName, tooltip) { - super(container, iconName ? iconName : 'circle', tooltip ? tooltip : 'Circle Tool: Graph a circle.'); - this.supportsSolidDash = true; - } - - handleKeyEvent(e) { - if (!this.hlObjs.hl_point || !gt.board.containerObj.contains(document.activeElement)) return; - - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - e.stopPropagation(); - - if (this.center) this.phase2(this.hlObjs.hl_point.coords.usrCoords); - else this.phase1(this.hlObjs.hl_point.coords.usrCoords); - } - } - - updateHighlights(e) { - this.hlObjs.hl_circle?.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); - this.hlObjs.hl_point?.rendNode.focus(); - - let coords; - if (e instanceof MouseEvent && e.type === 'pointermove') { - coords = gt.getMouseCoords(e); - this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [coords.usrCoords[1], coords.usrCoords[2]]); - } else if (e instanceof KeyboardEvent && e.type === 'keydown') { - coords = this.hlObjs.hl_point.coords; - } else if (e instanceof JXG.Coords) { - coords = e; - this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [coords.usrCoords[1], coords.usrCoords[2]]); - } else return false; - - if (!this.hlObjs.hl_point) { - this.hlObjs.hl_point = gt.board.create('point', [coords.usrCoords[1], coords.usrCoords[2]], { - size: 2, - color: gt.color.underConstruction, - snapToGrid: true, - highlight: false, - snapSizeX: gt.snapSizeX, - snapSizeY: gt.snapSizeY, - withLabel: false - }); - this.hlObjs.hl_point.rendNode.focus(); - } - - // Make sure the highlight point is not moved off the board or on the center. - if (e instanceof Event) gt.adjustDragPosition(e, this.hlObjs.hl_point, this.center); - - if (this.center && !this.hlObjs.hl_circle) { - this.hlObjs.hl_circle = gt.board.create('circle', [this.center, this.hlObjs.hl_point], { - fixed: true, - strokeColor: gt.color.underConstruction, - highlight: false, - dash: gt.drawSolid ? 0 : 2 - }); - } - - gt.setTextCoords(this.hlObjs.hl_point.X(), this.hlObjs.hl_point.Y()); - gt.board.update(); - return true; - } - - deactivate() { - delete this.helpText; - gt.board.off('up'); - if (this.center) gt.board.removeObject(this.center); - delete this.center; - gt.board.containerObj.style.cursor = 'auto'; - super.deactivate(); - } - - activate() { - super.activate(); - gt.board.containerObj.style.cursor = 'none'; - - // Draw a highlight point on the board. - this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [0, 0], gt.board)); - - this.helpText = 'Plot the center of the circle.'; - gt.updateHelp(); - - gt.board.on('up', (e) => this.phase1(gt.getMouseCoords(e).usrCoords)); - } - - // In phase1 the user has selected a point. If the point is on the board, then create the center of the circle, - // and set up phase2. - phase1(coords) { - if (!gt.boardHasPoint(coords[1], coords[2])) return; - - gt.board.off('up'); - - this.center = gt.board.create('point', [coords[1], coords[2]], { - size: 2, - withLabel: false, - highlight: false, - snapToGrid: true, - snapSizeX: gt.snapSizeX, - snapSizeY: gt.snapSizeY - }); - this.center.setAttribute({ fixed: true }); - - // Get a new x coordinate that is to the right, unless that is off the board. - // In that case go left instead. - let newX = this.center.X() + gt.snapSizeX; - if (newX > gt.board.getBoundingBox()[2]) newX = this.center.X() - gt.snapSizeX; - - this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [newX, this.center.Y()], gt.board)); - - this.helpText = 'Plot a point on the circle.'; - gt.updateHelp(); - - gt.board.on('up', (e) => this.phase2(gt.getMouseCoords(e).usrCoords)); - - gt.board.update(); - } - - // In phase2 the user has selected a second point. If that point is on the board, then finalize the circle. - phase2(coords) { - if (!gt.boardHasPoint(coords[1], coords[2])) return; - - // If the current coordinates are the same those of the first point, - // then use the highlight point coordinates instead. - if ( - Math.abs(this.center.X() - gt.snapRound(coords[1], gt.snapSizeX)) < JXG.Math.eps && - Math.abs(this.center.Y() - gt.snapRound(coords[2], gt.snapSizeY)) < JXG.Math.eps - ) - coords = this.hlObjs.hl_point.coords.usrCoords; - - gt.board.off('up'); - - const center = this.center; - delete this.center; - - center.setAttribute(gt.definingPointAttributes); - center.on('down', () => gt.onPointDown(center)); - center.on('up', () => gt.onPointUp(center)); - - const point = gt.createPoint(coords[1], coords[2], center); - gt.selectedObj = new gt.graphObjectTypes.circle(center, point, gt.drawSolid); - gt.selectedObj.focusPoint = point; - gt.graphedObjs.push(gt.selectedObj); - - this.finish(); - } - } - - // Parabola graphing tool - class ParabolaTool extends GenericTool { - constructor(container, vertical, iconName, tooltip) { - super( - container, - iconName ? iconName : vertical ? 'vertical-parabola' : 'horizontal-parabola', - tooltip - ? tooltip - : vertical - ? 'Vertical Parabola Tool: Graph a vertical parabola.' - : 'Horizontal Parabola Tool: Graph an horizontal parabola.' - ); - this.vertical = vertical; - this.supportsSolidDash = true; - } - - handleKeyEvent(e) { - if (!this.hlObjs.hl_point || !gt.board.containerObj.contains(document.activeElement)) return; - - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - e.stopPropagation(); - - if (this.vertex) this.phase2(this.hlObjs.hl_point.coords.usrCoords); - else this.phase1(this.hlObjs.hl_point.coords.usrCoords); - } - } - - updateHighlights(e) { - this.hlObjs.hl_parabola?.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); - this.hlObjs.hl_point?.rendNode.focus(); - - let coords; - if (e instanceof MouseEvent && e.type === 'pointermove') { - coords = gt.getMouseCoords(e); - this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [coords.usrCoords[1], coords.usrCoords[2]]); - } else if (e instanceof KeyboardEvent && e.type === 'keydown') { - coords = this.hlObjs.hl_point.coords; - } else if (e instanceof JXG.Coords) { - coords = e; - this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [coords.usrCoords[1], coords.usrCoords[2]]); - } else return false; - - if (!this.hlObjs.hl_point) { - this.hlObjs.hl_point = gt.board.create('point', [coords.usrCoords[1], coords.usrCoords[2]], { - size: 2, - color: gt.color.underConstruction, - snapToGrid: true, - snapSizeX: gt.snapSizeX, - snapSizeY: gt.snapSizeY, - highlight: false, - withLabel: false - }); - this.hlObjs.hl_point.rendNode.focus(); - } - - // Make sure the highlight point is not moved off the board or - // onto the same horizontal or vertical line as the vertex. - if (e instanceof Event) gt.adjustDragPositionRestricted(e, this.hlObjs.hl_point, this.vertex); - - if (this.vertex && !this.hlObjs.hl_parabola) { - this.hlObjs.hl_parabola = createParabola( - this.vertex, - this.hlObjs.hl_point, - this.vertical, - gt.drawSolid, - gt.color.underConstruction - ); - } - - gt.setTextCoords(this.hlObjs.hl_point.X(), this.hlObjs.hl_point.Y()); - gt.board.update(); - return true; - } - - deactivate() { - delete this.helpText; - gt.board.off('up'); - if (this.vertex) gt.board.removeObject(this.vertex); - delete this.vertex; - gt.board.containerObj.style.cursor = 'auto'; - super.deactivate(); - } - - activate() { - super.activate(); - gt.board.containerObj.style.cursor = 'none'; - - // Draw a highlight point on the board. - this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [0, 0], gt.board)); - - this.helpText = 'Plot the vertex of the parabola.'; - gt.updateHelp(); - - gt.board.on('up', (e) => this.phase1(gt.getMouseCoords(e).usrCoords)); - } - - phase1(coords) { - // Don't allow the point to be created off the board. - if (!gt.boardHasPoint(coords[1], coords[2])) return; - - gt.board.off('up'); - - this.vertex = gt.board.create('point', [coords[1], coords[2]], { - size: 2, - withLabel: false, - highlight: false, - snapToGrid: true, - snapSizeX: gt.snapSizeX, - snapSizeY: gt.snapSizeY - }); - this.vertex.setAttribute({ fixed: true }); - - // Get a new x coordinate that is to the right, unless that is off the board. - // In that case go left instead. - let newX = this.vertex.X() + gt.snapSizeX; - if (newX > gt.board.getBoundingBox()[2]) newX = this.vertex.X() - gt.snapSizeX; - - // Get a new y coordinate that is above, unless that is off the board. - // In that case go below instead. - let newY = this.vertex.Y() + gt.snapSizeY; - if (newY > gt.board.getBoundingBox()[1]) newY = this.vertex.Y() - gt.snapSizeY; - - this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [newX, newY], gt.board)); - - this.helpText = 'Plot another point on the parabola.'; - gt.updateHelp(); - - gt.board.on('up', (e) => this.phase2(gt.getMouseCoords(e).usrCoords)); - - gt.board.update(); - } - - phase2(coords) { - if (!gt.boardHasPoint(coords[1], coords[2])) return; - - // If the current coordinates are on the same horizontal or vertical line as the vertex, - // then use the highlight point coordinates instead. - if ( - Math.abs(this.vertex.X() - gt.snapRound(coords[1], gt.snapSizeX)) < JXG.Math.eps || - Math.abs(this.vertex.Y() - gt.snapRound(coords[2], gt.snapSizeY)) < JXG.Math.eps - ) - coords = this.hlObjs.hl_point.coords.usrCoords; - - gt.board.off('up'); - - const vertex = this.vertex; - delete this.vertex; - - vertex.setAttribute(gt.definingPointAttributes); - vertex.on('down', () => gt.onPointDown(vertex)); - vertex.on('up', () => gt.onPointUp(vertex)); - - const point = gt.createPoint(coords[1], coords[2], vertex, true); - gt.selectedObj = new gt.graphObjectTypes.parabola(vertex, point, this.vertical, gt.drawSolid); - gt.selectedObj.focusPoint = point; - gt.graphedObjs.push(gt.selectedObj); - - this.finish(); - } - } - - class VerticalParabolaTool extends ParabolaTool { - constructor(container, iconName, tooltip) { - super(container, true, iconName, tooltip); - } - } - - class HorizontalParabolaTool extends ParabolaTool { - constructor(container, iconName, tooltip) { - super(container, false, iconName, tooltip); - } - } - - // Fill tool - class FillTool extends GenericTool { - constructor(container, iconName, tooltip) { - super( - container, - iconName ? iconName : 'fill', - tooltip ? tooltip : 'Region Shading Tool: Shade a region in the graph.' - ); - } - - handleKeyEvent(e) { - if (!this.hlObjs.hl_point || !gt.board.containerObj.contains(document.activeElement)) return; - - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - e.stopPropagation(); - - this.phase1(this.hlObjs.hl_point.coords.usrCoords); - } - } - - updateHighlights(e) { - this.hlObjs.hl_point?.rendNode.focus(); - - let coords; - if (e instanceof MouseEvent && e.type === 'pointermove') { - coords = gt.getMouseCoords(e); - this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [coords.usrCoords[1], coords.usrCoords[2]]); - } else if (e instanceof KeyboardEvent && e.type === 'keydown') { - coords = this.hlObjs.hl_point.coords; - } else if (e instanceof JXG.Coords) { - coords = e; - this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [coords.usrCoords[1], coords.usrCoords[2]]); - } else return false; - - if (!this.hlObjs.hl_point) { - this.hlObjs.hl_point = gt.board.create('point', [coords.usrCoords[1], coords.usrCoords[2]], { - size: 2, - strokeColor: 'transparent', - fillColor: 'transparent', - strokeOpacity: 0, - fillOpacity: 0, - highlight: false, - withLabel: false, - snapToGrid: true, - snapSizeX: gt.snapSizeX, - snapSizeY: gt.snapSizeY - }); - this.hlObjs.hl_point.rendNode.classList.add('hidden-fill-point'); - - this.hlObjs.hl_icon = gt.board.create( - 'image', - [ - gt.fillIcon(gt.color.fill), - [ - () => this.hlObjs.hl_point.X() - 12 / gt.board.unitX, - () => this.hlObjs.hl_point.Y() - 12 / gt.board.unitY - ], - [() => 24 / gt.board.unitX, () => 24 / gt.board.unitY] - ], - { withLabel: false, highlight: false, fixed: true, layer: 8 } - ); - - this.hlObjs.hl_point.rendNode.focus(); - } - - // Make sure the point/icon is not moved off the board. - if (e instanceof Event) gt.adjustDragPosition(e, this.hlObjs.hl_point); - - gt.setTextCoords(coords.usrCoords[1], coords.usrCoords[2]); - gt.board.update(); - return true; - } - - deactivate() { - delete this.helpText; - gt.board.off('up'); - gt.board.containerObj.style.cursor = 'auto'; - super.deactivate(); - } - - activate() { - super.activate(); - gt.board.containerObj.style.cursor = 'none'; - - // Draw a highlight point on the board. - this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [0, 0], gt.board)); - - this.helpText = 'Choose a point in the region to be filled.'; - gt.updateHelp(); - - gt.board.on('up', (e) => this.phase1(gt.getMouseCoords(e).usrCoords)); - } - - phase1(coords) { - // Don't allow the fill to be created off the board - if (!gt.boardHasPoint(coords[1], coords[2])) return; - - gt.board.off('up'); - - gt.selectedObj = new gt.graphObjectTypes.fill(gt.createPoint(coords[1], coords[2])); - gt.graphedObjs.push(gt.selectedObj); - - this.finish(); - } - } - // Draw objects solid or dashed. Makes the currently selected object (if // any) solid or dashed, and anything drawn while the tool is selected will // be drawn solid or dashed. @@ -2406,14 +1408,7 @@ window.graphTool = (containerId, options) => { } } - gt.toolTypes = { - LineTool: LineTool, - CircleTool: CircleTool, - VerticalParabolaTool: VerticalParabolaTool, - HorizontalParabolaTool: HorizontalParabolaTool, - FillTool: FillTool, - SolidDashTool: SolidDashTool - }; + gt.toolTypes = { SolidDashTool }; // Create the tools and html elements. const graphDiv = document.createElement('div'); @@ -2429,8 +1424,14 @@ window.graphTool = (containerId, options) => { // Load any custom tools. if ('customTools' in options) { - Object.keys(options.customTools).forEach((tool) => { - const toolObject = options.customTools[tool]; + for (const [toolName, toolObject] of options.customTools) { + if (typeof toolObject === 'function') { + gt.toolTypes[toolName] = toolObject.call(null, gt); + continue; + } + + // The following approach should be considered deprecated. + // Use the class definition function approach above instead. const parentTool = 'parent' in toolObject ? (toolObject.parent ? gt.toolTypes[toolObject.parent] : null) : GenericTool; const customTool = class extends parentTool { @@ -2485,15 +1486,15 @@ window.graphTool = (containerId, options) => { // These are static class methods. if ('helperMethods' in toolObject) { - Object.keys(toolObject.helperMethods).forEach((method) => { + for (const method of Object.keys(toolObject.helperMethods)) { customTool[method] = function (...args) { return toolObject.helperMethods[method].apply(this, [gt, ...args]); }; - }); + } } - gt.toolTypes[tool] = customTool; - }); + gt.toolTypes[toolName] = customTool; + } } gt.tools = [gt.selectTool]; diff --git a/htdocs/js/GraphTool/intervaltools.js b/htdocs/js/GraphTool/intervaltools.js index e8438b07c..8839e82dc 100644 --- a/htdocs/js/GraphTool/intervaltools.js +++ b/htdocs/js/GraphTool/intervaltools.js @@ -1,260 +1,258 @@ /* global graphTool, JXG */ +'use strict'; + (() => { if (graphTool && graphTool.intervalTool) return; graphTool.intervalTool = { - Interval: { - preInit(gt, point1, point2) { - // If an endpoint is at infinity move the segment end back a bit so it doesn't show up after the end of - // the arrow. If the useBracketEnds option is not in use, then make the segment's points a little - // inside of the actual point so that if the point is not included in the interval and so has a - // transparent center, then the segment ends are not visible in that transparent center. - const segment = gt.board.create( - 'segment', - [ + Interval(gt) { + return class extends gt.GraphObject { + static strId = 'interval'; + supportsSolidDash = false; + + constructor(point1, point2, includePoint1, includePoint2) { + // If an endpoint is at infinity move the segment end back a bit so it doesn't show up after the + // end of the arrow. If the useBracketEnds option is not in use, then make the segment's points + // a little inside of the actual point so that if the point is not included in the interval and + // so has a transparent center, then the segment ends are not visible in that transparent + // center. + const segment = gt.board.create( + 'segment', [ - () => - gt.isNegInfX(point1.X()) - ? gt.board.getBoundingBox()[0] + 8 / gt.board.unitX - : gt.isPosInfX(point1.X()) - ? gt.board.getBoundingBox()[2] - 8 / gt.board.unitX - : gt.options.useBracketEnds - ? point1.X() - : point1.X() + (point1.X() < point2.X() ? 4 : -4) / gt.board.unitX, - 0 + [ + () => + gt.isNegInfX(point1.X()) + ? gt.board.getBoundingBox()[0] + 8 / gt.board.unitX + : gt.isPosInfX(point1.X()) + ? gt.board.getBoundingBox()[2] - 8 / gt.board.unitX + : gt.options.useBracketEnds + ? point1.X() + : point1.X() + (point1.X() < point2.X() ? 4 : -4) / gt.board.unitX, + 0 + ], + [ + () => + gt.isNegInfX(point2.X()) + ? gt.board.getBoundingBox()[0] + 8 / gt.board.unitX + : gt.isPosInfX(point2.X()) + ? gt.board.getBoundingBox()[2] - 8 / gt.board.unitX + : gt.options.useBracketEnds + ? point2.X() + : point2.X() + (point1.X() < point2.X() ? -4 : 4) / gt.board.unitX, + 0 + ] ], - [ - () => - gt.isNegInfX(point2.X()) - ? gt.board.getBoundingBox()[0] + 8 / gt.board.unitX - : gt.isPosInfX(point2.X()) - ? gt.board.getBoundingBox()[2] - 8 / gt.board.unitX - : gt.options.useBracketEnds - ? point2.X() - : point2.X() + (point1.X() < point2.X() ? -4 : 4) / gt.board.unitX, - 0 - ] - ], - { fixed: true, highlight: false, strokeColor: gt.color.curve, strokeWidth: 4 } - ); - - // Redefine the segment's hasPoint method to return true if either of the end points has the coordinates - // as well. This is so that a pointer over those points will give focus to the object with the point - // that the pointer is over activated. - const segmentHasPoint = segment.hasPoint.bind(segment); - segment.hasPoint = (x, y) => segmentHasPoint(x, y) || point1.hasPoint(x, y) || point2.hasPoint(x, y); - - return segment; - }, - - postInit(gt, point1, point2, includePoint1, includePoint2) { - this.supportsSolidDash = false; - - this.definingPts.push(point1, point2); - this.focusPoint = point1; - - for (const point of [point1, point2]) { - point.setAttribute({ fixed: gt.isStatic }); - - if (gt.isStatic) { - point.off('down'); - point.off('up'); - point.off('drag'); - } else { - point.rendNode.addEventListener('focus', () => { - this.focusPoint = point; - gt.toolTypes.IncludeExcludePointTool?.updateButtonStatus(point); - }); + { fixed: true, highlight: false, strokeColor: gt.color.curve, strokeWidth: 4 } + ); + + // Redefine the segment's hasPoint method to return true if either of the end points has the + // coordinates as well. This is so that a pointer over those points will give focus to the + // object with the point that the pointer is over activated. + const segmentHasPoint = segment.hasPoint.bind(segment); + segment.hasPoint = (x, y) => + segmentHasPoint(x, y) || point1.hasPoint(x, y) || point2.hasPoint(x, y); + + super(segment); + + this.definingPts.push(point1, point2); + this.focusPoint = point1; + + for (const point of [point1, point2]) { + point.setAttribute({ fixed: gt.isStatic }); + + if (gt.isStatic) { + point.off('down'); + point.off('up'); + point.off('drag'); + } else { + point.rendNode.addEventListener('focus', () => { + this.focusPoint = point; + gt.toolTypes.IncludeExcludePointTool?.updateButtonStatus(point); + }); + + point.text?.setAttribute({ + fontSize: 19, + highlightStrokeColor: gt.color.pointHighlightDarker, + cssStyle: 'cursor:auto;font-weight:900' + }); + + // Override hasPoint to make the point effectively + // bigger when it is a point at infinity. + const origHasPoint = point.hasPoint.bind(point); + point.hasPoint = (x, y) => { + if (gt.isNegInfX(point.X()) || gt.isPosInfX(point.X())) { + const coordsScr = point.coords.scrCoords; + return Math.abs(coordsScr[1] - x) < 20 && Math.abs(coordsScr[2] - y) < 20; + } + return origHasPoint(x, y); + }; + } + } + point1.setAttribute({ + fillColor: includePoint1 ? gt.color.curve : 'transparent', + highlightFillColor: includePoint1 ? gt.color.pointHighlightDarker : gt.color.pointHighlight + }); + point2.setAttribute({ + fillColor: includePoint2 ? gt.color.curve : 'transparent', + highlightFillColor: includePoint2 ? gt.color.pointHighlightDarker : gt.color.pointHighlight + }); + } + + blur() { + this.focused = false; + + for (const point of this.definingPts) { + this.setFocusBlurPointAttributes(point); + point.text?.setAttribute({ fontSize: 19, highlight: false, strokeColor: gt.color.curve }); + point.arrow?.setAttribute({ strokeWidth: 4 }); + point.arrow?.rendNodeTriangleEnd.setAttribute('fill', gt.color.curve); + point.off('over'); + point.off('out'); + point.text?.off('down'); + point.text?.off('up'); + } + + this.baseObj.setAttribute({ strokeColor: gt.color.curve, strokeWidth: 4 }); + + gt.updateHelp(); + } + + focus() { + this.focused = true; + + for (const point of this.definingPts) { + this.setFocusBlurPointAttributes(point); + // The default layer for text is 9. + // Setting the layer moves the text in front of any other text objects. point.text?.setAttribute({ - fontSize: 19, - highlightStrokeColor: gt.color.pointHighlightDarker, - cssStyle: 'cursor:auto;font-weight:900' + fontSize: 23, + highlight: true, + strokeColor: gt.color.underConstruction, + layer: 9 }); - // Override hasPoint to make the point effectively bigger when it is a point at infinity. - const origHasPoint = point.hasPoint.bind(point); - point.hasPoint = (x, y) => { - if (gt.isNegInfX(point.X()) || gt.isPosInfX(point.X())) { - const coordsScr = point.coords.scrCoords; - return Math.abs(coordsScr[1] - x) < 20 && Math.abs(coordsScr[2] - y) < 20; - } - return origHasPoint(x, y); - }; + // The default layer for lines (of which arrows are a part) is 7. + // Setting this moves the arrow to the front of arrows of other intervals. + point.arrow?.setAttribute({ strokeWidth: 5, layer: 7 }); + point.arrow?.rendNodeTriangleEnd.setAttribute('fill', gt.color.point); + + // This makes it so that if the pointer is over the point and it is a hidden point at + // infinity, then it looks like the pointer is over the arrow. The end arrows don't + // actually receive hover events, so this has to be done this way. + point.on('over', () => + point.arrow?.rendNodeTriangleEnd.setAttribute('fill', gt.color.pointHighlightDarker) + ); + point.on('out', () => point.arrow?.rendNodeTriangleEnd.setAttribute('fill', gt.color.point)); + + point.text?.on('down', () => { + point.text.setAttribute({ + cssStyle: 'cursor:none;font-weight:900', + strokeColor: gt.color.pointHighlightDarker + }); + point.paired_point?.text?.setAttribute({ cssStyle: 'cursor:none;font-weight:900' }); + }); + point.text?.on('up', () => { + point.text.setAttribute({ + cssStyle: 'cursor:auto;font-weight:900', + strokeColor: gt.color.underConstruction + }); + point.paired_point?.text?.setAttribute({ cssStyle: 'cursor:auto;font-weight:900' }); + }); } + this.baseObj.setAttribute({ strokeColor: gt.color.focusCurve, strokeWidth: 5 }); + + this.focusPoint.rendNode.focus(); + + gt.updateHelp(); } - point1.setAttribute({ - fillColor: includePoint1 ? gt.color.curve : 'transparent', - highlightFillColor: includePoint1 ? gt.color.pointHighlightDarker : gt.color.pointHighlight - }); - point2.setAttribute({ - fillColor: includePoint2 ? gt.color.curve : 'transparent', - highlightFillColor: includePoint2 ? gt.color.pointHighlightDarker : gt.color.pointHighlight - }); - }, - - blur(gt) { - this.focused = false; - - for (const point of this.definingPts) { - this.setFocusBlurPointAttributes(point); - point.text?.setAttribute({ fontSize: 19, highlight: false, strokeColor: gt.color.curve }); - point.arrow?.setAttribute({ strokeWidth: 4 }); - point.arrow?.rendNodeTriangleEnd.setAttribute('fill', gt.color.curve); - point.off('over'); - point.off('out'); - point.text?.off('down'); - point.text?.off('up'); + + isEventTarget(e) { + if (this.baseObj.rendNode === e.target) return true; + return this.definingPts.some( + (point) => + point.rendNode === e.target || + point.text?.rendNode === e.target || + point.arrow?.rendNode === e.target + ); } - this.baseObj.setAttribute({ strokeColor: gt.color.curve, strokeWidth: 4 }); + update() { + const infFirst = gt.isNegInfX(this.definingPts[0].X()) || gt.isPosInfX(this.definingPts[0].X()); + const infLast = gt.isNegInfX(this.definingPts[1].X()) || gt.isPosInfX(this.definingPts[1].X()); - gt.updateHelp(); - return false; - }, + if (infFirst) this.setInfiniteEndPoint(0); + else this.setFiniteEndPoint(0); - focus(gt) { - this.focused = true; + if (infLast) this.setInfiniteEndPoint(1); + else this.setFiniteEndPoint(1); + } - for (const point of this.definingPts) { - this.setFocusBlurPointAttributes(point); + stringify() { + const leftEndPoint = + this.definingPts[0].X() < this.definingPts[1].X() ? this.definingPts[0] : this.definingPts[1]; + const rightEndPoint = + this.definingPts[0].X() < this.definingPts[1].X() ? this.definingPts[1] : this.definingPts[0]; + const leftX = gt.snapRound(leftEndPoint.X(), gt.snapSizeX); + const rightX = gt.snapRound(rightEndPoint.X(), gt.snapSizeX); + + return `${this.constructor.strId},${ + gt.isNegInfX(leftX) || leftEndPoint.getAttribute('fillColor') === 'transparent' ? '(' : '[' + }${gt.isNegInfX(leftX) ? '-infinity' : leftX},${gt.isPosInfX(rightX) ? 'infinity' : rightX}${ + gt.isPosInfX(rightX) || rightEndPoint.getAttribute('fillColor') === 'transparent' ? ')' : ']' + }`; + } - // The default layer for text is 9. - // Setting the layer moves the text in front of any other text objects. - point.text?.setAttribute({ - fontSize: 23, - highlight: true, - strokeColor: gt.color.underConstruction, - layer: 9 - }); + setSolid() {} - // The default layer for lines (of which arrows are a part) is 7. - // Setting this moves the arrow to the front of arrows of other intervals. - point.arrow?.setAttribute({ strokeWidth: 5, layer: 7 }); - point.arrow?.rendNodeTriangleEnd.setAttribute('fill', gt.color.point); + remove() { + for (const point of this.definingPts) { + if (point.text) gt.board.removeObject(point.text); + if (point.arrow) gt.board.removeObject(point.arrow); + } + super.remove(); + } - // This makes it so that if the pointer is over the point and it is a hidden point at infinity, - // then it looks like the pointer is over the arrow. The end arrows don't actually receive - // hover events, so this has to be done this way. - point.on('over', () => - point.arrow?.rendNodeTriangleEnd.setAttribute('fill', gt.color.pointHighlightDarker) + static restore(string) { + const intervalParts = string.match( + new RegExp( + [ + /\s*([[(])\s*/, // left delimiter + /(-?(?:[0-9]*(?:\.[0-9]*)?|infinity))/, // left end point + /\s*,\s*/, // comma + /(-?(?:[0-9]*(?:\.[0-9]*)?|infinity))/, // right end point + /\s*([\])])\s*/ // right delimiter + ] + .map((r) => r.source) + .join('') + ) ); - point.on('out', () => point.arrow?.rendNodeTriangleEnd.setAttribute('fill', gt.color.point)); + if (!intervalParts || intervalParts.length !== 5) return false; - point.text?.on('down', () => { - point.text.setAttribute({ - cssStyle: 'cursor:none;font-weight:900', - strokeColor: gt.color.pointHighlightDarker - }); - point.paired_point?.text?.setAttribute({ cssStyle: 'cursor:none;font-weight:900' }); - }); - point.text?.on('up', () => { - point.text.setAttribute({ - cssStyle: 'cursor:auto;font-weight:900', - strokeColor: gt.color.underConstruction - }); - point.paired_point?.text?.setAttribute({ cssStyle: 'cursor:auto;font-weight:900' }); - }); - } - this.baseObj.setAttribute({ strokeColor: gt.color.focusCurve, strokeWidth: 5 }); - - this.focusPoint.rendNode.focus(); - - gt.updateHelp(); - return false; - }, - - isEventTarget(_gt, e) { - if (this.baseObj.rendNode === e.target) return true; - return this.definingPts.some( - (point) => - point.rendNode === e.target || - point.text?.rendNode === e.target || - point.arrow?.rendNode === e.target - ); - }, - - update(gt) { - const infFirst = gt.isNegInfX(this.definingPts[0].X()) || gt.isPosInfX(this.definingPts[0].X()); - const infLast = gt.isNegInfX(this.definingPts[1].X()) || gt.isPosInfX(this.definingPts[1].X()); - - if (infFirst) this.setInfiniteEndPoint(0); - else this.setFiniteEndPoint(0); - - if (infLast) this.setInfiniteEndPoint(1); - else this.setFiniteEndPoint(1); - }, - - stringify(gt) { - const leftEndPoint = - this.definingPts[0].X() < this.definingPts[1].X() ? this.definingPts[0] : this.definingPts[1]; - const rightEndPoint = - this.definingPts[0].X() < this.definingPts[1].X() ? this.definingPts[1] : this.definingPts[0]; - const leftX = gt.snapRound(leftEndPoint.X(), gt.snapSizeX); - const rightX = gt.snapRound(rightEndPoint.X(), gt.snapSizeX); - - return `${gt.isNegInfX(leftX) || leftEndPoint.getAttribute('fillColor') === 'transparent' ? '(' : '['}${ - gt.isNegInfX(leftX) ? '-infinity' : leftX - },${gt.isPosInfX(rightX) ? 'infinity' : rightX}${ - gt.isPosInfX(rightX) || rightEndPoint.getAttribute('fillColor') === 'transparent' ? ')' : ']' - }`; - }, - - setSolid() {}, - - remove(gt) { - for (const point of this.definingPts) { - if (point.text) gt.board.removeObject(point.text); - if (point.arrow) gt.board.removeObject(point.arrow); + const bbox = gt.board.getBoundingBox(); + + const point1 = this.createPoint( + intervalParts[2] === '-infinity' ? bbox[0] : parseFloat(intervalParts[2]), + 0 + ); + const point2 = this.createPoint( + intervalParts[3] === 'infinity' ? bbox[2] : parseFloat(intervalParts[3]), + 0, + point1 + ); + return new this(point1, point2, intervalParts[1] === '[', intervalParts[4] === ']'); } - }, - restore(gt, string) { - const intervalParts = string.match( - new RegExp( - [ - /\s*([[(])\s*/, // left delimiter - /(-?(?:[0-9]*(?:\.[0-9]*)?|infinity))/, // left end point - /\s*,\s*/, // comma - /(-?(?:[0-9]*(?:\.[0-9]*)?|infinity))/, // right end point - /\s*([\])])\s*/ // right delimiter - ] - .map((r) => r.source) - .join('') - ) - ); - if (!intervalParts || intervalParts.length !== 5) return false; - - const bbox = gt.board.getBoundingBox(); - - const point1 = gt.graphObjectTypes.interval.createPoint( - intervalParts[2] === '-infinity' ? bbox[0] : parseFloat(intervalParts[2]), - 0 - ); - const point2 = gt.graphObjectTypes.interval.createPoint( - intervalParts[3] === 'infinity' ? bbox[2] : parseFloat(intervalParts[3]), - 0, - point1 - ); - return new gt.graphObjectTypes.interval( - point1, - point2, - intervalParts[1] === '[', - intervalParts[4] === ']' - ); - }, - - classMethods: { - setIncludePoint(gt, include) { + setIncludePoint(include) { this.focusPoint?.setAttribute({ fillColor: include ? gt.color.curve : 'transparent', highlightFillColor: include ? gt.color.pointHighlightDarker : gt.color.pointHighlight, highlightFillOpacity: gt.options.useBracketEnds || this.focusPoint.arrow ? 0 : include ? 1 : 0.5 }); - }, + } - setFocusBlurPointAttributes(gt, point) { + setFocusBlurPointAttributes(point) { const attributes = this.focused ? { size: 4, @@ -281,9 +279,9 @@ } point.setAttribute(attributes); - }, + } - setInfiniteEndPoint(gt, index) { + setInfiniteEndPoint(index) { if (gt.options.useBracketEnds) { this.definingPts[index].text.setAttribute({ strokeOpacity: 0, highlightStrokeOpacity: 0 }); } else { @@ -322,9 +320,9 @@ this.definingPts[index].arrow.rendNodeTriangleEnd.setAttribute('fill', gt.color.point); this.definingPts[index].arrow.setAttribute({ strokeWidth: 5 }); } - }, + } - setFiniteEndPoint(gt, index) { + setFiniteEndPoint(index) { if (gt.options.useBracketEnds) { this.definingPts[index].text.setAttribute({ strokeOpacity: 1, highlightStrokeOpacity: 1 }); } else { @@ -346,41 +344,38 @@ } this.definingPts[index].rendNode.classList.remove('hidden-inf-point'); } - }, - helperMethods: { - // gt.adjustDragPosition prevents paired points from being moved into the same position by a drag, and - // prevents a point from being moved off the board. This also ensures that the y coordinate stays at 0. - pairedPointDrag(gt, e, point) { + // gt.adjustDragPosition prevents paired points from being moved into the same position by a drag, + // and prevents a point from being moved off the board. This also ensures that the y coordinate + // stays at 0. + static pairedPointDrag(e, point) { gt.adjustDragPositionRestricted(e, point, point.paired_point); if (point.Y() !== 0) point.setPosition(JXG.COORDS_BY_USER, [point.X(), 0]); gt.setTextCoords(point.X(), 0); gt.updateObjects(); gt.updateText(); - }, + } - createPoint(gt, x, _y, paired_point) { + static createPoint(x, _y, paired_point) { const point = gt.board.create('point', [gt.snapRound(x, gt.snapSizeX), 0], { snapSizeX: gt.snapSizeX, snapSizeY: gt.snapSizeY, - ...gt.graphObjectTypes.interval.definingPointAttributes(), - ...gt.graphObjectTypes.interval.maybeBracketAttributes() + ...this.definingPointAttributes(), + ...this.maybeBracketAttributes() }); point.setAttribute({ snapToGrid: true }); if (!gt.isStatic) { - point.on('down', () => gt.graphObjectTypes.interval.pointDown(point)); - point.on('up', () => gt.graphObjectTypes.interval.pointUp(point)); + point.on('down', () => this.pointDown(point)); + point.on('up', () => this.pointUp(point)); } if (typeof paired_point !== 'undefined') { point.paired_point = paired_point; paired_point.paired_point = point; if (!gt.isStatic) { - paired_point.on('drag', (e) => - gt.graphObjectTypes.interval.pairedPointDrag(e, paired_point) - ); - point.on('drag', (e) => gt.graphObjectTypes.interval.pairedPointDrag(e, point)); + paired_point.on('drag', (e) => this.pairedPointDrag(e, paired_point)); + point.on('drag', (e) => this.pairedPointDrag(e, point)); } } @@ -417,9 +412,9 @@ ); return point; - }, + } - pointDown(gt, point) { + static pointDown(point) { if (gt.activeTool !== gt.selectTool) return; point.dragging = true; @@ -439,53 +434,61 @@ } gt.board.containerObj.style.cursor = 'none'; - }, + } - pointUp(gt, point) { + static pointUp(point) { delete point.dragging; gt.board.containerObj.style.cursor = 'auto'; - }, + } // Interval endpoints are slightly larger and use different colors than the graphtool defaults. - definingPointAttributes(gt) { + static definingPointAttributes() { return { size: 3, fixed: false, withLabel: false, strokeWidth: 2, strokeColor: gt.color.curve, - fillColor: gt.color.curve, // 'transparent' if not included. + // fillColor is 'transparent' if not included. + fillColor: gt.color.curve, highlightStrokeWidth: 3, highlightStrokeColor: gt.color.pointHighlightDarker, - highlightFillColor: gt.color.pointHighlightDarker // gt.color.pointHighlight if not included. + // highlightFillColor is gt.color.pointHighlight if not included. + highlightFillColor: gt.color.pointHighlightDarker }; - }, + } // Attributes added to a point if the useBracketEnds option is in effect. // These attributes will make a point invisible, but not with the jsxgraph visible attribute. // This is so that the point will still behave as if it were visible. - maybeBracketAttributes(gt) { + static maybeBracketAttributes() { return gt.options.useBracketEnds ? { strokeOpacity: 0, fillOpacity: 0, highlightStrokeOpacity: 0, highlightFillOpacity: 0 } : {}; } - } + }; }, - IntervalTool: { - iconName: 'interval', - tooltip: 'Interval Tool: Graph an interval.', + IntervalTool(gt) { + return class extends gt.GenericTool { + object = 'interval'; + supportsSolidDash = false; + supportsIncludeExclude = true; + useStandardActivation = true; + activationHelpText = + 'Plot the first endpoint. ' + + 'Move the point to the left end for \\(-\\infty\\), or to the right end for \\(\\infty\\).'; - initialize(gt) { - this.supportsIncludeExclude = true; - this.supportsSolidDash = false; + constructor(container, iconName, tooltip) { + super(container, iconName ?? 'interval', tooltip ?? 'Interval Tool: Graph an interval.'); - if (gt.options.useBracketEnds) { - this.button.classList.remove('gt-interval-tool'); - this.button.classList.add('gt-interval-bracket-tool'); + if (gt.options.useBracketEnds) { + this.button.classList.remove('gt-interval-tool'); + this.button.classList.add('gt-interval-bracket-tool'); + } } - this.phase1 = (coords) => { + phase1(coords) { // Don't allow the point to be created off the board. if (!gt.boardHasPoint(coords[1], coords[2])) return; @@ -555,16 +558,15 @@ this.helpText = 'Plot the second endpoint. ' + - 'Move the point to the left end for \\(-\\infty\\), ' + - 'or to the right end for \\(\\infty\\).'; + 'Move the point to the left end for \\(-\\infty\\), or to the right end for \\(\\infty\\).'; gt.updateHelp(); gt.board.on('up', (e) => this.phase2(gt.getMouseCoords(e).usrCoords)); gt.board.update(); - }; + } - this.phase2 = (coords) => { + phase2(coords) { if (!gt.boardHasPoint(coords[1], coords[2])) return; // If the current coordinates are the same as those of the first point, @@ -588,7 +590,7 @@ }); const point2 = gt.graphObjectTypes.interval.createPoint(coords[1], 0, point1); - gt.selectedObj = new gt.graphObjectTypes.interval( + gt.selectedObj = new gt.graphObjectTypes[this.object]( point1, point2, includePoint1, @@ -599,218 +601,219 @@ delete this.point1; this.finish(); - }; - }, + } - handleKeyEvent(gt, e) { - if (!this.hlObjs.hl_point || !gt.board.containerObj.contains(document.activeElement)) return; + handleKeyEvent(e) { + if (!this.hlObjs.hl_point || !gt.board.containerObj.contains(document.activeElement)) return; - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - e.stopPropagation(); + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); - if (this.point1) this.phase2(this.hlObjs.hl_point.coords.usrCoords); - else this.phase1(this.hlObjs.hl_point.coords.usrCoords); + if (this.point1) this.phase2(this.hlObjs.hl_point.coords.usrCoords); + else this.phase1(this.hlObjs.hl_point.coords.usrCoords); + } } - }, - - updateHighlights(gt, e) { - this.hlObjs.hl_point?.setAttribute({ - fillColor: gt.toolTypes.IncludeExcludePointTool.include ? gt.color.underConstruction : 'transparent' - }); - this.hlObjs.hl_point?.rendNode.focus(); - - let coords; - if (e instanceof MouseEvent && e.type === 'pointermove') { - coords = gt.getMouseCoords(e); - this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [coords.usrCoords[1], coords.usrCoords[2]]); - } else if (e instanceof KeyboardEvent && e.type === 'keydown') { - coords = this.hlObjs.hl_point.coords; - } else if (e instanceof JXG.Coords) { - coords = e; - this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [coords.usrCoords[1], coords.usrCoords[2]]); - } else return false; - - if (!this.hlObjs.hl_point) { - this.hlObjs.hl_point = gt.board.create('point', [coords.usrCoords[1], 0], { - size: 4, - strokeWidth: 3, - highlight: false, - withLabel: false, - snapToGrid: true, - snapSizeX: gt.snapSizeX, - snapSizeY: gt.snapSizeY, - strokeColor: gt.color.underConstruction, + + updateHighlights(e) { + this.hlObjs.hl_point?.setAttribute({ fillColor: gt.toolTypes.IncludeExcludePointTool.include ? gt.color.underConstruction - : 'transparent', - ...gt.graphObjectTypes.interval.maybeBracketAttributes() + : 'transparent' }); - - if (gt.options.useBracketEnds) { - this.hlObjs.hl_point.rendNode.classList.add('hidden-end-point'); - this.hlObjs.hl_text = gt.board.create( - 'text', - [ - () => - this.hlObjs.hl_point.X() + - (this.point1 ? (this.point1.X() > this.hlObjs.hl_point.X() ? 1 : -1) : 0) / - gt.board.unitX, - () => 1 / gt.board.unitY, - () => - gt.toolTypes.IncludeExcludePointTool.include - ? this.point1?.X() < this.hlObjs.hl_point?.X() - ? ']' - : '[' - : this.point1?.X() < this.hlObjs.hl_point?.X() - ? ')' - : '(' - ], - { - fontSize: 23, - anchorX: 'middle', - anchorY: 'middle', - display: 'internal', - fixed: true, - highlight: false, - strokeColor: gt.color.underConstruction, - cssStyle: 'cursor:none;font-weight:900' - } - ); - } - - this.hlObjs.hl_point.rendNode.focus(); - } - - // Make sure the highlight point is not moved of the board or onto the other point. - if (e instanceof Event) gt.adjustDragPositionRestricted(e, this.hlObjs.hl_point, this.point1); - - if (this.point1 && !this.hlObjs.hl_segment) { - this.hlObjs.hl_segment = gt.board.create( - 'segment', - [ - [ - () => - (this.point1 - ? gt.isNegInfX(this.point1.X()) - ? gt.board.getBoundingBox()[0] + 8 / gt.board.unitX - : gt.isPosInfX(this.point1.X()) - ? gt.board.getBoundingBox()[2] - 8 / gt.board.unitX - : this.point1.X() - : 0) + - (gt.options.useBracketEnds || - gt.isNegInfX(this.point1?.X()) || - gt.isPosInfX(this.point1?.X()) - ? 0 - : (this.point1?.X() < this.hlObjs.hl_point?.X() ? 4 : -4) / gt.board.unitX), - 0 - ], - [ - () => - (this.hlObjs.hl_point?.X() ?? 0) + - (gt.options.useBracketEnds || - gt.isNegInfX(this.hlObjs.hl_point?.X()) || - gt.isPosInfX(this.hlObjs.hl_point?.X()) - ? 0 - : (this.point1?.X() < this.hlObjs.hl_point?.X() ? -4 : 4) / gt.board.unitX), - 0 - ] - ], - { fixed: true, strokeWidth: 5, strokeColor: gt.color.underConstruction, highlight: false } - ); - // The default layer for lines (of which arrows are a part) is 7. - // Setting this moves the arrow to the front of the segment created after it. - this.hlObjs.hl_arrow?.setAttribute({ layer: 7 }); - this.hlObjs.hl_arrow?.rendNodeTriangleEnd.setAttribute('fill', gt.color.underConstructionFixed); - } - - if (this.hlObjs.hl_segment) { - if ( - gt.isNegInfX(gt.snapRound(coords.usrCoords[1], gt.snapSizeX)) || - gt.isPosInfX(gt.snapRound(coords.usrCoords[1], gt.snapSizeX)) - ) { - this.hlObjs.hl_segment.setArrow(this.hlObjs.hl_segment.getAttribute('firstArrow'), { - type: 2, - size: 4 + this.hlObjs.hl_point?.rendNode.focus(); + + let coords; + if (e instanceof MouseEvent && e.type === 'pointermove') { + coords = gt.getMouseCoords(e); + this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [ + coords.usrCoords[1], + coords.usrCoords[2] + ]); + } else if (e instanceof KeyboardEvent && e.type === 'keydown') { + coords = this.hlObjs.hl_point.coords; + } else if (e instanceof JXG.Coords) { + coords = e; + this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [ + coords.usrCoords[1], + coords.usrCoords[2] + ]); + } else return false; + + if (!this.hlObjs.hl_point) { + this.hlObjs.hl_point = gt.board.create('point', [coords.usrCoords[1], 0], { + size: 4, + strokeWidth: 3, + highlight: false, + withLabel: false, + snapToGrid: true, + snapSizeX: gt.snapSizeX, + snapSizeY: gt.snapSizeY, + strokeColor: gt.color.underConstruction, + fillColor: gt.toolTypes.IncludeExcludePointTool.include + ? gt.color.underConstruction + : 'transparent', + ...gt.graphObjectTypes.interval.maybeBracketAttributes() }); - if (gt.options.useBracketEnds) this.hlObjs.hl_text.setAttribute({ strokeOpacity: 0 }); - else this.hlObjs.hl_point.setAttribute({ strokeOpacity: 0, fillOpacity: 0 }); - this.hlObjs.hl_point.rendNode.classList.add('hidden-inf-point'); - } else { - this.hlObjs.hl_segment.setAttribute({ lastArrow: false }); - if (gt.options.useBracketEnds) this.hlObjs.hl_text.setAttribute({ strokeOpacity: 1 }); - else this.hlObjs.hl_point.setAttribute({ strokeOpacity: 1, fillOpacity: 1 }); - this.hlObjs.hl_point.rendNode.classList.remove('hidden-inf-point'); - } - } else if (this.hlObjs.hl_point) { - if ( - gt.isNegInfX(gt.snapRound(coords.usrCoords[1], gt.snapSizeX)) || - gt.isPosInfX(gt.snapRound(coords.usrCoords[1], gt.snapSizeX)) - ) { - if (!this.hlObjs.hl_arrow) { - this.hlObjs.hl_arrow = gt.board.create( - 'arrow', + + if (gt.options.useBracketEnds) { + this.hlObjs.hl_point.rendNode.classList.add('hidden-end-point'); + this.hlObjs.hl_text = gt.board.create( + 'text', [ - [ + () => this.hlObjs.hl_point.X() + - (gt.isPosInfX(this.hlObjs.hl_point.X()) ? -26 : 26) / gt.board.unitX, - 0 - ], - [this.hlObjs.hl_point.X(), 0] + (this.point1 ? (this.point1.X() > this.hlObjs.hl_point.X() ? 1 : -1) : 0) / + gt.board.unitX, + () => 1 / gt.board.unitY, + () => + gt.toolTypes.IncludeExcludePointTool.include + ? this.point1?.X() < this.hlObjs.hl_point?.X() + ? ']' + : '[' + : this.point1?.X() < this.hlObjs.hl_point?.X() + ? ')' + : '(' ], { + fontSize: 23, + anchorX: 'middle', + anchorY: 'middle', + display: 'internal', fixed: true, - strokeWidth: 5, - strokeColor: 'transparent', highlight: false, - lastArrow: { type: 2, size: 4 } + strokeColor: gt.color.underConstruction, + cssStyle: 'cursor:none;font-weight:900' } ); - this.hlObjs.hl_arrow.rendNodeTriangleEnd.setAttribute('fill', gt.color.underConstruction); - - if (gt.options.useBracketEnds) this.hlObjs.hl_text.setAttribute({ strokeOpacity: 0 }); - else this.hlObjs.hl_point.setAttribute({ strokeOpacity: 0, fillOpacity: 0 }); - this.hlObjs.hl_point.rendNode.classList.add('hidden-inf-point'); } - } else if (this.hlObjs.hl_arrow) { - gt.board.removeObject(this.hlObjs.hl_arrow); - delete this.hlObjs.hl_arrow; - if (gt.options.useBracketEnds) this.hlObjs.hl_text.setAttribute({ strokeOpacity: 1 }); - else this.hlObjs.hl_point.setAttribute({ strokeOpacity: 1, fillOpacity: 1 }); - this.hlObjs.hl_point.rendNode.classList.remove('hidden-inf-point'); + this.hlObjs.hl_point.rendNode.focus(); } - } - gt.setTextCoords(this.hlObjs.hl_point.X(), 0); - gt.board.update(); - return true; - }, + // Make sure the highlight point is not moved of the board or onto the other point. + if (e instanceof Event) gt.adjustDragPositionRestricted(e, this.hlObjs.hl_point, this.point1); - deactivate(gt) { - delete this.helpText; - gt.board.off('up'); - if (this.point1?.text) gt.board.removeObject(this.point1.text); - if (this.point1) gt.board.removeObject(this.point1); - delete this.point1; - gt.board.containerObj.style.cursor = 'auto'; - }, + if (this.point1 && !this.hlObjs.hl_segment) { + this.hlObjs.hl_segment = gt.board.create( + 'segment', + [ + [ + () => + (this.point1 + ? gt.isNegInfX(this.point1.X()) + ? gt.board.getBoundingBox()[0] + 8 / gt.board.unitX + : gt.isPosInfX(this.point1.X()) + ? gt.board.getBoundingBox()[2] - 8 / gt.board.unitX + : this.point1.X() + : 0) + + (gt.options.useBracketEnds || + gt.isNegInfX(this.point1?.X()) || + gt.isPosInfX(this.point1?.X()) + ? 0 + : (this.point1?.X() < this.hlObjs.hl_point?.X() ? 4 : -4) / gt.board.unitX), + 0 + ], + [ + () => + (this.hlObjs.hl_point?.X() ?? 0) + + (gt.options.useBracketEnds || + gt.isNegInfX(this.hlObjs.hl_point?.X()) || + gt.isPosInfX(this.hlObjs.hl_point?.X()) + ? 0 + : (this.point1?.X() < this.hlObjs.hl_point?.X() ? -4 : 4) / gt.board.unitX), + 0 + ] + ], + { + fixed: true, + strokeWidth: 5, + strokeColor: gt.color.underConstruction, + highlight: false + } + ); + // The default layer for lines (of which arrows are a part) is 7. + // Setting this moves the arrow to the front of the segment created after it. + this.hlObjs.hl_arrow?.setAttribute({ layer: 7 }); + this.hlObjs.hl_arrow?.rendNodeTriangleEnd.setAttribute('fill', gt.color.underConstructionFixed); + } - activate(gt) { - gt.board.containerObj.style.cursor = 'none'; + if (this.hlObjs.hl_segment) { + if ( + gt.isNegInfX(gt.snapRound(coords.usrCoords[1], gt.snapSizeX)) || + gt.isPosInfX(gt.snapRound(coords.usrCoords[1], gt.snapSizeX)) + ) { + this.hlObjs.hl_segment.setArrow(this.hlObjs.hl_segment.getAttribute('firstArrow'), { + type: 2, + size: 4 + }); + if (gt.options.useBracketEnds) this.hlObjs.hl_text.setAttribute({ strokeOpacity: 0 }); + else this.hlObjs.hl_point.setAttribute({ strokeOpacity: 0, fillOpacity: 0 }); + this.hlObjs.hl_point.rendNode.classList.add('hidden-inf-point'); + } else { + this.hlObjs.hl_segment.setAttribute({ lastArrow: false }); + if (gt.options.useBracketEnds) this.hlObjs.hl_text.setAttribute({ strokeOpacity: 1 }); + else this.hlObjs.hl_point.setAttribute({ strokeOpacity: 1, fillOpacity: 1 }); + this.hlObjs.hl_point.rendNode.classList.remove('hidden-inf-point'); + } + } else if (this.hlObjs.hl_point) { + if ( + gt.isNegInfX(gt.snapRound(coords.usrCoords[1], gt.snapSizeX)) || + gt.isPosInfX(gt.snapRound(coords.usrCoords[1], gt.snapSizeX)) + ) { + if (!this.hlObjs.hl_arrow) { + this.hlObjs.hl_arrow = gt.board.create( + 'arrow', + [ + [ + this.hlObjs.hl_point.X() + + (gt.isPosInfX(this.hlObjs.hl_point.X()) ? -26 : 26) / gt.board.unitX, + 0 + ], + [this.hlObjs.hl_point.X(), 0] + ], + { + fixed: true, + strokeWidth: 5, + strokeColor: 'transparent', + highlight: false, + lastArrow: { type: 2, size: 4 } + } + ); + this.hlObjs.hl_arrow.rendNodeTriangleEnd.setAttribute( + 'fill', + gt.color.underConstruction + ); + + if (gt.options.useBracketEnds) this.hlObjs.hl_text.setAttribute({ strokeOpacity: 0 }); + else this.hlObjs.hl_point.setAttribute({ strokeOpacity: 0, fillOpacity: 0 }); + this.hlObjs.hl_point.rendNode.classList.add('hidden-inf-point'); + } + } else if (this.hlObjs.hl_arrow) { + gt.board.removeObject(this.hlObjs.hl_arrow); + delete this.hlObjs.hl_arrow; - // Draw a highlight point on the board. - this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [0, 0], gt.board)); + if (gt.options.useBracketEnds) this.hlObjs.hl_text.setAttribute({ strokeOpacity: 1 }); + else this.hlObjs.hl_point.setAttribute({ strokeOpacity: 1, fillOpacity: 1 }); + this.hlObjs.hl_point.rendNode.classList.remove('hidden-inf-point'); + } + } - this.helpText = - 'Plot the first endpoint. ' + - 'Move the point to the left end for \\(-\\infty\\), ' + - 'or to the right end for \\(\\infty\\).'; - gt.updateHelp(); + gt.setTextCoords(this.hlObjs.hl_point.X(), 0); + gt.board.update(); + return true; + } - // Wait for the user to select the first point. - gt.board.on('up', (e) => this.phase1(gt.getMouseCoords(e).usrCoords)); - } + deactivate() { + delete this.helpText; + gt.board.off('up'); + if (this.point1?.text) gt.board.removeObject(this.point1.text); + if (this.point1) gt.board.removeObject(this.point1); + delete this.point1; + gt.board.containerObj.style.cursor = 'auto'; + super.deactivate(); + } + }; } }; })(); @@ -819,82 +822,78 @@ if (graphTool && graphTool.includeExcludePointTool) return; graphTool.includeExcludePointTool = { - IncludeExcludePointTool: { - parent: undefined, - - initialize(gt, container) { - gt.toolTypes.IncludeExcludePointTool.include = true; - - const includePointBox = document.createElement('div'); - const includeButtonMessage = 'Include the selected point (i).'; - includePointBox.classList.add('gt-tool-button-pair'); - // The default is to include points. So the include point button is disabled by default. - const includePointButtonDiv = document.createElement('div'); - includePointButtonDiv.classList.add('gt-button-div', 'gt-tool-button-pair-top'); - includePointButtonDiv.addEventListener('pointerover', () => gt.setMessageText(includeButtonMessage)); - includePointButtonDiv.addEventListener('pointerout', () => gt.updateHelp()); - gt.toolTypes.IncludeExcludePointTool.includePointButton = document.createElement('button'); - gt.toolTypes.IncludeExcludePointTool.includePointButton.classList.add( - 'gt-button', - 'gt-tool-button', - gt.options.useBracketEnds ? 'gt-include-point-bracket-tool' : 'gt-include-point-tool' - ); - gt.toolTypes.IncludeExcludePointTool.includePointButton.type = 'button'; - gt.toolTypes.IncludeExcludePointTool.includePointButton.setAttribute( - 'aria-label', - includeButtonMessage - ); - gt.toolTypes.IncludeExcludePointTool.includePointButton.disabled = true; - gt.toolTypes.IncludeExcludePointTool.includePointButton.addEventListener('click', (e) => - gt.toolTypes.IncludeExcludePointTool.toggleIncludeExcludePoint(e, true) - ); - gt.toolTypes.IncludeExcludePointTool.includePointButton.addEventListener('focus', () => - gt.setMessageText(includeButtonMessage) - ); - gt.toolTypes.IncludeExcludePointTool.includePointButton.addEventListener('blur', () => gt.updateHelp()); - includePointButtonDiv.append(gt.toolTypes.IncludeExcludePointTool.includePointButton); - includePointBox.append(includePointButtonDiv); - - const excludePointButtonDiv = document.createElement('div'); - const excludeButtonMessage = 'Exclude the selected point (e).'; - excludePointButtonDiv.classList.add('gt-button-div', 'gt-tool-button-pair-bottom'); - excludePointButtonDiv.addEventListener('pointerover', () => gt.setMessageText(excludeButtonMessage)); - excludePointButtonDiv.addEventListener('pointerout', () => gt.updateHelp()); - gt.toolTypes.IncludeExcludePointTool.excludePointButton = document.createElement('button'); - gt.toolTypes.IncludeExcludePointTool.excludePointButton.classList.add( - 'gt-button', - 'gt-tool-button', - gt.options.useBracketEnds ? 'gt-exclude-point-parenthesis-tool' : 'gt-exclude-point-tool' - ); - gt.toolTypes.IncludeExcludePointTool.excludePointButton.type = 'button'; - gt.toolTypes.IncludeExcludePointTool.excludePointButton.setAttribute( - 'aria-label', - excludeButtonMessage - ); - gt.toolTypes.IncludeExcludePointTool.excludePointButton.addEventListener('click', (e) => - gt.toolTypes.IncludeExcludePointTool.toggleIncludeExcludePoint(e, false) - ); - gt.toolTypes.IncludeExcludePointTool.excludePointButton.addEventListener('focus', () => - gt.setMessageText(excludeButtonMessage) - ); - gt.toolTypes.IncludeExcludePointTool.excludePointButton.addEventListener('blur', () => gt.updateHelp()); - excludePointButtonDiv.append(gt.toolTypes.IncludeExcludePointTool.excludePointButton); - includePointBox.append(excludePointButtonDiv); - container.append(includePointBox); - }, - - handleKeyEvent(gt, e) { - if (e.key === 'e') { - // If 'e' is pressed change to excluding interval endpoints. - gt.toolTypes.IncludeExcludePointTool.toggleIncludeExcludePoint(e, false); - } else if (e.key === 'i') { - // If 'i' is pressed change to including interval endpoints. - gt.toolTypes.IncludeExcludePointTool.toggleIncludeExcludePoint(e, true); + IncludeExcludePointTool(gt) { + return class { + constructor(container) { + this.constructor.include = true; + + const includePointBox = document.createElement('div'); + const includeButtonMessage = 'Include the selected point (i).'; + includePointBox.classList.add('gt-tool-button-pair'); + // The default is to include points. So the include point button is disabled by default. + const includePointButtonDiv = document.createElement('div'); + includePointButtonDiv.classList.add('gt-button-div', 'gt-tool-button-pair-top'); + includePointButtonDiv.addEventListener('pointerover', () => + gt.setMessageText(includeButtonMessage) + ); + includePointButtonDiv.addEventListener('pointerout', () => gt.updateHelp()); + this.constructor.includePointButton = document.createElement('button'); + this.constructor.includePointButton.classList.add( + 'gt-button', + 'gt-tool-button', + gt.options.useBracketEnds ? 'gt-include-point-bracket-tool' : 'gt-include-point-tool' + ); + this.constructor.includePointButton.type = 'button'; + this.constructor.includePointButton.setAttribute('aria-label', includeButtonMessage); + this.constructor.includePointButton.disabled = true; + this.constructor.includePointButton.addEventListener('click', (e) => + this.constructor.toggleIncludeExcludePoint(e, true) + ); + this.constructor.includePointButton.addEventListener('focus', () => + gt.setMessageText(includeButtonMessage) + ); + this.constructor.includePointButton.addEventListener('blur', () => gt.updateHelp()); + includePointButtonDiv.append(this.constructor.includePointButton); + includePointBox.append(includePointButtonDiv); + + const excludePointButtonDiv = document.createElement('div'); + const excludeButtonMessage = 'Exclude the selected point (e).'; + excludePointButtonDiv.classList.add('gt-button-div', 'gt-tool-button-pair-bottom'); + excludePointButtonDiv.addEventListener('pointerover', () => + gt.setMessageText(excludeButtonMessage) + ); + excludePointButtonDiv.addEventListener('pointerout', () => gt.updateHelp()); + this.constructor.excludePointButton = document.createElement('button'); + this.constructor.excludePointButton.classList.add( + 'gt-button', + 'gt-tool-button', + gt.options.useBracketEnds ? 'gt-exclude-point-parenthesis-tool' : 'gt-exclude-point-tool' + ); + this.constructor.excludePointButton.type = 'button'; + this.constructor.excludePointButton.setAttribute('aria-label', excludeButtonMessage); + this.constructor.excludePointButton.addEventListener('click', (e) => + this.constructor.toggleIncludeExcludePoint(e, false) + ); + this.constructor.excludePointButton.addEventListener('focus', () => + gt.setMessageText(excludeButtonMessage) + ); + this.constructor.excludePointButton.addEventListener('blur', () => gt.updateHelp()); + excludePointButtonDiv.append(this.constructor.excludePointButton); + includePointBox.append(excludePointButtonDiv); + container.append(includePointBox); } - }, - classMethods: { - helpText(gt) { + handleKeyEvent(e) { + if (e.key === 'e') { + // If 'e' is pressed change to excluding interval endpoints. + this.constructor.toggleIncludeExcludePoint(e, false); + } else if (e.key === 'i') { + // If 'i' is pressed change to including interval endpoints. + this.constructor.toggleIncludeExcludePoint(e, true); + } + } + + helpText() { return (gt.selectedObj && typeof gt.selectedObj.setIncludePoint === 'function') || (gt.activeTool && gt.activeTool.supportsIncludeExclude) ? `Use the ${gt.options.useBracketEnds ? '(' : '\\(\\circ\\)'} or ${ @@ -902,10 +901,8 @@ } button or type e or i to exclude or include the selected endpoint.` : ''; } - }, - helperMethods: { - toggleIncludeExcludePoint(gt, e, include) { + static toggleIncludeExcludePoint(e, include) { e.preventDefault(); e.stopPropagation(); @@ -917,28 +914,22 @@ gt.selectedObj.setIncludePoint?.(include); gt.updateText(); } - gt.toolTypes.IncludeExcludePointTool.include = include; + this.include = include; gt.selectedObj?.focus(); gt.activeTool?.updateHighlights(); if (!gt.selectedObj && gt.activeTool === gt.selectTool) gt.board.containerObj.focus(); - if (gt.toolTypes.IncludeExcludePointTool.includePointButton) - gt.toolTypes.IncludeExcludePointTool.includePointButton.disabled = include; - if (gt.toolTypes.IncludeExcludePointTool.excludePointButton) - gt.toolTypes.IncludeExcludePointTool.excludePointButton.disabled = !include; - }, - - updateButtonStatus(gt, point) { - gt.toolTypes.IncludeExcludePointTool.include = point.getAttribute('fillColor') !== 'transparent'; - if (gt.toolTypes.IncludeExcludePointTool.includePointButton) - gt.toolTypes.IncludeExcludePointTool.includePointButton.disabled = - gt.toolTypes.IncludeExcludePointTool.include; - if (gt.toolTypes.IncludeExcludePointTool.excludePointButton) - gt.toolTypes.IncludeExcludePointTool.excludePointButton.disabled = - !gt.toolTypes.IncludeExcludePointTool.include; + if (this.includePointButton) this.includePointButton.disabled = include; + if (this.excludePointButton) this.excludePointButton.disabled = !include; + } + + static updateButtonStatus(point) { + this.include = point.getAttribute('fillColor') !== 'transparent'; + if (this.includePointButton) this.includePointButton.disabled = this.include; + if (this.excludePointButton) this.excludePointButton.disabled = !this.include; } - } + }; } }; })(); diff --git a/htdocs/js/GraphTool/linetool.js b/htdocs/js/GraphTool/linetool.js new file mode 100644 index 000000000..acf6cb03c --- /dev/null +++ b/htdocs/js/GraphTool/linetool.js @@ -0,0 +1,205 @@ +/* global graphTool, JXG */ + +'use strict'; + +(() => { + if (graphTool && graphTool.lineTool) return; + + graphTool.lineTool = { + Line(gt) { + return class Line extends gt.GraphObject { + static strId = 'line'; + + constructor(point1, point2, solid) { + super( + gt.board.create('line', [point1, point2], { + fixed: true, + highlight: false, + strokeColor: gt.color.curve, + dash: solid ? 0 : 2 + }) + ); + this.definingPts.push(point1, point2); + this.focusPoint = point1; + } + + stringify() { + return [ + this.constructor.strId, + this.baseObj.getAttribute('dash') == 0 ? 'solid' : 'dashed', + ...this.definingPts.map( + (point) => + `(${gt.snapRound(point.X(), gt.snapSizeX)},${gt.snapRound(point.Y(), gt.snapSizeY)})` + ) + ].join(','); + } + + fillCmp(point) { + return gt.sign(JXG.Math.innerProduct(point, this.baseObj.stdform)); + } + + hasPoint(point) { + return ( + Math.abs(JXG.Math.innerProduct(point, this.baseObj.stdform)) / + Math.sqrt(this.baseObj.stdform[1] ** 2 + this.baseObj.stdform[2] ** 2) < + 0.5 / Math.sqrt(gt.board.unitX * gt.board.unitY) + ); + } + + static restore(string) { + let pointData = gt.pointRegexp.exec(string); + const points = []; + while (pointData) { + points.push(pointData.slice(1, 3)); + pointData = gt.pointRegexp.exec(string); + } + if (points.length < 2) return false; + const point1 = gt.createPoint(parseFloat(points[0][0]), parseFloat(points[0][1])); + const point2 = gt.createPoint(parseFloat(points[1][0]), parseFloat(points[1][1]), point1); + return new this(point1, point2, /solid/.test(string)); + } + }; + }, + + LineTool(gt) { + return class LineTool extends gt.GenericTool { + object = 'line'; + useStandardActivation = true; + activationHelpText = 'Plot two points on the line.'; + useStandardDeactivation = true; + constructionObjects = ['point1']; + + constructor(container, iconName, tooltip) { + super(container, iconName ?? 'line', tooltip ?? 'Line Tool: Graph a line.'); + this.supportsSolidDash = true; + } + + handleKeyEvent(e) { + if (!this.hlObjs.hl_point || !gt.board.containerObj.contains(document.activeElement)) return; + + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + + if (this.point1) this.phase2(this.hlObjs.hl_point.coords.usrCoords); + else this.phase1(this.hlObjs.hl_point.coords.usrCoords); + } + } + + updateHighlights(e) { + this.hlObjs.hl_line?.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); + this.hlObjs.hl_point?.rendNode.focus(); + + let coords; + if (e instanceof MouseEvent && e.type === 'pointermove') { + coords = gt.getMouseCoords(e); + this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [ + coords.usrCoords[1], + coords.usrCoords[2] + ]); + } else if (e instanceof KeyboardEvent && e.type === 'keydown') { + coords = this.hlObjs.hl_point.coords; + } else if (e instanceof JXG.Coords) { + coords = e; + this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [ + coords.usrCoords[1], + coords.usrCoords[2] + ]); + } else return false; + + if (!this.hlObjs.hl_point) { + this.hlObjs.hl_point = gt.board.create('point', [coords.usrCoords[1], coords.usrCoords[2]], { + size: 2, + color: gt.color.underConstruction, + snapToGrid: true, + highlight: false, + snapSizeX: gt.snapSizeX, + snapSizeY: gt.snapSizeY, + withLabel: false + }); + this.hlObjs.hl_point.rendNode.focus(); + } + + // Make sure the highlight point is not moved off the board or on the other point. + if (e instanceof Event) gt.adjustDragPosition(e, this.hlObjs.hl_point, this.point1); + + if (this.point1 && !this.hlObjs.hl_line) { + this.hlObjs.hl_line = gt.board.create('line', [this.point1, this.hlObjs.hl_point], { + fixed: true, + strokeColor: gt.color.underConstruction, + highlight: false, + dash: gt.drawSolid ? 0 : 2 + }); + } + + gt.setTextCoords(this.hlObjs.hl_point.X(), this.hlObjs.hl_point.Y()); + gt.board.update(); + return true; + } + + // In phase1 the user has selected a point. If that point is on the board, then make + // that the first point for the line, and set up phase2. + phase1(coords) { + // Don't allow the point to be created off the board. + if (!gt.boardHasPoint(coords[1], coords[2])) return; + + gt.board.off('up'); + + this.point1 = gt.board.create('point', [coords[1], coords[2]], { + size: 2, + withLabel: false, + highlight: false, + snapToGrid: true, + snapSizeX: gt.snapSizeX, + snapSizeY: gt.snapSizeY + }); + this.point1.setAttribute({ fixed: true }); + + // Get a new x coordinate that is to the right, unless that is off the board. + // In that case go left instead. + let newX = this.point1.X() + gt.snapSizeX; + if (newX > gt.board.getBoundingBox()[2]) newX = this.point1.X() - gt.snapSizeX; + + this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [newX, this.point1.Y()], gt.board)); + + this.helpText = 'Plot one more point on the line.'; + gt.updateHelp(); + + gt.board.on('up', (e) => this.phase2(gt.getMouseCoords(e).usrCoords)); + + gt.board.update(); + } + + // In phase2 the user has selected a second point. + // If that point is on the board , then finalize the line. + phase2(coords) { + if (!gt.boardHasPoint(coords[1], coords[2])) return; + + // If the current coordinates are the same those of the first point, + // then use the highlight point coordinates instead. + if ( + Math.abs(this.point1.X() - gt.snapRound(coords[1], gt.snapSizeX)) < JXG.Math.eps && + Math.abs(this.point1.Y() - gt.snapRound(coords[2], gt.snapSizeY)) < JXG.Math.eps + ) + coords = this.hlObjs.hl_point.coords.usrCoords; + + gt.board.off('up'); + + const point1 = this.point1; + delete this.point1; + + point1.setAttribute(gt.definingPointAttributes); + point1.on('down', () => gt.onPointDown(point1)); + point1.on('up', () => gt.onPointUp(point1)); + + const point2 = gt.createPoint(coords[1], coords[2], point1); + gt.selectedObj = new gt.graphObjectTypes[this.object](point1, point2, gt.drawSolid); + gt.selectedObj.focusPoint = point2; + gt.graphedObjs.push(gt.selectedObj); + + this.finish(); + } + }; + } + }; +})(); diff --git a/htdocs/js/GraphTool/parabolatool.js b/htdocs/js/GraphTool/parabolatool.js new file mode 100644 index 000000000..c1da9bb0d --- /dev/null +++ b/htdocs/js/GraphTool/parabolatool.js @@ -0,0 +1,270 @@ +/* global graphTool, JXG */ + +'use strict'; + +(() => { + if (graphTool && graphTool.parabolaTool) return; + + graphTool.parabolaTool = { + Parabola(gt) { + return class Parabola extends gt.GraphObject { + static strId = 'parabola'; + + constructor(vertex, point, vertical, solid) { + super(gt.graphObjectTypes.parabola.createParabola(vertex, point, vertical, solid, gt.color.curve)); + this.definingPts.push(vertex, point); + this.vertical = vertical; + this.focusPoint = vertex; + } + + stringify() { + return [ + this.constructor.strId, + this.baseObj.getAttribute('dash') == 0 ? 'solid' : 'dashed', + this.vertical ? 'vertical' : 'horizontal', + ...this.definingPts.map( + (point) => + `(${gt.snapRound(point.X(), gt.snapSizeX)},${gt.snapRound(point.Y(), gt.snapSizeY)})` + ) + ].join(','); + } + + fillCmp(point) { + if (this.vertical) return gt.sign(point[2] - this.baseObj.Y(point[1])); + else return gt.sign(point[1] - this.baseObj.X(point[2])); + } + + static restore(string) { + let pointData = gt.pointRegexp.exec(string); + const points = []; + while (pointData) { + points.push(pointData.slice(1, 3)); + pointData = gt.pointRegexp.exec(string); + } + if (points.length < 2) return false; + const vertex = gt.createPoint(parseFloat(points[0][0]), parseFloat(points[0][1])); + const point = gt.createPoint(parseFloat(points[1][0]), parseFloat(points[1][1]), vertex, true); + return new this(vertex, point, /vertical/.test(string), /solid/.test(string)); + } + + // Parabola graph object. + // The underlying jsxgraph object is really a curve. The problem with the + // jsxgraph parabola object is that it can not be created from the vertex + // and a point on the graph of the parabola. + static aVal(vertex, point, vertical) { + return vertical + ? (point.Y() - vertex.Y()) / Math.pow(point.X() - vertex.X(), 2) + : (point.X() - vertex.X()) / Math.pow(point.Y() - vertex.Y(), 2); + } + + static createParabola(vertex, point, vertical, solid, color) { + if (vertical) + return gt.board.create( + 'curve', + [ + // x and y coordinates of point on curve + (x) => x, + (x) => this.aVal(vertex, point, vertical) * Math.pow(x - vertex.X(), 2) + vertex.Y(), + // domain minimum and maximum + () => gt.board.getBoundingBox()[0], + () => gt.board.getBoundingBox()[2] + ], + { + strokeWidth: 2, + highlight: false, + strokeColor: color ? color : gt.color.underConstruction, + dash: solid ? 0 : 2 + } + ); + else + return gt.board.create( + 'curve', + [ + // x and y coordinate of point on curve + (x) => this.aVal(vertex, point, vertical) * Math.pow(x - vertex.Y(), 2) + vertex.X(), + (x) => x, + // domain minimum and maximum + () => gt.board.getBoundingBox()[3], + () => gt.board.getBoundingBox()[1] + ], + { + strokeWidth: 2, + highlight: false, + strokeColor: color ? color : gt.color.underConstruction, + dash: solid ? 0 : 2 + } + ); + } + }; + }, + + ParabolaTool(gt) { + return class ParabolaTool extends gt.GenericTool { + object = 'parabola'; + useStandardActivation = true; + activationHelpText = 'Plot the vertex of the parabola.'; + useStandardDeactivation = true; + constructionObjects = ['vertex']; + + constructor(container, vertical, iconName, tooltip) { + super( + container, + iconName ? iconName : vertical ? 'vertical-parabola' : 'horizontal-parabola', + tooltip + ? tooltip + : vertical + ? 'Vertical Parabola Tool: Graph a vertical parabola.' + : 'Horizontal Parabola Tool: Graph an horizontal parabola.' + ); + this.vertical = vertical; + this.supportsSolidDash = true; + } + + handleKeyEvent(e) { + if (!this.hlObjs.hl_point || !gt.board.containerObj.contains(document.activeElement)) return; + + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + + if (this.vertex) this.phase2(this.hlObjs.hl_point.coords.usrCoords); + else this.phase1(this.hlObjs.hl_point.coords.usrCoords); + } + } + + updateHighlights(e) { + this.hlObjs.hl_parabola?.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); + this.hlObjs.hl_point?.rendNode.focus(); + + let coords; + if (e instanceof MouseEvent && e.type === 'pointermove') { + coords = gt.getMouseCoords(e); + this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [ + coords.usrCoords[1], + coords.usrCoords[2] + ]); + } else if (e instanceof KeyboardEvent && e.type === 'keydown') { + coords = this.hlObjs.hl_point.coords; + } else if (e instanceof JXG.Coords) { + coords = e; + this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [ + coords.usrCoords[1], + coords.usrCoords[2] + ]); + } else return false; + + if (!this.hlObjs.hl_point) { + this.hlObjs.hl_point = gt.board.create('point', [coords.usrCoords[1], coords.usrCoords[2]], { + size: 2, + color: gt.color.underConstruction, + snapToGrid: true, + snapSizeX: gt.snapSizeX, + snapSizeY: gt.snapSizeY, + highlight: false, + withLabel: false + }); + this.hlObjs.hl_point.rendNode.focus(); + } + + // Make sure the highlight point is not moved off the board or + // onto the same horizontal or vertical line as the vertex. + if (e instanceof Event) gt.adjustDragPositionRestricted(e, this.hlObjs.hl_point, this.vertex); + + if (this.vertex && !this.hlObjs.hl_parabola) { + this.hlObjs.hl_parabola = gt.graphObjectTypes.parabola.createParabola( + this.vertex, + this.hlObjs.hl_point, + this.vertical, + gt.drawSolid, + gt.color.underConstruction + ); + } + + gt.setTextCoords(this.hlObjs.hl_point.X(), this.hlObjs.hl_point.Y()); + gt.board.update(); + return true; + } + + phase1(coords) { + // Don't allow the point to be created off the board. + if (!gt.boardHasPoint(coords[1], coords[2])) return; + + gt.board.off('up'); + + this.vertex = gt.board.create('point', [coords[1], coords[2]], { + size: 2, + withLabel: false, + highlight: false, + snapToGrid: true, + snapSizeX: gt.snapSizeX, + snapSizeY: gt.snapSizeY + }); + this.vertex.setAttribute({ fixed: true }); + + // Get a new x coordinate that is to the right, unless that is off the board. + // In that case go left instead. + let newX = this.vertex.X() + gt.snapSizeX; + if (newX > gt.board.getBoundingBox()[2]) newX = this.vertex.X() - gt.snapSizeX; + + // Get a new y coordinate that is above, unless that is off the board. + // In that case go below instead. + let newY = this.vertex.Y() + gt.snapSizeY; + if (newY > gt.board.getBoundingBox()[1]) newY = this.vertex.Y() - gt.snapSizeY; + + this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [newX, newY], gt.board)); + + this.helpText = 'Plot another point on the parabola.'; + gt.updateHelp(); + + gt.board.on('up', (e) => this.phase2(gt.getMouseCoords(e).usrCoords)); + + gt.board.update(); + } + + phase2(coords) { + if (!gt.boardHasPoint(coords[1], coords[2])) return; + + // If the current coordinates are on the same horizontal or vertical line as the vertex, + // then use the highlight point coordinates instead. + if ( + Math.abs(this.vertex.X() - gt.snapRound(coords[1], gt.snapSizeX)) < JXG.Math.eps || + Math.abs(this.vertex.Y() - gt.snapRound(coords[2], gt.snapSizeY)) < JXG.Math.eps + ) + coords = this.hlObjs.hl_point.coords.usrCoords; + + gt.board.off('up'); + + const vertex = this.vertex; + delete this.vertex; + + vertex.setAttribute(gt.definingPointAttributes); + vertex.on('down', () => gt.onPointDown(vertex)); + vertex.on('up', () => gt.onPointUp(vertex)); + + const point = gt.createPoint(coords[1], coords[2], vertex, true); + gt.selectedObj = new gt.graphObjectTypes[this.object](vertex, point, this.vertical, gt.drawSolid); + gt.selectedObj.focusPoint = point; + gt.graphedObjs.push(gt.selectedObj); + + this.finish(); + } + }; + }, + + VerticalParabolaTool(gt) { + return class VerticalParabolaTool extends gt.toolTypes.ParabolaTool { + constructor(container, iconName, tooltip) { + super(container, true, iconName, tooltip); + } + }; + }, + + HorizontalParabolaTool(gt) { + return class HorizontalParabolaTool extends gt.toolTypes.ParabolaTool { + constructor(container, iconName, tooltip) { + super(container, false, iconName, tooltip); + } + }; + } + }; +})(); diff --git a/htdocs/js/GraphTool/pointtool.js b/htdocs/js/GraphTool/pointtool.js index 9689d6251..2420b41a2 100644 --- a/htdocs/js/GraphTool/pointtool.js +++ b/htdocs/js/GraphTool/pointtool.js @@ -1,177 +1,177 @@ /* global graphTool, JXG */ +'use strict'; + (() => { if (graphTool && graphTool.pointTool) return; graphTool.pointTool = { - Point: { - preInit(gt, x, y) { - return gt.board.create('point', [x, y], { - size: 2, - snapToGrid: true, - snapSizeX: gt.snapSizeX, - snapSizeY: gt.snapSizeY, - withLabel: false, - strokeColor: gt.color.curve, - fixed: gt.isStatic, - highlightStrokeColor: gt.color.underConstruction, - highlightFillColor: gt.color.pointHighlight - }); - }, - - postInit(gt) { - this.supportsSolidDash = false; - - // The base object is also a defining point for a Point. This makes it so that a point can not steal - // focus from another focused object that has a defining point at the same location. - this.definingPts.push(this.baseObj); - this.focusPoint = this.baseObj; - - if (!gt.isStatic) { - this.on('down', () => gt.onPointDown(this.baseObj)); - this.on('up', () => gt.onPointUp(this.baseObj)); - this.on('drag', (e) => { - gt.adjustDragPosition(e, this.baseObj); - gt.updateText(); + Point(gt) { + return class extends gt.GraphObject { + static strId = 'point'; + supportsSolidDash = false; + + constructor(x, y) { + super( + gt.board.create('point', [x, y], { + size: 2, + snapToGrid: true, + snapSizeX: gt.snapSizeX, + snapSizeY: gt.snapSizeY, + withLabel: false, + strokeColor: gt.color.curve, + fixed: gt.isStatic, + highlightStrokeColor: gt.color.underConstruction, + highlightFillColor: gt.color.pointHighlight + }) + ); + + // The base object is also a defining point for a Point. This makes it so that a point can not + // steal focus from another focused object that has a defining point at the same location. + this.definingPts.push(this.baseObj); + this.focusPoint = this.baseObj; + + if (!gt.isStatic) { + this.on('down', () => gt.onPointDown(this.baseObj)); + this.on('up', () => gt.onPointUp(this.baseObj)); + this.on('drag', (e) => { + gt.adjustDragPosition(e, this.baseObj); + gt.updateText(); + }); + } + } + + blur() { + this.focused = false; + this.baseObj.setAttribute({ + fixed: true, + highlight: false, + strokeColor: gt.color.curve, + strokeWidth: 2 }); + gt.updateHelp(); } - }, - - blur(gt) { - this.focused = false; - this.baseObj.setAttribute({ - fixed: true, - highlight: false, - strokeColor: gt.color.curve, - strokeWidth: 2 - }); - gt.updateHelp(); - return false; - }, - - focus(gt) { - this.focused = true; - this.baseObj.setAttribute({ - fixed: false, - highlight: true, - strokeColor: gt.color.focusCurve, - strokeWidth: 3 - }); - - this.focusPoint.rendNode.focus(); - gt.updateHelp(); - return false; - }, - - setSolid() {}, - - stringify(gt) { - return `(${gt.snapRound(this.baseObj.X(), gt.snapSizeX)},${gt.snapRound( - this.baseObj.Y(), - gt.snapSizeY - )})`; - }, - - updateTextCoords(gt, coords) { - if (this.baseObj.hasPoint(coords.scrCoords[1], coords.scrCoords[2])) { - gt.setTextCoords(this.baseObj.X(), this.baseObj.Y()); - return true; + + focus() { + this.focused = true; + this.baseObj.setAttribute({ + fixed: false, + highlight: true, + strokeColor: gt.color.focusCurve, + strokeWidth: 3 + }); + + this.focusPoint.rendNode.focus(); + gt.updateHelp(); + } + + setSolid() {} + + stringify() { + return [ + this.constructor.strId, + `(${gt.snapRound(this.baseObj.X(), gt.snapSizeX)},${gt.snapRound( + this.baseObj.Y(), + gt.snapSizeY + )})` + ].join(','); } - }, - - restore(gt, string) { - const points = []; - let pointData = gt.pointRegexp.exec(string); - while (pointData) { - points.push(pointData.slice(1, 3)); - pointData = gt.pointRegexp.exec(string); + + updateTextCoords(coords) { + if (this.baseObj.hasPoint(coords.scrCoords[1], coords.scrCoords[2])) { + gt.setTextCoords(this.baseObj.X(), this.baseObj.Y()); + return true; + } + } + + static restore(string) { + const points = []; + let pointData = gt.pointRegexp.exec(string); + while (pointData) { + points.push(pointData.slice(1, 3)); + pointData = gt.pointRegexp.exec(string); + } + if (points.length < 1) return false; + return new this(parseFloat(points[0][0]), parseFloat(points[0][1])); } - if (points.length < 1) return false; - return new gt.graphObjectTypes.point(parseFloat(points[0][0]), parseFloat(points[0][1])); - } + }; }, - PointTool: { - iconName: 'point', - tooltip: 'Point Tool: Plot a point.', + PointTool(gt) { + return class extends gt.GenericTool { + object = 'point'; + useStandardActivation = true; + activationHelpText = 'Plot a point.'; + useStandardDeactivation = true; - initialize(gt) { - this.phase1 = (coords) => { + constructor(container, iconName, tooltip) { + super(container, iconName ?? 'point', tooltip ?? 'Point Tool: Plot a point.'); + } + + phase1(coords) { // Don't allow the point to be created off the board if (!gt.boardHasPoint(coords[1], coords[2])) return; gt.board.off('up'); - gt.selectedObj = new gt.graphObjectTypes.point(coords[1], coords[2]); + gt.selectedObj = new gt.graphObjectTypes[this.object](coords[1], coords[2]); gt.graphedObjs.push(gt.selectedObj); this.finish(); - }; - }, - - handleKeyEvent(_gt, e) { - if (!this.hlObjs.hl_point) return; - - if (e.key === 'Enter' || e.key === 'Space') { - e.preventDefault(); - e.stopPropagation(); - - this.phase1(this.hlObjs.hl_point.coords.usrCoords); - } - }, - - updateHighlights(gt, e) { - this.hlObjs.hl_point?.rendNode.focus(); - - let coords; - if (e instanceof MouseEvent && e.type === 'pointermove') { - coords = gt.getMouseCoords(e); - this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [coords.usrCoords[1], coords.usrCoords[2]]); - } else if (e instanceof KeyboardEvent && e.type === 'keydown') { - coords = this.hlObjs.hl_point.coords; - } else if (e instanceof JXG.Coords) { - coords = e; - this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [coords.usrCoords[1], coords.usrCoords[2]]); - } else return false; - - if (!this.hlObjs.hl_point) { - this.hlObjs.hl_point = gt.board.create('point', [coords.usrCoords[1], coords.usrCoords[2]], { - size: 2, - color: gt.color.underConstruction, - snapToGrid: true, - highlight: false, - snapSizeX: gt.snapSizeX, - snapSizeY: gt.snapSizeY, - withLabel: false - }); - this.hlObjs.hl_point.rendNode.focus(); } - // Make sure the highlight point is not moved off the board. - if (e instanceof Event) gt.adjustDragPosition(e, this.hlObjs.hl_point); - - gt.setTextCoords(this.hlObjs.hl_point.X(), this.hlObjs.hl_point.Y()); - gt.board.update(); - return true; - }, - - deactivate(gt) { - delete this.helpText; - gt.board.off('up'); - gt.board.containerObj.style.cursor = 'auto'; - }, - - activate(gt) { - gt.board.containerObj.style.cursor = 'none'; + handleKeyEvent(e) { + if (!this.hlObjs.hl_point) return; - // Draw a highlight point on the board. - this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [0, 0], gt.board)); + if (e.key === 'Enter' || e.key === 'Space') { + e.preventDefault(); + e.stopPropagation(); - this.helpText = 'Plot a point.'; - gt.updateHelp(); + this.phase1(this.hlObjs.hl_point.coords.usrCoords); + } + } - gt.board.on('up', (e) => this.phase1(gt.getMouseCoords(e).usrCoords)); - } + updateHighlights(e) { + this.hlObjs.hl_point?.rendNode.focus(); + + let coords; + if (e instanceof MouseEvent && e.type === 'pointermove') { + coords = gt.getMouseCoords(e); + this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [ + coords.usrCoords[1], + coords.usrCoords[2] + ]); + } else if (e instanceof KeyboardEvent && e.type === 'keydown') { + coords = this.hlObjs.hl_point.coords; + } else if (e instanceof JXG.Coords) { + coords = e; + this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [ + coords.usrCoords[1], + coords.usrCoords[2] + ]); + } else return false; + + if (!this.hlObjs.hl_point) { + this.hlObjs.hl_point = gt.board.create('point', [coords.usrCoords[1], coords.usrCoords[2]], { + size: 2, + color: gt.color.underConstruction, + snapToGrid: true, + highlight: false, + snapSizeX: gt.snapSizeX, + snapSizeY: gt.snapSizeY, + withLabel: false + }); + this.hlObjs.hl_point.rendNode.focus(); + } + + // Make sure the highlight point is not moved off the board. + if (e instanceof Event) gt.adjustDragPosition(e, this.hlObjs.hl_point); + + gt.setTextCoords(this.hlObjs.hl_point.X(), this.hlObjs.hl_point.Y()); + gt.board.update(); + return true; + } + }; } }; })(); diff --git a/htdocs/js/GraphTool/quadratictool.js b/htdocs/js/GraphTool/quadratictool.js index fab3d3f2a..a5a8d9420 100644 --- a/htdocs/js/GraphTool/quadratictool.js +++ b/htdocs/js/GraphTool/quadratictool.js @@ -1,66 +1,61 @@ /* global graphTool, JXG */ +'use strict'; + (() => { if (graphTool && graphTool.quadraticTool) return; graphTool.quadraticTool = { - Quadratic: { - preInit(gt, point1, point2, point3, solid) { - [point1, point2, point3].forEach((point) => { - point.setAttribute(gt.definingPointAttributes); - if (!gt.isStatic) { - point.on('down', () => gt.onPointDown(point)); - point.on('up', () => gt.onPointUp(point)); + Quadratic(gt) { + return class extends gt.GraphObject { + static strId = 'quadratic'; + + constructor(point1, point2, point3, solid) { + for (const point of [point1, point2, point3]) { + point.setAttribute(gt.definingPointAttributes); + if (!gt.isStatic) { + point.on('down', () => gt.onPointDown(point)); + point.on('up', () => gt.onPointUp(point)); + } } - }); - return gt.graphObjectTypes.quadratic.createQuadratic(point1, point2, point3, solid, gt.color.curve); - }, - - postInit(_gt, point1, point2, point3) { - this.definingPts.push(point1, point2, point3); - this.focusPoint = point1; - }, - - stringify(gt) { - return [ - this.baseObj.getAttribute('dash') == 0 ? 'solid' : 'dashed', - ...this.definingPts.map( - (point) => `(${gt.snapRound(point.X(), gt.snapSizeX)},${gt.snapRound(point.Y(), gt.snapSizeY)})` - ) - ].join(','); - }, - - fillCmp(gt, point) { - return gt.sign(point[2] - this.baseObj.Y(point[1])); - }, - - restore(gt, string) { - let pointData = gt.pointRegexp.exec(string); - const points = []; - while (pointData) { - points.push(pointData.slice(1, 3)); - pointData = gt.pointRegexp.exec(string); + super(gt.graphObjectTypes.quadratic.createQuadratic(point1, point2, point3, solid, gt.color.curve)); + this.definingPts.push(point1, point2, point3); + this.focusPoint = point1; } - if (points.length < 3) return false; - const point1 = gt.graphObjectTypes.quadratic.createPoint( - parseFloat(points[0][0]), - parseFloat(points[0][1]) - ); - const point2 = gt.graphObjectTypes.quadratic.createPoint( - parseFloat(points[1][0]), - parseFloat(points[1][1]), - [point1] - ); - const point3 = gt.graphObjectTypes.quadratic.createPoint( - parseFloat(points[2][0]), - parseFloat(points[2][1]), - [point1, point2] - ); - return new gt.graphObjectTypes.quadratic(point1, point2, point3, /solid/.test(string)); - }, - - helperMethods: { - createQuadratic(gt, point1, point2, point3, solid, color) { + + stringify() { + return [ + this.constructor.strId, + this.baseObj.getAttribute('dash') == 0 ? 'solid' : 'dashed', + ...this.definingPts.map( + (point) => + `(${gt.snapRound(point.X(), gt.snapSizeX)},${gt.snapRound(point.Y(), gt.snapSizeY)})` + ) + ].join(','); + } + + fillCmp(point) { + return gt.sign(point[2] - this.baseObj.Y(point[1])); + } + + static restore(string) { + let pointData = gt.pointRegexp.exec(string); + const points = []; + while (pointData) { + points.push(pointData.slice(1, 3)); + pointData = gt.pointRegexp.exec(string); + } + if (points.length < 3) return false; + const point1 = this.createPoint(parseFloat(points[0][0]), parseFloat(points[0][1])); + const point2 = this.createPoint(parseFloat(points[1][0]), parseFloat(points[1][1]), [point1]); + const point3 = this.createPoint(parseFloat(points[2][0]), parseFloat(points[2][1]), [ + point1, + point2 + ]); + return new this(point1, point2, point3, /solid/.test(string)); + } + + static createQuadratic(point1, point2, point3, solid, color) { return gt.board.create( 'curve', [ @@ -90,14 +85,14 @@ dash: solid ? 0 : 2 } ); - }, + } // Prevent a point from being moved off the board by a drag. If a group of other points is provided, // then also prevent the point from being moved into the same vertical line as any of those points. - // Note that when this method is called, the point has already been moved by JSXGraph. Note that this - // ensures that the graphed object is a function, but does not prevent the quadratic from degenerating - // into a line. - adjustDragPosition(gt, e, point, groupedPoints) { + // Note that when this method is called, the point has already been moved by JSXGraph. Note that + // this ensures that the graphed object is a function, but does not prevent the quadratic from + // degenerating into a line. + static adjustDragPosition(e, point, groupedPoints) { const bbox = gt.board.getBoundingBox(); let left_x = point.X() < bbox[0] ? bbox[0] : point.X() > bbox[2] ? bbox[2] : point.X(); @@ -121,16 +116,16 @@ y ]); } - }, + } - groupedPointDrag(gt, e) { + static groupedPointDrag(e) { gt.graphObjectTypes.quadratic.adjustDragPosition(e, this, this.grouped_points); gt.setTextCoords(this.X(), this.Y()); gt.updateObjects(); gt.updateText(); - }, + } - createPoint(gt, x, y, grouped_points) { + static createPoint(x, y, grouped_points) { const point = gt.board.create( 'point', [gt.snapRound(x, gt.snapSizeX), gt.snapRound(y, gt.snapSizeY)], @@ -146,38 +141,48 @@ if (!gt.isStatic) { if (typeof grouped_points !== 'undefined' && grouped_points.length) { point.grouped_points = []; - grouped_points.forEach((paired_point) => { + for (const paired_point of grouped_points) { point.grouped_points.push(paired_point); if (!paired_point.grouped_points) { paired_point.grouped_points = []; - paired_point.on('drag', gt.graphObjectTypes.quadratic.groupedPointDrag); + paired_point.on('drag', this.groupedPointDrag); } paired_point.grouped_points.push(point); if ( !paired_point.eventHandlers.drag || paired_point.eventHandlers.drag.every( - (dragHandler) => - dragHandler.handler !== gt.graphObjectTypes.quadratic.groupedPointDrag + (dragHandler) => dragHandler.handler !== this.groupedPointDrag ) ) - paired_point.on('drag', gt.graphObjectTypes.quadratic.groupedPointDrag); - }); - point.on('drag', gt.graphObjectTypes.quadratic.groupedPointDrag, point); + paired_point.on('drag', this.groupedPointDrag); + } + point.on('drag', this.groupedPointDrag, point); } } + return point; } - } + }; }, - QuadraticTool: { - iconName: 'quadratic', - tooltip: '3-Point Quadratic Tool: Graph a quadratic function.', - - initialize(gt) { - this.supportsSolidDash = true; + QuadraticTool(gt) { + return class extends gt.GenericTool { + object = 'quadratic'; + supportsSolidDash = true; + useStandardActivation = true; + activationHelpText = 'Plot three points on the quadratic.'; + useStandardDeactivation = true; + constructionObjects = ['point1', 'point2']; + + constructor(container, iconName, tooltip) { + super( + container, + iconName ?? 'quadratic', + tooltip ?? '3-Point Quadratic Tool: Graph a quadratic function.' + ); + } - this.phase1 = (coords) => { + phase1(coords) { // Don't allow the point to be created off the board. if (!gt.boardHasPoint(coords[1], coords[2])) return; @@ -199,9 +204,9 @@ gt.board.on('up', (e) => this.phase2(gt.getMouseCoords(e).usrCoords)); gt.board.update(); - }; + } - this.phase2 = (coords) => { + phase2(coords) { if (!gt.boardHasPoint(coords[1], coords[2])) return; // If the current coordinates are on the same vertical line as the first point, @@ -230,9 +235,9 @@ gt.board.on('up', (e) => this.phase3(gt.getMouseCoords(e).usrCoords)); gt.board.update(); - }; + } - this.phase3 = (coords) => { + phase3(coords) { if (!gt.boardHasPoint(coords[1], coords[2])) return; // If the current coordinates are on the same vertical line as the first point, or on the same @@ -249,115 +254,104 @@ this.point1, this.point2 ]); - gt.selectedObj = new gt.graphObjectTypes.quadratic(this.point1, this.point2, point3, gt.drawSolid); + gt.selectedObj = new gt.graphObjectTypes[this.object]( + this.point1, + this.point2, + point3, + gt.drawSolid + ); gt.selectedObj.focusPoint = point3; gt.graphedObjs.push(gt.selectedObj); delete this.point1; delete this.point2; this.finish(); - }; - }, - - handleKeyEvent(gt, e) { - if (!this.hlObjs.hl_point || !gt.board.containerObj.contains(document.activeElement)) return; - - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - e.stopPropagation(); - - if (this.point2) this.phase3(this.hlObjs.hl_point.coords.usrCoords); - else if (this.point1) this.phase2(this.hlObjs.hl_point.coords.usrCoords); - else this.phase1(this.hlObjs.hl_point.coords.usrCoords); - } - }, - - updateHighlights(gt, e) { - this.hlObjs.hl_line?.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); - this.hlObjs.hl_quadratic?.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); - this.hlObjs.hl_point?.rendNode.focus(); - - let coords; - if (e instanceof MouseEvent && e.type === 'pointermove') { - coords = gt.getMouseCoords(e); - this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [coords.usrCoords[1], coords.usrCoords[2]]); - } else if (e instanceof KeyboardEvent && e.type === 'keydown') { - coords = this.hlObjs.hl_point.coords; - } else if (e instanceof JXG.Coords) { - coords = e; - this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [coords.usrCoords[1], coords.usrCoords[2]]); - } else return false; - - if (!this.hlObjs.hl_point) { - this.hlObjs.hl_point = gt.board.create('point', [coords.usrCoords[1], coords.usrCoords[2]], { - size: 2, - color: gt.color.underConstruction, - snapToGrid: true, - snapSizeX: gt.snapSizeX, - snapSizeY: gt.snapSizeY, - highlight: false, - withLabel: false - }); - this.hlObjs.hl_point.rendNode.focus(); } - // Make sure the highlight point is not moved off the board or onto the same - // vertical line as any of the other points that have already been created. - if (e instanceof Event) { - const groupedPoints = []; - if (this.point1) groupedPoints.push(this.point1); - if (this.point2) groupedPoints.push(this.point2); - gt.graphObjectTypes.quadratic.adjustDragPosition(e, this.hlObjs.hl_point, groupedPoints); - } + handleKeyEvent(e) { + if (!this.hlObjs.hl_point || !gt.board.containerObj.contains(document.activeElement)) return; - if (this.point2 && !this.hlObjs.hl_quadratic) { - // Delete the temporary highlight line if it exists. - if (this.hlObjs.hl_line) { - gt.board.removeObject(this.hlObjs.hl_line); - delete this.hlObjs.hl_line; - } + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); - this.hlObjs.hl_quadratic = gt.graphObjectTypes.quadratic.createQuadratic( - this.point1, - this.point2, - this.hlObjs.hl_point, - gt.drawSolid - ); - } else if (this.point1 && !this.point2 && !this.hlObjs.hl_line) { - this.hlObjs.hl_line = gt.board.create('line', [this.point1, this.hlObjs.hl_point], { - fixed: true, - strokeColor: gt.color.underConstruction, - highlight: false, - dash: gt.drawSolid ? 0 : 2 - }); + if (this.point2) this.phase3(this.hlObjs.hl_point.coords.usrCoords); + else if (this.point1) this.phase2(this.hlObjs.hl_point.coords.usrCoords); + else this.phase1(this.hlObjs.hl_point.coords.usrCoords); + } } - gt.setTextCoords(this.hlObjs.hl_point.X(), this.hlObjs.hl_point.Y()); - gt.board.update(); - return true; - }, + updateHighlights(e) { + this.hlObjs.hl_line?.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); + this.hlObjs.hl_quadratic?.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); + this.hlObjs.hl_point?.rendNode.focus(); + + let coords; + if (e instanceof MouseEvent && e.type === 'pointermove') { + coords = gt.getMouseCoords(e); + this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [ + coords.usrCoords[1], + coords.usrCoords[2] + ]); + } else if (e instanceof KeyboardEvent && e.type === 'keydown') { + coords = this.hlObjs.hl_point.coords; + } else if (e instanceof JXG.Coords) { + coords = e; + this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [ + coords.usrCoords[1], + coords.usrCoords[2] + ]); + } else return false; - deactivate(gt) { - delete this.helpText; - gt.board.off('up'); - if (this.point1) gt.board.removeObject(this.point1); - delete this.point1; - if (this.point2) gt.board.removeObject(this.point2); - delete this.point2; - gt.board.containerObj.style.cursor = 'auto'; - }, + if (!this.hlObjs.hl_point) { + this.hlObjs.hl_point = gt.board.create('point', [coords.usrCoords[1], coords.usrCoords[2]], { + size: 2, + color: gt.color.underConstruction, + snapToGrid: true, + snapSizeX: gt.snapSizeX, + snapSizeY: gt.snapSizeY, + highlight: false, + withLabel: false + }); + this.hlObjs.hl_point.rendNode.focus(); + } - activate(gt) { - gt.board.containerObj.style.cursor = 'none'; + // Make sure the highlight point is not moved off the board or onto the same + // vertical line as any of the other points that have already been created. + if (e instanceof Event) { + const groupedPoints = []; + if (this.point1) groupedPoints.push(this.point1); + if (this.point2) groupedPoints.push(this.point2); + gt.graphObjectTypes.quadratic.adjustDragPosition(e, this.hlObjs.hl_point, groupedPoints); + } - // Draw a highlight point on the board. - this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [0, 0], gt.board)); + if (this.point2 && !this.hlObjs.hl_quadratic) { + // Delete the temporary highlight line if it exists. + if (this.hlObjs.hl_line) { + gt.board.removeObject(this.hlObjs.hl_line); + delete this.hlObjs.hl_line; + } - this.helpText = 'Plot three points on the quadratic.'; - gt.updateHelp(); + this.hlObjs.hl_quadratic = gt.graphObjectTypes.quadratic.createQuadratic( + this.point1, + this.point2, + this.hlObjs.hl_point, + gt.drawSolid + ); + } else if (this.point1 && !this.point2 && !this.hlObjs.hl_line) { + this.hlObjs.hl_line = gt.board.create('line', [this.point1, this.hlObjs.hl_point], { + fixed: true, + strokeColor: gt.color.underConstruction, + highlight: false, + dash: gt.drawSolid ? 0 : 2 + }); + } - gt.board.on('up', (e) => this.phase1(gt.getMouseCoords(e).usrCoords)); - } + gt.setTextCoords(this.hlObjs.hl_point.X(), this.hlObjs.hl_point.Y()); + gt.board.update(); + return true; + } + }; } }; })(); diff --git a/htdocs/js/GraphTool/quadrilateral.js b/htdocs/js/GraphTool/quadrilateral.js index 7b5ce1e36..9ec263626 100644 --- a/htdocs/js/GraphTool/quadrilateral.js +++ b/htdocs/js/GraphTool/quadrilateral.js @@ -1,157 +1,121 @@ /* global graphTool, JXG */ +'use strict'; + (() => { if (graphTool && graphTool.quadrilateralTool) return; graphTool.quadrilateralTool = { - Quadrilateral: { - preInit(gt, point1, point2, point3, point4, solid) { - for (const point of [point1, point2, point3, point4]) { - point.setAttribute(gt.definingPointAttributes); - if (!gt.isStatic) { - point.on('down', () => gt.onPointDown(point)); - point.on('up', () => gt.onPointUp(point)); + Quadrilateral(gt) { + return class extends gt.GraphObject { + static strId = 'quadrilateral'; + + constructor(point1, point2, point3, point4, solid) { + for (const point of [point1, point2, point3, point4]) { + point.setAttribute(gt.definingPointAttributes); + if (!gt.isStatic) { + point.on('down', () => gt.onPointDown(point)); + point.on('up', () => gt.onPointUp(point)); + } } + super( + gt.graphObjectTypes.triangle.createPolygon( + [point1, point2, point3, point4], + solid, + gt.color.curve + ) + ); + this.definingPts.push(point1, point2, point3, point4); + this.focusPoint = point1; } - return gt.board.create('polygon', [point1, point2, point3, point4], { - highlight: false, - fillOpacity: 0, - fixed: true, - borders: { - strokeWidth: 2, - highlight: false, - fixed: true, - strokeColor: gt.color.curve, - dash: solid ? 0 : 2 - } - }); - }, - - postInit(_gt, point1, point2, point3, point4) { - this.definingPts.push(point1, point2, point3, point4); - this.focusPoint = point1; - }, - - blur(gt) { - this.focused = false; - for (const obj of this.definingPts) obj.setAttribute({ visible: false }); - for (const b of this.baseObj.borders) b.setAttribute({ strokeColor: gt.color.curve, strokeWidth: 2 }); - - gt.updateHelp(); - }, - - focus(gt) { - this.focused = true; - for (const obj of this.definingPts) obj.setAttribute({ visible: true }); - for (const b of this.baseObj.borders) - b.setAttribute({ strokeColor: gt.color.focusCurve, strokeWidth: 3 }); - - // Focus the currently set point of focus for this object. - this.focusPoint?.rendNode.focus(); - - gt.drawSolid = this.baseObj.borders[0].getAttribute('dash') == 0; - if (gt.solidButton) gt.solidButton.disabled = gt.drawSolid; - if (gt.dashedButton) gt.dashedButton.disabled = !gt.drawSolid; - - gt.updateHelp(); - }, - - stringify(gt) { - return [ - this.baseObj.borders[0].getAttribute('dash') === 0 ? 'solid' : 'dashed', - ...this.definingPts.map( - (point) => `(${gt.snapRound(point.X(), gt.snapSizeX)},${gt.snapRound(point.Y(), gt.snapSizeY)})` - ) - ].join(','); - }, - fillCmp(gt, point) { - // Check to see if the point is on the border. - for (let i = 0, j = this.definingPts.length - 1; i < this.definingPts.length; j = i++) { - if ( - point[1] <= Math.max(this.definingPts[i].X(), this.definingPts[j].X()) && - point[1] >= Math.min(this.definingPts[i].X(), this.definingPts[j].X()) && - point[2] <= Math.max(this.definingPts[i].Y(), this.definingPts[j].Y()) && - point[2] >= Math.min(this.definingPts[i].Y(), this.definingPts[j].Y()) && - gt.graphObjectTypes.quadrilateral.areColinear( - point[1], - point[2], - this.definingPts[i], - this.definingPts[j] + blur() { + this.focused = false; + for (const obj of this.definingPts) obj.setAttribute({ visible: false }); + for (const b of this.baseObj.borders) + b.setAttribute({ strokeColor: gt.color.curve, strokeWidth: 2 }); + + gt.updateHelp(); + } + + focus() { + this.focused = true; + for (const obj of this.definingPts) obj.setAttribute({ visible: true }); + for (const b of this.baseObj.borders) + b.setAttribute({ strokeColor: gt.color.focusCurve, strokeWidth: 3 }); + + // Focus the currently set point of focus for this object. + this.focusPoint?.rendNode.focus(); + + gt.drawSolid = this.baseObj.borders[0].getAttribute('dash') == 0; + if (gt.solidButton) gt.solidButton.disabled = gt.drawSolid; + if (gt.dashedButton) gt.dashedButton.disabled = !gt.drawSolid; + + gt.updateHelp(); + } + + stringify() { + return [ + this.constructor.strId, + this.baseObj.borders[0].getAttribute('dash') === 0 ? 'solid' : 'dashed', + ...this.definingPts.map( + (point) => + `(${gt.snapRound(point.X(), gt.snapSizeX)},${gt.snapRound(point.Y(), gt.snapSizeY)})` ) - ) { - return 0; - } + ].join(','); } - // Check to see if the point is inside. - const scrCoords = new JXG.Coords(JXG.COORDS_BY_USER, [point[1], point[2]], gt.board).scrCoords; - const isIn = JXG.Math.Geometry.pnpoly(scrCoords[1], scrCoords[2], this.baseObj.vertices); - if (isIn) { - if (!this.isCrossed()) return 1; + fillCmp(point) { + // Check to see if the point is on the border. + for (let i = 0, j = this.definingPts.length - 1; i < this.definingPts.length; j = i++) { + if ( + point[1] <= Math.max(this.definingPts[i].X(), this.definingPts[j].X()) && + point[1] >= Math.min(this.definingPts[i].X(), this.definingPts[j].X()) && + point[2] <= Math.max(this.definingPts[i].Y(), this.definingPts[j].Y()) && + point[2] >= Math.min(this.definingPts[i].Y(), this.definingPts[j].Y()) && + gt.areColinear(point.slice(1), this.definingPts[i], this.definingPts[j]) + ) { + return 0; + } + } + + // Check to see if the point is inside. + const scrCoords = new JXG.Coords(JXG.COORDS_BY_USER, [point[1], point[2]], gt.board).scrCoords; + const isIn = JXG.Math.Geometry.pnpoly(scrCoords[1], scrCoords[2], this.baseObj.vertices); + if (isIn) { + if (!this.isCrossed()) return 1; - let result = 1; - for (const [i, border] of this.baseObj.borders.entries()) { - if (gt.sign(JXG.Math.innerProduct(point, border.stdform)) > 0) result |= 1 << (i + 1); + let result = 1; + for (const [i, border] of this.baseObj.borders.entries()) { + if (gt.sign(JXG.Math.innerProduct(point, border.stdform)) > 0) result |= 1 << (i + 1); + } + return result; } - return result; + return -1; } - return -1; - }, - - onBoundary(gt, point, aVal, _from) { - if (this.fillCmp(point) != aVal) return true; - for (const border of this.baseObj.borders) { - if ( - Math.abs(JXG.Math.innerProduct(point, border.stdform)) / - Math.sqrt(border.stdform[1] ** 2 + border.stdform[2] ** 2) < - 0.5 / Math.sqrt(gt.board.unitX * gt.board.unitY) && - point[1] > Math.min(border.point1.X(), border.point2.X()) - 0.5 / gt.board.unitX && - point[1] < Math.max(border.point1.X(), border.point2.X()) + 0.5 / gt.board.unitX && - point[2] > Math.min(border.point1.Y(), border.point2.Y()) - 0.5 / gt.board.unitY && - point[2] < Math.max(border.point1.Y(), border.point2.Y()) + 0.5 / gt.board.unitY - ) - return true; + onBoundary(point, aVal, _from) { + if (this.fillCmp(point) != aVal) return true; + + for (const border of this.baseObj.borders) { + if ( + Math.abs(JXG.Math.innerProduct(point, border.stdform)) / + Math.sqrt(border.stdform[1] ** 2 + border.stdform[2] ** 2) < + 0.5 / Math.sqrt(gt.board.unitX * gt.board.unitY) && + point[1] > Math.min(border.point1.X(), border.point2.X()) - 0.5 / gt.board.unitX && + point[1] < Math.max(border.point1.X(), border.point2.X()) + 0.5 / gt.board.unitX && + point[2] > Math.min(border.point1.Y(), border.point2.Y()) - 0.5 / gt.board.unitY && + point[2] < Math.max(border.point1.Y(), border.point2.Y()) + 0.5 / gt.board.unitY + ) + return true; + } + return false; } - return false; - }, - - setSolid(_gt, solid) { - for (const border of this.baseObj.borders) border.setAttribute({ dash: solid ? 0 : 2 }); - }, - - restore(gt, string) { - let pointData = gt.pointRegexp.exec(string); - const points = []; - while (pointData) { - points.push(pointData.slice(1, 3)); - pointData = gt.pointRegexp.exec(string); + + setSolid(solid) { + for (const border of this.baseObj.borders) border.setAttribute({ dash: solid ? 0 : 2 }); } - if (points.length < 4) return false; - const point1 = gt.graphObjectTypes.quadrilateral.createPoint( - parseFloat(points[0][0]), - parseFloat(points[0][1]) - ); - const point2 = gt.graphObjectTypes.quadrilateral.createPoint( - parseFloat(points[1][0]), - parseFloat(points[1][1]), - [point1] - ); - const point3 = gt.graphObjectTypes.quadrilateral.createPoint( - parseFloat(points[2][0]), - parseFloat(points[2][1]), - [point1, point2] - ); - const point4 = gt.graphObjectTypes.quadrilateral.createPoint( - parseFloat(points[3][0]), - parseFloat(points[3][1]), - [point1, point2, point3] - ); - return new gt.graphObjectTypes.quadrilateral(point1, point2, point3, point4, /solid/.test(string)); - }, - - classMethods: { + isCrossed() { const points = this.baseObj.vertices; const borders = this.baseObj.borders; @@ -166,14 +130,35 @@ JXG.Math.innerProduct(points[2].coords.usrCoords, borders[3].stdform) > 0) ); } - }, - - helperMethods: { - // Prevent a point from being moved off the board by a drag. If one or two other points are provided, - // then also prevent the point from being moved onto those points or the line between them if there are - // two. Note that when this method is called, the point has already been moved by JSXGraph. Note that - // this ensures that the graphed object is a quadrilateral, and does not degenerate. - adjustDragPosition(gt, e, point, groupedPoints) { + + static restore(string) { + let pointData = gt.pointRegexp.exec(string); + const points = []; + while (pointData) { + points.push(pointData.slice(1, 3)); + pointData = gt.pointRegexp.exec(string); + } + if (points.length < 4) return false; + const point1 = this.createPoint(parseFloat(points[0][0]), parseFloat(points[0][1])); + const point2 = this.createPoint(parseFloat(points[1][0]), parseFloat(points[1][1]), [point1]); + const point3 = this.createPoint(parseFloat(points[2][0]), parseFloat(points[2][1]), [ + point1, + point2 + ]); + const point4 = this.createPoint(parseFloat(points[3][0]), parseFloat(points[3][1]), [ + point1, + point2, + point3 + ]); + return new this(point1, point2, point3, point4, /solid/.test(string)); + } + + // Prevent a point from being moved off the board by a drag. If one or two other points are + // provided, then also prevent the point from being moved onto those points or the line between them + // if there are two. Note that when this method is called, the point has already been moved by + // JSXGraph. Note that this ensures that the graphed object is a quadrilateral, and does not + // degenerate. + static adjustDragPosition(e, point, groupedPoints) { const bbox = gt.board.getBoundingBox(); let x = point.X() < bbox[0] ? bbox[0] : point.X() > bbox[2] ? bbox[2] : point.X(); @@ -201,26 +186,20 @@ } x += xDir * gt.snapSizeX; y += yDir * gt.snapSizeY; - } else if ( - groupedPoints.length == 2 && - gt.graphObjectTypes.quadrilateral.areColinear(x, y, ...groupedPoints) - ) { - // Adjust the position of the point if it is on the line passing through the two grouped points. + } else if (groupedPoints.length == 2 && gt.areColinear([x, y], ...groupedPoints)) { + // Adjust the position of the point if it is on the line + // passing through the two grouped points. if (e.type === 'pointermove') { const coords = gt.getMouseCoords(e); - // Of the points to the left of, right of, above, and below the current point, find those - // that are on the board and not on the line between the two grouped points. + // Of the points to the left of, right of, above, and below the current point, find + // those that are on the board and not on the line between the two grouped points. const points = [ [x - gt.snapSizeX, y], [x + gt.snapSizeX, y], [x, y + gt.snapSizeY], [x, y - gt.snapSizeY] - ].filter( - (p) => - gt.boardHasPoint(...p) && - !gt.graphObjectTypes.quadrilateral.areColinear(...p, ...groupedPoints) - ); + ].filter((p) => gt.boardHasPoint(...p) && !gt.areColinear(p, ...groupedPoints)); // Move to the point closest to the mouse cursor. let min = -1; @@ -238,18 +217,15 @@ x += xDir * gt.snapSizeX; y += yDir * gt.snapSizeY; } - } else if ( - groupedPoints.length == 3 && - gt.graphObjectTypes.quadrilateral.arePairwiseColinear(x, y, ...groupedPoints) - ) { + } else if (groupedPoints.length == 3 && gt.arePairwiseColinear([x, y], ...groupedPoints)) { // Adjust the position of the point if it is on any of the // lines passing through the pairs of grouped points. if (e.type === 'pointermove') { const coords = gt.getMouseCoords(e); - // Of the points to the upper left of, above, upper right of, left of, right of, lower left - // of, below, and lower right of the current point, find those that are on the board and not - // on the line between the two grouped points. + // Of the points to the upper left of, above, upper right of, left of, right of, lower + // left of, below, and lower right of the current point, find those that are on the + // board and not on the line between the two grouped points. const points = [ [x - gt.snapSizeX, y + gt.snapSizeY], [x, y + gt.snapSizeY], @@ -259,11 +235,7 @@ [x - gt.snapSizeX, y - gt.snapSizeY], [(x, y - gt.snapSizeY)], [x + gt.snapSizeX, y - gt.snapSizeY] - ].filter( - (p) => - gt.boardHasPoint(...p) && - !gt.graphObjectTypes.quadrilateral.arePairwiseColinear(...p, ...groupedPoints) - ); + ].filter((p) => gt.boardHasPoint(...p) && !gt.arePairwiseColinear(p, ...groupedPoints)); // Move to the point closest to the mouse cursor. let min = -1; @@ -280,7 +252,7 @@ const yDir = e.key === 'ArrowUp' ? 1 : e.key === 'ArrowDown' ? -1 : 0; x += xDir * gt.snapSizeX; y += yDir * gt.snapSizeY; - if (gt.graphObjectTypes.quadrilateral.arePairwiseColinear(x, y, ...groupedPoints)) { + if (gt.arePairwiseColinear([x, y], ...groupedPoints)) { x += xDir * gt.snapSizeX; y += yDir * gt.snapSizeY; } @@ -295,16 +267,16 @@ else if (y > bbox[1]) y = bbox[1] - gt.snapSizeY; point.setPosition(JXG.COORDS_BY_USER, [x, y]); - }, + } - groupedPointDrag(gt, e) { + static groupedPointDrag(e) { gt.graphObjectTypes.quadrilateral.adjustDragPosition(e, this, this.grouped_points); gt.setTextCoords(this.X(), this.Y()); gt.updateObjects(); gt.updateText(); - }, + } - createPoint(gt, x, y, grouped_points) { + static createPoint(x, y, grouped_points) { const point = gt.board.create( 'point', [gt.snapRound(x, gt.snapSizeX), gt.snapRound(y, gt.snapSizeY)], @@ -324,49 +296,43 @@ point.grouped_points.push(grouped_point); if (!grouped_point.grouped_points) { grouped_point.grouped_points = []; - grouped_point.on('drag', gt.graphObjectTypes.quadrilateral.groupedPointDrag); + grouped_point.on('drag', this.groupedPointDrag); } grouped_point.grouped_points.push(point); if ( !grouped_point.eventHandlers.drag || grouped_point.eventHandlers.drag.every( - (dragHandler) => - dragHandler.handler !== gt.graphObjectTypes.quadrilateral.groupedPointDrag + (dragHandler) => dragHandler.handler !== this.groupedPointDrag ) ) - grouped_point.on('drag', gt.graphObjectTypes.quadrilateral.groupedPointDrag); + grouped_point.on('drag', this.groupedPointDrag); } - point.on('drag', gt.graphObjectTypes.quadrilateral.groupedPointDrag, point); + point.on('drag', this.groupedPointDrag, point); } } return point; - }, - - // This returns true if the points (x, y), p1, and p2 are colinear. - areColinear(_gt, x, y, p1, p2) { - return Math.abs((y - p1.Y()) * (p2.X() - p1.X()) - (p2.Y() - p1.Y()) * (x - p1.X())) < JXG.Math.eps; - }, - - // This returns true if the point (x, y) is on one of the lines - // through the pairs of points given in p1, p2, and p3. - arePairwiseColinear(gt, x, y, p1, p2, p3) { - return ( - gt.graphObjectTypes.quadrilateral.areColinear(x, y, p1, p2) || - gt.graphObjectTypes.quadrilateral.areColinear(x, y, p1, p3) || - gt.graphObjectTypes.quadrilateral.areColinear(x, y, p2, p3) - ); } - } + }; }, - QuadrilateralTool: { - iconName: 'quadrilateral', - tooltip: 'Quadrilateral Tool: Graph a quadrilateral.', - - initialize(gt) { - this.supportsSolidDash = true; + QuadrilateralTool(gt) { + return class extends gt.GenericTool { + object = 'quadrilateral'; + supportsSolidDash = true; + useStandardActivation = true; + activationHelpText = 'Plot the vertices of the quadrilateral.'; + useStandardDeactivation = true; + constructionObjects = ['point1', 'point2', 'point3']; + + constructor(container, iconName, tooltip) { + super( + container, + iconName ?? 'quadrilateral', + tooltip ?? 'Quadrilateral Tool: Graph a quadrilateral.' + ); + } - this.phase1 = (coords) => { + phase1(coords) { // Don't allow the point to be created off the board. if (!gt.boardHasPoint(coords[1], coords[2])) return; @@ -395,9 +361,9 @@ gt.board.on('up', (e) => this.phase2(gt.getMouseCoords(e).usrCoords)); gt.board.update(); - }; + } - this.phase2 = (coords) => { + phase2(coords) { if (!gt.boardHasPoint(coords[1], coords[2])) return; // If the current coordinates are on top of the first point, then use the highlight point @@ -417,14 +383,12 @@ // point is off the board. In that case go left and down instead. let newX = this.point2.X() + gt.snapSizeX; let newY = this.point2.Y() + gt.snapSizeY; - if (gt.graphObjectTypes.quadrilateral.areColinear(newX, newY, this.point1, this.point2)) - newX += gt.snapSizeX; + if (gt.areColinear([newX, newY], this.point1, this.point2)) newX += gt.snapSizeX; if (newX > gt.board.getBoundingBox()[2] || newY > gt.board.getBoundingBox()[1]) { newX = this.point2.X() - gt.snapSizeX; newY = this.point2.Y() - gt.snapSizeY; - if (gt.graphObjectTypes.quadrilateral.areColinear(newX, newY, this.point1, this.point2)) - newX -= gt.snapSizeX; + if (gt.areColinear([newX, newY], this.point1, this.point2)) newX -= gt.snapSizeX; } this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [newX, newY], gt.board)); @@ -435,17 +399,16 @@ gt.board.on('up', (e) => this.phase3(gt.getMouseCoords(e).usrCoords)); gt.board.update(); - }; + } - this.phase3 = (coords) => { + phase3(coords) { if (!gt.boardHasPoint(coords[1], coords[2])) return; // If the current coordinates are on the line through the first and second points, // then use the highlight point coordinates instead. if ( - gt.graphObjectTypes.quadrilateral.areColinear( - gt.snapRound(coords[1], gt.snapSizeX), - gt.snapRound(coords[2], gt.snapSizeY), + gt.areColinear( + [gt.snapRound(coords[1], gt.snapSizeX), gt.snapRound(coords[2], gt.snapSizeY)], this.point1, this.point2 ) @@ -461,9 +424,9 @@ this.point3.setAttribute({ fixed: true, highlight: false }); // Get new coordinates for a point that is on the board and not on any of the lines between the - // other vertices. This starts at a point one snap size to the right of the last vertex graphed, and - // then circles around the last vertex in a counter clockwise direction on the latice of points with - // coordinates that are multiples of the snap sizes until it finds one that works. + // other vertices. This starts at a point one snap size to the right of the last vertex graphed, + // and then circles around the last vertex in a counter clockwise direction on the latice of + // points with coordinates that are multiples of the snap sizes until it finds one that works. let count = 0; let hDir = 0, vDir = -1; @@ -483,13 +446,7 @@ --count; } while ( (!gt.boardHasPoint(newX, newY) || - gt.graphObjectTypes.quadrilateral.arePairwiseColinear( - newX, - newY, - this.point1, - this.point2, - this.point3 - )) && + gt.arePairwiseColinear([newX, newY], this.point1, this.point2, this.point3)) && times < 20 ); @@ -501,18 +458,17 @@ gt.board.on('up', (e) => this.phase4(gt.getMouseCoords(e).usrCoords)); gt.board.update(); - }; + } - this.phase4 = (coords) => { + phase4(coords) { if (!gt.boardHasPoint(coords[1], coords[2])) return; - // If the current coordinates are on the line through the first and second points, or on the line - // through the first and third points, or on the line through the second and third points, then use - // the highlight point coordinates instead. + // If the current coordinates are on the line through the first and second points, or on the + // line through the first and third points, or on the line through the second and third points, + // then use the highlight point coordinates instead. if ( - gt.graphObjectTypes.quadrilateral.arePairwiseColinear( - gt.snapRound(coords[1], gt.snapSizeX), - gt.snapRound(coords[2], gt.snapSizeY), + gt.arePairwiseColinear( + [gt.snapRound(coords[1], gt.snapSizeX), gt.snapRound(coords[2], gt.snapSizeY)], this.point1, this.point2, this.point3 @@ -527,7 +483,7 @@ this.point2, this.point3 ]); - gt.selectedObj = new gt.graphObjectTypes.quadrilateral( + gt.selectedObj = new gt.graphObjectTypes[this.object]( this.point1, this.point2, this.point3, @@ -541,129 +497,102 @@ delete this.point3; this.finish(); - }; - }, + } - handleKeyEvent(gt, e) { - if (!this.hlObjs.hl_point || !gt.board.containerObj.contains(document.activeElement)) return; + handleKeyEvent(e) { + if (!this.hlObjs.hl_point || !gt.board.containerObj.contains(document.activeElement)) return; - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - e.stopPropagation(); + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); - if (this.point3) this.phase4(this.hlObjs.hl_point.coords.usrCoords); - else if (this.point2) this.phase3(this.hlObjs.hl_point.coords.usrCoords); - else if (this.point1) this.phase2(this.hlObjs.hl_point.coords.usrCoords); - else this.phase1(this.hlObjs.hl_point.coords.usrCoords); - } - }, - - updateHighlights(gt, e) { - this.hlObjs.hl_line?.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); - for (const border of this.hlObjs.hl_quadrilateral?.borders ?? []) - border.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); - this.hlObjs.hl_point?.rendNode.focus(); - - let coords; - if (e instanceof MouseEvent && e.type === 'pointermove') { - coords = gt.getMouseCoords(e); - this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [coords.usrCoords[1], coords.usrCoords[2]]); - } else if (e instanceof KeyboardEvent && e.type === 'keydown') { - coords = this.hlObjs.hl_point.coords; - } else if (e instanceof JXG.Coords) { - coords = e; - this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [coords.usrCoords[1], coords.usrCoords[2]]); - } else return false; - - if (!this.hlObjs.hl_point) { - this.hlObjs.hl_point = gt.board.create('point', [coords.usrCoords[1], coords.usrCoords[2]], { - size: 2, - color: gt.color.underConstruction, - snapToGrid: true, - highlight: false, - snapSizeX: gt.snapSizeX, - snapSizeY: gt.snapSizeY, - withLabel: false - }); - this.hlObjs.hl_point.rendNode.focus(); + if (this.point3) this.phase4(this.hlObjs.hl_point.coords.usrCoords); + else if (this.point2) this.phase3(this.hlObjs.hl_point.coords.usrCoords); + else if (this.point1) this.phase2(this.hlObjs.hl_point.coords.usrCoords); + else this.phase1(this.hlObjs.hl_point.coords.usrCoords); + } } - // Make sure the highlight point is not moved off the board or onto - // any other points or lines that have already been created. - if (e instanceof Event) { - const groupedPoints = []; - if (this.point1) groupedPoints.push(this.point1); - if (this.point2) groupedPoints.push(this.point2); - if (this.point3) groupedPoints.push(this.point3); - gt.graphObjectTypes.quadrilateral.adjustDragPosition(e, this.hlObjs.hl_point, groupedPoints); - } + updateHighlights(e) { + this.hlObjs.hl_line?.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); + for (const border of this.hlObjs.hl_quadrilateral?.borders ?? []) + border.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); + this.hlObjs.hl_point?.rendNode.focus(); + + let coords; + if (e instanceof MouseEvent && e.type === 'pointermove') { + coords = gt.getMouseCoords(e); + this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [ + coords.usrCoords[1], + coords.usrCoords[2] + ]); + } else if (e instanceof KeyboardEvent && e.type === 'keydown') { + coords = this.hlObjs.hl_point.coords; + } else if (e instanceof JXG.Coords) { + coords = e; + this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [ + coords.usrCoords[1], + coords.usrCoords[2] + ]); + } else return false; + + if (!this.hlObjs.hl_point) { + this.hlObjs.hl_point = gt.board.create('point', [coords.usrCoords[1], coords.usrCoords[2]], { + size: 2, + color: gt.color.underConstruction, + snapToGrid: true, + highlight: false, + snapSizeX: gt.snapSizeX, + snapSizeY: gt.snapSizeY, + withLabel: false + }); + this.hlObjs.hl_point.rendNode.focus(); + } - if (this.point3 && this.hlObjs.hl_quadrilateral && this.hlObjs.hl_quadrilateral.vertices.length < 5) { - this.hlObjs.hl_quadrilateral.removePoints(this.hlObjs.hl_point); - this.hlObjs.hl_quadrilateral.addPoints(this.point3, this.hlObjs.hl_point); - } else if (this.point2 && !this.hlObjs.hl_quadrilateral) { - // Delete the temporary highlight line if it exists. - if (this.hlObjs.hl_line) { - gt.board.removeObject(this.hlObjs.hl_line); - delete this.hlObjs.hl_line; + // Make sure the highlight point is not moved off the board or onto + // any other points or lines that have already been created. + if (e instanceof Event) { + const groupedPoints = []; + if (this.point1) groupedPoints.push(this.point1); + if (this.point2) groupedPoints.push(this.point2); + if (this.point3) groupedPoints.push(this.point3); + gt.graphObjectTypes.quadrilateral.adjustDragPosition(e, this.hlObjs.hl_point, groupedPoints); } - this.hlObjs.hl_quadrilateral = gt.board.create( - 'polygon', - [this.point1, this.point2, this.hlObjs.hl_point], - { - highlight: false, - fillOpacity: 0, - fixed: true, - borders: { - strokeWidth: 2, - highlight: false, - fixed: true, - strokeColor: gt.color.underConstruction, - dash: gt.drawSolid ? 0 : 2 - } + if ( + this.point3 && + this.hlObjs.hl_quadrilateral && + this.hlObjs.hl_quadrilateral.vertices.length < 5 + ) { + this.hlObjs.hl_quadrilateral.removePoints(this.hlObjs.hl_point); + this.hlObjs.hl_quadrilateral.addPoints(this.point3, this.hlObjs.hl_point); + } else if (this.point2 && !this.hlObjs.hl_quadrilateral) { + // Delete the temporary highlight line if it exists. + if (this.hlObjs.hl_line) { + gt.board.removeObject(this.hlObjs.hl_line); + delete this.hlObjs.hl_line; } - ); - } else if (this.point1 && !this.point2 && !this.hlObjs.hl_line) { - this.hlObjs.hl_line = gt.board.create('line', [this.point1, this.hlObjs.hl_point], { - fixed: true, - strokeColor: gt.color.underConstruction, - highlight: false, - dash: gt.drawSolid ? 0 : 2, - straightFirst: false, - straightLast: false - }); - } - gt.setTextCoords(this.hlObjs.hl_point.X(), this.hlObjs.hl_point.Y()); - gt.board.update(); - return true; - }, - - deactivate(gt) { - delete this.helpText; - gt.board.off('up'); - if (this.point1) gt.board.removeObject(this.point1); - delete this.point1; - if (this.point2) gt.board.removeObject(this.point2); - delete this.point2; - if (this.point3) gt.board.removeObject(this.point3); - delete this.point3; - gt.board.containerObj.style.cursor = 'auto'; - }, - - activate(gt) { - gt.board.containerObj.style.cursor = 'none'; - - // Draw a highlight point on the board. - this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [0, 0], gt.board)); - - this.helpText = 'Plot the vertices of the quadrilateral.'; - gt.updateHelp(); - - // Wait for the user to select the first point. - gt.board.on('up', (e) => this.phase1(gt.getMouseCoords(e).usrCoords)); - } + this.hlObjs.hl_quadrilateral = gt.graphObjectTypes.triangle.createPolygon( + [this.point1, this.point2, this.hlObjs.hl_point], + gt.drawSolid + ); + } else if (this.point1 && !this.point2 && !this.hlObjs.hl_line) { + this.hlObjs.hl_line = gt.board.create('line', [this.point1, this.hlObjs.hl_point], { + fixed: true, + strokeColor: gt.color.underConstruction, + highlight: false, + dash: gt.drawSolid ? 0 : 2, + straightFirst: false, + straightLast: false + }); + } + + gt.setTextCoords(this.hlObjs.hl_point.X(), this.hlObjs.hl_point.Y()); + gt.board.update(); + return true; + } + }; } }; })(); diff --git a/htdocs/js/GraphTool/segments.js b/htdocs/js/GraphTool/segments.js index 7b65dfba4..48e2c2fef 100644 --- a/htdocs/js/GraphTool/segments.js +++ b/htdocs/js/GraphTool/segments.js @@ -1,202 +1,130 @@ -/* global graphTool, JXG */ +/* global graphTool */ 'use strict'; (() => { if (!graphTool) return; - const stringify = function (gt) { - return [ - this.baseObj.getAttribute('dash') === 0 ? 'solid' : 'dashed', - ...this.definingPts.map( - (point) => `(${gt.snapRound(point.X(), gt.snapSizeX)},${gt.snapRound(point.Y(), gt.snapSizeY)})` - ) - ].join(','); - }; - - const restore = function (gt, string, objectClass) { - let pointData = gt.pointRegexp.exec(string); - const points = []; - while (pointData) { - points.push(pointData.slice(1, 3)); - pointData = gt.pointRegexp.exec(string); - } - if (points.length < 2) return false; - const point1 = gt.createPoint(parseFloat(points[0][0]), parseFloat(points[0][1])); - const point2 = gt.createPoint(parseFloat(points[1][0]), parseFloat(points[1][1]), point1); - return new objectClass(point1, point2, /solid/.test(string)); - }; - - const fillCmp = function (gt, point) { - return ( - gt.graphObjectTypes.line.prototype.fillCmp.call(this, point) || - (point[1] >= Math.min(this.definingPts[0].X(), this.definingPts[1].X()) && - point[1] <= Math.max(this.definingPts[0].X(), this.definingPts[1].X()) && - point[2] >= Math.min(this.definingPts[0].Y(), this.definingPts[1].Y()) && - point[2] <= Math.max(this.definingPts[0].Y(), this.definingPts[1].Y()) - ? 0 - : 1) - ); - }; - - const onBoundary = function (gt, point, _aVal, from) { - if ( - !( - point[1] > Math.min(this.definingPts[0].X(), this.definingPts[1].X()) - 0.5 / gt.board.unitX && - point[1] < Math.max(this.definingPts[0].X(), this.definingPts[1].X()) + 0.5 / gt.board.unitX && - point[2] > Math.min(this.definingPts[0].Y(), this.definingPts[1].Y()) - 0.5 / gt.board.unitY && - point[2] < Math.max(this.definingPts[0].Y(), this.definingPts[1].Y()) + 0.5 / gt.board.unitY - ) - ) - return 0; - - const crossingStdForm = [point[1] * from[2] - point[2] * from[1], point[2] - from[2], from[1] - point[1]]; - const pointSide = JXG.Math.innerProduct(point, this.baseObj.stdform); - return ( - (JXG.Math.innerProduct(from, this.baseObj.stdform) > 0 != pointSide > 0 && - JXG.Math.innerProduct(this.baseObj.point1.coords.usrCoords, crossingStdForm) > 0 != - JXG.Math.innerProduct(this.baseObj.point2.coords.usrCoords, crossingStdForm) > 0) || - Math.abs(pointSide) / Math.sqrt(this.baseObj.stdform[1] ** 2 + this.baseObj.stdform[2] ** 2) < - 0.5 / Math.sqrt(gt.board.unitX * gt.board.unitY) - ); - }; - - const initialize = function (gt, helpText, objectClass) { - this.phase1 = (coords) => { - gt.toolTypes.LineTool.prototype.phase1.call(this, coords); - this.helpText = helpText; - gt.updateHelp(); - }; - - this.phase2 = (coords) => { - if (!gt.boardHasPoint(coords[1], coords[2])) return; - - // If the current coordinates are on top of the first, - // then use the highlight point coordinates instead. - if ( - Math.abs(this.point1.X() - gt.snapRound(coords[1], gt.snapSizeX)) < JXG.Math.eps && - Math.abs(this.point1.Y() - gt.snapRound(coords[2], gt.snapSizeY)) < JXG.Math.eps - ) - coords = this.hlObjs.hl_point.coords.usrCoords; - - gt.board.off('up'); - - const point1 = this.point1; - delete this.point1; - - point1.setAttribute(gt.definingPointAttributes); - - point1.on('down', () => gt.onPointDown(point1)); - point1.on('up', () => gt.onPointUp(point1)); - - const point2 = gt.createPoint(coords[1], coords[2], point1); - gt.selectedObj = new objectClass(point1, point2, gt.drawSolid); - gt.selectedObj.focusPoint = point2; - gt.graphedObjs.push(gt.selectedObj); - - this.finish(); - }; - }; - if (!graphTool.segmentTool) { graphTool.segmentTool = { - Segment: { - parent: 'line', - - postInit(_gt, _point1, _point2, _solid) { - this.baseObj.setAttribute({ straightFirst: false, straightLast: false }); - }, - - fillCmp(gt, point) { - return fillCmp.call(this, gt, point); - }, - - onBoundary(gt, point, aVal, from) { - return onBoundary.call(this, gt, point, aVal, from); - }, - - stringify(gt) { - return stringify.call(this, gt); - }, - - restore(gt, string) { - return restore.call(this, gt, string, gt.graphObjectTypes.segment); - } + Segment(gt) { + return class extends gt.graphObjectTypes.line { + static strId = 'segment'; + + constructor(point1, point2, solid) { + super(point1, point2, solid); + this.baseObj.setAttribute({ straightFirst: false, straightLast: false }); + } + + fillCmp(point) { + return ( + super.fillCmp(point) || + (point[1] >= Math.min(this.definingPts[0].X(), this.definingPts[1].X()) && + point[1] <= Math.max(this.definingPts[0].X(), this.definingPts[1].X()) && + point[2] >= Math.min(this.definingPts[0].Y(), this.definingPts[1].Y()) && + point[2] <= Math.max(this.definingPts[0].Y(), this.definingPts[1].Y()) + ? 0 + : 1) + ); + } + + onBoundary(point, _aVal, from) { + if ( + !( + point[1] > + Math.min(this.definingPts[0].X(), this.definingPts[1].X()) - 0.5 / gt.board.unitX && + point[1] < + Math.max(this.definingPts[0].X(), this.definingPts[1].X()) + 0.5 / gt.board.unitX && + point[2] > + Math.min(this.definingPts[0].Y(), this.definingPts[1].Y()) - 0.5 / gt.board.unitY && + point[2] < + Math.max(this.definingPts[0].Y(), this.definingPts[1].Y()) + 0.5 / gt.board.unitY + ) + ) + return 0; + + const crossingStdForm = [ + point[1] * from[2] - point[2] * from[1], + point[2] - from[2], + from[1] - point[1] + ]; + const pointSide = JXG.Math.innerProduct(point, this.baseObj.stdform); + + return ( + (JXG.Math.innerProduct(from, this.baseObj.stdform) > 0 != pointSide > 0 && + JXG.Math.innerProduct(this.baseObj.point1.coords.usrCoords, crossingStdForm) > 0 != + JXG.Math.innerProduct(this.baseObj.point2.coords.usrCoords, crossingStdForm) > 0) || + Math.abs(pointSide) / + Math.sqrt(this.baseObj.stdform[1] ** 2 + this.baseObj.stdform[2] ** 2) < + 0.5 / Math.sqrt(gt.board.unitX * gt.board.unitY) + ); + } + }; }, - SegmentTool: { - iconName: 'segment', - tooltip: 'Segment Tool: Graph a line segment.', - parent: 'LineTool', - - initialize(gt) { - initialize.call(this, gt, 'Plot the other end of the line segment.', gt.graphObjectTypes.segment); - }, - - updateHighlights(gt, e) { - const handled = gt.toolTypes.LineTool.prototype.updateHighlights.call(this, e); - this.hlObjs.hl_line?.setAttribute({ straightFirst: false, straightLast: false }); - return handled; - }, - - activate(gt) { - this.helpText = 'Plot the points at the ends of the line segment.'; - gt.updateHelp(); - } + SegmentTool(gt) { + return class extends gt.toolTypes.LineTool { + object = 'segment'; + activationHelpText = 'Plot the points at the ends of the line segment.'; + + constructor(container, iconName, tooltip) { + super(container, iconName ?? 'segment', tooltip ?? 'Segment Tool: Graph a line segment.'); + } + + updateHighlights(e) { + const handled = super.updateHighlights(e); + this.hlObjs.hl_line?.setAttribute({ straightFirst: false, straightLast: false }); + return handled; + } + + phase1(coords) { + super.phase1(coords); + this.helpText = 'Plot the other end of the line segment.'; + gt.updateHelp(); + } + }; } }; } if (!graphTool.vectorTool) { graphTool.vectorTool = { - Vector: { - parent: 'line', - - postInit(_gt, _point1, _point2, _solid) { - this.baseObj.setAttribute({ straightFirst: false, straightLast: false }); - this.baseObj.setArrow(false, { type: 1, size: 4 }); - }, - - fillCmp(gt, point) { - return fillCmp.call(this, gt, point); - }, - - onBoundary(gt, point, aVal, from) { - return onBoundary.call(this, gt, point, aVal, from); - }, - - stringify(gt) { - return stringify.call(this, gt); - }, - - restore(gt, string) { - return restore.call(this, gt, string, gt.graphObjectTypes.vector); - } + Vector(gt) { + return class extends gt.graphObjectTypes.segment { + static strId = 'vector'; + + constructor(point1, point2, solid) { + super(point1, point2, solid); + this.baseObj.setArrow(false, { type: 1, size: 4 }); + } + }; }, - VectorTool: { - iconName: 'vector', - tooltip: 'Vector Tool: Graph a vector.', - parent: 'LineTool', - - initialize(gt) { - initialize.call(this, gt, 'Plot the terminal point of the vector.', gt.graphObjectTypes.vector); - }, - - updateHighlights(gt, e) { - const handled = gt.toolTypes.LineTool.prototype.updateHighlights.call(this, e); - this.hlObjs.hl_line?.setAttribute({ - straightFirst: false, - straightLast: false, - lastArrow: { type: 1, size: 6 } - }); - return handled; - }, - - activate(gt) { - this.helpText = 'Plot the initial point and then the terminal point of the vector.'; - gt.updateHelp(); - } + VectorTool(gt) { + return class extends gt.toolTypes.LineTool { + object = 'vector'; + activationHelpText = 'Plot the initial point and then the terminal point of the vector.'; + + constructor(container, iconName, tooltip) { + super(container, iconName ?? 'vector', tooltip ?? 'Vector Tool: Graph a vector.'); + } + + updateHighlights(e) { + const handled = super.updateHighlights(e); + this.hlObjs.hl_line?.setAttribute({ + straightFirst: false, + straightLast: false, + lastArrow: { type: 1, size: 6 } + }); + return handled; + } + + phase1(coords) { + super.phase1(coords); + this.helpText = 'Plot the terminal point of the vector.'; + gt.updateHelp(); + } + }; } }; } diff --git a/htdocs/js/GraphTool/sinewavetool.js b/htdocs/js/GraphTool/sinewavetool.js index b63c5a8cd..057559d9f 100644 --- a/htdocs/js/GraphTool/sinewavetool.js +++ b/htdocs/js/GraphTool/sinewavetool.js @@ -1,91 +1,95 @@ /* global graphTool, JXG */ +'use strict'; + (() => { if (graphTool && graphTool.sineWaveTool) return; graphTool.sineWaveTool = { - SineWave: { - preInit(gt, shiftPoint, periodPoint, amplitudePoint, solid) { - [shiftPoint, periodPoint, amplitudePoint].forEach((point) => { - point.setAttribute(gt.definingPointAttributes); - if (!gt.isStatic) { - point.on('down', () => gt.onPointDown(point)); - point.on('up', () => gt.onPointUp(point)); + SineWave(gt) { + return class extends gt.GraphObject { + static strId = 'sineWave'; + + constructor(shiftPoint, periodPoint, amplitudePoint, solid) { + for (const point of [shiftPoint, periodPoint, amplitudePoint]) { + point.setAttribute(gt.definingPointAttributes); + if (!gt.isStatic) { + point.on('down', () => gt.onPointDown(point)); + point.on('up', () => gt.onPointUp(point)); + } } - }); - return gt.graphObjectTypes.sineWave.createSineWave( - shiftPoint, - () => (2 * Math.PI) / (periodPoint.X() - shiftPoint.X()), - () => amplitudePoint.Y() - shiftPoint.Y(), - solid, - gt.color.curve - ); - }, - - postInit(_gt, shiftPoint, periodPoint, amplitudePoint) { - this.definingPts.push(shiftPoint, periodPoint, amplitudePoint); - this.focusPoint = shiftPoint; - }, - - stringify(gt) { - return [ - this.baseObj.getAttribute('dash') == 0 ? 'solid' : 'dashed', - `(${gt.snapRound(this.definingPts[0].X(), gt.snapSizeX)},${gt.snapRound( - this.definingPts[0].Y(), - gt.snapSizeY - )})`, - gt.snapRound(this.definingPts[1].X() - this.definingPts[0].X(), gt.snapSizeX), - gt.snapRound(this.definingPts[2].Y() - this.definingPts[0].Y(), gt.snapSizeY) - ].join(','); - }, - - fillCmp(gt, point) { - return gt.sign(point[2] - this.baseObj.Y(point[1])); - }, - - restore(gt, string) { - const data = string.match( - new RegExp( - [ - gt.pointRegexp, // phase shift and y translation point - /\s*,\s*/, // comma - /(-?[0-9]*(?:\.[0-9]*)?)/, // period - /\s*,\s*/, // comma - /(-?[0-9]*(?:\.[0-9]*)?)/ // amplitude - ] - .map((r) => r.source) - .join('') - ) - ); - if (!data || data.length !== 5) return false; - - const shiftPoint = gt.graphObjectTypes.sineWave.createPoint( - gt.snapRound(parseFloat(data[1]), gt.snapSizeX), - gt.snapRound(parseFloat(data[2]), gt.snapSizeY) - ); - const periodPoint = gt.graphObjectTypes.sineWave.createPoint( - gt.snapRound(shiftPoint.X() + parseFloat(data[3]), gt.snapSizeX), - shiftPoint.Y(), - shiftPoint - ); - const amplitudePoint = gt.graphObjectTypes.sineWave.createPoint( - (3 * shiftPoint.X()) / 4 + periodPoint.X() / 4, - gt.snapRound(shiftPoint.Y() + parseFloat(data[4]), gt.snapSizeY), - shiftPoint, - periodPoint - ); - return new gt.graphObjectTypes.sineWave(shiftPoint, periodPoint, amplitudePoint, /solid/.test(string)); - }, - - helpText(_gt) { - if (this.focusPoint == this.definingPts[1]) - return 'Note that the selected point can only be moved left and right.'; - else if (this.focusPoint == this.definingPts[2]) - return 'Note that the selected point can only be moved up and down.'; - }, - - helperMethods: { - createSineWave(gt, point, period, amplitude, solid, color) { + super( + gt.graphObjectTypes.sineWave.createSineWave( + shiftPoint, + () => (2 * Math.PI) / (periodPoint.X() - shiftPoint.X()), + () => amplitudePoint.Y() - shiftPoint.Y(), + solid, + gt.color.curve + ) + ); + this.definingPts.push(shiftPoint, periodPoint, amplitudePoint); + this.focusPoint = shiftPoint; + } + + stringify() { + return [ + this.constructor.strId, + this.baseObj.getAttribute('dash') == 0 ? 'solid' : 'dashed', + `(${gt.snapRound(this.definingPts[0].X(), gt.snapSizeX)},${gt.snapRound( + this.definingPts[0].Y(), + gt.snapSizeY + )})`, + gt.snapRound(this.definingPts[1].X() - this.definingPts[0].X(), gt.snapSizeX), + gt.snapRound(this.definingPts[2].Y() - this.definingPts[0].Y(), gt.snapSizeY) + ].join(','); + } + + fillCmp(point) { + return gt.sign(point[2] - this.baseObj.Y(point[1])); + } + + helpText() { + if (this.focusPoint == this.definingPts[1]) + return 'Note that the selected point can only be moved left and right.'; + else if (this.focusPoint == this.definingPts[2]) + return 'Note that the selected point can only be moved up and down.'; + } + + static restore(string) { + const data = string.match( + new RegExp( + [ + gt.pointRegexp, // phase shift and y translation point + /\s*,\s*/, // comma + /(-?[0-9]*(?:\.[0-9]*)?)/, // period + /\s*,\s*/, // comma + /(-?[0-9]*(?:\.[0-9]*)?)/ // amplitude + ] + .map((r) => r.source) + .join('') + ) + ); + if (!data || data.length !== 5) return false; + + const shiftPoint = this.createPoint( + gt.snapRound(parseFloat(data[1]), gt.snapSizeX), + gt.snapRound(parseFloat(data[2]), gt.snapSizeY) + ); + const periodPoint = this.createPoint( + gt.snapRound(shiftPoint.X() + parseFloat(data[3]), gt.snapSizeX), + shiftPoint.Y(), + shiftPoint + ); + const amplitudePoint = this.createPoint( + (3 * shiftPoint.X()) / 4 + periodPoint.X() / 4, + gt.snapRound(shiftPoint.Y() + parseFloat(data[4]), gt.snapSizeY), + shiftPoint, + periodPoint + ); + return new this(shiftPoint, periodPoint, amplitudePoint, /solid/.test(string)); + } + + static createSineWave(point, period, amplitude, solid, color) { return gt.board.create( 'curve', [ @@ -103,14 +107,14 @@ dash: solid ? 0 : 2 } ); - }, - - // Prevent a point from being moved off the board by a drag. If xRestrict is provided, then also prevent - // the point from being moved into the same vertical line as that point. If yRestrict is provided, then - // also prevent the point from being moved into the same horizontal line as that point. Note that when - // this method is called, the point has already been moved by JSXGraph. Note that this ensures that the - // sine wave does not degenerate into a line or even worse a point. - adjustDragPosition(gt, e, point, xRestrict = undefined, yRestrict = undefined) { + } + + // Prevent a point from being moved off the board by a drag. If xRestrict is provided, then also + // prevent the point from being moved into the same vertical line as that point. If yRestrict is + // provided, then also prevent the point from being moved into the same horizontal line as that + // point. Note that when this method is called, the point has already been moved by JSXGraph. Note + // that this ensures that the sine wave does not degenerate into a line or even worse a point. + static adjustDragPosition(e, point, xRestrict = undefined, yRestrict = undefined) { const bbox = gt.board.getBoundingBox(); // Clamp the coordinates to the board. @@ -163,9 +167,9 @@ Math.abs(point.Y() - y) >= JXG.Math.eps ) point.setPosition(JXG.COORDS_BY_USER, [x, y]); - }, + } - pointDrag(gt, e) { + static pointDrag(e) { gt.graphObjectTypes.sineWave.adjustDragPosition( e, this, @@ -188,9 +192,9 @@ gt.setTextCoords(this.X(), this.Y()); gt.updateObjects(); gt.updateText(); - }, + } - createPoint(gt, x, y, shiftPoint = undefined, periodPoint = undefined) { + static createPoint(x, y, shiftPoint = undefined, periodPoint = undefined) { const point = gt.board.create('point', [x, y], { size: 2, snapSizeX: periodPoint ? 1e-10 : gt.snapSizeX, @@ -204,9 +208,8 @@ point.shiftPoint = shiftPoint; if (!shiftPoint.periodPoint) shiftPoint.periodPoint = point; else if (!shiftPoint.amplitudePoint) shiftPoint.amplitudePoint = point; - if (!shiftPoint.eventHandlers.drag) - shiftPoint.on('drag', gt.graphObjectTypes.sineWave.pointDrag); - point.on('drag', gt.graphObjectTypes.sineWave.pointDrag); + if (!shiftPoint.eventHandlers.drag) shiftPoint.on('drag', this.pointDrag); + point.on('drag', this.pointDrag); } if (periodPoint) { @@ -217,17 +220,23 @@ return point; } - } + }; }, - SineWaveTool: { - iconName: 'sine-wave', - tooltip: 'Sine Wave Tool: Graph a sine wave.', - - initialize(gt) { - this.supportsSolidDash = true; + SineWaveTool(gt) { + return class extends gt.GenericTool { + object = 'sineWave'; + supportsSolidDash = true; + useStandardActivation = true; + activationHelpText = 'Move the highlighted point to set the phase shift and vertical translation.'; + useStandardDeactivation = true; + constructionObjects = ['shiftPoint', 'periodPoint', 'amplitudePoint']; + + constructor(container, iconName, tooltip) { + super(container, iconName ?? 'sine-wave', tooltip ?? 'Sine Wave Tool: Graph a sine wave.'); + } - this.phase1 = (coords) => { + phase1(coords) { // Don't allow the point to be created off the board if (!gt.boardHasPoint(coords[1], coords[2])) return; @@ -252,9 +261,9 @@ gt.board.on('up', (e) => this.phase2(gt.getMouseCoords(e).usrCoords)); gt.board.update(); - }; + } - this.phase2 = (coords) => { + phase2(coords) { if (!gt.boardHasPoint(coords[1], coords[2])) return; // If the current coordinates are on the same vertical line as the first point, @@ -290,9 +299,9 @@ gt.board.on('up', (e) => this.phase3(gt.getMouseCoords(e).usrCoords)); gt.board.update(); - }; + } - this.phase3 = (coords) => { + phase3(coords) { if (!gt.boardHasPoint(coords[1], coords[2])) return; // If the current coordinates are on the same horizontal line as the first point, @@ -308,7 +317,7 @@ this.shiftPoint, this.periodPoint ); - gt.selectedObj = new gt.graphObjectTypes.sineWave( + gt.selectedObj = new gt.graphObjectTypes[this.object]( this.shiftPoint, this.periodPoint, amplitudePoint, @@ -320,131 +329,113 @@ delete this.periodPoint; this.finish(); - }; - }, + } - handleKeyEvent(gt, e) { - if (!this.hlObjs.hl_point || !gt.board.containerObj.contains(document.activeElement)) return; + handleKeyEvent(e) { + if (!this.hlObjs.hl_point || !gt.board.containerObj.contains(document.activeElement)) return; - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - e.stopPropagation(); + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); - if (this.periodPoint) this.phase3(this.hlObjs.hl_point.coords.usrCoords); - else if (this.shiftPoint) this.phase2(this.hlObjs.hl_point.coords.usrCoords); - else this.phase1(this.hlObjs.hl_point.coords.usrCoords); - } - }, - - updateHighlights(gt, e) { - this.hlObjs.hl_period_sine_wave?.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); - this.hlObjs.hl_sine_wave?.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); - this.hlObjs.hl_point?.rendNode.focus(); - - let coords; - if (e instanceof MouseEvent && e.type === 'pointermove') { - coords = gt.getMouseCoords(e); - this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [ - this.shiftPoint && this.periodPoint - ? (3 * this.shiftPoint.X()) / 4 + this.periodPoint.X() / 4 - : coords.usrCoords[1], - this.shiftPoint && !this.periodPoint ? this.shiftPoint.Y() : coords.usrCoords[2] - ]); - } else if (e instanceof KeyboardEvent && e.type === 'keydown') { - coords = this.hlObjs.hl_point.coords; - } else if (e instanceof JXG.Coords) { - coords = e; - this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [coords.usrCoords[1], coords.usrCoords[2]]); - } else return false; - - if (!this.hlObjs.hl_point) { - this.hlObjs.hl_point = gt.board.create('point', [coords.usrCoords[1], coords.usrCoords[2]], { - size: 2, - color: gt.color.underConstruction, - snapToGrid: true, - snapSizeX: gt.snapSizeX, - snapSizeY: gt.snapSizeY, - highlight: false, - withLabel: false - }); - this.hlObjs.hl_point.rendNode.focus(); - } - - // Make sure the highlight point is not moved off the board, and that the sine wave is not degenerate. - if (e instanceof Event) { - gt.graphObjectTypes.sineWave.adjustDragPosition( - e, - this.hlObjs.hl_point, - this.shiftPoint, - this.periodPoint - ); + if (this.periodPoint) this.phase3(this.hlObjs.hl_point.coords.usrCoords); + else if (this.shiftPoint) this.phase2(this.hlObjs.hl_point.coords.usrCoords); + else this.phase1(this.hlObjs.hl_point.coords.usrCoords); + } } - if (this.periodPoint && !this.hlObjs.hl_sine_wave) { - // Remove the temporary highlight sine wave from the period phase if it exists. - if (this.hlObjs.hl_period_sine_wave) { - gt.board.removeObject(this.hlObjs.hl_period_sine_wave); - delete this.hlObjs.hl_period_sine_wave; + updateHighlights(e) { + this.hlObjs.hl_period_sine_wave?.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); + this.hlObjs.hl_sine_wave?.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); + this.hlObjs.hl_point?.rendNode.focus(); + + let coords; + if (e instanceof MouseEvent && e.type === 'pointermove') { + coords = gt.getMouseCoords(e); + this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [ + this.shiftPoint && this.periodPoint + ? (3 * this.shiftPoint.X()) / 4 + this.periodPoint.X() / 4 + : coords.usrCoords[1], + this.shiftPoint && !this.periodPoint ? this.shiftPoint.Y() : coords.usrCoords[2] + ]); + } else if (e instanceof KeyboardEvent && e.type === 'keydown') { + coords = this.hlObjs.hl_point.coords; + } else if (e instanceof JXG.Coords) { + coords = e; + this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [ + coords.usrCoords[1], + coords.usrCoords[2] + ]); + } else return false; + + if (!this.hlObjs.hl_point) { + this.hlObjs.hl_point = gt.board.create('point', [coords.usrCoords[1], coords.usrCoords[2]], { + size: 2, + color: gt.color.underConstruction, + snapToGrid: true, + snapSizeX: gt.snapSizeX, + snapSizeY: gt.snapSizeY, + highlight: false, + withLabel: false + }); + this.hlObjs.hl_point.rendNode.focus(); } - this.hlObjs.hl_point.setAttribute({ snapSizeX: 1e-10, snapSizeY: gt.snapSizeY }); - this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [ - (3 * this.shiftPoint.X()) / 4 + this.periodPoint.X() / 4, - this.hlObjs.hl_point.Y() - ]); + // Make sure the highlight point is not moved off the board, + // and that the sine wave is not degenerate. + if (e instanceof Event) { + gt.graphObjectTypes.sineWave.adjustDragPosition( + e, + this.hlObjs.hl_point, + this.shiftPoint, + this.periodPoint + ); + } - // Local references are needed because the coordinate methods can - // be called after this.shiftPoint and this.periodPoint are deleted. - const shiftPoint = this.shiftPoint; - const periodPoint = this.periodPoint; + if (this.periodPoint && !this.hlObjs.hl_sine_wave) { + // Remove the temporary highlight sine wave from the period phase if it exists. + if (this.hlObjs.hl_period_sine_wave) { + gt.board.removeObject(this.hlObjs.hl_period_sine_wave); + delete this.hlObjs.hl_period_sine_wave; + } - this.hlObjs.hl_sine_wave = gt.graphObjectTypes.sineWave.createSineWave( - this.shiftPoint, - () => (2 * Math.PI) / (periodPoint.X() - shiftPoint.X()), - () => this.hlObjs.hl_point.Y() - shiftPoint.Y(), - gt.drawSolid - ); - } else if (this.shiftPoint && !this.periodPoint && !this.hlObjs.hl_period_sine_wave) { - this.hlObjs.hl_point.setAttribute({ snapSizeY: 1e-10 }); + this.hlObjs.hl_point.setAttribute({ snapSizeX: 1e-10, snapSizeY: gt.snapSizeY }); + this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [ + (3 * this.shiftPoint.X()) / 4 + this.periodPoint.X() / 4, + this.hlObjs.hl_point.Y() + ]); - // A local reference is needed because the coordinate methods - // can be called after this.shiftPoint is deleted. - const shiftPoint = this.shiftPoint; + // Local references are needed because the coordinate methods can + // be called after this.shiftPoint and this.periodPoint are deleted. + const shiftPoint = this.shiftPoint; + const periodPoint = this.periodPoint; + + this.hlObjs.hl_sine_wave = gt.graphObjectTypes.sineWave.createSineWave( + this.shiftPoint, + () => (2 * Math.PI) / (periodPoint.X() - shiftPoint.X()), + () => this.hlObjs.hl_point.Y() - shiftPoint.Y(), + gt.drawSolid + ); + } else if (this.shiftPoint && !this.periodPoint && !this.hlObjs.hl_period_sine_wave) { + this.hlObjs.hl_point.setAttribute({ snapSizeY: 1e-10 }); + + // A local reference is needed because the coordinate methods + // can be called after this.shiftPoint is deleted. + const shiftPoint = this.shiftPoint; + + this.hlObjs.hl_period_sine_wave = gt.graphObjectTypes.sineWave.createSineWave( + this.shiftPoint, + () => (2 * Math.PI) / (this.hlObjs.hl_point.X() - shiftPoint.X()), + () => gt.snapSizeY, + gt.drawSolid + ); + } - this.hlObjs.hl_period_sine_wave = gt.graphObjectTypes.sineWave.createSineWave( - this.shiftPoint, - () => (2 * Math.PI) / (this.hlObjs.hl_point.X() - shiftPoint.X()), - () => gt.snapSizeY, - gt.drawSolid - ); + gt.setTextCoords(this.hlObjs.hl_point.X(), this.hlObjs.hl_point.Y()); + gt.board.update(); + return true; } - - gt.setTextCoords(this.hlObjs.hl_point.X(), this.hlObjs.hl_point.Y()); - gt.board.update(); - return true; - }, - - deactivate(gt) { - delete this.helpText; - gt.board.off('up'); - ['shiftPoint', 'periodPoint', 'amplitudePoint'].forEach(function (point) { - if (this[point]) gt.board.removeObject(this[point]); - delete this[point]; - }, this); - gt.board.containerObj.style.cursor = 'auto'; - }, - - activate(gt) { - gt.board.containerObj.style.cursor = 'none'; - - // Draw a highlight point on the board. - this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [0, 0], gt.board)); - - this.helpText = 'Move the highlighted point to set the phase shift and vertical translation.'; - gt.updateHelp(); - - gt.board.on('up', (e) => this.phase1(gt.getMouseCoords(e).usrCoords)); - } + }; } }; })(); diff --git a/htdocs/js/GraphTool/triangle.js b/htdocs/js/GraphTool/triangle.js index 4e8f80123..e68f15aea 100644 --- a/htdocs/js/GraphTool/triangle.js +++ b/htdocs/js/GraphTool/triangle.js @@ -1,131 +1,128 @@ /* global graphTool, JXG */ +'use strict'; + (() => { if (graphTool && graphTool.triangleTool) return; graphTool.triangleTool = { - Triangle: { - preInit(gt, point1, point2, point3, solid) { - for (const point of [point1, point2, point3]) { - point.setAttribute(gt.definingPointAttributes); - if (!gt.isStatic) { - point.on('down', () => gt.onPointDown(point)); - point.on('up', () => gt.onPointUp(point)); + Triangle(gt) { + return class extends gt.GraphObject { + static strId = 'triangle'; + + constructor(point1, point2, point3, solid) { + for (const point of [point1, point2, point3]) { + point.setAttribute(gt.definingPointAttributes); + if (!gt.isStatic) { + point.on('down', () => gt.onPointDown(point)); + point.on('up', () => gt.onPointUp(point)); + } } + super(gt.graphObjectTypes.triangle.createPolygon([point1, point2, point3], solid, gt.color.curve)); + this.definingPts.push(point1, point2, point3); + this.focusPoint = point1; } - return gt.graphObjectTypes.triangle.createTriangle(point1, point2, point3, solid, gt.color.curve); - }, - - postInit(_gt, point1, point2, point3) { - this.definingPts.push(point1, point2, point3); - this.focusPoint = point1; - this.floodFillUseHasPoint = true; - }, - - blur(gt) { - this.focused = false; - for (const obj of this.definingPts) obj.setAttribute({ visible: false }); - for (const b of this.baseObj.borders) b.setAttribute({ strokeColor: gt.color.curve, strokeWidth: 2 }); - - gt.updateHelp(); - }, - - focus(gt) { - this.focused = true; - for (const obj of this.definingPts) obj.setAttribute({ visible: true }); - for (const b of this.baseObj.borders) - b.setAttribute({ strokeColor: gt.color.focusCurve, strokeWidth: 3 }); - - // Focus the currently set point of focus for this object. - this.focusPoint?.rendNode.focus(); - - gt.drawSolid = this.baseObj.borders[0].getAttribute('dash') == 0; - if (gt.solidButton) gt.solidButton.disabled = gt.drawSolid; - if (gt.dashedButton) gt.dashedButton.disabled = !gt.drawSolid; - - gt.updateHelp(); - }, - - stringify(gt) { - return [ - this.baseObj.borders[0].getAttribute('dash') === 0 ? 'solid' : 'dashed', - ...this.definingPts.map( - (point) => `(${gt.snapRound(point.X(), gt.snapSizeX)},${gt.snapRound(point.Y(), gt.snapSizeY)})` - ) - ].join(','); - }, - - fillCmp(_gt, point) { - const denominator = - (this.definingPts[1].Y() - this.definingPts[2].Y()) * - (this.definingPts[0].X() - this.definingPts[2].X()) + - (this.definingPts[2].X() - this.definingPts[1].X()) * - (this.definingPts[0].Y() - this.definingPts[2].Y()); - const s = - ((this.definingPts[1].Y() - this.definingPts[2].Y()) * (point[1] - this.definingPts[2].X()) + - (this.definingPts[2].X() - this.definingPts[1].X()) * (point[2] - this.definingPts[2].Y())) / - denominator; - const t = - ((this.definingPts[2].Y() - this.definingPts[0].Y()) * (point[1] - this.definingPts[2].X()) + - (this.definingPts[0].X() - this.definingPts[2].X()) * (point[2] - this.definingPts[2].Y())) / - denominator; - if (s >= 0 && t >= 0 && s + t <= 1) { - if (s == 0 || t == 0 || s + t == 1) return 0; - return 1; + + blur() { + this.focused = false; + for (const obj of this.definingPts) obj.setAttribute({ visible: false }); + for (const b of this.baseObj.borders) + b.setAttribute({ strokeColor: gt.color.curve, strokeWidth: 2 }); + + gt.updateHelp(); } - return -1; - }, - onBoundary(gt, point, aVal, _from) { - if (this.fillCmp(point) != aVal) return true; + focus() { + this.focused = true; + for (const obj of this.definingPts) obj.setAttribute({ visible: true }); + for (const b of this.baseObj.borders) + b.setAttribute({ strokeColor: gt.color.focusCurve, strokeWidth: 3 }); - for (const border of this.baseObj.borders) { - if ( - Math.abs(JXG.Math.innerProduct(point, border.stdform)) / - Math.sqrt(border.stdform[1] ** 2 + border.stdform[2] ** 2) < - 0.5 / Math.sqrt(gt.board.unitX * gt.board.unitY) && - point[1] > Math.min(border.point1.X(), border.point2.X()) - 0.5 / gt.board.unitX && - point[1] < Math.max(border.point1.X(), border.point2.X()) + 0.5 / gt.board.unitX && - point[2] > Math.min(border.point1.Y(), border.point2.Y()) - 0.5 / gt.board.unitY && - point[2] < Math.max(border.point1.Y(), border.point2.Y()) + 0.5 / gt.board.unitY - ) - return true; + // Focus the currently set point of focus for this object. + this.focusPoint?.rendNode.focus(); + + gt.drawSolid = this.baseObj.borders[0].getAttribute('dash') == 0; + if (gt.solidButton) gt.solidButton.disabled = gt.drawSolid; + if (gt.dashedButton) gt.dashedButton.disabled = !gt.drawSolid; + + gt.updateHelp(); + } + + stringify() { + return [ + this.constructor.strId, + this.baseObj.borders[0].getAttribute('dash') === 0 ? 'solid' : 'dashed', + ...this.definingPts.map( + (point) => + `(${gt.snapRound(point.X(), gt.snapSizeX)},${gt.snapRound(point.Y(), gt.snapSizeY)})` + ) + ].join(','); } - return false; - }, - - setSolid(_gt, solid) { - for (const border of this.baseObj.borders) border.setAttribute({ dash: solid ? 0 : 2 }); - }, - - restore(gt, string) { - let pointData = gt.pointRegexp.exec(string); - const points = []; - while (pointData) { - points.push(pointData.slice(1, 3)); - pointData = gt.pointRegexp.exec(string); + + fillCmp(point) { + const denominator = + (this.definingPts[1].Y() - this.definingPts[2].Y()) * + (this.definingPts[0].X() - this.definingPts[2].X()) + + (this.definingPts[2].X() - this.definingPts[1].X()) * + (this.definingPts[0].Y() - this.definingPts[2].Y()); + const s = + ((this.definingPts[1].Y() - this.definingPts[2].Y()) * (point[1] - this.definingPts[2].X()) + + (this.definingPts[2].X() - this.definingPts[1].X()) * + (point[2] - this.definingPts[2].Y())) / + denominator; + const t = + ((this.definingPts[2].Y() - this.definingPts[0].Y()) * (point[1] - this.definingPts[2].X()) + + (this.definingPts[0].X() - this.definingPts[2].X()) * + (point[2] - this.definingPts[2].Y())) / + denominator; + if (s >= 0 && t >= 0 && s + t <= 1) { + if (s == 0 || t == 0 || s + t == 1) return 0; + return 1; + } + return -1; } - if (points.length < 3) return false; - const point1 = gt.graphObjectTypes.triangle.createPoint( - parseFloat(points[0][0]), - parseFloat(points[0][1]) - ); - const point2 = gt.graphObjectTypes.triangle.createPoint( - parseFloat(points[1][0]), - parseFloat(points[1][1]), - [point1] - ); - const point3 = gt.graphObjectTypes.triangle.createPoint( - parseFloat(points[2][0]), - parseFloat(points[2][1]), - [point1, point2] - ); - return new gt.graphObjectTypes.triangle(point1, point2, point3, /solid/.test(string)); - }, - - helperMethods: { - createTriangle(gt, point1, point2, point3, solid, color) { - return gt.board.create('polygon', [point1, point2, point3], { + + onBoundary(point, aVal, _from) { + if (this.fillCmp(point) != aVal) return true; + + for (const border of this.baseObj.borders) { + if ( + Math.abs(JXG.Math.innerProduct(point, border.stdform)) / + Math.sqrt(border.stdform[1] ** 2 + border.stdform[2] ** 2) < + 0.5 / Math.sqrt(gt.board.unitX * gt.board.unitY) && + point[1] > Math.min(border.point1.X(), border.point2.X()) - 0.5 / gt.board.unitX && + point[1] < Math.max(border.point1.X(), border.point2.X()) + 0.5 / gt.board.unitX && + point[2] > Math.min(border.point1.Y(), border.point2.Y()) - 0.5 / gt.board.unitY && + point[2] < Math.max(border.point1.Y(), border.point2.Y()) + 0.5 / gt.board.unitY + ) + return true; + } + return false; + } + + setSolid(solid) { + for (const border of this.baseObj.borders) border.setAttribute({ dash: solid ? 0 : 2 }); + } + + static restore(string) { + let pointData = gt.pointRegexp.exec(string); + const points = []; + while (pointData) { + points.push(pointData.slice(1, 3)); + pointData = gt.pointRegexp.exec(string); + } + if (points.length < 3) return false; + const point1 = this.createPoint(parseFloat(points[0][0]), parseFloat(points[0][1])); + const point2 = this.createPoint(parseFloat(points[1][0]), parseFloat(points[1][1]), [point1]); + const point3 = this.createPoint(parseFloat(points[2][0]), parseFloat(points[2][1]), [ + point1, + point2 + ]); + return new this(point1, point2, point3, /solid/.test(string)); + } + + static createPolygon(points, solid, color) { + return gt.board.create('polygon', points, { highlight: false, fillOpacity: 0, fixed: true, @@ -137,13 +134,14 @@ dash: solid ? 0 : 2 } }); - }, + } - // Prevent a point from being moved off the board by a drag. If one or two other points are provided, - // then also prevent the point from being moved onto those points or the line between them if there are - // two. Note that when this method is called, the point has already been moved by JSXGraph. Note that - // this ensures that the graphed object is a triangle, and does not degenerate into a line segment. - adjustDragPosition(gt, e, point, groupedPoints) { + // Prevent a point from being moved off the board by a drag. If one or two other points are + // provided, then also prevent the point from being moved onto those points or the line between them + // if there are two. Note that when this method is called, the point has already been moved by + // JSXGraph. Note that this ensures that the graphed object is a triangle, and does not degenerate + // into a line segment. + static adjustDragPosition(e, point, groupedPoints) { const bbox = gt.board.getBoundingBox(); let x = point.X() < bbox[0] ? bbox[0] : point.X() > bbox[2] ? bbox[2] : point.X(); @@ -156,7 +154,7 @@ ) { let xDir = 0, yDir = 0; - // Adjust position of the point if it has the same coordinates as its paired point. + // Adjust position of the point if it has the same coordinates as its only grouped point. if (e.type === 'pointermove') { const coords = gt.getMouseCoords(e); const x_trans = coords.usrCoords[1] - groupedPoints[0].X(), @@ -171,26 +169,20 @@ } x += xDir * gt.snapSizeX; y += yDir * gt.snapSizeY; - } else if ( - groupedPoints.length == 2 && - gt.graphObjectTypes.triangle.areColinear(x, y, ...groupedPoints) - ) { - // Adjust the position of the point if it is on the line passing through the two grouped points. + } else if (groupedPoints.length == 2 && gt.areColinear([x, y], ...groupedPoints)) { + // Adjust the position of the point if it is on the line + // passing through the two grouped points. if (e.type === 'pointermove') { const coords = gt.getMouseCoords(e); - // Of the points to the left of, right of, above, and below the current point, find those - // that are on the board and not on the line between the two grouped points. + // Of the points to the left of, right of, above, and below the current point, find + // those that are on the board and not on the line between the two grouped points. const points = [ [x - gt.snapSizeX, y], [x + gt.snapSizeX, y], [x, y + gt.snapSizeY], [x, y - gt.snapSizeY] - ].filter( - (p) => - gt.boardHasPoint(...p) && - !gt.graphObjectTypes.triangle.areColinear(...p, ...groupedPoints) - ); + ].filter((p) => gt.boardHasPoint(...p) && !gt.areColinear(p, ...groupedPoints)); // Move to the point closest to the mouse cursor. let min = -1; @@ -218,16 +210,16 @@ else if (y > bbox[1]) y = bbox[1] - gt.snapSizeY; point.setPosition(JXG.COORDS_BY_USER, [x, y]); - }, + } - groupedPointDrag(gt, e) { + static groupedPointDrag(e) { gt.graphObjectTypes.triangle.adjustDragPosition(e, this, this.grouped_points); gt.setTextCoords(this.X(), this.Y()); gt.updateObjects(); gt.updateText(); - }, + } - createPoint(gt, x, y, grouped_points) { + static createPoint(x, y, grouped_points) { const point = gt.board.create( 'point', [gt.snapRound(x, gt.snapSizeX), gt.snapRound(y, gt.snapSizeY)], @@ -243,43 +235,44 @@ if (!gt.isStatic) { if (typeof grouped_points !== 'undefined' && grouped_points.length) { point.grouped_points = []; - for (const paired_point of grouped_points) { - point.grouped_points.push(paired_point); - if (!paired_point.grouped_points) { - paired_point.grouped_points = []; - paired_point.on('drag', gt.graphObjectTypes.triangle.groupedPointDrag); + for (const grouped_point of grouped_points) { + point.grouped_points.push(grouped_point); + if (!grouped_point.grouped_points) { + grouped_point.grouped_points = []; + grouped_point.on('drag', this.groupedPointDrag); } - paired_point.grouped_points.push(point); + grouped_point.grouped_points.push(point); if ( - !paired_point.eventHandlers.drag || - paired_point.eventHandlers.drag.every( - (dragHandler) => - dragHandler.handler !== gt.graphObjectTypes.triangle.groupedPointDrag + !grouped_point.eventHandlers.drag || + grouped_point.eventHandlers.drag.every( + (dragHandler) => dragHandler.handler !== this.groupedPointDrag ) ) - paired_point.on('drag', gt.graphObjectTypes.triangle.groupedPointDrag); + grouped_point.on('drag', this.groupedPointDrag); } - point.on('drag', gt.graphObjectTypes.triangle.groupedPointDrag, point); + point.on('drag', this.groupedPointDrag, point); } } - return point; - }, - // This returns true if the points (x, y), p1, and p2 are colinear. - areColinear(_gt, x, y, p1, p2) { - return Math.abs((y - p1.Y()) * (p2.X() - p1.X()) - (p2.Y() - p1.Y()) * (x - p1.X())) < JXG.Math.eps; + return point; } - } + }; }, - TriangleTool: { - iconName: 'triangle', - tooltip: 'Triangle Tool: Graph a triangle.', - - initialize(gt) { - this.supportsSolidDash = true; + TriangleTool(gt) { + return class extends gt.GenericTool { + object = 'triangle'; + supportsSolidDash = true; + useStandardActivation = true; + activationHelpText = 'Plot the vertices of the triangle.'; + useStandardDeactivation = true; + constructionObjects = ['point1', 'point2']; + + constructor(container, iconName, tooltip) { + super(container, iconName ?? 'triangle', tooltip ?? 'Triangle Tool: Graph a triangle.'); + } - this.phase1 = (coords) => { + phase1(coords) { // Don't allow the point to be created off the board. if (!gt.boardHasPoint(coords[1], coords[2])) return; @@ -308,9 +301,9 @@ gt.board.on('up', (e) => this.phase2(gt.getMouseCoords(e).usrCoords)); gt.board.update(); - }; + } - this.phase2 = (coords) => { + phase2(coords) { if (!gt.boardHasPoint(coords[1], coords[2])) return; // If the current coordinates are on top of the first point, then use the highlight point @@ -330,14 +323,12 @@ // point is off the board. In that case go left and down instead. let newX = this.point2.X() + gt.snapSizeX; let newY = this.point2.Y() + gt.snapSizeY; - if (gt.graphObjectTypes.triangle.areColinear(newX, newY, this.point1, this.point2)) - newX += gt.snapSizeX; + if (gt.areColinear([newX, newY], this.point1, this.point2)) newX += gt.snapSizeX; if (newX > gt.board.getBoundingBox()[2] || newY > gt.board.getBoundingBox()[1]) { newX = this.point2.X() - gt.snapSizeX; newY = this.point2.Y() - gt.snapSizeY; - if (gt.graphObjectTypes.triangle.areColinear(newX, newY, this.point1, this.point2)) - newX -= gt.snapSizeX; + if (gt.areColinear([newX, newY], this.point1, this.point2)) newX -= gt.snapSizeX; } this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [newX, newY], gt.board)); @@ -348,17 +339,16 @@ gt.board.on('up', (e) => this.phase3(gt.getMouseCoords(e).usrCoords)); gt.board.update(); - }; + } - this.phase3 = (coords) => { + phase3(coords) { if (!gt.boardHasPoint(coords[1], coords[2])) return; // If the current coordinates are on the line through the first and second points, // then use the highlight point coordinates instead. if ( - gt.graphObjectTypes.triangle.areColinear( - gt.snapRound(coords[1], gt.snapSizeX), - gt.snapRound(coords[2], gt.snapSizeY), + gt.areColinear( + [gt.snapRound(coords[1], gt.snapSizeX), gt.snapRound(coords[2], gt.snapSizeY)], this.point1, this.point2 ) @@ -378,112 +368,93 @@ delete this.point2; this.finish(); - }; - }, + } - handleKeyEvent(gt, e) { - if (!this.hlObjs.hl_point || !gt.board.containerObj.contains(document.activeElement)) return; + handleKeyEvent(e) { + if (!this.hlObjs.hl_point || !gt.board.containerObj.contains(document.activeElement)) return; - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - e.stopPropagation(); + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); - if (this.point2) this.phase3(this.hlObjs.hl_point.coords.usrCoords); - else if (this.point1) this.phase2(this.hlObjs.hl_point.coords.usrCoords); - else this.phase1(this.hlObjs.hl_point.coords.usrCoords); - } - }, - - updateHighlights(gt, e) { - this.hlObjs.hl_line?.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); - for (const border of this.hlObjs.hl_triangle?.borders ?? []) - border.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); - this.hlObjs.hl_point?.rendNode.focus(); - - let coords; - if (e instanceof MouseEvent && e.type === 'pointermove') { - coords = gt.getMouseCoords(e); - this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [coords.usrCoords[1], coords.usrCoords[2]]); - } else if (e instanceof KeyboardEvent && e.type === 'keydown') { - coords = this.hlObjs.hl_point.coords; - } else if (e instanceof JXG.Coords) { - coords = e; - this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [coords.usrCoords[1], coords.usrCoords[2]]); - } else return false; - - if (!this.hlObjs.hl_point) { - this.hlObjs.hl_point = gt.board.create('point', [coords.usrCoords[1], coords.usrCoords[2]], { - size: 2, - color: gt.color.underConstruction, - snapToGrid: true, - highlight: false, - snapSizeX: gt.snapSizeX, - snapSizeY: gt.snapSizeY, - withLabel: false - }); - this.hlObjs.hl_point.rendNode.focus(); + if (this.point2) this.phase3(this.hlObjs.hl_point.coords.usrCoords); + else if (this.point1) this.phase2(this.hlObjs.hl_point.coords.usrCoords); + else this.phase1(this.hlObjs.hl_point.coords.usrCoords); + } } - // Make sure the highlight point is not moved off the board or onto - // any other points or lines that have already been created. - if (e instanceof Event) { - const groupedPoints = []; - if (this.point1) groupedPoints.push(this.point1); - if (this.point2) groupedPoints.push(this.point2); - gt.graphObjectTypes.triangle.adjustDragPosition(e, this.hlObjs.hl_point, groupedPoints); - } + updateHighlights(e) { + this.hlObjs.hl_line?.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); + for (const border of this.hlObjs.hl_triangle?.borders ?? []) + border.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); + this.hlObjs.hl_point?.rendNode.focus(); + + let coords; + if (e instanceof MouseEvent && e.type === 'pointermove') { + coords = gt.getMouseCoords(e); + this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [ + coords.usrCoords[1], + coords.usrCoords[2] + ]); + } else if (e instanceof KeyboardEvent && e.type === 'keydown') { + coords = this.hlObjs.hl_point.coords; + } else if (e instanceof JXG.Coords) { + coords = e; + this.hlObjs.hl_point?.setPosition(JXG.COORDS_BY_USER, [ + coords.usrCoords[1], + coords.usrCoords[2] + ]); + } else return false; + + if (!this.hlObjs.hl_point) { + this.hlObjs.hl_point = gt.board.create('point', [coords.usrCoords[1], coords.usrCoords[2]], { + size: 2, + color: gt.color.underConstruction, + snapToGrid: true, + highlight: false, + snapSizeX: gt.snapSizeX, + snapSizeY: gt.snapSizeY, + withLabel: false + }); + this.hlObjs.hl_point.rendNode.focus(); + } - if (this.point2 && !this.hlObjs.hl_triangle) { - // Delete the temporary highlight line if it exists. - if (this.hlObjs.hl_line) { - gt.board.removeObject(this.hlObjs.hl_line); - delete this.hlObjs.hl_line; + // Make sure the highlight point is not moved off the board or onto + // any other points or lines that have already been created. + if (e instanceof Event) { + const groupedPoints = []; + if (this.point1) groupedPoints.push(this.point1); + if (this.point2) groupedPoints.push(this.point2); + gt.graphObjectTypes.triangle.adjustDragPosition(e, this.hlObjs.hl_point, groupedPoints); } - this.hlObjs.hl_triangle = gt.graphObjectTypes.triangle.createTriangle( - this.point1, - this.point2, - this.hlObjs.hl_point, - gt.drawSolid - ); - } else if (this.point1 && !this.point2 && !this.hlObjs.hl_line) { - this.hlObjs.hl_line = gt.board.create('line', [this.point1, this.hlObjs.hl_point], { - fixed: true, - strokeColor: gt.color.underConstruction, - highlight: false, - dash: gt.drawSolid ? 0 : 2, - straightFirst: false, - straightLast: false - }); - } + if (this.point2 && !this.hlObjs.hl_triangle) { + // Delete the temporary highlight line if it exists. + if (this.hlObjs.hl_line) { + gt.board.removeObject(this.hlObjs.hl_line); + delete this.hlObjs.hl_line; + } + + this.hlObjs.hl_triangle = gt.graphObjectTypes.triangle.createPolygon( + [this.point1, this.point2, this.hlObjs.hl_point], + gt.drawSolid + ); + } else if (this.point1 && !this.point2 && !this.hlObjs.hl_line) { + this.hlObjs.hl_line = gt.board.create('line', [this.point1, this.hlObjs.hl_point], { + fixed: true, + strokeColor: gt.color.underConstruction, + highlight: false, + dash: gt.drawSolid ? 0 : 2, + straightFirst: false, + straightLast: false + }); + } - gt.setTextCoords(this.hlObjs.hl_point.X(), this.hlObjs.hl_point.Y()); - gt.board.update(); - return true; - }, - - deactivate(gt) { - delete this.helpText; - gt.board.off('up'); - if (this.point1) gt.board.removeObject(this.point1); - delete this.point1; - if (this.point2) gt.board.removeObject(this.point2); - delete this.point2; - gt.board.containerObj.style.cursor = 'auto'; - }, - - activate(gt) { - gt.board.containerObj.style.cursor = 'none'; - - // Draw a highlight point on the board. - this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [0, 0], gt.board)); - - this.helpText = 'Plot the vertices of the triangle.'; - gt.updateHelp(); - - // Wait for the user to select the first point. - gt.board.on('up', (e) => this.phase1(gt.getMouseCoords(e).usrCoords)); - } + gt.setTextCoords(this.hlObjs.hl_point.X(), this.hlObjs.hl_point.Y()); + gt.board.update(); + return true; + } + }; } }; })(); diff --git a/macros/graph/parserGraphTool.pl b/macros/graph/parserGraphTool.pl index 7408ca7ca..1d0ef89f1 100644 --- a/macros/graph/parserGraphTool.pl +++ b/macros/graph/parserGraphTool.pl @@ -509,6 +509,9 @@ sub _parserGraphTool_init { ADD_CSS_FILE('js/GraphTool/graphtool.css'); ADD_JS_FILE('node_modules/jsxgraph/distrib/jsxgraphcore.js', 0, { defer => undef }); ADD_JS_FILE('js/GraphTool/graphtool.js', 0, { defer => undef }); + ADD_JS_FILE('js/GraphTool/linetool.js', 0, { defer => undef }); + ADD_JS_FILE('js/GraphTool/circletool.js', 0, { defer => undef }); + ADD_JS_FILE('js/GraphTool/parabolatool.js', 0, { defer => undef }); ADD_JS_FILE('js/GraphTool/pointtool.js', 0, { defer => undef }); ADD_JS_FILE('js/GraphTool/quadratictool.js', 0, { defer => undef }); ADD_JS_FILE('js/GraphTool/cubictool.js', 0, { defer => undef }); @@ -517,6 +520,7 @@ sub _parserGraphTool_init { ADD_JS_FILE('js/GraphTool/triangle.js', 0, { defer => undef }); ADD_JS_FILE('js/GraphTool/quadrilateral.js', 0, { defer => undef }); ADD_JS_FILE('js/GraphTool/segments.js', 0, { defer => undef }); + ADD_JS_FILE('js/GraphTool/filltool.js', 0, { defer => undef }); return; } @@ -530,16 +534,7 @@ sub _parserGraphTool_init { package parser::GraphTool; our @ISA = qw(Value::List); -my %contextStrings = ( - line => {}, - circle => {}, - parabola => {}, - vertical => {}, - horizontal => {}, - fill => {}, - solid => {}, - dashed => {} -); +my %contextStrings = (solid => {}, dashed => {}); my $fillResolution = 400; @@ -636,2182 +631,1998 @@ sub sign { return 0; } -my %graphObjectTikz = ( - line => { - code => sub { - my ($self, $object) = @_; - - my ($p1x, $p1y) = map { $_->value } @{ $object->{data}[2]{data} }; - my ($p2x, $p2y) = map { $_->value } @{ $object->{data}[3]{data} }; - - if ($p1x == $p2x) { - # Vertical line - my $line = "($p1x,$self->{bBox}[3]) -- ($p1x,$self->{bBox}[1])"; - my $fillCmp = sub { return sign($_[0] - $p1x); }; - return ( - "\\draw[thick,blue,line width=2.5pt,$object->{data}[1]] $line;\n", - [ - $line - . "-- ($self->{bBox}[2],$self->{bBox}[1]) -- ($self->{bBox}[2],$self->{bBox}[3]) -- cycle", - $fillCmp, - sub { return $fillCmp->(@{ $_[0] }) != $_[1]; } - ] - ); - } else { - # Non-vertical line - my $m = ($p2y - $p1y) / ($p2x - $p1x); - my $y = sub { return $m * ($_[0] - $p1x) + $p1y; }; - my $line = - "($self->{bBox}[0]," - . $y->($self->{bBox}[0]) . ') -- ' - . "($self->{bBox}[2]," - . $y->($self->{bBox}[2]) . ')'; - my $fillCmp = sub { return sign($_[1] - $y->($_[0])); }; - return ( - "\\draw[thick,blue,line width=2.5pt,$object->{data}[1]] $line;\n", - [ - $line - . "-- ($self->{bBox}[2],$self->{bBox}[1]) -- ($self->{bBox}[0],$self->{bBox}[1]) -- cycle", - $fillCmp, - sub { return $fillCmp->(@{ $_[0] }) != $_[1]; } - ] - ); - } - } - }, - circle => { - code => sub { - my ($self, $object) = @_; - - my ($cx, $cy) = map { $_->value } @{ $object->{data}[2]{data} }; - my ($px, $py) = map { $_->value } @{ $object->{data}[3]{data} }; - my $r = sqrt(($cx - $px)**2 + ($cy - $py)**2); - my $circle = "($cx, $cy) circle[radius=$r]"; - - my $fillCmp = sub { return sign($r - sqrt(($cx - $_[0])**2 + ($cy - $_[1])**2)); }; - return ( - "\\draw[thick,blue,line width=2.5pt,$object->{data}[1]] $circle;\n", - [ $circle, $fillCmp, sub { return $fillCmp->(@{ $_[0] }) != $_[1]; } ] - ); - } - }, - parabola => { - code => sub { - my ($self, $object) = @_; - - my ($h, $k) = map { $_->value } @{ $object->{data}[3]{data} }; - my ($px, $py) = map { $_->value } @{ $object->{data}[4]{data} }; - - if ($object->{data}[2] eq 'vertical') { - # Vertical parabola - my $a = ($py - $k) / ($px - $h)**2; - my $diff = sqrt((($a >= 0 ? $self->{bBox}[1] : $self->{bBox}[3]) - $k) / $a); - my $dmin = $h - $diff; - my $dmax = $h + $diff; - my $parabola = "plot[domain=$dmin:$dmax,smooth](\\x,{$a*(\\x-($h))^2+($k)})"; - my $yEquation = sub { return $a * ($_[0] - $h)**2 + $k; }; - my $fillCmp = sub { return sign($a * ($_[1] - $yEquation->($_[0]))); }; - return ( - "\\draw[thick,blue,line width=2.5pt,$object->{data}[1]] $parabola;\n", - [ $parabola, $fillCmp, sub { return $fillCmp->(@{ $_[0] }) != $_[1]; } ] - ); - } else { - # Horizontal parabola - my $a = ($px - $h) / ($py - $k)**2; - my $diff = sqrt((($a >= 0 ? $self->{bBox}[2] : $self->{bBox}[0]) - $h) / $a); - my $dmin = $k - $diff; - my $dmax = $k + $diff; - my $parabola = "plot[domain=$dmin:$dmax,smooth]({$a*(\\x-($k))^2+($h)},\\x)"; - my $xEquation = sub { return $a * ($_[0] - $k)**2 + $h; }; - my $fillCmp = sub { return sign($a * ($_[0] - $xEquation->($_[1]))); }; - return ( - "\\draw[thick,blue,line width=2.5pt,$object->{data}[1]] $parabola;\n", - [ $parabola, $fillCmp, sub { return $fillCmp->(@{ $_[0] }) != $_[1]; } ] - ); - } - } - }, - fill => { - code => sub { - my ($self, $fill, $object_data) = @_; - my ($fx, $fy) = map { $_->value } @{ $fill->{data}[1]{data} }; - - if ($self->{useFloodFill}) { - my @aVals = (0) x @$object_data; - - # If the point is on a graphed object, then don't fill. - for (0 .. $#$object_data) { - $aVals[$_] = $object_data->[$_][1]->($fx, $fy); - return '' if $aVals[$_] == 0; - } - - my $isBoundaryPixel = sub { - my ($x, $y, $fromDir) = @_; - my $curPoint = - [ $self->{bBox}[0] + $x / $self->{unitX}, $self->{bBox}[1] - $y / $self->{unitY} ]; - my $from = [ - $curPoint->[0] + $fromDir->[0] / $self->{unitX}, - $curPoint->[1] + $fromDir->[1] / $self->{unitY} - ]; - for (0 .. $#$object_data) { - return 1 if $object_data->[$_][2]->($curPoint, $aVals[$_], $from); - } - return 0; - }; - - my @floodMap = (0) x $fillResolution**2; - my @pixelStack = ([ - main::round(($fx - $self->{bBox}[0]) * $self->{unitX}), - main::round(($self->{bBox}[1] - $fy) * $self->{unitY}) - ]); - - # Perform the flood fill algorithm. - while (@pixelStack) { - my ($x, $y) = @{ pop(@pixelStack) }; - - # Get current pixel position. - my $pixelPos = $y * $fillResolution + $x; - - # Go up until the boundary of the fill region or the edge of board is reached. - while ($y >= 0 && !$isBoundaryPixel->($x, $y, [ 0, 1 ])) { - $y -= 1; - $pixelPos -= $fillResolution; - } +my %graphObjects; - $y += 1; - $pixelPos += $fillResolution; - my $reachLeft = 0; - my $reachRight = 0; - - # Go down until the boundary of the fill region or the edge of the board is reached. - while ($y < $fillResolution && !$isBoundaryPixel->($x, $y, [ 0, -1 ])) { - # This is a protection against infinite loops. I have not seen this occur with this code unlike - # the corresponding JavaScript code, but it doesn't hurt to add the protection. - last if $floodMap[$pixelPos]; - - # Fill the pixel - $floodMap[$pixelPos] = 1; - - # While proceeding down check to the left and right to - # see if the fill region extends in those directions. - if ($x > 0) { - if (!$floodMap[ $pixelPos - 1 ] && !$isBoundaryPixel->($x - 1, $y, [ 1, 0 ])) { - if (!$reachLeft) { - push(@pixelStack, [ $x - 1, $y ]); - $reachLeft = 1; - } - } else { - $reachLeft = 0; - } - } +our %graphObjectCmps = (); - if ($x < $fillResolution - 1) { - if (!$floodMap[ $pixelPos + 1 ] && !$isBoundaryPixel->($x + 1, $y, [ -1, 0 ])) { - if (!$reachRight) { - push(@pixelStack, [ $x + 1, $y ]); - $reachRight = 1; - } - } else { - $reachRight = 0; - } - } +my $customGraphObjects = ''; +my $customTools = ''; - $y += 1; - $pixelPos += $fillResolution; - } - } +sub addGraphObjects { + my ($self, @objects) = @_; + + while (@objects) { + my ($name, $object) = (shift @objects, shift @objects); + $customGraphObjects .= "['$name', $object->{js}]" . ','; + $contextStrings{$name} = {}; + $contextStrings{$_} = {} for (@{ $object->{strings} }); + + if ($object->{perlClass}) { + $graphObjects{$name} = $object->{perlClass}; + + # Add a backwards compatibility entry to the %graphObjectCmps hash. + $graphObjectCmps{$name} = sub { + my ($object, $gt) = @_; + my $graphObject = $graphObjects{$name}->new($object, $gt); + return (sub { $graphObject->pointCmp(@_) }, sub { $graphObject->cmp(@_) }); + }; + } else { + # Backwards compatibility for the deprecated old way of adding objects. + $graphObjects{$name} = { + defined $object->{tikz} ? (tikz => $object->{tikz}) : (), + ref($object->{cmp}) eq 'CODE' ? (cmp => $object->{cmp}) : () + }; - # Next zero out the interior of the filled region so that only the boundary is left. - my @floodMapCopy = @floodMap; - for ($fillResolution + 1 .. $#floodMap - $fillResolution - 1) { - $floodMap[$_] = 0 - if $floodMapCopy[$_] - && $_ % $fillResolution > 0 - && $_ % $fillResolution < $fillResolution - 1 - && ($floodMapCopy[ $_ - $fillResolution ] - && $floodMapCopy[ $_ - 1 ] - && $floodMapCopy[ $_ + 1 ] - && $floodMapCopy[ $_ + $fillResolution ]); - } + $graphObjectCmps{$name} = $object->{cmp} if ref($object->{cmp}) eq 'CODE'; + } + } - my $tikz = - "\\begin{scope}[fillpurple, line width = 2.5pt]\n" - . '\\clip[rounded corners = 14pt] ' - . "($self->{bBox}[0], $self->{bBox}[3]) rectangle ($self->{bBox}[2], $self->{bBox}[1]);\n"; + return; +} - my $border = ''; - my $pass = 1; +sub addTools { + my ($self, @tools) = @_; + while (@tools) { + my ($name, $tool) = (shift @tools, shift @tools); + $customTools .= "['$name', $tool]" . ','; + } + return; +} - # This converts the fill boundaries into curves. On the first pass the outer border is obtained. On - # subsequent passes borders of inner holes are found. The outer border curve is filled, and the inner - # hole curves are clipped out. - while (1) { - my $pos = 0; - for ($pos = 0; $pos < @floodMap && !$floodMap[$pos]; ++$pos) { } - last if ($pos == @floodMap); - - my $followPath; - $followPath = sub { - my $pos = shift; - - my $length = 0; - my @coordinates; - - while (1) { - ++$length; - my $x = $self->{bBox}[0] + ($pos % $fillResolution) / $self->{unitX}; - my $y = $self->{bBox}[1] - int($pos / $fillResolution) / $self->{unitY}; - if (@coordinates > 1 - && ($y - $coordinates[-2][1]) * ($coordinates[-1][0] - $coordinates[-2][0]) == - ($coordinates[-1][1] - $coordinates[-2][1]) * ($x - $coordinates[-2][0])) - { - $coordinates[-1] = [ $x, $y ]; - } else { - push(@coordinates, [ $x, $y ]); - } +parser::GraphTool->addGraphObjects( + line => { js => 'graphTool.lineTool.Line', perlClass => 'GraphTool::GraphObject::Line' }, + circle => { js => 'graphTool.circleTool.Circle', perlClass => 'GraphTool::GraphObject::Circle' }, + parabola => { + js => 'graphTool.parabolaTool.Parabola', + perlClass => 'GraphTool::GraphObject::Parabola', + strings => [qw(vertical horizontal)] + }, + point => { js => 'graphTool.pointTool.Point', perlClass => 'GraphTool::GraphObject::Point' }, + quadratic => { js => 'graphTool.quadraticTool.Quadratic', perlClass => 'GraphTool::GraphObject::Quadratic' }, + cubic => { js => 'graphTool.cubicTool.Cubic', perlClass => 'GraphTool::GraphObject::Cubic' }, + interval => { js => 'graphTool.intervalTool.Interval', perlClass => 'GraphTool::GraphObject::Interval' }, + sineWave => { js => 'graphTool.sineWaveTool.SineWave', perlClass => 'GraphTool::GraphObject::SineWave' }, + triangle => { js => 'graphTool.triangleTool.Triangle', perlClass => 'GraphTool::GraphObject::Triangle' }, + quadrilateral => + { js => 'graphTool.quadrilateralTool.Quadrilateral', perlClass => 'GraphTool::GraphObject::Quadrilateral' }, + segment => { js => 'graphTool.segmentTool.Segment', perlClass => 'GraphTool::GraphObject::Segment' }, + vector => { js => 'graphTool.vectorTool.Vector', perlClass => 'GraphTool::GraphObject::Vector' }, + fill => { js => 'graphTool.fillTool.Fill', perlClass => 'GraphTool::GraphObject::Fill' } +); - $floodMap[$pos] = 0; - - my $haveRight = $pos % $fillResolution < $fillResolution - 1; - my $haveLower = $pos < @floodMap - $fillResolution; - my $haveLeft = $pos % $fillResolution > 0; - my $haveUpper = $pos >= $fillResolution; - - my @neighbors; - - push(@neighbors, $pos + 1) if ($haveRight && $floodMap[ $pos + 1 ]); - push(@neighbors, $pos + $fillResolution + 1) - if ($haveRight && $haveLower && $floodMap[ $pos + $fillResolution + 1 ]); - push(@neighbors, $pos + $fillResolution) - if ($haveLower && $floodMap[ $pos + $fillResolution ]); - push(@neighbors, $pos + $fillResolution - 1) - if ($haveLeft && $haveLower && $floodMap[ $pos + $fillResolution - 1 ]); - push(@neighbors, $pos - 1) if ($haveLeft && $floodMap[ $pos - 1 ]); - push(@neighbors, $pos - $fillResolution - 1) - if ($haveLeft && $haveUpper && $floodMap[ $pos - $fillResolution - 1 ]); - push(@neighbors, $pos - $fillResolution) - if ($haveUpper && $floodMap[ $pos - $fillResolution ]); - push(@neighbors, $pos - $fillResolution + 1) - if ($haveUpper && $haveRight && $floodMap[ $pos - $fillResolution + 1 ]); - - last unless @neighbors; - - if (@coordinates == 1 || @neighbors == 1) { $pos = $neighbors[0]; } - else { - my $maxLength = 0; - my $maxPath; - $floodMap[$_] = 0 for @neighbors; - for (@neighbors) { - my ($pathLength, @path) = $followPath->($_); - if ($pathLength > $maxLength) { - $maxLength = $pathLength; - $maxPath = \@path; - } - } - push(@coordinates, @$maxPath); - last; - } - } +parser::GraphTool->addTools( + LineTool => 'graphTool.lineTool.LineTool', + CircleTool => 'graphTool.circleTool.CircleTool', + ParabolaTool => 'graphTool.parabolaTool.ParabolaTool', + VerticalParabolaTool => 'graphTool.parabolaTool.VerticalParabolaTool', + HorizontalParabolaTool => 'graphTool.parabolaTool.HorizontalParabolaTool', + PointTool => 'graphTool.pointTool.PointTool', + QuadraticTool => 'graphTool.quadraticTool.QuadraticTool', + CubicTool => 'graphTool.cubicTool.CubicTool', + IntervalTool => 'graphTool.intervalTool.IntervalTool', + SineWaveTool => 'graphTool.sineWaveTool.SineWaveTool', + TriangleTool => 'graphTool.triangleTool.TriangleTool', + QuadrilateralTool => 'graphTool.quadrilateralTool.QuadrilateralTool', + SegmentTool => 'graphTool.segmentTool.SegmentTool', + VectorTool => 'graphTool.vectorTool.VectorTool', + FillTool => 'graphTool.fillTool.FillTool', + IncludeExcludePointTool => 'graphTool.includeExcludePointTool.IncludeExcludePointTool' +); - return ($length, @coordinates); - }; +sub ANS_NAME { + my $self = shift; + main::RECORD_IMPLICIT_ANS_NAME($self->{name} = main::NEW_ANS_NAME()) unless defined $self->{name}; + return $self->{name}; +} - (undef, my @coordinates) = $followPath->($pos); +sub type { return 'List'; } - if ($pass == 1) { - $border = - "\\filldraw plot coordinates {" . join('', map {"($_->[0],$_->[1])"} @coordinates) . "};\n"; - } elsif (@coordinates > 2) { - $tikz .= "\\clip[inverse clip] plot coordinates {" - . join('', map {"($_->[0],$_->[1])"} @coordinates) . "};\n"; +# Convert the GraphTool object's options into JSON that can be passed to the JavaScript +# graphTool method. +sub constructJSXGraphOptions { + my $self = shift; + return if defined($self->{JSXGraphOptions}); + $self->{JSXGraphOptions} = Mojo::JSON::encode_json({ + boundingBox => $self->{bBox}, + $self->{numberLine} + ? ( + defaultAxes => { + x => { + ticks => { + label => { offset => [ 0, -12 ], anchorY => 'top', anchorX => 'middle' }, + drawZero => 1, + ticksDistance => $self->{ticksDistanceX}, + minorTicks => $self->{minorTicksX}, + scale => $self->{scaleX}, + scaleSymbol => $self->{scaleSymbolX}, + strokeWidth => 2, + strokeOpacity => 0.5, + minorHeight => 10, + majorHeight => 14 } - ++$pass; } - - $tikz .= "$border\\end{scope}\n"; - - return $tikz; - } else { - my $clip_code = ''; - for (@$object_data) { - if (ref($_->[0]) eq 'CODE') { - my $objectClipCode = $_->[0]->($fx, $fy); - return '' unless defined $objectClipCode; - $clip_code .= $objectClipCode; - next; + }, + grid => 0 + ) + : ( + defaultAxes => { + x => { + ticks => { + ticksDistance => $self->{ticksDistanceX}, + minorTicks => $self->{minorTicksX}, + scale => $self->{scaleX}, + scaleSymbol => $self->{scaleSymbolX} + } + }, + y => { + ticks => { + ticksDistance => $self->{ticksDistanceY}, + minorTicks => $self->{minorTicksY}, + scale => $self->{scaleY}, + scaleSymbol => $self->{scaleSymbolY} } - my $clip_dir = $_->[1]->($fx, $fy); - return '' if $clip_dir == 0; - $clip_code .= "\\clip " . ($clip_dir < 0 ? '[inverse clip]' : '') . $_->[0] . ";\n"; } - return - "\\begin{scope}\n\\clip[rounded corners=14pt] " - . "($self->{bBox}[0],$self->{bBox}[3]) rectangle ($self->{bBox}[2],$self->{bBox}[1]);\n" - . $clip_code - . "\\fill[fillpurple] " - . "($self->{bBox}[0],$self->{bBox}[3]) rectangle ($self->{bBox}[2],$self->{bBox}[1]);\n" - . "\\end{scope}"; - } - }, - fillType => 1 - } -); - -our %graphObjectCmps = ( - line => sub { - my ($line, $gt) = @_; + }, + grid => { majorStep => [ $self->{gridX}, $self->{gridY} ] } + ) + }); - my $solid_dashed = $line->{data}[1]; - my ($x1, $y1) = $line->{data}[2]->value; - my ($x2, $y2) = $line->{data}[3]->value; + return; +} - # These are the coefficients a, b, and c in ax + by + c = 0. - my @stdform = ($y1 - $y2, $x2 - $x1, $x1 * $y2 - $x2 * $y1); +# Produce a hidden answer rule to contain the JavaScript result and insert the graphbox div and +# JavaScript to display the graph tool. If a hard copy is being generated, then PGtikz.pl is used +# to generate a printable graph instead. An attempt is made to make the printable graph look +# as much as possible like the JavaScript graph. +sub ans_rule { + my $self = shift; + my $answer_value = $main::envir{inputs_ref}{ $self->ANS_NAME } // ''; + my $ans_name = main::RECORD_ANS_NAME($self->ANS_NAME, $answer_value); - my $linePointCmp = sub { - my $point = shift; - my ($x, $y) = $point->value; - return $stdform[0] * $x + $stdform[1] * $y + $stdform[2] <=> 0; - }; + if ($main::displayMode =~ /^(TeX|PTX)$/ && $self->{showInStatic}) { + return $self->generateTeXGraph(showCorrect => 0) + . ($main::displayMode eq 'PTX' ? qq!

! : ''); + } elsif ($main::displayMode eq 'PTX') { + return qq!

!; + } elsif ($main::displayMode eq 'TeX') { + return ''; + } else { + $self->constructJSXGraphOptions; + return main::tag( + 'div', + data_feedback_insert_element => $ans_name, + class => 'graphtool-outer-container', + main::tag('input', type => 'hidden', name => $ans_name, id => $ans_name, value => $answer_value) + . main::tag( + 'input', + type => 'hidden', + name => "previous_$ans_name", + id => "previous_$ans_name", + value => $answer_value + ) + . < + +END_SCRIPT + } +} - return ( - $linePointCmp, - sub { - my ($other, $fuzzy) = @_; - return - $other->{data}[0] eq 'line' - && ($fuzzy || $other->{data}[1] eq $solid_dashed) - && $linePointCmp->($other->{data}[2]) == 0 - && $linePointCmp->($other->{data}[3]) == 0; - }, - [ defined $gt ? @{ ($graphObjectTikz{line}{code}->($gt, $line))[1] }[ 1, 2 ] : () ] - ); - }, - circle => sub { - my ($circle, $gt) = @_; - - my $solid_dashed = $circle->{data}[1]; - my $center = $circle->{data}[2]; - my ($cx, $cy) = $center->value; - my ($px, $py) = $circle->{data}[3]->value; - my $r_squared = ($cx - $px)**2 + ($cy - $py)**2; - - my $circlePointCmp = sub { - my $point = shift; - my ($x, $y) = $point->value; - return ($x - $cx)**2 + ($y - $cy)**2 <=> $r_squared; - }; +sub cmp_defaults { + my ($self, %options) = @_; + return ( + $self->SUPER::cmp_defaults(%options), + ordered => 0, + entry_type => 'object', + list_type => 'graph' + ); +} - return ( - $circlePointCmp, - sub { - my ($other, $fuzzy) = @_; - return - $other->{data}[0] eq 'circle' - && ($fuzzy || $other->{data}[1] eq $solid_dashed) - && $other->{data}[2] == $center - && $circlePointCmp->($other->{data}[3]) == 0; - }, - [ defined $gt ? @{ ($graphObjectTikz{circle}{code}->($gt, $circle))[1] }[ 1, 2 ] : () ] - ); - }, - parabola => sub { - my ($parabola, $gt) = @_; - - my $solid_dashed = $parabola->{data}[1]; - my $vertical_horizontal = $parabola->{data}[2]; - my $vertex = $parabola->{data}[3]; - my ($h, $k) = $vertex->value; - my ($px, $py) = $parabola->{data}[4]->value; - - my $x_pow = $vertical_horizontal eq 'vertical' ? 2 : 1; - my $y_pow = $vertical_horizontal eq 'vertical' ? 1 : 2; - - my $parabolaPointCmp = sub { - my $point = shift; - my ($x, $y) = $point->value; - return ($px - $h)**$x_pow * ($y - $k)**$y_pow <=> ($py - $k)**$y_pow * ($x - $h)**$x_pow; - }; +# Modify the student's list answer returned by the graphTool JavaScript to reproduce the +# JavaScript graph of the student's answer in the "Answer Preview" box of the results table. +# The raw list form of the answer is displayed in the "Entered" box. +sub cmp_preprocess { + my ($self, $ans) = @_; - return ( - $parabolaPointCmp, - sub { - my ($other, $fuzzy) = @_; - return - $other->{data}[0] eq 'parabola' - && ($fuzzy || $other->{data}[1] eq $solid_dashed) - && $other->{data}[2] eq $vertical_horizontal - && $other->{data}[3] == $vertex - && $parabolaPointCmp->($other->{data}[4]) == 0; - }, - [ defined $gt ? @{ ($graphObjectTikz{parabola}{code}->($gt, $parabola))[1] }[ 1, 2 ] : () ] + if ($main::displayMode ne 'TeX' && defined($ans->{student_value})) { + $ans->{preview_latex_string} = $self->generateHTMLAnswerGraph( + idSuffix => 'student_ans_graphbox', + cssClass => 'graphtool-answer-container', + ariaDescription => 'answer preview graph', + objects => join(',', $ans->{student_ans}), + showCorrect => 0 ); - }, - fill => sub { - my ($fill, $object_fill_cmps, $gt) = @_; - - my $fill_point = $fill->{data}[1]; + } - my $pointInFillRegion; + return; +} - my ($fx, $fy) = map { $_->value } @{ $fill->{data}[1]{data} }; +# Create an answer checker to be passed to ANS(). Any parameters are passed to the checker, as +# well as any parameters passed in via cmpOptions when the GraphTool object is created. +# The correct answer is modified to reproduce the JavaScript graph of the correct answer +# displayed in the "Correct Answer" box of the results table. +sub cmp { + my ($self, %options) = @_; + my $cmp = $self->SUPER::cmp( + feedback_options => sub { + my ($ansHash, $options, $problemContents) = @_; + $options->{wrapPreviewInTex} = 0; + $options->{showEntered} = 0; + $options->{feedbackElements} = $problemContents->find('[id="' . $self->ANS_NAME . '_graphbox"]'); + $options->{insertElement} = + $problemContents->at('[data-feedback-insert-element="' . $self->ANS_NAME . '"]'); + $options->{insertMethod} = 'append_content'; + }, + %{ $self->{cmpOptions} }, + %options + ); - if ($gt->{useFloodFill}) { - $pointInFillRegion = sub { - my $point = shift; + unless (ref($cmp->{rh_ans}{list_checker}) eq 'CODE' || ref($cmp->{rh_ans}{checker}) eq 'CODE') { + $cmp->{rh_ans}{list_checker} = sub { + my ($correct, $student, $ans, $value) = @_; + return 0 if $ans->{isPreview}; - my ($px, $py) = map { $_->value } @{ $point->{data} }; - return 1 if $fx == $px && $fy == $py; + # If there are no correct answers, then the answer is correct if the student doesn't graph anything, and is + # incorrect if the student does graph something. Although, this checker won't actually be called if the + # student doesn't graph anything. So if it is desired for that to be correct, then that must be handled in + # a post filter. + return @$student ? 0 : 1 if !@$correct; - my @aVals = (0) x @$object_fill_cmps; + my @incorrect_objects; - # If the point is on a graphed object, then there is no filled region. - # FIXME: How should this case be graded? Really, it never should happen. It means the problem author - # chose a fill point on another object. Probably because of carelessness with random parameters. - for (0 .. $#$object_fill_cmps) { - $aVals[$_] = $object_fill_cmps->[$_][0]->($fx, $fy); - return $object_fill_cmps->[$_][0]->($px, $py) == 0 ? 1 : 0 if $aVals[$_] == 0; + # If the student graphed multiple objects, then remove the duplicates. Note that a fuzzy comparison is + # done. This means that the solid/dashed status of the objects is ignored for the comparison. Only the + # solid variant is kept if both appear. The idea is that solid covers dashed. Fills are all kept and + # the duplicates dealt with later. + my (@student_objects, @student_fills); + ANSWER: for my $answer (@$student) { + if (!$graphObjects{ $answer->{data}[0] }) { + push(@incorrect_objects, $answer); + next; } - - my $isBoundaryPixel = sub { - my ($x, $y, $fromDir) = @_; - my $curPoint = [ $gt->{bBox}[0] + $x / $gt->{unitX}, $gt->{bBox}[1] - $y / $gt->{unitY} ]; - my $from = [ $curPoint->[0] + $fromDir->[0] / $gt->{unitX}, - $curPoint->[1] + $fromDir->[1] / $gt->{unitY} ]; - for (0 .. $#$object_fill_cmps) { - return 1 if $object_fill_cmps->[$_][1]->($curPoint, $aVals[$_], $from); + my $studentGraphObject = + GraphTool::GraphObject->new($answer, $self, $graphObjects{ $answer->{data}[0] }); + if ($studentGraphObject->{fillType}) { + push(@student_fills, $studentGraphObject); + next; + } + for (0 .. $#student_objects) { + next unless $student_objects[$_]->{object}{data}[0] eq $answer->{data}[0]; + if ($student_objects[$_]->cmp($answer, 1)) { + if ($answer->{data}[1] eq 'solid') { + $student_objects[$_] = $studentGraphObject; + } + next ANSWER; } - return 0; - }; - - my $pxPixel = main::round(($px - $gt->{bBox}[0]) * $gt->{unitX}); - my $pyPixel = main::round(($gt->{bBox}[1] - $py) * $gt->{unitY}); - - my @floodMap = (0) x $fillResolution**2; - my @pixelStack = ([ - main::round(($fx - $gt->{bBox}[0]) * $gt->{unitX}), - main::round(($gt->{bBox}[1] - $fy) * $gt->{unitY}) - ]); + } + push(@student_objects, $studentGraphObject); + } - # Perform the flood fill algorithm. - while (@pixelStack) { - my ($x, $y) = @{ pop(@pixelStack) }; + # Cache the correct graph objects. The fill graph objects are separated from the others. The others must be + # passed to the fill graph object compare methods. Fills need to have all of these to determine the correct + # regions of the graph that are to be filled. Note that the graph objects for static objects are added to + # this list later. + my @objects; + my @fillObjects; + for (@$correct) { + my $type = $_->{data}[0]; + next unless $graphObjects{$type}; + my $graphObject = GraphTool::GraphObject->new($_, $self, $graphObjects{$type}); + if ($graphObject->{fillType}) { push(@fillObjects, $graphObject); } + else { push(@objects, $graphObject); } + } - # Get current pixel position. - my $pixelPos = $y * $fillResolution + $x; + my @object_scores = (0) x @objects; - # Go up until the boundary of the fill region or the edge of board is reached. - while ($y >= 0 && !$isBoundaryPixel->($x, $y, [ 0, 1 ])) { - $y -= 1; - $pixelPos -= $fillResolution; - } + ENTRY: for my $student_object (@student_objects) { + for (0 .. $#objects) { + if ($objects[$_]->cmp($student_object->{object})) { + ++$object_scores[$_]; + next ENTRY; + } + } - $y += 1; - $pixelPos += $fillResolution; - my $reachLeft = 0; - my $reachRight = 0; - - # Go down until the boundary of the fill region or the edge of the board is reached. - while ($y < $fillResolution && !$isBoundaryPixel->($x, $y, [ 0, -1 ])) { - return 1 if $x == $pxPixel && $y == $pyPixel; - - # This is a protection against infinite loops. I have not seen this occur with this code unlike - # the corresponding JavaScript code, but it doesn't hurt to add the protection. - last if $floodMap[$pixelPos]; - - # Fill the pixel - $floodMap[$pixelPos] = 1; - - # While proceeding down check to the left and right to - # see if the fill region extends in those directions. - if ($x > 0) { - if (!$floodMap[ $pixelPos - 1 ] && !$isBoundaryPixel->($x - 1, $y, [ 1, 0 ])) { - if (!$reachLeft) { - push(@pixelStack, [ $x - 1, $y ]); - $reachLeft = 1; - } - } else { - $reachLeft = 0; - } + push(@incorrect_objects, $student_object); + } + + my $object_score = 0; + for (@object_scores) { ++$object_score if $_; } + + my $fill_score = 0; + my @fill_scores; + my @incorrect_fills; + + # Now check the fills if all of the objects were correctly graphed. + if ($object_score == @object_scores && $object_score == @student_objects) { + # Add the fill comparison methods for the static graph objects. + for (@{ $self->SUPER::new($self->{context}, @{ $self->{staticObjects} })->{data} }) { + my $type = $_->{data}[0]; + next unless $graphObjects{$type}; + my $graphObject = GraphTool::GraphObject->new($_, $self, $graphObjects{$type}); + next if $graphObject->{fillType}; + push(@objects, $graphObject); + } + + @fill_scores = (0) x @fillObjects; + + ENTRY: for my $student_index (0 .. $#student_fills) { + for (0 .. $#fillObjects) { + if ($fillObjects[$_]->cmp($student_fills[$student_index]->{object}, \@objects)) { + ++$fill_scores[$_]; + next ENTRY; } + } + + # Skip incorrect fills in the same region as another incorrect fill. + for (@incorrect_fills) { + next ENTRY if $_->cmp($student_fills[$student_index]->{object}, \@objects); + } + + # Cache comparison methods for incorrect fills. + push(@incorrect_fills, $student_fills[$student_index]); + } + + for (@fill_scores) { ++$fill_score if $_; } + } + + my $score = + ($object_score + $fill_score) / + (@$correct + + (@incorrect_objects ? (@incorrect_objects - (@object_scores - $object_score)) : 0) + + (@incorrect_fills ? (@incorrect_fills - (@fill_scores - $fill_score)) : 0)); + + return $score > 0 ? main::Round($score * (@$student > @$correct ? @$student : @$correct), 2) : 0; + }; + } + + if ($main::displayMode ne 'TeX' && $main::displayMode ne 'PTX') { + $cmp->{rh_ans}{correct_ans_latex_string} = $self->generateHTMLAnswerGraph( + idSuffix => 'correct_ans_graphbox', + cssClass => 'graphtool-answer-container', + ariaDescription => 'correct answer graph' + ); + } + + return $cmp; +} + +sub generateHTMLAnswerGraph { + my ($self, %options) = @_; + $options{showCorrect} //= 1; + + ++$self->{graphCount} unless defined $options{idSuffix}; + + my $idSuffix = $options{idSuffix} // "ans_graphbox_$self->{graphCount}"; + my $cssClass = $options{cssClass} // 'graphtool-solution-container'; + my $ariaDescription = $options{ariaDescription} // 'graph of solution'; + my $answerObjects = $options{showCorrect} ? join(',', @{ $self->{data} }) : ''; + $answerObjects = join(',', $options{objects}, $answerObjects) if defined $options{objects}; + + my $ans_name = $self->ANS_NAME; + $self->constructJSXGraphOptions; + + if ($options{width} || $options{height}) { + # This enforces a sane minimum width and height for the image. The minimum width is 200 pixels. The minimum + # height is the 200 pixels for two dimensional graphs, and is 50 pixels for number line graphs. Two is added to + # the width and height to account for the container border, and so that the graph image will be the given width + # and height. + my $width = + main::max($options{width} || ($self->{numberLine} ? ($options{height} / 0.1625) : $options{height}), 200) + + 2; + my $height = main::max($options{height} || ($self->{numberLine} ? (0.1625 * $options{width}) : $options{width}), + $self->{numberLine} ? 50 : 200) + 2; + + main::HEADER_TEXT( + ""); + } + + return << "END_SCRIPT"; +
+ +END_SCRIPT +} + +sub generateTeXGraph { + my ($self, %options) = @_; + + $options{showCorrect} //= 1; + $options{texSize} //= $self->{texSize}; + + return &{ $self->{printGraph} } if ref($self->{printGraph}) eq 'CODE'; + + my @size = $self->{numberLine} ? (500, 100) : (500, 500); + + my $graph = main::createTikZImage(); + $graph->tikzLibraries('arrows.meta'); + $graph->tikzOptions('x=' + . ($size[0] / 96 / ($self->{bBox}[2] - $self->{bBox}[0])) . 'in,y=' + . ($size[1] / 96 / ($self->{bBox}[1] - $self->{bBox}[3])) + . 'in'); + + my $tikz = <={Stealth[scale=1.8]}, + clip even odd rule/.code={\\pgfseteorule}, + inverse clip/.style={ clip,insert path=[clip even odd rule]{ + ($self->{bBox}[0],$self->{bBox}[3]) rectangle ($self->{bBox}[2],$self->{bBox}[1]) } + } +} +\\definecolor{borderblue}{HTML}{356AA0} +\\definecolor{fillpurple}{HTML}{A384E5} +\\pgfdeclarelayer{background} +\\pgfdeclarelayer{foreground} +\\pgfsetlayers{background,main,foreground} +\\begin{pgfonlayer}{background} + \\fill[white,rounded corners=14pt] + ($self->{bBox}[0],$self->{bBox}[3]) rectangle ($self->{bBox}[2],$self->{bBox}[1]); +\\end{pgfonlayer} +END_TIKZ + + unless ($self->{numberLine}) { + # Vertical grid lines + my @xGridLines = + grep { $_ < $self->{bBox}[2] } map { $_ * $self->{gridX} } (1 .. $self->{bBox}[2] / $self->{gridX}); + push(@xGridLines, + grep { $_ > $self->{bBox}[0] } map { -$_ * $self->{gridX} } (1 .. -$self->{bBox}[0] / $self->{gridX})); + $tikz .= + "\\foreach \\x in {" + . join(',', @xGridLines) + . "}{\\draw[line width=0.2pt,color=lightgray] (\\x,$self->{bBox}[3]) -- (\\x,$self->{bBox}[1]);}\n" + if (@xGridLines); + + # Horizontal grid lines + my @yGridLines = + grep { $_ < $self->{bBox}[1] } map { $_ * $self->{gridY} } (1 .. $self->{bBox}[1] / $self->{gridY}); + push(@yGridLines, + grep { $_ > $self->{bBox}[3] } map { -$_ * $self->{gridY} } (1 .. -$self->{bBox}[3] / $self->{gridY})); + $tikz .= + "\\foreach \\y in {" + . join(',', @yGridLines) + . "}{\\draw[line width=0.2pt,color=lightgray] ($self->{bBox}[0],\\y) -- ($self->{bBox}[2],\\y);}\n" + if (@yGridLines); + } + + # Axis and labels. + $tikz .= "\\huge\n\\draw[<->,thick] ($self->{bBox}[0],0) -- ($self->{bBox}[2],0)\n" + . "node[above left,outer sep=2pt]{\\($self->{xAxisLabel}\\)};\n"; + unless ($self->{numberLine}) { + $tikz .= "\\draw[<->,thick] (0,$self->{bBox}[3]) -- (0,$self->{bBox}[1])\n" + . "node[below right,outer sep=2pt]{\\($self->{yAxisLabel}\\)};\n"; + } + + # Horizontal axis ticks and labels + my @xTicks = grep { $_ < $self->{bBox}[2] } + map { $_ * $self->{ticksDistanceX} } (1 .. $self->{bBox}[2] / $self->{ticksDistanceX}); + push(@xTicks, + grep { $_ > $self->{bBox}[0] } + map { -$_ * $self->{ticksDistanceX} } (1 .. -$self->{bBox}[0] / $self->{ticksDistanceX})); + # Add zero if this is a number line and 0 is in the given range. + push(@xTicks, 0) if ($self->{numberLine} && $self->{bBox}[2] > 0 && $self->{bBox}[0] < 0); + my $tickSize = $self->{numberLine} ? '9' : '5'; + $tikz .= + "\\foreach \\x in {" + . join(',', @xTicks) + . "}{\\draw[thin] (\\x,${tickSize}pt) -- (\\x,-${tickSize}pt) node[below]{\\(\\x\\)};}\n" + if (@xTicks); + + # Vertical axis ticks and labels + unless ($self->{numberLine}) { + my @yTicks = grep { $_ < $self->{bBox}[1] } + map { $_ * $self->{ticksDistanceY} } (1 .. $self->{bBox}[1] / $self->{ticksDistanceY}); + push(@yTicks, + grep { $_ > $self->{bBox}[3] } + map { -$_ * $self->{ticksDistanceY} } (1 .. -$self->{bBox}[3] / $self->{ticksDistanceY})); + $tikz .= + "\\foreach \\y in {" + . join(',', @yTicks) + . "}{\\draw[thin] (5pt,\\y) -- (-5pt,\\y) node[left]{\$\\y\$};}\n" + if (@yTicks); + } + + # Border box + $tikz .= "\\draw[borderblue,rounded corners=14pt,thick] " + . "($self->{bBox}[0],$self->{bBox}[3]) rectangle ($self->{bBox}[2],$self->{bBox}[1]);\n"; + + # This works in two passes if both static objects are present and correct answers are present and to be graphed. + # First static objects are graphed, and then correct answers are graphed. The point is that static fills should not + # be affected (clipped) by the correct answer objects. Note that the @object_data containing the clipping code is + # cumulative. This is because the correct answer fills should be clipped by the static graph objects. + my (@object_group, @objects); + push(@object_group, $self->{staticObjects}) if @{ $self->{staticObjects} }; + push(@object_group, [ map {"$_"} @{ $self->{data} } ]) if $options{showCorrect} && @{ $self->{data} }; + + for my $fill_group (@object_group) { + # Graph the points, lines, circles, and parabolas in this group. + my $obj = $self->SUPER::new($self->{context}, @$fill_group); + + # Switch to the foreground layer and clipping box for the objects. + $tikz .= "\\begin{pgfonlayer}{foreground}\n"; + $tikz .= "\\clip[rounded corners=14pt] " + . "($self->{bBox}[0],$self->{bBox}[3]) rectangle ($self->{bBox}[2],$self->{bBox}[1]);\n"; + + my @fills; + + # First graph lines, parabolas, and circles. Cache the clipping path and a function + # for determining which side of the object to shade for filling later. + for (@{ $obj->{data} }) { + next unless $graphObjects{ $_->{data}[0] }; + my $graphObject = GraphTool::GraphObject->new($_, $self, $graphObjects{ $_->{data}[0] }); + if ($graphObject->{fillType}) { + push(@fills, $graphObject); + next; + } + $tikz .= $graphObject->tikz; + push(@objects, $graphObject); + } + + # Switch from the foreground layer to the background layer for the fills. + $tikz .= "\\end{pgfonlayer}\n\\begin{pgfonlayer}{background}\n"; + + # Now shade the fill regions. + $tikz .= $_->tikz(\@objects) for @fills; + + # End the background layer. + $tikz .= "\\end{pgfonlayer}"; + } + + $graph->tex($tikz); + + return main::image( + main::insertGraph($graph), + width => $size[0], + height => $size[1], + tex_size => $options{texSize} + ); +} + +sub generateAnswerGraph { + my ($self, %options) = @_; + return $main::displayMode =~ /^(TeX|PTX)$/ + ? $self->generateTeXGraph(%options) + : $self->generateHTMLAnswerGraph(%options); +} + +package GraphTool::GraphObject; + +sub new { + my ($invocant, $object, $gt, $definition) = @_; + return $definition->new($object, $gt) if (defined $definition && ref($definition) ne 'HASH'); + + my $self = bless { object => $object, gt => $gt }, ref($invocant) || $invocant; + + if (ref($definition) eq 'HASH') { + ($self->{pointCmp}, $self->{cmp}) = $definition->{cmp}->($object, $gt) if $definition->{cmp}; + ($self->{tikz}, my $fillData) = $definition->{tikz}{code}->($gt, $object) + if $definition->{tikz} && ref($definition->{tikz}{code}) eq 'CODE'; + ($self->{clipCode}, $self->{fillCmp}, $self->{onBoundary}) = @$fillData; + } + + return $self; +} + +sub pointCmp { my ($self, $point) = @_; return ref($self->{pointCmp}) eq 'CODE' ? $self->{pointCmp}->($point) : 1; } +sub cmp { my ($self, $other, $fuzzy) = @_; return ref($self->{cmp}) eq 'CODE' ? $self->{cmp}->($other, $fuzzy) : 1; } +sub tikz { my $self = shift; return $self->{tikz} // ''; } +sub fillCmp { my ($self, $x, $y) = @_; return ref($self->{fillCmp}) eq 'CODE' ? $self->{fillCmp}->($x, $y) : 1; } + +sub onBoundary { + my ($self, $point, $aVal, $from) = @_; + return + ref($self->{onBoundary}) eq 'CODE' + ? $self->{onBoundary}->($point, $aVal, $from) + : $self->fillCmp(@$point) != $aVal; +} + +package GraphTool::GraphObject::Line; +our @ISA = qw(GraphTool::GraphObject); + +sub new { + my ($invocant, $object, $gt) = @_; + my $self = $invocant->SUPER::new($object, $gt); + + $self->{solid_dashed} = $object->{data}[1]; + ($self->{x1}, $self->{y1}) = $object->{data}[2]->value; + ($self->{x2}, $self->{y2}) = $object->{data}[3]->value; + + $self->{isVertical} = $self->{x1}->value == $self->{x2}->value; + $self->{stdform} = + [ $self->{y1} - $self->{y2}, $self->{x2} - $self->{x1}, $self->{x1} * $self->{y2} - $self->{x2} * $self->{y1} ]; + + return $self unless defined $gt; + + $self->{normalLength} = sqrt(($self->{stdform}[0]->value)**2 + ($self->{stdform}[1]->value)**2); + $self->{drawAttributes} = ''; + + if ($self->{isVertical}) { + $self->{tikzCode} = "($self->{x1},$gt->{bBox}[3]) -- ($self->{x1},$gt->{bBox}[1])"; + } else { + my $m = ($self->{y2}->value - $self->{y1}->value) / ($self->{x2}->value - $self->{x1}->value); + my ($x1, $y1) = ($self->{x1}->value, $self->{y1}->value); + $self->{y} = sub { return $m * ($_[0] - $x1) + $y1; }; + $self->{tikzCode} = + "($gt->{bBox}[0]," + . $self->{y}->($gt->{bBox}[0]) . ') -- ' + . "($gt->{bBox}[2]," + . $self->{y}->($gt->{bBox}[2]) . ')'; + } + + $self->{clipCode} = + "$self->{tikzCode} -- ($self->{gt}{bBox}[2], $self->{gt}{bBox}[1]) -- " + . ($self->{isVertical} + ? "($self->{gt}{bBox}[2], $self->{gt}{bBox}[3])" + : "($self->{gt}{bBox}[0], $self->{gt}{bBox}[1])") + . " -- cycle"; + + return $self; +} + +sub pointCmp { + my ($self, $point) = @_; + my ($x, $y) = $point->value; + return $self->{stdform}[0] * $x + $self->{stdform}[1] * $y + $self->{stdform}[2] <=> 0; +} + +sub cmp { + my ($self, $other, $fuzzy) = @_; + return + $other->{data}[0] eq 'line' + && ($fuzzy || $other->{data}[1] eq $self->{solid_dashed}) + && $self->pointCmp($other->{data}[2]) == 0 + && $self->pointCmp($other->{data}[3]) == 0; +} + +sub tikz { + my $self = shift; + return "\\draw[thick, blue, line width = 2.5pt, $self->{solid_dashed}$self->{drawAttributes}] $self->{tikzCode};\n"; +} + +sub fillCmp { + my ($self, $x, $y) = @_; + return $self->{isVertical} + ? parser::GraphTool::sign($x - $self->{x1}->value) + : parser::GraphTool::sign($y - $self->{y}->($x)); +} + +package GraphTool::GraphObject::Circle; +our @ISA = qw(GraphTool::GraphObject); + +sub new { + my ($invocant, $object, $gt) = @_; + my $self = $invocant->SUPER::new($object, $gt); + + $self->{solid_dashed} = $object->{data}[1]; + $self->{center} = $object->{data}[2]; + ($self->{cx}, $self->{cy}) = $self->{center}->value; + ($self->{px}, $self->{py}) = $object->{data}[3]->value; + + $self->{r_squared} = ($self->{cx} - $self->{px})**2 + ($self->{cy} - $self->{py})**2; + + return $self unless defined $gt; + + $self->{r} = sqrt($self->{r_squared}->value); + $self->{tikzCode} = "($self->{cx}, $self->{cy}) circle[radius = $self->{r}]"; + $self->{clipCode} = $self->{tikzCode}; + + return $self; +} + +sub pointCmp { + my ($self, $point) = @_; + my ($x, $y) = $point->value; + return ($x - $self->{cx})**2 + ($y - $self->{cy})**2 <=> $self->{r_squared}; +} - if ($x < $fillResolution - 1) { - if (!$floodMap[ $pixelPos + 1 ] && !$isBoundaryPixel->($x + 1, $y, [ -1, 0 ])) { - if (!$reachRight) { - push(@pixelStack, [ $x + 1, $y ]); - $reachRight = 1; - } - } else { - $reachRight = 0; - } - } +sub cmp { + my ($self, $other, $fuzzy) = @_; + return + $other->{data}[0] eq 'circle' + && ($fuzzy || $other->{data}[1] eq $self->{solid_dashed}) + && $other->{data}[2] == $self->{center} + && $self->pointCmp($other->{data}[3]) == 0; +} - $y += 1; - $pixelPos += $fillResolution; - } - } +sub tikz { + my $self = shift; + return "\\draw[thick, blue, line width = 2.5pt, $self->{solid_dashed}] $self->{tikzCode};\n"; +} - return 0; - }; - } else { - $pointInFillRegion = sub { - my $point = shift; - my ($px, $py) = map { $_->value } @{ $point->{data} }; +sub fillCmp { + my ($self, $x, $y) = @_; + return parser::GraphTool::sign($self->{r} - sqrt(($self->{cx}->value - $x)**2 + ($self->{cy}->value - $y)**2)); +} - for (@$object_fill_cmps) { - return 0 if $_->[0]->($fx, $fy) != $_->[0]->($px, $py); - } - return 1; - }; - } +package GraphTool::GraphObject::Parabola; +our @ISA = qw(GraphTool::GraphObject); - return ( - $pointInFillRegion, - sub { - my $other = shift; - return $other->{data}[0] eq 'fill' && $pointInFillRegion->($other->{data}[1]); - } - ); +sub new { + my ($invocant, $object, $gt) = @_; + my $self = $invocant->SUPER::new($object, $gt); + + $self->{solid_dashed} = $object->{data}[1]; + $self->{vertical_horizontal} = $object->{data}[2]; + $self->{vertex} = $object->{data}[3]; + ($self->{h}, $self->{k}) = $self->{vertex}->value; + ($self->{px}, $self->{py}) = $object->{data}[4]->value; + + $self->{x_pow} = $self->{vertical_horizontal} eq 'vertical' ? 2 : 1; + $self->{y_pow} = $self->{vertical_horizontal} eq 'vertical' ? 1 : 2; + + return $self unless defined $gt; + + if ($self->{vertical_horizontal} eq 'vertical') { + $self->{a} = (($self->{py} - $self->{k}) / ($self->{px} - $self->{h})**2)->value; + my $diff = sqrt((($self->{a} >= 0 ? $gt->{bBox}[1] : $gt->{bBox}[3]) - $self->{k}->value) / $self->{a}); + my $dmin = $self->{h}->value - $diff; + my $dmax = $self->{h}->value + $diff; + $self->{tikzCode} = + "plot[domain = $dmin:$dmax, smooth] (\\x, {$self->{a} * (\\x - ($self->{h}))^2 + ($self->{k})})"; + $self->{yFunction} = sub { return $self->{a} * ($_[0] - $self->{h}->value)**2 + $self->{k}->value; }; + } else { + $self->{a} = (($self->{px} - $self->{h}) / ($self->{py} - $self->{k})**2)->value; + my $diff = sqrt((($self->{a} >= 0 ? $gt->{bBox}[2] : $gt->{bBox}[0]) - $self->{h}->value) / $self->{a}); + my $dmin = $self->{k}->value - $diff; + my $dmax = $self->{k}->value + $diff; + $self->{tikzCode} = + "plot[domain = $dmin:$dmax, smooth] ({$self->{a} * (\\x - ($self->{k}))^2 + ($self->{h})}, \\x)"; + $self->{xFunction} = sub { return $self->{a} * ($_[0] - $self->{k}->value)**2 + $self->{h}->value; }; } -); -my $customGraphObjects = ''; -my $customTools = ''; + $self->{clipCode} = $self->{tikzCode}; -sub addGraphObjects { - my ($self, %objects) = @_; - $customGraphObjects .= join(',', map {"$_: $objects{$_}{js}"} keys %objects) . ','; - - # Add the object's name and any other custom strings to the context strings, add the - # code for generating the object in print to the %graphObjectTikz hash, and add the - # cmp subroutine to the %graphObjectCmps hash. - for (keys %objects) { - $contextStrings{$_} = {}; - $contextStrings{$_} = {} for (@{ $objects{$_}{strings} }); - $graphObjectTikz{$_} = $objects{$_}{tikz} if defined $objects{$_}{tikz}; - $graphObjectCmps{$_} = $objects{$_}{cmp} if ref($objects{$_}{cmp}) eq 'CODE'; + return $self; +} + +sub pointCmp { + my ($self, $point) = @_; + my ($x, $y) = $point->value; + return ($self->{px} - $self->{h})**$self->{x_pow} * ($y - $self->{k})**$self->{y_pow} + <=> ($self->{py} - $self->{k})**$self->{y_pow} * ($x - $self->{h})**$self->{x_pow}; +} + +sub cmp { + my ($self, $other, $fuzzy) = @_; + return + $other->{data}[0] eq 'parabola' + && ($fuzzy || $other->{data}[1] eq $self->{solid_dashed}) + && $other->{data}[2] eq $self->{vertical_horizontal} + && $other->{data}[3] == $self->{vertex} + && $self->pointCmp($other->{data}[4]) == 0; +} + +sub tikz { + my $self = shift; + return "\\draw[thick, blue, line width = 2.5pt, $self->{solid_dashed}] $self->{tikzCode};\n"; +} + +sub fillCmp { + my ($self, $x, $y) = @_; + return $self->{vertical_horizontal} eq 'vertical' + ? parser::GraphTool::sign($self->{a} * ($y - $self->{yFunction}->($x))) + : parser::GraphTool::sign($self->{a} * ($x - $self->{xFunction}->($y))); +} + +package GraphTool::GraphObject::Point; +our @ISA = qw(GraphTool::GraphObject); + +sub new { + my ($invocant, $object, $gt) = @_; + my $self = $invocant->SUPER::new($object, $gt); + + $self->{point} = $object->{data}[1]; + ($self->{x}, $self->{y}) = map { $_->value } @{ $self->{point}{data} }; + $self->{clipCode} = ''; + + return $self; +} + +sub pointCmp { + my ($self, $point) = @_; + return $self->{point} == $point ? 0 : 1; +} + +sub cmp { + my ($self, $other, $fuzzy) = @_; + return $other->{data}[0] eq 'point' && $self->{point} == $other->{data}[1]; +} + +sub tikz { + my $self = shift; + return "\\draw[line width = 4pt, blue, fill = red] ($self->{x}, $self->{y}) circle[radius = 5pt];\n"; +} + +package GraphTool::GraphObject::Quadratic; +our @ISA = qw(GraphTool::GraphObject); + +sub new { + my ($invocant, $object, $gt) = @_; + my $self = $invocant->SUPER::new($object, $gt); + + $self->{solid_dashed} = $object->{data}[1]; + ($self->{x1}, $self->{y1}) = $object->{data}[2]->value; + ($self->{x2}, $self->{y2}) = $object->{data}[3]->value; + ($self->{x3}, $self->{y3}) = $object->{data}[4]->value; + + $self->{coeffs} = [ + ($self->{x1} - $self->{x2}) * $self->{y3}, + ($self->{x1} - $self->{x3}) * $self->{y2}, + ($self->{x2} - $self->{x3}) * $self->{y1} + ]; + $self->{den} = ($self->{x1} - $self->{x2}) * ($self->{x1} - $self->{x3}) * ($self->{x2} - $self->{x3}); + + return $self unless defined $gt; + + my ($x1, $y1) = ($self->{x1}->value, $self->{y1}->value); + my ($x2, $y2) = ($self->{x2}->value, $self->{y2}->value); + my ($x3, $y3) = ($self->{x3}->value, $self->{y3}->value); + + my $den = $self->{den}->value; + my $a = (($x2 - $x3) * $y1 + ($x3 - $x1) * $y2 + ($x1 - $x2) * $y3) / $den; + + if (abs($a) < 0.000001) { + # Colinear points + $self->{a} = 1; + $self->{function} = sub { return ($y2 - $y1) / ($x2 - $x1) * ($_[0] - $x1) + $y1; }; + $self->{tikzCode} = + "($gt->{bBox}[0]," + . $self->{function}->($gt->{bBox}[0]) + . ") -- ($gt->{bBox}[2]," + . $self->{function}->($gt->{bBox}[2]) . ")"; + + $self->{clipCode} = + "$self->{tikzCode} -- ($gt->{bBox}[2], $gt->{bBox}[1]) -- ($gt->{bBox}[0], $gt->{bBox}[1]) -- cycle"; + } else { + # Non-degenerate quadratic + my $b = (($x3**2 - $x2**2) * $y1 + ($x1**2 - $x3**2) * $y2 + ($x2**2 - $x1**2) * $y3) / $den; + my $c = (($x2 - $x3) * $x2 * $x3 * $y1 + ($x3 - $x1) * $x1 * $x3 * $y2 + ($x1 - $x2) * $x1 * $x2 * $y3) / $den; + my $h = -$b / (2 * $a); + my $k = $c - $b**2 / (4 * $a); + my $diff = sqrt((($a >= 0 ? $gt->{bBox}[1] : $gt->{bBox}[3]) - $k) / $a); + my $dmin = $h - $diff; + my $dmax = $h + $diff; + + $self->{a} = $a; + $self->{function} = sub { return $self->{a} * $_[0]**2 + $b * $_[0] + $c; }; + $self->{tikzCode} = "plot[domain = $dmin:$dmax, smooth] (\\x, {$a * (\\x)^2 + ($b) * \\x + ($c)})"; + $self->{clipCode} = $self->{tikzCode}; } - return; + return $self; } -sub addTools { - my ($self, %tools) = @_; - $customTools .= join(',', map {"$_: $tools{$_}"} keys %tools) . ','; - return; +sub pointCmp { + my ($self, $point) = @_; + my ($x, $y) = $point->value; + return ($x - $self->{x2}) * ($x - $self->{x3}) * $self->{coeffs}[2] - + ($x - $self->{x1}) * ($x - $self->{x3}) * $self->{coeffs}[1] + + ($x - $self->{x1}) * ($x - $self->{x2}) * $self->{coeffs}[0] <=> $self->{den} * $y; } -parser::GraphTool->addGraphObjects( - # The point graph object. - point => { - js => 'graphTool.pointTool.Point', - tikz => { - code => sub { - my ($gt, $object) = @_; +sub cmp { + my ($self, $other, $fuzzy) = @_; + return + $other->{data}[0] eq 'quadratic' + && ($fuzzy || $other->{data}[1] eq $self->{solid_dashed}) + && $self->pointCmp($other->{data}[2]) == 0 + && $self->pointCmp($other->{data}[3]) == 0 + && $self->pointCmp($other->{data}[4]) == 0; +} - my ($x, $y) = map { $_->value } @{ $object->{data}[1]{data} }; +sub tikz { + my $self = shift; + return "\\draw[thick, blue, line width = 2.5pt, $self->{solid_dashed}] $self->{tikzCode};\n"; +} - return ("\\draw[line width=4pt,blue,fill=red] ($x,$y) circle[radius=5pt];", - [ '', sub { return 1; }, sub { return 0; } ]); - } - }, - cmp => sub { - my ($pointObject, $gt) = @_; +sub fillCmp { + my ($self, $x, $y) = @_; + return parser::GraphTool::sign($self->{a} * ($y - $self->{function}->($x))); +} - my $point = $pointObject->{data}[1]; +package GraphTool::GraphObject::Cubic; +our @ISA = qw(GraphTool::GraphObject); +sub new { + my ($invocant, $object, $gt) = @_; + my $self = $invocant->SUPER::new($object, $gt); + + $self->{solid_dashed} = $object->{data}[1]; + ($self->{x1}, $self->{y1}) = $object->{data}[2]->value; + ($self->{x2}, $self->{y2}) = $object->{data}[3]->value; + ($self->{x3}, $self->{y3}) = $object->{data}[4]->value; + ($self->{x4}, $self->{y4}) = $object->{data}[5]->value; + + $self->{coeffs} = [ + ($self->{x1} - $self->{x2}) * ($self->{x1} - $self->{x3}) * ($self->{x2} - $self->{x3}) * $self->{y4}, + ($self->{x1} - $self->{x2}) * ($self->{x1} - $self->{x4}) * ($self->{x2} - $self->{x4}) * $self->{y3}, + ($self->{x1} - $self->{x3}) * ($self->{x1} - $self->{x4}) * ($self->{x3} - $self->{x4}) * $self->{y2}, + ($self->{x2} - $self->{x3}) * ($self->{x2} - $self->{x4}) * ($self->{x3} - $self->{x4}) * $self->{y1} + ]; + $self->{den} = + ($self->{x1} - $self->{x2}) * + ($self->{x1} - $self->{x3}) * + ($self->{x1} - $self->{x4}) * + ($self->{x2} - $self->{x3}) * + ($self->{x2} - $self->{x4}) * + ($self->{x3} - $self->{x4}); + + return $self unless defined $gt; + + my ($x1, $y1) = ($self->{x1}->value, $self->{y1}->value); + my ($x2, $y2) = ($self->{x2}->value, $self->{y2}->value); + my ($x3, $y3) = ($self->{x3}->value, $self->{y3}->value); + my ($x4, $y4) = ($self->{x4}->value, $self->{y4}->value); + + $self->{c3} = + ($y1 / (($x1 - $x2) * ($x1 - $x3) * ($x1 - $x4)) + + $y2 / (($x2 - $x1) * ($x2 - $x3) * ($x2 - $x4)) + + $y3 / (($x3 - $x1) * ($x3 - $x2) * ($x3 - $x4)) + + $y4 / (($x4 - $x1) * ($x4 - $x2) * ($x4 - $x3))); + my $c2 = + ((-$x2 - $x3 - $x4) * $y1 / (($x1 - $x2) * ($x1 - $x3) * ($x1 - $x4)) + + (-$x1 - $x3 - $x4) * $y2 / (($x2 - $x1) * ($x2 - $x3) * ($x2 - $x4)) + + (-$x1 - $x2 - $x4) * $y3 / (($x3 - $x1) * ($x3 - $x2) * ($x3 - $x4)) + + (-$x1 - $x2 - $x3) * $y4 / (($x4 - $x1) * ($x4 - $x2) * ($x4 - $x3))); + + if (abs($self->{c3}) < 0.000001 && abs($c2) < 0.000001) { + # Colinear points + $self->{c3} = 1; + $self->{function} = sub { return ($y2 - $y1) / ($x2 - $x1) * ($_[0] - $x1) + $y1; }; + $self->{tikzCode} = + "($gt->{bBox}[0]," + . $self->{function}->($gt->{bBox}[0]) + . ") -- ($gt->{bBox}[2]," + . $self->{function}->($gt->{bBox}[2]) . ")"; + $self->{clipCode} = + "$self->{tikzCode} -- ($gt->{bBox}[2], $gt->{bBox}[1]) -- ($gt->{bBox}[0], $gt->{bBox}[1]) -- cycle"; + } elsif (abs($self->{c3}) < 0.000001) { + # Quadratic + my $den = ($x1 - $x2) * ($x1 - $x3) * ($x2 - $x3); + my $a = (($x2 - $x3) * $y1 + ($x3 - $x1) * $y2 + ($x1 - $x2) * $y3) / $den; + my $b = (($x3**2 - $x2**2) * $y1 + ($x1**2 - $x3**2) * $y2 + ($x2**2 - $x1**2) * $y3) / $den; + my $c = (($x2 - $x3) * $x2 * $x3 * $y1 + ($x3 - $x1) * $x1 * $x3 * $y2 + ($x1 - $x2) * $x1 * $x2 * $y3) / $den; + my $h = -$b / (2 * $a); + my $k = $c - $b**2 / (4 * $a); + my $diff = sqrt((($a >= 0 ? $gt->{bBox}[1] : $gt->{bBox}[3]) - $k) / $a); + my $dmin = $h - $diff; + my $dmax = $h + $diff; + + $self->{tikzCode} = "plot[domain = $dmin:$dmax, smooth] (\\x, {$a * (\\x)^2 + ($b) * \\x + ($c)})"; + $self->{clipCode} = $self->{tikzCode}; + + $self->{c3} = $a; + $self->{function} = sub { return $a * $_[0]**2 + $b * $_[0] + $c; }; + } else { + # Non-degenerate cubic + $self->{function} = sub { return ( - sub { return 1 }, - sub { - my $other = shift; - return $other->{data}[0] eq 'point' && $point == $other->{data}[1]; - }, - [ defined $gt ? @{ ($graphObjectTikz{point}{code}->($gt, $pointObject))[1] }[ 1, 2 ] : () ] - ); - } - }, - # A three point quadratic graph object. - quadratic => { - js => 'graphTool.quadraticTool.Quadratic', - tikz => { - code => sub { - my ($gt, $object) = @_; - my ($x1, $y1) = map { $_->value } @{ $object->{data}[2]{data} }; - my ($x2, $y2) = map { $_->value } @{ $object->{data}[3]{data} }; - my ($x3, $y3) = map { $_->value } @{ $object->{data}[4]{data} }; - - my $den = ($x1 - $x2) * ($x1 - $x3) * ($x2 - $x3); - my $a = (($x2 - $x3) * $y1 + ($x3 - $x1) * $y2 + ($x1 - $x2) * $y3) / $den; - - if (abs($a) < 0.000001) { - # Colinear points - my $y = sub { return ($y2 - $y1) / ($x2 - $x1) * ($_[0] - $x1) + $y1; }; - my $line = - "($gt->{bBox}[0]," - . $y->($gt->{bBox}[0]) - . ") -- ($gt->{bBox}[2]," - . $y->($gt->{bBox}[2]) . ")"; - my $fillCmp = sub { return sign($_[1] - $y->($_[0])); }; - return ( - "\\draw[thick,blue,line width=2.5pt,$object->{data}[1]] $line;\n", - [ - "$line -- ($gt->{bBox}[2],$gt->{bBox}[1]) -- ($gt->{bBox}[0],$gt->{bBox}[1]) -- cycle", - $fillCmp, - sub { return $fillCmp->(@{ $_[0] }) != $_[1]; } - ] - ); - } else { - # Non-degenerate quadratic - my $b = (($x3**2 - $x2**2) * $y1 + ($x1**2 - $x3**2) * $y2 + ($x2**2 - $x1**2) * $y3) / $den; - my $c = - (($x2 - $x3) * $x2 * $x3 * $y1 + ($x3 - $x1) * $x1 * $x3 * $y2 + ($x1 - $x2) * $x1 * $x2 * $y3) - / $den; - my $h = -$b / (2 * $a); - my $k = $c - $b**2 / (4 * $a); - my $diff = sqrt((($a >= 0 ? $gt->{bBox}[1] : $gt->{bBox}[3]) - $k) / $a); - my $dmin = $h - $diff; - my $dmax = $h + $diff; - my $quadratic = "plot[domain=$dmin:$dmax,smooth](\\x,{$a*(\\x)^2+($b)*\\x+($c)})"; - - my $quadraticFunction = sub { return $a * $_[0]**2 + $b * $_[0] + $c; }; - my $fillCmp = sub { return sign($a * ($_[1] - $quadraticFunction->($_[0]))); }; - - return ( - "\\draw[thick,blue,line width=2.5pt,$object->{data}[1]] $quadratic;", - [ $quadratic, $fillCmp, sub { return $fillCmp->(@{ $_[0] }) != $_[1]; } ] - ); - } - } - }, - cmp => sub { - my ($quadratic, $gt) = @_; - - my $solid_dashed = $quadratic->{data}[1]; - my ($x1, $y1) = $quadratic->{data}[2]->value; - my ($x2, $y2) = $quadratic->{data}[3]->value; - my ($x3, $y3) = $quadratic->{data}[4]->value; - - my @coeffs = (($x1 - $x2) * $y3, ($x1 - $x3) * $y2, ($x2 - $x3) * $y1); - my $den = ($x1 - $x2) * ($x1 - $x3) * ($x2 - $x3); - - my $quadraticPointCmp = sub { - my $point = shift; - my ($x, $y) = $point->value; - return ($x - $x2) * ($x - $x3) * $coeffs[2] - ($x - $x1) * ($x - $x3) * $coeffs[1] + - ($x - $x1) * ($x - $x2) * $coeffs[0] <=> $den * $y; - }; + ($_[0] - $x2) * ($_[0] - $x3) * ($_[0] - $x4) * $y1 / (($x1 - $x2) * ($x1 - $x3) * ($x1 - $x4)) + + ($_[0] - $x1) * ($_[0] - $x3) * ($_[0] - $x4) * $y2 / (($x2 - $x1) * ($x2 - $x3) * ($x2 - $x4)) + + ($_[0] - $x1) * ($_[0] - $x2) * ($_[0] - $x4) * $y3 / (($x3 - $x1) * ($x3 - $x2) * ($x3 - $x4)) + + ($_[0] - $x1) * ($_[0] - $x2) * ($_[0] - $x3) * $y4 / (($x4 - $x1) * ($x4 - $x2) * ($x4 - $x3))); + }; - return ( - $quadraticPointCmp, - sub { - my ($other, $fuzzy) = @_; - return - $other->{data}[0] eq 'quadratic' - && ($fuzzy || $other->{data}[1] eq $solid_dashed) - && $quadraticPointCmp->($other->{data}[2]) == 0 - && $quadraticPointCmp->($other->{data}[3]) == 0 - && $quadraticPointCmp->($other->{data}[4]) == 0; - }, - [ defined $gt ? @{ ($graphObjectTikz{quadratic}{code}->($gt, $quadratic))[1] }[ 1, 2 ] : () ] - ); - } - }, - # A four point cubic graph object. - cubic => { - js => "graphTool.cubicTool.Cubic", - tikz => { - code => sub { - my ($gt, $object) = @_; - - my ($x1, $y1) = map { $_->value } @{ $object->{data}[2]{data} }; - my ($x2, $y2) = map { $_->value } @{ $object->{data}[3]{data} }; - my ($x3, $y3) = map { $_->value } @{ $object->{data}[4]{data} }; - my ($x4, $y4) = map { $_->value } @{ $object->{data}[5]{data} }; - - my $c3 = - ($y1 / (($x1 - $x2) * ($x1 - $x3) * ($x1 - $x4)) + - $y2 / (($x2 - $x1) * ($x2 - $x3) * ($x2 - $x4)) + - $y3 / (($x3 - $x1) * ($x3 - $x2) * ($x3 - $x4)) + - $y4 / (($x4 - $x1) * ($x4 - $x2) * ($x4 - $x3))); - my $c2 = - ((-$x2 - $x3 - $x4) * $y1 / (($x1 - $x2) * ($x1 - $x3) * ($x1 - $x4)) + - (-$x1 - $x3 - $x4) * $y2 / (($x2 - $x1) * ($x2 - $x3) * ($x2 - $x4)) + - (-$x1 - $x2 - $x4) * $y3 / (($x3 - $x1) * ($x3 - $x2) * ($x3 - $x4)) + - (-$x1 - $x2 - $x3) * $y4 / (($x4 - $x1) * ($x4 - $x2) * ($x4 - $x3))); - - if (abs($c3) < 0.000001 && abs($c2) < 0.000001) { - # Colinear points - my $y = sub { return ($y2 - $y1) / ($x2 - $x1) * ($_[0] - $x1) + $y1; }; - my $line = - "($gt->{bBox}[0]," - . $y->($gt->{bBox}[0]) - . ") -- ($gt->{bBox}[2]," - . $y->($gt->{bBox}[2]) . ")"; - my $fillCmp = sub { return sign($_[1] - $y->($_[0])); }; - return ( - "\\draw[thick,blue,line width=2.5pt,$object->{data}[1]] $line;\n", - [ - "$line -- ($gt->{bBox}[2],$gt->{bBox}[1]) -- ($gt->{bBox}[0],$gt->{bBox}[1]) -- cycle", - $fillCmp, - sub { return $fillCmp->(@{ $_[0] }) != $_[1]; } - ] - ); - } elsif (abs($c3) < 0.000001) { - # Quadratic - my $den = ($x1 - $x2) * ($x1 - $x3) * ($x2 - $x3); - my $a = (($x2 - $x3) * $y1 + ($x3 - $x1) * $y2 + ($x1 - $x2) * $y3) / $den; - my $b = (($x3**2 - $x2**2) * $y1 + ($x1**2 - $x3**2) * $y2 + ($x2**2 - $x1**2) * $y3) / $den; - my $c = - (($x2 - $x3) * $x2 * $x3 * $y1 + ($x3 - $x1) * $x1 * $x3 * $y2 + ($x1 - $x2) * $x1 * $x2 * $y3) - / $den; - my $h = -$b / (2 * $a); - my $k = $c - $b**2 / (4 * $a); - my $diff = sqrt((($a >= 0 ? $gt->{bBox}[1] : $gt->{bBox}[3]) - $k) / $a); - my $dmin = $h - $diff; - my $dmax = $h + $diff; - my $parabola = "plot[domain=$dmin:$dmax,smooth](\\x,{$a*(\\x)^2+($b)*\\x+($c)})"; - - my $quadraticFunction = sub { return $a * $_[0]**2 + $b * $_[0] + $c; }; - my $fillCmp = sub { return sign($a * ($_[1] - $quadraticFunction->($_[0]))); }; - - return ( - "\\draw[thick,blue,line width=2.5pt,$object->{data}[1]] $parabola;", - [ $parabola, $fillCmp, sub { return $fillCmp->(@{ $_[0] }) != $_[1]; } ] - ); - } else { - # Non-degenerate cubic - my $cubicFunction = sub { - return (($_[0] - $x2) * - ($_[0] - $x3) * - ($_[0] - $x4) * - $y1 / - (($x1 - $x2) * ($x1 - $x3) * ($x1 - $x4)) + - ($_[0] - $x1) * - ($_[0] - $x3) * - ($_[0] - $x4) * - $y2 / - (($x2 - $x1) * ($x2 - $x3) * ($x2 - $x4)) + - ($_[0] - $x1) * - ($_[0] - $x2) * - ($_[0] - $x4) * - $y3 / - (($x3 - $x1) * ($x3 - $x2) * ($x3 - $x4)) + - ($_[0] - $x1) * - ($_[0] - $x2) * - ($_[0] - $x3) * - $y4 / - (($x4 - $x1) * ($x4 - $x2) * ($x4 - $x3))); - }; - - my $height = $gt->{bBox}[1] - $gt->{bBox}[3]; - my $lowerBound = $gt->{bBox}[3] - $height; - my $upperBound = $gt->{bBox}[1] + $height; - my $step = ($gt->{bBox}[2] - $gt->{bBox}[0]) / 200; - my $x = $gt->{bBox}[0]; - - my $coords; - do { - my $y = $cubicFunction->($x); - $coords .= "($x,$y) " if $y >= $lowerBound && $y <= $upperBound; - $x += $step; - } while ($x < $gt->{bBox}[2]); - - my $cubic = "plot[smooth] coordinates { $coords }"; - my $fillCmp = sub { return sign($c3 * ($_[1] - $cubicFunction->($_[0]))); }; - return ( - "\\draw[thick,blue,line width=2.5pt,$object->{data}[1]] $cubic;", - [ - $cubic - . ( - $c3 > 0 - ? ("-- ($gt->{bBox}[2],$gt->{bBox}[1]) -- ($gt->{bBox}[0],$gt->{bBox}[1])" - . "-- ($gt->{bBox}[0],$gt->{bBox}[3]) -- cycle") - : ("-- ($gt->{bBox}[2],$gt->{bBox}[3]) -- ($gt->{bBox}[0],$gt->{bBox}[3])" - . "-- ($gt->{bBox}[0],$gt->{bBox}[1]) -- cycle") - ), - $fillCmp, - sub { return $fillCmp->(@{ $_[0] }) != $_[1]; } - ] - ); - } - } - }, - cmp => sub { - my ($cubic, $gt) = @_; - - my $solid_dashed = $cubic->{data}[1]; - my ($x1, $y1) = $cubic->{data}[2]->value; - my ($x2, $y2) = $cubic->{data}[3]->value; - my ($x3, $y3) = $cubic->{data}[4]->value; - my ($x4, $y4) = $cubic->{data}[5]->value; - - my @coeffs = ( - ($x1 - $x2) * ($x1 - $x3) * ($x2 - $x3) * $y4, - ($x1 - $x2) * ($x1 - $x4) * ($x2 - $x4) * $y3, - ($x1 - $x3) * ($x1 - $x4) * ($x3 - $x4) * $y2, - ($x2 - $x3) * ($x2 - $x4) * ($x3 - $x4) * $y1 + my $height = $gt->{bBox}[1] - $gt->{bBox}[3]; + my $lowerBound = $gt->{bBox}[3] - $height; + my $upperBound = $gt->{bBox}[1] + $height; + my $step = ($gt->{bBox}[2] - $gt->{bBox}[0]) / 200; + my $x = $gt->{bBox}[0]; + + my $coords; + do { + my $y = $self->{function}->($x); + $coords .= "($x,$y) " if $y >= $lowerBound && $y <= $upperBound; + $x += $step; + } while ($x < $gt->{bBox}[2]); + + $self->{tikzCode} = "plot[smooth] coordinates { $coords }"; + $self->{clipCode} = $self->{tikzCode} + . ( + $self->{c3} > 0 + ? ("-- ($gt->{bBox}[2], $gt->{bBox}[1]) -- ($gt->{bBox}[0], $gt->{bBox}[1])" + . "-- ($gt->{bBox}[0], $gt->{bBox}[3]) -- cycle") + : ("-- ($gt->{bBox}[2], $gt->{bBox}[3]) -- ($gt->{bBox}[0], $gt->{bBox}[3])" + . "-- ($gt->{bBox}[0], $gt->{bBox}[1]) -- cycle") ); - my $den = ($x1 - $x2) * ($x1 - $x3) * ($x1 - $x4) * ($x2 - $x3) * ($x2 - $x4) * ($x3 - $x4); - - my $cubicPointCmp = sub { - my $point = shift; - my ($x, $y) = $point->value; - return ($x - $x2) * ($x - $x3) * ($x - $x4) * $coeffs[3] - - ($x - $x1) * ($x - $x3) * ($x - $x4) * $coeffs[2] + - ($x - $x1) * ($x - $x2) * ($x - $x4) * $coeffs[1] - - ($x - $x1) * ($x - $x2) * ($x - $x3) * $coeffs[0] <=> $den * $y; - }; + } - return ( - $cubicPointCmp, - sub { - my ($other, $fuzzy) = @_; - return - $other->{data}[0] eq 'cubic' - && ($fuzzy || $other->{data}[1] eq $solid_dashed) - && $cubicPointCmp->($other->{data}[2]) == 0 - && $cubicPointCmp->($other->{data}[3]) == 0 - && $cubicPointCmp->($other->{data}[4]) == 0 - && $cubicPointCmp->($other->{data}[5]) == 0; - }, - [ defined $gt ? @{ ($graphObjectTikz{cubic}{code}->($gt, $cubic))[1] }[ 1, 2 ] : () ] - ); - } - }, - # The interval graph object. - interval => { - js => 'graphTool.intervalTool.Interval', - tikz => { - code => sub { - my ($gt, $object) = @_; - - my ($start, $end) = map { $_->value } @{ $object->{data}[1]{data} }; - - my $openEnd = - $gt->{useBracketEnds} - ? '{Parenthesis[round,width=28pt,line width=3pt,length=14pt]}' - : '{Circle[scale=1.1,open]}'; - my $closedEnd = - $gt->{useBracketEnds} ? '{Bracket[width=24pt,line width=3pt,length=8pt]}' : '{Circle[scale=1.1]}'; - - my $open = - $start eq '-infinity' ? '{Stealth[scale=1.1]}' - : $object->{data}[1]{open} eq '[' ? $closedEnd - : $openEnd; - my $close = - $end eq 'infinity' ? '{Stealth[scale=1.1]}' - : $object->{data}[1]{close} eq ']' ? $closedEnd - : $openEnd; - - $start = $gt->{bBox}[0] if $start eq '-infinity'; - $end = $gt->{bBox}[2] if $end eq 'infinity'; - - # This centers an open/close dot or a parenthesis or bracket on the tick. - # TikZ by default puts the end with its outer edge at the tick. - my $shortenLeft = - $open =~ /Circle/ ? ',shorten <=-8.25pt' - : $open =~ /Parenthesis|Bracket/ ? ',shorten <=-1.5pt' - : ''; - my $shortenRight = - $close =~ /Circle/ ? ',shorten >=-8.25pt' - : $open =~ /Parenthesis|Bracket/ ? ',shorten >=-1.5pt' - : ''; - - return ( - "\\draw[thick,blue,line width=4pt,$open-$close$shortenRight$shortenLeft] ($start,0) -- ($end,0);\n", - [ '', sub { return 1; }, sub { return 0; } ] - ); - } - }, - cmp => sub { - my ($intervalObj, $gt) = @_; + return $self; +} - my $interval = $intervalObj->{data}[1]; +sub pointCmp { + my ($self, $point) = @_; + my ($x, $y) = $point->value; + return ($x - $self->{x2}) * ($x - $self->{x3}) * ($x - $self->{x4}) * $self->{coeffs}[3] - + ($x - $self->{x1}) * ($x - $self->{x3}) * ($x - $self->{x4}) * $self->{coeffs}[2] + + ($x - $self->{x1}) * ($x - $self->{x2}) * ($x - $self->{x4}) * $self->{coeffs}[1] - + ($x - $self->{x1}) * ($x - $self->{x2}) * ($x - $self->{x3}) * $self->{coeffs}[0] <=> $self->{den} * $y; +} - return ( - sub { return 1 }, - sub { - my $other = shift; - return $other->{data}[0] eq 'interval' && $interval == $other->{data}[1]; - }, - [ defined $gt ? @{ ($graphObjectTikz{interval}{code}->($gt, $intervalObj))[1] }[ 1, 2 ] : () ] - ); - } - }, - sineWave => { - js => 'graphTool.sineWaveTool.SineWave', - tikz => { - code => sub { - my ($gt, $object) = @_; - - my ($phase, $yshift) = map { $_->value } @{ $object->{data}[2]{data} }; - my $period = $object->{data}[3]->value; - my $amplitude = $object->{data}[4]->value; - - my $pi = main::pi->value; - - my $sinFunction = sub { - return $amplitude * CORE::sin(2 * $pi / $period * ($_[0] - $phase)) + $yshift; - }; - - my $height = $gt->{bBox}[1] - $gt->{bBox}[3]; - my $lowerBound = $gt->{bBox}[3] - $height; - my $upperBound = $gt->{bBox}[1] + $height; - my $step = ($gt->{bBox}[2] - $gt->{bBox}[0]) / 200; - my $x = $gt->{bBox}[0]; - - my $coords; - do { - my $y = $sinFunction->($x); - $coords .= "($x,$y) " if $y >= $lowerBound && $y <= $upperBound; - $x += $step; - } while $x < $gt->{bBox}[2]; - - my $sin = "plot[smooth] coordinates { $coords }"; - my $fillCmp = sub { return sign($_[1] - $sinFunction->($_[0])); }; - - return ( - "\\draw[thick,blue,line width=2.5pt,$object->{data}[1]] $sin;\n", - [ - $sin . "-- ($gt->{bBox}[2],$gt->{bBox}[1]) -- ($gt->{bBox}[0],$gt->{bBox}[1]) -- cycle", - $fillCmp, sub { return $fillCmp->(@{ $_[0] }) != $_[1]; } - ] - ); - } - }, - cmp => sub { - my ($sineWave, $gt) = @_; +sub cmp { + my ($self, $other, $fuzzy) = @_; + return + $other->{data}[0] eq 'cubic' + && ($fuzzy || $other->{data}[1] eq $self->{solid_dashed}) + && $self->pointCmp($other->{data}[2]) == 0 + && $self->pointCmp($other->{data}[3]) == 0 + && $self->pointCmp($other->{data}[4]) == 0 + && $self->pointCmp($other->{data}[5]) == 0; +} - my $solid_dashed = $sineWave->{data}[1]; - my ($phase, $yshift) = $sineWave->{data}[2]->value; - my $period = $sineWave->{data}[3]->value; - my $amplitude = $sineWave->{data}[4]->value; +sub tikz { + my $self = shift; + return "\\draw[thick, blue, line width = 2.5pt, $self->{solid_dashed}] $self->{tikzCode};\n"; +} - my $sinFormula = main::Formula("$amplitude sin(2 * pi / abs($period) (x - $phase)) + $yshift"); +sub fillCmp { + my ($self, $x, $y) = @_; + return parser::GraphTool::sign($self->{c3} * ($y - $self->{function}->($x))); +} - return ( - sub { - my $point = shift; - return $sinFormula->eval(x => $point->{data}[0])->value <=> $point->{data}[1]; - }, - sub { - my ($other, $fuzzy) = @_; - return 0 unless $other->{data}[0] eq 'sineWave'; - my ($phase, $yshift) = $other->{data}[2]->value; - my $period = $other->{data}[3]->value; - my $amplitude = $other->{data}[4]->value; - my $otherSinFormula = - main::Formula("$amplitude sin(2 * pi / abs($period) (x - $phase)) + $yshift"); - return ($fuzzy || $other->{data}[1] eq $solid_dashed) && $sinFormula == $otherSinFormula; - }, - [ defined $gt ? @{ ($graphObjectTikz{sineWave}{code}->($gt, $sineWave))[1] }[ 1, 2 ] : () ] - ); - } - }, - triangle => { - js => 'graphTool.triangleTool.Triangle', - tikz => { - code => sub { - my ($gt, $object) = @_; - - my @points = map { [ $_->{data}[0]->value, $_->{data}[1]->value ] } @{ $object->{data} }[ 2 .. 4 ]; - - my ($p1x, $p1y) = @{ $points[0] }; - my ($p2x, $p2y) = @{ $points[1] }; - my ($p3x, $p3y) = @{ $points[2] }; - my $denominator = ($p2y - $p3y) * ($p1x - $p3x) + ($p3x - $p2x) * ($p1y - $p3y); - - my (@borderStdForms, @normalLengths); - for (0 .. $#points) { - my ($x1, $y1) = @{ $points[$_] }; - my ($x2, $y2) = @{ $points[ ($_ + 1) % 3 ] }; - push(@borderStdForms, [ $y1 - $y2, $x2 - $x1, $x1 * $y2 - $x2 * $y1 ]); - push(@normalLengths, sqrt($borderStdForms[-1][0]**2 + $borderStdForms[-1][1]**2)); - } +package GraphTool::GraphObject::Interval; +our @ISA = qw(GraphTool::GraphObject); - my $fillCmp = sub { - my $s = - (($p2y - $p3y) * ($_[0] - $p3x) + ($p3x - $p2x) * ($_[1] - $p3y)) / $denominator; - my $t = - (($p3y - $p1y) * ($_[0] - $p3x) + ($p1x - $p3x) * ($_[1] - $p3y)) / $denominator; - if ($s >= 0 && $t >= 0 && $s + $t <= 1) { - return 0 if ($s == 0 || $t == 0 || $s + $t == 1); - return 1; - } - return -1; - }; - - return ( - "\\draw[thick, blue, line width = 2.5pt, $object->{data}[1]] " - . join(' -- ', map {"($_->[0], $_->[1])"} @points) - . ' -- cycle;', - [ - join(' -- ', map {"($_->[0], $_->[1])"} @points) . ' -- cycle', - $fillCmp, - sub { - my ($point, $aVal, $from) = @_; - return 1 if $fillCmp->(@$point) != $aVal; - for (0 .. $#borderStdForms) { - my @stdform = @{ $borderStdForms[$_] }; - my ($x1, $y1) = @{ $points[$_] }; - my ($x2, $y2) = @{ $points[ ($_ + 1) % 3 ] }; - return 1 - if ( - abs($point->[0] * $stdform[0] + $point->[1] * $stdform[1] + $stdform[2]) / - $normalLengths[$_]) < 0.5 / sqrt($gt->{unitX} * $gt->{unitY}) - && $point->[0] > main::min($x1, $x2) - 0.5 / $gt->{unitX} - && $point->[0] < main::max($x1, $x2) + 0.5 / $gt->{unitX} - && $point->[1] > main::min($y1, $y2) - 0.5 / $gt->{unitY} - && $point->[1] < main::max($y1, $y2) + 0.5 / $gt->{unitY}; - } - return 0; - } - ] - ); - } - }, - cmp => sub { - my ($triangle, $gt) = @_; +sub new { + my ($invocant, $object, $gt) = @_; + my $self = $invocant->SUPER::new($object, $gt); - my $solid_dashed = $triangle->{data}[1]; - my $p1 = $triangle->{data}[2]; - my $p2 = $triangle->{data}[3]; - my $p3 = $triangle->{data}[4]; + $self->{interval} = $object->{data}[1]; - my ($p1x, $p1y) = $p1->value; - my ($p2x, $p2y) = $p2->value; - my ($p3x, $p3y) = $p3->value; - my $denominator = ($p2y - $p3y) * ($p1x - $p3x) + ($p3x - $p2x) * ($p1y - $p3y); + return $self unless defined $gt; - return ( - sub { - my $point = shift; - my ($x, $y) = $point->value; - my $s = (($p2y - $p3y) * ($x - $p3x) + ($p3x - $p2x) * ($y - $p3y)) / $denominator; - my $t = (($p3y - $p1y) * ($x - $p3x) + ($p1x - $p3x) * ($y - $p3y)) / $denominator; - if ($s >= 0 && $t >= 0 && $s + $t <= 1) { - return 0 if ($s == 0 || $t == 0 || $s + $t == 1); - return 1; - } - return -1; - }, - sub { - my ($other, $fuzzy) = @_; - return 0 if $other->{data}[0] ne 'triangle' || (!$fuzzy && $other->{data}[1] ne $solid_dashed); + my ($start, $end) = map { $_->value } @{ $self->{interval}{data} }; - for my $otherPoint (@{ $other->{data} }[ 2, 3, 4 ]) { - return 0 if !(grep { $_ == $otherPoint } $p1, $p2, $p3); - } + my $openEnd = + $gt->{useBracketEnds} + ? '{ Parenthesis[round, width = 28pt, line width = 3pt, length = 14pt] }' + : '{ Circle[scale = 1.1, open] }'; + my $closedEnd = + $gt->{useBracketEnds} ? '{ Bracket[width = 24pt,line width = 3pt, length = 8pt] }' : '{ Circle[scale = 1.1] }'; - return 1; - }, - [ defined $gt ? @{ ($graphObjectTikz{triangle}{code}->($gt, $triangle))[1] }[ 1, 2 ] : () ] - ); - } - }, - quadrilateral => { - js => 'graphTool.quadrilateralTool.Quadrilateral', - tikz => { - code => sub { - my ($gt, $object) = @_; - - my @points = map { [ $_->{data}[0]->value, $_->{data}[1]->value ] } @{ $object->{data} }[ 2 .. 5 ]; - - my (@borderCmps, @borderClipCode, @borderStdForms, @normalLengths); - for my $i (0 .. $#points) { - my ($x1, $y1) = @{ $points[$i] }; - my ($x2, $y2) = @{ $points[ ($i + 1) % 4 ] }; - - if ($x1 == $x2) { - # Vertical line - push(@borderCmps, sub { return sign($_[0] - $x1) }); - push( - @borderClipCode, - sub { - return - "\\clip" - . ($_[0] < $x1 ? '[inverse clip]' : '') - . "($x1,$gt->{bBox}[3]) -- ($x1,$gt->{bBox}[1]) -- " - . "($gt->{bBox}[2],$gt->{bBox}[1]) -- ($gt->{bBox}[2],$gt->{bBox}[3]) -- cycle;\n"; - } - ); - } else { - # Non-vertical line - my $m = ($y2 - $y1) / ($x2 - $x1); - my $eqn = sub { return $m * ($_[0] - $x1) + $y1; }; - - push(@borderCmps, sub { return sign($_[1] - $eqn->($_[0])); }); - push( - @borderClipCode, - sub { - return - "\\clip" - . ($_[1] < $eqn->($_[0]) ? '[inverse clip]' : '') - . "($gt->{bBox}[0]," - . $eqn->($gt->{bBox}[0]) . ') -- ' - . "($gt->{bBox}[2]," - . $eqn->($gt->{bBox}[2]) . ') -- ' - . "($gt->{bBox}[2],$gt->{bBox}[1]) -- ($gt->{bBox}[0],$gt->{bBox}[1]) -- cycle;\n"; - } - ); - } + my $open = + $start eq '-infinity' ? '{ Stealth[scale = 1.1] }' : $object->{data}[1]{open} eq '[' ? $closedEnd : $openEnd; + my $close = + $end eq 'infinity' ? '{ Stealth[scale = 1.1] }' : $object->{data}[1]{close} eq ']' ? $closedEnd : $openEnd; - push(@borderStdForms, [ $y1 - $y2, $x2 - $x1, $x1 * $y2 - $x2 * $y1 ]); - push(@normalLengths, sqrt($borderStdForms[-1][0]**2 + $borderStdForms[-1][1]**2)); - } + $start = $gt->{bBox}[0] if $start eq '-infinity'; + $end = $gt->{bBox}[2] if $end eq 'infinity'; - my $isCrossed = ( - ( - $points[0][0] * $borderStdForms[2][0] + - $points[0][1] * $borderStdForms[2][1] + - $borderStdForms[2][2] > 0 - ) != ( - $points[1][0] * $borderStdForms[2][0] + - $points[1][1] * $borderStdForms[2][1] + - $borderStdForms[2][2] > 0 - ) - && ($points[2][0] * $borderStdForms[0][0] + - $points[2][1] * $borderStdForms[0][1] + - $borderStdForms[0][2] > 0) != ( - $points[3][0] * $borderStdForms[0][0] + - $points[3][1] * $borderStdForms[0][1] + - $borderStdForms[0][2] > 0 - ) - ) - || ( - ( - $points[0][0] * $borderStdForms[1][0] + - $points[0][1] * $borderStdForms[1][1] + - $borderStdForms[1][2] > 0 - ) != ( - $points[3][0] * $borderStdForms[1][0] + - $points[3][1] * $borderStdForms[1][1] + - $borderStdForms[1][2] > 0 - ) - && ($points[1][0] * $borderStdForms[3][0] + - $points[1][1] * $borderStdForms[3][1] + - $borderStdForms[3][2] > 0) != ( - $points[2][0] * $borderStdForms[3][0] + - $points[2][1] * $borderStdForms[3][1] + - $borderStdForms[3][2] > 0 - ) - ); - - if ($isCrossed) { - warn 'this is crossed'; - } else { - warn 'this is NOT crossed'; - } + # This centers an open/close dot or a parenthesis or bracket on the tick. + # TikZ by default puts the end with its outer edge at the tick. + my $shortenLeft = + $open =~ /Circle/ ? ', shorten < = -8.25pt' : $open =~ /Parenthesis|Bracket/ ? ', shorten < = -1.5pt' : ''; + my $shortenRight = + $close =~ /Circle/ ? ', shorten > = -8.25pt' : $open =~ /Parenthesis|Bracket/ ? ', shorten > = -1.5pt' : ''; - my $fillCmp = sub { - my ($x, $y) = @_; - - # Check to see if the point is on the border. - for my $i (0 .. 3) { - my ($x1, $y1) = @{ $points[$i] }; - my ($x2, $y2) = @{ $points[ ($i + 1) % 4 ] }; - return 0 - if ($x <= main::max($x1, $x2) - && $x >= main::min($x1, $x2) - && $y <= main::max($y1, $y2) - && $y >= main::min($y1, $y2) - && ($y - $y1) * ($x2 - $x1) - ($y2 - $y1) * ($x - $x1) == 0); - } + $self->{tikzCode} = "($start, 0) -- ($end, 0)"; + $self->{drawAttributes} = ", $open-$close$shortenLeft$shortenRight"; + $self->{clipCode} = ''; - # Check to see if the point is inside. - my $isIn = 0; - for my $i (0 .. 3) { - my ($x1, $y1) = @{ $points[$i] }; - my ($x2, $y2) = @{ $points[ ($i + 1) % 4 ] }; - if ($y1 > $y != $y2 > $y && $x - $x1 < (($x2 - $x1) * ($y - $y1)) / ($y2 - $y1)) { - $isIn = !$isIn; - } - } - if ($isIn) { - return 1 if !$isCrossed; + return $self; +} - my $result = 1; - for my $i (0 .. 3) { - $result |= 1 << ($i + 1) if $borderCmps[$i]->($x, $y) > 0; - } - return $result; - } +sub pointCmp { + my ($self, $point) = @_; + my ($x, $y) = $point->value; + return + $x < $self->{interval}{data}[0] + && $x > $self->{interval}{data}[1] ? 1 : $x == $self->{interval}{data}[0] + && $self->{interval}{open} eq '[' ? 0 : $x == $self->{interval}{data}[1] + && $self->{interval}{close} eq ']' ? 0 : -1; +} - return -1; - }; - - return ( - "\\draw[thick, blue, line width = 2.5pt, $object->{data}[1]] " - . join(' -- ', map {"($_->[0], $_->[1])"} @points) - . " -- cycle;\n", - [ - sub { - my ($x, $y) = @_; - my $cmp = $fillCmp->($x, $y); - return if $cmp == 0; - return join('', map { $borderClipCode[$_]->($x, $y) } 0 .. 3) if $isCrossed && $cmp > 0; - return - '\\clip' - . ($cmp < 0 ? '[inverse clip] ' : ' ') - . join(' -- ', map {"($_->[0], $_->[1])"} @points) - . " -- cycle;\n"; - }, - $fillCmp, - sub { - my ($point, $aVal, $from) = @_; - return 1 if $fillCmp->(@$point) != $aVal; - for (0 .. $#borderStdForms) { - my @stdform = @{ $borderStdForms[$_] }; - my ($x1, $y1) = @{ $points[$_] }; - my ($x2, $y2) = @{ $points[ ($_ + 1) % 4 ] }; - return 1 - if ( - abs($point->[0] * $stdform[0] + $point->[1] * $stdform[1] + $stdform[2]) / - $normalLengths[$_] < 0.5 / - sqrt($gt->{unitX} * $gt->{unitY})) - && $point->[0] > main::min($x1, $x2) - 0.5 / $gt->{unitX} - && $point->[0] < main::max($x1, $x2) + 0.5 / $gt->{unitX} - && $point->[1] > main::min($y1, $y2) - 0.5 / $gt->{unitY} - && $point->[1] < main::max($y1, $y2) + 0.5 / $gt->{unitY}; - } - return 0; - } - ] - ); - } - }, - cmp => sub { - my ($quadrilateral, $gt) = @_; - - my $solid_dashed = $quadrilateral->{data}[1]; - my @points = @{ $quadrilateral->{data} }[ 2 .. 5 ]; - - my @borderCmps; - for my $i (0 .. 3) { - my ($x1, $y1) = @{ $points[$i]{data} }; - my ($x2, $y2) = @{ $points[ ($i + 1) % 4 ]{data} }; - - if ($x1 == $x2) { - # Vertical line - push(@borderCmps, sub { return $_[0] - $x1 }); - } else { - # Non-vertical line - push(@borderCmps, sub { return $_[1] - ($y2 - $y1) / ($x2 - $x1) * ($_[0] - $x1) - $y1; }); - } - } +sub cmp { + my ($self, $other, $fuzzy) = @_; + return $other->{data}[0] eq 'interval' && $self->{interval} == $other->{data}[1]; +} - return ( - sub { - my $point = shift; - my ($x, $y) = $point->value; - - # Check to see if the point is on the border. - for my $i (0 .. 3) { - my ($x1, $y1) = @{ $points[$i]{data} }; - my ($x2, $y2) = @{ $points[ ($i + 1) % 4 ]{data} }; - return 0 - if ($x <= main::max($x1, $x2) - && $x >= main::min($x1, $x2) - && $y <= main::max($y1, $y2) - && $y >= main::min($y1, $y2) - && ($y - $y1) * ($x2 - $x1) - ($y2 - $y1) * ($x - $x1) == 0); - } +sub tikz { + my $self = shift; + return "\\draw[thick, blue, line width = 4pt$self->{drawAttributes}] $self->{tikzCode};\n"; +} - # Check to see if the point is inside. - my $isIn = 0; - for my $i (0 .. 3) { - my ($x1, $y1) = @{ $points[$i]{data} }; - my ($x2, $y2) = @{ $points[ ($i + 1) % 4 ]{data} }; - if ($y1 > $y != $y2 > $y && $x - $x1 < (($x2 - $x1) * ($y - $y1)) / ($y2 - $y1)) { - $isIn = !$isIn; - } - } - if ($isIn) { - my $result = 1; - for my $i (0 .. 3) { - $result |= 1 << ($i + 1) if $borderCmps[$i]->($x, $y) > 0; - } - return $result; - } +package GraphTool::GraphObject::SineWave; +our @ISA = qw(GraphTool::GraphObject); - return -1; - }, - sub { - my ($other, $fuzzy) = @_; - return 0 - if $other->{data}[0] ne 'quadrilateral' || (!$fuzzy && $other->{data}[1] ne $solid_dashed); - - # Check for the four possible cycles that give the same quadrilateral in both directions. - for my $i (0 .. 3) { - my $correct = 1; - for my $j (0 .. 3) { - if ($points[ ($i + $j) % 4 ] != $other->{data}[ $j + 2 ]) { - $correct = 0; - last; - } - } - return 1 if $correct; +sub new { + my ($invocant, $object, $gt) = @_; + my $self = $invocant->SUPER::new($object, $gt); - $correct = 1; - for my $j (0 .. 3) { - if ($points[ 3 - ($i + $j) % 4 ] != $other->{data}[ $j + 2 ]) { - $correct = 0; - last; - } - } - return 1 if $correct; + $self->{solid_dashed} = $object->{data}[1]; + ($self->{phase}, $self->{yshift}) = map { $_->value } $object->{data}[2]->value; + $self->{period} = $object->{data}[3]->value; + $self->{amplitude} = $object->{data}[4]->value; + + $self->{sinFormula} = + main::Formula("$self->{amplitude} sin(2 * pi / abs($self->{period}) (x - $self->{phase})) + $self->{yshift}"); + + return $self unless defined $gt; + + my $pi = main::pi->value; + + $self->{function} = sub { + return $self->{amplitude} * CORE::sin(2 * $pi / $self->{period} * ($_[0] - $self->{phase})) + + $self->{yshift}; + }; + + my $height = $gt->{bBox}[1] - $gt->{bBox}[3]; + my $lowerBound = $gt->{bBox}[3] - $height; + my $upperBound = $gt->{bBox}[1] + $height; + my $step = ($gt->{bBox}[2] - $gt->{bBox}[0]) / 200; + my $x = $gt->{bBox}[0]; + + my $coords; + do { + my $y = $self->{function}->($x); + $coords .= "($x,$y) " if $y >= $lowerBound && $y <= $upperBound; + $x += $step; + } while $x < $gt->{bBox}[2]; + + $self->{tikzCode} = "plot[smooth] coordinates { $coords }"; + $self->{clipCode} = + $self->{tikzCode} . "-- ($gt->{bBox}[2],$gt->{bBox}[1]) -- ($gt->{bBox}[0],$gt->{bBox}[1]) -- cycle"; + + return $self; +} + +sub pointCmp { + my ($self, $point) = @_; + my ($x, $y) = $point->value; + return $self->{sinFormula}->eval(x => $point->{data}[0])->value <=> $point->{data}[1]; +} + +sub cmp { + my ($self, $other, $fuzzy) = @_; + return 0 unless $other->{data}[0] eq 'sineWave'; + + my ($phase, $yshift) = $other->{data}[2]->value; + my $period = $other->{data}[3]->value; + my $amplitude = $other->{data}[4]->value; + my $otherSinFormula = main::Formula("$amplitude sin(2 * pi / abs($period) (x - $phase)) + $yshift"); + + return ($fuzzy || $other->{data}[1] eq $self->{solid_dashed}) && $self->{sinFormula} == $otherSinFormula; +} + +sub tikz { + my $self = shift; + return "\\draw[thick, blue, line width = 2.5pt, $self->{solid_dashed}] $self->{tikzCode};\n"; +} + +sub fillCmp { + my ($self, $x, $y) = @_; + return parser::GraphTool::sign($y - $self->{function}->($x)); +} + +package GraphTool::GraphObject::Triangle; +our @ISA = qw(GraphTool::GraphObject); + +sub new { + my ($invocant, $object, $gt) = @_; + my $self = $invocant->SUPER::new($object, $gt); + + $self->{solid_dashed} = $object->{data}[1]; + $self->{vertices} = [ @{ $object->{data} }[ 2, 3, 4 ] ]; + + $self->{points} = [ map { [ $_->{data}[0]->value, $_->{data}[1]->value ] } @{ $self->{vertices} } ]; + + ($self->{x1}, $self->{y1}) = @{ $self->{points}[0] }; + ($self->{x2}, $self->{y2}) = @{ $self->{points}[1] }; + ($self->{x3}, $self->{y3}) = @{ $self->{points}[2] }; + $self->{denominator} = ($self->{y2} - $self->{y3}) * ($self->{x1} - $self->{x3}) + + ($self->{x3} - $self->{x2}) * ($self->{y1} - $self->{y3}); + + return $self unless defined $gt; + + $self->{borderStdForms} = []; + $self->{normalLengths} = []; + for (0 .. $#{ $self->{points} }) { + my ($x1, $y1) = @{ $self->{points}[$_] }; + my ($x2, $y2) = @{ $self->{points}[ ($_ + 1) % 3 ] }; + push(@{ $self->{borderStdForms} }, [ $y1 - $y2, $x2 - $x1, $x1 * $y2 - $x2 * $y1 ]); + push(@{ $self->{normalLengths} }, sqrt($self->{borderStdForms}[-1][0]**2 + $self->{borderStdForms}[-1][1]**2)); + } + + $self->{tikzCode} = join(' -- ', map {"($_->[0], $_->[1])"} @{ $self->{points} }) . ' -- cycle'; + $self->{clipCode} = $self->{tikzCode}; + + return $self; +} + +sub pointCmp { + my ($self, $point) = @_; + my ($x, $y) = $point->value; + return $self->fillCmp($x, $y); +} + +sub cmp { + my ($self, $other, $fuzzy) = @_; + return 0 if $other->{data}[0] ne 'triangle' || (!$fuzzy && $other->{data}[1] ne $self->{solid_dashed}); + + for my $otherPoint (@{ $other->{data} }[ 2 .. 4 ]) { + return 0 if !(grep { $_ == $otherPoint } @{ $self->{vertices} }); + } + + return 1; +} + +sub tikz { + my $self = shift; + return "\\draw[thick, blue, line width = 2.5pt, $self->{solid_dashed}] $self->{tikzCode};\n"; +} + +sub fillCmp { + my ($self, $x, $y) = @_; + my $s = + (($self->{y2} - $self->{y3}) * ($x - $self->{x3}) + ($self->{x3} - $self->{x2}) * ($y - $self->{y3})) / + $self->{denominator}; + my $t = + (($self->{y3} - $self->{y1}) * ($x - $self->{x3}) + ($self->{x1} - $self->{x3}) * ($y - $self->{y3})) / + $self->{denominator}; + if ($s >= 0 && $t >= 0 && $s + $t <= 1) { + return 0 if ($s == 0 || $t == 0 || $s + $t == 1); + return 1; + } + return -1; +} + +sub onBoundary { + my ($self, $point, $aVal, $from) = @_; + return 1 if $self->fillCmp(@$point) != $aVal; + for (0 .. $#{ $self->{borderStdForms} }) { + my @stdform = @{ $self->{borderStdForms}[$_] }; + my ($x1, $y1) = @{ $self->{points}[$_] }; + my ($x2, $y2) = @{ $self->{points}[ ($_ + 1) % 3 ] }; + return 1 + if (abs($point->[0] * $stdform[0] + $point->[1] * $stdform[1] + $stdform[2]) / $self->{normalLengths}[$_]) + < 0.5 / sqrt($self->{gt}{unitX} * $self->{gt}{unitY}) + && $point->[0] > main::min($x1, $x2) - 0.5 / $self->{gt}{unitX} + && $point->[0] < main::max($x1, $x2) + 0.5 / $self->{gt}{unitX} + && $point->[1] > main::min($y1, $y2) - 0.5 / $self->{gt}{unitY} + && $point->[1] < main::max($y1, $y2) + 0.5 / $self->{gt}{unitY}; + } + return 0; +} + +package GraphTool::GraphObject::Quadrilateral; +our @ISA = qw(GraphTool::GraphObject); + +sub new { + my ($invocant, $object, $gt) = @_; + my $self = $invocant->SUPER::new($object, $gt); + + $self->{solid_dashed} = $object->{data}[1]; + $self->{vertices} = [ @{ $object->{data} }[ 2 .. 5 ] ]; + + $self->{points} = [ map { [ $_->{data}[0]->value, $_->{data}[1]->value ] } @{ $self->{vertices} } ]; + + ($self->{borderCmps}, $self->{borderStdForms}, $self->{borderClipCode}, $self->{normalLengths}) = ([], [], [], []); + for my $i (0 .. $#{ $self->{points} }) { + my ($x1, $y1) = @{ $self->{points}[$i] }; + my ($x2, $y2) = @{ $self->{points}[ ($i + 1) % 4 ] }; + + if ($x1 == $x2) { + # Vertical line + push(@{ $self->{borderCmps} }, sub { return parser::GraphTool::sign($_[0] - $x1) }); + + unless (defined $gt) { + push( + @{ $self->{borderClipCode} }, + sub { + return + "\\clip" + . ($_[0] < $x1 ? '[inverse clip]' : '') + . "($x1, $gt->{bBox}[3]) -- ($x1, $gt->{bBox}[1]) -- " + . "($gt->{bBox}[2], $gt->{bBox}[1]) -- ($gt->{bBox}[2], $gt->{bBox}[3]) -- cycle;\n"; } + ); + } + } else { + # Non-vertical line + my $m = ($y2 - $y1) / ($x2 - $x1); + my $eqn = sub { return $m * ($_[0] - $x1) + $y1; }; - return 0; - }, - [ - defined $gt - ? @{ ($graphObjectTikz{quadrilateral}{code}->($gt, $quadrilateral))[1] }[ 1, 2 ] - : () - ] - ); - } - }, - segment => { - js => 'graphTool.segmentTool.Segment', - tikz => { - code => sub { - my ($gt, $object) = @_; - - my ($p1x, $p1y) = map { $_->value } @{ $object->{data}[2]{data} }; - my ($p2x, $p2y) = map { $_->value } @{ $object->{data}[3]{data} }; - - if ($p1x == $p2x) { - # Vertical segment - return ( - "\\draw[thick, blue, line width = 2.5pt, $object->{data}[1]] ($p1x, $p1y) -- ($p2x, $p2y);\n", - [ - "($p1x,$gt->{bBox}[3]) -- ($p1x,$gt->{bBox}[1])" - . "-- ($gt->{bBox}[2],$gt->{bBox}[1]) -- ($gt->{bBox}[2],$gt->{bBox}[3]) -- cycle", - sub { - return sign($_[0] - $p1x) - || ($_[1] >= main::min($p1y, $p2y) && $_[1] <= main::max($p1y, $p2y) ? 0 : 1); - }, - sub { - my ($point, $aVal, $from) = @_; - - return 0 - if !($_[1] > main::min($p1y, $p2y) - 0.5 / $gt->{unitY} - && $_[1] < main::max($p1y, $p2y) + 0.5 / $gt->{unitY}); - - my @crossingStdForm = ( - $point->[1] - $from->[1], - $from->[0] - $point->[0], - $point->[0] * $from->[1] - $point->[1] * $from->[0] - ); - return ( - ($from->[0] * ($p1y - $p2y) > $p1x * ($p1y - $p2y)) != - ($point->[0] * ($p1y - $p2y) > $p1x * ($p1y - $p2y)) - && ($p1x * $crossingStdForm[0] + - $p1y * $crossingStdForm[1] + - $crossingStdForm[2] > 0) != ( - $p2x * $crossingStdForm[0] + - $p2y * $crossingStdForm[1] + - $crossingStdForm[2] > 0 - ) - ) - || abs($_[0] - $p1x) < 0.5 / $gt->{unitX}; - } - ] - ); - } else { - # Non-vertical segment - my $m = ($p2y - $p1y) / ($p2x - $p1x); - my $y = sub { return $m * ($_[0] - $p1x) + $p1y; }; - my @stdform = ($p1y - $p2y, $p2x - $p1x, $p1x * $p2y - $p2x * $p1y); - my $normalLength = sqrt($stdform[0]**2 + $stdform[1]**2); - return ( - "\\draw[thick, blue, line width = 2.5pt, $object->{data}[1]] ($p1x, $p1y) -- ($p2x, $p2y);\n", - [ - "($gt->{bBox}[0]," - . $y->($gt->{bBox}[0]) . ') -- ' - . "($gt->{bBox}[2]," - . $y->($gt->{bBox}[2]) . ')' - . "-- ($gt->{bBox}[2],$gt->{bBox}[1]) -- ($gt->{bBox}[0],$gt->{bBox}[1]) -- cycle", - sub { - return sign($_[1] - $y->($_[0])) - || ($_[0] >= main::min($p1x, $p2x) - && $_[0] <= main::max($p1x, $p2x) - && $_[1] >= main::min($p1y, $p2y) - && $_[1] <= main::max($p1y, $p2y) ? 0 : 1); - }, - sub { - my ($point, $aVal, $from) = @_; - - return 0 - if !($point->[0] > main::min($p1x, $p2x) - 0.5 / $gt->{unitX} - && $point->[0] < main::max($p1x, $p2x) + 0.5 / $gt->{unitX} - && $point->[1] > main::min($p1y, $p2y) - 0.5 / $gt->{unitY} - && $point->[1] < main::max($p1y, $p2y) + 0.5 / $gt->{unitY}); - - my @crossingStdForm = ( - $point->[1] - $from->[1], - $from->[0] - $point->[0], - $point->[0] * $from->[1] - $point->[1] * $from->[0] - ); - my $pointSide = $point->[0] * $stdform[0] + $point->[1] * $stdform[1] + $stdform[2]; - return ( - ($from->[0] * $stdform[0] + $from->[1] * $stdform[1] + $stdform[2] > 0) != - $pointSide > 0 - && ($p1x * $crossingStdForm[0] + - $p1y * $crossingStdForm[1] + - $crossingStdForm[2] > 0) != ( - $p2x * $crossingStdForm[0] + - $p2y * $crossingStdForm[1] + - $crossingStdForm[2] > 0 - ) - ) - || abs($pointSide) / $normalLength < 0.5 / sqrt($gt->{unitX} * $gt->{unitY}); - } - ] - ); - } + push(@{ $self->{borderCmps} }, sub { return parser::GraphTool::sign($_[1] - $eqn->($_[0])); }); + + unless (defined $gt) { + push( + @{ $self->{borderClipCode} }, + sub { + return + "\\clip" + . ($_[1] < $eqn->($_[0]) ? '[inverse clip]' : '') + . "($gt->{bBox}[0]," + . $eqn->($gt->{bBox}[0]) . ') -- ' + . "($gt->{bBox}[2]," + . $eqn->($gt->{bBox}[2]) . ') -- ' + . "($gt->{bBox}[2],$gt->{bBox}[1]) -- ($gt->{bBox}[0],$gt->{bBox}[1]) -- cycle;\n"; + } + ); } - }, - cmp => sub { - my ($segment, $gt) = @_; - - my $solid_dashed = $segment->{data}[1]; - my @points = @{ $segment->{data} }[ 2, 3 ]; - - # These are the coefficients a, b, and c in ax + by + c = 0. - my @stdform = ( - $segment->{data}[2]{data}[1] - $segment->{data}[3]{data}[1], - $segment->{data}[3]{data}[0] - $segment->{data}[2]{data}[0], - $segment->{data}[2]{data}[0] * $segment->{data}[3]{data}[1] - - $segment->{data}[3]{data}[0] * $segment->{data}[2]{data}[1] - ); + } + + push(@{ $self->{borderStdForms} }, [ $y1 - $y2, $x2 - $x1, $x1 * $y2 - $x2 * $y1 ]); + push(@{ $self->{normalLengths} }, sqrt($self->{borderStdForms}[-1][0]**2 + $self->{borderStdForms}[-1][1]**2)); + } + + $self->{isCrossed} = ( + ( + $self->{points}[0][0] * $self->{borderStdForms}[2][0] + + $self->{points}[0][1] * $self->{borderStdForms}[2][1] + + $self->{borderStdForms}[2][2] > 0 + ) != ( + $self->{points}[1][0] * $self->{borderStdForms}[2][0] + + $self->{points}[1][1] * $self->{borderStdForms}[2][1] + + $self->{borderStdForms}[2][2] > 0 + ) + && ($self->{points}[2][0] * $self->{borderStdForms}[0][0] + + $self->{points}[2][1] * $self->{borderStdForms}[0][1] + + $self->{borderStdForms}[0][2] > 0) != ( + $self->{points}[3][0] * $self->{borderStdForms}[0][0] + + $self->{points}[3][1] * $self->{borderStdForms}[0][1] + + $self->{borderStdForms}[0][2] > 0 + ) + ) + || ( + ( + $self->{points}[0][0] * $self->{borderStdForms}[1][0] + + $self->{points}[0][1] * $self->{borderStdForms}[1][1] + + $self->{borderStdForms}[1][2] > 0 + ) != ( + $self->{points}[3][0] * $self->{borderStdForms}[1][0] + + $self->{points}[3][1] * $self->{borderStdForms}[1][1] + + $self->{borderStdForms}[1][2] > 0 + ) + && ($self->{points}[1][0] * $self->{borderStdForms}[3][0] + + $self->{points}[1][1] * $self->{borderStdForms}[3][1] + + $self->{borderStdForms}[3][2] > 0) != ( + $self->{points}[2][0] * $self->{borderStdForms}[3][0] + + $self->{points}[2][1] * $self->{borderStdForms}[3][1] + + $self->{borderStdForms}[3][2] > 0 + ) + ); - my $segmentPointCmp = sub { - my $point = shift; - my ($x, $y) = $point->value; - return $stdform[0] * $x + $stdform[1] * $y + $stdform[2] <=> 0; - }; + return $self unless defined $gt; - return ( - $segmentPointCmp, - sub { - my ($other, $fuzzy) = @_; - return - $other->{data}[0] eq 'segment' - && ($fuzzy || $other->{data}[1] eq $solid_dashed) - && (($points[0] == $other->{data}[2] && $points[1] == $other->{data}[3]) - || ($points[1] == $other->{data}[2] && $points[0] == $other->{data}[3])); - }, - [ defined $gt ? @{ ($graphObjectTikz{segment}{code}->($gt, $segment))[1] }[ 1, 2 ] : () ] - ); - } - }, - vector => { - js => 'graphTool.vectorTool.Vector', - tikz => { - code => sub { - my ($gt, $object) = @_; - - my ($p1x, $p1y) = map { $_->value } @{ $object->{data}[2]{data} }; - my ($p2x, $p2y) = map { $_->value } @{ $object->{data}[3]{data} }; - - if ($p1x == $p2x) { - # Vertical vector - return ( - "\\draw[thick, blue, line width = 2.5pt, $object->{data}[1], -{Stealth[scale = 1.4]}] " - . "($p1x, $p1y) -- ($p2x, $p2y);\n", - [ - "($p1x,$gt->{bBox}[3]) -- ($p1x,$gt->{bBox}[1])" - . "-- ($gt->{bBox}[2],$gt->{bBox}[1]) -- ($gt->{bBox}[2],$gt->{bBox}[3]) -- cycle", - sub { - return sign($_[0] - $p1x) - || ($_[1] >= main::min($p1y, $p2y) && $_[1] <= main::max($p1y, $p2y) ? 0 : 1); - }, - sub { - my ($point, $aVal, $from) = @_; - my @crossingStdForm = ( - $point->[1] - $from->[1], - $from->[0] - $point->[0], - $point->[0] * $from->[1] - $point->[1] * $from->[0] - ); - return ( - ($from->[0] * ($p1y - $p2y) > $p1x * ($p1y - $p2y)) != - ($point->[0] * ($p1y - $p2y) > $p1x * ($p1y - $p2y)) - && ($p1x * $crossingStdForm[0] + - $p1y * $crossingStdForm[1] + - $crossingStdForm[2] > 0) != ( - $p2x * $crossingStdForm[0] + - $p2y * $crossingStdForm[1] + - $crossingStdForm[2] > 0 - ) - ) - || (abs($_[0] - $p1x) < 0.5 / $gt->{unitX} - && $_[1] > main::min($p1y, $p2y) - 0.5 / $gt->{unitY} - && $_[1] < main::max($p1y, $p2y) + 0.5 / $gt->{unitY}); - } - ] - ); - } else { - # Non-vertical vector - my $m = ($p2y - $p1y) / ($p2x - $p1x); - my $y = sub { return $m * ($_[0] - $p1x) + $p1y; }; - my @stdform = ($p1y - $p2y, $p2x - $p1x, $p1x * $p2y - $p2x * $p1y); - my $normalLength = sqrt($stdform[0]**2 + $stdform[1]**2); - return ( - "\\draw[thick, blue, line width = 2.5pt, $object->{data}[1], -{Stealth[scale = 1.4]}] " - . "($p1x, $p1y) -- ($p2x, $p2y);\n", - [ - "($gt->{bBox}[0]," - . $y->($gt->{bBox}[0]) . ') -- ' - . "($gt->{bBox}[2]," - . $y->($gt->{bBox}[2]) . ')' - . "-- ($gt->{bBox}[2],$gt->{bBox}[1]) -- ($gt->{bBox}[0],$gt->{bBox}[1]) -- cycle", - sub { - return sign($_[1] - $y->($_[0])) - || ($_[0] >= main::min($p1x, $p2x) - && $_[0] <= main::max($p1x, $p2x) - && $_[1] >= main::min($p1y, $p2y) - && $_[1] <= main::max($p1y, $p2y) ? 0 : 1); - }, - sub { - my ($point, $aVal, $from) = @_; - my @crossingStdForm = ( - $point->[1] - $from->[1], - $from->[0] - $point->[0], - $point->[0] * $from->[1] - $point->[1] * $from->[0] - ); - my $pointSide = $point->[0] * $stdform[0] + $point->[1] * $stdform[1] + $stdform[2]; - return ( - ($from->[0] * $stdform[0] + $from->[1] * $stdform[1] + $stdform[2] > 0) != - $pointSide > 0 - && ($p1x * $crossingStdForm[0] + - $p1y * $crossingStdForm[1] + - $crossingStdForm[2] > 0) != ( - $p2x * $crossingStdForm[0] + - $p2y * $crossingStdForm[1] + - $crossingStdForm[2] > 0 - ) - ) - || ((abs($pointSide) / $normalLength < 0.5 / sqrt($gt->{unitX} * $gt->{unitY})) - && $point->[0] > main::min($p1x, $p2x) - 0.5 / $gt->{unitX} - && $point->[0] < main::max($p1x, $p2x) + 0.5 / $gt->{unitX} - && $point->[1] > main::min($p1y, $p2y) - 0.5 / $gt->{unitY} - && $point->[1] < main::max($p1y, $p2y) + 0.5 / $gt->{unitY}); - } - ] - ); - } - } - }, - cmp => sub { - my ($vector, $gt) = @_; - - my $solid_dashed = $vector->{data}[1]; - my $initial_point = $vector->{data}[2]; - my $terminal_point = $vector->{data}[3]; - - # These are the coefficients a, b, and c in ax + by + c = 0. - my @stdform = ( - $vector->{data}[2]{data}[1] - $vector->{data}[3]{data}[1], - $vector->{data}[3]{data}[0] - $vector->{data}[2]{data}[0], - $vector->{data}[2]{data}[0] * $vector->{data}[3]{data}[1] - - $vector->{data}[3]{data}[0] * $vector->{data}[2]{data}[1] - ); + $self->{tikzCode} = join(' -- ', map {"($_->[0], $_->[1])"} @{ $self->{points} }) . ' -- cycle'; - my $vectorPointCmp = sub { - my $point = shift; - my ($x, $y) = $point->value; - return $stdform[0] * $x + $stdform[1] * $y + $stdform[2] <=> 0; - }; + $self->{clipCode} = sub { + my ($x, $y) = @_; + my $cmp = $self->fillCmp($x, $y); + return if $cmp == 0; + return join('', map { $self->{borderClipCode}[$_]->($x, $y) } 0 .. 3) if $self->{isCrossed} && $cmp > 0; + return + '\\clip' + . ($cmp < 0 ? '[inverse clip] ' : ' ') + . join(' -- ', map {"($_->[0], $_->[1])"} @{ $self->{points} }) + . " -- cycle;\n"; + }; - my $positionalCmp = sub { - my ($other, $fuzzy) = @_; - return - $other->{data}[0] eq 'vector' - && ($fuzzy || $other->{data}[1] eq $solid_dashed) - && $initial_point == $other->{data}[2] - && $terminal_point == $other->{data}[3]; - }; + return $self; +} - if ($gt->{vectorsArePositional}) { - return ($vectorPointCmp, $positionalCmp, - [ @{ ($graphObjectTikz{vector}{code}->($gt, $vector))[1] }[ 1, 2 ] ]); - } else { - # This comparison method will only return that the other vector is correct once. If the same vector is - # graphed again at a different location it will be considered incorrect for this answer. - my $foundCorrect = 0; - return ( - $vectorPointCmp, - sub { - my ($other, $fuzzy) = @_; - return $positionalCmp->($other, 1) if $fuzzy; - return 0 - unless !$foundCorrect - && $other->{data}[0] eq 'vector' - && $other->{data}[1] eq $solid_dashed - && ($other->{data}[3]{data}[0] - $other->{data}[2]{data}[0] == - $terminal_point->{data}[0] - $initial_point->{data}[0]) - && ($other->{data}[3]{data}[1] - $other->{data}[2]{data}[1] == - $terminal_point->{data}[1] - $initial_point->{data}[1]); - $foundCorrect = 1; - return 1; - }, - [ @{ ($graphObjectTikz{vector}{code}->($gt, $vector))[1] }[ 1, 2 ] ] - ); +sub pointCmp { + my ($self, $point) = @_; + my ($x, $y) = $point->value; + return $self->fillCmp($x, $y); +} + +sub cmp { + my ($self, $other, $fuzzy) = @_; + return 0 + if $other->{data}[0] ne 'quadrilateral' || (!$fuzzy && $other->{data}[1] ne $self->{solid_dashed}); + + # Check for the four possible cycles that give the same quadrilateral in both directions. + for my $i (0 .. 3) { + my $correct = 1; + for my $j (0 .. 3) { + if ($self->{vertices}[ ($i + $j) % 4 ] != $other->{data}[ $j + 2 ]) { + $correct = 0; + last; + } + } + return 1 if $correct; + + $correct = 1; + for my $j (0 .. 3) { + if ($self->{vertices}[ 3 - ($i + $j) % 4 ] != $other->{data}[ $j + 2 ]) { + $correct = 0; + last; } } + return 1 if $correct; } -); -parser::GraphTool->addTools( - # The point tool. - PointTool => 'graphTool.pointTool.PointTool', - # A three point quadratic tool. - QuadraticTool => 'graphTool.quadraticTool.QuadraticTool', - # A four point cubic tool. - CubicTool => 'graphTool.cubicTool.CubicTool', - # An interval tool. - IntervalTool => 'graphTool.intervalTool.IntervalTool', - # A sine wave tool. - SineWaveTool => 'graphTool.sineWaveTool.SineWaveTool', - # A triangle tool. - TriangleTool => 'graphTool.triangleTool.TriangleTool', - # A quadrilateral tool. - QuadrilateralTool => 'graphTool.quadrilateralTool.QuadrilateralTool', - # A line segment tool. - SegmentTool => 'graphTool.segmentTool.SegmentTool', - # A vector tool. - VectorTool => 'graphTool.vectorTool.VectorTool', - # Include/Exclude point tool. - IncludeExcludePointTool => 'graphTool.includeExcludePointTool.IncludeExcludePointTool', -); + return 0; +} -sub ANS_NAME { +sub tikz { my $self = shift; - main::RECORD_IMPLICIT_ANS_NAME($self->{name} = main::NEW_ANS_NAME()) unless defined $self->{name}; - return $self->{name}; + return "\\draw[thick, blue, line width = 2.5pt, $self->{solid_dashed}] $self->{tikzCode};\n"; } -sub type { return 'List'; } +sub fillCmp { + my ($self, $x, $y) = @_; + + # Check to see if the point is on the border. + for my $i (0 .. 3) { + my ($x1, $y1) = @{ $self->{points}[$i] }; + my ($x2, $y2) = @{ $self->{points}[ ($i + 1) % 4 ] }; + return 0 + if ($x <= main::max($x1, $x2) + && $x >= main::min($x1, $x2) + && $y <= main::max($y1, $y2) + && $y >= main::min($y1, $y2) + && ($y - $y1) * ($x2 - $x1) - ($y2 - $y1) * ($x - $x1) == 0); + } -# Convert the GraphTool object's options into JSON that can be passed to the JavaScript -# graphTool method. -sub constructJSXGraphOptions { - my $self = shift; - return if defined($self->{JSXGraphOptions}); - $self->{JSXGraphOptions} = Mojo::JSON::encode_json({ - boundingBox => $self->{bBox}, - $self->{numberLine} - ? ( - defaultAxes => { - x => { - ticks => { - label => { offset => [ 0, -12 ], anchorY => 'top', anchorX => 'middle' }, - drawZero => 1, - ticksDistance => $self->{ticksDistanceX}, - minorTicks => $self->{minorTicksX}, - scale => $self->{scaleX}, - scaleSymbol => $self->{scaleSymbolX}, - strokeWidth => 2, - strokeOpacity => 0.5, - minorHeight => 10, - majorHeight => 14 - } - } - }, - grid => 0 - ) - : ( - defaultAxes => { - x => { - ticks => { - ticksDistance => $self->{ticksDistanceX}, - minorTicks => $self->{minorTicksX}, - scale => $self->{scaleX}, - scaleSymbol => $self->{scaleSymbolX} - } - }, - y => { - ticks => { - ticksDistance => $self->{ticksDistanceY}, - minorTicks => $self->{minorTicksY}, - scale => $self->{scaleY}, - scaleSymbol => $self->{scaleSymbolY} - } - } - }, - grid => { majorStep => [ $self->{gridX}, $self->{gridY} ] } - ) - }); + # Check to see if the point is inside. + my $isIn = 0; + for my $i (0 .. 3) { + my ($x1, $y1) = @{ $self->{points}[$i] }; + my ($x2, $y2) = @{ $self->{points}[ ($i + 1) % 4 ] }; + if ($y1 > $y != $y2 > $y && $x - $x1 < (($x2 - $x1) * ($y - $y1)) / ($y2 - $y1)) { + $isIn = !$isIn; + } + } + if ($isIn) { + return 1 if !$self->{isCrossed}; - return; + my $result = 1; + for my $i (0 .. 3) { + $result |= 1 << ($i + 1) if $self->{borderCmps}[$i]->($x, $y) > 0; + } + return $result; + } + + return -1; } -# Produce a hidden answer rule to contain the JavaScript result and insert the graphbox div and -# JavaScript to display the graph tool. If a hard copy is being generated, then PGtikz.pl is used -# to generate a printable graph instead. An attempt is made to make the printable graph look -# as much as possible like the JavaScript graph. -sub ans_rule { - my $self = shift; - my $answer_value = $main::envir{inputs_ref}{ $self->ANS_NAME } // ''; - my $ans_name = main::RECORD_ANS_NAME($self->ANS_NAME, $answer_value); +sub onBoundary { + my ($self, $point, $aVal, $from) = @_; + return 1 if $self->fillCmp(@$point) != $aVal; + for (0 .. $#{ $self->{borderStdForms} }) { + my @stdform = @{ $self->{borderStdForms}[$_] }; + my ($x1, $y1) = @{ $self->{points}[$_] }; + my ($x2, $y2) = @{ $self->{points}[ ($_ + 1) % 4 ] }; + return 1 + if ( + abs($point->[0] * $stdform[0] + $point->[1] * $stdform[1] + $stdform[2]) / $self->{normalLengths}[$_] < + 0.5 / sqrt($self->{gt}{unitX} * $self->{gt}{unitY})) + && $point->[0] > main::min($x1, $x2) - 0.5 / $self->{gt}{unitX} + && $point->[0] < main::max($x1, $x2) + 0.5 / $self->{gt}{unitX} + && $point->[1] > main::min($y1, $y2) - 0.5 / $self->{gt}{unitY} + && $point->[1] < main::max($y1, $y2) + 0.5 / $self->{gt}{unitY}; + } + return 0; +} - if ($main::displayMode =~ /^(TeX|PTX)$/ && $self->{showInStatic}) { - return $self->generateTeXGraph(showCorrect => 0) - . ($main::displayMode eq 'PTX' ? qq!

! : ''); - } elsif ($main::displayMode eq 'PTX') { - return qq!

!; - } elsif ($main::displayMode eq 'TeX') { - return ''; +package GraphTool::GraphObject::Segment; +our @ISA = qw(GraphTool::GraphObject::Line); + +sub new { + my ($invocant, $object, $gt) = @_; + my $self = $invocant->SUPER::new($object, $gt); + + $self->{points} = [ @{ $object->{data} }[ 2, 3 ] ]; + + return $self unless defined $gt; + + $self->{tikzCode} = "($self->{x1}, $self->{y1}) -- ($self->{x2}, $self->{y2})"; + + if ($self->{isVertical}) { + # Vertical segment + $self->{clipCode} = + "($self->{x1}, $gt->{bBox}[3]) -- ($self->{x1}, $gt->{bBox}[1])" + . "-- ($gt->{bBox}[2], $gt->{bBox}[1]) -- ($gt->{bBox}[2], $gt->{bBox}[3]) -- cycle"; } else { - $self->constructJSXGraphOptions; - return main::tag( - 'div', - data_feedback_insert_element => $ans_name, - class => 'graphtool-outer-container', - main::tag('input', type => 'hidden', name => $ans_name, id => $ans_name, value => $answer_value) - . main::tag( - 'input', - type => 'hidden', - name => "previous_$ans_name", - id => "previous_$ans_name", - value => $answer_value - ) - . < - -END_SCRIPT + # Non-vertical segment + $self->{clipCode} = + "($gt->{bBox}[0]," + . $self->{y}->($gt->{bBox}[0]) . ') -- ' + . "($gt->{bBox}[2]," + . $self->{y}->($gt->{bBox}[2]) . ')' + . "-- ($gt->{bBox}[2], $gt->{bBox}[1]) -- ($gt->{bBox}[0], $gt->{bBox}[1]) -- cycle"; } + + return $self; } -sub cmp_defaults { - my ($self, %options) = @_; +sub cmp { + my ($self, $other, $fuzzy) = @_; + return + $other->{data}[0] eq 'segment' + && ($fuzzy || $other->{data}[1] eq $self->{solid_dashed}) + && (($self->{points}[0] == $other->{data}[2] && $self->{points}[1] == $other->{data}[3]) + || ($self->{points}[1] == $other->{data}[2] && $self->{points}[0] == $other->{data}[3])); +} + +sub fillCmp { + my ($self, $x, $y) = @_; + return $self->SUPER::fillCmp($x, $y) + || ($x >= main::min($self->{x1}->value, $self->{x2}->value) + && $x <= main::max($self->{x1}->value, $self->{x2}->value) + && $y >= main::min($self->{y1}->value, $self->{y2}->value) + && $y <= main::max($self->{y1}->value, $self->{y2}->value) ? 0 : 1); +} + +sub onBoundary { + my ($self, $point, $aVal, $from) = @_; + + return 0 + if !($point->[0] > main::min($self->{x1}->value, $self->{x2}->value) - 0.5 / $self->{gt}{unitX} + && $point->[0] < main::max($self->{x1}->value, $self->{x2}->value) + 0.5 / $self->{gt}{unitX} + && $point->[1] > main::min($self->{y1}->value, $self->{y2}->value) - 0.5 / $self->{gt}{unitY} + && $point->[1] < main::max($self->{y1}->value, $self->{y2}->value) + 0.5 / $self->{gt}{unitY}); + + my @crossingStdForm = + ($point->[1] - $from->[1], $from->[0] - $point->[0], $point->[0] * $from->[1] - $point->[1] * $from->[0]); + my $pointSide = + $point->[0] * $self->{stdform}[0]->value + + $point->[1] * $self->{stdform}[1]->value + + $self->{stdform}[2]->value; + return ( - $self->SUPER::cmp_defaults(%options), - ordered => 0, - entry_type => 'object', - list_type => 'graph' - ); + ( + $from->[0] * $self->{stdform}[0]->value + + $from->[1] * $self->{stdform}[1]->value + + $self->{stdform}[2]->value > 0 + ) != $pointSide > 0 + && ($self->{x1}->value * $crossingStdForm[0] + + $self->{y1}->value * $crossingStdForm[1] + + $crossingStdForm[2] > 0) != ( + $self->{x2}->value * $crossingStdForm[0] + + $self->{y2}->value * $crossingStdForm[1] + + $crossingStdForm[2] > 0 + ) + ) + || abs($pointSide) / $self->{normalLength} < 0.5 / sqrt($self->{gt}{unitX} * $self->{gt}{unitY}); } -# Modify the student's list answer returned by the graphTool JavaScript to reproduce the -# JavaScript graph of the student's answer in the "Answer Preview" box of the results table. -# The raw list form of the answer is displayed in the "Entered" box. -sub cmp_preprocess { - my ($self, $ans) = @_; +package GraphTool::GraphObject::Vector; +our @ISA = qw(GraphTool::GraphObject::Segment); - if ($main::displayMode ne 'TeX' && defined($ans->{student_value})) { - $ans->{preview_latex_string} = $self->generateHTMLAnswerGraph( - idSuffix => 'student_ans_graphbox', - cssClass => 'graphtool-answer-container', - ariaDescription => 'answer preview graph', - objects => join(',', $ans->{student_ans}), - showCorrect => 0 - ); - } +sub new { + my ($invocant, $object, $gt) = @_; + my $self = $invocant->SUPER::new($object, $gt); - return; + # The comparison method for this object will only return that the other vector is correct once. If the same vector + # is graphed again at a different location it will be considered incorrect for this answer. + $self->{foundCorrect} = 0; + + $self->{drawAttributes} = ', ->'; + + return $self; +} + +sub positionalCmp { + my ($self, $other, $fuzzy) = @_; + return + $other->{data}[0] eq 'vector' + && ($fuzzy || $other->{data}[1] eq $self->{solid_dashed}) + && $self->{points}[0] == $other->{data}[2] + && $self->{points}[1] == $other->{data}[3]; } -# Create an answer checker to be passed to ANS(). Any parameters are passed to the checker, as -# well as any parameters passed in via cmpOptions when the GraphTool object is created. -# The correct answer is modified to reproduce the JavaScript graph of the correct answer -# displayed in the "Correct Answer" box of the results table. sub cmp { - my ($self, %options) = @_; - my $cmp = $self->SUPER::cmp( - feedback_options => sub { - my ($ansHash, $options, $problemContents) = @_; - $options->{wrapPreviewInTex} = 0; - $options->{showEntered} = 0; - $options->{feedbackElements} = $problemContents->find('[id="' . $self->ANS_NAME . '_graphbox"]'); - $options->{insertElement} = - $problemContents->at('[data-feedback-insert-element="' . $self->ANS_NAME . '"]'); - $options->{insertMethod} = 'append_content'; - }, - %{ $self->{cmpOptions} }, - %options - ); + my ($self, $other, $fuzzy) = @_; + return $self->positionalCmp($other, $fuzzy) if $fuzzy || $self->{gt}{vectorsArePositional}; + return 0 + unless !$self->{foundCorrect} + && $other->{data}[0] eq 'vector' + && $other->{data}[1] eq $self->{solid_dashed} + && ($other->{data}[3]{data}[0] - $other->{data}[2]{data}[0] == + $self->{points}[1]{data}[0] - $self->{points}[0]{data}[0]) + && ($other->{data}[3]{data}[1] - $other->{data}[2]{data}[1] == + $self->{points}[1]{data}[1] - $self->{points}[0]{data}[1]); + $self->{foundCorrect} = 1; + return 1; +} - unless (ref($cmp->{rh_ans}{list_checker}) eq 'CODE' || ref($cmp->{rh_ans}{checker}) eq 'CODE') { - $cmp->{rh_ans}{list_checker} = sub { - my ($correct, $student, $ans, $value) = @_; - return 0 if $ans->{isPreview}; +package GraphTool::GraphObject::Fill; +our @ISA = qw(GraphTool::GraphObject); - # If there are no correct answers, then the answer is correct if the student doesn't graph anything, and is - # incorrect if the student does graph something. Although, this checker won't actually be called if the - # student doesn't graph anything. So if it is desired for that to be correct, then that must be handled in - # a post filter. - return @$student ? 0 : 1 if !@$correct; +sub new { + my ($invocant, $object, $gt) = @_; + my $self = $invocant->SUPER::new($object, $gt); - # If the student graphed multiple objects, then remove the duplicates. Note that a fuzzy comparison is - # done. This means that the solid/dashed status of the objects is ignored for the comparison. Only the - # solid variant is kept if both appear. The idea is that solid covers dashed. Fills are all kept and - # the duplicates dealt with later. - my (@student_objects, @student_fills); - ANSWER: for my $answer (@$student) { - if ($answer->{data}[0] eq 'fill') { - push(@student_fills, $answer); - next; - } - for (0 .. $#student_objects) { - next unless $student_objects[$_]{data}[0] eq $answer->{data}[0]; - if (($graphObjectCmps{ $student_objects[$_]{data}[0] }->($student_objects[$_], $self))[1] - ->($answer, 1)) - { - $student_objects[$_] = $answer if $answer->{data}[1] eq 'solid'; - next ANSWER; - } - } - push(@student_objects, $answer); - } + $self->{fillType} = 1; + ($self->{fx}, $self->{fy}) = map { $_->value } $object->{data}[1]->value; - # Cache the correct graph object comparison methods. Also cache the correct graph object fill comparison - # methods. These must be passed to the fill compare method generator. Fills need to have all of these to - # determine the correct regions of the graph that are to be filled. Note that the fill comparison methods - # for static objects are added to this list later. - my @object_cmps; - my @object_fill_cmps; - for (@$correct) { - my $type = $_->{data}[0]; - next if $type eq 'fill' || ref($graphObjectCmps{$type}) ne 'CODE'; - my (undef, $object_cmp, $fill_cmp) = $graphObjectCmps{$type}->($_, $self); - push(@object_cmps, $object_cmp); - push(@object_fill_cmps, $fill_cmp); - } + return $self; +} - my @object_scores = (0) x @object_cmps; - my @incorrect_objects; +sub pointCmp { + my ($self, $point, $graphedObjs) = @_; - ENTRY: for my $student_object (@student_objects) { - for (0 .. $#object_cmps) { - if ($object_cmps[$_]->($student_object)) { - ++$object_scores[$_]; - next ENTRY; - } - } + my ($px, $py) = map { $_->value } @{ $point->{data} }; - push(@incorrect_objects, $student_object); + if ($self->{gt}{useFloodFill}) { + return 1 if $self->{fx} == $px && $self->{fy} == $py; + + my @aVals = (0) x @$graphedObjs; + + # If the point is on a graphed object, then there is no filled region. + # FIXME: How should this case be graded? Really, it never should happen. It means the problem author + # chose a fill point on another object. Probably because of carelessness with random parameters. + for (0 .. $#$graphedObjs) { + $aVals[$_] = $graphedObjs->[$_]->fillCmp($self->{fx}, $self->{fy}); + return $graphedObjs->[$_]->fillCmp($px, $py) == 0 ? 1 : 0 if $aVals[$_] == 0; + } + + my $isBoundaryPixel = sub { + my ($x, $y, $fromDir) = @_; + my $curPoint = + [ $self->{gt}{bBox}[0] + $x / $self->{gt}{unitX}, $self->{gt}{bBox}[1] - $y / $self->{gt}{unitY} ]; + my $from = [ + $curPoint->[0] + $fromDir->[0] / $self->{gt}{unitX}, + $curPoint->[1] + $fromDir->[1] / $self->{gt}{unitY} + ]; + for (0 .. $#$graphedObjs) { + return 1 if $graphedObjs->[$_]->onBoundary($curPoint, $aVals[$_], $from); } + return 0; + }; - my $object_score = 0; - for (@object_scores) { ++$object_score if $_; } + my $pxPixel = main::round(($px - $self->{gt}{bBox}[0]) * $self->{gt}{unitX}); + my $pyPixel = main::round(($self->{gt}{bBox}[1] - $py) * $self->{gt}{unitY}); - my $fill_score = 0; - my @fill_scores; - my @incorrect_fill_cmps; + my @floodMap = (0) x $fillResolution**2; + my @pixelStack = ([ + main::round(($self->{fx} - $self->{gt}{bBox}[0]) * $self->{gt}{unitX}), + main::round(($self->{gt}{bBox}[1] - $self->{fy}) * $self->{gt}{unitY}) + ]); - # Now check the fills if all of the objects were correctly graphed. - if ($object_score == @object_scores && $object_score == @student_objects) { - # Add the fill comparison methods for the static graph objects. - for (@{ $self->SUPER::new($self->{context}, @{ $self->{staticObjects} })->{data} }) { - my $type = $_->{data}[0]; - next if $type eq 'fill'; - push(@object_fill_cmps, ($graphObjectCmps{$type}->($_, $self))[2]) - if ref($graphObjectCmps{$type}) eq 'CODE'; - } + # Perform the flood fill algorithm. + while (@pixelStack) { + my ($x, $y) = @{ pop(@pixelStack) }; - # Cache the correct fill comparison methods. - my @fill_cmps; - for (@$correct) { - next unless $_->{data}[0] eq 'fill'; - push(@fill_cmps, ($graphObjectCmps{fill}->($_, \@object_fill_cmps, $self))[1]); - } + # Get current pixel position. + my $pixelPos = $y * $fillResolution + $x; - @fill_scores = (0) x @fill_cmps; + # Go up until the boundary of the fill region or the edge of board is reached. + while ($y >= 0 && !$isBoundaryPixel->($x, $y, [ 0, 1 ])) { + $y -= 1; + $pixelPos -= $fillResolution; + } - ENTRY: for my $student_index (0 .. $#student_fills) { - for (0 .. $#fill_cmps) { - if ($fill_cmps[$_]->($student_fills[$student_index])) { - ++$fill_scores[$_]; - next ENTRY; + $y += 1; + $pixelPos += $fillResolution; + my $reachLeft = 0; + my $reachRight = 0; + + # Go down until the boundary of the fill region or the edge of the board is reached. + while ($y < $fillResolution && !$isBoundaryPixel->($x, $y, [ 0, -1 ])) { + return 1 if $x == $pxPixel && $y == $pyPixel; + + # This is a protection against infinite loops. I have not seen this occur with this code unlike + # the corresponding JavaScript code, but it doesn't hurt to add the protection. + last if $floodMap[$pixelPos]; + + # Fill the pixel + $floodMap[$pixelPos] = 1; + + # While proceeding down check to the left and right to + # see if the fill region extends in those directions. + if ($x > 0) { + if (!$floodMap[ $pixelPos - 1 ] && !$isBoundaryPixel->($x - 1, $y, [ 1, 0 ])) { + if (!$reachLeft) { + push(@pixelStack, [ $x - 1, $y ]); + $reachLeft = 1; } + } else { + $reachLeft = 0; } + } - # Skip incorrect fills in the same region as another incorrect fill. - for (@incorrect_fill_cmps) { next ENTRY if $_->($student_fills[$student_index]); } - - # Cache comparison methods for incorrect fills. - push(@incorrect_fill_cmps, - ($graphObjectCmps{fill}->($student_fills[$student_index], \@object_fill_cmps, $self))[1]); + if ($x < $fillResolution - 1) { + if (!$floodMap[ $pixelPos + 1 ] && !$isBoundaryPixel->($x + 1, $y, [ -1, 0 ])) { + if (!$reachRight) { + push(@pixelStack, [ $x + 1, $y ]); + $reachRight = 1; + } + } else { + $reachRight = 0; + } } - for (@fill_scores) { ++$fill_score if $_; } + $y += 1; + $pixelPos += $fillResolution; } + } - my $score = - ($object_score + $fill_score) / - (@$correct + - (@incorrect_objects ? (@incorrect_objects - (@object_scores - $object_score)) : 0) + - (@incorrect_fill_cmps ? (@incorrect_fill_cmps - (@fill_scores - $fill_score)) : 0)); - - return $score > 0 ? main::Round($score * (@$student > @$correct ? @$student : @$correct), 2) : 0; - }; - } - - if ($main::displayMode ne 'TeX' && $main::displayMode ne 'PTX') { - $cmp->{rh_ans}{correct_ans_latex_string} = $self->generateHTMLAnswerGraph( - idSuffix => 'correct_ans_graphbox', - cssClass => 'graphtool-answer-container', - ariaDescription => 'correct answer graph' - ); + return 0; + } else { + for (@$graphedObjs) { + return 0 if $_->fillCmp($self->{fx}, $self->{fy}) != $_->fillCmp($px, $py); + } + return 1; } - - return $cmp; } -sub generateHTMLAnswerGraph { - my ($self, %options) = @_; - $options{showCorrect} //= 1; +sub cmp { + my ($self, $other, $graphedObjs) = @_; + return $other->{data}[0] eq 'fill' && $self->pointCmp($other->{data}[1], $graphedObjs); +} - ++$self->{graphCount} unless defined $options{idSuffix}; +sub tikz { + my ($self, $objects) = @_; - my $idSuffix = $options{idSuffix} // "ans_graphbox_$self->{graphCount}"; - my $cssClass = $options{cssClass} // 'graphtool-solution-container'; - my $ariaDescription = $options{ariaDescription} // 'graph of solution'; - my $answerObjects = $options{showCorrect} ? join(',', @{ $self->{data} }) : ''; - $answerObjects = join(',', $options{objects}, $answerObjects) if defined $options{objects}; + if ($self->{gt}{useFloodFill}) { + my @aVals = (0) x @$objects; - my $ans_name = $self->ANS_NAME; - $self->constructJSXGraphOptions; + # If the point is on a graphed object, then don't fill. + for (0 .. $#$objects) { + $aVals[$_] = $objects->[$_]->fillCmp($self->{fx}, $self->{fy}); + return '' if $aVals[$_] == 0; + } - if ($options{width} || $options{height}) { - # This enforces a sane minimum width and height for the image. The minimum width is 200 pixels. The minimum - # height is the 200 pixels for two dimensional graphs, and is 50 pixels for number line graphs. Two is added to - # the width and height to account for the container border, and so that the graph image will be the given width - # and height. - my $width = - main::max($options{width} || ($self->{numberLine} ? ($options{height} / 0.1625) : $options{height}), 200) + - 2; - my $height = main::max($options{height} || ($self->{numberLine} ? (0.1625 * $options{width}) : $options{width}), - $self->{numberLine} ? 50 : 200) + 2; + my $isBoundaryPixel = sub { + my ($x, $y, $fromDir) = @_; + my $curPoint = + [ $self->{gt}{bBox}[0] + $x / $self->{gt}{unitX}, $self->{gt}{bBox}[1] - $y / $self->{gt}{unitY} ]; + my $from = [ + $curPoint->[0] + $fromDir->[0] / $self->{gt}{unitX}, + $curPoint->[1] + $fromDir->[1] / $self->{gt}{unitY} + ]; + for (0 .. $#$objects) { + return 1 if $objects->[$_]->onBoundary($curPoint, $aVals[$_], $from); + } + return 0; + }; - main::HEADER_TEXT( - ""); - } + my @floodMap = (0) x $fillResolution**2; + my @pixelStack = ([ + main::round(($self->{fx} - $self->{gt}{bBox}[0]) * $self->{gt}{unitX}), + main::round(($self->{gt}{bBox}[1] - $self->{fy}) * $self->{gt}{unitY}) + ]); - return << "END_SCRIPT"; -
- -END_SCRIPT -} + # Perform the flood fill algorithm. + while (@pixelStack) { + my ($x, $y) = @{ pop(@pixelStack) }; -sub generateTeXGraph { - my ($self, %options) = @_; + # Get current pixel position. + my $pixelPos = $y * $fillResolution + $x; - $options{showCorrect} //= 1; - $options{texSize} //= $self->{texSize}; + # Go up until the boundary of the fill region or the edge of board is reached. + while ($y >= 0 && !$isBoundaryPixel->($x, $y, [ 0, 1 ])) { + $y -= 1; + $pixelPos -= $fillResolution; + } - return &{ $self->{printGraph} } if ref($self->{printGraph}) eq 'CODE'; + $y += 1; + $pixelPos += $fillResolution; + my $reachLeft = 0; + my $reachRight = 0; + + # Go down until the boundary of the fill region or the edge of the board is reached. + while ($y < $fillResolution && !$isBoundaryPixel->($x, $y, [ 0, -1 ])) { + # This is a protection against infinite loops. I have not seen this occur with this code unlike + # the corresponding JavaScript code, but it doesn't hurt to add the protection. + last if $floodMap[$pixelPos]; + + # Fill the pixel + $floodMap[$pixelPos] = 1; + + # While proceeding down check to the left and right to + # see if the fill region extends in those directions. + if ($x > 0) { + if (!$floodMap[ $pixelPos - 1 ] && !$isBoundaryPixel->($x - 1, $y, [ 1, 0 ])) { + if (!$reachLeft) { + push(@pixelStack, [ $x - 1, $y ]); + $reachLeft = 1; + } + } else { + $reachLeft = 0; + } + } - my @size = $self->{numberLine} ? (500, 100) : (500, 500); + if ($x < $fillResolution - 1) { + if (!$floodMap[ $pixelPos + 1 ] && !$isBoundaryPixel->($x + 1, $y, [ -1, 0 ])) { + if (!$reachRight) { + push(@pixelStack, [ $x + 1, $y ]); + $reachRight = 1; + } + } else { + $reachRight = 0; + } + } - my $graph = main::createTikZImage(); - $graph->tikzLibraries('arrows.meta'); - $graph->tikzOptions('x=' - . ($size[0] / 96 / ($self->{bBox}[2] - $self->{bBox}[0])) . 'in,y=' - . ($size[1] / 96 / ($self->{bBox}[1] - $self->{bBox}[3])) - . 'in'); + $y += 1; + $pixelPos += $fillResolution; + } + } - my $tikz = <={Stealth[scale=1.8]}, - clip even odd rule/.code={\\pgfseteorule}, - inverse clip/.style={ clip,insert path=[clip even odd rule]{ - ($self->{bBox}[0],$self->{bBox}[3]) rectangle ($self->{bBox}[2],$self->{bBox}[1]) } - } -} -\\definecolor{borderblue}{HTML}{356AA0} -\\definecolor{fillpurple}{HTML}{A384E5} -\\pgfdeclarelayer{background} -\\pgfdeclarelayer{foreground} -\\pgfsetlayers{background,main,foreground} -\\begin{pgfonlayer}{background} - \\fill[white,rounded corners=14pt] - ($self->{bBox}[0],$self->{bBox}[3]) rectangle ($self->{bBox}[2],$self->{bBox}[1]); -\\end{pgfonlayer} -END_TIKZ + # Next zero out the interior of the filled region so that only the boundary is left. + my @floodMapCopy = @floodMap; + for ($fillResolution + 1 .. $#floodMap - $fillResolution - 1) { + $floodMap[$_] = 0 + if $floodMapCopy[$_] + && $_ % $fillResolution > 0 + && $_ % $fillResolution < $fillResolution - 1 + && ($floodMapCopy[ $_ - $fillResolution ] + && $floodMapCopy[ $_ - 1 ] + && $floodMapCopy[ $_ + 1 ] + && $floodMapCopy[ $_ + $fillResolution ]); + } - unless ($self->{numberLine}) { - # Vertical grid lines - my @xGridLines = - grep { $_ < $self->{bBox}[2] } map { $_ * $self->{gridX} } (1 .. $self->{bBox}[2] / $self->{gridX}); - push(@xGridLines, - grep { $_ > $self->{bBox}[0] } map { -$_ * $self->{gridX} } (1 .. -$self->{bBox}[0] / $self->{gridX})); - $tikz .= - "\\foreach \\x in {" - . join(',', @xGridLines) - . "}{\\draw[line width=0.2pt,color=lightgray] (\\x,$self->{bBox}[3]) -- (\\x,$self->{bBox}[1]);}\n" - if (@xGridLines); + my $tikz = + "\\begin{scope}[fillpurple, line width = 2.5pt]\n" + . '\\clip[rounded corners = 14pt] ' + . "($self->{gt}{bBox}[0], $self->{gt}{bBox}[3]) rectangle ($self->{gt}{bBox}[2], $self->{gt}{bBox}[1]);\n"; - # Horizontal grid lines - my @yGridLines = - grep { $_ < $self->{bBox}[1] } map { $_ * $self->{gridY} } (1 .. $self->{bBox}[1] / $self->{gridY}); - push(@yGridLines, - grep { $_ > $self->{bBox}[3] } map { -$_ * $self->{gridY} } (1 .. -$self->{bBox}[3] / $self->{gridY})); - $tikz .= - "\\foreach \\y in {" - . join(',', @yGridLines) - . "}{\\draw[line width=0.2pt,color=lightgray] ($self->{bBox}[0],\\y) -- ($self->{bBox}[2],\\y);}\n" - if (@yGridLines); - } + my $border = ''; + my $pass = 1; - # Axis and labels. - $tikz .= "\\huge\n\\draw[<->,thick] ($self->{bBox}[0],0) -- ($self->{bBox}[2],0)\n" - . "node[above left,outer sep=2pt]{\\($self->{xAxisLabel}\\)};\n"; - unless ($self->{numberLine}) { - $tikz .= "\\draw[<->,thick] (0,$self->{bBox}[3]) -- (0,$self->{bBox}[1])\n" - . "node[below right,outer sep=2pt]{\\($self->{yAxisLabel}\\)};\n"; - } + # This converts the fill boundaries into curves. On the first pass the outer border is obtained. On + # subsequent passes borders of inner holes are found. The outer border curve is filled, and the inner + # hole curves are clipped out. + while (1) { + my $pos = 0; + for ($pos = 0; $pos < @floodMap && !$floodMap[$pos]; ++$pos) { } + last if ($pos == @floodMap); - # Horizontal axis ticks and labels - my @xTicks = grep { $_ < $self->{bBox}[2] } - map { $_ * $self->{ticksDistanceX} } (1 .. $self->{bBox}[2] / $self->{ticksDistanceX}); - push(@xTicks, - grep { $_ > $self->{bBox}[0] } - map { -$_ * $self->{ticksDistanceX} } (1 .. -$self->{bBox}[0] / $self->{ticksDistanceX})); - # Add zero if this is a number line and 0 is in the given range. - push(@xTicks, 0) if ($self->{numberLine} && $self->{bBox}[2] > 0 && $self->{bBox}[0] < 0); - my $tickSize = $self->{numberLine} ? '9' : '5'; - $tikz .= - "\\foreach \\x in {" - . join(',', @xTicks) - . "}{\\draw[thin] (\\x,${tickSize}pt) -- (\\x,-${tickSize}pt) node[below]{\\(\\x\\)};}\n" - if (@xTicks); + my $followPath; + $followPath = sub { + my $pos = shift; - # Vertical axis ticks and labels - unless ($self->{numberLine}) { - my @yTicks = grep { $_ < $self->{bBox}[1] } - map { $_ * $self->{ticksDistanceY} } (1 .. $self->{bBox}[1] / $self->{ticksDistanceY}); - push(@yTicks, - grep { $_ > $self->{bBox}[3] } - map { -$_ * $self->{ticksDistanceY} } (1 .. -$self->{bBox}[3] / $self->{ticksDistanceY})); - $tikz .= - "\\foreach \\y in {" - . join(',', @yTicks) - . "}{\\draw[thin] (5pt,\\y) -- (-5pt,\\y) node[left]{\$\\y\$};}\n" - if (@yTicks); - } + my $length = 0; + my @coordinates; - # Border box - $tikz .= "\\draw[borderblue,rounded corners=14pt,thick] " - . "($self->{bBox}[0],$self->{bBox}[3]) rectangle ($self->{bBox}[2],$self->{bBox}[1]);\n"; + while (1) { + ++$length; + my $x = $self->{gt}{bBox}[0] + ($pos % $fillResolution) / $self->{gt}{unitX}; + my $y = $self->{gt}{bBox}[1] - int($pos / $fillResolution) / $self->{gt}{unitY}; + if (@coordinates > 1 + && ($y - $coordinates[-2][1]) * ($coordinates[-1][0] - $coordinates[-2][0]) == + ($coordinates[-1][1] - $coordinates[-2][1]) * ($x - $coordinates[-2][0])) + { + $coordinates[-1] = [ $x, $y ]; + } else { + push(@coordinates, [ $x, $y ]); + } - # This works in two passes if both static objects are present and correct answers are present and to be graphed. - # First static objects are graphed, and then correct answers are graphed. The point is that static fills should not - # be affected (clipped) by the correct answer objects. Note that the @object_data containing the clipping code is - # cumulative. This is because the correct answer fills should be clipped by the static graph objects. - my (@object_group, @object_data); - push(@object_group, $self->{staticObjects}) if @{ $self->{staticObjects} }; - push(@object_group, [ map {"$_"} @{ $self->{data} } ]) if $options{showCorrect} && @{ $self->{data} }; + $floodMap[$pos] = 0; + + my $haveRight = $pos % $fillResolution < $fillResolution - 1; + my $haveLower = $pos < @floodMap - $fillResolution; + my $haveLeft = $pos % $fillResolution > 0; + my $haveUpper = $pos >= $fillResolution; + + my @neighbors; + + push(@neighbors, $pos + 1) if ($haveRight && $floodMap[ $pos + 1 ]); + push(@neighbors, $pos + $fillResolution + 1) + if ($haveRight && $haveLower && $floodMap[ $pos + $fillResolution + 1 ]); + push(@neighbors, $pos + $fillResolution) + if ($haveLower && $floodMap[ $pos + $fillResolution ]); + push(@neighbors, $pos + $fillResolution - 1) + if ($haveLeft && $haveLower && $floodMap[ $pos + $fillResolution - 1 ]); + push(@neighbors, $pos - 1) if ($haveLeft && $floodMap[ $pos - 1 ]); + push(@neighbors, $pos - $fillResolution - 1) + if ($haveLeft && $haveUpper && $floodMap[ $pos - $fillResolution - 1 ]); + push(@neighbors, $pos - $fillResolution) + if ($haveUpper && $floodMap[ $pos - $fillResolution ]); + push(@neighbors, $pos - $fillResolution + 1) + if ($haveUpper && $haveRight && $floodMap[ $pos - $fillResolution + 1 ]); + + last unless @neighbors; + + if (@coordinates == 1 || @neighbors == 1) { $pos = $neighbors[0]; } + else { + my $maxLength = 0; + my $maxPath; + $floodMap[$_] = 0 for @neighbors; + for (@neighbors) { + my ($pathLength, @path) = $followPath->($_); + if ($pathLength > $maxLength) { + $maxLength = $pathLength; + $maxPath = \@path; + } + } + push(@coordinates, @$maxPath); + last; + } + } - for my $fill_group (@object_group) { - # Graph the points, lines, circles, and parabolas in this group. - my $obj = $self->SUPER::new($self->{context}, @{$fill_group}); + return ($length, @coordinates); + }; - # Switch to the foreground layer and clipping box for the objects. - $tikz .= "\\begin{pgfonlayer}{foreground}\n"; - $tikz .= "\\clip[rounded corners=14pt] " - . "($self->{bBox}[0],$self->{bBox}[3]) rectangle ($self->{bBox}[2],$self->{bBox}[1]);\n"; + (undef, my @coordinates) = $followPath->($pos); - # First graph lines, parabolas, and circles. Cache the clipping path and a function - # for determining which side of the object to shade for filling later. - for (@{ $obj->{data} }) { - next - unless (ref($graphObjectTikz{ $_->{data}[0] }) eq 'HASH' - && ref($graphObjectTikz{ $_->{data}[0] }{code}) eq 'CODE' - && !$graphObjectTikz{ $_->{data}[0] }{fillType}); - my ($object_tikz, $object_data) = $graphObjectTikz{ $_->{data}[0] }{code}->($self, $_); - $tikz .= $object_tikz; - push(@object_data, $object_data); + if ($pass == 1) { + $border = "\\filldraw plot coordinates {" . join('', map {"($_->[0],$_->[1])"} @coordinates) . "};\n"; + } elsif (@coordinates > 2) { + $tikz .= "\\clip[inverse clip] plot coordinates {" + . join('', map {"($_->[0],$_->[1])"} @coordinates) . "};\n"; + } + ++$pass; } - # Switch from the foreground layer to the background layer for the fills. - $tikz .= "\\end{pgfonlayer}\n\\begin{pgfonlayer}{background}\n"; + $tikz .= "$border\\end{scope}\n"; - # Now shade the fill regions. - for (@{ $obj->{data} }) { - next - unless (ref($graphObjectTikz{ $_->{data}[0] }) eq 'HASH' - && ref($graphObjectTikz{ $_->{data}[0] }{code}) eq 'CODE' - && $graphObjectTikz{ $_->{data}[0] }{fillType}); - $tikz .= $graphObjectTikz{fill}{code}->($self, $_, [@object_data], $obj); + return $tikz; + } else { + my $clip_code = ''; + for (@$objects) { + if (ref($_->{clipCode}) eq 'CODE') { + my $objectClipCode = $_->{clipCode}->($self->{fx}, $self->{fy}); + return '' unless defined $objectClipCode; + $clip_code .= $objectClipCode; + next; + } + my $clip_dir = $_->fillCmp($self->{fx}, $self->{fy}); + return '' if $clip_dir == 0; + $clip_code .= "\\clip " . ($clip_dir < 0 ? '[inverse clip]' : '') . $_->{clipCode} . ";\n"; } - - # End the background layer. - $tikz .= "\\end{pgfonlayer}"; + return + "\\begin{scope}\n\\clip[rounded corners=14pt] " + . "($self->{gt}{bBox}[0],$self->{gt}{bBox}[3]) rectangle ($self->{gt}{bBox}[2],$self->{gt}{bBox}[1]);\n" + . $clip_code + . "\\fill[fillpurple] " + . "($self->{gt}{bBox}[0],$self->{gt}{bBox}[3]) rectangle ($self->{gt}{bBox}[2],$self->{gt}{bBox}[1]);\n" + . "\\end{scope}"; } - - $graph->tex($tikz); - - return main::image( - main::insertGraph($graph), - width => $size[0], - height => $size[1], - tex_size => $options{texSize} - ); -} - -sub generateAnswerGraph { - my ($self, %options) = @_; - return $main::displayMode =~ /^(TeX|PTX)$/ - ? $self->generateTeXGraph(%options) - : $self->generateHTMLAnswerGraph(%options); } 1; From b6e323e912b1c1bc3f54013511aadca75dc83535 Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Fri, 14 Mar 2025 10:41:59 -0500 Subject: [PATCH 2/3] Take the Perl package approach further and make them MathObjects. This makes things much simpler when writing a custom checker. The elements of the correct and student answer lists in the list_checker are all objects that derive from a GraphTool::GraphObject (which derives from a Value::List). See the new POD on CUSTOM CHECKERS for details. --- macros/graph/parserGraphTool.pl | 1537 +++++++++++++++++-------------- 1 file changed, 861 insertions(+), 676 deletions(-) diff --git a/macros/graph/parserGraphTool.pl b/macros/graph/parserGraphTool.pl index 1d0ef89f1..8ada567ae 100644 --- a/macros/graph/parserGraphTool.pl +++ b/macros/graph/parserGraphTool.pl @@ -55,19 +55,19 @@ =head1 GRAPH OBJECTS The following types of graph objects can be graphed: - points - lines - circles - parabolas - quadratics - cubics - intervals - sine waves - triangles - quadrilaterals - line segments - vectors - fills (or shading of a region) + points (GraphTool::GraphObject::Point) + lines (GraphTool::GraphObject::Line) + circles (GraphTool::GraphObject::Circle) + parabolas (GraphTool::GraphObject::Parabola) + quadratics (GraphTool::GraphObject::Qudratic) + cubics (GraphTool::GraphObject::Cubic) + intervals (GraphTool::GraphObject::Interval) + sine waves (GraphTool::GraphObject::SineWave) + triangles (GraphTool::GraphObject::Triangle) + quadrilaterals (GraphTool::GraphObject::Quadrilateral) + line segments (GraphTool::GraphObject::Segment) + vectors (GraphTool::GraphObject::Vector) + fills (or shading of a region) (GraphTool::GraphObject::Fill) The syntax for each of these objects to pass to the GraphTool constructor is summarized as follows. Each object must be enclosed in braces. The first element in the braces must be the @@ -129,7 +129,7 @@ =head1 GRAPH OBJECTS x-coordinate gives the phase shift (or x-translation) and y-coordinate gives the y-translation. The last two elements are the period and amplitude. For Example: - "{sineWave,solid,(2,-4),3,5}" + "{sineWave,solid,(2,-4),3,5}" represents the function C. @@ -137,13 +137,13 @@ =head1 GRAPH OBJECTS if the triangle is expected to be drawn solid or dashed. That is followed by the three vertices of the triangle. For example: - "{triangle,solid,(-1,2),(1,0),(3,3)}" + "{triangle,solid,(-1,2),(1,0),(3,3)}" For quadrilaterals the name "quadrilateral" must be followed by the word "solid" or "dashed" to indicate if the triangle is expected to be drawn solid or dashed. That is followed by the four vertices of the quadrilateral. For example: - "{quadrilateral,solid,(0,0),(4,3),(2,3),(4,-3)}" + "{quadrilateral,solid,(0,0),(4,3),(2,3),(4,-3)}" For line segments the name "segment" must be followed by the word "solid" or "dashed" to indicate if the segment is expected to be drawn solid or dashed. That is followed by the two @@ -165,16 +165,104 @@ =head1 GRAPH OBJECTS object is covered by the solid object and only the solid object is really visible), then the dashed object is ignored. +=head1 CUSTOM CHECKERS + A custom list_checker may be provided instead of using the default checker. This can either be passed as part of the C hash discussed below, or directly to the GraphTool object's -C method. The variable C<$graphToolObjectCmps> can be used in a custom checker and -contains a hash whose keys are the types of the objects described above, and whose values are -methods that can be called passing a MathObject list constructed from one of the objects -described above. When one of these methods is called it will return two methods. The first -method when called passing a MathObject point will return 0 if the point satisfies the equation -of the object, -1 if the equation evaluated at the point is negative, and 1 if the equation -evaluated at the point is positive. The second method when called passing another MathObject -list constructed from one of the objects described as above will return 1 if the two objects are +C method. + +In a custom list checker the correct and student answers will have the MathObject class +C and will be objects that derive from the C package (the +specific packages for the various objects are listed above). If the C<==> comparison operator +is used between these objects it will return true if the objects are visually exactly the same, +and false otherwise. For example, if a correct answer is C<$correct = {line, solid, (0, 0), (1, 1)}> +and the student graphs the line that is represented by C<$student = {line, solid, (-2, -2), (3, 3)}>, +then C<$correct == $student> will be true. + +In addition there are two methods all Cs have that are useful. + +The first is the C method. When it is called for most Cs, +passing a MathObject point it will return 0 if the point satisfies the equation of the object, +-1 if the equation evaluated at the point is negative, and 1 if the equation evaluated at the +point is positive. For a segment or vector it will return 0 if it is a point on the segment or +vector, 1 if the point is on the segment or vector extended to infinity but not on the segment +or vector, and otherwise it will return the same that it would for a line. For a triangle it +will return 0 if the point is on an edge, 1 if it is inside, and -1 if it is outside. For a +quadrilateral it will return 0 if the point is on an edge, and -1 if it is outside. But if the +point is inside then it depends on if the quadrilateral is crossed or not. If the quadrilateral +is not crossed it will return 1. If it is crossed, then it will return a positive number that is +different depending on which part of the interior it is in. For a fill, the C method +will return 0 if the point is in the same region as the fill point, and 1 otherwise. + +The second method is the C method. When it is called for a C +object passing it another C object it will return 1 if the two objects +are visually exactly the same, and 0 otherwise (this is equivalent to using the C<==> operator). +A second parameter may be passed and if that parameter is 1, then the method will return 1 if +the two objects are the same ignoring if the two objects are solid or dashed, and 0 otherwise. +For example, if a correct answer is C<$correct = {line, solid, (0, 0), (1, 1)}> and the student +graphs the line that is represented by C<$student = {line, dashed, (-2, -2), (3, 3)}>, then +C<< $correct->cmp($student, 1) >> will return 1. + +Further note that a C derives from a MathObject C, and so the +things that can be done with MathObject Cs can also be done with +Cs. + +An example of a custom checker follows: + + $m = 2 * random(1, 4); + + $gt = GraphTool("{line, solid, ($m / 2, 0), (0, -$m)}")->with( + cmpOptions => { + list_checker => sub { + my ($correct, $student, $ans, $value) = @_; + + my $score = 0; + my @errors; + + for (0 .. $#$student) { + if ($correct->[0] == $student->[$_]) { ++$score; next; } + + my $nth = Value::List->NameForNumber($_ + 1); + + if ($student->[$_]->extract(1) ne 'line') { + push(@errors, "The $nth object graphed is not a line."); + next; + } + + if ($student->[$_]->extract(2) ne 'solid') { + push(@errors, "The $nth object graphed should be a solid line."); + next; + } + + if (!$correct->[0]->pointCmp($student->[$_]->extract(3)) + || !$correct->[0]->pointCmp($student->[$_]->extract(4))) + { + $score += 0.5; + push(@errors, + "One of points graphed on the $nth object is incorrect." + ); + next; + } + + push(@errors, "The $nth object graphed is incorrect."); + } + + return ($score, @errors); + } + } + } + +B + +The variable C<$graphToolObjectCmps> can be used in a custom checker and contains +a hash whose keys are the types of the objects described above, and whose values are methods +that can be called passing a MathObject list constructed from one of the objects described +above. When one of these methods is called it will return two methods. The first method when +called passing a MathObject point will return 0 if the point satisfies the equation of the +object, -1 if the equation evaluated at the point is negative, and 1 if the equation evaluated +at the point is positive. The second method when called passing another MathObject list +constructed from one of the objects described as above will return 1 if the two objects are exactly the same, and 0 otherwise. A second parameter may be passed and if that parameter is 1, then the method will return 1 if the two objects are the same ignoring if the two objects are solid or dashed, and 0 otherwise. @@ -222,7 +310,7 @@ =head1 GRAPH OBJECTS Note that for C<'vector'> graph objects the C object must be passed in addition to the correct C<'vector'> object to compare to. For example, - my $vectorCmp = ($graphToolObjectCmps->{vector}->($correct->[0], $gt))[1]; + my $vectorCmp = ($graphToolObjectCmps->{vector}->($correct->[0], $gt))[1]; This is so that the correct methods can be returned that take into account the C option that is set for the particular C<$gt> object. @@ -509,10 +597,10 @@ sub _parserGraphTool_init { ADD_CSS_FILE('js/GraphTool/graphtool.css'); ADD_JS_FILE('node_modules/jsxgraph/distrib/jsxgraphcore.js', 0, { defer => undef }); ADD_JS_FILE('js/GraphTool/graphtool.js', 0, { defer => undef }); + ADD_JS_FILE('js/GraphTool/pointtool.js', 0, { defer => undef }); ADD_JS_FILE('js/GraphTool/linetool.js', 0, { defer => undef }); ADD_JS_FILE('js/GraphTool/circletool.js', 0, { defer => undef }); ADD_JS_FILE('js/GraphTool/parabolatool.js', 0, { defer => undef }); - ADD_JS_FILE('js/GraphTool/pointtool.js', 0, { defer => undef }); ADD_JS_FILE('js/GraphTool/quadratictool.js', 0, { defer => undef }); ADD_JS_FILE('js/GraphTool/cubictool.js', 0, { defer => undef }); ADD_JS_FILE('js/GraphTool/intervaltools.js', 0, { defer => undef }); @@ -527,7 +615,7 @@ sub _parserGraphTool_init { loadMacros('MathObjects.pl', 'PGtikz.pl'); -sub GraphTool { parser::GraphTool->new(@_) } +sub GraphTool { parser::GraphTool->create(@_) } $main::graphToolObjectCmps = \%parser::GraphTool::graphObjectCmps; @@ -535,13 +623,25 @@ package parser::GraphTool; our @ISA = qw(Value::List); my %contextStrings = (solid => {}, dashed => {}); +my %graphObjects; +our %graphObjectCmps = (); my $fillResolution = 400; -sub new { - my ($self, @options) = @_; - my $class = ref($self) || $self; - my $context = Parser::Context->getCopy('Point'); +sub create { + my ($invocant, @options) = @_; + + my $context; + if (Value::isContext($options[0])) { + # This supports a context being passed in for the first argument. This should be used with care. At the very + # least the context needs to derive from the Point context. There are advanced use cases that this allows for. + $context = shift @options; + } else { + $context = Parser::Context->getCopy('Point'); + $context->{name} = 'GraphTool'; + } + + $context->{value}{List} = 'parser::GraphTool'; $context->parens->set( '{' => { close => '}', type => 'List', formList => 1, formMatrix => 0, removable => 0 }, '[' => { type => 'Interval' } @@ -554,50 +654,70 @@ sub new { separator => ', ', nestedOpen => '{', nestedClose => '}' - } + }, + GraphObject => { class => 'Parser::List::List', open => '{', close => '}', separator => ', ' } ); $context->strings->add(%contextStrings); - my $obj = $self->SUPER::new($context, @options); - return bless { - data => $obj->{data}, - type => $obj->{type}, - context => $context, - staticObjects => [], - cmpOptions => {}, - bBox => [ -10, 10, 10, -10 ], - gridX => 1, - gridY => 1, - snapSizeX => 1, - snapSizeY => 1, - ticksDistanceX => 2, - ticksDistanceY => 2, - minorTicksX => 1, - minorTicksY => 1, - scaleX => 1, - scaleY => 1, - scaleSymbolX => '', - scaleSymbolY => '', - xAxisLabel => 'x', - yAxisLabel => 'y', - ariaDescription => '', - showCoordinateHints => 1, - coordinateHintsType => 'decimal', - coordinateHintsTypeX => undef, - coordinateHintsTypeY => undef, - showInStatic => 1, - numberLine => 0, - useBracketEnds => 0, - vectorsArePositional => 0, - useFloodFill => 0, - unitX => ($fillResolution - 1) / 20, - unitY => ($fillResolution - 1) / 20, - availableTools => - [ 'LineTool', 'CircleTool', 'VerticalParabolaTool', 'HorizontalParabolaTool', 'FillTool', 'SolidDashTool' ], - texSize => 400, - graphCount => 0 - }, $class; + + my $self = $invocant->SUPER::new($context, @options); + $self->{toolObject} = 1; + $self->{staticObjects} = $self->SUPER::new([]); + $self->{cmpOptions} = {}; + $self->{bBox} = [ -10, 10, 10, -10 ]; + $self->{gridX} = 1; + $self->{gridY} = 1; + $self->{snapSizeX} = 1; + $self->{snapSizeY} = 1; + $self->{ticksDistanceX} = 2; + $self->{ticksDistanceY} = 2; + $self->{minorTicksX} = 1; + $self->{minorTicksY} = 1; + $self->{scaleX} = 1; + $self->{scaleY} = 1; + $self->{scaleSymbolX} = ''; + $self->{scaleSymbolY} = ''; + $self->{xAxisLabel} = 'x'; + $self->{yAxisLabel} = 'y'; + $self->{ariaDescription} = ''; + $self->{showCoordinateHints} = 1; + $self->{coordinateHintsType} = 'decimal'; + $self->{coordinateHintsTypeX} = undef; + $self->{coordinateHintsTypeY} = undef; + $self->{showInStatic} = 1; + $self->{numberLine} = 0; + $self->{useBracketEnds} = 0; + $self->{vectorsArePositional} = 0; + $self->{useFloodFill} = 0; + $self->{unitX} = ($fillResolution - 1) / 20; + $self->{unitY} = ($fillResolution - 1) / 20; + $self->{availableTools} = + [ 'LineTool', 'CircleTool', 'VerticalParabolaTool', 'HorizontalParabolaTool', 'FillTool', 'SolidDashTool' ]; + $self->{texSize} = 400; + $self->{graphCount} = 0; + + $context->flags->set(graphToolObject => $self); + + return $self; } +sub new { + my ($invocant, @options) = @_; + + my $context; + if (Value::isContext($options[0])) { + $context = shift @options; + if (@options == 1 && $graphObjects{ $options[0][0] }) { + return GraphTool::GraphObject->new($context, Value::List->new($options[0]), + $graphObjects{ $options[0][0] }); + } + } + + return $invocant->SUPER::new($context, @options); +} + +sub class { return 'GraphTool'; } +sub type { return 'List'; } + sub with { my ($self, %options) = @_; @@ -613,14 +733,26 @@ sub with { ); } - $self = $self->SUPER::with(%options); - - # These must be recomputed in case the bounding box changed. This also prevents someone from changing these - # directly. They must be defined correctly in terms of the fill resolution and the bounding box with the - # formulas below or the flood fill algorithm won't work right and could even be thrown into an infinite loop. - $self->{unitX} = ($fillResolution - 1) / ($self->{bBox}[2] - $self->{bBox}[0]); - $self->{unitY} = ($fillResolution - 1) / ($self->{bBox}[1] - $self->{bBox}[3]); + $options{staticObjects} = $self->SUPER::new($options{staticObjects}) if ref($options{staticObjects}) eq 'ARRAY'; + $self = $self->SUPER::with(%options); + if ($self->{toolObject}) { + # This ensures that both the original $self and the new $self have their own context that has the + # graphToolObject context flag referring to the correct copy of $self. Furthermore, all of the objects for each + # copy also have the correct context with the flag referring to their copy of $self. + $self = $self->copy; + my $context = $self->context->copy; + $context->flags->set(graphToolObject => $self); + $self->context($context); + $self->{staticObjects} = $self->{staticObjects}->copy; + $self->{staticObjects}->context($context); + + # These must be recomputed in case the bounding box changed. This also prevents someone from changing these + # directly. They must be defined correctly in terms of the fill resolution and the bounding box with the + # formulas below or the flood fill algorithm won't work right and could even be thrown into an infinite loop. + $self->{unitX} = ($fillResolution - 1) / ($self->{bBox}[2] - $self->{bBox}[0]); + $self->{unitY} = ($fillResolution - 1) / ($self->{bBox}[1] - $self->{bBox}[3]); + } return $self; } @@ -631,10 +763,6 @@ sub sign { return 0; } -my %graphObjects; - -our %graphObjectCmps = (); - my $customGraphObjects = ''; my $customTools = ''; @@ -652,9 +780,8 @@ sub addGraphObjects { # Add a backwards compatibility entry to the %graphObjectCmps hash. $graphObjectCmps{$name} = sub { - my ($object, $gt) = @_; - my $graphObject = $graphObjects{$name}->new($object, $gt); - return (sub { $graphObject->pointCmp(@_) }, sub { $graphObject->cmp(@_) }); + my $object = shift; + return (sub { $object->pointCmp(@_) }, sub { $object->cmp(@_) }); }; } else { # Backwards compatibility for the deprecated old way of adding objects. @@ -680,6 +807,7 @@ sub addTools { } parser::GraphTool->addGraphObjects( + point => { js => 'graphTool.pointTool.Point', perlClass => 'GraphTool::GraphObject::Point' }, line => { js => 'graphTool.lineTool.Line', perlClass => 'GraphTool::GraphObject::Line' }, circle => { js => 'graphTool.circleTool.Circle', perlClass => 'GraphTool::GraphObject::Circle' }, parabola => { @@ -687,7 +815,6 @@ sub addTools { perlClass => 'GraphTool::GraphObject::Parabola', strings => [qw(vertical horizontal)] }, - point => { js => 'graphTool.pointTool.Point', perlClass => 'GraphTool::GraphObject::Point' }, quadratic => { js => 'graphTool.quadraticTool.Quadratic', perlClass => 'GraphTool::GraphObject::Quadratic' }, cubic => { js => 'graphTool.cubicTool.Cubic', perlClass => 'GraphTool::GraphObject::Cubic' }, interval => { js => 'graphTool.intervalTool.Interval', perlClass => 'GraphTool::GraphObject::Interval' }, @@ -701,12 +828,12 @@ sub addTools { ); parser::GraphTool->addTools( + PointTool => 'graphTool.pointTool.PointTool', LineTool => 'graphTool.lineTool.LineTool', CircleTool => 'graphTool.circleTool.CircleTool', ParabolaTool => 'graphTool.parabolaTool.ParabolaTool', VerticalParabolaTool => 'graphTool.parabolaTool.VerticalParabolaTool', HorizontalParabolaTool => 'graphTool.parabolaTool.HorizontalParabolaTool', - PointTool => 'graphTool.pointTool.PointTool', QuadraticTool => 'graphTool.quadraticTool.QuadraticTool', CubicTool => 'graphTool.cubicTool.CubicTool', IntervalTool => 'graphTool.intervalTool.IntervalTool', @@ -725,8 +852,6 @@ sub ANS_NAME { return $self->{name}; } -sub type { return 'List'; } - # Convert the GraphTool object's options into JSON that can be passed to the JavaScript # graphTool method. sub constructJSXGraphOptions { @@ -817,7 +942,7 @@ sub ans_rule { const initialize = () => { graphTool('${ans_name}_graphbox', { htmlInputId: '${ans_name}', - staticObjects: '${\(join(',', @{$self->{staticObjects}}))}', + staticObjects: '${\(join(',', $self->{staticObjects}->value))}', snapSizeX: $self->{snapSizeX}, snapSizeY: $self->{snapSizeY}, xAxisLabel: '$self->{xAxisLabel}', @@ -911,26 +1036,22 @@ sub cmp { # the duplicates dealt with later. my (@student_objects, @student_fills); ANSWER: for my $answer (@$student) { - if (!$graphObjects{ $answer->{data}[0] }) { + if (!Value::classMatch($answer, 'GraphObject')) { push(@incorrect_objects, $answer); next; } - my $studentGraphObject = - GraphTool::GraphObject->new($answer, $self, $graphObjects{ $answer->{data}[0] }); - if ($studentGraphObject->{fillType}) { - push(@student_fills, $studentGraphObject); + if ($answer->{fillType}) { + push(@student_fills, $answer); next; } for (0 .. $#student_objects) { - next unless $student_objects[$_]->{object}{data}[0] eq $answer->{data}[0]; + next unless $student_objects[$_]{data}[0] eq $answer->{data}[0]; if ($student_objects[$_]->cmp($answer, 1)) { - if ($answer->{data}[1] eq 'solid') { - $student_objects[$_] = $studentGraphObject; - } + $student_objects[$_] = $answer if $answer->{data}[1] eq 'solid'; next ANSWER; } } - push(@student_objects, $studentGraphObject); + push(@student_objects, $answer); } # Cache the correct graph objects. The fill graph objects are separated from the others. The others must be @@ -940,18 +1061,15 @@ sub cmp { my @objects; my @fillObjects; for (@$correct) { - my $type = $_->{data}[0]; - next unless $graphObjects{$type}; - my $graphObject = GraphTool::GraphObject->new($_, $self, $graphObjects{$type}); - if ($graphObject->{fillType}) { push(@fillObjects, $graphObject); } - else { push(@objects, $graphObject); } + if ($_->{fillType}) { push(@fillObjects, $_); } + else { push(@objects, $_); } } my @object_scores = (0) x @objects; ENTRY: for my $student_object (@student_objects) { for (0 .. $#objects) { - if ($objects[$_]->cmp($student_object->{object})) { + if ($objects[$_]->cmp($student_object)) { ++$object_scores[$_]; next ENTRY; } @@ -969,20 +1087,11 @@ sub cmp { # Now check the fills if all of the objects were correctly graphed. if ($object_score == @object_scores && $object_score == @student_objects) { - # Add the fill comparison methods for the static graph objects. - for (@{ $self->SUPER::new($self->{context}, @{ $self->{staticObjects} })->{data} }) { - my $type = $_->{data}[0]; - next unless $graphObjects{$type}; - my $graphObject = GraphTool::GraphObject->new($_, $self, $graphObjects{$type}); - next if $graphObject->{fillType}; - push(@objects, $graphObject); - } - @fill_scores = (0) x @fillObjects; ENTRY: for my $student_index (0 .. $#student_fills) { for (0 .. $#fillObjects) { - if ($fillObjects[$_]->cmp($student_fills[$student_index]->{object}, \@objects)) { + if ($fillObjects[$_]->cmp($student_fills[$student_index])) { ++$fill_scores[$_]; next ENTRY; } @@ -990,10 +1099,10 @@ sub cmp { # Skip incorrect fills in the same region as another incorrect fill. for (@incorrect_fills) { - next ENTRY if $_->cmp($student_fills[$student_index]->{object}, \@objects); + next ENTRY if $_->cmp($student_fills[$student_index]); } - # Cache comparison methods for incorrect fills. + # Cache incorrect fill objects. push(@incorrect_fills, $student_fills[$student_index]); } @@ -1030,8 +1139,8 @@ sub generateHTMLAnswerGraph { my $idSuffix = $options{idSuffix} // "ans_graphbox_$self->{graphCount}"; my $cssClass = $options{cssClass} // 'graphtool-solution-container'; my $ariaDescription = $options{ariaDescription} // 'graph of solution'; - my $answerObjects = $options{showCorrect} ? join(',', @{ $self->{data} }) : ''; - $answerObjects = join(',', $options{objects}, $answerObjects) if defined $options{objects}; + my $answerObjects = $options{showCorrect} ? join(',', $self->value) : ''; + $answerObjects = join(',', $options{objects}, $answerObjects || ()) if defined $options{objects}; my $ans_name = $self->ANS_NAME; $self->constructJSXGraphOptions; @@ -1057,7 +1166,7 @@ sub generateHTMLAnswerGraph { (() => { const initialize = () => { graphTool('${ans_name}_$idSuffix', { - staticObjects: '${\(join(',', @{$self->{staticObjects}}))}', + staticObjects: '${\(join(',', $self->{staticObjects}->value))}', answerObjects: '$answerObjects', isStatic: true, snapSizeX: $self->{snapSizeX}, @@ -1067,7 +1176,7 @@ sub generateHTMLAnswerGraph { numberLine: $self->{numberLine}, useBracketEnds: $self->{useBracketEnds}, useFloodFill: $self->{useFloodFill}, - customGraphObjects: [$customGraphObjects], + customGraphObjects: [ $customGraphObjects ], JSXGraphOptions: $self->{JSXGraphOptions}, ariaDescription: '$ariaDescription' }); @@ -1191,12 +1300,11 @@ sub generateTeXGraph { # be affected (clipped) by the correct answer objects. Note that the @object_data containing the clipping code is # cumulative. This is because the correct answer fills should be clipped by the static graph objects. my (@object_group, @objects); - push(@object_group, $self->{staticObjects}) if @{ $self->{staticObjects} }; - push(@object_group, [ map {"$_"} @{ $self->{data} } ]) if $options{showCorrect} && @{ $self->{data} }; + push(@object_group, $self->{staticObjects}) if $self->{staticObjects}; + push(@object_group, $self) if $options{showCorrect}; - for my $fill_group (@object_group) { + for my $obj (@object_group) { # Graph the points, lines, circles, and parabolas in this group. - my $obj = $self->SUPER::new($self->{context}, @$fill_group); # Switch to the foreground layer and clipping box for the objects. $tikz .= "\\begin{pgfonlayer}{foreground}\n"; @@ -1207,15 +1315,14 @@ sub generateTeXGraph { # First graph lines, parabolas, and circles. Cache the clipping path and a function # for determining which side of the object to shade for filling later. - for (@{ $obj->{data} }) { - next unless $graphObjects{ $_->{data}[0] }; - my $graphObject = GraphTool::GraphObject->new($_, $self, $graphObjects{ $_->{data}[0] }); - if ($graphObject->{fillType}) { - push(@fills, $graphObject); + for ($obj->value) { + next unless Value::classMatch($_, 'GraphObject'); + if ($_->{fillType}) { + push(@fills, $_); next; } - $tikz .= $graphObject->tikz; - push(@objects, $graphObject); + $tikz .= $_->tikz; + push(@objects, $_); } # Switch from the foreground layer to the background layer for the fills. @@ -1246,76 +1353,170 @@ sub generateAnswerGraph { } package GraphTool::GraphObject; +our @ISA = qw(Value::List); +# It is important that the parser::GraphTool object saved in the context flags is not accessed directly in the new +# method for any package that derives from the GraphTool::GraphObject package. The objects for correct answers are +# constructed when the parser::GraphTool "create" method is called, and at that time only the default GraphTool options +# are available. If the constructor uses one of those options (for example many of the objects use the bBox option) and +# that option is later changed when calling the "with" method, then the computations in the constructor will be +# incorrect and not updated. sub new { - my ($invocant, $object, $gt, $definition) = @_; - return $definition->new($object, $gt) if (defined $definition && ref($definition) ne 'HASH'); + my ($invocant, @arguments) = @_; + my $context = Value::isContext($arguments[0]) ? shift @arguments : $invocant->context; + my ($object, $definition) = @arguments; - my $self = bless { object => $object, gt => $gt }, ref($invocant) || $invocant; + return $definition->new($context, $object) if (defined $definition && ref($definition) ne 'HASH'); + + my $self = $invocant->SUPER::new($context, $object); if (ref($definition) eq 'HASH') { - ($self->{pointCmp}, $self->{cmp}) = $definition->{cmp}->($object, $gt) if $definition->{cmp}; - ($self->{tikz}, my $fillData) = $definition->{tikz}{code}->($gt, $object) - if $definition->{tikz} && ref($definition->{tikz}{code}) eq 'CODE'; - ($self->{clipCode}, $self->{fillCmp}, $self->{onBoundary}) = @$fillData; + $self->{compatibility} = $definition; + $self->{fillType} = 1 if $self->{compatibility}{tikz}{fillType}; } return $self; } -sub pointCmp { my ($self, $point) = @_; return ref($self->{pointCmp}) eq 'CODE' ? $self->{pointCmp}->($point) : 1; } -sub cmp { my ($self, $other, $fuzzy) = @_; return ref($self->{cmp}) eq 'CODE' ? $self->{cmp}->($other, $fuzzy) : 1; } -sub tikz { my $self = shift; return $self->{tikz} // ''; } -sub fillCmp { my ($self, $x, $y) = @_; return ref($self->{fillCmp}) eq 'CODE' ? $self->{fillCmp}->($x, $y) : 1; } +sub class {'GraphObject'} + +# This should return 0 if the $point is satisfies the defining equation of the object or is on an edge of the object. +# Otherwise it should return a nonzero number indicating a side or region of the object that the point is in. +sub pointCmp { + my ($self, $point) = @_; + $self->compatibility; + return ref($self->{pointCmp}) eq 'CODE' ? $self->{pointCmp}->($point) : 1; +} + +# If $fuzzy is false, then this should return true (or 1) if the $other object is visually the same as this object, and +# false (or 0) otherwise. If $fuzzy is true, then this should return true if the $other object is visually the same as +# the object ignoring if one object is solid and the other is dashed, and zero otherwise. +sub cmp { + my ($self, $other, $fuzzy) = @_; + $self->compatibility; + return ref($self->{cmp}) eq 'CODE' ? $self->{cmp}->($other, $fuzzy) : 1; +} + +# This makes the == operator work for GraphTool::GraphObjects. It should usually not be overridden. Instead override +# the cmp method above. +sub compare { my ($self, @args) = @_; return !$self->cmp(@args); } +# The TikZ code to draw the object. +sub tikz { my $self = shift; $self->compatibility; return $self->{tikz} // ''; } + +# The TikZ clipping path for the object (used by the inequality fill method) with out the \clip command and its options. +sub clipCode { my $self = shift; $self->compatibility; return $self->{clipCode} // ''; } + +# The TikZ clipping path for the object (used by the inequality fill method) with the \clip command and options. Most +# objects only override the clipCode method and let this method add in the default \clip command and inverse clip option +# based on the fillCmp return value. +sub clip { + my ($self, $fx, $fy) = @_; + $self->compatibility; + return $self->{clipCode}->($self->{fx}, $self->{fy}) if ref($self->{clipCode}) eq 'CODE'; + my $clip_dir = $self->fillCmp($fx, $fy); + return if $clip_dir == 0; + return "\\clip " . ($clip_dir < 0 ? '[inverse clip]' : '') . $_->clipCode . ";\n"; +} + +# This method should return discrete values that represent which region the point ($x, $y) is in of the regions the +# object breaks the plane into. The same value must be returned for all points in the same region. This method should +# return 0 for all points on a border of the object. +sub fillCmp { + my ($self, $x, $y) = @_; + $self->compatibility; + return ref($self->{fillCmp}) eq 'CODE' ? $self->{fillCmp}->($x, $y) : 1; +} + +# This is only used by the flood fill algorithm, and should return 1 if $point is on the border of an object, and 0 +# otherwise. This only needs to be overridden if the flood fill algorithm could potentially bleed across a boundary from +# one region to another (as determined by the return value of the fillCmp method) or the flood fill algorithm could +# potentially go around an end of the object. sub onBoundary { my ($self, $point, $aVal, $from) = @_; + $self->compatibility; return ref($self->{onBoundary}) eq 'CODE' ? $self->{onBoundary}->($point, $aVal, $from) : $self->fillCmp(@$point) != $aVal; } +# This method provides backward compatibility for objects defined the old way not deriving from the +# GraphTool::GraphObject package. The old methods must be called after construction because they may perform +# computations using options that are not set to their final values at that time. This is only called once and the +# results cached for later use. +sub compatibility { + my $self = shift; + return unless $self->{compatibility}; + my $graphToolObject = $self->context->flags->get('graphToolObject'); + ($self->{pointCmp}, $self->{cmp}) = $self->{compatibility}{cmp}->($self, $graphToolObject) + if ref($self->{compatibility}{cmp}) eq 'CODE'; + if (ref($self->{compatibility}{tikz}{code}) eq 'CODE') { + # Make sure that $self is set as $_ because previously there + # was an assumption that the object would be passed as $_. + for ($self) { + ($self->{tikz}, my $fillData) = $self->{compatibility}{tikz}{code}->($graphToolObject, $self); + ($self->{clipCode}, $self->{fillCmp}, $self->{onBoundary}) = @$fillData if ref($fillData) eq 'ARRAY'; + } + } + delete $self->{compatibility}; +} + +package GraphTool::GraphObject::Point; +our @ISA = qw(GraphTool::GraphObject); + +sub new { + my ($invocant, @arguments) = @_; + my $context = Value::isContext($arguments[0]) ? shift @arguments : $invocant->context; + my $self = $invocant->SUPER::new($context, @arguments); + + $self->{point} = $self->{data}[1]; + ($self->{x}, $self->{y}) = map { $_->value } @{ $self->{point}{data} }; + $self->{clipCode} = ''; + + return $self; +} + +sub pointCmp { + my ($self, $point) = @_; + return $self->{point} == $point ? 0 : 1; +} + +sub cmp { + my ($self, $other, $fuzzy) = @_; + return $other->{data}[0] eq 'point' && $self->{point} == $other->{data}[1]; +} + +sub tikz { + my $self = shift; + return "\\draw[line width = 4pt, blue, fill = red] ($self->{x}, $self->{y}) circle[radius = 5pt];\n"; +} + package GraphTool::GraphObject::Line; our @ISA = qw(GraphTool::GraphObject); sub new { - my ($invocant, $object, $gt) = @_; - my $self = $invocant->SUPER::new($object, $gt); + my ($invocant, @arguments) = @_; + my $context = Value::isContext($arguments[0]) ? shift @arguments : $invocant->context; + my $self = $invocant->SUPER::new($context, @arguments); - $self->{solid_dashed} = $object->{data}[1]; - ($self->{x1}, $self->{y1}) = $object->{data}[2]->value; - ($self->{x2}, $self->{y2}) = $object->{data}[3]->value; + $self->{solid_dashed} = $self->{data}[1]; + ($self->{x1}, $self->{y1}) = $self->{data}[2]->value; + ($self->{x2}, $self->{y2}) = $self->{data}[3]->value; $self->{isVertical} = $self->{x1}->value == $self->{x2}->value; $self->{stdform} = [ $self->{y1} - $self->{y2}, $self->{x2} - $self->{x1}, $self->{x1} * $self->{y2} - $self->{x2} * $self->{y1} ]; - return $self unless defined $gt; - $self->{normalLength} = sqrt(($self->{stdform}[0]->value)**2 + ($self->{stdform}[1]->value)**2); $self->{drawAttributes} = ''; - if ($self->{isVertical}) { - $self->{tikzCode} = "($self->{x1},$gt->{bBox}[3]) -- ($self->{x1},$gt->{bBox}[1])"; - } else { + if (!$self->{isVertical}) { my $m = ($self->{y2}->value - $self->{y1}->value) / ($self->{x2}->value - $self->{x1}->value); my ($x1, $y1) = ($self->{x1}->value, $self->{y1}->value); $self->{y} = sub { return $m * ($_[0] - $x1) + $y1; }; - $self->{tikzCode} = - "($gt->{bBox}[0]," - . $self->{y}->($gt->{bBox}[0]) . ') -- ' - . "($gt->{bBox}[2]," - . $self->{y}->($gt->{bBox}[2]) . ')'; } - $self->{clipCode} = - "$self->{tikzCode} -- ($self->{gt}{bBox}[2], $self->{gt}{bBox}[1]) -- " - . ($self->{isVertical} - ? "($self->{gt}{bBox}[2], $self->{gt}{bBox}[3])" - : "($self->{gt}{bBox}[0], $self->{gt}{bBox}[1])") - . " -- cycle"; - return $self; } @@ -1334,9 +1535,30 @@ sub cmp { && $self->pointCmp($other->{data}[3]) == 0; } +sub tikzCode { + my $self = shift; + my $bBox = $self->context->flags->get('graphToolObject')->{bBox}; + if ($self->{isVertical}) { + return "($self->{x1}, $bBox->[3]) -- ($self->{x1}, $bBox->[1])"; + } else { + return "($bBox->[0]," . $self->{y}->($bBox->[0]) . ') -- ' . "($bBox->[2]," . $self->{y}->($bBox->[2]) . ')'; + } +} + sub tikz { my $self = shift; - return "\\draw[thick, blue, line width = 2.5pt, $self->{solid_dashed}$self->{drawAttributes}] $self->{tikzCode};\n"; + return + "\\draw[thick, blue, line width = 2.5pt, $self->{solid_dashed}$self->{drawAttributes}] " + . $self->tikzCode . ";\n"; +} + +sub clipCode { + my $self = shift; + my $bBox = $self->context->flags->get('graphToolObject')->{bBox}; + $self->tikzCode + . " -- ($bBox->[2], $bBox->[1]) -- " + . ($self->{isVertical} ? "($bBox->[2], $bBox->[3])" : "($bBox->[0], $bBox->[1])") + . ' -- cycle'; } sub fillCmp { @@ -1350,21 +1572,19 @@ package GraphTool::GraphObject::Circle; our @ISA = qw(GraphTool::GraphObject); sub new { - my ($invocant, $object, $gt) = @_; - my $self = $invocant->SUPER::new($object, $gt); + my ($invocant, @arguments) = @_; + my $context = Value::isContext($arguments[0]) ? shift @arguments : $invocant->context; + my $self = $invocant->SUPER::new($context, @arguments); - $self->{solid_dashed} = $object->{data}[1]; - $self->{center} = $object->{data}[2]; + $self->{solid_dashed} = $self->{data}[1]; + $self->{center} = $self->{data}[2]; ($self->{cx}, $self->{cy}) = $self->{center}->value; - ($self->{px}, $self->{py}) = $object->{data}[3]->value; + ($self->{px}, $self->{py}) = $self->{data}[3]->value; $self->{r_squared} = ($self->{cx} - $self->{px})**2 + ($self->{cy} - $self->{py})**2; - - return $self unless defined $gt; - - $self->{r} = sqrt($self->{r_squared}->value); - $self->{tikzCode} = "($self->{cx}, $self->{cy}) circle[radius = $self->{r}]"; - $self->{clipCode} = $self->{tikzCode}; + $self->{r} = sqrt($self->{r_squared}->value); + $self->{tikzCode} = "($self->{cx}, $self->{cy}) circle[radius = $self->{r}]"; + $self->{clipCode} = $self->{tikzCode}; return $self; } @@ -1398,48 +1618,35 @@ package GraphTool::GraphObject::Parabola; our @ISA = qw(GraphTool::GraphObject); sub new { - my ($invocant, $object, $gt) = @_; - my $self = $invocant->SUPER::new($object, $gt); + my ($invocant, @arguments) = @_; + my $context = Value::isContext($arguments[0]) ? shift @arguments : $invocant->context; + my $self = $invocant->SUPER::new($context, @arguments); - $self->{solid_dashed} = $object->{data}[1]; - $self->{vertical_horizontal} = $object->{data}[2]; - $self->{vertex} = $object->{data}[3]; + $self->{solid_dashed} = $self->{data}[1]; + $self->{vertical_horizontal} = $self->{data}[2]; + $self->{vertex} = $self->{data}[3]; ($self->{h}, $self->{k}) = $self->{vertex}->value; - ($self->{px}, $self->{py}) = $object->{data}[4]->value; - - $self->{x_pow} = $self->{vertical_horizontal} eq 'vertical' ? 2 : 1; - $self->{y_pow} = $self->{vertical_horizontal} eq 'vertical' ? 1 : 2; - - return $self unless defined $gt; + ($self->{px}, $self->{py}) = $self->{data}[4]->value; if ($self->{vertical_horizontal} eq 'vertical') { - $self->{a} = (($self->{py} - $self->{k}) / ($self->{px} - $self->{h})**2)->value; - my $diff = sqrt((($self->{a} >= 0 ? $gt->{bBox}[1] : $gt->{bBox}[3]) - $self->{k}->value) / $self->{a}); - my $dmin = $self->{h}->value - $diff; - my $dmax = $self->{h}->value + $diff; - $self->{tikzCode} = - "plot[domain = $dmin:$dmax, smooth] (\\x, {$self->{a} * (\\x - ($self->{h}))^2 + ($self->{k})})"; + $self->{a} = (($self->{py} - $self->{k}) / ($self->{px} - $self->{h})**2)->value; $self->{yFunction} = sub { return $self->{a} * ($_[0] - $self->{h}->value)**2 + $self->{k}->value; }; } else { - $self->{a} = (($self->{px} - $self->{h}) / ($self->{py} - $self->{k})**2)->value; - my $diff = sqrt((($self->{a} >= 0 ? $gt->{bBox}[2] : $gt->{bBox}[0]) - $self->{h}->value) / $self->{a}); - my $dmin = $self->{k}->value - $diff; - my $dmax = $self->{k}->value + $diff; - $self->{tikzCode} = - "plot[domain = $dmin:$dmax, smooth] ({$self->{a} * (\\x - ($self->{k}))^2 + ($self->{h})}, \\x)"; + $self->{a} = (($self->{px} - $self->{h}) / ($self->{py} - $self->{k})**2)->value; $self->{xFunction} = sub { return $self->{a} * ($_[0] - $self->{k}->value)**2 + $self->{h}->value; }; } - $self->{clipCode} = $self->{tikzCode}; - return $self; } sub pointCmp { my ($self, $point) = @_; my ($x, $y) = $point->value; - return ($self->{px} - $self->{h})**$self->{x_pow} * ($y - $self->{k})**$self->{y_pow} - <=> ($self->{py} - $self->{k})**$self->{y_pow} * ($x - $self->{h})**$self->{x_pow}; + my $x_pow = $self->{vertical_horizontal} eq 'vertical' ? 2 : 1; + my $y_pow = $self->{vertical_horizontal} eq 'vertical' ? 1 : 2; + return ($self->{px} - $self->{h})**$x_pow * + ($y - $self->{k})**$y_pow <=> ($self->{py} - $self->{k})**$y_pow * + ($x - $self->{h})**$x_pow; } sub cmp { @@ -1452,9 +1659,31 @@ sub cmp { && $self->pointCmp($other->{data}[4]) == 0; } +sub tikzCode { + my $self = shift; + my $bBox = $self->context->flags->get('graphToolObject')->{bBox}; + if ($self->{vertical_horizontal} eq 'vertical') { + my $diff = sqrt((($self->{a} >= 0 ? $bBox->[1] : $bBox->[3]) - $self->{k}->value) / $self->{a}); + my $dmin = $self->{h}->value - $diff; + my $dmax = $self->{h}->value + $diff; + return "plot[domain = $dmin:$dmax, smooth] (\\x, {$self->{a} * (\\x - ($self->{h}))^2 + ($self->{k})})"; + } else { + $self->{a} = (($self->{px} - $self->{h}) / ($self->{py} - $self->{k})**2)->value; + my $diff = sqrt((($self->{a} >= 0 ? $bBox->[2] : $bBox->[0]) - $self->{h}->value) / $self->{a}); + my $dmin = $self->{k}->value - $diff; + my $dmax = $self->{k}->value + $diff; + return "plot[domain = $dmin:$dmax, smooth] ({$self->{a} * (\\x - ($self->{k}))^2 + ($self->{h})}, \\x)"; + } +} + sub tikz { my $self = shift; - return "\\draw[thick, blue, line width = 2.5pt, $self->{solid_dashed}] $self->{tikzCode};\n"; + return "\\draw[thick, blue, line width = 2.5pt, $self->{solid_dashed}] " . $self->tikzCode . ";\n"; +} + +sub clipCode { + my $self = shift; + return $self->tikzCode; } sub fillCmp { @@ -1464,46 +1693,18 @@ sub fillCmp { : parser::GraphTool::sign($self->{a} * ($x - $self->{xFunction}->($y))); } -package GraphTool::GraphObject::Point; -our @ISA = qw(GraphTool::GraphObject); - -sub new { - my ($invocant, $object, $gt) = @_; - my $self = $invocant->SUPER::new($object, $gt); - - $self->{point} = $object->{data}[1]; - ($self->{x}, $self->{y}) = map { $_->value } @{ $self->{point}{data} }; - $self->{clipCode} = ''; - - return $self; -} - -sub pointCmp { - my ($self, $point) = @_; - return $self->{point} == $point ? 0 : 1; -} - -sub cmp { - my ($self, $other, $fuzzy) = @_; - return $other->{data}[0] eq 'point' && $self->{point} == $other->{data}[1]; -} - -sub tikz { - my $self = shift; - return "\\draw[line width = 4pt, blue, fill = red] ($self->{x}, $self->{y}) circle[radius = 5pt];\n"; -} - package GraphTool::GraphObject::Quadratic; our @ISA = qw(GraphTool::GraphObject); sub new { - my ($invocant, $object, $gt) = @_; - my $self = $invocant->SUPER::new($object, $gt); + my ($invocant, @arguments) = @_; + my $context = Value::isContext($arguments[0]) ? shift @arguments : $invocant->context; + my $self = $invocant->SUPER::new($context, @arguments); - $self->{solid_dashed} = $object->{data}[1]; - ($self->{x1}, $self->{y1}) = $object->{data}[2]->value; - ($self->{x2}, $self->{y2}) = $object->{data}[3]->value; - ($self->{x3}, $self->{y3}) = $object->{data}[4]->value; + $self->{solid_dashed} = $self->{data}[1]; + ($self->{x1}, $self->{y1}) = $self->{data}[2]->value; + ($self->{x2}, $self->{y2}) = $self->{data}[3]->value; + ($self->{x3}, $self->{y3}) = $self->{data}[4]->value; $self->{coeffs} = [ ($self->{x1} - $self->{x2}) * $self->{y3}, @@ -1512,41 +1713,25 @@ sub new { ]; $self->{den} = ($self->{x1} - $self->{x2}) * ($self->{x1} - $self->{x3}) * ($self->{x2} - $self->{x3}); - return $self unless defined $gt; - my ($x1, $y1) = ($self->{x1}->value, $self->{y1}->value); my ($x2, $y2) = ($self->{x2}->value, $self->{y2}->value); my ($x3, $y3) = ($self->{x3}->value, $self->{y3}->value); my $den = $self->{den}->value; - my $a = (($x2 - $x3) * $y1 + ($x3 - $x1) * $y2 + ($x1 - $x2) * $y3) / $den; + $self->{a} = (($x2 - $x3) * $y1 + ($x3 - $x1) * $y2 + ($x1 - $x2) * $y3) / $den; - if (abs($a) < 0.000001) { + $self->{isLine} = abs($self->{a}) < 0.000001; + + if ($self->{isLine}) { # Colinear points $self->{a} = 1; $self->{function} = sub { return ($y2 - $y1) / ($x2 - $x1) * ($_[0] - $x1) + $y1; }; - $self->{tikzCode} = - "($gt->{bBox}[0]," - . $self->{function}->($gt->{bBox}[0]) - . ") -- ($gt->{bBox}[2]," - . $self->{function}->($gt->{bBox}[2]) . ")"; - - $self->{clipCode} = - "$self->{tikzCode} -- ($gt->{bBox}[2], $gt->{bBox}[1]) -- ($gt->{bBox}[0], $gt->{bBox}[1]) -- cycle"; } else { # Non-degenerate quadratic - my $b = (($x3**2 - $x2**2) * $y1 + ($x1**2 - $x3**2) * $y2 + ($x2**2 - $x1**2) * $y3) / $den; - my $c = (($x2 - $x3) * $x2 * $x3 * $y1 + ($x3 - $x1) * $x1 * $x3 * $y2 + ($x1 - $x2) * $x1 * $x2 * $y3) / $den; - my $h = -$b / (2 * $a); - my $k = $c - $b**2 / (4 * $a); - my $diff = sqrt((($a >= 0 ? $gt->{bBox}[1] : $gt->{bBox}[3]) - $k) / $a); - my $dmin = $h - $diff; - my $dmax = $h + $diff; - - $self->{a} = $a; - $self->{function} = sub { return $self->{a} * $_[0]**2 + $b * $_[0] + $c; }; - $self->{tikzCode} = "plot[domain = $dmin:$dmax, smooth] (\\x, {$a * (\\x)^2 + ($b) * \\x + ($c)})"; - $self->{clipCode} = $self->{tikzCode}; + $self->{b} = (($x3**2 - $x2**2) * $y1 + ($x1**2 - $x3**2) * $y2 + ($x2**2 - $x1**2) * $y3) / $den; + $self->{c} = + (($x2 - $x3) * $x2 * $x3 * $y1 + ($x3 - $x1) * $x1 * $x3 * $y2 + ($x1 - $x2) * $x1 * $x2 * $y3) / $den; + $self->{function} = sub { return $self->{a} * $_[0]**2 + $self->{b} * $_[0] + $self->{c}; }; } return $self; @@ -1570,9 +1755,36 @@ sub cmp { && $self->pointCmp($other->{data}[4]) == 0; } +sub tikzCode { + my $self = shift; + my $bBox = $self->context->flags->get('graphToolObject')->{bBox}; + if ($self->{isLine}) { + return + "($bBox->[0]," + . $self->{function}->($bBox->[0]) + . ") -- ($bBox->[2]," + . $self->{function}->($bBox->[2]) . ")"; + } else { + my $h = -$self->{b} / (2 * $self->{a}); + my $k = $self->{c} - $self->{b}**2 / (4 * $self->{a}); + my $diff = sqrt((($self->{a} >= 0 ? $bBox->[1] : $bBox->[3]) - $k) / $self->{a}); + my $dmin = $h - $diff; + my $dmax = $h + $diff; + return "plot[domain = $dmin:$dmax, smooth] (\\x, {$self->{a} * (\\x)^2 + ($self->{b}) * \\x + ($self->{c})})"; + } +} + sub tikz { my $self = shift; - return "\\draw[thick, blue, line width = 2.5pt, $self->{solid_dashed}] $self->{tikzCode};\n"; + return "\\draw[thick, blue, line width = 2.5pt, $self->{solid_dashed}] " . $self->tikzCode . ";\n"; +} + +sub clipCode { + my $self = shift; + my $bBox = $self->context->flags->get('graphToolObject')->{bBox}; + return $self->{isLine} + ? $self->tikzCode . " -- ($bBox->[2], $bBox->[1]) -- ($bBox->[0], $bBox->[1]) -- cycle" + : $self->tikzCode; } sub fillCmp { @@ -1584,14 +1796,15 @@ package GraphTool::GraphObject::Cubic; our @ISA = qw(GraphTool::GraphObject); sub new { - my ($invocant, $object, $gt) = @_; - my $self = $invocant->SUPER::new($object, $gt); + my ($invocant, @arguments) = @_; + my $context = Value::isContext($arguments[0]) ? shift @arguments : $invocant->context; + my $self = $invocant->SUPER::new($context, @arguments); - $self->{solid_dashed} = $object->{data}[1]; - ($self->{x1}, $self->{y1}) = $object->{data}[2]->value; - ($self->{x2}, $self->{y2}) = $object->{data}[3]->value; - ($self->{x3}, $self->{y3}) = $object->{data}[4]->value; - ($self->{x4}, $self->{y4}) = $object->{data}[5]->value; + $self->{solid_dashed} = $self->{data}[1]; + ($self->{x1}, $self->{y1}) = $self->{data}[2]->value; + ($self->{x2}, $self->{y2}) = $self->{data}[3]->value; + ($self->{x3}, $self->{y3}) = $self->{data}[4]->value; + ($self->{x4}, $self->{y4}) = $self->{data}[5]->value; $self->{coeffs} = [ ($self->{x1} - $self->{x2}) * ($self->{x1} - $self->{x3}) * ($self->{x2} - $self->{x3}) * $self->{y4}, @@ -1607,8 +1820,6 @@ sub new { ($self->{x2} - $self->{x4}) * ($self->{x3} - $self->{x4}); - return $self unless defined $gt; - my ($x1, $y1) = ($self->{x1}->value, $self->{y1}->value); my ($x2, $y2) = ($self->{x2}->value, $self->{y2}->value); my ($x3, $y3) = ($self->{x3}->value, $self->{y3}->value); @@ -1625,34 +1836,21 @@ sub new { (-$x1 - $x2 - $x4) * $y3 / (($x3 - $x1) * ($x3 - $x2) * ($x3 - $x4)) + (-$x1 - $x2 - $x3) * $y4 / (($x4 - $x1) * ($x4 - $x2) * ($x4 - $x3))); - if (abs($self->{c3}) < 0.000001 && abs($c2) < 0.000001) { + $self->{degree} = abs($self->{c3}) < 0.000001 && abs($c2) < 0.000001 ? 1 : abs($self->{c3}) < 0.000001 ? 2 : 3; + + if ($self->{degree} == 1) { # Colinear points $self->{c3} = 1; $self->{function} = sub { return ($y2 - $y1) / ($x2 - $x1) * ($_[0] - $x1) + $y1; }; - $self->{tikzCode} = - "($gt->{bBox}[0]," - . $self->{function}->($gt->{bBox}[0]) - . ") -- ($gt->{bBox}[2]," - . $self->{function}->($gt->{bBox}[2]) . ")"; - $self->{clipCode} = - "$self->{tikzCode} -- ($gt->{bBox}[2], $gt->{bBox}[1]) -- ($gt->{bBox}[0], $gt->{bBox}[1]) -- cycle"; - } elsif (abs($self->{c3}) < 0.000001) { + } elsif ($self->{degree} == 2) { # Quadratic my $den = ($x1 - $x2) * ($x1 - $x3) * ($x2 - $x3); - my $a = (($x2 - $x3) * $y1 + ($x3 - $x1) * $y2 + ($x1 - $x2) * $y3) / $den; - my $b = (($x3**2 - $x2**2) * $y1 + ($x1**2 - $x3**2) * $y2 + ($x2**2 - $x1**2) * $y3) / $den; - my $c = (($x2 - $x3) * $x2 * $x3 * $y1 + ($x3 - $x1) * $x1 * $x3 * $y2 + ($x1 - $x2) * $x1 * $x2 * $y3) / $den; - my $h = -$b / (2 * $a); - my $k = $c - $b**2 / (4 * $a); - my $diff = sqrt((($a >= 0 ? $gt->{bBox}[1] : $gt->{bBox}[3]) - $k) / $a); - my $dmin = $h - $diff; - my $dmax = $h + $diff; - - $self->{tikzCode} = "plot[domain = $dmin:$dmax, smooth] (\\x, {$a * (\\x)^2 + ($b) * \\x + ($c)})"; - $self->{clipCode} = $self->{tikzCode}; - - $self->{c3} = $a; - $self->{function} = sub { return $a * $_[0]**2 + $b * $_[0] + $c; }; + $self->{a} = (($x2 - $x3) * $y1 + ($x3 - $x1) * $y2 + ($x1 - $x2) * $y3) / $den; + $self->{c3} = $self->{a}; + $self->{b} = (($x3**2 - $x2**2) * $y1 + ($x1**2 - $x3**2) * $y2 + ($x2**2 - $x1**2) * $y3) / $den; + $self->{c} = + (($x2 - $x3) * $x2 * $x3 * $y1 + ($x3 - $x1) * $x1 * $x3 * $y2 + ($x1 - $x2) * $x1 * $x2 * $y3) / $den; + $self->{function} = sub { return $self->{a} * $_[0]**2 + $self->{b} * $_[0] + $self->{c}; }; } else { # Non-degenerate cubic $self->{function} = sub { @@ -1662,29 +1860,6 @@ sub new { ($_[0] - $x1) * ($_[0] - $x2) * ($_[0] - $x4) * $y3 / (($x3 - $x1) * ($x3 - $x2) * ($x3 - $x4)) + ($_[0] - $x1) * ($_[0] - $x2) * ($_[0] - $x3) * $y4 / (($x4 - $x1) * ($x4 - $x2) * ($x4 - $x3))); }; - - my $height = $gt->{bBox}[1] - $gt->{bBox}[3]; - my $lowerBound = $gt->{bBox}[3] - $height; - my $upperBound = $gt->{bBox}[1] + $height; - my $step = ($gt->{bBox}[2] - $gt->{bBox}[0]) / 200; - my $x = $gt->{bBox}[0]; - - my $coords; - do { - my $y = $self->{function}->($x); - $coords .= "($x,$y) " if $y >= $lowerBound && $y <= $upperBound; - $x += $step; - } while ($x < $gt->{bBox}[2]); - - $self->{tikzCode} = "plot[smooth] coordinates { $coords }"; - $self->{clipCode} = $self->{tikzCode} - . ( - $self->{c3} > 0 - ? ("-- ($gt->{bBox}[2], $gt->{bBox}[1]) -- ($gt->{bBox}[0], $gt->{bBox}[1])" - . "-- ($gt->{bBox}[0], $gt->{bBox}[3]) -- cycle") - : ("-- ($gt->{bBox}[2], $gt->{bBox}[3]) -- ($gt->{bBox}[0], $gt->{bBox}[3])" - . "-- ($gt->{bBox}[0], $gt->{bBox}[1]) -- cycle") - ); } return $self; @@ -1710,9 +1885,57 @@ sub cmp { && $self->pointCmp($other->{data}[5]) == 0; } +sub tikzCode { + my $self = shift; + + my $bBox = $self->context->flags->get('graphToolObject')->{bBox}; + + if ($self->{degree} == 1) { + return + "($bBox->[0]," + . $self->{function}->($bBox->[0]) + . ") -- ($bBox->[2]," + . $self->{function}->($bBox->[2]) . ')'; + } elsif ($self->{degree} == 2) { + my $h = -$self->{b} / (2 * $self->{a}); + my $k = $self->{c} - $self->{b}**2 / (4 * $self->{a}); + my $diff = sqrt((($self->{a} >= 0 ? $bBox->[1] : $bBox->[3]) - $k) / $self->{a}); + my $dmin = $h - $diff; + my $dmax = $h + $diff; + return "plot[domain = $dmin:$dmax, smooth] (\\x, {$self->{a} * (\\x)^2 + ($self->{b}) * \\x + ($self->{c})})"; + } else { + my $height = $bBox->[1] - $bBox->[3]; + my $lowerBound = $bBox->[3] - $height; + my $upperBound = $bBox->[1] + $height; + my $step = ($bBox->[2] - $bBox->[0]) / 200; + my $x = $bBox->[0]; + + my $coords; + do { + my $y = $self->{function}->($x); + $coords .= "($x,$y) " if $y >= $lowerBound && $y <= $upperBound; + $x += $step; + } while ($x < $bBox->[2]); + + return "plot[smooth] coordinates { $coords }"; + } +} + sub tikz { my $self = shift; - return "\\draw[thick, blue, line width = 2.5pt, $self->{solid_dashed}] $self->{tikzCode};\n"; + return "\\draw[thick, blue, line width = 2.5pt, $self->{solid_dashed}] " . $self->tikzCode . ";\n"; +} + +sub clipCode { + my $self = shift; + my $bBox = $self->context->flags->get('graphToolObject')->{bBox}; + return + $self->{degree} == 1 ? $self->tikzCode . " -- ($bBox->[2], $bBox->[1]) -- ($bBox->[0], $bBox->[1]) -- cycle" + : $self->{degree} == 2 ? $self->tikzCode + : $self->tikzCode + . ($self->{c3} > 0 + ? ("-- ($bBox->[2], $bBox->[1]) -- ($bBox->[0], $bBox->[1])" . "-- ($bBox->[0], $bBox->[3]) -- cycle") + : ("-- ($bBox->[2], $bBox->[3]) -- ($bBox->[0], $bBox->[3])" . "-- ($bBox->[0], $bBox->[1]) -- cycle")); } sub fillCmp { @@ -1724,40 +1947,12 @@ package GraphTool::GraphObject::Interval; our @ISA = qw(GraphTool::GraphObject); sub new { - my ($invocant, $object, $gt) = @_; - my $self = $invocant->SUPER::new($object, $gt); - - $self->{interval} = $object->{data}[1]; - - return $self unless defined $gt; - - my ($start, $end) = map { $_->value } @{ $self->{interval}{data} }; - - my $openEnd = - $gt->{useBracketEnds} - ? '{ Parenthesis[round, width = 28pt, line width = 3pt, length = 14pt] }' - : '{ Circle[scale = 1.1, open] }'; - my $closedEnd = - $gt->{useBracketEnds} ? '{ Bracket[width = 24pt,line width = 3pt, length = 8pt] }' : '{ Circle[scale = 1.1] }'; - - my $open = - $start eq '-infinity' ? '{ Stealth[scale = 1.1] }' : $object->{data}[1]{open} eq '[' ? $closedEnd : $openEnd; - my $close = - $end eq 'infinity' ? '{ Stealth[scale = 1.1] }' : $object->{data}[1]{close} eq ']' ? $closedEnd : $openEnd; - - $start = $gt->{bBox}[0] if $start eq '-infinity'; - $end = $gt->{bBox}[2] if $end eq 'infinity'; - - # This centers an open/close dot or a parenthesis or bracket on the tick. - # TikZ by default puts the end with its outer edge at the tick. - my $shortenLeft = - $open =~ /Circle/ ? ', shorten < = -8.25pt' : $open =~ /Parenthesis|Bracket/ ? ', shorten < = -1.5pt' : ''; - my $shortenRight = - $close =~ /Circle/ ? ', shorten > = -8.25pt' : $open =~ /Parenthesis|Bracket/ ? ', shorten > = -1.5pt' : ''; + my ($invocant, @arguments) = @_; + my $context = Value::isContext($arguments[0]) ? shift @arguments : $invocant->context; + my $self = $invocant->SUPER::new($context, @arguments); - $self->{tikzCode} = "($start, 0) -- ($end, 0)"; - $self->{drawAttributes} = ", $open-$close$shortenLeft$shortenRight"; - $self->{clipCode} = ''; + $self->{interval} = $self->{data}[1]; + $self->{clipCode} = ''; return $self; } @@ -1779,26 +1974,54 @@ sub cmp { sub tikz { my $self = shift; - return "\\draw[thick, blue, line width = 4pt$self->{drawAttributes}] $self->{tikzCode};\n"; + + my ($start, $end) = map { $_->value } @{ $self->{interval}{data} }; + + my $useBracketEnds = $self->context->flags->get('graphToolObject')->{useBracketEnds}; + + my $openEnd = + $useBracketEnds + ? '{ Parenthesis[round, width = 28pt, line width = 3pt, length = 14pt] }' + : '{ Circle[scale = 1.1, open] }'; + my $closedEnd = + $useBracketEnds ? '{ Bracket[width = 24pt,line width = 3pt, length = 8pt] }' : '{ Circle[scale = 1.1] }'; + + my $open = + $start eq '-infinity' ? '{ Stealth[scale = 1.1] }' : $self->{interval}{open} eq '[' ? $closedEnd : $openEnd; + my $close = + $end eq 'infinity' ? '{ Stealth[scale = 1.1] }' : $self->{interval}{close} eq ']' ? $closedEnd : $openEnd; + + my $bBox = $self->context->flags->get('graphToolObject')->{bBox}; + + $start = $bBox->[0] if $start eq '-infinity'; + $end = $bBox->[2] if $end eq 'infinity'; + + # This centers an open/close dot or a parenthesis or bracket on the tick. + # TikZ by default puts the end with its outer edge at the tick. + my $shortenLeft = + $open =~ /Circle/ ? ', shorten < = -8.25pt' : $open =~ /Parenthesis|Bracket/ ? ', shorten < = -1.5pt' : ''; + my $shortenRight = + $close =~ /Circle/ ? ', shorten > = -8.25pt' : $open =~ /Parenthesis|Bracket/ ? ', shorten > = -1.5pt' : ''; + + return "\\draw[thick, blue, line width = 4pt, $open-$close$shortenLeft$shortenRight] ($start, 0) -- ($end, 0);\n"; } package GraphTool::GraphObject::SineWave; our @ISA = qw(GraphTool::GraphObject); sub new { - my ($invocant, $object, $gt) = @_; - my $self = $invocant->SUPER::new($object, $gt); + my ($invocant, @arguments) = @_; + my $context = Value::isContext($arguments[0]) ? shift @arguments : $invocant->context; + my $self = $invocant->SUPER::new($context, @arguments); - $self->{solid_dashed} = $object->{data}[1]; - ($self->{phase}, $self->{yshift}) = map { $_->value } $object->{data}[2]->value; - $self->{period} = $object->{data}[3]->value; - $self->{amplitude} = $object->{data}[4]->value; + $self->{solid_dashed} = $self->{data}[1]; + ($self->{phase}, $self->{yshift}) = map { $_->value } $self->{data}[2]->value; + $self->{period} = $self->{data}[3]->value; + $self->{amplitude} = $self->{data}[4]->value; $self->{sinFormula} = main::Formula("$self->{amplitude} sin(2 * pi / abs($self->{period}) (x - $self->{phase})) + $self->{yshift}"); - return $self unless defined $gt; - my $pi = main::pi->value; $self->{function} = sub { @@ -1806,23 +2029,6 @@ sub new { $self->{yshift}; }; - my $height = $gt->{bBox}[1] - $gt->{bBox}[3]; - my $lowerBound = $gt->{bBox}[3] - $height; - my $upperBound = $gt->{bBox}[1] + $height; - my $step = ($gt->{bBox}[2] - $gt->{bBox}[0]) / 200; - my $x = $gt->{bBox}[0]; - - my $coords; - do { - my $y = $self->{function}->($x); - $coords .= "($x,$y) " if $y >= $lowerBound && $y <= $upperBound; - $x += $step; - } while $x < $gt->{bBox}[2]; - - $self->{tikzCode} = "plot[smooth] coordinates { $coords }"; - $self->{clipCode} = - $self->{tikzCode} . "-- ($gt->{bBox}[2],$gt->{bBox}[1]) -- ($gt->{bBox}[0],$gt->{bBox}[1]) -- cycle"; - return $self; } @@ -1844,9 +2050,36 @@ sub cmp { return ($fuzzy || $other->{data}[1] eq $self->{solid_dashed}) && $self->{sinFormula} == $otherSinFormula; } +sub tikzCode { + my $self = shift; + + my $bBox = $self->context->flags->get('graphToolObject')->{bBox}; + + my $height = $bBox->[1] - $bBox->[3]; + my $lowerBound = $bBox->[3] - $height; + my $upperBound = $bBox->[1] + $height; + my $step = ($bBox->[2] - $bBox->[0]) / 200; + my $x = $bBox->[0]; + + my $coords; + do { + my $y = $self->{function}->($x); + $coords .= "($x,$y) " if $y >= $lowerBound && $y <= $upperBound; + $x += $step; + } while $x < $bBox->[2]; + + return "plot[smooth] coordinates { $coords }"; +} + sub tikz { my $self = shift; - return "\\draw[thick, blue, line width = 2.5pt, $self->{solid_dashed}] $self->{tikzCode};\n"; + return "\\draw[thick, blue, line width = 2.5pt, $self->{solid_dashed}] " . $self->tikzCode . ";\n"; +} + +sub clipCode { + my $self = shift; + my $bBox = $self->context->flags->get('graphToolObject')->{bBox}; + return $self->tikzCode . "-- ($bBox->[2], $bBox->[1]) -- ($bBox->[0], $bBox->[1]) -- cycle"; } sub fillCmp { @@ -1858,11 +2091,12 @@ package GraphTool::GraphObject::Triangle; our @ISA = qw(GraphTool::GraphObject); sub new { - my ($invocant, $object, $gt) = @_; - my $self = $invocant->SUPER::new($object, $gt); + my ($invocant, @arguments) = @_; + my $context = Value::isContext($arguments[0]) ? shift @arguments : $invocant->context; + my $self = $invocant->SUPER::new($context, @arguments); - $self->{solid_dashed} = $object->{data}[1]; - $self->{vertices} = [ @{ $object->{data} }[ 2, 3, 4 ] ]; + $self->{solid_dashed} = $self->{data}[1]; + $self->{vertices} = [ @{ $self->{data} }[ 2, 3, 4 ] ]; $self->{points} = [ map { [ $_->{data}[0]->value, $_->{data}[1]->value ] } @{ $self->{vertices} } ]; @@ -1872,8 +2106,6 @@ sub new { $self->{denominator} = ($self->{y2} - $self->{y3}) * ($self->{x1} - $self->{x3}) + ($self->{x3} - $self->{x2}) * ($self->{y1} - $self->{y3}); - return $self unless defined $gt; - $self->{borderStdForms} = []; $self->{normalLengths} = []; for (0 .. $#{ $self->{points} }) { @@ -1929,17 +2161,20 @@ sub fillCmp { sub onBoundary { my ($self, $point, $aVal, $from) = @_; return 1 if $self->fillCmp(@$point) != $aVal; + + my $gt = $self->context->flags->get('graphToolObject'); + for (0 .. $#{ $self->{borderStdForms} }) { my @stdform = @{ $self->{borderStdForms}[$_] }; my ($x1, $y1) = @{ $self->{points}[$_] }; my ($x2, $y2) = @{ $self->{points}[ ($_ + 1) % 3 ] }; return 1 if (abs($point->[0] * $stdform[0] + $point->[1] * $stdform[1] + $stdform[2]) / $self->{normalLengths}[$_]) - < 0.5 / sqrt($self->{gt}{unitX} * $self->{gt}{unitY}) - && $point->[0] > main::min($x1, $x2) - 0.5 / $self->{gt}{unitX} - && $point->[0] < main::max($x1, $x2) + 0.5 / $self->{gt}{unitX} - && $point->[1] > main::min($y1, $y2) - 0.5 / $self->{gt}{unitY} - && $point->[1] < main::max($y1, $y2) + 0.5 / $self->{gt}{unitY}; + < 0.5 / sqrt($gt->{unitX} * $gt->{unitY}) + && $point->[0] > main::min($x1, $x2) - 0.5 / $gt->{unitX} + && $point->[0] < main::max($x1, $x2) + 0.5 / $gt->{unitX} + && $point->[1] > main::min($y1, $y2) - 0.5 / $gt->{unitY} + && $point->[1] < main::max($y1, $y2) + 0.5 / $gt->{unitY}; } return 0; } @@ -1948,11 +2183,12 @@ package GraphTool::GraphObject::Quadrilateral; our @ISA = qw(GraphTool::GraphObject); sub new { - my ($invocant, $object, $gt) = @_; - my $self = $invocant->SUPER::new($object, $gt); + my ($invocant, @arguments) = @_; + my $context = Value::isContext($arguments[0]) ? shift @arguments : $invocant->context; + my $self = $invocant->SUPER::new($context, @arguments); - $self->{solid_dashed} = $object->{data}[1]; - $self->{vertices} = [ @{ $object->{data} }[ 2 .. 5 ] ]; + $self->{solid_dashed} = $self->{data}[1]; + $self->{vertices} = [ @{ $self->{data} }[ 2 .. 5 ] ]; $self->{points} = [ map { [ $_->{data}[0]->value, $_->{data}[1]->value ] } @{ $self->{vertices} } ]; @@ -1965,18 +2201,17 @@ sub new { # Vertical line push(@{ $self->{borderCmps} }, sub { return parser::GraphTool::sign($_[0] - $x1) }); - unless (defined $gt) { - push( - @{ $self->{borderClipCode} }, - sub { - return - "\\clip" - . ($_[0] < $x1 ? '[inverse clip]' : '') - . "($x1, $gt->{bBox}[3]) -- ($x1, $gt->{bBox}[1]) -- " - . "($gt->{bBox}[2], $gt->{bBox}[1]) -- ($gt->{bBox}[2], $gt->{bBox}[3]) -- cycle;\n"; - } - ); - } + push( + @{ $self->{borderClipCode} }, + sub { + my $bBox = $self->context->flags->get('graphToolObject')->{bBox}; + return + "\\clip" + . ($_[0] < $x1 ? '[inverse clip]' : '') + . "($x1, $bBox->[3]) -- ($x1, $bBox->[1]) -- " + . "($bBox->[2], $bBox->[1]) -- ($bBox->[2], $bBox->[3]) -- cycle;\n"; + } + ); } else { # Non-vertical line my $m = ($y2 - $y1) / ($x2 - $x1); @@ -1984,21 +2219,20 @@ sub new { push(@{ $self->{borderCmps} }, sub { return parser::GraphTool::sign($_[1] - $eqn->($_[0])); }); - unless (defined $gt) { - push( - @{ $self->{borderClipCode} }, - sub { - return - "\\clip" - . ($_[1] < $eqn->($_[0]) ? '[inverse clip]' : '') - . "($gt->{bBox}[0]," - . $eqn->($gt->{bBox}[0]) . ') -- ' - . "($gt->{bBox}[2]," - . $eqn->($gt->{bBox}[2]) . ') -- ' - . "($gt->{bBox}[2],$gt->{bBox}[1]) -- ($gt->{bBox}[0],$gt->{bBox}[1]) -- cycle;\n"; - } - ); - } + push( + @{ $self->{borderClipCode} }, + sub { + my $bBox = $self->context->flags->get('graphToolObject')->{bBox}; + return + "\\clip" + . ($_[1] < $eqn->($_[0]) ? '[inverse clip]' : '') + . "($bBox->[0]," + . $eqn->($bBox->[0]) . ') -- ' + . "($bBox->[2]," + . $eqn->($bBox->[2]) . ') -- ' + . "($bBox->[2],$bBox->[1]) -- ($bBox->[0],$bBox->[1]) -- cycle;\n"; + } + ); } push(@{ $self->{borderStdForms} }, [ $y1 - $y2, $x2 - $x1, $x1 * $y2 - $x2 * $y1 ]); @@ -2042,22 +2276,6 @@ sub new { ) ); - return $self unless defined $gt; - - $self->{tikzCode} = join(' -- ', map {"($_->[0], $_->[1])"} @{ $self->{points} }) . ' -- cycle'; - - $self->{clipCode} = sub { - my ($x, $y) = @_; - my $cmp = $self->fillCmp($x, $y); - return if $cmp == 0; - return join('', map { $self->{borderClipCode}[$_]->($x, $y) } 0 .. 3) if $self->{isCrossed} && $cmp > 0; - return - '\\clip' - . ($cmp < 0 ? '[inverse clip] ' : ' ') - . join(' -- ', map {"($_->[0], $_->[1])"} @{ $self->{points} }) - . " -- cycle;\n"; - }; - return $self; } @@ -2096,9 +2314,26 @@ sub cmp { return 0; } +sub tikzCode { + my $self = shift; + return join(' -- ', map {"($_->[0], $_->[1])"} @{ $self->{points} }) . ' -- cycle'; +} + sub tikz { my $self = shift; - return "\\draw[thick, blue, line width = 2.5pt, $self->{solid_dashed}] $self->{tikzCode};\n"; + return "\\draw[thick, blue, line width = 2.5pt, $self->{solid_dashed}] " . $self->tikzCode . ";\n"; +} + +sub clip { + my ($self, $fx, $fy) = @_; + my $cmp = $self->fillCmp($fx, $fy); + return if $cmp == 0; + return join('', map { $self->{borderClipCode}[$_]->($fx, $fy) } 0 .. 3) if $self->{isCrossed} && $cmp > 0; + return + '\\clip' + . ($cmp < 0 ? '[inverse clip] ' : ' ') + . join(' -- ', map {"($_->[0], $_->[1])"} @{ $self->{points} }) + . " -- cycle;\n"; } sub fillCmp { @@ -2141,6 +2376,7 @@ sub fillCmp { sub onBoundary { my ($self, $point, $aVal, $from) = @_; return 1 if $self->fillCmp(@$point) != $aVal; + my $gt = $self->context->flags->get('graphToolObject'); for (0 .. $#{ $self->{borderStdForms} }) { my @stdform = @{ $self->{borderStdForms}[$_] }; my ($x1, $y1) = @{ $self->{points}[$_] }; @@ -2148,11 +2384,11 @@ sub onBoundary { return 1 if ( abs($point->[0] * $stdform[0] + $point->[1] * $stdform[1] + $stdform[2]) / $self->{normalLengths}[$_] < - 0.5 / sqrt($self->{gt}{unitX} * $self->{gt}{unitY})) - && $point->[0] > main::min($x1, $x2) - 0.5 / $self->{gt}{unitX} - && $point->[0] < main::max($x1, $x2) + 0.5 / $self->{gt}{unitX} - && $point->[1] > main::min($y1, $y2) - 0.5 / $self->{gt}{unitY} - && $point->[1] < main::max($y1, $y2) + 0.5 / $self->{gt}{unitY}; + 0.5 / sqrt($gt->{unitX} * $gt->{unitY})) + && $point->[0] > main::min($x1, $x2) - 0.5 / $gt->{unitX} + && $point->[0] < main::max($x1, $x2) + 0.5 / $gt->{unitX} + && $point->[1] > main::min($y1, $y2) - 0.5 / $gt->{unitY} + && $point->[1] < main::max($y1, $y2) + 0.5 / $gt->{unitY}; } return 0; } @@ -2161,29 +2397,11 @@ package GraphTool::GraphObject::Segment; our @ISA = qw(GraphTool::GraphObject::Line); sub new { - my ($invocant, $object, $gt) = @_; - my $self = $invocant->SUPER::new($object, $gt); - - $self->{points} = [ @{ $object->{data} }[ 2, 3 ] ]; + my ($invocant, @arguments) = @_; + my $context = Value::isContext($arguments[0]) ? shift @arguments : $invocant->context; + my $self = $invocant->SUPER::new($context, @arguments); - return $self unless defined $gt; - - $self->{tikzCode} = "($self->{x1}, $self->{y1}) -- ($self->{x2}, $self->{y2})"; - - if ($self->{isVertical}) { - # Vertical segment - $self->{clipCode} = - "($self->{x1}, $gt->{bBox}[3]) -- ($self->{x1}, $gt->{bBox}[1])" - . "-- ($gt->{bBox}[2], $gt->{bBox}[1]) -- ($gt->{bBox}[2], $gt->{bBox}[3]) -- cycle"; - } else { - # Non-vertical segment - $self->{clipCode} = - "($gt->{bBox}[0]," - . $self->{y}->($gt->{bBox}[0]) . ') -- ' - . "($gt->{bBox}[2]," - . $self->{y}->($gt->{bBox}[2]) . ')' - . "-- ($gt->{bBox}[2], $gt->{bBox}[1]) -- ($gt->{bBox}[0], $gt->{bBox}[1]) -- cycle"; - } + $self->{points} = [ @{ $self->{data} }[ 2, 3 ] ]; return $self; } @@ -2206,14 +2424,41 @@ sub fillCmp { && $y <= main::max($self->{y1}->value, $self->{y2}->value) ? 0 : 1); } +sub tikzCode { + my $self = shift; + return "($self->{x1}, $self->{y1}) -- ($self->{x2}, $self->{y2})"; +} + +sub clipCode { + my $self = shift; + my $bBox = $self->context->flags->get('graphToolObject')->{bBox}; + if ($self->{isVertical}) { + return + "($self->{x1}, $bBox->[3])" + . "-- ($self->{x1}, $bBox->[1])" + . "-- ($bBox->[2], $bBox->[1])" + . "-- ($bBox->[2], $bBox->[3]) -- cycle"; + } else { + return + "($bBox->[0]," + . $self->{y}->($bBox->[0]) . ')' + . "-- ($bBox->[2]," + . $self->{y}->($bBox->[2]) . ')' + . "-- ($bBox->[2], $bBox->[1])" + . "-- ($bBox->[0], $bBox->[1]) -- cycle"; + } +} + sub onBoundary { my ($self, $point, $aVal, $from) = @_; + my $gt = $self->context->flags->get('graphToolObject'); + return 0 - if !($point->[0] > main::min($self->{x1}->value, $self->{x2}->value) - 0.5 / $self->{gt}{unitX} - && $point->[0] < main::max($self->{x1}->value, $self->{x2}->value) + 0.5 / $self->{gt}{unitX} - && $point->[1] > main::min($self->{y1}->value, $self->{y2}->value) - 0.5 / $self->{gt}{unitY} - && $point->[1] < main::max($self->{y1}->value, $self->{y2}->value) + 0.5 / $self->{gt}{unitY}); + if !($point->[0] > main::min($self->{x1}->value, $self->{x2}->value) - 0.5 / $gt->{unitX} + && $point->[0] < main::max($self->{x1}->value, $self->{x2}->value) + 0.5 / $gt->{unitX} + && $point->[1] > main::min($self->{y1}->value, $self->{y2}->value) - 0.5 / $gt->{unitY} + && $point->[1] < main::max($self->{y1}->value, $self->{y2}->value) + 0.5 / $gt->{unitY}); my @crossingStdForm = ($point->[1] - $from->[1], $from->[0] - $point->[0], $point->[0] * $from->[1] - $point->[1] * $from->[0]); @@ -2236,15 +2481,16 @@ sub onBoundary { $crossingStdForm[2] > 0 ) ) - || abs($pointSide) / $self->{normalLength} < 0.5 / sqrt($self->{gt}{unitX} * $self->{gt}{unitY}); + || abs($pointSide) / $self->{normalLength} < 0.5 / sqrt($gt->{unitX} * $gt->{unitY}); } package GraphTool::GraphObject::Vector; our @ISA = qw(GraphTool::GraphObject::Segment); sub new { - my ($invocant, $object, $gt) = @_; - my $self = $invocant->SUPER::new($object, $gt); + my ($invocant, @arguments) = @_; + my $context = Value::isContext($arguments[0]) ? shift @arguments : $invocant->context; + my $self = $invocant->SUPER::new($context, @arguments); # The comparison method for this object will only return that the other vector is correct once. If the same vector # is graphed again at a different location it will be considered incorrect for this answer. @@ -2266,7 +2512,8 @@ sub positionalCmp { sub cmp { my ($self, $other, $fuzzy) = @_; - return $self->positionalCmp($other, $fuzzy) if $fuzzy || $self->{gt}{vectorsArePositional}; + return $self->positionalCmp($other, $fuzzy) + if $fuzzy || $self->context->flags->get('graphToolObject')->{vectorsArePositional}; return 0 unless !$self->{foundCorrect} && $other->{data}[0] eq 'vector' @@ -2283,220 +2530,164 @@ package GraphTool::GraphObject::Fill; our @ISA = qw(GraphTool::GraphObject); sub new { - my ($invocant, $object, $gt) = @_; - my $self = $invocant->SUPER::new($object, $gt); + my ($invocant, @arguments) = @_; + my $context = Value::isContext($arguments[0]) ? shift @arguments : $invocant->context; + my $self = $invocant->SUPER::new($context, @arguments); $self->{fillType} = 1; - ($self->{fx}, $self->{fy}) = map { $_->value } $object->{data}[1]->value; + ($self->{fx}, $self->{fy}) = map { $_->value } $self->{data}[1]->value; return $self; } sub pointCmp { - my ($self, $point, $graphedObjs) = @_; + my ($self, $point) = @_; + + my $gt = $self->context->flags->get('graphToolObject'); + my $objects = ref($gt) eq 'parser::GraphTool' ? $gt->data : []; + $objects = [ grep { !$_->{fillType} } @$objects ]; + + push(@$objects, grep { !$_->{fillType} } $gt->{staticObjects}->value) + if ref($gt) eq 'parser::GraphTool' + && ref($gt->{staticObjects}) eq 'parser::GraphTool'; my ($px, $py) = map { $_->value } @{ $point->{data} }; - if ($self->{gt}{useFloodFill}) { - return 1 if $self->{fx} == $px && $self->{fy} == $py; + if ($gt->{useFloodFill}) { + return 0 if $self->{fx} == $px && $self->{fy} == $py; - my @aVals = (0) x @$graphedObjs; + my $result = $self->floodMap( + $gt, $objects, + [ + main::round(($px - $gt->{bBox}[0]) * $gt->{unitX}), + main::round(($gt->{bBox}[1] - $py) * $gt->{unitY}) + ] + ); + return !$result if defined $result; - # If the point is on a graphed object, then there is no filled region. + # This is the case that the fill point is on a graphed object, so that there is no filled region. # FIXME: How should this case be graded? Really, it never should happen. It means the problem author # chose a fill point on another object. Probably because of carelessness with random parameters. - for (0 .. $#$graphedObjs) { - $aVals[$_] = $graphedObjs->[$_]->fillCmp($self->{fx}, $self->{fy}); - return $graphedObjs->[$_]->fillCmp($px, $py) == 0 ? 1 : 0 if $aVals[$_] == 0; - } - - my $isBoundaryPixel = sub { - my ($x, $y, $fromDir) = @_; - my $curPoint = - [ $self->{gt}{bBox}[0] + $x / $self->{gt}{unitX}, $self->{gt}{bBox}[1] - $y / $self->{gt}{unitY} ]; - my $from = [ - $curPoint->[0] + $fromDir->[0] / $self->{gt}{unitX}, - $curPoint->[1] + $fromDir->[1] / $self->{gt}{unitY} - ]; - for (0 .. $#$graphedObjs) { - return 1 if $graphedObjs->[$_]->onBoundary($curPoint, $aVals[$_], $from); - } - return 0; - }; - - my $pxPixel = main::round(($px - $self->{gt}{bBox}[0]) * $self->{gt}{unitX}); - my $pyPixel = main::round(($self->{gt}{bBox}[1] - $py) * $self->{gt}{unitY}); - - my @floodMap = (0) x $fillResolution**2; - my @pixelStack = ([ - main::round(($self->{fx} - $self->{gt}{bBox}[0]) * $self->{gt}{unitX}), - main::round(($self->{gt}{bBox}[1] - $self->{fy}) * $self->{gt}{unitY}) - ]); - - # Perform the flood fill algorithm. - while (@pixelStack) { - my ($x, $y) = @{ pop(@pixelStack) }; - - # Get current pixel position. - my $pixelPos = $y * $fillResolution + $x; - - # Go up until the boundary of the fill region or the edge of board is reached. - while ($y >= 0 && !$isBoundaryPixel->($x, $y, [ 0, 1 ])) { - $y -= 1; - $pixelPos -= $fillResolution; - } - - $y += 1; - $pixelPos += $fillResolution; - my $reachLeft = 0; - my $reachRight = 0; - - # Go down until the boundary of the fill region or the edge of the board is reached. - while ($y < $fillResolution && !$isBoundaryPixel->($x, $y, [ 0, -1 ])) { - return 1 if $x == $pxPixel && $y == $pyPixel; - - # This is a protection against infinite loops. I have not seen this occur with this code unlike - # the corresponding JavaScript code, but it doesn't hurt to add the protection. - last if $floodMap[$pixelPos]; - - # Fill the pixel - $floodMap[$pixelPos] = 1; - - # While proceeding down check to the left and right to - # see if the fill region extends in those directions. - if ($x > 0) { - if (!$floodMap[ $pixelPos - 1 ] && !$isBoundaryPixel->($x - 1, $y, [ 1, 0 ])) { - if (!$reachLeft) { - push(@pixelStack, [ $x - 1, $y ]); - $reachLeft = 1; - } - } else { - $reachLeft = 0; - } - } - - if ($x < $fillResolution - 1) { - if (!$floodMap[ $pixelPos + 1 ] && !$isBoundaryPixel->($x + 1, $y, [ -1, 0 ])) { - if (!$reachRight) { - push(@pixelStack, [ $x + 1, $y ]); - $reachRight = 1; - } - } else { - $reachRight = 0; - } - } - - $y += 1; - $pixelPos += $fillResolution; - } + for (0 .. $#$objects) { + return 0 if $objects->[$_]->fillCmp($px, $py) == 0; } - return 0; + return 1; } else { - for (@$graphedObjs) { - return 0 if $_->fillCmp($self->{fx}, $self->{fy}) != $_->fillCmp($px, $py); + for (@$objects) { + return 1 if $_->fillCmp($self->{fx}, $self->{fy}) != $_->fillCmp($px, $py); } - return 1; + return 0; } } sub cmp { - my ($self, $other, $graphedObjs) = @_; - return $other->{data}[0] eq 'fill' && $self->pointCmp($other->{data}[1], $graphedObjs); + my ($self, $other) = @_; + + return $other->{data}[0] eq 'fill' && !$self->pointCmp($other->{data}[1]); } -sub tikz { - my ($self, $objects) = @_; +sub floodMap { + my ($self, $gt, $objects, $searchPoint) = @_; + + my @aVals = (0) x @$objects; - if ($self->{gt}{useFloodFill}) { - my @aVals = (0) x @$objects; + # If the point is on a graphed object, then don't fill. + for (0 .. $#$objects) { + $aVals[$_] = $objects->[$_]->fillCmp($self->{fx}, $self->{fy}); + return if $aVals[$_] == 0; + } - # If the point is on a graphed object, then don't fill. + my $isBoundaryPixel = sub { + my ($x, $y, $fromDir) = @_; + my $curPoint = [ $gt->{bBox}[0] + $x / $gt->{unitX}, $gt->{bBox}[1] - $y / $gt->{unitY} ]; + my $from = [ $curPoint->[0] + $fromDir->[0] / $gt->{unitX}, $curPoint->[1] + $fromDir->[1] / $gt->{unitY} ]; for (0 .. $#$objects) { - $aVals[$_] = $objects->[$_]->fillCmp($self->{fx}, $self->{fy}); - return '' if $aVals[$_] == 0; + return 1 if $objects->[$_]->onBoundary($curPoint, $aVals[$_], $from); } + return 0; + }; - my $isBoundaryPixel = sub { - my ($x, $y, $fromDir) = @_; - my $curPoint = - [ $self->{gt}{bBox}[0] + $x / $self->{gt}{unitX}, $self->{gt}{bBox}[1] - $y / $self->{gt}{unitY} ]; - my $from = [ - $curPoint->[0] + $fromDir->[0] / $self->{gt}{unitX}, - $curPoint->[1] + $fromDir->[1] / $self->{gt}{unitY} - ]; - for (0 .. $#$objects) { - return 1 if $objects->[$_]->onBoundary($curPoint, $aVals[$_], $from); - } - return 0; - }; - - my @floodMap = (0) x $fillResolution**2; - my @pixelStack = ([ - main::round(($self->{fx} - $self->{gt}{bBox}[0]) * $self->{gt}{unitX}), - main::round(($self->{gt}{bBox}[1] - $self->{fy}) * $self->{gt}{unitY}) - ]); + my @floodMap = (0) x $fillResolution**2; + my @pixelStack = ([ + main::round(($self->{fx} - $gt->{bBox}[0]) * $gt->{unitX}), + main::round(($gt->{bBox}[1] - $self->{fy}) * $gt->{unitY}) + ]); - # Perform the flood fill algorithm. - while (@pixelStack) { - my ($x, $y) = @{ pop(@pixelStack) }; + # Perform the flood fill algorithm. + while (@pixelStack) { + my ($x, $y) = @{ pop(@pixelStack) }; - # Get current pixel position. - my $pixelPos = $y * $fillResolution + $x; + # Get current pixel position. + my $pixelPos = $y * $fillResolution + $x; - # Go up until the boundary of the fill region or the edge of board is reached. - while ($y >= 0 && !$isBoundaryPixel->($x, $y, [ 0, 1 ])) { - $y -= 1; - $pixelPos -= $fillResolution; - } + # Go up until the boundary of the fill region or the edge of board is reached. + while ($y >= 0 && !$isBoundaryPixel->($x, $y, [ 0, 1 ])) { + $y -= 1; + $pixelPos -= $fillResolution; + } - $y += 1; - $pixelPos += $fillResolution; - my $reachLeft = 0; - my $reachRight = 0; - - # Go down until the boundary of the fill region or the edge of the board is reached. - while ($y < $fillResolution && !$isBoundaryPixel->($x, $y, [ 0, -1 ])) { - # This is a protection against infinite loops. I have not seen this occur with this code unlike - # the corresponding JavaScript code, but it doesn't hurt to add the protection. - last if $floodMap[$pixelPos]; - - # Fill the pixel - $floodMap[$pixelPos] = 1; - - # While proceeding down check to the left and right to - # see if the fill region extends in those directions. - if ($x > 0) { - if (!$floodMap[ $pixelPos - 1 ] && !$isBoundaryPixel->($x - 1, $y, [ 1, 0 ])) { - if (!$reachLeft) { - push(@pixelStack, [ $x - 1, $y ]); - $reachLeft = 1; - } - } else { - $reachLeft = 0; + $y += 1; + $pixelPos += $fillResolution; + my $reachLeft = 0; + my $reachRight = 0; + + # Go down until the boundary of the fill region or the edge of the board is reached. + while ($y < $fillResolution && !$isBoundaryPixel->($x, $y, [ 0, -1 ])) { + return 1 if defined $searchPoint && $x == $searchPoint->[0] && $y == $searchPoint->[1]; + + # This is a protection against infinite loops. I have not seen this occur with this code unlike + # the corresponding JavaScript code, but it doesn't hurt to add the protection. + last if $floodMap[$pixelPos]; + + # Fill the pixel + $floodMap[$pixelPos] = 1; + + # While proceeding down check to the left and right to + # see if the fill region extends in those directions. + if ($x > 0) { + if (!$floodMap[ $pixelPos - 1 ] && !$isBoundaryPixel->($x - 1, $y, [ 1, 0 ])) { + if (!$reachLeft) { + push(@pixelStack, [ $x - 1, $y ]); + $reachLeft = 1; } + } else { + $reachLeft = 0; } + } - if ($x < $fillResolution - 1) { - if (!$floodMap[ $pixelPos + 1 ] && !$isBoundaryPixel->($x + 1, $y, [ -1, 0 ])) { - if (!$reachRight) { - push(@pixelStack, [ $x + 1, $y ]); - $reachRight = 1; - } - } else { - $reachRight = 0; + if ($x < $fillResolution - 1) { + if (!$floodMap[ $pixelPos + 1 ] && !$isBoundaryPixel->($x + 1, $y, [ -1, 0 ])) { + if (!$reachRight) { + push(@pixelStack, [ $x + 1, $y ]); + $reachRight = 1; } + } else { + $reachRight = 0; } - - $y += 1; - $pixelPos += $fillResolution; } + + $y += 1; + $pixelPos += $fillResolution; } + } + + return defined $searchPoint ? 0 : \@floodMap; +} + +sub tikz { + my ($self, $objects) = @_; + + my $gt = $self->context->flags->get('graphToolObject'); + + if ($gt->{useFloodFill}) { + my $floodMap = $self->floodMap($gt, $objects); + return '' unless defined $floodMap; # Next zero out the interior of the filled region so that only the boundary is left. - my @floodMapCopy = @floodMap; - for ($fillResolution + 1 .. $#floodMap - $fillResolution - 1) { - $floodMap[$_] = 0 + my @floodMapCopy = @$floodMap; + for ($fillResolution + 1 .. $#$floodMap - $fillResolution - 1) { + $floodMap->[$_] = 0 if $floodMapCopy[$_] && $_ % $fillResolution > 0 && $_ % $fillResolution < $fillResolution - 1 @@ -2508,8 +2699,8 @@ sub tikz { my $tikz = "\\begin{scope}[fillpurple, line width = 2.5pt]\n" - . '\\clip[rounded corners = 14pt] ' - . "($self->{gt}{bBox}[0], $self->{gt}{bBox}[3]) rectangle ($self->{gt}{bBox}[2], $self->{gt}{bBox}[1]);\n"; + . "\\clip[rounded corners = 14pt] " + . "($gt->{bBox}[0], $gt->{bBox}[3]) rectangle ($gt->{bBox}[2], $gt->{bBox}[1]);\n"; my $border = ''; my $pass = 1; @@ -2519,8 +2710,8 @@ sub tikz { # hole curves are clipped out. while (1) { my $pos = 0; - for ($pos = 0; $pos < @floodMap && !$floodMap[$pos]; ++$pos) { } - last if ($pos == @floodMap); + for ($pos = 0; $pos < @$floodMap && !$floodMap->[$pos]; ++$pos) { } + last if ($pos == @$floodMap); my $followPath; $followPath = sub { @@ -2531,8 +2722,8 @@ sub tikz { while (1) { ++$length; - my $x = $self->{gt}{bBox}[0] + ($pos % $fillResolution) / $self->{gt}{unitX}; - my $y = $self->{gt}{bBox}[1] - int($pos / $fillResolution) / $self->{gt}{unitY}; + my $x = $gt->{bBox}[0] + ($pos % $fillResolution) / $gt->{unitX}; + my $y = $gt->{bBox}[1] - int($pos / $fillResolution) / $gt->{unitY}; if (@coordinates > 1 && ($y - $coordinates[-2][1]) * ($coordinates[-1][0] - $coordinates[-2][0]) == ($coordinates[-1][1] - $coordinates[-2][1]) * ($x - $coordinates[-2][0])) @@ -2542,29 +2733,29 @@ sub tikz { push(@coordinates, [ $x, $y ]); } - $floodMap[$pos] = 0; + $floodMap->[$pos] = 0; my $haveRight = $pos % $fillResolution < $fillResolution - 1; - my $haveLower = $pos < @floodMap - $fillResolution; + my $haveLower = $pos < @$floodMap - $fillResolution; my $haveLeft = $pos % $fillResolution > 0; my $haveUpper = $pos >= $fillResolution; my @neighbors; - push(@neighbors, $pos + 1) if ($haveRight && $floodMap[ $pos + 1 ]); + push(@neighbors, $pos + 1) if ($haveRight && $floodMap->[ $pos + 1 ]); push(@neighbors, $pos + $fillResolution + 1) - if ($haveRight && $haveLower && $floodMap[ $pos + $fillResolution + 1 ]); + if ($haveRight && $haveLower && $floodMap->[ $pos + $fillResolution + 1 ]); push(@neighbors, $pos + $fillResolution) - if ($haveLower && $floodMap[ $pos + $fillResolution ]); + if ($haveLower && $floodMap->[ $pos + $fillResolution ]); push(@neighbors, $pos + $fillResolution - 1) - if ($haveLeft && $haveLower && $floodMap[ $pos + $fillResolution - 1 ]); - push(@neighbors, $pos - 1) if ($haveLeft && $floodMap[ $pos - 1 ]); + if ($haveLeft && $haveLower && $floodMap->[ $pos + $fillResolution - 1 ]); + push(@neighbors, $pos - 1) if ($haveLeft && $floodMap->[ $pos - 1 ]); push(@neighbors, $pos - $fillResolution - 1) - if ($haveLeft && $haveUpper && $floodMap[ $pos - $fillResolution - 1 ]); + if ($haveLeft && $haveUpper && $floodMap->[ $pos - $fillResolution - 1 ]); push(@neighbors, $pos - $fillResolution) - if ($haveUpper && $floodMap[ $pos - $fillResolution ]); + if ($haveUpper && $floodMap->[ $pos - $fillResolution ]); push(@neighbors, $pos - $fillResolution + 1) - if ($haveUpper && $haveRight && $floodMap[ $pos - $fillResolution + 1 ]); + if ($haveUpper && $haveRight && $floodMap->[ $pos - $fillResolution + 1 ]); last unless @neighbors; @@ -2572,7 +2763,7 @@ sub tikz { else { my $maxLength = 0; my $maxPath; - $floodMap[$_] = 0 for @neighbors; + $floodMap->[$_] = 0 for @neighbors; for (@neighbors) { my ($pathLength, @path) = $followPath->($_); if ($pathLength > $maxLength) { @@ -2605,22 +2796,16 @@ sub tikz { } else { my $clip_code = ''; for (@$objects) { - if (ref($_->{clipCode}) eq 'CODE') { - my $objectClipCode = $_->{clipCode}->($self->{fx}, $self->{fy}); - return '' unless defined $objectClipCode; - $clip_code .= $objectClipCode; - next; - } - my $clip_dir = $_->fillCmp($self->{fx}, $self->{fy}); - return '' if $clip_dir == 0; - $clip_code .= "\\clip " . ($clip_dir < 0 ? '[inverse clip]' : '') . $_->{clipCode} . ";\n"; + my $objectClipCode = $_->clip($self->{fx}, $self->{fy}); + return '' unless defined $objectClipCode; + $clip_code .= $objectClipCode; } return "\\begin{scope}\n\\clip[rounded corners=14pt] " - . "($self->{gt}{bBox}[0],$self->{gt}{bBox}[3]) rectangle ($self->{gt}{bBox}[2],$self->{gt}{bBox}[1]);\n" + . "($gt->{bBox}[0], $gt->{bBox}[3]) rectangle ($gt->{bBox}[2], $gt->{bBox}[1]);\n" . $clip_code . "\\fill[fillpurple] " - . "($self->{gt}{bBox}[0],$self->{gt}{bBox}[3]) rectangle ($self->{gt}{bBox}[2],$self->{gt}{bBox}[1]);\n" + . "($gt->{bBox}[0], $gt->{bBox}[3]) rectangle ($gt->{bBox}[2], $gt->{bBox}[1]);\n" . "\\end{scope}"; } } From c5cb246ad4bec8e2e7d0ad5d229111fb08151ea4 Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Thu, 17 Apr 2025 06:19:36 -0500 Subject: [PATCH 3/3] Update the sample problem demonstrating custom graphtool checker ussage. --- .../Algebra/GraphToolCustomChecker.pg | 28 ++++++------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/tutorial/sample-problems/Algebra/GraphToolCustomChecker.pg b/tutorial/sample-problems/Algebra/GraphToolCustomChecker.pg index 8e3d10fe5..b8d4230d2 100644 --- a/tutorial/sample-problems/Algebra/GraphToolCustomChecker.pg +++ b/tutorial/sample-problems/Algebra/GraphToolCustomChecker.pg @@ -86,18 +86,6 @@ $gt = GraphTool("{circle, solid, ($h, $k), ($h + $r, $k)}")->with( my @errors; my $count = 1; - # Get the center and point that define the correct circle and - # compute the square of the radius. - my ($cx, $cy) = $correct->[0]->extract(3)->value; - my ($px, $py) = $correct->[0]->extract(4)->value; - my $r_squared = ($cx - $px)**2 + ($cy - $py)**2; - - my $pointOnCircle = sub { - my $point = shift; - my ($x, $y) = $point->value; - return ($x - $cx)**2 + ($y - $cy)**2 == $r_squared; - }; - # Iterate through the objects the student graphed and check to # see if each is the correct circle. for (@$student) { @@ -107,12 +95,8 @@ $gt = GraphTool("{circle, solid, ($h, $k), ($h + $r, $k)}")->with( # type as the correct object type (a circle in this case), # has the same solid or dashed status, has the same center, and # if the other point graphed is on the circle. - if ($_->extract(1) eq $correct->[0]->extract(1) - && $_->extract(2) eq $correct->[0]->extract(2) - && $_->extract(3) == $correct->[0]->extract(3) - && $pointOnCircle->($_->extract(4))) - { - $score += 1; + if ($correct->[0] == $_) { + ++$score; next; } @@ -124,7 +108,13 @@ $gt = GraphTool("{circle, solid, ($h, $k), ($h + $r, $k)}")->with( next; } - if ($_->extract(2) ne $correct->[0]->extract(2)) { + # Calling $correct->[0]->cmp($_, 1) does a "fuzzy" comparison. + # This is the same as $correct->[0] == $_ except that it ignores + # the solid or dashed status and only checks the center point + # and radius, i.e., that the circles are the same. + if ($correct->[0]->cmp($_, 1) + && $_->extract(2) ne $correct->[0]->extract(2)) + { push(@errors, "The $nth object graphed should be a " . $correct->[0]->extract(2)