# Area Guide (Main · User Portal)

**Portal:** Main · **Routes:** none of its own since 2026-09-03 (`GET /area-guide` redirects to the bare `/property/academy`) · **Nav:** none — the founder removed the **Area Guide** tab from the Learning Hub on 2026-09-03 · **Gated by:** n/a while unmounted

> **UNMOUNTED SINCE 2026-09-03.** The tab, its `areaGuide` / `areaTutorials` props and the
> `CoursesController::areaGuideState()` lock method were removed at the founder's request; the
> `features.area_guide_locked` flag is now read only by `AreaTutorialsController`. The panel
> (`Components/AreaGuide/AreaGuidePanel.vue`), its content (`resources/js/utils/areaGuide/`) and the
> Area Tutorial pages remain in the tree. Everything below describes the guide as it was mounted
> from 2026-08-27 to 2026-09-03, and is what to re-mount from.

> **It is a TAB, not a page (2026-08-27).** The guide moved into the
> [Learning Hub](/docs/modules_handbook/main/lms/readMe.md) as that page's
> FIRST tab, left of e-Learning: "where should I buy" is reference material a
> member READS before a course about it, not a tool they operate, and every
> other portal nav entry at that level is a tool. What moved is only the
> chrome — the guide itself is now
> [AreaGuidePanel.vue](/resources/js/Components/AreaGuide/AreaGuidePanel.vue),
> mounted by [Lms/Index.vue](/resources/js/Pages/Main/Portal/Lms/Index.vue),
> and every behaviour below is unchanged. `ShowTabs` lazy-mounts the active
> tab, so a reader who never opens the tab still downloads neither three.js
> nor Mapbox.

## What it does

An interactive 3D map of the markets we sell in — Malaysia (West Malaysia,
KL & Selangor live; Penang and Johor marked *Soon*), Hong Kong, and the UAE
(Dubai + Abu Dhabi) — where every area carries the story we tell buyers:
KLCC, Bangsar South, Bukit Bintang, Kai Tak, Palm Jumeirah, Yas Island, and
so on. Each area has a tagline, a two-paragraph investment story, labelled
facts (rail, drivers, tenants, stock) and a positioning badge
(Prime / Hot / Mature / Emerging / Watchlist).

It drills down three levels — **country → region → area** — and the map
engine changes at the bottom one. Country and region are an illustrated
low-poly world (three.js); opening an area swaps it for the **real pitched
street map** (Mapbox Standard: true building heights, roads, street names),
so KLCC shows the Twin Towers and TRX shows Exchange 106. Two engines, on
purpose: real streets at country scale are an unreadable smear, and a cartoon
at area scale cannot show which side of the road a building sits on.

> **TEMPORARY lockdown.** The section is admin-preview only while the stories
> are reviewed: an admin sees the real guide plus an amber banner (with a
> "Preview what members see" link), a member gets the coming-soon panel. It
> was a copy of the Analyze Property lock — a self-contained middleware — until
> the guide became a tab: **a tab has no path of its own for middleware to
> match**, and a path-matching lock would have taken the whole Learning Hub
> with it, so the same three rules now live in
> `CoursesController::areaGuideState()` and reach the page as the `areaGuide`
> prop (`locked` · `previewUrl` · `exitUrl` · `mapboxToken`, withheld when
> locked). It still hangs off `features.area_guide_locked`
> (`AREA_GUIDE_LOCKED`, default **true**; phpunit forces it off so the suite
> exercises the real guide), and opening the section is `AREA_GUIDE_LOCKED=false`
> — no deploy. To remove the lock for good, delete:
> `areaGuideState()` + its prop in
> [CoursesController.php](/app/Http/Controllers/Main/Portal/CoursesController.php)
> (moving `mapboxToken` back to an unconditional prop),
> [AreaGuideComingSoon.vue](/resources/js/Components/AreaGuide/AreaGuideComingSoon.vue),
> [AreaGuideLockBanner.vue](/resources/js/Components/AreaGuide/AreaGuideLockBanner.vue)
> (+ both mounts in [Lms/Index.vue](/resources/js/Pages/Main/Portal/Lms/Index.vue)),
> [AreaGuideLockdownTest.php](/tests/Feature/Main/Portal/AreaGuide/AreaGuideLockdownTest.php),
> the `area_guide_locked` entry in [config/features.php](/config/features.php),
> and the `AREA_GUIDE_LOCKED` lines in `.env.example` / `phpunit.xml`.

