• Blog
  • Docs
  • Pricing
  • We’re hiring!
Log inSign up
jhiller

jhiller

nolaHotspotMap

Helium Mobile Hotspot growth map for New Orleans
Public
Like
nolaHotspotMap
Home
Code
7
backend
4
frontend
6
shared
1
CLAUDE.md
README.md
C
cron.ts
H
index.http.ts
Environment variables
Branches
1
Pull requests
Remixes
History
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.
Sign up now
Code
/
CLAUDE.md
Code
/
CLAUDE.md
Search
1/26/2026
Viewing readonly version of main branch: v20
View latest version
CLAUDE.md

DC Hotspot Growth Map

Project Overview

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.

Deployment

  • Live URL: https://dc-hotspot-growth.val.run/

Tech Stack

  • 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

Key Files

  • index.ts - Main Hono server with API routes
  • backend/db.ts - SQLite schema and queries
  • backend/heliumApi.ts - Helium Entity API client
  • backend/scraper.ts - Backfill and daily scrape logic
  • backend/migrate.ts - Migration script between SQLite versions
  • cron.ts - Daily scraper cron job (6 AM UTC)
  • frontend/components/App.tsx - Main React component with map, timeline, and clustering
  • frontend/utils/animalHash.ts - Animal name generator (angry-purple-tiger algorithm)
  • frontend/style.css - Helium-inspired dark theme styles

Val Town CLI

  • Push changes: ~/.deno/bin/vt push
  • Open in browser: ~/.deno/bin/vt browse
  • Configure cron schedule in Val Town web interface

Design Decisions

deck.gl Visualization

  • Load via CDN: <script src="https://unpkg.com/deck.gl@8.9.33/dist.min.js"></script>
  • Access as global deck object (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

deck.gl Playback Bug Fix (Critical)

  • 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: undefined during 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

Animal Names (animalHash.ts)

  • 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

Timeline/Histogram

  • 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: 0 prevents 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, onTouchEnd handlers mirror mouse events

Playback Performance Optimizations

  • requestAnimationFrame instead of setInterval for smoother animation
  • Index-based state (selectedIdx) instead of date string to avoid linear searches
  • Pre-computed clusters: clustersByDate Map 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: !isPlaying for minor performance gain
  • Transitions and static layer IDs re-enabled when playback stops for smooth manual scrubbing

iOS Safari Viewport Fix

  • Use height: 100dvh (dynamic viewport height) instead of 100vh
  • Add viewport-fit=cover to viewport meta tag
  • Use env(safe-area-inset-bottom) for timeline padding

Visual Design (Helium-inspired)

  • 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: 3px for triangle (reset to 0 for pause icon)

Color Coding (Hotspot Age)

  • Green (#2ecc71): New (< 7 days)
  • Blue (#3498db): Recent (< 30 days)
  • Grey (#a0a0a0): Established (30+ days)

Mobile Responsive (max-width: 768px)

  • Timeline: 10px margins, 12px padding
  • Histogram height: 40px with 6px 10px padding
  • Play button: 36x36px
  • Legend integrated into stats panel (always visible)

API Endpoints

  • GET /api/hotspots - All active hotspots
  • GET /api/snapshots - Daily snapshot stats
  • GET /api/stats - Current count and latest snapshot

Cron Job

  • cron.ts runs 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

Database Schema

  • 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)

SQLite Migration Notes

  • Different versioned imports (@14-main vs 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

DC Metro Bounding Box

const DC_BOUNDS = { minLat: 38.5, maxLat: 39.3, minLong: -77.5, maxLong: -76.7 };
FeaturesVersion controlCode intelligenceCLIMCP
Use cases
TeamsAI agentsSlackGTM
DocsShowcaseTemplatesNewestTrendingAPI examplesNPM packages
PricingNewsletterBlogAboutCareers
We’re hiring!
Brandhi@val.townStatus
X (Twitter)
Discord community
GitHub discussions
YouTube channel
Bluesky
Open Source Pledge
Terms of usePrivacy policyAbuse contact
© 2026 Val Town, Inc.