# Strategic Map Analyser

- **Date:** 2026-10-20
- **Status:** Complete — built and tested; Phase 2 dropped
- **Status Date:** 2026-10-21
- **Phases:** 6
- **Phases Complete:** 6
- **Notes:** A new Strategy & Insights page answering *"if I draw around an area on a map,
  tell me what is there"*. The user draws one **Analysis Area**, edits it, presses Analyse,
  and gets an **Area Summary**: premises by use and type, the projects, stocklists and
  wayleaves touching it with premise counts, and the freeholders. Exportable as
  xlsx. Terms are in `GLOSSARY.md`. All twenty-two design questions settled (Dave,
  2026-10-20) — see **Decisions**.
- **Amended 2026-10-27:** classifications and freeholders list in full, uncapped, on the
  page and in the export (Decisions 11, 13, 18). Tested 2026-10-27.

## Plan Phases

0. ✅ Prototype — layout and chart choice (`www/test.php`) *(2026-10-20)*
1. ✅ Route, nav and page shell — draw, edit, live area readout, cap *(2026-10-20)*
2. ~~Migration — the `strategicmap` map layer page context~~ — dropped; layers are assigned in Admin → Map Layers *(2026-10-20)*
3. ✅ Analyse endpoint — `fn/strategic_map_analyse.php` *(2026-10-20)*
4. ✅ Area Summary rendering and export *(2026-10-20)*
5. ✅ Documentation *(2026-10-21)*

## Problem

GeoLynx holds the data to describe any piece of ground — AddressBase premises with their
classification, building-level MDU/SDU and use, projects, stocklists, wayleaves, and Land
Registry company ownership — but only ever shows it through one record at a time. There is
no way to ask what a *place* contains. Strategy work (where to build next, who to approach
for wayleaves, what is already in hand) needs exactly that.

No endpoint in the app runs a query against an unsaved, user-drawn polygon today: every
geometry-accepting endpoint saves what it is given. This is the first, so it is also the
first with a size guard.

### Out of scope for v1

- Saving or loading Analysis Areas.
- Premise-level export. Only the summary exports — a premise list for a borough-sized area
  is beyond what the server should build or a spreadsheet should open.
- Drawing the matched records on the map, or zoom/highlight on row click (backlogged).
- Multi-part areas and holes.

## Decisions

Settled with Dave, 2026-10-20.

1. **Terms.** The drawn polygon is the **Analysis Area**; the result is the **Area
   Summary**; the Land Registry party is the **Freeholder**. In `GLOSSARY.md`.
2. **Record counts are in-area and total** — e.g. 200 / 1,000 — for projects, stocklists
   and wayleaves, so a user sees when the area cuts a record in two.
3. **Projects count `is_valid` (This Project)**, from `project_premises` at `approved`, with
   a **Sub-projects** column only when any row has a non-zero figure. **No column totals**
   on any record table: a contested premise counts against every project claiming it.
4. **Wayleaves match on both arms**, as `vw_wayleave_project_matches` does: approved
   coverage in the area **or** a drawn polygon intersecting it. The premise figure is
   approved coverage only, `count(DISTINCT uprn)`; a polygon-only match shows 0.
5. **Fail closed on visibility.** Records are listed only where the user has module read
   and passes `dashboardCompanySql()`; each row links per record permission. Base data
   (premises, buildings, freeholders) is not record-scoped and shows to everyone.
6. **Cap: 100 km²** — first set at 200, lowered because 200 takes in most of London
   (Dave, 2026-10-20). Shown live while drawing, Analyse disabled above it, enforced again
   server-side on `ST_Area` in 27700, with a `statement_timeout` as backstop. No
   premise-count cap.
7. **One simple polygon.** No holes, no multi-part; redrawing replaces it.
8. **Synchronous.** One AJAX request with a loading overlay, not a background job. Split
   into per-section requests only if testing shows it is slow.
9. **No map interaction from results in v1** beyond the Analysis Area staying drawn.
10. **Editing after Analyse marks the summary stale** — kept on screen, dimmed, export
    disabled until re-analysed, so an export can never describe a different area.
11. **Premise use** is `basedata.abp.classification_code`, read two ways: by **family**
    (first letter — Residential, Commercial, Parent shell, …) and as the **top 10 full
    codes** with description plus an *All other codes* row, so the panel totals. Every
    premise counts, `PP` included, so the area total matches project counts.
    *Amended 2026-10-27 (Dave):* **every code** is listed, in a scroll box headed with the
    code count; the *All other codes* row is gone with nothing left to fold. No total row
    — the sum is the Premises tile.
12. **Building type and building use** come from `basedata.toid_building_type_geom`
    (`building_type` MDU / SMDU / SDU / other; `building_use` Residential,
    Residential-Mixed, Commercial, Commercial-Mixed, Mixed, Other), aggregating
    `building_premises` and its residential / flat / commercial splits for **buildings
    whose point falls in the area** — chosen as the fastest (Dave). These panels can
    differ slightly from the premise total at the area's edge and where a premise's
    building has no row; labelled *premises in buildings within the area*.
