Skip to Content

Search tceq.texas.gov

TxVEMP Render Table

TxVEMP Render Table

text/javascript txvemp-render-table.js — 14.5 KB

File contents

const grantTableLabels = Object.freeze({
  EquipmentType: "Old Vehicle/Equipment Type",
  ApplicantType: "Applicant Type",
  HPRange: "HP Range",
  YearRange: "Year Range",
  NewFuelPower: "New Fuel/Power Source",
  GrantAmount: "Grant Amount",
  MinimumYear: "Minimum Year",
  MaximumYear: "Maximum Year",
  MinimumHP: "Minimum HP",
  MaximumHP: "Maximum HP",
});

const textAlignments = Object.freeze({
  LEFT: "left",
  CENTER: "center",
  RIGHT: "right",
});

// One source for table columns and filter controls.
// If the HTML ID changes, update controlID here only.
const grantTableHeaders = Object.freeze([
  {
    columnHeader: grantTableLabels.EquipmentType,
    textAlignment: textAlignments.LEFT,
    isFilter: true,
    filterKey: "equipment",
    controlID: "#filterEquipmentType",
  },
  {
    columnHeader: grantTableLabels.ApplicantType,
    textAlignment: textAlignments.CENTER,
    isFilter: true,
    filterKey: "applicant",
    controlID: "#filterApplicantType",
  },
  {
    columnHeader: grantTableLabels.HPRange,
    textAlignment: textAlignments.CENTER,
    isFilter: true,
    filterKey: "hpRange",
    controlID: "#filterHpRange",
  },
  {
    columnHeader: grantTableLabels.YearRange,
    textAlignment: textAlignments.CENTER,
    isFilter: true,
    filterKey: "yearRange",
    controlID: "#filterYearRange",
    usesYearLookup: true,
  },
  {
    columnHeader: grantTableLabels.NewFuelPower,
    textAlignment: textAlignments.LEFT,
  },
  {
    columnHeader: grantTableLabels.GrantAmount,
    textAlignment: textAlignments.RIGHT,
  },
]);

const filterColumns = grantTableHeaders.filter((col) => col.isFilter);
const filterSelectors = filterColumns.map((col) => col.controlID).join(", ");

let pageSize = 10;
let currentPage = 1;
function getGrantTablesData() {
  if (typeof grantTables !== "undefined" && Array.isArray(grantTables)) {
    return grantTables;
  }

  if (Array.isArray(window.grantTables)) {
    return window.grantTables;
  }

  return [];
}

let filteredData = sortGrantTables([...getGrantTablesData()]);

// Tracks which equipment types are expanded in the mobile accordion.
let accordionState = {};

function getColumnByHeader(columnHeader) {
  return grantTableHeaders.find((col) => col.columnHeader === columnHeader);
}

function getColumnByFilterKey(filterKey) {
  return filterColumns.find((col) => col.filterKey === filterKey);
}

function getFilterState() {
  return filterColumns.reduce((state, col) => {
    state[col.filterKey] = $(col.controlID).val() || "";
    return state;
  }, {});
}

function rowMatchesFilters(row, filters) {
  return filterColumns.every((col) => {
    const selectedValue = filters[col.filterKey];
    if (!selectedValue) return true;

    if (col.usesYearLookup) {
      const selectedYear = parseInt(selectedValue, 10);
      const minYear = parseInt(row[grantTableLabels.MinimumYear], 10);
      const maxYear = parseInt(row[grantTableLabels.MaximumYear], 10);

      return (
        !Number.isNaN(selectedYear) &&
        !Number.isNaN(minYear) &&
        !Number.isNaN(maxYear) &&
        selectedYear >= minYear &&
        selectedYear <= maxYear
      );
    }

    return row[col.columnHeader] === selectedValue;
  });
}

function filterRows(baseFilters) {
  return getGrantTablesData().filter((row) => rowMatchesFilters(row, baseFilters));
}

function getFirstRangeNumber(value) {
  return parseInt(String(value).split("-")[0].trim(), 10) || 0;
}

