diff --git a/.github/workflows/manual-regression.yml b/.github/workflows/manual-regression.yml index 2350d4dfda..43de72aa66 100644 --- a/.github/workflows/manual-regression.yml +++ b/.github/workflows/manual-regression.yml @@ -48,8 +48,8 @@ on: - functional/tile_tests/drawing_tool_spec.js - functional/tile_tests/duplicate_tile_spec.js - functional/tile_tests/expression_tool_spec.js - - functional/tile_tests/graph_table_integraton_test_spec.js - - functional/tile_tests/graph_tool_spec.js + - functional/tile_tests/geometry_tool_spec.js + - functional/tile_tests/geometry_table_integraton_test_spec.js - functional/tile_tests/image_tool_spec.js - functional/tile_tests/numberline_tool_spec.js - functional/tile_tests/shared_dataset_spec.js diff --git a/cypress/e2e/functional/document_tests/canvas_test_spec.js b/cypress/e2e/functional/document_tests/canvas_test_spec.js index 73dfa9c1b4..b50cc99efe 100644 --- a/cypress/e2e/functional/document_tests/canvas_test_spec.js +++ b/cypress/e2e/functional/document_tests/canvas_test_spec.js @@ -206,7 +206,7 @@ context('Test Canvas', function () { geometryToolTile.getGeometryTile().should('exist'); // clueCanvas.exportTileAndDocument('geometry-tool-tile'); // in case we created a point while exporting - cy.get('.primary-workspace .geometry-toolbar .button.delete').click({ force: true }); + // cy.get('.primary-workspace .geometry-toolbar .button.delete').click({ force: true }); cy.log('adds an image tool'); clueCanvas.addTile('image'); @@ -290,7 +290,6 @@ context('Test Canvas', function () { clueCanvas.deleteTile('draw'); clueCanvas.deleteTile('table'); clueCanvas.deleteTile('text'); - clueCanvas.deleteTile('text'); textToolTile.getTextTile().should('not.exist'); geometryToolTile.getGeometryTile().should('not.exist'); drawToolTile.getDrawTile().should('not.exist'); diff --git a/cypress/e2e/functional/document_tests/copy_doc_test_spec.js b/cypress/e2e/functional/document_tests/copy_doc_test_spec.js index 6df1478a67..60f4daf83a 100644 --- a/cypress/e2e/functional/document_tests/copy_doc_test_spec.js +++ b/cypress/e2e/functional/document_tests/copy_doc_test_spec.js @@ -53,13 +53,12 @@ context('Copy Document', () => { cy.log('Add geometry tile'); clueCanvas.addTile('geometry'); - cy.get('.spacer').click(); - textTile.deleteTextTile(); geometryTile.getGeometryTile().last().click(); - geometryTile.addPointToGraph(5, 5); - geometryTile.addPointToGraph(10, 5); - geometryTile.addPointToGraph(10, 10); - geometryTile.getGraphPoint().should('have.length', 3); + clueCanvas.clickToolbarButton('geometry', 'point'); + geometryTile.clickGraphPosition(5, 5); + geometryTile.clickGraphPosition(10, 5); + geometryTile.clickGraphPosition(10, 10); + geometryTile.getGraphPoint().should('have.length', 4); cy.log('Add drawing tile'); clueCanvas.addTile("drawing"); diff --git a/cypress/e2e/functional/document_tests/student_teacher_4up_readonly_spec.js b/cypress/e2e/functional/document_tests/student_teacher_4up_readonly_spec.js index bf0513a67d..d0a3668bb0 100644 --- a/cypress/e2e/functional/document_tests/student_teacher_4up_readonly_spec.js +++ b/cypress/e2e/functional/document_tests/student_teacher_4up_readonly_spec.js @@ -81,12 +81,12 @@ function setupTest(studentIndex) { }); clueCanvas.addTile('geometry'); cy.get('.spacer').click(); - textToolTile.deleteTextTile(); geometryToolTile.getGeometryTile().last().click(); - geometryToolTile.addPointToGraph(5, 5); - geometryToolTile.addPointToGraph(10, 5); - geometryToolTile.addPointToGraph(10, 10); - geometryToolTile.getGraphPoint().should('have.length', 3); + clueCanvas.clickToolbarButton('geometry', 'point'); + geometryToolTile.clickGraphPosition(5, 5); + geometryToolTile.clickGraphPosition(10, 5); + geometryToolTile.clickGraphPosition(10, 10); + geometryToolTile.getGraphPoint().should('have.length', 4); // including phantom point clueCanvas.addTile("drawing"); drawToolTile.getDrawToolRectangle().click(); drawToolTile.getDrawTile() diff --git a/cypress/e2e/functional/document_tests/tiles_copy_test_spec.js b/cypress/e2e/functional/document_tests/tiles_copy_test_spec.js index 8b28aafbe8..2832ab2ee8 100644 --- a/cypress/e2e/functional/document_tests/tiles_copy_test_spec.js +++ b/cypress/e2e/functional/document_tests/tiles_copy_test_spec.js @@ -1,5 +1,4 @@ import ClueCanvas from '../../../support/elements/common/cCanvas'; -import TextToolTile from '../../../support/elements/tile/TextToolTile'; import Canvas from '../../../support/elements/common/Canvas'; import TableToolTile from '../../../support/elements/tile/TableToolTile'; import GeometryToolTile from '../../../support/elements/tile/GeometryToolTile'; @@ -16,7 +15,6 @@ const student5 = `${Cypress.config("qaUnitStudent5")}`; const student6 = `${Cypress.config("qaUnitStudent6")}`; let clueCanvas = new ClueCanvas, - textToolTile = new TextToolTile, tableToolTile = new TableToolTile, geometryToolTile = new GeometryToolTile, drawToolTile = new DrawToolTile, @@ -146,12 +144,12 @@ context('Test copy tiles from one document to other document', function () { cy.log('Add graph tile'); clueCanvas.addTile('geometry'); cy.get('.spacer').click(); - textToolTile.deleteTextTile(); geometryToolTile.getGeometryTile().last().click(); - geometryToolTile.addPointToGraph(5, 5); - geometryToolTile.addPointToGraph(10, 5); - geometryToolTile.addPointToGraph(10, 10); - geometryToolTile.getGraphPoint().should('have.length', 3); + clueCanvas.clickToolbarButton('geometry', 'point'); + geometryToolTile.clickGraphPosition(5, 5); + geometryToolTile.clickGraphPosition(10, 5); + geometryToolTile.clickGraphPosition(10, 10); + geometryToolTile.getGraphPoint().should('have.length', 4); cy.log('Add drawing tile'); clueCanvas.addTile("drawing"); diff --git a/cypress/e2e/functional/tile_tests/arrow_annotation_spec.js b/cypress/e2e/functional/tile_tests/arrow_annotation_spec.js index 53c9f85d17..43dd8198c3 100644 --- a/cypress/e2e/functional/tile_tests/arrow_annotation_spec.js +++ b/cypress/e2e/functional/tile_tests/arrow_annotation_spec.js @@ -236,21 +236,23 @@ context('Arrow Annotations (Sparrows)', function () { aa.getAnnotationArrows().should("have.length", 4); }); - it("can add arrows to geometry tiles", () => { + it("can add arrows to geometry tiles", { scrollBehavior: 'nearest'}, () => { beforeTest(queryParams); clueCanvas.addTile("geometry"); cy.log("Annotation buttons appear for points, polygons, and segments"); + clueCanvas.clickToolbarButton('geometry', 'polygon'); aa.clickArrowToolbarButton(); // sparrow mode on aa.getAnnotationLayer().should("have.class", "editing"); aa.getAnnotationButtons().should("not.exist"); + aa.clickArrowToolbarButton(); // sparrow mode off - // For some reason adding the first point is ignored, so we add four but get three to make a triangle - geometryToolTile.addPointToGraph(5, 5); - geometryToolTile.addPointToGraph(10, 5); - geometryToolTile.addPointToGraph(15, 10); - geometryToolTile.addPointToGraph(20, 5); - geometryToolTile.getGraphPoint().last().dblclick({ force: true }); + geometryToolTile.getGeometryTile().click(); // select tile + geometryToolTile.clickGraphPosition(10, 5); + geometryToolTile.clickGraphPosition(15, 10); + geometryToolTile.clickGraphPosition(20, 5); + geometryToolTile.clickGraphPosition(10, 5); // close polygon + aa.clickArrowToolbarButton(); // sparrow mode on // 3 points + 3 segments + 1 polygon = 7 aa.getAnnotationButtons().should("have.length", 7); @@ -261,14 +263,17 @@ context('Arrow Annotations (Sparrows)', function () { aa.getAnnotationButtons().eq(6).click(); aa.getAnnotationArrows().should("have.length", 1); aa.getAnnotationDeleteButtons().eq(0).click(); + // Remove all the points and polygons aa.clickArrowToolbarButton(); // sparrow mode off + geometryToolTile.getGeometryTile().click(); // select tile + clueCanvas.clickToolbarButton('geometry', 'select'); // switch to select mode geometryToolTile.getGraphPoint().eq(2).click(); - geometryToolTile.deleteGraphElement(); + clueCanvas.clickToolbarButton('geometry', 'delete'); geometryToolTile.getGraphPoint().eq(1).click(); - geometryToolTile.deleteGraphElement(); + clueCanvas.clickToolbarButton('geometry', 'delete'); geometryToolTile.getGraphPoint().eq(0).click(); - geometryToolTile.deleteGraphElement(); + clueCanvas.clickToolbarButton('geometry', 'delete'); aa.getAnnotationButtons().should("have.length", 0); aa.getAnnotationArrows().should("have.length", 0); diff --git a/cypress/e2e/functional/tile_tests/geometry_table_integraton_test_spec.js b/cypress/e2e/functional/tile_tests/geometry_table_integraton_test_spec.js index cf831a9063..3f692b55b3 100644 --- a/cypress/e2e/functional/tile_tests/geometry_table_integraton_test_spec.js +++ b/cypress/e2e/functional/tile_tests/geometry_table_integraton_test_spec.js @@ -3,14 +3,12 @@ import Canvas from '../../../support/elements/common/Canvas'; import ClueCanvas from '../../../support/elements/common/cCanvas'; import GeometryToolTile from '../../../support/elements/tile/GeometryToolTile'; import TableToolTile from '../../../support/elements/tile/TableToolTile'; -import TextToolTile from '../../../support/elements/tile/TextToolTile'; let resourcesPanel = new ResourcesPanel; const canvas = new Canvas; const clueCanvas = new ClueCanvas; const geometryToolTile = new GeometryToolTile; const tableToolTile = new TableToolTile; -const textToolTile = new TextToolTile; const x = ['3', '7', '6', '0']; const y = ['2.5', '5', '1', '0']; @@ -40,7 +38,6 @@ context('Geometry Table Integration', function () { tableToolTile.getTableCell().eq(17).click(); }); clueCanvas.addTile('geometry'); - textToolTile.deleteTextTile(); cy.log('verify correct geometry tile names appear in selection list'); tableToolTile.getTableTile().click(); @@ -61,13 +58,12 @@ context('Geometry Table Integration', function () { geometryToolTile.getGeometryTile().siblings(clueCanvas.linkIconEl()).children('svg').attribute('data-indicator-width').should('exist'); geometryToolTile.getGraph().should('have.class', 'is-linked'); - cy.log('verify points added has label in table and geometry'); + cy.log('verify points added not labeled by default'); tableToolTile.getIndexNumberToggle().should('exist').click({ force: true }); tableToolTile.getTableIndexColumnCell().first().should('contain', '1'); - geometryToolTile.getGraphPointLabel().contains('A').should('exist'); - geometryToolTile.getGraphPointLabel().contains('B').should('exist'); - geometryToolTile.getGraphPointLabel().contains('C').should('exist'); - geometryToolTile.getGraphPointLabel().contains('D').should('exist'); + geometryToolTile.getGeometryTile().click(); + geometryToolTile.getGraphPointLabel().should('have.length', 2); // just x and y labels + geometryToolTile.getGraphPointLabel().contains('A').should('not.exist'); cy.log('verify table can be linked to two geometry tiles'); clueCanvas.addTile('geometry'); @@ -80,7 +76,7 @@ context('Geometry Table Integration', function () { geometryToolTile.getGraph().last().should('not.have.class', 'is-linked'); cy.log('verify point no longer has p1 in table and geometry'); - geometryToolTile.getGraphPointLabel().contains('A').should('have.length', 1); + geometryToolTile.getGraphPointLabel().contains('A').should('not.exist'); clueCanvas.deleteTile('geometry'); }); @@ -152,10 +148,10 @@ context('Geometry Table Integration', function () { cy.log('normal geometry interactions'); cy.log('will add a polygon directly onto the geometry'); geometryToolTile.getGeometryTile().click(); - geometryToolTile.addPointToGraph(10, 10); //not sure why this isn't appearing - geometryToolTile.addPointToGraph(10, 10); - geometryToolTile.addPointToGraph(15, 10); - geometryToolTile.addPointToGraph(10, 5); + geometryToolTile.clickGraphPosition(10, 10); //not sure why this isn't appearing + geometryToolTile.clickGraphPosition(10, 10); + geometryToolTile.clickGraphPosition(15, 10); + geometryToolTile.clickGraphPosition(10, 5); geometryToolTile.getGraphPoint().last().click({ force: true }).click({ force: true }); cy.log('will add an angle to a point created from a table'); @@ -186,7 +182,6 @@ context('Geometry Table Integration', function () { tableToolTile.getTableCell().eq(9).click(); }); clueCanvas.addTile('geometry'); - textToolTile.deleteTextTile(); cy.linkTableToTile('Table Data 1', "Shapes Graph 1"); // Open the document on the left, then create a new document on the right diff --git a/cypress/e2e/functional/tile_tests/geometry_tool_spec.js b/cypress/e2e/functional/tile_tests/geometry_tool_spec.js index 36b7010b42..566c6138d3 100644 --- a/cypress/e2e/functional/tile_tests/geometry_tool_spec.js +++ b/cypress/e2e/functional/tile_tests/geometry_tool_spec.js @@ -3,14 +3,12 @@ import ClueCanvas from '../../../support/elements/common/cCanvas'; import PrimaryWorkspace from '../../../support/elements/common/PrimaryWorkspace'; import ResourcePanel from '../../../support/elements/common/ResourcesPanel'; import GeometryToolTile from '../../../support/elements/tile/GeometryToolTile'; -import TextToolTile from '../../../support/elements/tile/TextToolTile'; const canvas = new Canvas; const clueCanvas = new ClueCanvas; const geometryToolTile = new GeometryToolTile; const primaryWorkspace = new PrimaryWorkspace; const resourcePanel = new ResourcePanel; -const textToolTile = new TextToolTile; const problemDoc = 'QA 1.1 Solving a Mystery with Proportional Reasoning'; const ptsDoc = 'Points'; @@ -24,23 +22,24 @@ function beforeTest() { } context('Geometry Tool', function () { - it('will test adding points to a geometry', function () { + it('will test basic geometry functions', function () { beforeTest(); cy.log("add a point to the origin"); clueCanvas.addTile('geometry'); - geometryToolTile.addPointToGraph(0, 0); + clueCanvas.clickToolbarButton('geometry', 'point'); + geometryToolTile.clickGraphPosition(0, 0); geometryToolTile.getGraphPointCoordinates().should('exist'); cy.log("add points to a geometry"); canvas.createNewExtraDocumentFromFileMenu(ptsDoc, "my-work"); clueCanvas.addTile('geometry'); cy.get('.spacer').click(); - textToolTile.deleteTextTile(); geometryToolTile.getGeometryTile().last().click(); - geometryToolTile.addPointToGraph(5, 5); - geometryToolTile.addPointToGraph(10, 5); - geometryToolTile.addPointToGraph(10, 10); + clueCanvas.clickToolbarButton('geometry', 'point'); + geometryToolTile.clickGraphPosition(5, 5); + geometryToolTile.clickGraphPosition(10, 5); + geometryToolTile.clickGraphPosition(10, 10); cy.log("copy a point to the clipboard"); let clipSpy; @@ -94,6 +93,156 @@ context('Geometry Tool', function () { geometryToolTile.getGraphTitle().should("contain", newName); geometryToolTile.getGraphPoint().should('have.length', 3); + + // Zoom in and out, fit + geometryToolTile.getGraphTileTitle().click(); + geometryToolTile.getGraphAxisTickLabels().last().should("have.text", "10"); + clueCanvas.clickToolbarButton('geometry', 'zoom-in'); + clueCanvas.clickToolbarButton('geometry', 'zoom-in'); + geometryToolTile.getGraphAxisTickLabels().last().should("have.text", "8"); + clueCanvas.clickToolbarButton('geometry', 'fit-all'); + geometryToolTile.getGraphAxisTickLabels().last().should("have.text", "10"); + clueCanvas.clickToolbarButton('geometry', 'zoom-out'); + clueCanvas.clickToolbarButton('geometry', 'zoom-out'); + geometryToolTile.getGraphAxisTickLabels().last().should("have.text", "15"); + }); + + it('works in all three modes', () => { + beforeTest(); + clueCanvas.addTile('geometry'); + geometryToolTile.getGraph().should("exist"); + + cy.log("add points with points mode"); + clueCanvas.clickToolbarButton('geometry', 'point'); + clueCanvas.toolbarButtonIsSelected('geometry', 'point'); + geometryToolTile.getGraph().trigger('mousemove'); + geometryToolTile.getGraphPoint().should("have.length", 1); // phantom point + geometryToolTile.clickGraphPosition(1, 1); + geometryToolTile.clickGraphPosition(2, 2); + geometryToolTile.getGraphPoint().should("have.length", 3); + + // Duplicate point + geometryToolTile.selectGraphPoint(1, 1); + clueCanvas.clickToolbarButton('geometry', 'duplicate'); + geometryToolTile.getGraph().trigger('mousemove'); // get phantom point back onto canvas after toolbar use + geometryToolTile.getGraphPoint().should("have.length", 4); + + // Delete point + geometryToolTile.getGraphPoint().eq(2).click(); + clueCanvas.clickToolbarButton('geometry', 'delete'); + geometryToolTile.getGraph().trigger('mousemove'); + geometryToolTile.getGraphPoint().should("have.length", 3); + + cy.log("select points with select mode"); + clueCanvas.clickToolbarButton('geometry', 'select'); + clueCanvas.toolbarButtonIsSelected('geometry', 'select'); + geometryToolTile.getGraph().trigger('mousemove'); + geometryToolTile.getGraphPoint().should("have.length", 2); // no phantom point + + // Clicking background should NOT create a point. + geometryToolTile.clickGraphPosition(3, 3); + geometryToolTile.getGraphPoint().should("have.length", 2); // same as before + + geometryToolTile.getSelectedGraphPoint().should("have.length", 0); + + // select one point + geometryToolTile.selectGraphPoint(1, 1); + geometryToolTile.getGraphPoint().eq(0).should("have.attr", "fill", "#0069ff"); // $data-blue + geometryToolTile.getSelectedGraphPoint().should("have.length", 1); + // set label options + geometryToolTile.getGraphPointLabel().contains('A').should('not.exist'); + clueCanvas.clickToolbarButton('geometry', 'label'); + geometryToolTile.chooseLabelOption('label'); + geometryToolTile.getGraphPointLabel().contains('A').should('exist'); + clueCanvas.clickToolbarButton('geometry', 'label'); + geometryToolTile.chooseLabelOption('length'); + geometryToolTile.getGraphPointLabel().contains('A').should('not.exist'); + geometryToolTile.getGraphPointLabel().contains('1.00, 1.00').should('not.exist'); + + // select a different point + geometryToolTile.selectGraphPoint(2, 2); + geometryToolTile.getSelectedGraphPoint().should("have.length", 1); + // use shift to select both points + geometryToolTile.selectGraphPoint(1, 1, true); + geometryToolTile.getSelectedGraphPoint().should("have.length", 2); + + clueCanvas.clickToolbarButton('geometry', 'delete'); + geometryToolTile.getGraphPoint().should("have.length", 0); + + cy.log("make a polygon with polygon mode"); + clueCanvas.clickToolbarButton('geometry', 'polygon'); + clueCanvas.toolbarButtonIsSelected('geometry', 'polygon'); + geometryToolTile.getGraph().trigger('mousemove'); + geometryToolTile.getGraphPoint().should("have.length", 1); // phantom point + geometryToolTile.clickGraphPosition(5, 5); + geometryToolTile.getGraphPoint().should("have.length", 2); + geometryToolTile.clickGraphPosition(10, 5); + geometryToolTile.getGraphPoint().should("have.length", 3); + geometryToolTile.clickGraphPosition(10, 10); + geometryToolTile.getGraphPoint().should("have.length", 4); + geometryToolTile.clickGraphPosition(5, 5); // click first point again to close polygon. + geometryToolTile.getGraphPoint().should("have.length", 4); + geometryToolTile.getGraphPolygon().should("have.length", 1); + + // Create vertex angle + geometryToolTile.getGraphPointLabel().contains('90°').should('not.exist'); + clueCanvas.clickToolbarButton('geometry', 'select'); + geometryToolTile.selectGraphPoint(10, 5); // this point is a 90 degree angle + clueCanvas.clickToolbarButton('geometry', 'label'); + geometryToolTile.toggleAngleCheckbox(); + geometryToolTile.getGraphPointLabel().contains('90°').should('exist'); + + // Label the polygon + geometryToolTile.getGraphPolygon().click(50, 50, { force: true, }); + geometryToolTile.getSelectedGraphPoint().should('have.length', 3); + geometryToolTile.getGraphPointLabel().contains('12.').should('not.exist'); + geometryToolTile.getGraphPointLabel().contains('ABC').should('not.exist'); + clueCanvas.clickToolbarButton('geometry', 'label'); + geometryToolTile.getModalTitle().should('contain.text', 'Polygon Label/Value'); + geometryToolTile.chooseLabelOption('length'); + geometryToolTile.getGraphPointLabel().contains('12.').should('exist'); + clueCanvas.clickToolbarButton('geometry', 'label'); + geometryToolTile.getModalLabelInput().should('have.value', 'ABC'); + geometryToolTile.chooseLabelOption('label'); + geometryToolTile.getGraphPointLabel().contains('12.').should('not.exist'); + geometryToolTile.getGraphPointLabel().contains('ABC').should('exist'); + clueCanvas.clickToolbarButton('geometry', 'label'); + geometryToolTile.chooseLabelOption('none'); + geometryToolTile.clickGraphPosition(0, 0); // deselect polygon + + // Label a segment + geometryToolTile.getGraphPointLabel().contains('AB').should('not.exist'); + geometryToolTile.getGraphLine().should('have.length', 5); // 0-1 = axis lines, 2-4 = triangle + geometryToolTile.getGraphLine().eq(4).click({ force: true }); + clueCanvas.clickToolbarButton('geometry', 'label'); + geometryToolTile.getModalTitle().should('contain.text', 'Segment Label/Value'); + geometryToolTile.chooseLabelOption('label'); + geometryToolTile.getGraphPointLabel().contains('AB').should('exist'); + clueCanvas.clickToolbarButton('geometry', 'label'); + geometryToolTile.chooseLabelOption('length'); + geometryToolTile.getGraphPointLabel().contains('AB').should('not.exist'); + geometryToolTile.getGraphPointLabel().contains('5').should('exist'); + clueCanvas.clickToolbarButton('geometry', 'label'); + geometryToolTile.chooseLabelOption('none'); + geometryToolTile.getGraphPointLabel().contains('AB').should('not.exist'); + geometryToolTile.getGraphPointLabel().contains('5').should('not.exist'); + + // Duplicate polygon + clueCanvas.clickToolbarButton('geometry', 'select'); + geometryToolTile.selectGraphPoint(7, 6); // click middle of polygon to select it + geometryToolTile.getSelectedGraphPoint().should("have.length", 3); + clueCanvas.clickToolbarButton('geometry', 'duplicate'); + geometryToolTile.getGraphPolygon().should("have.length", 2); + geometryToolTile.getGraphPoint().should("have.length", 6); + geometryToolTile.getSelectedGraphPoint().should("have.length", 0); + + // Delete polygon + geometryToolTile.selectGraphPoint(7, 6); + geometryToolTile.getSelectedGraphPoint().should("have.length", 3); + clueCanvas.clickToolbarButton('geometry', 'delete'); + geometryToolTile.getGraphPolygon().should("have.length", 1); + geometryToolTile.getGraphPoint().should("have.length", 3); + geometryToolTile.getSelectedGraphPoint().should("have.length", 0); }); it('will test Geometry tile undo redo', () => { @@ -103,29 +252,23 @@ context('Geometry Tool', function () { // Creation - Undo/Redo clueCanvas.addTile('geometry'); geometryToolTile.getGraph().should("exist"); - textToolTile.getTextTile().should("exist"); clueCanvas.getUndoTool().should("not.have.class", "disabled"); clueCanvas.getRedoTool().should("have.class", "disabled"); clueCanvas.getUndoTool().click(); geometryToolTile.getGraph().should("not.exist"); - textToolTile.getTextTile().should("not.exist"); clueCanvas.getUndoTool().should("have.class", "disabled"); clueCanvas.getRedoTool().should("not.have.class", "disabled"); clueCanvas.getRedoTool().click(); geometryToolTile.getGraph().should("exist"); - textToolTile.getTextTile().should("exist"); clueCanvas.getUndoTool().should("not.have.class", "disabled"); clueCanvas.getRedoTool().should("have.class", "disabled"); // Deletion - Undo/Redo clueCanvas.deleteTile('geometry'); geometryToolTile.getGraph().should("not.exist"); - textToolTile.getTextTile().should("exist"); clueCanvas.getUndoTool().click(); geometryToolTile.getGraph().should("exist"); - textToolTile.getTextTile().should("exist"); clueCanvas.getRedoTool().click(); geometryToolTile.getGraph().should("not.exist"); - textToolTile.getTextTile().should("exist"); clueCanvas.getUndoTool().click(); cy.log("edit tile title"); @@ -144,6 +287,5 @@ context('Geometry Tool', function () { cy.log("verify delete geometry"); clueCanvas.deleteTile('geometry'); geometryToolTile.getGraph().should("not.exist"); - textToolTile.getTextTile().should("exist"); }); }); diff --git a/cypress/e2e/smoke/single_student_canvas_test.js b/cypress/e2e/smoke/single_student_canvas_test.js index 884bfcaa9f..99bff1ac36 100644 --- a/cypress/e2e/smoke/single_student_canvas_test.js +++ b/cypress/e2e/smoke/single_student_canvas_test.js @@ -102,7 +102,7 @@ context('single student functional test', () => { cy.log('adds a geometry tool'); clueCanvas.addTile('geometry'); geometryToolTile.getGeometryTile().should('exist'); - geometryToolTile.addPointToGraph(0, 0); + geometryToolTile.clickGraphPosition(0, 0); cy.log('adds an image tool'); clueCanvas.addTile('image'); diff --git a/cypress/support/elements/tile/GeometryToolTile.js b/cypress/support/elements/tile/GeometryToolTile.js index 64e0af8f3f..fdeba05630 100644 --- a/cypress/support/elements/tile/GeometryToolTile.js +++ b/cypress/support/elements/tile/GeometryToolTile.js @@ -58,6 +58,11 @@ class GeometryToolTile { return cy.get('.canvas-area .geometry-content .JXGtext').contains('y'); } } + // Returns all tick labels on both axes. The X-axis ones are first in the list. + getGraphAxisTickLabels(axis) { + return cy.get('.canvas-area .geometry-content .tick-label'); + } + getGraphPointCoordinates(index){ //This is the point coordinate text let x=0, y=0; @@ -69,12 +74,15 @@ class GeometryToolTile { return '"(' + this.transformToCoordinate('x',x) + ', ' + this.transformToCoordinate('y',y) + ')"'; }); } - getGraphPointLabel(){ //This is the letter label for a point - return cy.get('.geometry-content.editable .JXGtext'); + getGraphPointLabel(){ // This selects the letter labels for points as well as the x,y labels on the axes + return cy.get('.geometry-content.editable .JXGtext:visible'); } getGraphPoint(){ return cy.get('.geometry-content.editable ellipse[display="inline"]'); } + getSelectedGraphPoint() { + return cy.get('.geometry-content.editable ellipse[stroke-opacity="0.25"]'); + } hoverGraphPoint(x,y){ let transX=this.transformFromCoordinate('x', x), transY=this.transformFromCoordinate('y', y); @@ -82,11 +90,12 @@ class GeometryToolTile { this.getGraph().last() .trigger('mouseover',transX,transY); } - selectGraphPoint(x,y){ + selectGraphPoint(x, y, withShiftKey = false ){ let transX=this.transformFromCoordinate('x', x), transY=this.transformFromCoordinate('y', y); - this.getGraph().last().click(transX, transY, {force:true}); + this.getGraph().last() + .click(transX, transY, { force:true, shiftKey: withShiftKey }); } getGraphPointID(point){ return cy.get('.geometry-content.editable ellipse').eq(point) @@ -95,10 +104,13 @@ class GeometryToolTile { return id; }); } + getGraphLine(){ + return cy.get('.single-workspace .geometry-content.editable line'); + } getGraphPolygon(){ return cy.get('.single-workspace .geometry-content.editable polygon'); } - addPointToGraph(x,y){ + clickGraphPosition(x,y){ let transX=this.transformFromCoordinate('x', x), transY=this.transformFromCoordinate('y', y); @@ -110,6 +122,26 @@ class GeometryToolTile { getGraphToolMenuIcon(){ return cy.get('.geometry-menu-button'); } + + getModalTitle() { + return cy.get('.ReactModalPortal'); + } + + getModalLabelInput() { + return cy.get('.ReactModalPortal input[type=text]'); + } + + // Name should be something like 'none', 'label', or 'length' + chooseLabelOption(name) { + cy.get(`.ReactModalPortal input[value=${name}]`).click(); + cy.get('.ReactModalPortal button.default').click(); + } + + toggleAngleCheckbox(value) { + cy.get('.ReactModalPortal input#angle-checkbox').click(); + cy.get('.ReactModalPortal button.default').click(); + } + showAngle(){ cy.get('.single-workspace.primary-workspace .geometry-toolbar .button.angle-label').click({force: true}); } @@ -128,8 +160,5 @@ class GeometryToolTile { addComment(){ cy.get('.single-workspace.primary-workspace .geometry-toolbar .button.comment.enabled').click(); } - deleteGraphElement(){ - cy.get('.single-workspace.primary-workspace .geometry-toolbar .button.delete.enabled').click(); - } } export default GeometryToolTile; diff --git a/dependencies-notes.md b/dependencies-notes.md index b9d58be592..7b826284b2 100644 --- a/dependencies-notes.md +++ b/dependencies-notes.md @@ -17,16 +17,14 @@ Notes on dependencies, particularly reasons for not updating to their latest ver |Dependency |Current Version|Latest Version|Notes | |--------------------|---------------|--------------|-------------------------------------------------------------------------------------| -|@concord-consortium/jsxgraph|0.99.8-cc.1|1.4.4 |We have our own fork that (unfortunately) hasn't been updated for a long time. | |@concord-consortium/react-hook-form|3.0.0-cc.1|3.0.0|Had to create our own fork to update React `peerDependencies` for npm 8.11. Original appears to have been abandoned.| |@chakra-ui/react |1.8.9 |2.5.5 |Brought in with CODAP's Graph component. CODAP uses v2 but v2 requires React 18 | |chart.js |2.9.4 |3.9.1 |Major version not attempted; only used by Dataflow tile, which doesn't really use it.| |firebase |8.10.1 |9.9.3 |Version 9 requires substantial migration; attempted update with `compat` imports failed.| |immutable |3.8.2 |4.1.0 |Major version update not attempted; only required by legacy slate versions. | -|jsxgraph |1.4.4 |1.4.5 |1.4.5 broke scaled rendering, e.g. in 4-up views | |mob-state-tree |5.1.5-cc.1 |5.1.6 |We are using a concord fork which fixes a bug. Additionally latest version changes TS types for arrays which broke a number of our models.| |nanoid |3.3.4 |4.0.0 |v4 switched to ESM and dependencies such as postcss break with v4 | -|netlify-cms-app |2.15.72 |2.15.72 |Requires React 16 or 17. Blocks upgrade to React 18. +|netlify-cms-app |2.15.72 |2.15.72 |Requires React 16 or 17. Blocks upgrade to React 18. | |react |17.0.2 |18.2.0 |React 18 | |react-chartjs-2 |2.11.2 |4.3.1 |Major version update not attempted; may not be used any more (was used by Dataflow) | |react-data-grid |7.0.0-canary.46|7.0.0-beta.16 |Canary.47 changed the RowFormatter props requiring some additional refactoring. Note that `beta` versions come after `canary` versions. We are patching react-data-grid and our patch only applies to 7.0.0-canary.46| diff --git a/docs/unit-configuration.md b/docs/unit-configuration.md index d6f2f772f4..b90567438f 100644 --- a/docs/unit-configuration.md +++ b/docs/unit-configuration.md @@ -159,7 +159,7 @@ Uses common toolbar framework. Default buttons: - `stroke-color` - `fill-color` - `text` -- `image-upload` +- `upload` - `group` - `ungroup` - `duplicate` @@ -177,7 +177,21 @@ In addition, if shared variables are configured in unit, it supports additional #### Geometry (Shapes Graph) -Not updated to common toolbar framework and does not support toolbar configuration. +Common toolbar framework. Default buttons: + +- `select`: mode for selecting and moving objects +- `point`: mode for creating points +- `polygon`: mode for creating polygons +- `upload`: allows uploading an image to display in the background +- `duplicate`: copies the currently selected objects +- `label`: opens dialog to choose the type of label for selected object +- `add-data`: link or unlink from a dataset +- `delete`: delete the currently selected objects + +Available buttons not in default set: + +- `comment`: adds a text callout to the currently selected object +- `movable-line`: creates a line that can be positioned #### Graph diff --git a/package-lock.json b/package-lock.json index 25bf63dcb4..f574f52cd9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,7 @@ "immutable": "^4.3.0", "initials": "^3.1.2", "json-stringify-pretty-compact": "^4.0.0", - "jsxgraph": "1.4.4", + "jsxgraph": "1.9.2", "jwt-decode": "^3.1.2", "lodash": "^4.17.21", "mathlive": "^0.94.6", @@ -16734,8 +16734,9 @@ } }, "node_modules/jsxgraph": { - "version": "1.4.4", - "license": "(MIT OR LGPL-3.0-or-later)", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/jsxgraph/-/jsxgraph-1.9.2.tgz", + "integrity": "sha512-vaZe7PRY6lCtLHzDQJUEZj7qJhi58aXMvZN8eP2U2955y1y13myphDaQjsHuNuj2mdlANohtFzz/bdoifebV+g==", "engines": { "node": ">=0.6.0" } @@ -34120,7 +34121,9 @@ } }, "jsxgraph": { - "version": "1.4.4" + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/jsxgraph/-/jsxgraph-1.9.2.tgz", + "integrity": "sha512-vaZe7PRY6lCtLHzDQJUEZj7qJhi58aXMvZN8eP2U2955y1y13myphDaQjsHuNuj2mdlANohtFzz/bdoifebV+g==" }, "jwa": { "version": "2.0.0", diff --git a/package.json b/package.json index 6c4015fd07..71ff14d4e1 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ }, "transformIgnorePatterns": [ "/comments/ESM-only (https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c) modules that should not be transformed by ts-jest", - "/node_modules/(?!(d3|d3-(.+)|delaunator|escape-string-regexp|internmap|json-stringify-pretty-compact|nanoid|robust-predicates)/)" + "/node_modules/(?!(d3|d3-(.+)|delaunator|escape-string-regexp|internmap|json-stringify-pretty-compact|jsxgraph|nanoid|robust-predicates)/)" ], "coveragePathIgnorePatterns": [ "/node_modules/", @@ -244,7 +244,7 @@ "immutable": "^4.3.0", "initials": "^3.1.2", "json-stringify-pretty-compact": "^4.0.0", - "jsxgraph": "1.4.4", + "jsxgraph": "1.9.2", "jwt-decode": "^3.1.2", "lodash": "^4.17.21", "mathlive": "^0.94.6", diff --git a/src/assets/rotate-selection.svg b/src/assets/rotate-selection.svg new file mode 100644 index 0000000000..fc505c66d7 --- /dev/null +++ b/src/assets/rotate-selection.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/clue/app-config.json b/src/clue/app-config.json index f635dcc502..67618182e1 100644 --- a/src/clue/app-config.json +++ b/src/clue/app-config.json @@ -285,6 +285,24 @@ "delete" ] }, + "geometry": { + "tools": [ + "select", + "point", + "polygon", + "upload", + "duplicate", + "label", + "|", + "add-data", + "|", + "zoom-in", + "zoom-out", + "fit-all", + "|", + "delete" + ] + }, "graph": { "emptyPlotIsNumeric": true, "scalePlotOnValueChange": true, diff --git a/src/plugins/diagram-viewer/src/assets/fit-view-icon.svg b/src/clue/assets/icons/fit-view-icon.svg similarity index 100% rename from src/plugins/diagram-viewer/src/assets/fit-view-icon.svg rename to src/clue/assets/icons/fit-view-icon.svg diff --git a/src/clue/assets/icons/geometry/add-image-icon.svg b/src/clue/assets/icons/geometry/add-image-icon.svg new file mode 100644 index 0000000000..43a2d94073 --- /dev/null +++ b/src/clue/assets/icons/geometry/add-image-icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/clue/assets/icons/geometry/angle-label.svg b/src/clue/assets/icons/geometry/angle-label.svg deleted file mode 100644 index 536223b176..0000000000 --- a/src/clue/assets/icons/geometry/angle-label.svg +++ /dev/null @@ -1,11 +0,0 @@ - - Artboard 1 - - - - - - - - - diff --git a/src/clue/assets/icons/geometry/copy-polygon.svg b/src/clue/assets/icons/geometry/copy-polygon.svg deleted file mode 100644 index 22e486b3ae..0000000000 --- a/src/clue/assets/icons/geometry/copy-polygon.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - diff --git a/src/clue/assets/icons/geometry/line-label.svg b/src/clue/assets/icons/geometry/line-label.svg deleted file mode 100644 index 2d5bf8317a..0000000000 --- a/src/clue/assets/icons/geometry/line-label.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/clue/assets/icons/geometry/point-icon.svg b/src/clue/assets/icons/geometry/point-icon.svg new file mode 100644 index 0000000000..31db530f7c --- /dev/null +++ b/src/clue/assets/icons/geometry/point-icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/clue/assets/icons/geometry/polygon-icon.svg b/src/clue/assets/icons/geometry/polygon-icon.svg new file mode 100644 index 0000000000..94d2033b52 --- /dev/null +++ b/src/clue/assets/icons/geometry/polygon-icon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/clue/assets/icons/geometry/shapes-duplicate-icon.svg b/src/clue/assets/icons/geometry/shapes-duplicate-icon.svg new file mode 100644 index 0000000000..9e49028995 --- /dev/null +++ b/src/clue/assets/icons/geometry/shapes-duplicate-icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/clue/assets/icons/shapes-label-value-icon.svg b/src/clue/assets/icons/shapes-label-value-icon.svg new file mode 100644 index 0000000000..5c99b86964 --- /dev/null +++ b/src/clue/assets/icons/shapes-label-value-icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/plugins/diagram-viewer/src/assets/zoom-in-icon.svg b/src/clue/assets/icons/zoom-in-icon.svg similarity index 100% rename from src/plugins/diagram-viewer/src/assets/zoom-in-icon.svg rename to src/clue/assets/icons/zoom-in-icon.svg diff --git a/src/plugins/diagram-viewer/src/assets/zoom-out-icon.svg b/src/clue/assets/icons/zoom-out-icon.svg similarity index 100% rename from src/plugins/diagram-viewer/src/assets/zoom-out-icon.svg rename to src/clue/assets/icons/zoom-out-icon.svg diff --git a/src/clue/data-colors.scss b/src/clue/data-colors.scss new file mode 100644 index 0000000000..a4b4f0f7ad --- /dev/null +++ b/src/clue/data-colors.scss @@ -0,0 +1,20 @@ +// These colors are used for data points in the Graph and Geometry tiles +// The export allows them to be referenced in Javascript (via data-colors.scss.d.ts) + +$data-blue: #0069ff; +$data-orange: #ff9617; +$data-green : #19a90f; +$data-red: #ee0000; +$data-yellow: #cbd114; +$data-purple: #d51eff; +$data-indigo: #6b00d2; + +:export { + dataBlue: $data-blue; + dataOrange: $data-orange; + dataGreen: $data-green; + dataRed: $data-red; + dataYellow: $data-yellow; + dataPurple: $data-purple; + dataIndigo: $data-indigo; +} diff --git a/src/clue/data-colors.scss.d.ts b/src/clue/data-colors.scss.d.ts new file mode 100644 index 0000000000..963306484d --- /dev/null +++ b/src/clue/data-colors.scss.d.ts @@ -0,0 +1,17 @@ +// Colors used across multiple tiles for plotting data +// Actual color values are in the SCSS file. +// cf. https://mattferderer.com/use-sass-variables-in-typescript-and-javascript + +export interface IClueDataColors { + dataBlue: string; + dataOrange: string; + dataGreen: string; + dataRed: string; + dataYellow: string; + dataPurple: string; + dataIndigo: string; +} + +export const clueDataColors: IClueDataColors; + +export default clueDataColors; diff --git a/src/components/document/document-content.tsx b/src/components/document/document-content.tsx index 5c43c0d70c..ac87828188 100644 --- a/src/components/document/document-content.tsx +++ b/src/components/document/document-content.tsx @@ -419,10 +419,7 @@ export class DocumentContentComponent extends BaseComponent { const { toolId, title } = createTileInfo; const insertRowInfo = this.getDropRowInfo(e); - const isInsertingInExistingRow = insertRowInfo?.rowDropLocation && - (["left", "right"].indexOf(insertRowInfo.rowDropLocation) >= 0); - const addSidecarNotes = (toolId.toLowerCase() === "geometry") && !isInsertingInExistingRow; - const rowTile = content.userAddTile(toolId, {title, addSidecarNotes, insertRowInfo}); + const rowTile = content.userAddTile(toolId, {title, insertRowInfo}); if (rowTile?.tileId) { ui.setSelectedTileId(rowTile.tileId); diff --git a/src/components/tiles/geometry/axis-settings-dialog.scss b/src/components/tiles/geometry/axis-settings-dialog.scss deleted file mode 100644 index bec7f4a7be..0000000000 --- a/src/components/tiles/geometry/axis-settings-dialog.scss +++ /dev/null @@ -1,31 +0,0 @@ - -.axis-settings-container { - margin-top: 15px; - margin-bottom: 0px; - - .axis-title { - font-size: 12pt; - font-weight: bold; - margin-top: 10px; - margin-bottom: 10px; - } - - .axis-options { - display: flex; - - .axis-option { - display: flex; - flex-direction: column; - } - } -} - -.axis-settings-label { - padding: 1em; - font-size: 12pt; - padding: .5em; -} - -.input-margin { - margin: 0 .5em; -} diff --git a/src/components/tiles/geometry/axis-settings-dialog.tsx b/src/components/tiles/geometry/axis-settings-dialog.tsx deleted file mode 100644 index d00ff5ebc3..0000000000 --- a/src/components/tiles/geometry/axis-settings-dialog.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React, { useEffect } from "react"; -import { IAxesParams } from "../../../models/tiles/geometry/geometry-content"; -import { useAxisSettingsDialog } from "./use-axis-settings-dialog"; - -interface IProps { - board: JXG.Board; - onAccept: (params: IAxesParams) => void; - onClose: () => void; -} - -// Component wrapper for useAxisSettingsDialog() for use by class components. -const AxisSettingsDialog: React.FC = ({ - board, onAccept, onClose -}: IProps) => { - - const [showDialog, hideDialog] = useAxisSettingsDialog({ - board, - onAccept, - onClose - }); - - useEffect(() => { - showDialog(); - return () => hideDialog(); - }, [hideDialog, showDialog]); - - return null; -}; -export default AxisSettingsDialog; diff --git a/src/components/tiles/geometry/geometry-constants.ts b/src/components/tiles/geometry/geometry-constants.ts index 0df2e458d4..d424cd406f 100644 --- a/src/components/tiles/geometry/geometry-constants.ts +++ b/src/components/tiles/geometry/geometry-constants.ts @@ -1,3 +1,4 @@ export const pointBoundingBoxSize = 14; export const pointButtonRadius = 9; export const segmentButtonWidth = 4; +export const zoomFactor = 1.25; diff --git a/src/components/tiles/geometry/geometry-content-wrapper.tsx b/src/components/tiles/geometry/geometry-content-wrapper.tsx index 85fe0a460b..2f4e437a5e 100644 --- a/src/components/tiles/geometry/geometry-content-wrapper.tsx +++ b/src/components/tiles/geometry/geometry-content-wrapper.tsx @@ -6,7 +6,6 @@ import { useMeasureText } from "../hooks/use-measure-text"; import { GeometryContentComponent, IGeometryContentProps } from "./geometry-content"; interface IProps extends IGeometryContentProps{ - isLinkButtonEnabled: boolean; readOnly?: boolean; } export const GeometryContentWrapper: React.FC = (props) => { diff --git a/src/components/tiles/geometry/geometry-content.tsx b/src/components/tiles/geometry/geometry-content.tsx index f71255b564..affc9cf8c0 100644 --- a/src/components/tiles/geometry/geometry-content.tsx +++ b/src/components/tiles/geometry/geometry-content.tsx @@ -1,15 +1,14 @@ -import { castArray, each, filter, find, keys as _keys, throttle, values } from "lodash"; -import { observe, reaction } from "mobx"; +import React from "react"; +import { castArray, each, find, isEqual, keys as _keys, throttle, values } from "lodash"; +import { IObjectDidChange, observable, observe, reaction, runInAction } from "mobx"; import { inject, observer } from "mobx-react"; import { getSnapshot, onSnapshot } from "mobx-state-tree"; import objectHash from "object-hash"; -import React from "react"; import { SizeMeProps } from "react-sizeme"; -import { pointBoundingBoxSize, pointButtonRadius, segmentButtonWidth } from "./geometry-constants"; +import { pointBoundingBoxSize, pointButtonRadius, segmentButtonWidth, zoomFactor } from "./geometry-constants"; import { BaseComponent } from "../../base"; import { DocumentContentModelType } from "../../../models/document/document-content"; -import { getTableLinkColors } from "../../../models/tiles/table-links"; import { IGeometryProps, IActionHandlers } from "./geometry-shared"; import { GeometryContentModelType, IAxesParams, isGeometryContentReady, setElementColor @@ -19,54 +18,56 @@ import { cloneGeometryObject, GeometryObjectModelType, isPointModel, pointIdsFromSegmentId, PointModelType, PolygonModelType } from "../../../models/tiles/geometry/geometry-model"; import { copyCoords, getEventCoords, getAllObjectsUnderMouse, getClickableObjectUnderMouse, - isDragTargetOrAncestor } from "../../../models/tiles/geometry/geometry-utils"; + isDragTargetOrAncestor, + getPolygon, + logGeometryEvent, + getPoint, + getBoardObject, + findBoardObject} from "../../../models/tiles/geometry/geometry-utils"; import { RotatePolygonIcon } from "./rotate-polygon-icon"; import { getPointsByCaseId } from "../../../models/tiles/geometry/jxg-board"; import { - ESegmentLabelOption, JXGCoordPair + ELabelOption, JXGCoordPair } from "../../../models/tiles/geometry/jxg-changes"; -import { applyChange, applyChanges } from "../../../models/tiles/geometry/jxg-dispatcher"; -import { kSnapUnit } from "../../../models/tiles/geometry/jxg-point"; +import { applyChange } from "../../../models/tiles/geometry/jxg-dispatcher"; +import { kSnapUnit, setPropertiesForLabelOption } from "../../../models/tiles/geometry/jxg-point"; import { getAssociatedPolygon, getPointsForVertexAngle, getPolygonEdges } from "../../../models/tiles/geometry/jxg-polygon"; import { - isAxis, isAxisLabel, isBoard, isComment, isFreePoint, isImage, isLine, isMovableLine, - isMovableLineControlPoint, isMovableLineLabel, isPoint, isPolygon, isVertexAngle, - isVisibleEdge, isVisibleMovableLine, isVisiblePoint, kGeometryDefaultPixelsPerUnit + isAxis, isComment, isImage, isLine, isLinkedPoint, isMovableLine, + isMovableLineControlPoint, isMovableLineLabel, isPoint, isPolygon, isRealVisiblePoint, isVertexAngle, + isVisibleEdge, isVisibleMovableLine, kGeometryDefaultPixelsPerUnit } from "../../../models/tiles/geometry/jxg-types"; import { getVertexAngle, updateVertexAngle, updateVertexAnglesFromObjects } from "../../../models/tiles/geometry/jxg-vertex-angle"; -import { getAllLinkedPoints, injectGetTableLinkColorsFunction } from "../../../models/tiles/geometry/jxg-table-link"; +import { createLinkedPoint, getAllLinkedPoints } from "../../../models/tiles/geometry/jxg-table-link"; import { extractDragTileType, kDragTileContent, kDragTileId, dragTileSrcDocId } from "../tile-component"; import { gImageMap, ImageMapEntry } from "../../../models/image-map"; import { ITileExportOptions } from "../../../models/tiles/tile-content-info"; import { getParentWithTypeName } from "../../../utilities/mst-utils"; -import { safeJsonParse, uniqueId } from "../../../utilities/js-utils"; +import { notEmpty, safeJsonParse, uniqueId } from "../../../utilities/js-utils"; import { hasSelectionModifier } from "../../../utilities/event-utils"; -import AxisSettingsDialog from "./axis-settings-dialog"; import { EditableTileTitle } from "../editable-tile-title"; import LabelSegmentDialog from "./label-segment-dialog"; import MovableLineDialog from "./movable-line-dialog"; import placeholderImage from "../../../assets/image_placeholder.png"; -import { LinkTableButton } from "./link-table-button"; import ErrorAlert from "../../utilities/error-alert"; import { halfPi, isFiniteNumber, normalizeAngle, Point } from "../../../utilities/math-utils"; import SingleStringDialog from "../../utilities/single-string-dialog"; import { getClipboardContent, pasteClipboardImage } from "../../../utilities/clipboard-utils"; import { TileTitleArea } from "../tile-title-area"; - -import "./geometry-tile.sass"; +import { GeometryTileContext } from "./geometry-tile-context"; +import LabelPointDialog from "./label-point-dialog"; +import LabelPolygonDialog from "./label-polygon-dialog"; export interface IGeometryContentProps extends IGeometryProps { onSetBoard: (board: JXG.Board) => void; onSetActionHandlers: (handlers: IActionHandlers) => void; onContentChange: () => void; - onLinkTileButtonClick?: () => void; } export interface IProps extends IGeometryContentProps, SizeMeProps { - isLinkButtonEnabled: boolean; measureText: (text: string) => number; } @@ -89,9 +90,10 @@ interface IState extends Mutable { redoStack: string[][]; selectedComment?: JXG.Text; selectedLine?: JXG.Line; + showPointLabelDialog?: boolean; showSegmentLabelDialog?: boolean; + showPolygonLabelDialog?: boolean; showInvalidTableDataAlert?: boolean; - axisSettingsOpen: boolean; } interface JXGPtrEvent { @@ -105,8 +107,6 @@ interface IDragPoint { snapToGrid?: boolean; } -injectGetTableLinkColorsFunction(getTableLinkColors); - interface IPasteContent { pasteId: string; isSameTile: boolean; @@ -118,11 +118,14 @@ let sInstanceId = 0; @inject("stores") @observer export class GeometryContentComponent extends BaseComponent { + static contextType = GeometryTileContext; + declare context: React.ContextType; + private updateObservable = observable({updateCount: 0}); + public state: IState = { size: { width: null, height: null }, disableRotate: false, redoStack: [], - axisSettingsOpen: false, }; private instanceId = ++sInstanceId; @@ -134,7 +137,6 @@ export class GeometryContentComponent extends BaseComponent { private lastBoardDown: JXGPtrEvent; private lastPointDown?: JXGPtrEvent; - private lastSelectDown?: any; private dragPts: { [id: string]: IDragPoint } = {}; private isVertexDrag: boolean; @@ -192,26 +194,39 @@ export class GeometryContentComponent extends BaseComponent { handlePaste: this.handlePaste, handleDuplicate: this.handleDuplicate, handleDelete: this.handleDelete, - handleToggleVertexAngle: this.handleToggleVertexAngle, - handleCreateLineLabel: this.handleCreateLineLabel, + handleLabelDialog: this.handleLabelDialog, handleCreateMovableLine: this.handleCreateMovableLine, handleCreateComment: this.handleCreateComment, - handleUploadImageFile: this.handleUploadBackgroundImage + handleUploadImageFile: this.handleUploadBackgroundImage, + handleZoomIn: this.handleZoomIn, + handleZoomOut: this.handleZoomOut, + handleFitAll: this.handleScaleToFit }; onSetActionHandlers(handlers); } } private getPointScreenCoords(pointId: string) { + if (!this.state.board) return; + const pt = getPoint(this.state.board, pointId); + if (!pt) return; + if (isLinkedPoint(pt)) { + return this.getLinkedPointScreenCoords(pointId); + } else { + return this.getLocalPointScreenCoords(pointId); + } + } + + private getLocalPointScreenCoords(pointId: string) { // Access the model to ensure that model changes trigger a rerender const p = this.getContent().getObject(pointId) as PointModelType; if (!p || p.x == null || p.y == null) return; if (!this.state.board) return; - const element = this.state.board?.objects[pointId]; + const element = getPoint(this.state.board, pointId); if (!element) return; - const bounds = element.bounds(); - const coords = new JXG.Coords(JXG.COORDS_BY_USER, bounds.slice(0, 2), this.state.board); + const [a, b] = element.bounds(); + const coords = new JXG.Coords(JXG.COORDS_BY_USER, [a, b], this.state.board); const point: Point = [coords.scrCoords[1], coords.scrCoords[2]]; return point; } @@ -220,16 +235,16 @@ export class GeometryContentComponent extends BaseComponent { if (!this.state.board) return; // Access the model to ensure that model changes trigger a rerender - const element = this.state.board?.objects[linkedPointId]; - if (!element) { console.log("didn't find", linkedPointId); return; } + const element = getBoardObject(this.state.board, linkedPointId); + if (!element) return; const dataSet = this.getContent().getLinkedDataset(element.getAttribute("linkedTableId"))?.dataSet; const caseIndex = dataSet?.caseIndexFromID(element.getAttribute("linkedRowId")); const yValue = caseIndex!==undefined && dataSet?.attrFromID(element.getAttribute("linkedColId")).numValue(caseIndex); if (!isFiniteNumber(yValue)) return; - const bounds = element.bounds(); - const coords = new JXG.Coords(JXG.COORDS_BY_USER, bounds.slice(0, 2), this.state.board); + const [a, b] = element.bounds(); + const coords = new JXG.Coords(JXG.COORDS_BY_USER, [a, b], this.state.board); const point: Point = [coords.scrCoords[1], coords.scrCoords[2]]; return point; } @@ -238,6 +253,11 @@ export class GeometryContentComponent extends BaseComponent { this._isMounted = true; this.disposers = []; + if (this.props.readOnly) { + // Points mode may be the default, but it shouldn't be for read-only tiles. + this.context.setMode("select"); + } + this.initializeContent(); this.props.onRegisterTileApi({ @@ -252,9 +272,13 @@ export class GeometryContentComponent extends BaseComponent { return this.getContent().exportJson(options); }, getObjectBoundingBox: (objectId: string, objectType?: string) => { + // This gets updated when the JSX board needs to be rebuilt + // eslint-disable-next-line unused-imports/no-unused-vars -- need to observe + const {updateCount} = this.updateObservable; + if (objectType === "point" || objectType === "linkedPoint") { const coords = objectType === "point" - ? this.getPointScreenCoords(objectId) + ? this.getLocalPointScreenCoords(objectId) : this.getLinkedPointScreenCoords(objectId); if (!coords) return undefined; const [x, y] = coords; @@ -310,7 +334,7 @@ export class GeometryContentComponent extends BaseComponent { if (objectType === "point" || objectType === "linkedPoint") { // Find the center point const coords = objectType === "point" - ? this.getPointScreenCoords(objectId) + ? this.getLocalPointScreenCoords(objectId) : this.getLinkedPointScreenCoords(objectId); if (!coords) return; @@ -369,11 +393,40 @@ export class GeometryContentComponent extends BaseComponent { this.disposers.push(onSnapshot(this.getContent(), () => { if (!this.suspendSnapshotResponse) { - this.destroyBoard(); - this.setState({ board: undefined }); - this.initializeBoard(); + if (this.state.board) { + this.destroyBoard(); + this.setState({ board: undefined }); + this.initializeBoard(); + } + } + })); + + // synchronize selection changes + this.disposers.push(observe(this.getContent().metadata.selection, (change: IObjectDidChange) => { + const { board: _board } = this.state; + if (_board) { + // this may be a shared selection change; get all points associated with it + const objs = getPointsByCaseId(_board, change.name.toString()); + const edges: JXG.Line[] = []; + objs.forEach(obj => { + if (change.type !== 'remove') { + setElementColor(_board, obj.id, change.newValue.value); + // Also find segments that are attached to the changed points + Object.values(obj.childElements).forEach(child => { + if(isVisibleEdge(child) && !edges.includes(child)) { + edges.push(child); + } + }); + } + }); + edges.forEach(edge => { + // Edge is selected if both end points are. + const selected = this.getContent().isSelected(edge.point1.id) && this.getContent().isSelected(edge.point2.id); + setElementColor(_board, edge.id, selected); + }); } })); + } private getButtonPath( @@ -429,6 +482,7 @@ export class GeometryContentComponent extends BaseComponent { geometryContent.updateScale(this.state.board, scale); } } + runInAction(() => this.updateObservable.updateCount++); } public componentWillUnmount() { @@ -444,25 +498,62 @@ export class GeometryContentComponent extends BaseComponent { this._isMounted = false; } + private handlePointerMove = (evt: any) => { + if (this.props.readOnly || this.context.mode === "select") return; + // Move phantom point to location of mouse pointer + const { board, content } = this.context; + if (!board || !content) return; + const usrCoords = getEventCoords(board, evt, this.props.scale).usrCoords; + if (usrCoords.length >= 2) { + const position: JXGCoordPair = [usrCoords[1], usrCoords[2]]; + this.applyChange(() => { + if (content.phantomPoint) { + content.setPhantomPointPosition(board, position); + } else { + content.addPhantomPoint(board, position, content.activePolygonId); + } + }); + const phantom = content.phantomPoint && getPoint(board, content.phantomPoint?.id); + phantom && updateVertexAnglesFromObjects([phantom]); + } + }; + + private handlePointerLeave = () => { + if (!this.context.board || this.props.readOnly || this.context.mode === "select") return; + const { board, content } = this.context; + if (board && content) { + content.clearPhantomPoint(board); + // Removing the phantom point from the polygon re-creates it, so we have to add the handlers again. + if (content.activePolygonId) { + const poly = getPolygon(board, content.activePolygonId); + poly && this.handleCreatePolygon(poly); + } + } + }; + public render() { const editableClass = this.props.readOnly ? "read-only" : "editable"; const isLinkedClass = this.getContent().isLinked ? "is-linked" : ""; const classes = `geometry-content ${editableClass} ${isLinkedClass}`; - return ([ - this.renderCommentEditor(), - this.renderLineEditor(), - this.renderSettingsEditor(), - this.renderSegmentLabelDialog(), -
this.domElement = elt} - onDragOver={this.handleDragOver} - onDragLeave={this.handleDragLeave} - onDrop={this.handleDrop} />, - this.renderRotateHandle(), - this.renderTitleArea(), - this.renderInvalidTableDataAlert() - ]); + return ( + <> + {this.renderCommentEditor()} + {this.renderLineEditor()} + {this.renderPolygonLabelDialog()} + {this.renderSegmentLabelDialog()} + {this.renderPointLabelDialog()} +
this.domElement = elt} + onMouseMove={this.handlePointerMove} + onMouseLeave={this.handlePointerLeave} + onDragOver={this.handleDragOver} + onDragLeave={this.handleDragLeave} + onDrop={this.handleDrop} />, + {this.renderRotateHandle()} + {this.renderTitleArea()} + {this.renderInvalidTableDataAlert()} + ); } private renderCommentEditor() { @@ -498,15 +589,22 @@ export class GeometryContentComponent extends BaseComponent { } } - private renderSettingsEditor() { - const { board, axisSettingsOpen } = this.state; - if (board && axisSettingsOpen) { + private renderPointLabelDialog() { + const content = this.getContent(); + const { board, showPointLabelDialog } = this.state; + if (board && showPointLabelDialog) { + const point = content.getOneSelectedPoint(board); + if (!point) return; + const handleClose = () => this.setState({ showPointLabelDialog: false }); + const handleAccept = (p: JXG.Point, labelOption: ELabelOption, name: string, angleLabel: boolean) => { + this.handleSetPointLabelOptions(p, labelOption, name, angleLabel); + }; return ( - ); } @@ -521,14 +619,13 @@ export class GeometryContentComponent extends BaseComponent { const polygon = segment && getAssociatedPolygon(segment); if (!polygon || !segment || (points.length !== 2)) return; const handleClose = () => this.setState({ showSegmentLabelDialog: false }); - const handleAccept = (poly: JXG.Polygon, pts: [JXG.Point, JXG.Point], labelOption: ESegmentLabelOption) => + const handleAccept = (poly: JXG.Polygon, pts: [JXG.Point, JXG.Point], labelOption: ELabelOption, name: string) => { - this.handleLabelSegment(poly, pts, labelOption); + this.handleLabelSegment(poly, pts, labelOption, name); handleClose(); }; return ( { } } + private renderPolygonLabelDialog() { + const content = this.getContent(); + const { board, showPolygonLabelDialog } = this.state; + if (board && showPolygonLabelDialog) { + const polygon = content.getOneSelectedPolygon(board); + if (!polygon) return; + const handleClose = () => this.setState({ showPolygonLabelDialog: false }); + const handleAccept = (poly: JXG.Polygon, labelOption: ELabelOption, name: string) => { + this.handleLabelPolygon(poly, labelOption, name); + handleClose(); + }; + return ( + + ); + } + } + private renderRotateHandle() { const { board, disableRotate } = this.state; const selectedPolygon = board && !disableRotate && !this.props.readOnly @@ -568,7 +687,6 @@ export class GeometryContentComponent extends BaseComponent { return ( {this.renderTitle()} - {this.renderTileLinkButton()} ); } @@ -582,13 +700,6 @@ export class GeometryContentComponent extends BaseComponent { ); } - private renderTileLinkButton() { - const { isLinkButtonEnabled, onLinkTileButtonClick } = this.props; - return (!this.state.isEditingTitle && !this.props.readOnly && - - ); - } - private renderInvalidTableDataAlert() { const { showInvalidTableDataAlert } = this.state; if (!showInvalidTableDataAlert) return; @@ -612,14 +723,15 @@ export class GeometryContentComponent extends BaseComponent { private async initializeBoard(): Promise { return new Promise((resolve, reject) => { isGeometryContentReady(this.getContent()).then(() => { - const board = this.getContent().initializeBoard(this.elementId, this.handleCreateElements); + const board = this.getContent() + .initializeBoard(this.elementId, this.handleCreateElements, + (b: JXG.Board) => this.syncLinkedGeometry(b)); if (board) { this.handleCreateBoard(board); const { url, filename } = this.getContent().bgImage || {}; if (url) { this.updateImageUrl(url, filename); } - this.syncLinkedGeometry(board); this.setState({ board }); resolve(board); } @@ -695,50 +807,102 @@ export class GeometryContentComponent extends BaseComponent { syncLinkedGeometry(_board?: JXG.Board) { const board = _board || this.state.board; if (!board) return; + const content = this.getContent(); + + // Make sure each linked dataset's attributes have colors assigned. + this.applyChange(() => { + content.linkedDataSets.forEach(link => { + link.dataSet.attributes.forEach(attr => { + content.assignColorSchemeForAttributeId(attr.id); + }); + }); + }); + + this.updateSharedPoints(board); + } + + /** + * Update/add/remove linked points to matched what is in shared data sets. + * + * @param board + */ + updateSharedPoints(board: JXG.Board) { + this.applyChange(() => { + let pointsAdded = false; + const content = this.getContent(); + const data = content.getLinkedPointsData(); + const remainingIds = getAllLinkedPoints(board); + for (const [link, points] of data.entries()) { + // Loop through points, adding new ones and updating any that need to be moved. + for (let i=0; i 0){ + applyChange(board, { operation: "delete", target: "linkedPoint", targetID: remainingIds }); + } - // identify objects that exist in the model but not in JSXGraph - const modelObjectsToConvert: GeometryObjectModelType[] = []; - this.getContent().objects.forEach(obj => { - if (!board.objects[obj.id]) { - modelObjectsToConvert.push(obj); + if (pointsAdded) { + this.scaleToFit(); } }); + } - if (modelObjectsToConvert.length > 0) { - const changesToApply = convertModelObjectsToChanges(modelObjectsToConvert); - applyChanges(board, changesToApply); - } + private handleZoomIn = () => { + const { board } = this.state; + const content = this.getContent(); + if (!board || !content) return; + content.zoomBoard(board, zoomFactor); + logGeometryEvent(content, "update", "board", undefined, { userAction: "zoom in" }); + }; + private handleZoomOut = () => { + const { board } = this.state; + const content = this.getContent(); + if (!board || !content) return; + content.zoomBoard(board, 1/zoomFactor); + logGeometryEvent(content, "update", "board", undefined, { userAction: "zoom out" }); + }; + + private handleScaleToFit = () => { + const content = this.getContent(); + logGeometryEvent(content, "update", "board", undefined, { userAction: "fit all" }); + this.scaleToFit(); + }; + + private scaleToFit = () => { + const { board } = this.state; + if (!board || this.props.readOnly) return; const extents = this.getBoardPointsExtents(board); this.rescaleBoardAndAxes(extents); - } - - // remove/recreate all linked points - // Shared points are deleted, and in the process, so are the polygons that depend on them - // This is built into JSXGraph's Board#removeObject function, which descends through and deletes all children: - // https://github.com/jsxgraph/jsxgraph/blob/60a2504ed66b8c6fea30ef67a801e86877fb2e9f/src/base/board.js#L4775 - // Ids persist in their recreation because they are ultimately derived from canonical values - // NOTE: A more tailored response would match up the existing points with the data set and only - // change the affected points, which would eliminate some visual flashing that occurs when - // unchanged points are re-created and would allow derived polygons to be preserved rather than created anew. - recreateSharedPoints(board: JXG.Board){ - const ids = getAllLinkedPoints(board); - if (ids.length > 0){ - applyChange(board, { operation: "delete", target: "linkedPoint", targetID: ids }); - } - const data = this.getContent().getLinkedPointsData(); - for (const [link,points] of data.entries()) { - const pts = applyChange(board, { - operation: "create", - target: "linkedPoint", - parents: points.coords, - properties: points.properties, - links: { tileIds: [link]} }); - castArray(pts || []).forEach(pt => !isBoard(pt) && this.handleCreateElements(pt)); - } - } + }; private handleArrowKeys = (e: React.KeyboardEvent, keys: string) => { const { board } = this.state; @@ -760,33 +924,58 @@ export class GeometryContentComponent extends BaseComponent { return hasSelectedPoints; }; - private handleToggleVertexAngle = () => { - const { board } = this.state; - const selectedObjects = board && this.getContent().selectedObjects(board); - const selectedPoints = selectedObjects?.filter(isPoint); - const selectedPoint = selectedPoints?.[0]; - if (board && selectedPoint) { - const vertexAngle = getVertexAngle(selectedPoint); - if (!vertexAngle) { - const anglePts = getPointsForVertexAngle(selectedPoint); - if (anglePts) { - const anglePtIds = anglePts.map(pt => pt.id); - this.applyChange(() => { - const angle = this.getContent().addVertexAngle(board, anglePtIds); - if (angle) { - this.handleCreateVertexAngle(angle); - } - }); - } + private handleLabelDialog = (selectedPoint: JXG.Point|undefined, selectedSegment: JXG.Line|undefined, + selectedPolygon: JXG.Polygon|undefined) => { + // If there are just two points in a polygon, we want to label the segment not the polygon. + if (selectedSegment) { + this.setState({ showSegmentLabelDialog: true }); + } else if (selectedPolygon) { + this.setState({ showPolygonLabelDialog: true }); + } else { + this.setState({ showPointLabelDialog: true }); + } + }; + + private handleSetPointLabelOptions = + (point: JXG.Point, labelOption: ELabelOption, name: string, angleLabel: boolean) => { + point._set("clientLabelOption", labelOption); + point._set("clientName", name); + setPropertiesForLabelOption(point); + this.applyChange(() => { + this.getContent().setPointLabelProps(point.id, name, labelOption); + const vertexAngle = getVertexAngle(point); + if (vertexAngle && !angleLabel) { + this.handleUnlabelVertexAngle(vertexAngle); } - else { - this.applyChange(() => { - this.getContent().removeObjects(board, vertexAngle.id); - }); + if (!vertexAngle && angleLabel) { + this.handleLabelVertexAngle(point); } + }); + logGeometryEvent(this.getContent(), "update", "point", point.id, { text: name, labelOption }); + }; + + private handleLabelVertexAngle = (point: JXG.Point) => { + const { board } = this.state; + const anglePts = getPointsForVertexAngle(point); + if (board && anglePts) { + const anglePtIds = anglePts.map(pt => pt.id); + this.applyChange(() => { + const angle = this.getContent().addVertexAngle(board, anglePtIds); + if (angle) { + this.handleCreateVertexAngle(angle); + } + }); } }; + private handleUnlabelVertexAngle = (vertexAngle: JXG.Angle) => { + const { board } = this.state; + if (!board || !vertexAngle) return; + this.applyChange(() => { + this.getContent().removeObjects(board, vertexAngle.id); + }); + }; + private handleCreateMovableLine = () => { const { board } = this.state; const content = this.getContent(); @@ -806,21 +995,6 @@ export class GeometryContentComponent extends BaseComponent { this.setState({ selectedLine: undefined }); }; - private closeSettings = () => { - this.setState({ axisSettingsOpen: false }); - }; - - private handleCreateLineLabel = () => { - const { board } = this.state; - const content = this.getContent(); - if (board) { - const segment = content.getOneSelectedSegment(board); - if (segment) { - this.setState({ showSegmentLabelDialog: true }); - } - } - }; - // Currently, we don't allow commenting of polygon edges because the commenting feature // requires that objects have persistent/unique IDs, but polygon edges don't have such // IDs because their IDs are generated by JSXGraph. @@ -849,14 +1023,16 @@ export class GeometryContentComponent extends BaseComponent { }; private handleLabelSegment = - (polygon: JXG.Polygon, points: [JXG.Point, JXG.Point], labelOption: ESegmentLabelOption) => { + (polygon: JXG.Polygon, points: [JXG.Point, JXG.Point], labelOption: ELabelOption, name: string) => { this.applyChange(() => { - this.getContent().updatePolygonSegmentLabel(this.state.board, polygon, points, labelOption); + this.getContent().updatePolygonSegmentLabel(this.state.board, polygon, points, labelOption, name); }); }; - private handleOpenAxisSettings = () => { - this.setState({ axisSettingsOpen: true }); + private handleLabelPolygon = (polygon: JXG.Polygon, labelOption: ELabelOption, name: string) => { + this.applyChange(() => { + this.getContent().updatePolygonLabel(this.state.board, polygon, labelOption, name); + }); }; private handleUpdateComment = (text: string, commentId?: string) => { @@ -879,11 +1055,6 @@ export class GeometryContentComponent extends BaseComponent { this.setState({ selectedLine: undefined }); }; - private handleUpdateSettings = (params: IAxesParams) => { - this.rescaleBoardAndAxes(params); - this.setState({ axisSettingsOpen: false }); - }; - private handleRotatePolygon = (polygon: JXG.Polygon, vertexCoords: JXG.Coords[], isComplete: boolean) => { const { board } = this.state; if (!board) return; @@ -909,7 +1080,9 @@ export class GeometryContentComponent extends BaseComponent { vertexCoords .map(coords => ({ snapToGrid: false, position: coords.usrCoords.slice(1) })) - .slice(0, vertexCount)); + .slice(0, vertexCount), + undefined, + "rotate"); }); } }; @@ -933,7 +1106,7 @@ export class GeometryContentComponent extends BaseComponent { // hash the copied objects to create a pasteId tied to the content const excludeKeys = (key: string) => ["id", "anchors", "points"].includes(key); const hash = objectHash(copiedObjects.map(obj => getSnapshot(obj)), { excludeKeys }); - this.pasteObjects({ pasteId: hash, isSameTile: true, objects: copiedObjects }); + this.pasteObjects({ pasteId: hash, isSameTile: true, objects: copiedObjects }, "duplicate"); } }; @@ -1011,11 +1184,11 @@ export class GeometryContentComponent extends BaseComponent { const objects = clipboard.getTileContent(content.type); const pasteId = clipboard.getTileContentId(content.type) || objectHash(objects); const isSameTile = clipboard.isSourceTile(content.type, content.metadata.id); - this.pasteObjects({ pasteId, isSameTile, objects }); + this.pasteObjects({ pasteId, isSameTile, objects }, "paste"); }; // paste specified object content - private pasteObjects = (pasteContent: IPasteContent) => { + private pasteObjects = (pasteContent: IPasteContent, userAction: string) => { const content = this.getContent(); const { readOnly } = this.props; const { board } = this.state; @@ -1058,6 +1231,10 @@ export class GeometryContentComponent extends BaseComponent { content.deselectAll(board); content.selectObjects(board, newPointIds); } + + // Log both the old and new IDs + const targetIds = [ ...Object.keys(idMap), ...Object.values(idMap)]; + logGeometryEvent(content, "paste", "object", targetIds, { userAction }); } return true; } @@ -1224,15 +1401,23 @@ export class GeometryContentComponent extends BaseComponent { this.isSqrDistanceWithinThreshold(9, c1.coords, c2.coords)); } + /** + * Adjust display parameters depending on whether user is currently dragging a point. + * @param value {boolean} + */ + private setDragging(value: boolean) { + this.state.board?.infobox.setAttribute({ opacity: value ? 1 : .75 }); + } + private moveSelectedPoints(dx: number, dy: number) { this.beginDragSelectedPoints(); - if (this.endDragSelectedPoints(undefined, undefined, [0, dx, dy])) { + if (this.endDragSelectedPoints(undefined, undefined, [0, dx, dy], "keyboard")) { const { board } = this.state; const content = this.getContent(); if (board) { Object.keys(this.dragPts || {}) .forEach(id => { - const elt = board.objects[id]; + const elt = getBoardObject(board, id); if (elt && content.isSelected(id)) { board.updateInfobox(elt); } @@ -1246,6 +1431,7 @@ export class GeometryContentComponent extends BaseComponent { private beginDragSelectedPoints(evt?: any, dragTarget?: JXG.GeometryElement) { const { board } = this.state; const content = this.getContent(); + this.setDragging(true); if (board && !hasSelectionModifier(evt || {})) { content.metadata.selection.forEach((isSelected: boolean, id: string) => { const obj = board.objects[id]; @@ -1278,18 +1464,20 @@ export class GeometryContentComponent extends BaseComponent { } }); - const affectedObjects = _keys(this.dragPts).map(id => board.objects[id]); + const affectedObjects = _keys(this.dragPts).map(id => getBoardObject(board, id)).filter(notEmpty); updateVertexAnglesFromObjects(affectedObjects); } - private endDragSelectedPoints(evt: any, dragTarget: JXG.GeometryElement | undefined, usrDiff: number[]) { + private endDragSelectedPoints(evt: any, dragTarget: JXG.GeometryElement | undefined, + usrDiff: number[], userAction: string) { const { board } = this.state; const content = this.getContent(); if (!board || !content) return false; + this.setDragging(false); let didDragPoints = false; each(this.dragPts, (entry, id) => { - const obj = board.objects[id]; + const obj = getBoardObject(board, id); if (obj) { obj.setAttribute({ snapToGrid: !!entry.snapToGrid }); } @@ -1314,7 +1502,7 @@ export class GeometryContentComponent extends BaseComponent { } }); - this.applyChange(() => content.updateObjects(board, ids, props)); + this.applyChange(() => content.updateObjects(board, ids, props, undefined, userAction)); } this.dragPts = {}; @@ -1363,10 +1551,12 @@ export class GeometryContentComponent extends BaseComponent { .filter(obj => obj && (obj.elType !== "image")); if (!elements.length && !hasSelectionModifier(evt) && geometryContent.hasSelection()) { geometryContent.deselectAll(board); - return; } - if (readOnly) return; + // Consider whether we should create a point or not. + if (readOnly || this.context.mode === "select" || hasSelectionModifier(evt)) { + return; + } // extended clicks don't create new points const clickTimeThreshold = 500; @@ -1380,36 +1570,42 @@ export class GeometryContentComponent extends BaseComponent { return; } - for (const elt of board.objectsList) { - if (shouldInterceptPointCreation(elt) && elt.hasPoint(coords.scrCoords[1], coords.scrCoords[2])) { - return; - } - } - - // clicks that affect selection don't create new points - if (this.lastSelectDown && - (evt.timeStamp - this.lastSelectDown.timeStamp < clickTimeThreshold)) { + // Certain objects can block point creation + if (findBoardObject(board, elt => { + const shouldIntercept = (this.context.mode === "polygon") + ? shouldInterceptVertexCreation(elt) + : shouldInterceptPointCreation(elt); + return (shouldIntercept && elt.hasPoint(coords.scrCoords[1], coords.scrCoords[2])); + })) { return; } - // clicks on board background create new points - if (!hasSelectionModifier(evt)) { - const props = { snapToGrid: true, snapSizeX: kSnapUnit, snapSizeY: kSnapUnit }; - this.applyChange(() => { - const point = geometryContent.addPoint(board, [x, y], props); - if (point) { - this.handleCreatePoint(point); - } - }); - } + // other clicks on board background create new points, perhaps even starting a polygon. + this.applyChange(() => { + const createPoly = this.context.mode === "polygon"; + const { point, polygon } = geometryContent.realizePhantomPoint(board, [x, y], createPoly); + if (point) { + this.handleCreatePoint(point); + } + if (polygon) { + this.handleCreatePolygon(polygon); + } + }); }; + // Don't create new points on top of an existing point, line, etc. const shouldInterceptPointCreation = (elt: JXG.GeometryElement) => { - return isPolygon(elt) - || isVisiblePoint(elt) + return isRealVisiblePoint(elt) || isVisibleEdge(elt) || isVisibleMovableLine(elt) - || isAxisLabel(elt) + || isComment(elt) + || isMovableLineLabel(elt); + }; + + // When creating a polygon, don't put points on top of points or labels. + // But, you can put a point on a line or inside another polygon. + const shouldInterceptVertexCreation = (elt: JXG.GeometryElement) => { + return isRealVisiblePoint(elt) || isComment(elt) || isMovableLineLabel(elt); }; @@ -1423,16 +1619,6 @@ export class GeometryContentComponent extends BaseComponent { } }); - // synchronize selection changes - this.disposers.push(observe(content.metadata.selection, (change: any) => { - const { board: _board } = this.state; - if (_board) { - // this may be a shared selection change; get all points associated with it - const objs = getPointsByCaseId(_board, change.name); - objs.forEach(obj => setElementColor(_board, obj.id, change.newValue.value)); - } - })); - if (this.props.onSetBoard) { this.props.onSetBoard(board); } @@ -1442,81 +1628,91 @@ export class GeometryContentComponent extends BaseComponent { }; private handleCreateAxis = (axis: JXG.Line) => { - const handlePointerDown = (evt: any) => { - const { readOnly, scale } = this.props; - const { board } = this.state; - // Axis labels get the event preferentially even though we think of other potentially - // overlapping objects (like movable line labels) as being on top. Therefore, we only - // open the axis settings dialog if we consider the axis label to be the preferred - // clickable object at the position of the event. - if (board && !readOnly && (axis.label === getClickableObjectUnderMouse(board, evt, false, scale))) { - this.handleOpenAxisSettings(); - } - }; - - axis.label && axis.label.on("down", handlePointerDown); + // nothing needed, but keep this method for consistency }; private handleCreatePoint = (point: JXG.Point) => { - const handlePointerDown = (evt: any) => { + const handlePointerDown = (evt: Event) => { + const { board, mode } = this.context; const geometryContent = this.props.model.content as GeometryContentModelType; - const { board } = this.state; if (!board) return; const id = point.id; const coords = copyCoords(point.coords); - const tableId = point.getAttribute("linkedTableId"); - const columnId = point.getAttribute("linkedColId"); const isPointDraggable = !this.props.readOnly && !point.getAttribute("fixed"); - if (isFreePoint(point) && this.isDoubleClick(this.lastPointDown, { evt, coords })) { - if (board) { - this.applyChange(() => { - const polygon = geometryContent.createPolygonFromFreePoints(board, tableId, columnId); - if (polygon) { - this.handleCreatePolygon(polygon); - this.props.onContentChange(); + + // Polygon mode interactions with existing points + if (mode === "polygon") { + this.applyChange(() => { + if (geometryContent.phantomPoint && geometryContent.activePolygonId) { + const poly = getPolygon(board, geometryContent.activePolygonId); + const vertex = poly && poly.vertices.find(p => p.id === id); + if (vertex) { + // user clicked on a vertex that is in the current polygon - close the polygon. + const polygon = geometryContent.closeActivePolygon(board, vertex); + if (polygon) { + this.handleCreatePolygon(polygon); + } + } else { + // use clicked a vertex that is not part of the current polygon - adopt it. + geometryContent.addPointToActivePolygon(board, point.id); } - }); - this.lastPointDown = undefined; - } + } else { + // No active polygon. Activate one for the point clicked. + const polys = Object.values(point.childElements).filter(child => isPolygon(child)); + if (polys.length > 0 && isPolygon(polys[0])) { + // The point clicked is in one or more polygons. + // Activate the first polygon returned. + const poly = polys[0]; + const polygon = geometryContent.makePolygonActive(board, poly.id, point.id); + if (polygon) { + this.handleCreatePolygon(polygon); + } + } else { + // Point clicked is not part of a polygon. Create one. + const polygon = geometryContent.createPolygonIncludingPoint(board, point.id); + if (polygon) { + this.handleCreatePolygon(polygon); + } + } + } + }); + return; } - else { - this.dragPts = isPointDraggable ? { [id]: { initial: coords } } : {}; - this.lastPointDown = { evt, coords }; - // click on selected element - deselect if appropriate modifier key is down - if (geometryContent.isSelected(id)) { - if (hasSelectionModifier(evt)) { - geometryContent.deselectElement(board, id); - } + this.dragPts = isPointDraggable ? { [id]: { initial: coords } } : {}; + this.lastPointDown = { evt, coords }; - if (isMovableLineControlPoint(point)) { - // When a control point is clicked, deselect the rest of the line so the line slope can be changed - const line = find(point.descendants, isMovableLine); - if (line) { - geometryContent.deselectElement(undefined, line.id); - each(line.ancestors, (parentPoint, parentId) => { - if (parentId !== point.id) { - geometryContent.deselectElement(undefined, parentId); - } - }); - } - } + // click on selected element - deselect if appropriate modifier key is down + if (geometryContent.isSelected(id)) { + if (evt instanceof MouseEvent && hasSelectionModifier(evt)) { + geometryContent.deselectElement(board, id); } - // click on unselected element - else { - // deselect other elements unless appropriate modifier key is down - if (!hasSelectionModifier(evt)) { - geometryContent.deselectAll(board); + + if (isMovableLineControlPoint(point)) { + // When a control point is clicked, deselect the rest of the line so the line slope can be changed + const line = find(point.descendants, isMovableLine); + if (line) { + geometryContent.deselectElement(undefined, line.id); + each(line.ancestors, (parentPoint, parentId) => { + if (parentId !== point.id) { + geometryContent.deselectElement(undefined, parentId); + } + }); } - geometryContent.selectElement(board, id); } - - if (isPointDraggable) { - this.beginDragSelectedPoints(evt, point); + } + // click on unselected element + else { + // deselect other elements unless appropriate modifier key is down + if (evt instanceof MouseEvent && !hasSelectionModifier(evt)) { + geometryContent.deselectAll(board); } + geometryContent.selectElement(board, id); + } - this.lastSelectDown = evt; + if (isPointDraggable) { + this.beginDragSelectedPoints(evt, point); } }; @@ -1546,7 +1742,7 @@ export class GeometryContentComponent extends BaseComponent { dragEntry.final = copyCoords(point.coords); const usrDiff = JXG.Math.Statistics.subtract(dragEntry.final.usrCoords, dragEntry.initial.usrCoords) as number[]; - this.endDragSelectedPoints(evt, point, usrDiff); + this.endDragSelectedPoints(evt, point, usrDiff, "drag point"); } delete this.dragPts[id]; @@ -1560,7 +1756,7 @@ export class GeometryContentComponent extends BaseComponent { private handleCreateLine = (line: JXG.Line) => { function getVertices() { - return filter(line.ancestors, isPoint); + return [line.point1, line.point2]; } const isInVertex = (evt: any) => { @@ -1575,6 +1771,7 @@ export class GeometryContentComponent extends BaseComponent { const { readOnly, scale } = this.props; const { board } = this.state; if (!board || (line !== getClickableObjectUnderMouse(board, evt, !readOnly, scale))) return; + if (isInVertex(evt)) return; const content = this.getContent(); const vertices = getVertices(); @@ -1622,7 +1819,7 @@ export class GeometryContentComponent extends BaseComponent { if (dragEntry && dragEntry.initial) { const usrDiff = JXG.Math.Statistics.subtract(vertex.coords.usrCoords, dragEntry.initial.usrCoords) as number[]; - this.endDragSelectedPoints(evt, line, usrDiff); + this.endDragSelectedPoints(evt, line, usrDiff, "drag segment"); } } this.isVertexDrag = false; @@ -1667,22 +1864,14 @@ export class GeometryContentComponent extends BaseComponent { const geometryContent = this.props.model.content as GeometryContentModelType; const inVertex = isInVertex(evt); const allVerticesSelected = areAllVerticesSelected(); - let selectPolygon = false; if (!inVertex && !allVerticesSelected) { // deselect other elements unless appropriate modifier key is down - if (board && !hasSelectionModifier(evt)) { + if (!hasSelectionModifier(evt)) { geometryContent.deselectAll(board); } - selectPolygon = true; - this.lastSelectDown = evt; - } - if (selectPolygon) { - geometryContent.selectElement(board, polygon.id); - each(polygon.ancestors, point => { - if (board && isPoint(point) && !inVertex) { - geometryContent.selectElement(board, point.id); - } - }); + const ids = Object.values(polygon.ancestors).filter(obj => isPoint(obj)).map(obj => obj.id); + ids.push(polygon.id); + geometryContent.selectObjects(board, ids); } if (!readOnly) { @@ -1716,7 +1905,7 @@ export class GeometryContentComponent extends BaseComponent { if (dragEntry && dragEntry.initial) { const usrDiff = JXG.Math.Statistics.subtract(vertex.coords.usrCoords, dragEntry.initial.usrCoords) as number[]; - this.endDragSelectedPoints(evt, polygon, usrDiff); + this.endDragSelectedPoints(evt, polygon, usrDiff, "drag polygon"); } } this.isVertexDrag = false; diff --git a/src/components/tiles/geometry/geometry-shared.tsx b/src/components/tiles/geometry/geometry-shared.tsx index e999ba4faf..90a5828ed9 100644 --- a/src/components/tiles/geometry/geometry-shared.tsx +++ b/src/components/tiles/geometry/geometry-shared.tsx @@ -4,11 +4,14 @@ import { HotKeyHandler } from "../../../utilities/hot-keys"; export interface IToolbarActionHandlers { handleDuplicate: () => void; handleDelete: () => void; - handleToggleVertexAngle: () => void; + handleLabelDialog: (selectedPoint: JXG.Point|undefined, selectedSegment: JXG.Line|undefined, + selectedPolygon: JXG.Polygon|undefined ) => void; handleCreateMovableLine: () => void; - handleCreateLineLabel: () => void; handleCreateComment: () => void; handleUploadImageFile: (file: File) => void; + handleZoomIn: () => void; + handleZoomOut: () => void; + handleFitAll: () => void; } export interface IActionHandlers extends IToolbarActionHandlers { handleArrows: HotKeyHandler; diff --git a/src/components/tiles/geometry/geometry-tile-context.ts b/src/components/tiles/geometry/geometry-tile-context.ts new file mode 100644 index 0000000000..f2c8329573 --- /dev/null +++ b/src/components/tiles/geometry/geometry-tile-context.ts @@ -0,0 +1,24 @@ +import { createContext, useContext } from "react"; +import { GeometryContentModelType } from "../../../models/tiles/geometry/geometry-content"; +import { IActionHandlers } from "./geometry-shared"; +import { GeometryTileMode, GeometryTileModes } from "./geometry-types"; + +export interface IGeometryTileContext { + mode: GeometryTileMode; + setMode: (mode: GeometryTileMode) => void; + content: GeometryContentModelType|undefined; + board: JXG.Board|undefined; + handlers: IActionHandlers|undefined; +} + +const defaultValue = { + mode: GeometryTileModes[0], + setMode: (mode: GeometryTileMode) => { }, + content: undefined, + board: undefined, + handlers: undefined +}; + +export const GeometryTileContext = createContext(defaultValue); + +export const useGeometryTileContext = () => useContext(GeometryTileContext); diff --git a/src/components/tiles/geometry/geometry-tile.sass b/src/components/tiles/geometry/geometry-tile.sass deleted file mode 100644 index c41fd57d0c..0000000000 --- a/src/components/tiles/geometry/geometry-tile.sass +++ /dev/null @@ -1,48 +0,0 @@ -@import ../../vars - -$toolbar-width: 44px - -.geometry-tool - position: relative - width: 100% - height: 100% - min-height: 52px - - .geometry-wrapper - position: absolute - height: 100% - left: 0 - right: 0 - - &.read-only - left: 0 - - .geometry-size-me - height: 100% - - .geometry-content - height: 100% - outline: none - - .comment - min-width: 30px - max-width: 250px - background-color: #009CDC - border: 1px black solid - border-radius: 5px - padding: 3px - cursor: pointer - - &.selected - background-color: red - -.rotate-polygon-icon - background-image: url("../../../assets/rotate.png") - position: absolute - width: 16px - height: 16px - z-index: 10 - display: none - - &.enabled - display: block diff --git a/src/components/tiles/geometry/geometry-tile.scss b/src/components/tiles/geometry/geometry-tile.scss new file mode 100644 index 0000000000..18f4b6219e --- /dev/null +++ b/src/components/tiles/geometry/geometry-tile.scss @@ -0,0 +1,80 @@ +@import "../../vars"; + +$toolbar-width: 44px; + +.geometry-tool { + position: relative; + width: 100%; + height: 100%; + min-height: 52px; + + .geometry-wrapper { + position: absolute; + height: 100%; + left: 0; + right: 0; + + &.read-only { + left: 0; + } + .geometry-size-me { + height: 100%; + overflow: hidden; // for older browsers + overflow: clip; + + .geometry-content { + height: 100%; + outline: none; + + .comment { + min-width: 30px; + max-width: 250px; + background-color: #009CDC; + border: 1px black solid; + border-radius: 5px; + padding: 3px; + cursor: pointer; + + &.selected { + background-color: red; + } + } + + svg { + + ellipse { + paint-order: stroke fill; + } + + // JSXGraph doesn't allow us to set a class attribute + // so we use stroke opacity .99 to signal highlighting via drop-shadow. + line[stroke-opacity="0.99"] { + -webkit-filter: drop-shadow(0 0 6px #0081ff); + filter: drop-shadow(0 0 6px #0081ff); + } + + .tool-tile.selected:not(.readonly) & { + ellipse, line, polygon { + cursor: move; + } + } + + } + + } + } + } +} + +.rotate-polygon-icon { + background-image: url("../../../assets/rotate-selection.svg"); + position: absolute; + width: 30px; + height: 30px; + z-index: 10; + display: none; + + &.enabled { + display: block; + } +} diff --git a/src/components/tiles/geometry/geometry-tile.tsx b/src/components/tiles/geometry/geometry-tile.tsx index c049ee4e4d..85a5f2e7fc 100644 --- a/src/components/tiles/geometry/geometry-tile.tsx +++ b/src/components/tiles/geometry/geometry-tile.tsx @@ -1,27 +1,30 @@ import React, { useCallback, useRef, useState } from "react"; import { GeometryContentWrapper } from "./geometry-content-wrapper"; import { IGeometryProps, IActionHandlers } from "./geometry-shared"; -import { GeometryToolbar } from "./geometry-toolbar"; import { GeometryContentModelType } from "../../../models/tiles/geometry/geometry-content"; import { useTileSelectionPointerEvents } from "./use-tile-selection-pointer-events"; import { useUIStore } from "../../../hooks/use-stores"; import { useCurrent } from "../../../hooks/use-current"; import { useForceUpdate } from "../hooks/use-force-update"; -import { useToolbarTileApi } from "../hooks/use-toolbar-tile-api"; -import { useProviderTileLinking } from "../../../hooks/use-provider-tile-linking"; import { HotKeys } from "../../../utilities/hot-keys"; +import { TileToolbar } from "../../toolbar/tile-toolbar"; +import { IGeometryTileContext, GeometryTileContext } from "./geometry-tile-context"; +import { GeometryTileMode } from "./geometry-types"; -import "./geometry-tile.sass"; +import "./geometry-toolbar-registration"; + +import "./geometry-tile.scss"; const _GeometryToolComponent: React.FC = ({ model, readOnly, ...others }) => { - const { documentContent, tileElt, scale, onRegisterTileApi, onUnregisterTileApi } = others; + const { tileElt } = others; const modelRef = useCurrent(model); const domElement = useRef(null); const content = model.content as GeometryContentModelType; const [board, setBoard] = useState(); const [actionHandlers, setActionHandlers] = useState(); + const [mode, setMode] = useState("select"); const hotKeys = useRef(new HotKeys()); const forceUpdate = useForceUpdate(); @@ -40,35 +43,41 @@ const _GeometryToolComponent: React.FC = ({ setActionHandlers(handlers); }; + const context: IGeometryTileContext = { + mode, + setMode, + content, + board, + handlers: actionHandlers + }; + const ui = useUIStore(); const [handlePointerDown, handlePointerUp] = useTileSelectionPointerEvents( useCallback(() => ui.isSelectedTile(modelRef.current), [modelRef, ui]), useCallback((append: boolean) => ui.setSelectedTile(modelRef.current, { append }), [modelRef, ui]), domElement ); - const enabled = !readOnly && !!board && !!actionHandlers; - const toolbarProps = useToolbarTileApi({ id: model.id, enabled, onRegisterTileApi, onUnregisterTileApi }); - const { isLinkEnabled, showLinkTileDialog } - = useProviderTileLinking({ model, readOnly, sharedModelTypes: [ "SharedDataSet" ] }); + // We must listen for pointer events because we want to get the events before // JSXGraph, which appears to listen to pointer events on browsers that support them. // We must listen for mouse events because some browsers (notably Safari) don't // support pointer events. return ( -
hotKeys.current.dispatch(e)} > - - - -
+ +
hotKeys.current.dispatch(e)} > + + +
+
); }; + const GeometryToolComponent = React.memo(_GeometryToolComponent); export default GeometryToolComponent; diff --git a/src/components/tiles/geometry/geometry-tool-buttons.tsx b/src/components/tiles/geometry/geometry-tool-buttons.tsx deleted file mode 100644 index e6993d1bdd..0000000000 --- a/src/components/tiles/geometry/geometry-tool-buttons.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import classNames from "classnames"; -import React from "react"; -import { Tooltip } from "react-tippy"; -// geometry icons -import AngleLabelSvg from "../../../clue/assets/icons/geometry/angle-label.svg"; -import CopyPolygonSvg from "../../../clue/assets/icons/geometry/copy-polygon.svg"; -import LineLabelSvg from "../../../clue/assets/icons/geometry/line-label.svg"; -import MovableLineSvg from "../../../clue/assets/icons/geometry/movable-line.svg"; -// generic icons -import CommentSvg from "../../../assets/icons/comment/comment.svg"; -import DeleteSvg from "../../../assets/icons/delete/delete-selection-icon.svg"; -import { useTooltipOptions } from "../../../hooks/use-tooltip-options"; - -type SvgComponent = React.FC>; - -export interface IClientToolButtonProps { - disabled?: boolean; - selected?: boolean; - onClick?: () => void; -} -interface IGeometryToolButtonProps extends IClientToolButtonProps { - SvgComponent: SvgComponent; - className: string; -} -export const GeometryToolButton: React.FC = ({ - SvgComponent, className, disabled, selected, onClick -}) => { - const classes = classNames("button", className, { enabled: !disabled, disabled, selected }); - return ( -
- -
- ); -}; - -/* - * Geometry buttons - */ -const kTooltipYDistance = 2; -export const AngleLabelButton: React.FC = (props) => { - const tooltipOptions = useTooltipOptions({ distance: kTooltipYDistance }); - return ( - - - - ); -}; - -export const DuplicateButton: React.FC = (props) => { - const tooltipOptions = useTooltipOptions({ distance: kTooltipYDistance }); - return ( - - - - ); -}; - -export const LineLabelButton: React.FC = (props) => { - const tooltipOptions = useTooltipOptions({ distance: kTooltipYDistance }); - return ( - - - - ); -}; - -export const MovableLineButton: React.FC = (props) => { - const tooltipOptions = useTooltipOptions({ distance: kTooltipYDistance }); - return ( - - - - ); -}; - -/* - * Generic buttons - */ -export const CommentButton: React.FC = (props) => { - const tooltipOptions = useTooltipOptions({ distance: kTooltipYDistance }); - return ( - - - - ); -}; - -export const DeleteButton: React.FC = (props) => { - const tooltipOptions = useTooltipOptions({ distance: kTooltipYDistance }); - return ( - - - - ); -}; diff --git a/src/components/tiles/geometry/geometry-toolbar-registration.tsx b/src/components/tiles/geometry/geometry-toolbar-registration.tsx new file mode 100644 index 0000000000..7634c4d910 --- /dev/null +++ b/src/components/tiles/geometry/geometry-toolbar-registration.tsx @@ -0,0 +1,303 @@ +import React, { FunctionComponent, SVGProps } from "react"; +import { observer } from "mobx-react"; +import { IToolbarButtonComponentProps, registerTileToolbarButtons } from "../../toolbar/toolbar-button-manager"; +import { TileToolbarButton } from "../../toolbar/tile-toolbar-button"; +import { useGeometryTileContext } from "./geometry-tile-context"; +import { UploadButton } from "../../toolbar/upload-button"; +import { useProviderTileLinking } from "../../../hooks/use-provider-tile-linking"; +import { useReadOnlyContext } from "../../document/read-only-context"; +import { useTileModelContext } from "../hooks/use-tile-model-context"; +import { GeometryTileMode } from "./geometry-types"; + +import AddImageSvg from "../../../clue/assets/icons/geometry/add-image-icon.svg"; +import CommentSvg from "../../../assets/icons/comment/comment.svg"; +import DeleteSvg from "../../../assets/icons/delete/delete-selection-icon.svg"; +import LabelSvg from "../../../clue/assets/icons/shapes-label-value-icon.svg"; +import MovableLineSvg from "../../../clue/assets/icons/geometry/movable-line.svg"; +import PointSvg from "../../../clue/assets/icons/geometry/point-icon.svg"; +import PolygonSvg from "../../../clue/assets/icons/geometry/polygon-icon.svg"; +import SelectSvg from "../../../clue/assets/icons/select-tool.svg"; +import ShapesDuplicateSvg from "../../../clue/assets/icons/geometry/shapes-duplicate-icon.svg"; +import AddDataSvg from "../../../assets/icons/add-data-graph-icon.svg"; +import ZoomInSvg from "../../../clue/assets/icons/zoom-in-icon.svg"; +import ZoomOutSvg from "../../../clue/assets/icons/zoom-out-icon.svg"; +import FitAllSvg from "../../../clue/assets/icons/fit-view-icon.svg"; + +function ModeButton({name, title, targetMode, Icon}: + { name: string, title: string, targetMode: GeometryTileMode, Icon: FunctionComponent> }) { + const { board, content, mode, setMode } = useGeometryTileContext(); + + function onClick() { + if (mode !== targetMode) { + setMode(targetMode); + if (board) { + content?.clearPhantomPoint(board); + content?.clearActivePolygon(board); + } + } + } + + return ( + + + + ); +} + +const SelectButton = observer(function SelectButton({name}: IToolbarButtonComponentProps) { + return(); +}); + +const PointButton = observer(function PointButton({name}: IToolbarButtonComponentProps) { + return(); +}); + +const PolygonButton = observer(function PolygonButton({name}: IToolbarButtonComponentProps) { + return(); +}); + +const DuplicateButton = observer(function DuplicateButton({name}: IToolbarButtonComponentProps) { + const { content, board, handlers } = useGeometryTileContext(); + const disableDuplicate = !content || !board || !content.hasDeletableSelection(board); + return ( + handlers?.handleDuplicate()} + > + + + ); + +}); + +const LabelButton = observer(function LabelButton({name}: IToolbarButtonComponentProps) { + const { content, board, handlers } = useGeometryTileContext(); + const selectedPoint = board && content?.getOneSelectedPoint(board); + const selectedSegment = board && content?.getOneSelectedSegment(board); + const selectedPolygon = board && content?.getOneSelectedPolygon(board); + + const pointHasLabel = selectedPoint && selectedPoint.hasLabel; + const segmentHasLabel = selectedSegment && selectedSegment.hasLabel; + const polygonHasLabel = selectedPolygon && selectedPolygon.hasLabel; + + function handleClick() { + handlers?.handleLabelDialog(selectedPoint, selectedSegment, selectedPolygon); + } + + return ( + + + + ); + +}); + +const MovableLineButton = observer(function MovableLineButton({name}: IToolbarButtonComponentProps) { + const { handlers } = useGeometryTileContext(); + return ( + handlers?.handleCreateMovableLine()} + > + + + ); +}); + +const CommentButton = observer(function CommentButton({name}: IToolbarButtonComponentProps) { + const { content, board, handlers } = useGeometryTileContext(); + const disableComment = board && !content?.getCommentAnchor(board) && !content?.getOneSelectedComment(board); + + return ( + handlers?.handleCreateComment()} + > + + + ); +}); + +const DeleteButton = observer(function DeleteButton({name}: IToolbarButtonComponentProps) { + const { content, board, handlers } = useGeometryTileContext(); + const disableDelete = !board || !content?.hasDeletableSelection(board); + + return ( + handlers?.handleDelete()} + > + + + ); +}); + +const ImageUploadButton = observer(function ImageUploadButton({name}: IToolbarButtonComponentProps) { + const { handlers } = useGeometryTileContext(); + + const onUploadImageFile = (x: File) => { + handlers?.handleUploadImageFile(x); + }; + + return ( + + + + ); +}); + +const AddDataButton = observer (function AddDataButton({name}: IToolbarButtonComponentProps) { + const readOnly = useReadOnlyContext(); + const { tile } = useTileModelContext(); + const { isLinkEnabled, showLinkTileDialog } + = useProviderTileLinking({ model: tile!, readOnly, sharedModelTypes: [ "SharedDataSet" ] }); + return ( + + + + ); +}); + +function ZoomInButton({name}: IToolbarButtonComponentProps) { + const readOnly = useReadOnlyContext(); + const { handlers } = useGeometryTileContext(); + + function handleClick() { + if (readOnly) return; + handlers?.handleZoomIn(); + } + + return ( + + + + ); +} + +function ZoomOutButton({name}: IToolbarButtonComponentProps) { + const readOnly = useReadOnlyContext(); + const { handlers } = useGeometryTileContext(); + + function handleClick() { + if (readOnly) return; + handlers?.handleZoomOut(); + } + + return ( + + + + ); +} + +function FitAllButton({name}: IToolbarButtonComponentProps) { + const readOnly = useReadOnlyContext(); + const { handlers } = useGeometryTileContext(); + + function handleClick() { + if (readOnly) return; + handlers?.handleFitAll(); + } + + return ( + + + + ); +} + +registerTileToolbarButtons("geometry", + [ + { name: "select", + component: SelectButton + }, + { + name: "point", + component: PointButton + }, + { + name: "polygon", + component: PolygonButton + }, + { + name: "duplicate", + component: DuplicateButton + }, + { + name: "label", + component: LabelButton + }, + { + name: "movable-line", + component: MovableLineButton + }, + { + name: "comment", + component: CommentButton + }, + { + name: "upload", + component: ImageUploadButton + }, + { + name: "add-data", + component: AddDataButton + }, + { + name: "delete", + component: DeleteButton + }, + { + name: "zoom-in", + component: ZoomInButton + }, + { + name: "zoom-out", + component: ZoomOutButton + }, + { + name: "fit-all", + component: FitAllButton + } + ] +); diff --git a/src/components/tiles/geometry/geometry-toolbar.sass b/src/components/tiles/geometry/geometry-toolbar.sass deleted file mode 100644 index b97a44b261..0000000000 --- a/src/components/tiles/geometry/geometry-toolbar.sass +++ /dev/null @@ -1,69 +0,0 @@ -@import ../../vars - -.geometry-toolbar - position: absolute - height: $toolbar-button-height + 4px - border: $toolbar-border - border-radius: 0 0 $toolbar-border-radius $toolbar-border-radius - background-color: $workspace-teal-light-9 - overflow: hidden - z-index: $toolbar-z-index - - &.disabled - display: none - - .toolbar-buttons - height: $toolbar-button-height - display: flex - flex-direction: row - justify-content: center - align-items: center - - .button - box-sizing: content-box - width: $toolbar-button-width - height: $toolbar-button-height - margin: 0 2px - background-color: $workspace-teal-light-9 - display: flex - justify-content: center - align-items: center - &:active - background-color: $workspace-teal-light-4 - cursor: pointer - &.selected - background-color: $workspace-teal-light-4 - &:hover - background-color: $workspace-teal-light-6 - cursor: pointer - &.disabled - opacity: .25 - pointer-events: none - - svg - height: 34px - &.movable-line svg - height: 30px - - // image upload button - .toolbar-button - position: relative - width: $toolbar-button-width - height: $toolbar-button-height - background-color: $workspace-teal-light-9 - - &:hover - background-color: $workspace-teal-light-6 - - &:active - background-color: $workspace-teal-light-4 - - svg path - fill: $workspace-teal-dark-1 - - input - position: absolute - left: 0 - top: 0 - width: $toolbar-button-width - height: $toolbar-button-height diff --git a/src/components/tiles/geometry/geometry-toolbar.test.tsx b/src/components/tiles/geometry/geometry-toolbar.test.tsx deleted file mode 100644 index d4b4d3b80d..0000000000 --- a/src/components/tiles/geometry/geometry-toolbar.test.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from "react"; -import { render, screen } from "@testing-library/react"; -import { GeometryToolbar } from "./geometry-toolbar"; -import { GeometryContentModel, GeometryMetadataModel } from "../../../models/tiles/geometry/geometry-content"; - -describe("GeometryToolbar", () => { - const content = GeometryContentModel.create(); - const metadata = GeometryMetadataModel.create({ id: "test-metadata" }); - content.doPostCreate!(metadata); - - it("renders successfully", () => { - render(
); - const documentContent = screen.getByTestId("document-content"); - - render( - true} - onRegisterTileApi={() => null} onUnregisterTileApi={() => null} /> - ); - expect(screen.getByTestId("geometry-toolbar")).toBeInTheDocument(); - }); -}); diff --git a/src/components/tiles/geometry/geometry-toolbar.tsx b/src/components/tiles/geometry/geometry-toolbar.tsx deleted file mode 100644 index 5a9adfdb1d..0000000000 --- a/src/components/tiles/geometry/geometry-toolbar.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import classNames from "classnames"; -import { observer } from "mobx-react"; -import React from "react"; -import ReactDOM from "react-dom"; -import { GeometryContentModelType } from "../../../models/tiles/geometry/geometry-content"; -import { isPoint } from "../../../models/tiles/geometry/jxg-types"; -import { canSupportVertexAngle, getVertexAngle } from "../../../models/tiles/geometry/jxg-vertex-angle"; -import { IFloatingToolbarProps, useFloatingToolbarLocation } from "../hooks/use-floating-toolbar-location"; -import { IToolbarActionHandlers } from "./geometry-shared"; -import { - AngleLabelButton, CommentButton, DeleteButton, DuplicateButton, LineLabelButton, MovableLineButton -} from "./geometry-tool-buttons"; -import { ImageUploadButton } from "../image/image-toolbar"; - -import "./geometry-toolbar.sass"; - -interface IProps extends IFloatingToolbarProps { - board?: JXG.Board; - content: GeometryContentModelType; - handlers?: IToolbarActionHandlers; -} - -export const GeometryToolbar: React.FC = observer(({ - documentContent, tileElt, board, content, handlers, onIsEnabled, ...others -}) => { - const { - handleCreateComment, handleCreateMovableLine, handleDelete, handleDuplicate, - handleToggleVertexAngle, handleCreateLineLabel, handleUploadImageFile - } = handlers || {}; - const enabled = onIsEnabled(); - const location = useFloatingToolbarLocation({ - documentContent, - tileElt, - toolbarHeight: 38, - toolbarTopOffset: 2, - enabled, - ...others - }); - // reference the entire selection map so selection changes trigger observable render - content.metadata.selection.toJSON(); - const selectedObjects = board && content.selectedObjects(board); - const selectedPoints = selectedObjects?.filter(isPoint); - const selectedPoint = selectedPoints?.length === 1 ? selectedPoints[0] : undefined; - const disableVertexAngle = !(selectedPoint && canSupportVertexAngle(selectedPoint)); - const disableLineLabel = board && !content.getOneSelectedSegment(board); - const hasVertexAngle = !!selectedPoint && !!getVertexAngle(selectedPoint); - const disableDelete = board && !content.getDeletableSelectedIds(board).length; - const disableDuplicate = board && (!content.getOneSelectedPoint(board) && - !content.getOneSelectedPolygon(board)); - const disableComment = board && !content.getCommentAnchor(board) && - !content.getOneSelectedComment(board); - return documentContent - ? ReactDOM.createPortal( -
e.stopPropagation()}> -
- - - - - - - -
-
, documentContent) - : null; -}); diff --git a/src/components/tiles/geometry/geometry-types.ts b/src/components/tiles/geometry/geometry-types.ts new file mode 100644 index 0000000000..4af11bdd3c --- /dev/null +++ b/src/components/tiles/geometry/geometry-types.ts @@ -0,0 +1,2 @@ +export const GeometryTileModes = ["select", "points", "polygon"] as const; +export type GeometryTileMode = typeof GeometryTileModes[number]; diff --git a/src/components/tiles/geometry/label-dialog.scss b/src/components/tiles/geometry/label-dialog.scss new file mode 100644 index 0000000000..4956edeac8 --- /dev/null +++ b/src/components/tiles/geometry/label-dialog.scss @@ -0,0 +1,21 @@ +fieldset.radio-button-set { + border: none; + padding: 6px 0; +} + +.radio-button-container { + display: flex; + align-items: center; +} + +input.radio-button { + margin-right: .5em; +} + +input.name-input { + margin-left: 8px; +} + +input.checkbox { + margin-right: .5em; +} diff --git a/src/components/tiles/geometry/label-point-dialog.tsx b/src/components/tiles/geometry/label-point-dialog.tsx new file mode 100644 index 0000000000..d73fb4bb99 --- /dev/null +++ b/src/components/tiles/geometry/label-point-dialog.tsx @@ -0,0 +1,31 @@ +import React, { useEffect } from "react"; +import { ELabelOption } from "../../../models/tiles/geometry/jxg-changes"; +import { useLabelPointDialog } from "./use-label-point-dialog"; + +interface IProps { + board: JXG.Board; + point: JXG.Point; + onAccept: (point: JXG.Point, labelOption: ELabelOption, name: string, hasAngle: boolean) => void; + onClose: () => void; +} + +// Component wrapper for useLabelPointDialog() for use by class components. +const LabelPointDialog: React.FC = ({ + board, point, onAccept, onClose +}: IProps) => { + + const [showDialog, hideDialog] = useLabelPointDialog({ + board, + point, + onAccept, + onClose + }); + + useEffect(() => { + showDialog(); + return () => hideDialog(); + }, [hideDialog, showDialog]); + + return null; +}; +export default LabelPointDialog; diff --git a/src/components/tiles/geometry/label-polygon-dialog.tsx b/src/components/tiles/geometry/label-polygon-dialog.tsx new file mode 100644 index 0000000000..4a5bd1e89c --- /dev/null +++ b/src/components/tiles/geometry/label-polygon-dialog.tsx @@ -0,0 +1,31 @@ +import React, { useEffect } from "react"; +import { ELabelOption } from "../../../models/tiles/geometry/jxg-changes"; +import { useLabelPolygonDialog } from "./use-label-polygon-dialog"; + +interface IProps { + board: JXG.Board; + polygon: JXG.Polygon; + onAccept: (polygon: JXG.Polygon, labelOption: ELabelOption, name: string) => void; + onClose: () => void; +} + +// Component wrapper for useLabelPolygonDialog() for use by class components. +const LabelPolygonDialog: React.FC = ({ + board, polygon, onAccept, onClose +}: IProps) => { + + const [showDialog, hideDialog] = useLabelPolygonDialog({ + board, + polygon, + onAccept, + onClose + }); + + useEffect(() => { + showDialog(); + return () => hideDialog(); + }, [hideDialog, showDialog]); + + return null; +}; +export default LabelPolygonDialog; diff --git a/src/components/tiles/geometry/label-radio-button.tsx b/src/components/tiles/geometry/label-radio-button.tsx new file mode 100644 index 0000000000..de25538d21 --- /dev/null +++ b/src/components/tiles/geometry/label-radio-button.tsx @@ -0,0 +1,32 @@ +import React, { PropsWithChildren } from "react"; + +interface LabelRadioButtonProps { + display: string; + label: string; + checkedLabel: string; + setLabelOption: React.Dispatch>; +} +export const LabelRadioButton = function ( + {display, label, checkedLabel, setLabelOption, children}: PropsWithChildren) { + return ( +
+ { + if (e.target.checked) { + setLabelOption(e.target.value); + } + }} + /> + + {children} +
+ ); +}; diff --git a/src/components/tiles/geometry/label-segment-dialog.scss b/src/components/tiles/geometry/label-segment-dialog.scss deleted file mode 100644 index cf59fa68f1..0000000000 --- a/src/components/tiles/geometry/label-segment-dialog.scss +++ /dev/null @@ -1,15 +0,0 @@ - -.radio-button-set { - border: 0px; - width: 250px; - padding: 0px; -} - -.radio-button-container { - display: flex; - align-items: center; -} - -.radio-button { - margin-right: .5em; -} diff --git a/src/components/tiles/geometry/label-segment-dialog.tsx b/src/components/tiles/geometry/label-segment-dialog.tsx index 6c8ad46756..4054fc6856 100644 --- a/src/components/tiles/geometry/label-segment-dialog.tsx +++ b/src/components/tiles/geometry/label-segment-dialog.tsx @@ -1,16 +1,16 @@ import React, { useEffect } from "react"; -import { ESegmentLabelOption } from "../../../models/tiles/geometry/jxg-changes"; +import { ELabelOption } from "../../../models/tiles/geometry/jxg-changes"; import { useLabelSegmentDialog } from "./use-label-segment-dialog"; interface IProps { board: JXG.Board; polygon: JXG.Polygon; points: [JXG.Point, JXG.Point]; - onAccept: (polygon: JXG.Polygon, points: [JXG.Point, JXG.Point], labelOption: ESegmentLabelOption) => void; + onAccept: (polygon: JXG.Polygon, points: [JXG.Point, JXG.Point], labelOption: ELabelOption, name: string) => void; onClose: () => void; } -// Component wrapper for useAxisSettingsDialog() for use by class components. +// Component wrapper for useLabelSegmentDialog() for use by class components. const LabelSegmentDialog: React.FC = ({ board, polygon, points, onAccept, onClose }: IProps) => { diff --git a/src/components/tiles/geometry/link-table-button.scss b/src/components/tiles/geometry/link-table-button.scss deleted file mode 100644 index f01e82c716..0000000000 --- a/src/components/tiles/geometry/link-table-button.scss +++ /dev/null @@ -1,30 +0,0 @@ -@import "../../vars.sass"; - -.link-table-button { - position: relative; - margin-left: 10px; - width: 32px; - height: 26px; - border-radius: 5px; - border: solid 1.5px $charcoal-light-1; - background-color: white; - display: flex; - justify-content: center; - align-items: center; - z-index: 10; - - &.disabled { - svg { - opacity: 35%; - } - border-color: $charcoal-light-4; - } - - &:hover:not(.disabled) { - background-color: $workspace-teal-light-4; - } - - &:active:not(.disabled) { - background-color: $workspace-teal-light-2; - } -} diff --git a/src/components/tiles/geometry/link-table-button.true.test.tsx b/src/components/tiles/geometry/link-table-button.true.test.tsx deleted file mode 100644 index c5711bb085..0000000000 --- a/src/components/tiles/geometry/link-table-button.true.test.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import React from "react"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { LinkTableButton } from "./link-table-button"; -import { act } from "react-dom/test-utils"; - -// mocking is module-level, so we have separate modules to mock the different return values -const useFeatureFlag = jest.fn().mockReturnValue(true); -jest.mock("../../../hooks/use-stores", () => ({ - useFeatureFlag: (...args: any) => useFeatureFlag(...args) -})); - -describe("LinkTableButton with linking enabled", () => { - - const onClick = jest.fn(); - - beforeEach(() => { - onClick.mockReset(); - }); - - it("renders when disabled", () => { - const { unmount } = render(); - expect(screen.getByTestId("table-link-button")).toBeInTheDocument(); - act(() => { - userEvent.click(screen.getByTestId("table-link-button")); - unmount(); - }); - expect(onClick).not.toHaveBeenCalled(); - }); - - it("renders when enabled", () => { - const { unmount } = render(); - expect(screen.getByTestId("table-link-button")).toBeInTheDocument(); - act(() => { - userEvent.click(screen.getByTestId("table-link-button")); - unmount(); - }); - expect(onClick).toHaveBeenCalledTimes(1); - }); - - it("renders when enabled without onClick", () => { - const { unmount } = render(); - expect(screen.getByTestId("table-link-button")).toBeInTheDocument(); - act(() => { - userEvent.click(screen.getByTestId("table-link-button")); - unmount(); - }); - expect(onClick).not.toHaveBeenCalled(); - }); -}); diff --git a/src/components/tiles/geometry/link-table-button.tsx b/src/components/tiles/geometry/link-table-button.tsx deleted file mode 100644 index 5a8c078c5d..0000000000 --- a/src/components/tiles/geometry/link-table-button.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import classNames from "classnames"; -import React from "react"; -import LinkTableIcon from "../../../clue/assets/icons/geometry/link-table-icon.svg"; - -import "./link-table-button.scss"; - -//TODO: dataflow-program-link-table-button.tsx is very similar -//consider refactoring -> https://www.pivotaltracker.com/n/projects/2441242/stories/184992684 - -interface IProps { - isEnabled?: boolean; - onClick?: () => void; -} -export const LinkTableButton: React.FC = ({ isEnabled, onClick }) => { - const classes = classNames("link-table-button", { disabled: !isEnabled }); - const handleClick = (e: React.MouseEvent) => { - isEnabled && onClick?.(); - e.stopPropagation(); - }; - return ( -
- -
- ); -}; diff --git a/src/components/tiles/geometry/link-table-dialog.scss b/src/components/tiles/geometry/link-table-dialog.scss deleted file mode 100644 index 028f97b698..0000000000 --- a/src/components/tiles/geometry/link-table-dialog.scss +++ /dev/null @@ -1,24 +0,0 @@ -@import "../../../components/vars.sass"; - -.custom-modal.link-table { - width: 420px; - height: 200px; - outline: none; - - .modal-content { - justify-content: flex-start; - - .prompt { - margin-top: 15px; - } - } - - select { - margin: 15px 20px; - font-style: italic; - } - - .modal-button.disabled { - opacity: 35%; - } -}; diff --git a/src/components/tiles/geometry/rotate-polygon-icon.tsx b/src/components/tiles/geometry/rotate-polygon-icon.tsx index 744300c3d2..9193f2ba70 100644 --- a/src/components/tiles/geometry/rotate-polygon-icon.tsx +++ b/src/components/tiles/geometry/rotate-polygon-icon.tsx @@ -104,8 +104,8 @@ export class RotatePolygonIcon extends React.Component { if (!board || !polygon) return; const polygonBounds = polygon.bounds(); - const centerCoords = [(polygonBounds[0] + polygonBounds[2]) / 2, - (polygonBounds[1] + polygonBounds[3]) / 2]; + const centerCoords: [number, number] = [(polygonBounds[0] + polygonBounds[2]) / 2, + (polygonBounds[1] + polygonBounds[3]) / 2]; this.polygonCenter = new JXG.Coords(JXG.COORDS_BY_USER, centerCoords, board); this.initialIconAnchor = this.state.iconAnchor ? copyCoords(this.state.iconAnchor) diff --git a/src/components/tiles/geometry/use-axis-settings-dialog.tsx b/src/components/tiles/geometry/use-axis-settings-dialog.tsx deleted file mode 100644 index 28943bb8f2..0000000000 --- a/src/components/tiles/geometry/use-axis-settings-dialog.tsx +++ /dev/null @@ -1,242 +0,0 @@ -import React, { useState } from "react"; -import GeometryToolIcon from "../../../clue/assets/icons/geometry-tool.svg"; -import { useCustomModal } from "../../../hooks/use-custom-modal"; -import { IAxesParams } from "../../../models/tiles/geometry/geometry-content"; -import { getAxisAnnotations, getBaseAxisLabels, guessUserDesiredBoundingBox - } from "../../../models/tiles/geometry/jxg-board"; -import "./axis-settings-dialog.scss"; -import "./dialog.scss"; - -const kBoundsMaxChars = 6; -const kNameMaxChars = 20; -const kLabelMaxChars = 40; - -// The complete dialog content -interface IContentProps { - xName: string; - setXName: React.Dispatch>; - yName: string; - setYName: React.Dispatch>; - xLabel: string; - setXLabel: React.Dispatch>; - yLabel: string; - setYLabel: React.Dispatch>; - xMin: string; - setXMin: React.Dispatch>; - yMin: string; - setYMin: React.Dispatch>; - xMax: string; - setXMax: React.Dispatch>; - yMax: string; - setYMax: React.Dispatch>; - errorMessage: string; -} -const Content: React.FC = ({ - xName, setXName, yName, setYName, - xLabel, setXLabel, yLabel, setYLabel, - xMin, setXMin, yMin, setYMin, - xMax, setXMax, yMax, setYMax, - errorMessage - })=> { - return ( - <> - - -
- {errorMessage} -
- - ); -}; - -// Options for a single axis (included twice in the content) -interface axisViewProps { - title: string; - axisName: string; // Can't use just 'name' because it's a built-in javascript property - setName: React.Dispatch>; - label: string; - setLabel: React.Dispatch>; - min: string; - setMin: React.Dispatch>; - max: string; - setMax: React.Dispatch>; -} -const AxisView: React.FC = ({ - title, - axisName, setName, - label, setLabel, - min, setMin, - max, setMax -}: axisViewProps) => { - const labelId = `${title}-label-input-id`; - return ( -
-
{title}
-
- - setLabel(e.target.value)} - dir="auto" - /> -
-
- - - -
-
- ); -}; - -// A single axis option -interface axisOptionProps { - axis: string; - optionLabel: string; - defaultValue: string; - setValue: React.Dispatch>; - maxChars: number; -} -const AxisOption: React.FC = ({ - axis, optionLabel, defaultValue, setValue, maxChars -}: axisOptionProps) => { - const id = `${axis}-${optionLabel}-input-id`; - return ( -
- - setValue(e.target.value)} - dir="auto" - /> -
- ); -}; - -interface IProps { - board: JXG.Board; - onAccept: (params: IAxesParams) => void; - onClose: () => void; -} -export const useAxisSettingsDialog = ({ board, onAccept, onClose }: IProps) => { - const [hName, vName] = getBaseAxisLabels(board); - const [xName, setXName] = useState(hName); - const [yName, setYName] = useState(vName); - - const [hLabel, vLabel] = getAxisAnnotations(board); - const [xLabel, setXLabel] = useState(hLabel); - const [yLabel, setYLabel] = useState(vLabel); - - const bBox = guessUserDesiredBoundingBox(board); - const [xMin, setXMin] = useState(JXG.toFixed(Math.min(0, bBox[0]), 1)); - const [yMax, setYMax] = useState(JXG.toFixed(Math.max(0, bBox[1]), 1)); - const [xMax, setXMax] = useState(JXG.toFixed(Math.max(0, bBox[2]), 1)); - const [yMin, setYMin] = useState(JXG.toFixed(Math.min(0, bBox[3]), 1)); - - const fXMin = parseFloat(xMin); - const fXMax = parseFloat(xMax); - const fYMin = parseFloat(yMin); - const fYMax = parseFloat(yMax); - const errorMessage = - !isFinite(fXMin) || !isFinite(fXMax) || !isFinite(fYMin) || !isFinite(fYMax) - ? "Please enter valid numbers for axis minimum and maximum values" - : fXMin > 0 || fYMin > 0 - ? "Axis minimum values must be less than or equal to 0." - : fXMax < 0 || fYMax < 0 - ? "Axis maximum values must be greater than or equal to 0." - : fXMin >= fXMax || fYMin >= fYMax - ? "Axis minimum values must be less than axis maximum values" - : ""; - - const handleClick = () => { - if (errorMessage.length === 0) { - onAccept({ - xName, - yName, - xAnnotation: xLabel, - yAnnotation: yLabel, - xMax: fXMax, - yMax: fYMax, - xMin: fXMin, - yMin: fYMin - }); - } else { - onClose(); - } - }; - - const [showModal, hideModal] = useCustomModal({ - Icon: GeometryToolIcon, - title: "Axis Settings", - Content, - contentProps: { - xName, setXName, yName, setYName, - xLabel, setXLabel, yLabel, setYLabel, - xMin, setXMin, yMin, setYMin, - xMax, setXMax, yMax, setYMax, - errorMessage - }, - buttons: [ - { label: "Cancel" }, - { label: "OK", - isDefault: true, - isDisabled: errorMessage.length > 0, - onClick: handleClick - } - ], - onClose - }, [xName, yName, xLabel, yLabel, xMin, yMin, xMax, yMax, errorMessage]); - - return [showModal, hideModal]; -}; diff --git a/src/components/tiles/geometry/use-label-point-dialog.tsx b/src/components/tiles/geometry/use-label-point-dialog.tsx new file mode 100644 index 0000000000..f44ea2cfbd --- /dev/null +++ b/src/components/tiles/geometry/use-label-point-dialog.tsx @@ -0,0 +1,105 @@ +import React, { useState } from "react"; +import { ELabelOption } from "../../../models/tiles/geometry/jxg-changes"; +import { useCustomModal } from "../../../hooks/use-custom-modal"; +import { canSupportVertexAngle, getVertexAngle } from "../../../models/tiles/geometry/jxg-vertex-angle"; +import { LabelRadioButton } from "./label-radio-button"; + +import LabelSvg from "../../../clue/assets/icons/shapes-label-value-icon.svg"; + +import "./label-dialog.scss"; + +interface IContentProps { + labelOption: string; + setLabelOption: React.Dispatch>; + pointName?: string; + onNameChange: React.Dispatch>; + supportsAngle: boolean; + hasAngle: boolean; + setHasAngle: React.Dispatch>; +} +const Content = function ( + { labelOption, setLabelOption, pointName, onNameChange, supportsAngle, hasAngle, setHasAngle }: IContentProps) { + return ( +
+ + + { onNameChange(e.target.value); }} /> + + +
+ setHasAngle(e.target.checked) } + /> + +
+
+ ); +}; + +interface IProps { + board: JXG.Board; + point: JXG.Point; + onAccept: (point: JXG.Point, labelOption: ELabelOption, name: string, hasAngle: boolean) => void; + onClose: () => void; +} + +export const useLabelPointDialog = ({ board, point, onAccept, onClose }: IProps) => { + const [initialLabelOption] = useState(point.getAttribute("clientLabelOption") || ELabelOption.kNone); + const [initialPointName] = useState(point.getAttribute("clientName") || ""); + const [labelOption, setLabelOption] = useState(initialLabelOption); + const [pointName, setPointName] = useState(initialPointName); + const supportsAngle = canSupportVertexAngle(point); + const [initialHasAngle] = useState(!!getVertexAngle(point)); + const [hasAngle, setHasAngle] = useState(initialHasAngle); + + const handleSubmit = () => { + if (initialLabelOption !== labelOption || initialPointName !== pointName || initialHasAngle !== hasAngle) { + onAccept(point, labelOption, pointName, hasAngle); + } else { + onClose(); + } + }; + + const [showModal, hideModal] = useCustomModal({ + Icon: LabelSvg, + title: "Point Label/Value", + Content, + contentProps: + { labelOption, setLabelOption, pointName, onNameChange: setPointName, supportsAngle, hasAngle, setHasAngle }, + buttons: [ + { label: "Cancel" }, + { label: "OK", + isDefault: true, + isDisabled: false, + onClick: handleSubmit + } + ], + onClose + }, [labelOption, pointName, hasAngle]); + + return [showModal, hideModal]; +}; diff --git a/src/components/tiles/geometry/use-label-polygon-dialog.tsx b/src/components/tiles/geometry/use-label-polygon-dialog.tsx new file mode 100644 index 0000000000..543c642a76 --- /dev/null +++ b/src/components/tiles/geometry/use-label-polygon-dialog.tsx @@ -0,0 +1,90 @@ +import React, { useState } from "react"; +import { ELabelOption } from "../../../models/tiles/geometry/jxg-changes"; +import { useCustomModal } from "../../../hooks/use-custom-modal"; +import { LabelRadioButton } from "./label-radio-button"; +import { pointName } from "../../../models/tiles/geometry/jxg-point"; + +import LabelSvg from "../../../clue/assets/icons/shapes-label-value-icon.svg"; + +import "./label-dialog.scss"; + +interface IContentProps { + labelOption: string; + setLabelOption: React.Dispatch>; + name?: string; + setName: React.Dispatch>; +} +const Content: React.FC = ( + { labelOption, setLabelOption, name, setName }) => { + return ( +
+ + + setName(e.target.value)} /> + + +
+ ); +}; + +function constructName(polygon: JXG.Polygon) { + return polygon.vertices.slice(0, -1) + .reduce((name: string, point) => { return name + pointName(point); }, ""); +} + +interface IProps { + board: JXG.Board; + polygon: JXG.Polygon; + onAccept: (polygon: JXG.Polygon, labelOption: ELabelOption, name: string) => void; + onClose: () => void; +} +export const useLabelPolygonDialog = ({ board, polygon, onAccept, onClose }: IProps) => { + const [initialLabelOption] = useState(polygon?.getAttribute("clientLabelOption") || "none"); + const [labelOption, setLabelOption] = useState(initialLabelOption); + const [initialName] = useState(polygon?.getAttribute("clientName")); + const [name, setName] = useState(initialName || constructName(polygon)); + + const handleSubmit = () => { + if (polygon && (initialLabelOption !== labelOption || initialName !== name)) { + onAccept(polygon, labelOption, name); + } else { + onClose(); + } + }; + + const [showModal, hideModal] = useCustomModal({ + Icon: LabelSvg, + title: "Polygon Label/Value", + Content, + contentProps: { labelOption, setLabelOption, name, setName }, + buttons: [ + { label: "Cancel" }, + { label: "OK", + isDefault: true, + isDisabled: false, + onClick: handleSubmit + } + ], + onClose + }, [labelOption, name]); + + return [showModal, hideModal]; +}; diff --git a/src/components/tiles/geometry/use-label-segment-dialog.tsx b/src/components/tiles/geometry/use-label-segment-dialog.tsx index ae92f1c6ca..3cf4ebdd6e 100644 --- a/src/components/tiles/geometry/use-label-segment-dialog.tsx +++ b/src/components/tiles/geometry/use-label-segment-dialog.tsx @@ -1,61 +1,44 @@ import React, { useState, useMemo } from "react"; -import LineLabelSvg from "../../../clue/assets/icons/geometry/line-label.svg"; -import { ESegmentLabelOption } from "../../../models/tiles/geometry/jxg-changes"; +import { ELabelOption } from "../../../models/tiles/geometry/jxg-changes"; import { getPolygonEdge } from "../../../models/tiles/geometry/jxg-polygon"; import { useCustomModal } from "../../../hooks/use-custom-modal"; -import "./label-segment-dialog.scss"; +import { LabelRadioButton } from "./label-radio-button"; +import { pointName } from "../../../models/tiles/geometry/jxg-point"; -interface LabelRadioButtonProps { - display: string; - label: string; - checkedLabel: string; - setLabelOption: React.Dispatch>; -} -const LabelRadioButton: React.FC = ({display, label, checkedLabel, setLabelOption}) => { - return ( -
- { - if (e.target.checked) { - setLabelOption(e.target.value); - } - }} - /> - -
- ); -}; +import LabelSvg from "../../../clue/assets/icons/shapes-label-value-icon.svg"; + +import "./label-dialog.scss"; interface IContentProps { labelOption: string; setLabelOption: React.Dispatch>; + name?: string; + setName: React.Dispatch>; } -const Content: React.FC = ({ labelOption, setLabelOption }) => { +const Content: React.FC = ( + { labelOption, setLabelOption, name, setName }) => { return (
+ > + setName(e.target.value)} /> + @@ -72,37 +55,39 @@ interface IProps { board: JXG.Board; polygon: JXG.Polygon; points: [JXG.Point, JXG.Point]; - onAccept: (polygon: JXG.Polygon, points: [JXG.Point, JXG.Point], labelOption: ESegmentLabelOption) => void; + onAccept: (polygon: JXG.Polygon, points: [JXG.Point, JXG.Point], labelOption: ELabelOption, name: string) => void; onClose: () => void; } export const useLabelSegmentDialog = ({ board, polygon, points, onAccept, onClose }: IProps) => { const segment = useMemo(() => getPolygonSegment(board, polygon, points), [board, polygon, points]); const [initialLabelOption] = useState(segment?.getAttribute("clientLabelOption") || "none"); const [labelOption, setLabelOption] = useState(initialLabelOption); + const [initialName] = useState(segment?.getAttribute("clientName")); + const [name, setName] = useState(initialName || (pointName(points[0]) + pointName(points[1]))); - const handleClick = () => { - if (polygon && points && (initialLabelOption !== labelOption)) { - onAccept(polygon, points, labelOption); + const handleSubmit = () => { + if (polygon && points && (initialLabelOption !== labelOption || initialName !== name)) { + onAccept(polygon, points, labelOption, name); } else { onClose(); } }; const [showModal, hideModal] = useCustomModal({ - Icon: LineLabelSvg, - title: "Segment Label", + Icon: LabelSvg, + title: "Segment Label/Value", Content, - contentProps: { labelOption, setLabelOption }, + contentProps: { labelOption, setLabelOption, name, setName }, buttons: [ { label: "Cancel" }, { label: "OK", isDefault: true, isDisabled: false, - onClick: handleClick + onClick: handleSubmit } ], onClose - }, [labelOption]); + }, [labelOption, name]); return [showModal, hideModal]; }; diff --git a/src/components/toolbar.tsx b/src/components/toolbar.tsx index 453760be0f..2e23c4c712 100644 --- a/src/components/toolbar.tsx +++ b/src/components/toolbar.tsx @@ -187,7 +187,6 @@ export class ToolbarComponent extends BaseComponent { } const newTileOptions: IDocumentContentAddTileOptions = { - addSidecarNotes: !!tileContentInfo?.addSidecarNotes, insertRowInfo: { rowInsertIndex: document.content?.defaultInsertRow ?? 0 } }; const rowTile = document.addTile(tool.id, newTileOptions); diff --git a/src/components/toolbar/tile-toolbar-button.tsx b/src/components/toolbar/tile-toolbar-button.tsx index 21bd7c9948..b33be88ac6 100644 --- a/src/components/toolbar/tile-toolbar-button.tsx +++ b/src/components/toolbar/tile-toolbar-button.tsx @@ -1,8 +1,8 @@ import React, { PropsWithChildren } from "react"; import { useTouchHold } from "../../hooks/use-touch-hold"; import classNames from "classnames"; -import { useTooltipOptions } from "../../hooks/use-tooltip-options"; import { Tooltip } from "react-tippy"; +import { useTooltipOptions } from "../../hooks/use-tooltip-options"; /** * Create the complete tooltip from the given button information. diff --git a/src/models/document/base-document-content.ts b/src/models/document/base-document-content.ts index b85d01052d..d1527c155e 100644 --- a/src/models/document/base-document-content.ts +++ b/src/models/document/base-document-content.ts @@ -4,7 +4,6 @@ import { kPlaceholderTileDefaultHeight } from "../tiles/placeholder/placeholder- import { getPlaceholderSectionId, isPlaceholderTile, PlaceholderContentModel } from "../tiles/placeholder/placeholder-content"; -import { kTextTileType } from "../tiles/text/text-content"; import { getTileContentInfo, IDocumentExportOptions } from "../tiles/tile-content-info"; import { ITileContentModel, ITileEnvironment, TileContentModel } from "../tiles/tile-content"; import { ILinkableTiles, ITypedTileLinkMetadata } from "../tiles/tile-link-types"; @@ -790,13 +789,12 @@ export const BaseDocumentContentModel = types * @param toolId the type of tile to create. * @param options an options object, which can include: * @param options.title title for the new tile - * @param options.addSidecarNotes if true, creates an additional text tile alongside * @param options.url passed to the default content creation method * @param options.insertRowInfo specifies where the tile should be placed * @returns an object containing information about the results: rowId, tileId, additionalTileIds */ addTile(toolId: string, options?: IDocumentContentAddTileOptions) { - const { title, addSidecarNotes, url, insertRowInfo } = options || {}; + const { title, url, insertRowInfo } = options || {}; // for historical reasons, this function initially places new rows at // the end of the content and then moves them to the desired location. const contentInfo = getTileContentInfo(toolId); @@ -813,38 +811,17 @@ export const BaseDocumentContentModel = types const tileInfo = self.addTileContentInNewRow( newContent, addTileOptions); - if (addSidecarNotes) { - const { rowId } = tileInfo; - const row = self.rowMap.get(rowId); - const textContentInfo = getTileContentInfo(kTextTileType); - if (row && textContentInfo) { - const tile = TileModel.create({ content: textContentInfo.defaultContent() }); - self.insertNewTileInRow(tile, row, 1); - tileInfo.additionalTileIds = [ tile.id ]; - } - } - // TODO: For historical reasons, this function initially places new rows at the end of the content // and then moves them to their desired locations from there using the insertRowInfo to specify the // desired destination. The underlying addTileInNewRow() function has a separate mechanism for specifying // the location of newly created rows. It would be better to eliminate the redundant insertRowInfo // specification used by this function and instead just use the one from addTileInNewRow(). if (tileInfo && insertRowInfo) { - // Move newly-create tile(s) into requested row. If we have created more than one tile, e.g. the sidecar text - // for the graph tool, we need to insert the tiles one after the other. If we are inserting on the left, we - // have to reverse the order of insertion. If we are inserting into a new row, the first tile is inserted - // into a new row and then the sidecar tiles into that same row. This makes the logic rather verbose... + // Move newly-create tile(s) into requested row. const { rowDropLocation } = insertRowInfo; - let tileIdsToMove; - if (tileInfo.additionalTileIds) { - tileIdsToMove = [tileInfo.tileId, ...tileInfo.additionalTileIds]; - if (rowDropLocation && rowDropLocation === "left") { - tileIdsToMove = tileIdsToMove.reverse(); - } - } else { - tileIdsToMove = [tileInfo.tileId]; - } + // TODO simplify this + const tileIdsToMove = [tileInfo.tileId]; const moveSubsequentTilesRight = !rowDropLocation || rowDropLocation === "bottom" diff --git a/src/models/document/document-content-tests/dc-general.test.ts b/src/models/document/document-content-tests/dc-general.test.ts index bfcf525c8c..a909d2e056 100644 --- a/src/models/document/document-content-tests/dc-general.test.ts +++ b/src/models/document/document-content-tests/dc-general.test.ts @@ -46,22 +46,19 @@ describe("DocumentContentModel", () => { expect(documentContent.tileMap.size).toBe(0); documentContent.addTile("text", { title: "Text 1" }); expect(documentContent.tileMap.size).toBe(1); - // adding geometry tool adds sidecar text tool - documentContent.addTile("geometry", { addSidecarNotes: true, title: "Shapes Graph 1" }); - expect(documentContent.tileMap.size).toBe(3); + documentContent.addTile("geometry", { title: "Shapes Graph 1" }); + expect(documentContent.tileMap.size).toBe(2); expect(documentContent.defaultInsertRow).toBe(2); const newRowTile = documentContent.addTile("table", { title: "Table 1" }); const columnWidths = getColumnWidths(documentContent, newRowTile?.tileId); - expect(documentContent.tileMap.size).toBe(4); + expect(documentContent.tileMap.size).toBe(3); documentContent.addTile("drawing", { title: "Sketch 1" }); - expect(documentContent.tileMap.size).toBe(5); + expect(documentContent.tileMap.size).toBe(4); expect(parsedContentExport()).toEqual({ tiles: [ { title: "Text 1", content: { type: "Text", format: "html", text: ["

"] } }, - [ - { title: "Shapes Graph 1", content: { type: "Geometry", objects: [] } }, - { content: { type: "Text", format: "html", text: ["

"] } } - ], + { title: "Shapes Graph 1", content: { type: "Geometry", objects: {}, + linkedAttributeColors: {}, pointMetadata: {} } }, { title: "Table 1", content: { type: "Table", columnWidths } }, { title: "Sketch 1", content: { type: "Drawing", objects: [] } } ] @@ -80,7 +77,6 @@ describe("DocumentContentModel", () => { // insert image between text tiles const imageTile1 = documentContent.addTile("image", { title: "Image 1", - addSidecarNotes: false, insertRowInfo: { rowInsertIndex: 1, rowDropIndex: 1, @@ -102,7 +98,6 @@ describe("DocumentContentModel", () => { // insert image at bottom const imageTile2 = documentContent.addTile("image", { title: "Image 2", - addSidecarNotes: false, insertRowInfo: { rowInsertIndex: 3, rowDropIndex: 3, @@ -136,7 +131,6 @@ describe("DocumentContentModel", () => { const imageTile1 = documentContent.addTile("image", { title: "Image 1", - addSidecarNotes: false, insertRowInfo: { rowInsertIndex: 1, rowDropIndex: 1, @@ -165,95 +159,6 @@ describe("DocumentContentModel", () => { ] }); }); - - it("allows the geometry tiles to be added with sidecar text as new row", () => { - documentContent.addTile("text", { title: "Text 1" }); - const textTile2 = documentContent.addTile("text", { title: "Text 2" }); - - const graphTileInfo = documentContent.addTile("geometry", { - title: "Shapes Graph 1", - addSidecarNotes: true, - insertRowInfo: { - rowInsertIndex: 1, - rowDropIndex: 1, - rowDropLocation: "bottom" - } - }); - - const geometryRowId = documentContent.findRowContainingTile(graphTileInfo!.tileId); - const geometryRowIndex = documentContent.rowOrder.findIndex((id: string) => id === geometryRowId); - - expect(geometryRowIndex).toBe(1); - - // sidecar text tile should be on same row - expect(graphTileInfo!.additionalTileIds).toBeDefined(); - - const sidecarRowId = documentContent.findRowContainingTile(graphTileInfo!.tileId); - const sidecarRowIndex = documentContent.rowOrder.findIndex((id: string) => id === sidecarRowId); - - expect(sidecarRowIndex).toBe(1); - - // text tile should be on 2 - const textTile2RowId = documentContent.findRowContainingTile(textTile2!.tileId); - const textTile2RowIndex1 = documentContent.rowOrder.findIndex((id: string) => id === textTile2RowId); - - expect(textTile2RowIndex1).toBe(2); - expect(parsedContentExport()).toEqual({ - tiles: [ - { title: "Text 1", content: { type: "Text", format: "html", text: ["

"] } }, - [ - { title: "Shapes Graph 1", content: { type: "Geometry", objects: [] } }, - { content: { type: "Text", format: "html", text: ["

"] } } - ], - { title: "Text 2", content: { type: "Text", format: "html", text: ["

"] } } - ] - }); - }); - - it("allows the geometry tiles to be added with sidecar text at side of existing rows", () => { - documentContent.addTile("text", { title: "Text 1" }); - const textTile2 = documentContent.addTile("text", { title: "Text 2" }); - - const graphTileInfo = documentContent.addTile("geometry", { - title: "Shapes Graph 1", - addSidecarNotes: true, - insertRowInfo: { - rowInsertIndex: 1, - rowDropIndex: 1, - rowDropLocation: "left" - } - }); - - const geometryRowId = documentContent.findRowContainingTile(graphTileInfo!.tileId); - const geometryRowIndex = documentContent.rowOrder.findIndex((id: string) => id === geometryRowId); - - expect(geometryRowIndex).toBe(1); - - // sidecar text tile should be on same row - expect(graphTileInfo!.additionalTileIds).toBeDefined(); - - const sidecarRowId = documentContent.findRowContainingTile(graphTileInfo!.tileId); - const sidecarRowIndex = documentContent.rowOrder.findIndex((id: string) => id === sidecarRowId); - - expect(sidecarRowIndex).toBe(1); - - // original text tile should be on 1 as well - const textTile2RowId = documentContent.findRowContainingTile(textTile2!.tileId); - const textTile2RowIndex1 = documentContent.rowOrder.findIndex((id: string) => id === textTile2RowId); - - expect(textTile2RowIndex1).toBe(1); - expect(parsedContentExport()).toEqual({ - tiles: [ - { title: "Text 1", content: { type: "Text", format: "html", text: ["

"] } }, - [ - { title: "Shapes Graph 1", content: { type: "Geometry", objects: [] } }, - { content: { type: "Text", format: "html", text: ["

"] } }, - { title: "Text 2", content: { type: "Text", format: "html", text: ["

"] } } - ] - ] - }); - }); - }); const sectionedContent = { @@ -561,14 +466,11 @@ describe("DocumentContentModel -- sectioned documents --", () => { { title: "Text 1", content: { type: "Text", format: "html", text: ["

"] } }, ]); - content.addTile("geometry", { title: "Shapes Graph 1", addSidecarNotes: true, - insertRowInfo: { rowInsertIndex: 2 } }); + content.addTile("geometry", { title: "Shapes Graph 1", insertRowInfo: { rowInsertIndex: 2 } }); expect(getAllRows(content)).toEqual([ { Header: "A"}, - [ - { title: "Shapes Graph 1", content: { type: "Geometry", objects: [] } }, - { content: { type: "Text", format: "html", text: ["

"] } } - ], + { title: "Shapes Graph 1", + content: { type: "Geometry", objects: {}, linkedAttributeColors: {}, pointMetadata: {} } }, { Header: "B"}, { title: "Text 1", content: { type: "Text", format: "html", text: ["

"] } }, ]); @@ -577,10 +479,11 @@ describe("DocumentContentModel -- sectioned documents --", () => { content.moveTile(geometryId, { rowDropIndex: 3, rowDropLocation: "left", rowInsertIndex: 3 }); expect(getAllRows(content)).toEqual([ { Header: "A"}, - { content: { type: "Text", format: "html", text: ["

"] } }, + { Placeholder: "A" }, { Header: "B"}, [ - { title: "Shapes Graph 1", content: { type: "Geometry", objects: [] } }, + { title: "Shapes Graph 1", content: { type: "Geometry", objects: {}, + linkedAttributeColors: {}, pointMetadata: {} } }, { title: "Text 1", content: { type: "Text", format: "html", text: ["

"] } } ], ]); @@ -588,10 +491,8 @@ describe("DocumentContentModel -- sectioned documents --", () => { content.moveTile(geometryId, { rowDropIndex: 1, rowDropLocation: "left", rowInsertIndex: 1 }); expect(getAllRows(content)).toEqual([ { Header: "A"}, - [ - { title: "Shapes Graph 1", content: { type: "Geometry", objects: [] } }, - { content: { type: "Text", format: "html", text: ["

"] } } - ], + { title: "Shapes Graph 1", content: { type: "Geometry", objects: {}, + linkedAttributeColors: {}, pointMetadata: {} } }, { Header: "B"}, { title: "Text 1", content: { type: "Text", format: "html", text: ["

"] } }, ]); @@ -607,7 +508,8 @@ describe("DocumentContentModel -- sectioned documents --", () => { { Header: "A"}, [ { content: { type: "Text", format: "html", text: ["

"] } }, - { title: "Shapes Graph 1", content: { type: "Geometry", objects: [] } }, + { title: "Shapes Graph 1", content: { type: "Geometry", objects: {}, + linkedAttributeColors: {}, pointMetadata: {} } }, ], { Header: "B"}, { title: "Text 1", content: { type: "Text", format: "html", text: ["

"] } }, @@ -622,7 +524,8 @@ describe("DocumentContentModel -- sectioned documents --", () => { content.deleteTile(tileId); expect(getAllRows(content)).toEqual([ { Header: "A"}, - { title: "Shapes Graph 1", content: { type: "Geometry", objects: [] } }, + { title: "Shapes Graph 1", content: { type: "Geometry", objects: {}, + linkedAttributeColors: {}, pointMetadata: {} } }, { Header: "B"}, { title: "Text 1", content: { type: "Text", format: "html", text: ["

"] } }, ]); diff --git a/src/models/document/document-content-tests/dc-shared-models.test.ts b/src/models/document/document-content-tests/dc-shared-models.test.ts index 2af909520a..78a231eae9 100644 --- a/src/models/document/document-content-tests/dc-shared-models.test.ts +++ b/src/models/document/document-content-tests/dc-shared-models.test.ts @@ -68,7 +68,7 @@ describe("DocumentContentModel -- shared Models --", () => { tiles: [ [ { content: { type: "Table", columnWidths } }, - { content: { type: "Geometry", objects: [] } } + { content: { type: "Geometry", objects: {}, linkedAttributeColors: {}, pointMetadata: {} } } ] ], sharedModels: [ @@ -242,7 +242,9 @@ Object { "unit": 18.3, }, }, + "linkedAttributeColors": Object {}, "objects": Object {}, + "pointMetadata": Object {}, "type": "Geometry", }, "display": undefined, @@ -691,7 +693,9 @@ Object { "unit": 18.3, }, }, + "linkedAttributeColors": Object {}, "objects": Object {}, + "pointMetadata": Object {}, "type": "Geometry", }, "display": undefined, diff --git a/src/models/document/document-content-tests/dc-tile-move-copy.test.ts b/src/models/document/document-content-tests/dc-tile-move-copy.test.ts index fe50354945..0e44160445 100644 --- a/src/models/document/document-content-tests/dc-tile-move-copy.test.ts +++ b/src/models/document/document-content-tests/dc-tile-move-copy.test.ts @@ -106,7 +106,7 @@ describe("DocumentContentModel -- move/copy tiles --", () => { { content: { type: "Image", url: "image/url" } } ], [ - { content: { type: "Geometry", objects: [] } }, + { content: { type: "Geometry", objects: {}, linkedAttributeColors: {}, pointMetadata: {} } }, { content: { type: "Text", format: "html", text: ["

More text

"] } }, // explicit row height exported since it differs from drawing tool default { content: { type: "Drawing", objects: [] }, layout: { height: 320 } } @@ -133,7 +133,7 @@ describe("DocumentContentModel -- move/copy tiles --", () => { { content: { type: "Image", url: "image/url" } } ], [ - { content: { type: "Geometry", objects: [] } }, + { content: { type: "Geometry", objects: {}, linkedAttributeColors: {}, pointMetadata: {} } }, { content: { type: "Text", format: "html", text: ["

More text

"] } }, // explicit row height exported since it differs from drawing tool default { content: { type: "Drawing", objects: [] }, layout: { height: 320 } } diff --git a/src/models/document/document-content-types.ts b/src/models/document/document-content-types.ts index f046fb7d1e..bc5bde580f 100644 --- a/src/models/document/document-content-types.ts +++ b/src/models/document/document-content-types.ts @@ -5,7 +5,6 @@ import { IDropRowInfo } from "./tile-row"; export interface IDocumentAddTileOptions { title?: string; - addSidecarNotes?: boolean; url?: string; } @@ -21,7 +20,6 @@ export interface INewTileOptions { export interface INewRowTile { rowId: string; tileId: string; - additionalTileIds?: string[]; } export type NewRowTileArray = Array; diff --git a/src/models/document/document.test.ts b/src/models/document/document.test.ts index ae9f9f32bd..7b2b32e824 100644 --- a/src/models/document/document.test.ts +++ b/src/models/document/document.test.ts @@ -180,9 +180,8 @@ describe("document model", () => { expect(document.content!.tileMap.size).toBe(0); document.addTile("text"); expect(document.content!.tileMap.size).toBe(1); - // adding geometry tool adds sidecar text tool - document.addTile("geometry", {addSidecarNotes: true}); - expect(document.content!.tileMap.size).toBe(3); + document.addTile("geometry"); + expect(document.content!.tileMap.size).toBe(2); }); it("allows tiles to be deleted", () => { diff --git a/src/models/shared/shared-data-set.ts b/src/models/shared/shared-data-set.ts index de76aacb20..d9130542ec 100644 --- a/src/models/shared/shared-data-set.ts +++ b/src/models/shared/shared-data-set.ts @@ -128,12 +128,30 @@ function flattenedMap(sharedDatasetIds: UpdatedSharedDataSetIds[]) { return map; } -export function replaceJsonStringsWithUpdatedIds(json: unknown, ...sharedDatasetIds: UpdatedSharedDataSetIds[]) { +/** + * Find all IDs referenced in the JSON and replace them. This method assumes + * we're dealing with IDs that are globally unique, so all the replacement lists + * can be merged together without duplication. + * + * The separator pattern is normally just a double quote, if IDs are expected to + * be found as string values in the JSON. However, it can be a different string; + * for example the Geometry uses quote and colon since there are JSON values + * like "ID:ID" and each ID needs to be separately replaced. + * @param json + * @param separator + * @param sharedDatasetIds + * @returns updated json + */ +export function replaceJsonStringsWithUpdatedIds(json: unknown, separator: string, + ...sharedDatasetIds: UpdatedSharedDataSetIds[]) { const flatMap = flattenedMap(sharedDatasetIds); - const keyPattern = Object.keys(flatMap).map(key => escapeStringRegexp(key)).join("|"); - const matchRegexp = new RegExp(`\\"(${keyPattern})\\"`, "g"); - const updated = JSON.stringify(json).replace(matchRegexp, (match, key) => { - return `"${flatMap[key]}"`; + const keys = Object.keys(flatMap); + if (keys.length === 0) { return json; } + + const keyPattern = keys.map(key => escapeStringRegexp(key)).join("|"); + const matchRegexp = new RegExp(`(?<=${separator})(${keyPattern})(?=${separator})`, "g"); + const updated = JSON.stringify(json).replace(matchRegexp, (match) => { + return `${flatMap[match]}`; }); return JSON.parse(updated); } diff --git a/src/models/tiles/geometry/geometry-content.test.ts b/src/models/tiles/geometry/geometry-content.test.ts index 0a77f936f9..8a70f1c56a 100644 --- a/src/models/tiles/geometry/geometry-content.test.ts +++ b/src/models/tiles/geometry/geometry-content.test.ts @@ -5,10 +5,10 @@ import { } from "./geometry-content"; import { CommentModel, defaultBoard, ImageModel, MovableLineModel, PointModel, PolygonModel, - PolygonModelType, segmentIdFromPointIds, VertexAngleModel + PolygonModelType, segmentIdFromPointIds, VertexAngleModel, VertexAngleModelType } from "./geometry-model"; import { kGeometryTileType } from "./geometry-types"; -import { ESegmentLabelOption, JXGChange } from "./jxg-changes"; +import { ELabelOption, JXGChange, JXGCoordPair } from "./jxg-changes"; import { isPointInPolygon, getPointsForVertexAngle, getPolygonEdge } from "./jxg-polygon"; import { canSupportVertexAngle, getVertexAngle, updateVertexAnglesFromObjects } from "./jxg-vertex-angle"; import { @@ -16,6 +16,8 @@ import { isText, kGeometryDefaultPixelsPerUnit, kGeometryDefaultXAxisMin, kGeometryDefaultYAxisMin } from "./jxg-types"; import { TileModel, ITileModel } from "../tile-model"; +import { getPoint, getPolygon } from "./geometry-utils"; +import placeholderImage from "../../../assets/image_placeholder.png"; // This is needed so MST can deserialize snapshots referring to tools import { registerTileTypes } from "../../../register-tile-types"; @@ -29,8 +31,6 @@ jest.mock( "../../../utilities/image-utils", () => ({ Promise.resolve({ src: "test-file-stub", width: 200, height: 150 })) })); -import placeholderImage from "../../../assets/image_placeholder.png"; - // mock Logger calls const mockLogTileChangeEvent = jest.fn(); jest.mock("../log/log-tile-change-event", () => ({ @@ -38,11 +38,12 @@ jest.mock("../log/log-tile-change-event", () => ({ })); // mock uniqueId so we can recognize auto-generated IDs -const { uniqueId, castArrayCopy, safeJsonParse } = jest.requireActual("../../../utilities/js-utils"); +const { uniqueId, castArrayCopy, safeJsonParse, notEmpty } = jest.requireActual("../../../utilities/js-utils"); jest.mock("../../../utilities/js-utils", () => ({ uniqueId: () => `testid-${uniqueId()}`, castArrayCopy: (itemOrArray: any) => castArrayCopy(itemOrArray), - safeJsonParse: (json: string) => safeJsonParse(json) + safeJsonParse: (json: string) => safeJsonParse(json), + notEmpty: (value:any) => notEmpty(value) })); let message = () => ""; @@ -105,6 +106,25 @@ declare global { } } +function buildPolygon(board: JXG.Board, content: GeometryContentModelType, + coordinates: JXGCoordPair[], finalVertexClicked=0) { + const points: JXG.Point[] = []; + content.addPhantomPoint(board, [0, 0]); + coordinates.forEach(pair => { + const { point } = content.realizePhantomPoint(board, pair, true); + if (point) points.push(point); + }); + const polygon = content.closeActivePolygon(board, points[finalVertexClicked]); + assertIsDefined(polygon); + return { polygon, points }; +} + +function exportAndSimplifyIds(content: GeometryContentModelType) { + return content.exportJson() + .replaceAll(/testid-[a-zA-Z0-9_-]+/g, "testid") + .replaceAll(/jxgBoard[a-zA-Z0-9_-]+/g, "jxgid"); +} + describe("GeometryContent", () => { const divId = "1234"; @@ -115,7 +135,7 @@ describe("GeometryContent", () => { function onCreate(elt: JXG.GeometryElement) { // handle a point } - const board = content.initializeBoard(divId, onCreate) as JXG.Board; + const board = content.initializeBoard(divId, onCreate, (b) => {}) as JXG.Board; content.resizeBoard(board, 200, 200); content.updateScale(board, 0.5); return board; @@ -168,7 +188,8 @@ describe("GeometryContent", () => { it("can create with default properties", () => { const content = GeometryContentModel.create(); - expect(getSnapshot(content)).toEqual({ type: kGeometryTileType, board: defaultBoard(), objects: {} }); + expect(getSnapshot(content)).toEqual( + { type: kGeometryTileType, board: defaultBoard(), objects: {}, linkedAttributeColors: {}, pointMetadata: {} }); destroy(content); }); @@ -190,7 +211,9 @@ describe("GeometryContent", () => { xAxis: { name: "authorX", min: kGeometryDefaultXAxisMin, unit: kGeometryDefaultPixelsPerUnit }, yAxis: { name: "authorY", min: kGeometryDefaultYAxisMin, unit: kGeometryDefaultPixelsPerUnit } }, - objects: {} + objects: {}, + linkedAttributeColors: {}, + pointMetadata: {} }); destroy(content); @@ -205,7 +228,6 @@ describe("GeometryContent", () => { content.resizeBoard(board, 200, 200); content.updateScale(board, 0.5); - expect(board.cssTransMat).toEqual([[1, 0, 0], [0, 2, 0], [0, 0, 2]]); const boardId = board.id; const boundingBox = clone(board.attr.boundingbox); @@ -227,9 +249,6 @@ describe("GeometryContent", () => { content.syncChange(null as any as JXG.Board, null as any as JXGChange); - const polygon = content.createPolygonFromFreePoints(board); - expect(polygon).toBeUndefined(); - // can delete board with change content.applyChange(board, { operation: "delete", target: "board", targetID: boardId }); @@ -254,11 +273,12 @@ describe("GeometryContent", () => { expect(content.board?.xAxis.name).toBe("xName"); expect(content.board?.xAxis.label).toBe("xAnnotation"); expect(content.board?.xAxis.min).toBe(-1); - expect(content.board?.xAxis.range).toBe(10); expect(content.board?.yAxis.name).toBe("yName"); expect(content.board?.yAxis.label).toBe("yAnnotation"); expect(content.board?.yAxis.min).toBe(-2); - expect(content.board?.yAxis.range).toBe(5); + // Scales are forced to be equal, and Y axis is slightly longer than X axis + expect(content.board?.xAxis.range).toBe(10); + expect(content.board?.yAxis.range).toBeCloseTo(11.4286); const xAxis = content.board?.xAxis; if (xAxis) { @@ -284,7 +304,8 @@ describe("GeometryContent", () => { let p1: JXG.Point = board.objects[p1Id] as JXG.Point; expect(p1).toBeUndefined(); p1 = content.addPoint(board, [1, 1], { id: p1Id }) as JXG.Point; - expect(content.lastObject).toEqual({ id: p1Id, type: "point", x: 1, y: 1 }); + expect(content.lastObject).toEqual({ id: p1Id, type: "point", x: 1, y: 1, colorScheme: 0, + labelOption: "none", name: undefined, snapToGrid: undefined }); expect(isPoint(p1)).toBe(true); expect(isFreePoint(p1)).toBe(true); // won't create generic objects @@ -308,6 +329,9 @@ describe("GeometryContent", () => { expect(p1.getAttribute("fixed")).toBe(true); content.updateObjects(board, "foo", { }); content.applyChange(board, { operation: "update", target: "point" }); + content.removeObjects(board, p1Id); // should not be removed because it is "fixed" + expect(board.objects[p1Id]).toBeDefined(); + content.updateObjects(board, [p1Id], { fixed: false }); content.removeObjects(board, p1Id); expect(board.objects[p1Id]).toBeUndefined(); const p3: JXG.Point = content.addPoint(board, [2, 2]) as JXG.Point; @@ -323,7 +347,8 @@ describe("GeometryContent", () => { const { content, board } = createContentAndBoard(); const p1Id = "point-1"; content.addPoint(board, [1, 1], { id: p1Id }) as JXG.Point; - expect(content.lastObject).toEqual({ id: p1Id, type: "point", x: 1, y: 1 }); + expect(content.lastObject).toEqual({ id: p1Id, type: "point", x: 1, y: 1, colorScheme: 0, + labelOption: "none", name: undefined, snapToGrid: undefined }); // add comment to point const [comment] = content.addComment(board, p1Id)!; @@ -339,31 +364,202 @@ describe("GeometryContent", () => { it("can add/remove/update polygons", () => { const { content, board } = createContentAndBoard(); - content.addPoints(board, [[1, 1], [3, 3], [5, 1]], [{ id: "p1" }, { id: "p2" }, { id: "p3" }]); - expect(content.lastObject).toEqual({ id: "p3", type: "point", x: 5, y: 1 }); - let polygon: JXG.Polygon | undefined = content.createPolygonFromFreePoints(board) as JXG.Polygon; - expect(content.lastObject).toEqual({ id: polygon.id, type: "polygon", points: ["p1", "p2", "p3"] }); + const { polygon, points } = buildPolygon(board, content, [[1, 1], [3, 3], [5, 1]]); + expect(content.lastObjectOfType("polygon")).toEqual({ + id: polygon?.id, type: "polygon", points: [ points[0].id, points[1].id, points[2].id ], + colorScheme: 0, labelOption: "none" }); expect(isPolygon(polygon)).toBe(true); - const polygonId = polygon.id; - expect(polygonId.startsWith("testid-")).toBe(true); - expect(content.getDependents(["p1"])).toEqual(["p1", polygonId]); - expect(content.getDependents(["p1"], { required: true })).toEqual(["p1"]); - expect(content.getDependents(["p3"])).toEqual(["p3", polygonId]); - expect(content.getDependents(["p3"], { required: true })).toEqual(["p3"]); + const polygonId = polygon?.id; + expect(content.getDependents([points[0].id])).toEqual([points[0].id, polygonId]); + expect(content.getDependents([points[0].id], { required: true })).toEqual([points[0].id]); + expect(content.getDependents([points[2].id])).toEqual([points[2].id, polygonId]); + expect(content.getDependents([points[2].id||''], { required: true })).toEqual([points[2].id]); + + expect(points.length).toEqual(3); + expect(points[0].coords.usrCoords).toEqual([1, 1, 1]); + expect(points[1].coords.usrCoords).toEqual([1, 3, 3]); + expect(points[2].coords.usrCoords).toEqual([1, 5, 1]); const ptInPolyCoords = new JXG.Coords(JXG.COORDS_BY_USER, [3, 2], board); const [, ptInScrX, ptInScrY] = ptInPolyCoords.scrCoords; - expect(isPointInPolygon(ptInScrX, ptInScrY, polygon)).toBe(true); + expect(polygon && isPointInPolygon(ptInScrX, ptInScrY, polygon)).toBe(true); const ptOutPolyCoords = new JXG.Coords(JXG.COORDS_BY_USER, [4, 4], board); const [, ptOutScrX, ptOutScrY] = ptOutPolyCoords.scrCoords; - expect(isPointInPolygon(ptOutScrX, ptOutScrY, polygon)).toBe(false); + expect(polygon && isPointInPolygon(ptOutScrX, ptOutScrY, polygon)).toBe(false); - content.removeObjects(board, polygonId); - expect(content.getObject(polygonId)).toBeUndefined(); - expect(board.objects[polygonId]).toBeUndefined(); + polygonId && content.removeObjects(board, polygonId); + expect(polygonId && content.getObject(polygonId)).toBeUndefined(); + expect(board.objects[polygonId||'']).toBeUndefined(); // can't create polygon without vertices - polygon = content.applyChange(board, { operation: "create", target: "polygon" }) as any as JXG.Polygon; - expect(polygon).toBeUndefined(); + const badpoly = content.applyChange(board, { operation: "create", target: "polygon" }) as any as JXG.Polygon; + expect(badpoly).toBeUndefined(); + + destroyContentAndBoard(content, board); + }); + + it("handles vertex angles in polygons properly", () => { + let polygonId; + const { content, board } = createContentAndBoard((_content) => { + _content.addObjectModel(PointModel.create({ id: "p1", x: 1, y: 1 })); + _content.addObjectModel(PointModel.create({ id: "p2", x: 3, y: 3 })); + _content.addObjectModel(PointModel.create({ id: "p3", x: 5, y: 1 })); + _content.addObjectModel(PointModel.create({ id: "p5", x: 10, y: 7 })); + polygonId = _content.addObjectModel(PolygonModel.create({ points: ["p1", "p2", "p3"] })); + }); + assertIsDefined(polygonId); + const poly = content.getObject(polygonId) as PolygonModelType; + content.addVertexAngle(board, ["p3", "p1", "p2"], { id: "va1" }); + content.addVertexAngle(board, ["p1", "p2", "p3"], { id: "va2" }); + content.addVertexAngle(board, ["p2", "p3", "p1"], { id: "va3" }); + + expect(getPolygon(board, polygonId)!.vertices.map(v=>v.id)).toEqual(["p1", "p2", "p3", "p1"]); + expect(poly.points).toEqual(["p1", "p2", "p3"]); + expect((content.getObject("va1") as VertexAngleModelType).points).toEqual(["p3", "p1", "p2"]); + + // Simulate going back into polygon mode, clicking one of the vertices, and adding some points to the polygon + const p4 = content.addPhantomPoint(board, [1, 1])!; + content.makePolygonActive(board, polygonId, "p2"); + expect(poly.points).toEqual(["p3", "p1", "p2"]); + expect(getPolygon(board, polygonId)!.vertices.map(v=>v.id)).toEqual(["p3", "p1", "p2", p4.id, "p3"]); + expect((content.getObject("va1") as VertexAngleModelType).points).toEqual(["p3", "p1", "p2"]); + expect((content.getObject("va2") as VertexAngleModelType).points).toEqual(["p1", "p2", p4.id]); + expect((content.getObject("va3") as VertexAngleModelType).points).toEqual([p4.id, "p3", "p1"]); + + content.realizePhantomPoint(board, [1, 1], true); + const p6 = content.phantomPoint!; + expect(poly.points).toEqual(["p3", "p1", "p2", p4.id]); + expect(getPolygon(board, polygonId)!.vertices.map(v=>v.id)).toEqual(["p3", "p1", "p2", p4.id, p6.id, "p3"]); + expect((content.getObject("va1") as VertexAngleModelType).points).toEqual(["p3", "p1", "p2"]); + expect((content.getObject("va2") as VertexAngleModelType).points).toEqual(["p1", "p2", p4.id]); + expect((content.getObject("va3") as VertexAngleModelType).points).toEqual([p6.id, "p3", "p1"]); + + content.addPointToActivePolygon(board, "p5"); + expect(poly.points).toEqual(["p3", "p1", "p2", p4.id, "p5"]); + expect(getPolygon(board, polygonId)!.vertices.map(v=>v.id)).toEqual(["p3", "p1", "p2", p4.id, "p5", p6.id, "p3"]); + expect((content.getObject("va1") as VertexAngleModelType).points).toEqual(["p3", "p1", "p2"]); + expect((content.getObject("va2") as VertexAngleModelType).points).toEqual(["p1", "p2", p4.id]); + expect((content.getObject("va3") as VertexAngleModelType).points).toEqual([p6.id, "p3", "p1"]); + + // Shortcut polygon by clicking p1 rather than the expected p3. p3 gets cut out. + content.closeActivePolygon(board, getPoint(board, "p1")!); + expect(poly.points).toEqual(["p1", "p2", p4.id, "p5"]); + expect(getPolygon(board, polygonId)!.vertices.map(v=>v.id)).toEqual(["p1", "p2", p4.id, "p5", "p1"]); + expect((content.getObject("va1") as VertexAngleModelType).points).toEqual(["p5", "p1", "p2"]); + expect((content.getObject("va2") as VertexAngleModelType).points).toEqual(["p1", "p2", p4.id]); + expect(content.getObject("va3")).toBeUndefined(); + }); + + it("can short-circuit a polygon", () => { + const { content, board } = createContentAndBoard(); + const { polygon, points } = buildPolygon(board, content, [[1, 1], [3, 3], [7, 4], [5, 1]], 1); + expect(content.lastObjectOfType("polygon")).toEqual({ + id: polygon?.id, type: "polygon", points: [ points[1].id, points[2].id, points[3].id ], + colorScheme: 0, labelOption: "none" }); + expect(isPolygon(polygon)).toBe(true); + const polygonId = polygon?.id; + // point 0 should have been freed + expect(content.getDependents([points[0].id])).toEqual([points[0].id]); + expect(content.getDependents([points[0].id], { required: true })).toEqual([points[0].id]); + // the rest of the points are in the poly + expect(content.getDependents([points[1].id])).toEqual([points[1].id, polygonId]); + expect(content.getDependents([points[1].id], { required: true })).toEqual([points[1].id]); + expect(content.getDependents([points[2].id])).toEqual([points[2].id, polygonId]); + expect(content.getDependents([points[2].id||''], { required: true })).toEqual([points[2].id]); + expect(content.getDependents([points[3].id])).toEqual([points[3].id, polygonId]); + expect(content.getDependents([points[3].id||''], { required: true })).toEqual([points[3].id]); + destroyContentAndBoard(content, board); + }); + + it("can create polygon from existing points", () => { + const { content, board } = createContentAndBoard((_content) => { + _content.addObjectModel(PointModel.create({ id: "p1", x: 1, y: 1, colorScheme: 3 })); + _content.addObjectModel(PointModel.create({ id: "p2", x: 3, y: 3, colorScheme: 2 })); + _content.addObjectModel(PointModel.create({ id: "p3", x: 5, y: 1 })); + }); + const phantom = content.addPhantomPoint(board, [0, 0]); + + let polygon = content.createPolygonIncludingPoint(board, "p1"); + assertIsDefined(polygon); + expect(polygon.vertices.map(v => v.id)).toEqual(["p1", phantom?.id, "p1"]); + const polyModel = content.getObject(polygon.id) as PolygonModelType; + assertIsDefined(polyModel); + expect(polyModel.points).toEqual(["p1"]); + + polygon = content.addPointToActivePolygon(board, "p2")!; + expect(polygon.vertices.map(v => v.id)).toEqual(["p1", "p2", phantom?.id, "p1"]); + expect(polyModel.points).toEqual(["p1", "p2"]); + + polygon = content.addPointToActivePolygon(board, "p3")!; + expect(polygon.vertices.map(v => v.id)).toEqual(["p1", "p2", "p3", phantom?.id, "p1"]); + expect(polyModel.points).toEqual(["p1", "p2", "p3"]); + + polygon = content.closeActivePolygon(board, getPoint(board, "p1")!)!; + expect(polygon.vertices.map(v => v.id)).toEqual(["p1", "p2", "p3", "p1"]); + expect(polyModel.points).toEqual(["p1", "p2", "p3"]); + expect(polyModel.colorScheme).toEqual(3); // Starting point sets color + destroyContentAndBoard(content, board); + }); + + it("can make two polygons that share a vertex", () => { + const { content, board } = createContentAndBoard(); + // first polygon + const { polygon, points } = buildPolygon(board, content, [[0, 0], [1, 1], [2, 2]]); // points 0, 1, 2 + // second polygon + points.push(content.realizePhantomPoint(board, [5, 5], true).point!); // point 3 + points.push(content.realizePhantomPoint(board, [4, 4], true).point!); // point 4 + content.addPointToActivePolygon(board, points[2].id); + const polygon2 = content.closeActivePolygon(board, points[3])!; + expect(polygon?.vertices.map(v => v.id)).toEqual([points[0].id, points[1].id, points[2].id, points[0].id]); + expect(polygon2.vertices.map(v => v.id)).toEqual([points[3].id, points[4].id, points[2].id, points[3].id]); + + expect(content.getDependents([points[2].id])).toEqual([points[2].id, polygon?.id, polygon2.id]); + destroyContentAndBoard(content, board); + }); + + it("can extend a polygon with additional points", () => { + const { content, board } = createContentAndBoard((_content) => { + _content.addObjectModel(PointModel.create({ id: "extra1", x: 1, y: 1, colorScheme: 3 })); + }); + const { polygon, points } = buildPolygon(board, content, [[1, 1], [3, 3], [7, 4]], 0); + expect(polygon?.vertices.map(v => v.id)).toEqual([points[0].id, points[1].id, points[2].id, points[0].id]); + expect(content.lastObjectOfType("polygon")).toEqual({ + id: polygon?.id, type: "polygon", points: [ points[0].id, points[1].id, points[2].id ], + colorScheme: 0, labelOption: "none" }); + + // Let's add some points between point[1] and points[2]. + let newPoly = content.makePolygonActive(board, polygon.id, points[1].id); + expect(newPoly?.vertices.map(v => v.id)).toEqual( + [points[2].id, points[0].id, points[1].id, content.phantomPoint?.id, points[2].id]); + expect(content.lastObjectOfType("polygon")).toEqual({ + id: polygon?.id, type: "polygon", points: [ points[2].id, points[0].id, points[1].id ], + colorScheme: 0, labelOption: "none" }); + + // Add existing point + newPoly = content.addPointToActivePolygon(board, "extra1"); + expect(newPoly?.vertices.map(v => v.id)).toEqual( + [points[2].id, points[0].id, points[1].id, "extra1", content.phantomPoint?.id, points[2].id]); + expect(content.lastObjectOfType("polygon")).toEqual({ + id: polygon?.id, type: "polygon", + points: [ points[2].id, points[0].id, points[1].id, "extra1" ], colorScheme: 0, labelOption: "none" }); + + // Add new point + const result = content.realizePhantomPoint(board, [10, 10], true); + newPoly = result.polygon; + const newPoint = result.point; + expect(newPoly?.vertices.map(v => v.id)).toEqual( + [points[2].id, points[0].id, points[1].id, "extra1", newPoint?.id, content.phantomPoint?.id, points[2].id]); + expect(content.lastObjectOfType("polygon")).toEqual({ + id: polygon?.id, type: "polygon", + points: [ points[2].id, points[0].id, points[1].id, "extra1", newPoint?.id ], + colorScheme: 0, labelOption: "none" }); + + newPoly = content.closeActivePolygon(board, points[2]); + expect(newPoly?.vertices.map(v => v.id)).toEqual( + [points[2].id, points[0].id, points[1].id, "extra1", newPoint?.id, points[2].id]); + expect(content.lastObjectOfType("polygon")).toEqual({ + id: polygon?.id, type: "polygon", + points: [ points[2].id, points[0].id, points[1].id, "extra1", newPoint?.id ], + colorScheme: 0, labelOption: "none" }); destroyContentAndBoard(content, board); }); @@ -400,19 +596,17 @@ describe("GeometryContent", () => { it("can add comments to polygons", () => { const { content, board } = createContentAndBoard(); - content.addPoints(board, [[0, 0], [0, 2], [2, 2], [2, 0]], - [{ id: "p1" }, { id: "p2" }, { id: "p3" }, { id: "p4" }]); - const polygon: JXG.Polygon | undefined = content.createPolygonFromFreePoints(board) as JXG.Polygon; - + const { polygon } = buildPolygon(board, content, [[0, 0], [0, 2], [2, 2], [2, 0]]); + expect(polygon).toBeTruthy(); // add comment to polygon - const [comment] = content.addComment(board, polygon.id)!; - expect(content.lastObject).toEqual({ id: comment.id, type: "comment", anchors: [polygon.id] }); + const [comment] = content.addComment(board, polygon!.id)!; + expect(content.lastObject).toEqual({ id: comment.id, type: "comment", anchors: [polygon!.id] }); expect(isComment(comment)).toBe(true); // update comment text content.updateObjects(board, comment.id, { position: [5, 5], text: "new" }); expect(content.lastObject).toEqual( - { id: comment.id, type: "comment", anchors: [polygon.id], x: 4, y: 4, text: "new" }); + { id: comment.id, type: "comment", anchors: [polygon!.id], x: 4, y: 4, text: "new" }); destroyContentAndBoard(content, board); }); @@ -453,7 +647,8 @@ describe("GeometryContent", () => { _content.addObjectModel(PointModel.create({ id: "p3", x: 5, y: 1 })); polygonId = _content.addObjectModel(PolygonModel.create({ points: ["p1", "p2", "p3"], - labels: [{ id: segmentIdFromPointIds(["p1", "p2"]), option: ESegmentLabelOption.kLength }] + colorScheme: 0, + labels: [{ id: segmentIdFromPointIds(["p1", "p2"]), option: ELabelOption.kLength }] })); }); const polygon: JXG.Polygon | undefined = board.objects[polygonId] as JXG.Polygon; @@ -477,27 +672,33 @@ describe("GeometryContent", () => { const p1 = board.objects.p1 as JXG.Point; const p2 = board.objects.p2 as JXG.Point; const p3 = board.objects.p3 as JXG.Point; - content.updatePolygonSegmentLabel(board, polygon, [p1, p2], ESegmentLabelOption.kLabel); + content.updatePolygonSegmentLabel(board, polygon, [p1, p2], ELabelOption.kLabel, "seg1"); expect(content.getObject(polygon.id)).toEqual({ id: polygonId, type: "polygon", points: ["p1", "p2", "p3"], - labels: [{ id: segmentIdFromPointIds(["p1", "p2"]), option: ESegmentLabelOption.kLabel }] + colorScheme: 0, + labelOption: "none", + labels: [{ id: segmentIdFromPointIds(["p1", "p2"]), option: ELabelOption.kLabel, name: "seg1" }] }); - content.updatePolygonSegmentLabel(board, polygon, [p2, p3], ESegmentLabelOption.kLength); + content.updatePolygonSegmentLabel(board, polygon, [p2, p3], ELabelOption.kLength, "seg2"); expect(content.getObject(polygon.id)).toEqual({ id: polygonId, type: "polygon", points: ["p1", "p2", "p3"], - labels: [{ id: segmentIdFromPointIds(["p1", "p2"]), option: ESegmentLabelOption.kLabel }, - { id: segmentIdFromPointIds(["p2", "p3"]), option: ESegmentLabelOption.kLength }] + colorScheme: 0, + labelOption: "none", + labels: [{ id: segmentIdFromPointIds(["p1", "p2"]), option: ELabelOption.kLabel, name: "seg1" }, + { id: segmentIdFromPointIds(["p2", "p3"]), option: ELabelOption.kLength, name: "seg2" }] }); - content.updatePolygonSegmentLabel(board, polygon, [p1, p2], ESegmentLabelOption.kNone); + content.updatePolygonSegmentLabel(board, polygon, [p1, p2], ELabelOption.kNone, undefined); expect(content.getObject(polygon.id)).toEqual({ id: polygonId, type: "polygon", + colorScheme: 0, + labelOption: "none", points: ["p1", "p2", "p3"], - labels: [{ id: segmentIdFromPointIds(["p2", "p3"]), option: ESegmentLabelOption.kLength }] + labels: [{ id: segmentIdFromPointIds(["p2", "p3"]), option: ELabelOption.kLength, name: "seg2" }] }); content.removeObjects(board, polygonId); @@ -557,17 +758,18 @@ describe("GeometryContent", () => { it("can select points, etc.", () => { const { content, board } = createContentAndBoard(); - const p1 = content.addPoint(board, [0, 0]); - const p2 = content.addPoint(board, [1, 1]); - const p3 = content.addPoint(board, [1, 0]); - const poly = content.createPolygonFromFreePoints(board); - expect(content.lastObject).toEqual({ id: poly?.id, type: "polygon", points: [p1!.id, p2!.id, p3!.id] }); + const { points, polygon } = buildPolygon(board, content, [[0, 0], [1, 1], [1, 0]]); + const [p1, p2, p3] = points; + expect(content.lastObjectOfType("polygon")).toEqual( + { id: polygon?.id, type: "polygon", colorScheme: 0, points: [p1!.id, p2!.id, p3!.id], + labelOption: "none" + }); content.selectObjects(board, p1!.id); expect(content.isSelected(p1!.id)).toBe(true); expect(content.isSelected(p2!.id)).toBe(false); expect(content.isSelected(p3!.id)).toBe(false); - content.selectObjects(board, poly!.id); - expect(content.isSelected(poly!.id)).toBe(true); + content.selectObjects(board, polygon!.id); + expect(content.isSelected(polygon!.id)).toBe(true); expect(content.hasSelection()).toBe(true); let found = content.findObjects(board, (obj: JXG.GeometryElement) => obj.id === p1!.id); expect(found.length).toBe(1); @@ -586,11 +788,12 @@ describe("GeometryContent", () => { it("can add a vertex angle to a polygon", () => { const { content, board } = createContentAndBoard(); - const p0: JXG.Point = content.addPoint(board, [0, 0])!; - const px: JXG.Point = content.addPoint(board, [1, 0])!; - const py: JXG.Point = content.addPoint(board, [0, 1])!; - const poly: JXG.Polygon = content.createPolygonFromFreePoints(board)!; - expect(content.lastObject).toEqual({ id: poly?.id, type: "polygon", points: [p0!.id, px!.id, py!.id] }); + const { points, polygon } = buildPolygon(board, content, [[0, 0], [1, 0], [0, 1]]); + const [p0, px, py] = points; + expect(content.lastObjectOfType("polygon")).toEqual( + { id: polygon?.id, type: "polygon", colorScheme: 0, points: [p0!.id, px!.id, py!.id], + labelOption: "none" + }); const pSolo: JXG.Point = content.addPoint(board, [9, 9])!; expect(canSupportVertexAngle(p0)).toBe(true); expect(canSupportVertexAngle(pSolo)).toBe(false); @@ -604,20 +807,21 @@ describe("GeometryContent", () => { expect(getVertexAngle(p0)!.id).toBe(va0!.id); expect(getVertexAngle(px)!.id).toBe(vax!.id); expect(getVertexAngle(py)!.id).toBe(vay!.id); - expect(content.getDependents([p0!.id])).toEqual([p0!.id, poly!.id, va0!.id, vax!.id, vay!.id]); + expect(content.getDependents([p0!.id])).toEqual([p0!.id, polygon!.id, va0!.id, vax!.id, vay!.id]); expect(content.getDependents([p0!.id], { required: true })).toEqual([p0!.id, va0!.id, vax!.id, vay!.id]); expect(getPointsForVertexAngle(pSolo)).toBeUndefined(); expect(getPointsForVertexAngle(p0)!.map(p => p.id)).toEqual([px.id, p0.id, py.id]); expect(getPointsForVertexAngle(px)!.map(p => p.id)).toEqual([py.id, px.id, p0.id]); expect(getPointsForVertexAngle(py)!.map(p => p.id)).toEqual([p0.id, py.id, px.id]); p0.setPosition(JXG.COORDS_BY_USER, [1, 1]); - updateVertexAnglesFromObjects([p0, px, py, poly]); + updateVertexAnglesFromObjects([p0, px, py, polygon!]); expect(getPointsForVertexAngle(p0)!.map(p => p.id)).toEqual([py.id, p0.id, px.id]); content.removeObjects(board, [p0!.id]); expect(content.getObject(p0!.id)).toBeUndefined(); // first point can be removed from polygon without deleting polygon - expect(content.getObject(poly!.id)).toEqual({ id: poly?.id, type: "polygon", points: [px!.id, py!.id] }); + expect(content.getObject(polygon!.id)).toEqual( + { id: polygon?.id, type: "polygon", colorScheme: 0, points: [px!.id, py!.id], labelOption: "none" }); // vertex angles are deleted when any dependent point is deleted expect(content.getObject(va0!.id)).toBeUndefined(); expect(content.getObject(vax!.id)).toBeUndefined(); @@ -626,7 +830,7 @@ describe("GeometryContent", () => { // removing second point results in removal of polygon content.removeObjects(board, [px!.id]); expect(content.getObject(px!.id)).toBeUndefined(); - expect(content.getObject(poly!.id)).toBeUndefined(); + expect(content.getObject(polygon!.id)).toBeUndefined(); expect(content.applyChange(board, { operation: "create", target: "vertexAngle" })).toBeUndefined(); }); @@ -666,7 +870,10 @@ describe("GeometryContent", () => { content.removeObjects(board, [p0!.id]); expect(content.getObject(p0!.id)).toBeUndefined(); // first point can be removed from polygon without deleting polygon - expect(content.getObject(poly!.id)).toEqual({ id: poly?.id, type: "polygon", points: [px!.id, py!.id] }); + expect(content.getObject(poly!.id)).toEqual( + { id: poly?.id, type: "polygon", colorScheme: 0, points: [px!.id, py!.id], + labelOption: "none" + }); // vertex angles are deleted when any dependent point is deleted expect(content.getObject(vAngle0Id)).toBeUndefined(); expect(content.getObject(vAngleXId)).toBeUndefined(); @@ -720,8 +927,12 @@ describe("GeometryContent", () => { content.addMovableLine(board, [[1, 1], [5, 5]], { id: "ml" }); expect(content.lastObject).toEqual({ id: "ml", type: "movableLine", - p1: { id: "ml-point1", type: "point", x: 1, y: 1 }, - p2: { id: "ml-point2", type: "point", x: 5, y: 5 } }); + colorScheme: 0, + p1: { id: "ml-point1", type: "point", colorScheme: 0, x: 1, y: 1, + labelOption: "none", name: undefined, snapToGrid: undefined }, + p2: { id: "ml-point2", type: "point", colorScheme: 0, x: 5, y: 5, + labelOption: "none", name: undefined, snapToGrid: undefined } + }); const line = board.objects.ml as JXG.Line; expect(isMovableLine(line)).toBe(true); const [comment] = content.addComment(board, "ml")!; @@ -737,15 +948,23 @@ describe("GeometryContent", () => { expect(p1).toEqual({ id: "ml-point1", type: "point", + colorScheme: 0, x: 1, - y: 1 + y: 1, + labelOption: "none", + name: undefined, + snapToGrid: undefined }); const p2 = content.getAnyObject("ml-point2"); expect(p2).toEqual({ id: "ml-point2", type: "point", + colorScheme: 0, x: 5, - y:5 + y:5, + labelOption: "none", + name: undefined, + snapToGrid: undefined }); // removing the line removes the line and its comment from the model and the board @@ -772,23 +991,22 @@ describe("GeometryContent", () => { it("can copy selected objects", () => { const { content, board } = createContentAndBoard(); - const p0: JXG.Point = content.addPoint(board, [0, 0])!; - const px: JXG.Point = content.addPoint(board, [1, 0])!; - const py: JXG.Point = content.addPoint(board, [0, 1])!; - const poly: JXG.Polygon = content.createPolygonFromFreePoints(board)!; - const polygon = content.getObject(poly.id)! as PolygonModelType; - expect(polygon.type).toBe("polygon"); + const { points, polygon } = buildPolygon(board, content, [[0, 0], [1, 0], [0, 1]]); + const [p0, px, py] = points; + const polygonModel = content.getObject(polygon!.id) as PolygonModelType; + expect(polygonModel?.type).toBe("polygon"); // copies selected points content.selectObjects(board, p0.id); expect(content.getSelectedIds(board)).toEqual([p0.id]); expect(content.copySelection(board)) - .toEqualWithUniqueIds([PointModel.create({ id: p0.id, x: 0, y: 0 })]); + .toEqualWithUniqueIds([PointModel.create( + { id: p0.id, x: 0, y: 0, snapToGrid:true, colorScheme: 0 })]); // copies comments along with selected points const [comment] = content.addComment(board, p0.id, "p0 comment") || []; expect(content.copySelection(board)).toEqualWithUniqueIds([ - PointModel.create({ id: p0.id, x: 0, y: 0 }), + PointModel.create({ id: p0.id, x: 0, y: 0, snapToGrid: true, colorScheme: 0 }), CommentModel.create({ id: comment.id, anchors: [p0.id], text: "p0 comment"}) ]); content.removeObjects(board, [comment.id]); @@ -797,21 +1015,24 @@ describe("GeometryContent", () => { // content.selectObjects(board, poly.id); expect(content.getSelectedIds(board)).toEqual([p0.id]); expect(content.copySelection(board)) - .toEqualWithUniqueIds([PointModel.create({ id: p0.id, x: 0, y: 0 })]); + .toEqualWithUniqueIds([PointModel.create({ id: p0.id, x: 0, y: 0, snapToGrid: true, colorScheme: 0 })]); + + // For comparison purposes, we need the polygon to be after the points in the array of objects + const origObjects = Array.from(content.objects.values()).sort((a,b)=>a.type.localeCompare(b.type)); // copies polygons if all vertices are selected content.selectObjects(board, [px.id, py.id]); expect(content.getSelectedIds(board)).toEqual([p0.id, px.id, py.id]); expect(content.copySelection(board)) - .toEqualWithUniqueIds(Array.from(content.objects.values())); + .toEqualWithUniqueIds(origObjects); // copies segment labels when copying polygons - polygon.setSegmentLabel([p0.id, px.id], ESegmentLabelOption.kLabel); + polygonModel?.setSegmentLabel([p0.id, px.id], ELabelOption.kLabel, "name1"); content.selectObjects(board, [p0.id, px.id, py.id]); expect(content.copySelection(board)) - .toEqualWithUniqueIds(Array.from(content.objects.values())); + .toEqualWithUniqueIds(origObjects); - content.removeObjects(board, poly.id); + content.removeObjects(board, polygon!.id); content.addVertexAngle(board, [py.id, p0.id, px.id]); // copies vertex angles if all vertices are selected @@ -830,21 +1051,19 @@ describe("GeometryContent", () => { it("can duplicate selected objects", () => { const { content, board } = createContentAndBoard(); - const p0: JXG.Point = content.addPoint(board, [0, 0])!; - const px: JXG.Point = content.addPoint(board, [1, 0])!; - const py: JXG.Point = content.addPoint(board, [0, 1])!; - const poly: JXG.Polygon = content.createPolygonFromFreePoints(board)!; + const { points, polygon } = buildPolygon(board, content, [[0, 0], [1, 0], [0, 1]]); + const [p0, px, py] = points; // copies selected points content.selectObjects(board, p0.id); expect(content.getSelectedIds(board)).toEqual([p0.id]); expect(content.copySelection(board)) - .toEqualWithUniqueIds([PointModel.create({ id: p0.id, x: 0, y: 0 })]); + .toEqualWithUniqueIds([PointModel.create({ id: p0.id, x: 0, y: 0, snapToGrid: true, colorScheme: 0 })]); // copies comments along with selected points const [comment] = content.addComment(board, p0.id, "p0 comment") || []; expect(content.copySelection(board)).toEqualWithUniqueIds([ - PointModel.create({ id: p0.id, x: 0, y: 0 }), + PointModel.create({ id: p0.id, x: 0, y: 0, snapToGrid: true, colorScheme: 0 }), CommentModel.create({ id: comment.id, anchors: [p0.id], text: "p0 comment"}) ]); content.removeObjects(board, [comment.id]); @@ -853,15 +1072,18 @@ describe("GeometryContent", () => { // content.selectObjects(board, poly.id); expect(content.getSelectedIds(board)).toEqual([p0.id]); expect(content.copySelection(board)) - .toEqualWithUniqueIds([PointModel.create({ id: p0.id, x: 0, y: 0 })]); + .toEqualWithUniqueIds([PointModel.create({ id: p0.id, x: 0, y: 0, snapToGrid: true, colorScheme: 0 })]); + + // For comparison purposes, we need the polygon to be after the points in the array of objects + const origObjects = Array.from(content.objects.values()).sort((a,b)=>a.type.localeCompare(b.type)); // copies polygons if all vertices are selected content.selectObjects(board, [px.id, py.id]); expect(content.getSelectedIds(board)).toEqual([p0.id, px.id, py.id]); expect(content.copySelection(board)) - .toEqualWithUniqueIds(Array.from(content.objects.values())); + .toEqualWithUniqueIds(origObjects); - content.removeObjects(board, poly.id); + content.removeObjects(board, polygon!.id); content.addVertexAngle(board, [py.id, p0.id, px.id]); // copies vertex angles if all vertices are selected @@ -893,4 +1115,103 @@ describe("GeometryContent", () => { expect(content.batchChangeCount).toBe(0); expect(content.isUserResizable).toBe(true); }); + + /* eslint-disable max-len */ + it("exports basic content properly", () => { + const { content } = createContentAndBoard((_content) => { + _content.addObjectModel(PointModel.create({ id: "p1", x: 1, y: 1 })); + _content.addObjectModel(PointModel.create({ id: "p2", x: 3, y: 3, colorScheme: 1, snapToGrid: false })); + _content.addObjectModel(PointModel.create({ id: "p3", x: 5, y: 1, name: "A", labelOption: "label" })); + }); + + expect(exportAndSimplifyIds(content)).toMatchInlineSnapshot(` +"{ + \\"type\\": \\"Geometry\\", + \\"board\\": {\\"xAxis\\": {\\"name\\": \\"x\\", \\"label\\": \\"x\\", \\"min\\": -2, \\"unit\\": 18.3, \\"range\\": 26.229508196721312}, \\"yAxis\\": {\\"name\\": \\"y\\", \\"label\\": \\"y\\", \\"min\\": -1, \\"unit\\": 18.3, \\"range\\": 17.486338797814206}}, + \\"objects\\": { + \\"p1\\": {\\"type\\": \\"point\\", \\"id\\": \\"p1\\", \\"x\\": 1, \\"y\\": 1, \\"colorScheme\\": 0, \\"labelOption\\": \\"none\\"}, + \\"p2\\": {\\"type\\": \\"point\\", \\"id\\": \\"p2\\", \\"x\\": 3, \\"y\\": 3, \\"snapToGrid\\": false, \\"colorScheme\\": 1, \\"labelOption\\": \\"none\\"}, + \\"p3\\": {\\"type\\": \\"point\\", \\"id\\": \\"p3\\", \\"x\\": 5, \\"y\\": 1, \\"name\\": \\"A\\", \\"colorScheme\\": 0, \\"labelOption\\": \\"label\\"} + }, + \\"pointMetadata\\": {}, + \\"linkedAttributeColors\\": {} +}" +`); + }); + + it("exports polygons and vertexangles correctly", () => { + const { content, board } = createContentAndBoard(); + const { points } = buildPolygon(board, content, [[0, 0], [1, 0], [0, 1]]); + content.addVertexAngle(board, [points[0].id, points[1].id, points[2].id]); + expect(exportAndSimplifyIds(content)). +toMatchInlineSnapshot(` +"{ + \\"type\\": \\"Geometry\\", + \\"board\\": {\\"xAxis\\": {\\"name\\": \\"x\\", \\"label\\": \\"x\\", \\"min\\": -2, \\"unit\\": 18.3, \\"range\\": 26.229508196721312}, \\"yAxis\\": {\\"name\\": \\"y\\", \\"label\\": \\"y\\", \\"min\\": -1, \\"unit\\": 18.3, \\"range\\": 17.486338797814206}}, + \\"objects\\": { + \\"testid\\": {\\"type\\": \\"point\\", \\"id\\": \\"testid\\", \\"x\\": 0, \\"y\\": 0, \\"snapToGrid\\": true, \\"colorScheme\\": 0, \\"labelOption\\": \\"none\\"}, + \\"jxgid\\": { + \\"type\\": \\"polygon\\", + \\"id\\": \\"jxgid\\", + \\"points\\": [\\"testid\\", \\"testid\\", \\"testid\\"], + \\"labelOption\\": \\"none\\", + \\"colorScheme\\": 0 + }, + \\"testid\\": {\\"type\\": \\"point\\", \\"id\\": \\"testid\\", \\"x\\": 1, \\"y\\": 0, \\"snapToGrid\\": true, \\"colorScheme\\": 0, \\"labelOption\\": \\"none\\"}, + \\"testid\\": {\\"type\\": \\"point\\", \\"id\\": \\"testid\\", \\"x\\": 0, \\"y\\": 1, \\"snapToGrid\\": true, \\"colorScheme\\": 0, \\"labelOption\\": \\"none\\"}, + \\"testid\\": {\\"type\\": \\"vertexAngle\\", \\"id\\": \\"testid\\", \\"points\\": [\\"testid\\", \\"testid\\", \\"testid\\"]} + }, + \\"pointMetadata\\": {}, + \\"linkedAttributeColors\\": {} +}" +`); + }); + + it("exports movable lines and comments correctly", () => { + const { content, board } = createContentAndBoard(); + content.addMovableLine(board, [[1, 1], [5, 5]], { id: "ml" }); + const line = board.objects.ml as JXG.Line; + expect(isMovableLine(line)).toBe(true); + content.addComment(board, "ml")!; + + expect(exportAndSimplifyIds(content)). +toMatchInlineSnapshot(` +"{ + \\"type\\": \\"Geometry\\", + \\"board\\": {\\"xAxis\\": {\\"name\\": \\"x\\", \\"label\\": \\"x\\", \\"min\\": -2, \\"unit\\": 18.3, \\"range\\": 26.229508196721312}, \\"yAxis\\": {\\"name\\": \\"y\\", \\"label\\": \\"y\\", \\"min\\": -1, \\"unit\\": 18.3, \\"range\\": 17.486338797814206}}, + \\"objects\\": { + \\"ml\\": { + \\"type\\": \\"movableLine\\", + \\"id\\": \\"ml\\", + \\"p1\\": {\\"type\\": \\"point\\", \\"id\\": \\"ml-point1\\", \\"x\\": 1, \\"y\\": 1, \\"colorScheme\\": 0, \\"labelOption\\": \\"none\\"}, + \\"p2\\": {\\"type\\": \\"point\\", \\"id\\": \\"ml-point2\\", \\"x\\": 5, \\"y\\": 5, \\"colorScheme\\": 0, \\"labelOption\\": \\"none\\"}, + \\"colorScheme\\": 0 + }, + \\"testid\\": {\\"type\\": \\"comment\\", \\"id\\": \\"testid\\", \\"anchors\\": [\\"ml\\"]} + }, + \\"pointMetadata\\": {}, + \\"linkedAttributeColors\\": {} +}" +`); + }); + + it("exports background image correctly", () => { + const { content } = createContentAndBoard((_content) => { + _content.setBackgroundImage( + ImageModel.create({ id: "img", url: placeholderImage, x: 0, y: 0, width: 5, height: 5 })); + }); + + expect(exportAndSimplifyIds(content)).toMatchInlineSnapshot(` +"{ + \\"type\\": \\"Geometry\\", + \\"board\\": {\\"xAxis\\": {\\"name\\": \\"x\\", \\"label\\": \\"x\\", \\"min\\": -2, \\"unit\\": 18.3, \\"range\\": 26.229508196721312}, \\"yAxis\\": {\\"name\\": \\"y\\", \\"label\\": \\"y\\", \\"min\\": -1, \\"unit\\": 18.3, \\"range\\": 17.486338797814206}}, + \\"bgImage\\": {\\"type\\": \\"image\\", \\"id\\": \\"img\\", \\"x\\": 0, \\"y\\": 0, \\"url\\": \\"test-file-stub\\", \\"width\\": 5, \\"height\\": 5}, + \\"objects\\": {}, + \\"pointMetadata\\": {}, + \\"linkedAttributeColors\\": {} +}" +`); + }); + }); +/* eslint-enable max-len */ diff --git a/src/models/tiles/geometry/geometry-content.ts b/src/models/tiles/geometry/geometry-content.ts index 89ad2e4c60..bcf70fbb3f 100644 --- a/src/models/tiles/geometry/geometry-content.ts +++ b/src/models/tiles/geometry/geometry-content.ts @@ -1,44 +1,46 @@ -import { castArray, difference, each, size as _size, union } from "lodash"; +import { castArray, difference, each, every, size as _size, union } from "lodash"; import { reaction } from "mobx"; -import { addDisposer, applySnapshot, Instance, SnapshotIn, types } from "mobx-state-tree"; +import { addDisposer, applySnapshot, detach, Instance, SnapshotIn, types, getSnapshot } from "mobx-state-tree"; +import stringify from "json-stringify-pretty-compact"; import { SharedDataSet, SharedDataSetType } from "../../shared/shared-data-set"; import { SelectionStoreModelType } from "../../stores/selection"; -import { ITableLinkProperties, linkedPointId } from "../table-link-types"; +import { ITableLinkProperties, linkedPointId, splitLinkedPointId } from "../table-link-types"; import { ITileExportOptions, IDefaultContentOptions } from "../tile-content-info"; import { TileMetadataModel } from "../tile-metadata"; import { tileContentAPIActions, tileContentAPIViews } from "../tile-model-hooks"; -import { ICreateRowsProperties, IRowProperties, ITableChange } from "../table/table-change"; -import { canonicalizeValue } from "../table/table-model-types"; -import { convertModelToChanges, exportGeometryJson } from "./geometry-migrate"; +import { convertModelToChanges, getGeometryBoardChange } from "./geometry-migrate"; import { preprocessImportFormat } from "./geometry-import"; import { cloneGeometryObject, CommentModel, CommentModelType, GeometryBaseContentModel, GeometryObjectModelType, GeometryObjectModelUnion, ImageModel, ImageModelType, isCommentModel, isMovableLineModel, isMovableLinePointId, - isPointModel, isPolygonModel, MovableLineModel, PointModel, PolygonModel, PolygonModelType, VertexAngleModel + isPointModel, isPolygonModel, isVertexAngleModel, MovableLineModel, PointModel, PolygonModel, PolygonModelType, + segmentIdFromPointIds, + VertexAngleModel } from "./geometry-model"; import { getBoardUnitsAndBuffers, getObjectById, guessUserDesiredBoundingBox, kXAxisTotalBuffer, kYAxisTotalBuffer, resumeBoardUpdates, suspendBoardUpdates } from "./jxg-board"; import { - ESegmentLabelOption, ILinkProperties, JXGChange, JXGCoordPair, JXGPositionProperty, JXGProperties, JXGUnsafeCoordPair + ELabelOption, ILinkProperties, JXGChange, JXGCoordPair, JXGPositionProperty, JXGProperties, JXGUnsafeCoordPair } from "./jxg-changes"; import { applyChange, applyChanges, IDispatcherChangeContext } from "./jxg-dispatcher"; -import { kPointDefaults } from "./jxg-point"; -import { prepareToDeleteObjects } from "./jxg-polygon"; +import { getAssociatedPolygon, getEdgeVisualProps, prepareToDeleteObjects } from "./jxg-polygon"; import { - isAxisArray, isBoard, isComment, isFreePoint, isImage, isMovableLine, isPoint, isPointArray, isPolygon, + isAxisArray, isBoard, isComment, isImage, isMovableLine, isPoint, isPointArray, isPolygon, isVertexAngle, isVisibleEdge, kGeometryDefaultXAxisMin, kGeometryDefaultYAxisMin, - kGeometryDefaultHeight, kGeometryDefaultPixelsPerUnit, kGeometryDefaultWidth, toObj + kGeometryDefaultHeight, kGeometryDefaultPixelsPerUnit, kGeometryDefaultWidth, toObj, isGeometryElement } from "./jxg-types"; import { SharedModelType } from "../../shared/shared-model"; import { ISharedModelManager } from "../../shared/shared-model-manager"; import { IDataSet } from "../../data/data-set"; import { uniqueId } from "../../../utilities/js-utils"; -import { logTileChangeEvent } from "../log/log-tile-change-event"; -import { LogEventName } from "../../../lib/logger-types"; import { gImageMap } from "../../image-map"; import { IClueTileObject } from "../../annotations/clue-object"; +import { appendVertexId, getPoint, filterBoardObjects, forEachBoardObject, getBoardObject, getBoardObjectIds, + getPolygon, logGeometryEvent, removeClosingVertexId } from "./geometry-utils"; +import { getPointVisualProps } from "./jxg-point"; +import { getVertexAngle } from "./jxg-vertex-angle"; export type onCreateCallback = (elt: JXG.GeometryElement) => void; @@ -124,19 +126,16 @@ export type GeometryMetadataModelType = Instance; export function setElementColor(board: JXG.Board, id: string, selected: boolean) { const element = getObjectById(board, id); if (element) { - const fillColor = element.getAttribute("clientFillColor") || kPointDefaults.fillColor; - const strokeColor = element.getAttribute("clientStrokeColor") || kPointDefaults.strokeColor; - const selectedFillColor = element.getAttribute("clientSelectedFillColor") || kPointDefaults.selectedFillColor; - const selectedStrokeColor = element.getAttribute("clientSelectedStrokeColor") || kPointDefaults.selectedStrokeColor; - const clientCssClass = selected - ? element.getAttribute("clientSelectedCssClass") - : element.getAttribute("clientCssClass"); - const cssClass = clientCssClass ? { cssClass: clientCssClass } : undefined; - element.setAttribute({ - fillColor: selected ? selectedFillColor : fillColor, - strokeColor: selected ? selectedStrokeColor : strokeColor, - ...cssClass - }); + let colorScheme = element.getAttribute("colorScheme")||0; + if (isPoint(element)) { + const props = getPointVisualProps(selected, colorScheme, element.getAttribute("isPhantom"), + element.getAttribute("clientLabelOption")); + element.setAttribute(props); + } else if (isVisibleEdge(element)) { + colorScheme = getAssociatedPolygon(element)?.getAttribute("colorScheme")||0; + const props = getEdgeVisualProps(selected, colorScheme, false); + element.setAttribute(props); + } } } @@ -191,20 +190,33 @@ export const GeometryContentModel = GeometryBaseContentModel }); return point; }, + /** + * Compile a map of data for all points that are part of linked datasets. + * The returned Map has the providing tile's ID as the key, and an object + * containing two parallel lists as its value: + * + * - coords: list of coordinate pairs + * - properties: list of point property objects (id and color) + * + * TODO: should we also look at the selections in the DataSet + * + * @returns the Map + */ getLinkedPointsData() { - const data: Map = new Map(); + const data: Map = new Map(); self.linkedDataSets.forEach(link => { const coords: JXGCoordPair[] = []; - const properties: Array<{ id: string }> = []; + const properties: Array<{ id: string, colorScheme: number }> = []; for (let ci = 0; ci < link.dataSet.cases.length; ++ci) { const x = link.dataSet.attributes[0]?.numValue(ci); for (let ai = 1; ai < link.dataSet.attributes.length; ++ai) { const attr = link.dataSet.attributes[ai]; + const colorScheme = self.getColorSchemeForAttributeId(attr.id) || 0; const id = linkedPointId(link.dataSet.cases[ci].__id__, attr.id); const y = attr.numValue(ci); if (isFinite(x) && isFinite(y)) { coords.push([x, y]); - properties.push({ id }); + properties.push({ id, colorScheme }); } } } @@ -223,6 +235,22 @@ export const GeometryContentModel = GeometryBaseContentModel return self.getObject(id); } }, + getObjectColorScheme(id: string) { + const obj = self.getObject(id); + if (isPointModel(obj) || isPolygonModel(obj) || isMovableLineModel(obj)) { + return obj.colorScheme; + } + if (obj === undefined) { + const [linkedRowId, linkedColId] = splitLinkedPointId(id); + if (linkedRowId && linkedColId) { + return self.getColorSchemeForAttributeId(linkedColId); + } + } + }, + // Color to use for new points. Placeholder for now until appropriate UI is added. + get newPointColorScheme() { + return 0; + }, getDependents(ids: string[], options?: { required: boolean }) { const { required = false } = options || {}; let dependents = [...ids]; @@ -237,6 +265,10 @@ export const GeometryContentModel = GeometryBaseContentModel get lastObject() { return self.objects.size ? Array.from(self.objects.values())[self.objects.size - 1] : undefined; }, + lastObjectOfType(type: string) { + const ofType = Array.from(self.objects.values()).filter((obj => obj.type === type)); + return ofType.length ? ofType[ofType.length -1] : undefined; + }, isSelected(id: string) { return !!self.metadata?.isSelected(id); }, @@ -251,6 +283,12 @@ export const GeometryContentModel = GeometryBaseContentModel }, isLinkedToTile(tileId: string) { return self.linkedDataSets.some(link => link.providerId === tileId); + }, + isDeletable(board: JXG.Board, id: string) { + const obj = getObjectById(board, id); + if (!obj || obj.getAttribute("clientUndeletable")) return false; + if (isVertexAngle(obj)) return true; + return !obj.getAttribute("fixed"); } })) .views(self => ({ @@ -258,17 +296,12 @@ export const GeometryContentModel = GeometryBaseContentModel return self.linkedDataSets.find(ds => ds.providerId === linkedTableId); }, getSelectedIds(board: JXG.Board) { - // returns the ids in creation order - return board.objectsList - .filter(obj => self.isSelected(obj.id)) - .map(obj => obj.id); + return Array.from(self.metadata.selection.entries()) + .filter(([id, selected]) => selected) + .map(([id, selected]) => id); }, getDeletableSelectedIds(board: JXG.Board) { - // returns the ids in creation order - return board.objectsList - .filter(obj => self.isSelected(obj.id) && - !obj.getAttribute("fixed") && !obj.getAttribute("clientUndeletable")) - .map(obj => obj.id); + return this.getSelectedIds(board).filter(id => self.isDeletable(board, id)); } })) .views(self => ({ @@ -276,12 +309,11 @@ export const GeometryContentModel = GeometryBaseContentModel return self.getDeletableSelectedIds(board).length > 0; }, selectedObjects(board: JXG.Board) { - return board.objectsList.filter(obj => self.isSelected(obj.id)); + return filterBoardObjects(board, obj => self.isSelected(obj.id)); }, exportJson(options?: ITileExportOptions) { - const changes = convertModelToChanges(self, { addBuffers: false, includeUnits: false}); - const jsonChanges = changes.map(change => JSON.stringify(change)); - return exportGeometryJson(jsonChanges, options); + const snapshot = getSnapshot(self); + return stringify(snapshot, {maxLength: 200}); } })) .views(self => tileContentAPIViews({ @@ -317,7 +349,7 @@ export const GeometryContentModel = GeometryBaseContentModel .actions(self => ({ setElementSelection(board: JXG.Board | undefined, id: string, select: boolean) { if (self.isSelected(id) !== select) { - const elt = board && board.objects[id]; + const elt = getBoardObject(board, id); const tableId = elt && elt.getAttribute("linkedTableId"); const rowId = elt && elt.getAttribute("linkedRowId"); self.metadata.setSelection(id, select); @@ -428,13 +460,13 @@ export const GeometryContentModel = GeometryBaseContentModel onDidApplyChange: handleDidApplyChange }; } - // views - // actions - function initializeBoard(domElementID: string, onCreate?: onCreateCallback): JXG.Board | undefined { + function initializeBoard(domElementID: string, + onCreate: onCreateCallback, syncLinked: (board:JXG.Board) => void): JXG.Board | undefined { let board: JXG.Board | undefined; - const changes = convertModelToChanges(self, { addBuffers: true, includeUnits: true}); - applyChanges(domElementID, changes, getDispatcherContext()) + const context = getDispatcherContext(); + // Create the board and axes + applyChanges(domElementID, [getGeometryBoardChange(self, { addBuffers: true, includeUnits: true })], context) .filter(result => result != null) .forEach(changeResult => { const changeElems = castArray(changeResult); @@ -442,15 +474,30 @@ export const GeometryContentModel = GeometryBaseContentModel if (isBoard(changeElem)) { board = changeElem; suspendBoardUpdates(board); + } else { + onCreate(changeElem); } - else if (onCreate) { + }); + }); + if (!board) return; + + // Add linked points + syncLinked(board); + + // Now add all local objects + const changes = convertModelToChanges(self); + applyChanges(board, changes, context) + .filter(result => result != null) + .forEach(changeResult => { + const changeElems = castArray(changeResult); + changeElems.forEach(changeElem => { + if (isGeometryElement(changeElem)) { onCreate(changeElem); } }); }); - if (board) { - resumeBoardUpdates(board); - } + + resumeBoardUpdates(board); return board; } @@ -480,6 +527,17 @@ export const GeometryContentModel = GeometryBaseContentModel board.update(); } + function zoomBoard(board: JXG.Board, factor: number) { + if (!self.board) return; + const {xAxis, yAxis} = self.board; + xAxis.range = xAxis.range ? xAxis.range / factor : xAxis.range; + yAxis.range = yAxis.range ? yAxis.range / factor : yAxis.range; + // Update units, but keep them the same (avoid rounding error building up) + const oldUnit = (xAxis.unit + yAxis.unit) / 2; + const newUnit = oldUnit * factor; + xAxis.unit = yAxis.unit = newUnit; + } + function rescaleBoard(board: JXG.Board, params: IAxesParams) { const { canvasWidth, canvasHeight } = board; const { xName, xAnnotation, xMin, xMax, yName, yAnnotation, yMin, yMax } = params; @@ -488,20 +546,41 @@ export const GeometryContentModel = GeometryBaseContentModel const unitX = width / (xMax - xMin); const unitY = height / (yMax - yMin); + // Now force equal scaling. The smaller unit wins, since we want to keep all points in view. + let calcUnit, calcXrange, calcYrange; + if (unitX < unitY) { + calcUnit = unitX; + calcXrange = xMax - xMin; + calcYrange = height / calcUnit; + } else { + calcUnit = unitY; + calcXrange = width / calcUnit; + calcYrange = yMax - yMin; + } + const xAxisProperties = { name: xName, label: xAnnotation, min: xMin, - unit: unitX, - range: xMax - xMin + unit: calcUnit, + range: calcXrange }; const yAxisProperties = { name: yName, label: yAnnotation, min: yMin, - unit: unitY, - range: yMax - yMin + unit: calcUnit, + range: calcYrange }; + // Don't force a redisplay if nothing has changed. + const curX = self.board?.xAxis; + const curY = self.board?.yAxis; + if (curX && curX.min === xAxisProperties.min + && curX.unit === xAxisProperties.unit && curX.range === xAxisProperties.range + && curY && curY.min === yAxisProperties.min + && curY.unit === yAxisProperties.unit && curY.range === yAxisProperties.range) { + return undefined; + } if (self.board) { applySnapshot(self.board.xAxis, xAxisProperties); applySnapshot(self.board.yAxis, yAxisProperties); @@ -512,29 +591,19 @@ export const GeometryContentModel = GeometryBaseContentModel target: "board", targetID: board.id, properties: { boardScale: { - xMin, yMin, unitX, unitY, + xMin, yMin, unitX: calcUnit, unitY: calcUnit, ...toObj("xName", xName), ...toObj("yName", yName), ...toObj("xAnnotation", xAnnotation), ...toObj("yAnnotation", yAnnotation), canvasWidth: width, canvasHeight: height } } }; - const axes = applyAndLogChange(board, change); + const axes = syncChange(board, change); return isAxisArray(axes) ? axes : undefined; } function updateScale(board: JXG.Board, scale: number) { - // Ostensibly, the "right" thing to do here is to call - // board.updateCSSTransforms(), but that call inexplicably incorporates - // the scale factor multiple times as it walks the DOM hierarchy, so we - // just skip the DOM walk and set the transform to the correct value. if (board) { - const invScale = 1 / (scale || 1); - const cssTransMat = [ - [1, 0, 0], - [0, invScale, 0], - [0, 0, invScale] - ]; - board.cssTransMat = cssTransMat; + board.updateCSSTransforms(); } } @@ -556,7 +625,7 @@ export const GeometryContentModel = GeometryBaseContentModel if (imageIds.length) { // change URL if there's already an image present const imageId = imageIds[imageIds.length - 1]; - updateObjects(board, imageId, { url, size: [width, height] }); + updateObjects(board, imageId, { url, size: [width, height], ...props }); } else { const change: JXGChange = { @@ -587,6 +656,429 @@ export const GeometryContentModel = GeometryBaseContentModel return isPoint(point) ? point : undefined; } + /** + * Creates a "phantom" point, which is shown on the board but not (yet) persisted in the model. + * It can be part of a polygon (which is expected to be the activePolygon). + * If a polygon is provided the phantom point will be added at the end of its list of vertices. + * @param board + * @param coordinates + * @param polygonId optional polygon + * @returns the new Point object + */ + function addPhantomPoint(board: JXG.Board, coordinates: JXGCoordPair, polygonId?: string): + JXG.Point | undefined { + if (!board) return undefined; + const id = uniqueId(); + const props = { + id, + colorScheme: self.newPointColorScheme, + isPhantom: true, + clientLabelOption: ELabelOption.kNone, + snapToGrid: true + }; + const pointModel = PointModel.create({ x: coordinates[0], y: coordinates[1], ...props }); + self.phantomPoint = pointModel; + + const change: JXGChange = { + operation: "create", + target: "point", + parents: coordinates, + properties: { ...props } + }; + const result = syncChange(board, change); + const point = isPoint(result) ? result : undefined; + + if (point && polygonId) { + appendPhantomPointToPolygon(board, polygonId); + } + return point; + } + + function appendPhantomPointToPolygon(board: JXG.Board, polygonId: string) { + const poly = getPolygon(board, polygonId); + const id = self.phantomPoint?.id; + if (!poly || !id) return; + const vertexIds = poly.vertices.map(v => v.id); + // The point before the one we're adding + const lastPoint = poly.vertices[poly.vertices.length-2]; + // The point after the one we're adding + const nextPoint = poly.vertices[0]; + + const newPolygon = syncChange(board, { + operation: "update", + target: "polygon", + targetID: polygonId, + parents: appendVertexId(vertexIds, id) + }); + if (!isPolygon(newPolygon)) return; + + // If there is a vertex angle before or after the added point, it needs to be updated + fixVertexAngle(board, newPolygon, lastPoint); + fixVertexAngle(board, newPolygon, nextPoint); + return newPolygon; + } + + function fixVertexAngle(board: JXG.Board, polygon: JXG.Polygon, point: JXG.Point) { + const vertexAngle = getVertexAngle(point); + if (!vertexAngle) return; + const model = self.getObject(vertexAngle.id); + if (!isVertexAngleModel(model)) return; + const pointIndex = polygon.vertices.indexOf(point); + const newPoints = [ + polygon.vertices[pointIndex>0 ? pointIndex-1 : polygon.vertices.length-2].id, + polygon.vertices[pointIndex].id, + polygon.vertices[pointIndex+1].id + ]; + model.replacePoints(newPoints); + rebuildVertexAngle(board, vertexAngle.id, newPoints); + } + + function deleteVertexAngle(board: JXG.Board, point: JXG.Point) { + const va = getVertexAngle(point); + if (va) { + self.deleteObjects([va.id]); + syncChange(board, { + operation: "delete", + target: "vertexAngle", + targetID: va.id + }); + } + } + + function setPhantomPointPosition(board: JXG.Board, position: JXGCoordPair) { + if (self.phantomPoint) { + self.phantomPoint.setPosition(position); + const change: JXGChange = { + operation: "update", + target: "object", + targetID: self.phantomPoint.id, + properties: { + position + } + }; + syncChange(board, change); + } + } + + /** + * "Opens up" the polygon for editing. + * Sets the active polygon ID. + * The vertices of this polygon are "rotated" if necessary so that the point + * clicked becomes the last point in the list of vertices, and then the + * phantom point is inserted after it. + * @param board + * @param polygonId + * @param pointId + * @returns the updated polygon + */ + function makePolygonActive(board: JXG.Board, polygonId: string, pointId: string) { + const poly = getPolygon(board, polygonId); + const polygonModel = self.getObject(polygonId); + const point = getPoint(board, pointId); + if (!poly || !point || !polygonModel || !isPolygonModel(polygonModel) || !self.phantomPoint) return; + const pointIndex = poly.vertices.indexOf(point); + if (pointIndex < 0) return; + + const vertices = removeClosingVertexId(poly.vertices.map(vert => vert.id)); + // Rewrite the list of vertices so that the point clicked on is last. + const reorderedVertices = vertices.slice(pointIndex+1).concat(vertices.slice(0, pointIndex+1)); + polygonModel.points.replace(reorderedVertices); + + const change: JXGChange = { + operation: "update", + target: "polygon", + targetID: polygonId, + parents: reorderedVertices + }; + syncChange(board, change); + self.activePolygonId = polygonId; + + // Then add phantom point at the end + appendPhantomPointToPolygon(board, polygonId); + + return getPolygon(board, polygonId); + } + + // Delete old angle from board and build new one with the new parent points + function rebuildVertexAngle(board: JXG.Board, id: string, points: string[]) { + syncChange(board, + { + operation: "delete", + target: "vertexAngle", + targetID: id + }); + syncChange(board, + { + operation: "create", + target: "vertexAngle", + parents: points, + properties: { id } + }); + } + + /** + * Adds the given existing point to the active polygon. + * It is appended to the end of the list of vertexes in the model. + * On the board the phantom point will be moved to after this new vertex, + * and the polygon will remain unclosed. + * @param board + * @param pointId + * @returns the polygon + */ + function addPointToActivePolygon(board: JXG.Board, pointId: string) { + // Sanity check everything + if (!self.activePolygonId || !self.phantomPoint) return; + const poly = getPolygon(board, self.activePolygonId); + if (!poly) return; + const vertexIds = poly.vertices.map(v => v.id); + const phantomPointIndex = vertexIds.indexOf(self.phantomPoint.id); + if (phantomPointIndex<0) return; + const polygonModel = self.objects.get(self.activePolygonId); + if (!isPolygonModel(polygonModel)) return; + + // Insert the new point before the phantom point + vertexIds.splice(phantomPointIndex, 0, pointId); + const change: JXGChange = { + operation: "update", + target: "polygon", + targetID: poly.id, + parents: vertexIds + }; + const updatedPolygon = syncChange(board, change); + if (!isPolygon(updatedPolygon)) return; + polygonModel.points.push(pointId); + + fixVertexAngle(board, updatedPolygon, updatedPolygon.vertices[phantomPointIndex-1]); + fixVertexAngle(board, updatedPolygon, updatedPolygon.vertices[phantomPointIndex]); + + logGeometryEvent(self, "update", + "vertex", + pointId, { userAction: "join to polygon" }); + + return isPolygon(updatedPolygon) ? updatedPolygon : undefined; + } + + /** + * Make the current phantom point into a real point. + * The new point is persisted into the model. It remains a part of the active polygon if any. + * @param board + * @param position + * @param polygonId + * @returns the point, now considered "real". + */ + function realizePhantomPoint(board: JXG.Board, position: JXGCoordPair, makePolygon: boolean): + { point: JXG.Point | undefined, polygon: JXG.Polygon | undefined } { + // Transition the current phantom point into a real point. + if (!self.phantomPoint) return { point: undefined, polygon: undefined }; + self.phantomPoint.setPosition(position); + const newRealPoint = self.phantomPoint; + detach(newRealPoint); + self.addObjectModel(newRealPoint); + + // Create a new phantom point + const phantomPoint = addPhantomPoint(board, position); + if (!phantomPoint) { + console.warn("Failed to create phantom point"); + return { point: undefined, polygon: undefined }; + } + + // Update the previously-existing JSXGraph point to be real, not phantom + const change: JXGChange = { + operation: "update", + target: "object", + targetID: newRealPoint.id, + properties: { + ...getPointVisualProps(false, newRealPoint.colorScheme, false, ELabelOption.kNone), + isPhantom: false, + position + } + }; + syncChange(board, change); + + let newPolygon: JXG.Polygon|undefined = undefined; + if (makePolygon) { + const poly = self.activePolygonId && getPolygon(board, self.activePolygonId); + if (poly) { + newPolygon = appendPhantomPointToPolygon(board, poly.id); + const polyModel = self.activePolygonId && self.getObject(self.activePolygonId); + if (polyModel && isPolygonModel(polyModel)) { + polyModel.points.push(newRealPoint.id); + } + } else { + // Create a new polygon with the two points (real and phantom) + const change2: JXGChange = { + operation: "create", + target: "polygon", + parents: [newRealPoint.id, phantomPoint?.id], + properties: { id: self.activePolygonId, colorScheme: newRealPoint.colorScheme } + }; + const result = syncChange(board, change2); + if (isPolygon(result)) { + newPolygon = result; + + // Update the model + const polygonModel = PolygonModel.create( + { id: newPolygon.id, points: [newRealPoint.id], colorScheme: newRealPoint.colorScheme }); + self.addObjectModel(polygonModel); + self.activePolygonId = polygonModel.id; + } + } + } + + // Log event + logGeometryEvent(self, "create", + makePolygon ? "vertex" : "point", + self.activePolygonId ? [newRealPoint.id, self.activePolygonId] : newRealPoint.id); + + // Return newly-created objects + const obj = board.objects[newRealPoint.id]; + const point = isPoint(obj) ? obj : undefined; + return { point, polygon: newPolygon }; + } + + /** + * Removes the phantom point from the board, adjusting the active polygon if there is one. + * @param board + */ + function clearPhantomPoint(board: JXG.Board) { + if (!self.phantomPoint) return; + const phantomId = self.phantomPoint.id; + + // remove from polygon, if it's in one. + const activePolygon = self.activePolygonId && getPolygon(board, self.activePolygonId); + if (activePolygon) { + const phantomIndex = activePolygon.vertices.findIndex(v => v.id === phantomId); + const remainingVertices = activePolygon.vertices.map(v => v.id).filter(id => id !== phantomId); + const change1: JXGChange = { + operation: "update", + target: "polygon", + targetID: self.activePolygonId, + parents: remainingVertices + }; + const updatedPolygon = syncChange(board, change1); + if (isPolygon(updatedPolygon) && phantomIndex) { + // Check for VertexAngles on the vertices before and after the deleted one. + fixVertexAngle(board, updatedPolygon, updatedPolygon.vertices[phantomIndex - 1]); + fixVertexAngle(board, updatedPolygon, updatedPolygon.vertices[phantomIndex]); + } + } + + const change: JXGChange = { + operation: "delete", + target: "point", + targetID: self.phantomPoint.id + }; + syncChange(board, change); + self.phantomPoint = undefined; + } + + function createPolygonIncludingPoint(board: JXG.Board, pointId: string) { + if (!self.phantomPoint) return; + const colorScheme = self.getObjectColorScheme(pointId) || 0; + const polygonModel = PolygonModel.create({ points: [pointId], colorScheme }); + self.addObjectModel(polygonModel); + self.activePolygonId = polygonModel.id; + const change: JXGChange = { + operation: "create", + target: "polygon", + parents: [pointId, self.phantomPoint.id], + properties: { id: polygonModel.id, colorScheme } + }; + const result = syncChange(board, change); + + logGeometryEvent(self, "update", + "vertex", + pointId, { userAction: "join to polygon" }); + + if (isPolygon(result)) { + return result; + } + } + + /** + * De-activate the active polygon. + * This means it is no longer being edited. + * If it only has a single point, the polygon will be deleted, leaving just a regular point. + * @param board + */ + function clearActivePolygon(board: JXG.Board) { + if (!self.activePolygonId) return; + const poly = getPolygon(board, self.activePolygonId); + self.activePolygonId = undefined; + if (!poly) return; + if (poly.vertices.length < 2 + || (poly.vertices.length === 2 && poly.vertices[0]===poly.vertices[1])) { + const change: JXGChange = { + operation: "delete", + target: "polygon", + targetID: poly.id + }; + syncChange(board, change); + } + } + + /** + * Complete the polygon being drawn. + * The point argument is the point the user clicked; normally the first point of the polygon to close it. + * If a different point is clicked, though, the polygon is closed to that vertex, freeing any earlier points + * since they are not part of the closed shape. + * @param board + * @param point + * @returns the polygon + */ + function closeActivePolygon(board: JXG.Board, point: JXG.Point) { + if (!self.activePolygonId) return; + let poly = getPolygon(board, self.activePolygonId); + if (!poly) return; + const vertexIds = poly.vertices.map(v => v.id); + removeClosingVertexId(vertexIds); + // Remove any points prior to the one clicked, they are no longer part of the poly. + const clickedIndex = vertexIds.indexOf(point.id); + if (clickedIndex) { + // Not undefined and not zero; they clicked something other than the first point. + // First remove vertex angles + for (let i = 0; i < clickedIndex; i++) { + deleteVertexAngle(board, poly.vertices[i]); + } + // Update the polygon's list of vertices + vertexIds.splice(0, clickedIndex); + // Update the model as well + const polyModel = self.activePolygonId && self.getObject(self.activePolygonId); + if (polyModel && isPolygonModel(polyModel)) { + polyModel.points.splice(0, clickedIndex); + } + } + // Remove the phantom point from the list of vertices + const index = vertexIds.findIndex(v => v === self.phantomPoint?.id); + if (index >= 1) { + vertexIds.splice(index,1); + + const change: JXGChange = { + operation: "update", + target: "polygon", + targetID: poly.id, + parents: vertexIds + }; + const result = syncChange(board, change); + if (isPolygon(result)) { + poly = result; + } + fixVertexAngle(board, poly, poly.vertices[index-1]); + fixVertexAngle(board, poly, poly.vertices[index]); + } else { + // If index === 1, only a single non-phantom point remains, so we delete the polygon object. + const change: JXGChange = { + operation: "delete", + target: "polygon", + targetID: poly.id + }; + syncChange(board, change); + poly = undefined; + } + self.activePolygonId = undefined; + return poly; + } + function addPoints(board: JXG.Board | undefined, parents: JXGUnsafeCoordPair[], _properties?: JXGProperties | JXGProperties[], @@ -643,14 +1135,15 @@ export const GeometryContentModel = GeometryBaseContentModel return elems ? elems as JXG.GeometryElement[] : undefined; } - function removeObjects(board: JXG.Board | undefined, ids: string | string[], links?: ILinkProperties) { - board && self.deselectObjects(board, ids); - self.deleteObjects(castArray(ids)); + function removeObjects(board: JXG.Board, ids: string | string[], links?: ILinkProperties) { + self.deselectObjects(board, ids); + const deletable = castArray(ids).filter(id => self.isDeletable(board, id)); + self.deleteObjects(deletable); const change: JXGChange = { operation: "delete", target: "object", - targetID: ids, + targetID: deletable, links }; return applyAndLogChange(board, change); @@ -684,7 +1177,8 @@ export const GeometryContentModel = GeometryBaseContentModel function updateObjects(board: JXG.Board | undefined, ids: string | string[], properties: JXGProperties | JXGProperties[], - links?: ILinkProperties) { + links?: ILinkProperties, + userAction?: string) { const propsArray = castArray(properties); castArray(ids).forEach((id, i) => { const obj = self.getAnyObject(id); @@ -713,35 +1207,12 @@ export const GeometryContentModel = GeometryBaseContentModel target: "object", targetID: ids, properties, - links + links, + userAction }; return applyAndLogChange(board, change); } - function createPolygonFromFreePoints( - board: JXG.Board, linkedTableId?: string, linkedColumnId?: string, properties?: JXGProperties - ): JXG.Polygon | undefined { - const freePtIds = board.objectsList - .filter(elt => isFreePoint(elt) && - (linkedTableId === elt.getAttribute("linkedTableId")) && - (linkedColumnId === elt.getAttribute("linkedColId"))) - .map(pt => pt.id); - if (freePtIds && freePtIds.length > 1) { - const { id = uniqueId(), ...props } = properties || {}; - const polygonModel = PolygonModel.create({ id, points: freePtIds, ...props }); - self.addObjectModel(polygonModel); - - const change: JXGChange = { - operation: "create", - target: "polygon", - parents: freePtIds, - properties: { id, ...props } - }; - const polygon = applyAndLogChange(board, change); - return isPolygon(polygon) ? polygon : undefined; - } - } - function addVertexAngle(board: JXG.Board, parents: string[], properties?: JXGProperties): JXG.Angle | undefined { @@ -770,10 +1241,11 @@ export const GeometryContentModel = GeometryBaseContentModel } function updatePolygonSegmentLabel(board: JXG.Board | undefined, polygon: JXG.Polygon, - points: [JXG.Point, JXG.Point], labelOption: ESegmentLabelOption) { + points: [JXG.Point, JXG.Point], labelOption: ELabelOption, + name: string|undefined ) { const polygonModel = self.getObject(polygon.id); if (isPolygonModel(polygonModel)) { - polygonModel.setSegmentLabel([points[0].id, points[1].id], labelOption); + polygonModel.setSegmentLabel([points[0].id, points[1].id], labelOption, name); } const parentIds = points.map(obj => obj.id); @@ -782,13 +1254,34 @@ export const GeometryContentModel = GeometryBaseContentModel target: "polygon", targetID: polygon.id, parents: parentIds, - properties: { labelOption } + properties: { labelOption, name } }; - return applyAndLogChange(board, change); + logGeometryEvent(self, "update", "segment", + segmentIdFromPointIds(parentIds as [string,string]), + { text: name, labelOption }); + return board && syncChange(board, change); + } + + function updatePolygonLabel(board: JXG.Board|undefined, polygon: JXG.Polygon, + labelOption: ELabelOption, name: string|undefined ) { + const polygonModel = self.getObject(polygon.id); + if (!board || !isPolygonModel(polygonModel)) return; + polygonModel.labelOption = labelOption; + polygonModel.name = name; + + logGeometryEvent(self, "update", "polygon", polygon.id, + { text: name, labelOption }); + + return syncChange(board, { + operation: "update", + target: "polygon", + targetID: polygon.id, + properties: { labelOption, clientName: name } + }); } function findObjects(board: JXG.Board, test: (obj: JXG.GeometryElement) => boolean): JXG.GeometryElement[] { - return board.objectsList.filter(test); + return filterBoardObjects(board, test); } function isCopyableChild(child: JXG.GeometryElement) { @@ -810,13 +1303,12 @@ export const GeometryContentModel = GeometryBaseContentModel // ancestors are selected. function getSelectedIdsAndChildren(board: JXG.Board) { // list of selected ids in order of creation - const selectedIds = board.objectsList - .map(obj => obj.id) - .filter(id => self.isSelected(id)); + const selectedIds = getBoardObjectIds(board) + .filter(id => self.isSelected(id)); const children: { [id: string]: JXG.GeometryElement } = {}; // identify children (e.g. polygons) that may be selected as well selectedIds.forEach(id => { - const obj = board.objects[id]; + const obj = getBoardObject(board, id); if (obj) { each(obj.childElements, child => { if (child && !self.isSelected(child.id) && isCopyableChild(child)) { @@ -847,41 +1339,21 @@ export const GeometryContentModel = GeometryBaseContentModel function getOneSelectedPoint(board: JXG.Board) { const selected = self.selectedObjects(board); - return (selected.length === 1 && isPoint(selected[0])); + return (selected.length === 1 && isPoint(selected[0])) ? selected[0] : undefined; } function getOneSelectedPolygon(board: JXG.Board) { // all vertices of polygon must be selected to show rotate handle - const polygonSelection: { [id: string]: { any: boolean, all: boolean } } = {}; const polygons = board.objectsList - .filter(isPolygon) - .filter(polygon => { - const selected = { any: false, all: true }; - each(polygon.ancestors, vertex => { - if (self.metadata.isSelected(vertex.id)) { - selected.any = true; - } - else { - selected.all = false; - } - }); - polygonSelection[polygon.id] = selected; - return selected.any; - }); + .filter(isPolygon) + .filter(polygon => { + return every(polygon.ancestors, vertex => self.metadata.isSelected(vertex.id)); + }); const selectedPolygonId = (polygons.length === 1) && polygons[0].id; - const selectedPolygon = selectedPolygonId && polygonSelection[selectedPolygonId].all - ? polygons[0] : undefined; + const selectedPolygon = selectedPolygonId ? polygons[0] : undefined; // must not have any selected points other than the polygon vertices if (selectedPolygon) { - type IEntry = [string, boolean]; - const selectionEntries = Array.from(self.metadata.selection.entries()) as IEntry[]; - const selectedPts = selectionEntries - .filter(entry => { - const id = entry[0]; - const obj = board.objects[id]; - const isSelected = entry[1]; - return obj && (obj.elType === "point") && isSelected; - }); + const selectedPts = self.selectedObjects(board).filter(isPoint); return _size(selectedPolygon.ancestors) === selectedPts.length ? selectedPolygon : undefined; } @@ -916,7 +1388,7 @@ export const GeometryContentModel = GeometryBaseContentModel // Labeling polygon edges is not supported due to unpredictable IDs. However, if the polygon has only two sides, // then labeling an edge is equivalent to labeling the whole polygon. const parentPoly = selectedSegments[0].parentPolygon; - if (parentPoly && parentPoly.borders.length === 2) { + if (parentPoly && parentPoly.vertices.length === 3) { return parentPoly; } } @@ -928,7 +1400,7 @@ export const GeometryContentModel = GeometryBaseContentModel // sort into creation order const idToIndexMap: { [id: string]: number } = {}; - board.objectsList.forEach((obj, index) => { + forEachBoardObject(board, (obj, index) => { idToIndexMap[obj.id] = index; }); selectedIds.sort((a, b) => idToIndexMap[a] - idToIndexMap[b]); @@ -952,38 +1424,35 @@ export const GeometryContentModel = GeometryBaseContentModel return copies; } + /** + * Delete the selected objects. + * Adjusts for various business logic before actually deleting: + * eg, preserving linked points and points connected to polygons that are not being deleted. + * @param board + */ function deleteSelection(board: JXG.Board) { - const selectedIds = self.getDeletableSelectedIds(board); + const selectedIds = self.getSelectedIds(board); // remove points from polygons; identify additional objects to delete - selectedIds.push(...prepareToDeleteObjects(board, selectedIds)); + const deleteIds = prepareToDeleteObjects(board, selectedIds); - self.deselectAll(board); - board.showInfobox(false); - if (selectedIds.length) { - removeObjects(board, selectedIds); + if (deleteIds.length) { + removeObjects(board, deleteIds); } } - function applyAndLogChange(board: JXG.Board | undefined, _change: JXGChange) { - const result = board && syncChange(board, _change); - - let loggedChange = {..._change}; - if (!Array.isArray(_change.properties)) { - // flatten change.properties - delete loggedChange.properties; - loggedChange = { - ...loggedChange, - ..._change.properties - }; - } else { - // or clean up MST array - loggedChange.properties = Array.from(_change.properties); + function applyAndLogChange(board: JXG.Board | undefined, change: JXGChange) { + const result = board && syncChange(board, change); + let propsId, text, labelOption, filename; + if (change.properties && !Array.isArray(change.properties)) { + propsId = change.properties.id; + text = change.properties.text; + labelOption = change.properties.labelOption?.toString(); + filename = change.properties.filename; } - const tileId = self.metadata?.id || ""; - const { operation, ...change } = loggedChange; - logTileChangeEvent(LogEventName.GEOMETRY_TOOL_CHANGE, { tileId, operation, change }); - + const targetId = propsId || change.targetID; + logGeometryEvent(self, change.operation, change.target, targetId, + { text, labelOption, filename, userAction: change.userAction }); return result; } @@ -1028,18 +1497,28 @@ export const GeometryContentModel = GeometryBaseContentModel actions: { initializeBoard, destroyBoard, + zoomBoard, rescaleBoard, resizeBoard, updateScale, addImage, addPoint, addPoints, + addPhantomPoint, + setPhantomPointPosition, + realizePhantomPoint, + addPointToActivePolygon, + makePolygonActive, + clearPhantomPoint, + createPolygonIncludingPoint, + clearActivePolygon, + closeActivePolygon, addMovableLine, removeObjects, updateObjects, - createPolygonFromFreePoints, addVertexAngle, updateAxisLabels, + updatePolygonLabel, updatePolygonSegmentLabel, deleteSelection, applyChange: applyAndLogChange, @@ -1066,66 +1545,6 @@ export const GeometryContentModel = GeometryBaseContentModel } }; }) - .views(self => ({ - getPositionOfPoint(dataSet: IDataSet, caseId: string, attrId: string): JXGUnsafeCoordPair { - const attrCount = dataSet.attributes.length; - const xAttr = attrCount > 0 ? dataSet.attributes[0] : undefined; - const yAttr = dataSet.attrFromID(attrId); - const xValue = xAttr ? dataSet.getValue(caseId, xAttr.id) : undefined; - const yValue = yAttr ? dataSet.getValue(caseId, yAttr.id) : undefined; - return [canonicalizeValue(xValue), canonicalizeValue(yValue)]; - } - })) - .views(self => ({ - getPointPositionsForColumns(dataSet: IDataSet, attrIds: string[]): [string[], JXGUnsafeCoordPair[]] { - const pointIds: string[] = []; - const positions: JXGUnsafeCoordPair[] = []; - dataSet.cases.forEach(aCase => { - const caseId = aCase.__id__; - attrIds.forEach(attrId => { - pointIds.push(linkedPointId(caseId, attrId)); - positions.push(self.getPositionOfPoint(dataSet, caseId, attrId)); - }); - }); - return [pointIds, positions]; - }, - getPointPositionsForRowsChange(dataSet: IDataSet, change: ITableChange): [string[], JXGUnsafeCoordPair[]] { - const pointIds: string[] = []; - const positions: JXGUnsafeCoordPair[] = []; - const caseIds = castArray(change.ids); - const propsArray: IRowProperties[] = change.action === "create" - ? (change.props as ICreateRowsProperties)?.rows - : castArray(change.props as any); - const xAttrId = dataSet.attributes.length > 0 ? dataSet.attributes[0].id : undefined; - caseIds.forEach((caseId, caseIndex) => { - const tableProps = propsArray[caseIndex] || propsArray[0]; - // if x value changes, all points in row are affected - if (xAttrId && tableProps[xAttrId] != null) { - for (let attrIndex = 1; attrIndex < dataSet.attributes.length; ++attrIndex) { - const attrId = dataSet.attributes[attrIndex].id; - const pointId = linkedPointId(caseId, attrId); - const position = self.getPositionOfPoint(dataSet, caseId, attrId); - if (pointId && position) { - pointIds.push(pointId); - positions.push(position); - } - } - } - // otherwise, only points with y-value changes are affected - else { - each(tableProps, (value, attrId) => { - const pointId = linkedPointId(caseId, attrId); - const position = self.getPositionOfPoint(dataSet, caseId, attrId); - if (pointId && position) { - pointIds.push(pointId); - positions.push(position); - } - }); - } - }); - return [pointIds, positions]; - } - })) .actions(self => ({ afterAttach() { // This reaction monitors legacy links and shared data sets, linking to tables as their diff --git a/src/models/tiles/geometry/geometry-import.test.ts b/src/models/tiles/geometry/geometry-import.test.ts index ed7f7ffed7..40fb13013a 100644 --- a/src/models/tiles/geometry/geometry-import.test.ts +++ b/src/models/tiles/geometry/geometry-import.test.ts @@ -130,7 +130,7 @@ describe("Geometry import", () => { objects: [ { type: "point", parents: [0, 0] }, { type: "point", parents: [2, 2], properties: { id: "p1" } }, - { type: "point", parents: [5, 5], properties: { foo: "bar" }} + { type: "point", parents: [5, 5], properties: { foo: "bar", name: "Bob", labelOption: "label" }} ] }; jestSpyConsole("warn", spy => { @@ -138,9 +138,9 @@ describe("Geometry import", () => { type: kGeometryTileType, board: kDefaultBoardModelOutputProps, objects: { - "testid-1": { type: "point", id: "testid-1", x: 0, y: 0 }, - "p1": { type: "point", id: "p1", x: 2, y: 2 }, - "testid-2": { type: "point", id: "testid-2", x: 5, y: 5 } + "testid-1": { type: "point", id: "testid-1", colorScheme: 0, x: 0, y: 0, labelOption: "none" }, + "p1": { type: "point", id: "p1", colorScheme: 0, x: 2, y: 2, labelOption: "none" }, + "testid-2": { type: "point", id: "testid-2", colorScheme: 0, x: 5, y: 5, name: "Bob", labelOption: "label" } } }); // warns about unrecognized property "foo" @@ -162,7 +162,7 @@ describe("Geometry import", () => { type: kGeometryTileType, board: kDefaultBoardModelOutputProps, objects: { - "testid-1": { type: "point", id: "testid-1", x: 0, y: 0 }, + "testid-1": { type: "point", id: "testid-1", colorScheme: 0, x: 0, y: 0, labelOption: "none" }, "testid-2": { type: "comment", id: "testid-2", anchors: ["testid-1"], text: "Point Comment" } } }); @@ -182,7 +182,7 @@ describe("Geometry import", () => { type: kGeometryTileType, board: kDefaultBoardModelOutputProps, objects: { - "testid-1": { type: "point", id: "testid-1", x: 0, y: 0 }, + "testid-1": { type: "point", id: "testid-1", colorScheme: 0, x: 0, y: 0, labelOption: "none" }, "testid-2": { type: "comment", id: "testid-2", anchors: ["testid-1"], x: 5, y: 5, text: "Point Comment" } } }); @@ -218,14 +218,16 @@ describe("Geometry import", () => { type: kGeometryTileType, board: kDefaultBoardModelOutputProps, objects: { - "testid-1": { type: "polygon", id: "testid-1", points: ["testid-2", "testid-3", "testid-4"] }, - "testid-2": { type: "point", id: "testid-2", x: 0, y: 0 }, - "testid-3": { type: "point", id: "testid-3", x: 5, y: 0 }, - "testid-4": { type: "point", id: "testid-4", x: 5, y: 5 }, - "testid-5": { type: "point", id: "testid-5", x: 10, y: 10 }, - "testid-6": { type: "point", id: "testid-6", x: 15, y: 10 }, - "testid-7": { type: "point", id: "testid-7", x: 15, y: 15 }, - "poly1": { type: "polygon", id: "poly1", points: ["testid-5", "testid-6", "testid-7"] }, + "testid-1": { type: "polygon", id: "testid-1", colorScheme: 0, labelOption: "none", + points: ["testid-2", "testid-3", "testid-4"] }, + "testid-2": { type: "point", id: "testid-2", colorScheme: 0, x: 0, y: 0, labelOption: "none" }, + "testid-3": { type: "point", id: "testid-3", colorScheme: 0, x: 5, y: 0, labelOption: "none" }, + "testid-4": { type: "point", id: "testid-4", colorScheme: 0, x: 5, y: 5, labelOption: "none" }, + "testid-5": { type: "point", id: "testid-5", colorScheme: 0, x: 10, y: 10, labelOption: "none" }, + "testid-6": { type: "point", id: "testid-6", colorScheme: 0, x: 15, y: 10, labelOption: "none" }, + "testid-7": { type: "point", id: "testid-7", colorScheme: 0, x: 15, y: 15, labelOption: "none" }, + "poly1": { type: "polygon", id: "poly1", colorScheme: 0, labelOption: "none", + points: ["testid-5", "testid-6", "testid-7"] }, } }); @@ -250,10 +252,11 @@ describe("Geometry import", () => { type: kGeometryTileType, board: kDefaultBoardModelOutputProps, objects: { - "testid-1": { type: "polygon", id: "testid-1", points: ["testid-2", "testid-3", "testid-4"] }, - "testid-2": { type: "point", id: "testid-2", x: 0, y: 0 }, - "testid-3": { type: "point", id: "testid-3", x: 5, y: 0 }, - "testid-4": { type: "point", id: "testid-4", x: 5, y: 5 }, + "testid-1": { type: "polygon", id: "testid-1", colorScheme: 0, labelOption: "none", + points: ["testid-2", "testid-3", "testid-4"] }, + "testid-2": { type: "point", id: "testid-2", colorScheme: 0, x: 0, y: 0, labelOption: "none" }, + "testid-3": { type: "point", id: "testid-3", colorScheme: 0, x: 5, y: 0, labelOption: "none" }, + "testid-4": { type: "point", id: "testid-4", colorScheme: 0, x: 5, y: 5, labelOption: "none" }, "testid-5": { type: "vertexAngle", id: "testid-5", points: ["testid-4", "testid-2", "testid-3"] }, "testid-6": { type: "vertexAngle", id: "testid-6", points: ["testid-2", "testid-3", "testid-4"] }, "testid-7": { type: "vertexAngle", id: "testid-7", points: ["testid-3", "testid-4", "testid-2"] } @@ -281,10 +284,10 @@ describe("Geometry import", () => { type: kGeometryTileType, board: kDefaultBoardModelOutputProps, objects: { - "v1": { type: "point", id: "v1", x: 0, y: 0 }, - "v2": { type: "point", id: "v2", x: 5, y: 0 }, - "v3": { type: "point", id: "v3", x: 5, y: 5 }, - "p1": { type: "polygon", id: "p1", points: ["v1", "v2", "v3"] }, + "v1": { type: "point", id: "v1", colorScheme: 0, x: 0, y: 0, labelOption: "none" }, + "v2": { type: "point", id: "v2", colorScheme: 0, x: 5, y: 0, labelOption: "none" }, + "v3": { type: "point", id: "v3", colorScheme: 0, x: 5, y: 5, labelOption: "none" }, + "p1": { type: "polygon", id: "p1", colorScheme: 0, labelOption: "none", points: ["v1", "v2", "v3"] }, "a1": { type: "vertexAngle", id: "a1", points: ["v3", "v1", "v2"] }, "a2": { type: "vertexAngle", id: "a2", points: ["v1", "v2", "v3"] }, "a3": { type: "vertexAngle", id: "a3", points: ["v2", "v3", "v1"] } @@ -313,10 +316,11 @@ describe("Geometry import", () => { type: kGeometryTileType, board: kDefaultBoardModelOutputProps, objects: { - "testid-1": { type: "polygon", id: "testid-1", points: ["testid-2", "testid-3", "testid-4"] }, - "testid-2": { type: "point", id: "testid-2", x: 0, y: 0 }, - "testid-3": { type: "point", id: "testid-3", x: 5, y: 0 }, - "testid-4": { type: "point", id: "testid-4", x: 5, y: 5 }, + "testid-1": { type: "polygon", id: "testid-1", colorScheme: 0, labelOption: "none", + points: ["testid-2", "testid-3", "testid-4"] }, + "testid-2": { type: "point", id: "testid-2", colorScheme: 0, x: 0, y: 0, labelOption: "none" }, + "testid-3": { type: "point", id: "testid-3", colorScheme: 0, x: 5, y: 0, labelOption: "none" }, + "testid-4": { type: "point", id: "testid-4", colorScheme: 0, x: 5, y: 5, labelOption: "none" }, "testid-5": { type: "comment", id: "testid-5", anchors: ["testid-1"], text: "Polygon Comment" } } }); @@ -417,12 +421,12 @@ describe("Geometry import", () => { type: kGeometryTileType, board: kDefaultBoardModelOutputProps, objects: { - "testid-1": { type: "movableLine", id: "testid-1", - p1: { type: "point", id: "testid-1-point1", x: 0, y: 0 }, - p2: { type: "point", id: "testid-1-point2", x: 5, y: 5 } }, - "l1": { type: "movableLine", id: "l1", - p1: { type: "point", id: "l1-point1", x: 10, y: 10 }, - p2: { type: "point", id: "l1-point2", x: 15, y: 15 } } + "testid-1": { type: "movableLine", id: "testid-1", colorScheme: 0, + p1: { type: "point", id: "testid-1-point1", colorScheme: 0, x: 0, y: 0, labelOption: "none" }, + p2: { type: "point", id: "testid-1-point2", colorScheme: 0, x: 5, y: 5, labelOption: "none" } }, + "l1": { type: "movableLine", id: "l1", colorScheme: 0, + p1: { type: "point", id: "l1-point1", colorScheme: 0, x: 10, y: 10, labelOption: "none" }, + p2: { type: "point", id: "l1-point2", colorScheme: 0, x: 15, y: 15, labelOption: "none" } } } }); @@ -447,9 +451,9 @@ describe("Geometry import", () => { type: kGeometryTileType, board: kDefaultBoardModelOutputProps, objects: { - "testid-1": { type: "movableLine", id: "testid-1", - p1: { type: "point", id: "testid-1-point1", x: 0, y: 0 }, - p2: { type: "point", id: "testid-1-point2", x: 5, y: 5 } }, + "testid-1": { type: "movableLine", id: "testid-1", colorScheme: 0, + p1: { type: "point", id: "testid-1-point1", colorScheme: 0, x: 0, y: 0, labelOption: "none" }, + p2: { type: "point", id: "testid-1-point2", colorScheme: 0, x: 5, y: 5, labelOption: "none" } }, "testid-2": { type: "comment", id: "testid-2", anchors: ["testid-1"], text: "Line Comment" } } }); diff --git a/src/models/tiles/geometry/geometry-import.ts b/src/models/tiles/geometry/geometry-import.ts index f36035bf84..212d23ddb8 100644 --- a/src/models/tiles/geometry/geometry-import.ts +++ b/src/models/tiles/geometry/geometry-import.ts @@ -201,7 +201,6 @@ export function defaultGeometryBoardChange( operation: "create", target: "board", properties: { - axis: true, boundingBox, ...units, ...overrides diff --git a/src/models/tiles/geometry/geometry-migrate.test.ts b/src/models/tiles/geometry/geometry-migrate.test.ts index 3f86a63502..8caac4bca5 100644 --- a/src/models/tiles/geometry/geometry-migrate.test.ts +++ b/src/models/tiles/geometry/geometry-migrate.test.ts @@ -1,7 +1,7 @@ import { safeJsonParse } from "../../../utilities/js-utils"; import { omitUndefined } from "../../../utilities/test-utils"; import { convertChangesToModel, exportGeometryJson } from "./geometry-migrate"; -import { ESegmentLabelOption, JXGChange } from "./jxg-changes"; +import { ELabelOption, JXGChange } from "./jxg-changes"; // default unitPx is 18.3, but for testing purposes we use rounder numbers @@ -237,8 +237,8 @@ describe("Geometry migration", () => { expect(convertChangesToModelSnapshot(changes)).toEqual({ ...kDefaultModelProps, objects: { - p1: { type: "point", id: "p1", x: 0, y: 0 }, - p2: { type: "point", id: "p2", x: 5, y: 5 } + p1: { type: "point", id: "p1", colorScheme: 0, x: 0, y: 0, labelOption: "none" }, + p2: { type: "point", id: "p2", colorScheme: 0, x: 5, y: 5, labelOption: "none" } } }); }); @@ -265,8 +265,8 @@ describe("Geometry migration", () => { expect(convertChangesToModelSnapshot(changes)).toEqual({ ...kDefaultModelProps, objects: { - p1: { type: "point", id: "p1", x: 0, y: 0 }, - p2: { type: "point", id: "p2", x: 5, y: 5 } + p1: { type: "point", id: "p1", colorScheme: 0, x: 0, y: 0, labelOption: "none" }, + p2: { type: "point", id: "p2", colorScheme: 0, x: 5, y: 5, labelOption: "none" } } }); }); @@ -295,8 +295,8 @@ describe("Geometry migration", () => { expect(convertChangesToModelSnapshot(changes)).toEqual({ ...kDefaultModelProps, objects: { - p1: { type: "point", id: "p1", x: 0, y: 0 }, - p2: { type: "point", id: "p2", x: 2, y: 2 } + p1: { type: "point", id: "p1", colorScheme: 0, x: 0, y: 0, labelOption: "none" }, + p2: { type: "point", id: "p2", colorScheme: 0, x: 2, y: 2, labelOption: "none" } } }); }); @@ -325,8 +325,8 @@ describe("Geometry migration", () => { expect(convertChangesToModelSnapshot(changes)).toEqual({ ...kDefaultModelProps, objects: { - p1: { type: "point", id: "p1", x: 0, y: 0 }, - p2: { type: "point", id: "p2", x: 2, y: 2 } + p1: { type: "point", id: "p1", colorScheme: 0, x: 0, y: 0, labelOption: "none" }, + p2: { type: "point", id: "p2", colorScheme: 0, x: 2, y: 2, labelOption: "none" } } }); }); @@ -356,8 +356,8 @@ describe("Geometry migration", () => { expect(convertChangesToModelSnapshot(changes)).toEqual({ ...kDefaultModelProps, objects: { - p1: { type: "point", id: "p1", x: 0, y: 0 }, - p2: { type: "point", id: "p2", x: 5, y: 5 }, + p1: { type: "point", id: "p1", colorScheme: 0, x: 0, y: 0, labelOption: "none" }, + p2: { type: "point", id: "p2", colorScheme: 0, x: 5, y: 5, labelOption: "none" }, c1: { type: "comment", id: "c1", anchors: ["p1"] } } }); @@ -388,8 +388,8 @@ describe("Geometry migration", () => { expect(convertChangesToModelSnapshot(changes)).toEqual({ ...kDefaultModelProps, objects: { - p1: { type: "point", id: "p1", x: 0, y: 0 }, - p2: { type: "point", id: "p2", x: 5, y: 5 }, + p1: { type: "point", id: "p1", colorScheme: 0, x: 0, y: 0, labelOption: "none" }, + p2: { type: "point", id: "p2", colorScheme: 0, x: 5, y: 5, labelOption: "none" }, c1: { type: "comment", id: "c1", anchors: ["p1"], x: 5, y: 5 } } }); @@ -424,8 +424,8 @@ describe("Geometry migration", () => { expect(convertChangesToModelSnapshot(changes)).toEqual({ ...kDefaultModelProps, objects: { - p1: { type: "point", id: "p1", x: 0, y: 0 }, - p2: { type: "point", id: "p2", x: 5, y: 5 }, + p1: { type: "point", id: "p1", colorScheme: 0, x: 0, y: 0, labelOption: "none" }, + p2: { type: "point", id: "p2", colorScheme: 0, x: 5, y: 5, labelOption: "none" }, c1: { type: "comment", id: "c1", anchors: ["p1"], x: 2, y: 2 }, c2: { type: "comment", id: "c2", anchors: ["p2"], x: 3, y: 3 } } @@ -439,15 +439,16 @@ describe("Geometry migration", () => { target: "board", properties: { axis: true, boundingBox: [-2, 15, 22, -1], unitX: 20, unitY: 20 } }, - { operation: "create", target: "point", parents: [0, 0], properties: { id: "p1", fillColor: "blue" } }, - { operation: "create", target: "point", parents: [5, 5], properties: { id: "p2" } } + { operation: "create", target: "point", parents: [0, 0], properties: { id: "p1", colorScheme: 1 } }, + { operation: "create", target: "point", parents: [5, 5], + properties: { id: "p2", name: "Bob", labelOption: ELabelOption.kLabel } } ]; expect(convertChangesToJson(changes)).toEqual({ type: "Geometry", board: { properties: { axisMin: [-2, -1], axisRange: [24, 16] } }, objects: [ - { type: "point", parents: [0, 0], properties: { id: "p1", fillColor: "blue" } }, - { type: "point", parents: [5, 5], properties: { id: "p2" } } + { type: "point", parents: [0, 0], properties: { id: "p1", colorScheme: 1 } }, + { type: "point", parents: [5, 5], properties: { id: "p2", name: "Bob", labelOption: "label" } } ] }); const [received, expected] = testRoundTrip(changes); @@ -455,8 +456,8 @@ describe("Geometry migration", () => { expect(convertChangesToModelSnapshot(changes)).toEqual({ ...kDefaultModelProps, objects: { - p1: { type: "point", id: "p1", x: 0, y: 0, fillColor: "blue" }, - p2: { type: "point", id: "p2", x: 5, y: 5 } + p1: { type: "point", id: "p1", colorScheme: 1, x: 0, y: 0, labelOption: "none" }, + p2: { type: "point", id: "p2", colorScheme: 0, x: 5, y: 5, name: "Bob", labelOption: "label" } } }); }); @@ -506,7 +507,7 @@ describe("Geometry migration", () => { expect(convertChangesToModelSnapshot(changes)).toEqual({ ...kDefaultModelProps, objects: { - p1: { type: "point", id: "p1", x: 0, y: 0 } + p1: { type: "point", id: "p1", colorScheme: 0, x: 0, y: 0, labelOption: "none" } } }); }); @@ -554,7 +555,7 @@ describe("Geometry migration", () => { expect(convertChangesToModelSnapshot(changes)).toEqual({ ...kDefaultModelProps, objects: { - p1: { type: "polygon", id: "p1", points: ["lp1", "lp2", "lp3"]}, + p1: { type: "polygon", id: "p1", colorScheme: 0, labelOption: "none", points: ["lp1", "lp2", "lp3"]}, } }); }); @@ -644,11 +645,11 @@ describe("Geometry migration", () => { expect(convertChangesToModelSnapshot(changes)).toEqual({ ...kDefaultModelProps, objects: { - v1: { type: "point", id: "v1", x: 0, y: 0 }, - v2: { type: "point", id: "v2", x: 5, y: 0 }, - v3: { type: "point", id: "v3", x: 0, y: 5 }, - p1: { type: "polygon", id: "p1", points: ["v1", "v2", "v3"]}, - p2: { type: "polygon", id: "p2", points: ["v1", "v2", "v3"]} + v1: { type: "point", id: "v1", colorScheme: 0, x: 0, y: 0, labelOption: "none" }, + v2: { type: "point", id: "v2", colorScheme: 0, x: 5, y: 0, labelOption: "none" }, + v3: { type: "point", id: "v3", colorScheme: 0, x: 0, y: 5, labelOption: "none" }, + p1: { type: "polygon", id: "p1", colorScheme: 0, labelOption: "none", points: ["v1", "v2", "v3"]}, + p2: { type: "polygon", id: "p2", colorScheme: 0, labelOption: "none", points: ["v1", "v2", "v3"]} } }); }); @@ -682,10 +683,10 @@ describe("Geometry migration", () => { expect(convertChangesToModelSnapshot(changes)).toEqual({ ...kDefaultModelProps, objects: { - v1: { type: "point", id: "v1", x: 0, y: 0 }, - v2: { type: "point", id: "v2", x: 5, y: 0 }, - v3: { type: "point", id: "v3", x: 0, y: 5 }, - p2: { type: "polygon", id: "p2", points: ["v1", "v2", "v3"]} + v1: { type: "point", id: "v1", colorScheme: 0, x: 0, y: 0, labelOption: "none" }, + v2: { type: "point", id: "v2", colorScheme: 0, x: 5, y: 0, labelOption: "none" }, + v3: { type: "point", id: "v3", colorScheme: 0, x: 0, y: 5, labelOption: "none" }, + p2: { type: "polygon", id: "p2", colorScheme: 0, labelOption: "none", points: ["v1", "v2", "v3"]} } }); }); @@ -719,10 +720,10 @@ describe("Geometry migration", () => { expect(convertChangesToModelSnapshot(changes)).toEqual({ ...kDefaultModelProps, objects: { - v1: { type: "point", id: "v1", x: 0, y: 0 }, - v2: { type: "point", id: "v2", x: 5, y: 0 }, - v3: { type: "point", id: "v3", x: 0, y: 5 }, - p1: { type: "polygon", id: "p1", points: ["v1", "v2", "v3"]} + v1: { type: "point", id: "v1", colorScheme: 0, x: 0, y: 0, labelOption: "none" }, + v2: { type: "point", id: "v2", colorScheme: 0, x: 5, y: 0, labelOption: "none" }, + v3: { type: "point", id: "v3", colorScheme: 0, x: 0, y: 5, labelOption: "none" }, + p1: { type: "polygon", id: "p1", colorScheme: 0, labelOption: "none", points: ["v1", "v2", "v3"]} } }); }); @@ -740,9 +741,9 @@ describe("Geometry migration", () => { { operation: "create", target: "point", parents: [0, 0], properties: { id: "v4" } }, { operation: "create", target: "polygon", parents: ["v1", "v2", "v3", "v4"], properties: { id: "p1" } }, { operation: "update", target: "polygon", targetID: "p1", parents: ["v1", "v2"], - properties: { labelOption: ESegmentLabelOption.kLength } }, + properties: { labelOption: ELabelOption.kLength } }, { operation: "update", target: "polygon", targetID: "p1", parents: ["v2", "v3"], - properties: { labelOption: ESegmentLabelOption.kLabel } } + properties: { labelOption: ELabelOption.kLabel } } ]; // NOTE: Legacy JSON export apparently never supported segment labels. ¯\_ (ツ)_/¯ // We could fix this, but since we're deprecating the legacy import format, it doesn't seem worth it. @@ -762,12 +763,12 @@ describe("Geometry migration", () => { expect(convertChangesToModelSnapshot(changes)).toEqual({ ...kDefaultModelProps, objects: { - v1: { type: "point", id: "v1", x: 0, y: 6 }, - v2: { type: "point", id: "v2", x: 6, y: 6 }, - v3: { type: "point", id: "v3", x: 6, y: 0 }, - v4: { type: "point", id: "v4", x: 0, y: 0 }, - p1: { type: "polygon", id: "p1", points: ["v1", "v2", "v3", "v4"], - labels: [{ id: "v1:v2", option: "length" }, { id: "v2:v3", option: "label" }] } + v1: { type: "point", id: "v1", colorScheme: 0, x: 0, y: 6, labelOption: "none" }, + v2: { type: "point", id: "v2", colorScheme: 0, x: 6, y: 6, labelOption: "none" }, + v3: { type: "point", id: "v3", colorScheme: 0, x: 6, y: 0, labelOption: "none" }, + v4: { type: "point", id: "v4", colorScheme: 0, x: 0, y: 0, labelOption: "none" }, + p1: { type: "polygon", id: "p1", colorScheme: 0, labelOption: "none", points: ["v1", "v2", "v3", "v4"], + labels: [{ id: "v1::v2", option: "length" }, { id: "v2::v3", option: "label" }] } } }); }); @@ -803,11 +804,11 @@ describe("Geometry migration", () => { expect(convertChangesToModelSnapshot(changes)).toEqual({ ...kDefaultModelProps, objects: { - v1: { type: "point", id: "v1", x: 0, y: 6 }, - v2: { type: "point", id: "v2", x: 6, y: 6 }, - v3: { type: "point", id: "v3", x: 6, y: 0 }, - v4: { type: "point", id: "v4", x: 0, y: 0 }, - p1: { type: "polygon", id: "p1", points: ["v1", "v2", "v3", "v4"]}, + v1: { type: "point", id: "v1", colorScheme: 0, x: 0, y: 6, labelOption: "none" }, + v2: { type: "point", id: "v2", colorScheme: 0, x: 6, y: 6, labelOption: "none" }, + v3: { type: "point", id: "v3", colorScheme: 0, x: 6, y: 0, labelOption: "none" }, + v4: { type: "point", id: "v4", colorScheme: 0, x: 0, y: 0, labelOption: "none" }, + p1: { type: "polygon", id: "p1", colorScheme: 0, labelOption: "none", points: ["v1", "v2", "v3", "v4"]}, c1: { type: "comment", id: "c1", anchors: ["p1"] } } }); @@ -844,11 +845,11 @@ describe("Geometry migration", () => { expect(convertChangesToModelSnapshot(changes)).toEqual({ ...kDefaultModelProps, objects: { - v1: { type: "point", id: "v1", x: 0, y: 6 }, - v2: { type: "point", id: "v2", x: 6, y: 6 }, - v3: { type: "point", id: "v3", x: 6, y: 0 }, - v4: { type: "point", id: "v4", x: 0, y: 0 }, - p1: { type: "polygon", id: "p1", points: ["v1", "v2", "v3", "v4"]}, + v1: { type: "point", id: "v1", colorScheme: 0, x: 0, y: 6, labelOption: "none" }, + v2: { type: "point", id: "v2", colorScheme: 0, x: 6, y: 6, labelOption: "none" }, + v3: { type: "point", id: "v3", colorScheme: 0, x: 6, y: 0, labelOption: "none" }, + v4: { type: "point", id: "v4", colorScheme: 0, x: 0, y: 0, labelOption: "none" }, + p1: { type: "polygon", id: "p1", colorScheme: 0, labelOption: "none", points: ["v1", "v2", "v3", "v4"]}, c1: { type: "comment", id: "c1", anchors: ["p1"], x: 3, y: 3 } } }); @@ -886,11 +887,11 @@ describe("Geometry migration", () => { expect(convertChangesToModelSnapshot(changes)).toEqual({ ...kDefaultModelProps, objects: { - v1: { type: "point", id: "v1", x: 0, y: 6 }, - v2: { type: "point", id: "v2", x: 6, y: 6 }, - v3: { type: "point", id: "v3", x: 6, y: 0 }, - v4: { type: "point", id: "v4", x: 0, y: 0 }, - p1: { type: "polygon", id: "p1", points: ["v1", "v2", "v3", "v4"]}, + v1: { type: "point", id: "v1", colorScheme: 0, x: 0, y: 6, labelOption: "none" }, + v2: { type: "point", id: "v2", colorScheme: 0, x: 6, y: 6, labelOption: "none" }, + v3: { type: "point", id: "v3", colorScheme: 0, x: 6, y: 0, labelOption: "none" }, + v4: { type: "point", id: "v4", colorScheme: 0, x: 0, y: 0, labelOption: "none" }, + p1: { type: "polygon", id: "p1", colorScheme: 0, labelOption: "none", points: ["v1", "v2", "v3", "v4"]}, c1: { type: "comment", id: "c1", anchors: ["p1"], x: 2, y: 2 } } }); @@ -925,10 +926,10 @@ describe("Geometry migration", () => { expect(convertChangesToModelSnapshot(changes)).toEqual({ ...kDefaultModelProps, objects: { - v1: { type: "point", id: "v1", x: 0, y: 0 }, - v2: { type: "point", id: "v2", x: 5, y: 0 }, - v3: { type: "point", id: "v3", x: 0, y: 5 }, - p1: { type: "polygon", id: "p1", points: ["v1", "v2", "v3"]}, + v1: { type: "point", id: "v1", colorScheme: 0, x: 0, y: 0, labelOption: "none" }, + v2: { type: "point", id: "v2", colorScheme: 0, x: 5, y: 0, labelOption: "none" }, + v3: { type: "point", id: "v3", colorScheme: 0, x: 0, y: 5, labelOption: "none" }, + p1: { type: "polygon", id: "p1", colorScheme: 0, labelOption: "none", points: ["v1", "v2", "v3"]}, a1: { type: "vertexAngle", id: "a1", points: ["v1", "v2", "v3"] } } }); @@ -962,9 +963,9 @@ describe("Geometry migration", () => { expect(convertChangesToModelSnapshot(changes)).toEqual({ ...kDefaultModelProps, objects: { - l1: { type: "movableLine", id: "l1", - p1: { type: "point", id: "l1-point1", x: 0, y: 0 }, - p2: { type: "point", id: "l1-point2", x: 5, y: 5 } } + l1: { type: "movableLine", id: "l1", colorScheme: 0, + p1: { type: "point", id: "l1-point1", colorScheme: 0, x: 0, y: 0, labelOption: "none" }, + p2: { type: "point", id: "l1-point2", colorScheme: 0, x: 5, y: 5, labelOption: "none" } } } }); }); @@ -998,9 +999,9 @@ describe("Geometry migration", () => { expect(convertChangesToModelSnapshot(changes)).toEqual({ ...kDefaultModelProps, objects: { - l1: { type: "movableLine", id: "l1", - p1: { type: "point", id: "l1-point1", x: 0, y: 5 }, - p2: { type: "point", id: "l1-point2", x: 5, y: 10 } } + l1: { type: "movableLine", id: "l1", colorScheme: 0, + p1: { type: "point", id: "l1-point1", colorScheme: 0, x: 0, y: 5, labelOption: "none" }, + p2: { type: "point", id: "l1-point2", colorScheme: 0, x: 5, y: 10, labelOption: "none" } } } }); }); diff --git a/src/models/tiles/geometry/geometry-migrate.ts b/src/models/tiles/geometry/geometry-migrate.ts index 3fff6f49fa..89f79b679e 100644 --- a/src/models/tiles/geometry/geometry-migrate.ts +++ b/src/models/tiles/geometry/geometry-migrate.ts @@ -5,11 +5,12 @@ import { comma, StringBuilder } from "../../../utilities/string-builder"; import { BoardModel, BoardModelType, CommentModel, CommentModelType, GeometryBaseContentModelType, GeometryExtrasContentSnapshotType, GeometryObjectModelType, ImageModel, ImageModelType, + isPointModel, MovableLineModel, MovableLineModelType, pointIdsFromSegmentId, PointModel, PointModelType, PolygonModel, PolygonModelType, PolygonSegmentLabelModelSnapshot, VertexAngleModel, VertexAngleModelType } from "./geometry-model"; import { - ESegmentLabelOption, JXGChange, JXGCoordPair, JXGImageParents, JXGObjectType, JXGProperties + ELabelOption, JXGChange, JXGCoordPair, JXGImageParents, JXGObjectType, JXGProperties } from "./jxg-changes"; import { getMovableLinePointIds, kGeometryDefaultHeight, kGeometryDefaultWidth } from "./jxg-types"; import { kDefaultBoardModelOutputProps, kGeometryTileType } from "./geometry-types"; @@ -31,18 +32,20 @@ export const convertChangesToModel = (changes: JXGChange[]) => { return exportGeometryModel(changesJson); }; -export const convertModelToChanges = ( +export const getGeometryBoardChange = ( model: GeometryBaseContentModelType, boardOptions?: IGeometryBoardChangeOptions -): JXGChange[] => { - const { board, bgImage, objects } = model; - const changes: JXGChange[] = []; - // convert the board - const { xAxis, yAxis } = board || BoardModel.create(kDefaultBoardModelOutputProps); +): JXGChange => { + const { xAxis, yAxis } = model.board || BoardModel.create(kDefaultBoardModelOutputProps); const { name: xName, label: xAnnotation } = xAxis; const { name: yName, label: yAnnotation } = yAxis; - changes.push( + return ( defaultGeometryBoardChange(xAxis, yAxis, { xName, yName, xAnnotation, yAnnotation }, boardOptions ) ); +}; + +export const convertModelToChanges = (model: GeometryBaseContentModelType): JXGChange[] => { + const { bgImage, objects } = model; + const changes: JXGChange[] = []; // convert the background image (if any) if (bgImage) { changes.push(...convertModelObjectToChanges(bgImage)); @@ -77,8 +80,16 @@ function omitNullish(inProps: Record) { export const convertModelObjectsToChanges = (objects: GeometryObjectModelType[]): JXGChange[] => { const changes: JXGChange[] = []; + // Process points first, before objects like polygons that refer to them. + objects.forEach(obj => { + if (isPointModel(obj)) { + changes.push(...convertModelObjectToChanges(obj)); + } + }); objects.forEach(obj => { - changes.push(...convertModelObjectToChanges(obj)); + if (!isPointModel(obj)) { + changes.push(...convertModelObjectToChanges(obj)); + } }); return changes; }; @@ -110,6 +121,10 @@ export const convertModelObjectToChanges = (obj: GeometryObjectModelType): JXGCh case "point": { const { type, x, y, ...props } = obj as PointModelType; const properties = omitNullish(props); + if (properties.labelOption) { + properties.clientLabelOption = properties.labelOption; + properties.labelOption = undefined; + } changes.push({ operation: "create", target: "point", parents: [x, y], properties }); break; } @@ -117,12 +132,20 @@ export const convertModelObjectToChanges = (obj: GeometryObjectModelType): JXGCh const poly = obj as PolygonModelType; const { type, points: parents, labels, ...props } = poly; const properties = omitNullish(props); + if (properties.labelOption) { + properties.clientLabelOption = properties.labelOption; + properties.labelOption = undefined; + } + if (properties.name) { + properties.clientName = properties.name; + properties.name = undefined; + } changes.push({ operation: "create", target: "polygon", parents, properties }); - (labels || []).forEach(({ id, option }) => { + (labels || []).forEach(({ id, option, name }) => { const pts = pointIdsFromSegmentId(id); if (pts.length === 2) { const _parents = [pts[0], pts[1]]; - const _properties = { labelOption: option }; + const _properties = { labelOption: option, name }; changes.push({ operation: "update", target: "polygon", targetID: poly.id, parents: _parents, properties: _properties }); } @@ -223,6 +246,12 @@ function getDependenciesFromChange(change: JXGChange, objectInfoMap: Record { return exportGeometry(changes, { ...options, json: true }) as string; }; @@ -478,7 +507,7 @@ export const exportGeometry = (changes: string[], options?: ITileExportOptions) const exportPolygon = (id: string, isLast: boolean) => { const _changes = objectInfoMap[id].changes; - const labelMap = new Map(); + const labelMap = new Map(); let props: any = {}; _changes.forEach(change => { const { parents, properties } = change; diff --git a/src/models/tiles/geometry/geometry-model.ts b/src/models/tiles/geometry/geometry-model.ts index ee0843c2e0..788ea13403 100644 --- a/src/models/tiles/geometry/geometry-model.ts +++ b/src/models/tiles/geometry/geometry-model.ts @@ -1,16 +1,13 @@ import { difference, intersection } from "lodash"; -import { applySnapshot, getSnapshot, getType, Instance, SnapshotIn, types } from "mobx-state-tree"; +import { applySnapshot, getSnapshot, Instance, SnapshotIn, types } from "mobx-state-tree"; import { kDefaultBoardModelInputProps, kGeometryTileType } from "./geometry-types"; import { uniqueId } from "../../../utilities/js-utils"; import { typeField } from "../../../utilities/mst-utils"; import { TileContentModel } from "../tile-content"; -import { ESegmentLabelOption, JXGChange, JXGPositionProperty } from "./jxg-changes"; -import { imageChangeAgent } from "./jxg-image"; -import { movableLineChangeAgent } from "./jxg-movable-line"; -import { createPoint } from "./jxg-point"; -import { polygonChangeAgent } from "./jxg-polygon"; -import { vertexAngleChangeAgent } from "./jxg-vertex-angle"; +import { ELabelOption, JXGPositionProperty } from "./jxg-changes"; import { kGeometryDefaultPixelsPerUnit } from "./jxg-types"; +import { findLeastUsedNumber } from "../../../utilities/math-utils"; +import { clueDataColorInfo } from "../../../utilities/color-utils"; export interface IDependsUponResult { depends: boolean; @@ -155,24 +152,96 @@ export const PointModel = PositionedObjectModel .props({ type: typeField("point"), name: types.maybe(types.string), - fillColor: types.maybe(types.string), - strokeColor: types.maybe(types.string), snapToGrid: types.maybe(types.boolean), - snapSizeX: types.maybe(types.number), - snapSizeY: types.maybe(types.number) + colorScheme: 0, + labelOption: types.optional( + types.enumeration("LabelOption", Object.values(ELabelOption)), + ELabelOption.kNone) }) - .preProcessSnapshot(preProcessPositionInSnapshot); + .preProcessSnapshot(preProcessPositionInSnapshot) + .actions(self => ({ + setLabelOption(option: ELabelOption) { + if (option !== self.labelOption) { + self.labelOption = option; + } + }, + setName(name: string) { + if (name !== self.name) { + self.name = name; + } + } + })); export interface PointModelType extends Instance {} export const isPointModel = (o?: GeometryObjectModelType): o is PointModelType => o?.type === "point"; -export const segmentIdFromPointIds = (ptIds: [string, string]) => `${ptIds[0]}:${ptIds[1]}`; -export const pointIdsFromSegmentId = (segmentId: string) => segmentId.split(":"); +/** + * PointMetadata supplements the information about points that are stored in a DataSet. + * The ID corresponds to the ID that we construct for the DataSet point, + * and the metadata record holds labeling options. If no metadata record exists + * for a given point, then default values are assumed. + */ +export const PointMetadataModel = types.model("PointMetadata", { + id: types.identifier, + name: types.maybe(types.string), + labelOption: types.optional( + types.enumeration("LabelOption", Object.values(ELabelOption)), + ELabelOption.kNone) +}) +.actions(self => ({ + setLabelOption(option: ELabelOption) { + if (option !== self.labelOption) { + self.labelOption = option; + } + }, + setName(name: string) { + if (name !== self.name) { + self.name = name; + } + } +})); + +export interface PointMetadataModelType extends Instance {} + +// PolygonSegments are edges of polygons. +// Usually we don't need to know anything about them since they are defined by +// the polygon and its vertices. However, if they are labeled we store that +// information. The ID used is the concatenated IDs of the endpoints. + +// We use a double colon separator since linked point IDs have a single colon in +// them. Besides these methods, also note the separator comes into play in +// `updateGeometryContentWithNewSharedModelIds`. + +export const segmentIdFromPointIds = (ptIds: [string, string]) => `${ptIds[0]}::${ptIds[1]}`; +export const pointIdsFromSegmentId = (segmentId: string) => segmentId.split("::"); export const PolygonSegmentLabelModel = types.model("PolygonSegmentLabel", { - id: types.identifier, // {pt1Id}:{pt2Id} - option: types.enumeration("LabelOption", Object.values(ESegmentLabelOption)) + id: types.identifier, // {pt1Id}::{pt2Id} + option: types.enumeration("LabelOption", Object.values(ELabelOption)), + name: types.maybe(types.string) +}) +.preProcessSnapshot(snap => { + // Previously a single colon was used as a separator. + // If this is found, replace it with a double colon. + // If the point IDs were from linked points, there would be 3 colons, and the middle one should be doubled. + // Since it was previously not possible to make a polygon from a mixture of linked and unlinked points, + // there should never be 2 ambiguous colons in legacy content. + const id = snap.id; + if (id.match(/::/)) { + // Modern format, return as-is. + return snap; + } + let newId = id; + const colons = (id.match(/:/g) || []).length; + if (colons === 1) { + newId = id.replace(":", "::"); + } else if (colons === 3) { + const parts = id.split(":"); + newId = parts[0] + ":" + parts[1] + "::" + parts[2] + ":" + parts[3]; + } + return { ...snap, id: newId }; }); + export interface PolygonSegmentLabelModelType extends Instance {} export interface PolygonSegmentLabelModelSnapshot extends SnapshotIn {} @@ -181,7 +250,12 @@ export const PolygonModel = GeometryObjectModel .props({ type: typeField("polygon"), points: types.array(types.string), - labels: types.maybe(types.array(PolygonSegmentLabelModel)) + labelOption: types.optional( + types.enumeration("LabelOption", Object.values(ELabelOption)), + ELabelOption.kNone), + name: types.maybe(types.string), + labels: types.maybe(types.array(PolygonSegmentLabelModel)), + colorScheme: 0 }) .views(self => ({ get dependencies(): string[] { @@ -218,12 +292,12 @@ export const PolygonModel = GeometryObjectModel replacePoints(ids: string[]) { self.points.replace(ids); }, - setSegmentLabel(ptIds: [string, string], option: ESegmentLabelOption) { + setSegmentLabel(ptIds: [string, string], option: ELabelOption, name: string|undefined) { const id = segmentIdFromPointIds(ptIds); - const value = { id, option }; + const value = { id, option, name }; const foundIndex = self.labels?.findIndex(label => label.id === id); // remove any existing label if setting label to "none" - if (option === ESegmentLabelOption.kNone) { + if (option === ELabelOption.kNone) { if (self.labels && foundIndex != null && foundIndex >= 0) { self.labels.splice(foundIndex, 1); } @@ -272,7 +346,8 @@ export const MovableLineModel = GeometryObjectModel .props({ type: typeField("movableLine"), p1: PointModel, - p2: PointModel + p2: PointModel, + colorScheme: 0 }); export interface MovableLineModelType extends Instance {} @@ -299,72 +374,6 @@ export const ImageModel = PositionedObjectModel export interface ImageModelType extends Instance {} export const isImageModel = (o: GeometryObjectModelType): o is ImageModelType => o.type === "image"; -export function createObject(board: JXG.Board, obj: GeometryObjectModelType) { - const objType = getType(obj); - switch(objType.name) { - - case ImageModel.name: { - const image = obj as ImageModelType; - const { x, y, url, width, height, ...properties } = image; - const change: JXGChange = { - operation: "create", - target: "image", - parents: [url, [x, y], [width, height]], - properties - }; - imageChangeAgent.create(board, change); - break; - } - - case MovableLineModel.name: { - const line = obj as MovableLineModelType; - const { p1, p2, ...properties } = line; - const change: JXGChange = { - operation: "create", - target: "movableLine", - parents: [[p1.x, p1.y], [p2.x, p2.y]], - properties - }; - movableLineChangeAgent.create(board, change); - break; - } - - case PointModel.name: { - const pt = obj as PointModelType; - const { x, y, ...props } = pt; - createPoint(board, [pt.x, pt.y], props); - break; - } - - case PolygonModel.name: { - const poly = obj as PolygonModelType; - const { points, ...properties } = poly; - const change: JXGChange = { - operation: "create", - target: "polygon", - parents: poly.points.filter(id => !!id) as string[], - properties - }; - polygonChangeAgent.create(board, change); - break; - } - - case VertexAngleModel.name: { - const angle = obj as VertexAngleModelType; - const { points, ...properties } = angle; - const change: JXGChange = { - operation: "create", - target: "vertexAngle", - parents: angle.points.filter(id => !!id) as string[], - properties - }; - vertexAngleChangeAgent.create(board, change); - break; - } - - } -} - export type GeometryObjectModelUnion = CommentModelType | ImageModelType | MovableLineModelType | PointModelType | PolygonModelType | VertexAngleModelType; @@ -376,9 +385,18 @@ export const GeometryBaseContentModel = TileContentModel board: types.maybe(BoardModel), bgImage: types.maybe(ImageModel), objects: types.map(types.union(CommentModel, MovableLineModel, PointModel, PolygonModel, VertexAngleModel)), + pointMetadata: types.map(PointMetadataModel), + // Maps attribute ID to color. + linkedAttributeColors: types.map(types.number), // Used for importing table links from legacy documents links: types.array(types.string) // table tile ids }) + .volatile(self => ({ + // This is the point that tracks the mouse pointer when you're in a shape-creation mode. + phantomPoint: undefined as PointModelType|undefined, + // In polygon mode, the phantom point is considered to be part of an in-progress polygon. + activePolygonId: undefined as string|undefined + })) .preProcessSnapshot(snapshot => { // fix null table links ¯\_(ツ)_/¯ if (snapshot.links?.some(link => link == null)) { @@ -394,9 +412,65 @@ export const GeometryBaseContentModel = TileContentModel const { links, ...rest } = snapshot; return { ...rest }; }) + .views(self => ({ + getColorSchemeForAttributeId(id: string) { + return self.linkedAttributeColors.get(id); + }, + /** + * Return the name and labelOption for a given point. + * If this is a regular point, these values are stored in the Point object. + * If it is a linked point, they are stored in pointMetadata, + * or default values are used if no record is found in either place. + * @param id + * @returns an object with "name" and "labelOption" properties + */ + getPointLabelProps(id: string) { + const object = self.objects.get(id); + if (isPointModel(object)) { + return { name: object.name, labelOption: object.labelOption }; + } + const metadata = self.pointMetadata.get(id); + if (metadata) { + return { name: metadata.name, labelOption: metadata.labelOption }; + } + return { name: "", labelOption: ELabelOption.kNone }; + } + })) .actions(self => ({ replaceLinks(newLinks: string[]) { self.links.replace(newLinks); + }, + assignColorSchemeForAttributeId(id: string) { + if (self.linkedAttributeColors.get(id)) { + return self.linkedAttributeColors.get(id); + } + const color = findLeastUsedNumber(clueDataColorInfo.length, self.linkedAttributeColors.values()); + self.linkedAttributeColors.set(id, color); + return color; + }, + /** + * Sets the name and labelOption properties in the correct place for the point. + * If this is a regular point, these values are stored in the Point object. + * If it is a linked point, they are stored in pointMetadata. A new metadata record + * will be created if necessary. + * @param id + * @param name + * @param labelOption + */ + setPointLabelProps(id: string, name: string, labelOption: ELabelOption) { + const object = self.objects.get(id); + if (isPointModel(object)) { + object.setName(name); + object.setLabelOption(labelOption); + return; + } + const metadata = self.pointMetadata.get(id); + if (metadata) { + metadata.setName(name); + metadata.setLabelOption(labelOption); + } else { + self.pointMetadata.put(PointMetadataModel.create({ id, name, labelOption })); + } } })); export interface GeometryBaseContentModelType extends Instance {} diff --git a/src/models/tiles/geometry/geometry-registration.ts b/src/models/tiles/geometry/geometry-registration.ts index edb91f0781..bd1e419ee9 100644 --- a/src/models/tiles/geometry/geometry-registration.ts +++ b/src/models/tiles/geometry/geometry-registration.ts @@ -4,6 +4,8 @@ import { GeometryContentModel, GeometryMetadataModel, defaultGeometryContent } f import { kGeometryTileType } from "./geometry-types"; import { kGeometryDefaultHeight } from "./jxg-types"; import GeometryToolComponent from "../../../components/tiles/geometry/geometry-tile"; +import { updateGeometryContentWithNewSharedModelIds, updateGeometryObjectWithNewSharedModelIds } + from "./geometry-utils"; import Icon from "../../../clue/assets/icons/geometry-tool.svg"; import HeaderIcon from "../../../assets/icons/sort-by-tools/shapes-graph-tile-id.svg"; @@ -20,13 +22,14 @@ registerTileContentInfo({ displayName: "Shapes Graph", modelClass: GeometryContentModel, metadataClass: GeometryMetadataModel, - addSidecarNotes: true, defaultHeight: kGeometryDefaultHeight, exportNonDefaultHeight: true, isDataConsumer: true, consumesMultipleDataSets: () => true, defaultContent: defaultGeometryContent, - tileSnapshotPreProcessor + tileSnapshotPreProcessor, + updateContentWithNewSharedModelIds: updateGeometryContentWithNewSharedModelIds, + updateObjectReferenceWithNewSharedModelIds: updateGeometryObjectWithNewSharedModelIds }); registerTileComponentInfo({ diff --git a/src/models/tiles/geometry/geometry-utils.ts b/src/models/tiles/geometry/geometry-utils.ts index 795f730c7c..be4ac7cacc 100644 --- a/src/models/tiles/geometry/geometry-utils.ts +++ b/src/models/tiles/geometry/geometry-utils.ts @@ -1,8 +1,97 @@ -import { getAssociatedPolygon } from "./jxg-polygon"; import { values } from "lodash"; +import { Instance, SnapshotOut } from "mobx-state-tree"; +import { getAssociatedPolygon } from "./jxg-polygon"; +import { isGeometryElement, isPoint, isPolygon } from "./jxg-types"; +import { JXGObjectType } from "./jxg-changes"; +import { logTileChangeEvent } from "../log/log-tile-change-event"; +import { LogEventName } from "../../../lib/logger-types"; +import { GeometryBaseContentModel } from "./geometry-model"; +import { getTileIdFromContent } from "../tile-model"; +import { isFiniteNumber } from "../../../utilities/math-utils"; +import { clueDataColorInfo } from "../../../utilities/color-utils"; +import { GeometryContentModel } from "./geometry-content"; +import { SharedModelEntrySnapshotType } from "../../document/shared-model-entry"; +import { replaceJsonStringsWithUpdatedIds, UpdatedSharedDataSetIds } from "../../shared/shared-data-set"; +import { IClueObjectSnapshot } from "../../annotations/clue-object"; +import { linkedPointId, splitLinkedPointId } from "../table-link-types"; export function copyCoords(coords: JXG.Coords) { - return new JXG.Coords(JXG.COORDS_BY_USER, coords.usrCoords.slice(1), coords.board); + const usrCoords = coords.usrCoords; + if (usrCoords.length >=3 ) { + const shortCoords: [number,number] = [usrCoords[1],usrCoords[2]]; + return new JXG.Coords(JXG.COORDS_BY_USER, shortCoords, coords.board); + } else { + // This should not happen, but return a default value to keep this method type-safe. + return new JXG.Coords(JXG.COORDS_BY_USER, [0, 0], coords.board); + } +} + +// Define some helper functions to work around the typing of board.objectsList as unknown[]. + +export function getBoardObjectIds(board: JXG.Board): string[] { + return Object.keys(board.objects); +} + +export function getBoardObject(board: JXG.Board|undefined, id: string): JXG.GeometryElement|undefined { + const obj = board && board.objects[id]; + return isGeometryElement(obj) ? obj : undefined; +} + +export function forEachBoardObject(board: JXG.Board, callback: (elt: JXG.GeometryElement, index: number) => void) { + board.objectsList.forEach((obj, index) => { + if (isGeometryElement(obj)) { callback(obj, index); } + }); +} + +export function findBoardObject(board: JXG.Board, callback: (elt: JXG.GeometryElement) => any): + JXG.GeometryElement | undefined { + const found = board.objectsList.find(obj => { return isGeometryElement(obj) && callback(obj); } ); + return isGeometryElement(found) ? found : undefined; +} + +export function filterBoardObjects(board: JXG.Board, + callback: (elt: JXG.GeometryElement) => any): JXG.GeometryElement[] { + return board.objectsList.filter((obj) => isGeometryElement(obj) && callback(obj)) as JXG.GeometryElement[]; +} + +export function getPoint(board: JXG.Board, id: string): JXG.Point|undefined { + const obj = board.objects[id]; + return isPoint(obj) ? obj : undefined; +} + +export function getPolygon(board: JXG.Board, id: string): JXG.Polygon|undefined { + const obj = board.objects[id]; + return isPolygon(obj) ? obj : undefined; +} + +/** + * Remove the final item of the array if it is equal to the first item. JSXGraph + * polygons' list of vertices includes the first vertex again at the end of the + * list to show that it is closed. This method removes it for convenience in + * manipulating the list. + * @param ids + * @returns the array, which has been modified in-place. + */ +export function removeClosingVertexId(ids: string[]) { + if (ids.length >= 2 && ids[0] === ids[ids.length-1]) { + ids.pop(); + } + return ids; +} + +/** + * Adds a vertex ID to the list of existing IDs. + * JSX Graph will append the first ID to the end of its list of vertices to close the shape. + * So, this method removes the last ID before appending if it is the same as the first one. + * @param existingIds + * @param newId + * @returns the extended list + */ +export function appendVertexId(existingIds: string[], newId: string): string[] { + const result: string[] = [...existingIds]; + removeClosingVertexId(result); + result.push(newId); + return result; } // cf. https://jsxgraph.uni-bayreuth.de/wiki/index.php/Browser_event_and_coordinates @@ -32,36 +121,36 @@ function isPreferredClickableObject(current: JXG.GeometryElement | undefined, pr const currentPolygon = getAssociatedPolygon(current); const proposedPolygon = getAssociatedPolygon(proposed); if (currentPolygon !== proposedPolygon) return true; - return (proposed.visProp.layer >= current.visProp.layer); + if (!isFiniteNumber(current.visProp.layer)) return true; + return (isFiniteNumber(proposed.visProp.layer) && proposed.visProp.layer >= current.visProp.layer); } // Note: Our layering logic is different from JSXGraph's. When clicks occur on overlapping objects, // we may select one object, but JSXGraph may drag another. For now this is preferable to adopting // the JSXGraph layering model in which all points are above all segments which are above all // polygons. Fixing the drag behavior would require internal changes to JSXGraph. -export function getClickableObjectUnderMouse(board: JXG.Board, evt: any, draggable: boolean, scale?: number) { +export function getClickableObjectUnderMouse(board: JXG.Board, evt: any, draggable: boolean, scale?: number): + JXG.GeometryElement|undefined { const coords = getEventCoords(board, evt, scale); const [ , x, y] = coords.scrCoords; - const count = board.objectsList.length; - let dragEl; - for (let i = 0; i < count; ++i) { - const pEl = board.objectsList[i]; + let dragEl: JXG.GeometryElement|undefined = undefined; + forEachBoardObject(board, pEl => { const hasPoint = pEl && pEl.hasPoint && pEl.hasPoint(x, y); - const isFixed = pEl && pEl.getAttribute("fixed"); // !Type.evaluate(pEl.visProp.fixed) + const isFixed = pEl && !!pEl.getAttribute("fixed"); // !Type.evaluate(pEl.visProp.fixed) const isDraggable = pEl.isDraggable && !isFixed; - if (hasPoint && pEl.visPropCalc.visible && (!draggable || isDraggable)) { + if (hasPoint && !!pEl.visPropCalc.visible && (!draggable || isDraggable)) { if (isPreferredClickableObject(dragEl, pEl)) { dragEl = pEl; } } - } + }); return dragEl; } // Replacement for Board.getAllObjectsUnderMouse() which doesn't handle scaled coordinates export function getAllObjectsUnderMouse(board: JXG.Board, evt: any, scale?: number) { const coords = getEventCoords(board, evt, scale); - return board.objectsList.filter(obj => { + return filterBoardObjects(board, obj => { return obj.visPropCalc.visible && obj.hasPoint && obj.hasPoint(coords.scrCoords[1], coords.scrCoords[2]); }); @@ -81,3 +170,67 @@ export function rotateCoords(coords: JXG.Coords, center: JXG.Coords, angle: numb y += center.usrCoords[2]; return new JXG.Coords(JXG.COORDS_BY_USER, [x, y], coords.board); } + +export function logGeometryEvent(model: Instance, + operation: string, target: JXGObjectType, targetId?: string|string[], + more?: { text?: string, labelOption?: string, filename?: string, userAction?: string }) { + const tileId = getTileIdFromContent(model) || ""; + const change = { + target, + targetId, + ...more + }; + logTileChangeEvent(LogEventName.GEOMETRY_TOOL_CHANGE, { + tileId, + operation, + change + }); +} + +export function fillPropsForColorScheme(colorScheme: number) { + const spec = clueDataColorInfo[colorScheme % clueDataColorInfo.length]; + return { + fillColor: spec.color, + highlightFillColor: spec.color + }; +} + +export function strokePropsForColorScheme(colorScheme: number) { + const spec = clueDataColorInfo[(colorScheme||0) % clueDataColorInfo.length]; + return { + strokeColor: spec.color, + highlightStrokeColor: spec.color + }; +} + +// The geometry model uses IDs of the Attributes and Cases in the shared dataset +// when listing the vertices of polygons formed with these points. These need +// to be updated to the new values when a tile is copied. +export function updateGeometryContentWithNewSharedModelIds( + content: SnapshotOut, + sharedDataSetEntries: SharedModelEntrySnapshotType[], + updatedSharedModelMap: Record +) { + return replaceJsonStringsWithUpdatedIds(content, '[":]', ...Object.values(updatedSharedModelMap)); +} + +// Update an annotated object with new IDs after copy. +// Geometry object types are: point, linkedPoint, segment, polygon +// Of these, only linkedPoint needs to be modified +export function updateGeometryObjectWithNewSharedModelIds( + object: IClueObjectSnapshot, + sharedDataSetEntries: SharedModelEntrySnapshotType[], + updatedSharedModelMap: Record) { + if (object.objectType === "linkedPoint") { + const [caseId, attrId] = splitLinkedPointId(object.objectId); + // The ID values don't distinguish which shared model they came from, so we loop through the options. + for (const updates of Object.values(updatedSharedModelMap)) { + if (caseId in updates.caseIdMap && attrId in updates.attributeIdMap) { + object.objectId = linkedPointId(updates.caseIdMap[caseId], updates.attributeIdMap[attrId]); + return object; + } + } + console.warn("Could not find new IDs for object:", object); + } + return object; +} diff --git a/src/models/tiles/geometry/jsxgraph.d.ts b/src/models/tiles/geometry/jsxgraph.d.ts index 0bd37cc75b..e4a316eef6 100644 --- a/src/models/tiles/geometry/jsxgraph.d.ts +++ b/src/models/tiles/geometry/jsxgraph.d.ts @@ -1,193 +1,76 @@ -// eslint-disable-next-line @typescript-eslint/no-unused-vars +// These are additional declarations that the JSXGraph package does not supply. +// It should be reviewed when updating JSXGraph. +// As of now we are making use of some items that are not part of the public JSXGraph API, +// as noted below. declare namespace JXG { - const COORDS_BY_SCREEN: number; - const COORDS_BY_USER: number; + const touchProperty: string; // Documented as private - const touchProperty: string; - - const boards: { [id: string]: Board }; - - interface Angle extends Sector { - anglepoint: GeometryElement; - point: GeometryElement; - point1: GeometryElement; - point2: GeometryElement; - point3: GeometryElement; - pointsquare: GeometryElement; - radiuspoint: GeometryElement; - dot?: GeometryElement; - - Value: () => number; + interface Angle { + point1, point2, point3, radiuspoint, anglepoint: Point; } type BoundingBox = [number, number, number, number]; - class Board { - id: string; - attr: { - // [x1,y1,x2,y2] upper left corner, lower right corner - boundingbox: BoundingBox - }; - axis: boolean; - canvasWidth: number; - canvasHeight: number; - container: string; - containerObj: HTMLElement; - cssTransMat: number[][]; - isSuspendedUpdate: boolean; - suspendCount: number | undefined; // CC addition - keepaspectratio: boolean; - origin: { - usrCoords: [number, number, number], - scrCoords: [number, number, number] - }; - showCopyright: boolean; - showNavigation: boolean; - showZoom: boolean; - unitX: number; - unitY: number; - zoomFactor: number; - zoomX: number; - zoomY: number; - options: any; - - objects: { [id: string]: GeometryElement }; + interface Board { + id: string; // Not documented for Board + suspendCount: number; // CLUE added; not part of JSXGraph objectsList: GeometryElement[]; - - create: (elementType: string, parents?: any, attributes?: any) => any; - generateName: (object: GeometryElement) => string; - hasPoint: (x: number, y: number) => boolean; - removeObject: (object: GeometryElement) => JXG.Board; - on: (event: string, handler: (evt: any) => void) => void; - getCoordsTopLeftCorner: () => number[]; - // use geometry-utils.getAllObjectsUnderMouse() instead - // getAllObjectsUnderMouse: (evt: any) => GeometryElement[]; - - resizeContainer: (canvasWidth: number, canvasHeight: number, - dontSet?: boolean, dontSetBoundingBox?: boolean) => JXG.Board; - getBoundingBox: () => BoundingBox; - setBoundingBox: (boundingBox: BoundingBox, keepaspectratio?: boolean) => JXG.Board; - showInfobox: (value: boolean) => JXG.Board; - updateInfobox: (el: JXG.GeometryElement) => JXG.Board; - update: (drag?: JXG.GeometryElement) => JXG.Board; - fullUpdate: () => JXG.Board; - suspendUpdate: () => JXG.Board; - unsuspendUpdate: () => JXG.Board; - addGrid: () => void; - removeGrids: () => void; + // setAttribute is documented to accept any of these, but we only use the first. + // object: {key1:value1,key2:value2,...} + // string: 'key:value' + // array: ['key', value] + setAttribute: (attrs: {[id:string]: string|number|boolean}) => void; } - class Coords { - board: JXG.Board; - usrCoords: number[]; - scrCoords: number[]; - emitter: boolean; - - constructor(method: number, coordinates: number[], board: JXG.Board, emitter?: boolean); - normalizeUsrCoords: () => void; - usr2screen: (doRound: boolean) => void; - screen2usr: () => void; - } - - class CoordsElement extends GeometryElement { - coords: JXG.Coords; + interface BoardAttributes { + infobox: TextAttributes, + keyboard: { enabled: boolean } } - class Curve extends GeometryElement { - updateDataArray: () => void; - } - - type EventHandler = ((evt: any) => void) | ((obj: any, elt: JXG.GeometryElement) => void); - - class GeometryElement { - board: JXG.Board; - id: string; - elType: string; - type: number; - name: string | (() => string); - hasLabel: boolean; - label?: JXG.Text; + interface GeometryElement { + _set: (key: string, value: string | null) => void; // Documented as private ancestors: { [id: string]: GeometryElement }; - descendants: { [id: string]: GeometryElement }; - parents: string[]; + bounds: () => [number, number, number, number]; childElements: { [id: string]: GeometryElement }; - isDraggable: boolean; - lastDragTime: Date; - stdform: [number, number, number, number, number, number, number, number]; - transformations: any[]; - visProp: { [prop: string]: any }; - visPropCalc: { [prop: string]: any }; - fixed: boolean; - - removeChild: (child: GeometryElement) => JXG.Board; + descendants: { [id: string]: GeometryElement }; hasPoint: (x: number, y: number) => boolean; - // [x1,y1,x2,y2] upper left corner, lower right corner - bounds: () => [number, number, number, number]; - getAttribute: (key: string) => any; - setAttribute: (attrs: any) => void; - setPosition: (method: number, coords: number[]) => JXG.Point; - getLabelAnchor: () => JXG.Coords; - on: (event: string, handler: EventHandler) => void; - _set: (key: string, value: string | null) => void; + isDraggable: boolean; } - const JSXGraph: { - initBoard: (box: string, attributes: any) => JXG.Board; - freeBoard: (board: JXG.Board | string) => void; - }; + interface GridOptions { + majorStep: number; + } - class Image extends CoordsElement { - size: [number, number]; + interface Image { url: string; - setSize: (width: number, height: number) => void; } - class Line extends GeometryElement { - point1: JXG.Point; - point2: JXG.Point; - parentPolygon?: JXG.Polygon; - getRise: () => number; - getSlope: () => number; - L: () => number; + interface Line { + getRise: () => number; // Not documented + getSlope: () => number; // Not documented + parentPolygon?: JXG.Polygon; // Documented as private. } - class Text extends CoordsElement { - plaintext: string; - size: [number, number]; // [width, height] - setText: (content: string) => void; + interface Statistics { + add: (arr1: number | number[], arr2: number | number[]) => number | number[]; + subtract: (arr1: number | number[], arr2: number | number[]) => number | number[]; } - const Math: { - Geometry: { - rad: (p1: JXG.Point, p2: JXG.Point, p3: JXG.Point) => number - }, - Statistics: { - add: (arr1: number | number[], arr2: number | number[]) => number | number[]; - subtract: (arr1: number | number[], arr2: number | number[]) => number | number[]; - } - }; - - class Point extends CoordsElement { + interface Math { + Statistics: Statistics; } - class Polygon extends GeometryElement { - vertices: JXG.Point[]; - borders: JXG.Line[]; - - findPoint: (point: JXG.Point) => number; - removePoints: (...points: JXG.Point[]) => void; + interface Polygon { + borders: Line[]; } - class Sector extends Curve { + interface Text { + plaintext: string; // Not documented } - const _ceil10: (value: number, exp: number) => number; - const _floor10: (value: number, exp: number) => number; - const _round10: (value: number, exp: number) => number; - const toFixed: (num: number, precision: number) => string; - const isObject: (v: any) => boolean; - const isPoint: (v: any) => boolean; - const getPosition: (evt: any, index?: number, doc?: any) => number[]; + interface ZoomOptions { + enabled: boolean; + } } diff --git a/src/models/tiles/geometry/jxg-board.ts b/src/models/tiles/geometry/jxg-board.ts index 5aba997784..22c5f8eb3e 100644 --- a/src/models/tiles/geometry/jxg-board.ts +++ b/src/models/tiles/geometry/jxg-board.ts @@ -1,11 +1,12 @@ -import { assign, each, find } from "lodash"; -import "./jxg"; -import { ITableLinkProperties, JXGChange, JXGChangeAgent, JXGProperties } from "./jxg-changes"; +import { assign } from "lodash"; +import JXG, { BoardAttributes, GeometryElement } from "jsxgraph"; +import { JXGChange, JXGChangeAgent, JXGProperties } from "./jxg-changes"; import { - isAxis, isBoard, isLinkedPoint, isPoint, kGeometryDefaultXAxisMin, kGeometryDefaultYAxisMin, + isAxis, isBoard, isPoint, kGeometryDefaultXAxisMin, kGeometryDefaultYAxisMin, kGeometryDefaultHeight, kGeometryDefaultPixelsPerUnit, kGeometryDefaultWidth, toObj } from "./jxg-types"; import { goodTickValue } from "../../../utilities/graph-utils"; +import { filterBoardObjects, findBoardObject, forEachBoardObject, getBoardObject } from "./geometry-utils"; const kScalerClasses = ["canvas-scaler", "scaled-list-item"]; @@ -29,13 +30,13 @@ export function resumeBoardUpdates(board: JXG.Board) { } export function getObjectById(board: JXG.Board, id: string): JXG.GeometryElement | undefined { - let obj: JXG.GeometryElement | undefined = board.objects[id]; + let obj = getBoardObject(board, id); if (!obj && id?.includes(":")) { // legacy support for early tiles in which points were identified by caseId, // before we added support for multiple columns, i.e. multiple points per row/case // newer code uses `${caseId}:${attrId}` for the id of points const caseId = id.split(":")[0]; - obj = board.objects[caseId]; + obj = getBoardObject(board, caseId); } return obj; } @@ -45,31 +46,7 @@ export function getPointsByCaseId(board: JXG.Board, caseId: string) { const obj = getObjectById(board, caseId); return obj ? [obj] : []; } - return board.objectsList.filter(obj => isPoint(obj) && (obj.id.split(":")[0] === caseId)); -} - -export function syncLinkedPoints(board: JXG.Board, links: ITableLinkProperties) { - if (board && links?.labels) { - // build map of points associated with each case - const ptsForCaseMap: Record = {}; - each(board.objects, (obj, id) => { - if (isLinkedPoint(obj)) { - const caseId = obj.getAttribute("linkedRowId"); - if (caseId) { - if (!ptsForCaseMap[caseId]) ptsForCaseMap[caseId] = [obj]; - else ptsForCaseMap[caseId].push(obj); - } - } - }); - // assign case label to each point associated with a given case - links.labels.forEach(item => { - const { id, label } = item; - const ptsForCase = ptsForCaseMap[id]; - if (ptsForCase) { - ptsForCase.forEach(pt => pt?.setAttribute({ name: label })); - } - }); - } + return filterBoardObjects(board, obj => isPoint(obj) && (obj.id.split(":")[0] === caseId)); } // Buffer space in pixels around the plot for labels, etc. @@ -86,7 +63,7 @@ export const getAxisType = (v: any) => { if (stdFormY) return "y"; }; export function getAxis(board: JXG.Board, type: "x" | "y") { - return find(board.objectsList, obj => isAxis(obj) && (getAxisType(obj) === type)); + return findBoardObject(board, obj => isAxis(obj) && (getAxisType(obj) === type)); } function getClientAxisLabels(board: JXG.Board) { @@ -140,7 +117,7 @@ export function getTickValues(pixPerUnit: number) { export const kReverse = true; export function sortByCreation(board: JXG.Board, ids: string[], reverse = false) { const indices: { [id: string]: number } = {}; - board.objectsList.forEach((obj, index) => { + forEachBoardObject(board, (obj, index) => { indices[obj.id] = index; }); ids.sort(reverse @@ -218,17 +195,29 @@ function getAxisUnitsFromProps(props?: JXGProperties, scale = 1) { } function createBoard(domElementId: string, properties?: JXGProperties) { - const defaults = { - keepaspectratio: true, - showCopyright: false, - showNavigation: false, - minimizeReflow: "none" - }; - const [unitX, unitY] = getAxisUnitsFromProps(properties); // cf. https://www.intmath.com/cg3/jsxgraph-axes-ticks-grids.php - const overrides = { axis: false, keepaspectratio: unitX === unitY }; + const defaults: Partial = { + axis: false, + keepaspectratio: true, + showCopyright: false, + showNavigation: false, + minimizeReflow: "none", + infobox: { color: "#3f3f3f", opacity: 0.75 }, + // Disabled for now - could be refactored so that these native abilities of + // JSXGraph are available to the user. Changes made via the native zoom, + // pan, or keyboard controls are not persisted to the model and so would be + // more frustrating than helpful. + // For accessibility, it would be very nice to have these work. + zoom: { enabled: false }, + pan: { enabled: false }, + keyboard: { enabled: false }, + renderer: "svg" + }; + const overrides = {}; const props = combineProperties(domElementId, defaults, properties, overrides); const board = JXG.JSXGraph.initBoard(domElementId, props); + // I would prefer to have the font specified in CSS, but if this setting is left blank some defaults get inserted. + JXG.Options.text.cssDefaultStyle = "font-family: 'Lato', 'Noto Sans Symbols 2', 'Noto Sans Math', sans-serif"; return board; } @@ -244,10 +233,13 @@ interface IAddAxesParams { function addAxes(board: JXG.Board, params: IAddAxesParams) { const { xName, yName, xAnnotation, yAnnotation, unitX, unitY, boundingBox } = params; - const [xMajorTickDistance, xMinorTicks, xMinorTickDistance] = getTickValues(unitX); - const [yMajorTickDistance, yMinorTicks, yMinorTickDistance] = getTickValues(unitY); + const [ xMajorTickDistance, xMinorTicks, xMinorTickDistance ] = getTickValues(unitX); + const [ yMajorTickDistance, yMinorTicks ] = getTickValues(unitY); + + // This grid is pale grey lines for the minor (unlabeled) ticks. + // The major ticks produce their darker grid by having the axis majorHeight set to -1 board.removeGrids(); - board.options.grid = { ...board.options.grid, gridX: xMinorTickDistance, gridY: yMinorTickDistance }; + board.options.grid = { ...board.options.grid, majorStep: xMinorTickDistance }; board.addGrid(); if (boundingBox && boundingBox.every((val: number) => isFinite(val))) { board.setBoundingBox(boundingBox); @@ -256,15 +248,17 @@ function addAxes(board: JXG.Board, params: IAddAxesParams) { name: xName || "x", withLabel: true, label: {fontSize: 13, anchorX: "right", position: "rt", offset: [0, 15]}, + ticks: { visible: false }, ...toObj("clientName", xName), ...toObj("clientAnnotation", xAnnotation) }); - xAxis.removeAllTicks(); - board.create("ticks", [xAxis, xMajorTickDistance], { + board.create("ticks", [xAxis], { strokeColor: "#bbb", majorHeight: -1, + insertTicks: false, + ticksDistance: xMajorTickDistance, drawLabels: true, - label: { anchorX: "middle", offset: [-8, -10] }, + label: { anchorX: "middle", offset: [0, -10], cssClass: "tick-label" }, minorTicks: xMinorTicks, drawZero: true }); @@ -272,15 +266,17 @@ function addAxes(board: JXG.Board, params: IAddAxesParams) { name: yName || "y", withLabel: true, label: {fontSize: 13, position: "rt", offset: [15, 0]}, + ticks: { visible: false }, ...toObj("clientName", yName), ...toObj("clientAnnotation", yAnnotation) }); - yAxis.removeAllTicks(); - board.create("ticks", [yAxis, yMajorTickDistance], { + board.create("ticks", [yAxis], { strokeColor: "#bbb", majorHeight: -1, + insertTicks: false, + ticksDistance: yMajorTickDistance, drawLabels: true, - label: { anchorX: "right", offset: [-4, -1] }, + label: { anchorX: "right", offset: [-4, 0], cssClass: "tick-label" }, minorTicks: yMinorTicks, drawZero: false }); @@ -342,7 +338,7 @@ export const boardChangeAgent: JXGChangeAgent = { const bbox: JXG.BoundingBox = [xMin, yMin + yRange, xMin + xRange, yMin]; suspendBoardUpdates(board); // remove old axes before resetting bounding box - board.objectsList.forEach(el => { + forEachBoardObject(board, (el: GeometryElement) => { if (el.elType === "axis") { board.removeObject(el); } diff --git a/src/models/tiles/geometry/jxg-changes.ts b/src/models/tiles/geometry/jxg-changes.ts index 041a9aee66..612d9e3249 100644 --- a/src/models/tiles/geometry/jxg-changes.ts +++ b/src/models/tiles/geometry/jxg-changes.ts @@ -4,7 +4,7 @@ export { type ILinkProperties, type ITableLinkProperties }; export type JXGOperation = "create" | "update" | "delete"; export type JXGObjectType = "board" | "comment" | "image" | "linkedPoint" | "metadata" | "movableLine" | - "object" | "point" | "polygon" | "tableLink" | "vertexAngle"; + "object" | "point" | "polygon" | "segment" | "tableLink" | "vertex" | "vertexAngle"; export type JXGCoordPair = [number, number]; export type JXGNormalizedCoordPair = [1, number, number]; @@ -16,7 +16,7 @@ export type JXGImageParents = [string, JXGCoordPair, JXGCoordPair]; export type JXGParentType = string | number | undefined | JXGCoordPair | JXGUnsafeCoordPair; -export enum ESegmentLabelOption { +export enum ELabelOption { kNone = "none", kLabel = "label", // parents kLength = "length" @@ -38,7 +38,7 @@ export interface IBoardScale { export interface JXGProperties { id?: string; ids?: string[]; // ids of linked points in tableLink change - labelOption?: ESegmentLabelOption; + labelOption?: ELabelOption; position?: JXGPositionProperty; title?: string; // metadata property url?: string; @@ -59,6 +59,7 @@ export interface JXGChange { links?: ILinkProperties; startBatch?: boolean; endBatch?: boolean; + userAction?: string; } export interface JXGNormalizedChange { diff --git a/src/models/tiles/geometry/jxg-comment.ts b/src/models/tiles/geometry/jxg-comment.ts index 17eabfd2d2..31f5631b4d 100644 --- a/src/models/tiles/geometry/jxg-comment.ts +++ b/src/models/tiles/geometry/jxg-comment.ts @@ -87,17 +87,18 @@ export const commentChangeAgent: JXGChangeAgent = { "point", [1, 0], // places the end point of the comment line one unit to the right of the left edge of the comment { ...pointProps, anchor: comment.id, id: `${id}-commentPoint` } - ); + ) as JXG.Point; const anchorPoint = _board.create( "point", // pass functions so that centroid is computed dynamically as anchor changes [centroidCoordinateGetter(0), centroidCoordinateGetter(1)], { ...pointProps, id: `${id}-anchorPoint` } - ); + ) as JXG.Point; const line = _board.create( "line", [anchorPoint, commentPoint], - { ...lineProps, id: `${id}-labelLine`}); + { ...lineProps, id: `${id}-labelLine`} + ) as JXG.Line; return [comment, commentPoint, anchorPoint, line]; } }, diff --git a/src/models/tiles/geometry/jxg-dispatcher.ts b/src/models/tiles/geometry/jxg-dispatcher.ts index 2fc7135682..714b5d9c7d 100644 --- a/src/models/tiles/geometry/jxg-dispatcher.ts +++ b/src/models/tiles/geometry/jxg-dispatcher.ts @@ -27,8 +27,8 @@ const agents: JXGChangeAgents = { comment: commentChangeAgent, image: imageChangeAgent, linkedpoint: linkedPointChangeAgent, - object: objectChangeAgent, movableline: movableLineChangeAgent, + object: objectChangeAgent, point: pointChangeAgent, polygon: polygonChangeAgent, tablelink: tableLinkChangeAgent, diff --git a/src/models/tiles/geometry/jxg-image.ts b/src/models/tiles/geometry/jxg-image.ts index 33fea75700..570c5b34ce 100644 --- a/src/models/tiles/geometry/jxg-image.ts +++ b/src/models/tiles/geometry/jxg-image.ts @@ -15,7 +15,7 @@ export const imageChangeAgent: JXGChangeAgent = { if (displayUrl) parents[0] = displayUrl; const props = { id: uniqueId(), fixed: true, ...change.properties }; return parents && parents.length >= 3 - ? _board.create("image", parents, props) + ? _board.create("image", parents, props) as JXG.Image : undefined; }, diff --git a/src/models/tiles/geometry/jxg-movable-line.ts b/src/models/tiles/geometry/jxg-movable-line.ts index fc56cab3b2..b615bc49a3 100644 --- a/src/models/tiles/geometry/jxg-movable-line.ts +++ b/src/models/tiles/geometry/jxg-movable-line.ts @@ -2,7 +2,6 @@ import { castArray, find, uniqWith } from "lodash"; import { getBaseAxisLabels, getObjectById } from "./jxg-board"; import { JXGChangeAgent } from "./jxg-changes"; import { objectChangeAgent } from "./jxg-object"; -import { syncClientColors } from "./jxg-point"; import { getMovableLinePointIds, isBoard, isMovableLine, isMovableLineControlPoint, isMovableLineLabel, kMovableLineType } from "./jxg-types"; @@ -94,12 +93,16 @@ export const movableLineChangeAgent: JXGChangeAgent = { create: (board, change, context) => { const { id, pt1, pt2, line, ...shared }: any = change.properties || {}; const lineId = id || uniqueId(); - const props = syncClientColors({...sharedProps, ...shared }); + const props = {...sharedProps, ...shared }; const lineProps = {...props, ...lineSpecificProps, ...line }; const pointProps = {...props, ...pointSpecificProps}; const pointIds = getMovableLinePointIds(lineId); - if (change.parents && change.parents.length === 2) { + if (change.parents + && Array.isArray(change.parents) + && change.parents.length === 2 + && Array.isArray(change.parents[0]) + && Array.isArray(change.parents[1])) { const interceptPoint = (board as JXG.Board).create( "point", change.parents[0], @@ -148,8 +151,8 @@ export const movableLineChangeAgent: JXGChangeAgent = { }, ...line, ...overrides - }); - const label = movableLine && movableLine.label; + }) as JXG.Line; + const label = movableLine.label!; return [movableLine, interceptPoint, slopePoint, label]; } diff --git a/src/models/tiles/geometry/jxg-object.ts b/src/models/tiles/geometry/jxg-object.ts index 48ed20429a..b527ab4c30 100644 --- a/src/models/tiles/geometry/jxg-object.ts +++ b/src/models/tiles/geometry/jxg-object.ts @@ -1,10 +1,10 @@ -import { sortByCreation, kReverse, getObjectById, syncLinkedPoints } from "./jxg-board"; -import { - ITableLinkProperties, JXGChangeAgent, JXGCoordPair, JXGPositionProperty, JXGProperties +import { sortByCreation, kReverse, getObjectById } from "./jxg-board"; +import { JXGChangeAgent, JXGCoordPair, JXGPositionProperty, JXGProperties } from "./jxg-changes"; -import { isLinkedPoint, isText } from "./jxg-types"; +import { isText } from "./jxg-types"; import { castArrayCopy } from "../../../utilities/js-utils"; import { castArray, size } from "lodash"; +import { GeometryElementAttributes } from "jsxgraph"; // Inexplicably, we occasionally encounter JSXGraph objects with null // transformations which cause JSXGraph to crash. Until we figure out @@ -36,10 +36,6 @@ export function getGraphablePosition(position: JXGPositionProperty) { }) as JXGCoordPair; } -export function getElementName(elt: JXG.GeometryElement) { - return (typeof elt.name === "function") ? elt.name() : elt.name; -} - export const objectChangeAgent: JXGChangeAgent = { create: (board, change) => { // can't create generic objects @@ -51,10 +47,8 @@ export const objectChangeAgent: JXGChangeAgent = { const ids = castArray(change.targetID); const props: JXGProperties[] = castArray(change.properties); let hasSuspendedTextUpdates = false; - let hasLinkedPoints = false; ids.forEach((id, index) => { const obj = getObjectById(board, id); - if (isLinkedPoint(obj)) hasLinkedPoints = true; const textObj = isText(obj) ? obj : undefined; const objProps = index < props.length ? props[index] : props[0]; if (obj && objProps) { @@ -64,7 +58,7 @@ export const objectChangeAgent: JXGChangeAgent = { // suspended, and a text object (e.g. a comment or its anchor) has moved, the // transform will be calculated from a stale position. We unsuspend updates to // force a refresh on coordinate positions. - if (textObj && board.isSuspendedUpdate) { + if (textObj && board.isSuspendedRedraw) { hasSuspendedTextUpdates = true; board.unsuspendUpdate(); } @@ -83,11 +77,10 @@ export const objectChangeAgent: JXGChangeAgent = { textObj.setText(text); } if (size(others)) { - obj.setAttribute(others); + obj.setAttribute(others as GeometryElementAttributes); } } }); - if (hasLinkedPoints) syncLinkedPoints(board, change.links as ITableLinkProperties); if (hasSuspendedTextUpdates) board.suspendUpdate(); board.update(); return undefined; diff --git a/src/models/tiles/geometry/jxg-point.ts b/src/models/tiles/geometry/jxg-point.ts index 6aa97ffea4..71bd15bd4f 100644 --- a/src/models/tiles/geometry/jxg-point.ts +++ b/src/models/tiles/geometry/jxg-point.ts @@ -1,67 +1,102 @@ import { castArray } from "lodash"; -import { getColorMapEntry } from "../../shared/shared-data-set-colors"; +import { PointAttributes } from "jsxgraph"; import { uniqueId } from "../../../utilities/js-utils"; -import { JXGChangeAgent, JXGCoordPair, JXGUnsafeCoordPair } from "./jxg-changes"; +import { ELabelOption, JXGChangeAgent, JXGCoordPair, JXGUnsafeCoordPair } from "./jxg-changes"; import { objectChangeAgent, isPositionGraphable, getGraphablePosition } from "./jxg-object"; import { prepareToDeleteObjects } from "./jxg-polygon"; +import { fillPropsForColorScheme } from "./geometry-utils"; +import { kGeometryHighlightColor } from "./jxg-types"; -// For snap to grid -const kPrevSnapUnit = 0.2; +// Set as snap unit for all points that have snapToGrid set. +// Also used as the distance moved by arrow-key presses. export const kSnapUnit = 0.1; -export const kPointDefaults = { - fillColor: "#CCCCCC", - strokeColor: "#888888", - selectedFillColor: "#FF0000", - selectedStrokeColor: "#FF0000" - }; +const defaultPointProperties = Object.freeze({ + strokeColor: "#000000", highlightStrokeColor: kGeometryHighlightColor, + strokeWidth: 1, highlightStrokeWidth: 10, + strokeOpacity: 1, highlightStrokeOpacity: .12, + fillOpacity: 1, highlightFillOpacity: 1, + size: 4, + snapSizeX: kSnapUnit, + snapSizeY: kSnapUnit, + transitionDuration: 0 +}); -const defaultProps = { - fillColor: kPointDefaults.fillColor, - strokeColor: kPointDefaults.strokeColor - }; +const selectedPointProperties = Object.freeze({ + strokeColor: kGeometryHighlightColor, highlightStrokeColor: kGeometryHighlightColor, + strokeWidth: 10, highlightStrokeWidth: 10, + strokeOpacity: .25, highlightStrokeOpacity: .25 +}); -// fillColor/strokeColor are ephemeral properties that change with selection; -// we store the desired colors in clientFillColor/clientStrokeColor for persistence -// colors for linked points are derived from the link color map -export function syncClientColors(props: any) { - const { selectedFillColor, selectedStrokeColor, ...p } = props || {} as any; - const colorMapEntry = getColorMapEntry(p.linkedTableId); +const phantomPointProperties = Object.freeze({ + fillOpacity: .5, highlightFillOpacity: .5, + withLabel: false +}); - if (colorMapEntry?.colorSet) { - const { fill, stroke, selectedFill, selectedStroke } = colorMapEntry.colorSet; - p.fillColor = p.clientFillColor = fill; - p.strokeColor = p.clientStrokeColor = stroke; - p.clientSelectedFillColor = selectedFill; - p.clientSelectedStrokeColor = selectedStroke; - } - else { - if (p.fillColor) p.clientFillColor = p.fillColor; - if (p.strokeColor) p.clientStrokeColor = p.strokeColor; - if (selectedFillColor) p.clientSelectedFillColor = selectedFillColor; - if (selectedStrokeColor) p.clientSelectedStrokeColor = selectedStrokeColor; - } - return p; +export function getPointVisualProps(selected: boolean, colorScheme: number, phantom: boolean, + labelOption?: ELabelOption) { + const withLabel = labelOption && [ELabelOption.kLabel, ELabelOption.kLength].includes(labelOption); + const props: PointAttributes = { + ...defaultPointProperties, + ...fillPropsForColorScheme(colorScheme), + ...(selected ? selectedPointProperties : {}), + ...(phantom ? phantomPointProperties : {}), + withLabel + }; + + return props; } export function createPoint(board: JXG.Board, parents: JXGUnsafeCoordPair, changeProps: any) { // If id is not provided we generate one, but this will prevent // model-level synchronization. This should only occur for very // old geometry tiles created before the introduction of the uuid. - const props = { id: uniqueId(), ...defaultProps, ...syncClientColors(changeProps) }; - - // default snap size has changed over time - if (props.snapSizeX === kPrevSnapUnit) { - props.snapSizeX = kSnapUnit; - } - if (props.snapSizeY === kPrevSnapUnit) { - props.snapSizeY = kSnapUnit; - } + const props = { + id: uniqueId(), + ...getPointVisualProps(false, changeProps?.colorScheme||0, changeProps?.isPhantom||false, + changeProps?.clientLabelOption), + ...changeProps }; const isGraphable = isPositionGraphable(parents); const point = board.create("point", getGraphablePosition(parents), {...props, visible: isGraphable}); + point._set("clientName", point.name); // Hold onto original name for later use + setPropertiesForLabelOption(point); return point; } +export function pointName(point: JXG.Point) { + const origName = point.getAttribute("clientName"); + if (origName) return origName; + if (typeof(point.name) === "string") { + return point.name; + } + return ""; +} + +export function setPropertiesForLabelOption(point: JXG.Point) { + const labelOption = point.getAttribute("clientLabelOption") || ELabelOption.kNone; + switch (labelOption) { + case ELabelOption.kLength: + point.setAttribute({ + showInfobox: false, + withLabel: true, + name() { return `(${point.X().toFixed(2)}, ${point.Y().toFixed(2)})`; } + }); + break; + case ELabelOption.kLabel: + point.setAttribute({ + showInfobox: true, + withLabel: true, + name: point.getAttribute("clientName") + }); + break; + default: + point.setAttribute({ + showInfobox: true, + withLabel: false + }); + } +} + export const pointChangeAgent: JXGChangeAgent = { create: (board, change) => { const parents: any = change.parents; @@ -82,7 +117,9 @@ export const pointChangeAgent: JXGChangeAgent = { update: objectChangeAgent.update, delete: (board, change) => { - prepareToDeleteObjects(board, castArray(change.targetID)); - objectChangeAgent.delete(board, change); + // Removes the point from any polygons + const idsToDelete = prepareToDeleteObjects(board, castArray(change.targetID)); + const revisedChange = { ...change, targetID: idsToDelete }; + objectChangeAgent.delete(board, revisedChange); } }; diff --git a/src/models/tiles/geometry/jxg-polygon.ts b/src/models/tiles/geometry/jxg-polygon.ts index e1f356cebf..fe6c9ca10d 100644 --- a/src/models/tiles/geometry/jxg-polygon.ts +++ b/src/models/tiles/geometry/jxg-polygon.ts @@ -1,10 +1,63 @@ -import { each, filter, find, uniqueId, values } from "lodash"; +import { LineAttributes, PolygonAttributes } from "jsxgraph"; +import { each, filter, find, merge, remove, uniqueId, values } from "lodash"; +import { notEmpty } from "../../../utilities/js-utils"; +import { fillPropsForColorScheme, getPoint, getPolygon, strokePropsForColorScheme } from "./geometry-utils"; import { getObjectById } from "./jxg-board"; -import { ESegmentLabelOption, JXGChange, JXGChangeAgent } from "./jxg-changes"; -import { getElementName, objectChangeAgent } from "./jxg-object"; +import { ELabelOption, JXGChange, JXGChangeAgent, JXGParentType } from "./jxg-changes"; +import { objectChangeAgent } from "./jxg-object"; import { isLine, isPoint, isPolygon, isVertexAngle, isVisibleEdge } from "./jxg-types"; import { wn_PnPoly } from "./soft-surfer-sunday"; +const defaultPolygonProps = Object.freeze({ + hasInnerPoints: true, + fillOpacity: .2, highlightFillOpacity: .25 +}); + +const selectedPolygonProps = Object.freeze({ + fillOpacity: .3, highlightFillOpacity: .3 +}); + +// Hack alert: JSXGraph for some reason doesn't allow us to specify a CSS class to be applied. +// In order to be able to use CSS for adding a drop shadow to hovered & selected lines, +// using a CSS rule that is targeted on stroke-opacity="0.99". +const defaultPolygonEdgeProps = Object.freeze({ + strokeWidth: 2.5, highlightStrokeWidth: 2.5, + strokeOpacity: 1, highlightStrokeOpacity: 0.99, // 0.99 triggers shadow + transitionDuration: 0 +}); + +const selectedPolygonEdgeProps = Object.freeze({ + strokeWidth: 2.5, highlightStrokeWidth: 2.5, + strokeOpacity: 0.99, highlightStrokeOpacity: 0.99, // 0.99 triggers shadow +}); + +const phantomPolygonEdgeProps = Object.freeze({ + strokeOpacity: 0, + highlightStrokeOpacity: 0 +}); + +function getPolygonVisualProps(selected: boolean, colorScheme: number) { + const props: PolygonAttributes = { ...defaultPolygonProps }; + if (selected) { + merge(props, selectedPolygonProps); + } + merge(props, fillPropsForColorScheme(colorScheme)); + return props; +} + +export function getEdgeVisualProps(selected: boolean, colorScheme: number, phantom: boolean) { + if (phantom) { + // Invisible, so don't apply any other styles + return phantomPolygonEdgeProps; + } + const props: LineAttributes = { + ...defaultPolygonEdgeProps, + ...strokePropsForColorScheme(colorScheme), + ...(selected ? selectedPolygonEdgeProps : {}) + }; + return props; +} + export function isPointInPolygon(x: number, y: number, polygon: JXG.Polygon) { const v = polygon.vertices.map(vertex => { const [, vx, vy] = vertex.coords.scrCoords; @@ -14,15 +67,7 @@ export function isPointInPolygon(x: number, y: number, polygon: JXG.Polygon) { } export function getPolygonEdges(polygon: JXG.Polygon) { - const edges: { [id: string]: JXG.Line } = {}; - polygon.vertices.forEach(vertex => { - each(vertex.childElements, child => { - if (child.elType === "segment") { - edges[child.id] = child as JXG.Line; - } - }); - }); - return values(edges); + return polygon.borders; } export function getPolygonEdge(board: JXG.Board, polygonId: string, pointIds: string[]) { @@ -43,15 +88,35 @@ export function getAssociatedPolygon(elt: JXG.GeometryElement): JXG.Polygon | un if (isPoint(elt)) { return find(elt.childElements, isPolygon); } - if (elt.elType === "segment") { - const vertices = filter(elt.ancestors, isPoint); - for (const vertex of vertices) { - const polygon = find(vertex.childElements, isPolygon); - if (polygon) return polygon; - } + if (isLine(elt)) { + // Find a polygon that contains both ends of this segment. + // It can still be ambiguous if polygons overlap at more than one point, + // in which case we just return the first one found. + const p1polygons = filter(elt.point1.childElements, isPolygon); + const p2polygons = filter(elt.point2.childElements, isPolygon); + return p1polygons.find(p => p2polygons.includes(p)); } } +/** + * Set appropriate colors for the edges of a polygon. + * An edge between a phantom point and the first vertex is considered as incompleted, + * and is not drawn in. + * @param polygon + */ +function setPolygonEdgeColors(polygon: JXG.Polygon) { + const segments = getPolygonEdges(polygon); + const firstVertex = polygon.vertices[0]; + segments.forEach(seg => { + // the "uncompleted side" of an in-progress polygon is considered phantom + const phantom = segments.length > 2 && + ((seg.point1.getAttribute("isPhantom") && seg.point2 === firstVertex) + ||(seg.point2.getAttribute("isPhantom") && seg.point1 === firstVertex)); + const props = getEdgeVisualProps(false, polygon.getAttribute("colorScheme")||0, phantom); + seg.setAttribute(props); + }); +} + export function getPointsForVertexAngle(vertex: JXG.Point) { const children = values(vertex.childElements); const polygons = children.filter(isPolygon); @@ -83,18 +148,22 @@ export function getPointsForVertexAngle(vertex: JXG.Point) { : [p2, p1, p0]; } -export function prepareToDeleteObjects(board: JXG.Board, ids: string[]) { +export function prepareToDeleteObjects(board: JXG.Board, ids: string[]): string[] { + const selectedPoints: string[] = []; const polygonsToDelete: { [id: string]: JXG.Polygon } = {}; const anglesToDelete: { [id: string]: JXG.GeometryElement } = {}; - const moreIdsToDelete: string[] = []; // Identify polygons and angles scheduled for deletion and points that are vertices of polygons - const polygonVertexMap: { [id: string]: string[] } = {}; + const polygonVertexMap: { [id: string]: string[] } = {}; // maps polygon ids to vertex ids + const vertexPolygonMap: { [id: string]: string[] } = {}; // maps vertex ids to polygon ids ids.forEach(id => { const elt = getObjectById(board, id); if (isPoint(elt)) { + selectedPoints.push(elt.id); + vertexPolygonMap[elt.id] = []; each(elt.childElements, child => { if (isPolygon(child)) { + vertexPolygonMap[elt.id].push(child.id); if (!polygonVertexMap[child.id]) { polygonVertexMap[child.id] = []; } @@ -110,49 +179,96 @@ export function prepareToDeleteObjects(board: JXG.Board, ids: string[]) { } }); - // Consider each polygon with vertices to be deleted + // "Fully selected" polygons means polygons where all of their vertices are selected + const fullySelectedPolygons = Object.entries(polygonVertexMap) + .filter(([polyId, vertexIds]) => { + const poly = getPolygon(board, polyId)!; + return vertexIds.length === poly.vertices.length - 1; }) + .map(([polyId, poly]) => polyId); + + // Implement intuitive behavior for deleting a polygon that may be connected to other polygons. + // Polygons that are fully selected are deleted, but any of their points that are shared + // with a polygon that is NOT fully selected, are NOT deleted. + const pointsToDelete = selectedPoints; + each(fullySelectedPolygons, polyId => { + each(polygonVertexMap[polyId], vertexId => { + const externalPolygon = vertexPolygonMap[vertexId].find(pId => !fullySelectedPolygons.includes(pId)); + if (externalPolygon) { + // Do not actually delete this point, it connects to a polygon that should not be altered. + remove(pointsToDelete, v => v===vertexId); + } + }); + }); + + // Remove vertices that are going to be deleted from polygons, + // and find polygons that need to be deleted since they lost most or all of their points. each(polygonVertexMap, (vertexIds, polygonId) => { const polygon = getObjectById(board, polygonId) as JXG.Polygon; const vertexCount = polygon.vertices.length - 1; - const deleteCount = vertexIds.length; - // remove points from polygons if possible - if (vertexCount - deleteCount >= 2) { - vertexIds.forEach(id => { - const pt = getObjectById(board, id) as JXG.Point; - // removing multiple points at one time sometimes gives unexpected results - polygon.removePoints(pt); - }); - } - // otherwise, the polygon should be deleted as well - else { - if (!polygonsToDelete[polygon.id]) { - polygonsToDelete[polygon.id] = polygon; - moreIdsToDelete.push(polygon.id); + const deleteCount = vertexIds.filter(id=>pointsToDelete.includes(id)).length; + + // Remove polygons that will have 0 or 1 points left. + if (fullySelectedPolygons.includes(polygonId) || vertexCount - deleteCount <= 1) { + if (!polygonsToDelete[polygonId]) { + polygonsToDelete[polygonId] = polygon; + } + } else { + // Leave this polygon, but remove points that will be deleted from it. + const deletePoints = polygon.vertices.filter(v => pointsToDelete.includes(v.id)); + if (deletePoints.length) { + each(deletePoints, v => polygon.removePoints(v)); + setPolygonEdgeColors(polygon); } } }); // identify angle labels to delete - each(polygonsToDelete, polygon => { - polygon.vertices.forEach(vertex => { - each(vertex.childElements, child => { - if (isVertexAngle(child)) { - if (!anglesToDelete[child.id]) { - anglesToDelete[child.id] = child; - moreIdsToDelete.push(child.id); - } + each(pointsToDelete, pointId => { + const vertex = getPoint(board, pointId)!; + each(vertex.childElements, child => { + if (isVertexAngle(child)) { + if (!anglesToDelete[child.id]) { + anglesToDelete[child.id] = child; } - }); + } }); }); - // return ids of additional objects to delete - return moreIdsToDelete; + // return adjusted list of ids to delete + return [...pointsToDelete, ...Object.keys(polygonsToDelete), ...Object.keys(anglesToDelete)]; +} + +function setPropertiesForPolygonLabelOption(polygon: JXG.Polygon) { + const labelOption = polygon.getAttribute("clientLabelOption") || ELabelOption.kNone; + switch (labelOption) { + case ELabelOption.kLength: + polygon.setAttribute({ + withLabel: true, + name() { return polygon.Area().toFixed(2); } + }); + break; + case ELabelOption.kLabel: + polygon.setAttribute({ + withLabel: true, + name: polygon.getAttribute("clientName") + }); + break; + default: + polygon.setAttribute({ + withLabel: false + }); + } } -function segmentNameLabelFn(this: JXG.Line) { - const p1Name = getElementName(this.point1); - const p2Name = getElementName(this.point2); +function segmentNameLabelFn(line: JXG.Line) { + let p1Name = line.point1.getName(); + if (typeof p1Name === "function") { + p1Name = line.point1.getAttribute("clientName"); + } + let p2Name = line.point2.getName(); + if (typeof p2Name === "function") { + p2Name = line.point2.getAttribute("clientName"); + } return `${p1Name}${p2Name}`; } @@ -163,62 +279,118 @@ function segmentNameLengthFn(this: JXG.Line) { function updateSegmentLabelOption(board: JXG.Board, change: JXGChange) { const segment = getPolygonEdge(board, change.targetID as string, change.parents as string[]); if (segment) { - const labelOption = !Array.isArray(change.properties) && change.properties?.labelOption; - const clientLabelOption = (labelOption === ESegmentLabelOption.kLabel) || - (labelOption === ESegmentLabelOption.kLength) - ? labelOption - : null; - const clientOriginalName = segment.getAttribute("clientOriginalName"); - if (!clientOriginalName && (typeof segment.name === "string")) { - // store the original generated name so we can restore it if necessary - segment._set("clientOriginalName", segment.name); - } - segment._set("clientLabelOption", clientLabelOption); - const name = clientLabelOption - ? clientLabelOption === "label" - ? segmentNameLabelFn - : segmentNameLengthFn - // if we're removing our label, restore the original one - : clientOriginalName || board.generateName(segment); - segment.setAttribute({ name, withLabel: !!clientLabelOption }); - segment.label?.setAttribute({ visible: !!clientLabelOption }); + const labelOption = (!Array.isArray(change.properties) && change.properties?.labelOption) + || ELabelOption.kNone; + + const nameOption = (!Array.isArray(change.properties) && change.properties?.name) + || segmentNameLabelFn(segment); + + segment._set("clientLabelOption", labelOption); + segment._set("clientName", nameOption); + + const name = labelOption === "label" + ? nameOption + : labelOption === "length" + ? segmentNameLengthFn + : ""; + + segment.setAttribute({ name, withLabel: labelOption !== ELabelOption.kNone }); } } +function updatePolygonVertices(board: JXG.Board, polygonId: string, vertexIds: JXGParentType[]) { + // Remove the old polygon and create a new one. + const oldPolygon = getPolygon(board, polygonId); + const colorScheme = oldPolygon?.getAttribute("colorScheme"); + if (!oldPolygon) return; + board.removeObject(oldPolygon); + const vertices: JXG.Point[] + = vertexIds.map(v => typeof(v)==='string' ? getPoint(board, v) : undefined) + .filter(notEmpty); + const props = { + id: polygonId, // re-use the same ID + colorScheme, + ...getPolygonVisualProps(false, colorScheme) + }; + const polygon = board.create("polygon", vertices, props) as JXG.Polygon; + + + // Without deleting/rebuilding, would look something like this (but this fails due to apparent bugs in JSXGraph 1.4.x) + // const polygon = getPolygon(board, polygonId); + // if (!polygon) return; + + // const existingVertices = polygon.vertices; + // const newVertices: JXG.Point[] + // = vertexIds.map(v => typeof(v)==='string' ? getPoint(board, v) : undefined) + // .filter(notEmpty); + + // const addedVertices = newVertices.filter(v => !existingVertices.includes(v)); + // const removedVertices = existingVertices.filter(v => !newVertices.includes(v)); + + // console.log('current:', existingVertices.map(v=>`${v.id}${v.getAttribute('isPhantom')?'*':''}`)); + // console.log('adding:', addedVertices.map(v=>`${v.id}${v.getAttribute('isPhantom')?'*':''}`), + // 'removing:', removedVertices.map(v=>`${v.id}${v.getAttribute('isPhantom')?'*':''}`)); + + // for (const v of removedVertices) { + // polygon.removePoints(v); + // } + // for (const v of addedVertices) { + // polygon.addPoints(v); + // } + // console.log('final:', polygon.vertices.map(v=>`${v.id}${v.getAttribute('isPhantom')?'*':''}`)); + + setPolygonEdgeColors(polygon); + return polygon; +} + export const polygonChangeAgent: JXGChangeAgent = { create: (board, change) => { const _board = board as JXG.Board; const parents = (change.parents || []) - .map(id => getObjectById(_board, id as string)) - .filter(pt => pt != null); + .map(id => getObjectById(_board, id as string)) + .filter(notEmpty); + if (change.parents?.length !== parents.length) { + console.warn("Some points were missing when creating polygon"); + } + const colorScheme = !Array.isArray(change.properties) && change.properties?.colorScheme; const props = { id: uniqueId(), - hasInnerPoints: true, - // default color changed to yellow in JSXGraph 1.4.0 - fillColor: "#00FF00", - selectedFillColor: "#00FF00", - clientFillColor: "#00FF00", - clientSelectedFillColor: "#00FF00", + ...getPolygonVisualProps(false, colorScheme||0), ...change.properties }; - const poly = parents.length ? _board.create("polygon", parents, props) : undefined; + const poly = parents.length ? _board.create("polygon", parents, props) as JXG.Polygon : undefined; if (poly) { - const segments = getPolygonEdges(poly); - segments.forEach(seg => { - seg.setAttribute({strokeColor: "#0000FF"}); - seg._set("clientStrokeColor", "#0000FF"); - seg._set("clientSelectedStrokeColor", "#0000FF"); - }); + setPropertiesForPolygonLabelOption(poly); + setPolygonEdgeColors(poly); } return poly; }, update: (board, change) => { + // Parents and a labelOption means we're updating a segment label if ((change.target === "polygon") && change.parents && !Array.isArray(change.properties) && change.properties?.labelOption) { updateSegmentLabelOption(board, change); return; } + // labelOption without parents is updating the polygon's label + if (change.target === "polygon" && + change.targetID && !Array.isArray(change.targetID) && + !Array.isArray(change.properties) && change.properties?.labelOption) { + const polygon = getPolygon(board, change.targetID); + if (isPolygon(polygon)) { + polygon._set("clientLabelOption", change.properties.labelOption); + polygon._set("clientName", change.properties.clientName); + setPropertiesForPolygonLabelOption(polygon); + } + return; + } + // An update with an array of parents is considered to be a request to update the list of vertices. + if ((change.target === "polygon") + && change.targetID && !Array.isArray(change.targetID) + && change.parents && Array.isArray(change.parents)) { + return updatePolygonVertices(board, change.targetID, change.parents); + } // other updates can be handled generically return objectChangeAgent.update(board, change); }, diff --git a/src/models/tiles/geometry/jxg-table-link.ts b/src/models/tiles/geometry/jxg-table-link.ts index c5c479b23a..3c18a2299b 100644 --- a/src/models/tiles/geometry/jxg-table-link.ts +++ b/src/models/tiles/geometry/jxg-table-link.ts @@ -1,6 +1,7 @@ import { splitLinkedPointId } from "../table-link-types"; -import { resumeBoardUpdates, suspendBoardUpdates, syncLinkedPoints } from "./jxg-board"; -import { ILinkProperties, ITableLinkProperties, JXGChange, JXGChangeAgent, JXGCoordPair } from "./jxg-changes"; +import { filterBoardObjects, forEachBoardObject } from "./geometry-utils"; +import { resumeBoardUpdates, suspendBoardUpdates } from "./jxg-board"; +import { ILinkProperties, JXGChange, JXGChangeAgent, JXGCoordPair } from "./jxg-changes"; import { createPoint, pointChangeAgent } from "./jxg-point"; import { isPoint } from "./jxg-types"; @@ -15,28 +16,13 @@ export interface ITableLinkColors { fill: string; stroke: string; } -export type GetTableLinkColorsFunction = (tableId?: string) => ITableLinkColors | undefined; -let sGetTableLinkColors: GetTableLinkColorsFunction; - -export function injectGetTableLinkColorsFunction(getTableLinkColors: GetTableLinkColorsFunction) { - sGetTableLinkColors = getTableLinkColors; -} - -function createLinkedPoint(board: JXG.Board, parents: JXGCoordPair, props: any, links?: ILinkProperties) { +export function createLinkedPoint(board: JXG.Board, parents: JXGCoordPair, props: any, links?: ILinkProperties) { const tableId = links?.tileIds?.[0]; const [linkedRowId, linkedColId] = splitLinkedPointId(props?.id); - const linkColors = sGetTableLinkColors(tableId); - if (!board || !linkColors) return; const linkedProps = { clientType: "linkedPoint", fixed: true, - fillColor: linkColors.fill, - strokeColor: linkColors.stroke, - clientFillColor: linkColors.fill, - clientStrokeColor: linkColors.stroke, - clientSelectedFillColor: linkColors.stroke, - clientSelectedStrokeColor: linkColors.stroke, linkedTableId: tableId, linkedRowId, linkedColId @@ -47,7 +33,7 @@ function createLinkedPoint(board: JXG.Board, parents: JXGCoordPair, props: any, export function getAllLinkedPoints(board: JXG.Board) { const ids: string[] = []; - board.objectsList.forEach(obj => { + forEachBoardObject(board, obj => { if (obj.elType === "point" && obj.getAttribute("clientType") === "linkedPoint") { ids.push(obj.id); } @@ -68,9 +54,6 @@ export const linkedPointChangeAgent: JXGChangeAgent = { else { result = createLinkedPoint(board as JXG.Board, change.parents as JXGCoordPair, change.properties, change.links); } - - syncLinkedPoints(board as JXG.Board, change.links as ITableLinkProperties); - return result; }, @@ -78,8 +61,6 @@ export const linkedPointChangeAgent: JXGChangeAgent = { delete: (board, change) => { pointChangeAgent.delete(board, change); - - syncLinkedPoints(board, change.links as ITableLinkProperties); } }; @@ -106,7 +87,7 @@ export const tableLinkChangeAgent: JXGChangeAgent = { delete: (board, change) => { if (board) { const tableId = getTableIdFromLinkChange(change); - const pts = board.objectsList.filter(elt => { + const pts = filterBoardObjects(board, elt => { return isPoint(elt) && tableId && (elt.getAttribute("linkedTableId") === tableId); }); suspendBoardUpdates(board); diff --git a/src/models/tiles/geometry/jxg-types.ts b/src/models/tiles/geometry/jxg-types.ts index 44f3bd4fa3..aeeb8d8ddd 100644 --- a/src/models/tiles/geometry/jxg-types.ts +++ b/src/models/tiles/geometry/jxg-types.ts @@ -6,6 +6,9 @@ export const kGeometryDefaultPixelsPerUnit = 18.3; // matches S&S curriculum im export const kGeometryDefaultXAxisMin = -2; export const kGeometryDefaultYAxisMin = -1; +export const kGeometryHighlightColor = "#0081ff"; + + // utility for creating an object from a property/value pair export const toObj = (p: string, v: any) => v != null ? { [p]: v } : undefined; @@ -20,7 +23,9 @@ export const isGeometryElement = (v: any): v is JXG.GeometryElement => v instanc export const isPoint = (v: any): v is JXG.Point => v instanceof JXG.Point; export const isPointArray = (v: any): v is JXG.Point[] => Array.isArray(v) && v.every(isPoint); -export const isVisiblePoint = (v: any): v is JXG.Point => isPoint(v) && v.visProp.visible; +export const isVisiblePoint = (v: any): v is JXG.Point => isPoint(v) && !!v.visProp.visible; +export const isRealVisiblePoint = (v: any): v is JXG.Point => isPoint(v) && !!v.visProp.visible + && !v.getAttribute("isPhantom"); export const isLinkedPoint = (v: any): v is JXG.Point => { return isPoint(v) && (v.getAttribute("clientType") === "linkedPoint"); @@ -46,7 +51,7 @@ export const isLine = (v: any): v is JXG.Line => v instanceof JXG.Line; export const isPolygon = (v: any): v is JXG.Polygon => v instanceof JXG.Polygon; export const isVisibleEdge = (v: any): v is JXG.Line => { - return v instanceof JXG.Line && (v.elType === "segment") && v.visProp.visible; + return v instanceof JXG.Line && (v.elType === "segment") && !!v.visProp.visible; }; export const isText = (v: any): v is JXG.Text => v instanceof JXG.Text; @@ -59,7 +64,7 @@ export const kMovableLineType = "movableLine"; export const isMovableLine = (v: any): v is JXG.Line => { return v && (v.elType === "line") && (v.getAttribute("clientType") === kMovableLineType); }; -export const isVisibleMovableLine = (v: any): v is JXG.Line => isMovableLine(v) && v.visProp.visible; +export const isVisibleMovableLine = (v: any): v is JXG.Line => isMovableLine(v) && !!v.visProp.visible; export const isMovableLineControlPoint = (v: any): v is JXG.Point => { return isPoint(v) && v.getAttribute("clientType") === kMovableLineType; }; diff --git a/src/models/tiles/geometry/jxg-vertex-angle.ts b/src/models/tiles/geometry/jxg-vertex-angle.ts index a47b7e90ac..5dbb18c586 100644 --- a/src/models/tiles/geometry/jxg-vertex-angle.ts +++ b/src/models/tiles/geometry/jxg-vertex-angle.ts @@ -1,4 +1,5 @@ import { castArray, each, values } from "lodash"; +import JXG from "jsxgraph"; import { getObjectById } from "./jxg-board"; import { JXGChangeAgent } from "./jxg-changes"; import { objectChangeAgent } from "./jxg-object"; diff --git a/src/models/tiles/geometry/jxg.test.ts b/src/models/tiles/geometry/jxg.test.ts index 92eb8d5554..523d19479c 100644 --- a/src/models/tiles/geometry/jxg.test.ts +++ b/src/models/tiles/geometry/jxg.test.ts @@ -1,11 +1,10 @@ -import "./jxg"; +import JXG from "jsxgraph"; describe("JSXGraph library", () => { it("JXG is available", () => { expect(JXG).toBeDefined(); // test a few utility functions to verify library is loaded correctly - expect(JXG._round10(3.14159, -2)).toBe(3.14); expect(JXG.toFixed(-0.000001, 2)).toBe("0.00"); }); }); diff --git a/src/models/tiles/geometry/jxg.ts b/src/models/tiles/geometry/jxg.ts deleted file mode 100644 index b365f49511..0000000000 --- a/src/models/tiles/geometry/jxg.ts +++ /dev/null @@ -1 +0,0 @@ -import "jsxgraph/distrib/jsxgraphsrc.js"; diff --git a/src/models/tiles/table-links.ts b/src/models/tiles/table-links.ts index 173592384d..ce2aba39ba 100644 --- a/src/models/tiles/table-links.ts +++ b/src/models/tiles/table-links.ts @@ -45,7 +45,7 @@ export function getTableLinkColors(tableId?: string) { const linkIndex = 0; return linkIndex >= 0 ? colors[linkIndex % colors.length] - : undefined; + : colors[0]; } export function isLinkableTable(client: IAnyStateTreeNode, tableId: string) { diff --git a/src/models/tiles/tile-content-info.ts b/src/models/tiles/tile-content-info.ts index 3e74a91ab4..41395e149d 100644 --- a/src/models/tiles/tile-content-info.ts +++ b/src/models/tiles/tile-content-info.ts @@ -53,7 +53,6 @@ export interface ITileContentInfo { */ useContentTitle?: boolean; metadataClass?: typeof TileMetadataModel; - addSidecarNotes?: boolean; defaultHeight?: number; exportNonDefaultHeight?: boolean; isDataConsumer?: boolean; diff --git a/src/plugins/diagram-viewer/diagram-toolbar-buttons.tsx b/src/plugins/diagram-viewer/diagram-toolbar-buttons.tsx index d1bede8466..ab90501764 100644 --- a/src/plugins/diagram-viewer/diagram-toolbar-buttons.tsx +++ b/src/plugins/diagram-viewer/diagram-toolbar-buttons.tsx @@ -15,9 +15,9 @@ import { SharedVariablesLinkButton } from "../shared-variables/shared-variables- import AddVariableCardIcon from "./src/assets/add-variable-card-icon.svg"; import InsertVariableCardIcon from "./src/assets/insert-variable-card-icon.svg"; import VariableEditorIcon from "../shared-variables/assets/variable-editor-icon.svg"; -import ZoomInIcon from "./src/assets/zoom-in-icon.svg"; -import ZoomOutIcon from "./src/assets/zoom-out-icon.svg"; -import FitViewIcon from "./src/assets/fit-view-icon.svg"; +import ZoomInIcon from "../../clue/assets/icons/zoom-in-icon.svg"; +import ZoomOutIcon from "../../clue/assets/icons/zoom-out-icon.svg"; +import FitViewIcon from "../../clue/assets/icons/fit-view-icon.svg"; import LockLayoutIcon from "./src/assets/lock-layout-icon.svg"; import UnlockLayoutIcon from "./src/assets/unlock-layout-icon.svg"; import HideNavigatorIcon from "./src/assets/hide-navigator-icon.svg"; diff --git a/src/plugins/graph/components/legend/layer-legend.tsx b/src/plugins/graph/components/legend/layer-legend.tsx index 15f87b1ad0..257692d54a 100644 --- a/src/plugins/graph/components/legend/layer-legend.tsx +++ b/src/plugins/graph/components/legend/layer-legend.tsx @@ -7,7 +7,7 @@ import { useReadOnlyContext } from "../../../../components/document/read-only-co import { useGraphModelContext } from "../../hooks/use-graph-model-context"; import { getSharedModelManager } from "../../../../models/tiles/tile-environment"; import { isSharedDataSet, SharedDataSet } from "../../../../models/shared/shared-data-set"; -import { clueGraphColors } from "../../../../utilities/color-utils"; +import { clueDataColorInfo } from "../../../../utilities/color-utils"; import { DataConfigurationContext, useDataConfigurationContext } from "../../hooks/use-data-configuration-context"; import { IGraphLayerModel } from "../../models/graph-layer-model"; import { LegendDropdown } from "./legend-dropdown"; @@ -101,7 +101,7 @@ const SingleLayerLegend = observer(function SingleLayerLegend(props: ILegendPart buttonAriaLabel={`Color: ${graphModel.getColorNameForId(description.attributeID)}`} buttonLabel={} menuItems={ - clueGraphColors.map((color, colorIndex) => ({ + clueDataColorInfo.map((color, colorIndex) => ({ ariaLabel: color.name, key: color.color, label: , diff --git a/src/plugins/graph/models/graph-model.test.ts b/src/plugins/graph/models/graph-model.test.ts index 8f11256b42..dae4e64300 100644 --- a/src/plugins/graph/models/graph-model.test.ts +++ b/src/plugins/graph/models/graph-model.test.ts @@ -32,11 +32,27 @@ const createElementSpy = jest.spyOn(document, "createElement") : origCreateElement.call(document, tagName, options); }); +// Mock colors imported from SCSS +jest.mock("../../../utilities/color-utils.ts", () => { + const originalModule = jest.requireActual("../../../utilities/color-utils.ts"); + return { + ...originalModule, + clueDataColorInfo: [ + { color: "#0069ff", name: "blue" }, + { color: "#ff9617", name: "orange" }, + { color: "#19a90f", name: "green" }, + { color: "#ee0000", name: "red" }, + { color: "#cbd114", name: "yellow" }, + { color: "#d51eff", name: "purple" }, + { color: "#6b00d2", name: "indigo" } ] + }; +}); + import { getSnapshot } from '@concord-consortium/mobx-state-tree'; import { GraphModel, IGraphModel } from './graph-model'; import { kGraphTileType } from '../graph-defs'; import { - clueGraphColors, defaultBackgroundColor, defaultPointColor, defaultStrokeColor + clueDataColorInfo, defaultBackgroundColor, defaultPointColor, defaultStrokeColor } from "../../../utilities/color-utils"; import { MovablePointModel } from '../adornments/movable-point/movable-point-model'; import { createDocumentModel, DocumentModelType } from '../../../models/document/document'; @@ -179,21 +195,21 @@ describe('GraphModel', () => { } // Colors should loop once we've gone through them all - clueGraphColors.forEach(color => { + clueDataColorInfo.forEach(color => { graphModel.setColorForId(color.color); // graphModel.getColorForId(color.color); }); const extraId = "extra"; graphModel.setColorForId(extraId); // graphModel.getColorForId(extraId); - expect(getUniqueColorIndices().length).toEqual(clueGraphColors.length); + expect(getUniqueColorIndices().length).toEqual(clueDataColorInfo.length); // After removing a color, we should get it when we add a new color const uniqueKey = - clueGraphColors.find(id => graphModel.getColorForId(id.color) !== graphModel.getColorForId(extraId))!.color; + clueDataColorInfo.find(id => graphModel.getColorForId(id.color) !== graphModel.getColorForId(extraId))!.color; const oldColor = graphModel.getColorForId(uniqueKey); graphModel.removeColorForId(uniqueKey); - expect(getUniqueColorIndices().length).toEqual(clueGraphColors.length - 1); + expect(getUniqueColorIndices().length).toEqual(clueDataColorInfo.length - 1); const newKey = "new"; graphModel.setColorForId(newKey); const newColor = graphModel.getColorForId(newKey); diff --git a/src/plugins/graph/models/graph-model.ts b/src/plugins/graph/models/graph-model.ts index 4f09a0f70e..3c9a9b1bed 100644 --- a/src/plugins/graph/models/graph-model.ts +++ b/src/plugins/graph/models/graph-model.ts @@ -22,7 +22,7 @@ import {ITileContentModel, TileContentModel} from "../../../models/tiles/tile-co import {ITileExportOptions} from "../../../models/tiles/tile-content-info"; import { getSharedModelManager } from "../../../models/tiles/tile-environment"; import { - clueGraphColors, defaultBackgroundColor, defaultPointColor, defaultStrokeColor + clueDataColorInfo, defaultBackgroundColor, defaultPointColor, defaultStrokeColor } from "../../../utilities/color-utils"; import { AdornmentModelUnion } from "../adornments/adornment-types"; import { isSharedCaseMetadata, SharedCaseMetadata } from "../../../models/shared/shared-case-metadata"; @@ -35,6 +35,7 @@ import { multiLegendParts } from "../components/legend/legend-registration"; import { addAttributeToDataSet, DataSet } from "../../../models/data/data-set"; import { getDocumentContentFromNode } from "../../../utilities/mst-utils"; import { ICase } from "../../../models/data/data-set-types"; +import { findLeastUsedNumber } from "../../../utilities/math-utils"; export interface GraphProperties { axes: Record @@ -139,22 +140,7 @@ export const GraphModel = TileContentModel return all; }, get nextColor() { - const colorCounts: Record = {}; - self._idColors.forEach(index => { - if (!colorCounts[index]) colorCounts[index] = 0; - colorCounts[index]++; - }); - const usedColorIndices = Object.keys(colorCounts).map(index => Number(index)); - if (usedColorIndices.length < clueGraphColors.length) { - // If there are unused colors, return the index of the first one - return Object.keys(clueGraphColors).map(index => Number(index)) - .filter(index => !usedColorIndices.includes(index))[0]; - } else { - // Otherwise, use the next minimally used color's index - const counts = usedColorIndices.map(index => colorCounts[index]); - const minCount = Math.min(...counts); - return usedColorIndices.find(index => colorCounts[index] === minCount) ?? 0; - } + return findLeastUsedNumber(clueDataColorInfo.length, self._idColors.values()); }, getAdornmentOfType(type: string) { return self.adornments.find(a => a.type === type); @@ -165,7 +151,7 @@ export const GraphModel = TileContentModel if (plotIndex < self._pointColors.length) { return self._pointColors[plotIndex]; } else { - return clueGraphColors[plotIndex % clueGraphColors.length].color; + return clueDataColorInfo[plotIndex % clueDataColorInfo.length].color; } }, get pointColor() { @@ -613,12 +599,12 @@ export const GraphModel = TileContentModel getColorForId(id: string) { const colorIndex = self._idColors.get(id); if (colorIndex === undefined) return "#000000"; - return clueGraphColors[colorIndex % clueGraphColors.length].color; + return clueDataColorInfo[colorIndex % clueDataColorInfo.length].color; }, getColorNameForId(id: string) { const colorIndex = self._idColors.get(id); if (colorIndex === undefined) return "black"; - return clueGraphColors[colorIndex % clueGraphColors.length].name; + return clueDataColorInfo[colorIndex % clueDataColorInfo.length].name; }, getEditablePointsColor() { let color = "#000000"; diff --git a/src/plugins/graph/utilities/graph-utils.ts b/src/plugins/graph/utilities/graph-utils.ts index 46389d228b..be488f681b 100644 --- a/src/plugins/graph/utilities/graph-utils.ts +++ b/src/plugins/graph/utilities/graph-utils.ts @@ -669,7 +669,7 @@ export function updateGraphContentWithNewSharedModelIds( sharedDataSetEntries: SharedModelEntrySnapshotType[], updatedSharedModelMap: Record ) { - return replaceJsonStringsWithUpdatedIds(content, ...Object.values(updatedSharedModelMap)); + return replaceJsonStringsWithUpdatedIds(content, '"', ...Object.values(updatedSharedModelMap)); } export function updateGraphObjectWithNewSharedModelIds( diff --git a/src/plugins/shared-variables/graph/legend/variable-function-legend.tsx b/src/plugins/shared-variables/graph/legend/variable-function-legend.tsx index 2f91b2e9ae..375c3655bb 100644 --- a/src/plugins/shared-variables/graph/legend/variable-function-legend.tsx +++ b/src/plugins/shared-variables/graph/legend/variable-function-legend.tsx @@ -3,7 +3,7 @@ import { observer } from "mobx-react"; import { useReadOnlyContext } from "../../../../components/document/read-only-context"; import { getSharedModelManager } from "../../../../models/tiles/tile-environment"; -import { clueGraphColors } from "../../../../utilities/color-utils"; +import { clueDataColorInfo } from "../../../../utilities/color-utils"; import { LegendDropdown } from "../../../graph/components/legend/legend-dropdown"; import { LegendIdListFunction, ILegendHeightFunctionProps, ILegendPartProps @@ -92,7 +92,7 @@ export const SingleVariableFunctionLegend = observer(function SingleVariableFunc buttonAriaLabel={`Color: ${graphModel.getColorNameForId(instanceKey)}`} buttonLabel={} menuItems={ - clueGraphColors.map((color, index) => ({ + clueDataColorInfo.map((color, index) => ({ ariaLabel: color.name, key: color.color, label: , diff --git a/src/utilities/color-utils.ts b/src/utilities/color-utils.ts index 7e6fae6005..3cd79a254f 100644 --- a/src/utilities/color-utils.ts +++ b/src/utilities/color-utils.ts @@ -1,4 +1,5 @@ import colorString from "color-string"; +import clueDataColors from "../clue/data-colors.scss"; export const lightenColor = (color: string, pct = 0.5) => { const rgb = colorString.get.rgb(color); @@ -25,25 +26,19 @@ export const isLightColorRequiringContrastOffset = (color?: string) => { return (luminance != null) && (luminance >= kLightLuminanceThreshold); }; -interface ClueColor { +export interface ClueColor { color: string; name: string; } -export const kGraphDataBlue = "#0069ff"; -export const kGraphDataOrange = "#ff9617"; -export const kGraphDataGreen = "#19a90f"; -export const kGraphDataRed = "#e00"; -export const kGraphDataYellow = "#cbd114"; -export const kGraphDataPurple = "#d51eff"; -export const kGraphDataIndigo = "#6b00d2"; -export const clueGraphColors: ClueColor[] = [ - { color: kGraphDataBlue, name: "blue" }, - { color: kGraphDataOrange, name: "orange" }, - { color: kGraphDataGreen, name: "green" }, - { color: kGraphDataRed, name: "red" }, - { color: kGraphDataYellow, name: "yellow" }, - { color: kGraphDataPurple, name: "purple" }, - { color: kGraphDataIndigo, name: "indigo" } + +export const clueDataColorInfo: ClueColor[] = [ + { color: clueDataColors.dataBlue, name: "blue" }, + { color: clueDataColors.dataOrange, name: "orange" }, + { color: clueDataColors.dataGreen, name: "green" }, + { color: clueDataColors.dataRed, name: "red" }, + { color: clueDataColors.dataYellow, name: "yellow" }, + { color: clueDataColors.dataPurple, name: "purple" }, + { color: clueDataColors.dataIndigo, name: "indigo" } ]; /* diff --git a/src/utilities/js-utils.ts b/src/utilities/js-utils.ts index 82876d3a77..ce34db7b3c 100644 --- a/src/utilities/js-utils.ts +++ b/src/utilities/js-utils.ts @@ -152,3 +152,15 @@ export function formatTimeZoneOffset(offset: number) { pad2(Math.floor(posOffset / 60)) + pad2(posOffset % 60); } + +/** + * Check whether the given value is not null or undefined. + * This is useful for `filter` statements since it gives typescript the type certainty it needs. + * See https://stackoverflow.com/questions/43118692/typescript-filter-out-nulls-from-an-array + * Should be unnecessary after Typescript version 5.5 + * @param value + * @returns + */ +export function notEmpty(value: TValue | null | undefined): value is TValue { + return value != null; +} diff --git a/src/utilities/math-utils.test.ts b/src/utilities/math-utils.test.ts new file mode 100644 index 0000000000..30f1aa9acf --- /dev/null +++ b/src/utilities/math-utils.test.ts @@ -0,0 +1,35 @@ +import { findLeastUsedNumber } from "./math-utils"; + +describe("findLeastUsedNumber", () => { + + it('should return the (first) least-used number within the range', () => { + const numbers = [0, 0, 1, 2, 2, 3, 3, 3, 4, 5, 6, 7, 8, 9, 9]; + const limit = 10; + expect(findLeastUsedNumber(limit, numbers)).toBe(1); + }); + + it('should return 0 for an empty iterable', () => { + const numbers: number[] = []; + const limit = 10; + expect(findLeastUsedNumber(limit, numbers)).toBe(0); + }); + + it('should return 0 if all numbers are out of the specified range', () => { + const numbers = [10, 11, 12, 13]; + const limit = 10; + expect(findLeastUsedNumber(limit, numbers)).toBe(0); + }); + + it('should ignore invalid items', () => { + const numbers = [-1, 1.5, 2/7, Math.PI, NaN, Infinity, + 0, 0, 0, 1, 1, 2, 2, 3, 4]; + const limit = 3; + expect(findLeastUsedNumber(limit, numbers)).toBe(1); + }); + + it('should handle large inputs efficiently', () => { + const numbers = Array.from({ length: 100000 }, (_, i) => i % 10); + const limit = 10; + expect(findLeastUsedNumber(limit, numbers)).toBe(0); + }); +}); diff --git a/src/utilities/math-utils.ts b/src/utilities/math-utils.ts index 24fc81928f..b5313253bb 100644 --- a/src/utilities/math-utils.ts +++ b/src/utilities/math-utils.ts @@ -12,3 +12,35 @@ export type Point = [x: number, y: number]; export function isFiniteNumber(x: any): x is number { return x != null && Number.isFinite(x); } + +/** + * Finds the least-used number within a specified range in an iterable of numbers. + * + * @param {number} limit - The upper limit (exclusive) for the range of numbers to consider. + * @param {Iterable} iterable - An iterable of numbers to analyze. + * @returns {number} The least-used number within the specified range, or 0 if the iterable is empty + * or all numbers are out of the specified range. + */ +export function findLeastUsedNumber(limit: number, iterable: Iterable): number { + const counts = new Array(limit).fill(0); // Array to count occurrences of numbers + + // Count occurrences of each valid integer in the iterable + for (const number of iterable) { + if (Number.isInteger(number) && number >= 0 && number < limit) { + counts[number]++; + } + } + + let leastUsedNumber = 0; + let leastCount = Infinity; + + // Find the least-used number between 0 and (limit - 1) + for (let i = 0; i < limit; i++) { + if (counts[i] < leastCount) { + leastCount = counts[i]; + leastUsedNumber = i; + } + } + + return leastUsedNumber; +}