From 46d358fe5d8504d9f5073066af5b2b543aeb86d7 Mon Sep 17 00:00:00 2001 From: Suhas Dissanayake Date: Sat, 4 May 2024 20:32:42 +0530 Subject: [PATCH] feat: add graphing calculator --- .../java/net/youapps/calcyou/Destination.kt | 1 + .../main/java/net/youapps/calcyou/NavHost.kt | 5 + .../data/graphing/CompiledExpression.kt | 41 ++ .../youapps/calcyou/data/graphing/Defaults.kt | 157 +++++ .../calcyou/data/graphing/EvalConfig.kt | 87 +++ .../calcyou/data/graphing/Evaluator.kt | 544 ++++++++++++++++++ .../youapps/calcyou/data/graphing/Function.kt | 21 + .../calcyou/data/graphing/OffsetConverters.kt | 25 + .../youapps/calcyou/data/graphing/Token.kt | 70 +++ .../youapps/calcyou/data/graphing/Window.kt | 63 ++ .../ui/components/AddNewFunctionDialog.kt | 154 +++++ .../ui/components/ColorSelectionDialog.kt | 212 +++++++ .../calcyou/ui/components/NavDrawerContent.kt | 39 +- .../calcyou/ui/screens/graphing/CanvasView.kt | 103 ++++ .../ui/screens/graphing/GraphingScreen.kt | 148 +++++ .../ui/screens/graphing/GraphingUtils.kt | 268 +++++++++ .../calcyou/viewmodels/GraphViewModel.kt | 79 +++ app/src/main/res/values/strings.xml | 10 + .../java/net/youapps/calcyou/EvaluatorTest.kt | 65 +++ 19 files changed, 2091 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/net/youapps/calcyou/data/graphing/CompiledExpression.kt create mode 100644 app/src/main/java/net/youapps/calcyou/data/graphing/Defaults.kt create mode 100644 app/src/main/java/net/youapps/calcyou/data/graphing/EvalConfig.kt create mode 100644 app/src/main/java/net/youapps/calcyou/data/graphing/Evaluator.kt create mode 100644 app/src/main/java/net/youapps/calcyou/data/graphing/Function.kt create mode 100644 app/src/main/java/net/youapps/calcyou/data/graphing/OffsetConverters.kt create mode 100644 app/src/main/java/net/youapps/calcyou/data/graphing/Token.kt create mode 100644 app/src/main/java/net/youapps/calcyou/data/graphing/Window.kt create mode 100644 app/src/main/java/net/youapps/calcyou/ui/components/AddNewFunctionDialog.kt create mode 100644 app/src/main/java/net/youapps/calcyou/ui/components/ColorSelectionDialog.kt create mode 100644 app/src/main/java/net/youapps/calcyou/ui/screens/graphing/CanvasView.kt create mode 100644 app/src/main/java/net/youapps/calcyou/ui/screens/graphing/GraphingScreen.kt create mode 100644 app/src/main/java/net/youapps/calcyou/ui/screens/graphing/GraphingUtils.kt create mode 100644 app/src/main/java/net/youapps/calcyou/viewmodels/GraphViewModel.kt create mode 100644 app/src/test/java/net/youapps/calcyou/EvaluatorTest.kt diff --git a/app/src/main/java/net/youapps/calcyou/Destination.kt b/app/src/main/java/net/youapps/calcyou/Destination.kt index 93ca1a3..7ebfc00 100644 --- a/app/src/main/java/net/youapps/calcyou/Destination.kt +++ b/app/src/main/java/net/youapps/calcyou/Destination.kt @@ -48,6 +48,7 @@ sealed class Destination(open val route: String) { object Converters : Destination("converters") object CharacterInput : Destination("character_input") + object Graphing : Destination("graphing") sealed class Converter( override val route: String, @StringRes val resId: Int, diff --git a/app/src/main/java/net/youapps/calcyou/NavHost.kt b/app/src/main/java/net/youapps/calcyou/NavHost.kt index f10c14e..ffc77f9 100644 --- a/app/src/main/java/net/youapps/calcyou/NavHost.kt +++ b/app/src/main/java/net/youapps/calcyou/NavHost.kt @@ -9,6 +9,7 @@ import net.youapps.calcyou.ui.CalculatorScreen import net.youapps.calcyou.ui.screens.CharacterInputScreen import net.youapps.calcyou.ui.screens.ConverterGridScreen import net.youapps.calcyou.ui.screens.ConverterScreen +import net.youapps.calcyou.ui.screens.graphing.GraphingScreen @Composable fun AppNavHost(modifier: Modifier = Modifier, navHostController: NavHostController) { @@ -31,6 +32,10 @@ fun AppNavHost(modifier: Modifier = Modifier, navHostController: NavHostControll CharacterInputScreen() } + composable(route = Destination.Graphing.route) { + GraphingScreen() + } + Destination.Converter.values.forEach { converter -> composable(route = converter.route) { ConverterScreen(converter = converter.converter, converterName = converter.resId) diff --git a/app/src/main/java/net/youapps/calcyou/data/graphing/CompiledExpression.kt b/app/src/main/java/net/youapps/calcyou/data/graphing/CompiledExpression.kt new file mode 100644 index 0000000..ea53c54 --- /dev/null +++ b/app/src/main/java/net/youapps/calcyou/data/graphing/CompiledExpression.kt @@ -0,0 +1,41 @@ +/* + This code is a modified version of https://github.com/danielgindi/KotlinEval + The original source code is licensed under MIT LICENSE + */ + +package net.youapps.calcyou.data.graphing + +class CompiledExpression internal constructor( + internal var root: Token, + var configuration: EvalConfiguration +) { + fun execute(): Double? = + Evaluator.execute(expression = this) + + fun execute(constant: Pair): Double? = + Evaluator.execute(this, constant) + + fun setConstant(name: String, value: Double) { + configuration.setConstant(name = name, value = value) + } + + fun removeConstant(name: String) { + configuration.removeConstant(name = name) + } + + fun setFunction(name: String, func: EvalFunctionBlock) { + configuration.setFunction(name = name, func = func) + } + + fun removeFunction(name: String) { + configuration.removeFunction(name = name) + } + + fun clearConstants() { + configuration.clearConstants() + } + + fun clearFunctions() { + configuration.clearConstants() + } +} \ No newline at end of file diff --git a/app/src/main/java/net/youapps/calcyou/data/graphing/Defaults.kt b/app/src/main/java/net/youapps/calcyou/data/graphing/Defaults.kt new file mode 100644 index 0000000..b5ff60b --- /dev/null +++ b/app/src/main/java/net/youapps/calcyou/data/graphing/Defaults.kt @@ -0,0 +1,157 @@ +/* + This code is a modified version of https://github.com/danielgindi/KotlinEval + The original source code is licensed under MIT LICENSE + */ + +package net.youapps.calcyou.data.graphing + +import java.security.InvalidParameterException +import kotlin.math.abs +import kotlin.math.acos +import kotlin.math.asin +import kotlin.math.atan +import kotlin.math.atan2 +import kotlin.math.ceil +import kotlin.math.cos +import kotlin.math.cosh +import kotlin.math.exp +import kotlin.math.floor +import kotlin.math.ln +import kotlin.math.log +import kotlin.math.log10 +import kotlin.math.log2 +import kotlin.math.pow +import kotlin.math.round +import kotlin.math.sin +import kotlin.math.sinh +import kotlin.math.sqrt +import kotlin.math.tan +import kotlin.math.tanh +import kotlin.math.truncate + +object Defaults { + val defaultOperatorOrder: List> = listOf( + listOf(Operator.POW), + listOf(Operator.DIV, Operator.MUL, Operator.MOD), + listOf(Operator.SUB, Operator.ADD), + ) + val defaultRightAssociativeOps: Set = setOf("**") + val defaultGenericConstants: Map = mapOf( + "PI" to Math.PI, + "PI_2" to Math.PI / 2, + "LOG2E" to log2(Math.E), + "DEG" to Math.PI / 180f, + "E" to Math.E + ) + val defaultVarNameChars: Set = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$".toCharArray().toSet() + + fun getDefaultGenericFunctions(): Map { + return mapOf( + "ABS" to { args -> + abs(args.first()) + }, + "ACOS" to { args -> + + acos(args.first()) + }, + "ASIN" to { args -> + + asin(args.first()) + }, + "ATAN" to { args -> + + atan(args.first()) + }, + "ATAN2" to { args -> + val arg1 = args[0] + val arg2 = args[1] + if (args.size != 2) { + throw InvalidParameterException() + } + atan2(arg1, arg2) + }, + "CEILING" to { args -> + ceil(args.first()) + }, + "COS" to { args -> + cos(args.first()) + }, + "COSH" to { args -> + cosh(args.first()) + }, + "EXP" to { args -> + exp(args.first()) + }, + "FLOOR" to { args -> + floor(args.first()) + }, + "LOG" to fn@{ args -> + if (args.size == 2) { + val arg1 = args[0] + val arg2 = args[1] + + return@fn log(arg1, arg2) + } else if (args.size == 1) { + return@fn ln(args.first()) + } + throw InvalidParameterException() + }, + "LOG2" to { args -> + log2(args.first()) + }, + "LOG10" to { args -> + log10(args.first()) + }, + "MAX" to fn@{ args -> + var v = args[0] + for (arg in args) { + val narg = arg + if (narg > v) + v = narg + } + v + }, + "MIN" to fn@{ args -> + var v = args[0] + for (arg in args) { + val narg = arg + if (narg < v) + v = narg + } + v + }, + "POW" to fn@{ args -> + if (args.size == 2) { + val arg1 = args[0] + val arg2 = args[1] + return@fn arg1.pow(arg2) + } + throw InvalidParameterException() + }, + "ROUND" to { args -> + round(args.first()) + }, + "SIGN" to { args -> + if (args.first() < 0) -1.0 else if (args.first() > 0) 1.0 else 0.0 + }, + "SIN" to { args -> + sin(args.first()) + }, + "SINH" to { args -> + sinh(args.first()) + }, + "SQRT" to { args -> + sqrt(args.first()) + }, + "TAN" to { args -> + tan(args.first()) + }, + "TANH" to { args -> + tanh(args.first()) + }, + "TRUNCATE" to { args -> + truncate(args.first()) + }) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/youapps/calcyou/data/graphing/EvalConfig.kt b/app/src/main/java/net/youapps/calcyou/data/graphing/EvalConfig.kt new file mode 100644 index 0000000..e680b44 --- /dev/null +++ b/app/src/main/java/net/youapps/calcyou/data/graphing/EvalConfig.kt @@ -0,0 +1,87 @@ +/* + This code is a modified version of https://github.com/danielgindi/KotlinEval + The original source code is licensed under MIT LICENSE + */ + +package net.youapps.calcyou.data.graphing + +typealias EvalFunctionBlock = (args: List) -> Double + +class EvalConfiguration( + var constants: MutableMap? = null, + var functions: MutableMap? = null +) { + + internal var allOperators = listOf() + private var _operatorOrder = listOf>() + var operatorOrder: List> + get() { + return _operatorOrder + } + set(newValue) { + val ops = mutableListOf() + for (ops2 in newValue) + ops.addAll(ops2) + + _operatorOrder = newValue + allOperators = ops + } + + var rightAssociativeOps = setOf() + + var varNameChars = setOf() + + var genericConstants = mutableMapOf() + var genericFunctions = mutableMapOf() + + @Suppress("unused") + fun setConstant(name: String, value: Double) { + if (constants == null) { + constants = mutableMapOf() + } + constants!![name] = value + } + + @Suppress("unused") + fun removeConstant(name: String) { + if (constants == null) { + return + } + constants?.remove(name) + } + + @Suppress("unused") + fun setFunction(name: String, func: EvalFunctionBlock) { + if (functions == null) { + functions = mutableMapOf() + } + functions!![name] = func + } + + @Suppress("unused") + fun removeFunction(name: String) { + if (functions == null) { + return + } + functions?.remove(name) + } + + @Suppress("unused") + fun clearConstants() { + constants?.clear() + } + + @Suppress("unused") + fun clearFunctions() { + functions?.clear() + } + + init { + this.operatorOrder = Defaults.defaultOperatorOrder + this.rightAssociativeOps = Defaults.defaultRightAssociativeOps + this.varNameChars = Defaults.defaultVarNameChars + this.genericConstants = Defaults.defaultGenericConstants.toMutableMap() + this.genericFunctions = Defaults.getDefaultGenericFunctions().toMutableMap() + + } +} \ No newline at end of file diff --git a/app/src/main/java/net/youapps/calcyou/data/graphing/Evaluator.kt b/app/src/main/java/net/youapps/calcyou/data/graphing/Evaluator.kt new file mode 100644 index 0000000..6c1df82 --- /dev/null +++ b/app/src/main/java/net/youapps/calcyou/data/graphing/Evaluator.kt @@ -0,0 +1,544 @@ +/* + This code is a modified version of https://github.com/danielgindi/KotlinEval + The original source code is licensed under MIT LICENSE + */ + +package net.youapps.calcyou.data.graphing + +import java.text.ParseException +import kotlin.math.pow + +object Evaluator { + private val configuration = EvalConfiguration() + + fun compile(expression: String): CompiledExpression { + val tokens = + tokenizeExpression(expression = expression) + var end = tokens.size + + // Collapse +- + var i = 1 + while (i < end) { + val token = tokens[i] + val prevToken = tokens[i - 1] + // When there are two consecetive + or - tokens, simplify them to one + // For example 5--1 becomes 5+1 + if (token is Token.Op && (token.value == Operator.SUB || token.value == Operator.ADD) && prevToken is Token.Op && (prevToken.value == Operator.SUB || prevToken.value == Operator.ADD)) { + if (prevToken.value != Operator.ADD) { + if ((token.value == Operator.SUB)) { + token.value = Operator.ADD + } else { + token.value = Operator.SUB + } + } + tokens.removeAt(i - 1) + end = tokens.size + continue + } + + // When we have something like this: "5*-1", we will move the "-" to be part of the number token. + if (token is Token.Number && + prevToken is Token.Op && + (prevToken.value == Operator.SUB || prevToken.value == Operator.ADD) && + ((i > 1 && tokens[i - 2] is Token.Op) || i == 1) + ) { + if (prevToken.value == Operator.SUB) { + token.value = token.value * -1 + } + tokens.removeAt(i - 1) + end = tokens.size + continue + } + i += 1 + } + + // Take care of groups (including function calls) + i = 0 + while (i < end) { + val token = tokens[i] + if (token is Token.LeftParen) { + groupTokens(tokens = tokens, startAt = i) + end = tokens.size + continue + } + i += 1 + } + + // Build the tree + val tree = buildTree(tokens = tokens, configuration = configuration) + + return CompiledExpression(root = tree, configuration = configuration) + } + + fun execute(expression: String): Any? = + execute(expression = compile(expression = expression)) + + fun execute(expression: CompiledExpression): Double? = + evaluateToken(token = expression.root, configuration = expression.configuration) + + fun execute(expression: CompiledExpression, constant: Pair): Double? { + configuration.setConstant(constant.first, constant.second) + return evaluateToken(token = expression.root, configuration = configuration) + } + + private fun opAtPosition( + expression: String, + start: Int + ): Operator? { + var op: Operator? = null + val allOperators = configuration.allOperators + for (item in allOperators) { + if (op != null && (op == item || item.length <= op.length)) { + continue + } + if (expression.substring( + startIndex = start, + endIndex = start + item.length + ) == item.text + ) { + op = item + } + } + return op + } + + private fun indexOfOpInTokens(tokens: List, op: Operator): Int? { + for ((i, token) in tokens.withIndex()) { + if (token is Token.Op && token.value == op) { + return i + } + } + return null + } + + private fun lastIndexOfOpInTokens(tokens: List, op: Operator): Int? { + for (i in tokens.size - 1 downTo 0) { + val token = tokens[i] + if (token is Token.Op && token.value == op) { + return i + } + } + return null + } + + private data class MatchWithIndex(val index: Int, val match: String) + + private fun lastIndexOfOpArray( + tokens: List, + ops: List + ): MatchWithIndex? { + var pos: Int? = null + var bestMatch: String? = null + + for (item in ops) { + val opIndex = (if (item == Operator.POW) { + indexOfOpInTokens(tokens = tokens, op = item) + } else { + lastIndexOfOpInTokens(tokens = tokens, op = item) + }) ?: continue + + if (pos == null || opIndex > pos) { + pos = opIndex + bestMatch = item.text + } + } + + if (pos == null || bestMatch == null) + return null + + return MatchWithIndex(index = pos, match = bestMatch) + } + + /*** Parses a number from the givenstring, starting at the specified index. + * + * @param data The string to parse. + * @param startAt The starting index in the string. + * @return A [MatchWithIndex] object containing the parsed number and the index after the parsed number. + * @throws ParseException If a number cannotbe parsed. + */ + private fun parseNumber(data: String, startAt: Int): MatchWithIndex { + var i = startAt + val endIndex = data.length + var exp = + 0 // 0 - no expoent found | 1 - exponent found | 2 - exponent with a sign found | 3 - exponent found with a preceding number + var isDecimalNumber = false + if (i >= endIndex) { + throw ParseException("Can't parse token at $i", -1) + } + while (i < endIndex) { + val c = data[i] + if ((c in '0'..'9')) { // Numeric + if (exp == 1 || exp == 2) { + exp = 3 // There are numbers after the exponent and the sign + } + } else if ((c == '.')) { // Decimal Point + if (isDecimalNumber || exp > 0) { + break + } + isDecimalNumber = true + } else if ((c == 'e')) { + if (exp > 0) { + break // Came across a second 'e'. number has probably reached the end + } + exp = 1 // Exponent + } else if ((exp == 1 && (c == '-' || c == '+'))) { + exp = 2 // Exponent with a sign + } else { + break + } + i++ + } + if ((i == startAt || exp == 1 /* Exponent 'e' without number */ || exp == 2 /* Exponent 'e' and sign without number */)) { + throw ParseException("Unexpected character at index $i", -1) + } + + return MatchWithIndex( + index = i, + match = data.substring(startIndex = startAt, endIndex = i) + ) + } + + private fun tokenizeExpression( + expression: String + ): MutableList { + val varNameChars = configuration.varNameChars + val tokens = mutableListOf() + + if (expression.isEmpty()) + return tokens + + var i = 0 + val endIndex = expression.length + while (i < endIndex) { + var c = expression[i] + + val isDigit = c in '0'..'9' + + if (isDigit || c == '.') { + // Starting a number + val parsedNumber = parseNumber(data = expression, startAt = i) + tokens.add(Token.Number(position = i, value = parsedNumber.match.toDouble())) + i = parsedNumber.index + continue + } + + var isVarChars = varNameChars.contains(c) + if (isVarChars) { + // Starting a variable name - can start only with A-Z_ + var token = "" + while (i < endIndex) { + c = expression[i] + isVarChars = varNameChars.contains(c) + if (!isVarChars) { + break + } + token += c + i++ + } + tokens.add( + Token.Var( + position = i - token.length, + value = token + ) + ) + continue + } + + if (c == '(') { + tokens.add(Token.LeftParen(position = i)) + i++ + continue + } + + if (c == ')') { + tokens.add(Token.RightParen(position = i)) + i++ + continue + } + + if (c == ',') { // Commas are used to seperate function arguments + tokens.add(Token.Comma(position = i)) + i++ + continue + } + if (c == ' ' || c == '\t' || c == '\u000c' || c == '\r' || c == '\n') { + // Whitespace, skip + i++ + continue + } + + val op = opAtPosition(expression = expression, start = i) + if (op != null) { + tokens.add(Token.Op(position = i, value = op)) + i += op.length + continue + } + + throw ParseException("Unexpected token at index $i", -1) + } + return tokens + } + + private fun groupTokens(tokens: MutableList, startAt: Int = 0): Token { + val isFunc = startAt > 0 && tokens[startAt - 1] is Token.Var + var rootToken = tokens[if (isFunc) startAt - 1 else startAt] + + var groups: MutableList>? = null + var sub: MutableList = mutableListOf() + if (isFunc) { + groups = mutableListOf() + val newToken = Token.Call( + rootToken.position, + argumentsGroups = groups, + function = getFunFromName((rootToken as Token.Var).value) + ) + tokens[startAt - 1] = newToken + rootToken = newToken + } else { + val newToken = Token.Group(rootToken.position, tokens = sub) + tokens[startAt] = newToken + rootToken = newToken + } + + var i = startAt + 1 + var end = tokens.size + + while (i < end) { + val token = tokens[i] + + if (isFunc && token is Token.Comma) { + sub = mutableListOf() + groups!!.add(sub) + i += 1 + continue + } + + if (token is Token.RightParen) { + if (isFunc) { + for (r in i downTo startAt) + tokens.removeAt(r) + } else { + for (r in i downTo (startAt + 1)) + tokens.removeAt(r) + } + return rootToken + } + + if (token is Token.LeftParen) { + groupTokens(tokens = tokens, startAt = i) + end = tokens.size + continue + } + + if (isFunc && groups!!.size == 0) { + groups.add(sub) + } + + sub.add(token) + i += 1 + } + + throw ParseException( + "Unmatched parenthesis for parenthesis at index $startAt", + -1 + ) + } + + private fun buildTree(tokens: MutableList, configuration: EvalConfiguration): Token { + val order = configuration.operatorOrder + val orderCount = order.size + var i = orderCount - 1 + while (i >= 0) { + val cs = order[i] + val match = lastIndexOfOpArray(tokens = tokens, ops = cs) + if (match == null) { + i -= 1 + continue + } + + val pos = match.index + val op = match.match + + val token = tokens[pos] as Token.Op + var left: MutableList? = tokens.subList(0, pos).toMutableList() + val right: MutableList = tokens.subList(pos + 1, tokens.size) + + if (left?.size == 0 && (op == "-" || op == "+")) { + left = null + } + if ((left != null && left.size == 0) || right.size == 0) { + throw ParseException("Invalid expression, missing operand at $pos", -1) + } + if (left == null && op == "-") { + left = mutableListOf() + left.add(Token.Number(value = 0.0)) + } else if (left == null) { + return buildTree(tokens = right, configuration = configuration) + } + token.left = buildTree(tokens = left, configuration = configuration) + token.right = buildTree(tokens = right, configuration = configuration) + return token + } + + if (tokens.size > 1) { + throw ParseException( + "Invalid expression, missing operand or operator at ${tokens[1].position}", + -1 + ) + } + + if (tokens.size == 0) { + throw ParseException("Invalid expression, missing operand or operator.", -1) + } + + // Recursive function reached a single token level + var singleToken = tokens[0] + + when (singleToken) { + is Token.Group -> { + singleToken = buildTree( + tokens = singleToken.tokens, + configuration = configuration + ) + } + + is Token.Call -> { + singleToken.arguments = mutableListOf() + for (a in 0 until (singleToken.argumentsGroups?.size ?: 0)) { + if (singleToken.argumentsGroups!![a].size == 0) { + singleToken.arguments?.add(null) + } else { + singleToken.arguments?.add( + buildTree( + tokens = singleToken.argumentsGroups!![a], + configuration = configuration + ) + ) + } + } + } + + is Token.Comma -> { + throw ParseException( + "Unexpected character at index ${singleToken.position}", + -1 + ) + } + + else -> { + } + } + + return singleToken + } + + private fun evaluateToken(token: Token, configuration: EvalConfiguration): Double? { + when (token) { + is Token.Number -> { + return token.value + } + + is Token.Var -> { + val tokenValue = token.value + val constants = configuration.constants + if (constants != null) { + if (constants.containsKey(tokenValue)) + return constants[tokenValue]!! + + if (constants.containsKey(tokenValue.uppercase())) + return constants[tokenValue.uppercase()]!! + } + + if (configuration.genericConstants.containsKey(tokenValue)) + return configuration.genericConstants[tokenValue]!! + + if (configuration.genericConstants.containsKey(tokenValue.uppercase())) + return configuration.genericConstants[tokenValue.uppercase()]!! + + throw ParseException("Error Evaluating token: Variable ${tokenValue} invalid", -1) + } + + is Token.Call -> return evaluateFunction(token = token, configuration = configuration) + is Token.Op -> { + val left = token.left + val right = token.right + if (left == null || right == null) { + throw ParseException( + "An unexpected error occurred while evaluating expression", + -1 + ) + } + + val a = evaluateToken(left, configuration) + ?: throw ParseException("Error Evaluating token: Left operand invalid", -1) + val b = evaluateToken(right, configuration) + ?: throw ParseException("Error Evaluating token: Right operand invalid", -1) + + when (token.value) { + Operator.DIV -> // Divide + return a.div(b) + + Operator.MUL -> // Multiply + return a.times(b) + + Operator.ADD -> // Add + return a.plus(b) + + Operator.SUB -> // Subtract + return a.minus(b) + + Operator.POW -> // Power + return a.pow(b) + + Operator.MOD -> // Mod + return a.mod(b) + } + + } + + else -> { + } + } + + throw ParseException("An unexpected error occurred while evaluating expression", -1) + } + + private fun evaluateFunction(token: Token.Call, configuration: EvalConfiguration): Double { + val args = mutableListOf() + for (arg in token.arguments ?: listOf()) { + if (arg != null) { + val value = evaluateToken(token = arg, configuration = configuration) + ?: throw ParseException( + "An unexpected error occurred while evaluating expression", + -1 + ) + args.add(value) + } + } + + return token.function.invoke(args) + } + + private fun getFunFromName(fname: String): EvalFunctionBlock { + val fnameUpper = fname.uppercase() + + val functions = configuration.functions + if (functions != null) { + functions[fname]?.let { + return it + } + + functions[fnameUpper]?.let { + return it + } + } + configuration.genericFunctions[fname]?.let { + return it + } + + configuration.genericFunctions[fnameUpper]?.let { + return it + } + throw ParseException("Function named \"${fname}\" was not found", -1) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/youapps/calcyou/data/graphing/Function.kt b/app/src/main/java/net/youapps/calcyou/data/graphing/Function.kt new file mode 100644 index 0000000..e86ebe6 --- /dev/null +++ b/app/src/main/java/net/youapps/calcyou/data/graphing/Function.kt @@ -0,0 +1,21 @@ +package net.youapps.calcyou.data.graphing + +import androidx.compose.ui.graphics.Color + +class Function( + val expression: String, + val color: Color, + val function: (Float) -> Float? +) { + + companion object { + fun create(expression: String, color: Color): Function { + val compiled: CompiledExpression = Evaluator.compile(expression) + return Function( + expression, color + ) { value -> + compiled.execute("x" to value.toDouble())?.toFloat() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/net/youapps/calcyou/data/graphing/OffsetConverters.kt b/app/src/main/java/net/youapps/calcyou/data/graphing/OffsetConverters.kt new file mode 100644 index 0000000..2d03740 --- /dev/null +++ b/app/src/main/java/net/youapps/calcyou/data/graphing/OffsetConverters.kt @@ -0,0 +1,25 @@ +package net.youapps.calcyou.data.graphing + +import androidx.compose.ui.geometry.Offset + +fun Offset.unitToPxCoordinates( + window: Window, + canvasWidth: Float, + canvasHeight: Float +): Offset { + return Offset( + (x - window.xMin) / (window.xMax - window.xMin) * canvasWidth, + (window.yMax - y) / (window.yMax - window.yMin) * canvasHeight + ) +} + +fun Offset.pxToUnitCoordinates( + window: Window, + canvasWidth: Float, + canvasHeight: Float +): Offset { + return Offset( + window.xMin + x * (window.xMax - window.xMin) / canvasWidth, + window.yMax - y * (window.yMax - window.yMin) / canvasHeight + ) +} \ No newline at end of file diff --git a/app/src/main/java/net/youapps/calcyou/data/graphing/Token.kt b/app/src/main/java/net/youapps/calcyou/data/graphing/Token.kt new file mode 100644 index 0000000..a0fe3f9 --- /dev/null +++ b/app/src/main/java/net/youapps/calcyou/data/graphing/Token.kt @@ -0,0 +1,70 @@ +package net.youapps.calcyou.data.graphing + +sealed class Token(val position: Int?) { + class Group( + position: Int?, + val tokens: MutableList + ) : Token(position = position) + + class Call( + position: Int? = null, + var argumentsGroups: MutableList>? = null, + var arguments: MutableList? = null, + var function: EvalFunctionBlock + ) : Token(position) + + class Var( + position: Int? = null, + var value: String + ) : Token(position) + + class Number( + position: Int? = null, + var value: Double + ) : Token(position) + + class Op( + position: Int? = null, + var value: Operator + ) : Token(position) { + var left: Token? = null + var right: Token? = null + } + + class LeftParen( + position: Int? = null + ) : Token(position) + + class RightParen( + position: Int? = null + ) : Token(position) + + class Comma( + position: Int? = null + ) : Token(position) +} + +enum class Operator(val text: String) { + ADD("+"), + SUB("-"), + MUL("*"), + DIV("/"), + POW("**"), + MOD("%") +} + +internal fun String.toOperator(): Operator = when (this) { + "+" -> Operator.ADD + "-" -> Operator.SUB + "*" -> Operator.MUL + "/" -> Operator.DIV + "**" -> Operator.POW + "%" -> Operator.MOD + else -> throw IllegalArgumentException("Unknown operator: $this") +} + +internal val Operator.length: Int + get() = when (this) { + Operator.POW -> 2 + else -> 1 + } \ No newline at end of file diff --git a/app/src/main/java/net/youapps/calcyou/data/graphing/Window.kt b/app/src/main/java/net/youapps/calcyou/data/graphing/Window.kt new file mode 100644 index 0000000..1507c6f --- /dev/null +++ b/app/src/main/java/net/youapps/calcyou/data/graphing/Window.kt @@ -0,0 +1,63 @@ +package net.youapps.calcyou.data.graphing + +/** + * The graph is drawn relative to this window and the window is drawn on the canvas. + * + * @property xScale The horizontal scale of the window. + * @property yScale The vertical scale of the window. + * @property xMin The leftmost boundary of the x-coordinate. + * @property yMin The bottommost boundary of the y-coordinate. + * @property xMax The rightmost boundary of the x-coordinate. + * @property yMax The topmost boundary of the y-coordinate. + */ +class Window { + + var xMin: Float = -20f + var yMin: Float = -40f + var xMax: Float = 20f + var yMax: Float = 40f + + var xScale: Float = 4f + var yScale: Float = 4f + + val minHorizontalGridLines: Int = 8 + val minVerticalGridLines: Int = 4 + + val maxHorizontalGridLines: Int = 24 + val maxVerticalGridLines: Int = 12 + + fun findAutoScale() { + var xScale = this.xScale + var yScale = this.yScale + + assert(xMax > xMin) + assert(yMax > yMin) + + if (xScale <= 0f || yScale <= 0f) { + //Invalid settings + xScale = 4f + yScale = 4f + } + var windowWidth = (xMax - xMin) / xScale + var windowHeight = (yMax - yMin) / yScale + + while (windowWidth > maxVerticalGridLines) { + xScale *= 2 + windowWidth = (xMax - xMin) / xScale + } + while (windowHeight > maxHorizontalGridLines) { + yScale *= 2 + windowHeight = (yMax - yMin) / yScale + } + while (windowWidth < minVerticalGridLines) { + xScale /= 2 + windowWidth = (xMax - xMin) / xScale + } + while (windowHeight < minHorizontalGridLines) { + yScale /= 2 + windowHeight = (yMax - yMin) / yScale + } + this.xScale = xScale + this.yScale = yScale + } +} \ No newline at end of file diff --git a/app/src/main/java/net/youapps/calcyou/ui/components/AddNewFunctionDialog.kt b/app/src/main/java/net/youapps/calcyou/ui/components/AddNewFunctionDialog.kt new file mode 100644 index 0000000..a5900e2 --- /dev/null +++ b/app/src/main/java/net/youapps/calcyou/ui/components/AddNewFunctionDialog.kt @@ -0,0 +1,154 @@ +package net.youapps.calcyou.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import net.youapps.calcyou.R +import net.youapps.calcyou.viewmodels.GraphViewModel + +@Composable +fun AddNewFunctionDialog( + graphViewModel: GraphViewModel, + initialExpression: String, + initialColor: Color, + onDismissRequest: () -> Unit +) { + Dialog(onDismissRequest) { + DialogContent( + onConfirm = { expression, color -> + graphViewModel.addFunction(expression, color) + onDismissRequest() + }, + onCancel = onDismissRequest, + checkExpression = graphViewModel::checkExpression, + isError = graphViewModel.isError, + errorMessage = graphViewModel.errorText, + initialExpression = initialExpression, + initialColor = initialColor + ) + } +} + +@Composable +private fun DialogContent( + modifier: Modifier = Modifier, + onConfirm: (expression: String, color: Color) -> Unit, + onCancel: () -> Unit, + checkExpression: (String) -> Unit, + isError: Boolean, + errorMessage: String, + initialExpression: String = "", + initialColor: Color = rainbowColors.first(), +) { + var color by remember { mutableStateOf(initialColor) } + var showColorPicker by remember { mutableStateOf(false) } + Box( + modifier = modifier + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp)) + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + text = stringResource(R.string.add_new_function), + style = MaterialTheme.typography.titleLarge + ) + var text by remember { mutableStateOf(initialExpression) } + OutlinedTextField( + value = text, + onValueChange = { + text = it + checkExpression(it) + }, + isError = isError, + prefix = { + Text( + text = "f(x) = ", style = TextStyle( + fontFamily = FontFamily.Serif, + fontWeight = FontWeight.Medium, + fontSize = MaterialTheme.typography.bodyLarge.fontSize, + fontStyle = FontStyle.Italic + ) + ) + }, + trailingIcon = { + Box( + Modifier + .size(24.dp) + .clip(CircleShape) + .background(color) + .clickable { + showColorPicker = true + }) + }, + textStyle = TextStyle( + fontFamily = FontFamily.Serif, + fontWeight = FontWeight.Medium, + fontSize = MaterialTheme.typography.bodyLarge.fontSize, + fontStyle = FontStyle.Italic + ) + ) + AnimatedVisibility(isError) { + Text(text = errorMessage, color = MaterialTheme.colorScheme.error) + } + Row(Modifier.align(Alignment.End)) { + TextButton(onClick = onCancel) { + Text(text = stringResource(id = android.R.string.cancel)) + } + TextButton(onClick = { + onConfirm(text, color) + }, enabled = !isError) { + Text(text = stringResource(id = android.R.string.ok)) + } + } + } + } + if (showColorPicker) { + ColorSelectionDialog(initialColor = remember { color }, + onDismiss = { showColorPicker = false }) { + color = it + } + } +} + + +@Preview(showBackground = true) +@Composable +private fun DialogContentPreview() { + DialogContent( + onConfirm = { _, _ -> }, + onCancel = {}, + checkExpression = {}, + isError = true, + errorMessage = "Invalid token at index 0" + ) +} \ No newline at end of file diff --git a/app/src/main/java/net/youapps/calcyou/ui/components/ColorSelectionDialog.kt b/app/src/main/java/net/youapps/calcyou/ui/components/ColorSelectionDialog.kt new file mode 100644 index 0000000..1d968f0 --- /dev/null +++ b/app/src/main/java/net/youapps/calcyou/ui/components/ColorSelectionDialog.kt @@ -0,0 +1,212 @@ +package net.youapps.calcyou.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.core.graphics.ColorUtils.HSLToColor +import androidx.core.graphics.ColorUtils.colorToHSL +import net.youapps.calcyou.R + +@Composable +fun ColorSelectionDialog( + initialColor: Color, + onDismiss: () -> Unit, + onSelectColor: (Color) -> Unit +) { + val initialHSL by remember { mutableStateOf(floatArrayOf(0f, 0f, 0f)) } + colorToHSL(initialColor.toArgb(), initialHSL) + var hue by remember { mutableFloatStateOf(initialHSL[0]) } // [0,360) + var saturation by remember { mutableFloatStateOf(initialHSL[1]) } // [0,1] + var lightness by remember { mutableFloatStateOf(initialHSL[2]) } // [0,1] + + val color = Color(HSLToColor(floatArrayOf(hue, saturation, lightness))) + + Dialog(onDismissRequest = onDismiss) { + ElevatedCard { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 10.dp) + ) { + Text( + text = stringResource(R.string.color_picker_dialog), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(top = 12.dp) + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 50.dp, vertical = 20.dp) + ) { + Box( + modifier = Modifier + .weight(1f) + .height(40.dp) + .background( + initialColor, + shape = RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp) + ), + contentAlignment = Alignment.Center + ) { + Text(stringResource(R.string.old_color), color = Color.White) + } + Box( + modifier = Modifier + .weight(1f) + .height(40.dp) + .background( + color, + shape = RoundedCornerShape(topEnd = 8.dp, bottomEnd = 8.dp) + ), + contentAlignment = Alignment.Center + ) { + Text(stringResource(R.string.new_color), color = Color.White) + } + } + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = stringResource(R.string.hue_slider)) + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + HueBar( + modifier = Modifier + .fillMaxWidth() + .height(20.dp) + ) + Slider( + modifier = Modifier, + value = hue, + onValueChange = { hue = it }, + valueRange = 0f..360f, + onValueChangeFinished = {} + ) + } + } + Spacer(modifier = Modifier.height(4.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = stringResource(R.string.saturation_slider)) + Spacer(modifier = Modifier.width(8.dp)) + Slider( + modifier = Modifier.weight(1f), + value = saturation, + onValueChange = { saturation = it }, + valueRange = 0f..1f, + onValueChangeFinished = {} + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = stringResource(R.string.lightness_slider)) + Spacer(modifier = Modifier.width(8.dp)) + Slider( + modifier = Modifier.weight(1f), + value = lightness, + onValueChange = { lightness = it }, + valueRange = 0f..1f, + onValueChangeFinished = {} + ) + } + Spacer(modifier = Modifier.height(24.dp)) + + // Buttons + + Row( + modifier = Modifier + .fillMaxWidth() + .height(60.dp), + verticalAlignment = Alignment.CenterVertically + + ) { + TextButton( + onClick = onDismiss, + modifier = Modifier + .weight(1f) + .fillMaxHeight() + ) { + Text(text = stringResource(android.R.string.cancel)) + } + TextButton( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + onClick = { + onSelectColor(color) + onDismiss() + } + ) { + Text(text = stringResource(android.R.string.ok)) + } + } + } + } + } +} + +val rainbowColors = listOf( + Color(0xFFFF0040), + Color(0xFFFF00FF), + Color(0xFF8000FF), + Color(0xFF0000FF), + Color(0xFF0080FF), + Color(0xFF00FFFF), + Color(0xFF00FF80), + Color(0xFF00FF00), + Color(0xFF80FF00), + Color(0xFFFFFF00), + Color(0xFFFF8000), + Color(0xFFFF0000) +) + +@Composable +fun HueBar(modifier: Modifier = Modifier) { + Canvas(modifier = modifier) { + val canvasWidth = size.width + val canvasHeight = size.height + + drawRoundRect( + brush = Brush.horizontalGradient(colors = rainbowColors.asReversed()), + size = Size(canvasWidth, canvasHeight), + cornerRadius = CornerRadius(.5f, .5f) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/youapps/calcyou/ui/components/NavDrawerContent.kt b/app/src/main/java/net/youapps/calcyou/ui/components/NavDrawerContent.kt index 16ba98a..ad8ab79 100644 --- a/app/src/main/java/net/youapps/calcyou/ui/components/NavDrawerContent.kt +++ b/app/src/main/java/net/youapps/calcyou/ui/components/NavDrawerContent.kt @@ -12,9 +12,11 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Calculate import androidx.compose.material.icons.outlined.KeyboardAlt +import androidx.compose.material.icons.outlined.LineAxis import androidx.compose.material.icons.outlined.WifiProtectedSetup import androidx.compose.material.icons.rounded.Calculate import androidx.compose.material.icons.rounded.KeyboardAlt +import androidx.compose.material.icons.rounded.LineAxis import androidx.compose.material.icons.rounded.WifiProtectedSetup import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor @@ -114,6 +116,22 @@ fun NavDrawerContent( } ) } + item { + NavigationDrawerItem( + icon = { + Icon( + imageVector = Icons.Rounded.LineAxis, + contentDescription = null + ) + }, + label = { Text(text = stringResource(id = R.string.graphing)) }, + selected = Destination.Graphing == currentDestination, + onClick = { + view.playSoundEffect(SoundEffectConstants.CLICK) + onDestinationSelected(Destination.Graphing) + } + ) + } } } } @@ -180,6 +198,25 @@ fun ColumnScope.NavRailContent( onDestinationSelected(Destination.CharacterInput) } ) - + NavigationRailItem( + alwaysShowLabel = false, + icon = { + Icon( + imageVector = Icons.Outlined.LineAxis, + contentDescription = null + ) + }, + label = { + Text( + text = stringResource(id = R.string.graphing), + textAlign = TextAlign.Center + ) + }, + selected = Destination.Graphing == currentDestination, + onClick = { + view.playSoundEffect(SoundEffectConstants.CLICK) + onDestinationSelected(Destination.Graphing) + } + ) } diff --git a/app/src/main/java/net/youapps/calcyou/ui/screens/graphing/CanvasView.kt b/app/src/main/java/net/youapps/calcyou/ui/screens/graphing/CanvasView.kt new file mode 100644 index 0000000..902c8e2 --- /dev/null +++ b/app/src/main/java/net/youapps/calcyou/ui/screens/graphing/CanvasView.kt @@ -0,0 +1,103 @@ +package net.youapps.calcyou.ui.screens.graphing + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.calculateCentroid +import androidx.compose.foundation.gestures.calculatePan +import androidx.compose.foundation.gestures.calculateZoom +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.viewmodel.compose.viewModel +import net.youapps.calcyou.data.graphing.pxToUnitCoordinates +import net.youapps.calcyou.viewmodels.GraphViewModel + +@Composable +fun CanvasView(vm: GraphViewModel) { + Box(Modifier.background(MaterialTheme.colorScheme.background)) { + val textMeasurer = rememberTextMeasurer() + val drawModifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + awaitEachGesture { + val downEvent = awaitFirstDown() + if (downEvent.pressed != downEvent.previousPressed) downEvent.consume() + do { + val event = awaitPointerEvent() + if (event.changes.size == 1) { + val pan = event.changes + .first() + .positionChange() + with(vm.window) { + + val xPan = pan.x * (xMax - xMin) / size.width.toFloat() + val yPan = pan.y * (yMax - yMin) / size.height.toFloat() + + xMin -= xPan + xMax -= xPan + yMin += yPan + yMax += yPan + } + event.changes + .first() + .consume() + } else if (event.changes.size > 1) { + with(vm.window) { + + val zoom = event.calculateZoom() + val center = event + .calculateCentroid() + .pxToUnitCoordinates( + vm.window, + size.width.toFloat(), + size.height.toFloat() + ) + + xMin = center.x + (xMin - center.x) / zoom + xMax = center.x + (xMax - center.x) / zoom + yMin = center.y + (yMin - center.y) / zoom + yMax = center.y + (yMax - center.y) / zoom + + val pan = event + .calculatePan() + + + val xPan = pan.x * (xMax - xMin) / size.width.toFloat() + val yPan = pan.y * (yMax - yMin) / size.height.toFloat() + + xMin -= xPan + xMax -= xPan + yMin += yPan + yMax += yPan + } + event.changes.forEach { it.consume() } + } + vm.window = vm.window + } while (event.changes.any { it.pressed }) + } + } + val gridLinesColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.2f) + val gridAxesColor = MaterialTheme.colorScheme.onBackground + Canvas(modifier = drawModifier) { + val scale = 1f + scale(scale) { + renderCanvas(vm.window, vm, scale, textMeasurer, gridLinesColor, gridAxesColor) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun CanvasViewPreview() { + CanvasView(vm = viewModel()) +} \ No newline at end of file diff --git a/app/src/main/java/net/youapps/calcyou/ui/screens/graphing/GraphingScreen.kt b/app/src/main/java/net/youapps/calcyou/ui/screens/graphing/GraphingScreen.kt new file mode 100644 index 0000000..76152d3 --- /dev/null +++ b/app/src/main/java/net/youapps/calcyou/ui/screens/graphing/GraphingScreen.kt @@ -0,0 +1,148 @@ +package net.youapps.calcyou.ui.screens.graphing + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Clear +import androidx.compose.material3.BottomSheetScaffold +import androidx.compose.material3.Button +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.material3.rememberStandardBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import net.youapps.calcyou.data.graphing.Function +import net.youapps.calcyou.ui.components.AddNewFunctionDialog +import net.youapps.calcyou.viewmodels.GraphViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GraphingScreen(graphViewModel: GraphViewModel = viewModel()) { + var showAddFunctionDialog by remember { mutableStateOf(false) } + val bottomSheetState = rememberStandardBottomSheetState( + skipHiddenState = true + ) + val scaffoldState = rememberBottomSheetScaffoldState( + bottomSheetState = bottomSheetState + ) + BottomSheetScaffold( + modifier = Modifier.fillMaxSize(), + scaffoldState = scaffoldState, + sheetContent = { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + FunctionList( + modifier = Modifier.padding(top = 16.dp, start = 8.dp, end = 8.dp), + functions = graphViewModel.functions, + onClickFunction = { + graphViewModel.updateSelectedFunction(it) + showAddFunctionDialog = true + }, + onClickRemove = { + graphViewModel.removeFunction(it) + }) + Button( + modifier = Modifier.align(Alignment.CenterHorizontally), + onClick = { showAddFunctionDialog = true }) { + Text(text = "Add Function") + } + } + }) { + CanvasView(graphViewModel) + } + if (showAddFunctionDialog) { + AddNewFunctionDialog( + graphViewModel = graphViewModel, + onDismissRequest = { + graphViewModel.updateSelectedFunction(-1) + showAddFunctionDialog = false + }, + initialColor = + remember(graphViewModel.selectedFunctionIndex) { graphViewModel.functionColor }, + initialExpression = + remember(graphViewModel.selectedFunctionIndex) { graphViewModel.expression }, + ) + } +} + +@Composable +fun FunctionList( + modifier: Modifier = Modifier, + functions: List, + onClickFunction: (Int) -> Unit, + onClickRemove: (Int) -> Unit +) { + Column(modifier = modifier) { + functions.forEachIndexed { index, function -> + FunctionRow( + text = function.expression, + color = function.color, + onClick = { onClickFunction(index) }, + onClickRemove = { + onClickRemove(index) + }) + Divider(Modifier.fillMaxWidth()) + } + } +} + +@Composable +fun FunctionRow(text: String, color: Color, onClick: () -> Unit, onClickRemove: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + .clickable { onClick.invoke() }, + verticalAlignment = Alignment.CenterVertically + ) { + + Text( + text = "f(x) = ", style = TextStyle( + fontFamily = FontFamily.Serif, + fontWeight = FontWeight.Bold, + fontSize = MaterialTheme.typography.titleMedium.fontSize, + fontStyle = FontStyle.Italic + ), color = color + ) + Text( + text = text, style = TextStyle( + fontFamily = FontFamily.Serif, + fontWeight = FontWeight.Medium, + fontSize = MaterialTheme.typography.titleMedium.fontSize, + fontStyle = FontStyle.Italic + ) + ) + Spacer(modifier = Modifier.weight(1f)) + IconButton(onClick = { onClickRemove() }) { + Icon( + imageVector = Icons.Rounded.Clear, + contentDescription = "Remove function" + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/net/youapps/calcyou/ui/screens/graphing/GraphingUtils.kt b/app/src/main/java/net/youapps/calcyou/ui/screens/graphing/GraphingUtils.kt new file mode 100644 index 0000000..e3b381f --- /dev/null +++ b/app/src/main/java/net/youapps/calcyou/ui/screens/graphing/GraphingUtils.kt @@ -0,0 +1,268 @@ +package net.youapps.calcyou.ui.screens.graphing + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import net.youapps.calcyou.data.graphing.Function +import net.youapps.calcyou.data.graphing.Window +import net.youapps.calcyou.data.graphing.unitToPxCoordinates +import net.youapps.calcyou.viewmodels.GraphViewModel +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.floor + +fun DrawScope.drawGridLines(window: Window, lineWidth: Float, gridLinesColor: Color) { + val xRange = IntRange( + ceil(window.xMin / window.xScale).toInt(), + floor(window.xMax / window.xScale).toInt() + ) + + for (i in xRange) { + if (i == 0) continue + val xDraw = + Offset(i * window.xScale, 0f).unitToPxCoordinates(window, size.width, size.height).x + drawLine(gridLinesColor, Offset(xDraw, 0f), Offset(xDraw, size.height), lineWidth) + } + val yRange = IntRange( + ceil(window.yMin / window.yScale).toInt(), + floor(window.yMax / window.yScale).toInt() + ) + + for (i in yRange) { + if (i == 0) continue + val yDraw = + Offset(0f, i * window.yScale).unitToPxCoordinates(window, size.width, size.height).y + drawLine(gridLinesColor, Offset(0f, yDraw), Offset(size.width, yDraw), lineWidth) + } +} + +internal fun Float.toDisplayString(): String { + return when { + (this % 1f == 0f) && (this in 0.0001f..10000f) -> { + this.toInt().toString() + } + + abs(this) <= 0.0001 -> { + "%.3e".format(this) + } + + abs(this) < 10000 -> { + this.toString() + } + + else -> { + "%.2e".format(this) + } + } +} + +fun DrawScope.drawAxes( + window: Window, + lineWidth: Float, + canvasScale: Float, + textMeasurer: TextMeasurer, + axesColor: Color +) { + + // y-axis + val windowCenterInCanvas = Offset(0f, 0f).unitToPxCoordinates(window, size.width, size.height) + drawLine( + axesColor, + Offset(windowCenterInCanvas.x, 0f), + Offset(windowCenterInCanvas.x, size.height), + lineWidth + ) + // x-axis + drawLine( + axesColor, + Offset(0f, windowCenterInCanvas.y), + Offset(size.width, windowCenterInCanvas.y), + lineWidth + ) + + // Ticks on x-axis + val xTickRange = IntRange( + ceil(window.xMin / window.xScale).toInt(), floor(window.xMax / window.xScale).toInt() + ) + + for (i in xTickRange) { + if (i == 0) continue + + val xDisplayValue = (i * window.xScale).toDisplayString() + val xDraw = + Offset(i * window.xScale, 0f).unitToPxCoordinates(window, size.width, size.height).x + val yDraw = Offset(0f, 0f).unitToPxCoordinates(window, size.width, size.height).y + +// val halfTickLength = 10f / canvasScale +// drawLine( +// axesColor, +// Offset(xDraw, yDraw + halfTickLength), +// Offset(xDraw, yDraw - halfTickLength), +// lineWidth +// ) + val textWidth = 200 / canvasScale + val textHeight = 40 / canvasScale + val textPadding = 20 / canvasScale + drawText( + textMeasurer, + xDisplayValue, + topLeft = Offset(xDraw - textWidth / 2, yDraw + textPadding), + size = Size(textWidth, textHeight), + style = TextStyle( + color = axesColor, + textAlign = TextAlign.Center, + fontWeight = FontWeight.Medium + ) + ) + } + + val yTickRange = IntRange( + ceil(window.yMin / window.yScale).toInt(), floor(window.yMax / window.yScale).toInt() + ) + + for (i in yTickRange) { + if (i == 0) continue + + val yDisplayValue = (i * window.yScale).toDisplayString() + val xDraw = Offset(0f, 0f).unitToPxCoordinates(window, size.width, size.height).x + val yDraw = + Offset(0f, i * window.yScale).unitToPxCoordinates(window, size.width, size.height).y + +// val halfTickLength = 10f / canvasScale +// drawLine( +// axesColor, +// Offset(xDraw - halfTickLength, yDraw), +// Offset(xDraw + halfTickLength, yDraw), +// lineWidth +// ) + val textWidth = 200 / canvasScale + val textHeight = 40 / canvasScale + val textPadding = 20 / canvasScale + drawText( + textMeasurer, + yDisplayValue, + topLeft = Offset(xDraw + textPadding, yDraw - textHeight / 2), + size = Size(textWidth, textHeight), + style = TextStyle(color = axesColor, fontWeight = FontWeight.Medium) + ) + } +} + +fun DrawScope.graphAroundAsymptote( + window: Window, + function: Function, + aX1: Float, + aX2: Float, + pDerivative: Float, + depth: Int, + lineWidth: Float, +) { + var previousDerivative = pDerivative + val precision = 2 + for (j in 0 until precision) { + val currentX = aX1 + (aX2 - aX1) * j / precision + val nextX = aX1 + (aX2 - aX1) * (j + 1) / precision + val currentY = function.function(currentX) ?: 0f + val nextY = function.function(nextX) ?: 0f + + val currentDerivative = (nextY - currentY) / (nextX - currentX) + if ((currentDerivative >= 0 && previousDerivative >= 0) || (currentDerivative <= 0 && previousDerivative <= 0)) { + drawLine( + function.color, + Offset(currentX, currentY).unitToPxCoordinates(window, size.width, size.height), + Offset(nextX, nextY).unitToPxCoordinates(window, size.width, size.height), + lineWidth + ) + } else { + if (depth > 1) { + graphAroundAsymptote( + window, + function, + currentX, + nextX, + previousDerivative, + depth - 1, + lineWidth + ) + } + return + } + previousDerivative = currentDerivative + } +} + +fun DrawScope.drawGraph(window: Window, function: Function, lineWidth: Float) { + val resolution = 500 + var previousX = 0f + var previousDerivative = 0f + for (i in 0 until resolution) { + val currentX = window.xMin + i / resolution.toFloat() * (window.xMax - window.xMin) + val nextX = window.xMin + (i + 1) / resolution.toFloat() * (window.xMax - window.xMin) + + val currentY = function.function(currentX) ?: 0f + val nextY = function.function(nextX) ?: 0f + + val currentDerivative = (nextY - currentY) / (nextX - currentX) + if ((currentDerivative >= 0 && previousDerivative >= 0) || (currentDerivative <= 0 && previousDerivative <= 0)) { + drawLine( + function.color, + Offset(currentX, currentY).unitToPxCoordinates(window, size.width, size.height), + Offset(nextX, nextY).unitToPxCoordinates(window, size.width, size.height), + lineWidth + ) + } else { + if (abs(previousDerivative) < abs(currentDerivative)) { + graphAroundAsymptote( + window, + function, + currentX, + nextX, + previousDerivative, + 20, + lineWidth + ) + // If curve approaches asymptote from right side + } else { + graphAroundAsymptote( + window, + function, + nextX, + previousX, + currentDerivative, + 20, + lineWidth + ) + } + drawLine( + function.color, + Offset(currentX, currentY).unitToPxCoordinates(window, size.width, size.height), + Offset(nextX, currentY).unitToPxCoordinates(window, size.width, size.height), + lineWidth + ) + } + previousDerivative = currentDerivative + previousX = currentX + } +} + +fun DrawScope.renderCanvas( + window: Window, + vm: GraphViewModel, + canvasScale: Float, + textMeasurer: TextMeasurer, + gridLinesColor: Color, + axesColor: Color +) { + val lineWidth = 5f / canvasScale + window.findAutoScale() + drawGridLines(window, lineWidth, gridLinesColor) + drawAxes(window, lineWidth, canvasScale, textMeasurer, axesColor) + + vm.functions.forEach { drawGraph(window, it, lineWidth) } +} \ No newline at end of file diff --git a/app/src/main/java/net/youapps/calcyou/viewmodels/GraphViewModel.kt b/app/src/main/java/net/youapps/calcyou/viewmodels/GraphViewModel.kt new file mode 100644 index 0000000..15c33f1 --- /dev/null +++ b/app/src/main/java/net/youapps/calcyou/viewmodels/GraphViewModel.kt @@ -0,0 +1,79 @@ +package net.youapps.calcyou.viewmodels + +import android.app.Application +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.neverEqualPolicy +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.lifecycle.AndroidViewModel +import net.youapps.calcyou.R +import net.youapps.calcyou.data.graphing.Evaluator +import net.youapps.calcyou.data.graphing.Function +import net.youapps.calcyou.data.graphing.Window +import java.text.ParseException +import kotlin.random.Random + +class GraphViewModel(private val application: Application) : AndroidViewModel(application) { + val context: Context + get() = application.applicationContext + var window by mutableStateOf(Window(), neverEqualPolicy()) + val functions = mutableStateListOf() + private val random = Random(System.currentTimeMillis()) + var isError by mutableStateOf(true) + private set + var errorText by mutableStateOf(context.getString(R.string.expression_is_empty)) + private set + + var selectedFunctionIndex by mutableIntStateOf(-1) + private set + var functionColor by mutableStateOf(Color.Red) + + var expression by mutableStateOf("") + + fun updateSelectedFunction(index: Int) { + selectedFunctionIndex = index + if (index == -1) { + expression = "" + return + } + val function = functions[index] + functionColor = function.color + expression = function.expression + } + + fun checkExpression(expression: String) { + if (expression.isBlank()) { + errorText = context.getString(R.string.expression_is_empty) + isError = true + return + } + try { + val compiled = Evaluator.compile(expression) + compiled.execute("x" to random.nextDouble()) + isError = false + } catch (e: ParseException) { + errorText = e.message ?: context.getString(R.string.error_parsing_expression) + isError = true + } catch (e: Exception) { + errorText = context.getString(R.string.error_parsing_expression) + isError = true + } + } + + fun addFunction(expression: String, color: Color) { + if (selectedFunctionIndex != -1) { + functions[selectedFunctionIndex] = Function.create(expression, color) + updateSelectedFunction(-1) + return + } + functions.add(Function.create(expression, color)) + } + + fun removeFunction(index: Int) { + functions.removeAt(index) + } +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3e5c7e0..8577587 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -161,4 +161,14 @@ Terabit Petabit Exabit + Graphing + Add new function + Expression is empty + Error parsing expression + Color Picker + Old color + New color + Hue + Saturation + Lightness diff --git a/app/src/test/java/net/youapps/calcyou/EvaluatorTest.kt b/app/src/test/java/net/youapps/calcyou/EvaluatorTest.kt new file mode 100644 index 0000000..04fb739 --- /dev/null +++ b/app/src/test/java/net/youapps/calcyou/EvaluatorTest.kt @@ -0,0 +1,65 @@ +package net.youapps.calcyou + +import net.youapps.calcyou.data.graphing.CompiledExpression +import net.youapps.calcyou.data.graphing.Evaluator +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.exp +import kotlin.math.ln +import kotlin.math.log +import kotlin.math.log10 +import kotlin.math.pow +import kotlin.math.round +import kotlin.math.sin +import kotlin.math.sqrt +import kotlin.math.tan +import kotlin.random.Random + +internal class EvaluatorTest { + + val testCases = mapOf( + "sin(x)" to { x: Double -> sin(x) }, + "cos(x)" to { x: Double -> cos(x) }, + "tan(x)" to { x: Double -> tan(x) }, + "sqrt(x)" to { x: Double -> sqrt(x) }, + "abs(x)" to { x: Double -> abs(x) }, + "log(x)" to { x: Double -> ln(x) }, + "exp(x)" to { x: Double -> exp(x) }, + "log10(x)" to { x: Double -> log10(x) }, + "log(x,2)" to { x: Double -> log(x, 2.0) }, + "sin(x) + cos(x)" to { x: Double -> sin(x) + cos(x) }, + "sin(x) * cos(x)" to { x: Double -> sin(x) * cos(x) }, + "sin(x) / cos(x)" to { x: Double -> sin(x) / cos(x) }, + "sin(x) - cos(x)" to { x: Double -> sin(x) - cos(x) }, + "sin(x) ** 2" to { x: Double -> sin(x) * sin(x) }, + "x * x" to { x: Double -> x * x }, + "x ** 2" to { x: Double -> x.pow(2) }, + "pow(x,3)" to { x: Double -> x.pow(3) }, + "2" to { _: Double -> 2.0 }, + "sqrt(4)" to { _: Double -> sqrt(4.0) }, + "round(x)" to { x: Double -> round(x) }, + "sin(2*PI)" to { _: Double -> sin(2 * Math.PI) }, + "x*(5+3*x)" to { x: Double -> x * (5 + 3 * x) }, + //Todo: support function inside another function + //"sin(cos(x))" to { x: Double -> sin(cos(x)) }, + ) + val random = Random(System.currentTimeMillis()) + + @Test + fun `execute() should return correct answer for compiled expressions`() { + testCases.forEach { expression, func -> + val argument = random.nextDouble() + val compiled: CompiledExpression = Evaluator.compile(expression) + + val answer = compiled.execute("x" to argument) + + assertNotNull(answer) + + val expected = func(argument) + assertEquals(expected, answer!!, 1e-6) + } + } +} \ No newline at end of file