Thu H — Add a 3D Globe to OPT Pal

Project: OPT Pal / H-1B Employer Data Hub
Repo: final-project-huynth4
Goal: Make the dashboard feel demo-ready by turning approvals-by-state data into an interactive 3D map.

Your dashboard already has the right data for this. app.py passes by_state=approvals_by_state(filters) into dashboard.html, and dashboard.html already embeds that list as JSON for the flat "Approvals by state" chart. This guide reuses that same data to draw raised state shapes on a 3D globe.

We will use globe.gl, a browser library for 3D globe visualizations. You do not need to change Python, SQL, or the database for the main version.

Before You Start

Pull your latest code and make sure the dashboard still works:

uv sync
uv run python import_data_into_SQL.py
uv run flask --app app run --debug

Open the preview, go through the quiz, and confirm you can reach the dashboard with the two existing bar charts.

Why a Globe Fits This Project

OPT Pal helps students explore where H-1B sponsoring employers are concentrated. A 3D map makes that idea visible immediately: states with more approvals rise higher and glow brighter. It also gives you a strong Demo Day moment because you can change filters, reload the dashboard, and watch the map represent a different slice of the data.

Keep the bar chart too. The globe is the "wow" visual; the chart is the readable backup.

Phase 1 — Understand the Data You Already Have

In templates/dashboard.html, you already have this block:

<script id="state-data" type="application/json">
  {{ by_state|tojson }}
</script>

And later:

const byState = JSON.parse(document.getElementById("state-data").textContent)

That gives JavaScript a list shaped like this:

;[
  { state: "CA", total_approvals: 12345 },
  { state: "TX", total_approvals: 9876 },
]

The globe needs map shapes too. We will load a public US-states GeoJSON file in the browser. GeoJSON is just JSON that describes map boundaries. In this file, each state feature has an id like "CA" or "TX", which matches your state column.

Phase 2 — Add a Globe Panel to dashboard.html

Open templates/dashboard.html.

Find the two existing chart cards:

<div class="chart-card">
  <canvas id="top-employers-chart" height="140"></canvas>
</div>
<div class="chart-card">
  <canvas id="by-state-chart" height="140"></canvas>
</div>

Add this right after them:

<div class="globe-card">
  <div class="globe-header">
    <div>
      <h2>Approvals by state — 3D view</h2>
      <p class="meta">Taller, brighter states have more total approvals.</p>
    </div>
    <p id="globe-caption" class="meta">Hover a state to see its approvals.</p>
  </div>
  <div id="globe"></div>
</div>

Do not remove the existing charts yet. The globe needs WebGL, so the bar chart is a useful fallback if someone's browser has trouble.

Phase 3 — Load globe.gl and Draw the States

Scroll near the bottom of dashboard.html. You already have:

<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script>

Add two more script tags between the Chart.js line and your existing <script> tag:

<script src="https://unpkg.com/globe.gl"></script>
<script src="https://unpkg.com/d3-fetch"></script>

Then scroll to the bottom of the existing script, after the second new Chart(...) block. Add this before </script>:

const stateApprovals = Object.fromEntries(
  byState.map((r) => [r.state, r.total_approvals]),
)

const stateName = (feature) => feature.properties?.name || feature.id
const approvalsFor = (feature) => stateApprovals[feature.id] || 0
const formatNumber = (num) => new Intl.NumberFormat().format(num)

d3.json(
  "https://raw.githubusercontent.com/PublicaMundi/MappingAPI/master/data/geojson/us-states.json",
).then((states) => {
  const maxApprovals = Math.max(...Object.values(stateApprovals), 1)
  const scale = (feature) => approvalsFor(feature) / maxApprovals
  let hoveredState = null

  const globe = new Globe(document.getElementById("globe"))
    .globeImageUrl("//unpkg.com/three-globe/example/img/earth-blue-marble.jpg")
    .backgroundColor("rgba(0,0,0,0)")
    .atmosphereColor("#7dd3fc")
    .atmosphereAltitude(0.18)
    .polygonsData(states.features)
    .polygonAltitude((feature) => {
      const base = 0.015 + 0.22 * scale(feature)
      return feature === hoveredState ? base + 0.06 : base
    })
    .polygonCapColor((feature) => {
      const opacity = 0.25 + 0.65 * scale(feature)
      return feature === hoveredState
        ? "rgba(251, 191, 36, 0.95)"
        : `rgba(15, 118, 110, ${opacity})`
    })
    .polygonSideColor(() => "rgba(15, 118, 110, 0.2)")
    .polygonStrokeColor(() => "#0f3d2e")
    .onPolygonHover((feature) => {
      hoveredState = feature
      globe
        .polygonAltitude(globe.polygonAltitude())
        .polygonCapColor(globe.polygonCapColor())

      const caption = document.getElementById("globe-caption")
      if (!feature) {
        caption.textContent = "Hover a state to see its approvals."
        return
      }

      caption.textContent = `${stateName(feature)}: ${formatNumber(
        approvalsFor(feature),
      )} total approvals`
    })

  globe.pointOfView({ lat: 38, lng: -97, altitude: 1.6 }, 0)
})

