> Source URL: /unit-3/project-paths/thu-h/thu-h-2026-04-28-3d-globe.guide
# 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](https://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:

```bash
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:

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

And later:

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

That gives JavaScript a list shaped like this:

```javascript
;[
  { 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:

```html
<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:

```html
<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:

```html
<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:

```html
<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>`:

```javascript
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:

```css
.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:

```bash
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(...)`:

```javascript
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:

```javascript
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.

---

## Related Resources

- [Render Deploy Guide](thu-h-2026-04-28-render-deploy.guide.md)
- [globe.gl documentation](https://globe.gl/)
- [Checkpoint 3 Instructions](../../projects/final-project-checkpoint-3.project.md)


---

## Backlinks

The following sources link to this document:

- [April 28 — 3D globe dashboard](/unit-3/project-paths/projects.path.llm.md)
- [April 28 -- 3D globe dashboard](/unit-3/project-paths/thu-h/thu-h.path.llm.md)
- [April 28 - 3D globe dashboard](/unit-3/projects/showcase/thu-h.project.llm.md)
