Any elegant way to sum up inside map function?

Hey! So i was trying to have a sum variable and then iterate over an array of lines and add the result of each line to the sum variable. I found out there are no global variables, but is there any way to do this elegantly without iterating twice over the lines object (which would also require e.g. the same if statement for line.type)?
See the commented out lines for what I want to do:

#let sum = 0

#table(
      columns: (auto, auto, 1fr, auto, auto),
      align: (col, row) => (auto, auto, auto, auto, auto,).at(col),
      inset: 7pt,
      stroke: luma(180),
      
      table.header[Amount][Unit][Work Done][Unit Price][Total Price]
      ..lines.map(line => {
        if (line.type == lineTypes.timed) {
          let start_time = line.details.time.at(0)
          let end_time = line.details.time.at(1)
          
          let hours = (end_time.at(0) - start_time.at(0)) + (end_time.at(1) - start_time.at(1))/60
          (
          format(hours, 2),
          "hours",
          line.details.description, 
          eurStr(HOURLY_RATE),
          eurStr(hours * HOURLY_RATE)
          )
          //sum += hours * HOURLY_RATE
        } else if (line.type == lineTypes.special) {
          (
          str(line.details.amount),
          str(line.details.unit),
          line.details.description, 
          eurStr(line.details.pricePerUnit),
          eurStr(line.details.amount * line.details.pricePerUnit)
          //sum += line.details.amount * line.details.pricePerUnit
          )
        }
      }).flatten(),
      [], [], [], "Total:", text(size: 15pt)[#strong(eurStr(sum))]
  )

This is what an example of the lines variable looks like:

#let lineTypes = (timed: 0, special: 1)

#let lines = (
  (type: lineTypes.timed, details: (
    date: "20.09.2024",
    time: ((9, 30), (11, 23)),
    description: "Driving"
  )),
  (type: lineTypes.special, details: (
    amount: 1, unit: "-", pricePerUnit: 8.0, 
    date: "24.06.2024",
    description: "Using Car"
  )),
)

Also p.s. if there’s anything ugly or bad about what I’m doing here, please tell me. I’m pretty new to typst.

Hello!
Please make sure your code is reproducible before posting, it helps to reduce the code to a minimal working example. You can take a look at How to post in the Questions category for other tips!

For your problem, I wouldn’t actually have a “global variable” and add the total price of each line when constructing the rows. You can do it twice at nearly no cost, this would be more “elegant” than having a global variable.

To actually answer your question, you can create a state to keep track of the total price.

Here are the relevant lines below. I showcase two ways of computing the total price: sumtotalprice or totalprice-state.get().

#let totalprice-state = state("totalprice", 0.0)
#let totalprice(line) = {
  if line.type == lineTypes.special {
    return line.details.amount * line.details.pricePerUnit 
  }
  return 0
}
#let sumtotalprice(lines) = {
  if type(lines) == "array" {
    return lines.map(line => totalprice(line)).sum()
  }
}
// ...
          str(totalprice(line)) + totalprice-state.update(it => it + totalprice(line))
// ...
      [], [], [], "Total:", text(size: 15pt)[#sumtotalprice(lines) vs #context totalprice-state.get()]

1 Like

I think there are two ways that are somewhat idiomatic. I simplified your example a bit, and will use this data:

#let lines = (
  (name: "foo", value: 5),
  (name: "bar", value: 42),
)

I will produce a table listing the name in upper case and the cumulative sum next to it, and a results line at the end, like so:
grafik

Approach with for loop

Global variables are immutable in Typst, but local variables are not. So you can introduce a new scope, a variable within it, and then modify it using a for loop. When the body of a for loop returns joinable values, they will be joined automatically. Arrays are joinable, so just returning the cells for a single line at the end of the loop will work. (If you just want to return a single value in each iteration, this means that you will often have to write (value,) to turn it into a one-element array to get an array of results back. Here, we want to values each time, so we can just return the two-element array and skip the flatten at the end.)

#table(columns: 2,
  ..{
    let cum-sum = 0
    for line in lines {
      cum-sum = cum-sum + line.value
      ([#upper(line.name)], [#cum-sum])
    }
    ([Result], [#cum-sum])
  }
)

Approach with fold

If you want a more “functional” solution, you can use fold instead of map. lines.fold takes two arguments: The initial value for the accumulator and a folding function. For us, the initial value is a pair of an empty list (we will “fill” this with the cells) and 0 as the initial value of the cumulative sum. The folding function gets both the current value of the accumulator and the next element from the lines array. It must return the updated accumulator.

I would usually recommend using the for loop approach, instead.

#table(columns: 2,
  ..{
    let (cells, sum) = lines.fold(((), 0), ((results, cum-sum), line) => {
      let new-sum = cum-sum + line.value
      (
        results + ([#upper(line.name)], [#new-sum],),
        new-sum,
      )
    })
    cells
    ([Result], [#sum])
  }
)
2 Likes

Wow, thank you both for these great answers. I will keep in mind to do a minimal example next time, sorry about that!

One thing I noticed right away is that you can use upper(line.name) instead of [#upper(line.name)]. The upper function return str for str input and content for content input. Table cell can be either str or content.