function sortEquipmentTypes(a, b) {
  const startsWithClassA = String(a).startsWith("Class");
  const startsWithClassB = String(b).startsWith("Class");

  if (startsWithClassA && !startsWithClassB) return 1;
  if (!startsWithClassA && startsWithClassB) return -1;

  return String(a).localeCompare(String(b));
}

function sortGrantTables(data) {
  return data.sort((a, b) => {
    const equipmentCompare = sortEquipmentTypes(
      a[grantTableLabels.EquipmentType] || "",
      b[grantTableLabels.EquipmentType] || ""
    );
    if (equipmentCompare !== 0) return equipmentCompare;

    const applicantCompare = String(a[grantTableLabels.ApplicantType] || "").localeCompare(
      String(b[grantTableLabels.ApplicantType] || "")
    );
    if (applicantCompare !== 0) return applicantCompare;

    const hpCompare =
      (parseInt(a[grantTableLabels.MinimumHP], 10) || getFirstRangeNumber(a[grantTableLabels.HPRange])) -
      (parseInt(b[grantTableLabels.MinimumHP], 10) || getFirstRangeNumber(b[grantTableLabels.HPRange]));
    if (hpCompare !== 0) return hpCompare;

    return (
      (parseInt(a[grantTableLabels.MinimumYear], 10) || 0) -
      (parseInt(b[grantTableLabels.MinimumYear], 10) || 0)
    );
  });
}

function renderGrantTableHeaders() {
  const thead = $("#grantTable thead").empty();
  const headerRow = $("<tr>");

  grantTableHeaders.forEach((col) => {
    headerRow.append(
      $("<th>").text(col.columnHeader).css("text-align", textAlignments.CENTER)
    );
  });

  thead.append(headerRow);
}

function renderGrantTableBody(page) {
  const tbody = $("#grantTable tbody").empty();

  if (!filteredData || filteredData.length === 0) {
    tbody.append(
      $("<tr>").append(
        $("<td>")
          .attr("colspan", grantTableHeaders.length)
          .addClass("text-center")
          .text("No data")
      )
    );
    return;
  }

  const start = (page - 1) * pageSize;
  const end = start + pageSize;

  filteredData.slice(start, end).forEach((item) => {
    const row = $("<tr>");

    grantTableHeaders.forEach((col) => {
      const value = item[col.columnHeader] || "—";
      row.append($("<td>").text(value).css("text-align", col.textAlignment));
    });

    tbody.append(row);
  });
}

function renderDesktopPagination(page) {
  const totalItems = filteredData.length;
  const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
  const pagination = $("#grantPagination").empty();
  const windowSize = 7;

  function addPageButton(label, targetPage, disabled, active) {
    const pageItem = $("<li>").addClass(
      "page-item" + (disabled ? " disabled" : "") + (active ? " active" : "")
    );

    const pageLink = $("<a>")
      .addClass("page-link")
      .attr("href", "#")
      .text(label)
      .on("click", function (e) {
        e.preventDefault();
        if (!disabled && currentPage !== targetPage) {
          currentPage = targetPage;
          renderGrantTable(currentPage);
        }
      });

    pageItem.append(pageLink);
    pagination.append(pageItem);
  }

  addPageButton("First", 1, page === 1, false);
  addPageButton("Prev", Math.max(1, page - 1), page === 1, false);

  if (totalPages <= windowSize) {
    for (let i = 1; i <= totalPages; i++) {
      addPageButton(String(i), i, false, i === page);
    }
  } else {
    const half = Math.floor(windowSize / 2);
    let startPage = Math.max(1, page - half);
    let endPage = Math.min(totalPages, startPage + windowSize - 1);

    if (endPage - startPage + 1 < windowSize) {
      startPage = Math.max(1, endPage - windowSize + 1);
    }

    if (startPage > 1) addPageButton("…", Math.max(1, startPage - windowSize), false, false);

    for (let i = startPage; i <= endPage; i++) {
      addPageButton(String(i), i, false, i === page);
    }

    if (endPage < totalPages) addPageButton("…", Math.min(totalPages, endPage + 1), false, false);
  }

  addPageButton("Next", Math.min(totalPages, page + 1), page === totalPages, false);
  addPageButton("Last", totalPages, page === totalPages, false);
}

