# Project export: Homing

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2026
- Tagline: Move to a new city like you already live there.
- Devpost: https://devpost.com/software/homeawayfromhome
- GitHub: https://github.com/sprothia/3dmap-calhack
- Demo: https://homingtourcalaihacks.vercel.app/
- Video: https://www.youtube.com/embed/aCmHMGtGuyk?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 4 GitHub contributor(s) — Siddharth Prothia (8 commits), rshirams_ (4 commits), yashy10 (2 commits), vibhab4 (1 commits)

## Devpost submission (written by the team)

### Inspiration

Every one of us has moved to a city where we knew nobody. The first few weeks are the same disorienting blur: Which neighborhood should I live in? Is that commute actually bearable? Where do people like me hang out? Is this area my speed or not? Tourist apps answer "what should I see this weekend" but nobody builds for the person who has to figure out a whole life in a place they've never lived. Google Maps shows you streets. Zillow shows you listings. Niche shows you a wall of statistics. None of them let you feel a city before you arrive. We wanted the thing we all wished we'd had on day one: a local friend who flies you through your new city in 3D, talks you through each neighborhood, and helps you find where you actually belong. That's Homing.

### What it does

Homing turns "I'm moving to a new city" from a spreadsheet of unknowns into a guided, explorable experience. Fly through your new city in 3D. Real photorealistic 3D tiles, not a flat map. Orbit, pan, and zoom over actual buildings and terrain. Take a narrated tour. A camera-on-rails ride glides you stop-to-stop along real transit lines while a voice (powered by Deepgram) narrates each place like a local showing you around. Explore neighborhoods with real context. Click a district to see its character, population, density, median income, dominant industries, and climate — the stuff that actually decides whether you'd live there. Check your commute. Drop in two addresses and compare drive / transit / bike / walk times and distances, with the route drawn on the 3D map. Everything the UI renders comes from a single City data object, so a new city is one data file away.

### How we built it

Frontend: React 19 + TypeScript, Zustand for state, Tailwind for styling, Vite for the build. 3D engine: CesiumJS rendering Google Photorealistic 3D Tiles through the Map Tiles API. Voice: Deepgram text-to-speech streams narration that stays in sync with the camera as it moves between tour stops. Routing: Google Routes API for multimodal commute comparison, with results geocoded and drawn as polylines on the globe. Architecture: a fully data-driven city model — places, tours, districts, and quests are pure data, so the picker, map, explore mode, and tour mode, all light up automatically for any new city. The guided tour is the technical heart. Instead of teleporting the camera between stops, we build a smoothed spline through the route's control points and interpolate the camera along it. For control points $Not disclosed$ and a local parameter $t \in [0,1]$, we evaluate a Catmull–Rom segment: Not disclosed $$ which gives the camera a continuous, train-like glide instead of robotic jumps. Commute distances use the haversine formula over the WGS-84 sphere: Not disclosed

### Challenges we ran into

Syncing voice to motion. Getting Deepgram narration to start, pace, and finish in lockstep with a moving camera — without audio cutting off mid-flight or lagging behind the visuals — took real tuning of the playback and tour-advance lifecycle. Taming the camera. Photorealistic 3D tiles look incredible but make camera control unforgiving: pitch, heading, and height all interact, and a naive path through waypoints felt nauseating. The spline interpolation above was our fix. 3D tile performance. Streaming gigabytes of 3D tiles while keeping the UI responsive meant being careful about when we fly, preload, and render overlays. Graceful degradation. The app depends on third-party keys (Cesium Ion, Google Maps). Rather than show a broken globe, we built a friendly "add your keys" state so the experience never hard-crashes. Designing for relocators, not tourists. Our hardest product challenge was resisting the tourist framing. Every feature had to answer "does this help someone decide where to live?" which is why neighborhoods carry demographics and commute math, not just pretty photos.

### Accomplishments we're proud of

