diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000000..c5ebe9a905 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,26 @@ +# This is a basic workflow to help you get started with Actions + +name: Coverage + +on: + push: + workflow_dispatch: + +jobs: + code_coverage: + runs-on: ubuntu-latest + container: eltenedor/pg-no-ww:latest + steps: + - uses: actions/checkout@v2 + - name: Adapt configuration and run tests + env: + HARNESS_PERL_SWITCHES: -MDevel::Cover + run: | + ln -s -t /opt/webwork/ `pwd` + prove -r t + - name: push coverage analysis + if: always() + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + run: cover -report codecov + diff --git a/.gitignore b/.gitignore index 9efdf335e0..68e887d01b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,10 @@ -lib/chromatic/color \ No newline at end of file +lib/chromatic/color +conf/pg_defaults.yml +*.bak +cover_db/ + +htdocs/package-lock.json +htdocs/node_modules +htdocs/static-assets.json +htdocs/**/*.min.js +htdocs/**/*.min.css diff --git a/.perltidyrc b/.perltidyrc new file mode 100644 index 0000000000..8314b7c173 --- /dev/null +++ b/.perltidyrc @@ -0,0 +1,22 @@ +# PBP .perltidyrc file +-l=120 # Max line width is 120 cols +-et=4 # Use tabs instead of spaces. +-i=4 # Indent level is 4 cols +-ci=4 # Continuation indent is 4 cols +-b # Write the file inline and create a .bak file +-vt=0 # Minimal vertical tightness +-cti=0 # No extra indentation for closing brackets +-pt=2 # Maximum parenthesis tightness +-bt=1 # Medium brace tightness +-sbt=1 # Medium square bracket tightness +-bbt=1 # Medium block brace tightness +-nsfs # No space before semicolons +-nolq # Don't outdent long quoted strings +-mbl=1 # Do not allow multiple empty lines +-ce # Cuddled else +-cb # Cuddled blocks +-nbbc # Do not add blank lines before full length comments +-nbot # No line break on ternary +-nlop # No logical padding (this causes mixed tabs and spaces) +-wn # Weld nested containers +-xci # Extended continuation indentation diff --git a/LICENSE b/LICENSE index 8df1667545..980aae59d3 100644 --- a/LICENSE +++ b/LICENSE @@ -2,7 +2,7 @@ Online Homework Delivery System Version 2.* - Copyright 2000-2018, The WeBWorK Project + Copyright 2000-2022, The WeBWorK Project All rights reserved. This program is free software; you can redistribute it and/or modify diff --git a/README b/README index 2326906afb..45ed6d2993 100644 --- a/README +++ b/README @@ -6,6 +6,6 @@ http://webwork.maa.org/wiki/Category:Release_Notes - Copyright 2000-2017, The WeBWorK Project + Copyright 2000-2022, The WeBWorK Project http://webwork.maa.org All rights reserved. diff --git a/README.md b/README.md index 6603452793..d40515112f 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,27 @@ -#Welcome to WeBWorK +# Welcome to WeBWorK + +![main workflow](https://github.com/pstaabp/pg/actions/workflows/coverage.yml/badge.svg) +[![codecov](https://codecov.io/gh/pstaabp/pg/branch/unit-test/graph/badge.svg?token=H7WYHBDB9S)](https://codecov.io/gh/pstaabp/pg) +![GitHub last commit](https://img.shields.io/github/last-commit/pstaabp/pg/unit-test) WeBWorK is an open-source online homework system for math and sciences courses. WeBWorK is supported by the MAA and the NSF and comes with an Open Problem Library (OPL) of over 30,000 homework problems. Problems in the OPL target most lower division undergraduate math courses and some advanced courses. Supported courses include college algebra, discrete mathematics, probability and statistics, single and multivariable calculus, differential equations, linear algebra and complex analysis. Find out more at the main WeBWorK [webpage](http://webwork.maa.org). ## Information for Users -New users interested in getting started with their own WeBWorK server, or instructors looking to learn more about how to use WeBWorK in their classes, should take a look at one of the following resources: -* [WeBWorK wiki](http://webwork.maa.org/wiki/Main_Page) - The main WeBWorK wiki - * [Instructors](http://webwork.maa.org/wiki/Instructors) - Information for Instructors - * [Problem Authors](http://webwork.maa.org/wiki/Authors) - Information for Problem Authors -* [WW_Install](http://github.com/aubreyja/ww_install) - Information for using the WW_install script -* [Forum](http://webwork.maa.org/moodle/mod/forum/index.php?id=3) - The WeBWorK Forum -* [Frequently Asked Questions](https://github.com/openwebwork/webwork2/wiki/Frequently-Asked-Questions) - A list of frequently asked questions. +New users interested in getting started with their own WeBWorK server, or instructors looking to learn more about how to use WeBWorK in their classes, should take a look at one of the following resources: + +* [WeBWorK wiki](http://webwork.maa.org/wiki/Main_Page) - The main WeBWorK wiki + * [Instructors](http://webwork.maa.org/wiki/Instructors) - Information for Instructors + * [Problem Authors](http://webwork.maa.org/wiki/Authors) - Information for Problem Authors +* [WW_Install](http://github.com/aubreyja/ww_install) - Information for using the WW_install script +* [Forum](http://webwork.maa.org/moodle/mod/forum/index.php?id=3) - The WeBWorK Forum +* [Frequently Asked Questions](https://github.com/openwebwork/webwork2/wiki/Frequently-Asked-Questions) - A list of frequently asked questions. -##Information For Developers +## Information For Developers People interested in developing new features for WeBWorK should take a look at the following resources. People interested in developing new problems for WeBWorK should visit [Problem Authors](http://webwork.maa.org/wiki/Authors). -* [First Time Setup](https://github.com/openwebwork/webwork2/wiki/First-Time-Setup) - Setting up your clone of this github repo for the first time. -* [Coding and Workflow](https://github.com/openwebwork/webwork2/wiki/Coding-and-Workflow) - Our suggested workflow processes. Following this will make it much easier to get code accepted into the repo. -* [Creating Pull Requests](https://github.com/openwebwork/webwork2/wiki/Creating-Pull-Requests) - Instructions on how to submit a pull request. -* [More Information](https://github.com/openwebwork/webwork2/wiki/) - Our Github wiki has additional information for developers, including information about WeBWorK3. + +* [First Time Setup](https://github.com/openwebwork/webwork2/wiki/First-Time-Setup) - Setting up your clone of this github repo for the first time. +* [Coding and Workflow](https://github.com/openwebwork/webwork2/wiki/Coding-and-Workflow) - Our suggested workflow processes. Following this will make it much easier to get code accepted into the repo. +* [Creating Pull Requests](https://github.com/openwebwork/webwork2/wiki/Creating-Pull-Requests) - Instructions on how to submit a pull request. +* [More Information](https://github.com/openwebwork/webwork2/wiki/) - Our Github wiki has additional information for developers, including information about WeBWorK3. diff --git a/VERSION b/VERSION index d59de1c529..0048b529d4 100644 --- a/VERSION +++ b/VERSION @@ -1,4 +1,4 @@ -$PG_VERSION ='2.16'; -$PG_COPYRIGHT_YEARS = '1996-2021'; +$PG_VERSION ='2.17'; +$PG_COPYRIGHT_YEARS = '1996-2022'; 1; diff --git a/conf/pg_defaults.dist.yml b/conf/pg_defaults.dist.yml new file mode 100644 index 0000000000..fe31876a56 --- /dev/null +++ b/conf/pg_defaults.dist.yml @@ -0,0 +1,19 @@ +# This file is loaded if PG is loaded without a webwork2 lib +# +# The following are configuration options that are needed by PG +# and are a slimmed down list from those of WEBWORK_ROOT/conf/defaults.config + +options: + webworkDirs: + tmp: /tmp + externalPrograms: + curl: /usr/bin/curl + cp: /bin/cp + mv: /bin/mv + rm: /bin/rm + tar: /bin/tar + latex: /usr/bin/latex --no-shell-escape + pdflatex: /usr/bin/pdflatex --no-shell-escape + dvisvgm: /usr/bin/dvisvgm + pdf2svf: /usr/bin/pdf2svg + convert: /usr/bin/convert diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000000..a846604f96 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,63 @@ +# Docker Instructions + +These are instructions to get the docker image running for running the unit tests in `/t`. + +Note: You may need sudo privileges in order to run the commands starting with `docker ...`. + +## Using the Image from Docker Hub + +The following Docker command will execute the command `prove -r t` inside the Docker container from the image [`eltenedor/pg-no-ww:latest`](https://hub.docker.com/r/eltenedor/pg-no-ww). +Make sure to run the commands from your `pg` folder. +The first time, this may take a couple of minutes. + +### Running the Test Suite + +```bash +docker run -it --rm --name pg-unit-test -v `pwd`:/opt/webwork/pg -w /opt/webwork/pg eltenedor/pg-no-ww prove -r t +``` + +### Code Coverage + +As above, run one of the following Docker commands from you `pg` folder. + +#### HTML Output + +This runs the command `cover -report html` in the Docker container. + +```bash +docker run -it --rm --name pg-unit-test -v `pwd`:/opt/webwork/pg -w /opt/webwork/pg eltenedor/pg-no-ww cover -report html +``` + +Check the HTML output written to `./pg/cover_db/coverage.html`. + +#### Publish Results to [`codecov.io`](https://about.codecov.io/) + +```bash +docker run -it --rm --name pg-unit-test -v `pwd`:/opt/webwork/pg -w /opt/webwork/pg -e CODECOV_TOKEN=xxxx-xxxx-xxxx eltenedor/pg-no-ww cover -report codecov +``` + +Here, `CODECOV_TOKEN=xxxx-xxxx-xxxx` should be adapted to your actual [token](https://docs.codecov.com/docs/quick-start). It is passed to the docker container as argument to the `-e` option. + +HTTP Code `200` means that the data was sent successfully to codecov and is availablle at [https://app.codecov.io/gh/pstaabp/pg](https://app.codecov.io/gh/pstaabp/pg) + +### Using the Shell + +You can also just open up the `bash` of the Docker container via + +```bash +docker run -it --rm --name pg-unit-test -v `pwd`:/opt/webwork/pg -w /opt/webwork/pg eltenedor/pg-no-ww +``` + +At the prompt, just run the commands `prove -r t` and `cover -report html` as indicated above. + +## Building the Docker Image Locally + +Execute the following command from your `pg/docker` folder + +```bash +docker build -t pg-no-ww -f pg-no-dww.Dockerfile +``` + +### Running the Test Suite Locally + +Same as above for the image from Docker Hub. Just replace the name `eltenedor/pg-no-ww` by `pg-no-ww`. diff --git a/docker/pg-no-ww.Dockerfile b/docker/pg-no-ww.Dockerfile new file mode 100644 index 0000000000..14b2396c7c --- /dev/null +++ b/docker/pg-no-ww.Dockerfile @@ -0,0 +1,28 @@ +FROM perl:5.32 +# set metadata +LABEL maintainer="fabian.gabel@tuhh.de" +LABEL hub.docker.com="eltenedor/pg-no-ww" +# install needed perl modules +RUN cpanm -fi --notest \ + Class::Accessor \ + Date::Parse \ + DateTime \ + Devel::Cover \ + Devel::Cover::Report::Codecov \ + HTML::Entities \ + HTML::TagParser \ + JSON \ + Test::Exception \ + Tie::IxHash \ + UUID::Tiny module \ + YAML::XS +# create webwork environment +RUN mkdir -p /opt/webwork +# rename config file at startup +RUN echo "cp -f /opt/webwork/pg/conf/pg_defaults.yml.dist /opt/webwork/pg/conf/pg_defaults.yml" >> ~/.bashrc +WORKDIR /opt/webwork +ENV PG_ROOT /opt/webwork/pg +ENV WEBWORK_ROOT /opt/webwork/webwork2 +ENV WEBWORK_TOPLEVEL /opt/webwork +ENV HARNESS_PERL_SWITCHES -MDevel::Cover +CMD ["/bin/bash"] diff --git a/docker/test-docker.Dockerfile b/docker/test-docker.Dockerfile new file mode 100644 index 0000000000..009a7db1f4 --- /dev/null +++ b/docker/test-docker.Dockerfile @@ -0,0 +1,25 @@ +FROM perl:5.32 +# install needed perl modules +RUN cpanm -fi --notest \ + Class::Accessor \ + Date::Parse \ + DateTime \ + Devel::Cover \ + Devel::Cover::Report::Codecov \ + HTML::Entities \ + HTML::TagParser \ + JSON \ + Test::Exception \ + Tie::IxHash \ + UUID::Tiny module \ + YAML::XS +# create webwork environment +RUN mkdir -p /opt/webwork +# rename config file at startup +# RUN echo "cp -f /opt/webwork/pg/conf/pg_defaults.yml.dist /opt/webwork/pg/conf/pg_defaults.yml" >> ~/.bashrc +WORKDIR /opt/webwork/pg +ENV PG_ROOT /opt/webwork/pg +# ENV WEBWORK_ROOT /opt/webwork/webwork2 +# ENV WEBWORK_TOPLEVEL /opt/webwork +# ENV HARNESS_PERL_SWITCHES -MDevel::Cover +CMD ["/bin/bash"] diff --git a/htdocs/generate-assets.js b/htdocs/generate-assets.js new file mode 100755 index 0000000000..b907da0ccd --- /dev/null +++ b/htdocs/generate-assets.js @@ -0,0 +1,234 @@ +#!/usr/bin/env node + +/* eslint-env node */ + +const yargs = require('yargs'); +const chokidar = require('chokidar'); +const path = require('path'); +const { minify } = require('terser'); +const fs = require('fs'); +const crypto = require('crypto'); +const sass = require('sass'); +const autoprefixer = require('autoprefixer'); +const postcss = require('postcss'); +const rtlcss = require('rtlcss'); +const cssMinify = require('cssnano'); +const thirdPartyAssets = require('./third-party-assets.json'); + +const argv = yargs + .usage('$0 Options').version(false).alias('help', 'h').wrap(100) + .option('useCDN', { + alias: 'c', + description: 'Use third party assets from a CDN rather than serving them locally.', + type: 'boolean' + }) + .option('enable-sourcemaps', { + alias: 's', + description: 'Generate source maps. (Not for use in production!)', + type: 'boolean' + }) + .option('watch-files', { + alias: 'w', + description: 'Continue to watch files for changes. (Developer tool)', + type: 'boolean' + }) + .option('clean', { + alias: 'd', + description: 'Delete all generated files.', + type: 'boolean' + }) + .argv; + +const assetFile = path.resolve(__dirname, 'static-assets.json'); +const assets = {}; + +const cleanDir = (dir) => { + for (const file of fs.readdirSync(dir, { withFileTypes: true })) { + if (file.isDirectory()) { + cleanDir(path.resolve(dir, file.name)); + } else { + if (/.[a-z0-9]{8}.min.(css|js)$/.test(file.name)) { + const fullPath = path.resolve(dir, file.name); + console.log(`\x1b[34mRemoving ${fullPath} from previous build.\x1b[0m`); + fs.unlinkSync(fullPath); + } + } + } +} + +// The is set to true after all files are processed for the first time. +let ready = false; + +const processFile = async (file, _details) => { + if (file) { + const baseName = path.basename(file); + + if (/(? { accumulator[file] = file; return accumulator; }, {})); +} + +// Set up the watcher. +if (argv.watchFiles) console.log('\x1b[32mEstablishing watches and performing initial build.\x1b[0m'); +chokidar.watch(['js'], { + ignored: /\.min\.(js|css)$/, + cwd: __dirname, // Make sure all paths are given relative to the htdocs directory. + usePolling: true, // Needed to get changes to symlinks. + interval: 500, + awaitWriteFinish: { stabilityThreshold: 500 }, + persistent: argv.watchFiles ? true : false +}) + .on('add', processFile).on('change', processFile).on('ready', processFile) + .on('unlink', (file) => { + // If a file is deleted, then also delete the corresponding generated file. + if (assets[file]) { + console.log(`\x1b[34mDeleting minified file for ${file}.\x1b[0m`); + fs.unlinkSync(path.resolve(__dirname, assets[file])); + delete assets[file]; + } + }) + .on('error', (error) => console.log(`\x1b[32m${error}\x1b[0m`)); diff --git a/htdocs/helpFiles/Entering-Angles.html b/htdocs/helpFiles/Entering-Angles.html new file mode 100644 index 0000000000..611d879e0c --- /dev/null +++ b/htdocs/helpFiles/Entering-Angles.html @@ -0,0 +1,49 @@ +

Entering Angles

+ + diff --git a/htdocs/helpFiles/Entering-Decimals.html b/htdocs/helpFiles/Entering-Decimals.html new file mode 100644 index 0000000000..538664800c --- /dev/null +++ b/htdocs/helpFiles/Entering-Decimals.html @@ -0,0 +1,41 @@ +

Entering decimals

+ + diff --git a/htdocs/helpFiles/Entering-Equations.html b/htdocs/helpFiles/Entering-Equations.html new file mode 100644 index 0000000000..e0041bfdc8 --- /dev/null +++ b/htdocs/helpFiles/Entering-Equations.html @@ -0,0 +1,52 @@ +

Entering Equations

+ + diff --git a/htdocs/helpFiles/Entering-Exponents.html b/htdocs/helpFiles/Entering-Exponents.html new file mode 100644 index 0000000000..16f43aeffd --- /dev/null +++ b/htdocs/helpFiles/Entering-Exponents.html @@ -0,0 +1,46 @@ +

Entering Exponents

+ + diff --git a/htdocs/helpFiles/Entering-Formulas.html b/htdocs/helpFiles/Entering-Formulas.html new file mode 100644 index 0000000000..6868d6fcbe --- /dev/null +++ b/htdocs/helpFiles/Entering-Formulas.html @@ -0,0 +1,116 @@ +

Entering Formulas

+ + diff --git a/htdocs/helpFiles/Entering-Formulas10.html b/htdocs/helpFiles/Entering-Formulas10.html new file mode 100644 index 0000000000..27c5048153 --- /dev/null +++ b/htdocs/helpFiles/Entering-Formulas10.html @@ -0,0 +1,116 @@ +

Entering Formulas

+ + diff --git a/htdocs/helpFiles/Entering-Fractions.html b/htdocs/helpFiles/Entering-Fractions.html new file mode 100644 index 0000000000..6a6935b93b --- /dev/null +++ b/htdocs/helpFiles/Entering-Fractions.html @@ -0,0 +1,73 @@ +

Entering Fractions

+ + diff --git a/htdocs/helpFiles/Entering-Inequalities.html b/htdocs/helpFiles/Entering-Inequalities.html new file mode 100644 index 0000000000..a553485e21 --- /dev/null +++ b/htdocs/helpFiles/Entering-Inequalities.html @@ -0,0 +1,188 @@ +

Entering Inequalities

+ + diff --git a/htdocs/helpFiles/Entering-Intervals.html b/htdocs/helpFiles/Entering-Intervals.html new file mode 100644 index 0000000000..46d3a2edb6 --- /dev/null +++ b/htdocs/helpFiles/Entering-Intervals.html @@ -0,0 +1,40 @@ +

Using Interval Notation

+ + diff --git a/htdocs/helpFiles/Entering-Limits.html b/htdocs/helpFiles/Entering-Limits.html new file mode 100644 index 0000000000..6cc8de402e --- /dev/null +++ b/htdocs/helpFiles/Entering-Limits.html @@ -0,0 +1,67 @@ +

Entering Limits

+ + diff --git a/htdocs/helpFiles/Entering-Logarithms.html b/htdocs/helpFiles/Entering-Logarithms.html new file mode 100644 index 0000000000..ed055d68fc --- /dev/null +++ b/htdocs/helpFiles/Entering-Logarithms.html @@ -0,0 +1,61 @@ +

Entering Logarithms

+ + diff --git a/htdocs/helpFiles/Entering-Logarithms10.html b/htdocs/helpFiles/Entering-Logarithms10.html new file mode 100644 index 0000000000..39b764f85e --- /dev/null +++ b/htdocs/helpFiles/Entering-Logarithms10.html @@ -0,0 +1,62 @@ +

Entering Logarithms

+ + diff --git a/htdocs/helpFiles/Entering-Numbers.html b/htdocs/helpFiles/Entering-Numbers.html new file mode 100644 index 0000000000..318d4c4922 --- /dev/null +++ b/htdocs/helpFiles/Entering-Numbers.html @@ -0,0 +1,56 @@ +

Entering Angles

+ + diff --git a/htdocs/helpFiles/Entering-Points.html b/htdocs/helpFiles/Entering-Points.html new file mode 100644 index 0000000000..cb104db552 --- /dev/null +++ b/htdocs/helpFiles/Entering-Points.html @@ -0,0 +1,44 @@ +

Entering Angles

+ + diff --git a/htdocs/helpFiles/Entering-Syntax.html b/htdocs/helpFiles/Entering-Syntax.html new file mode 100644 index 0000000000..a95067272b --- /dev/null +++ b/htdocs/helpFiles/Entering-Syntax.html @@ -0,0 +1,249 @@ +

Syntax for entering answers to WeBWorK

+ +

Mathematical Symbols Available In WeBWorK

+ + +

Syntax for entering expressions

+ + +

Mathematical Constants Available In WeBWorK

+ + +

Scientific Notation Available In WeBWorK

+ + +

Mathematical Functions Available In WeBWorK

+

+ Note that sometimes one or more of these functions is disabled for a WeBWorK problem because the instructor wants + you to calculate the answer by some means other than just using the function. +

+ + +

Other Mathematical Functions

+

These functions may not always be available for every problem.

+ + +For more information see the +list of all available functions. diff --git a/htdocs/helpFiles/Entering-Units.html b/htdocs/helpFiles/Entering-Units.html new file mode 100644 index 0000000000..f216909ffa --- /dev/null +++ b/htdocs/helpFiles/Entering-Units.html @@ -0,0 +1,203 @@ +

Units Available in WeBWorK

+

+ Some WeBWorK problems ask for answers with units. Below is a list of basic units and how they need to be abbreviated + in WeBWorK answers. In some problems, you may need to combine units (e.g, velocity might be in + ft/s for feet per second). +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
UnitAbbreviation
Time
Secondss
Minutesmin
Hourshr
Daysday
Yearsyr
Millisecondsms
Distance
Feetft
Inchesin
Milesmi
Metersm
Centimeterscm
Millimetersmm
Kilometerskm
AngstromsA
Light yearslight-year
Mass
Gramsg
Kilogramskg
Slugsslug
Volume
LitersL
Cubic Centimeterscc
Millilitersml
Force
NewtonsN
Dynesdyne
Poundslb
Tonston
Work/Energy
JoulesJ
kilo JoulekJ
ergserg
foot poundslbf
caloriescal
kilo calorieskcal
electron voltseV
kilo Watt hourskWh
Misc
Amperesamp
Molesmol
Degrees CentrigradedegC
Degrees FahrenheitdegF
Degrees KelvindegK
Angle degreesdeg
Angle radiansrad
+

+ More details on units in WeBWorK +

diff --git a/htdocs/helpFiles/Entering-Vectors.html b/htdocs/helpFiles/Entering-Vectors.html new file mode 100644 index 0000000000..7a1523b13b --- /dev/null +++ b/htdocs/helpFiles/Entering-Vectors.html @@ -0,0 +1,71 @@ +

Entering Vectors

+ + diff --git a/htdocs/helpFiles/IntervalNotation.html b/htdocs/helpFiles/IntervalNotation.html new file mode 100644 index 0000000000..46d3a2edb6 --- /dev/null +++ b/htdocs/helpFiles/IntervalNotation.html @@ -0,0 +1,40 @@ +

Using Interval Notation

+ + diff --git a/htdocs/helpFiles/PDE-notation.html b/htdocs/helpFiles/PDE-notation.html new file mode 100644 index 0000000000..3ec368c2e8 --- /dev/null +++ b/htdocs/helpFiles/PDE-notation.html @@ -0,0 +1,107 @@ +

Entering Partial Derivatives

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Partial DerivativeEnter into WeBWork
\(\frac{\partial u}{\partial x}\)ux
\(\frac{\partial u}{\partial t}\)ut
\(\frac{\partial u}{\partial y}\)uy
\(\frac{\partial^2 u}{\partial x^2}\)uxx
\(\frac{\partial^2 u}{\partial t^2}\)utt
\(\frac{\partial^2 u}{\partial y^2}\)uyy
+ +

+ To answer questions that require you to input a PDE you will also need to know the form of the PDE WeBWorK is + expecting. In particular you will need to use the same letters for constants that WeBWorK is expecting. Below is a + table of the PDE's you may encounter. +

+ +