function renderMobileAccordion() {
  const table = $("#grantTable");
  const container = table.closest(".table-responsive");
  let accordion = $("#mobileAccordion");

  if (!accordion.length) {
    accordion = $('<div id="mobileAccordion" class="accordion mt-3"></div>');
    container.append(accordion);
  } else {
    accordion.empty();
  }

  if (!filteredData || !filteredData.length) {
    accordion.append('<div class="text-center text-muted small">No data</div>');
    return;
  }

  if (!accordion.data("state-wired")) {
    accordion
      .on("shown.bs.collapse", ".accordion-collapse", function () {
        const equipmentKey = $(this).data("equipment");
        if (equipmentKey) accordionState[equipmentKey] = true;
      })
      .on("hidden.bs.collapse", ".accordion-collapse", function () {
        const equipmentKey = $(this).data("equipment");
        if (equipmentKey) accordionState[equipmentKey] = false;
      });

    accordion.data("state-wired", true);
  }

  const equipmentGroups = {};
  filteredData.forEach((row) => {
    const equipmentKey = row[grantTableLabels.EquipmentType] || "Other";
    if (!equipmentGroups[equipmentKey]) equipmentGroups[equipmentKey] = [];
    equipmentGroups[equipmentKey].push(row);
  });

  function buildInnerTable(rows) {
    const tbl = $(
      '<div class="table-responsive">' +
        '<table class="table table-striped table-bordered table-sm align-middle mb-0">' +
          '<thead></thead>' +
          '<tbody></tbody>' +
        '</table>' +
      '</div>'
    );

    const thead = tbl.find("thead");
    const tbody = tbl.find("tbody");
    const headerRow = $("<tr>");

    grantTableHeaders.forEach((col) => {
      if (col.columnHeader === grantTableLabels.EquipmentType) return;
      if (col.columnHeader === grantTableLabels.ApplicantType) return;

      headerRow.append(
        $("<th>")
          .text(col.columnHeader)
          .css("text-align", textAlignments.CENTER)
          .addClass("fw-bold fs-6 text-body-secondary")
      );
    });

    thead.append(headerRow);

    rows.forEach((rowData) => {
      const row = $("<tr>");

      grantTableHeaders.forEach((col) => {
        if (col.columnHeader === grantTableLabels.EquipmentType) return;
        if (col.columnHeader === grantTableLabels.ApplicantType) return;

        const value = rowData[col.columnHeader] || "—";
        row.append($("<td>").text(value).css("text-align", col.textAlignment));
      });

      tbody.append(row);
    });

    return tbl;
  }

  Object.entries(equipmentGroups).forEach(([equipment, equipmentRows], index) => {
    const safeIndex = `equipment-${index}`;
    const collapseId = `collapse-${safeIndex}`;
    const headingId = `heading-${safeIndex}`;
    const isOpen = accordionState[equipment] === true;

    const item = $(
      '<div class="accordion-item">' +
        `<h2 class="accordion-header" id="${headingId}">` +
          `<button class="accordion-button ${isOpen ? "" : "collapsed"}" type="button" data-bs-toggle="collapse" data-bs-target="#${collapseId}" aria-expanded="${isOpen ? "true" : "false"}" aria-controls="${collapseId}"></button>` +
        '</h2>' +
        `<div id="${collapseId}" class="accordion-collapse collapse ${isOpen ? "show" : ""}" data-bs-parent="#mobileAccordion" data-equipment="${equipment}">` +
          '<div class="accordion-body p-2">' +
            '<div class="accordion-body-scroll" style="max-height: calc(100vh - 220px); overflow-y: auto;"></div>' +
          '</div>' +
        '</div>' +
      '</div>'
    );

    item.find(".accordion-button").text(equipment);
    const scrollBody = item.find(".accordion-body-scroll");

    const applicantGroups = {};
    equipmentRows.forEach((row) => {
      const applicantKey = row[grantTableLabels.ApplicantType] || "—";
      if (!applicantGroups[applicantKey]) applicantGroups[applicantKey] = [];
      applicantGroups[applicantKey].push(row);
    });

    Object.entries(applicantGroups).forEach(([applicant, rows]) => {
      scrollBody.append(
        $("<div>")
          .addClass("fw-bold fs-5 text-uppercase mt-4 mb-1")
          .text(`${grantTableLabels.ApplicantType}: ${applicant}`)
      );
      scrollBody.append(buildInnerTable(rows));
    });

    accordion.append(item);
  });
}