A genuinely cinematic 3D city tour with synchronized AI voice narration that feels like a friend, not a robot. A data-driven architecture where adding an entire new city is one file no UI changes. Real, decision-grade neighborhood context (income, density, industries, climate) fused with live multimodal commute routing on the same 3D map. We shipped all of it as a small team in a hackathon timebox.

### What we learned

CesiumJS + Google 3D Tiles from the ground up, camera math, splines, and the art of making 3D navigation feel good rather than dizzying. Streaming TTS is a UX problem, not just an API call - timing and lifecycle matter more than the audio itself. Constraints make products. Narrowing from "explore any city" to "help someone relocate" sharpened every design decision. Data modeling is leverage - investing early in a clean City schema paid for itself the moment we wanted to scale beyond one city.

### What's next

More cities, fast - our data-driven model means national coverage is a content problem, not an engineering one. A "neighborhood fit" engine - tell us your budget, commute, and lifestyle, and we recommend where to live, then learn from whether the fit was right. That outcome data is something no map or listings site has, and it's the moat we're building toward. End-to-end relocation workflow - from "where should I live" through housing, commute, and a settle-in checklist, so Homing becomes the system of record for an entire move. Voice-first concierge - ask "where should someone like me live in this city?" out loud and take the tour that answers it. Embedded distribution - partnering with employers, universities, and relocation services to reach people at the exact moment they're moving.

## README (from the GitHub repository)

# City Explorer

A simple, 3D, gamified way for someone new to a city to get the lay of the land.
Pick a city, then either **ride a guided tour** (camera on rails) or **explore in 3D**
(free-roam map, click places to learn about them). v1 supports the **Bay Area**.