What this does:

  • stateApprovals turns your dashboard data into a lookup like { CA: 12345 }.
  • states.features gives globe.gl the actual state shapes.
  • polygonAltitude makes states with more approvals rise higher.
  • polygonCapColor makes states with more approvals brighter.
  • onPolygonHover highlights the state and updates the caption.
  • pointOfView starts the globe centered on the United States.

Phase 4 — Style the Globe

Open static/style.css.

Add this near your other card/panel styles:

.globe-card {
  margin-top: 1.5rem;
  padding: 1.25rem;
  border-radius: 1rem;
  background:
    radial-gradient(
      circle at top left,
      rgba(20, 184, 166, 0.28),
      transparent 36%
    ),
    linear-gradient(135deg, #020617, #0f172a);
  color: #f8fafc;
  box-shadow: 0 18px 40px rgba(15, 23, 42, 0.22);
}

.globe-header {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  align-items: flex-start;
  margin-bottom: 0.75rem;
}

.globe-card h2 {
  margin: 0 0 0.25rem;
  color: #f8fafc;
}

.globe-card .meta {
  color: #cbd5e1;
}

#globe {
  min-height: 520px;
}

#globe canvas {
  border-radius: 0.75rem;
}

If the globe feels too tall on your laptop, change 520px to 460px.

Phase 5 — Test It

Restart the Flask server if needed:

uv run flask --app app run --debug

Then test:

  1. Go through the quiz to reach the dashboard.
  2. Confirm the normal bar charts still show.
  3. Confirm the 3D globe appears below the charts.
  4. Drag the globe to rotate it.
  5. Hover over a state and confirm the caption updates.
  6. Change filters and reload the dashboard. The globe should redraw using the new by_state data.

If the globe does not load, open the browser developer console and look for errors.

Phase 6 (Stretch) — Add Arcs from Furman to Top Employer States

This part is optional, but it looks cool. The idea: draw animated arcs from Furman/Greenville to the states where your top employers are located.

Your top data currently includes employer names and approval counts, but not state coordinates. The simple version is to use a small lookup table of state center points.

Add this inside the same d3.json(...).then((states) => { ... }) block, after globe.pointOfView(...):

const GREENVILLE = { lat: 34.8526, lng: -82.394 }

const STATE_CENTERS = {
  CA: { lat: 36.7783, lng: -119.4179 },
  TX: { lat: 31.9686, lng: -99.9018 },
  NY: { lat: 43.2994, lng: -74.2179 },
  NJ: { lat: 40.0583, lng: -74.4057 },
  WA: { lat: 47.7511, lng: -120.7401 },
  IL: { lat: 40.6331, lng: -89.3985 },
  MA: { lat: 42.4072, lng: -71.3824 },
  GA: { lat: 32.1656, lng: -82.9001 },
  NC: { lat: 35.7596, lng: -79.0193 },
  SC: { lat: 33.8361, lng: -81.1637 },
}

const arcs = byState
  .filter((row) => STATE_CENTERS[row.state])
  .slice(0, 10)
  .map((row) => ({
    startLat: GREENVILLE.lat,
    startLng: GREENVILLE.lng,
    endLat: STATE_CENTERS[row.state].lat,
    endLng: STATE_CENTERS[row.state].lng,
    state: row.state,
    approvals: row.total_approvals,
  }))

globe
  .arcsData(arcs)
  .arcColor(() => ["rgba(251, 191, 36, 0.25)", "rgba(251, 191, 36, 0.95)"])
  .arcAltitude(0.22)
  .arcStroke(0.8)
  .arcDashLength(0.45)
  .arcDashGap(1.8)
  .arcDashAnimateTime(2400)

This uses state-level arcs, not exact city locations. That is good enough for a visual demo, and you can explain it honestly: "The arcs show where approvals are concentrated by state, starting from Furman."

Demo Day Notes

Keep the original bar chart visible. It gives you a clear backup if WebGL is blocked or the Wi-Fi is slow.

Suggested demo line:

"The globe uses the same approvals-by-state data as the chart, but turns it into a geographic view. Taller and brighter states have more H-1B approvals for the current filters."

Troubleshooting

The globe area is blank

Open the browser console. If you see a WebGL error, the browser or device may not support the 3D renderer. Keep the bar chart as the fallback.

The map never loads

Check the Network tab for the US-states GeoJSON URL. If it fails, try refreshing once. If it still fails, copy the GeoJSON file into static/us-states.json later and load it from your own app.

Every state has the same height

Check the shape of byState in the console:

console.log(byState)

Make sure each row has state and total_approvals. Your current search.py returns those names.

A state shows 0 approvals even though the chart has data

That usually means the map state ID does not match your data. This guide uses a GeoJSON file where feature.id is the two-letter state abbreviation, matching your database's state column.