All Downloads are FREE. Search and download functionalities are using the official Maven repository.

lucuma.ui.table.hooks.DynTable.scala Maven / Gradle / Ivy

There is a newer version: 0.123.0
Show newest version
// Copyright (c) 2016-2023 Association of Universities for Research in Astronomy, Inc. (AURA)
// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause

package lucuma.ui.table.hooks

import cats.syntax.all.*
import lucuma.react.SizePx
import lucuma.react.table.*
import lucuma.ui.table.*
import lucuma.ui.table.ColumnSize.*
import monocle.Focus
import monocle.Lens

import scala.annotation.tailrec

/**
 * Definition of a dynamic table to be passed to `useDynTable`. Avoid creating in the hook call,
 * since it performs coherence checks upon creation. Pass a static instance to the hook instead.
 *
 * @param columnSizes
 *   Size definitions for all the columns in the table.
 * @param columnPriorities
 *   The order in which columns are removed by overflow. The ones at the beginning are removed
 *   first. Missing columns are not removed by overflow.
 * @param initialState
 */
case class DynTable(
  columnSizes:      Map[ColumnId, ColumnSize],
  columnPriorities: List[ColumnId],
  initialState:     DynTable.ColState
):
  // Check columns comply with the ones passed in columnSizes
  columnPriorities.foreach(colId =>
    assert(
      columnSizes.keySet.contains(colId),
      s"DynTable.initialState.columnPriorities contains unknown column [$colId] not in columnSizes"
    )
  )
  initialState.resized.value.keySet.foreach(colId =>
    assert(
      columnSizes.keySet.contains(colId),
      s"DynTable.initialState.resized contains unknown column [$colId] not in columnSizes"
    )
  )
  initialState.visibility.value.keySet.foreach(colId =>
    assert(
      columnSizes.keySet.contains(colId),
      s"DynTable.initialState.visibility contains unknown column [$colId] not in columnSizes"
    )
  )
  initialState.overflow.foreach(colId =>
    assert(
      columnSizes.keySet.contains(colId),
      s"DynTable.initialState.overflow contains unknown column [$colId] not in columnSizes"
    )
  )

  // This method:
  // - Adjusts resizable columns proportionally to available space (taking into account space taken by fixed columns).
  // - If all visible columns are at their minimum width and overflow the viewport,
  //     then starts dropping columns (as long as there are reamining droppable ones).
  def adjustColSizes(width: SizePx)(colState: DynTable.ColState): DynTable.ColState = {
    // Recurse at go1 when a column is dropped.
    // This level just to avoid clearing overflow on co-recursion
    @tailrec
    def go1(colState: DynTable.ColState): DynTable.ColState =
      lazy val visible: Set[ColumnId] =
        columnSizes.keySet.filterNot(colId =>
          colState.visibility.value.get(colId).contains(Visibility.Hidden)
        ) -- colState.overflow

      lazy val visibleSizes: Map[ColumnId, SizePx] =
        visible
          .map: colId =>
            colId -> colState.resized.value.getOrElse(colId, columnSizes(colId).initial)
          .toMap

      lazy val prioritizedRemainingCols: List[ColumnId] =
        columnPriorities.filter(visible.contains)

      lazy val overflowColumn: DynTable.ColState =
        DynTable.ColState.overflow.modify(_ ++ prioritizedRemainingCols.headOption)(colState)

      if (width.value == 0) colState
      else {
        // Recurse at go2 when a column was shrunk/expanded beyond its bounds.
        @tailrec
        def go2(
          remainingCols:  Map[ColumnId, SizePx],
          fixedAccum:     Map[ColumnId, SizePx] = Map.empty,
          fixedSizeAccum: Int = 0
        ): DynTable.ColState = {
          val (boundedCols, unboundedCols)
            : (Iterable[(Option[ColumnId], SizePx)], Iterable[(ColumnId, SizePx)]) =
            remainingCols.partitionMap: (colId, colSize) =>
              columnSizes(colId) match
                case FixedSize(size)                                         =>
                  (none -> size).asLeft
                // Columns that reach or go beyond their bounds are treated as fixed.
                case Resizable(_, Some(min), _) if colSize.value < min.value =>
                  (colId.some -> min).asLeft
                case Resizable(_, _, Some(max)) if colSize.value > max.value =>
                  (colId.some -> max).asLeft
                case _                                                       =>
                  (colId -> colSize).asRight

          val boundedColsWidth: Int = boundedCols.map(_._2.value).sum
          val totalBounded: Int     = fixedSizeAccum + boundedColsWidth

          // If bounded columns are more than the viewport width, drop the lowest priority column and start again.
          if (totalBounded > width.value && prioritizedRemainingCols.nonEmpty)
            // We must remove columns one by one, since removing one causes the rest to recompute.
            go1(overflowColumn)
          else
            val remainingSpace: Int = width.value - totalBounded

            val totalNewUnbounded: Int = unboundedCols.map(_._2.value).sum

            val ratio: Double = remainingSpace.toDouble / totalNewUnbounded

            val newFixedAccum: Map[ColumnId, SizePx] =
              fixedAccum ++
                boundedCols.collect:
                  case (Some(colId), size) => colId -> size

            val unboundedColsAdjusted: Map[ColumnId, SizePx] =
              unboundedCols
                .map: (colId, width) =>
                  colId -> width.modify(x => (x * ratio).toInt)
                .toMap

            boundedCols match
              case Nil        =>
                DynTable.ColState.resized.replace(
                  ColumnSizing(newFixedAccum ++ unboundedColsAdjusted)
                )(colState)
              case newBounded =>
                go2(unboundedColsAdjusted, newFixedAccum, totalBounded)
        }

        go2(visibleSizes)
      }

    go1(colState.resetOverflow)
  }

object DynTable:
  /**
   * State of the columns in a dynamic table.
   *
   * @param resized
   *   Resized columns. To be passed directly to table `state` as `PartialTableState.columnSizing`.
   * @param visibility
   *   Column visitibility without taking into account overflows. Do not pass to table. See
   *   `computedVisibility` instead.
   * @param overflow
   *   Column overflows.
   */
  case class ColState(
    val resized:    ColumnSizing,
    val visibility: ColumnVisibility,
    val overflow:   Set[ColumnId] = Set.empty
  ):
    /**
     * Column visitibility. To be passed directly to table `state` as
     * `PartialTableState.columnVisibility`.
     */
    lazy val computedVisibility: ColumnVisibility =
      visibility.modify(_ ++ overflow.map(_ -> Visibility.Hidden))

    /**
     * The same state without overflows. Useful when recomputing them.
     */
    def resetOverflow: ColState =
      ColState.overflow.replace(Set.empty)(this)

  object ColState:
    val resized: Lens[ColState, ColumnSizing]        = Focus[ColState](_.resized)
    val visibility: Lens[ColState, ColumnVisibility] = Focus[ColState](_.visibility)
    val overflow: Lens[ColState, Set[ColumnId]]      = Focus[ColState](_.overflow)




© 2015 - 2024 Weber Informatics LLC | Privacy Policy