function updateFilterOptionsForField(column, currentFilters) {
  const baseFilters = { ...currentFilters, [column.filterKey]: "" };
  const rowsForField = filterRows(baseFilters);
  const valueSet = new Set();

  if (column.usesYearLookup) {
    let minYear = Infinity;
    let maxYear = -Infinity;

    rowsForField.forEach((row) => {
      const rowMin = parseInt(row[grantTableLabels.MinimumYear], 10);
      const rowMax = parseInt(row[grantTableLabels.MaximumYear], 10);

      if (!Number.isNaN(rowMin) && rowMin < minYear) minYear = rowMin;
      if (!Number.isNaN(rowMax) && rowMax > maxYear) maxYear = rowMax;
    });

    if (minYear !== Infinity && maxYear !== -Infinity) {
      for (let year = maxYear; year >= minYear; year--) {
        valueSet.add(String(year));
      }
    }
  } else {
    rowsForField.forEach((row) => {
      if (row[column.columnHeader]) valueSet.add(row[column.columnHeader]);
    });
  }

  const select = $(column.controlID);
  const previousValue = select.val();

  select.empty();
  select.append($("<option>").attr("value", "").text("All"));

  const sortedValues = Array.from(valueSet).sort((a, b) => {
    if (column.columnHeader === grantTableLabels.HPRange) {
      return getFirstRangeNumber(a) - getFirstRangeNumber(b);
    }

    if (column.columnHeader === grantTableLabels.EquipmentType) {
      return sortEquipmentTypes(a, b);
    }

    if (column.usesYearLookup) {
      return parseInt(b, 10) - parseInt(a, 10);
    }

    return String(a).localeCompare(String(b));
  });

  sortedValues.forEach((value) => {
    select.append($("<option>").attr("value", value).text(value));
  });

  select.val(previousValue && valueSet.has(previousValue) ? previousValue : "");
}

function updateAllFilterOptions(currentFilters) {
  filterColumns.forEach((column) => updateFilterOptionsForField(column, currentFilters));
}

function applyFilters() {
  const filters = getFilterState();
  filteredData = sortGrantTables(filterRows(filters));
  currentPage = 1;

  updateAllFilterOptions(filters);
  renderGrantTable(currentPage);
}

function initFilters() {
  updateAllFilterOptions(getFilterState());

  $(filterSelectors).on("change", applyFilters);

  $("#resetFilters").on("click", function () {
    filterColumns.forEach((col) => $(col.controlID).val(""));
    applyFilters();
  });
}

function renderGrantTable(page) {
  const isMobile = window.innerWidth < 768;

  if (isMobile) {
    $("#grantTable").hide();
    $("#grantPagination").closest("nav").hide();
    renderMobileAccordion();
    return;
  }

  $("#grantTable").show();
  $("#grantPagination").closest("nav").show();
  $("#mobileAccordion").remove();

  renderGrantTableHeaders();
  renderGrantTableBody(page);
  renderDesktopPagination(page);
}

$(function () {
  initFilters();
  renderGrantTable(currentPage);

  let resizeTimer = null;
  $(window).on("resize", function () {
    clearTimeout(resizeTimer);
    resizeTimer = setTimeout(function () {
      renderGrantTable(currentPage);
    }, 150);
  });
});