Built on [CesiumJS](https://cesium.com/) with Google Photorealistic 3D Tiles, the
feeling we're after is *a local friend showing a newcomer around.*

## Run it

```bash
npm install
cp .env.example .env   # then fill in your two keys (see below)
npm run dev            # http://localhost:5173
```

### API keys (both have free tiers)

`.env` needs:

- `VITE_CESIUM_ION_TOKEN` — from https://ion.cesium.com/tokens
- `VITE_GOOGLE_MAPS_KEY` — a Google Maps Platform key with the **Map Tiles API** enabled

Without keys the app shows a friendly "add your keys" panel instead of a broken map.

## How it works

- **Pick a city** → **pick a mode** → the 3D map.
- **Explore:** orbit/pan/zoom; click a pin to fly there and read a blurb.
- **Tour:** camera glides stop-to-stop with narration; advance via buttons,
  scroll, or arrow keys.
- **Passport** (top-right): collects a stamp per discovered place, tracks
  "X of N discovered", and awards badges for quests. Persists in localStorage.

## Adding another city

Everything the UI renders comes from a `City` data object — no city is hardcoded.
To add one:

1. Create `src/data/cities/<city>.ts` exporting a `City` (places, tours, quests,
   an `intro` camera view). Use `src/data/cities/bay-area.ts` as the template.
2. Add it to the registry in `src/data/cities/index.ts`.

That's it — the picker, map, explore, tour, and passport all pick it up.

## Project structure

```
src/
  data/         types.ts + cities/ (all content lives here)
  state/        zustand stores: app screen, passport, quests
  cesium/       viewer hook, camera helpers, pins
  screens/      CityPicker, ModePicker, MapView, ExploreMode, TourMode
  components/   PlacePanel, Passport, BadgeToast, Fog, MissingKeys
```

## Notes

- Place coordinates are approximate and camera framings use sensible defaults —
  tune per place in `bay-area.ts` as desired.
- Restrict your Google Maps key (HTTP referrers + Map Tiles API only) before
  deploying anywhere public.


## Detected evidence (automated analysis)

Indexed codebase: 61 recognized source files, 287 KB.
- Anthropic (technology) — detected in the code
- CSS (language) — detected in the code
- Express (technology) — detected in the code
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code
- React (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- Node.js (technology) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (69 of 69)

```
.env.example
.gitignore
.nvmrc
eslint.config.js
index.html
package.json
postcss.config.js
README.md
render.yaml
server/browserbase.js
server/cache.js
server/claude.js
server/index.js
server/scrape.js
src/App.tsx
src/cesium/atmosphere.ts
src/cesium/camera.ts
src/cesium/districts.ts
src/cesium/env.ts
src/cesium/pins.ts
src/cesium/rideCamera.ts
src/cesium/route.ts
src/cesium/routeLine.ts
src/cesium/routesApi.ts
src/cesium/skyLife.ts
src/cesium/useViewer.ts
src/components/AreaInfoSidebar.tsx
src/components/CategorySidebar.tsx
src/components/CommutePanel.tsx
src/components/DistrictCard.tsx
src/components/ErrorBoundary.tsx
src/components/Fog.tsx
src/components/ImageGallery.tsx
src/components/landing/HeroMapField.tsx
src/components/landing/initHeroMapField.ts
src/components/landing/LegendGrid.tsx
src/components/MapAtmosphere.tsx
src/components/MissingKeys.tsx
src/components/NeighborhoodAsk.tsx
src/components/PlacePanel.tsx
src/components/ScreenTransition.tsx
src/components/TransitionCurtain.tsx
src/components/ZoomControls.tsx
src/data/categories.ts
src/data/cities/bay-area.ts
src/data/cities/index.ts
src/data/placeImages.ts
src/data/types.ts
src/data/worldCities.ts
src/index.css
src/lib/api.ts
src/lib/renderMarkdown.tsx
src/main.tsx
src/narration/deepgramTTS.ts
src/screens/CityPicker.tsx
src/screens/ExploreMode.tsx
src/screens/landing.css
src/screens/MapView.tsx
src/screens/ModePicker.tsx
src/screens/TourMode.tsx
src/state/useAppStore.ts
src/state/useNarration.ts
src/state/useScene.ts
src/three/baygrid.json
tailwind.config.js
tsconfig.app.json
tsconfig.json
tsconfig.node.json
vite.config.ts
```

### Dependencies

- package.json: @anthropic-ai/sdk@^0.69.0, @browserbasehq/sdk@^2.6.1, @eslint/js@^10.0.1, @types/node@^24.12.3, @types/react@^19.2.14, @types/react-dom@^19.2.3, @types/three@^0.184.1, @vitejs/plugin-react@^6.0.1, autoprefixer@^10.5.0, cesium@^1.142.0, cors@^2.8.5, eslint@^10.3.0, eslint-plugin-react-hooks@^7.1.1, eslint-plugin-react-refresh@^0.5.2, express@^4.21.2, globals@^17.6.0, playwright-core@^1.49.1, postcss@^8.5.15, react@^19.2.6, react-dom@^19.2.6, tailwindcss@^3.4.19, three@^0.184.0, tslib@^2.8.1, typescript@~6.0.2, typescript-eslint@^8.59.2, vite@^8.0.12, vite-plugin-cesium@^1.2.23, zustand@^5.0.14

### Recent commits (newest first)

- added health check
- render yaml file
- Merge pull request #4 from sprothia/browserbase
- added browserbase
- Merge pull request #3 from sprothia/deepgram-integration
- Merge remote-tracking branch 'origin/main' into deepgram-integration
- WIP: deepgram integration
- Merge pull request #2 from sprothia/rishibranch
- more features for maps and other improvements
- Merge pull request #1 from sprothia/rishibranch
- Merge remote-tracking branch 'origin/main'
- landing page in progress
- added features for map
- extended capability for tour
- added more features for tour
- initial commit

## Key source files (fetched from GitHub, selected and truncated for size)

### package.json

```
{
  "name": "3dtour_aihackathon",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "lint": "eslint .",
    "preview": "vite preview",
    "server": "node --env-file=.env server/index.js"
  },
  "dependencies": {
    "@anthropic-ai/sdk": "^0.69.0",
    "@browserbasehq/sdk": "^2.6.1",
    "@types/three": "^0.184.1",
    "cesium": "^1.142.0",
    "cors": "^2.8.5",
    "express": "^4.21.2",
    "playwright-core": "^1.49.1",
    "react": "^19.2.6",
    "react-dom": "^19.2.6",
    "three": "^0.184.0",
    "tslib": "^2.8.1",
    "vite-plugin-cesium": "^1.2.23",
    "zustand": "^5.0.14"
  },
  "devDependencies": {
    "@eslint/js": "^10.0.1",
    "@types/node": "^24.12.3",
    "@types/react": "^19.2.14",
    "@types/react-dom": "^19.2.3",
    "@vitejs/plugin-react": "^6.0.1",
    "autoprefixer": "^10.5.0",
    "eslint": "^10.3.0",
    "eslint-plugin-react-hooks": "^7.1.1",
    "eslint-plugin-react-refresh": "^0.5.2",
    "globals": "^17.6.0",
    "postcss": "^8.5.15",
    "tailwindcss": "^3.4.19",
    "typescript": "~6.0.2",
    "typescript-eslint": "^8.59.2",
    "vite": "^8.0.12"
  }
}

```

### src/main.tsx

```typescript
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import ErrorBoundary from './components/ErrorBoundary.tsx'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <ErrorBoundary>
      <App />
    </ErrorBoundary>
  </StrictMode>,
)

```

### server/index.js

```javascript
import express from 'express'
import cors from 'cors'
import { getCached, setCached } from './cache.js'
import { scrapeAll } from './scrape.js'
import { synthesizeAnswer } from './claude.js'

const app = express()
app.use(cors())
app.use(express.json())

app.get('/', (_req, res) => res.json({ ok: true, service: '3dtour-backend'}))

app.post('/ask/:neighborhoodId', async (req, res) => {
  const neighborhoodName = decodeURIComponent(req.params.neighborhoodId)
  const question = req.body?.question
  if (typeof question !== 'string' || !question.trim()) {
    return res.status(400).json({ error: 'question is required' })
  }

  const cacheKey = `${neighborhoodName}::${question.trim().toLowerCase()}`
  const cached = getCached(cacheKey)
  if (cached) {
    return res.json({ ...cached, fromCache: true })
  }

  try {
    const sources = await scrapeAll(neighborhoodName, question)
    const result = await synthesizeAnswer(neighborhoodName, question, sources)
    if (result.sources.length > 0) {
      setCached(cacheKey, result)
    }
    res.json({ ...result, fromCache: false })
  } catch (err) {
    console.error('Failed to answer neighborhood question:', err)
    res.status(502).json({ error: 'Could not fetch live information right now. Please try again.' })
  }
})

const port = process.env.PORT ?? 8787
app.listen(port, () => {
  console.log(`Neighborhood Q&A server listening on :${port}`)
})

```

### src/App.tsx

```typescript
import { useAppStore } from './state/useAppStore'
import { getCity } from './data/cities'
import CityPicker from './screens/CityPicker'
import ModePicker from './screens/ModePicker'
import MapView from './screens/MapView'
import ExploreMode from './screens/ExploreMode'
import TourMode from './screens/TourMode'
import ScreenTransition from './components/ScreenTransition'
import TransitionCurtain from './components/TransitionCurtain'

function App() {
  const screen = useAppStore((s) => s.screen)
  const cityId = useAppStore((s) => s.cityId)
  const city = cityId ? getCity(cityId) : undefined

  const onMap = (screen === 'explore' || screen === 'tour') && city

  return (
    // Map needs a definite full-viewport height for the Cesium canvas;
    // the picker screens scroll within min-height.
    <div className={onMap ? 'h-screen w-screen overflow-hidden' : 'min-h-screen'}>
      {/* Picker screens transition with a curtain sweep. The map is kept out of
          the keyed wrapper so the Cesium viewer is never remounted. */}
      {!onMap && (
        <ScreenTransition screenKey={screen}>
          {screen === 'pick-city' && <CityPicker />}
          {screen === 'pick-mode' && <ModePicker />}
        </ScreenTransition>
      )}

      {/* Both modes share the same map; overlays differ per mode. */}
      {onMap && (
        <div style={{ animation: 'screenIn 0.6s ease both' }} className="h-full w-full">
          <MapView city={city}>
            {(viewer) =>
              screen === 'explore' ? (
                <ExploreMode city={city} viewer={viewer} />
              ) : (
                <TourMode city={city} viewer={viewer} />
              )
            }
          </MapView>
        </div>
      )}

      {/* Global transition curtain — covers every screen change */}
      <TransitionCurtain />
    </div>
  )
}

export default App

```

### src/data/cities/index.ts

```typescript
import type { City } from '../types'
import { bayArea } from './bay-area'

// A "coming soon" city needs only an id, name, tagline, and status.
// When one becomes real, import its full City object here instead.
function comingSoon(id: string, name: string, tagline: string): City {
  return {
    id,
    name,
    tagline,
    status: 'coming-soon',
    intro: { lat: 0, lng: 0, height: 0, heading: 0, pitch: 0 },
    places: [],
    tours: [],
  }
}

export const cities: City[] = [
  bayArea,
  comingSoon('los-angeles', 'Los Angeles', 'Sun, sprawl, and the Pacific'),
  comingSoon('new-york', 'New York', 'Five boroughs, one island'),
  comingSoon('seattle', 'Seattle', 'Mountains, water, and coffee'),
]

export function getCity(id: string): City | undefined {
  return cities.find((c) => c.id === id)
}

```

### postcss.config.js

```javascript
export default {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
}

```

### vite.config.ts

```typescript
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import cesium from 'vite-plugin-cesium'

// https://vite.dev/config/
export default defineConfig({
  plugins: [react(), cesium()],
  server: {
    // Bind to IPv4 loopback so http://localhost works regardless of how the
    // browser resolves "localhost" (some resolve to IPv6 [::1] only).
    host: '127.0.0.1',
    port: 5173,
  },
})

```

### eslint.config.js

```javascript
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'

export default defineConfig([
  globalIgnores(['dist']),
  {
    files: ['**/*.{ts,tsx}'],
    extends: [
      js.configs.recommended,
      tseslint.configs.recommended,
      reactHooks.configs.flat.recommended,
      reactRefresh.configs.vite,
    ],
    languageOptions: {
      globals: globals.browser,
    },
  },
])

```

### render.yaml

```yaml
# Render Blueprint for the Neighborhood Q&A backend (server/index.js).
# In Render: New + > Blueprint, point at this repo, then set the secret
# env vars (marked sync:false) in the dashboard.
services:
  - type: web
    name: 3dtour-backend
    runtime: node
    plan: free
    buildCommand: npm install
    startCommand: node server/index.js
    healthCheckPath: /
    envVars:
      - key: NODE_VERSION
        value: "22"
      # Render injects PORT automatically; server/index.js already reads it.
      - key: ANTHROPIC_API_KEY
        sync: false
      - key: BROWSERBASE_API_KEY
        sync: false
      - key: BROWSERBASE_PROJECT_ID
        sync: false

```

### tailwind.config.js

```javascript
/** @type {import('tailwindcss').Config} */
export default {
  content: ['./index.html', './src/**/*.{ts,tsx}'],
  theme: {
    extend: {
      colors: {
        // Warm travel-journal palette
        cream: '#FBF3E4',
        parchment: '#F3E7CE',
        ink: '#3A2E25',
        cocoa: '#6B4E3D',
        sunset: '#E8743B',
        sky: '#5B8DB8',
        sage: '#7A9471',
        gold: '#D9A441',
      },
      fontFamily: {
        display: ['"Georgia"', 'serif'],
        body: ['"Inter"', 'system-ui', 'sans-serif'],
        grotesk: ['"Space Grotesk"', 'sans-serif'],
        mono: ['"Space Mono"', 'ui-monospace', 'monospace'],
      },
    },
  },
  plugins: [],
}

```

[50 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]