A personal, interactive 3D map of the restaurants I've actually eaten at across downtown Toronto.
Not a finder. Not a directory. My own hand‑curated map — click a glowing pin to read my review, then wander the city in 3D or flip to a flat 2D street map.
Overview · Features · Tech Stack · Architecture · Getting Started · Make It Your Own · Roadmap
Chen's Toronto Eats is a premium, single‑page map experience built to feel like the best parts of Apple Maps, Mapbox, Notion, and Airbnb — a full‑bleed, stylized 3D city rendered with extruded buildings, floating glassmorphic UI, and cinematic camera animations.
It is deliberately not a generic restaurant finder. Every glowing marker is a place I have visited; tapping one opens my own review, rating, visit date, and notes. The map is a living, personal food diary of downtown Toronto.
The entire experience runs on free, key‑less, open‑source map tiles — no Google Maps, no Mapbox account, no API keys, and no usage costs.
Why it exists: a portfolio‑grade demonstration of a modern, production‑quality Next.js app — strict typing, thoughtful state management, accessible and responsive UI, and a genuinely delightful map interaction — wrapped around something personal.
- Immersive 3D basemap — extruded buildings with height‑based shading and a subtle atmospheric sky, rendered from OpenStreetMap vector data.
- 2D / 3D toggle — flip between a pitched 3D city and a flat, Google‑Maps‑style 2D street map (footprint buildings, top‑down, rotation locked). Both are generated from the same free tiles; the camera animates smoothly between them.
- Progressive skyline — when zoomed out, only major (tall) buildings render, so the city reads as a recognizable skyline instead of a carpet of boxes; mid‑ and low‑rise fill in as you zoom in.
- Cinematic camera — selecting a place performs an eased ~1 s fly‑to. It never jumps, and re‑selecting the same place always re‑centers.
- Custom floating markers — bespoke pins that float, pulse, glow and scale on hover (never default map pins).
- Glassmorphic detail popup — a blurred, rounded card (a bottom sheet on mobile) with the photo, my review link, description, rating, visit date, and tags.
- Three ways to browse — a top‑center search with full keyboard navigation, a collapsible sidebar of every place, and a hoverable bottom carousel.
- About panel — a tap on the logo explains whose map this is and what it's for.
- Filters — by cuisine and tag, plus a favourites‑only view.
- Favourites — starred places, persisted locally.
- Statistics — total visited, average rating, distinct cuisines, and a top‑cuisines breakdown.
- Add / edit / delete places in the app — set a location by clicking the map, validated with Zod, and persisted locally so changes survive a reload.
- Dark / light theme — dark by default, with a smooth toggle (persisted, no flash on load).
- Responsive — desktop‑first, with graceful tablet and mobile layouts (drawers and bottom sheets).
- Accessible — keyboard navigation, visible focus rings, ARIA labelling, and Escape‑to‑close everywhere.
- Performant — lazy‑loaded map, memoized selectors, diffed markers, and minimized re‑renders.
Capture the views below into
docs/screenshots/and embed them here.
| 3D · Dark (default) | 2D · Light (Google‑Maps style) | Restaurant popup | Mobile |
|---|---|---|---|
| Pitched skyline with extruded buildings | Flat top‑down street map | Glassmorphic review card | Drawer + bottom sheet |
| Area | Choice |
|---|---|
| Framework | Next.js 15 (App Router) · React 19 |
| Language | TypeScript — strict, zero any |
| Mapping | MapLibre GL JS + free OpenFreeMap vector tiles (3D building extrusion) |
| Styling | Tailwind CSS · shadcn/ui · Radix UI |
| State | Zustand (with persist) |
| Animation | Framer Motion |
| Validation | Zod |
| Icons | Lucide |
| Tooling | ESLint · Prettier |
| Package manager | npm |
| Deployment | Vercel |
A single immersive client experience: a full‑bleed map with independent, self‑positioning overlays floating above it. A typed Zustand store is the single source of truth; the map camera is decoupled from the UI so every surface stays in sync.
flowchart LR
Data[("data/restaurants.ts")]
Store[("Zustand store<br/>selection · hover · filters<br/>favourites · theme · mode")]
Camera["CameraController"]
Style["getMapStyle()"]
Map["MapLibre GL map"]
Tiles[("OpenStreetMap data")]
subgraph Overlays["Floating UI overlays"]
direction TB
Search["Search"]
Sidebar["Sidebar"]
Carousel["Carousel"]
Controls["Controls · filters / stats / 2D·3D / theme"]
Popup["Detail popup"]
end
Data --> Store
Overlays -- "selectRestaurant · setHovered · filters" --> Store
Store -- "derived state" --> Overlays
Store -- "selectionTick" --> Camera
Store -- "theme · mode" --> Style
Camera -- "eased flyTo" --> Map
Style -- "style spec" --> Map
Map -- "free OpenFreeMap tiles" --> Tiles
Key decisions
- Single source of truth. Selection, hover, filters, favourites, theme, and 2D/3D mode all live in one Zustand slice, so markers, sidebar, search, carousel, and popup can never disagree.
- Decoupled camera. Any surface focuses a place by calling
selectRestaurant(id). A dedicatedCameraControllerwatches the selection and performs the eased fly‑to — no component drives the camera directly. A monotonicselectionTickguarantees re‑selecting the same place re‑centers. - Imperative markers, declarative UI. Markers are managed via the MapLibre API and diffed by id for smooth panning; everything else is declarative React + Framer Motion.
- Style as data.
getMapStyle(theme, mode)returns one of four cached MapLibre style specs (dark/light × 2D/3D). 3D uses height‑tieredfill-extrusionlayers; 2D swaps in flat footprint fills. - Resilient by design. Photos fall back to deterministic gradients; imported JSON is validated with Zod before it can touch state.
app/ # Next.js App Router
layout.tsx # Root layout, fonts, metadata, theme bootstrap
page.tsx # Composes the map + floating overlays
icon.svg # Favicon
components/
map/ # MapView, RestaurantMarker (layer), CameraController
restaurant/ # RestaurantPopup, cards, RatingStars, RestaurantImage
sidebar/ # Sidebar, SidebarItem, SidebarToggle
search/ # SearchBar (keyboard-navigable combobox)
controls/ # Filters, stats, import/export, 2D-3D & theme toggles
layout/ # Brand + About dialog, TopBar
providers/ # ThemeProvider
ui/ # shadcn/ui primitives
data/restaurants.ts # The curated list + derived cuisines/tags
hooks/ # useFilteredRestaurants, useFlyTo, useMediaQuery, useEscapeKey
lib/ # utils, constants, mapStyle (the 2D/3D dark/light styles)
store/useAppStore.ts # Zustand store (+ persist)
styles/globals.css # Design tokens, glass utilities, MapLibre overrides
types/ # Restaurant type + Zod schemas
- Node.js 18.18+ (Node 20 LTS recommended)
- npm 9+
No environment variables or API keys are required — the map tiles are free and key‑less.
git clone https://github.com/YheChen/RestarauntBlog.git
cd RestarauntBlog
npm installnpm run dev # start the dev server → http://localhost:3000| Script | Description |
|---|---|
npm run dev |
Start the development server |
npm run build |
Create an optimized production build |
npm run start |
Serve the production build |
npm run lint |
Run ESLint |
npm run format |
Format the codebase with Prettier |
npm run format:check |
Check formatting without writing |
The basemap is rendered by MapLibre GL JS over OpenFreeMap vector tiles, which serve the full
planet of OpenStreetMap data for free with no API key and no signup. Map "quality" here is driven by
the style, not the tile source, so the app ships four hand‑tuned styles (dark/light × 2D/3D) defined in
lib/mapStyle.ts:
- 3D — pitched camera with height‑tiered building extrusion, road hierarchy, labels, and an atmospheric sky.
- 2D — a flat, top‑down, Google‑Maps‑style street map with footprint buildings and rotation locked (mouse, touch, and keyboard).
Because there are no metered API calls, the map is free to run at any scale.
The restaurant list is the single source of truth in
data/restaurants.ts. Add, edit, or remove entries there — the search, filters,
sidebar, carousel, markers, and statistics all update automatically.
{
id: 'my-restaurant', // required · unique, kebab-case
name: 'My Restaurant', // required
latitude: 43.6481, // required
longitude: -79.3962, // required · negative in Toronto
reviewUrl: 'https://…', // required · a valid URL
cuisine: 'Italian', // required · drives the carousel + cuisine filter
description: 'My review…', // required
rating: 4.7, // optional · 0–5 (half-stars supported)
visitDate: '2024-06-02', // optional · ISO date → "Visited June 2024"
tags: ['pasta', 'date night'], // optional · become tag filters
priceRange: '$$$', // optional · '$' | '$$' | '$$$' | '$$$$'
neighbourhood: 'King West', // optional
image: 'https://…jpg', // optional · falls back to a gradient tile
}Prefer no code? Use the + Add button in the app — it has the same fields, sets the location by
letting you click the map, and saves to your browser. See the type definition in
types/restaurant.ts.
Importing from Google Maps. The list in
data/restaurants.tsis generated from a Google Takeout export (Maps (your places) →Reviews.json) byscripts/import-google-takeout.mjs. Re‑run it any time to pull in new reviews:node scripts/import-google-takeout.mjs "/path/to/Reviews.json"It keeps Toronto food & drink places, maps star ratings, review text, prices, and meal‑type tags, and derives cuisine and neighbourhood. After regenerating, bump
SEED_VERSIONinstore/useAppStore.tsso browsers holding an older copy pick up the new data.
- Full keyboard navigation, including an ARIA combobox pattern for search.
- Visible focus rings on every interactive element.
- ARIA labels on all icon‑only buttons;
Escapecloses every overlay. - Respects reduced‑motion preferences via the animation layer.
- The MapLibre map is lazy‑loaded (
next/dynamic, client‑only) behind a graceful loading state. - Filtering is a single memoized selector; markers are diffed by id rather than rebuilt.
- Store subscriptions are sliced to avoid unnecessary re‑renders.
- Production build is fully static; first‑load JS is ~214 kB.
The project is strict end‑to‑end and clean on every gate:
npx tsc --noEmit # no type errors (strict, no `any`)
npm run lint # no ESLint errors
npm run build # production build succeedsDeploys to Vercel with zero configuration:
- Push to GitHub.
- Import the repo at vercel.com/new.
- Accept the detected Next.js defaults and deploy.
No environment variables are required. Any host that supports Next.js works too.
- Marker clustering at low zoom for dense areas
- Shareable deep links (
?place=alo) that open a restaurant on load - "Near me" via the browser geolocation API
- MDX‑backed long‑form reviews with photo galleries
- PWA / offline support and installability
This is a personal project, but issues and pull requests are welcome. To propose a change:
- Fork the repository and create a feature branch (
git checkout -b feat/my-change). - Make your change and ensure
npm run lintandnpm run buildboth pass. - Open a pull request describing the change and the motivation.
Released under the MIT License © Yanzhen Chen.
- OpenFreeMap and OpenStreetMap contributors for free, open map data and tiles
- MapLibre for the open‑source GL renderer
- shadcn/ui and Radix UI for accessible UI primitives
Yanzhen Chen — built with a lot of good meals. 🧡