> **Area Tutorials (2026-08-30).** An area's story can now list guided **corridor drives** — an
> animated Mapbox route that stops at each condo with its live Subsale Database figures, chaptered
> to the team's walkthrough video. That is a separate, database-backed module with an admin builder:
> [Area Tutorials](/docs/modules_handbook/manage/area-tutorials/readMe.md). This panel only renders
> the "Drive the area" list (the `tutorials` prop, published tutorials keyed by `area_key`) and links
> to `/property/academy/area-tutorials/{uuid}`. The stories themselves are still static JS.

## How it works

- **All content is static JS, no database — one file per country.** The
  stories live in [malaysia.js](/resources/js/utils/areaGuide/malaysia.js),
  [hongKong.js](/resources/js/utils/areaGuide/hongKong.js) and
  [uae.js](/resources/js/utils/areaGuide/uae.js);
  [countries.js](/resources/js/utils/areaGuide/countries.js) is assembly only
  (`COUNTRIES` + the shared `BADGE_TONES`) and documents the shared shape.
  Static JS is the banks.js precedent: only this page consumes it, so
  threading it through Inertia props would be machinery for nothing — the
  controller's single prop is the Mapbox token, which cannot live in the
  bundle. Moving stories into the DB for admin editing is the planned v2.
- **Geometry is pre-simplified offline — split the same way.**
  [geoMalaysia.js](/resources/js/utils/areaGuide/geoMalaysia.js),
  [geoHongKong.js](/resources/js/utils/areaGuide/geoHongKong.js) and
  [geoUae.js](/resources/js/utils/areaGuide/geoUae.js) hold the low-poly maps
  (~32KB total: West Malaysia states, KL/Selangor districts, Hong Kong, UAE
  emirates) as plain `{ name, rings }` outer boundaries — holes and islets
  dropped, Douglas-Peucker simplified, so the browser does zero geo work;
  [geo.js](/resources/js/utils/areaGuide/geo.js) merges them into the `GEO`
  keys the content files reference. Sources + licence: geoBoundaries MYS/ARE
  ADM1 and OSM relation 913110 (ODbL 1.0 — OpenStreetMap contributors), DOSM
  data-open districts. To regenerate: download the source GeoJSON, run a
  Douglas-Peucker pass (tolerance ≈ 0.004–0.018° by map size, drop rings with
  area < ~0.0002–0.004 deg², round to 3–4 decimals) and paste the arrays back.
