{"id":640,"date":"2026-03-03T06:27:58","date_gmt":"2026-03-03T05:27:58","guid":{"rendered":"https:\/\/highered-change.org\/?page_id=640"},"modified":"2026-06-19T03:49:21","modified_gmt":"2026-06-19T01:49:21","slug":"inventory-he-academic-programs","status":"publish","type":"page","link":"https:\/\/highered-change.org\/index.php\/inventory-he-academic-programs\/","title":{"rendered":"Inventory HE academic programs"},"content":{"rendered":"\n<!--\n  ============================================================================\n  GOHEC \u2014 Inventory of HE Academic Programs (single embed block, NO iframes)\n  ============================================================================\n\n  WHAT THIS IS\n  This is the HTML\/CSS\/JS that powers the \"Inventory HE academic programs\" page:\n  https:\/\/highered-change.org\/index.php\/inventory-he-academic-programs\/\n\n  It renders, on the SAME page (no iframes):\n    1. An interactive Leaflet world map with one marker per university.\n    2. A searchable\/sortable DataTables table of academic programs.\n\n  DATA SOURCES (produced by the \"HE Maps data sync\" WordPress plugin):\n    - JSON for the map  : \/wp-content\/uploads\/json_and_csv\/he_academic.json\n    - CSV  for the table: \/wp-content\/uploads\/json_and_csv\/he_academic.csv\n  Those files are refreshed from Google Sheets via Tools \u2192 HE maps data in wp-admin.\n\n  DIFFERENCES vs the research centers embed\n    - The table has a \"Description\" column with a clamp \/ \"Show more\" toggle.\n    - Deep-link filters use ?university=\u2026 & ?country=\u2026 (research uses ?center=\u2026).\n    - The table min-width is wider (extra column).\n  Otherwise the map + table machinery is intentionally identical.\n\n  HOW TO USE\n  Paste this whole block into a WordPress \"Custom HTML\" block on the page.\n  External libraries (jQuery, Leaflet, DataTables, PapaParse) load from CDNs and\n  should appear only ONCE per page.\n\n  NOTE ON IDS\n  Element ids reuse the \"he-centers-*\" prefix on purpose so the shared CSS\/JS is\n  identical across both inventory pages.\n-->\n\n<style>\n  \/* Base reset: shared font + remove default margins\/padding. *\/\n  * {\n    margin: 0;\n    padding: 0;\n    box-sizing: border-box;\n    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;\n  }\n\n  \/* Wrapper for all body text\/content of the embed. *\/\n  .itBody {\n    color: #001e2c;\n    line-height: 1.6;\n    box-sizing: border-box;\n    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;\n  }\n\n  .container {\n    max-width: 1200px;\n    margin: 0 auto;\n    padding: 0 20px;\n  }\n\n  \/* Optional dark page header (kept for reuse; not always rendered). *\/\n  .site-header {\n    background-color: #001e2c;\n    color: white;\n    padding: 30px 0;\n    border-bottom: 4px solid #ec7c34;\n  }\n\n  .site-title {\n    font-size: 2.5rem;\n    text-align: center;\n    margin-bottom: 10px;\n  }\n\n  .site-description {\n    text-align: center;\n    opacity: 0.9;\n    max-width: 800px;\n    margin: 0 auto;\n  }\n\n  \/* Optional tab bar (only used when the tabbed layout is present). *\/\n  .tabs-container {\n    display: flex;\n    justify-content: center;\n    padding-bottom: 20px;\n  }\n\n  .tab-button {\n    padding: 12px 24px;\n    background-color: #4e7bc7;\n    color: white;\n    border: none;\n    margin: 0 10px;\n    cursor: pointer;\n    font-weight: bold;\n    border-radius: 5px;\n    transition: all 0.3s ease;\n  }\n\n  .tab-button.active {\n    background-color: #ec7c34;\n    transform: translateY(-5px);\n  }\n\n  .tab-button:hover {\n    opacity: 0.9;\n  }\n\n  .content-area {\n    padding: 40px 0;\n  }\n\n  \/* Tab panels: hidden unless marked active. *\/\n  .panel-content {\n    display: none;\n    margin: 0 14px;\n  }\n\n  .panel-content.active {\n    display: block;\n  }\n\n  .section-heading {\n    color: #4e7bc7;\n    margin-bottom: 20px;\n    border-bottom: 2px solid #ec7c34;\n    padding-bottom: 10px;\n    font-size: 2rem;\n  }\n\n  .content-paragraph {\n    margin-bottom: 20px;\n  }\n\n  .content-link {\n    color: #4e7bc7;\n    text-decoration: none;\n  }\n\n  .content-link:hover {\n    text-decoration: underline;\n    color: #ec7c34;\n  }\n\n  \/* White \"cards\" that hold the map and the table. *\/\n  .map-container,\n  .table-container {\n    background-color: white;\n    padding: 20px;\n    border-radius: 8px;\n    box-shadow: 0 4px 12px rgba(0,0,0,0.1);\n    margin-bottom: 30px;\n  }\n\n  \/* The Leaflet map mount point (named *-iframe for legacy reasons; it is a div). *\/\n  .map-iframe {\n    width: 100%;\n    height: 600px;\n    border: 2px solid white;\n    border-radius: 5px;\n  }\n\n  .table-iframe {\n    width: 100%;\n    border: none;\n  }\n\n  \/* \"Add \/ edit a program\" call-to-action box. *\/\n  .cta-section {\n    background-color: #4e7bc7;\n    padding: 30px;\n    border-radius: 8px;\n    margin-top: 40px;\n    color: white;\n    text-align: center;\n  }\n\n  .cta-heading {\n    margin-bottom: 15px;\n    color: white;\n  }\n\n  .cta-button {\n    display: inline-block;\n    background-color: #ec7c34;\n    text-decoration: none;\n    color: white;\n    padding: 12px 24px;\n    border-radius: 5px;\n    font-weight: bold;\n    margin-top: 15px;\n    transition: all 0.3s ease;\n  }\n\n  .cta-button:hover {\n    transform: translateY(-3px);\n    box-shadow: 0 4px 12px rgba(0,0,0,0.2);\n    text-decoration: none;\n    color: white;\n  }\n\n  .text-highlight {\n    color: #ec7c34;\n    font-weight: bold;\n  }\n\n  .itBody p {\n    margin: 10px 0;\n  }\n\n  \/* Inline links inside paragraphs (WordPress theme override). *\/\n  p a:where(:not(.wp-element-button)) {\n    color: blue;\n    font-weight: 500;\n  }\n\n  \/* ---- DataTables look & feel ---- *\/\n  table.dataTable thead {\n    background-color: #4193cf;\n    color: white;\n  }\n\n  table.dataTable tbody tr:nth-child(even) {\n    background-color: #f2f2f2;\n  }\n\n  table.dataTable tbody tr:hover {\n    background-color: #ddd;\n  }\n\n  table.dataTable {\n    border-collapse: collapse;\n    width: 100%;\n  }\n\n  table.dataTable th,\n  table.dataTable td {\n    padding: 8px 12px;\n    text-align: left;\n    border: 1px solid #ddd;\n  }\n\n  .dataTables_length,\n  .dataTables_filter {\n    margin-bottom: 15px;\n  }\n\n  \/* Per-column filter inputs live in the SECOND header row. *\/\n  #he-centers-table thead tr:nth-child(2) th input {\n    width: 100%;\n    box-sizing: border-box;\n    padding: 4px;\n    border: 1px solid #ccc;\n    min-width: 140px;\n  }\n\n  \/* Row above the table holding the heading + \"Clear filters\" button. *\/\n  .table-header-row {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    gap: 12px;\n    margin-bottom: 12px;\n    flex-wrap: wrap;\n  }\n\n  \/* \"Clear filters\" button: hidden until at least one filter is active. *\/\n  .clear-filters-btn {\n    display: none;\n    background-color: #ec7c34;\n    color: white;\n    border: none;\n    border-radius: 5px;\n    padding: 10px 14px;\n    font-weight: 600;\n    cursor: pointer;\n    transition: opacity 0.2s ease, transform 0.2s ease;\n  }\n\n  .clear-filters-btn.is-visible {\n    display: inline-block;\n  }\n\n  .clear-filters-btn:hover {\n    opacity: 0.92;\n    transform: translateY(-1px);\n  }\n\n  \/* ===== Mobile-friendly horizontal scroll wrapper for the table ===== *\/\n  .table-scroll {\n    width: 100%;\n    overflow-x: auto;\n    -webkit-overflow-scrolling: touch;\n  }\n\n  \/* Wider min-width than research page because of the extra Description column. *\/\n  #he-centers-table {\n    width: 100% !important;\n    min-width: 1100px;\n  }\n\n  \/* Force all table links to render blue regardless of theme styles. *\/\n  #he-centers-table a,\n  #he-centers-table a:visited {\n    color: #1a73e8 !important;\n    font-weight: 500;\n    text-decoration: none;\n  }\n\n  #he-centers-table a:hover {\n    text-decoration: underline;\n  }\n\n  \/* The Website column shows a \"Website\" link but stores the raw URL in a hidden\n     span so DataTables search still matches against the full URL text. *\/\n  .table-search-hidden {\n    display: none;\n  }\n\n  \/* ---- Description column: clamp to 3 lines with an expand\/collapse toggle ---- *\/\n  #he-centers-table td.desc-cell {\n    white-space: normal !important;\n    min-width: 320px;\n    vertical-align: top;\n  }\n\n  .desc-wrap {\n    white-space: normal;\n  }\n\n  \/* Collapsed state: show only 3 lines (CSS line clamp). *\/\n  .desc-text {\n    display: -webkit-box;\n    -webkit-box-orient: vertical;\n    -webkit-line-clamp: 3;\n    overflow: hidden;\n    line-height: 1.45;\n    max-height: calc(1.45em * 3);\n    word-break: break-word;\n  }\n\n  \/* Expanded state: show the full description. *\/\n  .desc-wrap.expanded .desc-text {\n    display: block;\n    -webkit-line-clamp: unset;\n    max-height: none;\n    overflow: visible;\n  }\n\n  \/* \"Show more \/ Show less\" toggle link. *\/\n  .desc-toggle {\n    display: inline-block;\n    margin-top: 6px;\n    padding: 0;\n    border: 0;\n    background: none;\n    color: #1a73e8;\n    cursor: pointer;\n    font-weight: 500;\n    text-decoration: none;\n  }\n\n  .desc-toggle:hover {\n    text-decoration: underline;\n  }\n\n  \/* Hide the toggle when the text is short enough to need no clamping. *\/\n  .desc-toggle.is-hidden {\n    display: none;\n  }\n\n  \/* WordPress theme spacing tweaks (site-specific). *\/\n  .has-text-align-center.wp-block-post-title.has-x-large-font-size {\n    margin-top: 30px !important;\n  }\n\n  .wp-block-group.has-global-padding.is-layout-constrained.wp-container-core-group-is-layout-5da90744.wp-block-group-is-layout-constrained {\n    margin-top: 30px !important;\n  }\n\n  \/* ---- Responsive adjustments for phones\/tablets ---- *\/\n  @media (max-width: 768px) {\n    .site-title {\n      font-size: 2rem;\n    }\n\n    .tabs-container {\n      flex-direction: column;\n      align-items: center;\n    }\n\n    .tab-button {\n      margin: 5px 0;\n      width: 80%;\n    }\n\n    .map-iframe {\n      height: 400px;\n    }\n\n    .table-container {\n      padding: 12px;\n    }\n\n    table.dataTable th,\n    table.dataTable td {\n      padding: 6px 10px;\n      font-size: 14px;\n      white-space: nowrap;\n    }\n\n    \/* Keep description readable (wrapping) even on small screens. *\/\n    #he-centers-table td.desc-cell {\n      white-space: normal !important;\n      min-width: 260px;\n    }\n  }\n<\/style>\n\n<!-- Third-party libraries (load once per page): Leaflet (map), DataTables (table),\n     jQuery (DataTables dependency), PapaParse (CSV parser). -->\n<link rel=\"stylesheet\" href=\"https:\/\/unpkg.com\/leaflet\/dist\/leaflet.css\" \/>\n<link rel=\"stylesheet\" href=\"https:\/\/cdn.datatables.net\/1.11.5\/css\/jquery.dataTables.min.css\" \/>\n\n<script src=\"https:\/\/code.jquery.com\/jquery-3.6.0.min.js\"><\/script>\n<script src=\"https:\/\/unpkg.com\/leaflet\/dist\/leaflet.js\"><\/script>\n<script src=\"https:\/\/cdn.datatables.net\/1.11.5\/js\/jquery.dataTables.min.js\"><\/script>\n<script src=\"https:\/\/cdnjs.cloudflare.com\/ajax\/libs\/PapaParse\/5.3.2\/papaparse.min.js\"><\/script>\n\n<main class=\"itBody\">\n  <!-- Static intro copy. Safe to edit text; mirrors the live page content. -->\n  <p>In 2000, the <a href=\"http:\/\/www.bc.edu\/cihe\" target=\"_blank\">Center for International Higher Education<\/a> at Boston College, under the leadership of Philip G. Altbach, completed the first global inventory of higher education centers and programs around the world (Altbach &amp; Engberg, 2000). The CIHE inventory was updated in 2006 (Altbach et. al. 2006) and again in 2013 (Rumbley et al, 2014), the latter also including a partial inventory of journals publishing on issues related to higher education around the world. In 2024 and 2025, GOHEC worked with CIHE to revitalize the inventory project. The resulting Beta version, available here, is therefore the most current comprehensive global \u201cinventory\u201d of higher education research centers and degree-granting academic programs focused on higher education.<\/p>\n\n  <p>The Inventory of Academic Programs provides valuable insights into the global landscape of higher education capacity building, highlighting key trends and issues that shape the scholarly field of higher education worldwide. The accompanying map not only captures a \u201csnapshot\u201d of higher education academic programs but also serves as an exceptional resource to enhance communication and collaboration within the higher education research community.<\/p>\n\n  <p>The version of the inventory of 2026 was developed by a team that included: <i>Prof.A.Pausits, Prof. I.Frumin, Dr. A.Vorochkov, O. Mostafa<\/i>.<\/p>\n\n  <!-- NOTE: the two links below were malformed in the original source\n       (missing href= attribute). They are fixed here. -->\n  <p>Please cite as: Pausits, A., Frumin, I., Vorochkov, A., &#038; Mostafa, O. (2026). Inventory of Higher Education  Academic Programs [Data set]. DOOR, Universit\u00e4t f\u00fcr Weiterbildung Krems. <a href=\"https:\/\/doi.org\/10.48341\/0n4d-g853\" target=\"_blank\" rel=\"noopener\">https:\/\/doi.org\/10.48341\/0n4d-g853<\/a>\n  Licence: <a href=\"https:\/\/creativecommons.org\/licenses\/by-nc\/4.0\/\" target=\"_blank\" rel=\"noopener\">CC BY-NC 4.0 International<\/a><\/p>\n\n  <p>If you would like more information about our data collection methodology, please <a href=\"https:\/\/docs.google.com\/document\/d\/1f9RM97nWEe3qmSdtsxRWV7QQIwxUTjcASIIWwrjktrQ\/edit?usp=sharing\" target=\"_blank\">click here<\/a>.<\/p>\n\n  <p>If you have additional information, corrections, or know of any degree program in the field of higher education not yet listed, please kindly fill out the form linked below. You can contact us via email: <a href=\"mailto:inventory.gohec@donau-uni.ac.at\">inventory.gohec@donau-uni.ac.at<\/a><\/p>\n\n  <p><a href=\"https:\/\/docs.google.com\/spreadsheets\/d\/1JMrimCnvOy5sZruM1J-Qu0S1QyrMSzYSdfg4Ki-IqoA\" target=\"_blank\" rel=\"noopener\">Download Excel data<\/a><\/p>\n  <br>\n\n  <!-- Map card: JS mounts the Leaflet map into #he-centers-map. -->\n  <div class=\"map-container\">\n    <h3>Interactive Map of academic programs<\/h3>\n    <div id=\"he-centers-map\" class=\"map-iframe\"><\/div>\n  <\/div>\n\n  <!-- Table card: heading + clear-filters button, then the DataTables table. -->\n  <div class=\"table-container\">\n    <div class=\"table-header-row\">\n      <h3>Table of academic programs<\/h3>\n      <button type=\"button\" id=\"clear-he-filters\" class=\"clear-filters-btn\">Clear filters<\/button>\n    <\/div>\n\n    <div class=\"table-scroll\">\n      <!-- Two header rows: row 1 = column titles, row 2 = per-column filter inputs.\n           Row order must match the CSV columns handled in initCentersTable(). -->\n      <table id=\"he-centers-table\" class=\"display\" style=\"width:100%\">\n        <thead>\n          <tr>\n            <th>Program<\/th>\n            <th>University<\/th>\n            <th>Country<\/th>\n            <th>Website<\/th>\n            <th>Level<\/th>\n            <th>Description<\/th>\n          <\/tr>\n          <tr>\n            <th><input type=\"text\" placeholder=\"Filter Program\" \/><\/th>\n            <th><input type=\"text\" placeholder=\"Filter University\" \/><\/th>\n            <th><input type=\"text\" placeholder=\"Filter Country\" \/><\/th>\n            <th><input type=\"text\" placeholder=\"Filter Website\" \/><\/th>\n            <th><input type=\"text\" placeholder=\"Filter Level\" \/><\/th>\n            <th><input type=\"text\" placeholder=\"Filter Description\" \/><\/th>\n          <\/tr>\n        <\/thead>\n        <tbody><\/tbody>\n      <\/table>\n    <\/div>\n  <\/div>\n\n  <!-- Call to action: submit a missing\/updated program via the external form. -->\n  <div class=\"cta-section\">\n    <h3 class=\"cta-heading\">Missing or Editing an academic program?<\/h3>\n    <p>Help us keep the map complete. If your academic program isn&#8217;t listed or you&#8217;d like to update its information, please fill out the form below.<\/p>\n    <a href=\"https:\/\/forms.office.com\/Pages\/ResponsePage.aspx?id=v3HXst56qUWhtClAjz3C5j66EepJPWVHnbXY_51bLcxURUhUVzFPRlk5TFczWTBWSVlNSkZWUFQ3Ry4u\" class=\"cta-button\" target=\"_blank\" rel=\"noopener\">Add academic program<\/a>\n  <\/div>\n\n  <!-- Optional panels used only if the tabbed layout (centers\/programs) is enabled. -->\n  <div id=\"centers-panel\" class=\"panel-content active\"><\/div>\n  <div id=\"programs-panel\" class=\"panel-content\"><\/div>\n<\/main>\n\n<script>\n\/*\n  All logic runs inside an IIFE to avoid polluting the global scope.\n  High-level flow:\n    boot() \u2192 build map + load JSON markers, build table from CSV, wire up filters.\n  Deep-linking: ?university=\u2026&country=\u2026 in the URL pre-applies table filters and the\n  same filters can be triggered by clicking marker popup links.\n\n  Extra vs research page: a \"Description\" column rendered with a 3-line clamp and a\n  \"Show more \/ Show less\" toggle (see refreshDescriptionToggles()).\n*\/\n(function () {\n  \/\/ Data files written by the WordPress plugin (cache-busted with ?v=timestamp).\n  const JSON_URL = '\/wp-content\/uploads\/json_and_csv\/he_academic.json';\n  const CSV_URL = '\/wp-content\/uploads\/json_and_csv\/he_academic.csv';\n\n  \/\/ Escape text before inserting it into HTML (prevents XSS \/ broken markup).\n  function escapeHtml(value) {\n    return String(value ?? '')\n      .replace(\/&\/g, '&amp;')\n      .replace(\/<\/g, '&lt;')\n      .replace(\/>\/g, '&gt;')\n      .replace(\/\"\/g, '&quot;')\n      .replace(\/'\/g, '&#039;');\n  }\n\n  \/\/ Escape text, then convert newlines to <br> (used for the Description cell).\n  function formatTextWithBreaks(value) {\n    return escapeHtml(value).replace(\/\\r?\\n\/g, '<br>');\n  }\n\n  \/\/ Return a URL only if it is a valid http(s) link; otherwise return '' (drop it).\n  function safeUrl(url) {\n    const value = String(url ?? '').trim();\n    if (!value) return '';\n\n    try {\n      const parsed = new URL(value, window.location.origin);\n      if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {\n        return parsed.href;\n      }\n    } catch (e) {}\n\n    return '';\n  }\n\n  \/\/ Allow only a small whitelist of tags inside map popups; strip everything else.\n  function sanitizePopupHtml(html) {\n    const template = document.createElement('template');\n    template.innerHTML = String(html ?? '');\n\n    const allowedTags = new Set(['BR', 'B', 'STRONG', 'I', 'EM', 'U', 'A', 'P', 'UL', 'OL', 'LI']);\n    const elements = Array.from(template.content.querySelectorAll('*'));\n\n    elements.forEach(function (el) {\n      \/\/ Replace any non-whitelisted element with its plain text content.\n      if (!allowedTags.has(el.tagName)) {\n        el.replaceWith(document.createTextNode(el.textContent || ''));\n        return;\n      }\n\n      \/\/ Strip all attributes except safe ones on <a>.\n      const attrs = Array.from(el.attributes);\n      attrs.forEach(function (attr) {\n        const attrName = attr.name.toLowerCase();\n\n        if (el.tagName === 'A' && (attrName === 'href' || attrName === 'target' || attrName === 'rel')) {\n          return;\n        }\n\n        el.removeAttribute(attr.name);\n      });\n\n      \/\/ Force links to be safe external links only.\n      if (el.tagName === 'A') {\n        const href = el.getAttribute('href') || '';\n        if (!\/^https?:\\\/\\\/\/i.test(href) && !\/^mailto:\/i.test(href)) {\n          el.removeAttribute('href');\n        }\n        el.setAttribute('target', '_blank');\n        el.setAttribute('rel', 'noopener');\n      }\n    });\n\n    return template.innerHTML;\n  }\n\n  \/\/ Recompute which Description cells need a \"Show more\" toggle.\n  \/\/ A toggle is shown only when the (collapsed) text actually overflows 3 lines.\n  \/\/ Called after table draws\/redraws and on resize because clamp height changes.\n  function refreshDescriptionToggles(scope) {\n    const $root = scope ? jQuery(scope) : jQuery(document);\n\n    $root.find('.desc-wrap').each(function () {\n      const wrap = this;\n      const textEl = wrap.querySelector('.desc-text');\n      const btn = wrap.querySelector('.desc-toggle');\n\n      if (!textEl || !btn) return;\n\n      \/\/ Temporarily collapse to measure overflow accurately.\n      const wasExpanded = wrap.classList.contains('expanded');\n      if (wasExpanded) wrap.classList.remove('expanded');\n\n      const needsToggle = textEl.scrollHeight > textEl.clientHeight + 2;\n\n      if (!needsToggle) {\n        \/\/ Short text: hide the toggle entirely.\n        btn.classList.add('is-hidden');\n        btn.textContent = 'Show more';\n        wrap.classList.remove('expanded');\n      } else {\n        \/\/ Long text: show toggle and restore previous expanded\/collapsed state.\n        btn.classList.remove('is-hidden');\n        btn.textContent = wasExpanded ? 'Show less' : 'Show more';\n        if (wasExpanded) wrap.classList.add('expanded');\n      }\n    });\n  }\n\n  \/\/ The per-column filter <input> elements (second <thead> row).\n  function getFilterInputs($table) {\n    return $table.find('thead tr:nth-child(2) th input');\n  }\n\n  \/\/ Normalize a pathname for comparison (strip trailing slashes).\n  function normalizePath(path) {\n    return String(path || '').replace(\/\\\/+$\/, '') || '\/';\n  }\n\n  \/\/ Read deep-link filter values from a URL: ?university=\u2026 and ?country=\u2026\n  function getUrlFilterValues(urlLike) {\n    const url = urlLike instanceof URL ? urlLike : new URL(urlLike, window.location.href);\n    return {\n      university: (url.searchParams.get('university') || '').trim(),\n      country: (url.searchParams.get('country') || '').trim()\n    };\n  }\n\n  \/\/ True if any column filter currently has text.\n  function hasAnyActiveFilters($table) {\n    let active = false;\n    getFilterInputs($table).each(function () {\n      if (String(this.value || '').trim() !== '') {\n        active = true;\n        return false; \/\/ break out of jQuery .each\n      }\n    });\n    return active;\n  }\n\n  \/\/ Show\/hide the \"Clear filters\" button based on current filter state.\n  function updateClearFiltersButton($table) {\n    const btn = document.getElementById('clear-he-filters');\n    if (!btn) return;\n\n    if (hasAnyActiveFilters($table)) {\n      btn.classList.add('is-visible');\n    } else {\n      btn.classList.remove('is-visible');\n    }\n  }\n\n  \/\/ Smoothly scroll the table into view (used after applying a deep-link filter).\n  function scrollToTable() {\n    const target = document.getElementById('he-centers-table');\n    if (!target) return;\n\n    target.scrollIntoView({\n      behavior: 'smooth',\n      block: 'start'\n    });\n  }\n\n  \/\/ Keep the URL in sync with the \"university\" (col 1) and \"country\" (col 2) filters,\n  \/\/ so the current view can be bookmarked \/ shared \/ navigated back to.\n  function syncUrlWithUniversityCountry($table, historyMode) {\n    const mode = historyMode || 'replace';\n    const $inputs = getFilterInputs($table);\n\n    const university = ($inputs.eq(1).val() || '').trim();\n    const country = ($inputs.eq(2).val() || '').trim();\n\n    const url = new URL(window.location.href);\n\n    if (university) {\n      url.searchParams.set('university', university);\n    } else {\n      url.searchParams.delete('university');\n    }\n\n    if (country) {\n      url.searchParams.set('country', country);\n    } else {\n      url.searchParams.delete('country');\n    }\n\n    \/\/ Anchor to the table so a shared link jumps straight to it.\n    if (university || country) {\n      url.hash = 'he-centers-table';\n    } else {\n      url.hash = '';\n    }\n\n    if (mode === 'push') {\n      window.history.pushState({}, '', url.toString());\n    } else {\n      window.history.replaceState({}, '', url.toString());\n    }\n  }\n\n  \/\/ Reset every column filter and the global search, then refresh URL + button + toggles.\n  function clearAllTableFilters(dt, $table, historyMode) {\n    const $inputs = getFilterInputs($table);\n\n    $inputs.each(function () {\n      this.value = '';\n    });\n\n    dt.search('');\n    dt.columns().search('');\n    dt.draw();\n\n    syncUrlWithUniversityCountry($table, historyMode || 'replace');\n    updateClearFiltersButton($table);\n    refreshDescriptionToggles($table);\n  }\n\n  \/\/ Apply specific university\/country filter values to the table (used by deep links).\n  function applyUniversityCountryFilters(dt, $table, filters, options) {\n    const opts = options || {};\n    const $inputs = getFilterInputs($table);\n\n    const university = String(filters.university || '').trim();\n    const country = String(filters.country || '').trim();\n\n    \/\/ Start from a clean slate.\n    dt.search('');\n    dt.columns().search('');\n\n    $inputs.each(function () {\n      this.value = '';\n    });\n\n    if (university) {\n      $inputs.eq(1).val(university);\n      dt.column(1).search(university);\n    }\n\n    if (country) {\n      $inputs.eq(2).val(country);\n      dt.column(2).search(country);\n    }\n\n    dt.draw();\n\n    if (opts.updateUrl !== false) {\n      syncUrlWithUniversityCountry($table, opts.historyMode || 'replace');\n    }\n\n    updateClearFiltersButton($table);\n    refreshDescriptionToggles($table);\n\n    if (opts.scrollToTable) {\n      setTimeout(scrollToTable, 60);\n    }\n  }\n\n  \/\/ On first load, apply any filters already present in the page URL.\n  function applyFiltersFromCurrentUrl(dt, $table, options) {\n    const filters = getUrlFilterValues(window.location.href);\n\n    if (!filters.university && !filters.country) {\n      updateClearFiltersButton($table);\n      return;\n    }\n\n    applyUniversityCountryFilters(dt, $table, filters, {\n      updateUrl: false, \/\/ URL already reflects these values\n      scrollToTable: !!(options && options.scrollToTable)\n    });\n  }\n\n  \/\/ Intercept clicks on same-page deep links (e.g. marker popups, related links)\n  \/\/ and apply them as table filters instead of doing a full page reload.\n  function installFilterLinksHandler(dt, $table) {\n    if (window.heFilterLinksHandlerInstalled) return; \/\/ install only once\n    window.heFilterLinksHandlerInstalled = true;\n\n    document.addEventListener('click', function (event) {\n      const link = event.target.closest('a[href]');\n      if (!link) return;\n\n      const href = link.getAttribute('href');\n      if (!href || href.startsWith('javascript:')) return;\n\n      let url;\n      try {\n        url = new URL(href, window.location.href);\n      } catch (e) {\n        return;\n      }\n\n      const sameOrigin = url.origin === window.location.origin;\n      const samePath = normalizePath(url.pathname) === normalizePath(window.location.pathname);\n      const filters = getUrlFilterValues(url);\n\n      \/\/ Only hijack links that point to THIS page and carry filter params.\n      if (!sameOrigin || !samePath) return;\n      if (!filters.university && !filters.country) return;\n\n      event.preventDefault();\n      window.history.pushState({}, '', url.toString());\n\n      applyUniversityCountryFilters(dt, $table, filters, {\n        updateUrl: false,\n        scrollToTable: true\n      });\n    });\n\n    \/\/ Support browser back\/forward between filtered states.\n    window.addEventListener('popstate', function () {\n      const filters = getUrlFilterValues(window.location.href);\n\n      if (!filters.university && !filters.country) {\n        clearAllTableFilters(dt, $table, 'replace');\n        return;\n      }\n\n      applyUniversityCountryFilters(dt, $table, filters, {\n        updateUrl: false,\n        scrollToTable: false\n      });\n    });\n  }\n\n  \/\/ Some WordPress themes wrap content in .wp-block-cover; unwrap so layout is clean.\n  function unwrapWpBlockCover() {\n    document.querySelectorAll('.wp-block-cover').forEach(function (cover) {\n      while (cover.firstChild) {\n        cover.parentNode.insertBefore(cover.firstChild, cover);\n      }\n      cover.remove();\n    });\n  }\n\n  \/\/ Optional tab wiring: only activates if both tabs and both panels exist.\n  function initTabsIfPresent() {\n    const centersTab = document.getElementById('centers-tab');\n    const programsTab = document.getElementById('programs-tab');\n    const centersPanel = document.getElementById('centers-panel');\n    const programsPanel = document.getElementById('programs-panel');\n\n    if (!centersTab || !programsTab || !centersPanel || !programsPanel) return;\n\n    centersTab.addEventListener('click', function () {\n      centersPanel.classList.add('active');\n      programsPanel.classList.remove('active');\n      centersTab.classList.add('active');\n      programsTab.classList.remove('active');\n      invalidateMapSizeSoon();\n      initCentersTable();\n    });\n\n    programsTab.addEventListener('click', function () {\n      centersPanel.classList.remove('active');\n      programsPanel.classList.add('active');\n      centersTab.classList.remove('active');\n      programsTab.classList.add('active');\n    });\n  }\n\n  \/\/ Create the Leaflet map once and cache it on window.heCentersMap.\n  function initCentersMap() {\n    if (window.heCentersMap) return window.heCentersMap; \/\/ already created\n\n    const el = document.getElementById('he-centers-map');\n    if (!el || typeof L === 'undefined') return null;\n\n    \/\/ World view, locked to a single world copy (no infinite horizontal wrap).\n    const map = L.map(el, {\n      worldCopyJump: false,\n      maxBounds: [[-85, -180], [85, 180]],\n      maxBoundsViscosity: 1.0\n    }).setView([20, 0], 2);\n\n    L.tileLayer('https:\/\/{s}.tile.openstreetmap.org\/{z}\/{x}\/{y}.png', {\n      attribution: '\u00a9 OpenStreetMap contributors',\n      noWrap: true,\n      bounds: [[-85, -180], [85, 180]]\n    }).addTo(map);\n\n    \/\/ All markers live in a single layer group for easy clearing\/reloading.\n    window.heCentersMarkers = L.layerGroup().addTo(map);\n\n    \/\/ Keep the user inside the allowed bounds while dragging.\n    map.on('drag', function () {\n      map.panInsideBounds(map.options.maxBounds, { animate: false });\n    });\n\n    window.heCentersMap = map;\n    return map;\n  }\n\n  \/\/ Fetch the JSON (universities) and add one marker per university with a popup.\n  function loadCentersMarkers() {\n    const map = initCentersMap();\n    if (!map) return;\n\n    if (window.heCentersMarkers) {\n      window.heCentersMarkers.clearLayers(); \/\/ avoid duplicate markers on reload\n    }\n\n    fetch(JSON_URL + '?v=' + Date.now())\n      .then(function (r) {\n        if (!r.ok) throw new Error(r.statusText);\n        return r.json();\n      })\n      .then(function (data) {\n        if (!Array.isArray(data) || !data.length) return;\n\n        data.forEach(function (u) {\n          const lat = Number(u.lat);\n          const lon = Number(u.lon);\n\n          \/\/ Skip rows without valid coordinates.\n          if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;\n\n          const rawName = String(u.name || 'Unnamed').trim();\n          const safeName = sanitizePopupHtml(rawName);\n          const descr = sanitizePopupHtml(u.description || '');\n\n          \/\/ Bold the title if the source did not already include <b>\/<strong>.\n          const titleHtml = \/<(b|strong)\\b\/i.test(safeName)\n            ? safeName\n            : '<strong>' + safeName + '<\/strong>';\n\n          const popupHtml = titleHtml + (descr ? '<br>' + descr : '');\n\n          L.marker([lat, lon])\n            .bindPopup(popupHtml)\n            .addTo(window.heCentersMarkers);\n        });\n      })\n      .catch(function (err) {\n        console.error('JSON load error:', err);\n      });\n  }\n\n  \/\/ Leaflet needs a size recalculation when its container becomes visible\/resizes.\n  function invalidateMapSizeSoon() {\n    if (!window.heCentersMap) return;\n    setTimeout(function () {\n      window.heCentersMap.invalidateSize();\n    }, 150);\n  }\n\n  \/\/ Build (or refresh) the DataTables table from the CSV file.\n  function initCentersTable() {\n    if (!window.jQuery || !jQuery.fn.dataTable || typeof Papa === 'undefined') return;\n\n    const $table = jQuery('#he-centers-table');\n    if (!$table.length) return;\n\n    \/\/ If already initialized, just re-adjust columns + toggles and exit.\n    if (jQuery.fn.dataTable.isDataTable('#he-centers-table')) {\n      const dtExisting = $table.DataTable();\n      setTimeout(function () {\n        dtExisting.columns.adjust().draw(false);\n        refreshDescriptionToggles($table);\n        updateClearFiltersButton($table);\n      }, 0);\n      return;\n    }\n\n    \/\/ Stream + parse the CSV (header row becomes object keys).\n    Papa.parse(CSV_URL + '?v=' + Date.now(), {\n      download: true,\n      header: true,\n      skipEmptyLines: true,\n      complete: function (results) {\n        const data = Array.isArray(results.data) ? results.data : [];\n        let rowsHtml = '';\n\n        \/\/ Build table rows. Column keys must match the CSV headers exactly.\n        data.forEach(function (row) {\n          const program = row['Program'] || '';\n          \/\/ Skip rows with no program name (e.g. trailing blank lines).\n          if (String(program).trim() === '') return;\n\n          const university = row['University'] || '';\n          const country = row['Country'] || '';\n          const website = row['Website'] || '';\n          const level = row['Level'] || '';\n          const description = row['Description'] || '';\n\n          \/\/ Website cell: visible \"Website\" link + hidden full URL for searching.\n          const safeWebsite = safeUrl(website);\n          const websiteCell = safeWebsite\n            ? '<span class=\"table-search-hidden\">' + escapeHtml(website) + '<\/span><a href=\"' + escapeHtml(safeWebsite) + '\" target=\"_blank\" rel=\"noopener\">Website<\/a>'\n            : '';\n\n          \/\/ Description cell: clamped text + a toggle (shown only if it overflows).\n          const descriptionCell =\n            '<div class=\"desc-wrap\">' +\n              '<div class=\"desc-text\">' + formatTextWithBreaks(description) + '<\/div>' +\n              '<button type=\"button\" class=\"desc-toggle is-hidden\">Show more<\/button>' +\n            '<\/div>';\n\n          rowsHtml +=\n            '<tr>' +\n              '<td>' + escapeHtml(program) + '<\/td>' +\n              '<td>' + escapeHtml(university) + '<\/td>' +\n              '<td>' + escapeHtml(country) + '<\/td>' +\n              '<td>' + websiteCell + '<\/td>' +\n              '<td>' + escapeHtml(level) + '<\/td>' +\n              '<td class=\"desc-cell\">' + descriptionCell + '<\/td>' +\n            '<\/tr>';\n        });\n\n        $table.find('tbody').html(rowsHtml);\n\n        \/\/ Initialize DataTables. orderCellsTop keeps sorting on the title row\n        \/\/ while the second header row is used for filter inputs.\n        const dt = $table.DataTable({\n          autoWidth: false,\n          paging: true,\n          searching: true,\n          ordering: true,\n          info: true,\n          orderCellsTop: true,\n          order: [[2, 'asc'], [0, 'asc']], \/\/ sort by Country, then Program\n          pageLength: 50,\n          lengthMenu: [[10, 25, 50, 100, -1], [10, 25, 50, 100, 'All']]\n        });\n\n        \/\/ Wire each per-column filter input to that column's search.\n        $table.find('thead tr:nth-child(2) th input').each(function (colIndex) {\n          jQuery(this).on('input keyup change', function (e) {\n            e.stopPropagation(); \/\/ don't let typing trigger column sorting\n            dt.column(colIndex).search(this.value).draw();\n            syncUrlWithUniversityCountry($table, 'replace');\n            updateClearFiltersButton($table);\n          });\n        });\n\n        \/\/ Expand\/collapse a single Description cell on toggle click (delegated).\n        $table\n          .off('click.descToggle', '.desc-toggle')\n          .on('click.descToggle', '.desc-toggle', function (e) {\n            e.preventDefault();\n\n            const $btn = jQuery(this);\n            const $wrap = $btn.closest('.desc-wrap');\n\n            $wrap.toggleClass('expanded');\n            $btn.text($wrap.hasClass('expanded') ? 'Show less' : 'Show more');\n          });\n\n        \/\/ \"Clear filters\" button.\n        jQuery('#clear-he-filters')\n          .off('click')\n          .on('click', function () {\n            clearAllTableFilters(dt, $table, 'replace');\n          });\n\n        \/\/ Enable deep-linking + apply any filters from the current URL.\n        installFilterLinksHandler(dt, $table);\n        applyFiltersFromCurrentUrl(dt, $table, { scrollToTable: true });\n\n        \/\/ Initial toggle calculation.\n        refreshDescriptionToggles($table);\n\n        \/\/ After every redraw (paging\/sort\/filter): recompute toggles + button.\n        dt.on('draw', function () {\n          refreshDescriptionToggles($table);\n          updateClearFiltersButton($table);\n        });\n\n        \/\/ Final width adjustment once layout has settled.\n        setTimeout(function () {\n          dt.columns.adjust().draw(false);\n          refreshDescriptionToggles($table);\n          updateClearFiltersButton($table);\n        }, 0);\n      },\n      error: function (err) {\n        console.error('CSV parse error:', err);\n      }\n    });\n  }\n\n  \/\/ Entry point: run everything in order.\n  function boot() {\n    unwrapWpBlockCover();\n    initTabsIfPresent();\n    loadCentersMarkers();\n    initCentersTable();\n    invalidateMapSizeSoon();\n  }\n\n  \/\/ Run boot() after the DOM is ready (or immediately if it already is).\n  if (document.readyState === 'loading') {\n    document.addEventListener('DOMContentLoaded', boot, { once: true });\n  } else {\n    boot();\n  }\n\n  \/\/ On resize, re-adjust table column widths, description toggles, and map size.\n  window.addEventListener('resize', function () {\n    if (!window.jQuery) return;\n\n    if (jQuery.fn.dataTable.isDataTable('#he-centers-table')) {\n      const dt = jQuery('#he-centers-table').DataTable();\n      dt.columns.adjust().draw(false);\n    }\n\n    refreshDescriptionToggles(jQuery('#he-centers-table'));\n    invalidateMapSizeSoon();\n  });\n})();\n<\/script>\n\n","protected":false},"excerpt":{"rendered":"<p>In 2000, the Center for International Higher Education at Boston College, under the leadership of Philip G. Altbach, completed the first global inventory of higher education centers and programs around the world (Altbach &amp; Engberg, 2000). The CIHE inventory was updated in 2006 (Altbach et. al. 2006) and again in 2013 (Rumbley et al, 2014), [&hellip;]<\/p>\n","protected":false},"author":2,"featured_media":0,"parent":0,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"","meta":{"ub_ctt_via":"","footnotes":""},"class_list":["post-640","page","type-page","status-publish","hentry"],"featured_image_src":null,"wps_subtitle":"","_links":{"self":[{"href":"https:\/\/highered-change.org\/index.php\/wp-json\/wp\/v2\/pages\/640","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/highered-change.org\/index.php\/wp-json\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/highered-change.org\/index.php\/wp-json\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/highered-change.org\/index.php\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/highered-change.org\/index.php\/wp-json\/wp\/v2\/comments?post=640"}],"version-history":[{"count":18,"href":"https:\/\/highered-change.org\/index.php\/wp-json\/wp\/v2\/pages\/640\/revisions"}],"predecessor-version":[{"id":700,"href":"https:\/\/highered-change.org\/index.php\/wp-json\/wp\/v2\/pages\/640\/revisions\/700"}],"wp:attachment":[{"href":"https:\/\/highered-change.org\/index.php\/wp-json\/wp\/v2\/media?parent=640"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}