Public
LikenolaHotspotMap
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.
Viewing readonly version of main branch: v20View latest version
A Val Town app that visualizes Helium Mobile hotspot growth in the DC Metro area over time, featuring an interactive deck.gl map with H3-based clustering (rendered as scaled circles) and timeline scrubbing.
- Live URL: https://dc-hotspot-growth.val.run/
- Platform: Val Town (Deno runtime)
- Backend: Hono HTTP framework
- Database: Val Town SQLite (Turso) -
import sqlite from "https://esm.town/v/std/sqlite@14-main/main.ts" - Frontend: React + deck.gl (WebGL)
- Clustering: Uber H3 (resolution 8) for grouping
- Map Tiles: CartoDB dark-matter (via TileLayer)
- Styling: Helium-inspired dark theme
index.ts- Main Hono server with API routesbackend/db.ts- SQLite schema and queriesbackend/heliumApi.ts- Helium Entity API clientbackend/scraper.ts- Backfill and daily scrape logicbackend/migrate.ts- Migration script between SQLite versionscron.ts- Daily scraper cron job (6 AM UTC)frontend/components/App.tsx- Main React component with map, timeline, and clusteringfrontend/utils/animalHash.ts- Animal name generator (angry-purple-tiger algorithm)frontend/style.css- Helium-inspired dark theme styles
- Push changes:
~/.deno/bin/vt push - Open in browser:
~/.deno/bin/vt browse - Configure cron schedule in Val Town web interface
- Load via CDN:
<script src="https://unpkg.com/deck.gl@8.9.33/dist.min.js"></script> - Access as global
deckobject (esm.sh imports have luma.gl compatibility issues) - Using ScatterplotLayer with
radiusUnits: "pixels",stroked: false - Circles scaled by count using sqrt easing curve:
radius = 8 + 27 * (sqrt(count-1) / sqrt(69)) - Min radius: 8px (count=1), Max radius: 35px (count=70)
- Easing makes 1→2 dramatic but 67→70 nearly imperceptible
- Transitions enabled only when NOT playing:
transitions: { getRadius: { duration: 150 }, getFillColor: { duration: 150 } } - TextLayer for count labels:
fontFamily: "Figtree, sans-serif",fontWeight: 300 - TileLayer for CartoDB dark basemap:
https://basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png
- Problem: During timeline playback, circles would disappear while text labels remained visible
- Root cause: deck.gl's transition interpolation system conflicts with rapid data updates - when clusters appear/disappear, the interpolation can produce invalid intermediate states (e.g., radius interpolating toward 0)
- Solution: Use dynamic layer IDs during playback to force complete layer recreation:
id: isPlaying ? `hotspot-clusters-${selectedIdx}` : "hotspot-clusters"
- This bypasses deck.gl's transition system entirely during playback by making each frame a "new" layer
- Also set
transitions: undefinedduring playback (not{ duration: 0 }- that still triggers interpolation) - Disable picking during playback for performance:
pickable: !isPlaying - When playback stops, layers revert to static IDs and smooth transitions are re-enabled
- Uses exact angry-purple-tiger algorithm with blueimp-md5
- Critical: ADJECTIVES array must include duplicate "skinny" at index 85 (after "shallow")
- Display names in Title Case (e.g., "Angry Purple Tiger"), not kebab-case
- Interactive scrubber: Click or drag anywhere on histogram (no separate slider)
- Histogram bars:
flex: 1 1 0,min-width: 0(allows fractional pixel widths) - Critical:
min-width: 0prevents overflow on narrow viewports with many bars - Histogram height: 50px desktop, 40px mobile
- Histogram capped at 10 new hotspots to prevent outliers from flattening
- When at end of timeline and user presses play, reset to beginning
- Playhead positioning: Center on each bar using
calc(12px + (100% - 24px) * ${(currentIdx + 0.5) / snapshots.length}) - Playhead has
transform: translateX(-50%)to center the 2px line on calculated position - Touch support:
onTouchStart,onTouchMove,onTouchEndhandlers mirror mouse events
- requestAnimationFrame instead of setInterval for smoother animation
- Index-based state (
selectedIdx) instead of date string to avoid linear searches - Pre-computed clusters:
clustersByDateMap computed once on data load for O(1) lookup - 33ms frame interval (~30fps) balances smoothness with performance
- Dynamic layer IDs during playback: Forces complete layer recreation to avoid transition bugs
- Disabled transitions during playback: Set
transitions: undefined(not duration: 0) - Disabled picking during playback:
pickable: !isPlayingfor minor performance gain - Transitions and static layer IDs re-enabled when playback stops for smooth manual scrubbing
- Use
height: 100dvh(dynamic viewport height) instead of100vh - Add
viewport-fit=coverto viewport meta tag - Use
env(safe-area-inset-bottom)for timeline padding
- Dark theme with
--bg-primary: #04081b - Accent colors: cyan (#4FC3F7), green (#00d97d), purple (#5e25fd)
- Avoid bold fonts - use font-weight 400-500
- Circle fill opacity: 0.5 for established (grey), 0.7 for new/recent
- Glass pane effect:
background: var(--bg-card),backdrop-filter: blur(15px),border: 1px solid var(--border-color),border-radius: 12px - Two floating panels only: stats panel (top-left with integrated legend) and timeline (bottom)
- Play button: white background, optical alignment with
padding-left: 3pxfor triangle (reset to 0 for pause icon)
- Green (#2ecc71): New (< 7 days)
- Blue (#3498db): Recent (< 30 days)
- Grey (#a0a0a0): Established (30+ days)
- Timeline: 10px margins, 12px padding
- Histogram height: 40px with 6px 10px padding
- Play button: 36x36px
- Legend integrated into stats panel (always visible)
GET /api/hotspots- All active hotspotsGET /api/snapshots- Daily snapshot statsGET /api/stats- Current count and latest snapshot
cron.tsruns daily at 6 AM UTC via Val Town's cron scheduler- Fetches new hotspots from Helium Entity API
- Updates daily_snapshots table with new counts
- hotspots - Main hotspot records (entity_key_str, lat/long, created_at, first_seen_at, is_active)
- daily_snapshots - Pre-computed daily stats (snapshot_date, total_count, new_count)
- hotspot_locations - Location change history (tracks when hotspots move >11 meters between daily scrapes)
- Different versioned imports (
@14-mainvs unversioned) use isolated database instances - Always use consistent import path across all files
- Migration script (
backend/migrate.ts) copies data between database instances - Test thoroughly after any import path changes
const DC_BOUNDS = {
minLat: 38.5, maxLat: 39.3,
minLong: -77.5, maxLong: -76.7
};