Vision is the user-facing frontend for the wBus project — a real-time bus tracking app for Wŏnju (원주), South Korea. It renders live bus positions on an interactive map, animates their movement along route polylines, and displays arrival predictions and timetables.
The project follows Feature-Sliced Design (FSD) and is organized into five layers, each with a clear responsibility and strict import rules.
src/
├── app/ # Next.js App Router — pages, layouts, API routes, global styles
│ └── api/ # Server-side API routes (bus locations, arrivals, stops)
├── entities/ # Domain models and data access for each entity
│ ├── bus/ # BusItem, BusStopArrival — types & helpers
│ ├── route/ # RouteInfo, BusRouteFeature, BusSchedule — polyline service
│ └── station/ # BusStop, StationLocation — station map access
├── features/ # Use-case-specific logic (hooks, derived state)
│ ├── live-tracking/ # SWR hooks for live bus locations & arrivals
│ └── map-view/ # Map view persistence (localStorage)
├── shared/ # Cross-cutting infrastructure
│ ├── api/ # fetchAPI() — retries, timeout, error handling
│ ├── cache/ # CacheManager — LRU cache with request deduplication
│ ├── config/ # Centralized env config with sensible defaults
│ ├── context/ # AppMapContext — global MapRef provider
│ ├── hooks/ # Shared React hooks
│ ├── redis/ # Redis client, public API wrappers
│ ├── ui/ # Shared UI components
│ └── utils/ # General utilities
└── widgets/ # Composite UI blocks
├── BusListSheet/ # Route list, schedule display
└── MapContainer/ # Map + markers + polylines + popups
Import rule: widgets → features → entities → shared. Each layer only imports from layers
below it.
Vision consumes two categories of data through a three-tier caching pipeline:
┌──────────────────────────────────────────────────────────────────────┐
│ EXTERNAL DATA SOURCES │
│ │
│ apis.data.go.kr Vercel Blob / public/data/ localStorage │
│ (Live bus API) (Static GeoJSON, schedules) (View state) │
└────────┬──────────────────────────┬────────────────────────┬─────────┘
│ │ │
┌────▼────┐ ┌─────▼────────┐ ┌─────▼──────┐
│ Redis │ │ CacheManager │ │ Browser │
│ (3-600s)│ │ (LRU, dedup) │ │ Storage │
└────┬────┘ └─────┬────────┘ └────────────┘
│ │
┌────▼──────────────────────────▼──────────────────────────────────┐
│ API ROUTES (Server) │
│ GET /api/bus/[routeId] → CachedData<BusItem[]> │
│ GET /api/bus-arrival/[busStopId] → CachedData<BusStopArrival[]> │
│ GET /api/bus-stops/[routeId] → CachedData<RawBusStop[]> │
└────────────────────────┬─────────────────────────────────────────┘
│
┌────────────────────────▼─────────────────────────────────────────┐
│ CLIENT (SWR + Hooks) │
│ useBusLocationData() ─── 10s polling ──→ /api/bus/{routeId} │
│ useBusArrivalInfo() ─── 10s polling ──→ /api/bus-arrival/{id} │
│ useBusData() ─── combines live data + static polylines │
└──────────────────────────────────────────────────────────────────┘
Generated by the sibling project Polly and stored in public/data/ (dev) or Vercel Blob (prod):
| File | Format | Content |
|---|---|---|
routeMap.json |
JSON | Maps route names (e.g. "30") → array of route IDs |
stationMap.json |
JSON | All bus stops in the city with coordinates |
polylines/{routeId}.geojson |
GeoJSON | Route path as LineString + stop metadata |
schedules/{routeName}.json |
JSON | Weekday/weekend timetables per route |
Static data is loaded on demand through CacheManager, which provides LRU eviction (50–100
items) and request deduplication — concurrent fetches for the same key share a single promise.
Real-time bus positions and arrival info are fetched from the
Korea Public Data Portal (apis.data.go.kr), proxied through server-side
API routes. Next.js API routes are marked with export const dynamic = "force-dynamic" to bypass default static caching
and ensure our caching layers handle all logic.
Two-Tier Caching Strategy:
1. Real-Time Data (Redis + CDN):
To optimize the API and prevent timeouts from the public portal, the Redis caching layer (src/shared/redis/client.ts)
implements several advanced strategies for frequently-changing data:
- Smart Caching (3s TTL): Live bus positions/arrivals are cached for 3 seconds in Redis, with an
additional 3 seconds of CDN edge caching via
Cache-Control: public, s-maxage=3, stale-while-revalidate=3. - In-Flight Request Coalescing: Prevents Cache Stampedes (Thundering Herd). If a cache expires and multiple users request the same data simultaneously, only one outgoing request is made to the public API, and all concurrent requests await its resolution.
- Stale Data Fallback: The public API is occasionally unstable. The Redis cache keeps expired entries for an extended period (e.g., 60 seconds). If the public API fails (with exponential backoff retries), the system catches the error and serves the older "stale" data instead of showing an error to the user, ensuring uninterrupted service.
2. Static Data (CDN Only): Rarely-changing data like bus stop coordinates and route stop lists bypass Redis entirely and rely on CDN edge caching:
- CDN-Only Caching (1h edge): Static endpoints use
Cache-Control: public, s-maxage=3600, stale-while-revalidate=86400to cache for 1 hour at the CDN edge, with 24-hour stale tolerance. - No Redis Overhead: By skipping Redis for static data, we reduce Redis memory usage and latency.
| API Route | Caching Strategy | TTL | Data Source |
|---|---|---|---|
/api/bus/[routeId] |
Redis + CDN | 3 sec | Bus location API |
/api/bus-arrival/[busStopId] |
Redis + CDN | 3 sec | Arrival prediction API |
/api/bus-stops/[routeId] |
CDN only | 1 hour | Route stop list API |
/api/route-stops/[routeName] |
CDN only | 1 hour | Static data files |
The Redis layer implements a "smart cache" — when one user's request triggers a fetch, the result is cached for all later users within the TTL window.
Client-side polling (SWR):
- Polling interval: 10 seconds
- Deduplication window: 2 seconds
- Revalidates on tab focus
The app internally uses [lat, lng] order. GeoJSON files follow the standard [lng, lat]
convention. Conversion happens at the boundary — when loading polylines and when passing data to
MapLibre.
When a user selects route "30":
routeMap.jsonresolves"30"→["WJB251000068", "WJB251000376", ...]- SWR fetches
/api/bus/{routeId}for each route ID → server checks Redis → on miss, calls the public API - Polyline GeoJSON files are loaded for each route ID, split at
turn_idxinto up and down segments - Bus GPS positions are snapped to the nearest point on the polyline
BusAnimatedMarkersmoothly animates markers along the polyline path (3-seconds duration)- Every 10 seconds, SWR refetches and markers animate to updated positions
| Mechanism | Scope | Data |
|---|---|---|
| SWR | Live data | Bus locations, arrival info (10s polling) |
| CacheManager | Static data | Polylines, route/station maps (in-memory LRU) |
| AppMapContext | UI state | Global MapRef instance for cross-component map control |
| localStorage | Persistence | Map view state (center, zoom, bearing), selected route |
| useMemo | Derived state | Active route detection, sorted bus lists, snapped positions |
| Category | Technology |
|---|---|
| Framework | Next.js 16 (App Router, Turbopack) |
| UI | React 19 |
| Language | TypeScript 5 |
| Styling | Tailwind CSS 4 |
| Map Renderer | MapLibre GL JS via react-map-gl |
| Live Data | SWR for stale-while-revalidate polling |
| Server Cache | Redis for API response caching |
| Static CDN | Vercel Blob for production assets |
| Linting | ESLint 9 |
-
Navigate into the
Visiondirectory:cd Vision -
Install dependencies:
npm install
-
Set up environment variables:
cp .env.local.example .env.local
Edit
.env.localwith your credentials:# Static data — use local files during development NEXT_PUBLIC_USE_REMOTE_STATIC_DATA="false" NEXT_PUBLIC_STATIC_API_URL="/data" # Korea Public Data Portal API key (required for live bus data) DATA_GO_KR_SERVICE_KEY="your-service-key" # Redis URL for server-side caching REDIS_URL="redis://..." # Vercel Blob token (only needed for upload-data script) BLOB_READ_WRITE_TOKEN="your-token"
-
Run the development server:
npm run dev
Open http://localhost:3000.
All environment variables are centralized in src/shared/config/env.ts with sensible defaults — the
app runs without any configuration in development mode (using local static data).
| Variable | Required | Description |
|---|---|---|
DATA_GO_KR_SERVICE_KEY |
Yes | API key for apis.data.go.kr live bus endpoints |
BLOB_READ_WRITE_TOKEN |
No | Vercel Blob token (only for upload-data script) |
REDIS_URL |
Yes | Redis connection string for API response caching |
| Variable | Default | Description |
|---|---|---|
NEXT_PUBLIC_USE_REMOTE_STATIC_DATA |
false |
true to load static data from Vercel Blob |
NEXT_PUBLIC_STATIC_API_URL |
/data |
Base URL for static data (local path or Blob URL) |
NEXT_PUBLIC_MAP_DEFAULT_POSITION |
37.3421,127.91976 |
Initial map center (lat, lng) |
NEXT_PUBLIC_MAP_DEFAULT_ZOOM |
13 |
Initial zoom level |
NEXT_PUBLIC_MAP_MÏAX_BOUNDS |
37.10,127.60,37.60,128.30 |
Map boundary limits |
NEXT_PUBLIC_BUS_STOP_MARKER_MIN_ZOOM |
15 |
Zoom threshold for stop markers |
NEXT_PUBLIC_BUS_ANIMATION_DURATION |
4000 |
Bus marker animation duration (ms) |
NEXT_PUBLIC_LIVE_API_REFRESH_INTERVAL |
10000 |
Live data polling interval (ms) |
NEXT_PUBLIC_DEFAULT_ROUTE |
30 |
Default route loaded on first visit |
| Command | Description |
|---|---|
npm run dev |
Start dev server with Turbopack |
npm run build |
Production build |
npm run start |
Start production server |
npm run lint |
Run ESLint |
npm run lint:fix |
Run ESLint with auto-fix |
npm run typecheck |
Type-check without emitting |
npm run upload-data |
Upload public/data/ to Vercel Blob |