From 0d76db2c57f728ba85e6e55d350973f41ecaffe4 Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Sat, 8 Feb 2025 17:03:48 -0600 Subject: [PATCH] Add triangle and quadrilateral tools to the graph tool. These were requested by Robin Cruz in https://webwork.maa.org/moodle/mod/forum/discuss.php?d=8646. The usage of the new tools is documented in the parserGraphTool.pl macro. --- htdocs/js/GraphTool/graphtool.scss | 8 + htdocs/js/GraphTool/images/Quadrilateral.svg | 6 + htdocs/js/GraphTool/images/Triangle.svg | 6 + htdocs/js/GraphTool/quadrilateral.js | 626 +++++++++++++++++++ htdocs/js/GraphTool/triangle.js | 468 ++++++++++++++ macros/graph/parserGraphTool.pl | 218 ++++++- 6 files changed, 1325 insertions(+), 7 deletions(-) create mode 100644 htdocs/js/GraphTool/images/Quadrilateral.svg create mode 100644 htdocs/js/GraphTool/images/Triangle.svg create mode 100644 htdocs/js/GraphTool/quadrilateral.js create mode 100644 htdocs/js/GraphTool/triangle.js diff --git a/htdocs/js/GraphTool/graphtool.scss b/htdocs/js/GraphTool/graphtool.scss index de1bba870..ffed82d32 100644 --- a/htdocs/js/GraphTool/graphtool.scss +++ b/htdocs/js/GraphTool/graphtool.scss @@ -235,6 +235,14 @@ &.gt-sine-wave-tool { background-image: url('images/SineWaveTool.svg'); } + + &.gt-triangle-tool { + background-image: url('images/Triangle.svg'); + } + + &.gt-quadrilateral-tool { + background-image: url('images/Quadrilateral.svg'); + } } } diff --git a/htdocs/js/GraphTool/images/Quadrilateral.svg b/htdocs/js/GraphTool/images/Quadrilateral.svg new file mode 100644 index 000000000..7f02af4ac --- /dev/null +++ b/htdocs/js/GraphTool/images/Quadrilateral.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/htdocs/js/GraphTool/images/Triangle.svg b/htdocs/js/GraphTool/images/Triangle.svg new file mode 100644 index 000000000..39c84bb24 --- /dev/null +++ b/htdocs/js/GraphTool/images/Triangle.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/htdocs/js/GraphTool/quadrilateral.js b/htdocs/js/GraphTool/quadrilateral.js new file mode 100644 index 000000000..63369f9f1 --- /dev/null +++ b/htdocs/js/GraphTool/quadrilateral.js @@ -0,0 +1,626 @@ +/* global graphTool, JXG */ + +(() => { + 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)); + } + } + 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(','); + }, + + // Note that this is an interior fill which is a bit inconsistent with the other fill cmp methods for other + // graph objects. Other grap objects use an inequality fill. + fillCmp(gt, point) { + // Check to see if the point is on the border. + for (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] + ) + ) { + 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) return 1; + return -1; + }, + + 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); + } + 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)); + }, + + 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) { + const bbox = gt.board.getBoundingBox(); + + let x = point.X() < bbox[0] ? bbox[0] : point.X() > bbox[2] ? bbox[2] : point.X(); + let y = point.Y() < bbox[3] ? bbox[3] : point.Y() > bbox[1] ? bbox[1] : point.Y(); + + if ( + groupedPoints.length == 1 && + Math.abs(x - groupedPoints[0].X()) < JXG.Math.eps && + Math.abs(y - groupedPoints[0].Y()) < JXG.Math.eps + ) { + // 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(), + y_trans = coords.usrCoords[2] - groupedPoints[0].Y(); + [xDir, yDir] = + Math.abs(x_trans) < Math.abs(y_trans) + ? [0, y_trans < 0 ? -1 : 1] + : [x_trans < 0 ? -1 : 1, 0]; + } else if (e.type === 'keydown') { + xDir = e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : 0; + yDir = e.key === 'ArrowUp' ? 1 : e.key === 'ArrowDown' ? -1 : 0; + } + 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. + 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. + 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) + ); + + // Move to the point closest to the mouse cursor. + let min = -1; + for (const p of points) { + const dist = (p[0] - coords.usrCoords[1]) ** 2 + (p[1] - coords.usrCoords[2]) ** 2; + if (min == -1 || dist < min) { + min = dist; + x = p[0]; + y = p[1]; + } + } + } else if (e.type === 'keydown') { + const xDir = e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : 0; + const yDir = e.key === 'ArrowUp' ? 1 : e.key === 'ArrowDown' ? -1 : 0; + x += xDir * gt.snapSizeX; + y += yDir * gt.snapSizeY; + } + } else if ( + groupedPoints.length == 3 && + gt.graphObjectTypes.quadrilateral.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. + const points = [ + [x - gt.snapSizeX, y + gt.snapSizeY], + [x, y + gt.snapSizeY], + [x + gt.snapSizeX, y + gt.snapSizeY], + [x - gt.snapSizeX, y], + [x + gt.snapSizeX, y], + [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) + ); + + // Move to the point closest to the mouse cursor. + let min = -1; + for (const p of points) { + const dist = (p[0] - coords.usrCoords[1]) ** 2 + (p[1] - coords.usrCoords[2]) ** 2; + if (min == -1 || dist < min) { + min = dist; + x = p[0]; + y = p[1]; + } + } + } else if (e.type === 'keydown') { + const xDir = e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : 0; + 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)) { + x += xDir * gt.snapSizeX; + y += yDir * gt.snapSizeY; + } + } + } + + // If the computed new coordinates are off the board, + // then move the coordinates the other direction instead. + if (x < bbox[0]) x = bbox[0] + gt.snapSizeX; + else if (x > bbox[2]) x = bbox[2] - gt.snapSizeX; + if (y < bbox[3]) y = bbox[3] + gt.snapSizeY; + else if (y > bbox[1]) y = bbox[1] - gt.snapSizeY; + + point.setPosition(JXG.COORDS_BY_USER, [x, y]); + }, + + groupedPointDrag(gt, 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) { + 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 = []; + 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', gt.graphObjectTypes.quadrilateral.groupedPointDrag); + } + grouped_point.grouped_points.push(point); + if ( + !grouped_point.eventHandlers.drag || + grouped_point.eventHandlers.drag.every( + (dragHandler) => + dragHandler.handler !== gt.graphObjectTypes.quadrilateral.groupedPointDrag + ) + ) + grouped_point.on('drag', gt.graphObjectTypes.quadrilateral.groupedPointDrag); + } + point.on('drag', gt.graphObjectTypes.quadrilateral.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; + + this.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 three more vertices for the quadrilateral.'; + gt.updateHelp(); + + gt.board.on('up', (e) => this.phase2(gt.getMouseCoords(e).usrCoords)); + + gt.board.update(); + }; + + this.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 + // 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'); + + this.point2 = gt.graphObjectTypes.quadrilateral.createPoint(coords[1], coords[2], [this.point1]); + this.point2.setAttribute({ fixed: true, highlight: false }); + + // Get a new x coordinate that is to the right and a new y coordinate that is above, unless that + // 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 (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; + } + + this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [newX, newY], gt.board)); + + this.helpText = 'Plot two more vertices for the quadrilateral.'; + gt.updateHelp(); + + gt.board.on('up', (e) => this.phase3(gt.getMouseCoords(e).usrCoords)); + + gt.board.update(); + }; + + this.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), + this.point1, + this.point2 + ) + ) + coords = this.hlObjs.hl_point.coords.usrCoords; + + gt.board.off('up'); + + this.point3 = gt.graphObjectTypes.quadrilateral.createPoint(coords[1], coords[2], [ + this.point1, + this.point2 + ]); + 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. + let count = 0; + let hDir = 0, + vDir = -1; + let times = 0; + + let newX = this.point3.X(); + let newY = this.point3.Y(); + + do { + if (count == 0) { + if (vDir != 0) ++times; + count = times; + [hDir, vDir] = [!!hDir ? 0 : -vDir, !!vDir ? 0 : hDir]; + } + newX += hDir * gt.snapSizeX; + newY += vDir * gt.snapSizeY; + --count; + } while ( + (!gt.boardHasPoint(newX, newY) || + gt.graphObjectTypes.quadrilateral.arePairwiseColinear( + newX, + newY, + this.point1, + this.point2, + this.point3 + )) && + times < 20 + ); + + this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [newX, newY], gt.board)); + + this.helpText = 'Plot the last vertex of the quadrilateral.'; + gt.updateHelp(); + + gt.board.on('up', (e) => this.phase4(gt.getMouseCoords(e).usrCoords)); + + gt.board.update(); + }; + + this.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 ( + gt.graphObjectTypes.quadrilateral.arePairwiseColinear( + gt.snapRound(coords[1], gt.snapSizeX), + gt.snapRound(coords[2], gt.snapSizeY), + this.point1, + this.point2, + this.point3 + ) + ) + coords = this.hlObjs.hl_point.coords.usrCoords; + + gt.board.off('up'); + + const point4 = gt.graphObjectTypes.quadrilateral.createPoint(coords[1], coords[2], [ + this.point1, + this.point2, + this.point3 + ]); + gt.selectedObj = new gt.graphObjectTypes.quadrilateral( + this.point1, + this.point2, + this.point3, + point4, + gt.drawSolid + ); + gt.selectedObj.focusPoint = point4; + gt.graphedObjs.push(gt.selectedObj); + delete this.point1; + delete this.point2; + delete this.point3; + + 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.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(); + } + + // 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); + } + + 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; + } + + 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 + } + } + ); + } 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)); + } + } + }; +})(); diff --git a/htdocs/js/GraphTool/triangle.js b/htdocs/js/GraphTool/triangle.js new file mode 100644 index 000000000..c957c30fe --- /dev/null +++ b/htdocs/js/GraphTool/triangle.js @@ -0,0 +1,468 @@ +/* global graphTool, JXG */ + +(() => { + 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)); + } + } + 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; + }, + + 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; + } + return -1; + }, + + 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); + } + 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], { + highlight: false, + fillOpacity: 0, + fixed: true, + borders: { + strokeWidth: 2, + highlight: false, + fixed: true, + strokeColor: color ? color : gt.color.underConstruction, + 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) { + const bbox = gt.board.getBoundingBox(); + + let x = point.X() < bbox[0] ? bbox[0] : point.X() > bbox[2] ? bbox[2] : point.X(); + let y = point.Y() < bbox[3] ? bbox[3] : point.Y() > bbox[1] ? bbox[1] : point.Y(); + + if ( + groupedPoints.length == 1 && + Math.abs(x - groupedPoints[0].X()) < JXG.Math.eps && + Math.abs(y - groupedPoints[0].Y()) < JXG.Math.eps + ) { + // Adjust position of the point if it has the same coordinates as its paired point. + if (e.type === 'pointermove') { + const coords = gt.getMouseCoords(e); + const x_trans = coords.usrCoords[1] - groupedPoints[0].X(), + y_trans = coords.usrCoords[2] - groupedPoints[0].Y(); + [xDir, yDir] = + Math.abs(x_trans) < Math.abs(y_trans) + ? [0, y_trans < 0 ? -1 : 1] + : [x_trans < 0 ? -1 : 1, 0]; + } else if (e.type === 'keydown') { + xDir = e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : 0; + yDir = e.key === 'ArrowUp' ? 1 : e.key === 'ArrowDown' ? -1 : 0; + } + 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. + 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. + 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) + ); + + // Move to the point closest to the mouse cursor. + let min = -1; + for (const p of points) { + const dist = (p[0] - coords.usrCoords[1]) ** 2 + (p[1] - coords.usrCoords[2]) ** 2; + if (min == -1 || dist < min) { + min = dist; + x = p[0]; + y = p[1]; + } + } + } else if (e.type === 'keydown') { + const xDir = e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : 0; + const yDir = e.key === 'ArrowUp' ? 1 : e.key === 'ArrowDown' ? -1 : 0; + x += xDir * gt.snapSizeX; + y += yDir * gt.snapSizeY; + } + } + + // If the computed new coordinates are off the board, + // then move the coordinates the other direction instead. + if (x < bbox[0]) x = bbox[0] + gt.snapSizeX; + else if (x > bbox[2]) x = bbox[2] - gt.snapSizeX; + if (y < bbox[3]) y = bbox[3] + gt.snapSizeY; + else if (y > bbox[1]) y = bbox[1] - gt.snapSizeY; + + point.setPosition(JXG.COORDS_BY_USER, [x, y]); + }, + + groupedPointDrag(gt, 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) { + 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 = []; + 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); + } + paired_point.grouped_points.push(point); + if ( + !paired_point.eventHandlers.drag || + paired_point.eventHandlers.drag.every( + (dragHandler) => + dragHandler.handler !== gt.graphObjectTypes.triangle.groupedPointDrag + ) + ) + paired_point.on('drag', gt.graphObjectTypes.triangle.groupedPointDrag); + } + point.on('drag', gt.graphObjectTypes.triangle.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; + } + } + }, + + TriangleTool: { + iconName: 'triangle', + tooltip: 'Triangle Tool: Graph a triangle.', + + initialize(gt) { + this.supportsSolidDash = true; + + this.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 two more vertices for the triangle.'; + gt.updateHelp(); + + gt.board.on('up', (e) => this.phase2(gt.getMouseCoords(e).usrCoords)); + + gt.board.update(); + }; + + this.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 + // 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'); + + this.point2 = gt.graphObjectTypes.triangle.createPoint(coords[1], coords[2], [this.point1]); + this.point2.setAttribute({ fixed: true, highlight: false }); + + // Get a new x coordinate that is to the right and a new y coordinate that is above, unless that + // 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 (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; + } + + this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [newX, newY], gt.board)); + + this.helpText = 'Plot the last vertex of the triangle.'; + gt.updateHelp(); + + gt.board.on('up', (e) => this.phase3(gt.getMouseCoords(e).usrCoords)); + + gt.board.update(); + }; + + this.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), + this.point1, + this.point2 + ) + ) + coords = this.hlObjs.hl_point.coords.usrCoords; + + gt.board.off('up'); + + const point3 = gt.graphObjectTypes.triangle.createPoint(coords[1], coords[2], [ + this.point1, + this.point2 + ]); + gt.selectedObj = new gt.graphObjectTypes.triangle(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 }); + 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(); + } + + // 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); + } + + 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.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 + }); + } + + 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)); + } + } + }; +})(); diff --git a/macros/graph/parserGraphTool.pl b/macros/graph/parserGraphTool.pl index 2d50fec1d..db366f807 100644 --- a/macros/graph/parserGraphTool.pl +++ b/macros/graph/parserGraphTool.pl @@ -53,11 +53,12 @@ =head1 DESCRIPTION =head1 GRAPH OBJECTS -There are nine types of graph objects that the students can graph. Points, lines, circles, -parabolas, quadratics, cubics, intervals, sine waves, and fills (or shading of a region). 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 name of the -object. The following elements in the braces depend on the type of element. +There are eleven types of graph objects that the students can graph. Points, lines, circles, +parabolas, quadratics, cubics, intervals, sine waves, triangles, quadrilaterals and fills (or +shading of a region). 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 name of the object. The following elements in the braces depend on the type +of element. For points the name "point" must be followed by the coordinates. For example: @@ -119,6 +120,18 @@ =head1 GRAPH OBJECTS represents the function C. +For triangles the name "triangle" 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 three vertices +of the triangle. For example: + + "{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)}" + The student answers that are returned by the JavaScript will be a list of the list objects discussed above and will be parsed by WeBWorK and passed to the checker as such. The default checker is designed to grade the graph based on appearance. This means that if a student graphs @@ -298,8 +311,8 @@ =head1 OPTIONS The order the tools are listed here will also be the order the tools are presented in the graph tool button box. In addition to the tools listed in the default options above, there is a "PointTool", three point "QuadraticTool", four point "CubicTool", "IntervalTool", -"IncludeExcludePointTool", and "SineWaveTool". Note that the case of the tool names must match -what is shown. +"IncludeExcludePointTool", "SineWaveTool", "TriangleTool", and "QuadrilateralTool". Note that +the case of the tool names must match what is shown. =item staticObjects (Default: C<< staticObjects => [] >>) @@ -439,6 +452,8 @@ sub _parserGraphTool_init { ADD_JS_FILE('js/GraphTool/cubictool.js', 0, { defer => undef }); ADD_JS_FILE('js/GraphTool/intervaltools.js', 0, { defer => undef }); ADD_JS_FILE('js/GraphTool/sinewavetool.js', 0, { defer => undef }); + ADD_JS_FILE('js/GraphTool/triangle.js', 0, { defer => undef }); + ADD_JS_FILE('js/GraphTool/quadrilateral.js', 0, { defer => undef }); return; } @@ -1149,6 +1164,191 @@ sub addTools { } ); } + }, + triangle => { + js => 'graphTool.triangleTool.Triangle', + tikz => { + code => sub { + my $gt = shift; + + my ($p1x, $p1y) = @{ $_->{data}[2]{data} }; + my ($p2x, $p2y) = @{ $_->{data}[3]{data} }; + my ($p3x, $p3y) = @{ $_->{data}[4]{data} }; + + return ( + "\\draw[thick, blue, line width = 2.5pt, $_->{data}[1]]" + . " ($p1x, $p1y) -- ($p2x, $p2y) -- ($p3x, $p3y) -- cycle;", + [ + "($p1x, $p1y) -- ($p2x, $p2y) -- ($p3x, $p3y) -- cycle", + sub { + my $denominator = ($p2y - $p3y) * ($p1x - $p3x) + ($p3x - $p2x) * ($p1y - $p3y); + 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; + } + ] + ); + } + }, + cmp => sub { + my $triangle = shift; + + my $solid_dashed = $triangle->{data}[1]; + my $p1 = $triangle->{data}[2]; + my $p2 = $triangle->{data}[3]; + my $p3 = $triangle->{data}[4]; + + 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 ( + 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); + + for my $otherPoint (@{ $other->{data} }[ 2, 3, 4 ]) { + return 0 if !(grep { $_ == $otherPoint } $p1, $p2, $p3); + } + + return 1; + } + ); + } + }, + quadrilateral => { + js => 'graphTool.quadrilateralTool.Quadrilateral', + tikz => { + code => sub { + my $gt = shift; + + my @points = @{ $_->{data} }[ 2 .. 5 ]; + my ($p1x, $p1y) = @{ $points[0]{data} }; + my ($p2x, $p2y) = @{ $points[1]{data} }; + my ($p3x, $p3y) = @{ $points[2]{data} }; + my ($p4x, $p4y) = @{ $points[3]{data} }; + + return ( + "\\draw[thick, blue, line width = 2.5pt, $_->{data}[1]]" + . " ($p1x, $p1y) -- ($p2x, $p2y) -- ($p3x, $p3y) -- ($p4x, $p4y) -- cycle;", + [ + "($p1x, $p1y) -- ($p2x, $p2y) -- ($p3x, $p3y) -- ($p4x, $p4y) -- cycle", + sub { + my ($x, $y) = @_; + + # 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); + } + + # 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; + } + } + return 1 if $isIn; + + return -1; + } + ] + ); + } + }, + cmp => sub { + my $quadrilateral = shift; + + my $solid_dashed = $quadrilateral->{data}[1]; + my @points = @{ $quadrilateral->{data} }[ 2 .. 5 ]; + + 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); + } + + # 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; + } + } + return 1 if $isIn; + + 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; + + $correct = 1; + for my $j (0 .. 3) { + if ($points[ 3 - ($i + $j) % 4 ] != $other->{data}[ $j + 2 ]) { + $correct = 0; + last; + } + } + return 1 if $correct; + } + + return 0; + } + ); + } } ); @@ -1163,6 +1363,10 @@ sub addTools { 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', # Include/Exclude point tool. IncludeExcludePointTool => 'graphTool.includeExcludePointTool.IncludeExcludePointTool', );