Hello, this was a very hard lesson and took days figuring out. These are notes making sense of the code’s overall structure. If anyone has the time, I would like feedback on whether my understanding of the code and structuring is right.
I’ve read through several Functional programming articles, the main separation for this is immutability of data. Meaning, the variables are not edited but passed along for computation. For example, x=+1 is mutable. While add(1,3) sent into a function that adds both is not.
There are several help functions range, charRange, sum that are used in the code. Examples:
const range = (start, end) => Array(end - start + 1).fill(start).map((element, index) => element + index);
const charRange = (start, end) => range(start.charCodeAt(0), end.charCodeAt(0)).map(code => String.fromCharCode(code));
---------Notes---------
const update = event => {
const element = event.target;
const value = element.value.replace(/\s/g, "");
if (!value.includes(element.id) && value.startsWith('=')) {
element.value = evalFormula(value.slice(1), Array.from(document.getElementById("container").children));
}
After the onlook, the if condition checks that the value of the cell selected is not the same as its id and starts with “=” to prevent circular references. Two parameters are sent to evalFormula. The first is a slice at the 1st (and slices to the end), removing “=”. The second is an Array.from of the container’s children. (I.e all cells)
These two parameters are passed to evalFormula. (At this point, x has it’s spaces removed)
const evalFormula = (x, cells) => {
const idToText = id => cells.find(cell => cell.id === id).value;
const rangeRegex = /([A-J])([1-9][0-9]?):([A-J])([1-9][0-9]?)/gi;
const rangeFromString = (num1, num2) => range(parseInt(num1), parseInt(num2));
const elemValue = num => character => idToText(character + num);
const addCharacters = character1 => character2 => num => charRange(character1, character2).map(elemValue(num));
const rangeExpanded = x.replace(rangeRegex, (_match, char1, num1, char2, num2) => rangeFromString(num1, num2).map(addCharacters(char1)(char2)));
const elemValue = num => character => idToText(character + num);
const cellRegex = /[A-J][1-9][0-9]?/gi;
const cellExpanded = rangeExpanded.replace(cellRegex, match => idToText(match.toUpperCase()));
const functionExpanded = applyFunction(cellExpanded);
return functionExpanded === x ? functionExpanded : evalFormula(functionExpanded, cells);
}
The arguments x and cells are first utilized in the rangeExpanded function.
const rangeExpanded = x.replace(rangeRegex, (_match, char1, num1, char2, num2) => rangeFromString(num1,
rangeExpanded checks x against the rangeRegex with the .replace method. However, when a .replace method has a callback function, it has a different effect. Normal replace changes all in the first parameter to the second parameter. For example, x.range(regex, “”). All regex in x is now an empty string. (I’ll refer to this as .replacecallback)
In a replacecallback, x.replace(regex, (parameters) => { } x is matched against the first parameter and similar ones are extracted them to the second parameter.(all_matches, char1, num1, char2, num2) In this, the first returns all matches, the rest are parameters given names with the extract values that matched from rangeRegex.This is based on the capture grouping specified in rangeRegex. (In this case, it has 4 capture grouping for A 00 : A 99)
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace
https://www.freecodecamp.org/news/how-to-pass-callback-functions-to-string-replace-javascript/
Using the extracted parameters, it gets a range of numbers and is mapped. (That is, the map’s callback/effect goes through each number in the array.)
Summary: rangeExpanded extracts characters and numbers from the cell and places them in parameters, used to make a range and mapped to the next function.
const addCharacters = character1 => character2 => num => charRange(character1, character2).map(elemValue(num));
This is the part that uses currying. Within addCharacters, there is a parameter, with multiple arrow function nesting. These are the parameters from the shallowest to deepest layer: character1 => character2 => num
When addCharacters(char1)(char2) was called, char1 gets passed into the function addCharacters first. It fills the “character1” parameter like a bucket or a hole that’s been filled. This is sort of like data being “stored” for the moment. (char2) is passed next, but because the first bucket is filled, char2 is “curried” into the next innernest.
Because this call was mapped to the range of numbers earlier, “rangeFromString(num1, num2)”, for example, the array [3,4,5,6]. Each value is sent as the third parameter. In other words, map(addCharacters(char1)(char2)(nums_from_array).
Since character1 and character2 are filled buckets, num is curried into the innermost function. The innermost function is able to access the parameters of the first two buckets because of “closure”, that is, a function that remembers it’s scope.
https://www.youtube.com/watch?v=Nj3_DMUXEbE
That’s why charRange(character1, character2).map(elemValue(num)) is able to use them.
Summary: A character range is made and mapped to the num range. [A,B,C].map [1,2,3]
const elemValue = num => character => idToText(character + num);
Next, the values are passed into elemValue(num). This step does the same currying. elemValue(num) fills the first bucket of num in element. The mapped values (charRange of character1 and 2, which is an array) fills the inner parameter of character. It happens many times due to the mapping, not a single array but of every value in the array.
Summary, adds character to number. E.g “A2” [A1,B1,C1,A2,B2,C2 etc]
const idToText = id => cells.find(cell => cell.id === id).value;
Finally, it gets used in idtoText, both values were added previously. For example, A+1 = A1. This is now the parameter id in idToText. cells is the second parameter sent when the function was called, which is an array of every children cell in the #container. So id is checked against the cell.id for a match and its .value is extracted.
This extracts a string of all cell values that was in the initial input x.For example, if the input in the cell was = A1: B2, and those had values in the cells of 1, 2, 3, 4 respectively.The input is matched to all cells for matching ids and the values are extracted when going through evalFormula.So, cellExpanded goes through the whole sequence to extract the string of (1, 2, 3, 4)
const cellRegex = /[A-J][1-9][0-9]?/gi;
const cellExpanded = rangeExpanded.replace(cellRegex, match => idToText(match.toUpperCase()));
This was the step that uses rangeExpanded. (the string of id), it checks for cellRegex which are individual cells (“A2”) but not a sequence. This is because in a spreadsheet, it’s possible to write A1:B3 for a range, and +B5 for an indiviudal cell. The individual cells are stragglers that weren’t accommodated for in the rangeExpanded regex. At this point, there may be functions like “SUM” which were left alone too.
const functionExpanded = applyFunction(cellExpanded);
return functionExpanded === x ? functionExpanded : evalFormula(functionExpanded, cells);
functionExpanded is then called wth the string of straggler names like “SUM” and all values in the cells mentioned in input. (since cellExpanded is the forefather of idToText)
const infixToFunction = {
"+": (x, y) => x + y,
"-": (x, y) => x - y,
"*": (x, y) => x * y,
"/": (x, y) => x / y,
}
This is a helper-function object with keys : output of a callback function.
const infixEval = (str, regex) => str.replace(regex, (_match, arg1, operator, arg2) => infixToFunction[operator](parseFloat(arg1), parseFloat(arg2)));
This takes a “str” parameter input and a regex to use in the infixToFunction method. The sequence is the same extraction method as rangeExpanded. In this case, the capture groups of (number)(operator)(number) are (arg1, operator, arg2) respectively. The callback is using the infix with the [operator key] and parameters of arg1 and arg2 passed in.
Summary: Evaluates the math operations.
const highPrecedence = str => {
const regex = /([\d.]+)([*\/])([\d.]+)/;
const str2 = infixEval(str, regex);
return str === str2 ? str : highPrecedence(str2);
}
This is a helper that sets priority to multiplication and division. Of note is how it calls on itself with the output of str2 and checks for any changes with the ternary operator. (Since str within gets “overwritten” with the passed argument when it was called.
const applyFunction = str => {
const noHigh = highPrecedence(str);
const infix = /([\d.]+)([+-])([\d.]+)/;
const str2 = infixEval(noHigh, infix);
This is where cellExpanded is sent to. It is now the paramter str in applyFunction.
It first gets the calculations of highPrecedence, uses that against the regex of infix to check for “normal” operators like plus and minus to make str2.
Summary: Normal operators are cellExpanded is now calculated. So if cellExpanded was SUM(A1:A4)+A3+A5 in values SUM(3,6,2,7)+2+6, it is now SUM(3,6,2,7)+8
const functionCall = /([a-z0-9]*)\(([0-9., ]*)\)(?!.*\()/i;
const toNumberList = args => args.split(",").map(parseFloat);
const apply = (fn, args) => spreadsheetFunctions[fn.toLowerCase()](toNumberList(args));
return str2.replace(functionCall, (match, fn, args) => spreadsheetFunctions.hasOwnProperty(fn.toLowerCase()) ? apply(fn, args) : match);
}
The next bit “starts” from the return, it takes str2, extracting functionCall (which is the operators like SUM) into fn and the remaining values into ags. Taking note of the brackets so that the finished calculations are not affected.
const spreadsheetFunctions = {
sum, //This comes from a helper sum function.
average,
median,
even: nums => nums.filter(isEven),
}
The callback uses spreadsheetFunctions (which is similar to infixToFunction) to check if it has the property of fn (the function key). If it does is calls on apply with the key and the values. apply is similar to infixEval. Otherwise, it returns match. (match is the whole string in the replace method)
return functionExpanded === x ? functionExpanded : evalFormula(functionExpanded, cells);
The last bit is returned to evalFunction at the start. This is a check for whether x had is the same x as the initial x passed to evalFunction. The pattern is the same as how highPrecedence checks for str agianst str2 with a ternary recursion.
element.value = evalFormula(value.slice(1), Array.from(document.getElementById("container").children));
This gets sent back to update at the very start and assigned to the element.value. (cell value by event.target)
----------STRUCTURE----------
The next hard part I’m trying to wrap my head around for functional programming is the overall structure. In “normal programming”, I find it much simpler when variables are clearly distinct. (Though, TODO list and Musicplayer was a fuzzy-wuzzy)
That is, how would one go about thinking about all the little functions. Right now, I imagine three steps: I have a goal/intention (to evaluate the functions), extract the values to use (parsing/input), send it to the calculator. (for the output)
Then, I would think about what is needed in each step. For an input of A1:B3, it first needs to be made into an array. I would extract (regex) the characters and make a range of them (helper functions) to map against the number to have an array of all affected cells. Then convert them to the values with id checks. This handles (input).
Then I use the value to call on the function that calculates, however, it needs to first note high precedence and then lower precedence. (PEMDAS) Using more (helper regex) functions. Then, handle the function with alphabets like (SUM) by another extraction, eventually returning that value. Then handle edge cases or irregularities by first checking it is has been updated right with a ternary. It goes through another return and another ternery. Eventually updating the element.value’s cell.
Even with this overview, imaging the connection between each nesting-nesting-nesting currying is tricky. (Perhaps I could imagine them as flying mail with the information to be plugged into mailboxes)
Is there a better way of/How do others think about the connections/transfers?