Models

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ModelPDE
heat equation + \(k \frac{\partial^2 u}{\partial x^2} = \frac{\partial u}{\partial t}\) +
+ heat equation with lateral heat transfer - \(u_m\) is the temperature of the surrounding medium and will be + given, and h is the constant from Newton's $ + + \(k \frac{\partial^2 u}{\partial x^2}-h (u-u_m) = \frac{\partial u}{\partial t}\) +
wave equation + \(a^2 \frac{\partial^2 u}{\partial x^2} = \frac{\partial^2 u}{\partial t^2}\) +
wave equation with damping + \(a^2 \frac{\partial^2 u}{\partial x^2} = + \frac{\partial^2 u}{\partial t^2}+c \frac{\partial u}{\partial t}\) +
+ wave equation with an external force (f(x,t) will specified in the problem + + \(a^2 \frac{\partial^2 u}{\partial x^2} + f(x,t) = \frac{\partial^2 u}{\partial t^2}\) +
Laplace's equation + \(\frac{\partial^2 u}{\partial x^2}+\frac{\partial^2 u}{\partial y^2} = 0\) +
+ +

For problems with proportional quantities the constant of proportionality is c.

+ +

Entering Boundary and Initial Conditions

+ +There are three types of boundary conditions we will consider: + +
    +
  1. Ends held at a constant temperature \(u_0\) (Dirichlet condition): \(u(L,t) = u_0\)
  2. +
  3. Ends insulated (Neumann condition): \(\frac{\partial u}{\partial x}\big\vert_{x=L} = 0\)
  4. +
  5. Heat transfer through the ends into a medium held at constant temperature \(u_m\)
  6. +
+ +

+ Suppose that we want to enter the boundary condition \(\frac{\partial u}{\partial x}\big\vert_{x=0}=0\). You will be + given two answer blanks: the first is to input the partial derivative and the point, \(ux(0,t)\), and the second + will be for the right hand side. In WeBWorK notation the boundary condition would be given as + \(ux(0,t) = 0\). +

diff --git a/htdocs/helpFiles/Syntax.html b/htdocs/helpFiles/Syntax.html new file mode 100644 index 0000000000..a95067272b --- /dev/null +++ b/htdocs/helpFiles/Syntax.html @@ -0,0 +1,249 @@ +

Syntax for entering answers to WeBWorK

+ +

Mathematical Symbols Available In WeBWorK

+ + +

Syntax for entering expressions

+ + +

Mathematical Constants Available In WeBWorK

+ + +

Scientific Notation Available In WeBWorK

+ + +

Mathematical Functions Available In WeBWorK

+

+ Note that sometimes one or more of these functions is disabled for a WeBWorK problem because the instructor wants + you to calculate the answer by some means other than just using the function. +

+ + +

Other Mathematical Functions

+

These functions may not always be available for every problem.

+ + +For more information see the +list of all available functions. diff --git a/htdocs/helpFiles/Units.html b/htdocs/helpFiles/Units.html new file mode 100644 index 0000000000..f216909ffa --- /dev/null +++ b/htdocs/helpFiles/Units.html @@ -0,0 +1,203 @@ +

Units Available in WeBWorK

+

+ Some WeBWorK problems ask for answers with units. Below is a list of basic units and how they need to be abbreviated + in WeBWorK answers. In some problems, you may need to combine units (e.g, velocity might be in + ft/s for feet per second). +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
UnitAbbreviation
Time
Secondss
Minutesmin
Hourshr
Daysday
Yearsyr
Millisecondsms
Distance
Feetft
Inchesin
Milesmi
Metersm
Centimeterscm
Millimetersmm
Kilometerskm
AngstromsA
Light yearslight-year
Mass
Gramsg
Kilogramskg
Slugsslug
Volume
LitersL
Cubic Centimeterscc
Millilitersml
Force
NewtonsN
Dynesdyne
Poundslb
Tonston
Work/Energy
JoulesJ
kilo JoulekJ
ergserg
foot poundslbf
caloriescal
kilo calorieskcal
electron voltseV
kilo Watt hourskWh
Misc
Amperesamp
Molesmol
Degrees CentrigradedegC
Degrees FahrenheitdegF
Degrees KelvindegK
Angle degreesdeg
Angle radiansrad
+

+ More details on units in WeBWorK +

diff --git a/htdocs/js/apps/AppletSupport/ww_applet_support.js b/htdocs/js/apps/AppletSupport/ww_applet_support.js new file mode 100644 index 0000000000..605f584bc3 --- /dev/null +++ b/htdocs/js/apps/AppletSupport/ww_applet_support.js @@ -0,0 +1,357 @@ +// ################################################################################ +// # WeBWorK Online Homework Delivery System +// # Copyright © 2000-2022 The WeBWorK Project, https://github.com/openwebwork +// # +// # This program is free software; you can redistribute it and/or modify it under +// # the terms of either: (a) the GNU General Public License as published by the +// # Free Software Foundation; either version 2, or (at your option) any later +// # version, or (b) the "Artistic License" which comes with this package. +// # +// # This program is distributed in the hope that it will be useful, but WITHOUT +// # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// # FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the +// # Artistic License for more details. +// ################################################################################ + +// List of web applets on the page. +const ww_applet_list = {}; + +// Utility functions + +// Dummy function provided to prevent console errors for problems written for lecacy code. +const applet_loaded = () => {}; + +const getApplet = (appletName) => window[appletName]; + +// Get Question Element in problemMainForm by name +const getQE = (name1) => { + const obj = document.getElementById(name1) ?? document.problemMainForm[name1]; + if (!obj || obj.name != name1) { + console.log(`Can't find element ${name1}`); + } else { + return obj; + } +} + +const getQuestionElement = getQE; + +// WW_Applet class definition +class ww_applet { + constructor(appletName) { + this.appletName = appletName; + this.type = ''; + this.initialState = ''; + this.configuration = ''; + this.getStateAlias = ''; + this.setStateAlias = ''; + this.setConfigAlias = ''; + this.getConfigAlias = ''; + this.submitActionScript = ''; + this.onInit = 0; + this.debug = 0; + } + + // Determine whether an XML string has been base64 encoded. + // This returns false if the string is empty, or if it contains a < or > character. + // The empty string is not a base64 string, and + // base64 can't contain < or > and xml strings contain lots of them. + base64Q(str) { return str && !/[<>]+/.exec(str); } + + // Make sure that the applet has this function available + methodDefined(methodName) { + const applet = getApplet(this.appletName); + if (methodName && typeof(applet[methodName]) == 'function') return true; + if (this.debug) console.log(`${this.appletName}: Method name ${methodName} is not defined`); + return false; + } + + // CONFIGURATIONS + // Configurations are "permanent" + setConfig() { + if (this.debug) console.log(`${this.appletName}: calling setConfig`); + const applet = getApplet(this.appletName); + try { + if (this.methodDefined(this.setConfigAlias)) { + if (this.debug) console.log(`${this.appletName}: calling ${this.setConfigAlias}${ + this.debug > 1 ? `with configuration:\n${this.configuration}` : ''}`); + applet[this.setConfigAlias](this.configuration); + } + } catch(e) { + console.log(`Error configuring ${this.appletName} using command ${this.setConfigAlias}: ${e}`); + } + } + + // Gets the configuration from the applet. + getConfig() { + if (this.debug) console.log(`${this.appletName}: calling getConfig`); + const applet = getApplet(this.appletName); + try { + if (this.methodDefined(this.getConfigAlias)) { + if (this.debug) console.log(`${this.appletName}: calling ${this.getConfigAlias}`); + console.log(applet[this.getConfigAlias]()); + } + } catch(e) { + console.log(`Error getting configuration for ${this.appletName} using command ${this.getConfigAlias}: ${e}`); + } + } + + // Set the state stored on the HTML page + setHTMLAppletState(newState) { + if (this.debug) console.log(`${this.appletName}: setHTMLAppletState${this.debug > 1 ? ` to:\n${newState}` : ''}`); + if (typeof(newState) === 'undefined') newState = 'restart_applet'; + const stateInput = ww_applet_list[this.appletName].stateInput; + getQE(stateInput).value = newState; + getQE(`previous_${stateInput}`).value = newState; + } + + // STATE: + // State can vary as the applet is manipulated. It is reset from the questions state values. + setState(state) { + if (this.debug) console.log(`${this.appletName}: calling setState`); + const applet = getApplet(this.appletName); + + // Obtain the state which will be sent to the applet and if it is encoded place it in plain xml text. + // Communication with the applet is in plain text, not in base64 code. + + if (state) { + if (this.debug) console.log(`${this.appletName}: Obtain state from calling paramater`); + } else { + if (this.debug) console.log(`${this.appletName}: Obtain state from ${this.stateInput}`); + // Hidden answer box preserving applet state + const ww_preserve_applet_state = getQE(this.stateInput); + state = ww_preserve_applet_state.value; + } + + if (this.base64Q(state)) state = Base64.decode(state); + + // Handle the exceptional cases: + // If the state is blank, undefined, or explicitly defined as restart_applet, + // then we will not simply be restoring the state of the applet from HTML "memory". + // + // 1. For a restart we wipe the HTML state cache so that we won't restart again. + // 2. In the other "empty" cases we attempt to replace the state with the contents of the + // initialState variable. + + // Exceptional cases + if (state.match(/^restart_applet<\/xml>/) || + state.match(/^\s*$/) || + state.match(/^\s*<\/xml>/)) { + + if (typeof(this.initialState) == 'undefined') { this.initialState = ''; } + if (this.debug > 1) console.log(`${this.appletName}: Restarting with initial state:\n${this.initialState}`); + if (this.initialState.match(/^\s*<\/xml>/) || this.initialState.match(/^\s*$/)) { + // Set the saved state to the empty state, so that the submit action will not be overridden by + // restart_applet. + this.setHTMLAppletState(''); + + // Don't call the setStateAlias function. + // Quit because we know we will not transmitting any starting data to the applet + return; + } else { + state = this.initialState; + if (this.base64Q(state)) state = Base64.decode(state); + + // Store the state in the HTML variables just for safety + this.setHTMLAppletState(this.initialState); + + // If there was a viable state in the initialState variable we can + // now continue as if we had found a valid state in the HTML cache. + } + } + + // State MUST be an xml string in plain text + if (state.match(/\ 1 ? ` with state ${state}` : ''}`); + applet[this.setStateAlias](state); + } + } catch(err) { + console.log(`Error setting state for ${this.appletName} using command ${ + this.setStateAlias}: ${err} ${err.number} ${err.description}`); + } + } + } + + getState() { + if (this.debug) console.log(`${this.appletName}: calling getState`); + let state = 'foobar'; + const applet = getApplet(this.appletName); + + try { + if (this.methodDefined(this.getStateAlias)) { + if (this.debug) console.log(`${this.appletName}: calling ${this.getStateAlias}`); + state = applet[this.getStateAlias](); // Get state in xml format + } else { + state = ''; + } + } catch (e) { + console.log(`Error getting state for ${this.appletName} calling command ${this.getStateAlias}: ${e}`); + } + + if (this.debug > 1) console.log(`${this.appletName}: Got state:\n${state}`); + + // Replace state by encoded version + if (!this.base64Q(state)) state = Base64.encode(state); + + // Save the state to the hidden input preserving applet state + getQE(this.stateInput).value = state; + } + + submitAction() { + if (this.debug) console.log(`${this.appletName}: calling submitAction`); + // Find the hidden input element preserving applet state and get its value. + const ww_preserve_applet_state = getQE(this.stateInput); + + // Check to see if we want to restart the applet + if (ww_preserve_applet_state.value.match(/^restart_applet<\/xml>/)) { + // Replace the saved state with restart_applet + this.setHTMLAppletState(); + return; + } + + // If we are not restarting the applet, then save the state and submit. + + // Have ww_applet retrieve state from applet and store in HTML cache + this.getState(); + + if (this.debug > 1) console.log(`${this.appletName}: Evaluating ${this.submitActionScript}`); + eval(this.submitActionScript); + + // Because the state has not always been perfectly preserved when storing the state in text + // area boxes we take a "belt && suspenders" approach by converting the value of the text + // area state cache to base64 form. + if (!this.base64Q(ww_preserve_applet_state.value)) + ww_preserve_applet_state.value = Base64.encode(ww_preserve_applet_state.value); + } + + safe_applet_initialize() { + if (this.debug) console.log(`${this.appletName}: calling safe_applet_initialize`); + + // Configure the applet. + try { + this.setConfig(); + } catch(e) { + console.log(`Unable to configure ${this.appletName}:\n${e}`); + } + + // Set the applet state. + try { + this.setState(); + } catch(e) { + console.log(`Unable to set the state for ${this.appletName}:\n${e}`); + } + } +} + +(() => { + // This should be the only ggbOnInit method defined. Unfortunately some older problems define a + // ggbOnInit so we check for that here. Those problems should be updated, and newly written + // problems should not define a javascript function by that name. + // This caches the ggbOnInit from the problem, and calls it in the ggbOnInit function defined + // here. This will only work if there is only one of these old problems on the page. + let ggbOnInitFromProblem = window.ggbOnInit; + const wwGGBOnInit = (appletName) => { + if (typeof ggbOnInitFromProblem == 'function') { + ggbOnInitFromProblem(appletName); + } + if (appletName in ww_applet_list && ww_applet_list[appletName].onInit && + ww_applet_list[appletName].onInit != 'ggbOnInit') { + if (window[ww_applet_list[appletName].onInit] && + typeof(window[ww_applet_list[appletName].onInit]) == 'function') { + window[ww_applet_list[appletName].onInit](appletName); + } else { + eval(ww_applet_list[appletName].onInit); + } + } + }; + window.ggbOnInit = wwGGBOnInit; + + const addProblemFormSubmitHandler = (form) => { + if (form.submitHandlerInitialized) return; + form.submitHandlerInitialized = true; + + // Connect the submit action handler to the form. + form.addEventListener('submit', () => { + for (const appletName in ww_applet_list) { + ww_applet_list[appletName].submitAction(); + } + }); + }; + + // Initialize applet support and the applets. + const initializeAppletSupport = () => { + const problemForm = document.problemMainForm ?? document.gwquiz; + if (problemForm) { + if (problemForm instanceof HTMLCollection) { + for (const form of problemForm) addProblemFormSubmitHandler(form); + } else { + addProblemFormSubmitHandler(problemForm); + } + } + + if (window.ggbOnInit !== wwGGBOnInit) { + ggbOnInitFromProblem = window.ggbOnInit; + window.ggbOnInit = wwGGBOnInit; + } + + for (const appletName in ww_applet_list) { + const container = document.getElementById(appletName); + + // Delete applets in the list that are no longer on the page. + if (!container) { + delete document[appletName]; + delete ww_applet_list[appletName]; + continue; + } + + if (ww_applet_list[appletName].setupComplete) continue; + ww_applet_list[appletName].setupComplete = true; + + const resetButton = document.querySelector(`.applet-reset-btn[data-applet-name="${appletName}"]`); + if (resetButton && problemForm) { + let containingForm = null; + if (problemForm instanceof HTMLCollection) { + for (const form of problemForm) { + if (form.querySelector(`.applet-reset-btn[data-applet-name="${appletName}"]`)) { + containingForm = form; + break; + } + } + } else { + if (problemForm.querySelector(`.applet-reset-btn[data-applet-name="${appletName}"]`)) + containingForm = problemForm; + } + if (containingForm) { + resetButton.addEventListener('click', () => { + ww_applet_list[appletName].setHTMLAppletState(); + let previewAnswerButton = null; + for (const control of containingForm.elements) { + if (control.name === 'previewAnswers') { + previewAnswerButton = control; + break; + } + } + previewAnswerButton?.click(); + }); + } + } + + // Create and initialize geogebra applet objects. + if (ww_applet_list[appletName].type == 'geogebraweb') { + const ggbApplet = new GGBApplet(Object.assign({}, container.dataset), true); + ggbApplet.setHTML5Codebase('https://geogebra.org/apps/latest/web3d/'); + ggbApplet.inject(appletName); + } + + // If onInit is defined, then the onInit function will handle the initialization. + if (!ww_applet_list[appletName].onInit) { + ww_applet_list[appletName].safe_applet_initialize(); + } + } + } + + window.addEventListener('PGContentLoaded', initializeAppletSupport); + window.addEventListener('DOMContentLoaded', initializeAppletSupport); +})(); diff --git a/htdocs/js/apps/Base64/Base64.js b/htdocs/js/apps/Base64/Base64.js new file mode 100644 index 0000000000..7d9536a4f0 --- /dev/null +++ b/htdocs/js/apps/Base64/Base64.js @@ -0,0 +1,143 @@ + +/** +* +* Base64 encode / decode +* http://www.webtoolkit.info/ +* +**/ + +var Base64 = { + + // private property + _keyStr : "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=", + + // public method for encoding + encode : function (input) { + var output = ""; + var chr1, chr2, chr3, enc1, enc2, enc3, enc4; + var i = 0; + + input = Base64._utf8_encode(input); + + while (i < input.length) { + + chr1 = input.charCodeAt(i++); + chr2 = input.charCodeAt(i++); + chr3 = input.charCodeAt(i++); + + enc1 = chr1 >> 2; + enc2 = ((chr1 & 3) << 4) | (chr2 >> 4); + enc3 = ((chr2 & 15) << 2) | (chr3 >> 6); + enc4 = chr3 & 63; + + if (isNaN(chr2)) { + enc3 = enc4 = 64; + } else if (isNaN(chr3)) { + enc4 = 64; + } + + output = output + + this._keyStr.charAt(enc1) + this._keyStr.charAt(enc2) + + this._keyStr.charAt(enc3) + this._keyStr.charAt(enc4); + + } + + return output; + }, + + // public method for decoding + decode : function (input) { + var output = ""; + var chr1, chr2, chr3; + var enc1, enc2, enc3, enc4; + var i = 0; + + input = input.replace(/[^A-Za-z0-9\+\/\=]/g, ""); + + while (i < input.length) { + + enc1 = this._keyStr.indexOf(input.charAt(i++)); + enc2 = this._keyStr.indexOf(input.charAt(i++)); + enc3 = this._keyStr.indexOf(input.charAt(i++)); + enc4 = this._keyStr.indexOf(input.charAt(i++)); + + chr1 = (enc1 << 2) | (enc2 >> 4); + chr2 = ((enc2 & 15) << 4) | (enc3 >> 2); + chr3 = ((enc3 & 3) << 6) | enc4; + + output = output + String.fromCharCode(chr1); + + if (enc3 != 64) { + output = output + String.fromCharCode(chr2); + } + if (enc4 != 64) { + output = output + String.fromCharCode(chr3); + } + + } + + output = Base64._utf8_decode(output); + + return output; + + }, + + // private method for UTF-8 encoding + _utf8_encode : function (string) { + string = string.replace(/\r\n/g,"\n"); + var utftext = ""; + + for (var n = 0; n < string.length; n++) { + + var c = string.charCodeAt(n); + + if (c < 128) { + utftext += String.fromCharCode(c); + } + else if((c > 127) && (c < 2048)) { + utftext += String.fromCharCode((c >> 6) | 192); + utftext += String.fromCharCode((c & 63) | 128); + } + else { + utftext += String.fromCharCode((c >> 12) | 224); + utftext += String.fromCharCode(((c >> 6) & 63) | 128); + utftext += String.fromCharCode((c & 63) | 128); + } + + } + + return utftext; + }, + + // private method for UTF-8 decoding + _utf8_decode : function (utftext) { + var string = ""; + var i = 0; + var c = c1 = c2 = 0; + + while ( i < utftext.length ) { + + c = utftext.charCodeAt(i); + + if (c < 128) { + string += String.fromCharCode(c); + i++; + } + else if((c > 191) && (c < 224)) { + c2 = utftext.charCodeAt(i+1); + string += String.fromCharCode(((c & 31) << 6) | (c2 & 63)); + i += 2; + } + else { + c2 = utftext.charCodeAt(i+1); + c3 = utftext.charCodeAt(i+2); + string += String.fromCharCode(((c & 15) << 12) | ((c2 & 63) << 6) | (c3 & 63)); + i += 3; + } + + } + + return string; + } + +} diff --git a/htdocs/js/apps/DragNDrop/dragndrop.css b/htdocs/js/apps/DragNDrop/dragndrop.css new file mode 100644 index 0000000000..7b703319ef --- /dev/null +++ b/htdocs/js/apps/DragNDrop/dragndrop.css @@ -0,0 +1,98 @@ +.dd-bucket-pool { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-evenly; + gap: 1rem; + margin-bottom: 1rem; +} + +.dd-container { + display: flex; + flex-direction: column; + width: 350px; + padding: 0.5rem; + color: #000000; + border: 1px solid #388E8E !important; + border-radius: 5px; + text-align: center; +} + +.nestable-label { + margin: 0 0 10px 0; +} + +.dd-list { + display: flex !important; + flex-direction: column; + gap: 0.5rem; +} + +.dd { + flex-grow: 1; +} + +.dd-list, .dd-item { + list-style: none; + margin: 0; + padding: 0; + min-height: 30px; +} + +.dd-hidden { + display: none; +} + +.dd-empty, .dd-handle, .dd-placeholder { + margin: 0 !important; + min-height: 30px !important; + box-sizing: border-box; + border-radius: 5px; +} + +.dd-empty { + height: 100%; +} + +.dd-handle { + background: #f5f5f5 !important; + border: 1px solid #388E8E !important; + text-align: center !important; + height: auto !important; +} + +.dd-handle:hover { + cursor: pointer; + background: #EEE3CE; + color: #222; +} + +.dd-placeholder { + background: #f2fbff; + border: 1px dashed #b6bcbf; +} + +.dd-dragel { + position: absolute; + pointer-events: none; + z-index: 9999; +} + +.dd-dragel > .dd-item .dd-handle { + margin-top: 0; +} + +.dd-dragel .dd-handle { + box-shadow: 2px 4px 6px 0 rgba(0,0,0,.1); + opacity: 0.8; +} + +.dd-remove-bucket { + margin-top: 0.5rem; + align-self: center; +} + +.dd-buttons { + display: flex; + gap: 0.25rem; +} diff --git a/htdocs/js/apps/DragNDrop/dragndrop.js b/htdocs/js/apps/DragNDrop/dragndrop.js new file mode 100644 index 0000000000..d8c2948a64 --- /dev/null +++ b/htdocs/js/apps/DragNDrop/dragndrop.js @@ -0,0 +1,146 @@ +(function() { + class DragNDropBucket { + constructor(pgData) { + this.answerInputId = pgData['answerInputId']; + this.bucketId = pgData['bucketId']; + this.label = pgData['label'] || ''; + this.removable = pgData['removable']; + this.bucketPool = $('.dd-bucket-pool[data-ans="' + this.answerInputId + '"]').first()[0]; + + const $bucketPool = $(this.bucketPool); + const $newBucket = this._newBucket( + this.bucketId, + this.label, + this.removable, + $bucketPool.find('.dd-hidden.dd-past-answers.dd-bucket[data-bucket-id="' + this.bucketId + '"]') + ); + + $bucketPool.append($newBucket); + + const el = this; + + $newBucket.find('.dd').nestable({ + group: el.answerInputId, + maxDepth: 1, + scroll: true, + callback: function() {el._nestableUpdate();} + }); + this._nestableUpdate(); + this._ddUpdate(); + } + + _newBucket(bucketId, label, removable, $bucketHtmlElement) { + const $newBucket = $('
'); + + $newBucket.attr('data-bucket-id', bucketId); + $newBucket.append($('
' + label + '
')); + $newBucket.append($('
')); + + if (removable != 0) { + $newBucket.append($('')); + } + + if ($bucketHtmlElement.find('ol.dd-answer li').length) { + const $ddList = $('
    '); + + $bucketHtmlElement.find('ol.dd-answer li').each(function() { + const $item = $('
  1. ' + $(this).html() + '
  2. '); + + $item.addClass('dd-item').attr('data-shuffled-index', $(this).attr('data-shuffled-index')); + $ddList.append($item); + }); + $newBucket.find('.dd').first().append($ddList); + } + $newBucket.css('background-color', 'hsla(' + ((100 + (bucketId)*100) % 360) + ', 40%, 90%, 1)'); + return $newBucket; + } + + _nestableUpdate() { + const buckets = []; + + $(this.bucketPool).find('.dd').each(function() { + const list = []; + + $(this).find('li.dd-item').each(function() { + list.push($(this).attr('data-shuffled-index')); + }); + if (list.length) { + buckets.push('(' + list.join(",") + ')'); + } else { + buckets.push('(-1)'); + } + }); + + $("#" + this.answerInputId).val(buckets.join(",")); + } + + _ddUpdate() { + const answerInputId = this.answerInputId; + const $bucketPool = $('.dd-bucket-pool[data-ans="' + answerInputId + '"]').first(); + const el = this; + + $(function() { + $bucketPool.parent().find('.dd-add-bucket').off(); + $bucketPool.parent().find('.dd-add-bucket').on('click', function() { + new DragNDropBucket({ + answerInputId: $(this).attr('data-ans'), + bucketId: +($('.dd').length) + 1, + removable: 1, + label:'', + }); + }); + $bucketPool.find('.dd-remove-bucket').off(); + $bucketPool.find('.dd-remove-bucket').on('click', function() { + if ($bucketPool.find('.dd ol').length == 1) { + return 0; + } + const $container = $(this).closest('.dd-container'); + + $container.find('li').appendTo($bucketPool.find('.dd ol').first()); + $container.remove(); + el._nestableUpdate(); + }); + $bucketPool.parent().find('.dd-reset-buckets').off(); + $bucketPool.parent().find('.dd-reset-buckets').on('click', function() { + $bucketPool.find('.dd-container').remove(); + $bucketPool.find('div.dd-hidden.dd-default.dd-bucket').each(function() { + const bucketId = $(this).attr('data-bucket-id'); + const $bucket = el._newBucket( + $(this).attr('data-bucket-id'), + $(this).find('.dd-label').first().html(), + $(this).attr('data-removable'), + $bucketPool.find('.dd-hidden.dd-default.dd-bucket[data-bucket-id="' + bucketId + '"]') + ); + + $bucketPool.append($bucket); + }); + + $bucketPool.find('.dd').nestable({ + group: el.answerInputId, + maxDepth: 1, + scroll: true, + callback: function() {el._nestableUpdate();} + }); + el._nestableUpdate(); + }); + }); + } + + } + + $('div.dd-bucket-pool').each(function() { + const answerInputId = $(this).attr('data-ans'); + + if ($(this).find('div.dd-bucket.dd-past-answers.dd-hidden').length) { + $(this).find('div.dd-bucket.dd-past-answers.dd-hidden').each(function() { + new DragNDropBucket({ + answerInputId : answerInputId, + bucketId : $(this).attr('data-bucket-id'), + label : $(this).find('.dd-label').html(), + removable : $(this).attr('data-removable'), + }); + }); + } + }); + +})(); diff --git a/htdocs/js/apps/GraphTool/cubictool.js b/htdocs/js/apps/GraphTool/cubictool.js new file mode 100644 index 0000000000..b059f9724a --- /dev/null +++ b/htdocs/js/apps/GraphTool/cubictool.js @@ -0,0 +1,390 @@ +/* global graphTool, JXG */ + +(() => { + if (graphTool && graphTool.cubicTool) return; + + graphTool.cubicTool = { + Cubic: { + preInit(gt, point1, point2, point3, point4, solid) { + [point1, point2, point3, point4].forEach((point) => { + point.setAttribute(gt.definingPointAttributes); + point.on('down', () => gt.board.containerObj.style.cursor = 'none'); + point.on('up', () => gt.board.containerObj.style.cursor = 'auto'); + }); + return gt.graphObjectTypes.cubic.createCubic(point1, point2, point3, point4, solid, gt.color.curve); + }, + + postInit(_gt, point1, point2, point3, point4) { + this.definingPts.push(point1, point2, point3, point4); + this.focusPoint = point1; + }, + + handleKeyEvent(gt, e, el) { + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; + + // Make sure that this point is not moved onto the same vertical line as another point. + const pointIndex = this.definingPts.findIndex((pt) => pt.id === el.id); + if (pointIndex > -1) { + let x = el.X(); + const dir = (e.key === 'ArrowLeft' ? -1 : 1) * gt.snapSizeX; + + while (this.definingPts.some((other, i) => i !== pointIndex && x === other.X())) x += dir; + + // If the computed new x coordinate is off the board, then we need to move the point back instead. + const boundingBox = gt.board.getBoundingBox(); + if (x < boundingBox[0] || x > boundingBox[2]) { + x = el.X() - dir; + while (this.definingPts.some((other, i) => i !== pointIndex && x === other.X())) x -= dir; + } + + el.setPosition(JXG.COORDS_BY_USER, [x, el.Y()]); + gt.board.update(); + } + }, + + stringify(gt) { + return [ + this.baseObj.getAttribute('dash') == 0 ? 'solid' : 'dashed', + ...this.definingPts.map( + (point) => `(${gt.snapRound(point.X(), gt.snapSizeX)},${gt.snapRound(point.Y(), gt.snapSizeY)})` + ) + ].join(','); + }, + + fillCmp(gt, point) { + return gt.sign(point[2] - this.baseObj.Y(point[1])); + }, + + restore(gt, string) { + let pointData = gt.pointRegexp.exec(string); + const points = []; + while (pointData) { + points.push(pointData.slice(1, 3)); + pointData = gt.pointRegexp.exec(string); + } + if (points.length < 4) return false; + var point1 = gt.graphObjectTypes.cubic.createPoint( + parseFloat(points[0][0]), parseFloat(points[0][1])); + var point2 = gt.graphObjectTypes.cubic.createPoint( + parseFloat(points[1][0]), parseFloat(points[1][1]), [point1]); + var point3 = gt.graphObjectTypes.cubic.createPoint( + parseFloat(points[2][0]), parseFloat(points[2][1]), [point1, point2]); + var point4 = gt.graphObjectTypes.cubic.createPoint( + parseFloat(points[3][0]), parseFloat(points[3][1]), [point1, point2, point3]); + return new gt.graphObjectTypes.cubic(point1, point2, point3, point4, /solid/.test(string)); + }, + + helperMethods: { + createParabola(gt, point1, point2, point3, solid, color) { + return gt.board.create('curve', [ + // x and y coordinates of point on curve + (x) => x, + (x) => { + const x1 = point1.X(), x2 = point2.X(), x3 = point3.X(), + y1 = point1.Y(), y2 = point2.Y(), y3 = point3.Y(); + return (x - x2) * (x - x3) * y1 / ((x1 - x2) * (x1 - x3)) + + (x - x1) * (x - x3) * y2 / ((x2 - x1) * (x2 - x3)) + + (x - x1) * (x - x2) * y3 / ((x3 - x1) * (x3 - x2)); + }, + // domain minimum and maximum + () => gt.board.getBoundingBox()[0], () => gt.board.getBoundingBox()[2] + ], { + strokeWidth: 2, highlight: false, strokeColor: color ? color : gt.color.underConstruction, + dash: solid ? 0 : 2 + }); + }, + + createCubic(gt, point1, point2, point3, point4, solid, color) { + return gt.board.create('curve', [ + // x and y coordinate of point on curve + (x) => x, + (x) => { + const x1 = point1.X(), x2 = point2.X(), x3 = point3.X(), x4 = point4.X(), + y1 = point1.Y(), y2 = point2.Y(), y3 = point3.Y(), y4 = point4.Y(); + return (x - x2) * (x - x3) * (x - x4) * y1 / ((x1 - x2) * (x1 - x3) * (x1 - x4)) + + (x - x1) * (x - x3) * (x - x4) * y2 / ((x2 - x1) * (x2 - x3) * (x2 - x4)) + + (x - x1) * (x - x2) * (x - x4) * y3 / ((x3 - x1) * (x3 - x2) * (x3 - x4)) + + (x - x1) * (x - x2) * (x - x3) * y4 / ((x4 - x1) * (x4 - x2) * (x4 - x3)); + }, + // domain minimum and maximum + () => gt.board.getBoundingBox()[0], () => gt.board.getBoundingBox()[2] + ], { + strokeWidth: 2, highlight: false, strokeColor: color ? color : gt.color.underConstruction, + dash: solid ? 0 : 2 + }); + }, + + pairedPointDrag(gt, e) { + const coords = gt.getMouseCoords(e); + let left_x = this.X(), right_x = this.X(); + + while (this.paired_points.some((pairedPoint) => left_x == pairedPoint.X())) + left_x -= gt.snapSizeX; + while (this.paired_points.some((pairedPoint) => right_x == pairedPoint.X())) + right_x += gt.snapSizeX; + + if (this.X() != left_x && this.X() != right_x) { + const left_dist = Math.abs(coords.usrCoords[1] - left_x); + const right_dist = Math.abs(coords.usrCoords[1] - right_x); + this.setPosition(JXG.COORDS_BY_USER, [ + left_x < gt.board.getBoundingBox()[0] ? right_x + : (left_dist < right_dist || right_x > gt.board.getBoundingBox()[2]) ? left_x : right_x, + this.Y() + ]); + } + gt.updateObjects(); + gt.updateText(); + }, + + createPoint(gt, x, y, paired_points) { + const point = gt.board.create('point', [x, y], { + size: 2, snapToGrid: true, snapSizeX: gt.snapSizeX, snapSizeY: gt.snapSizeY, withLabel: false + }); + if (typeof paired_points !== 'undefined' && paired_points.length) { + point.paired_points = []; + paired_points.forEach((paired_point) => { + point.paired_points.push(paired_point); + if (!paired_point.paired_points) { + paired_point.paired_points = []; + paired_point.on('drag', gt.graphObjectTypes.cubic.pairedPointDrag); + } + paired_point.paired_points.push(point); + if (!paired_point.eventHandlers.drag || + paired_point.eventHandlers.drag.every((dragHandler) => + dragHandler.handler !== gt.graphObjectTypes.cubic.pairedPointDrag) + ) + paired_point.on('drag', gt.graphObjectTypes.cubic.pairedPointDrag); + }); + point.on('drag', gt.graphObjectTypes.cubic.pairedPointDrag, point); + } + return point; + } + } + }, + + CubicTool: { + iconName: 'cubic', + tooltip: '4-Point Cubic Tool', + + initialize(gt) { + this.phase1 = (coords) => { + // Don't allow the point to be created off the board. + if (!gt.boardHasPoint(coords[1], coords[2])) return; + + gt.board.off('up'); + + this.point1 = gt.graphObjectTypes.cubic.createPoint(coords[1], coords[2]); + this.point1.setAttribute({ fixed: true }); + + // Get a new x coordinate that is to the right, unless that is off the board. + // In that case go left instead. + let newX = this.point1.X() + gt.snapSizeX; + if (newX > gt.board.getBoundingBox()[2]) newX = this.point1.X() - gt.snapSizeX; + + this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [newX, this.point1.Y()], gt.board)); + + gt.board.on('up', (e) => this.phase2(gt.getMouseCoords(e).usrCoords)); + + gt.board.update(); + }; + + this.phase2 = (coords) => { + // Don't allow the second point to be created on the same + // vertical line as the first point or off the board. + if (this.point1.X() == gt.snapRound(coords[1], gt.snapSizeX) || + !gt.boardHasPoint(coords[1], coords[2])) + return; + + gt.board.off('up'); + + this.point2 = gt.graphObjectTypes.cubic.createPoint(coords[1], coords[2], [this.point1]); + this.point2.setAttribute({ fixed: true }); + + // Get a new x coordinate that is to the right, unless that is off the board. + // In that case go left instead. + let newX = this.point2.X() + gt.snapSizeX; + if (newX === this.point1.X()) newX += gt.snapSizeX; + + if (newX > gt.board.getBoundingBox()[2]) newX = this.point2.X() - gt.snapSizeX; + if (newX === this.point1.X()) newX -= gt.snapSizeX; + + this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [newX, this.point2.Y()], gt.board)); + + gt.board.on('up', (e) => this.phase3(gt.getMouseCoords(e).usrCoords)); + + gt.board.update(); + }; + + this.phase3 = (coords) => { + // Don't allow the third point to be created on the same vertical line as the + // first point, on the same vertical line as the second point, or off the board. + if (this.point1.X() == gt.snapRound(coords[1], gt.snapSizeX) || + this.point2.X() == gt.snapRound(coords[1], gt.snapSizeX) || + !gt.boardHasPoint(coords[1], coords[2])) + return; + + gt.board.off('up'); + this.point3 = gt.graphObjectTypes.cubic.createPoint(coords[1], coords[2], + [this.point1, this.point2]); + this.point3.setAttribute({ fixed: true }); + + // Get a new x coordinate that is to the right, unless that is off the board. + // In that case go left instead. + let newX = this.point3.X() + gt.snapSizeX; + while ([this.point1, this.point2].some((other, i) => newX === other.X())) x += gt.snapSizeX; + + // If the computed new x coordinate is off the board, then we need to move the point back instead. + const boundingBox = gt.board.getBoundingBox(); + if (newX < boundingBox[0] || newX > boundingBox[2]) { + x = this.point3.X() - gt.snapSizeX; + while ([this.point1, this.point2].some((other, i) => x === other.X())) x -= gt.snapSizeX; + } + + this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [newX, this.point3.Y()], gt.board)); + + gt.board.on('up', (e) => this.phase4(gt.getMouseCoords(e).usrCoords)); + + gt.board.update(); + }; + + this.phase4 = (coords) => { + // Don't allow the fourth point to be created on the same vertical line as the first + // point, on the same vertical line as the second point, on the same vertical line as + // the third point, or off the board. + if (this.point1.X() == gt.snapRound(coords[1], gt.snapSizeX) || + this.point2.X() == gt.snapRound(coords[1], gt.snapSizeX) || + this.point3.X() == gt.snapRound(coords[1], gt.snapSizeX) || + !gt.boardHasPoint(coords[1], coords[2])) + return; + + gt.board.off('up'); + + const point4 = gt.graphObjectTypes.cubic.createPoint(coords[1], coords[2], + [this.point1, this.point2, this.point3]); + gt.selectedObj = new gt.graphObjectTypes.cubic(this.point1, this.point2, this.point3, point4, + gt.drawSolid); + gt.selectedObj.focusPoint = point4; + gt.graphedObjs.push(gt.selectedObj); + delete this.point1; + delete this.point2; + delete this.point3; + + this.finish(); + }; + }, + + handleKeyEvent(gt, e) { + if (!this.hlObjs.hl_point || !gt.board.containerObj.contains(document.activeElement)) return; + + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + + if (this.point3) this.phase4(this.hlObjs.hl_point.coords.usrCoords); + else if (this.point2) this.phase3(this.hlObjs.hl_point.coords.usrCoords); + else if (this.point1) this.phase2(this.hlObjs.hl_point.coords.usrCoords); + else this.phase1(this.hlObjs.hl_point.coords.usrCoords); + } else if (['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp'].includes(e.key)) { + if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { + // Make sure the highlight point is not moved onto the same vertical line as any of the other + // points that have already been created. + const others = []; + if (this.point1) others.push(this.point1); + if (this.point2) others.push(this.point2); + if (this.point3) others.push(this.point3); + + let x = this.hlObjs.hl_point.X(); + while (others.some((other) => x === other.X())) + x += (e.key === 'ArrowRight' ? 1 : -1) * gt.snapSizeX; + + // If the computed new x coordinate is off the board, + // then we need to move the point back instead. + const boundingBox = gt.board.getBoundingBox(); + if (x < boundingBox[0] || x > boundingBox[2]) { + x = this.hlObjs.hl_point.X(); + while (others.some((other) => x === other.X())) + x += (e.key === 'ArrowRight' ? -1 : 1) * gt.snapSizeX; + } + + if (x !== this.hlObjs.hl_point.X()) + this.hlObjs.hl_point.setPosition(JXG.COORDS_BY_USER, [x, this.hlObjs.hl_point.Y()]); + } + + this.updateHighlights(this.hlObjs.hl_point.coords); + } + }, + + updateHighlights(gt, coords) { + if (this.hlObjs.hl_line) this.hlObjs.hl_line.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); + if (this.hlObjs.hl_parabola) this.hlObjs.hl_parabola.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); + if (this.hlObjs.hl_cubic) this.hlObjs.hl_cubic.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); + this.hlObjs.hl_point?.rendNode.focus(); + + if (typeof coords === 'undefined') return; + + const new_x = gt.snapRound(coords.usrCoords[1], gt.snapSizeX); + if ((this.point1 && new_x == this.point1.X()) || + (this.point2 && new_x == this.point2.X()) || + (this.point3 && new_x == this.point3.X())) + return; + + if (this.hlObjs.hl_point) { + this.hlObjs.hl_point.setPosition(JXG.COORDS_BY_USER, [coords.usrCoords[1], coords.usrCoords[2]]); + } else { + this.hlObjs.hl_point = gt.board.create('point', [coords.usrCoords[1], coords.usrCoords[2]], { + size: 2, color: gt.color.underConstruction, snapToGrid: true, + snapSizeX: gt.snapSizeX, snapSizeY: gt.snapSizeY, + highlight: false, withLabel: false + }); + this.hlObjs.hl_point.rendNode.focus(); + } + + if (this.point3 && !this.hlObjs.hl_cubic) { + // Delete the temporary highlight parabola if it exists. + if (this.hlObjs.hl_parabola) { + gt.board.removeObject(this.hlObjs.hl_parabola); + delete this.hlObjs.hl_parabola; + } + + this.hlObjs.hl_cubic = gt.graphObjectTypes.cubic.createCubic( + this.point1, this.point2, this.point3, this.hlObjs.hl_point, gt.drawSolid); + } else if (this.point2 && !this.point3 && !this.hlObjs.hl_parabola) { + // Delete the temporary highlight line if it exists. + if (this.hlObjs.hl_line) { + gt.board.removeObject(this.hlObjs.hl_line); + delete this.hlObjs.hl_line; + } + + this.hlObjs.hl_parabola = gt.graphObjectTypes.cubic.createParabola( + this.point1, this.point2, this.hlObjs.hl_point, gt.drawSolid); + } else if (this.point1 && !this.point2 && !this.hlObjs.hl_line) { + this.hlObjs.hl_line = gt.board.create('line', [this.point1, this.hlObjs.hl_point], { + fixed: true, strokeColor: gt.color.underConstruction, highlight: false, + dash: gt.drawSolid ? 0 : 2 + }); + } + + gt.setTextCoords(this.hlObjs.hl_point.X(), this.hlObjs.hl_point.Y()); + gt.board.update(); + }, + + deactivate(gt) { + gt.board.off('up'); + ['point1', 'point2', 'point3'].forEach(function(point) { + if (this[point]) gt.board.removeObject(this[point]); + delete this[point]; + }, this); + gt.board.containerObj.style.cursor = 'auto'; + }, + + activate(gt) { + gt.board.containerObj.style.cursor = 'none'; + + // Draw a highlight point on the board. + this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [0, 0], gt.board)); + + gt.board.on('up', (e) => this.phase1(gt.getMouseCoords(e).usrCoords)); + } + } + }; +})(); diff --git a/htdocs/js/apps/GraphTool/graphtool.js b/htdocs/js/apps/GraphTool/graphtool.js new file mode 100644 index 0000000000..4bd6f88b62 --- /dev/null +++ b/htdocs/js/apps/GraphTool/graphtool.js @@ -0,0 +1,2018 @@ +/* global JXG, bootstrap */ + +'use strict'; + +window.graphTool = (containerId, options) => { + // Do nothing if the graph has already been created. + if (document.getElementById(`${containerId}_graph`)) return; + + const gt = {}; + + gt.graphContainer = document.getElementById(containerId); + if (getComputedStyle(gt.graphContainer)?.width === '') { + setTimeout(() => window.graphTool(containerId, options), 100); + return; + } + + // Semantic color control + gt.color = { + // dark blue + // > 13:1 with white + curve: '#0000a6', + + // blue + // > 9:1 with white + focusCurve: '#0000f5', + + // fill color must use 6-digit hex + // medium purple + // 3:1 with white + // 4.5:1 with #0000a6 + // > 3:1 with #0000f5 + fill: '#a384e5', + + // strict contrast ratios are less important for these colors + point: 'orange', + pointHighlight: 'yellow', + underConstruction: 'orange' + }; + + gt.definingPointAttributes = { + size: 3, + fixed: false, + withLabel: false, + strokeWidth: 1, + strokeColor: gt.color.focusCurve, + fillColor: gt.color.point, + highlightStrokeWidth: 1, + highlightStrokeColor: gt.color.focusCurve, + highlightFillColor: gt.color.pointHighlight + }; + + gt.snapSizeX = options.snapSizeX ? options.snapSizeX : 1; + gt.snapSizeY = options.snapSizeY ? options.snapSizeY : 1; + gt.isStatic = options.isStatic ? true : false; + const availableTools = options.availableTools ? options.availableTools + : ['LineTool', 'CircleTool', 'VerticalParabolaTool', 'HorizontalParabolaTool', 'FillTool', 'SolidDashTool']; + + // These are the icons used for the fill tool and fill graph object. + gt.fillIcon = + "data:image/svg+xml,%3Csvg xmlns:dc='http://purl.org/dc/elements/1.1/' xmlns:cc='http://creativecommons.org/ns%23' xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns%23' xmlns:svg='http://www.w3.org/2000/svg' xmlns='http://www.w3.org/2000/svg' id='SVGRoot' version='1.1' viewBox='0 0 32 32' height='32px' width='32px'%3E%3Cdefs id='defs815' /%3E%3Cmetadata id='metadata818'%3E%3Crdf:RDF%3E%3Ccc:Work rdf:about=''%3E%3Cdc:format%3Eimage/svg+xml%3C/dc:format%3E%3Cdc:type rdf:resource='http://purl.org/dc/dcmitype/StillImage' /%3E%3Cdc:title%3E%3C/dc:title%3E%3C/cc:Work%3E%3C/rdf:RDF%3E%3C/metadata%3E%3Cg id='layer1'%3E%3Cpath id='path1382' d='m 13.466084,10.267728 -4.9000003,8.4 4.9000003,4.9 8.4,-4.9 z' style='opacity:1;fill:" + + gt.color.fill.replace(/#/, '%23') + + ";fill-opacity:1;stroke:%23000000;stroke-width:1.3;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none' /%3E%3Cpath id='path1384' d='M 16.266084,15.780798 V 6.273173' style='fill:none;stroke:%23000000;stroke-width:1.38;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1' /%3E%3Cpath id='path1405' d='m 20,16 c 0,0 2,-1 3,0 1,0 1,1 2,2 0,1 0,2 0,3 0,1 0,2 0,2 0,0 -1,0 -1,0 -1,-1 -1,-1 -1,-2 0,-1 0,-1 -1,-2 0,-1 0,-2 -1,-2 -1,-1 -2,-1 -1,-1 z' style='fill:%230900ff;fill-opacity:1;stroke:%23000000;stroke-width:0.7px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1' /%3E%3C/g%3E%3C/svg%3E"; + + gt.fillIconFocused = + "data:image/svg+xml,%3Csvg xmlns:dc='http://purl.org/dc/elements/1.1/' xmlns:cc='http://creativecommons.org/ns%23' xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns%23' xmlns:svg='http://www.w3.org/2000/svg' xmlns='http://www.w3.org/2000/svg' id='SVGRoot' version='1.1' viewBox='0 0 32 32' height='32px' width='32px'%3E%3Cdefs id='defs815' /%3E%3Cmetadata id='metadata818'%3E%3Crdf:RDF%3E%3Ccc:Work rdf:about=''%3E%3Cdc:format%3Eimage/svg+xml%3C/dc:format%3E%3Cdc:type rdf:resource='http://purl.org/dc/dcmitype/StillImage' /%3E%3Cdc:title%3E%3C/dc:title%3E%3C/cc:Work%3E%3C/rdf:RDF%3E%3C/metadata%3E%3Cg id='layer1'%3E%3Cpath id='path1382' d='m 13.466084,10.267728 -4.9000003,8.4 4.9000003,4.9 8.4,-4.9 z' style='opacity:1;fill:" + + gt.color.pointHighlight.replace(/#/, '%23') + + ";fill-opacity:1;stroke:%23000000;stroke-width:1.3;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none' /%3E%3Cpath id='path1384' d='M 16.266084,15.780798 V 6.273173' style='fill:none;stroke:%23000000;stroke-width:1.38;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1' /%3E%3Cpath id='path1405' d='m 20,16 c 0,0 2,-1 3,0 1,0 1,1 2,2 0,1 0,2 0,3 0,1 0,2 0,2 0,0 -1,0 -1,0 -1,-1 -1,-1 -1,-2 0,-1 0,-1 -1,-2 0,-1 0,-2 -1,-2 -1,-1 -2,-1 -1,-1 z' style='fill:%230900ff;fill-opacity:1;stroke:%23000000;stroke-width:0.7px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1' /%3E%3C/g%3E%3C/svg%3E"; + + if ('htmlInputId' in options) gt.html_input = document.getElementById(options.htmlInputId); + const cfgOptions = { + title: 'WeBWorK Graph Tool', + description: options.ariaDescription ?? 'Interactively graph objects', + showCopyright: false, + pan: { enabled: false }, + zoom: { enabled: false }, + showNavigation: false, + boundingBox: [-10, 10, 10, -10], + defaultAxes: {}, + axis: { + ticks: { + label: { highlight: false }, + insertTicks: false, + ticksDistance: 2, + minorTicks: 1, + minorHeight: 6, + majorHeight: 6, + tickEndings: [1, 1] + }, + highlight: false, + firstArrow: { size: 7 }, + lastArrow: { size: 7 }, + straightFirst: false, + straightLast: false, + fixed: true + }, + grid: { gridX: gt.snapSizeX, gridY: gt.snapSizeY }, + keyboard: { + enabled: true, + dx: gt.snapSizeX, + dy: gt.snapSizeY, + panShift: false + } + }; + + // Deep extend utility function. This should be good enough for what is needed here. + const extend = (out, obj) => { + for (const prop in obj) { + if (obj.hasOwnProperty(prop)) { + if (typeof obj[prop] === 'object' && typeof out[prop] === 'object') extend(out[prop], obj[prop]); + else out[prop] = obj[prop]; + } + } + }; + + // Merge options that are set by the problem. + if (typeof options.JSXGraphOptions === 'object') extend(cfgOptions, options.JSXGraphOptions); + + const setupBoard = () => { + gt.board = JXG.JSXGraph.initBoard(`${containerId}_graph`, cfgOptions); + gt.board.suspendUpdate(); + + // Move the axes defining points to the end so that the arrows go to the board edges. + const bbox = gt.board.getBoundingBox(); + gt.board.defaultAxes.x.point1.setPosition(JXG.COORDS_BY_USER, [bbox[0], 0]); + gt.board.defaultAxes.x.point2.setPosition(JXG.COORDS_BY_USER, [bbox[2], 0]); + gt.board.defaultAxes.y.point1.setPosition(JXG.COORDS_BY_USER, [0, bbox[3]]); + gt.board.defaultAxes.y.point2.setPosition(JXG.COORDS_BY_USER, [0, bbox[1]]); + + // Add labels to the x and y axes. + if (options.xAxisLabel) { + gt.board.create( + 'text', + [ + () => gt.board.getBoundingBox()[2] - 3 / gt.board.unitX, + () => 1.5 / gt.board.unitY, + () => `\\(${options.xAxisLabel}\\)` + ], + { anchorX: 'right', anchorY: 'bottom', highlight: false, color: 'black', fixed: true, useMathJax: true } + ); + } + if (options.yAxisLabel) { + gt.board.create( + 'text', + [ + () => 4.5 / gt.board.unitX, + () => gt.board.getBoundingBox()[1] + 2.5 / gt.board.unitY, + () => `\\(${options.yAxisLabel}\\)` + ], + { anchorX: 'left', anchorY: 'top', highlight: false, color: 'black', fixed: true, useMathJax: true } + ); + } + + // Add an empty text that will hold the cursor position. + gt.current_pos_text = gt.board.create( + 'text', + [ + () => gt.board.getBoundingBox()[2] - 5 / gt.board.unitX, + () => gt.board.getBoundingBox()[3] + 5 / gt.board.unitY, + '' + ], + { anchorX: 'right', anchorY: 'bottom', fixed: true } + ); + + // Overwrite the popup infobox for points. + gt.board.highlightInfobox = (x, y, el) => gt.board.highlightCustomInfobox('', el); + + if (!gt.isStatic) { + gt.graphContainer.tabIndex = -1; + gt.board.containerObj.tabIndex = -1; + + gt.board.on('move', (e) => { + const coords = gt.getMouseCoords(e); + if (gt.activeTool?.updateHighlights(coords)) return; + if (!gt.selectedObj || !gt.selectedObj.updateTextCoords(coords)) + gt.setTextCoords(coords.usrCoords[1], coords.usrCoords[2]); + }); + + gt.board.on('hit', (e, el) => { + switch (e.type) { + case 'focusin': + if (el === gt.board.containerObj.id) { + if (gt.board.containerObj.contains(e.relatedTarget)) { + // Prevent the board from gaining focus and place the + // focus back onto where it was coming from. + e.preventDefault(); + e.stopPropagation(); + e.relatedTarget.focus(); + } + } else { + // Update the focus point for whichever object the focused point belongs to. + gt.graphedObjs.some((obj) => + obj.definingPts.some((point) => { + if (point.id === el.id) { + obj.focusPoint = point; + return true; + } + }) + ); + } + break; + } + }); + + gt.hasFocus = false; + gt.preventFocusLoss = false; + gt.objectFocusSet = false; + + gt.board.containerObj.addEventListener('focus', (e) => gt.hasFocus = true); + + gt.graphContainer.addEventListener('focusin', (e) => { + e.preventDefault(); + e.stopPropagation(); + if (!gt.graphContainer.contains(e.relatedTarget) && !gt.hasFocus) { + // Focus has entered from outside the container. + if (e.target === gt.graphContainer || e.target === gt.selectTool.button) { + // In this case focus has entered the container from above. + if (gt.graphedObjs.length) { + gt.objectFocusSet = true; + gt.selectedObj = gt.graphedObjs[0]; + gt.selectedObj.focusPoint = gt.selectedObj?.definingPts[0]; + } else { + gt.objectFocusSet = false; + gt.buttonBox.querySelectorAll('.gt-button')[1]?.focus(); + } + } else { + // In this case focus has entered the container from below. + gt.objectFocusSet = false; + setTimeout(() => e.target?.focus()); + } + + gt.hasFocus = true; + if (!gt.activeTool) gt.selectTool.activate(); + } else if (gt.board.containerObj.contains(e.relatedTarget) && gt.graphContainer === e.target) { + // If the graph container is the explicit target and focus is coming from something on the board, + // then send focus back to the object on the board it came from. + e.relatedTarget.focus(); + } else { + gt.objectFocusSet = false; + } + }); + + gt.board.containerObj.addEventListener('focusin', (e) => { + e.preventDefault(); + e.stopPropagation(); + + if (e.relatedTarget !== gt.board.containerObj && + (gt.buttonBox.contains(e.relatedTarget) || + (gt.board.containerObj.contains(e.relatedTarget) && + gt.graphedObjs.every( + (obj) => obj.definingPts.every((point) => point.rendNode !== e.relatedTarget)))) && + gt.graphedObjs.some((obj) => obj.definingPts.some((point) => point.rendNode === e.target)) + ) { + if (!gt.objectFocusSet) { + gt.objectFocusSet = true; + const lastSelected = gt.selectedObj; + gt.selectedObj = gt.graphedObjs[gt.graphedObjs.length - 1]; + gt.selectedObj.focusPoint = gt.selectedObj.definingPts[gt.selectedObj.definingPts.length - 1]; + gt.selectedObj.focus(); + if (lastSelected !== gt.selectedObj) lastSelected?.blur(); + } + } else if (!gt.board.containerObj.contains(e.relatedTarget)) { + let lastSelected; + if (!gt.hasFocus && gt.graphedObjs.length) { + lastSelected = gt.selectedObj; + if (gt.graphContainer.contains(e.relatedTarget)) { + gt.selectedObj = gt.graphedObjs[gt.graphedObjs.length - 1]; + gt.selectedObj.focusPoint = + gt.selectedObj.definingPts[gt.selectedObj.definingPts.length - 1]; + } else { + gt.selectedObj = gt.graphedObjs[0]; + gt.selectedObj.focusPoint = gt.selectedObj?.definingPts[0]; + } + gt.objectFocusSet = true; + } else + gt.objectFocusSet = false; + + gt.hasFocus = true; + if (!gt.activeTool) gt.selectTool.activate(); + if (lastSelected !== gt.selectedObj) lastSelected?.blur(); + } + }); + + gt.board.containerObj.addEventListener('pointerdown', (e) => { + if (gt.activeTool) return; + const coords = gt.getMouseCoords(e).scrCoords.slice(1); + + // Check to see if a defining point of a graphed object was clicked on. + // If so focus the object and that point. + for (const obj of gt.graphedObjs) { + if (obj.baseObj.rendNode === e.target) { + for (const point of obj.definingPts) { + if (point.rendNode === e.target) { + gt.hasFocus = true; + gt.selectedObj = obj; + gt.selectTool.activate(); + // If a focus point was found, then resend this event so that jsxgraph + // will start a drag if the pointer is held down. + obj.focusPoint?.rendNode.dispatchEvent(new PointerEvent('pointerdown', e)); + return; + } + } + } + } + + // Check to see if the pointer is on an object, in which case focus that. This focuses the first object + // found searching in the order that the objecs were graphed. + for (const obj of gt.graphedObjs) { + if (obj.baseObj.hasPoint(...coords)) { + for (const point of obj.definingPts) { + if (point.hasPoint(...coords)) { + obj.focusPoint = point; + break; + } + } + gt.hasFocus = true; + gt.selectedObj = obj; + gt.selectTool.activate(); + // If a focus point was found, then resend this event so that jsxgraph + // will start a drag if the pointer is held down. + obj.focusPoint?.rendNode.dispatchEvent(new PointerEvent('pointerdown', e)); + return; + } + } + }); + + gt.graphContainer.addEventListener('focusout', (e) => { + if (!gt.graphContainer.contains(e.relatedTarget) && !gt.preventFocusLoss) { + // Focus is being lost to something outside the container. + // So deactivate any active tool and blur any selected object. + gt.hasFocus = false; + gt.objectFocusSet = false; + gt.activeTool?.deactivate(); + delete gt.activeTool; + // Hide tooltips that have been shown. This seems to only be needed for touch screen devices. + gt.tooltips.forEach((tooltip) => tooltip.hide()); + } + }); + + gt.graphContainer.addEventListener('keydown', (e) => { + if (gt.activeTool === gt.selectTool && + gt.board.containerObj.contains(document.activeElement) && gt.graphedObjs.length && + ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key) + ) { + gt.graphedObjs.some((obj) => { + const el = obj.definingPts.find((point) => point.rendNode === e.target); + if (el) { + obj.handleKeyEvent(e, el); + gt.updateObjects(); + gt.updateText(); + if (!gt.activeTool?.updateHighlights(el.coords)) { + if (!gt.selectedObj || !gt.selectedObj.updateTextCoords(el.coords)) + gt.setTextCoords(el.coords.usrCoords[1], el.coords.usrCoords[2]); + } + } + }); + } else { + gt.activeTool?.handleKeyEvent(e); + } + + if (!gt.buttonBox.contains(document.activeElement) && e.key === 'N' && e.shiftKey) { + // Shift-N moves focus to the first tool button after the select button unless the tool bar already + // has the focused element. (The select button is disabled at this point, so focus can't go there.) + gt.buttonBox.querySelectorAll('.gt-button:not([disabled])')[0]?.focus(); + } else if (e.key === 'Escape' && gt.activeTool !== gt.selectTool) { + // Escape deactivates any active tool except the select tool. + gt.selectTool.activate(); + } else if (e.key === 'Delete' && e.ctrlKey) { + // If the Ctrl-Delete is pressed, then ask to delete all objects. + gt.clearAll(); + } else if (e.key === 'Delete' && gt.activeTool === gt.selectTool) { + // If the select tool is active and Delete is pressed, then ask to delete the selected object. + gt.deleteSelected(); + } else if (e.key === 's') { + // If 's' is pressed change to drawing solid. + gt.toggleSolidity(e, true) + } else if (e.key === 'd') { + // If 'd' is pressed change to drawing dashed. + gt.toggleSolidity(e, false) + } + }); + } + + window.addEventListener('resize', () => { + if (gt.board.canvasWidth != gt.board.containerObj.offsetWidth - 2 || + gt.board.canvasHeight != gt.board.containerObj.offsetHeight - 2) + { + gt.board.resizeContainer( + gt.board.containerObj.offsetWidth - 2, gt.board.containerObj.offsetHeight - 2, true); + gt.graphedObjs.forEach((object) => object.onResize()); + gt.staticObjs.forEach((object) => object.onResize()); + } + }); + + gt.drawSolid = true; + gt.graphedObjs = []; + gt.staticObjs = []; + + gt.board.unsuspendUpdate(); + }; + + // Some utility functions. + gt.snapRound = (x, snap) => Math.round(Math.round(x / snap) * snap * 100000) / 100000; + + gt.setTextCoords = options.showCoordinateHints + ? (x, y) => gt.current_pos_text.setText(`(${gt.snapRound(x, gt.snapSizeX)}, ${gt.snapRound(y, gt.snapSizeY)})`) + : () => {}; + + gt.updateText = () => { + gt.html_input.value = gt.graphedObjs.reduce( + (val, obj) => `${val}${val.length ? ',' : ''}{${obj.stringify()}}`, '' + ); + }; + + gt.updateUI = () => { + gt.deleteButton.disabled = !gt.selectedObj; + gt.clearButton.disabled = !gt.graphedObjs.length; + }; + + gt.getMouseCoords = (e) => { + const cPos = gt.board.getCoordsTopLeftCorner(), + absPos = JXG.getPosition(e, e[JXG.touchProperty] ? 0 : undefined); + + return new JXG.Coords(JXG.COORDS_BY_SCREEN, [absPos[0] - cPos[0], absPos[1] - cPos[1]], gt.board); + }; + + gt.sign = (x) => { + x = +x; + if (Math.abs(x) < JXG.Math.eps) return 0; + return x > 0 ? 1 : -1; + }; + + // Use this instead of gt.board.hasPoint. That method uses strict inequality. + // Using inequality with equality allows points on the edge of the board. + gt.boardHasPoint = (x, y) => { + const bbox = gt.board.getBoundingBox(); + return x >= bbox[0] && x <= bbox[2] && y <= bbox[1] && y >= bbox[3]; + }; + + gt.pointRegexp = /\( *(-?[0-9]*(?:\.[0-9]*)?), *(-?[0-9]*(?:\.[0-9]*)?) *\)/g; + + // This method makes the actual adjustment for the gt.keyboardMovementAdjust and + // gt.keyboardMovementAdjustRestricted methods below. + gt.keyboardMovementAdjustPosition = (key, point1) => { + let x = point1.X() + (key === 'ArrowLeft' ? -1 : key === 'ArrowRight' ? 1 : 0) * gt.snapSizeX; + let y = point1.Y() + (key === 'ArrowUp' ? 1 : key === 'ArrowDown' ? -1 : 0) * gt.snapSizeY; + + // If the computed new coordinates are off the board, then we need to move the point back instead. + const boundingBox = gt.board.getBoundingBox(); + if (x < boundingBox[0]) x = boundingBox[0] + gt.snapSizeX; + else if (x > boundingBox[2]) x = boundingBox[2] - gt.snapSizeX; + if (y < boundingBox[3]) y = boundingBox[3] + gt.snapSizeY; + else if (y > boundingBox[1]) y = boundingBox[1] - gt.snapSizeY; + + point1.setPosition(JXG.COORDS_BY_USER, [x, y]); + gt.board.update(); + }; + + // Prevent paired points from being moved into the same position by a drag. This + // prevents lines and circles from being made degenerate. + gt.pairedPointDrag = (point, e) => { + if (point.X() == point.paired_point.X() && point.Y() == point.paired_point.Y()) { + const coords = gt.getMouseCoords(e); + const x_trans = coords.usrCoords[1] - point.paired_point.X(), + y_trans = coords.usrCoords[2] - point.paired_point.Y(); + if (y_trans > Math.abs(x_trans)) + point.setPosition(JXG.COORDS_BY_USER, [point.X(), point.Y() + gt.snapSizeY]); + else if (x_trans > Math.abs(y_trans)) + point.setPosition(JXG.COORDS_BY_USER, [point.X() + gt.snapSizeX, point.Y()]); + else if (x_trans < -Math.abs(y_trans)) + point.setPosition(JXG.COORDS_BY_USER, [point.X() - gt.snapSizeX, point.Y()]); + else + point.setPosition(JXG.COORDS_BY_USER, [point.X(), point.Y() - gt.snapSizeY]); + } + gt.updateObjects(); + gt.updateText(); + }; + + // This does much the same as the above method, except for keyboard movement of a point. Note that for this method, + // point1 has already moved, but if that point is now located at the same place as point2, then point1 is made to + // jump over point2 (or back to where it came from if that is off the board). + gt.keyboardMovementAdjust = (key, point1, point2) => { + if (point1.X() === point2.X() && point1.Y() === point2.Y()) gt.keyboardMovementAdjustPosition(key, point1); + }; + + // Prevent paired points from being moved onto the same horizontal or vertical + // line by a drag. This prevents parabolas from being made degenerate. + gt.pairedPointDragRestricted = (point, e) => { + const coords = gt.getMouseCoords(e); + let new_x = point.X(), new_y = point.Y(); + if (point.X() == point.paired_point.X()) { + if (coords.usrCoords[1] > point.paired_point.X()) new_x += gt.snapSizeX; + else new_x -= gt.snapSizeX; + } + if (point.Y() == point.paired_point.Y()) { + if (coords.usrCoords[2] > point.paired_point.Y()) new_y += gt.snapSizeX; + else new_y -= gt.snapSizeX; + } + if (point.X() == point.paired_point.X() || point.Y() == point.paired_point.Y()) + point.setPosition(JXG.COORDS_BY_USER, [new_x, new_y]); + gt.updateObjects(); + gt.updateText(); + }; + + // This does much the same as the above method, except for keyboard movement of a point. Note that for this method, + // point1 has already moved, but if that point is now located on the same horizontal or vertical line as point2, + // then point1 is made to jump over that line (or back to where it came from if that is off the board). + gt.keyboardMovementAdjustRestricted = (key, point1, point2) => { + if (point1.X() === point2.X() || point1.Y() === point2.Y()) gt.keyboardMovementAdjustPosition(key, point1); + }; + + gt.createPoint = (x, y, paired_point, restrict) => { + const point = gt.board.create('point', [x, y], + { snapToGrid: true, snapSizeX: gt.snapSizeX, snapSizeY: gt.snapSizeY, ...gt.definingPointAttributes }); + point.on('down', () => (gt.board.containerObj.style.cursor = 'none')); + point.on('up', () => (gt.board.containerObj.style.cursor = 'auto')); + if (typeof paired_point !== 'undefined') { + point.paired_point = paired_point; + paired_point.paired_point = point; + paired_point.on('drag', + restrict + ? (e) => gt.pairedPointDragRestricted(paired_point, e) + : (e) => gt.pairedPointDrag(paired_point, e) + ); + point.on('drag', + restrict + ? (e) => gt.pairedPointDragRestricted(point, e) + : (e) => gt.pairedPointDrag(point, e) + ); + } + return point; + }; + + gt.updateObjects = () => { + gt.graphedObjs.forEach((obj) => obj.update()); + gt.staticObjs.forEach((obj) => obj.update()); + }; + + // Generic graph object class from which all the specific graph objects + // derive. + class GraphObject { + constructor(jsxGraphObject) { + this.baseObj = jsxGraphObject; + this.definingPts = []; + // This is used to cache the last focused point for this object. If focus is + // returned by a pointer event then this point will be refocused. + this.focusPoint = null; + } + + handleKeyEvent(_e, _el) {} + + blur() { + this.definingPts.forEach((obj) => obj.setAttribute({ visible: false })); + this.baseObj.setAttribute({ strokeColor: gt.color.curve, strokeWidth: 2 }); + } + + focus() { + this.definingPts.forEach((obj) => obj.setAttribute({ visible: true })); + this.baseObj.setAttribute({ strokeColor: gt.color.focusCurve, strokeWidth: 3 }); + + // Focus the currently set point of focus for this object. + this.focusPoint?.rendNode.focus(); + + gt.drawSolid = this.baseObj.getAttribute('dash') == 0; + if (gt.solidButton) gt.solidButton.disabled = gt.drawSolid; + if (gt.dashedButton) gt.dashedButton.disabled = !gt.drawSolid; + } + + update() {} + + fillCmp(_point) { return 1; } + + remove() { + this.definingPts.forEach((point) => gt.board.removeObject(point)); + gt.board.removeObject(this.baseObj); + } + + setSolid(solid) { this.baseObj.setAttribute({ dash: solid ? 0 : 2 }); } + + stringify() { return ''; } + id() { return this.baseObj.id; } + on(e, handler, context) { this.baseObj.on(e, handler, context); } + off(e, handler) { this.baseObj.off(e, handler); } + onResize() {} + + updateTextCoords(coords) { + return !this.definingPts.every((point) => { + if (point.hasPoint(coords.scrCoords[1], coords.scrCoords[2])) { + gt.setTextCoords(point.X(), point.Y()); + return false; + } + return true; + }); + } + + static restore(string) { + const data = string.match(/^(.*?),(.*)/); + if (data.length < 3) return false; + let obj = false; + Object.keys(gt.graphObjectTypes).every((type) => { + if (data[1] == gt.graphObjectTypes[type].strId) { + obj = gt.graphObjectTypes[type].restore(data[2]); + return false; + } + return true; + }); + if (obj !== false) obj.blur(); + return obj; + } + } + + // Line graph object + class Line extends GraphObject { + static strId = 'line'; + + constructor(point1, point2, solid) { + super(gt.board.create('line', [point1, point2], + { fixed: true, highlight: false, strokeColor: gt.color.curve, dash: solid ? 0 : 2 } + )); + this.definingPts.push(point1, point2); + this.focusPoint = point1; + } + + handleKeyEvent(e, el) { + // Make sure that one point is not moved on top of the other. + if (el.id === this.focusPoint.id) + gt.keyboardMovementAdjust(e.key, + el, el.id === this.definingPts[0].id ? this.definingPts[1] : this.definingPts[0]); + } + + stringify() { + return [ + Line.strId, + this.baseObj.getAttribute('dash') == 0 ? 'solid' : 'dashed', + ...this.definingPts.map( + (point) => `(${gt.snapRound(point.X(), gt.snapSizeX)},${gt.snapRound(point.Y(), gt.snapSizeY)})` + ) + ].join(','); + } + + fillCmp(point) { + return gt.sign(JXG.Math.innerProduct(point, this.baseObj.stdform)); + } + + static restore(string) { + let pointData = gt.pointRegexp.exec(string); + const points = []; + while (pointData) { + points.push(pointData.slice(1, 3)); + pointData = gt.pointRegexp.exec(string); + } + if (points.length < 2) return false; + const point1 = gt.createPoint(parseFloat(points[0][0]), parseFloat(points[0][1])); + const point2 = gt.createPoint(parseFloat(points[1][0]), parseFloat(points[1][1]), point1); + return new gt.graphObjectTypes.line(point1, point2, /solid/.test(string)); + } + } + + // Circle graph object + class Circle extends GraphObject { + static strId = 'circle'; + + constructor(center, point, solid) { + super(gt.board.create('circle', [center, point], + { fixed: true, highlight: false, strokeColor: gt.color.curve, dash: solid ? 0 : 2 } + )); + this.definingPts.push(center, point); + this.focusPoint = center; + + // Redefine the circle's hasPoint method to return true if the center point has the given coordinates, so + // that a pointer over the center point will give focus to the object with the center point activated. + const circleHasPoint = this.baseObj.hasPoint.bind(this.baseObj); + this.baseObj.hasPoint = (x, y) => circleHasPoint(x, y) || center.hasPoint(x, y); + } + + handleKeyEvent(e, el) { + // Make sure that one point is not moved on top of the other. + if (el.id === this.focusPoint.id) + gt.keyboardMovementAdjust(e.key, + el, el.id === this.definingPts[0].id ? this.definingPts[1] : this.definingPts[0]); + } + + stringify() { + return [ + Circle.strId, + this.baseObj.getAttribute('dash') == 0 ? 'solid' : 'dashed', + ...this.definingPts.map( + (point) => `(${gt.snapRound(point.X(), gt.snapSizeX)},${gt.snapRound(point.Y(), gt.snapSizeY)})` + ) + ].join(','); + } + + fillCmp(point) { + return gt.sign(this.baseObj.stdform[3] * + (point[1] * point[1] + point[2] * point[2]) + + JXG.Math.innerProduct(point, this.baseObj.stdform)); + } + + static restore(string) { + let pointData = gt.pointRegexp.exec(string); + const points = []; + while (pointData) { + points.push(pointData.slice(1, 3)); + pointData = gt.pointRegexp.exec(string); + } + if (points.length < 2) return false; + const center = gt.createPoint(parseFloat(points[0][0]), parseFloat(points[0][1])); + const point = gt.createPoint(parseFloat(points[1][0]), parseFloat(points[1][1]), center); + return new gt.graphObjectTypes.circle(center, point, /solid/.test(string)); + } + } + + // Parabola graph object. + // The underlying jsxgraph object is really a curve. The problem with the + // jsxgraph parabola object is that it can not be created from the vertex + // and a point on the graph of the parabola. + const aVal = (vertex, point, vertical) => + vertical + ? (point.Y() - vertex.Y()) / Math.pow(point.X() - vertex.X(), 2) + : (point.X() - vertex.X()) / Math.pow(point.Y() - vertex.Y(), 2); + + const createParabola = (vertex, point, vertical, solid, color) => { + if (vertical) return gt.board.create('curve', [ + // x and y coordinates of point on curve + (x) => x, (x) => aVal(vertex, point, vertical) * Math.pow(x - vertex.X(), 2) + vertex.Y(), + // domain minimum and maximum + () => gt.board.getBoundingBox()[0], () => gt.board.getBoundingBox()[2] + ], { + strokeWidth: 2, highlight: false, strokeColor: color ? color : gt.color.underConstruction, + dash: solid ? 0 : 2 + }); + else return gt.board.create('curve', [ + // x and y coordinate of point on curve + (x) => aVal(vertex, point, vertical) * Math.pow(x - vertex.Y(), 2) + vertex.X(), (x) => x, + // domain minimum and maximum + () => gt.board.getBoundingBox()[3], () => gt.board.getBoundingBox()[1] + ], { + strokeWidth: 2, highlight: false, strokeColor: color ? color : gt.color.underConstruction, + dash: solid ? 0 : 2 + }); + }; + + class Parabola extends GraphObject { + static strId = 'parabola'; + + constructor(vertex, point, vertical, solid) { + super(createParabola(vertex, point, vertical, solid, gt.color.curve)); + this.definingPts.push(vertex, point); + this.vertical = vertical; + this.focusPoint = vertex; + } + + handleKeyEvent(e, el) { + // Make sure that one point is not moved onto the same horizontal or vertical line as the other. + if (el.id === this.focusPoint.id) + gt.keyboardMovementAdjustRestricted(e.key, + el, el === this.definingPts[0] ? this.definingPts[1] : this.definingPts[0]); + } + + stringify() { + return [ + Parabola.strId, + this.baseObj.getAttribute('dash') == 0 ? 'solid' : 'dashed', + this.vertical ? 'vertical' : 'horizontal', + ...this.definingPts.map( + (point) => `(${gt.snapRound(point.X(), gt.snapSizeX)},${gt.snapRound(point.Y(), gt.snapSizeY)})` + ) + ].join(','); + } + + fillCmp(point) { + if (this.vertical) return gt.sign(point[2] - this.baseObj.Y(point[1])); + else return gt.sign(point[1] - this.baseObj.X(point[2])); + } + + static restore(string) { + let pointData = gt.pointRegexp.exec(string); + const points = []; + while (pointData) { + points.push(pointData.slice(1, 3)); + pointData = gt.pointRegexp.exec(string); + } + if (points.length < 2) return false; + const vertex = gt.createPoint(parseFloat(points[0][0]), parseFloat(points[0][1])); + const point = gt.createPoint(parseFloat(points[1][0]), parseFloat(points[1][1]), vertex, true); + return new gt.graphObjectTypes.parabola(vertex, point, /vertical/.test(string), /solid/.test(string)); + } + } + + // Fill graph object + class Fill extends GraphObject { + static strId = 'fill'; + + constructor(point) { + super(point); + // Make the point invisible, but not with the jsxgraph visible attribute. The icon will be shown instead. + point.setAttribute({ + strokeOpacity: 0, highlightStrokeOpacity: 0, fillOpacity: 0, highlightFillOpacity: 0, fixed: gt.isStatic + }); + this.definingPts.push(point); + this.focusPoint = point; + this.isAnswer = gt.graphingAnswers; + this.focused = true; + this.updateTimeout = 0; + this.update(); + this.isStatic = gt.isStatic; + + point.rendNode.classList.add('hidden-fill-point'); + + // The icon is what is actually shown. It is centered on the point which is the actual object. + this.icon = gt.board.create( + 'image', + [ + () => this.focused ? gt.fillIconFocused : gt.fillIcon, + [() => point.X() - 12 / gt.board.unitX, () => point.Y() - 12 / gt.board.unitY], + [() => 24 / gt.board.unitX, () => 24 / gt.board.unitY] + ], + { withLabel: false, highlight: false, layer: 8, name: 'FillIcon', fixed: true } + ) + + if (!gt.isStatic) this.on('drag', (e) => { this.update(); gt.updateText(); }); + } + + // The fill object has an invisible focus object. So the focus/blur methods need to be overridden. + blur() { + this.focused = false; + this.baseObj.setAttribute({ fixed: true }); + gt.board.update(); + } + + focus() { + this.focused = true; + this.baseObj.setAttribute({ fixed: false }); + gt.board.update(); + this.baseObj.rendNode.focus(); + } + + remove() { + gt.board.removeObject(this.icon); + if (this.fillObj) gt.board.removeObject(this.fillObj); + super.remove(); + } + + update() { + const updateReal = () => { + this.updateTimeout = 0; + if (this.fillObj) { + gt.board.removeObject(this.fillObj); + delete this.fillObj; + } + + const centerPt = this.baseObj.coords.usrCoords; + const allObjects = gt.graphedObjs.concat(gt.staticObjs); + + // Determine which side of each object needs to be shaded. If the point + // is on a graphed object, then don't fill. + const a_vals = Array(allObjects.length); + for (let i = 0; i < allObjects.length; ++i) { + a_vals[i] = allObjects[i].fillCmp(centerPt); + if (a_vals[i] == 0) return; + } + + const canvas = document.createElement('canvas'); + canvas.width = gt.board.canvasWidth; + canvas.height = gt.board.canvasHeight; + const context = canvas.getContext('2d'); + const colorLayerData = context.getImageData(0, 0, canvas.width, canvas.height); + + const fillPixel = (pixelPos) => { + colorLayerData.data[pixelPos] = Number('0x' + gt.color.fill.slice(1, 3)); + colorLayerData.data[pixelPos + 1] = Number('0x' + gt.color.fill.slice(3, 5)); + colorLayerData.data[pixelPos + 2] = Number('0x' + gt.color.fill.slice(5)); + colorLayerData.data[pixelPos + 3] = 255; + }; + + const isFillPixel = (x, y) => { + const curPixel = [1.0, (x - gt.board.origin.scrCoords[1]) / gt.board.unitX, + (gt.board.origin.scrCoords[2] - y) / gt.board.unitY]; + for (let i = 0; i < allObjects.length; ++i) { + if (allObjects[i].fillCmp(curPixel) != a_vals[i]) return false; + } + return true; + }; + + for (let j = 0; j < canvas.width; ++j) { + for (let k = 0; k < canvas.height; ++k) { + if (isFillPixel(j, k)) fillPixel((k * canvas.width + j) * 4); + } + } + + context.putImageData(colorLayerData, 0, 0); + const dataURL = canvas.toDataURL('image/png'); + canvas.remove(); + + const boundingBox = gt.board.getBoundingBox(); + this.fillObj = gt.board.create('image', [ + dataURL, + [boundingBox[0], boundingBox[3]], + [boundingBox[2] - boundingBox[0], boundingBox[1] - boundingBox[3]] + ], { withLabel: false, highlight: false, fixed: true, layer: 0 }); + }; + + if (!('isStatic' in this) || (gt.isStatic && !gt.graphingAnswers) || this.isAnswer) { + // The only time this happens is on initial construction or if the board is static. + updateReal(); + return; + } else if (this.isStatic) return; + + if (this.updateTimeout) clearTimeout(this.updateTimeout); + this.updateTimeout = setTimeout(updateReal, 100); + } + + stringify() { + return [ + Fill.strId, + `(${gt.snapRound(this.baseObj.X(), gt.snapSizeX)},${gt.snapRound(this.baseObj.Y(), gt.snapSizeY)})` + ].join(','); + } + + static restore(string) { + let pointData = gt.pointRegexp.exec(string); + const points = []; + while (pointData) { + points.push(pointData.slice(1, 3)); + pointData = gt.pointRegexp.exec(string); + } + if (!points.length) return false; + return new gt.graphObjectTypes.fill(gt.createPoint(parseFloat(points[0][0]), parseFloat(points[0][1]))); + } + } + + gt.graphObjectTypes = {}; + gt.graphObjectTypes[Line.strId] = Line; + gt.graphObjectTypes[Parabola.strId] = Parabola; + gt.graphObjectTypes[Circle.strId] = Circle; + gt.graphObjectTypes[Fill.strId] = Fill; + + // Load any custom graph objects. + if ('customGraphObjects' in options) { + Object.keys(options.customGraphObjects).forEach((name) => { + const graphObject = options.customGraphObjects[name]; + const parentObject = 'parent' in graphObject ? + (graphObject.parent ? gt.graphObjectTypes[graphObject.parent] : null) : GraphObject; + + const customGraphObject = class extends parentObject { + static strId = name; + + constructor(...args) { + if (parentObject) { + if ('preInit' in graphObject) super(graphObject.preInit.apply(null, [gt, ...args])); + else super(...args); + + if ('postInit' in graphObject) graphObject.postInit.apply(this, [gt, ...args]); + } else { + const that = Object.create(new.target.prototype); + // The preInit method is required if not deriving from another class. It is essentially the + // constructor in this case. Furthermore, there is no need for a postInit method as everything + // can be done in the preInit method. + graphObject.preInit.apply(that, [gt, ...args]); + return that; + } + } + + handleKeyEvent(e, el) { + if ('handleKeyEvent' in graphObject) graphObject.handleKeyEvent.call(this, gt, e, el); + else if (parentObject) super.handleKeyEvent(e, el); + } + + blur() { + if ((!('blur' in graphObject) || graphObject.blur.call(this, gt)) && parentObject) super.blur(); + } + + focus() { + if ((!('focus' in graphObject) || graphObject.focus.call(this, gt)) && parentObject) super.focus(); + } + + update() { + if ('update' in graphObject) graphObject.update.call(this, gt); + else if (parentObject) super.update(); + } + + onResize() { + if ('onResize' in graphObject) graphObject.onResize.call(this, gt); + else if (parentObject) super.onResize(); + } + + updateTextCoords(coords) { + if ('updateTextCoords' in graphObject) return graphObject.updateTextCoords.call(this, gt, coords); + else if (parentObject) return super.updateTextCoords(coords); + return false; + } + + fillCmp(point) { + if ('fillCmp' in graphObject) return graphObject.fillCmp.call(this, gt, point); + else if (parentObject) return super.fillCmp(point); + return 1; + } + + remove() { + if ('remove' in graphObject) graphObject.remove.call(this, gt); + if (parentObject) super.remove(); + } + + setSolid(solid) { + if ('setSolid' in graphObject) graphObject.setSolid.call(this, gt, solid); + else if (parentObject) super.setSolid(solid); + } + + on(e, handler, context) { + if ('on' in graphObject) graphObject.on.call(this, e, handler, context); + else if (parentObject) super.on(e, handler, context); + } + + off(e, handler) { + if ('off' in graphObject) graphObject.off.call(this, e, handler); + else if (parentObject) super.off(e, handler); + } + + stringify() { + if ('stringify' in graphObject) + return [customGraphObject.strId, graphObject.stringify.call(this, gt)].join(','); + else if (parentObject) return super.stringify(); + return ''; + } + + static restore(string) { + if ('restore' in graphObject) return graphObject.restore.call(this, gt, string); + else if (parentObject) return super.restore(string); + return false; + } + }; + + if ('helperMethods' in graphObject) { + Object.keys(graphObject.helperMethods).forEach((method) => { + customGraphObject[method] = function(...args) { + return graphObject.helperMethods[method].apply(this, [gt, ...args]); + }; + }); + } + + gt.graphObjectTypes[customGraphObject.strId] = customGraphObject; + }); + } + + // Generic tool class from which all the graphing tools derive. Most of the methods, if overridden, must call the + // corresponding generic method. At this point the updateHighlights method is the only one that this doesn't need + // to be done with. + class GenericTool { + constructor(container, name, tooltip) { + const div = document.createElement('div'); + div.classList.add('gt-button-div'); + div.dataset.bsToggle = 'tooltip'; + div.title = tooltip; + div.id = `gt-${name}-tool`; + this.button = document.createElement('button'); + this.button.type = 'button'; + this.button.classList.add('btn', 'btn-light', 'gt-button', 'gt-tool-button', div.id); + this.button.addEventListener('click', () => this.activate()); + this.button.setAttribute('aria-labelledby', div.id); + div.append(this.button); + container.append(div); + this.hlObjs = {}; + } + + activate() { + gt.activeTool?.deactivate(); + gt.activeTool = this; + if (!(this instanceof SelectTool)) gt.board.containerObj.focus(); + this.button.disabled = true; + } + + finish() { + gt.updateObjects(); + gt.updateText(); + gt.board.update(); + gt.selectTool.activate(); + } + + handleKeyEvent(_e) {} + + updateHighlights(_coords) { return false; } + + removeHighlights() { + for (const obj in this.hlObjs) { + gt.board.removeObject(this.hlObjs[obj]); + delete this.hlObjs[obj]; + } + } + + deactivate() { + this.button.disabled = false; + this.removeHighlights(); + } + } + + // Select tool + class SelectTool extends GenericTool { + constructor(container) { + super(container, 'select', 'Object Selection Tool'); + } + + activate(initialize) { + // Cache the currently selected object to re-select after the GenericTool + // activate method de-selects it. + const selectedObj = gt.selectedObj; + super.activate(); + gt.selectedObj = selectedObj; + + // Select the object that was last active if there is one. + // If not then select the first graphed object if there is one. + if (!initialize && !gt.selectedObj) { + if (this.lastSelected) gt.selectedObj = this.lastSelected; + else if (gt.graphedObjs.length) gt.selectedObj = gt.graphedObjs[0]; + } + + delete this.lastSelected; + + if (gt.selectedObj) { + // First focus the board, and then focus the currently select object. + gt.board.containerObj.focus(); + gt.selectedObj.focus(); + } + gt.updateUI(); + + // This handles pointer selection of an object. + for (const [index, obj] of gt.graphedObjs.entries()) { + obj.selectionChangedHandler = (e) => { + const coords = gt.getMouseCoords(e); + + // Determine if another object or one of its defining points is the actual target of this event. + // This can happen if the defining point of another object is on this object and neither has focus. + const otherIsTarget = gt.graphedObjs.some((otherObj) => { + if (otherObj.id() === obj.id()) return false; + if (otherObj.baseObj.rendNode === e.target) return true; + return otherObj.definingPts.some((otherPt) => otherPt.rendNode === e.target); + }); + + // Check to see if one of the defining points of this object has the pointer. If so set that point + // as the focus point. However, if some other object is the target then don't focus this object. + // This is most important if a fill object is the target. In that case the focus slips through to + // the object below it. + for (const point of obj.definingPts) { + if (point.hasPoint(coords.scrCoords[1], coords.scrCoords[2])) { + obj.focusPoint = point; + if (otherIsTarget) return; + point.rendNode.focus(); + break; + } + } + + let lastSelected; + if (gt.selectedObj) { + if (gt.selectedObj.id() != obj.id()) { + // Don't allow the selection of a new object if the pointer + // is in the vicinity of one of the currently selected + // object's defining points. + for (const point of gt.selectedObj.definingPts) { + if ( + point.X() == gt.snapRound(coords.usrCoords[1], gt.snapSizeX) && + point.Y() == gt.snapRound(coords.usrCoords[2], gt.snapSizeY) + ) + return; + } + lastSelected = gt.selectedObj; + } else return; + } + + gt.selectedObj = obj; + gt.selectedObj.focus(); + lastSelected?.blur(); + }; + obj.on('down', obj.selectionChangedHandler); + + // This changes the selection on tab or shift-tab ensures that a natural tabIndex order is followed for + // the objects from first graphed to last. + obj.definingPts.forEach((point, pIndex, a) => { + // If this is the first or last defining point of an object, then attach a keydown handler that will + // focus the previous or next object when shitf-tab or tab is pressed. + if (pIndex === 0 || pIndex === a.length - 1) { + point.focusOutHandler = (e) => { + if (e.key !== 'Tab' || + (index === 0 && e.shiftKey) || + (index === gt.graphedObjs.length - 1 && !e.shiftKey) || + (a.length > 1 && + ((pIndex === 0 && !e.shiftKey) || (pIndex === a.length - 1 && e.shiftKey))) + ) + return; + + e.preventDefault(); + e.stopPropagation(); + + if (e.shiftKey) { + gt.selectedObj = gt.graphedObjs[index - 1]; + gt.selectedObj.focusPoint = + gt.selectedObj.definingPts[gt.selectedObj.definingPts.length - 1]; + gt.selectedObj.focus(); + } else { + gt.selectedObj = gt.graphedObjs[index + 1]; + gt.selectedObj.focusPoint = gt.selectedObj.definingPts[0]; + gt.selectedObj.focus(); + } + + obj.blur(); + }; + point.rendNode.addEventListener('keydown', point.focusOutHandler); + } + + // Attach a focusin handler to all points to update the coordinates display. + point.focusInHandler = (e) => gt.setTextCoords(point.X(), point.Y()); + point.rendNode.addEventListener('focusin', point.focusInHandler); + }); + } + } + + deactivate() { + this.lastSelected = gt.selectedObj; + + for (const obj of gt.graphedObjs) { + obj.off('down', obj.selectionChangedHandler); + delete obj.selectionChangedHandler; + + obj.definingPts.filter((_p, i, a) => i === 0 || i === a.length - 1).forEach((point) => { + point.rendNode.removeEventListener('keydown', point.focusOutHandler); + point.rendNode.removeEventListener('focusin', point.focusInHandler); + delete point.focusOutHandler; + }); + } + + gt.selectedObj?.blur(); + delete gt.selectedObj; + gt.updateUI(); + + super.deactivate(); + } + } + + // Line graphing tool + class LineTool extends GenericTool { + constructor(container, iconName, tooltip) { + super(container, iconName ? iconName : 'line', tooltip ? tooltip : 'Line Tool'); + } + + handleKeyEvent(e) { + if (!this.hlObjs.hl_point || !gt.board.containerObj.contains(document.activeElement)) return; + + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + + if (this.point1) this.phase2(this.hlObjs.hl_point.coords.usrCoords); + else this.phase1(this.hlObjs.hl_point.coords.usrCoords); + } else if (['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp'].includes(e.key)) { + // Make sure the highlight point is not moved onto the other point. + if (this.point1) gt.keyboardMovementAdjust(e.key, this.hlObjs.hl_point, this.point1); + this.updateHighlights(this.hlObjs.hl_point.coords); + } + } + + updateHighlights(coords) { + if (this.hlObjs.hl_line) this.hlObjs.hl_line.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); + this.hlObjs.hl_point?.rendNode.focus(); + + if (typeof coords === 'undefined') return false; + if (this.point1 && + gt.snapRound(coords.usrCoords[1], gt.snapSizeX) == this.point1.X() && + gt.snapRound(coords.usrCoords[2], gt.snapSizeY) == this.point1.Y()) + return false; + + if (!this.hlObjs.hl_point) { + this.hlObjs.hl_point = gt.board.create('point', [coords.usrCoords[1], coords.usrCoords[2]], { + size: 2, color: gt.color.underConstruction, snapToGrid: true, + snapSizeX: gt.snapSizeX, snapSizeY: gt.snapSizeY, withLabel: false + }); + this.hlObjs.hl_point.rendNode.focus(); + } else + this.hlObjs.hl_point.setPosition(JXG.COORDS_BY_USER, [coords.usrCoords[1], coords.usrCoords[2]]); + + if (this.point1 && !this.hlObjs.hl_line) { + this.hlObjs.hl_line = gt.board.create('line', [this.point1, this.hlObjs.hl_point], { + fixed: true, strokeColor: gt.color.underConstruction, highlight: false, + dash: gt.drawSolid ? 0 : 2 + }); + } + + gt.setTextCoords(this.hlObjs.hl_point.X(), this.hlObjs.hl_point.Y()); + gt.board.update(); + return true; + } + + // If graphing is interupted by pressing escape or the graph tool losing focus, + // then clean up whatever has been done so far and deactivate the tool. + deactivate() { + gt.board.off('up'); + if (this.point1) gt.board.removeObject(this.point1); + delete this.point1; + gt.board.containerObj.style.cursor = 'auto'; + super.deactivate(); + } + + activate() { + super.activate(); + gt.board.containerObj.style.cursor = 'none'; + + // Draw a highlight point on the board. + this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [0, 0], gt.board)); + + // Wait for the user to select the first point. + gt.board.on('up', (e) => this.phase1(gt.getMouseCoords(e).usrCoords)); + } + + // In phase1 the user has selected a point. If that point is on the board, then make + // that the first point for the line, and set up phase2. + phase1(coords) { + // Don't allow the point to be created off the board. + if (!gt.boardHasPoint(coords[1], coords[2])) return; + + gt.board.off('up'); + + this.point1 = gt.board.create('point', [coords[1], coords[2]], + { size: 2, snapToGrid: true, snapSizeX: gt.snapSizeX, snapSizeY: gt.snapSizeY, withLabel: false }); + this.point1.setAttribute({ fixed: true }); + + // Get a new x coordinate that is to the right, unless that is off the board. + // In that case go left instead. + let newX = this.point1.X() + gt.snapSizeX; + if (newX > gt.board.getBoundingBox()[2]) newX = this.point1.X() - gt.snapSizeX; + + this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [newX, this.point1.Y()], gt.board)); + + gt.board.on('up', (e) => this.phase2(gt.getMouseCoords(e).usrCoords)); + + gt.board.update(); + } + + // In phase2 the user has selected a second point. If that point is on the board + // and is not the same as the first point, then finalize the line. + phase2(coords) { + // Don't allow the second point to be created on top of the first or off the board + const bbox = gt.board.getBoundingBox(); + if ((this.point1.X() == gt.snapRound(coords[1], gt.snapSizeX) && + this.point1.Y() == gt.snapRound(coords[2], gt.snapSizeY)) || + !gt.boardHasPoint(coords[1], coords[2])) + return; + + gt.board.off('up'); + + this.point1.setAttribute(gt.definingPointAttributes); + this.point1.on('down', () => gt.board.containerObj.style.cursor = 'none'); + this.point1.on('up', () => gt.board.containerObj.style.cursor = 'auto'); + + const point2 = gt.createPoint(coords[1], coords[2], this.point1); + gt.selectedObj = new gt.graphObjectTypes.line(this.point1, point2, gt.drawSolid); + gt.selectedObj.focusPoint = point2; + gt.graphedObjs.push(gt.selectedObj); + delete this.point1; + + this.finish(); + } + } + + // Circle graphing tool + class CircleTool extends GenericTool { + constructor(container, iconName, tooltip) { + super(container, iconName ? iconName : 'circle', tooltip ? tooltip : 'Circle Tool'); + } + + handleKeyEvent(e) { + if (!this.hlObjs.hl_point || !gt.board.containerObj.contains(document.activeElement)) return; + + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + + if (this.center) this.phase2(this.hlObjs.hl_point.coords.usrCoords); + else this.phase1(this.hlObjs.hl_point.coords.usrCoords); + } else if (['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp'].includes(e.key)) { + // Make sure the highlight point is not moved onto the other point. + if (this.center) gt.keyboardMovementAdjust(e.key, this.hlObjs.hl_point, this.center); + this.updateHighlights(this.hlObjs.hl_point.coords); + } + } + + updateHighlights(coords) { + if (this.hlObjs.hl_circle) this.hlObjs.hl_circle.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); + this.hlObjs.hl_point?.rendNode.focus(); + + if (typeof coords === 'undefined') return false; + if (this.center && gt.snapRound(coords.usrCoords[1], gt.snapSizeX) == this.center.X() && + gt.snapRound(coords.usrCoords[2], gt.snapSizeY) == this.center.Y()) + return false; + + if (!this.hlObjs.hl_point) { + this.hlObjs.hl_point = gt.board.create('point', [coords.usrCoords[1], coords.usrCoords[2]], { + size: 2, color: gt.color.underConstruction, snapToGrid: true, + snapSizeX: gt.snapSizeX, snapSizeY: gt.snapSizeY, withLabel: false + }); + this.hlObjs.hl_point.rendNode.focus(); + } else + this.hlObjs.hl_point.setPosition(JXG.COORDS_BY_USER, [coords.usrCoords[1], coords.usrCoords[2]]); + + if (this.center && !this.hlObjs.hl_circle) { + this.hlObjs.hl_circle = gt.board.create('circle', [this.center, this.hlObjs.hl_point], { + fixed: true, strokeColor: gt.color.underConstruction, highlight: false, + dash: gt.drawSolid ? 0 : 2 + }); + } + + gt.setTextCoords(this.hlObjs.hl_point.X(), this.hlObjs.hl_point.Y()); + gt.board.update(); + return true; + } + + deactivate() { + gt.board.off('up'); + if (this.center) gt.board.removeObject(this.center); + delete this.center; + gt.board.containerObj.style.cursor = 'auto'; + super.deactivate(); + } + + activate() { + super.activate(); + gt.board.containerObj.style.cursor = 'none'; + + // Draw a highlight point on the board. + this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [0, 0], gt.board)); + + gt.board.on('up', (e) => this.phase1(gt.getMouseCoords(e).usrCoords)); + } + + // In phase1 the user has selected a point. If that point is on the board, then make + // that the center of the circle, and set up phase2. + phase1(coords) { + // Don't allow the point to be created off the board. + if (!gt.boardHasPoint(coords[1], coords[2])) return; + gt.board.off('up'); + + this.center = gt.board.create('point', [coords[1], coords[2]], + { size: 2, snapToGrid: true, snapSizeX: gt.snapSizeX, snapSizeY: gt.snapSizeY, withLabel: false }); + this.center.setAttribute({ fixed: true }); + + // Get a new x coordinate that is to the right, unless that is off the board. + // In that case go left instead. + let newX = this.center.X() + gt.snapSizeX; + if (newX > gt.board.getBoundingBox()[2]) newX = this.center.X() - gt.snapSizeX; + + this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [newX, this.center.Y()], gt.board)); + + gt.board.on('up', (e) => this.phase2(gt.getMouseCoords(e).usrCoords)); + + gt.board.update(); + } + + // In phase2 the user has selected a second point. If that point is on the board + // and is not the same as the center, then finalize the circle. + phase2(coords) { + // Don't allow the second point to be created on top of the center or off the board + if ((this.center.X() == gt.snapRound(coords[1], gt.snapSizeX) && + this.center.Y() == gt.snapRound(coords[2], gt.snapSizeY)) || + !gt.boardHasPoint(coords[1], coords[2])) + return; + + gt.board.off('up'); + + this.center.setAttribute(gt.definingPointAttributes); + this.center.on('down', () => gt.board.containerObj.style.cursor = 'none'); + this.center.on('up', () => gt.board.containerObj.style.cursor = 'auto'); + + const point = gt.createPoint(coords[1], coords[2], this.center); + gt.selectedObj = new gt.graphObjectTypes.circle(this.center, point, gt.drawSolid); + gt.selectedObj.focusPoint = point; + gt.graphedObjs.push(gt.selectedObj); + delete this.center; + + this.finish(); + } + } + + // Parabola graphing tool + class ParabolaTool extends GenericTool { + constructor(container, vertical, iconName, tooltip) { + super(container, + iconName ? iconName : vertical ? 'vertical-parabola' : 'horizontal-parabola', + tooltip ? tooltip : vertical ? 'Vertical Parabola Tool' : 'Horizontal Parabola Tool'); + this.vertical = vertical; + } + + handleKeyEvent(e) { + if (!this.hlObjs.hl_point || !gt.board.containerObj.contains(document.activeElement)) return; + + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + + if (this.vertex) this.phase2(this.hlObjs.hl_point.coords.usrCoords); + else this.phase1(this.hlObjs.hl_point.coords.usrCoords); + } else if (['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp'].includes(e.key)) { + // Make sure the highlight point is not moved onto the same horizontal or vertical line as the vertex. + if (this.vertex) gt.keyboardMovementAdjustRestricted(e.key, this.hlObjs.hl_point, this.vertex); + this.updateHighlights(this.hlObjs.hl_point.coords); + } + } + + updateHighlights(coords) { + if (this.hlObjs.hl_parabola) this.hlObjs.hl_parabola.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); + this.hlObjs.hl_point?.rendNode.focus(); + + if (typeof coords === 'undefined') return false; + if (this.vertex && + (gt.snapRound(coords.usrCoords[1], gt.snapSizeX) == this.vertex.X() || + gt.snapRound(coords.usrCoords[2], gt.snapSizeY) == this.vertex.Y())) + return false; + + if (!this.hlObjs.hl_point) { + this.hlObjs.hl_point = gt.board.create('point', [coords.usrCoords[1], coords.usrCoords[2]], { + size: 2, color: gt.color.underConstruction, snapToGrid: true, + snapSizeX: gt.snapSizeX, snapSizeY: gt.snapSizeY, + highlight: false, withLabel: false + }); + this.hlObjs.hl_point.rendNode.focus(); + } else + this.hlObjs.hl_point.setPosition(JXG.COORDS_BY_USER, [coords.usrCoords[1], coords.usrCoords[2]]); + + if (this.vertex && !this.hlObjs.hl_parabola) { + this.hlObjs.hl_parabola = createParabola(this.vertex, this.hlObjs.hl_point, this.vertical, + gt.drawSolid, gt.color.underConstruction); + } + + gt.setTextCoords(this.hlObjs.hl_point.X(), this.hlObjs.hl_point.Y()); + gt.board.update(); + return true; + } + + deactivate() { + gt.board.off('up'); + if (this.vertex) gt.board.removeObject(this.vertex); + delete this.vertex; + gt.board.containerObj.style.cursor = 'auto'; + super.deactivate(); + } + + activate() { + super.activate(); + gt.board.containerObj.style.cursor = 'none'; + + // Draw a highlight point on the board. + this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [0, 0], gt.board)); + + gt.board.on('up', (e) => this.phase1(gt.getMouseCoords(e).usrCoords)); + } + + phase1(coords) { + // Don't allow the point to be created off the board. + if (!gt.boardHasPoint(coords[1], coords[2])) return; + + gt.board.off('up'); + + this.vertex = gt.board.create('point', [coords[1], coords[2]], + { size: 2, snapToGrid: true, snapSizeX: gt.snapSizeX, snapSizeY: gt.snapSizeY, withLabel: false }); + this.vertex.setAttribute({ fixed: true }); + + // Get a new x coordinate that is to the right, unless that is off the board. + // In that case go left instead. + let newX = this.vertex.X() + gt.snapSizeX; + if (newX > gt.board.getBoundingBox()[2]) newX = this.vertex.X() - gt.snapSizeX; + + // Get a new y coordinate that is above, unless that is off the board. + // In that case go below instead. + let newY = this.vertex.Y() + gt.snapSizeY; + if (newY > gt.board.getBoundingBox()[1]) newY = this.vertex.Y() - gt.snapSizeY; + + this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [newX, newY], gt.board)); + + gt.board.on('up', (e) => this.phase2(gt.getMouseCoords(e).usrCoords)); + + gt.board.update(); + } + + phase2(coords) { + // Don't allow the second point to be created on the same + // horizontal or vertical line as the vertex or off the board. + if (this.vertex.X() == gt.snapRound(coords[1], gt.snapSizeX) || + this.vertex.Y() == gt.snapRound(coords[2], gt.snapSizeY) || + !gt.boardHasPoint(coords[1], coords[2])) + return; + + gt.board.off('up'); + + this.vertex.setAttribute(gt.definingPointAttributes); + this.vertex.on('down', () => gt.board.containerObj.style.cursor = 'none'); + this.vertex.on('up', () => gt.board.containerObj.style.cursor = 'auto'); + + const point = gt.createPoint(coords[1], coords[2], this.vertex, true); + gt.selectedObj = new gt.graphObjectTypes.parabola(this.vertex, point, this.vertical, gt.drawSolid); + gt.selectedObj.focusPoint = point; + gt.graphedObjs.push(gt.selectedObj); + delete this.vertex; + + this.finish(); + } + } + + class VerticalParabolaTool extends ParabolaTool { + constructor(container, iconName, tooltip) { + super(container, true, iconName, tooltip); + } + } + + class HorizontalParabolaTool extends ParabolaTool { + constructor(container, iconName, tooltip) { + super(container, false, iconName, tooltip); + } + } + + // Fill tool + class FillTool extends GenericTool { + constructor(container, iconName, tooltip) { + super(container, iconName ? iconName : 'fill', tooltip ? tooltip : 'Region Shading Tool'); + } + + handleKeyEvent(e) { + if (!this.hlObjs.hl_point || !gt.board.containerObj.contains(document.activeElement) || + !['Enter', ' ', 'ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp'].includes(e.key)) + return; + + // The highlight fill icon will have moved but only by 1 pixel and not the snap size, because it does + // not snap to the grid. So undo that move and redo it in an increment of the snap size. Note that the + // coordinate also needs to be translated back to the correct integer lattice point. + let x = this.hlObjs.hl_point.coords.usrCoords[1] + + (e.key === 'ArrowLeft' ? 1 : e.key === 'ArrowRight' ? -1 : 0) / gt.board.unitX + + (e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : 0) * gt.snapSizeX + + 12 / gt.board.unitX; + let y = this.hlObjs.hl_point.coords.usrCoords[2] + + (e.key === 'ArrowUp' ? -1 : e.key === 'ArrowDown' ? 1 : 0) / gt.board.unitY + + (e.key === 'ArrowUp' ? 1 : e.key === 'ArrowDown' ? -1 : 0) * gt.snapSizeX + + 12 / gt.board.unitY; + + // Don't allow the fill point to be moved off the board. + const boundingBox = gt.board.getBoundingBox(); + if (x < boundingBox[0]) x = boundingBox[0]; + else if (x > boundingBox[2]) x = boundingBox[2]; + if (y < boundingBox[3]) y = boundingBox[3]; + else if (y > boundingBox[1]) y = boundingBox[1]; + + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + + this.phase1(new JXG.Coords(JXG.COORDS_BY_USER, [x, y], gt.board).usrCoords); + } else { + this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [x, y], gt.board)); + } + } + + updateHighlights(coords) { + this.hlObjs.hl_point?.rendNode.focus(); + + if (typeof coords === 'undefined') return false; + + if (!this.hlObjs.hl_point) { + this.hlObjs.hl_point = gt.board.create('image', [ + gt.fillIcon, [ + gt.snapRound(coords.usrCoords[1], gt.snapSizeX) - 12 / gt.board.unitX, + gt.snapRound(coords.usrCoords[2], gt.snapSizeY) - 12 / gt.board.unitY + ], [24 / gt.board.unitX, 24 / gt.board.unitY] + ], { withLabel: false, highlight: false, layer: 9 }); + this.hlObjs.hl_point.rendNode.focus(); + } else { + this.hlObjs.hl_point.setPosition(JXG.COORDS_BY_USER, [ + gt.snapRound(coords.usrCoords[1], gt.snapSizeX) - 12 / gt.board.unitX, + gt.snapRound(coords.usrCoords[2], gt.snapSizeY) - 12 / gt.board.unitY + ]); + } + + gt.setTextCoords(coords.usrCoords[1], coords.usrCoords[2]); + gt.board.update(); + return true; + } + + deactivate() { + gt.board.off('up'); + gt.board.containerObj.style.cursor = 'auto'; + super.deactivate(); + } + + activate() { + super.activate(); + gt.board.containerObj.style.cursor = 'none'; + + // Draw a highlight point on the board. + this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [0, 0], gt.board)); + + gt.board.on('up', (e) => this.phase1(gt.getMouseCoords(e).usrCoords)); + } + + phase1(coords) { + // Don't allow the fill to be created off the board + if (!gt.boardHasPoint(coords[1], coords[2])) return; + gt.board.off('up'); + + gt.selectedObj = new gt.graphObjectTypes.fill(gt.createPoint(coords[1], coords[2])); + gt.graphedObjs.push(gt.selectedObj); + + this.finish(); + } + } + + // Draw objects solid or dashed. Makes the currently selected object (if + // any) solid or dashed, and anything drawn while the tool is selected will + // be drawn solid or dashed. + gt.toggleSolidity = (e, drawSolid) => { + e.preventDefault(); + e.stopPropagation(); + + if (gt.selectedObj) { + gt.selectedObj.setSolid(drawSolid); + gt.updateText(); + } + gt.drawSolid = drawSolid; + + gt.selectedObj?.focus(); + gt.activeTool?.updateHighlights(); + if (!gt.selectedObj && gt.activeTool === gt.selectTool) gt.board.containerObj.focus(); + + if (gt.solidButton) gt.solidButton.disabled = drawSolid; + if (gt.dashedButton) gt.dashedButton.disabled = !drawSolid; + }; + + class SolidDashTool { + constructor(container) { + const solidDashBox = document.createElement('div'); + solidDashBox.classList.add('gt-solid-dash-box'); + // The draw solid button is active by default. + const solidButtonDiv = document.createElement('div'); + solidButtonDiv.classList.add('gt-button-div', 'gt-solid-button-div'); + solidButtonDiv.dataset.bsToggle = 'tooltip'; + solidButtonDiv.title = 'Make Selected Object Solid'; + solidButtonDiv.id = 'gt-solid-tool'; + gt.solidButton = document.createElement('button'); + gt.solidButton.classList.add('btn', 'btn-light', 'gt-button', 'gt-tool-button', solidButtonDiv.id); + gt.solidButton.type = 'button'; + gt.solidButton.setAttribute('aria-labelledby', solidButtonDiv.id); + gt.solidButton.disabled = true; + gt.solidButton.addEventListener('click', (e) => gt.toggleSolidity(e, true)); + solidButtonDiv.append(gt.solidButton); + solidDashBox.append(solidButtonDiv); + + const dashedButtonDiv = document.createElement('div'); + dashedButtonDiv.classList.add('gt-button-div', 'gt-dashed-button-div'); + dashedButtonDiv.dataset.bsToggle = 'tooltip'; + dashedButtonDiv.title = 'Make Selected Object Dashed'; + dashedButtonDiv.id = 'gt-dashed-tool'; + gt.dashedButton = document.createElement('button'); + gt.dashedButton.classList.add('btn', 'btn-light', 'gt-button', 'gt-tool-button', dashedButtonDiv.id); + gt.dashedButton.type = 'button'; + gt.dashedButton.setAttribute('aria-labelledby', dashedButtonDiv.id); + gt.dashedButton.addEventListener('click', (e) => gt.toggleSolidity(e, false)); + dashedButtonDiv.append(gt.dashedButton); + solidDashBox.append(dashedButtonDiv); + container.append(solidDashBox); + } + } + + gt.toolTypes = { + LineTool: LineTool, + CircleTool: CircleTool, + VerticalParabolaTool: VerticalParabolaTool, + HorizontalParabolaTool: HorizontalParabolaTool, + FillTool: FillTool, + SolidDashTool: SolidDashTool + }; + + // Create the tools and html elements. + const graphDiv = document.createElement('div'); + graphDiv.id = `${containerId}_graph`; + graphDiv.classList.add('jxgbox', 'graphtool-graph'); + gt.graphContainer.append(graphDiv); + + if (!gt.isStatic) { + gt.buttonBox = document.createElement('div'); + gt.buttonBox.classList.add('gt-toolbar-container'); + gt.selectTool = new SelectTool(gt.buttonBox); + + // Load any custom tools. + if ('customTools' in options) { + Object.keys(options.customTools).forEach((tool) => { + const toolObject = options.customTools[tool]; + const parentTool = 'parent' in toolObject ? + (toolObject.parent ? gt.toolTypes[toolObject.parent] : null) : GenericTool; + const customTool = class extends parentTool { + constructor(container) { + if (parentTool) { + super(container, toolObject.iconName, toolObject.tooltip); + if ('initialize' in toolObject) toolObject.initialize.call(this, gt, container); + } else { + const that = Object.create(new.target.prototype); + // The initialize method is required if not deriving from another class. It is essentially + // the constructor in this case. + toolObject.initialize.call(that, gt, container); + return that; + } + } + + handleKeyEvent(e) { + if ('handleKeyEvent' in toolObject) toolObject.handleKeyEvent.call(this, gt, e); + if (parentTool) super.handleKeyEvent(); + } + + activate() { + if (parentTool) super.activate(); + if ('activate' in toolObject) toolObject.activate.call(this, gt); + } + + deactivate() { + if ('deactivate' in toolObject) toolObject.deactivate.call(this, gt); + if (parentTool) super.deactivate(); + } + + updateHighlights(coords) { + if ('updateHighlights' in toolObject) return toolObject.updateHighlights.call(this, gt, coords); + if (parentTool) return super.updateHighlights(); + return false; + } + + removeHighlights() { + if ('removeHighlights' in toolObject) toolObject.removeHighlights.call(this, gt); + if (parentTool) super.removeHighlights(); + } + }; + + if ('helperMethods' in toolObject) { + Object.keys(toolObject.helperMethods).forEach((method) => { + customTool[method] = function(...args) { + return toolObject.helperMethods[method].apply(this, [gt, ...args]); + }; + }); + } + + gt.toolTypes[tool] = customTool; + }); + } + + availableTools.forEach((tool) => { + if (tool in gt.toolTypes) new gt.toolTypes[tool](gt.buttonBox); + else console.log('Unknown tool: ' + tool); + }); + + const confirmDialog = (title, titleId, message, yesAction) => { + // Keep the graph tool active while this dialog is open. + gt.preventFocusLoss = true; + + const modal = document.createElement('div'); + modal.classList.add('modal', 'gt-modal'); + modal.tabIndex = -1; + modal.setAttribute('aria-labelledby', titleId); + modal.setAttribute('aria-hidden', 'true'); + + const modalDialog = document.createElement('div'); + modalDialog.classList.add('modal-dialog', 'modal-dialog-centered'); + const modalContent = document.createElement('div'); + modalContent.classList.add('modal-content'); + + const modalHeader = document.createElement('div'); + modalHeader.classList.add('modal-header'); + + const titleH3 = document.createElement('h3'); + titleH3.id = titleId; + titleH3.textContent = title; + + const closeButton = document.createElement('button'); + closeButton.type = 'button'; + closeButton.classList.add('btn-close'); + closeButton.dataset.bsDismiss = 'modal'; + closeButton.setAttribute('aria-label', 'close'); + + modalHeader.append(titleH3, closeButton); + + const modalBody = document.createElement('div'); + modalBody.classList.add('modal-body'); + const modalBodyContent = document.createElement('div'); + modalBodyContent.textContent = message; + modalBody.append(modalBodyContent); + + const modalFooter = document.createElement('div'); + modalFooter.classList.add('modal-footer'); + + const yesButton = document.createElement('button'); + yesButton.classList.add('btn', 'btn-primary'); + yesButton.textContent = 'Yes'; + yesButton.addEventListener('click', () => { yesAction(); bsModal.hide(); }); + + const noButton = document.createElement('button'); + noButton.classList.add('btn', 'btn-primary'); + noButton.dataset.bsDismiss = 'modal'; + noButton.textContent = 'No'; + + modalFooter.append(yesButton, noButton); + modalContent.append(modalHeader, modalBody, modalFooter); + modalDialog.append(modalContent); + modal.append(modalDialog); + + const bsModal = new bootstrap.Modal(modal); + bsModal.show(); + document.querySelector('.modal-backdrop').style.opacity = '0.2'; + + modal.addEventListener('hidden.bs.modal', () => { + bsModal.dispose(); + modal.remove(); + gt.preventFocusLoss = false; + if (gt.selectedObj) gt.selectedObj.focus(); + else if (gt.graphedObjs.length) gt.board.containerObj.focus(); + else gt.buttonBox.querySelectorAll('.gt-button:not([disabled])')[0]?.focus(); + }); + }; + + gt.deleteSelected = () => { + if (!gt.selectedObj) return; + + confirmDialog('Delete Selected Object', 'deleteObjectDialog', + 'Do you want to delete the selected object?', + () => { + const i = gt.graphedObjs.findIndex((obj) => obj.id() === gt.selectedObj.id()); + gt.graphedObjs[i].remove(); + gt.graphedObjs.splice(i, 1); + + if (i < gt.graphedObjs.length) gt.selectedObj = gt.graphedObjs[i]; + else if (gt.graphedObjs.length) gt.selectedObj = gt.graphedObjs[0]; + else delete gt.selectedObj; + delete gt.selectTool.lastSelected; + + // Toggle the select tool so that the focus order event handlers are realigned. + gt.selectTool.deactivate(); + gt.selectTool.activate(); + + gt.updateObjects(); + gt.updateText(); + } + ); + }; + + // Add a button to delete the selected object. + gt.deleteButton = document.createElement('button'); + gt.deleteButton.type = 'button'; + gt.deleteButton.classList.add('btn', 'btn-light', 'gt-button'); + gt.deleteButton.dataset.bsToggle = 'tooltip'; + gt.deleteButton.title = 'Delete Selected Object'; + gt.deleteButton.textContent = 'Delete'; + gt.deleteButton.addEventListener('click', gt.deleteSelected); + gt.buttonBox.append(gt.deleteButton); + + gt.clearAll = () => { + if (gt.graphedObjs.length == 0) return; + + confirmDialog('Clear Graph', 'clearGraphDialog', + 'Do you want to remove all graphed objects?', + () => { + gt.graphedObjs.forEach((obj) => obj.remove()); + gt.graphedObjs = []; + delete gt.selectedObj; + delete gt.selectTool.lastSelected; + gt.selectTool.activate(); + gt.html_input.value = ''; + } + ); + }; + + // Add a button to remove all graphed objects. + gt.clearButton = document.createElement('button'); + gt.clearButton.type = 'button'; + gt.clearButton.classList.add('btn', 'btn-light', 'gt-button'); + gt.clearButton.dataset.bsToggle = 'tooltip'; + gt.clearButton.title = 'Clear All Objects From Graph'; + gt.clearButton.textContent = 'Clear'; + gt.clearButton.addEventListener('click', gt.clearAll); + gt.buttonBox.append(gt.clearButton); + + gt.graphContainer.append(gt.buttonBox); + + gt.tooltips = Array.from( + document.querySelectorAll('.gt-button-div[data-bs-toggle="tooltip"],.gt-button[data-bs-toggle="tooltip"]')) + .map((tooltip) => new bootstrap.Tooltip(tooltip, + { placement: 'bottom', trigger: 'hover', delay: { show: 500, hide: 0 } })); + } + + setupBoard(); + + // Restore data from previous attempts if available + const restoreObjects = (data, objectsAreStatic, objectsAreAnswers) => { + gt.board.suspendUpdate(); + const tmpIsStatic = gt.isStatic; + gt.isStatic = objectsAreStatic; + gt.graphingAnswers = objectsAreAnswers; + const objectRegexp = /{(.*?)}/g; + let objectData = objectRegexp.exec(data); + while (objectData) { + const obj = GraphObject.restore(objectData[1]); + if (obj !== false) { + if (objectsAreStatic) gt.staticObjs.push(obj); + else gt.graphedObjs.push(obj); + } + objectData = objectRegexp.exec(data); + } + gt.updateObjects(); + gt.isStatic = tmpIsStatic; + delete gt.graphingAnswers; + gt.board.unsuspendUpdate(); + }; + + if ('staticObjects' in options && typeof options.staticObjects === 'string' && options.staticObjects.length) + restoreObjects(options.staticObjects, true); + if ('answerObjects' in options && typeof options.answerObjects === 'string' && options.answerObjects.length) + restoreObjects(options.answerObjects, true, true); + if ('html_input' in gt) restoreObjects(gt.html_input.value, false); + + if (!gt.isStatic) { + gt.updateText(); + gt.updateUI(); + } +}; diff --git a/htdocs/js/apps/GraphTool/graphtool.scss b/htdocs/js/apps/GraphTool/graphtool.scss new file mode 100644 index 0000000000..4970b6b467 --- /dev/null +++ b/htdocs/js/apps/GraphTool/graphtool.scss @@ -0,0 +1,149 @@ +.graphtool-container { + width: 400px; + + @media only screen and (max-width: 600px) { + width: 300px; + } + + .graphtool-graph { + width: 400px; + height: 400px; + + @media only screen and (max-width: 600px) { + width: 300px; + height: 300px; + } + + .hidden-fill-point:focus { + outline-offset: 5px; + } + } + + .gt-toolbar-container { + margin: 0.5rem 0; + text-align: right; + width: 100%; + padding: 0; + + .gt-button-div { + display: inline-block; + box-sizing: border-box; + height: 38px; + } + + .gt-button { + box-sizing: border-box; + height: 34px; + text-align: center; + padding: 1px; + margin: 2px; + border-radius: 4px; + border: 1px solid #ccc !important; + } + + .gt-tool-button { + width: 34px; + background-position: 0; + + &:disabled { + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.05); + outline: 0; + opacity: unset; + } + } + + .gt-solid-dash-box { + display: inline-block; + box-sizing: border-box; + height: 38px; + padding: 0; + vertical-align: middle; + + .gt-solid-button-div { + display: block; + height: 20px; + } + + .gt-dashed-button-div { + display: block; + height: 18px; + } + } + + .gt-solid-tool { + height: 16px; + vertical-align: top; + margin: 2px 2px 0 2px; + border-radius: 4px 4px 0 0; + background-image: url('images/SolidTool.svg'); + } + + .gt-dashed-tool { + height: 16px; + vertical-align: top; + margin: 0 2px 2px 2px; + border-radius: 0 0 4px 4px; + background-image: url('images/DashTool.svg'); + } + + .gt-tool-button { + &.gt-select-tool { + background-image: url('images/SelectTool.svg'); + } + + &.gt-point-tool { + background-image: url('images/PointTool.svg'); + } + + &.gt-line-tool { + background-image: url('images/LineTool.svg'); + } + + &.gt-circle-tool { + background-image: url('images/CircleTool.svg'); + } + + &.gt-vertical-parabola-tool { + background-image: url('images/VerticalParabolaTool.svg'); + } + + &.gt-horizontal-parabola-tool { + background-image: url('images/HorizontalParabolaTool.svg'); + } + + &.gt-quadratic-tool { + background-image: url('images/QuadraticTool.svg'); + } + + &.gt-cubic-tool { + background-image: url('images/CubicTool.svg'); + } + + &.gt-fill-tool { + background-image: url('images/FillTool.svg'); + } + } + } +} + +.graphtool-answer-container { + width: 100%; + + .graphtool-graph { + margin: auto; + width: 200px; + height: 200px; + + @media only screen and (max-width: 600px) { + width: 150px; + height: 150px; + } + } +} + +.gt-modal { + h3 { + margin: 0; + font-size: 1.2rem; + } +} diff --git a/htdocs/js/apps/GraphTool/images/CircleTool.svg b/htdocs/js/apps/GraphTool/images/CircleTool.svg new file mode 100644 index 0000000000..c8a97a940b --- /dev/null +++ b/htdocs/js/apps/GraphTool/images/CircleTool.svg @@ -0,0 +1,75 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/htdocs/js/apps/GraphTool/images/CubicTool.svg b/htdocs/js/apps/GraphTool/images/CubicTool.svg new file mode 100644 index 0000000000..dbb87f68b2 --- /dev/null +++ b/htdocs/js/apps/GraphTool/images/CubicTool.svg @@ -0,0 +1 @@ + diff --git a/htdocs/js/apps/GraphTool/images/DashTool.svg b/htdocs/js/apps/GraphTool/images/DashTool.svg new file mode 100644 index 0000000000..aece11cfb5 --- /dev/null +++ b/htdocs/js/apps/GraphTool/images/DashTool.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/htdocs/js/apps/GraphTool/images/FillTool.svg b/htdocs/js/apps/GraphTool/images/FillTool.svg new file mode 100644 index 0000000000..776ae9f83a --- /dev/null +++ b/htdocs/js/apps/GraphTool/images/FillTool.svg @@ -0,0 +1,98 @@ + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/htdocs/js/apps/GraphTool/images/HorizontalParabolaTool.svg b/htdocs/js/apps/GraphTool/images/HorizontalParabolaTool.svg new file mode 100644 index 0000000000..918863625d --- /dev/null +++ b/htdocs/js/apps/GraphTool/images/HorizontalParabolaTool.svg @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/htdocs/js/apps/GraphTool/images/LineTool.svg b/htdocs/js/apps/GraphTool/images/LineTool.svg new file mode 100644 index 0000000000..814c8288f7 --- /dev/null +++ b/htdocs/js/apps/GraphTool/images/LineTool.svg @@ -0,0 +1,80 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/htdocs/js/apps/GraphTool/images/PointTool.svg b/htdocs/js/apps/GraphTool/images/PointTool.svg new file mode 100644 index 0000000000..6e1abcda81 --- /dev/null +++ b/htdocs/js/apps/GraphTool/images/PointTool.svg @@ -0,0 +1,63 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/htdocs/js/apps/GraphTool/images/QuadraticTool.svg b/htdocs/js/apps/GraphTool/images/QuadraticTool.svg new file mode 100644 index 0000000000..03c786c4df --- /dev/null +++ b/htdocs/js/apps/GraphTool/images/QuadraticTool.svg @@ -0,0 +1 @@ + diff --git a/htdocs/js/apps/GraphTool/images/SelectTool.svg b/htdocs/js/apps/GraphTool/images/SelectTool.svg new file mode 100644 index 0000000000..dbbd2e5b86 --- /dev/null +++ b/htdocs/js/apps/GraphTool/images/SelectTool.svg @@ -0,0 +1,68 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/htdocs/js/apps/GraphTool/images/SolidTool.svg b/htdocs/js/apps/GraphTool/images/SolidTool.svg new file mode 100644 index 0000000000..33a8a8643b --- /dev/null +++ b/htdocs/js/apps/GraphTool/images/SolidTool.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/htdocs/js/apps/GraphTool/images/VerticalParabolaTool.svg b/htdocs/js/apps/GraphTool/images/VerticalParabolaTool.svg new file mode 100644 index 0000000000..e023d95a1f --- /dev/null +++ b/htdocs/js/apps/GraphTool/images/VerticalParabolaTool.svg @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/htdocs/js/apps/GraphTool/pointtool.js b/htdocs/js/apps/GraphTool/pointtool.js new file mode 100644 index 0000000000..df81279144 --- /dev/null +++ b/htdocs/js/apps/GraphTool/pointtool.js @@ -0,0 +1,130 @@ +/* global graphTool, JXG */ + +(() => { + if (graphTool && graphTool.pointTool) return; + + graphTool.pointTool = { + Point: { + preInit(gt, x, y) { + return gt.board.create('point', [x, y], { + size: 2, snapToGrid: true, snapSizeX: gt.snapSizeX, snapSizeY: gt.snapSizeY, withLabel: false, + strokeColor: gt.color.curve, fixed: gt.isStatic, + highlightStrokeColor: gt.color.underConstruction, highlightFillColor: gt.color.pointHighlight + }); + }, + + postInit(gt) { + // The base object is also a defining point for a Point. This makes it so that a point can not steal + // focus from another focused object that has a defining point at the same location. + this.definingPts.push(this.baseObj); + this.focusPoint = this.baseObj; + + if (!gt.isStatic) { + this.on('down', () => gt.board.containerObj.style.cursor = 'none'); + this.on('up', () => gt.board.containerObj.style.cursor = 'auto'); + this.on('drag', gt.updateText); + } + }, + + blur(gt) { + this.baseObj.setAttribute( + { fixed: true, highlight: false, strokeColor: gt.curveColor, strokeWidth: 2 }); + }, + + focus(gt) { + this.baseObj.setAttribute( + { fixed: false, highlight: true, strokeColor: gt.focusCurveColor, strokeWidth: 3 }); + + this.focusPoint.rendNode.focus(); + }, + + setSolid() {}, + + stringify(gt) { + return `(${ + gt.snapRound(this.baseObj.X(), gt.snapSizeX)},${gt.snapRound(this.baseObj.Y(), gt.snapSizeY)})`; + }, + + updateTextCoords(gt, coords) { + if (this.baseObj.hasPoint(coords.scrCoords[1], coords.scrCoords[2])) + gt.setTextCoords(this.baseObj.X(), this.baseObj.Y()); + }, + + restore(gt, string) { + const points = []; + let pointData = gt.pointRegexp.exec(string); + while (pointData) { + points.push(pointData.slice(1, 3)); + pointData = gt.pointRegexp.exec(string); + } + if (points.length < 1) return false; + return new gt.graphObjectTypes.point(parseFloat(points[0][0]), parseFloat(points[0][1])); + } + }, + + PointTool: { + iconName: 'point', + tooltip: 'Point Tool', + + initialize(gt) { + this.phase1 = (coords) => { + // Don't allow the point to be created off the board + if (!gt.boardHasPoint(coords[1], coords[2])) return; + + gt.board.off('up'); + + gt.selectedObj = new gt.graphObjectTypes.point(coords[1], coords[2]); + gt.graphedObjs.push(gt.selectedObj); + + this.finish(); + }; + }, + + handleKeyEvent(gt, e) { + if (!this.hlObjs.hl_point) return; + + if (e.key === 'Enter' || e.key === 'Space') { + e.preventDefault(); + e.stopPropagation(); + + this.phase1(this.hlObjs.hl_point.coords.usrCoords); + } else if (['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp'].includes(e.key)) { + this.updateHighlights(this.hlObjs.hl_point.coords); + } + }, + + updateHighlights(gt, coords) { + this.hlObjs.hl_point?.rendNode.focus(); + + if (typeof coords === 'undefined') return false; + + if (!this.hlObjs.hl_point) { + this.hlObjs.hl_point = gt.board.create('point', [coords.usrCoords[1], coords.usrCoords[2]], { + size: 2, color: gt.color.underConstruction, snapToGrid: true, + snapSizeX: gt.snapSizeX, snapSizeY: gt.snapSizeY, withLabel: false + }); + this.hlObjs.hl_point.rendNode.focus(); + } else + this.hlObjs.hl_point.setPosition(JXG.COORDS_BY_USER, [coords.usrCoords[1], coords.usrCoords[2]]); + + gt.setTextCoords(this.hlObjs.hl_point.X(), this.hlObjs.hl_point.Y()); + gt.board.update(); + return true; + }, + + deactivate(gt) { + gt.board.off('up'); + gt.board.containerObj.style.cursor = 'auto'; + }, + + activate(gt) { + gt.board.containerObj.style.cursor = 'none'; + + // Draw a highlight point on the board. + this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [0, 0], gt.board)); + + gt.board.on('up', (e) => this.phase1(gt.getMouseCoords(e).usrCoords)); + } + } + }; +})(); diff --git a/htdocs/js/apps/GraphTool/quadratictool.js b/htdocs/js/apps/GraphTool/quadratictool.js new file mode 100644 index 0000000000..a487077b6e --- /dev/null +++ b/htdocs/js/apps/GraphTool/quadratictool.js @@ -0,0 +1,319 @@ +/* global graphTool, JXG */ + +(() => { + if (graphTool && graphTool.quadraticTool) return; + + graphTool.quadraticTool = { + Quadratic: { + preInit(gt, point1, point2, point3, solid) { + [point1, point2, point3].forEach((point) => { + point.setAttribute(gt.definingPointAttributes); + point.on('down', () => gt.board.containerObj.style.cursor = 'none'); + point.on('up', () => gt.board.containerObj.style.cursor = 'auto'); + }); + return gt.graphObjectTypes.quadratic.createQuadratic(point1, point2, point3, solid, gt.color.curve); + }, + + postInit(_gt, point1, point2, point3) { + this.definingPts.push(point1, point2, point3); + this.focusPoint = point1; + }, + + handleKeyEvent(gt, e, el) { + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; + + // Make sure that this point is not moved onto the same vertical line as another point. + const pointIndex = this.definingPts.findIndex((pt) => pt.id === el.id); + if (pointIndex > -1) { + let x = el.X(); + const dir = (e.key === 'ArrowLeft' ? -1 : 1) * gt.snapSizeX; + + while (this.definingPts.some((other, i) => i !== pointIndex && x === other.X())) x += dir; + + // If the computed new x coordinate is off the board, then we need to move the point back instead. + const boundingBox = gt.board.getBoundingBox(); + if (x < boundingBox[0] || x > boundingBox[2]) { + x = el.X() - dir; + while (this.definingPts.some((other, i) => i !== pointIndex && x === other.X())) x -= dir; + } + + el.setPosition(JXG.COORDS_BY_USER, [x, el.Y()]); + gt.board.update(); + } + }, + + stringify(gt) { + return [ + this.baseObj.getAttribute('dash') == 0 ? 'solid' : 'dashed', + ...this.definingPts.map( + (point) => `(${gt.snapRound(point.X(), gt.snapSizeX)},${gt.snapRound(point.Y(), gt.snapSizeY)})` + ) + ].join(','); + }, + + fillCmp(gt, point) { + return gt.sign(point[2] - this.baseObj.Y(point[1])); + }, + + restore(gt, string) { + let pointData = gt.pointRegexp.exec(string); + const points = []; + while (pointData) { + points.push(pointData.slice(1, 3)); + pointData = gt.pointRegexp.exec(string); + } + if (points.length < 3) return false; + const point1 = gt.graphObjectTypes.quadratic.createPoint( + parseFloat(points[0][0]), parseFloat(points[0][1])); + const point2 = gt.graphObjectTypes.quadratic.createPoint( + parseFloat(points[1][0]), parseFloat(points[1][1]), [point1]); + const point3 = gt.graphObjectTypes.quadratic.createPoint( + parseFloat(points[2][0]), parseFloat(points[2][1]), [point1, point2]); + return new gt.graphObjectTypes.quadratic(point1, point2, point3, /solid/.test(string)); + }, + + helperMethods: { + createQuadratic(gt, point1, point2, point3, solid, color) { + return gt.board.create('curve', [ + // x and y coordinates of point on curve + (x) => x, + (x) => { + const x1 = point1.X(), x2 = point2.X(), x3 = point3.X(), + y1 = point1.Y(), y2 = point2.Y(), y3 = point3.Y(); + return (x - x2) * (x - x3) * y1 / ((x1 - x2) * (x1 - x3)) + + (x - x1) * (x - x3) * y2 / ((x2 - x1) * (x2 - x3)) + + (x - x1) * (x - x2) * y3 / ((x3 - x1) * (x3 - x2)); + }, + // domain minimum and maximum + () => gt.board.getBoundingBox()[0], () => gt.board.getBoundingBox()[2] + ], { + strokeWidth: 2, highlight: false, strokeColor: color ? color : gt.color.underConstruction, + dash: solid ? 0 : 2 + }); + }, + + pairedPointDrag(gt, e) { + const coords = gt.getMouseCoords(e); + let left_x = this.X(), right_x = this.X(); + + while (this.paired_points.some((pairedPoint) => left_x == pairedPoint.X())) + left_x -= gt.snapSizeX; + while (this.paired_points.some((pairedPoint) => right_x == pairedPoint.X())) + right_x += gt.snapSizeX; + + if (this.X() != left_x && this.X() != right_x) { + const left_dist = Math.abs(coords.usrCoords[1] - left_x); + const right_dist = Math.abs(coords.usrCoords[1] - right_x); + this.setPosition(JXG.COORDS_BY_USER, [ + left_x < gt.board.getBoundingBox()[0] ? right_x + : (left_dist < right_dist || right_x > gt.board.getBoundingBox()[2]) ? left_x : right_x, + this.Y() + ]); + } + + gt.updateObjects(); + gt.updateText(); + }, + + createPoint(gt, x, y, paired_points) { + const point = gt.board.create('point', [x, y], { + size: 2, snapToGrid: true, snapSizeX: gt.snapSizeX, snapSizeY: gt.snapSizeY, withLabel: false + }); + if (typeof paired_points !== 'undefined' && paired_points.length) { + point.paired_points = []; + paired_points.forEach((paired_point) => { + point.paired_points.push(paired_point); + if (!paired_point.paired_points) { + paired_point.paired_points = []; + paired_point.on('drag', gt.graphObjectTypes.quadratic.pairedPointDrag); + } + paired_point.paired_points.push(point); + if (!paired_point.eventHandlers.drag || + paired_point.eventHandlers.drag.every((dragHandler) => + dragHandler.handler !== gt.graphObjectTypes.quadratic.pairedPointDrag) + ) + paired_point.on('drag', gt.graphObjectTypes.quadratic.pairedPointDrag); + }); + point.on('drag', gt.graphObjectTypes.quadratic.pairedPointDrag, point); + } + return point; + } + } + }, + + QuadraticTool: { + iconName: 'quadratic', + tooltip: '3-Point Quadratic Tool', + + initialize(gt) { + this.phase1 = (coords) => { + // Don't allow the point to be created off the board. + if (!gt.boardHasPoint(coords[1], coords[2])) return; + + gt.board.off('up'); + + this.point1 = gt.graphObjectTypes.quadratic.createPoint(coords[1], coords[2]); + this.point1.setAttribute({ fixed: true }); + + // Get a new x coordinate that is to the right, unless that is off the board. + // In that case go left instead. + let newX = this.point1.X() + gt.snapSizeX; + if (newX > gt.board.getBoundingBox()[2]) newX = this.point1.X() - gt.snapSizeX; + + this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [newX, this.point1.Y()], gt.board)); + + gt.board.on('up', (e) => this.phase2(gt.getMouseCoords(e).usrCoords)); + + gt.board.update(); + }; + + this.phase2 = (coords) => { + // Don't allow the second point to be created on the same + // vertical line as the first point or off the board. + if (this.point1.X() == gt.snapRound(coords[1], gt.snapSizeX) || + !gt.boardHasPoint(coords[1], coords[2])) + return; + + gt.board.off('up'); + + this.point2 = gt.graphObjectTypes.quadratic.createPoint(coords[1], coords[2], [this.point1]); + this.point2.setAttribute({ fixed: true }); + + // Get a new x coordinate that is to the right, unless that is off the board. + // In that case go left instead. + let newX = this.point2.X() + gt.snapSizeX; + if (newX === this.point1.X()) newX += gt.snapSizeX; + + if (newX > gt.board.getBoundingBox()[2]) newX = this.point2.X() - gt.snapSizeX; + if (newX === this.point1.X()) newX -= gt.snapSizeX; + + this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [newX, this.point2.Y()], gt.board)); + + gt.board.on('up', (e) => this.phase3(gt.getMouseCoords(e).usrCoords)); + + gt.board.update(); + }; + + this.phase3 = (coords) => { + // Don't allow the third point to be created on the same vertical line as the + // first point, on the same vertical line as the second point, or off the board. + if (this.point1.X() == gt.snapRound(coords[1], gt.snapSizeX) || + this.point2.X() == gt.snapRound(coords[1], gt.snapSizeX) || + !gt.boardHasPoint(coords[1], coords[2])) + return; + + gt.board.off('up'); + + const point3 = gt.graphObjectTypes.quadratic.createPoint(coords[1], coords[2], + [this.point1, this.point2]); + gt.selectedObj = new gt.graphObjectTypes.quadratic(this.point1, this.point2, point3, gt.drawSolid); + gt.selectedObj.focusPoint = point3; + gt.graphedObjs.push(gt.selectedObj); + delete this.point1; + delete this.point2; + + this.finish(); + }; + }, + + handleKeyEvent(gt, e) { + if (!this.hlObjs.hl_point || !gt.board.containerObj.contains(document.activeElement)) return; + + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + + if (this.point2) this.phase3(this.hlObjs.hl_point.coords.usrCoords); + else if (this.point1) this.phase2(this.hlObjs.hl_point.coords.usrCoords); + else this.phase1(this.hlObjs.hl_point.coords.usrCoords); + } else if (['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp'].includes(e.key)) { + if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { + // Make sure the highlight point is not moved onto the same vertical line as any of the other + // points that have already been created. + const others = []; + if (this.point1) others.push(this.point1); + if (this.point2) others.push(this.point2); + + let x = this.hlObjs.hl_point.X(); + while (others.some((other) => x === other.X())) + x += (e.key === 'ArrowRight' ? 1 : -1) * gt.snapSizeX; + + // If the computed new x coordinate is off the board, + // then we need to move the point back instead. + const boundingBox = gt.board.getBoundingBox(); + if (x < boundingBox[0] || x > boundingBox[2]) { + x = this.hlObjs.hl_point.X(); + while (others.some((other) => x === other.X())) + x += (e.key === 'ArrowRight' ? -1 : 1) * gt.snapSizeX; + } + + if (x !== this.hlObjs.hl_point.X()) + this.hlObjs.hl_point.setPosition(JXG.COORDS_BY_USER, [x, this.hlObjs.hl_point.Y()]); + } + + this.updateHighlights(this.hlObjs.hl_point.coords); + } + }, + + updateHighlights(gt, coords) { + if (this.hlObjs.hl_line) this.hlObjs.hl_line.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); + if (this.hlObjs.hl_quadratic) this.hlObjs.hl_quadratic.setAttribute({ dash: gt.drawSolid ? 0 : 2 }); + this.hlObjs.hl_point?.rendNode.focus(); + + if (typeof coords === 'undefined') return; + + const new_x = gt.snapRound(coords.usrCoords[1], gt.snapSizeX); + if ((this.point1 && new_x == this.point1.X()) || (this.point2 && new_x == this.point2.X())) return; + + if (this.hlObjs.hl_point) { + this.hlObjs.hl_point.setPosition(JXG.COORDS_BY_USER, [coords.usrCoords[1], coords.usrCoords[2]]); + } else { + this.hlObjs.hl_point = gt.board.create('point', [coords.usrCoords[1], coords.usrCoords[2]], { + size: 2, color: gt.color.underConstruction, snapToGrid: true, + snapSizeX: gt.snapSizeX, snapSizeY: gt.snapSizeY, + highlight: false, withLabel: false + }); + this.hlObjs.hl_point.rendNode.focus(); + } + + if (this.point2 && !this.hlObjs.hl_quadratic) { + // Delete the temporary highlight line if it exists. + if (this.hlObjs.hl_line) { + gt.board.removeObject(this.hlObjs.hl_line); + delete this.hlObjs.hl_line; + } + + this.hlObjs.hl_quadratic = gt.graphObjectTypes.quadratic.createQuadratic( + this.point1, this.point2, this.hlObjs.hl_point, gt.drawSolid); + } else if (this.point1 && !this.point2 && !this.hlObjs.hl_line) { + this.hlObjs.hl_line = gt.board.create('line', [this.point1, this.hlObjs.hl_point], { + fixed: true, strokeColor: gt.color.underConstruction, highlight: false, + dash: gt.drawSolid ? 0 : 2 + }); + } + + gt.setTextCoords(this.hlObjs.hl_point.X(), this.hlObjs.hl_point.Y()); + gt.board.update(); + return true; + }, + + deactivate(gt) { + gt.board.off('up'); + if (this.point1) gt.board.removeObject(this.point1); + delete this.point1; + if (this.point2) gt.board.removeObject(this.point2); + delete this.point2; + gt.board.containerObj.style.cursor = 'auto'; + }, + + activate(gt) { + gt.board.containerObj.style.cursor = 'none'; + + // Draw a highlight point on the board. + this.updateHighlights(new JXG.Coords(JXG.COORDS_BY_USER, [0, 0], gt.board)); + + gt.board.on('up', (e) => this.phase1(gt.getMouseCoords(e).usrCoords)); + } + } + }; +})(); diff --git a/htdocs/js/apps/ImageView/imageview.css b/htdocs/js/apps/ImageView/imageview.css new file mode 100644 index 0000000000..874d9647a8 --- /dev/null +++ b/htdocs/js/apps/ImageView/imageview.css @@ -0,0 +1,60 @@ +.image-view-elt:hover { + cursor: pointer; +} + +.image-view-dialog.modal { + padding: 0 !important; +} + +.image-view-dialog .modal-dialog { + margin: 0; +} + +.image-view-dialog .modal-body { + overflow: unset; + padding: 8px; + text-align: center; + box-sizing: content-box !important; +} + +.image-view-dialog .modal-header { + padding: 0.1rem 0.5rem; +} + +.image-view-dialog .modal-header .drag-handle { + cursor: pointer; + width: 100%; + height: 26px; + touch-action: none; +} + +.image-view-dialog .modal-header .btn { + padding: 0 0.2rem; + margin: 0 0.25rem 0 0; +} + +.image-view-dialog .modal-header .btn svg { + opacity: 0.5; +} + +.image-view-dialog .modal-header .btn svg:hover { + opacity: 1; +} + +.image-view-dialog .modal-header .btn-close { + padding: 0.25rem 0.25rem; + margin: -0.5rem 0 -0.5rem auto; +} + +.image-view-dialog .modal-body img { + max-width: 100%; + height: 100%; +} + +.image-view-dialog .modal-body svg { + overflow: visible; +} + +.image-view-dialog .btn-close { + cursor: pointer; +} diff --git a/htdocs/js/apps/ImageView/imageview.js b/htdocs/js/apps/ImageView/imageview.js new file mode 100644 index 0000000000..4db35e24ba --- /dev/null +++ b/htdocs/js/apps/ImageView/imageview.js @@ -0,0 +1,316 @@ +'use strict'; + +/* global bootstrap */ + +(() => { + const imageViewDialog = function() { + const img = this.cloneNode(true); + const imgType = img.tagName.toLowerCase(); + img.classList.remove('image-view-elt'); + img.removeAttribute('tabindex'); + img.removeAttribute('role'); + img.removeAttribute('width'); + img.removeAttribute('height'); + img.removeAttribute('style'); + + let imgHtml = img.outerHTML; + if (imgType == 'svg') { + const ids = imgHtml.match(/\bid="[^"]*"/g); + if (ids) { + // Sort the ids from longest to shortest. + ids.sort((a, b) => b.length - a.length); + ids.forEach((id) => { + const idString = id.replace(/id="(.*)"/, '$1'); + imgHtml = imgHtml.replaceAll(idString, 'viewDialog' + idString); + }); + } + } + + const modal = document.createElement('div'); + modal.classList.add('modal', 'image-view-dialog'); + modal.ariaLabel = 'image view dialog'; + modal.tabIndex = -1; + + const dialog = document.createElement('div'); + dialog.classList.add('modal-dialog'); + + const content = document.createElement('div'); + content.classList.add('modal-content'); + + const header = document.createElement('div'); + header.classList.add('modal-header'); + + const zoomInButton = document.createElement('button'); + zoomInButton.type = 'button'; + zoomInButton.classList.add('btn', 'zoom-in'); + zoomInButton.ariaLabel = 'zoom in'; + + const zoomInSVG = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + zoomInSVG.classList.add('bi', 'bi-zoom-in'); + zoomInSVG.setAttribute('width', 16); + zoomInSVG.setAttribute('height', 16); + zoomInSVG.setAttribute('fill', 'currentColor'); + zoomInSVG.setAttribute('viewBox', '0 0 16 16'); + zoomInSVG.setAttribute('aria-hidden', true); + const zoomInPath1 = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + zoomInPath1.setAttribute('fill-rule', 'evenodd'); + zoomInPath1.setAttribute('d', + 'M6.5 12a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zM13 6.5a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0z'); + const zoomInPath2 = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + zoomInPath2.setAttribute('d', + 'M10.344 11.742c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 ' + + '1.007 0 0 0-.115-.1 6.538 6.538 0 0 1-1.398 1.4z'); + const zoomInPath3 = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + zoomInPath3.setAttribute('fill-rule', 'evenodd'); + zoomInPath3.setAttribute('d', + 'M6.5 3a.5.5 0 0 1 .5.5V6h2.5a.5.5 0 0 1 0 1H7v2.5a.5.5 0 0 1-1 0V7H3.5a.5.5 0 0 1 ' + + '0-1H6V3.5a.5.5 0 0 1 .5-.5z'); + zoomInSVG.append(zoomInPath1, zoomInPath2, zoomInPath3); + + const zoomOutButton = document.createElement('button'); + zoomOutButton.type = 'button'; + zoomOutButton.classList.add('btn', 'zoom-in'); + zoomOutButton.ariaLabel = 'zoom in'; + + const zoomOutSVG = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + zoomOutSVG.classList.add('bi', 'bi-zoom-out'); + zoomOutSVG.setAttribute('width', 16); + zoomOutSVG.setAttribute('height', 16); + zoomOutSVG.setAttribute('fill', 'currentColor'); + zoomOutSVG.setAttribute('viewBox', '0 0 16 16'); + zoomOutSVG.setAttribute('aria-hidden', true); + const zoomOutPath1 = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + zoomOutPath1.setAttribute('fill-rule', 'evenodd'); + zoomOutPath1.setAttribute('d', + 'M6.5 12a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zM13 6.5a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0z'); + const zoomOutPath2 = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + zoomOutPath2.setAttribute('d', + 'M10.344 11.742c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 ' + + '0 0 0-.115-.1 6.538 6.538 0 0 1-1.398 1.4z'); + const zoomOutPath3 = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + zoomOutPath3.setAttribute('fill-rule', 'evenodd'); + zoomOutPath3.setAttribute('d', + 'M3 6.5a.5.5 0 0 1 .5-.5h6a.5.5 0 0 1 0 1h-6a.5.5 0 0 1-.5-.5z'); + zoomOutSVG.append(zoomOutPath1, zoomOutPath2, zoomOutPath3); + + const dragHandle = document.createElement('span'); + dragHandle.classList.add('drag-handle'); + dragHandle.textContent = '\u00A0'; + + const closeButton = document.createElement('button'); + closeButton.type = 'button'; + closeButton.classList.add('btn-close'); + closeButton.dataset.bsDismiss = 'modal'; + closeButton.ariaLabel = 'close'; + + const body = document.createElement('div'); + body.classList.add('modal-body'); + body.innerHTML = imgHtml; + + zoomInButton.append(zoomInSVG); + zoomOutButton.append(zoomOutSVG); + header.append(zoomInButton, zoomOutButton, dragHandle, closeButton); + content.append(header, body); + dialog.append(content); + modal.append(dialog); + + let onWinResize; + + modal.addEventListener('shown.bs.modal', () => { + // Find the natural dimensions of the image. + let naturalWidth, naturalHeight; + if (imgType == 'img') { + naturalWidth = this.naturalWidth; + naturalHeight = this.naturalHeight; + } else if (imgType == 'svg') { + const svg = body.querySelector('svg'); + const viewBoxDims = svg.viewBox.baseVal; + // This assumes the units of the view box dimensions are points. + naturalWidth = viewBoxDims.width * 4 / 3; + naturalHeight = viewBoxDims.height * 4 / 3; + } + + const headerHeight = header.offsetHeight; + + // Initial image maximum width and height + let maxWidth = window.innerWidth - 18; + let maxHeight = window.innerHeight - headerHeight - 18; + + // Dialog maximum width and height + dialog.style.maxWidth = (maxWidth + 18) + 'px'; + dialog.style.maxHeight = (maxHeight + headerHeight + 18) + 'px'; + + // Initial image width and height + let width = naturalWidth; + let height = naturalHeight; + + // Dialog position + let left; + let top; + + const repositionModal = (x, y) => { + if (x < 0 || width >= maxWidth) left = 0; + else if (x + width > maxWidth) left = maxWidth - width; + else left = x; + if (y < 0 || height >= maxHeight) top = 0; + else if (y + height > maxHeight) top = maxHeight - height; + else top = y; + + dialog.style.left = left + 'px'; + dialog.style.top = top + 'px'; + }; + + // Resize the modal. Care is taken to maintain the aspect ratio. + const zoom = (factor, initial) => { + // Save the current dimensions for repositioning later. + const initialWidth = width; + const initialHeight = height; + + // Determine the width and height after applying the zoom factor. + if (factor * width > maxWidth || factor * height > maxHeight) { + width = maxWidth; + height = width * naturalHeight / naturalWidth; + if (height > maxHeight) { + height = maxHeight; + width = height * naturalWidth / naturalHeight; + } + } else if (factor * width < 100 || factor * height < 100) { + width = 100; + height = width * naturalHeight / naturalWidth; + if (height < 100) { + height = 100; + width = height * naturalWidth / naturalHeight; + } + } else { + width = factor * width; + height = factor * height; + } + + // Resize the modal + body.style.width = width + 'px'; + body.style.height = height + 'px'; + dialog.style.width = (width + 18) + 'px'; + dialog.style.height = (height + headerHeight + 18) + 'px'; + + // Re-position the modal. + if (initial) { + // Center the modal initially + repositionModal((maxWidth - width) / 2, (maxHeight - height) / 2); + } else { + repositionModal(left - (width - initialWidth) / 2, top - (height - initialHeight) / 2); + } + + dialog.focus(); + }; + + // Make the dialog draggable + dragHandle.addEventListener('pointerdown', (e) => { + e.preventDefault(); + + // Save the position of the pointer event relative to the top left corner of the dialog. + const pointerPosX = e.offsetX + dragHandle.offsetLeft; + const pointerPosY = e.offsetY + dragHandle.offsetTop; + + const imageViewDrag = (e) => { + e.preventDefault(); + repositionModal(e.clientX - pointerPosX, e.clientY - pointerPosY); + }; + + dragHandle.addEventListener('pointermove', imageViewDrag); + dragHandle.setPointerCapture(e.pointerId); + dragHandle.addEventListener('lostpointercapture', (e) => { + e.preventDefault(); + dragHandle.removeEventListener('pointermove', imageViewDrag); + }, { once: true }); + + }); + + // Set up the zoom in and zoom out click handlers. + zoomInButton.addEventListener('click', () => { zoomInButton.blur(); zoom(1.25); }); + zoomOutButton.addEventListener('click', () => { zoomOutButton.blur(); zoom(0.8); }); + + onWinResize = () => { + maxWidth = window.innerWidth - 18; + maxHeight = window.innerHeight - headerHeight - 18; + dialog.style.maxWidth = (maxWidth + 18) + 'px'; + dialog.style.maxHeight = (maxHeight + headerHeight + 18) + 'px'; + + // Update the dialog position and size + zoom(1); + }; + + window.addEventListener('resize', onWinResize); + + // The + or = key zooms in and the - key zooms out. + modal.addEventListener('keydown', (e) => { + if (e.key === '=' || e.key === '+') zoom(1.25); + if (e.key === '-') zoom(0.8); + + // ctrl+0 resets to the natural width and height + if (e.ctrlKey && e.key === '0') { + width = naturalWidth; + height = naturalHeight; + zoom(1); + } + }); + + // The mouse wheel zooms in and out also. + dialog.addEventListener('wheel', (e) => { + e.preventDefault(); + if (e.deltaY < 0) zoom(1.25); + if (e.deltaY > 0) zoom(0.8); + }); + + // Perform the initial zoom + zoom(1, true); + + // Make the backdrop a little lighter and set the size + const backdrop = document.querySelector('.modal-backdrop'); + backdrop.style.opacity = '0.2'; + }); + modal.addEventListener('hidden.bs.modal', () => { + bsModal.dispose(); + modal.remove(); + window.removeEventListener('resize', onWinResize); + this.focus(); + }); + const bsModal = new bootstrap.Modal(modal); + bsModal.show(); + }; + + const keyHandler = function(e) { + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault(); + imageViewDialog.call(this); + } + }; + + // Set up images that are already in the page. + document.querySelectorAll('.image-view-elt').forEach((elt) => { + elt.addEventListener('click', imageViewDialog); + elt.addEventListener('keydown', keyHandler); + }); + + const attachListeners = (node) => { + node.removeEventListener('click', imageViewDialog); + node.removeEventListener('keydown', keyHandler); + node.addEventListener('click', imageViewDialog); + node.addEventListener('keydown', keyHandler); + }; + + // Deal with images that are added to the page later. + const observer = new MutationObserver((mutationsList) => { + mutationsList.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if (node instanceof Element) { + if (node.classList.contains('image-view-elt')) attachListeners(node); + else node.querySelectorAll('.image-view-elt').forEach(attachListeners); + } + }); + }); + }); + observer.observe(document.body, { childList: true, subtree: true }); + + // Stop the mutation observer when the window is closed. + window.addEventListener('unload', () => observer.disconnect()); +})(); diff --git a/htdocs/js/apps/InputColor/color.js b/htdocs/js/apps/InputColor/color.js new file mode 100644 index 0000000000..bd079acd6f --- /dev/null +++ b/htdocs/js/apps/InputColor/color.js @@ -0,0 +1,66 @@ +// For coloring the input elements with the proper color based on whether they are correct or incorrect. + +(() => { + const setupAnswerLink = (answerLink) => { + const answerId = answerLink.dataset.answerId; + const answerInput = document.getElementById(answerId); + + const type = answerLink.parentNode.classList.contains('ResultsWithoutError') ? 'correct' : 'incorrect'; + const radioGroups = {}; + + // Color all of the inputs and selects associated with this answer. On the first pass radio inputs are + // collected into groups by name, and on the second pass the checked radio is highlighted, or if none are + // checked all are highlighted. + document.querySelectorAll(`input[name*=${answerId}],select[name*=${answerId}`) + .forEach((input) => { + if (input.type.toLowerCase() === 'radio') { + if (!radioGroups[input.name]) radioGroups[input.name] = []; + radioGroups[input.name].push(input); + } else { + input.classList.add(type); + } + }); + + Object.values(radioGroups).forEach((group) => { + if (group.every((radio) => { + if (radio.checked) { + radio.classList.add(type); + return false; + } + return true; + })) { + group.forEach((radio) => radio.classList.add(type)); + } + }); + + if (answerInput) { + answerLink.addEventListener('click', (e) => { + e.preventDefault(); + answerInput.focus(); + }); + } else { + answerLink.href = ''; + } + }; + + // Color inputs already on the page. + document.querySelectorAll('td a[data-answer-id]').forEach(setupAnswerLink); + + // Deal with inputs that are added to the page later. + const observer = new MutationObserver((mutationsList) => { + mutationsList.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if (node instanceof Element) { + if (node.type && node.type.toLowerCase() === 'td' && node.firstElementChild + && node.firstElementChild.type.toLowerCase() == 'a' && node.firstElementChild.dataset.answerId) + setupAnswerLink(node.firstElementChild); + else node.querySelectorAll('td a[data-answer-id]').forEach(setupAnswerLink); + } + }); + }); + }); + observer.observe(document.body, { childList: true, subtree: true }); + + // Stop the mutation observer when the window is closed. + window.addEventListener('unload', () => observer.disconnect()); +})(); diff --git a/htdocs/js/apps/Knowls/knowl.css b/htdocs/js/apps/Knowls/knowl.css new file mode 100644 index 0000000000..5b9c721ff5 --- /dev/null +++ b/htdocs/js/apps/Knowls/knowl.css @@ -0,0 +1,94 @@ +.knowl { + display: inline; + border-bottom: 2px dotted #00a; + color: #00a; + cursor: pointer; + border-top-left-radius: 3px; + border-top-right-radius: 3px; + margin: 0; + padding: 0 2px; +} + +.knowl:hover, +.knowl.active { + border-bottom: 2px solid #aaf; + background: #ddf; + color: #006; + text-decoration: none; +} + +.knowl.active:hover { + background: #cce; +} + +div > .knowl, p > .knowl { + position: relative; +} + +.knowl-content { + padding: 10px; + border-bottom-left-radius: 10px; +} + +.knowl-content h1 { + margin: 0 0 10px 0; +} + +.knowl-content h2 { + margin: 0 0 5px 0; +} + +.knowl-content p { + margin-bottom: 0; + margin-top: 10px; +} + +.knowl-output { + background: #eef; + border: 10px solid #ddf; + border-radius: 10px; + padding: 0; + margin-top: 10px; + margin-bottom: 0; + margin-right: 0; +} + +.knowl-output h1, .knowl-output h2 { + margin: 5px 0; +} + +.knowl-output h1 { + color: #006; +} + +.knowl-output h2 { + color: #006; +} + +.knowl-output a { + display: inline; +} + +.knowl-error { + color: darkred; + border-bottom: 0; +} + +.knowl-footer { + position: relative; + bottom: -10px; + font-size: x-small; + background: #ddf; + color: #555; + padding: 4px 0 4px 10px; + margin: -10px 0 0 0; +} + +.knowl-footer a { + color: #006; +} + +.knowl-footer a:hover { + background: none; + color: #88f; +} diff --git a/htdocs/js/apps/Knowls/knowl.js b/htdocs/js/apps/Knowls/knowl.js new file mode 100644 index 0000000000..57b8388a40 --- /dev/null +++ b/htdocs/js/apps/Knowls/knowl.js @@ -0,0 +1,141 @@ +/* global MathJax, Base64 */ + +(() => { + let knowlUID = 0; + + // This sets the innerHTML of the element and executes any script tags therein. + const setInnerHTML = (elt, html) => { + elt.innerHTML = html; + elt.querySelectorAll('script').forEach((origScript) => { + const newScript = document.createElement('script'); + Array.from(origScript.attributes).forEach(attr => newScript.setAttribute(attr.name, attr.value)); + newScript.appendChild(document.createTextNode(origScript.innerHTML)); + origScript.parentNode.replaceChild(newScript, origScript); + }); + }; + + const initializeKnowl = (knowl) => { + if (getComputedStyle(knowl)?.display === '') { + setTimeout(() => initializeKnowl(knowl), 100); + return; + } + + knowl.dataset.bsToggle = 'collapse'; + if (!knowl.knowlContainer) { + knowl.knowlContainer = document.createElement('div'); + knowl.knowlContainer.id = `knowl-uid-${knowlUID++}`; + knowl.knowlContainer.classList.add('collapse'); + + const knowlOutput = document.createElement('div'); + knowlOutput.classList.add('knowl-output'); + + const knowlContent = document.createElement('div'); + knowlContent.classList.add('knowl-content'); + knowlOutput.append(knowlContent); + + if (knowl.dataset.knowlUrl) { + const knowlFooter = document.createElement('div'); + knowlFooter.classList.add('knowl-footer'); + knowlFooter.textContent = knowl.dataset.knowlUrl; + knowlOutput.append(knowlFooter); + } + + knowl.knowlContainer.appendChild(knowlOutput); + + knowl.knowlContainer.addEventListener('show.bs.collapse', () => knowl.classList.add('active')); + knowl.knowlContainer.addEventListener('hide.bs.collapse', () => knowl.classList.remove('active')); + + // If the knowl is inside a table row, then insert a new row into the table after that one to contain + // the knowl content. If the knowl is inside a list element, then insert the content after the list + // element. Otherwise insert the content either before the first sibling that follows it that is + // display block, or append it to the first ancestor that is display block. + let insertElt = knowl.closest('tr'); + if (insertElt) { + const row = document.createElement('tr'); + const td = document.createElement('td'); + td.colSpan = insertElt.childElementCount; + td.appendChild(knowl.knowlContainer); + row.appendChild(td); + insertElt.after(row); + } else { + insertElt = knowl.closest('li'); + if (insertElt) { + const newLi = document.createElement('li'); + newLi.style.listStyle = 'none'; + newLi.append(knowl.knowlContainer); + insertElt.after(newLi); + } else { + let append = false; + insertElt = knowl; + do { + const lastElt = insertElt; + insertElt = lastElt.nextElementSibling; + if (!insertElt) { + insertElt = lastElt.parentNode; + append = true; + } + } while (getComputedStyle(insertElt)?.getPropertyValue('display') !== 'block'); + + if (append) insertElt.append(knowl.knowlContainer); + else insertElt.before(knowl.knowlContainer); + } + } + + knowl.dataset.bsTarget = `#${knowl.knowlContainer.id}`; + + if (knowl.dataset.knowlContents) { + // Inline html + if (knowl.dataset.base64 == '1') { + if (window.Base64) + setInnerHTML(knowlContent, Base64.decode(knowl.dataset.knowlContents)); + else { + setInnerHTML(knowlContent, 'ERROR: Base64 decoding not available'); + knowlContent.classList.add('knowl-error'); + } + } else { + setInnerHTML(knowlContent, knowl.dataset.knowlContents); + } + // If we are using MathJax, then render math content. + if (window.MathJax) { + MathJax.startup.promise = + MathJax.startup.promise.then(() => MathJax.typesetPromise([knowlContent])); + } + } else if (knowl.dataset.knowlUrl) { + // Retrieve url content. + fetch(knowl.dataset.knowlUrl).then((response) => response.ok ? response.text() : response) + .then((data) => { + if (typeof data == 'object') { + knowlContent.textContent = `ERROR: ${data.status} ${data.statusText}`; + knowlContent.classList.add('knowl-error'); + } else { + setInnerHTML(knowlContent, data); + } + // If we are using MathJax, then render math content. + if (window.MathJax) { + MathJax.startup.promise = + MathJax.startup.promise.then(() => MathJax.typesetPromise([knowlContent])); + } + }); + } else { + knowlContent.textContent = 'ERROR: knowl content not provided.'; + knowlContent.classList.add('knowl-error'); + } + } + }; + + // Deal with knowls that are already on the page. + document.querySelectorAll('.knowl').forEach(initializeKnowl); + + // Deal with knowls that are added to the page later. + const observer = new MutationObserver((mutationsList) => { + mutationsList.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if (node instanceof Element) { + if (node.classList.contains('knowl')) initializeKnowl(node); + else node.querySelectorAll('.knowl').forEach(initializeKnowl); + } + }); + }); + }); + observer.observe(document.body, { childList: true, subtree: true }); +})(); diff --git a/htdocs/js/apps/MathQuill/mqeditor.css b/htdocs/js/apps/MathQuill/mqeditor.css new file mode 100644 index 0000000000..1cfc8b61db --- /dev/null +++ b/htdocs/js/apps/MathQuill/mqeditor.css @@ -0,0 +1,115 @@ +span[id^="mq-answer"].correct { + border-color: rgba(81, 153, 81, 0.8); + outline: 0; + outline: thin dotted \9; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 8px rgba(81, 153, 81, .6); + color:inherit; +} + +span[id^="mq-answer"].incorrect { + border-color: rgba(191, 84, 84, 0.8); + outline: 0; + outline: thin dotted \9; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 8px rgba(191, 84, 84, .6); + color: inherit; +} + +span[id^="mq-answer"] { + /*rtl:ignore*/ + direction: ltr; + padding: 4px 5px 2px 5px; + border-radius: 4px !important; + background-color: white; + margin-right: 0; + margin-left: 0 +} + +input[type="text"].codeshard.mq-edit { + display: none !important; +} + +.quill-toolbar { + max-height: 95vh; + position: fixed; + font-size: .75em; + /*rtl:ignore*/ + direction: ltr; + display: flex; + flex-direction: column; + flex-wrap: wrap; + justify-content: center; + border-radius: 4px; + border: 2px solid darkgray; + background-color: white; + top: 50%; + right: 10px; + width: 53px; + transform: translateY(-50%); + z-index: 1001; +} + +@media only screen and (max-height: 519px) { + .quill-toolbar { + width: 102px; + } +} + +@media only screen and (max-height: 262px) { + .quill-toolbar { + width: 151px; + } +} + +.quill-toolbar .symbol-button { + box-sizing: border-box; + text-align: center; + padding: 3px; + margin: 2px; + display: block; + width: 45px; + height: 45px; + border-radius: 4px; + background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 1px rgba(0, 0, 0, 0.075); +} + +.quill-toolbar > .symbol-button:focus, +.quill-toolbar > .symbol-button.ui-visual-focus { + z-index: 9999; +} + +.quill-toolbar .symbol-button .ui-button-text { + padding: 0; +} + +.quill-toolbar .symbol-button span[id^="icon-"]:hover { + cursor: pointer; +} + +.quill-toolbar .symbol-button:not([id^="text-mq-answer"]) .mq-text-mode { + height: 10px; + width: 8px; + transform: translateY(2px); + background-color: skyblue !important; +} + +.quill-toolbar .symbol-button .mq-nthroot > .mq-text-mode, +.quill-toolbar .symbol-button .mq-sup > .mq-text-mode, +.quill-toolbar .symbol-button .mq-sub > .mq-text-mode { + height: 6px; + width: 6px; +} + +.quill-toolbar .symbol-button .mq-sup > .mq-text-mode { + transform: translateY(2px); +} + +.quill-toolbar .symbol-button .mq-sub > .mq-text-mode { + transform: translateY(0); +} + +.quill-toolbar .symbol-button .mq-supsub { + height: 6px; + width: 6px; + margin-left: 2px; +} diff --git a/htdocs/js/apps/MathQuill/mqeditor.js b/htdocs/js/apps/MathQuill/mqeditor.js new file mode 100644 index 0000000000..1da4b9482e --- /dev/null +++ b/htdocs/js/apps/MathQuill/mqeditor.js @@ -0,0 +1,221 @@ +'use strict'; + +/* global MathQuill, bootstrap */ + +(() => { + // Global list of all MathQuill answer inputs. + window.answerQuills = {}; + + // initialize MathQuill + const MQ = MathQuill.getInterface(2); + + const setupMQInput = (mq_input) => { + const answerLabel = mq_input.id.replace(/^MaThQuIlL_/, ''); + const input = document.getElementById(answerLabel); + const inputType = input?.type; + if (typeof(inputType) != 'string' + || inputType.toLowerCase() !== 'text' + || !input.classList.contains('codeshard')) + return; + + const answerQuill = document.createElement('span'); + answerQuill.id = `mq-answer-${answerLabel}`; + answerQuill.input = input; + input.classList.add('mq-edit'); + answerQuill.latexInput = mq_input; + + input.after(answerQuill); + + // Default options. + const cfgOptions = { + spaceBehavesLikeTab: true, + leftRightIntoCmdGoes: 'up', + restrictMismatchedBrackets: true, + sumStartsWithNEquals: true, + supSubsRequireOperand: true, + autoCommands: 'pi sqrt root vert inf union abs', + rootsAreExponents: true, + maxDepth: 10 + }; + + // Merge options that are set by the problem. + if (answerQuill.latexInput.dataset.mqOpts) + Object.assign(cfgOptions, JSON.parse(answerQuill.latexInput.dataset.mqOpts)); + + // This is after the option merge to prevent handlers from being overridden. + cfgOptions.handlers = { + edit: (mq) => { + if (mq.text() !== '') { + answerQuill.input.value = mq.text().trim(); + answerQuill.latexInput.value = mq.latex().replace(/^(?:\\\s)*(.*?)(?:\\\s)*$/, '$1'); + } else { + answerQuill.input.value = ''; + answerQuill.latexInput.value = ''; + } + }, + // Disable the toolbar when a text block is entered. + textBlockEnter: () => { + if (answerQuill.toolbar) + answerQuill.toolbar.querySelectorAll('button').forEach((button) => button.disabled = true); + }, + // Re-enable the toolbar when a text block is exited. + textBlockExit: () => { + if (answerQuill.toolbar) + answerQuill.toolbar.querySelectorAll('button').forEach((button) => button.disabled = false); + } + }; + + answerQuill.mathField = MQ.MathField(answerQuill, cfgOptions); + + answerQuill.textarea = answerQuill.querySelector('textarea'); + + answerQuill.buttons = [ + { id: 'frac', latex: '/', tooltip: 'fraction (/)', icon: '\\frac{\\text{ }}{\\text{ }}' }, + { id: 'abs', latex: '|', tooltip: 'absolute value (|)', icon: '|\\text{ }|' }, + { id: 'sqrt', latex: '\\sqrt', tooltip: 'square root (sqrt)', icon: '\\sqrt{\\text{ }}' }, + { id: 'nthroot', latex: '\\root', tooltip: 'nth root (root)', icon: '\\sqrt[\\text{ }]{\\text{ }}' }, + { id: 'exponent', latex: '^', tooltip: 'exponent (^)', icon: '\\text{ }^\\text{ }' }, + { id: 'infty', latex: '\\infty', tooltip: 'infinity (inf)', icon: '\\infty' }, + { id: 'pi', latex: '\\pi', tooltip: 'pi (pi)', icon: '\\pi' }, + { id: 'vert', latex: '\\vert', tooltip: 'such that (vert)', icon: '|' }, + { id: 'cup', latex: '\\cup', tooltip: 'union (union)', icon: '\\cup' }, + // { id: 'leq', latex: '\\leq', tooltip: 'less than or equal (<=)', icon: '\\leq' }, + // { id: 'geq', latex: '\\geq', tooltip: 'greater than or equal (>=)', icon: '\\geq' }, + { id: 'text', latex: '\\text', tooltip: 'text mode (")', icon: 'Tt' } + ]; + + answerQuill.hasFocus = false; + + // Open the toolbar when the mathquill answer box gains focus. + answerQuill.textarea.addEventListener('focusin', () => { + answerQuill.hasFocus = true; + if (answerQuill.toolbar) return; + + answerQuill.toolbar = document.createElement('div'); + answerQuill.toolbar.classList.add('quill-toolbar'); + + answerQuill.toolbar.tooltips = []; + + answerQuill.buttons.forEach((buttonData) => { + const button = document.createElement('button'); + button.type = 'button'; + button.id = `${buttonData.id}-${answerQuill.id}`; + button.classList.add('symbol-button', 'btn', 'btn-dark'); + button.dataset.latex = buttonData.latex; + button.dataset.bsToggle = 'tooltip'; + button.dataset.bsTitle = buttonData.tooltip; + const icon = document.createElement('span'); + icon.id = `icon-${buttonData.id}-${answerQuill.id}`; + icon.textContent = buttonData.icon; + button.append(icon); + answerQuill.toolbar.append(button); + + MQ.StaticMath(icon, { mouseEvents: false }); + + answerQuill.toolbar.tooltips.push(new bootstrap.Tooltip(button, { + placement: 'left', trigger: 'hover', delay: { show: 500, hide: 0 } + })); + + button.addEventListener('click', () => { + answerQuill.hasFocus = true; + answerQuill.mathField.cmd(button.dataset.latex); + answerQuill.textarea.focus(); + }) + }); + document.body.append(answerQuill.toolbar); + + // This is covered by css for the standard toolbar sizes. However, if buttons are added or removed from the + // toolbar by the problem or if the window height is excessively small, those may be incorrect. So this + // adjusts the width in those cases. + answerQuill.toolbar.adjustWidth = () => { + if (!answerQuill.toolbar) return; + const left = + answerQuill.toolbar.querySelector('.symbol-button:first-child')?.getBoundingClientRect().left ?? 0; + const right = + answerQuill.toolbar.querySelector('.symbol-button:last-child')?.getBoundingClientRect().right ?? 0; + answerQuill.toolbar.style.width = `${right - left + 8}px`; + }; + window.addEventListener('resize', answerQuill.toolbar.adjustWidth); + setTimeout(answerQuill.toolbar.adjustWidth); + }); + + answerQuill.textarea.addEventListener('focusout', (e) => { + answerQuill.hasFocus = false; + setTimeout(function() { + if (!answerQuill.hasFocus && answerQuill.toolbar) { + window.removeEventListener('resize', answerQuill.toolbar.adjustWidth); + answerQuill.toolbar.tooltips.forEach((tooltip) => tooltip.dispose()); + answerQuill.toolbar.remove(); + delete answerQuill.toolbar; + } + }, 200); + }); + + // Trigger an answer preview when the enter key is pressed in an answer box. + answerQuill.keypressHandler = (e) => { + if (e.key == 'Enter') { + // Ensure that the toolbar and any open tooltips are removed. + answerQuill.toolbar?.tooltips.forEach((tooltip) => tooltip.dispose()); + answerQuill.toolbar?.remove(); + delete answerQuill.toolbar; + + // For ww2 homework + document.getElementById('previewAnswers_id')?.click(); + // For gateway quizzes + document.querySelector('input[name=previewAnswers]')?.click(); + // For ww3 + const previewButtonId = + answerQuill.textarea.closest('[name=problemMainForm]')?.id + .replace('problemMainForm', 'previewAnswers'); + if (previewButtonId) document.getElementById(previewButtonId)?.click(); + } + }; + answerQuill.addEventListener('keypress', answerQuill.keypressHandler); + + answerQuill.mathField.latex(answerQuill.latexInput.value); + answerQuill.mathField.moveToLeftEnd(); + answerQuill.mathField.blur(); + + // Look for a result in the attempts table for this answer. + document.querySelectorAll('td a[data-answer-id]').forEach((tableLink) => { + // Give the mathquill answer box the correct/incorrect colors. + if (answerLabel.includes(tableLink.dataset.answerId)) { + if (tableLink.parentNode.classList.contains('ResultsWithoutError')) + answerQuill.classList.add('correct'); + else answerQuill.classList.add('incorrect'); + } + + // Make a click on the results table link give focus to the mathquill answer box. + if (answerLabel === tableLink.dataset.answerId) { + tableLink.addEventListener('click', (e) => { + e.preventDefault(); + answerQuill.textarea.focus(); + }); + } + }); + + window.answerQuills[answerLabel] = answerQuill; + }; + + // Set up MathQuill inputs that are already in the page. + document.querySelectorAll('[id^=MaThQuIlL_]').forEach((input) => setupMQInput(input)); + + // Observer that sets up MathQuill inputs. + const observer = new MutationObserver((mutationsList) => { + mutationsList.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if (node instanceof Element) { + if (node.id && node.id.startsWith('MaThQuIlL_')) { + setupMQInput(node); + } else { + node.querySelectorAll('input[id^=MaThQuIlL_]').forEach((input) => setupMQInput(input)); + } + } + }); + }); + }); + observer.observe(document.body, { childList: true, subtree: true }); + + // Stop the mutation observer when the window is closed. + window.addEventListener('unload', () => observer.disconnect()); +})(); diff --git a/htdocs/js/apps/MathView/mathview.css b/htdocs/js/apps/MathView/mathview.css new file mode 100644 index 0000000000..569171956d --- /dev/null +++ b/htdocs/js/apps/MathView/mathview.css @@ -0,0 +1,53 @@ +.mv-container .popover { + width : 400px; + max-width : 400px; + max-height: 400px; +} + +.mv-container .popover .mviewer { + height : 60px; +} + +.mv-container .popover .tab-content { + height: 145px; + margin-top: 20px; +} + +.mv-container .popover .tab-content .mvspan3 { + width: 87px; +} + +.mv-container .popover .tab-content .mvthumbnail { + box-sizing: content-box; + padding: 12px; + height: 22px; + cursor: pointer; + display: block; + line-height: 20px; + border: 1px solid #ddd; + border-radius: 4px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.055); + transition: all 0.2s ease-in-out; +} + +.mv-container .popover a.mvthumbnail:hover, +.mv-container .popover a.mvthumbnail:focus { + border-color: var(--bs-primary, #038); + box-shadow: 0 1px 4px var(--bs-primary, #038); +} + +.mv-container .popover .mvinput { + height: 22px; + padding-top: 10px; + padding-bottom: 10px; +} + +.mv-container .popover .mvinput a { + float: right; +} + +.mv-container .popover .navbar .navbar-brand { + font-size: 20px; + font-weight: 200; + color: #555555; +} diff --git a/htdocs/js/apps/MathView/mathview.js b/htdocs/js/apps/MathView/mathview.js new file mode 100644 index 0000000000..1ec6e6c977 --- /dev/null +++ b/htdocs/js/apps/MathView/mathview.js @@ -0,0 +1,515 @@ +// Class constructor +// Create the equation editor decorator around the decoratedTextBox and +// generate PGML or LaTeX code depending ont the renderingMode option. +// params: +// decoratedTextBox: the text box being decorated this is now always defined using the +// class constructor +// rederingMode: Either "PGML" to render PGML code or "LATEX" to render LaTeX code. + +// Note: This version has been forked from the 1.1.0 mathveiw release and is now WeBWorK specific + +/* load up and config mathview */ +window.addEventListener('DOMContentLoaded', function () { + /* Makes escape key hide any visible popups */ + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') + document.querySelectorAll('.popover').forEach((popover) => { + bootstrap.Popover.getInstance(popover).hide(); + }); + }); + + /* attach a viewer to each answer input */ + document.querySelectorAll('.codeshard').forEach(input => { + /* create button and attach it to side of input */ + $(input).wrap('
    '); + + /* define the button and place it */ + var button = $('', { + href: '#', + class: 'btn btn-sm btn-secondary codeshard-btn', + }).append($('', {'data-alt': 'Equation Editor'}) + .append($('', {class: 'fas fa-th'})) + .append($('
    ', {class: 'sr-only-glyphicon'}).html('equation editor'))); + $(input).parent().append(button); + + /* generate the mathviewer */ + var mviewer = new MathViewer(input, button, $(input).parent('.input-group')); + mviewer.initialize(); + + /* set mviewer behavior specific to problem inputs */ + /* have button close other open mviewers */ + mviewer.button.on('click', function () { + var current = this; + + $('.codeshard').each(function () { + var others = $(this).siblings('a')[0]; + if (others != current) { + $(others).popover('hide'); + } + }); + }); + + /* have the mviewer close if the input loses focus */ + $(input).on('focus', function () { + var current = $(this).siblings('a')[0]; + + $('.codeshard').each(function () { + var others = $(this).siblings('a')[0]; + if (others != current) { + $(others).popover('hide'); + } + }); + }); + + }); + + /* attach an editor to any needed latex/pg fields */ + $('.latexentryfield').each(function () { + var input = this; + + /* define the button and place it */ + var button = $('', {href: '#', class: 'btn', style: 'margin-left : 2ex; vertical-align : top'}) + .html('') + $(input).after(button); + options = { + renderingMode: 'LATEX', + decoratedTextBoxAsInput: false, + autocomplete: false, + includeDelimiters: true + }; + + /* generate the mathviewer */ + var mviewer = new MathViewer(input, button, 'body', options); + mviewer.initialize(); + + }); + + function MathViewer(field, button, container, userOptions) { + var defaults = { + renderingMode: "PGML", + decoratedTextBoxAsInput: true, + autocomplete: true, + includeDelimiters: false + } + + this.options = $.extend({}, defaults, userOptions); + + this.renderingMode = this.options.renderingMode; + + /* give a unique index to this instance of the viewer */ + if (typeof MathViewer.viewerCount == 'undefined') { + MathViewer.viewerCount = 0; + } + MathViewer.viewerCount++; + var viewerIndex = MathViewer.viewerCount; + var me = this; + this.decoratedTextBox = $(field); + this.button = $(button); + + if (this.options.decoratedTextBoxAsInput) { + this.inputTextBox = $(field); + } else { + this.inputTextBox = $('', {type: 'text', class: 'mv-input', size: '32'}); + } + + /* start setting up html elements */ + var popupdiv; + var popupttl; + var dropdown; + var tabContent; + + /* make sure the popover opens when we click the button */ + this.button.on('click', function () { + me.button.popover('toggle'); + me.inputTextBox.trigger('keyup'); + MathJax.startup.promise = MathJax.startup.promise.then(() => MathJax.typesetPromise(['.popover'])); + return false; + }); + + + /* set mviewer behavior specific to problem inputs */ + + /* initialization function does heavy lifting of generating html */ + this.initialize = function () { + + /* start setting up html elements */ + popupdiv = $('
    ', {class: 'popupdiv'}); + popupttl = $('
    ', {class: 'navbar', role: 'menubar'}); + dropdown = $('
    END_ANS } @@ -542,27 +827,33 @@ sub cmp_preprocess { # displayed in the "Correct Answer" box of the results table. sub cmp { my $self = shift; - my $cmp = $self->SUPER::cmp(non_tex_preview => 1, %{$self->{cmpOptions}}, @_); + my $cmp = $self->SUPER::cmp(non_tex_preview => 1, %{ $self->{cmpOptions} }, @_); if ($main::displayMode ne 'TeX' && $main::displayMode ne 'PTX') { my $ans_name = $self->ANS_NAME; $self->constructJSXGraphOptions; - my $graphObjs = @{$self->{staticObjects}} ? - join(",", @{$self->{staticObjects}}, $cmp->{rh_ans}{correct_ans}) : $cmp->{rh_ans}{correct_ans}; - $cmp->{rh_ans}{correct_ans_latex_string} = << "END_ANS"; + $cmp->{rh_ans}{correct_ans_latex_string} = <
    END_ANS } diff --git a/macros/parserImplicitEquation.pl b/macros/parserImplicitEquation.pl index 269d8edad1..7e66a5c70a 100644 --- a/macros/parserImplicitEquation.pl +++ b/macros/parserImplicitEquation.pl @@ -1,13 +1,12 @@ ################################################################################ # WeBWorK Online Homework Delivery System -# Copyright © 2000-2018 The WeBWorK Project, http://openwebwork.sf.net/ -# $CVSHeader: pg/macros/parserImplicitEquation.pl,v 1.14 2009/06/25 23:28:44 gage Exp $ -# +# Copyright © 2000-2022 The WeBWorK Project, https://github.com/openwebwork +# # This program is free software; you can redistribute it and/or modify it under # the terms of either: (a) the GNU General Public License as published by the # Free Software Foundation; either version 2, or (at your option) any later # version, or (b) the "Artistic License" which comes with this package. -# +# # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the diff --git a/macros/parserImplicitPlane.pl b/macros/parserImplicitPlane.pl index 50a8111c5f..f482f23b76 100644 --- a/macros/parserImplicitPlane.pl +++ b/macros/parserImplicitPlane.pl @@ -1,13 +1,12 @@ ################################################################################ # WeBWorK Online Homework Delivery System -# Copyright © 2000-2018 The WeBWorK Project, http://openwebwork.sf.net/ -# $CVSHeader$ -# +# Copyright © 2000-2022 The WeBWorK Project, https://github.com/openwebwork +# # This program is free software; you can redistribute it and/or modify it under # the terms of either: (a) the GNU General Public License as published by the # Free Software Foundation; either version 2, or (at your option) any later # version, or (b) the "Artistic License" which comes with this package. -# +# # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the diff --git a/macros/parserLinearInequality.pl b/macros/parserLinearInequality.pl index 943edcf6dd..6bcd34b3e4 100644 --- a/macros/parserLinearInequality.pl +++ b/macros/parserLinearInequality.pl @@ -1,13 +1,12 @@ ################################################################################ # WeBWorK Online Homework Delivery System -# Copyright © 2000-2018 The WeBWorK Project, http://openwebwork.sf.net/ -# $CVSHeader$ -# +# Copyright © 2000-2022 The WeBWorK Project, https://github.com/openwebwork +# # This program is free software; you can redistribute it and/or modify it under # the terms of either: (a) the GNU General Public License as published by the # Free Software Foundation; either version 2, or (at your option) any later # version, or (b) the "Artistic License" which comes with this package. -# +# # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the diff --git a/macros/parserMultiAnswer.pl b/macros/parserMultiAnswer.pl index cab1e0556a..b37a547ae0 100644 --- a/macros/parserMultiAnswer.pl +++ b/macros/parserMultiAnswer.pl @@ -1,13 +1,12 @@ ################################################################################ # WeBWorK Online Homework Delivery System -# Copyright © 2000-2018 The WeBWorK Project, http://openwebwork.sf.net/ -# $CVSHeader: pg/macros/parserMultiAnswer.pl,v 1.11 2009/06/25 23:28:44 gage Exp $ -# +# Copyright © 2000-2022 The WeBWorK Project, https://github.com/openwebwork +# # This program is free software; you can redistribute it and/or modify it under # the terms of either: (a) the GNU General Public License as published by the # Free Software Foundation; either version 2, or (at your option) any later # version, or (b) the "Artistic License" which comes with this package. -# +# # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the @@ -370,20 +369,28 @@ sub entry_check { # sub perform_check { my $self = shift; my $rh_ans = shift; - $self->context->clearError; + my $context = $self->context; + $context->clearError; my @correct; my @student; foreach my $ans (@{$self->{ans}}) { push(@correct,$ans->{correct_value}); push(@student,$ans->{student_value}); - return if $ans->{ans_message} ne "" || !defined($ans->{student_value}); + return if $ans->{ans_message} || !defined($ans->{student_value}); return if $self->{checkTypes} && $ans->{student_value}->type ne $ans->{correct_value}->type && !($self->{allowBlankAnswers} && $ans->{student_ans} !~ m/\S/) ; } my $inputs = $main::inputs_ref; $rh_ans->{isPreview} = $inputs->{previewAnswers} || ($inputs_{action} && $inputs->{action} =~ m/^Preview/); + + Parser::Context->current(undef,$context); # change to multi-answser's context + my $flags = Value::contextSet($context,$self->cmp_contextFlags($ans)); # save old context flags + $context->{answerHash} = $rh_ans; # attach the answerHash my @result = Value::cmp_compare([@correct],[@student],$self,$rh_ans); - if (!@result && $self->context->{error}{flag}) {$self->cmp_error($self->{ans}[0]); return 1} + Value::contextSet($context,%{$flags}); # restore context values + $context->{answerHash} = undef; # remove answerHash + if (!@result && $context->{error}{flag}) {$self->cmp_error($self->{ans}[0]); return 1} + my $result = (scalar(@result) > 1 ? [@result] : $result[0] || 0); if (ref($result) eq 'ARRAY') { die "Checker subroutine returned the wrong number of results" diff --git a/macros/parserMultiPart.pl b/macros/parserMultiPart.pl index f5a1937e12..77b2196a76 100644 --- a/macros/parserMultiPart.pl +++ b/macros/parserMultiPart.pl @@ -1,13 +1,12 @@ ################################################################################ # WeBWorK Online Homework Delivery System -# Copyright © 2000-2018 The WeBWorK Project, http://openwebwork.sf.net/ -# $CVSHeader$ -# +# Copyright © 2000-2022 The WeBWorK Project, https://github.com/openwebwork +# # This program is free software; you can redistribute it and/or modify it under # the terms of either: (a) the GNU General Public License as published by the # Free Software Foundation; either version 2, or (at your option) any later # version, or (b) the "Artistic License" which comes with this package. -# +# # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the diff --git a/macros/parserNumberWithUnits.pl b/macros/parserNumberWithUnits.pl index 3d58bae5b1..6c8169bbea 100644 --- a/macros/parserNumberWithUnits.pl +++ b/macros/parserNumberWithUnits.pl @@ -1,13 +1,12 @@ ################################################################################ # WeBWorK Online Homework Delivery System -# Copyright © 2000-2018 The WeBWorK Project, http://openwebwork.sf.net/ -# $CVSHeader$ -# +# Copyright © 2000-2022 The WeBWorK Project, https://github.com/openwebwork +# # This program is free software; you can redistribute it and/or modify it under # the terms of either: (a) the GNU General Public License as published by the # Free Software Foundation; either version 2, or (at your option) any later # version, or (b) the "Artistic License" which comes with this package. -# +# # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the diff --git a/macros/parserOneOf.pl b/macros/parserOneOf.pl index 424d496d09..5033cce410 100644 --- a/macros/parserOneOf.pl +++ b/macros/parserOneOf.pl @@ -1,13 +1,12 @@ ################################################################################ # WeBWorK Online Homework Delivery System -# Copyright © 2000-2012 The WeBWorK Project, http://openwebwork.sf.net/ -# $CVSHeader$ -# +# Copyright © 2000-2022 The WeBWorK Project, https://github.com/openwebwork +# # This program is free software; you can redistribute it and/or modify it under # the terms of either: (a) the GNU General Public License as published by the # Free Software Foundation; either version 2, or (at your option) any later # version, or (b) the "Artistic License" which comes with this package. -# +# # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the diff --git a/macros/parserParametricLine.pl b/macros/parserParametricLine.pl index 9cda736bc5..b903df6242 100644 --- a/macros/parserParametricLine.pl +++ b/macros/parserParametricLine.pl @@ -1,13 +1,12 @@ ################################################################################ # WeBWorK Online Homework Delivery System -# Copyright © 2000-2018 The WeBWorK Project, http://openwebwork.sf.net/ -# $CVSHeader: pg/macros/parserParametricLine.pl,v 1.17 2009/06/25 23:28:44 gage Exp $ -# +# Copyright © 2000-2022 The WeBWorK Project, https://github.com/openwebwork +# # This program is free software; you can redistribute it and/or modify it under # the terms of either: (a) the GNU General Public License as published by the # Free Software Foundation; either version 2, or (at your option) any later # version, or (b) the "Artistic License" which comes with this package. -# +# # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the diff --git a/macros/parserParametricPlane.pl b/macros/parserParametricPlane.pl index 8c64772d31..ec6447221e 100644 --- a/macros/parserParametricPlane.pl +++ b/macros/parserParametricPlane.pl @@ -1,13 +1,12 @@ ################################################################################ # WeBWorK Online Homework Delivery System -# Copyright © 2000-2013 The WeBWorK Project, http://openwebwork.sf.net/ -# $CVSHeader: pg/macros/parserParametricLine.pl,v 1.17 2009/06/25 23:28:44 gage Exp $ -# +# Copyright © 2000-2022 The WeBWorK Project, https://github.com/openwebwork +# # This program is free software; you can redistribute it and/or modify it under # the terms of either: (a) the GNU General Public License as published by the # Free Software Foundation; either version 2, or (at your option) any later # version, or (b) the "Artistic License" which comes with this package. -# +# # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the diff --git a/macros/parserPopUp.pl b/macros/parserPopUp.pl index f8781a6861..25aa271d9a 100644 --- a/macros/parserPopUp.pl +++ b/macros/parserPopUp.pl @@ -1,13 +1,12 @@ ################################################################################ # WeBWorK Online Homework Delivery System -# Copyright © 2000-2018 The WeBWorK Project, http://openwebwork.sf.net/ -# $CVSHeader: pg/macros/parserPopUp.pl,v 1.10 2009/06/25 23:28:44 gage Exp $ -# +# Copyright © 2000-2022 The WeBWorK Project, https://github.com/openwebwork +# # This program is free software; you can redistribute it and/or modify it under # the terms of either: (a) the GNU General Public License as published by the # Free Software Foundation; either version 2, or (at your option) any later # version, or (b) the "Artistic License" which comes with this package. -# +# # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the diff --git a/macros/parserPrime.pl b/macros/parserPrime.pl index 089debb573..81deebfe05 100644 --- a/macros/parserPrime.pl +++ b/macros/parserPrime.pl @@ -1,13 +1,12 @@ ################################################################################ # WeBWorK Online Homework Delivery System -# Copyright © 2000-2009 The WeBWorK Project, http://openwebwork.sf.net/ -# $CVSHeader: pg/macros/parserPrime.pl,v 1.2 2009/10/03 15:58:49 dpvc Exp $ -# +# Copyright © 2000-2022 The WeBWorK Project, https://github.com/openwebwork +# # This program is free software; you can redistribute it and/or modify it under # the terms of either: (a) the GNU General Public License as published by the # Free Software Foundation; either version 2, or (at your option) any later # version, or (b) the "Artistic License" which comes with this package. -# +# # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the diff --git a/macros/parserRadioButtons.pl b/macros/parserRadioButtons.pl index 751eb618b6..f3a9e90dad 100644 --- a/macros/parserRadioButtons.pl +++ b/macros/parserRadioButtons.pl @@ -1,7 +1,6 @@ ################################################################################ # WeBWorK Online Homework Delivery System -# Copyright © 2000-2018 The WeBWorK Project, http://openwebwork.sf.net/ -# $CVSHeader$ +# Copyright © 2000-2022 The WeBWorK Project, https://github.com/openwebwork # # This program is free software; you can redistribute it and/or modify it under # the terms of either: (a) the GNU General Public License as published by the diff --git a/macros/parserRoot.pl b/macros/parserRoot.pl index 122beb9f01..30a7563e41 100644 --- a/macros/parserRoot.pl +++ b/macros/parserRoot.pl @@ -1,13 +1,12 @@ ################################################################################ # WeBWorK Online Homework Delivery System -# Copyright © 2000-2013 The WeBWorK Project, http://openwebwork.sf.net/ -# $CVSHeader$ -# +# Copyright © 2000-2022 The WeBWorK Project, https://github.com/openwebwork +# # This program is free software; you can redistribute it and/or modify it under # the terms of either: (a) the GNU General Public License as published by the # Free Software Foundation; either version 2, or (at your option) any later # version, or (b) the "Artistic License" which comes with this package. -# +# # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the diff --git a/macros/parserSolutionFor.pl b/macros/parserSolutionFor.pl index 894058f09e..6cf8778831 100644 --- a/macros/parserSolutionFor.pl +++ b/macros/parserSolutionFor.pl @@ -1,13 +1,12 @@ ################################################################################ # WeBWorK Online Homework Delivery System -# Copyright © 2000-2018 The WeBWorK Project, http://openwebwork.sf.net/ -# $CVSHeader$ -# +# Copyright © 2000-2022 The WeBWorK Project, https://github.com/openwebwork +# # This program is free software; you can redistribute it and/or modify it under # the terms of either: (a) the GNU General Public License as published by the # Free Software Foundation; either version 2, or (at your option) any later # version, or (b) the "Artistic License" which comes with this package. -# +# # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the diff --git a/macros/parserVectorUtils.pl b/macros/parserVectorUtils.pl index f43061561f..c554c81518 100644 --- a/macros/parserVectorUtils.pl +++ b/macros/parserVectorUtils.pl @@ -1,13 +1,12 @@ ################################################################################ # WeBWorK Online Homework Delivery System -# Copyright © 2000-2018 The WeBWorK Project, http://openwebwork.sf.net/ -# $CVSHeader$ -# +# Copyright © 2000-2022 The WeBWorK Project, https://github.com/openwebwork +# # This program is free software; you can redistribute it and/or modify it under # the terms of either: (a) the GNU General Public License as published by the # Free Software Foundation; either version 2, or (at your option) any later # version, or (b) the "Artistic License" which comes with this package. -# +# # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the diff --git a/macros/parserWordCompletion.pl b/macros/parserWordCompletion.pl index d55ce6dbc0..5c556cbbc7 100644 --- a/macros/parserWordCompletion.pl +++ b/macros/parserWordCompletion.pl @@ -1,13 +1,12 @@ ################################################################################ # WeBWorK Online Homework Delivery System -# Copyright � 2000-2015 The WeBWorK Project, http://openwebwork.sf.net/ -# $CVSHeader: pg/macros/parserWordCompletion.pl,v 1.0 2015/11/25 23:28:44 paultpearson Exp $ -# +# Copyright © 2000-2022 The WeBWorK Project, https://github.com/openwebwork +# # This program is free software; you can redistribute it and/or modify it under # the terms of either: (a) the GNU General Public License as published by the # Free Software Foundation; either version 2, or (at your option) any later # version, or (b) the "Artistic License" which comes with this package. -# +# # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the diff --git a/macros/problemPanic.pl b/macros/problemPanic.pl index 4a398a63ac..fe11f3544a 100644 --- a/macros/problemPanic.pl +++ b/macros/problemPanic.pl @@ -1,13 +1,12 @@ ################################################################################ # WeBWorK Online Homework Delivery System -# Copyright © 2009 The WeBWorK Project, http://openwebwork.sf.net/ -# $CVSHeader: pg/macros/problemPanic.pl,v 1.6 2010/04/27 02:00:37 dpvc Exp $ -# +# Copyright © 2000-2022 The WeBWorK Project, https://github.com/openwebwork +# # This program is free software; you can redistribute it and/or modify it under # the terms of either: (a) the GNU General Public License as published by the # Free Software Foundation; either version 2, or (at your option) any later # version, or (b) the "Artistic License" which comes with this package. -# +# # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the diff --git a/macros/problemPreserveAnswers.pl b/macros/problemPreserveAnswers.pl index ad72195a2d..9ba7752c1a 100644 --- a/macros/problemPreserveAnswers.pl +++ b/macros/problemPreserveAnswers.pl @@ -1,13 +1,12 @@ ################################################################################ # WeBWorK Online Homework Delivery System -# Copyright © 2000-2018 The WeBWorK Project, http://openwebwork.sf.net/ -# $CVSHeader$ -# +# Copyright © 2000-2022 The WeBWorK Project, https://github.com/openwebwork +# # This program is free software; you can redistribute it and/or modify it under # the terms of either: (a) the GNU General Public License as published by the # Free Software Foundation; either version 2, or (at your option) any later # version, or (b) the "Artistic License" which comes with this package. -# +# # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the diff --git a/macros/problemRandomize.pl b/macros/problemRandomize.pl index 092e391949..e996990d4b 100644 --- a/macros/problemRandomize.pl +++ b/macros/problemRandomize.pl @@ -1,13 +1,12 @@ ################################################################################ # WeBWorK Online Homework Delivery System -# Copyright © 2000-2018 The WeBWorK Project, http://openwebwork.sf.net/ -# $CVSHeader: pg/macros/problemRandomize.pl,v 1.12 2009/06/25 23:28:44 gage Exp $ -# +# Copyright © 2000-2022 The WeBWorK Project, https://github.com/openwebwork +# # This program is free software; you can redistribute it and/or modify it under # the terms of either: (a) the GNU General Public License as published by the # Free Software Foundation; either version 2, or (at your option) any later # version, or (b) the "Artistic License" which comes with this package. -# +# # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the diff --git a/macros/scaffold.pl b/macros/scaffold.pl index 21f1e90054..54aba86773 100644 --- a/macros/scaffold.pl +++ b/macros/scaffold.pl @@ -1,12 +1,12 @@ ################################################################################ # WeBWorK Online Homework Delivery System -# Copyright © 2000-2020 The WeBWorK Project, http://openwebwork.sf.net/ -# +# Copyright © 2000-2022 The WeBWorK Project, https://github.com/openwebwork +# # This program is free software; you can redistribute it and/or modify it under # the terms of either: (a) the GNU General Public License as published by the # Free Software Foundation; either version 2, or (at your option) any later # version, or (b) the "Artistic License" which comes with this package. -# +# # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the @@ -42,21 +42,21 @@ =head1 DESCRIPTION the section. For example: Scaffold::Begin(); - + Section::Begin("Part 1: The first part"); BEGIN_TEXT This is the text for part 1. \(1+1\) = \{ans_rule\} END_TEXT ANS(Real(2)->cmp); Section::End(); - + Section::Begin("Part 2: The second part"); BEGIN_TEXT This is text for the second part. \(2*2\) = \{ans_rule\} END_TEXT ANS(Real(4)->cmp); Section::End(); - + Scaffold::End(); You can include whatever code you need to between the @@ -95,7 +95,7 @@ =head1 DESCRIPTION =over -=item C condition >>> +=item C condition >>> This specifies when a section can be opened by the student. The C is either one of the strings C<"always">, @@ -208,7 +208,7 @@ =head1 DESCRIPTION can_open => "when_previous_correct", is_open => "first_incorrect" ); - + # # Sections stay open as the student works through # the problem. @@ -217,7 +217,7 @@ =head1 DESCRIPTION can_open => "when_previous_correct", is_open => "correct_or_first_incorrect" ); - + # # Students work through the problem seeing only # one section at a time, and can't go back to @@ -227,7 +227,7 @@ =head1 DESCRIPTION can_open => "first_incorrect", is_open => "first_incorrect" ); - + # # Students can view and work on any section, # but only the first incorrect one is shown initially. @@ -236,7 +236,7 @@ =head1 DESCRIPTION can_open => "always", is_open => "first_incorrect" ); - + # # Students see all the parts initially, but the # sections close as the student gets them correct. @@ -245,7 +245,7 @@ =head1 DESCRIPTION can_open => "always", is_open => "incorrect" ); - + # # Students see all the parts initially, but the # sections close as the student gets them correct, @@ -274,7 +274,7 @@ =head1 DESCRIPTION sub _scaffold_init { # Load style and javascript for opening and closing the scaffolds. ADD_CSS_FILE("js/apps/Scaffold/scaffold.css"); - ADD_JS_FILE("js/apps/Scaffold/scaffold.js"); + ADD_JS_FILE("js/apps/Scaffold/scaffold.js", 0, { defer => undef }); }; # @@ -614,21 +614,29 @@ sub add_container { splice(@$PG_OUTPUT,0,scalar(@$PG_OUTPUT)) if !($canopen || $iscorrect || $Scaffold::isPTX) || (!$isopen && $Scaffold::isHardcopy); unshift(@$PG_OUTPUT,@{main::MODES( HTML => [ - '
    ', - '
    ', - '', - "$number", - '' . $title . '', + '
    ', + '
    ', + '
    ', + '', '
    ', - '
    ', - '
    ' + qq{
    }, + '
    ' ], TeX => ["\\par{\\bf $number $title}\\addtolength{\\leftskip}{15pt}\\par "], PTX => $name ? ["\n", "$name\n"] : ["\n"], )}); push(@$PG_OUTPUT,main::MODES( - HTML => '
    ', + HTML => '
    ', TeX => "\\addtolength{\\leftskip}{-15pt}\\par ", PTX => "<\/task>\n", )); diff --git a/t/README b/t/README deleted file mode 100644 index 77ca89b829..0000000000 --- a/t/README +++ /dev/null @@ -1,14 +0,0 @@ -These files, contributed by Boyd Duffe, are an example of writing unit tests -for PG modules so that we can determine (a) that they work, and (b) that they continue -to work when some seemingly innocuous change elsewhere in the system causes troubles -in this module. - -Of course this is not supposed to happen when modular programming practices are followed -but it happens anyway. :-) - -We need more "unit tests" like this. (The unit does not refer to Unit.pm but to testing -a small piece or unit of the WeBWorK/PG code.) I would also like to wire these -in so that administrators can perform these tests from the admin page on the web -to reassure themselves that the new version of PG they are using is functioning correctly. - - diff --git a/t/README.md b/t/README.md new file mode 100644 index 0000000000..93f8ef8c8b --- /dev/null +++ b/t/README.md @@ -0,0 +1,72 @@ +# Unit Tests for PG + +The individual unit tests are located in each of the directories. + +Formal unit tests are located in the the `macros` and `contexts` directories that are designed to test the pg macros and contexts respectively. + +## Running the tests + +```bash +cd $PG_ROOT +prove -r . +``` + +will run all of the tests in `.t` files within subdirectories of `t`. + +### Running an individual test + +If instead, you want to run an individual test, for example the `pgaux.t` test suite, + +```bash +cd $PG_ROOT/t/macros +prove -v pgaux.t +``` + +which will be verbose (`-v`). + +## Writing a Unit Test + +To write a unit test, the following is needed at the top of the file: + +```perl +use warnings; +use strict; + +package main; + +use Test::More; +use Test::Exception; + +## the following needs to include at the top of any testing down to TOP_MATERIAL + +BEGIN { + die "PG_ROOT not found in environment.\n" unless $ENV{PG_ROOT}; + $main::pg_dir = $ENV{PG_ROOT}; +} + +use lib "$main::pg_dir/lib"; + +require("$main::pg_dir/t/build_PG_envir.pl"); + +## END OF TOP_MATERIAL +``` + +and ensure that `PG_ROOT` is in your environmental variables. + +### Example: Running a test + +The following shows how to test a Math object + +```perl +loadMacros("MathObjects.pl"); + +Context("Numeric"); + +my $f = Compute("x^2"); + +# evaluate f at x=2 + +is(check_score($f->eval(x=>2),"4"),1,"math objects: eval x^2 at x=2"); +``` + +The `check_score` subroutine evaluates and compares a MathObject with a string representation of the answer. If the score is 1, then the two are equal. diff --git a/t/build_PG_envir.pl b/t/build_PG_envir.pl new file mode 100644 index 0000000000..417cb3844a --- /dev/null +++ b/t/build_PG_envir.pl @@ -0,0 +1,43 @@ +use warnings; +use strict; + +package main; + +$main::macros_dir = "$main::pg_dir/macros"; + +# use WeBWorK::Localize; +use PGcore; +use Parser; + +# build up enough of a PG environment to get things running + +our %envir = (); +$envir{htmlDirectory} = "/opt/webwork/courses/daemon_course/html"; +$envir{htmlURL} = "http://localhost/webwork2/daemon_course/html"; +$envir{tempURL} = "http://localhost/webwork2/daemon_course/tmp"; +$envir{pgDirectories}->{macrosPath} = ["$main::macros_dir"]; +$envir{macrosPath} = ["$main::macros_dir"]; +$envir{displayMode} = "HTML_MathJax"; +$envir{language} = "en-us"; +$envir{language_subroutine} = sub { return @_; }; # return the string passed in instead going to maketext + +sub be_strict { + require 'ww_strict.pm'; + strict::import(); +} + +sub PG_restricted_eval { + WeBWorK::PG::Translator::PG_restricted_eval(@_); +} + +sub check_score { + my ($correct_answer, $ans) = @_; + return $correct_answer->cmp->evaluate($ans)->{score}; +} + +require "$main::macros_dir/PG.pl"; +DOCUMENT(); + +loadMacros("PGbasicmacros.pl"); + +1; diff --git a/t/contexts/fraction.t b/t/contexts/fraction.t new file mode 100644 index 0000000000..ecebcd848f --- /dev/null +++ b/t/contexts/fraction.t @@ -0,0 +1,47 @@ +use warnings; +use strict; + +package main; + +use Test::More; +use Test::Exception; + +# The following needs to include at the top of any testing down to END OF TOP_MATERIAL. + +BEGIN { + die "PG_ROOT not found in environment.\n" unless $ENV{PG_ROOT}; + $main::pg_dir = $ENV{PG_ROOT}; +} + +use lib "$main::pg_dir/lib"; + +require("$main::pg_dir/t/build_PG_envir.pl"); + +## END OF TOP_MATERIAL + +loadMacros("PGstandard.pl", "MathObjects.pl", "contextFraction.pl"); + +# dd @INC; + +for my $module (qw/Parser Value Parser::Legacy/) { + eval "package Main; require $module; import $module;"; +} + +# use Value; +# use Value::Complex; +# # use Value::Type; +# use Parser::Context::Default; +# use Parser::Legacy; +# use Parser::Context; + +Context("Fraction"); + +# require("Parser::Legacy::LimitedNumeric::Number"); +# require("Parser::Legacy"); + +my $a1 = Compute("1/2"); +my $a2 = Compute("2/4"); + +is($a1->value, $a2->value, "contextFraction: reduce fractions"); + +done_testing(); diff --git a/t/contexts/integer.t b/t/contexts/integer.t new file mode 100644 index 0000000000..7981c240dd --- /dev/null +++ b/t/contexts/integer.t @@ -0,0 +1,36 @@ +use warnings; +use strict; + +package main; + +use Test::More; +use Test::Exception; + +# The following needs to include at the top of any testing down to END OF TOP_MATERIAL. + +BEGIN { + die "PG_ROOT not found in environment.\n" unless $ENV{PG_ROOT}; + $main::pg_dir = $ENV{PG_ROOT}; +} + +use lib "$main::pg_dir/lib"; + +require("$main::pg_dir/t/build_PG_envir.pl"); + +## END OF TOP_MATERIAL + +loadMacros("MathObjects.pl", "contextInteger.pl"); + +for my $module (qw/Parser Value Parser::Legacy/) { + eval "package Main; require $module; import $module;"; +} + +Context("Integer"); + +my $b = Compute(gcd(5, 2)); +ANS($b->cmp); + +ok(1, "integer test: dummy test"); + +done_testing(); + diff --git a/t/contexts/toltype_digits.t b/t/contexts/toltype_digits.t new file mode 100644 index 0000000000..f878cea032 --- /dev/null +++ b/t/contexts/toltype_digits.t @@ -0,0 +1,64 @@ +use warnings; +use strict; + +package main; + +use Test::More; +use Test::Exception; + +# The following needs to include at the top of any testing down to END OF TOP_MATERIAL. + +BEGIN { + die "PG_ROOT not found in environment.\n" unless $ENV{PG_ROOT}; + $main::pg_dir = $ENV{PG_ROOT}; +} + +use lib "$main::pg_dir/lib"; + +require("$main::pg_dir/t/build_PG_envir.pl"); + +## END OF TOP_MATERIAL + +loadMacros("PGstandard.pl", "MathObjects.pl"); + +my $ctx = Context("Numeric"); +$ctx->flags->set(tolType => 'digits', tolerance => 3, tolTruncation => 1); + +my $pi = Real("pi"); + +is(check_score($pi, Compute("3.14")), 1, "toltype digits: pi is 3.14"); +is(check_score($pi, Compute("3.141")), 1, "toltype digits: pi is 3.141"); +is(check_score($pi, Compute("3.142")), 1, "toltype digits: pi is 3.142"); +is(check_score($pi, Compute("3.143")), 0, "toltype digits: pi is not 3.143"); +is(check_score($pi, Compute("3.15")), 0, "toltype digits: pi is not 3.15"); + +note(""); +note("change tolTrunction to 0"); + +$ctx->flags->set(tolType => 'digits', tolerance => 3, tolTruncation => 0); +is(check_score($pi, Compute("3.14")), 1, "toltype digits: pi is 3.14"); +is(check_score($pi, Compute("3.141")), 0, "toltype digits: pi is not 3.141"); +is(check_score($pi, Compute("3.142")), 1, "toltype digits: pi is not 3.142"); +is(check_score($pi, Compute("3.143")), 0, "toltype digits: pi is not 3.143"); +is(check_score($pi, Compute("3.15")), 0, "toltype digits: pi is not 3.15"); + +note(""); +note("set tolExtraDigits to 2"); + +$ctx->flags->set( + tolType => 'digits', + tolerance => 3, + tolTruncation => 0, + tolExtraDigits => 2 +); +is(check_score($pi, Compute("3.14")), 1, "toltype digits: pi is 3.14"); +is(check_score($pi, Compute("3.141")), 0, "toltype digits: pi is not 3.141"); +is(check_score($pi, Compute("3.142")), 1, "toltype digits: pi is not 3.142"); +is(check_score($pi, Compute("3.143")), 0, "toltype digits: pi is not 3.143"); +is(check_score($pi, Compute("3.15")), 0, "toltype digits: pi is not 3.15"); + +is(check_score($pi, Compute("3.1416")), 1, "toltype digits: pi is 3.1416"); +is(check_score($pi, Compute("3.1415888")), 1, "toltype digits: pi is 3.1415888"); +is(check_score($pi, Compute("3.1415")), 0, "toltype digits: pi is not 3.1415"); + +done_testing(); diff --git a/t/contexts/trig_degrees.t b/t/contexts/trig_degrees.t new file mode 100644 index 0000000000..3d2230ad75 --- /dev/null +++ b/t/contexts/trig_degrees.t @@ -0,0 +1,41 @@ +use warnings; +use strict; + +package main; + +use Test::More; +use Test::Exception; + +# The following needs to include at the top of any testing down to END OF TOP_MATERIAL. + +BEGIN { + die "PG_ROOT not found in environment.\n" unless $ENV{PG_ROOT}; + $main::pg_dir = $ENV{PG_ROOT}; +} + +use lib "$main::pg_dir/lib"; + +require("$main::pg_dir/t/build_PG_envir.pl"); + +## END OF TOP_MATERIAL + +loadMacros("contextTrigDegrees.pl"); + +my $ctx = Context("TrigDegrees"); + +ok(Value::isContext($ctx), "trig degrees: check context"); + +my $cos60 = Compute("cos(60)"); + +Compute("cos(60)")->cmp->evaluate("1/2"); +# dd Compute("1/2")->value; +# is (check_score($cos60,"1/2"),1,"trig degrees: cos(60) = 1/2"); + +# dd $cos60->cmp->evaluate("1/2")->{type}; +# dd $cos60->cmp->evaluate("1/2")->{score}; +# dd $cos60->cmp->evaluate("1/2")->{correct_ans}; +# dd $cos60->cmp->evaluate("1/2")->{student_ans}; + +# is (check_score(Compute("cos(60)"),"sin(30)"),1,"trig degrees: cos(60) = 1/2"); + +done_testing(); diff --git a/t/latex_image_test/latex_image_test1.pg b/t/latex_image_test/latex_image_test1.pg new file mode 100644 index 0000000000..109e42beed --- /dev/null +++ b/t/latex_image_test/latex_image_test1.pg @@ -0,0 +1,41 @@ +##DESCRIPTION +# TEST tikz from a pg problem +##ENDDESCRIPTION + +DOCUMENT(); + +loadMacros( + "PGstandard.pl", + "MathObjects.pl", + "PGlateximage.pl" +); + +TEXT(beginproblem()); + +############################################################## +# Setup +############################################################## + +$drawing = createLaTeXImage(); +$drawing->texPackages([['xy','all']]); +$drawing->BEGIN_LATEX_IMAGE +\xymatrix{ A \ar[r] & B \ar[d] \\\\ + D \ar[u] & C \ar[l] } +END_LATEX_IMAGE + +$path = insertGraph($drawing); + +Context("Numeric"); + +############################################################## +# Text +############################################################## + +BEGIN_TEXT +\{protect_underbar("path = $path")\}; +$BR alias = \{protect_underbar(alias($path))\} +$PAR image = \{image($path, width => 228, height => 114, tex_size => 400)\} +$PAR svg = \{embedSVG($path)\} +END_TEXT + +ENDDOCUMENT(); diff --git a/t/latex_image_test/latex_image_test2.pg b/t/latex_image_test/latex_image_test2.pg new file mode 100644 index 0000000000..686ad21551 --- /dev/null +++ b/t/latex_image_test/latex_image_test2.pg @@ -0,0 +1,44 @@ +##DESCRIPTION +# TEST tikz from a pg problem +##ENDDESCRIPTION + +DOCUMENT(); + +loadMacros( + "PGstandard.pl", + "MathObjects.pl", + "PGlateximage.pl" +); + +TEXT(beginproblem()); + +############################################################## +# Setup +############################################################## + +$drawing = createLaTeXImage(); +$drawing->texPackages(['circuitikz']); +$drawing->environment(['circuitikz','scale=1.2, transform shape']); +$drawing->BEGIN_LATEX_IMAGE +\draw (60,1) to [battery2, v_=\(V_{cc}\), name=B] ++(0,2); +\node[draw,red,circle,inner sep=4pt] at(B.left) {}; +\node[draw,red,circle,inner sep=4pt] at(B.right) {}; +END_LATEX_IMAGE + +$path = insertGraph($drawing); + +Context("Numeric"); + +############################################################## +# Text +############################################################## + +BEGIN_TEXT +\{protect_underbar("path = $path")\}; +$BR alias = \{protect_underbar(alias($path))\} +$PAR image = \{image($path, width => 228, height => 114, tex_size => 400)\} +$PAR svg = \{embedSVG($path)\} +END_TEXT + +ENDDOCUMENT(); + diff --git a/t/macros/basicmacros.t b/t/macros/basicmacros.t new file mode 100644 index 0000000000..eab341352f --- /dev/null +++ b/t/macros/basicmacros.t @@ -0,0 +1,41 @@ +use warnings; +use strict; + +package main; + +use Test::More; +use Test::Exception; + +# The following needs to include at the top of any testing down to END OF TOP_MATERIAL. + +BEGIN { + die "PG_ROOT not found in environment.\n" unless $ENV{PG_ROOT}; + $main::pg_dir = $ENV{PG_ROOT}; +} + +use lib "$main::pg_dir/lib"; + +require("$main::pg_dir/t/build_PG_envir.pl"); + +## END OF TOP_MATERIAL + +loadMacros("PGbasicmacros.pl"); + +use HTML::Entities; +use HTML::TagParser; + +my $name = "myansrule"; +my $named_box = NAMED_ANS_RULE($name); + +my $html = HTML::TagParser->new($named_box); + +my @inputs = $html->getElementsByTagName("input"); +is($inputs[0]->attributes->{id}, $name, "basicmacros: test NAMED_ANS_RULE id attribute"); +is($inputs[0]->attributes->{name}, $name, "basicmacros: test NAMED_ANS_RULE name attribute"); +is($inputs[0]->attributes->{type}, "text", "basicmacros: test NAMED_ANS_RULE type attribute"); +ok(!$inputs[0]->attributes->{value}, "basicmacros: test NAMED_ANS_RULE value attribute"); + +is($inputs[1]->attributes->{name}, "previous_$name", "basicmacros: test NAMED_ANS_RULE hidden name attribute"); +is($inputs[1]->attributes->{type}, "hidden", "basicmacros: test NAMED_ANS_RULE hidden type attribute"); + +done_testing(); diff --git a/t/macros/math_objects_basics.t b/t/macros/math_objects_basics.t new file mode 100644 index 0000000000..a0b5a18d72 --- /dev/null +++ b/t/macros/math_objects_basics.t @@ -0,0 +1,98 @@ +use warnings; +use strict; + +package main; + +use Test::More; +use Test::Exception; + +# The following needs to include at the top of any testing down to END OF TOP_MATERIAL. + +BEGIN { + die "PG_ROOT not found in environment.\n" unless $ENV{PG_ROOT}; + $main::pg_dir = $ENV{PG_ROOT}; +} + +use lib "$main::pg_dir/lib"; + +require("$main::pg_dir/t/build_PG_envir.pl"); + +## END OF TOP_MATERIAL + +use Parser; + +loadMacros("MathObjects.pl"); + +Context("Numeric"); + +my ($val1, $val2) = (10, 5); +my $obj1 = Compute($val1); +my $obj2 = Compute($val2); +my $one = Compute("1"); +my $zero = Compute("0"); + +is($obj1->class, "Real", "math objects: check class of object"); +is($obj2->type, "Number", "math objects: check type of object"); +ok($one->isOne, "math objects: check if a number is 1"); +ok(!$zero->isOne, "math objects: check if a number is not 1"); +ok($zero->isZero, "math objects: check if a number is 0"); +ok(!$one->isZero, "math objects: check if a number is not 0"); + +ok(Value::isValue($obj1), "math objects: check if an object is a value"); +ok(Value::isNumber($obj1), "math objects: check if an object is a number"); +ok(Value::isReal($obj1), "math objects: check if a number is a real number"); +ok(!Value::isComplex($obj1), "math objects: check if an integer is complex"); + +ok(!Value::isFormula($obj1), "math objects: check if a number is not a formula"); + +# check infinite values +note("Tests for infinite values"); + +my $inf = Compute("inf"); +is($inf->value, "infinity", "math objects: check for infinity via a string"); +is($inf->class, "Infinity", "math objects: check that the class is Infinity"); +is($inf->type, "Infinity", "math objects: check that the type is Infinity"); +ok(!Value::isNumber($inf), "math objects: check if inf is a number"); + +# check that operations with infinity are not allowed + +throws_ok { + Compute("$obj1+$inf"); +} +qr/can't be infinities/, "math objects: addition with infinity"; +throws_ok { + Compute("$obj1-$inf"); +} +qr/can't be infinities/, "math objects: subtraction with infinity"; +throws_ok { + Compute("$obj1*$inf"); +} +qr/can't be infinities/, "math objects: multiplication with infinity"; +throws_ok { + Compute("$obj1/$inf"); +} +qr/can't be infinities/, "math objects: division with infinity"; + +# is($result1->value,"infinity","math objects: check that the sum of a finite and infinite value is infinite"); + +my $sum = $obj1 + $obj2; +my $diff = $obj1 - $obj2; +my $prod = $obj1 * $obj2; + +is($sum->value, $val1 + $val2, "math objects: test sum"); +is($diff->value, $val1 - $val2, "math objects: test difference"); +is($prod->value, $val1 * $val2, "math objects: test product"); + +## check scores using the cmp method + +is(check_score($sum, Compute($sum)), 1, "math object: use cmp to check sum"); +is(check_score($diff, Compute($diff)), 1, "math object: use cmp to check diff"); +is(check_score($prod, Compute($prod)), 1, "math object: use cmp to check prod"); + +## check some wrong answers; + +is(check_score($sum, Compute($sum + 1)), 0, "math object: use cmp to check sum"); +is(check_score($diff, Compute($diff + 1)), 0, "math object: use cmp to check diff"); +is(check_score($prod, Compute($prod + 1)), 0, "math object: use cmp to check prod"); + +done_testing(); diff --git a/t/macros/math_objects_more.t b/t/macros/math_objects_more.t new file mode 100644 index 0000000000..e54bb409e8 --- /dev/null +++ b/t/macros/math_objects_more.t @@ -0,0 +1,45 @@ +use warnings; +use strict; + +package main; + +use Test::More; +use Test::Exception; + +# The following needs to include at the top of any testing down to END OF TOP_MATERIAL. + +BEGIN { + die "PG_ROOT not found in environment.\n" unless $ENV{PG_ROOT}; + $main::pg_dir = $ENV{PG_ROOT}; +} + +use lib "$main::pg_dir/lib"; + +require("$main::pg_dir/t/build_PG_envir.pl"); + +## END OF TOP_MATERIAL + +loadMacros("MathObjects.pl"); + +my $ctx = Context("Numeric"); + +ok(Value::isContext($ctx), "math objects: check context"); + +my $f = Compute("x^2"); +my $g = Compute("sin(x)"); + +ok(Value::isFormula($f), "math objects: check for formula"); +is($f->class, "Formula", "math objects: check that the class is Formula"); +is($f->type, "Number", "math objects: check that the type is Number"); + +## check answer evaluators + +is(check_score($f->eval(x => 2), "4"), 1, "math objects: eval x^2 at x=2"); +is(check_score($f->eval(x => -3), "9"), 1, "math objects: eval x^2 at x=-3"); +# is(check_score($g->eval(x=>Compute("pi/6")),"1/2"),1,"math objects: eval sin(x) at x=pi/6"); + +## check derivatives +is(check_score($f->D("x"), "2x"), 1, "math objects: derivative of x^2"); +is(check_score($g->D("x"), "cos(x)"), 1, "math objects: derivative of sin(x)"); + +done_testing(); diff --git a/t/macros/pgaux.t b/t/macros/pgaux.t new file mode 100644 index 0000000000..73bc3031de --- /dev/null +++ b/t/macros/pgaux.t @@ -0,0 +1,123 @@ +use warnings; +use strict; + +package main; + +use Test::More; + +## the following needs to include at the top of any testing down to TOP_MATERIAL + +BEGIN { + die "PG_ROOT not found in environment.\n" unless $ENV{PG_ROOT}; + $main::pg_dir = $ENV{PG_ROOT}; +} + +use lib "$main::pg_dir/lib"; +require("$main::pg_dir/t/build_PG_envir.pl"); + +## END OF TOP_MATERIAL + +loadMacros("PGauxiliaryFunctions.pl"); + +# test step functions + +is(step(8), 1, "step: positive number"); +is(step(-8), 0, "step: negative number"); +is(step(0), 0, "step: step(0)=0"); + +# test floor function + +is(floor(0.5), 0, "floor: positive non-integer"); +is(floor(-0.5), -1, "floor: negative non-integer"); +is(floor(1), 1, "floor: positive integer"); +is(floor(0), 0, "floor: floor(0)=0"); +is(floor(-1), -1, "floor: negative integer"); + +# test ceiling function + +is(ceil(0.5), 1, "ceil: positive non-integer"); +is(ceil(-0.5), 0, "ceil: negative non-integer"); +is(ceil(1), 1, "ceil: positive integer"); +is(ceil(0), 0, "ceil: floor(0)=0"); +is(ceil(-1), -1, "ceil: negative integer"); + +# max/min functions + +is(max(1, 2, 3, 9, 4, 5, 6, 8), 9, "max: set of integers"); +is(max(0.1, -2.3, 1.345, 2.71712, -1000.1), 2.71712, "max: set of decimals"); +is(min(1, 2, 3, 9, 4, 5, 6, 8), 1, "min: set of integers"); +is(min(0.1, -2.3, 1.345, 2.71712, -1000.1), -1000.1, "min: set of decimals"); + +# round function + +is(round(0.95), 1, "round: fractional part > 0.5"); +is(round(0.45), 0, "round: fractional part < 0.5"); +is(round(0.5), 1, "round: fractional part = 0.5"); +is(round(-0.95), -1, "round: fractional part > 0.5 and negative"); +is(round(-0.45), 0, "round: fractional part < 0.5 and negative"); +is(round(-0.5), -1, "round: fractional part = 0.5 and negative"); + +## Round function which takes a second number, the number of digits to round to + +is(Round(1.793, 2), 1.79, "Round to 2 digits: test 1"); +is(Round(1.797, 2), 1.80, "Round to 2 digits: test 2"); +is(Round(1.795, 2), 1.80, "Round to 2 digits: test 3"); +is(Round(-1.793, 2), -1.79, "Round to 2 digits: test 1"); +is(Round(-1.797, 2), -1.80, "Round to 2 digits: test 2"); +is(Round(-1.795, 2), -1.80, "Round to 2 digits: test 3"); + +is(Round(15.793, -1), 20, "Round to -1 digits (nearest 10)"); + +## lcm + +is(lcm(20, 30), 60, "lcm: non relatively prime numbers"); +is(lcm(5, 6), 30, "lcm: relatively prime numbers"); +is(lcm(2, 3, 4), 12, "lcm: 3 numbers"); +is(lcm(2, 3, 4, 5, 6, 7, 8), 840, "lcm: 7 numbers"); + +## gcd +is(gcd(16, 8), 8, "gcd: 2 powers of 2"); +is(gcd(10, 9), 1, "gcd: 2 relatively prime"); + +is(gcd(10, 20, 30, 40), 10, "gcd: 4 multiples of 10"); + +## isPrime +is(isPrime(7), 1, "isPrime: 7 is prime"); +is(isPrime(2), 1, "isPrime: 2 is prime"); +is(isPrime(15), 0, "isPrime: 15 is not prime"); + +## random_coprime + +my $sum = 0; +for my $i (1 .. 100) { + my @coprimes = random_coprime([ 1 .. 20 ], [ 1 .. 20 ]); + $sum += gcd($coprimes[0], $coprimes[1]); +} +is($sum, 100, "random_coprime: 100 tests in 1..20,1..20"); + +$sum = 0; + +for my $i (1 .. 100) { + my @coprimes = random_coprime([ -9 .. -1, 1 .. 9 ], [ 1 .. 9 ], [ 1 .. 9 ]); + $sum += gcd(@coprimes); +} +is($sum, 100, "random_coprime: 100 tests in [-9..-1,1..9],[1..9],[1..9]"); + +my ($sum1, $sum2, $sum3, $sum4) = (0, 0, 0); +for my $i (1 .. 100) { + my @coprimes = random_pairwise_coprime([ -9 .. -1, 1 .. 9 ], [ 1 .. 9 ], [ 1 .. 9 ]); + $sum1 += gcd(@coprimes); + $sum2 += gcd($coprimes[0], $coprimes[1]); + $sum3 += gcd($coprimes[0], $coprimes[2]); + $sum4 += gcd($coprimes[1], $coprimes[2]); +} +is($sum1 + $sum2 + $sum3 + $sum4, 400, "random_pairwise_coprime: 100 tests of [-9..-1,1..9],[1..9],[1..9]"); + +## reduce +## it would be nicer to directly compare the arrays +my @my_arr = (3, 4); +my @res = reduce(15, 20); +is($my_arr[0], $res[0], "reduce: correct numerator"); +is($my_arr[1], $res[1], "reduce: correct denominator"); + +done_testing; diff --git a/t/macros/tableau.t b/t/macros/tableau.t new file mode 100755 index 0000000000..a059ab4557 --- /dev/null +++ b/t/macros/tableau.t @@ -0,0 +1,322 @@ +use warnings; +use strict; + +package main; + +use Test::More; +use Test::Exception; + +# The following needs to include at the top of any testing down to END OF TOP_MATERIAL. + +BEGIN { + die "PG_ROOT not found in environment.\n" unless $ENV{PG_ROOT}; + $main::pg_dir = $ENV{PG_ROOT}; +} + +use lib "$main::pg_dir/lib"; + +require("$main::pg_dir/t/build_PG_envir.pl"); + +## END OF TOP_MATERIAL + +use Parser; +use Value; +use Class::Accessor; +use PGcore; + +loadMacros("tableau.pl", "Value.pl"); #gives us Real() etc. + +note( + "THIS FILE TESTS MANY OF THE FUNCTIONS PROVIDED BY tableau.pl +# +# +" +); + +my %context = (); + +sub Context { Parser::Context->current(\%context, @_) } +unless (%context && $context{current}) { + # ^variable our %context + %context = (); # Locally defined contexts, including 'current' context + # ^uses Context + Context(); # Initialize context (for persistent mod_perl) +} + +sub WARN_MESSAGE { + warn("WARN MESSAGE: ", @_); +} + +sub DEBUG_MESSAGE { + warn("DEBUG MESSAGE: ", @_); +} +Context("Matrix"); + +Context()->flags->set( + zeroLevel => 1E-5, + zeroLevelTol => 1E-5 +); + +my $A = Real(.0000005); +my $B = Real(0); + +is($A, $B, "test zeroLevel tolerance"); +ok($A == $B, "test zeroLevel tolerance with ok"); + +my $money_total = 6000; +my $time_total = 600; + +# Bill +my $bill_money_commitment = 5000; #dollars +my $bill_time_commitment = 400; # hours +my $bill_profit = 4700; + +# Steve +my $steve_money_commitment = 3000; +my $steve_time_commitment = 500; +my $steve_profit = 4500; + +#### problem starts here: + +# need error checking to make sure that tableau->new checks +# that inputs are matrices +my $ra_matrix = [ + [ -$bill_money_commitment, -$bill_time_commitment, -1, 0, 1, 0, 0, -$bill_profit ], + [ -$steve_money_commitment, -$steve_time_commitment, 0, -1, 0, 1, 0, -$steve_profit ], + [ -$money_total, -$time_total, -1, -1, 0, 0, 1, 0 ] +]; +my $a = Value::Matrix->new([ + [ -$bill_money_commitment, -$bill_time_commitment, -1, 0 ], + [ -$steve_money_commitment, -$steve_time_commitment, 0, -1 ] +]); +my $b = Value::Vector->new([ -$bill_profit, -$steve_profit ]); # need vertical vector +my $c = Value::Vector->new([ $money_total, $time_total, 1, 1 ]); + +my $tableau1 = Tableau->new(A => $a, b => $b, c => $c); +############################################################### +# Check mutators +# +# +############################################################### + +ok(1 == 1, "trivial first test"); +ok(defined($tableau1), 'tableau has been defined and loaded'); +is(ref($tableau1), "Tableau", 'has type Tableau'); + +# test "close_enough_to_zero" subroutine +is $tableau1->close_enough_to_zero(0), 1, "checking_close_enough to zero"; +is $tableau1->close_enough_to_zero(1e-9), 0, "checking_close_enough to zero"; +is $tableau1->close_enough_to_zero(1e-5), 0, "checking_close_enough to zero"; +is $tableau1->close_enough_to_zero(1e-10), 1, "checking_NOT_close_enough to zero for 1e-10 "; +note("sanity check 1e-10 vs 10**(-10): ", 1e-10, " ", 10**(-10)); +note(1.e-10); +note(0.9999e-10); +note(-0.9999e-10); +is $tableau1->close_enough_to_zero(0.9999e-10), 1, "checking_close_enough to zero for 0.9999e-10"; + +is $tableau1->close_enough_to_zero(-0.9999e-10), 1, "checking_close_enough to zero for -0.9999e-10"; +note("display stringified \$tableau1: ", $tableau1, "\n"); +is ref($tableau1), "Tableau", "checking data type is Tableau"; +ok $tableau1 eq "[[-5000,-400,-1,0,1,0,0,-4700],[-3000,-500,0,-1,0,1,0,-4500]]", "checking_stringification of tableau"; + +is($tableau1->{m}, 2, 'number of constraints is 2'); +is($tableau1->{n}, 4, 'number of variables is 4'); +is_deeply([ $tableau1->{m}, $tableau1->{n} ], [ $tableau1->{A}->dimensions ], '{m},{n} match dimensions of A'); +is_deeply($tableau1->{A}, $a, 'constraint matrix'); +is_deeply($tableau1->{b}, Matrix([$b])->transpose, 'constraint constants is m by 1 matrix'); +is_deeply($tableau1->{c}, $c, 'objective function constants'); +is_deeply($tableau1->{A}, $tableau1->A, '{A} original constraint matrix accessor'); +is_deeply($tableau1->{b}, $tableau1->b, '{b} orginal constraint constants accessor'); +is_deeply($tableau1->{c}, $tableau1->c, '{c} original objective function constants accessor'); + +my $test_constraint_matrix = Matrix($ra_matrix->[0], $ra_matrix->[1]); +is_deeply($tableau1->{current_constraint_matrix}, + $test_constraint_matrix, 'initialization of current_constraint_matrix'); +is_deeply( + $tableau1->{current_constraint_matrix}, + $tableau1->current_constraint_matrix, + 'current_constraint_matrix accessor' +); +is_deeply($tableau1->{current_b}, $tableau1->{b}, 'initialization of current_b'); +is_deeply($tableau1->{current_b}, $tableau1->current_b, 'current_b accessor'); +is_deeply([ $tableau1->current_b->dimensions ], [ 2, 1 ], 'dimensions of current_b'); +my $obj_row_test = [ ((-$c)->value, 0, 0, 1, 0) ]; +is_deeply($tableau1->objective_row, $obj_row_test, 'initialization of $tableau->{obj_row}'); + +is(ref($tableau1->{obj_row}), 'Value::Matrix', '->{obj_row} has type Value::Matrix'); +is(ref($tableau1->obj_row), 'Value::Matrix', '->obj_row has type Value::Matrix'); +is_deeply($tableau1->obj_row, $tableau1->{obj_row}, 'verify mutator for {obj_row}'); +is_deeply(ref($tableau1->objective_row), 'ARRAY', '->objective_row has type ARRAY'); +is_deeply($tableau1->objective_row, [ $tableau1->{obj_row}->value ], 'access to {obj_row}'); +is_deeply($tableau1->objective_row, [ $tableau1->obj_row->value ], 'objective_row is obj_row->value = ARRAY'); + +is(ref($tableau1->current_tableau), 'Value::Matrix', '-> current_tableau is Value::Matrix'); +is_deeply($tableau1->current_tableau, Matrix($ra_matrix), 'entire tableau including obj coeff row'); + +is(ref($tableau1->S), "Value::Matrix", 'slack variables are a Value::Matrix'); +is_deeply($tableau1->S, $tableau1->I($tableau1->m), 'slack variables are identity matrix'); + +# test basis +is_deeply(ref($tableau1->basis_columns), "ARRAY", "{basis_column} has type ARRAY"); +is_deeply($tableau1->basis_columns, [ 5, 6 ], "initialization of basis"); +is( + ref($tableau1->current_basis_matrix), + ref(Value::Matrix->I($tableau1->m)), + "current_basis_matrix type is MathObjectMatrix" +); +is_deeply($tableau1->current_basis_matrix, Value::Matrix->I($tableau1->m), "initialization of basis"); + +# change basis and test again +$tableau1->basis(2, 3); +is_deeply(ref($tableau1->basis_columns), "ARRAY", "{basis_column} has type ARRAY"); +is_deeply($tableau1->basis_columns, [ 2, 3 ], " basis columns set to {2,3}"); +is( + ref($tableau1->current_basis_matrix), + ref($test_constraint_matrix->column_slice(2, 3)), + "current_basis_matrix type is MathObjectMatrix" +); +is_deeply( + $tableau1->current_basis_matrix, + $test_constraint_matrix->column_slice(2, 3), + "basis_matrix for columns {2,3} is correct" +); +is_deeply($tableau1->basis(Set(2, 3)), List([ 2, 3 ]), "->basis(Set(2,3))"); +is_deeply($tableau1->basis(List(2, 3)), List([ 2, 3 ]), "->basis(List(2,3))"); +is_deeply($tableau1->basis([ 2, 3 ]), List([ 2, 3 ]), "->basis([2,3])"); + +# find basis column index corresponding to row index (and value of the basis coefficient) + +$tableau1->basis(5, 6); +note("\nbasis is", $tableau1->basis(5, 6)); +note(print $tableau1->current_tableau, "\n"); +is_deeply([ $tableau1->find_leaving_column(1) ], [ 5, 1 ], "find_leaving_column returns [col_index, pivot_value] "); +is_deeply([ $tableau1->find_leaving_column(2) ], [ 6, 1 ], "find_leaving_column returns [col_index, pivot_value] "); + +is_deeply($tableau1->find_next_basis_from_pivot(1, 2), Set(2, 6), "find next basis from pivot (1,2)"); +is_deeply($tableau1->find_next_basis_from_pivot(1, 3), Set(3, 6), "find next basis from pivot (1,3)"); +is_deeply($tableau1->find_next_basis_from_pivot(2, 1), Set(1, 5), "find next basis from pivot (2,1)"); +is_deeply($tableau1->find_next_basis_from_pivot(1, 1), Set(1, 6), "find next basis from pivot (1,1)"); + +throws_ok( + sub { $tableau1->find_next_basis_from_pivot(2, 5) }, + qr/pivot point should not be in a basis column/, + "can't pivot in basis column (2,5)" +); # probably shouldn't be doing this. +throws_ok( + sub { $tableau1->find_next_basis_from_pivot(1, 6) }, + qr/pivot point should not be in a basis column/, + "can't pivot in basis column (2,6)" +); # probably shouldn't be doing this. +is_deeply($tableau1->find_next_basis_from_pivot(2, 1), Set(1, 5), "find next basis from pivot (2,1)"); +throws_ok( + sub { $tableau1->find_next_basis_from_pivot(2, 6) }, + qr/pivot point should not be in a basis column/, + "can't pivot in basis column (2,6)" +); # probably shouldn't be doing this. + +$tableau1->basis(2, 3); +note("\nbasis is", $tableau1->basis()); +note(print $tableau1->current_tableau, "\n"); +is_deeply([ $tableau1->find_leaving_column(1) ], [ 2, 500 ], "find_leaving_column returns [col_index, pivot_value] "); +is_deeply([ $tableau1->find_leaving_column(2) ], [ 3, 500 ], "find_leaving_column returns [col_index, pivot_value] "); + +throws_ok( + sub { $tableau1->find_next_basis_from_pivot(1, 2) }, + qr/pivot point should not be in a basis column/, + "can't pivot in basis column (1,2)" +); # probably shouldn't be doing this either. +throws_ok( + sub { $tableau1->find_next_basis_from_pivot(1, 3) }, + qr/pivot point should not be in a basis column.*/, + "can't pivot in basis column (1,3)" +); # probably shouldn't be doing this. +is_deeply($tableau1->find_next_basis_from_pivot(2, 1), Set(1, 2), "find next basis from pivot (2,1)"); +is_deeply($tableau1->find_next_basis_from_pivot(1, 1), Set(1, 3), "find next basis from pivot (1,1)"); + +$tableau1->basis(5, 6); +note("\nbasis is ", $tableau1->basis()); +note($tableau1->current_tableau, "\n"); +note("find next short cut pivots"); +# ($row_index, $value, $feasible_point) = $self->find_short_cut_row() +is_deeply([ $tableau1->find_short_cut_row() ], [ 1, -4700, 0 ], "row 1"); +is_deeply([ $tableau1->find_short_cut_column(1) ], [ 1, -5000, 0 ], "column 1 "); +is_deeply([ $tableau1->next_short_cut_pivot() ], [ 1, 1, 0, 0 ], "pivot (1,1)"); +is_deeply([ $tableau1->next_short_cut_basis() ], [ 1, 6, undef ], "new basis {1,6} continue"); +$tableau1->current_tableau(1, 6); +note($tableau1->current_tableau); + +is_deeply([ $tableau1->find_short_cut_row ], [ 2, Value::Real->new(-8.4E+06), 0 ], "find short cut row"); +is_deeply([ $tableau1->find_short_cut_column(2) ], [ 2, Value::Real->new(-1.3E+06), 0 ], "find short cut col 2 "); +is_deeply([ $tableau1->next_short_cut_pivot() ], [ 2, 2, 0, 0 ], "pivot (2,2)"); +is_deeply([ $tableau1->next_short_cut_basis() ], [ 1, 2, undef ], "new basis {1,2} continue"); + +$tableau1->current_tableau(1, 2); +note($tableau1->current_tableau); + +is_deeply([ $tableau1->next_short_cut_pivot() ], [ undef, undef, 1, 0 ], "feasible point found"); +is_deeply( + [ $tableau1->next_short_cut_basis() ], + [ 1, 2, 'feasible_point' ], + "all constraints positive at basis {1,2} --start phase2" +); +is_deeply([ $tableau1->find_pivot_column('max') ], [ 3, Value::Real->new(-100000), 0 ], "col 3"); +is_deeply([ $tableau1->find_pivot_row(3) ], [ 1, Value::Real->new(550000 / 500), 0 ], "row 1 "); +is_deeply([ $tableau1->find_next_pivot('max') ], [ 1, 3, 0, 0 ], "pivot (1,3)"); +is_deeply([ $tableau1->find_next_basis('max') ], [ 2, 3, undef ], "new basis {2,3} continue"); + +$tableau1->current_tableau(2, 3); +note($tableau1->current_tableau); +is_deeply([ $tableau1->find_pivot_column('max') ], [ 4, Value::Real->new(-300), 0 ], "col 4"); +is_deeply([ $tableau1->find_pivot_row(4) ], [ 1, 4500, 0 ], "row 2) "); + +is_deeply([ $tableau1->find_next_pivot('max') ], [ 1, 4, 0, 0 ], "pivot 1,4"); +is_deeply([ $tableau1->find_next_basis('max') ], [ 3, 4, undef ], "new basis {3,4} continue"); + +$tableau1->current_tableau(3, 4); +note($tableau1->current_tableau); +is_deeply([ $tableau1->find_pivot_column('max') ], [ 5, Value::Real->new(-1), 0 ], "col 5"); +is_deeply([ $tableau1->find_pivot_row(5) ], [ undef, undef, 1 ], "row 2) "); + +is_deeply([ $tableau1->find_next_pivot('max') ], [ undef, 5, 0, 1 ], "unbounded -- no pivot"); +is_deeply([ $tableau1->find_next_basis('max') ], [ 3, 4, 'unbounded' ], "basis 3,4 unbounded"); +# note that the column is returned from find_next_pivot so one can find a certificate +# of unboundedness (can return a line going off to infinity) + +# # this is ok -- we're looking at the dual of the bill and steve problem +# # and the original test was to minimize it not to maximize it +# # recheck the original problem with websim!!!! +# +# # regularize the output for row and column definitions if one of the flags is set. +# # can we always set those to undefined? +# # can we change the flag notification to +# # "unbounded, feasible_point, infeasible_tableau, optimal"? +# # it might be easier to remember. +# +note("reset tableau to feasible point and try to minimize it for phase2"); +$tableau1->current_tableau(1, 2); +note($tableau1->current_tableau); +is_deeply([ $tableau1->next_short_cut_pivot() ], [ undef, undef, 1, 0 ], "feasible point found"); +is_deeply( + [ $tableau1->next_short_cut_basis() ], + [ 1, 2, 'feasible_point' ], + "all constraints positive at basis {1,2} --start phase2" +); + +is_deeply([ $tableau1->find_pivot_column('min') ], [ undef, undef, 1 ], "all neg coeff"); +is_deeply([ $tableau1->find_pivot_row(1) ], [ 1, Value::Real->new(550000 / 1300000), 0 ], "row 1 "); +is_deeply([ $tableau1->find_next_pivot('min') ], [ undef, undef, 1, 0 ], "optimum"); +is_deeply([ $tableau1->find_next_basis('min') ], [ 1, 2, 'optimum' ], "optimum"); +# +# + +is_deeply( + $tableau1->statevars, # round off errors + [ 550000 / 1300000, 8400000 / 1300000, 0, 0, 0, 0, 8.339999999999999E9 / 1300000 ], "state variables" +); + +is($tableau1->align, 'cccc|cc|c|c', "check align"); +is_deeply($tableau1->toplevel, [qw(x1 x2 x3 x4 x5 x6 z b)], "check toplevel"); + +# diag($tableau1->align); +# diag(join(" " , @{$tableau1->toplevel})); +done_testing(); diff --git a/t/math_objects/factorial.t b/t/math_objects/factorial.t new file mode 100644 index 0000000000..5342054ad5 --- /dev/null +++ b/t/math_objects/factorial.t @@ -0,0 +1,78 @@ +use warnings; +use strict; + +package main; + +use Test::More; +use Test::Exception; + +# The following needs to include at the top of any testing down to END OF TOP_MATERIAL. + +BEGIN { + die 'PG_ROOT not found in environment.\n' unless $ENV{PG_ROOT}; + $main::pg_dir = $ENV{PG_ROOT}; +} + +use lib "$main::pg_dir/lib"; + +require("$main::pg_dir/t/build_PG_envir.pl"); + +## END OF TOP_MATERIAL + +use Parser; + +loadMacros('MathObjects.pl'); + +Context('Numeric'); +Context()->variables->add(y => "Real"); +Context()->variables->add(n => "Real"); + +my $five_fact = Compute('5!'); + +use Data::Dumper; +print Dumper $five_fact->class; + +is($five_fact->class, 'Real', 'factorial: check class of object'); +is($five_fact->type, 'Number', 'factorial: check type of object'); + +ok(Value::isValue($five_fact), 'factorial: check if an object is a value'); +ok(Value::isNumber($five_fact), 'factorial: check if an object is a number'); +ok(Value::isReal($five_fact), 'factorial: check if a number is a real number'); +ok(!Value::isComplex($five_fact), 'factorial: check if an integer is complex'); +ok(!Value::isFormula($five_fact), 'factorial: check if a number is not a formula'); + +is($five_fact->value,120, 'factorial: 5! is 120'); +is(Compute("0!")->value, 1, 'factorial: 0! is 1'); + +note('The double factorial is not defined here.'); +my $four_double_fact = Compute("4!!")->value; +ok(6.2e+23 < $four_double_fact && $four_double_fact < 6.3e+23, 'factorial: 4!! is defined as (4!)!=24!' ); + +ok(Compute("170!") > 1e+306, 'factorial: 170! is large but not infinite.'); + +note('Tests for throwing exceptions.'); + +throws_ok { + Compute("(-1)!"); +} +qr/Factorial can only be taken of \(non-negative\) integers/, 'factorial: can\'t take factorial of negative integers.'; + +throws_ok { + Compute("1.5!"); +} +qr/Factorial can only be taken of \(non-negative\) integers/, 'factorial: can\'t take factorial of non-integer reals.'; + +note('Try taking factorials of variables'); +my $n_fact = Compute("n!"); +is($n_fact->class, "Formula", "factorial: n! is a Formula"); +is($n_fact->type, "Number", "factorial: n! has type is Number"); +is($n_fact->eval(n=>5), 120, 'factorial: n! evaluated at n=5 is correct.'); + +# check infinite values +note('Tests for infinite values'); + +my $large_fact = Compute('171!'); +my $inf = Compute('inf'); +is($large_fact->value, $inf, '171! is infinite.'); + +done_testing(); diff --git a/t/tikz_test/tikz_test3.pg b/t/tikz_test/tikz_test3.pg new file mode 100644 index 0000000000..0f686dc8d8 --- /dev/null +++ b/t/tikz_test/tikz_test3.pg @@ -0,0 +1,72 @@ +##DESCRIPTION +# TEST tikz from a pgml problem +##ENDDESCRIPTION + +DOCUMENT(); + +loadMacros( + "PGstandard.pl", + "MathObjects.pl", + "PGML.pl", + "PGtikz.pl" +); + +TEXT(beginproblem()); + +############################################################## +# Setup +############################################################## + +$a = random(1, 4); +$b = random(3, 6); +$c = random(5, 8); +$d = random(7, 10); + +$tikz_code = <texPackages([["pgfplots"]]); +$drawing->addToPreamble("\pgfplotsset{compat=1.15}"); +$drawing->tikzOptions("main_node/.style={circle,fill=blue!20,draw,minimum size=1em,inner sep=3pt}"); +$drawing->tex($tikz_code); + + +$path = insertGraph($drawing); + +Context("Numeric"); + +############################################################## +# Text +############################################################## + +BEGIN_PGML +path = [@ protect_underbar($path) @] +[@ $BR @]* +alias = [@ protect_underbar(alias($path)) @]* + +image = [@ image($path, width => 100, tex_size => 400) @]* + +svg = [@ embedSVG($path) @]* +END_PGML + +ENDDOCUMENT();