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/[email protected]/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:
stateApprovalsturns your dashboard data into a lookup like{ CA: 12345 }.states.featuresgives globe.gl the actual state shapes.polygonAltitudemakes states with more approvals rise higher.polygonCapColormakes states with more approvals brighter.onPolygonHoverhighlights the state and updates the caption.pointOfViewstarts 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:
- Go through the quiz to reach the dashboard.
- Confirm the normal bar charts still show.
- Confirm the 3D globe appears below the charts.
- Drag the globe to rotate it.
- Hover over a state and confirm the caption updates.
- Change filters and reload the dashboard. The globe should redraw using the new
by_statedata.
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.