- **The 3D scene is one reusable component.**
  [AreaMap3d.vue](/resources/js/Components/AreaGuide/AreaMap3d.vue) extrudes
  the rings into a cartoon island scene (three.js `ExtrudeGeometry`, already a
  package dependency — nothing new was installed) and draws each pin as a
  cream disc + emoji sprite. Props: `lands`, `pins`, `highlight` (dim all but
  the named lands — the UAE region views), `fit` (`'lands'` frames geometry,
  `'pins'` frames markers — Abu Dhabi's emirate dwarfs its city), `activePin`.
  Emits `select`. three.js objects live in plain `let`s (never `ref` — the
  VrPanoramaViewer deep-reactivity trap), the renderer is created in
  `onMounted` and disposed (with `forceContextLoss`) in `onBeforeUnmount`, and
  the whole component is `defineAsyncComponent`-lazy so three.js stays out of
  the app bundle. Portal pages are client-only, so SSR never sees it. If the
  WebGL context cannot be created the component catches it, `devWarn`s and
  renders a plain notice in place of the canvas — the side panel's stories
  still work, so the page degrades rather than breaking.
- **The area level is the real map, and it costs nothing new.**
  [AreaStreetMap.vue](/resources/js/Components/AreaGuide/AreaStreetMap.vue)
  reuses the Mapbox runtime the public project pages already load —
  [utils/mapboxLoader.js](/resources/js/utils/mapboxLoader.js) injects
  Mapbox GL from its CDN on demand, and the token arrives as the page's one
  Inertia prop (`config('services.mapbox.token')`, `MAPBOX_TOKEN` — a PUBLIC
  `pk.…` token, restricted by URL in the Mapbox dashboard rather than treated
  as a secret). It uses the same `mapbox://styles/mapbox/standard` style as
  [LocationMap.vue](/resources/js/Components/ProjectDetail/LocationMap.vue),
  but deliberately diverges on three settings: `pitch: 55` (not 45 — a
  district needs more tilt than a single address pin), a fixed `bearing: -18`,
  and `cooperativeGestures: true`, because this map is twice as tall and sits
  directly above the story on a phone, so it must not eat the scroll reaching
  past it. Labels are pinned to English; no portal page is localised yet.
  Each area may carry a `zoom` (≈15+ for a block like KLCC, 13 for a township
  like Cyberjaya, 12 for a planning region like the Northern Metropolis);
  it defaults to 15.2.
- **One map load per visit — the reason both maps use `v-show`, never `v-if`.**
  Mapbox meters a **map load per `new Map()`**, and this is a *browsing*
  surface: a reader hops between areas and flips Streets/Illustrated freely.
  Mounted per area or per flip, thirty flips would be thirty billed loads —
  and each return to the illustrated map would rebuild the whole three.js
  scene and throw away the reader's camera. So both maps stay mounted and
  swap visibility. Two consequences to preserve if this is ever touched:
  the street map is bound to the **last opened area** (not `area`, which goes
  null between areas) and flies itself with `flyTo` when those coordinates
  change; and each map is told when it is hidden — `AreaMap3d` stops drawing
  (`active`), `AreaStreetMap` calls `map.resize()` on the way back, since a
  `display:none` container measures 0×0. Mapbox only downloads once a reader
  actually opens an area (`v-if="lastArea"` gates the first mount), and
  `onBeforeUnmount` still calls `map.remove()` — browsers cap live WebGL
  contexts, so an Inertia page swap must hand it back.
- **Failure is loud, never blank.** A blank map panel is indistinguishable
  from a slow one, so every way this can fail ends at the same notice: no
  token (the Streets control is hidden entirely and the area level simply
  keeps the illustrated map), a CDN refused by an ad blocker or proxy (an
  `error` listener, added to the shared `loadMapbox` as an optional second
  argument so its other consumers are unaffected), a request black-holed
  without either event (a 12s watchdog), a revoked / URL-restricted /
  over-quota token (`map.on('error')` before first paint — a transient tile
  error on a working map is ignored), and a synchronous throw.
- **Panel state is four refs.** [AreaGuidePanel.vue](/resources/js/Components/AreaGuide/AreaGuidePanel.vue)
  tracks country / region / area, plus which map an open area shows. Country
  view pins its regions (Penang and Johor render as dimmed *Soon* pins and
  disabled chips); picking a region swaps the map to that region's geometry
  with its areas pinned; picking an area opens the story panel **and lands on
  the street map** — a `Streets | Illustrated` control on the map card flips
  between the two, and it exists only at the area level, because that is the
  only level where both readings are honest. A country whose single region is
  already built (Hong Kong) skips the region level; one whose only region is
  still `soon` does not, since a `soon` region has no map or areas to open.
  Emoji icons are deliberate placeholders — an AI-illustrated icon set can
  replace them per-area without touching the page.
- **Content integrity is tested.** [areaGuide.test.js](/resources/js/utils/areaGuide/areaGuide.test.js)
  (vitest) fails on a duplicate key, an empty story, an unknown badge tone, a
  highlight naming a land that isn't on its map, or a pin whose coordinates
  don't land on its own map (catches swapped lat/lng).

## Five-day trainees (2026-09-02)
`CoursesController::areaGuideState()` also locks the guide for a **five-day trainee** until their
M card is complete (`TrainingAccess::verdict($user, 'area_guide')`) — `areaGuide.locked` plus
`reason / day / cardHref`, rendered by `AreaGuideComingSoon.vue`, Mapbox token withheld. Checked only
when the global `area_guide_locked` flag is not already showing; non-trainees and admins are untouched.

## Related files

**Backend**
- [CoursesController.php](/app/Http/Controllers/Main/Portal/CoursesController.php) — the Learning Hub's `index()` hosts the guide: `areaGuideState()` supplies the Mapbox token from `config('services.mapbox.token')` plus the TEMPORARY admin-preview lock (flag in [config/features.php](/config/features.php)). *(Was `AreaGuideController` + `LockAreaGuide` middleware; both deleted 2026-08-27.)*

**Frontend (Vue)**
- [AreaGuidePanel.vue](/resources/js/Components/AreaGuide/AreaGuidePanel.vue) — the guide itself: country tabs, region chips, side panel (list ⇄ story) · [AreaGuideComingSoon.vue](/resources/js/Components/AreaGuide/AreaGuideComingSoon.vue) — member coming-soon panel · both mounted by [Lms/Index.vue](/resources/js/Pages/Main/Portal/Lms/Index.vue)'s `#tab-area-guide`.
- [AreaMap3d.vue](/resources/js/Components/AreaGuide/AreaMap3d.vue) — the illustrated three.js scene (country + region) · [AreaStreetMap.vue](/resources/js/Components/AreaGuide/AreaStreetMap.vue) — the real pitched Mapbox street map (area) · [AreaGuideLockBanner.vue](/resources/js/Components/AreaGuide/AreaGuideLockBanner.vue) — admin banner, off the `areaGuide.previewUrl` prop.
- [utils/mapboxLoader.js](/resources/js/utils/mapboxLoader.js) — shared CDN loader; this module added its optional `onError` argument (existing consumers pass none and are unchanged).
- Content, one file per country: [malaysia.js](/resources/js/utils/areaGuide/malaysia.js) · [hongKong.js](/resources/js/utils/areaGuide/hongKong.js) · [uae.js](/resources/js/utils/areaGuide/uae.js) — assembled by [countries.js](/resources/js/utils/areaGuide/countries.js) (+ shared `BADGE_TONES`).
- Geometry, one file per country: [geoMalaysia.js](/resources/js/utils/areaGuide/geoMalaysia.js) · [geoHongKong.js](/resources/js/utils/areaGuide/geoHongKong.js) · [geoUae.js](/resources/js/utils/areaGuide/geoUae.js) — merged by [geo.js](/resources/js/utils/areaGuide/geo.js).
- [areaGuide.test.js](/resources/js/utils/areaGuide/areaGuide.test.js) — content integrity across all countries (vitest).
- Nav: no entry of its own — reached through "Learning Hub" in [resources/js/Layouts/AppLayout.vue](/resources/js/Layouts/AppLayout.vue).

**Migrations** — none (static content, no tables).

**Seeders** — none.

**Routes**
- [routes/main.php](/routes/main.php) — the guide rides `GET /property/academy` (`main.portal.courses.index`). `GET /area-guide` survives as a **redirect** to `/property/academy?tab=area-guide` (still named `main.portal.area-guide.index` — it is bookmarked, and was a sidebar entry until it moved). Both are inside the portal group (`auth`, `main`, `contact.verified`).

**Tests**
- [AreaGuideTest.php](/tests/Feature/Main/Portal/AreaGuide/AreaGuideTest.php) + [AreaGuideLockdownTest.php](/tests/Feature/Main/Portal/AreaGuide/AreaGuideLockdownTest.php) (+ shared [AreaGuideTestCase.php](/tests/Feature/Main/Portal/AreaGuide/AreaGuideTestCase.php)) · the tab pair in [InvestorPortalShellTest.php](/tests/Feature/Main/Portal/InvestorPortalShellTest.php).

## Working in parallel (one country per person)

The file layout exists so different people can build different countries with
no merge conflicts: the Malaysia author owns `malaysia.js` + `geoMalaysia.js`,
the UAE author owns `uae.js` + `geoUae.js`, the Hong Kong author owns
`hongKong.js` + `geoHongKong.js` — and nobody else touches those. The shared
files (`countries.js`, `geo.js`, `AreaGuidePanel.vue`, `AreaMap3d.vue`) only
change when a whole country is added or the panel/scene itself gains a feature — give
those changes to ONE person. `npx vitest run resources/js/utils/areaGuide/`
checks every country's content (keys, stories, badge tones, coordinates
landing on the right map), so run it before pushing content edits.

## Known gaps

- Stories are JS constants — admins can't edit them without a deploy (v2: DB + Manage CRUD).
- Area icons are emoji placeholders pending a consistent AI-illustrated set.
- Penang and Johor are `soon: true` shells — their pins sit on the country map (`GEO.westMalaysia`), but opening either needs a NEW district-level map in [geoMalaysia.js](/resources/js/utils/areaGuide/geoMalaysia.js) (the `GEO.klSelangor` shape) on top of its areas and stories. Geometry is the real blocker, not the writing; `areaGuide.test.js` fails a non-`soon` region that has no `map.lands`.
- No photos on area stories yet — licensing needs deciding (own shots / Wikimedia with credits / AI illustration) before adding any.

**See also:** [Analyze Property](/docs/modules_handbook/main/analyze-property/readMe.md) — the sibling portal module whose lockdown pattern this copies.
