An interactive world map of specialty coffee — origins, flavor profiles, brewing recommendations, and an interactive flavor wheel tailored to each bean.
Status: Still Under Development — Phases 1 & 2 complete; Phase 3 substantially complete. 30 bean profiles with full SCA flavor-note tagging, custom Mapbox styles, SSR bean pages, responsive panel with a draggable mobile bottom sheet, dark/light mode, faceted filters, ⌘K search, brewing recommendation cards with dose calculator + interactive brew timer, /beans browser with grid/table toggle, Euclidean similar-beans, side-by-side bean comparison, D3 flavor wheel with category/subcategory/note filtering, an MDX-powered Learn section, and shareable URLs.
- Framework: Next.js 16 (App Router, Turbopack) + React 19 + TypeScript
- Map: react-map-gl + Mapbox GL JS (globe projection, clustering, custom Mapbox Studio styles)
- Styling: Tailwind CSS v4 + shadcn/ui (on top of Base UI) + custom coffee color palette
- State: Zustand
- URL state:
nuqs— type-safe URL search params, shallow routing - Search: Fuse.js (weighted, fuzzy)
- Data viz: d3-hierarchy + d3-shape (sunburst flavor wheel); pure SVG everywhere else
- Gestures:
@use-gesture/react+@react-spring/web(draggable mobile bottom sheet) - Content:
next-mdx-remote(RSC) +gray-matterfor the Learn section - Data: JSON seed files validated with Zod at build time
- Theme: next-themes (Mapbox style swaps on toggle)
- Deploy: Vercel
- Interactive map — Mapbox globe with clustered bean markers, on-hover region highlights. Click a marker to fly to the origin and open its profile.
- Bean profiles — Each bean carries a 6-axis flavor profile (acidity, body, sweetness, bitterness, complexity, fruitiness), tagged tasting notes, varieties, processing, harvest months, and an SSR detail page. A similar-beans section uses Euclidean distance over the flavor profile to surface related origins from other countries.
- Brewing recommendations — Per-bean cards sorted by affinity score with a "Best Match" highlight. Open any card for a full recipe — grind-scale visualization, water temperature with °C/°F toggle, ratio, pour schedule, equipment list, and an embedded dose calculator that scales by cup count and persists the user's preferred cup size.
- Interactive brew timer — Drift-free
requestAnimationFrametimer with circular progress ring, automatic stage advancement, opt-in Web Audio API beep on stage transitions,Spaceto start/pause, andprefers-reduced-motionsupport. Lives inside the brew detail modal and is exposed as<BrewTimer />to MDX articles. - SCA flavor wheel — D3-driven sunburst at /explore/flavors and as a toggleable overlay on the map. Click any segment — category, subcategory, or specific note — to filter beans across the whole app. Includes a screen-reader-only data table and is lazy-loaded so D3 stays out of the initial bundle.
- Bean comparison — Add up to three beans to the comparison tray, then open the side-by-side view with overlaid radar charts, a parameter table, and a "best for [method]" highlight. Shareable via
/compare?beans=slug1,slug2,slug3. - Insights —
/explore/insightsshows aggregate visualizations across the (filtered) catalog: an altitude bar chart with green→brown gradient sorted by midpoint elevation, and a Gantt-style harvest calendar that highlights the current month. - Learn section — MDX-rendered articles at
/learnfor processing methods and brewing guides. Articles can embed<BrewTimer />and<Callout>components. The pipeline ships with one stub per category (washed processing, V60); the remaining 11 articles are scaffolded as TODOs. - Mobile — Bean panel becomes a draggable bottom sheet with three snap points (peek, half, full), flick-to-close, and a dimmed backdrop. Filters open as a bottom sheet too.
- Search — ⌘K opens a fuzzy search across name, country, region, and flavor notes. Recent searches persist in
localStorage. - SCA flavor-notes hierarchy — 9 categories / 29 subcategories / 84 specific notes in src/data/flavor-notes.json, cross-validated against every bean at build time.
- Shareable URLs —
nuqssyncs selected bean, map viewport, all filters (region, processing, roast, altitude, flavor notes) into the URL with shallow routing.
- Node.js 20+
- A Mapbox account and access token
git clone https://github.com/DouglasC2627/bean-map.git
cd bean-map
npm install
cp .env.example .env.local
# edit .env.local with your Mapbox token (see below)
npm run devOpen http://localhost:3000.
| Name | Required | Purpose |
|---|---|---|
NEXT_PUBLIC_MAPBOX_TOKEN |
Yes | Mapbox public access token (pk.*) |
NEXT_PUBLIC_MAPBOX_STYLE_LIGHT |
No | Custom Mapbox Studio style URL for light mode. Falls back to mapbox://styles/mapbox/light-v11 |
NEXT_PUBLIC_MAPBOX_STYLE_DARK |
No | Custom Mapbox Studio style URL for dark mode. Falls back to mapbox://styles/mapbox/dark-v11 |
All three are inlined into the client bundle at build time — changing them requires a full rebuild.
| Command | Purpose |
|---|---|
npm run dev |
Start dev server (Turbopack) |
npm run build |
Run Zod validation, then production build |
npm run start |
Start production server |
npm run lint |
Run ESLint |
npm run validate:data |
Validate src/data/ against Zod schemas + cross-check flavor-note IDs, method IDs, and related-bean IDs |
npm run expand:brewing |
Regenerate missing brewing recommendations via affinity weights |
npm run new:bean |
Interactive scaffolder that prompts for every field and appends a new bean profile to beans.json |
bean-map/
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── bean/[slug]/ # SSR bean detail page (generateStaticParams)
│ │ ├── beans/ # /beans grid + table browser
│ │ ├── compare/ # /compare?beans=slug1,slug2,slug3
│ │ ├── explore/
│ │ │ ├── flavors/ # D3 flavor wheel + matched beans list
│ │ │ └── insights/ # Altitude chart + harvest calendar
│ │ ├── learn/
│ │ │ ├── page.tsx # Hub listing processing + brewing articles
│ │ │ ├── processing/[slug]/ # MDX article renderer
│ │ │ └── brewing/[slug]/ # MDX article renderer
│ │ ├── layout.tsx # Root layout — fonts, ThemeProvider, NuqsAdapter, TopNav
│ │ ├── page.tsx # Home (map view)
│ │ └── globals.css # Tailwind v4 theme + coffee palette
│ │
│ ├── components/
│ │ ├── map/ # CoffeeMap, MapView, RegionHighlight, FlavorWheelOverlay
│ │ ├── bean/ # BeanPanel, BeansBrowser
│ │ ├── filter/ # FilterPanel, FlavorSliders, ActiveFilters
│ │ ├── brewing/ # BrewCard, BrewDetailModal, BrewCalculator, BrewTimer
│ │ ├── compare/ # ComparisonTray, ComparisonView, CompareToggle
│ │ ├── visualization/ # FlavorRadar, FlavorWheel(+Lazy), AltitudeChart, SeasonalChart
│ │ ├── layout/ # TopNav, MobileBottomSheet
│ │ ├── shared/ # ThemeProvider, ThemeToggle, SearchCommand, UrlStateSync
│ │ └── ui/ # shadcn/ui primitives (Button, Dialog, Sheet, Slider, …)
│ │
│ ├── content/ # MDX articles for the Learn section
│ │ ├── processing/ # washed.mdx (+ stubs to come)
│ │ └── brewing/ # v60.mdx (+ stubs to come)
│ │
│ ├── lib/
│ │ ├── data.ts # Cached bean / method / flavor-notes loaders
│ │ ├── schemas.ts # Zod schemas mirroring src/types
│ │ ├── search.ts # Fuse.js index + recent-searches helpers
│ │ ├── similar.ts # Euclidean distance over flavor profile
│ │ ├── mdx.ts # Article frontmatter + content loaders (gray-matter)
│ │ ├── mdx-components.tsx # MDX components map (BrewTimer, Callout, prose styles)
│ │ ├── url-state.ts # nuqs parsers for filters / viewport / selection
│ │ ├── altitude-color.ts # Shared color ramp for altitude visualizations
│ │ ├── use-media-query.ts # SSR-safe matchMedia hook + prefers-reduced-motion helper
│ │ └── utils.ts # cn(), country flags, formatters, flavor-note label lookup
│ │
│ ├── store/ # Zustand store + filter selectors (includes flavor-note hierarchy match)
│ ├── types/ # TypeScript interfaces
│ └── data/ # beans.json, brewing-methods.json, flavor-notes.json, regions.geojson
│
├── public/
│ └── data/ # regions.geojson (fetched at runtime by the map)
│
├── scripts/
│ ├── validate-data.ts # Zod validation + cross-checks — runs before `next build`
│ ├── expand-brewing-recs.mjs # Generate missing brewing recs by affinity
│ ├── generate-regions.mjs # Generate placeholder region polygons
│ └── new-bean.mjs # Interactive scaffolder (`npm run new:bean`)
│
├── .env.example # Env var template
├── AGENTS.md # Agent-facing notes (Next.js 16 caveats)
├── CLAUDE.md # Claude Code project instructions
├── TASKS.md # Phased roadmap (source of truth)
└── package.json
Bean profiles live in src/data/beans.json. The fastest way to add one:
npm run new:beanThe interactive scaffolder prompts for every field, validates flavor-note IDs against src/data/flavor-notes.json, refuses duplicate slugs, and appends the new bean. After it finishes, you can either edit beans.json to add brewing recommendations by hand or run npm run expand:brewing to algorithmically fill the remaining methods.
To add a bean manually:
- Pick a unique
id(kebab-case, e.g.ethiopian-guji) andslug. - Fill in origin fields:
country,countryCode(ISO-2),region,coordinates: [lng, lat],altitudeMasl: [min, max]. - Fill in the 6-axis
flavorProfile(1–10 each),flavorNotes(must be IDs from src/data/flavor-notes.json),varieties,processing,roastRecommendation,harvestMonths. - Add at least one entry to
brewingRecommendations. Runnpm run expand:brewingto algorithmically fill the remaining methods. - Run
npm run validate:data— Zod will catch any missing or malformed fields and the cross-check will flag unknown flavor-note IDs, method IDs, or related-bean IDs.
Region polygons live in public/data/regions.geojson and are fetched client-side for hover highlights.
Articles are MDX files in src/content/ under processing/ or brewing/. Each file needs a frontmatter block:
---
title: Washed Processing
description: How wet processing strips the coffee cherry to highlight bright, clean acidity.
summary: Sometimes called wet processing — the fruit is removed before the seed is dried.
readingTimeMinutes: 6
---
## Heading
Markdown body. You can also drop in registered components:
<BrewTimer totalSeconds={180} bloomSeconds={30} stages={[...]} />
<Callout title="Note">
Body text inside an aside.
</Callout>Slugs are inferred from the filename. The hub and routes (/learn, /learn/processing/[slug], /learn/brewing/[slug]) pick up new files automatically — no registry update needed.
The project deploys to Vercel with no configuration beyond environment variables:
- Import the GitHub repo into Vercel.
- Add the three
NEXT_PUBLIC_MAPBOX_*env vars across Production/Preview/Development. - Deploy. The build runs
npm run validate:data && next build. - In your Mapbox account, restrict the token to your Vercel domains +
localhost:3000to prevent scraping.
Released under the MIT License.