13. **Freeholders** use the Land Registry page's path — `ccod → toid_title_map →
    toid_building_type_geom`, building point in area — **fixed**: each building's premises
    counted once per freeholder, grouped on `coalesce(company_registration_no_1,
    proprietor_name_1)`. Freehold, first-named proprietor only, said in the heading. Top 10
    by premises with titles, buildings and the MDU / SMDU / SDU split. The Land Registry
    page's own double count is backlogged.
    *Amended 2026-10-27 (Dave):* **every freeholder** is listed — the `LIMIT 10` is gone,
    with no replacement cap; the 100 km² area cap and the timeout bound it, and the
    grouping already ran over all of them. Count in the heading. **No total row**: a
    building with two freeholders counts against each, so the premises column does not
    sum to the area.
14. **Permission:** `strategy` write, page and endpoint —
    `requireModuleAccess($pdo, 'strategy', 'strategy', 'write')`. `db/006` grants it to
    every role; narrowing that is a role change in admin, not a migration.
15. **Layers:** a `strategicmap` page context in Admin → Map Layers, assigned there by an
    admin. First planned as a migration copying `map`'s rows; dropped because page layers
    are admin configuration (Dave, 2026-10-20).
16. **Own route and files**, not a mode of `map_v5.js` (which is gated on `projects`):
    `?do=strategicmap`, under Strategy & Insights as *Strategic Map Analyser*.
17. **Export** is one xlsx built client-side with SheetJS from the summary on screen: a
    cover sheet (area km², date, user, the Analysis Area as WKT so it can be redrawn) and a
    sheet per section.
18. **Sections, in order:** headline tiles (area, premises, buildings, projects,
    stocklists, wayleaves); building type | building use; premise use |
    classifications; projects (full width); stocklists | wayleaves; freeholders
    (full width). Export sheets follow the same order after the cover sheet. (*Top 10*
    dropped from both headings 2026-10-27; every scroll-box table has a sticky header.)
19. **Charts** (Chart.js 3.6.2, already global) for building type, building use and
    premise use only — **horizontal bars** with value labels, a table beneath each;
    tables for the rest; headline figures as tiles.
20. **Layout C** (Phase 0 pick, Dave 2026-10-20): before a result the map is the page;
    after Analyse the Area Summary takes the page in two columns and the map drops to a
    **420px** band above it, with an **Expand / Shrink map** toggle (420 ↔ full height).
    After every resize the view **fits to the Analysis Area**; editing does not refit, so
    the view never moves under the cursor. Clear restores the full map. Export sits in the
    page header, so its disabled state shows staleness even when the banner is scrolled
    away.

## Build

### Phase 0 — Prototype

`www/test.php`: the real draw / modify / area cap / stale / xlsx export over fake data,
inside mock page chrome with the app's CSS. A switcher between three layouts — **A** right
panel, **B** bottom drawer, **C** summary takes the page — and between bars, doughnuts and
tables only, against five scenarios (typical, empty, busy, long names, dominant code).
Dave picks a layout and a chart form; the pick is written into Decisions.

✅ *2026-10-20* — **C with bars** (Decisions 18–20). Changes made on the prototype before
sign-off: the map band raised from 200px to 420px, fit-to-area after resize, the expand
toggle, building sections moved to the first row, and the cap lowered to 100 km². The
prototype is the reference for Phase 4's markup and CSS; the `.sec` header takes an inner
radius (`calc(.4rem - 1px)`) so its fill does not paint over the card's rounded border.
Basemap in the prototype is OSM standard (Carto now needs a key); the built page uses the
layer config.

### Phase 1 — Route, nav and page shell

- `fn/routes.php`: `strategicmap` → module / permission `strategy`, head nav
  `html_header_nav_v2.php`, body `html/html_body_strategic_map.php`, CSS
  `css/strategic_map.css`, JS `js/map_layer_utils.js` then `js/strategic_map.js`.
- `html/nav_global.php`: a child under `strategy`, and the route in `$navRouteToModule`.
- The template includes `nav_global.php` itself, as `html_body_landregistry.php` does.
- `index.php` gates on read; the page also needs write, so the template or route check
  must refuse read-only users — confirm the route mechanism can express write, or gate in
  the template.
- `strategic_map.js`: OpenLayers map (EPSG:3857), layers via
  `MapLayerUtils.loadLayersFromAPI('strategicmap')`, one `ol.interaction.Draw` polygon and
  a `Modify` on it, the live area readout (`ol.sphere.getArea`), Analyse enabled only for
  a closed area within the cap.

✅ *2026-10-20 — built and tested (all twelve checks passed).* The write gate became a route key:
`permission_level` (default `read`) in `routes.php`, honoured by `index.php`'s gate and by
`nav_global.php`, which hides a child link from users below its level. `strategicmap` is
the only route that sets it. The page has its own small layer builder (draw and toggle
only) rather than `map_v5.js`'s, which initialises project creation on load. It shares
`?do=map`'s saved view cookie, so it opens where the user last was. `strategicmap` was
added to `admin_map_layers.js`'s page contexts, so layers can be assigned to the page in
Admin → Map Layers — which bears on Phase 2.

### Phase 2 — Migration: layer page context — dropped

*Dropped 2026-10-20 (Dave).* Which layers a page shows is admin configuration, owned by
Admin → Map Layers, and a migration copying `map`'s rows would have been a migration
choosing it. Phase 1 added `strategicmap` to that screen's page contexts, and Dave assigned
the page's layers there. No migration.

### Phase 3 — Analyse endpoint

`fn/strategic_map_analyse.php`, POST, the Analysis Area as GeoJSON in 4326.

- Auth, then `requireModuleAccess($pdo, 'strategy', 'strategy', 'write')`.
- Geometry: `ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(?), 4326), 27700)`; refuse anything
  but a single valid Polygon; refuse `ST_Area` over 100 km² with a message naming the cap.
- `SET LOCAL statement_timeout` inside a transaction.
- One query per section, each a spatial filter on an indexed geometry:
  premises and codes from `basedata.abp` (GiST `abp_geom_idx`); buildings from
  `toid_building_type_geom` (GiST); projects via `project_premises` joined to the
  in-area UPRNs plus `is_deleted = false`; stocklists via `stocklist_premises` on in-area
  UPRNs; wayleaves via `wayleave_coverage` `approved` on in-area UPRNs **union** polygons
  intersecting; freeholders per Decision 13. Totals per record from the same tables
  unfiltered by area, for the listed ids only.
- Module enablement and permission per section, company scoping via
  `dashboardCompanySql()`, per-row link permission — Decision 5.
- Returns JSON with the area in km² echoed back.

✅ *2026-10-20 — built and tested (all thirteen checks passed).* As built:

- **One AddressBase pass.** The premises in the area go into an `ON COMMIT DROP` temp
  table once, and the code, project, stocklist and wayleave sections all join to it rather
  than repeating the spatial scan of `basedata.abp`. It is created and then filled, because
  `CREATE TABLE AS` is a utility statement and Postgres will not bind a parameter in one.
- **The area is validated by PostGIS**, never PHP: anything `ST_GeomFromGeoJSON` cannot
  parse, anything but a `POLYGON` with no holes, an invalid (self-crossing) ring, and more
  than 100 km² are each refused with their own message. The 27700 WKT goes back for the
  export's cover sheet.
- **Timeout 60 s**, raised as SQLSTATE 57014 and reported as *try a smaller area*.
- **Visibility is list-or-not.** A section the user cannot list comes back
  `available: false` rather than empty. Listing needs module read plus the company match —
  the editors' own test — so every listed record opens and no per-row link check is
  needed; a record-level grant without module read does not list a record (fail closed).
- **Freeholders** group on `coalesce(company_registration_no_1, 'name:' ||
  proprietor_name_1)`, take the commonest spelling as the name, and DISTINCT the
  (freeholder, building) pairs before summing premises.
- Analyse posts the area and, until Phase 4, prints the raw JSON beneath the map.

### Phase 4 — Area Summary and export

Render the chosen layout and charts from the endpoint's JSON; loading overlay; empty
states per section; stale state on `modifyend`; xlsx export per Decision 17.

*2026-10-20 — built and tested.* Ported from the prototype into
`strategic_map.js` / `strategic_map.css`. Beyond the prototype:

- **A section the user cannot list says so** — *Needs Projects access.* — and its tile shows
  a dash, rather than reading as an empty area.
- **Status badges** use the admin's style token from the same inline payload the map
  layers use, keyed on the token; a status with no token prints as plain text.
- **Record names link to their editors** in a new tab, so the summary and its drawn area
  survive the click (no saving in v1).
- **Export's cover sheet takes who and when from the server** (`analysed.by` / `.at`,
  added to the endpoint), and carries the area as 27700 WKT. Sheets follow the page order;
  a section the user cannot list exports as a one-line *not available* sheet.

### Phase 5 — Documentation

`CLAUDE.md` route table and a short section; `features.md`; tick Phases; restore
`www/test.php`'s occupant note.

*2026-10-21 — reviewed.* Testing found one fault: the page scrolls under the sticky
header, which `?do=map` never does, so `#sidebarLayers` (9999) and the loading overlay drew
over the nav. `.sma-page` now sets `isolation: isolate`, scoped to this page. `CLAUDE.md` gains the `strategicmap` route row, a
*Strategic Map Analyser* architecture section (the `permission_level` gate, the server-side
cap, the counting rules borrowed from the rest of the app, fail-closed visibility, layers as
admin config) and this plan in the documentation list. `features.md` gains a section, the
module-table mention and the xlsx export line. `www/test.php`'s note marks the prototype
shipped, so the next occupant can replace it.

## Testing checklist

To be written per phase as each is built.

## Risks

- **First endpoint over an unsaved user polygon.** The cap and timeout are the only things
  between a user and a national scan; both must be server-side, not just in the client.
- **Building panels do not reconcile with the premise total** (Decision 12). If the gap
  proves confusing on real data, switch to labelling each in-area premise by its building.
- **`toid_building_type` is populated outside the repo.** If it is stale, building panels
  and freeholders are stale with it, silently.
- **`strategy` write is universal today**, so the gate is nominal until roles change.
